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:
@@ -50,6 +50,10 @@ CHOICE_FIELDS: tuple[tuple[str, str], ...] = (
|
|||||||
("cut_basis", "11.공급가절사기준"),
|
("cut_basis", "11.공급가절사기준"),
|
||||||
("cut_unit_krw", "11.절사 단위"),
|
("cut_unit_krw", "11.절사 단위"),
|
||||||
("vat_mode", "12.부 가 가 치 세"),
|
("vat_mode", "12.부 가 가 치 세"),
|
||||||
|
("private_material_position", "13.사급비 위치"),
|
||||||
|
("bid_method", "14. 낙 찰 방 식"),
|
||||||
|
("performance_guarantee_mode", "15. 이행보증 방식"),
|
||||||
|
("contract_law_basis", "16. 적용기준"),
|
||||||
("waste_placement", "폐기물처리비 자리"),
|
("waste_placement", "폐기물처리비 자리"),
|
||||||
)
|
)
|
||||||
#: 칸 밑 안내 — 기본값의 까닭과 실무가 어떻게 쓰는지(2026-09-14 브레인 판정 · 도자 60m 방식).
|
#: 칸 밑 안내 — 기본값의 까닭과 실무가 어떻게 쓰는지(2026-09-14 브레인 판정 · 도자 60m 방식).
|
||||||
@@ -66,6 +70,25 @@ FIELD_HINTS: dict[str, str] = {
|
|||||||
" 「절사 자동보정」으로 적힘"
|
" 「절사 자동보정」으로 적힘"
|
||||||
),
|
),
|
||||||
"vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건",
|
"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": (
|
"waste_placement": (
|
||||||
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
|
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
|
||||||
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
|
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
|
||||||
@@ -101,6 +124,24 @@ def choice_options() -> dict[str, list[dict[str, str]]]:
|
|||||||
{"value": "expense", "label": "경비 — 승률 안(법 문언 · 기본)"},
|
{"value": "expense", "label": "경비 — 승률 안(법 문언 · 기본)"},
|
||||||
{"value": "after_profit", "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()],
|
"overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()],
|
||||||
"cut_basis": [
|
"cut_basis": [
|
||||||
{"value": "", "label": "절사 안 함(이윤보정액 입력)"},
|
{"value": "", "label": "절사 안 함(이윤보정액 입력)"},
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ class CostInput:
|
|||||||
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
|
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
|
||||||
#: 율이 안 갈리는 구분은 칸으로 안 세움.
|
#: 율이 안 갈리는 구분은 칸으로 안 세움.
|
||||||
retirement_mutual_aid_mode: str = "auto"
|
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` 한 줄씩, 사유 포함) — 발주처별 별도요율.
|
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
|
||||||
rate_overrides: tuple[dict[str, Any], ...] = ()
|
rate_overrides: tuple[dict[str, Any], ...] = ()
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,35 @@ def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decim
|
|||||||
return Decimal(match.group(1))
|
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(
|
def safety_management_cost(
|
||||||
dataset: RateDataset,
|
dataset: RateDataset,
|
||||||
data: CostInput,
|
data: CostInput,
|
||||||
@@ -278,16 +307,30 @@ def statutory_expenses(
|
|||||||
)
|
)
|
||||||
continue
|
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)
|
base, percent, note = _base_and_rate(dataset, data, ctx, item)
|
||||||
if base is None:
|
if base is None:
|
||||||
continue # 적용 하한 미달 — 줄 자체를 만들지 않는다
|
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(
|
amount = emit(
|
||||||
key=key,
|
key=key,
|
||||||
name=item.name,
|
name=item.name,
|
||||||
base_label=item.base_label,
|
base_label=item.base_label,
|
||||||
base=base,
|
base=base,
|
||||||
percent=percent,
|
percent=percent,
|
||||||
|
flat=flat,
|
||||||
note=note,
|
note=note,
|
||||||
)
|
)
|
||||||
if key == "health_insurance":
|
if key == "health_insurance":
|
||||||
@@ -395,6 +438,28 @@ def _base_and_rate(
|
|||||||
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
|
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
|
||||||
|
|
||||||
if key == "subcontract_payment_guarantee":
|
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억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다.
|
# 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다.
|
||||||
row = select_bracket(
|
row = select_bracket(
|
||||||
variable["brackets"],
|
variable["brackets"],
|
||||||
@@ -415,6 +480,11 @@ def _base_and_rate(
|
|||||||
percent = _guarantee_percent_from_formula(row, label=item.name)
|
percent = _guarantee_percent_from_formula(row, label=item.name)
|
||||||
years = Decimal(str(data.duration_days)) / Decimal(365)
|
years = Decimal(str(data.duration_days)) / Decimal(365)
|
||||||
note = variable.get("typical_forest_road_applicability", "")
|
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}")
|
raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}")
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ function drawPanel(ctx: B09TabContext, sheet: CostSheetDto, reload: () => void):
|
|||||||
typeSelect("cut_basis");
|
typeSelect("cut_basis");
|
||||||
typeSelect("cut_unit_krw");
|
typeSelect("cut_unit_krw");
|
||||||
typeSelect("vat_mode");
|
typeSelect("vat_mode");
|
||||||
|
// 13~16 — 사급비 위치·적용기준은 칸만 받음(계산에 안 씀 · 까닭은 칸 밑 안내).
|
||||||
|
typeSelect("private_material_position");
|
||||||
|
typeSelect("bid_method");
|
||||||
|
typeSelect("performance_guarantee_mode");
|
||||||
|
typeSelect("contract_law_basis");
|
||||||
typeSelect("waste_placement");
|
typeSelect("waste_placement");
|
||||||
box.append(el("div", "b09cs__group", "계산/인쇄 설정"));
|
box.append(el("div", "b09cs__group", "계산/인쇄 설정"));
|
||||||
for (const field of sheet.fields.amounts.slice(2)) {
|
for (const field of sheet.fields.amounts.slice(2)) {
|
||||||
|
|||||||
@@ -161,3 +161,39 @@ def test_폐기물_승률_밖_자리() -> None:
|
|||||||
assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼
|
assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼
|
||||||
keys = [line.key for line in practice.lines]
|
keys = [line.key for line in practice.lines]
|
||||||
assert keys.index("profit") < keys.index("waste_disposal") < keys.index("total_cost")
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user