From 36c437d9a1ece40f5940915e5f76225e799c0352 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:51:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(M02):=20=EB=82=A8=EC=9D=98=20=EC=B8=B5?= =?UTF-8?q?=20=EC=93=B0=EA=B8=B0=20=EA=B8=B0=EB=A1=9D=20=3D=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=ED=95=9C=20=EC=93=B0=EA=B8=B0=EB=A7=8C=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EB=8B=B5=ED=95=9C=20=EB=92=A4=20=EB=82=A8?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EB=A7=89=ED=9E=98=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=B6=80=EB=A6=84=20=EB=BA=8C(39-1d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Router_Items.py | 41 +++++++++++++++++-- .../M02_MasterTemplete_Router_Layers.py | 4 +- resources/tester/test_m02_layer_access.py | 2 +- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 0a40524d3..cef56af78 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -11,11 +11,14 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) from __future__ import annotations import logging +from collections.abc import Callable, Coroutine +from contextvars import ContextVar from pathlib import Path from typing import Any, Literal import anyio.from_thread -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.routing import APIRoute from pydantic import BaseModel from common_util.common_util_audit import record_audit @@ -37,7 +40,32 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import ( ) logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) +#: 이 요청이 쓴 남의 층(`others_write`) — 길이 성공해 답한 뒤에만 시스템 로그(`NotedRoute`) +_WRITES: ContextVar[list[tuple[dict, access.Grant]] | None] = ContextVar("m02_writes", default=None) + + +class NotedRoute(APIRoute): + """남의 층 쓰기 기록 = 성공한 쓰기만(39-1d) — 막히거나 실패한 부름(예외 · 4xx · 5xx)은 뺌.""" + + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + handler = super().get_route_handler() + + async def run(request: Request) -> Response: + notes: list[tuple[dict, access.Grant]] = [] + token = _WRITES.set(notes) + try: + response = await handler(request) + finally: + _WRITES.reset(token) + if response.status_code < 400: + for session, got in notes: + await _note(session, got) + return response + + return run + + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"], route_class=NotedRoute) Layer = Literal["system", "company", "personal"] Session = dict[str, Any] @@ -114,12 +142,17 @@ async def _note(session: Session, got: access.Grant) -> None: async def others_write(session: Session, got: access.Grant, folder: Path) -> None: - """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 쓸 때마다 시스템 로그.""" + """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 시스템 로그는 길이 성공한 + 뒤(`NotedRoute` · 그 밖의 길은 바로).""" if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): if not await _member(str(got.owner)): raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님") items_store.ensure(folder) - await _note(session, got) + pending = _WRITES.get() + if pending is None: + await _note(session, got) + else: + pending.append((session, got)) def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index f3097f50b..a2e2aa95d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -34,7 +34,9 @@ from M02_MasterTemplete import M02_Template_Layers as layers logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"]) +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Layers"], route_class=items_router.NotedRoute +) Layer = Literal["system", "company", "personal", "project"] diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py index f89e29ea9..3f0ac4938 100644 --- a/resources/tester/test_m02_layer_access.py +++ b/resources/tester/test_m02_layer_access.py @@ -160,7 +160,7 @@ def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any] add["판"] = "" assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409 assert (storage / "9/templates/table/구조물집계표.json").is_file() - assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"] + assert [n[2] for n in world["notes"]] == ["7/44"] # 성공한 쓰기만(39-1d) · 409 · 403 은 안 남김 def test_남의_층_목록(world: dict[str, Any]): From cbcfc184367fe903ce6138b99e288dc44bd18c4a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 18:03:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M01):=20=EB=A1=9C=EC=A7=81=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=EA=B2=80=EC=82=AC=20=3D=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=20=EC=B8=B5=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=8B=B4=EC=9D=80=EB=A1=9C=EC=A7=81=20+?= =?UTF-8?q?=20=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=20=E2=80=94=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EA=B3=B3=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=EA=B8=B8(PUT)=20?= =?UTF-8?q?=EC=9D=80=ED=87=B4(39-1d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- M01_MasterData/M01_MasterData_Store_Cache.py | 32 +++++++++++++- .../M02_MasterTemplete_Router_Combos.py | 12 ------ resources/tester/test_m01_make.py | 43 +++++++++++++++++++ resources/tester/test_m02_combo_layers.py | 9 ++-- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index c704de769..cca474f97 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -217,7 +217,37 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) out += cm.check_links(whole) - return out + cm.mcb.check_all(files, whole) + return out + cm.mcb.check_all(files, whole) + labor_errors(whole) + + +def _labor_files() -> list[tuple[str, Path]]: + """모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일).""" + from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 + + root = layers.storage_root() + found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))] + found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))] + personal = sorted(root.glob("*/*/templates/labor/*.json")) + return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal] + + +def labor_errors(whole) -> list[str]: + """일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림. + 전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌.""" + out = [] + for where, path in _labor_files(): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem + for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []: + key = str(one.get("로직") or "") if isinstance(one, dict) else "" + try: + whole.logic(key) + except cm.mf.FormulaError: + out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") + return out def warm(folder: Path) -> threading.Thread: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 543a4905b..ef603b5eb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -138,18 +138,6 @@ def post_combo( return _call(folder, combos.create, body.combo, body.판 or None) -@router.put("/layers/{layer}/combos/{key}") -def put_combo( - layer: Layer, - key: str, - body: ComboBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit, key, body.combo, body.판 or "") - - @router.delete("/layers/{layer}/combos/{key}") def delete_combo( layer: Layer, diff --git a/resources/tester/test_m01_make.py b/resources/tester/test_m01_make.py index 3ccb36cc2..c0847a8bd 100644 --- a/resources/tester/test_m01_make.py +++ b/resources/tester/test_m01_make.py @@ -176,3 +176,46 @@ def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0 listed = _get(client, "/api/m01/logics", owner="공용")["logics"] assert made["key"] in [x["키"] for x in listed] + + +def test_일위대가가_담은_로직은_못_지운다(client: TestClient, tmp_path: Path, monkeypatch) -> None: + """39-1d — 로직 지우기 검사 = 모든 층(마스터 · 회사 · 개인) labor 담은로직 + 실무문서(마스터 + 일위대가 조합) · 쓰는 곳을 알림 · 다 빼면 지워짐(임시 storage · 임시 마스터).""" + import json + + from config import config_system + from M02_MasterTemplete import M02_Template_Layers as layers + + system, storage = tmp_path / "system", tmp_path / "storage" + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) + key = made["key"] + docs = { + "회사 3": storage / "3/templates/labor/item1.json", + "개인 3/15": storage / "3/15/templates/labor/item2.json", + "마스터": system / "labor/item3.json", + } + for at, path in enumerate(docs.values()): + path.parent.mkdir(parents=True) + doc = {"일위대가번": f"SU-00000{at}", "담은로직": [{"로직": SAMPLE}, {"로직": key}]} + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + combos = store.FOLDER / store.mcb.FILE + raw = json.loads(combos.read_text(encoding="utf-8")) + raw["줄"].append({**raw["줄"][0], "키": "UA999999", "담은로직": [{"로직": key, "메모": None}]}) + combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + + def delete(want: int) -> dict: + version = _get(client, "/api/m01/logic", key=key)["version"] + return _post(client, "/api/m01/logic/delete", {"key": key, "version": version}, want) + + errors = " ".join(delete(422)["detail"]["errors"]) + for where in docs: + assert f"일위대가 {where} 「SU-" in errors and key in errors # 어디서 쓰는지 알림 + assert store.mcb.FILE in errors # 실무문서(마스터 일위대가 조합) + for path in docs.values(): # 일위대가에서 뺌 — 실무문서만 남음 + path.write_text(json.dumps({"담은로직": [{"로직": SAMPLE}]}), encoding="utf-8") + assert "일위대가 " not in " ".join(delete(422)["detail"]["errors"]) + raw["줄"].pop() + combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + assert delete(200)["file"] == make.OWN # 쓰는 곳 0 = 지워짐 diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 9bcbfb596..d219dd1a4 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -60,7 +60,7 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: def test_조합_길은_시스템_관리자만(world): - """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직은 로그인만.""" world["client"].app.dependency_overrides.pop(combos_router._door) for role in ("USER", "ADMIN"): client = _as(world, role) @@ -149,10 +149,9 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world): branched = _new(me, "personal", _combo("마스터 본뜸", 본뜬키=master_key)) assert branched["combo"]["본뜬키"] == master_key # 층을 넘는 계보 url = f"{URL.format('personal')}/{first['키']}" - assert me.put(url, json={"combo": _combo("원본 고침"), "판": "낡은판"}).status_code == 409 - now = _list(me, "personal")["판"] - res = me.put(url, json={"combo": _combo("원본 고침", (B,)), "판": now}) - assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B + assert ( + me.put(url, json={"combo": _combo("원본 고침")}).status_code == 405 + ) # 조합 고치기 은퇴(39-1d) # ── 나무 ─────────────────────────────────────────────────────────────