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:
+23
-51
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
/* -*- coding: utf-8 -*- */
|
||||
/**
|
||||
* STMATE 시뮬레이터: 내역서(BOQ), 제잡비(Calc_Kanjub), 변경설계(Revision) 렌더러 (app_views_cost.js)
|
||||
*/
|
||||
const StmateCostViews = (() => {
|
||||
const esc = (s) =>
|
||||
String(s ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
const fmt = (n, maxDec = 2) =>
|
||||
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0);
|
||||
|
||||
function renderBoqTab(state, res) {
|
||||
const sys = state.sysConfig,
|
||||
boq = res.boqOrig;
|
||||
const unitChoices = [
|
||||
...state.ilwiItems.map((b) => [b.id, `[일위 B] ${b.code} ${b.name}`]),
|
||||
...state.sanItems.map((d) => [d.id, `[단산 D] ${d.code} ${d.name}`]),
|
||||
...state.machines.map((m) => [m.id, `[중기 X] ${m.code} ${m.name}`]),
|
||||
...state.resources.map((r) => [r.id, `[단독 ${r.code[0]}] ${r.code} ${r.name}`]),
|
||||
];
|
||||
|
||||
const rowsHtml = boq.rows
|
||||
.map(
|
||||
(r, i) => `
|
||||
<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>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>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
@@ -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;
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
/* -*- coding: utf-8 -*- */
|
||||
/**
|
||||
* STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 컨트롤러 (db_bridge.js)
|
||||
*/
|
||||
const StmateDbBridge = (() => {
|
||||
const API_BASE = "http://127.0.0.1:8095";
|
||||
const esc = (s) =>
|
||||
String(s ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
const fmt = (n, maxDec = 2) =>
|
||||
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0);
|
||||
|
||||
const dbCtx = {
|
||||
connected: false,
|
||||
sources: { projects: [], jbr_files: [], work_root: "" },
|
||||
selectedProjectPath: "",
|
||||
selectedJbrPath: "",
|
||||
targetFolderName: "stmate_edited",
|
||||
loadedDbf: null,
|
||||
loadedJbr: null,
|
||||
lastSaveResult: null,
|
||||
statusMsg: "로컬 DB 브리지(127.0.0.1:8095) 연결 확인 중...",
|
||||
};
|
||||
|
||||
async function checkAndLoadSources(onUpdate) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/sources`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
dbCtx.connected = true;
|
||||
dbCtx.sources = data;
|
||||
if (!dbCtx.selectedProjectPath && data.projects.length > 0) {
|
||||
dbCtx.selectedProjectPath = data.projects[0].path;
|
||||
}
|
||||
const onlyJbr = data.jbr_files.filter((f) => f.ext === ".JB_R");
|
||||
if (!dbCtx.selectedJbrPath && onlyJbr.length > 0) {
|
||||
dbCtx.selectedJbrPath = onlyJbr[onlyJbr.length - 1].path;
|
||||
}
|
||||
dbCtx.statusMsg = `연결됨 — PC 프로젝트 DB ${data.projects.length}건 · ST_BSC 제비율 DB ${data.jbr_files.length}건 감지`;
|
||||
} catch (_) {
|
||||
dbCtx.connected = false;
|
||||
dbCtx.statusMsg =
|
||||
"오프라인 — 터미널에서 .\\venv\\Scripts\\python.exe tools\\stmate-test\\server.py 실행 시 DBF 직접 읽기/쓰기 활성화";
|
||||
}
|
||||
if (onUpdate) onUpdate();
|
||||
}
|
||||
|
||||
async function loadProjectDbf(applyStateCallback, onUpdate) {
|
||||
if (!dbCtx.selectedProjectPath) return;
|
||||
try {
|
||||
dbCtx.statusMsg = `DBF/STC 읽는 중: ${dbCtx.selectedProjectPath} ...`;
|
||||
if (onUpdate) onUpdate();
|
||||
const resp = await fetch(`${API_BASE}/api/load`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source_path: dbCtx.selectedProjectPath }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const data = await resp.json();
|
||||
dbCtx.loadedDbf = data;
|
||||
dbCtx.lastSaveResult = null;
|
||||
dbCtx.statusMsg = `로드 완료: ${data.prjlst?.PNAME || data.source_path} (COSTN ${data.costn_count}건, ${data.boq_table_name} ${data.boq_count}건)`;
|
||||
if (data.sim_state && applyStateCallback) {
|
||||
applyStateCallback(data.sim_state);
|
||||
}
|
||||
} catch (err) {
|
||||
dbCtx.statusMsg = `DBF 로드 실패: ${err.message || err}`;
|
||||
if (onUpdate) onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyJbr(state, saveAndRender) {
|
||||
if (!dbCtx.selectedJbrPath) return;
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/load-jbr`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jbr_path: dbCtx.selectedJbrPath }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const data = await resp.json();
|
||||
dbCtx.loadedJbr = data;
|
||||
const r = data.extracted_rates;
|
||||
Object.assign(state.kanjubConfig.rates, {
|
||||
KANNO: r.KANNO || state.kanjubConfig.rates.KANNO,
|
||||
KITA: r.KITA || state.kanjubConfig.rates.KITA,
|
||||
ILBAN: r.ILBAN || state.kanjubConfig.rates.ILBAN,
|
||||
SANBO: r.SANBO,
|
||||
KOBO: r.KOBO,
|
||||
KUNBO: r.KUNBO,
|
||||
YUNBO: r.YUNBO,
|
||||
SLVBO: r.SLVBO,
|
||||
IYUN: r.IYUN,
|
||||
ANJUN: r.ANJUN,
|
||||
});
|
||||
state.kanjubConfig.anjunBaseAdd = r.anjunBaseAdd;
|
||||
dbCtx.statusMsg = `ST_BSC 제비율 DB(${data.name}) 요율 적용 완료`;
|
||||
if (saveAndRender) saveAndRender();
|
||||
} catch (err) {
|
||||
dbCtx.statusMsg = `JB_R 로드 실패: ${err.message || err}`;
|
||||
if (saveAndRender) saveAndRender();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToDbf(state, calcRes, onUpdate) {
|
||||
const srcPath = dbCtx.loadedDbf?.source_path || dbCtx.selectedProjectPath;
|
||||
if (!srcPath) return;
|
||||
try {
|
||||
const k = calcRes.kanjubOrig;
|
||||
const costnUpdates = (dbCtx.loadedDbf?.costn_rows || []).slice(0, 80).map((r) => ({
|
||||
CODE: r.CODE,
|
||||
NAME: r.NAME,
|
||||
TAMT1: r.TAMT1,
|
||||
LAMT1: r.LAMT1,
|
||||
MAMT1: r.MAMT1,
|
||||
SAMT1: r.SAMT1,
|
||||
}));
|
||||
// 시뮬레이터 기초자원 단가 변경분도 COSTN 업데이트에 병합
|
||||
for (const resItem of calcRes.resources.list) {
|
||||
costnUpdates.push({
|
||||
CODE: resItem.code,
|
||||
NAME: resItem.name,
|
||||
TAMT1: resItem.adoptedPrice,
|
||||
LAMT1: resItem.kind === "노무비" ? resItem.adoptedPrice : 0,
|
||||
MAMT1: resItem.kind === "재료비" ? resItem.adoptedPrice : 0,
|
||||
SAMT1: resItem.kind === "경비" ? resItem.adoptedPrice : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const boqUpdates = calcRes.boqOrig.rows
|
||||
.filter((r) => r._recno !== undefined)
|
||||
.map((r) => ({
|
||||
_recno: r._recno,
|
||||
NAME1: r.name,
|
||||
QTY1: r.qty,
|
||||
MAMT1: r.unitInfo.M,
|
||||
LAMT1: r.unitInfo.L,
|
||||
SAMT1: r.unitInfo.S,
|
||||
TAMT1: r.unitInfo.T,
|
||||
}));
|
||||
|
||||
const prjlstUpdates = {
|
||||
MCOST1: k.dM,
|
||||
LCOST1: k.dL,
|
||||
SCOST1: k.dS,
|
||||
TCOST1: k.dT,
|
||||
COSTW1: k.grandTotal,
|
||||
};
|
||||
const rates = state.kanjubConfig.rates;
|
||||
const smtUpdates = {
|
||||
"1_R_KANNO": rates.KANNO,
|
||||
"1_R_SANBO": rates.SANBO,
|
||||
"1_R_KOBO": rates.KOBO,
|
||||
"1_R_KUNBO": rates.KUNBO,
|
||||
"1_R_SLVBO": rates.SLVBO,
|
||||
"1_R_YUNBO": rates.YUNBO,
|
||||
"1_R_ANJUN": rates.ANJUN,
|
||||
"1_R_T_KITA": rates.KITA,
|
||||
"1_R_HWAN": rates.HWAN,
|
||||
"1_R_EQIPSU": rates.EQIP,
|
||||
"1_R_ILBAN": rates.ILBAN,
|
||||
"1_R_IYUN": rates.IYUN,
|
||||
"1_C_KANNO": k.kanno,
|
||||
"1_C_ILBAN": k.ilbanAmt,
|
||||
"1_C_IYUN_M": k.pretaxProfitAdj,
|
||||
"1_C_KONGSA_J": k.contractBeforeAdj,
|
||||
"1_C_UPDNKWN": k.govFinal,
|
||||
};
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/save`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source_path: srcPath,
|
||||
target_folder_name: dbCtx.targetFolderName || "stmate_edited",
|
||||
costn_updates: costnUpdates,
|
||||
boq_updates: boqUpdates,
|
||||
prjlst_updates: prjlstUpdates,
|
||||
smt_updates: smtUpdates,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const saved = await resp.json();
|
||||
dbCtx.lastSaveResult = saved;
|
||||
dbCtx.statusMsg = `DBF·STC 저장 완료: ${saved.saved_stc} (${fmt(saved.stc_size, 0)} bytes)`;
|
||||
await checkAndLoadSources();
|
||||
} catch (err) {
|
||||
dbCtx.statusMsg = `DBF 저장 실패: ${err.message || err}`;
|
||||
}
|
||||
if (onUpdate) onUpdate();
|
||||
}
|
||||
|
||||
function renderDbTab(state, calcRes) {
|
||||
const projOpts = dbCtx.sources.projects
|
||||
.map(
|
||||
(p) =>
|
||||
`<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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>&X00026L&</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;">자원참조(&코드&)</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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user