diff --git a/app/conf/app.ini b/app/conf/app.ini
index 570e385..d3fbafb 100644
--- a/app/conf/app.ini
+++ b/app/conf/app.ini
@@ -9,8 +9,8 @@ NDRX_IPCKEY=44100
NDRX_LDBAL=0
NDRX_LEV=5
NDRX_LOG=${NDRX_APPHOME}/log/xadmin.log
-NDRX_MSGMAX=100
-NDRX_MSGSIZEMAX=56000
+NDRX_MSGMAX=50
+NDRX_MSGSIZEMAX=16000
NDRX_NODEID=1
NDRX_QPATH=/dev/mqueue
NDRX_QPREFIX=/acq
diff --git a/app/conf/ndrxconfig.xml b/app/conf/ndrxconfig.xml
index cdc13de..9809bae 100644
--- a/app/conf/ndrxconfig.xml
+++ b/app/conf/ndrxconfig.xml
@@ -35,17 +35,65 @@
1
-e ${NDRX_APPHOME}/log/ac_svr.log -r
-
+
110
1
1
+ -e ${NDRX_APPHOME}/log/au_svr.log -r
+
+
+ 120
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/cl_svr.log -r
+
+
+ 130
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/cm_svr.log -r
+
+
+ 140
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/lg_svr.log -r
+
+
+ 150
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/mg_svr.log -r
+
+
+ 160
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/mm_svr.log -r
+
+
+ 170
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/py_svr.log -r
+
+
+ 180
+ 1
+ 1
-e ${NDRX_APPHOME}/log/rc_svr.log -r
- 120
+ 190
1
1
-e ${NDRX_APPHOME}/log/st_svr.log -r
+
+ 200
+ 1
+ 1
+ -e ${NDRX_APPHOME}/log/vl_svr.log -r
+
diff --git a/app/src/au/au_svr.pgc b/app/src/au/au_svr.pgc
new file mode 100644
index 0000000..2384ecd
--- /dev/null
+++ b/app/src/au/au_svr.pgc
@@ -0,0 +1,660 @@
+/*
+ * au_svr.pgc - au (승인/한도) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online au services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct
+ * 승인/한도 operation with genuine EXEC SQL (directly, or via the linked
+ * libaudbio.a dbio layer) and common math from libacqcommon.a.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch); there is NO
+ * EXEC SQL CONNECT. Owner-writes discipline: services in this server never
+ * tpcall each other; each owns its tables on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "au_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void AUTH(TPSVCINFO *p); void AUTH_CANCEL(TPSVCINFO *p);
+void AUTH_PARTCXL(TPSVCINFO *p); void LIMIT_CHK(TPSVCINFO *p);
+void LIMIT_DEC(TPSVCINFO *p); void LIMIT_RST(TPSVCINFO *p);
+void LIMIT_INQ(TPSVCINFO *p); void STANDIN(TPSVCINFO *p);
+void FRAUD_DETECT(TPSVCINFO *p); void AUTH_RETRY(TPSVCINFO *p);
+void INSTALL_AUTH(TPSVCINFO *p); void AUTH_STATUS(TPSVCINFO *p);
+void AUTH_REVERSE(TPSVCINFO *p); void AUTH_APPROVE(TPSVCINFO *p);
+void AUTH_DECLINE(TPSVCINFO *p); void CARD_VALIDATE(TPSVCINFO *p);
+void VELOCITY_CHK(TPSVCINFO *p); void BLACKLIST_CHK(TPSVCINFO *p);
+void AUTH_HOLD(TPSVCINFO *p); void AUTH_RELEASE(TPSVCINFO *p);
+void AUTH_HISTORY(TPSVCINFO *p); void LIMIT_SET(TPSVCINFO *p);
+void LIMIT_TEMP(TPSVCINFO *p); void AUTH_SUMMARY(TPSVCINFO *p);
+void AUTH_ROUTE(TPSVCINFO *p); void DCC_QUOTE(TPSVCINFO *p);
+void AUTH_CAPTURE(TPSVCINFO *p); void PREAUTH(TPSVCINFO *p);
+void PREAUTH_COMPLETE(TPSVCINFO *p); void AUTH_VOID(TPSVCINFO *p);
+
+/* ----- advertise table (this is what "scales to 30" cleanly) ------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"AUTH", AUTH}, {"AUTH_CANCEL", AUTH_CANCEL},
+ {"AUTH_PARTCXL", AUTH_PARTCXL}, {"LIMIT_CHK", LIMIT_CHK},
+ {"LIMIT_DEC", LIMIT_DEC}, {"LIMIT_RST", LIMIT_RST},
+ {"LIMIT_INQ", LIMIT_INQ}, {"STANDIN", STANDIN},
+ {"FRAUD_DETECT", FRAUD_DETECT}, {"AUTH_RETRY", AUTH_RETRY},
+ {"INSTALL_AUTH", INSTALL_AUTH}, {"AUTH_STATUS", AUTH_STATUS},
+ {"AUTH_REVERSE", AUTH_REVERSE}, {"AUTH_APPROVE", AUTH_APPROVE},
+ {"AUTH_DECLINE", AUTH_DECLINE}, {"CARD_VALIDATE", CARD_VALIDATE},
+ {"VELOCITY_CHK", VELOCITY_CHK}, {"BLACKLIST_CHK", BLACKLIST_CHK},
+ {"AUTH_HOLD", AUTH_HOLD}, {"AUTH_RELEASE", AUTH_RELEASE},
+ {"AUTH_HISTORY", AUTH_HISTORY}, {"LIMIT_SET", LIMIT_SET},
+ {"LIMIT_TEMP", LIMIT_TEMP}, {"AUTH_SUMMARY", AUTH_SUMMARY},
+ {"AUTH_ROUTE", AUTH_ROUTE}, {"DCC_QUOTE", DCC_QUOTE},
+ {"AUTH_CAPTURE", AUTH_CAPTURE}, {"PREAUTH", PREAUTH},
+ {"PREAUTH_COMPLETE", PREAUTH_COMPLETE}, {"AUTH_VOID", AUTH_VOID},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("au_svr: tpopen FAIL: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("au_svr: tpadvertise(%s) FAIL: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("au_svr: %d au(승인/한도) 서비스 광고 완료, RM 오픈", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("au_svr: tpsvrdone");
+}
+
+/* ========================================================================= */
+/* 1. AUTH - 승인 접수: 한도 점검 후 authorization 원장 기록 + 한도 차감. */
+/* ========================================================================= */
+void AUTH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], merch[64], bizdate[16], appr[12];
+ long amount, auth_id, lim = 0, used = 0;
+
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (card[0] == 0) { userlog("AUTH: T_KEY1(카드번호) 누락"); FAIL(b); }
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amount = getl(b, T_AMOUNT);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+
+ /* 한도 점검: 초과면 거절(D) 상태로 기록 */
+ if (audb_limit_get(card, &lim, &used) < 0) FAIL(b);
+ if (used + amount > lim) {
+ userlog("AUTH 한도초과 card=%s used=%ld amt=%ld lim=%ld -> 거절", card, used, amount, lim);
+ auth_id = audb_next_auth_id();
+ if (auth_id < 0) FAIL(b);
+ if (audb_insert_auth(auth_id, card, merch, amount, "000000", "AUTH", "D", bizdate) < 0) FAIL(b);
+ setl(b, T_ID1, auth_id);
+ setl(b, T_RC, 1);
+ Bchg(b, T_STATUS, 0, "D", 0L);
+ OK(b);
+ }
+ auth_id = audb_next_auth_id();
+ if (auth_id < 0) FAIL(b);
+ sprintf(appr, "%06ld", auth_id % 1000000);
+ if (audb_insert_auth(auth_id, card, merch, amount, appr, "AUTH", "A", bizdate) < 0) FAIL(b);
+ if (audb_limit_dec(card, amount) < 0) FAIL(b);
+
+ setl(b, T_ID1, auth_id);
+ setl(b, T_RC, 0);
+ Bchg(b, T_STR2, 0, appr, 0L);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ userlog("AUTH 승인 auth_id=%ld card=%s merch=%s amt=%ld appr=%s", auth_id, card, merch, amount, appr);
+ OK(b);
+}
+
+/* 2. AUTH_CANCEL - 승인 전체 취소 + 한도 원복. */
+void AUTH_CANCEL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], bizdate[16], reason[128];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_auth, h_amt;
+ char h_card[24], h_bizdate[16], h_reason[128];
+ EXEC SQL END DECLARE SECTION;
+
+ h_auth = getl(b, T_ID1);
+ h_amt = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "CUSTOMER");
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_reason, reason, sizeof(h_reason)-1); h_reason[sizeof(h_reason)-1] = 0;
+
+ EXEC SQL SELECT nextval('au_cancel_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO au_cancel (cancel_id, auth_id, cancel_type, amount, reason, biz_date)
+ VALUES (:h_id, :h_auth, 'FULL', :h_amt, :h_reason, :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (audb_update_auth_status(h_auth, "C") < 0) FAIL(b);
+ if (card[0] && audb_limit_dec(card, -h_amt) < 0) FAIL(b);
+ setl(b, T_CANCEL_ID, h_id);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 3. AUTH_PARTCXL - 부분 취소: 승인 금액 감액 + 한도 부분 원복. */
+void AUTH_PARTCXL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_auth, h_amt, h_new;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ h_auth = getl(b, T_ID1);
+ h_amt = getl(b, T_AMOUNT); /* 취소 금액 */
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT amount INTO :h_new FROM au_authorization WHERE auth_id = :h_auth;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ h_new = h_new - h_amt;
+ EXEC SQL SELECT nextval('au_cancel_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO au_cancel (cancel_id, auth_id, cancel_type, amount, reason, biz_date)
+ VALUES (:h_id, :h_auth, 'PARTIAL', :h_amt, 'PARTIAL', :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (audb_update_auth_amount(h_auth, h_new) < 0) FAIL(b);
+ if (card[0] && audb_limit_dec(card, -h_amt) < 0) FAIL(b);
+ setl(b, T_CANCEL_ID, h_id);
+ setl(b, T_AMOUNT, h_new);
+ OK(b);
+}
+
+/* 4. LIMIT_CHK - 카드 한도 점검 (사용가능 여부만 리턴). */
+void LIMIT_CHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ long amount, lim = 0, used = 0;
+ gets_(b, T_KEY1, card, sizeof(card));
+ amount = getl(b, T_AMOUNT);
+ if (audb_limit_get(card, &lim, &used) < 0) FAIL(b);
+ setl(b, T_AMT1, lim);
+ setl(b, T_AMT2, used);
+ setl(b, T_RC, (used + amount > lim) ? 1 : 0);
+ OK(b);
+}
+
+/* 5. LIMIT_DEC - 한도 차감(사용금액 증가). */
+void LIMIT_DEC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ long amount = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (audb_limit_dec(card, amount) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 6. LIMIT_RST - 한도 사용금액 초기화(일마감 원복). */
+void LIMIT_RST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (audb_limit_reset(card) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 7. LIMIT_INQ - 한도 조회 (가용한도 계산). */
+void LIMIT_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ long lim = 0, used = 0;
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (audb_limit_get(card, &lim, &used) < 0) FAIL(b);
+ setl(b, T_AMT1, lim);
+ setl(b, T_AMT2, used);
+ setl(b, T_AMT3, lim - used); /* 가용한도 */
+ OK(b);
+}
+
+/* 8. STANDIN - 발급사 무응답 시 대리승인(STANDIN, 소액 한도 내). */
+void STANDIN(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], merch[64], bizdate[16];
+ long amount, auth_id, stand_lim = 50000;
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amount = getl(b, T_AMOUNT);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (amount > stand_lim) { setl(b, T_RC, 1); Bchg(b, T_STATUS, 0, "D", 0L); OK(b); }
+ auth_id = audb_next_auth_id();
+ if (auth_id < 0) FAIL(b);
+ if (audb_insert_auth(auth_id, card, merch, amount, "STDIN0", "STANDIN", "A", bizdate) < 0) FAIL(b);
+ setl(b, T_ID1, auth_id);
+ setl(b, T_RC, 0);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ OK(b);
+}
+
+/* 9. FRAUD_DETECT - 부정거래 스코어링 + 위험시 fraud_log 기록. */
+void FRAUD_DETECT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], bizdate[16], reason[128];
+ long auth_id, amount, score = 0, fraud_id;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+
+ auth_id = getl(b, T_ID1);
+ amount = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+
+ /* 룰: 고액 + 당일 승인건수 과다 = 스코어 상승 */
+ if (amount > 1000000) score += 50;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM au_authorization WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt > 5) score += 40;
+ score += (h_cnt * 2);
+
+ strcpy(reason, score >= 50 ? "HIGH_RISK" : "OK");
+ setl(b, T_ARG1, score);
+ if (score >= 50) {
+ fraud_id = audb_insert_fraud(auth_id, card, score, reason, bizdate);
+ if (fraud_id < 0) FAIL(b);
+ setl(b, T_ID2, fraud_id);
+ setl(b, T_RC, 1);
+ } else {
+ setl(b, T_RC, 0);
+ }
+ OK(b);
+}
+
+/* 10. AUTH_RETRY - 거절 승인 재시도 (상태 D->A 재승인). */
+void AUTH_RETRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ long auth_id = getl(b, T_ID1), amount = getl(b, T_AMOUNT), lim = 0, used = 0;
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (audb_limit_get(card, &lim, &used) < 0) FAIL(b);
+ if (used + amount > lim) { setl(b, T_RC, 1); FAIL(b); }
+ if (audb_update_auth_status(auth_id, "A") < 0) FAIL(b);
+ if (audb_limit_dec(card, amount) < 0) FAIL(b);
+ setl(b, T_RC, 0);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ OK(b);
+}
+
+/* 11. INSTALL_AUTH - 할부 승인: 개월 수만큼 분할 승인 레코드. */
+void INSTALL_AUTH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], merch[64], bizdate[16];
+ long months, amount, i, auth_id;
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amount = getl(b, T_AMOUNT);
+ months = getl(b, T_INSTALL_N); if (months < 1) months = 3;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ for (i = 1; i <= months; i++) {
+ auth_id = audb_next_auth_id();
+ if (auth_id < 0) FAIL(b);
+ if (audb_insert_auth(auth_id, card, merch, amount / months, "INST00", "INSTALL", "A", bizdate) < 0) FAIL(b);
+ }
+ if (audb_limit_dec(card, amount) < 0) FAIL(b);
+ setl(b, T_COUNT, months);
+ OK(b);
+}
+
+/* 12. AUTH_STATUS - 승인 상태 조회 (dbio). */
+void AUTH_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long auth_id = getl(b, T_ID1);
+ char st[4];
+ if (audb_get_auth_status(auth_id, st) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, st, 0L);
+ OK(b);
+}
+
+/* 13. AUTH_REVERSE - 승인 역전(reversal) + 한도 원복. */
+void AUTH_REVERSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ long auth_id = getl(b, T_ID1), amount = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (audb_update_auth_status(auth_id, "R") < 0) FAIL(b);
+ if (card[0] && audb_limit_dec(card, -amount) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 14. AUTH_APPROVE - 승인 확정(pending->approved). */
+void AUTH_APPROVE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long auth_id = getl(b, T_ID1);
+ if (audb_update_auth_status(auth_id, "A") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ OK(b);
+}
+
+/* 15. AUTH_DECLINE - 승인 거절 처리. */
+void AUTH_DECLINE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char reason[128];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_auth;
+ char h_reason[128];
+ EXEC SQL END DECLARE SECTION;
+ h_auth = getl(b, T_ID1);
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "DECLINE");
+ strncpy(h_reason, reason, sizeof(h_reason)-1); h_reason[sizeof(h_reason)-1] = 0;
+ EXEC SQL UPDATE au_authorization SET status = 'D', decline_reason = :h_reason, updated_at = now()
+ WHERE auth_id = :h_auth;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_STATUS, 0, "D", 0L);
+ OK(b);
+}
+
+/* 16. CARD_VALIDATE - 카드번호 Luhn 검증 (common lib). */
+void CARD_VALIDATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ gets_(b, T_KEY1, card, sizeof(card));
+ setl(b, T_RC, acq_luhn_ok(card) ? 0 : 1);
+ OK(b);
+}
+
+/* 17. VELOCITY_CHK - 속도(velocity) 점검: 당일 카드 승인건수/합계. */
+void VELOCITY_CHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_sum;
+ char h_card[24], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_cnt, :h_sum
+ FROM au_authorization WHERE card_no = :h_card AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_sum);
+ setl(b, T_RC, (h_cnt > 10) ? 1 : 0);
+ OK(b);
+}
+
+/* 18. BLACKLIST_CHK - 블랙리스트 카드 조회. */
+void BLACKLIST_CHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, card, sizeof(card));
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM au_blacklist WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_RC, (h_cnt > 0) ? 1 : 0);
+ OK(b);
+}
+
+/* 19. AUTH_HOLD - 승인 보류. */
+void AUTH_HOLD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long auth_id = getl(b, T_ID1);
+ if (audb_update_auth_status(auth_id, "H") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "H", 0L);
+ OK(b);
+}
+
+/* 20. AUTH_RELEASE - 보류 해제. */
+void AUTH_RELEASE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long auth_id = getl(b, T_ID1);
+ if (audb_update_auth_status(auth_id, "A") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ OK(b);
+}
+
+/* 21. AUTH_HISTORY - 카드 승인 이력 건수 조회. */
+void AUTH_HISTORY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, card, sizeof(card));
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM au_authorization WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 22. LIMIT_SET - 카드 한도 설정/등록 (dbio upsert). */
+void LIMIT_SET(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ long lim = getl(b, T_AMT1);
+ gets_(b, T_KEY1, card, sizeof(card));
+ if (lim <= 0) lim = 1000000;
+ if (audb_limit_set(card, lim) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 23. LIMIT_TEMP - 임시 한도 상향 (temp_limit 저장 + 당일 상향). */
+void LIMIT_TEMP(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_temp;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, card, sizeof(card));
+ h_temp = getl(b, T_AMT1); if (h_temp <= 0) h_temp = 2000000;
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+ EXEC SQL UPDATE card_limit
+ SET temp_limit = :h_temp, daily_limit = daily_limit + :h_temp, updated_at = now()
+ WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ OK(b);
+}
+
+/* 24. AUTH_SUMMARY - 가맹점 당일 승인 집계 upsert (dbio). */
+void AUTH_SUMMARY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_appr, h_dec, h_amt;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*),
+ count(*) FILTER (WHERE status = 'A'),
+ count(*) FILTER (WHERE status = 'D'),
+ coalesce(sum(amount),0)
+ INTO :h_cnt, :h_appr, :h_dec, :h_amt
+ FROM au_authorization WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (audb_upsert_auth_summary(merch, bizdate, h_cnt, h_appr, h_dec, h_amt) < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ OK(b);
+}
+
+/* 25. AUTH_ROUTE - 승인 라우팅(발급사 BIN 기준 라우팅 코드 산출). */
+void AUTH_ROUTE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], route[8];
+ long bin;
+ gets_(b, T_KEY1, card, sizeof(card));
+ bin = atol(card) / 1000000000L; /* 앞 6자리 근사 BIN */
+ if (card[0] == '4') strcpy(route, "VISA");
+ else if (card[0] == '5') strcpy(route, "MC");
+ else if (card[0] == '3') strcpy(route, "AMEX");
+ else strcpy(route, "LOCAL");
+ Bchg(b, T_STR1, 0, route, 0L);
+ setl(b, T_ARG1, bin);
+ OK(b);
+}
+
+/* 26. DCC_QUOTE - 해외승인 DCC 환산 견적(통화 -> 원화). */
+void DCC_QUOTE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ccy[8];
+ long fx, rate = 1350, krw;
+ fx = getl(b, T_FX_AMT);
+ gets_(b, T_CCY, ccy, sizeof(ccy)); if (ccy[0] == 0) strcpy(ccy, "USD");
+ krw = fx * rate / 100;
+ setl(b, T_AMOUNT, krw);
+ setl(b, T_ARG1, rate);
+ OK(b);
+}
+
+/* 27. AUTH_CAPTURE - 승인 매입확정(capture): 상태 A->M(매입대상). */
+void AUTH_CAPTURE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_auth;
+ EXEC SQL END DECLARE SECTION;
+ h_auth = getl(b, T_ID1);
+ EXEC SQL UPDATE au_authorization SET status = 'M', captured_at = now(), updated_at = now()
+ WHERE auth_id = :h_auth AND status = 'A';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (sqlca.sqlcode == 100) { setl(b, T_RC, 1); FAIL(b); }
+ setl(b, T_RC, 0);
+ Bchg(b, T_STATUS, 0, "M", 0L);
+ OK(b);
+}
+
+/* 28. PREAUTH - 예비승인(pre-authorization) 기록. */
+void PREAUTH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], merch[64], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_amt;
+ char h_card[24], h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ h_amt = getl(b, T_AMOUNT);
+ strncpy(h_card, card, sizeof(h_card)-1); h_card[sizeof(h_card)-1] = 0;
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT nextval('au_preauth_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO au_preauth (preauth_id, card_no, merchant_id, amount, status, biz_date)
+ VALUES (:h_id, :h_card, :h_merch, :h_amt, 'P', :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_ID2, h_id);
+ OK(b);
+}
+
+/* 29. PREAUTH_COMPLETE - 예비승인 확정: preauth 소진 + 본승인 생성. */
+void PREAUTH_COMPLETE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long auth_id;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pre, h_amt;
+ char h_card[24], h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pre = getl(b, T_ID2);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT card_no, merchant_id, amount INTO :h_card, :h_merch, :h_amt
+ FROM au_preauth WHERE preauth_id = :h_pre;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ EXEC SQL UPDATE au_preauth SET status = 'C' WHERE preauth_id = :h_pre;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ auth_id = audb_next_auth_id();
+ if (auth_id < 0) FAIL(b);
+ if (audb_insert_auth(auth_id, h_card, h_merch, h_amt, "PRECMP", "PREAUTH", "A", bizdate) < 0) FAIL(b);
+ setl(b, T_ID1, auth_id);
+ OK(b);
+}
+
+/* 30. AUTH_VOID - 당일 승인 무효화(void) + 한도 원복. */
+void AUTH_VOID(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char card[24], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_auth, h_amt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_auth = getl(b, T_ID1); h_amt = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, card, sizeof(card));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT nextval('au_cancel_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO au_cancel (cancel_id, auth_id, cancel_type, amount, reason, biz_date)
+ VALUES (:h_id, :h_auth, 'VOID', :h_amt, 'VOID', :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (audb_update_auth_status(h_auth, "V") < 0) FAIL(b);
+ if (card[0] && audb_limit_dec(card, -h_amt) < 0) FAIL(b);
+ setl(b, T_CANCEL_ID, h_id);
+ Bchg(b, T_STATUS, 0, "V", 0L);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/au/batch/au_fraud_scan_batch.pgc b/app/src/au/batch/au_fraud_scan_batch.pgc
new file mode 100644
index 0000000..512403b
--- /dev/null
+++ b/app/src/au/batch/au_fraud_scan_batch.pgc
@@ -0,0 +1,58 @@
+/*
+ * au_fraud_scan_batch.pgc - au 부정거래 스캔 배치 (GROUP BY HAVING + XA).
+ *
+ * Groups the day's authorizations by card and, for cards whose승인건수/합계 that
+ * exceed velocity thresholds (HAVING), inserts a fraud_log row via the dbio
+ * layer - all under ONE global XA transaction. Usage: au_fraud_scan_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "au_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ char h_card[24];
+ long h_cnt, h_sum, h_score;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE fsc CURSOR FOR
+ SELECT card_no, count(*), coalesce(sum(amount),0)
+ FROM au_authorization
+ WHERE biz_date = :h_bizdate
+ GROUP BY card_no
+ HAVING count(*) > 5 OR coalesce(sum(amount),0) > 5000000
+ ORDER BY card_no;
+ EXEC SQL OPEN fsc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH fsc INTO :h_card, :h_cnt, :h_sum;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE fsc; tpabort(0); return 1; }
+ h_score = h_cnt * 10 + (h_sum > 5000000 ? 40 : 0);
+ if (audb_insert_fraud(0, h_card, h_score, "VELOCITY_BATCH", bizdate) < 0) {
+ EXEC SQL CLOSE fsc; tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE fsc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> au_fraud_scan_batch COMMIT: bizdate=%s flagged_cards=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/au/batch/au_settle_reflect_batch.pgc b/app/src/au/batch/au_settle_reflect_batch.pgc
new file mode 100644
index 0000000..9854d6a
--- /dev/null
+++ b/app/src/au/batch/au_settle_reflect_batch.pgc
@@ -0,0 +1,54 @@
+/*
+ * au_settle_reflect_batch.pgc - au 승인 정산반영 배치 (커서 M->S + XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction, an EXEC SQL cursor scans captured authorizations (status='M')
+ * for a biz_date and flips each to settled (status='S') via the dbio layer,
+ * then tpcommit drives XA 2PC. Usage: au_settle_reflect_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "au_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_auth, h_amt;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE arc CURSOR FOR
+ SELECT auth_id, amount FROM au_authorization
+ WHERE biz_date = :h_bizdate AND status = 'M'
+ ORDER BY auth_id;
+ EXEC SQL OPEN arc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH arc INTO :h_auth, :h_amt;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE arc; tpabort(0); return 1; }
+ if (audb_update_auth_status(h_auth, "S") < 0) {
+ EXEC SQL CLOSE arc; tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE arc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> au_settle_reflect_batch COMMIT: bizdate=%s reflected(M->S)=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/au/dbio/au_auth_dbio.pgc b/app/src/au/dbio/au_auth_dbio.pgc
new file mode 100644
index 0000000..de3d95b
--- /dev/null
+++ b/app/src/au/dbio/au_auth_dbio.pgc
@@ -0,0 +1,121 @@
+/*
+ * au_auth_dbio.pgc - 승인(authorization)/부정거래 DB 접근 (libaudbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "au_dbio.h"
+
+long audb_next_auth_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('au_auth_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("audb_next_auth_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int audb_insert_auth(long auth_id, const char *card, const char *merch, long amount,
+ const char *appr_code, const char *auth_type, const char *status,
+ const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = auth_id, h_amount = amount;
+ char h_card[24], h_merch[64], h_appr[12], h_type[12], h_status[8], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_card, card, sizeof(h_card) - 1); h_card[sizeof(h_card)-1] = 0;
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_appr, appr_code, sizeof(h_appr) - 1); h_appr[sizeof(h_appr)-1] = 0;
+ strncpy(h_type, auth_type, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO au_authorization
+ (auth_id, card_no, merchant_id, amount, approval_code, auth_type, status, biz_date)
+ VALUES (:h_id, :h_card, :h_merch, :h_amount, :h_appr, :h_type, :h_status, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("audb_insert_auth FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int audb_update_auth_status(long auth_id, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = auth_id;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE au_authorization SET status = :h_status, updated_at = now()
+ WHERE auth_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("audb_update_auth_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int audb_get_auth_status(long auth_id, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = auth_id;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT status INTO :h_status FROM au_authorization WHERE auth_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("audb_get_auth_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ out[0] = h_status[0];
+ out[1] = 0;
+ return 0;
+}
+
+int audb_update_auth_amount(long auth_id, long amount)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = auth_id, h_amount = amount;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL UPDATE au_authorization SET amount = :h_amount, updated_at = now()
+ WHERE auth_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("audb_update_auth_amount FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+long audb_insert_fraud(long auth_id, const char *card, long score, const char *reason,
+ const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_auth = auth_id, h_score = score;
+ char h_card[24], h_reason[128], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_card, card, sizeof(h_card) - 1); h_card[sizeof(h_card)-1] = 0;
+ strncpy(h_reason, reason, sizeof(h_reason) - 1); h_reason[sizeof(h_reason)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('au_fraud_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO au_fraud_log (fraud_id, auth_id, card_no, score, reason, biz_date)
+ VALUES (:h_id, :h_auth, :h_card, :h_score, :h_reason, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("audb_insert_fraud FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
diff --git a/app/src/au/dbio/au_dbio.h b/app/src/au/dbio/au_dbio.h
new file mode 100644
index 0000000..641fe68
--- /dev/null
+++ b/app/src/au/dbio/au_dbio.h
@@ -0,0 +1,32 @@
+/*
+ * au_dbio.h - au 모듈 (승인/한도) DB 접근 계층 (libaudbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * au_svr and the au batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef AU_DBIO_H
+#define AU_DBIO_H
+
+/* authorization */
+long audb_next_auth_id(void);
+int audb_insert_auth(long auth_id, const char *card, const char *merch, long amount,
+ const char *appr_code, const char *auth_type, const char *status,
+ const char *bizdate);
+int audb_update_auth_status(long auth_id, const char *status);
+int audb_get_auth_status(long auth_id, char *out /* >= 2 bytes */);
+int audb_update_auth_amount(long auth_id, long amount);
+
+/* fraud */
+long audb_insert_fraud(long auth_id, const char *card, long score, const char *reason,
+ const char *bizdate);
+
+/* card limit */
+int audb_limit_get(const char *card, long *limit_out, long *used_out);
+int audb_limit_dec(const char *card, long amount);
+int audb_limit_reset(const char *card);
+int audb_limit_set(const char *card, long limit);
+int audb_upsert_auth_summary(const char *merch, const char *bizdate, long cnt,
+ long approved, long declined, long amount);
+
+#endif /* AU_DBIO_H */
diff --git a/app/src/au/dbio/au_limit_dbio.pgc b/app/src/au/dbio/au_limit_dbio.pgc
new file mode 100644
index 0000000..23bf0b5
--- /dev/null
+++ b/app/src/au/dbio/au_limit_dbio.pgc
@@ -0,0 +1,108 @@
+/*
+ * au_limit_dbio.pgc - 카드 한도(card_limit) + 승인 일집계 DB 접근 (libaudbio.a).
+ */
+#include
+#include
+#include "au_dbio.h"
+
+int audb_limit_get(const char *card, long *limit_out, long *used_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_limit, h_used;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_card, card, sizeof(h_card) - 1); h_card[sizeof(h_card)-1] = 0;
+
+ EXEC SQL SELECT daily_limit, used_amount INTO :h_limit, :h_used
+ FROM card_limit WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("audb_limit_get FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ *limit_out = h_limit;
+ *used_out = h_used;
+ return 0;
+}
+
+int audb_limit_dec(const char *card, long amount)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_amount = amount;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_card, card, sizeof(h_card) - 1); h_card[sizeof(h_card)-1] = 0;
+
+ EXEC SQL UPDATE card_limit SET used_amount = used_amount + :h_amount, updated_at = now()
+ WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("audb_limit_dec FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int audb_limit_reset(const char *card)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_card, card, sizeof(h_card) - 1); h_card[sizeof(h_card)-1] = 0;
+
+ EXEC SQL UPDATE card_limit SET used_amount = 0, updated_at = now()
+ WHERE card_no = :h_card;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("audb_limit_reset FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int audb_limit_set(const char *card, long limit)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_limit = limit;
+ char h_card[24];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_card, card, sizeof(h_card) - 1); h_card[sizeof(h_card)-1] = 0;
+
+ EXEC SQL INSERT INTO card_limit (card_no, daily_limit, used_amount)
+ VALUES (:h_card, :h_limit, 0)
+ ON CONFLICT (card_no) DO UPDATE
+ SET daily_limit = EXCLUDED.daily_limit, updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("audb_limit_set FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int audb_upsert_auth_summary(const char *merch, const char *bizdate, long cnt,
+ long approved, long declined, long amount)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt = cnt, h_appr = approved, h_dec = declined, h_amt = amount;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO au_summary
+ (merchant_id, biz_date, auth_count, approve_count, decline_count, auth_amount)
+ VALUES (:h_merch, :h_bizdate, :h_cnt, :h_appr, :h_dec, :h_amt)
+ ON CONFLICT (merchant_id, biz_date) DO UPDATE
+ SET auth_count = au_summary.auth_count + EXCLUDED.auth_count,
+ approve_count = au_summary.approve_count + EXCLUDED.approve_count,
+ decline_count = au_summary.decline_count + EXCLUDED.decline_count,
+ auth_amount = au_summary.auth_amount + EXCLUDED.auth_amount,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("audb_upsert_auth_summary FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/cl/batch/cl_daily_batch.pgc b/app/src/cl/batch/cl_daily_batch.pgc
new file mode 100644
index 0000000..aadedc5
--- /dev/null
+++ b/app/src/cl/batch/cl_daily_batch.pgc
@@ -0,0 +1,71 @@
+/*
+ * cl_daily_batch.pgc - cl 일 마감 배치 (미마감 점검 + 커서 M->S + XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction. 먼저 당일 대사완료(M) 미마감 매입 건수를 점검하고, 커서로 각 건을
+ * 정산완료(S)로 마감 전환한 뒤, 당일 마감이력(close_log, DAILY)을 집계와 함께
+ * 기록한다. tpcommit 이 XA 2PC 를 구동. Usage: cl_daily_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "cl_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long closed = 0, cid;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_pid, h_unclosed, h_cnt, h_amt;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ /* 1) 미마감(대사완료 M) 점검 */
+ EXEC SQL SELECT count(*) INTO :h_unclosed FROM purchase
+ WHERE biz_date = :h_bizdate AND status = 'M';
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "COUNT FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+ printf(">>> cl_daily_batch 미마감(M) 점검: bizdate=%s 대상=%ld건\n", bizdate, h_unclosed);
+
+ /* 2) 커서로 M -> S 마감 전환 */
+ EXEC SQL DECLARE cdc CURSOR FOR
+ SELECT purchase_id FROM purchase
+ WHERE biz_date = :h_bizdate AND status = 'M'
+ ORDER BY purchase_id;
+ EXEC SQL OPEN cdc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH cdc INTO :h_pid;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE cdc; tpabort(0); return 1; }
+ EXEC SQL UPDATE purchase SET status = 'S', updated_at = now() WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) { EXEC SQL CLOSE cdc; tpabort(0); return 1; }
+ closed++;
+ }
+ EXEC SQL CLOSE cdc;
+
+ /* 3) 당일 집계 + 마감이력(DAILY) 기록 (via dbio) */
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_cnt, :h_amt
+ FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) { tpabort(0); return 1; }
+ cid = cldb_next_close_id();
+ if (cid < 0) { tpabort(0); return 1; }
+ if (cldb_insert_close(cid, "DAILY", bizdate, bizdate, "C", h_cnt, h_amt, "일마감(배치)") < 0) {
+ tpabort(0); return 1;
+ }
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> cl_daily_batch COMMIT: bizdate=%s 마감전환=%ld cid=%ld 총건수=%ld\n",
+ bizdate, closed, cid, h_cnt);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/cl/batch/cl_snapshot_batch.pgc b/app/src/cl/batch/cl_snapshot_batch.pgc
new file mode 100644
index 0000000..f2da5d6
--- /dev/null
+++ b/app/src/cl/batch/cl_snapshot_batch.pgc
@@ -0,0 +1,66 @@
+/*
+ * cl_snapshot_batch.pgc - cl 마감 스냅샷 배치 (커서 + XA).
+ *
+ * 당일 마감(close_log, DAILY)에 대해 merchant_summary 를 가맹점별로 커서 순회하여
+ * cl_snapshot 에 고정 복사(스냅샷)한다. 마감 시점의 가맹점별 집계를 불변 기록으로
+ * 남긴다. 모두 ONE global XA transaction 하에서 수행.
+ * Usage: cl_snapshot_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "cl_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0, sid;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_merch[64];
+ long h_cid, h_gross, h_fee, h_net, h_cnt;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ /* 당일 최종 DAILY 마감 id 조회 (없으면 스냅샷 생략) */
+ EXEC SQL SELECT coalesce(max(close_id), 0) INTO :h_cid FROM cl_close_log
+ WHERE close_type = 'DAILY' AND biz_date = :h_bizdate AND status <> 'X';
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "CID FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+ if (h_cid == 0) {
+ printf(">>> cl_snapshot_batch: bizdate=%s DAILY 마감 없음 -> 스냅샷 생략\n", bizdate);
+ tpabort(0); tpclose(); tpterm(); return 0;
+ }
+
+ EXEC SQL DECLARE csc CURSOR FOR
+ SELECT merchant_id, gross_amount, fee_amount, net_amount, txn_count
+ FROM merchant_summary WHERE biz_date = :h_bizdate
+ ORDER BY merchant_id;
+ EXEC SQL OPEN csc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH csc INTO :h_merch, :h_gross, :h_fee, :h_net, :h_cnt;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE csc; tpabort(0); return 1; }
+ sid = cldb_next_snapshot_id();
+ if (sid < 0) { EXEC SQL CLOSE csc; tpabort(0); return 1; }
+ if (cldb_insert_snapshot(sid, h_cid, bizdate, bizdate, h_merch,
+ h_gross, h_fee, h_net, h_cnt) < 0) {
+ EXEC SQL CLOSE csc; tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE csc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> cl_snapshot_batch COMMIT: bizdate=%s cid=%ld 스냅샷=%ld건\n", bizdate, h_cid, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/cl/cl_svr.pgc b/app/src/cl/cl_svr.pgc
new file mode 100644
index 0000000..6868cab
--- /dev/null
+++ b/app/src/cl/cl_svr.pgc
@@ -0,0 +1,650 @@
+/*
+ * cl_svr.pgc - cl (마감/closing) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online cl services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct 마감
+ * operation with genuine EXEC SQL (directly, or via the linked libcldbio.a dbio
+ * layer) and common helpers from libacqcommon.a.
+ *
+ * 마감 도메인: 일/월/분기/연 마감 이력(close_log), 가맹점별 마감 스냅샷(snapshot),
+ * 기간 잠금(period_lock). 마감 상태 O=개시 -> C=마감완료 -> A=승인 -> L=잠금,
+ * 취소 X / 재마감 R.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch libndrxxaecpg.so);
+ * there is NO EXEC SQL CONNECT. Each tpcall runs on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "cl_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void CL_DAILY(TPSVCINFO *p); void CL_MONTHLY(TPSVCINFO *p);
+void CL_QUARTER(TPSVCINFO *p); void CL_SNAPSHOT(TPSVCINFO *p);
+void CL_RECLOSE(TPSVCINFO *p); void CL_VERIFY(TPSVCINFO *p);
+void CL_CANCEL(TPSVCINFO *p); void CL_STATS(TPSVCINFO *p);
+void CL_BALVERIFY(TPSVCINFO *p); void CL_SEQ(TPSVCINFO *p);
+void CL_REPORT(TPSVCINFO *p); void CL_ALERT(TPSVCINFO *p);
+void CL_UNCLOSED(TPSVCINFO *p); void CL_APPROVE(TPSVCINFO *p);
+void CL_LOCK(TPSVCINFO *p); void CL_UNLOCK(TPSVCINFO *p);
+void CL_AGGREGATE(TPSVCINFO *p); void CL_DIFFCHK(TPSVCINFO *p);
+void CL_SUMMARY(TPSVCINFO *p); void CL_STATUS(TPSVCINFO *p);
+void CL_ROLLBACK(TPSVCINFO *p); void CL_CONFIRM(TPSVCINFO *p);
+void CL_PERIOD(TPSVCINFO *p); void CL_YEAREND(TPSVCINFO *p);
+void CL_MIDCLOSE(TPSVCINFO *p); void CL_RECON(TPSVCINFO *p);
+void CL_ARCHIVE(TPSVCINFO *p); void CL_PURGE(TPSVCINFO *p);
+void CL_RESTORE(TPSVCINFO *p); void CL_INQUIRY(TPSVCINFO *p);
+
+/* ----- advertise table (this is what "scales to 30" cleanly) ------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"CL_DAILY", CL_DAILY}, {"CL_MONTHLY", CL_MONTHLY},
+ {"CL_QUARTER", CL_QUARTER}, {"CL_SNAPSHOT", CL_SNAPSHOT},
+ {"CL_RECLOSE", CL_RECLOSE}, {"CL_VERIFY", CL_VERIFY},
+ {"CL_CANCEL", CL_CANCEL}, {"CL_STATS", CL_STATS},
+ {"CL_BALVERIFY", CL_BALVERIFY}, {"CL_SEQ", CL_SEQ},
+ {"CL_REPORT", CL_REPORT}, {"CL_ALERT", CL_ALERT},
+ {"CL_UNCLOSED", CL_UNCLOSED}, {"CL_APPROVE", CL_APPROVE},
+ {"CL_LOCK", CL_LOCK}, {"CL_UNLOCK", CL_UNLOCK},
+ {"CL_AGGREGATE", CL_AGGREGATE}, {"CL_DIFFCHK", CL_DIFFCHK},
+ {"CL_SUMMARY", CL_SUMMARY}, {"CL_STATUS", CL_STATUS},
+ {"CL_ROLLBACK", CL_ROLLBACK}, {"CL_CONFIRM", CL_CONFIRM},
+ {"CL_PERIOD", CL_PERIOD}, {"CL_YEAREND", CL_YEAREND},
+ {"CL_MIDCLOSE", CL_MIDCLOSE}, {"CL_RECON", CL_RECON},
+ {"CL_ARCHIVE", CL_ARCHIVE}, {"CL_PURGE", CL_PURGE},
+ {"CL_RESTORE", CL_RESTORE}, {"CL_INQUIRY", CL_INQUIRY},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("cl_svr: tpopen FAIL: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("cl_svr: tpadvertise(%s) FAIL: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("cl_svr: %d cl 서비스 광고 완료, RM 개방", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("cl_svr: tpsvrdone");
+}
+
+/* 공통: 매입 집계 후 마감 이력 생성 헬퍼 (일/월/분기/연 공용). */
+static int cl_open_close(UBFH *b, const char *ctype, const char *period,
+ const char *bizdate, long *cid_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt, h_cid;
+ char h_bizdate[16], h_type[16], h_period[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_type, ctype, sizeof(h_type)-1); h_type[sizeof(h_type)-1] = 0;
+
+ if (strcmp(ctype, "DAILY") == 0) {
+ EXEC SQL SELECT count(*), coalesce(sum(amount), 0) INTO :h_cnt, :h_amt
+ FROM purchase WHERE biz_date = :h_bizdate;
+ } else {
+ strncpy(h_period, period, sizeof(h_period)-1); h_period[sizeof(h_period)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(amount), 0) INTO :h_cnt, :h_amt
+ FROM purchase WHERE to_char(biz_date, 'YYYY-MM') LIKE :h_period || '%';
+ }
+ if (sqlca.sqlcode < 0) return -1;
+
+ h_cid = cldb_next_close_id();
+ if (h_cid < 0) return -1;
+ if (cldb_insert_close(h_cid, ctype, period, bizdate, "C", h_cnt, h_amt, "마감") < 0) return -1;
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ *cid_out = h_cid;
+ return 0;
+}
+
+/* ========================================================================= */
+/* 1. CL_DAILY - 일 마감: 당일 매입 집계 후 마감이력(DAILY) 생성. */
+/* ========================================================================= */
+void CL_DAILY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long cid = 0;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (cl_open_close(b, "DAILY", bizdate, bizdate, &cid) < 0) FAIL(b);
+ setl(b, T_ID1, cid);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ userlog("CL_DAILY cid=%ld bizdate=%s 일마감 완료", cid, bizdate);
+ OK(b);
+}
+
+/* 2. CL_MONTHLY - 월 마감: 해당 월(YYYY-MM) 매입 집계 후 마감이력(MONTHLY). */
+void CL_MONTHLY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], period[16];
+ long cid = 0;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_STR1, period, sizeof(period));
+ if (period[0] == 0) { strncpy(period, bizdate, 7); period[7] = 0; }
+ if (cl_open_close(b, "MONTHLY", period, bizdate, &cid) < 0) FAIL(b);
+ setl(b, T_ID1, cid);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 3. CL_QUARTER - 분기 마감: 분기 프리픽스(YYYY-MM LIKE)로 집계. */
+void CL_QUARTER(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], period[16];
+ long cid = 0;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_STR1, period, sizeof(period));
+ if (period[0] == 0) { strncpy(period, bizdate, 4); period[4] = 0; }
+ if (cl_open_close(b, "QUARTER", period, bizdate, &cid) < 0) FAIL(b);
+ setl(b, T_ID1, cid);
+ OK(b);
+}
+
+/* 4. CL_SNAPSHOT - 마감 스냅샷: merchant_summary 를 close_id 로 고정 복사. */
+void CL_SNAPSHOT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], period[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_cnt;
+ char h_bizdate[16], h_period[16];
+ EXEC SQL END DECLARE SECTION;
+
+ h_cid = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_STR1, period, sizeof(period)); if (period[0] == 0) strcpy(period, bizdate);
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_period, period, sizeof(h_period)-1); h_period[sizeof(h_period)-1] = 0;
+
+ EXEC SQL INSERT INTO cl_snapshot
+ (snapshot_id, close_id, period_key, biz_date, merchant_id,
+ gross_amount, fee_amount, net_amount, txn_count)
+ SELECT nextval('cl_snapshot_seq'), :h_cid, :h_period, :h_bizdate,
+ ms.merchant_id, ms.gross_amount, ms.fee_amount, ms.net_amount, ms.txn_count
+ FROM merchant_summary ms WHERE ms.biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) { userlog("CL_SNAPSHOT FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ h_cnt = sqlca.sqlerrd[2];
+ setl(b, T_COUNT, h_cnt);
+ userlog("CL_SNAPSHOT cid=%ld 가맹점 %ld건 스냅샷", h_cid, h_cnt);
+ OK(b);
+}
+
+/* 5. CL_RECLOSE - 재마감: 상태 R 로 표시하고 총계 재집계. */
+void CL_RECLOSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_cnt, h_amt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_cnt, :h_amt
+ FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (cldb_update_close_totals(h_cid, h_cnt, h_amt) < 0) FAIL(b);
+ if (cldb_update_close_status(h_cid, "R") < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 6. CL_VERIFY - 마감 검증: close_log 총건수 vs 실제 매입건수 비교. */
+void CL_VERIFY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_stored, h_actual;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT txn_count INTO :h_stored FROM cl_close_log WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_actual FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_actual);
+ setl(b, T_RC, (h_stored == h_actual) ? 0 : 1);
+ OK(b);
+}
+
+/* 7. CL_CANCEL - 마감 취소: 상태 X. */
+void CL_CANCEL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long cid = getl(b, T_ID1);
+ if (cldb_update_close_status(cid, "X") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "X", 0L);
+ OK(b);
+}
+
+/* 8. CL_STATS - 마감 통계: close_type 별 마감 건수/총액. */
+void CL_STATS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ctype[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt;
+ char h_type[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR2, ctype, sizeof(ctype)); if (ctype[0] == 0) strcpy(ctype, "DAILY");
+ strncpy(h_type, ctype, sizeof(h_type)-1); h_type[sizeof(h_type)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(total_amount),0) INTO :h_cnt, :h_amt
+ FROM cl_close_log WHERE close_type = :h_type AND status <> 'X';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ OK(b);
+}
+
+/* 9. CL_BALVERIFY - 잔액 검증: 스냅샷 net 합 vs close_log total 비교. */
+void CL_BALVERIFY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_snap_net, h_total;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL SELECT coalesce(sum(net_amount),0) INTO :h_snap_net
+ FROM cl_snapshot WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT total_amount INTO :h_total FROM cl_close_log WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ setl(b, T_AMT2, h_snap_net);
+ setl(b, T_AMT3, h_total);
+ setl(b, T_DELTA, h_total - h_snap_net);
+ OK(b);
+}
+
+/* 10. CL_SEQ - 마감 순번 발번(dbio upsert). */
+void CL_SEQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ctype[16];
+ long seq;
+ gets_(b, T_STR2, ctype, sizeof(ctype)); if (ctype[0] == 0) strcpy(ctype, "DAILY");
+ seq = cldb_bump_seq(ctype);
+ if (seq < 0) FAIL(b);
+ setl(b, T_ID2, seq);
+ OK(b);
+}
+
+/* 11. CL_REPORT - 마감 리포트: 특정 마감의 스냅샷 건수/총매출. */
+void CL_REPORT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_cnt, h_gross;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL SELECT count(*), coalesce(sum(gross_amount),0) INTO :h_cnt, :h_gross
+ FROM cl_snapshot WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_gross);
+ OK(b);
+}
+
+/* 12. CL_ALERT - 미마감 경보: 당일 대사완료(M) 미전기 매입 건수. */
+void CL_ALERT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM purchase
+ WHERE biz_date = :h_bizdate AND status = 'M';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, (h_cnt > 0) ? 1 : 0);
+ if (h_cnt > 0) userlog("CL_ALERT %s 미마감 %ld건 감지", bizdate, h_cnt);
+ OK(b);
+}
+
+/* 13. CL_UNCLOSED - 미마감일 점검: close_log 없는 매입 영업일 건수. */
+void CL_UNCLOSED(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ EXEC SQL SELECT count(DISTINCT p.biz_date) INTO :h_cnt
+ FROM purchase p
+ WHERE NOT EXISTS (SELECT 1 FROM cl_close_log c
+ WHERE c.close_type = 'DAILY' AND c.biz_date = p.biz_date
+ AND c.status <> 'X');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 14. CL_APPROVE - 마감 승인: 상태 A. */
+void CL_APPROVE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long cid = getl(b, T_ID1);
+ if (cldb_update_close_status(cid, "A") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ OK(b);
+}
+
+/* 15. CL_LOCK - 기간 잠금: cl_period_lock upsert(locked=true) + 마감 L. */
+void CL_LOCK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char period[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid;
+ char h_period[16];
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ gets_(b, T_STR1, period, sizeof(period)); if (period[0] == 0) gets_(b, T_BIZDATE, period, sizeof(period));
+ strncpy(h_period, period, sizeof(h_period)-1); h_period[sizeof(h_period)-1] = 0;
+ EXEC SQL INSERT INTO cl_period_lock (period_key, locked) VALUES (:h_period, true)
+ ON CONFLICT (period_key) DO UPDATE SET locked = true, locked_at = now();
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cid > 0 && cldb_update_close_status(h_cid, "L") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "L", 0L);
+ OK(b);
+}
+
+/* 16. CL_UNLOCK - 기간 잠금 해제. */
+void CL_UNLOCK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char period[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_period[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR1, period, sizeof(period)); if (period[0] == 0) gets_(b, T_BIZDATE, period, sizeof(period));
+ strncpy(h_period, period, sizeof(h_period)-1); h_period[sizeof(h_period)-1] = 0;
+ EXEC SQL UPDATE cl_period_lock SET locked = false, locked_at = now()
+ WHERE period_key = :h_period;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, sqlca.sqlerrd[2]);
+ OK(b);
+}
+
+/* 17. CL_AGGREGATE - 마감 총계 재집계 후 close_log 반영. */
+void CL_AGGREGATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_cnt, h_amt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_cnt, :h_amt
+ FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (cldb_update_close_totals(h_cid, h_cnt, h_amt) < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ OK(b);
+}
+
+/* 18. CL_DIFFCHK - 차액 점검: close_log total vs 스냅샷 gross 합. */
+void CL_DIFFCHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_total, h_snap;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL SELECT total_amount INTO :h_total FROM cl_close_log WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ EXEC SQL SELECT coalesce(sum(gross_amount),0) INTO :h_snap
+ FROM cl_snapshot WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_DELTA, h_total - h_snap);
+ setl(b, T_RC, (h_total == h_snap) ? 0 : 1);
+ OK(b);
+}
+
+/* 19. CL_SUMMARY - 마감 요약: 스냅샷 gross/fee/net 합계 반환. */
+void CL_SUMMARY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_gross, h_fee, h_net;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL SELECT coalesce(sum(gross_amount),0), coalesce(sum(fee_amount),0),
+ coalesce(sum(net_amount),0)
+ INTO :h_gross, :h_fee, :h_net FROM cl_snapshot WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_GROSS, h_gross);
+ setl(b, T_FEE, h_fee);
+ setl(b, T_NET, h_net);
+ OK(b);
+}
+
+/* 20. CL_STATUS - 마감 상태 조회(dbio getter). */
+void CL_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long cid = getl(b, T_ID1);
+ char st[4];
+ if (cldb_get_close_status(cid, st) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, st, 0L);
+ OK(b);
+}
+
+/* 21. CL_ROLLBACK - 마감 롤백: 스냅샷 삭제 + 상태 O 로 복귀. */
+void CL_ROLLBACK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL DELETE FROM cl_snapshot WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, sqlca.sqlerrd[2]);
+ if (cldb_update_close_status(h_cid, "O") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "O", 0L);
+ OK(b);
+}
+
+/* 22. CL_CONFIRM - 마감 확정: 상태 C. */
+void CL_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long cid = getl(b, T_ID1);
+ if (cldb_update_close_status(cid, "C") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 23. CL_PERIOD - 기간 마감 개시(open): close_log 를 O 상태로 등록. */
+void CL_PERIOD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], period[16], ctype[16];
+ long cid;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_STR1, period, sizeof(period)); if (period[0] == 0) strcpy(period, bizdate);
+ gets_(b, T_STR2, ctype, sizeof(ctype)); if (ctype[0] == 0) strcpy(ctype, "DAILY");
+ cid = cldb_next_close_id();
+ if (cid < 0) FAIL(b);
+ if (cldb_insert_close(cid, ctype, period, bizdate, "O", 0, 0, "기간개시") < 0) FAIL(b);
+ setl(b, T_ID1, cid);
+ Bchg(b, T_STATUS, 0, "O", 0L);
+ OK(b);
+}
+
+/* 24. CL_YEAREND - 연말 마감: 연도(YYYY) 프리픽스로 집계. */
+void CL_YEAREND(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], period[16];
+ long cid = 0;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(period, bizdate, 4); period[4] = 0;
+ if (cl_open_close(b, "YEAREND", period, bizdate, &cid) < 0) FAIL(b);
+ setl(b, T_ID1, cid);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ userlog("CL_YEAREND cid=%ld 연도=%s 연마감 완료", cid, period);
+ OK(b);
+}
+
+/* 25. CL_MIDCLOSE - 중간 마감: 당일 중간 스냅 마감이력(MID) 생성. */
+void CL_MIDCLOSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt, h_cid;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_cnt, :h_amt
+ FROM purchase WHERE biz_date = :h_bizdate AND status IN ('A', 'M');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_cid = cldb_next_close_id();
+ if (h_cid < 0) FAIL(b);
+ if (cldb_insert_close(h_cid, "MID", bizdate, bizdate, "O", h_cnt, h_amt, "중간마감") < 0) FAIL(b);
+ setl(b, T_ID1, h_cid);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 26. CL_RECON - 마감 대사: 실제 매입건수 vs close_log txn_count 대사. */
+void CL_RECON(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_stored, h_actual, h_asum, h_tsum;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT txn_count, total_amount INTO :h_stored, :h_tsum
+ FROM cl_close_log WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_actual, :h_asum
+ FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_actual);
+ setl(b, T_DELTA, h_tsum - h_asum);
+ setl(b, T_RC, (h_stored == h_actual && h_tsum == h_asum) ? 0 : 1);
+ OK(b);
+}
+
+/* 27. CL_ARCHIVE - 마감 아카이브: 승인/완료 마감을 보관 상태로(memo 표시). */
+void CL_ARCHIVE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL UPDATE cl_close_log
+ SET memo = 'ARCHIVED', updated_at = now()
+ WHERE close_id = :h_cid AND status IN ('C', 'A', 'L');
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ setl(b, T_COUNT, sqlca.sqlerrd[2]);
+ OK(b);
+}
+
+/* 28. CL_PURGE - 취소 마감 정리: 오래된 X 상태 마감과 그 스냅샷 삭제. */
+void CL_PURGE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL DELETE FROM cl_snapshot WHERE close_id IN
+ (SELECT close_id FROM cl_close_log WHERE status = 'X' AND biz_date < :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL DELETE FROM cl_close_log WHERE status = 'X' AND biz_date < :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_cnt = sqlca.sqlerrd[2];
+ setl(b, T_COUNT, h_cnt);
+ userlog("CL_PURGE %s 이전 취소마감 %ld건 삭제", bizdate, h_cnt);
+ OK(b);
+}
+
+/* 29. CL_RESTORE - 마감 복원: 취소(X) 마감을 개시(O) 상태로 되돌림. */
+void CL_RESTORE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid;
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL UPDATE cl_close_log SET status = 'O', memo = 'RESTORED', updated_at = now()
+ WHERE close_id = :h_cid AND status = 'X';
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_STATUS, 0, "O", 0L);
+ OK(b);
+}
+
+/* 30. CL_INQUIRY - 마감 조회: close_type/기간/상태/총건수 반환. */
+void CL_INQUIRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid, h_cnt, h_amt;
+ char h_type[16], h_status[8];
+ EXEC SQL END DECLARE SECTION;
+ h_cid = getl(b, T_ID1);
+ EXEC SQL SELECT close_type, status, txn_count, total_amount
+ INTO :h_type, :h_status, :h_cnt, :h_amt
+ FROM cl_close_log WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_STR2, 0, h_type, 0L);
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/cl/dbio/cl_close_dbio.pgc b/app/src/cl/dbio/cl_close_dbio.pgc
new file mode 100644
index 0000000..79211a5
--- /dev/null
+++ b/app/src/cl/dbio/cl_close_dbio.pgc
@@ -0,0 +1,120 @@
+/*
+ * cl_close_dbio.pgc - 마감이력(close_log) + 순번관리 DB 접근 (part of libcldbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "cl_dbio.h"
+
+long cldb_next_close_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('cl_close_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("cldb_next_close_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int cldb_insert_close(long cid, const char *ctype, const char *period,
+ const char *bizdate, const char *status,
+ long txn_count, long total_amount, const char *memo)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid = cid, h_cnt = txn_count, h_amt = total_amount;
+ char h_type[16], h_period[16], h_bizdate[16], h_status[8], h_memo[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, ctype, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_period, period, sizeof(h_period) - 1); h_period[sizeof(h_period)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_memo, memo, sizeof(h_memo) - 1); h_memo[sizeof(h_memo)-1] = 0;
+
+ EXEC SQL INSERT INTO cl_close_log
+ (close_id, close_type, period_key, biz_date, status, txn_count, total_amount, memo)
+ VALUES (:h_cid, :h_type, :h_period, :h_bizdate, :h_status, :h_cnt, :h_amt, :h_memo);
+ if (sqlca.sqlcode < 0) {
+ userlog("cldb_insert_close FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int cldb_update_close_status(long cid, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid = cid;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE cl_close_log SET status = :h_status, updated_at = now()
+ WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("cldb_update_close_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int cldb_get_close_status(long cid, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid = cid;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT status INTO :h_status FROM cl_close_log WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("cldb_get_close_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ out[0] = h_status[0];
+ out[1] = 0;
+ return 0;
+}
+
+int cldb_update_close_totals(long cid, long txn_count, long total_amount)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cid = cid, h_cnt = txn_count, h_amt = total_amount;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL UPDATE cl_close_log
+ SET txn_count = :h_cnt, total_amount = :h_amt, updated_at = now()
+ WHERE close_id = :h_cid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("cldb_update_close_totals FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+/* close_type 별 마지막 순번을 1 증가시켜 반환 (upsert). */
+long cldb_bump_seq(const char *ctype)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_seq;
+ char h_type[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, ctype, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+
+ EXEC SQL INSERT INTO cl_seq_reg (close_type, last_seq)
+ VALUES (:h_type, 1)
+ ON CONFLICT (close_type) DO UPDATE
+ SET last_seq = cl_seq_reg.last_seq + 1, updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("cldb_bump_seq FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ EXEC SQL SELECT last_seq INTO :h_seq FROM cl_seq_reg WHERE close_type = :h_type;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) return -1;
+ return h_seq;
+}
diff --git a/app/src/cl/dbio/cl_dbio.h b/app/src/cl/dbio/cl_dbio.h
new file mode 100644
index 0000000..b8c9729
--- /dev/null
+++ b/app/src/cl/dbio/cl_dbio.h
@@ -0,0 +1,28 @@
+/*
+ * cl_dbio.h - cl 모듈 (마감/closing) DB 접근 계층 (libcldbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * cl_svr and the cl batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef CL_DBIO_H
+#define CL_DBIO_H
+
+/* 마감 이력 (close_log) */
+long cldb_next_close_id(void);
+int cldb_insert_close(long cid, const char *ctype, const char *period,
+ const char *bizdate, const char *status,
+ long txn_count, long total_amount, const char *memo);
+int cldb_update_close_status(long cid, const char *status);
+int cldb_get_close_status(long cid, char *out /* >= 2 bytes */);
+int cldb_update_close_totals(long cid, long txn_count, long total_amount);
+
+/* 마감 스냅샷 (snapshot) */
+long cldb_next_snapshot_id(void);
+int cldb_insert_snapshot(long sid, long cid, const char *period, const char *bizdate,
+ const char *merch, long gross, long fee, long net, long cnt);
+
+/* 마감 순번 관리 (seq registry) */
+long cldb_bump_seq(const char *ctype);
+
+#endif /* CL_DBIO_H */
diff --git a/app/src/cl/dbio/cl_snapshot_dbio.pgc b/app/src/cl/dbio/cl_snapshot_dbio.pgc
new file mode 100644
index 0000000..4c2383f
--- /dev/null
+++ b/app/src/cl/dbio/cl_snapshot_dbio.pgc
@@ -0,0 +1,45 @@
+/*
+ * cl_snapshot_dbio.pgc - 마감 스냅샷(snapshot) DB 접근 (part of libcldbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "cl_dbio.h"
+
+long cldb_next_snapshot_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('cl_snapshot_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("cldb_next_snapshot_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int cldb_insert_snapshot(long sid, long cid, const char *period, const char *bizdate,
+ const char *merch, long gross, long fee, long net, long cnt)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_sid = sid, h_cid = cid, h_gross = gross, h_fee = fee, h_net = net, h_cnt = cnt;
+ char h_period[16], h_bizdate[16], h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_period, period, sizeof(h_period) - 1); h_period[sizeof(h_period)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+
+ EXEC SQL INSERT INTO cl_snapshot
+ (snapshot_id, close_id, period_key, biz_date, merchant_id,
+ gross_amount, fee_amount, net_amount, txn_count)
+ VALUES (:h_sid, :h_cid, :h_period, :h_bizdate, :h_merch,
+ :h_gross, :h_fee, :h_net, :h_cnt);
+ if (sqlca.sqlcode < 0) {
+ userlog("cldb_insert_snapshot FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/cm/batch/cm_bizday_roll_batch.pgc b/app/src/cm/batch/cm_bizday_roll_batch.pgc
new file mode 100644
index 0000000..ffc035c
--- /dev/null
+++ b/app/src/cm/batch/cm_bizday_roll_batch.pgc
@@ -0,0 +1,45 @@
+/*
+ * cm_bizday_roll_batch.pgc - cm 영업일 롤 배치 (XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction. Reads cm_param BIZ_DATE, advances it to the next 영업일 (주말=common
+ * util로 skip, 휴일=cm_holiday로 skip), writes it back via the dbio layer, then
+ * tpcommit drives XA 2PC. Usage: cm_bizday_roll_batch
+ */
+#include
+#include
+#include
+#include
+#include "cm_dbio.h"
+#include "acq_common.h"
+
+int main(int argc, char **argv)
+{
+ char cur[16], nxt[16];
+ int hol, guard = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ if (cmdb_get_param("BIZ_DATE", cur) < 0) {
+ fprintf(stderr, "BIZ_DATE 조회 실패\n"); tpabort(0); return 1;
+ }
+
+ /* 다음 영업일: 주말 + 휴일 모두 건너뜀 */
+ do {
+ if (acq_next_bizday(cur, nxt) < 0) { fprintf(stderr, "next_bizday 파싱 실패: %s\n", cur); tpabort(0); return 1; }
+ strncpy(cur, nxt, sizeof(cur)-1); cur[sizeof(cur)-1] = 0;
+ hol = cmdb_is_holiday(nxt);
+ if (hol < 0) { tpabort(0); return 1; }
+ if (++guard > 30) break;
+ } while (hol == 1);
+
+ if (cmdb_set_param("BIZ_DATE", nxt) < 0) { tpabort(0); return 1; }
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> cm_bizday_roll_batch COMMIT: BIZ_DATE -> %s\n", nxt);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/cm/batch/cm_purge_batch.pgc b/app/src/cm/batch/cm_purge_batch.pgc
new file mode 100644
index 0000000..74a6f84
--- /dev/null
+++ b/app/src/cm/batch/cm_purge_batch.pgc
@@ -0,0 +1,58 @@
+/*
+ * cm_purge_batch.pgc - cm 공통로그 보존기간 경과분 삭제 배치 (XA).
+ *
+ * ATMI client: opens the ECPG XA RM, begins ONE global transaction, deletes
+ * cm_log rows older than cm_param LOG_KEEP_DAYS (기본 90일) from a cutoff biz_date,
+ * then tpcommit drives XA 2PC. Usage: cm_purge_batch [YYYY-MM-DD(기준일)]
+ */
+#include
+#include
+#include
+#include
+#include "cm_dbio.h"
+
+int main(int argc, char **argv)
+{
+ char keep_s[128];
+ long keep_days = 90;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_asof[16];
+ long h_keep, h_deleted;
+ EXEC SQL END DECLARE SECTION;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ /* 보존일수는 파라미터에서 (없으면 90일 기본) */
+ if (cmdb_get_param("LOG_KEEP_DAYS", keep_s) == 0) {
+ long v = atol(keep_s);
+ if (v > 0) keep_days = v;
+ }
+ h_keep = keep_days;
+
+ /* 기준일: 인자 우선, 없으면 cm_param BIZ_DATE */
+ if (argc > 1) {
+ strncpy(h_asof, argv[1], sizeof(h_asof)-1); h_asof[sizeof(h_asof)-1] = 0;
+ } else if (cmdb_get_param("BIZ_DATE", keep_s) == 0) {
+ strncpy(h_asof, keep_s, sizeof(h_asof)-1); h_asof[sizeof(h_asof)-1] = 0;
+ } else {
+ strcpy(h_asof, "2026-07-19");
+ }
+
+ EXEC SQL DELETE FROM cm_log
+ WHERE biz_date < (CAST(:h_asof AS date) - (:h_keep || ' days')::interval);
+ if (sqlca.sqlcode < 0) {
+ fprintf(stderr, "DELETE FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ tpabort(0); return 1;
+ }
+ h_deleted = sqlca.sqlerrd[2];
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> cm_purge_batch COMMIT: asof=%s keep_days=%ld deleted=%ld\n",
+ h_asof, keep_days, h_deleted);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/cm/cm_svr.pgc b/app/src/cm/cm_svr.pgc
new file mode 100644
index 0000000..756b89d
--- /dev/null
+++ b/app/src/cm/cm_svr.pgc
@@ -0,0 +1,554 @@
+/*
+ * cm_svr.pgc - cm (공통 / common) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online cm services (the real Tuxedo
+ * "one module server, many services" pattern). Services are the shared utilities
+ * every other module leans on: 공통코드/파라미터 조회 (EXEC SQL against cm_code /
+ * cm_param), 영업일/휴일 계산, 금액 반올림·분할·포맷, 환율 환산, Luhn/CRC/체크디지트,
+ * 이자·일할·네팅 계산, 채번, 공통 로그. Compute-only services just Bget->compute->
+ * Bchg with no SQL; DB-backed ones use the linked libcmdbio.a layer or直접 EXEC SQL.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch libndrxxaecpg.so);
+ * there is NO EXEC SQL CONNECT. Each tpcall runs on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "cm_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+static void sets_(UBFH *b, BFLDID f, const char *s) { Bchg(b, f, 0, (char *)s, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- module-local pure-compute date helpers ---------------------------- */
+static int cm_is_leap(int y) { return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); }
+static int cm_mdays(int y, int m)
+{
+ static const int d[] = {31,28,31,30,31,30,31,31,30,31,30,31};
+ if (m < 1 || m > 12) return 30;
+ if (m == 2 && cm_is_leap(y)) return 29;
+ return d[m - 1];
+}
+/* Sakamoto: 0=Sun..6=Sat */
+static int cm_dow(int y, int m, int d)
+{
+ static int t[] = {0,3,2,5,0,3,5,1,4,6,2,4};
+ if (m < 3) y -= 1;
+ return (y + y/4 - y/100 + y/400 + t[m-1] + d) % 7;
+}
+/* CRC-16/CCITT-FALSE over a string. */
+static unsigned int cm_crc16(const char *s)
+{
+ unsigned int crc = 0xFFFF; int i, j;
+ for (i = 0; s[i]; i++) {
+ crc ^= (unsigned int)(unsigned char)s[i] << 8;
+ for (j = 0; j < 8; j++)
+ crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF;
+ }
+ return crc & 0xFFFF;
+}
+
+/* ----- service prototypes ------------------------------------------------ */
+void CM_CODE(TPSVCINFO *p); void CM_CODENAME(TPSVCINFO *p);
+void CM_BIZDAY(TPSVCINFO *p); void CM_NEXTBIZ(TPSVCINFO *p);
+void CM_ADDDAYS(TPSVCINFO *p); void CM_WEEKDAY(TPSVCINFO *p);
+void CM_MONTHEND(TPSVCINFO *p); void CM_AMT_ROUND(TPSVCINFO *p);
+void CM_AMT_SPLIT(TPSVCINFO *p); void CM_SEQ(TPSVCINFO *p);
+void CM_FX(TPSVCINFO *p); void CM_FX_CONV(TPSVCINFO *p);
+void CM_LUHN(TPSVCINFO *p); void CM_CRC(TPSVCINFO *p);
+void CM_MASK(TPSVCINFO *p); void CM_FMT_DATE(TPSVCINFO *p);
+void CM_FMT_AMT(TPSVCINFO *p); void CM_PARAM(TPSVCINFO *p);
+void CM_PARAM_SET(TPSVCINFO *p); void CM_CACHE_LOAD(TPSVCINFO *p);
+void CM_CACHE_INV(TPSVCINFO *p); void CM_ERRMSG(TPSVCINFO *p);
+void CM_LOG(TPSVCINFO *p); void CM_HOLIDAY_CHK(TPSVCINFO *p);
+void CM_CHECKDIGIT(TPSVCINFO *p); void CM_INTEREST(TPSVCINFO *p);
+void CM_PRORATE(TPSVCINFO *p); void CM_NETTING_CALC(TPSVCINFO *p);
+void CM_STATUS(TPSVCINFO *p); void CM_PING(TPSVCINFO *p);
+
+/* ----- advertise table --------------------------------------------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"CM_CODE", CM_CODE}, {"CM_CODENAME", CM_CODENAME},
+ {"CM_BIZDAY", CM_BIZDAY}, {"CM_NEXTBIZ", CM_NEXTBIZ},
+ {"CM_ADDDAYS", CM_ADDDAYS}, {"CM_WEEKDAY", CM_WEEKDAY},
+ {"CM_MONTHEND", CM_MONTHEND}, {"CM_AMT_ROUND", CM_AMT_ROUND},
+ {"CM_AMT_SPLIT", CM_AMT_SPLIT}, {"CM_SEQ", CM_SEQ},
+ {"CM_FX", CM_FX}, {"CM_FX_CONV", CM_FX_CONV},
+ {"CM_LUHN", CM_LUHN}, {"CM_CRC", CM_CRC},
+ {"CM_MASK", CM_MASK}, {"CM_FMT_DATE", CM_FMT_DATE},
+ {"CM_FMT_AMT", CM_FMT_AMT}, {"CM_PARAM", CM_PARAM},
+ {"CM_PARAM_SET", CM_PARAM_SET}, {"CM_CACHE_LOAD", CM_CACHE_LOAD},
+ {"CM_CACHE_INV", CM_CACHE_INV}, {"CM_ERRMSG", CM_ERRMSG},
+ {"CM_LOG", CM_LOG}, {"CM_HOLIDAY_CHK", CM_HOLIDAY_CHK},
+ {"CM_CHECKDIGIT", CM_CHECKDIGIT},{"CM_INTEREST", CM_INTEREST},
+ {"CM_PRORATE", CM_PRORATE}, {"CM_NETTING_CALC", CM_NETTING_CALC},
+ {"CM_STATUS", CM_STATUS}, {"CM_PING", CM_PING},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("cm_svr: tpopen FAIL: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("cm_svr: tpadvertise(%s) FAIL: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("cm_svr: %d cm 공통 서비스 광고 완료, RM 오픈", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("cm_svr: tpsvrdone");
+}
+
+/* ========================================================================= */
+/* 1. CM_CODE - 공통코드 존재 확인 (EXEC SQL via dbio). */
+/* ========================================================================= */
+void CM_CODE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32], code[32];
+ int ex;
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "CCY");
+ gets_(b, T_STR2, code, sizeof(code));
+ ex = cmdb_code_exists(grp, code);
+ if (ex < 0) FAIL(b);
+ setl(b, T_RC, ex);
+ OK(b);
+}
+
+/* 2. CM_CODENAME - 공통코드 명 조회 (EXEC SQL). */
+void CM_CODENAME(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32], code[32], name[128];
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "CCY");
+ gets_(b, T_STR2, code, sizeof(code));
+ if (cmdb_get_code_name(grp, code, name) < 0) {
+ userlog("CM_CODENAME 미존재 grp=%s code=%s", grp, code);
+ FAIL(b);
+ }
+ sets_(b, T_STR3, name);
+ OK(b);
+}
+
+/* 3. CM_BIZDAY - 현재 영업일 조회 (cm_param BIZ_DATE). */
+void CM_BIZDAY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char val[128];
+ if (cmdb_get_param("BIZ_DATE", val) < 0) FAIL(b);
+ sets_(b, T_BIZDATE, val);
+ OK(b);
+}
+
+/* 4. CM_NEXTBIZ - 다음 영업일 (주말 skip=common util + 휴일 skip=cm_holiday). */
+void CM_NEXTBIZ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char in[16], cur[16], nxt[16];
+ int hol, guard = 0;
+ gets_(b, T_STR1, in, sizeof(in));
+ if (in[0] == 0) { if (cmdb_get_param("BIZ_DATE", in) < 0) strcpy(in, "2026-07-19"); }
+ strncpy(cur, in, sizeof(cur)-1); cur[sizeof(cur)-1] = 0;
+ do {
+ if (acq_next_bizday(cur, nxt) < 0) FAIL(b); /* 주말 건너뜀 */
+ strncpy(cur, nxt, sizeof(cur)-1); cur[sizeof(cur)-1] = 0;
+ hol = cmdb_is_holiday(nxt);
+ if (hol < 0) FAIL(b);
+ if (++guard > 30) break;
+ } while (hol == 1); /* 휴일이면 다시 +1영업일 */
+ sets_(b, T_STR3, nxt);
+ OK(b);
+}
+
+/* 5. CM_ADDDAYS - 달력일 N일 가산 (순수 계산). */
+void CM_ADDDAYS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char in[16], out[16];
+ long n, i;
+ int y, m, d;
+ gets_(b, T_STR1, in, sizeof(in));
+ n = getl(b, T_ARG1);
+ if (sscanf(in, "%d-%d-%d", &y, &m, &d) != 3) FAIL(b);
+ for (i = 0; i < n; i++) {
+ d++;
+ if (d > cm_mdays(y, m)) { d = 1; if (++m > 12) { m = 1; y++; } }
+ }
+ sprintf(out, "%04d-%02d-%02d", y, m, d);
+ sets_(b, T_STR3, out);
+ OK(b);
+}
+
+/* 6. CM_WEEKDAY - 요일 계산 (순수 계산): 0=일..6=토. */
+void CM_WEEKDAY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ static const char *KOR[] = {"일","월","화","수","목","금","토"};
+ char in[16];
+ int y, m, d, w;
+ gets_(b, T_STR1, in, sizeof(in));
+ if (sscanf(in, "%d-%d-%d", &y, &m, &d) != 3) FAIL(b);
+ w = cm_dow(y, m, d);
+ setl(b, T_ARG1, w);
+ sets_(b, T_STR3, KOR[w]);
+ setl(b, T_RC, (w == 0 || w == 6) ? 1 : 0); /* 1=주말 */
+ OK(b);
+}
+
+/* 7. CM_MONTHEND - 월말일 계산 (순수 계산). */
+void CM_MONTHEND(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char in[16], out[16];
+ int y, m, d, last;
+ gets_(b, T_STR1, in, sizeof(in));
+ if (sscanf(in, "%d-%d-%d", &y, &m, &d) != 3) FAIL(b);
+ last = cm_mdays(y, m);
+ sprintf(out, "%04d-%02d-%02d", y, m, last);
+ sets_(b, T_STR3, out);
+ setl(b, T_ARG1, last);
+ OK(b);
+}
+
+/* 8. CM_AMT_ROUND - 금액 반올림 (순수 계산): 단위(T_ARG1)로 반올림. */
+void CM_AMT_ROUND(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long amt = getl(b, T_AMOUNT), unit = getl(b, T_ARG1), r;
+ if (unit <= 0) unit = 1;
+ r = ((amt + unit / 2) / unit) * unit;
+ setl(b, T_AMT1, r);
+ OK(b);
+}
+
+/* 9. CM_AMT_SPLIT - 금액 균등 분할 (순수 계산): n등분 + 잔액. */
+void CM_AMT_SPLIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long amt = getl(b, T_AMOUNT), n = getl(b, T_ARG1), each, rem;
+ if (n < 1) n = 1;
+ each = amt / n;
+ rem = amt - each * n; /* 첫 회차에 잔액 가산 */
+ setl(b, T_AMT1, each);
+ setl(b, T_AMT2, rem);
+ setl(b, T_COUNT, n);
+ OK(b);
+}
+
+/* 10. CM_SEQ - 공통 채번 (cm_seq 이름있는 카운터). */
+void CM_SEQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char name[32];
+ long v;
+ gets_(b, T_KEY1, name, sizeof(name)); if (name[0] == 0) strcpy(name, "BATCH");
+ v = cmdb_next_seq(name);
+ if (v < 0) FAIL(b);
+ setl(b, T_ARG1, v);
+ OK(b);
+}
+
+/* 11. CM_FX - 환율 조회 (cm_fx_rate). */
+void CM_FX(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ccy[8], bizdate[16];
+ long rate;
+ gets_(b, T_CCY, ccy, sizeof(ccy)); if (ccy[0] == 0) strcpy(ccy, "USD");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (bizdate[0] == 0) { if (cmdb_get_param("BIZ_DATE", bizdate) < 0) strcpy(bizdate, "2026-07-19"); }
+ if (cmdb_get_fx_rate(ccy, bizdate, &rate) < 0) { userlog("CM_FX 환율 없음 ccy=%s", ccy); FAIL(b); }
+ setl(b, T_ARG1, rate);
+ OK(b);
+}
+
+/* 12. CM_FX_CONV - 환율 환산 (cm_fx_rate + compute): 외화 -> 원화. */
+void CM_FX_CONV(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ccy[8], bizdate[16];
+ long rate, fx, krw;
+ gets_(b, T_CCY, ccy, sizeof(ccy)); if (ccy[0] == 0) strcpy(ccy, "USD");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (bizdate[0] == 0) { if (cmdb_get_param("BIZ_DATE", bizdate) < 0) strcpy(bizdate, "2026-07-19"); }
+ fx = getl(b, T_FX_AMT);
+ if (cmdb_get_fx_rate(ccy, bizdate, &rate) < 0) FAIL(b);
+ krw = fx * rate / 100; /* rate_bps = KRW*100 per unit */
+ setl(b, T_AMOUNT, krw);
+ setl(b, T_ARG1, rate);
+ OK(b);
+}
+
+/* 13. CM_LUHN - 카드번호 Luhn 검증 (순수 계산, common util). */
+void CM_LUHN(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char pan[32];
+ gets_(b, T_STR5, pan, sizeof(pan));
+ setl(b, T_RC, acq_luhn_ok(pan) ? 0 : 1); /* 0=유효 */
+ OK(b);
+}
+
+/* 14. CM_CRC - CRC16 계산 (순수 계산). */
+void CM_CRC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char in[256], out[8];
+ unsigned int crc;
+ gets_(b, T_STR1, in, sizeof(in));
+ crc = cm_crc16(in);
+ sprintf(out, "%04X", crc);
+ sets_(b, T_STR4, out);
+ setl(b, T_ARG1, (long)crc);
+ OK(b);
+}
+
+/* 15. CM_MASK - 카드번호 마스킹 (순수 계산): 앞6 뒤4 노출. */
+void CM_MASK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char pan[32], out[32];
+ int n, i;
+ gets_(b, T_STR5, pan, sizeof(pan));
+ n = (int)strlen(pan);
+ for (i = 0; i < n && i < (int)sizeof(out) - 1; i++)
+ out[i] = (i < 6 || i >= n - 4) ? pan[i] : '*';
+ out[(n < (int)sizeof(out)-1) ? n : (int)sizeof(out)-1] = 0;
+ sets_(b, T_STR4, out);
+ OK(b);
+}
+
+/* 16. CM_FMT_DATE - 날짜 포맷 변환 (순수 계산): YYYYMMDD -> YYYY-MM-DD. */
+void CM_FMT_DATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char in[16], out[16];
+ int y, m, d;
+ gets_(b, T_STR1, in, sizeof(in));
+ if (strlen(in) == 8 && sscanf(in, "%4d%2d%2d", &y, &m, &d) == 3)
+ sprintf(out, "%04d-%02d-%02d", y, m, d);
+ else if (sscanf(in, "%d-%d-%d", &y, &m, &d) == 3)
+ sprintf(out, "%04d%02d%02d", y, m, d); /* 역변환 */
+ else FAIL(b);
+ sets_(b, T_STR3, out);
+ OK(b);
+}
+
+/* 17. CM_FMT_AMT - 금액 천단위 콤마 포맷 (순수 계산). */
+void CM_FMT_AMT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char raw[32], out[48];
+ long amt = getl(b, T_AMOUNT);
+ int n, i, j, k, neg = 0;
+ if (amt < 0) { neg = 1; amt = -amt; }
+ sprintf(raw, "%ld", amt);
+ n = (int)strlen(raw);
+ j = 0;
+ if (neg) out[j++] = '-';
+ for (i = 0; i < n; i++) {
+ out[j++] = raw[i];
+ k = n - i - 1;
+ if (k > 0 && k % 3 == 0) out[j++] = ',';
+ }
+ out[j] = 0;
+ sets_(b, T_STR4, out);
+ OK(b);
+}
+
+/* 18. CM_PARAM - 공통 파라미터 조회. */
+void CM_PARAM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char key[64], val[128];
+ gets_(b, T_STR1, key, sizeof(key));
+ if (key[0] == 0) FAIL(b);
+ if (cmdb_get_param(key, val) < 0) { userlog("CM_PARAM 미존재 key=%s", key); FAIL(b); }
+ sets_(b, T_STR3, val);
+ OK(b);
+}
+
+/* 19. CM_PARAM_SET - 공통 파라미터 설정 (owner-writes, upsert). */
+void CM_PARAM_SET(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char key[64], val[128];
+ gets_(b, T_STR1, key, sizeof(key));
+ gets_(b, T_STR3, val, sizeof(val));
+ if (key[0] == 0) FAIL(b);
+ if (cmdb_set_param(key, val) < 0) FAIL(b);
+ userlog("CM_PARAM_SET %s=%s", key, val);
+ OK(b);
+}
+
+/* 20. CM_CACHE_LOAD - 코드 그룹 캐시 로드 (건수 조회로 로딩 시뮬레이션). */
+void CM_CACHE_LOAD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32];
+ long cnt;
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "CCY");
+ cnt = cmdb_count_codes(grp);
+ if (cnt < 0) FAIL(b);
+ setl(b, T_COUNT, cnt);
+ userlog("CM_CACHE_LOAD grp=%s codes=%ld", grp, cnt);
+ OK(b);
+}
+
+/* 21. CM_CACHE_INV - 캐시 무효화 (순수 처리: 버전 증가 표시). */
+void CM_CACHE_INV(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32];
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "ALL");
+ setl(b, T_RC, 0);
+ sets_(b, T_MSG, "캐시 무효화 완료");
+ userlog("CM_CACHE_INV grp=%s", grp);
+ OK(b);
+}
+
+/* 22. CM_ERRMSG - 오류코드 -> 메시지 매핑 (cm_code 그룹 STATUS/ERR 조회). */
+void CM_ERRMSG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32], code[32], name[128];
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "STATUS");
+ gets_(b, T_STR2, code, sizeof(code));
+ if (cmdb_get_code_name(grp, code, name) < 0)
+ strcpy(name, "알 수 없는 코드"); /* 미등록 시 기본 메시지 */
+ sets_(b, T_MSG, name);
+ OK(b);
+}
+
+/* 23. CM_LOG - 공통 로그 기록 (cm_log). */
+void CM_LOG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char svc[64], msg[256], bizdate[16];
+ long id;
+ gets_(b, T_SVCNAME, svc, sizeof(svc)); if (svc[0] == 0) strcpy(svc, "CM");
+ gets_(b, T_MSG, msg, sizeof(msg));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (bizdate[0] == 0) { if (cmdb_get_param("BIZ_DATE", bizdate) < 0) strcpy(bizdate, "2026-07-19"); }
+ id = cmdb_log(svc, "I", msg, bizdate);
+ if (id < 0) FAIL(b);
+ setl(b, T_ID1, id);
+ OK(b);
+}
+
+/* 24. CM_HOLIDAY_CHK - 휴일 여부 확인 (cm_holiday). */
+void CM_HOLIDAY_CHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char date[16];
+ int h;
+ gets_(b, T_STR1, date, sizeof(date));
+ if (date[0] == 0) FAIL(b);
+ h = cmdb_is_holiday(date);
+ if (h < 0) FAIL(b);
+ setl(b, T_RC, h); /* 1=휴일, 0=평일 */
+ OK(b);
+}
+
+/* 25. CM_CHECKDIGIT - Luhn 체크디지트 생성 (순수 계산). */
+void CM_CHECKDIGIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char base[32], cand[34];
+ int d, found = -1, n;
+ gets_(b, T_STR2, base, sizeof(base));
+ n = (int)strlen(base);
+ if (n == 0 || n >= (int)sizeof(cand) - 1) FAIL(b);
+ for (d = 0; d <= 9; d++) {
+ strcpy(cand, base);
+ cand[n] = (char)('0' + d);
+ cand[n + 1] = 0;
+ if (acq_luhn_ok(cand)) { found = d; break; }
+ }
+ if (found < 0) FAIL(b);
+ setl(b, T_ARG1, found);
+ sets_(b, T_STR4, cand); /* 체크디지트 포함 완성번호 */
+ OK(b);
+}
+
+/* 26. CM_INTEREST - 단리 이자 계산 (순수 계산): 원금*연이율(bps)*일수/(10000*365). */
+void CM_INTEREST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long principal = getl(b, T_AMOUNT), rate_bps = getl(b, T_ARG1), days = getl(b, T_ARG2), interest;
+ if (principal < 0 || rate_bps < 0 || days < 0) FAIL(b);
+ interest = principal * rate_bps / 10000 * days / 365;
+ setl(b, T_AMT1, interest);
+ OK(b);
+}
+
+/* 27. CM_PRORATE - 일할 계산 (순수 계산): 금액*사용일/총일. */
+void CM_PRORATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long amt = getl(b, T_AMOUNT), used = getl(b, T_ARG1), total = getl(b, T_ARG2), pro;
+ if (total <= 0) FAIL(b);
+ if (used < 0) used = 0;
+ if (used > total) used = total;
+ pro = amt * used / total;
+ setl(b, T_AMT1, pro);
+ OK(b);
+}
+
+/* 28. CM_NETTING_CALC - 네팅 계산 (순수 계산): 차변-대변. */
+void CM_NETTING_CALC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long debit = getl(b, T_AMT1), credit = getl(b, T_AMT2), net;
+ net = debit - credit;
+ setl(b, T_NET, net);
+ setl(b, T_RC, net > 0 ? 1 : (net < 0 ? -1 : 0)); /* 1=수취, -1=지급 */
+ OK(b);
+}
+
+/* 29. CM_STATUS - 모듈 상태: 파라미터 건수 조회로 DB 연결 확인. */
+void CM_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM cm_param;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, 0);
+ sets_(b, T_STATUS, "UP");
+ OK(b);
+}
+
+/* 30. CM_PING - 핑 (순수 처리). */
+void CM_PING(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ sets_(b, T_MSG, "PONG");
+ setl(b, T_RC, 0);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/cm/dbio/cm_code_dbio.pgc b/app/src/cm/dbio/cm_code_dbio.pgc
new file mode 100644
index 0000000..ded04ce
--- /dev/null
+++ b/app/src/cm/dbio/cm_code_dbio.pgc
@@ -0,0 +1,126 @@
+/*
+ * cm_code_dbio.pgc - 공통코드/환율/휴일 DB 접근 (part of libcmdbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "cm_dbio.h"
+
+int cmdb_get_code_name(const char *grp, const char *code, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_grp[32], h_code[32], h_name[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ strncpy(h_code, code, sizeof(h_code)-1); h_code[sizeof(h_code)-1] = 0;
+
+ EXEC SQL SELECT code_name INTO :h_name FROM cm_code
+ WHERE code_grp = :h_grp AND code = :h_code AND use_yn = 'Y';
+ if (sqlca.sqlcode == 100) { out[0] = 0; return -1; }
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_get_code_name FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ strncpy(out, h_name, 127); out[127] = 0;
+ return 0;
+}
+
+int cmdb_code_exists(const char *grp, const char *code)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_grp[32], h_code[32];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ strncpy(h_code, code, sizeof(h_code)-1); h_code[sizeof(h_code)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM cm_code
+ WHERE code_grp = :h_grp AND code = :h_code;
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_code_exists FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_cnt > 0 ? 1 : 0;
+}
+
+int cmdb_insert_code(const char *grp, const char *code, const char *name, long sort)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_sort = sort;
+ char h_grp[32], h_code[32], h_name[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ strncpy(h_code, code, sizeof(h_code)-1); h_code[sizeof(h_code)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+
+ EXEC SQL INSERT INTO cm_code (code_grp, code, code_name, sort_ord)
+ VALUES (:h_grp, :h_code, :h_name, :h_sort)
+ ON CONFLICT (code_grp, code) DO UPDATE
+ SET code_name = EXCLUDED.code_name, sort_ord = EXCLUDED.sort_ord, updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_insert_code FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+long cmdb_count_codes(const char *grp)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_grp[32];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM cm_code
+ WHERE code_grp = :h_grp AND use_yn = 'Y';
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_count_codes FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_cnt;
+}
+
+int cmdb_get_fx_rate(const char *ccy, const char *base_date, long *rate_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_rate;
+ char h_ccy[8], h_date[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_ccy, ccy, sizeof(h_ccy)-1); h_ccy[sizeof(h_ccy)-1] = 0;
+ strncpy(h_date, base_date, sizeof(h_date)-1); h_date[sizeof(h_date)-1] = 0;
+
+ /* 요청일 이하 가장 최근 기준일 환율 */
+ EXEC SQL SELECT rate_bps INTO :h_rate FROM cm_fx_rate
+ WHERE ccy = :h_ccy AND base_date <= :h_date
+ ORDER BY base_date DESC LIMIT 1;
+ if (sqlca.sqlcode == 100) return -1;
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_get_fx_rate FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ *rate_out = h_rate;
+ return 0;
+}
+
+int cmdb_is_holiday(const char *date)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_date[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_date, date, sizeof(h_date)-1); h_date[sizeof(h_date)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM cm_holiday WHERE holi_date = :h_date;
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_is_holiday FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_cnt > 0 ? 1 : 0;
+}
diff --git a/app/src/cm/dbio/cm_dbio.h b/app/src/cm/dbio/cm_dbio.h
new file mode 100644
index 0000000..e7db49c
--- /dev/null
+++ b/app/src/cm/dbio/cm_dbio.h
@@ -0,0 +1,33 @@
+/*
+ * cm_dbio.h - cm 모듈 (공통) DB 접근 계층 (libcmdbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * cm_svr and the cm batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error / not found (caller inspects sqlca).
+ */
+#ifndef CM_DBIO_H
+#define CM_DBIO_H
+
+/* 공통코드 (cm_code) */
+int cmdb_get_code_name(const char *grp, const char *code, char *out /* >= 128 */);
+int cmdb_code_exists(const char *grp, const char *code); /* 1=존재, 0=없음, -1=오류 */
+int cmdb_insert_code(const char *grp, const char *code, const char *name, long sort);
+long cmdb_count_codes(const char *grp);
+
+/* 공통 파라미터 (cm_param) */
+int cmdb_get_param(const char *key, char *out /* >= 128 */);
+int cmdb_set_param(const char *key, const char *val);
+
+/* 채번 (cm_seq) - 이름있는 카운터 +1 -> 새 값 */
+long cmdb_next_seq(const char *name);
+
+/* 환율 (cm_fx_rate) - 통화의 최신 기준일 rate_bps 조회 */
+int cmdb_get_fx_rate(const char *ccy, const char *base_date, long *rate_out);
+
+/* 휴일 (cm_holiday) - 1=휴일, 0=평일, -1=오류 */
+int cmdb_is_holiday(const char *date);
+
+/* 공통 로그 (cm_log) */
+long cmdb_log(const char *svc, const char *level, const char *msg, const char *bizdate);
+
+#endif /* CM_DBIO_H */
diff --git a/app/src/cm/dbio/cm_param_dbio.pgc b/app/src/cm/dbio/cm_param_dbio.pgc
new file mode 100644
index 0000000..2b62cff
--- /dev/null
+++ b/app/src/cm/dbio/cm_param_dbio.pgc
@@ -0,0 +1,93 @@
+/*
+ * cm_param_dbio.pgc - 공통 파라미터/채번/공통로그 DB 접근 (part of libcmdbio.a).
+ */
+#include
+#include
+#include "cm_dbio.h"
+
+int cmdb_get_param(const char *key, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_key[64], h_val[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_key, key, sizeof(h_key)-1); h_key[sizeof(h_key)-1] = 0;
+
+ EXEC SQL SELECT param_val INTO :h_val FROM cm_param WHERE param_key = :h_key;
+ if (sqlca.sqlcode == 100) { out[0] = 0; return -1; }
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_get_param FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ strncpy(out, h_val, 127); out[127] = 0;
+ return 0;
+}
+
+int cmdb_set_param(const char *key, const char *val)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_key[64], h_val[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_key, key, sizeof(h_key)-1); h_key[sizeof(h_key)-1] = 0;
+ strncpy(h_val, val, sizeof(h_val)-1); h_val[sizeof(h_val)-1] = 0;
+
+ EXEC SQL INSERT INTO cm_param (param_key, param_val) VALUES (:h_key, :h_val)
+ ON CONFLICT (param_key) DO UPDATE
+ SET param_val = EXCLUDED.param_val, updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_set_param FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+/* cm_seq 이름있는 카운터를 원자적으로 +1 하고 새 값을 돌려준다. */
+long cmdb_next_seq(const char *name)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_val;
+ char h_name[32];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+
+ EXEC SQL UPDATE cm_seq SET seq_val = seq_val + 1, updated_at = now()
+ WHERE seq_name = :h_name;
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_next_seq UPDATE FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ if (sqlca.sqlcode == 100) { /* 최초 사용: 행 생성 */
+ EXEC SQL INSERT INTO cm_seq (seq_name, seq_val) VALUES (:h_name, 1);
+ if (sqlca.sqlcode < 0) return -1;
+ return 1;
+ }
+ EXEC SQL SELECT seq_val INTO :h_val FROM cm_seq WHERE seq_name = :h_name;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) return -1;
+ return h_val;
+}
+
+long cmdb_log(const char *svc, const char *level, const char *msg, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ char h_svc[64], h_level[4], h_msg[256], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_svc, svc, sizeof(h_svc)-1); h_svc[sizeof(h_svc)-1] = 0;
+ strncpy(h_level, level, sizeof(h_level)-1); h_level[sizeof(h_level)-1] = 0;
+ strncpy(h_msg, msg, sizeof(h_msg)-1); h_msg[sizeof(h_msg)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('cm_log_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
+ VALUES (:h_id, :h_svc, :h_level, :h_msg, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("cmdb_log FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
diff --git a/app/src/lg/batch/lg_post_batch.pgc b/app/src/lg/batch/lg_post_batch.pgc
new file mode 100644
index 0000000..aac2f1f
--- /dev/null
+++ b/app/src/lg/batch/lg_post_batch.pgc
@@ -0,0 +1,65 @@
+/*
+ * lg_post_batch.pgc - lg 매입 원장 전기 배치 (커서 + XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction, an EXEC SQL cursor walks 대사완료(M) 상태 매입건을 훑어 각 건에
+ * 대해 매입 전기 전표(ACQ)를 복식부기로 생성한다:
+ * 차변 RECV = amount, 대변 MERCH_PAY = net, 대변 FEE_INC = fee.
+ * 전기 후 purchase.status M -> 'P'(posted) 로 표시, tpcommit 이 XA 2PC 를 구동.
+ * Usage: lg_post_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "lg_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0, vid;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_merch[64];
+ long h_pid, h_amount, h_fee, h_net;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE lpc CURSOR FOR
+ SELECT p.purchase_id, p.merchant_id, p.amount, p.fee, p.net
+ FROM purchase p
+ WHERE p.biz_date = :h_bizdate AND p.status = 'M'
+ ORDER BY p.purchase_id;
+ EXEC SQL OPEN lpc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH lpc INTO :h_pid, :h_merch, :h_amount, :h_fee, :h_net;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE lpc; tpabort(0); return 1; }
+
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) { EXEC SQL CLOSE lpc; tpabort(0); return 1; }
+ if (lgdb_insert_voucher(vid, "ACQ", h_pid, "P", bizdate, "매입전기(배치)") < 0 ||
+ lgdb_insert_line(vid, "RECV", "D", h_amount, h_merch, bizdate, "posted") < 0 ||
+ lgdb_insert_line(vid, "MERCH_PAY", "C", h_net, h_merch, bizdate, "posted") < 0 ||
+ lgdb_insert_line(vid, "FEE_INC", "C", h_fee, h_merch, bizdate, "posted") < 0) {
+ EXEC SQL CLOSE lpc; tpabort(0); return 1;
+ }
+ EXEC SQL UPDATE purchase SET status = 'P', updated_at = now() WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) { EXEC SQL CLOSE lpc; tpabort(0); return 1; }
+ rows++;
+ }
+ EXEC SQL CLOSE lpc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> lg_post_batch COMMIT: bizdate=%s 전기전표=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/lg/batch/lg_trialbal_batch.pgc b/app/src/lg/batch/lg_trialbal_batch.pgc
new file mode 100644
index 0000000..29563f0
--- /dev/null
+++ b/app/src/lg/batch/lg_trialbal_batch.pgc
@@ -0,0 +1,63 @@
+/*
+ * lg_trialbal_batch.pgc - lg 시산표(trial balance) 배치 (커서 + XA).
+ *
+ * 당일 원장 라인을 계정과목(acct)별로 GROUP BY 하여 차변/대변 합계를 산출하고
+ * lg_trial_bal 에 upsert 한다. 전체 대차 차액(diff) 을 누적해 마지막에 출력하여
+ * 복식부기 균형 여부를 검증한다. 모두 ONE global XA transaction 하에서 수행.
+ * Usage: lg_trialbal_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "lg_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0, net_diff = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_acct[32];
+ long h_dr, h_cr, h_diff;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE ltc CURSOR FOR
+ SELECT acct,
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0)
+ FROM lg_ledger_line WHERE biz_date = :h_bizdate
+ GROUP BY acct ORDER BY acct;
+ EXEC SQL OPEN ltc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH ltc INTO :h_acct, :h_dr, :h_cr;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE ltc; tpabort(0); return 1; }
+ h_diff = h_dr - h_cr;
+
+ EXEC SQL INSERT INTO lg_trial_bal (acct, biz_date, debit_total, credit_total, diff)
+ VALUES (:h_acct, :h_bizdate, :h_dr, :h_cr, :h_diff)
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = EXCLUDED.debit_total, credit_total = EXCLUDED.credit_total,
+ diff = EXCLUDED.diff, updated_at = now();
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "UPSERT FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE ltc; tpabort(0); return 1; }
+ net_diff += h_diff;
+ rows++;
+ }
+ EXEC SQL CLOSE ltc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> lg_trialbal_batch COMMIT: bizdate=%s 계정=%ld 총대차차액=%ld %s\n",
+ bizdate, rows, net_diff, (net_diff == 0) ? "(균형)" : "(불균형!)");
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/lg/dbio/lg_balance_dbio.pgc b/app/src/lg/dbio/lg_balance_dbio.pgc
new file mode 100644
index 0000000..5f631cb
--- /dev/null
+++ b/app/src/lg/dbio/lg_balance_dbio.pgc
@@ -0,0 +1,110 @@
+/*
+ * lg_balance_dbio.pgc - 계정/가맹점 잔액 재계산 및 upsert (part of liblgdbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ * 복식부기 원칙: balance = 차변(D) 합 - 대변(C) 합.
+ */
+#include
+#include
+#include "lg_dbio.h"
+
+/* 계정과목 x 영업일 잔액을 원장 라인에서 재집계하여 upsert. */
+int lgdb_recalc_acct_bal(const char *acct, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_acct[32], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_account_bal (acct, biz_date, debit_total, credit_total, balance)
+ SELECT :h_acct, :h_bizdate,
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE -amount END), 0)
+ FROM lg_ledger_line
+ WHERE acct = :h_acct AND biz_date = :h_bizdate
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = EXCLUDED.debit_total,
+ credit_total = EXCLUDED.credit_total,
+ balance = EXCLUDED.balance,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_recalc_acct_bal FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int lgdb_get_acct_bal(const char *acct, const char *bizdate, long *bal_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_bal;
+ char h_acct[32], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT balance INTO :h_bal FROM lg_account_bal
+ WHERE acct = :h_acct AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("lgdb_get_acct_bal FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ *bal_out = h_bal;
+ return 0;
+}
+
+int lgdb_upsert_acct_bal(const char *acct, const char *bizdate, long debit, long credit)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_debit = debit, h_credit = credit, h_bal = debit - credit;
+ char h_acct[32], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_account_bal (acct, biz_date, debit_total, credit_total, balance)
+ VALUES (:h_acct, :h_bizdate, :h_debit, :h_credit, :h_bal)
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = lg_account_bal.debit_total + EXCLUDED.debit_total,
+ credit_total = lg_account_bal.credit_total + EXCLUDED.credit_total,
+ balance = lg_account_bal.balance + EXCLUDED.balance,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_upsert_acct_bal FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+/* 가맹점 x 영업일 잔액을 원장 라인에서 재집계하여 upsert. */
+int lgdb_upsert_merch_bal(const char *merch, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_merch_bal (merchant_id, biz_date, debit_total, credit_total, balance)
+ SELECT :h_merch, :h_bizdate,
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE -amount END), 0)
+ FROM lg_ledger_line
+ WHERE merchant_id = :h_merch AND biz_date = :h_bizdate
+ ON CONFLICT (merchant_id, biz_date) DO UPDATE
+ SET debit_total = EXCLUDED.debit_total,
+ credit_total = EXCLUDED.credit_total,
+ balance = EXCLUDED.balance,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_upsert_merch_bal FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/lg/dbio/lg_dbio.h b/app/src/lg/dbio/lg_dbio.h
new file mode 100644
index 0000000..ff8ca0e
--- /dev/null
+++ b/app/src/lg/dbio/lg_dbio.h
@@ -0,0 +1,29 @@
+/*
+ * lg_dbio.h - lg 모듈 (원장/복식부기) DB 접근 계층 (liblgdbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * lg_svr and the lg batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef LG_DBIO_H
+#define LG_DBIO_H
+
+/* 전표 (voucher) */
+long lgdb_next_voucher_id(void);
+long lgdb_next_line_id(void);
+int lgdb_insert_voucher(long vid, const char *vtype, long ref_id,
+ const char *status, const char *bizdate, const char *memo);
+int lgdb_update_voucher_status(long vid, const char *status);
+int lgdb_get_voucher_status(long vid, char *out /* >= 2 bytes */);
+
+/* 원장 라인 (ledger_line) - 차변(D)/대변(C) 분개 */
+int lgdb_insert_line(long vid, const char *acct, const char *dc, long amount,
+ const char *merch, const char *bizdate, const char *status);
+
+/* 계정/가맹점 잔액 (balance) */
+int lgdb_recalc_acct_bal(const char *acct, const char *bizdate);
+int lgdb_get_acct_bal(const char *acct, const char *bizdate, long *bal_out);
+int lgdb_upsert_acct_bal(const char *acct, const char *bizdate, long debit, long credit);
+int lgdb_upsert_merch_bal(const char *merch, const char *bizdate);
+
+#endif /* LG_DBIO_H */
diff --git a/app/src/lg/dbio/lg_ledger_dbio.pgc b/app/src/lg/dbio/lg_ledger_dbio.pgc
new file mode 100644
index 0000000..e7ecb88
--- /dev/null
+++ b/app/src/lg/dbio/lg_ledger_dbio.pgc
@@ -0,0 +1,119 @@
+/*
+ * lg_ledger_dbio.pgc - 전표/원장라인 DB 접근 (part of liblgdbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "lg_dbio.h"
+
+long lgdb_next_voucher_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('lg_voucher_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_next_voucher_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long lgdb_next_line_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('lg_line_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_next_line_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int lgdb_insert_voucher(long vid, const char *vtype, long ref_id,
+ const char *status, const char *bizdate, const char *memo)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid = vid, h_ref = ref_id;
+ char h_type[16], h_status[8], h_bizdate[16], h_memo[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, vtype, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_memo, memo, sizeof(h_memo) - 1); h_memo[sizeof(h_memo)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_voucher (voucher_id, voucher_type, ref_id, biz_date, status, memo)
+ VALUES (:h_vid, :h_type, :h_ref, :h_bizdate, :h_status, :h_memo);
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_insert_voucher FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int lgdb_update_voucher_status(long vid, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid = vid;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE lg_voucher SET status = :h_status, updated_at = now()
+ WHERE voucher_id = :h_vid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("lgdb_update_voucher_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int lgdb_get_voucher_status(long vid, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid = vid;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT status INTO :h_status FROM lg_voucher WHERE voucher_id = :h_vid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("lgdb_get_voucher_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ out[0] = h_status[0];
+ out[1] = 0;
+ return 0;
+}
+
+int lgdb_insert_line(long vid, const char *acct, const char *dc, long amount,
+ const char *merch, const char *bizdate, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_vid = vid, h_amount = amount;
+ char h_acct[32], h_dc[4], h_merch[64], h_bizdate[16], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_dc, dc, sizeof(h_dc) - 1); h_dc[sizeof(h_dc)-1] = 0;
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL SELECT nextval('lg_line_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO lg_ledger_line
+ (line_id, voucher_id, acct, dc, amount, merchant_id, biz_date, status)
+ VALUES (:h_id, :h_vid, :h_acct, :h_dc, :h_amount, :h_merch, :h_bizdate, :h_status);
+ if (sqlca.sqlcode < 0) {
+ userlog("lgdb_insert_line FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/lg/lg_svr.pgc b/app/src/lg/lg_svr.pgc
new file mode 100644
index 0000000..982561f
--- /dev/null
+++ b/app/src/lg/lg_svr.pgc
@@ -0,0 +1,703 @@
+/*
+ * lg_svr.pgc - lg (원장/ledger) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online lg services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct 원장
+ * (복식부기) operation with genuine EXEC SQL (directly, or via the linked
+ * liblgdbio.a dbio layer) and common math from libacqcommon.a.
+ *
+ * 복식부기 원칙: 모든 전표는 차변(D) 합 = 대변(C) 합. 계정잔액 balance = 차변-대변.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch libndrxxaecpg.so);
+ * there is NO EXEC SQL CONNECT. Each tpcall runs on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "lg_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void LG_POST(TPSVCINFO *p); void LG_CREDIT(TPSVCINFO *p);
+void LG_DEBIT(TPSVCINFO *p); void LG_DOUBLE(TPSVCINFO *p);
+void LG_BALCHK(TPSVCINFO *p); void LG_REVERSE(TPSVCINFO *p);
+void LG_CARRY(TPSVCINFO *p); void LG_CLOSE_LINK(TPSVCINFO *p);
+void LG_ACCTSUM(TPSVCINFO *p); void LG_RECALC(TPSVCINFO *p);
+void LG_UNSETTLED(TPSVCINFO *p); void LG_STATS(TPSVCINFO *p);
+void LG_DIFFCHK(TPSVCINFO *p); void LG_CORRECT(TPSVCINFO *p);
+void LG_INQUIRY(TPSVCINFO *p); void LG_TRIALBAL(TPSVCINFO *p);
+void LG_JOURNAL(TPSVCINFO *p); void LG_VOUCHER(TPSVCINFO *p);
+void LG_APPLY(TPSVCINFO *p); void LG_ROLLFWD(TPSVCINFO *p);
+void LG_MERCHBAL(TPSVCINFO *p); void LG_DAILYSUM(TPSVCINFO *p);
+void LG_MONTHLYSUM(TPSVCINFO *p); void LG_ADJUST(TPSVCINFO *p);
+void LG_TRANSFER(TPSVCINFO *p); void LG_SETTLEPOST(TPSVCINFO *p);
+void LG_ACQPOST(TPSVCINFO *p); void LG_FEEPOST(TPSVCINFO *p);
+void LG_STATUS(TPSVCINFO *p); void LG_CONFIRM(TPSVCINFO *p);
+
+/* ----- advertise table (this is what "scales to 30" cleanly) ------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"LG_POST", LG_POST}, {"LG_CREDIT", LG_CREDIT},
+ {"LG_DEBIT", LG_DEBIT}, {"LG_DOUBLE", LG_DOUBLE},
+ {"LG_BALCHK", LG_BALCHK}, {"LG_REVERSE", LG_REVERSE},
+ {"LG_CARRY", LG_CARRY}, {"LG_CLOSE_LINK", LG_CLOSE_LINK},
+ {"LG_ACCTSUM", LG_ACCTSUM}, {"LG_RECALC", LG_RECALC},
+ {"LG_UNSETTLED", LG_UNSETTLED}, {"LG_STATS", LG_STATS},
+ {"LG_DIFFCHK", LG_DIFFCHK}, {"LG_CORRECT", LG_CORRECT},
+ {"LG_INQUIRY", LG_INQUIRY}, {"LG_TRIALBAL", LG_TRIALBAL},
+ {"LG_JOURNAL", LG_JOURNAL}, {"LG_VOUCHER", LG_VOUCHER},
+ {"LG_APPLY", LG_APPLY}, {"LG_ROLLFWD", LG_ROLLFWD},
+ {"LG_MERCHBAL", LG_MERCHBAL}, {"LG_DAILYSUM", LG_DAILYSUM},
+ {"LG_MONTHLYSUM", LG_MONTHLYSUM},{"LG_ADJUST", LG_ADJUST},
+ {"LG_TRANSFER", LG_TRANSFER}, {"LG_SETTLEPOST", LG_SETTLEPOST},
+ {"LG_ACQPOST", LG_ACQPOST}, {"LG_FEEPOST", LG_FEEPOST},
+ {"LG_STATUS", LG_STATUS}, {"LG_CONFIRM", LG_CONFIRM},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("lg_svr: tpopen FAIL: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("lg_svr: tpadvertise(%s) FAIL: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("lg_svr: %d lg 서비스 광고 완료, RM 개방", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("lg_svr: tpsvrdone");
+}
+
+/* ========================================================================= */
+/* 1. LG_POST - 전표 작성(draft) + 차변/대변 균형 분개 2줄. */
+/* ========================================================================= */
+void LG_POST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char dacct[32], cacct[32], bizdate[16], merch[64];
+ long vid, ref, amount;
+
+ gets_(b, T_KEY1, dacct, sizeof(dacct)); if (dacct[0] == 0) strcpy(dacct, "RECV");
+ gets_(b, T_KEY2, cacct, sizeof(cacct)); if (cacct[0] == 0) strcpy(cacct, "MERCH_PAY");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ ref = getl(b, T_PURCHASE_ID);
+ amount = getl(b, T_AMOUNT);
+
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "POST", ref, "D", bizdate, "전표작성") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, dacct, "D", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, cacct, "C", amount, merch, bizdate, "posted") < 0) FAIL(b);
+
+ setl(b, T_ID1, vid);
+ Bchg(b, T_STATUS, 0, "D", 0L);
+ userlog("LG_POST vid=%ld 차변=%s 대변=%s 금액=%ld", vid, dacct, cacct, amount);
+ OK(b);
+}
+
+/* 2. LG_CREDIT - 기존 전표에 대변(C) 분개 1줄 추가. */
+void LG_CREDIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], bizdate[16], merch[64];
+ long vid = getl(b, T_ID1), amount = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, acct, sizeof(acct)); if (acct[0] == 0) strcpy(acct, "MERCH_PAY");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ if (lgdb_insert_line(vid, acct, "C", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ userlog("LG_CREDIT vid=%ld 대변 %s %ld", vid, acct, amount);
+ OK(b);
+}
+
+/* 3. LG_DEBIT - 기존 전표에 차변(D) 분개 1줄 추가. */
+void LG_DEBIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], bizdate[16], merch[64];
+ long vid = getl(b, T_ID1), amount = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, acct, sizeof(acct)); if (acct[0] == 0) strcpy(acct, "RECV");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ if (lgdb_insert_line(vid, acct, "D", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ userlog("LG_DEBIT vid=%ld 차변 %s %ld", vid, acct, amount);
+ OK(b);
+}
+
+/* 4. LG_DOUBLE - 신규 전표 즉시 전기(posted): 차변/대변 + 양 계정잔액 반영. */
+void LG_DOUBLE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char dacct[32], cacct[32], bizdate[16], merch[64];
+ long vid, amount;
+
+ gets_(b, T_KEY1, dacct, sizeof(dacct)); if (dacct[0] == 0) strcpy(dacct, "RECV");
+ gets_(b, T_KEY2, cacct, sizeof(cacct)); if (cacct[0] == 0) strcpy(cacct, "MERCH_PAY");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amount = getl(b, T_AMOUNT);
+
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "DOUBLE", getl(b, T_PURCHASE_ID), "P", bizdate, "복식전기") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, dacct, "D", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, cacct, "C", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_upsert_acct_bal(dacct, bizdate, amount, 0) < 0) FAIL(b);
+ if (lgdb_upsert_acct_bal(cacct, bizdate, 0, amount) < 0) FAIL(b);
+
+ setl(b, T_ID1, vid);
+ Bchg(b, T_STATUS, 0, "P", 0L);
+ OK(b);
+}
+
+/* 5. LG_BALCHK - 전표 단위 대차 균형 점검 (차변합 vs 대변합). */
+void LG_BALCHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid, h_dr, h_cr;
+ EXEC SQL END DECLARE SECTION;
+ h_vid = getl(b, T_ID1);
+ EXEC SQL SELECT coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0)
+ INTO :h_dr, :h_cr FROM lg_ledger_line WHERE voucher_id = :h_vid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_AMT2, h_dr);
+ setl(b, T_AMT3, h_cr);
+ setl(b, T_RC, (h_dr == h_cr) ? 0 : 1);
+ userlog("LG_BALCHK vid=%ld 차변=%ld 대변=%ld %s", h_vid, h_dr, h_cr,
+ (h_dr == h_cr) ? "균형" : "불균형");
+ OK(b);
+}
+
+/* 6. LG_REVERSE - 역분개 전표: 원전표 라인의 차/대를 뒤집어 신규 전표에 복사. */
+void LG_REVERSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_src, h_new;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ h_src = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ h_new = lgdb_next_voucher_id();
+ if (h_new < 0) FAIL(b);
+ if (lgdb_insert_voucher(h_new, "REVERSE", h_src, "P", bizdate, "역분개") < 0) FAIL(b);
+
+ EXEC SQL INSERT INTO lg_ledger_line
+ (line_id, voucher_id, acct, dc, amount, merchant_id, biz_date, status)
+ SELECT nextval('lg_line_seq'), :h_new, acct,
+ CASE WHEN dc = 'D' THEN 'C' ELSE 'D' END,
+ amount, merchant_id, :h_bizdate, 'posted'
+ FROM lg_ledger_line WHERE voucher_id = :h_src;
+ if (sqlca.sqlcode < 0) { userlog("LG_REVERSE FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ setl(b, T_ID2, h_new);
+ setl(b, T_COUNT, sqlca.sqlerrd[2]);
+ OK(b);
+}
+
+/* 7. LG_CARRY - 이월: 당일 계정잔액을 익영업일 개시잔액으로 이월(upsert). */
+void LG_CARRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], nextday[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_next[16];
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (acq_next_bizday(bizdate, nextday) < 0) strcpy(nextday, bizdate);
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_next, nextday, sizeof(h_next)-1); h_next[sizeof(h_next)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_account_bal (acct, biz_date, debit_total, credit_total, balance)
+ SELECT acct, :h_next, debit_total, credit_total, balance
+ FROM lg_account_bal WHERE biz_date = :h_bizdate
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = EXCLUDED.debit_total,
+ credit_total = EXCLUDED.credit_total,
+ balance = EXCLUDED.balance,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_cnt = sqlca.sqlerrd[2];
+ setl(b, T_COUNT, h_cnt);
+ userlog("LG_CARRY %s -> %s 계정 %ld건 이월", bizdate, nextday, h_cnt);
+ OK(b);
+}
+
+/* 8. LG_CLOSE_LINK - 마감연계: 전표 라인 closed + 전표 상태 S(마감연계). */
+void LG_CLOSE_LINK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid;
+ EXEC SQL END DECLARE SECTION;
+ h_vid = getl(b, T_ID1);
+ EXEC SQL UPDATE lg_ledger_line SET status = 'closed'
+ WHERE voucher_id = :h_vid AND status = 'posted';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, sqlca.sqlerrd[2]);
+ if (lgdb_update_voucher_status(h_vid, "S") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "S", 0L);
+ OK(b);
+}
+
+/* 9. LG_ACCTSUM - 계정 잔액 재집계(dbio) 후 잔액 반환. */
+void LG_ACCTSUM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], bizdate[16];
+ long bal = 0;
+ gets_(b, T_KEY1, acct, sizeof(acct)); if (acct[0] == 0) strcpy(acct, "RECV");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (lgdb_recalc_acct_bal(acct, bizdate) < 0) FAIL(b);
+ if (lgdb_get_acct_bal(acct, bizdate, &bal) < 0) FAIL(b);
+ setl(b, T_NET, bal);
+ OK(b);
+}
+
+/* 10. LG_RECALC - 계정 잔액 원장 라인 기준 재계산(dbio). */
+void LG_RECALC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], bizdate[16];
+ gets_(b, T_KEY1, acct, sizeof(acct)); if (acct[0] == 0) strcpy(acct, "RECV");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (lgdb_recalc_acct_bal(acct, bizdate) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 11. LG_UNSETTLED - 미마감(status=posted) 원장 라인 건수. */
+void LG_UNSETTLED(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM lg_ledger_line
+ WHERE biz_date = :h_bizdate AND status = 'posted';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 12. LG_STATS - 당일 전표/원장 통계(전표수, 총차변). */
+void LG_STATS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vc, h_dr;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(DISTINCT voucher_id),
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0)
+ INTO :h_vc, :h_dr FROM lg_ledger_line WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_vc);
+ setl(b, T_GROSS, h_dr);
+ OK(b);
+}
+
+/* 13. LG_DIFFCHK - 당일 전체 대차 차액 점검(총차변-총대변). */
+void LG_DIFFCHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_dr, h_cr;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0)
+ INTO :h_dr, :h_cr FROM lg_ledger_line WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_AMT2, h_dr);
+ setl(b, T_AMT3, h_cr);
+ setl(b, T_DELTA, h_dr - h_cr);
+ setl(b, T_RC, (h_dr == h_cr) ? 0 : 1);
+ OK(b);
+}
+
+/* 14. LG_CORRECT - 원장 라인 금액 정정. */
+void LG_CORRECT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_line, h_amount;
+ EXEC SQL END DECLARE SECTION;
+ h_line = getl(b, T_ID1);
+ h_amount = getl(b, T_AMOUNT);
+ EXEC SQL UPDATE lg_ledger_line SET amount = :h_amount WHERE line_id = :h_line;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ OK(b);
+}
+
+/* 15. LG_INQUIRY - 전표 상태 조회(dbio). */
+void LG_INQUIRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long vid = getl(b, T_ID1);
+ char st[4];
+ if (lgdb_get_voucher_status(vid, st) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, st, 0L);
+ OK(b);
+}
+
+/* 16. LG_TRIALBAL - 계정별 시산표 산출 후 lg_trial_bal upsert. */
+void LG_TRIALBAL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_dr, h_cr;
+ char h_acct[32], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, acct, sizeof(acct)); if (acct[0] == 0) strcpy(acct, "RECV");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_acct, acct, sizeof(h_acct)-1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0)
+ INTO :h_dr, :h_cr FROM lg_ledger_line
+ WHERE acct = :h_acct AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO lg_trial_bal (acct, biz_date, debit_total, credit_total, diff)
+ VALUES (:h_acct, :h_bizdate, :h_dr, :h_cr, :h_dr - :h_cr)
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = EXCLUDED.debit_total, credit_total = EXCLUDED.credit_total,
+ diff = EXCLUDED.diff, updated_at = now();
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_AMT2, h_dr); setl(b, T_AMT3, h_cr); setl(b, T_DELTA, h_dr - h_cr);
+ OK(b);
+}
+
+/* 17. LG_JOURNAL - 당일 분개(원장 라인) 건수 조회. */
+void LG_JOURNAL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM lg_ledger_line WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 18. LG_VOUCHER - 빈 전표 헤더 생성(작성 상태). */
+void LG_VOUCHER(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char vtype[16], bizdate[16], memo[128];
+ long vid, ref = getl(b, T_PURCHASE_ID);
+ gets_(b, T_STR2, vtype, sizeof(vtype)); if (vtype[0] == 0) strcpy(vtype, "POST");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MSG, memo, sizeof(memo)); if (memo[0] == 0) strcpy(memo, "전표생성");
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, vtype, ref, "D", bizdate, memo) < 0) FAIL(b);
+ setl(b, T_ID1, vid);
+ Bchg(b, T_STATUS, 0, "D", 0L);
+ OK(b);
+}
+
+/* 19. LG_APPLY - 전표 전기확정(P): 라인들의 계정잔액을 잔액테이블에 반영. */
+void LG_APPLY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_vid = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_account_bal (acct, biz_date, debit_total, credit_total, balance)
+ SELECT acct, :h_bizdate,
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE -amount END), 0)
+ FROM lg_ledger_line WHERE voucher_id = :h_vid
+ GROUP BY acct
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = lg_account_bal.debit_total + EXCLUDED.debit_total,
+ credit_total = lg_account_bal.credit_total + EXCLUDED.credit_total,
+ balance = lg_account_bal.balance + EXCLUDED.balance,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (lgdb_update_voucher_status(h_vid, "P") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "P", 0L);
+ OK(b);
+}
+
+/* 20. LG_ROLLFWD - 전 계정 잔액 익일 이월(개시잔액 롤포워드). */
+void LG_ROLLFWD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], nextday[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_next[16];
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (acq_next_bizday(bizdate, nextday) < 0) strcpy(nextday, bizdate);
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_next, nextday, sizeof(h_next)-1); h_next[sizeof(h_next)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_account_bal (acct, biz_date, debit_total, credit_total, balance)
+ SELECT acct, :h_next, 0, 0, balance
+ FROM lg_account_bal WHERE biz_date = :h_bizdate
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET balance = EXCLUDED.balance, updated_at = now();
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_cnt = sqlca.sqlerrd[2];
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 21. LG_MERCHBAL - 가맹점 잔액 재집계(dbio). */
+void LG_MERCHBAL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (merch[0] == 0) FAIL(b);
+ if (lgdb_upsert_merch_bal(merch, bizdate) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 22. LG_DAILYSUM - 당일 계정별 잔액 일괄 재집계(전 계정 upsert). */
+void LG_DAILYSUM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO lg_account_bal (acct, biz_date, debit_total, credit_total, balance)
+ SELECT acct, :h_bizdate,
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE -amount END), 0)
+ FROM lg_ledger_line WHERE biz_date = :h_bizdate
+ GROUP BY acct
+ ON CONFLICT (acct, biz_date) DO UPDATE
+ SET debit_total = EXCLUDED.debit_total,
+ credit_total = EXCLUDED.credit_total,
+ balance = EXCLUDED.balance,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_cnt = sqlca.sqlerrd[2];
+ setl(b, T_COUNT, h_cnt);
+ userlog("LG_DAILYSUM %s 계정 %ld건 집계", bizdate, h_cnt);
+ OK(b);
+}
+
+/* 23. LG_MONTHLYSUM - 월 계정과목별 차/대 집계 건수 조회(YYYY-MM). */
+void LG_MONTHLYSUM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char month[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_dr;
+ char h_month[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR1, month, sizeof(month));
+ if (month[0] == 0) { gets_(b, T_BIZDATE, month, sizeof(month)); month[7] = 0; }
+ strncpy(h_month, month, sizeof(h_month)-1); h_month[sizeof(h_month)-1] = 0;
+ EXEC SQL SELECT count(DISTINCT acct),
+ coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0)
+ INTO :h_cnt, :h_dr FROM lg_ledger_line
+ WHERE to_char(biz_date, 'YYYY-MM') = :h_month;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_dr);
+ OK(b);
+}
+
+/* 24. LG_ADJUST - 조정 전표: 부호에 따라 차변/대변 단일 분개. */
+void LG_ADJUST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], bizdate[16], merch[64];
+ long vid, delta, amt;
+ const char *dc;
+ gets_(b, T_KEY1, acct, sizeof(acct)); if (acct[0] == 0) strcpy(acct, "ADJ");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ delta = getl(b, T_DELTA);
+ dc = (delta >= 0) ? "D" : "C";
+ amt = (delta >= 0) ? delta : -delta;
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "ADJ", getl(b, T_PURCHASE_ID), "P", bizdate, "조정분개") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, acct, dc, amt, merch, bizdate, "posted") < 0) FAIL(b);
+ setl(b, T_ID1, vid);
+ OK(b);
+}
+
+/* 25. LG_TRANSFER - 계정간 대체: 출금계정 대변, 입금계정 차변(균형). */
+void LG_TRANSFER(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char src[32], dst[32], bizdate[16], merch[64];
+ long vid, amount;
+ gets_(b, T_KEY1, src, sizeof(src)); if (src[0] == 0) strcpy(src, "MERCH_PAY");
+ gets_(b, T_KEY2, dst, sizeof(dst)); if (dst[0] == 0) strcpy(dst, "CASH");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amount = getl(b, T_AMOUNT);
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "TRANSFER", 0, "P", bizdate, "계정대체") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, src, "C", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, dst, "D", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ setl(b, T_ID1, vid);
+ OK(b);
+}
+
+/* 26. LG_SETTLEPOST - 정산 전기: 차변 MERCH_PAY, 대변 CASH (net). */
+void LG_SETTLEPOST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], merch[64];
+ long vid, net;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ net = getl(b, T_NET);
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "SETTLE", getl(b, T_PURCHASE_ID), "P", bizdate, "정산전기") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "MERCH_PAY", "D", net, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "CASH", "C", net, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_upsert_merch_bal(merch, bizdate) < 0) FAIL(b);
+ setl(b, T_ID1, vid);
+ userlog("LG_SETTLEPOST vid=%ld merch=%s net=%ld", vid, merch, net);
+ OK(b);
+}
+
+/* 27. LG_ACQPOST - 매입 전기: 차변 RECV=amount, 대변 MERCH_PAY=net + FEE_INC=fee. */
+void LG_ACQPOST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], merch[64];
+ long vid, amount, fee, net;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amount = getl(b, T_AMOUNT);
+ fee = getl(b, T_FEE);
+ net = getl(b, T_NET);
+ if (net == 0) net = amount - fee;
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "ACQ", getl(b, T_PURCHASE_ID), "P", bizdate, "매입전기") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "RECV", "D", amount, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "MERCH_PAY", "C", net, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "FEE_INC", "C", fee, merch, bizdate, "posted") < 0) FAIL(b);
+ setl(b, T_ID1, vid);
+ userlog("LG_ACQPOST vid=%ld amount=%ld net=%ld fee=%ld", vid, amount, net, fee);
+ OK(b);
+}
+
+/* 28. LG_FEEPOST - 수수료수익 전기: 차변 RECV=fee, 대변 FEE_INC=fee. */
+void LG_FEEPOST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], merch[64];
+ long vid, fee;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ fee = getl(b, T_FEE);
+ vid = lgdb_next_voucher_id();
+ if (vid < 0) FAIL(b);
+ if (lgdb_insert_voucher(vid, "FEE", getl(b, T_PURCHASE_ID), "P", bizdate, "수수료전기") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "RECV", "D", fee, merch, bizdate, "posted") < 0) FAIL(b);
+ if (lgdb_insert_line(vid, "FEE_INC", "C", fee, merch, bizdate, "posted") < 0) FAIL(b);
+ setl(b, T_ID1, vid);
+ OK(b);
+}
+
+/* 29. LG_STATUS - 전표 상태 직접 변경(setter). */
+void LG_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char st[8];
+ long vid = getl(b, T_ID1);
+ gets_(b, T_STATUS, st, sizeof(st)); if (st[0] == 0) strcpy(st, "P");
+ if (lgdb_update_voucher_status(vid, st) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 30. LG_CONFIRM - 전표 확정: 대차 균형이면 P로 확정, 아니면 실패. */
+void LG_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_vid, h_dr, h_cr;
+ EXEC SQL END DECLARE SECTION;
+ h_vid = getl(b, T_ID1);
+ EXEC SQL SELECT coalesce(sum(CASE WHEN dc = 'D' THEN amount ELSE 0 END), 0),
+ coalesce(sum(CASE WHEN dc = 'C' THEN amount ELSE 0 END), 0)
+ INTO :h_dr, :h_cr FROM lg_ledger_line WHERE voucher_id = :h_vid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_dr != h_cr) {
+ userlog("LG_CONFIRM vid=%ld 대차 불균형 차변=%ld 대변=%ld -> 거부", h_vid, h_dr, h_cr);
+ setl(b, T_RC, 1);
+ FAIL(b);
+ }
+ if (lgdb_update_voucher_status(h_vid, "P") < 0) FAIL(b);
+ setl(b, T_RC, 0);
+ Bchg(b, T_STATUS, 0, "P", 0L);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/mg/batch/mg_resend_batch.pgc b/app/src/mg/batch/mg_resend_batch.pgc
new file mode 100644
index 0000000..9e48510
--- /dev/null
+++ b/app/src/mg/batch/mg_resend_batch.pgc
@@ -0,0 +1,56 @@
+/*
+ * mg_resend_batch.pgc - mg 실패 전문 재전송 배치 (커서 + XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction, an EXEC SQL cursor sweeps mg_queue rows in F(실패)/Q(대기) state
+ * for a biz_date, re-queues each (status->Q, retry_cnt++) via the linked dbio
+ * lib, then tpcommit drives XA 2PC. Usage: mg_resend_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "mg_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_qid, h_retry;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE rsc CURSOR FOR
+ SELECT queue_id, retry_cnt FROM mg_queue
+ WHERE biz_date = :h_bizdate AND status = 'F'
+ ORDER BY queue_id;
+ EXEC SQL OPEN rsc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH rsc INTO :h_qid, :h_retry;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE rsc; tpabort(0); return 1; }
+ /* 실패 전문을 재전송 대기(Q)로 되돌리고 retry_cnt 증가 */
+ EXEC SQL UPDATE mg_queue
+ SET status = 'Q', retry_cnt = retry_cnt + 1, updated_at = now()
+ WHERE queue_id = :h_qid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { EXEC SQL CLOSE rsc; tpabort(0); return 1; }
+ rows++;
+ }
+ EXEC SQL CLOSE rsc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> mg_resend_batch COMMIT: bizdate=%s resent=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/mg/batch/mg_stat_batch.pgc b/app/src/mg/batch/mg_stat_batch.pgc
new file mode 100644
index 0000000..a22df5d
--- /dev/null
+++ b/app/src/mg/batch/mg_stat_batch.pgc
@@ -0,0 +1,62 @@
+/*
+ * mg_stat_batch.pgc - mg 채널 일통계 배치 (커서 + XA).
+ *
+ * Cursors over mg_msg_log aggregated per channel for a biz_date and upserts
+ * mg_stat (수신/송신/오류 건수 + 금액합), all under ONE global XA transaction.
+ * Usage: mg_stat_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "mg_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ char h_chan[16];
+ long h_in, h_out, h_err, h_sum;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE msc CURSOR FOR
+ SELECT channel,
+ coalesce(sum(CASE WHEN direction='IN' THEN 1 ELSE 0 END),0),
+ coalesce(sum(CASE WHEN direction='OUT' THEN 1 ELSE 0 END),0),
+ coalesce(sum(CASE WHEN rc<>'00' THEN 1 ELSE 0 END),0),
+ coalesce(sum(amount),0)
+ FROM mg_msg_log WHERE biz_date = :h_bizdate
+ GROUP BY channel ORDER BY channel;
+ EXEC SQL OPEN msc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH msc INTO :h_chan, :h_in, :h_out, :h_err, :h_sum;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE msc; tpabort(0); return 1; }
+ EXEC SQL INSERT INTO mg_stat (channel, biz_date, in_cnt, out_cnt, err_cnt, amount_sum)
+ VALUES (:h_chan, :h_bizdate, :h_in, :h_out, :h_err, :h_sum)
+ ON CONFLICT (channel, biz_date) DO UPDATE
+ SET in_cnt = EXCLUDED.in_cnt, out_cnt = EXCLUDED.out_cnt,
+ err_cnt = EXCLUDED.err_cnt, amount_sum = EXCLUDED.amount_sum,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "UPSERT FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE msc; tpabort(0); return 1; }
+ rows++;
+ }
+ EXEC SQL CLOSE msc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> mg_stat_batch COMMIT: bizdate=%s channels=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/mg/dbio/mg_dbio.h b/app/src/mg/dbio/mg_dbio.h
new file mode 100644
index 0000000..8be9a1d
--- /dev/null
+++ b/app/src/mg/dbio/mg_dbio.h
@@ -0,0 +1,41 @@
+/*
+ * mg_dbio.h - mg 모듈 (전문게이트웨이) DB 접근 계층 (libmgdbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * mg_svr and the mg batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef MG_DBIO_H
+#define MG_DBIO_H
+
+/* 채번 (STAN/전문 시퀀스) */
+long mgdb_next_stan(void);
+long mgdb_next_msg_id(void);
+long mgdb_next_queue_id(void);
+long mgdb_seq_bump(const char *name); /* mg_seq 이름있는 카운터 +1 -> 새 값 */
+
+/* 전문 로그 (mg_msg_log) */
+int mgdb_insert_msg(long msg_id, long stan, const char *mti, const char *channel,
+ const char *direction, const char *proc_code, const char *bitmap,
+ long amount, const char *pan, const char *rc, const char *raw,
+ const char *status, const char *bizdate);
+int mgdb_update_msg_rc(long msg_id, const char *rc, const char *status);
+int mgdb_get_msg_status(long msg_id, char *out /* >= 2 bytes */);
+long mgdb_find_msg_by_stan(long stan); /* msg_id 반환, 없으면 -1 */
+
+/* 라우팅 (mg_route) */
+int mgdb_insert_route(long route_id, const char *channel, const char *mti,
+ const char *dest, long priority, const char *bizdate);
+int mgdb_lookup_route(const char *channel, const char *mti, char *dest_out /* >= 64 */);
+
+/* 채널 (mg_channel) */
+int mgdb_get_channel_status(const char *channel, char *status_out /* >= 16 */);
+int mgdb_set_channel_status(const char *channel, const char *status);
+int mgdb_set_channel_session(const char *channel, const char *session_key, long stan);
+
+/* 큐 (mg_queue) */
+int mgdb_enqueue(long queue_id, long stan, const char *channel, const char *mti,
+ const char *payload, const char *bizdate);
+int mgdb_set_queue_status(long queue_id, const char *status);
+
+#endif /* MG_DBIO_H */
diff --git a/app/src/mg/dbio/mg_msg_dbio.pgc b/app/src/mg/dbio/mg_msg_dbio.pgc
new file mode 100644
index 0000000..4257daf
--- /dev/null
+++ b/app/src/mg/dbio/mg_msg_dbio.pgc
@@ -0,0 +1,200 @@
+/*
+ * mg_msg_dbio.pgc - 전문 로그/채번/큐 DB 접근 (part of libmgdbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "mg_dbio.h"
+
+long mgdb_next_stan(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('mg_stan_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_next_stan FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long mgdb_next_msg_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('mg_msg_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_next_msg_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long mgdb_next_queue_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('mg_queue_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_next_queue_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+/* mg_seq 이름있는 카운터를 원자적으로 +1 하고 새 값을 돌려준다 (세션 시퀀스 채번). */
+long mgdb_seq_bump(const char *name)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_val;
+ char h_name[32];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_name, name, sizeof(h_name) - 1); h_name[sizeof(h_name)-1] = 0;
+
+ EXEC SQL UPDATE mg_seq SET seq_val = seq_val + 1, updated_at = now()
+ WHERE seq_name = :h_name;
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_seq_bump UPDATE FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ if (sqlca.sqlcode == 100) { /* 최초 사용: 행 생성 */
+ EXEC SQL INSERT INTO mg_seq (seq_name, seq_val) VALUES (:h_name, 1);
+ if (sqlca.sqlcode < 0) return -1;
+ return 1;
+ }
+ EXEC SQL SELECT seq_val INTO :h_val FROM mg_seq WHERE seq_name = :h_name;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) return -1;
+ return h_val;
+}
+
+int mgdb_insert_msg(long msg_id, long stan, const char *mti, const char *channel,
+ const char *direction, const char *proc_code, const char *bitmap,
+ long amount, const char *pan, const char *rc, const char *raw,
+ const char *status, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = msg_id, h_stan = stan, h_amount = amount;
+ char h_mti[8], h_chan[16], h_dir[8], h_proc[8], h_bitmap[40];
+ char h_pan[24], h_rc[4], h_raw[512], h_status[4], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_mti, mti, sizeof(h_mti)-1); h_mti[sizeof(h_mti)-1] = 0;
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_dir, direction, sizeof(h_dir)-1); h_dir[sizeof(h_dir)-1] = 0;
+ strncpy(h_proc, proc_code, sizeof(h_proc)-1); h_proc[sizeof(h_proc)-1] = 0;
+ strncpy(h_bitmap, bitmap, sizeof(h_bitmap)-1); h_bitmap[sizeof(h_bitmap)-1] = 0;
+ strncpy(h_pan, pan, sizeof(h_pan)-1); h_pan[sizeof(h_pan)-1] = 0;
+ strncpy(h_rc, rc, sizeof(h_rc)-1); h_rc[sizeof(h_rc)-1] = 0;
+ strncpy(h_raw, raw, sizeof(h_raw)-1); h_raw[sizeof(h_raw)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status)-1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO mg_msg_log
+ (msg_id, stan, mti, channel, direction, proc_code, bitmap, amount,
+ pan, rc, raw_msg, status, biz_date)
+ VALUES (:h_id, :h_stan, :h_mti, :h_chan, :h_dir, :h_proc, :h_bitmap,
+ :h_amount, :h_pan, :h_rc, :h_raw, :h_status, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_insert_msg FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mgdb_update_msg_rc(long msg_id, const char *rc, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = msg_id;
+ char h_rc[4], h_status[4];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_rc, rc, sizeof(h_rc)-1); h_rc[sizeof(h_rc)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status)-1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE mg_msg_log SET rc = :h_rc, status = :h_status WHERE msg_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mgdb_update_msg_rc FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mgdb_get_msg_status(long msg_id, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = msg_id;
+ char h_status[4];
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT status INTO :h_status FROM mg_msg_log WHERE msg_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mgdb_get_msg_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ out[0] = h_status[0];
+ out[1] = 0;
+ return 0;
+}
+
+long mgdb_find_msg_by_stan(long stan)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_stan = stan, h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT msg_id INTO :h_id FROM mg_msg_log
+ WHERE stan = :h_stan ORDER BY msg_id DESC LIMIT 1;
+ if (sqlca.sqlcode == 100) return -1;
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_find_msg_by_stan FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int mgdb_enqueue(long queue_id, long stan, const char *channel, const char *mti,
+ const char *payload, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = queue_id, h_stan = stan;
+ char h_chan[16], h_mti[8], h_payload[512], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_mti, mti, sizeof(h_mti)-1); h_mti[sizeof(h_mti)-1] = 0;
+ strncpy(h_payload, payload, sizeof(h_payload)-1); h_payload[sizeof(h_payload)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO mg_queue (queue_id, stan, channel, mti, payload, status, biz_date)
+ VALUES (:h_id, :h_stan, :h_chan, :h_mti, :h_payload, 'Q', :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_enqueue FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mgdb_set_queue_status(long queue_id, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = queue_id;
+ char h_status[4];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_status, status, sizeof(h_status)-1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE mg_queue SET status = :h_status, updated_at = now()
+ WHERE queue_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mgdb_set_queue_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/mg/dbio/mg_route_dbio.pgc b/app/src/mg/dbio/mg_route_dbio.pgc
new file mode 100644
index 0000000..751caa0
--- /dev/null
+++ b/app/src/mg/dbio/mg_route_dbio.pgc
@@ -0,0 +1,111 @@
+/*
+ * mg_route_dbio.pgc - 라우팅 룰 + 채널 세션 DB 접근 (part of libmgdbio.a).
+ */
+#include
+#include
+#include "mg_dbio.h"
+
+int mgdb_insert_route(long route_id, const char *channel, const char *mti,
+ const char *dest, long priority, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = route_id, h_pri = priority;
+ char h_chan[16], h_mti[8], h_dest[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_mti, mti, sizeof(h_mti)-1); h_mti[sizeof(h_mti)-1] = 0;
+ strncpy(h_dest, dest, sizeof(h_dest)-1); h_dest[sizeof(h_dest)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO mg_route (route_id, channel, mti, dest, priority, active, biz_date)
+ VALUES (:h_id, :h_chan, :h_mti, :h_dest, :h_pri, true, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_insert_route FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+/* 채널+전문유형으로 활성 라우팅 목적지를 우선순위 순으로 1건 조회. */
+int mgdb_lookup_route(const char *channel, const char *mti, char *dest_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_chan[16], h_mti[8], h_dest[64];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_mti, mti, sizeof(h_mti)-1); h_mti[sizeof(h_mti)-1] = 0;
+
+ EXEC SQL SELECT dest INTO :h_dest FROM mg_route
+ WHERE channel = :h_chan AND mti = :h_mti AND active = true
+ ORDER BY priority ASC LIMIT 1;
+ if (sqlca.sqlcode == 100) { dest_out[0] = 0; return -1; }
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_lookup_route FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ strncpy(dest_out, h_dest, 63); dest_out[63] = 0;
+ return 0;
+}
+
+int mgdb_get_channel_status(const char *channel, char *status_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_chan[16], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+
+ EXEC SQL SELECT status INTO :h_status FROM mg_channel WHERE channel = :h_chan;
+ if (sqlca.sqlcode == 100) { strcpy(status_out, "UNKNOWN"); return -1; }
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_get_channel_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ strncpy(status_out, h_status, 15); status_out[15] = 0;
+ return 0;
+}
+
+int mgdb_set_channel_status(const char *channel, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_chan[16], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status)-1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE mg_channel SET status = :h_status, updated_at = now()
+ WHERE channel = :h_chan;
+ if (sqlca.sqlcode < 0) {
+ userlog("mgdb_set_channel_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ if (sqlca.sqlcode == 100) { /* 신규 채널 등록 */
+ EXEC SQL INSERT INTO mg_channel (channel, status) VALUES (:h_chan, :h_status);
+ if (sqlca.sqlcode < 0) return -1;
+ }
+ return 0;
+}
+
+int mgdb_set_channel_session(const char *channel, const char *session_key, long stan)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_stan = stan;
+ char h_chan[16], h_key[64];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_chan, channel, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_key, session_key, sizeof(h_key)-1); h_key[sizeof(h_key)-1] = 0;
+
+ EXEC SQL UPDATE mg_channel
+ SET session_key = :h_key, last_stan = :h_stan, status = 'SIGNON',
+ signon_at = now(), updated_at = now()
+ WHERE channel = :h_chan;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mgdb_set_channel_session FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/mg/mg_svr.pgc b/app/src/mg/mg_svr.pgc
new file mode 100644
index 0000000..624b959
--- /dev/null
+++ b/app/src/mg/mg_svr.pgc
@@ -0,0 +1,649 @@
+/*
+ * mg_svr.pgc - mg (전문게이트웨이 / message gateway) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online mg services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct 전문
+ * (ISO8583) gateway operation with genuine EXEC SQL (directly, or via the linked
+ * libmgdbio.a dbio layer) plus 고정길이/비트맵 pack/unpack idioms and STAN 채번
+ * via a Postgres sequence.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch libndrxxaecpg.so);
+ * there is NO EXEC SQL CONNECT. Each tpcall runs on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "mg_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+static void sets_(UBFH *b, BFLDID f, const char *s) { Bchg(b, f, 0, (char *)s, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void MG_RECV(TPSVCINFO *p); void MG_SEND(TPSVCINFO *p);
+void MG_ISO8583(TPSVCINFO *p); void MG_BITMAP(TPSVCINFO *p);
+void MG_STAN(TPSVCINFO *p); void MG_ROUTE(TPSVCINFO *p);
+void MG_RESEND(TPSVCINFO *p); void MG_CONVERT(TPSVCINFO *p);
+void MG_ERRMSG(TPSVCINFO *p); void MG_LOG(TPSVCINFO *p);
+void MG_CHANAUTH(TPSVCINFO *p); void MG_SESSION(TPSVCINFO *p);
+void MG_ACK(TPSVCINFO *p); void MG_NACK(TPSVCINFO *p);
+void MG_ECHO(TPSVCINFO *p); void MG_HEALTH(TPSVCINFO *p);
+void MG_SIGNON(TPSVCINFO *p); void MG_SIGNOFF(TPSVCINFO *p);
+void MG_KEYEXCH(TPSVCINFO *p); void MG_MAC(TPSVCINFO *p);
+void MG_PARSE(TPSVCINFO *p); void MG_PACK(TPSVCINFO *p);
+void MG_UNPACK(TPSVCINFO *p); void MG_QUEUE(TPSVCINFO *p);
+void MG_DEQUEUE(TPSVCINFO *p); void MG_RETRY(TPSVCINFO *p);
+void MG_STATS(TPSVCINFO *p); void MG_CHANSTAT(TPSVCINFO *p);
+void MG_STATUS(TPSVCINFO *p); void MG_CONFIRM(TPSVCINFO *p);
+
+/* ----- advertise table (this is what "scales to 30" cleanly) ------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"MG_RECV", MG_RECV}, {"MG_SEND", MG_SEND},
+ {"MG_ISO8583", MG_ISO8583}, {"MG_BITMAP", MG_BITMAP},
+ {"MG_STAN", MG_STAN}, {"MG_ROUTE", MG_ROUTE},
+ {"MG_RESEND", MG_RESEND}, {"MG_CONVERT", MG_CONVERT},
+ {"MG_ERRMSG", MG_ERRMSG}, {"MG_LOG", MG_LOG},
+ {"MG_CHANAUTH", MG_CHANAUTH}, {"MG_SESSION", MG_SESSION},
+ {"MG_ACK", MG_ACK}, {"MG_NACK", MG_NACK},
+ {"MG_ECHO", MG_ECHO}, {"MG_HEALTH", MG_HEALTH},
+ {"MG_SIGNON", MG_SIGNON}, {"MG_SIGNOFF", MG_SIGNOFF},
+ {"MG_KEYEXCH", MG_KEYEXCH}, {"MG_MAC", MG_MAC},
+ {"MG_PARSE", MG_PARSE}, {"MG_PACK", MG_PACK},
+ {"MG_UNPACK", MG_UNPACK}, {"MG_QUEUE", MG_QUEUE},
+ {"MG_DEQUEUE", MG_DEQUEUE}, {"MG_RETRY", MG_RETRY},
+ {"MG_STATS", MG_STATS}, {"MG_CHANSTAT", MG_CHANSTAT},
+ {"MG_STATUS", MG_STATUS}, {"MG_CONFIRM", MG_CONFIRM},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("mg_svr: tpopen FAIL: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("mg_svr: tpadvertise(%s) FAIL: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("mg_svr: %d mg 서비스 광고 완료, RM 오픈", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("mg_svr: tpsvrdone");
+}
+
+/* ----- module-local pure helpers (고정길이/비트맵/체크섬) ----------------- */
+/* 16-hex primary bitmap from a 64-bit field-present mask. */
+static void mg_bitmap_hex(unsigned long mask, char *out /* >= 17 */)
+{
+ sprintf(out, "%016lX", mask);
+}
+/* simple additive 4-hex MAC/checksum over a buffer (전문 무결성 데모). */
+static void mg_checksum(const char *s, char *out /* >= 5 */)
+{
+ unsigned int sum = 0; int i;
+ for (i = 0; s[i]; i++) sum = (sum + (unsigned char)s[i] * 31u) & 0xFFFF;
+ sprintf(out, "%04X", sum);
+}
+
+/* ========================================================================= */
+/* 1. MG_RECV - 전문 수신 접수 (direction=IN). STAN 채번 후 로그 적재. */
+/* ========================================================================= */
+void MG_RECV(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], raw[512], bizdate[16], pan[24];
+ long stan, msg_id, amount;
+
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0200");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_STR5, pan, sizeof(pan));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ amount = getl(b, T_AMOUNT);
+
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ msg_id = mgdb_next_msg_id(); if (msg_id < 0) FAIL(b);
+ if (mgdb_insert_msg(msg_id, stan, mti, chan, "IN", "000000", "", amount,
+ pan, "00", raw, "R", bizdate) < 0) FAIL(b);
+
+ setl(b, T_ARG1, stan);
+ setl(b, T_ID1, msg_id);
+ userlog("MG_RECV 수신 stan=%ld msg_id=%ld chan=%s mti=%s amt=%ld", stan, msg_id, chan, mti, amount);
+ OK(b);
+}
+
+/* 2. MG_SEND - 전문 송신 (direction=OUT). */
+void MG_SEND(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], raw[512], bizdate[16];
+ long stan, msg_id, amount;
+
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0210");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ amount = getl(b, T_AMOUNT);
+
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ msg_id = mgdb_next_msg_id(); if (msg_id < 0) FAIL(b);
+ if (mgdb_insert_msg(msg_id, stan, mti, chan, "OUT", "000000", "", amount,
+ "", "00", raw, "S", bizdate) < 0) FAIL(b);
+ setl(b, T_ARG1, stan);
+ setl(b, T_ID1, msg_id);
+ OK(b);
+}
+
+/* 3. MG_ISO8583 - ISO8583 전문 파싱 후 로그 (mti=전문 앞 4자리). */
+void MG_ISO8583(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], raw[512], bizdate[16], mti[8], proc[8];
+ long stan, msg_id, amount, n;
+
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ n = (long)strlen(raw);
+ /* 고정길이 전문: [0..3]=MTI, [4..9]=처리코드, [10..21]=금액(12자리) */
+ memset(mti, 0, sizeof(mti)); memset(proc, 0, sizeof(proc));
+ if (n >= 4) strncpy(mti, raw, 4);
+ else strcpy(mti, "0200");
+ if (n >= 10) strncpy(proc, raw + 4, 6);
+ else strcpy(proc, "000000");
+ amount = (n >= 22) ? atol(raw + 10) : getl(b, T_AMOUNT);
+
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ msg_id = mgdb_next_msg_id(); if (msg_id < 0) FAIL(b);
+ if (mgdb_insert_msg(msg_id, stan, mti, chan, "IN", proc, "", amount,
+ "", "00", raw, "R", bizdate) < 0) FAIL(b);
+ sets_(b, T_STR1, mti);
+ sets_(b, T_STR4, proc);
+ setl(b, T_AMOUNT, amount);
+ setl(b, T_ID1, msg_id);
+ OK(b);
+}
+
+/* 4. MG_BITMAP - 비트맵 계산 후 해당 전문(msg_id)에 반영. */
+void MG_BITMAP(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char hex[24];
+ unsigned long mask = (unsigned long)getl(b, T_ARG1); /* 필드 존재 마스크 */
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ char h_bitmap[40];
+ EXEC SQL END DECLARE SECTION;
+
+ mg_bitmap_hex(mask, hex);
+ sets_(b, T_STR6, hex);
+
+ h_id = getl(b, T_ID1);
+ if (h_id > 0) {
+ strncpy(h_bitmap, hex, sizeof(h_bitmap)-1); h_bitmap[sizeof(h_bitmap)-1] = 0;
+ EXEC SQL UPDATE mg_msg_log SET bitmap = :h_bitmap WHERE msg_id = :h_id;
+ if (sqlca.sqlcode < 0) { userlog("MG_BITMAP UPDATE FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ }
+ OK(b);
+}
+
+/* 5. MG_STAN - STAN 채번 (Postgres sequence). */
+void MG_STAN(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long stan = mgdb_next_stan();
+ if (stan < 0) FAIL(b);
+ setl(b, T_ARG1, stan);
+ OK(b);
+}
+
+/* 6. MG_ROUTE - 채널+전문유형 -> 목적지 라우팅 조회. */
+void MG_ROUTE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], dest[64];
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0200");
+ if (mgdb_lookup_route(chan, mti, dest) < 0) {
+ userlog("MG_ROUTE 라우팅 없음 chan=%s mti=%s", chan, mti);
+ setl(b, T_RC, 1);
+ FAIL(b);
+ }
+ sets_(b, T_STR3, dest);
+ setl(b, T_RC, 0);
+ OK(b);
+}
+
+/* 7. MG_RESEND - 전문 재전송: STAN 으로 원전문 찾아 큐에 적재. */
+void MG_RESEND(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], raw[512], bizdate[16];
+ long stan, msg_id, qid;
+
+ stan = getl(b, T_ARG1);
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0200");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+
+ msg_id = mgdb_find_msg_by_stan(stan);
+ if (msg_id < 0) { userlog("MG_RESEND 원전문 없음 stan=%ld", stan); FAIL(b); }
+
+ qid = mgdb_next_queue_id(); if (qid < 0) FAIL(b);
+ if (mgdb_enqueue(qid, stan, chan, mti, raw, bizdate) < 0) FAIL(b);
+ setl(b, T_ID2, qid);
+ userlog("MG_RESEND 재전송 큐적재 stan=%ld qid=%ld", stan, qid);
+ OK(b);
+}
+
+/* 8. MG_CONVERT - 요청전문 -> 응답전문 변환 (MTI +0x10) 후 송신 로그. */
+void MG_CONVERT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], rmti[8], raw[512], bizdate[16];
+ long stan, msg_id, amount, mnum;
+
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0200");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ amount = getl(b, T_AMOUNT);
+
+ mnum = atol(mti) + 10; /* 0200 -> 0210 응답 */
+ sprintf(rmti, "%04ld", mnum);
+
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ msg_id = mgdb_next_msg_id(); if (msg_id < 0) FAIL(b);
+ if (mgdb_insert_msg(msg_id, stan, rmti, chan, "OUT", "000000", "", amount,
+ "", "00", raw, "S", bizdate) < 0) FAIL(b);
+ sets_(b, T_STR1, rmti);
+ setl(b, T_ID1, msg_id);
+ OK(b);
+}
+
+/* 9. MG_ERRMSG - 오류 전문 기록 (status=N, rc=입력). */
+void MG_ERRMSG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], raw[512], bizdate[16], rc[4];
+ long stan, msg_id, rcnum;
+
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0210");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ rcnum = getl(b, T_RC); if (rcnum <= 0) rcnum = 96;
+ sprintf(rc, "%02ld", rcnum % 100);
+
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ msg_id = mgdb_next_msg_id(); if (msg_id < 0) FAIL(b);
+ if (mgdb_insert_msg(msg_id, stan, mti, chan, "OUT", "000000", "", 0,
+ "", rc, raw, "N", bizdate) < 0) FAIL(b);
+ setl(b, T_ID1, msg_id);
+ OK(b);
+}
+
+/* 10. MG_LOG - 채널 당일 전문 로그 건수 조회. */
+void MG_LOG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_chan[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_chan, chan, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM mg_msg_log
+ WHERE channel = :h_chan AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 11. MG_CHANAUTH - 채널 인증: UP/SIGNON 상태만 통과. */
+void MG_CHANAUTH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], st[16];
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ if (mgdb_get_channel_status(chan, st) < 0) { setl(b, T_RC, 2); FAIL(b); }
+ if (strcmp(st, "UP") == 0 || strcmp(st, "SIGNON") == 0) {
+ setl(b, T_RC, 0);
+ sets_(b, T_STATUS, st);
+ OK(b);
+ }
+ userlog("MG_CHANAUTH 거부 chan=%s status=%s", chan, st);
+ setl(b, T_RC, 1);
+ sets_(b, T_STATUS, st);
+ FAIL(b);
+}
+
+/* 12. MG_SESSION - 세션 시퀀스 채번 (mg_seq SESSION). */
+void MG_SESSION(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long v = mgdb_seq_bump("SESSION");
+ if (v < 0) FAIL(b);
+ setl(b, T_ARG2, v);
+ OK(b);
+}
+
+/* 13. MG_ACK - 응답 확인 (status=A, rc=00). */
+void MG_ACK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long msg_id = getl(b, T_ID1);
+ if (mgdb_update_msg_rc(msg_id, "00", "A") < 0) FAIL(b);
+ sets_(b, T_STATUS, "A");
+ OK(b);
+}
+
+/* 14. MG_NACK - 부정 응답 (status=N, rc=입력). */
+void MG_NACK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char rc[4];
+ long msg_id = getl(b, T_ID1), rcnum = getl(b, T_RC);
+ if (rcnum <= 0) rcnum = 91;
+ sprintf(rc, "%02ld", rcnum % 100);
+ if (mgdb_update_msg_rc(msg_id, rc, "N") < 0) FAIL(b);
+ sets_(b, T_STATUS, "N");
+ OK(b);
+}
+
+/* 15. MG_ECHO - 에코 전문(0800) 처리 + 채널 UP 표시. */
+void MG_ECHO(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], bizdate[16];
+ long stan, msg_id;
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ msg_id = mgdb_next_msg_id(); if (msg_id < 0) FAIL(b);
+ if (mgdb_insert_msg(msg_id, stan, "0800", chan, "IN", "990000", "", 0,
+ "", "00", "ECHO-TEST", "A", bizdate) < 0) FAIL(b);
+ if (mgdb_set_channel_status(chan, "UP") < 0) FAIL(b);
+ setl(b, T_ID1, msg_id);
+ sets_(b, T_STATUS, "UP");
+ OK(b);
+}
+
+/* 16. MG_HEALTH - 게이트웨이 헬스: 활성 채널 수 조회. */
+void MG_HEALTH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_up;
+ EXEC SQL END DECLARE SECTION;
+ EXEC SQL SELECT count(*) INTO :h_up FROM mg_channel WHERE status IN ('UP','SIGNON');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_up);
+ setl(b, T_RC, 0);
+ OK(b);
+}
+
+/* 17. MG_SIGNON - 채널 사인온 + 세션키 설정. */
+void MG_SIGNON(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], key[64];
+ long stan;
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_KEY1, key, sizeof(key)); if (key[0] == 0) strcpy(key, "SESSION-KEY-DFLT");
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ if (mgdb_set_channel_session(chan, key, stan) < 0) FAIL(b);
+ sets_(b, T_STATUS, "SIGNON");
+ userlog("MG_SIGNON chan=%s stan=%ld", chan, stan);
+ OK(b);
+}
+
+/* 18. MG_SIGNOFF - 채널 사인오프 (status=DOWN). */
+void MG_SIGNOFF(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16];
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ if (mgdb_set_channel_status(chan, "DOWN") < 0) FAIL(b);
+ sets_(b, T_STATUS, "DOWN");
+ OK(b);
+}
+
+/* 19. MG_KEYEXCH - 키 교환: 키버전 채번 + 세션키 갱신. */
+void MG_KEYEXCH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], key[64];
+ long ver, stan;
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_KEY1, key, sizeof(key)); if (key[0] == 0) strcpy(key, "NEW-KEY");
+ ver = mgdb_seq_bump("KEYVER"); if (ver < 0) FAIL(b);
+ stan = mgdb_next_stan(); if (stan < 0) FAIL(b);
+ if (mgdb_set_channel_session(chan, key, stan) < 0) FAIL(b);
+ setl(b, T_ARG2, ver);
+ userlog("MG_KEYEXCH chan=%s keyver=%ld", chan, ver);
+ OK(b);
+}
+
+/* 20. MG_MAC - 전문 MAC 계산 후 해당 전문에 반영 (무결성). */
+void MG_MAC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char raw[512], mac[8];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ char h_mac[8];
+ EXEC SQL END DECLARE SECTION;
+
+ gets_(b, T_STR3, raw, sizeof(raw));
+ mg_checksum(raw, mac);
+ sets_(b, T_KEY1, mac);
+
+ h_id = getl(b, T_ID1);
+ if (h_id > 0) {
+ strncpy(h_mac, mac, sizeof(h_mac)-1); h_mac[sizeof(h_mac)-1] = 0;
+ EXEC SQL UPDATE mg_msg_log SET mac = :h_mac WHERE msg_id = :h_id;
+ if (sqlca.sqlcode < 0) { userlog("MG_MAC UPDATE FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ }
+ OK(b);
+}
+
+/* 21. MG_PARSE - 고정길이 전문에서 처리코드 필드 추출 (순수 계산). */
+void MG_PARSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char raw[512], mti[8], proc[8];
+ long n;
+ gets_(b, T_STR3, raw, sizeof(raw));
+ n = (long)strlen(raw);
+ memset(mti, 0, sizeof(mti)); memset(proc, 0, sizeof(proc));
+ if (n >= 4) strncpy(mti, raw, 4); else strcpy(mti, "0000");
+ if (n >= 10) strncpy(proc, raw + 4, 6); else strcpy(proc, "000000");
+ sets_(b, T_STR1, mti);
+ sets_(b, T_STR4, proc);
+ setl(b, T_COUNT, n);
+ OK(b);
+}
+
+/* 22. MG_PACK - 고정길이 전문 조립 (순수 계산): MTI(4)+처리코드(6)+금액(12). */
+void MG_PACK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char mti[8], proc[8], raw[64];
+ long amount;
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0200");
+ gets_(b, T_STR4, proc, sizeof(proc)); if (proc[0] == 0) strcpy(proc, "000000");
+ amount = getl(b, T_AMOUNT);
+ sprintf(raw, "%.4s%.6s%012ld", mti, proc, amount);
+ sets_(b, T_STR3, raw);
+ setl(b, T_COUNT, (long)strlen(raw));
+ OK(b);
+}
+
+/* 23. MG_UNPACK - 고정길이 전문 해체 (순수 계산). */
+void MG_UNPACK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char raw[512], mti[8], proc[8];
+ long amount, n;
+ gets_(b, T_STR3, raw, sizeof(raw));
+ n = (long)strlen(raw);
+ memset(mti, 0, sizeof(mti)); memset(proc, 0, sizeof(proc));
+ if (n >= 4) strncpy(mti, raw, 4); else strcpy(mti, "0000");
+ if (n >= 10) strncpy(proc, raw + 4, 6); else strcpy(proc, "000000");
+ amount = (n >= 22) ? atol(raw + 10) : 0;
+ sets_(b, T_STR1, mti);
+ sets_(b, T_STR4, proc);
+ setl(b, T_AMOUNT, amount);
+ OK(b);
+}
+
+/* 24. MG_QUEUE - 송신 큐 적재. */
+void MG_QUEUE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], mti[8], raw[512], bizdate[16];
+ long stan, qid;
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_STR1, mti, sizeof(mti)); if (mti[0] == 0) strcpy(mti, "0200");
+ gets_(b, T_STR3, raw, sizeof(raw));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ stan = getl(b, T_ARG1);
+ if (stan <= 0) { stan = mgdb_next_stan(); if (stan < 0) FAIL(b); }
+ qid = mgdb_next_queue_id(); if (qid < 0) FAIL(b);
+ if (mgdb_enqueue(qid, stan, chan, mti, raw, bizdate) < 0) FAIL(b);
+ setl(b, T_ID2, qid);
+ setl(b, T_ARG1, stan);
+ OK(b);
+}
+
+/* 25. MG_DEQUEUE - 대기(Q) 전문 1건 인출 후 송신중(S) 전이. */
+void MG_DEQUEUE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_qid, h_stan;
+ char h_payload[512], h_chan[16];
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT queue_id, stan, channel, payload INTO :h_qid, :h_stan, :h_chan, :h_payload
+ FROM mg_queue WHERE status = 'Q' ORDER BY queue_id ASC LIMIT 1;
+ if (sqlca.sqlcode == 100) { setl(b, T_COUNT, 0); OK(b); }
+ if (sqlca.sqlcode < 0) FAIL(b);
+
+ EXEC SQL UPDATE mg_queue SET status = 'S', updated_at = now() WHERE queue_id = :h_qid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+
+ setl(b, T_ID2, h_qid);
+ setl(b, T_ARG1, h_stan);
+ sets_(b, T_CHANNEL, h_chan);
+ sets_(b, T_STR3, h_payload);
+ setl(b, T_COUNT, 1);
+ OK(b);
+}
+
+/* 26. MG_RETRY - 큐 재시도: retry_cnt +1, status=Q. */
+void MG_RETRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_qid, h_retry;
+ EXEC SQL END DECLARE SECTION;
+ h_qid = getl(b, T_ID2);
+ EXEC SQL UPDATE mg_queue SET retry_cnt = retry_cnt + 1, status = 'Q', updated_at = now()
+ WHERE queue_id = :h_qid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ EXEC SQL SELECT retry_cnt INTO :h_retry FROM mg_queue WHERE queue_id = :h_qid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ setl(b, T_COUNT, h_retry);
+ OK(b);
+}
+
+/* 27. MG_STATS - 채널 당일 통계: 수신/송신/오류 건수. */
+void MG_STATS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_in, h_out, h_err, h_sum;
+ char h_chan[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_chan, chan, sizeof(h_chan)-1); h_chan[sizeof(h_chan)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT
+ coalesce(sum(CASE WHEN direction='IN' THEN 1 ELSE 0 END),0),
+ coalesce(sum(CASE WHEN direction='OUT' THEN 1 ELSE 0 END),0),
+ coalesce(sum(CASE WHEN rc<>'00' THEN 1 ELSE 0 END),0),
+ coalesce(sum(amount),0)
+ INTO :h_in, :h_out, :h_err, :h_sum
+ FROM mg_msg_log WHERE channel = :h_chan AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_ARG1, h_in);
+ setl(b, T_ARG2, h_out);
+ setl(b, T_ARG3, h_err);
+ setl(b, T_GROSS, h_sum);
+ OK(b);
+}
+
+/* 28. MG_CHANSTAT - 채널 상태 조회. */
+void MG_CHANSTAT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char chan[16], st[16];
+ gets_(b, T_CHANNEL, chan, sizeof(chan)); if (chan[0] == 0) strcpy(chan, "CH01");
+ if (mgdb_get_channel_status(chan, st) < 0) FAIL(b);
+ sets_(b, T_STATUS, st);
+ OK(b);
+}
+
+/* 29. MG_STATUS - 전문 처리 상태 조회 (msg_id). */
+void MG_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long msg_id = getl(b, T_ID1);
+ char st[4];
+ if (mgdb_get_msg_status(msg_id, st) < 0) FAIL(b);
+ sets_(b, T_STATUS, st);
+ OK(b);
+}
+
+/* 30. MG_CONFIRM - 전문 확정 (status=C). */
+void MG_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long msg_id = getl(b, T_ID1);
+ if (mgdb_update_msg_rc(msg_id, "00", "C") < 0) FAIL(b);
+ sets_(b, T_STATUS, "C");
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/mm/batch/mm_grade_recalc_batch.pgc b/app/src/mm/batch/mm_grade_recalc_batch.pgc
new file mode 100644
index 0000000..fdb2ca5
--- /dev/null
+++ b/app/src/mm/batch/mm_grade_recalc_batch.pgc
@@ -0,0 +1,68 @@
+/*
+ * mm_grade_recalc_batch.pgc - 가맹점 등급 재산정 배치 (누적매출 커서 + XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction, an EXEC SQL cursor sums each merchant's 누적 매출(gross) from
+ * merchant_summary, derives a grade (S/A/B/C) and UPDATEs mm_merchant_ext (via
+ * the linked dbio lib), then tpcommit drives XA 2PC.
+ * Usage: mm_grade_recalc_batch
+ */
+#include
+#include
+#include
+#include
+#include "mm_dbio.h"
+
+/* 누적매출 -> 등급 임계 (원): S>=10억, A>=3억, B>=5천만, 그 외 C */
+static const char *grade_of(long gross)
+{
+ if (gross >= 1000000000L) return "S";
+ if (gross >= 300000000L) return "A";
+ if (gross >= 50000000L) return "B";
+ return "C";
+}
+
+int main(int argc, char **argv)
+{
+ long rows = 0;
+ (void)argc; (void)argv;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64];
+ long h_gross;
+ EXEC SQL END DECLARE SECTION;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ /* 가맹점별 누적 매출 = merchant_summary.gross_amount 합계 */
+ EXEC SQL DECLARE grc CURSOR FOR
+ SELECT ms.merchant_id, coalesce(sum(ms.gross_amount),0)
+ FROM merchant_summary ms
+ JOIN mm_merchant_ext me ON me.merchant_id = ms.merchant_id
+ WHERE me.status = 'ACTIVE'
+ GROUP BY ms.merchant_id ORDER BY ms.merchant_id;
+ EXEC SQL OPEN grc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH grc INTO :h_merch, :h_gross;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE grc; tpabort(0); return 1; }
+ if (mmdb_update_merchant_grade(h_merch, grade_of(h_gross)) < 0) {
+ EXEC SQL CLOSE grc; tpabort(0); return 1;
+ }
+ if (mmdb_insert_merch_hist(h_merch, "GRADE", "", grade_of(h_gross), "배치 재산정") < 0) {
+ EXEC SQL CLOSE grc; tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE grc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> mm_grade_recalc_batch COMMIT: 재산정 가맹점=%ld\n", rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/mm/batch/mm_stat_batch.pgc b/app/src/mm/batch/mm_stat_batch.pgc
new file mode 100644
index 0000000..9cef65b
--- /dev/null
+++ b/app/src/mm/batch/mm_stat_batch.pgc
@@ -0,0 +1,53 @@
+/*
+ * mm_stat_batch.pgc - 등급별 가맹점 수 통계 배치 (커서 + XA).
+ *
+ * Cursors over mm_merchant_ext grouped by grade, upserting a per-grade count
+ * into mm_grade_stat for the run date, all under ONE global XA transaction.
+ * Usage: mm_stat_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+
+int main(int argc, char **argv)
+{
+ const char *statdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_statdate[16], h_grade[8];
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_statdate, statdate, sizeof(h_statdate)-1); h_statdate[sizeof(h_statdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE gsc CURSOR FOR
+ SELECT grade, count(*) FROM mm_merchant_ext
+ WHERE status = 'ACTIVE' GROUP BY grade ORDER BY grade;
+ EXEC SQL OPEN gsc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH gsc INTO :h_grade, :h_cnt;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE gsc; tpabort(0); return 1; }
+ EXEC SQL INSERT INTO mm_grade_stat (stat_date, grade, merch_cnt)
+ VALUES (:h_statdate, :h_grade, :h_cnt)
+ ON CONFLICT (stat_date, grade) DO UPDATE
+ SET merch_cnt = EXCLUDED.merch_cnt, updated_at = now();
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "UPSERT FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE gsc; tpabort(0); return 1; }
+ rows++;
+ }
+ EXEC SQL CLOSE gsc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> mm_stat_batch COMMIT: statdate=%s 등급구간=%ld\n", statdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/mm/dbio/mm_dbio.h b/app/src/mm/dbio/mm_dbio.h
new file mode 100644
index 0000000..22408d1
--- /dev/null
+++ b/app/src/mm/dbio/mm_dbio.h
@@ -0,0 +1,28 @@
+/*
+ * mm_dbio.h - mm 모듈 (마스터/master) DB 접근 계층 (libmmdbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * mm_svr and the mm batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef MM_DBIO_H
+#define MM_DBIO_H
+
+/* 가맹점 마스터 (mm_merch_dbio) */
+long mmdb_next_hist_id(void);
+int mmdb_insert_merchant(const char *merch, const char *name, const char *grade,
+ const char *acct, const char *status);
+int mmdb_get_merchant(const char *merch, char *name_out /*>=64*/,
+ char *grade_out /*>=8*/, char *status_out /*>=16*/);
+int mmdb_update_merchant_status(const char *merch, const char *status);
+int mmdb_update_merchant_grade(const char *merch, const char *grade);
+int mmdb_insert_merch_hist(const char *merch, const char *action,
+ const char *before_val, const char *after_val,
+ const char *memo);
+
+/* 수수료율 마스터 (mm_feerate_dbio) */
+int mmdb_set_fee_rate(const char *merch, const char *card_type, long rate_bps);
+int mmdb_get_fee_rate(const char *merch, const char *card_type, long *bps_out);
+long mmdb_feerate_hist_count(const char *merch);
+
+#endif /* MM_DBIO_H */
diff --git a/app/src/mm/dbio/mm_feerate_dbio.pgc b/app/src/mm/dbio/mm_feerate_dbio.pgc
new file mode 100644
index 0000000..6bb437a
--- /dev/null
+++ b/app/src/mm/dbio/mm_feerate_dbio.pgc
@@ -0,0 +1,77 @@
+/*
+ * mm_feerate_dbio.pgc - 수수료율 마스터 upsert + 이력 적재 (libmmdbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "mm_dbio.h"
+
+int mmdb_set_fee_rate(const char *merch, const char *card_type, long rate_bps)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_bps = rate_bps;
+ char h_merch[64], h_ctype[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_ctype, card_type, sizeof(h_ctype) - 1); h_ctype[sizeof(h_ctype)-1] = 0;
+
+ EXEC SQL INSERT INTO mm_fee_rate (merchant_id, card_type, rate_bps, eff_date)
+ VALUES (:h_merch, :h_ctype, :h_bps, current_date)
+ ON CONFLICT (merchant_id, card_type) DO UPDATE
+ SET rate_bps = EXCLUDED.rate_bps, eff_date = current_date, updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("mmdb_set_fee_rate FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+
+ /* 이력 적재 */
+ EXEC SQL SELECT nextval('mm_feerate_hist_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+ EXEC SQL INSERT INTO mm_fee_rate_hist
+ (hist_id, merchant_id, card_type, rate_bps, eff_date)
+ VALUES (:h_id, :h_merch, :h_ctype, :h_bps, current_date);
+ if (sqlca.sqlcode < 0) {
+ userlog("mmdb_set_fee_rate hist FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mmdb_get_fee_rate(const char *merch, const char *card_type, long *bps_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_bps;
+ char h_merch[64], h_ctype[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_ctype, card_type, sizeof(h_ctype) - 1); h_ctype[sizeof(h_ctype)-1] = 0;
+
+ EXEC SQL SELECT rate_bps INTO :h_bps FROM mm_fee_rate
+ WHERE merchant_id = :h_merch AND card_type = :h_ctype;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mmdb_get_fee_rate FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ *bps_out = h_bps;
+ return 0;
+}
+
+long mmdb_feerate_hist_count(const char *merch)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM mm_fee_rate_hist
+ WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0) {
+ userlog("mmdb_feerate_hist_count FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_cnt;
+}
diff --git a/app/src/mm/dbio/mm_merch_dbio.pgc b/app/src/mm/dbio/mm_merch_dbio.pgc
new file mode 100644
index 0000000..62ad0ed
--- /dev/null
+++ b/app/src/mm/dbio/mm_merch_dbio.pgc
@@ -0,0 +1,133 @@
+/*
+ * mm_merch_dbio.pgc - 가맹점 확장 마스터 CRUD + 변경 이력 (libmmdbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "mm_dbio.h"
+
+long mmdb_next_hist_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('mm_merch_hist_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("mmdb_next_hist_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int mmdb_insert_merchant(const char *merch, const char *name, const char *grade,
+ const char *acct, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_name[128], h_grade[8], h_acct[64], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name) - 1); h_name[sizeof(h_name)-1] = 0;
+ strncpy(h_grade, grade, sizeof(h_grade) - 1); h_grade[sizeof(h_grade)-1] = 0;
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1]= 0;
+
+ EXEC SQL INSERT INTO mm_merchant_ext
+ (merchant_id, name, grade, settle_acct, status)
+ VALUES (:h_merch, :h_name, :h_grade, :h_acct, :h_status)
+ ON CONFLICT (merchant_id) DO UPDATE
+ SET name = EXCLUDED.name, grade = EXCLUDED.grade,
+ settle_acct = EXCLUDED.settle_acct, status = EXCLUDED.status,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("mmdb_insert_merchant FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mmdb_get_merchant(const char *merch, char *name_out, char *grade_out,
+ char *status_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_name[128], h_grade[8], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+
+ EXEC SQL SELECT name, grade, status INTO :h_name, :h_grade, :h_status
+ FROM mm_merchant_ext WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mmdb_get_merchant FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ strncpy(name_out, h_name, 63); name_out[63] = 0;
+ strncpy(grade_out, h_grade, 7); grade_out[7] = 0;
+ strncpy(status_out, h_status, 15); status_out[15] = 0;
+ return 0;
+}
+
+int mmdb_update_merchant_status(const char *merch, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1]= 0;
+
+ EXEC SQL UPDATE mm_merchant_ext SET status = :h_status, updated_at = now()
+ WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mmdb_update_merchant_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mmdb_update_merchant_grade(const char *merch, const char *grade)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_grade[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_grade, grade, sizeof(h_grade) - 1); h_grade[sizeof(h_grade)-1] = 0;
+
+ EXEC SQL UPDATE mm_merchant_ext SET grade = :h_grade, updated_at = now()
+ WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("mmdb_update_merchant_grade FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int mmdb_insert_merch_hist(const char *merch, const char *action,
+ const char *before_val, const char *after_val,
+ const char *memo)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ char h_merch[64], h_action[16], h_before[64], h_after[64], h_memo[128];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_action, action, sizeof(h_action) - 1); h_action[sizeof(h_action)-1]= 0;
+ strncpy(h_before, before_val, sizeof(h_before) - 1); h_before[sizeof(h_before)-1]= 0;
+ strncpy(h_after, after_val, sizeof(h_after) - 1); h_after[sizeof(h_after)-1] = 0;
+ strncpy(h_memo, memo, sizeof(h_memo) - 1); h_memo[sizeof(h_memo)-1] = 0;
+
+ EXEC SQL SELECT nextval('mm_merch_hist_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO mm_merch_hist
+ (hist_id, merchant_id, action, before_val, after_val, memo)
+ VALUES (:h_id, :h_merch, :h_action, :h_before, :h_after, :h_memo);
+ if (sqlca.sqlcode < 0) {
+ userlog("mmdb_insert_merch_hist FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/mm/mm_svr.pgc b/app/src/mm/mm_svr.pgc
new file mode 100644
index 0000000..12cd395
--- /dev/null
+++ b/app/src/mm/mm_svr.pgc
@@ -0,0 +1,655 @@
+/*
+ * mm_svr.pgc - mm (마스터/master) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online mm services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct
+ * 마스터(기준정보) operation with genuine EXEC SQL (directly, or via the linked
+ * libmmdbio.a dbio layer) and common math from libacqcommon.a.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch libndrxxaecpg.so);
+ * there is NO EXEC SQL CONNECT. Each tpcall runs on this process's XA branch, so
+ * a service sees its OWN uncommitted writes but NOT a sibling branch's.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "mm_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void MM_MERCH_REG(TPSVCINFO *p); void MM_MERCH_UPD(TPSVCINFO *p);
+void MM_MERCH_INQ(TPSVCINFO *p); void MM_MERCH_DEL(TPSVCINFO *p);
+void MM_FEERATE_SET(TPSVCINFO *p); void MM_FEERATE_INQ(TPSVCINFO *p);
+void MM_FEERATE_HIST(TPSVCINFO *p); void MM_BIN_REG(TPSVCINFO *p);
+void MM_BIN_INQ(TPSVCINFO *p); void MM_LIMIT_SET(TPSVCINFO *p);
+void MM_LIMIT_INQ(TPSVCINFO *p); void MM_CODE_REG(TPSVCINFO *p);
+void MM_CODE_INQ(TPSVCINFO *p); void MM_ISSUER_REG(TPSVCINFO *p);
+void MM_ISSUER_INQ(TPSVCINFO *p); void MM_MERCH_STATUS(TPSVCINFO *p);
+void MM_MERCH_ACCT(TPSVCINFO *p); void MM_MERCH_GRADE(TPSVCINFO *p);
+void MM_TERMINAL_REG(TPSVCINFO *p); void MM_TERMINAL_INQ(TPSVCINFO *p);
+void MM_MCC_REG(TPSVCINFO *p); void MM_HOLIDAY_REG(TPSVCINFO *p);
+void MM_FX_REG(TPSVCINFO *p); void MM_FX_INQ(TPSVCINFO *p);
+void MM_MERCH_BULK(TPSVCINFO *p); void MM_VALIDATE(TPSVCINFO *p);
+void MM_MERCH_SUM(TPSVCINFO *p); void MM_CODE_CACHE(TPSVCINFO *p);
+void MM_STATUS(TPSVCINFO *p); void MM_CONFIRM(TPSVCINFO *p);
+
+/* ----- advertise table (this is what "scales to 30" cleanly) ------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"MM_MERCH_REG", MM_MERCH_REG}, {"MM_MERCH_UPD", MM_MERCH_UPD},
+ {"MM_MERCH_INQ", MM_MERCH_INQ}, {"MM_MERCH_DEL", MM_MERCH_DEL},
+ {"MM_FEERATE_SET", MM_FEERATE_SET}, {"MM_FEERATE_INQ", MM_FEERATE_INQ},
+ {"MM_FEERATE_HIST", MM_FEERATE_HIST}, {"MM_BIN_REG", MM_BIN_REG},
+ {"MM_BIN_INQ", MM_BIN_INQ}, {"MM_LIMIT_SET", MM_LIMIT_SET},
+ {"MM_LIMIT_INQ", MM_LIMIT_INQ}, {"MM_CODE_REG", MM_CODE_REG},
+ {"MM_CODE_INQ", MM_CODE_INQ}, {"MM_ISSUER_REG", MM_ISSUER_REG},
+ {"MM_ISSUER_INQ", MM_ISSUER_INQ}, {"MM_MERCH_STATUS", MM_MERCH_STATUS},
+ {"MM_MERCH_ACCT", MM_MERCH_ACCT}, {"MM_MERCH_GRADE", MM_MERCH_GRADE},
+ {"MM_TERMINAL_REG", MM_TERMINAL_REG}, {"MM_TERMINAL_INQ", MM_TERMINAL_INQ},
+ {"MM_MCC_REG", MM_MCC_REG}, {"MM_HOLIDAY_REG", MM_HOLIDAY_REG},
+ {"MM_FX_REG", MM_FX_REG}, {"MM_FX_INQ", MM_FX_INQ},
+ {"MM_MERCH_BULK", MM_MERCH_BULK}, {"MM_VALIDATE", MM_VALIDATE},
+ {"MM_MERCH_SUM", MM_MERCH_SUM}, {"MM_CODE_CACHE", MM_CODE_CACHE},
+ {"MM_STATUS", MM_STATUS}, {"MM_CONFIRM", MM_CONFIRM},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("mm_svr: tpopen 실패: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("mm_svr: tpadvertise(%s) 실패: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("mm_svr: mm 마스터 서비스 %d개 광고 완료, RM open", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("mm_svr: tpsvrdone");
+}
+
+/* ========================================================================= */
+/* 1. MM_MERCH_REG - 가맹점 확장 마스터 등록 (dbio upsert + 이력). */
+/* ========================================================================= */
+void MM_MERCH_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], name[128], grade[8], acct[64];
+
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ if (merch[0] == 0) { userlog("MM_MERCH_REG: T_MERCHANT 누락"); FAIL(b); }
+ gets_(b, T_STR1, name, sizeof(name)); if (name[0] == 0) strcpy(name, "무명가맹점");
+ gets_(b, T_STR2, grade, sizeof(grade)); if (grade[0] == 0) strcpy(grade, "C");
+ gets_(b, T_STR3, acct, sizeof(acct));
+
+ if (mmdb_insert_merchant(merch, name, grade, acct, "ACTIVE") < 0) FAIL(b);
+ if (mmdb_insert_merch_hist(merch, "REG", "", grade, "신규 등록") < 0) FAIL(b);
+
+ Bchg(b, T_STATUS, 0, "ACTIVE", 0L);
+ userlog("MM_MERCH_REG merch=%s grade=%s 등록", merch, grade);
+ OK(b);
+}
+
+/* 2. MM_MERCH_UPD - 가맹점명/계좌 수정 (직접 UPDATE + 이력). */
+void MM_MERCH_UPD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], name[128], acct[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_name[128], h_acct[64];
+ EXEC SQL END DECLARE SECTION;
+
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STR1, name, sizeof(name)); if (name[0] == 0) strcpy(name, "무명가맹점");
+ gets_(b, T_STR3, acct, sizeof(acct));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+ strncpy(h_acct, acct, sizeof(h_acct)-1); h_acct[sizeof(h_acct)-1] = 0;
+
+ EXEC SQL UPDATE mm_merchant_ext
+ SET name = :h_name, settle_acct = :h_acct, updated_at = now()
+ WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("MM_MERCH_UPD FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b);
+ }
+ if (mmdb_insert_merch_hist(merch, "UPD", "", name, "정보 수정") < 0) FAIL(b);
+ OK(b);
+}
+
+/* 3. MM_MERCH_INQ - 가맹점 마스터 조회 (dbio). */
+void MM_MERCH_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], name[64], grade[8], status[16];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ if (mmdb_get_merchant(merch, name, grade, status) < 0) FAIL(b);
+ Bchg(b, T_STR1, 0, name, 0L);
+ Bchg(b, T_STR2, 0, grade, 0L);
+ Bchg(b, T_STATUS, 0, status, 0L);
+ OK(b);
+}
+
+/* 4. MM_MERCH_DEL - 가맹점 논리 삭제 (상태 DELETED + 이력). */
+void MM_MERCH_DEL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ if (mmdb_update_merchant_status(merch, "DELETED") < 0) FAIL(b);
+ if (mmdb_insert_merch_hist(merch, "DEL", "ACTIVE", "DELETED", "논리 삭제") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "DELETED", 0L);
+ OK(b);
+}
+
+/* 5. MM_FEERATE_SET - 수수료율 설정 (dbio upsert + 이력). */
+void MM_FEERATE_SET(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], ctype[16];
+ long bps;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STR4, ctype, sizeof(ctype)); if (ctype[0] == 0) strcpy(ctype, "CREDIT");
+ bps = getl(b, T_ARG1); if (bps <= 0) bps = 25;
+ if (mmdb_set_fee_rate(merch, ctype, bps) < 0) FAIL(b);
+ userlog("MM_FEERATE_SET merch=%s type=%s bps=%ld", merch, ctype, bps);
+ OK(b);
+}
+
+/* 6. MM_FEERATE_INQ - 수수료율 조회 (dbio). */
+void MM_FEERATE_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], ctype[16];
+ long bps = 25;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STR4, ctype, sizeof(ctype)); if (ctype[0] == 0) strcpy(ctype, "CREDIT");
+ if (mmdb_get_fee_rate(merch, ctype, &bps) < 0) FAIL(b);
+ setl(b, T_ARG1, bps);
+ OK(b);
+}
+
+/* 7. MM_FEERATE_HIST - 수수료율 변경 이력 건수 (dbio). */
+void MM_FEERATE_HIST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ long cnt;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ cnt = mmdb_feerate_hist_count(merch);
+ if (cnt < 0) FAIL(b);
+ setl(b, T_COUNT, cnt);
+ OK(b);
+}
+
+/* 8. MM_BIN_REG - BIN 마스터 등록. */
+void MM_BIN_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bin[16], issuer[32], brand[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bin[16], h_issuer[32], h_brand[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, bin, sizeof(bin));
+ if (bin[0] == 0) { userlog("MM_BIN_REG: T_KEY1(bin) 누락"); FAIL(b); }
+ gets_(b, T_ISSUER, issuer, sizeof(issuer)); if (issuer[0] == 0) strcpy(issuer, "UNKNOWN");
+ gets_(b, T_STR5, brand, sizeof(brand)); if (brand[0] == 0) strcpy(brand, "LOCAL");
+ strncpy(h_bin, bin, sizeof(h_bin)-1); h_bin[sizeof(h_bin)-1] = 0;
+ strncpy(h_issuer, issuer, sizeof(h_issuer)-1); h_issuer[sizeof(h_issuer)-1] = 0;
+ strncpy(h_brand, brand, sizeof(h_brand)-1); h_brand[sizeof(h_brand)-1] = 0;
+
+ EXEC SQL INSERT INTO mm_bin (bin, issuer_cd, card_brand)
+ VALUES (:h_bin, :h_issuer, :h_brand)
+ ON CONFLICT (bin) DO UPDATE
+ SET issuer_cd = EXCLUDED.issuer_cd, card_brand = EXCLUDED.card_brand;
+ if (sqlca.sqlcode < 0) { userlog("MM_BIN_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 9. MM_BIN_INQ - BIN 조회. */
+void MM_BIN_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bin[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bin[16], h_issuer[32], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, bin, sizeof(bin));
+ strncpy(h_bin, bin, sizeof(h_bin)-1); h_bin[sizeof(h_bin)-1] = 0;
+ EXEC SQL SELECT issuer_cd, status INTO :h_issuer, :h_status
+ FROM mm_bin WHERE bin = :h_bin;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_ISSUER, 0, h_issuer, 0L);
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ OK(b);
+}
+
+/* 10. MM_LIMIT_SET - 가맹점 한도 설정 (upsert). */
+void MM_LIMIT_SET(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_daily, h_monthly, h_pertxn;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ char merch[64];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ h_daily = getl(b, T_AMT1); if (h_daily <= 0) h_daily = 100000000;
+ h_monthly = getl(b, T_AMT2); if (h_monthly <= 0) h_monthly = h_daily * 30;
+ h_pertxn = getl(b, T_AMT3); if (h_pertxn <= 0) h_pertxn = 10000000;
+
+ EXEC SQL INSERT INTO mm_limit
+ (merchant_id, daily_limit, monthly_limit, per_txn_limit)
+ VALUES (:h_merch, :h_daily, :h_monthly, :h_pertxn)
+ ON CONFLICT (merchant_id) DO UPDATE
+ SET daily_limit = EXCLUDED.daily_limit, monthly_limit = EXCLUDED.monthly_limit,
+ per_txn_limit = EXCLUDED.per_txn_limit, updated_at = now();
+ if (sqlca.sqlcode < 0) { userlog("MM_LIMIT_SET FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 11. MM_LIMIT_INQ - 가맹점 한도 조회. */
+void MM_LIMIT_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_daily, h_monthly, h_pertxn;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ char merch[64];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ EXEC SQL SELECT daily_limit, monthly_limit, per_txn_limit
+ INTO :h_daily, :h_monthly, :h_pertxn FROM mm_limit WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ setl(b, T_AMT1, h_daily); setl(b, T_AMT2, h_monthly); setl(b, T_AMT3, h_pertxn);
+ OK(b);
+}
+
+/* 12. MM_CODE_REG - 공통코드 등록. */
+void MM_CODE_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32], code[32], name[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_grp[32], h_code[32], h_name[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "GEN");
+ gets_(b, T_KEY1, code, sizeof(code));
+ if (code[0] == 0) { userlog("MM_CODE_REG: T_KEY1(code) 누락"); FAIL(b); }
+ gets_(b, T_STR2, name, sizeof(name));
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ strncpy(h_code, code, sizeof(h_code)-1); h_code[sizeof(h_code)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+ EXEC SQL INSERT INTO mm_code (code_group, code, code_name)
+ VALUES (:h_grp, :h_code, :h_name)
+ ON CONFLICT (code_group, code) DO UPDATE SET code_name = EXCLUDED.code_name;
+ if (sqlca.sqlcode < 0) { userlog("MM_CODE_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 13. MM_CODE_INQ - 공통코드 조회. */
+void MM_CODE_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32], code[32];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_grp[32], h_code[32], h_name[64], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "GEN");
+ gets_(b, T_KEY1, code, sizeof(code));
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ strncpy(h_code, code, sizeof(h_code)-1); h_code[sizeof(h_code)-1] = 0;
+ EXEC SQL SELECT code_name, status INTO :h_name, :h_status
+ FROM mm_code WHERE code_group = :h_grp AND code = :h_code;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_STR2, 0, h_name, 0L);
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ OK(b);
+}
+
+/* 14. MM_ISSUER_REG - 발급사 마스터 등록. */
+void MM_ISSUER_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char cd[32], name[64], country[8];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_cd[32], h_name[64], h_country[8];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_ISSUER, cd, sizeof(cd));
+ if (cd[0] == 0) { userlog("MM_ISSUER_REG: T_ISSUER 누락"); FAIL(b); }
+ gets_(b, T_STR1, name, sizeof(name));
+ gets_(b, T_STR2, country, sizeof(country)); if (country[0] == 0) strcpy(country, "KR");
+ strncpy(h_cd, cd, sizeof(h_cd)-1); h_cd[sizeof(h_cd)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+ strncpy(h_country, country, sizeof(h_country)-1); h_country[sizeof(h_country)-1] = 0;
+ EXEC SQL INSERT INTO mm_issuer (issuer_cd, issuer_name, country)
+ VALUES (:h_cd, :h_name, :h_country)
+ ON CONFLICT (issuer_cd) DO UPDATE
+ SET issuer_name = EXCLUDED.issuer_name, country = EXCLUDED.country;
+ if (sqlca.sqlcode < 0) { userlog("MM_ISSUER_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 15. MM_ISSUER_INQ - 발급사 조회. */
+void MM_ISSUER_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char cd[32];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_cd[32], h_name[64], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_ISSUER, cd, sizeof(cd));
+ strncpy(h_cd, cd, sizeof(h_cd)-1); h_cd[sizeof(h_cd)-1] = 0;
+ EXEC SQL SELECT issuer_name, status INTO :h_name, :h_status
+ FROM mm_issuer WHERE issuer_cd = :h_cd;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_STR1, 0, h_name, 0L);
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ OK(b);
+}
+
+/* 16. MM_MERCH_STATUS - 가맹점 상태 변경 (dbio + 이력). */
+void MM_MERCH_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], status[16];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STATUS, status, sizeof(status)); if (status[0] == 0) strcpy(status, "HOLD");
+ if (mmdb_update_merchant_status(merch, status) < 0) FAIL(b);
+ if (mmdb_insert_merch_hist(merch, "STATUS", "", status, "상태 변경") < 0) FAIL(b);
+ OK(b);
+}
+
+/* 17. MM_MERCH_ACCT - 정산계좌 변경 (직접 UPDATE + 이력). */
+void MM_MERCH_ACCT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], acct[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_acct[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STR3, acct, sizeof(acct));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_acct, acct, sizeof(h_acct)-1); h_acct[sizeof(h_acct)-1] = 0;
+ EXEC SQL UPDATE mm_merchant_ext SET settle_acct = :h_acct, updated_at = now()
+ WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ if (mmdb_insert_merch_hist(merch, "ACCT", "", acct, "계좌 변경") < 0) FAIL(b);
+ OK(b);
+}
+
+/* 18. MM_MERCH_GRADE - 가맹점 등급 변경 (dbio + 이력). */
+void MM_MERCH_GRADE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], grade[8];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STR2, grade, sizeof(grade)); if (grade[0] == 0) strcpy(grade, "C");
+ if (mmdb_update_merchant_grade(merch, grade) < 0) FAIL(b);
+ if (mmdb_insert_merch_hist(merch, "GRADE", "", grade, "등급 변경") < 0) FAIL(b);
+ Bchg(b, T_STR2, 0, grade, 0L);
+ OK(b);
+}
+
+/* 19. MM_TERMINAL_REG - 단말 마스터 등록. */
+void MM_TERMINAL_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char term[32], merch[64], model[32];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_term[32], h_merch[64], h_model[32];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR5, term, sizeof(term));
+ if (term[0] == 0) { userlog("MM_TERMINAL_REG: T_STR5(terminal) 누락"); FAIL(b); }
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_STR6, model, sizeof(model)); if (model[0] == 0) strcpy(model, "STD");
+ strncpy(h_term, term, sizeof(h_term)-1); h_term[sizeof(h_term)-1] = 0;
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_model, model, sizeof(h_model)-1); h_model[sizeof(h_model)-1] = 0;
+ EXEC SQL INSERT INTO mm_terminal (terminal_id, merchant_id, model)
+ VALUES (:h_term, :h_merch, :h_model)
+ ON CONFLICT (terminal_id) DO UPDATE
+ SET merchant_id = EXCLUDED.merchant_id, model = EXCLUDED.model;
+ if (sqlca.sqlcode < 0) { userlog("MM_TERMINAL_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 20. MM_TERMINAL_INQ - 단말 조회. */
+void MM_TERMINAL_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char term[32];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_term[32], h_merch[64], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR5, term, sizeof(term));
+ strncpy(h_term, term, sizeof(h_term)-1); h_term[sizeof(h_term)-1] = 0;
+ EXEC SQL SELECT merchant_id, status INTO :h_merch, :h_status
+ FROM mm_terminal WHERE terminal_id = :h_term;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_MERCHANT, 0, h_merch, 0L);
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ OK(b);
+}
+
+/* 21. MM_MCC_REG - MCC 업종 마스터 등록. */
+void MM_MCC_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char mcc[16], name[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_risk;
+ char h_mcc[16], h_name[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR6, mcc, sizeof(mcc));
+ if (mcc[0] == 0) { userlog("MM_MCC_REG: T_STR6(mcc) 누락"); FAIL(b); }
+ gets_(b, T_STR1, name, sizeof(name));
+ h_risk = getl(b, T_ARG1); if (h_risk <= 0) h_risk = 1;
+ strncpy(h_mcc, mcc, sizeof(h_mcc)-1); h_mcc[sizeof(h_mcc)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+ EXEC SQL INSERT INTO mm_mcc (mcc, mcc_name, risk_level)
+ VALUES (:h_mcc, :h_name, :h_risk)
+ ON CONFLICT (mcc) DO UPDATE
+ SET mcc_name = EXCLUDED.mcc_name, risk_level = EXCLUDED.risk_level;
+ if (sqlca.sqlcode < 0) { userlog("MM_MCC_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 22. MM_HOLIDAY_REG - 공휴일 마스터 등록. */
+void MM_HOLIDAY_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char hdate[16], name[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_date[16], h_name[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, hdate, sizeof(hdate));
+ if (hdate[0] == 0) { userlog("MM_HOLIDAY_REG: T_BIZDATE 누락"); FAIL(b); }
+ gets_(b, T_STR1, name, sizeof(name)); if (name[0] == 0) strcpy(name, "공휴일");
+ strncpy(h_date, hdate, sizeof(h_date)-1); h_date[sizeof(h_date)-1] = 0;
+ strncpy(h_name, name, sizeof(h_name)-1); h_name[sizeof(h_name)-1] = 0;
+ EXEC SQL INSERT INTO mm_holiday (holiday_date, holiday_name)
+ VALUES (:h_date, :h_name)
+ ON CONFLICT (holiday_date) DO UPDATE SET holiday_name = EXCLUDED.holiday_name;
+ if (sqlca.sqlcode < 0) { userlog("MM_HOLIDAY_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 23. MM_FX_REG - 환율 마스터 upsert. */
+void MM_FX_REG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ccy[8];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_rate;
+ char h_ccy[8];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_CCY, ccy, sizeof(ccy)); if (ccy[0] == 0) strcpy(ccy, "USD");
+ h_rate = getl(b, T_ARG2); if (h_rate <= 0) h_rate = 1350;
+ strncpy(h_ccy, ccy, sizeof(h_ccy)-1); h_ccy[sizeof(h_ccy)-1] = 0;
+ EXEC SQL INSERT INTO mm_fx_rate (ccy, rate_bps, base_date)
+ VALUES (:h_ccy, :h_rate, current_date)
+ ON CONFLICT (ccy) DO UPDATE
+ SET rate_bps = EXCLUDED.rate_bps, base_date = current_date, updated_at = now();
+ if (sqlca.sqlcode < 0) { userlog("MM_FX_REG FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ OK(b);
+}
+
+/* 24. MM_FX_INQ - 환율 조회 + 금액 환산 (rate_bps는 100 기준 bps). */
+void MM_FX_INQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ccy[8];
+ long fx_amt, krw;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_rate;
+ char h_ccy[8];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_CCY, ccy, sizeof(ccy)); if (ccy[0] == 0) strcpy(ccy, "USD");
+ fx_amt = getl(b, T_FX_AMT);
+ strncpy(h_ccy, ccy, sizeof(h_ccy)-1); h_ccy[sizeof(h_ccy)-1] = 0;
+ EXEC SQL SELECT rate_bps INTO :h_rate FROM mm_fx_rate WHERE ccy = :h_ccy;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ krw = fx_amt * h_rate / 100;
+ setl(b, T_ARG2, h_rate);
+ setl(b, T_AMOUNT, krw);
+ OK(b);
+}
+
+/* 25. MM_MERCH_BULK - 가맹점 대량 등록 (T_COUNT개 파생 생성). */
+void MM_MERCH_BULK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char prefix[48], grade[8];
+ long n, i, made = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_name[128], h_grade[8];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, prefix, sizeof(prefix)); if (prefix[0] == 0) strcpy(prefix, "MBULK");
+ gets_(b, T_STR2, grade, sizeof(grade)); if (grade[0] == 0) strcpy(grade, "C");
+ n = getl(b, T_COUNT); if (n < 1) n = 1; if (n > 1000) n = 1000;
+ strncpy(h_grade, grade, sizeof(h_grade)-1); h_grade[sizeof(h_grade)-1] = 0;
+ for (i = 1; i <= n; i++) {
+ snprintf(h_merch, sizeof(h_merch), "%s-%04ld", prefix, i);
+ snprintf(h_name, sizeof(h_name), "%s 지점%ld", prefix, i);
+ EXEC SQL INSERT INTO mm_merchant_ext (merchant_id, name, grade)
+ VALUES (:h_merch, :h_name, :h_grade)
+ ON CONFLICT (merchant_id) DO NOTHING;
+ if (sqlca.sqlcode < 0) { userlog("MM_MERCH_BULK FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ made++;
+ }
+ setl(b, T_COUNT, made);
+ userlog("MM_MERCH_BULK prefix=%s 생성=%ld", prefix, made);
+ OK(b);
+}
+
+/* 26. MM_VALIDATE - 가맹점 마스터 정합성 검사 (존재+수수료율 존재). */
+void MM_VALIDATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_mcnt, h_fcnt;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_mcnt FROM mm_merchant_ext
+ WHERE merchant_id = :h_merch AND status = 'ACTIVE';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_fcnt FROM mm_fee_rate WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_mcnt == 0) rc = 1; /* 마스터 없음/비활성 */
+ else if (h_fcnt == 0) rc = 2; /* 수수료율 미설정 */
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 27. MM_MERCH_SUM - 상태별 가맹점 수 집계 (ACTIVE 기준). */
+void MM_MERCH_SUM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char status[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STATUS, status, sizeof(status)); if (status[0] == 0) strcpy(status, "ACTIVE");
+ strncpy(h_status, status, sizeof(h_status)-1); h_status[sizeof(h_status)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM mm_merchant_ext WHERE status = :h_status;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 28. MM_CODE_CACHE - 코드그룹의 활성 코드 수 (캐시 워밍 시뮬). */
+void MM_CODE_CACHE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_grp[32];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR1, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "CARD_TYPE");
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM mm_code
+ WHERE code_group = :h_grp AND status = 'ACTIVE';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 29. MM_STATUS - 서비스 상태 (전체 가맹점 수, RC=0). */
+void MM_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM mm_merchant_ext;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, 0);
+ Bchg(b, T_MSG, 0, "mm 마스터 서비스 정상", 0L);
+ OK(b);
+}
+
+/* 30. MM_CONFIRM - 가맹점 승인 확정 (상태 ACTIVE + 이력). */
+void MM_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ if (mmdb_update_merchant_status(merch, "ACTIVE") < 0) FAIL(b);
+ if (mmdb_insert_merch_hist(merch, "STATUS", "HOLD", "ACTIVE", "승인 확정") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "ACTIVE", 0L);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/py/batch/py_filegen_batch.pgc b/app/src/py/batch/py_filegen_batch.pgc
new file mode 100644
index 0000000..6791e6e
--- /dev/null
+++ b/app/src/py/batch/py_filegen_batch.pgc
@@ -0,0 +1,77 @@
+/*
+ * py_filegen_batch.pgc - py 지급파일 생성 배치 (커서 -> 고정길이 파일 + XA).
+ *
+ * ATMI client: opens the ECPG XA RM, starts ONE global transaction, inserts a
+ * py_file header, then cursors승인(A)된 지급건 for a biz_date writing one
+ * fixed-length record per payout into PAYOUT_.dat and stamping each
+ * payout with the file_id (status 'F') via the dbio layer, then tpcommit drives
+ * XA 2PC. Usage: py_filegen_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "py_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ char path[128], rec[128];
+ FILE *fp;
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_fname[64], h_acct[32];
+ long h_file, h_pay, h_net, h_cnt, h_sum;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ snprintf(h_fname, sizeof(h_fname), "PAYOUT_%s.dat", bizdate);
+ snprintf(path, sizeof(path), "/tmp/%s", h_fname);
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL SELECT count(*), coalesce(sum(net),0) INTO :h_cnt, :h_sum
+ FROM py_payout WHERE biz_date = :h_bizdate AND status = 'A';
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "COUNT FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ EXEC SQL SELECT nextval('py_file_seq') INTO :h_file;
+ if (sqlca.sqlcode < 0) { tpabort(0); return 1; }
+ EXEC SQL INSERT INTO py_file (file_id, biz_date, file_name, record_count, total_amount, status)
+ VALUES (:h_file, :h_bizdate, :h_fname, :h_cnt, :h_sum, 'G');
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FILE INS FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ fp = fopen(path, "w");
+ if (fp == NULL) { fprintf(stderr, "fopen FAIL: %s\n", path); tpabort(0); return 1; }
+
+ EXEC SQL DECLARE fgc CURSOR FOR
+ SELECT payout_id, account_no, net FROM py_payout
+ WHERE biz_date = :h_bizdate AND status = 'A'
+ ORDER BY payout_id;
+ EXEC SQL OPEN fgc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); fclose(fp); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH fgc INTO :h_pay, :h_acct, :h_net;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE fgc; fclose(fp); tpabort(0); return 1; }
+ /* 고정길이 레코드: payout_id(12) account_no(20) net(15) = 47 bytes + NL */
+ snprintf(rec, sizeof(rec), "%012ld%-20.20s%015ld\n", h_pay, h_acct, h_net);
+ fputs(rec, fp);
+ if (pydb_set_file(h_pay, h_file, "F") < 0) {
+ EXEC SQL CLOSE fgc; fclose(fp); tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE fgc;
+ fclose(fp);
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> py_filegen_batch COMMIT: file=%s file_id=%ld records=%ld total=%ld\n",
+ path, h_file, rows, h_sum);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/py/batch/py_result_batch.pgc b/app/src/py/batch/py_result_batch.pgc
new file mode 100644
index 0000000..7e80007
--- /dev/null
+++ b/app/src/py/batch/py_result_batch.pgc
@@ -0,0 +1,52 @@
+/*
+ * py_result_batch.pgc - py 지급결과 반영 배치 (커서 F->P + 집계 + XA).
+ *
+ * Cursors over payouts that were filed (status='F') for a biz_date, flips each
+ * to 지급완료(status='P') and rolls a per-merchant payout summary via the dbio
+ * layer - all under ONE global XA transaction. Usage: py_result_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "py_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_merch[64];
+ long h_pay, h_net;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE prc CURSOR FOR
+ SELECT payout_id, merchant_id, net FROM py_payout
+ WHERE biz_date = :h_bizdate AND status = 'F'
+ ORDER BY payout_id;
+ EXEC SQL OPEN prc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH prc INTO :h_pay, :h_merch, :h_net;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE prc; tpabort(0); return 1; }
+ if (pydb_update_status(h_pay, "P") < 0) { EXEC SQL CLOSE prc; tpabort(0); return 1; }
+ if (pydb_upsert_summary(h_merch, bizdate, 1, h_net) < 0) { EXEC SQL CLOSE prc; tpabort(0); return 1; }
+ rows++;
+ }
+ EXEC SQL CLOSE prc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> py_result_batch COMMIT: bizdate=%s paid(F->P)=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/py/dbio/py_account_dbio.pgc b/app/src/py/dbio/py_account_dbio.pgc
new file mode 100644
index 0000000..4e8eda3
--- /dev/null
+++ b/app/src/py/dbio/py_account_dbio.pgc
@@ -0,0 +1,44 @@
+/*
+ * py_account_dbio.pgc - 지급계좌(py_account) DB 접근 (libpydbio.a).
+ */
+#include
+#include
+#include "py_dbio.h"
+
+int pydb_account_check(const char *acct, char *status_out, long *balance_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_bal;
+ char h_acct[32], h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+
+ EXEC SQL SELECT status, balance INTO :h_status, :h_bal
+ FROM py_account WHERE account_no = :h_acct;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("pydb_account_check FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ strncpy(status_out, h_status, 7); status_out[7] = 0;
+ *balance_out = h_bal;
+ return 0;
+}
+
+int pydb_account_add(const char *acct, long delta)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_delta = delta;
+ char h_acct[32];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+
+ EXEC SQL UPDATE py_account SET balance = balance + :h_delta, updated_at = now()
+ WHERE account_no = :h_acct;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("pydb_account_add FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/py/dbio/py_dbio.h b/app/src/py/dbio/py_dbio.h
new file mode 100644
index 0000000..f39e87a
--- /dev/null
+++ b/app/src/py/dbio/py_dbio.h
@@ -0,0 +1,26 @@
+/*
+ * py_dbio.h - py 모듈 (지급) DB 접근 계층 (libpydbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * py_svr and the py batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef PY_DBIO_H
+#define PY_DBIO_H
+
+/* payout */
+long pydb_next_payout_id(void);
+int pydb_insert_payout(long payout_id, const char *merch, const char *acct, long amount,
+ long fee, long net, const char *status, const char *channel,
+ const char *bizdate);
+int pydb_update_status(long payout_id, const char *status);
+int pydb_get_status(long payout_id, char *out /* >= 2 bytes */);
+int pydb_set_file(long payout_id, long file_id, const char *status);
+int pydb_upsert_summary(const char *merch, const char *bizdate, long cnt, long amount);
+long pydb_insert_adjust(long payout_id, long delta, const char *reason, const char *bizdate);
+
+/* account */
+int pydb_account_check(const char *acct, char *status_out /* >= 8 */, long *balance_out);
+int pydb_account_add(const char *acct, long delta);
+
+#endif /* PY_DBIO_H */
diff --git a/app/src/py/dbio/py_payout_dbio.pgc b/app/src/py/dbio/py_payout_dbio.pgc
new file mode 100644
index 0000000..0858a21
--- /dev/null
+++ b/app/src/py/dbio/py_payout_dbio.pgc
@@ -0,0 +1,144 @@
+/*
+ * py_payout_dbio.pgc - 지급(payout)/조정/집계 DB 접근 (libpydbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "py_dbio.h"
+
+long pydb_next_payout_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('py_payout_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("pydb_next_payout_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int pydb_insert_payout(long payout_id, const char *merch, const char *acct, long amount,
+ long fee, long net, const char *status, const char *channel,
+ const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = payout_id, h_amount = amount, h_fee = fee, h_net = net;
+ char h_merch[64], h_acct[32], h_status[8], h_channel[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_acct, acct, sizeof(h_acct) - 1); h_acct[sizeof(h_acct)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_channel, channel, sizeof(h_channel) - 1); h_channel[sizeof(h_channel)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO py_payout
+ (payout_id, merchant_id, account_no, amount, fee, net, status, channel, biz_date)
+ VALUES (:h_id, :h_merch, :h_acct, :h_amount, :h_fee, :h_net, :h_status, :h_channel, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("pydb_insert_payout FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int pydb_update_status(long payout_id, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = payout_id;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE py_payout SET status = :h_status, updated_at = now()
+ WHERE payout_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("pydb_update_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int pydb_get_status(long payout_id, char *out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = payout_id;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT status INTO :h_status FROM py_payout WHERE payout_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("pydb_get_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ out[0] = h_status[0];
+ out[1] = 0;
+ return 0;
+}
+
+int pydb_set_file(long payout_id, long file_id, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id = payout_id, h_file = file_id;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL UPDATE py_payout SET file_id = :h_file, status = :h_status, updated_at = now()
+ WHERE payout_id = :h_id;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("pydb_set_file FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int pydb_upsert_summary(const char *merch, const char *bizdate, long cnt, long amount)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt = cnt, h_amt = amount;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO py_summary (merchant_id, biz_date, payout_count, payout_amount)
+ VALUES (:h_merch, :h_bizdate, :h_cnt, :h_amt)
+ ON CONFLICT (merchant_id, biz_date) DO UPDATE
+ SET payout_count = py_summary.payout_count + EXCLUDED.payout_count,
+ payout_amount = py_summary.payout_amount + EXCLUDED.payout_amount,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("pydb_upsert_summary FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+long pydb_insert_adjust(long payout_id, long delta, const char *reason, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pay = payout_id, h_delta = delta;
+ char h_reason[128], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_reason, reason, sizeof(h_reason) - 1); h_reason[sizeof(h_reason)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('py_adjust_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO py_adjust (adj_id, payout_id, delta, reason, biz_date)
+ VALUES (:h_id, :h_pay, :h_delta, :h_reason, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("pydb_insert_adjust FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
diff --git a/app/src/py/py_svr.pgc b/app/src/py/py_svr.pgc
new file mode 100644
index 0000000..1882c67
--- /dev/null
+++ b/app/src/py/py_svr.pgc
@@ -0,0 +1,621 @@
+/*
+ * py_svr.pgc - py (지급) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online py services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct
+ * 지급 operation with genuine EXEC SQL (directly, or via the linked libpydbio.a
+ * dbio layer) and common math from libacqcommon.a.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch); there is NO
+ * EXEC SQL CONNECT. Owner-writes discipline: services in this server never
+ * tpcall each other; each owns its tables on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "py_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void PAY_FILEGEN(TPSVCINFO *p); void PAY_RESULT(TPSVCINFO *p);
+void PAY_REPROC(TPSVCINFO *p); void PAY_HOLD(TPSVCINFO *p);
+void PAY_RELEASE(TPSVCINFO *p); void PAY_CANCEL(TPSVCINFO *p);
+void PAY_ACCTCHK(TPSVCINFO *p); void PAY_SPLIT(TPSVCINFO *p);
+void PAY_INT(TPSVCINFO *p); void PAY_XFERLINK(TPSVCINFO *p);
+void PAY_STATUS(TPSVCINFO *p); void PAY_SCHEDULE(TPSVCINFO *p);
+void PAY_MERCHSUM(TPSVCINFO *p); void PAY_NETOFF(TPSVCINFO *p);
+void PAY_ADVANCE(TPSVCINFO *p); void PAY_RETRY(TPSVCINFO *p);
+void PAY_APPROVE(TPSVCINFO *p); void PAY_REJECT(TPSVCINFO *p);
+void PAY_RECON(TPSVCINFO *p); void PAY_STATEMENT(TPSVCINFO *p);
+void PAY_FEEDED(TPSVCINFO *p); void PAY_ADJUST(TPSVCINFO *p);
+void PAY_BULK(TPSVCINFO *p); void PAY_INQUIRY(TPSVCINFO *p);
+void PAY_HISTORY(TPSVCINFO *p); void PAY_REVERSE(TPSVCINFO *p);
+void PAY_CONFIRM(TPSVCINFO *p); void PAY_REMIT(TPSVCINFO *p);
+void PAY_CLOSE(TPSVCINFO *p); void PAY_SUMMARY(TPSVCINFO *p);
+
+/* ----- advertise table (this is what "scales to 30" cleanly) ------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"PAY_FILEGEN", PAY_FILEGEN}, {"PAY_RESULT", PAY_RESULT},
+ {"PAY_REPROC", PAY_REPROC}, {"PAY_HOLD", PAY_HOLD},
+ {"PAY_RELEASE", PAY_RELEASE}, {"PAY_CANCEL", PAY_CANCEL},
+ {"PAY_ACCTCHK", PAY_ACCTCHK}, {"PAY_SPLIT", PAY_SPLIT},
+ {"PAY_INT", PAY_INT}, {"PAY_XFERLINK", PAY_XFERLINK},
+ {"PAY_STATUS", PAY_STATUS}, {"PAY_SCHEDULE", PAY_SCHEDULE},
+ {"PAY_MERCHSUM", PAY_MERCHSUM}, {"PAY_NETOFF", PAY_NETOFF},
+ {"PAY_ADVANCE", PAY_ADVANCE}, {"PAY_RETRY", PAY_RETRY},
+ {"PAY_APPROVE", PAY_APPROVE}, {"PAY_REJECT", PAY_REJECT},
+ {"PAY_RECON", PAY_RECON}, {"PAY_STATEMENT", PAY_STATEMENT},
+ {"PAY_FEEDED", PAY_FEEDED}, {"PAY_ADJUST", PAY_ADJUST},
+ {"PAY_BULK", PAY_BULK}, {"PAY_INQUIRY", PAY_INQUIRY},
+ {"PAY_HISTORY", PAY_HISTORY}, {"PAY_REVERSE", PAY_REVERSE},
+ {"PAY_CONFIRM", PAY_CONFIRM}, {"PAY_REMIT", PAY_REMIT},
+ {"PAY_CLOSE", PAY_CLOSE}, {"PAY_SUMMARY", PAY_SUMMARY},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("py_svr: tpopen FAIL: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("py_svr: tpadvertise(%s) FAIL: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("py_svr: %d py(지급) 서비스 광고 완료, RM 오픈", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("py_svr: tpsvrdone");
+}
+
+/* ========================================================================= */
+/* 1. PAY_FILEGEN - 지급 파일 헤더 생성: 당일 승인(A) 지급건 집계 -> py_file. */
+/* ========================================================================= */
+void PAY_FILEGEN(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], fname[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_cnt, h_sum;
+ char h_bizdate[16], h_fname[64];
+ EXEC SQL END DECLARE SECTION;
+
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT count(*), coalesce(sum(net),0) INTO :h_cnt, :h_sum
+ FROM py_payout WHERE biz_date = :h_bizdate AND status = 'A';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ snprintf(fname, sizeof(fname), "PAYOUT_%s.dat", bizdate);
+ strncpy(h_fname, fname, sizeof(h_fname)-1); h_fname[sizeof(h_fname)-1] = 0;
+ EXEC SQL SELECT nextval('py_file_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO py_file (file_id, biz_date, file_name, record_count, total_amount, status)
+ VALUES (:h_id, :h_bizdate, :h_fname, :h_cnt, :h_sum, 'G');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_ID2, h_id);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_sum);
+ Bchg(b, T_STR1, 0, fname, 0L);
+ userlog("PAY_FILEGEN file_id=%ld name=%s recs=%ld amt=%ld", h_id, fname, h_cnt, h_sum);
+ OK(b);
+}
+
+/* 2. PAY_RESULT - 지급 결과 반영: 지급건 상태 P(지급완료). */
+void PAY_RESULT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long payout_id = getl(b, T_ID1);
+ if (pydb_update_status(payout_id, "P") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "P", 0L);
+ OK(b);
+}
+
+/* 3. PAY_REPROC - 재처리: 상태 R(요청)으로 되돌림. */
+void PAY_REPROC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long payout_id = getl(b, T_ID1);
+ if (pydb_update_status(payout_id, "R") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 4. PAY_HOLD - 지급 보류. */
+void PAY_HOLD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long payout_id = getl(b, T_ID1);
+ if (pydb_update_status(payout_id, "H") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "H", 0L);
+ OK(b);
+}
+
+/* 5. PAY_RELEASE - 보류 해제(요청 상태로). */
+void PAY_RELEASE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long payout_id = getl(b, T_ID1);
+ if (pydb_update_status(payout_id, "R") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 6. PAY_CANCEL - 지급 취소. */
+void PAY_CANCEL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], reason[128];
+ long payout_id, amt, adj;
+ payout_id = getl(b, T_ID1);
+ amt = getl(b, T_AMOUNT);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "CANCEL");
+ adj = pydb_insert_adjust(payout_id, -amt, reason, bizdate);
+ if (adj < 0) FAIL(b);
+ if (pydb_update_status(payout_id, "C") < 0) FAIL(b);
+ setl(b, T_ADJ, adj);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 7. PAY_ACCTCHK - 지급계좌 유효성 점검 (dbio). */
+void PAY_ACCTCHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], st[8];
+ long bal = 0;
+ gets_(b, T_KEY1, acct, sizeof(acct));
+ if (pydb_account_check(acct, st, &bal) < 0) { setl(b, T_RC, 1); FAIL(b); }
+ setl(b, T_AMT1, bal);
+ Bchg(b, T_STR1, 0, st, 0L);
+ setl(b, T_RC, (strcmp(st, "ACTIVE") == 0) ? 0 : 1);
+ OK(b);
+}
+
+/* 8. PAY_SPLIT - 지급 분할: 금액을 N건으로 나눠 지급건 생성. */
+void PAY_SPLIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], acct[32], bizdate[16];
+ long n, amount, i, part, payout_id;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_KEY1, acct, sizeof(acct));
+ amount = getl(b, T_AMOUNT);
+ n = getl(b, T_COUNT); if (n < 1) n = 2;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ part = amount / n;
+ for (i = 0; i < n; i++) {
+ payout_id = pydb_next_payout_id();
+ if (payout_id < 0) FAIL(b);
+ if (pydb_insert_payout(payout_id, merch, acct, part, 0, part, "R", "SPLIT", bizdate) < 0) FAIL(b);
+ }
+ setl(b, T_COUNT, n);
+ OK(b);
+}
+
+/* 9. PAY_INT - 선지급 이자 계산 (일할, bps). */
+void PAY_INT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long principal, days, bps, interest;
+ principal = getl(b, T_AMOUNT);
+ days = getl(b, T_ARG1); if (days < 1) days = 1;
+ bps = getl(b, T_ARG2); if (bps <= 0) bps = 15; /* 0.15%/일 */
+ interest = principal * bps * days / 10000;
+ setl(b, T_AMT1, interest);
+ setl(b, T_NET, principal - interest);
+ OK(b);
+}
+
+/* 10. PAY_XFERLINK - 이체 연계 원장 기록(py_ledger). */
+void PAY_XFERLINK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pay, h_amt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1); h_amt = getl(b, T_AMOUNT);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL INSERT INTO py_ledger (ledger_id, payout_id, entry_type, amount, biz_date)
+ VALUES (nextval('py_ledger_seq'), :h_pay, 'XFER', :h_amt, :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ OK(b);
+}
+
+/* 11. PAY_STATUS - 지급 상태 조회 (dbio). */
+void PAY_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long payout_id = getl(b, T_ID1);
+ char st[4];
+ if (pydb_get_status(payout_id, st) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, st, 0L);
+ OK(b);
+}
+
+/* 12. PAY_SCHEDULE - 지급 예약 등록(py_schedule). */
+void PAY_SCHEDULE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char rundate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pay;
+ char h_rundate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1);
+ gets_(b, T_BIZDATE, rundate, sizeof(rundate));
+ strncpy(h_rundate, rundate, sizeof(h_rundate)-1); h_rundate[sizeof(h_rundate)-1] = 0;
+ EXEC SQL SELECT nextval('py_schedule_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO py_schedule (schedule_id, payout_id, run_date, status)
+ VALUES (:h_id, :h_pay, :h_rundate, 'S');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_ID2, h_id);
+ OK(b);
+}
+
+/* 13. PAY_MERCHSUM - 가맹점 당일 지급 집계 upsert (dbio). */
+void PAY_MERCHSUM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(net),0) INTO :h_cnt, :h_amt
+ FROM py_payout WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (pydb_upsert_summary(merch, bizdate, h_cnt, h_amt) < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ OK(b);
+}
+
+/* 14. PAY_NETOFF - 상계(net-off): 지급 금액에서 차감액 반영. */
+void PAY_NETOFF(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pay, h_off, h_new;
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1);
+ h_off = getl(b, T_AMOUNT);
+ EXEC SQL SELECT net INTO :h_new FROM py_payout WHERE payout_id = :h_pay;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ h_new = h_new - h_off;
+ EXEC SQL UPDATE py_payout SET net = :h_new, updated_at = now() WHERE payout_id = :h_pay;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_NET, h_new);
+ OK(b);
+}
+
+/* 15. PAY_ADVANCE - 선지급(advance) 지급건 생성. */
+void PAY_ADVANCE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], acct[32], bizdate[16];
+ long amount, fee, net, payout_id;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_KEY1, acct, sizeof(acct));
+ amount = getl(b, T_AMOUNT);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ fee = acq_fee(amount, 100); /* 선지급 수수료 1% */
+ net = amount - fee;
+ payout_id = pydb_next_payout_id();
+ if (payout_id < 0) FAIL(b);
+ if (pydb_insert_payout(payout_id, merch, acct, amount, fee, net, "A", "ADVANCE", bizdate) < 0) FAIL(b);
+ setl(b, T_ID1, payout_id);
+ setl(b, T_FEE, fee);
+ setl(b, T_NET, net);
+ OK(b);
+}
+
+/* 16. PAY_RETRY - 실패 지급 재시도(J/H -> R). */
+void PAY_RETRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pay;
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1);
+ EXEC SQL UPDATE py_payout SET status = 'R', retry_count = retry_count + 1, updated_at = now()
+ WHERE payout_id = :h_pay AND status IN ('J', 'H');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (sqlca.sqlcode == 100) { setl(b, T_RC, 1); FAIL(b); }
+ setl(b, T_RC, 0);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 17. PAY_APPROVE - 지급 승인. */
+void PAY_APPROVE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long payout_id = getl(b, T_ID1);
+ if (pydb_update_status(payout_id, "A") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "A", 0L);
+ OK(b);
+}
+
+/* 18. PAY_REJECT - 지급 반려 + 사유 기록. */
+void PAY_REJECT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char reason[128];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pay;
+ char h_reason[128];
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1);
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "REJECT");
+ strncpy(h_reason, reason, sizeof(h_reason)-1); h_reason[sizeof(h_reason)-1] = 0;
+ EXEC SQL UPDATE py_payout SET status = 'J', reject_reason = :h_reason, updated_at = now()
+ WHERE payout_id = :h_pay;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ Bchg(b, T_STATUS, 0, "J", 0L);
+ OK(b);
+}
+
+/* 19. PAY_RECON - 지급-파일 대사: 파일에 편성된 건수/합계 확인. */
+void PAY_RECON(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_file, h_cnt, h_sum;
+ EXEC SQL END DECLARE SECTION;
+ h_file = getl(b, T_ID2);
+ EXEC SQL SELECT count(*), coalesce(sum(net),0) INTO :h_cnt, :h_sum
+ FROM py_payout WHERE file_id = :h_file;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_sum);
+ OK(b);
+}
+
+/* 20. PAY_STATEMENT - 가맹점 지급 명세: 기간(당일) 지급 합계 집계. */
+void PAY_STATEMENT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt, h_fee, h_net;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0), coalesce(sum(fee),0), coalesce(sum(net),0)
+ INTO :h_cnt, :h_amt, :h_fee, :h_net FROM py_payout
+ WHERE merchant_id = :h_merch AND biz_date = :h_bizdate AND status = 'P';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ setl(b, T_FEE, h_fee);
+ setl(b, T_NET, h_net);
+ OK(b);
+}
+
+/* 21. PAY_FEEDED - 수수료 공제(fee deduction): net 재계산. */
+void PAY_FEEDED(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long amount, bps, fee, net;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pay, h_fee, h_net;
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1);
+ amount = getl(b, T_AMOUNT);
+ bps = getl(b, T_ARG1); if (bps <= 0) bps = 30;
+ fee = acq_fee(amount, bps); net = amount - fee;
+ h_fee = fee; h_net = net;
+ EXEC SQL UPDATE py_payout SET fee = :h_fee, net = :h_net, updated_at = now()
+ WHERE payout_id = :h_pay;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ setl(b, T_FEE, fee); setl(b, T_NET, net);
+ OK(b);
+}
+
+/* 22. PAY_ADJUST - 지급 조정 기록 + net 반영 (dbio + 직접 SQL). */
+void PAY_ADJUST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], reason[128];
+ long payout_id, delta, adj;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long e_pay, e_delta;
+ EXEC SQL END DECLARE SECTION;
+ payout_id = getl(b, T_ID1);
+ delta = getl(b, T_DELTA);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "ADJ");
+ adj = pydb_insert_adjust(payout_id, delta, reason, bizdate);
+ if (adj < 0) FAIL(b);
+ e_pay = payout_id; e_delta = delta;
+ EXEC SQL UPDATE py_payout SET net = net + :e_delta, updated_at = now()
+ WHERE payout_id = :e_pay;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ setl(b, T_ADJ, adj);
+ OK(b);
+}
+
+/* 23. PAY_BULK - 대량 지급 생성: N건 반복 등록. */
+void PAY_BULK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], acct[32], bizdate[16];
+ long n, amount, i, payout_id;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_KEY1, acct, sizeof(acct));
+ amount = getl(b, T_AMOUNT);
+ n = getl(b, T_COUNT); if (n < 1) n = 1;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ for (i = 0; i < n; i++) {
+ payout_id = pydb_next_payout_id();
+ if (payout_id < 0) FAIL(b);
+ if (pydb_insert_payout(payout_id, merch, acct, amount, 0, amount, "R", "BULK", bizdate) < 0) FAIL(b);
+ }
+ setl(b, T_COUNT, n);
+ OK(b);
+}
+
+/* 24. PAY_INQUIRY - 지급 단건 상세 조회. */
+void PAY_INQUIRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], acct[32], st[8];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pay, h_amt, h_fee, h_net;
+ char h_merch[64], h_acct[32], h_status[8];
+ EXEC SQL END DECLARE SECTION;
+ h_pay = getl(b, T_ID1);
+ EXEC SQL SELECT merchant_id, account_no, amount, fee, net, status
+ INTO :h_merch, :h_acct, :h_amt, :h_fee, :h_net, :h_status
+ FROM py_payout WHERE payout_id = :h_pay;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ strncpy(merch, h_merch, sizeof(merch)-1); merch[sizeof(merch)-1] = 0;
+ strncpy(acct, h_acct, sizeof(acct)-1); acct[sizeof(acct)-1] = 0;
+ strncpy(st, h_status, sizeof(st)-1); st[sizeof(st)-1] = 0;
+ Bchg(b, T_MERCHANT, 0, merch, 0L);
+ Bchg(b, T_KEY1, 0, acct, 0L);
+ Bchg(b, T_STATUS, 0, st, 0L);
+ setl(b, T_AMOUNT, h_amt);
+ setl(b, T_FEE, h_fee);
+ setl(b, T_NET, h_net);
+ OK(b);
+}
+
+/* 25. PAY_HISTORY - 가맹점 지급 이력 건수 조회. */
+void PAY_HISTORY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM py_payout WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 26. PAY_REVERSE - 지급 역분개(reverse) + 계좌 잔액 원복. */
+void PAY_REVERSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32];
+ long payout_id = getl(b, T_ID1), amount = getl(b, T_AMOUNT);
+ gets_(b, T_KEY1, acct, sizeof(acct));
+ if (pydb_update_status(payout_id, "V") < 0) FAIL(b);
+ if (acct[0] && pydb_account_add(acct, amount) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "V", 0L);
+ OK(b);
+}
+
+/* 27. PAY_CONFIRM - 지급 확정(confirm): 파일 상태 C(확정). */
+void PAY_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_file;
+ EXEC SQL END DECLARE SECTION;
+ h_file = getl(b, T_ID2);
+ EXEC SQL UPDATE py_file SET status = 'C', confirmed_at = now() WHERE file_id = :h_file;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ OK(b);
+}
+
+/* 28. PAY_REMIT - 실지급 송금: 상태 P + 계좌 잔액 차감. */
+void PAY_REMIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char acct[32], st[8];
+ long payout_id, net, bal = 0;
+ payout_id = getl(b, T_ID1);
+ net = getl(b, T_NET);
+ gets_(b, T_KEY1, acct, sizeof(acct));
+ if (pydb_account_check(acct, st, &bal) < 0) FAIL(b);
+ if (bal < net) { setl(b, T_RC, 1); FAIL(b); }
+ if (pydb_account_add(acct, -net) < 0) FAIL(b);
+ if (pydb_update_status(payout_id, "P") < 0) FAIL(b);
+ setl(b, T_RC, 0);
+ Bchg(b, T_STATUS, 0, "P", 0L);
+ OK(b);
+}
+
+/* 29. PAY_CLOSE - 일마감: 당일 지급완료(P)건 상태 X(마감)로 일괄 전환. */
+void PAY_CLOSE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL UPDATE py_payout SET status = 'X', updated_at = now()
+ WHERE biz_date = :h_bizdate AND status = 'P';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_cnt = sqlca.sqlerrd[2];
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 30. PAY_SUMMARY - 가맹점 일별 지급 집계 조회(py_summary). */
+void PAY_SUMMARY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT coalesce(payout_count,0), coalesce(payout_amount,0) INTO :h_cnt, :h_amt
+ FROM py_summary WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (sqlca.sqlcode == 100) { h_cnt = 0; h_amt = 0; }
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_amt);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/rc/batch/rc_diff_batch.pgc b/app/src/rc/batch/rc_diff_batch.pgc
new file mode 100644
index 0000000..c0b90fe
--- /dev/null
+++ b/app/src/rc/batch/rc_diff_batch.pgc
@@ -0,0 +1,59 @@
+/*
+ * rc_diff_batch.pgc - rc 금액 차이 배치 (커서 + XA).
+ *
+ * 당일 매입 중 정산 원장(SETTLE) 합계가 net 과 다른 건을 커서로 순회하며 rc_diff
+ * 에 AMT 차이 라인을 기록한다 (ONE global XA tx). 사용법: rc_diff_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "rc_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_id, h_pid, h_exp, h_act, h_diff;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE rdc CURSOR FOR
+ SELECT p.purchase_id, p.net,
+ coalesce((SELECT sum(l.amount) FROM ledger l
+ WHERE l.purchase_id = p.purchase_id AND l.entry_type = 'SETTLE'), 0)
+ FROM purchase p
+ WHERE p.biz_date = :h_bizdate
+ ORDER BY p.purchase_id;
+ EXEC SQL OPEN rdc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH rdc INTO :h_pid, :h_exp, :h_act;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE rdc; tpabort(0); return 1; }
+ if (h_exp == h_act) continue; /* 일치 건은 건너뜀 */
+ h_diff = h_exp - h_act;
+ EXEC SQL SELECT nextval('rc_diff_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) { EXEC SQL CLOSE rdc; tpabort(0); return 1; }
+ EXEC SQL INSERT INTO rc_diff (diff_id, purchase_id, expected_amt, actual_amt, diff_amt, diff_kind, biz_date)
+ VALUES (:h_id, :h_pid, :h_exp, :h_act, :h_diff, 'AMT', :h_bizdate);
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "INSERT FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE rdc; tpabort(0); return 1; }
+ rows++;
+ }
+ EXEC SQL CLOSE rdc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> rc_diff_batch COMMIT: bizdate=%s diff_rows=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/rc/batch/rc_match_batch.pgc b/app/src/rc/batch/rc_match_batch.pgc
new file mode 100644
index 0000000..e26a38e
--- /dev/null
+++ b/app/src/rc/batch/rc_match_batch.pgc
@@ -0,0 +1,64 @@
+/*
+ * rc_match_batch.pgc - rc 3-way 대사 배치 (커서 + XA).
+ *
+ * ATMI client: tpinit/tpopen 로 ECPG XA RM 를 열고, tpbegin 으로 ONE global tx
+ * 를 시작한 뒤, 승인(approval)과 원장(ledger)이 모두 존재하는 당일 매입을 커서로
+ * 순회하며 rc_match_log 에 3WAY MATCHED 를 (dbio 로) 기록하고, 나머지는 EXCEPTION
+ * 으로 남긴다. 마지막에 tpcommit 이 XA 2PC 를 구동. 사용법: rc_match_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "rc_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0, exc = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_pid, h_net, h_appr, h_led;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE rmc CURSOR FOR
+ SELECT p.purchase_id, p.net,
+ (SELECT count(*) FROM approval a WHERE a.purchase_id = p.purchase_id),
+ (SELECT count(*) FROM ledger l WHERE l.purchase_id = p.purchase_id)
+ FROM purchase p
+ WHERE p.biz_date = :h_bizdate
+ ORDER BY p.purchase_id;
+ EXEC SQL OPEN rmc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH rmc INTO :h_pid, :h_net, :h_appr, :h_led;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE rmc; tpabort(0); return 1; }
+ if (h_appr > 0 && h_led > 0) {
+ if (rcdb_insert_match(h_pid, "3WAY", h_net, "MATCHED", bizdate) < 0) {
+ EXEC SQL CLOSE rmc; tpabort(0); return 1;
+ }
+ rows++;
+ } else {
+ if (rcdb_insert_match(h_pid, "3WAY", h_net, "EXCEPTION", bizdate) < 0) {
+ EXEC SQL CLOSE rmc; tpabort(0); return 1;
+ }
+ exc++;
+ }
+ }
+ EXEC SQL CLOSE rmc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> rc_match_batch COMMIT: bizdate=%s matched=%ld exception=%ld\n", bizdate, rows, exc);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/rc/dbio/rc_dbio.h b/app/src/rc/dbio/rc_dbio.h
new file mode 100644
index 0000000..cb4a1f8
--- /dev/null
+++ b/app/src/rc/dbio/rc_dbio.h
@@ -0,0 +1,26 @@
+/*
+ * rc_dbio.h - rc (대사) 모듈 DB 접근 계층 (librcdbio.a).
+ * ECPG (EXEC SQL) 함수들을 정적 라이브러리로 묶어 rc_svr / rc 배치에 링크한다.
+ * 호출자의 XA 브랜치/커넥션에서 수행되며 EXEC SQL CONNECT 는 없다.
+ * 반환 규약: 0 = ok, -1 = SQL error (호출자가 sqlca 도 확인 가능).
+ */
+#ifndef RC_DBIO_H
+#define RC_DBIO_H
+
+/* 대사 로그 */
+long rcdb_next_match_id(void);
+long rcdb_insert_match(long pid, const char *mtype, long amount,
+ const char *status, const char *bizdate);
+int rcdb_update_match_status(long pid, const char *from_status, const char *to_status);
+long rcdb_count_match(const char *bizdate, const char *status);
+
+/* 미대사 / 예외 */
+long rcdb_count_unmatched(const char *bizdate);
+long rcdb_insert_exception(long pid, const char *reason, const char *bizdate);
+int rcdb_resolve_exception(long pid);
+
+/* 원장 연계 + 요약 upsert */
+long rcdb_insert_ledger(long pid, const char *type, long amount, const char *bizdate);
+int rcdb_upsert_summary(const char *bizdate, long matched, long unmatched, long gross);
+
+#endif /* RC_DBIO_H */
diff --git a/app/src/rc/dbio/rc_ledger_dbio.pgc b/app/src/rc/dbio/rc_ledger_dbio.pgc
new file mode 100644
index 0000000..83c307e
--- /dev/null
+++ b/app/src/rc/dbio/rc_ledger_dbio.pgc
@@ -0,0 +1,68 @@
+/*
+ * rc_ledger_dbio.pgc - 대사 원장 연계 + 요약 upsert DB 접근 (librcdbio.a).
+ * ECPG 함수; 호출자의 XA 브랜치에서 수행. EXEC SQL CONNECT 없음.
+ */
+#include
+#include
+#include "rc_dbio.h"
+
+long rcdb_count_unmatched(const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM purchase p
+ WHERE p.biz_date = :h_bizdate
+ AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.purchase_id = p.purchase_id);
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_count_unmatched FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_cnt;
+}
+
+long rcdb_insert_ledger(long pid, const char *type, long amount, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid = pid, h_amount = amount;
+ char h_type[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, type, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date)
+ VALUES (nextval('ledger_seq'), :h_pid, :h_type, :h_amount, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_insert_ledger FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int rcdb_upsert_summary(const char *bizdate, long matched, long unmatched, long gross)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_matched = matched, h_unmatched = unmatched, h_gross = gross;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO rc_summary (biz_date, matched_cnt, unmatched_cnt, gross_amount)
+ VALUES (:h_bizdate, :h_matched, :h_unmatched, :h_gross)
+ ON CONFLICT (biz_date) DO UPDATE
+ SET matched_cnt = EXCLUDED.matched_cnt,
+ unmatched_cnt = EXCLUDED.unmatched_cnt,
+ gross_amount = EXCLUDED.gross_amount,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_upsert_summary FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/rc/dbio/rc_match_dbio.pgc b/app/src/rc/dbio/rc_match_dbio.pgc
new file mode 100644
index 0000000..f4fd41d
--- /dev/null
+++ b/app/src/rc/dbio/rc_match_dbio.pgc
@@ -0,0 +1,117 @@
+/*
+ * rc_match_dbio.pgc - 대사 로그/예외 DB 접근 (librcdbio.a).
+ * ECPG 함수; 호출자의 XA 브랜치에서 수행. EXEC SQL CONNECT 없음.
+ */
+#include
+#include
+#include "rc_dbio.h"
+
+long rcdb_next_match_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('rc_match_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_next_match_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long rcdb_insert_match(long pid, const char *mtype, long amount,
+ const char *status, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pid = pid, h_amt = amount;
+ char h_type[24], h_status[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, mtype, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('rc_match_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO rc_match_log (match_id, purchase_id, match_type, amount, status, biz_date)
+ VALUES (:h_id, :h_pid, :h_type, :h_amt, :h_status, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_insert_match FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int rcdb_update_match_status(long pid, const char *from_status, const char *to_status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid = pid;
+ char h_from[16], h_to[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_from, from_status, sizeof(h_from) - 1); h_from[sizeof(h_from)-1] = 0;
+ strncpy(h_to, to_status, sizeof(h_to) - 1); h_to[sizeof(h_to)-1] = 0;
+
+ EXEC SQL UPDATE rc_match_log SET status = :h_to, updated_at = now()
+ WHERE purchase_id = :h_pid AND status = :h_from;
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_update_match_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+long rcdb_count_match(const char *bizdate, const char *status)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM rc_match_log
+ WHERE biz_date = :h_bizdate AND status = :h_status;
+ if (sqlca.sqlcode < 0) return -1;
+ return h_cnt;
+}
+
+long rcdb_insert_exception(long pid, const char *reason, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pid = pid;
+ char h_reason[128], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_reason, reason, sizeof(h_reason) - 1); h_reason[sizeof(h_reason)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('rc_exc_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO rc_exception (exc_id, purchase_id, reason, biz_date)
+ VALUES (:h_id, :h_pid, :h_reason, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_insert_exception FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int rcdb_resolve_exception(long pid)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid = pid;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL UPDATE rc_exception SET resolved = true
+ WHERE purchase_id = :h_pid AND resolved = false;
+ if (sqlca.sqlcode < 0) {
+ userlog("rcdb_resolve_exception FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/rc/rc_svr.pgc b/app/src/rc/rc_svr.pgc
index 8411d71..67923a9 100644
--- a/app/src/rc/rc_svr.pgc
+++ b/app/src/rc/rc_svr.pgc
@@ -1,9 +1,16 @@
/*
* rc_svr.pgc - rc (대사) MODULE SERVER.
*
- * Advertises the reconcile services. RECONCILE keeps the proven behavior: it is
- * a DISTINCT XA branch (own DB connection) from ACQUIRE, so it writes only its
- * own ledger recon row, then tpcall()s SETTLE (st_svr) inside the same global tx.
+ * ONE binary that tpadvertise()s ALL ~30 online rc(대사) services — the real
+ * Tuxedo/Enduro-X "one module server, many services" pattern, modeled on ac_svr.
+ * Every service is a distinct 대사 operation with genuine EXEC SQL (directly, or
+ * via the linked librcdbio.a dbio layer).
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch); there is NO
+ * EXEC SQL CONNECT. RECONCILE keeps the proven chain behavior: it is a DISTINCT
+ * XA branch from ACQUIRE, writes its own recon ledger row, then tpcall()s SETTLE
+ * (st_svr) inside the SAME global tx. Owner-writes: rc services never tpcall each
+ * other.
*/
#include
#include
@@ -13,22 +20,51 @@
#include
#include
#include "acq.fd.h"
+#include "rc_dbio.h"
+/* ----- small UBF helpers ------------------------------------------------- */
static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
static void gets_(UBFH *b, BFLDID f, char *out, int cap)
{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
-void RECONCILE(TPSVCINFO *p);
-void RC_REMATCH(TPSVCINFO *p);
-void RC_UNMATCH(TPSVCINFO *p);
-void RC_HOLD(TPSVCINFO *p);
+/* ----- service prototypes ------------------------------------------------ */
+void RECONCILE(TPSVCINFO *p); void RC_APPR_ACQ(TPSVCINFO *p);
+void RC_ACQ_DEP(TPSVCINFO *p); void RC_RETURN(TPSVCINFO *p);
+void RC_AMTDIFF(TPSVCINFO *p); void RC_CNTDIFF(TPSVCINFO *p);
+void RC_3WAY(TPSVCINFO *p); void RC_TOLERANCE(TPSVCINFO *p);
+void RC_REMATCH(TPSVCINFO *p); void RC_HOLD(TPSVCINFO *p);
+void RC_UNMATCH(TPSVCINFO *p); void RC_RELEASE(TPSVCINFO *p);
+void RC_EXCEPTION(TPSVCINFO *p); void RC_AUTOFIX(TPSVCINFO *p);
+void RC_BYISSUER(TPSVCINFO *p); void RC_BYDATE(TPSVCINFO *p);
+void RC_BYMERCH(TPSVCINFO *p); void RC_PARTIAL(TPSVCINFO *p);
+void RC_DIFFRPT(TPSVCINFO *p); void RC_STATS(TPSVCINFO *p);
+void RC_PROGRESS(TPSVCINFO *p); void RC_SUMMARY(TPSVCINFO *p);
+void RC_REPROC(TPSVCINFO *p); void RC_DELAY(TPSVCINFO *p);
+void RC_LEDGERLINK(TPSVCINFO *p);void RC_ALERT(TPSVCINFO *p);
+void RC_MANUAL(TPSVCINFO *p); void RC_ROLLBACK(TPSVCINFO *p);
+void RC_CONFIRM(TPSVCINFO *p); void RC_STATUS(TPSVCINFO *p);
+/* ----- advertise table --------------------------------------------------- */
static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
- {"RECONCILE", RECONCILE}, {"RC_REMATCH", RC_REMATCH},
- {"RC_UNMATCH", RC_UNMATCH}, {"RC_HOLD", RC_HOLD},
+ {"RECONCILE", RECONCILE}, {"RC_APPR_ACQ", RC_APPR_ACQ},
+ {"RC_ACQ_DEP", RC_ACQ_DEP}, {"RC_RETURN", RC_RETURN},
+ {"RC_AMTDIFF", RC_AMTDIFF}, {"RC_CNTDIFF", RC_CNTDIFF},
+ {"RC_3WAY", RC_3WAY}, {"RC_TOLERANCE", RC_TOLERANCE},
+ {"RC_REMATCH", RC_REMATCH}, {"RC_HOLD", RC_HOLD},
+ {"RC_UNMATCH", RC_UNMATCH}, {"RC_RELEASE", RC_RELEASE},
+ {"RC_EXCEPTION", RC_EXCEPTION}, {"RC_AUTOFIX", RC_AUTOFIX},
+ {"RC_BYISSUER", RC_BYISSUER}, {"RC_BYDATE", RC_BYDATE},
+ {"RC_BYMERCH", RC_BYMERCH}, {"RC_PARTIAL", RC_PARTIAL},
+ {"RC_DIFFRPT", RC_DIFFRPT}, {"RC_STATS", RC_STATS},
+ {"RC_PROGRESS", RC_PROGRESS}, {"RC_SUMMARY", RC_SUMMARY},
+ {"RC_REPROC", RC_REPROC}, {"RC_DELAY", RC_DELAY},
+ {"RC_LEDGERLINK", RC_LEDGERLINK},{"RC_ALERT", RC_ALERT},
+ {"RC_MANUAL", RC_MANUAL}, {"RC_ROLLBACK", RC_ROLLBACK},
+ {"RC_CONFIRM", RC_CONFIRM}, {"RC_STATUS", RC_STATUS},
{NULL, NULL}
};
@@ -47,6 +83,9 @@ int tpsvrinit(int argc, char **argv)
void tpsvrdone(void) { tpclose(); userlog("rc_svr: tpsvrdone"); }
+/* ========================================================================= */
+/* 1. RECONCILE - 대사 접수 (own branch), then XA chain -> SETTLE. (KEEP) */
+/* ========================================================================= */
void RECONCILE(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -70,7 +109,7 @@ void RECONCILE(TPSVCINFO *p)
FAIL(b);
}
Bchg(b, T_STATUS, 0, "M", 0L);
- userlog("RECONCILE pid=%ld matched -> SETTLE", h_pid);
+ userlog("RECONCILE pid=%ld 대사완료 -> SETTLE", h_pid);
if (tpcall("SETTLE", (char *)b, 0L, (char **)&b, &rlen, 0L) < 0) {
userlog("RECONCILE: tpcall(SETTLE) FAIL: %s", tpstrerror(tperrno));
@@ -79,7 +118,165 @@ void RECONCILE(TPSVCINFO *p)
OK(b);
}
-/* RC_REMATCH - 재대사 원장 기록. */
+/* 2. RC_APPR_ACQ - 승인-매입 대사 (승인 존재 여부 확인 후 대사 로그). */
+void RC_APPR_ACQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long mid;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_cnt, h_net;
+ EXEC SQL END DECLARE SECTION;
+
+ h_pid = getl(b, T_PURCHASE_ID);
+ h_net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM approval WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt > 0)
+ mid = rcdb_insert_match(h_pid, "APPR_ACQ", h_net, "MATCHED", bizdate);
+ else
+ mid = rcdb_insert_match(h_pid, "APPR_ACQ", h_net, "EXCEPTION", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_ID1, mid);
+ setl(b, T_RC, h_cnt > 0 ? 0 : 1);
+ OK(b);
+}
+
+/* 3. RC_ACQ_DEP - 매입-입금(원장) 대사: 원장 합계와 net 비교. */
+void RC_ACQ_DEP(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long mid;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_net, h_dep;
+ EXEC SQL END DECLARE SECTION;
+
+ h_pid = getl(b, T_PURCHASE_ID);
+ h_net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT coalesce(sum(amount),0) INTO :h_dep FROM ledger
+ WHERE purchase_id = :h_pid AND entry_type IN ('RECON','SETTLE');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ mid = rcdb_insert_match(h_pid, "ACQ_DEP", h_dep,
+ (h_dep == h_net) ? "MATCHED" : "EXCEPTION", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_GROSS, h_dep);
+ setl(b, T_RC, (h_dep == h_net) ? 0 : 1);
+ OK(b);
+}
+
+/* 4. RC_RETURN - 반품 대사: 반품 원장 + 음수 대사 로그. */
+void RC_RETURN(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid = getl(b, T_PURCHASE_ID), amt = getl(b, T_AMOUNT), mid;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (rcdb_insert_ledger(pid, "RETURN", -amt, bizdate) < 0) FAIL(b);
+ mid = rcdb_insert_match(pid, "RETURN", -amt, "MATCHED", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_ID1, mid);
+ OK(b);
+}
+
+/* 5. RC_AMTDIFF - 금액 차이 대사: 기대(net) vs 실제(원장합) 차이 기록. */
+void RC_AMTDIFF(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pid, h_exp, h_act, h_diff;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ h_pid = getl(b, T_PURCHASE_ID);
+ h_exp = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT coalesce(sum(amount),0) INTO :h_act FROM ledger
+ WHERE purchase_id = :h_pid AND entry_type = 'SETTLE';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_diff = h_exp - h_act;
+ EXEC SQL SELECT nextval('rc_diff_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO rc_diff (diff_id, purchase_id, expected_amt, actual_amt, diff_amt, diff_kind, biz_date)
+ VALUES (:h_id, :h_pid, :h_exp, :h_act, :h_diff, 'AMT', :h_bizdate);
+ if (sqlca.sqlcode < 0) { userlog("RC_AMTDIFF INSERT FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ setl(b, T_DELTA, h_diff);
+ OK(b);
+}
+
+/* 6. RC_CNTDIFF - 건수 차이 대사: 매입 건수 vs 대사 로그 건수. */
+void RC_CNTDIFF(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pcnt, h_mcnt, h_diff;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_pcnt FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_mcnt FROM rc_match_log WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ h_diff = h_pcnt - h_mcnt;
+ EXEC SQL SELECT nextval('rc_diff_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO rc_diff (diff_id, purchase_id, expected_amt, actual_amt, diff_amt, diff_kind, biz_date)
+ VALUES (:h_id, 0, :h_pcnt, :h_mcnt, :h_diff, 'CNT', :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_diff);
+ OK(b);
+}
+
+/* 7. RC_3WAY - 3-way 대사: 승인+매입+원장 모두 존재 시 MATCHED. */
+void RC_3WAY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long mid;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_appr, h_led, h_net;
+ EXEC SQL END DECLARE SECTION;
+
+ h_pid = getl(b, T_PURCHASE_ID);
+ h_net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT count(*) INTO :h_appr FROM approval WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_led FROM ledger WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ mid = rcdb_insert_match(h_pid, "3WAY", h_net,
+ (h_appr > 0 && h_led > 0) ? "MATCHED" : "EXCEPTION", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_RC, (h_appr > 0 && h_led > 0) ? 0 : 1);
+ OK(b);
+}
+
+/* 8. RC_TOLERANCE - 허용오차 대사: |차이| <= 허용치면 MATCHED. */
+void RC_TOLERANCE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid, tol, diff, adiff, mid;
+ pid = getl(b, T_PURCHASE_ID);
+ diff = getl(b, T_DELTA);
+ tol = getl(b, T_ARG1); if (tol <= 0) tol = 100; /* 기본 허용치 100원 */
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ adiff = diff < 0 ? -diff : diff;
+ mid = rcdb_insert_match(pid, "TOLERANCE", diff,
+ (adiff <= tol) ? "MATCHED" : "EXCEPTION", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_RC, (adiff <= tol) ? 0 : 1);
+ OK(b);
+}
+
+/* 9. RC_REMATCH - 재대사 원장 기록. (KEEP) */
void RC_REMATCH(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -97,7 +294,25 @@ void RC_REMATCH(TPSVCINFO *p)
OK(b);
}
-/* RC_UNMATCH - 미대사 건수 조회. */
+/* 10. RC_HOLD - 대사 보류 원장 기록. (KEEP) */
+void RC_HOLD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_net;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID); h_net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date)
+ VALUES (nextval('ledger_seq'), :h_pid, 'RC_HOLD', :h_net, :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ OK(b);
+}
+
+/* 11. RC_UNMATCH - 미대사 건수 조회. (KEEP) */
void RC_UNMATCH(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -116,21 +331,298 @@ void RC_UNMATCH(TPSVCINFO *p)
OK(b);
}
-/* RC_HOLD - 대사 보류 원장 기록. */
-void RC_HOLD(TPSVCINFO *p)
+/* 12. RC_RELEASE - 대사 보류 해제 (HOLD -> RELEASED). */
+void RC_RELEASE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long pid = getl(b, T_PURCHASE_ID);
+ if (rcdb_update_match_status(pid, "HOLD", "RELEASED") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 13. RC_EXCEPTION - 대사 예외 등록. */
+void RC_EXCEPTION(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], reason[128];
+ long pid = getl(b, T_PURCHASE_ID), eid;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "AMT_MISMATCH");
+ eid = rcdb_insert_exception(pid, reason, bizdate);
+ if (eid < 0) FAIL(b);
+ setl(b, T_ID1, eid);
+ OK(b);
+}
+
+/* 14. RC_AUTOFIX - 자동 보정: 예외 해소 + 보정 원장. */
+void RC_AUTOFIX(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid = getl(b, T_PURCHASE_ID), amt = getl(b, T_AMOUNT);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (rcdb_resolve_exception(pid) < 0) FAIL(b);
+ if (rcdb_insert_ledger(pid, "AUTOFIX", amt, bizdate) < 0) FAIL(b);
+ if (rcdb_update_match_status(pid, "EXCEPTION", "MATCHED") < 0) FAIL(b);
+ OK(b);
+}
+
+/* 15. RC_BYISSUER - 발급사별 매입 건수 집계. */
+void RC_BYISSUER(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], issuer[32];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16], h_issuer[32];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_ISSUER, issuer, sizeof(issuer)); if (issuer[0] == 0) strcpy(issuer, "BC");
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_issuer, issuer, sizeof(h_issuer)-1); h_issuer[sizeof(h_issuer)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM purchase
+ WHERE biz_date = :h_bizdate AND issuer = :h_issuer;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 16. RC_BYDATE - 일자별 대사(MATCHED) 집계. */
+void RC_BYDATE(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
char bizdate[16];
EXEC SQL BEGIN DECLARE SECTION;
- long h_pid, h_net;
+ long h_cnt, h_sum;
char h_bizdate[16];
EXEC SQL END DECLARE SECTION;
- h_pid = getl(b, T_PURCHASE_ID); h_net = getl(b, T_NET);
gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
- EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date)
- VALUES (nextval('ledger_seq'), :h_pid, 'RC_HOLD', :h_net, :h_bizdate);
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_cnt, :h_sum
+ FROM rc_match_log WHERE biz_date = :h_bizdate AND status = 'MATCHED';
if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_sum);
+ OK(b);
+}
+
+/* 17. RC_BYMERCH - 가맹점별 net 합계 조회. */
+void RC_BYMERCH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], merch[64];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_sum;
+ char h_bizdate[16], h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_sum FROM purchase
+ WHERE biz_date = :h_bizdate AND merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_GROSS, h_sum);
+ OK(b);
+}
+
+/* 18. RC_PARTIAL - 부분 대사 로그. */
+void RC_PARTIAL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid = getl(b, T_PURCHASE_ID), part = getl(b, T_AMOUNT), mid;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ mid = rcdb_insert_match(pid, "PARTIAL", part, "MATCHED", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_ID1, mid);
+ OK(b);
+}
+
+/* 19. RC_DIFFRPT - 당일 차이 라인 건수 조회. */
+void RC_DIFFRPT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_sum;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(diff_amt),0) INTO :h_cnt, :h_sum
+ FROM rc_diff WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_DELTA, h_sum);
+ OK(b);
+}
+
+/* 20. RC_STATS - MATCHED/EXCEPTION 통계 (dbio count). */
+void RC_STATS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long matched, exc;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ matched = rcdb_count_match(bizdate, "MATCHED");
+ exc = rcdb_count_match(bizdate, "EXCEPTION");
+ if (matched < 0 || exc < 0) FAIL(b);
+ setl(b, T_COUNT, matched);
+ setl(b, T_RC, exc);
+ OK(b);
+}
+
+/* 21. RC_PROGRESS - 진행률(%) = MATCHED / 전체매입 * 100. */
+void RC_PROGRESS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_total, h_matched;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_total FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_matched FROM rc_match_log
+ WHERE biz_date = :h_bizdate AND status = 'MATCHED';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_RC, h_total > 0 ? (h_matched * 100 / h_total) : 0);
+ OK(b);
+}
+
+/* 22. RC_SUMMARY - 대사 요약 upsert (dbio). */
+void RC_SUMMARY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long matched, unmatched;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_gross;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ matched = rcdb_count_match(bizdate, "MATCHED");
+ unmatched = rcdb_count_unmatched(bizdate);
+ if (matched < 0 || unmatched < 0) FAIL(b);
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_gross FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (rcdb_upsert_summary(bizdate, matched, unmatched, h_gross) < 0) FAIL(b);
+ setl(b, T_COUNT, matched);
+ setl(b, T_GROSS, h_gross);
+ OK(b);
+}
+
+/* 23. RC_REPROC - 재처리: EXCEPTION 대사 로그를 MATCHED 로 되돌림. */
+void RC_REPROC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long pid = getl(b, T_PURCHASE_ID);
+ if (rcdb_update_match_status(pid, "EXCEPTION", "MATCHED") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "M", 0L);
+ OK(b);
+}
+
+/* 24. RC_DELAY - 지연 대사 로그(익일 반영). */
+void RC_DELAY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid = getl(b, T_PURCHASE_ID), net = getl(b, T_NET), mid;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ mid = rcdb_insert_match(pid, "DELAY", net, "HOLD", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_ID1, mid);
+ OK(b);
+}
+
+/* 25. RC_LEDGERLINK - 대사 원장 연계 기록 (dbio). */
+void RC_LEDGERLINK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid = getl(b, T_PURCHASE_ID), net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (rcdb_insert_ledger(pid, "RC_LINK", net, bizdate) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 26. RC_ALERT - 대사 경보 등록. */
+void RC_ALERT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], msg[128], lvl[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pid;
+ char h_bizdate[16], h_msg[128], h_lvl[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_MSG, msg, sizeof(msg)); if (msg[0] == 0) strcpy(msg, "대사 지연 경보");
+ gets_(b, T_STR1, lvl, sizeof(lvl)); if (lvl[0] == 0) strcpy(lvl, "WARN");
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_msg, msg, sizeof(h_msg)-1); h_msg[sizeof(h_msg)-1] = 0;
+ strncpy(h_lvl, lvl, sizeof(h_lvl)-1); h_lvl[sizeof(h_lvl)-1] = 0;
+ EXEC SQL SELECT nextval('rc_alert_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL INSERT INTO rc_alert (alert_id, purchase_id, lvl, message, biz_date)
+ VALUES (:h_id, :h_pid, :h_lvl, :h_msg, :h_bizdate);
+ if (sqlca.sqlcode < 0) { userlog("RC_ALERT INSERT FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); FAIL(b); }
+ setl(b, T_ID1, h_id);
+ OK(b);
+}
+
+/* 27. RC_MANUAL - 수동 대사 로그. */
+void RC_MANUAL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long pid = getl(b, T_PURCHASE_ID), net = getl(b, T_NET), mid;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ mid = rcdb_insert_match(pid, "MANUAL", net, "MATCHED", bizdate);
+ if (mid < 0) FAIL(b);
+ setl(b, T_ID1, mid);
+ OK(b);
+}
+
+/* 28. RC_ROLLBACK - 대사 롤백 (-> ROLLBACK 상태). */
+void RC_ROLLBACK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long pid = getl(b, T_PURCHASE_ID);
+ if (rcdb_update_match_status(pid, "MATCHED", "ROLLBACK") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "X", 0L);
+ OK(b);
+}
+
+/* 29. RC_CONFIRM - 대사 확정 (MATCHED -> CONFIRMED). */
+void RC_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long pid = getl(b, T_PURCHASE_ID);
+ if (rcdb_update_match_status(pid, "MATCHED", "CONFIRMED") < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 30. RC_STATUS - 최근 대사 상태 조회. */
+void RC_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid;
+ char h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT status INTO :h_status FROM rc_match_log
+ WHERE purchase_id = :h_pid ORDER BY match_id DESC LIMIT 1;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (sqlca.sqlcode == 100) strcpy(h_status, "NONE");
+ Bchg(b, T_STATUS, 0, h_status, 0L);
OK(b);
}
diff --git a/app/src/st/batch/st_feecalc_batch.pgc b/app/src/st/batch/st_feecalc_batch.pgc
new file mode 100644
index 0000000..6cf70f1
--- /dev/null
+++ b/app/src/st/batch/st_feecalc_batch.pgc
@@ -0,0 +1,59 @@
+/*
+ * st_feecalc_batch.pgc - st 수수료 계산 배치 (커서 + XA).
+ *
+ * 당일 정산 각 건에 대해 매입 금액 기준 구간요율(dbio)을 적용해 MDR 수수료를
+ * 계산하고 st_settle_dtl 에 정산 상세를 (dbio 로) 기록한다 (ONE global XA tx).
+ * 사용법: st_feecalc_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "st_dbio.h"
+#include "acq_common.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_merch[64];
+ long h_sid, h_pid, h_amount;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE sfc CURSOR FOR
+ SELECT s.settlement_id, s.purchase_id, s.merchant_id, p.amount
+ FROM settlement s JOIN purchase p ON p.purchase_id = s.purchase_id
+ WHERE s.biz_date = :h_bizdate
+ ORDER BY s.settlement_id;
+ EXEC SQL OPEN sfc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ long bps, fee;
+ EXEC SQL FETCH sfc INTO :h_sid, :h_pid, :h_merch, :h_amount;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE sfc; tpabort(0); return 1; }
+ bps = stdb_lookup_rate("MDR", h_amount);
+ if (bps < 0) { EXEC SQL CLOSE sfc; tpabort(0); return 1; }
+ fee = acq_fee(h_amount, bps);
+ if (stdb_insert_fee_dtl(h_sid, h_pid, h_merch, "MDR", fee, bizdate) < 0) {
+ EXEC SQL CLOSE sfc; tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE sfc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> st_feecalc_batch COMMIT: bizdate=%s fee_rows=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/st/batch/st_settle_batch.pgc b/app/src/st/batch/st_settle_batch.pgc
new file mode 100644
index 0000000..822b642
--- /dev/null
+++ b/app/src/st/batch/st_settle_batch.pgc
@@ -0,0 +1,56 @@
+/*
+ * st_settle_batch.pgc - st 가맹점 정산 네팅 배치 (GROUP BY 커서 + XA).
+ *
+ * ATMI client: tpinit/tpopen 로 ECPG XA RM 를 열고 tpbegin 으로 ONE global tx 를
+ * 시작한 뒤, 당일 정산(settlement)을 가맹점별로 GROUP BY 하여 net 합계를 구하고
+ * st_merch_settle 에 upsert(dbio) 한다. 마지막 tpcommit 이 XA 2PC 구동.
+ * 사용법: st_settle_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "st_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long rows = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16], h_merch[64];
+ long h_cnt, h_gross, h_fee, h_net;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ EXEC SQL DECLARE ssc CURSOR FOR
+ SELECT s.merchant_id, count(*), coalesce(sum(p.amount),0),
+ coalesce(sum(p.fee),0), coalesce(sum(s.net),0)
+ FROM settlement s JOIN purchase p ON p.purchase_id = s.purchase_id
+ WHERE s.biz_date = :h_bizdate
+ GROUP BY s.merchant_id ORDER BY s.merchant_id;
+ EXEC SQL OPEN ssc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH ssc INTO :h_merch, :h_cnt, :h_gross, :h_fee, :h_net;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE ssc; tpabort(0); return 1; }
+ if (stdb_upsert_merch_settle(h_merch, bizdate, h_gross, h_fee, h_net, h_cnt) < 0) {
+ EXEC SQL CLOSE ssc; tpabort(0); return 1;
+ }
+ rows++;
+ }
+ EXEC SQL CLOSE ssc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> st_settle_batch COMMIT: bizdate=%s merchants=%ld\n", bizdate, rows);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/st/dbio/st_dbio.h b/app/src/st/dbio/st_dbio.h
new file mode 100644
index 0000000..c7c8380
--- /dev/null
+++ b/app/src/st/dbio/st_dbio.h
@@ -0,0 +1,25 @@
+/*
+ * st_dbio.h - st (정산/수수료) 모듈 DB 접근 계층 (libstdbio.a).
+ * ECPG (EXEC SQL) 함수들을 정적 라이브러리로 묶어 st_svr / st 배치에 링크한다.
+ * 호출자의 XA 브랜치/커넥션에서 수행되며 EXEC SQL CONNECT 는 없다.
+ * 반환 규약: 0 = ok, -1 = SQL error (음수 ID/rate 는 오류).
+ */
+#ifndef ST_DBIO_H
+#define ST_DBIO_H
+
+/* 정산 상세 / 지급 / 가맹점 집계 (st_settle_dbio.pgc) */
+long stdb_insert_fee_dtl(long sid, long pid, const char *merch,
+ const char *fee_type, long amount, const char *bizdate);
+long stdb_insert_payment(const char *merch, long net, const char *paydate,
+ const char *status, const char *bizdate);
+int stdb_update_payment_status(const char *merch, const char *from_status,
+ const char *to_status, const char *bizdate);
+int stdb_upsert_merch_settle(const char *merch, const char *bizdate, long gross,
+ long fee, long net, long cnt);
+
+/* 수수료 요율 / 조정 (st_fee_dbio.pgc) */
+long stdb_lookup_rate(const char *fee_type, long amount); /* 구간 요율(bps), -1=오류 */
+long stdb_insert_adjust(long sid, long pid, long delta, const char *reason,
+ const char *bizdate);
+
+#endif /* ST_DBIO_H */
diff --git a/app/src/st/dbio/st_fee_dbio.pgc b/app/src/st/dbio/st_fee_dbio.pgc
new file mode 100644
index 0000000..35bfdda
--- /dev/null
+++ b/app/src/st/dbio/st_fee_dbio.pgc
@@ -0,0 +1,51 @@
+/*
+ * st_fee_dbio.pgc - 수수료 구간요율 조회 + 정산 조정 DB 접근 (libstdbio.a).
+ * ECPG 함수; 호출자의 XA 브랜치에서 수행. EXEC SQL CONNECT 없음.
+ */
+#include
+#include
+#include "st_dbio.h"
+
+/* 금액에 해당하는 최상위 구간 요율(bps)을 반환. 없으면 0, SQL 오류면 -1. */
+long stdb_lookup_rate(const char *fee_type, long amount)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_bps, h_amt = amount;
+ char h_type[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, fee_type, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+
+ EXEC SQL SELECT rate_bps INTO :h_bps FROM st_fee_rate
+ WHERE fee_type = :h_type AND band_min <= :h_amt
+ ORDER BY band_min DESC LIMIT 1;
+ if (sqlca.sqlcode == 100) return 0;
+ if (sqlca.sqlcode < 0) {
+ userlog("stdb_lookup_rate FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_bps;
+}
+
+long stdb_insert_adjust(long sid, long pid, long delta, const char *reason,
+ const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_sid = sid, h_pid = pid, h_delta = delta;
+ char h_reason[128], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_reason, reason, sizeof(h_reason) - 1); h_reason[sizeof(h_reason)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('st_adj_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO st_adjust (adj_id, settlement_id, purchase_id, delta, reason, biz_date)
+ VALUES (:h_id, :h_sid, :h_pid, :h_delta, :h_reason, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("stdb_insert_adjust FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
diff --git a/app/src/st/dbio/st_settle_dbio.pgc b/app/src/st/dbio/st_settle_dbio.pgc
new file mode 100644
index 0000000..f33fe94
--- /dev/null
+++ b/app/src/st/dbio/st_settle_dbio.pgc
@@ -0,0 +1,105 @@
+/*
+ * st_settle_dbio.pgc - 정산 상세/지급/가맹점 집계 DB 접근 (libstdbio.a).
+ * ECPG 함수; 호출자의 XA 브랜치에서 수행. EXEC SQL CONNECT 없음.
+ */
+#include
+#include
+#include "st_dbio.h"
+
+long stdb_insert_fee_dtl(long sid, long pid, const char *merch,
+ const char *fee_type, long amount, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_sid = sid, h_pid = pid, h_amt = amount;
+ char h_merch[64], h_type[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_type, fee_type, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('st_dtl_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO st_settle_dtl
+ (dtl_id, settlement_id, purchase_id, merchant_id, fee_type, amount, biz_date)
+ VALUES (:h_id, :h_sid, :h_pid, :h_merch, :h_type, :h_amt, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("stdb_insert_fee_dtl FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long stdb_insert_payment(const char *merch, long net, const char *paydate,
+ const char *status, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_net = net;
+ char h_merch[64], h_paydate[16], h_status[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_paydate, paydate, sizeof(h_paydate) - 1); h_paydate[sizeof(h_paydate)-1] = 0;
+ strncpy(h_status, status, sizeof(h_status) - 1); h_status[sizeof(h_status)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT nextval('st_pay_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO st_payment (pay_id, merchant_id, net_amount, pay_date, status, biz_date)
+ VALUES (:h_id, :h_merch, :h_net, :h_paydate, :h_status, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("stdb_insert_payment FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+int stdb_update_payment_status(const char *merch, const char *from_status,
+ const char *to_status, const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_from[16], h_to[16], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_from, from_status, sizeof(h_from) - 1); h_from[sizeof(h_from)-1] = 0;
+ strncpy(h_to, to_status, sizeof(h_to) - 1); h_to[sizeof(h_to)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL UPDATE st_payment SET status = :h_to
+ WHERE merchant_id = :h_merch AND status = :h_from AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) {
+ userlog("stdb_update_payment_status FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
+
+int stdb_upsert_merch_settle(const char *merch, const char *bizdate, long gross,
+ long fee, long net, long cnt)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_gross = gross, h_fee = fee, h_net = net, h_cnt = cnt;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL INSERT INTO st_merch_settle
+ (merchant_id, biz_date, txn_count, gross_amount, fee_amount, net_amount)
+ VALUES (:h_merch, :h_bizdate, :h_cnt, :h_gross, :h_fee, :h_net)
+ ON CONFLICT (merchant_id, biz_date) DO UPDATE
+ SET txn_count = st_merch_settle.txn_count + EXCLUDED.txn_count,
+ gross_amount = st_merch_settle.gross_amount + EXCLUDED.gross_amount,
+ fee_amount = st_merch_settle.fee_amount + EXCLUDED.fee_amount,
+ net_amount = st_merch_settle.net_amount + EXCLUDED.net_amount,
+ updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("stdb_upsert_merch_settle FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/st/st_svr.pgc b/app/src/st/st_svr.pgc
index 3f0da2c..c771e4b 100644
--- a/app/src/st/st_svr.pgc
+++ b/app/src/st/st_svr.pgc
@@ -1,9 +1,16 @@
/*
* st_svr.pgc - st (정산/수수료) MODULE SERVER.
*
- * Advertises the settlement services. SETTLE keeps the proven behavior: its own
- * XA branch writes the settlement + settle ledger rows (the purchase status
- * transition is owned by the ACQUIRE branch). Terminal service of the 매입 chain.
+ * ONE binary that tpadvertise()s ALL ~30 online st(정산/수수료) services — the
+ * real Enduro-X "one module server, many services" pattern, modeled on ac_svr.
+ * Every service is a distinct 정산/수수료 operation with genuine EXEC SQL (directly,
+ * or via the linked libstdbio.a dbio layer) plus fee math from libacqcommon.a.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch); there is NO
+ * EXEC SQL CONNECT. SETTLE keeps the proven behavior: its own XA branch writes
+ * the settlement + settle ledger rows (the purchase status transition is owned by
+ * the ACQUIRE branch). Terminal service of the 매입 chain. Owner-writes: st
+ * services never tpcall each other.
*/
#include
#include
@@ -13,7 +20,10 @@
#include
#include
#include "acq.fd.h"
+#include "acq_common.h"
+#include "st_dbio.h"
+/* ----- small UBF helpers ------------------------------------------------- */
static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
static void gets_(UBFH *b, BFLDID f, char *out, int cap)
{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
@@ -22,14 +32,40 @@ static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
-void SETTLE(TPSVCINFO *p);
-void ST_MDR(TPSVCINFO *p);
-void ST_NETTING(TPSVCINFO *p);
-void ST_VAT(TPSVCINFO *p);
+/* ----- service prototypes ------------------------------------------------ */
+void SETTLE(TPSVCINFO *p); void ST_MDR(TPSVCINFO *p);
+void ST_NETTING(TPSVCINFO *p); void ST_VAT(TPSVCINFO *p);
+void ST_VANFEE(TPSVCINFO *p); void ST_RELAYFEE(TPSVCINFO *p);
+void ST_WHT(TPSVCINFO *p); void ST_ADJUST(TPSVCINFO *p);
+void ST_ADVANCE(TPSVCINFO *p); void ST_DELAYINT(TPSVCINFO *p);
+void ST_HOLD(TPSVCINFO *p); void ST_RELEASE(TPSVCINFO *p);
+void ST_SPLIT(TPSVCINFO *p); void ST_PAYDATE(TPSVCINFO *p);
+void ST_BYMERCH(TPSVCINFO *p); void ST_MONTHLY(TPSVCINFO *p);
+void ST_OFFSET(TPSVCINFO *p); void ST_DIFFCHK(TPSVCINFO *p);
+void ST_RECALC(TPSVCINFO *p); void ST_RATEAPPLY(TPSVCINFO *p);
+void ST_REPORT(TPSVCINFO *p); void ST_XFERLINK(TPSVCINFO *p);
+void ST_STATS(TPSVCINFO *p); void ST_CANCEL(TPSVCINFO *p);
+void ST_LEDGERLINK(TPSVCINFO *p);void ST_BANDRATE(TPSVCINFO *p);
+void ST_CAP(TPSVCINFO *p); void ST_FXCONV(TPSVCINFO *p);
+void ST_CONFIRM(TPSVCINFO *p); void ST_STATUS(TPSVCINFO *p);
+/* ----- advertise table --------------------------------------------------- */
static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
- {"SETTLE", SETTLE}, {"ST_MDR", ST_MDR},
- {"ST_NETTING", ST_NETTING}, {"ST_VAT", ST_VAT},
+ {"SETTLE", SETTLE}, {"ST_MDR", ST_MDR},
+ {"ST_NETTING", ST_NETTING}, {"ST_VAT", ST_VAT},
+ {"ST_VANFEE", ST_VANFEE}, {"ST_RELAYFEE", ST_RELAYFEE},
+ {"ST_WHT", ST_WHT}, {"ST_ADJUST", ST_ADJUST},
+ {"ST_ADVANCE", ST_ADVANCE}, {"ST_DELAYINT", ST_DELAYINT},
+ {"ST_HOLD", ST_HOLD}, {"ST_RELEASE", ST_RELEASE},
+ {"ST_SPLIT", ST_SPLIT}, {"ST_PAYDATE", ST_PAYDATE},
+ {"ST_BYMERCH", ST_BYMERCH}, {"ST_MONTHLY", ST_MONTHLY},
+ {"ST_OFFSET", ST_OFFSET}, {"ST_DIFFCHK", ST_DIFFCHK},
+ {"ST_RECALC", ST_RECALC}, {"ST_RATEAPPLY", ST_RATEAPPLY},
+ {"ST_REPORT", ST_REPORT}, {"ST_XFERLINK", ST_XFERLINK},
+ {"ST_STATS", ST_STATS}, {"ST_CANCEL", ST_CANCEL},
+ {"ST_LEDGERLINK", ST_LEDGERLINK},{"ST_BANDRATE", ST_BANDRATE},
+ {"ST_CAP", ST_CAP}, {"ST_FXCONV", ST_FXCONV},
+ {"ST_CONFIRM", ST_CONFIRM}, {"ST_STATUS", ST_STATUS},
{NULL, NULL}
};
@@ -48,6 +84,9 @@ int tpsvrinit(int argc, char **argv)
void tpsvrdone(void) { tpclose(); userlog("st_svr: tpsvrdone"); }
+/* ========================================================================= */
+/* 1. SETTLE - 정산 + settle 원장 (own branch). 매입 체인 종단. (KEEP) */
+/* ========================================================================= */
void SETTLE(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -79,7 +118,7 @@ void SETTLE(TPSVCINFO *p)
OK(b);
}
-/* ST_MDR - MDR 수수료 원장 기록. */
+/* 2. ST_MDR - MDR 수수료 원장 기록. (KEEP) */
void ST_MDR(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -97,7 +136,7 @@ void ST_MDR(TPSVCINFO *p)
OK(b);
}
-/* ST_NETTING - 당일 net 합계 조회. */
+/* 3. ST_NETTING - 당일 net 합계 조회. (KEEP) */
void ST_NETTING(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -117,7 +156,7 @@ void ST_NETTING(TPSVCINFO *p)
OK(b);
}
-/* ST_VAT - 정산 부가세 원장 기록. */
+/* 4. ST_VAT - 정산 부가세 원장 기록. (KEEP) */
void ST_VAT(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
@@ -136,4 +175,454 @@ void ST_VAT(TPSVCINFO *p)
OK(b);
}
+/* 5. ST_VANFEE - VAN 수수료: 구간요율 조회 후 정산 상세 기록 (dbio). */
+void ST_VANFEE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long amount = getl(b, T_AMOUNT), bps, fee, dtl;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ bps = stdb_lookup_rate("VAN", amount);
+ if (bps < 0) FAIL(b);
+ fee = acq_fee(amount, bps);
+ dtl = stdb_insert_fee_dtl(sid, pid, merch, "VAN", fee, bizdate);
+ if (dtl < 0) FAIL(b);
+ setl(b, T_FEE, fee);
+ OK(b);
+}
+
+/* 6. ST_RELAYFEE - 중계 수수료: 구간요율 조회 후 정산 상세 기록 (dbio). */
+void ST_RELAYFEE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long amount = getl(b, T_AMOUNT), bps, fee, dtl;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ bps = stdb_lookup_rate("RELAY", amount);
+ if (bps < 0) FAIL(b);
+ fee = acq_fee(amount, bps);
+ dtl = stdb_insert_fee_dtl(sid, pid, merch, "RELAY", fee, bizdate);
+ if (dtl < 0) FAIL(b);
+ setl(b, T_FEE, fee);
+ OK(b);
+}
+
+/* 7. ST_WHT - 원천징수(수수료의 22%) 정산 상세 기록 (dbio). */
+void ST_WHT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long fee = getl(b, T_FEE), tax, dtl;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ tax = acq_wht_amt(fee, 2200);
+ dtl = stdb_insert_fee_dtl(sid, pid, merch, "WHT", tax, bizdate);
+ if (dtl < 0) FAIL(b);
+ setl(b, T_TAX, tax);
+ OK(b);
+}
+
+/* 8. ST_ADJUST - 정산 조정 (가감) 기록 (dbio). */
+void ST_ADJUST(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16], reason[128];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long delta = getl(b, T_DELTA), adj;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_REASON, reason, sizeof(reason)); if (reason[0] == 0) strcpy(reason, "ADJ");
+ adj = stdb_insert_adjust(sid, pid, delta, reason, bizdate);
+ if (adj < 0) FAIL(b);
+ setl(b, T_ADJ, adj);
+ OK(b);
+}
+
+/* 9. ST_ADVANCE - 선지급 예약: 지급 레코드(SCHEDULED) 생성 (dbio). */
+void ST_ADVANCE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16], paydate[16];
+ long net = getl(b, T_NET), pay;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_KEY1, paydate, sizeof(paydate));
+ if (paydate[0] == 0) { strncpy(paydate, bizdate, sizeof(paydate)-1); paydate[sizeof(paydate)-1] = 0; }
+ pay = stdb_insert_payment(merch, net, paydate, "SCHEDULED", bizdate);
+ if (pay < 0) FAIL(b);
+ setl(b, T_ID1, pay);
+ OK(b);
+}
+
+/* 10. ST_DELAYINT - 지연이자(net * bps/10000 * 일수) 정산 상세 기록 (dbio). */
+void ST_DELAYINT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long net = getl(b, T_NET), days = getl(b, T_ARG1), bps = getl(b, T_ARG2), intr, dtl;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (days <= 0) days = 1;
+ if (bps <= 0) bps = 15; /* 일 0.15% 기본 */
+ intr = acq_fee(net, bps) * days;
+ dtl = stdb_insert_fee_dtl(sid, pid, merch, "DELAYINT", intr, bizdate);
+ if (dtl < 0) FAIL(b);
+ setl(b, T_AMOUNT, intr);
+ OK(b);
+}
+
+/* 11. ST_HOLD - 가맹점 지급 보류 (SCHEDULED -> HELD) (dbio). */
+void ST_HOLD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (stdb_update_payment_status(merch, "SCHEDULED", "HELD", bizdate) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "H", 0L);
+ OK(b);
+}
+
+/* 12. ST_RELEASE - 지급 보류 해제 (HELD -> SCHEDULED) (dbio). */
+void ST_RELEASE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (stdb_update_payment_status(merch, "HELD", "SCHEDULED", bizdate) < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "R", 0L);
+ OK(b);
+}
+
+/* 13. ST_SPLIT - 분할 지급: net 을 n 회로 나눠 지급 레코드 생성 (dbio 루프). */
+void ST_SPLIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long net = getl(b, T_NET), n = getl(b, T_INSTALL_N), part, i, pay;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (n < 1) n = 2;
+ part = net / n;
+ for (i = 0; i < n; i++) {
+ pay = stdb_insert_payment(merch, part, bizdate, "SCHEDULED", bizdate);
+ if (pay < 0) FAIL(b);
+ }
+ setl(b, T_COUNT, n);
+ OK(b);
+}
+
+/* 14. ST_PAYDATE - 지급일 산정(익영업일) 후 지급일 갱신. */
+void ST_PAYDATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16], paydate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_bizdate[16], h_paydate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (acq_next_bizday(bizdate, paydate) < 0) FAIL(b);
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ strncpy(h_paydate, paydate, sizeof(h_paydate)-1); h_paydate[sizeof(h_paydate)-1] = 0;
+ EXEC SQL UPDATE st_payment SET pay_date = :h_paydate
+ WHERE merchant_id = :h_merch AND biz_date = :h_bizdate AND status = 'SCHEDULED';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ Bchg(b, T_KEY1, 0, paydate, 0L);
+ OK(b);
+}
+
+/* 15. ST_BYMERCH - 가맹점별 정산 집계 upsert (dbio). */
+void ST_BYMERCH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_gross, h_fee, h_net;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(p.amount),0), coalesce(sum(p.fee),0), coalesce(sum(s.net),0)
+ INTO :h_cnt, :h_gross, :h_fee, :h_net
+ FROM settlement s JOIN purchase p ON p.purchase_id = s.purchase_id
+ WHERE s.merchant_id = :h_merch AND s.biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (stdb_upsert_merch_settle(merch, bizdate, h_gross, h_fee, h_net, h_cnt) < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt); setl(b, T_GROSS, h_gross);
+ OK(b);
+}
+
+/* 16. ST_MONTHLY - 월별 정산 net 합계 (biz_date LIKE 'YYYY-MM%'). */
+void ST_MONTHLY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], ym[16], like[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_sum;
+ char h_merch[64], h_like[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_KEY1, ym, sizeof(ym)); if (ym[0] == 0) strcpy(ym, "2026-07");
+ snprintf(like, sizeof(like), "%s%%", ym);
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_like, like, sizeof(h_like)-1); h_like[sizeof(h_like)-1] = 0;
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_sum FROM settlement
+ WHERE merchant_id = :h_merch AND to_char(biz_date, 'YYYY-MM-DD') LIKE :h_like;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_GROSS, h_sum);
+ OK(b);
+}
+
+/* 17. ST_OFFSET - 상계: 음수 조정으로 채권 상계 기록 (dbio). */
+void ST_OFFSET(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long amt = getl(b, T_AMOUNT), adj;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ adj = stdb_insert_adjust(sid, pid, -amt, "OFFSET", bizdate);
+ if (adj < 0) FAIL(b);
+ setl(b, T_ADJ, adj);
+ OK(b);
+}
+
+/* 18. ST_DIFFCHK - 정산 net vs 매입 net 차이 점검. */
+void ST_DIFFCHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_snet, h_pnet, h_diff;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_snet FROM settlement WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT net INTO :h_pnet FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ h_diff = h_pnet - h_snet;
+ setl(b, T_DELTA, h_diff);
+ setl(b, T_RC, h_diff == 0 ? 0 : 1);
+ OK(b);
+}
+
+/* 19. ST_RECALC - 매입 수수료 기준 정산 net 재계산 + 갱신. */
+void ST_RECALC(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_amount, h_fee, h_net;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT amount, fee INTO :h_amount, :h_fee FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) FAIL(b);
+ h_net = h_amount - h_fee;
+ EXEC SQL UPDATE settlement SET net = :h_net WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_NET, h_net);
+ OK(b);
+}
+
+/* 20. ST_RATEAPPLY - 구간요율 적용 수수료 계산 + 정산 상세 기록 (dbio). */
+void ST_RATEAPPLY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16], ftype[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long amount = getl(b, T_AMOUNT), bps, fee, dtl;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ gets_(b, T_STR1, ftype, sizeof(ftype)); if (ftype[0] == 0) strcpy(ftype, "MDR");
+ bps = stdb_lookup_rate(ftype, amount);
+ if (bps < 0) FAIL(b);
+ fee = acq_fee(amount, bps);
+ dtl = stdb_insert_fee_dtl(sid, pid, merch, ftype, fee, bizdate);
+ if (dtl < 0) FAIL(b);
+ setl(b, T_FEE, fee);
+ setl(b, T_RC, bps);
+ OK(b);
+}
+
+/* 21. ST_REPORT - 당일 정산 건수/합계 리포트. */
+void ST_REPORT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_sum;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(net),0) INTO :h_cnt, :h_sum
+ FROM settlement WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_sum);
+ OK(b);
+}
+
+/* 22. ST_XFERLINK - 이체 연계 원장 기록. */
+void ST_XFERLINK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_net;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID); h_net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date)
+ VALUES (nextval('ledger_seq'), :h_pid, 'ST_XFER', :h_net, :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ OK(b);
+}
+
+/* 23. ST_STATS - 정산 통계(건수/net 합계) 조회. */
+void ST_STATS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_sum, h_max;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(net),0), coalesce(max(net),0)
+ INTO :h_cnt, :h_sum, :h_max FROM settlement WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_GROSS, h_sum);
+ setl(b, T_AMT1, h_max);
+ OK(b);
+}
+
+/* 24. ST_CANCEL - 정산 취소: 상태 CANCELLED + 취소 조정 (dbio). */
+void ST_CANCEL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long adj;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_net;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_net FROM settlement WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL UPDATE settlement SET status = 'CANCELLED' WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ adj = stdb_insert_adjust(0, h_pid, -h_net, "SETTLE_CANCEL", bizdate);
+ if (adj < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 25. ST_LEDGERLINK - 정산 연계 원장 기록. */
+void ST_LEDGERLINK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_net;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID); h_net = getl(b, T_NET);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date)
+ VALUES (nextval('ledger_seq'), :h_pid, 'SETTLELINK', :h_net, :h_bizdate);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ OK(b);
+}
+
+/* 26. ST_BANDRATE - 금액 구간 요율(bps) 조회 (dbio). */
+void ST_BANDRATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char ftype[16];
+ long amount = getl(b, T_AMOUNT), bps;
+ gets_(b, T_STR1, ftype, sizeof(ftype)); if (ftype[0] == 0) strcpy(ftype, "MDR");
+ bps = stdb_lookup_rate(ftype, amount);
+ if (bps < 0) FAIL(b);
+ setl(b, T_RC, bps);
+ OK(b);
+}
+
+/* 27. ST_CAP - 수수료 상한 적용: 상한 초과분을 감액 조정 (dbio). */
+void ST_CAP(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long sid = getl(b, T_SETTLE_ID), pid = getl(b, T_PURCHASE_ID);
+ long fee = getl(b, T_FEE), cap = getl(b, T_ARG1), over, adj;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (cap <= 0) cap = 100000; /* 기본 상한 */
+ if (fee > cap) {
+ over = fee - cap;
+ adj = stdb_insert_adjust(sid, pid, -over, "FEE_CAP", bizdate);
+ if (adj < 0) FAIL(b);
+ setl(b, T_FEE, cap);
+ setl(b, T_DELTA, -over);
+ } else {
+ setl(b, T_DELTA, 0);
+ }
+ OK(b);
+}
+
+/* 28. ST_FXCONV - 외화 환산: fx * rate/100 = KRW. */
+void ST_FXCONV(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long fx = getl(b, T_FX_AMT), rate = getl(b, T_ARG1), krw;
+ if (rate <= 0) rate = 1350; /* 13.50 KRW/unit (bps of 100) */
+ krw = fx * rate / 100;
+ setl(b, T_AMOUNT, krw);
+ OK(b);
+}
+
+/* 29. ST_CONFIRM - 정산 확정 (SETTLED -> CONFIRMED). */
+void ST_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL UPDATE settlement SET status = 'CONFIRMED'
+ WHERE purchase_id = :h_pid AND status = 'SETTLED';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ Bchg(b, T_STATUS, 0, "C", 0L);
+ OK(b);
+}
+
+/* 30. ST_STATUS - 정산 상태 조회. */
+void ST_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid;
+ char h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT status INTO :h_status FROM settlement
+ WHERE purchase_id = :h_pid ORDER BY settlement_id DESC LIMIT 1;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (sqlca.sqlcode == 100) strcpy(h_status, "NONE");
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ OK(b);
+}
+
/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/app/src/vl/batch/vl_anomaly_batch.pgc b/app/src/vl/batch/vl_anomaly_batch.pgc
new file mode 100644
index 0000000..567205c
--- /dev/null
+++ b/app/src/vl/batch/vl_anomaly_batch.pgc
@@ -0,0 +1,62 @@
+/*
+ * vl_anomaly_batch.pgc - 이상거래(고액/0원) 탐지 배치 (커서 + XA).
+ *
+ * Cursors over a biz_date's 매입 whose amount exceeds the AMT_MAX rule threshold
+ * or is non-positive, and that is not already flagged; each is written to
+ * vl_suspect (via dbio) under ONE global XA transaction.
+ * Usage: vl_anomaly_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "vl_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long flagged = 0;
+ char sev[8] = "ERROR";
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_pid, h_amt, thr = 50000000;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ /* 규칙 임계 로드 (dbio) - 없으면 기본값 유지 */
+ if (vldb_get_rule("AMT_MAX", &thr, sev) < 0) { thr = 50000000; strcpy(sev, "ERROR"); }
+
+ EXEC SQL DECLARE vac CURSOR FOR
+ SELECT p.purchase_id, p.amount FROM purchase p
+ WHERE p.biz_date = :h_bizdate
+ AND (p.amount > :thr OR p.amount <= 0)
+ AND NOT EXISTS (SELECT 1 FROM vl_suspect s
+ WHERE s.purchase_id = p.purchase_id AND s.rule_code = 'AMT_MAX')
+ ORDER BY p.purchase_id;
+ EXEC SQL OPEN vac;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH vac INTO :h_pid, :h_amt;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE vac; tpabort(0); return 1; }
+ if (vldb_insert_suspect(h_pid, "AMT_MAX", sev,
+ (h_amt <= 0) ? "0원/음수 금액" : "고액 이상거래", bizdate) < 0) {
+ EXEC SQL CLOSE vac; tpabort(0); return 1;
+ }
+ flagged++;
+ }
+ EXEC SQL CLOSE vac;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> vl_anomaly_batch COMMIT: bizdate=%s 임계=%ld 적출=%ld\n", bizdate, thr, flagged);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/vl/batch/vl_sales_batch.pgc b/app/src/vl/batch/vl_sales_batch.pgc
new file mode 100644
index 0000000..a84c447
--- /dev/null
+++ b/app/src/vl/batch/vl_sales_batch.pgc
@@ -0,0 +1,64 @@
+/*
+ * vl_sales_batch.pgc - 매입-정산 대사검증 배치 (커서 JOIN + XA).
+ *
+ * ATMI client: tpinit/tpopen open the ECPG XA RM, tpbegin starts ONE global
+ * transaction, an EXEC SQL cursor JOINs 정산완료(status='S') 매입 against its
+ * settlement rows for a biz_date; any purchase whose settled net != sum of its
+ * settlement net (or with no settlement) is written to vl_suspect (via the
+ * linked dbio lib), then tpcommit drives XA 2PC.
+ * Usage: vl_sales_batch [YYYY-MM-DD]
+ */
+#include
+#include
+#include
+#include
+#include "vl_dbio.h"
+
+int main(int argc, char **argv)
+{
+ const char *bizdate = (argc > 1) ? argv[1] : "2026-07-19";
+ long scanned = 0, flagged = 0;
+
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_bizdate[16];
+ long h_pid, h_pnet, h_snet;
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ 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; }
+ if (tpbegin(60, 0) < 0) { fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); return 1; }
+
+ /* 매입(정산완료) LEFT JOIN 정산합계 - 불일치/누락 추출 */
+ EXEC SQL DECLARE vsc CURSOR FOR
+ SELECT p.purchase_id, p.net, coalesce(s.snet, 0)
+ FROM purchase p
+ LEFT JOIN (SELECT purchase_id, sum(net) AS snet FROM settlement
+ GROUP BY purchase_id) s ON s.purchase_id = p.purchase_id
+ WHERE p.biz_date = :h_bizdate AND p.status = 'S'
+ ORDER BY p.purchase_id;
+ EXEC SQL OPEN vsc;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "OPEN FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); tpabort(0); return 1; }
+
+ for (;;) {
+ EXEC SQL FETCH vsc INTO :h_pid, :h_pnet, :h_snet;
+ if (sqlca.sqlcode == 100) break;
+ if (sqlca.sqlcode < 0) { fprintf(stderr, "FETCH FAIL [%d] %s\n", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); EXEC SQL CLOSE vsc; tpabort(0); return 1; }
+ scanned++;
+ if (h_snet != h_pnet) {
+ if (vldb_insert_suspect(h_pid, "LEDGER_MIS", "CRIT",
+ "매입-정산 대사 불일치", bizdate) < 0) {
+ EXEC SQL CLOSE vsc; tpabort(0); return 1;
+ }
+ flagged++;
+ }
+ }
+ EXEC SQL CLOSE vsc;
+
+ if (tpcommit(0) < 0) { fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); tpabort(0); return 1; }
+ printf(">>> vl_sales_batch COMMIT: bizdate=%s 검사=%ld 적출=%ld\n", bizdate, scanned, flagged);
+
+ tpclose(); tpterm();
+ return 0;
+}
diff --git a/app/src/vl/dbio/vl_check_dbio.pgc b/app/src/vl/dbio/vl_check_dbio.pgc
new file mode 100644
index 0000000..dc9b88a
--- /dev/null
+++ b/app/src/vl/dbio/vl_check_dbio.pgc
@@ -0,0 +1,92 @@
+/*
+ * vl_check_dbio.pgc - 의심거래 적재 + 검증 로그 기록 (libvldbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "vl_dbio.h"
+
+long vldb_next_suspect_id(void)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id;
+ EXEC SQL END DECLARE SECTION;
+
+ EXEC SQL SELECT nextval('vl_suspect_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) {
+ userlog("vldb_next_suspect_id FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long vldb_insert_suspect(long purchase_id, const char *rule_code,
+ const char *severity, const char *detail,
+ const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_pid = purchase_id;
+ char h_rule[32], h_sev[8], h_detail[128], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_rule, rule_code, sizeof(h_rule) - 1); h_rule[sizeof(h_rule)-1] = 0;
+ strncpy(h_sev, severity, sizeof(h_sev) - 1); h_sev[sizeof(h_sev)-1] = 0;
+ strncpy(h_detail, detail, sizeof(h_detail) - 1); h_detail[sizeof(h_detail)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1);h_bizdate[sizeof(h_bizdate)-1]= 0;
+
+ EXEC SQL SELECT nextval('vl_suspect_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO vl_suspect
+ (suspect_id, purchase_id, rule_code, severity, detail, biz_date)
+ VALUES (:h_id, :h_pid, :h_rule, :h_sev, :h_detail, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("vldb_insert_suspect FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long vldb_insert_check_log(const char *check_type, long target_id,
+ long result_code, const char *detail,
+ const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_id, h_target = target_id, h_rc = result_code;
+ char h_type[32], h_detail[128], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_type, check_type, sizeof(h_type) - 1); h_type[sizeof(h_type)-1] = 0;
+ strncpy(h_detail, detail, sizeof(h_detail) - 1);h_detail[sizeof(h_detail)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1);h_bizdate[sizeof(h_bizdate)-1]= 0;
+
+ EXEC SQL SELECT nextval('vl_checklog_seq') INTO :h_id;
+ if (sqlca.sqlcode < 0) return -1;
+
+ EXEC SQL INSERT INTO vl_check_log
+ (log_id, check_type, target_id, result_code, detail, biz_date)
+ VALUES (:h_id, :h_type, :h_target, :h_rc, :h_detail, :h_bizdate);
+ if (sqlca.sqlcode < 0) {
+ userlog("vldb_insert_check_log FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_id;
+}
+
+long vldb_suspect_count(const char *bizdate)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM vl_suspect
+ WHERE biz_date = :h_bizdate AND status = 'OPEN';
+ if (sqlca.sqlcode < 0) {
+ userlog("vldb_suspect_count FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return h_cnt;
+}
diff --git a/app/src/vl/dbio/vl_dbio.h b/app/src/vl/dbio/vl_dbio.h
new file mode 100644
index 0000000..41bffd1
--- /dev/null
+++ b/app/src/vl/dbio/vl_dbio.h
@@ -0,0 +1,27 @@
+/*
+ * vl_dbio.h - vl 모듈 (정합성검증/validation) DB 접근 계층 (libvldbio.a).
+ * ECPG (EXEC SQL) functions compiled into a static library and linked into
+ * vl_svr and the vl batches. They run on the caller's XA branch/connection
+ * (no EXEC SQL CONNECT); sqlca is the shared per-thread ECPG SQLCA.
+ * Return convention: 0/>=0 = ok, -1 = SQL error (caller may also inspect sqlca).
+ */
+#ifndef VL_DBIO_H
+#define VL_DBIO_H
+
+/* 의심거래/검증로그 (vl_check_dbio) */
+long vldb_next_suspect_id(void);
+long vldb_insert_suspect(long purchase_id, const char *rule_code,
+ const char *severity, const char *detail,
+ const char *bizdate);
+long vldb_insert_check_log(const char *check_type, long target_id,
+ long result_code, const char *detail,
+ const char *bizdate);
+long vldb_suspect_count(const char *bizdate);
+
+/* 검증규칙 (vl_rule_dbio) */
+int vldb_get_rule(const char *rule_code, long *threshold_out,
+ char *severity_out /* >= 8 */);
+int vldb_upsert_rule(const char *rule_code, const char *rule_name,
+ long threshold, const char *severity);
+
+#endif /* VL_DBIO_H */
diff --git a/app/src/vl/dbio/vl_rule_dbio.pgc b/app/src/vl/dbio/vl_rule_dbio.pgc
new file mode 100644
index 0000000..bbdf348
--- /dev/null
+++ b/app/src/vl/dbio/vl_rule_dbio.pgc
@@ -0,0 +1,51 @@
+/*
+ * vl_rule_dbio.pgc - 검증 규칙 조회 + 등록 (libvldbio.a).
+ * ECPG functions; run on the caller's XA branch. No EXEC SQL CONNECT.
+ */
+#include
+#include
+#include "vl_dbio.h"
+
+int vldb_get_rule(const char *rule_code, long *threshold_out, char *severity_out)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_thr;
+ char h_rule[32], h_sev[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_rule, rule_code, sizeof(h_rule) - 1); h_rule[sizeof(h_rule)-1] = 0;
+
+ EXEC SQL SELECT threshold, severity INTO :h_thr, :h_sev
+ FROM vl_rule WHERE rule_code = :h_rule AND status = 'ACTIVE';
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
+ userlog("vldb_get_rule FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ *threshold_out = h_thr;
+ strncpy(severity_out, h_sev, 7); severity_out[7] = 0;
+ return 0;
+}
+
+int vldb_upsert_rule(const char *rule_code, const char *rule_name,
+ long threshold, const char *severity)
+{
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_thr = threshold;
+ char h_rule[32], h_name[64], h_sev[8];
+ EXEC SQL END DECLARE SECTION;
+
+ strncpy(h_rule, rule_code, sizeof(h_rule) - 1); h_rule[sizeof(h_rule)-1] = 0;
+ strncpy(h_name, rule_name, sizeof(h_name) - 1); h_name[sizeof(h_name)-1] = 0;
+ strncpy(h_sev, severity, sizeof(h_sev) - 1); h_sev[sizeof(h_sev)-1] = 0;
+
+ EXEC SQL INSERT INTO vl_rule (rule_code, rule_name, threshold, severity)
+ VALUES (:h_rule, :h_name, :h_thr, :h_sev)
+ ON CONFLICT (rule_code) DO UPDATE
+ SET rule_name = EXCLUDED.rule_name, threshold = EXCLUDED.threshold,
+ severity = EXCLUDED.severity, updated_at = now();
+ if (sqlca.sqlcode < 0) {
+ userlog("vldb_upsert_rule FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
+ return -1;
+ }
+ return 0;
+}
diff --git a/app/src/vl/vl_svr.pgc b/app/src/vl/vl_svr.pgc
new file mode 100644
index 0000000..a3f0266
--- /dev/null
+++ b/app/src/vl/vl_svr.pgc
@@ -0,0 +1,674 @@
+/*
+ * vl_svr.pgc - vl (정합성검증/validation) MODULE SERVER.
+ *
+ * ONE binary that tpadvertise()s ALL ~30 online vl services (the real Tuxedo
+ * "one module server, many services" pattern). Every service is a distinct
+ * 검증 operation: it SELECTs the target rows, applies a validation branch, and
+ * returns an 에러코드 in T_RC (0=정상, >0=검증 실패). Genuine EXEC SQL directly
+ * or via the linked libvldbio.a dbio layer, plus common math from libacqcommon.a.
+ *
+ * XA: the connection is opened once by tpopen() (ECPG XA switch); there is NO
+ * EXEC SQL CONNECT. Each tpcall runs on this process's XA branch.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "acq.fd.h"
+#include "vl_dbio.h"
+#include "acq_common.h"
+
+/* ----- small UBF helpers ------------------------------------------------- */
+static long getl(UBFH *b, BFLDID f) { long v = 0; Bget(b, f, 0, (char *)&v, 0L); return v; }
+static void gets_(UBFH *b, BFLDID f, char *out, int cap)
+{ BFLDLEN l = cap; out[0] = 0; Bget(b, f, 0, out, &l); }
+static void setl(UBFH *b, BFLDID f, long v) { Bchg(b, f, 0, (char *)&v, 0L); }
+
+#define FAIL(b) do { tpreturn(TPFAIL, 0, (char *)(b), 0L, 0L); return; } while (0)
+#define OK(b) do { tpreturn(TPSUCCESS, 0, (char *)(b), 0L, 0L); return; } while (0)
+
+/* ----- service prototypes ------------------------------------------------ */
+void VL_SALES(TPSVCINFO *p); void VL_CARD(TPSVCINFO *p);
+void VL_LIMIT(TPSVCINFO *p); void VL_AMOUNT(TPSVCINFO *p);
+void VL_ANOMALY(TPSVCINFO *p); void VL_REQUIRED(TPSVCINFO *p);
+void VL_CODEVAL(TPSVCINFO *p); void VL_APPR_ACQ(TPSVCINFO *p);
+void VL_DUP(TPSVCINFO *p); void VL_DATE(TPSVCINFO *p);
+void VL_FEE(TPSVCINFO *p); void VL_SETTLE(TPSVCINFO *p);
+void VL_LEDGER(TPSVCINFO *p); void VL_FORMAT(TPSVCINFO *p);
+void VL_REFINTEG(TPSVCINFO *p); void VL_DUPAUTH(TPSVCINFO *p);
+void VL_BIN(TPSVCINFO *p); void VL_EXPIRY(TPSVCINFO *p);
+void VL_TOLERANCE(TPSVCINFO *p); void VL_MERCH(TPSVCINFO *p);
+void VL_STATUS_CHK(TPSVCINFO *p); void VL_BALANCE(TPSVCINFO *p);
+void VL_SUMMARY(TPSVCINFO *p); void VL_REPORT(TPSVCINFO *p);
+void VL_BATCH_CHK(TPSVCINFO *p); void VL_THRESHOLD(TPSVCINFO *p);
+void VL_RULE(TPSVCINFO *p); void VL_CROSSCHK(TPSVCINFO *p);
+void VL_STATUS(TPSVCINFO *p); void VL_CONFIRM(TPSVCINFO *p);
+
+/* ----- advertise table --------------------------------------------------- */
+static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
+ {"VL_SALES", VL_SALES}, {"VL_CARD", VL_CARD},
+ {"VL_LIMIT", VL_LIMIT}, {"VL_AMOUNT", VL_AMOUNT},
+ {"VL_ANOMALY", VL_ANOMALY}, {"VL_REQUIRED", VL_REQUIRED},
+ {"VL_CODEVAL", VL_CODEVAL}, {"VL_APPR_ACQ", VL_APPR_ACQ},
+ {"VL_DUP", VL_DUP}, {"VL_DATE", VL_DATE},
+ {"VL_FEE", VL_FEE}, {"VL_SETTLE", VL_SETTLE},
+ {"VL_LEDGER", VL_LEDGER}, {"VL_FORMAT", VL_FORMAT},
+ {"VL_REFINTEG", VL_REFINTEG}, {"VL_DUPAUTH", VL_DUPAUTH},
+ {"VL_BIN", VL_BIN}, {"VL_EXPIRY", VL_EXPIRY},
+ {"VL_TOLERANCE", VL_TOLERANCE}, {"VL_MERCH", VL_MERCH},
+ {"VL_STATUS_CHK", VL_STATUS_CHK},{"VL_BALANCE", VL_BALANCE},
+ {"VL_SUMMARY", VL_SUMMARY}, {"VL_REPORT", VL_REPORT},
+ {"VL_BATCH_CHK", VL_BATCH_CHK}, {"VL_THRESHOLD", VL_THRESHOLD},
+ {"VL_RULE", VL_RULE}, {"VL_CROSSCHK", VL_CROSSCHK},
+ {"VL_STATUS", VL_STATUS}, {"VL_CONFIRM", VL_CONFIRM},
+ {NULL, NULL}
+};
+
+int tpsvrinit(int argc, char **argv)
+{
+ int i;
+ if (tpopen() < 0) {
+ userlog("vl_svr: tpopen 실패: %s", tpstrerror(tperrno));
+ return -1;
+ }
+ for (i = 0; SVCS[i].name != NULL; i++) {
+ if (tpadvertise((char *)SVCS[i].name, SVCS[i].fn) < 0) {
+ userlog("vl_svr: tpadvertise(%s) 실패: %s", SVCS[i].name, tpstrerror(tperrno));
+ return -1;
+ }
+ }
+ userlog("vl_svr: vl 검증 서비스 %d개 광고 완료, RM open", i);
+ return 0;
+}
+
+void tpsvrdone(void)
+{
+ tpclose();
+ userlog("vl_svr: tpsvrdone");
+}
+
+/* ========================================================================= */
+/* 1. VL_SALES - 매입 원거래 존재 검증 (RC 1=미존재). */
+/* ========================================================================= */
+void VL_SALES(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt == 0) rc = 1;
+ vldb_insert_check_log("VL_SALES", h_pid, rc, rc ? "매입 미존재" : "OK", bizdate);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 2. VL_CARD - 카드번호 Luhn 검증 (RC 1=체크섬 불일치). */
+void VL_CARD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char pan[32];
+ long rc;
+ gets_(b, T_STR1, pan, sizeof(pan));
+ if (pan[0] == 0) { setl(b, T_RC, 2); OK(b); } /* 2=번호 없음 */
+ rc = acq_luhn_ok(pan) ? 0 : 1;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 3. VL_LIMIT - 매입금액 vs 가맹점 일한도 검증 (RC 1=초과). */
+void VL_LIMIT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_amt, h_lim;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ h_amt = getl(b, T_AMOUNT);
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ EXEC SQL SELECT daily_limit INTO :h_lim FROM merchant WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); } /* 3=가맹점 없음 */
+ if (h_amt > h_lim) rc = 1;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 4. VL_AMOUNT - 금액 유효성(0data;
+ long amt, rc = 0, thr = 50000000;
+ char sev[8];
+ amt = getl(b, T_AMOUNT);
+ if (vldb_get_rule("AMT_MAX", &thr, sev) < 0) thr = 50000000;
+ if (amt <= 0) rc = 1;
+ else if (amt > thr) rc = 2;
+ setl(b, T_RC, rc);
+ setl(b, T_ARG1, thr);
+ OK(b);
+}
+
+/* 5. VL_ANOMALY - 이상거래 탐지 후 의심거래 적재 (dbio). */
+void VL_ANOMALY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long rc = 0, sid = 0, thr = 50000000;
+ char sev[8];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_amt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT amount INTO :h_amt FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); }
+ if (vldb_get_rule("AMT_MAX", &thr, sev) < 0) { thr = 50000000; strcpy(sev, "ERROR"); }
+ if (h_amt > thr) {
+ rc = 1;
+ sid = vldb_insert_suspect(h_pid, "AMT_MAX", sev, "고액 이상거래", bizdate);
+ if (sid < 0) FAIL(b);
+ }
+ setl(b, T_RC, rc);
+ setl(b, T_ID1, sid);
+ OK(b);
+}
+
+/* 6. VL_REQUIRED - 필수필드(가맹점/금액) 존재 검증 (RC 비트합). */
+void VL_REQUIRED(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ long amt, rc = 0;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ amt = getl(b, T_AMOUNT);
+ if (merch[0] == 0) rc |= 1;
+ if (amt == 0) rc |= 2;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 7. VL_CODEVAL - 공통코드 유효성 검증 vs mm_code (RC 1=미존재). */
+void VL_CODEVAL(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char grp[32], code[32];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_grp[32], h_code[32];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR3, grp, sizeof(grp)); if (grp[0] == 0) strcpy(grp, "CARD_TYPE");
+ gets_(b, T_KEY1, code, sizeof(code));
+ strncpy(h_grp, grp, sizeof(h_grp)-1); h_grp[sizeof(h_grp)-1] = 0;
+ strncpy(h_code, code, sizeof(h_code)-1); h_code[sizeof(h_code)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM mm_code
+ WHERE code_group = :h_grp AND code = :h_code AND status = 'ACTIVE';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt == 0) rc = 1;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 8. VL_APPR_ACQ - 승인-매입 매칭 검증 (RC 1=승인 없음). */
+void VL_APPR_ACQ(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM approval
+ WHERE purchase_id = :h_pid AND approved = true;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt == 0) rc = 1;
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 9. VL_DUP - 중복 매입 검증 (동일 가맹점/금액/영업일 2건 이상 → RC 1). */
+void VL_DUP(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_amt;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ h_amt = getl(b, T_AMOUNT);
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM purchase
+ WHERE merchant_id = :h_merch AND amount = :h_amt AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt > 1) rc = 1;
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 10. VL_DATE - 영업일 유효성(미래일자 금지) 검증 (RC 1=미래). */
+void VL_DATE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_future;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (bizdate[0] == 0) { setl(b, T_RC, 2); OK(b); } /* 2=일자 없음 */
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT CASE WHEN :h_bizdate::date > current_date THEN 1 ELSE 0 END INTO :h_future;
+ if (sqlca.sqlcode < 0) { setl(b, T_RC, 3); OK(b); } /* 3=형식 오류 */
+ if (h_future) rc = 1;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 11. VL_FEE - 수수료 재계산 정합 검증 (저장 fee vs MDR 재계산, RC 1=불일치). */
+void VL_FEE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0, recompute;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_amount, h_fee, h_bps;
+ char h_merch[64];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT amount, fee, merchant_id INTO :h_amount, :h_fee, :h_merch
+ FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); }
+ EXEC SQL SELECT mdr_bps INTO :h_bps FROM merchant WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) h_bps = 25;
+ recompute = acq_fee(h_amount, h_bps);
+ if (recompute != h_fee) rc = 1;
+ setl(b, T_FEE, recompute);
+ setl(b, T_DELTA, h_fee - recompute);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 12. VL_SETTLE - 정산금액 정합 검증 (settlement.net vs purchase.net, RC 1=불일치). */
+void VL_SETTLE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_pnet, h_snet, h_scnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT net INTO :h_pnet FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); }
+ EXEC SQL SELECT count(*), coalesce(sum(net),0) INTO :h_scnt, :h_snet
+ FROM settlement WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_scnt == 0) rc = 2; /* 정산 미존재 */
+ else if (h_snet != h_pnet) rc = 1; /* 금액 불일치 */
+ setl(b, T_NET, h_pnet);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 13. VL_LEDGER - 원장 합계 정합 검증 (sum(ledger) vs purchase.net, RC 1=불일치). */
+void VL_LEDGER(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_pnet, h_lsum, h_lcnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ EXEC SQL SELECT net INTO :h_pnet FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); }
+ EXEC SQL SELECT count(*), coalesce(sum(amount),0) INTO :h_lcnt, :h_lsum
+ FROM ledger WHERE purchase_id = :h_pid AND entry_type = 'SETTLELINK';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_lcnt == 0) rc = 2; /* 원장 미기록 */
+ else if (h_lsum != h_pnet) {
+ rc = 1;
+ vldb_insert_suspect(h_pid, "LEDGER_MIS", "CRIT", "원장 합계 불일치", bizdate);
+ }
+ setl(b, T_GROSS, h_lsum);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 14. VL_FORMAT - 가맹점ID 형식(길이 4~16) 검증 (RC 1=형식 위반). */
+void VL_FORMAT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ long rc = 0, len;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ len = (long)strlen(merch);
+ if (len < 4 || len > 16) rc = 1;
+ setl(b, T_COUNT, len);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 15. VL_REFINTEG - 참조무결성(purchase.merchant_id ∈ merchant) 검증. */
+void VL_REFINTEG(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM purchase p
+ WHERE p.purchase_id = :h_pid
+ AND NOT EXISTS (SELECT 1 FROM merchant m WHERE m.merchant_id = p.merchant_id);
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt > 0) rc = 1; /* 참조 깨짐 */
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 16. VL_DUPAUTH - 중복 승인 검증 (approval 2건 이상 → RC 1). */
+void VL_DUPAUTH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM approval WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt > 1) rc = 1;
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 17. VL_BIN - BIN 유효성 검증 vs mm_bin (RC 1=미등록,2=비활성). */
+void VL_BIN(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bin[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt, h_active;
+ char h_bin[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_KEY1, bin, sizeof(bin));
+ strncpy(h_bin, bin, sizeof(h_bin)-1); h_bin[sizeof(h_bin)-1] = 0;
+ EXEC SQL SELECT count(*), coalesce(sum(CASE WHEN status='ACTIVE' THEN 1 ELSE 0 END),0)
+ INTO :h_cnt, :h_active FROM mm_bin WHERE bin = :h_bin;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt == 0) rc = 1;
+ else if (h_active == 0) rc = 2;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 18. VL_EXPIRY - 카드 유효기간(YYYYMM) vs 영업일 검증 (RC 1=만료). */
+void VL_EXPIRY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char expiry[8], bizdate[16];
+ long rc = 0, exp_ym, biz_ym, y, m, d;
+ gets_(b, T_STR2, expiry, sizeof(expiry));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ if (strlen(expiry) < 6 || sscanf(bizdate, "%ld-%ld-%ld", &y, &m, &d) != 3) {
+ setl(b, T_RC, 2); OK(b); /* 2=형식 오류 */
+ }
+ exp_ym = atol(expiry); /* YYYYMM */
+ biz_ym = y * 100 + m;
+ if (exp_ym < biz_ym) rc = 1;
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 19. VL_TOLERANCE - 금액 허용오차(amount == fee+net ± tol) 검증 (RC 1=초과). */
+void VL_TOLERANCE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0, tol, diff;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_amount, h_fee, h_net;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ tol = getl(b, T_ARG1); if (tol < 0) tol = 0;
+ EXEC SQL SELECT amount, fee, net INTO :h_amount, :h_fee, :h_net
+ FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); }
+ diff = h_amount - (h_fee + h_net);
+ if (diff < 0) diff = -diff;
+ if (diff > tol) rc = 1;
+ setl(b, T_DELTA, diff);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 20. VL_MERCH - 가맹점 상태 검증 (merchant.status != ACTIVE → RC 1). */
+void VL_MERCH(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ char h_merch[64], h_status[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ EXEC SQL SELECT status INTO :h_status FROM merchant WHERE merchant_id = :h_merch;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 2); OK(b); }
+ if (strcmp(h_status, "ACTIVE") != 0) rc = 1;
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 21. VL_STATUS_CHK - 매입 상태 유효값 검증 (허용집합 밖 → RC 1). */
+void VL_STATUS_CHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_cnt;
+ char h_status[8];
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT status INTO :h_status FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 3); OK(b); }
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM (SELECT :h_status AS s) t
+ WHERE t.s IN ('A','M','S','C','H','V');
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_cnt == 0) rc = 1; /* 허용되지 않은 상태값 */
+ Bchg(b, T_STATUS, 0, h_status, 0L);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 22. VL_BALANCE - 가맹점 순액 대차 검증 (sum(purchase.net) vs sum(settlement.net)). */
+void VL_BALANCE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char merch[64], bizdate[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pnet, h_snet;
+ char h_merch[64], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_MERCHANT, merch, sizeof(merch));
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_pnet FROM purchase
+ WHERE merchant_id = :h_merch AND biz_date = :h_bizdate AND status = 'S';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT coalesce(sum(net),0) INTO :h_snet FROM settlement
+ WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_pnet != h_snet) rc = 1;
+ setl(b, T_GROSS, h_pnet);
+ setl(b, T_NET, h_snet);
+ setl(b, T_DELTA, h_pnet - h_snet);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 23. VL_SUMMARY - 당일 미결(OPEN) 의심거래 요약 (dbio). */
+void VL_SUMMARY(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long cnt;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ cnt = vldb_suspect_count(bizdate);
+ if (cnt < 0) FAIL(b);
+ setl(b, T_COUNT, cnt);
+ setl(b, T_RC, cnt > 0 ? 1 : 0);
+ OK(b);
+}
+
+/* 24. VL_REPORT - 심각도별 의심거래 건수 리포트. */
+void VL_REPORT(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char sev[8], bizdate[16];
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ char h_sev[8], h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_STR4, sev, sizeof(sev)); if (sev[0] == 0) strcpy(sev, "CRIT");
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_sev, sev, sizeof(h_sev)-1); h_sev[sizeof(h_sev)-1] = 0;
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM vl_suspect
+ WHERE severity = :h_sev AND biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ OK(b);
+}
+
+/* 25. VL_BATCH_CHK - 배치 정합(purchase 건수 vs merchant_summary.txn_count). */
+void VL_BATCH_CHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char bizdate[16];
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pcnt, h_scnt;
+ char h_bizdate[16];
+ EXEC SQL END DECLARE SECTION;
+ gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
+ strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
+ EXEC SQL SELECT count(*) INTO :h_pcnt FROM purchase WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT coalesce(sum(txn_count),0) INTO :h_scnt FROM merchant_summary
+ WHERE biz_date = :h_bizdate;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_pcnt != h_scnt) rc = 1; /* 집계 누락/불일치 */
+ setl(b, T_COUNT, h_pcnt);
+ setl(b, T_GROSS, h_scnt);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 26. VL_THRESHOLD - 규칙 임계치 검사 (dbio get_rule; amount>threshold → RC 1). */
+void VL_THRESHOLD(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char rule[32], sev[8];
+ long amt, thr = 0, rc = 0;
+ gets_(b, T_STR3, rule, sizeof(rule)); if (rule[0] == 0) strcpy(rule, "AMT_MAX");
+ amt = getl(b, T_AMOUNT);
+ if (vldb_get_rule(rule, &thr, sev) < 0) { setl(b, T_RC, 3); OK(b); } /* 3=규칙 없음 */
+ if (thr > 0 && amt > thr) rc = 1;
+ setl(b, T_ARG1, thr);
+ Bchg(b, T_STR4, 0, sev, 0L);
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 27. VL_RULE - 검증 규칙 등록/갱신 (dbio upsert). */
+void VL_RULE(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ char rule[32], name[64], sev[8];
+ long thr;
+ gets_(b, T_STR3, rule, sizeof(rule));
+ if (rule[0] == 0) { userlog("VL_RULE: T_STR3(rule_code) 누락"); FAIL(b); }
+ gets_(b, T_STR1, name, sizeof(name)); if (name[0] == 0) strcpy(name, "사용자 규칙");
+ gets_(b, T_STR4, sev, sizeof(sev)); if (sev[0] == 0) strcpy(sev, "WARN");
+ thr = getl(b, T_ARG1);
+ if (vldb_upsert_rule(rule, name, thr, sev) < 0) FAIL(b);
+ OK(b);
+}
+
+/* 28. VL_CROSSCHK - 교차검증(매입+가맹점+승인 동시 존재, RC 비트합). */
+void VL_CROSSCHK(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ long rc = 0;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_pid, h_pcnt, h_mcnt, h_acnt;
+ EXEC SQL END DECLARE SECTION;
+ h_pid = getl(b, T_PURCHASE_ID);
+ EXEC SQL SELECT count(*) INTO :h_pcnt FROM purchase WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_mcnt FROM purchase p
+ JOIN merchant m ON m.merchant_id = p.merchant_id WHERE p.purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ EXEC SQL SELECT count(*) INTO :h_acnt FROM approval WHERE purchase_id = :h_pid;
+ if (sqlca.sqlcode < 0) FAIL(b);
+ if (h_pcnt == 0) rc |= 1; /* 매입 없음 */
+ if (h_mcnt == 0) rc |= 2; /* 가맹점 참조 깨짐 */
+ if (h_acnt == 0) rc |= 4; /* 승인 없음 */
+ setl(b, T_RC, rc);
+ OK(b);
+}
+
+/* 29. VL_STATUS - 서비스 상태 (전체 OPEN 의심거래 수, RC=0). */
+void VL_STATUS(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_cnt;
+ EXEC SQL END DECLARE SECTION;
+ EXEC SQL SELECT count(*) INTO :h_cnt FROM vl_suspect WHERE status = 'OPEN';
+ if (sqlca.sqlcode < 0) FAIL(b);
+ setl(b, T_COUNT, h_cnt);
+ setl(b, T_RC, 0);
+ Bchg(b, T_MSG, 0, "vl 검증 서비스 정상", 0L);
+ OK(b);
+}
+
+/* 30. VL_CONFIRM - 의심거래 종결 확정 (status CLOSED by suspect_id). */
+void VL_CONFIRM(TPSVCINFO *p)
+{
+ UBFH *b = (UBFH *)p->data;
+ EXEC SQL BEGIN DECLARE SECTION;
+ long h_sid;
+ EXEC SQL END DECLARE SECTION;
+ h_sid = getl(b, T_ID1);
+ EXEC SQL UPDATE vl_suspect SET status = 'CLOSED'
+ WHERE suspect_id = :h_sid AND status = 'OPEN';
+ if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { setl(b, T_RC, 1); OK(b); }
+ setl(b, T_RC, 0);
+ OK(b);
+}
+
+/* vim: set ts=4 sw=4 et smartindent: */
diff --git a/db/schema.d/20-au.sql b/db/schema.d/20-au.sql
new file mode 100644
index 0000000..8956d7a
--- /dev/null
+++ b/db/schema.d/20-au.sql
@@ -0,0 +1,111 @@
+-- au 모듈 (승인/한도) 스키마 - 승인 원장 / 카드 한도 / 부정거래 / 예비승인 / 집계
+-- Loaded into PostgreSQL on first boot (docker-entrypoint-initdb.d). Prefix 20.
+
+-- ---------------------------------------------------------------------------
+-- 승인 원장 (authorization)
+-- status: A=승인, D=거절, C=취소, R=역전, H=보류, M=매입확정(capture),
+-- S=정산반영, V=무효(void)
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS au_auth_seq;
+CREATE TABLE IF NOT EXISTS au_authorization (
+ auth_id BIGINT PRIMARY KEY,
+ card_no TEXT NOT NULL,
+ merchant_id TEXT NOT NULL DEFAULT '',
+ amount BIGINT NOT NULL,
+ approval_code TEXT NOT NULL DEFAULT '',
+ auth_type TEXT NOT NULL DEFAULT 'AUTH', -- AUTH/STANDIN/INSTALL/PREAUTH
+ status TEXT NOT NULL,
+ decline_reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ captured_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 카드 한도 (card_limit)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS card_limit (
+ card_no TEXT PRIMARY KEY,
+ daily_limit BIGINT NOT NULL DEFAULT 1000000,
+ used_amount BIGINT NOT NULL DEFAULT 0,
+ temp_limit BIGINT NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 부정거래 로그 (fraud_log)
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS au_fraud_seq;
+CREATE TABLE IF NOT EXISTS au_fraud_log (
+ fraud_id BIGINT PRIMARY KEY,
+ auth_id BIGINT NOT NULL DEFAULT 0,
+ card_no TEXT NOT NULL,
+ score BIGINT NOT NULL,
+ reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 블랙리스트 (au_blacklist)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS au_blacklist (
+ card_no TEXT PRIMARY KEY,
+ reason TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 예비승인 (au_preauth) - status: P=예비, C=확정, X=취소
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS au_preauth_seq;
+CREATE TABLE IF NOT EXISTS au_preauth (
+ preauth_id BIGINT PRIMARY KEY,
+ card_no TEXT NOT NULL,
+ merchant_id TEXT NOT NULL DEFAULT '',
+ amount BIGINT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'P',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 승인 취소 (au_cancel) - FULL / PARTIAL / VOID
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS au_cancel_seq;
+CREATE TABLE IF NOT EXISTS au_cancel (
+ cancel_id BIGINT PRIMARY KEY,
+ auth_id BIGINT NOT NULL,
+ cancel_type TEXT NOT NULL,
+ amount BIGINT NOT NULL,
+ reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 가맹점 일별 승인 집계 (au_summary) - 배치/서비스가 채움
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS au_summary (
+ merchant_id TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ auth_count BIGINT NOT NULL DEFAULT 0,
+ approve_count BIGINT NOT NULL DEFAULT 0,
+ decline_count BIGINT NOT NULL DEFAULT 0,
+ auth_amount BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (merchant_id, biz_date)
+);
+
+-- Seed card limits + a blacklist entry so AUTH/LIMIT services have counterparties.
+INSERT INTO card_limit (card_no, daily_limit, used_amount) VALUES
+ ('4000123412341234', 1000000, 0),
+ ('5100123412341234', 2000000, 0),
+ ('3400123412341234', 5000000, 0)
+ON CONFLICT (card_no) DO NOTHING;
+
+INSERT INTO au_blacklist (card_no, reason) VALUES
+ ('4000999999999999', 'REPORTED_STOLEN')
+ON CONFLICT (card_no) DO NOTHING;
diff --git a/db/schema.d/25-py.sql b/db/schema.d/25-py.sql
new file mode 100644
index 0000000..fad39f6
--- /dev/null
+++ b/db/schema.d/25-py.sql
@@ -0,0 +1,110 @@
+-- py 모듈 (지급) 스키마 - 지급 원장 / 지급계좌 / 지급파일 / 예약 / 조정 / 집계
+-- Loaded into PostgreSQL on first boot (docker-entrypoint-initdb.d). Prefix 25.
+
+-- ---------------------------------------------------------------------------
+-- 지급 원장 (payout)
+-- status: R=요청, H=보류, A=승인, J=반려, F=파일편성, P=지급완료,
+-- C=취소, V=역분개, X=마감
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS py_payout_seq;
+CREATE TABLE IF NOT EXISTS py_payout (
+ payout_id BIGINT PRIMARY KEY,
+ merchant_id TEXT NOT NULL DEFAULT '',
+ account_no TEXT NOT NULL DEFAULT '',
+ amount BIGINT NOT NULL,
+ fee BIGINT NOT NULL DEFAULT 0,
+ net BIGINT NOT NULL,
+ status TEXT NOT NULL,
+ channel TEXT NOT NULL DEFAULT 'STD', -- STD/SPLIT/BULK/ADVANCE
+ file_id BIGINT NOT NULL DEFAULT 0,
+ retry_count INTEGER NOT NULL DEFAULT 0,
+ reject_reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 지급계좌 (py_account)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS py_account (
+ account_no TEXT PRIMARY KEY,
+ merchant_id TEXT NOT NULL DEFAULT '',
+ bank_code TEXT NOT NULL DEFAULT '',
+ holder TEXT NOT NULL DEFAULT '',
+ balance BIGINT NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 지급 파일 (py_file) - status: G=생성, C=확정
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS py_file_seq;
+CREATE TABLE IF NOT EXISTS py_file (
+ file_id BIGINT PRIMARY KEY,
+ biz_date DATE NOT NULL,
+ file_name TEXT NOT NULL,
+ record_count BIGINT NOT NULL DEFAULT 0,
+ total_amount BIGINT NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'G',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ confirmed_at TIMESTAMPTZ
+);
+
+-- ---------------------------------------------------------------------------
+-- 지급 예약 (py_schedule) - status: S=예약, D=실행
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS py_schedule_seq;
+CREATE TABLE IF NOT EXISTS py_schedule (
+ schedule_id BIGINT PRIMARY KEY,
+ payout_id BIGINT NOT NULL,
+ run_date DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'S',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 지급 조정 (py_adjust)
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS py_adjust_seq;
+CREATE TABLE IF NOT EXISTS py_adjust (
+ adj_id BIGINT PRIMARY KEY,
+ payout_id BIGINT NOT NULL,
+ delta BIGINT NOT NULL,
+ reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 지급 원장 상세 (py_ledger) - 이체/연계 원장
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS py_ledger_seq;
+CREATE TABLE IF NOT EXISTS py_ledger (
+ ledger_id BIGINT PRIMARY KEY,
+ payout_id BIGINT NOT NULL,
+ entry_type TEXT NOT NULL,
+ amount BIGINT NOT NULL,
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 가맹점 일별 지급 집계 (py_summary) - 배치/서비스가 채움
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS py_summary (
+ merchant_id TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ payout_count BIGINT NOT NULL DEFAULT 0,
+ payout_amount BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (merchant_id, biz_date)
+);
+
+-- Seed payout accounts so PAY_REMIT/PAY_ACCTCHK have counterparties.
+INSERT INTO py_account (account_no, merchant_id, bank_code, holder, balance, status) VALUES
+ ('110-1234-567890', 'M0001', '020', '클라로 커피', 500000000, 'ACTIVE'),
+ ('333-9876-543210', 'M0002', '088', '포지 마트', 500000000, 'ACTIVE'),
+ ('456-0000-111222', 'M0003', '004', '클라로 면세점', 900000000, 'ACTIVE')
+ON CONFLICT (account_no) DO NOTHING;
diff --git a/db/schema.d/30-rc.sql b/db/schema.d/30-rc.sql
new file mode 100644
index 0000000..14d71c5
--- /dev/null
+++ b/db/schema.d/30-rc.sql
@@ -0,0 +1,69 @@
+-- rc 모듈 (대사) 상세 테이블. Prefix 30 (00-base 다음, 35-st 이전에 로드).
+-- ECPG 서버/배치가 참조하는 대사 로그/예외/차이/경보/요약 테이블 + 시퀀스.
+
+-- ---------------------------------------------------------------------------
+-- 대사 로그 (rc_match_log) - 서비스별 대사 결과 1행
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS rc_match_seq;
+CREATE TABLE IF NOT EXISTS rc_match_log (
+ match_id BIGINT PRIMARY KEY,
+ purchase_id BIGINT NOT NULL,
+ match_type TEXT NOT NULL, -- APPR_ACQ/ACQ_DEP/3WAY/PARTIAL/MANUAL/RETURN/DELAY/TOLERANCE
+ amount BIGINT NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'MATCHED',-- MATCHED/HOLD/EXCEPTION/RELEASED/CONFIRMED/ROLLBACK
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 대사 예외 (rc_exception)
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS rc_exc_seq;
+CREATE TABLE IF NOT EXISTS rc_exception (
+ exc_id BIGINT PRIMARY KEY,
+ purchase_id BIGINT NOT NULL,
+ reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ resolved BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 대사 차이 (rc_diff) - 금액/건수 차이 리포트 라인
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS rc_diff_seq;
+CREATE TABLE IF NOT EXISTS rc_diff (
+ diff_id BIGINT PRIMARY KEY,
+ purchase_id BIGINT NOT NULL DEFAULT 0,
+ expected_amt BIGINT NOT NULL DEFAULT 0,
+ actual_amt BIGINT NOT NULL DEFAULT 0,
+ diff_amt BIGINT NOT NULL DEFAULT 0,
+ diff_kind TEXT NOT NULL DEFAULT 'AMT', -- AMT / CNT
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 대사 경보 (rc_alert)
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS rc_alert_seq;
+CREATE TABLE IF NOT EXISTS rc_alert (
+ alert_id BIGINT PRIMARY KEY,
+ purchase_id BIGINT NOT NULL DEFAULT 0,
+ lvl TEXT NOT NULL DEFAULT 'INFO',
+ message TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 대사 요약 (rc_summary) - 영업일 1행 (배치/RC_SUMMARY가 upsert)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS rc_summary (
+ biz_date DATE PRIMARY KEY,
+ matched_cnt BIGINT NOT NULL DEFAULT 0,
+ unmatched_cnt BIGINT NOT NULL DEFAULT 0,
+ gross_amount BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/db/schema.d/35-st.sql b/db/schema.d/35-st.sql
new file mode 100644
index 0000000..026ddf8
--- /dev/null
+++ b/db/schema.d/35-st.sql
@@ -0,0 +1,77 @@
+-- st 모듈 (정산/수수료) 상세 테이블. Prefix 35 (30-rc 다음).
+-- ECPG 서버/배치가 참조하는 정산 상세/수수료율/조정/지급/가맹점집계 테이블 + 시퀀스.
+
+-- ---------------------------------------------------------------------------
+-- 정산 상세 (st_settle_dtl) - 수수료 종류별 정산 라인
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS st_dtl_seq;
+CREATE TABLE IF NOT EXISTS st_settle_dtl (
+ dtl_id BIGINT PRIMARY KEY,
+ settlement_id BIGINT NOT NULL DEFAULT 0,
+ purchase_id BIGINT NOT NULL DEFAULT 0,
+ merchant_id TEXT NOT NULL DEFAULT '',
+ fee_type TEXT NOT NULL, -- MDR/VAN/RELAY/WHT/VAT/ADJ/DELAYINT/CAP/RATE
+ amount BIGINT NOT NULL DEFAULT 0,
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 수수료 요율표 (st_fee_rate) - 구간별 요율 (basis points)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS st_fee_rate (
+ fee_type TEXT NOT NULL,
+ band_min BIGINT NOT NULL DEFAULT 0, -- 이 금액 이상에 적용
+ rate_bps BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (fee_type, band_min)
+);
+
+-- ---------------------------------------------------------------------------
+-- 정산 조정 (st_adjust) - 가감/상계/취소 조정
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS st_adj_seq;
+CREATE TABLE IF NOT EXISTS st_adjust (
+ adj_id BIGINT PRIMARY KEY,
+ settlement_id BIGINT NOT NULL DEFAULT 0,
+ purchase_id BIGINT NOT NULL DEFAULT 0,
+ delta BIGINT NOT NULL DEFAULT 0,
+ reason TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 지급 (st_payment) - 가맹점 지급 예정/보류/완료
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS st_pay_seq;
+CREATE TABLE IF NOT EXISTS st_payment (
+ pay_id BIGINT PRIMARY KEY,
+ merchant_id TEXT NOT NULL,
+ net_amount BIGINT NOT NULL DEFAULT 0,
+ pay_date DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'SCHEDULED', -- SCHEDULED/HELD/PAID/CANCELLED
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 가맹점 정산 집계 (st_merch_settle) - 배치/ST_BYMERCH가 upsert
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS st_merch_settle (
+ merchant_id TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ txn_count BIGINT NOT NULL DEFAULT 0,
+ gross_amount BIGINT NOT NULL DEFAULT 0,
+ fee_amount BIGINT NOT NULL DEFAULT 0,
+ net_amount BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (merchant_id, biz_date)
+);
+
+-- 수수료 구간 요율 시드 (ST_BANDRATE / ST_RATEAPPLY 가 조회)
+INSERT INTO st_fee_rate (fee_type, band_min, rate_bps) VALUES
+ ('MDR', 0, 25),
+ ('MDR', 10000000, 20),
+ ('VAN', 0, 10),
+ ('RELAY', 0, 5)
+ON CONFLICT (fee_type, band_min) DO NOTHING;
diff --git a/db/schema.d/40-lg.sql b/db/schema.d/40-lg.sql
new file mode 100644
index 0000000..53b5115
--- /dev/null
+++ b/db/schema.d/40-lg.sql
@@ -0,0 +1,67 @@
+-- ---------------------------------------------------------------------------
+-- lg 모듈 (원장/ledger) - 복식부기 전표/원장/계정잔액 스키마 (prefix 40)
+-- Loaded after 00-base.sql by docker-entrypoint-initdb.d.
+-- lg 서비스/배치가 채우는 전표(voucher) + 원장 라인(ledger_line) + 계정잔액 테이블.
+-- ---------------------------------------------------------------------------
+
+-- 전표 (voucher) - 하나의 회계 이벤트(매입전기/정산전기/조정/대체 등)의 헤더
+CREATE SEQUENCE IF NOT EXISTS lg_voucher_seq;
+CREATE TABLE IF NOT EXISTS lg_voucher (
+ voucher_id BIGINT PRIMARY KEY,
+ voucher_type TEXT NOT NULL, -- POST/DOUBLE/ADJ/REVERSE/TRANSFER/SETTLE/ACQ/FEE
+ ref_id BIGINT NOT NULL DEFAULT 0, -- 연계 매입/정산 ID
+ biz_date DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'D', -- D=작성, P=전기, M=대사, S=마감연계, X=취소
+ memo TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 원장 라인 (ledger_line) - 복식부기 차변(D)/대변(C) 개별 분개
+CREATE SEQUENCE IF NOT EXISTS lg_line_seq;
+CREATE TABLE IF NOT EXISTS lg_ledger_line (
+ line_id BIGINT PRIMARY KEY,
+ voucher_id BIGINT NOT NULL,
+ acct TEXT NOT NULL, -- 계정과목: RECV/MERCH_PAY/FEE_INC/CASH/ADJ ...
+ dc TEXT NOT NULL, -- D=차변(debit), C=대변(credit)
+ amount BIGINT NOT NULL,
+ merchant_id TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'posted', -- posted / closed
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_lg_line_voucher ON lg_ledger_line(voucher_id);
+CREATE INDEX IF NOT EXISTS ix_lg_line_acct ON lg_ledger_line(acct, biz_date);
+
+-- 계정잔액 (account balance) - 계정과목 x 영업일 누계
+CREATE TABLE IF NOT EXISTS lg_account_bal (
+ acct TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ debit_total BIGINT NOT NULL DEFAULT 0,
+ credit_total BIGINT NOT NULL DEFAULT 0,
+ balance BIGINT NOT NULL DEFAULT 0, -- 차변 - 대변
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (acct, biz_date)
+);
+
+-- 가맹점 잔액 (merchant balance) - 가맹점 x 영업일 미지급/미수 누계
+CREATE TABLE IF NOT EXISTS lg_merch_bal (
+ merchant_id TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ debit_total BIGINT NOT NULL DEFAULT 0,
+ credit_total BIGINT NOT NULL DEFAULT 0,
+ balance BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (merchant_id, biz_date)
+);
+
+-- 시산표 (trial balance) - 배치가 계정별로 채움
+CREATE TABLE IF NOT EXISTS lg_trial_bal (
+ acct TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ debit_total BIGINT NOT NULL DEFAULT 0,
+ credit_total BIGINT NOT NULL DEFAULT 0,
+ diff BIGINT NOT NULL DEFAULT 0, -- 차변 - 대변 (0 이어야 정상)
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (acct, biz_date)
+);
diff --git a/db/schema.d/45-cl.sql b/db/schema.d/45-cl.sql
new file mode 100644
index 0000000..0c4760e
--- /dev/null
+++ b/db/schema.d/45-cl.sql
@@ -0,0 +1,51 @@
+-- ---------------------------------------------------------------------------
+-- cl 모듈 (마감/closing) - 일/월/분기/연 마감 로그 + 스냅샷 + 기간잠금 (prefix 45)
+-- Loaded after 40-lg.sql by docker-entrypoint-initdb.d.
+-- cl 서비스/배치가 채우는 마감이력(close_log) + 마감스냅샷(snapshot) 테이블.
+-- ---------------------------------------------------------------------------
+
+-- 마감 이력 (close_log) - 하나의 마감 실행(일/월/분기/연/중간)의 헤더
+CREATE SEQUENCE IF NOT EXISTS cl_close_seq;
+CREATE TABLE IF NOT EXISTS cl_close_log (
+ close_id BIGINT PRIMARY KEY,
+ close_type TEXT NOT NULL, -- DAILY/MONTHLY/QUARTER/YEAREND/MID
+ period_key TEXT NOT NULL, -- YYYY-MM-DD / YYYY-MM / YYYY-Qn / YYYY
+ biz_date DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'O', -- O=개시, C=마감완료, R=재마감, A=승인, L=잠금, X=취소
+ txn_count BIGINT NOT NULL DEFAULT 0,
+ total_amount BIGINT NOT NULL DEFAULT 0,
+ memo TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_cl_close_period ON cl_close_log(close_type, period_key);
+
+-- 마감 스냅샷 (snapshot) - 마감 시점 가맹점별 집계 고정
+CREATE SEQUENCE IF NOT EXISTS cl_snapshot_seq;
+CREATE TABLE IF NOT EXISTS cl_snapshot (
+ snapshot_id BIGINT PRIMARY KEY,
+ close_id BIGINT NOT NULL,
+ period_key TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ merchant_id TEXT NOT NULL,
+ gross_amount BIGINT NOT NULL DEFAULT 0,
+ fee_amount BIGINT NOT NULL DEFAULT 0,
+ net_amount BIGINT NOT NULL DEFAULT 0,
+ txn_count BIGINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_cl_snapshot_close ON cl_snapshot(close_id);
+
+-- 기간 잠금 (period lock) - 마감된 기간의 추가 정정 차단
+CREATE TABLE IF NOT EXISTS cl_period_lock (
+ period_key TEXT PRIMARY KEY,
+ locked BOOLEAN NOT NULL DEFAULT true,
+ locked_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 마감 시퀀스 관리 (close_type 별 마지막 순번)
+CREATE TABLE IF NOT EXISTS cl_seq_reg (
+ close_type TEXT PRIMARY KEY,
+ last_seq BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/db/schema.d/50-mm.sql b/db/schema.d/50-mm.sql
new file mode 100644
index 0000000..3178a94
--- /dev/null
+++ b/db/schema.d/50-mm.sql
@@ -0,0 +1,162 @@
+-- mm 모듈 (마스터/master) 상세 스키마
+-- 가맹점 확장원장 / 수수료율 / BIN / 공통코드 / 한도 / 발급사 / 단말 / MCC /
+-- 공휴일 / 환율 마스터 + 이력. Loaded on first boot (docker-entrypoint-initdb.d).
+
+-- ---------------------------------------------------------------------------
+-- 가맹점 확장 마스터 (mm_merchant_ext) - base merchant를 보강하는 마스터 원장
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_merchant_ext (
+ merchant_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ biz_reg_no TEXT NOT NULL DEFAULT '', -- 사업자등록번호
+ grade TEXT NOT NULL DEFAULT 'C', -- 가맹점 등급 (S/A/B/C)
+ settle_acct TEXT NOT NULL DEFAULT '', -- 정산 계좌
+ mcc TEXT NOT NULL DEFAULT '0000', -- 업종코드
+ status TEXT NOT NULL DEFAULT 'ACTIVE',-- ACTIVE / HOLD / DELETED
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 가맹점 마스터 변경 이력
+CREATE SEQUENCE IF NOT EXISTS mm_merch_hist_seq;
+CREATE TABLE IF NOT EXISTS mm_merch_hist (
+ hist_id BIGINT PRIMARY KEY,
+ merchant_id TEXT NOT NULL,
+ action TEXT NOT NULL, -- REG / UPD / DEL / STATUS / GRADE / ACCT
+ before_val TEXT NOT NULL DEFAULT '',
+ after_val TEXT NOT NULL DEFAULT '',
+ memo TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 수수료율 마스터 (mm_fee_rate) + 이력
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_fee_rate (
+ merchant_id TEXT NOT NULL,
+ card_type TEXT NOT NULL DEFAULT 'CREDIT',-- CREDIT / CHECK / PREPAID
+ rate_bps BIGINT NOT NULL DEFAULT 25, -- basis points
+ eff_date DATE NOT NULL DEFAULT current_date,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (merchant_id, card_type)
+);
+
+CREATE SEQUENCE IF NOT EXISTS mm_feerate_hist_seq;
+CREATE TABLE IF NOT EXISTS mm_fee_rate_hist (
+ hist_id BIGINT PRIMARY KEY,
+ merchant_id TEXT NOT NULL,
+ card_type TEXT NOT NULL,
+ rate_bps BIGINT NOT NULL,
+ eff_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- BIN 마스터 (mm_bin)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_bin (
+ bin TEXT PRIMARY KEY, -- 6-8 digit BIN
+ issuer_cd TEXT NOT NULL DEFAULT 'UNKNOWN',
+ card_brand TEXT NOT NULL DEFAULT 'LOCAL', -- VISA / MASTER / LOCAL
+ card_type TEXT NOT NULL DEFAULT 'CREDIT',
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 공통코드 마스터 (mm_code)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_code (
+ code_group TEXT NOT NULL,
+ code TEXT NOT NULL,
+ code_name TEXT NOT NULL DEFAULT '',
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (code_group, code)
+);
+
+-- ---------------------------------------------------------------------------
+-- 가맹점 한도 마스터 (mm_limit)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_limit (
+ merchant_id TEXT PRIMARY KEY,
+ daily_limit BIGINT NOT NULL DEFAULT 100000000,
+ monthly_limit BIGINT NOT NULL DEFAULT 3000000000,
+ per_txn_limit BIGINT NOT NULL DEFAULT 10000000,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 발급사 마스터 (mm_issuer)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_issuer (
+ issuer_cd TEXT PRIMARY KEY,
+ issuer_name TEXT NOT NULL DEFAULT '',
+ country TEXT NOT NULL DEFAULT 'KR',
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 단말 마스터 (mm_terminal)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_terminal (
+ terminal_id TEXT PRIMARY KEY,
+ merchant_id TEXT NOT NULL,
+ model TEXT NOT NULL DEFAULT 'STD',
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- MCC 업종 마스터 (mm_mcc)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_mcc (
+ mcc TEXT PRIMARY KEY,
+ mcc_name TEXT NOT NULL DEFAULT '',
+ risk_level INTEGER NOT NULL DEFAULT 1
+);
+
+-- ---------------------------------------------------------------------------
+-- 공휴일 마스터 (mm_holiday)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_holiday (
+ holiday_date DATE PRIMARY KEY,
+ holiday_name TEXT NOT NULL DEFAULT '',
+ nation TEXT NOT NULL DEFAULT 'KR'
+);
+
+-- ---------------------------------------------------------------------------
+-- 환율 마스터 (mm_fx_rate)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_fx_rate (
+ ccy TEXT PRIMARY KEY,
+ rate_bps BIGINT NOT NULL DEFAULT 100, -- KRW per unit, in bps of 100
+ base_date DATE NOT NULL DEFAULT current_date,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 등급 통계 (mm_grade_stat) - mm_stat_batch가 채움
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mm_grade_stat (
+ stat_date DATE NOT NULL,
+ grade TEXT NOT NULL,
+ merch_cnt BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (stat_date, grade)
+);
+
+-- Seed 몇 개 마스터 (테스트/데모용)
+INSERT INTO mm_code (code_group, code, code_name, sort_order) VALUES
+ ('CARD_TYPE', 'CREDIT', '신용카드', 1),
+ ('CARD_TYPE', 'CHECK', '체크카드', 2),
+ ('GRADE', 'S', '최우수', 1),
+ ('GRADE', 'A', '우수', 2)
+ON CONFLICT (code_group, code) DO NOTHING;
+
+INSERT INTO mm_issuer (issuer_cd, issuer_name, country) VALUES
+ ('BC', 'BC카드', 'KR'),
+ ('SHC', '신한카드', 'KR')
+ON CONFLICT (issuer_cd) DO NOTHING;
diff --git a/db/schema.d/55-vl.sql b/db/schema.d/55-vl.sql
new file mode 100644
index 0000000..e3b1b07
--- /dev/null
+++ b/db/schema.d/55-vl.sql
@@ -0,0 +1,53 @@
+-- vl 모듈 (정합성검증/validation) 상세 스키마
+-- 검증 규칙 / 의심거래(suspect) / 검증 로그. Loaded on first boot.
+
+-- ---------------------------------------------------------------------------
+-- 검증 규칙 마스터 (vl_rule)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS vl_rule (
+ rule_code TEXT PRIMARY KEY,
+ rule_name TEXT NOT NULL DEFAULT '',
+ threshold BIGINT NOT NULL DEFAULT 0, -- 규칙별 임계값 (금액/건수 등)
+ severity TEXT NOT NULL DEFAULT 'WARN', -- INFO / WARN / ERROR / CRIT
+ status TEXT NOT NULL DEFAULT 'ACTIVE',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 의심거래 (vl_suspect) - 검증 실패/이상 거래 적재
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS vl_suspect_seq;
+CREATE TABLE IF NOT EXISTS vl_suspect (
+ suspect_id BIGINT PRIMARY KEY,
+ purchase_id BIGINT NOT NULL DEFAULT 0,
+ rule_code TEXT NOT NULL,
+ severity TEXT NOT NULL DEFAULT 'WARN',
+ detail TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'OPEN', -- OPEN / CONFIRMED / CLOSED
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 검증 로그 (vl_check_log) - 모든 검증 서비스 호출 기록
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS vl_checklog_seq;
+CREATE TABLE IF NOT EXISTS vl_check_log (
+ log_id BIGINT PRIMARY KEY,
+ check_type TEXT NOT NULL, -- 서비스명 (VL_SALES 등)
+ target_id BIGINT NOT NULL DEFAULT 0, -- 검증 대상 (purchase_id 등)
+ result_code BIGINT NOT NULL DEFAULT 0, -- 0=정상, >0=검증 실패코드
+ detail TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- Seed 기본 검증 규칙
+INSERT INTO vl_rule (rule_code, rule_name, threshold, severity) VALUES
+ ('AMT_MAX', '최대금액 초과', 50000000, 'ERROR'),
+ ('AMT_ZERO', '0원/음수 금액', 0, 'ERROR'),
+ ('DUP_TXN', '중복 매입', 1, 'WARN'),
+ ('LIMIT_OVR', '일한도 초과', 0, 'WARN'),
+ ('LEDGER_MIS','원장 불일치', 0, 'CRIT')
+ON CONFLICT (rule_code) DO NOTHING;
diff --git a/db/schema.d/60-mg.sql b/db/schema.d/60-mg.sql
new file mode 100644
index 0000000..04951d5
--- /dev/null
+++ b/db/schema.d/60-mg.sql
@@ -0,0 +1,119 @@
+-- mg 모듈 (전문게이트웨이 / message gateway) 스키마
+-- ISO8583 전문 로그/라우팅/채번/채널세션/큐/통계 테이블.
+-- Loaded into PostgreSQL on first boot (docker-entrypoint-initdb.d). Prefix 60.
+
+-- ---------------------------------------------------------------------------
+-- 전문 채번 (STAN / 전문 시퀀스) - MG_STAN 이 nextval 로 채번
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS mg_stan_seq; -- System Trace Audit Number
+CREATE SEQUENCE IF NOT EXISTS mg_msg_seq; -- 전문 로그 PK
+CREATE SEQUENCE IF NOT EXISTS mg_route_seq; -- 라우팅 룰 PK
+CREATE SEQUENCE IF NOT EXISTS mg_queue_seq; -- 송수신 큐 PK
+
+-- 이름있는 카운터 (세션 시퀀스 등 채번용 - MG_SESSION/MG_KEYEXCH)
+CREATE TABLE IF NOT EXISTS mg_seq (
+ seq_name TEXT PRIMARY KEY,
+ seq_val BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 전문 로그 (mg_msg_log) - 수신/송신 전문의 원장
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mg_msg_log (
+ msg_id BIGINT PRIMARY KEY,
+ stan BIGINT NOT NULL, -- 전문 추적번호
+ mti TEXT NOT NULL, -- 전문유형(0200/0210/0800...)
+ channel TEXT NOT NULL, -- 채널ID
+ direction TEXT NOT NULL, -- IN / OUT
+ proc_code TEXT NOT NULL DEFAULT '000000', -- 처리코드
+ bitmap TEXT NOT NULL DEFAULT '', -- 비트맵(hex)
+ amount BIGINT NOT NULL DEFAULT 0,
+ pan TEXT NOT NULL DEFAULT '', -- (마스킹된) 카드번호
+ mac TEXT NOT NULL DEFAULT '', -- 메시지 인증코드
+ rc TEXT NOT NULL DEFAULT '00', -- 응답코드
+ raw_msg TEXT NOT NULL DEFAULT '', -- 원 전문
+ status TEXT NOT NULL DEFAULT 'R', -- R=수신, S=송신, A=ACK, N=NACK, C=확인
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_mg_msg_log_stan ON mg_msg_log (stan);
+CREATE INDEX IF NOT EXISTS ix_mg_msg_log_chan ON mg_msg_log (channel, biz_date);
+
+-- ---------------------------------------------------------------------------
+-- 라우팅 룰 (mg_route) - 채널/전문유형 -> 목적지 매핑
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mg_route (
+ route_id BIGINT PRIMARY KEY,
+ channel TEXT NOT NULL,
+ mti TEXT NOT NULL,
+ dest TEXT NOT NULL, -- 목적지 서비스/노드
+ priority INTEGER NOT NULL DEFAULT 100,
+ active BOOLEAN NOT NULL DEFAULT true,
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_mg_route_lookup ON mg_route (channel, mti, active);
+
+-- ---------------------------------------------------------------------------
+-- 채널 (mg_channel) - 세션/사인온 상태
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mg_channel (
+ channel TEXT PRIMARY KEY,
+ name TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'DOWN', -- UP / DOWN / SIGNON / SIGNOFF
+ session_key TEXT NOT NULL DEFAULT '',
+ last_stan BIGINT NOT NULL DEFAULT 0,
+ signon_at TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 송수신 큐 (mg_queue) - 재전송 대상 관리
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mg_queue (
+ queue_id BIGINT PRIMARY KEY,
+ stan BIGINT NOT NULL,
+ channel TEXT NOT NULL,
+ mti TEXT NOT NULL DEFAULT '',
+ payload TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'Q', -- Q=대기, S=송신, D=완료, F=실패
+ retry_cnt INTEGER NOT NULL DEFAULT 0,
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_mg_queue_status ON mg_queue (status, biz_date);
+
+-- ---------------------------------------------------------------------------
+-- 채널 일별 통계 (mg_stat) - mg_stat_batch 가 채움
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS mg_stat (
+ channel TEXT NOT NULL,
+ biz_date DATE NOT NULL,
+ in_cnt BIGINT NOT NULL DEFAULT 0,
+ out_cnt BIGINT NOT NULL DEFAULT 0,
+ err_cnt BIGINT NOT NULL DEFAULT 0,
+ amount_sum BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (channel, biz_date)
+);
+
+-- ---------------------------------------------------------------------------
+-- Seed: 채널 + 기본 라우팅 룰
+-- ---------------------------------------------------------------------------
+INSERT INTO mg_channel (channel, name, status) VALUES
+ ('CH01', 'VAN 게이트웨이', 'DOWN'),
+ ('CH02', '매입사 링크', 'DOWN'),
+ ('CH03', '해외 네트워크', 'DOWN')
+ON CONFLICT (channel) DO NOTHING;
+
+INSERT INTO mg_route (route_id, channel, mti, dest, priority, active, biz_date) VALUES
+ (nextval('mg_route_seq'), 'CH01', '0200', 'ACQUIRE', 10, true, '2026-07-19'),
+ (nextval('mg_route_seq'), 'CH01', '0800', 'MG_ECHO', 20, true, '2026-07-19'),
+ (nextval('mg_route_seq'), 'CH02', '0200', 'ACQ_EDC', 10, true, '2026-07-19')
+ON CONFLICT DO NOTHING;
+
+INSERT INTO mg_seq (seq_name, seq_val) VALUES
+ ('SESSION', 0), ('KEYVER', 0)
+ON CONFLICT (seq_name) DO NOTHING;
diff --git a/db/schema.d/65-cm.sql b/db/schema.d/65-cm.sql
new file mode 100644
index 0000000..fa23b20
--- /dev/null
+++ b/db/schema.d/65-cm.sql
@@ -0,0 +1,112 @@
+-- cm 모듈 (공통 / common) 스키마
+-- 공통코드/파라미터/휴일/환율/채번/공통로그 + seed 데이터. Prefix 65.
+-- Loaded into PostgreSQL on first boot (docker-entrypoint-initdb.d).
+
+-- ---------------------------------------------------------------------------
+-- 공통코드 (cm_code)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS cm_code (
+ code_grp TEXT NOT NULL, -- 코드 그룹 (예: CCY, CHANNEL)
+ code TEXT NOT NULL, -- 코드 값
+ code_name TEXT NOT NULL, -- 코드 명
+ sort_ord INTEGER NOT NULL DEFAULT 0,
+ use_yn TEXT NOT NULL DEFAULT 'Y',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (code_grp, code)
+);
+
+-- ---------------------------------------------------------------------------
+-- 공통 파라미터 (cm_param) - 시스템 운영 파라미터 (BIZ_DATE 포함)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS cm_param (
+ param_key TEXT PRIMARY KEY,
+ param_val TEXT NOT NULL,
+ descr TEXT NOT NULL DEFAULT '',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 휴일 (cm_holiday) - 영업일 계산에 사용
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS cm_holiday (
+ holi_date DATE PRIMARY KEY,
+ holi_name TEXT NOT NULL,
+ nation TEXT NOT NULL DEFAULT 'KR'
+);
+
+-- ---------------------------------------------------------------------------
+-- 환율 (cm_fx_rate) - 통화별 기준일 환율 (bps of 100 = KRW*100/unit)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS cm_fx_rate (
+ ccy TEXT NOT NULL,
+ base_date DATE NOT NULL,
+ rate_bps BIGINT NOT NULL, -- KRW per unit * 100
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (ccy, base_date)
+);
+
+-- ---------------------------------------------------------------------------
+-- 채번 (cm_seq) - 이름있는 공통 시퀀스 (CM_SEQ 서비스)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS cm_seq (
+ seq_name TEXT PRIMARY KEY,
+ seq_val BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ---------------------------------------------------------------------------
+-- 공통 로그 (cm_log) - CM_LOG 서비스 + cm_purge_batch 대상
+-- ---------------------------------------------------------------------------
+CREATE SEQUENCE IF NOT EXISTS cm_log_seq;
+CREATE TABLE IF NOT EXISTS cm_log (
+ log_id BIGINT PRIMARY KEY,
+ svc_name TEXT NOT NULL DEFAULT '',
+ log_level TEXT NOT NULL DEFAULT 'I', -- I/W/E
+ msg TEXT NOT NULL DEFAULT '',
+ biz_date DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS ix_cm_log_date ON cm_log (biz_date);
+
+-- ---------------------------------------------------------------------------
+-- Seed rows
+-- ---------------------------------------------------------------------------
+INSERT INTO cm_code (code_grp, code, code_name, sort_ord) VALUES
+ ('CCY', 'KRW', '원화', 1),
+ ('CCY', 'USD', '미국 달러', 2),
+ ('CCY', 'JPY', '일본 엔', 3),
+ ('CCY', 'EUR', '유로', 4),
+ ('CHANNEL', 'POS', 'POS 단말', 1),
+ ('CHANNEL', 'EDC', 'EDC 단말', 2),
+ ('CHANNEL', 'EDI', 'EDI 청구', 3),
+ ('CHANNEL', 'FOREIGN', '해외 매입', 4),
+ ('STATUS', 'A', '접수', 1),
+ ('STATUS', 'S', '정산완료', 2),
+ ('STATUS', 'C', '취소', 3)
+ON CONFLICT (code_grp, code) DO NOTHING;
+
+INSERT INTO cm_param (param_key, param_val, descr) VALUES
+ ('BIZ_DATE', '2026-07-19', '현재 영업일'),
+ ('VAT_RATE', '10', '부가세율(%)'),
+ ('WHT_BPS', '2200', '원천징수율(bps)'),
+ ('LOG_KEEP_DAYS','90', '공통로그 보존일수'),
+ ('SETTLE_CYCLE','1', '정산주기(일)')
+ON CONFLICT (param_key) DO NOTHING;
+
+INSERT INTO cm_holiday (holi_date, holi_name, nation) VALUES
+ ('2026-01-01', '신정', 'KR'),
+ ('2026-03-01', '삼일절', 'KR'),
+ ('2026-05-05', '어린이날', 'KR'),
+ ('2026-08-15', '광복절', 'KR'),
+ ('2026-12-25', '성탄절', 'KR')
+ON CONFLICT (holi_date) DO NOTHING;
+
+INSERT INTO cm_fx_rate (ccy, base_date, rate_bps) VALUES
+ ('USD', '2026-07-19', 135000), -- 1350.00 KRW
+ ('JPY', '2026-07-19', 900), -- 9.00 KRW
+ ('EUR', '2026-07-19', 147000) -- 1470.00 KRW
+ON CONFLICT (ccy, base_date) DO NOTHING;
+
+INSERT INTO cm_seq (seq_name, seq_val) VALUES
+ ('BATCH', 0), ('SLIP', 0), ('TICKET', 0)
+ON CONFLICT (seq_name) DO NOTHING;
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 106e5df..01656bb 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -28,8 +28,10 @@ services:
sysctls:
fs.mqueue.msg_max: "512"
fs.mqueue.msgsize_max: "65536"
+ # ~330 서비스 = 330+ POSIX 큐. 기본 queues_max(256) 초과 → 11 모듈서버 부팅 실패하므로 상향.
+ fs.mqueue.queues_max: "8192"
ulimits:
- msgqueue: 536870912
+ msgqueue: 2147483648
nofile: 65536
volumes:
- ../app:/app