Files
Aislo/tools/stmate-test/change_calc.js
T

562 lines
21 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 설계변경 차수 대비 계산 (change_calc.js) — 내역 머리·계 구조 위에 차수별 수량·단가·금액을 얹어 당초/변경/증감을 냄
* 근거: 지식DB STmate/old/20_분석 08 §2(변경설계 데이터 모델 · 삭제 0 유지 · 신규 안정 ID) · 16 §4.5~4.6(차수 칸)
* · 19 §6.5(변경 직접비 당초·변경·증감) · 35 §4(당초액 유지 4항목) · 24 §7(차수 4벌)
* 정답 대조: resources/tester/test_stmate_stage_change.py
* (영덕 변경 STC BOQ11 n칸 저장 금액 · 같은 폴더 STMATE 엑셀 설계변경증감대비표·금액대비표·설계변경내역서)
*/
const StmateChange = (() => {
const STRUCT = /^(T\d|E\d|C\d|:)$/;
const CARRY_DEFAULT = ["KUNBO", "SLVBO", "YUNBO", "TBUGUM"];
const n0 = (v) => Number(v) || 0;
const clone = (o) => JSON.parse(JSON.stringify(o));
// 차수별 계산 내역 행 — 1·2 차수는 calculateAll 기본 결과, 3·4 는 stages 에 있을 때만
function stageRows(computed, n) {
const boq =
(computed.stages && computed.stages[n] && computed.stages[n].boq) ||
(n === 1 ? computed.boqOrig : n === 2 ? computed.boqRev : null);
return boq ? boq.rows || [] : [];
}
function emptySum() {
return { mAmt: 0, lAmt: 0, sAmt: 0, tAmt: 0 };
}
// 내역 머리(T)·계(E/C)·빈 줄·공종 행을 차례대로 — 머리마다 짝이 되는 닫는 행과 하위 공종 합계
function buildTree(boqRows, state, computed, stageList) {
const stagesN = stageList && stageList.length ? stageList : [1, 2];
const items = (state.boqItems || []).slice();
const byRecno = new Map(items.filter((b) => b._recno != null).map((b) => [b._recno, b]));
const calcBy = {};
for (const n of stagesN) calcBy[n] = new Map(stageRows(computed, n).map((r) => [r.id, r]));
const nodes = [];
const pushLeaf = (b, dbf) => {
const st = {};
for (const n of stagesN) {
const r = calcBy[n].get(b.id);
// 수량 0 칸은 단가도 0(STMATE 저장 · 설계변경내역서 표기 — 삭제·신규 공종)
const z = r && n0(r.qty) === 0 ? 0 : 1;
st[n] = r
? {
qty: n0(r.qty),
mUnit: z * n0(r.mUnit),
lUnit: z * n0(r.lUnit),
sUnit: z * n0(r.sUnit),
tUnit: z * n0(r.tUnit),
mAmt: n0(r.M),
lAmt: n0(r.L),
sAmt: n0(r.S),
tAmt: n0(r.T),
}
: Object.assign({ qty: 0, mUnit: 0, lUnit: 0, sUnit: 0, tUnit: 0 }, emptySum());
}
nodes.push({
kind: "leaf",
id: b.id,
recno: b._recno,
code: b.code,
itnum: (dbf && dbf.ITNUM) || b.itnum || "",
name: b.name,
spec: b.spec || "",
unit: b.unit || "",
gov: !!b.rowType && b.rowType !== "normal",
rowType: b.rowType || "normal",
isNew: !!b.isNew,
st,
});
if (String(b.unit || "").endsWith("%")) {
const nd = nodes[nodes.length - 1];
nd.pct = true;
nd.pctQty = Object.fromEntries(
stagesN.map((n) => {
const own = Array.isArray(b.pctQtys) ? b.pctQtys[n - 1] : null;
return [n, own != null ? n0(own) : n0(dbf && dbf[`QTY${n}`])];
}),
);
}
};
// 새 공종 = 소속 머리의 닫는 행 앞에 끼움(없으면 맨 끝 총합계 앞)
const added = items.filter((b) => b._recno == null);
const addedBy = new Map();
for (const b of added) {
const k = b.parentRecno != null ? b.parentRecno : "__END__";
if (!addedBy.has(k)) addedBy.set(k, []);
addedBy.get(k).push(b);
}
const sortMap = new Map(boqRows.map((r) => [String(r.SORTCODE), r]));
const closerOf = new Map();
for (const r of boqRows) {
const bd = String(r.BDCODE1 || "").trim();
if (/^[EC]\d$/.test(bd) && sortMap.has(String(r.ENDCODE)))
closerOf.set(r._recno, sortMap.get(String(r.ENDCODE))._recno);
}
let lastCloser = null;
boqRows.forEach((r) => {
const bd = String(r.BDCODE1 || "").trim();
if (/^[EC]\d$/.test(bd)) lastCloser = r._recno;
});
boqRows.forEach((r) => {
const bd = String(r.BDCODE1 || "").trim();
if (/^[EC]\d$/.test(bd)) {
const head = closerOf.get(r._recno);
const extra = (addedBy.get(head) || []).concat(
r._recno === lastCloser ? addedBy.get("__END__") || [] : [],
);
extra.forEach((b) => pushLeaf(b, null));
}
if (!bd || bd === ":") {
nodes.push({ kind: "blank", recno: r._recno, itnum: r.ITNUM || "", name: r.NAME1 || "" });
} else if (STRUCT.test(bd)) {
const isHead = bd[0] === "T";
nodes.push({
kind: isHead ? "head" : "close",
code: bd,
level: Number(bd.slice(1)),
zone: bd[0] === "C",
recno: r._recno,
sortcode: String(r.SORTCODE),
endcode: String(r.ENDCODE),
itnum: r.ITNUM || "",
name: r.NAME1 || "",
spec: r.SIZE1 || "",
st: {},
});
} else {
const b = byRecno.get(r._recno);
if (b) pushLeaf(b, r);
else
nodes.push({
kind: "leaf",
missing: true,
recno: r._recno,
code: bd,
itnum: r.ITNUM || "",
name: r.NAME1 || "",
spec: r.SIZE1 || "",
unit: r.UNIT1 || "",
st: Object.fromEntries(stagesN.map((n) => [n, Object.assign({ qty: 0 }, emptySum())])),
});
}
});
if (!boqRows.length) added.forEach((b) => pushLeaf(b, null));
applyPct(nodes, stagesN);
sumHeads(nodes, stagesN);
return { nodes, stages: stagesN };
}
// % 단위 공종(조달수수료 등) — 같은 머리 안 앞선 공종 금액 합 × 수량% · 단가 칸 = 밑수(STMATE 저장 방식)
function applyPct(nodes, stagesN) {
let group = [];
for (const nd of nodes) {
if (nd.kind === "head" || nd.kind === "close") group = [];
if (nd.kind !== "leaf") continue;
if (!nd.pct) {
group.push(nd);
continue;
}
for (const n of stagesN) {
const base = { m: 0, l: 0, s: 0 };
for (const g of group) for (const c of ["m", "l", "s"]) base[c] += n0(g.st[n][c + "Amt"]);
const q = n0(nd.pctQty[n]);
const a = {};
for (const c of ["m", "l", "s"]) a[c] = Math.trunc((base[c] * q) / 100 + 1e-9);
nd.st[n] = {
qty: q,
mUnit: base.m,
lUnit: base.l,
sUnit: base.s,
tUnit: base.m + base.l + base.s,
mAmt: a.m,
lAmt: a.l,
sAmt: a.s,
tAmt: a.m + a.l + a.s,
};
}
}
}
// 머리 합계 — 닫는 행이 C(제외금액 구역)이면 관급 공종까지, 아니면 일반 공종만
function sumHeads(nodes, stagesN) {
const idxBySort = new Map();
nodes.forEach((nd, i) => nd.kind === "head" && idxBySort.set(nd.sortcode, i));
const spans = [];
nodes.forEach((nd, j) => {
if (nd.kind !== "close") return;
const i = idxBySort.get(nd.endcode);
if (i == null) return;
nodes[i].closeIdx = j;
nodes[i].zone = nd.zone;
nd.headIdx = i;
spans.push([i, j, nd.zone]);
});
const inZone = new Array(nodes.length).fill(false);
spans.forEach(([i, j, z]) => z && inZone.fill(true, i, j + 1));
nodes.forEach((nd, i) => {
if (nd.kind !== "head") return;
const end = nd.closeIdx != null ? nd.closeIdx : nodes.length;
const s = Object.fromEntries(stagesN.map((n) => [n, emptySum()]));
for (let k = i + 1; k < end; k++) {
const lf = nodes[k];
if (lf.kind !== "leaf" || (lf.gov && !inZone[i])) continue;
for (const n of stagesN)
for (const key of ["mAmt", "lAmt", "sAmt", "tAmt"]) s[n][key] += n0(lf.st[n][key]);
}
nd.st = s;
if (nd.closeIdx != null) nodes[nd.closeIdx].st = clone(s);
});
}
// 설계변경증감대비표 — 닫는 행 빼고 · 머리는 금액만 · 공종은 수량·금액 당초/변경/증감
function diffTable(tree, a = 1, b = 2) {
return tree.nodes
.filter((nd) => nd.kind !== "close")
.map((nd) => {
const sa = (nd.st && nd.st[a]) || emptySum(),
sb = (nd.st && nd.st[b]) || emptySum();
const leaf = nd.kind === "leaf";
const qa = leaf ? n0(sa.qty) : 0,
qb = leaf ? n0(sb.qty) : 0;
return {
kind: nd.kind,
id: nd.id,
itnum: nd.kind === "head" ? nd.itnum : "",
name: nd.name,
spec: nd.spec || "",
unit: leaf ? nd.unit : "",
code: leaf ? nd.code : "",
qa,
qb,
qd: Math.round((qb - qa) * 1e6) / 1e6,
aa: n0(sa.tAmt),
ab: n0(sb.tAmt),
ad: n0(sb.tAmt) - n0(sa.tAmt),
state: !leaf ? "" : qa === 0 && qb !== 0 ? "신규" : qa !== 0 && qb === 0 ? "삭제" : "",
};
});
}
// 설계변경내역서 — 공종마다 당초 줄·변경 줄(수량·단가·금액 4벌)
function changeStatement(tree, a = 1, b = 2) {
const out = [];
for (const nd of tree.nodes) {
if (nd.kind === "close" || nd.kind === "blank") continue;
for (const n of [a, b]) {
const s = (nd.st && nd.st[n]) || emptySum();
out.push({
kind: nd.kind,
stage: n,
first: n === a,
itnum: n === a && nd.kind === "head" ? nd.itnum : "",
name: n === a ? nd.name : "",
spec: n === a ? nd.spec || "" : "",
unit: n === a && nd.kind === "leaf" ? nd.unit : "",
qty: nd.kind === "leaf" ? n0(s.qty) : null,
tUnit: nd.kind === "leaf" ? n0(s.tUnit) : 0,
lUnit: nd.kind === "leaf" ? n0(s.lUnit) : 0,
mUnit: nd.kind === "leaf" ? n0(s.mUnit) : 0,
sUnit: nd.kind === "leaf" ? n0(s.sUnit) : 0,
tAmt: n0(s.tAmt),
lAmt: n0(s.lAmt),
mAmt: n0(s.mAmt),
sAmt: n0(s.sAmt),
});
}
}
return out;
}
// 원가계산서 항목 순서(금액대비표) — 0원 항목은 빼고 차례 번호
function kanjubRows(kjA, kjB) {
const codes = kjA.items.map((it) => it.code);
return codes
.map((c) => {
const ia = kjA.itemMap[c],
ib = kjB.itemMap[c] || { amount: 0 };
return { code: c, label: ia.label, a: n0(ia.amount), b: n0(ib.amount) };
})
.filter((r) => r.a !== 0 || r.b !== 0)
.map((r) => Object.assign(r, { d: r.b - r.a }));
}
// 설계변경금액대비표 — 머리(최상위 아래 2단까지) + 순공사비 · 간접비 · 일반관리비 · 이윤 · 부가세 · 도급
function amountTable(tree, kjA, kjB, a = 1, b = 2, maxDepth = 2) {
const work = workHeads(tree, maxDepth).map((h) => ({
kind: "head",
itnum: h.itnum,
name: h.name,
spec: h.spec,
a: n0(h.st[a] && h.st[a].tAmt),
b: n0(h.st[b] && h.st[b].tAmt),
}));
const line = (label, va, vb, kind = "sum") => ({ kind, name: label, a: va, b: vb });
const ind = kanjubRows(kjA, kjB).map((r, i) =>
line(`${String(i + 1).padStart(2)}. ${r.label}`, r.a, r.b, "item"),
);
const nextNo = ind.length + 1;
const sub1 = (k) => k.dT + k.kanno + k.indirectExpenseSum;
const rows = work.concat([
line("가. 순공사비계", kjA.dT, kjB.dT),
...ind,
line("나. 소계", sub1(kjA), sub1(kjB)),
line(`${nextNo}. 일반관리비`, kjA.ilbanAmt, kjB.ilbanAmt, "item"),
line("다. 소계", sub1(kjA) + kjA.ilbanAmt, sub1(kjB) + kjB.ilbanAmt),
line(`${nextNo + 1}. 이윤`, kjA.iyunFinal, kjB.iyunFinal, "item"),
line("라. 공급가액", kjA.supplyFinal, kjB.supplyFinal),
line(`${nextNo + 2}. 부가가치세`, kjA.vatFinal, kjB.vatFinal, "item"),
line("마. 도급공사비", kjA.contractFinal, kjB.contractFinal),
]);
if (kjA.grandTotal !== kjA.contractFinal || kjB.grandTotal !== kjB.contractFinal) {
rows.push(line("바. 관급자재대", kjA.govFinal, kjB.govFinal));
rows.push(line("총공사비", kjA.grandTotal, kjB.grandTotal));
}
rows.forEach((r) => (r.d = n0(r.b) - n0(r.a)));
return { rows, totalA: kjA.grandTotal, totalB: kjB.grandTotal };
}
// 머리 고르기 — 관급 구역 빼고 최상위에서 maxDepth 단 아래까지
function workHeads(tree, maxDepth = 2) {
const heads = tree.nodes.filter((nd) => nd.kind === "head");
const top = heads.length ? Math.max(...heads.map((h) => h.level)) : 0;
return heads.filter((h) => !h.zone && top - h.level <= maxDepth);
}
// 변경 총괄내역서(0661) — 머리마다 당초 줄·변경 줄(합계·노무비·재료비·경비)
function summaryRows(tree, a = 1, b = 2, maxDepth = 2) {
const out = [];
for (const h of workHeads(tree, maxDepth))
for (const n of [a, b]) {
const s = h.st[n] || emptySum();
out.push({
stage: n,
itnum: n === a ? h.itnum : "",
name: n === a ? h.name : "",
spec: n === a ? h.spec : "",
tAmt: n0(s.tAmt),
lAmt: n0(s.lAmt),
mAmt: n0(s.mAmt),
sAmt: n0(s.sAmt),
});
}
return out;
}
// 공사원가계산서 당초/변경 대비(PM_Won_A_C) — 비목 차례 · 0원 간접비 항목 뺌 · onlyChanged = 변경분만
function costCompare(kA, kB, onlyChanged = false) {
const L = (group, label, f, note = "") => ({ group, label, a: n0(f(kA)), b: n0(f(kB)), note });
const amt = (code) => (k) => (k.itemMap[code] ? k.itemMap[code].amount : 0);
const add = (st) => (k) => (k.adds || []).filter((x) => x.stage === st);
const addRows = (st, group) => {
const names = [
...new Set(
add(st)(kA)
.concat(add(st)(kB))
.map((x) => x.name),
),
];
return names.map((nm) =>
L(group, nm, (k) =>
add(st)(k)
.filter((x) => x.name === nm)
.reduce((acc, x) => acc + n0(x.amount), 0),
),
);
};
const items = kA.items
.filter((it) => it.code !== "KANNO")
.filter((it) => n0(it.amount) !== 0 || n0(amt(it.code)(kB)) !== 0)
.map((it) => L("경비", it.label, amt(it.code), it.formula));
const rows = [
L("재료비", "직접재료비", (k) => k.dM),
L("재료비", "간접재료비", () => 0),
L("재료비", "작업설,부산물등(△)", () => 0),
L("재료비", "소계", (k) => k.dM),
L("노무비", "직접노무비", (k) => k.dL),
L("노무비", "간접노무비", (k) => k.kanno),
L("노무비", "소계", (k) => k.totalLabor),
L("경비", "산출경비", (k) => k.dS),
...items,
...addRows(1, "경비"),
L("경비", "소계", (k) => k.totalExpense),
L("", "일반관리비", (k) => k.ilbanAmt),
L("", "이윤", (k) => k.iyunFinal),
...addRows(2, ""),
L("", "총원가", (k) => k.supplyFinal),
L("", "부가가치세", (k) => k.vatFinal),
...addRows(3, ""),
L("", "도급공사비", (k) => k.contractFinal),
];
if (kA.govFinal || kB.govFinal) rows.push(L("", "관급자재대", (k) => k.govFinal));
rows.push(...addRows(4, ""));
rows.push(L("", "총공사비", (k) => k.grandTotal));
rows.forEach((r) => (r.d = r.b - r.a));
return onlyChanged ? rows.filter((r) => r.d !== 0) : rows;
}
// 변경율 내역서(0645) — 머리별 증감율(%)
function changeRateTable(tree, a = 1, b = 2) {
return diffTable(tree, a, b)
.filter((r) => r.kind === "head")
.map((r) =>
Object.assign(r, { rate: r.aa ? Math.round((r.ad / r.aa) * 100000) / 1000 : null }),
);
}
// ── 입력(설계변경 작업) ──
const findItem = (state, id) => (state.boqItems || []).find((b) => b.id === id);
const ensureQtys = (b) => {
if (!Array.isArray(b.qtys)) b.qtys = [n0(b.qty), n0(b.changedQty ?? b.qty), 0, 0];
while (b.qtys.length < 4) b.qtys.push(0);
if (!b.units) b.units = {};
return b;
};
function setQty(state, id, stage, qty) {
const b = ensureQtys(findItem(state, id));
if (String(b.unit || "").endsWith("%")) {
if (!Array.isArray(b.pctQtys)) b.pctQtys = [null, null, null, null];
b.pctQtys[stage - 1] = n0(qty);
return b;
}
b.qtys[stage - 1] = n0(qty);
if (stage === 1) b.qty = n0(qty);
if (stage === 2) b.changedQty = n0(qty);
return b;
}
// 변경 단가 — 차수 2~4 칸에 [재,노,경]
function setUnit(state, id, stage, mls) {
if (stage < 2) throw new Error("당초(1차수) 단가는 단가 화면에서 바꿈");
const b = ensureQtys(findItem(state, id));
b.units[stage] = mls.map(n0);
return b;
}
// 삭제 = 그 차수 수량 0(행은 남김 · 코드 대응 보존)
const deleteInStage = (state, id, stage) => setQty(state, id, stage, 0);
// 신규 공종 — 앞 차수 수량 0 · 안정 ID(NEW_n) · 소속 머리 recno
function addItem(state, spec) {
const used = new Set((state.boqItems || []).map((b) => b.id));
let k = 1;
while (used.has(`NEW_${k}`)) k++;
const stage = spec.stage || 2;
const qtys = [0, 0, 0, 0];
qtys[stage - 1] = n0(spec.qty);
const units = {};
if (spec.mls) for (let n = Math.max(2, stage); n <= 4; n++) units[n] = spec.mls.map(n0);
for (let n = stage; n < 4; n++) qtys[n] = qtys[n] || 0;
const item = {
id: `NEW_${k}`,
code: spec.code || "",
itnum: "",
name: spec.name || "신규 공종",
spec: spec.spec || "",
unit: spec.unit || "",
refId: spec.refId || spec.code || "",
rowType: "normal",
qty: qtys[0],
changedQty: qtys[1],
qtys,
units,
isNew: true,
parentRecno: spec.parentRecno ?? null,
};
state.boqItems.push(item);
return item;
}
// 차수 시작 — 앞 차수 수량·단가·원가설정을 복사, 당초액 유지 항목은 당초 금액 고정
function startStage(state, n, kjOrig, carry = CARRY_DEFAULT, boqRows = []) {
if (n < 2 || n > 4) throw new Error("변경 차수는 2~4");
const dbfBy = new Map(boqRows.map((r) => [r._recno, r]));
for (const b of state.boqItems || []) {
ensureQtys(b);
if (String(b.unit || "").endsWith("%")) {
const r = dbfBy.get(b._recno) || {};
if (!Array.isArray(b.pctQtys))
b.pctQtys = [1, 2, 3, 4].map((k) => (r[`QTY${k}`] ? n0(r[`QTY${k}`]) : null));
b.pctQtys[n - 1] = b.pctQtys[n - 2] != null ? b.pctQtys[n - 2] : n0(r[`QTY${n - 1}`]);
}
b.qtys[n - 1] = b.qtys[n - 2];
if (b.units[n - 1]) b.units[n] = b.units[n - 1].slice();
if (n === 2) b.changedQty = b.qtys[1];
}
const st = (state.kanjubStages = state.kanjubStages || {});
const prev = st[n - 1] || state.kanjubConfig || {};
const cfg = clone(prev);
cfg.slot = n;
cfg.fixed = {};
if (kjOrig)
for (const c of carry) if (kjOrig.itemMap[c]) cfg.fixed[c] = kjOrig.itemMap[c].amount;
if (cfg.anjunAuto == null || cfg.anjunAuto === 1) cfg.anjunAuto = 2;
st[n] = cfg;
if (!st[1]) st[1] = Object.assign(clone(state.kanjubConfig || {}), { slot: 1 });
return cfg;
}
// 간접비 항목 변경 계산방식 — recalculated · carry_original · proportional(방법 A) · delta_rate(방법 B)
function setRevMode(state, n, code, mode, kjOrig) {
const cfg = state.kanjubStages[n];
cfg.revModes = cfg.revModes || {};
cfg.fixed = cfg.fixed || {};
if (mode === "carry_original" && kjOrig && kjOrig.itemMap[code])
cfg.fixed[code] = kjOrig.itemMap[code].amount;
else delete cfg.fixed[code];
cfg.revModes[code] = mode === "carry_original" ? "recalculated" : mode;
return cfg;
}
// 저장용 — 차수 n 칸 BOQ 행 갱신값(QTYn·xAMTn·x_AMTn)
function boqUpdates(tree, n) {
return tree.nodes
.filter((nd) => nd.recno != null && nd.kind !== "blank")
.map((nd) => {
const s = nd.st[n] || emptySum();
const up = { _recno: nd.recno };
if (nd.kind === "leaf") {
up[`QTY${n}`] = s.qty;
Object.assign(up, {
[`TAMT${n}`]: s.tUnit,
[`LAMT${n}`]: s.lUnit,
[`MAMT${n}`]: s.mUnit,
[`SAMT${n}`]: s.sUnit,
});
}
return Object.assign(up, {
[`T_AMT${n}`]: s.tAmt,
[`L_AMT${n}`]: s.lAmt,
[`M_AMT${n}`]: s.mAmt,
[`S_AMT${n}`]: s.sAmt,
});
});
}
return {
CARRY_DEFAULT,
stageRows,
buildTree,
applyPct,
sumHeads,
diffTable,
changeStatement,
kanjubRows,
amountTable,
workHeads,
summaryRows,
costCompare,
changeRateTable,
setQty,
setUnit,
deleteInStage,
addItem,
startStage,
setRevMode,
boqUpdates,
};
})();
if (typeof window !== "undefined") window.STMATE_CHANGE = StmateChange;
if (typeof module !== "undefined" && module.exports) module.exports = StmateChange;