/* -*- coding: utf-8 -*- */ /** * 보고서 공통 (report_core.js) — 시트 모형 · 열 순서(BMKNM) · 단가 조회 · 구성행 줄별 계산 * 근거: 지식DB STmate/old/20_분석 20(설정파일·보고서 카탈로그 §2 · §3) · 17(단가조립) · 06 §3(출력별 반올림) * 정답 대조: resources/tester/test_stmate_report_sheets.py (원본 STMATE 출력 엑셀) * 시트 모형 = { name, title, cols:{A:폭}, rows:[{A:값,…, _k:종류}] } — rows[i] = 엑셀 i+1 행 */ const StmateReportCore = (() => { const num = (v, d = 0) => { if (v === null || v === undefined || v === "") return d; const n = Number(String(v).replace(/,/g, "").trim()); return Number.isFinite(n) ? n : d; }; // 절사 = 소수 d 자리 아래 버림(이진 오차 보정) 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 roundUp = (v, d = 0) => { const f = 10 ** d, s = v * f; return (v > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9)) / f; }; const fix = (v, d = 6) => half(v, d); // 표시용 이진 오차 걷기 const str = (v) => String(v ?? "").trimEnd(); // 단위 표시 — 중기 단위 X = 시간 · 비율 단위 M%·L%·S%·T% = % const unitText = (u) => { const t = String(u ?? "").trim(); return t === "X" ? "시간" : /^[MLST]%$/.test(t) ? "%" : t; }; const code = (r) => String((r && (r.CODE || r.BDCODE1)) || "").trim(); const commas = (v, d = 0) => new Intl.NumberFormat("en-US", { minimumFractionDigits: d, maximumFractionDigits: d }).format( v, ); // 비목 열 순서 — Start8x.Ini BMKNM(기본 TLMS = 합계·노무·재료·경비) · 머리글 BMK_STR1~4 function bimokOrder(ini) { const s = String((ini && ini.BMKNM) || "TLMS").toUpperCase(); const order = s.split("").filter((c) => "TLMS".includes(c)); return order.length === 4 ? order : ["T", "L", "M", "S"]; } function bimokLabel(ini, k) { const i = { M: 1, L: 2, S: 3, T: 4 }[k]; const dflt = { M: "재 료 비", L: "노 무 비", S: "경 비", T: "합 계" }[k]; return String((ini && ini[`BMK_STR${i}`]) || dflt); } // 시트 머리 — 1행 제목 · 2행 공사명 · 오른쪽 끝 시트 이름표 function newSheet(name, title, pname, tagCol) { const rows = [ { A: title, _k: "title" }, { A: `공사명 : ${pname}`, _k: "pname" }, ]; if (tagCol) rows[1][tagCol] = name; return { name, title, rows, cols: {} }; } const put = (sh, r, cells) => { while (sh.rows.length < r) sh.rows.push({}); Object.assign(sh.rows[r - 1], cells); return sh.rows[r - 1]; }; const push = (sh, cells) => (sh.rows.push(cells), sh.rows.length); /** * 원가 자료 묶음 — 불러온 자료(dbfData) 기준 단가 사전 * 낙찰률 단가(NAGCD>0) = 1칸 · 그 밖 = 1칸(설계 단가) — 계산 엔진이 갱신한 COSTN 값을 그대로 읽음 */ function costnIndex(dbf) { const by = {}; for (const r of (dbf && dbf.costn_rows) || []) by[code(r)] = r; const price = (c, slot = 1) => { const r = by[c]; if (!r) return null; return { M: num(r[`MAMT${slot}`]), L: num(r[`LAMT${slot}`]), S: num(r[`SAMT${slot}`]), unit: str(r.UNIT).trim(), unitDisp: unitText(r.UNIT), name: str(r.NAME), spec: str(r.SIZE), row: r, }; }; const ofPrefix = (p) => Object.keys(by) .filter((c) => c[0] === p) .sort() .map((c) => by[c]); return { by, price, ofPrefix }; } /** * 구성행 줄별 계산 — 일위대가표(0302)·중기사용료(0203) 출력 칸 그대로 * 줄 금액 = 단가 × 수량 0.1원 절사 · 총액우선(CHBNM=0)이면 줄마다 0.1원 끝수를 성분에 나눔 * 끝수 배분 = 소수 셋째 자리까지 남은 끝수가 큰 성분부터 · 같으면 OPTNM 순서 앞(S·M·L) * 합계 = 줄 합계의 원 미만 절사 · 총액우선이면 원 끝수도 같은 규칙 */ const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" }; function spread(parts, total, step, order, fracDigits) { const d = step === 0.1 ? 1 : 0; const base = {}, frac = {}; for (const k of "MLS") { base[k] = trunc(parts[k], d); frac[k] = half(trunc(parts[k], fracDigits) - base[k], fracDigits); } const diff = trunc(half(trunc(total, d) - (base.M + base.L + base.S), fracDigits), d); const n = Math.round(Math.abs(diff) / step); const sign = diff < 0 ? -1 : 1; const adj = { M: 0, L: 0, S: 0 }; if (!n) return adj; const ord = order.filter((k) => "MLS".includes(k)); for (const k of ord) { const ahead = ord.filter((o) => (sign > 0 ? frac[o] > frac[k] : frac[o] < frac[k])).length; const tieAhead = ord.slice(0, ord.indexOf(k)).filter((o) => frac[o] === frac[k]).length; if (ahead + tieAhead < n) adj[k] = sign * step; } return adj; } function compositionLines(rows, lookup, opts = {}) { const order = String(opts.order || "SML") .toUpperCase() .split(""); const lines = []; const marked = { M: 0, L: 0, S: 0 }; const sum = { M: 0, L: 0, S: 0 }; let closed = false; const hourly = (daily) => { let acc = daily; for (const [, op, n] of String(opts.rx || "") .replace(/\s+/g, "") .matchAll(/([*/])?(\d+(?:\.\d+)?)/g)) acc = op === "/" ? acc / Number(n) : acc * Number(n); return trunc(acc); }; for (const row of rows) { const c = String(row.CODE || "").trim(), q = num(row.QTY1); if (c === "PR") { // 비율 줄 — 표시(PRPT) 줄의 밑수 비목(PRIN) 합 × 율 → 대상 비목(PROUT) const bk = COMP[row.PRIN], ok = COMP[row.PROUT] === "T" ? "M" : COMP[row.PROUT]; if (!bk || !ok) continue; const bv = bk === "T" ? marked.M + marked.L + marked.S : marked[bk]; const u = { M: 0, L: 0, S: 0 }, a = { M: 0, L: 0, S: 0 }; u[ok] = fix(bv); a[ok] = q ? trunc((bv * q) / 100, 1) : 0; sum[ok] += a[ok]; lines.push({ code: "PR", pct: true, qty: q, unit: "%", u, a, marked: false }); continue; } if (/^C\d$/.test(c)) closed = true; // 글 줄 — 코드 없는 설명 줄(EXIST1=1) if (!c && String(row.EXIST1 || "").trim() === "1") { lines.push({ code: "", text: true, qty: 0, u: { M: 0, L: 0, S: 0 }, a: { M: 0, L: 0, S: 0 }, }); continue; } const r = lookup(c); if (!r) continue; const unit = String(r.unit || "").trim(); const own = c[0] === "L" ? "L" : c[0] === "S" ? "S" : "M"; const pct = unit.match(/^([MLST])%$/); const u = { M: 0, L: 0, S: 0 }, a = { M: 0, L: 0, S: 0 }; if (pct) { if (closed) continue; // 닫힌 뒤 % = 앞 금액 전체 비율 — 줄 없음 const bv = pct[1] === "T" ? sum.M + sum.L + sum.S : sum[pct[1]]; u[own] = fix(bv); a[own] = q ? trunc((bv * q) / 100, 1) : 0; sum[own] += a[own]; lines.push({ code: c, ref: r, pct: true, qty: q, unit, u, a, marked: false }); continue; } let p = { M: r.M, L: r.L, S: r.S }; if (opts.labor && opts.labor.has(c)) p = { M: 0, L: hourly(r.L), S: 0 }; if (unit === "$") for (const k of "MLS") p[k] = (p[k] * num(opts.usdRate)) / 1000; const raw = { M: q * p.M, L: q * p.L, S: q * p.S }; for (const k of "MLS") a[k] = q ? trunc(raw[k], 1) : 0; if (opts.totalFirst && q) { const adj = spread(raw, q * (p.M + p.L + p.S), 0.1, order, 3); for (const k of "MLS") a[k] = fix(a[k] + adj[k], 1); } const isMarked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== ""; for (const k of "MLS") { sum[k] += a[k]; if (isMarked) marked[k] += a[k]; } lines.push({ code: c, ref: r, qty: q, unit, u: p, a, marked: isMarked, labor: !!opts.labor?.has(c), }); } const raw = { M: fix(sum.M, 4), L: fix(sum.L, 4), S: fix(sum.S, 4) }; const tot = { M: trunc(raw.M), L: trunc(raw.L), S: trunc(raw.S) }; if (opts.totalFirst) { const adj = spread(raw, raw.M + raw.L + raw.S, 1, order, 2); for (const k of "MLS") tot[k] += adj[k]; } let T = tot.M + tot.L + tot.S; const sbmokS = String(opts.sbmok || "").trim() === "S"; if (sbmokS) Object.assign(tot, { M: 0, L: 0, S: T }); return { lines, raw, tot, T, sbmokS, totalFirst: !!opts.totalFirst }; } /** * SET_REP.DBF 서식 제목 적용(지식DB 20 §3) — 제목 = HEADTITLE · 시트 이름 = 제목에서 빈칸 뺌 * 서식 번호(INDX)는 old/30_원자료/print_form_map.txt */ function applyForm(sh, dbf, indx) { const r = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx); const title = r ? String(r.HEADTITLE || "").trim() : ""; if (!sh || !title) return sh; const name = title.replace(/[\s,/]+/g, ""); // 시트 이름 = 제목에서 빈칸·쉼표·빗금 뺌 sh.title = title; sh.indx = indx; if (sh.rows[0] && sh.rows[0]._k === "title") sh.rows[0].A = title; const tag = sh.rows[1] && Object.keys(sh.rows[1]).find((k) => sh.rows[1][k] === sh.name); if (tag) sh.rows[1][tag] = name; sh.name = name; return sh; } /** * 엑셀 수식(ST_P_XLS Prn_FMLA_INFO) — 비목 쌍(단가·금액) 줄 수식 · {r} = 행 번호(report_xlsx 가 채움) * 금액 = ROUNDDOWN(단가 × 수량, 자리) · 합계 쌍 = 세 비목 합(원본 STMATE 출력 수식 꼴) * zeroGuard = 수량 0 이면 0(일위대가표 「IF(C=0,0,…)」) */ function pairFormulas(order, pairs, qtyCol, digits, zeroGuard) { const f = {}; const at = (k) => pairs[order.indexOf(k)]; for (const k of order) { const [u, a] = at(k); if (k === "T") { const parts = order.filter((x) => x !== "T").map((x) => at(x)); f[u] = "=" + parts.map(([pu]) => pu + "{r}").join("+"); f[a] = "=" + parts.map(([, pa]) => pa + "{r}").join("+"); } else { const core = `ROUNDDOWN(${u}{r}*${qtyCol}{r},${digits})`; f[a] = zeroGuard ? `=IF(${qtyCol}{r}=0,0,${core})` : "=" + core; } } return f; } // 공사명 — PRJLST.DBF PNAME const projectName = (dbf) => str((dbf && dbf.prjlst && dbf.prjlst.PNAME) || ""); return { num, trunc, half, roundUp, fix, str, code, unitText, commas, bimokOrder, bimokLabel, newSheet, put, push, costnIndex, compositionLines, spread, projectName, applyForm, pairFormulas, }; })(); if (typeof window !== "undefined") window.STMATE_REPORT_CORE = StmateReportCore; if (typeof module !== "undefined" && module.exports) module.exports = StmateReportCore;