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:
2026-09-14 03:01:54 +09:00
co-authored by Claude Opus 5
parent fb2de4a679
commit 72046265fc
6 changed files with 133 additions and 7 deletions
+48 -1
View File
@@ -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))
+21 -4
View File
@@ -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),
},
+16 -2
View File
@@ -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(
+39
View File
@@ -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")