feat(M01): 일위대가 조합 마스터 — 데이터 · 엔진 · 서버 길(화면은 아직)

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
This commit is contained in:
2026-09-21 23:50:42 +09:00
co-authored by Claude Sonnet 5
parent 9d6e60f08c
commit a4b3e7d77a
11 changed files with 800 additions and 7 deletions
+44 -4
View File
@@ -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():