/* -*- coding: utf-8 -*- */ /** * 내역 보고서 (report_boq.js) — 설계내역서(0603) · 총괄내역서(0601) · 공사원가계산서(0608) * 근거: 지식DB STmate/old/20_분석 20 §2.2(간접비 코드 순서 · 0원 항목 빠짐) · §2.6(DISP_EX 제외금액) * 16 §4(BOQ 계층 T·E·C) · 06 §3(내역 절사) · 25 §2(간접비 식 설명) * 정답 대조: resources/tester/test_stmate_report_boq.py (원본 STMATE 출력 엑셀) * 금액 = 계산 엔진(calc.js calculateAll) 차수별 결과 · 구조(공종 계층·순서) = BOQ11.DBF */ const StmateReportBoq = (() => { const C = (typeof globalThis !== "undefined" && globalThis.STMATE_REPORT_CORE) || require("./report_core.js"); const { num, str, trunc, fix, commas } = C; const PAIR = [ ["F", "G"], ["H", "I"], ["J", "K"], ["L", "M"], ]; const STRUCT = /^(T\d|E\d|C\d|:)$/; const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" }; const rateText = (r) => String(Number(num(r).toFixed(5))); /** * 공종 나무 — SORTCODE 순 · T 행이 ENDCODE 의 E/C 행까지 묶음 · C 로 닫히면 제외금액 구역 * 묶음 금액 = 직속 품목 + 제외 아닌 하위 묶음 (원본 엑셀 SUMIF 와 같음) * 비율 행(PR · 단위 %) = 같은 묶음의 표시(PRPT) 품목 금액 × 율 / 100 원 미만 절사 */ function boqTree(dbf, stageBoq, slot) { const raw = ((dbf && dbf.boq_rows) || []) .slice() .sort((a, b) => num(a.SORTCODE) - num(b.SORTCODE)); const calcBy = {}; for (const r of (stageBoq && stageBoq.rows) || []) calcBy[r._recno] = r; const root = { kind: "T", children: [], sum: { M: 0, L: 0, S: 0 }, end: null }; const stack = [root]; const out = []; let finalRow = null; for (const r of raw) { const b = str(r.BDCODE1).trim(); const top = stack[stack.length - 1]; if (/^T\d$/.test(b)) { const node = { kind: "T", row: r, level: Number(b[1]), children: [], parent: top }; Object.assign(node, { sum: { M: 0, L: 0, S: 0 }, end: num(r.ENDCODE), excluded: false }); top.children.push(node); stack.push(node); out.push(node); continue; } if (/^[EC]\d$/.test(b)) { const at = stack.findIndex((n) => n.end === num(r.SORTCODE)); if (at > 0) { const node = stack[at]; if (b[0] === "C") node.excluded = true; stack.length = at; if (at === 1) finalRow = { kind: "final", node: root }; } continue; } if (!b || b === ":") { out.push({ kind: b === ":" ? "page" : "blank", row: r }); continue; } const item = { kind: "item", row: r, parent: top, code: b }; const unit = str(r.UNIT1).trim(); if (b === "PR" || /%$/.test(unit)) { item.pct = true; const inK = COMP[num(r.PRIN)] || "M", outK = COMP[num(r.PROUT)] || (b[0] === "L" ? "L" : b[0] === "S" ? "S" : "M"); const sibs = top.children.filter((c) => c.kind === "item" && !c.pct); const marked = sibs.filter((c) => str(c.row.PRPT).trim() === "1"); const base = (marked.length ? marked : sibs).reduce( (s, c) => s + (inK === "T" ? c.amt.M + c.amt.L + c.amt.S : c.amt[inK]), 0, ); const q = num(r[`QTY${slot}`]) || num(r.QTY1); item.qty = q; item.unitP = { M: 0, L: 0, S: 0 }; item.amt = { M: 0, L: 0, S: 0 }; const k = outK === "T" ? "M" : outK; item.unitP[k] = base; item.amt[k] = trunc((base * q) / 100); } else { const c = calcBy[r._recno]; item.qty = c ? c.qty : num(r[`QTY${slot}`]); item.unitP = c ? { M: c.mUnit, L: c.lUnit, S: c.sUnit } : { M: 0, L: 0, S: 0 }; item.amt = c ? { M: c.mAmt, L: c.lAmt, S: c.sAmt } : { M: 0, L: 0, S: 0 }; } top.children.push(item); out.push(item); } // 묶음 금액 — 아래에서 위로 const sumOf = (node) => { const s = { M: 0, L: 0, S: 0 }; for (const ch of node.children) { const v = ch.kind === "T" ? sumOf(ch) : ch.amt; if (ch.kind === "T" && ch.excluded) continue; for (const k of "MLS") s[k] += v[k]; } node.sum = s; return s; }; sumOf(root); return { out, root, finalRow }; } // 품목 비고 — 코드 모드(SH_TYP1=0) 코드 · 호표 모드 = 대가·산근·중기는 「산근 n호표」 · 자원은 가격지 위치(BQCODE) // (SH_TYP3=1 이면 자원도 「자재 n」) · 호표 번호 = 종류별 코드순 자리 function itemRemark(dbf, idx, b) { const ini = (dbf && dbf.start_ini) || {}; if (b === "PR") return ""; if (String(ini.SH_TYP1 ?? "0") === "0") return b; const r = idx.by[b]; if (!r) return ""; const kind = String(ini[`COD_FMT_${"LMSBXD".indexOf(b[0]) + 1}`] || ""); const no = idx.ofPrefix(b[0]).findIndex((x) => C.code(x) === b) + 1; if ("BDX".includes(b[0])) return `${kind}${String(no).padStart(4)}호표`; if (String(ini.SH_TYP3 ?? "2") === "1") return `${kind}${String(no).padStart(5)}`; return str(r.BQCODE).trim(); } // 설계내역서(0603) — 계(E)·총계(C) 줄은 묶음 머리 금액으로 올라가고 끝에 총합계 금액 줄 function designSheet(dbf, stage, opts = {}) { const ini = (dbf && dbf.start_ini) || {}; const order = C.bimokOrder(ini); const idx = C.costnIndex(dbf); const name = opts.name || "설계내역서"; const sh = C.newSheet(name, "설 계 내 역 서", C.projectName(dbf), "O"); const h3 = { A: "공 종", B: "명 칭", C: "규 격", D: "수 량", E: "단위" }; Object.assign(h3, { N: "비 고", _k: "head" }); const h4 = { _k: "head" }; order.forEach((k, i) => { h3[PAIR[i][0]] = C.bimokLabel(ini, k); h4[PAIR[i][0]] = "단 가"; h4[PAIR[i][1]] = "금 액"; }); C.push(sh, h3); C.push(sh, h4); const tree = boqTree(dbf, stage.boq, stage.slot || 1); const dispEx = `(${str(ini.DISP_EX || "제외금액").trim()})`; const fill = (cells, unitP, amt, zeroUnit) => { const u = { M: unitP.M, L: unitP.L, S: unitP.S, T: unitP.M + unitP.L + unitP.S }; const a = { M: amt.M, L: amt.L, S: amt.S, T: amt.M + amt.L + amt.S }; order.forEach((k, i) => { cells[PAIR[i][0]] = zeroUnit ? 0 : fix(u[k]); cells[PAIR[i][1]] = fix(a[k]); }); }; // 쪽 넘김(:) — 빈 줄 하나에 쪽 나눔 표시(인쇄 미리보기에서 다음 쪽으로) for (const n of tree.out) { const r = n.row; if (n.kind === "page") { C.push(sh, { _k: "page" }); continue; } if (n.kind === "blank") { C.push(sh, { F: 0, _k: "blank" }); continue; } const cells = { A: str(r.ITNUM).trim(), B: str(r.NAME1), C: str(r.SIZE1) }; if (n.kind === "T") { Object.assign(cells, { _k: `T${n.level}` }); fill(cells, { M: 0, L: 0, S: 0 }, n.sum, true); if (n.excluded) cells.N = dispEx; } else { Object.assign(cells, { D: fix(n.qty), E: C.unitText(r.UNIT1), _k: "item", _code: n.code }); fill(cells, n.unitP, n.amt, false); const rm = itemRemark(dbf, idx, n.code); if (rm) cells.N = rm; } C.push(sh, cells); } if (tree.finalRow) { const cells = { _k: "final" }; const s = tree.finalRow.node.sum; fill(cells, { M: 0, L: 0, S: 0 }, s, true); for (const [u] of PAIR) delete cells[u]; C.push(sh, cells); } sh._tree = tree; return sh; } // 간접비 항목 — 지식DB 20 §2.2 순서(KAN_ORD_1) · 이름 · 식 설명(25 §2) const ITEMS = [ ["KANNO", "간접노무비", "간 접 노 무 비", "직접노무비"], ["SANBO", "산재보험료", "산 재 보 험 료", "(직접노무비+간접노무비)"], ["KOBO", "고용보험료", "고 용 보 험 료", "(직접노무비+간접노무비)"], ["KUNBO", "건강보험료", "건 강 보 험 료", "직접노무비"], ["SLVBO", "노인장기요양보험료", "노인장기요양보험료", "건강보험료"], ["YUNBO", "연금보험료", "연 금 보 험 료", "직접노무비"], ["TBUGUM", "퇴직공제부금비", "퇴직공제부금비", "직접노무비"], ["ANJUN", "산업안전보건관리비", "산업안전보건관리비", ""], ["KITA", "기타경비", "기 타 경 비", "(직접노무비+간접노무비+재료비)"], ["HWAN", "환경보전비", "환 경 보 전 비", "(재료비 + 직접노무비 + 산출경비)"], [ "EQIP", "건설기계대여금지급보증 금액", "건설기계대여금지급보증 금액", "(재료비 + 직접노무비 + 산출경비)", ], ]; // 안전관리비 설명 — A) 관급재/1.1 포함 · B) 관급 빼고 1.2배 상한(지식DB 06) function anjunNote(k) { const it = k.itemMap.ANJUN; if (!it) return ""; if (it.modeUsed === "fixed") return ""; const r = it.rate, add = num(k._anjunAdd); const noGov = k.dM + k.dL; const a = trunc((k.anjunBaseRaw * r) / 100) + add; const b = trunc((trunc((noGov * r) / 100) + add) * 1.2); return ( `A)(직노+직재+간재+관급재/1.1)x${rateText(r)}% + ${commas(add)} = ${commas(a)}\n` + `B)<(직노+직재+간재)x${rateText(r)}% + ${commas(add)}>의 1.2배 = ${commas(b)}` ); } /** * 원가 줄 — 순공사비 → 간접비(0원 빠짐) → 소계 → 일반관리비 → 이윤 → 단계별 추가금액 → 부가세 → 도급 → 관급 → 총공사비 * fixedKeys = D_ 표시(저장액 고정) 항목 — 식 설명 없음 */ function costLines(k, cfg) { const fixedKeys = new Set(Object.keys((cfg && cfg.fixed) || {})); const govMat = k.govRawTotal + k.govFeeTotal; const exp = []; for (const [code, short, long, base] of ITEMS) { const it = k.itemMap[code]; if (!it || !it.amount) continue; let note = ""; if (!fixedKeys.has(code)) note = code === "ANJUN" ? anjunNote(k) : `${base} x ${rateText(it.rate)}%`; exp.push({ code, short, long, amount: it.amount, rate: it.rate, note }); } const adds = (st) => (k.adds || []).filter((a) => a.stage === st && num(a.amount)); return { exp, adds, govMat, fixedKeys }; } // 총괄내역서(0601) — 공종 묶음 줄 + 원가 계산 줄 function summarySheet(dbf, stage, design, opts = {}) { const ini = (dbf && dbf.start_ini) || {}; const order = C.bimokOrder(ini); const cols = ["D", "E", "F", "G"]; const name = opts.name || "총괄내역서"; const sh = C.newSheet(name, "총 괄 내 역 서", C.projectName(dbf), "H"); const h3 = { A: "공 종", B: "명 칭", C: "규 격", _k: "head" }; order.forEach((k, i) => (h3[cols[i]] = C.bimokLabel(ini, k))); C.push(sh, h3); const minLevel = opts.minLevel ?? 2; const put4 = (cells, s) => { const v = { M: s.M, L: s.L, S: s.S, T: s.M + s.L + s.S }; order.forEach((k, i) => (cells[cols[i]] = v[k])); }; for (const n of design._tree.out) if (n.kind === "T" && n.level >= minLevel) { const cells = { A: str(n.row.ITNUM).trim(), B: str(n.row.NAME1), C: str(n.row.SIZE1) }; put4(Object.assign(cells, { _k: `T${n.level}` }), n.sum); C.push(sh, cells); } const k = stage.kanjub; const L = costLines(k, stage.cfg); const direct = design._tree.finalRow ? design._tree.finalRow.node.sum : { M: k.dM, L: k.dL, S: k.dS }; const ga = { A: " 가.", B: "순공사비계", _k: "sub" }; put4(ga, direct); C.push(sh, ga); let no = 0; const line = (label, amount, note, extra) => { no += 1; const cells = { B: `${String(no).padStart(2)}. ${label}`, D: amount, _k: "cost" }; if (note) cells.E = note; return C.push(sh, Object.assign(cells, extra || {})); }; const sub = (a, label, amount) => C.push(sh, { A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" }); line( "간접노무비", k.kanno, L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`, ); for (const e of L.exp.filter((x) => x.code !== "KANNO")) line( e.short, e.amount, e.note, e.code === "ANJUN" && L.govMat > 0 && !L.fixedKeys.has("ANJUN") ? { C: "A)관급재/1.1포함 적용" } : null, ); for (const a of L.adds(1)) line(a.name, num(a.amount)); const na = k.pureConstructionCost; sub("나.", "소 계", na); line( "일반관리비", k.ilbanAmt, L.fixedKeys.has("ILBAN") ? "" : `(나.소 계) x ${rateText(num(stage.cfg?.rates?.ILBAN))}%`, ); sub("다.", "소 계", na + k.ilbanAmt); const iyunR = rateText(num(stage.cfg?.rates?.IYUN)); line("이 윤", k.iyunFinal, `(다.소계 - 재료비) x ${iyunR}% = ${commas(k.iyunRaw)}`); for (const a of L.adds(2)) line(a.name, num(a.amount)); sub("라.", "공급가액", k.supplyFinal); const vn = vatNote(stage.cfg); line("부가가치세", k.vatFinal, vn === "총 원 가 x 10%" ? "공급가액 x 10%" : vn); for (const a of L.adds(3)) line(a.name, num(a.amount)); sub("마.", "도급공사비", k.contractFinal); if (k.govFinal) line( govLabel(dbf, stage, false), k.govFinal, `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`, ); for (const a of L.adds(4)) line(a.name, num(a.amount)); sub("바.", "총공사비", k.grandTotal); return sh; } // 부가세 설명 — I_BUGA 갈래(공급가액 · 산림조합 · 면세품 · 재료비) // 설정 두 꼴 — 옛 vatMode/vatExempt · 새 keys(I_BUGA · C_BUGA_M 원키) const VAT_MODES = { 0: "supply_10", 1: "mat_10", 2: "zero", 3: "forest", 4: "forest_exempt" }; function vatNote(cfg) { const keys = (cfg && cfg.keys) || {}; const m = (cfg && cfg.vatMode) || ("I_BUGA" in keys ? VAT_MODES[num(keys.I_BUGA)] : "") || "supply_10"; const exempt = cfg && cfg.vatExempt != null ? num(cfg.vatExempt) : num(keys.C_BUGA_M); if (m === "forest_exempt") return `(재료비 - 면세품목:₩${commas(exempt)} + 산출경비) x 10%`; if (m === "forest") return "(재료비 + 산출경비) x 10%"; if (m === "mat_10") return "재료비 x 10%"; return m === "zero" ? "" : "총 원 가 x 10%"; } // 관급 이름 — 25 판부터 도급자 설치 관급(C_KWANGUP_D)은 「관급자재대(도급자설치)」(울진 2025 출력) function govLabel(dbf, stage, wide) { const ini = (dbf && dbf.start_ini) || {}; const kv = (dbf && dbf.prjlst_smt) || {}; const d = num(kv[`${stage.slot}_C_KWANGUP_D`]); if (num(ini.PRG_V) >= 25 && d > 0) return "관급자재대(도급자설치)"; return wide ? "관 급 자 재 대" : "관급자재대"; } // 원가계산서 줄 — 차수 하나의 { key, 칸, 금액, 설명 } function wonRows(dbf, stage) { const k = stage.kanjub; const L = costLines(k, stage.cfg); const rows = []; const add = (key, cells, amount, note) => rows.push({ key, cells, amount, note: note || "" }); add("dM", { A: "순 공 사 원 가", B: "재 료 비", C: "직 접 재 료 비" }, k.dM); add("iM", { C: "간 접 재 료 비" }, 0); add("by", { C: "작업설,부산물등(△)" }, 0); add("sM", { C: "소 계" }, k.dM); add("dL", { B: "노 무 비", C: "직 접 노 무 비" }, k.dL); const kn = L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`; add("KANNO", { C: "간 접 노 무 비" }, k.kanno, kn); add("sL", { C: "소 계" }, k.totalLabor); add("dS", { B: "경 비", C: "산 출 경 비" }, k.dS); for (const e of L.exp.filter((x) => x.code !== "KANNO")) { const note = e.code === "ANJUN" && stage.cfg.anjunAuto === 2 && stage.slot > 1 ? "당초금액 + 안전관리비 증감액" : e.note; add(e.code, { C: e.long }, e.amount, note); } for (const a of L.adds(1)) add(`A1${a.name}`, { C: a.name }, num(a.amount)); add("sS", { C: "소 계" }, k.totalExpense); const ilR = rateText(num(stage.cfg?.rates?.ILBAN)); add( "ILBAN", { A: "일 반 관 리 비" }, k.ilbanAmt, L.fixedKeys.has("ILBAN") ? "" : `순공사원가 x ${ilR}%`, ); const iyR = rateText(num(stage.cfg?.rates?.IYUN)); add("IYUN", { A: "이 윤" }, k.iyunFinal, `(노무비+경비+일반관리비) x ${iyR}%`); for (const a of L.adds(2)) add(`A2${a.name}`, { A: a.name }, num(a.amount)); add("SUP", { A: "총 원 가" }, k.supplyFinal); add("VAT", { A: "부 가 가 치 세" }, k.vatFinal, vatNote(stage.cfg)); for (const a of L.adds(3)) add(`A3${a.name}`, { A: a.name }, num(a.amount)); add("CON", { A: "도 급 공 사 비" }, k.contractFinal); if (k.govFinal) { const gn = `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`; add("GOV", { A: govLabel(dbf, stage, true) }, k.govFinal, gn); } for (const a of L.adds(4)) add(`A4${a.name}`, { A: a.name }, num(a.amount)); add("GRAND", { A: "총 공 사 비" }, k.grandTotal); return rows; } /** * 공사원가계산서(0608) — 비목 · 금액 · 구성비(총공사비 대비 %) · 비고(식 설명) * base(당초 차수)를 주면 변경 양식 — 당초 · 변경 · 증감 · 구성비(변경) · 비고(변경) */ function wonSheet(dbf, stage, base) { const sh = C.newSheet( "공사원가계산서", "공 사 원 가 계 산 서", C.projectName(dbf), base ? "I" : "G", ); const now = wonRows(dbf, stage); const grand = stage.kanjub.grandTotal || 1; if (!base) { C.put(sh, 3, { A: "구 분 ", D: "금 액", E: "구 성 비", F: "비 고", _k: "head" }); C.put(sh, 4, { A: " 비 목", _k: "head" }); now.forEach((r, i) => { const c = Object.assign({ D: r.amount, E: (r.amount / grand) * 100, _k: "cost" }, r.cells); if (r.note) c.F = r.note; C.put(sh, 5 + i, c); }); } else { C.put(sh, 3, { A: "구 분 ", D: "금 액", G: "구 성 비", H: "비 고", _k: "head" }); C.put(sh, 4, { A: " 비 목", D: "당 초", E: "변 경", F: "증 감", _k: "head" }); const old = Object.fromEntries(wonRows(dbf, base).map((r) => [r.key, r])); now.forEach((r, i) => { const o = old[r.key] ? old[r.key].amount : 0; const c = Object.assign({ D: o, E: r.amount, F: r.amount - o, _k: "cost" }, r.cells); c.G = (r.amount / grand) * 100; if (r.note) c.H = r.note; C.put(sh, 5 + i, c); }); } const last = sh.rows[sh.rows.length - 1]; last[base ? "G" : "E"] = 100; return sh; } /** * 차수 맥락 — calc.js 결과(stages)에서 n 차수 내역·원가 · 설정(kanjubStages) * 안전관리비 기초액(C_ANJUN_G)은 식 설명용 */ function stageOf(state, computed, n = 1) { const st = (computed && computed.stages && computed.stages[n]) || { boq: computed.boqOrig, kanjub: computed.kanjubOrig, }; const cfg = (state.kanjubStages && state.kanjubStages[n]) || state.kanjubConfig || {}; const kanjub = Object.assign({}, st.kanjub, { _anjunAdd: num(cfg.anjunBaseAdd) }); if (cfg.fixed && cfg.fixed.ANJUN != null && kanjub.itemMap.ANJUN) kanjub.itemMap = Object.assign({}, kanjub.itemMap, { ANJUN: Object.assign({}, kanjub.itemMap.ANJUN, { modeUsed: "fixed" }), }); return { boq: st.boq, kanjub, cfg, slot: n }; } function buildBoqSheets(dbf, state, computed, n = 1, opts = {}) { const stage = stageOf(state, computed, n); const design = C.applyForm(designSheet(dbf, stage, opts), dbf, "0603"); const summary = C.applyForm(summarySheet(dbf, stage, design, opts), dbf, "0601"); const base = n > 1 && opts.changeForm !== false ? stageOf(state, computed, 1) : null; const won = C.applyForm(wonSheet(dbf, stage, base), dbf, "0608"); return { stage, 공사원가계산서: won, 총괄내역서: summary, 설계내역서: design }; } return { buildBoqSheets, boqTree, designSheet, summarySheet, wonSheet, stageOf, ITEMS }; })(); if (typeof window !== "undefined") window.STMATE_REPORT_BOQ = StmateReportBoq; if (typeof module !== "undefined" && module.exports) module.exports = StmateReportBoq;