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 {
"꼴": "엑셀꼴",
+1 -1
View File
@@ -138,7 +138,7 @@ def _caches(folder: Path, key: str) -> list[Path]:
def finish(folder: str | Path, key: str) -> dict[str, Any]:
"""불러오기 끝냄 — 만든 문서 사본 걷음 · HP · 격자 · 맞춤셈 캐시 지움 → `{지움, 만든}`."""
folder = Path(folder)
hopyo_store.read(folder, key) # 없으면 404
hopyo_store.read(folder, key, old=True) # 없으면 404 · 옛 판도 끝냄(776-2)
head = f"{key} "
made = forget(folder, lambda mark: mark.startswith(head))
caches = _caches(folder, key) # HP 지우기 전에 자리 셈(HP 지문)
+19 -14
View File
@@ -24,13 +24,14 @@ import zipfile
from typing import Any
from openpyxl.utils import get_column_letter
from openpyxl.xml.functions import fromstring
from openpyxl.xml.functions import fromstring, iterparse
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_basis # EST 단가산출 시트 이름
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
LIST_E, LIST_S, HOPYO_S = "단가산출총괄표", hopyo_stmate.LISTS[0], "일위대가목록표"
LIST_E, HOPYO_S = hopyo_excel.LISTS
LIST_S = hopyo_stmate.LISTS[0]
SHEETS = (
hopyo_excel.SHEET,
LIST_E,
@@ -185,10 +186,8 @@ def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]:
return out
def _xls(data: bytes) -> dict[str, Any]:
import xlrd
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
def _xls(book: Any) -> dict[str, Any]:
"""xlrd 책(`formatting_info` · 읽기가 연 것) → 격자."""
styles = _Styles()
found = {sh.name.strip(): sh for sh in book.sheets()}
sheets = [
@@ -203,8 +202,11 @@ def _xls(data: bytes) -> dict[str, Any]:
}
def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]:
"""시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML `<cols>` 를 읽음(read_only 는 폭을 안 줌)."""
def _xlsx_widths(
data: bytes, names: tuple[str, ...] | None = None
) -> dict[str, dict[str, dict[str, Any]]]:
"""시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML 머리 `<cols>` 만 읽음(`<sheetData>` 에서 멈춤 ·
read_only 는 폭을 안 줌) · `names` = 이 시트만."""
out: dict[str, dict[str, dict[str, Any]]] = {}
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
@@ -216,11 +218,15 @@ def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]:
for sheet in fromstring(zf.read("xl/workbook.xml")).iter():
if tag(sheet) != "sheet":
continue
if names is not None and sheet.get("name", "").strip() not in names:
continue
rid = next((v for k, v in sheet.attrib.items() if k.endswith("}id")), None)
path = (rels.get(rid) or "").lstrip("/")
path = path if path.startswith("xl/") else f"xl/{path}"
cols: dict[str, dict[str, Any]] = {}
for col in fromstring(zf.read(path)).iter():
for _, col in iterparse(zf.open(path), events=("start",)):
if tag(col) == "sheetData":
break
if tag(col) != "col":
continue
first, last = int(col.get("min", 0)), min(int(col.get("max", 0)), 200)
@@ -240,7 +246,7 @@ def _xlsx(
) -> dict[str, Any]:
# ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
found = found or hopyo_excel._sheets(name, data, EXTRA)
widths = _xlsx_widths(data)
widths = _xlsx_widths(data, SHEETS)
sheets = []
for at, title in enumerate((n for n in SHEETS if n in found), 1):
rows = found[title]
@@ -319,9 +325,8 @@ def _lists(rows: dict[str, Any], parsed: dict[str, Any]) -> dict[str, list[dict[
def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]:
"""엑셀 → `{책: Workbook, 호표, 단산, 보기, 원본, 목록, 줄}` — `parsed` =
`M02_Hopyo_Excel.read_book` 결과(행 범위)."""
xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx")
rows = hopyo_excel._sheets(name, data, EXTRA) # ponytail: xls 는 서식 없이 한 번 더 읽음
book = _xlsx(name, data, rows) if xlsx else _xls(data)
rows, xls = hopyo_excel.opened(name, data, EXTRA) # 읽기가 연 책 그대로(한 번 열기 · 776-2)
book = _xlsx(name, data, rows) if xls is None else _xls(xls)
keep = ("번", "이름", "행")
ids = {s["이름"]: s["id"] for s in book["시트"]}
on = {role: next((n for n in names if n in ids), None) for role, names in ROLES.items()}
+1 -14
View File
@@ -45,7 +45,6 @@ _COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
_CALC_AMOUNT = {"합계": 2, "노무비": 3, "재료비": 4, "경비": 5} # 단가산출근거 C~F 금액
_CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~L 단가(T · L · M · S)
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
def is_stmate(names: Any) -> bool:
@@ -199,18 +198,6 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
# ── 일위대가표 ────────────────────────────────────────────────────────
def _kind(code: str, line: dict[str, Any]) -> str:
if code.startswith("PR") or _flat(line["단위"]) == "%":
return "%"
price = line["단가"]
if code[0] == "M":
apart = line["비고"].startswith("별산") or "별도계상" in f"{line['규격']}{line['이름']}"
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 _KIND.get(code[0], "기타")
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
@@ -258,7 +245,7 @@ def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict
"코드": code,
"행": at,
}
line["갈래"] = _kind(code, line)
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
line["참조"] = (
{"종류": "단산", "번": int(code[1:]), "코드": code}
if code[0] == "D"
+6 -2
View File
@@ -8,7 +8,7 @@
(작업 중)만 · 옛 HP · 하루 지난 HP 는 목록에서 숨김(지움 = `M02_Hopyo_Forget`) ·
`실무자료/불러옴.json` `{번}` = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀 · 옛 `지문` 안 읽음).
판(776-2) = 읽기 꼴(Book) 판 `BOOK` 을 HP 에 찍음 · 다른 판 = 지난 것(목록에서 숨김 ·
다음 올림 때 끝냄 · 옛 꼴 읽기 갈래 없음 — 다시 올림).
다음 올림 때 끝냄 · 작업 중 열기 = 409 「다시 올림」 · 옛 꼴 읽기 갈래 없음).
"""
from __future__ import annotations
@@ -30,6 +30,7 @@ MEMORY = "맞춤"
GRID = "격자"
DONE = "불러옴"
NO_GRID = "격자 없음 — 다시 올리면 보임"
RELOAD = "읽기 꼴이 바뀐 옛 불러오기 — 엑셀을 다시 올림"
EXPIRE = timedelta(days=1) # 작업 중 HP 보관 — 올린 뒤 하루
BOOK = 2 # 읽기 꼴(Book) 판 — 읽기 · 정규화 출력 꼴을 바꾸면 올림
_KEY = re.compile(r"^HP-(\d{6})$")
@@ -106,10 +107,13 @@ def _put_done(folder: str | Path, got: dict[str, Any]) -> None:
atomic_write_json(_dir(folder) / f"{DONE}.json", got)
def read(folder: str | Path, key: str) -> dict[str, Any]:
def read(folder: str | Path, key: str, old: bool = False) -> dict[str, Any]:
"""HP — 다른 판 = 409 「다시 올림」(`old` = 끝내기 · 정리만 옛 판도 읽음)."""
doc = _load(_path(folder, key))
if not isinstance(doc, dict):
raise store.StoreError(404, f"없는 실무자료 「{key}」")
if not old and doc.get("판") != BOOK:
raise store.StoreError(409, RELOAD)
return doc
+15 -1
View File
@@ -98,7 +98,21 @@ def test_결과_단가산출_산근_블록_기초_SK_고름대기_겹침_없음(
def test_판_다른_HP_지난_것():
"""776-2 — 읽기 꼴 판이 다르면(옛 판 · 판 없음) 막 올렸어도 지난 것 = 목록 숨김 · 다음 올림 때 끝냄."""
"""776-2 — 읽기 꼴 판이 다르면(옛 판 · 판 없음) 막 올렸어도 지난 것(숨김 · 올림 때 끝냄)."""
now = {"올린때": "2999-01-01T00:00:00Z"}
assert hs.stale({**now, "판": hs.BOOK}, now=None) is False
assert hs.stale(now) and hs.stale({**now, "판": hs.BOOK - 1})
def test_판_다른_HP_열기_409_끝내기는_됨(client):
"""776-2 — 작업 중 옛 판 HP 를 열면 409 「다시 올림」 · [끝내기]는 옛 판도 지움."""
folder = layers.personal_dir(7, 42)
assert _post(client).json()["키"] == "HP-000001"
path = folder / hs.DIRNAME / "HP-000001.json"
doc = json.loads(path.read_bytes())
path.write_text(json.dumps({**doc, "판": hs.BOOK - 1}, ensure_ascii=False), encoding="utf-8")
res = client.get(URL.format("personal") + "/HP-000001")
assert res.status_code == 409 and res.json()["detail"] == hs.RELOAD
assert _keys(client) == [] # 목록 숨김
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert not path.exists()