/* -*- coding: utf-8 -*- */ /** * STMATE 계산 사슬 및 제어 옵션 시뮬레이터 UI 컨트롤러 (app.js) */ const STORAGE_KEY = "aislo_stmate_test_v1"; function createDefaultSample() { const mkRes = (id, name, spec, unit, kind, candidates, manualPrice, convertHourly = false) => ({ id, code: id, name, spec, unit, kind, candidates, adoptMode: "min", manualPrice, convertHourly, isUsd: false, }); return { sysConfig: { rxFormula: "1/8*16/12*25/20", condCodes: "L00008, L00009, L00010", usdRate1: 1350, usdRate2: 1400, useChangedUsd: false, ecdDigits: 0, chnMode: "bimok", optOrder: "SML", kOrdSim: { pclass: 7, regMode: 7, flag: 0, currentK: 2 }, }, resources: [ mkRes( "L00008", "건설기계운전사 (일노임→시간당)", "표준직종", "hr", "노무비", [0, 0, 0, 0, 267360], 267360, true, ), mkRes("L00001", "보통인부", "시중노임", "인", "노무비", [165545, 0, 0, 0, 165545], 165545), mkRes("L00002", "특별인부", "시중노임", "인", "노무비", [212410, 0, 0, 0, 212410], 212410), mkRes( "M00001", "경유 (중기연료)", "저유황", "L", "재료비", [1520, 1480, 1453, 1510, 1490], 1453, ), mkRes( "M00002", "레미콘 (사급자재)", "25-24-150", "㎥", "재료비", [92000, 89500, 91000, 88700, 90000], 88700, ), mkRes( "M00901", "관급 원심력콘크리트관(흄관)", "D1000mm", "본", "재료비", [285000, 279000, 281000, 0, 279000], 279000, ), mkRes( "S00902", "조달수수료 (관급부대비)", "0.54%", "식", "경비", [771029, 0, 0, 0, 771029], 771029, ), ], machines: [ { id: "X00001", code: "X00001", name: "굴삭기 (무한궤도)", spec: "0.2 ㎥", unit: "hr", operatorId: "L00008", operatorCount: 1, fuelId: "M00001", fuelConsumption: 5.0, miscRatePercent: 6.0, acquisitionPrice: 41800000, hourlyRateFactor: 0.000312, }, ], sanItems: [ { id: "D00002", code: "D00002", name: "구조물 터파기(토사) 굴삭기 0.2㎥", spec: "기계 100%", unit: "㎥", machineId: "X00001", formulaType: "3600_qKfE_Cm", q: 0.2, K: 0.7, f: 0.85, E: 0.55, Cm: 15, }, ], ilwiItems: [ { id: "B00001", code: "B00001", name: "소형 구조물 기초 콘크리트 타설 및 터파기 복합공", spec: "인력+기계복합", unit: "㎥", lines: [ { refId: "M00002", qty: 1.0, halPercent: 2.0, devd: false }, { refId: "L00001", qty: 0.35, halPercent: 0, devd: false }, { refId: "L00002", qty: 0.15, halPercent: 0, devd: false }, { refId: "D00002", qty: 1.2, halPercent: 0, devd: false }, ], }, ], boqItems: [ { id: "BOQ1", code: "D00002", name: "본선 구조물 터파기 (토사)", refId: "D00002", rowType: "normal", qty: 1250.5, changedQty: 1420.0, }, { id: "BOQ2", code: "B00001", name: "배수 구조물 콘크리트 및 기초 복합시공", refId: "B00001", rowType: "normal", qty: 340.3, changedQty: 395.0, }, { id: "BOQ3", code: "M00901", name: "[제외구역 C4] 관급 흄관 자재대 (원자재)", refId: "M00901", rowType: "gov_mat", qty: 120, changedQty: 120, }, { id: "BOQ4", code: "S00902", name: "[제외구역 C4] 관급 조달수수료", refId: "S00902", rowType: "gov_fee", qty: 1, changedQty: 1, }, ], kanjubConfig: { scheme: "T", indirectTruncDigits: 0, anjunBaseAdd: 5349000, anjunGovVatExcluded: true, anjunCap12: true, vatMode: "supply_10", totJeolDigits: -3, enableProfitEndAdj: true, govRoundMode: "up", govRoundDigits: -3, rates: { KANNO: 14.5, SANBO: 3.56, KOBO: 1.01, KUNBO: 3.545, SLVBO: 12.95, YUNBO: 4.5, TBUGUM: 0.0, ANJUN: 1.86, KITA: 6.0, HWAN: 0.8, EQIP: 0.4, ILBAN: 6.0, IYUN: 15.0, S_SUDO: 0.45, S_BOKRI: 1.15, S_SOMO: 0.65, S_TONGSIN: 0.85, S_SEGUM: 0.55, S_PEGI: 0.35, S_DOSEO: 0.25, S_JIGUP: 0.45, }, revModes: { KANNO: "recalculated", SANBO: "recalculated", KOBO: "recalculated", KUNBO: "carry_original", SLVBO: "carry_original", YUNBO: "carry_original", TBUGUM: "recalculated", ANJUN: "recalculated", KITA: "recalculated", HWAN: "recalculated", EQIP: "recalculated", }, }, }; } let state = loadState(); let activeTab = "resources"; let selectedIlwiId = state.ilwiItems[0]?.id || ""; function loadState() { try { const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return createDefaultSample(); const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.resources) && parsed.kanjubConfig) return parsed; } catch (_) {} return createDefaultSample(); } function saveState() { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (_) {} } const $ = (id) => document.getElementById(id); const esc = (s) => String(s ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], ); const fmt = (n, maxDec = 2) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0); const uid = (prefix) => `${prefix}${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 5)}`; function renderAll() { const res = StmateCalc.calculateAll(state); renderKpi(res); document.querySelectorAll("#mainTabs button[data-tab]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tab === activeTab); }); const panel = $("tabPanel"); if (activeTab === "resources") panel.innerHTML = renderResourcesTab(res); else if (activeTab === "machines") panel.innerHTML = renderMachinesTab(res); else if (activeTab === "ilwi") panel.innerHTML = renderIlwiTab(res); else if (activeTab === "boq") panel.innerHTML = StmateCostViews.renderBoqTab(state, res); else if (activeTab === "kanjub") panel.innerHTML = StmateCostViews.renderKanjubTab(state, res); else if (activeTab === "revision") panel.innerHTML = StmateCostViews.renderRevisionTab(state, res); } function renderKpi(res) { const k = res.kanjubOrig, kr = res.kanjubRev; const cards = [ [ "직접공사비 (재+직노+산출경비)", `${fmt(k.dT, 0)} 원`, `재료 ${fmt(k.dM, 0)} · 직노 ${fmt(k.dL, 0)} · 산출경비 ${fmt(k.dS, 0)}`, ], [ "순공사원가 (직접비+간접비)", `${fmt(k.pureConstructionCost, 0)} 원`, `간접노무비 ${fmt(k.kanno, 0)} · 제경비소계 ${fmt(k.indirectExpenseSum, 0)}`, ], [ "도급공사비 (끝전보정 후)", `${fmt(k.contractFinal, 0)} 원`, `이윤 세전조정: ${k.pretaxProfitAdj >= 0 ? "-" : "+"}${fmt(Math.abs(k.pretaxProfitAdj), 0)} 원`, ], [ "총공사비 (도급 + 관급자재)", `${fmt(k.grandTotal, 0)} 원`, `관급자재대 ${fmt(k.govFinal, 0)} · 변경차액 ${kr.grandTotal - k.grandTotal >= 0 ? "+" : ""}${fmt(kr.grandTotal - k.grandTotal, 0)}`, ], ]; $("kpiSummary").innerHTML = cards .map( ([title, val, sub]) => `
${esc(title)}${esc(val)}${esc(sub)}
`, ) .join(""); } function renderResourcesTab(res) { const sys = state.sysConfig; const rowsHtml = res.resources.list .map((r, i) => { const candInputs = [0, 1, 2, 3, 4] .map( (ci) => ``, ) .join(""); const modeOpts = [ ["min", "최저가 자동(0제외)"], ["slot1", "후보1(조달) 고정"], ["slot2", "후보2(물자) 고정"], ["slot3", "후보3(물정) 고정"], ["slot4", "후보4(거래) 고정"], ["slot5", "후보5(견적) 고정"], ["manual", "수동단가 직접적용"], ] .map( ([v, l]) => ``, ) .join(""); const wageBadge = r.wageInfo ? `
순차평가: ${fmt(r.wageInfo.seqResult, 0)}원 vs 선계수곱: ${fmt(r.wageInfo.preCalcResult, 0)}원 ${r.wageInfo.seqResult !== r.wageInfo.preCalcResult ? '(1원차이 발생!)' : ""}
` : ""; return ` ${wageBadge} ${candInputs} ${fmt(r.adoptedPrice, 0)}
${esc(r.chosenLabel)}
`; }) .join(""); return `

초기값 설정 및 노임 시간당 환산 (ST_Sys.CPM · INITIAL)

STMATE는 RXNM_에 저장된 식(1/8*16/12*25/20)을 왼쪽부터 차례로 평가합니다. 계수를 미리 곱하면 부동소수점 오차로 55,699원이 되어 1원 오차가 발생합니다(18번 문서 §2.1).

기초자원 단가 원장 및 5개 가격지 후보 자동채택 (COSTN.DBF · JMKN1..6)

0이 아닌 후보 단가 중 최저가 자동채택(실무 76/76건 일치, 17번 문서 §5.5) 또는 특정 가격지 슬롯 수동고정을 실험합니다.

${rowsHtml}
코드품명 / 규격단위비목후보1(조달)후보2(물자)후보3(물정)후보4(거래)후보5(견적)채택 규칙시간환산최종 채택단가(6칸)관리
`; } function renderMachinesTab(res) { const laborOpts = state.resources .filter((r) => r.kind === "노무비") .map((r) => [r.id, `${r.code} ${r.name}`]); const matOpts = state.resources .filter((r) => r.kind === "재료비") .map((r) => [r.id, `${r.code} ${r.name}`]); const machOpts = state.machines.map((m) => [m.id, `${m.code} ${m.name} (${m.spec})`]); const machRows = res.machines.list .map( (m, i) => ` ${fmt(m.M, 0)} ${fmt(m.L, 0)} ${fmt(m.S, 0)} ${fmt(m.T, 0)} `, ) .join(""); const sanRows = res.sanCal.list .map( (d, i) => ` ${fmt(d.Q, 2)}
원값 ${fmt(d.rawQ, 3)}
${fmt(d.M, 1)}
내역 ${fmt(d.intM, 0)}
${fmt(d.L, 1)}
내역 ${fmt(d.intL, 0)}
${fmt(d.S, 1)}
내역 ${fmt(d.intS, 0)}
${fmt(d.T, 1)}
내역합 ${fmt(d.intT, 0)}
`, ) .join(""); return `

시간당 중기사용료 (X 코드 · 중기사용료 시트)

중기 한 대를 참조할 때 노무비(&X..L&), 재료비(&X..M&), 경비(&X..S&) 세 성분을 각각 원 단위 절사하여 산출합니다(17번 문서 §3).

${machRows}
코드기계명규격운전원(L)주연료(M)연료량(L/hr)잡품률(%)취득가격(원)시간손료계수재료비(M)노무비(L)경비(S)시간당합계(T)관리

단가산출근거 (D 코드 · ST_SanCal.CPM · COSTN.SMT)

작업량 Q를 소수 2자리로 먼저 확정한 뒤, 중기 시간당 성분단가를 Q로 나누어 소수 1자리 절사합니다. 내역서 수록 시에는 정수 절사값이 전달됩니다(18번 문서 §2.2).

${sanRows}
코드단가산출 공종명참조 중기(&X..&)Q 산출식 유형q(용량)K(계수)f(토량)E(효율)Cm(싸이클)작업량 Q(㎥/hr)재료비/Q노무비/Q경비/Q호표소계(소수1)관리
`; } function renderIlwiTab(res) { if (!state.ilwiItems.some((x) => x.id === selectedIlwiId)) selectedIlwiId = state.ilwiItems[0]?.id || ""; const curIdx = state.ilwiItems.findIndex((x) => x.id === selectedIlwiId); const curIlwi = res.ilwi.list[curIdx]; const allRefChoices = [ ...state.resources.map((r) => [r.id, `[기초 ${r.kind}] ${r.code} ${r.name} (${r.unit})`]), ...state.machines.map((m) => [m.id, `[중기 X] ${m.code} ${m.name} (${m.unit})`]), ...state.sanItems.map((d) => [d.id, `[단산 D] ${d.code} ${d.name} (${d.unit})`]), ]; let detailHtml = '
일위대가 항목을 추가해 주세요.
'; if (curIlwi) { const lineRows = curIlwi.lines .map( (ln, li) => ` ${fmt(ln.appliedQty, 4)} ${fmt(ln.lineM, 1)} ${fmt(ln.lineL, 1)} ${fmt(ln.lineS, 1)} ${fmt(ln.lineT, 1)} `, ) .join(""); detailHtml = `
호표 성분 소계(원 단위 절사): 재료비 ${fmt(curIlwi.M, 0)}원 · 노무비 ${fmt(curIlwi.L, 0)}원 · 경비 ${fmt(curIlwi.S, 0)}원 → 합계 ${fmt(curIlwi.T, 0)}원
${lineRows}
참조 자원 / 단가산출원수량(QTY1)역수(DEVD1)할증(X_HAL %)적용수량재료비노무비경비행 합계관리
`; } return `

일위대가표 구성 및 수량 변환 (ST_EdiLWi.CPM · BDQTY.DBF)

구성 자원의 수량 할증(X_HAL) 및 나눗셈 수량 역수 변환(DEVD1: 1/QTY1, 16단계·30단계 REA 메모)을 적용하고, 호표 소계에서 각 성분을 원 단위로 절사하여 TAMT = MAMT + LAMT + SAMT 불변식을 유지합니다.

${detailHtml}
`; } document.addEventListener("click", (e) => { const tabBtn = e.target.closest("#mainTabs button[data-tab]"); if (tabBtn) { activeTab = tabBtn.dataset.tab; renderAll(); return; } const actBtn = e.target.closest("[data-action]"); if (!actBtn) return; const act = actBtn.dataset.action, idx = Number(actBtn.dataset.idx), pidx = Number(actBtn.dataset.pidx); if (act === "add-res") { state.resources.push( Object.assign(createDefaultSample().resources[3], { id: uid("R"), code: `M00${state.resources.length + 10}`, name: "신규 시험 자재", }), ); } else if (act === "del-res") state.resources.splice(idx, 1); else if (act === "add-mach") { state.machines.push( Object.assign(createDefaultSample().machines[0], { id: uid("X"), code: `X0000${state.machines.length + 1}`, name: "신규 중기장비", }), ); } else if (act === "del-mach") state.machines.splice(idx, 1); else if (act === "add-san") { state.sanItems.push( Object.assign(createDefaultSample().sanItems[0], { id: uid("D"), code: `D0000${state.sanItems.length + 2}`, name: "신규 기계 시공 단가산출", }), ); } else if (act === "del-san") state.sanItems.splice(idx, 1); else if (act === "add-ilwi") { const newIlwi = { id: uid("B"), code: `B0000${state.ilwiItems.length + 1}`, name: "신규 일위대가 호표", spec: "기본", unit: "㎥", lines: [], }; state.ilwiItems.push(newIlwi); selectedIlwiId = newIlwi.id; } else if (act === "del-ilwi") { state.ilwiItems.splice(idx, 1); selectedIlwiId = state.ilwiItems[0]?.id || ""; } else if (act === "add-ilwi-line") { state.ilwiItems[pidx].lines.push({ refId: state.resources[0]?.id || "", qty: 1, halPercent: 0, devd: false, }); } else if (act === "del-ilwi-line") state.ilwiItems[pidx].lines.splice(idx, 1); else if (act === "add-boq") { state.boqItems.push( Object.assign(createDefaultSample().boqItems[0], { id: uid("BOQ"), code: `B000${state.boqItems.length + 1}`, name: "신규 내역 공종", }), ); } else if (act === "del-boq") state.boqItems.splice(idx, 1); saveState(); renderAll(); }); document.addEventListener("change", (e) => { const el = e.target; if (el.id === "selIlwiPicker") { selectedIlwiId = el.value; renderAll(); return; } const sec = el.dataset.sec; if (!sec) return; const idx = Number(el.dataset.idx), field = el.dataset.field; const val = el.type === "checkbox" ? el.checked : el.type === "number" ? Number(el.value) || 0 : el.value; if (sec === "sysConfig") state.sysConfig[field] = val; else if (sec === "cand") state.resources[idx].candidates[Number(el.dataset.cidx)] = Number(el.value) || 0; else if (sec === "resources") state.resources[idx][field] = val; else if (sec === "machines") state.machines[idx][field] = val; else if (sec === "sanItems") state.sanItems[idx][field] = val; else if (sec === "ilwiItems") state.ilwiItems[idx][field] = val; else if (sec === "ilwiLines") state.ilwiItems[Number(el.dataset.pidx)].lines[idx][field] = val; else if (sec === "boqItems") state.boqItems[idx][field] = val; else if (sec === "kanjubConfig") state.kanjubConfig[field] = val; else if (sec === "kanjubRates") state.kanjubConfig.rates[field] = Number(el.value) || 0; else if (sec === "revModes") state.kanjubConfig.revModes[field] = val; else if (sec === "kOrdSim") state.sysConfig.kOrdSim[field] = Number(el.value) || 0; saveState(); renderAll(); }); $("btnPresetBonghwa").addEventListener("click", () => { state = createDefaultSample(); selectedIlwiId = state.ilwiItems[0]?.id || ""; saveState(); renderAll(); }); $("btnSaveJson").addEventListener("click", () => { const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "stmate-test-state.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }); $("fileOpen").addEventListener("change", async (e) => { const file = e.target.files?.[0]; if (!file) return; try { const imported = JSON.parse(await file.text()); if (!imported || !Array.isArray(imported.resources) || !imported.kanjubConfig) throw new Error("Invalid format"); state = imported; selectedIlwiId = state.ilwiItems[0]?.id || ""; saveState(); renderAll(); } catch (_) { alert("유효한 STMATE 테스트 JSON 파일이 아닙니다."); } finally { e.target.value = ""; } }); renderAll();