"""Z01 공종 편집 라우터 — 공종 머리·구성 줄을 관리자가 고치고 메움(2026-09-18 PLAN 1-0 계약). GET /api/master-data/work-items?axis&status&q&page&size 목록 + 상태별 개수 GET /api/master-data/work-items/{key} 머리(+revision)·구성 줄·원문 표·[주] PUT /api/master-data/work-items/{key} 전체 줄 보내기 → 409(낡은 revision) · 422(검사) POST /api/master-data/work-items/{key}/reset 머리·줄을 초기값으로(추가 줄 삭제) 쓰기는 한 트랜잭션(전부 아니면 전무) · 응답은 새 GET 모양 그대로(화면이 다시 안 불러도 됨). ⚠ 권한은 등록하는 쪽(`main.py`)이 시스템 관리자 전용 묶음으로 붙임. """ from __future__ import annotations from decimal import Decimal from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session from config.config_db import get_db_pool from Z01_MasterData import Z01_MasterData_Repository_WorkItems 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 WorkItems"]) Status = Literal[work_items.STATUSES] # 튜플을 펼침 — 상태 이름은 한 곳(`STATUSES`) MAX_ROWS = 500 class _Strict(BaseModel): model_config = ConfigDict(extra="forbid") class ItemHead(_Strict): name: str = Field(min_length=1) basis_quantity: Decimal | None = Field(default=None, ge=0) basis_unit: str | None = None status: Status class ItemRow(_Strict): id: int | None = None # 있으면 수정 · 없으면 추가 variant: str = "" resource_kind: Literal["labor", "machine", "material", "etc"] resource_key: str | None = Field(default=None, min_length=1) # 못 맞춘 줄은 null(빈 글자 금지) resource_name: str = "" resource_spec: str = "" amount: Decimal | None = Field(default=None, ge=0) amount_unit: str = "" alternative_amount: Decimal | None = Field(default=None, ge=0) group_ratio_pct: Decimal | None = Field(default=None, ge=0) raw_cell: str | None = None pum_table_id: str = "" class WorkItemSave(_Strict): base_revision: int item: ItemHead rows: list[ItemRow] = Field(max_length=MAX_ROWS) # 전체 줄 — 빠진 줄은 삭제 @router.get("/work-items") async def list_work_items( axis: Literal["", "forest", "const"] = "", status: Status | Literal[""] = "", q: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, ) -> dict[str, Any]: page, size = max(page, 1), max(1, min(size, tables.MAX_PAGE_SIZE)) async with get_db_pool().acquire() as connection: found = await repo.list_items( connection, axis=axis, status=status, q=q.strip(), page=page, size=size ) counts = {s: found["counts"].get(s, 0) for s in work_items.STATUSES} return {"status": "success", **found, "counts": counts, "page": page, "size": size} async def _detail(connection: Any, key: str) -> dict[str, Any]: head = await repo.fetch_item(connection, key) if head is None: raise HTTPException(status_code=404, detail=f"없는 공종: {key}") rows = await repo.fetch_rows(connection, head["id"]) revision = await repo.revision(connection, work_items.revision_scope(key)) return {"status": "success", **work_items.detail(head, rows, revision)} @router.get("/work-items/{key}") async def get_work_item(key: str) -> dict[str, Any]: async with get_db_pool().acquire() as connection: return await _detail(connection, key) async def _locked(connection: Any, key: str) -> tuple[dict[str, Any], int]: """공종 머리 + revision 을 잡음(같은 공종 저장이 줄 섬).""" head = await repo.fetch_item(connection, key, lock=True) if head is None: raise HTTPException(status_code=404, detail=f"없는 공종: {key}") return head, await repo.revision(connection, work_items.revision_scope(key), lock=True) @router.put("/work-items/{key}") async def put_work_item( key: str, body: WorkItemSave, session: dict = Depends(verify_session) ) -> dict[str, Any]: rows = [row.model_dump() for row in body.rows] async with get_db_pool().acquire() as connection: await connection.begin() try: head, current = await _locked(connection, key) if current != body.base_revision: raise HTTPException( status_code=409, detail="다른 곳에서 먼저 고쳤음 — 다시 불러올 것" f"(지금 {current} · 보낸 {body.base_revision})", ) existing = await repo.fetch_rows(connection, head["id"]) known = await repo.known_resource_keys( connection, {row["resource_key"] for row in rows if row["resource_key"]} ) errors = work_items.check_rows(rows, {row["id"] for row in existing}, known) if errors: raise HTTPException(status_code=422, detail="\n".join(errors)) plan = work_items.plan_rows(existing, rows) await repo.write( connection, head["id"], body.item.model_dump(), plan, session.get("user_id") ) await repo.bump_revision(connection, work_items.revision_scope(key)) await connection.commit() except BaseException: await connection.rollback() raise return await _detail(connection, key) @router.post("/work-items/{key}/reset") async def reset_work_item(key: str, session: dict = Depends(verify_session)) -> dict[str, Any]: async with get_db_pool().acquire() as connection: await connection.begin() try: head, _current = await _locked(connection, key) rows = await repo.fetch_rows(connection, head["id"], with_deleted=True) plan = work_items.reset_plan(head, rows) await repo.write(connection, head["id"], plan["head"], plan, session.get("user_id")) await repo.bump_revision(connection, work_items.revision_scope(key)) await connection.commit() except BaseException: await connection.rollback() raise return await _detail(connection, key)