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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user