/* -*- 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); // 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 원 미만 버림(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); } // 나누기수량(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 } * 근거: 지식DB 17(성분 절사 · 중기 손료·운전원·연료·잡품) · 실무 STC 대조 */ function evaluateComposition(rows, lookup, opts = {}) { const s = { M: 0, L: 0, S: 0, T: 0 }, base = zero(); 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, 1) : trunc(bv * coef, 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 }; 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), S: 0 }; } if (unit === "$") for (const k of "MLS") p[k] = (p[k] * (Number(opts.usdRate) || 0)) / 1000; // 손료 갈래(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), 1); const marked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== ""; for (const k of "MLS") { const a = trunc(qq * p[k], 1); 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;