- 인력_자체: 지적기사·지적산업기사 뺌(호표 72줄 → 건설노임 1072·1073) · 목도공 → 목도(원문 직종명) - 재료_자체: 커터 블레이드 규격 비움 · 아세틸렌가스 → 아세틸렌 · 이형철근 → 철근(원문 이름) - 품명·규격·단위 모두 맞는 5줄(벤토나이트알갱이 · 카트리지 화약 · 전력 · 중유 · 징크프라이머) 재료_자체에서 뺌 · 호표 요소 「재료:시중물가:<코드>.채택」 - 엔진: 호표 요소가 값 묶음의 칸(.채택)이면 그 칸을 단가로 · M01 단가 요약도 같음 - 후보만 있는 99줄 비고에 「나라장터/시중물가 후보 N줄 — 선택 필요」 · 결과 ref/_점검_자체_짝짓기.md - 로직 일괄 계산: 계산됨 1,070 → 1,109 · 자체 값 없음 279 → 240 · 미공표 2 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
412 lines
16 KiB
Python
412 lines
16 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
|
|
|
|
FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈
|
|
_CHAPTER = re.compile(r"_([^_]*\d+장)\.json$") # 「공통3장」 · 「13장」
|
|
# 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) -> str:
|
|
m = _CHAPTER.search(file)
|
|
return m.group(1) if m else ""
|
|
|
|
|
|
def _hit(row: dict, q: str) -> bool:
|
|
q = q.lower()
|
|
return not q or q in str(row.get("열쇠", "")).lower() or q in str(row.get("이름", "")).lower()
|
|
|
|
|
|
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 sorted(FOLDER.glob(f"{group}_*.json")):
|
|
data, version = read(path.name)
|
|
out.append(
|
|
{
|
|
"file": path.name,
|
|
"book": data.get("원문"),
|
|
"chapter": chapter_of(path.name),
|
|
"edition": data.get("판"),
|
|
"rows": len(data.get(items_key(data)) or []),
|
|
"version": version,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def rows(file: str, page: int, size: int, q: str) -> dict:
|
|
data, version = read(file)
|
|
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
|
|
page, size = max(page, 1), min(max(size, 1), 500)
|
|
return {
|
|
"file": file,
|
|
"version": version,
|
|
"total": len(hits),
|
|
"page": page,
|
|
"size": size,
|
|
"rows": hits[(page - 1) * size : page * size],
|
|
}
|
|
|
|
|
|
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 _logic_files(files):
|
|
if (book and data.get("원문") != book) or (chapter and chapter_of(name) != chapter):
|
|
continue
|
|
for row in data.get("줄") or []:
|
|
if not _hit(row, q):
|
|
continue
|
|
reasons = cm.mf.check_logic(whole, data.get("원문"), row)[0]
|
|
if blocked is not None and bool(reasons) != bool(blocked):
|
|
continue
|
|
out.append(
|
|
{
|
|
"file": name,
|
|
"book": data.get("원문"),
|
|
"chapter": chapter_of(name),
|
|
**{k: row.get(k) for k in ("열쇠", "이름", "결과단위", "출처")},
|
|
"blocked": bool(reasons),
|
|
"reasons": reasons,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def logic(book: str, key: str) -> dict:
|
|
files = cm.load(folder=FOLDER)
|
|
for name, data in _logic_files(files):
|
|
if data.get("원문") != book:
|
|
continue
|
|
for row in data.get("줄") or []:
|
|
if str(row.get("열쇠")) == key:
|
|
whole = cm.mf.Master(files)
|
|
reasons = cm.mf.check_logic(whole, book, 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"없는 로직 「{book}:{key}」")
|
|
|
|
|
|
def _brief(ref: str, row: dict) -> dict:
|
|
return {"ref": ref, **{k: row.get(k) for k in ("이름", "규격", "단위", "값", "값칸")}}
|
|
|
|
|
|
def _prices(whole, row: dict) -> dict:
|
|
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐."""
|
|
out = {}
|
|
for item in row.get("호표") or []:
|
|
ref = str(item.get("요소", ""))
|
|
if item.get("종류") != "로직" and "{" not in ref:
|
|
try:
|
|
node = cm.mf.parse(ref)
|
|
if node[0] == "field": # 값 묶음 요소의 칸 — 단가는 그 칸
|
|
row = whole.get(node[1][1])
|
|
out[ref] = {**_brief(ref, row), "값": row["값"][node[2]]}
|
|
else:
|
|
out[ref] = _brief(ref, whole.get(ref))
|
|
except (cm.mf.FormulaError, KeyError, TypeError):
|
|
out[ref] = None
|
|
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):
|
|
ref = f"{group}:{data.get('원문')}:{row.get('열쇠')}"
|
|
hits.append({**_brief(ref, row), "file": name})
|
|
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
|
|
|
|
|
# ── 시험 계산 ──────────────────────────────────────────────────────────
|
|
def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None:
|
|
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함)."""
|
|
row = _dec(row)
|
|
for name, data in _logic_files(files):
|
|
if data.get("원문") == book:
|
|
items = data.get("줄") or []
|
|
for i, old in enumerate(items):
|
|
if str(old.get("열쇠")) == key:
|
|
items[i] = row
|
|
return
|
|
if file not in files or files[file].get("그룹") != "로직":
|
|
raise StoreError(404, f"없는 로직 파일 「{file}」")
|
|
files[file].setdefault("줄", []).append(row)
|
|
|
|
|
|
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:
|
|
_swap(files, book, key, row, file)
|
|
key = str(row.get("열쇠", key))
|
|
try:
|
|
result = cm.calc(files, f"{book}:{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) -> None:
|
|
items = data.setdefault(items_key(data), [])
|
|
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) and "열쇠" in row):
|
|
raise StoreError(400, f"{file} · row 에 열쇠 없음")
|
|
new_key = str(row["열쇠"]) if op != "delete" else None
|
|
taken = {str(r.get("열쇠")) for i, r in enumerate(items) if op == "add" or i != at}
|
|
if new_key in taken:
|
|
raise StoreError(400, f"{file} · 열쇠 겹침 「{new_key}」")
|
|
if op == "add":
|
|
items.append(row)
|
|
elif op == "edit":
|
|
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 = 고칠 사본
|
|
names = [part["file"] for part in batch]
|
|
for part in batch:
|
|
_apply(after[part["file"]], part.get("changes") or [], part["file"])
|
|
old = set(cm.check_saved(before, names))
|
|
new = [x for x in cm.check_saved(after, names) if x not in old]
|
|
if new:
|
|
raise StoreError(422, {"errors": new})
|
|
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) -> str:
|
|
if isinstance(v, dict):
|
|
if not v:
|
|
return "{}"
|
|
body = ", ".join(f"{_atom(k)}: {_flat(x, tight)}" for k, x in v.items())
|
|
return "{" + body + "}" if tight else "{ " + body + " }"
|
|
if isinstance(v, list):
|
|
return "[" + ", ".join(_flat(x, tight) 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:
|
|
parts.append(_flat(row, tight=shape.startswith('{"')))
|
|
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")
|