"""경쟁사 산근 엑셀(.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, note = book.applied_how(r) vals = {k: float(v or 0) for k, v in vals.items()} got.append({**_drop(one), "표": sheet, "값": vals} | ({"칸": note} if note else {})) out[kind] = got from M02_MasterTemplete import M02_Machine_Find as mfind same = mfind.peers(makes(sheets.get("기계경비") or [])) for one in out["기계"]: # 816-2 같은 파일에서 구성이 같은 단독 기계 if got := same.get((flat(one["이름"]), flat(one["규격"]))): one["같은구성"] = got return out def makes(rows: list[list[Any]]) -> dict[tuple[str, str], dict[str, Any]]: """EST 엑셀 「기계경비」 시트(816-2) — 「N. 이름 · 규격」 머리 아래 빈 줄까지: 「손료 가격 ₩× 계수 × 10(-7)」 · 「주연료 종류 ℓ」 · 「직종 1 인」 → {(이름, 규격) 붙임: (이름, 규격, {손료 [[계수, 가격]] · 주연료 ℓ · 조종원})}.""" out: dict[tuple[str, str], tuple[str, str, dict[str, Any]]] = {} cur = None for row in rows: v = [c for c in row if c not in (None, "")] head = re.match(r"\d+\.\s*(.+)", str(v[0])) if v else None if head: spec = v[1] if len(v) > 1 and isinstance(v[1], str) else "" name = head.group(1).strip() make = {"손료": [], "주연료": None, "조종원": False} cur = out.setdefault((flat(name), flat(spec)), (name, spec, make))[2] elif not v: cur = None elif cur is not None: word = flat(v[0]) if word.startswith("손료") and "₩×" in v and (at := v.index("₩×") + 1) < len(v): cur["손료"].append([number(v[at]), number(v[1])]) elif word == "주연료" and len(v) > 2 and cur["주연료"] is None: cur["주연료"] = number(v[2]) elif len(v) > 2 and v[2] == "인" and (number(v[1]) or 0) > 0: cur["조종원"] = True return {k: m for k, m in out.items() if m[2]["손료"] and None not in m[2]["손료"][0]} 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 산근 시트 없음")