feat(stmate-test): 일위대가·중기 재계산 — BDQTY 구성표 복호 · 연쇄 재계산 기초단가부터 1원

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 14:29:14 +09:00
co-authored by Claude Opus 5.5
parent f591c14356
commit 217e20a6e0
8 changed files with 386 additions and 172 deletions
@@ -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 }));
});
+35
View File
@@ -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));
});
+74
View File
@@ -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
+13 -11
View File
@@ -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 정적 노출)
+98
View File
@@ -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("<IHH", b, 4)
fields, o, off = [], 32, 1
while o < hlen and b[o] != 0x0D:
name = b[o : o + 11].split(b"\x00")[0].decode("ascii", "ignore")
fields.append((name, off, b[o + 16]))
off += b[o + 16]
o += 32
rows = []
for i in range(n):
rec = b[hlen + i * rlen : hlen + (i + 1) * rlen]
if len(rec) < rlen:
break
row = {}
for name, foff, ln in fields:
span = range(foff, foff + ln)
if all(j in PARAMS for j in span):
raw = bytes(_dec(rec[j], PARAMS[j]) for j in span)
row[name] = raw.decode("cp949", "replace").strip()
rows.append(row)
return rows
+2
View File
@@ -13,6 +13,7 @@ from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from bdqty import decode_bdqty
from excel_builder import build_stmate_full_xlsx
from kanjub_cfg import build_kanjub_config
from smt_parser import (
@@ -443,6 +444,7 @@ def api_load(req: LoadReq):
"boq_rows": boq_rows,
"rate_tables": rate_tables,
"set_rep_rows": set_rep_rows,
"bdqty_rows": decode_bdqty(files.get("BDQTY.DBF", b"")),
"start_ini": parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b""),
"sim_state": sim_state,
}
+66 -158
View File
@@ -6,7 +6,7 @@
"use strict";
const Calc = global.STMATE_CALC;
const { fmt, num, roundDown } = Calc;
const { fmt, num } = Calc;
const SmtEval = global.STMATE_SMT_EVAL;
const rowCode = (r) => 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;
}
/**
+78 -3
View File
@@ -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;