"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 DB 정본 일괄 저장(2026-09-18 PLAN 1-0 계약). GET /api/master-data/tree 갈래 → 파일 → 표 GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색) GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함) GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표 + revision · 줄마다 changed_columns · is_added PUT /api/master-data/base-prices/{kind} {base_revision, edits, added, deleted} → 409 · 전부 아니면 전무 POST /api/master-data/base-prices/{kind}/reset {row_keys} → 초기값으로(추가 줄은 지움) ⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임. ⚠ 자동저장 없음 — 화면은 [저장] 에서만 PUT(지침 5장). """ from __future__ import annotations import asyncio from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field from starlette.concurrency import run_in_threadpool from common_util.common_util_auth import verify_session from Z01_MasterData import Z01_MasterData_BasePrices as base_prices from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo from Z01_MasterData import Z01_MasterData_Tables as tables from Z01_MasterData import Z01_MasterData_WorkItems as work_items router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"]) _STALE = "그 사이 누가 이 표를 저장했습니다 — 표를 다시 불러와 고칠 것." class CellEdit(BaseModel): model_config = ConfigDict(extra="forbid") row_key: str = Field(min_length=1, max_length=190) column: str = Field(min_length=1, max_length=100) value: Any = None class AddedRow(BaseModel): model_config = ConfigDict(extra="forbid") data: dict[str, Any] = Field(min_length=1) class BasePriceSave(BaseModel): """[저장] 한 번 — 읽어 간 판번호와 함께 고친 칸·새 줄·지운 줄.""" model_config = ConfigDict(extra="forbid") base_revision: int = Field(ge=0) edits: list[CellEdit] = Field(default_factory=list, max_length=500) added: list[AddedRow] = Field(default_factory=list, max_length=500) deleted: list[str] = Field(default_factory=list, max_length=500) class BasePriceReset(BaseModel): model_config = ConfigDict(extra="forbid") row_keys: list[str] = Field(min_length=1, max_length=10000) def _error(status: int, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"status": "error", "message": message}) @router.get("/tree") def get_tree() -> dict: return tables.tree() @router.get("/rows") def get_rows( file: str, table: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" ) -> dict: result = tables.rows(file, table, page=page, size=size, q=q) if result is None: raise HTTPException(status_code=404, detail="없는 파일이나 표입니다.") return result def _kind(kind: str) -> str: if kind not in base_prices.KINDS: raise HTTPException(status_code=404, detail=f"없는 기초단가: {kind}") return kind def _db_kind(kind: str) -> str | None: """고칠 수 있는 표(DB 정본) — 공종 축은 여기서 안 고침(`/work-items` 몫).""" return kind if kind in base_prices.KINDS and kind not in work_items.KINDS else None @router.get("/base-prices") async def get_base_price_kinds() -> dict: """어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄.""" return {"status": "success", "kinds": await base_prices.kinds()} @router.get("/base-prices/{kind}") async def get_base_prices( kind: str, request: Request, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "", sort: str = "", desc: int = 0, ) -> dict: kind = _kind(kind) # 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실) picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS} if _db_kind(kind): all_rows, revision = await asyncio.gather(base_prices.rows(kind), repo.revision(kind)) else: all_rows, revision = await base_prices.rows(kind), 0 try: table = await run_in_threadpool( base_prices.table, kind, all_rows, page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked, ) except base_prices.SortError as error: raise HTTPException(status_code=400, detail=str(error)) from error return {"status": "success", "revision": revision, **table} @router.put("/base-prices/{kind}") async def put_base_prices( kind: str, body: BasePriceSave, session: dict = Depends(verify_session) ) -> JSONResponse: """고친 칸·새 줄·지운 줄을 한 번에 — 다 맞춰 본 뒤 한 트랜잭션(하나라도 틀리면 아무것도 안 씀).""" if not _db_kind(kind): return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}") if await repo.revision(kind) != body.base_revision: return _error(409, _STALE) try: changed, added, deleted = base_prices.plan_save( kind, await base_prices.rows(kind), [edit.model_dump() for edit in body.edits], [row.data for row in body.added], body.deleted, ) except base_prices.SaveError as error: return _error(error.status, str(error)) if not (changed or added or deleted): return JSONResponse( content={"status": "success", "revision": body.base_revision, "added": []} ) try: revision = await repo.save( kind, body.base_revision, changed, added, deleted, session.get("user_id") ) except repo.RevisionConflict: return _error(409, _STALE) return JSONResponse(content={"status": "success", "revision": revision, "added": list(added)}) @router.post("/base-prices/{kind}/reset") async def reset_base_prices( kind: str, body: BasePriceReset, session: dict = Depends(verify_session) ) -> JSONResponse: """고른 줄을 초기값으로 — 주입 줄은 data := seed · 추가 줄은 지움. 판번호 +1.""" if not _db_kind(kind): return _error(404 if kind not in base_prices.KINDS else 400, f"고칠 수 없는 표: {kind}") try: revision = await repo.reset(kind, body.row_keys, session.get("user_id")) except repo.UnknownRows as error: return _error(404, f"없는 줄: {', '.join(error.args[0])}") return JSONResponse(content={"status": "success", "revision": revision})