PLAN 4-2. 조합 = 관리자가 이름을 붙이고 단가산출 로직을 골라 담은 묶음. 마스터에는 담은 로직 목록만 두고 수량·비율·계산은 두지 않음(4-0 확정 · 계산은 프로젝트 몫). - 새 파일 `일위대가조합.json`(빈 채 · 줄은 사용자가 화면에서 만듦) · 테이블ID `UA` · 원문이 없어 머리 `원문` 과 줄 `원문번호` 를 두지 않음(`mk.NO_NUMBER` 에 `UA`) - 엔진 `scripts/master_combo.py` — 검사 넷(없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음) · 「이 로직을 쓰는 조합」 거꾸로 찾기 · 미리 보기 계산 - `check_master.py` 에 「조합」 갈래 더함 · 저장 전 검사(`check_saved`)에도 얹어 422 로 막음 - 서버 `M01_MasterData_Store_Combo.py` — `GET /combos`·`/combo`·`/logic/combos` · `POST /combo/new`·`/edit`·`/delete`·`/preview`(저장 없음) - 조합은 요소 그룹이 아님 — `/groups` 는 여덟 그대로 두고 제 길로만 다룸 - 틀 `_틀.md` 10장 · 계약 `_화면_계약.md` 9장 · 시험 `test_m01_combo.py` 17 검증 — 견본 조합(13-4-1 · 13-4-2 · 13-2-1) 미리 보기 합계가 그 로직 셋의 시험 계산을 비목별로 더한 값과 같음 · 못 도는 줄은 그 줄만 멈추고 나머지 합계는 그대로 · 저장 전 조합 줄로도 미리 보기 되고 아무것도 안 생김 · 검사 넷 다 422 · 낡은 판본 409 · 정본 로직 안 바뀜 · `check_master 틀`·`조합` 0건 · pytest 44 통과(조합 17 + api 27) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaS48Nvs26o3aFUpcfJxVv
331 lines
9.3 KiB
Python
331 lines
9.3 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_Combo as combo_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
|
|
|
|
|
|
class FormulaLogic(BaseModel):
|
|
"""복사해서 만들기 — 별칭 꼴 식이 든 로직 줄과 그 별칭표(되돌릴 원문)."""
|
|
|
|
로직: dict[str, Any]
|
|
별칭: list[dict[str, Any]] = []
|
|
owner: str = make.OWNERS[0]
|
|
이름: str = ""
|
|
본뜬키: str = ""
|
|
|
|
|
|
class FormulaPreview(BaseModel):
|
|
"""저장 전 미리보기 — 별칭을 원문으로 되돌린 로직 줄만 받고 싶을 때(시험 계산 전)."""
|
|
|
|
로직: dict[str, Any]
|
|
별칭: list[dict[str, Any]] = []
|
|
|
|
|
|
class NewCombo(BaseModel):
|
|
"""일위대가 조합 — 담은 단가산출 로직 목록만(수량·비율·계산은 안 둠)."""
|
|
|
|
combo: dict[str, Any]
|
|
owner: str = combo_store.OWNERS[0]
|
|
|
|
|
|
class EditCombo(BaseModel):
|
|
key: str
|
|
version: str
|
|
combo: dict[str, Any]
|
|
owner: str = ""
|
|
|
|
|
|
class DeleteCombo(BaseModel):
|
|
key: str
|
|
version: str
|
|
|
|
|
|
class PreviewCombo(BaseModel):
|
|
"""저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 저장된 키 · `inputs` 는 로직마다."""
|
|
|
|
combo: dict[str, Any] | str
|
|
inputs: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
class DraftLogic(BaseModel):
|
|
text: str # 줄로 적은 식 — 줄마다 「이름 = 식」
|
|
decided: dict[str, Any] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}}
|
|
head: dict[str, Any] | None = None # 로직 머리(이름 · 결과단위 …) — 주면 로직 줄까지
|
|
save: bool = False
|
|
owner: str = make.OWNERS[0]
|
|
|
|
|
|
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)
|
|
|
|
|
|
@router.get("/logic/formula")
|
|
def get_logic_formula(key: str) -> dict:
|
|
"""복사해서 만들기 — 로직 한 줄을 별칭 꼴 식 묶음으로."""
|
|
return _call(make.logic_formula, key)
|
|
|
|
|
|
@router.post("/logic/formula/save")
|
|
def post_logic_formula_save(body: FormulaLogic) -> dict:
|
|
"""고친 식 묶음을 자체 로직(`GX`)으로 — 별칭은 원문으로 되돌려 저장."""
|
|
return _call(make.logic_from_formula, body.로직, body.별칭, body.owner, body.이름, body.본뜬키)
|
|
|
|
|
|
@router.post("/logic/formula/preview")
|
|
def post_logic_formula_preview(body: FormulaPreview) -> dict:
|
|
"""저장 전 시험 계산용 — 별칭을 원문으로 되돌린 로직 줄만(저장 안 함)."""
|
|
return _call(make.logic_formula_preview, body.로직, body.별칭)
|
|
|
|
|
|
@router.post("/logic/draft")
|
|
def post_logic_draft(body: DraftLogic) -> dict:
|
|
"""식으로 새 로직 만들기 — 변수 뽑기 · 검사 · 로직 줄로 옮기기(`save` 면 저장까지)."""
|
|
return _call(make.logic_draft, body.text, body.decided, body.head, body.save, body.owner)
|
|
|
|
|
|
# ── 일위대가 조합(계약 9장) ────────────────────────────────────────────
|
|
@router.get("/combos")
|
|
def get_combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> dict:
|
|
return {"combos": _call(combo_store.combos, sub, detail, q, owner)}
|
|
|
|
|
|
@router.get("/combo")
|
|
def get_combo(key: str) -> dict:
|
|
return _call(combo_store.combo, key)
|
|
|
|
|
|
@router.get("/logic/combos")
|
|
def get_logic_combos(key: str) -> dict:
|
|
"""이 단가산출 로직을 담은 조합 — 로직 쪽에서 거꾸로 봄."""
|
|
return _call(combo_store.combos_of_logic, key)
|
|
|
|
|
|
@router.post("/combo/new")
|
|
def post_combo_new(body: NewCombo) -> dict:
|
|
return _call(combo_store.combo_new, body.combo, body.owner)
|
|
|
|
|
|
@router.post("/combo/edit")
|
|
def post_combo_edit(body: EditCombo) -> dict:
|
|
return _call(combo_store.combo_edit, body.key, body.version, body.combo, body.owner)
|
|
|
|
|
|
@router.post("/combo/delete")
|
|
def post_combo_delete(body: DeleteCombo) -> dict:
|
|
return _call(combo_store.combo_delete, body.key, body.version)
|
|
|
|
|
|
@router.post("/combo/preview")
|
|
def post_combo_preview(body: PreviewCombo) -> dict:
|
|
"""담은 로직마다 값을 뽑아 비목별로 갈라 합계 — 저장하지 않음."""
|
|
return _call(combo_store.combo_preview, body.combo, body.inputs)
|