"""M02 층 집계표 API — `/api/m02/layers/{layer}/…` · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2). ⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(양식 길 `Router_Layers` 와 한 벌 · PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) · 회사 없음 = 409. 옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로. 회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`). 일위대가 갈라 내기(PLAN 27-2c): 새 조합은 그 층에(마스터 UA · 회사 UC · 개인 UP) · 층 쓰기 권한 · 첫 진입 뒤 구조물집계표가 이미 있어 같은 이름 새로 쓰기(판 없이)는 409 그대로. """ from __future__ import annotations from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Item_Paste as paste_store from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete.M02_MasterTemplete_Router import ( BranchBody, MaterialBranchBody, MatsetBody, UseComboBody, ) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) Layer = Literal["system", "company", "personal"] Session = dict[str, Any] NO_COMPANY = access.NO_COMPANY class InputLinksBody(BaseModel): 판: int | None = None 줄: list[Any] = [] class TreeBody(BaseModel): """26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`).""" op: str 경로: list[str] | None = None 이름: str | None = None 새경로: list[str] | None = None 키: str | None = None 단위: str | None = None 판: str # 여러 개 옮기기 · 지우기(PLAN 30-2) — `[{경로, 키?}]` · 있으면 이것마다 차례로 · 저장 한 번 대상: list[dict[str, Any]] | None = None class PasteBody(BaseModel): """30-2 층 사이 붙여넣기 — 복사한 층(`from_layer` · `from_owner`) `대상` → 이 층 `경로` 아래.""" from_layer: Layer from_owner: str | None = None 대상: list[dict[str, Any]] 경로: list[str] = [] class CopyItemBody(BaseModel): from_layer: Literal["system", "company"] from_owner: str | None = None key: str def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦.""" got = access.require(session, layer, owner, write=write) if layer == "system": return store.FOLDER folder = access.folder(got) if got.mine: items_store.ensure(folder) elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return folder def _call(folder: Path, fn, *args): with store.at(folder): try: return fn(*args) except store.StoreError as e: raise HTTPException(status_code=e.status, detail=e.detail) from e # ── 항목 목록 · 복사 ─────────────────────────────────── def _listing(session: Session, layer: str, owner: str | None) -> dict[str, Any]: if layer != "system" and not owner and access.own_owner(session, layer) is None: return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹 who = access.grant(session, layer, owner).owner folder = _root(session, layer, who) return {"layer": layer, "owner": who, "items": items_store.items(folder)} @router.get("/items") def get_items( layer: Layer = "system", owner: str | None = None, all: bool = False, # noqa: A002 — 21-1 약속 이름 `?all=1` session: Session = Depends(verify_session), ): """21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번, 재료번}]` · `?all=1` 이면 세 층 `[{layer, owner, items}]`(회사 · 개인은 세션 것 · 회사 없으면 빈 목록).""" if all: return [_listing(session, one, None) for one in ("system", "company", "personal")] return _listing(session, layer, owner)["items"] @router.post("/layers/personal/items/copy") def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> dict: """회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`).""" target = _root(session, "personal", None, write=True) source = _root(session, body.from_layer, body.from_owner) owner = access.grant(session, body.from_layer, body.from_owner).owner origin = {"layer": body.from_layer, "owner": owner, "key": body.key} try: return items_store.copy_item(source, target, body.key, origin) except store.StoreError as e: raise HTTPException(status_code=e.status, detail=e.detail) from e # ── 항목 선택 입력(PLAN 25-1b · 25-2a) — 읽기 = 층 읽기 권한(시스템 층 = 로그인 누구나) ── @router.get("/layers/{layer}/items/{key}/inputs") def get_item_inputs( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: """그 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값 — M01 길은 관리자 전용이라 일반 사용자는 이 길로 읽음.""" return _call(_root(session, layer, owner), inputs_store.inputs, key) @router.get("/layers/{layer}/items/{key}/input-links") def get_input_links( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: return _call(_root(session, layer, owner), inputs_store.read_links, key) @router.put("/layers/{layer}/items/{key}/input-links") def put_input_links( layer: Layer, key: str, body: InputLinksBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """본문 `{판, 줄}` — 판 다르면 409 · 없는 로직 · 입력 키 422 · 층 쓰기 권한.""" folder = _root(session, layer, owner, write=True) return _call(folder, inputs_store.write_links, key, body.판, body.줄) @router.get("/layers/{layer}/items/{key}/pills") def get_item_pills( layer: Layer, key: str, place: Literal["면적", "재료"], owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: """면적산출 · 재료산출 시트 알약 — `{물량: [조합 물량], 입력: [그 시트로 이은 입력]}`.""" return _call(_root(session, layer, owner), inputs_store.pills, key, place) # ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ── def _tree_view(folder: Path) -> dict[str, Any]: """나무 — `{판(집계표), 폴더: [경로 …], items: [항목 …]}` · 폴더 = 빈 폴더 + 항목 앞마디.""" with store.at(folder): got = store.read("table", store.STRUCTURE_TABLE) doc = tree.normalize_table(got["문서"]) # 옛 파일도 경로로 읽음 return {"판": got["판"], "폴더": tree.folders_of(doc), "items": items_store.items(folder)} @router.get("/layers/{layer}/tree") def get_tree(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): folder = _root(session, layer, owner) return _call(folder, _tree_view, folder) def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]: """집계표를 고쳐 집계표 저장 한 줄기로 — 번호 · SS 장부 · 지운 항목의 세 파일 · 입력 연결.""" got = store.read("table", store.STRUCTURE_TABLE) if got["판"] != body.get("판"): raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": got["판"]}) ids = {} # 키 · 옛 키(회사 · 개인 층 SS) → 열 id for r in store.read_combos().get("줄", []): for key in [r.get("키"), *(r.get("옛키") or [])]: ids[str(key)] = str(r.get("열")) try: targets = body.get("대상") if targets is None: doc, note = tree.apply(got["문서"], body, ids) else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀) if body.get("op") not in ("move", "delete") or not targets: raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") doc, note = got["문서"], {"op": body["op"], "지운항목": []} for one in paste_store.outer(targets): step = {**body, "경로": one.get("경로"), "키": one.get("키")} doc, done = tree.apply(doc, step, ids) note["지운항목"] += done.get("지운항목", []) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e store.write("table", store.STRUCTURE_TABLE, got["판"], doc) return {**_tree_view(folder), "알림": note} @router.post("/layers/{layer}/tree") def post_tree( layer: Layer, body: TreeBody, owner: str | None = None, session: Session = Depends(verify_session), ): """폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 → 새 나무 · 판 다르면 409. 쓰이는 항목을 지워도 프로젝트엔 영향 없음(프로젝트는 복사본을 씀 · 알림의 `지운항목`).""" folder = _root(session, layer, owner, write=True) return _call(folder, _edit_tree, folder, body.model_dump()) @router.post("/layers/{layer}/items/paste") def post_paste( layer: Layer, body: PasteBody, owner: str | None = None, session: Session = Depends(verify_session), ): """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 · 새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림.""" target = _root(session, layer, owner, write=True) source = _root(session, body.from_layer, body.from_owner) who = access.grant(session, body.from_layer, body.from_owner).owner try: note = paste_store.paste( source, target, body.대상, body.경로, {"layer": body.from_layer, "owner": who} ) except store.StoreError as e: raise HTTPException(status_code=e.status, detail=e.detail) from e return {**_call(target, _tree_view, target), "알림": note} # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── @router.get("/layers/{layer}/structures/numbers") def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.structure_numbers) @router.get("/layers/{layer}/structure-combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.read_combos) @router.post("/layers/{layer}/structures/{column}") def post_structure( layer: Layer, column: str, owner: str | None = None, session: Session = Depends(verify_session) ): return _call(_root(session, layer, owner, write=True), store.create_structure, column) @router.post("/layers/{layer}/structures/{column}/combo") def post_combo( layer: Layer, column: str, body: BranchBody, owner: str | None = None, session: Session = Depends(verify_session), ): """일위대가 조합 갈라 내기 — 새 조합은 이 층 조합 표에(`Combo_Store`).""" folder = _root(session, layer, owner, write=True) return _call( folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판 ) @router.post("/layers/{layer}/structures/{column}/material-combo") def post_material_combo_branch( layer: Layer, column: str, body: MaterialBranchBody, owner: str | None = None, session: Session = Depends(verify_session), ): folder = _root(session, layer, owner, write=True) args = (column, body.본뜬키, body.담은자재, body.이름, body.판) return _call(folder, branch.branch_material_combo, *args) @router.post("/layers/{layer}/structures/{column}/use-combo") def post_use_combo( layer: Layer, column: str, body: UseComboBody, owner: str | None = None, session: Session = Depends(verify_session), ): folder = _root(session, layer, owner, write=True) return _call(folder, branch.use_combo, column, body.종류, body.키, body.판) @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, kind: Literal["structures", "area", "material"], column: str, source: str = Query(alias="from"), owner: str | None = None, session: Session = Depends(verify_session), ): folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind return _call(folder, store.copy_from, part, column, source) @router.get("/layers/{layer}/material-combos") def get_matsets(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), matset.listing) @router.get("/layers/{layer}/material-combos/{key}") def get_matset( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ): return _call(_root(session, layer, owner), matset.one, key) @router.post("/layers/{layer}/material-combos") def post_matset( layer: Layer, body: MatsetBody, owner: str | None = None, session: Session = Depends(verify_session), ): return _call(_root(session, layer, owner, write=True), matset.create, body.줄, body.판) @router.put("/layers/{layer}/material-combos/{key}") def put_matset( layer: Layer, key: str, body: MatsetBody, owner: str | None = None, session: Session = Depends(verify_session), ): return _call(_root(session, layer, owner, write=True), matset.edit, key, body.줄, body.판) @router.delete("/layers/{layer}/material-combos/{key}") def delete_matset( layer: Layer, key: str, 판: str = "", owner: str | None = None, session: Session = Depends(verify_session), ): return _call(_root(session, layer, owner, write=True), matset.delete, key, 판)