From 8693cf0012a0e8e1c26ed16725ac5302b5eed7bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 03:56:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=EA=B8=B0=EC=A4=80=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?13~16=20=E2=80=94=20=EC=82=AC=EA=B8=89=EB=B9=84=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=C2=B7=20=EB=82=99=EC=B0=B0=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=ED=96=89=EB=B3=B4=EC=A6=9D=20=C2=B7=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=C2=B7=20=EC=9D=B4=ED=96=89=EB=B3=B4?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=9D=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 13 사급비 위치: 재료비에 포함만(사급은 이미 내역 재료비 · 실무 거창·영월) — 칸만 받음 - 14 낙찰방식: 하도급대금 지급보증 요율 줄 — 종합심사 외(규모 구간) · 종합심사 대상(300억 이상 줄) · 턴키(0.084) - 15 이행보증: 일반계약 = 추정가격 300억 이상만 공사이행보증수수료 · 최저가·기술제안 = 규모 무관 (종전 늘 계산 — 제비율 기준 「소규모 통상 비대상」·실무 여섯 건 금액 없음과 어긋났음) - 곁 고침: 70억 이상 구간 [기초액 + (직공비−기준액) × 율] × 공기 — 종전 백분율만 쓰고 기초액·기준액을 버렸음 - 16 적용기준: 칸만 받음(조달청 표 한 벌 · 제20조 국가 표 이미지 미확인 · 행자부 표 없음) - 칸 밑 안내 · 시험: 300억 미만 안 섬 · 100억 식 965,000 · 요율 줄 셋 · 13·16 계산 불변 · 골든셋 초록 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_CostSheet.py | 41 +++++++++++ B09_Estimation/B09_Estimation_Engine_Cost.py | 12 ++++ B09_Estimation/B09_Estimation_Statutory.py | 72 ++++++++++++++++++- .../B09_Estimation_UI_Tab_CostSheet.ts | 5 ++ resources/tester/test_b09_cost_options.py | 36 ++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py index 9eaf2d6b..11b15ae5 100644 --- a/B09_Estimation/B09_Estimation_CostSheet.py +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -50,6 +50,10 @@ CHOICE_FIELDS: tuple[tuple[str, str], ...] = ( ("cut_basis", "11.공급가절사기준"), ("cut_unit_krw", "11.절사 단위"), ("vat_mode", "12.부 가 가 치 세"), + ("private_material_position", "13.사급비 위치"), + ("bid_method", "14. 낙 찰 방 식"), + ("performance_guarantee_mode", "15. 이행보증 방식"), + ("contract_law_basis", "16. 적용기준"), ("waste_placement", "폐기물처리비 자리"), ) #: 칸 밑 안내 — 기본값의 까닭과 실무가 어떻게 쓰는지(2026-09-14 브레인 판정 · 도자 60m 방식). @@ -66,6 +70,25 @@ FIELD_HINTS: dict[str, str] = { " 「절사 자동보정」으로 적힘" ), "vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건", + "private_material_position": ( + "사급자재대는 설계내역서 재료비에 이미 들어 옴(실무 거창·영월 · STmate 기본) —" + " 다른 자리(이윤 다음·도급공사비 직전·관급자재대 다음)는 사급 금액 칸이 설 때 엶" + ), + "bid_method": ( + "하도급대금 지급보증 요율 줄만 고름 — 종합심사 외 = 규모 구간 ·" + " 종합심사 대상 = 300억 이상 줄" + " · 턴키(대안) = 0.084% · 설계 단계 금액엔 이 한 줄뿐, 계약 단계 단가 적용률로 이어질 칸" + ), + "performance_guarantee_mode": ( + "일반계약(기본) = 추정가격 300억 이상일 때만 공사이행보증수수료를 셈 · 최저가·기술제안 =" + " 규모와 무관하게 셈 — 근거: 예정가격작성기준 제19조③20호(시행령 제52조제1항 단서) ·" + " 제비율 기준 「소규모 공사는 통상 비대상」 · 실무 임도 원가계산서 여섯 건에 금액 없음" + ), + "contract_law_basis": ( + "계산에 안 씀 — 지금 요율은 조달청 표 한 벌 ·" + " 국가/지방 구간 차이(예정가격작성기준 제20조 표)는" + " 원문이 이미지라 미확인 · 행자부 표 없음. 발주처 구분만 받아 둠" + ), "waste_placement": ( "법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤" " 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고" @@ -101,6 +124,24 @@ def choice_options() -> dict[str, list[dict[str, str]]]: {"value": "expense", "label": "경비 — 승률 안(법 문언 · 기본)"}, {"value": "after_profit", "label": "이윤 뒤 · 총원가 안 — 승률 밖(실무 관행)"}, ], + "private_material_position": [ + {"value": "material", "label": "재료비에 포함"}, + ], + "bid_method": [ + {"value": "not_comprehensive", "label": "종합심사 외"}, + {"value": "comprehensive", "label": "종합심사 대상"}, + {"value": "turnkey", "label": "턴키(대안)"}, + ], + "performance_guarantee_mode": [ + {"value": "general", "label": "일반계약"}, + {"value": "lowest_price_tech", "label": "최저가,기술제안"}, + ], + "contract_law_basis": [ + {"value": "", "label": "미정(계산에 안 씀)"}, + {"value": "national", "label": "일반(국가계약법)"}, + {"value": "local", "label": "일반(지방계약법)"}, + {"value": "moi", "label": "행자부기준"}, + ], "overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()], "cut_basis": [ {"value": "", "label": "절사 안 함(이윤보정액 입력)"}, diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 05264420..345335a5 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -133,6 +133,18 @@ class CostInput: #: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% — #: 율이 안 갈리는 구분은 칸으로 안 세움. retirement_mutual_aid_mode: str = "auto" + #: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로 + #: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶. + private_material_position: str = "material" + #: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`. + #: 하도급대금 지급보증 요율 줄만 고름. + bid_method: str = "not_comprehensive" + #: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) · + #: `lowest_price_tech`(최저가·기술제안: 규모 무관). + performance_guarantee_mode: str = "general" + #: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방 + #: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음. + contract_law_basis: str = "" #: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율. rate_overrides: tuple[dict[str, Any], ...] = () diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 9cf9b78a..1a04cf1f 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -151,6 +151,35 @@ def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decim return Decimal(match.group(1)) +#: 70억 이상 구간 식 — `(기초액 + (direct_cost - 기준액) * 율%) * duration_years`. +_RE_GUARANTEE_STEP = re.compile(r"\(\s*(\d+)\s*\+\s*\(\s*direct_cost\s*-\s*(\d+)\s*\)") + + +def _guarantee_step(row: dict[str, Any]) -> tuple[Decimal, Decimal]: + """(기초액, 기준액) — 70억 미만 식은 둘 다 0. + + ⚠ 2026-09-14 고침: 종전엔 백분율만 뽑아 **기초액·기준액 차감을 버렸다**(직공비 70억 이상에서 + 틀림 — 조달청 제비율 적용기준 §10 「[79만원 + (직공비−75억원) × 0.0070%] × 공기(년)」). + """ + match = _RE_GUARANTEE_STEP.search(str(row.get("formula", ""))) + if not match: + return _ZERO, _ZERO + return Decimal(match.group(1)), Decimal(match.group(2)) + + +#: 15. 이행보증 — 일반계약이면 **추정가격 300억 이상**일 때만 계상(STmate 힌트 「일반계약일경우 +#: 추정가격 300억 이상공사에 적용」 · 지방계약법 시행령 이행보증서 의무 대상 · 제비율 기준 +#: 「소규모 공사는 통상 비대상」). 최저가·기술제안이면 규모와 무관하게 계상. +PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW = Decimal(30_000_000_000) + + +def _performance_guarantee_applies(data: CostInput, ctx: ExpenseContext) -> bool: + mode = str(getattr(data, "performance_guarantee_mode", "general") or "general") + if mode == "lowest_price_tech": + return True + return ctx.scale_reference >= PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW + + def safety_management_cost( dataset: RateDataset, data: CostInput, @@ -278,16 +307,30 @@ def statutory_expenses( ) continue + if key == "performance_guarantee_fee" and not _performance_guarantee_applies(data, ctx): + continue # 15. 일반계약·추정가격 300억 미만 — 줄 자체를 만들지 않는다 + base, percent, note = _base_and_rate(dataset, data, ctx, item) if base is None: continue # 적용 하한 미달 — 줄 자체를 만들지 않는다 + flat = _ZERO + if key == "performance_guarantee_fee": + # 70억 이상 구간 기초액 × 공기(년) — 밑수는 `_base_and_rate` 가 기준액을 뺐다. + row = select_bracket( + dataset.variable(item.rate_variable)["brackets"], + amount_field="direct_cost_bracket", + amount=ctx.direct_construction_cost, + label=item.name, + ) + flat = _guarantee_step(row)[0] * Decimal(str(data.duration_days)) / Decimal(365) amount = emit( key=key, name=item.name, base_label=item.base_label, base=base, percent=percent, + flat=flat, note=note, ) if key == "health_insurance": @@ -395,6 +438,28 @@ def _base_and_rate( return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" if key == "subcontract_payment_guarantee": + # 14. 낙찰방식 — 턴키(대안)는 규모와 무관한 한 줄(0.084) · 종합심사 대상은 300억 이상 줄. + bid = str(getattr(data, "bid_method", "not_comprehensive") or "not_comprehensive") + if bid == "turnkey": + row = next( + ( + r + for r in variable["brackets"] + if r["estimated_price_bracket"].startswith("turnkey") + ), + None, + ) + if row is None: + raise RateLookupError(f"{item.name}: 턴키(대안) 요율이 요율 데이터에 없습니다") + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "턴키(대안)" + if bid == "comprehensive": + suffix = f"gte_30_billion_{data.subcontract_guarantee_variant}" + row = next( + (r for r in variable["brackets"] if r["estimated_price_bracket"] == suffix), None + ) + if row is None: + raise RateLookupError(f"{item.name}: 종합심사 대상 요율이 없습니다 — {suffix}") + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "종합심사 대상" # 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다. row = select_bracket( variable["brackets"], @@ -415,6 +480,11 @@ def _base_and_rate( percent = _guarantee_percent_from_formula(row, label=item.name) years = Decimal(str(data.duration_days)) / Decimal(365) note = variable.get("typical_forest_road_applicability", "") - return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else "" + deduct = _guarantee_step(row)[1] + return ( + (ctx.direct_construction_cost - deduct) * years, + percent, + f"임도 적용성: {note}" if note else "", + ) raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}") diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts index 816fef2e..902f017d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -231,6 +231,11 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): typeSelect("cut_basis"); typeSelect("cut_unit_krw"); typeSelect("vat_mode"); + // 13~16 — 사급비 위치·적용기준은 칸만 받음(계산에 안 씀 · 까닭은 칸 밑 안내). + typeSelect("private_material_position"); + typeSelect("bid_method"); + typeSelect("performance_guarantee_mode"); + typeSelect("contract_law_basis"); typeSelect("waste_placement"); box.append(el("div", "b09cs__group", "계산/인쇄 설정")); for (const field of sheet.fields.amounts.slice(2)) { diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py index 3e6e24e4..3fbbd32a 100644 --- a/resources/tester/test_b09_cost_options.py +++ b/resources/tester/test_b09_cost_options.py @@ -161,3 +161,39 @@ def test_폐기물_승률_밖_자리() -> None: assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼 keys = [line.key for line in practice.lines] assert keys.index("profit") < keys.index("waste_disposal") < keys.index("total_cost") + + +def test_이행보증은_일반계약이면_300억_미만에서_안_선다() -> None: + assert not calculate_cost(BASE).has("performance_guarantee_fee") + tech = calculate_cost(replace(BASE, performance_guarantee_mode="lowest_price_tech")) + assert tech.amount("performance_guarantee_fee") > 0 + + +def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None: + """제비율 기준 §10 — [79만원 + (직공비−75억원) × 0.0070%] × 공기(년).""" + big = CostInput( + direct_material_krw=Decimal(500_000_000), + direct_labor_krw=Decimal(300_000_000), + direct_expense_krw=Decimal(9_200_000_000), # 직공비 100억 · 안전 대상액 8억 + estimated_price_krw=Decimal(12_000_000_000), + duration_days=365, + performance_guarantee_mode="lowest_price_tech", + ) + assert calculate_cost(big).amount("performance_guarantee_fee") == Decimal(790_000 + 175_000) + + +def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None: + assert calculate_cost(BASE).line("subcontract_payment_guarantee").rate_percent == Decimal( + "0.081" + ) + turnkey = calculate_cost(replace(BASE, bid_method="turnkey")) + assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084") + comp = calculate_cost(replace(BASE, bid_method="comprehensive")) + assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071") + + +def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None: + same = calculate_cost( + replace(BASE, private_material_position="material", contract_law_basis="local") + ) + assert same.totals == calculate_cost(BASE).totals