/* -*- coding: utf-8 -*- */ /** * 계약 변환·계약 원가계산 (contract_calc.js) — 설계 내역 → 계약 내역(비목별 단가적용율 · 적용제외 공정 · W코드 공내역) * 근거: 지식DB STmate/old/20_분석 27 §2(wM_Mk_Cont 노·재·경 적용율 · 옵션 · 적용제외 · 예상적용율 찾기) * · 26 §4(NAGCD 낙찰율 열 · 계약단가) · 35 §3(계약 단계) · 16 §4.5(BOQ11 설계 + BOQ12 계약) * 규칙(울진 2025 STC 실증): 계약 단가 = 비목별 trunc(설계 단가 × 적용율/100) · 금액 = 비목별 trunc(단가 × 수량) * · 새 계약 코드 = 머리글자별 기존 최대 번호 다음부터 내역 차례 · 적용제외 공정은 코드·단가 그대로 * · 계약 원가계산 = 계약 직접비 위에 계약 차수 원가설정(당초액 유지 항목 고정) * 정답 대조: resources/tester/test_stmate_stage_contract.py (BOQ12 계약내역 · PRJLST 계약 행 · 계약 SMT) */ const StmateContract = (() => { const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js"); const KF = (typeof window !== "undefined" && window.STMATE_KANJUB_FACTORY) || require("./kanjub_calc.js"); const Change = (typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js"); const { roundDown, roundHalfUp, roundUp } = Calc; const applyRoundMode = (v, mode, d = 0) => mode === "up" ? roundUp(v, d) : mode === "half" ? roundHalfUp(v, d) : roundDown(v, d); const calcKanjub = KF(Object.assign({}, Calc, { applyRoundMode })); const n0 = (v) => Number(v) || 0; const COMPS = [ ["m", "M", "rateM"], ["l", "L", "rateL"], ["s", "S", "rateS"], ]; // 계약 원가계산에서 설계 금액을 그대로 쓰는 항목(D_ 표시) — 울진 2025 계약 SMT 실증 · 35번 §4 const CONTRACT_CARRY = ["KUNBO", "SLVBO", "YUNBO", "TBUGUM", "ANJUN"]; const DEFAULT_OPTS = { rateL: 100, rateM: 100, rateS: 100, digits: 0, excludeHeads: [], excludeLeaves: [], excludeGov: true, zeroToW: true, sameCodeSeparate: false, }; // 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터 function codeBaseFromCostn(costnRows) { const base = {}; for (const r of costnRows || []) { const m = /^([A-Z])(\d+)$/.exec(String(r.CODE || "").trim()); if (m) base[m[1]] = Math.max(base[m[1]] || 0, Number(m[2])); } return base; } // 적용제외 판정 — 고른 머리 아래 전부 · 고른 공종 · (옵션) 관급 구역 function excludedSet(tree, o) { const ex = new Set(); const heads = new Set((o.excludeHeads || []).map(String)); const leaves = new Set((o.excludeLeaves || []).map(String)); tree.nodes.forEach((nd, i) => { if (nd.kind === "head" && heads.has(String(nd.recno))) { const end = nd.closeIdx != null ? nd.closeIdx : tree.nodes.length; for (let k = i; k <= end && k < tree.nodes.length; k++) ex.add(k); } if (nd.kind === "leaf") { if (leaves.has(String(nd.id)) || leaves.has(String(nd.recno))) ex.add(i); if (o.excludeGov && nd.gov) ex.add(i); } }); return ex; } // 설계 → 계약 변환 — 행별 단가·금액과 머리 합계 · 직접비·관급 합계 function convert(tree, opts, stage = 1, codeBase = null) { const o = Object.assign({}, DEFAULT_OPTS, opts || {}); const ex = excludedSet(tree, o); const counter = codeBase ? Object.assign({}, codeBase) : null; const codeMap = {}; const nextCode = (code) => { const p = code[0]; counter[p] = (counter[p] || 0) + 1; return p + String(counter[p]).padStart(Math.max(5, code.length - 1), "0"); }; const nodes = tree.nodes.map((nd, i) => { const out = Object.assign({}, nd, { st: {} }); if (nd.kind !== "leaf") return out; const d = nd.st[stage] || {}; const excluded = ex.has(i); const unit = {}; for (const [c, , rk] of COMPS) { const u = n0(d[c + "Unit"]); unit[c] = excluded ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits)); } const qty = n0(d.qty); const amt = {}; for (const [c] of COMPS) amt[c] = roundDown(unit[c] * qty, 0); const tUnit = unit.m + unit.l + unit.s; let newCode = nd.code; const designT = n0(d.tUnit); const zeroed = !excluded && o.zeroToW && designT !== 0 && tUnit === 0; if (!excluded && counter && nd.code) { const key = zeroed ? "W" + nd.code.slice(1) : nd.code; if (!o.sameCodeSeparate && codeMap[key]) newCode = codeMap[key]; else { newCode = nextCode(zeroed ? "W" + nd.code.slice(1) : nd.code); codeMap[key] = newCode; } } else if (zeroed) newCode = "W" + nd.code.slice(1); out.excluded = excluded; out.zeroed = zeroed; out.newCode = newCode; out.design = d; out.st[stage] = { qty, mUnit: unit.m, lUnit: unit.l, sUnit: unit.s, tUnit, mAmt: amt.m, lAmt: amt.l, sAmt: amt.s, tAmt: amt.m + amt.l + amt.s, }; return out; }); nodes.forEach((nd) => nd.pct && (nd.pctQty = { [stage]: n0(nd.pctQty && nd.pctQty[stage]) })); Change.applyPct(nodes, [stage]); Change.sumHeads(nodes, [stage]); let directM = 0, directL = 0, directS = 0, govRawTotal = 0, govFeeTotal = 0; for (const nd of nodes) { if (nd.kind !== "leaf" || nd.missing) continue; const s = nd.st[stage]; if (nd.gov) { if (nd.rowType === "gov_fee") govFeeTotal += s.tAmt; else govRawTotal += s.tAmt; } else ((directM += s.mAmt), (directL += s.lAmt), (directS += s.sAmt)); } const directTotal = directM + directL + directS; const totals = { directM, directL, directS, directT: directTotal, directTotal }; Object.assign(totals, { govRawTotal, govFeeTotal, govCombinedRaw: govRawTotal + govFeeTotal }); return { nodes, rows: rowsOf(nodes, stage), totals, codeMap, opts: o, stage }; } function rowsOf(nodes, stage) { return nodes .filter((nd) => nd.kind !== "blank") .map((nd) => { const s = nd.st[stage] || {}; const r = { kind: nd.kind, recno: nd.recno, itnum: nd.itnum, name: nd.name }; Object.assign(r, { mAmt: n0(s.mAmt), lAmt: n0(s.lAmt), sAmt: n0(s.sAmt), tAmt: n0(s.tAmt), }); if (nd.kind === "leaf") Object.assign(r, { id: nd.id, code: nd.code, newCode: nd.newCode, excluded: nd.excluded, zeroed: nd.zeroed, qty: n0(s.qty), unit: nd.unit, spec: nd.spec, mUnit: n0(s.mUnit), lUnit: n0(s.lUnit), sUnit: n0(s.sUnit), tUnit: n0(s.tUnit), dUnit: n0(nd.design && nd.design.tUnit), dAmt: n0(nd.design && nd.design.tAmt), }); return r; }); } // 계약 원가설정 기본 — 설계 설정 복사 · 당초액 유지 항목은 설계 금액 고정 · 이윤율만 따로 function defaultContractCfg(designCfg, designKj, opts = {}) { const cfg = JSON.parse(JSON.stringify(designCfg || {})); cfg.fixed = {}; for (const c of opts.carry || CONTRACT_CARRY) if (designKj && designKj.itemMap[c]) cfg.fixed[c] = designKj.itemMap[c].amount; if (opts.iyunRate != null) cfg.rates = Object.assign({}, cfg.rates, { IYUN: n0(opts.iyunRate) }); cfg.anjunAuto = 1; return cfg; } // 계약 원가계산서 — 계약 직접비 + 계약 원가설정 function contractKanjub(conv, cfg) { return calcKanjub({ kanjubConfig: cfg }, conv.totals, null, cfg); } // 예상적용율 찾기 — 노·재·경 같은 율 · 0.001% 단위 · 목표 이하 최대 // what = "direct"(직접공사비) · "grand"(총공사비, cfg 필요) function findRate(tree, opts, target, stage = 1, what = "direct", cfg = null) { const value = (k) => { const r = k / 1000; const conv = convert(tree, Object.assign({}, opts, { rateL: r, rateM: r, rateS: r }), stage); return what === "grand" && cfg ? contractKanjub(conv, cfg).grandTotal : conv.totals.directTotal; }; let lo = 0, hi = 100000; if (value(hi) <= target) return { rate: 100, value: value(hi) }; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (value(mid) <= n0(target)) lo = mid; else hi = mid; } return { rate: lo / 1000, value: value(lo) }; } // 적용금액 미리계산 — 비목별 설계·계약 합과 실제 적용율 function preview(conv) { const d = { m: 0, l: 0, s: 0 }, c = { m: 0, l: 0, s: 0 }; for (const nd of conv.nodes) { if (nd.kind !== "leaf" || nd.gov) continue; for (const k of ["m", "l", "s"]) { d[k] += n0(nd.design && nd.design[k + "Amt"]); c[k] += n0(nd.st[conv.stage][k + "Amt"]); } } const rate = (a, b) => (a ? Math.round((b / a) * 1e7) / 1e5 : 0); return { design: d, contract: c, rates: { m: rate(d.m, c.m), l: rate(d.l, c.l), s: rate(d.s, c.s) }, total: { design: d.m + d.l + d.s, contract: c.m + c.l + c.s }, }; } // 낙찰율 열(NAGCD) 저장값 — 새 계약 코드마다 [코드, 설계 코드, 적용율, 단가] function nagcdRows(conv) { const seen = new Set(), out = []; for (const nd of conv.nodes) { if (nd.kind !== "leaf" || nd.excluded || seen.has(nd.newCode)) continue; seen.add(nd.newCode); const s = nd.st[conv.stage]; out.push({ code: nd.newCode, from: nd.code, rates: { L: conv.opts.rateL, M: conv.opts.rateM, S: conv.opts.rateS }, unit: [s.mUnit, s.lUnit, s.sUnit], design: [nd.design.mUnit, nd.design.lUnit, nd.design.sUnit], }); } return out; } return { CONTRACT_CARRY, DEFAULT_OPTS, codeBaseFromCostn, convert, defaultContractCfg, contractKanjub, findRate, preview, nagcdRows, }; })(); if (typeof window !== "undefined") window.STMATE_CONTRACT = StmateContract; if (typeof module !== "undefined" && module.exports) module.exports = StmateContract;