From e0c5e36433e40409a9be98837a8e1ea1b4585d7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 03:19:40 +0900 Subject: [PATCH] =?UTF-8?q?refactor(b09):=20700=EC=A4=84=20=EB=84=98?= =?UTF-8?q?=EC=9D=80=20=EB=AA=A8=EB=93=88=20=EB=91=98=20=EC=AA=BC=EA=B0=AC?= =?UTF-8?q?=20=E2=80=94=20=EC=88=9C=EC=88=98=20=EB=B6=84=EB=A6=AC,=20?= =?UTF-8?q?=EA=B7=B8=20=EA=B9=80=EC=97=90=20=EC=95=84=EB=AC=B4=EA=B2=83?= =?UTF-8?q?=EB=8F=84=20=EC=95=88=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · BillOfQuantities.py 733 → 597 · 새 _BillOfQuantities_Input.py 162 (인계 자료형 HandoffWorkItem·HandoffMaterial · parse_handoff · _decimal · BillError · 공급구분·미확정 상수) · Engine_Cost.py 725 → 609 · 새 _Engine_Cost_Input.py 136 (CostInput 한 벌 · DEFAULT_ITEMS) 반올림 두 함수(floor_won·ceil_thousand)는 엔진 것이라 도로 옮겨 둠 · 쓰던 이름은 그대로 — 본 모듈이 다시 내보내(noqa: E402,F401) from …BillOfQuantities import HandoffWorkItem · from …Engine_Cost import CostInput 가 종전대로 돎 · ruff — 새 파일 둘 다 깨끗 · 본 모듈 둘은 HEAD 와 같은 수(옮기며 안 늘음) ⚠ UnitPrice.py 1403 은 아직 남음 — 따로 배정 필요. 전체 시험 2102 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA --- .../B09_Estimation_BillOfQuantities.py | 160 ++--------------- .../B09_Estimation_BillOfQuantities_Input.py | 165 ++++++++++++++++++ B09_Estimation/B09_Estimation_Engine_Cost.py | 126 +------------ .../B09_Estimation_Engine_Cost_Input.py | 136 +++++++++++++++ 4 files changed, 319 insertions(+), 268 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BillOfQuantities_Input.py create mode 100644 B09_Estimation/B09_Estimation_Engine_Cost_Input.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 9e21fdf4..2f2fe682 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -45,28 +45,30 @@ from B09_Estimation.B09_Estimation_UnitPrice import ( _ZERO = Decimal(0) +# ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다. +from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401 + BLOCKED_UNCONFIRMED, + SUPPLY_OWNER, + SUPPLY_UNKNOWN, + BillError, + HandoffMaterial, + HandoffWorkItem, + _decimal, + parse_handoff, +) + #: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋). #: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것. CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05") #: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈. STRUCTURE_PRICE_PREFIX = "AX-ST-" -#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인). -#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. -SUPPLY_UNKNOWN = "unknown" - -#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2). -SUPPLY_OWNER = "owner_supplied" - #: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.** #: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** — #: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다. #: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙). _NOT_OUR_ROW = "not_our_row" -#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄. -BLOCKED_UNCONFIRMED = "unconfirmed" - _BLOCKED_LABELS = { _NOT_OUR_ROW: "여기서 세지 않는 줄", BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감", @@ -76,84 +78,6 @@ _BLOCKED_LABELS = { } -class BillError(ValueError): - """내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다.""" - - -@dataclass(frozen=True) -class HandoffWorkItem: - """B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다.""" - - work_item_code: str | None - name: str - spec: str - unit: str - quantity: Decimal - in_bill: bool - #: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다. - haul_distance_m: Decimal | None = None - haul_equipment: str | None = None - in_bill_reason: str = "" - origin: str = "" - ground_class: str = "" - #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. - application_ratio_pct: Decimal | None = None - #: 반영률 적용 **전** 수량. 산출근거에만 쓴다. - quantity_gross: Decimal | None = None - #: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다. - application_ratio_breakdown: dict | None = None - quantity_breakdown: dict | None = None - #: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지. - composite_parts: tuple = () - #: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다. - composite_not_ready: tuple = () - structure_kind: str = "" - #: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다). - blocked_reason: str = "" - #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / - #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. - blocked_kind: str = "" - #: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의). - variant_axis: str = "" - variant_value: str = "" - #: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구. - #: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다. - spec_class: str = "" - spec_class_basis: str = "" - #: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장). - pum_edition: str = "" - - @property - def display_name(self) -> str: - return f"{self.name} {self.spec}".strip() - - @property - def unconfirmed(self) -> bool: - """B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에 - **빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」.""" - return self.blocked_kind == BLOCKED_UNCONFIRMED - - -@dataclass(frozen=True) -class HandoffMaterial: - """B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정).""" - - material_name: str - spec: str - unit: str - net_amount: Decimal - total_amount: Decimal - supply_type: str - surcharge_pct: Decimal | None = None - surcharge_note: str = "" - install_by: str | None = None - source_structure: tuple[str, ...] = () - - @property - def display_name(self) -> str: - return f"{self.material_name} {self.spec}".strip() - - @dataclass class BillRow: """내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다.""" @@ -308,66 +232,6 @@ class BillResult: return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO) -def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None: - if value is None or value == "": - return default - return Decimal(str(value)) - - -def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]: - """인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**""" - if "work_items" not in payload or "materials" not in payload: - raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.") - - work_items = [ - HandoffWorkItem( - work_item_code=row.get("work_item_code"), - name=row.get("name", ""), - spec=row.get("spec") or "", - unit=row.get("unit") or "", - quantity=_decimal(row.get("quantity")) or _ZERO, - in_bill=bool(row.get("in_bill", True)), - in_bill_reason=row.get("in_bill_reason") or "", - origin=row.get("origin") or "", - ground_class=row.get("ground_class") or "", - haul_distance_m=_decimal(row.get("haul_distance_m"), None), - haul_equipment=row.get("haul_equipment"), - # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). - application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), - quantity_gross=_decimal(row.get("quantity_gross"), None), - application_ratio_breakdown=row.get("application_ratio_breakdown"), - quantity_breakdown=row.get("quantity_breakdown"), - composite_parts=tuple(row.get("composite_parts") or ()), - composite_not_ready=tuple(row.get("composite_not_ready") or ()), - structure_kind=row.get("structure_kind") or "", - blocked_reason=row.get("blocked_reason") or "", - blocked_kind=row.get("blocked_kind") or "", - variant_axis=row.get("variant_axis") or "", - variant_value=str(row.get("variant_value") or ""), - spec_class=row.get("spec_class") or "", - spec_class_basis=row.get("spec_class_basis") or "", - pum_edition=str(row.get("pum_edition") or ""), - ) - for row in payload["work_items"] - ] - materials = [ - HandoffMaterial( - material_name=row.get("material_name", ""), - spec=row.get("spec") or "", - unit=row.get("unit") or "", - net_amount=_decimal(row.get("net_amount")) or _ZERO, - total_amount=_decimal(row.get("total_amount")) or _ZERO, - supply_type=row.get("supply_type") or SUPPLY_UNKNOWN, - surcharge_pct=_decimal(row.get("surcharge_pct"), None), - surcharge_note=row.get("surcharge_note") or "", - install_by=row.get("install_by"), - source_structure=tuple(row.get("source_structure") or ()), - ) - for row in payload["materials"] - ] - return work_items, materials - - @dataclass(frozen=True) class _MasterNode: code: str diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py new file mode 100644 index 00000000..8731689e --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py @@ -0,0 +1,165 @@ +"""B09 원가계산 — ④ 예산내역서 **인계 읽기**(자료형 · 파싱). + +`B09_Estimation_BillOfQuantities` 가 700줄에 닿아 **그대로 옮겨 온 자리**다 +(2026-09-15 · 순수 분리 — 글자 한 자 안 고쳤다). 조판·계층은 그쪽에 그대로 있다. + +⚠ **모르는 칸을 채우지 않는다** — B08 이 안 보낸 값은 `None`·빈 문자열로 남긴다. +⚠ 쓰던 자리가 안 바뀌도록 본 모듈이 다시 내보낸다 — + `from ...BillOfQuantities import HandoffWorkItem` 가 그대로 돈다. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +_ZERO = Decimal(0) + +#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인). +#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. +SUPPLY_UNKNOWN = "unknown" + +#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2). +SUPPLY_OWNER = "owner_supplied" + +#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄. +BLOCKED_UNCONFIRMED = "unconfirmed" + + +class BillError(ValueError): + """내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다.""" + + +@dataclass(frozen=True) +class HandoffWorkItem: + """B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다.""" + + work_item_code: str | None + name: str + spec: str + unit: str + quantity: Decimal + in_bill: bool + #: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다. + haul_distance_m: Decimal | None = None + haul_equipment: str | None = None + in_bill_reason: str = "" + origin: str = "" + ground_class: str = "" + #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. + application_ratio_pct: Decimal | None = None + #: 반영률 적용 **전** 수량. 산출근거에만 쓴다. + quantity_gross: Decimal | None = None + #: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다. + application_ratio_breakdown: dict | None = None + quantity_breakdown: dict | None = None + #: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지. + composite_parts: tuple = () + #: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다. + composite_not_ready: tuple = () + structure_kind: str = "" + #: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다). + blocked_reason: str = "" + #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / + #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. + blocked_kind: str = "" + #: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의). + variant_axis: str = "" + variant_value: str = "" + #: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구. + #: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다. + spec_class: str = "" + spec_class_basis: str = "" + #: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장). + pum_edition: str = "" + + @property + def display_name(self) -> str: + return f"{self.name} {self.spec}".strip() + + @property + def unconfirmed(self) -> bool: + """B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에 + **빈 금액 + 빨간 테두리**로 서고 합계에 안 듦 · 머리에 「미확정 N건 — 금액에 안 들어감」.""" + return self.blocked_kind == BLOCKED_UNCONFIRMED + + +@dataclass(frozen=True) +class HandoffMaterial: + """B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정).""" + + material_name: str + spec: str + unit: str + net_amount: Decimal + total_amount: Decimal + supply_type: str + surcharge_pct: Decimal | None = None + surcharge_note: str = "" + install_by: str | None = None + source_structure: tuple[str, ...] = () + + @property + def display_name(self) -> str: + return f"{self.material_name} {self.spec}".strip() + + +def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None: + if value is None or value == "": + return default + return Decimal(str(value)) + + +def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]: + """인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**""" + if "work_items" not in payload or "materials" not in payload: + raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.") + + work_items = [ + HandoffWorkItem( + work_item_code=row.get("work_item_code"), + name=row.get("name", ""), + spec=row.get("spec") or "", + unit=row.get("unit") or "", + quantity=_decimal(row.get("quantity")) or _ZERO, + in_bill=bool(row.get("in_bill", True)), + in_bill_reason=row.get("in_bill_reason") or "", + origin=row.get("origin") or "", + ground_class=row.get("ground_class") or "", + haul_distance_m=_decimal(row.get("haul_distance_m"), None), + haul_equipment=row.get("haul_equipment"), + # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). + application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), + quantity_gross=_decimal(row.get("quantity_gross"), None), + application_ratio_breakdown=row.get("application_ratio_breakdown"), + quantity_breakdown=row.get("quantity_breakdown"), + composite_parts=tuple(row.get("composite_parts") or ()), + composite_not_ready=tuple(row.get("composite_not_ready") or ()), + structure_kind=row.get("structure_kind") or "", + blocked_reason=row.get("blocked_reason") or "", + blocked_kind=row.get("blocked_kind") or "", + variant_axis=row.get("variant_axis") or "", + variant_value=str(row.get("variant_value") or ""), + spec_class=row.get("spec_class") or "", + spec_class_basis=row.get("spec_class_basis") or "", + pum_edition=str(row.get("pum_edition") or ""), + ) + for row in payload["work_items"] + ] + materials = [ + HandoffMaterial( + material_name=row.get("material_name", ""), + spec=row.get("spec") or "", + unit=row.get("unit") or "", + net_amount=_decimal(row.get("net_amount")) or _ZERO, + total_amount=_decimal(row.get("total_amount")) or _ZERO, + supply_type=row.get("supply_type") or SUPPLY_UNKNOWN, + surcharge_pct=_decimal(row.get("surcharge_pct"), None), + surcharge_note=row.get("surcharge_note") or "", + install_by=row.get("install_by"), + source_structure=tuple(row.get("source_structure") or ()), + ) + for row in payload["materials"] + ] + return work_items, materials diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py index 1342b3f0..8b273395 100644 --- a/B09_Estimation/B09_Estimation_Engine_Cost.py +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -18,7 +18,6 @@ from __future__ import annotations from dataclasses import dataclass, field, replace from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal -from typing import Any from B09_Estimation.B09_Estimation_Engine_Cost_Options import ( CUT_BASES, @@ -45,9 +44,12 @@ from B09_Estimation.B09_Estimation_Statutory import ( _ZERO = Decimal(0) _HUNDRED = Decimal(100) -#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**. -#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다. -DEFAULT_ITEMS = "ALL_AVAILABLE" +# ⬇ 입력 한 벌은 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — +# 쓰던 이름이 그대로 살게 다시 내보낸다. +from B09_Estimation.B09_Estimation_Engine_Cost_Input import ( # noqa: E402,F401 + DEFAULT_ITEMS, + CostInput, +) def floor_won(value: Decimal) -> Decimal: @@ -60,122 +62,6 @@ def ceil_thousand(value: Decimal) -> Decimal: return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000 -@dataclass -class CostInput: - """원가계산 입력. 금액은 전부 원 단위 `Decimal`.""" - - direct_material_krw: Decimal - direct_labor_krw: Decimal - direct_expense_krw: Decimal - indirect_material_krw: Decimal = _ZERO - - #: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다. - work_type_indirect_labor: str = "civil" - work_type_safety: str = "civil" - duration_days: int = 183 - pension_year: int = 2026 - - #: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1). - #: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의 - #: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다 - #: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → - #: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다. - owner_supplied_material_krw: Decimal = _ZERO - procurement_fee_krw: Decimal = _ZERO - include_fee_in_owner_material_total: bool = True - - #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액. - owner_supplied_for_safety_krw: Decimal | None = None - #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다. - #: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당 - #: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외 - #: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인). - owner_supplied_includes_vat: bool = True - #: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다. - #: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조). - #: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될 - #: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖). - estimated_price_krw: Decimal | None = None - - #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. - profit_adjustment_krw: Decimal = _ZERO - #: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리. - profit_adjustment_label: str = "" - - #: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가). - #: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고 - #: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다. - #: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다. - waste_disposal_krw: Decimal = _ZERO - #: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다 - #: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐. - waste_separate_order: bool = False - #: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) · - #: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고). - #: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖). - waste_placement: str = "expense" - - #: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ── - #: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만). - form: str = "general" - #: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사). - overhead_class: str = "civil_landscape_industrial" - #: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대. - overhead_managed_material_krw: Decimal = _ZERO - #: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액. - vat_mode: str = "supply" - tax_exempt_material_krw: Decimal = _ZERO - #: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름. - #: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」 - #: 「설계서의 총액 · 원 · 1,000 · 미만버림」. - #: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거. - #: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과 - #: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고, - #: 설계자가 `none` 으로 **끌 수 있게** 둔다. - cut_basis: str = "grand_total" - cut_unit_krw: int = 1000 - #: 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" - #: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로 - #: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶. - private_material_position: str = "material" - #: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`. - #: 하도급대금 지급보증 요율 줄만 고름. - bid_method: str = "not_comprehensive" - #: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법 - #: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름). - subcontract_guarantee: str = "off" - #: 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], ...] = () - - #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). - #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. - #: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다. - environment_work_type: str = "civil_road" - #: 건설기계대여대금 지급보증 공종. - equipment_guarantee_work_type: str = "civil_general" - #: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축). - subcontract_guarantee_variant: str = "integrated_civil_or_industrial" - - #: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」. - enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS - - #: 요율 데이터 파일명. **연도를 갈아끼우는 자리.** - rate_file_name: str = "rates_2026.json" - #: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선. - rate_file_path: str | None = None - - @dataclass class CostLine: """원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다.""" diff --git a/B09_Estimation/B09_Estimation_Engine_Cost_Input.py b/B09_Estimation/B09_Estimation_Engine_Cost_Input.py new file mode 100644 index 00000000..b2d31fc0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Engine_Cost_Input.py @@ -0,0 +1,136 @@ +"""B09 원가계산 — ⑤ 공사원가계산서 **입력 한 벌**(`CostInput`). + +`B09_Estimation_Engine_Cost` 가 700줄에 닿아 **그대로 옮겨 온 자리**다 +(2026-09-15 · 순수 분리 — 글자 한 자 안 고쳤다). 계산 사슬은 그쪽에 그대로 있다. + +⚠ 쓰던 자리가 안 바뀌도록 엔진이 다시 내보낸다 — + `from ...Engine_Cost import CostInput` 가 그대로 돈다. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +_ZERO = Decimal(0) + +#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**. +#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다. +DEFAULT_ITEMS = "ALL_AVAILABLE" + + +@dataclass +class CostInput: + """원가계산 입력. 금액은 전부 원 단위 `Decimal`.""" + + direct_material_krw: Decimal + direct_labor_krw: Decimal + direct_expense_krw: Decimal + indirect_material_krw: Decimal = _ZERO + + #: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다. + work_type_indirect_labor: str = "civil" + work_type_safety: str = "civil" + duration_days: int = 183 + pension_year: int = 2026 + + #: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1). + #: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의 + #: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다 + #: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → + #: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다. + owner_supplied_material_krw: Decimal = _ZERO + procurement_fee_krw: Decimal = _ZERO + include_fee_in_owner_material_total: bool = True + + #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액. + owner_supplied_for_safety_krw: Decimal | None = None + #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다. + #: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당 + #: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외 + #: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인). + owner_supplied_includes_vat: bool = True + #: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다. + #: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조). + #: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될 + #: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖). + estimated_price_krw: Decimal | None = None + + #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. + profit_adjustment_krw: Decimal = _ZERO + #: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리. + profit_adjustment_label: str = "" + + #: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가). + #: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고 + #: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다. + #: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다. + waste_disposal_krw: Decimal = _ZERO + #: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다 + #: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐. + waste_separate_order: bool = False + #: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) · + #: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고). + #: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖). + waste_placement: str = "expense" + + #: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ── + #: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만). + form: str = "general" + #: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사). + overhead_class: str = "civil_landscape_industrial" + #: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대. + overhead_managed_material_krw: Decimal = _ZERO + #: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액. + vat_mode: str = "supply" + tax_exempt_material_krw: Decimal = _ZERO + #: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름. + #: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」 + #: 「설계서의 총액 · 원 · 1,000 · 미만버림」. + #: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거. + #: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과 + #: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고, + #: 설계자가 `none` 으로 **끌 수 있게** 둔다. + cut_basis: str = "grand_total" + cut_unit_krw: int = 1000 + #: 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" + #: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로 + #: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶. + private_material_position: str = "material" + #: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`. + #: 하도급대금 지급보증 요율 줄만 고름. + bid_method: str = "not_comprehensive" + #: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법 + #: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름). + subcontract_guarantee: str = "off" + #: 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], ...] = () + + #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). + #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. + #: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다. + environment_work_type: str = "civil_road" + #: 건설기계대여대금 지급보증 공종. + equipment_guarantee_work_type: str = "civil_general" + #: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축). + subcontract_guarantee_variant: str = "integrated_civil_or_industrial" + + #: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」. + enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS + + #: 요율 데이터 파일명. **연도를 갈아끼우는 자리.** + rate_file_name: str = "rates_2026.json" + #: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선. + rate_file_path: str | None = None