Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_desktop_1' into sub_laptop_1

This commit is contained in:
2026-09-21 23:51:51 +09:00
12 changed files with 826 additions and 7 deletions
+65
View File
@@ -12,6 +12,7 @@ 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"])
@@ -80,6 +81,32 @@ class FormulaPreview(BaseModel):
별칭: 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] = {} # 변수마다 정한 것 {이름: {무엇, 단위, 비목, 요소·찾기·값}}
@@ -263,3 +290,41 @@ def post_logic_formula_preview(body: FormulaPreview) -> dict:
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)
+1
View File
@@ -19,6 +19,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts"
sys.path.insert(0, str(SCRIPTS))
import check_master as cm # noqa: E402
import master_combo as mcb # noqa: E402,F401 (`_Store_Combo` 가 `store.mcb` 로 씀)
import master_copy as mcp # noqa: E402,F401 (`_Store_Make` 가 `store.mcp` 로 씀)
import master_keys as mk # noqa: E402
import master_text as mtx # noqa: E402
@@ -0,0 +1,198 @@
"""M01 일위대가 조합 저장소 — 목록 · 하나 읽기 · 만들기 · 고치기 · 지우기 · 미리 보기.
계약: `resources/master_data/_화면_계약.md` 9장 · 파일 모양: `_틀.md` 10장
· 엔진: `scripts/master_combo.py`.
조합은 담은 단가산출 로직 목록만 지님 — 수량 · 비율 · 계산은 두지 않음(프로젝트 몫).
자리는 `일위대가조합.json` 한 파일 · 키는 `_키대장.json` 의 `UA` 다음 번호(서버가 줌).
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
cm, mk, mcb = store.cm, store.mk, store.mcb
FILE = mcb.FILE
OWNERS = mcb.OWNERS
def _path() -> Path:
return store.FOLDER / FILE
def _make_file() -> None:
"""조합 파일이 없으면 머리만 두고 만듦."""
path = _path()
if path.is_file():
return
head = {"그룹": mcb.GROUP, "": str(date.today().year), "": []}
path.write_text(store.dump(head), encoding="utf-8", newline="\n")
def _read() -> tuple[dict, str]:
_make_file()
return store.read(FILE)
def _clean(combo: dict, owner: str, key: str | None = None) -> dict:
"""서버가 잡는 칸 — 키 · 소유. 화면이 보낸 키·소유는 안 씀 · 열 차례는 `_틀.md` 10장."""
if not isinstance(combo, dict) or not str(combo.get("이름") or "").strip():
raise store.StoreError(400, "조합 줄에 「이름」 이 없음")
if owner not in OWNERS:
raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}")
rest = {k: v for k, v in combo.items() if k not in ("", "원문번호", "소유")}
one = {**({"": key} if key else {}), "소유": owner, **rest}
one.setdefault("출처", "자체")
for col in mcb.COLS: # 열 한 벌 — 안 준 칸은 null 로 채움
one.setdefault(col, None)
return {k: one[k] for k in mcb.COLS if k in one}
def _find(data: dict, key: str) -> dict:
for row in data.get("") or []:
if str(row.get("")) == key:
return row
raise store.StoreError(404, f"없는 조합 「{key}")
def _write(changes: list[dict], version: str | None = None) -> dict:
"""조합 파일 한 벌 저장 — 검사는 `/save` 와 같음(422 면 아무것도 안 씀)."""
fresh = not _path().is_file()
_make_file()
try:
out = store.save(
[{"file": FILE, "version": version or store.read(FILE)[1], "changes": changes}]
)
except store.StoreError:
if fresh:
_path().unlink(missing_ok=True)
raise
return out[0]
# ── 읽기 ──────────────────────────────────────────────────────────────
def _brief(whole, row: dict) -> list[dict]:
"""담은 로직 줄에 그 로직의 이름·단위를 붙여 보임 — 없는 키는 `ok` false."""
out = []
for at, one in enumerate(mcb.items(row), start=1):
key = str(one.get("로직") or "") if isinstance(one, dict) else ""
head = {"차례": at, "로직": key, "메모": one.get("메모") if isinstance(one, dict) else None}
try:
got = whole.logic(key)
except cm.mf.FormulaError as e:
out.append({**head, "ok": False, "까닭": str(e)})
continue
out.append(
{
**head,
"ok": True,
**{
k: got.get(k)
for k in ("이름", "원문번호", "구분", "상세구분", "결과단위", "출처")
},
}
)
return out
def combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> list[dict]:
"""조합 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름."""
files = cm.load(folder=store.FOLDER)
whole = cm.mf.Master(files)
out = []
for _, row in mcb.rows(files):
if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail):
continue
if owner and row.get("소유") != owner:
continue
if not store._hit(row, q):
continue
problems = mcb.check_row(whole, row)
out.append(
{
"file": FILE,
**{k: row.get(k) for k in mcb.COLS if k != "담은로직"},
"count": len(mcb.items(row)),
"blocked": bool(problems),
"reasons": problems,
}
)
return out
def combo(key: str) -> dict:
"""조합 하나 — 담은 로직에 이름·단위를 붙여 줌."""
data, version = _read()
row = _find(data, key)
whole = cm.mf.Master(cm.load(folder=store.FOLDER))
problems = mcb.check_row(whole, row)
return {
"file": FILE,
"version": version,
"combo": row,
"": _brief(whole, row),
"blocked": bool(problems),
"reasons": problems,
}
def combos_of_logic(key: str) -> dict:
"""「이 로직을 쓰는 조합」 — 로직 화면이 거꾸로 볼 때(소요량 「용도」 와 같은 결)."""
files = cm.load(folder=store.FOLDER)
return {"key": key, "combos": mcb.using(files, key)}
# ── 만들기 · 고치기 · 지우기 ───────────────────────────────────────────
def _keys() -> list[str]:
if not _path().is_file():
return []
return [str(r.get("")) for r in store.read(FILE)[0].get("") or []]
def combo_new(combo_: dict, owner: str = OWNERS[0]) -> dict:
"""새 조합 — 키는 서버가 `UA` 다음 번호로 줌."""
before = set(_keys())
done = _write([{"op": "add", "row": _clean(combo_, owner)}])
key = next((k for k in reversed(_keys()) if k not in before), "")
return {**done, "key": key, "combo": _find(store.read(FILE)[0], key) if key else {}}
def combo_edit(key: str, version: str, combo_: dict, owner: str = "") -> dict:
data, _ = _read()
was = _find(data, key)
if store.read(FILE)[1] != version:
raise store.StoreError(409, {"stale": [FILE]})
fixed = _clean(combo_, owner or str(was.get("소유") or OWNERS[0]), key)
done = _write([{"op": "edit", "key": key, "row": fixed}], version)
return {**done, "combo": _find(store.read(FILE)[0], key)}
def combo_delete(key: str, version: str) -> dict:
data, _ = _read()
_find(data, key)
if store.read(FILE)[1] != version:
raise store.StoreError(409, {"stale": [FILE]})
return _write([{"op": "delete", "key": key}], version)
# ── 미리 보기 ─────────────────────────────────────────────────────────
def combo_preview(combo_: dict | str, inputs: dict | None = None) -> dict:
"""담은 로직마다 그 자리 입력값으로 값을 뽑아 비목별로 갈라 합침 — **저장하지 않음**.
`combo_` = 조합 줄 통째(저장 전 화면 상태) 또는 저장된 조합 키.
`inputs` = {로직 키: {입력 이름: 값}}.
"""
files = cm.load(folder=store.FOLDER)
whole = cm.mf.Master(files)
row = _find(_read()[0], combo_) if isinstance(combo_, str) else store._dec(combo_)
if not isinstance(row, dict):
raise store.StoreError(400, "조합 줄이 묶음 아님")
given = {
str(k): {kk: store._dec(vv) for kk, vv in (v or {}).items()}
for k, v in (inputs or {}).items()
}
got = mcb.run(whole, row, given)
return {"": row.get(""), "이름": row.get("이름"), "단위": row.get("단위"), **got}