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

505 lines
19 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 -*- */
/**
* 실행예산 계산 (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;