From 771e89c983973efa4e0173dca38986125fc60620 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 11:14:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(796-1):=20=EC=88=98=EC=8B=9D=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=E2=80=94=20=EC=97=94=EC=A7=84=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=E2=86=94=20=EC=88=98=EC=8B=9D=20=EB=82=98=EB=AC=B4=20?= =?UTF-8?q?=EC=96=91=EB=B0=A9=ED=96=A5=20=C2=B7=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=B6=94=EA=B0=80=20=C2=B7=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=C2=B7=20=EC=9D=B4=EB=A6=84=20=EC=A0=84=ED=8C=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B2=80=EC=82=AC=20=C2=B7=20M01=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/master_equation.py: 로직 줄 → 그림용 나무(이름식 · 값식 · 분수 · 퍼센트 · 조건 · 블록) · 풀이 · 변수 표 · 원문 「여기서」 기호 후보 - scripts/master_equation_edit.py: 나무 · 키보드 한 줄 식 → 엔진 식 · 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자(변수표 칸 · 식 그대로) · 고친 중간 줄 표시 4 자리 · 검사 - M01 길 셋(/logic/equation · preview · save — 기존 /save · 개정확인) · M02 사본 이음 export_row · edit_row - _틀.md 로직 열 차례 끝 변수표 · 셈 값 바뀜 0(791-1 지문 같음) - 시험 test_796_1_formula_model.py 17 Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Equation.py | 97 ++++ M01_MasterData/M01_MasterData_Router.py | 27 + resources/master_data/_틀.md | 3 +- .../master_data/scripts/master_equation.py | 469 +++++++++++++++++ .../scripts/master_equation_edit.py | 471 ++++++++++++++++++ resources/tester/test_796_1_formula_model.py | 364 ++++++++++++++ 6 files changed, 1430 insertions(+), 1 deletion(-) create mode 100644 M01_MasterData/M01_MasterData_Equation.py create mode 100644 resources/master_data/scripts/master_equation.py create mode 100644 resources/master_data/scripts/master_equation_edit.py create mode 100644 resources/tester/test_796_1_formula_model.py diff --git a/M01_MasterData/M01_MasterData_Equation.py b/M01_MasterData/M01_MasterData_Equation.py new file mode 100644 index 000000000..fa08f998f --- /dev/null +++ b/M01_MasterData/M01_MasterData_Equation.py @@ -0,0 +1,97 @@ +"""M01 수식 탭 서버 몫(PLAN 796-1 · 요구 655 · 657) — 수식 나무 읽기 · 고침 미리 셈 · 저장. + +모델 = `scripts/master_equation.py`(나무 · 변수 표) · `master_equation_edit.py`(고침 · 검사) · +키보드 글 번역 · 상한 = M02 단가산출 식 칸과 한 벌(`M02_Sangeun_Formula` 불러 씀 · 고치지 않음) · +셈 = 엔진 한 벌(③ 서버 단독 · 브라우저 값 받아 적지 않음) · 저장 = 기존 M01 `/save`(개정확인) · +M02 사본(795-1)은 `export_row` · `edit_row` 를 사본 줄로 부름 · 시험 `test_796_1_formula_model.py`. +""" + +from __future__ import annotations + +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Make as make + +_ERR = (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + +import master_equation as meq # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 +import master_equation_edit as me # noqa: E402 + + +def _fx() -> Any: + from M02_MasterTemplete import M02_Sangeun_Formula as fx # M02 가 M01 을 부름 — 늦게 + + return fx + + +def words(text: str, row: dict) -> tuple[str, list[str]]: + """층 번역 — 상한(길이 · 괄호) · `[노임 …]` · `[별칭]` → 엔진 식.""" + fx = _fx() + if bad := fx.limits(text): + return text, bad + return fx.to_engine(text, row) + + +def _given(inputs: dict | None) -> dict: + return {k: store._dec(v) for k, v in (inputs or {}).items()} + + +def _typed(eq: dict, row: dict) -> dict: + """줄마다 `입력글` = 키보드 한 줄 식(이름 꼴 · 변수 글) — 식 칸 처음 글.""" + fx = _fx() + for line in eq.get("줄") or []: + if line.get("식"): + line["입력글"] = me.keyboard_of(fx.to_name(str(line["식"]), row), row) + return eq + + +def export_row(whole, row: dict, inputs: dict | None = None) -> dict: + """로직 줄(정본 · 사본) → 수식 — 마스터에 그 줄을 얹어 셈.""" + eq = meq.export(me.swap(whole, row), str(row.get("키")), _given(inputs)) + return _typed(eq, row) + + +def edit_row(whole, row: dict, edits: list) -> tuple[dict, list[str]]: + """고침 → (새 줄, 까닭) — 까닭 있으면 저장 안 됨(M01 · M02 사본 같은 함수).""" + new, why, _ = me.apply(whole, row, edits, words) + return new, why + + +def read(key: str, inputs: dict | None = None) -> dict: + whole = store.loaded()[1] + try: + return {"ok": True, "수식": export_row(whole, whole.logic(key), inputs)} + except _ERR as e: + return {"ok": False, "reason": str(e) or type(e).__name__} + + +def preview(key: str, edits: list, inputs: dict | None = None) -> dict: + """고침 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전, 후}.""" + whole = store.loaded()[1] + row = whole.logic(key) + new, why = edit_row(whole, row, edits) + if why: + return {"되나": False, "까닭": why} + try: + before = meq.export(whole, key, _given(inputs))["합계"] + eq = export_row(whole, new, inputs) + except _ERR as e: + return {"되나": False, "까닭": [f"셈 막힘 — {e}"]} + return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]} + + +def save( + key: str, edits: list, inputs: dict | None = None, version: str = "", confirmed: bool = False +) -> dict: + """고침 저장 — 검사 통과 · 기존 M01 `/save`(정본 = 개정확인 · 판 다르면 409).""" + got = store.logic(key) + if version and version != got["version"]: + raise store.StoreError(409, {"stale": [got["file"]]}) + new, why = edit_row(store.loaded()[1], got["logic"], edits) + if why: + raise store.StoreError(400, {"까닭": why}) + change = {"op": "edit", "key": key, "row": new} + part = {"file": got["file"], "version": got["version"], "changes": [change]} + out = make.save([part], confirmed) + return {**out, **read(key, inputs)} diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index ee90bf81d..eeabc1c3e 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -12,6 +12,7 @@ from typing import Any, Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from M01_MasterData import M01_MasterData_Equation as equation from M01_MasterData import M01_MasterData_Sheet as line_sheet from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store @@ -344,6 +345,32 @@ def post_logic_sheet_check(body: SheetCheck) -> dict: return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식) +class EquationBody(BaseModel): + key: str + inputs: dict[str, Any] = {} + 고침: list[dict[str, Any]] = [] + version: str = "" + 개정확인: bool = False + + +@router.post("/logic/equation") +def post_logic_equation(body: EquationBody) -> dict: + """수식 탭(796-1) — 줄마다 이름식 · 값식 나무 · 풀이 · 변수표 · 합계(셈 · 쓰기 없음).""" + return _call(equation.read, body.key, body.inputs) + + +@router.post("/logic/equation/preview") +def post_logic_equation_preview(body: EquationBody) -> dict: + """수식 고침 미리 셈 — {되나, 까닭, 줄, 수식, 전, 후} · 저장 안 함.""" + return _call(equation.preview, body.key, body.고침, body.inputs) + + +@router.post("/logic/equation/save") +def post_logic_equation_save(body: EquationBody) -> dict: + """수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409).""" + return _call(equation.save, body.key, body.고침, body.inputs, body.version, body.개정확인) + + # ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ── @router.get("/logic/combos") def get_logic_combos(key: str) -> dict: diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index e2125f7b8..2989fd33e 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -145,7 +145,7 @@ ``` - 장 파일 줄은 갈래를 스스로 지님 — `구분` = 원문 + 부문(「산림품셈」 · 「건설품셈 공통」 · 「건설품셈 토목」 …) · `상세구분` = 「NN장 장 제목」(파일 이름 그대로 · 「13장 구조물」). 파일 머리의 `부문` · `차례` 는 목록 차례로 남김. -- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 개정 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수 · 단계 · 끝전 · 조정. +- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 개정 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수 · 단계 · 끝전 · 조정 · 변수표. - 갈래 목록은 서버가 장 파일에서 모아 줌(`GET /subs?group=…` · 장 차례대로) — 화면이 이름을 품지 않음. - 건설품셈 부문은 `구분` 칸 — 「건설품셈 건축」 · 원문번호는 절 번호만(「1-1-1」). - 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼). @@ -217,6 +217,7 @@ - `끝전`(로직 칸) = `비목버림`(없음과 같음 · 비목마다 원 버림) | `큰나머지`(계 원 버림 · 모자란 원을 원 미만 큰 비목부터 1 원씩 · 같으면 경 › 재 › 노). - `조정`(로직 칸) = `{배율: {비목: 수}, 옮김: 비목}`(둘 다 골라 씀) — 끝전 뒤 비목 × 배율 → 계 전부를 `옮김` 비목으로(「경비로 적용」) → 원 버림. - `표시`(중간 칸) = 0~6 — 값을 그 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`). +- `변수표`(로직 칸) = 수식 탭 변수 꼴 `{열쇠: {이름?, 기호?, 위?, 아래?}}` — 열쇠 = 입력 · 중간 이름(식이 부름 · 안 바뀜) · 바꾼 것만 · 셈에 안 씀 · 엔진 `scripts/master_equation.py`. - 날 셈(`master_formula.run`)은 번지 · 단계 · 합밖 · 조정 · 표시까지 · 줄 자리 · 끝전은 끝수 셈(`master_cut.engine`). - 계약단가 · 낙찰률 · 운전사 노임 5/24 는 이 의미 밖(B09 · 기계). 재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음: diff --git a/resources/master_data/scripts/master_equation.py b/resources/master_data/scripts/master_equation.py new file mode 100644 index 000000000..b81983c03 --- /dev/null +++ b/resources/master_data/scripts/master_equation.py @@ -0,0 +1,469 @@ +# -*- coding: utf-8 -*- +"""수식 나무(PLAN 796-1 · 요구 655 · 657 · 요구서_655 0 장) — 로직 줄 → 그림용 나무 · 변수 표. + +수식 = 계산 로직 그 자체 — 나무는 엔진 식 나무(`master_formula.parse`)를 그대로 옮긴 것이고 +`master_equation_edit.engine_text` 가 나무를 엔진 식 글로 되돌림(왕복 = 같은 금액). + +줄마다 `이름식`(변수 = 이름 · 기호 · 첨자) · `값식`(같은 나무의 값 꼴) · `값` · 오른쪽 `풀이`(입력 · +찾기 · 요소 값 + 표 · 행) · 하위 로직 부르기 = `블록`(접힘 · 노재경 소계 보이기만 · 금액 그대로). + +나무 마디(`꼴`): + 수 {글, 값} · 글 {글} · 변수 {열쇠, 이름, 기호, 위, 아래, 단위, 갈래, 값, 글, 식?} + (갈래 = 입력 · 중간 · 찾기 · 요소 · 합 — 찾기 · 요소 `식` = 엔진 원문 · 찾기 `표` · `행` · `칸`) + 연산 {기호, 좌, 우} · 분수 {위, 아래, 사슬?} · 괄호 {안} · 퍼센트 {안} · 음 {안} + 조건 {조건, 참, 거짓, 걸림(1 참 · 2 거짓 · 0 모름), 글} · 함수 {이름, 인자} + 블록 {열쇠, 이름, 결과단위, 칸, 값, 묶음, 식, 접힘, 나무} +변수 표 = 입력 · 중간마다 {열쇠, 이름, 기호, 위, 아래, 단위, 갈래, 기본값, 설명} — 열쇠 = 엔진 이름 +(안 바뀜) · 이름 · 기호 · 첨자 고침은 로직 줄 새 칸 `변수표` {열쇠: {이름?, 기호?, 위?, 아래?}} +(바꾼 것만 · 셈에 안 씀) · 기호 초기값 = 품셈 원문 「여기서 q : 버킷용량(㎥)」 후보(`symbols`). +셈 = `master_formula.run` · 끝수 `master_cut.engine` 한 벌 · eval 없음. +""" + +from __future__ import annotations + +import functools +import re +from decimal import Decimal +from pathlib import Path + +import master_auto as ma +import master_cut as mcut +import master_formula as mf + +from common_util.common_util_money import fmt + +FIELD = "변수표" # 로직 줄 새 칸(`_틀.md` 2 장 열 차례 끝) +LOOK = ("이름", "기호", "위", "아래") # 변수표에 두는 꼴 칸 +SHOW = 4 # 값 칸 보이는 자리 — 고친 중간 줄 `표시`(보이는 자리에서 잘라 셈 · 요구 658) +DEEP = 1 # 블록 안 나무를 펼칠 깊이(그 아래는 값 · 묶음만) +PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3} +PREC |= {"+": 4, "-": 4, "*": 5, "/": 5} +SIGN = {"*": "×", "/": "÷", "-": "−"} +_ERR = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError, IndexError) +_BOOK = Path(mf.mk.MASTER).parents[1] / "resources/knowledge/original/원가계산" +_DEF = re.compile(r"^(?:여기서\s*[,:]?\s*)?([A-Za-z][A-Za-z0-9′'ˊ]{0,5})\s*[::]\s*(.+?)\s*$") + + +# ── 변수 표 ──────────────────────────────────────────────────────────── +def _norm(text: str) -> str: + """원문 풀이 · 변수 이름 맞대기 — 괄호 · 빈칸 · 「1일 · 1회」 머리 뺌 · 「횟수」 = 「회수」.""" + text = re.sub(r"\([^)]*\)|([^)]*)|\s", "", str(text or "")) + return re.sub(r"^(1일|1회|일|회당)", "", text).replace("횟수", "회수") + + +@functools.lru_cache(maxsize=256) +def _section(book: str, number: str) -> tuple[tuple[str, str], ...]: + """원문 절의 「여기서 기호 : 풀이」 목록 — 절 머리(`### 8-2-3 …`) 아래 다음 머리까지.""" + root = _BOOK / ("산림_표준품셈/본문" if book == "산림품셈" else "건설공사_표준품셈/본문") + head = re.compile(rf"^#+\s*{re.escape(number)}\s") + out: list[tuple[str, str]] = [] + for path in sorted(root.rglob("*.md")) if number else []: + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + if number not in text: + continue + inside, block = False, False + for line in text.splitlines(): + if line.startswith("#"): + if inside: + break + inside = bool(head.match(line)) + continue + if not inside: + continue + line = re.sub(r"", "", line).strip() + m = _DEF.match(line) if (block or line.startswith("여기서")) else None + block = bool(m) or (block and not line) + if m: + out.append((m.group(1), m.group(2))) + if out: + break + return tuple(out) + + +def symbols(row: dict) -> dict[str, str]: + """{열쇠: 원문 기호 후보} — 이름 · 설명 「(q)」 · 풀이 맞대기가 하나일 때만(796-3 확정 전).""" + book = "산림품셈" if str(row.get("구분") or "").startswith("산림") else "건설품셈" + defs = _section(book, str(row.get("원문번호") or "")) + keys = {str(s.get("이름")) for k in ("입력", "중간") for s in row.get(k) or []} + got: dict[str, list[tuple[str, str]]] = {} # 기호 → [(열쇠, 갈래)] + for kind in ("입력", "중간"): + for spec in row.get(kind) or []: + key = str(spec.get("이름")) + if re.fullmatch(r"[A-Za-z][\w′']{0,5}", key): + continue # 이름이 이미 기호(K · E · f · cm) + said = re.findall( + rf"{re.escape(key)}\(([A-Za-z][\w′']{{0,5}})\)", str(spec.get("설명") or "") + ) + hits = {s for s, d in defs if s in said} or { + s for s, d in defs if _norm(d) == _norm(key) + } + if len(hits) == 1 and (sign := hits.pop()) not in keys: + got.setdefault(sign, []).append((key, kind)) + out = {} + for sign, owners in got.items(): # 한 기호는 한 변수 — 여럿이면 중간 하나일 때만 + mids = [k for k, kind in owners if kind == "중간"] + one = owners[0][0] if len(owners) == 1 else mids[0] if len(mids) == 1 else None + if one: + out[one] = sign + if "결과" in row and defs and defs[0][0] not in keys and defs[0][0] not in out.values(): + out["결과"] = defs[0][0] # 「여기서 Q : 시간당 작업량」 — 첫 풀이 = 결과 + return out + + +def table(row: dict, master=None) -> list[dict]: + """변수 표 — 입력 · 중간 차례 · 기본값 = 입력 `기본` › 자동 제안(`master_auto.defaults`).""" + saved = row.get(FIELD) if isinstance(row.get(FIELD), dict) else {} + auto = ma.defaults(master, row) if master is not None else {} + found = symbols(row) + out = [] + for kind in ("입력", "중간"): + for spec in row.get(kind) or []: + key = str(spec.get("이름")) + look = saved.get(key) if isinstance(saved.get(key), dict) else {} + out.append( + { + "열쇠": key, + "이름": look.get("이름") or key, + "기호": look.get("기호") or found.get(key) or key, + "위": look.get("위") or "", + "아래": look.get("아래") or "", + "단위": spec.get("단위") or "", + "갈래": kind, + "기본값": spec.get("기본", auto.get(key)) if kind == "입력" else None, + "설명": spec.get("설명") or "", + **({"표시": spec["표시"]} if spec.get("표시") is not None else {}), + } + ) + return out + + +def token(var: dict) -> str: + """키보드 한 줄 식의 변수 글 — 기호(_아래)(^위) · 기호가 열쇠와 같고 첨자 없으면 열쇠.""" + out = str(var.get("기호") or var["열쇠"]) + if var.get("아래"): + out += f"_{var['아래']}" + if var.get("위"): + out += f"^{var['위']}" + return out + + +# ── 나무 ────────────────────────────────────────────────────────────── +class _Ctx: + def __init__(self, master, row: dict, env: dict, depth: int): + self.master, self.row, self.env, self.depth = master, row, env, depth + self.vars = {v["열쇠"]: v for v in table(row)} + self.used: dict[str, dict] = {} # 풀이 — 쓰인 입력 · 찾기 · 요소 + + def value(self, node): + try: + got = mf.evaluate(node, self.env, self.master, self.depth) + except _ERR: + return None + return got + + +def _num(value) -> dict: + if isinstance(value, Decimal): + return {"꼴": "수", "글": fmt(value), "값": value} + return {"꼴": "글", "글": "" if value is None else str(value)} + + +def _var(ctx: _Ctx, key: str, kind: str, value, **more) -> dict: + look = ctx.vars.get(key) or {"열쇠": key, "이름": key, "기호": key, "위": "", "아래": ""} + node = { + "꼴": "변수", + "열쇠": key, + **{k: look.get(k, "") for k in LOOK}, + "단위": look.get("단위", more.pop("단위", "")), + "갈래": kind, + "값": value if not isinstance(value, dict) else None, + "글": "" if value is None or isinstance(value, dict) else fmt(value), + **more, + } + if kind in ("입력", "찾기", "요소"): + ctx.used.setdefault(more.get("식") or key, {k: v for k, v in node.items() if k != "꼴"}) + return node + + +def _raw(node) -> str: + """마디 → 엔진 식 글(찾기 · 요소 · 로직 부르기 원문 보관용).""" + import master_equation_edit as me # 서로 부름 — 늦게 + + return me.raw_text(node) + + +def _find(ctx: _Ctx, node, column: str) -> dict: + """찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행.""" + raw = _raw(("field", node, column)) + more = {"식": raw, "칸": column} + try: + ref = mf.fill(node[1], ctx.env) + table_row = ctx.master.get(ref) + conds = {k: mf.evaluate(v, ctx.env, ctx.master, ctx.depth) for k, v in node[2]} + more |= {"표": ref, "표이름": table_row.get("이름") or "", "행": conds} + more["단위"] = (table_row.get("값칸") or {}).get(column) or "" + except _ERR: + pass + name = f"{more.get('표이름') or node[1]}.{column}" + look = {"열쇠": raw, "이름": name, "기호": column, "위": "", "아래": ""} + ctx.vars.setdefault(raw, look) + return _var(ctx, raw, "찾기", ctx.value(("field", node, column)), **more) + + +def _block(ctx: _Ctx | None, node, column: str | None) -> dict: + """로직 부르기 → 블록 — 묶음(노 · 재 · 경 · 계) · 결과 수 · 접힌 하위 나무(`DEEP` 까지).""" + raw = _raw(node if column is None else ("field", node, column)) + out = {"꼴": "블록", "열쇠": node[1], "칸": column, "식": raw, "접힘": True} + if ctx is None: + return out + try: + ref = mf.fill(node[1], ctx.env) + sub = ctx.master.logic(ref) + given = {k: mf.evaluate(v, ctx.env, ctx.master, ctx.depth) for k, v in node[2]} + out |= {"열쇠": ref, "이름": sub.get("이름") or ref, "결과단위": sub.get("결과단위") or ""} + except _ERR: + return out + got = ctx.value(node) + if isinstance(got, dict): + out["묶음"] = got + got = got.get(column) if column else got.get("계") + elif column is not None: + got = None + out |= {"값": got, "글": fmt(got) if isinstance(got, Decimal) else ""} + if ctx.depth < DEEP: + try: + out["나무"] = export(ctx.master, ref, given, ctx.depth + 1) + except _ERR: + out["나무"] = None + return out + + +def _paren(child: dict, op: str, right: bool) -> dict: + """연산 자식 괄호 — 낮은 세기 · 오른쪽 같은 세기(엔진은 왼쪽부터 묶음 — 나무 그대로).""" + if child.get("꼴") != "연산": + return child + mine, up = PREC[child["_op"]], PREC[op] + if mine < up or (mine == up and right): + return {"꼴": "괄호", "안": child} + return child + + +def build(node, ctx: _Ctx | None) -> dict: + """엔진 식 마디 → 나무 마디(이름 꼴) · ctx 없으면 값 · 풀이 없이 꼴만.""" + kind = node[0] + if kind == "num": + return _num(node[1]) + if kind == "str": + return {"꼴": "글", "글": node[1], "따옴": True} + if kind == "name": + key = node[1] + if ctx is None: + return {"꼴": "변수", "열쇠": key, "갈래": "합"} + where = ctx.vars.get(key, {}).get("갈래") or "합" + return _var(ctx, key, where, ctx.env.get(key) if ctx else None) + if kind == "ref": + if ctx is None: + return {"꼴": "변수", "열쇠": node[1], "갈래": "요소", "식": node[1]} + return _elem(ctx, node[1]) + if kind == "neg": + inner = build(node[1], ctx) + return {"꼴": "음", "안": {"꼴": "괄호", "안": inner} if inner["꼴"] == "연산" else inner} + if kind == "field" and node[1][0] == "find": + if ctx is None: + return {"꼴": "변수", "갈래": "찾기", "식": _raw(node), "열쇠": _raw(node)} + return _find(ctx, node[1], node[2]) + if kind == "field" and node[1][0] == "logic": + return _block(ctx, node[1], node[2]) + if kind == "logic": + return _block(ctx, node, None) + if kind in ("field", "find"): # 그 밖의 칸 꺼냄(요소 묶음 · 찾기 줄째) — 원문 그대로 변수 + raw = _raw(node) + if ctx is None: + return {"꼴": "변수", "갈래": "요소", "식": raw, "열쇠": raw} + return _var(ctx, raw, "요소", ctx.value(node), 식=raw) + if kind == "call" and node[1] == "만약": + cond, yes, no = (build(x, ctx) for x in node[2]) + hit = ctx.value(node[2][0]) if ctx else None + at = 0 if hit is None else 1 if hit else 2 + return {"꼴": "조건", "조건": cond, "참": yes, "거짓": no, "걸림": at} + if kind == "call": + return {"꼴": "함수", "이름": node[1], "인자": [build(x, ctx) for x in node[2]]} + if kind == "bin": + return _bin(node, ctx) + raise mf.FormulaError(f"모르는 마디 {kind}") + + +def _elem(ctx: _Ctx, ref: str) -> dict: + import master_text as mt + + value = ctx.value(("ref", ref)) + name = mt._elem_name(ctx.master, ref, ctx.env) + look = {"열쇠": ref, "이름": name, "기호": name, "위": "", "아래": ""} + ctx.vars.setdefault(ref, look) + return _var(ctx, ref, "요소", value, 식=ref) + + +def _bin(node, ctx: _Ctx | None) -> dict: + _, op, left, right = node + if op == "/" and right == ("num", Decimal(100)): # 「… / 100」 = 퍼센트 + inner = _paren(build(left, ctx), "*", False) + return {"꼴": "퍼센트", "안": inner} + if op == "/": + if left[0] == "bin" and left[1] == "/" and left[3] != ("num", Decimal(100)): + top, mid = build(left[2], ctx), build(left[3], ctx) # a/b/c → a/(b×c) 모양만 + low = {"꼴": "연산", "기호": "×", "_op": "*", "좌": mid, "우": build(right, ctx)} + low["좌"], low["우"] = _paren(mid, "*", False), _paren(low["우"], "*", True) + return {"꼴": "분수", "위": top, "아래": low, "사슬": True} + return {"꼴": "분수", "위": build(left, ctx), "아래": build(right, ctx)} + a, b = build(left, ctx), build(right, ctx) + return { + "꼴": "연산", + "기호": SIGN.get(op, op), + "_op": op, + "좌": _paren(a, op, False), + "우": _paren(b, op, True), + } + + +def values(tree: dict) -> dict: + """이름 꼴 나무 → 값 꼴 나무 — 변수 · 블록은 값 수로(열쇠 남김) · 조건은 걸린 쪽만.""" + kind = tree.get("꼴") + if kind in ("변수", "블록"): + value = tree.get("값") + got = _num(value) if value is not None else {"꼴": "글", "글": "?"} + return {**got, "열쇠": tree.get("열쇠"), "갈래": tree.get("갈래") or "블록"} + if kind == "조건": + if tree.get("걸림"): + return values(tree["참" if tree["걸림"] == 1 else "거짓"]) + return {**tree, **{k: values(tree[k]) for k in ("조건", "참", "거짓")}} + out = dict(tree) + for k in ("좌", "우", "위", "아래", "안"): + if k in tree: + out[k] = values(tree[k]) + if "인자" in tree: + out["인자"] = [values(x) for x in tree["인자"]] + return out + + +def _clean(tree): + """밖으로 내보낼 나무 — 안쪽 칸(`_op`) 뺌.""" + if isinstance(tree, dict): + return {k: _clean(v) for k, v in tree.items() if k != "_op"} + if isinstance(tree, list): + return [_clean(x) for x in tree] + return tree + + +# ── 로직 한 줄 → 수식 ───────────────────────────────────────────────── +def given_of(master, row: dict, given: dict | None = None) -> dict: + """셈 입력 — 자동값 · 기본 제안 · 시험입력 · 받은 값 차례로 덮음.""" + out = ma.values(master, row) + out.update({k: v for k, v in ma.defaults(master, row).items() if v is not None}) + out.update(row.get("시험입력") or {}) + out.update(given or {}) + names = {s["이름"] for s in row.get("입력") or []} + return {k: v for k, v in out.items() if k in names} + + +def _line(ctx: _Ctx, kind: str, key: str, text: str, value, **more) -> dict: + try: + tree = build(mf.parse(text), ctx) + except mf.FormulaError as e: + return {"갈래": kind, "열쇠": key, "식": text, "까닭": str(e), **more} + head = _var(ctx, key, kind if kind in ("중간",) else "합", value) + if kind == "결과": + head |= {"이름": "결과", "기호": symbols(ctx.row).get("결과") or "결과"} + return { + "갈래": kind, + "열쇠": key, + "식": text, + "결과": head, + "이름식": tree, + "값식": values(tree), + "값": value, + "글": fmt(value) if isinstance(value, Decimal) else "", + **more, + } + + +def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict: + """로직 키 → 수식 {열쇠, 이름, 결과단위, 줄, 풀이, 변수표, 합계 | 결과} — 셈은 엔진 한 벌.""" + row = master.logic(key) + given = given_of(master, row, given) if depth == 0 else dict(given or {}) + raw = mf.run(master, key, given, depth) + env = {k: v for k, v in (raw.get("중간") or {}).items()} + ctx = _Ctx(master, row, env, depth) + out_lines = [] + for step in row.get("중간") or []: + name = step["이름"] + more = {"표시": step["표시"]} if step.get("표시") is not None else {} + out_lines.append(_line(ctx, "중간", name, step["식"], env.get(name), **more)) + total: dict = {} + if "결과" in row: + out_lines.append(_line(ctx, "결과", "결과", row["결과"]["식"], raw["결과"])) + total = {"결과": raw["결과"]} + else: + cut = mcut.engine(master, key, raw) if depth == 0 else raw + lines = raw.get("줄") or [] + for at, item in enumerate(row.get("호표") or []): + out_lines.append(_item(ctx, at, item, lines[at] if at < len(lines) else {})) + for at, extra in enumerate(row.get("덧줄") or []): + got = env.get(extra["이름"]) + one = _line(ctx, "덧줄", extra["이름"], extra["식"], got, 차례=at) + out_lines.append(one | {"비목": extra.get("비목")}) + total = {k: cut.get(k) for k in mf.BUNDLE} + return _clean( + { + "열쇠": row.get("키"), + "이름": row.get("이름") or "", + "결과단위": row.get("결과단위") or "", + "줄": out_lines, + "풀이": list(ctx.used.values()), + "변수표": table(row, master), + "합계": total, + } + ) + + +def _item(ctx: _Ctx, at: int, item: dict, line: dict) -> dict: + """호표 줄 = 「단가 × 수량식 = 금액」 · 로직 줄 단가 = 블록.""" + name = str(item.get("이름") or "") + try: + qty = build(mf.parse(item["수량"]), ctx) + except mf.FormulaError as e: + return {"갈래": "호표", "차례": at, "열쇠": name, "식": item["수량"], "까닭": str(e)} + if item.get("종류") == "로직": + price = build(mf.parse(str(item["요소"])), ctx) + elif isinstance(item.get("요소"), str): + price = _elem(ctx, item["요소"]) + price |= {"값": line.get("단가"), "글": fmt(line["단가"]) if line.get("단가") else ""} + else: # 재료 고르기 조건 — 단가는 엔진 고름 값 + key, label = f"단가:{name}", f"{name}단가" + ctx.vars.setdefault(key, {"열쇠": key, "이름": label, "기호": label, "위": "", "아래": ""}) + price = _var(ctx, key, "요소", line.get("단가"), 고르기=item["요소"]) + tree = { + "꼴": "연산", + "기호": "×", + "_op": "*", + "좌": price, + "우": _paren(qty, "*", True), + } + head = {"꼴": "변수", "열쇠": name, "이름": name, "기호": name, "갈래": "합"} + head |= {"값": line.get("금액"), "단위": item.get("단위") or ""} + return { + "갈래": "호표", + "차례": at, + "열쇠": name, + "식": item["수량"], + "결과": head, + "이름식": tree, + "값식": values(tree), + "값": line.get("금액"), + "글": fmt(line["금액"]) if isinstance(line.get("금액"), Decimal) else "", + "수량": line.get("수량"), + "비목": line.get("비목") or {}, + **({"까닭": line["까닭"]} if line.get("까닭") else {}), + } diff --git a/resources/master_data/scripts/master_equation_edit.py b/resources/master_data/scripts/master_equation_edit.py new file mode 100644 index 000000000..1c6c97771 --- /dev/null +++ b/resources/master_data/scripts/master_equation_edit.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +"""수식 고침 → 로직(PLAN 796-1 · 요구 657) — 나무 · 키보드 한 줄 식 → 엔진 식 · 변수 추가 · 삭제 · +이름 · 기호 · 첨자 바꿈 · 표시 자리 · 식 검사. + +로직 줄(dict) 한 벌만 고침 — 저장은 부르는 쪽(M01 = `/save` · M02 사본 = 795-1 사본 길)이 함. +고침 요청 `고침` = 목록 · 차례대로: + {할일: 식, 자리: 중간:<열쇠> | 결과 | 호표: | 덧줄:, 식: 글 | 나무: 나무} + {할일: 더함, 갈래: 입력 | 중간, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?, 설명?, 기본값?(입력), + 식(중간), 뒤?(중간 열쇠 · "" = 맨 위 · 없으면 끝)} + {할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록 + {할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐 + {할일: 표시, 열쇠, 표시: 0~6 | null} +고친 중간 줄은 `표시` 없으면 `SHOW`(보이는 자리에서 잘라 셈 · 요구 658) — 고치지 않은 줄은 그대로. +키보드 한 줄 식 = 변수 글(기호_아래^위 · 이름 · 열쇠) · `× ÷ −` · 나머지는 엔진 식 말 그대로 +(`[노임 …]` · `[별칭]` 은 M01 · M02 층이 `M02_Sangeun_Formula.to_engine` 으로 먼저 풂). +""" + +from __future__ import annotations + +import copy +import re +from decimal import Decimal, InvalidOperation + +import master_equation as meq +import master_formula as mf + +KEY_FORM = re.compile(r"^[^\W\d][\w]{0,19}$") # 열쇠 = 글자로 시작 · 20 자까지 +SIGN_FORM = re.compile(r"^[^\W\d_]\w{0,9}$") +SUB_FORM = re.compile(r"^[\w′'*]{1,6}$") +RESERVED = {"찾기", "로직", "그리고", "또는", "계", "줄", "번지", "단계", *mf.FUNCS, *mf.COST_ITEMS} +_TOKEN = re.compile(r"[^\W\d][\w]*(?:\^[^\s()+\-*/×÷−,=<>\[\]]+)?") +_BARE = re.compile(r"^[^\W\d][\w]*$") +_OPS = {"×": "*", "÷": "/", "−": "-"} + + +# ── 엔진 마디 · 나무 → 엔진 식 글 ────────────────────────────────────── +def _q(word: str) -> str: + return word if _BARE.match(word) else f"'{word}'" + + +def raw_text(node) -> str: + """엔진 식 마디 → 엔진 식 글(괄호는 세기대로만).""" + kind = node[0] + if kind == "num": + return mf._plain(node[1]) + if kind == "str": + return f"'{node[1]}'" + if kind in ("name", "ref"): + return node[1] + if kind == "neg": + inner = raw_text(node[1]) + return f"-({inner})" if node[1][0] == "bin" else f"-{inner}" + if kind == "field": + return f"{raw_text(node[1])}.{_q(node[2])}" + if kind in ("find", "logic"): + head = "찾기" if kind == "find" else "로직" + args = "".join(f", {_q(k)}={raw_text(v)}" for k, v in node[2]) + return f"{head}({node[1]}{args})" + if kind == "call": + return f"{node[1]}({', '.join(raw_text(x) for x in node[2])})" + _, op, left, right = node + return ( + f"{_wrap(raw_text(left), left, op, False)} {op} {_wrap(raw_text(right), right, op, True)}" + ) + + +def _wrap(text: str, node, op: str, right: bool) -> str: + if node[0] != "bin": + return text + mine, up = meq.PREC[node[1]], meq.PREC[op] + return f"({text})" if mine < up or (mine == up and right) else text # 왼쪽 묶임 그대로 + + +def engine_text(tree: dict) -> str: + """나무(이름 꼴) → 엔진 식 글 — 변수 = 열쇠 · 찾기 · 요소 · 블록 = 원문 식 · 조건 = 만약 셋.""" + kind = tree.get("꼴") + if kind == "수": + return mf._plain(Decimal(str(tree["값"]))) + if kind == "글": + return f"'{tree['글']}'" if tree.get("따옴") else str(tree["글"]) + if kind == "변수": + return str(tree.get("식") or tree["열쇠"]) + if kind == "블록": + return str(tree["식"]) + if kind == "괄호": + return f"({engine_text(tree['안'])})" + if kind == "음": + return f"-{_side(tree['안'], '*', True)}" + if kind == "퍼센트": + return f"{_side(tree['안'], '/', False)} / 100" + if kind == "분수": + low = tree["아래"] + if tree.get("사슬") and low.get("꼴") == "연산": # a/(b×c) 모양 = 원래 a / b / c + parts = [tree["위"], low["좌"], low["우"]] + first = _side(parts[0], "/", False) + return " / ".join([first, *(_side(p, "/", True) for p in parts[1:])]) + return f"{_side(tree['위'], '/', False)} / {_side(low, '/', True)}" + if kind == "조건": + return f"만약({', '.join(engine_text(tree[k]) for k in ('조건', '참', '거짓'))})" + if kind == "함수": + return f"{tree['이름']}({', '.join(engine_text(x) for x in tree['인자'])})" + if kind == "연산": + op = _OPS.get(tree["기호"], tree["기호"]) + return f"{_side(tree['좌'], op, False)} {op} {_side(tree['우'], op, True)}" + raise mf.FormulaError(f"모르는 나무 마디 「{kind}」") + + +def _strength(tree: dict) -> int | None: + kind = tree.get("꼴") + if kind == "연산": + return meq.PREC[_OPS.get(tree["기호"], tree["기호"])] + return meq.PREC["/"] if kind in ("분수", "퍼센트") else None + + +def _side(tree: dict, op: str, right: bool) -> str: + text, mine = engine_text(tree), _strength(tree) + if mine is None: + return text + up = meq.PREC[op] + return f"({text})" if mine < up or (mine == up and right) else text # 왼쪽 묶임 그대로 + + +# ── 키보드 한 줄 식 ↔ 엔진 식 ───────────────────────────────────────── +def _names(row: dict) -> tuple[dict, dict]: + """(변수 글 → 열쇠, 열쇠 → 변수 글) — 겹치는 글은 뺌(열쇠로 적어야 함).""" + rows = meq.table(row) + want: dict[str, set] = {} + for v in rows: + for word in {v["열쇠"], meq.token(v), v["이름"]}: + want.setdefault(word, set()).add(v["열쇠"]) + to_key = {w: next(iter(ks)) for w, ks in want.items() if len(ks) == 1} + to_word = {v["열쇠"]: meq.token(v) for v in rows if to_key.get(meq.token(v)) == v["열쇠"]} + return to_key, to_word + + +def _swap_words(text: str, table: dict) -> str: + """따옴표 · 대괄호 밖 변수 글만 바꿈 — 찾기 · 로직 인자 이름(「조건=」)은 그대로.""" + out, at = [], 0 + while at < len(text): + ch = text[at] + if ch in "'[": + end = text.find("'" if ch == "'" else "]", at + 1) + end = len(text) if end < 0 else end + 1 + out.append(text[at:end]) + at = end + continue + m = _TOKEN.match(text, at) if not (text[at - 1 : at].isalnum() and at) else None + if not m: + out.append(ch) + at += 1 + continue + word, rest = m.group(), text[m.end() :].lstrip() + arg = rest.startswith("=") and not rest.startswith("==") + out.append(table.get(word, word) if not arg else word) + at = m.end() + return "".join(out) + + +def keyboard(text: str, row: dict) -> str: + """키보드 한 줄 식 → 엔진 식 — 변수 글(첨자 `^'` 포함) → 열쇠 먼저 · 따옴표 밖 `× ÷ −`.""" + bits = _swap_words(str(text), _names(row)[0]).split("'") + return "'".join( + "".join(_OPS.get(c, c) for c in b) if i % 2 == 0 else b for i, b in enumerate(bits) + ) + + +def keyboard_of(text: str, row: dict) -> str: + """엔진 · 이름 꼴 식 → 키보드 한 줄 식(열쇠 → 변수 글).""" + return _swap_words(str(text), _names(row)[1]) + + +# ── 쓰는 곳 ─────────────────────────────────────────────────────────── +def _spots(row: dict): + """(자리 글, 자리 열쇠, 식 글) — 모든 식.""" + for step in row.get("중간") or []: + yield f"중간 {step['이름']}", f"중간:{step['이름']}", step["식"] + if "결과" in row: + yield "결과", "결과", row["결과"]["식"] + for at, item in enumerate(row.get("호표") or []): + where = f"호표 {at + 1} {item.get('이름', '')}" + yield f"{where} 수량", f"호표:{at}", item["수량"] + elem = item.get("요소") + if isinstance(elem, dict): + yield f"{where} 요소", f"호표:{at}", " ".join(str(v) for v in elem.values()) + else: + yield f"{where} 요소", f"호표:{at}", str(elem) + for at, extra in enumerate(row.get("덧줄") or []): + yield f"덧줄 {extra['이름']}", f"덧줄:{at}", extra["식"] + + +def _used(text: str) -> set[str]: + out = set(mf._HOLE.findall(text)) + try: + for part in mf.walk(mf.parse(text)): + if part[0] == "name": + out.add(part[1]) + except (mf.FormulaError, RecursionError): + pass + return out + + +def uses(row: dict, key: str) -> list[str]: + """열쇠를 쓰는 식 자리(제 줄 빼고).""" + return [ + where for where, spot, text in _spots(row) if spot != f"중간:{key}" and key in _used(text) + ] + + +# ── 고침 ────────────────────────────────────────────────────────────── +def _all_keys(row: dict) -> set[str]: + return {str(x.get("이름")) for k in ("입력", "중간", "덧줄") for x in row.get(k) or []} + + +def _key_ok(row: dict, key: str) -> str | None: + if not KEY_FORM.match(key) or mf.mk.KEY.fullmatch(key) or key in RESERVED: + return f"열쇠 꼴 아님 「{key}」(글자로 시작 · 20 자까지 · 예약어 · 요소 키 안 됨)" + if key in _all_keys(row): + return f"같은 열쇠 「{key}」 가 이미 있음" + return None + + +def _text_of(ed: dict, row: dict, words) -> tuple[str, list[str]]: + """고침 식 → 엔진 식 — 나무 · 키보드 글(`words` = 층 번역 `[노임 …]` · `[별칭]` · 상한).""" + if isinstance(ed.get("나무"), dict): + return engine_text(ed["나무"]), [] + text = keyboard(str(ed.get("식") or "").strip(), row) + return words(text, row) if words and text else (text, []) + + +def _set_formula(row: dict, ed: dict, why: list, spots: set, words=None) -> None: + spot = str(ed.get("자리") or "") + text, bad = _text_of(ed, row, words) + if bad: + why += [f"{spot} · {w}" for w in bad] + return + if not text: + why.append(f"{spot} · 식이 빔") + return + part, _, at = spot.partition(":") + if part == "중간": + step = next((s for s in row.get("중간") or [] if s["이름"] == at), None) + if step is None: + why.append(f"없는 자리 「{spot}」") + return + step["식"] = text + step.setdefault("표시", meq.SHOW) + elif part == "결과" and "결과" in row: + row["결과"]["식"] = text + elif part in ("호표", "덧줄") and at.isdigit() and int(at) < len(row.get(part) or []): + row[part][int(at)]["수량" if part == "호표" else "식"] = text + else: + why.append(f"없는 자리 「{spot}」") + return + spots.add(spot) + + +def _look(row: dict, key: str, ed: dict, why: list) -> None: + """이름 · 기호 · 첨자 → 변수표 · 단위 → 입력 · 중간 칸.""" + spec = next((s for k in ("입력", "중간") for s in row.get(k) or [] if s["이름"] == key), None) + if spec is None: + why.append(f"없는 변수 「{key}」") + return + look = dict((row.get(meq.FIELD) or {}).get(key) or {}) + for field, form in (("이름", None), ("기호", SIGN_FORM), ("위", SUB_FORM), ("아래", SUB_FORM)): + if field not in ed: + continue + got = str(ed[field] or "").strip() + if got and form is not None and not form.match(got): + why.append(f"「{key}」 {field} 꼴 아님 「{got}」") + return + if field == "이름" and len(got) > 40: + why.append(f"「{key}」 이름 40 자까지") + return + if got and got != (key if field in ("이름", "기호") else ""): + look[field] = got + else: + look.pop(field, None) + if "단위" in ed: + unit = str(ed["단위"] or "").strip() + spec.pop("단위", None) if not unit else spec.__setitem__("단위", unit) + table = dict(row.get(meq.FIELD) or {}) + table.pop(key, None) if not look else table.__setitem__(key, look) + if table: + row[meq.FIELD] = table + else: + row.pop(meq.FIELD, None) + seen: dict[str, str] = {} + for v in meq.table(row): # 변수 글 겹침 — 키보드 식이 갈리지 않게 + for word in {meq.token(v), v["이름"]}: + if seen.get(word, v["열쇠"]) != v["열쇠"]: + why.append(f"「{word}」 이 「{seen[word]}」 · 「{v['열쇠']}」 둘에 겹침") + seen.setdefault(word, v["열쇠"]) + + +def _add(row: dict, ed: dict, why: list, spots: set, words=None) -> None: + kind, key = ed.get("갈래"), str(ed.get("열쇠") or ed.get("이름") or "").strip() + if kind not in ("입력", "중간"): + why.append(f"더함 갈래 「{kind}」 — 입력 · 중간") + return + if bad := _key_ok(row, key): + why.append(bad) + return + if kind == "입력": + spec: dict = {"이름": key} + for field in ("설명", "단위"): + if ed.get(field): + spec[field] = str(ed[field]) + if ed.get("기본값") not in (None, ""): + try: + spec["기본"] = Decimal(str(ed["기본값"])) + except InvalidOperation: + spec["기본"] = str(ed["기본값"]) + row.setdefault("입력", []).append(spec) + else: + mids = row.setdefault("중간", []) + after = ed.get("뒤") + at = len(mids) if after is None else 0 if after == "" else None + if at is None: + at = next((i + 1 for i, s in enumerate(mids) if s["이름"] == after), None) + if at is None: + why.append(f"더함 자리 「{after}」 없는 중간") + return + mids.insert( + at, {"이름": key, "식": "0", **({"단위": ed["단위"]} if ed.get("단위") else {})} + ) + job = {"자리": f"중간:{key}", **{k: ed[k] for k in ("식", "나무") if k in ed}} + _set_formula(row, job, why, spots, words) + look = {k: ed[k] for k in ("이름", "기호", "위", "아래") if ed.get(k)} + if look: + _look(row, key, look, why) + + +def _drop(row: dict, key: str, why: list) -> None: + users = uses(row, key) + if users: + why.append(f"「{key}」 를 쓰는 곳이 있어 못 지움 — {' · '.join(users)}") + return + for kind in ("입력", "중간"): + got = [s for s in row.get(kind) or [] if s["이름"] != key] + if len(got) != len(row.get(kind) or []): + row[kind] = got + break + else: + why.append(f"없는 변수 「{key}」") + return + for field in (meq.FIELD, "시험입력"): + if isinstance(row.get(field), dict) and key in row[field]: + row[field] = {k: v for k, v in row[field].items() if k != key} + if not row[field]: + row.pop(field) + + +def _show(row: dict, key: str, ed: dict, why: list) -> None: + step = next((s for s in row.get("중간") or [] if s["이름"] == key), None) + show = ed.get("표시") + if step is None: + why.append(f"표시는 중간 줄만 — 「{key}」") + elif show is None: + step.pop("표시", None) + elif not isinstance(show, int) or isinstance(show, bool) or not 0 <= show <= 6: + why.append(f"「{key}」 표시는 0~6 자리") + else: + step["표시"] = show + + +def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str], set]: + """고침 목록 → (새 로직 줄, 까닭, 식 고친 자리) — 까닭 있으면 저장 안 됨. + `words(글, 줄) → (엔진 식, 까닭)` = 층 번역(`M02_Sangeun_Formula` 상한 · to_engine).""" + new, why, spots = copy.deepcopy(row), [], set() + if not isinstance(edits, list) or not edits: + return new, ["고침 없음"], spots + for ed in edits: + ed = ed if isinstance(ed, dict) else {} + job, key = ed.get("할일"), str(ed.get("열쇠") or "") + try: + if job == "식": + _set_formula(new, ed, why, spots, words) + elif job == "더함": + _add(new, ed, why, spots, words) + elif job == "지움": + _drop(new, key, why) + elif job == "변수": + _look(new, key, ed, why) + elif job == "표시": + _show(new, key, ed, why) + else: + why.append(f"모르는 할일 「{job}」") + except (mf.FormulaError, KeyError, TypeError, ValueError) as e: + why.append(f"{job} · {e}") + if not why: + why += check(master, row, new, spots) + return new, why, spots + + +# ── 검사 ────────────────────────────────────────────────────────────── +def swap(master, row: dict): + """그 로직만 고친 줄로 바꾼 마스터 사본(얕은 · 다른 줄은 같이 씀).""" + out = copy.copy(master) + key = str(row.get("키")) + out.index = dict(master.index) + out.index[key[:2]] = {**master.index.get(key[:2], {}), key: row} + return out + + +def _loop(row: dict, spots: set) -> list[str]: + """고친 중간 줄 — 자기 · 아래 줄 · 순환.""" + mids = [s["이름"] for s in row.get("중간") or []] + deps = {s["이름"]: _used(s["식"]) & set(mids) for s in row.get("중간") or []} + out = [] + for i, name in enumerate(mids): + if f"중간:{name}" not in spots: + continue + for used in sorted(deps[name] - set(mids[:i])): + if used == name: + out.append(f"중간 {name} · 순환 — 「{name}」 이 자기 자신을 씀") + else: + path = _path(deps, used, name) + out.append( + f"중간 {name} · 순환 — {' → '.join([name, *path])}" + if path + else f"중간 {name} · 「{used}」 은 아래 줄 — 위로 옮길 것" + ) + return out + + +def _path(deps: dict, start: str, goal: str) -> list[str] | None: + seen, todo = {start}, [[start]] + while todo: + path = todo.pop() + if path[-1] == goal: + return path + for nxt in sorted(deps.get(path[-1], ())): + if nxt not in seen: + seen.add(nxt) + todo.append([*path, nxt]) + return None + + +def _zero(text: str) -> bool: + try: + node = mf.parse(text) + except (mf.FormulaError, RecursionError): + return False + return any(n[0] == "bin" and n[1] == "/" and n[3] == ("num", 0) for n in mf.walk(node)) + + +def check(master, old: dict, new: dict, spots: set) -> list[str]: + """새로 생긴 문제만 — 식 못 읽음 · 없는 이름 · 0 나눔 · 순환 · 엔진 검사 · 견본 셈 막힘.""" + texts = {spot: text for _, spot, text in _spots(new)} + out = [f"{s} · 0 으로 나눔" for s in sorted(spots) if _zero(texts.get(s, ""))] + out += _loop(new, spots) + head = f"{new.get('키')} {new.get('원문번호')} " + base = set(mf.check_logic(master, old)[0]) + out += [w.removeprefix(head) for w in mf.check_logic(master, new)[0] if w not in base] + if out: + return out + before, after = _trial(master, old), _trial(swap(master, new), new) + if after and after != before: + out.append(f"견본 셈 막힘 — {after}") + return out + + +def _trial(master, row: dict) -> str | None: + """견본 입력 셈 — 막히면 까닭(빈 입력 빼고).""" + try: + mf.run(master, str(row.get("키")), meq.given_of(master, row)) + except mf.EmptyInput: + return None + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + return str(e) or type(e).__name__ + return None diff --git a/resources/tester/test_796_1_formula_model.py b/resources/tester/test_796_1_formula_model.py new file mode 100644 index 000000000..16b34d833 --- /dev/null +++ b/resources/tester/test_796_1_formula_model.py @@ -0,0 +1,364 @@ +"""796-1 수식 모델 지킴 — 엔진 로직 ↔ 수식 나무 양방향 · 변수 표 · 추가 · 삭제 · 이름 전파 · 검사. + +① 예시 셋(요구서_655 · `_분석_655-B` 2 장) — GC001148 굴착기(블록 GC001147 Q = 81 · GC000267) · + GC001140 지게(N · M · 42,917) · GC000211 거푸집(시공량 45 · 형틀목공 5/45 · 공구손료 1 %). +② 왕복 — M01 로직 식 전부: 엔진 식 → 나무 → 엔진 식 = 같은 식 나무 · 예시 셋 = 같은 금액. +③ 고침 — 식 고침 = 금액 바뀜 · 변수 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자 전파(식 그대로) · + 표시 자리 셈(고친 줄만) · 검사 까닭(없는 이름 · 괄호 · 순환 · 0 나눔 · 상한). +④ M01 길 — 읽기 · 미리 셈 · 저장(개정확인 · 임시 사본 폴더 · 정본 안 건드림). +변수표 칸 셈 값 바뀜 0 · M01 1,519 지문은 `test_791_1_engine_semantics.py`. +""" + +from __future__ import annotations + +import copy +import importlib +import json +import shutil +from decimal import Decimal as D +from functools import lru_cache +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Equation as eq_m01 +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +mf = importlib.import_module("master_formula") +mcut = importlib.import_module("master_cut") +meq = importlib.import_module("master_equation") +me = importlib.import_module("master_equation_edit") +revise = importlib.import_module("M01_MasterData.M01_MasterData_Store_Revise") + +REAL = store.FOLDER +EXCAVATOR, AFRAME, FORM = "GC001148", "GC001140", "GC000211" +GIVEN = {AFRAME: {"운반회수": 0}, FORM: {"유형": "보통", "높이감률": 0}, EXCAVATOR: {}} + + +@lru_cache(maxsize=1) +def whole(): + return store.loaded()[1] + + +def export(key: str, row: dict | None = None) -> dict: + w = whole() + return meq.export(me.swap(w, row) if row else w, key, GIVEN[key]) + + +def line(eq: dict, key: str, kind: str = "중간") -> dict: + return next(x for x in eq["줄"] if x["열쇠"] == key and x["갈래"] == kind) + + +def raw(key: str, row: dict | None = None) -> dict: + w = me.swap(whole(), row) if row else whole() + r = row or w.logic(key) + return mf.run(w, key, meq.given_of(w, r, GIVEN[key])) + + +def vars_in(tree) -> list[dict]: + if isinstance(tree, dict): + own = [tree] if tree.get("꼴") == "변수" else [] + return own + [v for x in tree.values() for v in vars_in(x)] + if isinstance(tree, list): + return [v for x in tree for v in vars_in(x)] + return [] + + +def edit(key: str, edits: list) -> tuple[dict, list[str]]: + return eq_m01.edit_row(whole(), whole().logic(key), edits) + + +# ── ① 예시 셋 ───────────────────────────────────────────────────────── +def test_굴착기_블록_Q_81_노재경(): + eq = export(EXCAVATOR) + block = line(eq, "작업량")["이름식"] + assert block["꼴"] == "블록" and block["열쇠"] == "GC001147" and block["값"] == D("81.000") + sub = block["나무"] + q = line(sub, "결과", "결과") + assert q["결과"]["기호"] == "Q" and q["값"] == D("81.000") + frac = q["이름식"] + assert frac["꼴"] == "분수" and frac["아래"]["열쇠"] == "cm" + assert [v["기호"] for v in vars_in(frac["위"])] == ["q", "K", "f", "E"] + nums = [x.get("글") for x in vars_in(q["값식"])] or [] + shown = json.dumps(q["값식"], default=str, ensure_ascii=False) + assert all(f'"글": "{n}"' in shown for n in ("3,600", "1", "0.9", "0.7", "0.75", "21")) + assert nums == [] # 값 꼴엔 변수 없음 + item = line(eq, "굴착기(무한궤도)", "호표") + price = item["이름식"]["좌"] + assert price["꼴"] == "블록" and price["열쇠"] == "GC000267" + assert set(price["묶음"]) == {"노무비", "재료비", "경비", "계"} + assert item["이름식"]["우"]["꼴"] == "분수" # 1 / 작업량 + cut = mcut.engine(whole(), EXCAVATOR, raw(EXCAVATOR)) + assert eq["합계"] == {k: cut[k] for k in mf.BUNDLE} + assert eq["합계"]["계"] == D("641.0") + + +def test_지게_N_M_42917(): + eq = export(AFRAME) + n = line(eq, "일운반회수") + assert n["결과"]["기호"] == "N" and n["글"] == "92.2131" + assert n["이름식"]["꼴"] == "조건" and n["이름식"]["걸림"] == 2 # 운반회수 0 → 식 + frac = n["이름식"]["거짓"] + assert frac["꼴"] == "분수" + assert [v["기호"] for v in vars_in(frac["위"])] == ["V", "작업시간"] + m = line(eq, "보통인부", "호표") + qty = m["이름식"]["우"] + assert qty["꼴"] == "분수" and qty.get("사슬") # 무게 / 운반량 / N → 무게 / (운반량 × N) + assert [v["기호"] for v in vars_in(qty["아래"])] == ["운반량", "N"] + assert m["수량"].quantize(D("0.0001")) == D("0.2494") + assert eq["합계"]["노무비"] == D("42917.0") + signs = {v["열쇠"]: v["기호"] for v in eq["변수표"]} + assert signs.items() >= {"운반거리": "L", "적재적하시간": "t", "평균왕복속도": "V"}.items() + assert signs["일운반회수"] == "N" and signs["운반회수"] == "운반회수" # 한 기호 = 한 변수 + table = next(p for p in eq["풀이"] if p.get("표") == "CC000343") + assert table["행"] == {"종류": "석재류", "운반로": "불량"} + assert {p["갈래"] for p in eq["풀이"]} >= {"입력", "찾기", "요소"} + + +def test_거푸집_시공량_형틀목공_공구손료(): + eq = export(FORM) + s = line(eq, "시공량") + assert s["값"] == 45 and "퍼센트" in json.dumps(s["이름식"], default=str, ensure_ascii=False) + w = line(eq, "형틀목공") + assert w["이름식"]["꼴"] == "분수" and w["글"] == "0.1111" + tool = line(eq, "공구손료 및 경장비", "덧줄") + assert tool["이름식"]["꼴"] == "퍼센트" and tool["글"] == "382.908" + + +def test_기호_후보_원문(): + w = whole() + assert meq.symbols(w.logic("GC001147")) == {"버킷용량": "q", "결과": "Q"} + got = meq.symbols(w.logic(AFRAME)) + assert got == {"운반거리": "L", "실작업시간": "T", "적재적하시간": "t", "평균왕복속도": "V"} | { + "일운반회수": "N" + } + + +# ── ② 왕복 ──────────────────────────────────────────────────────────── +def _texts(row: dict): + for step in row.get("중간") or []: + yield step["식"] + if "결과" in row: + yield row["결과"]["식"] + for item in row.get("호표") or []: + yield item["수량"] + if isinstance(item.get("요소"), str): + yield item["요소"] + for extra in row.get("덧줄") or []: + yield extra["식"] + + +def test_왕복_M01_식_전부_같은_나무(): + files, _ = store.loaded() + count, bad = 0, [] + for data in files.values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + for text in _texts(row): + try: + node = mf.parse(text) + except mf.FormulaError: + continue + back = me.engine_text(meq.build(node, None)) + count += 1 + if mf.parse(back) != node or mf.parse(me.raw_text(node)) != node: + bad.append((row["키"], text, back)) + assert count > 9000 and not bad, bad[:5] + + +@pytest.mark.parametrize("key", [EXCAVATOR, AFRAME, FORM, "GC001147"]) +def test_왕복_예시_같은_금액(key): + GIVEN.setdefault("GC001147", {}) + w = whole() + eq = meq.export(w, key, GIVEN[key]) + row = copy.deepcopy(w.logic(key)) + for one in eq["줄"]: + tree = one["이름식"]["우"] if one["갈래"] == "호표" else one["이름식"] + text = me.engine_text(tree) + if one["갈래"] == "중간": + next(s for s in row["중간"] if s["이름"] == one["열쇠"])["식"] = text + elif one["갈래"] == "결과": + row["결과"]["식"] = text + else: + row[one["갈래"]][one.get("차례", 0)]["수량" if one["갈래"] == "호표" else "식"] = text + before, after = raw(key), raw(key, row) + pick = ("결과",) if "결과" in row else mf.BUNDLE + assert {k: after[k] for k in pick} == {k: before[k] for k in pick} + + +def test_변수표_칸은_셈_안_바꿈(): + row = dict(whole().logic(AFRAME)) + row[meq.FIELD] = {"일운반회수": {"기호": "n", "아래": "d", "이름": "하루 운반 횟수"}} + assert raw(AFRAME, row)["계"] == raw(AFRAME)["계"] + assert revise.row_print(row) == revise.row_print(whole().logic(AFRAME)) # M01 개정 배지 안 뜸 + + +# ── ③ 고침 ──────────────────────────────────────────────────────────── +def test_식_고침_금액_바뀜_표시_자리(): + new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"}]) + assert not why + step = next(s for s in new["중간"] if s["이름"] == "환산거리") + assert step["식"] == "환산계수 * 운반거리 * 2" and step["표시"] == meq.SHOW + others = [s for s in new["중간"] if s["이름"] != "환산거리"] + assert all("표시" not in s for s in others) # 고친 줄만 + eq = export(AFRAME, new) + assert line(eq, "환산거리")["값"] == 96 + assert eq["합계"]["노무비"] > export(AFRAME)["합계"]["노무비"] + + +def test_표시_자리에서_잘라_셈(): + old = line(export(AFRAME), "일운반회수")["식"] + new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:일운반회수", "식": old}]) + assert not why and new["중간"][-1]["표시"] == 4 + got = raw(AFRAME, new) + assert got["중간"]["일운반회수"] == D("92.2131") + assert got["노무비"] == D(575) / D(25) / D("92.2131") * D(172068) + assert got["노무비"] != raw(AFRAME)["노무비"] # 보이는 자리 셈 = 금액 바뀜(요구 658) + + +def test_변수_더함_입력_중간_식에서_부름(): + new, why = edit( + AFRAME, + [ + { + "할일": "더함", + "갈래": "입력", + "열쇠": "할증", + "기호": "k", + "아래": "s", + "기본값": "1.1", + }, + {"할일": "더함", "갈래": "중간", "열쇠": "할증무게", "식": "무게 × k_s", "뒤": "무게"}, + {"할일": "식", "자리": "호표:0", "식": "할증무게 ÷ 운반량 ÷ N"}, + ], + ) + assert not why, why + assert new["입력"][-1] == {"이름": "할증", "기본": D("1.1")} + mids = [s["이름"] for s in new["중간"]] + assert mids[:2] == ["무게", "할증무게"] and new["중간"][1]["식"] == "무게 * 할증" + assert new["호표"][0]["수량"] == "할증무게 / 운반량 / 일운반회수" + eq = export(AFRAME, new) + assert line(eq, "할증무게")["값"] == D("632.5") + base = export(AFRAME)["합계"]["노무비"] + assert abs(eq["합계"]["노무비"] - base * D("1.1")) <= 1 + + +def test_변수_지움_쓰는_곳_막음(): + new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "무게"}]) + assert why and "호표 1 보통인부 수량" in why[0] + new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "운반회수"}]) + assert why and "중간 일운반회수" in why[0] + new, why = edit( + AFRAME, + [ + {"할일": "더함", "갈래": "중간", "열쇠": "임시", "식": "무게 × 2"}, + {"할일": "지움", "열쇠": "임시"}, + ], + ) + assert not why and "임시" not in {s["이름"] for s in new["중간"]} + assert new["중간"] == whole().logic(AFRAME)["중간"] + + +def test_이름_기호_첨자_바꿈_전파(): + new, why = edit( + AFRAME, + [ + { + "할일": "변수", + "열쇠": "일운반회수", + "기호": "n", + "아래": "d", + "이름": "하루 운반 횟수", + } + ], + ) + assert not why + assert new["호표"] == whole().logic(AFRAME)["호표"] # 식은 열쇠 — 그대로 + assert new["중간"] == whole().logic(AFRAME)["중간"] + eq = export(AFRAME, new) + used = [v for v in vars_in(line(eq, "보통인부", "호표")["이름식"]) if v["열쇠"] == "일운반회수"] + assert used and used[0]["기호"] == "n" and used[0]["아래"] == "d" + assert used[0]["이름"] == "하루 운반 횟수" + assert eq["합계"] == export(AFRAME)["합계"] + typed = eq_m01.export_row(whole(), new, GIVEN[AFRAME]) + assert "n_d" in line(typed, "보통인부", "호표")["입력글"] + again, why = eq_m01.edit_row( + whole(), new, [{"할일": "식", "자리": "호표:0", "식": "무게 ÷ 운반량 ÷ n_d"}] + ) + assert not why and again["호표"][0]["수량"] == "무게 / 운반량 / 일운반회수" + _, why = edit(AFRAME, [{"할일": "변수", "열쇠": "운반량", "기호": "N"}]) # 겹침 + assert why and "겹침" in why[0] + + +def test_검사_까닭(): + cases = { + "없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 이름"), + "괄호": ("중간:환산거리", "(환산계수 × L", "없음"), + "아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"), + "순환": ("중간:무게", "무게 + 1", "자기 자신"), + "0 나눔": ("중간:환산거리", "환산계수 / 0", "0 으로 나눔"), + "길이": ("중간:환산거리", "1 + " * 80 + "1", "자를 넘음"), + "깊이": ("중간:환산거리", "(" * 13 + "1" + ")" * 13, "겹을 넘음"), + } + for name, (spot, text, want) in cases.items(): + _, why = edit(AFRAME, [{"할일": "식", "자리": spot, "식": text}]) + assert why and any(want in w for w in why), (name, why) + _, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "무게"}]) + assert why and "같은 열쇠" in why[0] + _, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "LB000001"}]) + assert why and "꼴 아님" in why[0] + _, why = edit(AFRAME, [{"할일": "표시", "열쇠": "무게", "표시": 9}]) + assert why + + +# ── ④ M01 길 ────────────────────────────────────────────────────────── +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def test_M01_길_읽기_미리셈_저장(client: TestClient): + file = "로직_건설품셈_01장_적용기준.json" + real = (REAL / file).read_bytes() + body = {"key": AFRAME, "inputs": {"운반회수": 0}} + got = client.post("/api/m01/logic/equation", json=body).json() + assert got["ok"] and got["수식"]["합계"]["노무비"] == 42917 + edits = [ + {"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"}, + {"할일": "변수", "열쇠": "일운반회수", "기호": "n"}, + ] + pre = client.post("/api/m01/logic/equation/preview", json=body | {"고침": edits}).json() + assert pre["되나"] and pre["후"]["노무비"] > pre["전"]["노무비"] + assert (store.FOLDER / file).read_bytes() == real # 미리 셈 = 쓰기 없음 + bad = client.post( + "/api/m01/logic/equation/preview", json=body | {"고침": [edits[0] | {"식": "x)"}]} + ) + assert not bad.json()["되나"] + res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits}) + assert res.status_code == 409 # 정본 = 개정 확인 + res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits, "개정확인": True}) + assert res.status_code == 200, res.text + assert res.json()["수식"]["합계"]["노무비"] == pre["후"]["노무비"] + saved = json.loads((store.FOLDER / file).read_text(encoding="utf-8")) + before = json.loads(real.decode("utf-8")) + rows = {r["키"]: r for r in saved["줄"]} + assert rows[AFRAME][meq.FIELD] == {"일운반회수": {"기호": "n"}} + assert {k: v for k, v in rows.items() if k != AFRAME} == { + r["키"]: r for r in before["줄"] if r["키"] != AFRAME + } + assert (REAL / file).read_bytes() == real # 정본 안 건드림 + stale = client.post( + "/api/m01/logic/equation/save", + json=body | {"고침": edits, "version": "옛판", "개정확인": True}, + ) + assert stale.status_code == 409