feat(M02): 조합 갈라 내기 길 · 자재 조합(UM) 표 · 본뜬키 칸 · 분류 더하기 열 복사

- POST /api/m02/structures/{열}/combo — 고친 담은 로직으로 새 UA(같은 로직 집합이면 있는 키) · 집계표 그 열만 새 키 · 판 어긋나면 조합 만들기 전 409 · SS 장부 맞춤
- 일위대가 조합 줄에 본뜬키 칸(원래 키) · 기존 37 줄 null · M01 고치기에서 안 보내도 유지
- 자재 조합 표 resources/master_data/자재조합.json(키 UM · 대장 등록 · 틀 검사) · POST /structures/{열}/material-combo · GET /material-combos/{키}
- 구조물집계표조합 칸 자재조합 · 분류 더하기로 복사한 열은 저장 때 원본 열의 일위대가 · 자재조합 키와 구조물 도면 · 면적산출 · 재료산출 내용을 새 번호로 받음
- Api_Fetch branchCombo · branchMaterialCombo · fetchMaterialCombo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
This commit is contained in:
2026-09-28 18:56:51 +09:00
co-authored by Claude Opus 5.5
parent 3f39b5b6c3
commit e76e638013
17 changed files with 698 additions and 14 deletions
@@ -30,6 +30,7 @@ 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
import master_material_combo as mmc # noqa: E402
from pum_md_tool import _NUM, md_lines # noqa: E402
BOOKS = (
@@ -249,12 +250,17 @@ def check_combo_form(name: str, data: dict) -> list[str]:
out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(mcb.OWNERS)} 아님")
if not _source_ok(row.get("출처")):
out.append(f"{where} · 출처 모양 「{row.get('출처')}」")
origin = row.get("본뜬키")
if origin is not None and not (mk.KEY.fullmatch(str(origin)) and origin[:2] == mcb.TID):
out.append(f"{where} · 본뜬키 모양 「{origin}」")
return out
def check_form(name: str, data: dict) -> list[str]:
if name == mcb.FILE:
return check_combo_form(name, data)
if name == mmc.FILE:
return mmc.check_form(name, data)
if name in SPECIAL_FILES:
return []
m = _FILE.match(name)
@@ -18,8 +18,8 @@ FILE = "일위대가조합.json"
TID = "UA"
OWNERS = ("현장", "공용")
MAX_이름 = 60 # 조합 이름 최대 글자 수 — 관리자가 짓는 짧은 이름이라 이보다 길면 저장 막음
# 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장)
COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "출처", "소유", "비고")
# 줄 열 한 벌 — 차례까지 이대로(`_틀.md` 10장) · 본뜬키 = 갈라 낸 원래 조합 키(없으면 null)
COLS = ("키", "이름", "구분", "상세구분", "단위", "담은로직", "본뜬키", "출처", "소유", "비고")
ITEM_COLS = ("로직", "메모")
+4 -1
View File
@@ -32,12 +32,13 @@ TABLES = {
("로직", "건설품셈"): "GC",
("로직", "자체"): "GX",
("일위대가조합", None): "UA",
("자재조합", None): "UM",
}
GROUP_OF = {tid: group for (group, _), tid in TABLES.items()}
IDS = tuple(GROUP_OF)
# 줄에 원문번호를 두지 않는 테이블 — 자재품목은 번호가 남의 품번이라 이름 몫을 못 하고,
# 조합은 원문이 없어 번호 자체가 없음(대조는 대장에만)
NO_NUMBER = ("MT", "UA")
NO_NUMBER = ("MT", "UA", "UM")
def table_id(group: str, book: str | None) -> str:
@@ -181,10 +182,12 @@ ROW_ORDER = {
"상세구분",
"단위",
"담은로직",
"본뜬키",
"출처",
"소유",
"비고",
),
"자재조합": ("키", "이름", "담은자재", "본뜬키", "소유", "비고"),
}
OLD_CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$")
CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈|자체)_(\d\d)장_.+\.json$")
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""자재 조합 — 구조물 열 재료산출이 쓰는 자재 목록 묶음(키 UM · `_틀.md` 11장).
담은 자재 목록만 지님(수량 · 계산은 재료산출 몫). 일위대가 조합(`master_combo`)과 같은 결 —
갈라 내기(M02) 때 새 줄 · 본뜬키 = 원래 키.
"""
from __future__ import annotations
import master_combo as mcb
import master_keys as mk
GROUP = "자재조합"
FILE = "자재조합.json"
TID = "UM"
OWNERS = mcb.OWNERS
MAX_이름 = mcb.MAX_이름
COLS = mk.ROW_ORDER[GROUP] # 키 · 이름 · 담은자재 · 본뜬키 · 소유 · 비고
ITEM_COLS = ("코드", "이름", "규격", "단위")
def items(row: dict) -> list[dict]:
got = row.get("담은자재")
return got if isinstance(got, list) else []
def check_form(name: str, data: dict) -> list[str]:
"""파일 틀 + 줄 — 키 모양 · 열 한 벌 · 이름 · 소유 · 담은 자재(빈 목록 · 모양 · 같은 코드 두 번)."""
out = [] if data.get("그룹") == GROUP else [f"{name} · 머리 그룹이 「{GROUP}」 아님"]
rows = data.get("줄")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「줄」 목록 없음"]
seen_names: dict[str, str] = {}
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] != TID:
out.append(f"{where} · 키 모양이 「{TID}」 + 6자리 아님")
if tuple(row) != COLS:
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(COLS)}」")
plain = row.get("이름").strip() if isinstance(row.get("이름"), str) else ""
if not plain or len(plain) > MAX_이름:
out.append(f"{where} · 「이름」 이 없거나 {MAX_이름}자 넘음")
elif plain in seen_names:
out.append(f"{where} · 「이름」 이 같은 조합과 겹침 — {seen_names[plain]}")
seen_names.setdefault(plain, key)
if row.get("소유") not in OWNERS:
out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(OWNERS)} 아님")
origin = row.get("본뜬키")
if origin is not None and not (mk.KEY.fullmatch(str(origin)) and origin[:2] == TID):
out.append(f"{where} · 본뜬키 모양 「{origin}」")
got = items(row)
if not got:
out.append(f"{where} · 빈 조합 — 담은 자재가 없음")
codes: set[str] = set()
for at, one in enumerate(got, start=1):
if not isinstance(one, dict) or set(one) - set(ITEM_COLS):
out.append(f"{where} 줄{at} · 담은 자재 줄 모양 「{one}」")
continue
code = str(one.get("코드") or "")
if not mk.KEY.fullmatch(code):
out.append(f"{where} 줄{at} · 자재 코드 모양 아님 「{code}」")
elif code in codes:
out.append(f"{where} 줄{at} · 같은 자재를 두 번 담음 「{code}」")
codes.add(code)
return out