Files
Aislo/tools/stmate-test/smt_eval.js
T
eomsangdonandClaude Opus 5.5 217e20a6e0 feat(stmate-test): 일위대가·중기 재계산 — BDQTY 구성표 복호 · 연쇄 재계산 기초단가부터 1원
- bdqty.py — BDQTY.DBF 자리별 고정 변환(회전 + 더하기) 표 283자리로 복호(사용자 승인) · /api/load 에 bdqty_rows
- smt_eval.js evaluateComposition — 행 소수1 버림 · CHBNM 총액우선(행 합계액 기준) · PR 줄(PRPT 밑수 → PROUT)
  · % 단위 자원 · C* 뒤 % = 전체 비율 · 중기 운전원 CONLD 시간 환산 · '$' 단위 환율 · SBMOK 경비
- 연쇄 재계산 = X·D·B 참조 순서 · 봉화 기준 표준 레시피 이름 맞추기(자기 대조) 걷음
- 원설계 5건 일위대가·중기 291개 + 영덕 변경 31개 1원 · 기초단가부터 다시 셈해도 전 단가·총공사비 그대로
- 지킴 시험 test_stmate_ilwi_eval.py · 장부 32.2% → 33.5%

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

295 lines
11 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 -*- */
/**
* 단가 계산기 (smt_eval.js) — 산근(D) COSTN.SMT 식 · 일위대가(B)·중기(X) BDQTY 구성 · 순수 계산
* 근거: 지식DB STmate/old/20_분석 23(SMT 전수파싱) · 26(편집기와 사이클식) · SIxMemo_구조
* 정답 대조: resources/tester/test_stmate_smt_eval.py (실무 STC 저장 단가)
*/
const StmateSmtEval = (() => {
const REF_RE = /&([A-Z][A-Z_0-9]*?)(?::([LMS]))?&/g;
const SECTION = new Set(["(=)", "(==)", "(===)"]);
const trunc = (v, d = 0) => {
const f = 10 ** d,
s = v * f;
return Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)) / f;
};
const half = (v, d = 0) => {
const f = 10 ** d,
r = Math.floor(Math.abs(v) * f + 0.5 + 1e-9) / f;
return v < 0 ? -r : r;
};
// 따옴표 주석 걷기 — n'%' 는 백분율, n'단위' 는 수치만 남김
function stripQuotes(s) {
return s
.replace(/\[([A-Za-z_]\w*)\]\s*'%'/g, "($1/100)")
.replace(/(\d+(?:\.\d+)?)\s*'%'/g, (_m, d) => {
const v = parseFloat(d);
return `(${v >= 1 ? v / 100 : v})`;
})
.replace(/'[^']*'/g, " ");
}
function evalExpr(expr, vars) {
let s = expr.replace(/÷/g, "/").replace(/×/g, "*");
s = s.replace(/\b([A-Za-z_][A-Za-z_0-9]*)\b/g, (_m, k) => {
const v = vars[k.toUpperCase()];
return v === undefined ? "0" : `(${v})`;
});
s = s.replace(/[^0-9.+\-*/()\s]/g, " ").trim();
if (!s) return null;
try {
const v = Function(`"use strict"; return (${s});`)();
return Number.isFinite(v) ? v : 0;
} catch (_e) {
return null;
}
}
/**
* lookup(code) → { M, L, S, unit } 현재 단가 · 없으면 null
* opts.assignDigits — 끝에 '=' 붙은 변수 반올림 자리(기본 2)
*/
function evaluate(text, lookup, opts = {}) {
const assignDigits = opts.assignDigits ?? 2;
const lineDigits = opts.lineDigits ?? 1;
const vars = {};
const labels = {};
const lines = [];
const tot = { M: 0, L: 0, S: 0 };
const seen = { M: 0, L: 0, S: 0 }; // 제외 구간 포함 누계 — 「운반비 계」 같은 % 밑수
let sec = { M: 0, L: 0, S: 0 };
let lastSec = { M: 0, L: 0, S: 0 };
const flush = () => {
for (const k of "MLS") tot[k] += sec[k];
if (sec.M || sec.L || sec.S) lastSec = sec;
sec = { M: 0, L: 0, S: 0 };
};
const acc = (k) => tot[k] + sec[k];
const srcLines = String(text || "").split(/\r?\n/);
for (let lineNo = 1; lineNo <= srcLines.length; lineNo++) {
const ln = srcLines[lineNo - 1].trim();
if (!ln) continue;
if (SECTION.has(ln)) {
flush();
lines.push({ lineNo, raw: ln, kind: "section" });
continue;
}
// (-=) 지금 구간 · (-==) 이전 전부를 합계에서 뺌 — % 밑수(seen)에는 남음
if (/^\(-=+\)$/.test(ln)) {
sec = { M: 0, L: 0, S: 0 };
if (ln !== "(-=)") for (const k of "MLS") tot[k] = 0;
lines.push({ lineNo, raw: ln, kind: "exclude" });
continue;
}
const body = ln.startsWith(";;") ? ln.slice(2) : ln;
const refs = [...body.matchAll(REF_RE)];
if (!refs.length) {
const clean = stripQuotes(body);
const assigned = [];
for (const part of clean.split(",")) {
const m = part.match(/^\s*([A-Za-z_][A-Za-z_0-9]*)\s*=\s*([^=]*?)\s*(=)?\s*$/);
if (!m || !m[2].trim()) continue;
const v = evalExpr(m[2], vars);
if (v === null) continue;
const key = m[1].toUpperCase();
vars[key] = m[3] ? half(v, assignDigits) : v;
assigned.push(`${m[1]}=${vars[key]}`);
}
lines.push({ lineNo, raw: ln, kind: assigned.length ? "assign" : "text", assigned });
continue;
}
// 금액 줄 — 참조 하나를 성분별로 펼쳐 계산
const lbl = (body.match(/:=\s*([A-Z]\d{2})/) || [])[1] || "";
// 끝 '=' 뒤(단위 주석)와 앞머리 「M=」 같은 이름표를 걷음
const exprSrc = body
.replace(/:=.*$/, "")
.replace(/=[^=]*$/, "")
.replace(/^\s*[A-Za-z_]\w*\s*=(?!=)/, "");
const ref = refs[0];
const tok = ref[1];
const forced = ref[2] || "";
const parts = []; // [base, target]
let pa;
if ((pa = tok.match(/^(PS|RC|RS)_([LMS])$/))) {
// PS 누계 · RC 지금 구간 · RS 직전에 닫힌 구간
const src = pa[1] === "PS" ? { [pa[2]]: acc(pa[2]) } : pa[1] === "RC" ? sec : lastSec;
parts.push([src[pa[2]], forced || pa[2]]);
} else if ((pa = tok.match(/^[PR]([A-Z])((?:\d{2})+)([LMS])?$/))) {
// 라벨 묶음 — PA010203 = A01 + A02 + A03
let base = 0;
for (const n of pa[2].match(/\d{2}/g)) {
const lo = labels[pa[1] + n] || { M: 0, L: 0, S: 0 };
base += pa[3] ? lo[pa[3]] : lo.M + lo.L + lo.S;
}
parts.push([base, forced || pa[3] || "M"]);
} else {
const cm = tok.match(/^([A-Z]\d{4,5})([LMS])?$/);
const code = cm ? cm[1] : tok;
const comp = cm ? cm[2] || "" : "";
const r = lookup(code) || { M: 0, L: 0, S: 0, unit: "" };
const pct = String(r.unit || "").match(/^([MLST])%$/);
const own = code[0] === "L" ? "L" : code[0] === "S" ? "S" : "M";
if (pct) {
const base = pct[1] === "T" ? seen.M + seen.L + seen.S : seen[pct[1]];
parts.push([base, forced || own]);
} else if (comp) parts.push([r[comp], forced || comp]);
else if (/^[MLS]/.test(code)) parts.push([r.M + r.L + r.S, forced || own]);
else for (const k of "MLS") parts.push([r[k], forced || k]);
}
const amounts = { M: 0, L: 0, S: 0 };
for (const [base, target] of parts) {
const e = stripQuotes(exprSrc.replace(ref[0], `(${base})`));
const v = trunc(evalExpr(e, vars) || 0, lineDigits);
amounts[target] += v;
sec[target] += v;
seen[target] += v;
if (lbl) {
labels[lbl] = labels[lbl] || { M: 0, L: 0, S: 0 };
labels[lbl][target] += v;
}
}
lines.push({
lineNo,
raw: ln,
kind: "amount",
ref: tok,
label: lbl,
amounts,
base: parts[0][0],
});
}
flush();
const out = finish(tot, opts);
// 비목 지정 경비(COSTN SBMOK=S) — 합계를 경비 한 칸으로
if (opts.sbmok === "S") Object.assign(out, { M: 0, L: 0, S: out.T });
return Object.assign(out, { vars, lines });
}
// 비목우선 = 성분별 절사 · 총액우선(REA 15 OPTIMIZ) = 총액 절사 후 남는 1원을
// 소수부 큰 성분부터(동률은 OPTNM 순서 앞쪽) 하나씩
function finish(raw, opts = {}) {
const out = { M: trunc(raw.M), L: trunc(raw.L), S: trunc(raw.S) };
if (opts.totalFirst) {
const order = String(opts.order || "SML")
.toUpperCase()
.split("");
// 소수부는 십진 자리로 맞춰 비교 — 이진 오차로 동률이 깨지지 않게(REA 15 MFRAC)
const frac = Object.fromEntries(order.map((k) => [k, half(raw[k] - out[k], 6)]));
// 일위대가는 행별 합계액(raw.T) 기준 총액 · 산근은 성분 합
let rest = trunc(raw.T ?? raw.M + raw.L + raw.S) - out.M - out.L - out.S;
while (rest > 0) {
let pick = null;
for (const k of order) if (frac[k] < 1 && (pick === null || frac[k] > frac[pick])) pick = k;
if (pick === null) break;
out[pick] += 1;
frac[pick] += 1;
rest -= 1;
}
}
return { M: out.M, L: out.L, S: out.S, T: out.M + out.L + out.S, raw };
}
// 다른 D코드를 참조하는 산근이 뒤에 오도록 — 위상순서(순환이면 원래 자리)
function orderByRefs(memos) {
const done = new Set(),
out = [],
busy = new Set();
const visit = (code) => {
if (done.has(code) || busy.has(code) || !(code in memos)) return;
busy.add(code);
for (const m of String(memos[code] || "").matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g))
visit(m[1]);
busy.delete(code);
done.add(code);
out.push(code);
};
Object.keys(memos).forEach(visit);
return out;
}
// 낙찰률 단가 — 원단가(2칸) × 낙찰률, 성분별 원 미만 버림
const bidRate = (v, pct) => trunc(((Number(v) || 0) * (Number(pct) || 0)) / 100);
// 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 원 미만 버림(Start8x.Ini RXXNM)
function hourlyWage(daily, expr) {
let acc = Number(daily) || 0;
for (const [, op, n] of String(expr || "")
.replace(/\s+/g, "")
.matchAll(/([*/])?(\d+(?:\.\d+)?)/g))
acc = op === "/" ? acc / Number(n) : acc * Number(n);
return trunc(acc);
}
const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" };
/**
* 일위대가(B)·중기(X) 구성 조립 — BDQTY 구성행(bdqty.py 복호) 차례대로
* rows: [{ CODE, QTY1, PRPT, PRIN, PROUT }] · lookup(code) → { M, L, S, unit }
* opts: { totalFirst, order, sbmok, labor: Set(시간 환산 직종), rx, usdRate }
* 근거: 지식DB 17(성분 절사 · 중기 손료·운전원·연료·잡품) · 실무 STC 대조
*/
function evaluateComposition(rows, lookup, opts = {}) {
const s = { M: 0, L: 0, S: 0, T: 0 },
base = { M: 0, L: 0, S: 0 };
const add = (k, v) => ((s[k === "T" ? "M" : k] += v), (s.T += v));
let closed = false; // C* 줄 뒤의 % 자원 = 앞 금액 전체를 그 비율로(예: 켜쌓기 적용 90%)
for (const row of rows) {
const code = String(row.CODE || "").trim(),
q = Number(row.QTY1) || 0;
if (code === "PR") {
// 비율 줄 — 표시(PRPT)된 행의 밑수 비목(PRIN) × 율 → 대상 비목(PROUT)
const bk = COMP[row.PRIN],
ok = COMP[row.PROUT];
if (!bk || !ok) continue;
const bv = bk === "T" ? base.M + base.L + base.S : base[bk];
add(ok, trunc((bv * q) / 100, 1));
continue;
}
if (/^C\d$/.test(code)) closed = true;
const r = lookup(code);
if (!r) continue; // T1 · T2 · C1 같은 구분 줄
const unit = String(r.unit || "").trim();
const own = code[0] === "L" ? "L" : code[0] === "S" ? "S" : "M";
const pct = unit.match(/^([MLST])%$/);
if (pct && closed) {
for (const k of "MLST") s[k] = trunc((s[k] * q) / 100, 1);
continue;
}
if (pct) {
const bv = pct[1] === "T" ? s.M + s.L + s.S : s[pct[1]];
add(own, trunc((bv * q) / 100, 1));
continue;
}
let p = { M: r.M, L: r.L, S: r.S };
if (opts.labor && opts.labor.has(code)) p = { M: 0, L: hourlyWage(r.L, opts.rx), S: 0 };
if (unit === "$") for (const k of "MLS") p[k] = (p[k] * (Number(opts.usdRate) || 0)) / 1000;
s.T += trunc(q * (p.M + p.L + p.S), 1);
const marked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== "";
for (const k of "MLS") {
const a = trunc(q * p[k], 1);
s[k] += a;
if (marked) base[k] += a;
}
}
const out = finish(s, opts);
if (opts.sbmok === "S") Object.assign(out, { M: 0, L: 0, S: out.T });
return out;
}
return {
evaluate,
finish,
stripQuotes,
evalExpr,
orderByRefs,
bidRate,
hourlyWage,
evaluateComposition,
};
})();
if (typeof window !== "undefined") window.STMATE_SMT_EVAL = StmateSmtEval;
if (typeof module !== "undefined" && module.exports) module.exports = StmateSmtEval;