From f02c39c98a3e5e4035acb4c8262ece6645848763 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 12:05:48 +0900 Subject: [PATCH 1/2] =?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)["호표"] == [] From 0959ec3c98535480ac02807a3f0f6d6e3db863f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 12:20:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(796-2b):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=E3=80=8C=EC=88=98=EC=8B=9D=E3=80=8D=20?= =?UTF-8?q?=EC=B2=AB=20=ED=83=AD=20=E2=80=94=20M01=20=EC=88=98=EC=8B=9D=20?= =?UTF-8?q?=ED=83=AD=20=EB=B6=80=ED=92=88=20=EB=8B=A4=EC=8B=9C=20=EC=94=80?= =?UTF-8?q?(=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=88=EB=9E=98)=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20[=EB=8B=A8=EA=B0=80=20=EA=B0=B1=EC=8B=A0]?= =?UTF-8?q?=20=C2=B7=20[=EC=9E=AC=EC=83=9D=EC=84=B1]=20=C2=B7=20M01=20?= =?UTF-8?q?=EC=83=88=20=ED=8C=90=20=EB=B0=B0=EC=A7=80=20=C2=B7=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EC=9B=90=EB=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 수식 탭 부품에 `page` 갈래 — 길 · 고침 목록 · 옵션 값을 페이지가 줌 · 저장 = 페이지 [저장] · M01 동작 그대로(판 바뀌면 다시 셈만 더함) - 새 M02_MasterTemplete_Sangeun_Equation — 문서 `수식고침` 캐시 · 사본 표지 줄 · 가져온 식 「엑셀 원래」 · 일위대가 줄 모달 = 보기만 - 단가산출 상세 보기 = 수식(첫 · 기본) · 단가산출 상세 · 시트 · 옛 셋 뒤로(걷지 않음) · [저장] 뒤 할일 칸 뗌 - 낡은 시험 test_796_2 — 길 글이 앞머리 + 갈래로 바뀜(`/logic/equation${suffix}`) → 고침 · 화면 지킴 시험 하나 더함 Co-Authored-By: Claude Opus 5.5 --- .../M01_MasterData_UI_LogicLab_Equation.ts | 58 ++++-- .../M02_MasterTemplete_Sangeun_Equation.ts | 173 ++++++++++++++++++ .../M02_MasterTemplete_Sangeun_Sheet.ts | 33 ++-- .../tester/test_796_2_equation_screen.py | 5 +- resources/tester/test_796_2b_m02_equation.py | 15 ++ 5 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index ea23943ae..0b90859a9 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -7,6 +7,7 @@ * · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인) * 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음) * 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게) + * 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -47,6 +48,8 @@ const TEXT = { Discard: ["수식 고침 버림", "Discard edits"], Add: ["변수 더함", "Add variable"], Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"], + EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"], + DiscardPage: ["수식 원래로", "Back to copy"], Now: ["지금", "Now"], Before: ["고치기 전", "Before"], Blocked: [ @@ -104,6 +107,19 @@ const loadCache = (): Record => { } }; +/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */ +export interface EqPage { + ask: ( + suffix: "" | "/preview", + body: Record, + signal?: AbortSignal, + ) => Promise; + edits: () => EqEdit[]; + keep: (edits: EqEdit[]) => void; + values: () => Record; + setValue: (name: string, value: string | null) => void; +} + export interface LabEquationOptions { /** 시스템 관리자 — 고침 도구 */ admin: boolean; @@ -111,6 +127,8 @@ export interface LabEquationOptions { blocked: () => boolean; /** 저장 뒤 — 상세를 저장본으로 다시 엶 */ onSaved: () => void; + /** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */ + page?: EqPage; } export function createLabEquation(opt: LabEquationOptions) { @@ -139,9 +157,14 @@ export function createLabEquation(opt: LabEquationOptions) { const open = new Set(); const drafts = new Map(); const canEdit = (): boolean => opt.admin && !opt.blocked(); + const page = opt.page; + const vals = (): Record => page?.values() ?? inputs; + const ask = (suffix: "" | "/preview", body: Record, signal?: AbortSignal) => + page ? page.ask(suffix, body, signal) : post(`/logic/equation${suffix}`, body, signal); - const body0 = (): Record => ({ key, inputs: inputsOf(inputs), 고침: edits }); + const body0 = (): Record => ({ key, inputs: inputsOf(vals()), 고침: edits }); const persist = (): void => { + if (page) return page.keep(edits); const all = loadCache(); if (edits.length) all[key] = edits; else delete all[key]; @@ -157,9 +180,7 @@ export function createLabEquation(opt: LabEquationOptions) { ctl?.abort(); const mine = (ctl = new AbortController()); try { - const got = edits.length - ? await post("/logic/equation/preview", body0(), mine.signal) - : await post("/logic/equation", body0(), mine.signal); + const got = await ask(edits.length ? "/preview" : "", body0(), mine.signal); if (mine !== ctl) return; if (got.되나 === false || got.ok === false) { why = (got.까닭 ?? [got.reason ?? ""]).join(" · "); @@ -178,7 +199,7 @@ export function createLabEquation(opt: LabEquationOptions) { /** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */ async function commit(list: EqEdit[]): Promise { - const got = await post("/logic/equation/preview", { + const got = await ask("/preview", { ...body0(), 고침: [...edits, ...list], }).catch((error: unknown): Read => ({ @@ -214,6 +235,7 @@ export function createLabEquation(opt: LabEquationOptions) { }; const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12); const set = (v: string | number | null): void => { + if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v)); if (v === null || v === "") delete inputs[spec.이름]; else inputs[spec.이름] = String(v); askAgain(); @@ -222,7 +244,7 @@ export function createLabEquation(opt: LabEquationOptions) { if ((spec.고르기?.length ?? 0) > BIG) { // 찾기형 — 입력 + 목록(칠수록 좁혀짐) const box = createInputField({ - value: inputs[spec.이름] ?? "", + value: vals()[spec.이름] ?? "", compact: true, size: 24, placeholder: auto === null || auto === undefined ? "" : String(auto), @@ -239,7 +261,7 @@ export function createLabEquation(opt: LabEquationOptions) { }); control = el("div", { children: [box.root, list] }); } else { - const made = createValueControl(item, inputs[spec.이름] ?? null, (v) => set(v)); + const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v)); const tip = (): void => { const c = made.control; c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value; @@ -283,8 +305,8 @@ export function createLabEquation(opt: LabEquationOptions) { const check = (line: EqLine) => async (text: string, signal: AbortSignal): Promise => { - const got = await post( - "/logic/equation/preview", + const got = await ask( + "/preview", { ...body0(), 고침: [...edits, formulaEdit(line, text)] }, signal, ); @@ -369,15 +391,17 @@ export function createLabEquation(opt: LabEquationOptions) { if (!v || !canEdit()) return; openVarDialog({ row: v, - value: inputs[v.열쇠] ?? "", + value: vals()[v.열쇠] ?? "", commit, onValue: (value) => { + if (page) return page.setValue(v.열쇠, value || null); if (value) inputs[v.열쇠] = value; else delete inputs[v.열쇠]; drawOptions(); void refresh(); }, onDropped: (k) => { + if (page) return page.setValue(k, null); delete inputs[k]; drawOptions(); }, @@ -403,14 +427,16 @@ export function createLabEquation(opt: LabEquationOptions) { items.push("spacer"); if (edits.length) { items.push( - createBadge(t("Edits", edits.length), "warning"), + createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"), createButton({ - label: t("Discard"), + label: t(page ? "DiscardPage" : "Discard"), variant: "ghost", size: 24, onClick: () => void discard(), }), - createButton({ label: t("Save"), size: 24, onClick: () => void save() }), + ...(page + ? [] + : [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]), ); } else items.push(createText(t("Hint"), "muted", 12)); } @@ -488,17 +514,21 @@ export function createLabEquation(opt: LabEquationOptions) { root, /** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */ draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => { + const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈 version = nextVersion; row = nextRow; if (nextKey !== key) { [key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null]; inputs = {}; - edits = loadCache()[nextKey] ?? []; + edits = page ? page.edits() : (loadCache()[nextKey] ?? []); open.clear(); drafts.clear(); paint(); drawOptions(); void refresh().then(drawOptions); + } else if (moved) { + if (page) edits = page.edits(); + void refresh().then(drawOptions); } else if (opt.blocked() !== paintedBlocked) paint(); }, dirty: (): boolean => edits.length > 0, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts new file mode 100644 index 000000000..c2f3e4bd9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Equation.ts + * M02 단가산출(SK) 「수식」 보기(PLAN 796-2b · 요구 655 · 656 · 657 · 요구서_655 0 장) — M01 수식 탭 부품(`createLabEquation`)을 페이지 갈래로. + * 길 = `POST /layers/{층}/sangeun/equation`(+ `/preview`) · 몸 = 화면 문서(캐시) · 고침 목록 = 문서 `수식고침`(사본 위 기록) · + * 저장 = 상세 [저장](서버가 다시 얹어 검사 · 셈) · 옵션 = 상세 옵션 값(`onInput`) + * 위 줄 = 로직 사본 — 「M01 새 판 있음」 배지 · [단가 갱신] · [재생성](`…/sangeun/copy/price|regen` → 문서 `사본할일` · [저장] 때 서버 적용) + * 가져온 식 = 「엑셀 원래」 금액 글(셈 답 `원래` = 엑셀 금액) + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { formatMoney } from "../common_util/common_util_money"; +import type { LogicRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { createLabEquation } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Equation"; +import type { EqEdit } from "../M01_MasterData/logic/M01_Logic_Equation_Model"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model"; + +const TEXT = { + Copy: ["로직 사본", "Logic copy"], + NewM01: ["M01 새 판 있음", "New M01 version"], + Price: ["단가 갱신", "Refresh prices"], + Regen: ["재생성", "Regenerate"], + Job: ["{n} · [저장] 때 적용", "{n} · applied on save"], + Excel: ["엑셀 원래", "Excel original"], + Lost: ["못 얹은 고침", "Not re-applied"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT, n = ""): string => + (TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", n); + +const KEY = "수식고침"; +const JOB = "사본할일"; + +interface CopyMark { + M01새판?: boolean; + 지운키?: string[]; +} +type Answer = SkAnswer & { + 사본?: CopyMark; + 원래?: Record | null; + 가져온?: unknown; +}; +interface CopyReply { + 문서: SkDoc; + 전: Record | null; + 후: Record | null; + 바뀐자원?: unknown[]; + 못얹은?: { cid: string; 까닭: string }[]; +} + +export interface SkEquationOptions { + scope: { layer: Layer; owner: string | null }; + edit: boolean; + touch: () => void; + onInput: (name: string, value: string) => void; +} + +export interface SkEquationDraw { + doc: SkDoc; + ans: SkAnswer | null; + base: LogicRow | null; + values: Record; +} + +export function createSangeunEquation(opt: SkEquationOptions) { + const { layer, owner } = opt.scope; + const q = owner && layer !== "system" ? `?owner=${encodeURIComponent(owner)}` : ""; + const bar = el("div", { attrs: { "data-sk-copy": "" } }); + let last: SkEquationDraw | null = null; + const doc = (): SkDoc => last?.doc ?? ({} as SkDoc); + /** 고침 = 저장되는 SK 만(일위대가 줄 모달 `줄모드` = 보기만 — 줄로 못 옮김) · 처음 그릴 때 정함 */ + const make = (d: SkEquationDraw) => + createLabEquation({ + admin: opt.edit && d.doc.줄모드 !== true, + blocked: () => false, + onSaved: () => undefined, + page: { + ask: (suffix, body, signal) => + call(`/layers/${layer}/sangeun/equation${suffix}${q}`, { + method: "POST", + body: JSON.stringify({ ...body, 문서: doc() }), + signal, + }), + edits: () => (doc()[KEY] as EqEdit[] | undefined) ?? [], + keep: (edits) => { + if (edits.length) doc()[KEY] = edits; + else delete doc()[KEY]; + opt.touch(); + }, + values: () => last?.values ?? {}, + setValue: (name, value) => opt.onInput(name, value ?? ""), + }, + }); + let lab: ReturnType | null = null; + const root = el("div", { attrs: { "data-sk-equation": "" }, children: [bar] }); + + async function job(how: "price" | "regen"): Promise { + try { + const got = await call(`/layers/${layer}/sangeun/copy/${how}${q}`, { + method: "POST", + body: JSON.stringify({ 문서: doc(), 입력값: last?.values ?? {} }), + }); + doc()[JOB] = got.문서[JOB]; + const sum = (v: Record | null): string => formatMoney(v?.계 ?? 0); + const lost = got.못얹은 ?? []; + showToast( + `${t(how === "price" ? "Price" : "Regen")} ${sum(got.전)} → ${sum(got.후)}` + + (lost.length ? ` · ${t("Lost")} ${lost.map((x) => x.cid).join(" · ")}` : ""), + lost.length ? "warning" : "success", + ); + opt.touch(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + + function drawBar(d: SkEquationDraw): void { + const ans = d.ans as Answer | null; + const mark = ans?.사본; + const pending = d.doc[JOB] as string | undefined; + const items: (HTMLElement | "divider" | "spacer")[] = []; + if (ans && "가져온" in ans) + items.push(createText(`${t("Excel")} ${formatMoney(ans.원래?.계 ?? 0)}`, "muted", 14)); + if (mark) { + items.push(createText(t("Copy"), "muted", 14)); + if (mark.M01새판) items.push(createBadge(t("NewM01"), "warning")); + if (pending) items.push(createBadge(t("Job", pending), "info")); + if (opt.edit) { + items.push( + "spacer", + createButton({ + label: t("Price"), + variant: "ghost", + size: 24, + onClick: () => void job("price"), + }), + createButton({ + label: t("Regen"), + variant: "ghost", + size: 24, + onClick: () => void job("regen"), + }), + ); + } + } + bar.replaceChildren( + ...(items.length ? [createToolbar({ items, size: 28, label: t("Copy") })] : []), + ); + } + + return { + root, + draw: (d: SkEquationDraw): void => { + last = d; + drawBar(d); + if (!d.base) return; + if (!lab) root.append((lab = make(d)).root); + const { [KEY]: _edits, ...rest } = d.doc; + lab.draw(String(d.doc.키 ?? ""), JSON.stringify([rest, d.values]), d.base); + }, + /** [저장] 뒤 — 할일 칸은 서버가 적용 · 뗌(상세 `getDoc` 이 복사한 뒤) */ + saved: (): void => + queueMicrotask(() => { + if (!last?.doc[JOB]) return; + delete last.doc[JOB]; + drawBar(last); + }), + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts index 42cec8a6b..73125c0e6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Sangeun_Sheet.ts * M02 단가산출(SK) 줄 식 시트(PLAN 790-1 · E1 · 요구 650~652 · 요구서_652 1 장 4) — 상세 첫 탭 「시트」. - * 보기 고르기 = 시트 · 옛 보기 넷(단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — E7 까지 둠) + * 보기 고르기 = 수식(첫 탭 · 796-2b `_Sangeun_Equation`) · 단가산출 상세 · 시트 · 옛 보기 셋(인력·재료·경비 · 시험 계산 · 텍스트 수식 — 뒤로만) * 시트 = 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M02_Sangeun_Sheet_Model`) · 안 보기 전환 「시트 ↔ 산출서」 * 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄] * → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만) @@ -24,6 +24,7 @@ import { formatLineMoney, formatMoney } from "../common_util/common_util_money"; import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text"; import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; +import { createSangeunEquation } from "./M02_MasterTemplete_Sangeun_Equation"; import { addTopNote, formulaEditor, @@ -130,14 +131,16 @@ export interface SheetOptions { scope: { layer: Layer; owner: string | null }; } -type View = "sheet" | "ho" | "est" | "calc" | "formula"; +type View = "equation" | "sheet" | "ho" | "est" | "calc" | "formula"; /** 보기 고르기(시트 + 옛 넷) · 시트 그릇 — `editor` = 옛 보기 그릇(시트일 때 숨김) */ export function createSangeunSheet( editor: HTMLElement, opt: SheetOptions, ): { picker: HTMLElement; host: HTMLElement; draw: (d: SheetDraw) => void; saved: () => void } { - const host = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } }); + const sheetHost = el("div", { className: "m02-sks", attrs: { "data-sk-sheet": "" } }); + const eqView = createSangeunEquation(opt); // 796-2b 수식 첫 탭 + const host = el("div", { children: [eqView.root, sheetHost] }); const status = el("div", { className: "m02-sks__status", attrs: { "data-sk-status": "" } }); const body = el("div", { className: "m02-sks__body" }); let mode: "sheet" | "paper" = "sheet"; @@ -148,19 +151,21 @@ export function createSangeunSheet( const drafts = new Map(); // 치는 중 식 글 let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점 const show = (v: View): void => { - host.hidden = v !== "sheet"; - editor.hidden = v === "sheet"; - if (v !== "sheet") editor.dataset.view = v; + eqView.root.hidden = v !== "equation"; + sheetHost.hidden = v !== "sheet"; + editor.hidden = v === "sheet" || v === "equation"; + if (!editor.hidden) editor.dataset.view = v; }; const picker = createSegment({ options: [ - { value: "sheet", label: t("Sheet") }, + { value: "equation", label: tl("View_Equation") }, { value: "ho", label: tl("Ho_Title") }, + { value: "sheet", label: t("Sheet") }, { value: "est", label: tl("View_Est") }, { value: "calc", label: tl("Calc_Title") }, { value: "formula", label: tl("Formula_Title") }, ], - value: "sheet", + value: "equation", size: 32, ariaLabel: tl("View_Label"), onChange: (v) => show(v as View), @@ -180,8 +185,11 @@ export function createSangeunSheet( }, }); inner.root.dataset.skMode = ""; - host.append(createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }), body); - show("sheet"); + sheetHost.append( + createToolbar({ items: [inner.root, "divider", status], label: t("Mode") }), + body, + ); + show("equation"); function drawStatus(d: SheetDraw): void { const s = statusOf(d.ans, d.bare, before); @@ -447,6 +455,7 @@ export function createSangeunSheet( function draw(d: SheetDraw): void { last = d; + eqView.draw(d); if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계; ctx = { doc: d.doc as ConstDoc, @@ -461,7 +470,8 @@ export function createSangeunSheet( }; drawStatus(d); const active = document.activeElement as HTMLElement | null; - const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : ""); + const keep = + focusNext || (active && sheetHost.contains(active) ? active.dataset.sheetCell : ""); focusNext = ""; body.dataset.mode = mode; body.replaceChildren( @@ -479,6 +489,7 @@ export function createSangeunSheet( host, draw, saved: () => { + eqView.saved(); // 796-2b 사본 할일 뗌 before = last?.ans?.기준?.계 ?? null; // 저장 = 고치기 전 새로(상태줄만 다시) if (last) drawStatus(last); }, diff --git a/resources/tester/test_796_2_equation_screen.py b/resources/tester/test_796_2_equation_screen.py index 6b986f28a..6b67d5fd9 100644 --- a/resources/tester/test_796_2_equation_screen.py +++ b/resources/tester/test_796_2_equation_screen.py @@ -59,8 +59,9 @@ def test_계산_자리_화면은_모양만(): assert "fetch(" not in _text(path) # 부품은 그리기만 page = _text(PAGE[0]) assert page.count("fetch(") == 1 # 서버 길 한 곳 — 계산은 서버 한 벌(③) - for route in ('"/logic/equation"', '"/logic/equation/preview"', '"/logic/equation/save"'): - assert route in page, route + for route in ("`/logic/equation${suffix}`", '"/preview"', '"/logic/equation/save"'): + assert route in page, route # 796-2b 길 앞머리 = M01 · 페이지 갈래(page.ask) + assert "page ? page.ask(suffix, body, signal)" in page assert "saveRevised(" in page and "개정확인" in page # [저장] = 개정 확인 길 assert "edits" in page and "sessionStorage" in page # 고침 목록 = 캐시 → [저장] 한 번 assert "ctl?.abort()" in page # 옛 요청 끊음 diff --git a/resources/tester/test_796_2b_m02_equation.py b/resources/tester/test_796_2b_m02_equation.py index 57d93fce6..567fb651c 100644 --- a/resources/tester/test_796_2b_m02_equation.py +++ b/resources/tester/test_796_2b_m02_equation.py @@ -175,3 +175,18 @@ def test_m01_change_kept_regen_reapplies(world, monkeypatch): 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)["호표"] == [] + + +def test_screen_same_part_first_tab(): + """화면 — M02 수식 = M01 수식 탭 부품 다시 씀(복사 없음) · 첫 탭 · 옛 탭 뒤로 · 저장 = 페이지.""" + import re + from pathlib import Path + + root = Path(__file__).resolve().parents[2] / "M02_MasterTemplete" + mine = (root / "M02_MasterTemplete_Sangeun_Equation.ts").read_text(encoding="utf-8") + assert "createLabEquation(" in mine and "fetch(" not in mine and "createEqChain" not in mine + assert "/sangeun/equation${suffix}" in mine and "/sangeun/copy/${how}" in mine + sheet = (root / "M02_MasterTemplete_Sangeun_Sheet.ts").read_text(encoding="utf-8") + views = re.findall(r'\{ value: "(\w+)", label: ', sheet)[:6] + assert views == ["equation", "ho", "sheet", "est", "calc", "formula"] + assert 'show("equation");' in sheet