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

- 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

View file

@ -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

View file

@ -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);

View file

@ -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 => {