diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py index 73742462..6aa8d2e6 100644 --- a/B09_Estimation/B09_Estimation_CostSheet.py +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -44,11 +44,40 @@ AMOUNT_FIELDS: tuple[tuple[str, str], ...] = ( ) #: 10·11·12 — 선택지가 요율 데이터가 아니라 **엔진 규칙**(`Engine_Cost_Options`)에서 옴. CHOICE_FIELDS: tuple[tuple[str, str], ...] = ( + ("employment_insurance_grade", "4.고 용 보 험 료"), + ("retirement_mutual_aid_mode", "5.퇴직공제부금비"), ("overhead_class", "10.일반관리비"), ("cut_basis", "11.공급가절사기준"), ("cut_unit_krw", "11.절사 단위"), ("vat_mode", "12.부 가 가 치 세"), + ("waste_placement", "폐기물처리비 자리"), ) +#: 칸 밑 안내 — 기본값의 까닭과 실무가 어떻게 쓰는지(2026-09-14 브레인 판정 · 도자 60m 방식). +FIELD_HINTS: dict[str, str] = { + "employment_insurance_grade": "자동 = 추정금액 구간으로 등급을 고름(임도는 대개 7등급 이하)", + "retirement_mutual_aid_mode": ( + "자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타" + " 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음" + ), + "overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사", + "cut_basis": ( + "기본 「안 함」 — 이윤을 자동으로 깎지 않는다는 합의(2026-09-08) · 참고: 실무 임도 3건" + "(봉화·영월·거창) 모두 「총공사비 1,000원 미만 + 이윤 자동보정」을 씀 — 고르면 이윤 줄에" + " 「절사 자동보정」으로 적힘" + ), + "vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건", + "waste_placement": ( + "법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤" + " 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고" + "(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름" + ), +} +_GRADE_OPTIONS = [ + {"value": "auto", "label": "자동(추정금액 구간)"}, + {"value": "none", "label": "없음"}, + *({"value": str(g), "label": f"{g} 등급"} for g in range(1, 7)), + {"value": "7", "label": "7 등급이하"}, +] #: DFM `12.부 가 가 치 세` 선택지 표기 그대로. VAT_MODE_LABELS = { "supply": "(0) 공급가액의 10%", @@ -62,6 +91,16 @@ VAT_MODE_LABELS = { def choice_options() -> dict[str, list[dict[str, str]]]: """10·11·12 선택지. 절사 「안 함」이 첫째 = 기본(이윤을 자동으로 안 깎음).""" return { + "employment_insurance_grade": _GRADE_OPTIONS, + "retirement_mutual_aid_mode": [ + {"value": "auto", "label": "자동(추정금액 1억 이상)"}, + {"value": "apply", "label": "적용"}, + {"value": "none", "label": "미적용"}, + ], + "waste_placement": [ + {"value": "expense", "label": "경비 — 승률 안(법 문언 · 기본)"}, + {"value": "after_profit", "label": "이윤 뒤 · 총원가 안 — 승률 밖(실무 관행)"}, + ], "overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()], "cut_basis": [ {"value": "", "label": "절사 안 함(이윤보정액 입력)"}, @@ -240,7 +279,8 @@ def sheet_rows(result: CostResult, data: CostInput) -> list[dict[str, Any]]: ] number = 2 for key, label in EXPENSE_ORDER: - if not result.has(key) or (key == "waste_disposal" and data.waste_separate_order): + waste_elsewhere = data.waste_separate_order or data.waste_placement == "after_profit" + if not result.has(key) or (key == "waste_disposal" and waste_elsewhere): continue rows.append(_row(f"{number})", label, amount(key), 2, key, result)) if key == "safety_management_cost": @@ -276,6 +316,13 @@ def sheet_rows(result: CostResult, data: CostInput) -> list[dict[str, Any]]: before = result.line("profit_before_adjustment") profit_row.update(formula=before.formula_text, rate_percent=str(before.rate_percent)) rows.append(profit_row) + if ( + data.waste_placement == "after_profit" + and not data.waste_separate_order + and result.has("waste_disposal") + ): + # 실무 관행 자리(울진소광) — 이윤 뒤 · 총원가 안. + rows.append(_row("", "폐기물처리비", amount("waste_disposal"), 1, "waste_disposal", result)) rows.append(_row("라.", "공급가액", amount("total_cost"), 0, "total_cost", result)) rows.append(_row("", "부가가치세", amount("vat"), 1, "vat", result)) rows.append(_row("마.", "도급공사비", amount("contract_amount"), 0, "contract_amount", result)) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 5653b0eb..969255d1 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -106,6 +106,10 @@ class CostInput: #: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다 #: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐. waste_separate_order: bool = False + #: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) · + #: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고). + #: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖). + waste_placement: str = "expense" #: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ── #: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만). @@ -121,6 +125,12 @@ class CostInput: #: ⚠ 고른 때만 이윤을 자동보정한다 — 기본은 수동 이윤보정만(2026-09-08 합의). cut_basis: str = "" cut_unit_krw: int = 0 + #: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접). + employment_insurance_grade: str = "auto" + #: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용). + #: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% — + #: 율이 안 갈리는 구분은 칸으로 안 세움. + retirement_mutual_aid_mode: str = "auto" #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. @@ -424,7 +434,10 @@ def _calculate_once( ) statutory = statutory_expenses(dataset, data, ctx, result, emit) # 폐기물처리비 — 분리발주가 아니면 경비 한 줄. 법정경비 **뒤**에 둬 그 밑수에 안 섞인다. - waste_in_expense = _ZERO if data.waste_separate_order else _waste_line(data, emit) + after_profit = not data.waste_separate_order and data.waste_placement == "after_profit" + waste_in_expense = ( + _ZERO if data.waste_separate_order or after_profit else _waste_line(data, emit) + ) expense_total = data.direct_expense_krw + statutory + waste_in_expense emit( @@ -463,11 +476,13 @@ def _calculate_once( profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead) - total_cost = net_construction_cost + overhead + profit + # 실무 관행 자리 — 이윤 뒤 · 총원가 안(부가세 밑수에 듦) · 일반관리비·이윤 밑수 밖. + waste_after_profit = _waste_line(data, emit) if after_profit else _ZERO + total_cost = net_construction_cost + overhead + profit + waste_after_profit emit( key="total_cost", name="총원가", - base_label="순공사원가+일반관리비+이윤", + base_label="순공사원가+일반관리비+이윤" + ("+폐기물처리비" if waste_after_profit else ""), base=total_cost, amount=total_cost, ) @@ -519,7 +534,7 @@ def _calculate_once( "vat": vat, "contract_amount": contract_amount, "owner_supplied_material_total": owner_total, - "waste_disposal": waste_in_expense + waste, + "waste_disposal": waste_in_expense + waste_after_profit + waste, "grand_total": grand_total, } return result @@ -603,6 +618,8 @@ def _waste_line(data: CostInput, emit) -> Decimal: note=( "분리발주 — 총원가 밖 별도 표기" if data.waste_separate_order + else "실무 관행 자리 — 이윤 뒤 · 총원가 안 · 일반관리비·이윤 밑수 밖(설계자 선택)" + if data.waste_placement == "after_profit" else "경비(예정가격작성기준 제19조③18호) — 일반관리비·이윤 밑수에 듦" ), ) diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py index 4554af96..ea0a4cfb 100644 --- a/B09_Estimation/B09_Estimation_Router_CostSheet.py +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -21,6 +21,7 @@ from fastapi.responses import JSONResponse from B09_Estimation.B09_Estimation_CostSheet import ( AMOUNT_FIELDS, CHOICE_FIELDS, + FIELD_HINTS, SETTINGS_KEY, WORK_TYPE_FIELDS, clean_settings, @@ -112,6 +113,7 @@ async def get_cost_sheet(project_id: UUID) -> JSONResponse: "fields": { "work_types": [{"key": k, "label": label} for k, label in WORK_TYPE_FIELDS], "choices": [{"key": k, "label": label} for k, label in CHOICE_FIELDS], + "hints": FIELD_HINTS, "amounts": [{"key": k, "label": label} for k, label in AMOUNT_FIELDS], "options": field_options(dataset), }, diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 2fd37e8b..9cf9b78a 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -311,6 +311,15 @@ def _base_and_rate( return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), "" if key == "employment_insurance": + # 4. 등급 — 「없음」이면 안 섬 · 등급을 고르면 그 줄 · 자동이면 추정금액 구간(종전). + grade = str(getattr(data, "employment_insurance_grade", "auto") or "auto") + if grade == "none": + return None, _ZERO, "" + if grade != "auto": + row = next((r for r in variable["brackets"] if str(r.get("grade")) == grade), None) + if row is None: + raise RateLookupError(f"고용보험료: 요율표에 없는 등급입니다 — {grade}") + return ctx.total_labor_cost, rate_percent(row, label=item.name), f"{grade}등급 선택" # 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로 # 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율). row = select_bracket( @@ -362,11 +371,16 @@ def _base_and_rate( return ctx.direct_construction_cost, rate_percent(row, label=item.name), note if key == "retirement_mutual_aid": - if not _threshold_met( + # 5. 적용 여부 — 자동은 추정금액 하한(종전) · 적용/미적용은 설계자가 고름. + mode = str(getattr(data, "retirement_mutual_aid_mode", "auto") or "auto") + if mode == "none": + return None, _ZERO, "" + if mode == "auto" and not _threshold_met( dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference ): return None, _ZERO, "" - return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" + note = "적용 선택(추정금액 하한과 무관)" if mode == "apply" else "" + return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), note if key == "equipment_payment_guarantee": rows = variable["general_construction"] + variable["specialty_construction"] diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts index abee0240..816fef2e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -47,6 +47,8 @@ interface CostSheetDto { choices: { key: string; label: string }[]; amounts: { key: string; label: string }[]; options: Record; + /** 칸 밑 안내 — 기본값의 까닭 · 실무가 어떻게 쓰는지. */ + hints: Record; }; waste_note: string; bill_missing_count: number; @@ -205,6 +207,8 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): draft[key] = v; }), ); + const hint = sheet.fields.hints?.[key]; + if (hint) box.append(el("div", "b09cs__meta", hint)); }; typeSelect("work_type_indirect_labor"); box.append( @@ -212,6 +216,8 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): draft.duration_days = v; }), ); + typeSelect("employment_insurance_grade"); + typeSelect("retirement_mutual_aid_mode"); for (const field of sheet.fields.amounts.slice(0, 2)) { box.append( numberField(field.label + " :", draft[field.key] ?? "", (v) => (draft[field.key] = v)), @@ -225,6 +231,7 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): typeSelect("cut_basis"); typeSelect("cut_unit_krw"); typeSelect("vat_mode"); + typeSelect("waste_placement"); box.append(el("div", "b09cs__group", "계산/인쇄 설정")); for (const field of sheet.fields.amounts.slice(2)) { box.append( diff --git a/resources/tester/test_b09_cost_options.py b/resources/tester/test_b09_cost_options.py index 27960c35..3e6e24e4 100644 --- a/resources/tester/test_b09_cost_options.py +++ b/resources/tester/test_b09_cost_options.py @@ -122,3 +122,42 @@ def test_자동보정_줄은_제_이름을_적는다() -> None: assert "절사 자동보정" in cut.line("profit_adjustment").base_label manual = calculate_cost(replace(BASE, profit_adjustment_krw=Decimal(100))) assert manual.line("profit_adjustment").base_label == "설계자 명시 입력" + + +def test_고용보험_등급_고르기와_없음() -> None: + auto = calculate_cost(BASE) + grade1 = calculate_cost(replace(BASE, employment_insurance_grade="1")) + none = calculate_cost(replace(BASE, employment_insurance_grade="none")) + assert grade1.line("employment_insurance").rate_percent == Decimal("1.57") + assert grade1.amount("employment_insurance") > auto.amount("employment_insurance") + assert not none.has("employment_insurance") or none.amount("employment_insurance") == 0 + with pytest.raises(Exception): + calculate_cost(replace(BASE, employment_insurance_grade="9")) + + +def test_퇴직공제_적용_여부() -> None: + small = replace(BASE, estimated_price_krw=Decimal(50_000_000)) # 1억 미만 — 자동이면 안 섬 + assert ( + not calculate_cost(small).has("retirement_mutual_aid") + or calculate_cost(small).amount("retirement_mutual_aid") == 0 + ) + applied = calculate_cost(replace(small, retirement_mutual_aid_mode="apply")) + assert applied.amount("retirement_mutual_aid") > 0 + off = calculate_cost(replace(BASE, retirement_mutual_aid_mode="none")) + assert not off.has("retirement_mutual_aid") or off.amount("retirement_mutual_aid") == 0 + + +def test_폐기물_승률_밖_자리() -> None: + """실무 관행(울진소광) — 이윤 뒤 · 총원가 안 · 부가세 안 · 일반관리비·이윤 밑수 밖.""" + waste = Decimal(1_000_000) + law = calculate_cost(replace(BASE, waste_disposal_krw=waste)) + practice = calculate_cost( + replace(BASE, waste_disposal_krw=waste, waste_placement="after_profit") + ) + none = calculate_cost(BASE) + assert practice.amount("general_overhead") == none.amount("general_overhead") + assert practice.amount("profit") == none.amount("profit") + assert practice.amount("total_cost") - none.amount("total_cost") == waste + 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")