From a5d8b53b2f995f2c768e38edc9c3501a39168430 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 24 Sep 2026 08:59:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01):=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=8B=9D=20=EC=A1=B0=EA=B0=81=20=EA=B8=80=20=E2=80=94?= =?UTF-8?q?=20=EC=B0=BE=EC=9D=80=20=EC=A4=84=C2=B7=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=EA=B0=92=20=EC=9D=B4=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EB=A7=8C=EC=95=BD=20=EC=89=AC=EC=9A=B4=20=EB=A7=90=EA=B3=BC=20?= =?UTF-8?q?=ED=9D=90=EB=A6=BC=20=C2=B7=20=EB=8F=88=20=EC=A0=95=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 조각 글 = 「찾은 줄 · 칸」(합판 · 1회사용) · 참조.행 = 찾기 조건 - 중간값(재료율 · 인력율)은 글에 이름 · 값은 별도 칸 - 만약 = 「폼타이 가 사용 이면 A · 아니면 B」 · 안 걸린 갈래 흐림 · 조건 입력이 비면 두 갈래 다 - 조건 입력이 비어도 이름 식이 멈추지 않음(값 없이 1,354 중 멈춤 180 → 0) - 글 안의 돈(단가 · 금액)은 정수 · 계산 값은 소수 그대로 검증: test_m01_text 21개 통과 · GF000160 표 조각 일곱 줄 글 모두 다름 · GF000219 금액 앞과 같음. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ACe3CR4gBWhWUN5p44fLe4 --- resources/master_data/scripts/master_text.py | 110 ++++++++++++++----- resources/tester/test_m01_text.py | 74 +++++++++++-- 2 files changed, 147 insertions(+), 37 deletions(-) diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index 49b69247..b08fd440 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -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["결과"], "조각": 조각, diff --git a/resources/tester/test_m01_text.py b/resources/tester/test_m01_text.py index 36b2d5cd..82868f86 100644 --- a/resources/tester/test_m01_text.py +++ b/resources/tester/test_m01_text.py @@ -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") # 값은 그대로