Files
eomsangdonandClaude Opus 5.5 54fb55487c feat(802-3): M01 단가산출 기초 수식 문서 화면 — 왼쪽 기호 식 · 오른쪽 값 식 · 빈 문서 시작 · 연결 고르기
- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로)
- 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위
- 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만)
- 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움
- 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식
- 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈
- 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py)
- 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음)
- 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:23:57 +09:00

234 lines
9.6 KiB
Python

# -*- coding: utf-8 -*-
"""수식 문서 연결 풀이 — M01 마스터(PLAN 802-1 · 꼴 `_수식문서_틀.md` 3 장).
`master_doc_calc.calc(doc, Links(...))` 가 부름. 셈 엔진(`master_formula.run`
· `evaluate`)은 안 부름 —
마스터 색인(`Master`) · 요소 값(준용 · 자재 고르기 · 단위 환산 = `master_material`
· `master_pick`)만 씀.
표 찾기(범위 규칙 · 여러 줄 가름)는 여기
· TS 짝 = `common_util/common_util_formula_doc_calc.ts` `findRow`.
수식 문서 저장 자리 = `resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json`
(로직 파일마다 한 벌).
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
import master_doc as md
import master_doc_calc as mc
import master_formula as mf
import master_material as mm
import master_pick as mp
FOLDER = Path(__file__).resolve().parents[1] / "수식문서"
_ARITH_OK = ("num", "bin", "neg")
def _arith(text: str):
"""표 칸의 분수 · 식 글(「1/1.30」) → 수 · 아니면 글 그대로."""
try:
node = md.parse(text)
except md.DocError:
return text
if any(n[0] not in _ARITH_OK for n in md.walk(node)):
return text
def ev(n):
if n[0] == "num":
return n[1]
if n[0] == "neg":
return -ev(n[1])
a, b = ev(n[2]), ev(n[3])
return {"+": a + b, "-": a - b, "*": a * b}.get(n[1]) if n[1] != "/" else a / b
try:
return ev(node)
except ArithmeticError:
return text
def _cell(value, col: str):
if value is None:
raise mc.Gap(f"칸 「{col}」 값 없음 — 관리자가 채울 값")
if isinstance(value, list):
raise mc.Broken(f"칸 「{col}」 값이 범위 — 입력으로 받아야 함")
if isinstance(value, str):
return _arith(value)
return value
# ── 표 찾기 ───────────────────────────────────────────────────────────
def _match(table: dict, row: dict, cond: str, want) -> bool:
if cond not in row: # 빈 조건 칸 = 무엇이든 맞음
return True
have = row[cond]
if table.get("조건", {}).get(cond) != "범위":
return have == want
low, high = have
rule, *more = str(table.get("범위규칙") or "초과·이하").split(" · ")
rule = rule.split(" (")[0]
if rule in ("이상·이하",):
return (low is None or want >= low) and (high is None or want <= high)
if rule in ("초과·이하", "이하"):
return (low is None or want > low) and (high is None or want <= high)
if rule == "이상·미만":
top = max(r[cond][1] for r in table["줄"] if r.get(cond) and r[cond][1] is not None)
closed = any("이하 포함" in x for x in more) and high == top
return (low is None or want >= low) and (
high is None or want < high or (closed and want == high)
)
raise mc.Broken(f"모르는 범위규칙 「{table.get('범위규칙')}」")
def find(table: dict, conds: dict, want: str | None = None) -> dict:
"""조건에 맞는 한 줄 — 「이하」 · 「이상·이하」 는 위 끝이 가장 작은 줄.
여럿이면 찾는 칸 있는 줄."""
for cond in conds:
if cond not in table.get("조건", {}):
raise mc.Broken(f"표 {table.get('키')} 에 없는 조건 「{cond}」")
try:
hits = [r for r in table["줄"] if all(_match(table, r, c, v) for c, v in conds.items())]
except TypeError:
raise mc.Broken(f"표 {table.get('키')} 범위 조건에 수 아닌 값") from None
if len(hits) > 1 and str(table.get("범위규칙") or "").split(" · ")[0] in ("이하", "이상·이하"):
for c in conds:
ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)]
if ups and None not in ups:
hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)]
if len(hits) > 1 and want:
hits = [r for r in hits if want in r]
if len(hits) != 1:
what = " · ".join(f"{c} {mc.plain_text(v)}" for c, v in conds.items())
where = f"표 {table.get('키')}({table.get('원문번호')})"
raise mc.Gap(f"{where}에 {what} 인 줄이 " + ("없음" if not hits else f"{len(hits)}개"))
return hits[0]
# ── 연결 ──────────────────────────────────────────────────────────────
class Links:
"""M01 마스터 연결 — 표 · 요소 · 단가산출 문서(`docs` = {키: 문서} · 없으면 저장 폴더)."""
def __init__(self, master: mf.Master, docs: dict[str, dict] | None = None):
self.master = master
self.docs = load_all() if docs is None else docs
def _get(self, ref: str) -> dict:
try:
return self.master.get(ref)
except mf.FormulaError as e:
raise mc.Broken(str(e)) from None
def table(self, p: dict):
if not p.get("표") or not p.get("칸"): # 고르는 중 — 표지(예외 아님)
raise mc.Broken("표 · 칸 고르지 않음")
table = self._get(p["표"])
if "줄" not in table or "조건" not in table:
raise mc.Broken(f"「{p['표']}」 표 아님")
row = find(table, p.get("조건") or {}, p.get("칸"))
if p["칸"] not in row: # 원문 「-」 = 그 줄에 값 칸 없음
if p["칸"] not in (table.get("값칸") or {}) and not any(
p["칸"] in r for r in table["줄"]
):
raise mc.Broken(f"표 {table.get('키')} 에 칸 「{p['칸']}」 없음")
raise mc.Gap(f"표 {table.get('키')} 고른 줄에 「{p['칸']}」 값 없음")
return _cell(row[p["칸"]], p["칸"])
def element(self, p: dict):
if not p.get("키") and p.get("재료") is None: # 고르는 중 — 표지(예외 아님)
raise mc.Broken("요소 고르지 않음")
env = {}
if p.get("지역") is not None:
env[mm.REGION] = p["지역"]
if p.get("계약종별") is not None:
env[mm.TARIFF] = p["계약종별"]
if p.get("재료") is not None or p.get(
"단위"
): # 금액 줄 단가 — 고르기 · 단위 환산 · 값 없음 = 빈 줄
ref = p.get("재료") if p.get("재료") is not None else p["키"]
price, _, why, _ = mp.priced(self.master, {"단위": p.get("단위")}, ref, env, None)
if price is None:
raise mc.Gap(why or "단가 없음")
if isinstance(price, (dict, list)):
raise mc.Broken("단가가 묶음 · 범위")
return price
self._get(p["키"])
try:
value, _ = mm.element(self.master, p["키"], env)
except mf.FormulaError as e:
raise mc.Broken(str(e)) from None
col = p.get("칸")
if col:
if not isinstance(value, dict):
raise mc.Broken(f"「{p['키']}」 값에 칸 없음")
if col not in value:
raise mc.Broken(f"「{p['키']}」 칸 「{col}」 없음")
return _cell(value[col], col)
if isinstance(value, dict):
raise mc.Broken(f"「{p['키']}」 값이 묶음 — 칸 필요")
return _cell(value, self.master.label(p["키"]))
def doc(self, key: str) -> dict | None:
if key in self.docs:
return self.docs[key]
try:
row = self.master.get(key)
except mf.FormulaError:
return None
return self.docs.get(str(row.get("키")))
# ── 저장 ──────────────────────────────────────────────────────────────
def file_of(logic_file: str) -> str:
"""옛 로직 파일 이름 → 수식 문서 파일 이름(`로직_X.json` → `수식_X.json`)."""
return "수식_" + logic_file.split("_", 1)[1]
def load_file(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal)
def load_all(folder: Path = FOLDER) -> dict[str, dict]:
"""저장 폴더 문서 전부 — {키: 문서}."""
out: dict[str, dict] = {}
for path in sorted(folder.glob("수식_*.json")):
for doc in load_file(path).get("줄") or []:
out[str(doc.get("키"))] = doc
return out
def _norm(v):
if isinstance(v, Decimal):
if v == v.to_integral_value() and "E" not in str(v) and "." not in str(v):
return int(v)
return float(v)
if isinstance(v, dict):
return {k: _norm(x) for k, x in v.items()}
if isinstance(v, list):
return [_norm(x) for x in v]
return v
def _enc(v, depth: int, pad: str) -> str:
"""깊이 4(변수 · 식 · 금액 줄 하나)부터 한 줄."""
one = json.dumps(v, ensure_ascii=False, separators=(", ", ": "))
if depth >= 4 or not isinstance(v, (dict, list)) or not v:
return one
inner, cut = pad + " ", ",\n"
if isinstance(v, list):
body = cut.join(inner + _enc(x, depth + 1, inner) for x in v)
return "[\n" + body + "\n" + pad + "]"
body = cut.join(
inner + json.dumps(k, ensure_ascii=False) + ": " + _enc(x, depth + 1, inner)
for k, x in v.items()
)
return "{\n" + body + "\n" + pad + "}"
def dump(data) -> str:
"""저장 글 — UTF-8 · LF · 들여쓰기 2칸 · 변수 · 식 · 금액 줄 하나 = 한 줄.
수는 군더더기 없이."""
return _enc(_norm(data), 0, "") + "\n"