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:
@@ -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()
|
private fun lim(n: Int) = n.coerceIn(1, 200).toString()
|
||||||
|
|
||||||
@GetMapping("/admin/ledger/transfers")
|
@GetMapping("/admin/ledger/transfers")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import AdminPanel from './AdminPanel'
|
import AdminPanel from './AdminPanel'
|
||||||
import Portal from './Portal'
|
import Portal from './Portal'
|
||||||
|
import Report from './Report'
|
||||||
|
|
||||||
type Account = { code: string; name: string; balance: number }
|
type Account = { code: string; name: string; balance: number }
|
||||||
type Inquiry = Record<string, unknown>
|
type Inquiry = Record<string, unknown>
|
||||||
@@ -65,7 +66,7 @@ export default function App() {
|
|||||||
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
|
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
|
||||||
const [msgTab, setMsgTab] = useState<'008' | '002'>('008')
|
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 () => {
|
const loadAccounts = async () => {
|
||||||
setErr(null)
|
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>
|
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')}
|
<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>
|
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>
|
</div>
|
||||||
|
|
||||||
{view === 'portal' && <Portal onNavigate={setView} />}
|
{view === 'portal' && <Portal onNavigate={setView} />}
|
||||||
{view === 'admin' && <AdminPanel />}
|
{view === 'admin' && <AdminPanel />}
|
||||||
|
{view === 'report' && <Report />}
|
||||||
|
|
||||||
{view === 'ops' && (<>
|
{view === 'ops' && (<>
|
||||||
{/* 자금이체 신청 */}
|
{/* 자금이체 신청 */}
|
||||||
|
|||||||
139
frontend/src/Report.tsx
Normal file
139
frontend/src/Report.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user