feat(일위대가실무): 324-1 실무자료 엑셀 읽기 · 저장 · 올리기 길 — 「일위대가」 시트 → 호표 · 줄 · 갈래 · 참조 · 검산

- M02_Hopyo_Excel: xls(xlrd · logfile 막음) · xlsx(openpyxl) · 머리 글로 시트 · 열 찾기 · 제N호표 · 구성 줄 · 갈래(단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타) · 단산N/일위N 참조 · 검산(줄 금액 floor · 합계 단가 · % 줄 · 호표 비목 · 총괄표) · 규격 원문 보관 · 시트 없음 = 「일위대가 시트 없음」
- M02_Hopyo_Store: 층 폴더 실무자료/HP-######.json · 맞춤 기억 실무자료/맞춤.json · 잠금 + 원자 쓰기 · 양식 종류 밖(목록 · 복사 · 프로젝트 씨앗에 안 섞임)
- M02_Labor_Router: GET/POST /layers/{층}/hopyo · GET/DELETE /layers/{층}/hopyo/{키} · 20 MB · 엉터리 · 시트 없음 400 · 권한 = 층 읽기 · 쓰기 규칙
- requirements: xlrd==2.0.2
- 시험 test_m02_hopyo_excel 15 통과(북부청 68 · 사방 27 · 검산 0 · 역할 표 · sha256)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 22:03:59 +09:00
co-authored by Claude Opus 5.5
parent ecd4a95ab5
commit 63ba44a717
5 changed files with 659 additions and 2 deletions
+269
View File
@@ -0,0 +1,269 @@
"""M02 일위대가 실무자료 읽기(PLAN 324-1) — 설계내역서 엑셀 「일위대가」 시트 →
호표 · 줄 · 갈래 · 검산.
읽기 = .xls `xlrd`(경고가 stdout 으로 안 나가게 logfile) · .xlsx `openpyxl`(빈 칸 None) ·
시트 · 열은 머리 글(빈칸 뺌)로 찾음 — 호표 · 품명 · 규격 · 수량 · 단위 · 합계 · 노무비 ·
재료비 · 경비 · 비고(돈 머리 = 단가 열 · 다음 열 = 금액).
호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 ·
끝 = 다음 호표.
갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) ·
자재(재료비만) · 기타 — 규격은 원문 그대로 둠.
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
어긋남은 글 목록(막지 않음).
"""
from __future__ import annotations
import io
import re
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
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)
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 row in rows[start:]:
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),
}
)
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)
raw[-1].append(line)
if not hopyos:
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
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)
]
return {"시트": SHEET, "호표": out, "검산": {"어긋남": 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