From d6fd3c179630a3c9845843e900ec40df9ff9f487 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 15:56:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20STmate=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=ED=91=9C=E3=80=8D=20=EC=9D=BD=EA=B0=9C=20+=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=98=95=20=ED=95=AD=EB=AA=A9=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=E2=80=94=20=EC=8B=A4=EB=AC=B4=206?= =?UTF-8?q?=EA=B1=B4=20=ED=98=B8=ED=91=9C=20170=20=EC=A0=84=EB=B6=80=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=200(=EC=BD=94=EB=8D=B1=EC=8A=A4=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=EC=99=80=20=EA=B0=99=EC=9D=8C)=20=C2=B7=20=EB=B4=89?= =?UTF-8?q?=ED=99=94=2045=20=C2=B7=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=20H2.0=20=EA=B5=AC=EC=84=B1=209=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 머리 칸이 다르거나 수량이 수가 아니면 칸 주소를 짚은 사유로 그 호표를 통째로 뺌(추측 안 함) · 갈 곳은 구성 = unit_price · 별산자재 = material · % 가산 행 = reference + 항목 사유 · 계약단가·낙찰율 줄 안 뽑고 항목 사유 · 품셈 근거 줄은 formula_text 보존 · 원문 호표·자원 코드와 공사명은 출처로만(판정 Ⓑ~Ⓗ · ㉮㉯㉰) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StmateRecipe.py | 202 ++++++++++++++++++ .../tester/test_b08_stmate_recipe_read.py | 129 +++++++++++ 2 files changed, 331 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_StmateRecipe.py create mode 100644 resources/tester/test_b08_stmate_recipe_read.py diff --git a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py new file mode 100644 index 00000000..5b4eb0ce --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py @@ -0,0 +1,202 @@ +"""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 + +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) -> 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, + } + ) + 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, + # 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장). + "origin": { + "kind": "stmate_xlsx", + "file": file_name, + "project": project, + "hopyo_no": hopyo["no"], + "hopyo_code": hopyo["source_code"], + }, + } diff --git a/resources/tester/test_b08_stmate_recipe_read.py b/resources/tester/test_b08_stmate_recipe_read.py new file mode 100644 index 00000000..bb80f8bf --- /dev/null +++ b/resources/tester/test_b08_stmate_recipe_read.py @@ -0,0 +1,129 @@ +"""STmate 출력 엑셀 「일위대가표」 읽개 — 고정형 라이브러리 항목의 둘째 길(PLAN 4장 · 2026-09-14 브레인 판정 ①). + +왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수 확인) · +경쟁사 보호 장치는 풀지 않음. 출력 엑셀은 평문이고 코덱스 `recipe_extract.py` 가 실무 6건 누락 0 으로 뽑은 모양. +⚠ **모양이 다르면 억지로 읽지 않음** — 「어느 칸이 안 맞는지」 사유로 돌려보냄(추측해서 맞추면 + 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes # noqa: E402 + +PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" +BONGHWA = next( + (p for p in PRACTICE.rglob("*.xlsx") if "기번41" in p.name and not p.name.startswith("~$")), + None, +) + + +def _book(tmp_path: Path, rows: list[list[object]], sheet: str = "일위대가표") -> Path: + book = openpyxl.Workbook() + ws = book.active + ws.title = sheet + for row in rows: + ws.append(row) + path = tmp_path / "book.xlsx" + book.save(path) + return path + + +HEAD = [ + ["일 위 대 가 표"], + ["공사명 : 시험"], + ["명 칭", "규 격", "수 량", "단위", "합 계"], + [None], +] + + +@pytest.mark.skipif(BONGHWA is None, reason="실무 엑셀(지식DB 원문) 없음") +def test_봉화_호표_45_기슭막이_H2_구성_9줄() -> None: + read = read_recipes(BONGHWA) + assert read["problems"] == [] + assert len(read["hopyo"]) == 45 + target = next( + h + for h in read["hopyo"] + if h["name"] == "기슭막이(깬잡석,찰쌓기 L3=45m)" and h["spec"] == "H=2.0m, 채집" + ) + assert target["unit"] == "M" and target["source_code"] == "B00010" + rows = target["rows"] + assert len(rows) == 9 + assert (rows[0]["name"], rows[0]["spec"], rows[0]["amount"], rows[0]["unit"]) == ( + "깬잡석채집", + "L=45cm 내외", + 2.09, + "M2", + ) + assert rows[1]["source_code"] == "B00004" # 하위 호표 참조(한 단만 — 판정 Ⓕ) + assert rows[-1]["name"] == "콘크리트믹서사용" and rows[-1]["amount"] == 0.42 + + +def test_머리_칸이_다르면_안_읽고_칸을_짚는다(tmp_path: Path) -> None: + head = [row[:] for row in HEAD] + head[2][2] = "수량합" + read = read_recipes(_book(tmp_path, [*head, [" 제 1 호표"], ["벽", "H=1", None, "m"]])) + assert read["hopyo"] == [] + assert any("C3" in p and "수량" in p for p in read["problems"]) + + +def test_수량이_수가_아니면_그_호표를_안_읽는다(tmp_path: Path) -> None: + rows = [ + *HEAD, + [" 제 1 호표"], + ["벽", "H=1", None, "m"], + ["돌", "", "약 2", "㎡"], + ["합 계"], + [" 제 2 호표"], + ["담", "H=2", None, "m"], + ["돌", "", 3, "㎡"], + ["합 계"], + ] + read = read_recipes(_book(tmp_path, rows)) + assert [h["name"] for h in read["hopyo"]] == ["담"] # 틀린 호표는 통째로 뺌 + assert any("C7" in p for p in read["problems"]) + + +def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path: Path) -> None: + """판정 ㉮㉯㉰ — 별산은 자재총괄 · % 가산 행은 참고 + 항목 사유 · 계약단가(낙찰률)는 안 뽑음 · 근거 줄 보존.""" + rows = [ + *HEAD, + [" 제 1 호표"], + ["기슭막이", "H=2.0", None, "m"], + ["건설표준품셈", "7-1-1(메쌓기)", 0, None], + ["파쇄암", "별도계상", 2.09, "㎡", *[0] * 8, "별산자재 25 "], + ["메쌓기", "L3=55cm이하", 2.09, "m2", *[0] * 8, "대가 11호표"], + ["공구손료", "노무비의 %", 2, "%"], + ["계"], + ["계약단가", None, 88.5], + ] + from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item + + read = read_recipes(_book(tmp_path, rows)) + assert read["problems"] == [] and read["project"] == "시험" + item = recipe_item(read["hopyo"][0], type_id="masonry_dry", file_name="a.xlsx", project="시험") + assert [(r["name"], r["destination"]) for r in item["rows"]] == [ + ("파쇄암", "material"), + ("메쌓기", "unit_price"), + ("공구손료", "reference"), + ] + assert all(r["formula"] == "" and "7-1-1" in r["formula_text"] for r in item["rows"]) + assert item["rows"][1]["amount"] == "2.09" + assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"] + assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"] + assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험" + + +def test_시트가_없으면_사유(tmp_path: Path) -> None: + read = read_recipes(_book(tmp_path, HEAD, sheet="다른표")) + assert read["hopyo"] == [] and any("일위대가표" in p for p in read["problems"])