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")

View File

@@ -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<string, unknown>
@@ -65,7 +66,7 @@ export default function App() {
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
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' }}> </button>
<button onClick={() => setView('admin')}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'admin' ? '#0b63c4' : '#fff', color: view === 'admin' ? '#fff' : '#333' }}>🛠 (Louis Vuitton)</button>
<button onClick={() => setView('report')}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'report' ? '#0b63c4' : '#fff', color: view === 'report' ? '#fff' : '#333' }}>📈 </button>
</div>
{view === 'portal' && <Portal onNavigate={setView} />}
{view === 'admin' && <AdminPanel />}
{view === 'report' && <Report />}
{view === 'ops' && (<>
{/* 자금이체 신청 */}

139
frontend/src/Report.tsx Normal file
View File

@@ -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 <LoginGate><ReportInner /></LoginGate>
}
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<any>(null)
const [err, setErr] = useState<string | null>(null)
const [at, setAt] = useState<number>(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 <p style={{ color: '#c00', marginTop: 16 }}>{err}</p>
if (!rep) return <p style={{ color: '#888', marginTop: 16 }}> </p>
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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
<h2 style={{ margin: 0 }}>📈 </h2>
<button onClick={load} style={{ padding: '6px 14px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: '#fff' }}></button>
{at > 0 && <span style={{ color: '#999', fontSize: 12 }}> {new Date(at).toLocaleString('ko-KR')}</span>}
</div>
{/* 요약 카드 */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 16 }}>
<div style={card}><div style={lbl}> </div><div style={big}>{num(total)}</div></div>
<div style={card}><div style={lbl}> (ACCC)</div><div style={{ ...big, color: '#1a7f37' }}>{num(accc)}</div></div>
<div style={card}><div style={lbl}> (RJCT)</div><div style={{ ...big, color: '#c00' }}>{num(rjct)}</div></div>
<div style={card}><div style={lbl}>()</div><div style={{ ...big, color: '#8a6d00' }}>{num(pending)}</div></div>
<div style={card}><div style={lbl}></div><div style={big}>{rate}%</div></div>
</div>
{/* 대사(정합성) */}
<section style={{ ...box, background: '#f7fbff' }}>
<h3 style={{ marginTop: 0 }}>🔗 ()</h3>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
<div>(transfer) <b style={{ fontFamily: 'monospace' }}>{num(rec.transferCount)}</b></div>
<div>(journal) <b style={{ fontFamily: 'monospace' }}>{num(rec.journalCount)}</b></div>
<div>(view) <b style={{ fontFamily: 'monospace' }}>{num(rec.viewCount)}</b></div>
<div>{rec.consistent ? <span style={{ color: '#1a7f37', fontWeight: 600 }}> </span> : <span style={{ color: '#c00', fontWeight: 600 }}> </span>}</div>
<div style={{ marginLeft: 'auto' }}> <b style={{ fontFamily: 'monospace' }}>{won(n(rec.totalBalance))}</b> {rec.balancePreserved ? <span style={{ color: '#1a7f37' }}> </span> : <span style={{ color: '#c00' }}> </span>}</div>
</div>
</section>
{/* 금액 통계 */}
<section style={box}>
<h3 style={{ marginTop: 0 }}>💰 <span style={{ fontSize: 12, color: '#888' }}>( ACCC {num(amt.count)} )</span></h3>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<div> <b style={{ fontFamily: 'monospace' }}>{won(n(amt.sum))}</b></div>
<div> <b style={{ fontFamily: 'monospace' }}>{won(n(amt.avg))}</b></div>
<div> <b style={{ fontFamily: 'monospace' }}>{won(n(amt.min))}</b></div>
<div> <b style={{ fontFamily: 'monospace' }}>{won(n(amt.max))}</b></div>
</div>
</section>
{/* 처리시간(지연) */}
<section style={box}>
<h3 style={{ marginTop: 0 }}> () <span style={{ fontSize: 12, color: '#888' }}>( {num(lat.count)} · = )</span></h3>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginBottom: 12 }}>
<div> <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_avg)}</b></div>
<div>p50 <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_p50)}</b></div>
<div>p95 <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_p95)}</b></div>
<div> <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_max)}</b></div>
</div>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr><th style={th}></th><th style={{ ...th, textAlign: 'right' }}> </th></tr></thead>
<tbody>
{[
['접수 → 순번 (Sequencer)', lat.recv_to_actc],
['순번 → 기록 (Dior)', lat.actc_to_pdng],
['기록 → 정산 (Hermes)', lat.pdng_to_acsp],
['정산 → 완결 (Prada)', lat.acsp_to_accc],
].map(([k, v]) => (
<tr key={k as string}><td style={cell}>{k}</td><td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{ms(v)}</td></tr>
))}
</tbody>
</table>
</section>
{/* 기관별 */}
<section style={box}>
<h3 style={{ marginTop: 0 }}>🏛 · <span style={{ fontSize: 12, color: '#888' }}>( ACCC )</span></h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>
<th style={th}></th><th style={th}></th><th style={{ ...th, textAlign: 'right' }}></th>
<th style={{ ...th, textAlign: 'right' }}> </th><th style={{ ...th, textAlign: 'right' }}> </th>
<th style={{ ...th, textAlign: 'right' }}> </th><th style={{ ...th, textAlign: 'right' }}> </th>
<th style={{ ...th, textAlign: 'right' }}></th>
</tr></thead>
<tbody>
{(rep.byInstitution ?? []).map((r: any) => (
<tr key={r.code}>
<td style={{ ...cell, fontFamily: 'monospace' }}>{r.code}</td>
<td style={cell}>{r.name}</td>
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(n(r.balance))}</td>
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{num(r.sent_cnt)}</td>
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(n(r.sent_amt))}</td>
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{num(r.recv_cnt)}</td>
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(n(r.recv_amt))}</td>
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace', color: n(r.net) >= 0 ? '#1a7f37' : '#c00' }}>{won(n(r.net))}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}