From 80462050396130af10e8067a433d99c913f5bd8d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 04:07:45 +0900 Subject: [PATCH] =?UTF-8?q?fix(b09):=20=EC=82=B0=EC=97=85=EC=95=88?= =?UTF-8?q?=EC=A0=84=2050=EC=96=B5=20=EC=9D=B4=EC=83=81=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EA=B2=B9=EC=B9=A8=20=ED=95=B4=EC=86=8C=20=C2=B7=20?= =?UTF-8?q?=ED=95=98=EB=8F=84=EA=B8=89=EB=8C=80=EA=B8=88=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=EB=B3=B4=EC=A6=9D=EC=88=98=EC=88=98=EB=A3=8C=20?= =?UTF-8?q?=EC=B9=B8(=EA=B8=B0=EB=B3=B8=20=EA=BA=BC=EC=A7=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산업안전보건관리비: 고시 별표1 「보건관리자 선임 대상 건설공사의 적용비율」 열을 대상액이 아니라 공사가 선임 대상인가(요율 데이터 manager_thresholds 800억 · 토목 1,000억)로 가름 — 두 줄이 겹쳐 멈추던 자리 - 하도급대금 지급보증수수료: 기본 꺼짐 — 실무 원가계산서 6건에 줄 없음 · 건설산업기본법 제34조 대상이면 켬 (칸 밑에 「대상 아님인지 안 적음인지 못 가름」 사유) - 시험: 50억 이상 2.60 / 선임 대상 2.73 · 하도급 꺼짐·켬 · 골든셋 초록 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_CostSheet.py | 10 ++++ B09_Estimation/B09_Estimation_Engine_Cost.py | 3 ++ B09_Estimation/B09_Estimation_Statutory.py | 50 ++++++++++++++++--- .../B09_Estimation_UI_Tab_CostSheet.ts | 1 + resources/tester/test_b09_cost_options.py | 32 ++++++++++-- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py index 11b15ae5..d2c5bf2f 100644 --- a/B09_Estimation/B09_Estimation_CostSheet.py +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -52,6 +52,7 @@ CHOICE_FIELDS: tuple[tuple[str, str], ...] = ( ("vat_mode", "12.부 가 가 치 세"), ("private_material_position", "13.사급비 위치"), ("bid_method", "14. 낙 찰 방 식"), + ("subcontract_guarantee", "하도급대금 지급보증수수료"), ("performance_guarantee_mode", "15. 이행보증 방식"), ("contract_law_basis", "16. 적용기준"), ("waste_placement", "폐기물처리비 자리"), @@ -79,6 +80,11 @@ FIELD_HINTS: dict[str, str] = { " 종합심사 대상 = 300억 이상 줄" " · 턴키(대안) = 0.084% · 설계 단계 금액엔 이 한 줄뿐, 계약 단계 단가 적용률로 이어질 칸" ), + "subcontract_guarantee": ( + "기본 꺼짐 — 실무 원가계산서 6건에 이 줄이 없음. 건설산업기본법 제34조 대상" + "(하도급대금 지급보증서 교부)이면 켤 것 · 실무에 없는 것이 「대상 아님」인지" + " 「안 적음」인지는 못 가름" + ), "performance_guarantee_mode": ( "일반계약(기본) = 추정가격 300억 이상일 때만 공사이행보증수수료를 셈 · 최저가·기술제안 =" " 규모와 무관하게 셈 — 근거: 예정가격작성기준 제19조③20호(시행령 제52조제1항 단서) ·" @@ -132,6 +138,10 @@ def choice_options() -> dict[str, list[dict[str, str]]]: {"value": "comprehensive", "label": "종합심사 대상"}, {"value": "turnkey", "label": "턴키(대안)"}, ], + "subcontract_guarantee": [ + {"value": "off", "label": "계상 안 함(기본)"}, + {"value": "on", "label": "계상"}, + ], "performance_guarantee_mode": [ {"value": "general", "label": "일반계약"}, {"value": "lowest_price_tech", "label": "최저가,기술제안"}, diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 345335a5..b3bbb89b 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -139,6 +139,9 @@ class CostInput: #: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`. #: 하도급대금 지급보증 요율 줄만 고름. bid_method: str = "not_comprehensive" + #: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법 + #: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름). + subcontract_guarantee: str = "off" #: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) · #: `lowest_price_tech`(최저가·기술제안: 규모 무관). performance_guarantee_mode: str = "general" diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 1a04cf1f..2351b0b3 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -206,16 +206,46 @@ def safety_management_cost( base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied base_without = ctx.material_cost + ctx.direct_labor_cost + # ⚠ 2026-09-14 고침 — 대상액 50억 이상에서 두 줄(선임 대상 아님/대상)이 겹쳐 멈추던 자리. + # 고시 별표1 은 「대상액 50억원 이상」 열과 **따로** 「영 별표5에 따른 보건관리자 선임 대상 + # 건설공사의 적용비율」 열을 둔다 — 대상액이 아니라 **공사가 선임 대상인가**로 가르는 열. + # 선임 대상 금액은 요율 데이터 `manager_thresholds`(800억 · 토목 1,000억, 산업안전보건법 + # 시행령 별표5 — 원문 미보유). 추정금액 = 규모 기준액으로 견줌. + thresholds = variable.get("manager_thresholds") or {} + limit = thresholds.get( + "civil_main_work_estimated_amount_krw" + if data.work_type_safety == "civil" + else "default_estimated_amount_krw" + ) + manager_target = limit is not None and ctx.scale_reference >= Decimal(str(limit)) + def evaluate( base: Decimal, multiplier: Decimal, label: str ) -> tuple[Decimal, Decimal, Decimal]: - row = select_bracket( - brackets, - amount_field="target_amount_bracket", - amount=base, - equals={"work_type": data.work_type_safety}, - label=label, - ) + if manager_target: + row = next( + ( + r + for r in brackets + if str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold") + and r.get("work_type") == data.work_type_safety + ), + None, + ) + if row is None: + raise RateLookupError(f"{label}: 보건관리자 선임 대상 요율이 없습니다") + else: + row = select_bracket( + [ + r + for r in brackets + if not str(r["target_amount_bracket"]).endswith("at_or_above_manager_threshold") + ], + amount_field="target_amount_bracket", + amount=base, + equals={"work_type": data.work_type_safety}, + label=label, + ) percent = rate_percent(row, label=label) flat = base_amount(row) return (base * percent / _HUNDRED + flat) * multiplier, percent, flat @@ -309,6 +339,12 @@ def statutory_expenses( if key == "performance_guarantee_fee" and not _performance_guarantee_applies(data, ctx): continue # 15. 일반계약·추정가격 300억 미만 — 줄 자체를 만들지 않는다 + if key == "subcontract_payment_guarantee" and ( + str(getattr(data, "subcontract_guarantee", "off") or "off") != "on" + ): + # 기본 꺼짐(2026-09-14) — 실무 원가계산서 여섯 건에 이 줄이 없음. 건설산업기본법 + # 제34조 대상(하도급대금 지급보증서 교부)이면 설계자가 켬. + continue base, percent, note = _base_and_rate(dataset, data, ctx, item) if base is None: diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts index 902f017d..bb5f925d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -234,6 +234,7 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): // 13~16 — 사급비 위치·적용기준은 칸만 받음(계산에 안 씀 · 까닭은 칸 밑 안내). typeSelect("private_material_position"); typeSelect("bid_method"); + typeSelect("subcontract_guarantee"); typeSelect("performance_guarantee_mode"); typeSelect("contract_law_basis"); typeSelect("waste_placement"); diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py index 3fbbd32a..c2f8dfd2 100644 --- a/resources/tester/test_b09_cost_options.py +++ b/resources/tester/test_b09_cost_options.py @@ -183,12 +183,11 @@ def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None: - assert calculate_cost(BASE).line("subcontract_payment_guarantee").rate_percent == Decimal( - "0.081" - ) - turnkey = calculate_cost(replace(BASE, bid_method="turnkey")) + on = replace(BASE, subcontract_guarantee="on") + assert calculate_cost(on).line("subcontract_payment_guarantee").rate_percent == Decimal("0.081") + turnkey = calculate_cost(replace(on, bid_method="turnkey")) assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084") - comp = calculate_cost(replace(BASE, bid_method="comprehensive")) + comp = calculate_cost(replace(on, bid_method="comprehensive")) assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071") @@ -197,3 +196,26 @@ def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None: replace(BASE, private_material_position="material", contract_law_basis="local") ) assert same.totals == calculate_cost(BASE).totals + + +def test_산업안전_50억_이상은_선임_대상으로_가른다() -> None: + """고시 별표1 — 「50억 이상」 열과 「보건관리자 선임 대상」 열은 공사가 선임 대상인가로 갈림.""" + big = replace( + BASE, + direct_material_krw=Decimal(4_000_000_000), + direct_labor_krw=Decimal(3_000_000_000), + estimated_price_krw=Decimal(20_000_000_000), # 토목 선임 기준 1,000억 미만 + ) + assert calculate_cost(big).line("safety_management_cost_b").rate_percent == Decimal("2.6") + target = calculate_cost(replace(big, estimated_price_krw=Decimal(120_000_000_000))) + assert target.line("safety_management_cost_b").rate_percent == Decimal("2.73") + + +def test_하도급대금_지급보증은_기본_꺼짐() -> None: + assert not calculate_cost(BASE).has("subcontract_payment_guarantee") + assert ( + calculate_cost(replace(BASE, subcontract_guarantee="on")).amount( + "subcontract_payment_guarantee" + ) + > 0 + )