Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-06 23:20:21 +09:00
10 changed files with 707 additions and 177 deletions
+50 -7
View File
@@ -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
@@ -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),
]
+19 -3
View File
@@ -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,
}
+202 -135
View File
@@ -1,15 +1,26 @@
/* =============================================================================
* 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 한 번(서버 단독 · 읽기만) — 네 장이 같은 응답을 나눠 그림.
* 토적표 = 줄 측점 · 칸 = 사용자 견본(절토 토사 · 암석 · 측구터파기 토사 · 암석 각 단면적 · 입적 ·
* 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각
* 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—).
* 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, createCard } from "@ui/ui_template_elements";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
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,142 +45,193 @@ interface EarthworkRow {
balance_m3: number;
cumulative_m3: number;
notes: string[];
faces: Record<string, Face>;
}
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<string, number>;
conversion_factors: Record<string, { compacted: number }>;
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 TRIPLES: Array<[string[], string]> = [
[["절토", "토사"], "cut_soil"],
[["절토", "암석"], "cut_rock"],
[["측구터파기", "토사"], "ditch_soil"],
[["측구터파기", "암석"], "ditch_rock"],
];
/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */
const FACES: Array<[string[], string]> = [
[["면고르기", "성토면"], "grading_fill"],
[["면고르기", "절토면"], "grading_cut"],
[["지장목제거", "성토"], "tree_removal_fill"],
[["지장목제거", "절토"], "tree_removal_cut"],
[["제근"], "grubbing"],
[["노면정리"], "road_surface"],
];
const TRIPLE = ["단면적", "입적", "보정량"];
const num = (value: number | undefined | null): string =>
value == null || !Number.isFinite(value) ? "" : value.toFixed(2);
const n2 = (key: string, label: string, group?: string[]): DataColumn => ({
key,
label,
group,
kind: "number",
decimals: 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;
}
/** 견본 차례 32칸 */
const COLUMNS: DataColumn[] = [
{ key: "station", label: "측점", width: 72 },
n2("distance", "거리"),
...TRIPLES.flatMap(([group, key]) => [
n2(`${key}_area`, "단면적", group),
n2(`${key}_volume`, "입적", group),
n2(`${key}_adjusted`, "보정량", group),
]),
n2("adjusted_total", "보정량계"),
n2("fill_area", "단면적", ["성토"]),
n2("fill_volume", "입적", ["성토"]),
n2("diverted", "유용토"),
n2("balance", "차인토량"),
n2("cumulative", "누가토량"),
...FACES.flatMap(([group, key]) => [
n2(`${key}_length`, "거리", group),
n2(`${key}_area`, "면적", group),
]),
];
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;
}
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;
/** 표 한 장 — 공용 컨테이너(카드) 안에 설명 · 공용 표 · 아래 줄 */
export function tableCard(
info: HTMLElement[],
table: HTMLElement,
after: HTMLElement[] = [],
): HTMLElement {
return createCard({ body: [...info, table, ...after] });
}
/** 절토 암 세 칸 — 그 측점 갈래 열에만 · 다른 갈래 열은 빈 칸 */
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 stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow {
// 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값)
const v = (value: number): number | null => (first ? null : value);
const out: DataRow = {
station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""),
distance: row.distance_m,
adjusted_total: v(row.adjusted_total_m3),
fill_area: row.fill_area_m2,
fill_volume: v(row.fill_volume_m3),
diverted: v(row.diverted_m3),
balance: v(row.balance_m3),
cumulative: v(row.cumulative_m3),
};
const r = row as unknown as Record<string, number>;
for (const [, key] of TRIPLES) {
out[`${key}_area`] = r[`${key}_area_m2`];
out[`${key}_volume`] = v(r[`${key}_volume_m3`]);
out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]);
}
for (const [, key] of FACES) {
out[`${key}_length`] = row.faces?.[key]?.length_m ?? null;
out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null);
}
return out;
}
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 {
/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */
function totalRow(table: EarthworkTable): DataRow {
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;
const out: DataRow = {
station: "합계",
distance: t.distance_m,
adjusted_total: t.adjusted_total_m3,
fill_volume: t.fill_volume_m3,
diverted: t.diverted_m3,
balance: t.balance_m3,
cumulative: table.rows[table.rows.length - 1]?.cumulative_m3,
};
for (const [, key] of TRIPLES) {
out[`${key}_volume`] = t[`${key}_volume_m3`];
out[`${key}_adjusted`] = t[`${key}_adjusted_m3`];
}
// 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움
for (const [, key] of FACES) {
const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null);
out[`${key}_area`] = any ? t[`${key}_area_m2`] : null;
}
return out;
}
function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void {
if (!table.rows.length) {
host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."));
return;
}
const c = (kind: string): string => table.conversion_factors[kind]?.compacted.toFixed(2) ?? "";
const flagged = table.rows.filter((row) => row.notes.length);
const info = [
note(
`평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
`측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` +
`발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` +
`단위 m · ㎡ · ㎥`,
),
...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
];
const grid = createDataTable({
columns: COLUMNS,
rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)),
frozen: 1,
total: totalRow(table),
caption: "토적표",
});
grid.root.dataset.b08Earthwork = "table";
host.replaceChildren(tableCard(info, grid.root));
}
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<void> {
host.replaceChildren(note("토적표를 만드는 중…"));
host.replaceChildren(note("토공 수량을 만드는 중…"));
let table: EarthworkTable;
let interval = 20;
try {
@@ -186,29 +248,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");
}
@@ -0,0 +1,117 @@
/* =============================================================================
* B08_Quantity_UI_Earthwork_Sheets.ts
* 토공 보조 탭 셋(PLAN 69-2) — 토공집계표 · 평균운반거리 · 종방향 무대운반거리.
*
* 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`).
* 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임.
* 표 = 공용 많은 자료 표(`ui_template_data_table`).
* ========================================================================== */
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { 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 });
const SUMMARY_COLUMNS: DataColumn[] = [
{ key: "group", label: "구분", width: 120 },
{ key: "kind", label: "공종", width: 72 },
{ key: "spec", label: "규격", width: 128 },
{ key: "unit", label: "단위", width: 56 },
{ key: "value", label: "계", kind: "number", decimals: 2, width: 112 },
{ key: "note", label: "비고", width: 200 },
];
/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
const info = note(
"토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " +
"리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(— = 아직 값 없음)",
);
const grid = createDataTable({
columns: SUMMARY_COLUMNS,
// 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「—」(값 없음)로 그림
rows: table.summary.map((row) => ({
...row,
kind: row.kind || " ",
spec: row.spec || " ",
note: row.note || " ",
})),
frozen: 1,
caption: "토공집계표",
});
host.replaceChildren(tableCard([info], grid.root));
}
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 columns: DataColumn[] = [
{ key: "no", label: "번호", width: 56 },
...groups.flatMap(([label], g) =>
[
["q", "토량(Q)"],
["l", "거리(L)"],
["ql", "Q×L"],
].map(([key, text]) => ({
key: `${key}${g}`,
label: text,
group: [label],
kind: "number" as const,
decimals: 2,
width: 104,
})),
),
];
const count = Math.max(0, ...groups.map(([, list]) => list.rows.length));
const rows: DataRow[] = Array.from({ length: count }, (_, i) => {
const out: DataRow = { no: String(i + 1) };
groups.forEach(([, list], g) => {
const item = list.rows[i];
if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql });
});
return out;
});
const total: DataRow = { no: "합계" };
groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql }));
const averages = groups.map(([label, list], i) =>
note(
`${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`,
),
);
const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info });
host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages));
}
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",
);
}
-23
View File
@@ -67,26 +67,3 @@
.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 -2
View File
@@ -2,7 +2,8 @@
* B08_Quantity_UI_Page.ts
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
*
* 탭 = 구조물 집계표 · 구조물도 · 토적표 · 수리집수면적표 — 차례대로 만듦.
* 탭 = 구조물 집계표 · 구조물도 · 토공 · 수리집수면적표 — 차례대로 만듦.
* 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1).
* 1탭(구조물 집계표 · 61-1) · 4탭(수리집수면적표 · 홍수량 계산서 62-2) 내용 · 나머지는 빈 탭.
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
* ========================================================================== */
@@ -69,7 +70,6 @@ export function renderB08Quantity(root: HTMLElement): void {
const button = createButton({
label: L(label),
variant: id === tab ? "filled" : "ghost",
size: "sm",
onClick: () => id !== tab && select(id),
});
button.setAttribute("role", "tab");
@@ -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
+5 -6
View File
@@ -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:
+1 -1
View File
@@ -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: [
"측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.",