Files
Aislo/B09_Estimation/B09_Estimation_Guards.py
eomsangdonandClaude Opus 5 6d6079341f feat(B09): 조종원 노임에 제수당·상여금·퇴직급여충당금 계수 반영
사용자 확정 2차 ③ — 기술문서 DB 를 먼저 뒤지고, 계수 원문이 없으면 실무 기준 + 노티스.

근거 (원문 확인분)
- 대한건설협회 임금적용요령 4-나 (재경원 회계45101-45, 1995.1.13): 공표 노임은
  **기본급여액**이며 제수당·상여금·퇴직급여충당금은 예정가격작성기준에 따라 별도 계상
- 기재부 「(계약예규) 정부 입찰·계약 집행기준」 제76조의3(노무비의 계상):
  노임 + 제수당 + 상여금(연 400% 한도) + 퇴직급여충당금
- 품셈 8-1-2 5.6호가 운전사 노임을 그 예규로 넘김

미확인 (노티스로 드러냄)
- 예규 원문 자체는 저장소에 없음 — 위 둘은 인용 문서
- 16/12 는 상여 연 400% 와 산술이 일치하나 25/20 은 원문 근거 못 찾음
- 실물 셋이 같은 계수 사용: 실무 두 공사지·임도교본 예제·다산 ESTX 기준계수 0.20833

구현
- `OPERATOR_ALLOWANCE_FACTOR` = 16/12 × 25/20, 근거·한계 주석을 이 한 곳에 모음
- 나눗수 8시간은 그대로 두고 **곱셈으로만** 적용 — 나눗수를 줄이면 작업효율을
  시간당 사용료에 넣은 것이 되어 ㉣ 위반. 검사(`check_operator_hours_basis`)에도
  계수를 넘겨 곱셈 자리를 드러냄
- 일위대가 조종원 줄·A10 인건비·중기 탭 노티스를 같은 문구로 맞춤

검증 (79da822d 기준)
- 굴착기 0.7  86,096.4 → 109,704.5 (+27.4%) · 불도저 19t 124,842.3 → 148,450.4 (+18.9%)
- 조종원 수량 0.20833 = 다산 ESTX 기준계수와 일치
- 실무 시트 재현: 267,360 ÷ 8 × 계수 = 55,700 (시트 값과 일원까지 일치)
- 본체 11,356,910 → 12,141,903 (+784,993, +6.9%)
- pytest 267 통과 · tsc 통과

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

303 lines
14 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
_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 ㉡ 와 같은 성격)."
)
#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다.
#: 품셈 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 [주]① 「고임돌 및 "
"채움 콘크리트 등은 품에 포함」)."
)