- 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>
220 lines
8.4 KiB
JavaScript
220 lines
8.4 KiB
JavaScript
/* -*- 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;
|