diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index d894891c..2c212d81 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -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) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 752cb45d..58fddb8a 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -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 diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py new file mode 100644 index 00000000..52656739 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -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} diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index 0e5dcecc..366c2010 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -255,4 +255,39 @@ ## 9. 검사 -- 고친 뒤 `scripts/check_master.py` — 틀 · 본문 대조 · 로직 검사 · 키(모양 · 겹침 · 인력·재료의 원문번호 겹침 · 대장 · 끊긴 키). 로직은 `계산` 으로 손 계산과 맞댐. +- 고친 뒤 `scripts/check_master.py` — 틀 · 본문 대조 · 로직 검사 · 조합 검사 · 키(모양 · 겹침 · 인력·재료의 원문번호 겹침 · 대장 · 끊긴 키). 로직은 `계산` 으로 손 계산과 맞댐. + +## 10. 일위대가 조합 파일 + +`일위대가조합.json` 하나 — 관리자가 이름을 붙이고 **단가산출 로직을 골라 담은 묶음**. 원문 근거가 없어 원문·장으로 나누지 않음(머리에 `원문` 을 두지 않음). + +```json +{ + "그룹": "일위대가조합", + "판": "2026", + "줄": [ + { + "키": "UA000001", + "이름": "돌쌓기 한 벌", + "구분": "자체", + "상세구분": "13장 구조물", + "단위": "㎡", + "담은로직": [ + { "로직": "GF000219", "메모": "인력 메쌓기" }, + { "로직": "GF000220", "메모": null } + ], + "출처": "자체", + "소유": "현장", + "비고": null + } + ] +} +``` + +- 열 한 벌 — `키` · `이름` · `구분` · `상세구분` · `단위` · `담은로직` · `출처` · `소유` · `비고`(차례까지 이대로 · 안 채운 칸은 `null`). +- **담은 로직 목록만 둠** — 수량 · 비율 · 계산 칸을 두지 않음. 로직마다 하는 일이 달라 수량은 로직마다 따로 들어가고, 그 대입과 합계는 프로젝트 몫(B08 · B09). +- `담은로직` 줄 = `{로직, 메모}` 뿐 · 목록 차례가 곧 조합 안 차례. +- `키` = `UA` + 6자리 — 대장 `_키대장.json` 의 `UA` 다음 번호(서버가 줌). 원문이 없어 **줄에 `원문번호` 를 두지 않음**(대장 열쇠는 조합 이름). +- `소유` = `현장`(기본) · `공용` · `출처` = 「자체」. +- 검사(`check_master.py 조합` · 엔진 `scripts/master_combo.py`) — 없는 로직 키 · 단가산출 로직이 아닌 키 · 같은 로직 두 번 · 빈 조합 · **조합이 조합을 담음**(1단만). +- 「이 로직을 쓰는 조합」 은 로직 줄에 칸으로 박지 않고 물을 때 훑어 찾음(`master_combo.using`) — 조합은 관리자가 그때그때 만드는 것이라 박아 두면 낡음. 소요량 「용도」 와 결은 같되 자리가 다름. diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 6ed8ec27..1c40e216 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -163,3 +163,32 @@ - `save` = true 면 `POST /logic/new` 와 같은 자리에 저장(키 `GX` 는 서버 · 소유 `owner`) → 답에 `{file, version, key, logic}` 이 더 옴. 문제가 하나라도 있으면 422 `{detail: {errors, 문제}}` 로 아무것도 안 씀. - 옮기는 법 — 돈 로직(`결과단위` 가 「원」 으로 시작)은 결과 식을 `+` 로 갈라 항마다: 마스터요소 변수 하나면 **호표 줄**(나머지 곱이 `수량`) · 비목만 정한 변수면 **덧줄** · 그 밖은 문제 `항`. 나머지 정의 줄은 `중간`(쓰는 차례대로). 돈 아닌 로직은 호표 없이 `중간` + `결과` 식. - 표 찾기는 글자로 치지 않음 — 변수 하나로 두고 `무엇` 을 `표찾기` 로 정할 때 모달이 `찾기` 묶음을 채움. + +## 9. 일위대가 조합 + +관리자가 이름을 붙이고 **단가산출 로직을 골라 담은 묶음**. 파일 모양은 `_틀.md` 10장 · 엔진은 `scripts/master_combo.py`. + +- 자리 = `일위대가조합.json` 한 파일 · 없으면 서버가 머리(`{"그룹": "일위대가조합", "판": <올해>, "줄": []}`)와 함께 만듦. +- 키 = 서버가 `_키대장.json` 에서 `UA` 다음 번호로 줌 — 화면은 키를 만들지 않음. +- 조합은 **담은 로직 목록만** 지님 — 수량 · 비율 · 계산은 두지 않음(프로젝트 몫 · B08 · B09). +- 조합은 **요소 그룹이 아님** — `GET /groups` 목록에 끼지 않고 `/rows`·`/groups/{group}/files` 로 다루지 않음(줄 모양이 요소와 아주 다름). 아래 제 길로만. + +| 길 | 받음 | 줌 | +| --------------------- | -------------------------------- | -------------------------------------------------------- | +| `GET /combos` | `sub?` `detail?` `q?` `owner?` | `{combos: […]}` — 줄마다 `count` · `blocked` · `reasons` | +| `GET /combo` | `key` | `{file, version, combo, 줄: […], blocked, reasons}` | +| `GET /logic/combos` | `key` | `{key, combos: […]}` — 이 로직을 담은 조합 | +| `POST /combo/new` | `{combo, owner?}` | `{file, version, key, combo}` | +| `POST /combo/edit` | `{key, version, combo, owner?}` | `{file, version, combo}` | +| `POST /combo/delete` | `{key, version}` | `{file, version}` | +| `POST /combo/preview` | `{combo, inputs}` | `{키, 이름, 단위, 줄: […], 노무비, 재료비, 경비, 계, 멈춤}` | + +- `combo` = 조합 한 줄 통째(`_틀.md` 10장) · `키`·`소유` 는 보내도 서버 것이 이김 · 안 준 칸은 `null` 로 채워 열 한 벌을 맞춤. +- `version` = `GET /combo` 가 준 그 파일 판본 — 다르면 409 `{detail: {stale: ["일위대가조합.json"]}}`. +- `GET /combo` 의 `줄` = 담은 로직에 이름을 붙인 목록 `{차례, 로직, 메모, ok, 이름, 원문번호, 구분, 상세구분, 결과단위, 출처}` — 없는 키는 `ok` false 와 `까닭`. +- `GET /logic/combos` 의 `combos` 줄 = `{키, 이름, 구분, 상세구분, 단위, 소유, 차례}` — `차례` 는 그 조합 안 몇 번째인지(여러 번이면 여럿). 로직 줄에 칸으로 박지 않고 물을 때 훑음. +- `preview` 는 **보기만** — 마스터에 아무것도 쓰지 않음. `combo` 는 저장된 키(글자)여도 되고 저장 전 조합 줄(묶음)이어도 됨. + - `inputs` = `{로직 키: {입력 이름: 값}}` — 담은 로직마다 그 자리에서 따로 받음(로직마다 하는 일이 달라 수량이 따로 들어감). + - `줄` = `{차례, 로직, 메모, 이름, 원문번호, 결과단위, 구분, 상세구분, 출처, ok, 갈래, 노무비, 재료비, 경비, 계}` · 돈이 아닌 로직은 `갈래` 「결과」 와 `결과` 수 하나(비목이 없어 합계에 안 얹음). + - 못 도는 줄은 통째로 멈추지 않고 그 줄만 `ok` false · `까닭` · `멈춤` 에 한 줄 — 나머지 줄의 합계는 그대로 냄. +- 답 — 200 · 400(틀린 몸 · 이름 없음 · 모르는 소유) · 404(없는 키) · 409(낡은 판본) · 422 `{detail: {errors: […]}}`(검사 걸림 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음). diff --git a/resources/master_data/ref/_검증_이름식.md b/resources/master_data/ref/_검증_이름식.md new file mode 100644 index 00000000..7158999b --- /dev/null +++ b/resources/master_data/ref/_검증_이름식.md @@ -0,0 +1,26 @@ +# 이름 식 · 값 식 짝 검증 — 어긋난 것 (2026-09-21) + +`_화면_계약.md` 3장 `/calc`·`/text` — 서버 길만 부름(FastAPI TestClient, M01 라우터를 그대로 얹어 씀 · 화면·마스터 파일은 안 만짐 · 임시 폴더 사본이라 데이터도 안 고침). + +로직 27개(표 찾기 많은 것 · 로직 부르기 · 덧줄 · 만약() 있는 것 섞어 뽑음) · 시험입력은 `check_master.py` 의 `시험입력()` 규칙(고르기 첫 값 · 범위 위끝 · 줄의 시험입력 이김) 그대로 씀. + +## 어긋난 것 — ③ 이름 식 변수가 실제 별칭과 다름(엉뚱한 이름 짓기) 2건 + +찾기() 로 부른 별칭을 덧줄 비율식(`… * 찾기(…).칸 / 100`)에 쓴 줄에서, **이름 식이 별칭 목록의 실제 「이름」(QC…) 대신 밑이름을 풀어 이어붙인 다른 글자를 씀** — 화면이 짝을 눌러 서로 밝히는 기능(3장 「짝」)이 이 자리에서는 안 걸림. + +| 로직 키 | 파일 | 짝 | 이름 식 | 문제 | +|---|---|---|---|---| +| GC001008 | 로직_건설품셈_01장_공통.json | 4 | `(특별인부 * 특별인부노임 + 보통인부 * 보통인부노임 + 그물막인부 * 보통인부노임) * 잔디깎기공구손료경장비기계경비요율단가.요율 % = 공구손료및경장비` | 원본 식 `찾기(QC001882, 기계구분=…).요율` — 별칭 목록엔 이름 `QC001882`(밑이름 「잔디깎기 공구손료·경장비 기계경비 요율」) 뿐, `잔디깎기공구손료경장비기계경비요율단가` 라는 이름은 별칭 목록에 없음 | +| GC001017 | 로직_건설품셈_01장_공통.json | 11 | `(벌목부 * 벌목부노임 + 특별인부 * 특별인부노임 + 보통인부 * 보통인부노임) * 가로수제거공구손료경장비기계경비요율단가.요율 % = 공구손료및경장비` | 같은 꼴 — 별칭 목록의 실제 이름과 다른 밑이름 풀어쓴 글자를 씀 | + +값 식(글) 은 두 줄 다 `찾기(QC0018xx, …).요율 / 100` 을 그대로 셈해 `/calc` 계와 어긋남 없음(④ 는 이 두 줄 포함 27개 전부 통과) — **값은 맞고 이름 식 라벨만 실제 별칭 이름과 다름**. + +## ①·②·④ — 어긋난 것 없음 + +- ① 줄 수·차례 짝 — 27개 전부 `짝` 이 1부터 끊김 없이 오르고 글·이름글 둘 다 있거나 둘 다 없음(한쪽만 빈 줄 없음). +- ② 이름 식에 함수 글자(`찾기(`·`로직(`·`만약(`)·요소 키(`LB…`·`QF…`·`MP…`·`MT…`·`GC…`·`GF…`) 새 나감 — 27개 전부 없음. +- ④ `/text` 의 「계」 와 `/calc` 의 `sums.계` — 27개 전부 같음(오차 0.01 이내 포함해도 없음). + +## 뺀 것 — 검증 도구 자체의 오탐(제품 결함 아님) + +GC000996 짝=6·7 — 별칭 값칸 이름이 「그라우팅 믹서(190×2ℓ)」처럼 **띄어쓰기가 든 값칸 키**라, 이번 검증 스크립트가 공백으로 토큰을 가르다 오탐(값칸 이름 자체는 별칭 목록의 실제 값칸과 그대로 같음 — 실제 어긋남 아님, 목록에서 뺌). diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index a7dcea23..38fb9805 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- """마스터 데이터 검사 도구 (`resources/master_data/_틀.md`). -돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …] +돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직|조합] [파일 …] ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 GF000123 뒷길이=35 돌=견치돌 … (1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 · 키 모양·겹침 · 대장 (2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음) (3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 재료 고르기 조건 · 돌고 도는 참조 · 끊긴 키 -(4) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`) +(4) 조합 — 일위대가 조합이 담은 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음 (`master_combo.py`) +(5) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`) 수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀. """ @@ -26,6 +27,7 @@ COST = ROOT / "resources/knowledge/original/원가계산" sys.path.insert(0, str(HERE)) sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline")) +import master_combo as mcb # noqa: E402 import master_formula as mf # noqa: E402 import master_keys as mk # noqa: E402 from pum_md_tool import _NUM, md_lines # noqa: E402 @@ -173,9 +175,13 @@ def master(folder: Path = MASTER) -> mf.Master: def check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]: - """저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키. `files` = 폴더 전부(고친 뒤) · `book` = 키 대장.""" + """저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키 + 조합. + + `files` = 폴더 전부(고친 뒤) · `book` = 키 대장. + """ + whole = mf.Master(files) out = [x for name in changed for x in check_form(name, files[name])] - return out + check_logics(files, mf.Master(files), book) + return out + check_logics(files, whole, book) + mcb.check_all(files, whole) def calc(files: dict[str, dict], ref: str, given: dict) -> dict: @@ -215,7 +221,39 @@ def _head(name: str, data: dict, m) -> list[str]: return out +def check_combo_form(name: str, data: dict) -> list[str]: + """조합 파일 틀 — 원문·장이 없는 한 파일(관리자가 만드는 것이라 원문 근거가 없음).""" + out = [] + if data.get("그룹") != mcb.GROUP: + out.append(f"{name} · 머리 그룹이 「{mcb.GROUP}」 아님") + if "판" not in data: + out.append(f"{name} · 머리 「판」 없음") + if "원문" in data: + out.append(f"{name} · 조합은 원문이 없음 — 머리 「원문」 을 두지 않음") + rows_ = data.get("줄") + if not isinstance(rows_, list): + return out + [f"{name} · 머리 「줄」 목록 없음"] + for row in rows_: + key = str(row.get("키")) + where = f"{name} · {key} {row.get('이름') or ''}".strip() + if not mk.KEY.fullmatch(key) or key[:2] != mcb.TID: + out.append(f"{where} · 키 모양이 「{mcb.TID}」 + 6자리 아님") + if "원문번호" in row: + out.append(f"{where} · 원문번호는 줄에 두지 않음 — 대장에만") + if tuple(row) != mcb.COLS: + out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(mcb.COLS)}」") + if not str(row.get("이름") or "").strip(): + out.append(f"{where} · 「이름」 없음") + if row.get("소유") not in mcb.OWNERS: + out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(mcb.OWNERS)} 아님") + if not _source_ok(row.get("출처")): + out.append(f"{where} · 출처 모양 「{row.get('출처')}」") + return out + + def check_form(name: str, data: dict) -> list[str]: + if name == mcb.FILE: + return check_combo_form(name, data) m = _FILE.match(name) if not m: return [ @@ -632,6 +670,8 @@ def main(argv: list[str]) -> int: report["본문"] = check_body(files) if mode in ("로직", "전부"): report["로직"] = check_logics(files, whole) + if mode in ("조합", "전부"): + report["조합"] = mcb.check_all(files, whole) if mode in ("단위", "전부"): report["단위"] = check_units(files, whole) for title, found in report.items(): diff --git a/resources/master_data/scripts/master_combo.py b/resources/master_data/scripts/master_combo.py new file mode 100644 index 00000000..6438448c --- /dev/null +++ b/resources/master_data/scripts/master_combo.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +"""일위대가 조합 — 단가산출 로직을 골라 담은 묶음 (`_틀.md` 10장 · 계약 `_화면_계약.md` 9장). + +조합은 **담은 로직 목록만** 지님 — 수량 · 비율 · 계산은 두지 않음(프로젝트 몫). +미리 보기(`run`)는 담은 로직마다 그 자리에서 받은 입력값으로 값을 뽑아 +비목별로 갈라 합침 — 저장하지 않음. +""" + +from __future__ import annotations + +from decimal import Decimal + +import master_formula as mf +import master_keys as mk + +GROUP = "일위대가조합" +FILE = "일위대가조합.json" +TID = "UA" +OWNERS = ("현장", "공용") +# 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장) +COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "비고") +ITEM_COLS = ("로직", "메모") + + +def rows(files: dict[str, dict]): + """(파일 이름, 조합 줄) — 조합 파일은 하나지만 그룹으로 찾음.""" + for name, data in files.items(): + if data.get("그룹") == GROUP: + for row in data.get("줄") or []: + yield name, row + + +def items(row: dict) -> list[dict]: + """담은 로직 줄 목록 — 차례가 곧 조합 안 차례.""" + got = row.get("담은로직") + return got if isinstance(got, list) else [] + + +def logic_keys(row: dict) -> list[str]: + return [str(one.get("로직") or "") for one in items(row) if isinstance(one, dict)] + + +# ── 검사 ────────────────────────────────────────────────────────────── +def check_row(whole: mf.Master, row: dict) -> list[str]: + """조합 한 줄 — 빈 조합 · 없는 로직 키 · 같은 로직 두 번 · 조합을 담음(1단만).""" + where = f"{row.get('키')} {row.get('이름') or ''}".strip() + got = items(row) + out = [] if got else [f"{where} · 빈 조합 — 담은 로직이 없음"] + if row.get("담은로직") is not None and not isinstance(row.get("담은로직"), list): + out.append(f"{where} · 「담은로직」 이 목록 아님") + seen: set[str] = set() + for at, one in enumerate(got, start=1): + if not isinstance(one, dict): + out.append(f"{where} 줄{at} · 담은 로직 줄이 묶음 아님 「{one}」") + continue + extra = set(one) - set(ITEM_COLS) + if extra: + out.append(f"{where} 줄{at} · 모르는 칸 {sorted(extra)}") + key = str(one.get("로직") or "") + if not mk.KEY.fullmatch(key): + out.append(f"{where} 줄{at} · 로직 키 모양 아님 「{key}」") + continue + if mk.id_of(key) == TID: + out.append(f"{where} 줄{at} · 조합은 조합을 담지 못함 「{key}」 — 단가산출 로직만") + continue + try: + whole.logic(key) + except mf.FormulaError as e: + out.append(f"{where} 줄{at} · {e}") + continue + if key in seen: + out.append(f"{where} 줄{at} · 같은 로직을 두 번 담음 「{key}」") + seen.add(key) + return out + + +def check_all(files: dict[str, dict], whole: mf.Master) -> list[str]: + """조합 전부 — 파일 이름을 앞에 붙임.""" + return [f"{name} · {x}" for name, row in rows(files) for x in check_row(whole, row)] + + +def using(files: dict[str, dict], key: str) -> list[dict]: + """「이 로직을 쓰는 조합」 — 소요량 「용도」 와 결은 같되 자리가 다름. + + 조합은 관리자가 그때그때 만드는 것이라 로직 줄에 칸으로 박지 않고 물을 때 훑어 찾음 + (박아 두면 조합을 고칠 때마다 낡음). + """ + out = [] + for _, row in rows(files): + at = [i for i, k in enumerate(logic_keys(row), start=1) if k == key] + if at: + out.append( + { + **{k: row.get(k) for k in ("키", "이름", "구분", "상세구분", "단위", "소유")}, + "차례": at, + } + ) + return out + + +# ── 미리 보기 ───────────────────────────────────────────────────────── +def run(master: mf.Master, row: dict, given: dict[str, dict] | None = None) -> dict: + """담은 로직마다 그 자리의 입력값으로 값을 뽑아 비목별로 갈라 합침 — 저장하지 않음. + + `given` = {로직 키: {입력 이름: 값}} · 못 도는 줄은 까닭을 적고 합계에서 뺌(통째로 멈추지 않음). + 돈이 아닌 로직(결과 수 하나)은 비목이 없어 합계에 얹지 않고 결과만 보임. + """ + given = given or {} + sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0)) + lines, stuck = [], [] + for at, one in enumerate(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: + target = master.logic(key) + except mf.FormulaError as e: + lines.append({**head, "ok": False, "까닭": str(e)}) + stuck.append(f"{key} · {e}") + continue + head.update( + {k: target.get(k) for k in ("이름", "원문번호", "결과단위", "구분", "상세구분", "출처")} + ) + try: + got = mf.run(master, key, dict(given.get(key) or {})) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + reason = str(e) or type(e).__name__ + lines.append({**head, "ok": False, "까닭": reason}) + stuck.append(f"{key} {target.get('이름')} · {reason}") + continue + if "결과" in got: # 돈 아닌 로직 — 비목이 없어 합계에 안 얹음 + lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]}) + continue + part = {k: got[k] for k in mf.COST_ITEMS} + for k, v in part.items(): + sums[k] += v + lines.append({**head, "ok": True, "갈래": "돈", **part, "계": got["계"]}) + return {"줄": lines, **sums, "계": sum(sums.values()), "멈춤": stuck} diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index fed17bbe..ca735e47 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -13,6 +13,8 @@ from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal import master_keys as mk +# 식이 부르는 요소 그룹 — 일위대가 조합(`master_combo`)은 식에 안 들어와 여기 두지 않음 +# (조합은 `/rows`·`/files` 같은 요소 길이 아니라 제 길 `/combos`·`/combo` 로만 다룸) GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직") TABLE_GROUPS = ("소요량", "계수") TABLE_IDS = tuple(t for t in mk.IDS if mk.GROUP_OF[t] in TABLE_GROUPS) diff --git a/resources/master_data/scripts/master_keys.py b/resources/master_data/scripts/master_keys.py index 42244675..10f08fbf 100644 --- a/resources/master_data/scripts/master_keys.py +++ b/resources/master_data/scripts/master_keys.py @@ -31,11 +31,13 @@ TABLES = { ("로직", "산림품셈"): "GF", ("로직", "건설품셈"): "GC", ("로직", "자체"): "GX", + ("일위대가조합", None): "UA", } GROUP_OF = {tid: group for (group, _), tid in TABLES.items()} IDS = tuple(GROUP_OF) -# 줄에 원문번호를 두지 않는 테이블 — 번호가 남의 품번이라 이름 몫을 못 함(대조는 대장에만) -NO_NUMBER = ("MT",) +# 줄에 원문번호를 두지 않는 테이블 — 자재품목은 번호가 남의 품번이라 이름 몫을 못 하고, +# 조합은 원문이 없어 번호 자체가 없음(대조는 대장에만) +NO_NUMBER = ("MT", "UA") def table_id(group: str, book: str | None) -> str: @@ -172,6 +174,17 @@ ROW_ORDER = { "결과", "끝수", ), + "일위대가조합": ( + "키", + "이름", + "구분", + "상세구분", + "단위", + "담은로직", + "출처", + "소유", + "비고", + ), } OLD_CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$") CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈|자체)_(\d\d)장_.+\.json$") diff --git a/resources/master_data/일위대가조합.json b/resources/master_data/일위대가조합.json new file mode 100644 index 00000000..0e82e802 --- /dev/null +++ b/resources/master_data/일위대가조합.json @@ -0,0 +1,6 @@ +{ + "그룹": "일위대가조합", + "판": "2026", + "비고": ["조합 = 단가산출 로직을 골라 담은 묶음 · 수량·비율·계산은 두지 않음(프로젝트 몫)"], + "줄": [] +} diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py new file mode 100644 index 00000000..27f3568f --- /dev/null +++ b/resources/tester/test_m01_combo.py @@ -0,0 +1,267 @@ +"""M01 일위대가 조합(PLAN 4-2) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계. + +계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`. +못박는 것 + - 조합은 이름과 담은 로직 목록만 지님 — 수량·비율·계산 칸을 두지 않음. + - 미리 보기 합계 = 담은 로직 저마다의 시험 계산을 비목별로 더한 값(저장 없음). + - 검사 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음. + - 견본 조합은 시험에서만 만들고 정본 파일에는 넣지 않음(정본은 빈 채로 둠). +""" + +from __future__ import annotations + +import shutil +import sys +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) + +import master_combo as mcb # noqa: E402 + +REAL = store.FOLDER +STACK = "GF000219" # 13-4-1 돌쌓기 메쌓기(인력) +LAYER = "GF000220" # 13-4-2 돌쌓기 메쌓기(장비) +GATHER = "GF000211" # 13-2-1 모래·자갈·약돌 채집(인력) +SAMPLE = (STACK, LAYER, GATHER) # 견본 조합 — 로직 셋 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _get(client: TestClient, url: str, **params) -> dict: + res = client.get(url, params=params) + assert res.status_code == 200, res.text + return res.json() + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _inputs(key: str) -> dict: + """그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`).""" + row = store.cm.master(REAL).logic(key) + return {k: str(v) for k, v in store.cm.시험입력(row).items()} + + +def _sums(client: TestClient, key: str, inputs: dict) -> dict: + got = _post(client, "/api/m01/calc", {"key": key, "inputs": inputs}) + assert got["ok"] is True, got + return got["sums"] + + +def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: str = "현장") -> dict: + body = { + "이름": 이름, + "구분": "자체", + "상세구분": "13장 구조물", + "단위": "㎡", + "담은로직": [{"로직": k, "메모": None} for k in keys], + } + return _post(client, "/api/m01/combo/new", {"combo": body, "owner": owner}) + + +# ── 정본 파일 ───────────────────────────────────────────────────────── +def test_정본_조합_파일은_빈_채로_있다() -> None: + """줄은 사용자가 화면에서 만듦 — 견본은 시험에서만.""" + data = store.cm.load([mcb.FILE], folder=REAL)[mcb.FILE] + assert data["그룹"] == mcb.GROUP and data["줄"] == [] + assert "원문" not in data # 관리자가 만드는 것이라 원문 근거가 없음 + + +def test_조합_테이블ID_는_UA_이고_줄에_원문번호를_두지_않는다() -> None: + assert store.mk.table_id(mcb.GROUP, None) == "UA" + assert store.mk.GROUP_OF["UA"] == mcb.GROUP + assert "UA" in store.mk.NO_NUMBER + + +def test_조합은_요소_그룹이_아니다(client: TestClient) -> None: + """조합은 제 길(`/combos`·`/combo`)로만 다룸 — 요소 길(`/groups`·`/rows`)에 끼지 않음. + + 끼우면 화면이 조합을 여느 요소 표처럼 `/rows` 로 펴 버림(줄 모양이 아주 다름). + """ + assert mcb.GROUP not in store.cm.mf.GROUPS + groups = [g["group"] for g in _get(client, "/api/m01/groups")["groups"]] + assert groups == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"] + assert client.get("/api/m01/groups/일위대가조합/files").status_code == 404 + + +# ── 만들기 · 읽기 ───────────────────────────────────────────────────── +def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_붙는다(client: TestClient) -> None: + made = _make(client) + key = made["key"] + assert key.startswith("UA"), made + assert made["combo"]["소유"] == "현장" + assert tuple(made["combo"]) == mcb.COLS # 열 한 벌 · 차례까지 + assert "원문번호" not in made["combo"] + + got = _get(client, "/api/m01/combo", key=key) + assert [one["로직"] for one in got["줄"]] == list(SAMPLE) # 담은 차례 그대로 + assert got["줄"][0]["이름"] == "돌쌓기 메쌓기(인력)" + assert got["줄"][0]["결과단위"] == "원/㎡" + assert got["blocked"] is False and got["reasons"] == [] + # 수량·비율·계산은 조합에 두지 않음 + assert set(got["combo"]["담은로직"][0]) == {"로직", "메모"} + + +def test_목록은_구분_소유_찾기로_거른다(client: TestClient) -> None: + _make(client, 이름="돌쌓기 한 벌") + _make(client, keys=(GATHER,), 이름="채집만", owner="공용") + assert len(_get(client, "/api/m01/combos")["combos"]) == 2 + assert [c["이름"] for c in _get(client, "/api/m01/combos", q="채집")["combos"]] == ["채집만"] + only = _get(client, "/api/m01/combos", owner="공용")["combos"] + assert [c["이름"] for c in only] == ["채집만"] and only[0]["count"] == 1 + + +def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> None: + first = _make(client, 이름="돌쌓기 한 벌")["key"] + second = _make(client, keys=(STACK,), 이름="메쌓기만")["key"] + got = _get(client, "/api/m01/logic/combos", key=STACK) + assert [c["키"] for c in got["combos"]] == [first, second] + assert got["combos"][0]["차례"] == [1] # 그 조합 안 몇 번째인지 + assert _get(client, "/api/m01/logic/combos", key="GF000001")["combos"] == [] + + +# ── 고치기 · 지우기 ─────────────────────────────────────────────────── +def test_로직을_빼고_더하고_지울_수_있다(client: TestClient) -> None: + key = _make(client)["key"] + got = _get(client, "/api/m01/combo", key=key) + row = got["combo"] + row["담은로직"] = [{"로직": LAYER, "메모": "장비만 남김"}] + done = _post( + client, "/api/m01/combo/edit", {"key": key, "version": got["version"], "combo": row} + ) + assert [one["로직"] for one in done["combo"]["담은로직"]] == [LAYER] + assert done["combo"]["담은로직"][0]["메모"] == "장비만 남김" + assert done["combo"]["키"] == key # 키는 안 바뀜 + + now = _get(client, "/api/m01/combo", key=key) + _post(client, "/api/m01/combo/delete", {"key": key, "version": now["version"]}) + assert _get(client, "/api/m01/combos")["combos"] == [] + + +def test_낡은_판본으로_고치면_409(client: TestClient) -> None: + key = _make(client)["key"] + row = _get(client, "/api/m01/combo", key=key)["combo"] + got = _post( + client, + "/api/m01/combo/edit", + {"key": key, "version": "낡은판본", "combo": row}, + want=409, + ) + assert got["detail"] == {"stale": [mcb.FILE]} + + +# ── 검사 ────────────────────────────────────────────────────────────── +def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None: + got = _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "엉터리", "담은로직": [{"로직": "GF999999", "메모": None}]}}, + want=422, + ) + assert any("GF999999" in x for x in got["detail"]["errors"]), got + + +def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> None: + got = _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "두 번", "담은로직": [{"로직": STACK}, {"로직": STACK}]}}, + want=422, + ) + assert any("두 번 담음" in x for x in got["detail"]["errors"]), got + + +def test_빈_조합은_막는다(client: TestClient) -> None: + got = _post(client, "/api/m01/combo/new", {"combo": {"이름": "빈 것", "담은로직": []}}, 422) + assert any("빈 조합" in x for x in got["detail"]["errors"]), got + + +def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> None: + key = _make(client)["key"] + got = _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "겹친 것", "담은로직": [{"로직": key}]}}, + want=422, + ) + assert any("조합을 담지 못함" in x for x in got["detail"]["errors"]), got + + +def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> None: + _post(client, "/api/m01/combo/new", {"combo": {"담은로직": [{"로직": STACK}]}}, want=400) + _post( + client, + "/api/m01/combo/new", + {"combo": {"이름": "가", "담은로직": [{"로직": STACK}]}, "owner": "아무개"}, + want=400, + ) + + +# ── 미리 보기 ───────────────────────────────────────────────────────── +def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: + key = _make(client)["key"] + each = {k: _inputs(k) for k in SAMPLE} + want = { + name: sum(Decimal(str(_sums(client, k, each[k])[name])) for k in SAMPLE) + for name in ("노무비", "재료비", "경비", "계") + } + + got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": each}) + assert got["멈춤"] == [], got["멈춤"] + assert [one["로직"] for one in got["줄"]] == list(SAMPLE) + for name in ("노무비", "재료비", "경비", "계"): + assert Decimal(str(got[name])) == want[name], name + assert Decimal(str(got["계"])) > 0 + # 줄마다도 그 로직 하나의 시험 계산과 같음 + for one in got["줄"]: + mine = _sums(client, one["로직"], each[one["로직"]]) + assert Decimal(str(one["계"])) == Decimal(str(mine["계"])) + + +def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: + before = _get(client, "/api/m01/combos")["combos"] + row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} + got = _post(client, "/api/m01/combo/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) + assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 + assert _get(client, "/api/m01/combos")["combos"] == before # 아무것도 안 생김 + + +def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: + key = _make(client, keys=(STACK, LAYER))["key"] + only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 + got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": only}) + stuck = [one for one in got["줄"] if not one["ok"]] + assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] + assert len(got["멈춤"]) == 1 + alone = _sums(client, STACK, only[STACK]) + assert Decimal(str(got["계"])) == Decimal(str(alone["계"])) # 선 줄만 더함 + + +def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: + before = _get(client, "/api/m01/logic", key=STACK) + key = _make(client)["key"] + _post( + client, "/api/m01/combo/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}} + ) + after = _get(client, "/api/m01/logic", key=STACK) + assert after["version"] == before["version"] and after["logic"] == before["logic"]