refactor(776-2): 엑셀 한 번 열기(격자 = 읽은 책) · 줄 갈래 한 규칙 · EST 블록 금액 · 항목 · 옛 판 HP 409 「다시 올림」

- Excel — read_book 이 연 책(xls = formatting_info · 목록 시트까지)을 Grid.read 가 이어 씀(opened) · 거창 여는 횟수 2 → 1 · 여덟 파일 올림 시간 17.8 → 7.8 초
- Excel.kind_of = 두 형식 · 붙여넣기 한 규칙(STmate 코드 머리 · EST 칸 · 끝에 노임 올림) · Stmate._kind 걷음
- Excel — EST 블록에 금액 · 항목(ReadEst) 실음 · Basis 대신 ReadEst 직접
- Grid — 읽은 책 그대로 · xlsx 열 폭은 보기 시트 머리 <cols> 만(sheetData 에서 멈춤) · 여덟 파일 격자 결과 전과 같음
- Store.read 다른 판 = 409 「다시 올림」 · Forget.finish 는 옛 판도 끝냄(old=True)
- S1 · S2 해시 그대로 · test_718_1 옛 판 열기 409 · 끝내기 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 12:09:24 +09:00
co-authored by Claude Opus 5.5
parent a363bbc63a
commit 3a1ce9ca4f
6 changed files with 106 additions and 39 deletions
+64 -7
View File
@@ -29,11 +29,12 @@ 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_ReadEst as hopyo_basis # EST 단가산출 어댑터(776-2)
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
SHEET = "일위대가"
SUMMARY = "일위대가총괄표"
LISTS = ("단가산출총괄표", "일위대가목록표") # 엑셀 보기 목록 시트(779-1 격자) — 같이 읽음
COSTS = ("노무비", "재료비", "경비")
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
NO_SHEET = "일위대가 시트 없음"
@@ -42,6 +43,16 @@ _REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가
DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림"
_OLE = b"\xd0\xcf\x11\xe0"
_MACHINE_UNITS = ("hr", "시간", "h")
_CODE_KIND = {
"D": "단가산출",
"B": "호표",
"L": "노임",
"X": "기계",
"S": "별도계상",
"W": "별도계상",
}
# ponytail: 마지막 연 책 하나(올림 길 read_book → Grid.read 가 같은 바이트) — 여럿 동시면 다시 엶
_OPENED: tuple[bytes, dict[str, list[list[Any]]], Any] | None = None
class ExcelError(ValueError):
@@ -82,6 +93,22 @@ def _out(value: Decimal | None) -> int | float | None:
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, list[list[Any]]]:
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음(`wanted` = 더 읽을 시트)."""
return _open(name, data, wanted)[0]
def opened(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> tuple[dict[str, Any], Any]:
"""(시트 줄, xls 책 | None) — 방금 `read_book` 이 연 같은 바이트면 그 책(한 번 열기 · 776-2) ·
아니면 엶. xls 책 = xlrd `formatting_info`(격자 서식)."""
global _OPENED
got, _OPENED = _OPENED, None
if got is not None and got[0] is data: # read_book 은 목록 시트(LISTS)까지 읽어 둠
return got[1], got[2]
return _open(name, data, wanted)
def _open(
name: str, data: bytes, wanted: tuple[str, ...] = ()
) -> tuple[dict[str, list[list[Any]]], Any]:
keep = (SHEET, SUMMARY, hopyo_basis.SHEET, *wanted)
if not data:
raise ExcelError("빈 파일")
@@ -100,15 +127,21 @@ def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, l
if ws.title.strip() in keep
}
book.close()
return out
return out, None
import xlrd
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
return {
try:
book = xlrd.open_workbook(
file_contents=data, formatting_info=True, logfile=io.StringIO()
)
except Exception: # 서식 표가 깨진 파일 — 값만(격자는 그때 서식 없이 다시 엶)
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
rows = {
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
for sh in book.sheets()
if sh.name.strip() in keep
}
return rows, book
except ExcelError:
raise
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
@@ -143,7 +176,29 @@ def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decim
# ── 갈래 · 검산 ─────────────────────────────────────────────────────────
def kind_of(line: dict[str, Any]) -> str:
"""구성 줄 갈래 — 단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타."""
"""구성 줄 갈래 한 규칙(776-2 · EST · STmate · 붙여넣기) — 단가산출 · 자재 · 기계 · 노임 ·
호표 · % · 별도계상 · 기타. STmate 코드가 있으면 코드 머리 글자 · 없으면 칸(비고 참조 ·
단위 · 단가) · 끝에 노임 올림(단위 「인」 + 우리 직종 · 괄호 글 풂)."""
kind = _code_kind(line) if line.get("코드") else _cell_kind(line)
if kind in ("기타", "자재", "노임") and _flat(line.get("단위")) == "인":
return "노임" if hopyo_basis.trade(line.get("이름")) else kind
return kind
def _code_kind(line: dict[str, Any]) -> str:
code, price = str(line["코드"]), line["단가"]
if code.startswith("PR") or _flat(line.get("단위")) == "%":
return "%"
if code[0] == "M":
apart = str(line.get("비고") or "").startswith("별산")
apart = apart or "별도계상" in f"{line.get('규격')}{line.get('이름')}"
return "별도계상" if apart or not any(price.values()) else "자재"
if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"):
return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음)
return _CODE_KIND.get(code[0], "기타")
def _cell_kind(line: dict[str, Any]) -> str:
ref = line.get("참조") or {}
unit = _flat(line.get("단위")).lower()
price = line["단가"]
@@ -241,7 +296,9 @@ def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]])
# ── 읽기 ──────────────────────────────────────────────────────────────
def read_book(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
sheets = _sheets(name, data)
global _OPENED
sheets, xls = _open(name, data, LISTS)
_OPENED = (data, sheets, xls) # 격자(`Grid.read`)가 이어 씀 — 한 번 열기
if hopyo_stmate.is_stmate(sheets):
return hopyo_stmate.read(sheets)
rows = sheets.get(SHEET)
@@ -306,7 +363,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
{**_json(hopyo), "줄": [_json(line) for line in lines]}
for hopyo, lines in zip(hopyos, raw, strict=True)
]
keep = ("번", "이름", "단위", "행", "출처", "직종", "글")
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "금액", "항목")
basis = [{k: b[k] for k in keep} for b in blocks.values()]
return {
"꼴": "엑셀꼴",