"""M01 단가산출 기초 수식 문서 화면 서버 몫(PLAN 802-3 · 요구 664 · 요구서_664 3 장). 문서 = `resources/master_data/수식문서/수식_<로직 파일 꼬리>.json`(`master_doc_link.FOLDER`) · 셈 = `master_doc_calc.calc`(파이썬 정본 · TS 짝 `common_util_formula_doc_calc.ts`) · 연결 기록 = `master_doc_record.Rec` — 셈이 쓴 요소 값 · 표 · 단가산출 문서를 화면 캐시로 실음 (화면은 같은 계산기로 즉시 셈 · 캐시에 없는 연결만 뒤에서 미리 셈). - 읽기 {키} → {문서, 셈, 연결, 파일, 새} — 문서가 없으면 빈 문서(이름 · 단위 = 로직 줄) - 미리 셈 {문서, 입력} → {셈, 연결} — 쓰기 없음 - 저장 {문서} → {문서, 셈, 연결, 파일} — 서버가 정본으로 다시 셈 · 문서 파일에 씀(브라우저 셈 값 안 받음) 권한 = 라우터를 `main.py` 가 `system_admin_only` 로 붙임. M02 는 같은 꼴로 따로(802-4). """ from __future__ import annotations import json from decimal import Decimal from pathlib import Path from M01_MasterData import M01_MasterData_Store as store import master_doc as md # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 import master_doc_calc as mc # noqa: E402 import master_doc_link as ml # noqa: E402 import master_doc_record as mr # noqa: E402 import master_equation_link as link # noqa: E402 OWN_FILE = "수식_자체.json" _memo: dict = {} def folder() -> Path: return ml.FOLDER def _stamp() -> tuple: return tuple((p.name, p.stat().st_mtime_ns) for p in sorted(folder().glob("수식_*.json"))) def docs() -> dict[str, dict]: """저장 문서 전부 {키: 문서} — 파일 시각이 같으면 다시 안 읽음.""" stamp = (str(folder()), _stamp()) if _memo.get("stamp") != stamp: _memo.update(stamp=stamp, docs=ml.load_all(folder())) return _memo["docs"] def _dec(doc) -> dict: """화면 JSON(float) → Decimal 문서 — 저장 문서와 같은 수 꼴로 셈.""" return json.loads(json.dumps(doc, ensure_ascii=False), parse_float=Decimal, parse_int=Decimal) def _where(key: str) -> str | None: for path in sorted(folder().glob("수식_*.json")): if any(str(d.get("키")) == key for d in ml.load_file(path).get("줄") or []): return path.name return None def _run(doc: dict, given: dict | None = None) -> dict: whole = store.loaded()[1] every = docs() own = str(doc.get("키") or "") links = ml.Links(whole, {**every, own: doc} if own else every) rec = mr.Rec(links) rec.prefetch(doc) answer = mc.calc(doc, rec, _dec(given or {})) return {"셈": answer, "연결": rec.out()} def _check(doc) -> dict: if not isinstance(doc, dict) or doc.get("꼴") != "수식문서": raise store.StoreError(400, "수식 문서 꼴 아님") if not str(doc.get("키") or "").strip(): raise store.StoreError(400, "문서 키 없음") for k in ("변수", "식", "금액"): if not isinstance(doc.get(k) or [], list): raise store.StoreError(400, f"「{k}」 는 목록") return _dec(doc) def read(key: str) -> dict: found = docs().get(key) fresh = found is None if fresh: row = store.logic(key)["logic"] # 없는 키 = 404 found = md.blank(key, str(row.get("이름") or ""), str(row.get("결과단위") or "원")) out = _run(found) return {"문서": found, **out, "파일": _where(key) or "", "새": fresh} def preview(doc: dict, given: dict | None = None) -> dict: return _run(_check(doc), given) def _file_for(key: str) -> str: """문서 파일 — 있던 파일 › 로직 파일 짝 › 자체.""" have = _where(key) if have: return have try: return ml.file_of(store.logic(key)["file"]) except store.StoreError: return OWN_FILE def save(doc: dict) -> dict: doc = _check(doc) key = str(doc["키"]).strip() name = _file_for(key) path = folder() / name data = ml.load_file(path) if path.exists() else {"그룹": "수식문서", "로직파일": "", "줄": []} rows = data.setdefault("줄", []) at = next((i for i, d in enumerate(rows) if str(d.get("키")) == key), None) if at is None: rows.append(doc) else: rows[at] = doc path.write_text(ml.dump(data), encoding="utf-8", newline="\n") _memo.clear() return {"문서": doc, **_run(doc), "파일": name} def candidates(ask: dict) -> dict: """연결 후보 — 요소 · 표 = M01 색인 찾기 · 단가산출 = 수식 문서 이름 찾기(읽기만).""" if ask.get("갈래") != "단가산출": return link.candidates(store.loaded()[1], ask) words = str(ask.get("검색") or "").lower().split() out = [] for key, d in docs().items(): text = f"{key} {d.get('이름') or ''}".lower() if words and all(w in text for w in words): ins = [ str(v.get("열쇠")) for v in d.get("변수") or [] if (v.get("연결") or {}).get("갈래") in ("선택", "입력", "숫자") and v.get("열쇠") ] out.append({"키": key, "이름": d.get("이름"), "단위": d.get("결과단위"), "입력": ins}) return {"후보": out[: link.LIMIT], "더있음": len(out) > link.LIMIT} def table(key: str) -> dict: """표 하나(조건 · 칸 고르기) — 기록 꼴 그대로.""" rec = mr.Rec(ml.Links(store.loaded()[1], {})) rec.see_table(key) return rec.표[key] # ── 로직 지우기(804-7 · 요구 687-1) — 로직 줄 + 수식 문서를 서버 정본에서 함께 ── def users(key: str) -> list[str]: """이 로직을 부르는 곳 — 다른 수식 문서(단가산출 연결) · 일위대가 조합 · M02 단가산출(바탕). 다른 로직 줄이 부르는 것은 로직 저장 검사(422)가 막음.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 out = [] for k, d in docs().items(): if k != key and any( (v.get("연결") or {}).get("갈래") == "단가산출" and str((v.get("연결") or {}).get("키")) == key for v in d.get("변수") or [] ): out.append(f"단가산출 {k} {d.get('이름') or ''}".strip()) for c in combo_store.combos_of_logic(key)["combos"]: out.append(f"일위대가 조합 {c.get('키')} {c.get('이름') or ''}".strip()) for where, path in layers.layer_files("sangeun"): try: sk = json.loads(path.read_bytes()) except (OSError, ValueError): continue if str((sk.get("바탕") or {}).get("로직") or "") == key: out.append(f"M02 단가산출 {where} {sk.get('이름') or path.stem}") return out def drop(key: str) -> None: """수식 문서 줄 하나 지움(없으면 그냥).""" name = _where(key) if not name: return path = folder() / name data = ml.load_file(path) data["줄"] = [d for d in data.get("줄") or [] if str(d.get("키")) != key] path.write_text(ml.dump(data), encoding="utf-8", newline="\n") _memo.clear() def remove(key: str, file: str, version: str, confirmed: bool) -> dict: """로직 지우기 — 부르는 곳이 있으면 409 {쓰는곳} · 로직 저장 길(개정 확인 · 판 · 검사) 뒤 문서도.""" from M01_MasterData import M01_MasterData_Store_Make as make used = users(key) if used: raise store.StoreError(409, {"쓰는곳": used}) got = make.save( [{"file": file, "version": version, "changes": [{"op": "delete", "key": key}]}], confirmed, ) drop(key) return got