Files
Aislo/tools/stmate-test/conv_xls_boq.py
T

251 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.
# -*- coding: utf-8 -*-
"""엑셀 → 내역 불러오기 (wM_From_XLS · CONV_XLS_2_ST) — STMATE 가 내보낸 xlsx 의 「〓 INITIAL 〓」 메타로 내역 행 복원.
근거: XLSX_INITIAL_구조.md · 실무문서/_숨김탭분석.md §E · 24_모듈구조_전수.md §3(CONV_XLS_2_ST · XLS_2_BDREC) ·
16_골든셋_회귀_결과.md §4(SORTCODE↔ENDCODE 짝 · C* 제외 구역) · 34번 wKongJong(소계·계·합계·총계·총합계·총대계)
INITIAL 시트(veryHidden):
[Sheet정보] 목차줄마다 시트 이름 · 열 번호(ITNAME NAME SIZE QTY UNIT SPOS EPOS TAMT TAMOUNT MAMT MAMOUNT
LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT INFO) — 1부터 셈
[공사정보] [초기값] [인쇄설정] [PREPARE] — 키 · 값(여러 칸)
내역 시트 INFO 칸(숨은 열) = 0x07 `키|값 0x05 … — COD(코드) · QTYn|n(차수 줄) · EXI · ITT · END(그 묶음이 끝나는 엑셀 줄) ·
IPR · BLA · KWN · EQC · PRI · PRO · UNI · RCU · PSKP(이 줄 뒤 페이지 넘김)
복원 규칙(실무 4건 STC BOQ 와 대조 · resources/tester/test_stmate_conv_xls.py):
- 공종 머리 줄 COD = 닫는 코드(Ek · Ck) → 여는 행 Tk + 닫는 행 Ek/Ck(END 줄 앞에서 안쪽부터 닫음)
- 닫는 행 이름: Ek = 공종번호 칸에 단계 이름 · Ck = 명칭 칸에 「단계 이름(제외금액)」
- 여는 행·닫는 행 금액 = 머리 줄 금액(묶음 합) · SORTCODE = 256 × 차례 · 짝끼리 ENDCODE = 상대 SORTCODE
- PSKP 줄 뒤(그 자리 닫는 행 다음) ':' 페이지 넘김 행 · COD 빈 줄 = 빈 행
- 차수 줄(QTYn|n, n ≥ 2)은 바로 앞 행의 n차 칸
"""
from __future__ import annotations
import io
import re
import warnings
LEVEL_NAME = {1: "소계", 2: "계", 3: "합계", 4: "총계", 5: "총합계", 6: "총대계"}
COLS = (
"ITNAME NAME SIZE QTY UNIT SPOS EPOS TAMT TAMOUNT MAMT MAMOUNT "
"LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT INFO"
).split()
# INFO 키 → BOQ 칸
INFO_FIELD = {
"EQC": "EQCODE",
"PRI": "PRIN",
"PRO": "PROUT",
"UNI": "UNIT1",
"RCU": "RCU",
"KWN": "KWNGP",
"BLA": "BL_AMT",
"IPR": "PRPT", # 비율 줄(PR) 밑수 표시
}
INITIAL = "INITIAL"
def load_book(src):
"""경로 또는 바이트 → openpyxl 통합문서(계산된 값)."""
import openpyxl
with warnings.catch_warnings():
warnings.simplefilter("ignore")
if isinstance(src, (bytes, bytearray)):
return openpyxl.load_workbook(io.BytesIO(src), data_only=True)
return openpyxl.load_workbook(src, data_only=True)
def _unx(s: str) -> str:
return re.sub(r"_x([0-9A-Fa-f]{4})_", lambda m: chr(int(m.group(1), 16)), s)
def parse_info(s) -> dict[str, str]:
"""숨은 INFO 글 → {키: 값} (같은 키가 두 번이면 앞의 것)."""
out: dict[str, str] = {}
if not isinstance(s, str):
return out
for k, v in re.findall(r"`(\w+)\|([^\x05\x07`]*)", _unx(s)):
out.setdefault(k, v)
return out
def initial_sheet(wb):
return next((w for w in wb.worksheets if INITIAL in w.title), None)
def read_initial(wb) -> dict:
"""INITIAL 시트 → {sheets: {목차: {sheet, cols}}, sections: {이름: {키: [값…]}}}."""
ws = initial_sheet(wb)
if ws is None:
raise ValueError("〓 INITIAL 〓 시트 없음 — STMATE 가 내보낸 엑셀이 아님")
sheets, sections, sec = {}, {}, ""
for r in ws.iter_rows(values_only=True):
if len(r) < 2 or r[1] is None:
continue
key = str(r[1]).strip()
if key.startswith("[") and key.endswith("]"):
sec = key[1:-1]
continue
if sec == "Sheet정보":
if key == "목차" or not r[2]:
continue
vals = list(r[4:24]) + [None] * 20
cols = {c: int(v) for c, v in zip(COLS, vals) if isinstance(v, (int, float))}
sheets[key] = {"sheet": str(r[2]), "cols": cols}
elif sec:
sections.setdefault(sec, {})[key] = [v for v in r[2:] if v is not None]
return {"sheets": sheets, "sections": sections}
def find_sheet(info: dict, kind: str) -> dict | None:
"""목차 이름 끝말(예 '내역서' · '일위대가표')로 시트 찾기 — '11. 내역서' ↔ '12. 총괄내역서' 가름."""
for k, v in info["sheets"].items():
if re.sub(r"^\d+\.\s*", "", k).strip() == kind:
return v
return None
def _cell(row: tuple, col: int | None):
if not col or col - 1 >= len(row):
return None
return row[col - 1]
def _txt(v) -> str:
return "" if v is None else str(v).rstrip()
def _numv(v) -> float:
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def _amounts(row: tuple, cols: dict, n: int) -> dict:
"""한 줄의 단가·금액 → n차 칸."""
out = {}
for c in "TMLS":
out[f"{c}AMT{n}"] = _numv(_cell(row, cols.get(f"{c}AMT")))
out[f"{c}_AMT{n}"] = _numv(_cell(row, cols.get(f"{c}AMOUNT")))
return out
LABELS = {"공종": "ITNAME", "명칭": "NAME", "규격": "SIZE", "수량": "QTY", "단위": "UNIT"}
BIMOK = {"합계": "T", "노무비": "L", "재료비": "M", "경비": "S"}
def label_cols(ws, cols: dict) -> dict:
"""머리 줄 글(공종·명칭·…·합계/노무비/재료비/경비 + 단가/금액)로 열 번호를 다시 잡음.
변경 내역서는 INITIAL 열 번호가 시트와 어긋남(영덕 변경 실측) — 글이 다 있으면 글 쪽을 씀."""
spos = cols.get("SPOS", 1)
found: dict = {}
for r in ws.iter_rows(min_row=max(1, spos - 3), max_row=max(1, spos - 1), values_only=True):
for j, v in enumerate(r, start=1):
t = re.sub(r"\s+", "", str(v or ""))
if t in LABELS:
found.setdefault(LABELS[t], j)
elif t in BIMOK:
found.setdefault(f"{BIMOK[t]}AMT", j)
found.setdefault(f"{BIMOK[t]}AMOUNT", j + 1)
if all(f"{c}AMT" in found for c in "TLMS") and "NAME" in found:
return {**cols, **found}
return cols
def boq_rows_from_sheet(ws, cols: dict) -> list[dict]:
"""내역 시트 → BOQ 행 목록(DBF 칸 이름 · 숫자는 float · 글은 str)."""
cols = label_cols(ws, cols)
spos, epos = cols.get("SPOS", 1), cols.get("EPOS", ws.max_row)
out: list[dict] = []
stack: list[dict] = [] # 열린 묶음 {code, end, head}
pskp = False
last = None
def close_until(line: int):
while stack and stack[-1]["end"] <= line:
g = stack.pop()
lev = int(g["code"][1:]) if g["code"][1:].isdigit() else 0
name = LEVEL_NAME.get(lev, "")
row = {"BDCODE1": g["code"], "EXIST1": 1 if g["code"][0] == "C" else 0}
row.update({k: v for k, v in g["head"].items() if "AMT" in k})
if g["code"][0] == "C":
row.update(ITNUM="", NAME1=f"{name}(제외금액)")
else:
row.update(ITNUM=name, NAME1="")
row["_pair"] = g["open"]
out[g["open"]]["_pair"] = len(out)
out.append(row)
for line, row in enumerate(
ws.iter_rows(min_row=spos, max_row=epos, values_only=True), start=spos
):
close_until(line)
if pskp:
out.append({"BDCODE1": ":", "NAME1": "페이지 넘김", "EXIST1": 0})
pskp = False
meta = parse_info(_cell(row, cols.get("INFO")))
if "COD" not in meta:
continue
slot = next((int(v) for k, v in meta.items() if re.fullmatch(r"QTY\d", k)), 1)
if slot >= 2 and last is not None: # 차수 줄 — 앞 행의 n차 칸
last[f"QTY{slot}"] = _numv(_cell(row, cols.get("QTY")))
last.update(_amounts(row, cols, slot))
continue
code = meta["COD"].strip()
base = {
"ITNUM": _txt(_cell(row, cols.get("ITNAME"))),
"NAME1": _txt(_cell(row, cols.get("NAME"))),
"SIZE1": _txt(_cell(row, cols.get("SIZE"))),
"UNIT1": _txt(_cell(row, cols.get("UNIT"))).strip(),
"QTY1": _numv(_cell(row, cols.get("QTY"))),
}
base.update(_amounts(row, cols, 1))
for k, f in INFO_FIELD.items():
if k in meta:
base[f] = meta[k]
if "END" in meta and re.fullmatch(r"[EC]\d", code): # 공종 머리
base.update(BDCODE1="T" + code[1:], EXIST1=1, QTY1=0.0)
stack.append({"code": code, "end": int(meta["END"]), "head": base, "open": len(out)})
out.append(base)
elif not code: # 빈 줄(설명 줄)
base.update(BDCODE1="", EXIST1=1)
out.append(base)
else:
base.update(BDCODE1=code, EXIST1=int(_numv(meta.get("EXI"))) if code == "PR" else 0)
out.append(base)
last = out[-1]
pskp = meta.get("PSKP") == "1"
close_until(10**9)
for i, r in enumerate(out):
r["SORTCODE"] = 256.0 * (i + 1)
for r in out:
r["ENDCODE"] = out[r["_pair"]]["SORTCODE"] if "_pair" in r else 0.0
r.pop("_pair", None)
return out
def fill_from_costn(rows: list[dict], costn_rows: list[dict]) -> int:
"""코드 행의 BQCODE · EQCODE · 단위 = 그 코드의 COSTN 값(실무 6표 전 행이 같음) — 채운 행 수.
엑셀 단위 칸은 'M%' 를 '%' 로 보이므로 COSTN 단위가 원래 값."""
cm = {str(r.get("CODE", "")).strip(): r for r in costn_rows or []}
n = 0
for r in rows:
c = cm.get(str(r.get("BDCODE1", "")).strip())
if not c:
continue
for f, g in (("BQCODE", "BQCODE"), ("EQCODE", "EQCODE"), ("UNIT1", "UNIT")):
if g in c:
r[f] = str(c[g]).strip()
n += 1
return n
def boq_from_xlsx(src, kind: str = "내역서") -> dict:
"""xlsx → {rows, sheet, info} — kind = 목차 이름(내역서 · 총괄내역서 …)."""
wb = load_book(src)
info = read_initial(wb)
spec = find_sheet(info, kind)
if not spec or spec["sheet"] not in wb.sheetnames:
raise ValueError(f"INITIAL 목차에 '{kind}' 시트 없음")
rows = boq_rows_from_sheet(wb[spec["sheet"]], spec["cols"])
return {"rows": rows, "sheet": spec["sheet"], "info": info}