- B09_Estimation_Stc — DBF 필드 이름으로 읽음 · 묶음 T · 소계 E · 제외 C · 호표 번 = 같은 글자 코드 정렬 차례 - 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · BDQTY · COSTN.SMT · 요율 = UNKNOWN(같은 공사 엑셀로) - Router_Import 이름 끝 갈림(.estx · .stc) · 미리 보기 알림 · UI_Import 파일 고르기 *.stc · 창 알림 줄 - 시험 test_707_6_stc 16 — 실무 STC 일곱 직접비 = PRJLST · 기번3 · 영월 · 봉화 엑셀과 줄마다 같음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""B09 STmate 원본(.STC) 바로 읽기(PLAN 707-6) — 메모리에서 풀어 평문 표만 · 저장 안 함.
|
|
|
|
.STC = 보통 ZIP · 표마다 dBase/FoxPro DBF(cp949 · 이름 대소문자 섞임)
|
|
(지식DB `original/원가계산/STmate/STC_컨테이너_구조.md` · `STC_필드사전_핵심.md`):
|
|
머리 4 레코드 수 · 8 머리 길이 · 10 레코드 길이 · 32 부터 필드 설명자 32 바이트씩(0x0D 끝) ·
|
|
필드 = 이름으로만 찾음(위치는 판마다 다름) · 레코드 첫 바이트 `*` = 지운 줄.
|
|
접미 1 = 당초 · 2~4 = 변경 차수 — 당초만 읽음.
|
|
|
|
돌려주는 꼴 = 엑셀 · .estx 읽기와 같은 dict:
|
|
`read_bill` = `B09_Estimation_Bill_Excel.read_bill`(707-1) — BOQ11
|
|
BDCODE1 T1~T5 = 묶음(클수록 바깥) · E1~E5 = 소계 · 합계(그 깊이 묶음 닫음 · 안 읽음) ·
|
|
C1~C5 = 제외금액 합계(닫는 묶음 = 제외 · 관급 등) · `:` = 페이지 넘김 · 이름 없음 = 빈 줄 ·
|
|
단위 끝 `%` = 율 줄(제외 안 「수수료」 = 조달수수료율 · 그 밖 = 1 식 · 단가 = 금액) ·
|
|
호표 = 코드 B 대가 · D 산근 · M 자재 · 번 = COSTN 같은 글자 코드 정렬 차례
|
|
(STmate 엑셀 비고 「대가 5호표」 · 「자재 28」 과 같음 — 봉화 · 기번3 실측) ·
|
|
`알림` = 다공구(BOQ12 ~) · 변경 차수 줄 수(안 읽음)
|
|
`read_prices` = `B09_Estimation_Import_Prices.read_prices`(707-3) — COSTN
|
|
L 노임 · M 자재(+ 출처 다섯 칸 AMT_11~51 · 쪽 PG_11~51 · 이름 = Start8x.Ini JMOK1~5) ·
|
|
X 기계(시간당 노 · 재 · 경 · 단위 「X」 → 시간)
|
|
`read_hopyo` = 호표 목록(COSTN B 대가 · D 산근 — 코드 · 번 · 이름 · 단가 셋)
|
|
안 읽음(같은 공사 STmate 엑셀 「일위대가표」 로 메움) = `UNKNOWN`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import struct
|
|
import zipfile
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, _plain, _unwrap
|
|
|
|
UNKNOWN = (
|
|
"일위대가 구성 수량(BDQTY — 난독)",
|
|
"단가산출 전개 글(COSTN.SMT — 메모 자리 규칙 미확정)",
|
|
"원가계산서 요율(PRJLST 는 금액만)",
|
|
)
|
|
#: 가격자료 다섯 칸 이름 — Start8x.Ini JMOK1~5 가 없을 때
|
|
SOURCES = ("조달가격", "물가자료", "물가정보", "거래가격", "견적단가")
|
|
HOPYO = {"B": "대가", "D": "산근", "M": "자재"}
|
|
PART_KEYS = ("labor", "material", "expense")
|
|
|
|
|
|
class StcError(BillExcelError):
|
|
"""못 읽는 .STC — 길이 400 으로 답함."""
|
|
|
|
|
|
def _zip(data: bytes) -> tuple[zipfile.ZipFile, dict[str, str]]:
|
|
if data[:4] != b"PK\x03\x04":
|
|
raise StcError(".STC 가 아님 — ZIP 머리 없음")
|
|
try:
|
|
book = zipfile.ZipFile(io.BytesIO(data))
|
|
except zipfile.BadZipFile as error:
|
|
raise StcError("깨진 .STC") from error
|
|
if any(i.flag_bits & 1 for i in book.infolist()):
|
|
raise StcError("암호 걸린 .STC — 읽을 수 없음")
|
|
return book, {n.upper(): n for n in book.namelist()}
|
|
|
|
|
|
def table(book: tuple[zipfile.ZipFile, dict[str, str]], name: str) -> list[dict[str, Any]]:
|
|
"""`{name}.DBF` → 레코드 목록(파일 차례 · 지운 줄 뺌) — C 글 · N 수(Decimal) · L 참 거짓."""
|
|
zf, names = book
|
|
if f"{name}.DBF" not in names:
|
|
raise StcError(f".STC 안에 {name} 표가 없음")
|
|
d = zf.read(names[f"{name}.DBF"])
|
|
count, start, size = struct.unpack_from("<IHH", d, 4)
|
|
cols, at, pos = [], 32, 1
|
|
while at + 32 <= len(d) and d[at] != 0x0D:
|
|
field = d[at : at + 11].split(b"\0")[0].decode("ascii", "replace")
|
|
cols.append((field, chr(d[at + 11]), pos, d[at + 16]))
|
|
pos, at = pos + d[at + 16], at + 32
|
|
if start + count * size > len(d):
|
|
raise StcError(f"{name} 표 길이가 머리와 다름")
|
|
out = []
|
|
for r in range(count):
|
|
base = start + r * size
|
|
if d[base : base + 1] == b"*":
|
|
continue
|
|
row: dict[str, Any] = {}
|
|
for field, kind, off, width in cols:
|
|
raw = d[base + off : base + off + width]
|
|
if kind == "C":
|
|
row[field] = raw.decode("cp949", "replace").strip()
|
|
elif kind in "NF":
|
|
try:
|
|
row[field] = Decimal(raw.decode("ascii").strip() or 0)
|
|
except (InvalidOperation, UnicodeDecodeError):
|
|
row[field] = Decimal(0)
|
|
elif kind == "L":
|
|
row[field] = raw in (b"T", b"t", b"Y", b"y")
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def _parts(row: dict[str, Any], money: str) -> dict[str, Decimal]:
|
|
"""money = 'AMT1'(단가) · '_AMT1'(금액) → {labor, material, expense}."""
|
|
return {k: row.get(f"{p}{money}") or Decimal(0) for p, k in zip("LMS", PART_KEYS)}
|
|
|
|
|
|
def _numbers(costn: list[dict[str, Any]]) -> dict[str, int]:
|
|
"""대가 · 산근 · 자재 코드 → 번(같은 글자 코드 정렬 차례)."""
|
|
out = {}
|
|
for head in HOPYO:
|
|
for n, code in enumerate(sorted({r["CODE"] for r in costn if r["CODE"][:1] == head}), 1):
|
|
out[code] = n
|
|
return out
|
|
|
|
|
|
def _changes(rows: list[dict[str, Any]]) -> int:
|
|
"""변경 차수(2~4) 금액이 찬 줄 수 — 수량 칸만 베낀 당초 판은 0."""
|
|
return sum(any(r.get(f"T_AMT{k}") for k in "234") for r in rows)
|
|
|
|
|
|
def read_bill(name: str, data: bytes) -> dict[str, Any]:
|
|
""".STC → 엑셀 `read_bill` 과 같은 꼴(`행` = BOQ11 차례 1~)."""
|
|
book = _zip(data)
|
|
rows = table(book, "BOQ11")
|
|
numbers = _numbers(table(book, "COSTN"))
|
|
lines: list[dict[str, Any]] = []
|
|
stack: list[tuple[int, dict[str, Any]]] = [] # (깊이, 묶음 줄)
|
|
skipped = 0
|
|
for at, row in enumerate(rows, 1):
|
|
code, item_name = row.get("BDCODE1") or "", row.get("NAME1") or ""
|
|
kind = code[:1] if len(code) == 2 and code[1:].isdigit() else ""
|
|
if kind in ("E", "C"): # 소계 · 합계 — 그 깊이 묶음 닫음 · C = 제외
|
|
skipped += 1
|
|
hit = next(
|
|
(n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == int(code[1])), None
|
|
)
|
|
if hit is not None:
|
|
stack[hit][1]["제외"] = stack[hit][1]["제외"] or kind == "C"
|
|
del stack[hit:]
|
|
continue
|
|
if not item_name or code == ":":
|
|
continue
|
|
group = kind == "T"
|
|
if group:
|
|
while stack and stack[-1][0] <= int(code[1]):
|
|
stack.pop()
|
|
up = stack[-1][1] if stack else None
|
|
hopyo = None
|
|
if not group and code[:1] in HOPYO:
|
|
hopyo = {"종류": HOPYO[code[0]], "코드": code}
|
|
if code in numbers:
|
|
hopyo["번"] = numbers[code]
|
|
line = {
|
|
"행": at,
|
|
"묶음": group,
|
|
"위": up["행"] if up else None,
|
|
"공종": (row.get("ITNUM") or "") if group else "",
|
|
"이름": item_name,
|
|
"규격": row.get("SIZE1") or "",
|
|
"단위": row.get("UNIT1") or "",
|
|
"수량": None if group else row.get("QTY1") or Decimal(0),
|
|
"단가": _parts(row, "AMT1"),
|
|
"금액": _parts(row, "_AMT1"),
|
|
"비고": "",
|
|
"호표": hopyo,
|
|
"제외": False,
|
|
}
|
|
if group:
|
|
stack.append((int(code[1]), line))
|
|
lines.append(line)
|
|
by_row = {x["행"]: x for x in lines}
|
|
out, fee, pct_lines = [], None, 0
|
|
for line in lines: # 부모가 앞 — 제외 물려받음 · 맨 위 「관급」
|
|
up = by_row.get(line["위"])
|
|
line["제외"] = line["제외"] or (up["제외"] if up else "관급" in line["이름"])
|
|
if not line["묶음"] and line["단위"].endswith("%"):
|
|
if line["제외"] and "수수료" in line["이름"]:
|
|
fee = fee if fee is not None else line["수량"]
|
|
continue
|
|
pct_lines += 1
|
|
qty = _plain(line["수량"])
|
|
line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"})
|
|
line["규격"] = " ".join(t for t in (line["규격"], f"({qty} %)") if t)
|
|
out.append(line)
|
|
_unwrap(out)
|
|
total = {"직접": Decimal(0), "제외": Decimal(0)}
|
|
for line in out:
|
|
if not line["묶음"]:
|
|
total["제외" if line["제외"] else "직접"] += sum(line["금액"].values())
|
|
notes = [
|
|
f"다공구 — {t[:-4]} {len(table(book, t[:-4]))} 줄 있음(당초 첫 공구 BOQ11 만 읽음)"
|
|
for t in sorted(book[1])
|
|
if t.startswith("BOQ1") and t.endswith(".DBF") and t != "BOQ11.DBF"
|
|
]
|
|
if changed := _changes(rows):
|
|
notes.append(f"변경 차수 — {changed} 줄 있음(당초만 읽음)")
|
|
return _plain(
|
|
{
|
|
"꼴": "stc",
|
|
"시트": "BOQ11",
|
|
"줄": out,
|
|
"합계": total,
|
|
"조달수수료율": fee,
|
|
"합계줄": skipped,
|
|
"율줄": pct_lines,
|
|
"알림": notes,
|
|
}
|
|
)
|
|
|
|
|
|
def _item(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {"name": row["NAME"], "spec": row.get("SIZE") or "", "unit": row.get("UNIT") or ""}
|
|
|
|
|
|
def _titles(book: tuple[zipfile.ZipFile, dict[str, str]]) -> tuple[str, ...]:
|
|
zf, names = book
|
|
if "START8X.INI" not in names:
|
|
return SOURCES
|
|
text = zf.read(names["START8X.INI"]).decode("cp949", "replace")
|
|
got = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
|
|
return tuple(got.get(f"JMOK{n}", "").strip() or SOURCES[n - 1] for n in range(1, 6))
|
|
|
|
|
|
def read_prices(name: str, data: bytes) -> dict[str, Any]:
|
|
""".STC → 707-3 `read_prices` 꼴 {labor, material, machine, *_sheet} ·
|
|
자재 줄 + `sources` [{출처, 단가, 쪽}](값 있는 칸만)."""
|
|
book = _zip(data)
|
|
titles = _titles(book)
|
|
costn = [
|
|
r for r in table(book, "COSTN") if r.get("NAME") and not (r.get("UNIT") or "").endswith("%")
|
|
]
|
|
labor = [{**_item(r), "price": r["TAMT1"]} for r in costn if r["CODE"][:1] == "L"]
|
|
material = [
|
|
{
|
|
**_item(r),
|
|
"price": r["TAMT1"],
|
|
"sources": [
|
|
{"출처": titles[n - 1], "단가": r.get(f"AMT_{n}1"), "쪽": r.get(f"PG_{n}1") or ""}
|
|
for n in range(1, 6)
|
|
if r.get(f"AMT_{n}1")
|
|
],
|
|
}
|
|
for r in costn
|
|
if r["CODE"][:1] == "M"
|
|
]
|
|
machine = [
|
|
{**_item(r), "unit": "시간", **_parts(r, "AMT1")} for r in costn if r["CODE"][:1] == "X"
|
|
]
|
|
return _plain(
|
|
{
|
|
"labor": labor,
|
|
"labor_sheet": "COSTN L",
|
|
"material": material,
|
|
"material_sheet": "COSTN M",
|
|
"machine": machine,
|
|
"machine_sheet": "COSTN X",
|
|
}
|
|
)
|
|
|
|
|
|
def read_hopyo(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]:
|
|
""".STC → {대가: COSTN B, 산근: COSTN D} 목록
|
|
[{코드, 번, 이름, 규격, 단위, 단가 {labor, material, expense}, 합계}] — 구성 줄은 `UNKNOWN`."""
|
|
costn = table(_zip(data), "COSTN")
|
|
numbers = _numbers(costn)
|
|
out: dict[str, list[dict[str, Any]]] = {"대가": [], "산근": []}
|
|
for r in sorted(costn, key=lambda r: r["CODE"]):
|
|
if r["CODE"][:1] in "BD" and r.get("NAME"):
|
|
out[HOPYO[r["CODE"][0]]].append(
|
|
{
|
|
"코드": r["CODE"],
|
|
"번": numbers[r["CODE"]],
|
|
"이름": r["NAME"],
|
|
"규격": r.get("SIZE") or "",
|
|
"단위": r.get("UNIT") or "",
|
|
"단가": _parts(r, "AMT1"),
|
|
"합계": r.get("TAMT1") or Decimal(0),
|
|
}
|
|
)
|
|
return _plain(out)
|