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>
This commit is contained in:
2026-10-31 15:43:51 +09:00
co-authored by Claude Opus 5.5
parent df5da11b37
commit c0ea73501f
8 changed files with 910 additions and 8 deletions
+238
View File
@@ -0,0 +1,238 @@
"""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