From be43dad64e2d606e323a4db59da7a4bcd35945ec Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 15:38:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(796-2e-1):=20=EB=8D=94=ED=95=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=20=EB=B8=94=EB=A1=9D=20[=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=81=8A=EA=B8=B0]=20=C2=B7=20[=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0]=20=EC=84=9C=EB=B2=84=20=E2=80=94=20?= =?UTF-8?q?=EB=81=8A=EC=9D=8C=20=3D=20=EC=9D=B4=20SK=20=EC=95=88=20?= =?UTF-8?q?=EC=9E=90=EA=B8=B0=20=EC=82=AC=EB=B3=B8=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EC=88=98=EC=8B=9D=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=95=8C=20=EC=A4=84=20=EC=95=88=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Sangeun_Block 신설: 끊기(닫힌 묶음 굳힘 · 금액 그대로) · 다시 잇기(고침 버림 + 원본 갈음) · 끊은 기초 수식 읽기 · 미리 셈 · 저장 검사 - Calc.sub_run 이 끊은 기초 자기 줄 + 수식 고침을 얹어 셈 · Copy.settle 이 다시 잇기 표지 처리 · [단가 갱신] · [재생성] 응답 끊은기초 한 줄 - Router: block/unlink · relink · equation 몸 블록 · 새 시험 test_796_2e_unlink_basis 6 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Sangeun_Block.py | 212 ++++++++++++++++++ M02_MasterTemplete/M02_Sangeun_Calc.py | 15 +- M02_MasterTemplete/M02_Sangeun_Copy.py | 19 ++ M02_MasterTemplete/M02_Sangeun_Doc.py | 8 +- .../M02_Sangeun_Equation_Full.py | 8 +- M02_MasterTemplete/M02_Sangeun_Router.py | 29 +++ resources/tester/test_796_2e_unlink_basis.py | 186 +++++++++++++++ 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 M02_MasterTemplete/M02_Sangeun_Block.py create mode 100644 resources/tester/test_796_2e_unlink_basis.py diff --git a/M02_MasterTemplete/M02_Sangeun_Block.py b/M02_MasterTemplete/M02_Sangeun_Block.py new file mode 100644 index 000000000..e26f43455 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Block.py @@ -0,0 +1,212 @@ +"""M02 단가산출(SK) 더한 기초 블록 [연결 끊기] · [다시 잇기](PLAN 796-2e · 요구 659-3 · 656-1). + +더한 기초 = 더한줄 로직(`종류: 로직`). 기본 = 연결 — 이 SK 의 로직 사본(`M02_Sangeun_Copy`)이 닿는 그 기초 +(원본 바뀜은 [재생성]·[다시 잇기]에서 따라옴 · 자동 아님 · 보기만). +끊음 = 더한줄 칸 `끊음` {시점, 로직 [M01 줄]} — 끊는 때 그 기초가 닿는 닫힌 묶음(하위 로직 · 찾기 표 · + 요율 · 사본 줄 그대로 = 금액 그대로)을 이 SK 안 자기 사본으로 굳힘 → 그 줄만 고침: + 칸 `수식고침`(796-2b 와 같은 꼴 · 같은 `eqm.apply`)을 그 기초 줄에 얹어 셈(`Calc.sub_run`). + [재생성] = 끊은 기초의 줄은 안 바꿈(자원 단가만 — 단가는 SK 사본 `단가` · [단가 갱신] 한 길). +다시 잇기 = `끊음` · `수식고침` 뗌 + 표지 `다시잇기`(서버만 · 셈·저장 첫머리 `Copy.settle` 이 그 기초가 닿는 + 닫힌 묶음을 지금 M01 줄로 갈음하고 표지 뗌 — 고친 것 버림). +길(`M02_Sangeun_Router`): POST …/sangeun/block/{unlink|relink} {문서, 차례} → {문서, 전, 후}(저장 없음) · + POST …/sangeun/equation[/preview] 몸 `블록` = 더한줄 차례 → 그 끊은 기초의 수식(고침 통째 미리 셈). +저장 = SK [저장](서버가 다시 얹어 검사 · 셈 · 브라우저 값 안 받음 — 끊음 줄은 사본과 같은 `taken` 꼴 검사). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from M02_MasterTemplete import M02_Item_Prices as prices +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 + +CUT, EDIT, REJOIN = "끊음", eqm.KEY, "다시잇기" +MANY = 400 # 끊은 기초가 닿는 줄 상한 +_OWN: dict[int, tuple[Any, Any, Any]] = {} + + +def is_block(one: Any) -> bool: + """더한 기초 = 더한줄 로직(참조 · 고정 · 자원 줄 아님).""" + return isinstance(one, dict) and one.get("종류") == "로직" and bool(one.get("로직")) + + +def clean(raw: dict, where: str) -> dict: + """`sd._added` 가 부름 — 끊음 · 수식고침 꼴만(뜻 검사는 셈 · 저장 `strict`).""" + cut = raw.get(CUT) + if not cut: + if raw.get(EDIT): + raise sd.DocError(f"{where} · 수식 고침은 연결을 끊은 기초만") + return {} + rows = cut.get("로직") if isinstance(cut, dict) else None + code = str(raw.get("로직")) + if any(raw.get(k) not in (None, {}, []) for k in sd.BODY): + raise sd.DocError(f"{where} · 넣은 SK 몸은 이미 이 SK 안 사본 — 연결 끊기 없음") + if ( + not isinstance(rows, list) + or len(rows) > MANY + or not all(isinstance(r, dict) and r.get("키") for r in rows) + ): + raise sd.DocError(f"{where} · 끊은 기초 사본 꼴이 틀림") + rows = [r for r in rows if str(r["키"])[:2] not in skc.RES] # 단가 = SK 사본 한 곳 + if code not in {str(r["키"]) for r in rows}: + raise sd.DocError(f"{where} · 끊은 기초 사본에 「{code}」 줄 없음") + return {CUT: {"시점": str(cut.get("시점") or ""), "로직": rows}} | eqm.clean(raw, {}) + + +def own(one: dict, whole: Any) -> Any: + """끊은 기초의 셈 색인 — `whole` 위에 자기 사본 줄(끊음 아님 = `whole` 그대로).""" + cut = one.get(CUT) + if not isinstance(cut, dict): + return whole + hit = _OWN.get(id(cut)) + if hit and hit[0] is cut and hit[1] is whole: + return hit[2] + if len(_OWN) > 32: # ponytail: 통째 비움 · 끊은 기초가 많아 느려지면 LRU 로 + _OWN.clear() + got = skc.Copy(whole, skc._rows(cut)) + _OWN[id(cut)] = (cut, whole, got) + return got + + +def row_of(whole: Any, one: dict, edits: list | None = None) -> tuple[dict, list[str], Any]: + """(끊은 기초 줄 + 수식 고침, 못 얹은 까닭, 그 기초 셈 색인) — `edits` None = 그 기초 자기 고침.""" + mine = own(one, whole) + row = mine.logic(str(one["로직"])) + use = one.get(EDIT) if edits is None else edits + if not use: + return row, [], mine + new, why = eqm.apply(row, use, mine) + return new, why, mine + + +def strict(doc: dict, whole: Any) -> None: + """저장 — 끊은 기초 고침이 못 얹히면 400.""" + for at, one in enumerate(doc.get("더한줄") or [], 1): + if is_block(one) and one.get(EDIT): + why = row_of(whole, one)[1] + if why: + raise sd.DocError(f"더한줄 {at}째 수식 고침 못 얹음 — " + " · ".join(why)) + + +def names(doc: Any) -> list[str]: + """끊은 기초 이름들([단가 갱신] · [재생성] 보고).""" + adds = (doc.get("더한줄") if isinstance(doc, dict) else None) or [] + return [str(x.get("이름") or x.get("로직")) for x in adds if is_block(x) and x.get(CUT)] + + +# ── 끊기 · 다시 잇기 ─────────────────────────────────────────────────── +def _one(doc: Any, at: Any) -> dict: + adds = (doc.get("더한줄") if isinstance(doc, dict) else None) or [] + if not isinstance(at, int) or isinstance(at, bool) or not 0 <= at < len(adds): + raise sd.DocError(f"더한 기초 차례 「{at}」 없음") + if not is_block(adds[at]): + raise sd.DocError("더한줄이 기초(로직) 아님") + return adds[at] + + +def _put(doc: dict, at: int, one: dict) -> dict: + return {**doc, "더한줄": [one if k == at else x for k, x in enumerate(doc["더한줄"])]} + + +def _closure(doc: dict, code: str) -> list[dict]: + """`code` 가 닿는 닫힌 묶음(자원 단가 빼고) — 사본 있으면 사본 줄 그대로 · 없으면 지금 M01.""" + over = doc[skc.KEY]["로직"] if skc.has(doc) else None + got = prices.collect([code], None, None, over) + got.pop("_빠진", None) + whole = skc.over(doc, skc.whole_()) + keys = [str(r.get("키")) for data in got.values() for r in data[prices.m01.items_key(data)]] + rows = [ + whole.index[k[:2]][k] + for k in keys + if k[:2] not in skc.RES and k in whole.index.get(k[:2], {}) + ] + return prices.to_json(rows) + + +def unlink(doc: Any, at: int) -> dict: + """[연결 끊기] — 그 기초가 닿는 닫힌 묶음을 이 SK 안에 굳힘(금액 그대로).""" + one = _one(doc, at) + if one.get(CUT): + raise sd.DocError("이미 연결을 끊은 기초") + if any(one.get(k) not in (None, {}, []) for k in sd.BODY): + raise sd.DocError("넣은 SK 몸은 이미 이 SK 안 사본 — 연결 끊기 없음") + rows = _closure(skc.settle(doc), str(one["로직"])) + if not rows: + raise sd.DocError(f"기초 「{one['로직']}」 줄을 못 찾음") + cut = {"시점": datetime.now().isoformat(timespec="seconds"), "로직": rows} + return _put(doc, at, {**one, CUT: cut}) + + +def relink(doc: Any, at: int) -> dict: + """[다시 잇기] — 끊음 · 고침 버림 + 표지(셈·저장 때 사본을 지금 M01 줄로 갈음).""" + one = _one(doc, at) + if not one.get(CUT): + raise sd.DocError("연결된 기초") + keep = {k: v for k, v in one.items() if k not in (CUT, EDIT)} + return _put(doc, at, {**keep, REJOIN: True}) + + +def change(doc: Any, at: Any, how: str) -> dict: + """길 몸 — {문서, 전, 후}(전후 SK 금액 · 끊기 전후 같음).""" + from M02_MasterTemplete import M02_Sangeun_Equation_Full as full + + new = (unlink if how == "unlink" else relink)(doc, at) + money = [full.total(full.answer(d, None)) for d in (doc, new)] + return {"문서": new, "전": money[0], "후": money[1]} + + +# ── 수식 탭 ──────────────────────────────────────────────────────────── +def _setup(doc: Any, at: Any) -> tuple[dict, Any, dict]: + if not isinstance(doc, dict): + raise sd.DocError("단가산출 문서가 묶음 아님") + one = _one(doc, at) + if not one.get(CUT): + raise sd.DocError("연결된 기초 — 먼저 [연결 끊기]") + doc = skc.settle(doc) + return doc, skc.over(doc, skc.whole_()), doc["더한줄"][at] + + +def view(whole: Any, doc: dict, one: dict, edits: list | None, inputs: dict | None) -> tuple: + """(수식, 못 얹은 까닭) — 고친 줄을 그대로 그림(세부 덮개 · 뺀줄 얹기 전 = 고침 주소 그대로).""" + row, why, mine = row_of(whole, one, edits) + if why: + return None, why + code = str(one["로직"]) + given = {k: v for k, v in (one.get("입력값") or {}).items() if str(v).strip()} + given = {**(doc.get("입력값") or {}), **(inputs or {}), **given} + return eqm.export(mine, {**row, "키": code}, given), [] + + +def read(doc: Any, at: Any, inputs: dict | None = None) -> dict[str, Any]: + """{ok, 수식 | reason} — 끊은 기초 수식(자기 고침 얹음).""" + doc, whole, one = _setup(doc, at) + eq, why = view(whole, doc, one, None, inputs) + return {"ok": False, "reason": " · ".join(why)} if why else {"ok": True, "수식": eq} + + +def preview(doc: Any, at: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]: + """고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(기초 고침 없음), 후, 금액전, 금액후(SK)}.""" + from M02_MasterTemplete import M02_Sangeun_Equation_Full as full + + doc, whole, one = _setup(doc, at) + eq, why = view(whole, doc, one, edits, inputs) + if why: + return {"되나": False, "까닭": why} + bare = {k: v for k, v in one.items() if k != EDIT} + mine = {**bare, EDIT: edits} if edits else bare + plain = view(whole, doc, bare, None, inputs)[0] if edits else eq + row = row_of(whole, bare, edits)[0] + money = [full.total(full.answer(d, inputs)) for d in (doc, _put(doc, at, mine))] + return { + "되나": True, + "까닭": [], + "줄": {**row, "입력": []}, # 입력 칸 = SK 쪽 것만(끊은 기초 입력은 더한 기초 자리에서) + "수식": eq, + "전": plain["합계"], + "후": eq["합계"], + "금액전": money[0], + "금액후": money[1], + } diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index bcb77eff2..3b87bfb92 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -35,6 +35,7 @@ from typing import Any 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_Block as blk 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 @@ -247,8 +248,9 @@ def sub_run(one: dict, whole: Any, patched: bool = True) -> dict[str, Any]: 짝잃음 · 바탕판(덮기 전 기초 지문) · 입력 = 채운 줄 값(`_fill`). `patched` False = 덮기 전.""" code = str(one["로직"]) + mine = blk.own(one, whole) # 끊은 기초(796-2e) = 자기 사본 줄 위 try: - row = whole.logic(code) + row = mine.logic(code) except Exception as e: # noqa: BLE001 — 엔진 FormulaError return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} if any(one.get(k) not in (None, {}, []) for k in sd.BODY): # 넣은 SK 몸(740-1) = 이름 없는 SK @@ -256,13 +258,15 @@ def sub_run(one: dict, whole: Any, patched: bool = True) -> dict[str, Any]: doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": code}} doc |= {"덮개": {}, "뺀줄": [], "더한줄": [], **{k: one[k] for k in keep if k in one}} try: - return {**run(doc, whole), "바탕판": fingerprint(row)} + return {**run(doc, mine), "바탕판": fingerprint(row)} except sd.DocError as e: return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} - got = sd.patched(row, one if patched else {}) - over = _Over(whole, code, {**got["줄"], "키": code}) + edited, lost = blk.row_of(whole, one)[0:2] if patched else (row, []) # 수식 고침(796-2e) + got = sd.patched(edited, one if patched else {}) + over = _Over(mine, code, {**got["줄"], "키": code}) out = labor.run_line(over, code, one.get("입력값"), None, got["고름"] or None) - return {**out, "정체": got["정체"], "짝잃음": got["짝잃음"], "바탕판": fingerprint(row)} + miss = [{"cid": eqm.LOST, "까닭": "수식 고침 못 얹음 — " + " · ".join(lost)}] if lost else [] + return {**out, "정체": got["정체"], "짝잃음": got["짝잃음"] + miss, "바탕판": fingerprint(row)} def _bundled(item: dict, sub: dict) -> dict: @@ -548,6 +552,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: rough = sd.clean(doc, None, whole) _check_refs(rough, here_) eqm.strict(base := _base_of(rough, whole, refs.read_ref, ())) # 수식 고침(796-2b) + blk.strict(rough, whole) # 끊은 기초 수식 고침(796-2e) out = sd.clean(doc, base["줄"], whole) out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"] for one in out["더한줄"]: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량 diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 1b474a137..7bdf2e193 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -209,6 +209,19 @@ def grow(copy: dict, fresh: dict | None) -> dict: return {**copy, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)} +def rejoin(copy: dict, adds: list) -> dict: + """[다시 잇기](796-2e) — 표지 낀 더한 기초가 닿는 닫힌 묶음을 지금 M01 줄로 갈음(단가 줄은 더하기만).""" + keys = [ + str(x["로직"]) for x in adds if isinstance(x, dict) and x.get("다시잇기") and x.get("로직") + ] + fresh = snap(keys, []) + if not fresh: + return copy + new = {str(r.get("키")): r for r in fresh["로직"]} + logic = [new.pop(str(r.get("키")), r) for r in copy["로직"]] + list(new.values()) + return grow({**copy, "로직": logic, "판": _fp(logic)}, fresh) + + def taken(raw: Any) -> dict | None: """받은 사본(층 복사 · 새로) — 줄 꼴만 보고 판은 서버가 다시 셈.""" if not has({KEY: raw}): @@ -244,6 +257,8 @@ def settle(doc: Any, save: bool = False) -> Any: copy = stamp(out) elif job == PRICE and copy: copy = refresh(copy, whole_())[0] + if copy and any(isinstance(x, dict) and x.get("다시잇기") for x in out.get("더한줄") or []): + copy = rejoin(copy, out["더한줄"]) if save and copy: copy = grow(copy, stamp(out)) return {**out, KEY: copy} if copy else out @@ -311,6 +326,10 @@ def preview(doc: Any, job: str, given: dict | None = None) -> dict[str, Any]: before = calc.calc_doc(doc, given, False) after = calc.calc_doc(after_doc, given, False) out: dict[str, Any] = {"문서": after_doc, "전": _money(before), "후": _money(after)} + if cut := [ + str(x.get("이름") or x.get("로직")) for x in doc.get("더한줄") or [] if x.get("끊음") + ]: + out["끊은기초"] = cut # 줄은 그대로 · 자원 단가만(요구 656-1) if job == PRICE: copy = _stored(doc.get("키")) or taken(doc.get(KEY)) out["바뀐자원"] = refresh(copy, whole_())[1] if copy else [] diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 4cca5dbc2..3ed709ca5 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -183,7 +183,9 @@ def _added(raw: Any, where: str, whole: Any) -> dict: if row is not None: head["입력값"] = {k: v for k, v in given.items() if k in names} name = name or str(row.get("이름") or code) - head |= _sub_patch(raw, where) | _sub_body(raw, where, whole) + from M02_MasterTemplete import M02_Sangeun_Block as blk # 연결 끊기(796-2e) + + head |= _sub_patch(raw, where) | _sub_body(raw, where, whole) | blk.clean(raw, where) elif raw.get("종류") in ADD_PART: key = str(raw.get("요소") or "") got = _element(whole, key, where) @@ -253,8 +255,8 @@ def _sub_body(raw: dict, where: str, whole: Any) -> dict: def sub_patched(one: dict) -> bool: - """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄 · 넣은 SK 몸(740-1).""" - return one.get("종류") == "로직" and any(one.get(k) for k in ("덮개", "뺀줄", *BODY)) + """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄 · 넣은 SK 몸(740-1) · 끊은 기초(796-2e).""" + return one.get("종류") == "로직" and any(one.get(k) for k in ("덮개", "뺀줄", "끊음", *BODY)) def _added_names(whole: Any, adds: Any) -> set[str]: diff --git a/M02_MasterTemplete/M02_Sangeun_Equation_Full.py b/M02_MasterTemplete/M02_Sangeun_Equation_Full.py index 232ae1a4a..58d305186 100644 --- a/M02_MasterTemplete/M02_Sangeun_Equation_Full.py +++ b/M02_MasterTemplete/M02_Sangeun_Equation_Full.py @@ -97,7 +97,7 @@ def rows(doc: dict, ans: dict, tree: Any, out_ids: set[str] = frozenset()) -> li k = int(hit[1]) if hit else -1 state = "더함" if hit else "덮음" if cover else "바탕" one = adds[k] if 0 <= k < len(adds) else {} - if hit and one.get("종류") == "로직" and sd.sub_patched(one): + if hit and one.get("종류") == "로직" and sd.sub_patched({**one, "끊음": None}): cover = cover or ["세부"] outside = cid in out_ids # 합계 밖 줄(가져온 식 `합밖` · 제외 단계) — 합에 안 듦 out.append( @@ -116,6 +116,8 @@ def rows(doc: dict, ans: dict, tree: Any, out_ids: set[str] = frozenset()) -> li 원래=info.get("원래") if state == "덮음" else None, 까닭=line.get("까닭") or info.get("까닭"), 나무=tree(k) if hit and one.get("종류") == "로직" else None, + 블록=k if hit and one.get("종류") == "로직" else None, # 더한 기초 차례(연결 끊기) + 끊음=True if one.get("끊음") else None, ) ) for cid in doc.get("뺀줄") or []: # 지운 줄 — 금액 0 · 원래 금액만 보임 @@ -152,6 +154,10 @@ def _basic_tree(whole: Any, doc: dict, one: dict, inputs: dict | None) -> dict | if any(one.get(k) not in (None, {}, []) for k in sd.BODY): return None + if one.get("끊음"): # 끊은 기초(796-2e) = 자기 사본 줄 + 수식 고침(고침 주소 그대로) + from M02_MasterTemplete import M02_Sangeun_Block as blk + + return blk.view(whole, doc, one, None, inputs)[0] code = str(one["로직"]) mine = {k: v for k, v in (one.get("입력값") or {}).items() if str(v).strip()} given = {**(doc.get("입력값") or {}), **(inputs or {}), **mine} diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 8787d2897..6ab140d92 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -9,6 +9,8 @@ (화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기). 수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 · 층 쓰기) — 저장 없음 · 고침 = 화면 캐시 `수식고침` → [저장]. +더한 기초 [연결 끊기](796-2e · `M02_Sangeun_Block`) `…/sangeun/block/unlink` · `/relink`(화면 캐시 문서 고침 · +저장 없음 · 층 읽기) · 끊은 기초 수식 = 같은 equation 길 몸 `블록`(더한줄 차례). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) · @@ -26,6 +28,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Block as blk 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 @@ -232,6 +235,7 @@ class EquationBody(BaseModel): 문서: dict[str, Any] inputs: dict[str, Any] | None = None 고침: list[Any] | None = None + 블록: int | None = None # 끊은 더한 기초 차례(796-2e) — 없으면 바탕 줄 @router.post("/layers/{layer}/sangeun/equation") @@ -242,6 +246,8 @@ def sangeun_equation( session: Session = Depends(verify_session), ) -> dict: """수식 탭 읽기(796-2b) — 사본(가져온 식) 줄 + 문서 `수식고침` → {ok, 수식 | reason}.""" + if body.블록 is not None: + return _call(_root(session, layer, owner), _sd(blk.read), body.문서, body.블록, body.inputs) return _call(_root(session, layer, owner), _sd(eqm.read), body.문서, body.inputs) @@ -255,4 +261,27 @@ def sangeun_equation_preview( """수식 고침 미리 셈(796-2b · 층 쓰기 · 저장 없음) — 고침 = `수식고침` 통째 → {되나, 까닭, 줄, 수식, 전, 후}.""" folder = _root(session, layer, owner, write=True) + if body.블록 is not None: + args = (body.문서, body.블록, body.고침 or [], body.inputs) + return _call(folder, _sd(blk.preview), *args) return _call(folder, _sd(eqm.preview), body.문서, body.고침 or [], body.inputs) + + +class BlockBody(BaseModel): + 문서: dict[str, Any] + 차례: int + + +@router.post("/layers/{layer}/sangeun/block/{how}") +def sangeun_block( + layer: Layer, + how: str, + body: BlockBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """더한 기초 연결(796-2e) — unlink [연결 끊기] · relink [다시 잇기] · 답 = 고친 문서(화면 캐시 · + [저장] 때 서버가 다시 셈) + 전 · 후 SK 금액.""" + if how not in ("unlink", "relink"): + raise HTTPException(status_code=404, detail="unlink · relink") + return _call(_root(session, layer, owner), _sd(blk.change), body.문서, body.차례, how) diff --git a/resources/tester/test_796_2e_unlink_basis.py b/resources/tester/test_796_2e_unlink_basis.py new file mode 100644 index 000000000..bb6ce708d --- /dev/null +++ b/resources/tester/test_796_2e_unlink_basis.py @@ -0,0 +1,186 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""796-2e M02 단가산출 더한 기초 블록 [연결 끊기] · [다시 잇기] (임시 storage · M01 은 메모리 덮개). + +① 연결 블록 = 원본(M01) 바뀜을 [재생성] 에서 따라감 ② [연결 끊기] = 금액 그대로 · 이 SK 안 자기 사본 · +원본 바뀜 안 따라감([재생성] 도) ③ 끊은 기초 줄 고침 = 금액 바뀜(미리 셈 = 저장 뒤 서버 셈 · 틀린 고침 400 · +M01 안 바뀜) ④ [다시 잇기] = 고친 것 버림 · 원본 따라감 ⑤ [단가 갱신] = 연결 · 끊은 둘 다 자원 단가 따라감 +⑥ 꼴 검사(연결 기초에 고침 · 이미 끊음 · 차례 밖). +""" + +from __future__ import annotations + +import copy + +import pytest + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Block as blk +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_Full as full +from resources.tester.test_795_1_sk_copy import _calc, _changed, _doc, _fake, _live, _save, _sk +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +FORM = "GC000211" # 합판거푸집 +BASIC = "GC001148" # 굴착기 시간당 — 호표 0 = 시간당 기계 × (1 / 작업량) +ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub3임시 굴착기"} +ONLY = "LB000048" # 굴착기 기초에만 닿는 노임(거푸집엔 안 닿음) +DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "3 / 작업량", "까닭": "sub3임시 시험"}] + + +def _sum(folder, doc): + return _calc(folder, doc)["기준"]["계"] + + +@pytest.fixture +def made(world): + me = world["personal"] + key = _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)]) + return me, key + + +def _cut(me, key): + """끊어서 저장한 SK — (끊기 전 금액, 끊은 문서).""" + doc = _doc(me, key) + with store.at(me): + got = blk.change(doc, 0, "unlink") + assert got["전"]["계"] == got["후"]["계"] # 끊기 전후 같음 + _save(me, key, got["문서"]) + return got["전"]["계"], _doc(me, key) + + +def test_unlink_keeps_amount_and_freezes_rows(made): + """② 끊기 = 금액 그대로 · 닫힌 묶음(하위 로직 포함) 이 SK 안에 · 저장 뒤도 같음 · 수식 탭 표지.""" + me, key = made + was = _sum(me, _doc(me, key)) + plain = _calc(me, _doc(me, key)) + assert plain["기준"]["계"] == was + row_before = copy.deepcopy(_live().logic(BASIC)) + price, doc = _cut(me, key) + assert price == was and _sum(me, doc) == was + cut = doc["더한줄"][0]["끊음"] + keys = {r["키"] for r in cut["로직"]} + assert BASIC in keys and "GC000267" in keys and "GC001147" in keys # 하위 로직까지 + assert not any(k[:2] in skc.RES for k in keys) # 단가는 SK 사본 한 곳 + assert _live().logic(BASIC) == row_before # M01 안 바뀜 + with store.at(me): + eq = full.attach({"줄": []}, doc, skc.over(doc, skc.whole_()), {}, None, _calc(me, doc)) + block = next(r for r in eq["소계"]["줄"] if r.get("블록") == 0) + assert block["끊음"] is True and block["나무"] and block["상태"] == "더함" + assert not block.get("덮개") # 끊기만으로 「세부」 고침 표지 없음 + + +def test_connected_follows_original_on_regen_cut_does_not(made, monkeypatch): + """①·② 원본 바뀜 — 연결 = [재생성] 에서 따라감 · 끊음 = [재생성] 도 안 따라감.""" + me, key = made + was = _sum(me, _doc(me, key)) + _fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 호표 0 수량 × 2 (원본 바뀜) + assert _sum(me, _doc(me, key)) == was # 누르기 전 = 그대로(자동 갱신 없음) + with store.at(me): + got = skc.preview(_doc(me, key), skc.REGEN) + assert got["후"]["계"] > was and "끊은기초" not in got # 연결 = 원본 따라감 + monkeypatch.undo() + price, doc = _cut(me, key) + assert price == was + _fake(monkeypatch, [_changed(_live().logic(BASIC))]) + with store.at(me): + got = skc.preview(doc, skc.REGEN) + assert got["전"]["계"] == got["후"]["계"] == was # 끊음 = 안 따라감 + assert got["끊은기초"] == ["sub3임시 굴착기"] # 보고 한 줄 + _save(me, key, got["문서"]) + assert _sum(me, _doc(me, key)) == was + + +def test_price_refresh_follows_in_both(made, monkeypatch): + """⑤ [단가 갱신] — 연결 · 끊은 기초 모두 자원 단가는 따라감(요구 656-1) · 끊은 기초 줄은 그대로.""" + me, key = made + was = _sum(me, _doc(me, key)) + row = copy.deepcopy(_live().get(ONLY)) + row["값"] = row["값"] * 2 + _fake(monkeypatch, [row]) + with store.at(me): + fresh = skc.preview(_doc(me, key), skc.PRICE) + assert fresh["후"]["계"] > was and [x["키"] for x in fresh["바뀐자원"]] == [ONLY] + monkeypatch.undo() + _, doc = _cut(me, key) + _fake(monkeypatch, [row]) + with store.at(me): + cut = skc.preview(doc, skc.PRICE) + assert cut["후"]["계"] == fresh["후"]["계"] and cut["끊은기초"] == ["sub3임시 굴착기"] + _save(me, key, cut["문서"]) + assert _sum(me, _doc(me, key)) == fresh["후"]["계"] + + +def test_edit_cut_block_changes_amount_and_saves(made): + """③ 끊은 기초 줄 고침 — 미리 셈 = 금액 바뀜 · 저장 뒤 서버 셈 같음 · 틀린 고침 400 · M01 안 바뀜.""" + me, key = made + price, doc = _cut(me, key) + row_before = copy.deepcopy(_live().logic(BASIC)) + with store.at(me): + got = blk.preview(doc, 0, DOUBLE) + read = blk.read(doc, 0) + assert read["ok"] and got["되나"], got + assert got["금액전"]["계"] == price and got["금액후"]["계"] > price + assert got["수식"]["합계"]["계"] > got["전"]["계"] # 기초 1 단위 시간당 값 + item = {**doc["더한줄"][0], blk.EDIT: DOUBLE} + _save(me, key, {**doc, "더한줄": [item]}) + saved = _doc(me, key) + assert saved["더한줄"][0][blk.EDIT][0]["까닭"] == "sub3임시 시험" + assert _sum(me, saved) == got["금액후"]["계"] # 서버 셈 = 미리 셈 + assert _live().logic(BASIC) == row_before + with store.at(me): + eq = full.attach( + {"줄": []}, saved, skc.over(saved, skc.whole_()), {}, None, _calc(me, saved) + ) + block = next(r for r in eq["소계"]["줄"] if r.get("블록") == 0) + assert block["나무"]["합계"]["계"] == got["수식"]["합계"]["계"] # 접힘 블록 = 고친 수식 + bad = [{"할일": "식", "자리": "호표:0", "식": "없는값 / 작업량"}] + with store.at(me): + assert not blk.preview(saved, 0, bad)["되나"] + with pytest.raises(store.StoreError): + _save(me, key, {**saved, "더한줄": [{**saved["더한줄"][0], blk.EDIT: bad}]}) + assert _sum(me, _doc(me, key)) == got["금액후"]["계"] + + +def test_relink_drops_edits_and_follows_original(made, monkeypatch): + """④ [다시 잇기] — 고친 것 버림 · 원본 따라감(저장 뒤 표지 없음) · 금액 = 새로 찍은 SK.""" + me, key = made + was = _sum(me, _doc(me, key)) + _, doc = _cut(me, key) + item = {**doc["더한줄"][0], blk.EDIT: DOUBLE} + _save(me, key, {**doc, "더한줄": [item]}) + edited = _doc(me, key) + assert _sum(me, edited) > was + _fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 원본 바뀜 + fresh = _sum(me, _doc(me, _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)]))) + assert fresh > was + with store.at(me): + got = blk.change(edited, 0, "relink") + one = got["문서"]["더한줄"][0] + assert "끊음" not in one and blk.EDIT not in one and one[blk.REJOIN] is True + assert got["전"]["계"] > was and got["후"]["계"] == fresh # 고침 버림 + 원본 따라감 + _save(me, key, got["문서"]) + saved = _doc(me, key) + assert blk.REJOIN not in saved["더한줄"][0] and "끊음" not in saved["더한줄"][0] + assert _sum(me, saved) == fresh + + +def test_shape_checks(made): + """⑥ 연결 기초에 고침 · 이미 끊음 · 차례 밖 · 연결 기초 다시 잇기 · 끊은 기초 줄 모양.""" + me, key = made + doc = _doc(me, key) + with store.at(me): + with pytest.raises(sd.DocError, match="연결을 끊은 기초만"): + sd.clean({**doc, "더한줄": [{**ADD, blk.EDIT: DOUBLE}]}) + with pytest.raises(sd.DocError, match="없음"): + blk.unlink(doc, 3) + with pytest.raises(sd.DocError, match="연결된 기초"): + blk.relink(doc, 0) + with pytest.raises(sd.DocError, match="연결된 기초"): + blk.read(doc, 0) + cut = blk.unlink(doc, 0) + with pytest.raises(sd.DocError, match="이미 연결을 끊은"): + blk.unlink(cut, 0) + broken = {**cut["더한줄"][0], "끊음": {"로직": [{"키": "GC999999"}]}} + with pytest.raises(sd.DocError, match="줄 없음"): + sd.clean({**cut, "더한줄": [broken]}, None, skc.whole_())