- 원본 대조 = 설치본 실행파일 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>
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
/**
|
||
* 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);
|