Files
Aislo/tools/stmate-test/progress_calc.js
T
eomsangdonandClaude Opus 5.5 edd5d020f6 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>
2026-11-14 09:16:17 +09:00

419 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* -*- coding: utf-8 -*- */
/**
* 기성 계산 (progress_calc.js) — 회차별 전회·금회·누계 금액과 비율 · 공종별 기성수량·잔량 · 기성 제잡비 · 부가세 4택
* 근거: 지식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");
const Change =
(typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js");
const { roundDown } = Calc;
const n0 = (v) => Number(v) || 0;
const r4 = (v) => Math.round(n0(v) * 1e4) / 1e4;
const KEYS = ["mAmt", "lAmt", "sAmt", "tAmt"];
// 부가세 4택(cmBx_Buga · DFM TWM_KANJUB_K) — 0 직접입력 · 1 공급가액 10% · 2 재료비 10% · 3 재료비+산출경비 10%
const VAT_MODES = ["직접입력", "공급가액의 10%", "재료비의 10%", "재료비+산출경비의 10%"];
// 집계수준(rd_Filter 0x4cadbc) — 거르기 (BDCODE1="T") .and. (BDCODE1>"T"+순번) → 머리 단 ≥ 순번+1 · 처음 값 순번 2
const LEVELS = [
[0, "공종 전부"],
[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) {
const stage = conv.stage;
const leaves = conv.nodes
.filter((nd) => nd.kind === "leaf" && !nd.missing)
.map((nd) => {
const s = nd.st[stage];
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) }))
.concat([
{ code: "ILBAN", label: "일반관리비", amount: n0(kj.ilbanAmt) },
{ code: "IYUN", label: "이윤", amount: n0(kj.iyunFinal) },
]);
// 계약잡비율(%) = 계약 항목 ÷ 계약 직접공사비 × 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) {
const m = roundDown(lf.u.m * qty, 0),
l = roundDown(lf.u.l * qty, 0),
s = roundDown(lf.u.s * qty, 0);
return { mAmt: m, lAmt: l, sAmt: s, tAmt: m + l + s };
}
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]));
// 비율 = 금액 ÷ 계약 × 100 — 화면 서식 '0.00%'(0x4cdc7a · 0x4cea20) 두 자리
const pct = (a, b) => (b ? Math.round((a / b) * 1e4) / 1e2 : 0);
// 금회수량 상한(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, ... }
// 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 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);
q = capCur(lf.qty, prevQ, q, rd.allowOver);
const nowQ = r4(prevQ + q); // 누계 = 전회 + 금회(ST_Boq 0x4f4ca4~0x4f4ceb)
const prev = accAmt[lf.id],
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,
unit: lf.unit,
gov: lf.gov,
ctrQty: lf.qty,
prevQty: prevQ,
curQty: q,
accQty: nowQ,
remainQty: r4(lf.qty - nowQ),
qtyRate: pct(nowQ, lf.qty),
ctr: lf.amt,
prev,
cur,
acc,
prevRate: pct(prev.tAmt, lf.amt.tAmt),
curRate: pct(cur.tAmt, lf.amt.tAmt),
accRate: pct(acc.tAmt, lf.amt.tAmt),
};
});
const sumOf = (key) => rows.filter((r) => !r.gov).reduce((a, r) => add(a, r[key]), zero());
const totals = {
ctr: sumOf("ctr"),
prev: sumOf("prev"),
cur: sumOf("cur"),
acc: sumOf("acc"),
};
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: k0 + k,
rows,
totals,
jab: { prev: jabPrev, cur: jabCur, acc: jabAcc },
contractTotal,
rate: {
prev: pct(jabPrev.total, contractTotal),
cur: pct(jabCur.total, contractTotal),
acc: pct(jabAcc.total, contractTotal),
},
});
});
return out;
}
// 집계수준 필터 — 머리 합계(전회·금회·누계·계약)를 얹고 고른 단 이상 머리만
function aggregate(base, round, minLevel = 0) {
const byId = new Map(round.rows.map((r) => [r.id, r]));
const nodes = base.nodes.map((nd) => {
const o = Object.assign({}, nd, { st: {} });
if (nd.kind === "leaf") {
const r = byId.get(nd.id);
for (const k of ["ctr", "prev", "cur", "acc"]) o.st[k] = r ? r[k] : zero();
o.row = r;
}
return o;
});
Change.sumHeads(nodes, ["ctr", "prev", "cur", "acc"]);
return nodes.filter((nd) =>
minLevel <= 0
? nd.kind === "head" || nd.kind === "leaf"
: nd.kind === "head" && nd.level >= minLevel,
);
}
// 준공조서(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,
};
}
// 원본 저장 형식 — 기성 프로젝트(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;
if (typeof module !== "undefined" && module.exports) module.exports = StmateProgress;