Files
Aislo/B09_Estimation/B09_Estimation_Guards.py
eomsangdonandClaude Opus 5 efaf2403a0 feat(b09): 301 유로폼 사용수량 12-38-2 — 임대료 또는 손료를 「자재 단가」 탭에서 설계자가 넣은 쪽으로 세움
· 손료 = 자재가 × 표 수량 곧장(패널 0.089 · 내부 패널 0.003매/㎡) + 부자재 24/52/79% · 소모자재 5%(주자재비 = 패널 + 내부 패널) — 봉화 2024 호표 재료비 보통 4,500 · 간단 3,697 과 일치
· 임대료 = 「유로폼 임대료 ㎡당」 한 줄 · 부자재 % 는 원문이 안 적어 안 걺
· 둘 다 · 한쪽만 반쯤 · 아무것도 — 안 세우고 사유(부모 12-38 까지 그대로)
· 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·25% 몫으로 보임(우리 역산 · 차 2.5% 는 추정이라 비고에 밝힘)
· 이중계상 ③ 가드에서 12-38-02 만 뺌(패널이 자재총괄에 줄 없음) · 유로폼이 자재총괄로 가면 빨강 시험
· 합산형 부모 — 갈래 벌이 같으면 여러 단계 갈래끼리 합산(12-38 = 사용수량 + 설치·해체)
· 작업조 표 7 은 밑수 없음 목록에서 뺌(1단위당 = 인원 ÷ 시공량) — 12-38-3 설치·해체가 막혀 있던 자리
· 마스터 재생성 — 12-38 · 12-38-02 갈래 키만 바뀜

기본 조립 제목 금액 변화 0 · 프로젝트 내역 변화 0 · 잴 시험 일곱 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1677 + 318 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 21:29:04 +09:00

