- 잣대 = 같은 파일 · 같은 때의 구성 줄(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음(요구 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>
209 lines
8.6 KiB
Python
209 lines
8.6 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, 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 산근 시트 없음")
|