/* ============================================================================= * 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; } export interface HaulRow { q: number; l: number; ql: number; } export interface HaulList { rows: HaulRow[]; q: number; ql: number; average_m: number | null; } export interface SummaryRow { group: string; kind: string; spec: string; unit: string; value: number | null; note: string; } export interface EarthworkTable { rows: EarthworkRow[]; totals: Record; conversion_factors: Record; station_count: number; route_id: number; confirmed: boolean; summary: SummaryRow[]; haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; haul_saved: boolean; /** 저장된 연암 · 경암 %(암 전체 기준) */ 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>, table.totals, c); for (const record of [...table.rows, table.totals]) splitRockColumns(record as unknown as Record, 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; 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 }; }