Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Inputs.py
eomsangdonandClaude Opus 5.5 760e43c8df feat(M02): 항목 키 체계 · 품번 새 모양(PLAN 26-4a)
- 항목 키 = 마스터 SS + 6자리 · 회사 SC{회사}- + 6자리 · 개인 SP{사용자}- + 6자리(장부 key_head · 층 폴더로 가름) · 한 번 주면 안 바뀜 · 지운 키 다시 안 씀 · 층 사이 복사 · 개인에 저장 · 새 항목은 그 층 새 키 + 본뜬 곳
- 회사 · 개인 층의 옛 SS 키는 장부를 맞출 때(처음 읽을 때 한 번) 새 모양으로 옮김(숫자 여섯 그대로 · 겹치면 다음 번호) · 옛 키는 줄 옛키 에 남김 · 옛 키로도 찾음(store.find_combo · 나무 고침 · 입력 목록 · 항목 복사 · M02_Item_Source.row_of)
- summary_item 키 검사 — SC · SP 는 그 층 · 머리 번호 = owner 일 때만 · 옛 SS 는 어느 층이든 받음
- 품번(도번 SD · 면적번 SA · 재료번 SM) 새 일련 = 붙임표 + 6자리(SD-000001) — 옛 8자리(제목행 자리) · 12장 옛 6자리(SD010101)와 모양이 달라 안 겹침 · 옛 번호는 그대로 유효 · 새 모양끼리 셈 · 지운 번호 안 씀
- 시험 test_m02_item_keys 7(층마다 키 모양 · 안 바뀜 · 다시 안 씀 · 복사 본뜬 곳 · 옛 SS 옮김 · 옛 키로 찾음 · 겹친 숫자 · 참조 키 모양 · 120 형제 깊이 6 번호 · 층마다 번호 따로 · 키대장 안 겹침) · 번호 · 키 시험 기대값을 새 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 09:04:39 +09:00

230 lines
9.8 KiB
Python

