Files
Aislo/tools/stmate-test/report_unit.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

323 lines
13 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 단가 보고서 (report_unit.js) — 목록표(0101~0103 · 0202 · 0301 · 0401) · 일위대가표(0302) · 중기사용료(0203) · 환율및기초자료(0201)
* 근거: 지식DB STmate/old/20_분석 20 §2.4(코드 종류) · §3(SET_REP 서식) · 17(단가조립) · old/30_원자료/print_form_map.txt
* 정답 대조: resources/tester/test_stmate_report_sheets.py (원본 STMATE 출력 엑셀 셀 값)
*/
const StmateReportUnit = (() => {
const C =
(typeof globalThis !== "undefined" && globalThis.STMATE_REPORT_CORE) ||
require("./report_core.js");
const { num, str, code, fix } = C;
// 비목 열 — 목록표 E~H · 일위대가표 (E,F)~(K,L) 쌍
const LIST_COLS = ["E", "F", "G", "H"];
const PAIR_COLS = [
["E", "F"],
["G", "H"],
["I", "J"],
["K", "L"],
];
function iniOf(dbf) {
return (dbf && dbf.start_ini) || {};
}
const slotOf = (r) => (num(r && r.NAGCD) > 0 ? 2 : 1);
// 구성 조립 설정 — Start8x.Ini(CHBNM · OPTNM · CONLD · RXXNM · EXLNM1)
function compOptions(dbf, parent) {
const ini = iniOf(dbf);
const p = code(parent);
return {
totalFirst: p[0] === "B" && String(ini.CHBNM ?? "1") === "0",
order: ini.OPTNM || "SML",
sbmok: str(parent.SBMOK).trim(),
labor: p[0] === "X" ? new Set(String(ini.CONLD || "").match(/.{1,6}/g) || []) : null,
rx: ini.RXXNM || "",
usdRate: num(ini.EXLNM1),
};
}
function groupsOf(dbf) {
const g = {};
for (const r of (dbf && dbf.bdqty_rows) || []) (g[r.BDCODE] = g[r.BDCODE] || []).push(r);
return g;
}
// 코드 단가 조회 — 낙찰률 품목은 원단가(2칸)로 조립(지식DB 17 · smt_engine 과 같음)
function lookupOf(idx) {
return (c) => {
const r = idx.by[c];
return r ? idx.price(c, slotOf(r)) : null;
};
}
/**
* 출력 설정 — Start8x.Ini
* SH_TYP1 = 0 이면 첫 칸 코드 · 그 밖 호표 번호 / SH_TYP3 = 1 이면 비고에 호표 · 그 밖 가격지 위치(BQCODE)
* CH_PRN_COD = 1 이면 번호를 자리 맞춤(중기 「제 n호표」 · 자원 「 n」)
* 줄 비고 종류 이름 = COD_FMT_1~6(노무·자재·경비·대가·중기·산근 · 지식DB 20 §2.4)
*/
function printOptions(ini) {
const kind = {};
"LMSBXD".split("").forEach((p, i) => (kind[p] = String(ini[`COD_FMT_${i + 1}`] || "")));
return {
codeMode: String(ini.SH_TYP1 ?? "0") === "0",
remarkHopyo: String(ini.SH_TYP3 ?? "2") === "1",
padNo: String(ini.CH_PRN_COD ?? "0") === "1",
kind,
};
}
const pad = (n, w) => String(n).padStart(w);
// 호표 비고 글 — 대가·산근 「대가 n호표」 · 그 밖 「노무 n」
const kindNo = (po, p, n) =>
p === "B" || p === "D" ? `${po.kind[p]}${pad(n, 4)}호표` : `${po.kind[p] || ""}${pad(n, 5)}`;
// 목록 첫 칸 호표 글 — 일위대가·산근 「제 n호표」 · 중기·자원 번호
function hopyoLabel(po, prefix, n) {
if (prefix === "B" || prefix === "D" || (prefix === "X" && po.padNo))
return `제${pad(n, 4)}호표`;
return po.padNo ? pad(n, 4) : String(n);
}
// 목록표 — 코드 종류(M·L·S·B·D·X)의 COSTN 전수 · 코드순
function listSheet(ctx, name, title, prefix) {
const { dbf, idx, ini, order, po } = ctx;
const sh = C.newSheet(name, title, C.projectName(dbf), "J");
const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" };
Object.assign(head, { D: "단위", I: "비 고", _k: "head" });
order.forEach((k, i) => (head[LIST_COLS[i]] = C.bimokLabel(ini, k)));
C.push(sh, head);
idx.ofPrefix(prefix).forEach((r, i) => {
const c = code(r);
const tot = ctx.totals[c] || idx.price(c, 1);
const lbl = po.codeMode ? c : hopyoLabel(po, prefix, i + 1);
const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
Object.assign(cells, { _k: "item", _code: c });
const v = { M: tot.M, L: tot.L, S: tot.S, T: tot.M + tot.L + tot.S };
order.forEach((k, j) => (cells[LIST_COLS[j]] = v[k]));
const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
if (rm) cells.I = rm;
C.push(sh, cells);
});
return sh;
}
// 자원 목록표(0101~0103) — 단가 한 칸
function resourceSheet(ctx, name, title, prefix) {
const { dbf, idx, po } = ctx;
const sh = C.newSheet(name, title, C.projectName(dbf), "G");
const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" };
C.push(sh, Object.assign(head, { D: "단위", E: "단 가", F: "비 고", _k: "head" }));
idx.ofPrefix(prefix).forEach((r, i) => {
const c = code(r);
const p = idx.price(c, 1);
const lbl = po.codeMode ? c : hopyoLabel(po, prefix, i + 1);
const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
Object.assign(cells, { E: fix(p.M + p.L + p.S), _k: "item", _code: c });
const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
if (rm) cells.F = rm;
C.push(sh, cells);
});
return sh;
}
// 줄 비고 — 코드(뒤 한 칸) · 호표(종류 이름 + 번호 5자리) · 가격지 위치 · 단가 0 이면 「별산」(SET_REP 0302 FLD_N3)
function lineRemark(ctx, ref, c, pct) {
const { po, hopyoNo } = ctx;
const zero = !pct && ref && ref.M + ref.L + ref.S === 0 ? "별산" : "";
if (po.codeMode) return `${zero}${c} `;
if (po.remarkHopyo) return `${zero}${kindNo(po, c[0], hopyoNo[c] || "")} `;
return ref && ref.row ? str(ref.row.BQCODE).trim() : "";
}
/**
* 구성 호표 시트 — 일위대가표(B · 0302) · 중기사용료(X · 0203)
* 호표 머리 → 구성 줄(단가·금액 쌍) → 합계(합계 줄 원 미만 절사 · 경비 지정이면 경비 한 칸)
* 총액우선이면 합계 줄에 끝수 전 합(단가 칸)도 적음 · 글 줄(코드 없음)은 수량 0 빈 줄(명칭은 BDQTY 암호 칸)
*/
function compositionSheet(ctx, name, title, prefix) {
const { dbf, idx, ini, order, po, hopyoNo } = ctx;
const isB = prefix === "B";
const sh = C.newSheet(name, title, C.projectName(dbf), "N");
const h3 = {
A: "명 칭",
B: "규 격",
C: "수 량",
D: "단위",
M: "비 고",
_k: "head",
};
const h4 = { _k: "head" };
order.forEach((k, i) => {
h3[PAIR_COLS[i][0]] = C.bimokLabel(ini, k);
h4[PAIR_COLS[i][0]] = "단 가";
h4[PAIR_COLS[i][1]] = "금 액";
});
C.push(sh, h3);
C.push(sh, h4);
let no = 0;
for (const r of idx.ofPrefix(prefix)) {
const c = code(r);
no += 1;
const wide = isB || po.padNo;
C.push(sh, { A: wide ? ` 제${pad(no, 5)} 호표` : `제 ${no} 호표`, _k: "hopyo" });
const hd = { A: str(r.NAME), B: str(r.SIZE), D: C.unitText(r.UNIT), _k: "parent", _code: c };
const rm = po.codeMode
? c
: po.remarkHopyo
? `${po.kind[prefix]}${pad(no, 5)}`
: str(r.BQCODE).trim();
if (rm) hd.M = rm;
C.push(sh, hd);
const ev = ctx.evals[c];
if (!ev) continue;
for (const ln of ev.lines) {
if (ln.text) {
C.push(sh, {
C: 0,
_k: "text",
M: po.remarkHopyo && !po.codeMode ? " " : undefined,
});
continue;
}
const ref = ln.ref;
const cells = { C: ln.qty, D: ln.unit, _k: "line", _code: ln.code };
if (ref) Object.assign(cells, { A: ref.name, B: ref.spec, D: ref.unitDisp });
if (ln.pct) cells.D = "%";
const u = { M: ln.u.M, L: ln.u.L, S: ln.u.S };
u.T = fix(u.M + u.L + u.S);
const a = { M: ln.a.M, L: ln.a.L, S: ln.a.S };
a.T = fix(a.M + a.L + a.S, 1);
order.forEach((k, i) => {
cells[PAIR_COLS[i][0]] = fix(u[k]);
cells[PAIR_COLS[i][1]] = fix(a[k], 1);
});
if (ln.code !== "PR") {
const rm2 = lineRemark(ctx, ref, ln.code, ln.pct);
if (rm2) cells.M = rm2;
}
C.push(sh, cells);
}
const tot = { A: ev.sbmokS ? "합 계(경비 로적용)" : "합 계", _k: "total", _code: c };
const raw = Object.assign({}, ev.raw, { T: fix(ev.raw.M + ev.raw.L + ev.raw.S, 4) });
const fin = Object.assign({}, ev.tot, { T: ev.T });
order.forEach((k, i) => {
if (ev.totalFirst) tot[PAIR_COLS[i][0]] = fix(raw[k], 4);
tot[PAIR_COLS[i][1]] = fin[k];
});
C.push(sh, tot);
}
return sh;
}
// 환율및기초자료(0201) — 환율 · 노임 시간 환산(CONLD · RXXNM) · 중기 구성에 쓰인 재료
function basicSheet(ctx) {
const { dbf, idx, ini, groups, po, hopyoNo } = ctx;
const rmk = (c) => (po.remarkHopyo && !po.codeMode ? kindNo(po, c[0], hopyoNo[c]) : c);
const sh = { name: "환율및기초자료", title: "1. 환 율", rows: [], cols: {} };
C.put(sh, 1, { A: "1. 환 율", _k: "title" });
C.put(sh, 2, { C: "100,000$ 미만", D: "100,000$ 이상", E: "유로화(€)", G: "엔화(100¥)" });
sh.rows[1].I = "환율및기초자료";
const e3 = { C: num(ini.EXLNM1), D: num(ini.EXHNM1), E: num(ini.EXEUR1), G: num(ini.EXYEN1) };
C.put(sh, 3, e3);
C.put(sh, 5, { A: "2. 인 건 비" });
const labor = String(ini.CONLD || "").match(/.{1,6}/g) || [];
const rx = String(ini.RXXNM || "");
let r = 6;
const lab = labor.filter((c) => idx.by[c]);
for (const c of lab) {
const p = idx.price(c, 1);
const daily = p.M + p.L + p.S;
let acc = daily;
for (const [, op, n] of rx.replace(/\s+/g, "").matchAll(/([*/])?(\d+(?:\.\d+)?)/g))
acc = op === "/" ? acc / Number(n) : acc * Number(n);
C.put(sh, r++, {
C: p.name,
D: daily,
E: `* ${rx} =`,
G: C.trunc(acc),
H: rmk(c),
_code: c,
});
}
r += 1;
C.put(sh, r++, { A: "3. 단가 및 재료비" });
C.put(sh, r++, { B: "NO", C: "단 가 명", D: "규 격", E: "단위", F: "가 격", H: "비 고" });
// 재료 = 중기(X) 구성에 쓰인 재료비(M) 코드 · 코드순
const mats = new Set();
for (const [p, rows] of Object.entries(groups))
if (p[0] === "X")
for (const g of rows) {
const c = String(g.CODE).trim();
if (/^M/.test(c) && idx.by[c] && !/%$/.test(str(idx.by[c].UNIT).trim())) mats.add(c);
}
const items = lab.concat([...mats].filter((c) => idx.by[c]).sort());
items.forEach((c, i) => {
const p = idx.price(c, 1);
const cells = {
B: String(i + 1),
C: p.name,
E: p.unitDisp,
F: p.M + p.L + p.S,
H: rmk(c),
_code: c,
};
if (p.spec.trim()) cells.D = p.spec;
C.put(sh, r++, cells);
});
return sh;
}
/**
* 단가 맥락 — 구성 호표(B·X) 줄별 계산 결과와 목록표 합계
* 구성 없는 호표 = COSTN 단가 그대로
*/
function unitContext(dbf) {
const idx = C.costnIndex(dbf);
const ini = iniOf(dbf);
const order = C.bimokOrder(ini);
const groups = groupsOf(dbf);
const lookup = lookupOf(idx);
const evals = {},
totals = {};
for (const p of ["X", "B"])
for (const r of idx.ofPrefix(p)) {
const c = code(r);
if (!groups[c]) continue;
const ev = C.compositionLines(groups[c], lookup, compOptions(dbf, r));
evals[c] = ev;
totals[c] = ev.tot;
}
// 호표 번호 = 종류별 코드순 자리(1부터)
const hopyoNo = {};
for (const p of "LMSBXD") idx.ofPrefix(p).forEach((r, i) => (hopyoNo[code(r)] = i + 1));
const po = printOptions(ini);
return { dbf, idx, ini, order, groups, evals, totals, hopyoNo, po };
}
function buildUnitSheets(dbf) {
const ctx = unitContext(dbf);
const F = (sh, indx) => C.applyForm(sh, dbf, indx);
return {
ctx,
일위대가목록표: F(listSheet(ctx, "일위대가목록표", "일위대가 목록표", "B"), "0301"),
일위대가표: F(compositionSheet(ctx, "일위대가표", "일 위 대 가 표", "B"), "0302"),
단가산출근거목록표: F(
listSheet(ctx, "단가산출근거목록표", "단가산출근거 목록표", "D"),
"0401",
),
환율및기초자료: basicSheet(ctx),
중기목록표: F(listSheet(ctx, "중기목록표", "중기 목록표", "X"), "0202"),
중기사용료: F(compositionSheet(ctx, "중기사용료", "중 기 사 용 료", "X"), "0203"),
재료비목록표: F(resourceSheet(ctx, "재료비목록표", "재료비 목록표", "M"), "0101"),
노무비목록표: F(resourceSheet(ctx, "노무비목록표", "노무비 목록표", "L"), "0102"),
경비목록표: F(resourceSheet(ctx, "경비목록표", "경비 목록표", "S"), "0103"),
};
}
return { buildUnitSheets, unitContext, compOptions, LIST_COLS, PAIR_COLS };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_UNIT = StmateReportUnit;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportUnit;