From 1fd60a1e49a0f62500f1e4bca27835105f345c0b Mon Sep 17 00:00:00 2001 From: hyeongwoo-choi Date: Mon, 20 Jul 2026 03:24:57 +0000 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=9A=B4=EC=98=81=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20+=20=EC=9A=B4=EC=98=81=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C/=EC=A1=B0=ED=9A=8C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20(=EC=8B=A4=EC=82=AC=EC=9A=A9=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/build.sh | 2 +- app/src/clients/acq_httpgw.c | 65 ++++++++++++++++ app/ui/index.html | 98 +++++++++++++++++++++++- db/schema.d/90-seed.sql | 142 +++++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 db/schema.d/90-seed.sql diff --git a/app/build.sh b/app/build.sh index 50a1096..89d533b 100644 --- a/app/build.sh +++ b/app/build.sh @@ -153,7 +153,7 @@ NCLI=0 for c in "$SRC"/clients/*.c; do name="$(basename "${c%.c}")" log "client $name" - buildclient -o "$BIN/$name" -f "$c" + buildclient -o "$BIN/$name" -f "$c" -a "-L$PGLIB -lpq" NCLI=$((NCLI+1)) done diff --git a/app/src/clients/acq_httpgw.c b/app/src/clients/acq_httpgw.c index 3c72d6b..5a88bca 100644 --- a/app/src/clients/acq_httpgw.c +++ b/app/src/clients/acq_httpgw.c @@ -25,6 +25,8 @@ #include #include #include +#include +#include #define GW_PORT 8090 #define REQ_MAX 16384 @@ -186,6 +188,63 @@ static void handle_api_call(int fd, char *query) tpfree((char *)b); } + +/* ---- /api/query (읽기전용 조회 — 화면 그리드용, MIS/리포팅 경로) ------------- */ +static PGconn *g_pg = NULL; + +static void handle_api_query(int fd, char *query) +{ + static char out[524288]; + static char sql[8192]; + char esc[4096]; + char *tok, *save = NULL; + sql[0] = '\0'; + + for (tok = strtok_r(query, "&", &save); tok; tok = strtok_r(NULL, "&", &save)) { + char *eq = strchr(tok, '='); + if (!eq) continue; + *eq = '\0'; + if (0 == strcmp(tok, "q")) { + strncpy(sql, eq + 1, sizeof(sql) - 1); sql[sizeof(sql)-1] = '\0'; + url_decode(sql); + } + } + char *p = sql; + while (*p == ' ' || *p == '\n' || *p == '\t') p++; + if (g_pg && PQstatus(g_pg) != CONNECTION_OK) PQreset(g_pg); + if (!g_pg || PQstatus(g_pg) != CONNECTION_OK || + strncasecmp(p, "select", 6) != 0 || strchr(p, ';')) { + const char *e = "{\"ok\":false,\"error\":\"select only / no db\"}"; + http_send(fd, 400, "application/json", e, (long)strlen(e)); return; + } + PGresult *res = PQexec(g_pg, p); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + json_escape(PQerrorMessage(g_pg), esc, sizeof(esc)); + long n = snprintf(out, sizeof(out), "{\"ok\":false,\"error\":\"%s\"}", esc); + http_send(fd, 200, "application/json", out, n); PQclear(res); return; + } + int nc = PQnfields(res), nr = PQntuples(res); + if (nr > 500) nr = 500; + long o = snprintf(out, sizeof(out), "{\"ok\":true,\"cols\":["); + int c, r; + for (c = 0; c < nc; c++) { + json_escape(PQfname(res, c), esc, sizeof(esc)); + o += snprintf(out + o, sizeof(out) - o, "%s\"%s\"", c ? "," : "", esc); + } + o += snprintf(out + o, sizeof(out) - o, "],\"rows\":["); + for (r = 0; r < nr && (size_t)o < sizeof(out) - 8192; r++) { + o += snprintf(out + o, sizeof(out) - o, "%s[", r ? "," : ""); + for (c = 0; c < nc; c++) { + json_escape(PQgetvalue(res, r, c), esc, sizeof(esc)); + o += snprintf(out + o, sizeof(out) - o, "%s\"%s\"", c ? "," : "", esc); + } + o += snprintf(out + o, sizeof(out) - o, "]"); + } + o += snprintf(out + o, sizeof(out) - o, "]}"); + http_send(fd, 200, "application/json", out, o); + PQclear(res); +} + /* ---- 정적 파일 ------------------------------------------------------------- */ static void handle_static(int fd, const char *path) { @@ -217,6 +276,10 @@ int main(void) if (tpinit(NULL) < 0) { fprintf(stderr, "tpinit FAIL: %s\n", tpstrerror(tperrno)); return 1; } if (tpopen() < 0) { fprintf(stderr, "tpopen FAIL: %s\n", tpstrerror(tperrno)); return 1; } + g_pg = PQconnectdb("host=db dbname=acq user=acq password=acq"); + if (PQstatus(g_pg) != CONNECTION_OK) + fprintf(stderr, "경고: 조회DB 연결 실패(%s) — /api/query 비활성\n", PQerrorMessage(g_pg)); + int srv = socket(AF_INET, SOCK_STREAM, 0); int on = 1; setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); @@ -246,6 +309,8 @@ int main(void) if (0 == strcmp(path, "/api/call") && query) handle_api_call(fd, query); + else if (0 == strcmp(path, "/api/query") && query) + handle_api_query(fd, query); else handle_static(fd, path); close(fd); diff --git a/app/ui/index.html b/app/ui/index.html index ed42e62..be71684 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -63,6 +63,10 @@ #statusbar { height: 24px; background: #d5dce3; border-top: 1px solid #a9b4bf; display: flex; align-items: center; padding: 0 10px; font-size: 11px; color: #345; flex-shrink: 0; } #statusbar .dot { width: 8px; height: 8px; border-radius: 50%; background: #23a54a; margin-right: 6px; } + .cards { display: flex; gap: 10px; flex-wrap: wrap; } + .card { flex: 1; min-width: 128px; border: 1px solid #c6d0da; background: #f7fafd; padding: 8px 10px; } + .card .cl { font-size: 11px; color: #667; } + .card .cv { font-size: 16px; font-weight: bold; color: #1d4269; margin-top: 4px; text-align: right; font-family: Consolas, monospace; } @@ -4461,14 +4465,106 @@ function addGroup(title, items, open) { it.dataset.k = (cd + ' ' + s.svc + ' ' + (nm||'') + ' ' + (s.desc||'')).toLowerCase(); it.onclick = (e) => { e.stopPropagation(); openScreen(s, it, nm, note); }; wrap.appendChild(it); - if (s.svc === 'ACQUIRE' && open) setTimeout(()=>openScreen(s, it, nm, note), 0); }); } + +/* ───────── 조회/현황 화면 (운영 데이터 그리드 — /api/query) ───────── */ +let CURQ = null; +const fmtN = v => (v===null||v===undefined||v==='') ? '' : (/^-?\d+$/.test(String(v)) ? Number(v).toLocaleString('ko-KR') : v); +async function runQuery(sql){ + try { return await (await fetch('api/query?q='+encodeURIComponent(sql))).json(); } + catch(e){ return {ok:false, error:String(e)}; } +} +const QSCREENS = [ + {cd:'HM0001', nm:'운영 대시보드', dash:true}, + {cd:'QR0101', nm:'매입 목록 조회', filters:[['from','시작일','2026-07-01'],['to','종료일','2026-07-19'],['merch','가맹점번호(선택)','']], + sql:f=>`select p.purchase_id 매입번호, p.biz_date 영업일, p.merchant_id 가맹점, m.name 상호, p.amount 매입금액, p.fee 수수료, p.net 정산액, p.status 상태, p.channel 채널, p.issuer 발급사 from purchase p join merchant m on m.merchant_id=p.merchant_id where p.biz_date between '${f.from}' and '${f.to}' ${f.merch?`and p.merchant_id='${f.merch}'`:''} order by p.purchase_id desc limit 200`}, + {cd:'QR0201', nm:'정산 목록 조회', filters:[['from','시작일','2026-07-01'],['to','종료일','2026-07-19']], + sql:f=>`select s.settlement_id 정산번호, s.biz_date 영업일, s.merchant_id 가맹점, m.name 상호, s.net 지급액, s.status 상태, to_char(s.settled_at,'MM-DD HH24:MI') 지급시각 from settlement s join merchant m on m.merchant_id=s.merchant_id where s.biz_date between '${f.from}' and '${f.to}' order by s.settlement_id desc limit 200`}, + {cd:'QR0301', nm:'가맹점 현황', filters:[], + sql:f=>`select m.merchant_id 가맹점번호, m.name 상호, m.mdr_bps "MDR(bp)", m.daily_limit 일한도, m.status 상태, coalesce(t.cnt,0) 누적건수, coalesce(t.amt,0) 누적매입액 from merchant m left join (select merchant_id, count(*) cnt, sum(amount) amt from purchase group by merchant_id) t on t.merchant_id=m.merchant_id order by m.merchant_id limit 200`}, + {cd:'QR0401', nm:'일자별 실적 집계', filters:[['from','시작일','2026-06-20'],['to','종료일','2026-07-19']], + sql:f=>`select biz_date 영업일, count(*) 건수, sum(amount) 매입금액, sum(fee) 수수료, sum(net) 정산액, count(*) filter (where status='S') 정산완료, count(*) filter (where status in ('A','M')) 진행중 from purchase where biz_date between '${f.from}' and '${f.to}' group by biz_date order by biz_date desc limit 200`}, + {cd:'QR0501', nm:'원장 조회', filters:[['from','시작일','2026-07-10'],['to','종료일','2026-07-19']], + sql:f=>`select l.ledger_id 전표번호, l.biz_date 영업일, l.entry_type 유형, l.purchase_id 매입번호, l.amount 금액 from ledger l where l.biz_date between '${f.from}' and '${f.to}' order by l.ledger_id desc limit 200`}, + {cd:'QR0601', nm:'채널·발급사 분석', filters:[['from','시작일','2026-07-01'],['to','종료일','2026-07-19']], + sql:f=>`select channel 채널, issuer 발급사, count(*) 건수, sum(amount) 매입금액, round(avg(amount)) 평균금액 from purchase where biz_date between '${f.from}' and '${f.to}' group by channel, issuer order by 4 desc limit 200`}, +]; +function gridHtml(j){ + if (!j.rows.length) return '데이터 없음'; + return '' + j.cols.map(c=>``).join('') + '' + + j.rows.map(r=>''+r.map(v=>``).join('')+'').join('') + '
${c}
${fmtN(v)}
'; +} +async function openQuery(q, itEl){ + CUR = null; CURQ = q; + document.querySelectorAll('#menu .itm').forEach(e=>e.classList.remove('on')); + if (itEl) itEl.classList.add('on'); + document.getElementById('tabname').textContent = q.nm; + document.getElementById('scrno').textContent = q.cd; + if (q.dash) return renderDash(); + const f = (q.filters||[]).map(([id,label,dv]) => + `${label}`).join(''); + work.innerHTML = ` +

