feat(stmate-test): 단가산출근거(D코드) 재계산 — 원설계 5건 282개 1원 · 낙찰률 단가
- smt_eval.js 새 산근 평가기 — 봉화에만 맞던 옛 해석기(161/242) 대체 - 총액우선 차액 보정(Start8x.Ini CHGNM=0 · OPTNM 순서 · 소수부 십진 비교) · 비목우선은 성분 절사 - 다른 산근 참조 = 재·노·경 나눠 가져옴 · 위상순서 재계산 · 변수 대소문자 무시 - % 단위 자원(인건비의 % · 운반비 계) = 누계 밑수 · (-=)(-==) 합계 제외 · RS_ 직전 구간 · 라벨 묶음 PA010203 - 운반비 경비 귀속 = COSTN SBMOK=S — 원본 저장 금액으로 판정하던 부분 걷음 - 낙찰률 단가 = 2칸 원단가 × NAGCD 원 미만 버림(7건 127개) - 지킴 시험 test_stmate_smt_eval.py · 장부 진행률 30.6% → 32.2% Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }));
|
||||
});
|
||||
@@ -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)
|
||||
+17
-16
@@ -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)
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
</div>
|
||||
|
||||
<script src="calc.js"></script>
|
||||
<script src="smt_eval.js"></script>
|
||||
<script src="smt_engine.js"></script>
|
||||
<script src="view_prj_boq.js"></script>
|
||||
<script src="view_dan_ilwi.js"></script>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+45
-259
@@ -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++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user