From 53932e964f2808a806347c705ee34e17e4bd1ee9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 20:20:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(828-1):=20STmate=20=EC=9B=90=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B5=AC=EC=84=B1=ED=91=9C(BDQTY)=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EA=B1=B0=EC=9A=B8=20=C2=B7=20=EC=8A=A4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EB=AC=BC?= =?UTF-8?q?=EB=9F=AC=EB=82=A8=20=C2=B7=20=EC=9D=BC=EC=9C=84=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=EC=A4=84=20=EA=BC=B4=20=C2=B7=20=EB=82=99=EC=B0=B0?= =?UTF-8?q?=EB=A5=A0=20=EC=A4=84=202=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- M02_MasterTemplete/M02_Doc_FromNative.py | 3 +- M02_MasterTemplete/M02_San_Bdqty.py | 119 ++++++++++ M02_MasterTemplete/M02_San_Ilwi.py | 274 +++++++++++++++++++++++ M02_MasterTemplete/M02_San_Stc.py | 53 ++++- resources/tester/test_810_1_stc.py | 15 +- 5 files changed, 448 insertions(+), 16 deletions(-) create mode 100644 M02_MasterTemplete/M02_San_Bdqty.py create mode 100644 M02_MasterTemplete/M02_San_Ilwi.py diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index fc316068f..eaadfb8ed 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -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 ("이름", "규격", "단위")}} diff --git a/M02_MasterTemplete/M02_San_Bdqty.py b/M02_MasterTemplete/M02_San_Bdqty.py new file mode 100644 index 000000000..ce3b9c425 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Bdqty.py @@ -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(" 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 diff --git a/M02_MasterTemplete/M02_San_Ilwi.py b/M02_MasterTemplete/M02_San_Ilwi.py new file mode 100644 index 000000000..bb39447f9 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Ilwi.py @@ -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 diff --git a/M02_MasterTemplete/M02_San_Stc.py b/M02_MasterTemplete/M02_San_Stc.py index 14fff9c6e..71e790c6f 100644 --- a/M02_MasterTemplete/M02_San_Stc.py +++ b/M02_MasterTemplete/M02_San_Stc.py @@ -6,7 +6,9 @@ .stc = ZIP · COSTN.DBF(자원 · 산근 머리 · 저장 금액) · COSTN.SMT(SIxMemo · 산근 원문 · IFORM = ) · 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 diff --git a/resources/tester/test_810_1_stc.py b/resources/tester/test_810_1_stc.py index 324746a05..2af8c53b5 100644 --- a/resources/tester/test_810_1_stc.py +++ b/resources/tester/test_810_1_stc.py @@ -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 == []