298 lines
11 KiB
JavaScript
298 lines
11 KiB
JavaScript
/* -*- 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;
|