feat(b09): 원가계산서 기준 입력 4·5 — 고용보험 등급 · 퇴직공제 적용 · 폐기물 「승률 밖」 자리 · 칸 밑 안내
- 4 고용보험료: 자동(추정금액 구간 · 종전) · 없음 · 1~7등급 직접 - 5 퇴직공제부금비: 자동(추정금액 1억 이상 · 종전) · 적용 · 미적용 — 토목·준설·건축·기타는 현행 제비율에서 율이 같아 칸 안 둠 - 폐기물처리비 자리: 경비·승률 안(법 문언 · 기본) / 이윤 뒤·총원가 안·승률 밖(실무 관행 울진소광·실정보고) - 칸 밑 안내: 절사 기본 「안 함」과 실무 3건(총공사비 천원 미만 + 이윤 자동보정) · 폐기물 법/실무 갈라 적음 · 주/전문 규정 없음 - 시험: 등급 1.57% · 없음 · 없는 등급 멈춤 · 퇴직공제 1억 미만 적용 · 승률 밖 자리 · 골든셋 초록 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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호) — 일반관리비·이윤 밑수에 듦"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -47,6 +47,8 @@ interface CostSheetDto {
|
||||
choices: { key: string; label: string }[];
|
||||
amounts: { key: string; label: string }[];
|
||||
options: Record<string, FieldOption[]>;
|
||||
/** 칸 밑 안내 — 기본값의 까닭 · 실무가 어떻게 쓰는지. */
|
||||
hints: Record<string, string>;
|
||||
};
|
||||
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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user