316 lines
13 KiB
JavaScript
316 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;
|
|
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;
|