refactor(stmate-test): 원본에 없는 기능 걷음 — 제비율표 JB 대조 보기 · 보고서 CSV · 계약 저장 공사 대조 · 옛 화면 9개(표준카탈로그·레시피·REA 아키텍처 창·가짜 JDL/XML)

- 원본 대조 = 설치본 실행파일 52개 화면 이름·버튼 글·한글 문자열
- 지움: conv_jb.py · excel_builder.py · view_dan_ilwi.js · view_kanjub_prt.js · /api/conv/jb-* · /api/report/csv · /api/stage/projects · /api/catalog · /api/export-xlsx
- 낡은 시험 삭제(지운 기능 전용): conv_change 제비율표 3 · stage_api 공사별 설정 2 · report_sheets CSV 2줄
- 장부 F4-15 · F1-33 · F6-24 설명 고침 · stmate 시험 786 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 18:24:22 +09:00
co-authored by Claude Opus 5.5
parent 22a3942f23
commit a4c0bc14a6
24 changed files with 39 additions and 3093 deletions
+2 -58
View File
@@ -1,7 +1,6 @@
"""STMATE 재현 앱 분리형 변경 STC(차수 인계) · 코드 일괄 변경 · JB_* 기준 제비율표 · STC 검사.
"""STMATE 재현 앱 분리형 변경 STC(차수 인계) · 코드 일괄 변경 · STC 검사.
정답: 소광 원설계 STC(BOQ11·BOQ12) → 실제 「변경 1차분」 STC(16번 §4.6) · 원본 STC 바이트(코드 되돌림) ·
10번 §4 문서의 RATE ↔ JB 일치 사례(설치 폴더에 JB_* 가 있을 때만).
정답: 소광 원설계 STC(BOQ11·BOQ12) → 실제 「변경 1차분」 STC(16번 §4.6) · 원본 STC 바이트(코드 되돌림).
"""
import collections
@@ -13,7 +12,6 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
from bdqty import decode_bdqty # noqa: E402
from conv_jb import JB_DIR, list_jb, match_project # noqa: E402
from dbf_rw import DbfTable # noqa: E402
from smt_parser import extract_costn_memos, parse_dbf_bytes, parse_prjlst_smt # noqa: E402
from stc_archive import build_stc, check_stc # noqa: E402
@@ -117,57 +115,3 @@ def test_prjlst_rows_edit_other_project_row():
assert DbfTable(out["PRJLST.DBF"]).get(1, "PNAME") == "둘째 공사 이름 고침"
out2, log2 = apply_save(f, {"prjlst_rows": rows})
assert log2 == [] and out2["PRJLST.DBF"] is f["PRJLST.DBF"]
needs_jb = pytest.mark.skipif(not JB_DIR.exists(), reason="STMATE 설치 폴더 JB_* 없음")
@needs_jb
def test_jb_files_shapes():
files = list_jb()
assert len(files) >= 43 and all(f["shape_ok"] for f in files)
kinds = collections.Counter(f["kind"] for f in files)
assert (
kinds["JB_1"] >= 1 and kinds["JB_M"] >= 1 and kinds["JB_R"] >= 27 and kinds["JB_RA"] >= 14
)
jr = {f["date"]: f["cols"] for f in files if f["kind"] == "JB_R"}
assert jr["2020-01-02"] == 20 and jr["2020-05-27"] == 35 # 2020-05-27 확장(10번 §4)
@needs_jb
@pytest.mark.parametrize(
"rel,table,date",
[
(
"05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
"RATE_R.DBF",
"2023-01-02",
),
(
"05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
"RATE.DBF",
"2011-01-12",
),
(
"04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC",
"RATE_R.DBF",
"2024-03-15",
),
(
"2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC",
"RATE_R.DBF",
"2020-05-27",
),
],
)
def test_jb_match_documented_cases(rel, table, date):
m = match_project(Source(str(DOCS / rel)).files)
assert m[table]["exact"] and m[table]["date"] == date
@needs_jb
def test_jb_sogwang_five_cells():
m = match_project(Source(str(ORIG)).files)
assert (
m["RATE_R.DBF"]["date"] == "2025-01-01" and m["RATE_R.DBF"]["diff_cells"] == 5
) # 10번 §4 「5개 셀만 다름」
@@ -193,5 +193,3 @@ def test_xlsx_export_writes_every_sheet_with_model_values():
same = abs(got - v) < 1e-9 if isinstance(v, float) else got == v
assert same, f"{s['name']} {c}{i}"
assert wb["〓 목 차 〓"]["D5"].hyperlink is not None
csv = client.post("/api/report/csv", json={"sheets": [sheets[2]], "filename": "c"})
assert csv.status_code == 200 and "공사원가계산서" in csv.content.decode("utf-8-sig")
+2 -27
View File
@@ -1,11 +1,9 @@
"""STMATE 재현 앱 단계 서버(api_stage.py) — 공사별 원가설정 · 단계 보고서 엑셀.
"""STMATE 재현 앱 단계 서버(api_stage.py) — 단계 보고서 엑셀.
정답: 울진 2025 STC 계약 행(PCODE 12)의 PRJ_MEMO 를 smt_parser 로 따로 읽은 원가설정과 같음 ·
엑셀은 보낸 시트 모형 값이 그대로 셀에 들어감(병합 머리 포함).
정답: 보낸 시트 모형 값이 그대로 셀에 들어감(병합 머리 포함).
"""
import io
import json
import sys
from pathlib import Path
@@ -17,35 +15,12 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
sys.path.insert(0, str(Path(__file__).parent))
import api_stage # noqa: E402
import server # noqa: E402
from kanjub_cfg import build_kanjub_config # noqa: E402
from smt_parser import parse_prjlst_smt # noqa: E402
from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
app = FastAPI()
app.include_router(api_stage.router)
client = TestClient(app)
def test_projects_config_matches_memo():
path = str(DOCS / STCS["울진2025"])
res = client.post("/api/stage/projects", json={"source_path": path})
assert res.status_code == 200
projects = res.json()["projects"]
files = server.read_project_files(path)
load = server.api_load(server.LoadReq(source_path=path))
assert [p["pcode"] for p in projects] == [r["PCODE"] for r in load["prjlst_all"]]
for p, r in zip(projects, load["prjlst_all"]):
smt = parse_prjlst_smt(files["PRJLST.SMT"], r["PRJ_MEMO_HEX"])
assert p["stages"]["1"] == json.loads(json.dumps(build_kanjub_config(smt, 1)))
assert p["costs"]["COSTW1"] == r["COSTW1"]
def test_projects_bad_path():
res = client.post("/api/stage/projects", json={"source_path": str(ROOT / "없는파일.STC")})
assert res.status_code == 400
def test_export_xlsx_cells():
sheet = {
"name": "설계변경증감대비표",
+3 -3
View File
@@ -65,7 +65,7 @@
- [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값))
- [x] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 구현(가격지 후보 비교·채택 보호 행 제외(test_stmate_ui_protect))
- [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터)
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(scr_edit_san 대가표 목록·B/D/X 거르기)
- [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra))
- [x] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 구현(산근 편집 + F9 · 함수·문법 검사(test_stmate_smt_lang))
- [x] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 구현(REA 36 — 손료 계수 반올림 = MROUNDUD 선택 1(첫 버림 자리 ≥5 올림 · DP_Common 0x4544f0) · calc_xrate 원형 사용(test_stmate_xrate_round))
@@ -200,7 +200,7 @@
- [x] F4-12 COSTA.DBF 읽기 — 02번 · 현재: 구현(읽기(실무 표본 0건))
- [x] F4-13 BOQ1n 읽기·쓰기 — 02번 · 16번 · 현재: 구현(고친 칸만 · 2·3칸 보존 · 소계·TCOST 다시 셈 · 행 끼우기·지우기)
- [x] F4-14 RATE*.DBF 읽기 — 06번 §1 · 현재: 구현(server.py 474)
- [x] F4-15 JB_* 기준요율표 43종 읽기(구형·2020 확장) — 10번 · 12번 §9 · 현재: 구현(conv_jb JB 요율표 판·모양 문서 일치 · 문서 일치 사례 재현)
- [x] F4-15 JB_* 기준요율표 43종 읽기(구형·2020 확장) — 10번 · 12번 §9 · 현재: 구현(제비율표 불러오기 = /api/load-jbr 로 JB_* 읽기 · 판 모양 검사·프로젝트 대조 보기는 원본에 없어 걷음)
- [x] F4-16 KONGJONG.DBF 공종 분류 — 02번 · 현재: 구현(읽기(실무 표본 공종 1건 내외))
- [x] F4-17 SET_REP.DBF 서식 카탈로그 읽기 — 20번 §3 · 현재: 구현(표시)
- [x] F4-18 QCODE.DBF·QCODE.SMT 읽기 — 02번 · 28번 · 현재: 구현(읽기(실무 표본 0건))
@@ -281,7 +281,7 @@
- [x] F6-21 예상적용율 찾기(목표금액 역산)·미리계산 — 27번 §2.3 · 현재: 구현(목표금액 → 적용율 87.745% 역산 · 미리계산)
- [x] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 구현(소광 원설계 → 변환등록·저장 = 실제 울진 계약 STC 일치(test_stmate_stage_register))
- [x] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 구현(새 코드 낙찰율·단가·산근 글·구성표 저장 = 울진 계약 STC 일치(test_stmate_stage_register))
- [x] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 구현(계약 원가계산 · 울진 총공사비 333,315,000원 일치)
- [x] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 구현(계약 원가계산 · 울진 총공사비 333,315,000원 일치 · 저장 계약 공사 대조 단추는 원본에 없어 걷음)
- [x] F6-25 계약·실행내역서(0623·0624) — PM_Nae_Cont · 현재: 구현(REA MCP 32 §8 계약·실행내역서 0623·0624 = 설계 내역서 인쇄 모듈을 지금 공사에 그대로 · 기본 내역서 열(0623 두 모듈 고르는 기준 미확인))
- [x] F6-26 실행예산 작업(실행단가·실행수량) — wBoq 실행예산작업 · 현재: 구현(REA 32 할증 0x511fa8 + 행 금액 0x456504 = 설계와 같은 공용 행 계산 엔진(test_stmate_stage_exec_rea))
- [x] F6-27 중기 실행단가 계산(절사 6택·차액 보정 순서) — wM_Boq_ExecX · 현재: 구현(REA 32 0x4da7a8 합계 = 사용료÷시간 1원 절사 · 최초단가 비례 · 끝전 OPTIMIZ · 차액 = OPTNM 첫 성분(test_stmate_stage_exec_rea))
+3 -5
View File
@@ -21,10 +21,8 @@
| :--- | ---: | :--- |
| [`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` |
| [`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` 산출근거 본문 추출·재기록기 |
| [`server.py`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/server.py) | 649 | FastAPI 서버 (`127.0.0.1:8095`) — `/api/sources`, `/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 내역서 → 원가계산서`) — 원가계산서는 실무 STC 4건 1원 대조 시험(`resources/tester/test_stmate_kanjub_stc.py`) |
| [`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 변환) |
| [`view_prj_boq.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/view_prj_boq.js) | 189 | 계층 내역 재집계(`evaluateFullBoqRows` — `T1~T6` 공종헤더·`E1~E6` 소계·`C*` 관급자재 제외구간) · 단가 반영(`syncCostnUnitPrice`) · 저장 요청 묶음(`buildSavePayload`) — 화면은 `scr_*.js` |
| [`app.js`](file:///D:/aislo-wt/안티그래비티/tools/stmate-test/app.js) | 688 | 상단 5대 메뉴·툴바·모달·9개 화면 라우팅 및 DBF/SMT/STC 양방향 저장 컨트롤러 |
+2 -27
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""변환·가져오기 끼워 넣기 API — 조달청 XML 가져오기·변환 설정 · 엑셀 내역/역등록 · 기준 BOQ·자재·코드 엑셀 왕복 ·
분리형 변경 STC · JB_* 기준 제비율표.
분리형 변경 STC.
근거: 28번 §3(ST_U_Convert) · 30번(호환규정 XSD) · 24번 §3(ST_P_XLS CONV_XLS_2_ST · XLS_2_BDREC · MK_BSCBOQ_EXL) ·
16번 §4.6(차수 인계) · 10번 §4(JB_* ↔ RATE*) · 10번(ST_U_ChgCode)
16번 §4.6(차수 인계) · 10번(ST_U_ChgCode)
쓰기는 tools/stmate-test/work_dbf/ 아래만(server.WORK_DBF_ROOT) · 엑셀·XML 원본은 읽기만.
파일 받기: 경로(서버 PC) 또는 b64(화면에서 고른 파일).
"""
@@ -14,7 +14,6 @@ import base64
import pathlib
from conv_jaje import build_jaje_xlsx, caps_from_ini
from conv_jb import JB_DIR, list_jb, match_project, read_jb
from conv_win8 import convert as convert_win8
from conv_win8_ini import detect as detect_win8
from conv_xls_apply import import_boq, register
@@ -129,10 +128,6 @@ class SplitReq(BaseModel):
target_folder_name: str = ""
class JbReq(BaseModel):
path: str
class JajeSumReq(BaseModel):
source_path: str = ""
head: dict = {}
@@ -280,23 +275,3 @@ def api_split_change(req: SplitReq):
return _save(
req.target_folder_name or f"{s.path.stem}_변경{rep['k_ord']}차", files, s, {"report": rep}
)
# ── JB_* 기준 제비율표(읽기 전용) ──
@router.get("/api/conv/jb-list")
def api_jb_list():
return {"dir": str(JB_DIR), "files": list_jb()}
@router.post("/api/conv/jb-read")
def api_jb_read(req: JbReq):
p = pathlib.Path(req.path).resolve()
if JB_DIR.resolve() not in p.parents or not p.is_file():
raise HTTPException(400, "설치 폴더 ST_BSC 의 JB_* 만 읽음")
return read_jb(p)
@router.post("/api/conv/jb-match")
def api_jb_match(req: SrcReq):
"""프로젝트 RATE* 표 ↔ 기준 JB 파일(셀 모두 같은 것 · 아니면 가장 가까운 것)."""
return match_project(_src(req.source_path).files)
+2 -15
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""보고서 내보내기 끼워 넣기 (api_report.py) — 시트 모형 → 엑셀 · CSV · 조달청 XML 검증.
"""보고서 내보내기 끼워 넣기 (api_report.py) — 시트 모형 → 엑셀 · 조달청 XML 검증.
근거: 지식DB STmate/old/20_분석 19 §3 · 20 §3 · 30(조달청 XSD).
시트 모형은 화면(scr_report.js)이 report_*.js 로 만들어 보냄 — 계산은 브라우저 엔진과 같은 코드.
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from report_xlsx import build_csv, build_report_xlsx
from report_xlsx import build_report_xlsx
HERE = pathlib.Path(__file__).resolve().parent
XSD = (
@@ -50,19 +50,6 @@ def api_report_xlsx(req: ReportReq):
)
@router.post("/api/report/csv")
def api_report_csv(req: ReportReq):
if not req.sheets:
raise HTTPException(status_code=400, detail="시트 모형 없음")
body = "" + build_csv(req.sheets[0])
name = req.filename if req.filename.endswith(".csv") else req.filename + ".csv"
return Response(
content=body.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": _disposition(name)},
)
@router.post("/api/report/validate-pps")
def api_validate_pps(req: XmlReq):
"""조달청 호환규정 XSD(V1.0.5) 검증 — 위반 줄을 돌려줌."""
+2 -53
View File
@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
"""설계 후속 단계 서버 — 공사(PRJLST 행)별 원가설정 · 단계 보고서 엑셀 내보내기 (api_stage.py).
"""설계 후속 단계 서버 — 단계 보고서 엑셀 내보내기 (api_stage.py).
근거: 지식DB STmate/old/20_분석 27(계약·실행·기성·준공) · 16 §4.5(통합형 BOQ11 설계 + BOQ12 계약)
근거: 지식DB STmate/old/20_분석 27(계약·실행·기성·준공)
· 20 §3(SET_REP 보고서 0623·0624·0632~0638·0643~0648·0661·0673·0674)
/api/stage/projects PRJLST 행마다 PCODE·CCODE·차수별 직접비·총공사비
· 그 행 PRJ_MEMO 의 차수별 원가설정
/api/stage/export-xlsx 화면 보고서 시트 모형(name·title·info·head·rows) → xlsx
"""
@@ -16,19 +14,12 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from kanjub_cfg import build_kanjub_config, stage_slots
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, Side
from openpyxl.utils import get_column_letter
from pydantic import BaseModel
from stc_read import Source, prjlst_smt_all, table_rows
router = APIRouter()
COST_COLS = re.compile(r"^(MCOST|LCOST|SCOST|TCOST|ACOST|COSTW)\d$")
class StagePathReq(BaseModel):
source_path: str
class StageSheet(BaseModel):
@@ -44,48 +35,6 @@ class StageXlsxReq(BaseModel):
sheets: list[StageSheet]
def _flat(sections: dict) -> dict[str, str]:
"""PRJ_MEMO 섹션들을 키=값 하나로 · 빈 값은 뺌(smt_parser 와 같게)."""
out: dict[str, str] = {}
for kv in sections.values():
out.update({k: v for k, v in kv.items() if str(v).strip() != ""})
return out
def project_configs(files: dict[str, bytes]) -> list[dict]:
"""PRJLST 행마다 공사 정보 + 차수별 원가설정(build_kanjub_config)."""
rows = table_rows(files.get("PRJLST.DBF", b""))
memos = {m["recno"]: m for m in prjlst_smt_all(files)}
out = []
for r in rows:
kv = _flat(memos.get(r["_recno"], {}).get("sections", {}))
out.append(
{
"recno": r["_recno"],
"pcode": str(r.get("PCODE", "")).strip(),
"ccode": str(r.get("CCODE", "")).strip(),
"pclass": str(r.get("PCLASS", "")).strip(),
"k_ord": str(r.get("K_ORD", "")).strip(),
"pnum": r.get("PNUM", ""),
"pname": r.get("PNAME", ""),
"boq_table": f"BOQ{str(r.get('PCODE', '')).strip()}.DBF",
"has_boq": f"BOQ{str(r.get('PCODE', '')).strip()}.DBF" in files,
"costs": {k: v for k, v in r.items() if COST_COLS.match(k)},
"stages": {n: build_kanjub_config(kv, n) for n in stage_slots(kv)} if kv else {},
}
)
return out
@router.post("/api/stage/projects")
def stage_projects(req: StagePathReq):
try:
src = Source(req.source_path)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(status_code=400, detail=str(e))
return {"source_path": req.source_path, "projects": project_configs(src.files)}
def build_xlsx(sheets: list[StageSheet]) -> bytes:
"""시트 모형 → xlsx — 제목 · 안내 줄 · 머리(병합 {t,c,r}) · 값(숫자 #,##0 · 소수 4자리)."""
wb = Workbook()
+5 -278
View File
@@ -7,8 +7,6 @@
const Calc = global.STMATE_CALC;
const ViewPrjBoq = global.STMATE_VIEW_PRJ_BOQ;
const ViewDanIlwi = global.STMATE_VIEW_DAN_ILWI;
const ViewKanjubPrt = global.STMATE_VIEW_KANJUB_PRT;
const { fmt, num } = Calc;
const $ = (id) => document.getElementById(id),
closeModal = () => $("stmate-modal-backdrop").classList.add("hidden"),
@@ -21,7 +19,6 @@
state: Calc.createDefaultState(),
computed: null,
sourcesData: { projects: [], jbr_files: [], work_root: "" },
catalogData: { codes: [], recipes: [], special_san: [] },
currentSourcePath: "",
dbfData: null,
uiState: {
@@ -36,14 +33,6 @@
ilwiSubFilter: "ALL_BDX",
selectedIlwiCode: "",
editedSmtMap: {},
kanRateTab: "RATE_R.DBF",
stageSubTab: "CONTRACT",
bidRateM: 87.745,
bidRateL: 87.745,
bidRateS: 87.745,
prevPct: 30.0,
progPct: 65.0,
activeReportSheet: "0608",
},
};
@@ -86,28 +75,13 @@
});
}
const RENDERERS = {
wPrjList: ViewPrjBoq.renderWPrjList,
wBoq: ViewPrjBoq.renderWBoq,
wM_Set_Sys: ViewDanIlwi.renderWMSetSys,
wM_JabPum: ViewDanIlwi.renderWMJabPum,
wM_Edit_iLWi: ViewDanIlwi.renderWMEditIlwi,
wM_KanJub: ViewKanjubPrt.renderWMKanJub,
wM_KAN_RATE: ViewKanjubPrt.renderWMKanRate,
wStage: ViewKanjubPrt.renderWStage,
PM_Prt: ViewKanjubPrt.renderPMPrt,
};
function renderWorkspace() {
recompute();
renderChromeBars();
const ws = $("mdi-workspace");
if (!ws) return;
const scr = PLUG.screens[ctx.uiState.activeScreen];
ws.innerHTML = scr
? scr.render(ctx, API)
: (RENDERERS[ctx.uiState.activeScreen] || ViewPrjBoq.renderWPrjList)(ctx);
bindWorkspaceEvents();
const scr = PLUG.screens[ctx.uiState.activeScreen] || PLUG.screens.wPrjList;
ws.innerHTML = scr ? scr.render(ctx, API) : "";
if (scr && scr.bind) scr.bind(ctx, API);
}
@@ -198,209 +172,11 @@
$("modal-title-text").textContent = title;
$("modal-body-content").innerHTML = bodyHtml;
$("stmate-modal-backdrop").classList.remove("hidden");
bindModalEvents();
}
// 도구 창 바인딩·내역 행 끼워 넣기 — app_modals.js
const bindModalEvents = () => global.STMATE_APP_MODALS.bindModalEvents(API);
// 내역 행 끼워 넣기 — app_modals.js
const insertBoqRow = (...args) => global.STMATE_APP_MODALS.insertBoqRow(API, ...args);
function bindWorkspaceEvents() {
const goScreen = (scr) => {
ctx.uiState.activeScreen = scr;
renderWorkspace();
};
bindEach(".prj-row", "ondblclick", (tr) => {
const p = tr.getAttribute("data-path");
p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wBoq");
});
bindEach(".btn-load-prj", "onclick", (btn, e) => {
e.stopPropagation();
const p = btn.getAttribute("data-path");
p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wPrjList");
});
"prj-btn-open-boq:wBoq,prj-btn-kanjub:wM_KanJub,kj-btn-open-rate:wM_KAN_RATE"
.split(",")
.forEach((p) => {
const [id, scr] = p.split(":");
onClick(id, () => goScreen(scr));
});
["boq-btn-save-dbf", "kj-btn-save-dbf"].forEach((id) => onClick(id, saveAllToDbfServer));
onClick("prj-btn-toggle-preview", () => {
ctx.uiState.showBoqPreview = !ctx.uiState.showBoqPreview;
renderWorkspace();
});
onClick("prj-btn-edit-meta", () => {
const nextName = prompt("공사명(PNAME) 수정:", ctx.state.projectMeta.pname);
if (nextName) {
ctx.state.projectMeta.pname = nextName.trim();
if (ctx.dbfData && ctx.dbfData.prjlst) ctx.dbfData.prjlst.PNAME = nextName.trim();
renderWorkspace();
}
});
// 내역서·초기값·단가목록표 화면 묶음은 scr_ui_boq.js · scr_ui_sys.js · scr_ui_dan.js
bindModalOpener("ilwi-btn-load-recipe", "Act_CatalogModal");
bindEach(".ilwi-sub-btn", "onclick", (btn) => {
ctx.uiState.ilwiSubFilter = btn.getAttribute("data-sub");
renderWorkspace();
});
bindEach(".ilwi-master-row", "onclick", (tr) => {
ctx.uiState.selectedIlwiCode = tr.getAttribute("data-code");
renderWorkspace();
});
const smtArea = $("smt-formula-textarea");
if (smtArea)
smtArea.oninput = () =>
(ctx.uiState.editedSmtMap[smtArea.getAttribute("data-code")] = smtArea.value);
onClick("ilwi-btn-apply-f9", () => {
const code = $("ilwi-btn-apply-f9").getAttribute("data-code");
const txt = ($("smt-formula-textarea") || {}).value || "";
ctx.uiState.editedSmtMap[code] = txt;
if (ctx.dbfData && ctx.dbfData.costn_memos) {
const prev = ctx.dbfData.costn_memos[code] || { code, blockNo: 1 };
ctx.dbfData.costn_memos[code] = Object.assign({}, prev, { text: txt, byteLen: txt.length });
}
const evalRes = ViewDanIlwi.evaluateSmtFormulaText(txt, ctx, code);
if (evalRes.sumT > 0)
ViewPrjBoq.syncCostnUnitPrice(ctx, code, evalRes.sumM, evalRes.sumL, evalRes.sumS);
cascadeAndRender();
});
bindEach(".kj-scheme-tab", "onclick", (btn) => {
ctx.state.kanjubConfig.scheme = btn.getAttribute("data-scheme");
renderWorkspace();
});
bindEach(".kj-rate-input", "onchange", (inp) => {
ctx.state.kanjubConfig.rates[inp.getAttribute("data-key")] = num(inp.value);
renderWorkspace();
});
[
["kj-opt-anjun-gov", "anjunGovVatExcluded", (v) => v === "1"],
["kj-anjun-base", "anjunBaseAdd", (v) => num(v)],
["kj-anjun-cap", "anjunCap12", (v) => v === "1"],
["kj-ind-trunc", "indirectTruncDigits", (v) => parseInt(v, 10)],
["kj-vat-mode", "vatMode", (v) => v],
["kj-tot-jeol", "totJeolDigits", (v) => parseInt(v, 10)],
["kj-profit-adj", "enableProfitEndAdj", (v) => v === "1"],
["kj-gov-round", "govRoundMode", (v) => v],
].forEach(([id, key, fn]) =>
onChange(id, (e) => {
ctx.state.kanjubConfig[key] = fn(e.target.value);
renderWorkspace();
}),
);
onClick("kj-btn-apply-jbr", async () => {
const p = $("kj-jbr-select").value;
if (!p) return;
const d = await postJson("/api/load-jbr", { jbr_path: p });
Object.assign(ctx.state.kanjubConfig.rates, d.extracted_rates);
if (d.extracted_rates.anjunBaseAdd)
ctx.state.kanjubConfig.anjunBaseAdd = d.extracted_rates.anjunBaseAdd;
renderWorkspace();
});
bindEach(".kr-tab-btn", "onclick", (btn) => {
ctx.uiState.kanRateTab = btn.getAttribute("data-tab");
renderWorkspace();
});
onClick("kr-btn-load-jbr", async () => {
const p = $("kr-jbr-select").value;
if (!p) return;
const d = await postJson("/api/load-jbr", { jbr_path: p });
if (ctx.dbfData && ctx.dbfData.rate_tables) {
ctx.dbfData.rate_tables["RATE_R.DBF"] = d.rows;
ctx.uiState.kanRateTab = "RATE_R.DBF";
renderWorkspace();
}
});
bindEach(".stage-tab-btn", "onclick", (btn) => {
ctx.uiState.stageSubTab = btn.getAttribute("data-st");
renderWorkspace();
});
onClick("st-btn-apply-bid", () => {
["M:m", "L:l", "S:s"].forEach((p) => {
const [k, id] = p.split(":");
ctx.uiState["bidRate" + k] = num($("st-bid-" + id).value, 87.745);
});
renderWorkspace();
});
onClick("st-btn-apply-gisung", () => {
ctx.uiState.prevPct = num($("st-prev-pct").value, 30);
ctx.uiState.progPct = num($("st-prog-pct").value, 65);
renderWorkspace();
});
bindEach(".rev-edit-qty2", "onchange", (inp) => {
const item = ctx.state.boqItems.find((b) => b.id === inp.getAttribute("data-id"));
if (item) item.changedQty = num(inp.value);
renderWorkspace();
});
onChange("prt-sheet-select", (e) => {
ctx.uiState.activeReportSheet = e.target.value;
renderWorkspace();
});
onClick("prt-btn-xls", () => exportFullXlsx());
onClick("prt-btn-csv", () => {
const tbl = document.querySelector("#mdi-workspace table.dbgrid");
if (!tbl) return;
const rows = Array.from(tbl.querySelectorAll("tr")).map((tr) =>
Array.from(tr.querySelectorAll("th,td"))
.map((td) => `"${td.innerText.replace(/"/g, '""').trim()}"`)
.join(","),
);
downloadBlob(
"\uFEFF" + rows.join("\r\n"),
`STMATE_Report_${ctx.uiState.activeReportSheet}.csv`,
"text/csv;charset=utf-8",
);
});
onClick("prt-btn-xml", () => {
const xmlStr = global.STMATE_ENGINE
? global.STMATE_ENGINE.buildFullPpsXml(ctx)
: "<T3_C3B />";
downloadBlob(xmlStr, "PPS_CostExchange_V105.xml", "application/xml;charset=utf-8");
});
onClick("prt-btn-jdl", () => {
const lines = (ctx.computed.boqComputed.rows || []).map(
(r) => `JDL|${r.code}|${r.name}|${r.spec}|${r.unit}|${r.qty}|${r.tUnit}|${r.tAmt}`,
);
downloadBlob(lines.join("\r\n"), "STMATE_Export.JDL", "text/plain;charset=utf-8");
});
}
async function exportFullXlsx() {
try {
const fullBoq = ViewPrjBoq.evaluateFullBoqRows(ctx);
const res = await fetch("/api/export-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prj_meta: ctx.state.projectMeta,
costn_rows: (ctx.dbfData && ctx.dbfData.costn_rows) || [],
boq_rows: fullBoq,
kanjub_data: ctx.computed.kanjubComputed,
costn_memos: (ctx.dbfData && ctx.dbfData.costn_memos) || {},
recipes: (ctx.catalogData && ctx.catalogData.recipes) || [],
}),
});
if (!res.ok) throw new Error(await res.text());
downloadBlob(
await res.blob(),
`STMATE_Workbook_${ctx.state.projectMeta.pcode || "11"}.xlsx`,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
} catch (err) {
if (!global.STMATE_ENGINE) return alert("엑셀 내보내기 실패: " + err.message);
downloadBlob(
global.STMATE_ENGINE.buildMultiSheetExcelXml(ctx),
`STMATE_Workbook_${ctx.state.projectMeta.pcode || "11"}.xls`,
"application/vnd.ms-excel;charset=utf-8",
);
}
}
function downloadBlob(content, filename, mime) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
@@ -409,71 +185,22 @@
URL.revokeObjectURL(a.href);
}
const MENU_SCREEN_MAP = {
Act_PrjList: "wPrjList",
Act_Init: "wM_Set_Sys",
Act_BoqEdit: "wBoq",
Act_KanJub: "wM_KanJub",
Act_KanRate: "wM_KAN_RATE",
Act_StageEdit: "wStage",
Act_Print: "PM_Prt",
};
const MENU_MODAL_MAP = {
Act_BoxaModal: ["5개 가격지 비교 (wM_Boxa)", "dan:wM_Boxa"],
Act_EdtHalModal: ["기준단가 비율 일괄수정 (wM_Edt_Hal)", "dan:wM_Edt_Hal"],
Act_CatalogModal: ["STMATE 표준 카탈로그 브라우저", "dan:CATALOG"],
Act_ITNumModal: ["내역서 공종번호 자동부여 (wITNum)", "boq:wITNum"],
Act_BoqRateModal: ["내역 할증율 적용 (wBoqRate)", "boq:wBoqRate"],
Act_MRateModal: ["비목 단수 일괄보정 (wM_Rate)", "boq:wM_Rate"],
Act_ExpndModal: ["일위대가 풀어넣기 (wExpnd)", "boq:wExpnd"],
Act_QtyModal: ["수량산출서 연결 집계 (wQtyList)", "boq:wQtyList"],
Act_ReaInfoModal: ["STMATE w 8.0 REA 정적분석 아키텍처 & DBF/SMT 명세", "rea:info"],
};
function openMappedModal(key) {
const [title, spec] = MENU_MODAL_MAP[key];
const [grp, kind] = spec.split(":");
openModal(
title,
grp === "dan"
? ViewDanIlwi.buildDanModalHtml(kind, ctx)
: grp === "boq"
? ViewPrjBoq.buildBoqToolModalHtml(kind, ctx)
: `<div style="line-height:1.6;"><p><strong>1. 실행 모듈:</strong> <code>Start8x.exe</code> · <code>ST_Sub1.dll</code> · <code>ST_Prt.CPM</code></p><p><strong>2. DBF/SMT 직결:</strong> <code>COSTN.DBF</code> · <code>COSTN.SMT</code> · <code>BOQ11~14.DBF</code> · <code>PRJLST.DBF/.SMT</code> · <code>RATE_R/RA/M.DBF</code></p></div>`,
);
}
function bindModalOpener(id, key) {
onClick(id, () => openMappedModal(key));
}
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
Object.assign(API, {
saveAll: saveAllToDbfServer,
decorateSavePayload,
openMappedModal,
postSave,
});
async function initStmateApp() {
onClick("modal-close-btn", closeModal);
// 원본 메뉴·툴바·단축키 — ui_app_menu.js(끼워 넣은 메뉴·동작 포함) · 옛 동작 이름은 아래 표로
// 원본 메뉴·툴바·단축키 — ui_app_menu.js(끼워 넣은 메뉴·동작 포함)
global.STMATE_UI_APP_MENU.install(API);
$("main-menubar").addEventListener("click", (e) => {
const btn = e.target.closest("[data-act]");
const act = btn && btn.getAttribute("data-act");
if (!act || btn.disabled || PLUG.actions[act] || global.STMATE_UI_APP_MENU.H[act]) return;
if (MENU_SCREEN_MAP[act]) ctx.uiState.activeScreen = MENU_SCREEN_MAP[act];
else if (MENU_MODAL_MAP[act]) return openMappedModal(act);
renderWorkspace();
});
try {
const [srcRes, catRes] = await Promise.all([fetch("/api/sources"), fetch("/api/catalog")]);
const srcRes = await fetch("/api/sources");
if (srcRes.ok) ctx.sourcesData = await srcRes.json();
if (catRes.ok) ctx.catalogData = await catRes.json();
const sel = $("top-project-select");
if (sel && ctx.sourcesData.projects.length) {
sel.innerHTML = ctx.sourcesData.projects
+2 -26
View File
@@ -1,34 +1,10 @@
/**
* STMATE 도구 창 공통 바인딩 · 내역 행 끼워 넣기 (app_modals.js) — api = window.STMATE_APP
* STMATE 내역 행 끼워 넣기 (app_modals.js) — api = window.STMATE_APP
* 내역 도구(wITNum · wBoqRate · wM_Rate · wExpnd)는 scr_ui_boq_dlg.js · 가격지 비교(wM_Boxa)·기준단가 수정(wM_Edt_Hal)은 scr_ui_dan.js
* 여기 남은 것 = 수량산출서 연결(wQtyList 옛 창) · 표준 카탈로그 특수산식 불러오기
*/
(function (global) {
"use strict";
function bindModalEvents(api) {
const { ctx, Calc, bindEach, closeModal, renderWorkspace } = api;
bindEach(".btn-apply-qty-expr", "onclick", (btn) => {
const id = btn.getAttribute("data-id");
const inp = document.querySelector(`.qty-expr-input[data-id="${id}"]`);
const val = inp ? Calc.evalFormula(inp.value, {}) : 0;
const item = ctx.state.boqItems.find((b) => b.id === id);
if (!item || !(val > 0) || !ctx.dbfData) return;
const B = global.STMATE_UI_BOQ;
const model = { rows: ctx.dbfData.boq_rows, items: ctx.state.boqItems, deleted: [] };
B.setCell(model, item._recno, "QTY1", Math.round(val * 100) / 100);
renderWorkspace();
});
bindEach(".btn-use-special-san", "onclick", (btn) => {
const spec = (ctx.catalogData.special_san || [])[parseInt(btn.getAttribute("data-idx"), 10)];
if (!spec || !ctx.uiState.selectedIlwiCode) return;
ctx.uiState.editedSmtMap[ctx.uiState.selectedIlwiCode] = (spec["산식행"] || []).join("\r\n");
ctx.uiState.activeScreen = "wM_Edit_iLWi";
closeModal();
renderWorkspace();
});
}
/**
* 내역 행 끼워 넣기 — 선택 행 앞(선택 없으면 끝) · 저장 원천(boq_rows)·계산 원천(boqItems) 함께
* 코드가 단가목록에 있으면 명칭·규격·단위를 단가목록 값으로
@@ -60,5 +36,5 @@
return row;
}
global.STMATE_APP_MODALS = { bindModalEvents, insertBoqRow };
global.STMATE_APP_MODALS = { insertBoqRow };
})(window);
-111
View File
@@ -1,111 +0,0 @@
# -*- coding: utf-8 -*-
"""기준 제비율표 JB_* 43종 읽기(읽기 전용) — 설치 폴더 System\\ST_BSC\\*.JB_1·JB_M·JB_R·JB_RA.
근거: 10_System_186개_파일분석장부.md §4(배포파일 ↔ DBF 구조 ↔ 프로젝트 표) · 12번 §9(5행 묶음 · 열 1~5 간접노무 ·
6~10 기타경비 · 2020-05-27 확장 = 뒤에 15열 덧붙임) · 파일 이름 '2026(상_0413)_제비율.JB_R' = 기준일
판(edition): JB_1 41×10 · JB_M 25×10 · JB_R 구형 50×20 · JB_R 2020 확장 50×35 · JB_RA 50×20
프로젝트 짝: JB_1 → RATE.DBF · JB_M → RATE_M.DBF · JB_R → RATE_R.DBF · JB_RA → RATE_RA.DBF
"""
from __future__ import annotations
import pathlib
import re
from dbf_rw import DbfTable
JB_DIR = pathlib.Path(r"C:\경영정보(주)\System\ST_BSC")
TABLE_OF = {"JB_1": "RATE.DBF", "JB_M": "RATE_M.DBF", "JB_R": "RATE_R.DBF", "JB_RA": "RATE_RA.DBF"}
SHAPES = {"JB_1": {(41, 10)}, "JB_M": {(25, 10)}, "JB_R": {(50, 20), (50, 35)}, "JB_RA": {(50, 20)}}
def _num(s: str) -> float:
try:
return float(s.strip() or 0)
except ValueError:
return 0.0
def matrix_of(b: bytes) -> tuple[list[str], list[list[float]]]:
t = DbfTable(b)
return t.order, [[_num(t.get(i, f)) for f in t.order] for i in range(len(t))]
def base_date(name: str) -> str:
"""'2026(상_0413,표준시장)' → '2026-04-13' · 월일 없으면 연도만."""
m = re.match(r"^(\d{4})\([^_]*_?(\d{4})?", name)
if not m:
return ""
y, md = m.group(1), m.group(2)
return f"{y}-{md[:2]}-{md[2:]}" if md else y
def edition(kind: str, rows: int, cols: int) -> str:
if kind == "JB_R":
return "2020 확장(35열 · 국가유산 열)" if cols >= 35 else "구형(20열)"
return "단일판"
def read_jb(path) -> dict:
p = pathlib.Path(path)
kind = p.suffix.upper().lstrip(".")
fields, m = matrix_of(p.read_bytes())
shape = (len(m), len(fields))
return {
"name": p.name,
"path": str(p),
"kind": kind,
"table": TABLE_OF.get(kind, ""),
"date": base_date(p.stem),
"actual": "실적" in p.stem or "표준시장" in p.stem,
"rows": shape[0],
"cols": shape[1],
"edition": edition(kind, *shape),
"shape_ok": shape in SHAPES.get(kind, set()),
"fields": fields,
"matrix": m,
}
def list_jb(root: pathlib.Path | None = None) -> list[dict]:
"""설치 폴더 JB_* 전부(행렬 제외 요약) — 종류·기준일 차례."""
d = pathlib.Path(root or JB_DIR)
if not d.exists():
return []
out = []
for p in sorted(d.glob("*.JB_*")):
r = read_jb(p)
r.pop("matrix")
out.append(r)
return sorted(out, key=lambda r: (r["kind"], r["date"], r["name"]))
def match_project(files: dict, root: pathlib.Path | None = None) -> dict:
"""프로젝트 RATE*.DBF 마다 셀이 모두 같은 기준 JB 파일(없으면 가장 적게 다른 것과 다른 셀 수)."""
d = pathlib.Path(root or JB_DIR)
cands = [read_jb(p) for p in sorted(d.glob("*.JB_*"))] if d.exists() else []
out = {}
for kind, table in TABLE_OF.items():
if table not in files:
continue
_f, pm = matrix_of(files[table])
best = None
for c in (x for x in cands if x["kind"] == kind):
if len(c["matrix"]) != len(pm):
continue
diff = sum(
1
for a, b in zip(c["matrix"], pm)
for j in range(max(len(a), len(b)))
if abs((a[j] if j < len(a) else 0.0) - (b[j] if j < len(b) else 0.0)) > 1e-9
)
if best is None or diff < best[0]:
best = (diff, c["name"], c["date"])
if best:
out[table] = {
"jb": best[1],
"date": best[2],
"diff_cells": best[0],
"exact": best[0] == 0,
}
return out
-496
View File
@@ -1,496 +0,0 @@
"""STMATE 호환 21시트 전체 엑셀 빌더 (excel_builder.py).
- 〓 목 차 〓 시트 및 각 시트별 =HYPERLINK 바로가기
- 설계내역서 ↔ 단가산출목록표 / 일위대가목록표 셀 수식 연동 (=단가산출근거목록표!F15)
- 700줄 제한 준수.
"""
from io import BytesIO
from decimal import Decimal
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
def fnum(v, default=0.0):
try:
return float(Decimal(str(v or 0)))
except Exception:
return default
def inum(v, default=0):
try:
return int(Decimal(str(v or 0)))
except Exception:
return default
def build_stmate_full_xlsx(
prj_meta: dict,
costn_rows: list,
boq_rows: list,
kanjub_data: dict,
costn_memos: dict,
recipes: list,
) -> bytes:
wb = openpyxl.Workbook()
# remove default sheet
wb.remove(wb.active)
thin_border = Border(
left=Side(style="thin", color="D0D5DD"),
right=Side(style="thin", color="D0D5DD"),
top=Side(style="thin", color="D0D5DD"),
bottom=Side(style="thin", color="D0D5DD"),
)
header_fill = PatternFill(start_color="F1F5F9", end_color="F1F5F9", fill_type="solid")
header_font = Font(name="맑은 고딕", size=9, bold=True, color="1E293B")
data_font = Font(name="맑은 고딕", size=9)
bold_font = Font(name="맑은 고딕", size=9, bold=True)
link_font = Font(name="맑은 고딕", size=9, color="1D4ED8", underline="single")
title_font = Font(name="맑은 고딕", size=14, bold=True)
# 1. 〓 목 차 〓
ws_idx = wb.create_sheet(title="〓 목 차 〓")
ws_idx.views.sheetView[0].showGridLines = True
ws_idx.cell(
row=2, column=2, value="공사명 : " + str(prj_meta.get("pname", ""))
).font = title_font
ws_idx.cell(row=3, column=2, value="목 차").font = Font(name="맑은 고딕", size=12, bold=True)
sheet_catalog = [
("공사원가계산서", "원가 총괄 요약표"),
("설계내역서", "공종별 내역 및 금액 산출"),
("일위대가목록표", "일위대가 호표 및 단가 일람"),
("일위대가표", "일위대가 세부 구성 명세"),
("단가산출근거목록표", "단가산출서 호표 및 단가 일람"),
("단가산출근거", "단가산출 수식 및 인자 전개"),
("중기목록표", "건설기계 사용료 목록"),
("중기사용료", "중기 기계경비 세부 산정"),
("재료비목록표", "자재 단가 및 규격"),
("노무비목록표", "시중 노임 단가 및 직종"),
("경비목록표", "기타 경비 항목 단가"),
("환율및기초자료", "환율 및 기초 설정"),
("〓 INITIAL 〓", "STMATE 계산 옵션 메타데이터"),
]
for i, (sname, desc) in enumerate(sheet_catalog, start=5):
ws_idx.cell(row=i, column=2, value=f"{i - 4}. {sname}").font = bold_font
ws_idx.cell(row=i, column=3, value=desc).font = data_font
cell_link = ws_idx.cell(
row=i, column=4, value=f'=HYPERLINK("#\'{sname}\'!A1", "{sname} →")'
)
cell_link.font = link_font
# 2. 공사원가계산서
ws_won = wb.create_sheet(title="공사원가계산서")
ws_won.views.sheetView[0].showGridLines = True
ws_won.cell(row=1, column=1, value="공사원가계산서").font = title_font
ws_won.cell(
row=1, column=6, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
w_headers = ["비목", "산정기초 금액(원)", "적용요율(%)", "산출금액(원)", "비고"]
for c, h in enumerate(w_headers, 1):
cell = ws_won.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center", vertical="center")
w_lines = kanjub_data.get("lines", [])
for r, ln in enumerate(w_lines, start=4):
c1 = ws_won.cell(row=r, column=1, value=ln.get("label", ""))
c2 = ws_won.cell(row=r, column=2, value=inum(ln.get("base", 0)))
c3 = ws_won.cell(row=r, column=3, value=fnum(ln.get("rate", 0)))
c4 = ws_won.cell(row=r, column=4, value=inum(ln.get("amount", 0)))
c5 = ws_won.cell(row=r, column=5, value=ln.get("formula", ""))
c2.number_format = "#,##0"
c3.number_format = "0.000%" if fnum(ln.get("rate", 0)) < 1 else "0.00"
c4.number_format = "#,##0"
c1.alignment = Alignment(horizontal="left")
c2.alignment = c4.alignment = Alignment(horizontal="right")
c3.alignment = Alignment(horizontal="center")
for cell in (c1, c2, c3, c4, c5):
cell.font, cell.border = data_font, thin_border
# 3. 일위대가목록표 & 4. 단가산출근거목록표 사전 맵 구축
san_map = {}
ilwi_map = {}
d_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("D")]
b_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("B")]
x_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("X")]
ws_san_idx = wb.create_sheet(title="단가산출근거목록표")
ws_san_idx.views.sheetView[0].showGridLines = True
ws_san_idx.cell(row=1, column=1, value="단가산출근거목록표").font = title_font
ws_san_idx.cell(
row=1, column=9, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
san_headers = [
"순번",
"코드",
"품명",
"규격",
"단위",
"재료비",
"노무비",
"경비",
"합계단가",
"상세링크",
]
for c, h in enumerate(san_headers, 1):
cell = ws_san_idx.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
for idx, dr in enumerate(d_rows, start=4):
code = str(dr.get("CODE", "")).strip()
san_map[code] = idx
ws_san_idx.cell(row=idx, column=1, value=f"산근 {idx - 3}호표").alignment = Alignment(
horizontal="center"
)
ws_san_idx.cell(row=idx, column=2, value=code).alignment = Alignment(horizontal="center")
ws_san_idx.cell(row=idx, column=3, value=dr.get("NAME", ""))
ws_san_idx.cell(row=idx, column=4, value=dr.get("SIZE", ""))
ws_san_idx.cell(row=idx, column=5, value=dr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
c_m = ws_san_idx.cell(row=idx, column=6, value=fnum(dr.get("MAMT1", 0)))
c_l = ws_san_idx.cell(row=idx, column=7, value=fnum(dr.get("LAMT1", 0)))
c_s = ws_san_idx.cell(row=idx, column=8, value=fnum(dr.get("SAMT1", 0)))
c_t = ws_san_idx.cell(row=idx, column=9, value=f"=F{idx}+G{idx}+H{idx}")
for cell in (c_m, c_l, c_s, c_t):
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
link_c = ws_san_idx.cell(
row=idx,
column=10,
value=f'=HYPERLINK("#\'단가산출근거\'!A{idx}", "{code} 상세 →")',
)
link_c.font = link_font
for c in range(1, 11):
ws_san_idx.cell(row=idx, column=c).border = thin_border
if c != 10:
ws_san_idx.cell(row=idx, column=c).font = data_font
ws_ilwi_idx = wb.create_sheet(title="일위대가목록표")
ws_ilwi_idx.views.sheetView[0].showGridLines = True
ws_ilwi_idx.cell(row=1, column=1, value="일위대가목록표").font = title_font
ws_ilwi_idx.cell(
row=1, column=9, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(san_headers, 1):
cell = ws_ilwi_idx.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
for idx, br in enumerate(b_rows, start=4):
code = str(br.get("CODE", "")).strip()
ilwi_map[code] = idx
ws_ilwi_idx.cell(row=idx, column=1, value=f"제 {idx - 3}호표").alignment = Alignment(
horizontal="center"
)
ws_ilwi_idx.cell(row=idx, column=2, value=code).alignment = Alignment(horizontal="center")
ws_ilwi_idx.cell(row=idx, column=3, value=br.get("NAME", ""))
ws_ilwi_idx.cell(row=idx, column=4, value=br.get("SIZE", ""))
ws_ilwi_idx.cell(row=idx, column=5, value=br.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
c_m = ws_ilwi_idx.cell(row=idx, column=6, value=fnum(br.get("MAMT1", 0)))
c_l = ws_ilwi_idx.cell(row=idx, column=7, value=fnum(br.get("LAMT1", 0)))
c_s = ws_ilwi_idx.cell(row=idx, column=8, value=fnum(br.get("SAMT1", 0)))
c_t = ws_ilwi_idx.cell(row=idx, column=9, value=f"=F{idx}+G{idx}+H{idx}")
for cell in (c_m, c_l, c_s, c_t):
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
link_c = ws_ilwi_idx.cell(
row=idx,
column=10,
value=f'=HYPERLINK("#\'일위대가표\'!A{idx}", "{code} 상세 →")',
)
link_c.font = link_font
for c in range(1, 11):
ws_ilwi_idx.cell(row=idx, column=c).border = thin_border
if c != 10:
ws_ilwi_idx.cell(row=idx, column=c).font = data_font
# 5. 설계내역서 (하이퍼링크 & 목록표 셀 참조 수식 연동)
ws_boq = wb.create_sheet(title="설계내역서")
ws_boq.views.sheetView[0].showGridLines = True
ws_boq.cell(row=1, column=1, value="설계내역서").font = title_font
ws_boq.cell(
row=1, column=13, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
boq_headers = [
"공종번호",
"공종 및 품명",
"규격",
"수량",
"단위",
"재료비단가",
"노무비단가",
"경비단가",
"합계단가",
"재료비금액",
"노무비금액",
"경비금액",
"합계금액",
"호표연결",
]
for c, h in enumerate(boq_headers, 1):
cell = ws_boq.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
for r_idx, br in enumerate(boq_rows, start=4):
code = str(br.get("BDCODE1", "")).strip()
itnum = str(br.get("ITNUM", "")).strip()
name = str(br.get("NAME1", "")).strip()
spec = str(br.get("SIZE1", "")).strip()
unit = str(br.get("UNIT1", "")).strip()
qty = fnum(br.get("QTY1", 0))
is_sub = code.startswith("T") or code.startswith("E") or code.startswith("C") or not code
ws_boq.cell(row=r_idx, column=1, value=itnum).alignment = Alignment(horizontal="center")
ws_boq.cell(row=r_idx, column=2, value=name).font = bold_font if is_sub else data_font
ws_boq.cell(row=r_idx, column=3, value=spec)
ws_boq.cell(row=r_idx, column=4, value=qty if not is_sub else "").number_format = "#,##0.00"
ws_boq.cell(row=r_idx, column=5, value=unit).alignment = Alignment(horizontal="center")
if not is_sub and code in san_map:
s_row = san_map[code]
ws_boq.cell(row=r_idx, column=6, value=f"=단가산출근거목록표!F{s_row}")
ws_boq.cell(row=r_idx, column=7, value=f"=단가산출근거목록표!G{s_row}")
ws_boq.cell(row=r_idx, column=8, value=f"=단가산출근거목록표!H{s_row}")
link_txt = f"산근 {s_row - 3}호표"
ws_boq.cell(
row=r_idx,
column=14,
value=f'=HYPERLINK("#\'단가산출근거목록표\'!A{s_row}", "{link_txt} →")',
).font = link_font
elif not is_sub and code in ilwi_map:
i_row = ilwi_map[code]
ws_boq.cell(row=r_idx, column=6, value=f"=일위대가목록표!F{i_row}")
ws_boq.cell(row=r_idx, column=7, value=f"=일위대가목록표!G{i_row}")
ws_boq.cell(row=r_idx, column=8, value=f"=일위대가목록표!H{i_row}")
link_txt = f"제 {i_row - 3}호표"
ws_boq.cell(
row=r_idx,
column=14,
value=f'=HYPERLINK("#\'일위대가목록표\'!A{i_row}", "{link_txt} →")',
).font = link_font
else:
ws_boq.cell(
row=r_idx,
column=6,
value=fnum(br.get("MAMT1", 0)) if not is_sub else 0,
)
ws_boq.cell(
row=r_idx,
column=7,
value=fnum(br.get("LAMT1", 0)) if not is_sub else 0,
)
ws_boq.cell(
row=r_idx,
column=8,
value=fnum(br.get("SAMT1", 0)) if not is_sub else 0,
)
ws_boq.cell(row=r_idx, column=14, value="")
ws_boq.cell(row=r_idx, column=9, value=f"=F{r_idx}+G{r_idx}+H{r_idx}")
ws_boq.cell(row=r_idx, column=10, value=f"=ROUNDDOWN(D{r_idx}*F{r_idx}, 0)") # 재료비금액
ws_boq.cell(row=r_idx, column=11, value=f"=ROUNDDOWN(D{r_idx}*G{r_idx}, 0)") # 노무비금액
ws_boq.cell(row=r_idx, column=12, value=f"=ROUNDDOWN(D{r_idx}*H{r_idx}, 0)") # 경비금액
ws_boq.cell(row=r_idx, column=13, value=f"=J{r_idx}+K{r_idx}+L{r_idx}") # 합계금액
for c in range(6, 14):
ws_boq.cell(row=r_idx, column=c).number_format = "#,##0"
ws_boq.cell(row=r_idx, column=c).alignment = Alignment(horizontal="right")
for c in range(1, 15):
ws_boq.cell(row=r_idx, column=c).border = thin_border
if c != 14:
ws_boq.cell(row=r_idx, column=c).font = bold_font if is_sub else data_font
# 6. 기타 부속 시트들 (중기사용료, 자재, 노무, 경비, INITIAL)
_build_sub_sheets(
wb,
costn_rows,
costn_memos,
recipes,
thin_border,
header_fill,
header_font,
data_font,
bold_font,
link_font,
title_font,
)
# 자동 열 너비 조정
for ws in wb.worksheets:
for col in ws.columns:
max_len = max(len(str(cell.value or "")) for cell in col[:40])
col_letter = get_column_letter(col[0].column)
ws.column_dimensions[col_letter].width = min(max(max_len + 3, 10), 45)
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _build_sub_sheets(
wb,
costn_rows,
costn_memos,
recipes,
thin_border,
header_fill,
header_font,
data_font,
bold_font,
link_font,
title_font,
):
# 중기사용료
ws_x = wb.create_sheet(title="중기사용료")
ws_x.views.sheetView[0].showGridLines = True
ws_x.cell(row=1, column=1, value="중기사용료").font = title_font
ws_x.cell(row=1, column=8, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")').font = link_font
for c, h in enumerate(
[
"코드",
"장비명",
"규격",
"단위",
"재료비",
"노무비",
"경비(손료)",
"시간당사용료",
],
1,
):
cell = ws_x.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
x_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("X")]
for r_idx, xr in enumerate(x_rows, start=4):
ws_x.cell(row=r_idx, column=1, value=xr.get("CODE", "")).alignment = Alignment(
horizontal="center"
)
ws_x.cell(row=r_idx, column=2, value=xr.get("NAME", ""))
ws_x.cell(row=r_idx, column=3, value=xr.get("SIZE", ""))
ws_x.cell(row=r_idx, column=4, value=xr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
for c, k in enumerate(["MAMT1", "LAMT1", "SAMT1", "TAMT1"], 5):
cell = ws_x.cell(row=r_idx, column=c, value=fnum(xr.get(k, 0)))
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
for c in range(1, 9):
ws_x.cell(row=r_idx, column=c).border = thin_border
ws_x.cell(row=r_idx, column=c).font = data_font
# 단가산출근거 상세
ws_d_det = wb.create_sheet(title="단가산출근거")
ws_d_det.views.sheetView[0].showGridLines = True
ws_d_det.cell(row=1, column=1, value="단가산출근거 세부").font = title_font
ws_d_det.cell(
row=1, column=6, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(
["호표코드", "공종명", "규격", "단위", "합계단가", "COSTN.SMT 산출식 본문"],
1,
):
cell = ws_d_det.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
d_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("D")]
for r_idx, dr in enumerate(d_rows, start=4):
code = str(dr.get("CODE", "")).strip()
ws_d_det.cell(row=r_idx, column=1, value=code).alignment = Alignment(horizontal="center")
ws_d_det.cell(row=r_idx, column=2, value=dr.get("NAME", ""))
ws_d_det.cell(row=r_idx, column=3, value=dr.get("SIZE", ""))
ws_d_det.cell(row=r_idx, column=4, value=dr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
cell_t = ws_d_det.cell(row=r_idx, column=5, value=fnum(dr.get("TAMT1", 0)))
cell_t.number_format = "#,##0"
cell_t.alignment = Alignment(horizontal="right")
m_txt = costn_memos.get(code, {}).get("text", "")
ws_d_det.cell(row=r_idx, column=6, value=m_txt.replace("\r\n", " | "))
for c in range(1, 7):
ws_d_det.cell(row=r_idx, column=c).border = thin_border
ws_d_det.cell(row=r_idx, column=c).font = data_font
# 일위대가표 상세
ws_b_det = wb.create_sheet(title="일위대가표")
ws_b_det.views.sheetView[0].showGridLines = True
ws_b_det.cell(row=1, column=1, value="일위대가 세부 명세").font = title_font
ws_b_det.cell(
row=1, column=8, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(
["호표코드", "품명", "규격", "단위", "수량", "재료비단가", "노무비단가", "경비단가"],
1,
):
cell = ws_b_det.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
b_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith("B")]
for r_idx, br in enumerate(b_rows, start=4):
ws_b_det.cell(row=r_idx, column=1, value=br.get("CODE", "")).alignment = Alignment(
horizontal="center"
)
ws_b_det.cell(row=r_idx, column=2, value=br.get("NAME", ""))
ws_b_det.cell(row=r_idx, column=3, value=br.get("SIZE", ""))
ws_b_det.cell(row=r_idx, column=4, value=br.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
ws_b_det.cell(row=r_idx, column=5, value=1).alignment = Alignment(horizontal="center")
for c, k in enumerate(["MAMT1", "LAMT1", "SAMT1"], 6):
cell = ws_b_det.cell(row=r_idx, column=c, value=fnum(br.get(k, 0)))
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
for c in range(1, 9):
ws_b_det.cell(row=r_idx, column=c).border = thin_border
ws_b_det.cell(row=r_idx, column=c).font = data_font
# 자재/노무/경비 목록표
for pfx, s_title, col_lbl in [
("M", "재료비목록표", "자재명"),
("L", "노무비목록표", "직종명"),
("S", "경비목록표", "경비항목명"),
]:
ws_sub = wb.create_sheet(title=s_title)
ws_sub.views.sheetView[0].showGridLines = True
ws_sub.cell(row=1, column=1, value=s_title).font = title_font
ws_sub.cell(
row=1, column=7, value='=HYPERLINK("#\'〓 목 차 〓\'!B2", "목차 →")'
).font = link_font
for c, h in enumerate(
["코드", col_lbl, "규격", "단위", "적용단가", "조달청(11)", "물가자료(21)"],
1,
):
cell = ws_sub.cell(row=3, column=c, value=h)
cell.fill, cell.font, cell.border = header_fill, header_font, thin_border
cell.alignment = Alignment(horizontal="center")
p_rows = [r for r in costn_rows if str(r.get("CODE", "")).strip().startswith(pfx)]
for r_idx, pr in enumerate(p_rows, start=4):
ws_sub.cell(row=r_idx, column=1, value=pr.get("CODE", "")).alignment = Alignment(
horizontal="center"
)
ws_sub.cell(row=r_idx, column=2, value=pr.get("NAME", ""))
ws_sub.cell(row=r_idx, column=3, value=pr.get("SIZE", ""))
ws_sub.cell(row=r_idx, column=4, value=pr.get("UNIT", "")).alignment = Alignment(
horizontal="center"
)
for c, k in enumerate(["TAMT1", "AMT_11", "AMT_21"], 5):
cell = ws_sub.cell(row=r_idx, column=c, value=fnum(pr.get(k, 0)))
cell.number_format = "#,##0"
cell.alignment = Alignment(horizontal="right")
for c in range(1, 8):
ws_sub.cell(row=r_idx, column=c).border = thin_border
ws_sub.cell(row=r_idx, column=c).font = data_font
+1 -3
View File
@@ -37,7 +37,7 @@
<footer id="stmate-statusbar" class="stmate-statusbar"></footer>
</div>
<!-- 범용 모달 컨테이너 (wITNum, wBoqRate, wM_Rate, wExpnd, wM_Boxa, wM_Edt_Hal, 표준카탈로그 등) -->
<!-- 범용 모달 컨테이너 (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">
@@ -57,8 +57,6 @@
<script src="calc_a2w.js"></script>
<script src="save_payload.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_modals.js"></script>
<script src="ui_menu.js"></script>
<script src="ui_app_menu.js"></script>
-18
View File
@@ -191,21 +191,3 @@ def build_report_xlsx(sheets: list[dict], formulas: bool = False, prn: dict | No
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def build_csv(sheet: dict) -> str:
"""시트 하나 → CSV(쉼표 · 큰따옴표 감쌈 · 엑셀 한글용 BOM 은 호출 쪽)."""
rows = sheet.get("rows") or []
max_col = max(
(column_index_from_string(k) for r in rows for k in _cells(r)),
default=1,
)
out = []
for row in rows:
vals = []
for c_idx in range(1, max_col + 1):
v = row.get(get_column_letter(c_idx))
s = "" if v is None else str(v)
vals.append('"' + s.replace('"', '""') + '"' if re.search(r'[",\n]', s) else s)
out.append(",".join(vals))
return "\r\n".join(out) + "\r\n"
+1 -19
View File
@@ -1,7 +1,7 @@
/**
* STMATE w 8.0 — 변환·가져오기 끼워 넣기 (scr_conv.js) · 1번(파일) 메뉴
* 조달청 XML 가져오기(Conv_XML_ST8) · XML 변환 설정(uM_XML_Info · uM_ConstType) · 엑셀 → 내역(wM_From_XLS) ·
* 엑셀 역등록(CONV_XLS_2_ST) · 기준 BOQ·자재 기준자료·코드 변경표 엑셀 왕복 · 분리형 변경 STC · JB_* 제비율표
* 엑셀 역등록(CONV_XLS_2_ST) · 기준 BOQ·자재 기준자료·코드 변경표 엑셀 왕복 · 분리형 변경 STC
* 서버: api_conv.py — 쓰기는 work_dbf/ 아래만 · 파일은 화면에서 골라 base64 로 보냄
*/
(function (global) {
@@ -182,23 +182,6 @@
);
await openSaved(r.saved_dir);
},
async jb(ctx) {
const L = await req("/api/conv/jb-list");
const m = ctx.currentSourcePath
? await req("/api/conv/jb-match", { source_path: ctx.currentSourcePath })
: {};
const by = {};
for (const f of L.files)
by[`${f.kind} ${f.edition}`] = (by[`${f.kind} ${f.edition}`] || 0) + 1;
alert(
`[기준 제비율표 JB_*] ${L.files.length}개 (${L.dir})\n${Object.entries(by)
.map(([k, v]) => `${k}: ${v}`)
.join("\n")}\n\n[이 공사 RATE 표 ↔ 기준]\n` +
Object.entries(m)
.map(([t, v]) => `${t}: ${v.jb} ${v.exact ? "(같음)" : `(다른 칸 ${v.diff_cells})`}`)
.join("\n"),
);
},
};
async function run(act) {
@@ -218,7 +201,6 @@
["Act_ConvCodeXlsx", "코드 변경표 엑셀로 내보내기 (ST_U_ChgCode)", "codeXlsx"],
["Act_ConvCodeChange", "코드 일괄 변경 (ST_U_ChgCode)", "codeChange"],
["Act_ConvSplitChange", "분리형 변경 STC 만들기 — 차수 인계", "splitChange"],
["Act_ConvJb", "기준 제비율표 JB_* 보기", "jb"],
].forEach(([act, label, key]) => {
PLUG.actions[act] = () => run(key);
PLUG.menus.push({ menu: "m1", label, act });
+1 -8
View File
@@ -1,6 +1,6 @@
/* -*- coding: utf-8 -*- */
/**
* 보고서 출력 화면 (scr_report.js) — PM_Prt 대체 · 서식 목록(SET_REP 75종) · 미리보기 · 인쇄 · 엑셀/CSV · 조달청 XML
* 보고서 출력 화면 (scr_report.js) — PM_Prt 대체 · 서식 목록(SET_REP 75종) · 미리보기 · 인쇄 · 엑셀 · 조달청 XML
* 인쇄 선택(옵션·순서·영역) = scr_report_opts.js · 양식 변형(견본 없는 서식) = report_forms.js
* 근거: 지식DB STmate/old/20_분석 20 §3(SET_REP) · 30(조달청 XSD) · old/30_원자료/print_form_map.txt
* 계산 = calc.js 결과(ctx.computed) · 시트 모형 = report_*.js(엑셀 대조 시험 resources/tester/test_stmate_report_*.py)
@@ -162,7 +162,6 @@
<button class="win-btn" id="rp-print-all">인쇄(묶음 전체)</button>
<button class="win-btn" id="rp-xlsx-all">엑셀 내보내기(전체)</button>
<button class="win-btn" id="rp-xlsx-one">엑셀(이 시트)</button>
<button class="win-btn" id="rp-csv">CSV(이 시트)</button>
<button class="win-btn" id="rp-pps">조달청 XML</button>
<span id="rp-msg"></span>
</div>
@@ -230,12 +229,6 @@
() => msg("엑셀 저장"),
(e) => msg(e.message),
);
if ($("rp-csv"))
$("rp-csv").onclick = () =>
post("/api/report/csv", [cur()], `${cur().name}.csv`, "text/csv").then(
() => msg("CSV 저장"),
(e) => msg(e.message),
);
if ($("rp-pps"))
$("rp-pps").onclick = async () => {
try {
+6 -38
View File
@@ -95,14 +95,11 @@
const conv = K.convert(tree, Object.assign({}, o, { pclass }), st, base);
const designKj = S.kj(ctx, st);
const designCfg = (ctx.state.kanjubStages || {})[st] || ctx.state.kanjubConfig;
const cfg =
o.useStored && o.storedCfg
? o.storedCfg
: K.defaultContractCfg(designCfg, designKj, {
iyunRate: o.iyunRate === "" ? null : n0(o.iyunRate),
taxFree: o.taxFree,
rateM: K.effRates(Object.assign({}, o, { pclass })).M,
});
const cfg = K.defaultContractCfg(designCfg, designKj, {
iyunRate: o.iyunRate === "" ? null : n0(o.iyunRate),
taxFree: o.taxFree,
rateM: K.effRates(Object.assign({}, o, { pclass })).M,
});
const kj = K.contractKanjub(conv, cfg);
return { o, st, tree, conv, cfg, kj, designKj, pclass, preview: K.preview(conv) };
}
@@ -208,8 +205,7 @@
}
<span>단가 절사 자리</span>${inp("ct-dg", o.digits, 40)}
<label><input type="checkbox" id="ct-gov" ${o.excludeGov ? "checked" : ""}/> 관급(C 구역) 제외</label>
<button class="win-btn primary" id="ct-reg">계약자료 변환등록</button>
<button class="win-btn" id="ct-stored">저장된 계약 공사 대조</button></div>
<button class="win-btn primary" id="ct-reg">계약자료 변환등록</button></div>
<div class="win-subbar">${OPTIONS.map(
([k, l]) =>
`<label style="margin-right:10px;"><input type="checkbox" class="ct-opt" data-k="${k}" ${o[k] ? "checked" : ""}/> ${l}</label>`,
@@ -220,7 +216,6 @@
<select id="ct-what" class="win-btn"><option value="direct" ${o.what === "direct" ? "selected" : ""}>직접공사비</option><option value="grand" ${o.what === "grand" ? "selected" : ""}>총공사비</option></select>
${inp("ct-target", o.target, 130)}<button class="win-btn" id="ct-find">적용율 찾기</button>
<span style="margin-left:12px;">계약 이윤율(%)</span>${inp("ct-iyun", o.iyunRate, 70)}
${o.storedCfg ? `<label><input type="checkbox" id="ct-usestored" ${o.useStored ? "checked" : ""}/> 저장된 계약 원가설정 사용</label>` : ""}
${o.found ? `<b style="margin-left:8px;">찾은 적용율 ${o.found.rate}% → ${S.fmt(o.found.value)}원</b>` : ""}</div>`;
const pv = S.grid(
[["적용금액 미리계산", "재료비", "노무비", "경비", "계"]],
@@ -307,7 +302,6 @@
api.onChange("ct-what", (e) => (o.what = e.target.value));
api.onChange("ct-target", (e) => (o.target = e.target.value));
api.onChange("ct-iyun", (e) => ((o.iyunRate = e.target.value.trim()), re()));
api.onChange("ct-usestored", (e) => ((o.useStored = e.target.checked), re()));
api.bindEach(
".ct-opt",
"onchange",
@@ -353,32 +347,6 @@
alert("변환등록 실패: " + e.message);
}
});
api.onClick("ct-stored", async () => {
if (!ctx.currentSourcePath) return alert("먼저 공사를 불러옴");
try {
const res = await api.postJson("/api/stage/projects", {
source_path: ctx.currentSourcePath,
});
const c = compute(ctx);
const ctr = res.projects.find(
(p) => p.pcode !== String(ctx.state.projectMeta.pcode || "11"),
);
if (!ctr) return alert("이 STC 에 저장된 계약(다른 PCODE) 공사 없음");
o.storedCfg = ctr.stages[1];
const rows = [
["직접공사비", n0(ctr.costs.TCOST1), c.conv.totals.directTotal],
["총공사비", n0(ctr.costs.COSTW1), c.kj.grandTotal],
];
api.openModal(
`저장된 계약 공사 대조 — PCODE ${ctr.pcode} ${ctr.pnum || ""}`,
S.grid([["항목", "저장값", "재현값"]], rows, { style: "" }) +
`<p>「저장된 계약 원가설정 사용」을 켜면 원가계산서에 그 공사의 설정(이윤율·당초액 유지 표시)을 씀</p>`,
);
re();
} catch (e) {
alert("대조 실패: " + e.message);
}
});
S.bindReports(ctx, api, builders(ctx));
}
+1 -5
View File
@@ -138,11 +138,7 @@
} else Object.assign(ctx.uiState, { selectedIlwiCode: c, activeScreen: "wM_Edit_iLWi" });
api.renderWorkspace();
},
qtyList: (ctx, api) => {
if (P.screens.wQtyList)
return ((ctx.uiState.activeScreen = "wQtyList"), api.renderWorkspace());
api.openMappedModal("Act_QtyModal");
},
qtyList: (ctx, api) => ((ctx.uiState.activeScreen = "wQtyList"), api.renderWorkspace()),
};
function shift(ctx, api, d) {
-36
View File
@@ -8,7 +8,6 @@ import pathlib
import re
from bdqty import decode_bdqty
from excel_builder import build_stmate_full_xlsx
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
@@ -17,7 +16,6 @@ from kanjub_cfg import build_kanjub_config, stage_slots
from pydantic import BaseModel
from smt_parser import (
extract_costn_memos,
load_stmate_catalog,
parse_dbf_bytes,
parse_ini_bytes,
parse_prjlst_smt,
@@ -455,11 +453,6 @@ 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)
@@ -611,35 +604,6 @@ def api_save(req: SaveDbfReq):
return res
class ExportXlsxReq(BaseModel):
prj_meta: dict = {}
costn_rows: list = []
boq_rows: list = []
kanjub_data: dict = {}
costn_memos: dict = {}
recipes: list = []
@app.post("/api/export-xlsx")
def api_export_xlsx(req: ExportXlsxReq):
try:
xlsx_bytes = build_stmate_full_xlsx(
req.prj_meta,
req.costn_rows,
req.boq_rows,
req.kanjub_data,
req.costn_memos,
req.recipes,
)
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="STMATE_Workbook.xlsx"'},
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 끼워 넣기 — api_*.py 의 router 자동 연결 · scr_*.js 화면 파일 자동 불러오기
for _mod in sorted(HERE.glob("api_*.py")):
app.include_router(importlib.import_module(_mod.stem).router)
+3 -261
View File
@@ -1,12 +1,12 @@
/**
* STMATE w 8.0 — SIxMemo(COSTN.SMT) 산근 연결 · 5단 연쇄재계산(Act_Calc_All) · 다중시트 엑셀/조달XML/JDL 변환기 (smt_engine.js)
* 근거: 17_단가조립_검증.md, 18_누락_계산로직_점검.md, 23_COSTN_SMT_전수파싱.md, 28_변환_수량산출_식언어.md, 31_일위대가_레시피_사전.md
* STMATE w 8.0 — SIxMemo(COSTN.SMT) 산근 연결 · 5단 연쇄재계산(Act_Calc_All) (smt_engine.js)
* 근거: 17_단가조립_검증.md, 18_누락_계산로직_점검.md, 23_COSTN_SMT_전수파싱.md, 28_변환_수량산출_식언어.md
*/
(function (global) {
"use strict";
const Calc = global.STMATE_CALC;
const { fmt, num } = Calc;
const { num } = Calc;
const SmtEval = global.STMATE_SMT_EVAL;
const rowCode = (r) => String(r.CODE || "").trim();
@@ -291,261 +291,6 @@
return count;
}
/**
* 3. 선택 호표(B/X)의 표준 레시피 구성행 조회
*/
function getRecipeBreakdown(ctx, code) {
const crows = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
const row = crows.find((r) => String(r.CODE || "").trim() === code);
if (!row) return [];
const nm = String(row.NAME || "").trim();
const sz = String(row.SIZE || "").trim();
const p = code[0].toUpperCase();
const recipes = (ctx.catalogData && ctx.catalogData.recipes) || [];
const kind = p === "X" ? "중기" : p === "B" ? "일위대가" : "단가산출";
const pool = recipes.filter((r) => r["종류"] === kind);
const rec =
pool.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) ||
pool.find((r) => r["명칭"].trim() === nm);
return rec && rec["구성"] ? rec["구성"] : [];
}
/**
* 4. 다중 시트 Excel SpreadsheetML (.xls) 통합 워크북 생성기 (갑지·원가계산서·설계내역서·일위대가·단가산출·중기·단가목록)
*/
function buildMultiSheetExcelXml(ctx) {
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
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) || {};
const mkSheet = (name, headers, rows) => `
<Worksheet ss:Name="${esc(name)}">
<Table>
<Row>${headers.map((h) => `<Cell><Data ss:Type="String">${esc(h)}</Data></Cell>`).join("")}</Row>
${rows
.map(
(r) =>
`<Row>${r
.map((c) => {
const isN = typeof c === "number" && Number.isFinite(c);
return `<Cell><Data ss:Type="${isN ? "Number" : "String"}">${esc(c)}</Data></Cell>`;
})
.join("")}</Row>`,
)
.join("\n")}
</Table>
</Worksheet>`;
const shWon = mkSheet(
"공사원가계산서",
["비목", "산정기초(원)", "요율(%)", "금액(원)", "산출근거"],
kj.lines.map((l) => [l.label, l.base || 0, l.rate || 0, l.amount || 0, l.formula || ""]),
);
const shBoq = mkSheet(
"설계내역서",
[
"공종번호",
"코드",
"공종명",
"규격",
"단위",
"수량",
"재료비단가",
"노무비단가",
"경비단가",
"합계단가",
"합계금액",
],
boqRows.map((r) => [
r.ITNUM || "",
r.BDCODE1 || "",
r.NAME1 || "",
r.SIZE1 || "",
r.UNIT1 || "",
num(r.QTY1),
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
num(r.T_AMT1),
]),
);
const shIlwi = mkSheet(
"일위대가목록표",
["코드", "품명", "규격", "단위", "재료비", "노무비", "경비", "합계단가"],
costnRows
.filter((r) => String(r.CODE || "").startsWith("B"))
.map((r) => [
r.CODE,
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
]),
);
const shSan = mkSheet(
"단가산출근거",
[
"코드",
"품명",
"규격",
"단위",
"재료비",
"노무비",
"경비",
"합계단가",
"산출근거(COSTN.SMT)",
],
costnRows
.filter((r) => String(r.CODE || "").startsWith("D") || memos[r.CODE])
.map((r) => [
r.CODE,
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
memos[r.CODE] ? memos[r.CODE].text.replace(/\r?\n/g, " | ") : "",
]),
);
const shJoong = mkSheet(
"중기사용료",
[
"코드",
"장비명",
"규격",
"단위",
"재료비(연료/잡품)",
"노무비(운전원)",
"경비(손료)",
"시간당사용료",
],
costnRows
.filter((r) => String(r.CODE || "").startsWith("X"))
.map((r) => [
r.CODE,
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
]),
);
const shDan = mkSheet(
"단가목록표",
[
"코드",
"구분",
"품명",
"규격",
"단위",
"재료비",
"노무비",
"경비",
"적용단가",
"조달단가",
"물가자료",
],
costnRows
.filter((r) => /^[LMS]/.test(String(r.CODE || "")))
.map((r) => [
r.CODE,
r.CODE[0],
r.NAME,
r.SIZE,
r.UNIT,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
num(r.TAMT1),
num(r.AMT_11),
num(r.AMT_21),
]),
);
return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
${shWon}
${shBoq}
${shIlwi}
${shSan}
${shJoong}
${shDan}
</Workbook>`;
}
/**
* 5. 조달청 공사원가 호환규정 V1.0.5 (Valid_Xml.vsl · T3_C3B) 전체 계층 XML 생성기
*/
function buildFullPpsXml(ctx) {
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const kj = ctx.computed.kanjubComputed;
const bt = ctx.computed.boqComputed.totals;
const boqRows = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx);
const costnRows = (ctx.dbfData && ctx.dbfData.costn_rows) || [];
return `<?xml version="1.0" encoding="UTF-8"?>
<T3_C3B xmlns="http://www.pps.go.kr/cost/v105" schemaVersion="1.0.5">
<Header>
<Program>STMATE w 8.0 (경영정보)</Program>
<ProjectCode>${esc(ctx.state.projectMeta.pcode)}</ProjectCode>
<ProjectName>${esc(ctx.state.projectMeta.pname)}</ProjectName>
<PriceStandard>${esc(ctx.state.projectMeta.pnum)}</PriceStandard>
<DirectMaterial>${bt.directM}</DirectMaterial>
<DirectLabor>${bt.directL}</DirectLabor>
<DirectExpense>${bt.directS}</DirectExpense>
<DirectCostTotal>${bt.directT}</DirectCostTotal>
<TotalSupplyAmount>${kj.FINAL_SUPPLY}</TotalSupplyAmount>
<GovMaterialAmount>${kj.GOV_MAT}</GovMaterialAmount>
<GrandTotalCost>${kj.GRAND_TOTAL}</GrandTotalCost>
</Header>
<CostSummary>
${kj.lines
.map(
(ln) =>
` <CostLine name="${esc(ln.label)}" base="${ln.base || 0}" rate="${ln.rate || 0}" amount="${ln.amount || 0}" />`,
)
.join("\n")}
</CostSummary>
<BoqItems count="${boqRows.length}">
${boqRows
.map(
(r) =>
` <BoqRow recno="${r._recno}" itnum="${esc(r.ITNUM)}" code="${esc(r.BDCODE1)}" name="${esc(r.NAME1)}" spec="${esc(r.SIZE1)}" unit="${esc(r.UNIT1)}" qty="${num(r.QTY1)}" mUnit="${num(r.MAMT1)}" lUnit="${num(r.LAMT1)}" sUnit="${num(r.SAMT1)}" tAmt="${num(r.T_AMT1)}" />`,
)
.join("\n")}
</BoqItems>
<UnitCostCatalog count="${costnRows.length}">
${costnRows
.map(
(c) =>
` <CostItem code="${esc(c.CODE)}" jdcode="${esc(c.JDCODE)}" name="${esc(c.NAME)}" spec="${esc(c.SIZE)}" unit="${esc(c.UNIT)}" m="${num(c.MAMT1)}" l="${num(c.LAMT1)}" s="${num(c.SAMT1)}" t="${num(c.TAMT1)}" />`,
)
.join("\n")}
</UnitCostCatalog>
</T3_C3B>`;
}
global.STMATE_ENGINE = {
evaluateSmtFormulaText,
cascadeRecalculateAll,
@@ -555,8 +300,5 @@
qtyResolver,
laborMap,
findCycles,
getRecipeBreakdown,
buildMultiSheetExcelXml,
buildFullPpsXml,
};
})(window);
+1 -55
View File
@@ -1,23 +1,16 @@
# -*- coding: utf-8 -*-
"""STMATE dBASE III/IV(DBF) · SIxMemo(COSTN.SMT / PRJLST.SMT) 바이너리 파서·빌더 및 표준 카탈로그 로더.
"""STMATE dBASE III/IV(DBF) · SIxMemo(COSTN.SMT / PRJLST.SMT) 바이너리 파서·빌더.
무손실 고쳐 쓰기는 dbf_rw(DBF 칸) · dbf_memo(SIxMemo 블록) · stc_save(저장) 를 씀 — build_dbf_bytes 는 새 표 만들 때만.
"""
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'=]+)")
@@ -292,53 +285,6 @@ def rebuild_costn_with_smt(
return out["COSTN.DBF"], out.get("COSTN.SMT", smt_bytes)
_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
def parse_ini_bytes(b: bytes) -> dict[str, str]:
"""Start8x.Ini / STini.MEM 키=값(섹션 무시) — 산근 계산 방식(CHGNM) · 차액 보정 순서(OPTNM) 등."""
out: dict[str, str] = {}
-504
View File
@@ -1,504 +0,0 @@
/**
* 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, codeHint) {
if (global.STMATE_ENGINE && global.STMATE_ENGINE.evaluateSmtFormulaText) {
return global.STMATE_ENGINE.evaluateSmtFormulaText(smtText, ctx, codeHint);
}
return { vars: {}, linesOut: [], sumM: 0, sumL: 0, sumS: 0, sumT: 0 };
}
/**
* 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.find((r) => Boolean(memos[String(r.CODE || "").trim()])) || bdxRows[0]
? String(
(bdxRows.find((r) => Boolean(memos[String(r.CODE || "").trim()])) || bdxRows[0]).CODE,
).trim()
: "";
const selRow = bdxRows.find((r) => String(r.CODE).trim() === selCode) || {};
const memoObj = memos[selCode] || null;
const recipeComps =
global.STMATE_ENGINE && global.STMATE_ENGINE.getRecipeBreakdown
? global.STMATE_ENGINE.getRecipeBreakdown(ctx, selCode)
: [];
const defaultText = memoObj
? memoObj.text
: recipeComps.length
? `'▣ ${selRow.NAME || selCode} (${selRow.SIZE || ""}) — 표준 호표 조립 구성 (${recipeComps.length}행)'\r\n` +
recipeComps
.map(
(c, ci) =>
`'${ci + 1}. ${c["명칭"]} (${c["규격"] || "-"}) : 수량 ${c["수량"]} ${c["단위"] || ""}'`,
)
.join("\r\n") +
`\r\n'노 무 비 : ${num(selRow.LAMT1)}'\r\n'재 료 비 : ${num(selRow.MAMT1)}'\r\n'경 비 : ${num(selRow.SAMT1)}'\r\n(==)`
: `'▣ ${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, selCode);
return `
<div class="win-form">
<div class="win-form-header">
<span>🧮 일위대가 · 단가산출근거 · 중기사용료 편집기 [wM_Edit_iLWi / wM_Edit_San / COSTN.SMT]</span>
<span>선택 호표: ${selCode} ${selRow.NAME || ""} (SIxMemo 블록 #${memoObj ? memoObj.blockNo : "-"}, ${memoObj ? memoObj.byteLen : 0} bytes)</span>
</div>
<div class="win-subbar">
<button class="win-btn ilwi-sub-btn ${subFilter === "ALL_BDX" ? "active" : ""}" data-sub="ALL_BDX">전체 대가·산근·중기 (${bdxRows.length})</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "SMT_ONLY" ? "active" : ""}" data-sub="SMT_ONLY">COSTN.SMT 산식보유 (${Object.keys(memos).length})</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "D" ? "active" : ""}" data-sub="D">단가산출 D</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "B" ? "active" : ""}" data-sub="B">일위대가 B</button>
<button class="win-btn ilwi-sub-btn ${subFilter === "X" ? "active" : ""}" data-sub="X">중기사용료 X</button>
<button class="win-btn" id="ilwi-btn-load-recipe" style="margin-left:auto;">📥 표준 레시피(350종)/특수산식(21종) 불러오기</button>
<button class="win-btn primary" id="ilwi-btn-apply-f9" data-code="${selCode}">⚡ F9 산식 계산결과를 호표단가에 반영</button>
</div>
<div class="split-horiz">
<!-- 좌측: B / D / X 호표 목록 -->
<div class="pane-box" style="width:430px;">
<div class="pane-title">
<span>호표 마스터 목록 (클릭 시 우측 COSTN.SMT 전개식 로드)</span>
<span>총 ${bdxRows.length}건</span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:66px;">코드</th>
<th>품명 (NAME)</th>
<th style="width:95px;">규격</th>
<th style="width:42px;">단위</th>
<th style="width:82px;">합계단가</th>
<th style="width:45px;">SMT</th>
</tr>
</thead>
<tbody>
${bdxRows
.map((r) => {
const c = String(r.CODE || "").trim();
const isSel = c === selCode;
const hasM = Boolean(memos[c]);
return `
<tr class="ilwi-master-row ${isSel ? "selected" : ""}" data-code="${c}" style="cursor:pointer;">
<td class="center"><strong>${c}</strong></td>
<td>${r.NAME || ""}</td>
<td>${r.SIZE || ""}</td>
<td class="center">${r.UNIT || ""}</td>
<td class="num"><strong>${fmt(num(r.TAMT1))}</strong></td>
<td class="center">${hasM ? "📝" : "-"}</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
</div>
<!-- 우측: COSTN.SMT 수식 편집기 + 실시간 평가 분석기 -->
<div class="split-vert" style="flex:1;padding:0;">
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>📝 COSTN.SMT 전개 텍스트 편집기 — <code>&amp;X00026L&amp;</code> 참조 · <code>Q=3600*q*K*f*E/Cm</code> · <code>:=A01</code> 문법 실시간 해석</span>
<span>DB 저장단가: 재 ${fmt(num(selRow.MAMT1))} / 노 ${fmt(num(selRow.LAMT1))} / 경 ${fmt(num(selRow.SAMT1))} / 계 ${fmt(num(selRow.TAMT1))}원</span>
</div>
<textarea id="smt-formula-textarea" class="smt-editor-area" data-code="${selCode}" spellcheck="false">${currentSmtText}</textarea>
</div>
<div class="pane-box" style="flex:1;">
<div class="pane-title">
<span>🔍 SIxMemo 수식 파서 실시간 평가 결과 (변수: ${
Object.keys(evalRes.vars)
.map((k) => `${k}=${evalRes.vars[k]}`)
.join(", ") || "없음"
})</span>
<span>평가 합계: 재 ${fmt(evalRes.sumM)} + 노 ${fmt(evalRes.sumL)} + 경 ${fmt(evalRes.sumS)} = <strong>${fmt(evalRes.sumT)}원</strong></span>
</div>
<div class="grid-scroll">
<table class="dbgrid">
<thead>
<tr>
<th style="width:40px;">행</th>
<th>산식 원문 (COSTN.SMT Line)</th>
<th style="width:150px;">변수 대입/평가</th>
<th style="width:120px;">자원참조(&amp;코드&amp;)</th>
<th style="width:95px;">참조단가</th>
<th style="width:95px;">산출금액</th>
</tr>
</thead>
<tbody>
${evalRes.linesOut
.map(
(ln) => `
<tr>
<td class="center">${ln.lineNo}</td>
<td style="font-family:Consolas,monospace;">${ln.raw}</td>
<td style="color:#0050b3;font-weight:700;">${ln.assigned || ""}</td>
<td class="center">${ln.refInfo ? `${ln.refInfo.refCode}[${ln.refInfo.compTag}] (${ln.refInfo.refName})` : ""}</td>
<td class="num">${ln.refInfo ? fmt(ln.refInfo.baseUnitPrice) : ""}</td>
<td class="num"><strong>${ln.refInfo ? fmt(ln.refInfo.calcAmt, 1) : ""}</strong></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
`;
}
/**
* 4. 단가/카탈로그 모달 생성기 (wM_Boxa / wM_Edt_Hal / 표준카탈로그 894코드·350레시피·21특수산식)
*/
function buildDanModalHtml(modalKind, ctx) {
if (modalKind === "wM_Boxa") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wM_Boxa] 5개 가격지 단가 비교·일괄 채택</strong> — 자재 후보단가(AMT_11~51) 중 지정 슬롯 또는 최저가를 적용단가(AMT_61/MAMT1)로 일괄 채택합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<div class="kanjub-field"><label>채택 기준</label>
<select id="boxa-adopt-mode">
<option value="min">최소단가 (0원 제외 최저가 자동채택)</option>
<option value="0">1단가 (조달청 가격정보 AMT_11)</option>
<option value="1">2단가 (물가자료 AMT_21)</option>
<option value="2">3단가 (물가정보 AMT_31)</option>
<option value="3">4단가 (유통물가 AMT_41)</option>
<option value="4">5단가 (거래가격 AMT_51)</option>
</select>
</div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-boxa">자재 적용단가 일괄 변경</button></div>
</div>
`;
}
if (modalKind === "wM_Edt_Hal") {
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wM_Edt_Hal] 기준단가 비율 일괄 수정</strong> — <code>적용단가 = 기준단가 × 비율(%)</code> (적용단가 0원 및 보호항목 UC=T 제외).</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>대상 비목</label><select id="edthal-kind"><option value="ALL">전체 (재·노·경)</option><option value="M">재료비(M)만</option><option value="L">노무비(L)만</option><option value="S">경비(S)만</option></select></div>
<div class="kanjub-field"><label>적용 비율(%)</label><input type="number" id="edthal-pct" value="98" step="0.5" /></div>
<div class="kanjub-field"><label>끝전 처리</label><select id="edthal-round"><option value="trunc">원단위 절사</option><option value="round">원단위 반올림</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-edthal">기준단가 비율 일괄 수정 실행</button></div>
</div>
`;
}
// 표준 카탈로그 (894 코드 · 350 일위대가 레시피 · 21 특수 단가산출)
const cat = ctx.catalogData || { codes: [], recipes: [], special_san: [] };
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;">
<strong>[STMATE 표준 카탈로그 브라우저]</strong> — <code>30_원자료</code>에서 추출한 표준코드 <strong>${cat.codes.length}종</strong> · 일위대가 레시피 <strong>${cat.recipes.length}종</strong> · 특수 단가산출 <strong>${cat.special_san.length}종</strong>
</p>
<div class="split-horiz" style="height:400px;padding:0;">
<div class="pane-box" style="flex:1;">
<div class="pane-title"><span>특수 단가산출 21개 (Q식/사이클식 원문)</span></div>
<div class="grid-scroll">
<table class="dbgrid">
<thead><tr><th>호표</th><th>명칭</th><th>단위</th><th>Q식</th><th>편집기로 복사</th></tr></thead>
<tbody>
${(cat.special_san || [])
.map(
(s, idx) => `
<tr>
<td class="center">#${s["호표"]}</td>
<td><strong>${s["명칭"]}</strong></td>
<td class="center">${s["단위"]}</td>
<td style="font-family:Consolas,monospace;">${(s["인자"] && s["인자"].Q) || ""}</td>
<td class="center"><button class="win-btn btn-use-special-san" data-idx="${idx}">산식적용</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
<div class="pane-box" style="flex:1;">
<div class="pane-title"><span>표준 일위대가 레시피 (${cat.recipes.length}종 중 상위 60종)</span></div>
<div class="grid-scroll">
<table class="dbgrid">
<thead><tr><th>구분</th><th>명칭</th><th>규격</th><th>단위</th><th>구성수</th></tr></thead>
<tbody>
${(cat.recipes || [])
.slice(0, 60)
.map(
(r) => `
<tr>
<td class="center">${r["종류"]}</td>
<td><strong>${r["명칭"]}</strong></td>
<td>${r["규격"] || ""}</td>
<td class="center">${r["단위"] || ""}</td>
<td class="center">${(r["구성"] || []).length}행</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
}
global.STMATE_VIEW_DAN_ILWI = {
evaluateSmtFormulaText,
renderWMSetSys,
renderWMJabPum,
renderWMEditIlwi,
buildDanModalHtml,
};
})(window);
-647
View File
@@ -1,647 +0,0 @@
/**
* 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:260px;">
<option value="0608" ${activeRep === "0608" ? "selected" : ""}>[0608] 공사원가계산서 (PM_Won_A)</option>
<option value="0601" ${activeRep === "0601" ? "selected" : ""}>[0601] 총괄설계내역서 (PM_Nae_Tot)</option>
<option value="0603" ${activeRep === "0603" ? "selected" : ""}>[0603] 설계내역서 (PM_Nae)</option>
<option value="0607" ${activeRep === "0607" ? "selected" : ""}>[0607] 관급자재내역서 (PM_Nae_Kwn)</option>
<option value="0643" ${activeRep === "0643" ? "selected" : ""}>[0643] 설계변경내역서/증감대비표 (CHANGE)</option>
<option value="0623" ${activeRep === "0623" ? "selected" : ""}>[0623/0673] 계약내역서·실행예산서</option>
<option value="0633" ${activeRep === "0633" ? "selected" : ""}>[0633] 기성부분내역서·수량조서</option>
<option value="0302" ${activeRep === "0302" ? "selected" : ""}>[0301/0302] 일위대가표·목록표 (PM_iLWi)</option>
<option value="0402" ${activeRep === "0402" ? "selected" : ""}>[0401/0402] 단가산출근거·목록표 (Edit_SanKun)</option>
<option value="0203" ${activeRep === "0203" ? "selected" : ""}>[0202/0203] 시간당중기사용료·목록표 (PM_JOONG)</option>
<option value="0101" ${activeRep === "0101" ? "selected" : ""}>[0101~0105] 노무·재료·경비·단가대비표</option>
<option value="0501" ${activeRep === "0501" ? "selected" : ""}>[0501~0507] 자원/중기/대가 수량금액집계표</option>
<option value="0201" ${activeRep === "0201" ? "selected" : ""}>[0201] 환율및기초자료 · 〓 INITIAL 〓</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-xls">📗 다중시트 엑셀(.xls 워크북) 내보내기</button>
<button class="win-btn" id="prt-btn-csv">📊 현재 시트 CSV 다운로드</button>
<button class="win-btn" id="prt-btn-xml">🏛️ 조달청 호환규정 XML(T3_C3B)</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;">
${
{
"0608": "공 사 원 가 계 산 서",
"0601": "총 괄 설 계 내 역 서",
"0603": "설 계 내 역 서",
"0607": "관 급 자 재 내 역 서",
"0643": "설 계 변 경 내 역 서 (증 감 대 비)",
"0623": "계 약 내 역 서 · 실 행 예 산 서",
"0633": "기 성 부 분 내 역 서 · 수 량 조 서",
"0302": "일 위 대 가 표 (목 록)",
"0402": "단 가 산 출 근 거",
"0203": "시 간 당 중 기 사 용 료",
"0101": "자 재 · 노 무 · 경 비 단 가 목 록 표",
"0501": "자 원 · 중 기 · 대 가 수 량 금 액 집 계 표",
"0201": "환 율 및 기 초 자 료 (〓 INITIAL 〓)",
SET_REP_ALL: "SET_REP.DBF 75종 양식 설정표",
}[activeRep] || "설 계 보 고 서"
}
</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>`
: ""
}
${
["0601", "0603", "0607", "0643", "0623", "0633"].includes(activeRep)
? `<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
: activeRep === "0601"
? /^(T\d|E\d|C\d)$/.test(String(r.BDCODE1 || "").trim())
: 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("")
: ""
}
${
["0302", "0203", "0101", "0501", "0201"].includes(activeRep)
? `<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";
if (activeRep === "0201") return p === "L" || 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);
+2 -398
View File
@@ -1,31 +1,13 @@
/**
* 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
* STMATE w 8.0 — 계층 내역 재집계(evaluateFullBoqRows) · 단가 반영(syncCostnUnitPrice) · 저장 요청 묶음
* 근거: 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());
}
@@ -144,381 +126,6 @@
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" data-file-act="copy">📋 복사등록 (차수/단계 변환)</button>
<button class="win-btn" data-file-act="pack">🗜️ 해당분만 압축하기 (.STC 생성)</button>
<button class="win-btn" data-file-act="new">🆕 새 공사</button>
<button class="win-btn" data-file-act="check">🔍 STC 검사</button>
<button class="win-btn" data-file-act="backups">🗂️ 백업·복원</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>
${renderPrjRows(ctx)}
${
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>
`;
}
// 열린 프로젝트의 공사(PRJLST 행 · PCODE 1n ↔ BOQ1n) — 복사등록·해당분 압축 대상 고름(scr_file.js)
function renderPrjRows(ctx) {
const rows = (ctx.dbfData && ctx.dbfData.prjlst_all) || [];
if (!rows.length) return "";
const pick = ctx.uiState.filePcode || String(rows[0].PCODE || "11").trim();
const tr = (r) => {
const pc = String(r.PCODE || "").trim();
return `<tr><td class="center"><input type="radio" name="prj-pcode" value="${pc}" ${pc === pick ? "checked" : ""} /></td><td class="center">BOQ${pc}</td><td>${r.PNAME || ""}</td><td class="center">${KORD_BADGES[num(r.K_ORD)] || r.K_ORD || ""}</td><td class="num">${fmt(num(r.TCOST1))}</td><td class="num">${fmt(num(r.TCOST2))}</td><td class="num">${fmt(num(r.COSTW1))}</td></tr>`;
};
return `<div class="pane-box" style="flex:0 0 auto;max-height:120px;"><div class="pane-title"><span>공사 (PRJLST ${rows.length}행) — 고른 공사로 복사등록·해당분 압축</span></div><div class="grid-scroll"><table class="dbgrid"><thead><tr><th style="width:36px;">고름</th><th style="width:70px;">표</th><th>공사명</th><th style="width:95px;">단계(K_ORD)</th><th style="width:120px;">직공비 1차</th><th style="width:120px;">직공비 2차</th><th style="width:120px;">총공사비 1차</th></tr></thead><tbody>${rows.map(tr).join("")}</tbody></table></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(3,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") {
const bdxList = (ctx.state.ilwiItems || []).slice(0, 30);
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(3,1fr);"><div class="kanjub-field"><label>전개할 호표 선택</label><select id="expnd-code">${bdxList.map((x) => `<option value="${x.code}">${x.code} ${x.rawName || x.name}</option>`).join("")}</select></div><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>`;
}
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) {
@@ -576,9 +183,6 @@
global.STMATE_VIEW_PRJ_BOQ = {
evaluateFullBoqRows,
renderWPrjList,
renderWBoq,
buildBoqToolModalHtml,
syncCostnUnitPrice,
buildSavePayload,
};