Files
Aislo/tools/stmate-test/calc.js
T
eomsangdonandClaude Opus 5.5 3d8fddfc34 feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

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

494 lines
19 KiB
JavaScript

/* -*- coding: utf-8 -*- */
/**
* STMATE 계산 사슬 및 제어 옵션 시뮬레이터 엔진 (calc.js)
* 근거: resources/knowledge/original/경쟁사 프로그램/STmate/ (old/20_분석 · REA 분석)
*/
const StmateCalc = (() => {
const KINDS = ["재료비", "노무비", "경비"];
const KIND_KEYS = { 재료비: "M", 노무비: "L", 경비: "S" };
function num(val, def = 0) {
if (val === null || val === undefined || val === "") return def;
const n = Number(String(val).replace(/,/g, "").trim());
return Number.isFinite(n) ? n : def;
}
function fmt(val, maxDec = 0) {
return new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: maxDec,
}).format(num(val, 0));
}
// 자리 맞춤 — 음수 자리(십·백·천원)는 나눗셈으로 정수 결과를 냄(10^-n 곱셈 오차 방지)
const scaleOf = (digits) =>
digits >= 0
? { mul: (v) => v * Math.pow(10, digits), back: (s) => s / Math.pow(10, digits) }
: { mul: (v) => v / Math.pow(10, -digits), back: (s) => s * Math.pow(10, -digits) };
function roundDown(val, digits = 0) {
const n = Number(val) || 0,
f = scaleOf(digits),
s = f.mul(n);
return f.back(Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)));
}
function roundUp(val, digits = 0) {
const n = Number(val) || 0;
if (n === 0) return 0;
const f = scaleOf(digits),
s = f.mul(n);
return f.back(n > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9));
}
function roundHalfUp(val, digits = 0) {
const n = Number(val) || 0,
f = scaleOf(digits);
const r = f.back(Math.floor(f.mul(Math.abs(n)) + 0.5 + 1e-9));
return n < 0 ? -r : r;
}
// 은행가 반올림(Delphi RoundTo · FPU 최근접-짝수 — 11번 §4.2)
function roundBanker(val, digits = 0) {
const n = Number(val) || 0,
f = scaleOf(digits),
s = f.mul(n),
fl = Math.floor(s);
if (Math.abs(s - fl - 0.5) < 1e-9) return f.back(fl % 2 === 0 ? fl : fl + 1);
return f.back(Math.round(s));
}
function applyRoundMode(val, mode, digits = 0) {
return mode === "up"
? roundUp(val, digits)
: mode === "half"
? roundHalfUp(val, digits)
: mode === "banker"
? roundBanker(val, digits)
: roundDown(val, digits);
}
// 원가계산서(제잡비) 계산 — kanjub_calc.js
const calcKanjub = (globalThis.STMATE_KANJUB_FACTORY || require("./kanjub_calc.js"))({
num,
fmt,
roundDown,
roundUp,
roundHalfUp,
roundBanker,
applyRoundMode,
});
function evalFormula(exprStr, vars = {}) {
let s = String(exprStr || "")
.replace(/'[^']*'/g, "")
.trim();
if (!s) return 0;
Object.keys(vars)
.sort((a, b) => b.length - a.length)
.forEach((k) => {
s = s.replace(new RegExp("\\b" + k + "\\b", "g"), String(vars[k]));
});
s = s.replace(/[^0-9.+\-*/()]/g, "");
if (!s) return 0;
try {
const v = Function('"use strict"; return (' + s + ");")();
return Number.isFinite(v) ? v : 0;
} catch (_e) {
return 0;
}
}
function evalWageFormulaLeftToRight(dailyWage, exprStr) {
const base = Number(dailyWage) || 0,
clean = String(exprStr || "").replace(/\s+/g, "");
if (!clean) return { seqResult: base, preCalcResult: base, steps: [] };
let acc = base,
coeff = 1;
const steps = [];
for (const tok of clean.match(/([*/])?(\d+(?:\.\d+)?)/g) || []) {
const op = tok[0] === "*" || tok[0] === "/" ? tok[0] : "*";
const n = parseFloat(op === tok[0] ? tok.slice(1) : tok);
if (!Number.isFinite(n) || n === 0) continue;
acc = op === "*" ? acc * n : acc / n;
coeff = op === "*" ? coeff * n : coeff / n;
steps.push(`${op}${n} → ${roundHalfUp(acc, 4)}`);
}
return { seqResult: roundDown(acc, 0), preCalcResult: Math.trunc(base * coeff), coeff, steps };
}
function calcResources(data) {
const sys = data.sysConfig || {},
rxFormula = sys.rxFormula || "1/8*16/12*25/20";
const condCodes = new Set(
String(sys.condCodes || "")
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean),
);
const usdRate = Number(sys.useChangedUsd ? sys.usdRate2 : sys.usdRate1) || 1000,
ecdDigits = Number(sys.ecdDigits) || 0;
const map = {};
const list = (data.resources || []).map((res) => {
const cands = Array.isArray(res.candidates) ? res.candidates.map(Number) : [0, 0, 0, 0, 0];
const nonZero = cands.filter((v) => v > 0);
let basePrice = Number(res.manualPrice) || 0,
chosenLabel = "수동입력";
if (res.adoptMode === "min") {
basePrice = nonZero.length ? Math.min(...nonZero) : 0;
chosenLabel = nonZero.length ? `최저가(후보${cands.indexOf(basePrice) + 1})` : "0원";
} else if (res.adoptMode && res.adoptMode.startsWith("slot")) {
const sIdx = parseInt(res.adoptMode.replace("slot", ""), 10) - 1;
basePrice = cands[sIdx] || 0;
chosenLabel = `후보${sIdx + 1} 고정`;
}
const krwPrice = res.isUsd ? roundDown(basePrice * usdRate, ecdDigits) : basePrice;
const wageInfo =
res.kind === "노무비" && (res.convertHourly || condCodes.has(res.code))
? evalWageFormulaLeftToRight(krwPrice, rxFormula)
: null;
const adoptedPrice = wageInfo ? wageInfo.seqResult : krwPrice;
const item = Object.assign(
{},
res,
{ cands, basePrice, adoptedPrice },
{ appliedPrice: adoptedPrice, chosenLabel, wageInfo },
);
return ((map[res.id] = item), (map[res.code] = item), item);
});
return { list, map };
}
function calcMachines(data, resMap) {
const map = {};
const list = (data.machines || []).map((m) => {
let M = num(m.hourlyM),
L = num(m.hourlyL),
S = num(m.hourlyS),
opHourly = 0,
fuelPrice = 0;
if (m.hourlyT === undefined || m.operatorId !== undefined) {
opHourly = resMap[m.operatorId]?.adoptedPrice || 0;
fuelPrice = resMap[m.fuelId]?.adoptedPrice || 0;
M = roundDown(
(Number(m.fuelConsumption) || 0) *
fuelPrice *
(1 + (Number(m.miscRatePercent) || 0) / 100),
0,
);
L = roundDown(opHourly * (Number(m.operatorCount) || 1), 0);
S = roundDown((Number(m.acquisitionPrice) || 0) * (Number(m.hourlyRateFactor) || 0), 0);
}
const T = num(m.hourlyT) || M + L + S;
const item = Object.assign({}, m, { M, L, S, T, opHourly, fuelPrice });
return ((map[m.id] = item), (map[m.code] = item), item);
});
return { list, map };
}
function evalQFormula(dItem) {
const q = Number(dItem.q) || 0,
K = Number(dItem.K) || 0;
const f = dItem.f !== undefined ? Number(dItem.f) : evalFormula(dItem.fExpr || "1", { q, K });
const E =
dItem.E !== undefined ? Number(dItem.E) : evalFormula(dItem.EExpr || "0.55", { q, K, f });
const Cm = Number(dItem.Cm) || 1,
mult = dItem.formulaType === "60_qKfE_Cm" ? 60 : 3600;
const rawQ = Cm !== 0 ? (mult * q * K * f * E) / Cm : 0;
return { rawQ, Q: roundHalfUp(rawQ, 2) };
}
function calcSanCal(data, machineMap) {
const map = {};
const list = (data.sanItems || []).map((d) => {
const mach = machineMap[d.machineId],
{ rawQ, Q } = evalQFormula(d);
const M =
d.fixedM !== undefined ? num(d.fixedM) : mach && Q > 0 ? roundDown(mach.M / Q, 1) : 0;
const L =
d.fixedL !== undefined ? num(d.fixedL) : mach && Q > 0 ? roundDown(mach.L / Q, 1) : 0;
const S =
d.fixedS !== undefined ? num(d.fixedS) : mach && Q > 0 ? roundDown(mach.S / Q, 1) : 0;
const intM = roundDown(M, 0),
intL = roundDown(L, 0),
intS = roundDown(S, 0),
intT = intM + intL + intS;
const item = Object.assign({}, d, { mach, rawQ, Q, M, L, S, T: roundHalfUp(M + L + S, 1) });
Object.assign(item, { intM, intL, intS, intT });
return ((map[d.id] = item), (map[d.code] = item), item);
});
return { list, map };
}
function calcIlwi(data, resMap, machMap, sanMap) {
const map = {};
const list = (data.ilwiItems || []).map((ilwi) => {
let sumM = 0,
sumL = 0,
sumS = 0;
const lines = (ilwi.lines || []).map((ln) => {
const ref = resMap[ln.refId] || machMap[ln.refId] || sanMap[ln.refId],
rawQty = Number(ln.qty) || 0;
const baseQty = ln.devd && rawQty !== 0 ? roundHalfUp(1 / rawQty, 7) : rawQty;
const appliedQty = roundHalfUp(baseQty * (1 + (Number(ln.halPercent) || 0) / 100), 7);
const pickU = (k, prop) =>
!ref
? 0
: "adoptedPrice" in ref
? ref.kind === k
? ref.adoptedPrice
: 0
: ref[prop] || 0;
const uM = pickU("재료비", "M"),
uL = pickU("노무비", "L"),
uS = pickU("경비", "S");
const lineM = roundDown(uM * appliedQty, 2),
lineL = roundDown(uL * appliedQty, 2),
lineS = roundDown(uS * appliedQty, 2);
sumM += lineM;
sumL += lineL;
sumS += lineS;
const lineT = roundHalfUp(lineM + lineL + lineS, 2);
const out = Object.assign({}, ln, {
refName: ref?.name || "미연결",
refCode: ref?.code || "-",
});
return Object.assign(out, { appliedQty, uM, uL, uS, lineM, lineL, lineS, lineT });
});
const M = roundDown(sumM, 0),
L = roundDown(sumL, 0),
S = roundDown(sumS, 0);
const item = Object.assign({}, ilwi, { lines, M, L, S, T: M + L + S });
return ((map[ilwi.id] = item), (map[ilwi.code] = item), item);
});
return { list, map };
}
function calcBoq(data, resMap, machMap, sanMap, ilwiMap, useChangedQty = false) {
const slot = typeof useChangedQty === "number" ? useChangedQty : useChangedQty ? 2 : 1;
const sys = data.sysConfig || {},
chnMode = sys.chnMode || "bimok",
optOrder = (sys.optOrder || "SML").toUpperCase();
const lookupUnit = (refId) => {
const r = ilwiMap[refId] || sanMap[refId] || machMap[refId] || resMap[refId];
if (!r) return { M: 0, L: 0, S: 0, T: 0, name: "미연결", code: "-", spec: "", unit: "" };
const pick = (k, iProp, prop) =>
"adoptedPrice" in r
? r.kind === k
? r.adoptedPrice
: 0
: "intT" in r
? r[iProp]
: r[prop];
const M = pick("재료비", "intM", "M"),
L = pick("노무비", "intL", "L"),
S = pick("경비", "intS", "S");
const meta = { name: r.name, code: r.code, spec: r.spec || "", unit: r.unit || "" };
return Object.assign({ M, L, S, T: M + L + S }, meta);
};
let directM = 0,
directL = 0,
directS = 0,
govRawTotal = 0,
govFeeTotal = 0;
const rows = (data.boqItems || []).map((row) => {
const su = slot > 1 && row.units && row.units[slot];
const u = su
? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] })
: lookupUnit(row.refId);
// 행 고정 단가(fixedUnit) · 내역 할증율(wBoqRate RATE/RM·RL·RS = 비목별 단가 가산, 원 단위 절사)
// — 수량 할증(BDQTY X_HAL)과 다른 금액 가산(07번 §4 · 34번 wBoqRate) · 저장 차수 단가는 이미 반영된 값이라 건너뜀
if (!su && row.fixedUnit)
["M", "L", "S"].forEach((c) => row.fixedUnit[c] != null && (u[c] = num(row.fixedUnit[c])));
const hr = row.halRate;
if (!su && hr != null) {
const pct = (c) => num(typeof hr === "object" ? hr[c] : hr);
["M", "L", "S"].forEach((c) => pct(c) && (u[c] = roundDown(u[c] * (1 + pct(c) / 100), 0)));
}
u.T = u.M + u.L + u.S;
const qty =
Number(
slot > 1 && row.qtys
? row.qtys[slot - 1]
: slot > 1 && row.changedQty !== undefined
? row.changedQty
: row.qty,
) || 0;
const rawM = u.M * qty,
rawL = u.L * qty,
rawS = u.S * qty,
rawT = u.T * qty;
let M = roundDown(rawM, 0),
L = roundDown(rawL, 0),
S = roundDown(rawS, 0),
T = M + L + S;
let optimizDiff = 0,
optimizTarget = "";
if (chnMode === "total") {
const targetT = roundDown(rawT, 0);
optimizDiff = targetT - (M + L + S);
if (optimizDiff !== 0) {
const fracMap = { S: rawS - S, M: rawM - M, L: rawL - L };
let bestKey = "S",
bestFrac = -1;
for (const k of optOrder.split("").filter((c) => ["S", "M", "L"].includes(c))) {
if (fracMap[k] > bestFrac) ((bestFrac = fracMap[k]), (bestKey = k));
}
if (bestKey === "S") S += optimizDiff;
else if (bestKey === "M") M += optimizDiff;
else L += optimizDiff;
T = targetT;
optimizTarget = `${bestKey}(+${optimizDiff})`;
}
}
const aggM = roundHalfUp(rawM, 0),
aggL = roundHalfUp(rawL, 0),
aggS = roundHalfUp(rawS, 0);
if (row.rowType === "gov_mat") govRawTotal += T;
else if (row.rowType === "gov_fee") govFeeTotal += T;
else ((directM += M), (directL += L), (directS += S));
const out = Object.assign({}, row, {
spec: row.spec || u.spec || "",
unit: row.unit || u.unit || "",
});
const u1 = {
unitInfo: u,
appliedQty: qty,
qty,
mUnit: u.M,
lUnit: u.L,
sUnit: u.S,
tUnit: u.T,
};
const u2 = { mAmt: M, lAmt: L, sAmt: S, tAmt: T, M, L, S, T, optimizDiff, optimizTarget };
return Object.assign(out, u1, u2, { aggM, aggL, aggS, aggT: aggM + aggL + aggS });
});
const directT = directM + directL + directS,
govCombinedRaw = govRawTotal + govFeeTotal;
const totals = Object.assign(
{ directM, directL, directS, directT },
{ govRawTotal, govMatTotal: govRawTotal, govFeeTotal, govCombinedRaw },
);
return Object.assign({ rows, directTotal: directT, totals }, totals);
}
function evalKOrdTransition(pclass, regMode, flag, currentK) {
const K = Number(currentK) || 1;
const nextK =
Number(regMode) === 6
? Number(pclass) === 6
? K + 1
: 1
: Number(regMode) === 7 && Number(pclass) === 7 && Number(flag) === 0
? K + 1
: 1;
return { nextK, downK: K - (K > 5 ? K - 4 : 1) };
}
// 새 공사 = 빈 상태 · 요율만 초기값(지식DB 06 · 2024 기준표) — 특정 공사 금액을 담지 않음
function createDefaultState() {
const ratePairs =
"KANNO:14.5,SANBO:3.56,KOBO:1.01,KUNBO:3.545,SLVBO:12.95,YUNBO:4.5,TBUGUM:0,ANJUN:1.86,ANJUN1:1.86,ANJUN2:1.86,KITA:6,HWAN:0.8,EQIP:0.4,ILBAN:6,IYUN:15,S_SUDO:0,S_BOKRI:0,S_SOMO:0,S_TONGSIN:0,S_SEGUM:0,S_PEGI:0,S_DOSEO:0,S_JIGUP:0";
const rates = Object.fromEntries(
ratePairs.split(",").map((p) => [p.split(":")[0], Number(p.split(":")[1])]),
);
const revKeys = "KANNO,SANBO,KOBO,KUNBO,SLVBO,YUNBO,TBUGUM,ANJUN,KITA,HWAN,EQIP".split(",");
const revModes = Object.fromEntries(
revKeys.map((k) => [
k,
["KUNBO", "SLVBO", "YUNBO"].includes(k) ? "carry_original" : "recalculated",
]),
);
const projectMeta = { pname: "새 공사", pnum: "", pcode: "11", pclass: 0, kOrd: 0 };
const sysConfig = {
rxFormula: "1/8*16/12*25/20",
condCodes: "",
usdRate1: 0,
usdRate2: 0,
useChangedUsd: false,
ecdDigits: 0,
chnMode: "bimok",
optOrder: "SML",
};
sysConfig.kOrdSim = { pclass: 0, regMode: 7, flag: 0, currentK: 1 };
// 원본 키 초기값 — 일반형식 · 안전비 관급포함 자동 · 고용 7등급 · 환경 기타토목 · 사급 재료비 포함 · 관급 천원 절상
const keys = Object.fromEntries(
"JAB_TYPE:0,CAL_ACTL:0,I_KONG_TYPE:0,I_SET_BASE:0,I_KONG_KIKAN:0,I_KOBO_TYPE:7,I_TBUGUM:3,I_ANJUN_TYPE:1,I_HWAN:14,I_EQIP:0,I_POS_SA:3,I_KN_SA_TYPE:0,I_ANJUN_K:1,I_ANJUN_B:0,I_ANJUNAUTO:1,I_BUGA:0,I_TOT_JEOL:3,I_KWN_JEOLSA:3,I_KWN_K_JEOLSA:3,I_KWN_UPDN:0,I_KWN_K_UPDN:0,I_KAN_JEOLSA:0,I_JEOLSA:0,I_DOG_JEOL:0,C_ANJUN_G:0,C_ANJUN1_G:0,C_ANJUN2_G:0,C_KWANGUP_D:0,C_KWANGUP_K:0,C_T_JODAL:0,C_IYUN_M:0"
.split(",")
.map((p) => [p.split(":")[0], Number(p.split(":")[1])]),
);
const kanjubConfig = {
slot: 1,
scheme: "T",
keys,
addRows: [],
addAmounts: [],
fixed: {},
anjunAuto: 1,
anjunBaseAdd: 0,
};
Object.assign(kanjubConfig, { rates, revModes });
return Object.assign(
{ projectMeta, sysConfig, kanjubConfig },
{ resources: [], machines: [], sanItems: [], ilwiItems: [], boqItems: [] },
);
}
function calculateAll(data) {
const resources = calcResources(data),
machines = calcMachines(data, resources.map),
sanCal = calcSanCal(data, machines.map);
const ilwi = calcIlwi(data, resources.map, machines.map, sanCal.map);
const boqOrig = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, false),
kanjubOrig = calcKanjub(data, boqOrig, null);
const st = data.kanjubStages || {};
const boqRev = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, true),
kanjubRev = st[2]
? calcKanjub(data, boqRev, kanjubOrig, st[2])
: calcKanjub(data, boqRev, kanjubOrig);
// 차수 n 원가계산서 — 변경 계열은 바로 앞 차수(n−1)를 당초로 씀(REA 18 직전 계열 = 현재−1)
const stages = {};
for (const n of Object.keys(st)
.map(Number)
.sort((a, b) => a - b)) {
const boq =
n === 1
? boqOrig
: n === 2
? boqRev
: calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, n);
const prev = (stages[n - 1] && stages[n - 1].kanjub) || kanjubOrig;
stages[n] = {
boq,
kanjub: n === 1 ? kanjubOrig : n === 2 ? kanjubRev : calcKanjub(data, boq, prev, st[n]),
};
}
const revMap = Object.fromEntries((boqRev.rows || []).map((r) => [r.id, r]));
(boqOrig.rows || []).forEach((r) => {
const rv = revMap[r.id];
r.changedTAmt = rv ? rv.tAmt : r.tAmt;
r.diffTAmt = r.changedTAmt - r.tAmt;
});
const base = { resources, machines, sanCal, ilwi, boqOrig, kanjubOrig };
Object.assign(base, {
boqRev,
kanjubRev,
resList: resources.list,
revComputed: { revBoq: boqRev, revKj: kanjubRev },
stages,
});
return Object.assign(base, { boqComputed: boqOrig, kanjubComputed: kanjubOrig });
}
const evalLeftToRight = (exprStr) => evalWageFormulaLeftToRight(1, exprStr).coeff || 0;
const api = { KINDS, KIND_KEYS, num, fmt, evalFormula, evalLeftToRight };
Object.assign(api, { roundDown, roundUp, roundHalfUp, roundBanker });
Object.assign(api, { evalWageFormulaLeftToRight, evalQFormula, evalKOrdTransition });
return Object.assign(api, { createDefaultState, calculateAll, computeAll: calculateAll });
})();
if (typeof window !== "undefined") window.STMATE_CALC = StmateCalc;
if (typeof globalThis !== "undefined") globalThis.STMATE_CALC = StmateCalc;
if (typeof module !== "undefined" && module.exports) module.exports = StmateCalc;