- 알약 키를 첫 로직 코드에서 「이름|단위」 로 — 로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키 - 조합 지우기 전 집계표 · 구조물집계표조합의 「일위대가」 칸을 훑어 쓰는 열이 있으면 409 + 구조물 이름들 - 시험 셋 더함(키 안 바뀜 · 쓰는 조합 못 지움 · 안 쓰는 조합 지워짐) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp
263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""M01 일위대가 조합 저장소 — 목록 · 하나 읽기 · 만들기 · 고치기 · 지우기 · 미리 보기.
|
|
|
|
계약: `resources/master_data/_화면_계약.md` 9장 · 파일 모양: `_틀.md` 10장
|
|
· 엔진: `scripts/master_combo.py`.
|
|
조합은 담은 단가산출 로직 목록만 지님 — 수량 · 비율 · 계산은 두지 않음(프로젝트 몫).
|
|
자리는 `일위대가조합.json` 한 파일 · 키는 `_키대장.json` 의 `UA` 다음 번호(서버가 줌).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from config import config_system
|
|
from M01_MasterData import M01_MasterData_Store as store
|
|
|
|
cm, mk, mcb = store.cm, store.mk, store.mcb
|
|
|
|
FILE = mcb.FILE
|
|
OWNERS = mcb.OWNERS
|
|
_MT = config_system.PROJECT_ROOT / "resources" / "master_template"
|
|
# 조합을 쓰는 열이 적힌 곳 — 집계표(정본) · 구조물집계표조합(SS 장부) · 시험이 바꿈
|
|
USERS = (_MT / "table" / "구조물집계표.json", _MT / "구조물집계표조합.json")
|
|
|
|
|
|
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):
|
|
raise store.StoreError(400, "조합 줄이 묶음 아님")
|
|
name = combo.get("이름")
|
|
if not isinstance(name, str) or not name.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, whole = store.loaded()
|
|
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 = store.loaded()[1]
|
|
problems = mcb.check_row(whole, row)
|
|
return {
|
|
"file": FILE,
|
|
"version": version,
|
|
"combo": row,
|
|
"줄": _brief(whole, row),
|
|
"blocked": bool(problems),
|
|
"reasons": problems,
|
|
}
|
|
|
|
|
|
_PER = re.compile(r"원\s*/\s*(\d*\.?\d*)\s*([^\s·(]+)")
|
|
_TAIL = re.compile(r"\s*\([^()]*\)\s*$")
|
|
|
|
|
|
def combo_inputs(key: str) -> dict:
|
|
"""조합 알약 — 담은 로직마다 결과단위 분모(원/㎥ → ㎥)가 물량 하나.
|
|
|
|
이름 = 로직 이름 끝 괄호 뗀 것(「(인력)」·「(장비)」 한 알약) · 같은 이름 · 단위면 합침 ·
|
|
키 = 「이름|단위」(로직을 빼고 더하고 차례를 바꿔도 같은 물량이면 같은 키) ·
|
|
`배` = 분모 앞 수(원/10㎡ → 10 · 1 이면 안 줌) · 돈 아닌 로직은 빠짐.
|
|
`조건` 은 자리만(설계 조건 고르기 · 범위는 알약에 안 넣음).
|
|
"""
|
|
row = _find(_read()[0], key)
|
|
whole = store.loaded()[1]
|
|
pills: dict[tuple[str, str], dict] = {}
|
|
for one in mcb.items(row):
|
|
code = str(one.get("로직") or "") if isinstance(one, dict) else ""
|
|
try:
|
|
got = whole.logic(code)
|
|
except cm.mf.FormulaError:
|
|
continue
|
|
per = _PER.findall(str(got.get("결과단위") or ""))
|
|
if not per:
|
|
continue
|
|
# ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름
|
|
unit = " · ".join(dict.fromkeys(u for _, u in per))
|
|
name = _TAIL.sub("", str(got.get("이름") or code)) or code
|
|
pill = pills.setdefault(
|
|
(name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []}
|
|
)
|
|
pill["로직"].append(code)
|
|
times = per[0][0]
|
|
if len(per) == 1 and times and float(times) != 1:
|
|
pill["배"] = float(times) if "." in times else int(times)
|
|
return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []}
|
|
|
|
|
|
def combos_of_logic(key: str) -> dict:
|
|
"""「이 로직을 쓰는 조합」 — 로직 화면이 거꾸로 볼 때(소요량 「용도」 와 같은 결)."""
|
|
files, _ = store.loaded()
|
|
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_users(key: str) -> list[str]:
|
|
"""이 조합을 「일위대가」 로 쓰는 구조물 열 이름들 — 집계표 · SS 장부를 함께 봄."""
|
|
names: dict[str, str] = {}
|
|
for path in USERS:
|
|
if not path.is_file():
|
|
continue
|
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
for col in doc.get("열") or doc.get("줄") or []:
|
|
if col.get("일위대가") != key:
|
|
continue
|
|
head = col.get("머리") or [col.get(k) for k in ("종류", "분류1", "분류2", "분류3")]
|
|
cid = str(col.get("id") or col.get("열"))
|
|
names.setdefault(cid, " ".join(str(h) for h in head if h and str(h).strip()) or cid)
|
|
return list(names.values())
|
|
|
|
|
|
def combo_delete(key: str, version: str) -> dict:
|
|
data, _ = _read()
|
|
_find(data, key)
|
|
if users := combo_users(key):
|
|
msg = f"구조물 {len(users)} 곳이 쓰는 조합이라 못 지움 — {' · '.join(users)}"
|
|
raise store.StoreError(409, {"errors": [msg], "users": users})
|
|
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}
|