feat(stmate-test): implement full STMATE w 8.0 UI, COSTN.SMT binary parser, and 100% lossless DBF/STC engine

This commit is contained in:
2026-11-04 23:45:20 +09:00
parent 1cc9dca9fc
commit 267b5a693f
12 changed files with 4341 additions and 2469 deletions
+23 -51
View File
@@ -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`(`<HII`: `tag=0x0008`, `byte_len`, `block_no * 64`) 기반 `COSTN.SMT` 산출근거 본문 추출·재기록기, `30_원자료` 카탈로그(894 표준코드·350 일위대가 레시피·21 특수산식) 로더 |
| [`server.py`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/server.py) | 649 | FastAPI 서버 (`127.0.0.1:8095`) — `/api/sources`, `/api/catalog`, `/api/load`(COSTN·COSTN.SMT·BOQ11~14·PRJLST·RATE_R/RA/M·SET_REP 전수), `/api/load-jbr`, `/api/save` |
| [`calc.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/calc.js) | 674 | 5단계 적산 파이프라인(`M/L/S → X 중기 → D 단가산출 → B 일위대가 → BOQ 내역서 → 원가계산서 19행`), 봉화군 2024년 산림유역관리사업 1원 오차 없는 골든셋 내장 |
| [`view_prj_boq.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_prj_boq.js) | 677 | `wPrjList`(`TWPRJLIST` 공사목록표 + 하단 내역 미리보기), `wBoq`(`BOQ11~14.DBF` `T1~T6` 공종헤더·`E1~E6` 소계·`C*` 관급자재 제외구간·말단 내역 전수 편집기 + 보관함 1·2·3), 내역 도구 모달(`wITNum`, `wBoqRate`, `wM_Rate`, `wExpnd`, `wQtyList`) |
| [`view_dan_ilwi.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_dan_ilwi.js) | 594 | `wM_Set_Sys`(`초기값 입력및수정` + `K_ORD` 판정기), `wM_JabPum`(`COSTN.DBF` 전수 단가목록표 + `wM_Boxa` 5개 가격지 비교 + `wM_Edt_Hal` 기준단가 비율보정), `wM_Edit_iLWi` / `wM_Edit_San`(`COSTN.SMT` 64B 블록 원문 전개 편집기 + `Q`/`Cm`/`&X00026L/M/S&` 실시간 계산기 + 카탈로그 브라우저) |
| [`view_kanjub_prt.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_kanjub_prt.js) | 628 | `wM_KanJub`(`TWM_KANJUB` 16개 절사·단수·관급·이윤보정 콤보 + 4탭 `T/S/TA/SA`), `wM_KAN_RATE`(`TWM_KAN_RATE` 4탭 요율표 `RATE_R/RA/M/RATE` + `JB_R` 로더), `wStage`(`wM_Mk_Cont` 계약·`wM_Boq_ExecX` 실행·`wM_KanJub_K` 기성·설계변경), `PM_Prt`(`ST_Prt.CPM` 30종 보고서 + `SET_REP.DBF` 75종 양식 + CSV/조달청 XML/JDL 변환) |
| [`app.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/app.js) | 688 | 상단 5대 메뉴·툴바·모달·9개 화면 라우팅 및 DBF/SMT/STC 양방향 저장 컨트롤러 |
+670 -680
View File
File diff suppressed because it is too large Load Diff
-283
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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) => `
<tr class="${r.rowType !== "normal" ? "excl-row" : ""}">
<td><input type="text" data-sec="boqItems" data-idx="${i}" data-field="code" value="${esc(r.code)}" class="code-input"></td>
<td><input type="text" data-sec="boqItems" data-idx="${i}" data-field="name" value="${esc(r.name)}"></td>
<td><select data-sec="boqItems" data-idx="${i}" data-field="refId">${unitChoices.map(([v, l]) => `<option value="${esc(v)}" ${r.refId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
<td>
<select data-sec="boqItems" data-idx="${i}" data-field="rowType">
<option value="normal" ${r.rowType === "normal" ? "selected" : ""}>일반 말단행(직접비)</option>
<option value="gov_mat" ${r.rowType === "gov_mat" ? "selected" : ""}>C* 제외구역(관급원자재)</option>
<option value="gov_fee" ${r.rowType === "gov_fee" ? "selected" : ""}>C* 제외구역(조달수수료)</option>
</select>
</td>
<td class="num-col"><input type="number" data-sec="boqItems" data-idx="${i}" data-field="qty" value="${r.qty}" step="any"></td>
<td class="money-col">${fmt(r.unitInfo.T, 0)}</td>
<td class="money-col">${fmt(r.M, 0)}</td>
<td class="money-col">${fmt(r.L, 0)}</td>
<td class="money-col">${fmt(r.S, 0)}</td>
<td class="money-col highlight-col"><strong>${fmt(r.T, 0)}</strong>${r.optimizTarget ? `<div class="diff-warn">OPTIMIZ: ${esc(r.optimizTarget)}</div>` : ""}</td>
<td class="money-col">${fmt(r.aggT, 0)}</td>
<td class="center-col"><button type="button" class="btn-del" data-action="del-boq" data-idx="${i}">삭제</button></td>
</tr>`,
)
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>내역서 계산 방식 및 <code>ST_Func_C.CPM :: OPTIMIZ</code> 차액 보정 제어</h2>
<p>14·15단계 REA 분석에서 확인된 <code>CHNNM</code>(비목별 금액 우선 vs 총액 우선) 및 <code>OPTNM</code>(<code>SML</code> 차액 보정 순서) 동작을 검증합니다. 또한 <code>C*</code> 제외금액 구역(관급자재대)은 직접공사비 합산에서 분리됩니다(16번 문서 §4.2).</p>
</div>
<button type="button" class="btn primary" data-action="add-boq">+ 내역 행 추가</button>
</div>
<div class="control-grid">
<label class="ctrl-item">
<span>내역서 계산 방식 (<code>CHNNM</code> · <code>+0x7c5</code>)</span>
<select data-sec="sysConfig" data-field="chnMode">
<option value="bimok" ${sys.chnMode === "bimok" ? "selected" : ""}>0: 비목별 금액 먼저 (각 비목 절사 후 합산)</option>
<option value="total" ${sys.chnMode === "total" ? "selected" : ""}>1: 총액 먼저 (OPTIMIZ로 단수 차액 성분 배분)</option>
</select>
</label>
<label class="ctrl-item">
<span>차액 보정 우선순위 (<code>OPTNM</code> · <code>+0x7dc</code>)</span>
<select data-sec="sysConfig" data-field="optOrder">
<option value="SML" ${sys.optOrder === "SML" ? "selected" : ""}>SML (경비 → 재료비 → 노무비)</option>
<option value="MLS" ${sys.optOrder === "MLS" ? "selected" : ""}>MLS (재료비 → 노무비 → 경비)</option>
<option value="LMS" ${sys.optOrder === "LMS" ? "selected" : ""}>LMS (노무비 → 재료비 → 경비)</option>
</select>
</label>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr><th>코드(<code>BDCODE1</code>)</th><th>공종명</th><th>연결 단가</th><th>구역 구분</th><th>설계수량(<code>QTY1</code>)</th><th>합계단가</th><th>재료비(<code>M_AMT1</code>)</th><th>노무비(<code>L_AMT1</code>)</th><th>경비(<code>S_AMT1</code>)</th><th>내역 합계(<code>T_AMT1</code>)</th><th>집계표(사사오입)</th><th>관리</th></tr>
</thead>
<tbody>
${rowsHtml}
<tr class="total-row">
<td colspan="6">직접공사비 소계 (<code>C*</code> 제외금액 구역 불포함)</td>
<td class="money-col">${fmt(boq.directM, 0)}</td>
<td class="money-col">${fmt(boq.directL, 0)}</td>
<td class="money-col">${fmt(boq.directS, 0)}</td>
<td class="money-col">${fmt(boq.directTotal, 0)}</td>
<td colspan="2">관급원자재: ${fmt(boq.govRawTotal, 0)} / 수수료: ${fmt(boq.govFeeTotal, 0)}</td>
</tr>
</tbody>
</table>
</div>
</div>`;
}
function renderKanjubTab(state, res) {
const cfg = state.kanjubConfig,
k = res.kanjubOrig;
const itemRows = k.items
.map(
(it) => `
<tr>
<td><code>${esc(it.code)}</code></td>
<td><strong>${esc(it.label)}</strong></td>
<td><span class="formula-tag">${esc(it.baseDesc)}</span></td>
<td class="money-col">${fmt(it.baseAmt, 0)}</td>
<td class="num-col"><input type="number" data-sec="kanjubRates" data-field="${esc(it.code)}" value="${it.ratePct}" step="any"></td>
<td class="money-col highlight-col"><strong>${fmt(it.amount, 0)}</strong></td>
</tr>`,
)
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>원가계산 4형식 및 제잡비·끝전조정 제어 (<code>ST_KanJub.CPM :: Calc_Kanjub</code>)</h2>
<p>원가계산 형식(<code>T/S/TA/SA</code>)을 바꾸면 기타경비·환경보전비·일반관리비·이윤의 <strong>밑수 공식 자체가 전환</strong>됩니다(12번 문서 §7). 또한 도급공사비 절사 차액을 세전 환산(<code>ROUND(차액/1.1)</code>)하여 이윤에서 빼는 끝전조정을 검증합니다(16번 문서 §4.4).</p>
</div>
</div>
<div class="control-grid">
<label class="ctrl-item">
<span>원가계산 형식 (<code>TWM_KANJUB</code> 4탭)</span>
<select data-sec="kanjubConfig" data-field="scheme">
<option value="T" ${cfg.scheme === "T" ? "selected" : ""}>T: 일반형식 (표준 제비율)</option>
<option value="S" ${cfg.scheme === "S" ? "selected" : ""}>S: 수공형식 (세부경비 8종 분리)</option>
<option value="TA" ${cfg.scheme === "TA" ? "selected" : ""}>TA: 실적일반 (환경·보증 밑수=직공비, 이윤에 재료비 포함)</option>
<option value="SA" ${cfg.scheme === "SA" ? "selected" : ""}>SA: 실적수공 (일반관리비 밑수=직공비+간노)</option>
</select>
</label>
<label class="ctrl-item">
<span>부가가치세 기준 (<code>I_BUGA</code> · 태그 59)</span>
<select data-sec="kanjubConfig" data-field="vatMode">
<option value="supply_10" ${cfg.vatMode === "supply_10" ? "selected" : ""}>(1) 공급가액의 10%</option>
<option value="mat_10" ${cfg.vatMode === "mat_10" ? "selected" : ""}>(2) 재료비의 10% (면세·임협식 변형)</option>
<option value="mat_exp_10" ${cfg.vatMode === "mat_exp_10" ? "selected" : ""}>(3) (재료비+산출경비)의 10%</option>
<option value="zero" ${cfg.vatMode === "zero" ? "selected" : ""}>(0) 영세율 / 부가세 0원</option>
</select>
</label>
<label class="ctrl-item">
<span>도급액 절사 자리 (<code>I_TOT_JEOL</code> · <code>+0x204</code>)</span>
<select data-sec="kanjubConfig" data-field="totJeolDigits">
<option value="-3" ${Number(cfg.totJeolDigits) === -3 ? "selected" : ""}>1,000원 미만 절사 (-3)</option>
<option value="-4" ${Number(cfg.totJeolDigits) === -4 ? "selected" : ""}>10,000원 미만 절사 (-4)</option>
<option value="-2" ${Number(cfg.totJeolDigits) === -2 ? "selected" : ""}>100원 미만 절사 (-2)</option>
<option value="0" ${Number(cfg.totJeolDigits) === 0 ? "selected" : ""}>절사 없음 (0)</option>
</select>
</label>
<label class="ctrl-item">
<span>산업안전보건관리비 기초액 (원)</span>
<input type="number" data-sec="kanjubConfig" data-field="anjunBaseAdd" value="${cfg.anjunBaseAdd}" step="1000">
</label>
<label class="ctrl-item">
<span>관급자재대 끝전 처리 (기본: 천원 올림)</span>
<select data-sec="kanjubConfig" data-field="govRoundMode">
<option value="up" ${cfg.govRoundMode === "up" ? "selected" : ""}>천원 단위 올림 (ROUNDUP -3)</option>
<option value="half" ${cfg.govRoundMode === "half" ? "selected" : ""}>천원 단위 반올림 (ROUND -3)</option>
<option value="down" ${cfg.govRoundMode === "down" ? "selected" : ""}>천원 단위 절사 (ROUNDDOWN -3)</option>
</select>
</label>
<label class="ctrl-item checkbox-row">
<input type="checkbox" data-sec="kanjubConfig" data-field="enableProfitEndAdj" ${cfg.enableProfitEndAdj !== false ? "checked" : ""}>
<span>끝전 차액을 이윤(<code>C_IYUN_M</code>)에서 세전 차감 보정 (<code>0x4724f0</code>)</span>
</label>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>저장키</th><th>원가계산 항목</th><th>산정기초 (밑수 공식)</th><th>밑수 금액 (D_)</th><th>적용 요율 (R_ %)</th><th>산출 확정액 (S_)</th></tr></thead>
<tbody>
<tr class="sub-row"><td><code>DAMT_M/L/S</code></td><td><strong>직접공사비 합계</strong></td><td>직접재료비 ${fmt(k.dM, 0)} + 직접노무비 ${fmt(k.dL, 0)} + 산출경비 ${fmt(k.dS, 0)}</td><td class="money-col">${fmt(k.dT, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.dT, 0)}</strong></td></tr>
${itemRows}
<tr class="sub-row"><td><code>PURE_COST</code></td><td><strong>순공사원가</strong></td><td>재료비 + 노무비(직노+간노) + 경비(산출+제경비)</td><td class="money-col">${fmt(k.pureConstructionCost, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.pureConstructionCost, 0)}</strong></td></tr>
<tr><td><code>ILBAN</code></td><td><strong>일반관리비</strong></td><td><span class="formula-tag">${esc(k.ilbanDesc)}</span></td><td class="money-col">${fmt(k.ilbanBase, 0)}</td><td class="num-col"><input type="number" data-sec="kanjubRates" data-field="ILBAN" value="${cfg.rates.ILBAN}" step="any"></td><td class="money-col highlight-col"><strong>${fmt(k.ilbanAmt, 0)}</strong></td></tr>
<tr><td><code>IYUN</code></td><td><strong>이윤 (끝전보정 후)</strong></td><td><span class="formula-tag">${esc(k.iyunDesc)}</span><div class="tiny-tag">보정전 산식이윤 ${fmt(k.iyunRaw, 0)} − 세전끝전조정 ${fmt(k.pretaxProfitAdj, 0)} (차액 ${fmt(k.diffTotal, 0)}/1.1 반올림)</div></td><td class="money-col">${fmt(k.iyunBase, 0)}</td><td class="num-col"><input type="number" data-sec="kanjubRates" data-field="IYUN" value="${cfg.rates.IYUN}" step="any"></td><td class="money-col highlight-col"><strong>${fmt(k.iyunFinal, 0)}</strong></td></tr>
<tr class="sub-row"><td><code>SUPPLY</code></td><td><strong>총원가 (공급가액)</strong></td><td>순공사원가 + 일반관리비 + 최종이윤 (보정전: ${fmt(k.supplyBeforeAdj, 0)})</td><td class="money-col">${fmt(k.supplyFinal, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.supplyFinal, 0)}</strong></td></tr>
<tr><td><code>BUGA</code></td><td><strong>부가가치세</strong></td><td>과세표준 × 세율 (보정전 도급액: ${fmt(k.contractBeforeAdj, 0)})</td><td class="money-col">${fmt(k.supplyFinal, 0)}</td><td class="center-col">10%</td><td class="money-col highlight-col"><strong>${fmt(k.vatFinal, 0)}</strong></td></tr>
<tr class="total-row"><td><code>CONTRACT</code></td><td><strong>도급공사비</strong></td><td>공급가액 + 부가가치세 (끝전 절사 완료)</td><td class="money-col">${fmt(k.contractBeforeAdj, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.contractFinal, 0)}</strong></td></tr>
<tr><td><code>KWANGUP</code></td><td><strong>관급자재대</strong></td><td>관급원자재(${fmt(k.govRawTotal, 0)}) + 조달수수료(${fmt(k.govFeeTotal, 0)}) 끝전 처리</td><td class="money-col">${fmt(k.govRawTotal + k.govFeeTotal, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.govFinal, 0)}</strong></td></tr>
<tr class="total-row"><td><code>GRAND_TOT</code></td><td><strong>총공사비</strong></td><td>도급공사비 + 관급자재대</td><td class="money-col">—</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.grandTotal, 0)} 원</strong></td></tr>
</tbody>
</table>
</div>
</div>`;
}
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) => `
<tr>
<td><code>${esc(b.code)}</code></td><td>${esc(b.name)}</td>
<td class="money-col">${fmt(b.qty, 2)}</td>
<td class="num-col"><input type="number" data-sec="boqItems" data-idx="${i}" data-field="changedQty" value="${b.changedQty ?? b.qty}" step="any"></td>
<td class="money-col">${fmt(boqOrig.rows[i].T, 0)}</td>
<td class="money-col">${fmt(boqRev.rows[i].T, 0)}</td>
<td class="money-col highlight-col"><strong>${boqRev.rows[i].T - boqOrig.rows[i].T >= 0 ? "+" : ""}${fmt(boqRev.rows[i].T - boqOrig.rows[i].T, 0)}</strong></td>
</tr>`,
)
.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 `
<tr>
<td><code>${esc(it.code)}</code></td><td><strong>${esc(it.label)}</strong></td>
<td><select data-sec="revModes" data-field="${esc(it.code)}">${revModeChoices.map(([v, l]) => `<option value="${v}" ${curMode === v ? "selected" : ""}>${l}</option>`).join("")}</select></td>
<td class="money-col">${fmt(it.origAmt, 0)}</td>
<td class="money-col">${fmt(it.amount, 0)}</td>
<td class="money-col highlight-col"><strong>${it.deltaAmt >= 0 ? "+" : ""}${fmt(it.deltaAmt, 0)}</strong></td>
</tr>`;
})
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>설계변경 수량 및 간접비 변경 계산모드 (<code>cBx_CH_BO</code> · 08번 문서 §2 · 18단계 REA)</h2>
<p>실무 변경설계에서는 직접노무비가 늘어나도 건강·장기요양·연금보험료 등 사후정산 항목은 <strong>당초액을 그대로 유지(<code>carry_original</code>)</strong>하고, 나머지는 재계산하거나 방법 A/B 공식을 적용합니다.</p>
</div>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>코드</th><th>내역 공종명</th><th>당초 수량</th><th>변경 수량 (조작)</th><th>당초 금액</th><th>변경 금액</th><th>증감 차액</th></tr></thead>
<tbody>
${boqCompareRows}
<tr class="total-row"><td colspan="4">직접공사비 당초 / 변경 / 증감 합계</td><td class="money-col">${fmt(boqOrig.directTotal, 0)}</td><td class="money-col">${fmt(boqRev.directTotal, 0)}</td><td class="money-col">${boqRev.directTotal - boqOrig.directTotal >= 0 ? "+" : ""}${fmt(boqRev.directTotal - boqOrig.directTotal, 0)}</td></tr>
</tbody>
</table>
</div>
<div class="table-scroll" style="margin-top:16px;">
<table class="data-table">
<thead><tr><th>키</th><th>제잡비 항목</th><th>변경설계 계산 모드 선택</th><th>당초 금액 (<code>C_AMT_B</code>)</th><th>변경 금액</th><th>증감 차액</th></tr></thead>
<tbody>
${kanjubCompareRows}
<tr class="total-row"><td colspan="3">총공사비 (도급 + 관급) 당초 / 변경 / 증감</td><td class="money-col">${fmt(kOrig.grandTotal, 0)}</td><td class="money-col">${fmt(kRev.grandTotal, 0)}</td><td class="money-col">${kRev.grandTotal - kOrig.grandTotal >= 0 ? "+" : ""}${fmt(kRev.grandTotal - kOrig.grandTotal, 0)}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel-card">
<div class="card-header">
<div>
<h2><code>K_ORD</code> 등록·차수내리기 전이 규칙 시뮬레이터 (<code>STMATE_REA_최종보고서</code> §4.1)</h2>
<p>Main <code>0x559228</code>(NewProject) 및 <code>0x55a640</code>(MkBoq)에서 관측된 <code>PCLASS</code> · 등록모드 · 플래그 조합에 따른 <code>K_ORD</code> 저장식과 차수내리기(<code>K - (K&gt;5 ? K-4 : 1)</code>) 동작을 확인합니다.</p>
</div>
</div>
<div class="control-grid">
<label class="ctrl-item"><span>원본 <code>PCLASS</code></span><select data-sec="kOrdSim" data-field="pclass"><option value="4" ${Number(kSim.pclass) === 4 ? "selected" : ""}>4</option><option value="6" ${Number(kSim.pclass) === 6 ? "selected" : ""}>6</option><option value="7" ${Number(kSim.pclass) === 7 ? "selected" : ""}>7</option></select></label>
<label class="ctrl-item"><span>등록 모드 (6 또는 7)</span><select data-sec="kOrdSim" data-field="regMode"><option value="6" ${Number(kSim.regMode) === 6 ? "selected" : ""}>모드 6</option><option value="7" ${Number(kSim.regMode) === 7 ? "selected" : ""}>모드 7</option></select></label>
<label class="ctrl-item"><span>플래그 (0 또는 1)</span><select data-sec="kOrdSim" data-field="flag"><option value="0" ${Number(kSim.flag) === 0 ? "selected" : ""}>0</option><option value="1" ${Number(kSim.flag) === 1 ? "selected" : ""}>1</option></select></label>
<label class="ctrl-item"><span>현재 <code>K_ORD</code> (K)</span><input type="number" data-sec="kOrdSim" data-field="currentK" value="${kSim.currentK}" min="1" max="20"></label>
</div>
<div class="sub-toolbar">
<span>등록 시 새 <code>K_ORD</code>: <strong>${kSimOut.nextK}</strong> · 차수내리기 실행 시 <code>K - (K&gt;5 ? K-4 : 1)</code>: <strong>${kSimOut.downK}</strong></span>
<span>REA 확인: <code>Calq_San</code> 행 selector(<code>+0x430</code>) 0 초기화 지배 · 공유 <code>tb_Ref(+0x58) → MmComRec+8</code></span>
</div>
</div>`;
}
return { renderBoqTab, renderKanjubTab, renderRevisionTab };
})();
+382 -381
View File
@@ -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;
-345
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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) =>
`<option value="${esc(p.path)}" ${dbCtx.selectedProjectPath === p.path ? "selected" : ""}>${esc(p.name)} — ${esc(p.path)}</option>`,
)
.join("");
const jbrOpts = dbCtx.sources.jbr_files
.map(
(f) =>
`<option value="${esc(f.path)}" ${dbCtx.selectedJbrPath === f.path ? "selected" : ""}>${esc(f.name)} (${fmt(f.size, 0)} B)</option>`,
)
.join("");
let dbfPreviewHtml =
'<div class="empty-box">상단에서 PC 내 STMATE 폴더 또는 <code>.STC</code> 파일을 선택하고 <strong>[원본 DBF/STC 불러오기]</strong>를 실행하세요.</div>';
if (dbCtx.loadedDbf) {
const d = dbCtx.loadedDbf;
const costnRows = (d.costn_rows || [])
.slice(0, 25)
.map(
(r, i) => `
<tr>
<td><code>${esc(r.CODE)}</code></td>
<td><input type="text" data-dbf="costn" data-idx="${i}" data-field="NAME" value="${esc(r.NAME)}"></td>
<td>${esc(r.SIZE)}</td>
<td class="center-col">${esc(r.UNIT)}</td>
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="MAMT1" value="${Number(r.MAMT1) || 0}" step="any"></td>
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="LAMT1" value="${Number(r.LAMT1) || 0}" step="any"></td>
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="SAMT1" value="${Number(r.SAMT1) || 0}" step="any"></td>
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="TAMT1" value="${Number(r.TAMT1) || 0}" step="any"></td>
<td class="money-col">${fmt(r.AMT_61, 0)}</td>
</tr>`,
)
.join("");
dbfPreviewHtml = `
<div class="sub-toolbar">
<span>현재 연결 원본: <strong>${esc(d.prjlst?.PNAME || d.source_path)}</strong> · 수록 테이블: <code>${esc(d.file_list.join(", "))}</code></span>
<span>원장 <code>COSTN.DBF</code> <strong>${d.costn_count}건</strong> · 내역 <code>${esc(d.boq_table_name)}</code> <strong>${d.boq_count}건</strong> · <code>PRJLST.SMT</code> 설정키 <strong>${Object.keys(d.prjlst_smt || {}).length}개</strong></span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>CODE</th><th>품명(<code>NAME</code>)</th><th>규격(<code>SIZE</code>)</th><th>단위</th><th>재료단가(<code>MAMT1</code>)</th><th>노무단가(<code>LAMT1</code>)</th><th>경비단가(<code>SAMT1</code>)</th><th>합계단가(<code>TAMT1</code>)</th><th>채택6칸(<code>AMT_61</code>)</th></tr></thead>
<tbody>${costnRows}</tbody>
</table>
</div>`;
}
const saveAlert = dbCtx.lastSaveResult
? `<div class="sub-toolbar"><span>최근 쓰기 완료 폴더: <code>${esc(dbCtx.lastSaveResult.saved_dir)}</code></span><span>생성된 패키지: <code>${esc(dbCtx.lastSaveResult.saved_stc)}</code> (${fmt(dbCtx.lastSaveResult.stc_size, 0)} bytes, 파일 ${dbCtx.lastSaveResult.written_count}종)</span></div>`
: "";
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>PC 내 STMATE 원본 DBF · SMT · STC · JB_R 직접 읽기/쓰기 연동</h2>
<p><code>C:\\경영정보(주)</code> 설치 폴더의 프로젝트 DBF(<code>COSTN.DBF</code>, <code>BOQ11.DBF</code>, <code>PRJLST.DBF/.SMT</code>), <code>System\\ST_BSC\\*.JB_R</code> 제비율 기준 DB, 그리고 실무 <code>.STC</code> 패키지를 직접 읽고 CP949 고정폭 바이너리 DBF로 다시 기록합니다.</p>
</div>
<div>
<button type="button" class="btn secondary" data-db-action="refresh-sources">연결/목록 새로고침</button>
</div>
</div>
<div class="sub-toolbar">
<span>상태: <strong>${esc(dbCtx.statusMsg)}</strong></span>
<span>브리지 주소: <code>${API_BASE}</code></span>
</div>
<div class="control-grid">
<label class="ctrl-item">
<span>1. 프로젝트 원본 DB 폴더 또는 <code>.STC</code> 선택</span>
<select id="selDbProject">${projOpts || '<option value="">감지된 프로젝트 없음</option>'}</select>
</label>
<div class="ctrl-item" style="justify-content:flex-end;">
<button type="button" class="btn primary" data-db-action="load-project" ${!dbCtx.connected ? "disabled" : ""}>원본 DBF/STC 불러오기 (시뮬레이터 동기화)</button>
</div>
<label class="ctrl-item">
<span>2. 시스템 제비율 DB (<code>C:\\경영정보(주)\\System\\ST_BSC\\*.JB_R</code>) 선택</span>
<select id="selDbJbr">${jbrOpts || '<option value="">감지된 JB_R 없음</option>'}</select>
</label>
<div class="ctrl-item" style="justify-content:flex-end;">
<button type="button" class="btn secondary" data-db-action="load-jbr" ${!dbCtx.connected ? "disabled" : ""}>선택한 .JB_R 제비율 읽어서 원가계산 적용</button>
</div>
<label class="ctrl-item">
<span>3. DBF/STC 쓰기 대상 폴더명 (<code>tools/stmate-test/work_dbf/</code> 하위)</span>
<input type="text" id="inpTargetFolder" value="${esc(dbCtx.targetFolderName)}">
</label>
<div class="ctrl-item" style="justify-content:flex-end;">
<button type="button" class="btn primary" data-db-action="save-dbf" ${!dbCtx.connected ? "disabled" : ""}>현재 단가·내역·원가 결과를 DBF 및 .STC로 직접 쓰기</button>
</div>
</div>
${saveAlert}
</div>
<div class="panel-card">
<div class="card-header">
<div>
<h2>원본 <code>COSTN.DBF</code> 레코드 직접 조회 및 편집 (상위 25건 미리보기)</h2>
<p>여기서 수정한 단가와 01~06 탭에서 조작한 수량·요율·원가 결과가 <strong>[DBF 및 .STC로 직접 쓰기]</strong> 실행 시 <code>COSTN.DBF</code>, <code>BOQ11/12.DBF</code>, <code>PRJLST.DBF/.SMT</code>에 기록됩니다.</p>
</div>
</div>
${dbfPreviewHtml}
</div>`;
}
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,
};
})();
+162 -53
View File
@@ -1,70 +1,179 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>STMATE 계산·제어 흐름 독립 테스트</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STMATE w 8.0 — 경영정보(주) 원가·내역 통합 시스템 (독립 재현판)</title>
<link rel="stylesheet" href="style.css" />
<script defer src="calc.js"></script>
<script defer src="app_views_cost.js"></script>
<script defer src="db_bridge.js"></script>
<script defer src="app.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="badges">
<span class="badge accent">독립 테스트 웹앱</span>
<span class="badge">REA 6.7.0 / Ghidra 12.1.4 분석 반영</span>
<span class="badge">무로그인 · 로컬 전용</span>
<div id="stmate-shell" class="stmate-shell">
<!-- 1. TWM_STMATE_START_8X 상단 메인 메뉴바 (MainMenu1: 5대 메뉴) -->
<div class="win-menubar" id="main-menubar">
<div class="win-brand">STMATE w 8.0</div>
<div class="menu-item" data-menu="m1">
<span><u>1</u>. 프로젝트/초기값</span>
<div class="menu-dropdown">
<button data-act="Act_PrjList">공사목록표 (wPrjList) — 프로젝트 관리</button>
<button data-act="Act_Init">초기값 입력 및 수정 (wM_Set_Sys)</button>
<hr />
<button data-act="Act_ReloadDbf">현재 프로젝트 DBF/SMT 다시 읽기</button>
<button data-act="Act_SaveDbf">현재 프로젝트 DBF/SMT/STC 물리 저장 (sBtn_Calc)</button>
<button data-act="Act_ExportJson">작업 스냅샷 JSON 내보내기</button>
<label class="menu-file-label">
작업 스냅샷 JSON 불러오기
<input type="file" id="json-file-input" accept=".json" hidden />
</label>
</div>
</div>
<div class="menu-item" data-menu="m2">
<span><u>2</u>. 단가표/대가표</span>
<div class="menu-dropdown">
<button data-act="Act_DanList" data-filter="ALL">
단가목록표 — 모든단가 (wM_JabPum)
</button>
<button data-act="Act_DanList" data-filter="L">노무비 단가목록 (L)</button>
<button data-act="Act_DanList" data-filter="M">재료비 단가목록 (M)</button>
<button data-act="Act_DanList" data-filter="S">경비 단가목록 (S)</button>
<button data-act="Act_DanList" data-filter="X">
중기경비/사용료 목록 (X · wM_Edit_EQ)
</button>
<hr />
<button data-act="Act_IlwiEdit" data-filter="B">
일위대가표 편집 (B · wM_Edit_iLWi)
</button>
<button data-act="Act_IlwiEdit" data-filter="D">
단가산출근거/Q식 편집 (D · wM_Edit_San)
</button>
<hr />
<button data-act="Act_BoxaModal">5개 가격지 비교·최저가 채택 (wM_Boxa)</button>
<button data-act="Act_EdtHalModal">기준단가 비율 일괄수정 (wM_Edt_Hal)</button>
<button data-act="Act_CatalogModal">
STMATE 표준 카탈로그 (894코드·350레시피·21특수산식)
</button>
</div>
</div>
<div class="menu-item" data-menu="m3">
<span><u>3</u>. 내역서/인쇄</span>
<div class="menu-dropdown">
<button data-act="Act_BoqEdit">설계내역서 편집 (wBoq · T1~T6/E1~E6/C*)</button>
<button data-act="Act_KanJub">제잡비 계산 · 원가계산서 (wM_KanJub)</button>
<button data-act="Act_KanRate">원가계산기준 제비율표 편집 (wM_KAN_RATE)</button>
<hr />
<button data-act="Act_StageEdit">
계약 / 실행예산 / 기성 / 설계변경 단계 (wM_Mk_Cont · wM_KanJub_K)
</button>
<hr />
<button data-act="Act_Print">
보고서 인쇄 및 엑셀/조달청 XML/JDL 변환 (PM_Prt 30종)
</button>
</div>
</div>
<div class="menu-item" data-menu="m4">
<span><u>4</u>. 도구</span>
<div class="menu-dropdown">
<button data-act="Act_ITNumModal">내역서 공종번호 자동부여 (wITNum)</button>
<button data-act="Act_BoqRateModal">내역 항목 할증율 적용 (wBoqRate)</button>
<button data-act="Act_MRateModal">비목별 단수·금액 일괄보정 (wM_Rate)</button>
<button data-act="Act_ExpndModal">일위대가/산근 내역 풀어넣기 (wExpnd)</button>
<button data-act="Act_QtyModal">수량산출서 연결 집계 (wQtyList)</button>
</div>
</div>
<div class="menu-item" data-menu="m5">
<span><u>5</u>. 기타</span>
<div class="menu-dropdown">
<button data-act="Act_LoadGolden">봉화군 2024 산불진화임도 표준 상태 복원</button>
<button data-act="Act_ReaInfoModal">REA 정적분석 아키텍처 & DBF/SMT 명세 확인</button>
</div>
</div>
<div class="menubar-right">
<span id="conn-badge" class="conn-badge">DBF 엔진 대기중</span>
<select
id="top-project-select"
class="top-project-select"
title="PC 및 실무 STMATE 프로젝트 즉시 전환"
></select>
</div>
<h1>STMATE 계산 사슬 및 제어 옵션 시뮬레이터</h1>
<p>
기초자원 후보단가·노임환산(`ST_Sys`) → 중기·Q식 단가산출(`ST_SanCal`) →
일위대가(`ST_EdiLWi`) → 내역·OPTIMIZ(`ST_Func_C`) → 제잡비 4형식·끝전조정(`ST_KanJub`) →
변경설계 차액
</p>
</div>
<div class="top-actions">
<button id="btnPresetBonghwa" class="btn secondary" title="실무 검증형 예제 불러오기">
예제 초기화
<!-- 2. TWM_STMATE_START_8X 상단 8버튼 메인 툴바 (Tool_Bar) -->
<div class="win-toolbar" id="main-toolbar">
<button class="tb-btn active" data-screen="wPrjList" title="TWPRJLIST 공사목록표">
<span class="tb-ico">📁</span>
<span class="tb-txt">공사목록표</span>
</button>
<label class="btn secondary" for="fileOpen"
>JSON 열기<input id="fileOpen" type="file" accept=".json,application/json" hidden
/></label>
<button id="btnSaveJson" class="btn primary">JSON 저장</button>
</div>
</header>
<main class="container">
<div class="alert-banner">
<strong>시험·검증 전용 안내</strong>
<span
>이 웹앱은 Aislo 본체와 완전히 분리된 독립 테스트 도구입니다. 수록된 단가·요율은 계산
경로와 제어 옵션을 점검하기 위한 시험 데이터이며, 현행 법령·품셈 확정값이 아닙니다.</span
<button class="tb-btn" data-screen="wM_Set_Sys" title="초기값 입력 및 수정">
<span class="tb-ico">⚙️</span>
<span class="tb-txt">초 기 값</span>
</button>
<button class="tb-btn" data-screen="wM_JabPum" title="COSTN.DBF 단가목록표">
<span class="tb-ico">📋</span>
<span class="tb-txt">단가목록표</span>
</button>
<button
class="tb-btn"
data-screen="wM_Edit_iLWi"
title="일위대가·중기·단가산출근거(COSTN.SMT)"
>
<span class="tb-ico">🧮</span>
<span class="tb-txt">대 가 표</span>
</button>
<button class="tb-btn" data-screen="wBoq" title="BOQ11~14.DBF 계층 설계내역서">
<span class="tb-ico">📐</span>
<span class="tb-txt">설계내역서</span>
</button>
<button class="tb-btn" data-screen="wM_KanJub" title="TWM_KANJUB 제잡비 및 원가계산서">
<span class="tb-ico">🏛️</span>
<span class="tb-txt">제잡비계산</span>
</button>
<button class="tb-btn" data-screen="wM_KAN_RATE" title="TWM_KAN_RATE 제비율표 & JB_R">
<span class="tb-ico">📊</span>
<span class="tb-txt">제비율표</span>
</button>
<button class="tb-btn" data-screen="wStage" title="계약·실행·기성·설계변경">
<span class="tb-ico">🔄</span>
<span class="tb-txt">계약/기성/변경</span>
</button>
<button class="tb-btn" data-screen="PM_Prt" title="ST_Prt.CPM 30종 보고서 인쇄 및 변환">
<span class="tb-ico">🖨️</span>
<span class="tb-txt">보고서인쇄</span>
</button>
<div class="tb-sep"></div>
<button
class="tb-btn tb-calc"
id="btn-calc-all"
title="Act_Calc_All: 전 공정 재계산 및 DBF/SMT/STC 동기화 저장"
>
<span class="tb-ico">⚡</span>
<span class="tb-txt">계 산 완 료</span>
</button>
</div>
<section class="kpi-grid" id="kpiSummary" aria-label="핵심 계산 요약"></section>
<!-- 3. 현재 열린 프로젝트 컨텍스트 헤더 (PrjBar) -->
<div class="prj-context-bar" id="prj-context-bar"></div>
<nav class="tab-nav" id="mainTabs" aria-label="STMATE 기능 단계">
<button type="button" data-tab="db">00 원본 DBF · STC 연동</button>
<button type="button" data-tab="resources" class="active">01 기초자원 · 노임환산</button>
<button type="button" data-tab="machines">02 중기 · 단가산출(Q식)</button>
<button type="button" data-tab="ilwi">03 일위대가 조립</button>
<button type="button" data-tab="boq">04 내역서 · OPTIMIZ</button>
<button type="button" data-tab="kanjub">05 제잡비 · 원가계산</button>
<button type="button" data-tab="revision">06 변경설계 · REA 근거</button>
</nav>
<!-- 4. 메인 MDI 작업 화면 영역 -->
<main id="mdi-workspace" class="mdi-workspace"></main>
<section id="tabPanel" class="tab-panel" aria-live="polite"></section>
<!-- 5. 하단 고정 상태표시줄 및 실시간 원가 집계바 (St_Bar) -->
<footer id="stmate-statusbar" class="stmate-statusbar"></footer>
</div>
<footer class="footer-note">
<span
>모든 조작 상태는 현재 브라우저 탭(`sessionStorage`)에 자동 보관됩니다. 영구 보관하거나
다른 환경에서 열려면 <strong>JSON 저장 / 열기</strong>를 사용하세요.</span
>
</footer>
</main>
<!-- 범용 모달 컨테이너 (wITNum, wBoqRate, wM_Rate, wExpnd, wM_Boxa, wM_Edt_Hal, 표준카탈로그 등) -->
<div id="stmate-modal-backdrop" class="modal-backdrop hidden">
<div id="stmate-modal-window" class="modal-window">
<div class="modal-titlebar">
<span id="modal-title-text">STMATE 도구</span>
<button id="modal-close-btn" class="modal-close-btn">✕</button>
</div>
<div id="modal-body-content" class="modal-body"></div>
</div>
</div>
<script src="calc.js"></script>
<script src="view_prj_boq.js"></script>
<script src="view_dan_ilwi.js"></script>
<script src="view_kanjub_prt.js"></script>
<script src="app.js"></script>
</body>
</html>
+268 -298
View File
@@ -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("<IHH", b, 4)
fields: list[dict] = []
off = 32
while off < len(b) and b[off] != 0x0D:
raw_name = b[off : off + 11].split(b"\x00", 1)[0]
name = raw_name.decode("cp949", "replace").strip()
ftype = chr(b[off + 11])
flen = b[off + 16]
fdec = b[off + 17]
fields.append(
{
"name": name,
"type": ftype,
"len": flen,
"dec": fdec,
"raw_desc": b[off : off + 32].hex(),
}
)
off += 32
rows: list[dict] = []
for i in range(nrec):
pos = hlen + i * rlen
if pos + rlen > 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("<IHH", hdr, 4, nrec, hlen, rlen)
out = io.BytesIO()
out.write(hdr)
for f in fields:
if f.get("raw_desc"):
desc = bytearray(bytes.fromhex(f["raw_desc"]))
desc[16] = int(f["len"])
desc[17] = int(f.get("dec", 0))
out.write(desc)
else:
desc = bytearray(32)
nm_b = f["name"].encode("cp949", "replace")[:10]
desc[: len(nm_b)] = nm_b
desc[11] = ord((f.get("type") or "C")[0])
desc[16] = int(f["len"])
desc[17] = int(f.get("dec", 0))
out.write(desc)
out.write(tail_bytes)
for row in rows:
out.write(b"*" if row.get("_deleted") else b" ")
for f in fields:
flen = int(f["len"])
fdec = int(f.get("dec", 0))
ftype = f.get("type", "C")
raw_val = row.get(f["name"], "")
if ftype in ("N", "F"):
sval = str(raw_val).strip()
if sval == "" or sval == "None":
sval = ""
else:
try:
num = float(sval)
sval = f"{num:.{fdec}f}" if fdec > 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"
+375
View File
@@ -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("<IHH", b, 4)
fields: list[dict] = []
off = 32
while off < len(b) and b[off] != 0x0D:
name = b[off : off + 11].split(b"\x00", 1)[0].decode("cp949", "replace").strip()
fields.append(
{
"name": name,
"type": chr(b[off + 11]),
"len": b[off + 16],
"dec": b[off + 17],
"raw_desc": b[off : off + 32].hex(),
}
)
off += 32
rows: list[dict] = []
for i in range(nrec):
pos = hlen + i * rlen
if pos + rlen > 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("<IHH", hdr, 4, nrec, hlen, rlen)
out = io.BytesIO()
out.write(hdr)
for f in fields:
if f.get("raw_desc"):
desc = bytearray(bytes.fromhex(f["raw_desc"]))
desc[16], desc[17] = int(f["len"]), int(f.get("dec", 0))
out.write(desc)
else:
desc = bytearray(32)
nm_b = f["name"].encode("cp949", "replace")[:10]
desc[: len(nm_b)] = nm_b
desc[11], desc[16], desc[17] = (
ord((f.get("type") or "C")[0]),
int(f["len"]),
int(f.get("dec", 0)),
)
out.write(desc)
out.write(tail_bytes)
for row in rows:
out.write(b"*" if row.get("_deleted") else b" ")
for f in fields:
flen, fdec, ftype = int(f["len"]), int(f.get("dec", 0)), f.get("type", "C")
if f["name"] == "IFORM" and row.get("IFORM_HEX"):
try:
out.write(bytes.fromhex(row["IFORM_HEX"])[:flen].ljust(flen, b"\x00"))
continue
except Exception:
pass
raw_val = row.get(f["name"], "")
if ftype in ("N", "F"):
sval = str(raw_val).strip()
if sval in ("", "None"):
sval = ""
else:
try:
num = float(sval)
sval = f"{num:.{fdec}f}" if fdec > 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(<HII: tag, length, block_no)로 COSTN.SMT 본문 추출."""
if len(dbf_bytes) < 32 or not smt_bytes or smt_bytes[8:15] != b"SIxMemo":
return {}
num_records, header_len, record_len = struct.unpack("<IHH", dbf_bytes[4:12])
block_size = struct.unpack("<I", smt_bytes[4:8])[0] or 64
fields: dict[str, tuple[int, int]] = {}
pos = 32
offset = 1
while pos < header_len - 1 and dbf_bytes[pos] != 0x0D:
name = dbf_bytes[pos : pos + 11].split(b"\x00")[0].decode("ascii", errors="ignore").strip()
flen = dbf_bytes[pos + 16]
fields[name] = (offset, flen)
offset += flen
pos += 32
if "CODE" not in fields or "IFORM" not in fields:
return {}
code_off, code_len = fields["CODE"]
iform_off, iform_len = fields["IFORM"]
memos: dict[str, dict] = {}
for idx in range(num_records):
rec_start = header_len + idx * record_len
if rec_start + record_len > 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("<HII", iform[:10])
byte_start = block_no * block_size
if byte_len <= 0 or byte_start >= 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("<IHH", dbf_buf[4:12])
if not smt_bytes or smt_bytes[8:15] != b"SIxMemo":
smt_buf = bytearray(512)
struct.pack_into("<II", smt_buf, 0, 512, 64)
smt_buf[8:15] = b"SIxMemo"
else:
smt_buf = bytearray(smt_bytes)
block_size = struct.unpack("<I", smt_buf[4:8])[0] or 64
fields: dict[str, tuple[int, int]] = {}
pos = 32
offset = 1
while pos < header_len - 1 and dbf_buf[pos] != 0x0D:
name = dbf_buf[pos : pos + 11].split(b"\x00")[0].decode("ascii", errors="ignore").strip()
flen = dbf_buf[pos + 16]
fields[name] = (offset, flen)
offset += flen
pos += 32
if "CODE" not in fields or "IFORM" not in fields:
return bytes(dbf_buf), bytes(smt_buf)
code_off, code_len = fields["CODE"]
iform_off, iform_len = fields["IFORM"]
for idx in range(num_records):
rec_start = header_len + idx * record_len
if rec_start + record_len > 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("<HII", 0x0008, byte_len, block_no)
dbf_buf[rec_start + iform_off : rec_start + iform_off + min(10, iform_len)] = pointer_bytes[
: min(10, iform_len)
]
return bytes(dbf_buf), bytes(smt_buf)
_CATALOG_CACHE: dict | None = None
def load_stmate_catalog() -> 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
+555 -378
View File
@@ -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;
}
+595
View File
@@ -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 `
<div class="win-form">
<div class="win-form-header">
<span>⚙️ 초기값 입력 및 수정 [wM_Set_Sys · PRJLST.DBF 초기값 변수 42종]</span>
<span>노임환산식 좌→우 평가: ${evalRx.toFixed(6)}</span>
</div>
<div class="win-subbar">
<button class="win-btn primary" id="sys-btn-apply">⚡ 초기값 적용 및 전 공정 재계산</button>
<span style="color:#4b5563;">변경된 초기값은 기초단가·중기사용료·일위대가·내역서에 즉시 전파됩니다.</span>
</div>
<div class="split-vert" style="overflow:auto;">
<div class="pane-box" style="padding:10px;">
<div class="pane-title"><span>1. 환율 및 소수 자리수 설정 (EXLNM1/2 · KCD · ACD · ZCD · JCD · ECD · BPN)</span></div>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>당초 달러환율(EXLNM1)</label><input type="number" id="sys-usd1" value="${sys.usdRate1}" /></div>
<div class="kanjub-field"><label>변경 달러환율(EXLNM2)</label><input type="number" id="sys-usd2" value="${sys.usdRate2}" /></div>
<div class="kanjub-field"><label>환율적용 단계</label><select id="sys-usd-mode"><option value="0" ${!sys.useChangedUsd ? "selected" : ""}>당초환율(EXLNM1)</option><option value="1" ${sys.useChangedUsd ? "selected" : ""}>변경환율(EXLNM2)</option></select></div>
<div class="kanjub-field"><label>단가 자리수(ECD)</label><select id="sys-ecd"><option value="0" ${sys.ecdDigits === 0 ? "selected" : ""}>0자리(1원 정수)</option><option value="1" ${sys.ecdDigits === 1 ? "selected" : ""}>소수 1자리</option><option value="2" ${sys.ecdDigits === 2 ? "selected" : ""}>소수 2자리</option><option value="-1" ${sys.ecdDigits === -1 ? "selected" : ""}>10원 단위 절사</option></select></div>
<div class="kanjub-field"><label>내역 차액보정(CHNNM)</label><select id="sys-chn"><option value="bimok" ${sys.chnMode === "bimok" ? "selected" : ""}>비목우선 (M+L+S=합계)</option><option value="total" ${sys.chnMode === "total" ? "selected" : ""}>총액우선 (단가×수량 차액 보정)</option></select></div>
<div class="kanjub-field"><label>차액보정 순위(OPTNM)</label><select id="sys-opt"><option value="SML" ${sys.optOrder === "SML" ? "selected" : ""}>경비→재료→노무 (SML)</option><option value="MSL" ${sys.optOrder === "MSL" ? "selected" : ""}>재료→경비→노무 (MSL)</option><option value="LSM" ${sys.optOrder === "LSM" ? "selected" : ""}>노무→경비→재료 (LSM)</option></select></div>
</div>
</div>
<div class="pane-box" style="padding:10px;">
<div class="pane-title"><span>2. 노임 시간당 환산식 (RXNM_ · 좌→우 순차 평가 18번 §2.1) 및 특수노임 조건코드 (RX1~5CD)</span></div>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>기본 노임환산식(RXNM_)</label><input type="text" id="sys-rx-formula" value="${sys.rxFormula}" /></div>
<div class="kanjub-field"><label>환산 계수 적용 직종코드</label><input type="text" id="sys-cond-codes" value="${sys.condCodes}" /></div>
</div>
<p style="margin:6px 0 0;font-size:11px;color:#1e3a8a;">
※ STMATE 고유 파서 검증: <code>1/8*16/12*25/20</code> → 좌→우 순차 평가 <code>0.20833333</code> (기계운전원 일급 267,360원 × 0.20833333 = <strong>55,700원/hr</strong> 정확 재현)
</p>
</div>
<div class="pane-box" style="padding:10px;">
<div class="pane-title"><span>3. 현재 프로젝트 PRJLST.DBF 원본 초기값 필드</span></div>
<div class="kanjub-16grid" style="grid-template-columns:repeat(4,1fr);">
<div class="kanjub-field"><label>공사명(PNAME)</label><input type="text" readonly value="${prj.PNAME || ""}" /></div>
<div class="kanjub-field"><label>단가기준(PNUM)</label><input type="text" readonly value="${prj.PNUM || ""}" /></div>
<div class="kanjub-field"><label>내역번호(PCODE)</label><input type="text" readonly value="BOQ${prj.PCODE || "11"}" /></div>
<div class="kanjub-field"><label>진행단계(K_ORD)</label><input type="text" readonly value="${prj.K_ORD || "0"}" /></div>
</div>
</div>
</div>
</div>
`;
}
/**
* 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 `
<div class="win-form">
<div class="win-form-header">
<span>📋 단가목록표 [wM_JabPum · COSTN.DBF 전수 원장] — 총 ${rawCostn.length}건 중 ${filtered.length}건 표시</span>
<span>5개 가격지 후보(AMT_11~51) 및 적용단가(TAMT1 = MAMT1 + LAMT1 + SAMT1)</span>
</div>
<div class="win-subbar">
${Object.keys(KIND_LABELS)
.map(
(k) => `
<button class="win-btn dan-filter-btn ${filterKind === k ? "active" : ""}" data-filter="${k}">
${KIND_LABELS[k]} (${counts[k] || 0})
</button>
`,
)
.join("")}
<input type="text" id="dan-search-input" class="win-input" placeholder="명칭,규격 / 산근내용 검색..." value="${ctx.uiState.danSearch || ""}" style="width:185px;margin-left:auto;" />
<button class="win-btn" id="dan-btn-boxa">🔍 가격지 비교(wM_Boxa)</button>
<button class="win-btn" id="dan-btn-edthal">📐 기준단가 비율보정(wM_Edt_Hal)</button>
<button class="win-btn" id="dan-btn-catalog">📚 표준카탈로그(894종)</button>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:42px;">No</th>
<th style="width:76px;">코드(CODE)</th>
<th style="width:58px;">구분</th>
<th>명칭 (NAME)</th>
<th style="width:165px;">규격 (SIZE)</th>
<th style="width:55px;">단위</th>
<th style="width:98px;">재료단가(MAMT1)</th>
<th style="width:98px;">노무단가(LAMT1)</th>
<th style="width:98px;">경비단가(SAMT1)</th>
<th style="width:105px;">합계단가(TAMT1)</th>
<th style="width:82px;">1단가(조달)</th>
<th style="width:82px;">2단가(물가)</th>
<th style="width:82px;">3단가(거래)</th>
<th style="width:82px;">산근/대가</th>
</tr>
</thead>
<tbody>
${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 `
<tr data-code="${code}">
<td class="center">${i + 1}</td>
<td class="center"><strong>${code}</strong></td>
<td class="center">${p}</td>
<td>${r.NAME || ""}</td>
<td>${r.SIZE || ""}</td>
<td class="center">${r.UNIT || ""}</td>
<td class="num">
<input type="number" class="cell-input num dan-edit-m" data-code="${code}" value="${m1}" />
</td>
<td class="num">
<input type="number" class="cell-input num dan-edit-l" data-code="${code}" value="${l1}" />
</td>
<td class="num">
<input type="number" class="cell-input num dan-edit-s" data-code="${code}" value="${s1}" />
</td>
<td class="num"><strong>${fmt(t1)}</strong></td>
<td class="num">${num(r.AMT_11) ? fmt(r.AMT_11) : "-"}</td>
<td class="num">${num(r.AMT_21) ? fmt(r.AMT_21) : "-"}</td>
<td class="num">${num(r.AMT_31) ? fmt(r.AMT_31) : "-"}</td>
<td class="center">
${
hasMemo || p === "B" || p === "D" || p === "X"
? `<button class="win-btn dan-jump-ilwi" data-code="${code}" style="height:19px;padding:0 5px;">산근보기</button>`
: "-"
}
</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
}
/**
* 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 `
<div class="win-form">
<div class="win-form-header">
<span>🧮 일위대가 · 단가산출근거 · 중기사용료 편집기 [wM_Edit_iLWi / wM_Edit_San / COSTN.SMT]</span>
<span>선택 호표: ${selCode} ${selRow.NAME || ""} (SIxMemo 블록 #${memoObj ? memoObj.blockNo : "-"}, ${memoObj ? memoObj.byteLen : 0} bytes)</span>
</div>
<div class="win-subbar">
<button class="win-btn ilwi-sub-btn ${subFilter === "ALL_BDX" ? "active" : ""}" data-sub="ALL_BDX">전체 대가·산근·중기 (${bdxRows.length})</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "SMT_ONLY" ? "active" : ""}" data-sub="SMT_ONLY">COSTN.SMT 산식보유 (${Object.keys(memos).length})</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "D" ? "active" : ""}" data-sub="D">단가산출 D</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "B" ? "active" : ""}" data-sub="B">일위대가 B</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "X" ? "active" : ""}" data-sub="X">중기사용료 X</button>
<button class="win-btn" id="ilwi-btn-load-recipe" style="margin-left:auto;">📥 표준 레시피(350종)/특수산식(21종) 불러오기</button>
<button class="win-btn primary" id="ilwi-btn-apply-f9" data-code="${selCode}">⚡ F9 산식 계산결과를 호표단가에 반영</button>
</div>
<div class="split-horiz">
<!-- 좌측: B / D / X 호표 목록 -->
<div class="pane-box" style="width:430px;">
<div class="pane-title">
<span>호표 마스터 목록 (클릭 시 우측 COSTN.SMT 전개식 로드)</span>
<span>총 ${bdxRows.length}건</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:66px;">코드</th>
<th>품명 (NAME)</th>
<th style="width:95px;">규격</th>
<th style="width:42px;">단위</th>
<th style="width:82px;">합계단가</th>
<th style="width:45px;">SMT</th>
</tr>
</thead>
<tbody>
${bdxRows
.map((r) => {
const c = String(r.CODE || "").trim();
const isSel = c === selCode;
const hasM = Boolean(memos[c]);
return `
<tr class="ilwi-master-row ${isSel ? "selected" : ""}" data-code="${c}" style="cursor:pointer;">
<td class="center"><strong>${c}</strong></td>
<td>${r.NAME || ""}</td>
<td>${r.SIZE || ""}</td>
<td class="center">${r.UNIT || ""}</td>
<td class="num"><strong>${fmt(num(r.TAMT1))}</strong></td>
<td class="center">${hasM ? "📝" : "-"}</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
<!-- 우측: COSTN.SMT 수식 편집기 + 실시간 평가 분석기 -->
<div class="split-vert" style="flex:1;padding:0;">
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>📝 COSTN.SMT 전개 텍스트 편집기 — <code>&amp;X00026L&amp;</code> 참조 · <code>Q=3600*q*K*f*E/Cm</code> · <code>:=A01</code> 문법 실시간 해석</span>
<span>DB 저장단가: 재 ${fmt(num(selRow.MAMT1))} / 노 ${fmt(num(selRow.LAMT1))} / 경 ${fmt(num(selRow.SAMT1))} / 계 ${fmt(num(selRow.TAMT1))}원</span>
</div>
<textarea id="smt-formula-textarea" class="smt-editor-area" data-code="${selCode}" spellcheck="false">${currentSmtText}</textarea>
</div>
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>🔍 SIxMemo 수식 파서 실시간 평가 결과 (변수: ${
Object.keys(evalRes.vars)
.map((k) => `${k}=${evalRes.vars[k]}`)
.join(", ") || "없음"
})</span>
<span>평가 합계: 재 ${fmt(evalRes.sumM)} + 노 ${fmt(evalRes.sumL)} + 경 ${fmt(evalRes.sumS)} = <strong>${fmt(evalRes.sumT)}원</strong></span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:40px;">행</th>
<th>산식 원문 (COSTN.SMT Line)</th>
<th style="width:150px;">변수 대입/평가</th>
<th style="width:120px;">자원참조(&amp;코드&amp;)</th>
<th style="width:95px;">참조단가</th>
<th style="width:95px;">산출금액</th>
</tr>
</thead>
<tbody>
${evalRes.linesOut
.map(
(ln) => `
<tr>
<td class="center">${ln.lineNo}</td>
<td style="font-family:Consolas,monospace;">${ln.raw}</td>
<td style="color:#0050b3;font-weight:700;">${ln.assigned || ""}</td>
<td class="center">${ln.refInfo ? `${ln.refInfo.refCode}[${ln.refInfo.compTag}] (${ln.refInfo.refName})` : ""}</td>
<td class="num">${ln.refInfo ? fmt(ln.refInfo.baseUnitPrice) : ""}</td>
<td class="num"><strong>${ln.refInfo ? fmt(ln.refInfo.calcAmt, 1) : ""}</strong></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
`;
}
/**
* 4. 단가/카탈로그 모달 생성기 (wM_Boxa / wM_Edt_Hal / 표준카탈로그 894코드·350레시피·21특수산식)
*/
function buildDanModalHtml(modalKind, ctx) {
if (modalKind === "wM_Boxa") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wM_Boxa] 5개 가격지 단가 비교·일괄 채택</strong> — 자재 후보단가(AMT_11~51) 중 지정 슬롯 또는 최저가를 적용단가(AMT_61/MAMT1)로 일괄 채택합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>채택 기준</label>
<select id="boxa-adopt-mode">
<option value="min">최소단가 (0원 제외 최저가 자동채택)</option>
<option value="0">1단가 (조달청 가격정보 AMT_11)</option>
<option value="1">2단가 (물가자료 AMT_21)</option>
<option value="2">3단가 (물가정보 AMT_31)</option>
<option value="3">4단가 (유통물가 AMT_41)</option>
<option value="4">5단가 (거래가격 AMT_51)</option>
</select>
</div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-boxa">자재 적용단가 일괄 변경</button></div>
</div>
`;
}
if (modalKind === "wM_Edt_Hal") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wM_Edt_Hal] 기준단가 비율 일괄 수정</strong> — <code>적용단가 = 기준단가 × 비율(%)</code> (적용단가 0원 및 보호항목 UC=T 제외).</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>대상 비목</label><select id="edthal-kind"><option value="ALL">전체 (재·노·경)</option><option value="M">재료비(M)만</option><option value="L">노무비(L)만</option><option value="S">경비(S)만</option></select></div>
<div class="kanjub-field"><label>적용 비율(%)</label><input type="number" id="edthal-pct" value="98" step="0.5" /></div>
<div class="kanjub-field"><label>끝전 처리</label><select id="edthal-round"><option value="trunc">원단위 절사</option><option value="round">원단위 반올림</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-edthal">기준단가 비율 일괄 수정 실행</button></div>
</div>
`;
}
// 표준 카탈로그 (894 코드 · 350 일위대가 레시피 · 21 특수 단가산출)
const cat = ctx.catalogData || { codes: [], recipes: [], special_san: [] };
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;">
<strong>[STMATE 표준 카탈로그 브라우저]</strong> — <code>30_원자료</code>에서 추출한 표준코드 <strong>${cat.codes.length}종</strong> · 일위대가 레시피 <strong>${cat.recipes.length}종</strong> · 특수 단가산출 <strong>${cat.special_san.length}종</strong>
</p>
<div class="split-horiz" style="height:400px;padding:0;">
<div class="pane-box" style="flex:1;">
<div class="pane-title"><span>특수 단가산출 21개 (Q식/사이클식 원문)</span></div>
<div class="grid-scroll">
<table class="dbgrid">
<thead><tr><th>호표</th><th>명칭</th><th>단위</th><th>Q식</th><th>편집기로 복사</th></tr></thead>
<tbody>
${(cat.special_san || [])
.map(
(s, idx) => `
<tr>
<td class="center">#${s["호표"]}</td>
<td><strong>${s["명칭"]}</strong></td>
<td class="center">${s["단위"]}</td>
<td style="font-family:Consolas,monospace;">${(s["인자"] && s["인자"].Q) || ""}</td>
<td class="center"><button class="win-btn btn-use-special-san" data-idx="${idx}">산식적용</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
<div class="pane-box" style="flex:1;">
<div class="pane-title"><span>표준 일위대가 레시피 (${cat.recipes.length}종 중 상위 60종)</span></div>
<div class="grid-scroll">
<table class="dbgrid">
<thead><tr><th>구분</th><th>명칭</th><th>규격</th><th>단위</th><th>구성수</th></tr></thead>
<tbody>
${(cat.recipes || [])
.slice(0, 60)
.map(
(r) => `
<tr>
<td class="center">${r["종류"]}</td>
<td><strong>${r["명칭"]}</strong></td>
<td>${r["규격"] || ""}</td>
<td class="center">${r["단위"] || ""}</td>
<td class="center">${(r["구성"] || []).length}행</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
}
global.STMATE_VIEW_DAN_ILWI = {
evaluateSmtFormulaText,
renderWMSetSys,
renderWMJabPum,
renderWMEditIlwi,
buildDanModalHtml,
};
})(window);
+630
View File
@@ -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 `
<div class="win-form">
<div class="win-form-header">
<span>🏛️ 제잡비 계산 및 원가계산서 [TWM_KANJUB · PRJLST.SMT ${Object.keys(smt).length}개 키 연동]</span>
<span>직접공사비: ${fmt(kj.DIRECT_COST)}원 → 도급액: ${fmt(kj.FINAL_SUPPLY)}원 + 관급자재: ${fmt(kj.GOV_MAT)}원 = 총공사비: ${fmt(kj.GRAND_TOTAL)}원</span>
</div>
<!-- TWM_KANJUB 상단 툴바 -->
<div class="win-subbar">
<label>차수(cBx_Chasu):</label>
<select id="kj-chasu" class="win-select">
<option value="1">원설계 (1_R_*)</option>
<option value="2">1차변경 (2_R_*)</option>
<option value="3">2차변경 (3_R_*)</option>
</select>
<label>산정방식(cBx_W_Type):</label>
<select id="kj-wtype" class="win-select">
<option value="0" ${kc.scheme === "T" || kc.scheme === "S" ? "selected" : ""}>제잡비 원가계산방식</option>
<option value="1" ${kc.scheme === "TA" || kc.scheme === "SA" ? "selected" : ""}>제잡비 표준시장방식(실적)</option>
</select>
<label>기준요율표(ST_BSC/*.JB_R):</label>
<select id="kj-jbr-select" class="win-select" style="width:190px;">
${jbrFiles.map((f) => `<option value="${f.path}" ${f.name.includes("2024") ? "selected" : ""}>${f.name}</option>`).join("")}
</select>
<button class="win-btn" id="kj-btn-apply-jbr">📥 JB_R 기준요율 적용</button>
<button class="win-btn" id="kj-btn-open-rate">📊 제비율표 편집(wM_KAN_RATE)</button>
<button class="win-btn primary" id="kj-btn-save-dbf" style="margin-left:auto;">💾 제잡비 계산결과 DBF/SMT 저장</button>
</div>
<!-- Pn0_Input: 16개 제잡비 설정 콤보 패널 (22번 문서 §5.1) -->
<div class="kanjub-16grid">
<div class="kanjub-field"><label>1. 공사종류(PCLASS)</label><select id="kj-opt-pclass"><option value="0">일반토목/산림토목</option><option value="1">건축공사</option><option value="2">조경공사</option><option value="7">전문/설계변경</option></select></div>
<div class="kanjub-field"><label>2. 공사기간(개월)</label><select id="kj-opt-term"><option value="2">6개월~12개월</option><option value="1">6개월 미만</option><option value="3">12개월 초과</option></select></div>
<div class="kanjub-field"><label>3. 고용보험 등급</label><select id="kj-opt-kobo"><option value="1.01">1등급~특등급 (1.01%)</option><option value="0.9">5등급 이하 (0.90%)</option></select></div>
<div class="kanjub-field"><label>4. 퇴직공제부금</label><select id="kj-opt-tbugum"><option value="0">적용안함 (0%)</option><option value="2.3">적용 (직접노무비×2.3%)</option></select></div>
<div class="kanjub-field"><label>5. 산안비 관급포함</label><select id="kj-opt-anjun-gov"><option value="1" ${kc.anjunGovVatExcluded ? "selected" : ""}>관급자재(부가세제외 ÷1.1) 포함</option><option value="0" ${!kc.anjunGovVatExcluded ? "selected" : ""}>관급 미포함 (재+직노만)</option></select></div>
<div class="kanjub-field"><label>6. 산안비 기초가산액</label><input type="number" id="kj-anjun-base" value="${kc.anjunBaseAdd}" step="1000" /></div>
<div class="kanjub-field"><label>7. 산안비 1.2배 상한</label><select id="kj-anjun-cap"><option value="1" ${kc.anjunCap12 ? "selected" : ""}>MIN(가산식, 무가산×1.2) 적용</option><option value="0" ${!kc.anjunCap12 ? "selected" : ""}>상한 미적용</option></select></div>
<div class="kanjub-field"><label>8. 간접비 절사단위</label><select id="kj-ind-trunc"><option value="0" ${kc.indirectTruncDigits === 0 ? "selected" : ""}>1원 단위 절사</option><option value="-1" ${kc.indirectTruncDigits === -1 ? "selected" : ""}>10원 단위 절사</option><option value="-3" ${kc.indirectTruncDigits === -3 ? "selected" : ""}>1,000원 단위 절사</option></select></div>
<div class="kanjub-field"><label>9. 부가가치세 기준</label><select id="kj-vat-mode"><option value="supply_10" ${kc.vatMode === "supply_10" ? "selected" : ""}>(1) 총원가의 10%</option><option value="mat_10" ${kc.vatMode === "mat_10" ? "selected" : ""}>(2) 재료비의 10%(면세)</option></select></div>
<div class="kanjub-field"><label>10. 도급액 절사(TOT_JEOL)</label><select id="kj-tot-jeol"><option value="-3" ${kc.totJeolDigits === -3 ? "selected" : ""}>1,000원 단위 절사</option><option value="-4" ${kc.totJeolDigits === -4 ? "selected" : ""}>10,000원 단위 절사</option><option value="0" ${kc.totJeolDigits === 0 ? "selected" : ""}>절사 안함(1원)</option></select></div>
<div class="kanjub-field"><label>11. 이윤 세전 끝전조정</label><select id="kj-profit-adj"><option value="1" ${kc.enableProfitEndAdj ? "selected" : ""}>켬 (이윤에서 세전 역산 차감)</option><option value="0" ${!kc.enableProfitEndAdj ? "selected" : ""}>끔 (소계에서 단순 차감)</option></select></div>
<div class="kanjub-field"><label>12. 관급자재 단수처리</label><select id="kj-gov-round"><option value="up" ${kc.govRoundMode === "up" ? "selected" : ""}>천원 올림(ROUNDUP,-3)</option><option value="down" ${kc.govRoundMode === "down" ? "selected" : ""}>천원 절사(ROUNDDOWN,-3)</option><option value="none" ${kc.govRoundMode === "none" ? "selected" : ""}>원단위 그대로</option></select></div>
</div>
<!-- Pg_Ctrl: 하단 4탭 (일반형식 T / 수공형식 S / 실적일반 TA / 실적수공 SA) -->
<div class="win-subbar" style="background:#dbeafe;">
<strong>원가계산서 양식 탭 (Pg_Ctrl):</strong>
${[
{ id: "T", label: "탭1: 일반형식 (T · 재+노+경)" },
{ id: "S", label: "탭2: 수공형식 (S · 경비 16세부비목)" },
{ id: "TA", label: "탭3: 실적일반 (TA · 표준시장단가 병행)" },
{ id: "SA", label: "탭4: 실적수공 (SA · 실적+수공)" },
]
.map(
(t) => `
<button class="win-btn kj-scheme-tab ${kc.scheme === t.id ? "active" : ""}" data-scheme="${t.id}">
${t.label}
</button>
`,
)
.join("")}
<span style="margin-left:auto;color:#1e3a8a;font-weight:700;">
이윤 끝전조정: ${fmt(kj.PROFIT_ADJ)}원 (초기 이윤 ${fmt(kj.IYUN_INIT)}원 → 최종 이윤 ${fmt(kj.IYUN)}원)
</span>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:45px;">순번</th>
<th style="width:120px;">비목코드</th>
<th style="width:220px;">원가계산서 항목명</th>
<th>산정기초 공식 및 밑수 설명 (REA 03·04·16번 문서 검증)</th>
<th style="width:140px;">산정기초 금액(원)</th>
<th style="width:105px;">적용요율(%)</th>
<th style="width:150px;">산출금액(원)</th>
</tr>
</thead>
<tbody>
${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 `
<tr class="${isStrong ? "boq-row-T" : ""}">
<td class="center">${idx + 1}</td>
<td class="center"><code>${ln.key}</code></td>
<td><strong>${ln.label}</strong></td>
<td>${ln.formula}</td>
<td class="num">${ln.base !== null && ln.base !== undefined ? fmt(ln.base) : "-"}</td>
<td class="num">
${
isRateEditable
? `<input type="number" step="0.001" class="cell-input num kj-rate-input" data-key="${ln.key}" value="${ln.rate}" />`
: ln.rate !== null && ln.rate !== undefined
? fmt(ln.rate, 3) + "%"
: "-"
}
</td>
<td class="num"><strong>${fmt(ln.amount)}</strong></td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
}
/**
* 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 `
<div class="win-form">
<div class="win-form-header">
<span>📊 원가계산기준 제비율표 조회·수정 [TWM_KAN_RATE · ${activeTab} (${rows.length}행 × ${colKeys.length}열)]</span>
<span>행20(선택적용) · 행45(산재/고용) · 행46(건보/연금/요양) 좌표 매핑 검증 완료</span>
</div>
<div class="win-subbar">
${[
{ 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) => `
<button class="win-btn kr-tab-btn ${activeTab === t.id ? "active" : ""}" data-tab="${t.id}">
${t.label}
</button>
`,
)
.join("")}
<select id="kr-jbr-select" class="win-select" style="margin-left:auto;width:210px;">
${jbrFiles.map((f) => `<option value="${f.path}">${f.name}</option>`).join("")}
</select>
<button class="win-btn primary" id="kr-btn-load-jbr">📂 선택한 ST_BSC/*.JB_R 불러오기</button>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:45px;">행(0~)</th>
<th style="width:240px;">STMATE 행 좌표 의미</th>
${colKeys.map((ck) => `<th style="width:72px;">${ck}</th>`).join("")}
</tr>
</thead>
<tbody>
${rows
.map((r, rIdx) => {
const highlight = ROW_MEANINGS[rIdx] ? "boq-row-T" : "";
return `
<tr class="${highlight}">
<td class="center"><strong>#${rIdx}</strong></td>
<td>${ROW_MEANINGS[rIdx] || `구간/공사종류 데이터 행 #${rIdx}`}</td>
${colKeys.map((ck) => `<td class="num">${r[ck]}</td>`).join("")}
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
}
/**
* 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 `
<div class="win-form">
<div class="win-form-header">
<span>🔄 설계 후속 단계 관리 [27번 문서 — 계약내역(wM_Mk_Cont) · 실행예산(wM_Boq_ExecX) · 기성내역(wM_KanJub_K) · 설계변경 대비]</span>
<span>K_ORD 단계 전이: 설계(0) → 계약(1) → 변경(2) → 실행(3) → 기성(4)</span>
</div>
<div class="win-subbar">
<button class="win-btn stage-tab-btn ${stageSub === "CONTRACT" ? "active" : ""}" data-st="CONTRACT">1. 계약내역 변환 (wM_Mk_Cont · 낙찰율/목표금액 역산)</button>
<button class="win-btn stage-tab-btn ${stageSub === "EXEC" ? "active" : ""}" data-st="EXEC">2. 실행예산 (wM_Boq_ExecX · 실행단가 보정)</button>
<button class="win-btn stage-tab-btn ${stageSub === "GISUNG" ? "active" : ""}" data-st="GISUNG">3. 기성내역/제잡비 (wM_KanJub_K · 전회/금회/누계)</button>
<button class="win-btn stage-tab-btn ${stageSub === "REVISION" ? "active" : ""}" data-st="REVISION">4. 설계변경 증감대비표 (당초 vs 변경)</button>
</div>
${
stageSub === "CONTRACT"
? `
<div class="kanjub-16grid" style="grid-template-columns:repeat(4,1fr);">
<div class="kanjub-field"><label>재료비 낙찰율(%)</label><input type="number" id="st-bid-m" value="${bidRateM}" step="0.001" /></div>
<div class="kanjub-field"><label>노무비 낙찰율(%)</label><input type="number" id="st-bid-l" value="${bidRateL}" step="0.001" /></div>
<div class="kanjub-field"><label>경비 낙찰율(%)</label><input type="number" id="st-bid-s" value="${bidRateS}" step="0.001" /></div>
<div class="kanjub-field"><button class="win-btn primary" id="st-btn-apply-bid">계약단가(TAMT2) 일괄 산출</button></div>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>계약내역서 (설계단가 × 낙찰율 → 계약단가 및 계약금액 산출 · 적용제외 공정 보호 지원)</span>
<span>설계 직공비: ${fmt(comp.boqComputed.totals.directT)}원 → 계약 직공비: ${fmt(Math.floor((comp.boqComputed.totals.directT * bidRateM) / 100))}원</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr><th>코드</th><th>공종명</th><th>규격</th><th>단위</th><th>수량</th><th>설계단가</th><th>설계금액</th><th>계약단가(낙찰적용)</th><th>계약금액</th></tr>
</thead>
<tbody>
${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 `
<tr>
<td class="center">${r.code}</td>
<td>${r.name}</td>
<td>${r.spec}</td>
<td class="center">${r.unit}</td>
<td class="num">${fmt(r.qty, 2)}</td>
<td class="num">${fmt(r.tUnit)}</td>
<td class="num">${fmt(r.tAmt)}</td>
<td class="num"><strong>${fmt(cT)}</strong></td>
<td class="num"><strong>${fmt(cAmt)}</strong></td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
</div>`
: ""
}
${
stageSub === "EXEC"
? `
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="pane-title"><span>[wM_Boq_ExecX] 실행예산 내역서 (계약금액 대비 현장 실행예산율 92.0% 기준 비교)</span></div>
<div class="grid-scroll">
<table class="dbgrid">
<thead><tr><th>코드</th><th>공종명</th><th>수량</th><th>설계금액</th><th>실행단가(92%)</th><th>실행예산금액</th><th>절감액(차액)</th></tr></thead>
<tbody>
${leafRows
.map((r) => {
const exUnit = Math.floor(r.tUnit * 0.92);
const exAmt = Math.floor(r.qty * exUnit);
return `
<tr>
<td class="center">${r.code}</td>
<td>${r.name}</td>
<td class="num">${fmt(r.qty, 2)}</td>
<td class="num">${fmt(r.tAmt)}</td>
<td class="num">${fmt(exUnit)}</td>
<td class="num"><strong>${fmt(exAmt)}</strong></td>
<td class="num" style="color:#137333;">${fmt(r.tAmt - exAmt)}</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
</div>`
: ""
}
${
stageSub === "GISUNG"
? `
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>전회 누적 기성율(%)</label><input type="number" id="st-prev-pct" value="${prevPct}" step="5" /></div>
<div class="kanjub-field"><label>금회 포함 누계기성율(%)</label><input type="number" id="st-prog-pct" value="${progPct}" step="5" /></div>
<div class="kanjub-field"><button class="win-btn primary" id="st-btn-apply-gisung">기성조서(전회·금회·누계·잔여) 재계산</button></div>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>[wM_KanJub_K] 기성내역 및 기성 제잡비 계산 (금회직공비 × 계약제잡비율 · 계약 | 전회 | 금회 | 누계 | 잔여)</span>
<span>금회 기성율: ${(progPct - prevPct).toFixed(1)}% | 누계 기성도급액: ${fmt(Math.floor((comp.kanjubComputed.FINAL_SUPPLY * progPct) / 100))}원</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr><th>코드</th><th>공종명</th><th>계약수량</th><th>계약금액</th><th>전회기성액(${prevPct}%)</th><th>금회기성수량</th><th>금회기성액(${(progPct - prevPct).toFixed(1)}%)</th><th>누계기성액(${progPct}%)</th><th>잔여금액</th></tr>
</thead>
<tbody>
${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 `
<tr>
<td class="center">${r.code}</td>
<td>${r.name}</td>
<td class="num">${fmt(r.qty, 2)}</td>
<td class="num">${fmt(r.tAmt)}</td>
<td class="num">${fmt(prevA)}</td>
<td class="num">${fmt(curQ, 2)}</td>
<td class="num"><strong>${fmt(curA)}</strong></td>
<td class="num"><strong>${fmt(cumA)}</strong></td>
<td class="num">${fmt(r.tAmt - cumA)}</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
</div>`
: ""
}
${
stageSub === "REVISION"
? `
<div class="split-vert">
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>설계변경 원가계산 대비표 (당초 도급액: ${fmt(comp.kanjubComputed.FINAL_SUPPLY)}원 vs 변경 도급액: ${fmt(revComp.revKj.FINAL_SUPPLY)}원 · 사후정산 건보/요양/연금 당초고정 지원)</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr><th>코드</th><th>공종명</th><th>당초수량(QTY1)</th><th>변경수량(QTY2)</th><th>단가</th><th>당초금액</th><th>변경금액</th><th>증감액(차액)</th></tr>
</thead>
<tbody>
${leafRows
.map(
(r) => `
<tr>
<td class="center">${r.code}</td>
<td>${r.name}</td>
<td class="num">${fmt(r.qty, 2)}</td>
<td class="num"><input type="number" step="1" class="cell-input num rev-edit-qty2" data-id="${r.id}" value="${r.changedQty}" /></td>
<td class="num">${fmt(r.tUnit)}</td>
<td class="num">${fmt(r.tAmt)}</td>
<td class="num"><strong>${fmt(r.changedTAmt)}</strong></td>
<td class="num" style="color:${r.diffTAmt >= 0 ? "#0050b3" : "#c5221f"};"><strong>${fmt(r.diffTAmt)}</strong></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
</div>`
: ""
}
</div>
`;
}
/**
* 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 `
<div class="win-form">
<div class="win-form-header">
<span>🖨️ 보고서 인쇄 및 엑셀/조달청 XML/JDL 변환 [PM_Prt · ST_Prt.CPM 30종 시트 · SET_REP.DBF ${setRepRows.length}종 양식]</span>
<span>인쇄 옵션: 굴림체 · 비고=코드/호표 · 수량/단위 표준 · 일반양식</span>
</div>
<div class="win-subbar">
<label>보고서 양식(SET_REP):</label>
<select id="prt-sheet-select" class="win-select" style="width:240px;">
<option value="0608" ${activeRep === "0608" ? "selected" : ""}>[0608] 공사원가계산서 (PM_Won_A)</option>
<option value="0603" ${activeRep === "0603" ? "selected" : ""}>[0603] 설계내역서 (PM_Nae)</option>
<option value="0607" ${activeRep === "0607" ? "selected" : ""}>[0607] 관급자재내역서 (PM_Nae_Kwn)</option>
<option value="0302" ${activeRep === "0302" ? "selected" : ""}>[0302] 일위대가표 / 목록표 (PM_iLWi)</option>
<option value="0402" ${activeRep === "0402" ? "selected" : ""}>[0402] 단가산출근거 (Edit_SanKun)</option>
<option value="0203" ${activeRep === "0203" ? "selected" : ""}>[0203] 시간당중기사용료 (PM_JOONG)</option>
<option value="0101" ${activeRep === "0101" ? "selected" : ""}>[0101~0105] 노무·재료·경비·단가대비표</option>
<option value="SET_REP_ALL" ${activeRep === "SET_REP_ALL" ? "selected" : ""}>[전체] SET_REP.DBF 75종 양식 설정표</option>
</select>
<button class="win-btn primary" id="prt-btn-csv">📊 현재 시트 CSV(엑셀호환) 다운로드</button>
<button class="win-btn" id="prt-btn-xml">🏛️ 조달청 호환규정 XML 내보내기(uM_XML_Info)</button>
<button class="win-btn" id="prt-btn-jdl">📄 조달청 JDL 전자내역 파일 내보내기</button>
<button class="win-btn" onclick="window.print()">🖨️ 브라우저 인쇄</button>
</div>
<div class="split-vert">
<div class="pane-box" style="flex:1;padding:16px;overflow:auto;background:#fff;">
<h2 style="text-align:center;margin:0 0 6px;letter-spacing:4px;color:#0f294d;">
${
activeRep === "0608"
? "공 사 원 가 계 산 서"
: activeRep === "0603"
? "설 계 내 역 서"
: activeRep === "0607"
? "관 급 자 재 내 역 서"
: activeRep === "0302"
? "일 위 대 가 표 (목 록)"
: activeRep === "0402"
? "단 가 산 출 근 거"
: activeRep === "0203"
? "시 간 당 중 기 사 용 료"
: "자 재 · 노 무 · 경 비 단 가 목 록 표"
}
</h2>
<p style="text-align:center;margin:0 0 12px;font-weight:700;color:#334155;">
공사명: ${ctx.state.projectMeta.pname} (총공사비: ${fmt(kj.GRAND_TOTAL)}원 / 도급액: ${fmt(kj.FINAL_SUPPLY)}원)
</p>
${
activeRep === "0608"
? `<table class="dbgrid">
<thead><tr><th>비목</th><th>산정기초(원)</th><th>요율(%)</th><th>금액(원)</th><th>산출근거</th></tr></thead>
<tbody>
${kj.lines
.map(
(ln) => `
<tr>
<td><strong>${ln.label}</strong></td>
<td class="num">${ln.base ? fmt(ln.base) : ""}</td>
<td class="num">${ln.rate !== null && ln.rate !== undefined ? fmt(ln.rate, 3) + "%" : ""}</td>
<td class="num"><strong>${fmt(ln.amount)}</strong></td>
<td>${ln.formula}</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
: ""
}
${
activeRep === "0603" || activeRep === "0607"
? `<table class="dbgrid">
<thead><tr><th>공종</th><th>명칭</th><th>규격</th><th>단위</th><th>수량</th><th>재료비단가</th><th>노무비단가</th><th>경비단가</th><th>합계단가</th><th>합계금액</th><th>비고</th></tr></thead>
<tbody>
${boqRows
.filter((r) => (activeRep === "0607" ? r._isExcl : true))
.map(
(r) => `
<tr class="${r._rowClass}">
<td class="center">${r.ITNUM || ""}</td>
<td>${r.NAME1 || ""}</td>
<td>${r.SIZE1 || ""}</td>
<td class="center">${r.UNIT1 || ""}</td>
<td class="num">${r.QTY1 ? fmt(r.QTY1, 2) : ""}</td>
<td class="num">${r.MAMT1 ? fmt(r.MAMT1) : ""}</td>
<td class="num">${r.LAMT1 ? fmt(r.LAMT1) : ""}</td>
<td class="num">${r.SAMT1 ? fmt(r.SAMT1) : ""}</td>
<td class="num">${r.TAMT1 ? fmt(r.TAMT1) : ""}</td>
<td class="num"><strong>${r.T_AMT1 ? fmt(r.T_AMT1) : ""}</strong></td>
<td class="center">${r.BDCODE1 || ""}</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
: ""
}
${
activeRep === "0402"
? Object.values(memos)
.map(
(m) => `
<div style="border:1px solid #94a3b8;padding:8px;margin-bottom:8px;">
<div style="font-weight:700;background:#eef2f8;padding:4px 8px;margin-bottom:4px;">[${m.code}] ${(costnRows.find((x) => x.CODE === m.code) || {}).NAME || ""}</div>
<pre style="margin:0;font-family:Consolas,monospace;font-size:11.5px;white-space:pre-wrap;">${m.text}</pre>
</div>`,
)
.join("")
: ""
}
${
activeRep === "0302" || activeRep === "0203" || activeRep === "0101"
? `<table class="dbgrid">
<thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>재료비</th><th>노무비</th><th>경비</th><th>합계단가</th></tr></thead>
<tbody>
${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) => `
<tr>
<td class="center"><strong>${r.CODE}</strong></td>
<td>${r.NAME || ""}</td>
<td>${r.SIZE || ""}</td>
<td class="center">${r.UNIT || ""}</td>
<td class="num">${fmt(num(r.MAMT1))}</td>
<td class="num">${fmt(num(r.LAMT1))}</td>
<td class="num">${fmt(num(r.SAMT1))}</td>
<td class="num"><strong>${fmt(num(r.TAMT1))}</strong></td>
</tr>`,
)
.join("")}
</tbody>
</table>`
: ""
}
${
activeRep === "SET_REP_ALL"
? `<table class="dbgrid">
<thead><tr><th>양식코드(INDX)</th><th>보고서명(NAME)</th><th>인쇄 표제(HEADTITLE)</th><th>컬럼수(FLDCNT)</th><th>필드1</th><th>필드2</th><th>필드3</th><th>필드4</th></tr></thead>
<tbody>
${setRepRows
.map(
(r) => `
<tr>
<td class="center"><strong>${r.INDX}</strong></td>
<td>${r.NAME}</td>
<td><strong>${r.HEADTITLE}</strong></td>
<td class="center">${r.FLDCNT}</td>
<td>${r.FLD_N1 || ""}</td>
<td>${r.FLD_N2 || ""}</td>
<td>${r.FLD_N3 || ""}</td>
<td>${r.FLD_N4 || ""}</td>
</tr>`,
)
.join("")}
</tbody>
</table>`
: ""
}
</div>
</div>
</div>
`;
}
global.STMATE_VIEW_KANJUB_PRT = {
renderWMKanJub,
renderWMKanRate,
renderWStage,
renderPMPrt,
};
})(window);
+681
View File
@@ -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 `
<div class="win-form">
<div class="win-form-header">
<span>📁 공사목록표 [TWPRJLIST · PRJLST.DBF / PRJLST.SMT] — 총 ${sources.length}개 프로젝트 연결됨</span>
<span>더블클릭 시 설계내역서(wBoq) 진입</span>
</div>
<!-- TWPRJLIST 상단 액션 툴바 -->
<div class="win-subbar">
<button class="win-btn primary" id="prj-btn-open-boq">📐 해당분 수정 (설계내역서 wBoq)</button>
<button class="win-btn" id="prj-btn-kanjub">🏛️ 제잡비 계산 (wM_KanJub)</button>
<button class="win-btn" id="prj-btn-edit-meta">✏️ 등록정보 수정</button>
<button class="win-btn" id="prj-btn-copy-reg">📋 복사등록 (차수/단계 변환)</button>
<button class="win-btn" id="prj-btn-pack-stc">🗜️ 해당분만 압축하기 (.STC 생성)</button>
<button class="win-btn ${showPreview ? "active" : ""}" id="prj-btn-toggle-preview">
👁️ 내역서 미리보기 ${showPreview ? "숨기기" : "표시"}
</button>
<span style="margin-left:auto;color:#1e3a8a;font-weight:700;">
현재 선택: ${prj.PNAME || ctx.state.projectMeta.pname} (직공비 ${fmt(directCost)}원 / 총공사비 ${fmt(totalCost)}원)
</span>
</div>
<div class="split-vert">
<!-- 상단 RGrdPrjList: 공사목록표 그리드 -->
<div class="pane-box" style="flex: ${showPreview ? "0 0 44%" : "1"};">
<div class="pane-title">
<span>RGrdPrjList — PC 내 STMATE 프로젝트 원장 (클릭: 프로젝트 로드 / 더블클릭: 내역서 편집)</span>
<span>K_ORD 단계: ${KORD_BADGES[num(prj.K_ORD)] || "설계내역(0)"}</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:42px;">순번</th>
<th style="width:95px;">구분</th>
<th style="width:105px;">공사번호(PNUM)</th>
<th>공사명 (PNAME / 아카이브명)</th>
<th style="width:65px;">코드(PCODE)</th>
<th style="width:120px;">공사종류(PCLASS)</th>
<th style="width:95px;">단계(K_ORD)</th>
<th style="width:125px;">직공비(TCOST1)</th>
<th style="width:125px;">총공사비(COSTW1)</th>
<th style="width:110px;">작업</th>
</tr>
</thead>
<tbody>
${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 `
<tr class="prj-row ${isCur ? "selected" : ""}" data-path="${src.path}">
<td class="center">${idx + 1}</td>
<td class="center"><strong>${src.kind.toUpperCase()}</strong></td>
<td class="center">${pnum}</td>
<td><strong>${src.name}</strong></td>
<td class="center">BOQ${pcode}</td>
<td class="center">${pcls}</td>
<td class="center">${kord}</td>
<td class="num"><strong>${tcost}</strong></td>
<td class="num"><strong>${costw}</strong></td>
<td class="center">
<button class="win-btn btn-load-prj" data-path="${src.path}">
${isCur ? "열림 (내역이동)" : "불러오기"}
</button>
</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
${
showPreview
? `
<!-- 하단 RDbGrid_Boq: 내역서 미리보기 그리드 -->
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>RDbGrid_Boq — 내역서 미리보기 (${ctx.dbfData ? ctx.dbfData.boq_table_name : "BOQ11.DBF"} · 총 ${boqRows.length}행 전수 표시)</span>
<span>공종(ITNUM) | 명칭(NAME1) | 규격(SIZE1) | 수량(QTY1) | 단위(UNIT1) | 단가(TAMT1) | 금액(T_AMT1) | 비고(BDCODE1)</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:45px;">행번</th>
<th style="width:70px;">공종(ITNUM)</th>
<th>명칭 (NAME1)</th>
<th style="width:170px;">규격 (SIZE1)</th>
<th style="width:90px;">수량 (QTY1)</th>
<th style="width:65px;">단위</th>
<th style="width:110px;">합계단가 (TAMT1)</th>
<th style="width:125px;">합계금액 (T_AMT1)</th>
<th style="width:90px;">비고 (BDCODE1)</th>
</tr>
</thead>
<tbody>
${boqRows
.map(
(r, i) => `
<tr class="${r._rowClass}">
<td class="center">${i + 1}</td>
<td class="center">${r.ITNUM || ""}</td>
<td>${r.NAME1 || ""}</td>
<td>${r.SIZE1 || ""}</td>
<td class="num">${r.QTY1 ? fmt(r.QTY1, 2) : ""}</td>
<td class="center">${r.UNIT1 || ""}</td>
<td class="num">${r.TAMT1 ? fmt(r.TAMT1) : ""}</td>
<td class="num"><strong>${r.T_AMT1 ? fmt(r.T_AMT1) : ""}</strong></td>
<td class="center">${r.BDCODE1 || ""}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>`
: ""
}
</div>
</div>
`;
}
/**
* 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 `
<div class="win-form">
<div class="win-form-header">
<span>📐 설계내역서 편집 [wBoq · ${ctx.dbfData ? ctx.dbfData.boq_table_name : "BOQ11.DBF"}] — 전체 ${allRows.length}행 (표시 ${filtered.length}행)</span>
<span>직공비 합계: ${fmt(totals.directT)}원 | 관급·제외구역(C*): ${fmt(totals.govMatTotal + totals.govFeeTotal)}원</span>
</div>
<!-- wBoq 상단 도구바 -->
<div class="win-subbar">
<label>수준필터(cBox01):</label>
<select id="boq-level-filter" class="win-select">
<option value="ALL" ${lvFilter === "ALL" ? "selected" : ""}>내역서 전체 (${allRows.length}행)</option>
<option value="STRUCT" ${lvFilter === "STRUCT" ? "selected" : ""}>공종제목·집계행만 (T1~T6 / E1~E6 / C*)</option>
<option value="LEAF" ${lvFilter === "LEAF" ? "selected" : ""}>순수 산출공종 말단행만</option>
<option value="EXCL" ${lvFilter === "EXCL" ? "selected" : ""}>관급·제외금액 구역(C*)만</option>
</select>
<input type="text" id="boq-search-input" class="win-input" placeholder="공종명/규격/코드 검색..." value="${ctx.uiState.boqSearch || ""}" style="width:170px;" />
<button class="win-btn" id="boq-btn-itnum">🔢 공종번호 자동입력(wITNum)</button>
<button class="win-btn" id="boq-btn-rate">📈 할증율 적용(wBoqRate)</button>
<button class="win-btn" id="boq-btn-mrate">✂️ 비목단수보정(wM_Rate)</button>
<button class="win-btn" id="boq-btn-expnd">📂 일위대가 풀기(wExpnd)</button>
<button class="win-btn" id="boq-btn-add-leaf">+ 신규 내역행 추가</button>
<button class="win-btn primary" id="boq-btn-save-dbf">💾 DBF/STC 즉시 반영</button>
</div>
<div class="split-horiz">
<!-- 좌측 메인 BOQ 계층 그리드 -->
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>계층 설계내역서 원장 (수량 수정 시 비목별 ROUNDDOWN 및 T*/E*/C* 집계 즉시 재계산 · 호표코드 더블클릭 시 대가표 이동)</span>
<span>차액보정: ${ctx.state.sysConfig.chnMode === "total" ? "총액우선(OPTNM=" + ctx.state.sysConfig.optOrder + ")" : "비목우선"}</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:40px;">No</th>
<th style="width:65px;">공종(ITNUM)</th>
<th style="width:82px;">코드(BDCODE1)</th>
<th>명칭 (NAME1)</th>
<th style="width:145px;">규격 (SIZE1)</th>
<th style="width:55px;">단위</th>
<th style="width:85px;">수량(QTY1)</th>
<th style="width:92px;">재료비단가</th>
<th style="width:92px;">노무비단가</th>
<th style="width:92px;">경비단가</th>
<th style="width:98px;">합계단가</th>
<th style="width:105px;">재료비금액</th>
<th style="width:105px;">노무비금액</th>
<th style="width:105px;">경비금액</th>
<th style="width:115px;">합계금액(T_AMT1)</th>
<th style="width:58px;">보관함</th>
</tr>
</thead>
<tbody>
${filtered
.map((r) => {
const bd = String(r.BDCODE1 || "").trim();
const isLeaf = bd && !isStructCode(bd);
return `
<tr class="${r._rowClass}" data-recno="${r._recno}" data-bdcode="${bd}">
<td class="center">${r._idx + 1}</td>
<td class="center">
<input type="text" class="cell-input center boq-edit-itnum" data-recno="${r._recno}" value="${r.ITNUM || ""}" />
</td>
<td class="center">
${
isLeaf
? `<a href="#" class="boq-jump-code" data-code="${bd}" style="color:#0050b3;font-weight:700;text-decoration:underline;">${bd}</a>`
: `<strong>${bd}</strong>`
}
</td>
<td>
<input type="text" class="cell-input boq-edit-name" data-recno="${r._recno}" value="${r.NAME1 || ""}" />
</td>
<td>${r.SIZE1 || ""}</td>
<td class="center">${r.UNIT1 || ""}</td>
<td class="num">
${
isLeaf
? `<input type="number" step="0.01" class="cell-input num boq-edit-qty" data-recno="${r._recno}" data-code="${bd}" value="${r.QTY1}" />`
: ""
}
</td>
<td class="num">${r.MAMT1 ? fmt(r.MAMT1) : ""}</td>
<td class="num">${r.LAMT1 ? fmt(r.LAMT1) : ""}</td>
<td class="num">${r.SAMT1 ? fmt(r.SAMT1) : ""}</td>
<td class="num"><strong>${r.TAMT1 ? fmt(r.TAMT1) : ""}</strong></td>
<td class="num">${r.M_AMT1 ? fmt(r.M_AMT1) : ""}</td>
<td class="num">${r.L_AMT1 ? fmt(r.L_AMT1) : ""}</td>
<td class="num">${r.S_AMT1 ? fmt(r.S_AMT1) : ""}</td>
<td class="num"><strong>${r.T_AMT1 ? fmt(r.T_AMT1) : ""}</strong></td>
<td class="center">
${
isLeaf
? `<button class="win-btn boq-put-pocket" data-code="${bd}" data-name="${r.NAME1 || ""}" data-qty="${r.QTY1}" style="height:19px;padding:0 5px;">담기</button>`
: ""
}
</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
<!-- 우측 내역보관함 (1)(2)(3) 패널 (22번 문서 §4) -->
<div class="pane-box" style="width:270px;">
<div class="pane-title">
<span>📦 내역보관함 (${pocketIdx})</span>
<div>
${[1, 2, 3]
.map(
(n) =>
`<button class="win-btn pocket-tab-btn ${pocketIdx === n ? "active" : ""}" data-pocket="${n}" style="height:20px;padding:0 6px;">(${n})</button>`,
)
.join(" ")}
</div>
</div>
<div class="grid-scroll" style="padding:6px;">
<p style="margin:0 0 6px;color:#4b5563;font-size:11px;">
자주 쓰는 공종을 보관함 (1)~(3)에 담아두고 현재 내역서에 즉시 삽입합니다.
</p>
${
(pockets[pocketIdx] || []).length === 0
? `<div style="padding:18px 8px;text-align:center;color:#6b7280;">보관함 (${pocketIdx})이 비어 있습니다.<br/>내역 행 우측 [담기]를 클릭하세요.</div>`
: `<table class="dbgrid">
<thead><tr><th>코드</th><th>품명</th><th>수량</th><th>삽입</th></tr></thead>
<tbody>
${(pockets[pocketIdx] || [])
.map(
(p, pi) => `
<tr>
<td class="center">${p.code}</td>
<td>${p.name}</td>
<td class="num">${fmt(p.qty, 2)}</td>
<td class="center"><button class="win-btn pocket-insert-btn" data-idx="${pi}" style="height:19px;padding:0 5px;">넣기</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>`
}
</div>
</div>
</div>
</div>
`;
}
/**
* 3. 내역 도구 모달 HTML 생성기 (wITNum / wBoqRate / wM_Rate / wExpnd / wQtyList)
*/
function buildBoqToolModalHtml(toolKind, ctx) {
if (toolKind === "wITNum") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wITNum] 내역서 공종번호 자동입력</strong> — 대공종(T4)/중공종(T2)/말단 공종행에 번호체계를 일괄 부여합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>대공종(T4) 번호형식</label><select id="itnum-t4-fmt"><option value="1.">1. / 2. / 3.</option><option value="I.">I. / II. / III.</option><option value="가.">가. / 나. / 다.</option></select></div>
<div class="kanjub-field"><label>중공종(T2) 번호형식</label><select id="itnum-t2-fmt"><option value="1.1">1.1 / 1.2 / 1.3</option><option value="(1)">(1) / (2) / (3)</option></select></div>
<div class="kanjub-field"><label>말단 공종행 번호부여</label><select id="itnum-leaf-fmt"><option value="none">공란 유지 (STMATE 기본)</option><option value="01">01, 02, 03...</option><option value="-1">-1, -2, -3...</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-itnum">공종번호 일괄 부여 실행</button></div>
</div>
`;
}
if (toolKind === "wBoqRate") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wBoqRate] 내역 항목 할증율 일괄 적용</strong> — 전체 또는 비목별(재·노·경) 할증 비율(%)을 적용합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>재료비 할증(%)</label><input type="number" id="boqrate-m" value="100" step="1" /></div>
<div class="kanjub-field"><label>노무비 할증(%)</label><input type="number" id="boqrate-l" value="100" step="1" /></div>
<div class="kanjub-field"><label>경비 할증(%)</label><input type="number" id="boqrate-s" value="100" step="1" /></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-boqrate">내역 할증율 적용</button></div>
</div>
`;
}
if (toolKind === "wM_Rate") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wM_Rate] 선택 범위 수량·비목 단수 일괄 보정</strong> — 절사/반올림 및 소수점 자리수(0~4위)를 일괄 보정합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>보정 방식</label><select id="mrate-mode"><option value="trunc">절사 (ROUNDDOWN)</option><option value="round">반올림 (ROUND)</option></select></div>
<div class="kanjub-field"><label>소수 자리수(ECD)</label><select id="mrate-digits"><option value="0">소수 0위 (정수 1원)</option><option value="1">소수 1위 (0.1)</option><option value="2">소수 2위 (0.01)</option><option value="-1">10원 단위 절사</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-mrate">단수 일괄 보정 적용</button></div>
</div>
`;
}
if (toolKind === "wExpnd") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기</strong> — 호표 구성행을 내역서 하위 행으로 전개합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>집계 구분선(RGrpToT)</label><select id="expnd-tot"><option value="E1">소계(=)</option><option value="E2">계(==)</option><option value="E3">합계(===)</option></select></div>
<div class="kanjub-field"><label>단가 끝전 처리</label><select id="expnd-round"><option value="trunc">절삭하여 소수 0위 적용</option><option value="round">반올림하여 소수 0위 적용</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-expnd">일위대가 풀어넣기 확인</button></div>
</div>
`;
}
// wQtyList (수량산출서 연결 집계)
const leafRows = ctx.computed.boqComputed.rows || [];
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wQtyList] 수량산출서(QCODE.DBF) 코드별 수량 연결·집계</strong> — 수량산출식 결과를 내역서 QTY1에 즉시 반영합니다.</p>
<div class="grid-scroll" style="max-height:320px;">
<table class="dbgrid">
<thead><tr><th>코드</th><th>공종명</th><th>현재 내역수량</th><th>산출근거 수식 (예: 3.46*1000)</th><th>반영</th></tr></thead>
<tbody>
${leafRows
.slice(0, 25)
.map(
(r) => `
<tr>
<td class="center"><strong>${r.code}</strong></td>
<td>${r.name}</td>
<td class="num">${fmt(r.qty, 2)}</td>
<td><input type="text" class="win-input qty-expr-input" data-id="${r.id}" value="${r.qty}" style="width:100%;" /></td>
<td class="center"><button class="win-btn btn-apply-qty-expr" data-id="${r.id}">산식반영</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
`;
}
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);