실운영 데이터 + 운영 대시보드/조회 화면 (실사용 시스템화)

- db/schema.d/90-seed.sql: 가맹점 40(실제풍 상호·업종별 MDR 18~30bp·한도),
  2026-04-20~07-19 3개월 매입 8,190건(주중/주말 거래량·금액구간 분포·채널/발급사 비중),
  승인 1:1, 정산 7,560, 원장 15,120(RECON+SETTLE 이중기표), 가맹점 일집계, 시퀀스 정렬
- 게이트웨이 /api/query: libpq 읽기전용 조회(SELECT만, 단일문, 500행 캡) — MIS/리포팅 경로
- UI 조회/현황 그룹 7화면: 운영 대시보드(카드 7종+최근거래+가맹점TOP7+7영업일 실적),
  매입/정산 목록, 가맹점 현황, 일자별 실적, 원장, 채널·발급사 분석 — 기본화면=대시보드
- 검증: 시드 로딩 psql 확인, /api/query 동작, 헤드리스 스크린샷으로 대시보드 실데이터 렌더 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyeongwoo-choi 2026-07-20 03:24:57 +00:00
parent c5692410f6
commit 1fd60a1e49
4 changed files with 305 additions and 2 deletions

142
db/schema.d/90-seed.sql Normal file
View file

