Files
Aislo/M01_MasterData/M01_MasterData_Router.py
T
eomsangdonandClaude Opus 5 d12f471c93 feat(M01): 사람이 읽는 식 줄 API — 비목 묶음마다 「단가 * 수량식 = 금액」 과 소계
- 엔진 `resources/master_data/scripts/master_text.py` — 식 마디를 그 자리에서 나온 수로 풀어 한 줄 글로(찾기·로직·만약·중간 값·요소는 글자로 안 나감 · 「… / 100」 은 「… %」 · 수는 화면과 같은 자릿수)
- `POST /text` — 몸은 `/calc` 와 같음 · 답 `{ok, groups: [{비목, 줄, 소계, 끝수}], 계}` · 멈추면 까닭 한 줄
- 중간 값·끝수는 따로 줄로 안 보임(끝수는 소계 옆 한 마디) · 덧줄은 그 비목의 한 줄 · 하위 로직 줄은 비목마다 한 줄
- 단가 0 이거나 안 쓰는 줄은 까닭 한 마디
- 시험 `resources/tester/test_m01_text.py` 6 통과(13-4-1 · 12-4 · 12-17-1 · 계가 `/calc` 와 같음) · M01 시험 41 통과 · typecheck 통과 · 일괄 시험 계산 1,291 그대로

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-21 19:37:55 +09:00

217 lines
5.4 KiB
Python

"""M01 마스터 데이터 관리자 API — 계약 `resources/master_data/_화면_계약.md`.
⚠ 권한은 등록하는 쪽(`main.py`)이 `system_admin_only` 로 붙임.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Make as make
router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
class CalcBody(BaseModel):
key: str
inputs: dict[str, Any] = {}
row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로)
file: str | None = None # 새 로직이면 더할 로직 파일
class Change(BaseModel):
op: Literal["edit", "add", "delete"]
key: str | None = None
row: dict[str, Any] | None = None
class FileChanges(BaseModel):
file: str
version: str
changes: list[Change]
class SaveBody(BaseModel):
files: list[FileChanges]
class NewLogic(BaseModel):
logic: dict[str, Any]
owner: str = make.OWNERS[0]
class CopyLogic(BaseModel):
key: str
이름: str = ""
owner: str = make.OWNERS[0]
class EditLogic(BaseModel):
key: str
version: str
logic: dict[str, Any]
owner: str = ""
class DeleteLogic(BaseModel):
key: str
version: str
def _num(v):
"""Decimal → JSON 수(그냥 두면 글자로 나감)."""
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
if isinstance(v, list):
return [_num(x) for x in v]
if isinstance(v, dict):
return {k: _num(x) for k, x in v.items()}
return v
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/groups")
def get_groups() -> dict:
return {"groups": _call(store.groups)}
@router.get("/groups/{group}/files")
def get_files(group: str) -> dict:
return {"files": _call(store.files_of, group)}
@router.get("/subs")
def get_subs(file: str = "", group: str = "") -> dict:
return _call(store.subs, file, group)
@router.get("/rows")
def get_rows(
file: str,
page: int = 1,
size: int = 50,
q: str = "",
unlinked: bool = False,
sub: str = "",
detail: str = "",
state: str = "",
) -> dict:
return _call(store.rows, file, page, size, q, unlinked, sub, detail, state)
@router.get("/tables")
def get_tables(
file: str = "",
group: str = "",
sub: str = "",
detail: str = "",
q: str = "",
page: int = 1,
size: int = 30,
usage: str = "",
section: str = "",
) -> dict:
return _call(store.tables, file, group, sub, detail, q, page, size, usage, section)
@router.get("/table")
def get_table(file: str, key: str) -> dict:
return _call(store.table, file, key)
@router.get("/table/options")
def get_table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 줄을 통째로 받지 않음."""
return _call(make.table_options, file, key)
@router.get("/sections")
def get_sections(
book: str, division: str = "", chapter: str = "", q: str = "", limit: int = 200
) -> dict:
"""원문 절 목록 — 만들기 첫 걸음."""
return _call(make.sections, book, division, chapter, q, limit)
@router.get("/logics")
def get_logics(
sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = ""
) -> dict:
return {"logics": _call(store.logics, sub, detail, q, blocked, owner)}
@router.get("/logic")
def get_logic(key: str) -> dict:
return _call(store.logic, key)
@router.get("/elements")
def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
return _call(store.elements, group, q, limit)
@router.get("/pick")
def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
return _call(store.pick, kind, q, limit)
@router.get("/materials")
def get_materials(
sub: str,
detail: str = "",
spec: str = "",
region: str = "",
unit: str = "",
limit: int = 50,
) -> dict:
"""재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역
· unit = 호표 줄 단위(주면 그 단위 줄만)."""
return _call(store.materials, sub, detail, spec, region, unit, limit)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.key, body.inputs, body.row, body.file)
@router.post("/text")
def post_text(body: CalcBody) -> dict:
"""읽는 식 줄 — 몸은 `/calc` 와 같음."""
return _call(store.text, body.key, body.inputs, body.row, body.file)
@router.post("/save")
def post_save(body: SaveBody) -> dict:
return {"files": _call(store.save, [f.model_dump() for f in body.files])}
@router.post("/logic/new")
def post_logic_new(body: NewLogic) -> dict:
return _call(make.logic_new, body.logic, body.owner)
@router.post("/logic/copy")
def post_logic_copy(body: CopyLogic) -> dict:
return _call(make.logic_copy, body.key, body.이름, body.owner)
@router.post("/logic/edit")
def post_logic_edit(body: EditLogic) -> dict:
return _call(make.logic_edit, body.key, body.version, body.logic, body.owner)
@router.post("/logic/delete")
def post_logic_delete(body: DeleteLogic) -> dict:
return _call(make.logic_delete, body.key, body.version)