diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork.py b/B08_Quantity/B08_Quantity_Engine_Earthwork.py new file mode 100644 index 000000000..aa654e5a3 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork.py @@ -0,0 +1,288 @@ +"""토적표 — 측점별 단면적을 평균단면적법으로 체적화한다 (B08 일감 2 · PLAN 8-4b). + +옛 엔진 복사(PLAN 61-3) — `old_code/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py` 를 그대로 +옮김 · 계산식 그대로. 고친 곳은 입력부(암 종류가 없으면 지반 유형) · 암 칸을 리핑암 · 발파암으로 +가를 수 있게 줄에 `cut_rock_kind` 와 합계의 종류별 칸을 더한 것뿐. + +무엇을 만드나 + 실무 토적표의 열 구성 그대로다. 거창 실무 워크북 `토적표` 시트와 오솔길 `1.BOM` 36열이 + 서로 1:1 로 맞물리는 것을 확인해 열 이름을 그대로 옮겼다(PLAN 8-4b). + + 측점 · 거리 · 절토[토사·암 각 (단면적·입적·보정량)] · 측구터파기[토사·암 각 3칸] + · 보정량계 · 성토[단면적·입적] · 유용토 · 차인토량 · 누가토량 + + 사면 계열(층따기·면고르기·법면보호공·지장목제거)은 사면길이가 아직 없어 일감 3에서 붙인다. + +평균단면적법 (신규 문서 5장 「다. 공사수량의 산출」) + 체적 = (앞 측점 단면적 + 현 측점 단면적) ÷ 2 × 두 측점 사이 거리. + 첫 측점은 앞이 없으므로 체적이 없다(거창 실무 토적표도 첫 행 체적이 비어 있다). + +보정량 = 체적 × 토량환산계수(다짐) + 절취한 흙이 다져지면 줄거나 부푼다. 성토에 쓸 수 있는 양으로 환산한 것이 보정량이다. + 계수의 유일한 정의처는 `config.config_system_design.EARTHWORK_CONVERSION_FACTORS` 이며 + 여기서 값을 다시 적지 않는다. 프로젝트가 고른 값이 있으면 라우터가 + `earthwork_conversion_factors(settings)` 로 풀어 `factors` 로 넘긴다 — 기본값은 그대로다. + +측구터파기 토사·암 — 설계가 가른 값을 그대로 읽는다 + B06 이 지반 유형 + 암반 경계선으로 이미 갈라 냈다(`ditch_soil_area_m2`· + `ditch_rock_area_m2` · 사유 `ditch_split_basis`). 여기서 다시 나누지 않는다 — + 나누면 같은 측구가 횡단도와 토적표에서 다른 숫자로 선다. 가름이 붙기 전 저장분만 + 절토 면적비 안분으로 떨어지고, 그 줄에는 사유가 남는다(`_split_ditch`). + +⚠ 숫자는 자르지 않는다 (PLAN 8-16) + 품셈 1-2-2 의 소수 자리는 **표기 규칙**이다. 계산은 전정밀로 두고 화면·출력에서만 + 반올림한다. 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 섞지 말 것. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS + +# 절토 암을 어느 환산계수로 볼지 — 측점의 `cut_rock_kind` 를 그대로 쓴다. +# 값이 없으면 리핑암으로 본다(발파암보다 보수적으로 적은 쪽). +_DEFAULT_ROCK_KIND = "ripping_rock" + +# 측구 가름 근거 — 설계가 가른 값이 없어 절토 면적비로 안분했을 때만 붙는다. +# B06 가 내는 네 갈래(`no_ditch`·`soil_ground`·`rock_boundary`·`rock_ground_no_boundary`)와 +# 섞이지 않게 이름을 따로 둔다. 이 값이 표에 보이면 **설계가 가른 것이 아니다.** +_FALLBACK_BASIS = "cut_area_ratio_fallback" + +#: 폴백을 탄 측점 줄에 남기는 사유. 숫자만 봐서는 안분인지 설계값인지 알 수 없다. +_FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적비로 안분함" + +#: 절토 암 칸 갈래 — 화면 「리핑암 · 발파암」 열 +ROCK_KINDS = ("ripping_rock", "blasting_rock") + + +#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`. +Factors = dict[str, dict[str, float]] + + +def _factor(kind: str, factors: Factors) -> float: + """지반유형 → 다짐 환산계수. 모르는 유형이면 토사로 본다.""" + entry = factors.get(kind) or factors["soil"] + return float(entry["compacted"]) + + +@dataclass(slots=True) +class StationArea: + """토적표 한 줄이 필요로 하는 측점 값. B06 설계 결과에서 그대로 옮겨 담는다.""" + + chainage_m: float + cut_soil_area_m2: float = 0.0 + cut_rock_area_m2: float = 0.0 + fill_area_m2: float = 0.0 + ditch_area_m2: float = 0.0 + # 측구 가름은 **설계가 낸 값**이다(B06 `ditch_soil_area_m2`·`ditch_rock_area_m2`). + # `None` 은 「설계가 안 냈다」는 뜻이고 0.0 과 다르다 — 0.0 은 설계가 낸 「없음」이다. + ditch_soil_area_m2: float | None = None + ditch_rock_area_m2: float | None = None + ditch_split_basis: str | None = None + cut_rock_kind: str | None = None + + @classmethod + def from_design(cls, chainage_m: float, design: dict[str, Any]) -> "StationArea": + def num(key: str) -> float: + value = design.get(key) + return float(value) if isinstance(value, (int, float)) else 0.0 + + def optional(key: str) -> float | None: + value = design.get(key) + return float(value) if isinstance(value, (int, float)) else None + + return cls( + chainage_m=float(chainage_m), + 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"), + ditch_area_m2=num("ditch_area_m2"), + ditch_soil_area_m2=optional("ditch_soil_area_m2"), + ditch_rock_area_m2=optional("ditch_rock_area_m2"), + ditch_split_basis=design.get("ditch_split_basis") or None, + # 지금 B06 은 `cut_rock_kind` 를 냄 — 옛 저장분은 암 지반 유형으로(유토곡선과 같은 규칙) + cut_rock_kind=design.get("cut_rock_kind") + or (design.get("ground_type") if design.get("ground_type") in ROCK_KINDS else None), + ) + + +@dataclass(slots=True) +class EarthworkRow: + """토적표 한 줄. 열 이름은 실무 토적표(PLAN 8-4b)를 따른다.""" + + chainage_m: float + distance_m: float = 0.0 + cut_soil_area_m2: float = 0.0 + cut_soil_volume_m3: float = 0.0 + cut_soil_adjusted_m3: float = 0.0 + cut_rock_area_m2: float = 0.0 + cut_rock_volume_m3: float = 0.0 + cut_rock_adjusted_m3: float = 0.0 + ditch_soil_area_m2: float = 0.0 + ditch_soil_volume_m3: float = 0.0 + ditch_soil_adjusted_m3: float = 0.0 + ditch_rock_area_m2: float = 0.0 + ditch_rock_volume_m3: float = 0.0 + ditch_rock_adjusted_m3: float = 0.0 + adjusted_total_m3: float = 0.0 + fill_area_m2: float = 0.0 + fill_volume_m3: float = 0.0 + diverted_m3: float = 0.0 + balance_m3: float = 0.0 + cumulative_m3: float = 0.0 + # 측구를 무슨 근거로 갈랐나 — 설계가 낸 사유를 그대로 싣는다(빈 문자열은 사유 없음). + ditch_split_basis: str = "" + # 절토 암 칸이 어느 갈래(환산계수)로 셌나 — 화면이 리핑암 · 발파암 열로 가름 + cut_rock_kind: str = _DEFAULT_ROCK_KIND + notes: list[str] = field(default_factory=list) + + +def _split_ditch(area: StationArea) -> tuple[float, float, str]: + """측구터파기 단면적을 토사·암으로 가른다 — **설계가 가른 값을 그대로 읽는다.** + + 가름의 주인은 횡단 설계다. B06 이 절토 분리와 같은 근거(지반 유형 + 암반 경계선)로 + `ditch_soil_area_m2`·`ditch_rock_area_m2` 를 내고 사유를 `ditch_split_basis` 로 + 함께 낸다. 토적표가 여기서 다시 나누면 **같은 측구가 횡단도와 토적표에서 다른 숫자로 + 선다** — 그 어긋남을 없애는 자리다. + + 못 가른 측점(암 지반인데 암반 경계선이 없음)은 설계가 **전량 암 + 사유**로 내므로 + 여기서 손보지 않는다. + + ⚠ 폴백은 **설계값이 아예 없을 때만** — 가름이 붙기 전 저장분이다. 그때만 그 측점의 + 절토 토사:암 면적비로 안분하고, 근거를 `cut_area_ratio_fallback` 으로 남겨 + 「설계가 가른 것이 아니다」가 표에 드러나게 한다. + """ + if area.ditch_soil_area_m2 is not None or area.ditch_rock_area_m2 is not None: + return ( + float(area.ditch_soil_area_m2 or 0.0), + float(area.ditch_rock_area_m2 or 0.0), + area.ditch_split_basis or "", + ) + + ditch = area.ditch_area_m2 + if ditch <= 0: + return 0.0, 0.0, "no_ditch" + soil, rock = area.cut_soil_area_m2, area.cut_rock_area_m2 + total = soil + rock + if total <= 0: + return ditch, 0.0, _FALLBACK_BASIS # 절토가 없으면 토사로 본다. + return ditch * soil / total, ditch * rock / total, _FALLBACK_BASIS + + +def build_rows( + stations: Iterable[StationArea], factors: Factors | None = None +) -> list[EarthworkRow]: + """측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다. + + `factors` 는 프로젝트가 고른 토량환산계수다(`earthwork_conversion_factors`). + 안 주면 정본 기본값이 선다 — 설정을 안 읽는 자리(시험·되짚기)를 위한 것이다. + """ + factors = factors or EARTHWORK_CONVERSION_FACTORS + ordered = sorted(stations, key=lambda s: s.chainage_m) + rows: list[EarthworkRow] = [] + previous: StationArea | None = None + previous_ditch: tuple[float, float] = (0.0, 0.0) + cumulative = 0.0 + + for station in ordered: + ditch_soil, ditch_rock, ditch_basis = _split_ditch(station) + soil_factor = _factor("soil", factors) + rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND, factors) + row = EarthworkRow( + chainage_m=station.chainage_m, + cut_soil_area_m2=station.cut_soil_area_m2, + cut_rock_area_m2=station.cut_rock_area_m2, + ditch_soil_area_m2=ditch_soil, + ditch_rock_area_m2=ditch_rock, + fill_area_m2=station.fill_area_m2, + ditch_split_basis=ditch_basis, + cut_rock_kind=station.cut_rock_kind or _DEFAULT_ROCK_KIND, + ) + if ditch_basis == _FALLBACK_BASIS: + row.notes.append(_FALLBACK_NOTE) + if previous is not None: + distance = station.chainage_m - previous.chainage_m + row.distance_m = distance + + def mean_volume(before: float, now: float) -> float: + return (before + now) / 2.0 * distance + + row.cut_soil_volume_m3 = mean_volume( + previous.cut_soil_area_m2, station.cut_soil_area_m2 + ) + row.cut_rock_volume_m3 = mean_volume( + previous.cut_rock_area_m2, station.cut_rock_area_m2 + ) + row.ditch_soil_volume_m3 = mean_volume(previous_ditch[0], ditch_soil) + row.ditch_rock_volume_m3 = mean_volume(previous_ditch[1], ditch_rock) + row.fill_volume_m3 = mean_volume(previous.fill_area_m2, station.fill_area_m2) + + row.cut_soil_adjusted_m3 = row.cut_soil_volume_m3 * soil_factor + row.cut_rock_adjusted_m3 = row.cut_rock_volume_m3 * rock_factor + 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.diverted_m3 = min(row.adjusted_total_m3, row.fill_volume_m3) + row.balance_m3 = row.adjusted_total_m3 - row.fill_volume_m3 + cumulative += row.balance_m3 + row.cumulative_m3 = cumulative + + rows.append(row) + previous = station + previous_ditch = (ditch_soil, ditch_rock) + return rows + + +def totals(rows: list[EarthworkRow]) -> dict[str, float]: + """합계 행. 단면적은 합이 뜻이 없어 싣지 않는다(실무 토적표도 비워 둔다).""" + keys = ( + "distance_m", + "cut_soil_volume_m3", + "cut_soil_adjusted_m3", + "cut_rock_volume_m3", + "cut_rock_adjusted_m3", + "ditch_soil_volume_m3", + "ditch_soil_adjusted_m3", + "ditch_rock_volume_m3", + "ditch_rock_adjusted_m3", + "adjusted_total_m3", + "fill_volume_m3", + "diverted_m3", + "balance_m3", + ) + out = {key: sum(getattr(row, key) for row in rows) for key in keys} + for kind in ROCK_KINDS: # 암 칸을 갈래별로 — 화면 합계 줄의 리핑암 · 발파암 + 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) + return out + + +def build_table(stations: Iterable[StationArea], factors: Factors | None = None) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16). + + `conversion_factors` 로 **실제로 쓴 계수**를 되싣는다 — 프로젝트가 고른 값이면 + 그것이 나가야 화면이 「무엇으로 셌나」를 그대로 보인다. + """ + factors = factors or EARTHWORK_CONVERSION_FACTORS + rows = build_rows(stations, factors) + return { + "method": "average_end_area", + "conversion_factors": factors, + "rows": [row.__dict__ if not hasattr(row, "__slots__") else _as_dict(row) for row in rows], + "totals": totals(rows), + "station_count": len(rows), + } + + +def _as_dict(row: EarthworkRow) -> dict[str, Any]: + return {name: getattr(row, name) for name in EarthworkRow.__slots__} diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py new file mode 100644 index 000000000..970a74732 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -0,0 +1,43 @@ +"""B08 3탭 토적표 길 (PLAN 61-3) — 읽기만, 아무 파일 · DB 에도 쓰지 않음. + + GET /api/projects/{id}/quantity/earthwork 측점별 횡단 설계 단면적 → 평균단면적법 토적표 + +단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) · +토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, HTTPException + +from B06_Section.B06_Section_Repository import ( + get_confirmed_route_context, + get_cross_section_designs, + get_workflow_route_context, +) +from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table +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 + +router = APIRouter(prefix="/api/projects", tags=["B08 Earthwork"]) + + +@router.get("/{project_id}/quantity/earthwork") +async def earthwork_table(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + async with get_db_pool().acquire() as connection: + context = await get_confirmed_route_context(connection, project_id) + confirmed = context is not None + context = context or await get_workflow_route_context(connection, project_id) + if not context: + raise HTTPException(status_code=404, detail="노선이 없습니다 — 노선 · 횡단 설계부터.") + route_id = int(context["route_id"]) + rows = await get_cross_section_designs(connection, 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} diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts new file mode 100644 index 000000000..73175de72 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -0,0 +1,214 @@ +/* ============================================================================= + * B08_Quantity_UI_Earthwork.ts + * 3탭 토적표(PLAN 61-3) — 줄 = 측점 · 열 = 옛 토적 엔진 출력 칸 그대로 · 맨 아래 합계 줄. + * + * GET …/quantity/earthwork (서버 단독 · 읽기만) — 평균단면적법 · 보정량 = 입적 × 다짐 환산계수 + * 절토 암은 그 측점이 센 갈래(`cut_rock_kind`)대로 리핑암 · 발파암 열에 놓음. + * 표 모양은 1탭 집계표 것(`b08-summary__*`)을 다시 씀. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { stationLabel } from "../common_util/common_util_station"; +import { stationInterval } from "./B08_Quantity_UI_Summary"; + +interface EarthworkRow { + chainage_m: number; + distance_m: number; + cut_soil_area_m2: number; + cut_soil_volume_m3: number; + cut_soil_adjusted_m3: number; + cut_rock_area_m2: number; + cut_rock_volume_m3: number; + cut_rock_adjusted_m3: number; + cut_rock_kind: string; + ditch_soil_area_m2: number; + ditch_soil_volume_m3: number; + ditch_soil_adjusted_m3: number; + ditch_rock_area_m2: number; + ditch_rock_volume_m3: number; + ditch_rock_adjusted_m3: number; + adjusted_total_m3: number; + fill_area_m2: number; + fill_volume_m3: number; + diverted_m3: number; + balance_m3: number; + cumulative_m3: number; + notes: string[]; +} + +interface EarthworkTable { + rows: EarthworkRow[]; + totals: Record; + conversion_factors: Record; + station_count: number; + route_id: number; + confirmed: boolean; +} + +const ROCKS = ["ripping_rock", "blasting_rock"] as const; +const GROUPS: Array<[string, number]> = [ + ["절토 토사", 3], + ["절토 리핑암", 3], + ["절토 발파암", 3], + ["측구터파기 토사", 3], + ["측구터파기 암", 3], +]; +const TRIPLE = ["단면적", "입적", "보정량"]; + +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 }) { + const node = document.createElement(tag); + node.textContent = text; + if (span?.row) node.rowSpan = span.row; + if (span?.col) node.colSpan = span.col; + return node; +} + +function note(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "ui-general-block__empty"; + p.textContent = text; + return p; +} + +function head(): HTMLTableSectionElement { + 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 }), + ); + top.firstElementChild?.classList.add("b08-summary__station"); + const sub = document.createElement("tr"); + for (const _ of GROUPS) sub.append(...TRIPLE.map((t) => cell("th", t))); + sub.append(cell("th", "단면적"), cell("th", "입적")); + const thead = document.createElement("thead"); + thead.append(top, 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 { + const tr = document.createElement("tr"); + tr.dataset.b08Earthwork = String(row.chainage_m); + const station = cell( + "th", + stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), + ); + station.className = "b08-summary__station"; + if (row.notes.length) station.title = row.notes.join("\n"); + 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), + num(row.fill_area_m2), + num(row.fill_volume_m3), + num(row.diverted_m3), + num(row.balance_m3), + num(row.cumulative_m3), + ]; + tr.append(station, ...values.map((v) => cell("td", v))); + return tr; +} + +/** 합계 줄 — 단면적은 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ +function totalRow(table: EarthworkTable): HTMLTableRowElement { + const t = table.totals; + const last = table.rows[table.rows.length - 1]; + 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), + num(t.adjusted_total_m3), + "", + num(t.fill_volume_m3), + num(t.diverted_m3), + num(t.balance_m3), + num(last?.cumulative_m3), + ]; + const tr = document.createElement("tr"); + tr.className = "b08-earthwork__total"; + tr.dataset.b08EarthworkTotal = ""; + const label = cell("th", "합계"); + label.className = "b08-summary__station"; + tr.append(label, ...values.map((v) => cell("td", v))); + return tr; +} + +export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { + host.replaceChildren(note("토적표를 만드는 중…")); + let table: EarthworkTable; + let interval = 20; + try { + const [response, gap] = await Promise.all([ + fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }), + stationInterval(projectId).catch(() => 20), + ]); + const body = (await response.json().catch(() => null)) as + (EarthworkTable & { detail?: unknown }) | null; + if (!response.ok || !body) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + table = body; + interval = gap; + } catch (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); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 6fd399917..6a4a47611 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -67,3 +67,26 @@ .b08-summary__name.is-missing { color: var(--color-danger); } + +/* 3탭 토적표 — 수는 오른쪽 · 합계 줄은 아래에 붙어 굵게 */ +.b08-earthwork__table td { + text-align: right; +} + +.b08-earthwork__total th, +.b08-earthwork__total td { + position: sticky; + /* 칸 테두리는 표(border-collapse) 것이라 붙은 칸과 같이 안 움직임 — 그림자로 선을 긋고 + 아래로 1px 더 내려 뒤 줄이 비치지 않게 */ + bottom: -1px; + z-index: 2; + font-weight: 700; + background: var(--color-surface-raised, var(--color-surface)); + box-shadow: + inset 0 2px 0 var(--color-text-muted), + 0 1px 0 var(--color-surface-raised, var(--color-surface)); +} + +.b08-earthwork__total th { + z-index: 3; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index b12259dc5..d7b03fa65 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -18,6 +18,7 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflo import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; import { renderStructureSheets } from "./B08_Quantity_UI_Drawing"; +import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -45,6 +46,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi return; } if (tab === "sheet" && projectId) return void renderStructureSheets(view, projectId); + if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId); const pending = document.createElement("p"); pending.className = "ui-general-block__empty"; pending.textContent = L("B08_Quantity_Tab_Pending"); diff --git a/main.py b/main.py index bfc974584..b97b6e0a1 100644 --- a/main.py +++ b/main.py @@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router +from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -642,6 +643,7 @@ app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) app.include_router(b08_hydro_router, dependencies=protected_with_company) +app.include_router(b08_earthwork_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] diff --git a/resources/tester/test_61_3_b08_earthwork_kinds.py b/resources/tester/test_61_3_b08_earthwork_kinds.py new file mode 100644 index 000000000..eafc6e6eb --- /dev/null +++ b/resources/tester/test_61_3_b08_earthwork_kinds.py @@ -0,0 +1,55 @@ +"""B08 3탭 토적표 — 옛 엔진 복사본의 고친 곳만 (PLAN 61-3). + +계산식은 옛 시험(test_b08_earthwork_table.py · 경로만 새 엔진으로)이 잼. 여기서는 +① 암 종류가 없으면 암 지반 유형 · ② 줄의 `cut_rock_kind` · ③ 합계의 리핑암 · 발파암 칸. +""" + +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 B08_Quantity.B08_Quantity_Engine_Earthwork import ( # noqa: E402 + StationArea, + build_table, +) + +FACTORS = { + "soil": {"compacted": 0.9}, + "ripping_rock": {"compacted": 1.15}, + "blasting_rock": {"compacted": 1.3}, +} + + +def test_암_종류는_설계값_없으면_지반유형() -> None: + old = StationArea.from_design(0, {"cut_rock_area_m2": 2.0, "ground_type": "blasting_rock"}) + assert old.cut_rock_kind == "blasting_rock" + soil = StationArea.from_design(0, {"ground_type": "soil"}) + assert soil.cut_rock_kind is None + now = StationArea.from_design( + 0, {"ground_type": "blasting_rock", "cut_rock_kind": "ripping_rock"} + ) + assert now.cut_rock_kind == "ripping_rock" + + +def test_암_칸은_갈래별_합계() -> None: + stations = [ + StationArea(chainage_m=0, cut_rock_area_m2=2.0, cut_rock_kind="ripping_rock"), + StationArea(chainage_m=20, cut_rock_area_m2=4.0, cut_rock_kind="ripping_rock"), + StationArea(chainage_m=40, cut_rock_area_m2=6.0, cut_rock_kind="blasting_rock"), + ] + table = build_table(stations, FACTORS) + kinds = [row["cut_rock_kind"] for row in table["rows"]] + assert kinds == ["ripping_rock", "ripping_rock", "blasting_rock"] + t = table["totals"] + # 0~20: (2+4)/2×20 = 60 리핑 · 20~40: (4+6)/2×20 = 100 발파(현 측점 갈래 — 옛 엔진 규칙) + assert t["cut_ripping_rock_volume_m3"] == pytest.approx(60) + assert t["cut_blasting_rock_volume_m3"] == pytest.approx(100) + assert t["cut_ripping_rock_adjusted_m3"] == pytest.approx(60 * 1.15) + assert t["cut_blasting_rock_adjusted_m3"] == pytest.approx(100 * 1.3) + assert t["cut_rock_volume_m3"] == pytest.approx(160) diff --git a/resources/tester/test_b08_earthwork_table.py b/resources/tester/test_b08_earthwork_table.py index 1800131c3..236954b7e 100644 --- a/resources/tester/test_b08_earthwork_table.py +++ b/resources/tester/test_b08_earthwork_table.py @@ -14,7 +14,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Earthwork import ( # noqa: E402 StationArea, build_rows, build_table,