From 217e20a6e02ce3921d754d359ed652e70250281e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 14:29:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(stmate-test):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=C2=B7=EC=A4=91=EA=B8=B0=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=E2=80=94=20BDQTY=20=EA=B5=AC=EC=84=B1=ED=91=9C=20?= =?UTF-8?q?=EB=B3=B5=ED=98=B8=20=C2=B7=20=EC=97=B0=EC=87=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EA=B8=B0=EC=B4=88=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EB=B6=80=ED=84=B0=201=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bdqty.py — BDQTY.DBF 자리별 고정 변환(회전 + 더하기) 표 283자리로 복호(사용자 승인) · /api/load 에 bdqty_rows - smt_eval.js evaluateComposition — 행 소수1 버림 · CHBNM 총액우선(행 합계액 기준) · PR 줄(PRPT 밑수 → PROUT) · % 단위 자원 · C* 뒤 % = 전체 비율 · 중기 운전원 CONLD 시간 환산 · '$' 단위 환율 · SBMOK 경비 - 연쇄 재계산 = X·D·B 참조 순서 · 봉화 기준 표준 레시피 이름 맞추기(자기 대조) 걷음 - 원설계 5건 일위대가·중기 291개 + 영덕 변경 31개 1원 · 기초단가부터 다시 셈해도 전 단가·총공사비 그대로 - 지킴 시험 test_stmate_ilwi_eval.py · 장부 32.2% → 33.5% Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_stmate_cascade.cjs | 20 ++ resources/tester/helper_stmate_ilwi.cjs | 35 ++++ resources/tester/test_stmate_ilwi_eval.py | 74 +++++++ tools/stmate-test/PLAN.md | 24 ++- tools/stmate-test/bdqty.py | 98 +++++++++ tools/stmate-test/server.py | 2 + tools/stmate-test/smt_engine.js | 224 ++++++--------------- tools/stmate-test/smt_eval.js | 81 +++++++- 8 files changed, 386 insertions(+), 172 deletions(-) create mode 100644 resources/tester/helper_stmate_cascade.cjs create mode 100644 resources/tester/helper_stmate_ilwi.cjs create mode 100644 resources/tester/test_stmate_ilwi_eval.py create mode 100644 tools/stmate-test/bdqty.py diff --git a/resources/tester/helper_stmate_cascade.cjs b/resources/tester/helper_stmate_cascade.cjs new file mode 100644 index 000000000..e89a425de --- /dev/null +++ b/resources/tester/helper_stmate_cascade.cjs @@ -0,0 +1,20 @@ +// STMATE 재현 앱 연쇄 재계산(계산완료) — stdin = /api/load 결과 · 재계산 뒤 COSTN 단가와 원가계산서 +globalThis.window = globalThis; +require("../../tools/stmate-test/calc.js"); +require("../../tools/stmate-test/smt_eval.js"); +require("../../tools/stmate-test/smt_engine.js"); +require("../../tools/stmate-test/view_prj_boq.js"); +let buf = ""; +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const d = JSON.parse(buf); + const ctx = { dbfData: d, state: d.sim_state, uiState: {} }; + const count = window.STMATE_ENGINE.cascadeRecalculateAll(ctx); + const prices = {}; + for (const r of d.costn_rows) + prices[r.CODE.trim()] = ["MAMT1", "LAMT1", "SAMT1", "MAMT2", "LAMT2", "SAMT2"].map((k) => + Number(r[k]), + ); + const k = window.STMATE_CALC.calculateAll(ctx.state).kanjubOrig; + process.stdout.write(JSON.stringify({ count, prices, grand: k.grandTotal, dT: k.dT })); +}); diff --git a/resources/tester/helper_stmate_ilwi.cjs b/resources/tester/helper_stmate_ilwi.cjs new file mode 100644 index 000000000..6952f239f --- /dev/null +++ b/resources/tester/helper_stmate_ilwi.cjs @@ -0,0 +1,35 @@ +// STMATE 재현 앱 일위대가(B)·중기(X) 조립 — stdin {costn_rows, bdqty_rows, start_ini} +const E = require("../../tools/stmate-test/smt_eval.js"); +let buf = ""; +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const d = JSON.parse(buf); + const ini = d.start_ini || {}; + const by = {}; + for (const r of d.costn_rows) by[r.CODE.trim()] = r; + const slot = (r) => (Number(r.NAGCD) > 0 ? 2 : 1); + const lookup = (c) => { + const r = by[c]; + if (!r) return null; + const s = slot(r); + return { M: +r[`MAMT${s}`], L: +r[`LAMT${s}`], S: +r[`SAMT${s}`], unit: r.UNIT }; + }; + const conld = String(ini.CONLD || ""); + const labor = new Set(conld.match(/.{1,6}/g) || []); + const groups = {}; + for (const r of d.bdqty_rows) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(r); + const out = {}; + for (const [parent, rows] of Object.entries(groups)) { + if (!by[parent] || !/^[BX]/.test(parent)) continue; + const ev = E.evaluateComposition(rows, lookup, { + totalFirst: parent[0] === "B" && String(ini.CHBNM ?? "1") === "0", + order: ini.OPTNM || "SML", + sbmok: String(by[parent].SBMOK || "").trim(), + labor: parent[0] === "X" ? labor : null, + rx: ini.RXXNM, + usdRate: ini.EXLNM1, + }); + out[parent] = [ev.M, ev.L, ev.S]; + } + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/test_stmate_ilwi_eval.py b/resources/tester/test_stmate_ilwi_eval.py new file mode 100644 index 000000000..7a632d900 --- /dev/null +++ b/resources/tester/test_stmate_ilwi_eval.py @@ -0,0 +1,74 @@ +"""STMATE 재현 앱 일위대가(B)·중기(X) 재계산과 연쇄 재계산(계산완료) — 실무 STC 저장 단가와 1원 대조. + +구성 = BDQTY.DBF 복호(tools/stmate-test/bdqty.py · 사용자 승인) · 정답 = STC 의 COSTN 저장 단가. +연쇄 시험은 기초단가(L·M·S)만 두고 중기 → 산근 → 일위대가 → 내역 → 원가계산서를 다시 셈. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import server # noqa: E402 + +HERE = Path(__file__).parent +DOCS = ROOT / "resources/knowledge/original/실무문서" +ORIGINAL = { + "봉화2024": "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC", + "울진2025": "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", + "영월2024": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", + "대흥2024": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", + "소광원설계": "25년 산불진화임도(기번8)(울진소광)/1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC", +} +CHANGED_OK = { + "영덕변경": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", +} + + +def load(rel): + return server.api_load(server.LoadReq(source_path=str(DOCS / rel))) + + +def node(helper, payload): + out = subprocess.check_output( + ["node", str(HERE / helper)], + input=json.dumps(payload, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + return json.loads(out) + + +def stored(row, slot): + return [float(row[f"{k}AMT{slot}"] or 0) for k in "MLS"] + + +@pytest.mark.parametrize("name", list(ORIGINAL) + list(CHANGED_OK)) +def test_b_and_x_compositions_match_stored(name): + d = load({**ORIGINAL, **CHANGED_OK}[name]) + out = node( + "helper_stmate_ilwi.cjs", {k: d[k] for k in ("costn_rows", "bdqty_rows", "start_ini")} + ) + rows = {r["CODE"].strip(): r for r in d["costn_rows"]} + assert len(out) > 20, f"{name} 구성 호표 {len(out)}" + diffs = {} + for code, got in out.items(): + slot = 2 if float(rows[code].get("NAGCD") or 0) > 0 else 1 + if got != stored(rows[code], slot): + diffs[code] = (got, stored(rows[code], slot)) + assert not diffs, f"{name} 불일치 {len(diffs)}/{len(out)} {list(diffs.items())[:4]}" + + +@pytest.mark.parametrize("name", list(ORIGINAL)) +def test_full_cascade_keeps_every_unit_price_and_total(name): + d = load(ORIGINAL[name]) + before = {r["CODE"].strip(): stored(r, 1) for r in d["costn_rows"]} + out = node("helper_stmate_cascade.cjs", d) + assert out["count"]["xCount"] > 0 and out["count"]["dCount"] > 0 and out["count"]["bCount"] > 0 + moved = {c: (v[:3], before[c]) for c, v in out["prices"].items() if v[:3] != before[c]} + assert not moved, f"{name} 재계산 뒤 바뀐 단가 {list(moved.items())[:4]}" + assert abs(out["grand"] - float(d["prjlst"]["COSTW1"])) < 0.5 diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md index d636e13c1..70284fbe7 100644 --- a/tools/stmate-test/PLAN.md +++ b/tools/stmate-test/PLAN.md @@ -15,12 +15,13 @@ ## 지킴 시험 +- `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로 - `test_stmate_smt_eval.py` — 단가산출근거(D코드) 재계산: 원설계 5건(위 4건 + 소광 원설계) 282개 1원 · 낙찰률 단가 7건 127개 - `test_stmate_kanjub_stc.py` — 실무 STC 4건(봉화 2024 · 울진 2025 · 영월 2024 · 울진 대흥 2024) 원가계산서 18항목(직접비 4 · 간접비 11 · 이윤감액 · 관급 · 총공사비) 1원 대조 · 직접비는 내역 저장 단가 기준 ## 진행률 -- 합계: 전체 245 · 구현 36 · 부분 86 · 없음 123 → 진행률 32.2%((36 + 86×0.5) / 245) · 구현만 세면 14.7% +- 합계: 전체 246 · 구현 42 · 부분 81 · 없음 123 → 진행률 33.5%((42 + 81×0.5) / 246) · 구현만 세면 17.1% ### G1 화면·메뉴 (구현 5 · 부분 21 · 없음 28) — 28.7% @@ -79,22 +80,22 @@ - [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 없음 - [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음 -### G2 계산 엔진 (구현 14 · 부분 28 · 없음 19) — 45.9% +### G2 계산 엔진 (구현 19 · 부분 23 · 없음 19) — 50.0% - [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84) - [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 없음 -- [ ] F2-03 환산 적용 직종(COND_) — 20번 §2.5 · 현재: 부분(코드 고정 calc.js 600, 연쇄는 이름 '운전사'만 smt_engine 328) -- [ ] F2-04 환율 적용(USD 구간·EUR·YEN·적용 범위) — wM_Set_Sys cmBX_EXR · 현재: 부분(USD 한 값, 범위 선택 없음) +- [x] F2-03 환산 적용 직종(COND_) — 20번 §2.5 · 현재: 구현(중기 구성 운전원 = Start8x.Ini CONLD 직종 · RXXNM 좌→우 환산) +- [ ] F2-04 환율 적용(USD 구간·EUR·YEN·적용 범위) — wM_Set_Sys cmBX_EXR · 현재: 부분(중기 구성 '$' 단위 = EXLNM1÷1000 · 구간·EUR·YEN 없음) - [ ] F2-05 소수 자리수 설정(KCD·ACD·ZCD·JCD·ECD·BPN) — 22번 §3.4 · 현재: 부분(ECD만) - [ ] F2-06 자재 후보 최저가 자동채택 — 17번 §5.5 · 현재: 부분(후보 5칸만 calc.js 114, 6번째 적용칸 미반영) - [ ] F2-07 수동 고정·보호표시 우선 — 22번 §3.1 · 13번 T10 · 현재: 부분(일괄수정에서 UC=T 제외만) -- [ ] F2-08 중기 시간당 사용료 조립(손료·운전원·연료·잡품 성분 절사) — 17번 §3 · 현재: 부분(식 있으나 불러온 프로젝트는 저장값 server.py 188) +- [x] F2-08 중기 시간당 사용료 조립(손료·운전원·연료·잡품 성분 절사) — 17번 §3 · 현재: 구현(BDQTY 구성 · 성분 절사 · 7건 대조) - [ ] F2-09 손료 주간·야간·2교대·3교대(E0~E3) — 30번 §4 · 현재: 없음 - [x] F2-10 단가산출 D코드 전개 계산 — 18번 §2.2 · 23번 · 현재: 구현(smt_eval.js · 원설계 5건 282개 1원 · 참조 단가는 저장값) - [x] F2-11 운반비 경비 귀속 — 23번 §4 · 26번 §1 · 현재: 구현(COSTN SBMOK=S · (-=)+「운반비 계」 T% · 저장 금액 판정 걷음) -- [ ] F2-12 일위대가 B코드 구성 조립·성분 절사 — 17번 §2·§5 · 현재: 부분(calc.js 205 엔진뿐, 불러온 프로젝트는 저장값 server.py 230) -- [ ] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 부분(산근 D 위상순서 · 일위대가 구성은 BDQTY 암호화로 없음) -- [ ] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 부분(레시피 경로에서만, 레시피 0건 smt_parser 15) +- [x] F2-12 일위대가 B코드 구성 조립·성분 절사 — 17번 §2·§5 · 현재: 구현(BDQTY 구성 · 행 소수1 버림 · CHBNM 총액우선 = 행 합계액 기준 · 7건 대조) +- [ ] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 부분(X·D·B 참조 순서 재계산 · 순환 오류 알림 없음) +- [x] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 구현(PR 줄 = PRPT 표시 행 밑수 PRIN → PROUT · % 단위 자원 = 누계 · C* 뒤 % = 전체 비율) - [ ] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 부분(calc.js 215 일위 할증만, 박힌 기본 상태 전용) - [ ] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 부분(합산만, L/M/S 종류별 검사 없음) - [x] F2-17 내역 행 ROUNDDOWN(수량×단가) — 16번 · 06번 §3 · 현재: 구현(calc.js 249) @@ -140,7 +141,7 @@ - [ ] F2-57 간접비 식 템플릿 26 평가(IF·선택토큰) — 25번 §2 · 현재: 없음 - [ ] F2-58 항목별 밑수(D_)·결과(S_)·직접입력(C_) 키 왕복 — 25번 §1.1 · 현재: 없음 - [ ] F2-59 요율 순환(무한루프) 경고·수동 확정 — 06번 §2 · 현재: 없음 -- [ ] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 부분(X·B 단계 레시피 0건 무동작, 위상순서 없음) +- [x] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 구현(기초단가부터 원설계 5건 전 단가·총공사비 1원 · 변경설계 일부 미대조) - [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음 ### G3 수량산출·식언어 (구현 9 · 부분 4 · 없음 16) — 37.9% @@ -175,7 +176,7 @@ - [ ] F3-28 내역↔BDQTY 수량 연결(CON_QTY) — ST_U_ConBD · 현재: 없음 - [ ] F3-29 수량코드(QCODE) 등록·검사 — 28번 §4 · 현재: 없음 -### G4 파일 입출력 (구현 6 · 부분 9 · 없음 14) — 36.2% +### G4 파일 입출력 (구현 7 · 부분 9 · 없음 14) — 38.3% - [x] F4-01 STC(ZIP) 열기 — ST_U_Archive · 09번 §5 · 현재: 구현(server.py 92) - [ ] F4-02 STC 저장(압축) — ST_U_Archive ComP_Data · 현재: 부분(work_dbf 사본, 같은 이름 덮어씀 server.py 644) @@ -202,6 +203,7 @@ - [ ] F4-23 JDL 내보내기·검사 — frm_JDLAnno · JDLCHK.*.dll · 현재: 없음(근거 없는 '|' 줄 형식 app.js 569) - [ ] F4-24 엑셀 → 내역 불러오기 — wM_From_XLS · 현재: 없음 - [ ] F4-25 엑셀 역등록(CONV_XLS_2_ST·INITIAL)·엑셀 → BDQTY — 24번 §3 · 현재: 없음 +- [x] F4-30 BDQTY.DBF 구성표 복호 읽기 — STC_난독화_상태 · 현재: 구현(bdqty.py 자리별 회전·더하기 표 283자리 · 사용자 승인 · 글자 칸 제외) - [ ] F4-26 자재 기준자료 XLS 입출력 — wM_JajeXLS · 현재: 없음 - [ ] F4-27 기준 BOQ·코드 변경 엑셀 왕복 — 24번 §3 · ST_U_ChgCode · 현재: 없음 - [ ] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 없음 @@ -288,5 +290,5 @@ --- - 범위 밖(합계 제외): 하드웨어 동글(SX32W Sentinel) · 시리얼 인증(ST_BdSN) · 라이선스 서버 접속(wM_Host) · 유지보수 연장·A/S 신청(wM_Req_ON·wM_Req_AS) · 온라인 업데이트·패치·다운로드 센터(ST_U_ADL·STmexe·wM_Stmate_APatch·wM_Down_Center) -- 재현 불가(합계 제외): 원본 암호화 자료 — BDQTY.DBF 수량 할증 세부 · SYSINFO.DBF · EQSYNC.DBF · 기준단가 BSC/CSTA(자재 5.7만·노임·중기·표준시장) +- 재현 불가(합계 제외): 원본 암호화 자료 — BDQTY.DBF 글자 칸(명칭·규격)·차수 2~4 수량 일부 · SYSINFO.DBF · EQSYNC.DBF · 기준단가 BSC/CSTA(자재 5.7만·노임·중기·표준시장) - 기능 밖 결함(합계 제외): app.js 718줄 · 서버 0.0.0.0·CORS 전부 허용·임의 경로 읽기(/api/load·/work_dbf 정적 노출) diff --git a/tools/stmate-test/bdqty.py b/tools/stmate-test/bdqty.py new file mode 100644 index 000000000..c98e46762 --- /dev/null +++ b/tools/stmate-test/bdqty.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""BDQTY.DBF(일위대가·중기 구성표) 복호 — STMATE 가 레코드 본문을 자리별 고정 변환으로 숨김(삭제표시 0x45). + +변환 = 자리(레코드 안 바이트 위치)마다 고정된 (꼴, 회전 r, 값 k): + A 암호 = rol(평문, r) + k B 암호 = rol(평문 + k, r) X 암호 = rol(평문, r) ^ k +표는 실무 STC 7건에서 코드·수량 형식으로 맞춘 값(사용자 승인 하에 복호) — 글자 칸(명칭·규격)은 표에 없어 읽지 않음. +대조: resources/tester/test_stmate_ilwi_eval.py (일위대가·중기 재계산 = 저장 단가) +""" + +from __future__ import annotations + +import struct + +_TABLE = ( + "1A3.53 2A0.238 3A7.233 4A4.228 5A7.17 6A3.133 7A7.33 8A5.43 9A6.170 10A5.123 11A4.156 " + "12A1.167 133X4.229 134X0.87 135A7.198 136A5.155 137A2.254 138A7.217 139A5.123 140A7.193 " + "141B1.167 142A4.132 143A3.133 144B0.8 145B3.150 146B1.155 147A1.63 148B7.226 149A6.130 " + "150A3.253 151B2.85 152A5.235 153B6.91 154X0.231 155X0.231 156X4.0 157X1.223 158X6.130 " + "159A5.27 160B3.245 161B1.107 162X5.200 163B3.230 164B5.74 165X6.94 166X1.151 167B0.207 " + "168B5.10 169B7.206 170A0.166 171A7.198 172X6.98 173X1.63 174X0.103 175X0.233 176X1.223 " + "177X3.107 178A3.173 179B0.120 180X5.145 181B5.24 182X2.225 183A7.119 184X0.144 185A2.239 " + "186X4.27 187X4.180 188X4.229 189A5.241 190A2.41 191A5.232 192A6.154 193X3.143 194A7.70 " + "195X2.62 196X3.6 197X0.215 198A0.232 199B1.207 200X5.136 201A5.179 202B7.78 203X2.33 " + "204A6.154 205B3.214 206B5.74 207A1.176 208B6.174 209B6.110 210X5.249 211A6.154 212B0.194 " + "213B7.99 214A2.174 215A5.107 216B7.206 217X7.228 218B1.239 219B3.181 220X7.173 221A2.209 " + "222A1.155 223A1.155 224A1.191 225B2.62 226X4.128 227A6.209 228B6.64 229X0.105 230X6.167 " + "231X0.215 232A3.128 233B6.107 234A2.209 235A6.90 236B5.154 237A5.107 238A1.185 239B6.20 " + "240B2.85 241X1.22 242X1.166 243B7.66 244B7.114 245X7.22 246X3.167 247A2.70 248B3.167 " + "249B6.168 250A1.39 251X2.0 252A5.147 253A3.85 254A6.106 255A3.93 256X0.208 257A7.65 " + "258A5.203 259A7.41 260A3.117 261A6.34 262A3.181 263B5.101 264A7.177 265A7.1 266A1.55 " + "267B1.151 268B7.133 269A0.175 270X6.98 271B1.151 272B1.139 273A5.203 274A7.113 275A2.238 " + "276X7.193 277A6.74 278A5.83 279A7.97 280B5.154 281B3.174 282A2.70 283A7.233 284B6.104 " + "285B5.24 286X1.63 287B2.94 288B5.92 289B4.201 290A3.197 291B6.168 292B1.203 293B5.26 " + "294A5.123 295A3.253 296X6.178 297X2.241 298X5.136 299A3.197 300A4.12 301B5.89 302B6.15 " + "303A3.183 304X6.167 305B0.112 306B5.209 307A4.165 308X0.215 309B7.142 310B1.49 311A3.253 " + "312A2.74 313X7.230 314X2.161 315A1.231 316B3.165 317A0.230 318B7.14 319A4.115 320A3.128 " + "321B5.74 322A2.41 323A1.159 324A4.100 325A7.17 326B3.4 327X6.159 328A0.175 329B1.239 " + "330A0.38 331B1.239 332X1.93 333X2.58 334X5.63 335X3.96 336X0.247 337X6.183 338X1.102 " + "339A0.150 340X0.96 341X2.55 342A5.125 343A2.209 344B4.137 345A3.119 346B6.247 347X5.54 " + "348A6.26 349X2.225 350X5.127 351A5.177 352B2.103 353A6.99 354B2.85 355B6.183 356X6.167 " + "357X3.136 358A6.238 359A6.154 360X1.38 361A4.124 362X0.41 374A1.239 375A4.92 376X6.55 " + "377B2.62 378X7.49 379B7.226 380B2.129 381A7.145 382X1.214 383A6.94 384B4.222 385B5.152 " + "386A7.17 387B2.125 388A3.64 389A6.222 390A7.198 391A1.119 392B1.99 393A3.213 394A5.134 " + "395B5.81 396A7.198 397B4.95 398A5.107 399B7.178 400X2.55 401X5.74 402B3.174 403A0.134 " + "404A3.53 405A1.191 406X4.229 407X1.63 408X5.182 409B7.178 410B6.42 411B6.137 412A2.218 " + "413A5.235 414B7.162 " +) + + +def _params() -> dict[int, tuple[str, int, int]]: + out = {} + for tok in "".join(_TABLE).split(): + i = next(j for j, ch in enumerate(tok) if ch in "ABX") + r, k = tok[i + 1 :].split(".") + out[int(tok[:i])] = (tok[i], int(r), int(k)) + return out + + +PARAMS = _params() + + +def _ror(v: int, r: int) -> int: + return ((v >> r) | (v << (8 - r))) & 0xFF if r else v + + +def _dec(c: int, p: tuple[str, int, int]) -> int: + kind, r, k = p + if kind == "A": + return _ror((c - k) & 0xFF, r) + if kind == "B": + return (_ror(c, r) - k) & 0xFF + return _ror(c ^ k, r) + + +def decode_bdqty(b: bytes) -> list[dict]: + """복호 가능한 칸만 담은 행 목록(레코드 순서 유지).""" + if len(b) < 32: + return [] + n, hlen, rlen = struct.unpack_from(" String(r.CODE || "").trim(); @@ -50,165 +50,73 @@ return { vars: ev.vars, linesOut, sumM: ev.M, sumL: ev.L, sumS: ev.S, sumT: ev.T }; } - /** - * 2. 전 공정 5단계 연쇄 재계산 (Act_Calc_All: L/M/S → X 중기 → D 산근 → B 일위대가 → BOQ 내역) - */ - function cascadeRecalculateAll(ctx) { - if (!ctx.dbfData || !ctx.dbfData.costn_rows) return { xCount: 0, dCount: 0, bCount: 0 }; - const crows = ctx.dbfData.costn_rows; - const memos = ctx.dbfData.costn_memos || {}; - const recipes = (ctx.catalogData && ctx.catalogData.recipes) || []; - const rxCoeff = Calc.evalLeftToRight(ctx.state.sysConfig.rxFormula || "1/8*16/12*25/20"); - const syncUnit = global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice; - - // Step 1: X* 중기사용료 25종 연쇄 재계산 - const joongRecs = recipes.filter((r) => r["종류"] === "중기"); - let xCount = 0; - crows - .filter((r) => - String(r.CODE || "") - .trim() - .startsWith("X"), - ) - .forEach((xr) => { - const code = String(xr.CODE || "").trim(); - const nm = String(xr.NAME || "").trim(); - const sz = String(xr.SIZE || "").trim(); - const rec = - joongRecs.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) || - joongRecs.find((r) => r["명칭"].trim() === nm); - if (!rec || !rec["구성"]) return; - let sm = 0, - sl = 0, - ss = 0; - rec["구성"].forEach((comp) => { - const cnm = String(comp["명칭"] || "").trim(); - const csz = String(comp["규격"] || "").trim(); - const cunit = String(comp["단위"] || "").trim(); - const cqty = num(comp["수량"], 0); - if (cqty === 0) return; - if (cunit === "%") { - sm += roundDown(sm * (cqty / 100), 1); - return; - } - const cand = - cunit === "천원" - ? crows.find( - (x) => x.CODE.startsWith("S") && x.NAME.trim() === cnm && x.SIZE.trim() === csz, - ) || crows.find((x) => x.CODE.startsWith("S") && x.NAME.trim() === cnm) - : crows.find( - (x) => /^[MLS]/.test(x.CODE) && x.NAME.trim() === cnm && x.SIZE.trim() === csz, - ) || crows.find((x) => /^[MLS]/.test(x.CODE) && x.NAME.trim() === cnm); - if (cand) { - let tm = num(cand.MAMT1), - tl = num(cand.LAMT1), - ts = num(cand.SAMT1); - if (cand.CODE.startsWith("L") && cnm.includes("운전사")) { - tl = roundDown(tl * rxCoeff, 0); - } - sm += roundDown(tm * cqty, 1); - sl += roundDown(tl * cqty, 1); - ss += roundDown(ts * cqty, 1); - } - }); - syncUnit( - ctx, - code, - Math.floor(roundDown(sm, 0)), - Math.floor(roundDown(sl, 0)), - Math.floor(roundDown(ss, 0)), - ); - xCount++; - }); - - // Step 2: D* 단가산출근거 65종 (COSTN.SMT) 연쇄 재계산 - let dCount = 0; - const memoText = Object.fromEntries(Object.entries(memos).map(([c, m]) => [c, m.text])); - SmtEval.orderByRefs(memoText).forEach((code) => { - const txt = - (ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[code]) || - (memos[code] && memos[code].text) || - ""; - if (!txt.trim()) return; - const ev = evaluateSmtFormulaText(txt, ctx, code); - if (ev.sumT > 0 || (byCodeZeroOk(crows, code) && ev.sumT === 0)) { - const row = crows.find((r) => rowCode(r) === code); - const nag = row ? num(row.NAGCD) : 0; - if (nag > 0) { - Object.assign(row, { MAMT2: String(ev.sumM), LAMT2: String(ev.sumL) }); - Object.assign(row, { SAMT2: String(ev.sumS), TAMT2: String(ev.sumT) }); - const b = (v) => SmtEval.bidRate(v, nag); - syncUnit(ctx, code, b(ev.sumM), b(ev.sumL), b(ev.sumS)); - } else syncUnit(ctx, code, ev.sumM, ev.sumL, ev.sumS); - dCount++; - } - }); - - // Step 3: B* 일위대가 45종 연쇄 재계산 - const ilwiRecs = recipes.filter((r) => r["종류"] === "일위대가"); - let bCount = 0; - crows - .filter((r) => - String(r.CODE || "") - .trim() - .startsWith("B"), - ) - .forEach((br) => { - const code = String(br.CODE || "").trim(); - const nm = String(br.NAME || "").trim(); - const sz = String(br.SIZE || "").trim(); - const rec = - ilwiRecs.find((r) => r["명칭"].trim() === nm && r["규격"].trim() === sz) || - ilwiRecs.find((r) => r["명칭"].trim() === nm); - if (!rec || !rec["구성"]) return; - let sm = 0, - sl = 0, - ss = 0; - rec["구성"].forEach((comp) => { - const cnm = String(comp["명칭"] || "").trim(); - const csz = String(comp["규격"] || "").trim(); - const cunit = String(comp["단위"] || "").trim(); - const cqty = num(comp["수량"], 0); - if (cqty === 0) return; - if (cunit === "%") { - const baseV = csz.includes("재료비") ? sm : sl; - if (cnm.includes("경비") || cnm.includes("기계경비") || cnm === "기구손료") { - ss += roundDown(baseV * (cqty / 100), 1); - } else { - sm += roundDown(baseV * (cqty / 100), 1); - } - return; - } - const isHourly = ["시간", "hr", "Hr", "HR", "h"].includes(cunit); - const cand = isHourly - ? crows.find( - (x) => x.CODE.startsWith("X") && x.NAME.trim() === cnm && x.SIZE.trim() === csz, - ) || crows.find((x) => x.CODE.startsWith("X") && x.NAME.trim() === cnm) - : crows.find( - (x) => x.CODE !== code && x.NAME.trim() === cnm && x.SIZE.trim() === csz, - ) || crows.find((x) => x.CODE !== code && x.NAME.trim() === cnm); - if (cand) { - sm += roundDown(num(cand.MAMT1) * cqty, 1); - sl += roundDown(num(cand.LAMT1) * cqty, 1); - ss += roundDown(num(cand.SAMT1) * cqty, 1); - } - }); - syncUnit( - ctx, - code, - Math.floor(roundDown(sm, 0)), - Math.floor(roundDown(sl, 0)), - Math.floor(roundDown(ss, 0)), - ); - bCount++; - }); - - return { xCount, dCount, bCount }; + // 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CONLD · RXXNM · EXLNM1) + function compositionOptions(ctx, code, row) { + const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {}; + const labor = new Set(String(ini.CONLD || "").match(/.{1,6}/g) || []); + return { + totalFirst: code[0] === "B" && String(ini.CHBNM ?? "1") === "0", + order: ini.OPTNM || "SML", + sbmok: String(row.SBMOK || "").trim(), + labor: code[0] === "X" ? labor : null, + rx: ini.RXXNM || ctx.state.sysConfig.rxFormula, + usdRate: num(ini.EXLNM1), + }; } - function byCodeZeroOk(crows, code) { - const r = crows.find((x) => String(x.CODE || "").trim() === code); - return r && num(r.TAMT1) === 0; + /** + * 2. 연쇄 재계산(Act_Calc_All) — 중기(X)·산근(D)·일위대가(B)를 참조 순서대로 다시 셈 + * 구성: X·B = BDQTY 구성행(bdqty.py 복호) · D = COSTN.SMT 식 · 결과는 COSTN 단가 칸에 씀 + */ + function cascadeRecalculateAll(ctx) { + const count = { xCount: 0, dCount: 0, bCount: 0 }; + if (!ctx.dbfData || !ctx.dbfData.costn_rows) return count; + const crows = ctx.dbfData.costn_rows; + const memos = ctx.dbfData.costn_memos || {}; + const syncUnit = global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice; + const { byCode, lookup } = costnLookup(ctx); + const groups = {}; + for (const r of ctx.dbfData.bdqty_rows || []) { + if (/^[BX]/.test(r.BDCODE || "") && byCode[r.BDCODE]) + (groups[r.BDCODE] = groups[r.BDCODE] || []).push(r); + } + const memoText = (code) => + (ctx.uiState.editedSmtMap && ctx.uiState.editedSmtMap[code]) || + (memos[code] && memos[code].text) || + ""; + const deps = (code) => + groups[code] + ? groups[code].map((r) => String(r.CODE || "").trim()) + : [...memoText(code).matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g)].map((m) => m[1]); + const order = [], + seen = new Set(); + const visit = (code) => { + if (seen.has(code) || !(groups[code] || memoText(code).trim())) return; + seen.add(code); + deps(code).forEach(visit); + order.push(code); + }; + [...Object.keys(groups), ...Object.keys(memos)].forEach(visit); + + for (const code of order) { + const row = byCode[code]; + if (!row) continue; + const ev = groups[code] + ? SmtEval.evaluateComposition(groups[code], lookup, compositionOptions(ctx, code, row)) + : (({ sumM, sumL, sumS, sumT }) => ({ M: sumM, L: sumL, S: sumS, T: sumT }))( + evaluateSmtFormulaText(memoText(code), ctx, code), + ); + if (!(ev.T > 0 || num(row.TAMT1) === 0)) continue; // 계산 실패로 저장 단가를 지우지 않음 + const nag = num(row.NAGCD); + if (nag > 0) { + Object.assign(row, { MAMT2: String(ev.M), LAMT2: String(ev.L) }); + Object.assign(row, { SAMT2: String(ev.S), TAMT2: String(ev.T) }); + const b = (v) => SmtEval.bidRate(v, nag); + syncUnit(ctx, code, b(ev.M), b(ev.L), b(ev.S)); + } else syncUnit(ctx, code, ev.M, ev.L, ev.S); + count[code[0] === "X" ? "xCount" : code[0] === "D" ? "dCount" : "bCount"] += 1; + } + return count; } /** diff --git a/tools/stmate-test/smt_eval.js b/tools/stmate-test/smt_eval.js index 0f11afae6..164d60791 100644 --- a/tools/stmate-test/smt_eval.js +++ b/tools/stmate-test/smt_eval.js @@ -1,6 +1,6 @@ /* -*- coding: utf-8 -*- */ /** - * COSTN.SMT 단가산출근거(D코드) 식 평가기 (smt_eval.js) — 화면·서버 없이 도는 순수 계산 + * 단가 계산기 (smt_eval.js) — 산근(D) COSTN.SMT 식 · 일위대가(B)·중기(X) BDQTY 구성 · 순수 계산 * 근거: 지식DB STmate/old/20_분석 23(SMT 전수파싱) · 26(편집기와 사이클식) · SIxMemo_구조 * 정답 대조: resources/tester/test_stmate_smt_eval.py (실무 STC 저장 단가) */ @@ -178,7 +178,8 @@ const StmateSmtEval = (() => { .split(""); // 소수부는 십진 자리로 맞춰 비교 — 이진 오차로 동률이 깨지지 않게(REA 15 MFRAC) const frac = Object.fromEntries(order.map((k) => [k, half(raw[k] - out[k], 6)])); - let rest = trunc(raw.M + raw.L + raw.S) - out.M - out.L - out.S; + // 일위대가는 행별 합계액(raw.T) 기준 총액 · 산근은 성분 합 + let rest = trunc(raw.T ?? raw.M + raw.L + raw.S) - out.M - out.L - out.S; while (rest > 0) { let pick = null; for (const k of order) if (frac[k] < 1 && (pick === null || frac[k] > frac[pick])) pick = k; @@ -212,7 +213,81 @@ const StmateSmtEval = (() => { // 낙찰률 단가 — 원단가(2칸) × 낙찰률, 성분별 원 미만 버림 const bidRate = (v, pct) => trunc(((Number(v) || 0) * (Number(pct) || 0)) / 100); - return { evaluate, finish, stripQuotes, evalExpr, orderByRefs, bidRate }; + // 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 원 미만 버림(Start8x.Ini RXXNM) + function hourlyWage(daily, expr) { + let acc = Number(daily) || 0; + for (const [, op, n] of String(expr || "") + .replace(/\s+/g, "") + .matchAll(/([*/])?(\d+(?:\.\d+)?)/g)) + acc = op === "/" ? acc / Number(n) : acc * Number(n); + return trunc(acc); + } + + const COMP = { 1: "M", 2: "L", 3: "S", 4: "T" }; + /** + * 일위대가(B)·중기(X) 구성 조립 — BDQTY 구성행(bdqty.py 복호) 차례대로 + * rows: [{ CODE, QTY1, PRPT, PRIN, PROUT }] · lookup(code) → { M, L, S, unit } + * opts: { totalFirst, order, sbmok, labor: Set(시간 환산 직종), rx, usdRate } + * 근거: 지식DB 17(성분 절사 · 중기 손료·운전원·연료·잡품) · 실무 STC 대조 + */ + function evaluateComposition(rows, lookup, opts = {}) { + const s = { M: 0, L: 0, S: 0, T: 0 }, + base = { M: 0, L: 0, S: 0 }; + const add = (k, v) => ((s[k === "T" ? "M" : k] += v), (s.T += v)); + let closed = false; // C* 줄 뒤의 % 자원 = 앞 금액 전체를 그 비율로(예: 켜쌓기 적용 90%) + for (const row of rows) { + const code = String(row.CODE || "").trim(), + q = Number(row.QTY1) || 0; + if (code === "PR") { + // 비율 줄 — 표시(PRPT)된 행의 밑수 비목(PRIN) × 율 → 대상 비목(PROUT) + const bk = COMP[row.PRIN], + ok = COMP[row.PROUT]; + if (!bk || !ok) continue; + const bv = bk === "T" ? base.M + base.L + base.S : base[bk]; + add(ok, trunc((bv * q) / 100, 1)); + continue; + } + if (/^C\d$/.test(code)) closed = true; + const r = lookup(code); + if (!r) continue; // T1 · T2 · C1 같은 구분 줄 + const unit = String(r.unit || "").trim(); + const own = code[0] === "L" ? "L" : code[0] === "S" ? "S" : "M"; + const pct = unit.match(/^([MLST])%$/); + if (pct && closed) { + for (const k of "MLST") s[k] = trunc((s[k] * q) / 100, 1); + continue; + } + if (pct) { + const bv = pct[1] === "T" ? s.M + s.L + s.S : s[pct[1]]; + add(own, trunc((bv * q) / 100, 1)); + continue; + } + let p = { M: r.M, L: r.L, S: r.S }; + if (opts.labor && opts.labor.has(code)) p = { M: 0, L: hourlyWage(r.L, opts.rx), S: 0 }; + if (unit === "$") for (const k of "MLS") p[k] = (p[k] * (Number(opts.usdRate) || 0)) / 1000; + s.T += trunc(q * (p.M + p.L + p.S), 1); + const marked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== ""; + for (const k of "MLS") { + const a = trunc(q * p[k], 1); + s[k] += a; + if (marked) base[k] += a; + } + } + const out = finish(s, opts); + if (opts.sbmok === "S") Object.assign(out, { M: 0, L: 0, S: out.T }); + return out; + } + + return { + evaluate, + finish, + stripQuotes, + evalExpr, + orderByRefs, + bidRate, + hourlyWage, + evaluateComposition, + }; })(); if (typeof window !== "undefined") window.STMATE_SMT_EVAL = StmateSmtEval;