Files
Aislo/tools/stmate-test/exec_calc.js
T
eomsangdonandClaude Opus 5.5 5847580670 feat(stmate-test): REA 보강 — 실행·계약·변경 차수·잔여 계산 원본 규칙 · 장부 91.1%
- 지식DB REA 분석 32 실행계약 · 34 변경차수 잔여계산 작업자료(정적분석 · 원본 실행·수정 없음)
- 실행: 중기 실행단가 합계·성분 비례·끝전·차액 성분 · 실행단가·수량 할증 · 계약 변환 옵션·적용제외 · 기성 공사 낙찰율 100%
- 변경: 차수 목록·이어 셈 · 보험료 3모드(변경 2차 이후) · 방법 A/B 5항목 · 퇴직공제·건설기계·표준시장단가·노임 환산 갈래·JCD
- 시험 693 통과 · 브라우저 3건 화면 전수 오류 0 · 총공사비 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 09:29:53 +09:00

374 lines
15 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)마다 하나 · 단가 할증은 단위 % 줄 제외
* 앱 확장(원본 없음): 비목별 율(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) — 올림(0 반대쪽) · 이름·호출 자리만 확인(본문 미해독) → 중간 확신
function mroundup(v, d = 0) {
const t = mroundud(v, d, 0);
return t === n0(v) ? t : t + Math.sign(n0(v)) * Math.pow(10, -Math.trunc(n0(d)));
}
// 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],
];
return {
JEOL,
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;