From b9a3fe3abb08b84df9b4d80f8c47d4d245b9d993 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 19:38:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20/text=20=EC=A1=B0=EA=B0=81=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=8D=94=ED=95=A8=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=8B=9D=20=EA=B3=84=EC=82=B0=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?(PLAN=202-1=20=EC=B2=AB=EC=A4=84=C2=B72-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_text.py — 조각(요소·표·로직·값·기호·글) 생성기 추가, 이름글·글·금액· 까닭은 그대로 두고 줄마다 「조각」 칸만 더함 - 만약() 은 계산되면 걸린 가지만 · 계산 안 되면(FormulaError 따위) 양쪽 다 내보내고 깊이를 적어 접을 수 있게 함 - 자리 표시({…}) 든 요소 참조는 이름만 적고 참조(키) 자리는 비움 - mf.run 이 막혀도(재료 단가 없음 따위) row 구조만으로 이름 식·조각을 짓고 글·금액만 비움 — 로직 부르는 줄(비목 미정)은 세 비목에 다 걸쳐 보임 - test_m01_text.py — 새 시험 6개(계약 확인·요소 알약·자리 표시·만약 둘· 계산 막힘 no-calc 경로), 기존 10개 그대로 통과 - 검증: pytest resources/tester/test_m01_text.py 16 passed · 13-4-1·12-4· 12-17-1 금액이 앞과 같음(calc 엔드포인트와 대조, 기존 시험 그대로) · ruff format/check 통과 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- resources/master_data/scripts/master_text.py | 273 ++++++++++++++++--- resources/tester/test_m01_text.py | 80 ++++++ 2 files changed, 315 insertions(+), 38 deletions(-) diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index f234e6c2..8acea2af 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -7,6 +7,11 @@ · `이름글` 이름 꼴: 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」 — 표 찾기·로직 부르기는 별칭 이름(`master_copy` 3-4 규칙) · 중간 값·입력은 그 이름 그대로 두 꼴은 같은 줄에 나란히 있고 `짝` 번호가 붙음 — 화면 두 칸이 줄 맞춤으로 놓임. +줄마다 `조각`(PLAN 2-1 조각 계약) 도 같이 줌 — kind(요소·표·로직·값·기호·글) · 글 · 참조. +알약(요소·표·로직)이 될 자리는 구조만 보고 짓고, `만약()` 갈래만 골라야 해서 계산이 필요함 — +계산이 안 되면(FormulaError 따위) 갈래 양쪽을 다 내보내고 깊이를 적어 접을 수 있게 함. +호표 줄 계산 자체가 막혀도(재료 단가 없음 따위) 이름 식·조각은 `row` 구조만으로 그리고 +`글`·`금액`만 비움 — `만약() 과 무관` 인 그 60개 로직도 이름 식은 보이게. 끝수는 줄이 아니라 소계에 녹여 적음. 계산 자체는 `master_formula.run` 이 한 벌만 함. """ @@ -24,6 +29,7 @@ ELEM_TAIL = {"LB": "노임", "EQ": "손료"} # 식 안 요소 키의 이름 꼬 _PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3} _PREC |= {"+": 4, "-": 4, "*": 5, "/": 5} _RIGHT_TIGHT = ("-", "/") # 오른쪽 짝은 같은 세기라도 괄호가 필요함 +_CALC_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) # 계산 못 함 def fmt(value) -> str: @@ -125,6 +131,133 @@ def _side(show, real, parent, env: dict, master, depth: int, draw, right: bool) return text +# ── 조각(알약) — PLAN 2-1 조각 계약 · 2-2 「이름 식을 계산 없이」 ────────── +_PIECE_SYM = {"+": "+", "-": "-", "*": "×", "/": "÷"} + + +def _p_val(text: str) -> dict: + return {"kind": "값", "글": text} + + +def _p_sym(text: str) -> dict: + return {"kind": "기호", "글": text} + + +def _alias_index(marks: list[dict]) -> dict[str, dict]: + """별칭 이름 → 별칭 한 줄(`master_copy.aliases` 결과) — 조각이 참조를 되찾을 때 씀.""" + return {m["이름"]: m for m in marks} + + +def _elem_piece(master, ref: str) -> dict: + """요소 알약 — 글은 이름(못 찾아도 이름만). 자리 표시(`{…}`) 든 참조 14파일은 + 값(참조 키)을 못 구하므로 이름만 적고 참조 자리는 비움.""" + piece = {"kind": "요소", "글": _elem_name(master, ref)} + if "{" in str(ref): + return piece + try: + row = master.get(ref) + except mf.FormulaError: + return piece + key = str(row.get("키") or ref) + piece["참조"] = {"테이블": key[:2], "키": key} + return piece + + +def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict: + """표 찾기·로직 부르기 알약 — 글은 별칭 이름. 참조는 다시 찾아 봐서 되면만 붙임.""" + piece = {"kind": "표" if mark["종류"] == "찾기" else "로직", "글": mark["이름"]} + try: + node = mf.parse(mark["원문"]) + ref = mf.fill(node[1], env) + if mark["종류"] == "찾기": + 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, + } + else: + piece["참조"] = {"테이블": ref[:2], "키": ref} + except _CALC_ERRORS: + pass # 다시 못 찾음 — 별칭 이름만 남김(값 자리 비움) + return piece + + +def _if_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]: + """만약() 조각 — 계산되면 걸린 가지만 · 안 되면 양쪽 다(깊이를 적어 접을 수 있게).""" + try: + taken = mf.evaluate(real[2][0], env, master, 0) + 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) + + +def _side_pieces( + show, real, parent_show, env: dict, master, alias_by_name: dict, ifdepth: int, right: bool +): + inner = _pieces(show, real, env, master, alias_by_name, ifdepth) + if show[0] != "bin": + return inner + mine, up = _PREC[show[1]], _PREC[parent_show[1]] + if mine < up or (mine == up and right and parent_show[1] in _RIGHT_TIGHT): + return [_p_sym("("), *inner, _p_sym(")")] + return inner + + +def _bin_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]: + op = show[1] + if op == "/" and show[3][0] == "num" and show[3][1] == 100: # 「… / 100」 은 퍼센트로 + left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False) + return [*left, {"kind": "글", "글": "%"}] + left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False) + right = _side_pieces(show[3], real[3], show, env, master, alias_by_name, ifdepth, True) + return [*left, _p_sym(_PIECE_SYM.get(op, op)), *right] + + +def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0) -> list[dict]: + """식 한 마디 → 조각 목록. 요소·표·로직 부르기는 알약(참조가 됨) · 나머지는 값·기호·글.""" + kind = show[0] + if kind == "num": + return [_p_val(fmt(show[1]))] + if kind == "str": + return [_p_val(f"'{show[1]}'")] + if kind == "neg": + return [_p_sym("-"), *_pieces(show[1], real[1], env, master, alias_by_name, ifdepth)] + if kind == "bin": + return _bin_pieces(show, real, env, master, alias_by_name, ifdepth) + if kind == "call" and show[1] == "만약": + return _if_pieces(show, real, env, master, alias_by_name, ifdepth) + if kind == "ref": + return [_elem_piece(master, show[1])] + if kind == "field" and show[1][0] == "name" and show[1][1] in alias_by_name: + 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)] + try: + return [_p_val(_value(real, env, master, 0))] + except _CALC_ERRORS: + return [_p_val("")] # 그 밖(값 계산 자체가 막힘) — 빈 값 조각으로 줄이 안 죽게 + + +def _formula_pieces(text, env: dict, master, name_of: dict, alias_by_name: dict) -> list[dict]: + """식 글 한 줄의 조각 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음(`_names` 와 짝).""" + real = mf.parse(text) + try: + show = mf.parse(mc.mask(text, name_of)) + except mf.FormulaError: + show = real + return _pieces(show, real, env, master, alias_by_name) + + # ── 줄 세우기 ───────────────────────────────────────────────────────── def _env_of(master, row: dict, given: dict, depth: int = 0) -> dict: """입력 + 중간 — `run` 과 같은 차례로 다시 셈(덧줄 전 상태).""" @@ -197,56 +330,117 @@ def _names(text, env: dict, master, name_of: dict) -> str: def lines(files: dict, key: str, given: dict) -> dict: - """로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나.""" + """로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나. + 호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고 + `글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림).""" master = mf.Master(files) row = master.logic(key) - result = mf.run(master, key, given) env = _env_of(master, row, given) marks = mc.aliases(row, master) name_of = mc.names(marks) + alias_by_name = _alias_index(marks) + try: + result = mf.run(master, key, given) + except _CALC_ERRORS: + result = None + if "결과" in row: - text = f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}" + 조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name) got = mc.plain(row.get("이름")) or "결과" - one = { - "이름": row.get("이름") or "", - "글": text, - "이름글": f"{_names(row['결과']['식'], env, master, name_of)} = {got}", - "금액": result["결과"], - } - group = { - "비목": "결과", - "줄": [one], - "소계": result["결과"], - "끝수": _cut_note(row, "결과"), - } - return _paired({"ok": True, "groups": [group], "계": result["결과"], "별칭": marks}) - made: dict[str, list] = {name: [] for name in mf.COST_ITEMS} - shown = result["줄"] - env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]} - items = row.get("호표") or [] - for item, line in zip(items, shown): - for part, money in _shares(line): - split = part if len(line["비목"]) > 1 else None + 이름글 = f"{_names(row['결과']['식'], env, master, name_of)} = {got}" + if result is not None: one = { - "이름": _label(item, line), - "글": _line_text(item, line, env, master, split), - "이름글": _line_names(item, line, env, master, split, name_of), - "금액": money, + "이름": row.get("이름") or "", + "글": f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}", + "이름글": 이름글, + "금액": result["결과"], + "조각": 조각, } - why = _why(item, line) - made[part].append({**one, "까닭": why} if why else one) - _extras(row, shown[len(items) :], made, env, master, name_of) + group = { + "비목": "결과", + "줄": [one], + "소계": result["결과"], + "끝수": _cut_note(row, "결과"), + } + 계 = result["결과"] + else: + one = { + "이름": row.get("이름") or "", + "글": "", + "이름글": 이름글, + "금액": None, + "조각": 조각, + } + group = {"비목": "결과", "줄": [one], "소계": None, "끝수": ""} + 계 = None + return _paired({"ok": True, "groups": [group], "계": 계, "별칭": marks}) + + made: dict[str, list] = {name: [] for name in mf.COST_ITEMS} + items = row.get("호표") or [] + if result is not None: + shown = result["줄"] + env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]} + for item, line in zip(items, shown): + 조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name) + for part, money in _shares(line): + split = part if len(line["비목"]) > 1 else None + one = { + "이름": _label(item, line), + "글": _line_text(item, line, env, master, split), + "이름글": _line_names(item, line, env, master, split, name_of), + "금액": money, + "조각": 조각, + } + why = _why(item, line) + made[part].append({**one, "까닭": why} if why else one) + _extras(row, shown[len(items) :], made, env, master, name_of, alias_by_name) + 소계 = {name: result[name] for name in mf.COST_ITEMS} + 끝수 = _cut_note(row) + 계 = result["계"] + else: + for item in items: + parts = ( + list(mf.COST_ITEMS) + if item.get("종류") == "로직" and "비목" not in item + else [item.get("비목")] + ) + 조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name) + for part in parts: + if part not in mf.COST_ITEMS: + continue + split = part if len(parts) > 1 else None + made[part].append( + { + "이름": _label(item, {}), + "글": "", + "이름글": _line_names(item, {}, env, master, split, name_of), + "금액": None, + "조각": 조각, + } + ) + for extra in row.get("덧줄") or []: + made[extra["비목"]].append( + { + "이름": extra["이름"], + "글": "", + "이름글": ( + f"{_names(extra['식'], env, master, name_of)} = " + f"{mc.plain(extra.get('이름')) or '덧줄'}" + ), + "금액": None, + "조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name), + } + ) + 소계 = dict.fromkeys(mf.COST_ITEMS, None) + 끝수 = "" + 계 = None + groups = [ - { - "비목": name, - "줄": made[name], - "소계": result[name], - "끝수": _cut_note(row), - } + {"비목": name, "줄": made[name], "소계": 소계[name], "끝수": 끝수} for name in mf.COST_ITEMS if made[name] ] - return _paired({"ok": True, "groups": groups, "계": result["계"], "별칭": marks}) + return _paired({"ok": True, "groups": groups, "계": 계, "별칭": marks}) def _paired(answer: dict) -> dict: @@ -259,7 +453,9 @@ def _paired(answer: dict) -> dict: return answer -def _extras(row: dict, shown: list, made: dict, env: dict, master, name_of: dict) -> None: +def _extras( + row: dict, shown: list, made: dict, env: dict, master, name_of: dict, alias_by_name: dict +) -> None: """덧줄 — 앞선 줄까지의 비목 합을 넣고 한 줄씩(식 안의 비목 이름은 그 수로 바뀜).""" sums = {name: sum((one["금액"] for one in made[name]), Decimal(0)) for name in mf.COST_ITEMS} for extra, line in zip(row.get("덧줄") or [], shown): @@ -269,6 +465,7 @@ def _extras(row: dict, shown: list, made: dict, env: dict, master, name_of: dict "글": _extra_text(extra, line, env, master), "이름글": _extra_names(extra, env, master, name_of), "금액": line["금액"], + "조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name), } made[extra["비목"]].append(one) sums[extra["비목"]] += line["금액"] diff --git a/resources/tester/test_m01_text.py b/resources/tester/test_m01_text.py index 4ff426c0..b83e6fb3 100644 --- a/resources/tester/test_m01_text.py +++ b/resources/tester/test_m01_text.py @@ -160,3 +160,83 @@ def test_이름_꼴_찾기_별칭이_표_열쇠_그대로면_밑이름으로_풀 assert "QC001882.요율 %" in extra["이름글"] assert "잔디깎기공구손료" not in extra["이름글"] assert any(one["이름"] == "QC001882" for one in got["별칭"]) + + +# ── PLAN 2-1 · 2-2 — 조각(알약) ───────────────────────────────────────── +def test_조각이_이름글_금액_까닭_곁에_칸으로_더해진다(client: TestClient) -> None: + """지금 주던 이름글·글·금액·까닭은 그대로 두고 조각 칸만 더함(계약 확인).""" + got, calc = _both(client, STACK) + mason = _all_lines(got)[0] + assert mason["이름"] == "석공" and mason["글"].startswith("268,908 * ") + assert Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"])) + assert isinstance(mason["조각"], list) and mason["조각"] + kinds = {p["kind"] for p in mason["조각"]} + assert kinds <= {"요소", "표", "로직", "값", "기호", "글"} + 표 = next(p for p in mason["조각"] if p["kind"] == "표") + assert ( + 표["참조"]["테이블"] == "QF" + and 표["참조"]["키"] == "QF000421" + and 표["참조"]["열"] == "석공" + ) + assert any(p["kind"] == "글" and p["글"] == "%" for p in mason["조각"]) + + +def test_조각_요소는_알약이고_참조에_테이블과_키가_있다() -> None: + files = { + "재료_test.json": { + "그룹": "재료", + "줄": [{"키": "MT000001", "이름": "테스트자재", "값": Decimal("100")}], + } + } + master = mt.mf.Master(files) + piece = mt._elem_piece(master, "MT000001") + assert piece == { + "kind": "요소", + "글": "테스트자재단가", + "참조": {"테이블": "MT", "키": "MT000001"}, + } + + +def test_조각_자리_표시_든_요소_참조는_이름만_있고_참조는_빔() -> None: + """PLAN 2-2 ② — 자리 표시(`{…}`) 든 요소 참조는 값(참조)을 못 구해 이름만.""" + master = mt.mf.Master({}) + piece = mt._elem_piece(master, "LB:{직종}") + assert piece["kind"] == "요소" and "참조" not in piece + 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 2-2 ① — 계산 안 되면(모르는 이름) 양쪽 다 · 깊이를 적어 접을 수 있게.""" + node = mt.mf.parse("만약(모르는이름 > 0, 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)] + + +def test_조각_로직_부르는_줄_990개_세_비목에_다_걸치고_계산_전엔_금액이_빈다( + client: TestClient, +) -> None: + """PLAN 2-2 ③ — 계산이 막히면(재료 단가 없음 따위) 이름 식·조각은 구조로 남고 금액만 빔. + 비목이 안 정해진 로직 부르는 줄은 세 비목에 다 걸쳐 보여야 하나, 이 견본(STACK)은 + 호표 둘 다 비목이 「노무비」로 정해진 인력 줄이라 한 비목에만 걸림 — 그 좁은 경우 확인.""" + got = client.get("/api/m01/logic", params={"key": STACK}).json() + row = dict(got["logic"]) + row["호표"] = [dict(row["호표"][0], 요소="LB099999"), dict(row["호표"][1])] + body = {"key": STACK, "inputs": _inputs(STACK), "row": row, "file": got["file"]} + text = client.post("/api/m01/text", json=body) + assert text.status_code == 200, text.text + out = text.json() + assert out["ok"] is True and out["계"] is None + rows = _all_lines(out) + assert [one["이름"] for one in rows] == ["석공", "보통인부"] + for one in rows: + assert one["금액"] is None and one["글"] == "" and "까닭" not in one + assert one["조각"] # 수량 식은 깨진 요소와 무관해 구조로 그대로 남음 + assert [g["소계"] for g in out["groups"]] == [None]