revert(Z01): 롤백된 마스터 DB·화면 작업이 dev 로 되들어온 것 걷어냄

- 사용자 「완전히 되돌려」 뒤 dev 를 거쳐 다시 들어온 1-2·1-3·1-4 코드 26 경로를 9629430a 모습으로(고친 17 되돌림 · 새로 생긴 9 지움)
- 대상: Z01_MasterData 17 · main.py · ui_template_locale_b3.ts · resources/tester 의 Z01 시험 7
- 품셈 md·도구는 안 건드림 · 이력 재작성 없음(되돌림 커밋)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
This commit is contained in:
2026-09-19 01:36:21 +09:00
co-authored by Claude Opus 5
parent 6b417d3b79
commit f2fe4f4214
26 changed files with 1012 additions and 3598 deletions
+39 -113
View File
@@ -1,69 +1,32 @@
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 DB 정본 일괄 저장(2026-09-18 PLAN 1-0 계약).
"""Z01 마스터 데이터 라우터 — 읽기 + 기초단가 고치기(2026-09-15 브레인).
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} → 초기값으로(추가 줄은 지움)
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 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
⚠ 자동저장 없음 — 화면은 [저장] 에서만 PUT(지침 5장).
"""
from __future__ import annotations
import asyncio
from typing import Any
from typing import Any, Literal
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 pydantic import BaseModel
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})
class BasePriceEdit(BaseModel):
values: dict[str, Any]
@router.get("/tree")
@@ -87,19 +50,14 @@ 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")
async def get_base_price_kinds() -> dict:
def get_base_price_kinds() -> dict:
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
return {"status": "success", "kinds": await base_prices.kinds()}
return {"kinds": base_prices.kinds()}
@router.get("/base-prices/{kind}")
async def get_base_prices(
def get_base_prices(
kind: str,
request: Request,
page: int = 1,
@@ -108,71 +66,39 @@ async 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:
table = await run_in_threadpool(
base_prices.table,
kind,
all_rows,
page=page,
size=size,
q=q,
sort=sort,
desc=bool(desc),
filters=picked,
return base_prices.table(
_kind(kind), 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)
@router.put("/base-prices/{kind}/{row_id:path}")
def put_base_price(
kind: str, row_id: str, body: BasePriceEdit, 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)})
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)
@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})
@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)}