배분 창이 보고 숫자를 더해 보고 **노무 열만 5,162 어긋남**을 짚음. 확인 결과: - **값은 멀쩡했음. 내 보고가 줄 하나를 빠뜨린 것**(「제근」 본표는 보통인부 줄이 **둘**임 — 굴착기 규격 2종에 각각 붙음). 실제 열 합은 재료 20,956.3 · 노무 54,943.2 · 경비 22,393.4 · 합계 98,292.9 로 표시와 **전건 일치**. - **다만 지적한 「열 방향 검사가 비어 있다」는 실재했음.** `TC = NC + GC + JC` 는 **행 방향** 검사라, 같은 성분을 두 층에서 세는 어긋남(행마다는 다 맞고 열 합만 갈림)을 못 잡음. 예 — 기계 줄 안에 든 조종원 노무가 별도 노무 줄로도 서는 경우. - `check_column_sums()` 추가하고 `detail_of()` 에서 **실제로 호출**. 표시 합계가 상세 줄의 열별 합과 다르면 멈춤. - 테스트 둘 — ① 노무 열만 부푼 모양을 만들어 **행 검사가 통과하는데 열 검사가 잡는지** 확인 ② 실제 「제근」 본표가 열 검사를 통과하고 보통인부 줄이 둘인지 확인. ⇒ 이중계상 감시가 넷(㉠할증·㉡무대·㉢배합·㉣작업효율) + **방향 검사 ㉤** 로 늘어남. 자체검증 — 신규 2건 포함 `pytest tmp/tests/ -q` 118 passed · ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
8.1 KiB
Python
182 lines
8.1 KiB
Python
"""B09 원가계산 — 이중계상 감시 (거울 테스트 4종).
|
||
|
||
**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안,
|
||
「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서
|
||
위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는
|
||
검사**를 두어, 규칙을 어기면 계산이 멈추게 한다.
|
||
|
||
네 규칙 (㉠㉡㉢ 원문 = PLAN 8-7 · ㉣ = PLAN 9-6, 2026-09-07 구현 중 발견)
|
||
㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다
|
||
(품셈 1-3-1 「할증 중복 적용 금지」).
|
||
㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미
|
||
포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6).
|
||
㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래
|
||
분해는 일위대가에서 한 번만 한다.
|
||
㉣ **작업효율 `E` 를 두 곳에 넣지 않는다.** 품셈은 작업량 산정에 넣는다
|
||
(`Q = n·q·f·E`, 8-1-4). 시간당 사용료는 1일 8시간으로 나눈다(8-1-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 ㉢)."
|
||
)
|
||
|
||
|
||
def reject_efficiency_in_hourly_rate(factor: Decimal | None, *, where: str) -> None:
|
||
"""㉣ 작업효율 `E`(실작업시간율)가 **시간당 사용료 쪽**에 들어오면 멈춘다.
|
||
|
||
품셈은 작업효율을 **작업량 산정**에 넣는다 — `Q = n·q·f·E` (8-1-4).
|
||
시간당 사용료는 **1일 8시간**으로 나눈다 (8-1-6 「관리비는 1일 8시간 초과해도
|
||
8시간으로 계산」). **양쪽에 다 넣으면 같은 효율이 두 번 곱해진다.**
|
||
|
||
실무 관측(STC 2024)은 효율을 사용료 쪽에 미리 넣어 굴착기 0.7㎥ 노무가
|
||
55,700 원/hr(일당 ÷ 약 5.09시간)로 나온다. 그 값을 보고 「우리가 틀렸다」며
|
||
나눗수를 고치는 것이 정확히 이 자리에서 일어난다.
|
||
"""
|
||
if factor is None:
|
||
return
|
||
if Decimal(0) < Decimal(str(factor)) < Decimal(1):
|
||
raise DoubleCountError(
|
||
f"{where}: 작업효율({factor})이 시간당 사용료 계산에 들어왔습니다 — "
|
||
"효율은 작업량 산정(Q = n·q·f·E)에만 넣습니다. 양쪽에 넣으면 두 번 곱해집니다 "
|
||
"(PLAN 9-6 ㉣)."
|
||
)
|
||
|
||
|
||
def check_operator_hours_basis(
|
||
*,
|
||
labor_per_hour: Decimal,
|
||
daily_wage: Decimal,
|
||
person_days: Decimal = Decimal(1),
|
||
hours_per_day: int = 8,
|
||
) -> None:
|
||
"""㉣ 보조 — 조종원 노임 나눗수가 8시간인가.
|
||
|
||
나눗수를 몰래 줄이는 것이 곧 효율을 사용료에 넣는 것과 같다.
|
||
"""
|
||
expected = daily_wage * person_days / Decimal(hours_per_day)
|
||
if abs(labor_per_hour - expected) > _TOLERANCE:
|
||
raise DoubleCountError(
|
||
f"조종원 시간당 노무비 {labor_per_hour:,.2f} 가 "
|
||
f"{daily_wage:,.0f} × {person_days} ÷ {hours_per_day}h = {expected:,.2f} 와 다릅니다 — "
|
||
"나눗수를 줄이면 작업효율을 사용료에 넣은 것이 됩니다 (PLAN 9-6 ㉣)."
|
||
)
|
||
|
||
|
||
def check_column_sums(
|
||
*,
|
||
rows: list[dict],
|
||
totals: dict[str, Decimal],
|
||
columns: tuple[str, ...] = ("material", "labor", "expense", "total"),
|
||
label: str = "본표",
|
||
) -> None:
|
||
"""㉤ **열 방향** 검사 — 표시된 합계가 상세 줄의 열별 합과 같은가.
|
||
|
||
`TC = NC + GC + JC` 는 **행 방향** 검사라 「같은 성분을 두 층에서 세는」 어긋남을
|
||
못 잡는다(행마다는 다 맞는데 열 합만 갈리는 모양). 그래서 방향을 하나 더 둔다.
|
||
|
||
예 — 기계 줄 안에 든 조종원 노무가 별도 노무 줄로도 서면 노무 열만 부풀고
|
||
행 검사는 전부 통과한다.
|
||
"""
|
||
for column in columns:
|
||
column_sum = sum((Decimal(str(row[column])) for row in rows), Decimal(0))
|
||
shown = Decimal(str(totals[column]))
|
||
if abs(column_sum - shown) > _TOLERANCE:
|
||
raise DoubleCountError(
|
||
f"{label}: `{column}` 열 합계가 어긋납니다 — 줄 합 {column_sum:,.2f} vs "
|
||
f"표시 {shown:,.2f}. 같은 성분을 두 층에서 셌을 수 있습니다 "
|
||
"(행 방향 `TC=NC+GC+JC` 검사로는 안 잡힘)."
|
||
)
|