Files
Aislo/M02_MasterTemplete/M02_Hopyo_Excel.py
T
eomsangdonandClaude Opus 5.5 27c62ce220 feat(일위대가실무): 329-1 · 330-1 · 330-2 서버 — 엑셀꼴 격자 · 행 범위 · 단가산출 근거 맞춤
- 329-1 새 M02_Hopyo_Grid(xls formatting_info 값 · 병합 · 열 폭 · 행 높이 · 서식표 · 틀고정 / xlsx 값만) · read_book 호표 · 줄 「행」 · Store 격자 저장 · 읽기 · 지움(실무자료/격자/<키>.json) · GET hopyo-grid/{key}(옛 파일 404) · Match 요약 .get
- 330-1 새 M02_Hopyo_Basis.read — 단산N 블록 이름 · 단위 · 행 · 출처(책 · 절) · 직종(우리 인력 이름 · 옛이름) · 금액 · 단산 줄 「근거」
- 330-2 Basis.ours · candidates(조항 1.0 · 앞 마디 0.5 · 건설 공통 · 토목 0.1 · 직종 0.5 · 단위 0.15 · 까닭) · match_line 이름 후보와 로직 키로 합침 · 상태 후보 그대로
- 시험 test_m02_hopyo_grid · test_m02_hopyo_basis 새로 · test_m02_hopyo_match 제3호표 구조물터파기 1순위 → 굴착(인력/토사)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:37:18 +09:00

