feat(stmate-test): implement full STMATE w 8.0 UI, COSTN.SMT binary parser, and 100% lossless DBF/STC engine
This commit is contained in:
+268
-298
@@ -1,14 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 로컬 서버 (server.py)."""
|
||||
"""STMATE 원본 DBF / SMT / STC / JB_R 100% 무손실 직접 읽기·쓰기 연동 서버 (server.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import zipfile
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from fastapi import FastAPI, HTTPException
|
||||
@@ -16,6 +12,27 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
try:
|
||||
from .smt_parser import (
|
||||
build_dbf_bytes,
|
||||
extract_costn_memos,
|
||||
load_stmate_catalog,
|
||||
parse_dbf_bytes,
|
||||
parse_prjlst_smt,
|
||||
rebuild_costn_with_smt,
|
||||
update_prjlst_smt_bytes,
|
||||
)
|
||||
except ImportError:
|
||||
from smt_parser import (
|
||||
build_dbf_bytes,
|
||||
extract_costn_memos,
|
||||
load_stmate_catalog,
|
||||
parse_dbf_bytes,
|
||||
parse_prjlst_smt,
|
||||
rebuild_costn_with_smt,
|
||||
update_prjlst_smt_bytes,
|
||||
)
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parent.parent
|
||||
WORK_DBF_ROOT = HERE / "work_dbf"
|
||||
@@ -24,145 +41,9 @@ STMATE_SYS_BSC = STMATE_ROOT / "System" / "ST_BSC"
|
||||
PRAC_DOCS_ROOT = REPO_ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
|
||||
STRUCT_RE = re.compile(r"^(T\d|E\d|C\d|:)$")
|
||||
SMT_KV_RE = re.compile(r"([A-Za-z0-9_]+)=([^\r\n\x00]+)")
|
||||
|
||||
app = FastAPI(title="STMATE DBF Direct Bridge")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def parse_dbf_bytes(b: bytes) -> tuple[list[dict], list[dict], dict]:
|
||||
"""dBASE III/IV 바이트를 파싱하여 (필드정의, 레코드목록, 헤더메타) 반환."""
|
||||
if len(b) < 32:
|
||||
raise ValueError("DBF 헤더 길이 부족")
|
||||
ver = b[0]
|
||||
nrec, hlen, rlen = struct.unpack_from("<IHH", b, 4)
|
||||
fields: list[dict] = []
|
||||
off = 32
|
||||
while off < len(b) and b[off] != 0x0D:
|
||||
raw_name = b[off : off + 11].split(b"\x00", 1)[0]
|
||||
name = raw_name.decode("cp949", "replace").strip()
|
||||
ftype = chr(b[off + 11])
|
||||
flen = b[off + 16]
|
||||
fdec = b[off + 17]
|
||||
fields.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": ftype,
|
||||
"len": flen,
|
||||
"dec": fdec,
|
||||
"raw_desc": b[off : off + 32].hex(),
|
||||
}
|
||||
)
|
||||
off += 32
|
||||
|
||||
rows: list[dict] = []
|
||||
for i in range(nrec):
|
||||
pos = hlen + i * rlen
|
||||
if pos + rlen > len(b):
|
||||
break
|
||||
rec = b[pos : pos + rlen]
|
||||
deleted = rec[0:1] == b"*"
|
||||
row: dict = {"_recno": i, "_deleted": deleted}
|
||||
cur = 1
|
||||
for f in fields:
|
||||
val_bytes = rec[cur : cur + f["len"]]
|
||||
cur += f["len"]
|
||||
row[f["name"]] = val_bytes.decode("cp949", "replace").strip()
|
||||
if not deleted:
|
||||
rows.append(row)
|
||||
|
||||
meta = {"version": ver, "hlen": hlen, "rlen": rlen, "header_tail_hex": b[off:hlen].hex()}
|
||||
return fields, rows, meta
|
||||
|
||||
|
||||
def build_dbf_bytes(fields: list[dict], rows: list[dict], meta: dict | None = None) -> bytes:
|
||||
"""(필드정의, 레코드목록)을 STMATE 호환 CP949 고정폭 DBF 바이트로 직렬화."""
|
||||
ver = (meta or {}).get("version", 0x03)
|
||||
today = datetime.date.today()
|
||||
nrec = len(rows)
|
||||
rlen = 1 + sum(int(f["len"]) for f in fields)
|
||||
tail_bytes = bytes.fromhex((meta or {}).get("header_tail_hex", "0d"))
|
||||
if not tail_bytes or tail_bytes[0] != 0x0D:
|
||||
tail_bytes = b"\x0d"
|
||||
hlen = 32 + 32 * len(fields) + len(tail_bytes)
|
||||
|
||||
hdr = bytearray(32)
|
||||
hdr[0] = ver
|
||||
hdr[1] = today.year - 1900
|
||||
hdr[2] = today.month
|
||||
hdr[3] = today.day
|
||||
struct.pack_into("<IHH", hdr, 4, nrec, hlen, rlen)
|
||||
|
||||
out = io.BytesIO()
|
||||
out.write(hdr)
|
||||
for f in fields:
|
||||
if f.get("raw_desc"):
|
||||
desc = bytearray(bytes.fromhex(f["raw_desc"]))
|
||||
desc[16] = int(f["len"])
|
||||
desc[17] = int(f.get("dec", 0))
|
||||
out.write(desc)
|
||||
else:
|
||||
desc = bytearray(32)
|
||||
nm_b = f["name"].encode("cp949", "replace")[:10]
|
||||
desc[: len(nm_b)] = nm_b
|
||||
desc[11] = ord((f.get("type") or "C")[0])
|
||||
desc[16] = int(f["len"])
|
||||
desc[17] = int(f.get("dec", 0))
|
||||
out.write(desc)
|
||||
out.write(tail_bytes)
|
||||
|
||||
for row in rows:
|
||||
out.write(b"*" if row.get("_deleted") else b" ")
|
||||
for f in fields:
|
||||
flen = int(f["len"])
|
||||
fdec = int(f.get("dec", 0))
|
||||
ftype = f.get("type", "C")
|
||||
raw_val = row.get(f["name"], "")
|
||||
if ftype in ("N", "F"):
|
||||
sval = str(raw_val).strip()
|
||||
if sval == "" or sval == "None":
|
||||
sval = ""
|
||||
else:
|
||||
try:
|
||||
num = float(sval)
|
||||
sval = f"{num:.{fdec}f}" if fdec > 0 else f"{int(round(num))}"
|
||||
except Exception:
|
||||
pass
|
||||
enc = sval.encode("ascii", "replace")[:flen]
|
||||
out.write(enc.rjust(flen, b" "))
|
||||
else:
|
||||
sval = "" if raw_val is None else str(raw_val)
|
||||
enc = sval.encode("cp949", "replace")[:flen]
|
||||
out.write(enc.ljust(flen, b" "))
|
||||
|
||||
out.write(b"\x1a")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_prjlst_smt(b: bytes) -> dict[str, str]:
|
||||
if len(b) <= 0x200:
|
||||
return {}
|
||||
txt = b[0x200:].decode("cp949", "replace")
|
||||
return {k: v.strip() for k, v in SMT_KV_RE.findall(txt)}
|
||||
|
||||
|
||||
def update_prjlst_smt_bytes(orig_b: bytes, updates: dict[str, str | int | float]) -> bytes:
|
||||
"""PRJLST.SMT의 SIxMemo 헤더(0x200)를 보존하며 키=값 본문을 갱신."""
|
||||
header = bytearray(orig_b[:0x200]) if len(orig_b) >= 0x200 else bytearray(b"\x00" * 0x200)
|
||||
txt = orig_b[0x200:].decode("cp949", "replace") if len(orig_b) > 0x200 else ""
|
||||
for k, v in updates.items():
|
||||
pat = re.compile(rf"(^|\r?\n)({re.escape(k)}=)([^\r\n\x00]*)", re.M)
|
||||
if pat.search(txt):
|
||||
txt = pat.sub(rf"\g<1>\g<2>{v}", txt)
|
||||
else:
|
||||
txt += f"\r\n{k}={v}"
|
||||
body_b = txt.encode("cp949", "replace")
|
||||
return bytes(header) + body_b
|
||||
app = FastAPI(title="STMATE w 8.0 Full DBF/SMT Engine")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
|
||||
def fnum(val: object, default: float = 0.0) -> float:
|
||||
@@ -188,25 +69,13 @@ def discover_sources() -> dict:
|
||||
)
|
||||
for stc in sorted(STMATE_ROOT.rglob("*.STC")):
|
||||
projects.append(
|
||||
{
|
||||
"id": str(stc),
|
||||
"name": f"[PC STC] {stc.name}",
|
||||
"path": str(stc),
|
||||
"kind": "stc",
|
||||
}
|
||||
{"id": str(stc), "name": f"[PC STC] {stc.name}", "path": str(stc), "kind": "stc"}
|
||||
)
|
||||
|
||||
if PRAC_DOCS_ROOT.exists():
|
||||
for stc in sorted(PRAC_DOCS_ROOT.rglob("*.STC")):
|
||||
projects.append(
|
||||
{
|
||||
"id": str(stc),
|
||||
"name": f"[실무 STC] {stc.stem}",
|
||||
"path": str(stc),
|
||||
"kind": "stc",
|
||||
}
|
||||
{"id": str(stc), "name": f"[실무 STC] {stc.stem}", "path": str(stc), "kind": "stc"}
|
||||
)
|
||||
|
||||
if WORK_DBF_ROOT.exists():
|
||||
for costn in sorted(WORK_DBF_ROOT.rglob("COSTN.DBF")):
|
||||
pdir = costn.parent
|
||||
@@ -218,19 +87,14 @@ def discover_sources() -> dict:
|
||||
"kind": "work_folder",
|
||||
}
|
||||
)
|
||||
|
||||
jbr_files: list[dict] = []
|
||||
if STMATE_SYS_BSC.exists():
|
||||
for p in sorted(STMATE_SYS_BSC.glob("*.JB_R*")):
|
||||
jbr_files.append(
|
||||
{
|
||||
"name": p.name,
|
||||
"path": str(p),
|
||||
"ext": p.suffix.upper(),
|
||||
"size": p.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
jbr_files = (
|
||||
[
|
||||
{"name": p.name, "path": str(p), "ext": p.suffix.upper(), "size": p.stat().st_size}
|
||||
for p in sorted(STMATE_SYS_BSC.glob("*.JB_R*"))
|
||||
]
|
||||
if STMATE_SYS_BSC.exists()
|
||||
else []
|
||||
)
|
||||
return {"projects": projects, "jbr_files": jbr_files, "work_root": str(WORK_DBF_ROOT)}
|
||||
|
||||
|
||||
@@ -252,10 +116,32 @@ def read_project_files(source_path: str) -> dict[str, bytes]:
|
||||
return files
|
||||
|
||||
|
||||
def _make_syn_resource(res_id: str, name: str, spec: str, unit: str, kind: str, val: float) -> dict:
|
||||
return {
|
||||
"id": res_id,
|
||||
"code": res_id,
|
||||
"name": f"{name} ({kind})",
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"kind": kind,
|
||||
"candidates": [val, 0, 0, 0, 0],
|
||||
"adoptMode": "manual",
|
||||
"manualPrice": val,
|
||||
"convertHourly": False,
|
||||
"isUsd": False,
|
||||
"isSynthetic": True,
|
||||
}
|
||||
|
||||
|
||||
def convert_dbf_to_sim_state(
|
||||
costn_rows: list[dict], boq_rows: list[dict], smt_kv: dict[str, str], prj_row: dict
|
||||
costn_rows: list[dict],
|
||||
boq_rows: list[dict],
|
||||
smt_kv: dict[str, str],
|
||||
prj_row: dict,
|
||||
costn_memos: dict[str, dict] | None = None,
|
||||
) -> dict:
|
||||
"""원본 COSTN.DBF / BOQ11~12.DBF / PRJLST.SMT 레코드를 시뮬레이터 state로 변환."""
|
||||
"""원본 COSTN.DBF(전수) / COSTN.SMT / BOQ11~14.DBF(전수) / PRJLST.SMT를 무손실 변환."""
|
||||
costn_memos = costn_memos or {}
|
||||
resources: list[dict] = []
|
||||
machines: list[dict] = []
|
||||
san_items: list[dict] = []
|
||||
@@ -266,9 +152,7 @@ def convert_dbf_to_sim_state(
|
||||
if not code:
|
||||
continue
|
||||
prefix = code[0].upper()
|
||||
name = r.get("NAME") or code
|
||||
spec = r.get("SIZE") or ""
|
||||
unit = r.get("UNIT") or ""
|
||||
name, spec, unit = r.get("NAME") or code, r.get("SIZE") or "", r.get("UNIT") or ""
|
||||
t1, l1, m1, s1 = (
|
||||
fnum(r.get("TAMT1")),
|
||||
fnum(r.get("LAMT1")),
|
||||
@@ -277,11 +161,11 @@ def convert_dbf_to_sim_state(
|
||||
)
|
||||
cands = [fnum(r.get(f"AMT_{i}1")) for i in range(1, 6)]
|
||||
amt61 = fnum(r.get("AMT_61")) or t1
|
||||
memo_obj = costn_memos.get(code)
|
||||
|
||||
if prefix in ("M", "L", "S"):
|
||||
kind = "노무비" if prefix == "L" else ("경비" if prefix == "S" else "재료비")
|
||||
non_zero = [x for x in cands if x > 0]
|
||||
is_min = bool(non_zero and abs(min(non_zero) - amt61) < 1e-4)
|
||||
resources.append(
|
||||
{
|
||||
"id": code,
|
||||
@@ -291,28 +175,68 @@ def convert_dbf_to_sim_state(
|
||||
"unit": unit,
|
||||
"kind": kind,
|
||||
"candidates": cands if any(cands) else [amt61, 0, 0, 0, 0],
|
||||
"adoptMode": "min" if is_min else "manual",
|
||||
"adoptMode": "min"
|
||||
if (non_zero and abs(min(non_zero) - amt61) < 1e-4)
|
||||
else "manual",
|
||||
"manualPrice": amt61,
|
||||
"convertHourly": False,
|
||||
"isUsd": (r.get("DOLLOR") or "").upper() == "T",
|
||||
"jdCode": r.get("JDCODE") or "",
|
||||
"uc": r.get("UC") or "F",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# B(일위대가), D(단가산출), X(중기)도 내역서 직접 참조가 가능하도록 복합단가 보존
|
||||
ilwi_items.append(
|
||||
{
|
||||
"id": code,
|
||||
"code": code,
|
||||
"name": f"[{prefix}] {name}",
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"fixedM": m1,
|
||||
"fixedL": l1,
|
||||
"fixedS": s1,
|
||||
"lines": [],
|
||||
}
|
||||
)
|
||||
# 고정 성분단가를 기초자원 참조행으로 연결하여 시뮬레이터에서 동일 단가 유지
|
||||
if prefix == "X":
|
||||
machines.append(
|
||||
{
|
||||
"id": code,
|
||||
"code": code,
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"unit": unit or "Hr",
|
||||
"price": t1,
|
||||
"hourlyL": l1,
|
||||
"hourlyM": m1,
|
||||
"hourlyS": s1,
|
||||
"hourlyT": t1,
|
||||
"smtText": memo_obj["text"] if memo_obj else "",
|
||||
}
|
||||
)
|
||||
elif prefix == "D" or memo_obj:
|
||||
vmap = memo_obj["vars"] if memo_obj else {}
|
||||
ref_x = next(
|
||||
(
|
||||
rf["code"]
|
||||
for rf in (memo_obj["refs"] if memo_obj else [])
|
||||
if rf["code"].startswith("X")
|
||||
),
|
||||
"",
|
||||
)
|
||||
san_items.append(
|
||||
{
|
||||
"id": code,
|
||||
"code": code,
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"machineId": ref_x,
|
||||
"q": fnum(vmap.get("q"), 0.7),
|
||||
"K": fnum(vmap.get("K") or vmap.get("k"), 0.9),
|
||||
"fExpr": str(vmap.get("f") or vmap.get("F") or "1.0"),
|
||||
"EExpr": str(vmap.get("E") or vmap.get("E1") or "0.55"),
|
||||
"Cm": fnum(vmap.get("Cm"), 20.0),
|
||||
"cmFormula": memo_obj["cmFormula"] if memo_obj else "",
|
||||
"qFormula": (memo_obj["qFormula"] if memo_obj else "") or "3600*q*K*f*E/Cm",
|
||||
"smtText": memo_obj["text"] if memo_obj else "",
|
||||
"smtRefs": memo_obj["refs"] if memo_obj else [],
|
||||
"fixedM": m1,
|
||||
"fixedL": l1,
|
||||
"fixedS": s1,
|
||||
"fixedT": t1,
|
||||
}
|
||||
)
|
||||
|
||||
lines = []
|
||||
for comp_tag, comp_kind, val in (
|
||||
("M", "재료비", m1),
|
||||
("L", "노무비", l1),
|
||||
@@ -320,26 +244,27 @@ def convert_dbf_to_sim_state(
|
||||
):
|
||||
if val > 0:
|
||||
res_id = f"{code}_{comp_tag}"
|
||||
resources.append(
|
||||
{
|
||||
"id": res_id,
|
||||
"code": res_id,
|
||||
"name": f"{name} ({comp_kind})",
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"kind": comp_kind,
|
||||
"candidates": [val, 0, 0, 0, 0],
|
||||
"adoptMode": "manual",
|
||||
"manualPrice": val,
|
||||
"convertHourly": False,
|
||||
"isUsd": False,
|
||||
}
|
||||
)
|
||||
ilwi_items[-1]["lines"].append(
|
||||
{"refId": res_id, "qty": 1.0, "halPercent": 0, "devd": False}
|
||||
)
|
||||
resources.append(_make_syn_resource(res_id, name, spec, unit, comp_kind, val))
|
||||
lines.append({"refId": res_id, "qty": 1.0, "halPercent": 0, "devd": False})
|
||||
ilwi_items.append(
|
||||
{
|
||||
"id": code,
|
||||
"code": code,
|
||||
"name": f"[{prefix}] {name}",
|
||||
"rawName": name,
|
||||
"prefix": prefix,
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"fixedM": m1,
|
||||
"fixedL": l1,
|
||||
"fixedS": s1,
|
||||
"fixedT": t1,
|
||||
"smtText": memo_obj["text"] if memo_obj else "",
|
||||
"smtRefs": memo_obj["refs"] if memo_obj else [],
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
|
||||
# BOQ 제외금액 구역(C*) 판별 (16번 문서 §4.2)
|
||||
excl_span: set[int] = set()
|
||||
for i, r in enumerate(boq_rows):
|
||||
bd = (r.get("BDCODE1") or "").strip()
|
||||
@@ -360,37 +285,28 @@ def convert_dbf_to_sim_state(
|
||||
continue
|
||||
q1 = fnum(r.get("QTY1"))
|
||||
q2 = fnum(r.get("QTY2")) if r.get("QTY2") else q1
|
||||
name1 = r.get("NAME1") or bd
|
||||
name1, spec1, unit1 = r.get("NAME1") or bd, r.get("SIZE1") or "", r.get("UNIT1") or ""
|
||||
if bd not in known_ids:
|
||||
# BOQ 자체 단가를 가진 말단행이면 호표 자동 생성
|
||||
m1, l1, s1 = fnum(r.get("MAMT1")), fnum(r.get("LAMT1")), fnum(r.get("SAMT1"))
|
||||
lines = []
|
||||
for ctag, ckind, cval in (("M", "재료비", m1), ("L", "노무비", l1), ("S", "경비", s1)):
|
||||
if cval > 0:
|
||||
rid = f"{bd}_{ctag}"
|
||||
resources.append(
|
||||
{
|
||||
"id": rid,
|
||||
"code": rid,
|
||||
"name": f"{name1} ({ckind})",
|
||||
"spec": r.get("SIZE1") or "",
|
||||
"unit": r.get("UNIT1") or "",
|
||||
"kind": ckind,
|
||||
"candidates": [cval, 0, 0, 0, 0],
|
||||
"adoptMode": "manual",
|
||||
"manualPrice": cval,
|
||||
"convertHourly": False,
|
||||
"isUsd": False,
|
||||
}
|
||||
)
|
||||
resources.append(_make_syn_resource(rid, name1, spec1, unit1, ckind, cval))
|
||||
lines.append({"refId": rid, "qty": 1.0, "halPercent": 0, "devd": False})
|
||||
ilwi_items.append(
|
||||
{
|
||||
"id": bd,
|
||||
"code": bd,
|
||||
"name": name1,
|
||||
"spec": r.get("SIZE1") or "",
|
||||
"unit": r.get("UNIT1") or "",
|
||||
"rawName": name1,
|
||||
"prefix": bd[0].upper(),
|
||||
"spec": spec1,
|
||||
"unit": unit1,
|
||||
"fixedM": m1,
|
||||
"fixedL": l1,
|
||||
"fixedS": s1,
|
||||
"fixedT": fnum(r.get("TAMT1")),
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
@@ -399,12 +315,22 @@ def convert_dbf_to_sim_state(
|
||||
row_type = "normal"
|
||||
if i in excl_span:
|
||||
row_type = "gov_fee" if ("수수료" in name1 or fnum(r.get("S_AMT1")) > 0) else "gov_mat"
|
||||
|
||||
is_pct_fee = unit1.endswith("%") and fnum(r.get("T_AMT1")) > 0
|
||||
if is_pct_fee:
|
||||
fee_amt = fnum(r.get("T_AMT1"))
|
||||
for res in resources:
|
||||
if res["id"] == bd and res.get("manualPrice", 0) == 0:
|
||||
res["manualPrice"] = fee_amt
|
||||
res["candidates"][0] = fee_amt
|
||||
q1, q2 = 1.0, 1.0
|
||||
boq_items.append(
|
||||
{
|
||||
"id": f"BOQ_{i + 1}",
|
||||
"code": bd,
|
||||
"itnum": r.get("ITNUM") or "",
|
||||
"name": name1,
|
||||
"spec": spec1,
|
||||
"unit": unit1,
|
||||
"refId": bd,
|
||||
"rowType": row_type,
|
||||
"qty": q1,
|
||||
@@ -436,25 +362,27 @@ def convert_dbf_to_sim_state(
|
||||
"S_DOSEO": fnum(smt_kv.get("1_R_S_DOSEO"), 0.0),
|
||||
"S_JIGUP": fnum(smt_kv.get("1_R_S_JIGUP"), 0.0),
|
||||
}
|
||||
|
||||
jab_type = int(fnum(smt_kv.get("JAB_TYPE"), 0))
|
||||
cal_actl = int(fnum(smt_kv.get("CAL_ACTL"), 0))
|
||||
scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T")
|
||||
tot_jeol = -int(fnum(smt_kv.get("1_I_TOT_JEOL"), 3))
|
||||
|
||||
return {
|
||||
"projectMeta": {
|
||||
"pname": prj_row.get("PNAME", ""),
|
||||
"pnum": prj_row.get("PNUM", ""),
|
||||
"pcode": prj_row.get("PCODE", "11"),
|
||||
"pclass": int(fnum(prj_row.get("PCLASS"), 0)),
|
||||
"kOrd": int(fnum(prj_row.get("K_ORD"), 0)),
|
||||
"tcost1": fnum(prj_row.get("TCOST1"), 0),
|
||||
"costw1": fnum(prj_row.get("COSTW1"), 0),
|
||||
},
|
||||
"sysConfig": {
|
||||
"rxFormula": "1/8*16/12*25/20",
|
||||
"rxFormula": prj_row.get("RXNM_") or "1/8*16/12*25/20",
|
||||
"condCodes": "L00008, L00009, L00010",
|
||||
"usdRate1": 1350,
|
||||
"usdRate2": 1400,
|
||||
"usdRate1": fnum(prj_row.get("EXLNM1"), 1350),
|
||||
"usdRate2": fnum(prj_row.get("EXLNM2"), 1400),
|
||||
"useChangedUsd": False,
|
||||
"ecdDigits": 0,
|
||||
"ecdDigits": int(fnum(prj_row.get("ECD"), 0)),
|
||||
"chnMode": "total" if prj_row.get("CHAMT") == "T" else "bimok",
|
||||
"optOrder": "SML",
|
||||
"kOrdSim": {
|
||||
@@ -464,10 +392,10 @@ def convert_dbf_to_sim_state(
|
||||
"currentK": int(fnum(prj_row.get("K_ORD"), 2)) or 2,
|
||||
},
|
||||
},
|
||||
"resources": resources[:120],
|
||||
"resources": resources,
|
||||
"machines": machines,
|
||||
"sanItems": san_items,
|
||||
"ilwiItems": ilwi_items[:120],
|
||||
"ilwiItems": ilwi_items,
|
||||
"boqItems": boq_items,
|
||||
"kanjubConfig": {
|
||||
"scheme": scheme,
|
||||
@@ -476,23 +404,26 @@ def convert_dbf_to_sim_state(
|
||||
"anjunGovVatExcluded": True,
|
||||
"anjunCap12": True,
|
||||
"vatMode": "supply_10",
|
||||
"totJeolDigits": tot_jeol,
|
||||
"totJeolDigits": -int(fnum(smt_kv.get("1_I_TOT_JEOL"), 3)),
|
||||
"enableProfitEndAdj": True,
|
||||
"govRoundMode": "up",
|
||||
"govRoundDigits": -int(fnum(smt_kv.get("1_I_KWN_JEOLSA"), 3)),
|
||||
"rates": rates,
|
||||
"revModes": {
|
||||
"KANNO": "recalculated",
|
||||
"SANBO": "recalculated",
|
||||
"KOBO": "recalculated",
|
||||
"KUNBO": "carry_original",
|
||||
"SLVBO": "carry_original",
|
||||
"YUNBO": "carry_original",
|
||||
"TBUGUM": "recalculated",
|
||||
"ANJUN": "recalculated",
|
||||
"KITA": "recalculated",
|
||||
"HWAN": "recalculated",
|
||||
"EQIP": "recalculated",
|
||||
k: ("carry_original" if k in ("KUNBO", "SLVBO", "YUNBO") else "recalculated")
|
||||
for k in (
|
||||
"KANNO",
|
||||
"SANBO",
|
||||
"KOBO",
|
||||
"KUNBO",
|
||||
"SLVBO",
|
||||
"YUNBO",
|
||||
"TBUGUM",
|
||||
"ANJUN",
|
||||
"KITA",
|
||||
"HWAN",
|
||||
"EQIP",
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -513,6 +444,7 @@ class SaveDbfReq(BaseModel):
|
||||
boq_updates: list[dict] = []
|
||||
prjlst_updates: dict = {}
|
||||
smt_updates: dict = {}
|
||||
costn_memo_updates: dict[str, str] = {}
|
||||
|
||||
|
||||
@app.get("/api/sources")
|
||||
@@ -520,38 +452,57 @@ def api_sources():
|
||||
return discover_sources()
|
||||
|
||||
|
||||
@app.get("/api/catalog")
|
||||
def api_catalog():
|
||||
return load_stmate_catalog()
|
||||
|
||||
|
||||
@app.post("/api/load")
|
||||
def api_load(req: LoadReq):
|
||||
files = read_project_files(req.source_path)
|
||||
if "COSTN.DBF" not in files:
|
||||
raise HTTPException(status_code=400, detail="COSTN.DBF 파일이 포함되지 않은 경로임")
|
||||
|
||||
costn_flds, costn_rows, _ = parse_dbf_bytes(files["COSTN.DBF"])
|
||||
boq_name = "BOQ12.DBF" if "BOQ12.DBF" in files else "BOQ11.DBF"
|
||||
boq_flds, boq_rows, _ = parse_dbf_bytes(files[boq_name]) if boq_name in files else ([], [], {})
|
||||
prj_flds, prj_rows, _ = (
|
||||
_, costn_rows, _ = parse_dbf_bytes(files["COSTN.DBF"])
|
||||
costn_memos = extract_costn_memos(files["COSTN.DBF"], files.get("COSTN.SMT", b""))
|
||||
|
||||
boq_tables: dict[str, list[dict]] = {}
|
||||
for bname in sorted(k for k in files if k.startswith("BOQ") and k.endswith(".DBF")):
|
||||
_, brows, _ = parse_dbf_bytes(files[bname])
|
||||
boq_tables[bname] = brows
|
||||
|
||||
boq_name = "BOQ11.DBF" if "BOQ11.DBF" in boq_tables else (next(iter(boq_tables), "BOQ11.DBF"))
|
||||
boq_rows = boq_tables.get(boq_name, [])
|
||||
_, prj_rows, _ = (
|
||||
parse_dbf_bytes(files["PRJLST.DBF"]) if "PRJLST.DBF" in files else ([], [{}], {})
|
||||
)
|
||||
smt_kv = parse_prjlst_smt(files.get("PRJLST.SMT", b""))
|
||||
|
||||
rate_r_rows = []
|
||||
if "RATE_R.DBF" in files:
|
||||
_, rate_r_rows, _ = parse_dbf_bytes(files["RATE_R.DBF"])
|
||||
rate_tables: dict[str, list[dict]] = {}
|
||||
for rname in ("RATE_R.DBF", "RATE_RA.DBF", "RATE_M.DBF", "RATE_B.DBF", "RATE.DBF"):
|
||||
if rname in files:
|
||||
_, rrows, _ = parse_dbf_bytes(files[rname])
|
||||
rate_tables[rname] = rrows
|
||||
|
||||
set_rep_rows = parse_dbf_bytes(files["SET_REP.DBF"])[1] if "SET_REP.DBF" in files else []
|
||||
sim_state = convert_dbf_to_sim_state(
|
||||
costn_rows, boq_rows, smt_kv, prj_rows[0] if prj_rows else {}
|
||||
costn_rows, boq_rows, smt_kv, prj_rows[0] if prj_rows else {}, costn_memos
|
||||
)
|
||||
return {
|
||||
"source_path": req.source_path,
|
||||
"boq_table_name": boq_name,
|
||||
"boq_tables": boq_tables,
|
||||
"file_list": sorted(files.keys()),
|
||||
"prjlst_all": prj_rows,
|
||||
"prjlst": prj_rows[0] if prj_rows else {},
|
||||
"prjlst_smt": smt_kv,
|
||||
"costn_count": len(costn_rows),
|
||||
"costn_rows": costn_rows[:200],
|
||||
"costn_rows": costn_rows,
|
||||
"costn_memos": costn_memos,
|
||||
"boq_count": len(boq_rows),
|
||||
"boq_rows": boq_rows[:200],
|
||||
"rate_r_count": len(rate_r_rows),
|
||||
"boq_rows": boq_rows,
|
||||
"rate_tables": rate_tables,
|
||||
"set_rep_rows": set_rep_rows,
|
||||
"sim_state": sim_state,
|
||||
}
|
||||
|
||||
@@ -562,17 +513,16 @@ def api_load_jbr(req: LoadJbrReq):
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail=f"JB_R 파일 없음: {req.jbr_path}")
|
||||
flds, rows, _ = parse_dbf_bytes(p.read_bytes())
|
||||
matrix: list[list[float]] = []
|
||||
for r in rows:
|
||||
matrix.append([fnum(r.get(f["name"])) for f in flds])
|
||||
matrix = [[fnum(r.get(f["name"])) for f in flds] for r in rows]
|
||||
|
||||
def cell(row_idx: int, col_1based: int, default: float = 0.0) -> float:
|
||||
if 0 <= row_idx < len(matrix) and 0 <= col_1based - 1 < len(matrix[row_idx]):
|
||||
return matrix[row_idx][col_1based - 1]
|
||||
return default
|
||||
def cell(r_i: int, c_1: int, d: float = 0.0) -> float:
|
||||
return (
|
||||
matrix[r_i][c_1 - 1]
|
||||
if 0 <= r_i < len(matrix) and 0 <= c_1 - 1 < len(matrix[r_i])
|
||||
else d
|
||||
)
|
||||
|
||||
raw_base = cell(20, 18, 5349.0)
|
||||
anjun_base = raw_base * 1000 if 0 < raw_base < 100000 else raw_base
|
||||
extracted_rates = {
|
||||
"KANNO": cell(20, 1, 14.5),
|
||||
"KITA": cell(20, 6, 6.0),
|
||||
@@ -584,19 +534,16 @@ def api_load_jbr(req: LoadJbrReq):
|
||||
"SLVBO": cell(46, 11, 12.95),
|
||||
"IYUN": cell(20, 16, 15.0),
|
||||
"ANJUN": cell(20, 17, 1.86),
|
||||
"anjunBaseAdd": anjun_base,
|
||||
"anjunBaseAdd": raw_base * 1000 if 0 < raw_base < 100000 else raw_base,
|
||||
}
|
||||
return {
|
||||
"name": p.name,
|
||||
"row_count": len(matrix),
|
||||
"col_count": len(flds),
|
||||
"fields": [f["name"] for f in flds],
|
||||
"rows": rows,
|
||||
"matrix": matrix,
|
||||
"extracted_rates": extracted_rates,
|
||||
"preview_rows": {
|
||||
"row6_kanno_kita": matrix[6][:12] if len(matrix) > 6 else [],
|
||||
"row20_iyun_anjun": matrix[20][:20] if len(matrix) > 20 else [],
|
||||
"row45_sanbo_kobo": matrix[45][:20] if len(matrix) > 45 else [],
|
||||
"row46_kunbo_yunbo_slvbo": matrix[46][:15] if len(matrix) > 46 else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -607,7 +554,6 @@ def api_save(req: SaveDbfReq):
|
||||
out_dir = WORK_DBF_ROOT / (safe_name or "stmate_edited")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. COSTN.DBF 수정 반영 및 직렬화
|
||||
if "COSTN.DBF" in files:
|
||||
flds, rows, meta = parse_dbf_bytes(files["COSTN.DBF"])
|
||||
upd_map = {str(u.get("CODE", "")).strip(): u for u in req.costn_updates if u.get("CODE")}
|
||||
@@ -617,10 +563,15 @@ def api_save(req: SaveDbfReq):
|
||||
for k, v in upd_map[cd].items():
|
||||
if k in r:
|
||||
r[k] = str(v)
|
||||
files["COSTN.DBF"] = build_dbf_bytes(flds, rows, meta)
|
||||
dbf_b = build_dbf_bytes(flds, rows, meta)
|
||||
if req.costn_memo_updates:
|
||||
dbf_b, smt_b = rebuild_costn_with_smt(
|
||||
dbf_b, files.get("COSTN.SMT", b""), req.costn_memo_updates
|
||||
)
|
||||
files["COSTN.SMT"] = smt_b
|
||||
files["COSTN.DBF"] = dbf_b
|
||||
|
||||
# 2. BOQ11/12.DBF 수량·단가·비목금액 재계산 및 직렬화
|
||||
boq_name = "BOQ12.DBF" if "BOQ12.DBF" in files else "BOQ11.DBF"
|
||||
boq_name = "BOQ11.DBF" if "BOQ11.DBF" in files else "BOQ12.DBF"
|
||||
if boq_name in files:
|
||||
flds, rows, meta = parse_dbf_bytes(files[boq_name])
|
||||
upd_by_rec = {int(u["_recno"]): u for u in req.boq_updates if "_recno" in u}
|
||||
@@ -628,24 +579,45 @@ def api_save(req: SaveDbfReq):
|
||||
rno = int(r.get("_recno", -1))
|
||||
if rno in upd_by_rec:
|
||||
u = upd_by_rec[rno]
|
||||
for k in ("NAME1", "QTY1", "MAMT1", "LAMT1", "SAMT1", "TAMT1"):
|
||||
for k in (
|
||||
"ITNUM",
|
||||
"NAME1",
|
||||
"SIZE1",
|
||||
"UNIT1",
|
||||
"QTY1",
|
||||
"MAMT1",
|
||||
"LAMT1",
|
||||
"SAMT1",
|
||||
"TAMT1",
|
||||
):
|
||||
if k in u and k in r:
|
||||
r[k] = str(u[k])
|
||||
# 비목별 금액 ROUNDDOWN(QTY1 * 단가) 동기화
|
||||
q = Decimal(str(fnum(r.get("QTY1"))))
|
||||
m_u = Decimal(str(fnum(r.get("MAMT1"))))
|
||||
l_u = Decimal(str(fnum(r.get("LAMT1"))))
|
||||
s_u = Decimal(str(fnum(r.get("SAMT1"))))
|
||||
m_a = int((q * m_u).quantize(Decimal(1), rounding=ROUND_DOWN))
|
||||
l_a = int((q * l_u).quantize(Decimal(1), rounding=ROUND_DOWN))
|
||||
s_a = int((q * s_u).quantize(Decimal(1), rounding=ROUND_DOWN))
|
||||
r["M_AMT1"] = str(m_a)
|
||||
r["L_AMT1"] = str(l_a)
|
||||
r["S_AMT1"] = str(s_a)
|
||||
r["T_AMT1"] = str(m_a + l_a + s_a)
|
||||
bd = (r.get("BDCODE1") or "").strip()
|
||||
if bd and not STRUCT_RE.match(bd):
|
||||
q = Decimal(str(fnum(r.get("QTY1"))))
|
||||
m_a = int(
|
||||
(q * Decimal(str(fnum(r.get("MAMT1"))))).quantize(
|
||||
Decimal(1), rounding=ROUND_DOWN
|
||||
)
|
||||
)
|
||||
l_a = int(
|
||||
(q * Decimal(str(fnum(r.get("LAMT1"))))).quantize(
|
||||
Decimal(1), rounding=ROUND_DOWN
|
||||
)
|
||||
)
|
||||
s_a = int(
|
||||
(q * Decimal(str(fnum(r.get("SAMT1"))))).quantize(
|
||||
Decimal(1), rounding=ROUND_DOWN
|
||||
)
|
||||
)
|
||||
r["M_AMT1"], r["L_AMT1"], r["S_AMT1"], r["T_AMT1"] = (
|
||||
str(m_a),
|
||||
str(l_a),
|
||||
str(s_a),
|
||||
str(m_a + l_a + s_a),
|
||||
)
|
||||
files[boq_name] = build_dbf_bytes(flds, rows, meta)
|
||||
|
||||
# 3. PRJLST.DBF 및 PRJLST.SMT 갱신
|
||||
if "PRJLST.DBF" in files and req.prjlst_updates:
|
||||
flds, rows, meta = parse_dbf_bytes(files["PRJLST.DBF"])
|
||||
if rows:
|
||||
@@ -657,11 +629,9 @@ def api_save(req: SaveDbfReq):
|
||||
if "PRJLST.SMT" in files and req.smt_updates:
|
||||
files["PRJLST.SMT"] = update_prjlst_smt_bytes(files["PRJLST.SMT"], req.smt_updates)
|
||||
|
||||
# 4. 폴더 내 개별 DBF/SMT 저장 및 .STC 패키지 생성
|
||||
written_files = []
|
||||
for fname, content in files.items():
|
||||
fp = out_dir / fname
|
||||
fp.write_bytes(content)
|
||||
(out_dir / fname).write_bytes(content)
|
||||
written_files.append({"name": fname, "size": len(content)})
|
||||
|
||||
stc_path = WORK_DBF_ROOT / f"{out_dir.name}.STC"
|
||||
|
||||
Reference in New Issue
Block a user