Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-14 02:58:02 +09:00
7 changed files with 386 additions and 18 deletions
+44 -5
View File
@@ -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}
)
+87 -11
View File
@@ -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(
+124
View File
@@ -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) == {}