Files
Aislo/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py
eomsangdonandClaude Opus 5 4186a851a4 feat(b05·b08): 혼합석 부설 — 법령 별표2 조건 자동(종단 8% 초과 토사 · 연약·습윤 칸) · 「비포장 전 구간」 고르개(교본 3-2) · 11-4 ㎥ + 사전터파기 토공 축 · 자재총괄 혼합석(12장 D 포장 둘 · 브레인 ⓑ①~⑤)
- 자재 = 다짐 후 부피 ÷ C × L · 두께 제안 0.10(교본) · C 0.85 · L 1.25 제안(소광 관측 · 원문 표에 혼합석 줄 없음 · 역 C 와 방향 반대 사유)
- 종단 경사는 계획선에서 B05 포장 제안과 같은 한 벌(local_grade_pct 로 뺌 — 종단 파일 측점 경사엔 비정규 측점이 없었음)
- 해당 측점이 없으면 0 원 줄 안 세움 · 다짐 줄은 공종 없어 사유

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 04:55:31 +09:00

503 lines
25 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.
"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (B08 일감 4 · PLAN 8-11).
열 구성 (거창 실무 `토공집계표` 시트 그대로)
`구분 · 공종 · 규격 · 단위 · 계 · 비고`
암 갈래는 **개수를 코드에 박지 않는다** (PLAN 8-13)
울진 2갈래(연암·발파암) · 거창 5갈래(토사·풍화암·연암·보통암·경암) · 오솔길 BOM 1갈래로
공사마다 다르다. 프로젝트 설정의 세트를 받아 그만큼 줄을 낸다.
⚠ 측점별 암질 판정에 기대지 않는다(PLAN 8-1 사용자 확정) — 절토량은 기하에서 나오고
**암/토사 나눔과 갈래 비율은 설계자 입력**이다. 그래서 여기서는 토적표의 「암」 총량을
설계자가 준 비율(%)로 나눠 줄을 만든다.
⚠ 반영률은 법정값이 아니다 (PLAN 8-11)
실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **기본 100 %** 이고
설계자가 바꾼다. 실무 관측치(80/50/80)는 참고이지 기본값이 아니다.
⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 되지 않는다** (PLAN 8-7 ㉡)
품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다.
그래서 `in_bill=False` 로 표시해 넘긴다 — 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable
# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다.
# 면고르기는 절토면만 토사/암 갈래(성토면 줄은 `item` 이 비어 지반 없음 · 2026-09-14 Ⓑ).
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기", "면고르기")
#: 면고르기 갈래 — 품셈 9-19-1 원문 표 두 벌 그대로(L5414 절토면 토질 6 · L5430 성토면 시공·토질).
#: B09 표 읽기(`_ResourceAxis_JudgedTable`)가 같은 이름으로 갈래를 세움 — 짝 시험이 마스터와 대조.
#: ⚠ 토질·시공은 제안값 없음(판정 Ⓒ) · 성토면 기계는 굴착기 0.6㎥ 형식 둘로 갈래(브레인 ②).
FACE_DRESSING_CUT_CLASSES = (
"절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토",
"절토면 · 연질토ㆍ불순자갈",
"절토면 · 호박돌 섞인 고결토ㆍ경질토",
"절토면 · 풍화암",
"절토면 · 연암",
"절토면 · 보통암ㆍ경암",
)
FACE_DRESSING_FILL_CLASSES = (
"성토면 · 인력 · 점토 또는 점질토",
"성토면 · 인력 · 모래 또는 사질토",
"성토면 · 기계 · 무한궤도",
"성토면 · 기계 · 타이어",
)
#: 성토면 제안 — 칸 곁 회색 제안일 뿐 스스로 고르지 않음(「기본값을 몰래 확정으로 안 바꿈」).
FACE_DRESSING_FILL_SUGGESTED = (
"성토면 · 기계 · 무한궤도",
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
)
#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로.
#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮).
SEED_SPRAY_GROUNDS = ("일반", "마사토")
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
ROOT_REMOVAL_EXCAVATOR_SUGGESTED = (
"0.7",
"산림품셈 10-12-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — 같은 고시가 임도 표준"
" 장비를 0.7 로 적은 자리 · 0.2㎥ 도 고를 수 있음",
)
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
FACE_DRESSING_NOTE = (
"초류종자살포와 같은 면적(식재 대상) — 품셈 9-19-1 성토면 [주] 「식재를 위한 성토사면」 ·"
" 9-19-2·3 [주]① 「비탈면 식재기반 조성에만」"
)
FACE_DRESSING_ZERO = "파종 면적이 0 — 식재 대상 아님(면고르기 안 셈)"
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %).
RATIO_OF_ROW = {
"성토면다짐": "fill_slope_compaction",
"초류종자살포": "seed_spray_fill", # 성토면 몫에만 걸린다 — 절토면은 별도 키
"지장목제거": "obstacle_removal",
}
@dataclass(slots=True)
class SummaryRow:
"""집계표 한 줄. 이름·단위는 거창 실무 시트 문구를 따른다."""
group: str # 구분 (흙깎기·성토·…)
item: str = "" # 공종 (토사·연암·…)
spec: str = "" # 규격 (기계(굴삭기)·백호우·…)
unit: str = "㎥"
amount: float = 0.0 # 반영률을 **곱한 뒤** 값 — 내역서에 쓰는 값
# ⚠ 반영률 **적용 전** 값과 쓴 율을 함께 남긴다 (2026-09-07 3자 계약).
# 곱하기는 **B08 한 곳에서만** 한다. B09 가 율만 보고 또 곱하면 값이 두 배가 된다.
# 반영률 개념이 없는 줄은 `None` 이고, 100 % 인 줄도 **100.0 을 적는다** —
# 칸이 비어 있으면 「적용됐는지」를 받는 쪽이 단정할 수 없다.
amount_gross: float | None = None
application_ratio_pct: float | None = None
# ⚠ 성·절토면이 갈리는 줄은 **늘 갈래별로** 싣는다 (2026-09-07 3자 계약 확정).
# 「율이 같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고
# 그게 **한쪽만 고쳐지는** 자리가 된다. `application_ratio_pct` 는 두 율이 같을 때만
# 채우는 **편의값**이고, 정본은 아래 두 칸이다.
application_ratio_breakdown: dict[str, float] | None = None
quantity_breakdown: dict[str, float] | None = None
note: str = ""
# 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡).
in_bill: bool = True
@dataclass(slots=True)
class SummaryInput:
"""집계에 필요한 값 묶음. 토적표·사면표·운반계획에서 이미 나온 것만 받는다."""
earthwork_totals: dict[str, float] = field(default_factory=dict)
slope_totals: dict[str, float] = field(default_factory=dict)
haul_rows: list[dict[str, Any]] = field(default_factory=list)
rock_classes: list[str] = field(default_factory=list)
rock_ratios_pct: dict[str, float] = field(default_factory=dict)
application_ratios: dict[str, float] = field(default_factory=dict)
# 노체다짐 — 기본 꺼짐(2026-09-13 판정). 켜면 성토 밑에 별도 줄이 선다.
subgrade_compaction_enabled: bool = False
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict)
# 토취(반입토) — 유토곡선이 낸 성토 부족분(다짐상태 ㎥)과 구간(2026-09-14 브레인 ①).
borrow_m3: float = 0.0
borrow_sites: list[dict[str, Any]] = field(default_factory=list)
# 혼합석 부설 — `Engine_GravelSurfacing.gravel_surfacing` 한 벌(2026-09-15 브레인 포장 둘 ⓑ).
gravel: dict[str, Any] | None = None
#: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」).
BORROW_NAME = "토취(반입토)"
BORROW_REASON = (
"유토곡선이 성토 부족분으로 낸 수량(다짐상태) — 토취장 거리·재료 미정이라 금액이 서지 않음"
" · 반입 재료를 몰라 자연상태로 되돌리지 않음"
)
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
#: 성토 단가(포설)에 묻지 않는다 — 묻으면 왜 그 금액인지 설명이 안 되고 켜고 끈 것이 안 보인다.
#: ⚠ [주]③ 「굴착기와 진동롤러 조합」은 작업 방식 설명 — 굴착기는 성토(포설 9-16-1) 줄이 이미 셈.
#: 이 줄에 굴착기를 또 넣으면 두 번 셈(2026-09-14 「#조합」 철회).
SUBGRADE_COMPACTION_SPEC = "진동롤러(자주식 10ton)"
SUBGRADE_COMPACTION_NOTE = (
"산출 조건에서 켬 — 품셈 9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우 적용」"
" · 수량 = 성토량(포설과 같은 밑수 ㎥)"
" · [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 셈"
)
def _ratio(source: SummaryInput, key: str) -> float:
"""반영률(0~1). 없으면 1.0 — 실무 관측치를 기본값으로 쓰지 않는다."""
value = source.application_ratios.get(key)
return float(value) if isinstance(value, (int, float)) else 1.0
def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float, str]]:
"""암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다. `(이름, 물량, 비고)`.
비율이 아직 없으면 **나누지 않고 「암」 한 줄로** 낸다 — 지어낸 비율로 쪼개지 않는다.
⚠ 합이 100 이 아니어도 **총량은 보존**한다 — 준 비율끼리 안분한다. 물량이 조용히
사라지면 안 되기 때문이다. 다만 값이 말없이 바뀌는 것이므로 **비고에 드러낸다**
(60/30 을 넣으면 실제로는 66.7/33.3 으로 돈다).
"""
classes = [name for name in source.rock_classes if name != "토사"]
ratios = {name: float(source.rock_ratios_pct.get(name, 0) or 0) for name in classes}
given = sum(ratios.values())
if given <= 0:
return [("암", total, "")]
note = "" if abs(given - 100.0) < 1e-9 else f"암 갈래 입력 합 {given:g} % → 100 % 로 안분"
return [(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0]
def build_rows(source: SummaryInput) -> list[SummaryRow]:
"""토공집계표 줄 목록. 값이 0 인 갈래도 줄은 남긴다(실무 시트가 그렇다)."""
earth = source.earthwork_totals
slope = source.slope_totals
rows: list[SummaryRow] = []
# ── 흙깎기 · 측구터파기 — 토사 한 줄 + 암 갈래만큼 ──────────────
for group, soil_key, rock_key in (
("흙깎기", "cut_soil_volume_m3", "cut_rock_volume_m3"),
("측구터파기", "ditch_soil_volume_m3", "ditch_rock_volume_m3"),
):
rows.append(
SummaryRow(
group=group, item="토사", spec="기계(굴삭기)", amount=earth.get(soil_key, 0.0)
)
)
for name, amount, note in _split_by_rock(earth.get(rock_key, 0.0), source):
rows.append(
SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note)
)
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0)))
if source.subgrade_compaction_enabled:
# ⚠ 끄면 줄 자체를 안 낸다 — 빈 줄은 「세야 함」으로 읽힌다(임목파쇄와 같은 자리).
rows.append(
SummaryRow(
group="노체다짐",
spec=SUBGRADE_COMPACTION_SPEC,
amount=earth.get("fill_volume_m3", 0.0),
note=SUBGRADE_COMPACTION_NOTE,
)
)
if source.borrow_m3 > 0:
sites = " · ".join(
f"{float(s['from_m']):g}~{float(s['to_m']):g}m {float(s['volume_m3']):,.2f}㎥"
for s in source.borrow_sites
)
note = f"{BORROW_REASON} · 구간 {sites}" if sites else BORROW_REASON
rows.append(
SummaryRow(group=BORROW_NAME, amount=source.borrow_m3, note=note, in_bill=False)
)
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
rows.extend(_haul_rows(source))
# ── 사면 계열 — 반영률이 여기서 걸린다 ────────────────────────
fill_face = slope.get("face_dressing_fill", 0.0)
cut_face = slope.get("face_dressing_cut", 0.0)
rows.append(
SummaryRow(
group="성토면다짐",
unit="㎡",
amount=fill_face * _ratio(source, "fill_slope_compaction"),
amount_gross=fill_face,
application_ratio_pct=_ratio(source, "fill_slope_compaction") * 100.0,
application_ratio_breakdown={"fill": _ratio(source, "fill_slope_compaction") * 100.0},
quantity_breakdown={"fill": fill_face * _ratio(source, "fill_slope_compaction")},
note=_ratio_note(source, "fill_slope_compaction", "성토면"),
)
)
seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio(
source, "seed_spray_cut"
)
# ⚠ 성·절토면 율이 다를 수 있어 **한 줄에 하나의 율**로 못 적는다. 적용 전 합을 함께 두고
# 율은 두 율이 같을 때만 적는다 — 다르면 `None` 이고 비고에 두 율이 적힌다.
seed_gross = fill_face + cut_face
seed_fill_ratio = _ratio(source, "seed_spray_fill")
seed_cut_ratio = _ratio(source, "seed_spray_cut")
rows.append(
SummaryRow(
group="초류종자살포",
spec="씨드스프레이",
unit="㎡",
amount=seed,
amount_gross=seed_gross,
application_ratio_pct=(
seed_fill_ratio * 100.0 if seed_fill_ratio == seed_cut_ratio else None
),
application_ratio_breakdown={
"fill": seed_fill_ratio * 100.0,
"cut": seed_cut_ratio * 100.0,
},
quantity_breakdown={
"fill": fill_face * seed_fill_ratio,
"cut": cut_face * seed_cut_ratio,
},
note=_seed_note(source),
)
)
rows.extend(_face_dressing_rows(source, fill_face, cut_face))
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
# 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475
# 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
# ⭐ 2026-09-13 브레인 판정 — 지장목제거는 **품셈에 있다**(벌목 4장 + 제근 9-20~21 단계 합산).
# 잡관목제거 = 단목베기 1,000㎡당 「5m 미만」(4-2-2) · 뿌리뽑기 = 제근(9-21, 판정 Ⓑ).
# 준비공 「제근·뿌리다듬기」는 같은 면적이라 **참조로만** 보인다(이중계상 막이).
for item, why in (
(
"뿌리뽑기",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름"
"(같은 면적을 나눠 씀 · 이중계상 아님). 품셈 9-21 제근으로 이음 —"
" 준비공 「제근·뿌리다듬기」는 참조로만 보임(2026-09-13 판정)",
),
(
"잡관목제거",
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
" 품셈 4-2-2 단목베기(1,000㎡당) 「5m 미만」으로 이음"
"(2026-09-13 판정 · 실무 영월 1.9.2)",
),
):
rows.append(
SummaryRow(
group="지장목제거",
item=item,
unit="㎡",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0)
* _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=" · ".join(
part
for part in (_ratio_note(source, "obstacle_removal", "성토면+절토면"), why)
if part
),
)
)
rows.append(
SummaryRow(
group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0)
)
)
if source.gravel is not None:
# 노면 — 혼합석 부설(11-4 ㎥ = 흐트러진 부피) · 사전 터파기는 토공 축(브레인 ①).
note = " · ".join(source.gravel.get("notes") or [])
loose = float(source.gravel.get("loose_m3") or 0.0)
dig = float(source.gravel.get("excavation_soil_m3") or 0.0)
zero = "물량 0 이라 내역에 안 세움 · " # 0 원 줄은 「없음」과 구별이 안 됨(구조물 줄과 같은 규칙)
rows.append(
SummaryRow(
group="혼합석부설",
spec="유압식백호우(0.7㎥)",
amount=loose,
note=f"{'' if loose > 0 else zero}"
f"면적 {float(source.gravel.get('area_m2') or 0.0):,.2f}㎡ · {note}",
in_bill=loose > 0,
)
)
rows.append(
SummaryRow(
group="혼합석 사전터파기",
spec="토사 · 기계(굴삭기)",
amount=dig,
note=f"{'' if dig > 0 else zero}"
f"토사 면적 {float(source.gravel.get('soil_area_m2') or 0.0):,.2f}× 0.10m",
in_bill=dig > 0,
)
)
return rows
def _face_dressing_rows(
source: SummaryInput, fill_face: float, cut_face: float
) -> list[SummaryRow]:
"""면고르기 — 성토면 한 줄 · 절토면 토사 한 줄 + 암 갈래(판정 Ⓐ·Ⓑ). 밑수 = 파종 면적."""
rows: list[SummaryRow] = []
for face, key, gross, ratio_key in (
("성토면", "fill", fill_face, "seed_spray_fill"),
("절토면", "cut", cut_face, "seed_spray_cut"),
):
ratio = _ratio(source, ratio_key)
seeded = gross * ratio
given = source.face_dressing_area_m2.get(key)
amount = float(given) if isinstance(given, (int, float)) else seeded
if amount <= 0:
note = FACE_DRESSING_ZERO
elif isinstance(given, (int, float)):
note = f"덮어쓴 면적 {amount:,.2f}㎡ (파종 면적 {seeded:,.2f}㎡)"
else:
note = FACE_DRESSING_NOTE
common = {
"group": "면고르기",
"spec": face,
"unit": "㎡",
"amount_gross": gross,
"application_ratio_pct": None if isinstance(given, (int, float)) else ratio * 100.0,
}
if face == "성토면":
rows.append(SummaryRow(**common, amount=amount, note=note, in_bill=amount > 0))
continue
# 절토면 — 사면 조각의 토사/암 몫(적용 전 면적)으로 나누고 같은 배율을 곱함.
# 못 가른 몫은 안 세고 드러냄. 몫마다 적용 전 값을 따로 실어 율 거울 검사가 맞게.
slope = source.slope_totals
soil = slope.get("face_dressing_cut_soil", 0.0)
rock = slope.get("face_dressing_cut_rock", 0.0)
scale = amount / cut_face if cut_face > 0 else 0.0
unknown = max(cut_face - soil - rock, 0.0) * scale
if unknown > 1e-6:
note += f" · ⚠ 토사/암을 못 가른 사면 {unknown:,.2f}㎡ 는 안 셈(측점 지반 프리셋 없음)"
parts = [("토사", soil, "")] + (_split_by_rock(rock, source) if rock > 0 else [])
for name, part_gross, split_note in parts:
part = part_gross * scale
# 토사 몫은 갈래 값(「절토면 · 모래…」)이 규격을 말함 · 암 몫은 규격에 암 갈래
spec = face if name == "토사" else f"절토면 · {name}"
rows.append(
SummaryRow(
**{**common, "amount_gross": part_gross, "spec": spec},
item=name,
amount=part,
note=" · ".join(text for text in (note, split_note) if text),
in_bill=part > 0,
)
)
return rows
def _ratio_note(source: SummaryInput, key: str, base: str) -> str:
ratio = _ratio(source, key)
return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영"
def _seed_note(source: SummaryInput) -> str:
fill = _ratio(source, "seed_spray_fill")
cut = _ratio(source, "seed_spray_cut")
if abs(fill - 1.0) < 1e-9 and abs(cut - 1.0) < 1e-9:
return ""
return f"성토면 {fill * 100:g} % 반영 + 절토면 {cut * 100:g} % 반영"
# 운반수단 표기 — `HaulPlan` 의 키를 실무 시트 문구로 옮긴다.
HAUL_LABELS = {"free_haul": "무대(종방향유용토)", "dozer": "도자운반", "dump_truck": "덤프운반"}
def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
"""운반 — (운반수단 × 지반유형)별 가중평균 줄 (PLAN 8-3).
무대는 `in_bill=False` — 품셈 1-2-7 로 품에 포함돼 단가가 없다. 값은 검산에 쓴다.
"""
rows: list[SummaryRow] = []
for item in source.haul_rows:
key = str(item.get("equipment") or "")
label = HAUL_LABELS.get(key, key or "운반")
ground = str(item.get("rock_class") or item.get("ground") or "")
distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
# (config 5-4-3 인용). 유토곡선은 다짐으로 쌓으므로 여기서 ÷C 된 값을 받는다.
# 환산은 `HaulSummary` 한 곳에서만 하고, 여기서는 **고르기만** 한다(두 번 환산 금지).
compacted = float(item.get("volume_m3") or 0.0)
natural = item.get("natural_m3")
amount = float(natural) if isinstance(natural, (int, float)) else compacted
if isinstance(natural, (int, float)):
note = (note + f" · 자연상태 환산(다짐 {compacted:,.2f}㎥ ÷ C)").strip(" ·")
else:
note = (note + " · ⚠ 지반 갈래를 몰라 다짐상태 그대로임").strip(" ·")
if key == "free_haul":
note = (note + " · 내역 제외(품에 포함)").strip(" ·")
rows.append(
SummaryRow(
group=label,
item=ground,
amount=amount,
note=note,
in_bill=key != "free_haul",
)
)
return rows
def build_table(source: SummaryInput) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양."""
rows = build_rows(source)
return {
"columns": ["구분", "공종", "규격", "단위", "계", "비고"],
"rock_classes": list(source.rock_classes),
"rock_ratios_pct": dict(source.rock_ratios_pct),
"application_ratios": dict(source.application_ratios),
"rows": [
{
"group": row.group,
"item": row.item,
"spec": row.spec,
"unit": row.unit,
"amount": row.amount,
"amount_gross": row.amount_gross,
"application_ratio_pct": row.application_ratio_pct,
"application_ratio_breakdown": row.application_ratio_breakdown,
"quantity_breakdown": row.quantity_breakdown,
"note": row.note,
"in_bill": row.in_bill,
}
for row in rows
],
"row_count": len(rows),
}
def haul_check(source: SummaryInput, earthwork_totals: dict[str, float]) -> dict[str, Any]:
"""검산 — `무대 + 도자 + 덤프` 합이 총 운반토량과 맞는가 (PLAN 8-7 ㉡).
무대를 안 내면 이 검산이 안 된다. 그래서 값은 내되 내역 줄만 빼는 것이다.
"""
hauled = sum(float(item.get("volume_m3") or 0.0) for item in source.haul_rows)
diverted = float(earthwork_totals.get("diverted_m3") or 0.0)
return {
"hauled_total_m3": hauled,
"diverted_total_m3": diverted,
"difference_m3": hauled - diverted,
}
def totals_by_unit(rows: Iterable[SummaryRow]) -> dict[str, float]:
"""단위별 합계 — ㎥ 와 ㎡ 를 섞어 더하지 않는다."""
result: dict[str, float] = {}
for row in rows:
result[row.unit] = result.get(row.unit, 0.0) + row.amount
return result