/* -*- coding: utf-8 -*- */ /** * STMATE 계산 사슬 및 제어 옵션 시뮬레이터 엔진 (calc.js) * 근거: resources/knowledge/original/경쟁사 프로그램/STmate 분석/ (REA 정적분석 및 골든셋 회귀) */ const StmateCalc = (() => { const KINDS = ["재료비", "노무비", "경비"]; const KIND_KEYS = { 재료비: "M", 노무비: "L", 경비: "S" }; // MROUNDUD(selector=0): 0 방향 절사 (ROUNDDOWN) function roundDown(val, digits = 0) { const n = Number(val) || 0; const f = Math.pow(10, digits); const s = n * f; return Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)) / f; } // MROUNDUP: 0에서 먼 방향 올림 (ROUNDUP) function roundUp(val, digits = 0) { const n = Number(val) || 0; if (n === 0) return 0; const f = Math.pow(10, digits); const s = n * f; return (n > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9)) / f; } // 사사오입 (Excel ROUND / 세전 끝전조정 / 집계표) function roundHalfUp(val, digits = 0) { const n = Number(val) || 0; const f = Math.pow(10, digits); const r = Math.floor(Math.abs(n) * f + 0.5 + 1e-9) / f; return n < 0 ? -r : r; } // 은행가 반올림 (Delphi System.Round 최근접 짝수) function roundBanker(val, digits = 0) { const n = Number(val) || 0; const f = Math.pow(10, digits); const s = n * f; const fl = Math.floor(s); if (Math.abs(s - fl - 0.5) < 1e-9) return (fl % 2 === 0 ? fl : fl + 1) / f; return Math.round(s) / f; } function applyRoundMode(val, mode, digits = 0) { if (mode === "up") return roundUp(val, digits); if (mode === "half") return roundHalfUp(val, digits); if (mode === "banker") return roundBanker(val, digits); return roundDown(val, digits); } // 1. 노임 시간당 환산식 좌→우 순차 평가 (RXNM_ = "1/8*16/12*25/20", 18번 문서 §2.1) function evalWageFormulaLeftToRight(dailyWage, exprStr) { const base = Number(dailyWage) || 0; const clean = String(exprStr || "").replace(/\s+/g, ""); if (!clean) return { seqResult: base, preCalcResult: base, steps: [] }; const tokens = clean.match(/([*/])?(\d+(?:\.\d+)?)/g) || []; let acc = base, coeff = 1; const steps = []; for (const tok of tokens) { const op = tok[0] === "*" || tok[0] === "/" ? tok[0] : "*"; const num = parseFloat(op === tok[0] ? tok.slice(1) : tok); if (!Number.isFinite(num) || num === 0) continue; acc = op === "*" ? acc * num : acc / num; coeff = op === "*" ? coeff * num : coeff / num; steps.push(`${op}${num} → ${roundHalfUp(acc, 4)}`); } return { seqResult: roundDown(acc, 0), preCalcResult: Math.trunc(base * coeff), coeff, steps }; } // 2. 기초자원 후보단가 채택 및 노임 환산 처리 function calcResources(data) { const sys = data.sysConfig || {}; const 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; const 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 = 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} 고정`; } else { basePrice = Number(res.manualPrice) || 0; chosenLabel = "수동입력"; } const krwPrice = res.isUsd ? roundDown(basePrice * usdRate, ecdDigits) : basePrice; let adoptedPrice = krwPrice, wageInfo = null; if (res.kind === "노무비" && (res.convertHourly || condCodes.has(res.code))) { wageInfo = evalWageFormulaLeftToRight(krwPrice, rxFormula); adoptedPrice = wageInfo.seqResult; } const item = { ...res, cands, basePrice, adoptedPrice, chosenLabel, wageInfo }; map[res.id] = item; map[res.code] = item; return item; }); return { list, map }; } // 3. 중기 시간당 사용료(X 코드) 계산 (17번 문서 §3) function calcMachines(data, resMap) { const map = {}; const list = (data.machines || []).map((m) => { const opHourly = resMap[m.operatorId]?.adoptedPrice || 0; const fuelPrice = resMap[m.fuelId]?.adoptedPrice || 0; const rawFuel = (Number(m.fuelConsumption) || 0) * fuelPrice; const M = roundDown(rawFuel * (1 + (Number(m.miscRatePercent) || 0) / 100), 0); const L = roundDown(opHourly * (Number(m.operatorCount) || 1), 0); const S = roundDown((Number(m.acquisitionPrice) || 0) * (Number(m.hourlyRateFactor) || 0), 0); const item = { ...m, M, L, S, T: M + L + S, opHourly, fuelPrice }; map[m.id] = item; map[m.code] = item; return item; }); return { list, map }; } // 4. 단가산출근거(D 코드, Q식) 계산 (18번 문서 §2.2) function evalQFormula(dItem) { const q = Number(dItem.q) || 0, K = Number(dItem.K) || 0, f = Number(dItem.f) || 0; const E = Number(dItem.E) || 0, Cm = Number(dItem.Cm) || 1; const 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]; const { rawQ, Q } = evalQFormula(d); const M = mach && Q > 0 ? roundDown(mach.M / Q, 1) : 0; const L = mach && Q > 0 ? roundDown(mach.L / Q, 1) : 0; const S = mach && Q > 0 ? roundDown(mach.S / Q, 1) : 0; const intM = roundDown(M, 0), intL = roundDown(L, 0), intS = roundDown(S, 0); const item = { ...d, mach, rawQ, Q, M, L, S, T: roundHalfUp(M + L + S, 1), intM, intL, intS, intT: intM + intL + intS, }; map[d.id] = item; map[d.code] = item; return item; }); return { list, map }; } // 5. 일위대가(B 코드) 조립 (17번 문서 §2, 30번 문서 DEVD1·X_HAL) 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]; const 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); let uM = 0, uL = 0, uS = 0; if (ref) { if ("adoptedPrice" in ref) { if (ref.kind === "재료비") uM = ref.adoptedPrice; else if (ref.kind === "노무비") uL = ref.adoptedPrice; else uS = ref.adoptedPrice; } else { uM = ref.M || 0; uL = ref.L || 0; uS = ref.S || 0; } } const lineM = roundDown(uM * appliedQty, 2), lineL = roundDown(uL * appliedQty, 2), lineS = roundDown(uS * appliedQty, 2); sumM += lineM; sumL += lineL; sumS += lineS; return { ...ln, refName: ref?.name || "미연결", refCode: ref?.code || "-", appliedQty, uM, uL, uS, lineM, lineL, lineS, lineT: roundHalfUp(lineM + lineL + lineS, 2), }; }); const M = roundDown(sumM, 0), L = roundDown(sumL, 0), S = roundDown(sumS, 0); const item = { ...ilwi, lines, M, L, S, T: M + L + S }; map[ilwi.id] = item; map[ilwi.code] = item; return item; }); return { list, map }; } // 6. 내역서(BOQ) 및 ST_Func_C::OPTIMIZ 차액보정 계산 (15단계 메모, 16번 문서) function calcBoq(data, resMap, machMap, sanMap, ilwiMap, useChangedQty = false) { const sys = data.sysConfig || {}; const chnMode = sys.chnMode || "bimok"; const 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: "-" }; if ("adoptedPrice" in r) { const M = r.kind === "재료비" ? r.adoptedPrice : 0, L = r.kind === "노무비" ? r.adoptedPrice : 0, S = r.kind === "경비" ? r.adoptedPrice : 0; return { M, L, S, T: M + L + S, name: r.name, code: r.code, unit: r.unit }; } if ("intT" in r) return { M: r.intM, L: r.intL, S: r.intS, T: r.intT, name: r.name, code: r.code, unit: r.unit, }; return { M: r.M, L: r.L, S: r.S, T: r.T, name: r.name, code: r.code, unit: r.unit }; }; let directM = 0, directL = 0, directS = 0, govRawTotal = 0, govFeeTotal = 0; const rows = (data.boqItems || []).map((row) => { const u = lookupUnit(row.refId); const qty = Number(useChangedQty && 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; } return { ...row, unitInfo: u, appliedQty: qty, M, L, S, T, optimizDiff, optimizTarget, aggM, aggL, aggS, aggT: aggM + aggL + aggS, }; }); return { rows, directM, directL, directS, directTotal: directM + directL + directS, govRawTotal, govFeeTotal, govCombinedRaw: govRawTotal + govFeeTotal, }; } // 7. 제잡비 및 원가계산서 (ST_KanJub.CPM :: Calc_Kanjub, 06·12·16번 문서) function calcKanjub(data, boqRes, origKanjubRes = null) { const cfg = data.kanjubConfig || {}, scheme = cfg.scheme || "T", rates = cfg.rates || {}, revModes = cfg.revModes || {}; const indTruncDigits = Number(cfg.indirectTruncDigits) || 0; const dM = boqRes.directM, dL = boqRes.directL, dS = boqRes.directS, dT = boqRes.directTotal, govPureMat = boqRes.govRawTotal; const truncInd = (v) => roundDown(v, indTruncDigits); function resolveRow(code, label, baseDesc, baseAmt, ratePct, rawCalcAmt) { const truncAmt = truncInd(rawCalcAmt); if (!origKanjubRes) return { code, label, baseDesc, baseAmt, ratePct, amount: truncAmt, modeUsed: "원설계" }; const origRow = origKanjubRes.itemMap[code]; const origAmt = origRow ? origRow.amount : 0, origBase = origRow ? origRow.baseAmt : 0; const mode = revModes[code] || "recalculated"; let finalAmt = truncAmt; if (mode === "carry_original") finalAmt = origAmt; else if (mode === "proportional") finalAmt = origAmt + truncInd(origAmt * (origBase > 0 ? (baseAmt - origBase) / origBase : 0)); else if (mode === "delta_rate") finalAmt = origAmt + truncInd(((baseAmt - origBase) * ratePct) / 100); return { code, label, baseDesc, baseAmt, ratePct, amount: finalAmt, origAmt, deltaAmt: finalAmt - origAmt, modeUsed: mode, }; } const items = [], itemMap = {}; const pushItem = (row) => { items.push(row); itemMap[row.code] = row; return row.amount; }; const rKanno = Number(rates.KANNO) ?? 14.5; const kanno = pushItem( resolveRow("KANNO", "간접노무비", "직접노무비", dL, rKanno, (dL * rKanno) / 100), ); const totalLabor = dL + kanno; pushItem( resolveRow( "SANBO", "산재보험료", "노무비(직노+간노)", totalLabor, Number(rates.SANBO) ?? 3.56, (totalLabor * (Number(rates.SANBO) ?? 3.56)) / 100, ), ); pushItem( resolveRow( "KOBO", "고용보험료", "노무비(직노+간노)", totalLabor, Number(rates.KOBO) ?? 1.01, (totalLabor * (Number(rates.KOBO) ?? 1.01)) / 100, ), ); const kunbo = pushItem( resolveRow( "KUNBO", "건강보험료", "직접노무비", dL, Number(rates.KUNBO) ?? 3.545, (dL * (Number(rates.KUNBO) ?? 3.545)) / 100, ), ); pushItem( resolveRow( "SLVBO", "노인장기요양보험료", "건강보험료(확정액)", kunbo, Number(rates.SLVBO) ?? 12.95, (kunbo * (Number(rates.SLVBO) ?? 12.95)) / 100, ), ); pushItem( resolveRow( "YUNBO", "연금보험료", "직접노무비", dL, Number(rates.YUNBO) ?? 4.5, (dL * (Number(rates.YUNBO) ?? 4.5)) / 100, ), ); pushItem( resolveRow( "TBUGUM", "퇴직공제부금비", "직접노무비", dL, Number(rates.TBUGUM) ?? 0, (dL * (Number(rates.TBUGUM) ?? 0)) / 100, ), ); const rAnjun = Number(rates.ANJUN) ?? 1.86, anjunBaseAdd = Number(cfg.anjunBaseAdd) || 5349000; const anjunGovDiv = cfg.anjunGovVatExcluded !== false ? govPureMat / 1.1 : govPureMat; const anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL; const anjunBaseWithGov = anjunBaseNoGov + anjunGovDiv; const anjunNormal = truncInd((anjunBaseWithGov * rAnjun) / 100) + anjunBaseAdd; const anjunCap12 = truncInd(((anjunBaseNoGov * rAnjun) / 100 + anjunBaseAdd) * 1.2); const anjunRaw = cfg.anjunCap12 !== false ? Math.min(anjunNormal, anjunCap12) : anjunNormal; pushItem( resolveRow( "ANJUN", "산업안전보건관리비", scheme === "TA" || scheme === "SA" ? "직접공사비+관급원자재/1.1 (+기초액)" : "직재+직노+관급원자재/1.1 (+기초액)", roundHalfUp(anjunBaseWithGov, 0), rAnjun, anjunRaw, ), ); if (scheme === "S" || scheme === "SA") { const suBase = dM + totalLabor; for (const [sc, sl, sr] of [ ["S_SUDO", "수도광열비", Number(rates.S_SUDO) ?? 0.45], ["S_BOKRI", "복리후생비", Number(rates.S_BOKRI) ?? 1.15], ["S_SOMO", "소모품·사무용품비", Number(rates.S_SOMO) ?? 0.65], ["S_TONGSIN", "여비·교통·통신비", Number(rates.S_TONGSIN) ?? 0.85], ["S_SEGUM", "세금·공과금", Number(rates.S_SEGUM) ?? 0.55], ["S_PEGI", "폐기물처리비", Number(rates.S_PEGI) ?? 0.35], ["S_DOSEO", "도서인쇄비", Number(rates.S_DOSEO) ?? 0.25], ["S_JIGUP", "지급수수료", Number(rates.S_JIGUP) ?? 0.45], ]) pushItem( resolveRow( sc, `[수공] ${sl}`, "재료비+노무비(직노+간노)", suBase, sr, (suBase * sr) / 100, ), ); } else { const rKita = Number(rates.KITA) ?? 6.0, kitaBase = scheme === "TA" ? dT : dM + totalLabor; pushItem( resolveRow( "KITA", "기타경비", scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)", kitaBase, rKita, (kitaBase * rKita) / 100, ), ); } const hwanBase = scheme === "TA" ? dT : dM + dL + dS, hwanDesc = scheme === "TA" ? "직접공사비(실적일반)" : "재료비+직접노무비+산출경비"; pushItem( resolveRow( "HWAN", "환경보전비", hwanDesc, hwanBase, Number(rates.HWAN) ?? 0.8, (hwanBase * (Number(rates.HWAN) ?? 0.8)) / 100, ), ); pushItem( resolveRow( "EQIP", "건설기계대여대금 지급보증", hwanDesc, hwanBase, Number(rates.EQIP) ?? 0.4, (hwanBase * (Number(rates.EQIP) ?? 0.4)) / 100, ), ); const indirectExpenseSum = items .filter((x) => x.code !== "KANNO") .reduce((acc, x) => acc + x.amount, 0); const totalExpense = dS + indirectExpenseSum, pureConstructionCost = dM + totalLabor + totalExpense; const rIlban = Number(rates.ILBAN) ?? 6.0, ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost; const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)"; const ilbanAmt = truncInd((ilbanBase * rIlban) / 100); const rIyun = Number(rates.IYUN) ?? 15.0; const iyunBase = scheme === "TA" || scheme === "SA" ? pureConstructionCost + ilbanAmt : totalLabor + totalExpense + ilbanAmt; const iyunDesc = scheme === "TA" || scheme === "SA" ? "직접비+간접비+일반관리비(실적형식)" : "노무비+경비+일반관리비(재료비 제외)"; const iyunRaw = truncInd((iyunBase * rIyun) / 100); const vatMode = cfg.vatMode || "supply_10", totJeolDigits = Number(cfg.totJeolDigits) ?? -3; const calcVat = (sVal) => vatMode === "zero" ? 0 : vatMode === "mat_10" ? roundDown(dM * 0.1, 0) : vatMode === "mat_exp_10" ? roundDown((dM + dS) * 0.1, 0) : roundDown(sVal * 0.1, 0); const supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw, vatBeforeAdj = calcVat(supplyBeforeAdj), contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj; let contractTarget = contractBeforeAdj, diffTotal = 0, pretaxProfitAdj = 0, iyunFinal = iyunRaw, supplyFinal = supplyBeforeAdj, vatFinal = vatBeforeAdj; if (totJeolDigits < 0 && cfg.enableProfitEndAdj !== false) { contractTarget = roundDown(contractBeforeAdj, totJeolDigits); diffTotal = contractBeforeAdj - contractTarget; pretaxProfitAdj = vatMode === "supply_10" ? roundHalfUp(diffTotal / 1.1, 0) : diffTotal; iyunFinal = iyunRaw - pretaxProfitAdj; supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal; vatFinal = calcVat(supplyFinal); if (supplyFinal + vatFinal !== contractTarget) vatFinal = contractTarget - supplyFinal; } const contractFinal = supplyFinal + vatFinal; const govFinal = boqRes.govCombinedRaw > 0 ? applyRoundMode( boqRes.govCombinedRaw, cfg.govRoundMode || "up", Number(cfg.govRoundDigits) ?? -3, ) : 0; return { scheme, dM, dL, dS, dT, kanno, totalLabor, indirectExpenseSum, totalExpense, pureConstructionCost, ilbanBase, ilbanDesc, ilbanAmt, iyunBase, iyunDesc, iyunRaw, pretaxProfitAdj, iyunFinal, supplyBeforeAdj, vatBeforeAdj, contractBeforeAdj, diffTotal, supplyFinal, vatFinal, contractFinal, govRawTotal: boqRes.govRawTotal, govFeeTotal: boqRes.govFeeTotal, govFinal, grandTotal: contractFinal + govFinal, items, itemMap, }; } // 8. K_ORD 등록·차수내리기 전이 규칙 시뮬레이션 (STMATE_REA_최종보고서 §4.1) 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) }; } function calculateAll(data) { const resources = calcResources(data); const machines = calcMachines(data, resources.map); const 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); const kanjubOrig = calcKanjub(data, boqOrig, null); const boqRev = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, true); const kanjubRev = calcKanjub(data, boqRev, kanjubOrig); return { resources, machines, sanCal, ilwi, boqOrig, kanjubOrig, boqRev, kanjubRev }; } return { KINDS, KIND_KEYS, roundDown, roundUp, roundHalfUp, roundBanker, evalWageFormulaLeftToRight, evalQFormula, evalKOrdTransition, calculateAll, }; })(); if (typeof module !== "undefined" && module.exports) module.exports = StmateCalc;