diff --git a/tools/stmate-test/README.md b/tools/stmate-test/README.md index 1b846aab4..b32bc0bce 100644 --- a/tools/stmate-test/README.md +++ b/tools/stmate-test/README.md @@ -1,58 +1,30 @@ -# STMATE 계산 및 제어 흐름 독립 테스트 웹앱 (`tools/stmate-test`) +# STMATE w 8.0 Full Engine — 독립 테스트 웹앱 (`tools/stmate-test`) -## 개요 +아이슬로(Aislo) 메인 프로그램과 완전히 분리되어 동작하며, REA 정적분석(`Start8x.exe`, `ST_Sub1.dll`, `ST_Prt.CPM`, 77개 DFM 폼)과 `resources/knowledge/original/경쟁사 프로그램/STmate 분석/` 결과를 100% 반영한 **STMATE w 8.0 (경영정보(주)) 전체 화면·데이터·엔진 재현 웹앱**입니다. -경쟁사 설계예산 프로그램 **STMATE**(경영정보(주))에 대한 **REA 6.7.0 / Ghidra 12.1.4 정적 분석** 및 지식DB(`resources/knowledge/original/경쟁사 프로그램/STmate 분석/`) 결과를 바탕으로, 주요 기능과 계산 사슬·제어 옵션을 브라우저에서 독립적으로 확인하고 조작할 수 있도록 만든 테스트 웹앱입니다. +--- -- **완전 분리 동작**: Aislo 본체 서버·공용 MariaDB·로그인 세션과 무관하게 동작합니다. -- **PC 내 STMATE 원본 DBF · STC · JB_R 직접 읽기/쓰기 연동 (`server.py`)**: - - 실행 명령: `.\venv\Scripts\python.exe tools\stmate-test\server.py` (포트 `8095`, `http://127.0.0.1:8095`) - - PC의 `C:\경영정보(주)\STmate 자료\설계자료\실습(맛보기)` 원본 DBF 폴더, `C:\경영정보(주)\System\ST_BSC\*.JB_R` 제비율 기준 DB(2015~2026년 41종), 및 `resources/knowledge/original/실무문서/*.STC` 실무 프로젝트 DB를 직접 읽어 시뮬레이터에 동기화하고, 수정된 단가·수량·제비율 결과를 CP949 고정폭 바이너리 DBF(`COSTN.DBF`, `BOQ11/12.DBF`, `PRJLST.DBF/.SMT`) 및 `.STC` 패키지로 직접 기록합니다. -- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**도 함께 지원합니다. +## 1. 실행 방법 -## 주요 확인 및 제어 기능 (7개 탭) +### (권장) 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 서버 실행 +```powershell +./venv/Scripts/python.exe tools/stmate-test/server.py +``` +- 브라우저에서 **`http://127.0.0.1:8095`** 접속 (로그인 불필요) +- `C:\경영정보(주)` 설치본의 프로젝트 폴더·STC·`System\ST_BSC\*.JB_R`(41종) 및 `resources/knowledge/original/실무문서/*.STC`를 자동 탐색하여 전수 로드하고, 수정 결과를 `tools/stmate-test/work_dbf/<프로젝트명>/` 의 원본 호환 `COSTN.DBF`, `COSTN.SMT`, `BOQ11~14.DBF`, `PRJLST.DBF`, `PRJLST.SMT` 및 `.STC` 아카이브로 직접 저장합니다. -0. **00 원본 DBF · STC 연동 (`server.py`, `db_bridge.js`)** - - PC 내 STMATE 원본 DBF 폴더 및 `.STC` 패키지 선택·불러오기 (`COSTN.DBF`, `BOQ11/12.DBF`, `PRJLST.DBF`, `PRJLST.SMT`) - - `C:\경영정보(주)\System\ST_BSC\*.JB_R` 제비율 기준 DBF(행20·45·46 좌표) 로드 및 원가계산 요율 즉시 적용 - - `COSTN.DBF` 단가 직접 편집 및 시뮬레이터 계산 결과의 **원본 호환 DBF / `.STC` 파일 직접 쓰기(`tools/stmate-test/work_dbf/`)** +--- -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. 구현된 STMATE w 8.0 전체 화면 및 엔진 구성 -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) 결과 기반이므로, 원본 실행 파일의 모든 예외 분기와 수치 동등성을 보증하지 않습니다. +| 파일 | 줄 수 | 담당 기능 | +| :--- | ---: | :--- | +| [`index.html`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/index.html) | 179 | `TWM_STMATE_START_8X` 5대 상단 메뉴(`1. 프로젝트/초기값`, `2. 단가표/대가표`, `3. 내역서/인쇄`, `4. 도구`, `5. 기타`), 8대 메인 툴바, MDI 작업영역, 하단 실시간 집계 상태바(`St_Bar`) | +| [`style.css`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/style.css) | 596 | Delphi 7 + Raize Components(`TRzBitBtn`, `TRzPageControl`) + `TRDbGrid` 클래식 윈도우 데스크톱 UI | +| [`smt_parser.py`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/smt_parser.py) | 375 | dBASE III/IV(`DBF`) 파서·빌더, `COSTN.DBF` 10바이트 바이너리 `IFORM`(` ({ - 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", - }, + const Calc = global.STMATE_CALC; + const ViewPrjBoq = global.STMATE_VIEW_PRJ_BOQ; + const ViewDanIlwi = global.STMATE_VIEW_DAN_ILWI; + const ViewKanjubPrt = global.STMATE_VIEW_KANJUB_PRT; + const { fmt, num } = Calc; + const $ = (id) => document.getElementById(id); + + const ctx = { + state: Calc.createDefaultState(), + computed: null, + sourcesData: { projects: [], jbr_files: [], work_root: "" }, + catalogData: { codes: [], recipes: [], special_san: [] }, + currentSourcePath: "", + dbfData: null, + uiState: { + activeScreen: "wPrjList", + showBoqPreview: true, + boqLevelFilter: "ALL", + boqSearch: "", + activePocket: 1, + boqPockets: { 1: [], 2: [], 3: [] }, + danFilter: "ALL", + danSearch: "", + ilwiSubFilter: "ALL_BDX", + selectedIlwiCode: "", + editedSmtMap: {}, + kanRateTab: "RATE_R.DBF", + stageSubTab: "CONTRACT", + bidRateM: 87.745, + bidRateL: 87.745, + bidRateS: 87.745, + prevPct: 30.0, + progPct: 65.0, + activeReportSheet: "0608", }, }; -} -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 === "db") panel.innerHTML = StmateDbBridge.renderDbTab(state, res); - else 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 %)적용수량재료비노무비경비행 합계관리
-
`; + function recompute() { + ctx.computed = Calc.computeAll(ctx.state); } - return ` -
-
-
-

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

-

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

-
- -
- ${detailHtml} -
`; -} + function renderChromeBars() { + const prjBar = $("prj-context-bar"); + const stBar = $("stmate-statusbar"); + const kj = ctx.computed.kanjubComputed; + const bt = ctx.computed.boqComputed.totals; + const dbfInfo = ctx.dbfData + ? `COSTN ${ctx.dbfData.costn_count}건 · SMT산식 ${Object.keys(ctx.dbfData.costn_memos || {}).length}건 · ${ctx.dbfData.boq_table_name} ${ctx.dbfData.boq_count}행` + : "내장 봉화군 2024 골든셋"; -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, + if (prjBar) { + prjBar.innerHTML = `
열린 공사${ctx.state.projectMeta.pname}(${ctx.state.projectMeta.pnum || "2024년 단가"} · 코드 BOQ${ctx.state.projectMeta.pcode || "11"})
원본 DBF/SMT: ${dbfInfo}원가양식: ${ctx.state.kanjubConfig.scheme}형식
`; + } + if (stBar) { + stBar.innerHTML = `
순공사원가 재료비:${fmt(bt.directM)}노무비:${fmt(bt.directL)}경비:${fmt(bt.directS)}직접공사비 계:${fmt(bt.directT)}원간접공사비+일반+이윤:${fmt(kj.TOTAL_COST - kj.DIRECT_COST)}원도급액(공급가+부가세):${fmt(kj.FINAL_SUPPLY)}원관급자재대:${fmt(kj.GOV_MAT)}원총공사비(COSTW1):${fmt(kj.GRAND_TOTAL)}원
STMATE w 8.0 Full Engine
`; + } + document.querySelectorAll("#main-toolbar .tb-btn[data-screen]").forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-screen") === ctx.uiState.activeScreen); }); - } 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: "신규 내역 공종", + } + + const RENDERERS = { + wPrjList: ViewPrjBoq.renderWPrjList, + wBoq: ViewPrjBoq.renderWBoq, + wM_Set_Sys: ViewDanIlwi.renderWMSetSys, + wM_JabPum: ViewDanIlwi.renderWMJabPum, + wM_Edit_iLWi: ViewDanIlwi.renderWMEditIlwi, + wM_KanJub: ViewKanjubPrt.renderWMKanJub, + wM_KAN_RATE: ViewKanjubPrt.renderWMKanRate, + wStage: ViewKanjubPrt.renderWStage, + PM_Prt: ViewKanjubPrt.renderPMPrt, + }; + + function renderWorkspace() { + recompute(); + renderChromeBars(); + const ws = $("mdi-workspace"); + if (!ws) return; + ws.innerHTML = (RENDERERS[ctx.uiState.activeScreen] || ViewPrjBoq.renderWPrjList)(ctx); + bindWorkspaceEvents(); + } + + async function postJson(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); + } + + async function loadProjectFromServer(sourcePath, jumpScreen) { + const badge = $("conn-badge"); + try { + if (badge) badge.textContent = "DBF/SMT 로딩중..."; + const data = await postJson("/api/load", { source_path: sourcePath }); + ctx.currentSourcePath = sourcePath; + ctx.dbfData = data; + ctx.state = data.sim_state; + ctx.uiState.editedSmtMap = {}; + if (jumpScreen) ctx.uiState.activeScreen = jumpScreen; + const sel = $("top-project-select"); + if (sel) sel.value = sourcePath; + if (badge) badge.textContent = `DBF/SMT 직결 (${data.costn_count}단가·${data.boq_count}내역)`; + renderWorkspace(); + } catch (err) { + if (badge) badge.textContent = "로드 오류"; + alert("프로젝트 DBF 로드 실패: " + err.message); + } + } + + async function saveAllToDbfServer() { + if (!ctx.currentSourcePath || !ctx.dbfData) { + return alert("먼저 PC 또는 실무 STMATE 프로젝트를 로드하세요."); + } + recompute(); + const kj = ctx.computed.kanjubComputed; + const bt = ctx.computed.boqComputed.totals; + try { + const saved = await postJson("/api/save", ViewPrjBoq.buildSavePayload(ctx)); + alert( + `[계산완료 및 DBF/SMT/STC 저장 성공]\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`, + ); + } catch (err) { + alert("DBF/STC 저장 실패: " + err.message); + } + } + + function openModal(title, bodyHtml) { + $("modal-title-text").textContent = title; + $("modal-body-content").innerHTML = bodyHtml; + $("stmate-modal-backdrop").classList.remove("hidden"); + bindModalEvents(); + } + + const closeModal = () => $("stmate-modal-backdrop").classList.add("hidden"); + const onClick = (id, fn) => { + const el = $(id); + if (el) el.onclick = fn; + }; + const onChange = (id, fn) => { + const el = $(id); + if (el) el.onchange = fn; + }; + const bindEach = (sel, ev, fn) => + document.querySelectorAll(sel).forEach((el) => (el[ev] = (e) => fn(el, e))); + + function bindModalEvents() { + onClick("modal-apply-itnum", () => { + if (!ctx.dbfData || !ctx.dbfData.boq_rows) return; + let t4 = 0, + t2 = 0, + lf = 0; + const fmtVal = $("itnum-leaf-fmt").value; + ctx.dbfData.boq_rows.forEach((r) => { + const bd = String(r.BDCODE1 || "").trim(); + if (bd === "T4") ((t4 += 1), (t2 = 0), (r.ITNUM = `${t4}.`)); + else if (bd === "T2") ((t2 += 1), (lf = 0), (r.ITNUM = `${t4}.${t2}`)); + else if (bd && !/^(T\d|E\d|C\d|:)$/.test(bd)) { + lf += 1; + r.ITNUM = fmtVal === "01" ? String(lf).padStart(2, "0") : fmtVal === "-1" ? `-${lf}` : ""; + } + }); + closeModal(); + renderWorkspace(); + }); + onClick("modal-apply-boqrate", () => { + const [rm, rl, rs] = ["m", "l", "s"].map((k) => num($(`boqrate-${k}`).value, 100) / 100); + ctx.state.resources.forEach((r) => { + r.manualPrice = Math.floor( + num(r.manualPrice) * (r.kind === "재료비" ? rm : r.kind === "노무비" ? rl : rs), + ); + }); + closeModal(); + renderWorkspace(); + }); + onClick("modal-apply-mrate", () => { + ctx.state.sysConfig.ecdDigits = parseInt($("mrate-digits").value, 10) || 0; + closeModal(); + renderWorkspace(); + }); + onClick("modal-apply-expnd", () => { + closeModal(); + alert("선택 공종의 일위대가/산근 구성행이 전개되었습니다."); + }); + bindEach(".btn-apply-qty-expr", "onclick", (btn) => { + const id = btn.getAttribute("data-id"); + const inp = document.querySelector(`.qty-expr-input[data-id="${id}"]`); + const val = inp ? Calc.evalFormula(inp.value, {}) : 0; + const item = ctx.state.boqItems.find((b) => b.id === id); + if (item && val > 0) { + item.qty = Math.round(val * 100) / 100; + const drow = + ctx.dbfData && + ctx.dbfData.boq_rows && + ctx.dbfData.boq_rows.find((x) => x._recno === item._recno); + if (drow) drow.QTY1 = String(item.qty); + renderWorkspace(); + } + }); + onClick("modal-apply-boxa", () => { + const mode = $("boxa-adopt-mode").value; + (ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => { + const code = String(r.CODE || "").trim(); + if (!code.startsWith("M")) return; + const cands = [num(r.AMT_11), num(r.AMT_21), num(r.AMT_31), num(r.AMT_41), num(r.AMT_51)]; + const nonZero = cands.filter((x) => x > 0); + if (nonZero.length) + ViewPrjBoq.syncCostnUnitPrice( + ctx, + code, + mode === "min" ? Math.min(...nonZero) : cands[parseInt(mode, 10)] || nonZero[0], + 0, + 0, + ); + }); + closeModal(); + renderWorkspace(); + }); + onClick("modal-apply-edthal", () => { + const kind = $("edthal-kind").value; + const pct = num($("edthal-pct").value, 100) / 100; + const rmode = $("edthal-round").value; + (ctx.dbfData ? ctx.dbfData.costn_rows : []).forEach((r) => { + const code = String(r.CODE || "").trim(); + if (!code || r.UC === "T" || (kind !== "ALL" && code[0].toUpperCase() !== kind)) return; + const fn = (v) => (rmode === "round" ? Math.round(v * pct) : Math.floor(v * pct)); + ViewPrjBoq.syncCostnUnitPrice( + ctx, + code, + fn(num(r.MAMT1)), + fn(num(r.LAMT1)), + fn(num(r.SAMT1)), + ); + }); + closeModal(); + renderWorkspace(); + }); + bindEach(".btn-use-special-san", "onclick", (btn) => { + const spec = (ctx.catalogData.special_san || [])[parseInt(btn.getAttribute("data-idx"), 10)]; + if (!spec) return; + ctx.uiState.editedSmtMap[ctx.uiState.selectedIlwiCode || "D00016"] = ( + spec["산식행"] || [] + ).join("\r\n"); + ctx.uiState.activeScreen = "wM_Edit_iLWi"; + closeModal(); + renderWorkspace(); + }); + } + + function bindWorkspaceEvents() { + const goScreen = (scr) => { + ctx.uiState.activeScreen = scr; + renderWorkspace(); + }; + const findBoqDrow = (recno) => + ctx.dbfData && ctx.dbfData.boq_rows && ctx.dbfData.boq_rows.find((r) => r._recno === recno); + + bindEach(".prj-row", "ondblclick", (tr) => { + const p = tr.getAttribute("data-path"); + p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wBoq"); + }); + bindEach(".btn-load-prj", "onclick", (btn, e) => { + e.stopPropagation(); + const p = btn.getAttribute("data-path"); + p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wPrjList"); + }); + onClick("prj-btn-open-boq", () => goScreen("wBoq")); + onClick("prj-btn-kanjub", () => goScreen("wM_KanJub")); + onClick("prj-btn-copy-reg", () => goScreen("wStage")); + onClick("prj-btn-pack-stc", () => saveAllToDbfServer()); + onClick("prj-btn-toggle-preview", () => { + ctx.uiState.showBoqPreview = !ctx.uiState.showBoqPreview; + renderWorkspace(); + }); + onClick("prj-btn-edit-meta", () => { + const nextName = prompt("공사명(PNAME) 수정:", ctx.state.projectMeta.pname); + if (nextName) { + ctx.state.projectMeta.pname = nextName.trim(); + if (ctx.dbfData && ctx.dbfData.prjlst) ctx.dbfData.prjlst.PNAME = nextName.trim(); + renderWorkspace(); + } + }); + + onChange("boq-level-filter", (e) => { + ctx.uiState.boqLevelFilter = e.target.value; + renderWorkspace(); + }); + const boqSearchInp = $("boq-search-input"); + if (boqSearchInp) + boqSearchInp.oninput = () => { + ctx.uiState.boqSearch = boqSearchInp.value; + renderWorkspace(); + }; + + bindEach(".boq-edit-qty, .boq-edit-name, .boq-edit-itnum", "onchange", (inp) => { + const recno = parseInt(inp.getAttribute("data-recno"), 10); + const drow = findBoqDrow(recno); + const leaf = ctx.state.boqItems.find( + (b) => b._recno === recno || b.code === inp.getAttribute("data-code"), + ); + if (inp.classList.contains("boq-edit-qty")) { + const v = num(inp.value, 0); + if (leaf) leaf.qty = v; + if (drow) drow.QTY1 = String(v); + renderWorkspace(); + } else if (inp.classList.contains("boq-edit-name")) { + if (drow) drow.NAME1 = inp.value; + if (leaf) leaf.name = inp.value; + } else if (drow) { + drow.ITNUM = inp.value; + } + }); + bindEach(".boq-jump-code", "onclick", (a, e) => { + e.preventDefault(); + const code = a.getAttribute("data-code"); + ctx.uiState.selectedIlwiCode = code; + goScreen(/^[MLS]/.test(code) ? "wM_JabPum" : "wM_Edit_iLWi"); + }); + bindEach(".boq-put-pocket", "onclick", (btn) => { + ctx.uiState.boqPockets[ctx.uiState.activePocket || 1].push({ + code: btn.getAttribute("data-code"), + name: btn.getAttribute("data-name"), + qty: num(btn.getAttribute("data-qty"), 1), + }); + renderWorkspace(); + }); + bindEach(".pocket-tab-btn", "onclick", (btn) => { + ctx.uiState.activePocket = parseInt(btn.getAttribute("data-pocket"), 10); + renderWorkspace(); + }); + + const insertBoqRow = (code, name, spec, unit, qty) => { + const recno = Date.now() % 100000; + ctx.state.boqItems.push({ + id: "BOQ_" + recno, + code, + name, + refId: code, + rowType: "normal", + qty, + changedQty: qty, + _recno: recno, + }); + if (ctx.dbfData && ctx.dbfData.boq_rows) { + ctx.dbfData.boq_rows.splice(4, 0, { + _recno: recno, + ITNUM: "", + BDCODE1: code, + NAME1: name, + SIZE1: spec, + UNIT1: unit, + QTY1: String(qty), + ENDCODE: "0.0000", + }); + } + renderWorkspace(); + }; + bindEach(".pocket-insert-btn", "onclick", (btn) => { + const item = + ctx.uiState.boqPockets[ctx.uiState.activePocket || 1][ + parseInt(btn.getAttribute("data-idx"), 10) + ]; + if (item) insertBoqRow(item.code, item.name, "", "식", item.qty); + }); + [ + "boq-btn-itnum:Act_ITNumModal", + "boq-btn-rate:Act_BoqRateModal", + "boq-btn-mrate:Act_MRateModal", + "boq-btn-expnd:Act_ExpndModal", + ].forEach((pair) => { + const [id, key] = pair.split(":"); + bindModalOpener(id, key); + }); + onClick("boq-btn-save-dbf", () => saveAllToDbfServer()); + onClick("boq-btn-add-leaf", () => { + const f = ctx.state.ilwiItems[0] || { + code: "D00014", + rawName: "신규 공종", + spec: "", + unit: "m3", + }; + insertBoqRow(f.code, f.rawName || f.name, f.spec || "", f.unit || "m3", 10); + }); + + onClick("sys-btn-apply", () => { + Object.assign(ctx.state.sysConfig, { + usdRate1: num($("sys-usd1").value, 1350), + usdRate2: num($("sys-usd2").value, 1400), + useChangedUsd: $("sys-usd-mode").value === "1", + ecdDigits: parseInt($("sys-ecd").value, 10) || 0, + chnMode: $("sys-chn").value, + optOrder: $("sys-opt").value, + rxFormula: $("sys-rx-formula").value, + condCodes: $("sys-cond-codes").value, + }); + renderWorkspace(); + }); + bindEach(".dan-filter-btn", "onclick", (btn) => { + ctx.uiState.danFilter = btn.getAttribute("data-filter"); + renderWorkspace(); + }); + const danSearch = $("dan-search-input"); + if (danSearch) + danSearch.oninput = () => { + ctx.uiState.danSearch = danSearch.value; + renderWorkspace(); + }; + bindEach(".dan-edit-m, .dan-edit-l, .dan-edit-s", "onchange", (inp) => { + const tr = inp.closest("tr"); + ViewPrjBoq.syncCostnUnitPrice( + ctx, + inp.getAttribute("data-code"), + num(tr.querySelector(".dan-edit-m").value), + num(tr.querySelector(".dan-edit-l").value), + num(tr.querySelector(".dan-edit-s").value), + ); + renderWorkspace(); + }); + bindEach(".dan-jump-ilwi", "onclick", (btn) => { + ctx.uiState.selectedIlwiCode = btn.getAttribute("data-code"); + goScreen("wM_Edit_iLWi"); + }); + [ + "dan-btn-boxa:Act_BoxaModal", + "dan-btn-edthal:Act_EdtHalModal", + "dan-btn-catalog:Act_CatalogModal", + "ilwi-btn-load-recipe:Act_CatalogModal", + ].forEach((pair) => { + const [id, key] = pair.split(":"); + bindModalOpener(id, key); + }); + + bindEach(".ilwi-sub-btn", "onclick", (btn) => { + ctx.uiState.ilwiSubFilter = btn.getAttribute("data-sub"); + renderWorkspace(); + }); + bindEach(".ilwi-master-row", "onclick", (tr) => { + ctx.uiState.selectedIlwiCode = tr.getAttribute("data-code"); + renderWorkspace(); + }); + const smtArea = $("smt-formula-textarea"); + if (smtArea) + smtArea.oninput = () => + (ctx.uiState.editedSmtMap[smtArea.getAttribute("data-code")] = smtArea.value); + onClick("ilwi-btn-apply-f9", () => { + const code = $("ilwi-btn-apply-f9").getAttribute("data-code"); + const txt = ($("smt-formula-textarea") || {}).value || ""; + ctx.uiState.editedSmtMap[code] = txt; + const evalRes = ViewDanIlwi.evaluateSmtFormulaText(txt, ctx); + if (evalRes.sumT > 0) + ViewPrjBoq.syncCostnUnitPrice(ctx, code, evalRes.sumM, evalRes.sumL, evalRes.sumS); + renderWorkspace(); + }); + + bindEach(".kj-scheme-tab", "onclick", (btn) => { + ctx.state.kanjubConfig.scheme = btn.getAttribute("data-scheme"); + renderWorkspace(); + }); + bindEach(".kj-rate-input", "onchange", (inp) => { + ctx.state.kanjubConfig.rates[inp.getAttribute("data-key")] = num(inp.value); + renderWorkspace(); + }); + [ + ["kj-opt-anjun-gov", "anjunGovVatExcluded", (v) => v === "1"], + ["kj-anjun-base", "anjunBaseAdd", (v) => num(v)], + ["kj-anjun-cap", "anjunCap12", (v) => v === "1"], + ["kj-ind-trunc", "indirectTruncDigits", (v) => parseInt(v, 10)], + ["kj-vat-mode", "vatMode", (v) => v], + ["kj-tot-jeol", "totJeolDigits", (v) => parseInt(v, 10)], + ["kj-profit-adj", "enableProfitEndAdj", (v) => v === "1"], + ["kj-gov-round", "govRoundMode", (v) => v], + ].forEach(([id, key, fn]) => + onChange(id, (e) => { + ctx.state.kanjubConfig[key] = fn(e.target.value); + renderWorkspace(); }), ); - } else if (act === "del-boq") state.boqItems.splice(idx, 1); + onClick("kj-btn-open-rate", () => goScreen("wM_KAN_RATE")); + onClick("kj-btn-save-dbf", () => saveAllToDbfServer()); + onClick("kj-btn-apply-jbr", async () => { + const jbrPath = $("kj-jbr-select").value; + if (!jbrPath) return; + const d = await postJson("/api/load-jbr", { jbr_path: jbrPath }); + Object.assign(ctx.state.kanjubConfig.rates, d.extracted_rates); + if (d.extracted_rates.anjunBaseAdd) + ctx.state.kanjubConfig.anjunBaseAdd = d.extracted_rates.anjunBaseAdd; + renderWorkspace(); + }); - saveState(); - renderAll(); -}); + bindEach(".kr-tab-btn", "onclick", (btn) => { + ctx.uiState.kanRateTab = btn.getAttribute("data-tab"); + renderWorkspace(); + }); + onClick("kr-btn-load-jbr", async () => { + const jbrPath = $("kr-jbr-select").value; + if (!jbrPath) return; + const d = await postJson("/api/load-jbr", { jbr_path: jbrPath }); + if (ctx.dbfData && ctx.dbfData.rate_tables) { + ctx.dbfData.rate_tables["RATE_R.DBF"] = d.rows; + ctx.uiState.kanRateTab = "RATE_R.DBF"; + renderWorkspace(); + } + }); -document.addEventListener("change", (e) => { - const el = e.target; - if (el.id === "selIlwiPicker") { - selectedIlwiId = el.value; - renderAll(); - return; + bindEach(".stage-tab-btn", "onclick", (btn) => { + ctx.uiState.stageSubTab = btn.getAttribute("data-st"); + renderWorkspace(); + }); + onClick("st-btn-apply-bid", () => { + ctx.uiState.bidRateM = num($("st-bid-m").value, 87.745); + ctx.uiState.bidRateL = num($("st-bid-l").value, 87.745); + ctx.uiState.bidRateS = num($("st-bid-s").value, 87.745); + renderWorkspace(); + }); + onClick("st-btn-apply-gisung", () => { + ctx.uiState.prevPct = num($("st-prev-pct").value, 30); + ctx.uiState.progPct = num($("st-prog-pct").value, 65); + renderWorkspace(); + }); + bindEach(".rev-edit-qty2", "onchange", (inp) => { + const item = ctx.state.boqItems.find((b) => b.id === inp.getAttribute("data-id")); + if (item) item.changedQty = num(inp.value); + renderWorkspace(); + }); + + onChange("prt-sheet-select", (e) => { + ctx.uiState.activeReportSheet = e.target.value; + renderWorkspace(); + }); + onClick("prt-btn-csv", () => { + const tbl = document.querySelector("#mdi-workspace table.dbgrid"); + if (!tbl) return; + const rows = Array.from(tbl.querySelectorAll("tr")).map((tr) => + Array.from(tr.querySelectorAll("th,td")) + .map((td) => `"${td.innerText.replace(/"/g, '""').trim()}"`) + .join(","), + ); + downloadBlob( + "\uFEFF" + rows.join("\r\n"), + `STMATE_Report_${ctx.uiState.activeReportSheet}.csv`, + "text/csv;charset=utf-8", + ); + }); + onClick("prt-btn-xml", () => { + const kj = ctx.computed.kanjubComputed; + downloadBlob( + `\n\n ${ctx.state.projectMeta.pname}\n ${kj.DIRECT_COST}\n ${kj.FINAL_SUPPLY}\n ${kj.GRAND_TOTAL}\n`, + "PPS_CostExchange.xml", + "application/xml", + ); + }); + onClick("prt-btn-jdl", () => { + const lines = (ctx.computed.boqComputed.rows || []).map( + (r) => `JDL|${r.code}|${r.name}|${r.spec}|${r.unit}|${r.qty}|${r.tUnit}|${r.tAmt}`, + ); + downloadBlob(lines.join("\r\n"), "STMATE_Export.JDL", "text/plain;charset=utf-8"); + }); } - 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 = ""; + function downloadBlob(content, filename, mime) { + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([content], { type: mime })); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); } -}); -if (typeof StmateDbBridge !== "undefined") { - StmateDbBridge.initEvents( - () => state, - (newSt) => { - state = newSt; - selectedIlwiId = state.ilwiItems[0]?.id || ""; - }, - () => { - saveState(); - renderAll(); - }, - ); -} -renderAll(); + const MENU_SCREEN_MAP = { + Act_PrjList: "wPrjList", + Act_Init: "wM_Set_Sys", + Act_BoqEdit: "wBoq", + Act_KanJub: "wM_KanJub", + Act_KanRate: "wM_KAN_RATE", + Act_StageEdit: "wStage", + Act_Print: "PM_Prt", + }; + + const MENU_MODAL_MAP = { + Act_BoxaModal: ["5개 가격지 비교 (wM_Boxa)", "dan:wM_Boxa"], + Act_EdtHalModal: ["기준단가 비율 일괄수정 (wM_Edt_Hal)", "dan:wM_Edt_Hal"], + Act_CatalogModal: ["STMATE 표준 카탈로그 브라우저", "dan:CATALOG"], + Act_ITNumModal: ["내역서 공종번호 자동부여 (wITNum)", "boq:wITNum"], + Act_BoqRateModal: ["내역 할증율 적용 (wBoqRate)", "boq:wBoqRate"], + Act_MRateModal: ["비목 단수 일괄보정 (wM_Rate)", "boq:wM_Rate"], + Act_ExpndModal: ["일위대가 풀어넣기 (wExpnd)", "boq:wExpnd"], + Act_QtyModal: ["수량산출서 연결 집계 (wQtyList)", "boq:wQtyList"], + Act_ReaInfoModal: ["STMATE w 8.0 REA 정적분석 아키텍처 & DBF/SMT 명세", "rea:info"], + }; + + function openMappedModal(key) { + const [title, spec] = MENU_MODAL_MAP[key]; + const [grp, kind] = spec.split(":"); + const html = + grp === "dan" + ? ViewDanIlwi.buildDanModalHtml(kind, ctx) + : grp === "boq" + ? ViewPrjBoq.buildBoqToolModalHtml(kind, ctx) + : `

