From a56da7bb2b6a07d23d53cbd0e9f8decbeb747c5b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 22:08:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(tools):=20STMATE=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=82=AC=EC=8A=AC=20=EB=B0=8F=20=EC=A0=9C=EC=96=B4=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EB=8F=85=EB=A6=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9B=B9=EC=95=B1=20=EC=B6=94=EA=B0=80=20(tools/stmate-test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/stmate-test/README.md | 50 ++ tools/stmate-test/app.js | 684 ++++++++++++++++++++++++++++ tools/stmate-test/app_views_cost.js | 283 ++++++++++++ tools/stmate-test/calc.js | 674 +++++++++++++++++++++++++++ tools/stmate-test/index.html | 68 +++ tools/stmate-test/style.css | 419 +++++++++++++++++ 6 files changed, 2178 insertions(+) create mode 100644 tools/stmate-test/README.md create mode 100644 tools/stmate-test/app.js create mode 100644 tools/stmate-test/app_views_cost.js create mode 100644 tools/stmate-test/calc.js create mode 100644 tools/stmate-test/index.html create mode 100644 tools/stmate-test/style.css diff --git a/tools/stmate-test/README.md b/tools/stmate-test/README.md new file mode 100644 index 000000000..1fc154232 --- /dev/null +++ b/tools/stmate-test/README.md @@ -0,0 +1,50 @@ +# STMATE 계산 및 제어 흐름 독립 테스트 웹앱 (`tools/stmate-test`) + +## 개요 + +경쟁사 설계예산 프로그램 **STMATE**(경영정보(주))에 대한 **REA 6.7.0 / Ghidra 12.1.4 정적 분석** 및 지식DB(`resources/knowledge/original/경쟁사 프로그램/STmate 분석/`) 결과를 바탕으로, 주요 기능과 계산 사슬·제어 옵션을 브라우저에서 독립적으로 확인하고 조작할 수 있도록 만든 테스트 웹앱입니다. + +- **완전 분리 동작**: Aislo 본체 서버·DB·로그인 세션과 무관하게 `index.html` 단독으로 동작합니다. +- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**를 지원합니다. + +## 주요 확인 및 제어 기능 (6개 탭) + +1. **01 기초자원 · 노임환산 (`ST_Sys.CPM`, `COSTN.DBF`)** + - 자재·노무·경비 기초자원의 5개 가격지 후보(`AMT_11..51`: 조달·물자·물정·거래·견적) 입력 + - 채택 방식 제어: **0이 아닌 후보 중 최저가 자동채택(`min`)** 또는 **특정 후보 슬롯 수동고정** + - 노임 시간당 환산식(`RXNM_`, 예: `1/8*16/12*25/20`) 입력 및 **좌→우 순차 평가**(선계수 곱셈 오차 방지) 대조 + - 당초/변경 USD 환율(`EXLNM1/2`) 및 달러 단가 소수점 자리수(`ECDNM`) 설정 + +2. **02 중기 · 단가산출(Q식) (`ST_SanCal.CPM`, `COSTN.SMT`)** + - 시간당 중기사용료(`X` 코드): 운전원 시간당 노임 연결, 연료·소모품(재료비), 손료·정비·관리비(경비) 성분별 절사 합산 + - 단가산출(`D` 코드): 품셈 인자(`q, K, f, E, Cm`) 및 작업량 식(`Q = 3600*q*K*f*E/Cm` 등) 평가 → `Q` 소수 2자리 확정 후 중기 성분을 나누어 **소수 1자리 절사** + +3. **03 일위대가 조립 (`ST_EdiLWi.CPM`)** + - 기초자원(`M/L/S`), 중기(`X`), 단가산출(`D`)을 조합하여 단위당 복합단가(`TAMT = MAMT + LAMT + SAMT`) 산출 + - 수량 할증률(`X_HAL %`) 및 나눗셈 수량 플래그(`DEVD1`: `1/QTY`) 제어 + - 호표 성분 소계 원 단위 절사(`ROUNDDOWN`)와 집계표 사사오입(`ROUND`) 비교 + +4. **04 내역서 · 차액보정 (`ST_Func_C.CPM :: SUM_TOT / OPTIMIZ`)** + - 일반 말단 공종 행과 제외금액 구역(`C*` 관급자재대 등 직접공사비 합산 제외) 구분 제어 + - 계산 모드 제어: **비목별 금액 우선(`CHNNM=0`, `ROUNDDOWN(수량×단가)`)** vs **총액 우선(`CHNNM=1`, `OPTIMIZ` 차액보정)** + - 차액 보정 우선순위(`OPTNM`: `SML` 경비→재료→노무 등)에 따른 단수 차액 배분 시뮬레이션 + +5. **05 제잡비 · 원가계산 (`ST_KanJub.CPM :: Calc_Kanjub`)** + - 원가계산 4형식 전환 제어: **일반형식(`T`) · 수공형식(`S`) · 실적일반(`TA`) · 실적수공(`SA`)** + - 형식별 밑수 자동 전환 확인 (예: 환경보전비·보증수수료 밑수가 일반형식은 `재+직노+산출경비`, 실적일반은 `직접공사비`) + - 간접비 순차 절사 전달 (예: 건강보험료 절사 확정액 `C_KUNBO` → 노인장기요양보험료 밑수) + - 산업안전보건관리비 관급자재 포함(`(직재+직노+관급원자재/1.1)×율 + 기초액`) 및 1.2배 상한 비교 제어 + - 부가세 옵션(`I_BUGA`), 도급액 절사 단위(`I_TOT_JEOL`), **끝전조정(`차액 → ROUND(차액/1.1) → 이윤 차감`)**, 관급자재 천원 올림(`ROUNDUP(-3)`) 제어 + +6. **06 변경설계 · REA 근거 (`ST_KanJub.CPM`, `Main 0x55a640`)** + - 당초 대비 변경수량 입력 및 간접비 항목별 변경 계산모드 제어: + - `recalculated` (변경 직접비 기준 재계산) + - `carry_original` (사후정산 항목 당초액 유지 — 건강·장기요양·연금 등) + - `proportional` (방법 A: 당초액 비례 증감) + - `delta_rate` (방법 B: 증감 직접비 × 요율) + - `K_ORD` 등록·차수내리기 전이 규칙(`K - (K>5 ? K-4 : 1)`) 시뮬레이터 및 REA 정적분석 핵심 주소록 확인 + +## 주의사항 + +- 예제에 포함된 기초단가와 요율은 기능 확인용 시험 데이터이며, 현행 법령·품셈의 확정 기준값이 아닙니다. +- 라이선스 동글 없이 수행한 정적 역공학(REA) 결과 기반이므로, 원본 실행 파일의 모든 예외 분기와 수치 동등성을 보증하지 않습니다. diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js new file mode 100644 index 000000000..5d6e7d200 --- /dev/null +++ b/tools/stmate-test/app.js @@ -0,0 +1,684 @@ +/* -*- 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(); diff --git a/tools/stmate-test/app_views_cost.js b/tools/stmate-test/app_views_cost.js new file mode 100644 index 000000000..03e747529 --- /dev/null +++ b/tools/stmate-test/app_views_cost.js @@ -0,0 +1,283 @@ +/* -*- coding: utf-8 -*- */ +/** + * STMATE 시뮬레이터: 내역서(BOQ), 제잡비(Calc_Kanjub), 변경설계(Revision) 렌더러 (app_views_cost.js) + */ +const StmateCostViews = (() => { + 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); + + function renderBoqTab(state, res) { + const sys = state.sysConfig, + boq = res.boqOrig; + const unitChoices = [ + ...state.ilwiItems.map((b) => [b.id, `[일위 B] ${b.code} ${b.name}`]), + ...state.sanItems.map((d) => [d.id, `[단산 D] ${d.code} ${d.name}`]), + ...state.machines.map((m) => [m.id, `[중기 X] ${m.code} ${m.name}`]), + ...state.resources.map((r) => [r.id, `[단독 ${r.code[0]}] ${r.code} ${r.name}`]), + ]; + + const rowsHtml = boq.rows + .map( + (r, i) => ` + + + + + + + + + ${fmt(r.unitInfo.T, 0)} + ${fmt(r.M, 0)} + ${fmt(r.L, 0)} + ${fmt(r.S, 0)} + ${fmt(r.T, 0)}${r.optimizTarget ? `
OPTIMIZ: ${esc(r.optimizTarget)}
` : ""} + ${fmt(r.aggT, 0)} + + `, + ) + .join(""); + + return ` +
+
+
+

내역서 계산 방식 및 ST_Func_C.CPM :: OPTIMIZ 차액 보정 제어

+

14·15단계 REA 분석에서 확인된 CHNNM(비목별 금액 우선 vs 총액 우선) 및 OPTNM(SML 차액 보정 순서) 동작을 검증합니다. 또한 C* 제외금액 구역(관급자재대)은 직접공사비 합산에서 분리됩니다(16번 문서 §4.2).

+
+ +
+
+ + +
+
+ + + + + + ${rowsHtml} + + + + + + + + + +
코드(BDCODE1)공종명연결 단가구역 구분설계수량(QTY1)합계단가재료비(M_AMT1)노무비(L_AMT1)경비(S_AMT1)내역 합계(T_AMT1)집계표(사사오입)관리
직접공사비 소계 (C* 제외금액 구역 불포함)${fmt(boq.directM, 0)}${fmt(boq.directL, 0)}${fmt(boq.directS, 0)}${fmt(boq.directTotal, 0)}관급원자재: ${fmt(boq.govRawTotal, 0)} / 수수료: ${fmt(boq.govFeeTotal, 0)}
+
+
`; + } + + function renderKanjubTab(state, res) { + const cfg = state.kanjubConfig, + k = res.kanjubOrig; + const itemRows = k.items + .map( + (it) => ` + + ${esc(it.code)} + ${esc(it.label)} + ${esc(it.baseDesc)} + ${fmt(it.baseAmt, 0)} + + ${fmt(it.amount, 0)} + `, + ) + .join(""); + + return ` +
+
+
+

원가계산 4형식 및 제잡비·끝전조정 제어 (ST_KanJub.CPM :: Calc_Kanjub)

+

원가계산 형식(T/S/TA/SA)을 바꾸면 기타경비·환경보전비·일반관리비·이윤의 밑수 공식 자체가 전환됩니다(12번 문서 §7). 또한 도급공사비 절사 차액을 세전 환산(ROUND(차액/1.1))하여 이윤에서 빼는 끝전조정을 검증합니다(16번 문서 §4.4).

+
+
+
+ + + + + + +
+
+ + + + + ${itemRows} + + + + + + + + + +
저장키원가계산 항목산정기초 (밑수 공식)밑수 금액 (D_)적용 요율 (R_ %)산출 확정액 (S_)
DAMT_M/L/S직접공사비 합계직접재료비 ${fmt(k.dM, 0)} + 직접노무비 ${fmt(k.dL, 0)} + 산출경비 ${fmt(k.dS, 0)}${fmt(k.dT, 0)}—${fmt(k.dT, 0)}
PURE_COST순공사원가재료비 + 노무비(직노+간노) + 경비(산출+제경비)${fmt(k.pureConstructionCost, 0)}—${fmt(k.pureConstructionCost, 0)}
ILBAN일반관리비${esc(k.ilbanDesc)}${fmt(k.ilbanBase, 0)}${fmt(k.ilbanAmt, 0)}
IYUN이윤 (끝전보정 후)${esc(k.iyunDesc)}
보정전 산식이윤 ${fmt(k.iyunRaw, 0)} − 세전끝전조정 ${fmt(k.pretaxProfitAdj, 0)} (차액 ${fmt(k.diffTotal, 0)}/1.1 반올림)
${fmt(k.iyunBase, 0)}${fmt(k.iyunFinal, 0)}
SUPPLY총원가 (공급가액)순공사원가 + 일반관리비 + 최종이윤 (보정전: ${fmt(k.supplyBeforeAdj, 0)})${fmt(k.supplyFinal, 0)}—${fmt(k.supplyFinal, 0)}
BUGA부가가치세과세표준 × 세율 (보정전 도급액: ${fmt(k.contractBeforeAdj, 0)})${fmt(k.supplyFinal, 0)}10%${fmt(k.vatFinal, 0)}
CONTRACT도급공사비공급가액 + 부가가치세 (끝전 절사 완료)${fmt(k.contractBeforeAdj, 0)}—${fmt(k.contractFinal, 0)}
KWANGUP관급자재대관급원자재(${fmt(k.govRawTotal, 0)}) + 조달수수료(${fmt(k.govFeeTotal, 0)}) 끝전 처리${fmt(k.govRawTotal + k.govFeeTotal, 0)}—${fmt(k.govFinal, 0)}
GRAND_TOT총공사비도급공사비 + 관급자재대——${fmt(k.grandTotal, 0)} 원
+
+
`; + } + + function renderRevisionTab(state, res) { + const boqOrig = res.boqOrig, + boqRev = res.boqRev, + kOrig = res.kanjubOrig, + kRev = res.kanjubRev; + const kSim = state.sysConfig.kOrdSim || { pclass: 7, regMode: 7, flag: 0, currentK: 2 }; + const kSimOut = StmateCalc.evalKOrdTransition( + kSim.pclass, + kSim.regMode, + kSim.flag, + kSim.currentK, + ); + + const boqCompareRows = state.boqItems + .map( + (b, i) => ` + + ${esc(b.code)}${esc(b.name)} + ${fmt(b.qty, 2)} + + ${fmt(boqOrig.rows[i].T, 0)} + ${fmt(boqRev.rows[i].T, 0)} + ${boqRev.rows[i].T - boqOrig.rows[i].T >= 0 ? "+" : ""}${fmt(boqRev.rows[i].T - boqOrig.rows[i].T, 0)} + `, + ) + .join(""); + + const revModeChoices = [ + ["recalculated", "recalculated (변경 밑수로 재계산)"], + ["carry_original", "carry_original (당초액 유지 · 사후정산)"], + ["proportional", "방법 A (당초액 × 직접비 증감비율)"], + ["delta_rate", "방법 B (당초액 + 밑수증감 × 요율)"], + ]; + + const kanjubCompareRows = kRev.items + .map((it) => { + const curMode = state.kanjubConfig.revModes[it.code] || "recalculated"; + return ` + + ${esc(it.code)}${esc(it.label)} + + ${fmt(it.origAmt, 0)} + ${fmt(it.amount, 0)} + ${it.deltaAmt >= 0 ? "+" : ""}${fmt(it.deltaAmt, 0)} + `; + }) + .join(""); + + return ` +
+
+
+

설계변경 수량 및 간접비 변경 계산모드 (cBx_CH_BO · 08번 문서 §2 · 18단계 REA)

+

실무 변경설계에서는 직접노무비가 늘어나도 건강·장기요양·연금보험료 등 사후정산 항목은 당초액을 그대로 유지(carry_original)하고, 나머지는 재계산하거나 방법 A/B 공식을 적용합니다.

+
+
+
+ + + + ${boqCompareRows} + + +
코드내역 공종명당초 수량변경 수량 (조작)당초 금액변경 금액증감 차액
직접공사비 당초 / 변경 / 증감 합계${fmt(boqOrig.directTotal, 0)}${fmt(boqRev.directTotal, 0)}${boqRev.directTotal - boqOrig.directTotal >= 0 ? "+" : ""}${fmt(boqRev.directTotal - boqOrig.directTotal, 0)}
+
+
+ + + + ${kanjubCompareRows} + + +
키제잡비 항목변경설계 계산 모드 선택당초 금액 (C_AMT_B)변경 금액증감 차액
총공사비 (도급 + 관급) 당초 / 변경 / 증감${fmt(kOrig.grandTotal, 0)}${fmt(kRev.grandTotal, 0)}${kRev.grandTotal - kOrig.grandTotal >= 0 ? "+" : ""}${fmt(kRev.grandTotal - kOrig.grandTotal, 0)}
+
+
+
+
+
+

K_ORD 등록·차수내리기 전이 규칙 시뮬레이터 (STMATE_REA_최종보고서 §4.1)

+

Main 0x559228(NewProject) 및 0x55a640(MkBoq)에서 관측된 PCLASS · 등록모드 · 플래그 조합에 따른 K_ORD 저장식과 차수내리기(K - (K>5 ? K-4 : 1)) 동작을 확인합니다.

+
+
+
+ + + + +
+
+ 등록 시 새 K_ORD: ${kSimOut.nextK} · 차수내리기 실행 시 K - (K>5 ? K-4 : 1): ${kSimOut.downK} + REA 확인: Calq_San 행 selector(+0x430) 0 초기화 지배 · 공유 tb_Ref(+0x58) → MmComRec+8 +
+
`; + } + + return { renderBoqTab, renderKanjubTab, renderRevisionTab }; +})(); diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js new file mode 100644 index 000000000..d9d120a5b --- /dev/null +++ b/tools/stmate-test/calc.js @@ -0,0 +1,674 @@ +/* -*- 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; diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html new file mode 100644 index 000000000..54f84b72b --- /dev/null +++ b/tools/stmate-test/index.html @@ -0,0 +1,68 @@ + + + + + + STMATE 계산·제어 흐름 독립 테스트 + + + + + + +
+
+
+ 독립 테스트 웹앱 + REA 6.7.0 / Ghidra 12.1.4 분석 반영 + 무로그인 · 로컬 전용 +
+

STMATE 계산 사슬 및 제어 옵션 시뮬레이터

+

+ 기초자원 후보단가·노임환산(`ST_Sys`) → 중기·Q식 단가산출(`ST_SanCal`) → + 일위대가(`ST_EdiLWi`) → 내역·OPTIMIZ(`ST_Func_C`) → 제잡비 4형식·끝전조정(`ST_KanJub`) → + 변경설계 차액 +

+
+
+ + + +
+
+ +
+
+ 시험·검증 전용 안내 + 이 웹앱은 Aislo 본체와 완전히 분리된 독립 테스트 도구입니다. 수록된 단가·요율은 계산 + 경로와 제어 옵션을 점검하기 위한 시험 데이터이며, 현행 법령·품셈 확정값이 아닙니다. +
+ +
+ + + +
+ + +
+ + diff --git a/tools/stmate-test/style.css b/tools/stmate-test/style.css new file mode 100644 index 000000000..f5d9f7302 --- /dev/null +++ b/tools/stmate-test/style.css @@ -0,0 +1,419 @@ +/* -*- coding: utf-8 -*- */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f1f5f9; + color: #0f172a; + font: + 14px/1.5 system-ui, + -apple-system, + "Malgun Gothic", + sans-serif; +} + +.topbar { + background: #0f172a; + color: #f8fafc; + padding: 24px max(20px, calc((100% - 1440px) / 2)); + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + border-bottom: 3px solid #0284c7; +} + +.badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.badge { + display: inline-block; + padding: 3px 9px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + background: #1e293b; + color: #cbd5e1; + border: 1px solid #334155; +} + +.badge.accent { + background: #0284c7; + color: #ffffff; + border-color: #0369a1; +} + +.topbar h1 { + margin: 0 0 6px; + font-size: 22px; + letter-spacing: -0.02em; +} + +.topbar p { + margin: 0; + color: #94a3b8; + font-size: 13px; +} + +.top-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.btn { + border: 1px solid transparent; + border-radius: 6px; + padding: 9px 14px; + font: inherit; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn.primary { + background: #0284c7; + color: #ffffff; +} + +.btn.primary:hover { + background: #0369a1; +} + +.btn.secondary { + background: #1e293b; + color: #e2e8f0; + border-color: #475569; +} + +.btn.secondary:hover { + background: #334155; +} + +.panel-card .btn.secondary { + background: #e2e8f0; + color: #1e293b; + border-color: #cbd5e1; +} + +.btn-del { + border: 1px solid #fecaca; + background: #fef2f2; + color: #b91c1c; + border-radius: 5px; + padding: 5px 9px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.btn-del:hover { + background: #fee2e2; +} + +.container { + max-width: 1440px; + margin: 0 auto; + padding: 20px; +} + +.alert-banner { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + border-radius: 8px; + padding: 12px 16px; + display: flex; + gap: 10px; + align-items: baseline; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 18px; +} + +.kpi-card { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 14px 16px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.kpi-label { + display: block; + font-size: 12px; + color: #64748b; + font-weight: 600; +} + +.kpi-value { + display: block; + font-size: 20px; + color: #0f172a; + margin: 4px 0; + font-variant-numeric: tabular-nums; +} + +.kpi-sub { + display: block; + font-size: 11px; + color: #475569; +} + +.tab-nav { + display: flex; + gap: 4px; + border-bottom: 2px solid #cbd5e1; + overflow-x: auto; + margin-bottom: 16px; +} + +.tab-nav button { + border: 0; + background: transparent; + color: #475569; + font: inherit; + font-weight: 700; + padding: 12px 16px; + cursor: pointer; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + white-space: nowrap; +} + +.tab-nav button:hover { + color: #0f172a; + background: #e2e8f0; +} + +.tab-nav button.active { + color: #0284c7; + border-bottom-color: #0284c7; + background: #ffffff; +} + +.panel-card { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 8px; + margin-bottom: 18px; + overflow: hidden; +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + background: #f8fafc; +} + +.card-header h2 { + margin: 0; + font-size: 16px; + color: #0f172a; +} + +.card-header p { + margin: 4px 0 0; + font-size: 12.5px; + color: #475569; +} + +.control-grid { + padding: 16px 20px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + background: #ffffff; + border-bottom: 1px solid #e2e8f0; +} + +.ctrl-item { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12.5px; + font-weight: 600; + color: #334155; +} + +.ctrl-item.checkbox-row { + flex-direction: row; + align-items: center; + gap: 8px; + padding-top: 20px; +} + +.ctrl-item.checkbox-row input { + width: auto; +} + +.sub-toolbar { + padding: 12px 20px; + background: #f0f9ff; + border-bottom: 1px solid #bae6fd; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 13px; + color: #0369a1; +} + +.table-scroll { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 920px; +} + +.data-table th, +.data-table td { + padding: 8px 10px; + border-bottom: 1px solid #e2e8f0; + text-align: left; + vertical-align: middle; + font-size: 13px; +} + +.data-table th { + background: #f1f5f9; + color: #334155; + font-weight: 700; + font-size: 12px; + white-space: nowrap; +} + +.data-table td.money-col { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.data-table td.num-col { + width: 105px; +} + +.data-table td.center-col { + text-align: center; + white-space: nowrap; +} + +.data-table td.highlight-col { + background: #f8fafc; +} + +.data-table tr.sub-row { + background: #f8fafc; +} + +.data-table tr.total-row { + background: #e0f2fe; + font-weight: 700; +} + +.data-table tr.excl-row { + background: #fffbeb; +} + +input[type="text"], +input[type="number"], +select { + width: 100%; + padding: 6px 8px; + border: 1px solid #cbd5e1; + border-radius: 5px; + font: inherit; + font-size: 13px; + color: #0f172a; + background: #ffffff; +} + +input[type="number"] { + text-align: right; + font-variant-numeric: tabular-nums; +} + +input.code-input { + width: 86px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +input.short-input { + width: 72px; +} + +.sub-hint { + font-size: 11px; + color: #475569; + margin-top: 3px; +} + +.tiny-tag { + font-size: 11px; + color: #64748b; + font-weight: 500; +} + +.diff-warn { + color: #dc2626; + font-weight: 700; + font-size: 11px; +} + +.formula-tag { + display: inline-block; + padding: 2px 6px; + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 4px; + font-size: 11.5px; + color: #334155; +} + +.empty-box { + padding: 24px; + text-align: center; + color: #64748b; +} + +.footer-note { + margin-top: 16px; + padding: 12px 4px; + font-size: 12px; + color: #64748b; + text-align: center; +} + +@media (max-width: 900px) { + .topbar { + flex-direction: column; + align-items: flex-start; + } + .kpi-grid { + grid-template-columns: repeat(2, 1fr); + } +}