feat(b09): 원가계산서 기준 입력 13~16 — 사급비 위치 · 낙찰 · 이행보증 · 적용기준 · 이행보증 식 고침

- 13 사급비 위치: 재료비에 포함만(사급은 이미 내역 재료비 · 실무 거창·영월) — 칸만 받음
- 14 낙찰방식: 하도급대금 지급보증 요율 줄 — 종합심사 외(규모 구간) · 종합심사 대상(300억 이상 줄) · 턴키(0.084)
- 15 이행보증: 일반계약 = 추정가격 300억 이상만 공사이행보증수수료 · 최저가·기술제안 = 규모 무관
  (종전 늘 계산 — 제비율 기준 「소규모 통상 비대상」·실무 여섯 건 금액 없음과 어긋났음)
- 곁 고침: 70억 이상 구간 [기초액 + (직공비−기준액) × 율] × 공기 — 종전 백분율만 쓰고 기초액·기준액을 버렸음
- 16 적용기준: 칸만 받음(조달청 표 한 벌 · 제20조 국가 표 이미지 미확인 · 행자부 표 없음)
- 칸 밑 안내 · 시험: 300억 미만 안 섬 · 100억 식 965,000 · 요율 줄 셋 · 13·16 계산 불변 · 골든셋 초록

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:56:26 +09:00
co-authored by Claude Opus 5
parent d4ab146e02
commit 8693cf0012
5 changed files with 165 additions and 1 deletions
@@ -50,6 +50,10 @@ CHOICE_FIELDS: tuple[tuple[str, str], ...] = (
("cut_basis", "11.공급가절사기준"),
("cut_unit_krw", "11.절사 단위"),
("vat_mode", "12.부 가 가 치 세"),
("private_material_position", "13.사급비 위치"),
("bid_method", "14. 낙 찰 방 식"),
("performance_guarantee_mode", "15. 이행보증 방식"),
("contract_law_basis", "16. 적용기준"),
("waste_placement", "폐기물처리비 자리"),
)
#: 칸 밑 안내 — 기본값의 까닭과 실무가 어떻게 쓰는지(2026-09-14 브레인 판정 · 도자 60m 방식).
@@ -66,6 +70,25 @@ FIELD_HINTS: dict[str, str] = {
" 「절사 자동보정」으로 적힘"
),
"vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건",
"private_material_position": (
"사급자재대는 설계내역서 재료비에 이미 들어 옴(실무 거창·영월 · STmate 기본) —"
" 다른 자리(이윤 다음·도급공사비 직전·관급자재대 다음)는 사급 금액 칸이 설 때 엶"
),
"bid_method": (
"하도급대금 지급보증 요율 줄만 고름 — 종합심사 외 = 규모 구간 ·"
" 종합심사 대상 = 300억 이상 줄"
" · 턴키(대안) = 0.084% · 설계 단계 금액엔 이 한 줄뿐, 계약 단계 단가 적용률로 이어질 칸"
),
"performance_guarantee_mode": (
"일반계약(기본) = 추정가격 300억 이상일 때만 공사이행보증수수료를 셈 · 최저가·기술제안 ="
" 규모와 무관하게 셈 — 근거: 예정가격작성기준 제19조③20호(시행령 제52조제1항 단서) ·"
" 제비율 기준 「소규모 공사는 통상 비대상」 · 실무 임도 원가계산서 여섯 건에 금액 없음"
),
"contract_law_basis": (
"계산에 안 씀 — 지금 요율은 조달청 표 한 벌 ·"
" 국가/지방 구간 차이(예정가격작성기준 제20조 표)는"
" 원문이 이미지라 미확인 · 행자부 표 없음. 발주처 구분만 받아 둠"
),
"waste_placement": (
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
@@ -101,6 +124,24 @@ def choice_options() -> dict[str, list[dict[str, str]]]:
{"value": "expense", "label": "경비 — 승률 안(법 문언 · 기본)"},
{"value": "after_profit", "label": "이윤 뒤 · 총원가 안 — 승률 밖(실무 관행)"},
],
"private_material_position": [
{"value": "material", "label": "재료비에 포함"},
],
"bid_method": [
{"value": "not_comprehensive", "label": "종합심사 외"},
{"value": "comprehensive", "label": "종합심사 대상"},
{"value": "turnkey", "label": "턴키(대안)"},
],
"performance_guarantee_mode": [
{"value": "general", "label": "일반계약"},
{"value": "lowest_price_tech", "label": "최저가,기술제안"},
],
"contract_law_basis": [
{"value": "", "label": "미정(계산에 안 씀)"},
{"value": "national", "label": "일반(국가계약법)"},
{"value": "local", "label": "일반(지방계약법)"},
{"value": "moi", "label": "행자부기준"},
],
"overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()],
"cut_basis": [
{"value": "", "label": "절사 안 함(이윤보정액 입력)"},
@@ -133,6 +133,18 @@ class CostInput:
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
#: 율이 안 갈리는 구분은 칸으로 안 세움.
retirement_mutual_aid_mode: str = "auto"
#: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로
#: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶.
private_material_position: str = "material"
#: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`.
#: 하도급대금 지급보증 요율 줄만 고름.
bid_method: str = "not_comprehensive"
#: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) ·
#: `lowest_price_tech`(최저가·기술제안: 규모 무관).
performance_guarantee_mode: str = "general"
#: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방
#: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음.
contract_law_basis: str = ""
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
rate_overrides: tuple[dict[str, Any], ...] = ()
+71 -1
View File
@@ -151,6 +151,35 @@ def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decim
return Decimal(match.group(1))
#: 70억 이상 구간 식 — `(기초액 + (direct_cost - 기준액) * 율%) * duration_years`.
_RE_GUARANTEE_STEP = re.compile(r"\(\s*(\d+)\s*\+\s*\(\s*direct_cost\s*-\s*(\d+)\s*\)")
def _guarantee_step(row: dict[str, Any]) -> tuple[Decimal, Decimal]:
"""(기초액, 기준액) — 70억 미만 식은 둘 다 0.
⚠ 2026-09-14 고침: 종전엔 백분율만 뽑아 **기초액·기준액 차감을 버렸다**(직공비 70억 이상에서
틀림 — 조달청 제비율 적용기준 §10 「[79만원 + (직공비−75억원) × 0.0070%] × 공기(년)」).
"""
match = _RE_GUARANTEE_STEP.search(str(row.get("formula", "")))
if not match:
return _ZERO, _ZERO
return Decimal(match.group(1)), Decimal(match.group(2))
#: 15. 이행보증 — 일반계약이면 **추정가격 300억 이상**일 때만 계상(STmate 힌트 「일반계약일경우
#: 추정가격 300억 이상공사에 적용」 · 지방계약법 시행령 이행보증서 의무 대상 · 제비율 기준
#: 「소규모 공사는 통상 비대상」). 최저가·기술제안이면 규모와 무관하게 계상.
PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW = Decimal(30_000_000_000)
def _performance_guarantee_applies(data: CostInput, ctx: ExpenseContext) -> bool:
mode = str(getattr(data, "performance_guarantee_mode", "general") or "general")
if mode == "lowest_price_tech":
return True
return ctx.scale_reference >= PERFORMANCE_GUARANTEE_GENERAL_MIN_KRW
def safety_management_cost(
dataset: RateDataset,
data: CostInput,
@@ -278,16 +307,30 @@ def statutory_expenses(
)
continue
if key == "performance_guarantee_fee" and not _performance_guarantee_applies(data, ctx):
continue # 15. 일반계약·추정가격 300억 미만 — 줄 자체를 만들지 않는다
base, percent, note = _base_and_rate(dataset, data, ctx, item)
if base is None:
continue # 적용 하한 미달 — 줄 자체를 만들지 않는다
flat = _ZERO
if key == "performance_guarantee_fee":
# 70억 이상 구간 기초액 × 공기(년) — 밑수는 `_base_and_rate` 가 기준액을 뺐다.
row = select_bracket(
dataset.variable(item.rate_variable)["brackets"],
amount_field="direct_cost_bracket",
amount=ctx.direct_construction_cost,
label=item.name,
)
flat = _guarantee_step(row)[0] * Decimal(str(data.duration_days)) / Decimal(365)
amount = emit(
key=key,
name=item.name,
base_label=item.base_label,
base=base,
percent=percent,
flat=flat,
note=note,
)
if key == "health_insurance":
@@ -395,6 +438,28 @@ def _base_and_rate(
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
if key == "subcontract_payment_guarantee":
# 14. 낙찰방식 — 턴키(대안)는 규모와 무관한 한 줄(0.084) · 종합심사 대상은 300억 이상 줄.
bid = str(getattr(data, "bid_method", "not_comprehensive") or "not_comprehensive")
if bid == "turnkey":
row = next(
(
r
for r in variable["brackets"]
if r["estimated_price_bracket"].startswith("turnkey")
),
None,
)
if row is None:
raise RateLookupError(f"{item.name}: 턴키(대안) 요율이 요율 데이터에 없습니다")
return ctx.direct_construction_cost, rate_percent(row, label=item.name), "턴키(대안)"
if bid == "comprehensive":
suffix = f"gte_30_billion_{data.subcontract_guarantee_variant}"
row = next(
(r for r in variable["brackets"] if r["estimated_price_bracket"] == suffix), None
)
if row is None:
raise RateLookupError(f"{item.name}: 종합심사 대상 요율이 없습니다 — {suffix}")
return ctx.direct_construction_cost, rate_percent(row, label=item.name), "종합심사 대상"
# 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다.
row = select_bracket(
variable["brackets"],
@@ -415,6 +480,11 @@ def _base_and_rate(
percent = _guarantee_percent_from_formula(row, label=item.name)
years = Decimal(str(data.duration_days)) / Decimal(365)
note = variable.get("typical_forest_road_applicability", "")
return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else ""
deduct = _guarantee_step(row)[1]
return (
(ctx.direct_construction_cost - deduct) * years,
percent,
f"임도 적용성: {note}" if note else "",
)
raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}")
@@ -231,6 +231,11 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
typeSelect("cut_basis");
typeSelect("cut_unit_krw");
typeSelect("vat_mode");
// 13~16 — 사급비 위치·적용기준은 칸만 받음(계산에 안 씀 · 까닭은 칸 밑 안내).
typeSelect("private_material_position");
typeSelect("bid_method");
typeSelect("performance_guarantee_mode");
typeSelect("contract_law_basis");
typeSelect("waste_placement");
box.append(el("div", "b09cs__group", "계산/인쇄 설정"));
for (const field of sheet.fields.amounts.slice(2)) {
+36
View File
@@ -161,3 +161,39 @@ def test_폐기물_승률_밖_자리() -> None:
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")
def test_이행보증은_일반계약이면_300억_미만에서_안_선다() -> None:
assert not calculate_cost(BASE).has("performance_guarantee_fee")
tech = calculate_cost(replace(BASE, performance_guarantee_mode="lowest_price_tech"))
assert tech.amount("performance_guarantee_fee") > 0
def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None:
"""제비율 기준 §10 — [79만원 + (직공비−75억원) × 0.0070%] × 공기(년)."""
big = CostInput(
direct_material_krw=Decimal(500_000_000),
direct_labor_krw=Decimal(300_000_000),
direct_expense_krw=Decimal(9_200_000_000), # 직공비 100억 · 안전 대상액 8억
estimated_price_krw=Decimal(12_000_000_000),
duration_days=365,
performance_guarantee_mode="lowest_price_tech",
)
assert calculate_cost(big).amount("performance_guarantee_fee") == Decimal(790_000 + 175_000)
def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None:
assert calculate_cost(BASE).line("subcontract_payment_guarantee").rate_percent == Decimal(
"0.081"
)
turnkey = calculate_cost(replace(BASE, bid_method="turnkey"))
assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084")
comp = calculate_cost(replace(BASE, bid_method="comprehensive"))
assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071")
def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None:
same = calculate_cost(
replace(BASE, private_material_position="material", contract_law_basis="local")
)
assert same.totals == calculate_cost(BASE).totals