Files
Aislo/tools/stmate-test/smt_engine.js
T
eomsangdonandClaude Opus 5.5 5847580670 feat(stmate-test): REA 보강 — 실행·계약·변경 차수·잔여 계산 원본 규칙 · 장부 91.1%
- 지식DB REA 분석 32 실행계약 · 34 변경차수 잔여계산 작업자료(정적분석 · 원본 실행·수정 없음)
- 실행: 중기 실행단가 합계·성분 비례·끝전·차액 성분 · 실행단가·수량 할증 · 계약 변환 옵션·적용제외 · 기성 공사 낙찰율 100%
- 변경: 차수 목록·이어 셈 · 보험료 3모드(변경 2차 이후) · 방법 A/B 5항목 · 퇴직공제·건설기계·표준시장단가·노임 환산 갈래·JCD
- 시험 693 통과 · 브라우저 3건 화면 전수 오류 0 · 총공사비 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 09:29:53 +09:00

524 lines
19 KiB
JavaScript
Raw 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) 산근 연결 · 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 0·1·2 자리(22 §3.4) · 비거나 이상하면 1(실무 7건 값)
const digitOf = (v, def = 1) => (/^[0-2]$/.test(String(v ?? "").trim()) ? Number(v) : def);
// 외산장비 환율 — 단가의 조사시점(ARENM 1 당초 · 2 변경)의 EXLNM·EXHNM·EXEUR·EXYEN(REA 14 cmBx_ARE)
function fxOf(ini) {
const n = String(ini.ARENM || "1").trim() === "2" ? 2 : 1;
return {
usd: num(ini[`EXLNM${n}`]),
usdHigh: num(ini[`EXHNM${n}`]),
eur: num(ini[`EXEUR${n}`]),
yen: num(ini[`EXYEN${n}`]),
};
}
// 산근 계산 설정 — Start8x.Ini(CHGNM · OPTNM · ZCDNM · ACDNM) · 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,
lineDigits: digitOf(ini.ACDNM),
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 = 저장 0·1·10~14)
const RX_SETS = [
["CONLD", "RXXNM"],
["CONTD", "RXTNM"],
["CON1D", "RX1NM"],
["CON2D", "RX2NM"],
["CON3D", "RX3NM"],
["CON4D", "RX4NM"],
];
// 원본 갈래(Main 0x43894c~0x438d72 · 값↔화면 순번 0x4fcae8 = [0,1,0×8,2..6]) — 중기(X) 구성의 L 행만 · 단위 % 빼고
// COSTN.RXX 1 = RXXNM · 10 = RXTNM · 11~14 = RX1NM~RX4NM → 직종 목록을 보지 않고 모든 L 행을 그 식으로
// 그 밖(0 개별 · 2~9 · 15~) = 직종 목록 CONLD → CONTD → CON1D~4D 차례 첫 일치 묶음의 식 · 어디에도 없으면 환산 안 함
const RXX_KEY = { 1: "RXXNM", 10: "RXTNM", 11: "RX1NM", 12: "RX2NM", 13: "RX3NM", 14: "RX4NM" };
class AllLabor extends Map {
constructor(f) {
super();
this.f = f;
}
has(c) {
return String(c || "")[0] === "L";
}
get() {
return this.f;
}
}
function laborMap(ini, rxx, fallback) {
const base = ini.RXXNM || fallback;
const key = RXX_KEY[Number(rxx) || 0];
const m = key ? new AllLabor(ini[key] || base) : new Map();
// 목록 코드도 칸으로 남김(보기용 · AllLabor 는 has/get 이 모든 L 을 덮음)
for (const [set, k] of RX_SETS)
for (const c of String(ini[set] || "").match(/.{1,6}/g) || [])
if (!Map.prototype.has.call(m, c)) m.set(c, key ? m.f : ini[k] || base);
return m;
}
// 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · 환율 · 자리) · 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),
fx: fxOf(ini),
// 줄 금액 자리 = 중기 KCD · 일위대가 BPN · 중기 노임 단가 JCD · $장비 단가 ECD(22 §3.4 · REA 14 안내문)
lineDigits: digitOf(code[0] === "X" ? ini.KCDNM : ini.BPNNM),
// JCD 빈칸 기본 = KCD 2 이면 2 · 아니면 0(Main 0x42ab08)
wageDigits: digitOf(ini.JCDNM, digitOf(ini.KCDNM, 0) === 2 ? 2 : 0),
fxDigits: digitOf(ini.ECDNM, 0),
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, "&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,
costnLookup,
smtOptions,
compositionOptions,
qtyResolver,
laborMap,
findCycles,
getRecipeBreakdown,
buildMultiSheetExcelXml,
buildFullPpsXml,
};
})(window);