· 손료 = 자재가 × 표 수량 곧장(패널 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
368 lines
18 KiB
Python
368 lines
18 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
|
||
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 [주]① 「고임돌 및 "
|
||
"채움 콘크리트 등은 품에 포함」)."
|
||
)
|