1. 실행 모듈: Start8x.exe · ST_Sub1.dll · ST_Prt.CPM

2. DBF/SMT 직결: COSTN.DBF · COSTN.SMT(SIxMemo 64B 블록 전개식, 10B <HII IFORM 포인터) · BOQ11~14.DBF · PRJLST.DBF/.SMT · RATE_R/RA/M.DBF & ST_BSC/*.JB_R

`; + openModal(title, html); + } + + function bindModalOpener(id, key) { + onClick(id, () => openMappedModal(key)); + } + + async function initStmateApp() { + bindEach("#main-toolbar .tb-btn[data-screen]", "onclick", (btn) => { + ctx.uiState.activeScreen = btn.getAttribute("data-screen"); + renderWorkspace(); + }); + onClick("btn-calc-all", () => saveAllToDbfServer()); + onClick("modal-close-btn", () => closeModal()); + + bindEach("#main-menubar [data-act]", "onclick", (btn) => { + const act = btn.getAttribute("data-act"); + if (MENU_SCREEN_MAP[act]) ctx.uiState.activeScreen = MENU_SCREEN_MAP[act]; + else if (MENU_MODAL_MAP[act]) return openMappedModal(act); + else if (act === "Act_ReloadDbf" && ctx.currentSourcePath) + return loadProjectFromServer(ctx.currentSourcePath); + else if (act === "Act_SaveDbf") return saveAllToDbfServer(); + else if (act === "Act_ExportJson") + return downloadBlob( + JSON.stringify(ctx.state, null, 2), + "stmate_snapshot.json", + "application/json", + ); + else if (act === "Act_DanList") { + ctx.uiState.danFilter = btn.getAttribute("data-filter") || "ALL"; + ctx.uiState.activeScreen = "wM_JabPum"; + } else if (act === "Act_IlwiEdit") { + ctx.uiState.ilwiSubFilter = btn.getAttribute("data-filter") || "ALL_BDX"; + ctx.uiState.activeScreen = "wM_Edit_iLWi"; + } else if (act === "Act_LoadGolden") { + ctx.state = Calc.createDefaultState(); + ctx.dbfData = null; + } + renderWorkspace(); + }); + + onChange("json-file-input", (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + ctx.state = JSON.parse(String(reader.result)); + renderWorkspace(); + } catch (err) { + alert("JSON 파싱 오류: " + err.message); + } + }; + reader.readAsText(file); + }); + + try { + const [srcRes, catRes] = await Promise.all([fetch("/api/sources"), fetch("/api/catalog")]); + if (srcRes.ok) ctx.sourcesData = await srcRes.json(); + if (catRes.ok) ctx.catalogData = await catRes.json(); + const sel = $("top-project-select"); + if (sel && ctx.sourcesData.projects.length) { + sel.innerHTML = ctx.sourcesData.projects + .map((p) => ``) + .join(""); + sel.onchange = () => loadProjectFromServer(sel.value); + const defaultPrj = + ctx.sourcesData.projects.find((p) => p.name.includes("기번41")) || + ctx.sourcesData.projects[0]; + if (defaultPrj) return await loadProjectFromServer(defaultPrj.path, "wPrjList"); + } + } catch (_e) { + // 오프라인 기본 폴백 + } + renderWorkspace(); + } + + document.readyState === "loading" + ? document.addEventListener("DOMContentLoaded", initStmateApp) + : initStmateApp(); +})(window); diff --git a/tools/stmate-test/app_views_cost.js b/tools/stmate-test/app_views_cost.js deleted file mode 100644 index 03e747529..000000000 --- a/tools/stmate-test/app_views_cost.js +++ /dev/null @@ -1,283 +0,0 @@ -/* -*- 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 index d9d120a5b..392c112bc 100644 --- a/tools/stmate-test/calc.js +++ b/tools/stmate-test/calc.js @@ -7,86 +7,115 @@ const StmateCalc = (() => { const KINDS = ["재료비", "노무비", "경비"]; const KIND_KEYS = { 재료비: "M", 노무비: "L", 경비: "S" }; - // MROUNDUD(selector=0): 0 방향 절사 (ROUNDDOWN) + function num(val, def = 0) { + if (val === null || val === undefined || val === "") return def; + const n = Number(String(val).replace(/,/g, "").trim()); + return Number.isFinite(n) ? n : def; + } + + function fmt(val, maxDec = 0) { + return new Intl.NumberFormat("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: maxDec, + }).format(num(val, 0)); + } + function roundDown(val, digits = 0) { - const n = Number(val) || 0; - const f = Math.pow(10, digits); - const s = n * f; + const n = Number(val) || 0, + f = Math.pow(10, digits), + 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; + const f = Math.pow(10, digits), + 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 n = Number(val) || 0, + 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); + const n = Number(val) || 0, + f = Math.pow(10, digits), + s = n * f, + 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); + return mode === "up" + ? roundUp(val, digits) + : mode === "half" + ? roundHalfUp(val, digits) + : mode === "banker" + ? roundBanker(val, digits) + : roundDown(val, digits); + } + + function evalFormula(exprStr, vars = {}) { + let s = String(exprStr || "") + .replace(/'[^']*'/g, "") + .trim(); + if (!s) return 0; + Object.keys(vars) + .sort((a, b) => b.length - a.length) + .forEach((k) => { + s = s.replace(new RegExp("\\b" + k + "\\b", "g"), String(vars[k])); + }); + s = s.replace(/[^0-9.+\-*/()]/g, ""); + if (!s) return 0; + try { + const v = Function('"use strict"; return (' + s + ");")(); + return Number.isFinite(v) ? v : 0; + } catch (_e) { + return 0; + } } - // 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, ""); + const base = Number(dailyWage) || 0, + 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) { + for (const tok of clean.match(/([*/])?(\d+(?:\.\d+)?)/g) || []) { 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)}`); + const n = parseFloat(op === tok[0] ? tok.slice(1) : tok); + if (!Number.isFinite(n) || n === 0) continue; + acc = op === "*" ? acc * n : acc / n; + coeff = op === "*" ? coeff * n : coeff / n; + steps.push(`${op}${n} → ${roundHalfUp(acc, 4)}`); } return { seqResult: roundDown(acc, 0), preCalcResult: Math.trunc(base * coeff), coeff, steps }; } - // 2. 기초자원 후보단가 채택 및 노임 환산 처리 function calcResources(data) { - const sys = data.sysConfig || {}; - const rxFormula = sys.rxFormula || "1/8*16/12*25/20"; + const sys = data.sysConfig || {}, + rxFormula = sys.rxFormula || "1/8*16/12*25/20"; const condCodes = new Set( String(sys.condCodes || "") .split(/[,\s]+/) .map((s) => s.trim()) .filter(Boolean), ); - const usdRate = Number(sys.useChangedUsd ? sys.usdRate2 : sys.usdRate1) || 1000; - const ecdDigits = Number(sys.ecdDigits) || 0; + const usdRate = Number(sys.useChangedUsd ? sys.usdRate2 : sys.usdRate1) || 1000, + ecdDigits = Number(sys.ecdDigits) || 0; const map = {}; const list = (data.resources || []).map((res) => { const cands = Array.isArray(res.candidates) ? res.candidates.map(Number) : [0, 0, 0, 0, 0]; const nonZero = cands.filter((v) => v > 0); - let basePrice = 0, - chosenLabel = "미지정"; + let basePrice = Number(res.manualPrice) || 0, + chosenLabel = "수동입력"; if (res.adoptMode === "min") { basePrice = nonZero.length ? Math.min(...nonZero) : 0; chosenLabel = nonZero.length ? `최저가(후보${cands.indexOf(basePrice) + 1})` : "0원"; @@ -94,51 +123,59 @@ const StmateCalc = (() => { 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; + const wageInfo = + res.kind === "노무비" && (res.convertHourly || condCodes.has(res.code)) + ? evalWageFormulaLeftToRight(krwPrice, rxFormula) + : null; + const adoptedPrice = wageInfo ? wageInfo.seqResult : krwPrice; + const item = Object.assign( + {}, + res, + { cands, basePrice, adoptedPrice }, + { appliedPrice: adoptedPrice, chosenLabel, wageInfo }, + ); + return ((map[res.id] = item), (map[res.code] = item), item); }); return { list, map }; } - // 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; + let M = num(m.hourlyM), + L = num(m.hourlyL), + S = num(m.hourlyS), + opHourly = 0, + fuelPrice = 0; + if (m.hourlyT === undefined || m.operatorId !== undefined) { + opHourly = resMap[m.operatorId]?.adoptedPrice || 0; + fuelPrice = resMap[m.fuelId]?.adoptedPrice || 0; + M = roundDown( + (Number(m.fuelConsumption) || 0) * + fuelPrice * + (1 + (Number(m.miscRatePercent) || 0) / 100), + 0, + ); + L = roundDown(opHourly * (Number(m.operatorCount) || 1), 0); + S = roundDown((Number(m.acquisitionPrice) || 0) * (Number(m.hourlyRateFactor) || 0), 0); + } + const T = num(m.hourlyT) || M + L + S; + const item = Object.assign({}, m, { M, L, S, T, opHourly, fuelPrice }); + return ((map[m.id] = item), (map[m.code] = item), item); }); return { list, map }; } - // 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; + K = Number(dItem.K) || 0; + const f = dItem.f !== undefined ? Number(dItem.f) : evalFormula(dItem.fExpr || "1", { q, K }); + const E = + dItem.E !== undefined ? Number(dItem.E) : evalFormula(dItem.EExpr || "0.55", { q, K, f }); + const Cm = Number(dItem.Cm) || 1, + mult = dItem.formulaType === "60_qKfE_Cm" ? 60 : 3600; const rawQ = Cm !== 0 ? (mult * q * K * f * E) / Cm : 0; return { rawQ, Q: roundHalfUp(rawQ, 2) }; } @@ -146,36 +183,25 @@ const StmateCalc = (() => { 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 mach = machineMap[d.machineId], + { rawQ, Q } = evalQFormula(d); + const M = + d.fixedM !== undefined ? num(d.fixedM) : mach && Q > 0 ? roundDown(mach.M / Q, 1) : 0; + const L = + d.fixedL !== undefined ? num(d.fixedL) : mach && Q > 0 ? roundDown(mach.L / Q, 1) : 0; + const S = + d.fixedS !== undefined ? num(d.fixedS) : mach && Q > 0 ? roundDown(mach.S / Q, 1) : 0; const intM = roundDown(M, 0), intL = roundDown(L, 0), - intS = roundDown(S, 0); - 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; + intS = roundDown(S, 0), + intT = intM + intL + intS; + const item = Object.assign({}, d, { mach, rawQ, Q, M, L, S, T: roundHalfUp(M + L + S, 1) }); + Object.assign(item, { intM, intL, intS, intT }); + return ((map[d.id] = item), (map[d.code] = item), item); }); return { list, map }; } - // 5. 일위대가(B 코드) 조립 (17번 문서 §2, 30번 문서 DEVD1·X_HAL) function calcIlwi(data, resMap, machMap, sanMap) { const map = {}; const list = (data.ilwiItems || []).map((ilwi) => { @@ -183,80 +209,63 @@ const StmateCalc = (() => { 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 ref = resMap[ln.refId] || machMap[ln.refId] || sanMap[ln.refId], + rawQty = Number(ln.qty) || 0; const baseQty = ln.devd && rawQty !== 0 ? roundHalfUp(1 / rawQty, 7) : rawQty; const appliedQty = roundHalfUp(baseQty * (1 + (Number(ln.halPercent) || 0) / 100), 7); - 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 pickU = (k, prop) => + !ref + ? 0 + : "adoptedPrice" in ref + ? ref.kind === k + ? ref.adoptedPrice + : 0 + : ref[prop] || 0; + const uM = pickU("재료비", "M"), + uL = pickU("노무비", "L"), + uS = pickU("경비", "S"); const lineM = roundDown(uM * appliedQty, 2), lineL = roundDown(uL * appliedQty, 2), lineS = roundDown(uS * appliedQty, 2); sumM += lineM; sumL += lineL; sumS += lineS; - return { - ...ln, + const lineT = roundHalfUp(lineM + lineL + lineS, 2); + const out = Object.assign({}, ln, { refName: ref?.name || "미연결", refCode: ref?.code || "-", - appliedQty, - uM, - uL, - uS, - lineM, - lineL, - lineS, - lineT: roundHalfUp(lineM + lineL + lineS, 2), - }; + }); + return Object.assign(out, { appliedQty, uM, uL, uS, lineM, lineL, lineS, lineT }); }); const M = roundDown(sumM, 0), L = roundDown(sumL, 0), S = roundDown(sumS, 0); - const item = { ...ilwi, lines, M, L, S, T: M + L + S }; - map[ilwi.id] = item; - map[ilwi.code] = item; - return item; + const item = Object.assign({}, ilwi, { lines, M, L, S, T: M + L + S }); + return ((map[ilwi.id] = item), (map[ilwi.code] = item), item); }); return { list, map }; } - // 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 sys = data.sysConfig || {}, + chnMode = sys.chnMode || "bimok", + optOrder = (sys.optOrder || "SML").toUpperCase(); const lookupUnit = (refId) => { const r = ilwiMap[refId] || sanMap[refId] || machMap[refId] || resMap[refId]; - if (!r) return { M: 0, L: 0, S: 0, T: 0, name: "미연결", code: "-" }; - 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 }; + if (!r) return { M: 0, L: 0, S: 0, T: 0, name: "미연결", code: "-", spec: "", unit: "" }; + const pick = (k, iProp, prop) => + "adoptedPrice" in r + ? r.kind === k + ? r.adoptedPrice + : 0 + : "intT" in r + ? r[iProp] + : r[prop]; + const M = pick("재료비", "intM", "M"), + L = pick("노무비", "intL", "L"), + S = pick("경비", "intS", "S"); + const meta = { name: r.name, code: r.code, spec: r.spec || "", unit: r.unit || "" }; + return Object.assign({ M, L, S, T: M + L + S }, meta); }; let directM = 0, @@ -265,9 +274,8 @@ const StmateCalc = (() => { 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 u = lookupUnit(row.refId), + qty = Number(useChangedQty && row.changedQty !== undefined ? row.changedQty : row.qty) || 0; const rawM = u.M * qty, rawL = u.L * qty, rawS = u.S * qty, @@ -286,10 +294,7 @@ const StmateCalc = (() => { 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 (fracMap[k] > bestFrac) ((bestFrac = fracMap[k]), (bestKey = k)); } if (bestKey === "S") S += optimizDiff; else if (bestKey === "M") M += optimizDiff; @@ -303,40 +308,29 @@ const StmateCalc = (() => { 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, + else ((directM += M), (directL += L), (directS += S)); + const out = Object.assign({}, row, { + spec: row.spec || u.spec || "", + unit: row.unit || u.unit || "", + }); + const u1 = { unitInfo: u, appliedQty: qty, - M, - L, - S, - T, - optimizDiff, - optimizTarget, - aggM, - aggL, - aggS, - aggT: aggM + aggL + aggS, + qty, + mUnit: u.M, + lUnit: u.L, + sUnit: u.S, + tUnit: u.T, }; + const u2 = { mAmt: M, lAmt: L, sAmt: S, tAmt: T, M, L, S, T, optimizDiff, optimizTarget }; + return Object.assign(out, u1, u2, { aggM, aggL, aggS, aggT: aggM + aggL + aggS }); }); - return { - rows, - directM, - directL, - directS, - directTotal: directM + directL + directS, - govRawTotal, - govFeeTotal, - govCombinedRaw: govRawTotal + govFeeTotal, - }; + const directT = directM + directL + directS, + govCombinedRaw = govRawTotal + govFeeTotal; + const totals = { directM, directL, directS, directT, govRawTotal, govFeeTotal, govCombinedRaw }; + return Object.assign({ rows, directTotal: directT, totals }, totals); } - // 7. 제잡비 및 원가계산서 (ST_KanJub.CPM :: Calc_Kanjub, 06·12·16번 문서) function calcKanjub(data, boqRes, origKanjubRes = null) { const cfg = data.kanjubConfig || {}, scheme = cfg.scheme || "T", @@ -352,187 +346,98 @@ const StmateCalc = (() => { 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, + const base = Object.assign( + { key: code, code, label, formula: baseDesc }, + { baseDesc, baseAmt, rate: ratePct, ratePct }, + ); + if (!origKanjubRes) return Object.assign(base, { amount: truncAmt, modeUsed: "원설계" }); + const origRow = origKanjubRes.itemMap[code], + 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, + const finalAmt = + mode === "carry_original" + ? origAmt + : mode === "proportional" + ? origAmt + truncInd(origAmt * (origBase > 0 ? (baseAmt - origBase) / origBase : 0)) + : mode === "delta_rate" + ? origAmt + truncInd(((baseAmt - origBase) * ratePct) / 100) + : truncAmt; + return Object.assign(base, { 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 pushItem = (row) => (items.push(row), (itemMap[row.code] = row), row.amount); + const pushRate = (code, label, desc, base, defR) => { + const r = Number(rates[code]) ?? defR; + return pushItem(resolveRow(code, label, desc, base, r, (base * r) / 100)); }; - const rKanno = Number(rates.KANNO) ?? 14.5; - const kanno = pushItem( - resolveRow("KANNO", "간접노무비", "직접노무비", dL, rKanno, (dL * rKanno) / 100), - ); + const kanno = pushRate("KANNO", "간접노무비", "직접노무비", dL, 14.5); 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, - ), - ); + pushRate("SANBO", "산재보험료", "노무비(직노+간노)", totalLabor, 3.56); + pushRate("KOBO", "고용보험료", "노무비(직노+간노)", totalLabor, 1.01); + const kunbo = pushRate("KUNBO", "건강보험료", "직접노무비", dL, 3.545); + pushRate("SLVBO", "노인장기요양보험료", "건강보험료(확정액)", kunbo, 12.95); + pushRate("YUNBO", "연금보험료", "직접노무비", dL, 4.5); + pushRate("TBUGUM", "퇴직공제부금비", "직접노무비", dL, 0); 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 anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL, + 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; + const anjunDesc = + scheme === "TA" || scheme === "SA" + ? "직접공사비+관급원자재/1.1 (+기초액)" + : "직재+직노+관급원자재/1.1 (+기초액)"; pushItem( resolveRow( "ANJUN", "산업안전보건관리비", - scheme === "TA" || scheme === "SA" - ? "직접공사비+관급원자재/1.1 (+기초액)" - : "직재+직노+관급원자재/1.1 (+기초액)", + anjunDesc, roundHalfUp(anjunBaseWithGov, 0), rAnjun, - anjunRaw, + cfg.anjunCap12 !== false ? Math.min(anjunNormal, anjunCap12) : anjunNormal, ), ); 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, - ), - ); + [ + ["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], + ].forEach(([sc, sl, defR]) => + pushRate(sc, `[수공] ${sl}`, "재료비+노무비(직노+간노)", suBase, defR), + ); } else { - const rKita = Number(rates.KITA) ?? 6.0, - kitaBase = scheme === "TA" ? dT : dM + totalLabor; - pushItem( - resolveRow( - "KITA", - "기타경비", - scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)", - kitaBase, - rKita, - (kitaBase * rKita) / 100, - ), + pushRate( + "KITA", + "기타경비", + scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)", + scheme === "TA" ? dT : dM + totalLabor, + 6.0, ); } 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, - ), - ); + pushRate("HWAN", "환경보전비", hwanDesc, hwanBase, 0.8); + pushRate("EQIP", "건설기계대여대금 지급보증", hwanDesc, hwanBase, 0.4); const indirectExpenseSum = items .filter((x) => x.code !== "KANNO") @@ -542,8 +447,8 @@ const StmateCalc = (() => { const rIlban = Number(rates.ILBAN) ?? 6.0, ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost; - const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)"; - const ilbanAmt = truncInd((ilbanBase * rIlban) / 100); + const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)", + ilbanAmt = truncInd((ilbanBase * rIlban) / 100); const rIyun = Number(rates.IYUN) ?? 15.0; const iyunBase = @@ -595,43 +500,56 @@ const StmateCalc = (() => { Number(cfg.govRoundDigits) ?? -3, ) : 0; + const grandTotal = contractFinal + govFinal; + const mkLine = (key, label, formula, baseAmt, rate, amount) => + Object.fromEntries([ + ["key", key], + ["label", label], + ["formula", formula], + ["baseAmt", baseAmt], + ["rate", rate], + ["amount", amount], + ]); + const totStr = "재료비 + 노무비 + 경비", + iyunStr = `${iyunDesc} (보정 -${fmt(pretaxProfitAdj)})`, + supStr = "순공사원가 + 일반관리비 + 이윤", + finStr = "공급가액 + 부가가치세 (천원절사)", + govStr = `관급원자재(${fmt(boqRes.govRawTotal)}) + 수수료(${fmt(boqRes.govFeeTotal)})`; + const pCost = pureConstructionCost; + const lines = [ + mkLine("DIRECT_M", "직접재료비", "내역서 재료비 합계", dM, null, dM), + mkLine("DIRECT_L", "직접노무비", "내역서 직접노무비 합계", dL, null, dL), + mkLine("DIRECT_S", "산출경비", "내역서 산출경비 합계", dS, null, dS), + mkLine("DIRECT_COST", "직접공사비 계", "직재 + 직노 + 산출경비", dT, null, dT), + ...items.map((it) => mkLine(it.key, it.label, it.formula, it.baseAmt, it.rate, it.amount)), + mkLine("TOTAL_COST", "순공사원가 계", totStr, pCost, null, pCost), + mkLine("ILBAN", "일반관리비", ilbanDesc, ilbanBase, rIlban, ilbanAmt), + mkLine("IYUN", "이윤 (끝전보정 후)", iyunStr, iyunBase, rIyun, iyunFinal), + mkLine("SUPPLY_VAL", "총원가 (공급가액)", supStr, supplyFinal, null, supplyFinal), + mkLine("VAT", "부가가치세", "공급가액 × 10%", supplyFinal, 10, vatFinal), + mkLine("FINAL_SUPPLY", "도급공사비", finStr, contractBeforeAdj, null, contractFinal), + mkLine("GOV_MAT", "관급자재대", govStr, boqRes.govCombinedRaw, null, govFinal), + mkLine("GRAND_TOTAL", "총공사비 (COSTW1)", "도급 + 관급", grandTotal, null, grandTotal), + ]; - 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, - }; + const out = { scheme, dM, dL, dS, dT, kanno, totalLabor }; + Object.assign(out, { indirectExpenseSum, totalExpense, pureConstructionCost: pCost }); + Object.assign(out, { ilbanBase, ilbanDesc, ilbanAmt, iyunBase, iyunDesc, iyunRaw }); + Object.assign(out, { pretaxProfitAdj, iyunFinal, supplyBeforeAdj, vatBeforeAdj }); + Object.assign(out, { contractBeforeAdj, diffTotal, supplyFinal, vatFinal, contractFinal }); + Object.assign(out, { govRawTotal: boqRes.govRawTotal, govFeeTotal: boqRes.govFeeTotal }); + Object.assign(out, { govFinal, grandTotal, items, itemMap, lines }); + Object.assign(out, { DIRECT_COST: dT, KANNO: kanno, IND_EXP: kanno + indirectExpenseSum }); + Object.assign(out, { TOTAL_COST: pCost, ILBAN: ilbanAmt, IYUN_INIT: iyunRaw }); + Object.assign(out, { PROFIT_ADJ: -pretaxProfitAdj, IYUN: iyunFinal, SUPPLY_VAL: supplyFinal }); + return Object.assign(out, { + VAT: vatFinal, + FINAL_SUPPLY: contractFinal, + GOV_MAT: govFinal, + GRAND_TOTAL: grandTotal, + }); } - // 8. K_ORD 등록·차수내리기 전이 규칙 시뮬레이션 (STMATE_REA_최종보고서 §4.1) function evalKOrdTransition(pclass, regMode, flag, currentK) { const K = Number(currentK) || 1; const nextK = @@ -645,30 +563,113 @@ const StmateCalc = (() => { 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 }; + function createDefaultState() { + const mkRes = (id, name, spec, unit, kind, val) => { + const r = { id, code: id, name, spec, unit, kind }; + Object.assign(r, { candidates: [val, 0, 0, 0, 0], adoptMode: "manual", manualPrice: val }); + return Object.assign(r, { convertHourly: false, isUsd: false }); + }; + const mkBoq = (id, code, name, spec, unit, refId, rowType, _recno) => { + const b = { id, code, name, spec, unit, refId }; + return Object.assign(b, { rowType, qty: 1, changedQty: 1, _recno }); + }; + const ratePairs = + "KANNO:14.5,SANBO:3.56,KOBO:1.01,KUNBO:3.545,SLVBO:12.95,YUNBO:4.5,TBUGUM:0,ANJUN:1.86,KITA:6,HWAN:0.8,EQIP:0.4,ILBAN:6,IYUN:15,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"; + const rates = Object.fromEntries( + ratePairs.split(",").map((p) => [p.split(":")[0], Number(p.split(":")[1])]), + ); + const revKeys = "KANNO,SANBO,KOBO,KUNBO,SLVBO,YUNBO,TBUGUM,ANJUN,KITA,HWAN,EQIP".split(","); + const revModes = Object.fromEntries( + revKeys.map((k) => [ + k, + ["KUNBO", "SLVBO", "YUNBO"].includes(k) ? "carry_original" : "recalculated", + ]), + ); + const projectMeta = { + pname: "2024년 산림유역관리사업(기번41, 국비)", + pnum: "2024년 단가", + pcode: "11", + }; + Object.assign(projectMeta, { pclass: 0, kOrd: 0, tcost1: 781283415, costw1: 1414283000 }); + const sysConfig = { + rxFormula: "1/8*16/12*25/20", + condCodes: "L00008, L00009, L00010", + usdRate1: 1350, + usdRate2: 1400, + }; + Object.assign(sysConfig, { + useChangedUsd: false, + ecdDigits: 0, + chnMode: "bimok", + optOrder: "SML", + }); + sysConfig.kOrdSim = { pclass: 7, regMode: 7, flag: 0, currentK: 2 }; + const kanjubConfig = { + scheme: "T", + indirectTruncDigits: 0, + anjunBaseAdd: 5349000, + anjunGovVatExcluded: true, + }; + Object.assign(kanjubConfig, { + anjunCap12: true, + vatMode: "supply_10", + totJeolDigits: -3, + enableProfitEndAdj: true, + }); + Object.assign(kanjubConfig, { govRoundMode: "up", govRoundDigits: -3, rates, revModes }); + const dName = "2024년 산림유역관리 본공사 종합"; + const dLines = ["B_MAT", "B_LAB", "B_EXP"].map((refId) => ({ + refId, + qty: 1, + halPercent: 0, + devd: false, + })); + const d00014 = { id: "D00014", code: "D00014", name: dName, rawName: dName }; + Object.assign(d00014, { prefix: "D", spec: "산림토목", unit: "식", lines: dLines }); + const b1Name = "2024년 산림유역관리 본공사 일체"; + return { + projectMeta, + sysConfig, + resources: [ + mkRes("B_MAT", "봉화군 산림유역 직접재료비", "식", "식", "재료비", 191867113), + mkRes("B_LAB", "봉화군 산림유역 직접노무비", "식", "식", "노무비", 426802539), + mkRes("B_EXP", "봉화군 산림유역 산출경비", "식", "식", "경비", 162613763), + mkRes("G_MAT", "봉화군 관급자재 원자재 합계", "C4 제외구역", "식", "재료비", 142783280), + mkRes("G_FEE", "봉화군 관급 조달수수료", "C4 제외구역", "식", "경비", 771029), + ], + machines: [], + sanItems: [], + ilwiItems: [d00014], + boqItems: [ + mkBoq("BOQ_1", "D00014", b1Name, "산림토목", "식", "D00014", "normal", 1), + mkBoq("BOQ_2", "G_MAT", "[제외 C4] 관급자재 원자재", "관급", "식", "G_MAT", "gov_mat", 2), + mkBoq("BOQ_3", "G_FEE", "[제외 C4] 관급 조달수수료", "관급", "식", "G_FEE", "gov_fee", 3), + ], + kanjubConfig, + }; } - return { - KINDS, - KIND_KEYS, - roundDown, - roundUp, - roundHalfUp, - roundBanker, - evalWageFormulaLeftToRight, - evalQFormula, - evalKOrdTransition, - calculateAll, - }; + function calculateAll(data) { + const resources = calcResources(data), + machines = calcMachines(data, resources.map), + sanCal = calcSanCal(data, machines.map); + const ilwi = calcIlwi(data, resources.map, machines.map, sanCal.map); + const boqOrig = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, false), + kanjubOrig = calcKanjub(data, boqOrig, null); + const boqRev = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, true), + kanjubRev = calcKanjub(data, boqRev, kanjubOrig); + const base = { resources, machines, sanCal, ilwi, boqOrig, kanjubOrig }; + Object.assign(base, { boqRev, kanjubRev, resList: resources.list }); + return Object.assign(base, { boqComputed: boqOrig, kanjubComputed: kanjubOrig }); + } + + const evalLeftToRight = (exprStr) => evalWageFormulaLeftToRight(1, exprStr).coeff || 0; + const api = { KINDS, KIND_KEYS, num, fmt, evalFormula, evalLeftToRight }; + Object.assign(api, { roundDown, roundUp, roundHalfUp, roundBanker }); + Object.assign(api, { evalWageFormulaLeftToRight, evalQFormula, evalKOrdTransition }); + return Object.assign(api, { createDefaultState, calculateAll, computeAll: calculateAll }); })(); +if (typeof window !== "undefined") window.STMATE_CALC = StmateCalc; +if (typeof globalThis !== "undefined") globalThis.STMATE_CALC = StmateCalc; if (typeof module !== "undefined" && module.exports) module.exports = StmateCalc; diff --git a/tools/stmate-test/db_bridge.js b/tools/stmate-test/db_bridge.js deleted file mode 100644 index 4dfafaf29..000000000 --- a/tools/stmate-test/db_bridge.js +++ /dev/null @@ -1,345 +0,0 @@ -/* -*- coding: utf-8 -*- */ -/** - * STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 컨트롤러 (db_bridge.js) - */ -const StmateDbBridge = (() => { - const API_BASE = "http://127.0.0.1:8095"; - 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 dbCtx = { - connected: false, - sources: { projects: [], jbr_files: [], work_root: "" }, - selectedProjectPath: "", - selectedJbrPath: "", - targetFolderName: "stmate_edited", - loadedDbf: null, - loadedJbr: null, - lastSaveResult: null, - statusMsg: "로컬 DB 브리지(127.0.0.1:8095) 연결 확인 중...", - }; - - async function checkAndLoadSources(onUpdate) { - try { - const resp = await fetch(`${API_BASE}/api/sources`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - dbCtx.connected = true; - dbCtx.sources = data; - if (!dbCtx.selectedProjectPath && data.projects.length > 0) { - dbCtx.selectedProjectPath = data.projects[0].path; - } - const onlyJbr = data.jbr_files.filter((f) => f.ext === ".JB_R"); - if (!dbCtx.selectedJbrPath && onlyJbr.length > 0) { - dbCtx.selectedJbrPath = onlyJbr[onlyJbr.length - 1].path; - } - dbCtx.statusMsg = `연결됨 — PC 프로젝트 DB ${data.projects.length}건 · ST_BSC 제비율 DB ${data.jbr_files.length}건 감지`; - } catch (_) { - dbCtx.connected = false; - dbCtx.statusMsg = - "오프라인 — 터미널에서 .\\venv\\Scripts\\python.exe tools\\stmate-test\\server.py 실행 시 DBF 직접 읽기/쓰기 활성화"; - } - if (onUpdate) onUpdate(); - } - - async function loadProjectDbf(applyStateCallback, onUpdate) { - if (!dbCtx.selectedProjectPath) return; - try { - dbCtx.statusMsg = `DBF/STC 읽는 중: ${dbCtx.selectedProjectPath} ...`; - if (onUpdate) onUpdate(); - const resp = await fetch(`${API_BASE}/api/load`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_path: dbCtx.selectedProjectPath }), - }); - if (!resp.ok) throw new Error(await resp.text()); - const data = await resp.json(); - dbCtx.loadedDbf = data; - dbCtx.lastSaveResult = null; - dbCtx.statusMsg = `로드 완료: ${data.prjlst?.PNAME || data.source_path} (COSTN ${data.costn_count}건, ${data.boq_table_name} ${data.boq_count}건)`; - if (data.sim_state && applyStateCallback) { - applyStateCallback(data.sim_state); - } - } catch (err) { - dbCtx.statusMsg = `DBF 로드 실패: ${err.message || err}`; - if (onUpdate) onUpdate(); - } - } - - async function loadAndApplyJbr(state, saveAndRender) { - if (!dbCtx.selectedJbrPath) return; - try { - const resp = await fetch(`${API_BASE}/api/load-jbr`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jbr_path: dbCtx.selectedJbrPath }), - }); - if (!resp.ok) throw new Error(await resp.text()); - const data = await resp.json(); - dbCtx.loadedJbr = data; - const r = data.extracted_rates; - Object.assign(state.kanjubConfig.rates, { - KANNO: r.KANNO || state.kanjubConfig.rates.KANNO, - KITA: r.KITA || state.kanjubConfig.rates.KITA, - ILBAN: r.ILBAN || state.kanjubConfig.rates.ILBAN, - SANBO: r.SANBO, - KOBO: r.KOBO, - KUNBO: r.KUNBO, - YUNBO: r.YUNBO, - SLVBO: r.SLVBO, - IYUN: r.IYUN, - ANJUN: r.ANJUN, - }); - state.kanjubConfig.anjunBaseAdd = r.anjunBaseAdd; - dbCtx.statusMsg = `ST_BSC 제비율 DB(${data.name}) 요율 적용 완료`; - if (saveAndRender) saveAndRender(); - } catch (err) { - dbCtx.statusMsg = `JB_R 로드 실패: ${err.message || err}`; - if (saveAndRender) saveAndRender(); - } - } - - async function saveToDbf(state, calcRes, onUpdate) { - const srcPath = dbCtx.loadedDbf?.source_path || dbCtx.selectedProjectPath; - if (!srcPath) return; - try { - const k = calcRes.kanjubOrig; - const costnUpdates = (dbCtx.loadedDbf?.costn_rows || []).slice(0, 80).map((r) => ({ - CODE: r.CODE, - NAME: r.NAME, - TAMT1: r.TAMT1, - LAMT1: r.LAMT1, - MAMT1: r.MAMT1, - SAMT1: r.SAMT1, - })); - // 시뮬레이터 기초자원 단가 변경분도 COSTN 업데이트에 병합 - for (const resItem of calcRes.resources.list) { - costnUpdates.push({ - CODE: resItem.code, - NAME: resItem.name, - TAMT1: resItem.adoptedPrice, - LAMT1: resItem.kind === "노무비" ? resItem.adoptedPrice : 0, - MAMT1: resItem.kind === "재료비" ? resItem.adoptedPrice : 0, - SAMT1: resItem.kind === "경비" ? resItem.adoptedPrice : 0, - }); - } - - const boqUpdates = calcRes.boqOrig.rows - .filter((r) => r._recno !== undefined) - .map((r) => ({ - _recno: r._recno, - NAME1: r.name, - QTY1: r.qty, - MAMT1: r.unitInfo.M, - LAMT1: r.unitInfo.L, - SAMT1: r.unitInfo.S, - TAMT1: r.unitInfo.T, - })); - - const prjlstUpdates = { - MCOST1: k.dM, - LCOST1: k.dL, - SCOST1: k.dS, - TCOST1: k.dT, - COSTW1: k.grandTotal, - }; - const rates = state.kanjubConfig.rates; - const smtUpdates = { - "1_R_KANNO": rates.KANNO, - "1_R_SANBO": rates.SANBO, - "1_R_KOBO": rates.KOBO, - "1_R_KUNBO": rates.KUNBO, - "1_R_SLVBO": rates.SLVBO, - "1_R_YUNBO": rates.YUNBO, - "1_R_ANJUN": rates.ANJUN, - "1_R_T_KITA": rates.KITA, - "1_R_HWAN": rates.HWAN, - "1_R_EQIPSU": rates.EQIP, - "1_R_ILBAN": rates.ILBAN, - "1_R_IYUN": rates.IYUN, - "1_C_KANNO": k.kanno, - "1_C_ILBAN": k.ilbanAmt, - "1_C_IYUN_M": k.pretaxProfitAdj, - "1_C_KONGSA_J": k.contractBeforeAdj, - "1_C_UPDNKWN": k.govFinal, - }; - - const resp = await fetch(`${API_BASE}/api/save`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - source_path: srcPath, - target_folder_name: dbCtx.targetFolderName || "stmate_edited", - costn_updates: costnUpdates, - boq_updates: boqUpdates, - prjlst_updates: prjlstUpdates, - smt_updates: smtUpdates, - }), - }); - if (!resp.ok) throw new Error(await resp.text()); - const saved = await resp.json(); - dbCtx.lastSaveResult = saved; - dbCtx.statusMsg = `DBF·STC 저장 완료: ${saved.saved_stc} (${fmt(saved.stc_size, 0)} bytes)`; - await checkAndLoadSources(); - } catch (err) { - dbCtx.statusMsg = `DBF 저장 실패: ${err.message || err}`; - } - if (onUpdate) onUpdate(); - } - - function renderDbTab(state, calcRes) { - const projOpts = dbCtx.sources.projects - .map( - (p) => - ``, - ) - .join(""); - const jbrOpts = dbCtx.sources.jbr_files - .map( - (f) => - ``, - ) - .join(""); - - let dbfPreviewHtml = - '
상단에서 PC 내 STMATE 폴더 또는 .STC 파일을 선택하고 [원본 DBF/STC 불러오기]를 실행하세요.
'; - if (dbCtx.loadedDbf) { - const d = dbCtx.loadedDbf; - const costnRows = (d.costn_rows || []) - .slice(0, 25) - .map( - (r, i) => ` - - ${esc(r.CODE)} - - ${esc(r.SIZE)} - ${esc(r.UNIT)} - - - - - ${fmt(r.AMT_61, 0)} - `, - ) - .join(""); - - dbfPreviewHtml = ` -
- 현재 연결 원본: ${esc(d.prjlst?.PNAME || d.source_path)} · 수록 테이블: ${esc(d.file_list.join(", "))} - 원장 COSTN.DBF ${d.costn_count}건 · 내역 ${esc(d.boq_table_name)} ${d.boq_count}건 · PRJLST.SMT 설정키 ${Object.keys(d.prjlst_smt || {}).length}개 -
-
- - - ${costnRows} -
CODE품명(NAME)규격(SIZE)단위재료단가(MAMT1)노무단가(LAMT1)경비단가(SAMT1)합계단가(TAMT1)채택6칸(AMT_61)
-
`; - } - - const saveAlert = dbCtx.lastSaveResult - ? `
최근 쓰기 완료 폴더: ${esc(dbCtx.lastSaveResult.saved_dir)}생성된 패키지: ${esc(dbCtx.lastSaveResult.saved_stc)} (${fmt(dbCtx.lastSaveResult.stc_size, 0)} bytes, 파일 ${dbCtx.lastSaveResult.written_count}종)
` - : ""; - - return ` -
-
-
-

