From 8cd7baea5eb8b3976b51a4e95f36bd39627ca3c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 9 Nov 2026 07:34:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(812-1):=20=EC=97=91=EC=85=80=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20=EC=83=88=EB=A1=9C=20=E2=80=94=20xlsx=20?= =?UTF-8?q?=C2=B7=20xls=20=E2=86=92=20810-1=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A4=84=20=EA=BC=B4=20=E2=86=92=20=EC=9B=90=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EA=B8=B8=20=ED=95=9C=20=EB=B2=8C=20=C2=B7=20=EA=B3=A8?= =?UTF-8?q?=EB=93=A0=20=EC=8B=9C=ED=97=98=20=C2=B7=20=EC=98=AC=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B8=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_San_Xlsx(공통 · 글자 칸 배치 · 금액 칸 · 자원 표) · M02_San_Xlsx_Est(EST 글 → 표지: 변수 = 가장 늦은 대입 값 하나 · 칸 나눔 꼴은 닫는 표지 2 칸 · 합계 줄 · 세 줄 머리 · 노임 · 자재 단가 짝 · 소계 61 · 율 58 · 62 · 구간 글자) · M02_San_Xlsx_Stmate(숨은 G 열 원문 = stc_line · 끝전 파일 투표) - M02_Doc_FromNative: 엑셀 둘 꼴(번 · 시트 행 · 원본 엑셀 참 · 원파일 거짓) · M02_Doc_Import: 엑셀 길도 소계 비목 칸 비움 · 추정 칸(출처 = 엑셀 꼬리표) - M02_Sangeun_FromHopyo: 산근 블록 = 새 읽기기(옛 San_Est · Emit.est 부름 걷음 · 옛 저장은 한 번 지어 둠) - test_812_1: 실제 짝(거창 · STC 다섯) 같음 · 합성 배치 = 거창 실제 · 합성 EST 1,849 · STC 371 · 금액 실무 11(엇갈림 고정 목록) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_FromNative.py | 39 +- M02_MasterTemplete/M02_Doc_Import.py | 18 +- M02_MasterTemplete/M02_San_Xlsx.py | 174 ++++++ M02_MasterTemplete/M02_San_Xlsx_Est.py | 621 ++++++++++++++++++++ M02_MasterTemplete/M02_San_Xlsx_Stmate.py | 70 +++ M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 106 ++-- resources/tester/test_812_1_xlsx_golden.py | 426 ++++++++++++++ 7 files changed, 1369 insertions(+), 85 deletions(-) create mode 100644 M02_MasterTemplete/M02_San_Xlsx.py create mode 100644 M02_MasterTemplete/M02_San_Xlsx_Est.py create mode 100644 M02_MasterTemplete/M02_San_Xlsx_Stmate.py create mode 100644 resources/tester/test_812_1_xlsx_golden.py diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index 7f03e83f6..9f9c886e4 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -50,19 +50,27 @@ def read(name: str, data: bytes) -> dict[str, Any]: def pack(got: dict, name: str) -> dict[str, Any]: """파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON).""" out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []} - head = ("코드", "이름", "규격", "단위", "저장") + out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴 + head = ("코드", "번", "이름", "규격", "단위", "저장") + if got.get("엑셀"): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리) + got = {**got, "산근": [_rows(b) for b in got["산근"]]} if got["꼴"] == "EST": out["산근"] = [ - {**{k: b.get(k) for k in head}, "줄": [_est_line(ln) for ln in b["줄"]]} + {**{k: b.get(k) for k in head if k in b}, "줄": [_est_line(ln) for ln in b["줄"]]} for b in got["산근"] ] - out["일위"] = [{**{k: b.get(k) for k in head}, "줄": b["줄"]} for b in got["일위"]] + out["일위"] = [ + {**{k: b.get(k) for k in head if k in b}, "줄": b["줄"]} for b in got["일위"] + ] return out out["산근"] = [ - {**{k: b.get(k) for k in head}, "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]]} + { + **{k: b.get(k) for k in head if k in b}, + "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]], + } for b in got["산근"] ] - out["일위"] = [{k: b.get(k) for k in head} for b in got["일위"]] + out["일위"] = [{k: b.get(k) for k in head if k in b} for b in got["일위"]] res = got.get("자원") or {} out["단가"] = { code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}} @@ -72,6 +80,10 @@ def pack(got: dict, name: str) -> dict[str, Any]: return out +def _rows(b: dict) -> dict: + return {**b, "줄": [{**ln, "행": ln.get("엑셀행") or ln["행"]} for ln in b["줄"]]} + + def _est_line(ln: dict) -> dict: one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")} if one.get("자원"): @@ -81,9 +93,9 @@ def _est_line(ln: dict) -> dict: # ── 블록 ────────────────────────────────────────────────────────────── def numbers(saved: dict) -> dict[int, dict]: - """블록 번 → 산근 블록(엑셀 길 번과 같음).""" + """블록 번 → 산근 블록(엑셀 길 번과 같음 · 엑셀 읽기기 EST = 시트 「N.」 번).""" if saved["꼴"] == "EST": - return {i: b for i, b in enumerate(saved["산근"], 1)} + return {int(b.get("번") or i): b for i, b in enumerate(saved["산근"], 1)} return {int(str(b["코드"])[1:]): b for b in saved["산근"] if str(b["코드"])[1:].isdigit()} @@ -91,10 +103,10 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]: """원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError.""" blk = numbers(saved)[int(no)] if saved["꼴"] == "EST": - names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)} + names = {b["코드"]: n for n, b in numbers(saved).items()} out = ne.block(blk, int(no), keys, saved["파일"], names) out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {}) - return out + return _excel(saved, out) from M02_MasterTemplete import M02_San_Stmate as stm nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]] @@ -106,6 +118,15 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]: marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지")) sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"]) out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}} + return _excel(saved, out) + + +def _excel(saved: dict, out: dict) -> dict[str, Any]: + """엑셀 읽기기 꼴(812-1) — 원본 `원파일` 거짓 · `엑셀` 참(화면 꼬리표 「엑셀」 · 원코드 = 단산 번).""" + if saved.get("엑셀"): + out["원본"] |= {"원파일": False, "엑셀": True} + out["원본"] |= {"시트": "단가산출"} if saved["꼴"] == "EST" else {} + out["원본"].pop("원코드", None) return out diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 23e6f5ec9..8afe71c33 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -304,11 +304,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: doc, notes = dc.cv.convert(_Row(master, key, row), row, given) _attach(doc, row, ids, src) _numbers(doc, keep) - if (meta.get("원본") or {}).get("원파일"): # 원파일(810-2) — 소계 비목 칸 비움 · 값 변수 출처 - _native(doc) + native = bool((meta.get("원본") or {}).get("원파일")) + if native or (meta.get("원본") or {}).get( + "엑셀" + ): # 810-2 · 812-1 — 소계 비목 칸 비움 · 값 변수 출처 + _native(doc, native) head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)} marks = list((meta.get("원본") or {}).get("표지") or []) - if (meta.get("원본") or {}).get("원파일"): # 810-3 — 품셈 조건 글(값 없음) = 화면 「추정」 칸 + if native or (meta.get("원본") or {}).get( + "엑셀" + ): # 810-3 — 품셈 조건 글(값 없음) = 「추정」 칸 head["추정"] = [ {"행": x.get("행"), "글": " ".join(str(x.get("글") or "").split())} for x in meta["원본"].get("글줄") or [] @@ -322,13 +327,14 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))} -def _native(doc: dict) -> None: - """원파일 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 「원파일」.""" +def _native(doc: dict, native: bool = True) -> None: + """원파일 · 엑셀 읽기기 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 + 「원파일」(엑셀 = 출처 없음 → 화면 꼬리표 「엑셀」).""" for line in doc.get("금액") or []: if line.get("출처") == SUB and line.get("합밖"): line.pop("비목", None) for var in doc.get("변수") or []: - if (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]: + if native and (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]: var["출처"] = "원파일" diff --git a/M02_MasterTemplete/M02_San_Xlsx.py b/M02_MasterTemplete/M02_San_Xlsx.py new file mode 100644 index 000000000..92f0e0479 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Xlsx.py @@ -0,0 +1,174 @@ +"""경쟁사 산근 엑셀(.xlsx · .xls) 읽기기 — 810-1 원파일 파서와 같은 「구조 줄」 꼴(PLAN 812-1 · 요구 711-2 · +712 · 713 · 읽기만 · 서버 단독). + +`read(name, data)` = `M02_San_Estx.read` · `M02_San_Stc.read` 와 같은 칸 이름 · 같은 뜻 +(칸 뜻 `resources/master_data/_원파일파서_틀.md`) → 그 뒤는 원파일 길(`M02_Doc_FromNative.pack` → +`emit` → `M02_Doc_NativeEst` · `M02_San_Emit.stmate` → 수식 문서) 그대로. + EST = `M02_San_Xlsx_Est`(단가산출 시트 글 · 금액 칸 · 같은 책 단가표) · + STmate = `M02_San_Xlsx_Stmate`(숨은 G 열 원문 = 원파일 원문 · 단가 = 목록표 · 끝전 = 파일 투표). +엑셀이 못 주는 칸(자원 코드 · LinkIndex · 자리 Diget)은 비움 + 표지 「엑셀 — …」 · 글에서 읽어 정한 것은 +`연결뜻` 에 적고 줄 `추정` 참 + 블록 표지 「엑셀 추정」. +여기 = 공통(시트 → 글 줄 · 금액 칸 · 자원 표). +""" + +from __future__ import annotations + +import re +import unicodedata +from typing import Any + +from M02_MasterTemplete import M02_San_Book as book + +GUESS = "엑셀 추정" +NOTE = "엑셀 — " + + +class XlsxError(ValueError): + """산근 시트가 없는 엑셀.""" + + +def flat(v: Any) -> str: + return re.sub(r"\s+", "", str(v or "")) + + +def number(v: Any) -> float | None: + """칸 값 → 수(「3,442」 · 「8.」 · 수 칸) · 아니면 None.""" + if isinstance(v, bool) or v is None: + return None + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip().replace(",", "") + if not re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s): + return None + return float(s.rstrip(".") or 0) + + +def _cell(v: Any) -> str: + if isinstance(v, float) and v == int(v): + return str(int(v)) + return "" if v is None else str(v) + + +def _width(ch: str) -> int: + return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1 + + +def layout(cells: list[tuple[int, str]], base: int) -> str: + """글자 칸 나눔 줄(거창 꼴 · 열 = 화면 자리) → 한 줄 글(열 차례 · 모자란 자리 = 빈칸 · 겹친 자리(수 끝 점) + = 빈칸 하나).""" + out, end = "", 0 + for col, text in cells: + want = col - base + if want > end: + out += " " * (want - end) + elif out and want < end and not out.endswith(" "): + out += " " + out += text + end = want + sum(_width(c) for c in text) + return out + + +# ── 시트 머리 ────────────────────────────────────────────────────────── +MONEY = ("합계", "노무비", "재료비", "경비") + + +def money_cols(rows: list[list[Any]]) -> list[int] | None: + """머리 줄의 맨 오른쪽 「합계 · 노무비 · 재료비 · 경비」 네 칸 열 → [T L M S].""" + for r in rows[:6]: + names = [flat(v) for v in r] + hits = [j for j in range(len(names) - 3) if tuple(names[j : j + 4]) == MONEY] + if hits: + return list(range(hits[-1], hits[-1] + 4)) + return None + + +def money_of(row: list[Any], cols: list[int]) -> dict[str, float] | None: + got = {c: number(row[j]) if j < len(row) else None for c, j in zip("TLMS", cols)} + got = {c: v for c, v in got.items() if v is not None} + return got or None + + +def head_col(rows: list[list[Any]], *words: str) -> int | None: + for r in rows[:6]: + for j, v in enumerate(r): + if flat(v) in words: + return j + return None + + +# ── 자원 표(EST) ─────────────────────────────────────────────────────── +def _table(rows: list[list[Any]], price: tuple[str, ...]) -> list[dict[str, Any]]: + """단가 시트 → [{번, 이름, 규격, 단위, 줄 (원 줄)}](품명 · 규격 · 단위 · 값 열 = 머리 글).""" + c_no, c_name = head_col(rows, "No"), head_col(rows, "품명") + c_size, c_unit = head_col(rows, "규격"), head_col(rows, "단위") + if c_name is None: + return [] + out = [] + for r in rows: + no = str(r[c_no]).strip() if c_no is not None and c_no < len(r) else "" + if not no.isdigit() or c_name >= len(r) or not str(r[c_name] or "").strip(): + continue + out.append( + { + "번": int(no), + "이름": str(r[c_name]).strip(), + "규격": str(r[c_size] or "").strip() if c_size is not None else "", + "단위": str(r[c_unit] or "").strip() if c_unit is not None else "", + "줄": r, + } + ) + return out + + +def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]: + """같은 책 단가표 → {종류: [{번 · 이름 · 규격 · 단위 · 표 · 값 {T L M S}}]}(노임 · 자재 = 적용단가 · + 기계 · 견적 · 산근 = 적용 칸(합이 안 맞으면 당초 · `M02_San_Book.applied`)).""" + out: dict[str, list[dict[str, Any]]] = {} + for kind, sheet, comp in (("노임", "노임단가", "L"), ("자재", "자재단가", "M")): + rows = sheets.get(sheet) or [] + c = head_col(rows, "적용단가") + got = [] + for one in _table(rows, ("적용단가",)): + v = number(one["줄"][c]) if c is not None and c < len(one["줄"]) else None + if v is not None: + got.append({**_drop(one), "표": sheet, "값": {"T": v, comp: v}}) + out[kind] = got + for kind, sheet in ( + ("기계", "기계경비총괄표"), + ("견적", "견적단가"), + ("산근", "단가산출총괄표"), + ): + got = [] + for one in _table(sheets.get(sheet) or [], ()): + r = list(one["줄"]) + [None] * 30 + vals = {k: float(v or 0) for k, v in book.applied(r).items()} + got.append({**_drop(one), "표": sheet, "값": vals}) + out[kind] = got + return out + + +def _drop(one: dict) -> dict: + return {k: v for k, v in one.items() if k != "줄"} + + +def kind_of(sheets: dict[str, list]) -> str | None: + return book.kind_of(sheets) + + +def read(name: str, data: bytes) -> dict[str, Any]: + """엑셀 → {꼴, 산근, 일위, 자원, 내역, 표지(, 단가 · 옵션 STmate)} — 산근 시트 없음 = XlsxError.""" + sheets = book.sheets(name, data) + return read_sheets(sheets) + + +def read_sheets(sheets: dict[str, list]) -> dict[str, Any]: + kind = kind_of(sheets) + if kind == "EST": + from M02_MasterTemplete import M02_San_Xlsx_Est as xe + + return xe.read(sheets) | {"엑셀": True} + if kind == "STmate": + from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs + + return xs.read(sheets) | {"엑셀": True} + raise XlsxError("STmate · EST 산근 시트 없음") diff --git a/M02_MasterTemplete/M02_San_Xlsx_Est.py b/M02_MasterTemplete/M02_San_Xlsx_Est.py new file mode 100644 index 000000000..bce733b55 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Xlsx_Est.py @@ -0,0 +1,621 @@ +"""EST 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Estx.cost_line` 과 같은 칸 · 읽기만 · 서버 단독). + +엑셀 「단가산출」 시트 = 원파일 LineData 를 보인 글(제어문자 표지 없음) + 줄 금액 칸(원파일 줄 TC NC GC JC · +0 = 빈칸 · 소계 61 = 비목 칸만) → 표지를 글 · 금액 칸 · 같은 책 단가표(노임 · 자재 · 기계 · 견적 · 단가산출 +총괄표)로 다시 정함(`연결뜻` · 표지 「엑셀 추정」 · 자원 코드 · 자리 = 비움): + 변수 줄 「이름 = 식 = 값」 → 값 = 결과(`\\x12`) · 「이름 = 값」 → 대입(결과 없음) · 금액 줄 첫 식 조각의 수가 + 앞 대입 값과 같으면 변수(`\\x11` · 상수 아님) · + 합계 머리(「합 계」) 아래 노무비 · 재료비 · 경비 · 계 줄 = 합계(`\\x1a` · 비목 L M S T) · + 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격) = 세 줄 머리(104 · 101 · 105) → 아래 + 「노무비 · 재료비 · 경비 : 단가 …」(단가 = 그 자원 비목 값) = 세 줄 · 「소 계」 = 계 줄 · + 노임 · 자재 이름 + 그 단가 수 = 자원 줄(5 · 6 · 비목 = 금액 칸) · + 「…소계 : 수」(금액 칸 = 비목만) = 소계 61(줄참조) · 「수 × n% = 값」 + 금액 칸 = 율 58(줄참조) · + 「(A 수 + 수) × n%」 = 율 62(구간 합) · 「= A수」 = 구간 표지(`\\x06A\\x06`) · 그 밖 = 글 줄. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_San_Xlsx as sx + +SHEET = "단가산출" +LINK = {"자재": 5, "노임": 6, "견적": 101, "산근": 104, "기계": 105, "소계": 61, "율": 58} +MEAN = {5: "자재", 6: "노임", 101: "견적", 104: "산근", 105: "기계", 61: "소계", 58: "율"} +MEAN[62] = "율" +LABEL = {"노무비": "L", "재료비": "M", "경비": "S", "계": "T"} +NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+" # 「.1」 = 엑셀 꼴 0.1 +_NUM = re.compile(r"(? float: + return float(s.replace(",", "").rstrip(".")) + + +def _close(a: float, b: float) -> bool: + return abs(a - b) < 1e-6 * max(1.0, abs(b)) + + +def _span(text: str, name: str, start: int = 0) -> tuple[int, int] | None: + """빈칸을 건너 같은 글자 차례 자리(이름 · 규격) · 없음 None.""" + chars = [re.escape(c) for c in sx.flat(name)] + if not chars: + return None + m = re.compile(r"\s*".join(chars)).search(text, start) + return (m.start(), m.end()) if m else None + + +# ── 시트 → 블록 ─────────────────────────────────────────────────────── +def blocks(rows: list[list[Any]]) -> list[dict[str, Any]]: + """단가산출 시트 → [{번, 머리, 행, 저장, 줄 [{행, 엑셀행, 보임, 돈}]}](머리 = B 열 「N.이름[단위]」).""" + cols = sx.money_cols(rows) or [2, 3, 4, 5] + split = cols[0] > 6 # 거창 꼴 — 글자 칸 나눔(열 = 화면 자리 · B 열 = 머리만) + out: list[dict[str, Any]] = [] + for i, r in enumerate(rows): + head = r[1] if len(r) > 1 else None + m = _HEAD.match(head) if isinstance(head, str) and not head[:1].isspace() else None + if m and i > 2: + out.append({"번": int(m.group(1)), "머리": head, "행": i + 1, "줄": []}) + out[-1]["저장"] = sx.money_of(r, cols) + continue + if not out: + continue + if split: + cells = [(j, sx._cell(v)) for j, v in enumerate(r[2 : cols[0]], 2)] + text = sx.layout([(j, v) for j, v in cells if v.strip()], 2) + else: + text = sx._cell(head) + one = {"행": len(out[-1]["줄"]) + 1, "엑셀행": i + 1, "보임": text, "칸": split} + out[-1]["줄"].append(one | {"돈": sx.money_of(r, cols)}) + for b in out: # 끝 빈 줄(시트 꼬리) + while b["줄"] and not b["줄"][-1]["보임"].strip() and not b["줄"][-1]["돈"]: + b["줄"].pop() + return out + + +# ── 줄 ──────────────────────────────────────────────────────────────── +def _label(text: str) -> str | None: + """「노무비 :」 · 「경 비 :」 · 「계 :」 → 비목 글자 · 아니면 None.""" + m = _COLON.search(text) + if not m: + return None + return LABEL.get(sx.flat(_HEADING.sub("", text[: m.start()]))) + + +def _nums(text: str, lo: int = 0, hi: int | None = None) -> list[tuple[int, int, float]]: + hi = len(text) if hi is None else hi + return [(m.start(1), m.end(1), _v(m.group(1))) for m in _NUM.finditer(text, lo, hi)] + + +def _result(text: str) -> tuple[int, int, float, int | None] | None: + """마지막 「=」 뒤 첫 수(구간 글자 「A」 붙음 허용) → (시작, 끝, 값, 글자 자리).""" + eq = text.rfind("=") + if eq < 0: + return None + m = re.match(r"\s*(?:([A-Z])\s*)?(-?(?:" + NUM + r"))\.?(?![\d,])", text[eq + 1 :]) + if not m: + return None + at = eq + 1 + return at + m.start(2), at + m.end(2), _v(m.group(2)), at + m.start(1) if m.group(1) else None + + +class Lines: + """블록 한 벌 줄 → 구조 줄.""" + + def __init__(self, res: dict[str, list[dict]], tags: list[str]) -> None: + self.res, self.tags = res, tags + self.vars: dict[str, float] = {} # 이름 → 마지막 대입 값 + self.ctx: dict | None = None # 세 줄 머리 자원 {종류, 자원} + self.triple = False # 바로 앞이 세 줄 + self.heads: list[dict] = [] # 세 줄 머리 줄들 + self.lines: list[dict[str, Any]] = [] + + # ── 꼴 ───────────────────────────────────────────────────────── + def blank(self, src: dict) -> dict[str, Any]: + return { + "행": src["행"], + "엑셀행": src["엑셀행"], + "글": src["보임"], + "보임": src["보임"], + "품명": None, + "규격": None, + "단위": None, + "단": 0, + "색인": 0, + "연결": 0, + "연결뜻": None, + "비목": None, + "자원": None, + "연결단가": [], + "변수": [], + "줄참조": [], + "결과": [], + "합계": False, + "자리": {"자리수": None, "방법": None}, + "표지": None, + "기계": None, + "산근": None, + "저장": src.get("돈"), + "추정": False, + "_marks": [], + "_칸": src.get("칸"), + } + + def resource(self, kind: str, one: dict) -> dict[str, Any]: + code = ( + f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}" + ) # 원 코드 없음 = 표 줄 번 + out = {"코드": code, "종류": kind, "표": one["표"]} + return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])} + + def link(self, ln: dict, kind: str, one: dict | None) -> None: + ln["연결"] = LINK[kind] if kind in LINK else 62 + ln["연결뜻"] = MEAN.get(ln["연결"]) + ln["추정"] = True + if one is not None: + ln["자원"] = self.resource(kind, one) + if kind == "산근": + ln["산근"] = ln["자원"]["코드"] + + @staticmethod + def money_part(ln: dict) -> str | None: + """금액 칸 비목 — 노 · 재 · 경 중 값 하나 · 셋 다 = T · 없음 = None.""" + got = [c for c in "LMS" if (ln["저장"] or {}).get(c)] + return got[0] if len(got) == 1 else ("T" if got else None) + + @staticmethod + def closed(text: str, end: int) -> bool: + """칸 나눔 꼴 — 표지 붙은 수 뒤는 닫는 표지 2 칸(빈칸 · 수 끝 점 2 이상 · 줄 끝).""" + gap = len(text[end:]) - len(text[end:].lstrip(" .")) + return gap >= 2 or not text[end:].strip() + + def wrap(self, ln: dict, s: int, e: int, ch: str) -> None: + ln["_marks"].append((s, e, ch)) + + # ── 자원 찾기 ───────────────────────────────────────────────── + def names(self, ln: dict, one: dict, start: int = 0) -> int | None: + """이름 · 규격 · 단위 자리 표지(`\\x0e` · `\\x13` · `\\x15`) · 끝 자리.""" + text = ln["보임"] + sp = _span(text, one["이름"], start) + if not sp: + return None + self.wrap(ln, *sp, "\x0e") + ln["품명"] = text[sp[0] : sp[1]] + end = sp[1] + if one.get("규격"): + m = re.match(r"\s*", text[end:]) + at = end + m.end() + size = _span(text, one["규격"], at) + if size and size[0] - at <= 1: + lo = size[0] - (1 if text[size[0] - 1 : size[0]] in "([" and size[0] > at else 0) + hi = size[1] + (1 if text[size[1] : size[1] + 1] in ")]" and lo < size[0] else 0) + self.wrap(ln, lo, hi, "\x13") + ln["규격"] = text[lo:hi] + end = hi + unit = re.match(r"\s*(\[[^\[\]]*\])", text[end:]) + if unit: + self.wrap(ln, end + unit.start(1), end + unit.end(1), "\x15") + ln["단위"] = unit.group(1) + end += unit.end(1) + return end + + def find_item(self, ln: dict) -> tuple[str, dict, tuple[int, int, float]] | None: + """노임 · 자재 — 글 속 수가 그 단가이고 이름이 그 수 앞에 있음(가장 긴 이름 · 규격 맞음 먼저).""" + text, body = ln["보임"], sx.flat(ln["보임"]) + best = None + for hit in _nums(text): + for kind, one in self.res["_값"].get(round(hit[2], 4), ()): + name = sx.flat(one["이름"]) + if not name or name not in body: + continue + sp = _span(text, one["이름"]) + if not sp or sp[1] > hit[0]: + continue + key = (not one["규격"] or sx.flat(one["규격"]) in body, len(name)) + if best is None or key > best[0]: + best = (key, kind, one, hit) + return best[1:] if best else None + + def find_head(self, ln: dict) -> tuple[str, dict] | None: + """세 줄 머리 — 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격).""" + text = ln["보임"] + m = _REF.search(text) + if m: + kind = "산근" if m.group(1) == "단산" else "견적" + one = self.res["_번"].get((kind, int(m.group(2)))) + if kind == "견적" and (not one or sx.flat(one["이름"]) not in sx.flat(text)): + body = sx.flat(text[: m.start()]) # 견적 번 ≠ 표 차례 — 이름으로(가장 긴 것) + hits = [x for x in self.res.get("견적") or [] if sx.flat(x["이름"]) in body] + one = ( + max(hits, key=lambda x: len(sx.flat(x["이름"]) + sx.flat(x["규격"]))) + if hits + else None + ) + return (kind, one) if one else None + if "=" in text or self.res["_기계틀"] is None or text.lstrip()[:1] in ("※", "*"): + return None + body, best = sx.flat(text), None + for at in self.res["_기계틀"].finditer(body): + name = at.group(1) + for one in self.res["_기계"][name]: + size = sx.flat(one["규격"]) + tail = body[at.start() + len(name) : at.start() + len(name) + len(size) + 2] + if size and size not in tail: + continue + key = len(name) + len(size) + if best is None or key > best[0]: + best = (key, one) + return ("기계", best[1]) if best else None + + # ── 줄 갈래 ─────────────────────────────────────────────────── + def run(self, src: dict) -> dict[str, Any]: + ln = self.blank(src) + text = ln["보임"] + if not text.strip() and not ln["저장"]: + ln["연결"] = None + return self.done(ln) + heading = bool(_HEADING.match(text)) + part = _label(text) + if heading: + ln["단"] = 1 + title = sx.flat(_HEADING.sub("", text)) + if re.search(r"[합소총]계|^계", title): + self.heads = [] # 합계 절 — 세 줄 머리 닫음 + self.triple = False + if self.triple and "소계" in sx.flat(text.split(":")[0]) and self.ctx: + return self.done(self.triple_total(ln)) + self.triple = False + if part in ("L", "M", "S") and not heading and self.triple_line(ln, part): + return self.done(ln) + got = self.find_item(ln) + total = ln["저장"] if part == "T" else not ln["저장"] # 합계 = 계 줄만 금액 칸 + if part and not heading and got is None and total: + return self.done(self.total_line(ln, part)) + if got is not None: + return self.done(self.item(ln, *got)) + if (head := self.find_head(ln)) is not None: + kind, one = head + self.link(ln, kind, one) + end = self.names(ln, one) + loose = end is None or (one["규격"] and ln["규격"] is None) # 규격이 이름 곁에 없음 + loose = loose or text[end or 0 :].lstrip()[:1] in (":", ":") + if ln["단"] == 0 and kind == "기계" and loose: # 설명 글 속 장비 이름 + ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None} + ln |= {"규격": None, "단위": None, "_marks": []} + self.plain(ln) + return self.done(ln) + self.heads.append({"종류": kind, "자원": one, "줄": ln, "씀": False}) + self.ctx = self.heads[-1] + return self.done(ln) + if self.subtotal(ln) or self.rate62(ln) or self.rate(ln): + return self.done(ln) + self.plain(ln) + return self.done(ln) + + def unused(self) -> None: + """머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름).""" + for h in self.heads: + ln = h["줄"] + if h["종류"] == "기계" and not h["씀"] and ln["단"] == 0: + ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None} + ln |= {"규격": None, "단위": None, "_marks": []} + + def done(self, ln: dict) -> dict[str, Any]: + self.lines.append(ln) + return ln + + def total_line(self, ln: dict, part: str) -> dict: + """합계 줄 — 「노무비 : 식 = 값」 · 「재료비 : 값」 · 「계 : 값」(계 = 결과).""" + text = ln["보임"] + ln |= {"합계": True, "비목": part, "단": -1} + lead = len(text) - len(text.lstrip()) + res = _result(text) + if res: + self.wrap(ln, lead, text.rfind("=") + 1, "\x1a") + self.result(ln, res) + elif part == "T": + colon = _COLON.search(text).end() + self.wrap(ln, lead, colon, "\x1a") + nums = _nums(text, colon) + if nums: + self.wrap(ln, nums[0][0], nums[0][1], "\x12") + ln["결과"] = [nums[0][2]] + else: + self.wrap(ln, lead, len(text.rstrip()), "\x1a") + return ln + + def result(self, ln: dict, res: tuple) -> None: + s, e, v, letter = res + if letter is not None: + self.wrap(ln, letter, letter + 1, "\x06") + self.wrap(ln, s, e, "\x12") + ln["결과"].append(v) + + def triple_line(self, ln: dict, part: str) -> bool: + """세 줄 — 단가 = 머리 자원(기계 · 산근 · 견적)의 그 비목 값.""" + text = ln["보임"] + colon = _COLON.search(text).end() + nums = _nums(text, colon) + if not nums or not self.heads: + return False + price = nums[0] + tail = text[price[1] :] + if ("=" not in tail and tail.strip()) or tail.lstrip()[:1] == "+": + return False + fit = [ + h + for h in reversed(self.heads) + if _close(price[2], float(h["자원"]["값"].get(part) or 0)) + ] + if not fit: + return False + self.ctx = fit[0] + self.ctx["씀"] = True + one = self.ctx["자원"] + self.link(ln, self.ctx["종류"], one) + ln["비목"] = part + if self.ctx["종류"] == "기계": + ln["기계"] = True + self.money(ln, price) + self.triple = True + return True + + def triple_total(self, ln: dict) -> dict: + one = self.ctx["자원"] + self.link(ln, self.ctx["종류"], one) + ln["비목"] = "T" + text = ln["보임"] + res = _result(text) + if res: + self.result(ln, res) + else: + m = re.search(r"[::]\s*(?:([A-Z])\s*)?(" + NUM + r")", text) + if m: + self.result( + ln, (m.start(2), m.end(2), _v(m.group(2)), m.start(1) if m.group(1) else None) + ) + money = ln["저장"] or {} + rows = [] + for one in reversed(self.lines): + if one["연결"] != ln["연결"] or one["비목"] not in ("L", "M", "S"): + break + rows.append(one) + cols = [c for c in "LMS" if money.get(c)] + total = sum((one["결과"] or [0])[-1] for one in rows) + if len(cols) == 1 and money.get("T") and abs(total - money["T"]) < 1: + for one in rows: # 계 줄 금액 칸 비목 하나 = 값 있는 세 줄 모두 그 비목으로 옮김 + if (one["결과"] or [0])[-1]: + one["비목"] = cols[0] + elif money: + for one in rows: # 계 줄 금액 칸이 빈 비목 = 합에 안 든 줄(비목 없음) + if not money.get(one["비목"]) and one["결과"] and one["결과"][-1]: + one["비목"] = None + self.triple = False + return ln + + def item(self, ln: dict, kind: str, one: dict, price: tuple) -> dict: + self.link(ln, kind, one) + self.names(ln, one) + part = self.money_part(ln) + ln["비목"] = part if part in ("L", "M", "S") else None + self.money(ln, price) + return ln + + def money(self, ln: dict, price: tuple[int, int, float]) -> None: + """금액 줄 — 단가(`\\x03`) · 첫 식 조각의 앞 대입 값 = 변수(`\\x11`) · 끝 = 결과.""" + text = ln["보임"] + s, e, v = price + self.wrap(ln, s, e, "\x03") + ln["연결단가"] = [v] + self.var(ln, e) + res = _result(text) + if res and res[0] > e: + self.result(ln, res) + + def var(self, ln: dict, start: int) -> None: + """첫 식 조각(start ~ 첫 「=」)의 수 중 가장 늦게 대입한 변수 값 하나 = 변수(`\\x11` · 원파일 = 줄마다 하나) · + 칸 나눔 꼴은 닫는 표지 자리(2 칸)가 있는 수만.""" + text = ln["보임"] + eq = text.find("=", start) + order = list(self.vars.values()) + best = None + for a, b, val in _nums(text, start, eq if eq >= 0 else None): + rank = max((i for i, x in enumerate(order) if _close(val, x)), default=-1) + if ln["_칸"] and not self.closed(text, b): + continue + if rank >= 0 and (best is None or rank > best[0]): + best = (rank, a, b, val) + if best: + self.wrap(ln, best[1], best[2], "") + ln["변수"].append(best[3]) + + def subtotal(self, ln: dict) -> bool: + """「…소계 : 수」 — 금액 칸 계 없음 · 비목 하나(61) · 금액 칸 없음 = 비목 없음.""" + text = ln["보임"] + m = _COLON.search(text) + if not m or "소계" not in sx.flat(text[: m.start()]): + return False + money = ln["저장"] or {} + parts = [c for c in "LMS" if money.get(c)] + if len(parts) != 1 or ( + money.get("T") is not None and not _close(money["T"], money[parts[0]]) + ): + return False # 소계 61 = 금액 칸 비목 하나(계 칸 없음 · 있으면 같은 값) + got = re.match(r"\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text[m.end() :]) + if not got: + return False + at = m.end() + if got.group(1): + self.wrap(ln, at + got.start(1), at + got.end(1), "\x06") + self.wrap(ln, at + got.start(2), at + got.end(2), "\x0c") + ln["줄참조"] = [_v(got.group(2))] + ln["저장"] = {**money, "T": money[parts[0]]} + self.link(ln, "소계", None) + ln["비목"] = self.money_part(ln) + ln["표지"] = "소계" + self.var(ln, at + got.end(2)) + res = _result(text) + if res and res[0] > at + got.end(2): + self.result(ln, res) + return True + + def rate62(self, ln: dict) -> bool: + """「(A 수 + 수) × n% = 값」 · 「A 수 + 수 = 합 × n% = 값」 — 구간 합 율(62 · 합계 표지).""" + text = ln["보임"] + m = _GROUP62.search(text) + if not m or not re.search(r"[×xX*]", text[m.end() :]) or not ln["저장"]: + return False + self.wrap(ln, m.start(1), m.end(1), "\x07") + self.wrap(ln, m.start(2), m.end(3), "\x1a") + ln |= {"합계": True} + self.link(ln, "율62", None) + ln["비목"] = self.money_part(ln) + ln["표지"] = "율" + for r in re.finditer(r"=\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text): + self.result( + ln, (r.start(2), r.end(2), _v(r.group(2)), r.start(1) if r.group(1) else None) + ) + return True + + def rate(self, ln: dict) -> bool: + """「수 × n% = 값」 + 금액 칸 — 율(58 · 첫 수 = 줄참조).""" + text = ln["보임"] + res = _result(text) + if not ln["저장"] or not res: + return False + colon = _COLON.search(text) + nums = _nums(text, colon.end() if colon else 0, text.rfind("=")) + seen = [v for x in self.lines for v in x["결과"] + x["줄참조"]] + before = any(_close(nums[0][2], v) for v in seen) if nums else False + lead = text[colon.end() if colon else 0 :].lstrip(" (") + before = before and lead[:1].isdigit() + if not nums or ("%" not in text[: text.rfind("=")] and not before): + return False + self.wrap(ln, nums[0][0], nums[0][1], "\x0c") + ln["줄참조"] = [nums[0][2]] + self.link(ln, "율", None) + ln["비목"] = self.money_part(ln) + ln["표지"] = "율" + self.result(ln, res) + return True + + def plain(self, ln: dict) -> None: + """글 · 변수 줄 — 대입마다 「이름 = 식 = 값」 = 결과 · 그 밖 「… = 값」(식 있는 줄) = 결과.""" + text = ln["보임"] + ln["연결"] = 0 + part = self.money_part(ln) + ln["비목"] = part + hits: list[re.Match] = [] + for m in _ASSIGN.finditer(text): + if m.group(1).lower() in _UNITS and re.search(r"\d\.?\s?$", text[: m.start()]): + continue # 「8 hr =」 = 단위 + gap = text[hits[-1].end() : m.start()] if hits else "" + if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)): + hits.append(m) + lead = text[: hits[0].start()].strip(" ○●·-{") if hits else "" + if hits and (not lead or re.search(r"[::]$", lead)): + for n, m in enumerate(hits): + end = hits[n + 1].start() if n + 1 < len(hits) else len(text) + seg = text[m.end() : end] + parts = seg.split("=") + val = re.match(r"\s*(?:[A-Z](?=\d))?(" + NUM + r")", parts[-1]) + if not val: + continue + at = m.end() + len(seg) - len(parts[-1]) + if len(parts) >= 2: + res = _result(text[:end]) + if res and res[0] >= at: + self.result(ln, res) + self.vars.pop(m.group(1), None) + self.vars[m.group(1)] = _v(val.group(1)) + return + eq = text.rfind("=") + seg = re.split(r"[=::]", text[:eq])[-1] + if eq > 0 and re.search(r"[×÷+*]|\d\s*/|/\s*\d", seg): + res = _result(text) + if res: + self.result(ln, res) + + +# ── 책 ──────────────────────────────────────────────────────────────── +def _glyph(text: str, marks: list[tuple[int, int, str]]) -> str: + """보인 글 + 표지 자리 → 원파일 LineData 꼴(닫는 표지 먼저).""" + ins: list[tuple[int, int, str]] = [] + for s, e, ch in marks: + ins.append((s, 1, ch)) + ins.append((e, 0, ch)) + out, last = [], 0 + for at, _, ch in sorted(ins, key=lambda x: (x[0], x[1])): + out.append(text[last:at]) + out.append(ch) + last = at + out.append(text[last:]) + return "".join(out) + + +def index(res: dict[str, list[dict]]) -> dict[str, Any]: + """자원 표 → 찾기 색인(단가 → 노임 · 자재 · 이름 → 기계 · (종류, 번) → 산근 · 견적).""" + out: dict[str, Any] = dict(res) + out["_값"] = {} + for kind in ("노임", "자재"): + for one in res.get(kind) or []: + out["_값"].setdefault(round(one["값"]["T"], 4), []).append((kind, one)) + out["_기계"] = {} + for one in res.get("기계") or []: + if sx.flat(one["이름"]): + out["_기계"].setdefault(sx.flat(one["이름"]), []).append(one) + names = sorted(out["_기계"], key=len, reverse=True) + out["_기계틀"] = re.compile("(?=(" + "|".join(map(re.escape, names)) + "))") if names else None + out["_번"] = {(k, one["번"]): one for k in ("산근", "견적") for one in res.get(k) or []} + return out + + +def read(sheets: dict[str, list]) -> dict[str, Any]: + """EST 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 표지}(`M02_San_Estx.read` 꼴 · 일위 = 호표 길).""" + res = sx.resources(sheets) + look = index(res) + total = {one["번"]: one for one in res.get("산근") or []} + out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None} + for b in blocks(sheets.get(SHEET) or []): + tags: list[str] = [] + one = total.get(b["번"]) + m = _HEAD.match(b["머리"]) + head = { + "코드": f"단산{b['번']}", + "이름": one["이름"] if one else m.group(2).strip(), + "규격": one["규격"] if one else "", + "단위": one["단위"] if one else m.group(3).strip(), + "저장": b["저장"] or (dict(one["값"]) if one else None), + } + if not one: + tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음") + work = Lines(look, tags) + for src in b["줄"]: + work.run(src) + work.unused() + lines = [] + for ln in work.lines: + marks = ln.pop("_marks") + ln.pop("_칸", None) + ln["글"] = _glyph(ln["보임"], marks) + lines.append(ln) + out["산근"].append({**head, "번": b["번"], "엑셀행": b["행"], "줄": lines, "표지": tags}) + out["자원"] = { + f"{kind}:{one['번']}": {k: one[k] for k in ("이름", "규격", "단위", "표")} + | {"저장": one["값"]} + for kind, rows in res.items() + for one in rows + } + out["표지"] = [ + f"{sx.NOTE}자원 코드 · LinkIndex · 자리(Diget) 없음 — 연결뜻 = 글 · 금액 칸 · 단가표로 정함({sx.GUESS})", + f"{sx.NOTE}자원 코드 = 단가표 이름#No(산근 = 단산 번)", + ] + return out diff --git a/M02_MasterTemplete/M02_San_Xlsx_Stmate.py b/M02_MasterTemplete/M02_San_Xlsx_Stmate.py new file mode 100644 index 000000000..3d78e7d07 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Xlsx_Stmate.py @@ -0,0 +1,70 @@ +"""STmate 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Stc.read` 와 같은 꼴 · 읽기만 · 서버 단독). + +엑셀 「단가산출근거」 숨은 G 열 = 원파일 COSTN.SMT 원문 그대로 → 줄 = `M02_San_Stc.stc_line`(풀이 = +`M02_San_Stmate.parse_line` · 다시 짜지 않음) · 블록 머리 금액(C~F) = 원파일 저장(TAMT1 …) · +단가 = 목록표(노무비 · 재료비 · 경비 · 중기 · 단가산출근거 · 숨은 메타 = COSTN 칸) · +끝전 = 파일 투표(`_원파일파서_틀.md` 4 장 SAN_TYP 두 갈래 중 블록 계가 맞는 수가 많은 쪽 · 파일에 하나 · +같으면 비목버림). +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_San_Book as book +from M02_MasterTemplete import M02_San_Eval as ev +from M02_MasterTemplete import M02_San_Stc as stc +from M02_MasterTemplete import M02_San_Xlsx as sx + +RULE = {"비목버림": "1", "합버림_큰나머지": "0"} # 끝전 갈래 → SAN_TYP + + +def _amt(head: dict) -> dict[str, float]: + return {c: float(head.get(k) or 0) for c, k in zip("TLMS", "CDEF")} + + +def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]: + """파일 끝전 — 블록마다 두 갈래로 셈해(낙찰 · 조정 뒤) 엑셀 머리 계(C)와 맞는 수 · 많은 쪽(같으면 + 비목버림).""" + got = dict.fromkeys(RULE, 0) + for blk in blocks: + nodes = [ln["풀이"] for ln in blk["줄"]] + want = (blk.get("저장") or {}).get("T") + for rule in RULE: + try: + total = ev.stmate_block({"코드": blk["코드"]}, nodes, prices, rule)["금액"]["T"] + except Exception: # noqa: BLE001 — 못 셈 = 표 없음 + continue + got[rule] += want is not None and abs(float(total) - float(want)) < 0.5 + best = max(RULE, key=lambda r: (got[r], r == "비목버림")) + return best, got + + +def read(sheets: dict[str, list]) -> dict[str, Any]: + """STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}.""" + found = book.stmate_prices(sheets) + prices = {c: {k: p.get(k) for k in ("T", "L", "M", "S", "meta")} for c, p in found.items()} + res = { + c: {"종류": stc.KIND.get(c[:1]), **{k: p.get(k) for k in ("이름", "규격", "단위")}} + | {"저장": {k: p.get(k) for k in "TLMS"}} + for c, p in found.items() + } + out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices} + for blk in book.stmate_blocks(sheets): + tags: list[str] = [] + lines = [stc.stc_line(i, ln["G"], res, tags) for i, ln in enumerate(blk["몸"], 1)] + for one, src in zip(lines, blk["몸"]): + one["엑셀행"] = src["행"] + me = found.get(blk["코드"]) or {} + head = {"코드": blk["코드"], "이름": blk.get("이름") or me.get("이름")} + head |= {"규격": me.get("규격"), "단위": me.get("단위"), "저장": _amt(blk["머리"])} + _, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices) + out["산근"].append({**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]}) + rule, got = vote(out["산근"], prices) + out["옵션"] = {"SAN_TYP": RULE[rule]} + out["내역"] = None + out["표지"] = [ + f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)", + f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)", + ] + return out diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 6e2854e61..fa86b982b 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -1,14 +1,13 @@ """M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독. -올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를 -`실무자료/산근/.json` 에 둠 -(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기 -(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 · +올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)와 그 엑셀 읽기기 둘 꼴 +(812-1 `M02_San_Xlsx` → `M02_Doc_FromNative.pack` · 칸 `엑셀`)을 `실무자료/산근/.json` 에 둠 +(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 원파일과 같은 길 +(`M02_Doc_FromNative.emit` · 원본 `엑셀` 참) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 · 엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) · 같은 내용 SK 가 있으면 그것(다시씀). 블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). -STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해 -엑셀 계와 맞는 것. +STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`). 원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선 (그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 = 같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`). @@ -17,7 +16,6 @@ STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — from __future__ import annotations import json -import re from pathlib import Path from typing import Any @@ -30,7 +28,6 @@ from M02_MasterTemplete import M02_San_Emit as em DIRNAME = "산근" RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림" -RULES = ("비목버림", "합버림_큰나머지") MAX_INNER = 6 # 안쪽 산근 깊이 상한 @@ -60,14 +57,33 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None: return f"엑셀 형식 오류 — {type(e).__name__}: {str(e)[:80]}" if not book.kind_of(sheets): return None + try: + excel = _excel(name, sheets) + except Exception as e: # noqa: BLE001 — 산근 읽기 실패 = 사유(올리기는 됨) + return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}" path = _path(folder, key) path.parent.mkdir(parents=True, exist_ok=True) - got = {"파일": name, "시트": sheets} + got = {"파일": name, "시트": sheets, "엑셀": excel} native = next((n for n in _natives(folder) if fn.same(n, sheets)), None) atomic_write_json(path, got | ({"원파일": native} if native else {})) return None +def _excel(name: str, sheets: dict) -> dict: + """산근 시트 → 원파일과 같은 둘 꼴(812-1 엑셀 읽기기 `M02_San_Xlsx` → `M02_Doc_FromNative.pack`).""" + from M02_MasterTemplete import M02_San_Xlsx as sx + + return fn.pack(sx.read_sheets(sheets), name) + + +def _excel_of(path: Path, saved: dict) -> dict: + """저장 HP 의 엑셀 둘 꼴 — 옛 저장(시트만)은 한 번 지어 그 자리에 둠.""" + if not isinstance(saved.get("엑셀"), dict): + saved["엑셀"] = _excel(str(saved.get("파일") or ""), saved["시트"]) + atomic_write_json(path, saved) + return saved["엑셀"] + + # ── 원파일(810-2) ────────────────────────────────────────────────────── def _saved(folder: str | Path) -> list[tuple[Path, dict]]: out = [] @@ -114,32 +130,12 @@ def has(folder: str | Path, key: str) -> bool: return _path(folder, key).is_file() -def _rule(blk: dict, nodes: list, prices: dict) -> str: - """STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림.""" - from M02_MasterTemplete import M02_San_Eval as ev - - tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")] - want = (tails or [blk["머리"]])[0].get("C") - for rule in RULES: - try: - got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"] - except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래 - continue - if want is not None and abs(float(got) - float(want)) < 0.5: - return rule - return RULES[0] - - def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: - """HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키).""" - from M02_MasterTemplete import M02_San_Book as book - from M02_MasterTemplete import M02_San_Emit as em - from M02_MasterTemplete import M02_San_Est as est - from M02_MasterTemplete import M02_San_Stmate as stm - + """HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키).""" hopyo_store.read(folder, key) # 없는 HP = 404 + path = _path(folder, key) try: - saved = json.loads(_path(folder, key).read_bytes()) + saved = json.loads(path.read_bytes()) except (OSError, ValueError) as e: raise store.StoreError(409, RELOAD) from e native = saved.get("원파일") @@ -147,52 +143,22 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: return fn.emit(native, int(no)) if not saved.get("시트"): raise store.StoreError(404, f"없는 단산{no}") - sheets, name = saved["시트"], str(saved.get("파일") or "") - if book.kind_of(sheets) == "STmate": - blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None) - if blk is None: - raise store.StoreError(404, f"없는 단산{no}") - prices = book.stmate_prices(sheets) - nodes = stm.parse_block(blk["몸"]) - return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name) - prices, total = book.est_prices(sheets) - blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None) - if blk is None or blk["번"] not in total: + excel = _excel_of(path, saved) + if int(no) not in fn.numbers(excel): raise store.StoreError(404, f"없는 단산{no}") - return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name) + return fn.emit(excel, int(no)) def _inner( source: Path, key: str, logic: dict, native: bool = False ) -> list[tuple[dict, int | None, str | None]]: - """안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 · - EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만.""" - from M02_MasterTemplete import M02_San_Book as book - - saved = json.loads(_path(source, key).read_bytes()) + """안쪽 산근 줄 `[(호표 줄, 같은 파일 안쪽 블록 번, 못 잇는 까닭)]` — 원파일 · 엑셀 둘 꼴 같은 길 + (`M02_Doc_FromNative.inner` · 줄 자원 코드 = 블록 코드).""" + path = _path(source, key) + saved = json.loads(path.read_bytes()) if native and isinstance(saved.get("원파일"), dict): return fn.inner(saved["원파일"], logic) - sheets = saved["시트"] - stmate = book.kind_of(sheets) == "STmate" - have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {} - total = set() if stmate else set(book.est_prices(sheets)[1]) - out = [] - for line in logic["호표"]: - res = line.get("자원") or {} - if res.get("갈래") != "단가산출": - continue - if stmate: - n = have.get(str(res.get("코드"))) - else: - hit = re.fullmatch(r"단산(\d+)", str(res.get("이름"))) - n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None - if n is None: - out.append((line, None, "같은 엑셀에 안쪽 블록 없음")) - elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP): - out.append((line, n, "비목이 바뀐 부름")) - else: - out.append((line, n, None)) - return out + return fn.inner(_excel_of(path, saved), logic) def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None: diff --git a/resources/tester/test_812_1_xlsx_golden.py b/resources/tester/test_812_1_xlsx_golden.py new file mode 100644 index 000000000..5a7084fcc --- /dev/null +++ b/resources/tester/test_812_1_xlsx_golden.py @@ -0,0 +1,426 @@ +"""812-1 골든 — 엑셀 읽기기(`M02_San_Xlsx`) = 810-1 원파일 파서와 같은 구조 줄(읽기만). + +① 실제 짝 — 거창 xlsx ↔ 거창 .estx(블록 머리 · 줄마다 연결뜻 · 비목 · 변수 · 줄참조 · 결과 · 합계 · 저장 · + 자원 이름 · 규격 · 단위) · STC 다섯 xlsx ↔ .stc(같은 블록 · 원문 · 줄 칸 · 자원 이름 · 규격 · 끝전 = SAN_TYP) +② 합성 — 원파일 구조 줄 보임 글 + 저장 금액으로 경쟁사 내보내기 꼴 시트를 지어(EST = 거창 꼴 글자 칸 나눔 · + 닫는 표지 2 칸 · 합계 표지 1 칸 · 소계 61 = 비목 칸만 — 거창 실제와 글 배치 같음(수 꼴 빼고) 먼저 확인) → + 읽기기 → 원파일 구조 줄과 같음(EST 1,849 · STC 371 블록) · 원파일 자체 엇갈림(같은 글 · 다른 표지)은 + `ODD` 로 고정 +③ 금액 — 실무 엑셀 11 = 읽기기 → `M02_Doc_FromNative` → 수식 문서 셈 = 엑셀 블록 금액(`ODD_MONEY` 고정) +빠른 견본 기본 · 전수 = 환경 S8121_ALL=1 · 파일 없으면 skip(다른 PC). +""" + +from __future__ import annotations + +import math +import os +import re +import sys +import unicodedata +from collections import Counter +from functools import lru_cache +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import helper_m02_hopyo_golden as golden # noqa: E402 +import test_797_1a_import_flags as practice # noqa: E402 +import test_810_1_estx as estx # noqa: E402 +import test_810_1_stc as stc # noqa: E402 + +from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402 +from M02_MasterTemplete import M02_San_Book as book # noqa: E402 +from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402 + +ALL = os.environ.get("S8121_ALL") == "1" +F = sx.flat +FIELDS = ("연결뜻", "비목", "변수", "줄참조", "결과", "합계") +#: 원파일 자체 엇갈림 — 같은 보인 글 · 금액 칸인데 원파일 표지가 다른 줄(엑셀이 못 가름 · 고정) +ODD = { + ("main", 638, 30), # 산근 세 줄 뒤 「소 계」 연결 없음(같은 꼴 다른 곳 = 104) + ("main", 1249, 230), # 같음 + ("main", 1199, 40), # 「0 × 1% = A0」 금액 0 — 비목 S 가 금액 칸에 없음 + ("main", 1438, 4), # 「{ 골재장 상차도 }」 글 줄에 비목 M + ("main", 1286, 4), # 괄호 속 「L = … = 46.00 m」 결과 표지 없음(같은 꼴 다른 곳 = 결과) + ("main", 1391, 6), # 「= 5.714㎡ / 100㎡ =」 중간 값에 결과 표지 + *(("main", 1308, r) for r in (8, 16, 26)), # 금액 칸 없는 「소 계 : 수 kg」 = 61(861 = 글) + *(("main", 1441, r) for r in (64, 65, 66, 67)), # 단가가 기계표와 다른 세 줄 · 비목 없음 +} +#: 금액 — 셈 ≠ 엑셀 블록(까닭 고정) +ODD_MONEY = { + ( + "북부청임도", + 33, + ), # 노무비 줄 「36,248 ÷ 0.096」 오타 — 절 소계 덧셈이 그 줄을 뺌(글 덧셈만 앎) + ( + "북부청임도", + 3, + ), # 아래 넷 = `M02_Doc_NativeEst.triple` 자리 규칙 dict 묶음 TypeError(812-3 몫) + ("북부청임도", 54), + ("북부청임도", 55), + ("북부청사방", 24), +} + + +# ── 합성(경쟁사 내보내기 꼴) ───────────────────────────────────────────── +W, MC = 146, [141, 142, 143, 144] +_OPEN = dict.fromkeys("\x03\x0e\x11\x12\x13\x15\x0c\x06\x07", 0) | {"\x1a": 1} +_CLOSE = dict.fromkeys("\x03\x0e\x11\x12\x13\x15\x0c\x06\x07", 2) | {"\x1a": 1} + + +def _w(ch: str) -> int: + return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1 + + +def _cls(ch: str) -> str: + if ch.isdigit() or ch in ",.": + return "n" + o = ord(ch) + if 0xAC00 <= o <= 0xD7A3 or 0x3131 <= o <= 0x318E or 0x3300 <= o <= 0x33FF: + return "h" + return "a" if ch.isascii() and ch.isalpha() else "s" + + +def cells(text: str) -> list[tuple[int, str]]: + """LineData → 글자 칸(열 = 화면 자리 · 여는 표지 0 · 닫는 표지 2 · 합계 표지 1 칸 · 표지 안 = 한 칸).""" + out: list[list] = [] + col, cur, opened, angle = 2, None, set(), False + for i, ch in enumerate(text): + if ord(ch) < 32: + col += _CLOSE.get(ch, 0) if ch in opened else _OPEN.get(ch, 0) + opened ^= {ch} + cur = None + continue + whole = bool(opened & set("\x0e\x13\x15")) or angle + if ch == "<" and ">" in text[i:]: + angle, whole = True, False + if ch == " " and not (whole and cur): + cur, col = None, col + 1 + continue + c = _cls(ch) + joined = cur is not None and ( + whole + or (c == cur[2] and c != "s") + or (cur[2] == "a" and ch.isdigit()) + or (ch == ":" and cur[2] == "h") + ) + if joined: + cur[1] += ch + else: + cur = [col, ch, c] + out.append(cur) + angle = angle and ch != ">" + col += _w(ch) + return [(a, b.rstrip()) for a, b, _ in out] + + +def _fmt(v: float | None) -> str | None: + return None if not v else f"{v:,.6f}".rstrip("0").rstrip(".") + + +def _money(row: list, saved: dict | None, link: int | None) -> None: + s = saved or {} + if not any(s.get(c) for c in "TLMS"): + return + if link != 61 and s.get("T"): + row[MC[0]] = int(s["T"]) if s["T"] == int(s["T"]) else s["T"] + for c, j in zip("LMS", MC[1:]): + row[j] = _fmt(s.get(c)) + + +def _table(title: str, head: dict, recs: list[dict]) -> list[list]: + rows = [[None] * 30 for _ in range(3)] + rows[0][1] = title + for j, h in head.items(): + rows[2][j] = h + for r in recs: + rows.append([r.get(j) for j in range(30)]) + return rows + + +def synth_est(d: dict) -> dict[str, list]: + """원파일 EST → 엑셀 꼴 시트(단가산출 · 총괄표 · 노임 · 자재 · 기계 · 견적).""" + rows = [[None] * W for _ in range(3)] + rows[0][1], rows[2][1] = "단가산출", "산 출 근 거" + labels = ("합 계", "노무비", "재료비", "경 비") + for j, h in [*zip(MC, labels), *zip((2, 3, 4, 5), labels)]: + rows[2][j] = h + tot = [] + for n, b in enumerate(d["산근"], 1): + head = [None] * W + size = f"({b['규격']})" if b.get("규격") else "" + head[1] = f"{n}.{b['이름'] or ''}{size}[{b.get('단위') or ''}]" + _money(head, b["저장"], None) + rows.append(head) + for ln in b["줄"]: + row = [None] * W + for j, v in cells(ln["글"]): + if j < MC[0]: + row[j] = v + _money(row, ln["저장"], ln["연결"]) + rows.append(row) + s = b["저장"] or {} + one = {1: str(n), 2: b["이름"], 3: b.get("규격"), 4: b.get("단위"), 5: s.get("T")} + one |= {6: _fmt(s.get("L")), 7: _fmt(s.get("M")), 8: _fmt(s.get("S"))} + tot.append(one | {25: s.get("T"), 26: s.get("L"), 27: s.get("M"), 28: s.get("S")}) + head4 = { + 1: "No", + 2: "품명", + 3: "규격", + 4: "단위", + 5: "합계", + 6: "노무비", + 7: "재료비", + 8: "경비", + } + out = {"단가산출": rows, "단가산출총괄표": _table("단가산출총괄표", head4, tot)} + recs: dict[str, list] = {"노임": [], "자재": [], "기계": [], "견적": []} + for key, v in d["자원"].items(): + kind, s = key.split(":")[0], v["저장"] or {} + base = {2: v["이름"], 3: v["규격"], 4: v["단위"]} + if kind == "노임": + recs[kind].append(base | {7: s.get("T")}) + elif kind == "자재": + recs[kind].append(base | {19: s.get("T")}) + elif kind in ("기계", "견적"): + one = base | {5: s.get("T"), 6: s.get("L"), 7: s.get("M"), 8: s.get("S")} + recs[kind].append( + one | {25: s.get("T"), 26: s.get("L"), 27: s.get("M"), 28: s.get("S")} + ) + for kind in recs: + for i, r in enumerate(recs[kind], 1): + r[1] = str(i) + one_price = {1: "No", 2: "품명", 3: "규격", 4: "단위"} + out["노임단가"] = _table("노임단가", one_price | {7: "적용단가"}, recs["노임"]) + out["자재단가"] = _table("자재단가", one_price | {19: "적용단가"}, recs["자재"]) + out["기계경비총괄표"] = _table("기계경비총괄표", head4, recs["기계"]) + out["견적단가"] = _table("견적단가", head4, recs["견적"]) + return out + + +def synth_stmate(n: dict) -> dict[str, list]: + """원파일 STC → 엑셀 꼴 시트(단가산출근거 숨은 G 열 원문 · 목록표 숨은 메타).""" + rows: list[list] = [[None] * 12 for _ in range(3)] + for b in n["산근"]: + s, at = b["저장"], len(rows) + 3 + head = [b["코드"], None, s["T"], s["L"], s["M"], s["S"], None, None, None, None, None, None] + head[6] = f"BDCOD|{b['코드']}\x05POSS|{at}\x05POSE|{at + len(b['줄']) - 1}" + rows += [head, [None, b["이름"]] + [None] * 10] + rows += [[None] * 6 + [ln["글"]] + [None] * 5 for ln in b["줄"]] + out = {"단가산출근거": rows} + lists: dict[str, list] = {k: [] for k in ("노무비", "재료비", "경비", "단가산출근거")} + for code, v in n["자원"].items(): + p = n["단가"].get(code) or {} + meta = "\x05".join( + [f"COD|{code}"] + + [f"{k}|{x}" for k, x in (p.get("meta") or {}).items() if x is not None] + ) + r = [None, v["이름"], v["규격"], v["단위"], p.get("T")] + [None] * 7 + if code[:1] in "LMS": + r[6] = meta + lists[{"L": "노무비", "M": "재료비", "S": "경비"}[code[:1]]].append(r) + else: + r[5:8], r[9] = [p.get("L"), p.get("M"), p.get("S")], meta + lists["단가산출근거"].append(r) + for k, v in lists.items(): + out[f"{k}목록표"] = [[None] * 12] * 3 + v + return out + + +# ── 맞대기 ───────────────────────────────────────────────────────────── +def _res(r: dict | None, unit: bool = True) -> tuple | None: + if not r: + return None + return (r.get("종류"), F(r.get("이름")), F(r.get("규격")), F(r.get("단위")) if unit else "") + + +def _vals(v: list) -> list: + return [round(x, 6) for x in v] + + +def _saved(s: dict | None) -> tuple: + return tuple(round(float((s or {}).get(c) or 0), 6) for c in "TLMS") + + +def diff_est(tag: str, mine: dict, nat: dict) -> set[tuple]: + """EST 블록 · 줄 맞대기 → 다른 (파일, 블록, 행) · 블록 머리 다름 = 행 0.""" + bad = set() + assert len(mine["산근"]) == len(nat["산근"]) + for n, (a, b) in enumerate(zip(mine["산근"], nat["산근"]), 1): + if (F(a["이름"]), F(a["규격"]), a["단위"], _saved(a["저장"])[:4]) != ( + F(b["이름"]), + F(b["규격"]), + b["단위"], + _saved(b["저장"])[:4], + ): + bad.add((tag, n, 0)) + body = [x["보임"].strip() for x in b["줄"] if x["보임"].strip()] + assert len([x for x in a["줄"] if x["보임"].strip()]) == len(body) + for x, y in zip(a["줄"], b["줄"]): + same = all( + _vals(x[f]) == _vals(y[f]) if f in ("변수", "줄참조", "결과") else x[f] == y[f] + for f in FIELDS + ) + same = same and _saved(x["저장"]) == _saved(y["저장"]) + mine_r, want_r = _res(x["자원"]), _res(y["자원"]) + same = same and (mine_r == want_r or (want_r and want_r[0] == "산근" and mine_r)) + if not same: + bad.add((tag, n, y["행"])) + return bad + + +def diff_stc(mine: dict, nat: dict) -> Counter: + """STmate — 같은 코드 블록 · 원문 · 줄 칸 · 자원 이름 · 규격(단위 = 원파일 COSTN 은 기호 글자) 다른 수.""" + got: Counter = Counter() + have = {b["코드"]: b for b in nat["산근"]} + for b in mine["산근"]: + y = have.get(b["코드"]) + if y is None: + got["원파일 없는 블록"] += 1 + continue + got["블록"] += 1 + if [ln["글"].strip() for ln in b["줄"]] != [ln["글"].strip() for ln in y["줄"]]: + got["원문 다름"] += 1 + continue + got["저장 다름"] += _saved(b["저장"])[:4] != _saved(y["저장"])[:4] + for x, z in zip(b["줄"], y["줄"]): + got["줄"] += 1 + fields = ("연결뜻", "비목", "변수", "줄참조", "합계", "기계", "산근") + got["줄 다름"] += any(x[f] != z[f] for f in fields) + got["자원 다름"] += _res(x["자원"], False) != _res(z["자원"], False) + return got + + +# ── ① 실제 짝 ───────────────────────────────────────────────────────── +@lru_cache(maxsize=4) +def excel(tag: str) -> dict: + path = practice.FILES.get(tag) + if not path or not path.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + return sx.read(path.name, path.read_bytes()) + + +def test_real_pair_geochang_equals_estx() -> None: + assert diff_est("거창", excel("거창"), estx.parsed("거창")) == set() + + +STC_PAIRS = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광") + + +@pytest.mark.parametrize("tag", STC_PAIRS) +def test_real_pair_stmate_equals_stc(tag: str) -> None: + mine, nat = excel(tag), stc.parsed(tag) + got = diff_stc(mine, nat) + assert got["블록"] and got["줄"] + assert (got["원문 다름"], got["저장 다름"], got["줄 다름"], got["자원 다름"]) == (0, 0, 0, 0), ( + got + ) + assert mine["옵션"]["SAN_TYP"] == nat["옵션"]["SAN_TYP"] # 끝전 파일 투표 = 원파일 옵션 + + +# ── ② 합성 ──────────────────────────────────────────────────────────── +def _num_norm(text: str) -> str: + num = re.compile(r"\d[\d,]*(?:\.\d*)?|\.\d+") + out = num.sub(lambda m: repr(float(m.group(0).replace(",", "").rstrip(".") or 0)), text) + return re.sub(r"\s+", "", out) + + +def test_synth_layout_equals_real_geochang() -> None: + """합성 규칙 검증 — 거창 실제 시트 줄 = 원파일 LineData 합성 칸(글 배치 · 수 꼴 「8.」 · 「.1」 빼고).""" + path = golden.FILES["거창"] + if not path.exists(): + pytest.skip("거창 엑셀 없음") + rows = book.sheets(path.name, path.read_bytes())["단가산출"] + heads = [ + i + for i, r in enumerate(rows) + if isinstance(r[1], str) and re.match(r"^\d+\.", r[1]) and r[141] + ] + nat = estx.parsed("거창")["산근"] + bad = total = 0 + for k, (h, b) in enumerate(zip(heads, nat)): + end = heads[k + 1] if k + 1 < len(heads) else len(rows) + for ln, r in zip(b["줄"], rows[h + 1 : end]): + real = [(j, sx._cell(v)) for j, v in enumerate(r[2:141], 2) if sx._cell(v).strip()] + mine = sx.layout(real, 2) + total += 1 + bad += _num_norm(mine) != _num_norm(sx.layout(cells(ln["글"]), 2)) + got = sx.money_of(r, MC) or {} + want = {c: v for c, v in (ln["저장"] or {}).items() if v and c in "TLMS"} + if ln["연결"] == 61: + want.pop("T", None) + bad += {c: round(v, 6) for c, v in got.items()} != { + c: round(v, 6) for c, v in want.items() + } + assert total > 2000 and bad == 0 + + +EST_SAMPLE = ("거창", "통영") + + +@pytest.mark.parametrize("tag", list(estx.FILES) if ALL else EST_SAMPLE) +def test_synth_est_equals_native(tag: str) -> None: + nat = estx.parsed(tag) + got = diff_est(tag, sx.read_sheets(synth_est(nat)), nat) + assert got == {x for x in ODD if x[0] == tag}, sorted(got)[:10] + + +@pytest.mark.parametrize("tag", list(stc.FILES) if ALL else ("봉화",)) +def test_synth_stmate_equals_native(tag: str) -> None: + nat = stc.parsed(tag) + mine = sx.read_sheets(synth_stmate(nat)) + got = diff_stc(mine, nat) + assert got["블록"] == len(nat["산근"]) and got["원파일 없는 블록"] == 0 + assert (got["원문 다름"], got["저장 다름"], got["줄 다름"], got["자원 다름"]) == (0, 0, 0, 0), ( + got + ) + assert mine["옵션"]["SAN_TYP"] == nat["옵션"]["SAN_TYP"] + + +# ── ③ 금액 ──────────────────────────────────────────────────────────── +def sweep(tag: str) -> dict: + """엑셀 → 읽기기 → 둘 꼴 → 블록마다 수식 문서 셈 = 엑셀 블록 금액(낙찰률 = 계약단가 뒤).""" + import test_810_2_native_doc as nd + + path = practice.FILES[tag] + saved = fn.pack(excel(tag), path.name) + out = {"블록": 0, "같음": 0, "다름": set(), "변수줄": 0, "자원줄": 0, "자원추정": 0} + for ln in (x for b in saved["산근"] for x in b["줄"]): + out["변수줄"] += bool(ln.get("변수")) + out["자원줄"] += bool(ln.get("자원")) + for no, blk in fn.numbers(saved).items(): + out["블록"] += 1 + try: + emitted = fn.emit(saved, no) + _, _, got = nd.doc_and_money(emitted) + except TypeError: + out["다름"].add((tag, no)) + continue + mine = {c: got.get(k) for c, k in nd.COST} + nag = (emitted["원본"].get("계약") or {}).get("낙찰률") + if nag: + for c in "LMS": + mine[c] = math.floor(round(float(mine[c]) * nag / 100, 6)) + mine["T"] = mine["L"] + mine["M"] + mine["S"] + want = blk.get("저장") or {} + if all(abs(float(mine[c] or 0) - float(want.get(c) or 0)) < 0.5 for c, _ in nd.COST): + out["같음"] += 1 + else: + out["다름"].add((tag, no)) + assert emitted["원본"]["엑셀"] is True and not emitted["원본"]["원파일"] + return out + + +MONEY_SAMPLE = ("거창", "봉화") + + +@pytest.mark.parametrize("tag", list(practice.FILES) if ALL else MONEY_SAMPLE) +def test_money_equals_excel(tag: str) -> None: + got = sweep(tag) + assert got["블록"] and got["다름"] == {x for x in ODD_MONEY if x[0] == tag}, sorted(got["다름"])