Files
Aislo/M01_MasterData/M01_MasterData_Store.py
T
eomsangdonandClaude Sonnet 5 98fe87402f feat(M01): 로직 목록 원문번호 찾기 · 로직 목록 차례순
- 로직 목록 찾기가 원문번호도 봄(키가 번호꼴이 아니라서)
- 서버 로직 목록을 파일 머리 차례순으로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
2026-09-20 02:29:38 +09:00

517 lines
20 KiB
Python

"""M01 마스터 데이터 파일 저장소 — `resources/master_data/` 첫 층 JSON 읽기·검사·쓰기.
계약: `resources/master_data/_화면_계약.md` · 파일 모양: `_틀.md`
검사·계산 엔진: `scripts/check_master.py`.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import threading
from decimal import Decimal
from pathlib import Path
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_keys as mk # noqa: E402
FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈
_CHAPTER = re.compile(r"_(\d\d)장_(.+)\.json$") # 「_03장_토공사.json」
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
class StoreError(Exception):
def __init__(self, status: int, detail):
super().__init__(detail)
self.status, self.detail = status, detail
# ── 읽기 ──────────────────────────────────────────────────────────────
def _path(file: str) -> Path:
path = FOLDER / file
if "/" in file or "\\" in file or file.startswith("_") or not path.is_file():
raise StoreError(404, f"없는 파일 「{file}」")
return path
def version_of(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()[:16]
def read(file: str) -> tuple[dict, str]:
raw = _path(file).read_bytes()
return json.loads(raw, parse_float=Decimal, parse_int=Decimal), version_of(raw)
def items_key(data: dict) -> str:
return "표" if data.get("그룹") in cm.mf.TABLE_GROUPS else "줄"
def chapter_of(file: str, data: dict | None = None) -> str:
"""「공통 03장 토공사」 · 「13장 구조물」 — 부문은 파일 머리."""
m = _CHAPTER.search(file)
if not m:
return ""
division = (data or {}).get("부문") or ""
return f"{division} {m.group(1)}{m.group(2).replace('_', ' ')}".strip()
def _hit(row: dict, q: str) -> bool:
q = q.lower()
return not q or any(q in str(row.get(k, "")).lower() for k in ("키", "원문번호", "이름"))
def groups() -> list[dict]:
out = {g: {"group": g, "files": 0, "rows": 0} for g in cm.mf.GROUPS}
for data in cm.load(folder=FOLDER).values():
slot = out.get(data.get("그룹"))
if slot:
slot["files"] += 1
slot["rows"] += len(data.get(items_key(data)) or [])
return list(out.values())
def files_of(group: str) -> list[dict]:
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}」")
out = []
for path in [*FOLDER.glob(f"{group}.json"), *FOLDER.glob(f"{group}_*.json")]:
data, version = read(path.name)
out.append(
{
"file": path.name,
"book": data.get("원문"),
"chapter": chapter_of(path.name, data),
"edition": data.get("판"),
"rows": len(data.get(items_key(data)) or []),
"version": version,
"order": data.get("차례") or 0,
}
)
return sorted(out, key=lambda f: (f["order"], f["file"]))
_REF_KEY = re.compile(r"^[A-Z]{2}\d{6}$")
_REF_FILE = {
"LB": "인력.json",
"MT": "재료_시중물가.json",
"MN": "재료_나라장터자재.json",
"MP": "재료_품셈재료.json",
"EQ": "기계.json",
}
def _ref_keys(row: dict) -> list[str]:
"""줄이 가리키는 키 — 준용 · 연결 · 조달 출처(「나라장터:<키>」)."""
put = (
row.get("준용"),
row.get("연결"),
(row.get("값") or {}).get("조달", {}).get("출처")
if isinstance(row.get("값"), dict)
else None,
)
out = [v.removeprefix("나라장터:") for v in put if isinstance(v, str)]
return [k for k in out if _REF_KEY.match(k)]
def refs_of(rows_: list[dict]) -> dict[str, str]:
"""참조 칸에 키 옆에 보일 이름 — {키: 「이름 규격」}."""
want = {k for r in rows_ for k in _ref_keys(r)}
out: dict[str, str] = {}
for file in {_REF_FILE[k[:2]] for k in want if k[:2] in _REF_FILE}:
for r in read(file)[0].get("줄") or []:
if r.get("키") in want:
out[r["키"]] = f"{r.get('이름') or ''} {r.get('규격') or ''}".strip()
return out
def rows(file: str, page: int, size: int, q: str, unlinked: bool = False, sub: str = "") -> dict:
data, version = read(file)
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
if sub: # 인력 조사 · 기계 세부분류
hits = [r for r in hits if sub in (r.get("조사"), r.get("세부분류"))]
if unlinked: # 품셈재료 — 아직 못 이은 줄
hits = [r for r in hits if not r.get("연결")]
page, size = max(page, 1), min(max(size, 1), 500)
shown = hits[(page - 1) * size : page * size]
return {
"file": file,
"version": version,
"total": len(hits),
"page": page,
"size": size,
"rows": shown,
"refs": refs_of(shown),
}
def tables(file: str, q: str) -> dict:
data, version = read(file)
if items_key(data) != "표":
raise StoreError(400, f"표형 파일 아님 「{file}」")
heads = ("키", "원문번호", "이름", "기준", "출처", "조건", "값칸")
return {
"file": file,
"version": version,
"tables": [
{**{k: t.get(k) for k in heads}, "count": len(t.get("줄") or [])}
for t in data["표"]
if _hit(t, q)
],
}
def _one(data: dict, key: str) -> dict:
for row in data.get(items_key(data)) or []:
if str(row.get("키")) == key:
return row
raise StoreError(404, f"없는 키 「{key}」")
def table(file: str, key: str) -> dict:
data, version = read(file)
return {"file": file, "version": version, "table": _one(data, key)}
def _logic_files(files: dict[str, dict]):
for name, data in files.items():
if data.get("그룹") == "로직":
yield name, data
def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
files = cm.load(folder=FOLDER)
whole = cm.mf.Master(files)
out = []
for name, data in sorted(_logic_files(files), key=lambda f: f[1].get("차례") or 0):
if (book and data.get("원문") != book) or (chapter and chapter_of(name, data) != chapter):
continue
for row in data.get("줄") or []:
if not _hit(row, q):
continue
reasons = cm.mf.check_logic(whole, row)[0]
if blocked is not None and bool(reasons) != bool(blocked):
continue
out.append(
{
"file": name,
"book": data.get("원문"),
"chapter": chapter_of(name, data),
**{k: row.get(k) for k in ("키", "원문번호", "이름", "결과단위", "출처")},
"blocked": bool(reasons),
"reasons": reasons,
}
)
return out
def logic(book: str, key: str) -> dict:
"""로직 하나 — 키는 전체에서 하나(`book` 은 화면 호환으로만 받음)."""
files = cm.load(folder=FOLDER)
for name, data in _logic_files(files):
for row in data.get("줄") or []:
if str(row.get("키")) == key:
whole = cm.mf.Master(files)
reasons = cm.mf.check_logic(whole, row)[0]
_, version = read(name)
return {
"file": name,
"version": version,
"logic": row,
"blocked": bool(reasons),
"reasons": reasons,
"prices": _prices(whole, row),
}
raise StoreError(404, f"없는 로직 「{key}」")
def _brief(ref: str, row: dict) -> dict:
return {
"ref": ref,
**{k: row.get(k) for k in ("원문번호", "이름", "규격", "단위", "값", "값칸")},
}
def _prices(whole, row: dict) -> dict:
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 원문번호·하위 로직은 계산 때 정해짐.
품셈재료는 연결을 따라간 낮은 값과 출처 · 자재지역 후보는 계산 때 정해짐(값 None)."""
out = {}
for item in row.get("호표") or []:
ref = str(item.get("요소", ""))
if item.get("종류") != "로직" and "{" not in ref:
try:
out[ref] = _brief(ref, whole.get(ref))
except cm.mf.FormulaError:
out[ref] = None
continue
if ref[:2] == cm.mf.LINKED:
try:
value, source = cm.mf.element(whole, ref, {})
except (cm.mf.FormulaError, KeyError, TypeError):
value, source = None, None
out[ref].update({"값": value, "출처": source})
return out
def elements(group: str, q: str, limit: int) -> dict:
"""요소 찾기 창 — 그룹 전체에서 키·원문번호·이름 찾기 · `ref` 는 식에 그대로 넣는 키."""
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}」")
hits = []
for name, data in cm.load(folder=FOLDER).items():
if data.get("그룹") != group:
continue
for row in data.get(items_key(data)) or []:
if _hit(row, q):
hits.append({**_brief(str(row.get("키")), row), "file": name})
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
_PICK = {
"procure": "재료_나라장터자재.json",
"price": "재료_시중물가.json",
"job": "인력.json",
}
def _squash(text) -> str:
return "".join(str(text or "").lower().split())
def _lowest(value) -> object:
"""시중물가 줄 — 시중 다섯 칸 · 조달 값 가운데 낮은 값 · 그 밖은 `값` 그대로."""
if not isinstance(value, dict):
return value
nums = [v for v in value["시중"].values() if isinstance(v, (int, float))]
supply = value["조달"].get("값")
return min(nums + ([supply] if supply is not None else []), default=None)
def pick(kind: str, q: str, limit: int) -> dict:
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<키>) · `price` 시중물가(관급 조달 줄 포함 · ref = 키)
· `job` 값 있는 공표 노임 직종(준용대상 빼고 모든 조사)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
if kind not in _PICK:
raise StoreError(404, f"없는 고르기 「{kind}」")
words = [_squash(w) for w in q.split()]
hits = []
for row in read(_PICK[kind])[0].get("줄") or []:
if kind == "job" and (row.get("값") is None or row.get("조사") == "준용대상"):
continue
text = _squash(f"{row.get('이름')} {row.get('규격')}")
if all(w in text for w in words):
key = str(row.get("키"))
hits.append(
{
"ref": f"나라장터:{key}" if kind == "procure" else key,
**{k: row.get(k) for k in ("원문번호", "조사", "이름", "규격", "단위")},
"값": _lowest(row.get("값")),
"관급": kind == "price" and row["값"]["조달"].get("값") is not None,
}
)
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
# ── 시험 계산 ──────────────────────────────────────────────────────────
def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함) · 돌려받는 것 = 계산할 키."""
row = _dec(row)
for name, data in _logic_files(files):
items = data.get("줄") or []
for i, old in enumerate(items):
if str(old.get("키")) == key:
items[i] = {**row, "키": key}
return key
if file not in files or files[file].get("그룹") != "로직":
raise StoreError(404, f"없는 로직 파일 「{file}」")
new = mk.table_id("로직", files[file].get("원문")) + "999999" # 아직 번호 안 받은 새 로직
files[file].setdefault("줄", []).append({**row, "키": new})
return new
def calc(book: str, key: str, inputs: dict, row: dict | None = None, file: str | None = None):
given = {k: _dec(v) for k, v in inputs.items()}
files = cm.load(folder=FOLDER)
if row is not None:
key = _swap(files, str(row.get("키") or key), row, file)
try:
result = cm.calc(files, key, given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
if "결과" in result:
return {"ok": True, "result": result["결과"], "middle": result["중간"]}
return {
"ok": True,
"lines": result["줄"],
"sums": {k: result[k] for k in cm.mf.BUNDLE},
"middle": result["중간"],
}
# ── 저장 ──────────────────────────────────────────────────────────────
def _dec(v):
"""화면이 보낸 수 → Decimal(파일을 읽은 모양과 같게)."""
if isinstance(v, bool) or v is None:
return v
if isinstance(v, (int, float)):
return Decimal(str(v))
if isinstance(v, list):
return [_dec(x) for x in v]
if isinstance(v, dict):
return {k: _dec(x) for k, x in v.items()}
return v
def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
items = data.setdefault(items_key(data), [])
tid = mk.table_id(data.get("그룹"), data.get("원문"))
for ch in changes:
op, key, row = ch.get("op"), ch.get("key"), _dec(ch.get("row"))
if op not in ("edit", "add", "delete"):
raise StoreError(400, f"모르는 op 「{op}」")
if op != "add":
at = next((i for i, r in enumerate(items) if str(r.get("키")) == key), None)
if at is None:
raise StoreError(404, f"{file} · 없는 키 「{key}」")
if op != "delete" and not isinstance(row, dict):
raise StoreError(400, f"{file} · row 없음")
if op == "add":
number = str(row.get("원문번호") or "")
rest = {k: v for k, v in row.items() if k not in ("키", "원문번호")}
items.append({"키": mk.issue(book, tid, number, file), "원문번호": number, **rest})
elif op == "edit":
if str(row.get("키")) != key:
raise StoreError(400, f"{file} · 키는 바꾸지 않음 「{key}」")
items[at] = row
else:
del items[at]
def save(batch: list[dict]) -> list[dict]:
with _LOCK:
stale = [p.get("file", "") for p in batch if read(p.get("file", ""))[1] != p.get("version")]
if stale:
raise StoreError(409, {"stale": stale})
before, after = cm.load(folder=FOLDER), cm.load(folder=FOLDER) # after = 고칠 사본
book_path = FOLDER / mk.BOOK.name
book = mk.load_book(book_path)
issued = dict(book["다음"])
names = [part["file"] for part in batch]
for part in batch:
_apply(after[part["file"]], part.get("changes") or [], part["file"], book)
mk.finish(book)
old = set(cm.check_saved(before, names, mk.load_book(book_path)))
new = [x for x in cm.check_saved(after, names, book) if x not in old]
if new:
raise StoreError(422, {"errors": new})
if book["다음"] != issued:
mk.save_book(book, book_path)
out = []
for name in dict.fromkeys(names):
old_text = (FOLDER / name).read_bytes().decode("utf-8")
text = keep_shape(old_text, before[name], after[name]) or dump(after[name])
tmp = FOLDER / f".{name}.tmp"
tmp.write_text(text, encoding="utf-8", newline="\n")
os.replace(tmp, FOLDER / name)
out.append({"file": name, "version": version_of(text.encode("utf-8"))})
return out
# ── 쓰기 모양 ─────────────────────────────────────────────────────────
def _atom(v) -> str:
if isinstance(v, Decimal):
return str(v)
return json.dumps(v, ensure_ascii=False)
def _flat(v, tight: bool = False, inner: bool | None = None) -> str:
"""한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음)."""
inner = tight if inner is None else inner
if isinstance(v, dict):
if not v:
return "{}"
body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items())
return "{" + body + "}" if tight else "{ " + body + " }"
if isinstance(v, list):
return "[" + ", ".join(_flat(x, inner) for x in v) + "]"
return _atom(v)
def _skip(text: str, pos: int) -> int:
while text[pos] in " \t\r\n":
pos += 1
return pos
def keep_shape(old_text: str, before: dict, after: dict) -> str | None:
"""고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈).
머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump)."""
key = items_key(after)
head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731
old_items, new_items = before.get(key) or [], after.get(key) or []
if head(before) != head(after) or not old_items or not new_items:
return None
for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀
spans, pos, dec = [], m.end(), json.JSONDecoder()
try:
while old_text[pos := _skip(old_text, pos)] != "]":
_, end = dec.raw_decode(old_text, pos)
spans.append((pos, end))
pos = _skip(old_text, end)
pos += old_text[pos] == ","
except (IndexError, ValueError):
return None
if len(spans) == len(old_items):
break
else:
return None
nl = "\r\n" if "\r\n" in old_text else "\n"
lead = old_text[m.end() : spans[0][0]]
indent = len(lead) - len(lead.rstrip(" "))
olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)}
shape, parts = old_text[spans[0][0] : spans[0][1]], []
for row in new_items:
was, text = olds.get(str(row.get("키")), (None, None))
shape = text or shape
if was == row:
parts.append(text)
elif "\n" not in shape:
tight = shape.startswith('{"')
parts.append(_flat(row, tight, tight or ': {"' in shape))
else:
parts.append(dump(row, indent, expand=True).replace("\n", nl))
out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :]
same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after
return out if same else None
def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str:
"""UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침)."""
flat = _flat(v)
if (
not isinstance(v, (dict, list))
or not v
or (not expand and indent and len(flat) + indent <= width)
):
return flat + ("" if indent else "\n")
pad = " " * (indent + 2)
if isinstance(v, dict):
body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()]
text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}"
else:
text = (
"[\n"
+ ",\n".join(pad + dump(x, indent + 2, width) for x in v)
+ "\n"
+ " " * indent
+ "]"
)
return text + ("" if indent else "\n")