# -*- coding: utf-8 -*- """로직 셈 의미 넓힘(PLAN 791-1 · `_틀.md` 7장 「셈 의미」) — 덧줄 고리 · 금액번지 · 소계 단계 · 제외 · 결과금액 조정 · 변수 표시 반올림 · 검사. `master_formula.run` 이 부름(덧줄 고리를 여기로 옮김) · 끝전(줄 자리 · 블록 끝전)은 `master_cut`. 새 칸을 안 쓰는 로직은 옛 셈 그대로 — 지킴 = M01 로직 지문(`test_791_1_engine_semantics.py`). eval 없음 · 수는 Decimal. """ from __future__ import annotations from decimal import ROUND_HALF_UP, Decimal import master_formula as mf COSTS = mf.COST_ITEMS ROW_KEYS = ("단계", "끝전", "조정") # 로직 줄 칸 LINE_KEYS = ("번지", "합밖", "자리") # 호표 · 덧줄 칸 ENDS = ("비목버림", "큰나머지") MAX_PLACE = 3 # 줄 자리 0~3 MAX_SHOW = 6 # 변수 표시 0~6 MAX_STEP = 5 # 소계 단 1~5(STmate `(=)`~`(=====)`) ADDR, STEP = "번지", "단계" # 덧줄 식 이름 — `번지.A01.재료비` · `단계.'소계'.계` def items(row: dict) -> list[dict]: """엔진 줄 차례 = 호표 하나씩 뒤 덧줄 하나씩.""" return [*row.get("호표", []), *row.get("덧줄", [])] def uses(row: dict) -> bool: """새 의미(번지 · 단계 · 끝전 · 조정 · 합밖 · 줄 자리)를 쓰는 로직 — 아니면 옛 셈 그대로.""" return any(k in row for k in ROW_KEYS) or any(k in x for x in items(row) for k in LINE_KEYS) def rule(row: dict) -> dict: """셈법(792-3) — 새 의미 칸만 뗀 작은 로직 꼴(이 모듈 · `master_cut.settle` 이 읽음) · 엔진 답 `셈법` 으로 실어 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)이 씀.""" keep = ("이름", *LINE_KEYS) lines = { k: [{x: one[x] for x in keep if x in one} for one in row.get(k, [])] for k in ("호표", "덧줄") } return {**lines, **{k: row[k] for k in (*ROW_KEYS, "끝수") if k in row}} def shown(step: dict, value): """중간 `표시` = 값을 n 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`).""" if step.get("표시") is None or not isinstance(value, Decimal): return value return mf._round(value, step["표시"], ROUND_HALF_UP) def _add(acc: dict, money: dict | None) -> None: for k, v in (money or {}).items(): if k in acc: acc[k] += v or 0 def _bundle(acc: dict) -> dict: return {**acc, "계": sum(acc[k] for k in COSTS)} def _cover(row: dict) -> list[tuple[dict, set]]: """단계마다 (단계, 담은 줄 차례) — 단 1 = 제 `줄` · 단 k = 제 `줄` + 아직 안 담긴 아래 단 단계. 제외 단계는 위로 안 올라감.""" where = {x.get("이름"): i for i, x in enumerate(items(row)) if x.get("이름")} out, open_ = [], [] for st in row.get(STEP) or []: level, mine = int(st.get("단") or 1), {where[n] for n in st.get("줄") or [] if n in where} keep = [] for low, got in open_: if low < level: mine |= got else: keep.append((low, got)) open_ = keep if st.get("제외") else [*keep, (level, mine)] out.append((st, mine)) return out def skipped(row: dict) -> set: """비목 합에서 빼는 줄 차례 — `합밖` 줄(번지엔 쌓음) · 제외 단계가 담은 줄.""" out = {i for i, x in enumerate(items(row)) if x.get("합밖")} for st, mine in _cover(row): if st.get("제외"): out |= mine return out def total(row: dict, lines: list[dict]) -> dict: """비목 합 — 뺄 줄을 빼고 지금까지 줄.""" skip, acc = skipped(row), dict.fromkeys(COSTS, Decimal(0)) for i, line in enumerate(lines): if i not in skip: _add(acc, line.get("비목")) return acc def steps(row: dict, lines: list[dict]) -> list[dict]: """단계 값 — 지금까지 셈한 줄만(뒤 줄은 0).""" out = [] for st, mine in _cover(row): acc = dict.fromkeys(COSTS, Decimal(0)) for i in sorted(mine): if i < len(lines): _add(acc, lines[i].get("비목")) out.append( {"이름": st.get("이름"), "단": st.get("단"), **_bundle(acc)} | ({"제외": True} if st.get("제외") else {}) ) return out def scope(row: dict, lines: list[dict]) -> dict: """덧줄 식 이름 — `번지` {A01: 묶음} · `단계` {이름: 묶음} (지금까지 줄).""" places = {} for item, line in zip(items(row), lines): if item.get(ADDR): _add(places.setdefault(item[ADDR], dict.fromkeys(COSTS, Decimal(0))), line.get("비목")) return { ADDR: {k: _bundle(v) for k, v in places.items()}, STEP: {x["이름"]: {k: x[k] for k in (*COSTS, "계")} for x in steps(row, lines)}, } def adjust(row: dict, sums: dict) -> dict: """결과금액 조정 — `배율` {비목: 수} 곱 → `옮김` 비목으로 계 전부(STmate 「경비로 적용」).""" rule = row.get("조정") if not rule: return sums out = dict(sums) for k, rate in (rule.get("배율") or {}).items(): out[k] = out[k] * Decimal(str(rate)) if rule.get("옮김"): whole = sum(out.values()) out = {k: whole if k == rule["옮김"] else Decimal(0) for k in COSTS} return out def extras(master, row: dict, lines: list[dict], env: dict, sums: dict, depth: int): """덧줄 셈(옛 `run` 고리 그대로) + 새 의미 → (비목 합, 덧칸 `{단계}`).""" new = uses(row) # 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌) env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None} blank = {x["이름"]: x["까닭"] for x in lines if x.get("까닭")} for extra in row.get("덧줄", []): if new: sums = total(row, lines) env.update(scope(row, lines)) env.update(sums) try: # 덧줄도 값 빈 품목·빈 줄을 부르면 그 줄만 비움 value, why = mf.evaluate(mf.parse(extra["식"]), env, master, depth), None env[extra["이름"]] = value except mf.FormulaError as e: gone = next((n for n in blank if n in extra["식"]), None) value, why = None, f"「{gone}」 줄 비어 못 셈 — {blank[gone]}" if gone else str(e) blank[extra["이름"]] = why sums[extra["비목"]] += value or 0 lines.append( { "이름": extra["이름"], "단위": "식", "수량": Decimal(1), "단가": value, "금액": value, "비목": {extra["비목"]: value}, **({"까닭": why} if why else {}), } ) if new: sums = total(row, lines) sums = {k: mf.cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 if not new: return sums, {} return adjust(row, sums), {STEP: steps(row, lines)} # ── 검사 ────────────────────────────────────────────────────────────── def _whole(v, low: int, high: int) -> bool: return isinstance(v, (int, Decimal)) and v == int(v) and low <= int(v) <= high def _refs(node): """식 안 `번지.X.칸` · `단계.'X'.칸` → (이름, X, 칸).""" for part in mf.walk(node): if part[0] == "field" and part[1][0] == "field" and part[1][1][0] == "name": head = part[1][1][1] if head in (ADDR, STEP): yield head, part[1][2], part[2] def check(row: dict, key: str) -> list[str]: """새 의미 칸 — 끝전 · 조정 · 줄 자리 · 번지 · 표시 · 단계 · 덧줄이 부르는 번지 · 단계 차례.""" out, lines = [], items(row) end = row.get("끝전") if end is not None and end not in ENDS: out.append(f"{key} 끝전 「{end}」 — {' · '.join(ENDS)}") rule = row.get("조정") if rule is not None: if not isinstance(rule, dict) or set(rule) - {"옮김", "배율"}: out.append(f"{key} 조정 · 칸은 옮김 · 배율만") else: if rule.get("옮김") not in (None, *COSTS): out.append(f"{key} 조정 옮김 「{rule['옮김']}」 — {' · '.join(COSTS)}") for k, rate in (rule.get("배율") or {}).items(): if k not in COSTS or not isinstance(rate, (int, Decimal)) or rate < 0: out.append(f"{key} 조정 배율 「{k}」={rate}") for step in row.get("중간", []): if step.get("표시") is not None and not _whole(step["표시"], 0, MAX_SHOW): out.append(f"{key} 중간 {step['이름']} · 표시 「{step['표시']}」 — 0∼{MAX_SHOW} 정수") names = [x.get("이름") for x in lines] for item in lines: where = f"{key} 줄 {item.get('이름', '')}" place = item.get("자리") if place is not None and ( not isinstance(place, dict) or set(place) - {"방법", "자리"} or place.get("방법") not in mf.ROUND_WAYS or not _whole(place.get("자리", 0), 0, MAX_PLACE) ): out.append( f"{where} · 자리 「{place}」 — {{방법: 버림·반올림·올림, 자리: 0∼{MAX_PLACE}}}" ) if ADDR in item and not (isinstance(item[ADDR], str) and mf._NAME.fullmatch(item[ADDR])): out.append(f"{where} · 번지 「{item[ADDR]}」 — 글자 · 숫자만") if "합밖" in item and not isinstance(item["합밖"], bool): out.append(f"{where} · 합밖 「{item['합밖']}」 — 참 · 거짓") out += _check_steps(row, key, names) return out def _check_steps(row: dict, key: str, names: list) -> list[str]: out, seen, taken = [], set(), {} for st in row.get(STEP) or []: name = st.get("이름") if isinstance(st, dict) else None where = f"{key} 단계 {name}" if not isinstance(name, str) or not name.strip(): out.append(f"{key} 단계 · 이름 없음") continue if name in seen: out.append(f"{where} · 같은 이름") seen.add(name) if set(st) - {"이름", "단", "줄", "제외"}: out.append(f"{where} · 모르는 칸 {sorted(set(st) - {'이름', '단', '줄', '제외'})}") if not _whole(st.get("단"), 1, MAX_STEP): out.append(f"{where} · 단 「{st.get('단')}」 — 1∼{MAX_STEP} 정수") for line in st.get("줄") or []: if line not in names: out.append(f"{where} · 없는 줄 「{line}」") elif names.count(line) > 1: out.append(f"{where} · 줄 이름 「{line}」 겹침 — 줄 이름을 가를 것") elif line in taken: out.append(f"{where} · 줄 「{line}」 이 단계 「{taken[line]}」 에도 있음") taken.setdefault(line, name) if out: return out cover = {st["이름"]: mine for st, mine in _cover(row)} places = {} for i, item in enumerate(items(row)): if item.get(ADDR): places.setdefault(item[ADDR], set()).add(i) first = len(row.get("호표", [])) for n, extra in enumerate(row.get("덧줄", [])): try: node = mf.parse(extra["식"]) except mf.FormulaError: continue # 못 읽는 식은 `check_logic` 이 알림 for head, name, slot in _refs(node): where = f"{key} 덧줄 {extra.get('이름')}" group = (places if head == ADDR else cover).get(name) if group is None: out.append(f"{where} · 없는 {head} 「{name}」") elif any(i >= first + n for i in group): out.append(f"{where} · {head} 「{name}」 에 이 줄이나 뒤 줄이 듦 — 단계 순서") if slot not in mf.BUNDLE: out.append(f"{where} · {head} 칸 「{slot}」 — {' · '.join(mf.BUNDLE)}") return out