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]) => `${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}
+
+ ${StmateCalc.KINDS.map((k) => `${k} `).join("")}
+ ${candInputs}
+ ${modeOpts}
+
+ ${fmt(r.adoptedPrice, 0)} ${esc(r.chosenLabel)}
+ 삭제
+ `;
+ })
+ .join("");
+
+ return `
+
+ `;
+}
+
+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) => `
+
+
+
+
+ ${laborOpts.map(([v, l]) => `${esc(l)} `).join("")}
+ ${matOpts.map(([v, l]) => `${esc(l)} `).join("")}
+
+
+
+
+ ${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) => `
+
+
+
+ ${machOpts.map(([v, l]) => `${esc(l)} `).join("")}
+
+
+ 3600*q*K*f*E/Cm (초단위)
+ 60*q*K*f*E/Cm (분단위)
+
+
+
+
+
+
+
+ ${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 `
+
+ `;
+}
+
+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) => `
+
+ ${allRefChoices.map(([v, l]) => `${esc(l)} `).join("")}
+
+
+
+ ${fmt(ln.appliedQty, 4)}
+ ${fmt(ln.lineM, 1)}
+ ${fmt(ln.lineL, 1)}
+ ${fmt(ln.lineS, 1)}
+ ${fmt(ln.lineT, 1)}
+ 삭제
+ `,
+ )
+ .join("");
+
+ detailHtml = `
+
+ 일위대가 선택 ${state.ilwiItems.map((x) => `${esc(x.code)} - ${esc(x.name)} `).join("")}
+ 일위대가 코드 (BDCODE)
+ 공종명
+ 단위
+
+
+ `;
+ }
+
+ return `
+
+
+ ${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) => `
+
+
+
+ ${unitChoices.map(([v, l]) => `${esc(l)} `).join("")}
+
+
+ 일반 말단행(직접비)
+ C* 제외구역(관급원자재)
+ C* 제외구역(조달수수료)
+
+
+
+ ${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 `
+
+
+
+
+ 내역서 계산 방식 (CHNNM · +0x7c5)
+
+ 0: 비목별 금액 먼저 (각 비목 절사 후 합산)
+ 1: 총액 먼저 (OPTIMIZ로 단수 차액 성분 배분)
+
+
+
+ 차액 보정 우선순위 (OPTNM · +0x7dc)
+
+ SML (경비 → 재료비 → 노무비)
+ MLS (재료비 → 노무비 → 경비)
+ LMS (노무비 → 재료비 → 경비)
+
+
+
+
+
`;
+ }
+
+ 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 `
+ `;
+ }
+
+ 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)}
+ ${revModeChoices.map(([v, l]) => `${l} `).join("")}
+ ${fmt(it.origAmt, 0)}
+ ${fmt(it.amount, 0)}
+ ${it.deltaAmt >= 0 ? "+" : ""}${fmt(it.deltaAmt, 0)}
+ `;
+ })
+ .join("");
+
+ return `
+
+
+
+
+ 원본 PCLASS 4 6 7
+ 등록 모드 (6 또는 7) 모드 6 모드 7
+ 플래그 (0 또는 1) 0 1
+ 현재 K_ORD (K)
+
+
+ 등록 시 새 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`) →
+ 변경설계 차액
+
+
+
+
+ 예제 초기화
+
+ JSON 열기
+ JSON 저장
+
+
+
+
+
+ 시험·검증 전용 안내
+ 이 웹앱은 Aislo 본체와 완전히 분리된 독립 테스트 도구입니다. 수록된 단가·요율은 계산
+ 경로와 제어 옵션을 점검하기 위한 시험 데이터이며, 현행 법령·품셈 확정값이 아닙니다.
+
+
+
+
+
+ 01 기초자원 · 노임환산
+ 02 중기 · 단가산출(Q식)
+ 03 일위대가 조립
+ 04 내역서 · OPTIMIZ
+ 05 제잡비 · 원가계산
+ 06 변경설계 · REA 근거
+
+
+
+
+
+
+
+
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);
+ }
+}