"""M02 단가산출(SK) 수식 탭 서버 몫(PLAN 796-2b · 요구 655 · 656 · 657 · 요구서_655 0 · 2 장 1). 수식 = 그 SK 바탕 줄(로직 사본 줄 `M02_Sangeun_Copy` · 가져온 식 줄 `M02_Sangeun_Imported.row_of`) + 문서 덧칸 `수식고침` = 사본 위 고침 기록 — M01 수식 탭 고침 꼴(`master_equation_edit` 머리 · 차례대로 · 식 · 더함 · 지움 · 변수 · 표시) + 줄마다 `까닭`?(예정가격작성기준 제2조 ②) · 사본은 찍은 그대로(= 원래 대비 · 고친 칸 = 고침 목록). 셈 = `M02_Sangeun_Calc._base_of` 가 바탕 줄에 `on` 으로 얹음(엔진 · 검사 = M01 `edit_row` 한 벌) → 모든 셈 길(화면 · 저장 원값 · B09 · 펼침) 같은 줄 · 못 얹은 고침(재생성 뒤 자리 없음 등) = 고침 없는 바탕 + 짝잃음 cid `수식` · 저장(`strict`) = 400 · [재생성] = 새 사본에 같은 고침 다시 얹음(못 얹음 = 795-1 `못얹은`). 길(`M02_Sangeun_Router` · 저장 없음): POST …/sangeun/equation {문서, inputs?} → {ok, 수식 | reason}(층 읽기) POST …/sangeun/equation/preview {문서, inputs?, 고침} → {되나, 까닭, 줄, 수식, 전, 후} (고침 = `수식고침` 통째 · 전 = 고침 없는 사본 합계 · 층 쓰기) 저장 = SK [저장](화면 캐시 `수식고침` → `clean_doc` 이 다시 얹어 검사 · 셈 · 브라우저 값 안 받음). 바탕 참조 SK(`바탕.참조`) = 수식 없음(바탕 SK 의 고침은 그 SK 가 얹음). 합계 · 소계(796-2c) = SK 셈 답 기준(`M02_Sangeun_Equation_Full` — 더한 기초 · 뺀줄 · 덮개 · 율 줄 · 합밖) · 줄 값 = 식 덮개 · 상수 · 표값 · 변수줄을 얹은 식(고침 주소는 바탕 차례 그대로) · 전 = 고침 없는 SK 금액. """ from __future__ import annotations import hashlib import json from typing import Any from M02_MasterTemplete import M02_Sangeun_Doc as sd KEY = "수식고침" MANY = 200 LONG = 500 # 까닭 길이 LOST = "수식" # 짝잃음 cid STAND = "GX999997" # 가져온 식(SK 키) 줄을 엔진에 얹을 잠깐 키(GX999998 = 견본 · 999999 = 초안) _MEMO: dict[tuple[str, str], tuple[dict, list[str]]] = {} def _eqn() -> Any: from M01_MasterData import M01_MasterData_Equation as eqn # M02 가 M01 을 부름 — 늦게 return eqn def _hash(v: Any) -> str: raw = json.dumps(v, ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha256(raw.encode("utf-8")).hexdigest() def clean(raw: Any, out: dict) -> dict: """`sd.clean` 끝 — 꼴만(목록 · 묶음 · 까닭 길이) · 뜻 검사는 셈 · 저장 때 `on`.""" edits = raw.get(KEY) if isinstance(raw, dict) else None if not edits: return out if not isinstance(edits, list) or len(edits) > MANY: raise sd.DocError(f"수식고침 · {MANY} 개까지 목록") for at, one in enumerate(edits, 1): why = one.get("까닭") if isinstance(one, dict) else None if not isinstance(one, dict) or (why is not None and len(str(why)) > LONG): raise sd.DocError(f"수식고침 {at}째 · 묶음 · 까닭 {LONG} 자까지") out[KEY] = json.loads(json.dumps(edits, ensure_ascii=False, default=str)) return out def added_inputs(doc: Any) -> set[str]: """고침으로 더한 입력 이름 — `sd.clean` 입력값 거름에서 남김.""" edits = (doc.get(KEY) if isinstance(doc, dict) else None) or [] return { str(e.get("열쇠")) for e in edits if isinstance(e, dict) and e.get("할일") == "더함" and e.get("갈래") == "입력" } def _stand(row: dict) -> tuple[dict, str]: """(엔진이 로직으로 부를 줄, 본 키) — M01 로직 키 아님(가져온 식 SK) = 잠깐 키.""" key = str(row.get("키")) return (row, key) if key[:2] in ("GC", "GF", "GX") else ({**row, "키": STAND}, key) def export(whole: Any, row: dict, inputs: dict | None) -> dict: """수식 읽기 — 입력 = 받은 값 › 자동값(SK 셈 `run_line` 과 같음 · 기본 제안값이 안 덮음).""" stand, key = _stand(row) eqn = _eqn() auto = eqn.meq.ma.values(eqn.me.swap(whole, stand), stand) given = {**{k: v for k, v in auto.items() if v is not None}, **(inputs or {})} return eqn.export_row(whole, stand, given) | {"열쇠": key} def apply(row: dict, edits: list, whole: Any) -> tuple[dict, list[str]]: """바탕 줄 + 고침 → (새 줄, 까닭) — 같은 줄 · 같은 고침 = 메모(셈 길마다 다시 검사 안 함).""" tag = (_hash(row), _hash(edits)) got = _MEMO.get(tag) if got is None: if len(_MEMO) > 64: # ponytail: 통째 비움 · 쓰는 SK 가 많아 느려지면 LRU 로 _MEMO.clear() stand, key = _stand(row) new, why = _eqn().edit_row(whole, stand, edits) got = _MEMO[tag] = ({**new, "키": key}, why) return got def on(row: dict, doc: Any, whole: Any) -> tuple[dict, list[dict]]: """셈 길 — 고침 얹은 줄 · 못 얹음 = 바탕 그대로 + 짝잃음 `수식`.""" edits = (doc.get(KEY) if isinstance(doc, dict) else None) or [] if not edits: return row, [] new, why = apply(row, edits, whole) if why: return row, [{"cid": LOST, "까닭": "수식 고침 못 얹음 — " + " · ".join(why)}] return new, [] def strict(base: dict) -> None: """저장 — 이 SK 고침이 못 얹히면 400(바탕 SK 사슬 것 = 알림만).""" for x in base.get("짝잃음") or []: if x.get("cid") == LOST and "바탕" not in x: raise sd.DocError(x["까닭"]) # ── 길 ────────────────────────────────────────────────────────────── def _plain(doc: Any) -> tuple[dict, Any, dict]: """(정한 문서, 사본 색인, 고침 없는 바탕 줄) — 바탕 참조 · 빈 SK = DocError.""" from M02_MasterTemplete import M02_Sangeun_Calc as calc if not isinstance(doc, dict): raise sd.DocError("단가산출 문서가 묶음 아님") base = doc.get("바탕") or {} if "참조" in base: raise sd.DocError("바탕 참조 단가산출 — 수식은 바탕 단가산출에서") doc = calc.skc.settle(doc) whole = calc.skc.over(doc, calc.labor.m01.loaded()[1]) bare = {k: v for k, v in doc.items() if k != KEY} row = calc._base_of(bare, whole, calc.refs.read_ref, ())["줄"] if not row.get("중간") and not row.get("호표") and not row.get("결과"): raise sd.DocError("수식 없음 — 바탕 로직 · 가져온 식이 없는 단가산출") return doc, whole, row def _tree(doc: dict, whole: Any, base: dict, inputs: dict | None, ans: dict | None) -> dict: """수식 = 바탕 줄(+ 고침 · 식 덮개 · 상수 · 표값 · 변수줄) + SK 셈 답 합계 · 소계(796-2c).""" from M02_MasterTemplete import M02_Sangeun_Const as const from M02_MasterTemplete import M02_Sangeun_Equation_Full as full try: shown = const.formulas(base, doc) except Exception: # noqa: BLE001 — 틀린 식 덮개 = 바탕 그대로(짝잃음은 셈 길이 알림) shown = base return full.attach(export(whole, shown, inputs), doc, whole, base, inputs, ans) def read(doc: Any, inputs: dict | None = None) -> dict[str, Any]: """{ok, 수식} — 저장본 · 화면 캐시 고침 얹은 수식(못 얹음 = 바탕 그대로 + 까닭).""" from M02_MasterTemplete import M02_Sangeun_Equation_Full as full eqn = _eqn() try: doc, whole, row = _plain(doc) now, lost = on(row, doc, whole) out = {"ok": True, "수식": _tree(doc, whole, now, inputs, full.answer(doc, inputs))} except (sd.DocError, *eqn._ERR) as e: return {"ok": False, "reason": str(e) or type(e).__name__} return out | ({"reason": lost[0]["까닭"]} if lost else {}) def links(doc: Any, ask: dict) -> dict[str, Any]: """새 기호 연결 후보(800-3) — M01 과 같은 후보 길(읽기만).""" return _eqn().links(ask) def preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]: """고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본 SK 금액), 후}.""" from M02_MasterTemplete import M02_Sangeun_Equation_Full as full eqn = _eqn() try: doc, whole, row = _plain(doc) new, why = apply(row, edits, whole) if edits else (row, []) if why: return {"되나": False, "까닭": why} bare = {k: v for k, v in doc.items() if k != KEY} mine = {**bare, KEY: edits} if edits else bare ans = full.answer(mine, inputs) eq = _tree(mine, whole, new, inputs, ans) before = full.total(full.answer(bare, inputs) if edits else ans) before = before or export(whole, row, inputs)["합계"] except (sd.DocError, *eqn._ERR) as e: return {"되나": False, "까닭": [f"셈 막힘 — {e}"]} return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}