Files
Aislo/tools/stmate-test/smt_eval.js
T
eomsangdonandClaude Opus 5.5 f591c14356 feat(stmate-test): 단가산출근거(D코드) 재계산 — 원설계 5건 282개 1원 · 낙찰률 단가
- smt_eval.js 새 산근 평가기 — 봉화에만 맞던 옛 해석기(161/242) 대체
- 총액우선 차액 보정(Start8x.Ini CHGNM=0 · OPTNM 순서 · 소수부 십진 비교) · 비목우선은 성분 절사
- 다른 산근 참조 = 재·노·경 나눠 가져옴 · 위상순서 재계산 · 변수 대소문자 무시
- % 단위 자원(인건비의 % · 운반비 계) = 누계 밑수 · (-=)(-==) 합계 제외 · RS_ 직전 구간 · 라벨 묶음 PA010203
- 운반비 경비 귀속 = COSTN SBMOK=S — 원본 저장 금액으로 판정하던 부분 걷음
- 낙찰률 단가 = 2칸 원단가 × NAGCD 원 미만 버림(7건 127개)
- 지킴 시험 test_stmate_smt_eval.py · 장부 진행률 30.6% → 32.2%

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

220 lines
8.4 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 -*- */
/**
* COSTN.SMT 단가산출근거(D코드) 식 평가기 (smt_eval.js) — 화면·서버 없이 도는 순수 계산
* 근거: 지식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)]));
let rest = trunc(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);
return { evaluate, finish, stripQuotes, evalExpr, orderByRefs, bidRate };
})();
if (typeof window !== "undefined") window.STMATE_SMT_EVAL = StmateSmtEval;
if (typeof module !== "undefined" && module.exports) module.exports = StmateSmtEval;