feat(796-2e-1): 더한 기초 블록 [연결 끊기] · [다시 잇기] 서버 — 끊음 = 이 SK 안 자기 사본 줄 · 수식 고침 · 재생성 때 줄 안 바꿈

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 15:38:36 +09:00
co-authored by Claude Sonnet 5.5
parent e171dda4e3
commit be43dad64e
7 changed files with 468 additions and 9 deletions
+212
View File
@@ -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],
}
+10 -5
View File
@@ -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) = 넣을 때 수량 · 없으면 지금 수량
+19
View File
@@ -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 []
+5 -3
View File
@@ -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]:
@@ -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}
+29
View File
@@ -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)