From 3a1ce9ca4fe7e4593f04dae239f89374aedd2ec6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 12:09:24 +0900 Subject: [PATCH] =?UTF-8?q?refactor(776-2):=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=EC=97=B4=EA=B8=B0(=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=20=3D=20=EC=9D=BD=EC=9D=80=20=EC=B1=85)=20=C2=B7=20=EC=A4=84?= =?UTF-8?q?=20=EA=B0=88=EB=9E=98=20=ED=95=9C=20=EA=B7=9C=EC=B9=99=20=C2=B7?= =?UTF-8?q?=20EST=20=EB=B8=94=EB=A1=9D=20=EA=B8=88=EC=95=A1=20=C2=B7=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=C2=B7=20=EC=98=9B=20=ED=8C=90=20HP=20409?= =?UTF-8?q?=20=E3=80=8C=EB=8B=A4=EC=8B=9C=20=EC=98=AC=EB=A6=BC=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 열 폭은 보기 시트 머리 만(sheetData 에서 멈춤) · 여덟 파일 격자 결과 전과 같음 - Store.read 다른 판 = 409 「다시 올림」 · Forget.finish 는 옛 판도 끝냄(old=True) - S1 · S2 해시 그대로 · test_718_1 옛 판 열기 409 · 끝내기 시험 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Excel.py | 71 +++++++++++++++++++-- M02_MasterTemplete/M02_Hopyo_Forget.py | 2 +- M02_MasterTemplete/M02_Hopyo_Grid.py | 33 ++++++---- M02_MasterTemplete/M02_Hopyo_Stmate.py | 15 +---- M02_MasterTemplete/M02_Hopyo_Store.py | 8 ++- resources/tester/test_718_1_hopyo_import.py | 16 ++++- 6 files changed, 106 insertions(+), 39 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index a9c490808..aeedea10b 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -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 { "꼴": "엑셀꼴", diff --git a/M02_MasterTemplete/M02_Hopyo_Forget.py b/M02_MasterTemplete/M02_Hopyo_Forget.py index 52626f5e6..75bcfedcd 100644 --- a/M02_MasterTemplete/M02_Hopyo_Forget.py +++ b/M02_MasterTemplete/M02_Hopyo_Forget.py @@ -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 지문) diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index 0820a4326..e64f19caf 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -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 `` 를 읽음(read_only 는 폭을 안 줌).""" +def _xlsx_widths( + data: bytes, names: tuple[str, ...] | None = None +) -> dict[str, dict[str, dict[str, Any]]]: + """시트 이름 → 열 글자 → `{폭, 숨김}` — 시트 XML 머리 `` 만 읽음(`` 에서 멈춤 · + 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()} diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py index 8773333ae..f8f6efafe 100644 --- a/M02_MasterTemplete/M02_Hopyo_Stmate.py +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -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" diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index 6e3b45f85..981e9525c 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -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 diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index bf8af6ca4..82cd3701a 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -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()