/* -*- 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;