- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면 - 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면 - 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림 - 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표 - 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0) - DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인) - 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
490 lines
20 KiB
JavaScript
490 lines
20 KiB
JavaScript
/* -*- 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;
|