"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (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) #: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」). 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) ) ) 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