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

339 lines
14 KiB
JavaScript

/* -*- 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,
baseRate: true, // 기초단가에 적용율 — □ = 기초단가(M·L·S) 코드·단가 그대로
makeIlwi: true, // 일위대가/산출근거 생성 — □ = 대가·산근·중기(B·D·X)는 계약단가 W코드로
taxFree: true, // 비과세자재대 — □ = taxFreeCodes(COSTN NOTA) 금액 그대로
taxFreeCodes: [],
};
// 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터
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 kind = String(nd.code || "")[0] || "";
// 그대로 둘 공종 — 적용제외 · 기초단가 적용율 끔 · 비과세자재대 그대로
const keep =
excluded ||
(o.baseRate === false && /[MLS]/.test(kind)) ||
(o.taxFree === false && (o.taxFreeCodes || []).includes(nd.code));
const unit = {};
for (const [c, , rk] of COMPS) {
const u = n0(d[c + "Unit"]);
unit[c] = keep ? 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 = !keep && o.zeroToW && designT !== 0 && tUnit === 0;
// 일위대가/산출근거 생성 끔 — 대가·산근·중기는 낙찰율 적용 계약단가(W코드)로
const asW = zeroed || (!keep && o.makeIlwi === false && /[BDX]/.test(kind));
if (!keep && counter && nd.code) {
const key = asW ? "W" + nd.code.slice(1) : nd.code;
if (!o.sameCodeSeparate && codeMap[key]) newCode = codeMap[key];
else {
newCode = nextCode(key);
codeMap[key] = newCode;
}
} else if (asW) newCode = "W" + nd.code.slice(1);
out.excluded = excluded;
out.kept = keep;
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;
}
/**
* 설계 → 계약자료 변환등록 저장 요청(wM_Conv_Mode · 26 §4 · 울진 2025 실증)
* 내역: 계약 공종 행 = 새 코드 · 비목 단가·금액(1칸) · 변경 수량 적용이면 QTY1 = 그 차수 수량
* 새 COSTN 행: 설계 코드의 명칭·규격·단위·분류 칸을 옮김
* 대가·산근(B·D·X) = 2칸 원단가(설계) · 1칸 계약단가 · NAGCD = 적용율 · UC(결과값에 낙찰율적용)·BCONT(계약단가) = T
* · 산출근거 글(COSTN.SMT)·구성표(BDQTY) 그대로 옮김
* 기초단가·계약단가 W = 1칸 = 2칸 = 계약단가(낙찰율 열 없음)
*/
const COPY_COLS = ["NAME", "SIZE", "UNIT", "BQCODE", "EQCODE", "JDCODE", "NAME2", "SIZE2"];
const COPY_MORE = ["SBMOK", "S_ADJ", "ADJ_M", "ADJ_L", "ADJ_S", "EDT_DATE", "WQCODE"];
function registerRows(conv, costnRows, memos = {}, bdqtyRows = []) {
const st = conv.stage;
const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r]));
const o = conv.opts;
const same = n0(o.rateL) === n0(o.rateM) && n0(o.rateL) === n0(o.rateS);
const boq = [],
costn = [],
memo = {},
bdqty = [],
made = new Set();
for (const nd of conv.nodes) {
if (nd.kind !== "leaf" || nd.recno == null || nd.kept) continue;
const s = nd.st[st];
const u = { _recno: nd.recno, BDCODE1: nd.newCode };
if (!nd.pct) {
Object.assign(u, { MAMT1: s.mUnit, LAMT1: s.lUnit, SAMT1: s.sUnit, TAMT1: s.tUnit });
Object.assign(u, { M_AMT1: s.mAmt, L_AMT1: s.lAmt, S_AMT1: s.sAmt, T_AMT1: s.tAmt });
if (st !== 1) u.QTY1 = s.qty;
}
boq.push(u);
if (made.has(nd.newCode) || by.has(nd.newCode) || nd.newCode === nd.code) continue;
made.add(nd.newCode);
const src = by.get(nd.code) || {};
const row = { CODE: nd.newCode };
COPY_COLS.forEach((k) => src[k] != null && (row[k] = src[k]));
const comp = /^[BDX]/.test(nd.newCode);
const d = nd.design || {};
const one = { M: s.mUnit, L: s.lUnit, S: s.sUnit, T: s.tUnit };
const two = comp ? { M: n0(d.mUnit), L: n0(d.lUnit), S: n0(d.sUnit), T: n0(d.tUnit) } : one;
for (const c of ["T", "M", "L", "S"])
Object.assign(row, { [`${c}AMT1`]: one[c], [`${c}AMT2`]: two[c] });
if (comp) {
COPY_MORE.forEach((k) => src[k] != null && (row[k] = src[k]));
Object.assign(row, { NAGCD: same ? n0(o.rateL) : 0, UC: "T", BCONT: "T" });
const m = memos[nd.code];
if (m && String(m.text || "").trim()) memo[nd.newCode] = m.text;
(bdqtyRows || [])
.filter((q) => String(q.BDCODE || "").trim() === nd.code)
.forEach((q) => bdqty.push(Object.assign({}, q, { BDCODE: nd.newCode })));
}
costn.push(row);
}
return { boq, costn, memos: memo, bdqty };
}
return {
CONTRACT_CARRY,
registerRows,
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;