- 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
153 lines
6.4 KiB
Python
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)
|