Files
Aislo/M02_MasterTemplete/M02_San_Xlsx.py
T
eomsangdonandClaude Opus 5.5 ff19138727 feat(816-2): 후보 여럿 기계를 같은 파일 안 구성 대조로 가름
- 잣대 = 같은 파일 · 같은 때의 구성 줄(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음(요구 773-3)
- M02_Machine_Find.peers · narrow — 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아 후보 가운데 키 하나면 이음 · 여럿 · 없음 = 그대로
- EST 원파일 MachMainDat 묶기(M02_San_Estx.makes) · EST 엑셀 「기계경비」 시트(M02_San_Xlsx.makes) → 자원 「같은구성」
- 저장 묶음(M02_Doc_FromNative) · 줄 만들기(M02_Doc_NativeEst · M02_San_Emit) 가 「같은구성」 을 넘김
- 앞서 짠 우리 표 값 대조는 걷음 · STC = 구성 표(BDQTY) 난독화로 없음 · STmate 엑셀 중기사용료 = 같은 파일 단독 짝 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:46:23 +09:00

209 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""경쟁사 산근 엑셀(.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 산근 시트 없음")