- `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>
112 lines
4.6 KiB
Python
112 lines
4.6 KiB
Python
"""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 ㉢)."
|
||
)
|