Files
Aislo/tools/stmate-test/smt_eval.js
T

516 lines
21 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(편집기와 사이클식) · 28 §1·§4(식 언어·오류)
* · REA 30 TWM_EDIT_SAN Memo_Use(단가산출근거 문법 (1)~(10)) · quantity_variable_resolution
* · result_text_restoration(계표시 10종) · PR_RR_rate_coefficient(DEVD · RR) · SIxMemo_구조
* 식 평가: scr_edit_expr.js · 정답 대조: resources/tester/test_stmate_smt_eval.py (실무 STC 저장 단가)
*/
const StmateSmtEval = (() => {
const REF_RE = /&([A-Z][A-Z_0-9]*?)(?::([LMS]))?&/g;
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;
};
const zero = () => ({ M: 0, L: 0, S: 0 });
// 따옴표 주석 걷기 — n'%' 는 백분율, n'단위' 는 수치만 남김 · [식]'%' 는 ÷100
function stripQuotes(s) {
return s
.replace(/\[([^\]]*)\]\s*'%'/g, "([$1]/100)")
.replace(/(\d+(?:\.\d+)?)\s*'%'/g, (_m, d) => {
const v = parseFloat(d);
return `(${v >= 1 ? v / 100 : v})`;
})
.replace(/'[^']*'/g, " ");
}
// 식 평가 — scr_edit_expr.js(함수·조건·대괄호) · 모르는 글자(단위 등)는 빈칸
const EXPR = () =>
(typeof window !== "undefined" && window.STMATE_SMT_EXPR) ||
(typeof require !== "undefined" ? require("./scr_edit_expr.js") : null);
let lastMissing = [];
let lastError = null;
function evalExpr(expr, vars) {
const s = String(expr).replace(/[^0-9A-Za-z_.+\-*/^()<>=,[\]:√∏÷×\s]/g, " ");
const r = EXPR().evaluate(s, vars);
lastMissing = r.missing;
lastError = r.error;
return r.value;
}
// 수량코드 정규화 — Q 뒤를 0으로 채워 6자리 · 넘치면 Q 다음 0을 걷음(REA 30)
function normQCode(code) {
let c = String(code || "")
.trim()
.toUpperCase();
if (!/^Q-?\d+$/.test(c)) return null;
while (c.length < 6) c = "Q0" + c.slice(1);
while (c.length > 6 && c[1] === "0") c = c[0] + c.slice(2);
return c.length === 6 ? c : null;
}
function resolveQty(body, opts) {
const m = String(body).match(/^\s*([^.\s]+)\s*\.\s*(\S+)\s*$/);
if (!m) return { value: 0, error: `~${body}~ 수량코드 문법오류` };
const code = normQCode(m[1]);
if (!code) return { value: 0, error: `${m[1]} 는 등록된 수량코드가 아님` };
if (!opts.qty) return { value: 0, error: `${code} — 수량산출서 없음` };
const r = opts.qty(code, m[2].toUpperCase()) || {};
if (r.error) return { value: 0, error: r.error };
return { value: Number(r.value) || 0 };
}
// 한 줄 앞손질 — 따옴표 짝 · // 주석(한 줄 안) · 따옴표 밖 ~수량코드.필드~ 를 값으로
function prepLine(ln, opts, errs) {
let out = "",
inQ = false,
tildes = 0;
for (let i = 0; i < ln.length; i++) {
const c = ln[i];
if (c === "'") inQ = !inQ;
else if (!inQ && c === "/" && ln[i + 1] === "/") break;
else if (!inQ && c === "~") tildes++;
out += c;
}
if (inQ) errs.push("따옴표(') 짝이 맞지 않음");
if (!tildes) return out.trim();
if (tildes % 2) {
errs.push("수량코드 문법오류 — ~ 짝이 맞지 않음");
return out.trim();
}
return out
.split(/('[^']*')/)
.map((seg) =>
seg.startsWith("'")
? seg
: seg.replace(/~([^~]*)~/g, (_m, body) => {
const r = resolveQty(body, opts);
if (r.error) errs.push(r.error);
return `(${r.value})`;
}),
)
.join("")
.trim();
}
// 짧은 코드 &L84& → L00084(앞글자 + 다섯 자리)
const padCode = (c) => {
const m = String(c).match(/^([A-Z])(\d{1,4})$/);
return m ? m[1] + m[2].padStart(5, "0") : c;
};
// 따옴표 밖의 마지막 '='(괄호·대괄호 안 제외)
const lastEqOutside = (s) => EXPR().lastTopEq(s.replace(/'[^']*'/g, (q) => "_".repeat(q.length)));
// 식 전체가 자리 함수 하나 — round(A/B,4)= 는 그 자리 그대로(Memo_Use (3) 예 D)
function wholeRoundCall(expr) {
const s = String(expr).trim();
const m = s.match(/^(ROUND|ROUNDDOWN|ROUNDUP|CUT|INT)\s*\(/i);
if (!m) return false;
let depth = 0;
for (let i = m[0].length - 1; i < s.length; i++) {
if (s[i] === "(") depth++;
else if (s[i] === ")" && --depth === 0) return i === s.length - 1;
}
return false;
}
// 대입 줄 — 「변수 = 식」 소수 그대로 · 「변수 = 식 =」 ZCD 자리 반올림 · 「= [?:4R]」 자리 지정
function assignLine(body, vars, errs, assignDigits) {
const assigned = [];
for (const part of EXPR().splitTop(stripQuotes(body), ",")) {
const m = part.match(/^\s*([A-Za-z_][A-Za-z_0-9]*)\s*=(?!=)\s*(.*)$/);
if (!m) continue;
const at = EXPR().firstTopEq(m[2]);
const exprTxt = at >= 0 ? m[2].slice(0, at) : m[2];
const tail = at >= 0 ? m[2].slice(at + 1) : null;
if (!exprTxt.trim()) continue;
const v = evalExpr(exprTxt, vars);
if (lastError) errs.push(`${m[1]} 식 오류 — ${lastError}`);
for (const nm of lastMissing) errs.push(`정의되지 않은 변수 「${nm}」`);
if (v === null) continue;
const key = m[1].toUpperCase();
const rd = tail && tail.match(/^\s*\[\s*\?\s*:\s*(\d)\s*([RUCruc])\s*\]/);
if (rd) vars[key] = EXPR().RND[rd[2].toUpperCase()](v, Number(rd[1]));
else vars[key] = tail !== null && !wholeRoundCall(exprTxt) ? half(v, assignDigits) : v;
assigned.push(`${m[1]}=${vars[key]}`);
}
return assigned;
}
/**
* lookup(code) → { M, L, S, unit } 현재 단가 · 없으면 null
* opts.assignDigits — 끝에 '=' 붙은 변수 반올림 자리(기본 2 · Start8x.Ini ZCDNM)
* opts.qty(code, field) — ~Q00001.QTY1~ 수량변수 · opts.selfCode — 자기참조 검사
* opts.adj {M,L,S} — 결과금액조정 · opts.sbmok — 결과적용 비목(M·L·S 또는 1·2·3)
* 돌려줌: { M, L, S, T, vars, lines, labels, errors: [{lineNo, msg}] }
*/
function evaluate(text, lookup, opts = {}) {
const assignDigits = opts.assignDigits ?? 2;
const lineDigits = opts.lineDigits ?? 1;
const vars = {};
const labels = {};
const lines = [];
const errors = [];
const tot = zero();
const seen = zero(); // 제외 구간 포함 누계 — 「운반비 계」 같은 % 밑수
let sec = zero();
let lastSec = zero();
let sinceT = zero(); // 누계번지 (=):T01 이후 — &PT_..&
const flush = () => {
for (const k of "MLS") tot[k] += sec[k];
if (sec.M || sec.L || sec.S) lastSec = sec;
sec = zero();
};
const acc = (k) => tot[k] + sec[k];
const setT = (lbl, src) => {
if (!lbl) return;
labels[lbl] = { ...src };
sinceT = zero();
};
const srcLines = String(text || "").split(/\r?\n/);
for (let lineNo = 1; lineNo <= srcLines.length; lineNo++) {
const errs = [];
const note = () => {
const u = [...new Set(errs)];
u.forEach((msg) => errors.push({ lineNo, msg }));
return u;
};
const ln = prepLine(srcLines[lineNo - 1], opts, errs);
if (!ln) {
if (errs.length)
lines.push({ lineNo, raw: srcLines[lineNo - 1].trim(), kind: "text", errors: note() });
continue;
}
// 계표시 (=)소계 (==)계 (===)합계 (====)총계 (=====)총합계 · (=):T01 누계번지
const mk = ln.match(/^\((-?)(=+)\)\s*(?::\s*(T\d{2}))?$/);
if (mk && mk[2].length > 5) errs.push("계표시는 (=)~(=====) 다섯 단까지");
if (mk && !mk[1]) {
const closing = { ...sec };
flush();
setT(mk[3], closing);
lines.push({
lineNo,
raw: ln,
kind: "section",
level: mk[2].length,
label: mk[3] || "",
errors: note(),
});
continue;
}
// (-=) 지금 구간 · (-==) 이전 전부를 합계에서 뺌 — % 밑수(seen)에는 남음
if (mk) {
setT(mk[3], sec);
sec = zero();
if (mk[2] !== "=") for (const k of "MLS") tot[k] = 0;
lines.push({ lineNo, raw: ln, kind: "exclude", level: mk[2].length, errors: note() });
continue;
}
const body = ln.startsWith(";;") ? ln.slice(2) : ln;
// 그림코드 IMG(I00001) — 계산 없음
if (/IMG\s*\(/i.test(stripQuotes(body))) {
if ((stripQuotes(body).match(/IMG\s*\(/gi) || []).length > 1)
errs.push("그림코드는 한 줄에 1개만");
lines.push({ lineNo, raw: ln, kind: "image", errors: note() });
continue;
}
const refs = [...body.matchAll(REF_RE)];
if (!refs.length) {
const assigned = assignLine(body, vars, errs, assignDigits);
lines.push({
lineNo,
raw: ln,
kind: assigned.length ? "assign" : "text",
assigned,
errors: note(),
});
continue;
}
if (refs.length > 1) errs.push("단가코드+단가코드는 할 수 없음 — 줄을 나눠 씀");
// 금액 줄 — 참조 하나를 성분별로 펼쳐 계산
const lbl = (body.match(/:=\s*([A-Z]\d{2})/) || [])[1] || "";
// 「:=A01 ->」 · 「=->」 — 금액은 셈하되 합계에서 제외(Memo_Use (9)②)
const excluded = /->\s*$/.test(stripQuotes(body));
// 끝 '=' 뒤(단위 주석·금액결과)와 앞머리 「M=」 같은 이름표를 걷음
let exprSrc = body.replace(/:=.*$/, "").replace(/->\s*$/, "");
const eqAt = lastEqOutside(exprSrc);
if (eqAt >= 0) exprSrc = exprSrc.slice(0, eqAt);
exprSrc = exprSrc.replace(/^\s*[A-Za-z_]\w*\s*=(?!=)/, "");
const ref = refs[0];
const tok = ref[1];
const forced = ref[2] || "";
const esc = ref[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`\\^\\s*${esc}|${esc}\\s*\\^`).test(exprSrc))
errs.push("단가코드 앞·뒤에 거듭제곱(^)을 쓸 수 없음");
const parts = []; // [base, target]
let pa;
if ((pa = tok.match(/^([PR])([SCT])_([LMS]?)$/))) {
// S 처음부터 누계 · C 마지막 계표시 이후 · T 누계번지 이후(Memo_Use (9)⑦⑧)
const src =
pa[2] === "S" ? { M: acc("M"), L: acc("L"), S: acc("S") } : pa[2] === "C" ? sec : sinceT;
if (pa[3]) parts.push([src[pa[3]], forced || pa[3]]);
else if (forced) parts.push([src.M + src.L + src.S, forced]);
else for (const k of "MLS") parts.push([src[k], k]);
} else if ((pa = tok.match(/^[PR]([A-Z])((?:\d{2})+)([LMS])?$/))) {
// 라벨 묶음 — PA010203 = A01 + A02 + A03 · PT01 = 누계번지
let base = 0;
for (const n of pa[2].match(/\d{2}/g)) {
if (!labels[pa[1] + n]) errs.push(`금액변수 ${pa[1] + n} 이 앞에 없음`);
const lo = labels[pa[1] + n] || zero();
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{1,5})([LMS])?$/);
const code = cm ? padCode(cm[1]) : tok;
const comp = cm ? cm[2] || "" : "";
if (opts.selfCode && code === opts.selfCode)
errs.push(`${code} 자기 자신을 참조함(순환참조)`);
const found = lookup(code);
if (!found) errs.push(`${code} 는 등록된 단가코드가 아님`);
const r = found || { 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 = zero();
for (const [base, target] of parts) {
// 둘째 참조부터는 0(오류로 따로 알림)
const e = stripQuotes(exprSrc.replace(ref[0], `(${base})`).replace(REF_RE, "(0)"));
const v = trunc(evalExpr(e, vars) || 0, lineDigits);
if (lastError) errs.push(`식 오류 — ${lastError}`);
for (const nm of lastMissing) errs.push(`정의되지 않은 변수 「${nm}」`);
amounts[target] += v;
seen[target] += v;
if (!excluded) {
sec[target] += v;
sinceT[target] += v;
}
if (lbl) {
labels[lbl] = labels[lbl] || zero();
labels[lbl][target] += v;
}
}
lines.push({
lineNo,
raw: ln,
kind: "amount",
ref: tok,
label: lbl,
excluded,
amounts,
base: parts[0][0],
errors: note(),
});
}
flush();
const out = applyResult(finish(tot, opts), opts);
return Object.assign(out, { vars, lines, labels, errors });
}
// 결과금액조정(합계 × M·L·S 배율 · 0 이하 = 조정 없음) → 결과적용 비목(cBx_SBMOK 4택)
const SBMOK = { 1: "M", 2: "L", 3: "S", M: "M", L: "L", S: "S" };
function applyResult(out, opts = {}) {
if (opts.adj)
for (const k of "MLS") {
const f = Number(opts.adj[k]);
if (f > 0 && f !== 1) out[k] = trunc(out[k] * f);
}
const to = SBMOK[String(opts.sbmok || "").trim()];
const t = out.M + out.L + out.S;
if (to) Object.assign(out, zero(), { [to]: t });
out.T = out.M + out.L + out.S;
return out;
}
// 비목우선 = 성분별 절사 · 총액우선(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);
// 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 JCD 자리 미만 버림(Start8x.Ini RXXNM · JCDNM)
function hourlyWage(daily, expr, digits = 0) {
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, digits);
}
// 외산장비 환율 — 단위 $ · € · ¥(₩/100¥) · $ 는 10만$ 이상이면 고액 환율(EXHNM) · 단가 천원 단위(÷1000)
// 근거: REA 14 TWM_SET_SYS(mBtn_EXL · ed_EXH · mBtn_EXEU · mBtn_EXJP 안내문) · 자리 = ECD(「X」의 $장비 단가 소수점)
// opts.fx = { usd, usdHigh, eur, yen } · 옛 opts.usdRate 는 usd 로 봄
function foreignUnit(unit, price, opts) {
const fx = opts.fx || { usd: opts.usdRate };
const rate =
unit === "$"
? Number(price) >= 100000 && Number(fx.usdHigh) > 0
? fx.usdHigh
: fx.usd
: unit === "€"
? fx.eur
: unit === "¥" || unit === "¥"
? (Number(fx.yen) || 0) / 100
: null;
return rate === null ? null : Number(rate) || 0;
}
// 나누기수량(DEVD) — 저장값의 역수 · 0 은 0(REA 30 PR_RR · divisor_to_ordinary)
const isDevd = (row) => ["T", "t", "1", "true", true, 1].includes(row.DEVD1);
const rowQty = (row) => {
const q = Number(row.QTY1) || 0;
return isDevd(row) ? (q ? 1 / q : 0) : q;
};
const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" };
/**
* 일위대가(B)·중기(X) 구성 조립 — BDQTY 구성행(bdqty.py 복호) 차례대로
* rows: [{ CODE, QTY1, DEVD1, PRPT, PRIN, PROUT }] · lookup(code) → { M, L, S, unit }
* opts: { totalFirst, order, sbmok, adj, labor: Set(시간 환산 직종) 또는 Map(직종 → 환산식), rx, usdRate · fx }
* 자리(22 §3.4 · REA 14 안내문): lineDigits = 줄 금액(중기 KCD · 일위대가 BPN, 기본 1) · wageDigits = JCD · fxDigits = ECD
* 근거: 지식DB 17(성분 절사 · 중기 손료·운전원·연료·잡품) · 실무 STC 대조
*/
function evaluateComposition(rows, lookup, opts = {}) {
const s = { M: 0, L: 0, S: 0, T: 0 },
base = zero(),
ld = opts.lineDigits ?? 1;
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 = rowQty(row);
if (code === "PR" || code === "RR") {
// 비율 줄 — 표시(PRPT)된 행의 밑수 비목(PRIN) × 계수 → 대상 비목(PROUT)
// 계수: 나누기 = 1/저장값 · RR = 저장값 · PR = 저장값/100
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];
const coef = isDevd(row) || code === "RR" ? q : null;
add(ok, coef === null ? trunc((bv * q) / 100, ld) : trunc(bv * coef, ld));
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, ld);
continue;
}
if (pct) {
const bv = pct[1] === "T" ? s.M + s.L + s.S : s[pct[1]];
add(own, trunc((bv * q) / 100, ld));
continue;
}
let p = { M: r.M, L: r.L, S: r.S };
const lab = opts.labor;
if (lab && lab.has(code)) {
const rx = lab instanceof Map ? lab.get(code) : opts.rx;
p = { M: 0, L: hourlyWage(r.L, rx, Number(opts.wageDigits) || 0), S: 0 };
}
const fxRate = foreignUnit(unit, r.M + r.L + r.S, opts);
if (fxRate !== null)
for (const k of "MLS")
p[k] =
opts.fxDigits == null
? (p[k] * fxRate) / 1000
: trunc((p[k] * fxRate) / 1000, Number(opts.fxDigits) + 3);
// 손료 갈래(E1~E3) — 손료 행(경비 · 단위 천원)에 사용자 계수
const sf = opts.shiftFactor;
const qq = sf && sf !== 1 && code[0] === "S" && unit === "천원" ? q * sf : q;
s.T += trunc(qq * (p.M + p.L + p.S), ld);
const marked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== "";
for (const k of "MLS") {
const a = trunc(qq * p[k], ld);
s[k] += a;
if (marked) base[k] += a;
}
}
return applyResult(finish(s, opts), opts);
}
// 나누기수량 → 일반수량 — 1/QTY1(단위에 % 있으면 ×100) 소수 7자리 반올림 · 0 은 그대로
function devdToOrdinary(qty, unit) {
const q = Number(qty) || 0;
if (!q) return 0;
const v = /%/.test(String(unit || "")) ? (1 / q) * 100 : 1 / q;
return half(v, 7);
}
return {
evaluate,
finish,
applyResult,
normQCode,
stripQuotes,
evalExpr,
orderByRefs,
bidRate,
hourlyWage,
evaluateComposition,
devdToOrdinary,
REF_RE,
};
})();
if (typeof window !== "undefined") window.STMATE_SMT_EVAL = StmateSmtEval;
if (typeof module !== "undefined" && module.exports) module.exports = StmateSmtEval;