Files
Aislo/resources/master_data/scripts/master_equation.py
eomsangdonandClaude Opus 5.5 2376e70fbb revert(802-1): 801-1 wip 옛 나무 위 고침 걷음 — 요구 664 수식 문서로 다시 짬
- 걷음: 43784a4c0 의 옛 수식 나무 위 고침(서버 · 화면 · css) = 그 앞 판으로 되돌림
- 남김: master_equation_sign.py · ui_template_equation_text.ts · ui_template_equation_edit.ts(다시 쓸 조각 · 지금 안 부름) · text 는 `입력글` 을 제 타입에
- 지움: test_801_1_equation_inline.py — 걷은 옛 나무 고침을 재는 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:55:58 +09:00

523 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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_equation_group as grp
import master_equation_view as view
import master_formula as mf
import master_material as mm
import master_symbol as msym
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 me_keyboard(ctx: _Ctx, node) -> str:
"""조건 식 마디 → 키보드 한 줄 글(변수 = 기호) — 정의 영역 「조건 규격 = 폼타이규격」."""
import master_equation_edit as me
return me.keyboard_of(_raw(node), ctx.row)
def _find(ctx: _Ctx, node, column: str) -> dict:
"""찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행."""
raw = _raw(("field", node, column))
more = {"식": raw, "칸": column}
more["조건식"] = {k: me_keyboard(ctx, c) for k, c in node[2]}
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 subtotals(logic: dict, cut: dict, total: dict) -> dict:
"""호표 줄 → 소계 묶음(799-1 · 801-3 · 보이기만) — 부른 단가산출 = 묶음 하나씩 · 직접 줄 = 한 묶음 + 끝수.
값 = `master_cut.engine` 이 버린 줄 비목(0.1 원) 그대로 · 합계 = 엔진 비목 합(1 원 버림) ·
끝수 = 합계 − 묶음 합(금액 셈은 안 바꿈 · 사용자 확정 뒤에 정함). 묶음 꼴 = `master_equation_group`.
"""
items = logic.get("호표") or []
skip = mcut.ms.skipped(logic) if mcut.ms.uses(logic) else set()
rows: list[dict] = []
for n, line in enumerate(cut.get("줄") or []):
if n in skip:
continue
split = {k: line.get("비목", {}).get(k) or Decimal(0) for k in mf.COST_ITEMS}
item = items[n] if n < len(items) else {}
kind = "블록" if item.get("종류") == "로직" else "자원" if item else "덧줄"
rows.append(
{"갈래": kind, "이름": str(item.get("이름") or ""), "종류": item.get("종류"), **split}
| {"계": sum(split.values(), Decimal(0))}
)
got = grp.bundles(rows)
rest = {
k: (total.get(k) or Decimal(0)) - sum((one[k] for one in got["줄"]), Decimal(0))
for k in mf.BUNDLE
}
return {"줄": got["줄"], "끝수": rest}
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 = {}
sub: 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}
if depth == 0:
sub = {"소계": subtotals(row, cut, total)}
out = msym.apply( # 기호 먼저(800-1) → 정의는 기호 씌운 줄로(800-2)
{
"열쇠": row.get("키"),
"이름": row.get("이름") or "",
"결과단위": row.get("결과단위") or "",
"줄": out_lines,
"풀이": list(ctx.used.values()),
"변수표": table(row, master),
"합계": total,
**sub,
}
)
if depth == 0:
out["정의"] = view.definitions(out_lines, row, out["변수표"], _unused(row))
return _clean(out)
def _unused(row: dict):
"""열쇠 → 어느 식에도 안 쓰이나(정의 영역 · 지울 수 있는 입력)."""
import master_equation_edit as me
return lambda key: key != mm.REGION and not me.uses(row, key)
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 {}),
}