diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork.py b/B08_Quantity/B08_Quantity_Engine_Earthwork.py index aa654e5a3..704537f89 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork.py @@ -10,8 +10,15 @@ 측점 · 거리 · 절토[토사·암 각 (단면적·입적·보정량)] · 측구터파기[토사·암 각 3칸] · 보정량계 · 성토[단면적·입적] · 유용토 · 차인토량 · 누가토량 + · 면고르기[성토면·절토면] · 지장목제거[성토·절토] · 제근 · 노면정리 각 (거리·면적) - 사면 계열(층따기·면고르기·법면보호공·지장목제거)은 사면길이가 아직 없어 일감 3에서 붙인다. +보정량계 = 절토 보정량만 (PLAN 69 정함 · 사용자 견본 120+18 행 36.2 + 55.2 = 91.4) + 측구터파기 보정량은 칸에 두되 보정량계에 넣지 않는다. + +면 계열 (PLAN 69-3) — 측점 「거리」 = B07 횡단도 수량과 같은 값 + 면고르기 · 지장목제거 = `B07_DesignDetail_Engine_Cross_Quantity.derived_cells` 그대로 · + 노면정리 = 그 측점 노면 폭(설계 `roadbed_width_m`) · 제근 = B07 에 값이 없어 빈 칸. + 면적 = (앞 거리 + 현 거리) ÷ 2 × 측점 거리 — 첫 측점은 거리만. 층따기는 토공집계표용. 평균단면적법 (신규 문서 5장 「다. 공사수량의 산출」) 체적 = (앞 측점 단면적 + 현 측점 단면적) ÷ 2 × 두 측점 사이 거리. @@ -39,6 +46,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Iterable +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # 절토 암을 어느 환산계수로 볼지 — 측점의 `cut_rock_kind` 를 그대로 쓴다. @@ -57,6 +65,17 @@ _FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적 ROCK_KINDS = ("ripping_rock", "blasting_rock") +#: 면 계열 칸 — 화면 차례 · `benching`(층따기)은 토공집계표에만 씀 +FACE_KEYS = ( + "grading_fill", + "grading_cut", + "tree_removal_fill", + "tree_removal_cut", + "grubbing", + "road_surface", + "benching", +) + #: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`. Factors = dict[str, dict[str, float]] @@ -82,6 +101,8 @@ class StationArea: ditch_rock_area_m2: float | None = None ditch_split_basis: str | None = None cut_rock_kind: str | None = None + # 면 계열 그 측점 거리(m) — `None` = B07 에 값 없음 + faces: dict[str, float | None] = field(default_factory=dict) @classmethod def from_design(cls, chainage_m: float, design: dict[str, Any]) -> "StationArea": @@ -93,8 +114,12 @@ class StationArea: value = design.get(key) return float(value) if isinstance(value, (int, float)) else None + cells = derived_cells(float(chainage_m), design) + faces = {key: cells.get(key) for key in FACE_KEYS} + faces["road_surface"] = optional("roadbed_width_m") return cls( chainage_m=float(chainage_m), + faces=faces, cut_soil_area_m2=num("cut_soil_area_m2"), cut_rock_area_m2=num("cut_rock_area_m2"), fill_area_m2=num("fill_area_m2"), @@ -137,6 +162,8 @@ class EarthworkRow: # 절토 암 칸이 어느 갈래(환산계수)로 셌나 — 화면이 리핑암 · 발파암 열로 가름 cut_rock_kind: str = _DEFAULT_ROCK_KIND notes: list[str] = field(default_factory=list) + # 면 계열 — `{키: {"length_m": 거리, "area_m2": 면적}}` · 값 없으면 None + faces: dict[str, dict[str, float | None]] = field(default_factory=dict) def _split_ditch(area: StationArea) -> tuple[float, float, str]: @@ -224,12 +251,9 @@ def build_rows( row.ditch_soil_adjusted_m3 = row.ditch_soil_volume_m3 * soil_factor row.ditch_rock_adjusted_m3 = row.ditch_rock_volume_m3 * rock_factor - row.adjusted_total_m3 = ( - row.cut_soil_adjusted_m3 - + row.cut_rock_adjusted_m3 - + row.ditch_soil_adjusted_m3 - + row.ditch_rock_adjusted_m3 - ) + row.faces = _face_cells(previous, station) + # 보정량계 = 절토 보정량만(PLAN 69 정함) — 측구터파기는 칸에만 + row.adjusted_total_m3 = row.cut_soil_adjusted_m3 + row.cut_rock_adjusted_m3 # 유용토 = 그 측점에서 절취분과 성토분이 서로 만나는 몫. row.diverted_m3 = min(row.adjusted_total_m3, row.fill_volume_m3) row.balance_m3 = row.adjusted_total_m3 - row.fill_volume_m3 @@ -242,6 +266,22 @@ def build_rows( return rows +def _face_cells( + previous: StationArea | None, station: StationArea +) -> dict[str, dict[str, float | None]]: + """면 계열 거리 · 면적 — 면적 = 앞뒤 평균 거리 × 측점 거리 · 첫 측점은 거리만.""" + out: dict[str, dict[str, float | None]] = {} + for key in FACE_KEYS: + length = station.faces.get(key) + before = previous.faces.get(key) if previous else None + area = None + if previous is not None and (length is not None or before is not None): + distance = station.chainage_m - previous.chainage_m + area = ((before or 0.0) + (length or 0.0)) / 2.0 * distance + out[key] = {"length_m": length, "area_m2": area} + return out + + def totals(rows: list[EarthworkRow]) -> dict[str, float]: """합계 행. 단면적은 합이 뜻이 없어 싣지 않는다(실무 토적표도 비워 둔다).""" keys = ( @@ -264,6 +304,9 @@ def totals(rows: list[EarthworkRow]) -> dict[str, float]: mine = [row for row in rows if row.cut_rock_kind == kind] out[f"cut_{kind}_volume_m3"] = sum(row.cut_rock_volume_m3 for row in mine) out[f"cut_{kind}_adjusted_m3"] = sum(row.cut_rock_adjusted_m3 for row in mine) + out[f"ditch_{kind}_volume_m3"] = sum(row.ditch_rock_volume_m3 for row in mine) + for key in FACE_KEYS: + out[f"{key}_area_m2"] = sum(row.faces.get(key, {}).get("area_m2") or 0.0 for row in rows) return out diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py new file mode 100644 index 000000000..2acad151a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -0,0 +1,111 @@ +"""토공 보조 탭 셋 — 토공집계표 · 평균운반거리 · 종방향 무대운반거리 (PLAN 69-2). + +근거 = 거창 실무 워크북(`00.25년_산불_거창(…)내역+수량.xlsx`)의 같은 이름 시트 칸 · 식 그대로. + + 평균운반거리 덤프트럭 · 불도우저 각 토량(Q) · 거리(L) · Q×L · 합계 · + 평균 = ROUND(ΣQ×L ÷ ΣQ, 2) + 종방향 무대운반거리 무대운반 토량(Q) · 거리(L) · Q×L · 합계 · 평균(같은 식) + 토공집계표 토적표 합계 줄 + 위 두 시트 ΣQ 를 시트 식대로 묶음 + +Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토적도가 그리는 것과 같은 정본) · +장비 띠(`blocks[].bands[]`)와 떨어진 구간 운반(`transfers[]`)의 `equipment` 로 가름 +(`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭). + +암 갈래 — 리핑암 = 「연암」 줄 · 발파암 = 「경암」 줄. 풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 +값이 없어 비움(None). +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + +#: 운반 시트 열 — 장비 키 +HAUL_EQUIPMENT = ("dump_truck", "dozer", "free_haul") + + +def excel_round(value: float, digits: int = 0) -> float: + """엑셀 ROUND — 반은 0 에서 먼 쪽(파이썬 round 는 짝수 쪽).""" + step = Decimal(1).scaleb(-digits) + return float(Decimal(str(value)).quantize(step, rounding=ROUND_HALF_UP)) + + +def haul_lists(haul_plan: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + """장비별 `{rows: [{q, l, ql}], q, ql, average_m}` — 시트 한 줄 = 띠 · 운반 하나.""" + items: dict[str, list[dict[str, float]]] = {key: [] for key in HAUL_EQUIPMENT} + plan = haul_plan if isinstance(haul_plan, dict) else {} + bands = [band for block in plan.get("blocks") or [] for band in block.get("bands") or []] + for item in [*bands, *(plan.get("transfers") or [])]: + rows = items.get(str(item.get("equipment"))) + q, dist = item.get("volume_m3"), item.get("haul_distance_m") + if rows is None or not isinstance(q, (int, float)) or not isinstance(dist, (int, float)): + continue + rows.append({"q": float(q), "l": float(dist), "ql": float(q) * float(dist)}) + out: dict[str, dict[str, Any]] = {} + for key, rows in items.items(): + q = sum(row["q"] for row in rows) + ql = sum(row["ql"] for row in rows) + out[key] = { + "rows": rows, + "q": q, + "ql": ql, + "average_m": excel_round(ql / q, 2) if q > 0 else None, + } + return out + + +def _row(group: str, kind: str, spec: str, unit: str, value: float | None, note: str = ""): + return {"group": group, "kind": kind, "spec": spec, "unit": unit, "value": value, "note": note} + + +def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석).""" + t = totals + r0 = excel_round + free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"] + diverted = t["diverted_m3"] + tree = r0(t["tree_removal_fill_area_m2"] * 0.5 + t["tree_removal_cut_area_m2"], 2) + m3 = "㎥" + return [ + _row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137 + _row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None), + _row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(t["cut_ripping_rock_volume_m3"])), + _row("흙깍기", "보통암", "굴삭기 + 브레카", m3, None), + _row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(t["cut_blasting_rock_volume_m3"])), + _row("측구 터파기", "토사", "기계", m3, r0(t["ditch_soil_volume_m3"])), # 토적표!K137 + _row("측구 터파기", "풍화암", "굴삭기 + 브레카", m3, None), + _row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(t["ditch_ripping_rock_volume_m3"])), + _row("측구 터파기", "보통암", "굴삭기 + 브레카", m3, None), + _row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(t["ditch_blasting_rock_volume_m3"])), + _row("보정량계", "", "", m3, r0(t["adjusted_total_m3"])), # 토적표!P137 + _row("성토", "", "", m3, r0(t["fill_volume_m3"])), # 토적표!R137 + _row("유용토 소계", "", "", m3, diverted + free_q), # G18+G19 + _row("횡방향유용토", "", "백호우", m3, diverted, "유용토"), # 토적표!S137 + _row("종방향유용토", "", "백호우", m3, free_q, "유용토"), # 종방향무대운반거리!B25 + _row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42 + _row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ + _row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137 + _row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137 + _row("성토면다짐", "", "", "㎡", r0(t["grading_fill_area_m2"] * 0.8), "성토면80%반영"), + _row("표토제거", "", "", "㎡", None), + _row( + "초류종자살포", + "", + "씨드스프레이", + "㎡", + r0(t["grading_fill_area_m2"] * 0.5 + t["grading_cut_area_m2"]), + "성토면50%반영+절토면", + ), + _row("법면보호공", "", "", "㎡", None), + # 토적표!AI138 = ROUND(성토 × 0.5 + 절토, 2) → × 0.8 + _row("지장목제거", "", "", "㎡", r0(tree * 0.8), "80%반영"), + _row("구조물터파기", "토사", "기계(굴삭기)", m3, None, "구조물 수량"), + _row("구조물터파기", "풍화암", "굴삭기 + 브레카", m3, None), + _row("구조물터파기", "연암", "굴삭기 + 브레카", m3, None, "구조물 수량"), + _row("구조물터파기", "보통암", "굴삭기 + 브레카", m3, None), + _row("구조물터파기", "경암", "발파 + 굴삭기", m3, None), + _row("토사다짐", "", "", m3, None, "구조물 수량"), + _row("되메우기", "", "", m3, None, "구조물 수량"), + _row("잔토", "", "", m3, None, "현장내 성토(유용토)"), + _row("운반성토", "", "BH+DT+BH", m3, None), + ] diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 970a74732..a7abaa3e1 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -1,6 +1,9 @@ -"""B08 3탭 토적표 길 (PLAN 61-3) — 읽기만, 아무 파일 · DB 에도 쓰지 않음. +"""B08 토공 탭 길 (PLAN 61-3 · 69-2) — 읽기만, 아무 파일 · DB 에도 쓰지 않음. - GET /api/projects/{id}/quantity/earthwork 측점별 횡단 설계 단면적 → 평균단면적법 토적표 + GET /api/projects/{id}/quantity/earthwork + 토적표 + 토공집계표 + 평균운반거리 · 종방향 무대운반거리 + +운반 Q · L = 종단 레코드에 저장된 유토곡선 배분(`mass_haul.haul_plan`) — B07 토적도와 같은 정본. 단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) · 토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`. @@ -17,9 +20,11 @@ from fastapi import APIRouter, HTTPException from B06_Section.B06_Section_Repository import ( get_confirmed_route_context, get_cross_section_designs, + get_longitudinal_section, get_workflow_route_context, ) from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import haul_lists, summary_rows from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from common_util.common_util_project_settings import mixed_conversion_factors, quantity_settings from config.config_db import get_db_pool @@ -38,6 +43,17 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]: raise HTTPException(status_code=404, detail="노선이 없습니다 — 노선 · 횡단 설계부터.") route_id = int(context["route_id"]) rows = await get_cross_section_designs(connection, route_id) + section = await get_longitudinal_section(connection, project_id, route_id) factors = await asyncio.to_thread(lambda: mixed_conversion_factors(quantity_settings(root))) stations = [StationArea.from_design(row["chainage_m"], row["design"]) for row in rows] - return {**build_table(stations, factors), "route_id": route_id, "confirmed": confirmed} + table = build_table(stations, factors) + mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {} + haul = haul_lists(mass_haul.get("haul_plan")) + return { + **table, + "summary": summary_rows(table["totals"], haul), + "haul": haul, + "haul_saved": bool(mass_haul.get("haul_plan")), + "route_id": route_id, + "confirmed": confirmed, + } diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 73175de72..a6f826259 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -1,15 +1,25 @@ /* ============================================================================= * B08_Quantity_UI_Earthwork.ts - * 3탭 토적표(PLAN 61-3) — 줄 = 측점 · 열 = 옛 토적 엔진 출력 칸 그대로 · 맨 아래 합계 줄. + * 토공 탭(PLAN 61-3 · 69-2 · 69-3) — 보조 탭 넷 = 토공집계표 · 토적표 · 평균운반거리 · 종방향 무대운반거리. * - * GET …/quantity/earthwork (서버 단독 · 읽기만) — 평균단면적법 · 보정량 = 입적 × 다짐 환산계수 - * 절토 암은 그 측점이 센 갈래(`cut_rock_kind`)대로 리핑암 · 발파암 열에 놓음. - * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀. + * GET …/quantity/earthwork 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림. + * 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 · + * 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각 + * 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 「-」. + * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀 · 나머지 셋은 `B08_Quantity_UI_Earthwork_Sheets`. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, createCard } from "@ui/ui_template_elements"; import { stationLabel } from "../common_util/common_util_station"; import { stationInterval } from "./B08_Quantity_UI_Summary"; +import { + renderEarthworkSummary, + renderFreeHaul, + renderHaulDistance, +} from "./B08_Quantity_UI_Earthwork_Sheets"; + +type Face = { length_m: number | null; area_m2: number | null }; interface EarthworkRow { chainage_m: number; @@ -34,31 +44,70 @@ interface EarthworkRow { balance_m3: number; cumulative_m3: number; notes: string[]; + faces: Record; } -interface EarthworkTable { +export interface HaulRow { + q: number; + l: number; + ql: number; +} + +export interface HaulList { + rows: HaulRow[]; + q: number; + ql: number; + average_m: number | null; +} + +export interface SummaryRow { + group: string; + kind: string; + spec: string; + unit: string; + value: number | null; + note: string; +} + +export interface EarthworkTable { rows: EarthworkRow[]; totals: Record; conversion_factors: Record; station_count: number; route_id: number; confirmed: boolean; + summary: SummaryRow[]; + haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; + haul_saved: boolean; } -const ROCKS = ["ripping_rock", "blasting_rock"] as const; -const GROUPS: Array<[string, number]> = [ - ["절토 토사", 3], - ["절토 리핑암", 3], - ["절토 발파암", 3], - ["측구터파기 토사", 3], - ["측구터파기 암", 3], -]; +const ROCK_KIND_LABEL: Record = { ripping_rock: "리핑암", blasting_rock: "발파암" }; const TRIPLE = ["단면적", "입적", "보정량"]; +const FACE_GROUPS: Array<[string, Array<[string, string]>]> = [ + [ + "면고르기", + [ + ["grading_fill", "성토면"], + ["grading_cut", "절토면"], + ], + ], + [ + "지장목제거", + [ + ["tree_removal_fill", "성토"], + ["tree_removal_cut", "절토"], + ], + ], + ["제근", [["grubbing", ""]]], + ["노면정리", [["road_surface", ""]]], +]; +const FACE_KEYS = FACE_GROUPS.flatMap(([, faces]) => faces.map(([key]) => key)); +const DASH = "-"; const num = (value: number | undefined | null): string => value == null || !Number.isFinite(value) ? "" : value.toFixed(2); -function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) { +export function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: number }) { const node = document.createElement(tag); node.textContent = text; if (span?.row) node.rowSpan = span.row; @@ -66,41 +115,63 @@ function cell(tag: "th" | "td", text: string, span?: { row?: number; col?: numbe return node; } -function note(text: string): HTMLElement { +export function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; p.textContent = text; return p; } +/** 표 한 장 — 1탭 집계표 모양 · 공용 컨테이너(카드) 안 */ +export function tableCard( + info: HTMLElement[], + className: string, + parts: HTMLElement[], +): HTMLElement { + const el = document.createElement("table"); + el.className = `ui-general-block__table b08-summary__table ${className}`; + el.append(...parts); + const wrap = document.createElement("div"); + wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; + wrap.append(el); + return createCard({ body: [...info, wrap] }); +} + +/** 머리 석 줄 — 견본 차례 · 32칸 */ function head(): HTMLTableSectionElement { + const station = cell("th", "측점", { row: 3 }); + station.classList.add("b08-summary__station"); const top = document.createElement("tr"); - top.append(cell("th", "측점", { row: 2 }), cell("th", "거리", { row: 2 })); - for (const [label, span] of GROUPS) top.append(cell("th", label, { col: span })); top.append( - cell("th", "보정량계", { row: 2 }), - cell("th", "성토", { col: 2 }), - cell("th", "유용토", { row: 2 }), - cell("th", "차인토량", { row: 2 }), - cell("th", "누가토량", { row: 2 }), + station, + cell("th", "거리", { row: 3 }), + cell("th", "절토", { col: 6 }), + cell("th", "측구터파기", { col: 6 }), + cell("th", "보정량계", { row: 3 }), + cell("th", "성토", { col: 2, row: 2 }), + cell("th", "유용토", { row: 3 }), + cell("th", "차인토량", { row: 3 }), + cell("th", "누가토량", { row: 3 }), + ...FACE_GROUPS.map(([label, faces]) => + cell("th", label, { col: faces.length * 2, row: faces.length > 1 ? 1 : 2 }), + ), ); - top.firstElementChild?.classList.add("b08-summary__station"); + const mid = document.createElement("tr"); + for (let i = 0; i < 2; i += 1) + mid.append(cell("th", "토사", { col: 3 }), cell("th", "암석", { col: 3 })); + for (const [, faces] of FACE_GROUPS) { + if (faces.length > 1) mid.append(...faces.map(([, label]) => cell("th", label, { col: 2 }))); + } const sub = document.createElement("tr"); - for (const _ of GROUPS) sub.append(...TRIPLE.map((t) => cell("th", t))); + for (let i = 0; i < 4; i += 1) sub.append(...TRIPLE.map((t) => cell("th", t))); sub.append(cell("th", "단면적"), cell("th", "입적")); + for (const _ of FACE_KEYS) sub.append(cell("th", "거리"), cell("th", "면적")); const thead = document.createElement("thead"); - thead.append(top, sub); + thead.append(top, mid, sub); return thead; } -/** 절토 암 세 칸 — 그 측점 갈래 열에만 · 다른 갈래 열은 빈 칸 */ -function rockCells(row: EarthworkRow, kind: string): string[] { - return row.cut_rock_kind === kind - ? [num(row.cut_rock_area_m2), num(row.cut_rock_volume_m3), num(row.cut_rock_adjusted_m3)] - : ["", "", ""]; -} - -function bodyRow(row: EarthworkRow, interval: number): HTMLTableRowElement { +function bodyRow(row: EarthworkRow, first: boolean, interval: number): HTMLTableRowElement { const tr = document.createElement("tr"); tr.dataset.b08Earthwork = String(row.chainage_m); const station = cell( @@ -109,55 +180,55 @@ function bodyRow(row: EarthworkRow, interval: number): HTMLTableRowElement { ); station.className = "b08-summary__station"; if (row.notes.length) station.title = row.notes.join("\n"); + // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(견본 「-」) + const v = (value: number): string => (first ? DASH : num(value)); + const triple = (area: number, volume: number, adjusted: number) => [ + num(area), + v(volume), + v(adjusted), + ]; const values = [ num(row.distance_m), - num(row.cut_soil_area_m2), - num(row.cut_soil_volume_m3), - num(row.cut_soil_adjusted_m3), - ...ROCKS.flatMap((kind) => rockCells(row, kind)), - num(row.ditch_soil_area_m2), - num(row.ditch_soil_volume_m3), - num(row.ditch_soil_adjusted_m3), - num(row.ditch_rock_area_m2), - num(row.ditch_rock_volume_m3), - num(row.ditch_rock_adjusted_m3), - num(row.adjusted_total_m3), + ...triple(row.cut_soil_area_m2, row.cut_soil_volume_m3, row.cut_soil_adjusted_m3), + ...triple(row.cut_rock_area_m2, row.cut_rock_volume_m3, row.cut_rock_adjusted_m3), + ...triple(row.ditch_soil_area_m2, row.ditch_soil_volume_m3, row.ditch_soil_adjusted_m3), + ...triple(row.ditch_rock_area_m2, row.ditch_rock_volume_m3, row.ditch_rock_adjusted_m3), + v(row.adjusted_total_m3), num(row.fill_area_m2), - num(row.fill_volume_m3), - num(row.diverted_m3), - num(row.balance_m3), - num(row.cumulative_m3), + v(row.fill_volume_m3), + v(row.diverted_m3), + v(row.balance_m3), + v(row.cumulative_m3), + ...FACE_KEYS.flatMap((key) => { + const face = row.faces?.[key]; + return [num(face?.length_m), first ? DASH : num(face?.area_m2)]; + }), ]; - tr.append(station, ...values.map((v) => cell("td", v))); + const cells = values.map((text) => cell("td", text)); + // 암석 칸 — 어느 갈래(환산계수)로 셌는지 마우스를 올려 봄 + if (row.cut_rock_area_m2 > 0) cells[4].title = ROCK_KIND_LABEL[row.cut_rock_kind] ?? ""; + tr.append(station, ...cells); return tr; } -/** 합계 줄 — 단면적은 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ +/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ function totalRow(table: EarthworkTable): HTMLTableRowElement { const t = table.totals; const last = table.rows[table.rows.length - 1]; + const triple = (key: string) => ["", num(t[`${key}_volume_m3`]), num(t[`${key}_adjusted_m3`])]; const values = [ num(t.distance_m), - "", - num(t.cut_soil_volume_m3), - num(t.cut_soil_adjusted_m3), - ...ROCKS.flatMap((kind) => [ - "", - num(t[`cut_${kind}_volume_m3`]), - num(t[`cut_${kind}_adjusted_m3`]), - ]), - "", - num(t.ditch_soil_volume_m3), - num(t.ditch_soil_adjusted_m3), - "", - num(t.ditch_rock_volume_m3), - num(t.ditch_rock_adjusted_m3), + ...triple("cut_soil"), + ...triple("cut_rock"), + ...triple("ditch_soil"), + ...triple("ditch_rock"), num(t.adjusted_total_m3), "", num(t.fill_volume_m3), num(t.diverted_m3), num(t.balance_m3), num(last?.cumulative_m3), + ...FACE_KEYS.flatMap((key) => ["", num(t[`${key}_area_m2`])]), ]; const tr = document.createElement("tr"); tr.className = "b08-earthwork__total"; @@ -168,8 +239,38 @@ function totalRow(table: EarthworkTable): HTMLTableRowElement { return tr; } +function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { + if (!table.rows.length) { + host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); + return; + } + const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted); + const info = note( + `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + + `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + + `발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` + + `단위 m · ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`, + ); + const tbody = document.createElement("tbody"); + tbody.append(...table.rows.map((row, i) => bodyRow(row, i === 0, interval))); + const tfoot = document.createElement("tfoot"); + tfoot.append(totalRow(table)); + const card = tableCard([info], "b08-earthwork__table", [head(), tbody, tfoot]); + card.querySelector("table")!.dataset.b08Earthwork = "table"; + host.replaceChildren(card); +} + +const SHEETS = [ + { id: "summary", label: "토공집계표" }, + { id: "sheet", label: "토적표" }, + { id: "haul", label: "평균운반거리" }, + { id: "free", label: "종방향 무대운반거리" }, +] as const; + +type SheetId = (typeof SHEETS)[number]["id"]; + export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { - host.replaceChildren(note("토적표를 만드는 중…")); + host.replaceChildren(note("토공 수량을 만드는 중…")); let table: EarthworkTable; let interval = 20; try { @@ -186,29 +287,34 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro table = body; interval = gap; } catch (error) { - host.replaceChildren(note(`토적표를 불러오지 못했습니다 — ${String(error)}`)); + host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`)); return; } - if (!table.rows.length) { - host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); - return; - } - const c = (kind: string): string => num(table.conversion_factors[kind]?.compacted); - const info = note( - `평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` + - `측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` + - `발파암 ${c("blasting_rock")} · 단위 ㎡ · ㎥ · * = 사유 있음(마우스를 올림)`, - ); - const tbody = document.createElement("tbody"); - tbody.append(...table.rows.map((row) => bodyRow(row, interval))); - const tfoot = document.createElement("tfoot"); - tfoot.append(totalRow(table)); - const el = document.createElement("table"); - el.className = "ui-general-block__table b08-summary__table b08-earthwork__table"; - el.dataset.b08Earthwork = "table"; - el.append(head(), tbody, tfoot); - const wrap = document.createElement("div"); - wrap.className = "ui-general-block__table-wrap b08-summary__wrap"; - wrap.append(el); - host.replaceChildren(info, wrap); + + const tabs = document.createElement("div"); + tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; + tabs.setAttribute("role", "tablist"); + const view = document.createElement("div"); + const select = (picked: SheetId): void => { + tabs.replaceChildren( + ...SHEETS.map(({ id, label }) => { + const button = createButton({ + label, + variant: id === picked ? "filled" : "ghost", + size: "sm", + onClick: () => id !== picked && select(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === picked)); + button.dataset.b08EarthworkSheet = id; + return button; + }), + ); + if (picked === "sheet") renderSheet(view, table, interval); + else if (picked === "summary") renderEarthworkSummary(view, table); + else if (picked === "haul") renderHaulDistance(view, table); + else renderFreeHaul(view, table); + }; + host.replaceChildren(tabs, view); + select("summary"); } diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts new file mode 100644 index 000000000..2a04e3521 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -0,0 +1,133 @@ +/* ============================================================================= + * B08_Quantity_UI_Earthwork_Sheets.ts + * 토공 보조 탭 셋(PLAN 69-2) — 토공집계표 · 평균운반거리 · 종방향 무대운반거리. + * + * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). + * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. + * ========================================================================== */ + +import { + cell, + note, + tableCard, + type EarthworkTable, + type HaulList, +} from "./B08_Quantity_UI_Earthwork"; + +const fmt = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) + ? "—" + : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + +function row(cells: HTMLElement[], className = ""): HTMLTableRowElement { + const tr = document.createElement("tr"); + if (className) tr.className = className; + tr.append(...cells); + return tr; +} + +function station(text: string, span?: { row?: number; col?: number }): HTMLElement { + const th = cell("th", text, span); + th.className = "b08-summary__station"; + return th; +} + +/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 · 같은 구분은 한 칸으로 묶음 */ +export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { + const thead = document.createElement("thead"); + thead.append( + row([station("구분"), ...["공종", "규격", "단위", "계", "비고"].map((t) => cell("th", t))]), + ); + const tbody = document.createElement("tbody"); + const rows = table.summary; + rows.forEach((item, i) => { + const cells: HTMLElement[] = []; + if (rows[i - 1]?.group !== item.group) { + let span = 1; + while (rows[i + span]?.group === item.group) span += 1; + cells.push(station(item.group, { row: span })); + } + const value = cell("td", fmt(item.value)); + value.className = "b08-earthwork__num"; + cells.push(cell("td", item.kind), cell("td", item.spec), cell("td", item.unit), value); + cells.push(cell("td", item.note)); + tbody.append(row(cells)); + }); + const info = note( + "토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " + + "리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(— = 아직 값 없음)", + ); + host.replaceChildren(tableCard([info], "b08-earthwork__sheet", [thead, tbody])); +} + +function haulNote(table: EarthworkTable): HTMLElement[] { + return table.haul_saved + ? [] + : [note("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; +} + +/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ +function haulSheet( + host: HTMLElement, + table: EarthworkTable, + groups: Array<[string, HaulList]>, + info: string, +): void { + const thead = document.createElement("thead"); + thead.append( + row([station("번호", { row: 2 }), ...groups.map(([label]) => cell("th", label, { col: 3 }))]), + row(groups.flatMap(() => ["토량(Q)", "거리(L)", "Q×L"].map((t) => cell("th", t)))), + ); + const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); + const tbody = document.createElement("tbody"); + for (let i = 0; i < count; i += 1) { + const cells = groups.flatMap(([, list]) => { + const item = list.rows[i]; + return item ? [fmt(item.q), fmt(item.l), fmt(item.ql)] : ["", "", ""]; + }); + tbody.append(row([station(String(i + 1)), ...cells.map((t) => cell("td", t))])); + } + const tfoot = document.createElement("tfoot"); + tfoot.append( + row( + [ + station("합계"), + ...groups.flatMap(([, list]) => [fmt(list.q), "", fmt(list.ql)].map((t) => cell("td", t))), + ], + "b08-earthwork__total", + ), + ); + const averages = groups.map(([label, list], i) => + note( + `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, + ), + ); + const card = tableCard([note(info), ...haulNote(table)], "b08-earthwork__haul", [ + thead, + tbody, + tfoot, + ]); + card.append(...averages); + host.replaceChildren(card); +} + +export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { + haulSheet( + host, + table, + [ + ["덤프트럭", table.haul.dump_truck], + ["불도우저", table.haul.dozer], + ], + "유토곡선 배분의 덤프 · 도저 띠와 떨어진 구간 운반 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + ); +} + +export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { + haulSheet( + host, + table, + [["종방향무대", table.haul.free_haul]], + "유토곡선 배분의 무대(20 m 안) 띠 · 평균 = ΣQ×L ÷ ΣQ (소수 둘째 자리) · 단위 ㎥ · m", + ); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 6a4a47611..d9991c177 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -68,11 +68,18 @@ color: var(--color-danger); } -/* 3탭 토적표 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ -.b08-earthwork__table td { +/* 토공 탭 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ +.b08-earthwork__table td, +.b08-earthwork__haul td, +.b08-earthwork__sheet .b08-earthwork__num { text-align: right; } +/* 토적표 머리 셋째 줄 — 둘째 줄 아래에 붙음 */ +.b08-summary__table thead tr + tr + tr th { + top: calc(2 * (1lh + 2 * var(--spacing-4, 4px) + 1px)); +} + .b08-earthwork__total th, .b08-earthwork__total td { position: sticky; diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py new file mode 100644 index 000000000..d86e88575 --- /dev/null +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -0,0 +1,200 @@ +"""B08 토공 탭 (PLAN 69-2 · 69-3) — 토적표 견본 칸 · 면 계열 · 운반 시트 · 토공집계표 식. + +정답지 ① 사용자 견본 두 행(120 · 120+18) ② 거창 실무 워크북 토적표 131행(70) · +③ 같은 워크북 토공집계표 · 평균운반거리 · 종방향무대운반거리 시트 식. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Earthwork import ( # noqa: E402 + FACE_KEYS, + StationArea, + build_rows, + build_table, +) +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( # noqa: E402 + excel_round, + haul_lists, + summary_rows, +) + +# 견본 보정량 36.2 / 41.1 · 55.2 / 48.0 에서 거꾸로 — 토사 0.88 · 연암 1.15 +SAMPLE_FACTORS = { + "soil": {"compacted": 0.88}, + "ripping_rock": {"compacted": 1.15}, + "blasting_rock": {"compacted": 1.3}, +} +SAMPLE_FACES = ( + # grading_fill · grading_cut · tree_fill · tree_cut · grubbing · road + (4.4, 1.3, 4.7, 4.6, 1.3, 4.0), + (6.9, 0.4, 10.1, 4.6, 4.8, 5.7), +) + + +def _faces(values: tuple[float, ...]) -> dict[str, float | None]: + return dict(zip(FACE_KEYS, (*values, None))) + + +def 견본_두_측점() -> list[StationArea]: + return [ + StationArea( + chainage_m=120.0, + cut_soil_area_m2=3.28, + cut_rock_area_m2=1.61, + ditch_soil_area_m2=0.0, + ditch_rock_area_m2=0.18, + fill_area_m2=1.9, + cut_rock_kind="ripping_rock", + faces=_faces(SAMPLE_FACES[0]), + ), + StationArea( + chainage_m=138.0, + cut_soil_area_m2=1.29, + cut_rock_area_m2=3.72, + ditch_soil_area_m2=0.0, + ditch_rock_area_m2=0.0, + fill_area_m2=19.13, + cut_rock_kind="ripping_rock", + faces=_faces(SAMPLE_FACES[1]), + ), + ] + + +def test_견본_120_18_행() -> None: + first, row = build_rows(견본_두_측점(), SAMPLE_FACTORS) + assert row.distance_m == pytest.approx(18) + assert round(row.cut_soil_volume_m3, 1) == 41.1 + assert round(row.cut_soil_adjusted_m3, 1) == 36.2 + assert round(row.cut_rock_volume_m3, 1) == 48.0 + assert round(row.cut_rock_adjusted_m3, 1) == 55.2 + assert round(row.ditch_rock_volume_m3, 1) == 1.6 + assert round(row.ditch_rock_adjusted_m3, 1) == 1.9 + assert round(row.adjusted_total_m3, 1) == 91.4 # 절토 보정량만 + assert round(row.fill_volume_m3, 1) == 189.3 + assert round(row.diverted_m3, 1) == 91.4 + assert round(row.balance_m3, 1) == -97.9 + assert round(row.cumulative_m3, 1) == -97.9 + areas = [round(row.faces[key]["area_m2"], 1) for key in FACE_KEYS[:6]] + assert areas == [101.7, 15.3, 133.2, 82.8, 54.9, 87.3] + + +def test_첫_측점은_면_거리만() -> None: + first = build_rows(견본_두_측점(), SAMPLE_FACTORS)[0] + assert [first.faces[key]["length_m"] for key in FACE_KEYS[:6]] == list(SAMPLE_FACES[0]) + assert all(first.faces[key]["area_m2"] is None for key in FACE_KEYS) + + +def test_거창_토적표_131행() -> None: + """워크북 130(+10) → 131(70) — 절토 · 측구 · 성토 · 지장목 성토 면적 같은 입력 다시 셈.""" + stations = [ + StationArea( + chainage_m=0.0, + cut_soil_area_m2=6.72, + ditch_soil_area_m2=0.18, + ditch_rock_area_m2=0.0, + fill_area_m2=0.19, + faces=_faces((0.1, 4.7, 2.4, 6.3, 0, 0)), + ), + StationArea( + chainage_m=10.0, + cut_soil_area_m2=3.15, + ditch_soil_area_m2=0.18, + ditch_rock_area_m2=0.0, + fill_area_m2=15.02, + faces=_faces((8.0, 3.8, 11.3, 4.5, 0, 0)), + ), + ] + row = build_rows(stations)[1] + assert row.cut_soil_volume_m3 == pytest.approx(49.35) # E131 + assert row.cut_soil_adjusted_m3 == pytest.approx(44.415) # F131 44.41 + assert row.ditch_soil_volume_m3 == pytest.approx(1.8) # K131 + assert row.fill_volume_m3 == pytest.approx(76.05) # R131 + assert row.faces["tree_removal_fill"]["area_m2"] == pytest.approx(68.5) # AG131 + # 워크북 면적은 거리 소수 둘째 자리로 셈 — 표기 거리로는 ±0.5 안 + assert row.faces["grading_fill"]["area_m2"] == pytest.approx(40.7, abs=0.5) # Y131 + assert row.faces["tree_removal_cut"]["area_m2"] == pytest.approx(54.4, abs=0.5) # AI131 + + +def test_면_거리는_B07_횡단도_수량과_같음() -> None: + design = { + "design_line": [ + {"offset_m": o, "elevation_m": z} + for o, z in ((-8, 102), (-2, 100), (2, 100), (6, 102.5)) + ], + "road_edges": {"left": {"offset_m": 2}, "right": {"offset_m": -2}}, + "cut_slope_ratio": 0.8, + "fill_slope_ratio": 1.5, + "roadbed_width_m": 4.0, + } + area = StationArea.from_design(120.0, design) + cells = derived_cells(120.0, design) + for key in ("grading_fill", "grading_cut", "tree_removal_fill", "tree_removal_cut"): + assert area.faces[key] == cells.get(key) + assert area.faces["road_surface"] == 4.0 + assert area.faces["grubbing"] is None # B07 에 값 없음 + + +def test_엑셀_ROUND() -> None: + assert excel_round(2.5) == 3 + assert excel_round(-97.5) == -98 + assert excel_round(1.005, 2) == 1.01 + + +HAUL_PLAN = { + "blocks": [ + { + "bands": [ + {"equipment": "free_haul", "volume_m3": 50, "haul_distance_m": 10}, + {"equipment": "dozer", "volume_m3": 20, "haul_distance_m": 40}, + {"equipment": "dump_truck", "volume_m3": 68, "haul_distance_m": 35}, + ] + }, + {"bands": [{"equipment": "free_haul", "volume_m3": 32, "haul_distance_m": 13}]}, + ], + "transfers": [{"equipment": "dump_truck", "volume_m3": 2, "haul_distance_m": 61}], +} + + +def test_운반_시트() -> None: + haul = haul_lists(HAUL_PLAN) + free = haul["free_haul"] + assert [(r["q"], r["l"]) for r in free["rows"]] == [(50, 10), (32, 13)] + assert free["q"] == 82 and free["ql"] == 50 * 10 + 32 * 13 + assert free["average_m"] == excel_round((500 + 416) / 82, 2) + dump = haul["dump_truck"] + assert dump["q"] == 70 and dump["ql"] == 68 * 35 + 2 * 61 # 덤프 띠 + 떨어진 구간 운반 + assert haul["dozer"]["average_m"] == 40 + assert haul_lists(None)["dump_truck"] == {"rows": [], "q": 0, "ql": 0, "average_m": None} + + +def test_토공집계표_식() -> None: + table = build_table(견본_두_측점(), SAMPLE_FACTORS) + t = table["totals"] + rows = summary_rows(t, haul_lists(HAUL_PLAN)) + value = {(r["group"], r["kind"], r["spec"]): r["value"] for r in rows} + assert value[("흙깍기", "토사", "기계(굴삭기)")] == excel_round(t["cut_soil_volume_m3"]) + assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == 48 + assert value[("흙깍기", "경암", "발파 + 굴삭기")] == 0 + assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == 2 + assert value[("보정량계", "", "")] == 91 + assert value[("성토", "", "")] == 189 + assert value[("종방향유용토", "", "백호우")] == 82 + assert value[("토사운반", "", "BH+DT+BH")] == 70 + assert value[("토사운반", "", "19 TON B/D")] == 20 + assert value[("유용토 소계", "", "")] == pytest.approx(t["diverted_m3"] + 82) + assert value[("잔토처리", "", "백호우")] == -98 + assert value[("성토면다짐", "", "")] == excel_round(101.7 * 0.8) + assert value[("초류종자살포", "", "씨드스프레이")] == excel_round(101.7 * 0.5 + 15.3) + tree = excel_round(133.2 * 0.5 + 82.8, 2) + assert value[("지장목제거", "", "")] == excel_round(tree * 0.8) + assert value[("표토제거", "", "")] is None + assert len(rows) == 33 # 시트 C5:C37 diff --git a/resources/tester/test_b08_earthwork_table.py b/resources/tester/test_b08_earthwork_table.py index 236954b7e..2d14dd0bc 100644 --- a/resources/tester/test_b08_earthwork_table.py +++ b/resources/tester/test_b08_earthwork_table.py @@ -70,15 +70,14 @@ def test_측구_단면_0_18이_체적으로() -> None: assert rows[2].ditch_soil_adjusted_m3 == pytest.approx(1.62) -def test_보정량계는_네_갈래_합() -> None: +def test_보정량계는_절토_보정량만() -> None: + """PLAN 69 정함 — 측구터파기 보정량은 칸에만(거창 BOM 15열 17.91 = 16.29 + 1.62 와 다름).""" row = build_rows(거창_앞_네_측점())[2] assert row.adjusted_total_m3 == pytest.approx( - row.cut_soil_adjusted_m3 - + row.cut_rock_adjusted_m3 - + row.ditch_soil_adjusted_m3 - + row.ditch_rock_adjusted_m3 + row.cut_soil_adjusted_m3 + row.cut_rock_adjusted_m3 ) - assert row.adjusted_total_m3 == pytest.approx(16.29 + 1.62) # BOM 15열 17.91 + assert row.adjusted_total_m3 == pytest.approx(16.29) + assert row.ditch_soil_adjusted_m3 == pytest.approx(1.62) def test_누가토량은_차인토량의_누계() -> None: diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index aebdfa3cb..367a774db 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -595,7 +595,7 @@ export const ui_locales_b2 = { "수량 단계 확정에 실패했습니다.", "Failed to confirm the quantity stage.", ], - B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], + B08_Quantity_Tab_Earthwork: ["토공", "Earthwork"], B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], B08_Quantity_Grid_Empty: [ "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.",