# -*- 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