From a36092fad84064f2e63321cc27eb7f4fac1e0f3a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 23:03:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(753-1):=20=EC=98=B5=EC=85=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=A0=EC=A7=84=20=EC=A4=84=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=E2=80=94=20=EC=97=94=EC=A7=84=20=EC=A4=84=20=EB=8D=A7?= =?UTF-8?q?=EC=B9=B8=20=EC=98=B5=EC=85=98=EA=BA=BC=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_gate: 호표 줄 수량 0 이 만약 간 갈래(상수 0) 탓이면 옵션꺼짐 true (고르기 · 수 입력 문턱 · 중간값 거침) · 입력 0 · 표 칸 0 · 원문 0 = 칸 없음 - master_formula.run 한 곳에서 붙임 → M01 /calc · SK 셈 답 줄 저절로 실림 - master_sheet_line: /text · M02 sheet 줄에 같은 칸(KEYS) - M01 정본 자료 안 고침 · 금액 · 차례 · cid 그대로(돈 로직 1,440 전후 같음) - 지킴 시험 test_753_1_option_off(GC000211 폼타이 · 진짜 0 · 문턱 · 금액 · /text · SK) Co-Authored-By: Claude Opus 5.5 --- .../master_data/scripts/master_formula.py | 2 + resources/master_data/scripts/master_gate.py | 90 +++++++++++++++ .../master_data/scripts/master_sheet_line.py | 6 +- resources/tester/test_753_1_option_off.py | 108 ++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 resources/master_data/scripts/master_gate.py create mode 100644 resources/tester/test_753_1_option_off.py diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 422120a71..ea8ddfa99 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -658,6 +658,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: ) sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1) + mg.tag(master, row, lines, env) # 옵션으로 빠진 줄 `옵션꺼짐`(753-1) return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} @@ -678,6 +679,7 @@ from master_material import ( # noqa: E402, F401 ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음 +import master_gate as mg # noqa: E402 — 옵션으로 빠진 줄 from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 _check_cut, _check_node, diff --git a/resources/master_data/scripts/master_gate.py b/resources/master_data/scripts/master_gate.py new file mode 100644 index 000000000..15dc6548d --- /dev/null +++ b/resources/master_data/scripts/master_gate.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""옵션으로 빠진 줄 — **셈 때 파생**(정본에 칸 없음 · PLAN 753-1 · 요구 588-4). + +엔진 `run` 호표 줄 덧칸 `옵션꺼짐` = True — 그 줄 수량 0 이 조건 `만약` 의 간 갈래(상수 0) 탓 +(폼타이 12.7 · 9.5 · 7.9 셋 중 고른 규격 아닌 줄 · 폼타이 「아님」 이면 셋 다 · +타설길이 문턱으로 고른 장비 아닌 줄). 화면이 숨김(753-2). +셈 · 금액 · 줄 차례 그대로 · 덧칸만. + +가름(0 을 낸 마디를 거슬러 감): +- `만약` = 간 갈래가 상수 0 → 꺼짐(조건 = 고르기 · 수 입력 문턱 · 중간값 모두) · + 아니면 간 갈래 안으로. +- 곱 = 0 인 인수 중 하나라도 꺼짐 · 나눗셈 = 분자 · 더하기 · 빼기 = 두 쪽 다 0 이고 하나라도 꺼짐 · + 끝수 함수 = 첫 인수 · 최소 · 최대 = 0 인 인수 중 꺼짐 · 중간값 이름 = 그 식으로. +- 입력 값 · 표 값 · 요소 · 로직 부름 · 원문 0 = 진짜 0(안 꺼짐 · 구멍땜 개소 0 · 표 칸 0 등). +""" + +from __future__ import annotations + +from decimal import Decimal + +import master_formula as mf + +KEY = "옵션꺼짐" +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError, RecursionError) +_SOURCES = ("name", "find", "field", "ref", "logic") # 값이 입력 · 표에서 오는 마디 + + +class _Trace: + def __init__(self, master, row: dict, env: dict): + self.master, self.env = master, env + self.steps = {s["이름"]: s["식"] for s in row.get("중간") or []} + self.memo: dict[str, bool] = {} + + def zero(self, node) -> bool: + value = mf.evaluate(node, self.env, self.master) + return not isinstance(value, bool) and value == 0 + + def off(self, node, depth: int = 0) -> bool: + """이 마디 값 0 이 옵션 갈래 탓인지.""" + kind = node[0] + if depth > 40: + return False + if kind == "name": + name = node[1] + if name not in self.steps: + return False + if name not in self.memo: + self.memo[name] = False # 돌고 도는 이름 막음 + self.memo[name] = self.off(mf.parse(self.steps[name]), depth + 1) + return self.memo[name] + if kind == "neg": + return self.off(node[1], depth + 1) + if kind == "bin": + sides = node[2:4] + if node[1] == "*": + return any(self.zero(x) and self.off(x, depth + 1) for x in sides) + if node[1] == "/": + return self.off(node[2], depth + 1) + if node[1] in ("+", "-"): + both = all(self.zero(x) for x in sides) + return both and any(self.off(x, depth + 1) for x in sides) + return False + if kind != "call" or not node[2]: + return False + items = node[2] + if node[1] == "만약": + taken = items[1] if mf.evaluate(items[0], self.env, self.master) else items[2] + fixed = not any(x[0] in _SOURCES for x in mf.walk(taken)) + if fixed and self.zero(taken): + return True + return self.off(taken, depth + 1) + if node[1] in ("최소", "최대"): + return any(self.zero(x) and self.off(x, depth + 1) for x in items) + return self.off(items[0], depth + 1) + + +def tag(master, row: dict, lines: list[dict], env: dict) -> list[dict]: + """엔진 `run` 호표 줄(덧줄 앞)에 `옵션꺼짐` — 수량 0 인 줄만 가름 · 못 가름 = 안 붙임.""" + trace = None + for item, line in zip(row.get("호표") or [], lines): + qty = line.get("수량") + if not isinstance(qty, Decimal) or qty != 0: + continue + trace = trace or _Trace(master, row, env) + try: + if trace.off(mf.parse(item["수량"])): + line[KEY] = True + except _ERRORS: + continue + return lines diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py index d567b5554..10b8a3550 100644 --- a/resources/master_data/scripts/master_sheet_line.py +++ b/resources/master_data/scripts/master_sheet_line.py @@ -8,6 +8,7 @@ - `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). - `펼침`(장비 줄만 · 714-1b) = 부른 기계경비 로직의 계산서 줄(조종원 · 주연료 · 잡재료비 · 기계손료 …) · 시간당 비목 소계 · 계 · 부른 키 · 인자 — 셈 결과를 그대로 실음(환산 규칙을 여기 두지 않음). +- `옵션꺼짐` = 엔진 줄 덧칸 그대로(753-1 `master_gate` · 옵션 갈래로 0 인 줄 · 화면이 숨김). - `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). - `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 @@ -23,10 +24,11 @@ from decimal import Decimal import master_basis as mbs import master_bundle as mb import master_formula as mf +import master_gate as mg import master_text as mtx import master_unit as mu -KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침") +KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침", "옵션꺼짐") _HOLE = re.compile(r"\{([^{}]+)\}") _ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) @@ -179,6 +181,8 @@ def annotate(files: dict, key: str, given: dict, answer: dict) -> dict: parts = [item.get("비목")] exprs = [item.get("수량"), item.get("요소")] got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) + if line.get(mg.KEY): # 옵션으로 빠진 줄(753-1 · 엔진 줄 덧칸 그대로) + got[mg.KEY] = True if got.get("장비"): got["펼침"] = _spread(files, master, item, env) for part in parts: diff --git a/resources/tester/test_753_1_option_off.py b/resources/tester/test_753_1_option_off.py new file mode 100644 index 000000000..c01a2f8c2 --- /dev/null +++ b/resources/tester/test_753_1_option_off.py @@ -0,0 +1,108 @@ +"""옵션으로 빠진 줄 표시(PLAN 753-1 · 요구 588-4) — 엔진 줄 덧칸 `옵션꺼짐`. + +M01 셈(`/calc`) · 계산서(`/text` · `master_sheet_line.annotate`) · SK 셈(`M02_Sangeun_Calc.run`)이 +같은 엔진 줄을 씀 · 금액 그대로 · 진짜 0(입력 0 · 표 칸 0)은 표시 없음. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) + +import check_master as cm # noqa: E402 +import master_formula as mf # noqa: E402 +import master_gate as mg # noqa: E402 +import master_sheet_line as msl # noqa: E402 +import master_text as mtx # noqa: E402 +import pytest # noqa: E402 + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402 +from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Calc as calc # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402 + +FORM = "GC000211" # 6-3-1 합판거푸집 — 폼타이 12.7 · 9.5 · 7.9 규격마다 줄 +TIES = ("폼타이 12.7㎜", "폼타이 9.5㎜", "폼타이 7.9㎜") +CASES = [ + ({"폼타이": "아님"}, set(TIES)), + ({"폼타이": "사용", "폼타이규격": "12.7㎜"}, {"폼타이 9.5㎜", "폼타이 7.9㎜"}), + ({"폼타이": "사용", "폼타이규격": "9.5㎜"}, {"폼타이 12.7㎜", "폼타이 7.9㎜"}), +] + + +@pytest.fixture(scope="module") +def master(): + files = cm.load() + return files, mf.Master(files) + + +def _given(whole, key, **over): + return {**cm.시험입력(whole.logic(key)), **over} + + +@pytest.mark.parametrize("over,off", CASES) +def test_engine_marks_unpicked_ties(master, over, off): + _, whole = master + got = mf.run(whole, FORM, _given(whole, FORM, **over)) + marked = {x["이름"] for x in got["줄"] if x.get(mg.KEY)} + assert marked == off + + +def test_real_zero_not_marked(master): + _, whole = master + got = mf.run(whole, FORM, _given(whole, FORM, 구멍땜=0)) + holes = [x for x in got["줄"] if "구멍땜" in x["이름"]] + assert holes and all(x["수량"] == 0 and not x.get(mg.KEY) for x in holes) + # 조건이 참이고 표 칸이 0 인 줄(GC000480 측량초급기능사) — 진짜 0 + got = mf.run(whole, "GC000480", cm.시험입력(whole.logic("GC000480"))) + line = got["줄"][3] + assert line["수량"] == 0 and not line.get(mg.KEY) + + +def test_numeric_threshold_marks_other_machine(master): + _, whole = master + got = mf.run(whole, "GC000187", cm.시험입력(whole.logic("GC000187"))) + pairs = [(got["줄"][n], got["줄"][n + 1]) for n in (3, 5, 7)] + for a, b in pairs: # 타설길이 문턱으로 둘 중 하나 — 0 인 쪽만 표시 + assert {bool(a.get(mg.KEY)), bool(b.get(mg.KEY))} == {True, False} + assert all(bool(x.get(mg.KEY)) == (x["수량"] == 0) for x in (a, b)) + + +def test_money_same_without_mark(master, monkeypatch): + _, whole = master + for over, _ in CASES: + given = _given(whole, FORM, **over) + on = mf.run(whole, FORM, given) + with monkeypatch.context() as m: + m.setattr(mg, "tag", lambda *a: a[2]) + off = mf.run(whole, FORM, given) + assert on["계"] == off["계"] + assert [x["금액"] for x in on["줄"]] == [x["금액"] for x in off["줄"]] + + +def test_never_marks_nonzero(master): + _, whole = master + for key in ("GC000211", "GC000002", "GC000216", "GC000563", "GC000187"): + got = mf.run(whole, key, cm.시험입력(whole.logic(key))) + assert all(x["수량"] == 0 for x in got["줄"] if x.get(mg.KEY)) + + +def test_sheet_text_carries_mark(master): + files, whole = master + given = _given(whole, FORM, 폼타이="사용", 폼타이규격="9.5㎜") + got = msl.annotate(files, FORM, given, mtx.lines(files, FORM, given)) + lines = {x["이름"]: x for g in got["groups"] for x in g["줄"]} + assert lines["폼타이 9.5㎜"].get(mg.KEY) is None + assert lines["폼타이 12.7㎜"][mg.KEY] is True and lines["폼타이 7.9㎜"][mg.KEY] is True + assert mg.KEY in msl.KEYS # M02 `/logic-lib/sheet` 도 같은 칸 + + +def test_sk_calc_carries_mark(): + whole = m01.loaded()[1] + doc = {"키": "SK-000001", "이름": "시험", "단위": "㎡", "바탕": {"로직": FORM}} + clean = sd.clean(doc, None, whole) + got = labor._json(calc.run(clean, whole, {"폼타이": "사용", "폼타이규격": "12.7㎜"})) + marked = {x["cid"] for x in got["줄"] if x.get(mg.KEY)} + assert marked == {"material:폼타이 9.5㎜#0", "material:폼타이 7.9㎜#0"} + base = labor._json(calc.run(clean, whole)) + assert base["기준"]["계"] == pytest.approx(got["기준"]["계"])