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

386 lines
14 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* 조달청 공사원가호환규정 XML (report_pps.js) — 스키마 V1.0.5 한글 요소 이름 그대로
* 근거: 지식DB STmate/old/20_분석 30(조달청 호환규정 XSD) · old/30_원자료/조달청_공사원가호환규정.xsd
* 28 §3(Conv_ST8_XML 변환) · 20 §2.2(간접비 코드 301~370)
* 정답 대조: resources/tester/test_stmate_report_pps.py (XSD 검증 + 금액 합 = 원가계산서)
* 짜임: 공사원가호환규정 → 작성정보 · 공사정보(기계경비산출) · 공사내역서(공사제경비 · 단일공사내역서)
*/
const StmateReportPps = (() => {
const g = typeof globalThis !== "undefined" ? globalThis : {};
const C = g.STMATE_REPORT_CORE || require("./report_core.js");
const U = g.STMATE_REPORT_UNIT || require("./report_unit.js");
const B = g.STMATE_REPORT_BOQ || require("./report_boq.js");
const { num, str, code } = C;
const esc = (s) =>
String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// normalizedString — 줄바꿈·탭 없음 · 길이 제한(XSD maxLength)
const ns = (s, max) =>
String(s ?? "")
.replace(/[\r\n\t]+/g, " ")
.trim()
.slice(0, max);
const codeOf = (s, max) =>
String(s ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, max);
const dbl = (v) => {
const n = Number(v);
return Number.isFinite(n) ? String(C.fix(n, 6)) : "0";
};
const tag = (name, inner) => `<${name}>${inner}</${name}>`;
const leaf = (name, v) => `<${name}>${esc(v)}</${name}>`;
const RES = { M: "자재", L: "노무", S: "경비항목" };
// 자원구분 — 기계경비상세는 손료(경비 천원 단위)·기계경비 갈래 · 그 밖은 일위대가
const resKind = (c, machine, unit) =>
machine && c[0] === "S" && unit === "천원"
? "손료"
: RES[c[0]] || (machine ? "기계경비" : "일위대가");
// 단가 3칸 + 금액 3칸
const money = (u, a) =>
leaf("재료비단가", dbl(u.M)) +
leaf("노무비단가", dbl(u.L)) +
leaf("경비단가", dbl(u.S)) +
leaf("재료비", dbl(a.M)) +
leaf("노무비", dbl(a.L)) +
leaf("경비", dbl(a.S));
// 구성 줄 — 일위대가상세 · 기계경비상세
function detailLines(ev, machine) {
const nm = machine ? "기계경비상세" : "일위대가상세";
return ev.lines
.filter((ln) => !ln.text)
.map((ln) => {
const c = ln.code === "PR" ? "PR" : ln.code;
const ref = ln.ref || {};
let body =
leaf("자원구분", resKind(c, machine, String(ref.unit || "").trim())) +
leaf("자원코드", codeOf(c, 17) || "PR") +
leaf("계산제외", "false") +
leaf("품명", ns(ref.name || "비율 항목", 50)) +
leaf("규격", ns(ref.spec || "", 100)) +
leaf("단위", ns(ln.pct ? "%" : ref.unitDisp || ln.unit || "", 10)) +
leaf("수량", dbl(ln.qty)) +
money(ln.u, ln.a);
if (!machine) body += leaf("관급", "false");
return tag(nm, body + leaf("비고", ""));
})
.join("");
}
// 공사제경비 — 원가계산서 줄(간접비 코드 · 식 설명 · 요율 · 금액)
function costItems(stage) {
const k = stage.kanjub;
const rates = (stage.cfg && stage.cfg.rates) || {};
const DTP = { KANNO: 201, SANBO: 301, KOBO: 302, KUNBO: 303, SLVBO: 310, YUNBO: 304 };
Object.assign(DTP, { TBUGUM: 305, ANJUN: 306, KITA: 321, HWAN: 309, EQIP: 311 });
const out = [];
const item = (dtp, sum, name, formula, rate, add, amount, desc) =>
out.push(
tag(
"제경비항목",
leaf("세부공종코드", String(dtp)) +
leaf("집계항목여부", sum ? "true" : "false") +
leaf("비목명", ns(name, 50)) +
leaf("적용산식", ns(formula, 200)) +
leaf("적용요율", dbl(rate)) +
leaf("추가금액", dbl(add)) +
leaf("산출금액", dbl(amount)) +
leaf("적용산식설명", ns(desc, 200)),
),
);
item(100, true, "순공사비계", "재료비+노무비+경비", 0, 0, k.dT, "직접공사비");
for (const [cd, short] of B.ITEMS) {
const it = k.itemMap[cd];
if (!it || !it.amount) continue;
const add = cd === "ANJUN" ? num(stage.cfg.anjunBaseAdd) : 0;
item(
DTP[cd],
false,
short,
`${it.baseDesc || ""} x ${it.rate}%`,
it.rate,
add,
it.amount,
it.baseDesc || "",
);
}
item(400, true, "소계", "순공사원가", 0, 0, k.pureConstructionCost, "재료비+노무비+경비");
item(
401,
false,
"일반관리비",
`순공사원가 x ${num(rates.ILBAN)}%`,
num(rates.ILBAN),
0,
k.ilbanAmt,
k.ilbanDesc || "",
);
item(
402,
false,
"이윤",
`(노무비+경비+일반관리비) x ${num(rates.IYUN)}%`,
num(rates.IYUN),
0,
k.iyunFinal,
k.iyunDesc || "",
);
item(500, true, "공급가액", "순공사원가+일반관리비+이윤", 0, 0, k.supplyFinal, "총원가");
item(502, false, "부가가치세", "공급가액 x 10%", 10, 0, k.vatFinal, "부가가치세");
item(600, true, "도급공사비", "공급가액+부가가치세", 0, 0, k.contractFinal, "도급액");
if (k.govFinal) item(653, false, "관급자재대", "관급자재 합계", 0, 0, k.govFinal, "관급자재");
item(700, true, "총공사비", "도급공사비+관급자재대", 0, 0, k.grandTotal, "총공사비");
return tag("공사제경비", out.join(""));
}
// 내역서 — 공종(T 묶음)마다 하나 · 내역서번호 = 단계별 두 자리 · 세부공종 = 직속 품목
function boqXml(tree, idx) {
const out = [];
const walk = (node, prefix, govFlag, exFlag) => {
let i = 0;
const items = node.children.filter((c) => c.kind === "item");
if (node.kind === "T" && node.row) {
const r = node.row;
const ex = exFlag || node.excluded;
const gov = govFlag || (node.excluded && /관급/.test(str(r.NAME1)));
const kind = ex ? (gov ? "관급자재공종" : "제요율적용제외공종") : "일반공종";
const sub = items
.map((it) => {
const u = it.unitP,
a = it.amt;
return tag(
"세부공종",
leaf("세부공종코드", codeOf(it.code, 17)) +
leaf("자원구분", resKind(it.code, false)) +
leaf("계산제외", "false") +
leaf("세부공종번호", ns(it.row.ITNUM, 20)) +
leaf("세부공종명", ns(it.row.NAME1, 50)) +
leaf("규격", ns(it.row.SIZE1, 100)) +
leaf("단위", ns(C.unitText(it.row.UNIT1), 10)) +
leaf("수량", dbl(it.qty)) +
money(u, a) +
leaf("관급", gov ? "true" : "false") +
leaf("자재구분", "구분없음") +
leaf("세부공종구분", "일반내역") +
leaf("비고", ns((idx.by[it.code] && idx.by[it.code].BQCODE) || "", 50)),
);
})
.join("");
out.push(
tag(
"내역서",
leaf("내역서번호", prefix) +
leaf("공종구분", kind) +
leaf("계산제외", "false") +
leaf("공종번호", ns(r.ITNUM, 20)) +
leaf("공종명", ns(r.NAME1 || r.SIZE1 || "공종", 50)) +
leaf("재료비", dbl(node.sum.M)) +
leaf("노무비", dbl(node.sum.L)) +
leaf("경비", dbl(node.sum.S)) +
leaf("비고", "") +
sub,
),
);
govFlag = gov;
exFlag = ex;
}
for (const ch of node.children)
if (ch.kind === "T") walk(ch, prefix + String(++i).padStart(2, "0"), govFlag, exFlag);
};
walk(tree.root, "", false, false);
return out.join("");
}
/**
* XML 문서 — dbf(불러온 자료) · state · computed(calculateAll) · n 차수
* opts.today = 파일제작일(YYYY-MM-DD) · opts.company = 작성업체명
*/
function buildPpsXml(dbf, state, computed, n = 1, opts = {}) {
const ctx = U.unitContext(dbf);
const { idx, ini } = ctx;
const stage = B.stageOf(state, computed, n);
const design = B.designSheet(dbf, stage);
const tree = design._tree;
const pr = (dbf && dbf.prjlst) || {};
const pname = ns(C.projectName(dbf) || "공사", 100);
const today = opts.today || new Date().toISOString().slice(0, 10);
const rx = String(ini.RXXNM || "1/8*16/12*25/20");
const coef = C.fix(eval1(rx), 8);
const writer = tag(
"작성정보",
leaf("작성업체명", ns(opts.company || ini.SUBNM || "작성업체", 50)) +
leaf("프로그램명", "STMATE w 8.0 재현") +
leaf("프로그램버전", "8.0") +
leaf("파일제작일", today) +
leaf("파일검증여부", "false"),
);
const mach = tag(
"기계경비산출",
tag(
"환율",
leaf("미국_1USD", dbl(num(ini.EXLNM1))) +
leaf("유럽연합_1EUR", dbl(num(ini.EXEUR1))) +
leaf("일본_100엔", dbl(num(ini.EXYEN1))),
) +
tag(
"시간당노임산출",
tag(
"H0-일반구간",
leaf("산출식", ns(rx, 200)) + leaf("계산값", dbl(coef)) + leaf("적용구분", "산출식"),
),
),
);
const info = tag(
"공사정보",
leaf("공사관리번호", ns(pr.PNUM || pr.PCODE || "0", 20) || "0") +
leaf("주공사", "토목공사") +
leaf("공사명", pname) +
leaf("비고", "") +
mach,
);
const head = (c, r, tot, extra) =>
leaf(extra, codeOf(c, extra === "기계경비코드" ? 17 : 12)) +
leaf("번호", String(ctx.hopyoNo[c] || 1)) +
leaf("품명", ns(r.NAME, 50) || c) +
leaf("규격", ns(r.SIZE, 100)) +
leaf("단위", ns(C.unitText(r.UNIT), 10));
const ilwi = idx
.ofPrefix("B")
.map((r) => {
const c = code(r),
ev = ctx.evals[c],
t = ctx.totals[c] || idx.price(c, 1);
const bm = str(r.SBMOK).trim() === "S" ? "경비" : "합계";
return tag(
"일위대가",
head(c, r, t, "일위대가코드") +
leaf("재료비", dbl(t.M)) +
leaf("노무비", dbl(t.L)) +
leaf("경비", dbl(t.S)) +
leaf("비목구분", bm) +
leaf("비고", ns(r.BQCODE, 50)) +
(ev ? detailLines(ev, false) : ""),
);
})
.join("");
const memos = (dbf && dbf.costn_memos) || {};
const san = idx
.ofPrefix("D")
.map((r) => {
const c = code(r),
p = idx.price(c, 1);
return tag(
"중기단가산출",
head(c, r, p, "중기단가산출코드") +
leaf("재료비", dbl(p.M)) +
leaf("노무비", dbl(p.L)) +
leaf("경비", dbl(p.S)) +
leaf("비목구분", "합계") +
leaf("비고", ns(r.BQCODE, 50)) +
leaf("중기단가산출식", (memos[c] && memos[c].text) || ""),
);
})
.join("");
const machine = idx
.ofPrefix("X")
.map((r) => {
const c = code(r),
ev = ctx.evals[c],
t = ctx.totals[c] || idx.price(c, 1);
return tag(
"기계경비",
head(c, r, t, "기계경비코드") +
leaf("작업시간", "E0") +
leaf("작업장소", "H0") +
leaf("재료비", dbl(t.M)) +
leaf("노무비", dbl(t.L)) +
leaf("경비", dbl(t.S)) +
leaf("비고", ns(r.BQCODE, 50)) +
(ev ? detailLines(ev, true) : ""),
);
})
.join("");
const base = (r) =>
leaf("품명", ns(r.NAME, 50) || code(r)) +
leaf("규격", ns(r.SIZE, 100)) +
leaf("단위", ns(C.unitText(r.UNIT), 10));
const res = (p, nm, codeTag, body) =>
idx
.ofPrefix(p)
.map((r) => tag(nm, leaf(codeTag, codeOf(code(r), 16)) + base(r) + body(r)))
.join("");
const pv = (r) => idx.price(code(r), 1);
const mats = res(
"M",
"자재",
"자재코드",
(r) => leaf("재료비단가", dbl(pv(r).M + pv(r).L + pv(r).S)) + leaf("비고", ns(r.BQCODE, 50)),
);
const labs = res(
"L",
"노무",
"노무코드",
(r) => leaf("노무비단가", dbl(pv(r).M + pv(r).L + pv(r).S)) + leaf("비고", ns(r.BQCODE, 50)),
);
const exps = idx
.ofPrefix("S")
.map((r) => {
const p = pv(r);
const machineCost = str(r.UNIT).trim() === "천원";
return tag(
"경비항목",
leaf("경비항목코드", codeOf(code(r), 16)) +
leaf("경비구분", machineCost ? "건설기계" : "일반경비") +
base(r) +
leaf("통화구분", "대한민국_천원") +
leaf("재료비단가", dbl(p.M)) +
leaf("노무비단가", dbl(p.L)) +
leaf("경비단가", dbl(p.S)) +
leaf("비고", ns(r.BQCODE, 50)),
);
})
.join("");
const single = tag(
"단일공사내역서",
tag("단일공사정보", leaf("공사구분", "토목공사") + leaf("공사설명", pname)) +
boqXml(tree, idx) +
ilwi +
san +
machine +
mats +
labs +
exps,
);
return (
'<?xml version="1.0" encoding="UTF-8"?>\n' +
tag(
"공사원가호환규정",
leaf("스키마버전", "1.0.5") + writer + info + tag("공사내역서", costItems(stage) + single),
)
);
}
// 노임 환산 계수 — 식을 왼쪽부터 곱·나눔(Start8x.Ini RXXNM)
function eval1(expr) {
let acc = 1;
for (const [, op, n] of String(expr || "")
.replace(/\s+/g, "")
.matchAll(/([*/])?(\d+(?:\.\d+)?)/g))
acc = op === "/" ? acc / Number(n) : acc * Number(n);
return acc;
}
return { buildPpsXml, eval1 };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_PPS = StmateReportPps;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportPps;