diff --git a/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/AdminController.kt b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/AdminController.kt index 8b34534..5dbca0a 100644 --- a/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/AdminController.kt +++ b/backend/louisvuitton/src/main/kotlin/kr/or/bok/rtgs/louisvuitton/AdminController.kt @@ -112,6 +112,70 @@ class AdminController( } } + // ---------- 6) 통계 현황 보고서 — 전체 거래 집계(성능·대사·기관별) ---------- + @GetMapping("/admin/report") + fun report(): Map { + 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") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3880daa..193c709 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import AdminPanel from './AdminPanel' import Portal from './Portal' +import Report from './Report' type Account = { code: string; name: string; balance: number } type Inquiry = Record @@ -65,7 +66,7 @@ export default function App() { const [notifs, setNotifs] = useState[]>([]) const [msgTab, setMsgTab] = useState<'008' | '002'>('008') - const [view, setView] = useState<'portal' | 'ops' | 'admin'>('portal') + const [view, setView] = useState<'portal' | 'ops' | 'admin' | 'report'>('portal') const loadAccounts = async () => { setErr(null) @@ -172,10 +173,13 @@ export default function App() { style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'ops' ? '#0b63c4' : '#fff', color: view === 'ops' ? '#fff' : '#333' }}>운영 콘솔 + {view === 'portal' && } {view === 'admin' && } + {view === 'report' && } {view === 'ops' && (<> {/* 자금이체 신청 */} diff --git a/frontend/src/Report.tsx b/frontend/src/Report.tsx new file mode 100644 index 0000000..490b9db --- /dev/null +++ b/frontend/src/Report.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react' +import { useAuth, jget } from './auth' +import LoginGate from './AdminLogin' +import { won, box, cell, th } from './util' + +/** 통계 현황 보고서 — 전체 거래 집계(요약·대사·금액·처리시간·기관별). ADMIN 전용. */ +export default function Report() { + return +} + +const n = (v: unknown) => Number(v ?? 0) +const num = (v: unknown) => n(v).toLocaleString('ko-KR') +const ms = (v: unknown) => `${num(v)} ms` + +function ReportInner() { + const { token } = useAuth() + const [rep, setRep] = useState(null) + const [err, setErr] = useState(null) + const [at, setAt] = useState(0) + + const load = async () => { + setErr(null) + try { setRep(await jget('/admin/report')); setAt(Date.now()) } + catch (e: any) { setErr('보고서 로드 실패: ' + e.message) } + } + useEffect(() => { if (token) load() }, [token]) + + if (err) return

{err}

+ if (!rep) return

보고서 생성 중…

+ + const t = rep.totals ?? {} + const rec = rep.reconciliation ?? {} + const amt = rep.amount ?? {} + const lat = rep.latency ?? {} + const cnt = (st: string) => n((t.byStatus ?? []).find((b: any) => b.status === st)?.cnt) + const total = n(t.transfers) + const accc = cnt('ACCC'), rjct = cnt('RJCT') + const pending = Math.max(0, total - accc - rjct) + const rate = total > 0 ? Math.round((accc / total) * 1000) / 10 : 0 + + const card: React.CSSProperties = { flex: '1 1 150px', border: '1px solid #e2e2e2', borderRadius: 8, padding: '12px 16px', background: '#fff' } + const big: React.CSSProperties = { fontSize: 24, fontWeight: 700, fontFamily: 'monospace' } + const lbl: React.CSSProperties = { color: '#777', fontSize: 13, marginBottom: 4 } + + return ( +
+
+

📈 통계 현황 보고서

+ + {at > 0 && 생성 {new Date(at).toLocaleString('ko-KR')}} +
+ + {/* 요약 카드 */} +
+
총 거래
{num(total)}
+
완결 (ACCC)
{num(accc)}
+
반려 (RJCT)
{num(rjct)}
+
미결(대기)
{num(pending)}
+
완결률
{rate}%
+
+ + {/* 대사(정합성) */} +
+

🔗 대사(정합성)

+
+
원장(transfer) {num(rec.transferCount)}
+
선저널(journal) {num(rec.journalCount)}
+
조회사본(view) {num(rec.viewCount)}
+
{rec.consistent ? ✓ 계층 일치 : ⚠ 불일치}
+
총액 {won(n(rec.totalBalance))} {rec.balancePreserved ? ✓ 보존 : ⚠ 확인}
+
+
+ + {/* 금액 통계 */} +
+

💰 금액 통계 (완결 ACCC {num(amt.count)}건 기준)

+
+
합계 {won(n(amt.sum))}
+
평균 {won(n(amt.avg))}
+
최소 {won(n(amt.min))}
+
최대 {won(n(amt.max))}
+
+
+ + {/* 처리시간(지연) */} +
+

⏱️ 처리시간 (지연) (완결 {num(lat.count)}건 · 단일 파티션 저널 = 성능 급소)

+
+
접수→완결 평균 {ms(lat.e2e_avg)}
+
p50 {ms(lat.e2e_p50)}
+
p95 {ms(lat.e2e_p95)}
+
최대 {ms(lat.e2e_max)}
+
+ + + + {[ + ['접수 → 순번 (Sequencer)', lat.recv_to_actc], + ['순번 → 기록 (Dior)', lat.actc_to_pdng], + ['기록 → 정산 (Hermes)', lat.pdng_to_acsp], + ['정산 → 완결 (Prada)', lat.acsp_to_accc], + ].map(([k, v]) => ( + + ))} + +
단계평균 지연
{k}{ms(v)}
+
+ + {/* 기관별 */} +
+

🏛 기관별 송·수신 (완결 ACCC 기준)

+
+ + + + + + + + + {(rep.byInstitution ?? []).map((r: any) => ( + + + + + + + + + + + ))} + +
코드기관현재잔액송신 건송신 금액수신 건수신 금액순증감
{r.code}{r.name}{won(n(r.balance))}{num(r.sent_cnt)}{won(n(r.sent_amt))}{num(r.recv_cnt)}{won(n(r.recv_amt))}= 0 ? '#1a7f37' : '#c00' }}>{won(n(r.net))}
+
+
+
+ ) +}