368 lines
18 KiB
Python
Raw Permalink 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 원가계산 — 이중계상 감시 (거울 테스트 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
from typing import Any
_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 ㉡)."
)
#: ⚠ **아직 부르는 자리가 없다** (2026-09-08 메인 창 교차검토에서 확인).
#: 배합 분해(콘크리트 → 시멘트·모래·자갈)가 우리 일위대가에 아직 안 서 있어
#: 넘길 값이 없다. **「있다」와 「돈다」는 다르다** — 조건이 생기는 곳을 아래에 적어 둔다.
#:
#: **부를 자리** — `B09_Estimation_UnitPrice.build_unit_prices()` 에서 콘크리트 계열
#: (`FP-12-01*`)에 시멘트·모래·자갈 줄이 붙기 시작하면, 그 공종의 시멘트 총량과
#: 콘크리트 체적을 넘겨 부를 것. 지금 그 표는 「㎥」까지만 내고 분해가 없다.
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,
allowance_factor: Decimal = Decimal(1),
) -> None:
"""㉣ 보조 — 조종원 노임 나눗수가 8시간인가.
나눗수를 몰래 줄이는 것이 곧 효율을 사용료에 넣는 것과 같다.
⚠ `allowance_factor`(제수당·상여·퇴직충당 1.667배)는 **나눗수와 다르다** — 곱하는
자리를 드러내 놓고 검사에도 같이 넘긴다. 계수를 몰래 나눗수에 녹이면 이 검사가
통과해 버리므로, 계수는 **곱셈으로만** 들어와야 한다.
"""
expected = daily_wage * person_days / Decimal(hours_per_day) * allowance_factor
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` 검사로는 안 잡힘)."
)
def check_excluded_rows_not_priced(
*,
rows: list[dict],
amount_field: str = "amount_krw",
unit_price_field: str = "unit_price_krw",
label: str = "내역서",
) -> None:
"""㉡ 확장 — `in_bill=false` 줄(보정량계·무대 등)에 금액이 붙지 않았는가.
B08 은 검산용 줄도 **수량을 그대로 실어 보낸다**(계약 확정). 수량이 있으니
조판이 무심코 단가를 붙이면 같은 것을 두 번 세게 된다 — 합계 줄과 그 아래
상세 줄이 함께 더해지는 모양이라 **행 검사로는 안 잡힌다**.
"""
for row in rows:
for field_name in (amount_field, unit_price_field):
value = row.get(field_name)
if value in (None, ""):
continue
if Decimal(str(value)) != 0:
raise DoubleCountError(
f"{label}: 합계·검산용 줄(`in_bill=false`)에 {field_name} "
f"{Decimal(str(value)):,.0f} 이 붙었습니다 — 그 줄은 수량만 보이고 "
"금액을 매기지 않습니다 (PLAN 8-7 ㉡ 와 같은 성격)."
)
def check_handoff_boundaries(violations: list[str] | None) -> None:
"""이중계상 경계 ①②③(명세 6장) — B08 인계가 갈 곳 칸으로 판정해 보낸 어긴 자리.
판정은 성분 칸을 가진 B08 한 곳에서 한다(`B08_Quantity_Engine_Handoff_Boundaries`).
여기서는 **멈추기만** 한다 — 목록이 차 있는데 내역서를 세우면 같은 물량이 두 번 금액이 된다.
"""
if violations:
raise DoubleCountError("이중계상 경계를 어겼습니다 — " + " / ".join(violations))
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 셋, 코드는 마스터가 붙인 자리.
#: ⚠ 유로폼 사용수량 12-38-02([주]① 「할증 및 손율이 포함」)는 **뺐음**(2026-09-14 브레인 301 ④) —
#: 이 가드의 전제 「자재총괄에서 할증이 한 번 더」가 안 섬: 패널은 B08 자재총괄에 줄이 없음(유로폼 ㎡ 갈 곳
#: `unit_price`) · 손료 수량이라 할증 전 값이 원문에 없음 · 실무 넷도 일위대가에 곧장 붙임.
#: 유로폼·패널이 자재총괄로 가는 날 되돌릴 것 — `test_b09_euroform` 이 그 자리에서 빨강.
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
def check_materials_before_surcharge(book: Any) -> None:
"""③ 일위대가 재료비는 할증 전 — 할증 포함 재료량을 준 공종에 자재 줄이 붙으면 멈춘다.
⚠ 지금은 그 재료들이 자재 단가 층이 없어 **안 붙은 줄**로만 보인다 — 층이 서는 날 여기서 걸린다.
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
for title_code, details in book.details.items():
code = title_code[2:].split("#", 1)[0] if title_code.startswith("B-") else ""
if code not in SURCHARGE_INCLUDED_ITEMS:
continue
for detail in details:
ref = book.titles.get(detail.ref_code)
if ref is not None and ref.kind is PriceKind.MATERIAL:
raise DoubleCountError(
f"{title_code}: 자재 「{ref.name}」이 일위대가 재료비로 붙었습니다 — "
f"{SURCHARGE_INCLUDED_ITEMS[code]}. 일위대가 재료비는 할증 전 값이고 "
"할증은 자재총괄 한 곳뿐입니다 (PLAN 8-7 ㉠)."
)
def check_material_surcharge_once(materials: list[Any]) -> None:
"""③ 자재 줄 합계 = 순수량 × (1 + 할증률) **한 번** — 율이 없거나 품셈 포함이면 순수량 그대로.
내역서 자재대는 `total_amount` 를 수량으로 쓴다. 인계 도중 어디서든 할증이 한 번 더 붙으면
그 값이 그대로 금액이 되므로, **금액을 만드는 자리**에서 순수량과 율로 되짚는다.
"""
for material in materials:
net = Decimal(str(material.net_amount))
included = "할증 포함" in str(material.surcharge_note or "")
rate = None if included else material.surcharge_pct
expected = net if rate is None else net * (Decimal(1) + Decimal(str(rate)) / Decimal(100))
total = Decimal(str(material.total_amount))
if abs(total - expected) > max(Decimal("1e-6"), abs(expected) * Decimal("1e-9")):
raise DoubleCountError(
f"자재 「{material.material_name}」: 합계 {total} 가 순수량 {net} × "
f"(1+{rate or 0}%) = {expected} 와 다릅니다 — 할증이 두 번 붙었거나 빠졌습니다 "
"(PLAN 8-7 ㉠ 할증은 자재총괄 한 번)."
)
#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다.
#: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를
#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**.
OVERHEAD_TIER_WITH_PIPE = "with_pipe"
OVERHEAD_TIER_WITHOUT_PIPE = "without_pipe"
#: 물빼기 파이프를 가리키는 자재 이름들. **정확 일치**로만 본다 — 넓게 잡으면
#: 「물구멍 마감재」 같은 정상 자재까지 지운다.
DRAIN_PIPE_NAMES = ("물빼기파이프", "물빼기 파이프", "물구멍", "배수공")
#: ⚠ **아직 부르는 자리가 없다** — 제잡비를 늘 **아랫단**(파이프 미설치)으로만 붙여
#: 조건이 안 온다. 값이 틀린 것은 아니고, **윗단을 쓰게 되는 날 막을 것이 없는** 상태다.
#:
#: **부를 자리** — `B09_Estimation_BillOfQuantities.build_bill()` 의 자재 검사 옆.
#: 제잡비 윗단을 고르는 설계 조건(물빼기 파이프 설치 여부)이 인계에 실리면,
#: 그 조건과 자재 이름 목록을 넘겨 부를 것. 지금은 그 칸 자체가 없다.
def check_drain_pipe_not_double_counted(
*,
overhead_tier: str,
material_names: list[str],
label: str = "돌쌓기",
) -> None:
"""㉥ 제잡비 윗단을 쓰면서 물빼기 파이프를 또 세지 않았는가.
품셈 13-6-2 [주]③ 이 **윗단 값에 파이프 설치의 노무비·재료비가 포함**된다고
적어 두었다. 그 값을 쓰면서 파이프를 자재로 또 실으면 같은 것을 두 번 센다.
⚠ 둘 중 하나만 골라야 한다 — 아랫단(파이프 미설치) 값을 쓰고 파이프를 따로 세거나,
윗단 값을 쓰고 파이프를 안 세거나.
"""
if overhead_tier != OVERHEAD_TIER_WITH_PIPE:
return
tight = {"".join(str(name).split()) for name in material_names}
hit = next(
(name for name in DRAIN_PIPE_NAMES if "".join(name.split()) in tight),
None,
)
if hit is not None:
raise DoubleCountError(
f"{label}: 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 「{hit}」을 자재로 또 "
"실었습니다 — 윗단 값에 파이프의 노무비·재료비가 이미 들어 있습니다 "
"(품셈 13-6-2 [주]③)."
)
#: 큰돌쌓기(13-6) 품에 **이미 들어 있는** 자재 — 따로 세우면 두 번이다.
#: 근거: 품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」.
#: ⚠ **13-6 한정**이다 — 돌쌓기(13-4)·돌붙임(13-7)에는 이 [주]가 없으므로
#: 그쪽에서 고임돌이 자재로 오는 것은 정상이다. 넓게 잡으면 정상 자재를 지운다.
BOULDER_INCLUDED_MATERIALS = ("고임돌", "채움콘크리트", "채움 콘크리트")
BOULDER_WORK_ITEM_PREFIX = "FP-13-06"
def check_included_materials_not_listed(
*,
work_item_codes: list[str],
materials: list[dict],
name_field: str = "material_name",
source_field: str = "source_structure",
label: str = "큰돌쌓기",
) -> None:
"""㉦ 품에 포함된 자재를 따로 세지 않았는가 (품셈 13-6 [주]①).
큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면
같은 것을 두 번 센다. 자재의 `source_structure` 로 **그 구조물에서 온 것만** 본다 —
다른 구조물(돌쌓기 13-4)의 고임돌은 정상이다.
"""
if not any(str(code).startswith(BOULDER_WORK_ITEM_PREFIX) for code in work_item_codes):
return
for material in materials:
name = "".join(str(material.get(name_field) or "").split())
if name not in {"".join(x.split()) for x in BOULDER_INCLUDED_MATERIALS}:
continue
sources = material.get(source_field) or []
if any(label in str(source) for source in sources):
raise DoubleCountError(
f"{label}: 「{material.get(name_field)}」이 자재로도 실렸습니다 — "
"큰돌쌓기 품에 이미 들어 있습니다 (품셈 13-6 [주]① 「고임돌 및 "
"채움 콘크리트 등은 품에 포함」)."
)