/* -*- 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;