- 시험 615 통과 · 브라우저 화면 13개·메뉴 90개 오류 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
12 KiB
Python
320 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""코드 관리·자료 병합 저장 끼워 넣기 API.
|
|
|
|
코드 신규·삭제·변경·항목교체·병합 뒤 저장 · 프로젝트 DATA 검색 · 다른 내역서 열기.
|
|
근거: ST_U_ChgCode.CPM(uM_ChgCode · uM_Chg_Plus · uM_ReplCode) · ST_U_DelCode.CPM(uM_DelCode)
|
|
· ST_U_Merge.CPM(uM_Merge) · ST_Merge_Code.CPM(wM_Stmate_Merge_Code · wSrch_PL)
|
|
— DFM 원문(C:/경영정보(주)/System)
|
|
저장 = 구조 손질(COSTN 행 끼움·지움·코드 · 다른 내역 표 코드)
|
|
→ stc_save.apply_save(바뀐 칸만 · 구성표 bdqty_rows 포함)
|
|
요청 덧붙임 키:
|
|
costn_struct [{_recno | _new+_from|_vals, CODE, 덧붙임 칸…}] — 있는 행은 코드·덧붙임 칸만,
|
|
새 행은 _from 행 복사 뒤 코드 · _vals(다른 프로젝트에서 가져온 행의 칸 값)
|
|
costn_deleted [_recno…] — 지움 표시(*) · 원래 자리 유지
|
|
boq_code_rows [{table, _recno, BDCODE1~4}] — 열린 표 밖 내역 표의 코드 바꿈
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import re
|
|
|
|
from dbf_rw import DbfTable
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, ConfigDict
|
|
from smt_parser import parse_dbf_bytes
|
|
from stc_project import safe_name, write_output
|
|
from stc_save import apply_save, pick_boq_table
|
|
|
|
router = APIRouter()
|
|
|
|
# 덧붙임 칸 — 기본 저장(COSTN_SAVE_COL) 밖이나 코드 관리 화면이 고치는 칸
|
|
EXTRA_COL = ("BQCODE", "EQCODE", "JDCODE", "WQCODE", "EDT_DATE", "NAME2", "SIZE2", "PP", "CONTCODE")
|
|
|
|
|
|
def _txt(v) -> str:
|
|
return "" if v is None else str(v).strip()
|
|
|
|
|
|
def _same(tbl: DbfTable, i: int, name: str, value) -> bool:
|
|
"""뜻으로 같은가 — 숫자 칸이 깨진 값이어도 글자로 먼저 견줌."""
|
|
if _txt(tbl.get(i, name)) == _txt(value):
|
|
return True
|
|
try:
|
|
return tbl.same_value(i, name, value)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _put(tbl: DbfTable, i: int, name: str, value, log: list, table: str) -> None:
|
|
if name not in tbl.fields or tbl.fields[name].type == "M" or _same(tbl, i, name, value):
|
|
return
|
|
old = tbl.get(i, name)
|
|
tbl.set_raw(i, name, tbl.encode_value(name, value))
|
|
log.append({"table": table, "recno": i, "field": name, "old": old, "new": _txt(value)})
|
|
|
|
|
|
def _costn(files: dict, req: dict, log: list) -> None:
|
|
struct = req.get("costn_struct") or []
|
|
dele = {int(x) for x in (req.get("costn_deleted") or [])}
|
|
if not (struct or dele) or "COSTN.DBF" not in files:
|
|
return
|
|
ct = DbfTable(files["COSTN.DBF"])
|
|
n0 = len(ct)
|
|
for i in sorted(dele):
|
|
if 0 <= i < n0 and not ct.deleted(i):
|
|
ct.recs[i][0:1] = b"*"
|
|
ct.dirty = True
|
|
log.append(
|
|
{
|
|
"table": "COSTN.DBF",
|
|
"recno": i,
|
|
"field": "_delete",
|
|
"old": ct.get(i, "CODE"),
|
|
"new": "",
|
|
}
|
|
)
|
|
codes = {ct.get(i, "CODE") for i in range(n0) if not ct.deleted(i)}
|
|
for u in struct:
|
|
code = _txt(u.get("CODE"))
|
|
if not code:
|
|
continue
|
|
r = u.get("_recno")
|
|
if not u.get("_new") and r is not None and 0 <= int(r) < n0:
|
|
i = int(r)
|
|
if i in dele:
|
|
continue
|
|
else:
|
|
if code in codes:
|
|
raise ValueError(f"COSTN 에 이미 있는 코드: {code}")
|
|
src = u.get("_from")
|
|
tmpl = int(src) if src is not None and 0 <= int(src) < n0 else None
|
|
i = ct.insert(len(ct), {}, template=tmpl)
|
|
if "IFORM" in ct.fields: # 산출근거 메모는 새 자리로(복사원 메모 덮어쓰기 방지)
|
|
ct.set_raw(i, "IFORM", b" " * ct.fields["IFORM"].len)
|
|
log.append(
|
|
{"table": "COSTN.DBF", "recno": i, "field": "_insert", "old": "", "new": code}
|
|
)
|
|
if tmpl is None:
|
|
for k in ("NAME", "SIZE", "UNIT"):
|
|
_put(ct, i, k, u.get(k, ""), log, "COSTN.DBF")
|
|
_put(ct, i, "CODE", code, log, "COSTN.DBF")
|
|
codes.add(code)
|
|
for k, v in (
|
|
u.get("_vals") or {}
|
|
).items(): # 다른 프로젝트에서 가져온 새 행 — 원래 칸 값 그대로
|
|
if k in ct.fields and k not in ("CODE", "IFORM") and not k.endswith("_HEX"):
|
|
try:
|
|
_put(ct, i, k, v, log, "COSTN.DBF")
|
|
except ValueError: # 칸 폭·꼴이 다른 값은 건너뜀(원래 틀 값 유지)
|
|
continue
|
|
for k in EXTRA_COL:
|
|
if k in u:
|
|
_put(ct, i, k, u[k], log, "COSTN.DBF")
|
|
if ct.dirty:
|
|
files["COSTN.DBF"] = ct.to_bytes()
|
|
|
|
|
|
def _boq_codes(files: dict, req: dict, log: list) -> None:
|
|
by: dict[str, list] = {}
|
|
for u in req.get("boq_code_rows") or []:
|
|
by.setdefault(str(u.get("table", "")).upper(), []).append(u)
|
|
for name, ups in by.items():
|
|
if name not in files:
|
|
continue
|
|
bt = DbfTable(files[name])
|
|
for u in ups:
|
|
i = int(u.get("_recno", -1))
|
|
if not (0 <= i < len(bt)):
|
|
continue
|
|
for k in ("BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"):
|
|
if k in u:
|
|
_put(bt, i, k, u[k], log, name)
|
|
if bt.dirty:
|
|
files[name] = bt.to_bytes()
|
|
|
|
|
|
def _rates(files: dict, req: dict, log: list) -> None:
|
|
"""제비율표 수정(wM_KAN_RATE) — rate_tables{표 이름: 행[_recno · RATE_n 칸]} 중 바뀐 칸만."""
|
|
for name, rows in (req.get("rate_tables") or {}).items():
|
|
name = str(name).upper()
|
|
if not re.fullmatch(r"RATE[_A-Z0-9]*\.DBF", name) or name not in files:
|
|
continue
|
|
tbl = DbfTable(files[name])
|
|
for r in rows or []:
|
|
i = int(r.get("_recno", -1)) if str(r.get("_recno", "")).isdigit() else -1
|
|
if not 0 <= i < len(tbl):
|
|
continue
|
|
for k, v in r.items():
|
|
if k.startswith("_") or k.endswith("_HEX") or tbl.fields.get(k) is None:
|
|
continue
|
|
if tbl.fields[k].type in ("N", "F"):
|
|
_put(tbl, i, k, v, log, name)
|
|
if tbl.dirty:
|
|
files[name] = tbl.to_bytes()
|
|
|
|
|
|
def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]:
|
|
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움 · 제비율표 칸)."""
|
|
out = dict(files)
|
|
log: list = []
|
|
_costn(out, req, log)
|
|
_boq_codes(out, req, log)
|
|
_rates(out, req, log)
|
|
return out, log
|
|
|
|
|
|
class UiSaveReq(BaseModel):
|
|
"""/api/save 요청 그대로(덧붙은 키는 apply_save 로 넘김) + 구조 덧붙임 키."""
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
source_path: str
|
|
target_folder_name: str = "stmate_edited"
|
|
costn_struct: list[dict] = []
|
|
costn_deleted: list[int] = []
|
|
boq_code_rows: list[dict] = []
|
|
|
|
|
|
def _server():
|
|
import server # 실행 중 바뀔 수 있는 작업 폴더·원천 열기(시험은 임시 폴더로 바꿈)
|
|
|
|
return server
|
|
|
|
|
|
@router.post("/api/ui/save")
|
|
def ui_save(req: UiSaveReq):
|
|
"""구조 손질 → 바뀐 칸 저장 → work_dbf/<이름>/ · <이름>.STC (원본은 손대지 않음)."""
|
|
srv = _server()
|
|
src = srv.open_source(req.source_path)
|
|
body = req.model_dump()
|
|
try:
|
|
files, struct_log = apply_struct(src.files, body)
|
|
files, changes = apply_save(files, body)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
res = write_output(
|
|
srv.WORK_DBF_ROOT, safe_name(req.target_folder_name), files, src.names, src.entries
|
|
)
|
|
res["changed_files"] = sorted(k for k in files if files[k] is not src.files.get(k))
|
|
allc = struct_log + changes
|
|
res["changes"] = allc[:500]
|
|
res["change_count"] = len(allc)
|
|
return res
|
|
|
|
|
|
# ── 프로젝트 DATA 검색(wSrch_PL) — 등록된 모든 프로젝트의 단가·내역에서 명칭·규격 찾기 ──
|
|
_CACHE: dict[str, tuple[float, dict]] = {}
|
|
COSTN_COLS = (
|
|
"CODE",
|
|
"NAME",
|
|
"SIZE",
|
|
"UNIT",
|
|
"BQCODE",
|
|
"EQCODE",
|
|
"TAMT1",
|
|
"LAMT1",
|
|
"MAMT1",
|
|
"SAMT1",
|
|
)
|
|
COSTN_COLS += ("JDCODE", "EDT_DATE")
|
|
BOQ_COLS = ("ITNUM", "NAME1", "SIZE1", "UNIT1", "QTY1", "T_AMT1", "BDCODE1", "BQCODE")
|
|
|
|
|
|
def _project_rows(path: str) -> dict:
|
|
p = pathlib.Path(path)
|
|
key = str(p)
|
|
mt = p.stat().st_mtime if p.exists() else 0.0
|
|
hit = _CACHE.get(key)
|
|
if hit and hit[0] == mt:
|
|
return hit[1]
|
|
files = _server().open_source(path).files
|
|
costn = parse_dbf_bytes(files["COSTN.DBF"])[1] if "COSTN.DBF" in files else []
|
|
boq_name = pick_boq_table(files)
|
|
boq = parse_dbf_bytes(files[boq_name])[1] if boq_name else []
|
|
data = {
|
|
"costn": [{k: _txt(r.get(k)) for k in COSTN_COLS} for r in costn if _txt(r.get("CODE"))],
|
|
"boq": [dict({k: _txt(r.get(k)) for k in BOQ_COLS}, table=boq_name) for r in boq],
|
|
}
|
|
_CACHE[key] = (mt, data)
|
|
return data
|
|
|
|
|
|
SRCH_KINDS = {"LMS": "LMS", "XBD": "XBD", "W": "W", "BOQ": ""}
|
|
|
|
|
|
class SearchReq(BaseModel):
|
|
kind: str = "LMS" # LMS · XBD · W · BOQ (wcBx_Type 4택)
|
|
kw: str = "" # 띄어 쓴 낱말 모두 든 것
|
|
field: str = "name" # name(명칭·규격) · jdcode(호환규정코드) · code(코드번호)
|
|
paths: list[str] = [] # 비면 등록된 모든 프로젝트
|
|
limit: int = 500
|
|
|
|
|
|
def match_rows(rows: list[dict], kind: str, kw: str, field: str) -> list[dict]:
|
|
words = [w.lower() for w in kw.split() if w]
|
|
pre = SRCH_KINDS.get(kind, "")
|
|
out = []
|
|
for r in rows:
|
|
if kind == "BOQ":
|
|
if not r.get("BDCODE1") or len(r["BDCODE1"]) < 3:
|
|
continue
|
|
hay = f"{r['NAME1']} {r['SIZE1']}"
|
|
else:
|
|
if r["CODE"][:1] not in pre:
|
|
continue
|
|
hay = {"jdcode": r["JDCODE"], "code": r["CODE"]}.get(field, f"{r['NAME']} {r['SIZE']}")
|
|
if all(w in hay.lower() for w in words):
|
|
out.append(r)
|
|
return out
|
|
|
|
|
|
@router.post("/api/ui/search")
|
|
def ui_search(req: SearchReq):
|
|
srv = _server()
|
|
projs = srv.discover_sources()["projects"]
|
|
if req.paths:
|
|
want = set(req.paths)
|
|
projs = [p for p in projs if p["path"] in want]
|
|
found, errs = [], []
|
|
for p in projs:
|
|
try:
|
|
data = _project_rows(p["path"])
|
|
except Exception as e: # 열 수 없는 원천은 건너뛰고 알림
|
|
errs.append({"project": p["name"], "error": str(e)[:120]})
|
|
continue
|
|
rows = data["boq"] if req.kind == "BOQ" else data["costn"]
|
|
for r in match_rows(rows, req.kind, req.kw, req.field):
|
|
found.append(dict(r, project=p["name"], path=p["path"]))
|
|
if len(found) >= req.limit:
|
|
return {"rows": found, "truncated": True, "errors": errs}
|
|
return {"rows": found, "truncated": False, "errors": errs}
|
|
|
|
|
|
# ── 다른 내역서 열기(wPrjList — 공사 행마다 BOQ{PCODE}.DBF) ──
|
|
class LoadBoqReq(BaseModel):
|
|
source_path: str
|
|
pcode: str
|
|
|
|
|
|
@router.post("/api/ui/load-boq")
|
|
def ui_load_boq(req: LoadBoqReq):
|
|
"""불러오기 결과를 고른 공사(PRJLST 행)의 내역 표·제잡비 설정으로 바꿔 돌려줌."""
|
|
srv = _server()
|
|
data = srv.api_load(srv.LoadReq(source_path=req.source_path))
|
|
pc = _txt(req.pcode)
|
|
name = f"BOQ{pc}.DBF"
|
|
if name not in data["boq_tables"]:
|
|
raise HTTPException(status_code=404, detail=f"내역 표 없음: {name}")
|
|
prj = next((r for r in data["prjlst_all"] if _txt(r.get("PCODE")) == pc), None)
|
|
if prj is None:
|
|
raise HTTPException(status_code=404, detail=f"PRJLST 에 PCODE {pc} 공사 없음")
|
|
files = srv.read_project_files(req.source_path)
|
|
smt_kv = srv.parse_prjlst_smt(files.get("PRJLST.SMT", b""), prj.get("PRJ_MEMO_HEX", ""))
|
|
rows = data["boq_tables"][name]
|
|
sim = srv.convert_dbf_to_sim_state(data["costn_rows"], rows, smt_kv, prj, data["costn_memos"])
|
|
for k in ("condCodes", "rxFormula"):
|
|
sim["sysConfig"][k] = data["sim_state"]["sysConfig"].get(k, sim["sysConfig"].get(k))
|
|
data.update(boq_table_name=name, boq_rows=rows, boq_count=len(rows), prjlst=prj)
|
|
data.update(prjlst_smt=smt_kv, sim_state=sim)
|
|
return data
|