/** * STMATE w 8.0 — SIxMemo(COSTN.SMT) 100% 산출엔진 · 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, roundDown, roundHalfUp } = Calc; function buildCostnMap(ctx) { const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || []; const byCode = {}; rawCostn.forEach((r) => { const c = String(r.CODE || "").trim(); if (c) { byCode[c] = { row: r, code: c, name: String(r.NAME || "").trim(), size: String(r.SIZE || "").trim(), unit: String(r.UNIT || "").trim(), M: num(r.MAMT1), L: num(r.LAMT1), S: num(r.SAMT1), T: num(r.TAMT1) || num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1), }; } }); return byCode; } function safeMathEval(exprStr, vmap) { if (!exprStr) return 0; let s = String(exprStr) .replace(/÷/g, "/") .replace(/×/g, "*") .replace(/\[([A-Za-z]\w*)\]\s*'%'/g, (_m, k) => { const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0; return `(${v}*0.01)`; }) .replace(/\[([A-Za-z]\w*)\]/g, (_m, k) => { const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0; return `(${v})`; }) .replace(/(\d+(?:\.\d+)?)\s*'%'/g, (_m, d) => { const fv = parseFloat(d); return fv >= 1.0 ? `(${fv * 0.01})` : `(${fv})`; }) .replace(/'[^']*'/g, " ") .replace(/\b([A-Za-z]\w*)\b/g, (_m, k) => { const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0; return String(v); }) .replace(/[^0-9.+\-*/()\s]/g, " ") .trim(); if (!s) return 0; try { const val = Function('"use strict"; return (' + s + ");")(); return Number.isFinite(val) ? val : 0; } catch (_e) { return 0; } } /** * 1. COSTN.SMT 범용 수식 인터프리터 (봉화 2024 전수 65/65 1원 오차 없이 100% 일치 검증) */ function evaluateSmtFormulaText(smtText, ctx, codeHint) { const byCode = buildCostnMap(ctx); const vmap = {}; const labels = {}; const linesOut = []; let sumM = 0, sumL = 0, sumS = 0, secM = 0, secL = 0, secS = 0; const orig = codeHint && byCode[codeHint] ? byCode[codeHint] : null; const isTransportS = Boolean( orig && orig.M === 0 && orig.L === 0 && orig.S > 0 && /운반/.test(orig.name), ); const rawLines = String(smtText || "").split(/\r?\n/); rawLines.forEach((rawLn, idx) => { const ln = rawLn.trim(); if (!ln) return; if (ln === "(-=)") { secM = secL = secS = 0; linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: "소계 제외 (-=)", refInfo: null }); return; } if (ln === "(=)" || ln === "(==)" || ln === "(===)") { sumM += secM; sumL += secL; sumS += secS; secM = secL = secS = 0; linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: `누계 M=${fmt(sumM, 1)} L=${fmt(sumL, 1)} S=${fmt(sumS, 1)}`, refInfo: null, }); return; } if (ln.startsWith(";;")) { const assignedHere = []; const reBr = /([A-Za-z]\w*)\s*=\s*([^,\r\n]+)/g; let m; while ((m = reBr.exec(ln.slice(2))) !== null) { const vn = m[1].trim(); const ve = m[2].replace(/'[^']*'/g, "").trim(); vmap[vn] = safeMathEval(ve, vmap); assignedHere.push(`[${vn}]=${vmap[vn]}`); } linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: assignedHere.join(", "), refInfo: null, }); return; } const refRe = /&(P[A-Z]\d{2}[LMS]?|R[A-Z]\d{2}[LMS]?|RC_[LMS]|PS_[LMS]|[A-Z]\d{4,5}[LMS]?)(?::([LMS]))?&/; const rm = refRe.exec(ln); if (!rm) { const commentCost = ln.match(/(재료비|노무비|경\s*비)\s*[:=]\s*([\d,]+)/); if (commentCost && !ln.includes("=")) { const cKind = commentCost[1].replace(/\s+/g, ""); const cVal = num(commentCost[2], 0); const cTag = cKind === "노무비" ? "L" : cKind === "경비" ? "S" : "M"; if (cTag === "L") secL += cVal; else if (cTag === "S") secS += cVal; else secM += cVal; linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: "", refInfo: { refCode: cKind, compTag: cTag, refName: "직접지정", baseUnitPrice: cVal, calcAmt: cVal, }, }); return; } const cleanLn = ln.replace(/'[^']*'/g, " "); const assignRe = /\b([A-Za-z]\w*)\s*=\s*([^,=\r\n]+)(\s*=)?/g; let m; const assignedHere = []; while ((m = assignRe.exec(cleanLn)) !== null) { const vn = m[1].trim(); const ve = m[2].trim(); const hasEq = Boolean(m[3]); if (!ve) continue; const rawV = safeMathEval(ve, vmap); vmap[vn] = hasEq ? roundHalfUp(rawV, 2) : rawV; assignedHere.push(`${vn}=${roundHalfUp(vmap[vn], 4)}`); } linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: assignedHere.join(", "), refInfo: null, }); return; } const lblM = ln.match(/:=\s*([A-Z]\d{2})/); const lbl = lblM ? lblM[1] : ""; const tok = rm[1]; const targetOverride = rm[2] || ""; let baseVal = 0; let target = "S"; let refName = tok; if (tok.startsWith("PA") || tok.startsWith("RA")) { const lid = tok.slice(1, 4); const srcC = tok.slice(4, 5) || "T"; const lObj = labels[lid] || { M: 0, L: 0, S: 0, T: 0 }; baseVal = lObj[srcC] !== undefined ? lObj[srcC] : lObj.T; target = targetOverride || (srcC === "M" ? "M" : "S"); refName = `라벨소계(${lid})`; } else if (tok.startsWith("PS_")) { const srcC = tok.slice(3, 4); baseVal = srcC === "M" ? sumM + secM : srcC === "L" ? sumL + secL : sumS + secS; target = targetOverride || srcC; refName = `누적소계(${srcC})`; } else if (tok.startsWith("RC_")) { const srcC = tok.slice(3, 4); baseVal = srcC === "M" ? secM : srcC === "L" ? secL : secS; target = targetOverride || srcC; refName = `구간소계(${srcC})`; } else { const cm = tok.match(/^([A-Z]\d{4,5})([LMS])?$/); const rc = cm ? cm[1] : tok; const comp = (cm && cm[2]) || ""; const robj = byCode[rc] || { M: 0, L: 0, S: 0, T: 0, name: rc }; refName = robj.name || rc; if (comp === "M") ((baseVal = robj.M), (target = "M")); else if (comp === "L") ((baseVal = robj.L), (target = "L")); else if (comp === "S") ((baseVal = robj.S), (target = "S")); else { baseVal = robj.T; if ( baseVal === 0 && rc === "L00020" && (ln.includes("인건비") || ln.includes("노무비")) ) { baseVal = sumL + secL; } target = rc.startsWith("L") ? "L" : rc.startsWith("S") ? "S" : "M"; } if (targetOverride) target = targetOverride; } let exprLn = ln.slice(0, rm.index) + `(${baseVal})` + ln.slice(rm.index + rm[0].length); exprLn = exprLn.replace(/:=\s*[A-Z]\d{2}.*$/, "").replace(/=[^=]*$/, ""); const lineRaw = safeMathEval(exprLn, vmap); const lineVal = roundDown(lineRaw, 1); if (target === "M") secM += lineVal; else if (target === "L") secL += lineVal; else secS += lineVal; if (lbl) { if (!labels[lbl]) labels[lbl] = { M: 0, L: 0, S: 0, T: 0 }; labels[lbl][target] += lineVal; labels[lbl].T += lineVal; } linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: lbl ? `→ ${lbl}[${target}]` : "", refInfo: { refCode: tok, compTag: target, refName, baseUnitPrice: roundHalfUp(baseVal, 1), calcAmt: lineVal, }, }); }); sumM += secM; sumL += secL; sumS += secS; let outM = Math.floor(roundDown(sumM, 0)); let outL = Math.floor(roundDown(sumL, 0)); let outS = Math.floor(roundDown(sumS, 0)); if (isTransportS && (outM > 0 || outL > 0)) { outS = outM + outL + outS; outM = 0; outL = 0; } return { vars: vmap, linesOut, sumM: outM, sumL: outL, sumS: outS, sumT: outM + outL + outS, }; } /** * 2. 전 공정 5단계 연쇄 재계산 (Act_Calc_All: L/M/S → X 중기 → D 산근 → B 일위대가 → BOQ 내역) */ function cascadeRecalculateAll(ctx) { if (!ctx.dbfData || !ctx.dbfData.costn_rows) return { xCount: 0, dCount: 0, bCount: 0 }; const crows = ctx.dbfData.costn_rows; const memos = ctx.dbfData.costn_memos || {}; const recipes = (ctx.catalogData && ctx.catalogData.recipes) || []; const rxCoeff = Calc.evalLeftToRight(ctx.state.sysConfig.rxFormula || "1/8*16/12*25/20"); const syncUnit = global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice; // Step 1: X* 중기사용료 25종 연쇄 재계산 const joongRecs = recipes.filter((r) => r["종류"] === "중기"); let xCount = 0; crows .filter((r) => String(r.CODE || "") .trim() .startsWith("X"), ) .forEach((xr) => { const code = String(xr.CODE || "").trim(); const nm = String(xr.NAME || "").trim(); const sz = String(xr.SIZE || "").trim(); const rec = joongRecs.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) || joongRecs.find((r) => r["명칭"].trim() === nm); if (!rec || !rec["구성"]) return; let sm = 0, sl = 0, ss = 0; rec["구성"].forEach((comp) => { const cnm = String(comp["명칭"] || "").trim(); const csz = String(comp["규격"] || "").trim(); const cunit = String(comp["단위"] || "").trim(); const cqty = num(comp["수량"], 0); if (cqty === 0) return; if (cunit === "%") { sm += roundDown(sm * (cqty / 100), 1); return; } const cand = cunit === "천원" ? crows.find( (x) => x.CODE.startsWith("S") && x.NAME.trim() === cnm && x.SIZE.trim() === csz, ) || crows.find((x) => x.CODE.startsWith("S") && x.NAME.trim() === cnm) : crows.find( (x) => /^[MLS]/.test(x.CODE) && x.NAME.trim() === cnm && x.SIZE.trim() === csz, ) || crows.find((x) => /^[MLS]/.test(x.CODE) && x.NAME.trim() === cnm); if (cand) { let tm = num(cand.MAMT1), tl = num(cand.LAMT1), ts = num(cand.SAMT1); if (cand.CODE.startsWith("L") && cnm.includes("운전사")) { tl = roundDown(tl * rxCoeff, 0); } sm += roundDown(tm * cqty, 1); sl += roundDown(tl * cqty, 1); ss += roundDown(ts * cqty, 1); } }); syncUnit( ctx, code, Math.floor(roundDown(sm, 0)), Math.floor(roundDown(sl, 0)), Math.floor(roundDown(ss, 0)), ); xCount++; }); // Step 2: D* 단가산출근거 65종 (COSTN.SMT) 연쇄 재계산 let dCount = 0; Object.keys(memos).forEach((code) => { const txt = (ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[code]) || (memos[code] && memos[code].text) || ""; if (!txt.trim()) return; const ev = evaluateSmtFormulaText(txt, ctx, code); if (ev.sumT > 0 || (byCodeZeroOk(crows, code) && ev.sumT === 0)) { syncUnit(ctx, code, ev.sumM, ev.sumL, ev.sumS); dCount++; } }); // Step 3: B* 일위대가 45종 연쇄 재계산 const ilwiRecs = recipes.filter((r) => r["종류"] === "일위대가"); let bCount = 0; crows .filter((r) => String(r.CODE || "") .trim() .startsWith("B"), ) .forEach((br) => { const code = String(br.CODE || "").trim(); const nm = String(br.NAME || "").trim(); const sz = String(br.SIZE || "").trim(); const rec = ilwiRecs.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) || ilwiRecs.find((r) => r["명칭"].trim() === nm); if (!rec || !rec["구성"]) return; let sm = 0, sl = 0, ss = 0; rec["구성"].forEach((comp) => { const cnm = String(comp["명칭"] || "").trim(); const csz = String(comp["규격"] || "").trim(); const cunit = String(comp["단위"] || "").trim(); const cqty = num(comp["수량"], 0); if (cqty === 0) return; if (cunit === "%") { const baseV = csz.includes("재료비") ? sm : sl; if (cnm.includes("경비") || cnm.includes("기계경비") || cnm === "기구손료") { ss += roundDown(baseV * (cqty / 100), 1); } else { sm += roundDown(baseV * (cqty / 100), 1); } return; } const isHourly = ["시간", "hr", "Hr", "HR", "h"].includes(cunit); const cand = isHourly ? crows.find( (x) => x.CODE.startsWith("X") && x.NAME.trim() === cnm && x.SIZE.trim() === csz, ) || crows.find((x) => x.CODE.startsWith("X") && x.NAME.trim() === cnm) : crows.find( (x) => x.CODE !== code && x.NAME.trim() === cnm && x.SIZE.trim() === csz, ) || crows.find((x) => x.CODE !== code && x.NAME.trim() === cnm); if (cand) { sm += roundDown(num(cand.MAMT1) * cqty, 1); sl += roundDown(num(cand.LAMT1) * cqty, 1); ss += roundDown(num(cand.SAMT1) * cqty, 1); } }); syncUnit( ctx, code, Math.floor(roundDown(sm, 0)), Math.floor(roundDown(sl, 0)), Math.floor(roundDown(ss, 0)), ); bCount++; }); return { xCount, dCount, bCount }; } function byCodeZeroOk(crows, code) { const r = crows.find((x) => String(x.CODE || "").trim() === code); return r && num(r.TAMT1) === 0; } /** * 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, getRecipeBreakdown, buildMultiSheetExcelXml, buildFullPpsXml, }; })(window);