Files
Aislo/Z01_MasterData/Z01_MasterData_Router_WorkItems.py
T
eomsangdonandClaude Opus 5 e1c71c3e8b feat(Z01): 공종 편집 저장소·API — 목록·상세·전체 줄 저장·초기값으로 (PLAN 1-3)
- GET /work-items(상태별 개수) · GET/PUT /work-items/{key} · POST …/reset 신설, 시스템관리자 전용 묶음에 등록
- 저장은 한 트랜잭션 — 낡은 revision 409 · 없는 자원 열쇠·남의 줄 id 422 · 실패 시 전부 되돌림
- 빠진 줄은 초기값 줄이면 숨김(초기값으로 때 살림) · 관리자 추가 줄이면 지움
- 검사·줄 비교·되돌리기 규칙은 WorkItems.py 순수 함수 · SQL 은 Repository_WorkItems.py
- 옛 기초단가 표의 공종 축 보기(JSON 읽기)는 그대로 둠 — BasePrices.py(sub_laptop_2 몫)가 동기로 부름
- 시험 15개(test_z01_work_item_edit.py) — DB 없이 가짜 커넥션으로 409·422·롤백 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
2026-09-18 23:47:33 +09:00

153 lines
6.4 KiB
Python

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