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:
@@ -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["결과"],
|
||||
"조각": 조각,
|
||||
|
||||
@@ -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") # 값은 그대로
|
||||
|
||||
Reference in New Issue
Block a user