/* -*- 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 }; } // 총액우선 끝전 배분(OPTIMIZ) — 성분 절사 · 총액 절사와의 차 1원씩 소수부 큰 성분부터(동률 = 순서 앞) function optimize(raw, rawT, order) { const out = { M: roundDown(raw.M, 0), L: roundDown(raw.L, 0), S: roundDown(raw.S, 0) }; const keys = String(order || "SML") .toUpperCase() .split("") .filter((k) => "MLS".includes(k)); const frac = Object.fromEntries(keys.map((k) => [k, roundHalfUp(raw[k] - out[k], 6)])); let rest = roundDown(rawT, 0) - out.M - out.L - out.S; while (rest > 0) { let pick = null; for (const k of keys) if (frac[k] < 1 && (pick === null || frac[k] > frac[pick])) pick = k; if (pick === null) break; ((out[pick] += 1), (frac[pick] += 1), (rest -= 1)); } return out; } // 내역 비율 줄(PR · 조달수수료 등) — 밑수 비목(PRIN) 누적 × 율(QTY, %) → 대상 비목(PROUT) 원 미만 절사 // 단가 칸 = 밑수 · 수량 칸 = 율 (원본 BOQ MAMT1 · QTY1 · M_AMT1 저장 꼴) · PRIN 없는 줄은 일반 줄 const PR_COMP = { 1: "M", 2: "L", 3: "S", 4: "T" }; function prRow(row, slot, base) { if (String(row.code || "").trim() !== "PR") return null; const bk = PR_COMP[String(row.PRIN ?? "").trim()], ok = PR_COMP[String(row.PROUT ?? "").trim()]; if (!bk || !ok) return null; const pct = num((row.prQtys || [])[slot - 1]); const bv = bk === "T" ? base.M + base.L + base.S : base[bk]; const to = ok === "T" ? "M" : ok; const a = { M: 0, L: 0, S: 0 }, u = { M: 0, L: 0, S: 0 }; ((a[to] = roundDown((bv * pct) / 100, 0)), (u[to] = bv)); const T = a.M + a.L + a.S; const unitInfo = { M: u.M, L: u.L, S: u.S, T: bv, name: row.name, code: "PR", spec: "", unit: "%", }; const out = { unitInfo, appliedQty: pct, qty: pct, mUnit: u.M, lUnit: u.L, sUnit: u.S, tUnit: bv, }; Object.assign(out, { mAmt: a.M, lAmt: a.L, sAmt: a.S, tAmt: T, M: a.M, L: a.L, S: a.S, T }); Object.assign(out, { optimizDiff: 0, optimizTarget: "", aggM: a.M, aggL: a.L, aggS: a.S, aggT: T, }); return { M: a.M, L: a.L, S: a.S, T, out }; } 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; // 내역 비율 줄(PR) 밑수 — 표시(PRPT) 줄 금액 누적 · PR 줄을 지나면 비움(영월 조달수수료 4줄 대조) let prBase = { M: 0, L: 0, S: 0 }; const rows = (data.boqItems || []).map((row) => { const pr = prRow(row, slot, prBase); if (pr) { prBase = { M: 0, L: 0, S: 0 }; if (row.rowType === "gov_mat") govRawTotal += pr.T; else if (row.rowType === "gov_fee") govFeeTotal += pr.T; else ((directM += pr.M), (directL += pr.L), (directS += pr.S)); return Object.assign({}, row, pr.out); } 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") { // 총액우선(CHNNM=0) — REA 15 OPTIMIZ: 총액 절사 후 남는 1원을 소수부 큰 성분부터 하나씩 // (동률은 OPTNM 순서 앞쪽) · 산근·일위대가(smt_eval finish)와 같은 규칙 const got = optimize({ M: rawM, L: rawL, S: rawS }, rawT, optOrder); optimizDiff = got.M + got.L + got.S - (M + L + S); if (optimizDiff) { optimizTarget = "MLS" .split("") .filter((k) => got[k] !== { M, L, S }[k]) .map((k) => `${k}(+${got[k] - { M, L, S }[k]})`) .join(""); ((M = got.M), (L = got.L), (S = got.S), (T = M + L + S)); } } const aggM = roundHalfUp(rawM, 0), aggL = roundHalfUp(rawL, 0), aggS = roundHalfUp(rawS, 0); if (String(row.PRPT ?? "0").trim() && String(row.PRPT ?? "0").trim() !== "0") ((prBase.M += M), (prBase.L += L), (prBase.S += S)); 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;