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