From 00cb6db66f697276b0f043de7b7b19dce2b33a8b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:21:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=ED=95=AD=EB=AA=A9=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=EB=B9=84=EC=9A=B0=EA=B8=B0=20=EA=B8=B8=20=E2=80=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=EC=83=88=20=EC=97=B4=EA=B3=BC=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EB=B9=88=20=EB=AC=B8=EC=84=9C=EB=A1=9C=20(40-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/m02/layers/{층}/{structures|labor|area}/{열}/clear(owner) · 번호 · 열 그대로 · 받는 층 쓰기 권한(_root) - Store.clear · 면적을 비우면 그 열 일위대가 계산값 다시 · 장부 맞춤 - 화면 함수 Api_Fetch clearPart Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 13 ++ .../M02_MasterTemplete_Router_Items.py | 14 ++ .../M02_MasterTemplete_Store.py | 15 ++ resources/tester/test_m02_part_clear.py | 134 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 resources/tester/test_m02_part_clear.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 5bc26211c..2b1982f93 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -226,6 +226,19 @@ export const copyPartFrom = async ( }); }; +/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */ +export const clearPart = ( + kind: "structure" | "labor" | "area", + column: string, +): Promise => + call( + owned( + activeLayer, + `/layers/${activeLayer}/${kind === "structure" ? "structures" : kind}/${enc(column)}/clear`, + ), + { method: "POST" }, + ); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 89fb986df..413e5409c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -317,6 +317,20 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: return store.copy_from("labor", column, key, body=labor.from_combo(key)) +@router.post("/layers/{layer}/{kind}/{column}/clear") +def post_clear( + layer: Layer, + kind: Literal["structures", "labor", "area"], + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """항목 하나 비우기(40-4 · 카드 [삭제]) — 빈 문서 · 번호 · 열 그대로 · 받는 층 쓰기 권한.""" + folder = _root(session, layer, owner, write=True) + part = "structure" if kind == "structures" else kind + return _call(folder, store.clear, part, column) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5bfe9e227..0873d5d5e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -351,6 +351,21 @@ def create_structure(column: str) -> dict[str, Any]: return read("structure", column) +def clear(kind: str, column: str) -> dict[str, Any]: + """항목 하나 비우기(40-4) — 새 열 만들 때와 같은 빈 문서로 다시 씀 · 번호 · 열 그대로 · + 없는 파일은 404 · 면적산출을 비우면 그 열 일위대가 계산값도 다시 · 장부 맞춤.""" + if kind not in _KEY: + raise StoreError(404, f"없는 종류 「{kind}」") + number = read(kind, column)["문서"].get(_KEY[kind]) + with _LOCK: + atomic_write_json(_path(kind, column), {**_blank(kind, column), _KEY[kind]: number}) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + sync_combos() + return info + + def copy_from( kind: str, column: str, diff --git a/resources/tester/test_m02_part_clear.py b/resources/tester/test_m02_part_clear.py new file mode 100644 index 000000000..79a393f25 --- /dev/null +++ b/resources/tester/test_m02_part_clear.py @@ -0,0 +1,134 @@ +"""항목 하나 비우기(PLAN 40-4 · 카드 [삭제]) — `POST /api/m02/layers/{층}/{종류}/{열}/clear`. + +새 열과 같은 빈 문서 · 번호 · 열 그대로 · 권한 = 받는 층 쓰기(`_root`). +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 로직은 M01 마스터(읽기만). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/clear" +NUMBER = { + "structures": ("structure", "도번"), + "labor": ("labor", "일위대가번"), + "area": ("area", "면적번"), +} + + +def _fill(folder: Path, cid: str) -> None: + """세 문서를 채움 — 도면 선 · 일위대가 로직 · 면적 칸 + 알약 연결.""" + with store.at(folder): + for kind in ("structure", "labor", "area"): + store._ensure(kind, cid) + got = store.read("structure", cid) + doc = got["문서"] + doc["도면"]["entities"] = [{"type": "line"}] + store.write("structure", cid, got["판"], doc) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], "담은로직": [{"로직": TEN}]}) + got = store.read("area", cid) + sheet = got["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": "사석 수상고르기|㎡", "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", cid, got["판"], got["문서"]) + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + _fill(system, "rv") + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + _fill(personal, "a") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system, "personal": personal} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _doc(folder: Path, kind: str, cid: str) -> dict: + return json.loads((folder / kind / f"{cid}.json").read_bytes()) + + +@pytest.mark.parametrize("kind", ["structures", "labor", "area"]) +def test_세_종류_빈_문서_번호_열_그대로(world, kind): + system = world["system"] + part, key = NUMBER[kind] + before = _doc(system, part, "rv") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", kind, "rv")) + assert got.status_code == 200, got.text + after = _doc(system, part, "rv") + with store.at(system): + blank = store._blank(part, "rv") + assert after == {**blank, key: before[key]} # 새 열과 같은 빈 문서 + 번호 + assert after["열"] == "rv" and after[key] + with store.at(system): # 장부 번호 그대로 + row = next(r for r in store.read_combos()["줄"] if r["열"] == "rv") + assert row[key] == before[key] + + +def test_면적_비우면_일위대가_계산값_수량_없음으로(world): + system = world["system"] + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "칸" + _as(world, "SYSTEM_ADMIN").post(URL.format("system", "area", "rv")) + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "없음" + assert _doc(system, "labor", "rv")["담은로직"][0]["로직"] == TEN # 일위대가는 그대로 + + +def test_권한_일반_마스터_403_남의_개인_403_내_개인_됨(world): + system, personal = world["system"], world["personal"] + client = _as(world) + assert client.post(URL.format("system", "labor", "rv")).status_code == 403 + assert _doc(system, "labor", "rv")["담은로직"] # 안 지워짐 + other = URL.format("personal", "labor", "a") + "?owner=7/99" + assert client.post(other).status_code == 403 + assert client.post(URL.format("personal", "labor", "a")).status_code == 200 + assert _doc(personal, "labor", "a")["담은로직"] == [] + + +def test_회사_관리자는_동료_개인_층_비움(world): + personal = world["personal"] + client = _as(world, "ADMIN", user=41) + got = client.post(URL.format("personal", "structures", "a") + "?owner=7/42") + assert got.status_code == 200, got.text + assert _doc(personal, "structure", "a")["도면"]["entities"] == [] + + +def test_없는_열_404_모르는_종류_422(world): + client = _as(world, "SYSTEM_ADMIN") + assert client.post(URL.format("system", "labor", "없는열")).status_code == 404 + assert client.post(URL.format("system", "material", "rv")).status_code == 422