Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py
eomsangdonandClaude Opus 5.5 0f510eb53b feat(M02): 공용 나무 여러 개 고르기 · 복사 · 붙여넣기 · 여러 개 옮기기 · 지우기 + 층 사이 붙여넣기 서버 · 「+」 오른쪽 맞춤 (PLAN 30-2)
- 나무 부품 — Ctrl · Shift 클릭 고름(TreePanel_Pick) · 고른 줄 배경 · 층 단추 줄 「N개 고름」 · 오른쪽 클릭 메뉴가 고른 것 전부(복사 · 옮기기 · 지우기) · Ctrl+C · Ctrl+V · Delete · 복사는 읽기 층에서도 · 붙여넣기는 쓸 수 있는 층만 · 콜백 api.many · api.paste · clipKind(일위대가 쪽이 붙일 자리)
- 서버 — POST /layers/{층}/items/paste(여러 항목 · 폴더 · 새 SC/SP 키 · 본뜬 = 원본 · 같은 이름 「(2)」 · 세 파일 · 입력 연결 · 자재 조합 · 빈 폴더까지 · M02_Layer_Access 쓰기 층만) · 나무 고침 대상 여러 개(옮기기 · 지우기 한 번 저장 · 막히면 안 씀)
- 층 단추 줄 「N개 고름」 · 「+」 오른쪽 끝
- 시험 test_m02_tree_paste 새로 · 고르기 규칙 Node 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:05:01 +09:00

374 lines
15 KiB
Python

"""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, 판)