"""M02 일위대가 실무자료 읽기(PLAN 324-1) — 설계내역서 엑셀 「일위대가」 시트 → 호표 · 줄 · 갈래 · 검산. 읽기 = .xls `xlrd`(경고가 stdout 으로 안 나가게 logfile) · .xlsx `openpyxl`(빈 칸 None) · 시트 · 열은 머리 글(빈칸 뺌)로 찾음 — 호표 · 품명 · 규격 · 수량 · 단위 · 합계 · 노무비 · 재료비 · 경비 · 비고(돈 머리 = 단가 열 · 다음 열 = 금액). 호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 · 끝 = 다음 호표. 갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) · 자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠. 이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 → 호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면 이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음. STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` · 문서 보안(DRM) 파일 = 400 글. 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). 행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) · 근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) · `단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) · `꼴` = 「엑셀꼴」. """ 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 from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate SHEET = "일위대가" SUMMARY = "일위대가총괄표" COSTS = ("노무비", "재료비", "경비") HEADS = ("호표", "품명", "규격", "수량", "단위", "비고") NO_SHEET = "일위대가 시트 없음" _HOPYO = re.compile(r"제\s*(\d+)\s*호표") _REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$") DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림" _OLE = b"\xd0\xcf\x11\xe0" _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("빈 파일") if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE): raise ExcelError(DRM) 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) if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]): wanted = hopyo_stmate.SHEETS 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 unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names(): 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 _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None: """비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음.""" index: dict[tuple[str, str], list[int]] = {} for no, block in blocks.items(): index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no) for line in (one for lines in raw for one in lines): if line["참조"] or line["비고"] or line["갈래"] == "%": continue if not (line["단가"].get("노무비") or line["단가"].get("경비")): continue hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or [] if len(hit) == 1: line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름" line["갈래"] = kind_of(line) # ── 읽기 ────────────────────────────────────────────────────────────── def read_book(name: str, data: bytes) -> dict[str, Any]: """엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수.""" sheets = _sheets(name, data) if hopyo_stmate.is_stmate(sheets): return hopyo_stmate.read(sheets) 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(_flat(note)) 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, "참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))} 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)) _by_name(raw, blocks) 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) ] keep = ("번", "이름", "단위", "행", "출처", "직종", "글") basis = [{k: b[k] for k in keep} 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