From 1c1d756a1e2f051a74bb0dad5ff5f44ff47ca4a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 08:46:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(779-1):=20=EA=B2=A9=EC=9E=90=20=EC=84=B8?= =?UTF-8?q?=20=EB=B3=B4=EA=B8=B0=20=E2=80=94=20=EB=AA=A9=EB=A1=9D=ED=91=9C?= =?UTF-8?q?=20=EB=91=98=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=A4=84=20?= =?UTF-8?q?=ED=96=89=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grid SHEETS 에 단가산출총괄표 · 단가산출근거목록표 추가(탭 차례 일위대가표 · 단가산출표 · 상세) - 응답에 보기 {역할: 시트 id} · 원본 · 목록 {호표, 단산: [{번, 행}]} · 줄 [{번, 차례, 행, 단산?, 일위?}] 더함(옛 칸 그대로) - STmate 목록은 숨은 COD 코드로 짝(차례 번 ≠ 블록 번) - Excel._sheets 에 wanted 인자만(기본 그대로) - 시험: test_779_grid_views 새로(여덟 파일) · test_m02_hopyo_grid 시트 목록 기대값 세 장으로 고침(탭이 늘어서) Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Hopyo_Excel.py | 12 ++-- M02_MasterTemplete/M02_Hopyo_Grid.py | 93 +++++++++++++++++++++++-- resources/tester/test_779_grid_views.py | 74 ++++++++++++++++++++ resources/tester/test_m02_hopyo_grid.py | 8 ++- 4 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_779_grid_views.py diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index f1c114f8c..a9c490808 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -80,9 +80,9 @@ def _out(value: Decimal | None) -> int | float | None: # ── 파일 → 시트 표(줄마다 값 목록) ──────────────────────────────────────── -def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: - """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음.""" - wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) +def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, list[list[Any]]]: + """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음(`wanted` = 더 읽을 시트).""" + keep = (SHEET, SUMMARY, hopyo_basis.SHEET, *wanted) if not data: raise ExcelError("빈 파일") if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE): @@ -93,11 +93,11 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: 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 + keep = (*hopyo_stmate.SHEETS, *wanted) 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 + if ws.title.strip() in keep } book.close() return out @@ -107,7 +107,7 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: 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 + if sh.name.strip() in keep } except ExcelError: raise diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index 4c63b1d4a..0820a4326 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -8,12 +8,18 @@ xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). xlsx = 값 · 열 폭(``) · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. -결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. +결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}], 보기, 원본, 목록, 줄}` — +행 = 엑셀 1부터 [시작, 끝]. +세 보기(779-1) = 탭 차례 일위대가표 · 단가산출표(목록표 — 엑셀꼴 단가산출총괄표 · STmate +단가산출근거목록표) · 단가산출상세 · `보기 {역할: 시트 id}` · `원본 {역할: 원본 시트 이름}` · +`목록 {호표: [{번, 행}], 단산: [{번, 행}]}`(목록 시트의 행 1부터) · +`줄 [{번, 차례, 행, 단산?: {번, 행: [시작, 끝]}, 일위?: {번, 행: [시작, 끝]}}]`. """ from __future__ import annotations import io +import re import zipfile from typing import Any @@ -24,7 +30,23 @@ 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_Stmate as hopyo_stmate -SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC) +LIST_E, LIST_S, HOPYO_S = "단가산출총괄표", hopyo_stmate.LISTS[0], "일위대가목록표" +SHEETS = ( + hopyo_excel.SHEET, + LIST_E, + hopyo_basis.SHEET, + hopyo_stmate.SHEET, + LIST_S, + hopyo_stmate.CALC, +) +ROLES = { + "일위대가표": (hopyo_excel.SHEET, hopyo_stmate.SHEET), + "단가산출표": (LIST_E, LIST_S), + "단가산출상세": (hopyo_basis.SHEET, hopyo_stmate.CALC), +} +_B, _D, _NO = re.compile(r"(B\d{5}|제\d+호표)"), re.compile(r"(D\d{5})"), re.compile(r"(\d+)") +_HOPYO = hopyo_excel._HOPYO +EXTRA = (LIST_E, hopyo_excel.SUMMARY, HOPYO_S) # 격자 몫으로 더 읽는 시트 HEAD_ROWS = 4 _HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} _VALIGN = {0: "top", 1: "center"} @@ -213,9 +235,11 @@ def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]: return out -def _xlsx(name: str, data: bytes) -> dict[str, Any]: +def _xlsx( + name: str, data: bytes, found: dict[str, list[list[Any]]] | None = None +) -> dict[str, Any]: # ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 - found = hopyo_excel._sheets(name, data) + found = found or hopyo_excel._sheets(name, data, EXTRA) widths = _xlsx_widths(data) sheets = [] for at, title in enumerate((n for n in SHEETS if n in found), 1): @@ -244,14 +268,69 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]: return {"서식": [{}], "시트": sheets} +def _pairs( + rows: list[list[Any]] | None, col: int, rx: re.Pattern[str], hidden: bool = False +) -> list[tuple[str, int]]: + """목록 시트 → [(열쇠, 행)] — `col` 칸 글이 `rx` 에 꼭 맞는 줄(엑셀 1부터) · `hidden` = + STmate 숨은 칸 「COD|…」 코드 먼저(목록 차례 번 ≠ 진짜 번).""" + out = [] + for at, row in enumerate(rows or [], 1): + meta = hopyo_stmate._META.search(" ".join(map(str, row))) if hidden else None + cell = hopyo_excel._flat(hopyo_excel._text(row[col] if col < len(row) else None)) + if meta: + out.append((meta.group(1), at)) + elif hit := rx.fullmatch(cell): + out.append((hit.group(1), at)) + return out + + +def _lines(parsed: dict[str, Any]) -> list[dict[str, Any]]: + """일위대가 줄 → 엑셀 행 · 단산 줄 → 블록 행 · 일위 줄 → 하위 호표 행.""" + block = {b["번"]: b["행"] for b in parsed.get("단산") or []} + hopyo = {h["번"]: h["행"] for h in parsed.get("호표") or []} + out = [] + for h in parsed.get("호표") or []: + for line in h.get("줄") or []: + one: dict[str, Any] = {"번": h["번"], "차례": line["차례"], "행": line.get("행")} + ref = line.get("참조") or {} + for key, rows in (("단산", block), ("일위", hopyo)): + if ref.get("종류") == key and ref.get("번") in rows: + one[key] = {"번": ref["번"], "행": rows[ref["번"]]} + out.append(one) + return out + + +def _lists(rows: dict[str, Any], parsed: dict[str, Any]) -> dict[str, list[dict[str, int]]]: + """목록 시트 행 ↔ 호표 번 · 단산(블록) 번.""" + if parsed.get("꼴") == "STmate": # 숨은 코드(없으면 A 열) — 호표 = 코드 → 번 · 단산 = 코드 숫자 + code = {h["코드"]: h["번"] for h in parsed.get("호표") or [] if h.get("코드")} + hopyo = [ + (int(k[1:-2]) if k[0] == "제" else code.get(k), at) + for k, at in _pairs(rows.get(HOPYO_S), 0, _B, True) + ] + calc = [(int(k[1:]), at) for k, at in _pairs(rows.get(LIST_S), 0, _D, True)] + else: # B 열 「제N호표」 · 「No」 + hopyo = [(int(k), at) for k, at in _pairs(rows.get(hopyo_excel.SUMMARY), 1, _HOPYO)] + calc = [(int(k), at) for k, at in _pairs(rows.get(LIST_E), 1, _NO)] + pair = lambda got: [{"번": k, "행": at} for k, at in got if k is not None] # noqa: E731 + return {"호표": pair(hopyo), "단산": pair(calc)} + + def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]: - """엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과 - (행 범위).""" + """엑셀 → `{책: Workbook, 호표, 단산, 보기, 원본, 목록, 줄}` — `parsed` = + `M02_Hopyo_Excel.read_book` 결과(행 범위).""" xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx") - book = _xlsx(name, data) if xlsx else _xls(data) + rows = hopyo_excel._sheets(name, data, EXTRA) # ponytail: xls 는 서식 없이 한 번 더 읽음 + book = _xlsx(name, data, rows) if xlsx else _xls(data) 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()} return { "책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"}, "호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []], "단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []], + "보기": {role: ids[n] for role, n in on.items() if n}, + "원본": {role: n for role, n in on.items() if n}, + "목록": _lists(rows, parsed), + "줄": _lines(parsed), } diff --git a/resources/tester/test_779_grid_views.py b/resources/tester/test_779_grid_views.py new file mode 100644 index 000000000..42a7bed80 --- /dev/null +++ b/resources/tester/test_779_grid_views.py @@ -0,0 +1,74 @@ +"""779-1 격자 세 보기 — 여덟 실무 파일: 보기 셋(두 꼴 같은 역할) · 목록 짝 100 % · 줄 행 대응. + +`M02_Hopyo_Grid.read` 응답 `보기 · 원본 · 목록 · 줄` — 서버 몫만(화면 없음). +""" + +import warnings + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Grid as hg +from resources.tester.helper_m02_hopyo_golden import FILES + +warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl") +ROLES = {"일위대가표", "단가산출표", "단가산출상세"} + + +@pytest.fixture(scope="module", params=sorted(FILES)) +def one(request): + path = FILES[request.param] + data = path.read_bytes() + parsed = hx.read_book(path.name, data) + return parsed, hg.read(path.name, data, parsed) + + +def test_보기_셋(one): + parsed, grid = one + assert set(grid["보기"]) == set(grid["원본"]) == ROLES + names = {s["id"]: s["이름"] for s in grid["책"]["시트"]} + assert [names[grid["보기"][r]] for r in ("일위대가표", "단가산출표", "단가산출상세")] == [ + grid["원본"][r] for r in ("일위대가표", "단가산출표", "단가산출상세") + ] + assert list(names) == [grid["보기"][r] for r in ("일위대가표", "단가산출표", "단가산출상세")] + assert len(grid["책"]["시트"]) == 3 + assert parsed["꼴"] in ("엑셀꼴", "STmate") + + +def test_목록_짝_100(one): + parsed, grid = one + for key in ("호표", "단산"): + got = grid["목록"][key] + assert sorted(x["번"] for x in got) == sorted(x["번"] for x in parsed[key]), key + rows = [x["행"] for x in got] + assert len(set(rows)) == len(rows) and min(rows) >= 4 + + +def test_줄_행_대응(one): + parsed, grid = one + lines = [(h["번"], x) for h in parsed["호표"] for x in h["줄"]] + assert [(x["번"], x["차례"], x["행"]) for x in grid["줄"]] == [ + (n, x["차례"], x["행"]) for n, x in lines + ] + span = {h["번"]: h["행"] for h in parsed["호표"]} + block = {b["번"]: b["행"] for b in parsed["단산"]} + for one_, (no, line) in zip(grid["줄"], lines, strict=True): + assert span[no][0] < one_["행"] <= span[no][1] + ref = line.get("참조") or {} + if ref.get("종류") == "단산" and ref["번"] in block: + assert one_["단산"] == {"번": ref["번"], "행": block[ref["번"]]} + else: + assert "단산" not in one_ + if ref.get("종류") == "일위" and ref.get("번") in span: + assert one_["일위"] == {"번": ref["번"], "행": span[ref["번"]]} + else: + assert "일위" not in one_ + assert any("단산" in x for x in grid["줄"]) + + +def test_두_꼴_같은_역할_셋(): + """역할마다 엑셀꼴 · STmate 시트 이름 하나씩 · 탭 차례 = 역할 차례(꼴별로).""" + assert {r: len(n) for r, n in hg.ROLES.items()} == dict.fromkeys(ROLES, 2) + first = [hg.SHEETS.index(n[0]) for n in hg.ROLES.values()] + second = [hg.SHEETS.index(n[1]) for n in hg.ROLES.values()] + assert first == sorted(first) and second == sorted(second) diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 39ee22da2..a21a29eb4 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -38,7 +38,7 @@ def _sheet(grid: dict, name: str) -> dict: def test_격자_모양(north): book = north["책"] assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1") - assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"] + assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출총괄표", "단가산출"] sheet = _sheet(north, "일위대가") assert len(sheet["병합"]) == 11 and "B1:O2" in sheet["병합"] assert len(sheet["열"]) == 15 and sheet["열"]["C"]["폭"] == round(7208 / 256, 2) @@ -143,7 +143,11 @@ def test_STmate_격자(): data = path.read_bytes() parsed = hx.read_book(path.name, data) grid = hg.read(path.name, data, parsed) - assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"] + assert [s["이름"] for s in grid["책"]["시트"]] == [ + "일위대가표", + "단가산출근거목록표", + "단가산출근거", + ] cells = _sheet(grid, "일위대가표")["칸"] one = grid["호표"][0] assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]}