From c60b87c111774c8630f783a8ab9ac619e9ea876a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:38:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(715-1b):=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=8D=A7=EC=B9=B8=20=E2=80=94=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=EC=A4=84=20=EB=8B=A8=EA=B0=80(=EC=86=90=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80)=20=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=A4=84?= =?UTF-8?q?=20=EA=B0=92=EB=AC=B6=EC=9D=8C(=EB=85=B8=20=C2=B7=20=EC=9E=AC?= =?UTF-8?q?=20=C2=B7=20=EA=B2=BD)=20=C2=B7=20SK=20=EA=B3=A0=EC=B9=9C=20?= =?UTF-8?q?=EC=A4=84=20=EC=85=88=20=EB=A7=88=EB=AC=B4=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_pick — _priced 앞 「단가」 덧칸 = 손 단가(찾기 없음 · 출처 「손 단가」) · bundled(값묶음 셋 = 그것 · 일부 = 엔진 값 위 · 계 = 셋 합) - master_formula — 로직 줄 묶음 부르는 줄 하나(mp.bundled) · 712-1 출력 묶음과 다른 이름 - M01 호표 5,266 줄에 덧칸 없음 · M01 1,469 로직 값 전후 같음 - 시험 test_715_sangeun_engine.py 다시 넣음(손 단가 · 3 % 다시 셈 · 값묶음 일부 · 참조 · 고정 더한줄) Co-Authored-By: Claude Opus 5.5 --- .../master_data/scripts/master_formula.py | 2 +- resources/master_data/scripts/master_pick.py | 18 ++++ resources/tester/test_715_sangeun_engine.py | 86 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 29c4665bb..422120a71 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -587,7 +587,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: continue if item["종류"] == "로직": try: - bundle = evaluate(parse(item["요소"]), env, master, depth) + bundle = mp.bundled(item, lambda: evaluate(parse(item["요소"]), env, master, depth)) except EmptyInput as e: lines.append(_blank_line(item, str(e))) continue diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index fdbcb02a2..b28ff3453 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -9,6 +9,7 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar +from decimal import Decimal import master_material as mm from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2) @@ -121,6 +122,8 @@ def priced(master, item: dict, ref, env: dict, index: int | None): def _priced(master, item: dict, ref, env: dict, index: int | None): + if item.get("단가") is not None: # 손 단가(715-1b · M02 단가산출 덮개 · 더한줄) — 찾기 없음 + return Decimal(str(item["단가"])), HAND, None, None unit = item.get("단위") picked = (CHOSEN.get() or {}).get(str(index)) if index is not None else None if not picked and empty_term(ref): # 자재표에 없는 품목 — 구분 안 첫 품목으로 잇지 않음 @@ -143,6 +146,21 @@ def _priced(master, item: dict, ref, env: dict, index: int | None): return price, source, why, warn +HAND = "손 단가" + + +def bundled(item: dict, base): + """로직 줄 노 · 재 · 경 묶음 — 덧칸 `값묶음`(715-1b · M02 단가산출)이 셋 다면 그것 · + 일부면 `base()`(엔진 값) 위에 · 계 = 셋 합 · 덧칸 없음 = `base()` 그대로.""" + given = item.get("값묶음") + if not given: + return base() + costs = mf.COST_ITEMS + have = {k: Decimal(str(v)) for k, v in given.items() if k in costs} + got = have if len(have) == len(costs) else {**base(), **have} + return {**got, "계": sum(got[k] for k in costs)} + + NO_ITEM = "자재표에 없는 품목 — 검색어 비움(결정 목록) · 품목을 골라 넣음" diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py new file mode 100644 index 000000000..c3b6847f3 --- /dev/null +++ b/resources/tester/test_715_sangeun_engine.py @@ -0,0 +1,86 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick`) — 고친 줄 값 = 손셈. + +줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · +덧줄 % 가 덮인 값에서 다시 셈 · 더한줄 참조(SK · SC) · 고정 = 1 단위 묶음 · 덧칸 없는 줄 = 엔진 값. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) + + +def _whole(): + return m01.loaded()[1] + + +def _run(**doc): + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + whole = _whole() + clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) + return labor._json(calc.run(clean, whole)) + + +def test_price_cover_is_hand_price_and_pct_follows(): + bare = labor.run_line(_whole(), PCT, {}) + first, second = bare["줄"][0], bare["줄"][1] + got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) + line = got["줄"][0] + assert line["단가"] == 200000 and line["출처"] == "손 단가" + want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(want)) + assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) + + +def test_bundle_cover_on_logic_line_partial(): + bare = labor.run_line(_whole(), SUB, {}) + sub = bare["줄"][2] + got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) + line = got["줄"][2] + qty = Decimal(str(sub["수량"])) + assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) + assert line["비목"]["노무비"] == pytest.approx( + float(sub["비목"]["노무비"]) + ) # 안 덮은 칸 = 엔진 값 + + +def test_untouched_lines_same_as_engine(): + bare = labor.run_line(_whole(), SUB, {}) + got = _run(바탕={"로직": SUB}) + assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) + + +def test_added_ref_and_fixed_lines(world): + """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" + with store.at(world["personal"]): + sk = refs.create( + "sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + top = refs.create( + "sangeun", + { + "이름": "위", + "단위": "식", + "바탕": None, + "더한줄": [ + {"참조": {**MINE, "키": sk}, "수량": 2}, + {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, + ], + }, + )["이름"] + got = calc.calc_doc(store.read("sangeun", top)["문서"]) + assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) + expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] + assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From 328cb46d92fbb1eccacb8defeefd5358e38bff19 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:38:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(719-2):=20=EC=82=B0=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=8B=A8=EC=9C=84=20=EB=B0=B0=20=3D=20712-2?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=95=9C=20=EB=B2=8C(master=5Funit.per?= =?UTF-8?q?)=20=E2=80=94=20=EB=B0=9B=EC=9D=80=20712-2=20=EA=B0=80=20Store?= =?UTF-8?q?=5FCombo.=5FPER=20=EB=A5=BC=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 9815913f2..dedd80e90 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -208,12 +208,11 @@ class _Prices: def _unit(row: dict) -> tuple[str, Decimal]: """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10) · 알약 규칙(`combo_pills`) 그대로 · 줄 = 사본 단가 묶음 것(M01 다시 안 읽음 · 719-2).""" - per = combo_store._PER.findall(str(row.get("결과단위") or "")) + per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌 if not per: return "", Decimal(1) - times = per[0][0].replace(",", "") unit = " · ".join(dict.fromkeys(u for _, u in per)) - return unit, Decimal(times) if len(per) == 1 and times else Decimal(1) + return unit, Decimal(per[0][0]) if len(per) == 1 else Decimal(1) def unit_of(whole: Any, code: str) -> str: