feat(stmate-test): REA 기성·준공 — 칸 뜻(계약·누계·전회·금회) · 회차 이월 · 수량 상한 · 기성 제잡비·부가세·절사 원본 주소 근거 · 저장 묶음 분리

- 지식DB REA 분석 33 기성준공단계 작업자료(정적분석 보고서·근거)
- 기성 공사(PCLASS 6) 저장 = COSTW2~4 · [2·3·4_KANJUB] n_C_* 기성 화면 값 그대로
- save_payload.js 로 저장 요청 묶음 분리(view_prj_boq.js 700줄 한도)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 09:16:17 +09:00
co-authored by Claude Opus 5.5
parent 00613420aa
commit edd5d020f6
34 changed files with 10763 additions and 243 deletions
+1
View File
@@ -55,6 +55,7 @@
<script src="smt_engine.js"></script>
<script src="calc_xrate.js"></script>
<script src="calc_a2w.js"></script>
<script src="save_payload.js"></script>
<script src="view_prj_boq.js"></script>
<script src="view_dan_ilwi.js"></script>
<script src="view_kanjub_prt.js"></script>
+272 -85
View File
@@ -1,10 +1,14 @@
/* -*- coding: utf-8 -*- */
/**
* 기성 계산 (progress_calc.js) — 회차별 전회·금회·누계 금액과 비율 · 공종별 기성수량·잔량 · 기성 제잡비 · 부가세 4택
* 근거: 지식DB STmate/old/20_분석 27 §3(wM_KanJub_K · 금회직접공사비 × 계약제잡비율 · 4단 누적 · 부가세 4택
* · 공급가액/총공사비 절사 · 이윤 직접입력 · 서식 0632~0638) · 35 §3(기성 단계) · 11 §10(계약 제잡비율 경로)
* 미확인(표본 0건, 27 §9): 누계 금액 산식 · 절사 순서 — 여기서는 누계 = trunc(계약단가 × 누계수량), 금회 = 누계 − 전회
* 시험: resources/tester/test_stmate_stage_progress.py (자기 일관성 — 전회+금회=누계 · 100% 누계=계약)
* 근거: 지식DB STmate/REA 분석/33_기성준공단계_작업자료_20261114(원본 정적 분석 — 주소는 아래 각 규칙 옆)
* · STmate/old/20_분석 27 §3(화면 구성)
* 칸 번호(원본 BOQ QTYn·T_AMTn · PRJLST TCOSTn · PRJLST.SMT [n_KANJUB]) — 1 계약(도급) · 2 누계 · 3 전회 · 4 금회
* (ST_KanJub 0x4cae9b 열→칸 표 '1342' · 0x4cb03c 값 배열 순서 · ST_P_ConEst 0x47fadf~0x47fd4c)
* 금액: 금회 = 금회수량으로 · 누계 = 누계수량으로 각각 다시 셈(ST_Boq 0x4fc674 · 0x4fce6e→T_AMT2 0x4fcf24)
* · 전회 = 앞 회차 누계 이관(STmate 0x55d0b3~0x55d148 「전회<-누계, 금회=0」) — 전회+금회 ≠ 누계(끝전) 있음
* 미확인: 금회 칸 단가 원천(MAMT4 등 — 회차 생성 시 1→2·3 칸만 복사 0x55c826~0x55ca73) — 여기서는 계약 단가
* 시험: resources/tester/test_stmate_stage_progress.py · test_stmate_stage_progress_rea.py
*/
const StmateProgress = (() => {
const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js");
@@ -14,16 +18,34 @@ const StmateProgress = (() => {
const n0 = (v) => Number(v) || 0;
const r4 = (v) => Math.round(n0(v) * 1e4) / 1e4;
const KEYS = ["mAmt", "lAmt", "sAmt", "tAmt"];
// 부가세 4택(27 §3.3) — 0 직접입력 · 1 공급가액 10% · 2 재료비 10% · 3 재료비+산출경비 10%
// 부가세 4택(cmBx_Buga · DFM TWM_KANJUB_K) — 0 직접입력 · 1 공급가액 10% · 2 재료비 10% · 3 재료비+산출경비 10%
const VAT_MODES = ["직접입력", "공급가액의 10%", "재료비의 10%", "재료비+산출경비의 10%"];
// 집계수준(rd_Filter) — 소계 이상 … 총합계 (머리 단 T2~T9)
// 집계수준(rd_Filter 0x4cadbc) — 거르기 (BDCODE1="T") .and. (BDCODE1>"T"+순번) → 머리 단 ≥ 순번+1 · 처음 값 순번 2
const LEVELS = [
[0, "공종 전부"],
[2, "소계 이상"],
[3, "합계 이상"],
[4, "총계 이상"],
[5, "총합계"],
[1, "소계(+=) 이상"],
[2, "계(+==) 이상"],
[3, "합계(+===) 이상"],
[4, "총계(+====) 이상"],
[5, "총합계(+=====) 이상"],
];
const DEFAULT_LEVEL = 3;
// 앱 제잡비 코드 → 원본 저장키 C_ 코드(0x4cb564 행 목록 · 0x4caa48 「칸_C_코드」)
const C_CODE = {
KANNO: "C_KANNO",
SANBO: "C_SANBO",
KOBO: "C_KOBO",
KUNBO: "C_KUNBO",
SLVBO: "C_SLVBO",
YUNBO: "C_YUNBO",
TBUGUM: "C_TBUGUM",
ANJUN: "C_ANJUN",
KITA: "C_T_KITA",
HWAN: "C_HWAN",
EQIP: "C_EQIPSU",
ILBAN: "C_ILBAN",
IYUN: "C_IYUN",
};
// 계약 기준 — 계약 내역(contract_calc.convert)과 계약 원가계산서에서 공종·계약잡비율을 뽑음
function contractBase(conv, kj) {
@@ -35,18 +57,20 @@ const StmateProgress = (() => {
const k = nd.pct ? 100 : 1; // % 단위 공종 = 밑수 ÷ 100 을 단가로
return {
id: nd.id,
recno: nd.recno,
code: nd.newCode || nd.code,
name: nd.name,
spec: nd.spec,
unit: nd.unit,
gov: nd.gov,
pct: !!nd.pct,
qty: n0(s.qty),
u: { m: n0(s.mUnit) / k, l: n0(s.lUnit) / k, s: n0(s.sUnit) / k },
amt: { mAmt: s.mAmt, lAmt: s.lAmt, sAmt: s.sAmt, tAmt: s.tAmt },
};
});
const direct = n0(kj.dT);
// 계약잡비율 = 계약 제잡비 항목 금액 ÷ 계약 직접공사비
// 요율 행(원본 종류 1) — 간접노무비~기타경비 · 일반관리비 · 이윤
const lines = kj.items
.filter((it) => n0(it.amount) !== 0)
.map((it) => ({ code: it.code, label: it.label, amount: n0(it.amount) }))
@@ -54,8 +78,12 @@ const StmateProgress = (() => {
{ code: "ILBAN", label: "일반관리비", amount: n0(kj.ilbanAmt) },
{ code: "IYUN", label: "이윤", amount: n0(kj.iyunFinal) },
]);
const rates = Object.fromEntries(lines.map((l) => [l.code, direct ? l.amount / direct : 0]));
return { conv, kj, leaves, lines, rates, direct, nodes: conv.nodes, stage };
// 계약잡비율(%) = 계약 항목 ÷ 계약 직접공사비 × 100 — 버림 없이 그대로(0x4cb2cc~0x4cb300)
const ratePct = Object.fromEntries(
lines.map((l) => [l.code, direct ? (l.amount / direct) * 100 : 0]),
);
const rates = Object.fromEntries(lines.map((l) => [l.code, ratePct[l.code] / 100]));
return { conv, kj, leaves, lines, rates, ratePct, direct, nodes: conv.nodes, stage };
}
function amountsAt(lf, qty) {
@@ -64,74 +92,153 @@ const StmateProgress = (() => {
s = roundDown(lf.u.s * qty, 0);
return { mAmt: m, lAmt: l, sAmt: s, tAmt: m + l + s };
}
const sub = (a, b) => Object.fromEntries(KEYS.map((k) => [k, n0(a[k]) - n0(b[k])]));
const add = (a, b) => Object.fromEntries(KEYS.map((k) => [k, n0(a[k]) + n0(b[k])]));
const zero = () => Object.fromEntries(KEYS.map((k) => [k, 0]));
const pct = (a, b) => (b ? Math.round((a / b) * 1e5) / 1e3 : 0);
// 비율 = 금액 ÷ 계약 × 100 — 화면 서식 '0.00%'(0x4cdc7a · 0x4cea20) 두 자리
const pct = (a, b) => (b ? Math.round((a / b) * 1e4) / 1e2 : 0);
// 회차 하나의 제잡비·공급가액·부가세 — 금회직접 × 계약잡비율(항목별 버림)
function jabFor(base, cur, opt) {
const dT = cur.mAmt + cur.lAmt + cur.sAmt;
const items = base.lines.map((l) => ({
code: l.code,
label: l.label,
rate: base.rates[l.code],
amount:
l.code === "IYUN" && opt.iyunManual != null
? n0(opt.iyunManual)
: roundDown(dT * base.rates[l.code], 0),
}));
let supply = dT + items.reduce((a, it) => a + it.amount, 0);
const sj = n0(opt.supplyJeol);
if (sj < 0) {
const cut = supply - roundDown(supply, sj);
const iy = items.find((it) => it.code === "IYUN");
if (iy) iy.amount -= cut;
supply -= cut;
}
const mode = opt.vatMode == null ? 1 : Number(opt.vatMode);
const vat =
mode === 0
? n0(opt.vatManual)
: mode === 2
? roundDown(cur.mAmt * 0.1, 0)
: mode === 3
? roundDown((cur.mAmt + cur.sAmt) * 0.1, 0)
: roundDown(supply * 0.1, 0);
let total = supply + vat;
const tj = n0(opt.totalJeol);
let totalCut = 0;
if (tj < 0) {
totalCut = total - roundDown(total, tj);
const iy = items.find((it) => it.code === "IYUN");
if (iy) iy.amount -= totalCut;
supply -= totalCut;
total -= totalCut;
}
return { direct: dT, items, supply, vat, total, totalCut };
// 금회수량 상한(ST_Boq 0x4f4bbd~0x4f4c9f) — 계약×금회 > 0 이고 |전회+금회| > |계약| 이면 금회 = 계약 − 전회
// 설정 바이트(+0x1068→+0x69) 켜짐이면 상한 없음 — 그 설정 이름 미확인 → opt.allowOver
function capCur(ctrQ, prevQ, q, allowOver) {
if (allowOver || !(ctrQ * q > 0)) return q;
return Math.abs(r4(prevQ + q)) > Math.abs(ctrQ) ? r4(ctrQ - prevQ) : q;
}
// 회차 하나의 기성 제잡비 계산서(rGrd_Jab) — ST_KanJub 0x4cdec0(계약제잡비율 적용) · 0x4ce6c4(총공사비 계산)
// cur: 금회 직접비 {mAmt,lAmt,sAmt,tAmt} · prev: 앞 회차 누계 표(없으면 0) · accDirect: 누계 직접비
function jabFor(base, cur, opt, prev, accDirect) {
const dT = cur.mAmt + cur.lAmt + cur.sAmt;
const prevOf = (code) =>
prev ? n0((prev.items.find((x) => x.code === code) || {}).amount) : 0;
// 누계직접 = 도급직접 이면 요율 행 금회 = 도급 − 전회(0x4cdf53 비교 · 0x4cdfae~0x4cdff3)
const final = n0(accDirect) === n0(base.direct) && base.direct !== 0;
const items = base.lines.map((l) => {
const p = prevOf(l.code);
let amount = final ? l.amount - p : roundDown((base.ratePct[l.code] * dT) / 100, 0); // MROUNDUD(율 × 금회직접 / 100, 0, 0) 0x4ce025
// 상한 — 금회 ≥ 도급 + 전회 이면 금회 = 도급 − 전회(0x4ce045~0x4ce0db · 원본 비교 그대로)
if (amount >= l.amount + p) amount = l.amount - p;
return { code: l.code, label: l.label, rate: base.rates[l.code], amount };
});
const iy = items.find((it) => it.code === "IYUN");
const sj = Math.abs(n0(opt.supplyJeol));
const tj = sj ? 0 : Math.abs(n0(opt.totalJeol)); // 두 절사는 하나만(cmBx_ValueChange 0x4cef28~0x4cef84)
// 이윤 직접입력 = 총공사비 절사 「이윤금액 직접입력」(값 0)일 때 이윤 금회 칸 손입력
if (iy && !tj && opt.iyunManual != null && opt.iyunManual !== "")
iy.amount = n0(opt.iyunManual);
const mode = opt.vatMode == null ? 1 : Number(opt.vatMode);
// 소계 이후 다시 셈(0x4ce2c8) — 공급가액 → 부가세(4택) → 도급
const settle = () => {
const supply = dT + items.reduce((a, it) => a + it.amount, 0);
const vat =
mode === 0
? n0(opt.vatManual)
: mode === 2
? roundDown(cur.mAmt * 0.1, 0) // 0.1 × 금회 재료비(MCOST4) 0x4ce42e
: mode === 3
? roundDown((cur.mAmt + cur.sAmt) * 0.1, 0) // 0.1 × (MCOST4 + SCOST4) 0x4ce466
: roundDown(supply * 0.1, 0); // 0.1 × 부가세 행까지 누적(=공급가액) 0x4ce3f6
return { supply, vat, total: supply + vat };
};
let s = settle();
let cut = 0;
const orig = s.total;
if (iy && (sj || tj)) {
// 끝전 조정량 0x4ce55c — 공급가액 절사: 공급 − 버림(공급) · 총공사비 절사: 총 − 버림(총) (부가세 1택이면 ÷1.1 버림)
if (sj) cut = s.supply - roundDown(s.supply, -sj);
else {
cut = s.total - roundDown(s.total, -tj);
if (mode === 1) cut = roundDown(cut / 1.1, 0);
}
iy.amount -= cut; // 이윤 금회 − 조정량(0x4ce38b~0x4ce3c1)
s = settle();
if (tj && mode === 1) {
// 총액 − 목표가 1~10원 남으면 이윤 1원씩 더 깎음(0x4ce8c0~0x4ce90d)
const target = roundDown(orig, -tj);
for (let g = 0; g < 1000; g++) {
const d = Math.round(s.total - target);
if (d < 1 || d > 10) break;
iy.amount -= 1;
cut += 1;
s = settle();
}
}
}
return {
direct: dT,
items,
supply: s.supply,
vat: s.vat,
total: s.total,
totalCut: cut,
final,
};
}
const zeroJab = (cur) => ({
direct: 0,
items: cur.items.map((it) => Object.assign({}, it, { amount: 0 })),
supply: 0,
vat: 0,
total: 0,
});
// 행마다 누계 = 전회 + 금회(0x4ce4a4~0x4ce4e6) · 부분합 행도 같은 누적
const sumJab = (a, b) => ({
direct: a.direct + b.direct,
items: b.items.map((it, i) => Object.assign({}, it, { amount: a.items[i].amount + it.amount })),
supply: a.supply + b.supply,
vat: a.vat + b.vat,
total: a.total + b.total,
});
// 회차 실행 — rounds[k] = { qtyById: {id: 금회수량}, pct: 일괄 기성율(%), vatMode, vatManual, ... }
function run(base, rounds) {
const accQty = Object.fromEntries(base.leaves.map((lf) => [lf.id, 0]));
const accAmt = Object.fromEntries(base.leaves.map((lf) => [lf.id, zero()]));
// seed(기성 프로젝트 불러오기) = { kOrd, qtyById: 전회 수량, amtById: 전회 금액, jab: 전회 제잡비 {코드: 금액} }
function run(base, rounds, seed) {
const sd = seed || {};
const accQty = Object.fromEntries(
base.leaves.map((lf) => [lf.id, n0(sd.qtyById && sd.qtyById[lf.id])]),
);
const accAmt = Object.fromEntries(
base.leaves.map((lf) => {
const a = sd.amtById && sd.amtById[lf.id];
return [lf.id, a ? Object.assign(zero(), a) : amountsAt(lf, accQty[lf.id])];
}),
);
let prevJab = null;
if (sd.jab) {
const items = base.lines.map((l) => ({
code: l.code,
label: l.label,
rate: base.rates[l.code],
amount: n0(sd.jab[l.code]),
}));
const direct = base.leaves
.filter((lf) => !lf.gov)
.reduce((a, lf) => a + accAmt[lf.id].tAmt, 0);
prevJab = { direct, items, supply: n0(sd.jab.SUPPLY), vat: n0(sd.jab.BUGA), total: 0 };
prevJab.total = prevJab.supply + prevJab.vat;
}
const k0 = Math.max(1, n0(sd.kOrd) || 1);
const out = [];
rounds.forEach((rd, k) => {
const rows = base.leaves.map((lf) => {
const q =
rd.qtyById && rd.qtyById[lf.id] != null
? n0(rd.qtyById[lf.id])
const prevQ = accQty[lf.id];
const own = rd.qtyById && rd.qtyById[lf.id] != null;
// 첫 기성 등록 때 % 단위 공종은 금회 = 계약수량(STmate 0x55ccb1~0x55cd75 · P/R 코드 행도 같음)
let q = own
? n0(rd.qtyById[lf.id])
: k === 0 && !seed && lf.pct && rd.pctFirstFull !== false
? lf.qty
: r4((lf.qty * n0(rd.pct)) / 100);
const prevQ = accQty[lf.id],
nowQ = r4(prevQ + q);
q = capCur(lf.qty, prevQ, q, rd.allowOver);
const nowQ = r4(prevQ + q); // 누계 = 전회 + 금회(ST_Boq 0x4f4ca4~0x4f4ceb)
const prev = accAmt[lf.id],
acc = amountsAt(lf, nowQ),
cur = sub(acc, prev);
cur = amountsAt(lf, q),
acc = amountsAt(lf, nowQ);
accQty[lf.id] = nowQ;
accAmt[lf.id] = acc;
return {
id: lf.id,
recno: lf.recno,
code: lf.code,
name: lf.name,
spec: lf.spec,
@@ -159,27 +266,13 @@ const StmateProgress = (() => {
cur: sumOf("cur"),
acc: sumOf("acc"),
};
const jabCur = jabFor(base, totals.cur, rd);
const jabPrev = prevJab || {
direct: 0,
items: jabCur.items.map((it) => Object.assign({}, it, { amount: 0 })),
supply: 0,
vat: 0,
total: 0,
};
const jabAcc = {
direct: jabPrev.direct + jabCur.direct,
items: jabCur.items.map((it, i) =>
Object.assign({}, it, { amount: jabPrev.items[i].amount + it.amount }),
),
supply: jabPrev.supply + jabCur.supply,
vat: jabPrev.vat + jabCur.vat,
total: jabPrev.total + jabCur.total,
};
const jabCur = jabFor(base, totals.cur, rd, prevJab, totals.acc.tAmt);
const jabPrev = prevJab || zeroJab(jabCur);
const jabAcc = sumJab(jabPrev, jabCur);
prevJab = jabAcc;
const contractTotal = n0(base.kj.contractFinal);
out.push({
round: k + 1,
round: k0 + k,
rows,
totals,
jab: { prev: jabPrev, cur: jabCur, acc: jabAcc },
@@ -214,17 +307,111 @@ const StmateProgress = (() => {
);
}
// 준공조서 — 계약금액 | 준공금액(최종 누계, 35 §3 미확인 규칙 = 마지막 회차 누계 이관)
// 준공조서(0646·0647) — 서식 칸은 계약금액 | 준공금액 둘(SET_REP) · 준공금액 산식 원본 미확인
// 여기서는 마지막 회차 누계 이관(앱 가정)
function completion(base, runs) {
const last = runs[runs.length - 1];
return {
contract: n0(base.kj.contractFinal),
done: last ? last.jab.acc.total : 0,
direct: { contract: base.direct, done: last ? last.totals.acc.tAmt : 0 },
assumed: true,
};
}
return { VAT_MODES, LEVELS, contractBase, run, aggregate, completion, jabFor };
// 원본 저장 형식 — 기성 프로젝트(PCLASS 6 · K_ORD 회차) 한 회차를 BOQ 칸 · PRJLST · PRJLST.SMT 로
// BOQ: QTYn · M_/L_/S_/T_AMTn (n 1 계약 · 2 누계 · 3 전회 · 4 금회)
// PRJLST: TCOSTn·MCOSTn·LCOSTn·SCOSTn 직접비 · COSTW4 = 금회 총공사비 · COSTW3 = 누계 총공사비(0x4ca7f1~0x4ca86b)
// SMT: [2_KANJUB] 「2_C_코드」= 누계 · [4_KANJUB] 「4_C_코드」= 금회(0x4cac0c · 0x4caa48 · 섹션 이름 'KANJUB' 0x4c9b06)
function toStorage(base, round) {
const slot = { 1: "ctr", 2: "acc", 3: "prev", 4: "cur" };
const qkey = { 1: "ctrQty", 2: "accQty", 3: "prevQty", 4: "curQty" };
const boq = round.rows.map((r) => {
const u = { _recno: r.recno, id: r.id };
for (let n = 1; n <= 4; n++) {
const a = r[slot[n]];
Object.assign(u, {
[`QTY${n}`]: r[qkey[n]],
[`M_AMT${n}`]: n0(a.mAmt),
[`L_AMT${n}`]: n0(a.lAmt),
[`S_AMT${n}`]: n0(a.sAmt),
[`T_AMT${n}`]: n0(a.tAmt),
});
}
return u;
});
const prjlst = { PCLASS: 6, K_ORD: round.round };
for (let n = 1; n <= 4; n++) {
const t = round.totals[slot[n]];
Object.assign(prjlst, {
[`TCOST${n}`]: t.tAmt,
[`MCOST${n}`]: t.mAmt,
[`LCOST${n}`]: t.lAmt,
[`SCOST${n}`]: t.sAmt,
});
}
prjlst.COSTW3 = round.jab.acc.total;
prjlst.COSTW4 = round.jab.cur.total;
const sec = (n, jab) => {
const o = {};
jab.items.forEach((it) => C_CODE[it.code] && (o[`${n}_${C_CODE[it.code]}`] = it.amount));
o[`${n}_C_KONGGUP`] = jab.supply;
o[`${n}_C_BUGA`] = jab.vat;
o[`${n}_C_DOGUP`] = jab.total;
return o;
};
return {
boq,
prjlst,
smt: { "2_KANJUB": sec(2, round.jab.acc), "4_KANJUB": sec(4, round.jab.cur) },
};
}
// 기성 프로젝트 불러오기 — BOQ QTY3(전회)·QTY4(금회)·M_/L_/S_/T_AMT3 · [3_KANJUB] 또는 [2_KANJUB]−[4_KANJUB] 로 seed
function fromStorage(base, boqRows, prjlst, smt) {
const byRec = new Map((boqRows || []).map((r) => [r._recno, r]));
const qtyById = {},
amtById = {},
curById = {};
base.leaves.forEach((lf) => {
const r = byRec.get(lf.recno);
if (!r) return;
qtyById[lf.id] = n0(r.QTY3);
curById[lf.id] = n0(r.QTY4);
amtById[lf.id] = {
mAmt: n0(r.M_AMT3),
lAmt: n0(r.L_AMT3),
sAmt: n0(r.S_AMT3),
tAmt: n0(r.T_AMT3),
};
});
const kv = smt || {};
const jab = {};
const val = (n, c) => kv[`${n}_${c}`];
Object.entries(C_CODE).forEach(([code, c]) => {
const p3 = val(3, c);
jab[code] = p3 != null ? n0(p3) : n0(val(2, c)) - n0(val(4, c));
});
jab.SUPPLY = val(3, "C_KONGGUP") != null ? n0(val(3, "C_KONGGUP")) : 0;
jab.BUGA = val(3, "C_BUGA") != null ? n0(val(3, "C_BUGA")) : 0;
const kOrd = Math.max(1, n0(prjlst && prjlst.K_ORD));
return { seed: { kOrd, qtyById, amtById, jab }, round: { qtyById: curById, vatMode: 1 } };
}
return {
VAT_MODES,
LEVELS,
DEFAULT_LEVEL,
C_CODE,
contractBase,
run,
aggregate,
completion,
jabFor,
capCur,
toStorage,
fromStorage,
};
})();
if (typeof window !== "undefined") window.STMATE_PROGRESS = StmateProgress;
+142
View File
@@ -0,0 +1,142 @@
/**
* STMATE 저장 요청 묶음 (save_payload.js) — view_prj_boq.js 에서 떼어 냄 · /api/save 요청 본문
*/
(function (global) {
"use strict";
const num = (v, d = 0) => (global.STMATE_CALC || require("./calc.js")).num(v, d);
/**
* 저장 요청(/api/save) — 지금 값을 그대로 보내고 서버(stc_save.py)가 원본과 뜻으로 견줘 바뀐 칸만 씀
* 보내지 않는 것: 1차 단가로 2차 칸 덮기 · 고정 낙찰율 3차 칸 · 소계·PRJLST 직접비(서버가 다시 셈)
* 근거: STC_왕복검증.md §3(알려진 칸만 갱신) · 16번 §4(행 금액·계층 집계·TCOSTn)
*/
const COSTN_SAVE_COL =
/^(NAME|SIZE|UNIT|AMT_\d1|PG_1|UC|SBMOK|S_ADJ|ADJ_[MLS]|RXX|NAGCD|[TMLS]AMT[1-5])$/;
const isPctRow = (r) =>
String(r.UNIT1 || "")
.trim()
.endsWith("%") && num(r.T_AMT1) > 0;
const plainFields = (r) =>
Object.fromEntries(
Object.entries(r).filter(
([k, v]) =>
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
),
);
function itemQtyFields(b, raw) {
const u = {};
if (!b) return u;
if (isPctRow(raw)) {
(b.pctQtys || []).forEach((q, k) => q != null && (u[`QTY${k + 1}`] = q));
return u;
}
if (b.qty !== undefined) u.QTY1 = b.qty;
const q = Array.isArray(b.qtys) ? b.qtys : null;
for (let n = 2; n <= 4; n++) if (q && q[n - 1] !== undefined) u[`QTY${n}`] = q[n - 1];
if (q && num(q[1]) > 0 && b.changedQty !== undefined && num(b.changedQty) !== num(q[1]))
u.QTY2 = b.changedQty; // 변경 수량 칸만 고친 화면(rev-edit-qty2)
Object.entries(b.units || {}).forEach(([n, mls]) => {
if (num(n) < 2 || !Array.isArray(mls)) return;
["M", "L", "S"].forEach((c, k) => (u[`${c}AMT${n}`] = mls[k]));
});
return u;
}
function newItemRow(b) {
const q = Array.isArray(b.qtys) ? b.qtys : [b.qty, b.changedQty, 0, 0];
const u = { _new: true, BDCODE1: b.code || b.refId, NAME1: b.name, SIZE1: b.spec };
Object.assign(u, { UNIT1: b.unit, ITNUM: b.itnum || "", ENDCODE: 0 });
q.forEach((v, k) => (u[`QTY${k + 1}`] = v || 0));
return Object.assign(u, itemQtyFields(b, u));
}
function buildSavePayload(ctx) {
const d = ctx.dbfData || {};
const comp = ctx.computed || {};
const items = (ctx.state.boqItems || []).filter((b) => b._recno !== undefined && !b.isNew);
const byRec = new Map(items.map((b) => [b._recno, b]));
const fresh = (ctx.state.boqItems || []).filter((b) => b.isNew && b._recno === undefined);
const boqUpdates = [];
(d.boq_rows || []).forEach((r) => {
const isNew = r._new || r.SORTCODE === undefined;
const u = Object.assign(plainFields(r), itemQtyFields(byRec.get(r._recno), r));
boqUpdates.push(
isNew ? Object.assign(u, { _new: true }) : Object.assign(u, { _recno: r._recno }),
);
fresh
.filter((b) => b.parentRecno === r._recno)
.forEach((b) => boqUpdates.push(newItemRow(b)));
});
fresh.filter((b) => b.parentRecno == null).forEach((b) => boqUpdates.push(newItemRow(b)));
const prj = d.prjlst || {};
const prjUpd = { PNAME: ctx.state.projectMeta.pname, PNUM: ctx.state.projectMeta.pnum };
Object.entries(comp.stages || {}).forEach(([n, st]) => {
const g = st && st.kanjub && st.kanjub.grandTotal;
if (
g !== undefined &&
(n === "1" || num(prj[`COSTW${n}`]) > 0 || num(prj.K_ORD) >= num(n) - 1)
)
prjUpd[`COSTW${n}`] = g;
});
if (!comp.stages && comp.kanjubComputed) prjUpd.COSTW1 = comp.kanjubComputed.GRAND_TOTAL;
// 기성 공사(PCLASS 6) — 칸 2 누계 · 3 전회 · 4 금회는 기성 화면이 정함(REA 33 · ST_KanJub 0x4ca825)
const isProgress = num(prj.PCLASS) === 6;
if (isProgress)
["COSTW2", "COSTW3", "COSTW4"].forEach((k) => {
if (prj[k] !== undefined) prjUpd[k] = prj[k];
else delete prjUpd[k];
});
const smt = d.prjlst_smt || {};
const rts = (ctx.state.kanjubConfig && ctx.state.kanjubConfig.rates) || {};
const smtCand = {
"1_R_KANNO": rts.KANNO,
"1_R_SANBO": rts.SANBO,
"1_R_KOBO": rts.KOBO,
"1_R_KUNBO": rts.KUNBO,
"1_R_SLVBO": rts.SLVBO,
"1_R_YUNBO": rts.YUNBO,
"1_R_ANJUN": rts.ANJUN,
"1_R_T_KITA": rts.KITA,
"1_R_HWAN": rts.HWAN,
"1_R_EQIPSU": rts.EQIP,
"1_R_ILBAN": rts.ILBAN,
"1_R_IYUN": rts.IYUN,
"1_C_ANJUN_G": ctx.state.kanjubConfig && ctx.state.kanjubConfig.anjunBaseAdd,
};
const smtUpd = Object.fromEntries(
Object.entries(smtCand).filter(([k, v]) => k in smt && v !== undefined && v !== null),
);
// 기성 칸 제잡비 금액 [2·3·4_KANJUB] n_C_* — 기성 화면 [기성 칸 반영]이 쓴 값 그대로(서버가 바뀐 줄만 씀)
if (isProgress)
Object.entries(smt)
.filter(([k]) => /^[234]_C_/.test(k))
.forEach(([k, v]) => (smtUpd[k] = v));
return {
source_path: ctx.currentSourcePath,
target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28),
boq_table: d.boq_table_name || "",
costn_updates: (d.costn_rows || []).map((r) =>
Object.fromEntries(
Object.entries(r).filter(([k]) => k === "CODE" || COSTN_SAVE_COL.test(k)),
),
),
costn_memo_updates: ctx.uiState.editedSmtMap || {},
boq_updates: boqUpdates,
boq_deleted: d.boq_deleted || ctx.uiState.boqDeleted || [],
prjlst_updates: prjUpd,
smt_updates: smtUpd,
// 서버(stc_save_extra)가 불러온 값과 견줘 바뀐 것만 씀 — 제잡비 설정 · 구성표 · 수량코드
kanjub_stages: ctx.state.kanjubStages || {},
bdqty_rows: d.bdqty_rows || null,
qcode_rows: d.qcode_rows || null,
};
}
const api = { buildSavePayload };
global.STMATE_SAVE_PAYLOAD = api;
if (typeof module !== "undefined" && module.exports) module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);
+90 -34
View File
@@ -3,24 +3,29 @@
* 기성·준공 탭 (scr_stage_progress.js) — wM_KanJub_K 재현
* 회차 전회·금회·누계 금액·비율 · 공종별 기성수량·잔량 · 기성 제잡비(금회직접 × 계약잡비율) · 부가세 4택
* · 공급가액/총공사비 절사 · 이윤 직접입력 · 집계수준 필터 · 기성 서류(0632~0638) · 준공조서(0646·0647)
* 근거: 지식DB STmate/old/20_분석 27 §3·§5 · 35 §3 · 11 §10 — 기성 표본 0건(27 §9) · 계약 기준 = 계약 탭 결과
* 근거: 지식DB STmate/REA 분석/33_기성준공단계_작업자료_20261114 · old/20_분석 27 §3·§5 — 계약 기준 = 계약 탭 결과
* 기성 프로젝트(PRJLST.PCLASS 6) = BOQ 칸 3(전회)·4(금회) · K_ORD 회차를 읽어 시작 · [기성 칸 반영]으로 작업본에 씀
* 계산: progress_calc.js
*/
(function (global) {
"use strict";
const S = (global.STMATE_STAGE = global.STMATE_STAGE || { tabs: [] });
const n0 = (v) => Number(v) || 0;
const JEOL = [
[0, "절사 없음"],
[-1, "10원 미만"],
[-2, "100원 미만"],
[-3, "1,000원 미만"],
[-4, "1만원 미만"],
[-5, "10만원 미만"],
[-6, "100만원 미만"],
[-7, "1천만원 미만"],
[-8, "1억원 미만"],
];
// 절사 9택(cmBx_JeolSa · cmBx_Tot_Jeol) — 값 1..8 = 10^값 원 미만 버림 · 값 0 의 뜻은 콤보마다 다름
const CUTS = [
"10원 미만",
"100원 미만",
"1,000원 미만",
"10,000원 미만",
"100,000원 미만",
"1,000,000원 미만",
"10,000,000원 미만",
"100,000,000원 미만",
].map((l, i) => [i + 1, l]);
const JEOL_S = [[0, "총공사비에서 조정"]].concat(CUTS);
const JEOL_T = [[0, "이윤금액 직접입력"]].concat(CUTS);
const isProgPrj = (ctx) =>
String((ctx.dbfData && ctx.dbfData.prjlst && ctx.dbfData.prjlst.PCLASS) || "").trim() === "6";
function pst(ctx) {
const u = S.ui(ctx);
@@ -33,33 +38,72 @@
const c = S.contractCompute(ctx);
const base = G.contractBase(c.conv, c.kj);
const p = pst(ctx);
const runs = G.run(base, p.rounds);
if (isProgPrj(ctx) && !p.seeded) {
// 기성 프로젝트 — 전회 = BOQ 칸 3 · 금회 = 칸 4 · 회차 = K_ORD(STmate 0x55d0b3~0x55d1d8)
const d = ctx.dbfData;
const fs = G.fromStorage(base, d.boq_rows, d.prjlst, d.prjlst_smt);
Object.assign(p, { seed: fs.seed, rounds: [fs.round], cur: 0, seeded: true });
}
const runs = G.run(base, p.rounds, p.seed);
return { c, base, p, runs, run: runs[Math.min(p.cur, runs.length - 1)] };
}
// [기성 칸 반영] — 고른 회차를 작업본(dbfData)의 BOQ 칸 2·3·4 와 내역 항목 수량에 씀 · 파일 쓰기는 [저장]
function applyStorage(ctx) {
const { base, run } = compute(ctx);
const st = global.STMATE_PROGRESS.toStorage(base, run);
const d = ctx.dbfData;
const byRec = new Map((d.boq_rows || []).map((r) => [r._recno, r]));
const items = new Map((ctx.state.boqItems || []).map((b) => [b.id, b]));
const FLDS = ["QTY", "M_AMT", "L_AMT", "S_AMT", "T_AMT"];
st.boq.forEach((u) => {
const r = byRec.get(u._recno);
if (r) for (let n = 2; n <= 4; n++) FLDS.forEach((f) => (r[`${f}${n}`] = u[`${f}${n}`]));
const b = items.get(u.id);
if (!b) return;
if (!Array.isArray(b.qtys)) b.qtys = [n0(b.qty), 0, 0, 0];
[2, 3, 4].forEach((n) => (b.qtys[n - 1] = u[`QTY${n}`]));
});
const pr = st.prjlst;
d.prjlst = Object.assign(d.prjlst || {}, {
COSTW3: pr.COSTW3,
COSTW4: pr.COSTW4,
K_ORD: pr.K_ORD,
});
d.prjlst_smt = d.prjlst_smt || {};
Object.values(st.smt).forEach((sec) => Object.assign(d.prjlst_smt, sec));
return st;
}
function builders(ctx) {
const info = (r) => [`공사명 : ${S.pname(ctx)}`, `제 ${r.round} 회 기성`];
const G = global.STMATE_PROGRESS;
return {
total: () => {
const { run } = compute(ctx);
const j = run.jab;
const rows = [
["직접공사비", run.totals.ctr.tAmt, j.prev.direct, j.cur.direct, j.acc.direct],
];
j.cur.items.forEach((it, i) =>
rows.push([it.label, "", j.prev.items[i].amount, it.amount, j.acc.items[i].amount]),
);
rows.push(["공급가액", "", j.prev.supply, j.cur.supply, j.acc.supply]);
rows.push(["부가가치세", "", j.prev.vat, j.cur.vat, j.acc.vat]);
rows.push(["기성금액", run.contractTotal, j.prev.total, j.cur.total, j.acc.total]);
rows.push(["비율(%)", 100, run.rate.prev, run.rate.cur, run.rate.acc]);
// 0632 = ST_P_ConEst TPM_JAB_K — 계약 금액 | 전회·금회·누계 금액·비율(칸 3·4·2 ÷ 칸 1 × 100) | 비고
// 머리말 「3. 기성부분금액 : 금 … 원정 (₩…)」 = COSTW4(금회 총공사비) 0x48037b
const { base, run } = compute(ctx);
const pc = (x, c) => (c ? Math.round((x / c) * 1e4) / 1e2 : 0);
const rows = G.aggregate(base, run, 1).map((nd) => {
const s = nd.st;
return [nd.itnum || "", nd.name, nd.spec || "", s.ctr.tAmt]
.concat([s.prev.tAmt, pc(s.prev.tAmt, s.ctr.tAmt), s.cur.tAmt])
.concat([pc(s.cur.tAmt, s.ctr.tAmt), s.acc.tAmt, pc(s.acc.tAmt, s.ctr.tAmt), ""]);
});
const head1 = ["공종", "명칭", "규격"].map((t) => ({ t, r: 2 }));
return [
{
name: "기성부분총괄내역서",
title: "기 성 부 분 총 괄 내 역 서",
info: info(run),
head: [["구분", "계약", "전회", "금회", "누계"]],
info: info(run).concat([`3. 기성부분금액 : ${S.fmt(run.jab.cur.total)}원`]),
head: [
head1.concat(
["계약"],
["전회", "금회", "누계"].map((t) => ({ t, c: 2 })),
[{ t: "비고", r: 2 }],
),
["금액", "금액", "비율", "금액", "비율", "금액", "비율"],
],
rows,
},
];
@@ -87,7 +131,7 @@
];
if (sajung) vals.push(s.acc.tAmt);
if (rate) {
const pc = (x) => (s.ctr.tAmt ? Math.round((x / s.ctr.tAmt) * 1e5) / 1e3 : 0);
const pc = (x) => (s.ctr.tAmt ? Math.round((x / s.ctr.tAmt) * 1e4) / 1e2 : 0);
vals.splice(6, 0, pc(s.prev.tAmt));
vals.splice(8, 0, pc(s.cur.tAmt));
vals.push(pc(s.acc.tAmt));
@@ -196,9 +240,10 @@
`<input id="${id}" value="${v == null ? "" : v}" style="width:${w}px;text-align:right;"/>`;
const sel = (id, list, v) =>
`<select id="${id}" class="win-btn">${list.map(([k, l]) => `<option value="${k}" ${String(k) === String(v) ? "selected" : ""}>${l}</option>`).join("")}</select>`;
const k0 = (p.seed && p.seed.kOrd) || 1;
const top = `<div class="win-subbar"><span>회차</span>${sel(
"pg-cur",
p.rounds.map((_, i) => [i, `제 ${i + 1} 회`]),
p.rounds.map((_, i) => [i, `제 ${k0 + i} 회`]),
p.cur,
)}
<button class="win-btn" id="pg-add">회차 추가</button><button class="win-btn" id="pg-del">마지막 회차 삭제</button>
@@ -208,11 +253,12 @@
G.VAT_MODES.map((l, i) => [i, l]),
rd.vatMode == null ? 1 : rd.vatMode,
)}${inp("pg-vatm", rd.vatManual, 90)}
<span>공급가액 절사</span>${sel("pg-sj", JEOL, rd.supplyJeol || 0)}<span>총공사비 절사</span>${sel("pg-tj", JEOL, rd.totalJeol || 0)}
<span>공급가액 절사</span>${sel("pg-sj", JEOL_S, Math.abs(rd.supplyJeol || 0))}<span>총공사비 절사</span>${sel("pg-tj", JEOL_T, Math.abs(rd.totalJeol || 0))}
<span>이윤 직접입력</span>${inp("pg-iyun", rd.iyunManual, 90)}
<span>집계수준</span>${sel("pg-level", G.LEVELS, p.level)}</div>`;
<span>집계수준</span>${sel("pg-level", G.LEVELS, p.level)}
${isProgPrj(ctx) ? `<button class="win-btn" id="pg-store">기성 칸 반영</button>` : ""}</div>`;
const nodes = G.aggregate(base, run, n0(p.level));
const pc = (x, c) => (c ? Math.round((x / c) * 1e5) / 1e3 : 0);
const pc = (x, c) => (c ? Math.round((x / c) * 1e4) / 1e2 : 0);
const rows = nodes.map((nd) => {
const s = nd.st,
r = nd.row;
@@ -331,8 +377,18 @@
api.onChange("pg-pct", (e) => ((rd().pct = api.num(e.target.value, 0)), re()));
api.onChange("pg-vat", (e) => ((rd().vatMode = Number(e.target.value)), re()));
api.onChange("pg-vatm", (e) => ((rd().vatManual = val(e.target.value)), re()));
api.onChange("pg-sj", (e) => ((rd().supplyJeol = Number(e.target.value)), re()));
api.onChange("pg-tj", (e) => ((rd().totalJeol = Number(e.target.value)), re()));
// 두 절사는 하나만(cmBx_ValueChange 0x4cef28~0x4cef84)
api.onChange("pg-sj", (e) => {
rd().supplyJeol = Number(e.target.value);
if (rd().supplyJeol) rd().totalJeol = 0;
re();
});
api.onChange("pg-tj", (e) => {
rd().totalJeol = Number(e.target.value);
if (rd().totalJeol) rd().supplyJeol = 0;
re();
});
api.onClick("pg-store", () => (applyStorage(ctx), re()));
api.onChange("pg-iyun", (e) => ((rd().iyunManual = val(e.target.value)), re()));
api.onChange("pg-level", (e) => ((p.level = Number(e.target.value)), re()));
api.bindEach(".pg-q", "onchange", (el) => {
+6 -117
View File
@@ -567,123 +567,12 @@
});
}
/**
* 저장 요청(/api/save) — 지금 값을 그대로 보내고 서버(stc_save.py)가 원본과 뜻으로 견줘 바뀐 칸만 씀
* 보내지 않는 것: 1차 단가로 2차 칸 덮기 · 고정 낙찰율 3차 칸 · 소계·PRJLST 직접비(서버가 다시 셈)
* 근거: STC_왕복검증.md §3(알려진 칸만 갱신) · 16번 §4(행 금액·계층 집계·TCOSTn)
*/
const COSTN_SAVE_COL =
/^(NAME|SIZE|UNIT|AMT_\d1|PG_1|UC|SBMOK|S_ADJ|ADJ_[MLS]|RXX|NAGCD|[TMLS]AMT[1-5])$/;
const isPctRow = (r) =>
String(r.UNIT1 || "")
.trim()
.endsWith("%") && num(r.T_AMT1) > 0;
const plainFields = (r) =>
Object.fromEntries(
Object.entries(r).filter(
([k, v]) =>
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
),
);
function itemQtyFields(b, raw) {
const u = {};
if (!b) return u;
if (isPctRow(raw)) {
(b.pctQtys || []).forEach((q, k) => q != null && (u[`QTY${k + 1}`] = q));
return u;
}
if (b.qty !== undefined) u.QTY1 = b.qty;
const q = Array.isArray(b.qtys) ? b.qtys : null;
for (let n = 2; n <= 4; n++) if (q && q[n - 1] !== undefined) u[`QTY${n}`] = q[n - 1];
if (q && num(q[1]) > 0 && b.changedQty !== undefined && num(b.changedQty) !== num(q[1]))
u.QTY2 = b.changedQty; // 변경 수량 칸만 고친 화면(rev-edit-qty2)
Object.entries(b.units || {}).forEach(([n, mls]) => {
if (num(n) < 2 || !Array.isArray(mls)) return;
["M", "L", "S"].forEach((c, k) => (u[`${c}AMT${n}`] = mls[k]));
});
return u;
}
function newItemRow(b) {
const q = Array.isArray(b.qtys) ? b.qtys : [b.qty, b.changedQty, 0, 0];
const u = { _new: true, BDCODE1: b.code || b.refId, NAME1: b.name, SIZE1: b.spec };
Object.assign(u, { UNIT1: b.unit, ITNUM: b.itnum || "", ENDCODE: 0 });
q.forEach((v, k) => (u[`QTY${k + 1}`] = v || 0));
return Object.assign(u, itemQtyFields(b, u));
}
function buildSavePayload(ctx) {
const d = ctx.dbfData || {};
const comp = ctx.computed || {};
const items = (ctx.state.boqItems || []).filter((b) => b._recno !== undefined && !b.isNew);
const byRec = new Map(items.map((b) => [b._recno, b]));
const fresh = (ctx.state.boqItems || []).filter((b) => b.isNew && b._recno === undefined);
const boqUpdates = [];
(d.boq_rows || []).forEach((r) => {
const isNew = r._new || r.SORTCODE === undefined;
const u = Object.assign(plainFields(r), itemQtyFields(byRec.get(r._recno), r));
boqUpdates.push(
isNew ? Object.assign(u, { _new: true }) : Object.assign(u, { _recno: r._recno }),
);
fresh
.filter((b) => b.parentRecno === r._recno)
.forEach((b) => boqUpdates.push(newItemRow(b)));
});
fresh.filter((b) => b.parentRecno == null).forEach((b) => boqUpdates.push(newItemRow(b)));
const prj = d.prjlst || {};
const prjUpd = { PNAME: ctx.state.projectMeta.pname, PNUM: ctx.state.projectMeta.pnum };
Object.entries(comp.stages || {}).forEach(([n, st]) => {
const g = st && st.kanjub && st.kanjub.grandTotal;
if (
g !== undefined &&
(n === "1" || num(prj[`COSTW${n}`]) > 0 || num(prj.K_ORD) >= num(n) - 1)
)
prjUpd[`COSTW${n}`] = g;
});
if (!comp.stages && comp.kanjubComputed) prjUpd.COSTW1 = comp.kanjubComputed.GRAND_TOTAL;
const smt = d.prjlst_smt || {};
const rts = (ctx.state.kanjubConfig && ctx.state.kanjubConfig.rates) || {};
const smtCand = {
"1_R_KANNO": rts.KANNO,
"1_R_SANBO": rts.SANBO,
"1_R_KOBO": rts.KOBO,
"1_R_KUNBO": rts.KUNBO,
"1_R_SLVBO": rts.SLVBO,
"1_R_YUNBO": rts.YUNBO,
"1_R_ANJUN": rts.ANJUN,
"1_R_T_KITA": rts.KITA,
"1_R_HWAN": rts.HWAN,
"1_R_EQIPSU": rts.EQIP,
"1_R_ILBAN": rts.ILBAN,
"1_R_IYUN": rts.IYUN,
"1_C_ANJUN_G": ctx.state.kanjubConfig && ctx.state.kanjubConfig.anjunBaseAdd,
};
const smtUpd = Object.fromEntries(
Object.entries(smtCand).filter(([k, v]) => k in smt && v !== undefined && v !== null),
);
return {
source_path: ctx.currentSourcePath,
target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28),
boq_table: d.boq_table_name || "",
costn_updates: (d.costn_rows || []).map((r) =>
Object.fromEntries(
Object.entries(r).filter(([k]) => k === "CODE" || COSTN_SAVE_COL.test(k)),
),
),
costn_memo_updates: ctx.uiState.editedSmtMap || {},
boq_updates: boqUpdates,
boq_deleted: d.boq_deleted || ctx.uiState.boqDeleted || [],
prjlst_updates: prjUpd,
smt_updates: smtUpd,
// 서버(stc_save_extra)가 불러온 값과 견줘 바뀐 것만 씀 — 제잡비 설정 · 구성표 · 수량코드
kanjub_stages: ctx.state.kanjubStages || {},
bdqty_rows: d.bdqty_rows || null,
qcode_rows: d.qcode_rows || null,
};
}
// 저장 요청 묶음 — save_payload.js
const buildSavePayload = (ctx) =>
(
global.STMATE_SAVE_PAYLOAD ||
(typeof require !== "undefined" ? require("./save_payload.js") : null)
).buildSavePayload(ctx);
global.STMATE_VIEW_PRJ_BOQ = {
evaluateFullBoqRows,