${q.cd} · ${q.nm}

+ ${f||''}
조건전체 조회
+
+
+

조회 결과

조회 중 ...
`; + runQ(); +} +async function runQ(){ + const q = CURQ, f = {}; + (q.filters||[]).forEach(([id]) => { f[id] = (document.getElementById('qf_'+id)||{}).value || ''; }); + setStatus('조회 중 ...'); + const j = await runQuery(q.sql(f)); + const g = document.getElementById('qgrid'); + if (!j.ok) { g.innerHTML = `${j.error}`; setStatus('조회 실패', true); return; } + g.innerHTML = gridHtml(j); + setStatus(`조회 완료 — ${j.rows.length}건`); +} +async function renderDash(){ + work.innerHTML = ` +

HM0001 · 운영 대시보드 — 글로벌 매입 현황

로딩...
+

최근 매입 거래

로딩...
+
+

당월 가맹점 TOP 7

로딩...
+

최근 7영업일 실적

로딩...
+
`; + const k = await runQuery(`select to_char((select max(biz_date) from purchase),'YYYY-MM-DD') 기준일,(select count(*) from purchase where biz_date=(select max(biz_date) from purchase)) 오늘건수,(select coalesce(sum(amount),0) from purchase where biz_date=(select max(biz_date) from purchase)) 오늘매입액,(select count(*) from purchase where date_trunc('month',biz_date)=date_trunc('month',(select max(biz_date) from purchase))) 당월건수,(select coalesce(sum(amount),0) from purchase where date_trunc('month',biz_date)=date_trunc('month',(select max(biz_date) from purchase))) 당월매입액,(select count(*) from merchant where status='ACTIVE') 활성가맹점,(select count(*) from purchase where status in ('A','M','H')) 미정산건`); + if (k.ok) { + document.getElementById('cards').innerHTML = + k.cols.map((n,i)=>`
${n}
${fmtN(k.rows[0][i])}
`).join(''); + } else document.getElementById('cards').innerHTML = `${k.error}`; + const r = await runQuery(`select p.purchase_id 매입번호, p.biz_date 영업일, m.name 가맹점, p.amount 금액, p.fee 수수료, p.status 상태, p.channel 채널, p.issuer 발급사 from purchase p join merchant m on m.merchant_id=p.merchant_id order by p.purchase_id desc limit 12`); + if (r.ok) document.getElementById('d_recent').innerHTML = gridHtml(r); + const t = await runQuery(`select m.name 가맹점, count(*) 건수, sum(p.amount) 매입금액 from purchase p join merchant m on m.merchant_id=p.merchant_id where date_trunc('month',p.biz_date)=date_trunc('month',(select max(biz_date) from purchase)) group by m.name order by 3 desc limit 7`); + if (t.ok) document.getElementById('d_top').innerHTML = gridHtml(t); + const d = await runQuery(`select biz_date 영업일, count(*) 건수, sum(amount) 매입금액, sum(net) 정산액 from purchase group by biz_date order by biz_date desc limit 7`); + if (d.ok) document.getElementById('d_daily').innerHTML = gridHtml(d); + setStatus('대시보드 로드 완료 — 운영 데이터 기준'); +} +function addQueryGroup(){ + const gd = document.createElement('div'); gd.className = 'grp open'; + gd.innerHTML = `조회/현황 (운영)${QSCREENS.length}`; + const wrap = document.createElement('div'); wrap.className = 'itms'; + gd.onclick = () => gd.classList.toggle('open'); + menuEl.appendChild(gd); menuEl.appendChild(wrap); + QSCREENS.forEach(q => { + const it = document.createElement('div'); it.className = 'itm'; + it.innerHTML = `${q.cd}${q.nm}`; + it.dataset.k = (q.cd+' '+q.nm).toLowerCase(); + it.onclick = e => { e.stopPropagation(); openQuery(q, it); }; + wrap.appendChild(it); + }); +} +addQueryGroup(); + addGroup('★ 주요 업무', FAVS.filter(f=>bySvc[f.svc]).map(f=>({s:bySvc[f.svc], nm:f.nm, note:f.note})), true); Object.keys(byMod).sort().forEach(m => { addGroup(`${byMod[m][0].modnm} (${m}) 업무`, byMod[m].map(s=>({s})), false); }); +setTimeout(()=>openQuery(QSCREENS[0], document.querySelector('#menu .itms .itm')), 0); /* 검색 */ document.getElementById('q').addEventListener('input', e => { diff --git a/db/schema.d/90-seed.sql b/db/schema.d/90-seed.sql new file mode 100644 index 0000000..4b9eb1a --- /dev/null +++ b/db/schema.d/90-seed.sql @@ -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;