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

310 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* -*- 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];
const prpt = !["", "0"].includes(String(row.PRPT ?? "").trim());
lines.push({
code: "PR",
pct: true,
qty: q,
unit: "%",
u,
a,
marked: false,
prpt,
inK: bk,
outK: ok,
});
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 = ["M", "L", "S"].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;