- Z01_MasterData_Repository_BasePrices.py 신설 — master_base_price·master_revision Raw SQL(읽기·줄 수·판번호·저장·되돌리기) · 판번호 줄을 잠그고 다시 봄 · 틀리면 통째로 되돌림
- GET /base-prices/{kind} 가 DB 를 읽음 · 응답에 revision · 줄마다 changed_columns·is_added 추가
- PUT /base-prices/{kind} {base_revision, edits, added, deleted} — 판 다르면 409 · 하나라도 틀리면 아무것도 안 씀 · edits 500 상한 · 모르는 칸 거절
- POST /base-prices/{kind}/reset {row_keys} — 주입 줄은 data := seed(지운 줄도 되살림) · 추가 줄은 지움
- 기계 시간당 단가는 DB 노임·유가로 읽을 때마다 셈 · 제원은 줄 값에서(추가한 기종도 섬)
- 옛 덮개 층 삭제 — Z01_MasterData_Overrides.py · 칸 단위 PUT · /overrides·/overrides/clear
- 새 줄 열쇠는 서버가 줌(added/{난수}) · 주입 data 에 '@' 칸이 없어도 축 잠금은 파일 원본으로 섬
- 시험: DB 가짜(helper_z01_fake_repo.py) 로 API 손질 · 저장소 SQL 흐름은 가짜 커넥션으로 잼
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
179 lines
6.9 KiB
Python
179 lines
6.9 KiB
Python
"""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})
|