feat(796-2b): M02 단가산출 수식 서버 길 — 사본 줄 수식 읽기 · 고침 미리 셈 · 사본 위 고침 기록(수식고침) · [저장] 정본 셈

- 새 M02_Sangeun_Equation — 문서 덧칸 `수식고침`(M01 수식 고침 꼴 + 까닭) = 사본 위 기록 · 사본은 찍은 그대로(원래 대비)
- 셈 = _base_of 가 바탕 줄(사본 · 가져온 식)에 얹음 → 모든 셈 길 한 벌 · 못 얹음 = 짝잃음 「수식」 · 저장 = 400
- 길 POST …/sangeun/equation(층 읽기) · /equation/preview(층 쓰기 · 저장 없음) · 저장 = SK [저장]
- 가져온 식(SK 키) = 잠깐 키 GX999997 로 엔진에 얹음 · 사본 색인 Copy 얕은 사본(__copy__)
- 원래(sheet) = 수식고침 뺀 셈 · 수식고침 SK 펼치지 않음 · 더한 입력 이름 입력값에 남김
- 시험 test_796_2b_m02_equation 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:05:48 +09:00
co-authored by Claude Opus 5.5
parent 04b5592091
commit f02c39c98a
7 changed files with 392 additions and 13 deletions
+9 -9
View File
@@ -32,12 +32,12 @@ import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Doc as labor, M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Copy as skc, M02_Sangeun_Views as views
from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd, M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Views as views
COSTS = labor.COSTS
Ref = dict[str, Any]
@@ -120,13 +120,13 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s
"""이 SK 가 얹힐 바탕 — {줄, 고름, 자재표시, 입력값, 켜쌓기율, 짝잃음}."""
base = doc.get("바탕")
empty = {"고름": {}, "자재표시": {}, "입력값": {}, "켜쌓기율": None, "짝잃음": []}
if not base:
return {**empty, "줄": sd.empty_row(doc)}
if "로직" in base:
if not base or "로직" in base: # 사본(795-1) · 가져온 식 · 그 위 수식 고침(796-2b)
try:
return {**empty, "줄": skc.over(doc, whole).logic(base["로직"])} # 사본(795-1)
row = skc.over(doc, whole).logic(base["로직"]) if base else sd.empty_row(doc)
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
raise sd.DocError(f"바탕 로직 · {e}") from e
row, lost = eqm.on(row, doc, skc.over(doc, whole))
return {**empty, "줄": row, "짝잃음": lost}
ref = base["참조"]
rid = refs.ref_id(ref)
if rid in trail:
@@ -533,7 +533,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
try:
rough = sd.clean(doc, None, whole)
_check_refs(rough, here_)
base = _base_of(rough, whole, refs.read_ref, ())
eqm.strict(base := _base_of(rough, whole, refs.read_ref, ())) # 수식 고침(796-2b)
out = sd.clean(doc, base["줄"], whole)
out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"]
for one in out["더한줄"]: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량
+8 -3
View File
@@ -30,6 +30,7 @@ from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Formula as fx
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -159,8 +160,11 @@ def lost(row: dict, doc: dict) -> list[dict]:
def no_flat(doc: Any) -> None:
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개") or fx.has(doc)):
raise sd.DocError("상수 · 표값 덮개 · 식 칸 단가산출은 펼치지 않음(참조 그대로)")
keys = ("상수덮개", "표값덮개", eqm.KEY) # 수식 고침(796-2b)
if isinstance(doc, dict) and (any(doc.get(k) for k in keys) or fx.has(doc)):
raise sd.DocError(
"상수 · 표값 덮개 · 식 칸 · 수식 고침 단가산출은 펼치지 않음(참조 그대로)"
)
if imp.is_imported(doc): # 가져온 식(793-2) = M01 기초 없음
raise sd.DocError("가져온 식 단가산출은 펼치지 않음(참조 그대로)")
@@ -231,6 +235,7 @@ def clean_more(raw: Any, out: dict) -> dict:
out["까닭"] = why
if isinstance(raw.get("사본"), dict): # 로직 사본(795-1) — 셈 · 저장 첫머리 settle 이 정함
out["사본"] = raw["사본"]
out = eqm.clean(raw, out) # 수식 고침(796-2b)
return imp.clean(raw, fx.clean(raw, out)) # 식 칸(792-1) · 가져온 식(793-2)
@@ -336,7 +341,7 @@ def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -
def _bare(doc: dict) -> dict:
out = {k: v for k, v in doc.items() if k not in (*NEW, *fx.FIELDS, "뺀더한줄")}
out = {k: v for k, v in doc.items() if k not in (*NEW, *fx.FIELDS, "뺀더한줄", eqm.KEY)}
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
+5
View File
@@ -84,6 +84,11 @@ class Copy:
def __getattr__(self, name: str) -> Any:
return getattr(self._whole, name)
def __copy__(self) -> Copy: # 수식 고침(796-2b · `master_equation_edit.swap`) — 얕은 사본
out = object.__new__(Copy)
out.__dict__.update(self.__dict__)
return out
def _rows(copy: dict) -> list[dict]:
raw = json.dumps(prices.to_json([*copy["로직"], *(copy.get("단가") or [])]), ensure_ascii=False)
+3 -1
View File
@@ -315,7 +315,9 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
given = _given(doc.get("입력값"), "입력값")
opened = [str(x) for x in doc.get("열린입력") or []]
if base_row is not None:
keep = names | _added_names(whole, doc.get("더한줄"))
from M02_MasterTemplete import M02_Sangeun_Equation as eqm # 수식 고침 더한 입력(796-2b)
keep = names | _added_names(whole, doc.get("더한줄")) | eqm.added_inputs(doc)
given = {k: v for k, v in given.items() if k in keep}
bad = [x for x in opened if x not in names or x in given]
if bad:
+157
View File
@@ -0,0 +1,157 @@
"""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 가 얹음).
"""
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:
stand, key = _stand(row)
return _eqn().export_row(whole, stand, inputs) | {"열쇠": 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 read(doc: Any, inputs: dict | None = None) -> dict[str, Any]:
"""{ok, 수식} — 저장본 · 화면 캐시 고침 얹은 수식(못 얹음 = 바탕 그대로 + 까닭)."""
eqn = _eqn()
try:
doc, whole, row = _plain(doc)
now, lost = on(row, doc, whole)
out = {"ok": True, "수식": export(whole, now, 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 preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]:
"""고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본), 후}."""
eqn = _eqn()
try:
doc, whole, row = _plain(doc)
before = export(whole, row, inputs)["합계"]
new, why = apply(row, edits, whole) if edits else (row, [])
if why:
return {"되나": False, "까닭": why}
eq = export(whole, new, inputs)
except (sd.DocError, *eqn._ERR) as e:
return {"되나": False, "까닭": [f"셈 막힘 — {e}"]}
return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}
+33
View File
@@ -7,6 +7,8 @@
(만들 층 쓰기).
로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성]
(화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기).
수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 ·
층 쓰기) — 저장 없음 · 고침 = 화면 캐시 `수식고침` → [저장].
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
@@ -27,6 +29,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Formula as fx
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete import M02_Sangeun_Imported as imp
@@ -215,3 +218,33 @@ def sangeun_copy(
raise HTTPException(status_code=404, detail="price · regen")
args = (body.문서, _JOBS[how], body.입력값)
return _call(_root(session, layer, owner), _sd(skc.preview), *args)
class EquationBody(BaseModel):
문서: dict[str, Any]
inputs: dict[str, Any] | None = None
고침: list[Any] | None = None
@router.post("/layers/{layer}/sangeun/equation")
def sangeun_equation(
layer: Layer,
body: EquationBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""수식 탭 읽기(796-2b) — 사본(가져온 식) 줄 + 문서 `수식고침` → {ok, 수식 | reason}."""
return _call(_root(session, layer, owner), _sd(eqm.read), body.문서, body.inputs)
@router.post("/layers/{layer}/sangeun/equation/preview")
def sangeun_equation_preview(
layer: Layer,
body: EquationBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""수식 고침 미리 셈(796-2b · 층 쓰기 · 저장 없음) — 고침 = `수식고침` 통째 →
{되나, 까닭, 줄, 수식, 전, 후}."""
folder = _root(session, layer, owner, write=True)
return _call(folder, _sd(eqm.preview), body.문서, body.고침 or [], body.inputs)