feat(b08): 3탭 토적표 — 옛 평균단면적법 엔진 복사 · 서버 읽기 전용 길 · 측점별 표 + 합계 줄(PLAN 61-3)

- 엔진 = old_code B08_Quantity_Engine_EarthworkTable.py 복사(B08_Quantity_Engine_Earthwork.py) · 계산식 그대로
- 고친 곳 = 입력부(암 종류 없으면 암 지반 유형) · 줄 cut_rock_kind · 합계 리핑암 · 발파암 칸
- 길 = GET /quantity/earthwork — B06 횡단 설계 저장 단면적(확정 노선 · 없으면 최신) · 환산계수 = 유토곡선과 같은 mixed_conversion_factors · 아무것도 안 씀
- 화면 = 측점 · 거리 · 절토(토사 · 리핑암 · 발파암) · 측구터파기(토사 · 암) 각 단면적 · 입적 · 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 합계 줄
- 옛 시험 test_b08_earthwork_table.py 는 가져오는 경로만 새 엔진으로 · 새 시험 test_61_3_b08_earthwork_kinds.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-06 18:26:17 +09:00
co-authored by Claude Opus 5.5
parent 62b522ffa9
commit aefcb23dd2
8 changed files with 628 additions and 1 deletions
@@ -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__}
@@ -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}
+214
View File
@@ -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<string, number>;
conversion_factors: Record<string, { compacted: number }>;
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<void> {
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);
}
+23
View File
@@ -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;
}
+2
View File
@@ -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");
+2
View File
@@ -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)]
@@ -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)
+1 -1
View File
@@ -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,