feat(Z01): 기초단가 정본을 DB 로 — 저장소 신설 · 일괄 저장·줄 추가/삭제·초기값 되돌리기 API · 덮개 층 삭제

- 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
This commit is contained in:
2026-09-18 23:53:50 +09:00
co-authored by Claude Opus 5
parent 9629430aff
commit 6fe4056d98
11 changed files with 883 additions and 702 deletions
+113 -39
View File
@@ -1,32 +1,69 @@
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인).
"""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=[&거르기=값] 한 표(기초단가 다섯 · 품셈 기준 열 · 공종 축 둘)
PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기
GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
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
from typing import Any, Literal
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
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 BasePriceEdit(BaseModel):
values: dict[str, Any]
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")
@@ -50,14 +87,19 @@ def _kind(kind: str) -> str:
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")
def get_base_price_kinds() -> dict:
async def get_base_price_kinds() -> dict:
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
return {"kinds": base_prices.kinds()}
return {"status": "success", "kinds": await base_prices.kinds()}
@router.get("/base-prices/{kind}")
def get_base_prices(
async def get_base_prices(
kind: str,
request: Request,
page: int = 1,
@@ -66,39 +108,71 @@ def get_base_prices(
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:
return base_prices.table(
_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked
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}/{row_id:path}")
def put_base_price(
kind: str, row_id: str, body: BasePriceEdit, session: dict = Depends(verify_session)
@router.put("/base-prices/{kind}")
async def put_base_prices(
kind: str, body: BasePriceSave, session: dict = Depends(verify_session)
) -> JSONResponse:
status, payload = base_prices.edit(_kind(kind), row_id, body.values, session.get("user_id"))
if status != 200:
raise HTTPException(status_code=status, detail=payload["message"])
return JSONResponse(content=payload)
"""고친 칸·새 줄·지운 줄을 한 번에 — 다 맞춰 본 뒤 한 트랜잭션(하나라도 틀리면 아무것도 안 씀)."""
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.get("/overrides")
def get_overrides(
kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE
) -> dict:
return base_prices.override_items(kind and _kind(kind), state, page=page, size=size)
class OverrideClear(BaseModel):
kind: str
state: Literal["edited", "source_changed", "orphan"]
@router.post("/overrides/clear")
def clear_overrides(body: OverrideClear, _session: dict = Depends(verify_session)) -> dict:
"""한 kind 의 그 state 덮개를 한꺼번에 뺌 — kind·state 둘 다 있어야 함(통째로 비우기 막음)."""
return {"removed": base_prices.clear_overrides(_kind(body.kind), body.state)}
@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})