- 새 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
326 lines
14 KiB
Python
326 lines
14 KiB
Python
"""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
|