feat(b08): STmate 출력 엑셀 「일위대가표」 읽개 + 고정형 항목 만들기 — 실무 6건 호표 170 전부 문제 0(코덱스 감사와 같음) · 봉화 45 · 기슭막이 H2.0 구성 9줄
머리 칸이 다르거나 수량이 수가 아니면 칸 주소를 짚은 사유로 그 호표를 통째로 뺌(추측 안 함) · 갈 곳은 구성 = unit_price · 별산자재 = material · % 가산 행 = reference + 항목 사유 · 계약단가·낙찰율 줄 안 뽑고 항목 사유 · 품셈 근거 줄은 formula_text 보존 · 원문 호표·자원 코드와 공사명은 출처로만(판정 Ⓑ~Ⓗ · ㉮㉯㉰) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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"(?<!BD)COD\|([A-Z]\d{5})")
|
||||
HOPYO_CODE = re.compile(r"BDCOD\|(B\d{5})")
|
||||
#: 비고가 이것이면 별도계상 자재 — 자재총괄 자리(판정 ㉮). 그 밖의 비고로는 갈 곳을 안 가름.
|
||||
SEPARATE_MATERIAL = ("별산자재", "별산M")
|
||||
#: 착공·변경 내역서 판에만 섞이는 낙찰률 줄 — 설계 조합이 아니라 안 뽑음(판정 ㉰).
|
||||
#: 실무 6건 전수에서 단위 없는 줄은 이 다섯 꼴뿐: 합계 · 계 · 소계 · 계약단가 · 계 x 낙찰율(88 %).
|
||||
CONTRACT_ROWS = ("계약단가", "계x낙찰율")
|
||||
NOTE_SOURCE_QTY = "원문 시점 수량 · 현행 품셈 대조 전"
|
||||
NOTE_PERCENT = "원문에 가산 행 {count}줄 있었음({names}) — 우리 계산에는 안 듦"
|
||||
NOTE_CONTRACT = "원문 계약단가·낙찰율 줄 {count}줄은 설계 조합이 아니라 안 뽑음"
|
||||
|
||||
|
||||
def _text(value: Any) -> 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"],
|
||||
},
|
||||
}
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user