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["결과"],
"조각": 조각,
+63 -11
View File
@@ -93,7 +93,10 @@ def test_펌프카는_하위로직이_비목마다_한_줄씩이고_덧줄이_
pump = [one for one in _all_lines(got) if one["이름"] == "콘크리트펌프차"]
assert len(pump) == 3 # 노무비 · 재료비 · 경비 몫이 따로
extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료"))
assert extra[""].endswith(f"= {mt.fmt(Decimal(str(extra['금액'])))}") and "%" in extra[""]
assert (
extra[""].endswith(f"= {mt.fmt_money(Decimal(str(extra['금액'])))}")
and "%" in extra[""]
)
def test_몫이_모두_0인_장비_줄도_글에_남는다(client: TestClient) -> None:
@@ -216,19 +219,25 @@ def test_조각_자리_표시_든_요소_참조는_이름만_있고_참조는_
assert piece[""] # 이름 자리는 비지 않음(못 풀어도 원문 그대로)
def test_조각_만약은_계산되면_걸린_가지만() -> None:
node = mt.mf.parse("만약(1 > 0, 2, 3)")
pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {})
assert pieces == [{"kind": "", "": "2"}]
assert not any("깊이" in p for p in pieces)
def test_조각_만약은_쉬운_말로_안_걸린_갈래가_흐리다() -> None:
"""PLAN 3-3 — 「조건 이면 A · 아니면 B」 · 안 걸린 갈래 조각에 흐림."""
env = {"폼타이": "사용"}
node = mt.mf.parse("만약(폼타이 == '사용', 2.14 / 10, 0)")
pieces = mt._pieces(node, node, env, mt.mf.Master({}), {})
assert [p[""] for p in pieces] == ["폼타이 가 사용 이면", "2.14", "÷", "10", "· 아니면", "0"]
assert [bool(p.get("흐림")) for p in pieces] == [False, False, False, False, True, True]
off = mt._pieces(node, node, {"폼타이": "아님"}, mt.mf.Master({}), {})
assert [bool(p.get("흐림")) for p in off] == [False, True, True, True, False, False]
def test_조각_만약은_계산_안_되면_양쪽_다_깊이가_붙는다() -> None:
"""PLAN 2-2 ① — 계산 안 되면(모르는 이름) 양쪽 다 · 깊이를 적어 접을 수 있게."""
node = mt.mf.parse("만약(모르는이름 > 0, 2, 3)")
def test_조각_만약은_조건_입력이_비면_두_갈래_다_흐림_없음() -> None:
"""조건이 안 셈해지면(모르는 이름) 두 갈래 다 · 흐림 없음 · 깊이를 적어 접을 수 있게."""
node = mt.mf.parse("만약(폼타이 == '사용', 2, 3)")
pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {})
vals = [(p["kind"], p[""], p.get("깊이")) for p in pieces]
assert vals == [("", "2", 1), ("기호", "/", None), ("", "3", 1)]
assert [p[""] for p in pieces] == ["폼타이 가 사용 이면", "2", "· 아니면", "3"]
assert not any("흐림" in p for p in pieces)
assert [p.get("깊이") for p in pieces] == [None, 1, 1, 1]
assert mt.named(node, node, {}, mt.mf.Master({})) == "만약(폼타이 == '사용', 2, 3)"
def test_조각_로직_부르는_줄_990개_세_비목에_다_걸치고_계산_전엔_금액이_빈다(
@@ -251,3 +260,46 @@ def test_조각_로직_부르는_줄_990개_세_비목에_다_걸치고_계산_
assert one["금액"] is None and one[""] == "" and "까닭" not in one
assert one["조각"] # 수량 식은 깨진 요소와 무관해 구조로 그대로 남음
assert [g["소계"] for g in out["groups"]] == [None]
def test_합판거푸집_표_조각_글은_줄마다_다르고_참조에_찾기_조건이_있다(client: TestClient) -> None:
"""PLAN 3-3 — 글 = 「찾은 줄 · 칸」 · 참조.행 = 찾기 조건 · 중간 값은 글에 이름."""
got, _ = _both(client, FORM)
tables = [p for one in _all_lines(got) for p in one["조각"] if p["kind"] == ""]
assert len(tables) == 7 and len({p[""] for p in tables}) == 7
plywood = next(p for p in tables if p[""] == "합판 · 1회사용")
assert plywood["참조"][""] == {"구분": "합판"} and plywood["참조"][""] == "기준수량_1회사용"
rate = next(p for p in _all_lines(got)[0]["조각"] if p["kind"] == "")
assert rate[""] == "인력율" and rate[""] == "1.31"
tie = _all_lines(got)[-1]["조각"]
assert tie[0][""] == "폼타이 가 사용 이면" and [bool(p.get("흐림")) for p in tie][1] is True
def test_값_없이_열어도_텍스트가_멈추지_않는다(client: TestClient) -> None:
got = client.post("/api/m01/text", json={"key": FORM, "inputs": {}}).json()
assert got["ok"] is True
tie = _all_lines(got)[-1]
assert "만약(폼타이 == '사용'" in tie["이름글"] and not any("흐림" in p for p in tie["조각"])
def test_모든_로직을_값_없이_돌려도_멈추지_않는다() -> None:
files = store.cm.load(folder=REAL)
master = store.cm.master(REAL)
stops = []
for rows in master.index.values():
for key in rows:
if key[:2] in mt.mf.mk.GROUP_OF and mt.mf.mk.GROUP_OF[key[:2]] == "로직":
try:
mt.lines(files, key, {})
except mt._CALC_ERRORS as e:
stops.append((key, str(e)))
assert not stops, stops[:3]
def test_글_안의_돈은_정수_계산_값은_소수_그대로(client: TestClient) -> None:
assert mt.fmt_money(Decimal("79482.678")) == "79,483"
assert mt.fmt_money(Decimal("0.4")) == "0"
got, _ = _both(client, FORM)
first = _all_lines(got)[0]
assert first[""] == "275,790 * 0.22 * 1.31 = 79,483" # 수량·비율은 소수 그대로
assert Decimal(str(first["금액"])) == Decimal("79482.678") # 값은 그대로