Files
Aislo/B09_Estimation/B09_Estimation_Bill_Excel.py
T
eomsangdonandClaude Opus 5.5 c0ea73501f feat(B09 설계내역서): 경쟁사 내역 엑셀 줄 불러오기 서버 — 읽기 · 짜기 · 고정 단가 칸 (PLAN 707-1)
- 읽기(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>
2026-10-31 15:43:51 +09:00

239 lines
10 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.
"""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