Files
Aislo/tools/stmate-test/report_core.js
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:12:04 +09:00

275 lines
10 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];
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;
}
// 공사명 — 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,
};
})();
if (typeof window !== "undefined") window.STMATE_REPORT_CORE = StmateReportCore;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportCore;