Files

691 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
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) => `
<Worksheet ss:Name="${esc(name)}">
<Table>
<Row>${headers.map((h) => `<Cell><Data ss:Type="String">${esc(h)}</Data></Cell>`).join("")}</Row>
${rows
.map(
(r) =>
`<Row>${r
.map((c) => {
const isN = typeof c === "number" && Number.isFinite(c);
return `<Cell><Data ss:Type="${isN ? "Number" : "String"}">${esc(c)}</Data></Cell>`;
})
.join("")}</Row>`,
)
.join("\n")}
</Table>
</Worksheet>`;
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 `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
${shWon}
${shBoq}
${shIlwi}
${shSan}
${shJoong}
${shDan}
</Workbook>`;
}
/**
* 5. 조달청 공사원가 호환규정 V1.0.5 (Valid_Xml.vsl · T3_C3B) 전체 계층 XML 생성기
*/
function buildFullPpsXml(ctx) {
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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 `<?xml version="1.0" encoding="UTF-8"?>
<T3_C3B xmlns="http://www.pps.go.kr/cost/v105" schemaVersion="1.0.5">
<Header>
<Program>STMATE w 8.0 (경영정보)</Program>
<ProjectCode>${esc(ctx.state.projectMeta.pcode)}</ProjectCode>
<ProjectName>${esc(ctx.state.projectMeta.pname)}</ProjectName>
<PriceStandard>${esc(ctx.state.projectMeta.pnum)}</PriceStandard>
<DirectMaterial>${bt.directM}</DirectMaterial>
<DirectLabor>${bt.directL}</DirectLabor>
<DirectExpense>${bt.directS}</DirectExpense>
<DirectCostTotal>${bt.directT}</DirectCostTotal>
<TotalSupplyAmount>${kj.FINAL_SUPPLY}</TotalSupplyAmount>
<GovMaterialAmount>${kj.GOV_MAT}</GovMaterialAmount>
<GrandTotalCost>${kj.GRAND_TOTAL}</GrandTotalCost>
</Header>
<CostSummary>
${kj.lines
.map(
(ln) =>
` <CostLine name="${esc(ln.label)}" base="${ln.base || 0}" rate="${ln.rate || 0}" amount="${ln.amount || 0}" />`,
)
.join("\n")}
</CostSummary>
<BoqItems count="${boqRows.length}">
${boqRows
.map(
(r) =>
` <BoqRow recno="${r._recno}" itnum="${esc(r.ITNUM)}" code="${esc(r.BDCODE1)}" name="${esc(r.NAME1)}" spec="${esc(r.SIZE1)}" unit="${esc(r.UNIT1)}" qty="${num(r.QTY1)}" mUnit="${num(r.MAMT1)}" lUnit="${num(r.LAMT1)}" sUnit="${num(r.SAMT1)}" tAmt="${num(r.T_AMT1)}" />`,
)
.join("\n")}
</BoqItems>
<UnitCostCatalog count="${costnRows.length}">
${costnRows
.map(
(c) =>
` <CostItem code="${esc(c.CODE)}" jdcode="${esc(c.JDCODE)}" name="${esc(c.NAME)}" spec="${esc(c.SIZE)}" unit="${esc(c.UNIT)}" m="${num(c.MAMT1)}" l="${num(c.LAMT1)}" s="${num(c.SAMT1)}" t="${num(c.TAMT1)}" />`,
)
.join("\n")}
</UnitCostCatalog>
</T3_C3B>`;
}
global.STMATE_ENGINE = {
evaluateSmtFormulaText,
cascadeRecalculateAll,
getRecipeBreakdown,
buildMultiSheetExcelXml,
buildFullPpsXml,
};
})(window);