diff --git a/B09_Estimation/B09_Estimation_CostSheet.py b/B09_Estimation/B09_Estimation_CostSheet.py index d6abd37b..73742462 100644 --- a/B09_Estimation/B09_Estimation_CostSheet.py +++ b/B09_Estimation/B09_Estimation_CostSheet.py @@ -13,6 +13,12 @@ from decimal import Decimal, InvalidOperation from typing import Any from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult +from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( + CUT_BASES, + CUT_UNITS_KRW, + OVERHEAD_CLASSES, + VAT_MODES, +) from B09_Estimation.B09_Estimation_Rates import RateDataset _ZERO = Decimal(0) @@ -33,7 +39,38 @@ AMOUNT_FIELDS: tuple[tuple[str, str], ...] = ( ("procurement_fee_krw", "6.관/사급내용 — 조달수수료"), ("indirect_material_krw", "- 간 접 재 료 비"), ("profit_adjustment_krw", "이윤 보정액"), + ("overhead_managed_material_krw", "관리품목 자재대(일반관리비 밑수)"), + ("tax_exempt_material_krw", "면세품 금액(산림조합-면세품)"), ) +#: 10·11·12 — 선택지가 요율 데이터가 아니라 **엔진 규칙**(`Engine_Cost_Options`)에서 옴. +CHOICE_FIELDS: tuple[tuple[str, str], ...] = ( + ("overhead_class", "10.일반관리비"), + ("cut_basis", "11.공급가절사기준"), + ("cut_unit_krw", "11.절사 단위"), + ("vat_mode", "12.부 가 가 치 세"), +) +#: DFM `12.부 가 가 치 세` 선택지 표기 그대로. +VAT_MODE_LABELS = { + "supply": "(0) 공급가액의 10%", + "material": "(1) 재료비의 10%", + "none": "(2) 없음", + "forest_coop": "(3) 산림조합 형식", + "forest_coop_exempt": "(4) 산림조합-면세품", +} + + +def choice_options() -> dict[str, list[dict[str, str]]]: + """10·11·12 선택지. 절사 「안 함」이 첫째 = 기본(이윤을 자동으로 안 깎음).""" + return { + "overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()], + "cut_basis": [ + {"value": "", "label": "절사 안 함(이윤보정액 입력)"}, + *({"value": k, "label": f"{v}에서 조정"} for k, v in CUT_BASES.items()), + ], + "cut_unit_krw": [{"value": str(u), "label": f"{u:,}원 미만"} for u in CUT_UNITS_KRW], + "vat_mode": [{"value": k, "label": VAT_MODE_LABELS[k]} for k in VAT_MODES], + } + #: 공종 이름표 — 조달청 제비율 적용기준(현행 2026-04-13) 표기. WORK_TYPE_LABELS: dict[str, str] = { @@ -87,7 +124,7 @@ def field_options(dataset: RateDataset) -> dict[str, list[dict[str, str]]]: for key, values in sources.items(): labels = {**WORK_TYPE_LABELS, **(SAFETY_LABELS if key == "work_type_safety" else {})} options[key] = [{"value": v, "label": labels.get(v, v)} for v in _unique(values)] - return options + return {**options, **choice_options()} def _decimal(value: Any) -> Decimal: @@ -102,10 +139,10 @@ def clean_settings(values: dict[str, Any], dataset: RateDataset) -> dict[str, An """저장할 값 — 데이터에 없는 공종·음수·빈칸은 버린다(버린 칸은 엔진 기본값이 섬).""" options = field_options(dataset) cleaned: dict[str, Any] = {} - for key, _ in WORK_TYPE_FIELDS: + for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS): value = str(values.get(key) or "") - if value in {option["value"] for option in options[key]}: - cleaned[key] = value + if value and value in {option["value"] for option in options[key]}: + cleaned[key] = int(value) if key == "cut_unit_krw" else value for key, _ in AMOUNT_FIELDS: if values.get(key) not in (None, ""): cleaned[key] = str(_decimal(values.get(key))) @@ -125,7 +162,9 @@ def cost_input( waste_separate_order: bool = False, ) -> CostInput: """내역서 직접비 + 저장값 → 엔진 입력. 저장 안 한 칸은 **엔진 기본값**.""" - kwargs: dict[str, Any] = {key: stored[key] for key, _ in WORK_TYPE_FIELDS if stored.get(key)} + kwargs: dict[str, Any] = { + key: stored[key] for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS) if stored.get(key) + } kwargs.update( {key: _decimal(stored[key]) for key, _ in AMOUNT_FIELDS if stored.get(key) is not None} ) diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index e48df4ae..5653b0eb 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -19,6 +19,14 @@ from __future__ import annotations from dataclasses import dataclass, field, replace from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal +from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( + CUT_BASES, + VAT_MODES, + cut_gap, + overhead_base, + profit_cut, + vat_base, +) from B09_Estimation.B09_Estimation_Rates import ( RateDataset, flat_rate, @@ -87,6 +95,8 @@ class CostInput: #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. profit_adjustment_krw: Decimal = _ZERO + #: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리. + profit_adjustment_label: str = "" #: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가). #: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고 @@ -97,6 +107,21 @@ class CostInput: #: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐. waste_separate_order: bool = False + #: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ── + #: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만). + form: str = "general" + #: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사). + overhead_class: str = "civil_landscape_industrial" + #: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대. + overhead_managed_material_krw: Decimal = _ZERO + #: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액. + vat_mode: str = "supply" + tax_exempt_material_krw: Decimal = _ZERO + #: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · 빈 값은 안 자름. + #: ⚠ 고른 때만 이윤을 자동보정한다 — 기본은 수동 이윤보정만(2026-09-08 합의). + cut_basis: str = "" + cut_unit_krw: int = 0 + #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. #: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다. @@ -215,14 +240,16 @@ def _emitter(result: CostResult): _SCALE_MAX_PASSES = 5 -def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple: +def _scale_signature( + dataset: RateDataset, amount: Decimal, overhead_class: str = "civil_landscape_industrial" +) -> tuple: """이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문. 규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다. """ parts: list[str] = [] for variable, bracket_field, key in ( - ("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"), + ("rate_overhead", "estimated_price_bracket", overhead_class), ("rate_profit", "estimated_price_bracket", "brackets"), ("rate_goyong", "estimated_amount_bracket", "brackets"), ("rate_subcontract_payment_guarantee", "estimated_price_bracket", "brackets"), @@ -280,7 +307,7 @@ def calculate_cost(data: CostInput) -> CostResult: tried_amounts: list[Decimal] = [] for _ in range(_SCALE_MAX_PASSES): - signature = _scale_signature(dataset, scale) + signature = _scale_signature(dataset, scale, data.overhead_class) if signature in seen_signatures: # 구간이 진동한다 — 보수적으로 **높은 쪽**을 잡고 그 사실을 남긴다. highest = max([*tried_amounts, scale]) @@ -292,7 +319,7 @@ def calculate_cost(data: CostInput) -> CostResult: trial = _calculate_with_scale(data, dataset, scale, []) estimated_price = trial.totals["total_cost"] - if _scale_signature(dataset, estimated_price) == signature: + if _scale_signature(dataset, estimated_price, data.overhead_class) == signature: return trial scale = estimated_price @@ -301,11 +328,50 @@ def calculate_cost(data: CostInput) -> CostResult: ) +#: 절사 뒤 잔차 맞춤 반복 상한 — 실무 수식도 잔차 칸 한 번뿐(표본 넷 모두 0). +_CUT_MAX_PASSES = 3 + + def _calculate_with_scale( data: CostInput, dataset: RateDataset, scale: Decimal, notes: list[str], +) -> CostResult: + """규모 기준액을 못 박고 계산 — 11. 절사를 **고른 때만** 이윤을 보정해 다시 셈.""" + result = _calculate_once(data, dataset, scale, notes) + if not data.cut_basis or data.cut_unit_krw <= 0: + return result + key = "grand_total" if data.cut_basis == "grand_total" else "total_cost" + automatic = _ZERO + for _ in range(_CUT_MAX_PASSES): + gap = cut_gap(result.totals[key], data.cut_unit_krw) + if gap == 0: + break + # 첫 차례는 실무 식(총공사비·공급가액 비례 부가세면 ÷1.1), 그 뒤는 잔차 그대로. + automatic += profit_cut(gap, data.cut_basis, data.vat_mode) if automatic == 0 else gap + adjusted = replace( + data, + profit_adjustment_krw=data.profit_adjustment_krw + automatic, + cut_basis="", + profit_adjustment_label=( + f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만" + " 절사 자동보정" + (" + 설계자 입력" if data.profit_adjustment_krw else "") + ), + ) + result = _calculate_once(adjusted, dataset, scale, notes) + result.notes.append( + f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —" + f" 이윤에서 {automatic:,}원 자동보정(설계자가 고른 절사 기준)" + ) + return result + + +def _calculate_once( + data: CostInput, + dataset: RateDataset, + scale: Decimal, + notes: list[str], ) -> CostResult: """규모 기준액을 못 박고 한 번 계산한다.""" result = CostResult(rate_version=dataset.version_stamp, notes=list(notes)) @@ -378,17 +444,20 @@ def _calculate_with_scale( amount=net_construction_cost, ) + # 10. (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식이 정함 + # (일반 = 순공사원가 + 관리품목자재대). overhead_row = select_bracket( - dataset.variable("rate_overhead")["civil_landscape_industrial"], + dataset.variable("rate_overhead")[data.overhead_class], amount_field="estimated_price_bracket", amount=ctx.scale_reference, label="일반관리비", ) + managed = data.overhead_managed_material_krw overhead = emit( key="general_overhead", name="일반관리비", - base_label="순공사원가", - base=net_construction_cost, + base_label="순공사원가+관리품목자재대" if managed else "순공사원가", + base=overhead_base(data.form, net_construction_cost, managed), percent=rate_percent(overhead_row, label="일반관리비"), ) @@ -402,12 +471,19 @@ def _calculate_with_scale( base=total_cost, amount=total_cost, ) + # 12. 부가세 방식 — 밑수만 갈림(공급가액·재료비·없음·산림조합 둘). 율은 요율 데이터. vat = emit( key="vat", name="부가가치세", - base_label="총원가", - base=total_cost, - percent=flat_rate(dataset, "rate_vat"), + base_label="총원가" if data.vat_mode == "supply" else VAT_MODES[data.vat_mode], + base=vat_base( + data.vat_mode, + total_cost, + material_cost, + data.direct_expense_krw, + data.tax_exempt_material_krw, + ), + percent=_ZERO if data.vat_mode == "none" else flat_rate(dataset, "rate_vat"), ) contract_amount = total_cost + vat emit( @@ -477,7 +553,7 @@ def _profit_lines( emit( key="profit_adjustment", name="이윤 조정액", - base_label="설계자 명시 입력", + base_label=data.profit_adjustment_label or "설계자 명시 입력", base=_ZERO, amount=-data.profit_adjustment_krw, note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)", diff --git a/B09_Estimation/B09_Estimation_Engine_Cost_Options.py b/B09_Estimation/B09_Estimation_Engine_Cost_Options.py new file mode 100644 index 00000000..b8bd234d --- /dev/null +++ b/B09_Estimation/B09_Estimation_Engine_Cost_Options.py @@ -0,0 +1,94 @@ +"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절). + +`Engine_Cost` 가 700줄에 가까워 **고르는 규칙만** 여기 둔다. 계산 사슬은 엔진 그대로. + +근거(2026-09-14 조사 — STmate `TWM_KANJUB` 칸 힌트 · 실무 엑셀 수식) + 10 일반관리비 — 식은 그대로, **요율 구간표만** 주공사/전문공사로 갈림 + (KJ 「전문공사: 1/10 규모」). 밑수는 **형식마다** 다름(35번 §1) — 일반 T 「순공사원가 + + 관리품목자재대」. + 11 절사 — 「절사된 금액은 이윤에서 자동보정」(KJ 힌트). 실무 봉화·영월: + 차액 = 총공사비 − 천원 미만 버림 · 이윤 − ROUND(차액 ÷ 1.1) − 잔차 + (부가세가 공급가액 비례라 ÷1.1). + 영덕(산림조합-면세품): 차액 그대로 뺌(부가세가 이윤과 무관). + 12 부가세 — 「공급가액의 10% / 재료비의 10% / 없음 / 산림조합 형식 (재료비 + 산출경비 의 10%) / + 산림조합-면세품 (재료비-면세품 + 산출경비의 10%)」(KJ 힌트). ⚠ 기성 단계는 선택지가 달라 + (직접입력 · 공급가액 · 재료비 · 재료비+산출경비) 칸 이름을 문자열로 두어 늘릴 수 있게 함. +⚠ 기본값은 **전부 종전과 같게** — 공급가액 10% · 주공사 · 절사 없음(수동 이윤보정만). + 자동 이윤보정은 설계자가 절사 기준을 **고른 때만** 돈다(2026-09-08 「조용히 깎지 않음」). +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal + +_ZERO = Decimal(0) + +# ── 10 일반관리비 ───────────────────────────────────────────────────────── +#: 요율 데이터 `rate_overhead` 의 표 이름 — (주)공사 / 전문공사. +OVERHEAD_CLASSES = { + "civil_landscape_industrial": "(주)공사", + "specialty_electric_communication_fire_other": "전문공사", +} +DEFAULT_OVERHEAD_CLASS = "civil_landscape_industrial" + +#: 형식별 일반관리비 밑수 — 지금은 일반(T)만. 나머지 셋은 형식이 설 때 한 줄씩. +OVERHEAD_BASE_LABELS = {"general": "순공사원가+관리품목자재대"} + + +def overhead_base(form: str, net_construction_cost: Decimal, managed_material: Decimal) -> Decimal: + """형식별 일반관리비 밑수. 모르는 형식은 멈춤 — 다른 형식 밑수로 조용히 세지 않음.""" + if form == "general": + return net_construction_cost + managed_material + raise ValueError(f"일반관리비 밑수가 아직 없는 형식입니다: {form}") + + +# ── 12 부가가치세 ───────────────────────────────────────────────────────── +VAT_MODES = { + "supply": "공급가액", + "material": "재료비", + "none": "없음", + "forest_coop": "재료비+산출경비", + "forest_coop_exempt": "재료비−면세품+산출경비", +} +DEFAULT_VAT_MODE = "supply" + + +def vat_base( + mode: str, + total_cost: Decimal, + material_cost: Decimal, + direct_expense: Decimal, + tax_exempt_material: Decimal, +) -> Decimal: + """부가세 밑수. 「없음」은 0. 모르는 방식은 멈춤.""" + if mode == "supply": + return total_cost + if mode == "material": + return material_cost + if mode == "none": + return _ZERO + if mode == "forest_coop": + return material_cost + direct_expense + if mode == "forest_coop_exempt": + return material_cost - tax_exempt_material + direct_expense + raise ValueError(f"모르는 부가세 방식입니다: {mode}") + + +# ── 11 절사 ─────────────────────────────────────────────────────────────── +#: 절사 기준 — `grand_total` 총공사비에서 조정 · `supply` 공급가액 자체를 자름. 빈 값 = 안 자름. +CUT_BASES = {"grand_total": "총공사비", "supply": "공급가액"} +CUT_UNITS_KRW = (10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000) + + +def cut_gap(amount: Decimal, unit: int) -> Decimal: + """`unit` 원 미만 버림으로 떨어지는 몫.""" + unit_decimal = Decimal(unit) + kept = (amount / unit_decimal).quantize(Decimal(1), rounding=ROUND_FLOOR) * unit_decimal + return amount - kept + + +def profit_cut(gap: Decimal, basis: str, vat_mode: str) -> Decimal: + """이윤에서 뺄 몫. 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1 반올림(실무 봉화 373→339).""" + if basis == "grand_total" and vat_mode == "supply": + return (gap / Decimal("1.1")).quantize(Decimal(1), rounding=ROUND_HALF_UP) + return gap diff --git a/B09_Estimation/B09_Estimation_Router_CostSheet.py b/B09_Estimation/B09_Estimation_Router_CostSheet.py index c4cd62d6..4554af96 100644 --- a/B09_Estimation/B09_Estimation_Router_CostSheet.py +++ b/B09_Estimation/B09_Estimation_Router_CostSheet.py @@ -20,6 +20,7 @@ from fastapi.responses import JSONResponse from B09_Estimation.B09_Estimation_CostSheet import ( AMOUNT_FIELDS, + CHOICE_FIELDS, SETTINGS_KEY, WORK_TYPE_FIELDS, clean_settings, @@ -103,13 +104,14 @@ async def get_cost_sheet(project_id: UUID) -> JSONResponse: "rows": sheet_rows(result, data), "status_line": status_line(result, data), "settings": { - **{key: getattr(data, key) for key, _ in WORK_TYPE_FIELDS}, + **{key: str(getattr(data, key)) for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS)}, **{key: str(getattr(data, key)) for key, _ in AMOUNT_FIELDS}, "duration_days": data.duration_days, }, "stored": stored, "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], "amounts": [{"key": k, "label": label} for k, label in AMOUNT_FIELDS], "options": field_options(dataset), }, diff --git a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts index f23c7f67..abee0240 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_CostSheet.ts @@ -43,6 +43,8 @@ interface CostSheetDto { settings: Record; fields: { work_types: { key: string; label: string }[]; + /** 10·11·12 — 선택지는 엔진 규칙에서(요율 데이터 아님). */ + choices: { key: string; label: string }[]; amounts: { key: string; label: string }[]; options: Record; }; @@ -193,7 +195,8 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): () => undefined, ), ); - const workType = (key: string) => sheet.fields.work_types.find((f) => f.key === key); + const workType = (key: string) => + [...sheet.fields.work_types, ...sheet.fields.choices].find((f) => f.key === key); const typeSelect = (key: string) => { const field = workType(key); if (!field) return; @@ -217,6 +220,11 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void): typeSelect("work_type_safety"); typeSelect("environment_work_type"); typeSelect("equipment_guarantee_work_type"); + // 10·11·12 — DFM 번호 차례. 절사는 「안 함」이 기본(고른 때만 이윤 자동보정). + typeSelect("overhead_class"); + typeSelect("cut_basis"); + typeSelect("cut_unit_krw"); + typeSelect("vat_mode"); 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 new file mode 100644 index 00000000..27960c35 --- /dev/null +++ b/resources/tester/test_b09_cost_options.py @@ -0,0 +1,124 @@ +"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절). + +⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀: + 봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996. +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402 +from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( # noqa: E402 + cut_gap, + profit_cut, +) + +BASE = CostInput( + direct_material_krw=Decimal(123_456_789), + direct_labor_krw=Decimal(234_567_891), + direct_expense_krw=Decimal(45_678_912), + estimated_price_krw=Decimal(1_000_000_000), # 10억 — 주공사 8.0 / 전문 6.5 가 갈리는 자리 +) + + +def test_기본값은_종전과_같다() -> None: + explicit = replace( + BASE, overhead_class="civil_landscape_industrial", vat_mode="supply", cut_basis="" + ) + assert calculate_cost(explicit).totals == calculate_cost(BASE).totals + + +def test_전문공사는_요율표만_바뀐다() -> None: + main, special = ( + calculate_cost(BASE), + calculate_cost(replace(BASE, overhead_class="specialty_electric_communication_fire_other")), + ) + assert ( + main.line("general_overhead").base_amount_krw + == special.line("general_overhead").base_amount_krw + ) + assert ( + main.line("general_overhead").rate_percent != special.line("general_overhead").rate_percent + ) + + +def test_관리품목자재대는_일반관리비_밑수에_든다() -> None: + plain = calculate_cost(BASE) + managed = calculate_cost(replace(BASE, overhead_managed_material_krw=Decimal(10_000_000))) + gain = ( + managed.line("general_overhead").base_amount_krw + - plain.line("general_overhead").base_amount_krw + ) + assert gain == Decimal(10_000_000) + assert "관리품목자재대" in managed.line("general_overhead").base_label + + +@pytest.mark.parametrize( + ("mode", "expected"), + [ + ("material", lambda r, d: r.amount("material_cost")), + ("none", lambda r, d: Decimal(0)), + ("forest_coop", lambda r, d: r.amount("material_cost") + d.direct_expense_krw), + ( + "forest_coop_exempt", + lambda r, d: ( + r.amount("material_cost") - d.tax_exempt_material_krw + d.direct_expense_krw + ), + ), + ], +) +def test_부가세_방식은_밑수만_바꾼다(mode, expected) -> None: + data = replace(BASE, vat_mode=mode, tax_exempt_material_krw=Decimal(3_327_500)) + result = calculate_cost(data) + assert result.line("vat").base_amount_krw == expected(result, data) + if mode == "none": + assert result.amount("vat") == 0 + + +def test_실무_절사_식() -> None: + assert profit_cut(Decimal(373), "grand_total", "supply") == 339 # 봉화 + assert profit_cut(Decimal(695), "grand_total", "supply") == 632 # 영월 + assert profit_cut(Decimal(996), "grand_total", "forest_coop_exempt") == 996 # 영덕 + assert cut_gap(Decimal(1_270_728_373), 1000) == 373 + + +@pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"]) +def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None: + plain = calculate_cost(replace(BASE, vat_mode=vat_mode)) + cut = calculate_cost( + replace(BASE, vat_mode=vat_mode, cut_basis="grand_total", cut_unit_krw=1000) + ) + assert cut.amount("grand_total") % 1000 == 0 + assert 0 <= plain.amount("grand_total") - cut.amount("grand_total") < 1000 + assert cut.amount("profit") < plain.amount("profit") + assert cut.has("profit_adjustment") and any("자동보정" in note for note in cut.notes) + # 이윤 말고는 안 움직임 — 순공사원가·일반관리비 그대로. + for key in ("net_construction_cost", "general_overhead"): + assert cut.amount(key) == plain.amount(key) + + +def test_공급가액_절사는_차액_그대로() -> None: + plain = calculate_cost(BASE) + cut = calculate_cost(replace(BASE, cut_basis="supply", cut_unit_krw=10_000)) + assert cut.amount("total_cost") % 10_000 == 0 + assert plain.amount("profit") - cut.amount("profit") == plain.amount("total_cost") % 10_000 + + +def test_절사를_안_고르면_이윤을_안_건드린다() -> None: + assert not calculate_cost(replace(BASE, cut_unit_krw=1000)).has("profit_adjustment") + + +def test_자동보정_줄은_제_이름을_적는다() -> None: + cut = calculate_cost(replace(BASE, cut_basis="grand_total", cut_unit_krw=1000)) + 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 == "설계자 명시 입력" diff --git a/resources/tester/test_b09_cost_sheet_layout.py b/resources/tester/test_b09_cost_sheet_layout.py index d3e947d2..f287f98d 100644 --- a/resources/tester/test_b09_cost_sheet_layout.py +++ b/resources/tester/test_b09_cost_sheet_layout.py @@ -113,3 +113,28 @@ def test_요율표_구간_표기와_묶음() -> None: assert titles[0] == "일반관리비 · 이윤" and "산업안전보건관리비" in titles for section in rate_sections(load_rate_dataset()): assert all(len(row) == len(section["columns"]) for row in section["rows"]), section["title"] + + +def test_10_11_12_칸이_저장값을_엔진으로_옮긴다() -> None: + dataset = load_rate_dataset() + stored = clean_settings( + { + "overhead_class": "specialty_electric_communication_fire_other", + "cut_basis": "grand_total", + "cut_unit_krw": "1000", + "vat_mode": "forest_coop", + "tax_exempt_material_krw": "5000", + "cut_unit_krw_bad": "7", + }, + dataset, + ) + assert stored["cut_unit_krw"] == 1000 and stored["vat_mode"] == "forest_coop" + data = cost_input(DIRECT, stored) + assert (data.overhead_class, data.cut_basis, data.cut_unit_krw) == ( + "specialty_electric_communication_fire_other", + "grand_total", + 1000, + ) + rows = {r["key"]: r for r in sheet_rows(calculate_cost(data), data)} + assert int(rows["grand_total"]["amount_krw"]) % 1000 == 0 + assert clean_settings({"cut_unit_krw": "7", "vat_mode": "moon"}, dataset) == {}