- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table - 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_Earthwork_Tables.ts
|
||
* 토공 보조 탭 넷(PLAN 61-3 · 69-2) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3).
|
||
* 화면(`…_UI_Earthwork` · `…_UI_Earthwork_Sheets`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀.
|
||
*
|
||
* `loadEarthwork` = GET …/quantity/earthwork + 측점 간격 · `applyRock` = 연암 · 경암 = 암 × 좌측 패널 비율 ·
|
||
* 암 C = 비율 가중(캐시 → 저장본 · `…_Options_Calc` · PLAN 122 · 125-1)
|
||
* 토적표 = 견본 차례 32칸 · 합계 줄 · 첫 측점은 앞이 없어 입적 · 면적이 빈 값
|
||
* 토공집계표 · 평균운반거리 · 종방향 무대운반거리 = 거창 실무 워크북 같은 이름 시트 칸
|
||
* ========================================================================== */
|
||
|
||
import { API_BASE_URL } from "@config/config_frontend";
|
||
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
|
||
import { stationLabel } from "../common_util/common_util_station";
|
||
import {
|
||
mixedRockC,
|
||
recountAdjustedSummary,
|
||
recountRockC,
|
||
splitRockColumns,
|
||
splitRockSummary,
|
||
} from "./B08_Quantity_Options_Calc";
|
||
import { stationInterval } from "./B08_Quantity_Summary_Table";
|
||
import { liveSoftRock } from "./B08_Quantity_UI_Options";
|
||
|
||
type Face = { length_m: number | null; area_m2: number | null };
|
||
|
||
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[];
|
||
faces: Record<string, Face>;
|
||
}
|
||
|
||
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;
|
||
/** 저장된 연암 · 경암 %(암 전체 기준) */
|
||
rock_ratios_pct: { 연암: number; 경암: number };
|
||
/** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */
|
||
rock_c: { ripping_rock: number; blasting_rock: number };
|
||
}
|
||
|
||
/** 응답 + 측점 간격 · applyRock = 지금 좌측 패널 비율로 표를 고쳐 씀(조작마다 다시) */
|
||
export async function loadEarthwork(
|
||
projectId: string,
|
||
): Promise<{ table: EarthworkTable; interval: number; applyRock: () => void }> {
|
||
const [response, interval] = 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}`);
|
||
}
|
||
const table: EarthworkTable = body;
|
||
// 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1)
|
||
const summary = table.summary;
|
||
const applyRock = (): void => {
|
||
const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90);
|
||
const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock);
|
||
for (const kind of ["ripping_rock", "blasting_rock"])
|
||
table.conversion_factors[kind].compacted = c;
|
||
recountRockC(table.rows as unknown as Array<Record<string, unknown>>, table.totals, c);
|
||
for (const record of [...table.rows, table.totals])
|
||
splitRockColumns(record as unknown as Record<string, unknown>, soft);
|
||
table.summary = recountAdjustedSummary(
|
||
splitRockSummary(summary, table.totals, soft),
|
||
table.totals,
|
||
table.haul.free_haul.q,
|
||
);
|
||
};
|
||
applyRock();
|
||
return { table, interval, applyRock };
|
||
}
|
||
|
||
/** 보조 탭 넷 — 화면 탭 줄 · 인쇄 쪽 차례 */
|
||
export const EARTHWORK_SHEETS = [
|
||
{ id: "summary", label: "토공집계표" },
|
||
{ id: "sheet", label: "토적표" },
|
||
{ id: "haul", label: "평균운반거리" },
|
||
{ id: "free", label: "종방향 무대운반거리" },
|
||
] as const;
|
||
|
||
/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */
|
||
const TRIPLES: Array<[string[], string, string?]> = [
|
||
[["절토", "토사"], "cut_soil"],
|
||
[["절토", "연암"], "cut_soft_rock", "암 × 연암 %"],
|
||
[["절토", "경암"], "cut_hard_rock", "암 × 경암 %"],
|
||
[["측구파기", "토사"], "ditch_soil"],
|
||
[["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"],
|
||
];
|
||
/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */
|
||
const FACES: Array<[string[], string]> = [
|
||
[["면고르기", "성토면"], "grading_fill"],
|
||
[["면고르기", "절토면"], "grading_cut"],
|
||
[["지장목제거", "성토"], "tree_removal_fill"],
|
||
[["지장목제거", "절토"], "tree_removal_cut"],
|
||
[["제근"], "grubbing"],
|
||
[["노면정리"], "road_surface"],
|
||
];
|
||
|
||
const n2 = (key: string, label: string, group?: string[]): DataColumn => ({
|
||
key,
|
||
label,
|
||
group,
|
||
kind: "number",
|
||
decimals: 2,
|
||
});
|
||
|
||
/** 견본 차례 32칸 */
|
||
const COLUMNS: DataColumn[] = [
|
||
{ key: "station", label: "측점", width: 72 },
|
||
n2("distance", "거리"),
|
||
...TRIPLES.flatMap(([group, key, tip]) =>
|
||
[
|
||
n2(`${key}_area`, "단면적", group),
|
||
n2(`${key}_volume`, "입적", group),
|
||
n2(`${key}_adjusted`, "보정량", group),
|
||
].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)),
|
||
),
|
||
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 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;
|
||
}
|
||
|
||
/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */
|
||
function totalRow(table: EarthworkTable): DataRow {
|
||
const t = table.totals;
|
||
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;
|
||
}
|
||
|
||
/** 토적표 */
|
||
export const earthworkSheetOptions = (
|
||
table: EarthworkTable,
|
||
interval: number,
|
||
): DataTableOptions => ({
|
||
columns: COLUMNS,
|
||
rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)),
|
||
frozen: 1,
|
||
total: totalRow(table),
|
||
caption: "토적표",
|
||
});
|
||
|
||
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 const earthworkSummaryOptions = (table: EarthworkTable): DataTableOptions => ({
|
||
columns: SUMMARY_COLUMNS,
|
||
// 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림
|
||
rows: table.summary.map((row) => ({
|
||
...row,
|
||
kind: row.kind || " ",
|
||
spec: row.spec || " ",
|
||
note: row.note || " ",
|
||
})),
|
||
frozen: 1,
|
||
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
|
||
caption: "토공집계표",
|
||
});
|
||
|
||
const fmt = (value: number | null | undefined): string =>
|
||
value == null || !Number.isFinite(value)
|
||
? "-"
|
||
: value.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||
|
||
/** 운반 시트 둘 — [묶음 이름, 목록] · 표 이름 */
|
||
export const HAUL_SHEETS = {
|
||
haul: (table: EarthworkTable) => ({
|
||
groups: [
|
||
["덤프트럭", table.haul.dump_truck],
|
||
["불도우저", table.haul.dozer],
|
||
] as Array<[string, HaulList]>,
|
||
caption: "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m",
|
||
}),
|
||
free: (table: EarthworkTable) => ({
|
||
groups: [["종방향무대", table.haul.free_haul]] as Array<[string, HaulList]>,
|
||
caption: "종방향 무대운반거리 — 단위 ㎥ · m",
|
||
}),
|
||
};
|
||
|
||
/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · averages = 아래 평균운반거리 줄 */
|
||
export function haulOptions(
|
||
groups: Array<[string, HaulList]>,
|
||
caption: string,
|
||
): { options: DataTableOptions; averages: string[] } {
|
||
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) =>
|
||
`${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`,
|
||
);
|
||
return { options: { columns, rows, frozen: 1, total, caption }, averages };
|
||
}
|