- ㉯ STC 일위 = HP 호표(엑셀 일위대가표 길과 같은 꼴 · 「일위대가표」 시트) → 목록 · 만들기 · 고를 재료 판(구성 줄 · 「일위대가 N」) - ㉰ 구성표 중기 → 기계구성(엑셀 중기사용료와 같은 값 · 달러 손료 = 천원) · 같은 파일 구성 대조 같은구성 → STmate 기계 찾기 - ㉱ 낙찰률 줄 2 칸(원단가) = 구성 셈 · 일위 머리만(엑셀 일위대가표와 같음) · 산근 · 자원 = 1 칸 그대로(엑셀 단가산출근거와 같음 · 812-1 짝 0) - 별도계상 = 자재 이름 · 규격 「별도계상」 · 「별산」(엑셀 비고 「별산…」 짝 · 828-2 대조) - MyMachine 엑셀 중기사용료 잡품 줄 = 잡재료 · 잡재료비 이름도(원파일과 같은 값) - 시험 test_828_1_stc_composition 새로 · test_814_1 낡은 칸 고침(원파일 호표 0 → 45 · 격자 일위대가표 시트) · test_810_1 거울 대조 글 칸 앞 빈칸 걷음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
11 KiB
Python
248 lines
11 KiB
Python
"""경쟁사 STmate 원파일(.stc) 파서(PLAN 810-1 · 요구 705 · 706 · 읽기만 · 서버 단독).
|
|
|
|
거울 = `tools/stmate-test/smt_parser.py`(`parse_dbf_bytes` · `extract_costn_memos` — 시험
|
|
`test_810_1_stc.py` 가 행 · 칸 · 원문 1:1 대조) · 글 문법 = `M02_San_Stmate.parse_line` 재사용 ·
|
|
칸 뜻 = `resources/master_data/_원파일파서_틀.md`.
|
|
|
|
.stc = ZIP · COSTN.DBF(자원 · 산근 머리 · 저장 금액) · COSTN.SMT(SIxMemo · 산근 원문 · IFORM =
|
|
<HII 꼬리표 · 길이 · 블록 번호>) · BOQ11.DBF(내역).
|
|
BDQTY.DBF(일위대가 · 중기 구성) = 828-1 `M02_San_Bdqty`(믿을 칸 · 스스로 검사 · 어긋나면 물러남 = 일위 줄 없음 +
|
|
표지) → `M02_San_Ilwi`(일위 구성 줄 · 다시 셈 · 중기 구성 `기계구성` · 같은 파일 구성 대조 `같은구성`).
|
|
SYSINFO = 난독화 → 표지. 금액 칸 = 차수 1(산근 · 자원 = 엑셀 단가산출근거와 같음) · 낙찰률 줄(NAGCD > 0) 2 칸 =
|
|
`원단가`(828-1 ㉱ — 구성 셈 · 일위 머리 = 엑셀 일위대가표와 같음 · 2~4 차 수량은 못 읽음).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import re
|
|
import struct
|
|
import zipfile
|
|
from typing import Any
|
|
|
|
from M02_MasterTemplete import M02_San_Eval as ev
|
|
from M02_MasterTemplete import M02_San_Stmate as stm
|
|
|
|
#: 코드 머리 글자 → 종류
|
|
KIND = {"L": "노임", "M": "자재", "S": "경비", "E": "기계", "X": "기계", "D": "산근", "B": "일위"}
|
|
#: 금액 칸(차수 1) → 비목 글자
|
|
AMT = {"TAMT1": "T", "LAMT1": "L", "MAMT1": "M", "SAMT1": "S"}
|
|
AMT2 = {"TAMT2": "T", "LAMT2": "L", "MAMT2": "M", "SAMT2": "S"} # 낙찰률 줄 원단가(828-1 ㉱)
|
|
BOQ_AMT = {"T_AMT1": "T", "L_AMT1": "L", "M_AMT1": "M", "S_AMT1": "S"}
|
|
#: 엑셀 숨은 J 열 메타 이름 ← COSTN 칸(`M02_San_Eval.stmate_block` 이 읽음)
|
|
META = {"UNT": "UNIT", "UC": "UC", "NAG": "NAGCD", "SBM": "SBMOK", "ADJ": "S_ADJ"}
|
|
UNREAD = ("BDQTY.DBF 난독화 — 일위대가 구성 줄 못 읽음", "SYSINFO.DBF 난독화")
|
|
FALLBACK = "BDQTY 구성표 안 씀 — " # + 까닭(828-1 스스로 검사)
|
|
|
|
|
|
class StcError(ValueError):
|
|
"""못 읽는 .stc."""
|
|
|
|
|
|
def dbf(b: bytes) -> list[dict[str, Any]]:
|
|
"""dBASE → 지워지지 않은 행(글 = CP949 strip · IFORM = 바이트 그대로 · `_recno` 파일 차례)."""
|
|
if len(b) < 32:
|
|
raise StcError("DBF 헤더 길이 부족")
|
|
nrec, hlen, rlen = struct.unpack_from("<IHH", b, 4)
|
|
fields, off = [], 32
|
|
while off < len(b) and b[off] != 0x0D:
|
|
name = b[off : off + 11].split(b"\0", 1)[0].decode("cp949", "replace").strip()
|
|
fields.append((name, b[off + 16]))
|
|
off += 32
|
|
rows = []
|
|
for i in range(nrec):
|
|
pos = hlen + i * rlen
|
|
if pos + rlen > len(b):
|
|
break
|
|
rec = b[pos : pos + rlen]
|
|
if rec[:1] == b"*":
|
|
continue
|
|
row: dict[str, Any] = {"_recno": i}
|
|
cur = 1
|
|
for name, width in fields:
|
|
raw = rec[cur : cur + width]
|
|
cur += width
|
|
row[name] = raw if name == "IFORM" else raw.decode("cp949", "replace").strip()
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def memo(iform: bytes, smt: bytes) -> str | None:
|
|
"""IFORM(<HII 꼬리표 · 길이 · 블록) → COSTN.SMT 원문 · 없음 = None."""
|
|
if not smt or smt[8:15] != b"SIxMemo" or len(iform) < 10:
|
|
return None
|
|
if not any(c not in (0, 0x20) for c in iform):
|
|
return None
|
|
_, length, block = struct.unpack("<HII", iform[:10])
|
|
start = block * (struct.unpack("<I", smt[4:8])[0] or 64)
|
|
if length <= 0 or start >= len(smt):
|
|
return None
|
|
return smt[start : start + length].rstrip(b"\0").decode("cp949", "replace")
|
|
|
|
|
|
def _f(s: str | None) -> float:
|
|
try:
|
|
return float(s or 0)
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
|
|
def _amt(row: dict[str, Any], names: dict[str, str]) -> dict[str, float]:
|
|
return {k: _f(row.get(f)) for f, k in names.items()}
|
|
|
|
|
|
def _zip(data: bytes) -> tuple[zipfile.ZipFile, dict[str, str]]:
|
|
if data[:4] != b"PK\x03\x04":
|
|
raise StcError(".stc 가 아님 — ZIP 머리 없음")
|
|
try:
|
|
z = zipfile.ZipFile(io.BytesIO(data))
|
|
except zipfile.BadZipFile as error:
|
|
raise StcError("깨진 .stc") from error
|
|
names = {i.filename.upper(): i.filename for i in z.infolist()}
|
|
if "COSTN.DBF" not in names:
|
|
raise StcError(".stc 안에 COSTN.DBF 없음")
|
|
return z, names
|
|
|
|
|
|
def stc_line(at: int, raw: str, res: dict[str, dict], tags: list[str]) -> dict[str, Any]:
|
|
"""원문 한 줄 → 구조 줄(풀이 = `M02_San_Stmate.parse_line` 나무 그대로)."""
|
|
node = stm.parse_line(raw)
|
|
kind = node["종류"]
|
|
src = node.get("자원")
|
|
res_line = None
|
|
if src:
|
|
code = src["코드"]
|
|
head = res.get(code)
|
|
if head is None:
|
|
tags.append(f"자원 못 찾음 {code}")
|
|
res_line = {
|
|
"코드": code,
|
|
"종류": KIND.get(code[:1]),
|
|
"비목": src["비목"],
|
|
"옮김": src["옮김"],
|
|
**{k: (head or {}).get(k) for k in ("이름", "규격", "단위", "저장")},
|
|
}
|
|
rate = node.get("율")
|
|
names = [p["이름"] for p in node.get("대입", []) if p["이름"]]
|
|
code = res_line["코드"] if res_line else ""
|
|
return {
|
|
"행": at,
|
|
"글": raw,
|
|
"보임": raw.strip(),
|
|
"품명": None,
|
|
"규격": None,
|
|
"단위": None,
|
|
"단": node.get("단"),
|
|
"연결": kind,
|
|
"연결뜻": kind,
|
|
"비목": (src or rate or {}).get("비목"),
|
|
"자원": res_line,
|
|
"연결단가": [],
|
|
"변수": names,
|
|
"줄참조": (rate or {}).get("번지", []) + ([node["번지"]] if node.get("번지") else []),
|
|
"결과": [],
|
|
"합계": kind == "계",
|
|
"자리": {"자리수": None, "방법": None},
|
|
"표지": "율" if kind == "율" else "합계" if kind == "계" else None,
|
|
"기계": code if KIND.get(code[:1]) == "기계" else None,
|
|
"산근": code if code[:1] == "D" else None,
|
|
"저장": None,
|
|
"풀이": node,
|
|
}
|
|
|
|
|
|
def read(data: bytes) -> dict[str, Any]:
|
|
""".stc → {꼴, 산근, 일위, 자원, 내역, 단가(셈용), 표지}."""
|
|
z, names = _zip(data)
|
|
costn = dbf(z.read(names["COSTN.DBF"]))
|
|
smt = z.read(names["COSTN.SMT"]) if "COSTN.SMT" in names else b""
|
|
res, prices = {}, {}
|
|
for r in costn:
|
|
code = r.get("CODE") or ""
|
|
if not code:
|
|
continue
|
|
saved = _amt(r, AMT)
|
|
res[code] = {
|
|
"종류": KIND.get(code[:1]),
|
|
"이름": r.get("NAME"),
|
|
"규격": r.get("SIZE"),
|
|
"단위": r.get("UNIT"),
|
|
"저장": saved,
|
|
}
|
|
if _f(r.get("NAGCD")) > 0: # 낙찰률 줄 원단가(828-1 ㉱ — 구성 셈 · 일위 머리)
|
|
res[code]["원단가"] = _amt(r, AMT2)
|
|
prices[code] = {**saved, "meta": {k: r.get(f) for k, f in META.items()}}
|
|
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
|
|
ini = z.read(names["START8X.INI"]).decode("cp949", "replace") if "START8X.INI" in names else ""
|
|
out["옵션"] = dict(re.findall(r"^\s*(\w+)\s*=[ \t]*(.*?)\s*$", ini, re.M | re.A))
|
|
comp, why = _composition(z, names, costn, out)
|
|
for r in costn:
|
|
code, text = r.get("CODE") or "", memo(r.get("IFORM") or b"", smt)
|
|
if not code or (text is None and code[:1] != "B"):
|
|
continue
|
|
tags: list[str] = []
|
|
head = {k: res[code][k] for k in ("이름", "규격", "단위", "저장")}
|
|
if text is None and code in comp: # 일위 머리 = 원단가(엑셀 일위대가표와 같음)
|
|
head["저장"] = res[code].get("원단가") or head["저장"]
|
|
out["일위"].append({"코드": code, **head, **comp[code]})
|
|
continue
|
|
if text is None:
|
|
mark = ["BDQTY 구성 행 없음"] if why is None else [UNREAD[0], FALLBACK + why]
|
|
out["일위"].append({"코드": code, **head, "줄": [], "줄합": None, "표지": mark})
|
|
continue
|
|
lines = [stc_line(i, ln, res, tags) for i, ln in enumerate(text.splitlines(), 1)]
|
|
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
|
|
blk = {"코드": code, **head, "줄": lines, "줄합": tot, "표지": tags}
|
|
out["일위" if code[:1] == "B" else "산근"].append(blk)
|
|
out["내역"] = None
|
|
if "BOQ11.DBF" in names:
|
|
out["내역"] = [
|
|
{
|
|
"번": r.get("ITNUM"),
|
|
"이름": r.get("NAME1"),
|
|
"규격": r.get("SIZE1"),
|
|
"단위": r.get("UNIT1"),
|
|
"수량": _f(r.get("QTY1")),
|
|
"코드": r.get("BDCODE1"),
|
|
"저장": _amt(r, BOQ_AMT),
|
|
}
|
|
for r in dbf(z.read(names["BOQ11.DBF"]))
|
|
]
|
|
odd = sorted({c[:1] for c, v in res.items() if v["종류"] is None})
|
|
read_ok = "BDQTY.DBF 구성표 읽음 — 글자 칸 · 2~4 차 수량은 못 읽음(이름 · 규격 = 자원 표)"
|
|
out["표지"] = [
|
|
*([read_ok] if why is None else [UNREAD[0], FALLBACK + why]),
|
|
UNREAD[1],
|
|
*out.pop("구성표지", []),
|
|
"금액 = 차수 1 칸(TAMT1 · LAMT1 · MAMT1 · SAMT1) · 일위 머리 · 구성 셈의 낙찰률 줄 = 2 칸(원단가)",
|
|
"블록 끝전 갈래 = 옵션 SAN_TYP 후보(뜻 미확정)",
|
|
*(f"모르는 코드 머리 {c}" for c in odd),
|
|
]
|
|
return out
|
|
|
|
|
|
def _composition(
|
|
z: zipfile.ZipFile, names: dict, costn: list[dict], out: dict
|
|
) -> tuple[dict, str | None]:
|
|
"""BDQTY 구성표 → ({일위 코드: {줄, 줄합, 표지}}, 안 쓴 까닭 | None) · 중기 구성 = `out["기계구성"]` ·
|
|
같은 파일 구성 대조 = `out["단가"][X]["같은구성"]`(816-2 `M02_Machine_Find.peers`)."""
|
|
from M02_MasterTemplete import M02_Machine_Find as mfind
|
|
from M02_MasterTemplete import M02_San_Bdqty as bq
|
|
from M02_MasterTemplete import M02_San_Ilwi as il
|
|
|
|
if "BDQTY.DBF" not in names:
|
|
return {}, "BDQTY.DBF 없음"
|
|
try:
|
|
rows = bq.decode(z.read(names["BDQTY.DBF"]))
|
|
except Exception as e: # noqa: BLE001 — 못 읽음 = 물러남(오류 아님)
|
|
return {}, f"못 읽음 {type(e).__name__}"
|
|
if why := bq.check(rows, set(out["자원"])):
|
|
return {}, why
|
|
sbm = {r.get("CODE"): r.get("SBMOK") for r in costn}
|
|
blocks, makes, marks = il.compose(rows, out["자원"], out["옵션"], sbm)
|
|
out["기계구성"], out["구성표지"] = makes, marks
|
|
xs = {c: (m["이름"], m["규격"], il.peer_of(m)) for c, m in makes.items() if c[:1] == "X"}
|
|
for code, got in mfind.peers(xs).items():
|
|
out["단가"][code]["같은구성"] = got
|
|
return blocks, None
|