- 표 키(QC…) 대신 표 제목을 짧게 — 규격·코드 뗌 · 길면 첫 + 끝 낱말 · 제목 없으면 공종 · 상세구분 - 같은 표 여러 번은 번호 대신 서로 가르는 인자 값만(낙석방지망설치재료_철망 · 합판거푸집_각재) - 호표 줄 이름도 짧은 품명 · 한 로직 안에서 겹치면 꼬리 _2 - 자동값 7,008줄: 표 키 1,188→0 · 번호 별칭 982→4 · 붙인 20자 넘는 이름 108→0 · 금액 변화 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
635 lines
28 KiB
Python
635 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""사람이 읽는 식 줄 — 비목 묶음마다 「이름 : 단가 * 수량식 = 금액」 과 소계.
|
||
|
||
줄마다 글 두 꼴 —
|
||
· `글` 값 꼴: 수는 화면과 같은 자릿수(소수 넷째 자리까지 · 천 단위 쉼표) · `찾기`·`로직`·
|
||
중간 값·요소는 글자로 내보내지 않고 그 자리에서 나온 수로 적음
|
||
· `이름글` 이름 꼴: 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」 — 표 찾기·로직
|
||
부르기는 별칭 이름(`master_copy` 3-4 규칙) · 중간 값·입력은 그 이름 그대로
|
||
두 꼴은 같은 줄에 나란히 있고 `짝` 번호가 붙음 — 화면 두 칸이 줄 맞춤으로 놓임.
|
||
줄마다 `조각`(PLAN 2-1 조각 계약) 도 같이 줌 — kind(요소·표·로직·값·기호·글) · 글 · 참조.
|
||
알약(요소·표·로직)이 될 자리는 구조만 보고 짓고, `만약()` 갈래만 골라야 해서 계산이 필요함 —
|
||
계산이 안 되면(FormulaError 따위) 갈래 양쪽을 다 내보내고 깊이를 적어 접을 수 있게 함.
|
||
호표 줄 계산 자체가 막혀도(재료 단가 없음 따위) 이름 식·조각은 `row` 구조만으로 그리고
|
||
`글`·`금액`만 비움 — `만약() 과 무관` 인 그 60개 로직도 이름 식은 보이게.
|
||
끝수는 줄이 아니라 소계에 녹여 적음. 계산 자체는 `master_formula.run` 이 한 벌만 함.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
|
||
import master_copy as mc
|
||
import master_formula as mf
|
||
|
||
PLACES = Decimal("0.0001") # 화면 `formatNumber` 와 같음(소수 넷째 자리까지)
|
||
PRICE_TAIL = {"인력": "노임", "기계": "손료"} # 단가 자리의 이름 꼬리(그 밖은 「단가」)
|
||
ELEM_KEYS = ("LB", "EQ", "MT", "MP", "MO") # 요소 테이블 — 그 밖의 열쇠(QC · QF …)는 별칭 이름
|
||
ELEM_TAIL = {"LB": "노임", "EQ": "손료"} # 식 안 요소 키의 이름 꼬리(그 밖은 「단가」)
|
||
_PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3}
|
||
_PREC |= {"+": 4, "-": 4, "*": 5, "/": 5}
|
||
_RIGHT_TIGHT = ("-", "/") # 오른쪽 짝은 같은 세기라도 괄호가 필요함
|
||
_CALC_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) # 계산 못 함
|
||
|
||
|
||
def fmt(value) -> str:
|
||
"""보이는 자릿수 — 소수 넷째 자리·유효숫자 4 자리 중 많은 쪽 · 뒤 0 뗌 · 천 단위 쉼표."""
|
||
if not isinstance(value, Decimal):
|
||
return str(value)
|
||
places = max(4, 3 - value.adjusted()) if value else 4 # 0.00167 → 0.001667
|
||
cut = value
|
||
if value.as_tuple().exponent < -places:
|
||
cut = value.quantize(Decimal(1).scaleb(-places))
|
||
sign, digits = ("-", -cut) if cut < 0 else ("", cut)
|
||
whole, _, rest = f"{digits.normalize():f}".partition(".")
|
||
if "E" in whole or "e" in whole: # 아주 큰 수 — 지수 꼴을 펴서 씀
|
||
whole, _, rest = f"{digits:f}".rstrip("0").rstrip(".").partition(".")
|
||
return sign + f"{int(whole):,}" + (f".{rest}" if rest else "")
|
||
|
||
|
||
def fmt_money(value) -> str:
|
||
"""돈(단가 · 금액)은 원 단위 정수로 보임 — 글만 그렇고 계산 값은 소수 그대로."""
|
||
if isinstance(value, Decimal):
|
||
value = value.quantize(Decimal(1), rounding=ROUND_HALF_UP)
|
||
return fmt(value)
|
||
|
||
|
||
def fmt_price(value) -> str:
|
||
"""단가 — 정수로 보이되 200 원 미만은 반올림 오차가 커서 유효숫자로."""
|
||
small = isinstance(value, Decimal) and abs(value) < 200
|
||
return fmt(value) if small else fmt_money(value)
|
||
|
||
|
||
# ── 식을 글로 — 값 꼴과 이름 꼴 ────────────────────────────────────────
|
||
def render(node, env: dict, master, depth: int = 0) -> str:
|
||
"""식 한 마디 → 값 꼴 글. 값이 정해지는 마디(요소 · 찾기 · 로직 · 이름)는 그 수로 바뀜."""
|
||
return _draw(node, node, env, master, depth)
|
||
|
||
|
||
def _draw(show, real, env: dict, master, depth: int) -> str:
|
||
kind = show[0]
|
||
if kind == "num":
|
||
return fmt(show[1])
|
||
if kind == "str":
|
||
return f"'{show[1]}'"
|
||
if kind == "neg":
|
||
return "-" + _draw(show[1], real[1], env, master, depth)
|
||
if kind == "bin":
|
||
return _bin(show, real, env, master, depth, _draw)
|
||
if kind == "call" and show[1] == "만약": # 걸린 쪽만 적음
|
||
at = 1 if mf.evaluate(real[2][0], env, master, depth) else 2
|
||
return _draw(show[2][at], real[2][at], env, master, depth)
|
||
return _value(real, env, master, depth)
|
||
|
||
|
||
def named(show, real, env: dict, master, depth: int = 0) -> str:
|
||
"""식 한 마디 → 이름 꼴 글. `show` = 부르기를 별칭으로 갈아 끼운 나무 · `real` = 원래 나무.
|
||
이름·중간 값은 그대로 · 표 찾기·로직 부르기는 별칭 이름 · 요소는 그 이름."""
|
||
kind = show[0]
|
||
if kind == "name":
|
||
return show[1]
|
||
if kind == "ref":
|
||
return _elem_name(master, show[1])
|
||
if kind == "field":
|
||
return f"{named(show[1], real[1], env, master, depth)}.{show[2]}"
|
||
if kind == "neg":
|
||
return "-" + named(show[1], real[1], env, master, depth)
|
||
if kind == "bin":
|
||
return _bin(show, real, env, master, depth, named)
|
||
if kind == "call":
|
||
if show[1] == "만약": # 값 꼴과 같은 쪽만 적어 두 칸이 짝이 되게
|
||
try:
|
||
at = 1 if mf.evaluate(real[2][0], env, master, depth) else 2
|
||
except _CALC_ERRORS: # 조건 입력이 비면 식 그대로(아래 부르기 꼴)
|
||
at = 0
|
||
if at:
|
||
return named(show[2][at], real[2][at], env, master, depth)
|
||
args = ", ".join(named(s, r, env, master, depth) for s, r in zip(show[2], real[2]))
|
||
return f"{show[1]}({args})"
|
||
if kind in ("num", "str"):
|
||
return _draw(show, real, env, master, depth)
|
||
return _value(real, env, master, depth) # 별칭이 안 붙은 부르기 — 값으로
|
||
|
||
|
||
def _elem_name(master, ref: str) -> str:
|
||
"""식 안 요소 한 줄의 이름 — 「석공노임」 꼴(같은 이름의 중간 값과 헷갈리지 않게)."""
|
||
if (
|
||
str(ref)[:2] not in ELEM_KEYS
|
||
): # 별칭 이름이 표 열쇠 그대로인 것(QC…) — 밑이름을 풀어쓰지 않음
|
||
return ref
|
||
try:
|
||
name = mc.short(master.get(ref).get("이름"))
|
||
except mf.FormulaError:
|
||
name = ""
|
||
return f"{name}{ELEM_TAIL.get(str(ref)[:2], '단가')}" if name else ref
|
||
|
||
|
||
def _value(node, env: dict, master, depth: int) -> str:
|
||
value = mf.evaluate(node, env, master, depth)
|
||
if isinstance(value, dict): # 로직 부르기 묶음 — 줄 금액에 쓰는 것은 「계」
|
||
value = value.get("계")
|
||
return fmt(value)
|
||
|
||
|
||
def _bin(show, real, env: dict, master, depth: int, draw) -> str:
|
||
_, op, left, right = show
|
||
if op == "/" and right[0] == "num" and right[1] == 100: # 「… / 100」 은 퍼센트로
|
||
return f"{_side(left, real[2], show, env, master, depth, draw, False)} %"
|
||
return (
|
||
f"{_side(left, real[2], show, env, master, depth, draw, False)} {op} "
|
||
f"{_side(right, real[3], show, env, master, depth, draw, True)}"
|
||
)
|
||
|
||
|
||
def _side(show, real, parent, env: dict, master, depth: int, draw, right: bool) -> str:
|
||
text = draw(show, real, env, master, depth)
|
||
if show[0] != "bin":
|
||
return text
|
||
mine, up = _PREC[show[1]], _PREC[parent[1]]
|
||
if mine < up or (mine == up and right and parent[1] in _RIGHT_TIGHT):
|
||
return f"({text})"
|
||
return text
|
||
|
||
|
||
# ── 조각(알약) — PLAN 2-1 조각 계약 · 2-2 「이름 식을 계산 없이」 ──────────
|
||
_PIECE_SYM = {"+": "+", "-": "-", "*": "×", "/": "÷"}
|
||
|
||
|
||
def _p_val(text: str) -> dict:
|
||
return {"kind": "값", "글": text}
|
||
|
||
|
||
def _p_sym(text: str) -> dict:
|
||
return {"kind": "기호", "글": text}
|
||
|
||
|
||
def _alias_index(marks: list[dict]) -> dict[str, dict]:
|
||
"""별칭 이름 → 별칭 한 줄(`master_copy.aliases` 결과) — 조각이 참조를 되찾을 때 씀."""
|
||
return {m["이름"]: m for m in marks}
|
||
|
||
|
||
def _elem_piece(master, ref: str) -> dict:
|
||
"""요소 알약 — 글은 이름(못 찾아도 이름만). 자리 표시(`{…}`) 든 참조 14파일은
|
||
값(참조 키)을 못 구하므로 이름만 적고 참조 자리는 비움."""
|
||
piece = {"kind": "요소", "글": _elem_name(master, ref)}
|
||
if "{" in str(ref):
|
||
return piece
|
||
try:
|
||
row = master.get(ref)
|
||
except mf.FormulaError:
|
||
return piece
|
||
key = str(row.get("키") or ref)
|
||
piece["참조"] = {"테이블": key[:2], "키": key}
|
||
return piece
|
||
|
||
|
||
def _row_label(table: dict, row: dict, conds: dict) -> list[str]:
|
||
"""찾은 줄의 이름 — 조건마다 그 줄의 값(범위 칸은 찾은 범위)."""
|
||
out = []
|
||
for cond, want in conds.items():
|
||
have = row.get(cond, want)
|
||
if table.get("조건", {}).get(cond) == "범위" and isinstance(have, list):
|
||
low, high = ("" if x is None else fmt(x) for x in have)
|
||
out.append(f"{low}~{high}")
|
||
else:
|
||
out.append(fmt(have))
|
||
return out
|
||
|
||
|
||
def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict:
|
||
"""표 찾기·로직 부르기 알약 — 표는 글 = 「찾은 줄 · 칸」 · 참조.행 = 찾기 조건.
|
||
다시 못 찾으면(입력이 비어서 따위) 별칭 이름만 남김."""
|
||
piece = {"kind": "표" if mark["종류"] == "찾기" else "로직", "글": mark["이름"]}
|
||
try:
|
||
node = mf.parse(mark["원문"])
|
||
ref = mf.fill(node[1], env)
|
||
if mark["종류"] == "찾기":
|
||
table = master.get(ref)
|
||
conds = {k: mf.evaluate(v, env, master, 0) for k, v in node[2]}
|
||
row = mf.find(table, conds, column)
|
||
tail = column.rpartition("_")[2] if column else ""
|
||
piece["글"] = " · ".join([*_row_label(table, row, conds), *([tail] if tail else [])])
|
||
piece["참조"] = {"테이블": ref[:2], "키": ref, "행": conds, "열": column}
|
||
else:
|
||
piece["참조"] = {"테이블": ref[:2], "키": ref}
|
||
except _CALC_ERRORS:
|
||
pass # 다시 못 찾음 — 별칭 이름만 남김(값 자리 비움)
|
||
return piece
|
||
|
||
|
||
def _ga(word: str) -> str:
|
||
"""받침이 없으면 「가」 · 있으면 「이」."""
|
||
last = word[-1:]
|
||
return "이" if "가" <= last <= "힣" and (ord(last) - 0xAC00) % 28 else "가"
|
||
|
||
|
||
def _easy(show, real, env: dict, master) -> str:
|
||
if show[0] == "str":
|
||
return show[1]
|
||
try:
|
||
return named(show, real, env, master)
|
||
except _CALC_ERRORS:
|
||
return ""
|
||
|
||
|
||
def _cond_text(show, real, env: dict, master) -> str:
|
||
"""만약 조건 → 쉬운 말 — 「폼타이 가 사용 이면」(같음) · 그 밖은 「식 이면」."""
|
||
if show[0] == "bin" and show[1] == "==":
|
||
left = _easy(show[2], real[2], env, master)
|
||
return f"{left} {_ga(left)} {_easy(show[3], real[3], env, master)} 이면"
|
||
return f"{_easy(show, real, env, master)} 이면"
|
||
|
||
|
||
def _if_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
|
||
"""만약() 조각 — 「조건 이면 A · 아니면 B」 · 안 걸린 갈래는 흐림 ·
|
||
조건이 안 셈해지면(입력이 비어서 따위) 흐림 없이 두 갈래 다(깊이를 적어 접을 수 있게)."""
|
||
try:
|
||
taken = bool(mf.evaluate(real[2][0], env, master, 0))
|
||
except _CALC_ERRORS:
|
||
taken = None
|
||
deeper = ifdepth if taken is not None else ifdepth + 1
|
||
then = _pieces(show[2][1], real[2][1], env, master, alias_by_name, deeper)
|
||
other = _pieces(show[2][2], real[2][2], env, master, alias_by_name, deeper)
|
||
other.insert(0, {"kind": "글", "글": "· 아니면"})
|
||
for one in then if taken is None else ():
|
||
one["깊이"] = deeper
|
||
for one in other if taken is None else ():
|
||
one["깊이"] = deeper
|
||
for one in then if taken is False else other if taken else ():
|
||
one["흐림"] = True
|
||
cond = {"kind": "글", "글": _cond_text(show[2][0], real[2][0], env, master)}
|
||
return [cond, *then, *other]
|
||
|
||
|
||
def _side_pieces(
|
||
show, real, parent_show, env: dict, master, alias_by_name: dict, ifdepth: int, right: bool
|
||
):
|
||
inner = _pieces(show, real, env, master, alias_by_name, ifdepth)
|
||
if show[0] != "bin":
|
||
return inner
|
||
mine, up = _PREC[show[1]], _PREC[parent_show[1]]
|
||
if mine < up or (mine == up and right and parent_show[1] in _RIGHT_TIGHT):
|
||
return [_p_sym("("), *inner, _p_sym(")")]
|
||
return inner
|
||
|
||
|
||
def _bin_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
|
||
op = show[1]
|
||
if op == "/" and show[3][0] == "num" and show[3][1] == 100: # 「… / 100」 은 퍼센트로
|
||
left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False)
|
||
return [*left, {"kind": "글", "글": "%"}]
|
||
left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False)
|
||
right = _side_pieces(show[3], real[3], show, env, master, alias_by_name, ifdepth, True)
|
||
return [*left, _p_sym(_PIECE_SYM.get(op, op)), *right]
|
||
|
||
|
||
def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0) -> list[dict]:
|
||
"""식 한 마디 → 조각 목록. 요소·표·로직 부르기는 알약(참조가 됨) · 나머지는 값·기호·글."""
|
||
kind = show[0]
|
||
if kind == "num":
|
||
return [_p_val(fmt(show[1]))]
|
||
if kind == "str":
|
||
return [_p_val(f"'{show[1]}'")]
|
||
if kind == "neg":
|
||
return [_p_sym("-"), *_pieces(show[1], real[1], env, master, alias_by_name, ifdepth)]
|
||
if kind == "bin":
|
||
return _bin_pieces(show, real, env, master, alias_by_name, ifdepth)
|
||
if kind == "call" and show[1] == "만약":
|
||
return _if_pieces(show, real, env, master, alias_by_name, ifdepth)
|
||
if kind == "ref":
|
||
return [_elem_piece(master, show[1])]
|
||
if kind == "field" and show[1][0] == "name" and show[1][1] in alias_by_name:
|
||
return [_alias_piece(alias_by_name[show[1][1]], env, master, show[2])]
|
||
if kind == "name" and show[1] in alias_by_name:
|
||
return [_alias_piece(alias_by_name[show[1]], env, master, None)]
|
||
piece = _p_val(show[1] if kind == "name" else "") # 중간 값은 글에 그 이름
|
||
try:
|
||
value = _value(real, env, master, 0)
|
||
piece.update({"값": value} if kind == "name" else {"글": value})
|
||
except _CALC_ERRORS:
|
||
pass # 값 계산 자체가 막힘 — 이름만(그 밖은 빈 글) 남겨 줄이 안 죽게
|
||
return [piece]
|
||
|
||
|
||
def _formula_pieces(text, env: dict, master, name_of: dict, alias_by_name: dict) -> list[dict]:
|
||
"""식 글 한 줄의 조각 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음(`_names` 와 짝)."""
|
||
real = mf.parse(text)
|
||
try:
|
||
show = mf.parse(mc.mask(text, name_of))
|
||
except mf.FormulaError:
|
||
show = real
|
||
return _pieces(show, real, env, master, alias_by_name)
|
||
|
||
|
||
# ── 줄 세우기 ─────────────────────────────────────────────────────────
|
||
def _env_of(master, row: dict, given: dict, depth: int = 0) -> dict:
|
||
"""입력 + 중간 — `run` 과 같은 차례로 다시 셈(덧줄 전 상태).
|
||
아직 안 고른 입력이 있으면(값 넣기 전) 받은 것만 담아 돌려줌 — 이름 식은 값 없이도 지어짐
|
||
(고른 값이 틀린 경우(고르기·범위 밖)는 지금처럼 그대로 막힘 — `mf._inputs` 가 걸러냄)."""
|
||
specs = row.get("입력", [])
|
||
if any(spec["이름"] not in given for spec in specs):
|
||
return {spec["이름"]: given[spec["이름"]] for spec in specs if spec["이름"] in given}
|
||
env = mf._inputs(row, given)
|
||
for step in row.get("중간", []):
|
||
env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master, depth)
|
||
return env
|
||
|
||
|
||
def _why(item: dict, line: dict) -> str:
|
||
"""단가를 못 구했거나 0 인 줄의 까닭 한 마디."""
|
||
if line["수량"] == 0:
|
||
return "수량 0 — 이 조건에서는 안 쓰는 줄"
|
||
if line["단가"] not in (0, None):
|
||
return ""
|
||
where = item.get("요소")
|
||
where = "재료 고르기 조건" if isinstance(where, dict) else f"「{where}」"
|
||
return f"단가 0 — {where} 값이 아직 없음"
|
||
|
||
|
||
def _label(item: dict, line: dict) -> str:
|
||
spec = str(item.get("규격") or "").strip()
|
||
name = str(line.get("이름") or item.get("이름") or "")
|
||
return f"{name}({spec})" if spec else name
|
||
|
||
|
||
def _shares(line: dict) -> list[tuple[str, Decimal]]:
|
||
"""글로 적을 (비목, 몫) — 비목이 나뉜 로직 줄은 몫이 없는 비목을 건너뜀.
|
||
몫이 전부 0 이면 호표 줄이 사라지지 않게 첫 비목에 0 원 한 줄을 남김."""
|
||
parts = list(line["비목"].items())
|
||
if len(parts) <= 1:
|
||
return parts
|
||
return [(part, money) for part, money in parts if money != 0] or parts[:1]
|
||
|
||
|
||
def _loose(node, env: dict, master) -> bool:
|
||
"""수량식 맨 위가 덧셈·뺄셈이면(곱 뒤에 오면 괄호가 필요함) · 만약은 걸린 갈래 기준."""
|
||
if node[0] == "bin":
|
||
return _PREC[node[1]] < _PREC["*"]
|
||
if node[0] == "call" and node[1] == "만약":
|
||
try:
|
||
at = 1 if mf.evaluate(node[2][0], env, master, 0) else 2
|
||
except _CALC_ERRORS:
|
||
return False # 이름 꼴은 「만약(…)」 부르기 그대로라 괄호 불필요
|
||
return _loose(node[2][at], env, master)
|
||
return False
|
||
|
||
|
||
def _times(text: str, item: dict, env: dict, master) -> str:
|
||
"""「단가 * 수량식」 의 수량식 — 덧셈·뺄셈 묶음이면 괄호."""
|
||
return f"({text})" if _loose(mf.parse(item["수량"]), env, master) else text
|
||
|
||
|
||
def _line_text(item: dict, line: dict, env: dict, master, part: str | None) -> str:
|
||
"""「단가 * 수량식 = 금액」 — 비목이 나뉜 로직 줄은 그 비목 몫만 · 빈 줄(금액 null)은 빈 글."""
|
||
if line["금액"] is None:
|
||
return ""
|
||
qty = _times(render(mf.parse(item["수량"]), env, master), item, env, master)
|
||
money = line["비목"][part] if part else line["금액"]
|
||
price = line["단가"] if part is None or line["수량"] == 0 else money / line["수량"]
|
||
return f"{fmt_price(price)} * {qty} = {fmt_money(money)}"
|
||
|
||
|
||
def _item_names(items: list) -> list[tuple[str, str]]:
|
||
"""호표 줄마다 (짧은 품명(규격·코드 뗌), 겹침 꼬리) — 한 로직 안에서 겹치면 꼬리 `_2`."""
|
||
taken: set[str] = set()
|
||
out = []
|
||
for item in items:
|
||
name = mc.short(item.get("이름")) or "값"
|
||
out.append((name, mc._free(name, taken)[len(name) :]))
|
||
return out
|
||
|
||
|
||
def _line_names(
|
||
item: dict, env: dict, master, part: str | None, name_of: dict, label: tuple[str, str]
|
||
) -> str:
|
||
"""같은 줄을 변수 이름으로 — 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」."""
|
||
name, dup = label
|
||
tail = PRICE_TAIL.get(str(item.get("종류") or ""), "단가")
|
||
price = f"{name}{part or ''}{tail}{dup}"
|
||
money = f"{name}{part}{dup}" if part else f"{name}비{dup}"
|
||
qty = _times(_names(item["수량"], env, master, name_of), item, env, master)
|
||
return f"{price} * {qty} = {money}"
|
||
|
||
|
||
def _extra_text(extra: dict, line: dict, env: dict, master) -> str:
|
||
if line["금액"] is None: # 빈 덧줄 — 식을 다시 셈하지 않음
|
||
return ""
|
||
return f"{render(mf.parse(extra['식']), env, master)} = {fmt_money(line['금액'])}"
|
||
|
||
|
||
def _extra_names(extra: dict, env: dict, master, name_of: dict) -> str:
|
||
got = mc.short(extra.get("이름")) or "덧줄"
|
||
return f"{_names(extra['식'], env, master, name_of)} = {got}"
|
||
|
||
|
||
def _names(text, env: dict, master, name_of: dict) -> str:
|
||
"""식 글 한 줄을 이름 꼴로 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음."""
|
||
real = mf.parse(text)
|
||
try:
|
||
show = mf.parse(mc.mask(text, name_of))
|
||
except mf.FormulaError: # 별칭을 씌우면 못 읽는 글 — 값 꼴로 물러남
|
||
show = real
|
||
return named(show, real, env, master)
|
||
|
||
|
||
def lines(files: dict, key: str, given: dict) -> dict:
|
||
"""로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나.
|
||
호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고
|
||
`글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림)."""
|
||
master = mf.Master(files)
|
||
row = master.logic(key)
|
||
env = _env_of(master, row, given)
|
||
marks = mc.aliases(row, master)
|
||
name_of = mc.names(marks)
|
||
alias_by_name = _alias_index(marks)
|
||
try:
|
||
result = mf.run(master, key, given)
|
||
except _CALC_ERRORS:
|
||
result = None
|
||
|
||
if "결과" in row:
|
||
조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name)
|
||
got = mc.short(row.get("이름")) or "결과"
|
||
shown = (
|
||
fmt_money if str(row.get("결과단위") or "").startswith("원") else fmt
|
||
) # 돈 결과만 정수
|
||
이름글 = f"{_names(row['결과']['식'], env, master, name_of)} = {got}"
|
||
if result is not None:
|
||
one = {
|
||
"이름": row.get("이름") or "",
|
||
"글": f"{render(mf.parse(row['결과']['식']), env, master)} = "
|
||
f"{shown(result['결과'])}",
|
||
"이름글": 이름글,
|
||
"금액": result["결과"],
|
||
"조각": 조각,
|
||
}
|
||
group = {
|
||
"비목": "결과",
|
||
"줄": [one],
|
||
"소계": result["결과"],
|
||
"끝수": _cut_note(row, "결과"),
|
||
}
|
||
계 = result["결과"]
|
||
else:
|
||
one = {
|
||
"이름": row.get("이름") or "",
|
||
"글": "",
|
||
"이름글": 이름글,
|
||
"금액": None,
|
||
"조각": 조각,
|
||
}
|
||
group = {"비목": "결과", "줄": [one], "소계": None, "끝수": ""}
|
||
계 = None
|
||
return _paired({"ok": True, "groups": [group], "계": 계, "별칭": marks})
|
||
|
||
made: dict[str, list] = {name: [] for name in mf.COST_ITEMS}
|
||
items = row.get("호표") or []
|
||
labels = _item_names(items)
|
||
if result is not None:
|
||
shown = result["줄"]
|
||
env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]}
|
||
for item, line, label in zip(items, shown, labels):
|
||
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
|
||
요소 = _item_pieces(item, env, master)
|
||
for part, money in _shares(line):
|
||
split = part if len(line["비목"]) > 1 else None
|
||
one = {
|
||
"이름": _label(item, line),
|
||
"글": _line_text(item, line, env, master, split),
|
||
"이름글": _line_names(item, env, master, split, name_of, label),
|
||
"금액": money,
|
||
"조각": 조각,
|
||
"요소조각": 요소,
|
||
}
|
||
why = _why(item, line)
|
||
made[part].append({**one, "까닭": why} if why else one)
|
||
_extras(row, shown[len(items) :], made, env, master, name_of, alias_by_name)
|
||
소계 = {name: result[name] for name in mf.COST_ITEMS}
|
||
끝수 = _cut_note(row)
|
||
계 = result["계"]
|
||
else:
|
||
for item, label in zip(items, labels):
|
||
parts = (
|
||
list(mf.COST_ITEMS)
|
||
if item.get("종류") == "로직" and "비목" not in item
|
||
else [item.get("비목")]
|
||
)
|
||
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
|
||
요소 = _item_pieces(item, env, master)
|
||
for part in parts:
|
||
if part not in mf.COST_ITEMS:
|
||
continue
|
||
split = part if len(parts) > 1 else None
|
||
made[part].append(
|
||
{
|
||
"이름": _label(item, {}),
|
||
"글": "",
|
||
"이름글": _line_names(item, env, master, split, name_of, label),
|
||
"금액": None,
|
||
"조각": 조각,
|
||
"요소조각": 요소,
|
||
}
|
||
)
|
||
for extra in row.get("덧줄") or []:
|
||
made[extra["비목"]].append(
|
||
{
|
||
"이름": extra["이름"],
|
||
"글": "",
|
||
"이름글": (
|
||
f"{_names(extra['식'], env, master, name_of)} = "
|
||
f"{mc.short(extra.get('이름')) or '덧줄'}"
|
||
),
|
||
"금액": None,
|
||
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
|
||
}
|
||
)
|
||
소계 = dict.fromkeys(mf.COST_ITEMS, None)
|
||
끝수 = ""
|
||
계 = None
|
||
|
||
groups = [
|
||
{"비목": name, "줄": made[name], "소계": 소계[name], "끝수": 끝수}
|
||
for name in mf.COST_ITEMS
|
||
if made[name]
|
||
]
|
||
return _paired({"ok": True, "groups": groups, "계": 계, "별칭": marks})
|
||
|
||
|
||
def _arg_text(node, env: dict, master) -> str:
|
||
"""로직 부르기 인자 값을 사람이 읽는 글로 — 요소 키는 이름 · 입력 자리는 지금 값."""
|
||
try:
|
||
value = mf.evaluate(node, env, master, 0)
|
||
except _CALC_ERRORS: # 값 없는 입력 자리 — 이름만
|
||
return str(node[1]) if node[0] in ("name", "str") else ""
|
||
if isinstance(value, str):
|
||
for ref in (value, f"EQ:{value}"): # 기계 입력은 원문번호(0602-0025)
|
||
try:
|
||
return mc.plain(master.get(ref).get("이름")) or value
|
||
except mf.FormulaError:
|
||
pass
|
||
return value
|
||
return fmt(value)
|
||
|
||
|
||
def _item_pieces(item: dict, env: dict, master) -> list[dict]:
|
||
"""줄의 단가 자리(요소 · 로직 부르기) 조각 — PLAN 3-1 요소조각."""
|
||
ref = item.get("요소")
|
||
if item.get("종류") != "로직":
|
||
name = str(item.get("이름") or "")
|
||
if isinstance(ref, dict): # 재료 고르기 조건 — 품명만
|
||
return [{"kind": "요소", "글": name}]
|
||
piece = _elem_piece(master, ref)
|
||
return [{**piece, "글": name or piece["글"]}]
|
||
try:
|
||
node = mf.parse(ref)
|
||
key = mf.fill(node[1], env)
|
||
name = str(master.logic(key).get("이름") or key)
|
||
except _CALC_ERRORS:
|
||
return [{"kind": "로직", "글": str(item.get("이름") or "")}]
|
||
args = " · ".join(f"{k} {_arg_text(v, env, master)}" for k, v in node[2])
|
||
out = [{"kind": "로직", "글": name, "참조": {"테이블": key[:2], "키": key}}]
|
||
return out + ([{"kind": "글", "글": args}] if args else [])
|
||
|
||
|
||
def _paired(answer: dict) -> dict:
|
||
"""줄마다 짝 번호 — 두 칸(이름 꼴 · 값 꼴)이 같은 차례로 놓이게 1 부터."""
|
||
at = 0
|
||
for group in answer["groups"]:
|
||
for one in group["줄"]:
|
||
at += 1
|
||
one["짝"] = at
|
||
one.setdefault("요소조각", [])
|
||
return answer
|
||
|
||
|
||
def _extras(
|
||
row: dict, shown: list, made: dict, env: dict, master, name_of: dict, alias_by_name: dict
|
||
) -> None:
|
||
"""덧줄 — 앞선 줄까지의 비목 합을 넣고 한 줄씩(식 안의 비목 이름은 그 수로 바뀜)."""
|
||
sums = {
|
||
name: sum((one["금액"] or 0 for one in made[name]), Decimal(0)) for name in mf.COST_ITEMS
|
||
}
|
||
for extra, line in zip(row.get("덧줄") or [], shown):
|
||
env.update(sums)
|
||
one = {
|
||
"이름": extra["이름"],
|
||
"글": _extra_text(extra, line, env, master),
|
||
"이름글": _extra_names(extra, env, master, name_of),
|
||
"금액": line["금액"],
|
||
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
|
||
}
|
||
made[extra["비목"]].append(one)
|
||
if line["금액"] is not None: # 빈 덧줄은 합·이름 값에 안 넣음
|
||
sums[extra["비목"]] += line["금액"]
|
||
env[extra["이름"]] = line["금액"]
|
||
|
||
|
||
def _cut_note(row: dict, on: str = "계") -> str:
|
||
"""소계에 붙일 끝수 한 마디 — 끝수는 따로 줄로 보이지 않음."""
|
||
rule = row.get("끝수")
|
||
if not isinstance(rule, dict) or rule.get("대상") != on:
|
||
return ""
|
||
place = int(rule.get("자리") or 0)
|
||
where = "원 미만" if place == 0 else f"소수 {place + 1}째 자리에서"
|
||
return f"{where} {rule.get('방법')}"
|