Files
Aislo/M02_MasterTemplete/M02_San_Xlsx.py
T
eomsangdonandClaude Opus 5.5 8cd7baea5e feat(812-1): 엑셀 읽기기 새로 — xlsx · xls → 810-1 구조 줄 꼴 → 원파일 길 한 벌 · 골든 시험 · 올림 길 교체
- 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 <noreply@anthropic.com>
2026-11-09 07:34:28 +09:00

175 lines
6.7 KiB
Python

"""경쟁사 산근 엑셀(.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 산근 시트 없음")