Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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], ...] = ()
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user