feat(m01): 텍스트 수식 조각 글 — 찾은 줄·칸 · 중간값 이름 · 만약 쉬운 말과 흐림 · 돈 정수

- 표 조각 글 = 「찾은 줄 · 칸」(합판 · 1회사용) · 참조.행 = 찾기 조건
- 중간값(재료율 · 인력율)은 글에 이름 · 값은 별도 칸
- 만약 = 「폼타이 가 사용 이면 A · 아니면 B」 · 안 걸린 갈래 흐림 · 조건 입력이 비면 두 갈래 다
- 조건 입력이 비어도 이름 식이 멈추지 않음(값 없이 1,354 중 멈춤 180 → 0)
- 글 안의 돈(단가 · 금액)은 정수 · 계산 값은 소수 그대로

검증: test_m01_text 21개 통과 · GF000160 표 조각 일곱 줄 글 모두 다름 · GF000219 금액 앞과 같음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACe3CR4gBWhWUN5p44fLe4
This commit is contained in:
2026-09-24 08:59:25 +09:00
co-authored by Claude Sonnet 5
parent 6eaa539fde
commit a5d8b53b2f
2 changed files with 147 additions and 37 deletions
+84 -26
View File
@@ -17,7 +17,7 @@
from __future__ import annotations
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
import master_copy as mc
import master_formula as mf
@@ -44,6 +44,13 @@ def fmt(value) -> str:
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 render(node, env: dict, master, depth: int = 0) -> str:
"""식 한 마디 → 값 꼴 글. 값이 정해지는 마디(요소 · 찾기 · 로직 · 이름)는 그 수로 바뀜."""
@@ -82,8 +89,12 @@ def named(show, real, env: dict, master, depth: int = 0) -> str:
return _bin(show, real, env, master, depth, named)
if kind == "call":
if show[1] == "만약": # 값 꼴과 같은 쪽만 적어 두 칸이 짝이 되게
at = 1 if mf.evaluate(real[2][0], env, master, depth) else 2
return named(show[2][at], real[2][at], env, master, depth)
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"):
@@ -163,8 +174,22 @@ def _elem_piece(master, ref: str) -> dict:
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["원문"])
@@ -173,12 +198,9 @@ def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict:
table = master.get(ref)
conds = {k: mf.evaluate(v, env, master, 0) for k, v in node[2]}
row = mf.find(table, conds, column)
piece["참조"] = {
"테이블": ref[:2],
"키": ref,
"행": row.get("키") or row.get("원문번호"),
"열": 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:
@@ -186,19 +208,48 @@ def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict:
return piece
def _if_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
"""만약() 조각 — 계산되면 걸린 가지만 · 안 되면 양쪽 다(깊이를 적어 접을 수 있게)."""
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:
taken = mf.evaluate(real[2][0], env, master, 0)
return named(show, real, env, master)
except _CALC_ERRORS:
deeper = ifdepth + 1
left = _pieces(show[2][1], real[2][1], env, master, alias_by_name, deeper)
right = _pieces(show[2][2], real[2][2], env, master, alias_by_name, deeper)
for one in (*left, *right):
one["깊이"] = deeper
return [*left, _p_sym("/"), *right]
at = 1 if taken else 2
return _pieces(show[2][at], real[2][at], env, master, alias_by_name, ifdepth)
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(
@@ -242,10 +293,13 @@ def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0
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:
return [_p_val(_value(real, env, master, 0))]
value = _value(real, env, master, 0)
piece.update({"값": value} if kind == "name" else {"글": value})
except _CALC_ERRORS:
return [_p_val("")] # 그 밖(값 계산 자체가 막힘) — 빈 값 조각으로 줄이 안 죽게
pass # 값 계산 자체가 막힘 — 이름만(그 밖은 빈 글) 남겨 줄이 안 죽게
return [piece]
def _formula_pieces(text, env: dict, master, name_of: dict, alias_by_name: dict) -> list[dict]:
@@ -303,7 +357,7 @@ def _line_text(item: dict, line: dict, env: dict, master, part: str | None) -> s
qty = render(mf.parse(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)} * {qty} = {fmt(money)}"
return f"{fmt_money(price)} * {qty} = {fmt_money(money)}"
def _line_names(item: dict, line: dict, env: dict, master, part: str | None, name_of: dict) -> str:
@@ -316,7 +370,7 @@ def _line_names(item: dict, line: dict, env: dict, master, part: str | None, nam
def _extra_text(extra: dict, line: dict, env: dict, master) -> str:
return f"{render(mf.parse(extra['식']), env, master)} = {fmt(line['금액'])}"
return f"{render(mf.parse(extra['식']), env, master)} = {fmt_money(line['금액'])}"
def _extra_names(extra: dict, env: dict, master, name_of: dict) -> str:
@@ -352,11 +406,15 @@ def lines(files: dict, key: str, given: dict) -> dict:
if "결과" in row:
조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name)
got = mc.plain(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)} = {fmt(result['결과'])}",
"글": f"{render(mf.parse(row['결과']['식']), env, master)} = "
f"{shown(result['결과'])}",
"이름글": 이름글,
"금액": result["결과"],
"조각": 조각,