/** * STMATE w 8.0 — SIxMemo(COSTN.SMT) 산근 연결 · 5단 연쇄재계산(Act_Calc_All) · 다중시트 엑셀/조달XML/JDL 변환기 (smt_engine.js) * 근거: 17_단가조립_검증.md, 18_누락_계산로직_점검.md, 23_COSTN_SMT_전수파싱.md, 28_변환_수량산출_식언어.md, 31_일위대가_레시피_사전.md */ (function (global) { "use strict"; const Calc = global.STMATE_CALC; const { fmt, num } = Calc; const SmtEval = global.STMATE_SMT_EVAL; const rowCode = (r) => String(r.CODE || "").trim(); // 낙찰률(NAGCD) 단가는 2칸 원단가로 산근을 셈 — 1칸은 원단가 × 낙찰률 const costSlot = (r) => (num(r.NAGCD) > 0 ? 2 : 1); function costnLookup(ctx) { const byCode = {}; ((ctx.dbfData && ctx.dbfData.costn_rows) || []).forEach((r) => (byCode[rowCode(r)] = r)); const lookup = (code) => { const r = byCode[code]; if (!r) return null; const s = costSlot(r); const out = { M: num(r[`MAMT${s}`]), L: num(r[`LAMT${s}`]), S: num(r[`SAMT${s}`]) }; return Object.assign(out, { unit: String(r.UNIT || "").trim(), name: String(r.NAME || "") }); }; return { byCode, lookup }; } // 결과금액조정(COSTN S_ADJ · ADJ_M/L/S) — 표시된 것만 const adjOf = (row) => String(row.S_ADJ || "").trim() === "T" ? { M: num(row.ADJ_M), L: num(row.ADJ_L), S: num(row.ADJ_S) } : null; // 수량변수 ~Q00001.QTY1~ — QCODE 행(QTY0~9 직접 · 그 밖은 XCODE 「이름=값」 줄 · 빈 값 = 0) function qtyResolver(ctx) { const rows = (ctx.dbfData && ctx.dbfData.qcode_rows) || []; const by = {}; rows.forEach( (r) => (by[ String(r.CODE || "") .trim() .toUpperCase() ] = r), ); return (code, field) => { const r = by[code]; if (!r) return { error: `${code} 는 등록된 수량코드가 아님` }; if (/^QTY\d$/.test(field)) return { value: num(r[field]) }; for (const ln of String(r.XCODE || "").split(/\r?\n/)) { const at = ln.indexOf("="); if (at > 0 && ln.slice(0, at).trim().toUpperCase() === field) return { value: num(ln.slice(at + 1).trim() || "0") }; } return { error: `${code} 에 수량변수 「${field}」 없음` }; }; } // 산근 계산 설정 — Start8x.Ini(CHGNM · OPTNM · ZCDNM) · COSTN(SBMOK · S_ADJ) function smtOptions(ctx, code) { const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {}; const row = costnLookup(ctx).byCode[code] || {}; return { totalFirst: String(ini.CHGNM ?? "1") === "0", order: ini.OPTNM || "SML", assignDigits: ini.ZCDNM !== undefined && ini.ZCDNM !== "" ? num(ini.ZCDNM) : 2, sbmok: String(row.SBMOK || "").trim(), adj: adjOf(row), qty: qtyResolver(ctx), selfCode: code, }; } /** * 1. COSTN.SMT 산근 평가 — smt_eval.js 위임 · 계산 설정은 smtOptions */ function evaluateSmtFormulaText(smtText, ctx, codeHint) { const { lookup } = costnLookup(ctx); const ev = SmtEval.evaluate(smtText, lookup, smtOptions(ctx, codeHint)); const linesOut = ev.lines.map((ln) => { const out = { lineNo: ln.lineNo, raw: ln.raw, assigned: (ln.assigned || []).join(", ") }; Object.assign(out, { kind: ln.kind, errors: ln.errors || [], excluded: Boolean(ln.excluded), }); if (ln.kind !== "amount") return Object.assign(out, { refInfo: null }); const tag = "MLS".split("").find((k) => ln.amounts[k]) || "M"; const refName = (lookup(ln.ref.replace(/[LMS]$/, "")) || {}).name || ln.ref; const calcAmt = ln.amounts.M + ln.amounts.L + ln.amounts.S; const refInfo = { refCode: ln.ref, compTag: tag, refName, baseUnitPrice: ln.base, calcAmt }; return Object.assign(out, { assigned: ln.label ? `→ ${ln.label}` : "", refInfo }); }); const res = { vars: ev.vars, linesOut, sumM: ev.M, sumL: ev.L, sumS: ev.S, sumT: ev.T }; return Object.assign(res, { errors: ev.errors, labels: ev.labels }); } // 노임 환산 갈래 H0~H5 — 직종 묶음(CONLD · CONTD · CON1D~4D) ↔ 환산식(RXXNM · RXTNM · RX1NM~4NM) // 근거: 22 §3.4 cmBx_Sel_RX · 30 §4 시간당노임산출 · TWM_EDIT_ILWI wcBx_RXX(0 개별 · 1 기본 · 2 기타 · 3~6 기타-1~4) const RX_SETS = [ ["CONLD", "RXXNM"], ["CONTD", "RXTNM"], ["CON1D", "RX1NM"], ["CON2D", "RX2NM"], ["CON3D", "RX3NM"], ["CON4D", "RX4NM"], ]; function laborMap(ini, rxxSel, fallback) { const m = new Map(); const base = ini.RXXNM || fallback; for (const [set, key] of RX_SETS) for (const c of String(ini[set] || "").match(/.{1,6}/g) || []) if (!m.has(c)) m.set(c, ini[key] || base); const sel = RX_SETS[(Number(rxxSel) || 0) - 1]; if (sel) for (const c of m.keys()) m.set(c, ini[sel[1]] || base); return m; } // 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · EXLNM1) · COSTN RXX // 손료 갈래(E0 주간 · E1 야간 · E2 2교대 · E3 3교대, 30 §4) — 계수는 사용자 입력(sysConfig.shiftFactors) function compositionOptions(ctx, code, row) { const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {}; const sys = (ctx.state && ctx.state.sysConfig) || {}; const shift = String(row.SHIFT || sys.shiftMode || "E0"); return { totalFirst: code[0] === "B" && String(ini.CHBNM ?? "1") === "0", order: ini.OPTNM || "SML", sbmok: String(row.SBMOK || "").trim(), adj: adjOf(row), labor: code[0] === "X" ? laborMap(ini, row.RXX, sys.rxFormula) : null, rx: ini.RXXNM || sys.rxFormula, usdRate: num(ini.EXLNM1), shiftFactor: code[0] === "X" && shift !== "E0" ? num((sys.shiftFactors || {})[shift]) || 1 : 1, }; } // 참조 그래프 순환 찾기 — 구성(BDQTY)·산근(&코드&) · 경로를 6자리 코드로(REA 30 mode1_circular_reference) function findCycles(deps, codes) { const state = {}, cycles = [], path = []; const visit = (c) => { if (state[c] === 2) return; if (state[c] === 1) { cycles.push([...path.slice(path.indexOf(c)), c]); return; } state[c] = 1; path.push(c); for (const d of deps(c)) if (codes.has(d)) visit(d); path.pop(); state[c] = 2; }; [...codes].forEach(visit); return cycles; } /** * 2. 연쇄 재계산(Act_Calc_All) — 중기(X)·산근(D)·일위대가(B)를 참조 순서대로 다시 셈 * 구성: X·B = BDQTY 구성행(bdqty.py 복호) · D = COSTN.SMT 식 · 결과는 COSTN 단가 칸에 씀 */ function cascadeRecalculateAll(ctx) { const count = { xCount: 0, dCount: 0, bCount: 0 }; if (!ctx.dbfData || !ctx.dbfData.costn_rows) return count; const crows = ctx.dbfData.costn_rows; const memos = ctx.dbfData.costn_memos || {}; const syncUnit = global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice; const { byCode, lookup } = costnLookup(ctx); const groups = {}; for (const r of ctx.dbfData.bdqty_rows || []) { if (/^[BX]/.test(r.BDCODE || "") && byCode[r.BDCODE]) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(r); } const memoText = (code) => (ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[code]) || (memos[code] && memos[code].text) || ""; const deps = (code) => groups[code] ? groups[code].map((r) => String(r.CODE || "").trim()) : [...memoText(code).matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g)].map((m) => m[1]); const order = [], seen = new Set(); const visit = (code) => { if (seen.has(code) || !(groups[code] || memoText(code).trim())) return; seen.add(code); deps(code).forEach(visit); order.push(code); }; [...Object.keys(groups), ...Object.keys(memos)].forEach(visit); // 순환 참조 — 계산은 저장 단가로 이어 가고 경로를 알림(F2-13) count.cycles = findCycles(deps, new Set(order)).map((p) => p.join(" → ")); if (ctx.uiState) ctx.uiState.cycleErrors = count.cycles; for (const code of order) { const row = byCode[code]; if (!row) continue; const ev = groups[code] ? SmtEval.evaluateComposition(groups[code], lookup, compositionOptions(ctx, code, row)) : (({ sumM, sumL, sumS, sumT }) => ({ M: sumM, L: sumL, S: sumS, T: sumT }))( evaluateSmtFormulaText(memoText(code), ctx, code), ); if (!(ev.T > 0 || num(row.TAMT1) === 0)) continue; // 계산 실패로 저장 단가를 지우지 않음 const nag = num(row.NAGCD); if (nag > 0) { Object.assign(row, { MAMT2: String(ev.M), LAMT2: String(ev.L) }); Object.assign(row, { SAMT2: String(ev.S), TAMT2: String(ev.T) }); const b = (v) => SmtEval.bidRate(v, nag); syncUnit(ctx, code, b(ev.M), b(ev.L), b(ev.S)); } else syncUnit(ctx, code, ev.M, ev.L, ev.S); count[code[0] === "X" ? "xCount" : code[0] === "D" ? "dCount" : "bCount"] += 1; } return count; } /** * 3. 선택 호표(B/X)의 표준 레시피 구성행 조회 */ function getRecipeBreakdown(ctx, code) { const crows = (ctx.dbfData && ctx.dbfData.costn_rows) || []; const row = crows.find((r) => String(r.CODE || "").trim() === code); if (!row) return []; const nm = String(row.NAME || "").trim(); const sz = String(row.SIZE || "").trim(); const p = code[0].toUpperCase(); const recipes = (ctx.catalogData && ctx.catalogData.recipes) || []; const kind = p === "X" ? "중기" : p === "B" ? "일위대가" : "단가산출"; const pool = recipes.filter((r) => r["종류"] === kind); const rec = pool.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) || pool.find((r) => r["명칭"].trim() === nm); return rec && rec["구성"] ? rec["구성"] : []; } /** * 4. 다중 시트 Excel SpreadsheetML (.xls) 통합 워크북 생성기 (갑지·원가계산서·설계내역서·일위대가·단가산출·중기·단가목록) */ function buildMultiSheetExcelXml(ctx) { const esc = (v) => String(v ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); const kj = ctx.computed.kanjubComputed; const boqRows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); const costnRows = (ctx.dbfData && ctx.dbfData.costn_rows) || []; const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {}; const mkSheet = (name, headers, rows) => ` ${headers.map((h) => `${esc(h)}`).join("")} ${rows .map( (r) => `${r .map((c) => { const isN = typeof c === "number" && Number.isFinite(c); return `${esc(c)}`; }) .join("")}`, ) .join("\n")}
`; const shWon = mkSheet( "공사원가계산서", ["비목", "산정기초(원)", "요율(%)", "금액(원)", "산출근거"], kj.lines.map((l) => [l.label, l.base || 0, l.rate || 0, l.amount || 0, l.formula || ""]), ); const shBoq = mkSheet( "설계내역서", [ "공종번호", "코드", "공종명", "규격", "단위", "수량", "재료비단가", "노무비단가", "경비단가", "합계단가", "합계금액", ], boqRows.map((r) => [ r.ITNUM || "", r.BDCODE1 || "", r.NAME1 || "", r.SIZE1 || "", r.UNIT1 || "", num(r.QTY1), num(r.MAMT1), num(r.LAMT1), num(r.SAMT1), num(r.TAMT1), num(r.T_AMT1), ]), ); const shIlwi = mkSheet( "일위대가목록표", ["코드", "품명", "규격", "단위", "재료비", "노무비", "경비", "합계단가"], costnRows .filter((r) => String(r.CODE || "").startsWith("B")) .map((r) => [ r.CODE, r.NAME, r.SIZE, r.UNIT, num(r.MAMT1), num(r.LAMT1), num(r.SAMT1), num(r.TAMT1), ]), ); const shSan = mkSheet( "단가산출근거", [ "코드", "품명", "규격", "단위", "재료비", "노무비", "경비", "합계단가", "산출근거(COSTN.SMT)", ], costnRows .filter((r) => String(r.CODE || "").startsWith("D") || memos[r.CODE]) .map((r) => [ r.CODE, r.NAME, r.SIZE, r.UNIT, num(r.MAMT1), num(r.LAMT1), num(r.SAMT1), num(r.TAMT1), memos[r.CODE] ? memos[r.CODE].text.replace(/\r?\n/g, " | ") : "", ]), ); const shJoong = mkSheet( "중기사용료", [ "코드", "장비명", "규격", "단위", "재료비(연료/잡품)", "노무비(운전원)", "경비(손료)", "시간당사용료", ], costnRows .filter((r) => String(r.CODE || "").startsWith("X")) .map((r) => [ r.CODE, r.NAME, r.SIZE, r.UNIT, num(r.MAMT1), num(r.LAMT1), num(r.SAMT1), num(r.TAMT1), ]), ); const shDan = mkSheet( "단가목록표", [ "코드", "구분", "품명", "규격", "단위", "재료비", "노무비", "경비", "적용단가", "조달단가", "물가자료", ], costnRows .filter((r) => /^[LMS]/.test(String(r.CODE || ""))) .map((r) => [ r.CODE, r.CODE[0], r.NAME, r.SIZE, r.UNIT, num(r.MAMT1), num(r.LAMT1), num(r.SAMT1), num(r.TAMT1), num(r.AMT_11), num(r.AMT_21), ]), ); return ` ${shWon} ${shBoq} ${shIlwi} ${shSan} ${shJoong} ${shDan} `; } /** * 5. 조달청 공사원가 호환규정 V1.0.5 (Valid_Xml.vsl · T3_C3B) 전체 계층 XML 생성기 */ function buildFullPpsXml(ctx) { const esc = (v) => String(v ?? "") .replace(/&/g, "&") .replace(//g, ">"); const kj = ctx.computed.kanjubComputed; const bt = ctx.computed.boqComputed.totals; const boqRows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); const costnRows = (ctx.dbfData && ctx.dbfData.costn_rows) || []; return `
STMATE w 8.0 (경영정보) ${esc(ctx.state.projectMeta.pcode)} ${esc(ctx.state.projectMeta.pname)} ${esc(ctx.state.projectMeta.pnum)} ${bt.directM} ${bt.directL} ${bt.directS} ${bt.directT} ${kj.FINAL_SUPPLY} ${kj.GOV_MAT} ${kj.GRAND_TOTAL}
${kj.lines .map( (ln) => ` `, ) .join("\n")} ${boqRows .map( (r) => ` `, ) .join("\n")} ${costnRows .map( (c) => ` `, ) .join("\n")}
`; } global.STMATE_ENGINE = { evaluateSmtFormulaText, cascadeRecalculateAll, costnLookup, smtOptions, compositionOptions, qtyResolver, laborMap, findCycles, getRecipeBreakdown, buildMultiSheetExcelXml, buildFullPpsXml, }; })(window);