feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서
- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md - 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts - 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 - M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름 - 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름) - 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
# -*- 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):
|
||||
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):
|
||||
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"
|
||||
Reference in New Issue
Block a user