From f02c39c98a3e5e4035acb4c8262ece6645848763 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 12:05:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(796-2b):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=88=98=EC=8B=9D=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B8=B8=20=E2=80=94=20=EC=82=AC=EB=B3=B8=20=EC=A4=84=20?= =?UTF-8?q?=EC=88=98=EC=8B=9D=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EB=AF=B8=EB=A6=AC=20=EC=85=88=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=9C=84=20=EA=B3=A0=EC=B9=A8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?(=EC=88=98=EC=8B=9D=EA=B3=A0=EC=B9=A8)=20=C2=B7=20[=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=20=EC=A0=95=EB=B3=B8=20=EC=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 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 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 18 +- M02_MasterTemplete/M02_Sangeun_Const.py | 11 +- M02_MasterTemplete/M02_Sangeun_Copy.py | 5 + M02_MasterTemplete/M02_Sangeun_Doc.py | 4 +- M02_MasterTemplete/M02_Sangeun_Equation.py | 157 ++++++++++++++++ M02_MasterTemplete/M02_Sangeun_Router.py | 33 ++++ resources/tester/test_796_2b_m02_equation.py | 177 +++++++++++++++++++ 7 files changed, 392 insertions(+), 13 deletions(-) create mode 100644 M02_MasterTemplete/M02_Sangeun_Equation.py create mode 100644 resources/tester/test_796_2b_m02_equation.py diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 941e830ae..09d24f386 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -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) = 넣을 때 수량 · 없으면 지금 수량 diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py index bef0c1854..2b610d478 100644 --- a/M02_MasterTemplete/M02_Sangeun_Const.py +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -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 | {"덮개": {}, "뺀줄": [], "더한줄": []} diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 0501892a5..1b474a137 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -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) diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 24661c871..4cca5dbc2 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -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: diff --git a/M02_MasterTemplete/M02_Sangeun_Equation.py b/M02_MasterTemplete/M02_Sangeun_Equation.py new file mode 100644 index 000000000..d0fb68bd3 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Equation.py @@ -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["합계"]} diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 9f6b78f1f..c684372dc 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -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) diff --git a/resources/tester/test_796_2b_m02_equation.py b/resources/tester/test_796_2b_m02_equation.py new file mode 100644 index 000000000..57d93fce6 --- /dev/null +++ b/resources/tester/test_796_2b_m02_equation.py @@ -0,0 +1,177 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""796-2b M02 단가산출 수식 탭 — 사본 줄 수식 · 고침 = 금액 · 저장 = 사본 위 정본(임시 storage). + +① 사본 줄 수식 읽기 = SK 금액 ② 고침 미리 셈 = 금액 바뀜(저장 없음) ③ [저장] = 서버가 다시 얹어 셈 · +틀린 고침 400 · M01 그대로 ④ 변수 더함 · 지움(쓰는 곳 막음) · 기호 · 첨자 전파 ⑤ 권한 = 층 쓰기 +⑥ 가져온 식 SK 같은 길 ⑦ M01 바뀌어도 그대로 · [재생성] = 고침 다시 얹음 · 못 얹음 알림. +""" + +from __future__ import annotations + +import copy + +import pytest +from fastapi import HTTPException + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from M02_MasterTemplete import M02_Sangeun_Equation as eqm +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from M02_MasterTemplete import M02_Sangeun_Router as router +from resources.tester.test_795_1_sk_copy import ( + _calc, + _doc, + _fake, + _imported_row, + _live, + _save, + _sk, + _sum, +) +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공 +DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}] + + +def _line(eq, key, kind=None): + return next(x for x in eq["줄"] if x.get("열쇠") == key and (kind is None or x["갈래"] == kind)) + + +def _read(folder, doc): + with store.at(folder): + return eqm.read(doc) + + +def _preview(folder, doc, edits): + with store.at(folder): + return eqm.preview(doc, edits) + + +def test_read_copy_row_equals_sk_amount(world): + """① 사본 줄 수식 — 합계 = SK 금액 = M01 셈 · 키보드 글.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": KEY}) + got = _read(me, _doc(me, key)) + assert got["ok"], got + eq = got["수식"] + assert eq["열쇠"] == KEY and eq["합계"]["계"] == _sum(me, key) + assert eq["합계"]["계"] == labor.run_line(_live(), KEY, {})["기준"]["계"] + assert _line(eq, "형틀목공", "호표")["입력글"] == "형틀목공" + + +def test_edit_changes_amount_save_is_canonical(world): + """② · ③ 고침 미리 셈 = 금액 바뀜 · [저장] = 고침 목록 남음 · 서버 셈 = 미리 셈 · M01 그대로.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": KEY}) + was = _sum(me, key) + m01_row = copy.deepcopy(_live().logic(KEY)) + got = _preview(me, _doc(me, key), DOUBLE) + assert got["되나"], got["까닭"] + assert got["전"]["계"] == was and got["후"]["계"] > was + assert got["줄"]["호표"][0]["수량"] == "형틀목공 * 2" + assert _sum(me, key) == was # 미리 셈 = 저장 없음 + doc = {**_doc(me, key), eqm.KEY: DOUBLE} + _save(me, key, doc) + saved = _doc(me, key) + assert saved[eqm.KEY][0]["까닭"] == "sub2임시 시험" + assert saved["사본"]["로직"][0]["호표"][0]["수량"] == m01_row["호표"][0]["수량"] # 사본 = 원래 + now = _calc(me, saved) + assert now["기준"]["계"] == got["후"]["계"] + assert now["원래"]["계"] == was # 원래 대비 = 고침 없는 사본 + assert _read(me, saved)["수식"]["합계"]["계"] == got["후"]["계"] + assert _live().logic(KEY) == m01_row # M01 정본 안 바뀜 + bad = {**saved, eqm.KEY: [{"할일": "식", "자리": "호표:0", "식": "없는값 × 2"}]} + with pytest.raises(store.StoreError): + _save(me, key, bad) + assert _sum(me, key) == got["후"]["계"] + + +def test_variables_add_drop_rename(world): + """④ 변수 더함 → 식에서 부름 · 쓰는 변수 지움 막음 · 기호 · 첨자 = 쓰는 식 모두.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": KEY}) + doc = _doc(me, key) + add = [ + {"할일": "더함", "갈래": "중간", "열쇠": "할증", "식": "1.5", "뒤": "보통인부"}, + {"할일": "식", "자리": "호표:1", "식": "보통인부 × 할증"}, + ] + got = _preview(me, doc, add) + assert got["되나"], got["까닭"] + assert got["후"]["노무비"] > got["전"]["노무비"] + assert "할증" in [v["열쇠"] for v in got["수식"]["변수표"]] + got = _preview(me, doc, [{"할일": "지움", "열쇠": "시공량"}]) + assert not got["되나"] and "형틀목공" in " ".join(got["까닭"]) + sign = [{"할일": "변수", "열쇠": "시공량", "기호": "Q", "아래": "d", "이름": "하루 시공량"}] + got = _preview(me, doc, sign) + assert got["되나"] and got["후"] == got["전"] # 이름만 — 금액 그대로 + assert "Q_d" in _line(got["수식"], "형틀목공", "중간")["입력글"] + assert "Q_d" in _line(got["수식"], "보통인부", "중간")["입력글"] + _save(me, key, {**doc, eqm.KEY: sign}) + again = _read(me, _doc(me, key))["수식"] + assert next(v for v in again["변수표"] if v["열쇠"] == "시공량")["기호"] == "Q" + + +def _session(role="USER"): + return {"role": role, "company_id": 7, "user_id": 42} + + +def test_permission_layer_write(world, monkeypatch): + """⑤ 미리 셈 = 그 층 쓰기 — 마스터 = 시스템 관리자 · 회사 = 회사 관리자 · 개인 = 본인.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": KEY}) + body = router.EquationBody(문서=_doc(me, key), 고침=DOUBLE) + for layer in ("system", "company"): + with pytest.raises(HTTPException) as e: + router.sangeun_equation_preview(layer, body, None, _session()) + assert e.value.status_code == 403 + got = router.sangeun_equation_preview("personal", body, None, _session()) + assert got["되나"] and got["후"]["계"] > got["전"]["계"] + got = router.sangeun_equation("personal", body, None, _session()) + assert got["ok"] and got["수식"]["합계"]["계"] == got["수식"]["합계"]["계"] + monkeypatch.setattr(router, "_root", lambda *a, **k: me) + admin = router.sangeun_equation_preview("system", body, None, _session("SYSTEM_ADMIN")) + assert admin["되나"] + + +def test_imported_same_path(world): + """⑥ 가져온 식 SK(바탕 null · 가져온.로직) — 같은 읽기 · 고침 · 저장.""" + me = world["personal"] + with store.at(me): + key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"] + doc = _doc(me, key) + got = _read(me, doc) + assert got["ok"], got + assert got["수식"]["합계"]["계"] == _sum(me, key) + edit = [{"할일": "식", "자리": "호표:0", "식": "0.5 × 2"}] + got = _preview(me, doc, edit) + assert got["되나"], got["까닭"] + assert got["후"]["노무비"] == pytest.approx(got["전"]["노무비"] * 2, abs=1) + _save(me, key, {**doc, eqm.KEY: edit}) + now = _calc(me, _doc(me, key)) + assert now["기준"]["노무비"] == pytest.approx(got["후"]["노무비"], abs=1) + assert _doc(me, key)["가져온"]["로직"]["호표"][0]["수량"] == "0.5" # 엑셀 줄 그대로 + + +def test_m01_change_kept_regen_reapplies(world, monkeypatch): + """⑦ M01 바뀌어도 고친 SK 그대로 · [재생성] = 새 사본에 고침 다시 · 자리 없으면 못얹은.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": KEY}) + _save(me, key, {**_doc(me, key), eqm.KEY: DOUBLE}) + was = _sum(me, key) + row = copy.deepcopy(_live().logic(KEY)) + row["호표"][1]["수량"] = f"({row['호표'][1]['수량']}) * 3" + _fake(monkeypatch, [row]) + assert _sum(me, key) == was + with store.at(me): + got = skc.preview(_doc(me, key), skc.REGEN) + assert not got["못얹은"] and got["후"]["계"] > was # 새 판 + 같은 고침 + gone = copy.deepcopy(row) + gone["호표"] = [] + _fake(monkeypatch, [gone]) + with store.at(me): + got = skc.preview(_doc(me, key), skc.REGEN) + assert eqm.LOST in [x["cid"] for x in got["못얹은"]] # 그 밖 = 없어진 줄 M01판(790-2) + assert m01.loaded()[1].logic(KEY)["호표"] == []