"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약.
항목 = 구조물집계표조합(SS) 한 줄 → 그 열의 일위대가(UA) 조합 → 담은 단가산출 로직마다 입력 전부.
로직마다 갈라 보이고 이름이 같아도 합치지 않음(로직마다 값이 다를 수 있음) · 입력 목록은 그 로직
줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만 · M01 조합 미리 보기와
같음) · 자동값 = `master_auto.values`(보기용 · 저장 안 함).
항목 연결 파일 = 층 폴더 `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` —
판은 저장마다 1 씩 늘고, 연결만 적음(값은 프로젝트 몫). 자리 면적 · 재료 = 그 시트의 알약
(키 `입력|{로직}|{입력}`) · 선택 = 「선택 입력」 카드 · null = 안 씀.
M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · 권한은 부르는 쪽 몫.
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
PLACES = ("면적", "재료", "선택", None)
PILL_HEAD = "입력"
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
def _num(value: Any) -> Any:
"""Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지."""
if isinstance(value, Decimal):
return int(value) if value == value.to_integral_value() else float(value)
if isinstance(value, list):
return [_num(v) for v in value]
if isinstance(value, dict):
return {k: _num(v) for k, v in value.items()}
return value
def pill_key(logic: str, name: str) -> str:
return f"{PILL_HEAD}|{logic}|{name}"
def _item(key: str) -> dict[str, Any]:
"""지금 층(`store.at`)의 항목 줄 — 없으면 404."""
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
if row is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
return row
def _combo_row(ua: str) -> dict[str, Any] | None:
rows = m01.read(combo_store.FILE)[0].get("줄") or []
return next((r for r in rows if str(r.get("키")) == ua), None)
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
if not ua:
return None, [], []
row = _combo_row(str(ua))
if row is None:
return None, [], []
whole = m01.loaded()[1]
found, missing = [], []
for one in combo_store.mcb.items(row):
code = str(one.get("로직") or "") if isinstance(one, dict) else ""
try:
found.append(whole.logic(code))
except combo_store.cm.mf.FormulaError:
missing.append(code)
return row, found, missing
def inputs(key: str) -> dict[str, Any]:
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
일위대가 없는 항목은 로직 빈 목록."""
ua = _item(key).get("일위대가")
row, logics, missing = combo_logics(ua)
whole = m01.loaded()[1]
out = []
for logic in logics:
auto = m01.mau.values(whole, logic)
specs = []
for spec in logic.get("입력") or []:
one = {k: spec[k] for k in _SPEC if k in spec}
one.setdefault("설명", None)
one.setdefault("단위", None)
specs.append({**one, "자동값": auto.get(spec["이름"])})
out.append(
{
"로직": logic.get("키"),
"이름": logic.get("이름"),
"결과단위": logic.get("결과단위"),
"입력": specs,
}
)
return _num(
{
"일위대가": ua or None,
"이름": row.get("이름") if row else None,
"로직": out,
"빠진로직": missing,
}
)
# ── 항목 연결 파일 ─────────────────────────────────────
def read_file(column: str) -> dict[str, Any] | None:
"""지금 층의 `inputs/<열id>.json` 그대로 — 없으면 None(열 지우기 · 복사 · 스냅샷이 씀)."""
path = store.inputs_path(column)
return json.loads(path.read_bytes()) if path.is_file() else None
def write_file(column: str, doc: dict[str, Any]) -> None:
"""지금 층에 연결 파일을 그대로 씀(복사용 — 검사 · 판 비교 없음)."""
path = store.inputs_path(column)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, doc)
def _known(key: str) -> dict[str, set[str]]:
"""{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것."""
_, logics, _ = combo_logics(_item(key).get("일위대가"))
return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics}
def read_links(key: str) -> dict[str, Any]:
"""항목 연결 — 없으면 `{판: null, 줄: []}` · 조합이 바뀌어 사라진 로직 · 입력 줄은
`사라짐: true`."""
doc = read_file(_item(key)["열"]) or {"판": None, "줄": []}
known = _known(key)
rows = []
for row in doc.get("줄") or []:
gone = str(row.get("입력")) not in known.get(str(row.get("로직")), set())
rows.append({**row, "사라짐": True} if gone else row)
return {"판": doc.get("판"), "줄": rows}
def _clean_rows(key: str, rows: Any) -> list[dict[str, Any]]:
"""없는 로직 · 입력 · 모르는 자리 · 같은 입력 두 번은 422 · 알약 키는 서버가 붙임."""
if not isinstance(rows, list):
raise store.StoreError(422, "「줄」 이 목록 아님")
known = _known(key)
out, seen, errors = [], set(), []
for at, row in enumerate(rows, start=1):
if not isinstance(row, dict):
errors.append(f"줄{at} · 묶음 아님")
continue
logic, name, place = str(row.get("로직") or ""), str(row.get("입력") or ""), row.get("자리")
if logic not in known:
errors.append(f"줄{at} · 이 항목 일위대가에 없는 로직 「{logic}」")
elif name not in known[logic]:
errors.append(f"줄{at} · 로직 {logic} 에 없는 입력 「{name}」")
elif place not in PLACES:
errors.append(f"줄{at} · 모르는 자리 「{place}」 — 면적 · 재료 · 선택 · null")
elif (logic, name) in seen:
errors.append(f"줄{at} · 같은 입력 두 번 「{logic}|{name}」")
else:
seen.add((logic, name))
one: dict[str, Any] = {"로직": logic, "입력": name, "자리": place}
if place in ("면적", "재료"):
one["알약"] = pill_key(logic, name)
out.append(one)
if errors:
raise store.StoreError(422, {"errors": errors})
return out
def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]:
"""항목 연결 저장 — 받은 `판` 이 지금 판(없으면 null)과 다르면 409 · 저장마다 판 + 1."""
column = _item(key)["열"]
clean = _clean_rows(key, rows)
with store._LOCK:
have = (read_file(column) or {}).get("판")
if version != have:
raise store.StoreError(409, {"stale": [f"inputs/{column}"], "판": have})
doc = {"판": int(have or 0) + 1, "줄": clean}
write_file(column, doc)
return doc
# ── 알약 — 면적산출 · 재료산출 시트 ───────────────────────
def pills(key: str, place: str) -> dict[str, Any]:
"""그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_inputs` · 권한 무관 읽기) + 자리를
그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` —
이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침)."""
if place not in ("면적", "재료"):
raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료")
ua = _item(key).get("일위대가")
quantity: list[dict[str, Any]] = []
if place == "면적" and ua and _combo_row(str(ua)):
quantity = combo_store.combo_inputs(str(ua))["물량"]
_, logics, _ = combo_logics(ua)
by_key = {str(lg.get("키")): lg for lg in logics}
linked = []
for row in read_links(key)["줄"]:
if row.get("자리") != place or row.get("사라짐"):
continue
logic = by_key[str(row["로직"])]
spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"])
short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"]
linked.append(
{
"키": pill_key(row["로직"], row["입력"]),
"이름": f"{row['입력']} · {short}",
"단위": spec.get("단위"),
"로직": row["로직"],
"입력": row["입력"],
}
)
return _num({"물량": quantity, "입력": linked})
def copy_between(source: Path, target: Path, source_column: str, target_column: str) -> bool:
"""다른 층(또는 같은 층) 열의 연결 파일을 새 열로 — 판은 1 부터 · 없으면 아무것도 안 함."""
with store.at(source):
doc = read_file(source_column)
if doc is None:
return False
with store.at(target):
write_file(target_column, {"판": 1, "줄": doc.get("줄") or []})
return True