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

318 lines
13 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 단가산출근거 펼침 (report_san.js) — 0402 단가산출근거 · 식 줄마다 계산값을 넣은 글 + 줄 금액 + 계표시 합
* 근거: 지식DB STmate/old/20_분석 23(SMT 전수파싱) · 26 §3(계표시 이름 DISP_N1~5 · DISP_EX · DISP_TOT)
* REA 30 result_text_restoration(따옴표 글 · 끝 '=' 뒤 금액) · old/30_원자료/print_form_map.txt(0402)
* 줄 계산 = smt_eval.evaluate(계산 엔진과 같은 코드) · 이 파일은 글 꾸밈과 쪽 배치만
* 정답 대조: resources/tester/test_stmate_report_san.py (원본 STMATE 출력 엑셀)
*/
const StmateReportSan = (() => {
const g = typeof globalThis !== "undefined" ? globalThis : {};
const C = g.STMATE_REPORT_CORE || require("./report_core.js");
const { num, str, code } = C;
// 한 쪽 줄 수 — 호표마다 쪽 끝 줄에 총계 · 원본 출력 실측(LNP_CNT 20 → 35줄 · 25 → 50줄)에 맞춘 꼴(추정)
const pageRows = (ini) => Math.max(20, 3 * (num(ini.LNP_CNT) || 20) - 25);
// 수치 글 다듬기(FLT_JAB=1) — 따옴표 밖 수치의 끝 0 을 걷음(1.40 → 1.4 · 1.0 → 1) · 원본 출력 실측(추정)
const trimNum = (seg) =>
seg.replace(/(^|[^\w.])(\d+\.\d+)(?![\w.])/g, (m, a, n) => a + String(Number(n)));
const COLS = ["C", "D", "E", "F"];
const REF_RE = /&([A-Z][A-Z_0-9]*?)(?::([LMS]))?&/g;
const fmt = (v, d) =>
new Intl.NumberFormat("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }).format(
v,
);
// 참조 단가 글 — 소수 있으면 그 자리까지(최대 4) · 천 단위 쉼표
const fmtBase = (v) => {
const s = String(C.fix(v, 4));
const d = s.includes(".") ? s.split(".")[1].length : 0;
return fmt(v, d);
};
// 따옴표 밖 조각만 fn 으로 고침 · 따옴표는 그대로 둠
function mapOutside2(line, fn) {
return line
.split(/('[^']*'?)/)
.map((seg) => (seg.startsWith("'") ? seg : fn(seg)))
.join("");
}
// 따옴표 밖 조각마다 fn 을 돌리고 따옴표는 빈칸으로(원본 출력과 같은 글 자리)
function mapOutside(line, fn) {
let out = "",
buf = "",
inQ = false;
for (const ch of line) {
if (ch === "'") {
if (!inQ) out += fn(buf);
else out += buf;
buf = "";
out += " ";
inQ = !inQ;
continue;
}
buf += ch;
}
return out + (inQ ? buf : fn(buf));
}
// 따옴표 밖 글에서 n 번째 '=' 자리들(괄호 밖 · '==' 아님) — [시작 위치]
function eqPositions(s) {
const out = [];
let depth = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") depth--;
else if (c === "=" && depth === 0 && s[i - 1] !== "=" && s[i + 1] !== "=" && s[i - 1] !== ":")
out.push(i);
}
return out;
}
/**
* 대입 줄 글 — 「이름 = 식 =」 의 둘째 '=' 뒤에 값(ZCDNM 자리) · 쉼표로 나뉜 조각마다
* vals = evaluate 줄의 assigned 「이름=값」 차례
*/
function renderAssign(line, assigned, digits, plain) {
const vals = {};
for (const a of assigned || []) {
const at = a.indexOf("=");
vals[a.slice(0, at).toUpperCase()] = Number(a.slice(at + 1));
}
// 따옴표 밖 글을 한 줄로 잇고(따옴표 자리 표시) '=' 위치를 찾음
const marks = [];
let flat = "";
let inQ = false;
for (const ch of line) {
if (ch === "'") inQ = !inQ;
flat += inQ || ch === "'" ? "\u0001" : ch;
}
let name = null;
let seen = 0;
const eqs = eqPositions(flat);
for (const p of eqs) {
// 앞 쉼표(따옴표 밖) 이후의 첫 '=' 는 대입 기호 · 둘째가 값 자리
const prevComma = flat.lastIndexOf(",", p);
const seg = flat.slice(prevComma + 1, p).replace(/\u0001+/g, " ");
const m = seg.match(/^\s*([A-Za-z_][A-Za-z_0-9]*)\s*$/);
if (m) {
name = m[1].toUpperCase();
seen = 1;
continue;
}
if (seen === 1 && name && vals[name] !== undefined) {
marks.push([p, vals[name]]);
seen = 2;
}
}
let out = "";
let last = 0;
for (const [p, v] of marks) {
out +=
line.slice(last, p + 1) +
" " +
(plain ? C.half(v, digits).toFixed(digits) : fmt(v, digits)); // FLT_JAB=1 이면 쉼표 없음
last = p + 1;
}
out += line.slice(last);
return out.replace(/'/g, " ");
}
// 금액 줄 글 — &코드& 를 참조 단가로 · 끝 '=' 뒤에 줄 금액(lineDigits 자리)
// 「:=A01」(금액 이름표)는 「=」 자리로 · 「->」(합계 제외 표시)는 글에서 뺌
function renderAmount(line, base, amount, digits, vars, pctBase) {
line = line.replace(/:=(\s*)[A-Z]\d{2}/, "=$1").replace(/->\s*$/, "");
let flat = "";
let inQ = false;
for (const ch of line) {
if (ch === "'") inQ = !inQ;
flat += inQ || ch === "'" ? "\u0001" : ch;
}
const eqs = eqPositions(flat.replace(/:=.*$/, (m) => "\u0002".repeat(m.length)));
const at = eqs.length ? eqs[eqs.length - 1] : -1;
let s = at >= 0 ? line.slice(0, at + 1) + " " + fmt(amount, digits) + line.slice(at + 1) : line;
// [식] = 그 줄 시점의 변수 값 · &코드& = 참조 단가
const X =
g.STMATE_SMT_EXPR || (typeof require !== "undefined" ? require("./scr_edit_expr.js") : null);
const bracket = (m, e) => {
const r = X ? X.evaluate(e, vars || {}) : null;
return r && r.value != null && !r.error ? fmtBase(r.value) : m;
};
s = mapOutside(s, (seg) =>
seg
.replace(REF_RE, () => (pctBase ? fmt(base, digits) : fmtBase(base)))
.replace(/\[([^\]]*)\]/g, bracket),
);
return s;
}
// 결과적용 비목(SBMOK) 이름 — 총계 줄 꼬리
const SBMOK_NAME = { M: "재료비", 1: "재료비", L: "노무비", 2: "노무비", S: "경비", 3: "경비" };
// 비율 단위(M%·L%·S%·T%) 참조 — 밑수는 줄 금액 자리(소수 한 자리)로 보임
const isPct = (lookup, tok) => {
const m = String(tok).match(/^([A-Z]\d{1,5})[LMS]?$/);
if (!m) return true; // 금액 이름표·누계(PA01 · PS_ · PC_ …)도 줄 금액 합
const c = m[1].length < 6 ? m[1][0] + m[1].slice(1).padStart(5, "0") : m[1];
const r = lookup(c);
return !!r && /%$/.test(String(r.unit || "").trim());
};
// 참조 단가(글에 보일 값) — 비목 지정이면 그 비목 · 자원은 합계 · 그 밖은 세 비목 합
function shownBase(lookup, tok, ln) {
const m = String(tok).match(/^([A-Z]\d{1,5})([LMS])?$/);
if (!m) return ln.base;
const c = m[1].length < 6 ? m[1][0] + m[1].slice(1).padStart(5, "0") : m[1];
const r = lookup(c);
if (!r) return 0;
if (/%$/.test(String(r.unit || "").trim())) return ln.base;
if (m[2]) return r[m[2]];
return r.M + r.L + r.S;
}
/**
* 0402 시트 — u = report_unit unitContext(호표 번호·출력 설정)
* 호표 = [호표 줄 · 이름 줄 · 식 줄(원문 한 줄 = 한 행) · 빈 줄 · 총계] · 쪽(35줄) 단위로 채움
*/
function sanSheet(u, opts = {}) {
const SE = g.STMATE_SMT_EVAL;
const EN = g.STMATE_ENGINE;
const { dbf, idx, ini, order, po, hopyoNo } = u;
const sh = C.newSheet("단가산출근거", "단 가 산 출 근 거", C.projectName(dbf), "G");
C.push(sh, { A: "공 종", B: "산 출 내 역", _k: "head" });
order.forEach((k, i) => (sh.rows[2][COLS[i]] = C.bimokLabel(ini, k)));
C.push(sh, { A: "산 출 근 거", _k: "head" });
const memos = (dbf && dbf.costn_memos) || {};
const edited = opts.editedSmt || {};
const ctx = { dbfData: dbf, state: {}, uiState: {} };
const { lookup } = EN.costnLookup(ctx);
const dispN = (k) =>
str(ini[`DISP_N${k}`] || ["소계", "계", "합계", "총계", "총합계"][k - 1]).trim();
const dispEx = str(ini.DISP_EX || "제외금액").trim();
const dispTot = str(ini.DISP_TOT || "총 계");
const page = pageRows(ini);
const lineDigits = 1;
const put4 = (cells, v) => {
const t = { M: v.M, L: v.L, S: v.S, T: v.M + v.L + v.S };
order.forEach((k, i) => (cells[COLS[i]] = C.fix(t[k], 6)));
return cells;
};
for (const r of idx.ofPrefix("D")) {
const c = code(r);
const start = sh.rows.length;
const no = hopyoNo[c];
const lbl = po.codeMode ? c : `제${String(no).padStart(4)}호표`;
const p = idx.price(c, 1);
const head = put4({ B: lbl, _k: "hopyo", _code: c }, p);
// 호표 줄 비고 — 호표 비고면 호표 글 · 호표 모드면 가격지 위치(BQCODE)
const hrm = po.remarkHopyo && !po.codeMode ? lbl : po.remarkCode ? c : str(r.BQCODE).trim();
if (hrm) head.A = hrm;
C.push(sh, head);
C.push(sh, {
B: `${str(r.NAME)} ${str(r.SIZE)} / ${str(r.UNIT).trim()}`,
_k: "parent",
_code: c,
});
const text = edited[c] || (memos[c] && memos[c].text) || "";
const src = text.split(/\r?\n/);
const ev = text.trim() ? SE.evaluate(text, lookup, EN.smtOptions(ctx, c)) : { lines: [] };
const byNo = {};
for (const ln of ev.lines) byNo[ln.lineNo] = ln;
const cur = {}; // 그 줄 시점의 변수 값(대입 줄 assigned 차례로)
const acc = [null, 0, 0, 0, 0, 0].map(() => ({ M: 0, L: 0, S: 0 }));
const digits = num(ini.ZCDNM ?? 2) || 2;
const tidy =
String(ini.FLT_JAB ?? "2").trim() === "1" ? (t) => mapOutside2(t, trimNum) : (t) => t;
// 원문이 비면 빈 호표 한 쪽
for (let i = 0; i < (text ? src.length : 0); i++) {
const raw = tidy(src[i]);
let ln = byNo[i + 1];
if (!ln) {
C.push(sh, { _k: "blank" });
continue;
}
const mk =
ln &&
ln.kind === "text" &&
String(raw)
.trim()
.match(/^\((=+)\)/);
if (mk && mk[1].length <= 5) ln = { kind: "section", level: mk[1].length };
if (ln.kind === "section") {
const v = acc[ln.level];
C.push(sh, put4({ B: dispN(ln.level), _k: "sub" }, v));
for (let k = 1; k <= ln.level; k++) acc[k] = { M: 0, L: 0, S: 0 };
continue;
}
if (ln.kind === "exclude") {
// (-=) 지금 구간을 위 단 합에서 뺌 · (-==) 이전 전부를 지움(smt_eval 과 같음)
const v = { ...acc[ln.level] };
C.push(sh, put4({ B: `${dispN(ln.level)}(${dispEx})`, _k: "sub" }, v));
const cut = { ...acc[1] };
for (let k = 1; k <= 5; k++)
for (const x of "MLS") acc[k][x] = ln.level === 1 && k > 1 ? acc[k][x] - cut[x] : 0;
continue;
}
if (ln.kind === "amount") {
const a = ln.amounts;
const shown = shownBase(lookup, ln.ref, ln);
const cells = {
B: renderAmount(raw, shown, a.M + a.L + a.S, lineDigits, cur, isPct(lookup, ln.ref)),
_k: "line",
};
put4(cells, a);
const rm = refRemark(u, ln.ref);
if (rm) cells.A = rm;
const rc = String(ln.ref).match(/^([A-Z])(\d{1,5})[LMS]?$/);
if (rc) cells._ref = rc[1] + rc[2].padStart(5, "0"); // 참조 코드(안내 시트 % 단위 알림)
if (!ln.excluded) for (let k = 1; k <= 5; k++) for (const x of "MLS") acc[k][x] += a[x];
C.push(sh, cells);
continue;
}
// 「;;」 줄 = 숨은 줄(계산만 · 인쇄 안 함)
const hidden = /^s*;;/.test(raw);
const body = hidden
? ""
: ln.kind === "assign"
? renderAssign(raw, ln.assigned, digits, String(ini.FLT_JAB ?? "2").trim() === "1")
: raw.replace(/'/g, " ");
for (const a of ln.assigned || []) {
const at = a.indexOf("=");
cur[a.slice(0, at).toUpperCase()] = Number(a.slice(at + 1));
}
C.push(sh, body.trim() ? { B: body, _k: "text" } : { _k: "blank" });
}
// 쪽 채움 — 빈 줄 하나 이상 띄우고 총계는 쪽 끝 줄
const used = sh.rows.length - start + 2;
const pages = Math.max(1, Math.ceil(used / page));
while (sh.rows.length - start < pages * page - 1) C.push(sh, { _k: "blank" });
const to = SBMOK_NAME[str(r.SBMOK).trim()];
C.push(
sh,
put4({ A: to ? `${dispTot} (${to} 로적용)` : dispTot, _k: "total", _code: c }, p),
);
}
return sh;
}
// 참조 비고 — 코드 모드 = 원문 참조 글(L00002L) · 호표 비고(SH_TYP3=1) = 종류 이름 + 번호 + 비목 · 그 밖 = 가격지 위치
function refRemark(u, tok) {
const m = String(tok).match(/^([A-Z]\d{1,5})([LMS])?$/);
if (!m) return "";
const c = m[1].length < 6 ? m[1][0] + m[1].slice(1).padStart(5, "0") : m[1];
const r = u.idx.by[c];
if (!r) return "";
if (u.po.codeMode || u.po.remarkCode) return `${c}${m[2] || ""}`;
const kind = u.po.kind[c[0]] || "";
if (u.po.remarkHopyo && "BD".includes(c[0]))
return `${kind}${String(u.hopyoNo[c]).padStart(4)}호표`;
if (u.po.remarkHopyo) return `${kind}${String(u.hopyoNo[c]).padStart(5)}${m[2] || ""}`;
return str(r.BQCODE).trim();
}
return { sanSheet, renderAssign, renderAmount, pageRows };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_SAN = StmateReportSan;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportSan;