From 47196780b2d4c54a42801d758c1dad4f47fe20d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 23:04:31 +0900 Subject: [PATCH] =?UTF-8?q?fix(b09):=20=EC=A0=88=EC=82=AC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=B3=B4=EC=A0=95=EC=9D=84=20=EC=9D=B4=EB=B6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=95=89=ED=9E=98=20=E2=80=94=20=EB=AA=BB?= =?UTF-8?q?=20=EB=B0=9F=EB=8A=94=20=EB=81=9D=EC=9E=90=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20999=20=EA=B0=80=20=EB=82=A8=EB=8D=98=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 까닭 — 이윤 1원을 깎으면 총공사비는 1원이나 2원 준다(부가세가 버림). ÷1.1 어림이 한 칸 아래로 지나치고 3걸음 안에 못 돌아와 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았음. 고친 법 — 노릴 배수를 못 박고 목표 이하로 내려가는 **가장 작은 보정액을 이분으로** 찾는다 (총공사비는 보정액에 대해 비증가). 못 밟는 배수면 한 칸 아래로 내려 다시 찾는다(뒷받침 — 실측에서는 한 칸도 안 내려감). _CUT_MAX_PASSES → _CUT_DESCENT_MAX 로 뜻이 바뀜. 판정 — 거창 꼴 40/40 앉음 · 깎인 몫 356~423원으로 늘 한 칸 안쪽 · 실무 6건 자동보정액 그대로(봉화 339 · 영월 632, 골든셋이 지킴). 잴 시험 먼저 빨강 10 자리 확인한 뒤 고침. 전체 시험 2043 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA --- B09_Estimation/B09_Estimation_Engine_Cost.py | 73 ++++++++++++++------ resources/tester/test_b09_cost_options.py | 39 +++++++++++ 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index f58eb6ab..1342b3f0 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -25,7 +25,6 @@ from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( VAT_MODES, cut_gap, overhead_base, - profit_cut, vat_base, ) from B09_Estimation.B09_Estimation_RateOverride import apply_overrides @@ -368,9 +367,9 @@ def calculate_cost(data: CostInput) -> CostResult: ) -#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음 -#: (다섯 건 한 걸음 · 울진 신설 두 걸음). -_CUT_MAX_PASSES = 3 +#: 절사 목표를 몇 칸까지 내려 볼 것인가. 못 밟는 1,000 배수를 만나면 한 칸 내린다 — +#: 실측(거창 꼴 40 자리)에서는 **한 칸이면 다 앉았다**. 여유로 셋까지 본다. +_CUT_DESCENT_MAX = 3 #: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트). CUT_OFF = ("", "none") @@ -386,30 +385,64 @@ def _calculate_with_scale( ⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어 총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 — 울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`. + + ⚠ **못 밟는 배수가 있다**(2026-09-14 둘째 고침). 한 걸음 낙차가 1원일 때도 2원일 때도 있어 + (부가세가 버림이라 열 걸음에 한 번쯤 2원) 어떤 1,000 배수는 **건너뛴다**. 어림으로 좇으면 + 지나쳐 놓고 못 돌아온다 — 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았다. 그래서 + **목표를 못 박고 가장 작은 보정액을 이분으로 찾는다**(총공사비는 보정액에 대해 비증가). + 그 배수를 못 밟으면 **한 칸 아래 배수**로 목표를 내려 다시 찾는다(뒷받침일 뿐 — 실측 거창 꼴 + 40 자리에서는 한 칸도 안 내려갔다). 이윤은 늘 **가장 적게** 깎인다 — 실무 식(÷1.1 한 걸음)이 + 앉는 자리에서는 같은 값이 나온다(봉화 339 · 영월 632). """ result = _calculate_once(data, dataset, scale, notes) if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0: return result key = "grand_total" if data.cut_basis == "grand_total" else "total_cost" + label = ( + f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" + " 절사 자동보정(산림청고시 2025-82호)" + + (" + 설계자 입력" if data.profit_adjustment_krw else "") + ) + + def attempt(extra: Decimal) -> CostResult: + """이윤을 `extra` 만큼 더 깎아 한 번 셈. `0` 이면 안 자른 결과 그대로.""" + if extra == 0: + return result + return _calculate_once( + replace( + data, + profit_adjustment_krw=data.profit_adjustment_krw + extra, + cut_basis="none", + profit_adjustment_label=label, + ), + dataset, + scale, + notes, + ) + + raw = result.totals[key] + target = raw - cut_gap(raw, data.cut_unit_krw) automatic = _ZERO gap = _ZERO - for _ in range(_CUT_MAX_PASSES): - gap = cut_gap(result.totals[key], data.cut_unit_krw) + landed = result + for _ in range(_CUT_DESCENT_MAX): + # 목표 이하로 내려가는 **가장 작은** 보정액을 이분으로 찾는다. 한 걸음이 적어도 1원을 + # 깎으므로 `raw - target` 이면 반드시 목표 아래로 간다. + low, high = _ZERO, raw - target + while low < high: + middle = (low + high) // 2 + if attempt(middle).totals[key] <= target: + high = middle + else: + low = middle + 1 + landed = attempt(low) + gap = landed.totals[key] - target if gap == 0: + automatic = low break - # 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음). - automatic += profit_cut(gap, data.cut_basis, data.vat_mode) - adjusted = replace( - data, - profit_adjustment_krw=data.profit_adjustment_krw + automatic, - cut_basis="none", - profit_adjustment_label=( - f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" - " 절사 자동보정(산림청고시 2025-82호)" - + (" + 설계자 입력" if data.profit_adjustment_krw else "") - ), - ) - result = _calculate_once(adjusted, dataset, scale, notes) + # 지나쳤다 = 그 배수는 못 밟는 자리. 한 칸 아래를 노린다. + target -= data.cut_unit_krw + result = landed result.notes.append( f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —" f" 이윤에서 {automatic:,}원 자동보정." @@ -419,7 +452,7 @@ def _calculate_with_scale( if gap: # 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다. result.notes.append( - f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —" + f"⚠ 절사가 {_CUT_DESCENT_MAX}칸 안에 안 앉음 —" f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)" ) return result diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py index 22b69816..17f26e40 100644 --- a/resources/tester/test_b09_cost_options.py +++ b/resources/tester/test_b09_cost_options.py @@ -231,3 +231,42 @@ def test_하도급대금_지급보증은_기본_꺼짐() -> None: ) > 0 ) + + +#: 실무 거창 2025 꼴 — 안전관리비 한 줄만 서고 관급이 큰 모양. 총공사비 끝자리가 +#: **건너뛰는** 자리가 생긴다(이윤 1원이 총공사비 1~2원이라 어떤 배수는 못 밟는다). +_SKIPPING = CostInput( + direct_material_krw=Decimal(80_165_010), + direct_labor_krw=Decimal(243_648_150), + direct_expense_krw=Decimal(0), + owner_supplied_material_krw=Decimal(74_634_214), + enabled_items=("safety_management_cost",), + cut_basis="grand_total", + cut_unit_krw=1000, +) + + +@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) +def test_절사는_끝자리를_건너뛰는_자리에서도_앉는다(delta) -> None: + """⭐ 이윤 1원을 깎으면 총공사비는 **1원이나 2원** 줄어 어떤 1,000 배수는 못 밟는다. + + 종전엔 ÷1.1 어림이 한 칸 아래로 지나친 뒤 3걸음 안에 못 돌아와 끝자리 999 가 남았다 + (거창 꼴 40 자리 중 16). 못 밟는 배수면 **한 칸 아래 배수로 내려가** 앉아야 한다. + """ + result = calculate_cost(replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta))) + assert result.totals["grand_total"] % 1000 == 0, delta + assert not [n for n in result.notes if "안 앉음" in n], result.notes + + +@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39]) +def test_절사는_깎을_수_있는_가장_적은_몫만_깎는다(delta) -> None: + """버림이므로 깎인 몫은 **한 칸(1,000원) 안쪽**이어야 한다. + + ⚠ 이 줄이 깨지면 「못 밟는 배수를 만나 한 칸 내려갔다」는 뜻이다 — 실측(거창 꼴 40 자리)에서는 + 한 칸도 안 내려갔다. 깨지는 자리가 생기면 그 자체가 봐야 할 소식이다. + """ + data = replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta)) + plain = calculate_cost(replace(data, cut_basis="none")) + cut = calculate_cost(data) + drop = plain.totals["grand_total"] - cut.totals["grand_total"] + assert 0 <= drop < 1000, (delta, drop)