Files
Aislo/tools/stmate-test/smt_engine.js
T
eomsangdonandClaude Opus 5.5 a4c0bc14a6 refactor(stmate-test): 원본에 없는 기능 걷음 — 제비율표 JB 대조 보기 · 보고서 CSV · 계약 저장 공사 대조 · 옛 화면 9개(표준카탈로그·레시피·REA 아키텍처 창·가짜 JDL/XML)
- 원본 대조 = 설치본 실행파일 52개 화면 이름·버튼 글·한글 문자열
- 지움: conv_jb.py · excel_builder.py · view_dan_ilwi.js · view_kanjub_prt.js · /api/conv/jb-* · /api/report/csv · /api/stage/projects · /api/catalog · /api/export-xlsx
- 낡은 시험 삭제(지운 기능 전용): conv_change 제비율표 3 · stage_api 공사별 설정 2 · report_sheets CSV 2줄
- 장부 F4-15 · F1-33 · F6-24 설명 고침 · stmate 시험 786 통과

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

305 lines
13 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) (smt_engine.js)
* 근거: 17_단가조립_검증.md, 18_누락_계산로직_점검.md, 23_COSTN_SMT_전수파싱.md, 28_변환_수량산출_식언어.md
*/
(function (global) {
"use strict";
const Calc = global.STMATE_CALC;
const { 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);
// 표준시장단가(A) 줄 단가 설정 — PRJLST ACTTYP·ACTOPT(로더 0x45c938) · Start8x.Ini S_JDA(설정 +0x7c8, 기본 거짓)
const iniBool = (v) =>
["1", "T", "TRUE", "Y"].includes(
String(v ?? "")
.trim()
.toUpperCase(),
);
function stdMarketCfg(ctx) {
const d = ctx.dbfData || {},
prj = d.prjlst || {};
const useStored = iniBool((d.start_ini || {}).S_JDA);
return { acttyp: num(prj.ACTTYP), actopt: num(prj.ACTOPT), useStored };
}
// COSTA 행 → calc.stdMarketUnit 꼴(AAMTn · RATE_L · LAMTn_R · 저장 LAMTn·MAMTn·SAMTn)
const ns = [1, 2, 3, 4];
const stdItem = (r) => ({
aamt: ns.map((n) => num(r[`AAMT${n}`])),
rateL: num(r.RATE_L),
lamtR: ns.map((n) => num(r[`LAMT${n}_R`])),
stored: Object.fromEntries(
"LMS".split("").map((k) => [k, ns.map((n) => num(r[`${k}AMT${n}`]))]),
),
});
function costnLookup(ctx) {
const byCode = {},
byA = {};
((ctx.dbfData && ctx.dbfData.costn_rows) || []).forEach((r) => (byCode[rowCode(r)] = r));
((ctx.dbfData && ctx.dbfData.costa_rows) || []).forEach((r) => (byA[rowCode(r)] = r));
const smCfg = stdMarketCfg(ctx);
const lookup = (code) => {
// A 줄 = COSTA 에서 읽어 노무비율로 나눔(Main 0x437a4b → 0x430c60 → 0x45a468) · 넷째 칸 A 는 구성 합계 밖
const a = code && code[0] === "A" && !byCode[code] ? byA[code] : null;
if (a) {
const u = Calc.stdMarketUnit(stdItem(a), 1, smCfg);
const meta = { unit: String(a.UNIT || "").trim(), name: String(a.NAME || "") };
return Object.assign({ M: u.M, L: u.L, S: u.S, A: u.A }, meta);
}
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, byA, 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)
// 키가 없으면 로더 기본 "1000.00"(Main 0x42b0xx [EQUIPMENT])
function fxOf(ini) {
const n = String(ini.ARENM || "1").trim() === "2" ? 2 : 1;
const v = (k) => (ini[k] == null || String(ini[k]).trim() === "" ? 1000 : num(ini[k]));
return {
usd: v(`EXLNM${n}`),
usdHigh: v(`EXHNM${n}`),
eur: v(`EXEUR${n}`),
yen: v(`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) — XML 내보내기 구분값 · 금액 계수 없음(REA 34 §17.7)
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),
// 환율 적용 범위 EXRNM([EQUIPMENT] · 설정 +0x7c9 · 기본 거짓) — 참이면 S 줄 단가를 천원으로 미리 환산(0x437aa2)
exrnm: iniBool(ini.EXRNM),
// 줄 금액 자리 = 중기 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),
// 원본 계산에는 손료 구분 계수 곱이 없음 — E0~E3 = 조달청 XML 열거값뿐(REA 34 §17.7) · 구분은 행에 남기기만
shiftFactor: 1,
shift,
};
}
// 참조 그래프 순환 찾기 — 구성(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;
}
global.STMATE_ENGINE = {
evaluateSmtFormulaText,
cascadeRecalculateAll,
costnLookup,
smtOptions,
compositionOptions,
qtyResolver,
laborMap,
findCycles,
};
})(window);