Files
Aislo/tools/stmate-test/smt_engine.js
T
eomsangdonandClaude Opus 5.5 f591c14356 feat(stmate-test): 단가산출근거(D코드) 재계산 — 원설계 5건 282개 1원 · 낙찰률 단가
- smt_eval.js 새 산근 평가기 — 봉화에만 맞던 옛 해석기(161/242) 대체
- 총액우선 차액 보정(Start8x.Ini CHGNM=0 · OPTNM 순서 · 소수부 십진 비교) · 비목우선은 성분 절사
- 다른 산근 참조 = 재·노·경 나눠 가져옴 · 위상순서 재계산 · 변수 대소문자 무시
- % 단위 자원(인건비의 % · 운반비 계) = 누계 밑수 · (-=)(-==) 합계 제외 · RS_ 직전 구간 · 라벨 묶음 PA010203
- 운반비 경비 귀속 = COSTN SBMOK=S — 원본 저장 금액으로 판정하던 부분 걷음
- 낙찰률 단가 = 2칸 원단가 × NAGCD 원 미만 버림(7건 127개)
- 지킴 시험 test_stmate_smt_eval.py · 장부 진행률 30.6% → 32.2%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 13:05:56 +09:00

477 lines
16 KiB
JavaScript

/**
* 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, roundDown } = 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 };
}
/**
* 1. COSTN.SMT 산근 평가 — smt_eval.js 위임 · 계산 설정은 Start8x.Ini(CHGNM · OPTNM)
*/
function evaluateSmtFormulaText(smtText, ctx, codeHint) {
const { byCode, lookup } = costnLookup(ctx);
const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {};
const row = byCode[codeHint] || {};
const ev = SmtEval.evaluate(smtText, lookup, {
totalFirst: String(ini.CHGNM ?? "1") === "0",
order: ini.OPTNM || "SML",
sbmok: String(row.SBMOK || "").trim(),
});
const linesOut = ev.lines.map((ln) => {
const out = { lineNo: ln.lineNo, raw: ln.raw, assigned: (ln.assigned || []).join(", ") };
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 });
});
return { vars: ev.vars, linesOut, sumM: ev.M, sumL: ev.L, sumS: ev.S, sumT: ev.T };
}
/**
* 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;
const memoText = Object.fromEntries(Object.entries(memos).map(([c, m]) => [c, m.text]));
SmtEval.orderByRefs(memoText).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)) {
const row = crows.find((r) => rowCode(r) === code);
const nag = row ? num(row.NAGCD) : 0;
if (nag > 0) {
Object.assign(row, { MAMT2: String(ev.sumM), LAMT2: String(ev.sumL) });
Object.assign(row, { SAMT2: String(ev.sumS), TAMT2: String(ev.sumT) });
const b = (v) => SmtEval.bidRate(v, nag);
syncUnit(ctx, code, b(ev.sumM), b(ev.sumL), b(ev.sumS));
} else 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, "&amp;")
.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);