/* ============================================================================= * B08_Quantity_Options_Calc.ts * 좌측 패널 옵션 — 조작 중 브라우저가 바로 다시 세는 식(화면 없음 · PLAN 122). * * 짝(파이썬 · 같은 식) — 거울 시험 `test_122_b08_options_mirror`: * couplings · recountCouplings ↔ `B08_Quantity_Router_Summary.py` couplings(관마다 `pipes`) * rockSplit · splitRockColumns · splitRockSummary * ↔ `B08_Quantity_Engine_Earthwork_Summary.py` rock_split · * split_rock_columns · summary_rows(연암 · 경암 네 줄) * mixedRockC ↔ `common_util_project_settings.py` mixed_rock_c(125-1) * recountRockC · recountAdjustedSummary * ↔ `B08_Quantity_Engine_Earthwork.py` build_rows · totals · * summary_rows(보정량계 · 유용토 · 잔토처리 줄) * [저장] 뒤에는 서버가 정본으로 다시 셈 — 여기 값을 받아 적지 않음. * import 없음 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ /** 커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 null. */ export function couplings(lengthM: number | null | undefined, oneM: number | null): number | null { if (!lengthM || !oneM) return null; return Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0); } export interface PipeLength { chainage_m: number; /** 커플링 열 id(`pipe:cp|관경`) */ column: string; length_m: number | null; } interface CouplingSummary { rows: Array<{ chainage_m: number; values: Record }>; total: Record; pipes: PipeLength[]; } /** 커플링 열만 새 1본 길이로 — 같은 측점(1 cm)의 같은 열은 더함 · 계 = 줄 합. */ export function recountCouplings(summary: S, oneM: number | null): S { const at = (chainage: number): number => Math.round(chainage * 100); const columns = new Set(summary.pipes.map((pipe) => pipe.column)); const sums = new Map(); for (const pipe of summary.pipes) { const count = couplings(pipe.length_m, oneM); if (count === null) continue; const key = `${at(pipe.chainage_m)}|${pipe.column}`; sums.set(key, (sums.get(key) ?? 0) + count); } const keep = (values: Record): Record => Object.fromEntries(Object.entries(values).filter(([id]) => !columns.has(id))); const rows = summary.rows.map((row) => { const values = keep(row.values); for (const column of columns) { const count = sums.get(`${at(row.chainage_m)}|${column}`); if (count !== undefined) values[column] = count; } return { ...row, values }; }); const total = keep(summary.total); for (const row of rows) for (const column of columns) if (row.values[column] !== undefined) total[column] = (total[column] ?? 0) + row.values[column]; return { ...summary, rows, total }; } /** 0~100 으로 자름 · 숫자가 아니면 null. */ export function clampPct(value: number): number | null { return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : null; } /** 암 → [연암, 경암] — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암. */ export function rockSplit(value: number, softPct: number): [number, number] { const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100; return [soft, value - soft]; } const ROCK_SPLIT = [ ["cut_rock", "cut_soft_rock", "cut_hard_rock"], ["ditch_rock", "ditch_soft_rock", "ditch_hard_rock"], ] as const; const ROCK_SUFFIXES = ["area_m2", "volume_m3", "adjusted_m3"] as const; /** 줄 · 합계의 암 칸마다 연암 · 경암 칸을 덮어씀(숫자인 칸만). */ export function splitRockColumns(record: Record, softPct: number): void { for (const [base, softKey, hardKey] of ROCK_SPLIT) for (const suffix of ROCK_SUFFIXES) { const value = record[`${base}_${suffix}`]; if (typeof value !== "number") continue; const [soft, hard] = rockSplit(value, softPct); record[`${softKey}_${suffix}`] = soft; record[`${hardKey}_${suffix}`] = hard; } } /** 토공집계표 연암 · 경암 줄 — 흙깍기 = 절토 암 · 측구 터파기 = 측구 암 합계 × 비율 · 반올림 정수. */ const ROCK_ROWS: Record = { 흙깍기: "cut_rock_volume_m3", "측구 터파기": "ditch_rock_volume_m3", }; export function splitRockSummary( rows: readonly R[], totals: Record, softPct: number, ): R[] { return rows.map((row) => { const base = ROCK_ROWS[row.group]; if (!base || (row.kind !== "연암" && row.kind !== "경암")) return row; const [soft, hard] = rockSplit(totals[base] ?? 0, softPct); return { ...row, value: Math.round(row.kind === "연암" ? soft : hard) }; }); } /** 암 C = 연암 % × 리핑암 C + 경암 % × 발파암 C(0~100 자름) — 연암 = 리핑 · 경암 = 발파 고정(125-1). */ export function mixedRockC(softPct: number, rippingC: number, blastingC: number): number { const soft = Math.min(Math.max(softPct, 0), 100) / 100; return soft * rippingC + (1 - soft) * blastingC; } const num = (value: unknown): number => (typeof value === "number" ? value : 0); const RECOUNT_TOTALS = [ "cut_rock_adjusted_m3", "ditch_rock_adjusted_m3", "adjusted_total_m3", "diverted_m3", "balance_m3", ] as const; /** 토적표를 새 암 C 로 — 암 보정량 · 보정량계 · 유용토 · 차인 · 누가 · 합계(줄 · 합계 덮어씀). */ export function recountRockC( rows: Array>, totals: Record, c: number, ): void { let cumulative = 0; for (const row of rows) { const cutRock = num(row.cut_rock_volume_m3) * c; const adjusted = num(row.cut_soil_adjusted_m3) + cutRock; const fill = num(row.fill_volume_m3); cumulative += adjusted - fill; Object.assign(row, { cut_rock_adjusted_m3: cutRock, ditch_rock_adjusted_m3: num(row.ditch_rock_volume_m3) * c, adjusted_total_m3: adjusted, diverted_m3: Math.min(adjusted, fill), balance_m3: adjusted - fill, cumulative_m3: cumulative, }); } const sum = (key: string, picked = rows): number => picked.reduce((total, row) => total + num(row[key]), 0); for (const key of RECOUNT_TOTALS) totals[key] = sum(key); for (const kind of ["ripping_rock", "blasting_rock"]) totals[`cut_${kind}_adjusted_m3`] = sum( "cut_rock_adjusted_m3", rows.filter((row) => row.cut_rock_kind === kind), ); } /** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */ const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value)); /** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로). */ export function recountAdjustedSummary( rows: readonly R[], totals: Record, freeQ: number, ): R[] { const diverted = totals.diverted_m3 ?? 0; const values: Record = { 보정량계: excelRound(totals.adjusted_total_m3 ?? 0), "유용토 소계": diverted + freeQ, 횡방향유용토: diverted, 잔토처리: excelRound(totals.balance_m3 ?? 0), }; return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row)); }