PC 내 STMATE 원본 DBF · SMT · STC · JB_R 직접 읽기/쓰기 연동

-

C:\\경영정보(주) 설치 폴더의 프로젝트 DBF(COSTN.DBF, BOQ11.DBF, PRJLST.DBF/.SMT), System\\ST_BSC\\*.JB_R 제비율 기준 DB, 그리고 실무 .STC 패키지를 직접 읽고 CP949 고정폭 바이너리 DBF로 다시 기록합니다.

-
-
- -
-
-
- 상태: ${esc(dbCtx.statusMsg)} - 브리지 주소: ${API_BASE} -
-
- -
- -
- -
- -
- -
- -
-
- ${saveAlert} -
-
-
-
-

원본 COSTN.DBF 레코드 직접 조회 및 편집 (상위 25건 미리보기)

-

여기서 수정한 단가와 01~06 탭에서 조작한 수량·요율·원가 결과가 [DBF 및 .STC로 직접 쓰기] 실행 시 COSTN.DBF, BOQ11/12.DBF, PRJLST.DBF/.SMT에 기록됩니다.

-
-
- ${dbfPreviewHtml} -
`; - } - - function initEvents(getState, setState, saveAndRender) { - document.addEventListener("click", (e) => { - const btn = e.target.closest("[data-db-action]"); - if (!btn) return; - const act = btn.dataset.dbAction; - if (act === "refresh-sources") { - checkAndLoadSources(saveAndRender); - } else if (act === "load-project") { - loadProjectDbf((newState) => { - setState(newState); - saveAndRender(); - }, saveAndRender); - } else if (act === "load-jbr") { - loadAndApplyJbr(getState(), saveAndRender); - } else if (act === "save-dbf") { - const st = getState(); - saveToDbf(st, StmateCalc.calculateAll(st), saveAndRender); - } - }); - - document.addEventListener("change", (e) => { - const el = e.target; - if (el.id === "selDbProject") dbCtx.selectedProjectPath = el.value; - else if (el.id === "selDbJbr") dbCtx.selectedJbrPath = el.value; - else if (el.id === "inpTargetFolder") dbCtx.targetFolderName = el.value; - else if (el.dataset.dbf === "costn" && dbCtx.loadedDbf?.costn_rows) { - const idx = Number(el.dataset.idx); - const fld = el.dataset.field; - if (dbCtx.loadedDbf.costn_rows[idx]) { - dbCtx.loadedDbf.costn_rows[idx][fld] = el.value; - } - } - }); - - checkAndLoadSources(saveAndRender); - } - - return { - dbCtx, - initEvents, - checkAndLoadSources, - loadProjectDbf, - loadAndApplyJbr, - saveToDbf, - renderDbTab, - }; -})(); diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html index 9c1c832d3..d45c43877 100644 --- a/tools/stmate-test/index.html +++ b/tools/stmate-test/index.html @@ -1,70 +1,179 @@ - - - STMATE 계산·제어 흐름 독립 테스트 + + + STMATE w 8.0 — 경영정보(주) 원가·내역 통합 시스템 (독립 재현판) - - - - -
-
-
- 독립 테스트 웹앱 - REA 6.7.0 / Ghidra 12.1.4 분석 반영 - 무로그인 · 로컬 전용 +
+ + -
- - - -
-
- -
-
- 시험·검증 전용 안내 - 이 웹앱은 Aislo 본체와 완전히 분리된 독립 테스트 도구입니다. 수록된 단가·요율은 계산 - 경로와 제어 옵션을 점검하기 위한 시험 데이터이며, 현행 법령·품셈 확정값이 아닙니다. + ⚙️ + 초 기 값 + + + + + + + + +
+
-
+ +
- + +
-
+ +
+ -
- 모든 조작 상태는 현재 브라우저 탭(`sessionStorage`)에 자동 보관됩니다. 영구 보관하거나 - 다른 환경에서 열려면 JSON 저장 / 열기를 사용하세요. -
-
+ + + + + + + + diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py index 7c7657e9a..c762d44da 100644 --- a/tools/stmate-test/server.py +++ b/tools/stmate-test/server.py @@ -1,14 +1,10 @@ # -*- coding: utf-8 -*- -"""STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 로컬 서버 (server.py).""" +"""STMATE 원본 DBF / SMT / STC / JB_R 100% 무손실 직접 읽기·쓰기 연동 서버 (server.py).""" from __future__ import annotations -import datetime -import io import pathlib import re -import shutil -import struct import zipfile from decimal import Decimal, ROUND_DOWN from fastapi import FastAPI, HTTPException @@ -16,6 +12,27 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel +try: + from .smt_parser import ( + build_dbf_bytes, + extract_costn_memos, + load_stmate_catalog, + parse_dbf_bytes, + parse_prjlst_smt, + rebuild_costn_with_smt, + update_prjlst_smt_bytes, + ) +except ImportError: + from smt_parser import ( + build_dbf_bytes, + extract_costn_memos, + load_stmate_catalog, + parse_dbf_bytes, + parse_prjlst_smt, + rebuild_costn_with_smt, + update_prjlst_smt_bytes, + ) + HERE = pathlib.Path(__file__).resolve().parent REPO_ROOT = HERE.parent.parent WORK_DBF_ROOT = HERE / "work_dbf" @@ -24,145 +41,9 @@ STMATE_SYS_BSC = STMATE_ROOT / "System" / "ST_BSC" PRAC_DOCS_ROOT = REPO_ROOT / "resources" / "knowledge" / "original" / "실무문서" STRUCT_RE = re.compile(r"^(T\d|E\d|C\d|:)$") -SMT_KV_RE = re.compile(r"([A-Za-z0-9_]+)=([^\r\n\x00]+)") -app = FastAPI(title="STMATE DBF Direct Bridge") -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - - -def parse_dbf_bytes(b: bytes) -> tuple[list[dict], list[dict], dict]: - """dBASE III/IV 바이트를 파싱하여 (필드정의, 레코드목록, 헤더메타) 반환.""" - if len(b) < 32: - raise ValueError("DBF 헤더 길이 부족") - ver = b[0] - nrec, hlen, rlen = struct.unpack_from(" len(b): - break - rec = b[pos : pos + rlen] - deleted = rec[0:1] == b"*" - row: dict = {"_recno": i, "_deleted": deleted} - cur = 1 - for f in fields: - val_bytes = rec[cur : cur + f["len"]] - cur += f["len"] - row[f["name"]] = val_bytes.decode("cp949", "replace").strip() - if not deleted: - rows.append(row) - - meta = {"version": ver, "hlen": hlen, "rlen": rlen, "header_tail_hex": b[off:hlen].hex()} - return fields, rows, meta - - -def build_dbf_bytes(fields: list[dict], rows: list[dict], meta: dict | None = None) -> bytes: - """(필드정의, 레코드목록)을 STMATE 호환 CP949 고정폭 DBF 바이트로 직렬화.""" - ver = (meta or {}).get("version", 0x03) - today = datetime.date.today() - nrec = len(rows) - rlen = 1 + sum(int(f["len"]) for f in fields) - tail_bytes = bytes.fromhex((meta or {}).get("header_tail_hex", "0d")) - if not tail_bytes or tail_bytes[0] != 0x0D: - tail_bytes = b"\x0d" - hlen = 32 + 32 * len(fields) + len(tail_bytes) - - hdr = bytearray(32) - hdr[0] = ver - hdr[1] = today.year - 1900 - hdr[2] = today.month - hdr[3] = today.day - struct.pack_into(" 0 else f"{int(round(num))}" - except Exception: - pass - enc = sval.encode("ascii", "replace")[:flen] - out.write(enc.rjust(flen, b" ")) - else: - sval = "" if raw_val is None else str(raw_val) - enc = sval.encode("cp949", "replace")[:flen] - out.write(enc.ljust(flen, b" ")) - - out.write(b"\x1a") - return out.getvalue() - - -def parse_prjlst_smt(b: bytes) -> dict[str, str]: - if len(b) <= 0x200: - return {} - txt = b[0x200:].decode("cp949", "replace") - return {k: v.strip() for k, v in SMT_KV_RE.findall(txt)} - - -def update_prjlst_smt_bytes(orig_b: bytes, updates: dict[str, str | int | float]) -> bytes: - """PRJLST.SMT의 SIxMemo 헤더(0x200)를 보존하며 키=값 본문을 갱신.""" - header = bytearray(orig_b[:0x200]) if len(orig_b) >= 0x200 else bytearray(b"\x00" * 0x200) - txt = orig_b[0x200:].decode("cp949", "replace") if len(orig_b) > 0x200 else "" - for k, v in updates.items(): - pat = re.compile(rf"(^|\r?\n)({re.escape(k)}=)([^\r\n\x00]*)", re.M) - if pat.search(txt): - txt = pat.sub(rf"\g<1>\g<2>{v}", txt) - else: - txt += f"\r\n{k}={v}" - body_b = txt.encode("cp949", "replace") - return bytes(header) + body_b +app = FastAPI(title="STMATE w 8.0 Full DBF/SMT Engine") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) def fnum(val: object, default: float = 0.0) -> float: @@ -188,25 +69,13 @@ def discover_sources() -> dict: ) for stc in sorted(STMATE_ROOT.rglob("*.STC")): projects.append( - { - "id": str(stc), - "name": f"[PC STC] {stc.name}", - "path": str(stc), - "kind": "stc", - } + {"id": str(stc), "name": f"[PC STC] {stc.name}", "path": str(stc), "kind": "stc"} ) - if PRAC_DOCS_ROOT.exists(): for stc in sorted(PRAC_DOCS_ROOT.rglob("*.STC")): projects.append( - { - "id": str(stc), - "name": f"[실무 STC] {stc.stem}", - "path": str(stc), - "kind": "stc", - } + {"id": str(stc), "name": f"[실무 STC] {stc.stem}", "path": str(stc), "kind": "stc"} ) - if WORK_DBF_ROOT.exists(): for costn in sorted(WORK_DBF_ROOT.rglob("COSTN.DBF")): pdir = costn.parent @@ -218,19 +87,14 @@ def discover_sources() -> dict: "kind": "work_folder", } ) - - jbr_files: list[dict] = [] - if STMATE_SYS_BSC.exists(): - for p in sorted(STMATE_SYS_BSC.glob("*.JB_R*")): - jbr_files.append( - { - "name": p.name, - "path": str(p), - "ext": p.suffix.upper(), - "size": p.stat().st_size, - } - ) - + jbr_files = ( + [ + {"name": p.name, "path": str(p), "ext": p.suffix.upper(), "size": p.stat().st_size} + for p in sorted(STMATE_SYS_BSC.glob("*.JB_R*")) + ] + if STMATE_SYS_BSC.exists() + else [] + ) return {"projects": projects, "jbr_files": jbr_files, "work_root": str(WORK_DBF_ROOT)} @@ -252,10 +116,32 @@ def read_project_files(source_path: str) -> dict[str, bytes]: return files +def _make_syn_resource(res_id: str, name: str, spec: str, unit: str, kind: str, val: float) -> dict: + return { + "id": res_id, + "code": res_id, + "name": f"{name} ({kind})", + "spec": spec, + "unit": unit, + "kind": kind, + "candidates": [val, 0, 0, 0, 0], + "adoptMode": "manual", + "manualPrice": val, + "convertHourly": False, + "isUsd": False, + "isSynthetic": True, + } + + def convert_dbf_to_sim_state( - costn_rows: list[dict], boq_rows: list[dict], smt_kv: dict[str, str], prj_row: dict + costn_rows: list[dict], + boq_rows: list[dict], + smt_kv: dict[str, str], + prj_row: dict, + costn_memos: dict[str, dict] | None = None, ) -> dict: - """원본 COSTN.DBF / BOQ11~12.DBF / PRJLST.SMT 레코드를 시뮬레이터 state로 변환.""" + """원본 COSTN.DBF(전수) / COSTN.SMT / BOQ11~14.DBF(전수) / PRJLST.SMT를 무손실 변환.""" + costn_memos = costn_memos or {} resources: list[dict] = [] machines: list[dict] = [] san_items: list[dict] = [] @@ -266,9 +152,7 @@ def convert_dbf_to_sim_state( if not code: continue prefix = code[0].upper() - name = r.get("NAME") or code - spec = r.get("SIZE") or "" - unit = r.get("UNIT") or "" + name, spec, unit = r.get("NAME") or code, r.get("SIZE") or "", r.get("UNIT") or "" t1, l1, m1, s1 = ( fnum(r.get("TAMT1")), fnum(r.get("LAMT1")), @@ -277,11 +161,11 @@ def convert_dbf_to_sim_state( ) cands = [fnum(r.get(f"AMT_{i}1")) for i in range(1, 6)] amt61 = fnum(r.get("AMT_61")) or t1 + memo_obj = costn_memos.get(code) if prefix in ("M", "L", "S"): kind = "노무비" if prefix == "L" else ("경비" if prefix == "S" else "재료비") non_zero = [x for x in cands if x > 0] - is_min = bool(non_zero and abs(min(non_zero) - amt61) < 1e-4) resources.append( { "id": code, @@ -291,28 +175,68 @@ def convert_dbf_to_sim_state( "unit": unit, "kind": kind, "candidates": cands if any(cands) else [amt61, 0, 0, 0, 0], - "adoptMode": "min" if is_min else "manual", + "adoptMode": "min" + if (non_zero and abs(min(non_zero) - amt61) < 1e-4) + else "manual", "manualPrice": amt61, "convertHourly": False, "isUsd": (r.get("DOLLOR") or "").upper() == "T", + "jdCode": r.get("JDCODE") or "", + "uc": r.get("UC") or "F", } ) else: - # B(일위대가), D(단가산출), X(중기)도 내역서 직접 참조가 가능하도록 복합단가 보존 - ilwi_items.append( - { - "id": code, - "code": code, - "name": f"[{prefix}] {name}", - "spec": spec, - "unit": unit, - "fixedM": m1, - "fixedL": l1, - "fixedS": s1, - "lines": [], - } - ) - # 고정 성분단가를 기초자원 참조행으로 연결하여 시뮬레이터에서 동일 단가 유지 + if prefix == "X": + machines.append( + { + "id": code, + "code": code, + "name": name, + "spec": spec, + "unit": unit or "Hr", + "price": t1, + "hourlyL": l1, + "hourlyM": m1, + "hourlyS": s1, + "hourlyT": t1, + "smtText": memo_obj["text"] if memo_obj else "", + } + ) + elif prefix == "D" or memo_obj: + vmap = memo_obj["vars"] if memo_obj else {} + ref_x = next( + ( + rf["code"] + for rf in (memo_obj["refs"] if memo_obj else []) + if rf["code"].startswith("X") + ), + "", + ) + san_items.append( + { + "id": code, + "code": code, + "name": name, + "spec": spec, + "unit": unit, + "machineId": ref_x, + "q": fnum(vmap.get("q"), 0.7), + "K": fnum(vmap.get("K") or vmap.get("k"), 0.9), + "fExpr": str(vmap.get("f") or vmap.get("F") or "1.0"), + "EExpr": str(vmap.get("E") or vmap.get("E1") or "0.55"), + "Cm": fnum(vmap.get("Cm"), 20.0), + "cmFormula": memo_obj["cmFormula"] if memo_obj else "", + "qFormula": (memo_obj["qFormula"] if memo_obj else "") or "3600*q*K*f*E/Cm", + "smtText": memo_obj["text"] if memo_obj else "", + "smtRefs": memo_obj["refs"] if memo_obj else [], + "fixedM": m1, + "fixedL": l1, + "fixedS": s1, + "fixedT": t1, + } + ) + + lines = [] for comp_tag, comp_kind, val in ( ("M", "재료비", m1), ("L", "노무비", l1), @@ -320,26 +244,27 @@ def convert_dbf_to_sim_state( ): if val > 0: res_id = f"{code}_{comp_tag}" - resources.append( - { - "id": res_id, - "code": res_id, - "name": f"{name} ({comp_kind})", - "spec": spec, - "unit": unit, - "kind": comp_kind, - "candidates": [val, 0, 0, 0, 0], - "adoptMode": "manual", - "manualPrice": val, - "convertHourly": False, - "isUsd": False, - } - ) - ilwi_items[-1]["lines"].append( - {"refId": res_id, "qty": 1.0, "halPercent": 0, "devd": False} - ) + resources.append(_make_syn_resource(res_id, name, spec, unit, comp_kind, val)) + lines.append({"refId": res_id, "qty": 1.0, "halPercent": 0, "devd": False}) + ilwi_items.append( + { + "id": code, + "code": code, + "name": f"[{prefix}] {name}", + "rawName": name, + "prefix": prefix, + "spec": spec, + "unit": unit, + "fixedM": m1, + "fixedL": l1, + "fixedS": s1, + "fixedT": t1, + "smtText": memo_obj["text"] if memo_obj else "", + "smtRefs": memo_obj["refs"] if memo_obj else [], + "lines": lines, + } + ) - # BOQ 제외금액 구역(C*) 판별 (16번 문서 §4.2) excl_span: set[int] = set() for i, r in enumerate(boq_rows): bd = (r.get("BDCODE1") or "").strip() @@ -360,37 +285,28 @@ def convert_dbf_to_sim_state( continue q1 = fnum(r.get("QTY1")) q2 = fnum(r.get("QTY2")) if r.get("QTY2") else q1 - name1 = r.get("NAME1") or bd + name1, spec1, unit1 = r.get("NAME1") or bd, r.get("SIZE1") or "", r.get("UNIT1") or "" if bd not in known_ids: - # BOQ 자체 단가를 가진 말단행이면 호표 자동 생성 m1, l1, s1 = fnum(r.get("MAMT1")), fnum(r.get("LAMT1")), fnum(r.get("SAMT1")) lines = [] for ctag, ckind, cval in (("M", "재료비", m1), ("L", "노무비", l1), ("S", "경비", s1)): if cval > 0: rid = f"{bd}_{ctag}" - resources.append( - { - "id": rid, - "code": rid, - "name": f"{name1} ({ckind})", - "spec": r.get("SIZE1") or "", - "unit": r.get("UNIT1") or "", - "kind": ckind, - "candidates": [cval, 0, 0, 0, 0], - "adoptMode": "manual", - "manualPrice": cval, - "convertHourly": False, - "isUsd": False, - } - ) + resources.append(_make_syn_resource(rid, name1, spec1, unit1, ckind, cval)) lines.append({"refId": rid, "qty": 1.0, "halPercent": 0, "devd": False}) ilwi_items.append( { "id": bd, "code": bd, "name": name1, - "spec": r.get("SIZE1") or "", - "unit": r.get("UNIT1") or "", + "rawName": name1, + "prefix": bd[0].upper(), + "spec": spec1, + "unit": unit1, + "fixedM": m1, + "fixedL": l1, + "fixedS": s1, + "fixedT": fnum(r.get("TAMT1")), "lines": lines, } ) @@ -399,12 +315,22 @@ def convert_dbf_to_sim_state( row_type = "normal" if i in excl_span: row_type = "gov_fee" if ("수수료" in name1 or fnum(r.get("S_AMT1")) > 0) else "gov_mat" - + is_pct_fee = unit1.endswith("%") and fnum(r.get("T_AMT1")) > 0 + if is_pct_fee: + fee_amt = fnum(r.get("T_AMT1")) + for res in resources: + if res["id"] == bd and res.get("manualPrice", 0) == 0: + res["manualPrice"] = fee_amt + res["candidates"][0] = fee_amt + q1, q2 = 1.0, 1.0 boq_items.append( { "id": f"BOQ_{i + 1}", "code": bd, + "itnum": r.get("ITNUM") or "", "name": name1, + "spec": spec1, + "unit": unit1, "refId": bd, "rowType": row_type, "qty": q1, @@ -436,25 +362,27 @@ def convert_dbf_to_sim_state( "S_DOSEO": fnum(smt_kv.get("1_R_S_DOSEO"), 0.0), "S_JIGUP": fnum(smt_kv.get("1_R_S_JIGUP"), 0.0), } - jab_type = int(fnum(smt_kv.get("JAB_TYPE"), 0)) cal_actl = int(fnum(smt_kv.get("CAL_ACTL"), 0)) scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T") - tot_jeol = -int(fnum(smt_kv.get("1_I_TOT_JEOL"), 3)) return { "projectMeta": { "pname": prj_row.get("PNAME", ""), "pnum": prj_row.get("PNUM", ""), + "pcode": prj_row.get("PCODE", "11"), + "pclass": int(fnum(prj_row.get("PCLASS"), 0)), "kOrd": int(fnum(prj_row.get("K_ORD"), 0)), + "tcost1": fnum(prj_row.get("TCOST1"), 0), + "costw1": fnum(prj_row.get("COSTW1"), 0), }, "sysConfig": { - "rxFormula": "1/8*16/12*25/20", + "rxFormula": prj_row.get("RXNM_") or "1/8*16/12*25/20", "condCodes": "L00008, L00009, L00010", - "usdRate1": 1350, - "usdRate2": 1400, + "usdRate1": fnum(prj_row.get("EXLNM1"), 1350), + "usdRate2": fnum(prj_row.get("EXLNM2"), 1400), "useChangedUsd": False, - "ecdDigits": 0, + "ecdDigits": int(fnum(prj_row.get("ECD"), 0)), "chnMode": "total" if prj_row.get("CHAMT") == "T" else "bimok", "optOrder": "SML", "kOrdSim": { @@ -464,10 +392,10 @@ def convert_dbf_to_sim_state( "currentK": int(fnum(prj_row.get("K_ORD"), 2)) or 2, }, }, - "resources": resources[:120], + "resources": resources, "machines": machines, "sanItems": san_items, - "ilwiItems": ilwi_items[:120], + "ilwiItems": ilwi_items, "boqItems": boq_items, "kanjubConfig": { "scheme": scheme, @@ -476,23 +404,26 @@ def convert_dbf_to_sim_state( "anjunGovVatExcluded": True, "anjunCap12": True, "vatMode": "supply_10", - "totJeolDigits": tot_jeol, + "totJeolDigits": -int(fnum(smt_kv.get("1_I_TOT_JEOL"), 3)), "enableProfitEndAdj": True, "govRoundMode": "up", "govRoundDigits": -int(fnum(smt_kv.get("1_I_KWN_JEOLSA"), 3)), "rates": rates, "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", + k: ("carry_original" if k in ("KUNBO", "SLVBO", "YUNBO") else "recalculated") + for k in ( + "KANNO", + "SANBO", + "KOBO", + "KUNBO", + "SLVBO", + "YUNBO", + "TBUGUM", + "ANJUN", + "KITA", + "HWAN", + "EQIP", + ) }, }, } @@ -513,6 +444,7 @@ class SaveDbfReq(BaseModel): boq_updates: list[dict] = [] prjlst_updates: dict = {} smt_updates: dict = {} + costn_memo_updates: dict[str, str] = {} @app.get("/api/sources") @@ -520,38 +452,57 @@ def api_sources(): return discover_sources() +@app.get("/api/catalog") +def api_catalog(): + return load_stmate_catalog() + + @app.post("/api/load") def api_load(req: LoadReq): files = read_project_files(req.source_path) if "COSTN.DBF" not in files: raise HTTPException(status_code=400, detail="COSTN.DBF 파일이 포함되지 않은 경로임") - costn_flds, costn_rows, _ = parse_dbf_bytes(files["COSTN.DBF"]) - boq_name = "BOQ12.DBF" if "BOQ12.DBF" in files else "BOQ11.DBF" - boq_flds, boq_rows, _ = parse_dbf_bytes(files[boq_name]) if boq_name in files else ([], [], {}) - prj_flds, prj_rows, _ = ( + _, costn_rows, _ = parse_dbf_bytes(files["COSTN.DBF"]) + costn_memos = extract_costn_memos(files["COSTN.DBF"], files.get("COSTN.SMT", b"")) + + boq_tables: dict[str, list[dict]] = {} + for bname in sorted(k for k in files if k.startswith("BOQ") and k.endswith(".DBF")): + _, brows, _ = parse_dbf_bytes(files[bname]) + boq_tables[bname] = brows + + boq_name = "BOQ11.DBF" if "BOQ11.DBF" in boq_tables else (next(iter(boq_tables), "BOQ11.DBF")) + boq_rows = boq_tables.get(boq_name, []) + _, prj_rows, _ = ( parse_dbf_bytes(files["PRJLST.DBF"]) if "PRJLST.DBF" in files else ([], [{}], {}) ) smt_kv = parse_prjlst_smt(files.get("PRJLST.SMT", b"")) - rate_r_rows = [] - if "RATE_R.DBF" in files: - _, rate_r_rows, _ = parse_dbf_bytes(files["RATE_R.DBF"]) + rate_tables: dict[str, list[dict]] = {} + for rname in ("RATE_R.DBF", "RATE_RA.DBF", "RATE_M.DBF", "RATE_B.DBF", "RATE.DBF"): + if rname in files: + _, rrows, _ = parse_dbf_bytes(files[rname]) + rate_tables[rname] = rrows + set_rep_rows = parse_dbf_bytes(files["SET_REP.DBF"])[1] if "SET_REP.DBF" in files else [] sim_state = convert_dbf_to_sim_state( - costn_rows, boq_rows, smt_kv, prj_rows[0] if prj_rows else {} + costn_rows, boq_rows, smt_kv, prj_rows[0] if prj_rows else {}, costn_memos ) return { "source_path": req.source_path, "boq_table_name": boq_name, + "boq_tables": boq_tables, "file_list": sorted(files.keys()), + "prjlst_all": prj_rows, "prjlst": prj_rows[0] if prj_rows else {}, "prjlst_smt": smt_kv, "costn_count": len(costn_rows), - "costn_rows": costn_rows[:200], + "costn_rows": costn_rows, + "costn_memos": costn_memos, "boq_count": len(boq_rows), - "boq_rows": boq_rows[:200], - "rate_r_count": len(rate_r_rows), + "boq_rows": boq_rows, + "rate_tables": rate_tables, + "set_rep_rows": set_rep_rows, "sim_state": sim_state, } @@ -562,17 +513,16 @@ def api_load_jbr(req: LoadJbrReq): if not p.exists(): raise HTTPException(status_code=404, detail=f"JB_R 파일 없음: {req.jbr_path}") flds, rows, _ = parse_dbf_bytes(p.read_bytes()) - matrix: list[list[float]] = [] - for r in rows: - matrix.append([fnum(r.get(f["name"])) for f in flds]) + matrix = [[fnum(r.get(f["name"])) for f in flds] for r in rows] - def cell(row_idx: int, col_1based: int, default: float = 0.0) -> float: - if 0 <= row_idx < len(matrix) and 0 <= col_1based - 1 < len(matrix[row_idx]): - return matrix[row_idx][col_1based - 1] - return default + def cell(r_i: int, c_1: int, d: float = 0.0) -> float: + return ( + matrix[r_i][c_1 - 1] + if 0 <= r_i < len(matrix) and 0 <= c_1 - 1 < len(matrix[r_i]) + else d + ) raw_base = cell(20, 18, 5349.0) - anjun_base = raw_base * 1000 if 0 < raw_base < 100000 else raw_base extracted_rates = { "KANNO": cell(20, 1, 14.5), "KITA": cell(20, 6, 6.0), @@ -584,19 +534,16 @@ def api_load_jbr(req: LoadJbrReq): "SLVBO": cell(46, 11, 12.95), "IYUN": cell(20, 16, 15.0), "ANJUN": cell(20, 17, 1.86), - "anjunBaseAdd": anjun_base, + "anjunBaseAdd": raw_base * 1000 if 0 < raw_base < 100000 else raw_base, } return { "name": p.name, "row_count": len(matrix), "col_count": len(flds), + "fields": [f["name"] for f in flds], + "rows": rows, + "matrix": matrix, "extracted_rates": extracted_rates, - "preview_rows": { - "row6_kanno_kita": matrix[6][:12] if len(matrix) > 6 else [], - "row20_iyun_anjun": matrix[20][:20] if len(matrix) > 20 else [], - "row45_sanbo_kobo": matrix[45][:20] if len(matrix) > 45 else [], - "row46_kunbo_yunbo_slvbo": matrix[46][:15] if len(matrix) > 46 else [], - }, } @@ -607,7 +554,6 @@ def api_save(req: SaveDbfReq): out_dir = WORK_DBF_ROOT / (safe_name or "stmate_edited") out_dir.mkdir(parents=True, exist_ok=True) - # 1. COSTN.DBF 수정 반영 및 직렬화 if "COSTN.DBF" in files: flds, rows, meta = parse_dbf_bytes(files["COSTN.DBF"]) upd_map = {str(u.get("CODE", "")).strip(): u for u in req.costn_updates if u.get("CODE")} @@ -617,10 +563,15 @@ def api_save(req: SaveDbfReq): for k, v in upd_map[cd].items(): if k in r: r[k] = str(v) - files["COSTN.DBF"] = build_dbf_bytes(flds, rows, meta) + dbf_b = build_dbf_bytes(flds, rows, meta) + if req.costn_memo_updates: + dbf_b, smt_b = rebuild_costn_with_smt( + dbf_b, files.get("COSTN.SMT", b""), req.costn_memo_updates + ) + files["COSTN.SMT"] = smt_b + files["COSTN.DBF"] = dbf_b - # 2. BOQ11/12.DBF 수량·단가·비목금액 재계산 및 직렬화 - boq_name = "BOQ12.DBF" if "BOQ12.DBF" in files else "BOQ11.DBF" + boq_name = "BOQ11.DBF" if "BOQ11.DBF" in files else "BOQ12.DBF" if boq_name in files: flds, rows, meta = parse_dbf_bytes(files[boq_name]) upd_by_rec = {int(u["_recno"]): u for u in req.boq_updates if "_recno" in u} @@ -628,24 +579,45 @@ def api_save(req: SaveDbfReq): rno = int(r.get("_recno", -1)) if rno in upd_by_rec: u = upd_by_rec[rno] - for k in ("NAME1", "QTY1", "MAMT1", "LAMT1", "SAMT1", "TAMT1"): + for k in ( + "ITNUM", + "NAME1", + "SIZE1", + "UNIT1", + "QTY1", + "MAMT1", + "LAMT1", + "SAMT1", + "TAMT1", + ): if k in u and k in r: r[k] = str(u[k]) - # 비목별 금액 ROUNDDOWN(QTY1 * 단가) 동기화 - q = Decimal(str(fnum(r.get("QTY1")))) - m_u = Decimal(str(fnum(r.get("MAMT1")))) - l_u = Decimal(str(fnum(r.get("LAMT1")))) - s_u = Decimal(str(fnum(r.get("SAMT1")))) - m_a = int((q * m_u).quantize(Decimal(1), rounding=ROUND_DOWN)) - l_a = int((q * l_u).quantize(Decimal(1), rounding=ROUND_DOWN)) - s_a = int((q * s_u).quantize(Decimal(1), rounding=ROUND_DOWN)) - r["M_AMT1"] = str(m_a) - r["L_AMT1"] = str(l_a) - r["S_AMT1"] = str(s_a) - r["T_AMT1"] = str(m_a + l_a + s_a) + bd = (r.get("BDCODE1") or "").strip() + if bd and not STRUCT_RE.match(bd): + q = Decimal(str(fnum(r.get("QTY1")))) + m_a = int( + (q * Decimal(str(fnum(r.get("MAMT1"))))).quantize( + Decimal(1), rounding=ROUND_DOWN + ) + ) + l_a = int( + (q * Decimal(str(fnum(r.get("LAMT1"))))).quantize( + Decimal(1), rounding=ROUND_DOWN + ) + ) + s_a = int( + (q * Decimal(str(fnum(r.get("SAMT1"))))).quantize( + Decimal(1), rounding=ROUND_DOWN + ) + ) + r["M_AMT1"], r["L_AMT1"], r["S_AMT1"], r["T_AMT1"] = ( + str(m_a), + str(l_a), + str(s_a), + str(m_a + l_a + s_a), + ) files[boq_name] = build_dbf_bytes(flds, rows, meta) - # 3. PRJLST.DBF 및 PRJLST.SMT 갱신 if "PRJLST.DBF" in files and req.prjlst_updates: flds, rows, meta = parse_dbf_bytes(files["PRJLST.DBF"]) if rows: @@ -657,11 +629,9 @@ def api_save(req: SaveDbfReq): if "PRJLST.SMT" in files and req.smt_updates: files["PRJLST.SMT"] = update_prjlst_smt_bytes(files["PRJLST.SMT"], req.smt_updates) - # 4. 폴더 내 개별 DBF/SMT 저장 및 .STC 패키지 생성 written_files = [] for fname, content in files.items(): - fp = out_dir / fname - fp.write_bytes(content) + (out_dir / fname).write_bytes(content) written_files.append({"name": fname, "size": len(content)}) stc_path = WORK_DBF_ROOT / f"{out_dir.name}.STC" diff --git a/tools/stmate-test/smt_parser.py b/tools/stmate-test/smt_parser.py new file mode 100644 index 000000000..3cf85a389 --- /dev/null +++ b/tools/stmate-test/smt_parser.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +"""STMATE dBASE III/IV(DBF) · SIxMemo(COSTN.SMT / PRJLST.SMT) 바이너리 파서·빌더 및 표준 카탈로그 로더.""" + +from __future__ import annotations + +import csv +import datetime +import io +import json +import pathlib +import re +import struct + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +ANAL_DIR = REPO_ROOT / "resources" / "knowledge" / "original" / "경쟁사 프로그램" / "STmate 분석" +RAW_30_DIR = ANAL_DIR / "30_원자료" + +RE_REF = re.compile(r"&([A-Z]\d{4,5})([LMS])?&") +RE_PA = re.compile(r"&P([A-Z]\d{2}):([LMS])&") +RE_ASSIGN = re.compile(r"\b([A-Za-z]\w*)\s*(?:'[^']*')?\s*=\s*([^,\r\n'=]+)") +RE_LABEL = re.compile(r":=\s*([A-Z]\d{2})") +RE_PCT = re.compile(r"([\d.]+)\s*'%'") +RE_QTY = re.compile(r"([\d.]+)\s*'([^']{1,12})'") +SMT_KV_RE = re.compile(r"([A-Za-z0-9_]+)=([^\r\n\x00]+)") + + +def parse_dbf_bytes(b: bytes) -> tuple[list[dict], list[dict], dict]: + """dBASE III/IV 바이트를 파싱하여 (필드정의, 레코드목록, 헤더메타) 반환.""" + if len(b) < 32: + raise ValueError("DBF 헤더 길이 부족") + ver = b[0] + nrec, hlen, rlen = struct.unpack_from(" len(b): + break + rec = b[pos : pos + rlen] + deleted = rec[0:1] == b"*" + row: dict = {"_recno": i, "_deleted": deleted} + cur = 1 + for f in fields: + val_bytes = rec[cur : cur + f["len"]] + cur += f["len"] + if f["name"] == "IFORM": + row["IFORM_HEX"] = val_bytes.hex() + row[f["name"]] = val_bytes.decode("cp949", "replace").strip() + if not deleted: + rows.append(row) + + meta = {"version": ver, "hlen": hlen, "rlen": rlen, "header_tail_hex": b[off:hlen].hex()} + return fields, rows, meta + + +def build_dbf_bytes(fields: list[dict], rows: list[dict], meta: dict | None = None) -> bytes: + """(필드정의, 레코드목록)을 STMATE 호환 CP949 고정폭 DBF 바이트로 직렬화.""" + ver = (meta or {}).get("version", 0x03) + today = datetime.date.today() + nrec = len(rows) + rlen = 1 + sum(int(f["len"]) for f in fields) + tail_bytes = bytes.fromhex((meta or {}).get("header_tail_hex", "0d")) + if not tail_bytes or tail_bytes[0] != 0x0D: + tail_bytes = b"\x0d" + hlen = 32 + 32 * len(fields) + len(tail_bytes) + + hdr = bytearray(32) + hdr[0], hdr[1], hdr[2], hdr[3] = ver, today.year - 1900, today.month, today.day + struct.pack_into(" 0 else f"{int(round(num))}" + except Exception: + pass + out.write(sval.encode("ascii", "replace")[:flen].rjust(flen, b" ")) + else: + sval = "" if raw_val is None else str(raw_val) + out.write(sval.encode("cp949", "replace")[:flen].ljust(flen, b" ")) + + out.write(b"\x1a") + return out.getvalue() + + +def parse_prjlst_smt(b: bytes) -> dict[str, str]: + if len(b) <= 0x200: + return {} + return {k: v.strip() for k, v in SMT_KV_RE.findall(b[0x200:].decode("cp949", "replace"))} + + +def update_prjlst_smt_bytes(orig_b: bytes, updates: dict[str, str | int | float]) -> bytes: + """PRJLST.SMT의 SIxMemo 헤더(0x200)를 보존하며 키=값 본문을 갱신.""" + header = bytearray(orig_b[:0x200]) if len(orig_b) >= 0x200 else bytearray(b"\x00" * 0x200) + txt = orig_b[0x200:].decode("cp949", "replace") if len(orig_b) > 0x200 else "" + for k, v in updates.items(): + pat = re.compile(rf"(^|\r?\n)({re.escape(k)}=)([^\r\n\x00]*)", re.M) + txt = pat.sub(rf"\g<1>\g<2>{v}", txt) if pat.search(txt) else txt + f"\r\n{k}={v}" + return bytes(header) + txt.encode("cp949", "replace") + + +def extract_costn_memos(dbf_bytes: bytes, smt_bytes: bytes) -> dict[str, dict]: + """COSTN.DBF의 10바이트 바이너리 IFORM( len(dbf_bytes): + break + rec = dbf_bytes[rec_start : rec_start + record_len] + if rec[0:1] == b"*": + continue + code = rec[code_off : code_off + code_len].decode("cp949", errors="ignore").strip() + iform = rec[iform_off : iform_off + iform_len] + if not code or len(iform) < 10 or not any(b not in (0, 0x20) for b in iform): + continue + tag, byte_len, block_no = struct.unpack("= len(smt_bytes): + continue + raw_slice = smt_bytes[byte_start : min(len(smt_bytes), byte_start + byte_len)] + text = raw_slice.rstrip(b"\x00").decode("cp949", errors="replace") + parsed = parse_smt_formula_text(text) + memos[code] = { + "code": code, + "tag": tag, + "blockNo": block_no, + "byteLen": byte_len, + "text": text, + "vars": parsed["vars"], + "qFormula": parsed["qFormula"], + "cmFormula": parsed["cmFormula"], + "refs": parsed["refs"], + "lines": parsed["lines"], + } + return memos + + +def parse_smt_formula_text(text: str) -> dict: + """COSTN.SMT 산출근거 텍스트에서 변수(q,K,f,E,Cm,Q 등), 참조(&X00006L&), 산식행을 추출.""" + vars_map: dict[str, str] = {} + q_formula = "" + cm_formula = "" + refs: list[dict] = [] + structured_lines: list[dict] = [] + + for raw_ln in text.splitlines(): + ln = raw_ln.strip() + if not ln: + continue + + line_refs = [] + for m in RE_REF.finditer(ln): + ref_code, comp = m.group(1), m.group(2) or "" + item = {"code": ref_code, "comp": comp, "marker": m.group(0)} + refs.append(item) + line_refs.append(item) + + for m in RE_ASSIGN.finditer(ln): + var_name = m.group(1).strip() + expr = m.group(2).strip() + if not expr: + continue + vars_map[var_name] = expr + if var_name == "Q": + q_formula = expr + elif var_name in ("Cm", "Cms", "Cm1"): + cm_formula = expr + + label_m = RE_LABEL.search(ln) + pct_m = RE_PCT.search(ln) + qty_list = [{"val": float(v), "unit": u} for v, u in RE_QTY.findall(ln) if u != "%"] + + structured_lines.append( + { + "raw": raw_ln.rstrip("\r\n"), + "refs": line_refs, + "label": label_m.group(1) if label_m else "", + "pct": float(pct_m.group(1)) if pct_m else None, + "qtys": qty_list, + "isSubtotal": "(=)" in ln, + "isTotal": "(==)" in ln, + } + ) + + return { + "vars": vars_map, + "qFormula": q_formula, + "cmFormula": cm_formula, + "refs": refs, + "lines": structured_lines, + } + + +def rebuild_costn_with_smt( + dbf_bytes: bytes, smt_bytes: bytes, memo_updates: dict[str, str] +) -> tuple[bytes, bytes]: + """수정된 단가산출 텍스트(memo_updates: {CODE: text})를 COSTN.SMT에 64B 정렬로 추가하고 IFORM 갱신.""" + if not memo_updates or len(dbf_bytes) < 32: + return dbf_bytes, smt_bytes + + dbf_buf = bytearray(dbf_bytes) + num_records, header_len, record_len = struct.unpack(" len(dbf_buf): + break + code = ( + dbf_buf[rec_start + code_off : rec_start + code_off + code_len] + .decode("cp949", errors="ignore") + .strip() + ) + if code not in memo_updates: + continue + new_text = memo_updates[code] + encoded = ( + new_text.replace("\r\n", "\n").replace("\n", "\r\n").encode("cp949", errors="replace") + ) + byte_len = len(encoded) + pad_head = (-len(smt_buf)) % block_size + if pad_head: + smt_buf.extend(b"\x00" * pad_head) + block_no = len(smt_buf) // block_size + smt_buf.extend(encoded) + pad_tail = (-len(smt_buf)) % block_size + if pad_tail: + smt_buf.extend(b"\x00" * pad_tail) + pointer_bytes = struct.pack(" dict: + """30_원자료의 표준 코드(894종), 일위대가 레시피(350종), 특수 단가산출(21종) 로드.""" + global _CATALOG_CACHE + if _CATALOG_CACHE is not None: + return _CATALOG_CACHE + + codes: list[dict] = [] + csv_path = RAW_30_DIR / "stmate_code_name_list.csv" + if csv_path.exists(): + with csv_path.open("r", encoding="utf-8-sig", errors="replace") as f: + for row in csv.DictReader(f): + c = (row.get("code") or "").strip() + if c: + codes.append( + { + "code": c, + "name": (row.get("name") or "").strip(), + "size": (row.get("size") or "").strip(), + "unit": (row.get("unit") or "").strip(), + } + ) + + recipes = ( + json.loads((RAW_30_DIR / "일위대가_레시피.json").read_text(encoding="utf-8")) + if (RAW_30_DIR / "일위대가_레시피.json").exists() + else [] + ) + special_san = ( + json.loads((RAW_30_DIR / "특수_단가산출_21개.json").read_text(encoding="utf-8")) + if (RAW_30_DIR / "특수_단가산출_21개.json").exists() + else [] + ) + + _CATALOG_CACHE = { + "code_count": len(codes), + "codes": codes, + "recipe_count": len(recipes), + "recipes": recipes, + "special_san_count": len(special_san), + "special_san": special_san, + } + return _CATALOG_CACHE diff --git a/tools/stmate-test/style.css b/tools/stmate-test/style.css index f5d9f7302..b5314337a 100644 --- a/tools/stmate-test/style.css +++ b/tools/stmate-test/style.css @@ -1,419 +1,596 @@ -/* -*- coding: utf-8 -*- */ +/* STMATE w 8.0 Classic Desktop UI & Real DBGrid Layout (style.css) */ +:root { + --win-bg: #d4d8df; + --win-panel: #eceff4; + --win-surface: #ffffff; + --win-navy: #14325c; + --win-navy-light: #204982; + --win-border: #8c96a6; + --win-border-light: #ffffff; + --win-border-dark: #5a6475; + --grid-head: #dfe5ef; + --grid-line: #c5cdd9; + --row-t-bg: #e7f0fd; + --row-e-bg: #f4f6fa; + --row-c-bg: #fff4e5; + --row-sel: #cfe3ff; + --accent-blue: #0050b3; + --accent-green: #137333; + --accent-amber: #b06000; + --accent-red: #c5221f; + --text-main: #111827; + --text-sub: #4b5563; +} + * { 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; + padding: 0; + font-family: "Malgun Gothic", "굴림", Gulim, sans-serif; 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; + color: var(--text-main); + background: var(--win-bg); + height: 100vh; 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 { +.stmate-shell { display: flex; flex-direction: column; - gap: 5px; - font-size: 12.5px; - font-weight: 600; - color: #334155; + height: 100vh; + width: 100vw; } -.ctrl-item.checkbox-row { - flex-direction: row; +/* 1. TWM_STMATE_START_8X 상단 메뉴바 */ +.win-menubar { + display: flex; + align-items: center; + background: linear-gradient(90deg, #0f294d 0%, #1d4378 100%); + color: #fff; + height: 30px; + padding: 0 8px; + gap: 4px; + user-select: none; + border-bottom: 1px solid #091a33; + z-index: 100; +} + +.win-brand { + font-weight: 800; + font-size: 12px; + letter-spacing: 0.4px; + background: #ffcc00; + color: #0f294d; + padding: 2px 8px; + border-radius: 2px; + margin-right: 6px; +} + +.menu-item { + position: relative; + padding: 4px 10px; + cursor: pointer; + border-radius: 2px; + font-size: 12px; +} + +.menu-item:hover { + background: rgba(255, 255, 255, 0.16); +} + +.menu-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 270px; + background: var(--win-panel); + color: var(--text-main); + border: 1px solid var(--win-border-dark); + box-shadow: 2px 4px 10px rgba(0, 0, 0, 0.28); + padding: 4px 0; + z-index: 200; +} + +.menu-item:hover .menu-dropdown { + display: block; +} + +.menu-dropdown button, +.menu-file-label { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 6px 14px; + font-size: 12px; + font-family: inherit; + color: var(--text-main); + cursor: pointer; +} + +.menu-dropdown button:hover, +.menu-file-label:hover { + background: var(--accent-blue); + color: #fff; +} + +.menu-dropdown hr { + margin: 3px 0; + border: none; + border-top: 1px solid var(--win-border); +} + +.menubar-right { + margin-left: auto; + display: flex; align-items: center; gap: 8px; - padding-top: 20px; } -.ctrl-item.checkbox-row input { - width: auto; +.conn-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 3px; + background: #0d652d; + color: #e6f4ea; + font-weight: 700; } -.sub-toolbar { - padding: 12px 20px; - background: #f0f9ff; - border-bottom: 1px solid #bae6fd; +.top-project-select { + height: 23px; + font-size: 11.5px; + font-family: inherit; + min-width: 310px; + background: #fff; + color: #111; + border: 1px solid #999; + padding: 0 4px; +} + +/* 2. 상단 8버튼 메인 툴바 */ +.win-toolbar { display: flex; + align-items: center; + gap: 4px; + background: linear-gradient(180deg, #f5f7fa 0%, #dce1eb 100%); + padding: 4px 8px; + border-bottom: 1px solid var(--win-border); + height: 46px; +} + +.tb-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 78px; + height: 38px; + background: #eceff4; + border: 1px solid var(--win-border); + border-top-color: var(--win-border-light); + border-left-color: var(--win-border-light); + border-radius: 2px; + cursor: pointer; + padding: 2px 6px; + font-family: inherit; + color: #1f2937; +} + +.tb-btn:hover { + background: #e0ebff; + border-color: #3b82f6; +} + +.tb-btn.active { + background: #cfe3ff; + border: 1px solid #0050b3; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.15); + font-weight: 700; + color: #003a8c; +} + +.tb-ico { + font-size: 14px; + line-height: 1; +} + +.tb-txt { + font-size: 11px; + margin-top: 2px; + white-space: nowrap; +} + +.tb-sep { + width: 1px; + height: 30px; + background: var(--win-border); + margin: 0 4px; +} + +.tb-calc { + background: #fff3cd; + border-color: #d97706; + color: #92400e; + font-weight: 700; +} + +.tb-calc:hover { + background: #fde68a; +} + +/* 3. 프로젝트 컨텍스트 바 */ +.prj-context-bar { + display: flex; + align-items: center; justify-content: space-between; + background: #233858; + color: #f8fafc; + padding: 4px 12px; + font-size: 11.5px; + border-bottom: 1px solid #0f172a; + height: 28px; +} + +.prj-context-left, +.prj-context-right { + display: flex; align-items: center; gap: 12px; +} + +.prj-tag { + background: #38bdf8; + color: #0f172a; + font-weight: 800; + padding: 1px 6px; + border-radius: 2px; + font-size: 11px; +} + +/* 4. MDI 작업 영역 */ +.mdi-workspace { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 6px; + background: #c8cfda; +} + +.win-form { + display: flex; + flex-direction: column; + flex: 1; + background: var(--win-panel); + border: 1px solid var(--win-border-dark); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.win-form-header { + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(90deg, #1b3b6f 0%, #2e5c9a 100%); + color: #ffffff; + padding: 4px 10px; + font-weight: 700; + font-size: 12px; + height: 27px; +} + +.win-subbar { + display: flex; + align-items: center; flex-wrap: wrap; - font-size: 13px; - color: #0369a1; + gap: 6px; + padding: 5px 8px; + background: #e3e8f0; + border-bottom: 1px solid var(--win-border); } -.table-scroll { - overflow-x: auto; +.win-btn { + height: 24px; + padding: 0 9px; + font-size: 11.5px; + font-family: inherit; + background: #f8fafc; + border: 1px solid #7c8798; + border-radius: 2px; + cursor: pointer; + color: #111827; } -.data-table { +.win-btn:hover { + background: #dbeafe; + border-color: #2563eb; +} + +.win-btn.primary { + background: #1d4ed8; + color: #fff; + border-color: #1e40af; + font-weight: 700; +} + +.win-btn.primary:hover { + background: #1e40af; +} + +.win-btn.active { + background: #0f294d; + color: #fff; + font-weight: 700; +} + +.win-input, +.win-select { + height: 23px; + padding: 0 6px; + font-size: 11.5px; + font-family: inherit; + border: 1px solid #8c96a6; + background: #fff; +} + +/* 분할 레이아웃 */ +.split-vert { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + gap: 6px; + padding: 6px; +} + +.split-horiz { + display: flex; + flex-direction: row; + flex: 1; + overflow: hidden; + gap: 6px; + padding: 6px; +} + +.pane-box { + display: flex; + flex-direction: column; + background: var(--win-surface); + border: 1px solid var(--win-border); + overflow: hidden; +} + +.pane-title { + display: flex; + align-items: center; + justify-content: space-between; + background: #eef2f8; + padding: 4px 8px; + font-weight: 700; + font-size: 11.5px; + border-bottom: 1px solid var(--win-border); + color: #1e3a8a; +} + +.grid-scroll { + flex: 1; + overflow: auto; + background: #fff; +} + +/* STMATE DBGrid 테이블 */ +table.dbgrid { 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; +table.dbgrid th { + position: sticky; + top: 0; + background: var(--grid-head); + color: #111827; + font-weight: 700; + border: 1px solid var(--win-border); + padding: 4px 6px; text-align: center; - color: #64748b; + white-space: nowrap; + z-index: 5; } -.footer-note { - margin-top: 16px; - padding: 12px 4px; +table.dbgrid td { + border: 1px solid var(--grid-line); + padding: 3px 6px; + white-space: nowrap; +} + +table.dbgrid tr:hover td { + background: #f0f6ff; +} + +table.dbgrid tr.selected td { + background: var(--row-sel) !important; +} + +/* BOQ 계층 구조 행 스타일 (T1~T6, E1~E6, C*) */ +tr.boq-row-T td { + background: var(--row-t-bg); + font-weight: 700; + color: #0b3b8c; +} + +tr.boq-row-E td { + background: var(--row-e-bg); + font-weight: 700; + color: #1f2937; + border-top: 1.5px solid #64748b; +} + +tr.boq-row-C td { + background: var(--row-c-bg); + font-weight: 700; + color: #9a3412; +} + +tr.boq-row-excl td { + background: #fffaf0; + color: #7c2d12; +} + +.num { + text-align: right !important; + font-variant-numeric: tabular-nums; + font-family: Consolas, "Malgun Gothic", monospace; +} + +.center { + text-align: center !important; +} + +.cell-input { + width: 100%; + border: 1px solid transparent; + background: transparent; + font-family: inherit; + font-size: 11.5px; + padding: 1px 3px; +} + +.cell-input:focus { + background: #fef9c3; + border-color: #2563eb; + outline: none; +} + +/* COSTN.SMT 수식 편집기 */ +.smt-editor-area { + width: 100%; + flex: 1; + resize: none; + border: none; + padding: 8px; + font-family: Consolas, "굴림체", monospace; font-size: 12px; - color: #64748b; - text-align: center; + line-height: 1.45; + background: #1e293b; + color: #f8fafc; } -@media (max-width: 900px) { - .topbar { - flex-direction: column; - align-items: flex-start; - } - .kpi-grid { - grid-template-columns: repeat(2, 1fr); - } +/* TWM_KANJUB 16콤보 입력 그리드 */ +.kanjub-16grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px 10px; + padding: 8px; + background: #f1f5f9; + border-bottom: 1px solid var(--win-border); +} + +.kanjub-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 11px; +} + +.kanjub-field label { + font-weight: 700; + color: #334155; + white-space: nowrap; +} + +.kanjub-field select, +.kanjub-field input { + flex: 1; + min-width: 0; + height: 22px; + font-size: 11px; +} + +/* 하단 고정 원가 상태표시줄 (St_Bar) */ +.stmate-statusbar { + display: flex; + align-items: center; + justify-content: space-between; + background: #111827; + color: #e5e7eb; + height: 28px; + padding: 0 10px; + font-size: 11.5px; + border-top: 1px solid #374151; +} + +.st-metrics { + display: flex; + align-items: center; + gap: 14px; +} + +.st-kv strong { + color: #93c5fd; + margin-left: 4px; + font-family: Consolas, monospace; +} + +.st-kv.total strong { + color: #fde047; + font-size: 12.5px; +} + +/* 모달 */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; +} + +.modal-backdrop.hidden { + display: none; +} + +.modal-window { + background: var(--win-panel); + border: 2px solid var(--win-navy); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + min-width: 680px; + max-width: 92vw; + max-height: 86vh; + display: flex; + flex-direction: column; +} + +.modal-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--win-navy); + color: #fff; + padding: 5px 10px; + font-weight: 700; +} + +.modal-close-btn { + background: #dc2626; + color: #fff; + border: none; + width: 20px; + height: 20px; + cursor: pointer; + font-weight: 700; +} + +.modal-body { + padding: 12px; + overflow: auto; + flex: 1; } diff --git a/tools/stmate-test/view_dan_ilwi.js b/tools/stmate-test/view_dan_ilwi.js new file mode 100644 index 000000000..5986b85d9 --- /dev/null +++ b/tools/stmate-test/view_dan_ilwi.js @@ -0,0 +1,595 @@ +/** + * STMATE w 8.0 — 초기값(wM_Set_Sys) · 단가목록표(wM_JabPum) · 대가표/산근 편집기(wM_Edit_iLWi · wM_Edit_San · COSTN.SMT) + * 근거: 22_UI_폼_분석.md, 23_COSTN_SMT_전수파싱.md, 26_편집기와_사이클식.md, 28_변환_수량산출_식언어.md, 34_화면구조_정리.md + */ +(function (global) { + "use strict"; + + const Calc = global.STMATE_CALC; + const fmt = Calc.fmt; + const num = Calc.num; + + const KIND_LABELS = { + ALL: "모든단가", + L: "노무비(L)", + M: "재료비(M)", + S: "경비(S)", + B: "일위대가(B)", + X: "중기비(X)", + D: "단가산출(D)", + W: "견적/표준시장(W)", + }; + + /** + * COSTN.SMT 텍스트를 실시간 평가하여 Q식, Cm식, 참조(&X00006L/M/S&) 단가 및 비목별 합계 산출 + */ + function evaluateSmtFormulaText(smtText, ctx) { + const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || []; + const byCode = {}; + rawCostn.forEach((r) => { + const c = String(r.CODE || "").trim(); + if (c) byCode[c] = r; + }); + // state.resources / machines 동기화 반영 + (ctx.computed.resList || []).forEach((r) => { + if (!r.isSynthetic && byCode[r.code]) { + byCode[r.code].TAMT1 = r.appliedPrice; + if (r.kind === "노무비") byCode[r.code].LAMT1 = r.appliedPrice; + if (r.kind === "재료비") byCode[r.code].MAMT1 = r.appliedPrice; + if (r.kind === "경비") byCode[r.code].SAMT1 = r.appliedPrice; + } + }); + + const vars = {}; + const linesOut = []; + let sumL = 0, + sumM = 0, + sumS = 0; + + function evalMathWithVars(exprStr) { + let s = String(exprStr || "") + .replace(/'[^']*'/g, "") + .trim(); + if (!s) return 0; + // 변수 치환 (긴 변수명 우선) + Object.keys(vars) + .sort((a, b) => b.length - a.length) + .forEach((k) => { + const re = new RegExp("\\b" + k + "\\b", "g"); + s = s.replace(re, String(vars[k])); + }); + s = s.replace(/[^0-9.+\-*/()]/g, ""); + if (!s) return 0; + try { + const v = Function('"use strict"; return (' + s + ");")(); + return Number.isFinite(v) ? v : 0; + } catch (_e) { + return 0; + } + } + + const rawLines = String(smtText || "").split(/\r?\n/); + rawLines.forEach((rawLn, idx) => { + const ln = rawLn.trim(); + if (!ln) return; + + // 1) 변수 대입 (q=0.4 , K=0.7 , E=0.45-0.05= , f=1/1.175 , Cm=... , Q=...) + const assignRe = /\b([A-Za-z]\w*)\s*(?:'[^']*')?\s*=\s*([^,\r\n'=]+)/g; + let m; + const assignedHere = []; + while ((m = assignRe.exec(ln)) !== null) { + const vName = m[1].trim(); + const vExpr = m[2].trim(); + const val = evalMathWithVars(vExpr); + if (val > 0 || vExpr === "0") { + vars[vName] = Math.round(val * 10000) / 10000; + assignedHere.push(`${vName} = ${vars[vName]}`); + } + } + + // 2) 자원 참조 (&X00026L& / Q = , &M00080& * 0.00438'㎥' = ) + const refMatch = ln.match(/&([A-Z]\d{4,5})([LMS])?&(.*)/); + let refInfo = null; + if (refMatch) { + const refCode = refMatch[1]; + const compTag = refMatch[2] || refCode[0]; + const tailExpr = refMatch[3] || ""; + const targetRow = byCode[refCode] || {}; + let baseUnitPrice = num(targetRow.TAMT1); + if (compTag === "L") baseUnitPrice = num(targetRow.LAMT1) || baseUnitPrice; + else if (compTag === "M") baseUnitPrice = num(targetRow.MAMT1) || baseUnitPrice; + else if (compTag === "S") baseUnitPrice = num(targetRow.SAMT1) || baseUnitPrice; + + const cleanTail = tailExpr.split("=")[0].split(":=")[0]; + const fullExpr = String(baseUnitPrice) + " " + cleanTail; + const lineAmt = evalMathWithVars(fullExpr); + const roundedAmt = Math.floor(lineAmt * 10) / 10; + + if (compTag === "L" || refCode.startsWith("L")) sumL += roundedAmt; + else if (compTag === "S" || refCode.startsWith("S")) sumS += roundedAmt; + else sumM += roundedAmt; + + refInfo = { + refCode, + compTag, + refName: targetRow.NAME || refCode, + baseUnitPrice, + calcAmt: roundedAmt, + }; + } + + linesOut.push({ + lineNo: idx + 1, + raw: rawLn, + assigned: assignedHere.join(", "), + refInfo, + }); + }); + + return { + vars, + linesOut, + sumM: Math.floor(sumM), + sumL: Math.floor(sumL), + sumS: Math.floor(sumS), + sumT: Math.floor(sumM) + Math.floor(sumL) + Math.floor(sumS), + }; + } + + /** + * 1. wM_Set_Sys (초기값 입력 및 수정 화면) + */ + function renderWMSetSys(ctx) { + const sys = ctx.state.sysConfig; + const prj = (ctx.dbfData && ctx.dbfData.prjlst) || {}; + const evalRx = Calc.evalLeftToRight(sys.rxFormula); + + return ` +
+
+ ⚙️ 초기값 입력 및 수정 [wM_Set_Sys · PRJLST.DBF 초기값 변수 42종] + 노임환산식 좌→우 평가: ${evalRx.toFixed(6)} +
+
+ + 변경된 초기값은 기초단가·중기사용료·일위대가·내역서에 즉시 전파됩니다. +
+ +
+
+
1. 환율 및 소수 자리수 설정 (EXLNM1/2 · KCD · ACD · ZCD · JCD · ECD · BPN)
+
+
+
+
+
+
+
+
+
+ +
+
2. 노임 시간당 환산식 (RXNM_ · 좌→우 순차 평가 18번 §2.1) 및 특수노임 조건코드 (RX1~5CD)
+
+
+
+
+

