diff --git a/resources/tester/helper_stmate_smt.cjs b/resources/tester/helper_stmate_smt.cjs new file mode 100644 index 000000000..60bb7e90a --- /dev/null +++ b/resources/tester/helper_stmate_smt.cjs @@ -0,0 +1,23 @@ +// STMATE 재현 앱 산근 평가(smt_engine → smt_eval) — stdin {costn_rows, memos, start_ini} +globalThis.window = globalThis; +require("../../tools/stmate-test/calc.js"); +require("../../tools/stmate-test/smt_eval.js"); +require("../../tools/stmate-test/smt_engine.js"); +let buf = ""; +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const d = JSON.parse(buf); + const ctx = { dbfData: { costn_rows: d.costn_rows, start_ini: d.start_ini } }; + const out = {}; + for (const [code, text] of Object.entries(d.memos)) { + const ev = window.STMATE_ENGINE.evaluateSmtFormulaText(text, ctx, code); + out[code] = [ev.sumM, ev.sumL, ev.sumS]; + } + const bid = d.costn_rows + .filter((r) => Number(r.NAGCD) > 0) + .map((r) => [ + r.CODE.trim(), + ..."MLS".split("").map((k) => window.STMATE_SMT_EVAL.bidRate(r[`${k}AMT2`], r.NAGCD)), + ]); + process.stdout.write(JSON.stringify({ smt: out, bid })); +}); diff --git a/resources/tester/test_stmate_smt_eval.py b/resources/tester/test_stmate_smt_eval.py new file mode 100644 index 000000000..e538a6f9b --- /dev/null +++ b/resources/tester/test_stmate_smt_eval.py @@ -0,0 +1,70 @@ +"""STMATE 재현 앱 단가산출근거(D코드) 재계산 — COSTN.SMT 식을 다시 셈해 실무 STC 저장 단가와 1원 대조. + +정답 = STC 의 COSTN 저장 단가(낙찰률 단가는 2칸 원단가). 참조 단가(X·L·M·S)는 저장값을 씀. +변경설계 파일(영덕 변경 · 소광 변경1차)의 산근은 설계변경 기능 몫 — 낙찰률 규칙만 대조. +""" + +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 + +HELPER = Path(__file__).with_name("helper_stmate_smt.cjs") +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 = { + "영덕변경": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", + "소광변경1차": "25년 산불진화임도(기번8)(울진소광)/변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", +} + + +def run(rel): + loaded = server.api_load(server.LoadReq(source_path=str(DOCS / rel))) + payload = { + "costn_rows": loaded["costn_rows"], + "memos": {k: v["text"] for k, v in loaded["costn_memos"].items()}, + "start_ini": loaded["start_ini"], + } + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps(payload, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + return loaded, json.loads(out) + + +@pytest.mark.parametrize("name", list(ORIGINAL)) +def test_d_codes_match_stored_unit_prices(name): + loaded, out = run(ORIGINAL[name]) + rows = {r["CODE"].strip(): r for r in loaded["costn_rows"]} + assert out["smt"], f"{name} 산근 없음" + diffs = {} + for code, got in out["smt"].items(): + r = rows[code] + slot = 2 if float(r.get("NAGCD") or 0) > 0 else 1 + exp = [float(r[f"{k}AMT{slot}"]) for k in "MLS"] + if any(abs(g - e) >= 0.5 for g, e in zip(got, exp)): + diffs[code] = (got, exp) + assert not diffs, f"{name} 산근 불일치 {len(diffs)}/{len(out['smt'])} {list(diffs.items())[:5]}" + + +@pytest.mark.parametrize("name", list(ORIGINAL) + list(CHANGED)) +def test_bid_rate_unit_prices(name): + loaded, out = run({**ORIGINAL, **CHANGED}[name]) + rows = {r["CODE"].strip(): r for r in loaded["costn_rows"]} + for code, m, l, s in out["bid"]: + exp = [float(rows[code][f"{k}AMT1"]) for k in "MLS"] + assert [m, l, s] == exp, (name, code, [m, l, s], exp) diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md index ca2a4a074..d636e13c1 100644 --- a/tools/stmate-test/PLAN.md +++ b/tools/stmate-test/PLAN.md @@ -15,11 +15,12 @@ ## 지킴 시험 +- `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 · 구현 29 · 부분 92 · 없음 124 → 진행률 30.6%((29 + 92×0.5) / 245) · 구현만 세면 11.8% +- 합계: 전체 245 · 구현 36 · 부분 86 · 없음 123 → 진행률 32.2%((36 + 86×0.5) / 245) · 구현만 세면 14.7% ### G1 화면·메뉴 (구현 5 · 부분 21 · 없음 28) — 28.7% @@ -78,7 +79,7 @@ - [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 없음 - [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음 -### G2 계산 엔진 (구현 12 · 부분 29 · 없음 20) — 43.4% +### G2 계산 엔진 (구현 14 · 부분 28 · 없음 19) — 45.9% - [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84) - [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 없음 @@ -89,15 +90,15 @@ - [ ] F2-07 수동 고정·보호표시 우선 — 22번 §3.1 · 13번 T10 · 현재: 부분(일괄수정에서 UC=T 제외만) - [ ] F2-08 중기 시간당 사용료 조립(손료·운전원·연료·잡품 성분 절사) — 17번 §3 · 현재: 부분(식 있으나 불러온 프로젝트는 저장값 server.py 188) - [ ] F2-09 손료 주간·야간·2교대·3교대(E0~E3) — 30번 §4 · 현재: 없음 -- [ ] F2-10 단가산출 D코드 전개 계산 — 18번 §2.2 · 23번 · 현재: 부분(smt_engine 69 계산, 참조는 저장 단가·실패 시 저장값 유지 355) -- [ ] F2-11 운반비 경비 귀속 — 23번 §4 · 26번 §1 · 현재: 부분(원본 저장 금액으로 판정 smt_engine 82) +- [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 · 현재: 없음 +- [ ] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 부분(산근 D 위상순서 · 일위대가 구성은 BDQTY 암호화로 없음) - [ ] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 부분(레시피 경로에서만, 레시피 0건 smt_parser 15) - [ ] 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) -- [ ] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 부분(calc.js 283, 소수 비교 규칙 원본 미대조) +- [ ] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 부분(산근 = CHGNM·OPTNM 총액우선 구현 · 내역서 CHNNM 원본 미대조) - [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66) - [x] F2-20 제외금액 C* 구역 — 16번 §4.2 · 현재: 구현 - [ ] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 부분(SMT C_KWANGUP·C_T_JODAL 입력값 사용 · 내역 C* 구역 자동 분류 없음) @@ -142,28 +143,28 @@ - [ ] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 부분(X·B 단계 레시피 0건 무동작, 위상순서 없음) - [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음 -### G3 수량산출·식언어 (구현 4 · 부분 9 · 없음 16) — 29.3% +### G3 수량산출·식언어 (구현 9 · 부분 4 · 없음 16) — 37.9% - [x] F3-01 COSTN.SMT 메모 블록 읽기(IFORM 위치) — 21번 §1 · 23번 · 현재: 구현(smt_parser 150) - [x] F3-02 헤더 변수 정의(;; 줄)·변수 대입 — 23번 · 현재: 구현(smt_engine 112) - [x] F3-03 자원 참조 토큰 &코드[LMS]& 성분 지정 — 07번 §2 · 현재: 구현(참조값은 저장 단가) -- [x] F3-04 라벨 :=A01·&PA01:M&·&PS_M&·&RC_S& 소계 참조 — 23번 · 현재: 구현(smt_engine 186~) -- [ ] F3-05 구간 합산 (=)(==)(===)·소계 제외 (-=) — 23번 · 현재: 부분((==)(===)를 (=)와 같게, (-=) 뜻 추정) +- [x] F3-04 라벨 :=A01·&PA01:M&·&PA010203:S&·&PS_M&·&RC_S&·&RS_L& 소계 참조 — 23번 · 현재: 구현(smt_eval.js) +- [x] F3-05 구간 합산 (=)(==)(===)·소계 제외 (-=)(-==) — 23번 · 현재: 구현((-=) 지금 구간 · (-==) 이전 전부 합계 제외 · % 밑수엔 남음) - [ ] F3-06 사칙·괄호 평가와 좌→우 순차 — 18번 §2.1 · 현재: 부분(JS 우선순위 계산, 순차 평가 아님) -- [ ] F3-07 % 표기('%') 처리 — 23번 §5 · 현재: 부분(1 이상이면 ×0.01 짐작 smt_engine 46) -- [ ] F3-08 분수 인자(f=1/1.3) 자리수 보존 — 26번 §2 · 현재: 부분(끝 '='이면 소수 2자리 반올림 짐작) +- [x] F3-07 % 표기('%') 처리 — 23번 §5 · 현재: 구현(n'%' 1 이상 ÷100 · 1 미만 그대로 · [변수]'%' ÷100 · % 단위 자원 = 누계 밑수) +- [x] F3-08 분수 인자(f=1/1.3) 자리수 보존 — 26번 §2 · 현재: 구현(끝 '=' 변수 소수 2자리 반올림 · 282개 대조) - [ ] F3-09 함수 INT·ROUND·ROUNDDOWN·ROUNDUP·SQRT·√·SIN·COS·TAN·∏ — wM_Edit_San · 현재: 없음(이름을 0으로 바꿈 smt_engine 51) - [ ] F3-10 조건 함수 IF·IIF·IFI·IFO — 24번 §2.3 · 25번 §2.2 · 현재: 없음 - [ ] F3-11 금액변수·소계변수(대상·저장·%) 선언 — 28번 §1 · 현재: 없음 - [ ] F3-12 계산후 제외처리·항목집계 제외 코드 — 28번 §1·§4 · 현재: 없음 -- [ ] F3-13 강제금액(직접 지정 금액) — 28번 §4 · 현재: 부분('재료비: 숫자' 꼴만 smt_engine 131) +- [ ] F3-13 강제금액(직접 지정 금액) — 28번 §4 · 현재: 없음(옛 '재료비: 숫자' 짐작 처리 걷음 · 원본 문법 근거 대기) - [ ] F3-14 수량기호 Q·A·T·N — wM_Edit_San · 현재: 없음 - [ ] F3-15 식 문법 검사(줄 단위 오류 메시지) — 28번 §4 · 현재: 없음(오류 시 조용히 0) - [ ] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 없음 - [ ] F3-17 결과적용 비목 4택·0원 비목 입력안함 — 26번 §3 · 현재: 없음 -- [ ] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 없음 -- [ ] F3-19 Q식(3600qKfE/Cm 등 10꼴) 평가 — 23번 §3 · 현재: 부분(calc.js 171 두 꼴, 불러온 프로젝트는 저장값 server.py 223) -- [ ] F3-20 Cm 사이클식(t1+…·L/V1+L/V2+0.25·선회각 초) — 26번 §1 · 현재: 부분(사칙 대입만, 선회각 표기 미해석) +- [ ] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 부분(낙찰률 단가 = 원단가×율 버림 · 7건 127개 대조 · 화면 입력 없음) +- [x] F3-19 Q식(3600qKfE/Cm 등 10꼴) 평가 — 23번 §3 · 현재: 구현(식 그대로 평가 · 변수 대소문자 무시 · 282개 대조) +- [x] F3-20 Cm 사이클식(t1+…·L/V1+L/V2+0.25·선회각 초) — 26번 §1 · 현재: 구현(사칙 대입 · 따옴표 주석 걷음 · 282개 대조) - [ ] F3-21 표준중기공식 등록·수정·삭제 — wM_Edit_EQ · 28번 §1.1 · 현재: 없음 - [ ] F3-22 수량산출서 화면 — wQtyList · 현재: 부분(작은 창 25줄, QCODE 안 읽음 app.js 207) - [ ] F3-23 수량산출서 파일 저장·불러오기 — STmateQty8 · 현재: 없음 @@ -269,7 +270,7 @@ - [ ] F6-20 적용제외 공정 선택(레벨 9단계) — 27번 §2.3 · 현재: 없음(문구만) - [ ] F6-21 예상적용율 찾기(목표금액 역산)·미리계산 — 27번 §2.3 · 현재: 없음(문구만) - [ ] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 없음 -- [ ] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 부분(MAMT3를 87.745% 일괄값으로 덮어씀) +- [ ] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 부분(산근 재계산 시 2칸 원단가 → 1칸 낙찰 단가 · 저장은 MAMT3 87.745% 일괄 덮어씀) - [ ] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 없음(COSTW3=총공사비×재료율 view_prj_boq 633) - [ ] F6-25 계약·실행내역서(0623·0624) — PM_Nae_Cont · 현재: 없음(0603 복사) - [ ] F6-26 실행예산 작업(실행단가·실행수량) — wBoq 실행예산작업 · 현재: 없음(92% 고정 view_kanjub_prt 317) diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html index f1cd54983..9a917a1ca 100644 --- a/tools/stmate-test/index.html +++ b/tools/stmate-test/index.html @@ -171,6 +171,7 @@ + diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py index 5c8ddd36c..63d84ec90 100644 --- a/tools/stmate-test/server.py +++ b/tools/stmate-test/server.py @@ -20,6 +20,7 @@ from smt_parser import ( extract_costn_memos, load_stmate_catalog, parse_dbf_bytes, + parse_ini_bytes, parse_prjlst_smt, rebuild_costn_with_smt, update_prjlst_smt_bytes, @@ -442,6 +443,7 @@ def api_load(req: LoadReq): "boq_rows": boq_rows, "rate_tables": rate_tables, "set_rep_rows": set_rep_rows, + "start_ini": parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b""), "sim_state": sim_state, } diff --git a/tools/stmate-test/smt_engine.js b/tools/stmate-test/smt_engine.js index 87977570b..2fc63d437 100644 --- a/tools/stmate-test/smt_engine.js +++ b/tools/stmate-test/smt_engine.js @@ -1,275 +1,53 @@ /** - * STMATE w 8.0 — SIxMemo(COSTN.SMT) 100% 산출엔진 · 5단 연쇄재계산(Act_Calc_All) · 다중시트 엑셀/조달XML/JDL 변환기 (smt_engine.js) + * 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 */ (function (global) { "use strict"; const Calc = global.STMATE_CALC; - const { fmt, num, roundDown, roundHalfUp } = Calc; + const { fmt, num, roundDown } = Calc; - function buildCostnMap(ctx) { - const rawCostn = (ctx.dbfData && ctx.dbfData.costn_rows) || []; + const SmtEval = global.STMATE_SMT_EVAL; + const rowCode = (r) => String(r.CODE || "").trim(); + // 낙찰률(NAGCD) 단가는 2칸 원단가로 산근을 셈 — 1칸은 원단가 × 낙찰률 + const costSlot = (r) => (num(r.NAGCD) > 0 ? 2 : 1); + + function costnLookup(ctx) { const byCode = {}; - rawCostn.forEach((r) => { - const c = String(r.CODE || "").trim(); - if (c) { - byCode[c] = { - row: r, - code: c, - name: String(r.NAME || "").trim(), - size: String(r.SIZE || "").trim(), - unit: String(r.UNIT || "").trim(), - M: num(r.MAMT1), - L: num(r.LAMT1), - S: num(r.SAMT1), - T: num(r.TAMT1) || num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1), - }; - } - }); - return byCode; - } - - function safeMathEval(exprStr, vmap) { - if (!exprStr) return 0; - let s = String(exprStr) - .replace(/÷/g, "/") - .replace(/×/g, "*") - .replace(/\[([A-Za-z]\w*)\]\s*'%'/g, (_m, k) => { - const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0; - return `(${v}*0.01)`; - }) - .replace(/\[([A-Za-z]\w*)\]/g, (_m, k) => { - const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0; - return `(${v})`; - }) - .replace(/(\d+(?:\.\d+)?)\s*'%'/g, (_m, d) => { - const fv = parseFloat(d); - return fv >= 1.0 ? `(${fv * 0.01})` : `(${fv})`; - }) - .replace(/'[^']*'/g, " ") - .replace(/\b([A-Za-z]\w*)\b/g, (_m, k) => { - const v = vmap[k] ?? vmap[k.toUpperCase()] ?? vmap[k.toLowerCase()] ?? 0; - return String(v); - }) - .replace(/[^0-9.+\-*/()\s]/g, " ") - .trim(); - if (!s) return 0; - try { - const val = Function('"use strict"; return (' + s + ");")(); - return Number.isFinite(val) ? val : 0; - } catch (_e) { - return 0; - } + ((ctx.dbfData && ctx.dbfData.costn_rows) || []).forEach((r) => (byCode[rowCode(r)] = r)); + const lookup = (code) => { + const r = byCode[code]; + if (!r) return null; + const s = costSlot(r); + const out = { M: num(r[`MAMT${s}`]), L: num(r[`LAMT${s}`]), S: num(r[`SAMT${s}`]) }; + return Object.assign(out, { unit: String(r.UNIT || "").trim(), name: String(r.NAME || "") }); + }; + return { byCode, lookup }; } /** - * 1. COSTN.SMT 범용 수식 인터프리터 + * 1. COSTN.SMT 산근 평가 — smt_eval.js 위임 · 계산 설정은 Start8x.Ini(CHGNM · OPTNM) */ function evaluateSmtFormulaText(smtText, ctx, codeHint) { - const byCode = buildCostnMap(ctx); - const vmap = {}; - const labels = {}; - const linesOut = []; - let sumM = 0, - sumL = 0, - sumS = 0, - secM = 0, - secL = 0, - secS = 0; - - const orig = codeHint && byCode[codeHint] ? byCode[codeHint] : null; - const isTransportS = Boolean( - orig && orig.M === 0 && orig.L === 0 && orig.S > 0 && /운반/.test(orig.name), - ); - - const rawLines = String(smtText || "").split(/\r?\n/); - rawLines.forEach((rawLn, idx) => { - const ln = rawLn.trim(); - if (!ln) return; - if (ln === "(-=)") { - secM = secL = secS = 0; - linesOut.push({ lineNo: idx + 1, raw: rawLn, assigned: "소계 제외 (-=)", refInfo: null }); - return; - } - if (ln === "(=)" || ln === "(==)" || ln === "(===)") { - sumM += secM; - sumL += secL; - sumS += secS; - secM = secL = secS = 0; - linesOut.push({ - lineNo: idx + 1, - raw: rawLn, - assigned: `누계 M=${fmt(sumM, 1)} L=${fmt(sumL, 1)} S=${fmt(sumS, 1)}`, - refInfo: null, - }); - return; - } - - if (ln.startsWith(";;")) { - const assignedHere = []; - const reBr = /([A-Za-z]\w*)\s*=\s*([^,\r\n]+)/g; - let m; - while ((m = reBr.exec(ln.slice(2))) !== null) { - const vn = m[1].trim(); - const ve = m[2].replace(/'[^']*'/g, "").trim(); - vmap[vn] = safeMathEval(ve, vmap); - assignedHere.push(`[${vn}]=${vmap[vn]}`); - } - linesOut.push({ - lineNo: idx + 1, - raw: rawLn, - assigned: assignedHere.join(", "), - refInfo: null, - }); - return; - } - - const refRe = - /&(P[A-Z]\d{2}[LMS]?|R[A-Z]\d{2}[LMS]?|RC_[LMS]|PS_[LMS]|[A-Z]\d{4,5}[LMS]?)(?::([LMS]))?&/; - const rm = refRe.exec(ln); - - if (!rm) { - const commentCost = ln.match(/(재료비|노무비|경\s*비)\s*[:=]\s*([\d,]+)/); - if (commentCost && !ln.includes("=")) { - const cKind = commentCost[1].replace(/\s+/g, ""); - const cVal = num(commentCost[2], 0); - const cTag = cKind === "노무비" ? "L" : cKind === "경비" ? "S" : "M"; - if (cTag === "L") secL += cVal; - else if (cTag === "S") secS += cVal; - else secM += cVal; - linesOut.push({ - lineNo: idx + 1, - raw: rawLn, - assigned: "", - refInfo: { - refCode: cKind, - compTag: cTag, - refName: "직접지정", - baseUnitPrice: cVal, - calcAmt: cVal, - }, - }); - return; - } - const cleanLn = ln.replace(/'[^']*'/g, " "); - const assignRe = /\b([A-Za-z]\w*)\s*=\s*([^,=\r\n]+)(\s*=)?/g; - let m; - const assignedHere = []; - while ((m = assignRe.exec(cleanLn)) !== null) { - const vn = m[1].trim(); - const ve = m[2].trim(); - const hasEq = Boolean(m[3]); - if (!ve) continue; - const rawV = safeMathEval(ve, vmap); - vmap[vn] = hasEq ? roundHalfUp(rawV, 2) : rawV; - assignedHere.push(`${vn}=${roundHalfUp(vmap[vn], 4)}`); - } - linesOut.push({ - lineNo: idx + 1, - raw: rawLn, - assigned: assignedHere.join(", "), - refInfo: null, - }); - return; - } - - const lblM = ln.match(/:=\s*([A-Z]\d{2})/); - const lbl = lblM ? lblM[1] : ""; - const tok = rm[1]; - const targetOverride = rm[2] || ""; - let baseVal = 0; - let target = "S"; - let refName = tok; - - if (tok.startsWith("PA") || tok.startsWith("RA")) { - const lid = tok.slice(1, 4); - const srcC = tok.slice(4, 5) || "T"; - const lObj = labels[lid] || { M: 0, L: 0, S: 0, T: 0 }; - baseVal = lObj[srcC] !== undefined ? lObj[srcC] : lObj.T; - target = targetOverride || (srcC === "M" ? "M" : "S"); - refName = `라벨소계(${lid})`; - } else if (tok.startsWith("PS_")) { - const srcC = tok.slice(3, 4); - baseVal = srcC === "M" ? sumM + secM : srcC === "L" ? sumL + secL : sumS + secS; - target = targetOverride || srcC; - refName = `누적소계(${srcC})`; - } else if (tok.startsWith("RC_")) { - const srcC = tok.slice(3, 4); - baseVal = srcC === "M" ? secM : srcC === "L" ? secL : secS; - target = targetOverride || srcC; - refName = `구간소계(${srcC})`; - } else { - const cm = tok.match(/^([A-Z]\d{4,5})([LMS])?$/); - const rc = cm ? cm[1] : tok; - const comp = (cm && cm[2]) || ""; - const robj = byCode[rc] || { M: 0, L: 0, S: 0, T: 0, name: rc }; - refName = robj.name || rc; - if (comp === "M") ((baseVal = robj.M), (target = "M")); - else if (comp === "L") ((baseVal = robj.L), (target = "L")); - else if (comp === "S") ((baseVal = robj.S), (target = "S")); - else { - baseVal = robj.T; - if ( - baseVal === 0 && - rc === "L00020" && - (ln.includes("인건비") || ln.includes("노무비")) - ) { - baseVal = sumL + secL; - } - target = rc.startsWith("L") ? "L" : rc.startsWith("S") ? "S" : "M"; - } - if (targetOverride) target = targetOverride; - } - - let exprLn = ln.slice(0, rm.index) + `(${baseVal})` + ln.slice(rm.index + rm[0].length); - exprLn = exprLn.replace(/:=\s*[A-Z]\d{2}.*$/, "").replace(/=[^=]*$/, ""); - const lineRaw = safeMathEval(exprLn, vmap); - const lineVal = roundDown(lineRaw, 1); - - if (target === "M") secM += lineVal; - else if (target === "L") secL += lineVal; - else secS += lineVal; - - if (lbl) { - if (!labels[lbl]) labels[lbl] = { M: 0, L: 0, S: 0, T: 0 }; - labels[lbl][target] += lineVal; - labels[lbl].T += lineVal; - } - - linesOut.push({ - lineNo: idx + 1, - raw: rawLn, - assigned: lbl ? `→ ${lbl}[${target}]` : "", - refInfo: { - refCode: tok, - compTag: target, - refName, - baseUnitPrice: roundHalfUp(baseVal, 1), - calcAmt: lineVal, - }, - }); + const { byCode, lookup } = costnLookup(ctx); + const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {}; + const row = byCode[codeHint] || {}; + const ev = SmtEval.evaluate(smtText, lookup, { + totalFirst: String(ini.CHGNM ?? "1") === "0", + order: ini.OPTNM || "SML", + sbmok: String(row.SBMOK || "").trim(), }); - - sumM += secM; - sumL += secL; - sumS += secS; - let outM = Math.floor(roundDown(sumM, 0)); - let outL = Math.floor(roundDown(sumL, 0)); - let outS = Math.floor(roundDown(sumS, 0)); - if (isTransportS && (outM > 0 || outL > 0)) { - outS = outM + outL + outS; - outM = 0; - outL = 0; - } - return { - vars: vmap, - linesOut, - sumM: outM, - sumL: outL, - sumS: outS, - sumT: outM + outL + outS, - }; + const linesOut = ev.lines.map((ln) => { + const out = { lineNo: ln.lineNo, raw: ln.raw, assigned: (ln.assigned || []).join(", ") }; + if (ln.kind !== "amount") return Object.assign(out, { refInfo: null }); + const tag = "MLS".split("").find((k) => ln.amounts[k]) || "M"; + const refName = (lookup(ln.ref.replace(/[LMS]$/, "")) || {}).name || ln.ref; + const calcAmt = ln.amounts.M + ln.amounts.L + ln.amounts.S; + const refInfo = { refCode: ln.ref, compTag: tag, refName, baseUnitPrice: ln.base, calcAmt }; + return Object.assign(out, { assigned: ln.label ? `→ ${ln.label}` : "", refInfo }); + }); + return { vars: ev.vars, linesOut, sumM: ev.M, sumL: ev.L, sumS: ev.S, sumT: ev.T }; } /** @@ -345,7 +123,8 @@ // Step 2: D* 단가산출근거 65종 (COSTN.SMT) 연쇄 재계산 let dCount = 0; - Object.keys(memos).forEach((code) => { + 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) || @@ -353,7 +132,14 @@ if (!txt.trim()) return; const ev = evaluateSmtFormulaText(txt, ctx, code); if (ev.sumT > 0 || (byCodeZeroOk(crows, code) && ev.sumT === 0)) { - syncUnit(ctx, code, ev.sumM, ev.sumL, ev.sumS); + 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++; } }); diff --git a/tools/stmate-test/smt_eval.js b/tools/stmate-test/smt_eval.js new file mode 100644 index 000000000..0f11afae6 --- /dev/null +++ b/tools/stmate-test/smt_eval.js @@ -0,0 +1,219 @@ +/* -*- coding: utf-8 -*- */ +/** + * COSTN.SMT 단가산출근거(D코드) 식 평가기 (smt_eval.js) — 화면·서버 없이 도는 순수 계산 + * 근거: 지식DB STmate/old/20_분석 23(SMT 전수파싱) · 26(편집기와 사이클식) · SIxMemo_구조 + * 정답 대조: resources/tester/test_stmate_smt_eval.py (실무 STC 저장 단가) + */ +const StmateSmtEval = (() => { + const REF_RE = /&([A-Z][A-Z_0-9]*?)(?::([LMS]))?&/g; + const SECTION = new Set(["(=)", "(==)", "(===)"]); + + const trunc = (v, d = 0) => { + const f = 10 ** d, + s = v * f; + return Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)) / f; + }; + const half = (v, d = 0) => { + const f = 10 ** d, + r = Math.floor(Math.abs(v) * f + 0.5 + 1e-9) / f; + return v < 0 ? -r : r; + }; + + // 따옴표 주석 걷기 — n'%' 는 백분율, n'단위' 는 수치만 남김 + function stripQuotes(s) { + return s + .replace(/\[([A-Za-z_]\w*)\]\s*'%'/g, "($1/100)") + .replace(/(\d+(?:\.\d+)?)\s*'%'/g, (_m, d) => { + const v = parseFloat(d); + return `(${v >= 1 ? v / 100 : v})`; + }) + .replace(/'[^']*'/g, " "); + } + + function evalExpr(expr, vars) { + let s = expr.replace(/÷/g, "/").replace(/×/g, "*"); + s = s.replace(/\b([A-Za-z_][A-Za-z_0-9]*)\b/g, (_m, k) => { + const v = vars[k.toUpperCase()]; + return v === undefined ? "0" : `(${v})`; + }); + s = s.replace(/[^0-9.+\-*/()\s]/g, " ").trim(); + if (!s) return null; + try { + const v = Function(`"use strict"; return (${s});`)(); + return Number.isFinite(v) ? v : 0; + } catch (_e) { + return null; + } + } + + /** + * lookup(code) → { M, L, S, unit } 현재 단가 · 없으면 null + * opts.assignDigits — 끝에 '=' 붙은 변수 반올림 자리(기본 2) + */ + function evaluate(text, lookup, opts = {}) { + const assignDigits = opts.assignDigits ?? 2; + const lineDigits = opts.lineDigits ?? 1; + const vars = {}; + const labels = {}; + const lines = []; + const tot = { M: 0, L: 0, S: 0 }; + const seen = { M: 0, L: 0, S: 0 }; // 제외 구간 포함 누계 — 「운반비 계」 같은 % 밑수 + let sec = { M: 0, L: 0, S: 0 }; + let lastSec = { M: 0, L: 0, S: 0 }; + + const flush = () => { + for (const k of "MLS") tot[k] += sec[k]; + if (sec.M || sec.L || sec.S) lastSec = sec; + sec = { M: 0, L: 0, S: 0 }; + }; + const acc = (k) => tot[k] + sec[k]; + + const srcLines = String(text || "").split(/\r?\n/); + for (let lineNo = 1; lineNo <= srcLines.length; lineNo++) { + const ln = srcLines[lineNo - 1].trim(); + if (!ln) continue; + if (SECTION.has(ln)) { + flush(); + lines.push({ lineNo, raw: ln, kind: "section" }); + continue; + } + // (-=) 지금 구간 · (-==) 이전 전부를 합계에서 뺌 — % 밑수(seen)에는 남음 + if (/^\(-=+\)$/.test(ln)) { + sec = { M: 0, L: 0, S: 0 }; + if (ln !== "(-=)") for (const k of "MLS") tot[k] = 0; + lines.push({ lineNo, raw: ln, kind: "exclude" }); + continue; + } + const body = ln.startsWith(";;") ? ln.slice(2) : ln; + const refs = [...body.matchAll(REF_RE)]; + if (!refs.length) { + const clean = stripQuotes(body); + const assigned = []; + for (const part of clean.split(",")) { + const m = part.match(/^\s*([A-Za-z_][A-Za-z_0-9]*)\s*=\s*([^=]*?)\s*(=)?\s*$/); + if (!m || !m[2].trim()) continue; + const v = evalExpr(m[2], vars); + if (v === null) continue; + const key = m[1].toUpperCase(); + vars[key] = m[3] ? half(v, assignDigits) : v; + assigned.push(`${m[1]}=${vars[key]}`); + } + lines.push({ lineNo, raw: ln, kind: assigned.length ? "assign" : "text", assigned }); + continue; + } + // 금액 줄 — 참조 하나를 성분별로 펼쳐 계산 + const lbl = (body.match(/:=\s*([A-Z]\d{2})/) || [])[1] || ""; + // 끝 '=' 뒤(단위 주석)와 앞머리 「M=」 같은 이름표를 걷음 + const exprSrc = body + .replace(/:=.*$/, "") + .replace(/=[^=]*$/, "") + .replace(/^\s*[A-Za-z_]\w*\s*=(?!=)/, ""); + const ref = refs[0]; + const tok = ref[1]; + const forced = ref[2] || ""; + const parts = []; // [base, target] + let pa; + if ((pa = tok.match(/^(PS|RC|RS)_([LMS])$/))) { + // PS 누계 · RC 지금 구간 · RS 직전에 닫힌 구간 + const src = pa[1] === "PS" ? { [pa[2]]: acc(pa[2]) } : pa[1] === "RC" ? sec : lastSec; + parts.push([src[pa[2]], forced || pa[2]]); + } else if ((pa = tok.match(/^[PR]([A-Z])((?:\d{2})+)([LMS])?$/))) { + // 라벨 묶음 — PA010203 = A01 + A02 + A03 + let base = 0; + for (const n of pa[2].match(/\d{2}/g)) { + const lo = labels[pa[1] + n] || { M: 0, L: 0, S: 0 }; + base += pa[3] ? lo[pa[3]] : lo.M + lo.L + lo.S; + } + parts.push([base, forced || pa[3] || "M"]); + } else { + const cm = tok.match(/^([A-Z]\d{4,5})([LMS])?$/); + const code = cm ? cm[1] : tok; + const comp = cm ? cm[2] || "" : ""; + const r = lookup(code) || { M: 0, L: 0, S: 0, unit: "" }; + const pct = String(r.unit || "").match(/^([MLST])%$/); + const own = code[0] === "L" ? "L" : code[0] === "S" ? "S" : "M"; + if (pct) { + const base = pct[1] === "T" ? seen.M + seen.L + seen.S : seen[pct[1]]; + parts.push([base, forced || own]); + } else if (comp) parts.push([r[comp], forced || comp]); + else if (/^[MLS]/.test(code)) parts.push([r.M + r.L + r.S, forced || own]); + else for (const k of "MLS") parts.push([r[k], forced || k]); + } + const amounts = { M: 0, L: 0, S: 0 }; + for (const [base, target] of parts) { + const e = stripQuotes(exprSrc.replace(ref[0], `(${base})`)); + const v = trunc(evalExpr(e, vars) || 0, lineDigits); + amounts[target] += v; + sec[target] += v; + seen[target] += v; + if (lbl) { + labels[lbl] = labels[lbl] || { M: 0, L: 0, S: 0 }; + labels[lbl][target] += v; + } + } + lines.push({ + lineNo, + raw: ln, + kind: "amount", + ref: tok, + label: lbl, + amounts, + base: parts[0][0], + }); + } + flush(); + const out = finish(tot, opts); + // 비목 지정 경비(COSTN SBMOK=S) — 합계를 경비 한 칸으로 + if (opts.sbmok === "S") Object.assign(out, { M: 0, L: 0, S: out.T }); + return Object.assign(out, { vars, lines }); + } + + // 비목우선 = 성분별 절사 · 총액우선(REA 15 OPTIMIZ) = 총액 절사 후 남는 1원을 + // 소수부 큰 성분부터(동률은 OPTNM 순서 앞쪽) 하나씩 + function finish(raw, opts = {}) { + const out = { M: trunc(raw.M), L: trunc(raw.L), S: trunc(raw.S) }; + if (opts.totalFirst) { + const order = String(opts.order || "SML") + .toUpperCase() + .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; + while (rest > 0) { + let pick = null; + for (const k of order) if (frac[k] < 1 && (pick === null || frac[k] > frac[pick])) pick = k; + if (pick === null) break; + out[pick] += 1; + frac[pick] += 1; + rest -= 1; + } + } + return { M: out.M, L: out.L, S: out.S, T: out.M + out.L + out.S, raw }; + } + + // 다른 D코드를 참조하는 산근이 뒤에 오도록 — 위상순서(순환이면 원래 자리) + function orderByRefs(memos) { + const done = new Set(), + out = [], + busy = new Set(); + const visit = (code) => { + if (done.has(code) || busy.has(code) || !(code in memos)) return; + busy.add(code); + for (const m of String(memos[code] || "").matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g)) + visit(m[1]); + busy.delete(code); + done.add(code); + out.push(code); + }; + Object.keys(memos).forEach(visit); + return out; + } + + // 낙찰률 단가 — 원단가(2칸) × 낙찰률, 성분별 원 미만 버림 + const bidRate = (v, pct) => trunc(((Number(v) || 0) * (Number(pct) || 0)) / 100); + + return { evaluate, finish, stripQuotes, evalExpr, orderByRefs, bidRate }; +})(); + +if (typeof window !== "undefined") window.STMATE_SMT_EVAL = StmateSmtEval; +if (typeof module !== "undefined" && module.exports) module.exports = StmateSmtEval; diff --git a/tools/stmate-test/smt_parser.py b/tools/stmate-test/smt_parser.py index 0221161c1..062f55e26 100644 --- a/tools/stmate-test/smt_parser.py +++ b/tools/stmate-test/smt_parser.py @@ -381,3 +381,13 @@ def load_stmate_catalog() -> dict: "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] = {} + for ln in b.decode("cp949", "replace").splitlines(): + if "=" in ln and not ln.lstrip().startswith("["): + k, v = ln.split("=", 1) + out.setdefault(k.strip(), v.strip()) + return out