Files
Aislo/M02_MasterTemplete/M02_Hopyo_Excel.py
T
eomsangdonandClaude Opus 5.5 193e03927b feat(M02 일위대가): 338-2b STmate 꼴 읽기 · 거창 흩어진 칸 이음 · 이음 넓힘 · 노임 갈래 · 문서 보안 400 · STmate 격자
- 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글)
- 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0
- Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21
- Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안)
- Grid STmate 시트 · 숨은 칸 글 뺌
- 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:28:57 +09:00

326 lines
14 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) · 별도계상(값 없음) ·
자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠.
이음(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