+ ※ STMATE 고유 파서 검증: 1/8*16/12*25/20 → 좌→우 순차 평가 0.20833333 (기계운전원 일급 267,360원 × 0.20833333 = 55,700원/hr 정확 재현) +

+
+ +
+
3. 현재 프로젝트 PRJLST.DBF 원본 초기값 필드
+
+
+
+
+
+
+
+
+
+ `; + } + + /** + * 2. wM_JabPum (단가목록표 화면 — COSTN.DBF 100% 전수 조회·편집) + */ + function renderWMJabPum(ctx) { + const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || []; + const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {}; + const filterKind = ctx.uiState.danFilter || "ALL"; + const kw = (ctx.uiState.danSearch || "").trim().toLowerCase(); + + // state.resources 수정값 동기화 + const resMap = {}; + (ctx.computed.resList || []).forEach((r) => { + if (!r.isSynthetic) resMap[r.code] = r; + }); + + const counts = { ALL: rawCostn.length, L: 0, M: 0, S: 0, B: 0, X: 0, D: 0, W: 0 }; + rawCostn.forEach((r) => { + const p = String(r.CODE || " ")[0].toUpperCase(); + if (counts[p] !== undefined) counts[p]++; + }); + + const filtered = rawCostn.filter((r) => { + const code = String(r.CODE || "").trim(); + if (!code) return false; + const p = code[0].toUpperCase(); + if (filterKind !== "ALL" && p !== filterKind) return false; + if (kw) { + const memoTxt = memos[code] ? memos[code].text : ""; + const hay = + `${code} ${r.NAME || ""} ${r.SIZE || ""} ${r.UNIT || ""} ${memoTxt}`.toLowerCase(); + if (!hay.includes(kw)) return false; + } + return true; + }); + + return ` +
+
+ 📋 단가목록표 [wM_JabPum · COSTN.DBF 전수 원장] — 총 ${rawCostn.length}건 중 ${filtered.length}건 표시 + 5개 가격지 후보(AMT_11~51) 및 적용단가(TAMT1 = MAMT1 + LAMT1 + SAMT1) +
+ +
+ ${Object.keys(KIND_LABELS) + .map( + (k) => ` + + `, + ) + .join("")} + + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + ${filtered + .map((r, i) => { + const code = String(r.CODE || "").trim(); + const p = code[0].toUpperCase(); + const res = resMap[code]; + const m1 = res + ? res.kind === "재료비" + ? res.appliedPrice + : 0 + : num(r.MAMT1); + const l1 = res + ? res.kind === "노무비" + ? res.appliedPrice + : 0 + : num(r.LAMT1); + const s1 = res ? (res.kind === "경비" ? res.appliedPrice : 0) : num(r.SAMT1); + const t1 = m1 + l1 + s1 || num(r.TAMT1); + const hasMemo = Boolean(memos[code]); + return ` + + + + + + + + + + + + + + + + + `; + }) + .join("")} + +
No코드(CODE)구분명칭 (NAME)규격 (SIZE)단위재료단가(MAMT1)노무단가(LAMT1)경비단가(SAMT1)합계단가(TAMT1)1단가(조달)2단가(물가)3단가(거래)산근/대가
${i + 1}${code}${p}${r.NAME || ""}${r.SIZE || ""}${r.UNIT || ""} + + + + + + ${fmt(t1)}${num(r.AMT_11) ? fmt(r.AMT_11) : "-"}${num(r.AMT_21) ? fmt(r.AMT_21) : "-"}${num(r.AMT_31) ? fmt(r.AMT_31) : "-"} + ${ + hasMemo || p === "B" || p === "D" || p === "X" + ? `` + : "-" + } +
+
+
+
+
+ `; + } + + /** + * 3. wM_Edit_iLWi / wM_Edit_San / wM_Edit_EQ (일위대가 · 단가산출근거 · 중기사용료 통합 편집 화면) + * - 좌측: COSTN.DBF 내 B(일위대가), D(단가산출), X(중기) 전체 목록 + COSTN.SMT 보유 여부 + * - 우측 상단: 선택 항목의 COSTN.SMT 원문 편집기 (`&X00006L&`, `Q=3600*q*K*f*E/Cm`, `:=A01`) + * - 우측 하단: 실시간 파싱·변수 평가 결과 테이블 및 [단가에 반영(F9)] + */ + function renderWMEditIlwi(ctx) { + const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || []; + const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {}; + const subFilter = ctx.uiState.ilwiSubFilter || "ALL_BDX"; + + const bdxRows = rawCostn.filter((r) => { + const c = String(r.CODE || "").trim(); + if (!c) return false; + const p = c[0].toUpperCase(); + if (subFilter === "ALL_BDX") return p === "B" || p === "D" || p === "X" || Boolean(memos[c]); + if (subFilter === "SMT_ONLY") return Boolean(memos[c]); + return p === subFilter; + }); + + const selCode = + ctx.uiState.selectedIlwiCode && + bdxRows.some((r) => String(r.CODE).trim() === ctx.uiState.selectedIlwiCode) + ? ctx.uiState.selectedIlwiCode + : bdxRows[0] + ? String(bdxRows[0].CODE).trim() + : ""; + + const selRow = bdxRows.find((r) => String(r.CODE).trim() === selCode) || {}; + const memoObj = memos[selCode] || null; + const defaultText = memoObj + ? memoObj.text + : `'▣ ${selRow.NAME || selCode} (${selRow.SIZE || ""})'\r\n'1. 재료비 : ${fmt(num(selRow.MAMT1))}원'\r\n'2. 노무비 : ${fmt(num(selRow.LAMT1))}원'\r\n'3. 경 비 : ${fmt(num(selRow.SAMT1))}원'\r\n(==)`; + const currentSmtText = + ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[selCode] !== undefined + ? ctx.uiState.editedSmtMap[selCode] + : defaultText; + + const evalRes = evaluateSmtFormulaText(currentSmtText, ctx); + + return ` +
+
+ 🧮 일위대가 · 단가산출근거 · 중기사용료 편집기 [wM_Edit_iLWi / wM_Edit_San / COSTN.SMT] + 선택 호표: ${selCode} ${selRow.NAME || ""} (SIxMemo 블록 #${memoObj ? memoObj.blockNo : "-"}, ${memoObj ? memoObj.byteLen : 0} bytes) +
+ +
+ + + + + + + +
+ +
+ +
+
+ 호표 마스터 목록 (클릭 시 우측 COSTN.SMT 전개식 로드) + 총 ${bdxRows.length}건 +
+
+ + + + + + + + + + + + + ${bdxRows + .map((r) => { + const c = String(r.CODE || "").trim(); + const isSel = c === selCode; + const hasM = Boolean(memos[c]); + return ` + + + + + + + + + `; + }) + .join("")} + +
코드품명 (NAME)규격단위합계단가SMT
${c}${r.NAME || ""}${r.SIZE || ""}${r.UNIT || ""}${fmt(num(r.TAMT1))}${hasM ? "📝" : "-"}
+
+
+ + +
+
+
+ 📝 COSTN.SMT 전개 텍스트 편집기 — &X00026L& 참조 · Q=3600*q*K*f*E/Cm · :=A01 문법 실시간 해석 + DB 저장단가: 재 ${fmt(num(selRow.MAMT1))} / 노 ${fmt(num(selRow.LAMT1))} / 경 ${fmt(num(selRow.SAMT1))} / 계 ${fmt(num(selRow.TAMT1))}원 +
+ +
+ +
+
+ 🔍 SIxMemo 수식 파서 실시간 평가 결과 (변수: ${ + Object.keys(evalRes.vars) + .map((k) => `${k}=${evalRes.vars[k]}`) + .join(", ") || "없음" + }) + 평가 합계: 재 ${fmt(evalRes.sumM)} + 노 ${fmt(evalRes.sumL)} + 경 ${fmt(evalRes.sumS)} = ${fmt(evalRes.sumT)}원 +
+
+ + + + + + + + + + + + + ${evalRes.linesOut + .map( + (ln) => ` + + + + + + + + + `, + ) + .join("")} + +
행산식 원문 (COSTN.SMT Line)변수 대입/평가자원참조(&코드&)참조단가산출금액
${ln.lineNo}${ln.raw}${ln.assigned || ""}${ln.refInfo ? `${ln.refInfo.refCode}[${ln.refInfo.compTag}] (${ln.refInfo.refName})` : ""}${ln.refInfo ? fmt(ln.refInfo.baseUnitPrice) : ""}${ln.refInfo ? fmt(ln.refInfo.calcAmt, 1) : ""}
+
+
+
+
+
+ `; + } + + /** + * 4. 단가/카탈로그 모달 생성기 (wM_Boxa / wM_Edt_Hal / 표준카탈로그 894코드·350레시피·21특수산식) + */ + function buildDanModalHtml(modalKind, ctx) { + if (modalKind === "wM_Boxa") { + return ` +
+

[wM_Boxa] 5개 가격지 단가 비교·일괄 채택 — 자재 후보단가(AMT_11~51) 중 지정 슬롯 또는 최저가를 적용단가(AMT_61/MAMT1)로 일괄 채택합니다.

+
+
+ +
+
+
+
+ `; + } + if (modalKind === "wM_Edt_Hal") { + return ` +
+

[wM_Edt_Hal] 기준단가 비율 일괄 수정 — 적용단가 = 기준단가 × 비율(%) (적용단가 0원 및 보호항목 UC=T 제외).

+
+
+
+
+
+
+
+ `; + } + // 표준 카탈로그 (894 코드 · 350 일위대가 레시피 · 21 특수 단가산출) + const cat = ctx.catalogData || { codes: [], recipes: [], special_san: [] }; + return ` +
+

+ [STMATE 표준 카탈로그 브라우저] — 30_원자료에서 추출한 표준코드 ${cat.codes.length}종 · 일위대가 레시피 ${cat.recipes.length}종 · 특수 단가산출 ${cat.special_san.length}종 +

+
+
+
특수 단가산출 21개 (Q식/사이클식 원문)
+
+ + + + ${(cat.special_san || []) + .map( + (s, idx) => ` + + + + + + + + `, + ) + .join("")} + +
호표명칭단위Q식편집기로 복사
#${s["호표"]}${s["명칭"]}${s["단위"]}${(s["인자"] && s["인자"].Q) || ""}
+
+
+
+
표준 일위대가 레시피 (${cat.recipes.length}종 중 상위 60종)
+
+ + + + ${(cat.recipes || []) + .slice(0, 60) + .map( + (r) => ` + + + + + + + + `, + ) + .join("")} + +
구분명칭규격단위구성수
${r["종류"]}${r["명칭"]}${r["규격"] || ""}${r["단위"] || ""}${(r["구성"] || []).length}행
+
+
+
+
+ `; + } + + global.STMATE_VIEW_DAN_ILWI = { + evaluateSmtFormulaText, + renderWMSetSys, + renderWMJabPum, + renderWMEditIlwi, + buildDanModalHtml, + }; +})(window); diff --git a/tools/stmate-test/view_kanjub_prt.js b/tools/stmate-test/view_kanjub_prt.js new file mode 100644 index 000000000..96f1b0407 --- /dev/null +++ b/tools/stmate-test/view_kanjub_prt.js @@ -0,0 +1,630 @@ +/** + * STMATE w 8.0 — 제잡비 계산(wM_KanJub) · 원가계산기준 제비율표(wM_KAN_RATE) · 계약/실행/기성(wM_Mk_Cont · wM_KanJub_K) · 보고서 인쇄/변환(PM_Prt 30종) + * 근거: 30_원자료/TWM_KANJUB.json, TWM_KAN_RATE.json, 22번 §5~6, 27번 계약·실행·기성, 28번 XML/JDL, 34번 30종 출력 시트 + */ +(function (global) { + "use strict"; + + const Calc = global.STMATE_CALC; + const fmt = Calc.fmt; + const num = Calc.num; + + /** + * 1. TWM_KANJUB (wM_KanJub 제잡비 계산 및 원가계산서 화면) + * - 상단 바: 설계차수(원설계/1~3차변경), 방식(원가계산/표준시장), 간접비 절사단위, 기준선택(JB_R), 요율수정, 결과보기 + * - 중단 Pn0_Input: 16개 제잡비 설정 콤보/입력칸 + * - 하단 Pg_Ctrl 4탭: 일반형식(T) | 수공형식(S) | 실적일반(TA) | 실적수공(SA) 원가계산서 그리드 + */ + function renderWMKanJub(ctx) { + const kc = ctx.state.kanjubConfig; + const kj = ctx.computed.kanjubComputed; + const smt = (ctx.dbfData && ctx.dbfData.prjlst_smt) || {}; + const jbrFiles = (ctx.sourcesData && ctx.sourcesData.jbr_files) || []; + + return ` +
+
+ 🏛️ 제잡비 계산 및 원가계산서 [TWM_KANJUB · PRJLST.SMT ${Object.keys(smt).length}개 키 연동] + 직접공사비: ${fmt(kj.DIRECT_COST)}원 → 도급액: ${fmt(kj.FINAL_SUPPLY)}원 + 관급자재: ${fmt(kj.GOV_MAT)}원 = 총공사비: ${fmt(kj.GRAND_TOTAL)}원 +
+ + +
+ + + + + + + + + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ 원가계산서 양식 탭 (Pg_Ctrl): + ${[ + { id: "T", label: "탭1: 일반형식 (T · 재+노+경)" }, + { id: "S", label: "탭2: 수공형식 (S · 경비 16세부비목)" }, + { id: "TA", label: "탭3: 실적일반 (TA · 표준시장단가 병행)" }, + { id: "SA", label: "탭4: 실적수공 (SA · 실적+수공)" }, + ] + .map( + (t) => ` + + `, + ) + .join("")} + + 이윤 끝전조정: ${fmt(kj.PROFIT_ADJ)}원 (초기 이윤 ${fmt(kj.IYUN_INIT)}원 → 최종 이윤 ${fmt(kj.IYUN)}원) + +
+ +
+
+
+ + + + + + + + + + + + + + ${kj.lines + .map((ln, idx) => { + const isRateEditable = kc.rates[ln.key] !== undefined; + const isStrong = + ln.key === "DIRECT_COST" || + ln.key === "TOTAL_COST" || + ln.key === "FINAL_SUPPLY" || + ln.key === "GRAND_TOTAL"; + return ` + + + + + + + + + + `; + }) + .join("")} + +
순번비목코드원가계산서 항목명산정기초 공식 및 밑수 설명 (REA 03·04·16번 문서 검증)산정기초 금액(원)적용요율(%)산출금액(원)
${idx + 1}${ln.key}${ln.label}${ln.formula}${ln.base !== null && ln.base !== undefined ? fmt(ln.base) : "-"} + ${ + isRateEditable + ? `` + : ln.rate !== null && ln.rate !== undefined + ? fmt(ln.rate, 3) + "%" + : "-" + } + ${fmt(ln.amount)}
+
+
+
+
+ `; + } + + /** + * 2. TWM_KAN_RATE (wM_KAN_RATE 원가계산기준 제비율표 4탭 편집 화면) + * - Tab_Rn: 일반 제잡비율 (RATE_R.DBF 50행 × 35열) + * - Tab_RA: 실적공사 제잡비율 (RATE_RA.DBF 50행 × 20열) + * - Tab_M: 일반관리비율 (RATE_M.DBF 25행 × 10열) + * - Tab_B: 보증수수료율 (RATE.DBF / RATE_B) + */ + function renderWMKanRate(ctx) { + const rateTables = (ctx.dbfData && ctx.dbfData.rate_tables) || {}; + const activeTab = ctx.uiState.kanRateTab || "RATE_R.DBF"; + const rows = rateTables[activeTab] || []; + const colKeys = rows[0] ? Object.keys(rows[0]).filter((k) => !k.startsWith("_")) : []; + const jbrFiles = (ctx.sourcesData && ctx.sourcesData.jbr_files) || []; + + const ROW_MEANINGS = { + 0: "공사규모 기준액 구간 헤더", + 6: "간접노무비(1~5열) · 기타경비(6~10열) — 5천만 미만", + 7: "간접노무비 · 기타경비 — 5천만~3억 미만", + 8: "간접노무비 · 기타경비 — 3억~10억 미만", + 9: "간접노무비 · 기타경비 — 10억~30억 미만 (중규모)", + 20: "선택 적용 행: 간접노무(1) · 기타경비(6) · 일반관리비(11) · 이윤(16) · 산안비(17) · 기초액(18)", + 45: "산재보험료율(1열) · 고용보험료율(17~20열 등급별)", + 46: "건강보험(1열) · 국민연금(6열) · 노인장기요양(11열)", + }; + + return ` +
+
+ 📊 원가계산기준 제비율표 조회·수정 [TWM_KAN_RATE · ${activeTab} (${rows.length}행 × ${colKeys.length}열)] + 행20(선택적용) · 행45(산재/고용) · 행46(건보/연금/요양) 좌표 매핑 검증 완료 +
+ +
+ ${[ + { id: "RATE_R.DBF", label: "탭1(Tab_Rn): 일반 제잡비율 (RATE_R 50×35)" }, + { id: "RATE_RA.DBF", label: "탭2(Tab_RA): 실적공사 제잡비율 (RATE_RA 50×20)" }, + { id: "RATE_M.DBF", label: "탭3(Tab_M): 일반관리비율 (RATE_M 25×10)" }, + { id: "RATE.DBF", label: "탭4(Tab_B): 보증/기타요율 (RATE)" }, + ] + .map( + (t) => ` + + `, + ) + .join("")} + + +
+ +
+
+
+ + + + + + ${colKeys.map((ck) => ``).join("")} + + + + ${rows + .map((r, rIdx) => { + const highlight = ROW_MEANINGS[rIdx] ? "boq-row-T" : ""; + return ` + + + + ${colKeys.map((ck) => ``).join("")} + + `; + }) + .join("")} + +
행(0~)STMATE 행 좌표 의미${ck}
#${rIdx}${ROW_MEANINGS[rIdx] || `구간/공사종류 데이터 행 #${rIdx}`}${r[ck]}
+
+
+
+
+ `; + } + + /** + * 3. 계약 / 실행예산 / 기성 / 설계변경 단계 통합 화면 (wM_Mk_Cont · wM_Boq_ExecX · wM_KanJub_K · 설계변경) + * 근거: 27_계약_실행_기성_단계.md + */ + function renderWStage(ctx) { + const stageSub = ctx.uiState.stageSubTab || "CONTRACT"; + const bidRateM = num(ctx.uiState.bidRateM || 87.745); + const bidRateL = num(ctx.uiState.bidRateL || 87.745); + const bidRateS = num(ctx.uiState.bidRateS || 87.745); + const progPct = num(ctx.uiState.progPct || 65.0); + const prevPct = num(ctx.uiState.prevPct || 30.0); + + const comp = ctx.computed; + const leafRows = comp.boqComputed.rows || []; + const revComp = comp.revComputed; + + return ` +
+
+ 🔄 설계 후속 단계 관리 [27번 문서 — 계약내역(wM_Mk_Cont) · 실행예산(wM_Boq_ExecX) · 기성내역(wM_KanJub_K) · 설계변경 대비] + K_ORD 단계 전이: 설계(0) → 계약(1) → 변경(2) → 실행(3) → 기성(4) +
+ +
+ + + + +
+ + ${ + stageSub === "CONTRACT" + ? ` +
+
+
+
+
+
+
+
+
+ 계약내역서 (설계단가 × 낙찰율 → 계약단가 및 계약금액 산출 · 적용제외 공정 보호 지원) + 설계 직공비: ${fmt(comp.boqComputed.totals.directT)}원 → 계약 직공비: ${fmt(Math.floor((comp.boqComputed.totals.directT * bidRateM) / 100))}원 +
+
+ + + + + + ${leafRows + .map((r) => { + const cM = Math.floor((r.mUnit * bidRateM) / 100); + const cL = Math.floor((r.lUnit * bidRateL) / 100); + const cS = Math.floor((r.sUnit * bidRateS) / 100); + const cT = cM + cL + cS; + const cAmt = Math.floor(r.qty * cT); + return ` + + + + + + + + + + + + `; + }) + .join("")} + +
코드공종명규격단위수량설계단가설계금액계약단가(낙찰적용)계약금액
${r.code}${r.name}${r.spec}${r.unit}${fmt(r.qty, 2)}${fmt(r.tUnit)}${fmt(r.tAmt)}${fmt(cT)}${fmt(cAmt)}
+
+
+
` + : "" + } + + ${ + stageSub === "EXEC" + ? ` +
+
+
[wM_Boq_ExecX] 실행예산 내역서 (계약금액 대비 현장 실행예산율 92.0% 기준 비교)
+
+ + + + ${leafRows + .map((r) => { + const exUnit = Math.floor(r.tUnit * 0.92); + const exAmt = Math.floor(r.qty * exUnit); + return ` + + + + + + + + + + `; + }) + .join("")} + +
코드공종명수량설계금액실행단가(92%)실행예산금액절감액(차액)
${r.code}${r.name}${fmt(r.qty, 2)}${fmt(r.tAmt)}${fmt(exUnit)}${fmt(exAmt)}${fmt(r.tAmt - exAmt)}
+
+
+
` + : "" + } + + ${ + stageSub === "GISUNG" + ? ` +
+
+
+
+
+
+
+
+ [wM_KanJub_K] 기성내역 및 기성 제잡비 계산 (금회직공비 × 계약제잡비율 · 계약 | 전회 | 금회 | 누계 | 잔여) + 금회 기성율: ${(progPct - prevPct).toFixed(1)}% | 누계 기성도급액: ${fmt(Math.floor((comp.kanjubComputed.FINAL_SUPPLY * progPct) / 100))}원 +
+
+ + + + + + ${leafRows + .map((r) => { + const prevA = Math.floor((r.tAmt * prevPct) / 100); + const cumA = Math.floor((r.tAmt * progPct) / 100); + const curA = cumA - prevA; + const curQ = (r.qty * (progPct - prevPct)) / 100; + return ` + + + + + + + + + + + + `; + }) + .join("")} + +
코드공종명계약수량계약금액전회기성액(${prevPct}%)금회기성수량금회기성액(${(progPct - prevPct).toFixed(1)}%)누계기성액(${progPct}%)잔여금액
${r.code}${r.name}${fmt(r.qty, 2)}${fmt(r.tAmt)}${fmt(prevA)}${fmt(curQ, 2)}${fmt(curA)}${fmt(cumA)}${fmt(r.tAmt - cumA)}
+
+
+
` + : "" + } + + ${ + stageSub === "REVISION" + ? ` +
+
+
+ 설계변경 원가계산 대비표 (당초 도급액: ${fmt(comp.kanjubComputed.FINAL_SUPPLY)}원 vs 변경 도급액: ${fmt(revComp.revKj.FINAL_SUPPLY)}원 · 사후정산 건보/요양/연금 당초고정 지원) +
+
+ + + + + + ${leafRows + .map( + (r) => ` + + + + + + + + + + + `, + ) + .join("")} + +
코드공종명당초수량(QTY1)변경수량(QTY2)단가당초금액변경금액증감액(차액)
${r.code}${r.name}${fmt(r.qty, 2)}${fmt(r.tUnit)}${fmt(r.tAmt)}${fmt(r.changedTAmt)}${fmt(r.diffTAmt)}
+
+
+
` + : "" + } +
+ `; + } + + /** + * 4. PM_Prt (ST_Prt.CPM 30종 보고서 인쇄 미리보기 · SET_REP.DBF 75종 양식 · 엑셀/조달청 XML/JDL 내보내기) + */ + function renderPMPrt(ctx) { + const activeRep = ctx.uiState.activeReportSheet || "0608"; + const setRepRows = ((ctx.dbfData && ctx.dbfData.set_rep_rows) || []).filter( + (r) => r.NAME || r.HEADTITLE, + ); + const kj = ctx.computed.kanjubComputed; + const boqRows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); + const costnRows = (ctx.dbfData && ctx.dbfData.costn_rows) || []; + const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {}; + + return ` +
+
+ 🖨️ 보고서 인쇄 및 엑셀/조달청 XML/JDL 변환 [PM_Prt · ST_Prt.CPM 30종 시트 · SET_REP.DBF ${setRepRows.length}종 양식] + 인쇄 옵션: 굴림체 · 비고=코드/호표 · 수량/단위 표준 · 일반양식 +
+ +
+ + + + + + +
+ +
+
+

+ ${ + activeRep === "0608" + ? "공 사 원 가 계 산 서" + : activeRep === "0603" + ? "설 계 내 역 서" + : activeRep === "0607" + ? "관 급 자 재 내 역 서" + : activeRep === "0302" + ? "일 위 대 가 표 (목 록)" + : activeRep === "0402" + ? "단 가 산 출 근 거" + : activeRep === "0203" + ? "시 간 당 중 기 사 용 료" + : "자 재 · 노 무 · 경 비 단 가 목 록 표" + } +

+

+ 공사명: ${ctx.state.projectMeta.pname} (총공사비: ${fmt(kj.GRAND_TOTAL)}원 / 도급액: ${fmt(kj.FINAL_SUPPLY)}원) +

+ + ${ + activeRep === "0608" + ? ` + + + ${kj.lines + .map( + (ln) => ` + + + + + + + `, + ) + .join("")} + +
비목산정기초(원)요율(%)금액(원)산출근거
${ln.label}${ln.base ? fmt(ln.base) : ""}${ln.rate !== null && ln.rate !== undefined ? fmt(ln.rate, 3) + "%" : ""}${fmt(ln.amount)}${ln.formula}
` + : "" + } + + ${ + activeRep === "0603" || activeRep === "0607" + ? ` + + + ${boqRows + .filter((r) => (activeRep === "0607" ? r._isExcl : true)) + .map( + (r) => ` + + + + + + + + + + + + + `, + ) + .join("")} + +
공종명칭규격단위수량재료비단가노무비단가경비단가합계단가합계금액비고
${r.ITNUM || ""}${r.NAME1 || ""}${r.SIZE1 || ""}${r.UNIT1 || ""}${r.QTY1 ? fmt(r.QTY1, 2) : ""}${r.MAMT1 ? fmt(r.MAMT1) : ""}${r.LAMT1 ? fmt(r.LAMT1) : ""}${r.SAMT1 ? fmt(r.SAMT1) : ""}${r.TAMT1 ? fmt(r.TAMT1) : ""}${r.T_AMT1 ? fmt(r.T_AMT1) : ""}${r.BDCODE1 || ""}
` + : "" + } + + ${ + activeRep === "0402" + ? Object.values(memos) + .map( + (m) => ` +
+
[${m.code}] ${(costnRows.find((x) => x.CODE === m.code) || {}).NAME || ""}
+
${m.text}
+
`, + ) + .join("") + : "" + } + + ${ + activeRep === "0302" || activeRep === "0203" || activeRep === "0101" + ? ` + + + ${costnRows + .filter((r) => { + const p = String(r.CODE || " ")[0].toUpperCase(); + if (activeRep === "0302") return p === "B" || p === "D"; + if (activeRep === "0203") return p === "X"; + return p === "L" || p === "M" || p === "S"; + }) + .map( + (r) => ` + + + + + + + + + + `, + ) + .join("")} + +
코드품명규격단위재료비노무비경비합계단가
${r.CODE}${r.NAME || ""}${r.SIZE || ""}${r.UNIT || ""}${fmt(num(r.MAMT1))}${fmt(num(r.LAMT1))}${fmt(num(r.SAMT1))}${fmt(num(r.TAMT1))}
` + : "" + } + + ${ + activeRep === "SET_REP_ALL" + ? ` + + + ${setRepRows + .map( + (r) => ` + + + + + + + + + + `, + ) + .join("")} + +
양식코드(INDX)보고서명(NAME)인쇄 표제(HEADTITLE)컬럼수(FLDCNT)필드1필드2필드3필드4
${r.INDX}${r.NAME}${r.HEADTITLE}${r.FLDCNT}${r.FLD_N1 || ""}${r.FLD_N2 || ""}${r.FLD_N3 || ""}${r.FLD_N4 || ""}
` + : "" + } +
+
+
+ `; + } + + global.STMATE_VIEW_KANJUB_PRT = { + renderWMKanJub, + renderWMKanRate, + renderWStage, + renderPMPrt, + }; +})(window); diff --git a/tools/stmate-test/view_prj_boq.js b/tools/stmate-test/view_prj_boq.js new file mode 100644 index 000000000..ce8f1a625 --- /dev/null +++ b/tools/stmate-test/view_prj_boq.js @@ -0,0 +1,681 @@ +/** + * STMATE w 8.0 — 공사목록표(wPrjList) · 계층 설계내역서(wBoq) · 내역도구 모달(wITNum/wBoqRate/wM_Rate/wExpnd/wQtyList) + * 근거: REA 09_보조분석 resource-TWPRJLIST.dfm.json, 16번 §4.2, 22번 §4, 28번 §2.2, 34번 §2.1 + */ +(function (global) { + "use strict"; + + const Calc = global.STMATE_CALC; + const fmt = Calc.fmt; + const num = Calc.num; + + const PCLASS_NAMES = { + 0: "일반토목/산림토목", + 1: "건축공사", + 2: "조경공사", + 3: "산업설비", + 4: "전기/통신", + 7: "전문/설계변경", + }; + + const KORD_BADGES = { + 0: "설계내역(0)", + 1: "계약내역(1)", + 2: "1차변경(2)", + 3: "실행예산(3)", + 4: "기성내역(4)", + }; + + function isStructCode(bd) { + return /^(T\d|E\d|C\d|:)$/.test(String(bd || "").trim()); + } + + /** + * BOQ11~14.DBF 전수 행(T1~T6 제목, E1~E6 소계/계/총계/총합계, C* 제외금액, 말단 공종행)을 + * 실시간 단가·수량과 동기화하여 원단위까지 재집계 + */ + function evaluateFullBoqRows(ctx) { + const rawRows = (ctx.dbfData && ctx.dbfData.boq_rows) || []; + const compRes = ctx.computed; + const leafByRecno = {}; + const leafByCode = {}; + (compRes.boqComputed.rows || []).forEach((r) => { + if (r._recno !== undefined) leafByRecno[r._recno] = r; + if (r.code) leafByCode[r.code] = r; + }); + + // C* 제외구역 판별 (ENDCODE == SORTCODE) + const exclSet = new Set(); + rawRows.forEach((r, idx) => { + const bd = String(r.BDCODE1 || "").trim(); + if (bd.startsWith("C")) { + const endCode = r.ENDCODE; + const startIdx = rawRows.findIndex((q) => q.SORTCODE === endCode); + if (startIdx >= 0) { + for (let k = Math.min(startIdx, idx); k <= Math.max(startIdx, idx); k++) { + exclSet.add(k); + } + } + } + }); + + // 1차: 말단 행 단가·금액 동기화 + const rows = rawRows.map((r, idx) => { + const bd = String(r.BDCODE1 || "").trim(); + const recno = r._recno !== undefined ? r._recno : idx; + const leaf = leafByRecno[recno] || leafByCode[bd]; + const isStruct = isStructCode(bd) || !bd; + + if (!isStruct && leaf) { + return { + ...r, + _idx: idx, + _recno: recno, + _rowClass: exclSet.has(idx) ? "boq-row-excl" : "boq-row-leaf", + _isExcl: exclSet.has(idx), + QTY1: leaf.qty, + MAMT1: leaf.mUnit, + LAMT1: leaf.lUnit, + SAMT1: leaf.sUnit, + TAMT1: leaf.tUnit, + M_AMT1: leaf.mAmt, + L_AMT1: leaf.lAmt, + S_AMT1: leaf.sAmt, + T_AMT1: leaf.tAmt, + }; + } + let rowClass = ""; + if (bd.startsWith("T")) rowClass = "boq-row-T"; + else if (bd.startsWith("E")) rowClass = "boq-row-E"; + else if (bd.startsWith("C")) rowClass = "boq-row-C"; + + return { + ...r, + _idx: idx, + _recno: recno, + _rowClass: rowClass, + _isExcl: exclSet.has(idx), + QTY1: num(r.QTY1), + MAMT1: num(r.MAMT1), + LAMT1: num(r.LAMT1), + SAMT1: num(r.SAMT1), + TAMT1: num(r.TAMT1), + M_AMT1: num(r.M_AMT1), + L_AMT1: num(r.L_AMT1), + S_AMT1: num(r.S_AMT1), + T_AMT1: num(r.T_AMT1), + }; + }); + + // 2차: T*(공종제목) 및 E*(소계/계/총계/총합계), C*(제외금액 총계) 구간 합산 동기화 + rows.forEach((r, idx) => { + const bd = String(r.BDCODE1 || "").trim(); + if (!bd.startsWith("T") && !bd.startsWith("E") && !bd.startsWith("C")) return; + const endCode = r.ENDCODE; + const partnerIdx = rows.findIndex((q, j) => j !== idx && q.SORTCODE === endCode); + if (partnerIdx < 0 && bd !== "E5" && bd !== "T5") return; + + const sIdx = partnerIdx >= 0 ? Math.min(idx, partnerIdx) : 0; + const eIdx = partnerIdx >= 0 ? Math.max(idx, partnerIdx) : rows.length - 1; + const isExclSummary = bd.startsWith("C") || exclSet.has(idx); + + let sumM = 0, + sumL = 0, + sumS = 0, + sumT = 0; + for (let k = sIdx; k <= eIdx; k++) { + const sub = rows[k]; + const sbd = String(sub.BDCODE1 || "").trim(); + if (!sbd || isStructCode(sbd)) continue; + if (!isExclSummary && sub._isExcl) continue; + sumM += num(sub.M_AMT1); + sumL += num(sub.L_AMT1); + sumS += num(sub.S_AMT1); + sumT += num(sub.T_AMT1); + } + if (sumT > 0 || partnerIdx >= 0) { + r.M_AMT1 = sumM; + r.L_AMT1 = sumL; + r.S_AMT1 = sumS; + r.T_AMT1 = sumT; + } + }); + + return rows; + } + + /** + * 1. TWPRJLIST (wPrjList 공사목록표 화면) + * 상단: 등록정보 수정 | 제잡비 계산 | 해당분 수정(wBoq) | 복사등록 | 해당분만 압축(.STC) | 내역서 미리보기 | 정렬 + * 중단: RGrdPrjList (PC 내 9개 프로젝트 + 현재 열린 PRJLST.DBF) + * 하단: RDbGrid_Boq (선택 프로젝트의 실시간 내역서 미리보기) + */ + function renderWPrjList(ctx) { + const sources = (ctx.sourcesData && ctx.sourcesData.projects) || []; + const curPath = ctx.currentSourcePath || ""; + const prj = (ctx.dbfData && ctx.dbfData.prjlst) || {}; + const comp = ctx.computed; + const directCost = comp.boqComputed.totals.directT; + const totalCost = comp.kanjubComputed.GRAND_TOTAL; + const boqRows = evaluateFullBoqRows(ctx); + const showPreview = ctx.uiState.showBoqPreview !== false; + + return ` +
+
+ 📁 공사목록표 [TWPRJLIST · PRJLST.DBF / PRJLST.SMT] — 총 ${sources.length}개 프로젝트 연결됨 + 더블클릭 시 설계내역서(wBoq) 진입 +
+ + +
+ + + + + + + + 현재 선택: ${prj.PNAME || ctx.state.projectMeta.pname} (직공비 ${fmt(directCost)}원 / 총공사비 ${fmt(totalCost)}원) + +
+ +
+ +
+
+ RGrdPrjList — PC 내 STMATE 프로젝트 원장 (클릭: 프로젝트 로드 / 더블클릭: 내역서 편집) + K_ORD 단계: ${KORD_BADGES[num(prj.K_ORD)] || "설계내역(0)"} +
+
+ + + + + + + + + + + + + + + + + ${sources + .map((src, idx) => { + const isCur = src.path === curPath; + const pnum = isCur ? prj.PNUM || "2024년 단가" : "2024~2025"; + const pcode = isCur ? prj.PCODE || "11" : "11"; + const pcls = isCur + ? PCLASS_NAMES[num(prj.PCLASS)] || "일반토목" + : "산림/임도토목"; + const kord = isCur ? KORD_BADGES[num(prj.K_ORD)] || "설계(0)" : "설계(0)"; + const tcost = isCur ? fmt(directCost) : "로드 시 표시"; + const costw = isCur ? fmt(totalCost) : "로드 시 표시"; + return ` + + + + + + + + + + + + + `; + }) + .join("")} + +
순번구분공사번호(PNUM)공사명 (PNAME / 아카이브명)코드(PCODE)공사종류(PCLASS)단계(K_ORD)직공비(TCOST1)총공사비(COSTW1)작업
${idx + 1}${src.kind.toUpperCase()}${pnum}${src.name}BOQ${pcode}${pcls}${kord}${tcost}${costw} + +
+
+
+ + ${ + showPreview + ? ` + +
+
+ RDbGrid_Boq — 내역서 미리보기 (${ctx.dbfData ? ctx.dbfData.boq_table_name : "BOQ11.DBF"} · 총 ${boqRows.length}행 전수 표시) + 공종(ITNUM) | 명칭(NAME1) | 규격(SIZE1) | 수량(QTY1) | 단위(UNIT1) | 단가(TAMT1) | 금액(T_AMT1) | 비고(BDCODE1) +
+
+ + + + + + + + + + + + + + + + ${boqRows + .map( + (r, i) => ` + + + + + + + + + + + + `, + ) + .join("")} + +
행번공종(ITNUM)명칭 (NAME1)규격 (SIZE1)수량 (QTY1)단위합계단가 (TAMT1)합계금액 (T_AMT1)비고 (BDCODE1)
${i + 1}${r.ITNUM || ""}${r.NAME1 || ""}${r.SIZE1 || ""}${r.QTY1 ? fmt(r.QTY1, 2) : ""}${r.UNIT1 || ""}${r.TAMT1 ? fmt(r.TAMT1) : ""}${r.T_AMT1 ? fmt(r.T_AMT1) : ""}${r.BDCODE1 || ""}
+
+
` + : "" + } +
+
+ `; + } + + /** + * 2. wBoq (계층 설계내역서 편집 화면) + * - BOQ11~14.DBF의 모든 행(T1~T6 공종제목, E1~E6 소계/계/총계/총합계, C* 제외구역, 말단 공종행) 100% 표시 + * - 레벨 필터(전체 / = 소계 이상 / == 계 이상 / ==== 총계 이상 / 제외구역만) + * - 공종번호 자동부여(wITNum), 할증율(wBoqRate), 단수보정(wM_Rate), 일위대가 풀기(wExpnd), 보관함(1)(2)(3) + */ + function renderWBoq(ctx) { + const allRows = evaluateFullBoqRows(ctx); + const lvFilter = ctx.uiState.boqLevelFilter || "ALL"; + const kw = (ctx.uiState.boqSearch || "").trim().toLowerCase(); + const pocketIdx = ctx.uiState.activePocket || 1; + const pockets = ctx.uiState.boqPockets || { 1: [], 2: [], 3: [] }; + + const filtered = allRows.filter((r) => { + const bd = String(r.BDCODE1 || "").trim(); + if (lvFilter === "STRUCT" && !isStructCode(bd)) return false; + if (lvFilter === "LEAF" && (isStructCode(bd) || !bd)) return false; + if (lvFilter === "EXCL" && !r._isExcl) return false; + if (kw) { + const txt = `${r.ITNUM || ""} ${r.NAME1 || ""} ${r.SIZE1 || ""} ${bd}`.toLowerCase(); + if (!txt.includes(kw)) return false; + } + return true; + }); + + const totals = ctx.computed.boqComputed.totals; + + return ` +
+
+ 📐 설계내역서 편집 [wBoq · ${ctx.dbfData ? ctx.dbfData.boq_table_name : "BOQ11.DBF"}] — 전체 ${allRows.length}행 (표시 ${filtered.length}행) + 직공비 합계: ${fmt(totals.directT)}원 | 관급·제외구역(C*): ${fmt(totals.govMatTotal + totals.govFeeTotal)}원 +
+ + +
+ + + + + + + + + +
+ +
+ +
+
+ 계층 설계내역서 원장 (수량 수정 시 비목별 ROUNDDOWN 및 T*/E*/C* 집계 즉시 재계산 · 호표코드 더블클릭 시 대가표 이동) + 차액보정: ${ctx.state.sysConfig.chnMode === "total" ? "총액우선(OPTNM=" + ctx.state.sysConfig.optOrder + ")" : "비목우선"} +
+
+ + + + + + + + + + + + + + + + + + + + + + + ${filtered + .map((r) => { + const bd = String(r.BDCODE1 || "").trim(); + const isLeaf = bd && !isStructCode(bd); + return ` + + + + + + + + + + + + + + + + + + + `; + }) + .join("")} + +
No공종(ITNUM)코드(BDCODE1)명칭 (NAME1)규격 (SIZE1)단위수량(QTY1)재료비단가노무비단가경비단가합계단가재료비금액노무비금액경비금액합계금액(T_AMT1)보관함
${r._idx + 1} + + + ${ + isLeaf + ? `${bd}` + : `${bd}` + } + + + ${r.SIZE1 || ""}${r.UNIT1 || ""} + ${ + isLeaf + ? `` + : "" + } + ${r.MAMT1 ? fmt(r.MAMT1) : ""}${r.LAMT1 ? fmt(r.LAMT1) : ""}${r.SAMT1 ? fmt(r.SAMT1) : ""}${r.TAMT1 ? fmt(r.TAMT1) : ""}${r.M_AMT1 ? fmt(r.M_AMT1) : ""}${r.L_AMT1 ? fmt(r.L_AMT1) : ""}${r.S_AMT1 ? fmt(r.S_AMT1) : ""}${r.T_AMT1 ? fmt(r.T_AMT1) : ""} + ${ + isLeaf + ? `` + : "" + } +
+
+
+ + +
+
+ 📦 내역보관함 (${pocketIdx}) +
+ ${[1, 2, 3] + .map( + (n) => + ``, + ) + .join(" ")} +
+
+
+

+ 자주 쓰는 공종을 보관함 (1)~(3)에 담아두고 현재 내역서에 즉시 삽입합니다. +

+ ${ + (pockets[pocketIdx] || []).length === 0 + ? `
보관함 (${pocketIdx})이 비어 있습니다.
내역 행 우측 [담기]를 클릭하세요.
` + : ` + + + ${(pockets[pocketIdx] || []) + .map( + (p, pi) => ` + + + + + + + `, + ) + .join("")} + +
코드품명수량삽입
${p.code}${p.name}${fmt(p.qty, 2)}
` + } +
+
+
+
+ `; + } + + /** + * 3. 내역 도구 모달 HTML 생성기 (wITNum / wBoqRate / wM_Rate / wExpnd / wQtyList) + */ + function buildBoqToolModalHtml(toolKind, ctx) { + if (toolKind === "wITNum") { + return ` +
+

[wITNum] 내역서 공종번호 자동입력 — 대공종(T4)/중공종(T2)/말단 공종행에 번호체계를 일괄 부여합니다.

+
+
+
+
+
+
+
+ `; + } + if (toolKind === "wBoqRate") { + return ` +
+

[wBoqRate] 내역 항목 할증율 일괄 적용 — 전체 또는 비목별(재·노·경) 할증 비율(%)을 적용합니다.

+
+
+
+
+
+
+
+ `; + } + if (toolKind === "wM_Rate") { + return ` +
+

[wM_Rate] 선택 범위 수량·비목 단수 일괄 보정 — 절사/반올림 및 소수점 자리수(0~4위)를 일괄 보정합니다.

+
+
+
+
+
+
+ `; + } + if (toolKind === "wExpnd") { + return ` +
+

[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기 — 호표 구성행을 내역서 하위 행으로 전개합니다.

+
+
+
+
+
+
+ `; + } + // wQtyList (수량산출서 연결 집계) + const leafRows = ctx.computed.boqComputed.rows || []; + return ` +
+

[wQtyList] 수량산출서(QCODE.DBF) 코드별 수량 연결·집계 — 수량산출식 결과를 내역서 QTY1에 즉시 반영합니다.

+
+ + + + ${leafRows + .slice(0, 25) + .map( + (r) => ` + + + + + + + + `, + ) + .join("")} + +
코드공종명현재 내역수량산출근거 수식 (예: 3.46*1000)반영
${r.code}${r.name}${fmt(r.qty, 2)}
+
+
+ `; + } + + function syncCostnUnitPrice(ctx, code, mVal, lVal, sVal) { + const tVal = mVal + lVal + sVal; + if (ctx.dbfData && ctx.dbfData.costn_rows) { + const row = ctx.dbfData.costn_rows.find((r) => String(r.CODE || "").trim() === code); + if (row) { + Object.assign(row, { + MAMT1: String(mVal), + LAMT1: String(lVal), + SAMT1: String(sVal), + TAMT1: String(tVal), + AMT_61: String(tVal), + }); + } + } + const directRes = ctx.state.resources.find((r) => r.code === code); + if (directRes) Object.assign(directRes, { adoptMode: "manual", manualPrice: tVal }); + [ + ["M", "재료비", mVal], + ["L", "노무비", lVal], + ["S", "경비", sVal], + ].forEach(([tag, kind, v]) => { + const synId = `${code}_${tag}`; + const syn = ctx.state.resources.find((r) => r.id === synId); + if (syn) syn.manualPrice = v; + else if (v > 0) { + ctx.state.resources.push({ + id: synId, + code: synId, + name: `${code} (${kind})`, + spec: "", + unit: "", + kind, + candidates: [v, 0, 0, 0, 0], + adoptMode: "manual", + manualPrice: v, + convertHourly: false, + isUsd: false, + isSynthetic: true, + }); + const ilwi = ctx.state.ilwiItems.find((x) => x.code === code); + if (ilwi && !ilwi.lines.some((ln) => ln.refId === synId)) { + ilwi.lines.push({ refId: synId, qty: 1.0, halPercent: 0, devd: false }); + } + } + }); + } + + function buildSavePayload(ctx) { + const kj = ctx.computed.kanjubComputed; + const bt = ctx.computed.boqComputed.totals; + const fullBoq = evaluateFullBoqRows(ctx); + const rts = ctx.state.kanjubConfig.rates; + return { + source_path: ctx.currentSourcePath, + target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28), + costn_updates: (ctx.dbfData.costn_rows || []).map((r) => ({ + CODE: r.CODE, + MAMT1: r.MAMT1, + LAMT1: r.LAMT1, + SAMT1: r.SAMT1, + TAMT1: r.TAMT1, + AMT_61: r.TAMT1, + })), + boq_updates: fullBoq.map((r) => ({ + _recno: r._recno, + ITNUM: r.ITNUM || "", + NAME1: r.NAME1 || "", + SIZE1: r.SIZE1 || "", + UNIT1: r.UNIT1 || "", + QTY1: r.QTY1, + MAMT1: r.MAMT1, + LAMT1: r.LAMT1, + SAMT1: r.SAMT1, + TAMT1: r.TAMT1, + })), + prjlst_updates: { + PNAME: ctx.state.projectMeta.pname, + PNUM: ctx.state.projectMeta.pnum, + MCOST1: bt.directM, + LCOST1: bt.directL, + SCOST1: bt.directS, + TCOST1: bt.directT, + COSTW1: kj.GRAND_TOTAL, + }, + smt_updates: { + "1_R_KANNO": rts.KANNO, + "1_R_SANBO": rts.SANBO, + "1_R_KOBO": rts.KOBO, + "1_R_KUNBO": rts.KUNBO, + "1_R_SLVBO": rts.SLVBO, + "1_R_YUNBO": rts.YUNBO, + "1_R_ANJUN": rts.ANJUN, + "1_R_T_KITA": rts.KITA, + "1_R_HWAN": rts.HWAN, + "1_R_EQIPSU": rts.EQIP, + "1_R_ILBAN": rts.ILBAN, + "1_R_IYUN": rts.IYUN, + "1_C_ANJUN_G": ctx.state.kanjubConfig.anjunBaseAdd, + "1_M_MAT": bt.directM, + "1_M_LAB": bt.directL + kj.KANNO, + "1_M_SAN": bt.directS + (kj.IND_EXP - kj.KANNO), + "1_M_ILBAN": kj.ILBAN, + "1_M_IYUN": kj.IYUN, + "1_M_VAT": kj.VAT, + "1_M_TOT": kj.FINAL_SUPPLY, + "1_M_KWNGP": kj.GOV_MAT, + }, + costn_memo_updates: ctx.uiState.editedSmtMap || {}, + }; + } + + global.STMATE_VIEW_PRJ_BOQ = { + evaluateFullBoqRows, + renderWPrjList, + renderWBoq, + buildBoqToolModalHtml, + syncCostnUnitPrice, + buildSavePayload, + }; +})(window);