feat(828-1): STmate 원파일 구성표(BDQTY) 읽기 — 거울 · 스스로 검사 · 물러남 · 일위 구성 줄 꼴 · 낙찰률 줄 2 칸

- M02_San_Bdqty = tools/stmate-test/bdqty.py 거울(믿을 칸만 · 검사 ① 자원 표 ② 부모 머리 ③ DEVD1)
- M02_San_Ilwi = 일위 구성 줄 꼴 · 다시 셈(smt_eval evaluateComposition 짝 + 닫기 뒤 전체 % B00038) · 중기 구성 · HP 호표
- M02_San_Stc.read 일위 = 구성 줄(7 건 B 216 · X 다시 셈 = 저장 단가 전부) · 낙찰률 줄(NAGCD > 0) = 2 칸 원단가
- test_810_1_stc 낡은 칸 고침: 거울 판 smt_parser 가 글 칸 앞 빈칸을 남김(3d8fddfc3) → 대조 때 걷음 · 일위 = 구성 줄 · 산근 = 계(계약 전)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 20:20:52 +09:00
co-authored by Claude Opus 5.5
parent fc30093518
commit 53932e964f
5 changed files with 448 additions and 16 deletions
+2 -1
View File
@@ -75,7 +75,8 @@ def pack(got: dict, name: str) -> dict[str, Any]:
}
for b in got["산근"]
]
out["일위"] = [{k: b.get(k) for k in head if k in b} for b in got["일위"]]
keep = dict.fromkeys((*head, "줄", "줄합", "표지")) # 828-1 일위 구성 줄(`M02_San_Ilwi` 꼴)
out["일위"] = [{k: b.get(k) for k in keep if k in b} for b in got["일위"]]
res = got.get("자원") or {}
out["단가"] = {
code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}}
+119
View File
@@ -0,0 +1,119 @@
"""경쟁사 STmate 원파일 구성표(BDQTY.DBF) 읽기(PLAN 828-1 · 요구 813 · 814 · 읽기만 · 서버 단독).
짝 = `tools/stmate-test/bdqty.py`(`decode_bdqty` · 변환 표 그대로 · 시험 `test_828_1_stc_composition.py` 가
실무 STC 일곱에서 두 답 1:1 대조) · 새로 암호를 풀지 않음 · 쓰기(`dbf_crypt.py`)는 옮기지 않음.
읽는 칸 = 믿을 칸만(`TRUSTED` — 부모 · 자식 코드 · 1 차 수량 · 나누기 · 존재 · 율 밑줄 표시 · 율 밑수 ·
대상 비목 · 관급 · BL_AMT). 못 믿는 칸(QTY2~4 · BIMOK · EQCODE · X_CODE · NAME2 · SIZE2 · 글자 칸)은 안 읽음.
스스로 검사(`check`) — ① 자식 코드 = 자원 표에 있음 · 제어 코드(PR · RR · T · C · 빈) ② 부모 머리 ∈ B · X · D
③ DEVD1 ∈ {F · 빈} — 하나라도 어긋나면 구성표를 안 씀(까닭 글 · `M02_San_Stc.read` 가 옛 표지 길로 물러남).
"""
from __future__ import annotations
import re
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 "
)
#: 믿을 칸(분석 813 1-1 · 엑셀 메타 대조 100 %)
TRUSTED = ("BDCODE", "CODE", "QTY1", "DEVD1", "EXIST1", "PRPT", "PRIN", "PROUT", "KWNGP", "BL_AMT")
_CONTROL = re.compile(r"^(?:PR|RR|[TC]\d)?$")
def _params() -> dict[int, tuple[str, int, int]]:
out = {}
for tok in _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(b: bytes) -> list[dict[str, str]]:
"""BDQTY.DBF → 행(레코드 차례) · 믿을 칸 가운데 표 안 칸만 · 글 = CP949 strip."""
if len(b) < 32:
return []
n, hlen, rlen = struct.unpack_from("<IHH", b, 4)
fields, o, off = [], 32, 1
while o + 17 <= min(hlen, len(b)) and b[o] != 0x0D:
name = b[o : o + 11].split(b"\x00")[0].decode("ascii", "ignore")
if name in TRUSTED:
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
def check(rows: list[dict[str, str]], codes: set[str]) -> str | None:
"""스스로 검사 ① ~ ③ — 어긋남 까닭 글(첫 것) · 통과 = None."""
if not rows:
return "구성 행 없음"
for r in rows:
parent, child = r.get("BDCODE") or "", r.get("CODE") or ""
if parent[:1] not in ("B", "X", "D"):
return f"부모 코드 머리 다름 「{parent[:8]}」"
if child not in codes and not _CONTROL.match(child):
return f"자식 코드가 자원 표에 없음 「{child[:8]}」"
if (r.get("DEVD1") or "") not in ("F", ""):
return f"나누기 칸 값 다름 「{(r.get('DEVD1') or '')[:4]}」"
return None
+274
View File
@@ -0,0 +1,274 @@
"""STmate 구성표 → 일위대가 구성 줄 · 다시 셈 · 중기 구성(PLAN 828-1 · 요구 814 ㉯ ㉰ · 서버 단독).
셈 짝 = `tools/stmate-test/smt_eval.js` `evaluateComposition`(+ 시험 `resources/tester/helper_stmate_ilwi.cjs`
설정) — 닫기(C*) 뒤 PR 줄 밑수 · 대상 「전체」(4 · 4) = 앞 금액 전체 × % 만 더함(분석 813 1-3 B00038).
일위 구성 줄 꼴(원파일 · 엑셀 같은 꼴 — 읽기기 답 `일위` 블록 `줄`):
{차례, 코드(자원 · PR · RR · C1 · T1 · 빈), 갈래(M L S X B D W · 율 · 닫기 · 구분 · 글), 이름, 규격, 단위,
수량(1 차), 단가 {T L M S}?(셈에 쓴 자원 단가 · 중기 조종원 = 시간 환산), 금액 {T L M S}?(줄 금액),
율? {밑, 대상(M L S T), 곱(RR)}, 밑줄?(율 밑수에 듦), 관급?, 별도계상?(자원 규격 「별도계상」)}
블록 = {코드, 이름, 규격, 단위, 저장, 줄, 줄합 {T L M S}(다시 셈 · 끝전 뒤), 표지}
율 줄 이름 = 지은 글(「노무비의 3%」 · 원문 이름 칸은 못 읽음).
HP 호표(`hopyo`) = 엑셀 일위대가표 읽기(`M02_Hopyo_Stmate.read`)와 같은 꼴 — 글 · 구분 · 닫기 줄 뺌.
중기 구성(`makes`) = 엑셀 「중기사용료」(`M02_San_MyMachine._stm_sheet`)와 같은 꼴.
"""
from __future__ import annotations
import math
import re
from typing import Any
COMP = {"1": "M", "2": "L", "3": "S", "4": "T"}
WORD = {"M": "재료비", "L": "노무비", "S": "경비", "T": "전체"}
KIND = {"M", "L", "S", "X", "B", "D", "W"}
MISMATCH = "구성 다시 셈 ≠ 저장 단가"
_SBMOK = {"1": "M", "2": "L", "3": "S", "M": "M", "L": "L", "S": "S"}
_HP = {"T": "계", "L": "노무비", "M": "재료비", "S": "경비"}
def _f(v: Any) -> float:
try:
return float(v or 0)
except (TypeError, ValueError):
return 0.0
def _tr(v: float, d: int = 0) -> float:
f = 10**d
s = v * f
return math.trunc(s + (1e-9 if s >= 0 else -1e-9)) / f
def _half(v: float, d: int = 0) -> float:
f = 10**d
r = math.floor(abs(v) * f + 0.5 + 1e-9) / f
return -r if v < 0 else r
def _wage(daily: float, expr: str, digits: int = 0) -> float:
acc = daily
for op, n in re.findall(r"([*/])?(\d+(?:\.\d+)?)", re.sub(r"\s+", "", expr or "")):
acc = acc / float(n) if op == "/" else acc * float(n)
return _tr(acc, digits)
def options(parent: str, ini: dict, sbmok: str | None) -> dict[str, Any]:
"""`helper_stmate_ilwi.cjs` 설정 — 총액우선(일위 · CHBNM 0) · 차례 OPTNM · 중기 조종원 시간 환산(CONLD · RXXNM)."""
return {
"총액우선": parent[:1] == "B" and str(ini.get("CHBNM", "1")) == "0",
"차례": str(ini.get("OPTNM") or "SML").upper(),
"비목": _SBMOK.get(str(sbmok or "").strip()),
"환산": set(re.findall(r".{1,6}", str(ini.get("CONLD") or "")))
if parent[:1] == "X"
else set(),
"환산식": str(ini.get("RXXNM") or ""),
"달러": _f(ini.get("EXLNM1")),
}
def _finish(raw: dict[str, float], opt: dict) -> dict[str, float]:
out = {k: _tr(raw[k]) for k in "MLS"}
if opt["총액우선"]:
order = list(opt["차례"])
frac = {k: _half(raw[k] - out[k], 6) for k in order}
rest = _tr(raw["T"]) - sum(out.values())
while rest > 0:
pick = None
for k in order:
if frac[k] < 1 and (pick is None or frac[k] > frac[pick]):
pick = k
if pick is None:
break
out[pick] += 1
frac[pick] += 1
rest -= 1
if to := opt["비목"]:
out = {**dict.fromkeys("MLS", 0.0), to: sum(out.values())}
return {**out, "T": out["M"] + out["L"] + out["S"]}
def _rate_line(row: dict, q: float) -> dict[str, Any]:
base, to = COMP.get(row.get("PRIN") or ""), COMP.get(row.get("PROUT") or "")
out: dict[str, Any] = {"이름": "", "규격": "", "단위": "%"}
if base and to:
mul = row.get("CODE") == "RR"
out["이름"] = f"{WORD[base]}의 {q:g}{'배' if mul else '%'}"
out["규격"] = f"{WORD[base]}의 {'배' if mul else '%'}"
out["단위"] = "" if mul else "%"
out["율"] = {"밑": base, "대상": to, "곱": mul}
return out
def lines(rows: list[dict], res: dict[str, dict], opt: dict) -> tuple[list[dict], dict[str, float]]:
"""구성 행(부모 하나) → (구성 줄 꼴, 다시 셈 {T L M S})."""
s = {"M": 0.0, "L": 0.0, "S": 0.0, "T": 0.0}
base = {"M": 0.0, "L": 0.0, "S": 0.0}
closed, out = False, []
for i, row in enumerate(rows):
code, q = row.get("CODE") or "", _f(row.get("QTY1"))
head = res.get(code) or {}
if code in ("PR", "RR"):
kind = "율"
elif re.fullmatch(r"C\d", code):
kind, closed = "닫기", True
elif re.fullmatch(r"T\d", code):
kind = "구분"
else:
kind = code[:1] if code[:1] in KIND and head else "글"
one: dict[str, Any] = {"차례": i, "코드": code, "갈래": kind, "수량": q}
one |= {k: str(head.get(k) or "") for k in ("이름", "규격", "단위")}
if kind == "율":
one |= _rate_line(row, q)
if str(row.get("PRPT") or "0") not in ("0", ""):
one["밑줄"] = True
if row.get("KWNGP") == "1":
one["관급"] = True
if "별도계상" in one["규격"]:
one["별도계상"] = True
out.append(one)
before = dict(s)
if kind == "율":
rate = one.get("율")
if not rate:
continue
if closed and rate["밑"] == rate["대상"] == "T": # 닫기 뒤 전체 % (B00038)
for k in "MLST":
s[k] = _tr(s[k] * q / 100, 1)
else:
bv = sum(base.values()) if rate["밑"] == "T" else base[rate["밑"]]
v = _tr(bv * q, 1) if rate["곱"] else _tr(bv * q / 100, 1)
s["M" if rate["대상"] == "T" else rate["대상"]] += v
s["T"] += v
one["단가"] = {"T": bv}
one["금액"] = {k: s[k] - before[k] for k in "TLMS"}
continue
if kind not in KIND:
continue
saved = head.get("저장") or {}
unit = one["단위"].strip()
own = "L" if code[0] == "L" else "S" if code[0] == "S" else "M"
pct = re.fullmatch(r"([MLST])%", unit)
if pct and closed:
for k in "MLST":
s[k] = _tr(s[k] * q / 100, 1)
elif pct:
bv = s["M"] + s["L"] + s["S"] if pct.group(1) == "T" else s[pct.group(1)]
v = _tr(bv * q / 100, 1)
s[own] += v
s["T"] += v
one["단가"] = {"T": bv}
else:
p = {k: _f(saved.get(k)) for k in "MLS"}
if code in opt["환산"]:
p = {"M": 0.0, "L": _wage(p["L"], opt["환산식"]), "S": 0.0}
if unit == "$":
p = {k: v * opt["달러"] / 1000 for k, v in p.items()}
elif unit in ("€", "¥", "¥"):
p = dict.fromkeys("MLS", 0.0)
one["단가"] = {"T": p["M"] + p["L"] + p["S"], **p}
s["T"] += _tr(q * one["단가"]["T"], 1)
for k in "MLS":
a = _tr(q * p[k], 1)
s[k] += a
if one.get("밑줄"):
base[k] += a
one["금액"] = {k: round(s[k] - before[k], 6) for k in "TLMS"}
return out, _finish(s, opt)
def same(got: dict[str, float], saved: dict | None) -> bool:
return all(abs(got[k] - _f((saved or {}).get(k))) < 0.5 for k in "MLS")
# ── 중기 구성(㉰) ─────────────────────────────────────────────────────
def make_of(name: str, spec: str, rows: list[dict]) -> dict[str, Any]:
"""중기 구성 줄 → `M02_San_MyMachine` 구성 꼴(손료 계수 × 10⁴ · 가격 천원 × 1000 · 조종원 인 · 주연료 ℓ ·
잡재료율 % · 전력 kWh) — 엑셀 「중기사용료」 `_stm_sheet` 와 같은 값."""
make: dict[str, Any] = {"이름": name, "규격": spec, "손료": []}
for one in rows:
g, q, unit = one["갈래"], one["수량"], one["단위"].strip().upper()
if g == "S" and unit == "천원" and q and (one.get("단가") or {}).get("S"):
make["손료"].append([q * 10_000, one["단가"]["S"] * 1000, False])
elif g == "L" and q > 0 and not make.get("조종원"):
make["조종원"], make["조종원직종"] = q, one["이름"].strip()
elif g == "M" and unit in ("L", "ℓ") and q > 0 and make.get("주연료") is None:
make["주연료종류"], make["주연료"] = one["이름"].strip(), q
elif g == "M" and unit == "KWH" and q > 0:
make["전력"] = q
elif (g == "율" or unit.endswith("%")) and make.get("잡재료율") is None:
make["잡재료율"] = q
return make
def peer_of(make: dict) -> dict[str, Any]:
"""같은 파일 구성 대조 꼴(`M02_Machine_Find.peers` — 손료 [[계수, 가격]] · 주연료 · 조종원)."""
loss = [x[:2] for x in make["손료"]]
return {"손료": loss, "주연료": make.get("주연료"), "조종원": bool(make.get("조종원"))}
def compose(
rows: list[dict], res: dict[str, dict], ini: dict, sbm: dict
) -> tuple[dict[str, dict], dict[str, dict], list[str]]:
"""구성 행 → ({일위 코드: {줄, 줄합, 표지}}, {중기 코드 · 「이름|규격」: 구성}, 파일 표지) ·
다시 셈 ≠ 저장(값 있는 호표) = 호표 표지(일위) · 파일 표지(중기) — 가져오기는 됨."""
from M02_MasterTemplete.M02_San_MyMachine import key_of
groups: dict[str, list[dict]] = {}
for r in rows:
groups.setdefault(r.get("BDCODE") or "", []).append(r)
blocks, makes, marks = {}, {}, []
for parent, group in groups.items():
head = res.get(parent)
if parent[:1] not in ("B", "X") or not head:
continue
got, tot = lines(group, res, options(parent, ini, sbm.get(parent)))
saved = head.get("저장") or {}
ok = not _f(saved.get("T")) or same(tot, saved)
if parent[0] == "B":
blocks[parent] = {"줄": got, "줄합": tot, "표지": [] if ok else [MISMATCH]}
continue
if not ok:
marks.append(f"중기 {parent} {MISMATCH}")
make = make_of(str(head.get("이름") or ""), str(head.get("규격") or ""), got)
if make["손료"]:
makes[parent] = makes[key_of(make["이름"], make["규격"])] = make
return blocks, makes, marks
# ── HP 호표(일위대가 불러오기 길) ───────────────────────────────────────
def _hp(m: dict | None) -> dict[str, Any]:
return {_HP[k]: (round(v, 6) if isinstance(v, float) else v) for k, v in (m or {}).items()}
def hopyo(blocks: list[dict]) -> list[dict[str, Any]]:
"""일위 블록(줄 있음) → HP 호표 `{번, 코드, 이름, 규격, 단위, 금액, 줄}`(번 = 코드 숫자 · 줄 = 자원 · 율 줄)."""
from M02_MasterTemplete import M02_Hopyo_Excel as he
out = []
for b in blocks:
if not b.get("줄"):
continue
rows = []
for one in b["줄"]:
if one["갈래"] in ("글", "구분", "닫기"):
continue
code = one["코드"]
line = {"차례": len(rows), **{k: one[k] for k in ("이름", "규격", "수량", "단위")}}
line |= {"단가": _hp(one.get("단가")), "금액": _hp(one.get("금액")), "코드": code}
line["비고"] = "별산" if one.get("별도계상") else ""
for k in ("계", "노무비", "재료비", "경비"):
line["단가"].setdefault(k, 0)
line["금액"].setdefault(k, 0)
line["갈래"] = he.kind_of(line)
line["참조"] = (
{"종류": "단산", "번": int(code[1:]), "코드": code}
if code[:1] == "D" and code[1:].isdigit()
else {"종류": "일위", "번": int(code[1:]), "코드": code}
if code[:1] == "B" and code[1:].isdigit()
else None
)
rows.append(line)
head = {"번": int(b["코드"][1:]), "코드": b["코드"]}
head |= {k: str(b.get(k) or "") for k in ("이름", "규격", "단위")}
out.append({**head, "금액": _hp(b.get("저장")), "줄": rows})
return out
+45 -8
View File
@@ -6,7 +6,9 @@
.stc = ZIP · COSTN.DBF(자원 · 산근 머리 · 저장 금액) · COSTN.SMT(SIxMemo · 산근 원문 · IFORM =
<HII 꼬리표 · 길이 · 블록 번호>) · BOQ11.DBF(내역).
BDQTY.DBF(일위대가 구성) · SYSINFO = 난독화 → 표지.
BDQTY.DBF(일위대가 · 중기 구성) = 828-1 `M02_San_Bdqty`(믿을 칸 · 스스로 검사 · 어긋나면 물러남 = 일위 줄 없음 +
표지) → `M02_San_Ilwi`(일위 구성 줄 · 다시 셈 · 중기 구성 `기계구성` · 같은 파일 구성 대조 `같은구성`).
SYSINFO = 난독화 → 표지. 금액 칸 = 차수 1 · 낙찰률 줄(NAGCD > 0) = 2 칸(원단가 · 828-1 ㉱ · 2~4 차 수량은 못 읽음).
"""
from __future__ import annotations
@@ -24,10 +26,12 @@ from M02_MasterTemplete import M02_San_Stmate as stm
KIND = {"L": "노임", "M": "자재", "S": "경비", "E": "기계", "X": "기계", "D": "산근", "B": "일위"}
#: 금액 칸(차수 1) → 비목 글자
AMT = {"TAMT1": "T", "LAMT1": "L", "MAMT1": "M", "SAMT1": "S"}
AMT2 = {"TAMT2": "T", "LAMT2": "L", "MAMT2": "M", "SAMT2": "S"} # 낙찰률 줄 원단가(828-1 ㉱)
BOQ_AMT = {"T_AMT1": "T", "L_AMT1": "L", "M_AMT1": "M", "S_AMT1": "S"}
#: 엑셀 숨은 J 열 메타 이름 ← COSTN 칸(`M02_San_Eval.stmate_block` 이 읽음)
META = {"UNT": "UNIT", "UC": "UC", "NAG": "NAGCD", "SBM": "SBMOK", "ADJ": "S_ADJ"}
UNREAD = ("BDQTY.DBF 난독화 — 일위대가 구성 줄 못 읽음", "SYSINFO.DBF 난독화")
FALLBACK = "BDQTY 구성표 안 씀 — " # + 까닭(828-1 스스로 검사)
class StcError(ValueError):
@@ -156,7 +160,7 @@ def read(data: bytes) -> dict[str, Any]:
code = r.get("CODE") or ""
if not code:
continue
saved = _amt(r, AMT)
saved = _amt(r, AMT2 if _f(r.get("NAGCD")) > 0 else AMT)
res[code] = {
"종류": KIND.get(code[:1]),
"이름": r.get("NAME"),
@@ -166,14 +170,21 @@ def read(data: bytes) -> dict[str, Any]:
}
prices[code] = {**saved, "meta": {k: r.get(f) for k, f in META.items()}}
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
ini = z.read(names["START8X.INI"]).decode("cp949", "replace") if "START8X.INI" in names else ""
out["옵션"] = dict(re.findall(r"^\s*(\w+)\s*=[ \t]*(.*?)\s*$", ini, re.M | re.A))
comp, why = _composition(z, names, costn, out)
for r in costn:
code, text = r.get("CODE") or "", memo(r.get("IFORM") or b"", smt)
if not code or (text is None and code[:1] != "B"):
continue
tags: list[str] = []
head = {k: res[code][k] for k in ("이름", "규격", "단위", "저장")}
if text is None and code in comp:
out["일위"].append({"코드": code, **head, **comp[code]})
continue
if text is None:
out["일위"].append({"코드": code, **head, "줄": [], "줄합": None, "표지": [UNREAD[0]]})
mark = ["BDQTY 구성 행 없음"] if why is None else [UNREAD[0], FALLBACK + why]
out["일위"].append({"코드": code, **head, "줄": [], "줄합": None, "표지": mark})
continue
lines = [stc_line(i, ln, res, tags) for i, ln in enumerate(text.splitlines(), 1)]
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
@@ -193,14 +204,40 @@ def read(data: bytes) -> dict[str, Any]:
}
for r in dbf(z.read(names["BOQ11.DBF"]))
]
ini = z.read(names["START8X.INI"]).decode("cp949", "replace") if "START8X.INI" in names else ""
out["옵션"] = dict(re.findall(r"^\s*(\w+)\s*=[ \t]*(.*?)\s*$", ini, re.M | re.A))
odd = sorted({c[:1] for c, v in res.items() if v["종류"] is None})
read_ok = "BDQTY.DBF 구성표 읽음 — 글자 칸 · 2~4 차 수량은 못 읽음(이름 · 규격 = 자원 표)"
out["표지"] = [
*UNREAD,
"금액 = 차수 1 칸(TAMT1 · LAMT1 · MAMT1 · SAMT1)",
*([read_ok] if why is None else [UNREAD[0], FALLBACK + why]),
UNREAD[1],
*out.pop("구성표지", []),
"금액 = 차수 1 칸(TAMT1 · LAMT1 · MAMT1 · SAMT1) · 낙찰률 줄 = 2 칸(원단가)",
"블록 끝전 갈래 = 옵션 SAN_TYP 후보(뜻 미확정)",
*(f"모르는 코드 머리 {c}" for c in odd),
]
return out
def _composition(
z: zipfile.ZipFile, names: dict, costn: list[dict], out: dict
) -> tuple[dict, str | None]:
"""BDQTY 구성표 → ({일위 코드: {줄, 줄합, 표지}}, 안 쓴 까닭 | None) · 중기 구성 = `out["기계구성"]` ·
같은 파일 구성 대조 = `out["단가"][X]["같은구성"]`(816-2 `M02_Machine_Find.peers`)."""
from M02_MasterTemplete import M02_Machine_Find as mfind
from M02_MasterTemplete import M02_San_Bdqty as bq
from M02_MasterTemplete import M02_San_Ilwi as il
if "BDQTY.DBF" not in names:
return {}, "BDQTY.DBF 없음"
try:
rows = bq.decode(z.read(names["BDQTY.DBF"]))
except Exception as e: # noqa: BLE001 — 못 읽음 = 물러남(오류 아님)
return {}, f"못 읽음 {type(e).__name__}"
if why := bq.check(rows, set(out["자원"])):
return {}, why
sbm = {r.get("CODE"): r.get("SBMOK") for r in costn}
blocks, makes, marks = il.compose(rows, out["자원"], out["옵션"], sbm)
out["기계구성"], out["구성표지"] = makes, marks
xs = {c: (m["이름"], m["규격"], il.peer_of(m)) for c, m in makes.items() if c[:1] == "X"}
for code, got in mfind.peers(xs).items():
out["단가"][code]["같은구성"] = got
return blocks, None
+8 -7
View File
@@ -3,8 +3,8 @@
(a) 거울 — `tools/stmate-test/smt_parser.py`(parse_dbf_bytes · extract_costn_memos)와
COSTN · BOQ11 행 ·
칸 · 산근 원문 1:1(다름 0) · 줄 풀이 = `M02_San_Stmate.parse_line`
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(TAMT1 · LAMT1 · MAMT1 · SAMT1) 100 %
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` 계 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(차수 1 칸 · 낙찰률 줄 = 2 칸 · 828-1) 100 %
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
원파일 없으면 skip(다른 PC).
"""
@@ -37,7 +37,7 @@ FILES = {
"소광22": SO / "1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC", # noqa: E501
"소광변경1": SO / "변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", # noqa: E501
} # fmt: skip
#: 파일 → (산근 블록, 일위 블록 · 구성 못 읽음, SAN_TYP)
#: 파일 → (산근 블록, 일위 블록(828-1 구성표 줄), SAN_TYP)
WANT = {
"봉화": (65, 45, "1"),
"동부청영월": (69, 23, "0"),
@@ -78,7 +78,8 @@ def test_mirror_smt_parser(tag: str) -> None:
mine = S.dbf(m[name])
assert len(mine) == len(theirs), name
for a, b in zip(mine, theirs, strict=True):
b = {k: v for k, v in b.items() if k != "_deleted"}
# 거울 판(stmate-test)은 글 칸 앞 빈칸을 남김 — 제품은 양끝을 걷음(이름 맞춤 그대로)
b = {k: v.strip() if isinstance(v, str) else v for k, v in b.items() if k != "_deleted"}
if "IFORM" in a:
assert a.pop("IFORM").hex() == b.pop("IFORM_HEX")
b.pop("IFORM")
@@ -97,10 +98,10 @@ def test_saved_amounts(tag: str) -> None:
d = parsed(tag)
n_cost, n_head, san = WANT[tag]
assert (len(d["산근"]), len(d["일위"]), d["옵션"]["SAN_TYP"]) == (n_cost, n_head, san)
assert all(b["표지"] == [S.UNREAD[0]] and not b["줄"] for b in d["일위"])
assert all(b["줄"] and not b["표지"] for b in d["일위"]) # 828-1 구성표 읽음
bad = []
for b in d["산근"]:
got = ev.stmate_block(b, [ln["풀이"] for ln in b["줄"]], d["단가"], RULE[san])["금액"]
for b in d["산근"]: # 저장 = 계약 전 계(낙찰률 줄 = 2 칸 원단가 · 828-1 ㉱)
got = ev.stmate_block(b, [ln["풀이"] for ln in b["줄"]], d["단가"], RULE[san])["계"]
if any(abs(got[c] - b["저장"][c]) >= 0.5 for c in "TLMS"):
bad.append((b["코드"], got, b["저장"]))
assert bad == []