@ -0,0 +1,142 @@
-- ===========================================================================
-- 90-seed.sql : 실사용 이력 시드 데이터 (운영 3개월치)
-- 가맹점 40개 + 2026-04-20 ~ 2026-07-19 매입/승인/원장/정산/집계 이력.
-- 실제 운영계처럼: 주중>주말 거래량, 금액 구간 분포, MDR 요율별 수수료,
-- 최근 거래는 A/M/H 상태 잔존, 과거 거래는 S(정산완료).
-- ===========================================================================
SELECT setseed(0.42);
-- ---------------------------------------------------------------------------
-- 1. 가맹점 마스터 (실제풍 상호 40개, 업종·요율·한도 다양)
-- ---------------------------------------------------------------------------
INSERT INTO merchant (merchant_id, name, mdr_bps, daily_limit, status, created_at) VALUES
('M0001','클라로 커피 강남점', 25, 100000000,'ACTIVE', now()-interval '2 years'),
('M0002','포지 마트 역삼점', 22, 200000000,'ACTIVE', now()-interval '2 years'),
('M0003','클라로 면세점', 30, 500000000,'ACTIVE', now()-interval '2 years'),
('M0004','한강 곱창 본점', 28, 50000000,'ACTIVE', now()-interval '22 months'),
('M0005','서울정형외과의원', 18, 300000000,'ACTIVE', now()-interval '21 months'),
('M0006','블루문 헤어살롱', 27, 30000000,'ACTIVE', now()-interval '20 months'),
('M0007','대박 치킨 서초점', 28, 40000000,'ACTIVE', now()-interval '19 months'),
('M0008','그린약국', 18, 100000000,'ACTIVE', now()-interval '19 months'),
('M0009','썬샤인 주유소', 15, 400000000,'ACTIVE', now()-interval '18 months'),
('M0010','한빛서점', 24, 20000000,'ACTIVE', now()-interval '18 months'),
('M0011','미소분식 교대점', 29, 15000000,'ACTIVE', now()-interval '17 months'),
('M0012','로얄가구백화점', 26, 300000000,'ACTIVE', now()-interval '16 months'),
('M0013','스마일 안경원', 25, 50000000,'ACTIVE', now()-interval '16 months'),
('M0014','제주흑돼지 전문점', 28, 80000000,'ACTIVE', now()-interval '15 months'),
('M0015','네오 PC방', 27, 20000000,'ACTIVE', now()-interval '14 months'),
('M0016','골든짐 휘트니스', 26, 100000000,'ACTIVE', now()-interval '14 months'),
('M0017','파리의아침 베이커리', 26, 60000000,'ACTIVE', now()-interval '13 months'),
('M0018','한성전자 대리점', 23, 500000000,'ACTIVE', now()-interval '12 months'),
('M0019','포시즌 꽃집', 27, 10000000,'ACTIVE', now()-interval '12 months'),
('M0020','바다마트 수산', 24, 90000000,'ACTIVE', now()-interval '11 months'),
('M0021','씨티 세탁소', 28, 8000000,'ACTIVE', now()-interval '11 months'),
('M0022','건강한약국 2호점', 18, 120000000,'ACTIVE', now()-interval '10 months'),
('M0023','달빛 노래연습장', 29, 15000000,'ACTIVE', now()-interval '10 months'),
('M0024','우리동물병원', 20, 150000000,'ACTIVE', now()-interval '9 months'),
('M0025','에버그린 골프연습장', 25, 200000000,'ACTIVE', now()-interval '9 months'),
('M0026','정성한식 뷔페', 27, 100000000,'ACTIVE', now()-interval '8 months'),
('M0027','키즈랜드 문구완구', 26, 30000000,'ACTIVE', now()-interval '8 months'),
('M0028','모던 인테리어', 24, 250000000,'ACTIVE', now()-interval '7 months'),
('M0029','신선청과 도매', 21, 180000000,'ACTIVE', now()-interval '7 months'),
('M0030','휘게 캠핑용품', 26, 70000000,'ACTIVE', now()-interval '6 months'),
('M0031','더퀵 오토바이센터', 27, 60000000,'ACTIVE', now()-interval '6 months'),
('M0032','라온 웨딩홀', 23, 800000000,'ACTIVE', now()-interval '5 months'),
('M0033','스카이 여행사', 22, 400000000,'ACTIVE', now()-interval '5 months'),
('M0034','명가 한우촌', 28, 150000000,'ACTIVE', now()-interval '4 months'),
('M0035','코지 게스트하우스', 25, 50000000,'ACTIVE', now()-interval '4 months'),
('M0036','알파 어학원', 20, 200000000,'ACTIVE', now()-interval '3 months'),
('M0037','퓨어 요가스튜디오', 26, 40000000,'ACTIVE', now()-interval '3 months'),
('M0038','테크노 휴대폰 매장', 24, 300000000,'ACTIVE', now()-interval '2 months'),
('M0039','오후의 홍차', 27, 20000000,'HOLD', now()-interval '2 months'),
('M0040','신규상사 (심사중)', 25, 10000000,'PENDING',now()-interval '10 days')
ON CONFLICT (merchant_id) DO UPDATE SET name=EXCLUDED.name, mdr_bps=EXCLUDED.mdr_bps;
-- ---------------------------------------------------------------------------
-- 2. 매입 이력 (2026-04-20 ~ 2026-07-19, 주중 ~110건/일·주말 ~40건/일 ≈ 8,600건)
-- ---------------------------------------------------------------------------
CREATE TEMP TABLE seed_tx AS
WITH days AS (
SELECT d::date AS biz_date,
CASE WHEN extract(isodow FROM d) >= 6 THEN 40 ELSE 110 END AS ntx
FROM generate_series('2026-04-20'::date, '2026-07-19'::date, '1 day') d
),
raw AS (
SELECT row_number() OVER (ORDER BY days.biz_date, gs) + 1000 AS purchase_id,
days.biz_date,
'M' || lpad((1 + floor(random()*39))::int::text, 4, '0') AS merchant_id,
random() AS r_amt, random() AS r_st, random() AS r_ch, random() AS r_is
FROM days CROSS JOIN LATERAL generate_series(1, days.ntx) gs
)
SELECT r.purchase_id, r.biz_date, r.merchant_id,
-- 금액 분포: 소액 50% / 일반 35% / 고액 12% / 초고액 3%, 100원 단위
(CASE WHEN r.r_amt < 0.50 THEN 5000 + floor(random()*45000)
WHEN r.r_amt < 0.85 THEN 50000 + floor(random()*250000)
WHEN r.r_amt < 0.97 THEN 300000+ floor(random()*1200000)
ELSE 1500000 + floor(random()*3500000) END)::bigint / 100 * 100 AS amount,
m.mdr_bps,
-- 상태: 7/15 이전 = 96% S, 3% C, 1% V | 최근 5일 = A/M/H 혼재
CASE WHEN r.biz_date < '2026-07-15' THEN
CASE WHEN r.r_st < 0.96 THEN 'S' WHEN r.r_st < 0.99 THEN 'C' ELSE 'V' END
ELSE
CASE WHEN r.r_st < 0.30 THEN 'A' WHEN r.r_st < 0.70 THEN 'M'
WHEN r.r_st < 0.92 THEN 'S' WHEN r.r_st < 0.97 THEN 'H' ELSE 'C' END
END AS status,
CASE WHEN r.r_ch < 0.62 THEN 'POS' WHEN r.r_ch < 0.85 THEN 'EDC'
WHEN r.r_ch < 0.95 THEN 'EDI' ELSE 'FOREIGN' END AS channel,
CASE WHEN r.r_is < 0.28 THEN 'BC' WHEN r.r_is < 0.46 THEN 'KB'
WHEN r.r_is < 0.62 THEN 'SHINHAN' WHEN r.r_is < 0.74 THEN 'HYUNDAI'
WHEN r.r_is < 0.84 THEN 'SAMSUNG' WHEN r.r_is < 0.92 THEN 'LOTTE'
WHEN r.r_is < 0.97 THEN 'NH' ELSE 'HANA' END AS issuer
FROM raw r JOIN merchant m ON m.merchant_id = r.merchant_id;
INSERT INTO purchase (purchase_id, merchant_id, amount, fee, net, status, biz_date,
channel, issuer, category, created_at, updated_at)
SELECT purchase_id, merchant_id, amount,
amount * mdr_bps / 10000,
amount - amount * mdr_bps / 10000,
status, biz_date, channel, issuer,
CASE channel WHEN 'FOREIGN' THEN 'FX' ELSE 'GEN' END,
biz_date::timestamptz + (interval '9 hours') + (random() * interval '12 hours'),
biz_date::timestamptz + (interval '9 hours') + (random() * interval '13 hours')
FROM seed_tx;
-- 승인 (매입 1:1, 99% 승인)
INSERT INTO approval (approval_id, purchase_id, approved, approved_at)
SELECT purchase_id, purchase_id, random() < 0.99,
biz_date::timestamptz + interval '9 hours' + (random() * interval '11 hours')
FROM seed_tx;
-- 원장: 정산완료(S) 건 = RECON + SETTLE 이중 기표
INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date, created_at)
SELECT row_number() OVER (ORDER BY purchase_id) + 5000,
purchase_id, 'RECON', amount - amount*mdr_bps/10000, biz_date,
biz_date::timestamptz + interval '20 hours'
FROM seed_tx WHERE status = 'S';
INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date, created_at)
SELECT (SELECT count(*) FROM seed_tx WHERE status='S') + row_number() OVER (ORDER BY purchase_id) + 5000,
purchase_id, 'SETTLE', amount - amount*mdr_bps/10000, biz_date,
biz_date::timestamptz + interval '21 hours'
FROM seed_tx WHERE status = 'S';
-- 정산 (S건, T+1 지급)
INSERT INTO settlement (settlement_id, purchase_id, merchant_id, net, biz_date, status, settled_at)
SELECT row_number() OVER (ORDER BY purchase_id) + 3000,
purchase_id, merchant_id, amount - amount*mdr_bps/10000, biz_date, 'SETTLED',
biz_date::timestamptz + interval '1 day 10 hours'
FROM seed_tx WHERE status = 'S';
-- 가맹점 일집계
INSERT INTO merchant_summary (merchant_id, biz_date, txn_count, gross_amount, fee_amount, net_amount)
SELECT merchant_id, biz_date, count(*), sum(amount),
sum(amount*mdr_bps/10000), sum(amount - amount*mdr_bps/10000)
FROM seed_tx GROUP BY merchant_id, biz_date
ON CONFLICT (merchant_id, biz_date) DO NOTHING;
-- 시퀀스 정렬 (라이브 거래가 시드 뒤 번호를 받도록)
SELECT setval('purchase_seq', (SELECT max(purchase_id) FROM purchase));
SELECT setval('approval_seq', (SELECT max(approval_id) FROM approval));
SELECT setval('ledger_seq', (SELECT max(ledger_id) FROM ledger));
SELECT setval('settlement_seq', (SELECT max(settlement_id) FROM settlement));
DROP TABLE seed_tx;