Files
Aislo/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py
T
eomsangdonandClaude Opus 5 857a93f08c feat(B08): 지장목제거를 두 줄로 가름 (확정 5차 2번) + 작업 갈래를 지반 갈래로 안 읽음
- 실무(영월 1.9)가 한 면적에 두 작업을 얹음 — 뿌리뽑기 @475 · 잡관목제거 @882 가
  같은 11,035㎡. ⚠ 이중계상이 아니라 서식이 그러함(사유에 적음)
- 잡관목제거는 품셈에 이름이 없음(실무 D00033 별도 단가) — 공종 보류(확정 5차 3번)라
  코드 없이 서고 사유가 붙음
- ⚠ 곁들여 잡은 것: `item` 칸을 **늘 지반 갈래로 읽고 있었음** — 지장목제거의 작업 갈래에
  「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙었음. 갈래로 읽는 공종을
  GROUND_SPLIT_GROUPS 로 한정하고 나머지는 규격 칸으로 보냄
- tmp/tests/test_b08_tree_removal_split.py 신설(5건)

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

325 lines
16 KiB
Python

"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (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
# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다.
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기")
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 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)
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)))
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
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),
)
)
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㎡
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
# ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀.
# 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.**
for item, why in (
(
"뿌리뽑기",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)",
),
(
"잡관목제거",
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
" ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상",
),
):
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)
)
)
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("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