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

476 lines
20 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 -*- */
/**
* 계약 변환·계약 원가계산 (contract_calc.js) — 설계 내역 → 계약 내역(단가적용율 · 옵션 · 적용제외 공정)
* 근거: 지식DB STmate/REA 분석/32_실행계약단계_작업자료_20261114 (STmate.Cpm TwM_Mk_Cont 정적 분석)
* · 적용율 — 「일위대가/산출근거 생성」 켬 = 노무비 칸 하나를 노·재·경 모두에(0x556b02 · 0x5534f0 재·경 칸 숨김)
* 끔 = 노·재·경 따로 · 대표율 = MROUNDUD((노+재+경)/3, 3, 1)(0x556b59)
* · 코드별 갈래(0x554be0) — 대상 머리글자 MLSWBDXQ 만(0x556790) · 밖은 코드·단가 그대로
* 켬 + 율 0 → 아무 코드도 안 바꿈(조사가적용) · M·L·S·W = 「기초단가 적용」 켬일 때만
* · 새 머리글자 = 켬이면 제 글자 · 끔이면 모두 W(계약단가) · Q = 제 줄 단가만 고침(코드 그대로)
* · 새 코드 = 글자 + 다섯 자리(FormatFloat '00000') · 번호 = 설정의 글자별 마지막 번호 다음(0x555d8e)
* · 동일코드 개별생성 끔 = 같은 원래 코드는 처음 만든 새 코드 재사용(0x55504a)
* · 단가(0x5554f2~0x5556ed) — 대표율 100 = 그대로 · 대표율 0 = 단가 0(공내역 · 끔일 때만 닿음)
* · 세 율 같음 = 성분·총액 × 율/100 → OPTIMIZ(우선 플래그: B = CHBNM · D·Q = CHGNM · 그 밖 비목별, 0자리)
* · 세 율 다름 = 비목별 MROUNDUD(단가 × 율/100, 0, 0) · 계 = 합
* · 적용제외 = 고른 계표시 머리(T 레벨 ≥ 선택)부터 그 ENDCODE 까지(0x555f68) · 목록 기본 체크 = ITM_TYP 11·21·31·41·51·71·72
* (안전관리비·품질관리비·PS·제비율적용제외·작업설·관급자재대 도급자/관급자 — ITM_TYP 이름표 ST_U_Convert 0x587d9c)
* · 울진 관급 줄 = T3「관급자재대」(닫음 C3 제외금액) 머리를 고른 결과 — 그 머리 ITM_TYP 0(기본 체크 아님)
* · 비과세자재대 옵션 = 원가계산 C_NBUGAM·C_BUGA_M × 재료비율/100 1원 절사(0x556274) — 내역 줄과 무관
* · PNUM = 켬·율 0 → '조사가적용' · 그 밖 FormatFloat('0.00##', 대표율) + '% 적용'(0x55610e)
* 실물(울진 2025 STC): 계약 단가·금액 1원 · 직접비 · 원가계산 · PNUM '87.745% 적용'
* 앱 확장(원본 근거 없음): 관급 구역 줄 제외(excludeGov — 원본은 머리 선택으로만) · 공종 단위 제외(excludeLeaves) · 단가 절사 자리(digits)
* 정답 대조: resources/tester/test_stmate_stage_contract.py · test_stmate_stage_contract_rea.py
*/
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 Ex = () =>
(typeof window !== "undefined" && window.STMATE_EXEC) || require("./exec_calc.js");
const DEFAULT_OPTS = {
rateL: 100,
rateM: 100,
rateS: 100,
digits: 0,
excludeHeads: [],
excludeLeaves: [],
excludeGov: true,
sameCodeSeparate: false, // CBoxSetSame2DIF
baseRate: true, // CBoxSetHalMLSW — □ = M·L·S·W·Q 코드·단가 그대로
makeIlwi: true, // CBoxCreateILwi — √ 단일 율 · 제 글자 새 코드 / □ 노·재·경 따로 · 모두 W 코드
taxFree: true, // CBoxSetHal_NBUGAM — 원가계산 비과세자재대·면세품목 × 재료비율
chbnm: true, // 일위대가표 계산방식(START/CHBNM · 기본 참 = 비목별 먼저)
chgnm: true, // 단가산출근거 계산방식(START/CHGNM)
optnm: "SML", // 금액 보정순서(START/OPTNM)
};
// 적용제외 목록 기본 체크 ITM_TYP(0x55427b 비트표 0x55448c)
const DEFAULT_EXCLUDE_ITM = [11, 21, 31, 41, 51, 71, 72];
// ITM_TYP 공종구분 이름 — ST_U_Convert 상수 배열 0x587d9c(0~120) · 조달청 호환 공종구분과 같은 글
const ITM_TYP_NAMES = {
0: "일반공종",
10: "안전관리비",
11: "안전관리비",
20: "품질관리비",
21: "품질관리비",
30: "미확정설계공종(PS)",
31: "미확정설계공종(PS)",
40: "제요율적용제외공종",
41: "제비율적용제외공종",
50: "작업설 등",
51: "작업설 등",
60: "매입세",
61: "매입세",
70: "관급자재공종",
71: "관급자재대(도급자)",
72: "관급자재대(관급자)",
111: "비과세자재대",
120: "조달수수료",
};
const TARGET = "MLSWBDXQ";
// 실제 쓰는 율 — 켬 = 노무비 칸 하나 · 끔 = 셋 따로(대표율 = 평균 소수 셋째 사사오입)
function effRates(o) {
// 기성 공사(PCLASS 6) — 1칸이 이미 계약 금액 · 낙찰율 다시 먹이지 않음(100%) · 기성 REA 33 작업 쪽지
if (isProgressProject(o.pclass))
return { L: 100, M: 100, S: 100, overall: 100, single: o.makeIlwi !== false };
if (o.makeIlwi !== false) {
const r = n0(o.rateL);
return { L: r, M: r, S: r, overall: r, single: true };
}
const L = n0(o.rateL),
M = n0(o.rateM),
S = n0(o.rateS);
return { L, M, S, overall: Ex().mroundud((L + M + S) / 3, 3, 1), single: false };
}
const isProgressProject = (pclass) => String(pclass ?? "").trim() === "6";
// 프로젝트 이름 칸 PNUM(0x55610e~0x55626f)
function pnumText(opts) {
const o = Object.assign({}, DEFAULT_OPTS, opts || {});
const R = effRates(o);
if (R.single && R.overall === 0) return "조사가적용";
const v = R.single ? R.overall : (n0(o.rateL) + n0(o.rateM) + n0(o.rateS)) / 3;
const t = (Math.round(v * 1e4) / 1e4).toFixed(4).replace(/0{1,2}$/, "");
return `${t}% 적용`;
}
// 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터
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;
}
// 적용제외 목록(cBx_LevValueChange 0x55409c) — 계표시 머리 T 레벨 ≥ 고른 레벨(1 소계 ~ 9 광역대계)
function levelHeads(tree, level = 1, itmOf = null) {
return tree.nodes
.filter((nd) => nd.kind === "head" && !nd.zone && nd.level >= Math.max(1, n0(level)))
.map((nd) => {
const itm = n0(itmOf ? itmOf(nd) : nd.itmTyp);
return {
recno: nd.recno,
itnum: nd.itnum,
name: nd.name,
level: nd.level,
itm,
itmName: ITM_TYP_NAMES[itm] || "",
defaultExcluded: DEFAULT_EXCLUDE_ITM.includes(itm),
};
});
}
// 적용제외 판정 — 고른 머리 아래 전부(SORTCODE~ENDCODE) · (앱) 고른 공종 · (앱) 관급 구역
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;
}
// 코드 갈래(0x554be0) — keep(그대로) · inplace(Q 제 줄 단가만) · new(새 코드)
function actionOf(kind, o, R) {
if (!TARGET.includes(kind || "?")) return "keep";
if (R.single && R.overall === 0) return "keep";
if (kind === "Q") return o.baseRate === false ? "keep" : "inplace";
if ("MLSW".includes(kind)) return o.baseRate === false ? "keep" : "new";
return "new";
}
// 계약 단가(0x5554f2~0x5556ed · Q 는 0x554de1~0x554f98)
function contractUnit(d, kind, o, R, action) {
const E = Ex();
const u = { m: n0(d.mUnit), l: n0(d.lUnit), s: n0(d.sUnit), t: n0(d.tUnit) };
if (R.overall === 100) return { m: u.m, l: u.l, s: u.s };
if (R.overall === 0)
return action === "inplace" ? { m: u.m, l: u.l, s: u.s } : { m: 0, l: 0, s: 0 };
const dg = n0(o.digits);
if (R.L === R.overall && R.M === R.overall && R.S === R.overall) {
const r = R.overall / 100;
const pr =
action === "inplace" || kind === "D"
? o.chgnm !== false
: kind === "B"
? o.chbnm !== false
: true;
const g = E.optimiz(
{ T: u.t * r, L: u.l * r, M: u.m * r, S: u.s * r, A: 0 },
{ priority: pr, digits: dg, mode: 0, order: o.optnm },
);
return { m: g.M, l: g.L, s: g.S };
}
return {
m: E.mroundud((u.m * R.M) / 100, dg, 0),
l: E.mroundud((u.l * R.L) / 100, dg, 0),
s: E.mroundud((u.s * R.S) / 100, dg, 0),
};
}
// 설계 → 계약 변환 — 행별 단가·금액과 머리 합계 · 직접비·관급 합계
function convert(tree, opts, stage = 1, codeBase = null) {
const o = Object.assign({}, DEFAULT_OPTS, opts || {});
const R = effRates(o);
const ex = excludedSet(tree, o);
const counter = codeBase ? Object.assign({}, codeBase) : null;
const codeMap = {};
const nextCode = (p) => {
counter[p] = (counter[p] || 0) + 1;
return p + String(counter[p]).padStart(5, "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 action = excluded ? "keep" : actionOf(kind, o, R);
const keep = action === "keep";
const unit = keep
? { m: n0(d.mUnit), l: n0(d.lUnit), s: n0(d.sUnit) }
: contractUnit(d, kind, o, R, action);
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;
// 대표율 0 · 끔 → W 코드 단가 0(공내역 · lbMsg 안내문)
const zeroed = action === "new" && !R.single && R.overall === 0;
if (action === "new" && nd.code) {
const letter = R.single ? kind : "W";
if (counter) {
if (!o.sameCodeSeparate && codeMap[nd.code]) newCode = codeMap[nd.code];
else {
newCode = nextCode(letter);
codeMap[nd.code] = newCode;
}
} else newCode = letter + nd.code.slice(1);
}
out.excluded = excluded;
out.kept = keep;
out.action = action;
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;
// 비과세자재대 옵션(0x556274~0x55653f) — 재료비율 ≥ 0 이면 [n_KANJUB] C_NBUGAM·C_BUGA_M × 율/100 → 1원 절사
if (opts.taxFree !== false && opts.rateM != null && n0(opts.rateM) >= 0) {
const f = (v) => Ex().mroundud((n0(v) * n0(opts.rateM)) / 100, 0, 0);
cfg.keys = Object.assign({}, cfg.keys);
for (const k of ["C_NBUGAM", "C_BUGA_M"])
if (cfg.keys[k] != null && cfg.keys[k] !== "") cfg.keys[k] = f(cfg.keys[k]);
if (cfg.vatExempt != null) cfg.vatExempt = f(cfg.vatExempt);
}
return cfg;
}
// 계약 원가계산서 — 계약 직접비 + 계약 원가설정
function contractKanjub(conv, cfg) {
return calcKanjub({ kanjubConfig: cfg }, conv.totals, null, cfg);
}
// 예상적용율 찾기 — 노·재·경 같은 율 · 0.001% 단위 · 목표 이하 최대(앱: 행 단위 변환 이분 탐색)
// 원본 Act_Find_NagExecute 0x556d68 = 비목 합(MCOST·LCOST·SCOST) × 율 근사 되풀이 · 총공사비 목표 · 율 소수 5자리(REA 32 §8.2)
// 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];
const R = effRates(conv.opts);
out.push({
code: nd.newCode,
from: nd.code,
rates: { L: R.L, M: R.M, S: R.S },
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;
// NAGCD = 대표율(0x555848) — B·D·X 새 코드에만
const nag = effRates(o).overall;
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: nag, 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,
DEFAULT_EXCLUDE_ITM,
ITM_TYP_NAMES,
effRates,
isProgressProject,
pnumText,
levelHeads,
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;