acquire-core-x/app/ui/index.html
hyeongwoo-choi 5c819239ad 업무화면 포털 + HTTP→tpcall 게이트웨이 (검증용 화면 계층)
- app/src/clients/acq_httpgw.c: C 경량 HTTP 게이트웨이(:8090, libatmiclt만 링크)
  /api/call?svc=..&FLD=.. → UBF(CBchg) → (옵션 XA tpbegin/tpcommit) tpcall → JSON
  레거시 화면(Xplatform)→Webtier→Tuxedo 구조의 Webtier 대응
- app/ui/index.html: Xplatform풍 한국어 업무포털 (좌측 메뉴트리·폼·결과그리드·거래저널)
  화면 9종: 매입접수(XA체인)/상태조회/가맹점집계/한도조회/MDR/잔액검증/일마감/가맹점/Luhn
  + 333 서비스 자유호출 화면
- entrypoint: 게이트웨이 자동 기동, compose: 8090 포트 노출
- 검증: HTML 서빙, ACQUIRE 체인 HTTP 호출 → XA 커밋(purchase status=S), Luhn T_RC=0,
  pg_prepared_xacts=0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:29:05 +00:00

227 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>ACQUIRE-CORE-X 매입업무포털</title>
<style>
/* Xplatform(UXStudio) 풍 내부 업무화면 스타일 */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: "Malgun Gothic", "맑은 고딕", Dotum, sans-serif; font-size: 12px; color: #222; }
body { display: flex; flex-direction: column; background: #e8ebef; }
/* 상단 타이틀바 */
#titlebar { height: 44px; background: linear-gradient(#2c5d8f, #1d4269); color: #fff; display: flex; align-items: center; padding: 0 14px; }
#titlebar .logo { font-size: 16px; font-weight: bold; letter-spacing: 1px; }
#titlebar .sub { margin-left: 12px; font-size: 11px; color: #bcd3e8; }
#titlebar .right { margin-left: auto; font-size: 11px; color: #d6e4f2; }
#titlebar .right b { color: #ffd27f; }
#main { flex: 1; display: flex; min-height: 0; }
/* 좌측 메뉴 트리 */
#menu { width: 208px; background: #f4f6f8; border-right: 1px solid #b8c2cc; overflow-y: auto; }
#menu .grp { padding: 7px 10px; background: #dbe3ea; border-bottom: 1px solid #c3cdd6; font-weight: bold; color: #2c4a66; cursor: default; }
#menu .itm { padding: 6px 10px 6px 22px; border-bottom: 1px solid #e6eaee; cursor: pointer; color: #333; }
#menu .itm:hover { background: #e8f0f8; }
#menu .itm.on { background: #cfe0f0; color: #14487c; font-weight: bold; }
#menu .itm .cd { color: #888; font-size: 10px; margin-right: 5px; }
/* 컨텐츠 */
#content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#tabbar { height: 30px; background: #d5dce3; border-bottom: 1px solid #a9b4bf; display: flex; align-items: flex-end; padding: 0 8px; }
#tabbar .tab { padding: 5px 16px 4px; background: #fff; border: 1px solid #a9b4bf; border-bottom: none; font-weight: bold; color: #1d4269; }
#work { flex: 1; overflow: auto; padding: 12px; }
.panel { background: #fff; border: 1px solid #b0bac4; margin-bottom: 12px; }
.panel h3 { font-size: 12px; padding: 6px 10px; background: #eef2f6; border-bottom: 1px solid #ccd4dc;
color: #1d4269; display: flex; align-items: center; }
.panel h3::before { content: ""; width: 4px; height: 12px; background: #2c5d8f; margin-right: 6px; }
.panel .bd { padding: 10px; }
/* 폼 */
table.frm { border-collapse: collapse; width: 100%; }
table.frm th { background: #f0f3f6; border: 1px solid #d3dae1; padding: 5px 8px; text-align: right; width: 130px; font-weight: normal; color: #445; }
table.frm td { border: 1px solid #d3dae1; padding: 3px 6px; }
table.frm input { width: 100%; max-width: 260px; height: 22px; border: 1px solid #9aa8b5; padding: 0 6px; font-size: 12px; }
.btnrow { margin-top: 10px; text-align: right; }
button { height: 26px; padding: 0 16px; margin-left: 6px; background: linear-gradient(#fdfdfd, #dfe5ea); border: 1px solid #8fa0b0; cursor: pointer; font-size: 12px; }
button.pri { background: linear-gradient(#3f76ad, #29578a); color: #fff; border-color: #1d4269; font-weight: bold; }
button:hover { filter: brightness(1.06); }
/* 그리드 */
table.grd { border-collapse: collapse; width: 100%; }
table.grd th { background: #dfe6ec; border: 1px solid #b8c2cc; padding: 4px 6px; color: #2c4a66; }
table.grd td { border: 1px solid #d3dae1; padding: 3px 6px; background: #fff; }
table.grd tr:nth-child(even) td { background: #f7fafc; }
.num { text-align: right; font-family: Consolas, monospace; }
.ok { color: #0a7d2c; font-weight: bold; }
.err { color: #c22; font-weight: bold; }
#statusbar { height: 24px; background: #d5dce3; border-top: 1px solid #a9b4bf; display: flex; align-items: center;
padding: 0 10px; font-size: 11px; color: #345; }
#statusbar .dot { width: 8px; height: 8px; border-radius: 50%; background: #23a54a; margin-right: 6px; }
</style>
</head>
<body>
<div id="titlebar">
<span class="logo">ACQUIRE-CORE-X</span>
<span class="sub">글로벌 매입업무포털 (Enduro/X · ECPG/PostgreSQL · XA)</span>
<span class="right">사용자: <b>매입운영1팀</b> | 영업일 <span id="bizdate"></span> | 화면번호 <span id="scrno">AC0101</span></span>
</div>
<div id="main">
<div id="menu"></div>
<div id="content">
<div id="tabbar"><span class="tab" id="tabname">매입 접수</span></div>
<div id="work"></div>
<div id="statusbar"><span class="dot"></span><span id="stmsg">TP 모니터 연결됨 — 게이트웨이 /api/call</span></div>
</div>
</div>
<script>
/* ───────── 화면 정의: 실제 tpservice 와 1:1 매핑 ───────── */
const today = new Date().toISOString().slice(0,10).replace(/-/g,'');
document.getElementById('bizdate').textContent = today;
const SCREENS = [
{ grp: "매입 (ac)", items: [
{ cd:"AC0101", nm:"매입 접수(승인체인)", svc:"ACQUIRE", tx:1,
note:"글로벌 XA: ACQUIRE→RECONCILE→SETTLE 체인 후 tpcommit(2PC)",
flds:[ ["T_MERCHANT","가맹점번호","M0001"], ["T_AMOUNT","매입금액(원)","1000000"], ["T_BIZDATE","영업일자",today] ] },
{ cd:"AC0201", nm:"매입 상태조회", svc:"ACQ_STATUS", tx:0,
flds:[ ["T_PURCHASE_ID","매입번호","1"] ] },
{ cd:"AC0301", nm:"가맹점 매입집계", svc:"ACQ_MERCHSUM", tx:0,
flds:[ ["T_MERCHANT","가맹점번호","M0001"], ["T_BIZDATE","영업일자",today] ] },
]},
{ grp: "승인/한도 (au)", items: [
{ cd:"AU0101", nm:"한도 조회", svc:"LIMIT_INQ", tx:0,
flds:[ ["T_KEY1","카드번호","4111111111111111"] ] },
]},
{ grp: "정산/수수료 (st)", items: [
{ cd:"ST0101", nm:"MDR 수수료 산출", svc:"ST_MDR", tx:0,
flds:[ ["T_PURCHASE_ID","매입번호","1"], ["T_FEE","기준수수료","0"] ] },
]},
{ grp: "원장 (lg)", items: [
{ cd:"LG0101", nm:"원장 잔액검증", svc:"LG_BALCHK", tx:0,
flds:[ ["T_ID1","전표번호","1"] ] },
]},
{ grp: "마감 (cl)", items: [
{ cd:"CL0101", nm:"일마감 처리", svc:"CL_DAILY", tx:1,
flds:[ ["T_BIZDATE","마감영업일",today] ] },
]},
{ grp: "마스터 (mm)", items: [
{ cd:"MM0101", nm:"가맹점 조회", svc:"MM_MERCH_INQ", tx:0,
flds:[ ["T_MERCHANT","가맹점번호","M0001"] ] },
]},
{ grp: "공통 (cm)", items: [
{ cd:"CM0101", nm:"카드번호 검증(Luhn)", svc:"CM_LUHN", tx:0,
flds:[ ["T_STR5","카드번호","4111111111111111"] ] },
]},
{ grp: "자유 호출", items: [
{ cd:"ZZ0101", nm:"서비스 직접호출(333종)", svc:"", tx:0, free:true,
note:"advertise 된 333 개 tpservice 아무거나 호출 (필드 직접 지정)",
flds:[ ["T_MERCHANT","","M0001"] ] },
]},
];
/* ───────── 메뉴 렌더 ───────── */
const menuEl = document.getElementById('menu');
let CUR = null;
SCREENS.forEach(g => {
const gd = document.createElement('div'); gd.className='grp'; gd.textContent = g.grp; menuEl.appendChild(gd);
g.items.forEach(s => {
const it = document.createElement('div'); it.className='itm';
it.innerHTML = `<span class="cd">${s.cd}</span>${s.nm}`;
it.onclick = () => openScreen(s, it);
menuEl.appendChild(it);
if (s.cd === 'AC0101') setTimeout(()=>openScreen(s, it), 0);
});
});
/* ───────── 화면 렌더 ───────── */
const work = document.getElementById('work');
const journal = []; /* 호출이력 */
function openScreen(s, itEl) {
CUR = s;
document.querySelectorAll('#menu .itm').forEach(e=>e.classList.remove('on'));
if (itEl) itEl.classList.add('on');
document.getElementById('tabname').textContent = s.nm;
document.getElementById('scrno').textContent = s.cd;
let rows = s.flds.map(([f,label,dv],i) =>
`<tr><th>${label||f}</th><td><input id="f_${i}" data-fld="${f}" value="${dv}">
${s.free?`<input id="fn_${i}" data-name value="${f}" style="max-width:120px;margin-right:6px" placeholder="필드명">`:''}
</td></tr>`).join('');
const svcRow = s.free
? `<tr><th>서비스명</th><td><input id="svcname" value="ACQ_DUPCHK" style="max-width:200px"></td></tr>`
: `<tr><th>서비스명</th><td><b>${s.svc}</b>${s.tx?` <span style="color:#a60">[글로벌 XA 트랜잭션]</span>`:''}</td></tr>`;
work.innerHTML = `
<div class="panel"><h3>${s.cd} · ${s.nm}${s.note?` — <span style="font-weight:normal;color:#667">${s.note}</span>`:''}</h3>
<div class="bd">
<table class="frm">${svcRow}${rows}</table>
<div class="btnrow">
<button onclick="resetForm()">초기화</button>
<button class="pri" onclick="execute()">실행 (tpcall)</button>
</div>
</div>
</div>
<div class="panel"><h3>처리 결과 (응답 전문 UBF)</h3>
<div class="bd"><table class="grd" id="resgrid"><tr><th style="width:180px">필드</th><th>값</th></tr>
<tr><td colspan="2" style="color:#999">실행 전</td></tr></table></div>
</div>
<div class="panel"><h3>거래 저널 (금일 호출이력)</h3>
<div class="bd"><table class="grd" id="jgrid">
<tr><th>시각</th><th>화면</th><th>서비스</th><th>XA</th><th>결과</th><th>요약</th></tr></table></div>
</div>`;
renderJournal();
}
function resetForm(){ openScreen(CUR, document.querySelector('#menu .itm.on')); }
async function execute() {
const s = CUR;
const svc = s.free ? document.getElementById('svcname').value.trim() : s.svc;
const p = new URLSearchParams({ svc, _tx: s.tx || 0 });
s.flds.forEach((_,i) => {
const fld = s.free ? document.getElementById('fn_'+i).value.trim() : document.getElementById('f_'+i).dataset.fld;
const v = document.getElementById('f_'+i).value;
if (fld && v !== '') p.append(fld, v);
});
setStatus(`tpcall("${svc}") 호출 중 ...`);
let j;
try {
const r = await fetch('/api/call?' + p.toString());
j = await r.json();
} catch (e) { j = { ok:false, error: String(e) }; }
const g = document.getElementById('resgrid');
g.innerHTML = '<tr><th style="width:180px">필드</th><th>값</th></tr>';
if (j.ok) {
const f = j.fields || {};
if (!Object.keys(f).length) g.innerHTML += '<tr><td colspan="2">(응답 필드 없음 — 정상 처리)</td></tr>';
for (const [k,v] of Object.entries(f))
g.innerHTML += `<tr><td>${k}</td><td class="num">${fmt(k,v)}</td></tr>`;
setStatus(`tpcall("${svc}") 정상 처리${s.tx?' · XA tpcommit 완료(2PC)':''}`);
} else {
g.innerHTML += `<tr><td>오류</td><td class="err">${j.error||'unknown'}</td></tr>`;
setStatus(`tpcall("${svc}") 실패`, true);
}
journal.unshift({ t:new Date().toLocaleTimeString('ko-KR'), cd:s.cd, svc, tx:s.tx?'Y':'N',
ok:j.ok, sum: j.ok ? summarize(j.fields) : (j.error||'').slice(0,60) });
renderJournal();
}
function fmt(k, v){ return /AMOUNT|FEE|NET|GROSS|AMT|TAX|DELTA/.test(k) && /^\-?\d+$/.test(v) ? Number(v).toLocaleString('ko-KR') : v; }
function summarize(f){ if(!f) return ''; return Object.entries(f).slice(0,4).map(([k,v])=>`${k}=${v}`).join(' '); }
function renderJournal(){
const g = document.getElementById('jgrid'); if (!g) return;
g.innerHTML = '<tr><th>시각</th><th>화면</th><th>서비스</th><th>XA</th><th>결과</th><th>요약</th></tr>' +
journal.map(r=>`<tr><td>${r.t}</td><td>${r.cd}</td><td>${r.svc}</td><td>${r.tx}</td>
<td class="${r.ok?'ok':'err'}">${r.ok?'정상':'오류'}</td><td>${r.sum}</td></tr>`).join('');
}
function setStatus(m, err){ const e=document.getElementById('stmsg'); e.textContent=m; e.style.color=err?'#c22':'#345'; }
</script>
</body>
</html>