288 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 일위대가 실무자료 읽기(PLAN 324-1) — 설계내역서 엑셀 「일위대가」 시트 →
호표 · 줄 · 갈래 · 검산.
읽기 = .xls `xlrd`(경고가 stdout 으로 안 나가게 logfile) · .xlsx `openpyxl`(빈 칸 None) ·
시트 · 열은 머리 글(빈칸 뺌)로 찾음 — 호표 · 품명 · 규격 · 수량 · 단위 · 합계 · 노무비 ·
재료비 · 경비 · 비고(돈 머리 = 단가 열 · 다음 열 = 금액).
호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 ·
끝 = 다음 호표.
갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) ·
자재(재료비만) · 기타 — 규격은 원문 그대로 둠.
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
어긋남은 글 목록(막지 않음).
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
`단산` = 블록마다 `{번, 이름, 단위, 행}`.
"""
from __future__ import annotations
import io
import re
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
SHEET = "일위대가"
SUMMARY = "일위대가총괄표"
COSTS = ("노무비", "재료비", "경비")
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
NO_SHEET = "일위대가 시트 없음"
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$")
_MACHINE_UNITS = ("hr", "시간", "h")
class ExcelError(ValueError):
"""엉터리 파일 · 시트 없음 — 길이 400 으로 답함."""
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
def _text(value: Any) -> str:
if isinstance(value, float) and value == int(value):
value = int(value)
return str(value if value is not None else "").strip()
def _num(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool) or value == "":
return None
try:
got = Decimal(str(value).replace(",", "").strip())
except InvalidOperation:
return None
return got if got.is_finite() else None
def _floor(value: Decimal, places: int = 1) -> Decimal:
step = Decimal(1).scaleb(-places)
return value.quantize(step, rounding=ROUND_FLOOR)
def _out(value: Decimal | None) -> int | float | None:
if value is None:
return None
return int(value) if value == value.to_integral_value() else float(value)
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음."""
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
if not data:
raise ExcelError("빈 파일")
try:
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
import openpyxl
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
out = {
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
for ws in book.worksheets
if ws.title.strip() in wanted
}
book.close()
return out
import xlrd
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
return {
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
for sh in book.sheets()
if sh.name.strip() in wanted
}
except ExcelError:
raise
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
raise ExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error
def _columns(rows: list[list[Any]], money: tuple[str, ...]) -> tuple[int, dict[str, int]]:
"""(자료 첫 줄, {머리: 열}) — 머리 글은 앞 10 줄 안 「호표」 · 「품명」 이 든 줄."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "호표" not in flat or "품명" not in flat:
continue
cols = {head: flat.index(head) for head in (*HEADS, *money) if head in flat}
below = [_flat(v) for v in rows[at + 1]] if at + 1 < len(rows) else []
start = at + 2 if "단가" in below or "금액" in below else at + 1
return start, cols
raise ExcelError(f"{SHEET} 시트 머리 글(호표 · 품명)을 못 찾음")
def _cell(row: list[Any], col: int | None) -> Any:
return row[col] if col is not None and col < len(row) else None
def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decimal | None]:
"""비목 넷(합계 = 계) — `offset` 0 = 단가 열 · 1 = 금액 열."""
return {
("계" if head == "합계" else head): _num(_cell(row, None if c is None else c + offset))
for head in ("합계", *COSTS)
for c in [cols.get(head)]
}
# ── 갈래 · 검산 ─────────────────────────────────────────────────────────
def kind_of(line: dict[str, Any]) -> str:
"""구성 줄 갈래 — 단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타."""
ref = line.get("참조") or {}
unit = _flat(line.get("단위")).lower()
price = line["단가"]
if unit == "%":
return "%"
if ref.get("종류") == "일위":
return "호표"
if ref.get("종류") == "단산":
return "단가산출"
if unit in _MACHINE_UNITS:
return "기계"
if not any(price.get(k) for k in ("계", *COSTS)):
return "별도계상"
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
return "자재"
return "기타"
def _check_line(where: str, line: dict[str, Any]) -> list[str]:
qty, price, amount = line["수량"], line["단가"], line["금액"]
bad = []
if line["갈래"] == "%":
base = price.get("계")
want = _floor(base * qty / 100) if base is not None and qty else None
got = sum((amount.get(k) or Decimal(0)) for k in COSTS)
if want is not None and got != want:
bad.append(f"{where} · % 금액 {got} ≠ {want}")
return bad
if qty is None:
return bad
for k in COSTS:
if price.get(k) is None:
continue
want = _floor(price[k] * qty)
if (amount.get(k) or Decimal(0)) != want:
bad.append(f"{where} · {k} 금액 {amount.get(k)} ≠ {want}")
parts = [price.get(k) for k in COSTS]
if price.get("계") is not None and any(p is not None for p in parts):
total = sum((p or Decimal(0)) for p in parts)
if total != price["계"]:
bad.append(f"{where} · 합계 단가 {price['계']} ≠ {total}")
return bad
def _check_hopyo(hopyo: dict[str, Any], lines: list[dict[str, Any]]) -> list[str]:
where = f"제{hopyo['번']}호표"
bad = []
for line in lines:
bad += _check_line(f"{where} {line['차례'] + 1}째 줄 「{line['이름']}」", line)
if not lines:
return bad
for k in COSTS:
total = sum((line["금액"].get(k) or Decimal(0)) for line in lines)
want, got = _floor(total, 0), hopyo["금액"].get(k) or Decimal(0)
if got != want:
bad.append(f"{where} · {k} {got} ≠ 줄 합 {want}")
return bad
def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]:
"""총괄표 {호표 번: 합계} — 머리 글이 없으면 빈 것."""
if not rows:
return {}
try:
start, cols = _columns(rows, ("합계",))
except ExcelError:
return {}
out = {}
for row in rows[start:]:
hit = _HOPYO.fullmatch(_flat(_cell(row, cols.get("호표"))))
total = _num(_cell(row, cols.get("합계")))
if hit and total is not None:
out[int(hit.group(1))] = total
return out
# ── 읽기 ──────────────────────────────────────────────────────────────
def read_book(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
sheets = _sheets(name, data)
rows = sheets.get(SHEET)
if rows is None:
raise ExcelError(NO_SHEET)
start, cols = _columns(rows, ("합계", *COSTS))
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
for at, row in enumerate(rows[start:], start + 1):
head = _flat(_cell(row, cols.get("호표")))
name_ = _text(_cell(row, cols.get("품명")))
hit = _HOPYO.fullmatch(head)
if hit:
hopyos.append(
{
"번": int(hit.group(1)),
"이름": name_,
"규격": _text(_cell(row, cols.get("규격"))),
"단위": _text(_cell(row, cols.get("단위"))),
"금액": _money(row, cols, 1),
"행": [at, at],
}
)
raw.append([])
continue
if head or not name_ or not hopyos:
continue
note = _text(_cell(row, cols.get("비고")))
ref = _REF.match(note.replace(" ", ""))
line = {
"차례": len(raw[-1]),
"이름": name_,
"규격": _text(_cell(row, cols.get("규격"))),
"수량": _num(_cell(row, cols.get("수량"))),
"단위": _text(_cell(row, cols.get("단위"))),
"단가": _money(row, cols, 0),
"금액": _money(row, cols, 1),
"비고": note,
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
}
line["갈래"] = kind_of(line)
line["행"] = hopyos[-1]["행"][1] = at
raw[-1].append(line)
if not hopyos:
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET))
for line in (one for lines in raw for one in lines):
ref = line["참조"] or {}
if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])):
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
wrong: list[str] = []
totals = _summary(sheets.get(SUMMARY))
for hopyo, lines in zip(hopyos, raw, strict=True):
wrong += _check_hopyo(hopyo, lines)
total = hopyo["금액"].get("계")
if hopyo["번"] in totals and total is not None and totals[hopyo["번"]] != total:
wrong.append(f"제{hopyo['번']}호표 · 총괄표 합계 {totals[hopyo['번']]} ≠ {total}")
out = [
{**_json(hopyo), "줄": [_json(line) for line in lines]}
for hopyo, lines in zip(hopyos, raw, strict=True)
]
basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()]
return {
"시트": SHEET,
"호표": out,
"단산": basis,
"검산": {"어긋남": wrong, "총괄표": len(totals)},
}
def _json(value: Any) -> Any:
if isinstance(value, Decimal):
return _out(value)
if isinstance(value, dict):
return {k: _json(v) for k, v in value.items()}
return value