실운영 데이터 + 운영 대시보드/조회 화면 (실사용 시스템화)
- 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:
parent
c5692410f6
commit
1fd60a1e49
4 changed files with 305 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
#include <ubf.h>
|
||||
#include <userlog.h>
|
||||
#include <acq.fd.h>
|
||||
#include <strings.h>
|
||||
#include <libpq-fe.h>
|
||||
|
||||
#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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -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 '<span style="color:#889">데이터 없음</span>';
|
||||
return '<table class="grd"><tr>' + j.cols.map(c=>`<th>${c}</th>`).join('') + '</tr>' +
|
||||
j.rows.map(r=>'<tr>'+r.map(v=>`<td class="${/^-?\d+$/.test(v)?'num':''}">${fmtN(v)}</td>`).join('')+'</tr>').join('') + '</table>';
|
||||
}
|
||||
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]) =>
|
||||
`<tr><th>${label}</th><td><input type="text" id="qf_${id}" value="${dv}"></td></tr>`).join('');
|
||||
work.innerHTML = `
|
||||
<div class="panel"><h3>${q.cd} · ${q.nm}</h3><div class="bd">
|
||||
<table class="frm">${f||'<tr><th>조건</th><td style="color:#889">전체 조회</td></tr>'}</table>
|
||||
<div class="btnrow"><button class="pri" onclick="runQ()">조회</button></div>
|
||||
</div></div>
|
||||
<div class="panel"><h3>조회 결과</h3><div class="bd" id="qgrid" style="overflow:auto">조회 중 ...</div></div>`;
|
||||
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 = `<span class="err">${j.error}</span>`; setStatus('조회 실패', true); return; }
|
||||
g.innerHTML = gridHtml(j);
|
||||
setStatus(`조회 완료 — ${j.rows.length}건`);
|
||||
}
|
||||
async function renderDash(){
|
||||
work.innerHTML = `
|
||||
<div class="panel"><h3>HM0001 · 운영 대시보드 — 글로벌 매입 현황</h3><div class="bd"><div class="cards" id="cards">로딩...</div></div></div>
|
||||
<div class="panel"><h3>최근 매입 거래</h3><div class="bd" id="d_recent" style="overflow:auto">로딩...</div></div>
|
||||
<div style="display:flex; gap:12px">
|
||||
<div class="panel" style="flex:1"><h3>당월 가맹점 TOP 7</h3><div class="bd" id="d_top">로딩...</div></div>
|
||||
<div class="panel" style="flex:1"><h3>최근 7영업일 실적</h3><div class="bd" id="d_daily">로딩...</div></div>
|
||||
</div>`;
|
||||
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)=>`<div class="card"><div class="cl">${n}</div><div class="cv">${fmtN(k.rows[0][i])}</div></div>`).join('');
|
||||
} else document.getElementById('cards').innerHTML = `<span class="err">${k.error}</span>`;
|
||||
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 = `조회/현황 (운영)<span class="cnt">${QSCREENS.length}</span>`;
|
||||
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 = `<span class="cd">${q.cd}</span>${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 => {
|
||||
|
|
|
|||
142
db/schema.d/90-seed.sql
Normal file
142
db/schema.d/90-seed.sql
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue