feat(report): 통계 현황 보고서 화면(📈 보고서 탭)

- LouisVuitton /admin/report: 전체 거래 집계(요약·대사·금액·처리시간·기관별)
- 처리시간은 stage timestamp 기반 접수→완결 avg/p50/p95/max + 단계별 평균(percentile_cont)
  · 인과 정합(접수≤순번) 행만 집계해 BMI 재사용 시계역전 건 제외
- 대사: transfer=journal_log=settlement_view 건수 일치 + 총액 보존 검증
- 프론트: Report.tsx 신규 + App.tsx 상단 '📈 보고서' 탭(ADMIN 로그인 게이트)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rtgs
2026-07-16 14:13:55 +09:00
parent c96664e52d
commit 61ced29510
3 changed files with 208 additions and 1 deletions

View File

@@ -112,6 +112,70 @@ class AdminController(
}
}
// ---------- 6) 통계 현황 보고서 — 전체 거래 집계(성능·대사·기관별) ----------
@GetMapping("/admin/report")
fun report(): Map<String, Any?> {
val byStatus = jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status")
val transfers = jdbc.queryForObject("SELECT count(*) FROM transfer", Long::class.java) ?: 0
val accounts = jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java) ?: 0
val maxSeq = jdbc.queryForObject("SELECT COALESCE(max(global_seq),0) FROM transfer", Long::class.java) ?: 0
val totalBalance = jdbc.queryForObject("SELECT COALESCE(sum(balance),0) FROM account", Long::class.java) ?: 0
// 대사(정합성): 원장=선저널=조회사본 건수 일치 + 총액 보존
val journalCount = jdbc.queryForObject("SELECT count(*) FROM journal_log", Long::class.java) ?: 0
val viewCount = jdbc.queryForObject("SELECT count(*) FROM settlement_view", Long::class.java) ?: 0
val initialTotal = accounts * 1_000_000_000L
// 금액 통계(ACCC 완결 대상)
val amt = jdbc.queryForMap(
"SELECT count(*) AS count, COALESCE(sum(amount),0) AS sum, COALESCE(round(avg(amount)),0) AS avg, " +
"COALESCE(min(amount),0) AS min, COALESCE(max(amount),0) AS max FROM transfer WHERE status='ACCC'")
// 처리시간(지연, ms) — ACCC & 전 단계 시각 존재 건. created_at=순번, updated_at=완결, received_at=접수(raw_message)
val lat = jdbc.queryForMap(
"""
SELECT count(*) AS count,
COALESCE(round(avg(t.updated_at - r.received_at)),0) AS e2e_avg,
COALESCE(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY t.updated_at - r.received_at)),0) AS e2e_p50,
COALESCE(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY t.updated_at - r.received_at)),0) AS e2e_p95,
COALESCE(max(t.updated_at - r.received_at),0) AS e2e_max,
COALESCE(round(avg(t.created_at - r.received_at)),0) AS recv_to_actc,
COALESCE(round(avg(t.pdng_at - t.created_at)),0) AS actc_to_pdng,
COALESCE(round(avg(t.acsp_at - t.pdng_at)),0) AS pdng_to_acsp,
COALESCE(round(avg(t.updated_at - t.acsp_at)),0) AS acsp_to_accc
FROM transfer t JOIN raw_message r ON r.bmi = t.bmi
WHERE t.status='ACCC' AND r.received_at > 0 AND t.pdng_at IS NOT NULL AND t.acsp_at IS NOT NULL
AND t.created_at >= r.received_at -- 인과 정합(접수≤순번) 행만: BMI 재사용 등 시계 역전 건 제외
""".trimIndent())
// 기관별 송·수신 집계(ACCC 기준) + 현재 잔액
val byInstitution = jdbc.queryForList(
"""
SELECT a.code, a.name, a.balance,
COALESCE(s.cnt,0) AS sent_cnt, COALESCE(s.amt,0) AS sent_amt,
COALESCE(v.cnt,0) AS recv_cnt, COALESCE(v.amt,0) AS recv_amt,
(COALESCE(v.amt,0) - COALESCE(s.amt,0)) AS net
FROM account a
LEFT JOIN (SELECT sender_code c, count(*) cnt, sum(amount) amt FROM transfer WHERE status='ACCC' GROUP BY sender_code) s ON s.c = a.code
LEFT JOIN (SELECT receiver_code c, count(*) cnt, sum(amount) amt FROM transfer WHERE status='ACCC' GROUP BY receiver_code) v ON v.c = a.code
ORDER BY a.code
""".trimIndent())
return mapOf(
"generatedAt" to System.currentTimeMillis(),
"totals" to mapOf("transfers" to transfers, "accounts" to accounts, "maxSeq" to maxSeq, "byStatus" to byStatus),
"reconciliation" to mapOf(
"transferCount" to transfers, "journalCount" to journalCount, "viewCount" to viewCount,
"consistent" to (transfers == journalCount && transfers == viewCount),
"totalBalance" to totalBalance, "initialTotal" to initialTotal,
"balancePreserved" to (totalBalance == initialTotal),
),
"amount" to amt,
"latency" to lat,
"byInstitution" to byInstitution,
)
}
private fun lim(n: Int) = n.coerceIn(1, 200).toString()
@GetMapping("/admin/ledger/transfers")