- 공용 화면 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>
234 lines
9.6 KiB
Python
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"
|