Files
Aislo/B09_Estimation/B09_Estimation_Guards.py
T
eomsangdonandClaude Opus 5 50871e60b9 feat(B09): 원가계산 엔진 완성 — 법정경비 분리·이중계상 감시·재현 검산
- `B09_Estimation_Statutory.py` 신설(법정경비 14비목) — 700줄 제한 대비 분리.
  **비목 목록을 코드에 안 박음**: 그 해 요율 데이터에 변수가 있는 것만 유효(8-13·8-14).
- `B09_Estimation_Guards.py` 신설 — 이중계상 거울 테스트 3종을 함수로 둠(8-7 ㉠㉡㉢).
  할증 두 번 · 무대 줄 단가 · 배합 두 번 쪼개기를 **수치로 잡아 멈춤**.
- 엔진: 이윤 3줄(조정 전·조정액·조정 후) · 관급자재대 천원 올림 · 폐기물처리비 실비 슬롯 ·
  `formula_text`(줄마다 제 산식 — 실무 원문의 A식 복사 오류를 안 따라감) ·
  `proposed_profit_adjustment`(필요액을 보여만 주고 적용은 명시로, ★법대로 8-10).
- 요율 로더에 `load_rate_dataset_from_path` 추가 — 옛 연도 재현 검산 전용, 지문이 없어
  정본이 아님이 결과에 드러남.
- 검산 고정값 두 벌(`tmp/tests`, git 밖):
  · 2024 요율 → 울진 공통 금액 사슬 **전건 재현**(안전 16,586,996 · 이윤 108,109,955 ·
    총원가 1,029,117,273 · 총공사비 1,201,879,000)
  · 현행 요율 → 안전 18,586,091. **같은 입력·같은 코드, 요율만 교체** — 연도 교체 구조 증명
  · 거창 A/B min **양방향**(울진 A 채택 · 거창 B 채택)
- 자체검증: `pytest tmp/tests/ -q` **68 passed** · `ruff check` 통과 · 파일 최대 441줄.
- 잠정 반영(TODO 주석 + 계획서 항목번호): 환경보전비 임도 요율 0.9 % 잠정 ·
  폐기물처리비 자리 미확정 · 조달수수료 차감은 기본 꺼짐(옛 서류 재현용 옵션).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:01:08 +09:00

112 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종).
**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안,
「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서
위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는
검사**를 두어, 규칙을 어기면 계산이 멈추게 한다.
세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢)
㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다
(품셈 1-3-1 「할증 중복 적용 금지」).
㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미
포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6).
㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래
분해는 일위대가에서 한 번만 한다.
"""
from __future__ import annotations
from decimal import Decimal
_TOLERANCE = Decimal("0.5")
class DoubleCountError(AssertionError):
"""이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다."""
def check_surcharge_once(
*,
material_summary_total: Decimal,
unit_price_material_total: Decimal,
surcharge_rate_percent: Decimal,
label: str = "자재",
) -> None:
"""㉠ 할증이 두 번 붙지 않았는가.
`material_summary_total` = 자재총괄의 **할증 포함** 합계.
`unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계.
둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다.
"""
if unit_price_material_total <= 0:
return
expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100))
if material_summary_total > expected + _TOLERANCE:
raise DoubleCountError(
f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > "
f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) "
f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)."
)
def check_free_haul_not_priced(
*,
haul_rows: list[dict],
equipment_field: str = "equipment",
unit_price_field: str = "unit_price_krw",
free_haul_equipment: str = "free_haul",
) -> None:
"""㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가.
줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재).
금지되는 것은 **단가를 붙이는 것**이다.
"""
for row in haul_rows:
if row.get(equipment_field) != free_haul_equipment:
continue
price = Decimal(str(row.get(unit_price_field) or 0))
if price != 0:
raise DoubleCountError(
f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — "
"소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)."
)
def check_haul_volume_within_cut(
*,
haul_volume_total_m3: Decimal,
total_cut_volume_m3: Decimal,
) -> None:
"""㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가.
무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다.
"""
if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE:
raise DoubleCountError(
f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 "
f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 "
"(PLAN 8-7 ㉡)."
)
def check_mix_decomposed_once(
*,
cement_total_kg: Decimal,
concrete_volume_m3: Decimal,
cement_per_m3_kg: Decimal,
) -> None:
"""㉢ 콘크리트를 두 번 쪼개지 않았는가.
시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을
일위대가가 또 분해한 것이다.
"""
if concrete_volume_m3 <= 0:
return
expected = concrete_volume_m3 * cement_per_m3_kg
if cement_total_kg > expected + _TOLERANCE:
raise DoubleCountError(
f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f}× "
f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 "
"쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)."
)