- 항목 경로 = 폴더 이름 배열(깊이 자유) · 집계표 열 머리(빈 칸 건너뜀)가 곧 경로 · 열에 경로를 같이 적음 · 머리 줄 수(층)는 가장 깊은 경로에 맞춤(넷 밑으로는 안 줄임) · 빈 폴더는 집계표 폴더 칸(M02_MasterTemplete_Tree)
- 표 저장과 나무 고침 한 줄기 — 나무 고침은 집계표 문서를 고쳐 같은 집계표 저장(store.write)을 탐(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기가 함께)
- 나무 고침 길 POST /api/m02/layers/{layer}/tree(add-folder · add-item · rename · move · delete · 판 409 · 422 · 404 · 층 권한) · 읽기 GET 같은 길 {판, 폴더, items}
- 번호 = 새 항목은 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가장 큰 수 + 1) · 있던 번호 그대로
- SS 장부 = M02_MasterTemplete_Book 로 떼어 냄(Store 543 → 478) · 줄에 경로 · 이름 · 옛 종류 · 분류1~3 = 경로 앞 넷 · 경로 없는 옛 줄은 읽을 때 채움 · 옮기는 함수 migrate_paths
- 목록 길 /api/m02/items 줄에 경로 · 이름(경로 이음) · 자재 조합 · 갈라 내기 이름도 경로 이음
- 마스터 집계표 · 장부를 경로 모양으로 옮김(51 항목 · 키 · 번호 그대로 · 빈 글 머리는 null)
- 시험 test_m02_tree 5(옛 51 항목 · 옮기기 두 번 같음 · 깊이 1 · 2 · 5 · 이름 · 옮기기 · 지우기 · 마지막 항목 · 409 · 403 · 표 저장 한 줄기) · 번호 시험을 일련번호 규칙으로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""M02 층 집계표 API — `/api/m02/layers/{layer}/…` · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2).
|
|
|
|
⚠ 등록은 `main.py`(로그인만) · 층 권한은 여기서(`Router_Layers._layer_dir` 와 같은 규칙):
|
|
system 읽기 = 로그인 누구나(항목 고르기) · 쓰기 = SYSTEM_ADMIN
|
|
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN)
|
|
personal 읽기 · 쓰기 = 본인(SYSTEM_ADMIN 은 누구나) · 회사 없음 = 쓰기 409 「회사부터 등록」
|
|
옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로.
|
|
회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`).
|
|
결정(PLAN 21-9): 일위대가(UA) 갈라 내기는 새 UA 가 M01 시스템 마스터에 생기므로 층 길에서도
|
|
SYSTEM_ADMIN 만 · 첫 진입 뒤 구조물집계표가 이미 있어 같은 이름 새로 쓰기(판 없이)는 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_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 import M02_Template_Layers as layers
|
|
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 = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음"
|
|
|
|
|
|
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
|
|
|
|
|
|
class CopyItemBody(BaseModel):
|
|
from_layer: Literal["system", "company"]
|
|
from_owner: str | None = None
|
|
key: str
|
|
|
|
|
|
def _admin(session: Session) -> bool:
|
|
return session.get("role") == "SYSTEM_ADMIN"
|
|
|
|
|
|
def _company_admin(session: Session) -> bool:
|
|
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
|
|
|
|
|
|
def _owner(session: Session, layer: str, owner: str | None) -> str | None:
|
|
"""층 주인 글 — 회사 `{회사}` · 개인 `{회사}/{사용자}` · 시스템 None ·
|
|
남의 것은 SYSTEM_ADMIN 만."""
|
|
if layer == "system":
|
|
return None
|
|
company, user = session.get("company_id"), session.get("user_id")
|
|
mine = str(company) if layer == "company" else f"{company}/{user}"
|
|
if owner in (None, "", mine):
|
|
return None if company is None else mine
|
|
if not _admin(session):
|
|
raise HTTPException(status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표입니다.")
|
|
return owner
|
|
|
|
|
|
def _folder(layer: str, owner: str | None) -> Path:
|
|
if layer == "system":
|
|
return store.FOLDER
|
|
try:
|
|
if layer == "company":
|
|
return layers.company_dir(owner)
|
|
company, user = str(owner).split("/", 1)
|
|
return layers.personal_dir(company, user)
|
|
except ValueError as error:
|
|
raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error
|
|
|
|
|
|
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
|
|
"""층 폴더(권한까지) — 회사 · 개인은 없으면 빈 뼈대를 만듦. 회사 없는 사람: 쓰기 409."""
|
|
if layer == "system":
|
|
if write and not _admin(session):
|
|
raise HTTPException(status_code=403, detail="마스터 템플릿은 시스템 관리자만 고침.")
|
|
return store.FOLDER
|
|
who = _owner(session, layer, owner)
|
|
if who is None:
|
|
raise HTTPException(status_code=409, detail=NO_COMPANY)
|
|
if write and layer == "company" and not _company_admin(session):
|
|
raise HTTPException(
|
|
status_code=403, detail="회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
|
|
)
|
|
folder = _folder(layer, who)
|
|
items_store.ensure(folder)
|
|
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]:
|
|
who = None if layer == "system" else _owner(session, layer, owner)
|
|
if layer != "system" and who is None:
|
|
return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹
|
|
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 = (
|
|
None if body.from_layer == "system" else _owner(session, body.from_layer, body.from_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 = {str(r.get("키")): str(r.get("열")) for r in store.read_combos().get("줄", [])}
|
|
try:
|
|
doc, note = tree.apply(got["문서"], body, ids)
|
|
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.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),
|
|
):
|
|
"""일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만."""
|
|
folder = _root(session, layer, owner, write=True)
|
|
if not _admin(session):
|
|
raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.")
|
|
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, 판)
|