Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -43,6 +43,8 @@ interface CostSheetDto {
|
||||
settings: Record<string, string | number>;
|
||||
fields: {
|
||||
work_types: { key: string; label: string }[];
|
||||
/** 10·11·12 — 선택지는 엔진 규칙에서(요율 데이터 아님). */
|
||||
choices: { key: string; label: string }[];
|
||||
amounts: { key: string; label: string }[];
|
||||
options: Record<string, FieldOption[]>;
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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 == "설계자 명시 입력"
|
||||
@@ -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) == {}
|
||||
|
||||
Reference in New Issue
Block a user