251 lines
10 KiB
Python
251 lines
10 KiB
Python
# -*- 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}
|