- estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림 - 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」 - 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨) - PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지 - 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림 - 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""B09 원가계산서 화면 자료 — STmate 「제잡비 계산」(wM_KanJub) **일반형식** 서식 차례 (PLAN 12장).
|
|
|
|
⚠ 화면만 닮는다 — **값은 우리 엔진**(`Engine_Cost.calculate_cost`)이 낸다. 여기는
|
|
① 엔진 줄을 STmate 서식 차례·표기(나./ㄱ./1))로 늘어놓고
|
|
② 기준 입력판 칸(번호·제목은 DFM `TWM_KANJUB` 그대로)의 선택지를 요율 데이터에서 뽑고
|
|
③ 프로젝트 저장값을 `CostInput` 으로 옮기는 일만 한다. 계산을 두 벌로 짜지 않는다.
|
|
⚠ 형식은 「일반」만 — 수공·실적일반·실적수공은 항목·밑수·요율이 달라(35번 문서) 차례로 세움.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult
|
|
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
|
|
CUT_BASES,
|
|
CUT_UNITS_KRW,
|
|
OVERHEAD_CLASSES,
|
|
VAT_MODES,
|
|
)
|
|
from B09_Estimation.B09_Estimation_Rates import RateDataset
|
|
|
|
_ZERO = Decimal(0)
|
|
|
|
#: 저장 자리 — `estimation` 구획 안 한 칸.
|
|
SETTINGS_KEY = "cost_sheet"
|
|
|
|
#: 기준 입력판 — DFM 번호·제목 그대로. 엔진이 받는 칸만 연다(나머지 번호는 형식이 서면 채움).
|
|
#: (칸 이름, DFM 제목, 요율 변수 경로, 한글 이름표)
|
|
WORK_TYPE_FIELDS: tuple[tuple[str, str], ...] = (
|
|
("work_type_indirect_labor", "2.공사의종류(주)"),
|
|
("work_type_safety", "7.산업안전보건관리비"),
|
|
("environment_work_type", "8.환 경 보 전 비"),
|
|
("equipment_guarantee_work_type", " 9.건설기계대여금 지급수수료"),
|
|
)
|
|
AMOUNT_FIELDS: tuple[tuple[str, str], ...] = (
|
|
("owner_supplied_material_krw", "6.관/사급내용 — 관급자재대(순자재대)"),
|
|
("procurement_fee_krw", "6.관/사급내용 — 조달수수료"),
|
|
("indirect_material_krw", "- 간 접 재 료 비"),
|
|
("profit_adjustment_krw", "이윤 보정액"),
|
|
("overhead_managed_material_krw", "관리품목 자재대(일반관리비 밑수)"),
|
|
("tax_exempt_material_krw", "면세품 금액(산림조합-면세품)"),
|
|
)
|
|
#: 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%",
|
|
"material": "(1) 재료비의 10%",
|
|
"none": "(2) 없음",
|
|
"forest_coop": "(3) 산림조합 형식",
|
|
"forest_coop_exempt": "(4) 산림조합-면세품",
|
|
}
|
|
|
|
|
|
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": "절사 안 함(이윤보정액 입력)"},
|
|
*({"value": k, "label": f"{v}에서 조정"} for k, v in CUT_BASES.items()),
|
|
],
|
|
"cut_unit_krw": [{"value": str(u), "label": f"{u:,}원 미만"} for u in CUT_UNITS_KRW],
|
|
"vat_mode": [{"value": k, "label": VAT_MODE_LABELS[k]} for k in VAT_MODES],
|
|
}
|
|
|
|
|
|
#: 공종 이름표 — 조달청 제비율 적용기준(현행 2026-04-13) 표기.
|
|
WORK_TYPE_LABELS: dict[str, str] = {
|
|
"civil": "토목",
|
|
"landscape": "조경",
|
|
"industrial_facilities_civil": "산업설비(토목)",
|
|
"building": "건축공사",
|
|
"heavy_construction": "중건설공사",
|
|
"special_construction": "특수건설공사",
|
|
"civil_road": "토목 — 도로(교량·터널·활주로 등)",
|
|
"civil_plant": "토목 — 플랜트(발전소·쓰레기소각장 등)",
|
|
"civil_subway": "토목 — 지하철",
|
|
"civil_railway": "토목 — 철도",
|
|
"civil_water_and_sewer": "토목 — 상하수도",
|
|
"civil_port": "토목 — 항만",
|
|
"civil_port_with_silt_screen": "토목 — 항만(오탁방지막·준설토방지막 설치)",
|
|
"civil_dam": "토목 — 댐",
|
|
"civil_land_development": "토목 — 택지개발",
|
|
"civil_other": "토목 — 그 밖의 토목공사(하천 등)",
|
|
"building_housing_redevelopment": "건축 — 주택(재개발·재건축)",
|
|
"building_new_housing": "건축 — 주택(신축)",
|
|
"building_other": "건축 — 그 밖의 건축",
|
|
"civil_general": "일반건설 — 토목",
|
|
"industrial_facilities": "일반건설 — 산업설비",
|
|
}
|
|
#: 산업안전보건관리비 공종은 「토목공사」 표기(간접노무비 「토목」과 키가 같아 따로 둠).
|
|
SAFETY_LABELS = {"civil": "토목공사"}
|
|
|
|
|
|
def _unique(values: list[str]) -> list[str]:
|
|
return list(dict.fromkeys(values))
|
|
|
|
|
|
def field_options(dataset: RateDataset) -> dict[str, list[dict[str, str]]]:
|
|
"""기준 입력판 고르개 선택지 — **요율 데이터에 있는 공종만**(코드에 목록을 박지 않음)."""
|
|
variables = dataset.variables
|
|
sources = {
|
|
"work_type_indirect_labor": [
|
|
row["work_type"] for row in variables["rate_indirect_labor"]["brackets"]
|
|
],
|
|
"work_type_safety": [row["work_type"] for row in variables["rate_safety_pct"]["brackets"]],
|
|
"environment_work_type": [
|
|
row["work_type"] for row in variables["rate_environment"]["all_work_types"]
|
|
],
|
|
"equipment_guarantee_work_type": [
|
|
row["work_type"]
|
|
for row in variables["rate_equipment_payment_guarantee"]["general_construction"]
|
|
],
|
|
}
|
|
options: dict[str, list[dict[str, str]]] = {}
|
|
for key, values in sources.items():
|
|
labels = {**WORK_TYPE_LABELS, **(SAFETY_LABELS if key == "work_type_safety" else {})}
|
|
options[key] = [{"value": v, "label": labels.get(v, v)} for v in _unique(values)]
|
|
return {**options, **choice_options()}
|
|
|
|
|
|
def _decimal(value: Any) -> Decimal:
|
|
try:
|
|
number = Decimal(str(value))
|
|
except (InvalidOperation, ValueError):
|
|
return _ZERO
|
|
return number if number >= 0 else _ZERO
|
|
|
|
|
|
def clean_settings(values: dict[str, Any], dataset: RateDataset) -> dict[str, Any]:
|
|
"""저장할 값 — 데이터에 없는 공종·음수·빈칸은 버린다(버린 칸은 엔진 기본값이 섬)."""
|
|
options = field_options(dataset)
|
|
cleaned: dict[str, Any] = {}
|
|
for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS):
|
|
value = str(values.get(key) or "")
|
|
if value and value in {option["value"] for option in options[key]}:
|
|
cleaned[key] = int(value) if key == "cut_unit_krw" else value
|
|
for key, _ in AMOUNT_FIELDS:
|
|
if values.get(key) not in (None, ""):
|
|
cleaned[key] = str(_decimal(values.get(key)))
|
|
try:
|
|
days = int(values.get("duration_days"))
|
|
except (TypeError, ValueError):
|
|
days = 0
|
|
if days > 0:
|
|
cleaned["duration_days"] = days
|
|
return cleaned
|
|
|
|
|
|
def cost_input(
|
|
direct: dict[str, Decimal],
|
|
stored: dict[str, Any],
|
|
waste_krw: Decimal = _ZERO,
|
|
waste_separate_order: bool = False,
|
|
rate_overrides: tuple[dict[str, Any], ...] = (),
|
|
) -> CostInput:
|
|
"""내역서 직접비 + 저장값 → 엔진 입력. 저장 안 한 칸은 **엔진 기본값**."""
|
|
kwargs: dict[str, Any] = {
|
|
key: stored[key] for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS) if stored.get(key)
|
|
}
|
|
kwargs.update(
|
|
{key: _decimal(stored[key]) for key, _ in AMOUNT_FIELDS if stored.get(key) is not None}
|
|
)
|
|
if stored.get("duration_days"):
|
|
kwargs["duration_days"] = int(stored["duration_days"])
|
|
return CostInput(
|
|
direct_material_krw=direct["material"],
|
|
direct_labor_krw=direct["labor"],
|
|
direct_expense_krw=direct["expense"],
|
|
waste_disposal_krw=waste_krw,
|
|
waste_separate_order=waste_separate_order,
|
|
rate_overrides=tuple(rate_overrides),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
#: 경비 세목 — STmate 일반형식 서식 차례·표기. 키는 우리 엔진 줄.
|
|
EXPENSE_ORDER: tuple[tuple[str, str], ...] = (
|
|
("industrial_accident_insurance", "산재보험료"),
|
|
("employment_insurance", "고용보험료"),
|
|
("health_insurance", "건강보험료"),
|
|
("long_term_care_insurance", "장기요양보험료"),
|
|
("national_pension", "연금보험료"),
|
|
("retirement_mutual_aid", "퇴직공제부금비"),
|
|
("safety_management_cost", "산업안전보건관리비"),
|
|
("other_expense", "기타 경비"),
|
|
("performance_guarantee_fee", "공사이행 수수료"),
|
|
("subcontract_payment_guarantee", "하도대금 수수료"),
|
|
("environment_preservation", "환 경 보 전 비"),
|
|
("equipment_payment_guarantee", "건설기계 대여금"),
|
|
("asbestos_contribution", "석면분담금"),
|
|
("wage_claim_contribution", "임금채권부담금"),
|
|
("waste_disposal", "폐기물처리비"),
|
|
)
|
|
|
|
|
|
def _row(
|
|
mark: str, label: str, amount: Decimal, level: int, key: str, result: CostResult | None = None
|
|
) -> dict[str, Any]:
|
|
line = result.line(key) if result is not None and result.has(key) else None
|
|
return {
|
|
"key": key,
|
|
"mark": mark,
|
|
"label": label,
|
|
"level": level,
|
|
"formula": line.formula_text if line else "",
|
|
"rate_percent": None
|
|
if line is None or line.rate_percent is None
|
|
else str(line.rate_percent),
|
|
"amount_krw": str(amount),
|
|
"note": line.note if line else "",
|
|
"total": level == 0,
|
|
}
|
|
|
|
|
|
def sheet_rows(result: CostResult, data: CostInput) -> list[dict[str, Any]]:
|
|
"""일반형식 서식 줄 — 나.순공사원가 … 바.총공사비. 엔진에 없는 줄은 안 세움."""
|
|
amount = result.amount
|
|
rows = [
|
|
_row(
|
|
"나.",
|
|
"순공사원가(ㄱ+ㄴ+ㄷ)",
|
|
amount("net_construction_cost"),
|
|
0,
|
|
"net_construction_cost",
|
|
result,
|
|
),
|
|
_row("ㄱ.", "재 료 비", amount("material_cost"), 1, "material_cost", result),
|
|
_row("ㄴ.", "노 무 비", amount("labor_cost"), 1, "labor_cost", result),
|
|
_row("1)", "직접노무비", data.direct_labor_krw, 2, "direct_labor"),
|
|
_row("2)", "간접노무비", amount("indirect_labor_cost"), 2, "indirect_labor_cost", result),
|
|
_row("ㄷ.", "경 비", amount("expense"), 1, "expense", result),
|
|
_row("1)", "내역서경비", data.direct_expense_krw, 2, "direct_expense"),
|
|
]
|
|
number = 2
|
|
for key, label in EXPENSE_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":
|
|
# STmate 도 두 비교 줄을 나란히 둠 — 작은 쪽 채택.
|
|
for sub in ("safety_management_cost_a", "safety_management_cost_b"):
|
|
if result.has(sub):
|
|
rows.append(_row("", result.line(sub).name, amount(sub), 3, sub, result))
|
|
number += 1
|
|
overhead = amount("general_overhead")
|
|
rows.append(_row("5)", "일반관리비", overhead, 1, "general_overhead", result))
|
|
rows.append(
|
|
{
|
|
**_row("다.", "소 계", amount("net_construction_cost") + overhead, 0, "subtotal"),
|
|
"formula": "나.순공사원가 + 5)일반관리비",
|
|
}
|
|
)
|
|
if result.has("profit_adjustment"):
|
|
rows.append(
|
|
_row(
|
|
"",
|
|
"이윤(보정 전)",
|
|
amount("profit_before_adjustment"),
|
|
2,
|
|
"profit_before_adjustment",
|
|
result,
|
|
)
|
|
)
|
|
rows.append(
|
|
_row("", "이윤 보정액", amount("profit_adjustment"), 2, "profit_adjustment", result)
|
|
)
|
|
profit_row = _row("6)", "이윤", amount("profit"), 1, "profit", result)
|
|
if result.has("profit_before_adjustment"):
|
|
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))
|
|
if result.has("owner_supplied_material_total"):
|
|
rows.append(
|
|
_row(
|
|
"",
|
|
"관급자재대",
|
|
amount("owner_supplied_material_total"),
|
|
1,
|
|
"owner_supplied_material_total",
|
|
result,
|
|
)
|
|
)
|
|
if data.waste_separate_order and result.has("waste_disposal"):
|
|
rows.append(
|
|
_row(
|
|
"", "폐기물처리비(분리발주)", amount("waste_disposal"), 1, "waste_disposal", result
|
|
)
|
|
)
|
|
rows.append(_row("바.", "총공사비", amount("grand_total"), 0, "grand_total", result))
|
|
return rows
|
|
|
|
|
|
def status_line(result: CostResult, data: CostInput) -> dict[str, str]:
|
|
"""아래 상태줄 — DFM `직재: / 직노: / 산경: / 이윤:` 차례."""
|
|
return {
|
|
"직재": str(data.direct_material_krw),
|
|
"직노": str(data.direct_labor_krw),
|
|
"산경": str(data.direct_expense_krw),
|
|
"이윤": str(result.amount("profit")),
|
|
}
|