- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`) - 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로) - 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝 - 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김 - 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글 - 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음) - 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음 - 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록) - 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
454 lines
15 KiB
Python
454 lines
15 KiB
Python
"""M01 마스터 데이터 관리자 API — 계약 `resources/master_data/_화면_계약.md`.
|
|
|
|
⚠ 권한은 등록하는 쪽(`main.py`)이 `system_admin_only` 로 붙임.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from decimal import Decimal
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from M01_MasterData import M01_MasterData_Doc as doc_screen
|
|
from M01_MasterData import M01_MasterData_Equation as equation
|
|
from M01_MasterData import M01_MasterData_Sheet as line_sheet
|
|
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_List as store_list
|
|
from M01_MasterData import M01_MasterData_RateSheet as rate_sheet
|
|
from M01_MasterData import M01_MasterData_Store_Make as make
|
|
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
|
from M01_MasterData import M01_MasterData_Store_Sheet as sheet
|
|
|
|
import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠)
|
|
|
|
router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
|
|
|
|
if "pytest" not in sys.modules: # 시험은 사본 폴더를 써서 정본을 미리 읽을 까닭이 없음
|
|
store.warm() # 서버가 뜰 때 뒤에서 마스터를 한 번 읽어 둠 — 첫 목록을 기다리지 않게
|
|
|
|
|
|
class CalcBody(BaseModel):
|
|
key: str
|
|
inputs: dict[str, Any] = {}
|
|
row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로)
|
|
file: str | None = None # 새 로직이면 더할 로직 파일
|
|
고름: dict[
|
|
str, str
|
|
] = {} # {호표 줄 차례(0부터): 자재 키} — 그 줄만 이 품목으로 셈 · 저장 안 함
|
|
|
|
|
|
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]
|
|
개정확인: bool = False # 로직 정본 줄 고침 = 「원문 개정 반영」 확인(713-1) · 없으면 409
|
|
|
|
|
|
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 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:
|
|
"""`판정규` = 머리 판의 해(「2026-01-01」 · 「2026」 → 「2026」) · 읽을 때 파생(파일 안 씀)."""
|
|
files = _call(store.files_of, group)
|
|
return {"files": [{**f, "판정규": mbs.edition(f["edition"])} for f in files]}
|
|
|
|
|
|
@router.get("/rate-sheet")
|
|
def get_rate_sheet() -> dict:
|
|
"""요율 화면 위쪽 내장 엑셀(조달청 제비율) — 읽기만(798-1)."""
|
|
return _call(rate_sheet.load)
|
|
|
|
|
|
@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:
|
|
out = _call(store.tables, file, group, sub, detail, q, page, size, usage, section)
|
|
for t in out["tables"]: # 기준배 · 기준단위 — 읽을 때 파생(정본 표에 안 씀 · 712-3)
|
|
t["기준파생"] = _num(mbs.basis(t))
|
|
return out
|
|
|
|
|
|
@router.get("/table")
|
|
def get_table(file: str, key: str) -> dict:
|
|
"""`기준파생` 은 표 밖 칸 — 표 줄째 되돌려 저장해도 정본에 안 섞임."""
|
|
out = _call(store.table, file, key)
|
|
return {**out, "기준파생": _num(mbs.basis(out["table"]))}
|
|
|
|
|
|
@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:
|
|
"""줄마다 덧칸(묶음 · 작업량 · 부르는/불리는 수 · 관련 · 713-3) — 읽기만."""
|
|
return {"logics": store_list.attach(_call(store.logics, sub, detail, q, blocked, owner))}
|
|
|
|
|
|
@router.get("/logic")
|
|
def get_logic(key: str) -> dict:
|
|
"""`지문` = 줄 바탕 지문(계산 칸 · `Store_Revise.row_print`) — M02 덮개 「M01 개정」 비교 값."""
|
|
out = _call(store.logic, key)
|
|
return {**out, "지문": revise.row_print(out["logic"])}
|
|
|
|
|
|
@router.get("/logic/source")
|
|
def get_logic_source(src: str) -> dict:
|
|
"""로직 원문 — 출처 글로 찾은 품셈 본문 절(읽기만)."""
|
|
return _call(make.source, src)
|
|
|
|
|
|
@router.get("/logic/auto")
|
|
def get_logic_auto(key: str) -> dict:
|
|
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님)."""
|
|
return _call(store.auto, 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:
|
|
with store.cm.mf.mp.choosing(body.고름):
|
|
got = _call(store.calc, body.key, body.inputs, body.row, body.file)
|
|
return _pct(got, body)
|
|
|
|
|
|
def _pct(got: dict, body: CalcBody) -> dict:
|
|
"""% 덧줄 표기(149-4) — 줄에 율 · 규격 덧칸(M02 일위대가 하위 행과 같은 도우미 · 금액 그대로)."""
|
|
if not got.get("lines"):
|
|
return got
|
|
from M02_MasterTemplete import M02_Labor_Doc as labor_doc # M02 가 M01 을 부름 — 늦게
|
|
|
|
whole = store.loaded()[1]
|
|
try:
|
|
logic = body.row or whole.logic(body.key)
|
|
except store.cm.mf.FormulaError:
|
|
return got
|
|
lines = labor_doc.pct_extras(whole, logic, got["lines"], got.get("middle") or {})
|
|
return {**got, "lines": _num(lines)} # 율 Decimal(4.0) 이 글자 「4.0」 으로 나가지 않게
|
|
|
|
|
|
@router.post("/text")
|
|
def post_text(body: CalcBody) -> dict:
|
|
"""읽는 식 줄 — 몸은 `/calc` 와 같음."""
|
|
with store.cm.mf.mp.choosing(body.고름):
|
|
return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1)
|
|
|
|
|
|
@router.post("/save")
|
|
def post_save(body: SaveBody) -> dict:
|
|
"""로직 정본 줄 고침 = 개정 확인 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}."""
|
|
return _call(make.save, [f.model_dump() for f in body.files], body.개정확인)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
class SheetRow(BaseModel):
|
|
row: dict[str, Any]
|
|
|
|
|
|
class SheetCheck(BaseModel):
|
|
row: dict[str, Any]
|
|
갈래: Literal["중간", "호표", "덧줄"]
|
|
차례: int
|
|
식: str
|
|
|
|
|
|
@router.post("/logic/sheet")
|
|
def post_logic_sheet(body: SheetRow) -> dict:
|
|
"""줄 식 시트(794-1) — 줄마다 이름 꼴 식 글 · 식 칸 이름표(셈 · 쓰기 없음)."""
|
|
return _call(line_sheet.names, body.row)
|
|
|
|
|
|
@router.post("/logic/sheet/check")
|
|
def post_logic_sheet_check(body: SheetCheck) -> dict:
|
|
"""시트 식 칸 칠 때마다 — {되나, 까닭, 식(엔진), 글(이름 꼴)} · 저장 때와 같은 엔진 검사."""
|
|
return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식)
|
|
|
|
|
|
class EquationBody(BaseModel):
|
|
key: str
|
|
inputs: dict[str, Any] = {}
|
|
고침: list[dict[str, Any]] = []
|
|
version: str = ""
|
|
개정확인: bool = False
|
|
|
|
|
|
@router.post("/logic/equation")
|
|
def post_logic_equation(body: EquationBody) -> dict:
|
|
"""수식 탭(796-1) — 줄마다 이름식 · 값식 나무 · 풀이 · 변수표 · 합계(셈 · 쓰기 없음)."""
|
|
return _call(equation.read, body.key, body.inputs)
|
|
|
|
|
|
@router.post("/logic/equation/preview")
|
|
def post_logic_equation_preview(body: EquationBody) -> dict:
|
|
"""수식 고침 미리 셈 — {되나, 까닭, 줄, 수식, 전, 후} · 저장 안 함."""
|
|
return _call(equation.preview, body.key, body.고침, body.inputs)
|
|
|
|
|
|
class LinksBody(BaseModel):
|
|
갈래: str
|
|
검색: str = ""
|
|
분류: str = "인력"
|
|
|
|
|
|
@router.post("/logic/equation/links")
|
|
def post_logic_equation_links(body: LinksBody) -> dict:
|
|
"""새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만)."""
|
|
return _call(equation.links, body.model_dump())
|
|
|
|
|
|
@router.post("/logic/equation/save")
|
|
def post_logic_equation_save(body: EquationBody) -> dict:
|
|
"""수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409)."""
|
|
return _call(equation.save, body.key, body.고침, body.inputs, body.version, body.개정확인)
|
|
|
|
|
|
# ── 수식 문서(802-3) — 단가산출 기초 정본 · 화면은 같은 계산기로 즉시 셈 ──
|
|
class DocBody(BaseModel):
|
|
문서: dict[str, Any]
|
|
입력: dict[str, Any] = {}
|
|
|
|
|
|
@router.get("/logic/doc")
|
|
def get_logic_doc(key: str) -> dict:
|
|
"""수식 문서 {문서, 셈, 연결, 파일, 새} — 없으면 빈 문서(쓰기 없음)."""
|
|
return _call(doc_screen.read, key)
|
|
|
|
|
|
@router.post("/logic/doc/preview")
|
|
def post_logic_doc_preview(body: DocBody) -> dict:
|
|
"""미리 셈 {셈, 연결} — 화면 캐시에 없는 연결을 채움 · 쓰기 없음."""
|
|
return _call(doc_screen.preview, body.문서, body.입력)
|
|
|
|
|
|
@router.post("/logic/doc/save")
|
|
def post_logic_doc_save(body: DocBody) -> dict:
|
|
"""저장 — 서버가 정본으로 다시 셈 · 문서 파일에 씀 → {문서, 셈, 연결, 파일}."""
|
|
return _call(doc_screen.save, body.문서)
|
|
|
|
|
|
class RemoveBody(BaseModel):
|
|
key: str
|
|
file: str
|
|
version: str
|
|
개정확인: bool = False
|
|
|
|
|
|
@router.get("/logic/users")
|
|
def get_logic_users(key: str) -> dict:
|
|
"""로직을 부르는 곳(지우기 전 확인 · 읽기만) — {쓰는곳}."""
|
|
return {"쓰는곳": doc_screen.users(key)}
|
|
|
|
|
|
@router.post("/logic/remove")
|
|
def post_logic_remove(body: RemoveBody) -> dict:
|
|
"""로직 지우기(804-7) — 로직 줄 + 수식 문서 · 부르는 곳 있으면 409 {쓰는곳}."""
|
|
return _call(doc_screen.remove, body.key, body.file, body.version, body.개정확인)
|
|
|
|
|
|
@router.post("/logic/doc/links")
|
|
def post_logic_doc_links(body: LinksBody) -> dict:
|
|
"""연결 후보 — 요소 · 표 · 단가산출(수식 문서) 이름 찾기(읽기만)."""
|
|
return _call(doc_screen.candidates, body.model_dump())
|
|
|
|
|
|
@router.get("/logic/doc/table")
|
|
def get_logic_doc_table(key: str) -> dict:
|
|
"""표 하나 — 조건 · 칸 고르기(읽기만)."""
|
|
return _call(doc_screen.table, key)
|
|
|
|
|
|
# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ──
|
|
@router.get("/logic/combos")
|
|
def get_logic_combos(key: str) -> dict:
|
|
"""이 단가산출 로직을 담은 조합 — 로직 쪽에서 거꾸로 봄."""
|
|
return _call(combo_store.combos_of_logic, key)
|