/* -*- coding: utf-8 -*- */ /** * 실행예산 계산 (exec_calc.js) — 원본 반올림 원형(MROUNDUD·ADDBJVAL·OPTIMIZ) · 중기 실행단가 · 실행단가·실행수량 할증 * 근거: 지식DB STmate/REA 분석/32_실행계약단계_작업자료_20261114 (정적 분석 · 주소는 그 문서 표) * · MROUNDUD(값, 자릿수, 선택) = DP_Common 0x454044 — 선택 0 = 절사(0 쪽) · 1 = 사사오입(첫 버림 자리 ≥5) * · 자릿수 양수 = 소수 자리 · 음수 = 10^|d| 단위 · 문자열 변환 전 부동소수 잡음 미소값 더함 * · ADDBJVAL = DP_Common 0x4565f8 — 크기 비례 미소값을 부호 방향으로 더함(곱 뒤 절사 오차 막이) * · OPTIMIZ = ST_Func_C 0x48094c — 성분 MROUNDUD · 비목별 우선이면 총액 = 성분 합 · 아니면 총액 절사 뒤 * 남는 단위를 소수부(소수 둘째 절사) 큰 성분부터 하나씩(같으면 OPTNM 차례 앞 · L/M/S 뒤 A) * · 중기 실행단가 = ST_Boq TwM_Boq_ExecX.wNe_AutoCost 0x4da7a8 * 목표 X = MROUNDUD(1단위당 사용료 ÷ 1단위 시간, 0, 0) · 성분 = 최초단가 × X/최초합계(비례) * → OPTIMIZ(총액 우선 · 자릿수 = 절사 기준 0~−5) → 성분 합 ≠ X 이면 차액 X − 나머지 두 성분 = 보정 성분 * · 보정 성분 = 노/재/경 고름 · 기본보정 = 설정 OPTNM 첫 글자(L→노 · M→재 · 그 밖→경, 기본 SML → 경) * · 실행수량 = 1단위 시간 × 단위 수(0x4dab5c) · 실행금액 = OPTIMIZ(ADDBJVAL(수량 × 단가), CHNNM) * · 실행단가·실행수량 할증 = ST_Boq TwBoq.Act_Rate_Execute 0x512a80 → 0x511fa8 * 실행수량 = MROUNDUD(최초수량 × 율/100, 자릿수 0~4, 절사/반올림) · 실행단가 = MROUNDUD(최초단가 × 율/100, …) * · 율은 코드 머리글자 갈래(L 노무비 · M 재료비 · X 중장비 · 그 밖 W)마다 하나 · 단가 할증은 단위 % 줄 제외 * · 행 금액 = ST_Boq 0x456504 가 행을 계산 묶음에 싣고 공용 행 계산(0x433490 Q·A 줄 · 0x44019c → 0x43dee8) * 을 수량칸마다 부름 — 설계 내역과 같은 엔진(CHNNM 전달) · 앱은 OPTIMIZ(CHNNM, 0자리) 대입 * · 실행예산서 Ⅰ·Ⅱ = ST_P_Nae Prn_Nae_Cont1·2(0x4c27f8 · 모드 1·2 · 같은 PM_Nae_Cont 양식) * Ⅰ = 모든 줄(출력 설정 켬이면 실행수량 0 공종 뺌) · Ⅱ = 계표시 줄(T·E·C) + RS=1 줄 · 소계 닫음(C1·E1) 안 찍음 * 앱 확장(원본 없음): 비목별 율(rateM·rateL·rateS) · 원 단위 절사(jeol) — 옛 화면·시험 호환 * 시험: resources/tester/test_stmate_stage_exec_rea.py (원본 규칙 손계산 대조) */ const StmateExec = (() => { const Calc = (typeof window !== "undefined" && window.STMATE_CALC) || require("./calc.js"); const Change = (typeof window !== "undefined" && window.STMATE_CHANGE) || require("./change_calc.js"); const { roundDown } = Calc; const n0 = (v) => Number(v) || 0; // 절사 기준 6택 — wCm_Jeol Values '0'·'-1'…'-5'(= MROUNDUD 자릿수) · 화면 1원~100,000원 미만 절사 const JEOL = [1, 10, 100, 1000, 10000, 100000]; // 차액 보정 순서 — wCm_OPT Values 0~3 const ORDERS = [ ["base", "기본보정"], ["l", "노무비"], ["m", "재료비"], ["s", "경비"], ]; const cut = (v, j) => Math.trunc(n0(v) / (n0(j) || 1) + 1e-9) * (n0(j) || 1); const jeolDigits = (j) => -Math.round(Math.log10(n0(j) || 1)); // FormatFloat('0.###…', a) 꼴 — n 자리 반올림 뒤 끝 0 지움 function fmtHash(a, n) { const s = a.toFixed(Math.min(n, 20)); return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s; } // 부동소수 잡음 미소값 — '.' 자리(1부터) 2~9 → 10^(자리−13) · 10~13 → 1e-4 · 그 밖 1e-9 (0x4541ad) function noiseEps(pos) { if (pos < 2) return 0; if (pos <= 9) return Math.pow(10, pos - 13); return pos <= 13 ? 1e-4 : 1e-9; } // MROUNDUD(v, d, sel) — DP_Common 0x454044 function mroundud(v, d = 0, sel = 0) { v = n0(v); d = Math.trunc(n0(d)); if (v === 0) return 0; const a = Math.abs(v); const n = d > 11 && a <= 1 ? 15 : 13; const s0 = fmtHash(a, n); let pos = s0.indexOf(".") + 1; // 유효 숫자 11개 미만이면 미소값 없음(0x454154~0x4541ab) if (pos > 0 && s0.length - pos < 11) { const first = s0.search(/[1-9]/) + 1; if (first > 0 && s0.length - first < 11) pos = 0; } if (pos === 0 && d >= 0 && !s0.includes(".")) return v; const eps = pos === 0 ? 0 : noiseEps(pos); let s = fmtHash(a + eps, n); if (!s.includes(".")) s += ".0"; const p = s.indexOf(".") + 1; const ch = (i) => s.charAt(i - 1); let frac, body, e; if (d === 0) ((frac = "0." + ch(p + 1)), (body = s.slice(0, p - 1)), (e = 0)); else if (d > 0) { frac = "0." + ch(p + d + 1); body = s.slice(0, p - 1) + "." + s.slice(p, p + d); e = -d; } else { const D = -d; if (p - 1 > D) ((frac = "0." + ch(p - D)), (body = s.slice(0, p - D - 1) + "0".repeat(D))); else if (p - 1 === D) ((frac = "0." + ch(1)), (body = "0")); else ((frac = "0.0"), (body = "0")); e = D; } // 사사오입 = Round(StrToFloat(frac + '1')) × 10^e — '1' 덧붙여 은행가 동률 피함 const add = sel ? Math.round(Number(frac + "1")) * Math.pow(10, e) : 0; // 원본은 확장 정밀도 합 → binary64 저장 · 여기서는 자릿수로 다시 맞춰 이진 잡음 지움 const r = d > 0 ? Number((Number(body) + add).toFixed(Math.min(d, 20))) : Number(body) + add; return v > 0 ? r : -r; } // MROUNDUP(v, d) — DP_Common 0x4547ac (REA MCP Ghidra 의사코드로 확인) // FormatFloat('0.0#########', |v| + 미소값) → d 자리에서 자름 → 버린 다음 숫자들(최대 10자리)을 // '0.' 뒤에 붙여 Ceil(0x4297f4 = Trunc + (Frac > 0)) → 하나라도 0 이 아니면 한 단위 올림(0 반대쪽) function mroundup(v, d = 0) { v = n0(v); d = Math.trunc(n0(d)); if (v === 0) return 0; const a = Math.abs(v); const s0 = fmtHash(a, 10); let pos = s0.indexOf(".") + 1; if (pos > 0 && s0.length - pos < 11) { const first = s0.search(/[1-9]/) + 1; if (first > 0 && s0.length - first < 11) pos = 0; } if (pos === 0 && d >= 0 && !s0.includes(".")) return v; const s = fmtHash(a + (pos === 0 ? 0 : noiseEps(pos)), 10); const [ip, fp = ""] = s.split("."); let body, rest, e; if (d >= 0) { body = ip + (d ? "." + fp.slice(0, d).padEnd(d, "0") : ""); rest = fp.slice(d, d + 10); e = -d; } else { const D = -d; if (ip.length > D) ((body = ip.slice(0, ip.length - D) + "0".repeat(D)), (rest = ip.slice(-D) + fp)); else ((body = "0"), (rest = ip + fp)); e = D; } const sum = Number(body) + (/[1-9]/.test(rest) ? Math.pow(10, e) : 0); const r = d > 0 ? Number(sum.toFixed(Math.min(d, 20))) : sum; return v > 0 ? r : -r; } // ADDBJVAL(v) — DP_Common 0x4565f8 · '.' 자리 기준 미소값을 부호 방향으로 더함 function addbjval(v) { v = n0(v); const s = fmtHash(Math.abs(v), 14); const pos = s.indexOf(".") + 1; if (pos < 2) return v; const eps = pos <= 9 ? Math.pow(10, pos - 13) : pos <= 13 ? 1e-4 : 1e-9; return eps > Math.abs(v) ? v : v + Math.sign(v) * eps; } const mfrac = (x) => x - Math.trunc(x); // OPTIMIZ(rec, o) — ST_Func_C 0x48094c · rec {T,L,M,S,A} · o {priority, digits, mode, order} function optimiz(rec, o = {}) { const d = Math.trunc(n0(o.digits)), mode = Math.trunc(n0(o.mode)); const ks = String(o.order || "SML") .toUpperCase() .slice(0, 3) .split("") .filter((k) => "LMS".includes(k)); for (const k of ["S", "M", "L"]) if (!ks.includes(k)) ks.push(k); ks.push("A"); const c = ks.map((k) => n0(rec[k])); const R = (x) => mode === 1 ? mroundud(x, d, 1) : mode === 3 ? mroundup(x, d) : mroundud(x, d); const r = c.map(R); const T = n0(rec.T); const sumR = () => r.reduce((a, x) => a + x, 0); let Tout = sumR(), moved = 0; if (!(o.priority || mode === 1 || mode === 3 || Tout === T)) { Tout = mroundud(T, d, 0); const P = Math.pow(10, d); const f = c.map((x) => Math.abs(mroundud(mfrac(x * P), 2, 0))); const neg = c.map((x) => x < 0); let k = Math.round((Tout - sumR()) * P); const negRes = k < 0; k = Math.abs(k); for (let guard = 0; k > 0 && k <= 4 && guard < 32; guard++) { let i = 0, pick = -1; while (i < 4) { if (f[i] > 1) { i++; continue; } const j = f.findIndex((x, jj) => jj > i && x < 1 && x > f[i]); if (j >= 0) { i = j; continue; } pick = i; break; } if (pick < 0) break; f[pick] += 1; k = negRes === neg[pick] ? k - 1 : k + 1; moved++; } // 1/P 더하기 — 원본은 확장 정밀도 뒤 binary64 저장 · 여기서는 10^−d 를 바로 곱해 이진 잡음 막음 const step = d <= 0 ? Math.pow(10, -d) : 1 / Math.pow(10, d); for (let i = 0; i < 4; i++) { const t = Math.trunc(neg[i] ? -f[i] : f[i]); if (t) r[i] = d > 0 ? Number((r[i] + t * step).toFixed(d)) : r[i] + t * step; } } const out = { T: Tout, moved }; ks.forEach((k, i) => (out[k] = r[i])); return out; } const firstComp = (optnm) => { const c = String(optnm || "SML").toUpperCase()[0]; return c === "L" ? "l" : c === "M" ? "m" : "s"; }; // 중기 실행단가 — wNe_AutoCost 0x4da7a8 // p {fee, hours, units?, execQty?, initQty?, init{l,m,s,t?}, jeol | jeolDigits, order, optnm, chnnm} function machineExecUnit(p) { const hours = n0(p.hours), fee = n0(p.fee); const X = hours ? mroundud(fee / hours, 0, 0) : 0; const init = { l: n0(p.init && p.init.l), m: n0(p.init && p.init.m), s: n0(p.init && p.init.s), }; const initT = p.init && p.init.t != null ? n0(p.init.t) : init.l + init.m + init.s; const ratio = initT ? X / initT : 0; const digits = p.jeolDigits != null ? Math.trunc(n0(p.jeolDigits)) : jeolDigits(p.jeol); const o1 = optimiz( { T: X, L: init.l * ratio, M: init.m * ratio, S: init.s * ratio, A: 0 }, { priority: false, digits, mode: 0, order: p.optnm }, ); const comps = { l: o1.L, m: o1.M, s: o1.S }; let to = p.order || "base"; if (to === "base") to = firstComp(p.optnm); const before = comps.l + comps.m + comps.s; if (before !== X) comps[to] = X - (before - comps[to]); const qty = p.units != null && p.units !== "" ? hours * n0(p.units) : n0(p.execQty || p.initQty); const raw = { T: addbjval(qty * X), L: addbjval(qty * comps.l), M: addbjval(qty * comps.m), S: addbjval(qty * comps.s), A: 0, }; const o2 = optimiz(raw, { priority: p.chnnm !== false, digits: 0, mode: 0, order: p.optnm }); return { target: hours ? fee / hours : 0, X, T: X, Tr: o1.T, comps, diff: X - before, to, digits, initT, execQty: qty, qtyRatio: n0(p.initQty) ? qty / n0(p.initQty) : 1, amounts: { t: o2.T, l: o2.L, m: o2.M, s: o2.S }, }; } // 실행단가 = 최초단가(Act_SameFirstExecute 0x4dadd0) — 사용료 = 최초합계 × 시간 · 절사 1원 const sameFirst = (p) => Object.assign({}, p, { fee: n0(p.init && (p.init.t ?? p.init.l + p.init.m + p.init.s)) * n0(p.hours), jeol: 1, }); // 실행목표값(대략) 찾기(0x4da7e6) — 사용료 = MROUNDUP(목표 실행금액 ÷ 단위 수, 0) const feeFromTarget = (target, units) => (n0(units) ? mroundup(n0(target) / n0(units), 0) : 0); // 할증 갈래 — Act_Rate Tag%10: 1 L · 2 M · 3 X · 4 그 밖(S·W·B·D·A·P·Q) const kindOf = (code) => { const c = String(code || "").trim()[0] || ""; return c === "L" ? "L" : c === "M" ? "M" : c === "X" ? "X" : "W"; }; // 단가 할증 제외 — L·M 은 단위 %, W 갈래는 S·W·P 머리글자 + 단위 %(0x51300e) const pctExcluded = (code, unit) => { if (!String(unit || "").includes("%")) return false; const c = String(code || "").trim()[0] || ""; const k = kindOf(code); return k === "L" || k === "M" || (k === "W" && "SWP".includes(c)); }; // 실행예산 행 — 실행단가 = 직접 입력 또는 할증(율·자릿수·절사/반올림) · 실행수량 = 입력 또는 최초수량 × 수량율 // o {digits, round, rateQ, kindRates{L,M,X,W}, rateM/rateL/rateS(앱), jeol(앱), chnnm, optnm} function execRow(r, o) { const ov = (o.overrides || {})[r.id] || {}; const native = o.digits != null || o.round != null; const dg = Math.trunc(n0(o.digits)); const rnd = (v) => (native ? mroundud(v, dg, o.round ? 1 : 0) : cut(v, n0(o.jeol) || 1)); const kr = o.kindRates; const skip = kr && pctExcluded(r.code, r.unit); const rate = (k) => kr ? n0(kr[kindOf(r.code)] ?? 100) : n0(o[{ m: "rateM", l: "rateL", s: "rateS" }[k]] ?? 100); const u = {}; ["m", "l", "s"].forEach((k, i) => { u[k] = ov.mls ? n0(ov.mls[i]) : skip ? n0(r[k]) : rnd((n0(r[k]) * rate(k)) / 100); }); const q = ov.qty != null ? n0(ov.qty) : o.rateQ != null && n0(o.rateQ) !== 100 ? mroundud((n0(r.qty) * n0(o.rateQ)) / 100, dg, o.round ? 1 : 0) : n0(r.qty); // 행 금액 — 원본 행 계산(0x456504)은 미해독 · OPTIMIZ(CHNNM, 0자리) 대입(비목별 우선 = 비목 절사 합) const g = optimiz( { T: (u.m + u.l + u.s) * q, M: u.m * q, L: u.l * q, S: u.s * q, A: 0 }, { priority: o.chnnm !== false, digits: 0, order: o.optnm }, ); const a = { mAmt: g.M, lAmt: g.L, sAmt: g.S, tAmt: g.T }; return { qty: q, mUnit: u.m, lUnit: u.l, sUnit: u.s, tUnit: u.m + u.l + u.s, ...a }; } // 평면 행 실행예산 — rows [{id, code?, unit?, qty, m, l, s, gov}] function budget(rows, opts = {}) { const out = rows.map((r) => { const x = execRow(r, opts); const d = { mAmt: roundDown(n0(r.m) * n0(r.qty), 0), lAmt: roundDown(n0(r.l) * n0(r.qty), 0), sAmt: roundDown(n0(r.s) * n0(r.qty), 0), }; d.tAmt = d.mAmt + d.lAmt + d.sAmt; return Object.assign({}, r, { design: d, exec: x }); }); const tot = (key) => out .filter((r) => !r.gov) .reduce( (a, r) => ({ mAmt: a.mAmt + r[key].mAmt, lAmt: a.lAmt + r[key].lAmt, sAmt: a.sAmt + r[key].sAmt, tAmt: a.tAmt + r[key].tAmt, }), { mAmt: 0, lAmt: 0, sAmt: 0, tAmt: 0 }, ); const design = tot("design"), exec = tot("exec"); return { rows: out, design, exec, diff: exec.tAmt - design.tAmt, rate: design.tAmt ? Math.round((exec.tAmt / design.tAmt) * 1e5) / 1e3 : 0, }; } // 내역 구조 위 실행예산 — 설계(계획) 칸 = 최초(3칸 복사본 · TwPrjList.MkBoq 0x55c956) · 실행 칸 = 1칸 function budgetFromTree(tree, stage, opts = {}) { const nodes = tree.nodes.map((nd) => { const o = Object.assign({}, nd, { st: {} }); if (nd.kind !== "leaf") return o; const s = nd.st[stage] || {}; o.st.d = { qty: n0(s.qty), mUnit: n0(s.mUnit), lUnit: n0(s.lUnit), sUnit: n0(s.sUnit), tUnit: n0(s.tUnit), mAmt: n0(s.mAmt), lAmt: n0(s.lAmt), sAmt: n0(s.sAmt), tAmt: n0(s.tAmt), }; const row = { id: nd.id, code: nd.newCode || nd.code, unit: nd.unit, qty: s.qty }; Object.assign(row, { m: s.mUnit, l: s.lUnit, s: s.sUnit }); const flat = { rateM: 100, rateL: 100, rateS: 100, kindRates: null, rateQ: null }; o.st.x = execRow(row, nd.gov ? Object.assign({}, opts, flat) : opts); return o; }); Change.sumHeads(nodes, ["d", "x"]); const tops = nodes.filter((nd) => nd.kind === "head" && !nd.zone); const top = tops.reduce((a, h) => (!a || h.level > a.level ? h : a), null); return { nodes, design: top ? top.st.d.tAmt : 0, exec: top ? top.st.x.tAmt : 0, }; } // 실행예산서 Ⅰ·Ⅱ(0673·0674) 열 — 설치본 SET_REP.DBF 행(FLDCNT 5 · 열 이름·폭) const REPORT_COLS = [ ["공종", 25], ["설계(계획)", 20], ["실행예산", 25], ["작업명", 15], ["구분", 25], ]; // 실행예산서 Ⅰ·Ⅱ 줄 고르기 — ST_P_Nae 0x4c245d(조회식) · 0x4c3d38(소계 닫음 거름) // Ⅰ = 모든 줄 · 설정 켬(+0x1768)이면 !(MLSWBDXAP 공종 .and. QTY1=0) · Ⅱ = T·E·C 줄 + RS=1 줄 · C1·E1 안 찍음 function reportRows(nodes, mode = 1, o = {}) { return nodes.filter((nd) => { if (nd.kind === "blank") return false; const c = String(nd.newCode || nd.code || "").trim(); if (mode === 2) { if (nd.kind === "close") return !/^[CE]1$/.test(c); return nd.kind === "head" || !!nd.rs; } const q = nd.st && nd.st.x ? nd.st.x.qty : 0; return !(o.skipZeroQty && nd.kind === "leaf" && /^[MLSWBDXAP]/.test(c) && !n0(q)); }); } // PM_Nae_Cont 양식 열(DFM TPM_NAE_CONT 머리 띠) — 설계(계획) = 2칸(QTY2·UNIT2·T_AMT2) · 실행 = 1칸 const NAE_CONT_COLS = [ "공종", "설계(계획) 수량", "단위", "금액", "작업명", "구분", "수량", "단위", "재료비 단가", "재료비 금액", "노무비 단가", "노무비 금액", "경비 단가", "경비 금액", "총액 단가", "총액 금액", "비고", ]; // 계약,실행내역서(0623·0624) — ST_Prt 꼬리표 20623 → Prn_Nae/Prn_NaeW · 20624 → Prn_Nae_P/Prn_NaeW_P(0x4513e7) // 설계 내역서와 같은 인쇄 모듈을 지금 공사(계약·실행)에 씀 · SET_REP FLDCNT 0 → 내역서 기본 열 const NAE_COLS = [ "공종", "명칭", "규격", "수량", "단위", "재료비 단가", "재료비 금액", "노무비 단가", "노무비 금액", "경비 단가", "경비 금액", "합계 단가", "합계 금액", "비고", ]; // nodes 의 st[key] 값으로 내역서 줄 — 머리·닫음 = 금액만 function naeRows(nodes, key) { return nodes .filter((nd) => nd.kind !== "blank") .map((nd) => { const v = (nd.st && nd.st[key]) || {}; if (nd.kind !== "leaf") return [ nd.itnum || "", nd.name || "", "", "", "", "", v.mAmt, "", v.lAmt, "", v.sAmt, "", v.tAmt, "", ]; return [ nd.itnum || "", nd.name || "", nd.spec || "", v.qty, nd.unit || "", v.mUnit, v.mAmt, v.lUnit, v.lAmt, v.sUnit, v.sAmt, v.tUnit, v.tAmt, nd.newCode || nd.code || "", ]; }); } return { JEOL, NAE_COLS, naeRows, NAE_CONT_COLS, reportRows, ORDERS, REPORT_COLS, mroundud, mroundup, addbjval, optimiz, jeolDigits, kindOf, pctExcluded, machineExecUnit, sameFirst, feeFromTarget, execRow, budget, budgetFromTree, }; })(); if (typeof window !== "undefined") window.STMATE_EXEC = StmateExec; if (typeof module !== "undefined" && module.exports) module.exports = StmateExec;