- 보고서: 원본 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>
386 lines
14 KiB
JavaScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
// 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;
|