- 읽기(Bill_Excel) — STmate 「설계내역서」 숨은 코드 · 부모 코드 열 · EST 「내역서」 공종 번호 깊이 · 비고 대가 · 산근 · 단산 · 일위 · 제외 묶음(관급 · 제외금액) · 조달수수료 = 율 · DRM = 「읽을 수 없음」 · 열 머리 = M02 실무자료 칸 읽기 재사용 - 짜기(Bill_Import) — 공종 = 새 묶음 · 항목 = 새 줄 + 고정 단가 칸 · 수량 갈래 take · price · 이름 같은 자동 줄 붙임 · 남은 자동 줄 지움 · 같은 파일 다시 = 갈아 끼움 · 어긋난 줄 목록 - 길(Router_Import) — import/bill(file · file_key) 미리 보기 · import/bill/apply(같은 파일 이름 실무자료 사전조합 → 서고 사본 연결 · 층 쓰기 0) · 저장 안 함 - 셈 짝(Calc_Bill py · ts) — 줄 price = 연결이 단가를 못 낼 때 금액 · fixed · src_qty · Store 다듬기 - 확인 = 기번3 710,359,136 · 영월 767,148,700 · 봉화 781,283,415 · 거창 384,916,227 직접비 합 = 엑셀 · 빈 줄 0 · 어긋남 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""B09 경쟁사 내역 엑셀 읽기(PLAN 707-1) — 파일 → 내역 줄(묶음 · 항목) · 엑셀 합계. 저장 안 함.
|
||
|
||
꼴 둘(`_분석_705-4_불러오기_범위.md` ②):
|
||
STmate 「설계내역서」(없으면 「착공내역서」 · 「설계변경내역서」) —
|
||
숨은 열 = 줄 코드 · 부모 코드(`B00194_1` · `E2_1` — 비고 열 뒤 첫 둘) ·
|
||
묶음 = 코드 E · C(C = 제외금액 맨 위) ·
|
||
변경 차수(`_2` 뒤) 줄 = 안 읽음 · 부모 = 열린 묶음 가운데 부모 코드가 같은 가장 안쪽 ·
|
||
호표 = 코드 B 대가 · D 산근 · M 자재(번 = 비고 「대가 5호표」 · 「산근 13호표」 숫자)
|
||
EST 「내역서」 — 숨은 열 없음 · 묶음 = 공종 칸 있음 + 수량 없음 · 깊이 = 공종 번호
|
||
(Ⅰ · Ⅱ = 1 · 「4.1」 = 점 둘레 수 + 1) · 공종 · 수량 없이 금액만 = 합계 줄(안 읽음) ·
|
||
호표 = 비고 「단산46참조」 · 「일위8참조」 · 「관급」
|
||
열 = 머리 글(앞 10 줄 · 빈칸 뺌) 공종 · 명칭|품명 · 규격 · 수량 · 단위 · 합계 ·
|
||
노무비 · 재료비 · 경비 · 비고 — 돈 머리 = 단가 열 · 다음 열 = 금액
|
||
(`M02_Hopyo_Excel` 의 칸 읽기 재사용) · 빈 단가 = 0.
|
||
제외 = 맨 위 묶음 가운데 STmate 코드 C · 비고 「제외」 · 이름 「관급」 — 직접비 합에서 뺌.
|
||
조달수수료 = 제외 묶음 안 단위 % · 이름 「수수료」 줄 → `조달수수료율`
|
||
(줄로 안 실음 · 우리 조달수수료 줄이 셈).
|
||
그 밖 % 줄 = 1 식 · 단가 = 엑셀 금액(금액 그대로).
|
||
감싼 묶음 = 직접 맨 위 묶음이 하나뿐(공사 이름)이면 벗김(깊이 5 한도 · 레벨 1 = 공종).
|
||
DRM 파일 = 「읽을 수 없음」(`M02_Hopyo_Excel.DRM`).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import re
|
||
import warnings
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||
|
||
SHEETS = ("설계내역서", "내역서", "착공내역서", "설계변경내역서")
|
||
HEADS = {
|
||
"공종": "공종",
|
||
"명칭": "품명",
|
||
"품명": "품명",
|
||
"규격": "규격",
|
||
"수량": "수량",
|
||
"단위": "단위",
|
||
"합계": "합계",
|
||
"노무비": "노무비",
|
||
"재료비": "재료비",
|
||
"경비": "경비",
|
||
"비고": "비고",
|
||
}
|
||
PARTS = (("노무비", "labor"), ("재료비", "material"), ("경비", "expense"))
|
||
_CODE = re.compile(r"^([A-Z]+\d*)_(\d+)$")
|
||
_ROMAN = re.compile(r"^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+$|^[IVX]+\.?$")
|
||
_ST_NOTE = re.compile(r"^(대가|산근|자재)(\d+)")
|
||
_EST_NOTE = re.compile(r"^(단산|일위)(\d+)")
|
||
NO_SHEET = "내역서 시트(설계내역서 · 내역서) 없음"
|
||
|
||
|
||
class BillExcelError(ValueError):
|
||
"""못 읽는 파일 — 길이 400 으로 답함."""
|
||
|
||
|
||
def _sheet(name: str, data: bytes) -> tuple[str, list[list[Any]]]:
|
||
if not data:
|
||
raise BillExcelError("빈 파일")
|
||
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"):
|
||
raise BillExcelError(f"읽을 수 없음 — {hx.DRM}")
|
||
try:
|
||
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
|
||
import openpyxl
|
||
|
||
with warnings.catch_warnings(): # 인쇄 영역 이름 경고(STmate) — 읽기와 무관
|
||
warnings.simplefilter("ignore")
|
||
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||
titles = {ws.title.strip(): ws for ws in book.worksheets}
|
||
pick = next((t for t in SHEETS if t in titles), None)
|
||
rows = [list(r) for r in titles[pick].iter_rows(values_only=True)] if pick else []
|
||
book.close()
|
||
else:
|
||
import xlrd
|
||
|
||
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
|
||
titles = {sh.name.strip(): sh for sh in book.sheets()}
|
||
pick = next((t for t in SHEETS if t in titles), None)
|
||
sh = titles.get(pick or "")
|
||
rows = [sh.row_values(r) for r in range(sh.nrows)] if sh else []
|
||
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
|
||
raise BillExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error
|
||
if pick is None:
|
||
raise BillExcelError(NO_SHEET)
|
||
return pick, rows
|
||
|
||
|
||
def _columns(rows: list[list[Any]]) -> tuple[int, dict[str, int]]:
|
||
for at, row in enumerate(rows[:10]):
|
||
flat = [hx._flat(v) for v in row]
|
||
if "공종" not in flat or not ({"명칭", "품명"} & set(flat)):
|
||
continue
|
||
cols: dict[str, int] = {}
|
||
for col, head in enumerate(flat):
|
||
if head in HEADS and HEADS[head] not in cols:
|
||
cols[HEADS[head]] = col
|
||
below = [hx._flat(v) for v in rows[at + 1]] if at + 1 < len(rows) else []
|
||
return (at + 2 if "단가" in below or "금액" in below else at + 1), cols
|
||
raise BillExcelError("내역서 머리 글(공종 · 명칭)을 못 찾음")
|
||
|
||
|
||
def _codes(row: list[Any], after: int) -> tuple[str, str] | None:
|
||
"""STmate 숨은 열 (줄 코드, 부모 코드) — 비고 뒤 「X_n」 꼴 첫 둘 ·
|
||
변경 차수(_2 뒤)면 ("", "")."""
|
||
got = [m for v in row[after + 1 :] if (m := _CODE.match(hx._text(v)))]
|
||
if not got:
|
||
return None
|
||
if got[0].group(2) != "1":
|
||
return "", ""
|
||
return got[0].group(1), (got[1].group(1) if len(got) > 1 else "")
|
||
|
||
|
||
def _depth(head: str) -> int:
|
||
if _ROMAN.match(head):
|
||
return 1
|
||
parts = [p for p in head.split(".") if p.strip()]
|
||
return len(parts) + 1 if parts and all(p.strip().isdigit() for p in parts) else 1
|
||
|
||
|
||
def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decimal]:
|
||
got = hx._money(row, cols, offset)
|
||
return {key: got.get(head) or Decimal(0) for head, key in PARTS}
|
||
|
||
|
||
def _hopyo(note: str, code: str) -> dict[str, Any] | None:
|
||
flat = hx._flat(note)
|
||
if hit := _ST_NOTE.match(flat):
|
||
return {"종류": hit.group(1), "번": int(hit.group(2)), "코드": code}
|
||
if hit := _EST_NOTE.match(flat):
|
||
return {"종류": "산근" if hit.group(1) == "단산" else "대가", "번": int(hit.group(2))}
|
||
if code[:1] in ("B", "D", "M"):
|
||
return {"종류": {"B": "대가", "D": "산근", "M": "자재"}[code[0]], "코드": code}
|
||
return None
|
||
|
||
|
||
def read_bill(name: str, data: bytes) -> dict[str, Any]:
|
||
"""엑셀 → `{꼴, 시트, 줄 [{행, 묶음, 위, 공종, 이름, 규격, 단위, 수량, 단가 {labor, material,
|
||
expense}, 금액 {…}, 비고, 호표, 제외}], 합계 {직접, 제외}, 조달수수료율, 합계줄, 율줄}` —
|
||
`위` = 부모 줄 행(None = 맨 위) · 수 = JSON 수."""
|
||
title, rows = _sheet(name, data)
|
||
start, cols = _columns(rows)
|
||
lines: list[dict[str, Any]] = []
|
||
stack: list[tuple[Any, dict[str, Any]]] = [] # (코드 | 깊이, 묶음 줄)
|
||
stmate, skipped, fee, pct_lines = False, 0, None, 0
|
||
for at, row in enumerate(rows[start:], start + 1):
|
||
cell = lambda h: hx._cell(row, cols.get(h)) # noqa: E731
|
||
head, item_name = hx._text(cell("공종")), hx._text(cell("품명"))
|
||
if not item_name:
|
||
continue
|
||
qty = hx._num(cell("수량"))
|
||
codes = _codes(row, cols.get("비고", 13))
|
||
stmate = stmate or codes is not None
|
||
if codes == ("", ""):
|
||
continue
|
||
if codes:
|
||
own, parent = codes
|
||
group = own[:1] in ("E", "C")
|
||
at_up = next((n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == parent), None)
|
||
stack = stack[: at_up + 1] if at_up is not None else []
|
||
else:
|
||
own = ""
|
||
group = bool(head) and qty is None
|
||
if not group and qty is None:
|
||
skipped += 1 # 「순 공 사 비」 같은 합계 줄
|
||
continue
|
||
if group:
|
||
depth = _depth(head)
|
||
while stack and stack[-1][0] >= depth:
|
||
stack.pop()
|
||
up = stack[-1][1] if stack else None
|
||
note = hx._text(cell("비고"))
|
||
line = {
|
||
"행": at,
|
||
"묶음": group,
|
||
"위": up["행"] if up else None,
|
||
"공종": head,
|
||
"이름": item_name,
|
||
"규격": hx._text(cell("규격")),
|
||
"단위": hx._text(cell("단위")),
|
||
"수량": qty,
|
||
"단가": _money(row, cols, 0),
|
||
"금액": _money(row, cols, 1),
|
||
"비고": note,
|
||
"호표": None if group else _hopyo(note, own),
|
||
"제외": (up or {}).get("제외", False)
|
||
or (up is None and (own[:1] == "C" or "제외" in note or "관급" in item_name)),
|
||
}
|
||
if group:
|
||
stack.append((own if codes else _depth(head), line))
|
||
elif hx._flat(line["단위"]) == "%":
|
||
if line["제외"] and "수수료" in item_name:
|
||
fee = fee if fee is not None else qty
|
||
continue
|
||
pct_lines += 1
|
||
line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"})
|
||
line["규격"] = " ".join(t for t in (line["규격"], f"({hx._out(qty)} %)") if t)
|
||
lines.append(line)
|
||
_unwrap(lines)
|
||
total = {"직접": Decimal(0), "제외": Decimal(0)}
|
||
for line in lines:
|
||
if not line["묶음"]:
|
||
total["제외" if line["제외"] else "직접"] += sum(line["금액"].values())
|
||
return _plain(
|
||
{
|
||
"꼴": "STmate" if stmate else "EST",
|
||
"시트": title,
|
||
"줄": lines,
|
||
"합계": total,
|
||
"조달수수료율": fee,
|
||
"합계줄": skipped,
|
||
"율줄": pct_lines,
|
||
}
|
||
)
|
||
|
||
|
||
def _unwrap(lines: list[dict[str, Any]]) -> None:
|
||
"""직접 맨 위 묶음이 하나뿐이면(공사 이름) 벗김 — 자식이 맨 위로(되풀이)."""
|
||
while True:
|
||
tops = [x for x in lines if x["위"] is None and not x["제외"]]
|
||
if len(tops) != 1 or not tops[0]["묶음"]:
|
||
return
|
||
wrap = tops[0]["행"]
|
||
lines.remove(tops[0])
|
||
for line in lines:
|
||
if line["위"] == wrap:
|
||
line["위"] = None
|
||
|
||
|
||
def _plain(value: Any) -> Any:
|
||
if isinstance(value, Decimal):
|
||
return hx._out(value)
|
||
if isinstance(value, dict):
|
||
return {k: _plain(v) for k, v in value.items()}
|
||
if isinstance(value, list):
|
||
return [_plain(v) for v in value]
|
||
return value
|