- 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>
295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
/* -*- 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;
|