"""STmate 출력 엑셀 「일위대가표」 읽개 → **고정형** 라이브러리 항목 (PLAN 4장 · 2026-09-14 브레인 판정). 왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수) · 경쟁사 보호 장치는 안 풂. 출력 엑셀은 평문이고 모양은 코덱스 `recipe_extract.py`(실무 6건 누락 0)가 본 그대로. ⚠ **모양이 다르면 억지로 읽지 않음** — 머리 칸·수량 칸이 어긋나면 「어느 칸이 안 맞는지」 사유로 돌려보내고 그 호표는 통째로 뺌. 추측해서 맞추면 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임. ⚠ 뽑는 것은 **수량만**(판정 Ⓔ) — 원문 단가·금액은 안 실음. 수량도 그 시점 품셈값이라 사유를 붙임. ⚠ 한 단만(판정 Ⓕ) — 구성 줄이 하위 호표면 그 이름·수량만. 하위 전개는 우리 일위대가 몫. ⚠ 원문 호표·자원 코드(B#####·M#####)는 파일 안 카운터라 **출처로 기록만**(판정 Ⓓ). """ from __future__ import annotations import re from pathlib import Path from typing import Any, BinaryIO SHEET = "일위대가표" HEADER_ROW = 3 HEADER = ("명칭", "규격", "수량", "단위") COLUMNS = "ABCD" HOPYO = re.compile(r"^제(\d+)호표$") ROW_CODE = re.compile(r"(? str: return "" if value is None else str(value).strip() def read_recipes(path: str | Path | BinaryIO) -> dict[str, Any]: """`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만.""" import openpyxl problems: list[str] = [] try: book = openpyxl.load_workbook(path, data_only=True, read_only=True) except Exception as error: # noqa: BLE001 — 어떤 파일이든 사유로 돌려보냄 return {"project": "", "hopyo": [], "problems": [f"엑셀을 못 엶 — {error}"]} if SHEET not in book.sheetnames: return { "project": "", "hopyo": [], "problems": [f"시트 「{SHEET}」가 없음 — STmate 출력 엑셀이 아님"], } rows = [list(r) + [None] * 14 for r in book[SHEET].iter_rows(max_col=14, values_only=True)] head = rows[HEADER_ROW - 1] if len(rows) >= HEADER_ROW else [None] * 14 for column, want, got in zip(COLUMNS, HEADER, head): if _text(got).replace(" ", "") != want: problems.append( f"{column}{HEADER_ROW} 칸이 「{want}」이 아니라 「{_text(got)}」 — 이 모양은 못 읽음" ) if problems: return {"project": "", "hopyo": [], "problems": problems} project = _text(rows[1][0]).split(":", 1)[-1].strip() if len(rows) > 1 else "" hopyo: list[dict[str, Any]] = [] current: dict[str, Any] | None = None def close() -> None: if current and not current["broken"]: if current["name"]: hopyo.append({k: v for k, v in current.items() if k != "broken"}) else: problems.append(f"제{current['no']}호표 제목 줄이 없음 — 안 읽음") for index, row in enumerate(rows[HEADER_ROW:], start=HEADER_ROW + 1): name, spec, qty, unit = _text(row[0]), _text(row[1]), row[2], _text(row[3]) key = name.replace(" ", "") found = HOPYO.match(key) if found: close() code = HOPYO_CODE.search(_text(row[13])) current = { "no": int(found.group(1)), "source_code": code.group(1) if code else "", "name": "", "spec": "", "unit": "", "rows": [], "basis": [], "contract_rows": 0, "broken": False, } continue if key.startswith("합계"): close() current = None continue if current is None or current["broken"] or not any(_text(c) for c in row[:4]): continue where = f"제{current['no']}호표" if not current["name"]: if not name or qty not in (None, "") or not unit: problems.append( f"A{index}: {where} 제목 줄(명칭·단위 · 수량 빈칸) 모양이 아님 — 안 읽음" ) current["broken"] = True else: current.update(name=name, spec=spec, unit=unit) continue if not unit and (key == "계" or key.startswith("소계")): continue # 착공·변경 판 소계 줄 — 구성 아님 if not unit and key.startswith(CONTRACT_ROWS): current["contract_rows"] += 1 continue if not unit and qty == 0 and not isinstance(qty, bool): current["basis"].append(f"{name} {spec}".strip()) # 품셈 근거 줄 — 수량 0 · 단위 없음 continue if isinstance(qty, bool) or not isinstance(qty, (int, float)): problems.append( f"C{index}: {where} 「{name}」 수량 「{_text(qty)}」이 수가 아님 — 안 읽음" ) current["broken"] = True continue if not name or not unit: empty = "명칭" if not name else "단위" problems.append( f"{'A' if not name else 'D'}{index}: {where} 「{name}」 {empty}이 빔 — 이 모양은 못 읽음" ) current["broken"] = True continue code = ROW_CODE.search(_text(row[13])) current["rows"].append( { "name": name, "spec": spec, "amount": qty, "unit": unit, "remark": _text(row[12]), "source_code": code.group(1) if code else "", } ) close() return {"project": project, "hopyo": hopyo, "problems": problems} def recipe_item( hopyo: dict[str, Any], *, type_id: str, file_name: str, project: str ) -> dict[str, Any]: """읽은 호표 하나 → 고정형 라이브러리 항목(명세 13장 칸 계약 · `formula` 빈칸 = 고정형). 코드는 저장이 발급.""" basis = " · ".join(hopyo.get("basis") or []) rows = [] percent = [] for seq, source in enumerate(hopyo["rows"], start=1): is_percent = source["unit"] == "%" if is_percent: percent.append(source["name"]) separate = any(mark in source["remark"].replace(" ", "") for mark in SEPARATE_MATERIAL) rows.append( { "seq": seq, "name": source["name"], "spec": source["spec"], "formula": "", "formula_text": " · ".join(part for part in ("STmate 원문 수량", basis) if part), "amount": str(source["amount"]), "unit": source["unit"], # 원문에서 구성 줄은 그 호표 단가 안으로 들어감(호표 = 일위대가) · 별도계상만 자재총괄(판정 ㉮) "destination": "reference" if is_percent else "material" if separate else "unit_price", "rounding": {"mode": "none", "digits": 0}, "source": "library", "reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY, } ) # ㉮ 일위대가 구성 — 일위대가로 가는 줄마다 한 줄(수량 = 그 원단위 줄). **공종 코드는 비움**: # 원문 코드는 파일 카운터라 못 쓰고 이름으로 확정도 안 함(명세 2장) → 사용자가 고르개로 고름. # 고르기 전엔 그 장 금액이 「미완 — 공종 코드 미정」(조용한 0 아님 · 2026-09-14 판정). unit_rows = [ { "seq": index, "name": row["name"], "spec": row["spec"], "unit": row["unit"], "from_row": row["seq"], } for index, row in enumerate( (row for row in rows if row["destination"] == "unit_price"), start=1 ) ] notes = [NOTE_SOURCE_QTY] if percent: notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent))) if hopyo.get("contract_rows"): notes.append(NOTE_CONTRACT.format(count=hopyo["contract_rows"])) return { "schema_version": 1, "item_kind": "fixed", "type_id": type_id, "name": f"{hopyo['name']} {hopyo['spec']}".strip(), "unit": hopyo["unit"], "note": " · ".join(notes), "vars": {}, "tables": {}, "rows": rows, "unit_price": { "unit": hopyo["unit"], "note": "STmate 호표 구성 — 줄마다 공종 코드를 고르개로 골라야 금액이 섬(원문 코드는 안 씀)", "rows": unit_rows, }, # 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장). "origin": { "kind": "stmate_xlsx", "file": file_name, "project": project, "hopyo_no": hopyo["no"], "hopyo_code": hopyo["source_code"], }, }