diff --git a/B08_Quantity/B08_Quantity_Earthwork_Tables.ts b/B08_Quantity/B08_Quantity_Earthwork_Tables.ts new file mode 100644 index 000000000..efa789d0e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Earthwork_Tables.ts @@ -0,0 +1,327 @@ +/* ============================================================================= + * 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 }; +} diff --git a/B08_Quantity/B08_Quantity_Hydro_Table.ts b/B08_Quantity/B08_Quantity_Hydro_Table.ts new file mode 100644 index 000000000..6444aa257 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Hydro_Table.ts @@ -0,0 +1,188 @@ +/* ============================================================================= + * B08_Quantity_Hydro_Table.ts + * 수리집수면적표(PLAN 62-2 · 69-4) 자료 읽기 · 측점 입력 겹치기 · 인쇄 표 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Hydro`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 입력 · 같은 서버 계산을 씀. + * + * 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안(캐시 `b08-hydro`) · 연결값과 같은 칸은 null 로 저장(연동 유지) + * 계산 = 서버 단독(POST …/quantity/hydro/calc) + * `hydroTable` = 계산서 한 장(화면 두 카드 칸 차례 그대로)을 구분 · 항목 · 값 표로 — 인쇄 전용 + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_Material_Tables"; +import { stationInterval } from "./B08_Quantity_Summary_Table"; +import type { + HydroInputs, + HydroLink, + HydroOptions, + HydroResult, +} from "./B08_Quantity_UI_Hydro_Sheet"; + +export interface HydroStation { + chainage_m: number; + facility: string; + /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ + kind: string; + /** 계산서 장 — 시설이 정함(서버 station_rows) */ + sheet: HydroInputs["sheet"]; + /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ + linked: HydroLink["values"] | null; + b05: HydroLink["b05"]; +} + +export type Drafts = Record>; +export type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; + +export const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); + +const linkedOf = (station: HydroStation): Record => station.linked ?? {}; + +/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ +const layer = (station: HydroStation, values: Partial = {}) => + Object.fromEntries( + Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), + ); + +/** 측점 입력 — 기본 < 장 기본 < 연결 < 저장 < 초안 */ +export const hydroInputs = ( + sheet: HydroPayload, + station: HydroStation, + drafts: Drafts, +): HydroInputs => + ({ + ...sheet.defaults, + ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), + ...station.linked, + // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) + ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), + sheet: station.sheet, + }) as HydroInputs; + +/** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ +export const unlinkedInputs = (station: HydroStation, inputs: HydroInputs): HydroInputs => + Object.fromEntries( + Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), + ) as unknown as HydroInputs; + +export async function loadHydro( + projectId: string, +): Promise<{ sheet: HydroPayload; interval: number; drafts: Drafts }> { + const [sheet, interval] = await Promise.all([ + request(`/projects/${projectId}/quantity/hydro`), + stationInterval(projectId).catch(() => 20), + ]); + return { sheet, interval, drafts: readState("b08-hydro", projectId) ?? {} }; +} + +export const hydroCalc = (projectId: string, inputs: HydroInputs): Promise => + request(`/projects/${projectId}/quantity/hydro/calc`, { + method: "POST", + body: JSON.stringify(inputs), + }); + +const DASH = "-"; +const fmt = (value: number | null | undefined, digits: number, unit = ""): string => + value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`; +const raw = (value: number | string | null | undefined, unit = ""): string => + value == null || value === "" ? DASH : `${value}${unit}`; + +/** 계산서 한 장 → 표(구분 · 항목 · 값) — 칸 이름 · 자리수 = 화면 계산서(`…_UI_Hydro_Sheet`) */ +export function hydroTable( + stationText: string, + kind: string, + inputs: HydroInputs, + r: HydroResult | null, + options: HydroOptions, +): DataTableOptions { + const site = options.site; + const flood = "1. 홍수량 계산"; + const sheetDefaults = inputs.sheet === "pipe" ? {} : options.sheet_defaults[inputs.sheet]; + const n = + inputs.n ?? (inputs.sheet === "pipe" ? options.pipe_kinds[inputs.pipe_kind] : sheetDefaults.n); + const slope = [ + "하상경사 S = 1 /", + `${raw(inputs.slope_denominator)} (${fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %")})`, + ]; + const section: Array<[string, string]> = + inputs.sheet === "box" + ? [ + ["내공 폭 B (m)", raw(inputs.box_width_m)], + ["내공 높이 H (m)", raw(inputs.box_height_m)], + ["수심비 h / H", raw(inputs.depth_ratio)], + ["수심 h (m)", fmt(r?.depth, 2)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + slope as [string, string], + ["조도계수 n", raw(n)], + ] + : inputs.sheet === "ford" + ? [ + ["월류 폭 B (m)", raw(inputs.overflow_width_m)], + slope as [string, string], + ["조도계수 n", raw(n)], + ["월류 높이 h (m)", fmt(r?.depth, 2)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + ] + : [ + ["관 종류", inputs.pipe_kind], + ["단면 가정", options.sections[inputs.section] ?? inputs.section], + ["단면 D (mm)", raw(inputs.diameter_mm)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + slope as [string, string], + ["조도계수 n", raw(n)], + ]; + const judged = r != null && r.ok != null; + const check = + inputs.sheet === "box" + ? `2. 단면 검토 — ${kind}` + : inputs.sheet === "ford" + ? `2. 월류 높이 — ${kind}` + : "2. 단면 검토"; + const rows: Array<[string, string, string]> = [ + [flood, `Qd : 설계 홍수량 (㎥/s) = Q × ${inputs.factor.toFixed(1)}`, fmt(r?.design_flow, 2)], + [flood, "Q : 홍수량 (㎥/s)", fmt(r?.flow, 2)], + [flood, "f : 유출계수", raw(inputs.runoff)], + [flood, "rt : 평균 강우강도 (mm/hr)", fmt(r?.intensity, 2)], + [flood, "A : 유역면적 (㎢)", `${raw(inputs.area_km2)} (${fmt(r?.area_ha, 1, " ha")})`], + [flood, "T : 홍수 도달시간 (hr)", fmt(r?.tc_hr, 3)], + [flood, "L : 산정에서 하천시점까지 거리 (m)", raw(inputs.length_m)], + [flood, "H : 산정에서 하천시점까지 표고차 (m)", raw(inputs.height_m)], + [flood, "s : 지표면 경사 (H / L)", fmt(r?.slope_ground, 3)], + [flood, "최소 도달시간 (분)", raw(inputs.tc_min_minutes)], + [flood, "지역", site.region.name || raw(inputs.region)], + [flood, "재현기간", `${inputs.return_period} 년`], + [ + flood, + "R24 : 확률강우량 (mm/day)", + fmt(r?.r24_mm ?? site.r24[String(inputs.return_period)], 1), + ], + [check, "총 홍수량 Qd (㎥/s)", fmt(r?.design_flow, 2)], + ...section.map(([label, value]) => [check, label, value] as [string, string, string]), + [check, "유속 (m/s)", fmt(r?.velocity, 3)], + [check, "유출량 (㎥/s)", fmt(r?.capacity, 3)], + [ + check, + "판정", + judged + ? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}` + : DASH, + ], + [check, "유출율 (%)", fmt(r?.ratio_pct, 0)], + ]; + return { + columns: [ + { key: "group", label: "구분", width: 120 }, + { key: "item", label: "항목", width: 260 }, + { key: "value", label: "값", width: 240 }, + ], + rows: rows.map(([group, item, value]) => ({ group, item, value })), + mergeSame: ["group"], + caption: `홍수량 계산서 ${stationText}${kind ? ` ${kind}` : ""}`, + }; +} diff --git a/B08_Quantity/B08_Quantity_Material_Tables.ts b/B08_Quantity/B08_Quantity_Material_Tables.ts new file mode 100644 index 000000000..c81c546e4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Material_Tables.ts @@ -0,0 +1,261 @@ +/* ============================================================================= + * B08_Quantity_Material_Tables.ts + * 자재 보조 탭 셋(PLAN 121-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Material`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadMaterials` = GET …/quantity/materials + 캐시(`b08-materials`) + 좌측 패널 할증 → 짝 계산 + * `materialTable` = 보조 탭 한 장의 열 · 줄 · 계 · 고칠 칸 · 고친 칸 자리 + * `materialOptions` = 그 장의 공용 표 옵션(화면은 고치기 · 높이만 더함) + * 값 없는 줄은 숨김(143-3) · 콘크리트소요는 5줄 늘 보임(163-5) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../A00_Common/b_page_state"; +import { + computeMaterialSheets, + DEDUCT_KEY, + MIX_FIELDS, + type Cells, + type MaterialOverrides, + type MaterialSheets, + type SummarySource, +} from "./B08_Quantity_Material_Calc"; +import { liveSurcharge } from "./B08_Quantity_UI_Options"; + +export type MaterialSheetId = "material" | "concrete" | "quantity"; + +export interface Payload { + source: SummarySource; + overrides: MaterialOverrides; + /** 좌측 패널 할증 저장본(기본 채움) */ + surcharge_pct: Cells; + /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ + notes?: string[]; + sheets: MaterialSheets; +} + +const BASIS = + "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; + +/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ +const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => + rows.flatMap((row, i) => (has(row) ? [i] : [])); +const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); + +export async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + if (!response.ok) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body as T; +} + +/** 저장본 + 캐시(조작 중 값) — calc = 지금 좌측 패널 할증으로 세 표를 다시 셈 */ +export async function loadMaterials(projectId: string): Promise<{ + payload: Payload; + overrides: MaterialOverrides; + calc: (payload: Payload, overrides: MaterialOverrides) => MaterialSheets; +}> { + const payload = await request(`/projects/${projectId}/quantity/materials`); + const draft = readState("b08-materials", projectId); + return { + payload, + overrides: draft ?? payload.overrides, + calc: (payload, overrides) => + computeMaterialSheets( + payload.source, + overrides, + liveSurcharge(projectId, payload.surcharge_pct), + ), + }; +} + +/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ +const numberColumn = (column: DataColumn): DataColumn => ({ + kind: "number", + decimals: 3, + trim: true, + ...column, +}); + +export interface Table { + columns: DataColumn[]; + rows: DataRow[]; + total: DataRow | null; + frozen: number; + editable: (index: number, key: string) => boolean; + /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ + target: (index: number, key: string) => [Record | Cells, string, string | null]; + /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; +} + +function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { materials, rows, total } = s.quantity; + const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); + const rowsOut: DataRow[] = keep.flatMap((at) => { + const row = rows[at]; + const unit: DataRow = { + name: row.name, + spec: row.spec, + qty: row.qty, + unit: row.unit, + kind: "단위수량", + }; + const amount: DataRow = { kind: "수량" }; + for (const m of materials) { + unit[`m:${m.key}`] = row.units[m.key]; + amount[`m:${m.key}`] = row.amounts[m.key]; + } + return [unit, amount]; + }); + const totalRow: DataRow = { name: "계", kind: "수량" }; + for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; + return { + columns: [ + { key: "name", label: "구분", width: 120 }, + { key: "spec", label: "규격", width: 120 }, + numberColumn({ key: "qty", label: "수량", width: 72 }), + { key: "unit", label: "단위", width: 56 }, + { key: "kind", label: "구분", width: 72 }, + ...materials.map((m) => + numberColumn({ + key: `m:${m.key}`, + label: m.unit, + group: m.spec ? [m.name, m.spec] : [m.name], + title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), + width: 76, + }), + ), + ], + rows: rowsOut, + total: totalRow, + frozen: 5, + editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), + target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], + }; +} + +export const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { + cement: ["시멘트", "㎥당(kg)", "수량(kg)"], + sand: ["모래", "㎥당(㎥)", "수량(㎥)"], + gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], +}; + +function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { rows, total } = s.concrete; + const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 + const rowsOut: DataRow[] = keep.map((at) => { + const row = rows[at]; + const out: DataRow = { + name: row.name, // 위 줄과 같으면 세로 병합(201-1) + spec: row.spec, + qty: row.qty, + }; + for (const f of MIX_FIELDS) { + out[`per:${f}`] = row.per[f]; + out[`amt:${f}`] = row.amounts[f]; + } + return out; + }); + const totalRow: DataRow = { name: "계" }; + for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; + return { + columns: [ + { key: "name", label: "구분", width: 88 }, + { key: "spec", label: "규격", width: 110 }, + numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), + ...MIX_FIELDS.flatMap((f) => [ + numberColumn({ + key: `per:${f}`, + label: MIX_LABEL[f][1], + group: [MIX_LABEL[f][0]], + width: 84, + }), + numberColumn({ + key: `amt:${f}`, + label: MIX_LABEL[f][2], + group: [MIX_LABEL[f][0]], + width: 96, + }), + ]), + ], + rows: rowsOut, + total: totalRow, + frozen: 2, + mergeSame: ["name"], + editable: (_i, key) => key.startsWith("per:"), + target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], + }; +} + +function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { rows } = s.material; + const lost = new Set(s.quantity.unmatched); + const keep = shown(rows, (row) => row.qty != null || row.amount != null); + return { + columns: [ + { key: "group", label: "구분", width: 96 }, + { key: "spec", label: "규격", width: 120 }, + { key: "unit", label: "단위", width: 56 }, + numberColumn({ key: "qty", label: "수량", width: 96 }), + numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), + numberColumn({ key: "amount", label: "합계", width: 96 }), + { key: "note", label: "비고", width: 96 }, + ], + rows: keep.map((at) => { + const row = rows[at]; + return { + group: row.group, // 위 줄과 같으면 세로 병합(201-1) + spec: row.spec, + unit: row.unit, + qty: row.qty, + surcharge: row.surcharge, + amount: row.amount, + note: row.panel + ? "할증 = 좌측 패널" + : row.key === DEDUCT_KEY + ? "토공 잔토처리에서 뺌" + : lost.has(row.key) + ? "2공구 맞춤 없음" + : "", + }; + }), + total: null, + frozen: 2, + mergeSame: ["group"], + editable: (i, key) => + key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, + target: (i) => [o.surcharge, rows[keep[i]].key, null], + }; +} + +const BUILD: Record Table> = { + material: totalTable, + concrete: concreteTable, + quantity: quantityTable, +}; + +export const materialTable = ( + sheet: MaterialSheetId, + s: MaterialSheets, + o: MaterialOverrides, +): Table => BUILD[sheet](s, o); + +/** 공용 표 옵션 — 고치기 · 높이는 화면이 더함 */ +export const materialOptions = (built: Table): DataTableOptions => ({ + columns: built.columns, + rows: built.rows, + total: built.total, + frozen: built.frozen, + mergeSame: built.mergeSame, + empty: "", + caption: BASIS, +}); diff --git a/B08_Quantity/B08_Quantity_Milestone_Table.ts b/B08_Quantity/B08_Quantity_Milestone_Table.ts new file mode 100644 index 000000000..b35e69047 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Table.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Table.ts + * 거리이정표 표(PLAN 244-1 · 244-13) 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Milestone`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * 열 = 기준점 있음 = 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계 + * 줄 = TS 짝(`…_Milestone_Calc.computeMilestone`) · 목적지 = 글(화면은 고름 표 ▾ 를 덧붙임) + * ========================================================================== */ + +import type { DataColumn, DataTableOptions } from "@ui/ui_template_data_table"; +import { + computeMilestone, + type Dest, + type MilestoneData, + type MilestoneTableRow, +} from "./B08_Quantity_Milestone_Calc"; + +export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; + +const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; +const ROAD = ["도로 구분 (km)"]; +const SUM = ["계 (km)"]; + +/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · + * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ +export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { + const roads: DataColumn[] = withBase + ? [ + { key: "s2", label: "2차포장", group: ROAD, ...KM }, + { key: "s1", label: "1차포장", group: ROAD, ...KM }, + ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), + { key: "un", label: "비포장", group: ROAD, ...KM }, + { key: "paved", label: "포장", group: SUM, ...KM }, + { key: "unpaved", label: "비포장", group: SUM, ...KM }, + ] + : [ + { key: "paved", label: "포장", group: ROAD, ...KM }, + { key: "un", label: "비포장", group: ROAD, ...KM }, + ]; + return [ + { key: "item", label: "항목", width: 134 }, + { key: "company", label: "거래처 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + ...roads, + { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, + { key: "note", label: "비고", width: 160 }, + ]; +} + +/** 표 한 벌 — options(열 · 줄 · 이름) · rows = 짝 계산 줄(경로 id · 줄 차례 · km) · mode = 열 꼴 표 */ +export function milestoneTable( + data: MilestoneData, + midDefaultM: number | null, +): { options: DataTableOptions; rows: MilestoneTableRow[]; mode: string } { + const rows = computeMilestone(data, midDefaultM); + const withBase = data.bases.length > 0; + const anyPaved = rows.some((r) => r.pv > 0); + return { + options: { + columns: milestoneColumns(withBase, anyPaved), + rows: rows.map((r) => ({ ...r, dest: DEST_TEXT[r.dest] })), + caption: "거리이정표", + empty: "", + }, + rows, + mode: `${withBase}:${anyPaved}`, + }; +} diff --git a/B08_Quantity/B08_Quantity_Summary_Table.ts b/B08_Quantity/B08_Quantity_Summary_Table.ts new file mode 100644 index 000000000..01a4ea70a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Summary_Table.ts @@ -0,0 +1,245 @@ +/* ============================================================================= + * B08_Quantity_Summary_Table.ts + * 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로 + * `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class) + * 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { stationLabel } from "../common_util/common_util_station"; +import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import { + comboId, + linkedOnly, + pipeOwnerId, + summaryColumns, + summaryStations, + type ComboColumn, + type ComboLabel, + type LinkedSnapshot, + type PlacedItem, + type UnlinkedFacility, + unassignedText, + unlinkedAt, + widthOf, +} from "./B08_Quantity_Summary_Data"; +import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; +import { liveOnePipe } from "./B08_Quantity_UI_Options"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ +export async function stationInterval(projectId: string): Promise { + const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); + return latest.route_params?.station_interval_m || 20; +} + +/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ +interface PipeColumn { + id: string; + group: string[]; + label: string; + combos: string[]; +} +export interface PipeSummary { + columns: PipeColumn[]; + rows: Array<{ chainage_m: number; values: Record }>; + total: Record; + notes: string[]; + /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ + types: string[]; + one_length_m: number | null; + pipes: PipeLength[]; + /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ + linked: Record; + /** 미연결 시설(154-1) */ + unlinked: UnlinkedFacility[]; + /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ + facilities: UnlinkedFacility[]; +} + +async function fetchPipeSummary(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); + return response.json(); +} + +const PIPE_TOP = "관 공"; +/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ +export const LAMP_CLASS = "b08-summary__lamp"; +/** 모두 연결 = 같은 램프 초록(247-1) */ +const LINKED_CLASS = `${LAMP_CLASS} is-linked`; +const LAMP_W = 14; +const stationKey = (station: number): number => Math.round(station * 100); +/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ +const placesOf = (values: number[]): number => + Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); + +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ +function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { + const top = + summary.columns + .filter((column) => column.group[0] === PIPE_TOP) + .flatMap((column) => column.combos) + .map(comboPath) + .find((path) => !!path) ?? PIPE_TOP; + return summary.columns.map((column) => { + const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; + const values = [ + ...summary.rows.map((row) => row.values[column.id]), + summary.total[column.id], + ].filter((value): value is number => typeof value === "number"); + const decimals = placesOf(values); + const texts = values.map((value) => + value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }), + ); + return { + key: column.id, + label: column.label, + group, + kind: "number", + decimals, + title: [...group, column.label].join(" › "), + width: widthOf(column.label, ...texts), + }; + }); +} + +/** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */ +export interface SummaryView { + stations: number[]; + /** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */ + columns: ComboColumn[]; + /** 모달용 구조물 · 관 지점(사본 있는 것) */ + things: PlacedItem[]; + linked: Record; + describe: (item: SummaryItem) => ComboLabel | null; + comboPath: (id: string) => string | null; + interval: number; + unlinked: UnlinkedFacility[]; + saved: PipeSummary | null; + /** 배관 집계 실패 까닭 — 나머지 표는 그대로 */ + error: string | null; + /** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */ + live: () => PipeSummary | null; +} + +export async function loadSummary(projectId: string): Promise { + const [structures, pipes, interval, pipe] = await Promise.all([ + fetchStructures(projectId), + // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 + fetchDetailPipePoints(projectId).then( + (response) => response.pipe_points, + () => [], + ), + stationInterval(projectId).catch(() => 20), + // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 + fetchPipeSummary(projectId).catch((error: unknown) => String(error)), + ]); + const saved = typeof pipe === "string" ? null : pipe; + // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) + const linked = saved?.linked ?? {}; + const paths = new Map( + Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), + ); + const describe = (item: SummaryItem): ComboLabel | null => { + const [group = "", ...rest] = paths.get(comboId(item)) ?? []; + return group ? { group, name: rest.join(" ") || group } : null; + }; + const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; + // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) + const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; + const placed = linkedOnly( + pipes.map((point) => ({ + ...point, + structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), + })), + linked, + ); + const mine = linkedOnly(structures.structures, linked); + const things: PlacedItem[] = [...mine, ...placed]; + const unlinked = saved?.unlinked ?? []; + // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) + const served = new Set(saved?.types ?? []); + const others = [ + ...mine.filter((item) => !served.has(item.type_id)), + ...placed.filter((point) => !served.has(point.facility ?? "pipe")), + ]; + return { + stations: summaryStations([...things, ...unlinked]), + columns: summaryColumns(others, describe), + things, + linked, + describe, + comboPath, + interval, + unlinked, + saved, + error: typeof pipe === "string" ? pipe : null, + live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)), + }; +} + +/** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */ +export function summaryTable(view: SummaryView): { + options: DataTableOptions; + labels: string[]; + missing: string[]; +} { + const { stations, columns, interval, unlinked, comboPath } = view; + const pipe = view.live(); + const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); + const total: DataRow | null = pipe?.columns.length + ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } + : null; + const labels = stations.map((station) => stationLabel(station, interval)); + const stationHead = L("B08_Quantity_Summary_Station"); + // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) + const missing = stations.map((station) => + unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), + ); + // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 + const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); + const options: DataTableOptions = { + columns: [ + { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, + ...columns.map((column) => ({ + key: column.id, + label: column.name, + group: [column.group || L("B08_Quantity_Summary_MissingGroup")], + title: summaryTooltip(column.item), + className: column.missing ? "is-missing" : undefined, + width: widthOf(column.name), + })), + ...(pipe ? pipeColumns(pipe, comboPath) : []), + ], + // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 + rows: stations.map((station, i) => ({ + station: labels[i], + ...values.get(stationKey(station)), + })), + total, + cellClass: (i, key) => + key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, + frozen: 1, + empty: "", + caption: L("B08_Quantity_Tab_StructureSummary"), + }; + return { options, labels, missing }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index c29233cc3..071d23d3c 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -9,142 +9,24 @@ * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. * 절토 연암 · 경암 = 암 × 좌측 패널 비율 · 암 C = 비율 가중 — 조작 중 같은 식으로 바로 다시 셈 * (`…_Options_Calc` · PLAN 122 · 125-1). + * 자료 읽기 · 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -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 { createDataTable } from "@ui/ui_template_data_table"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { stationInterval } from "./B08_Quantity_UI_Summary"; +import { + EARTHWORK_SHEETS, + earthworkSheetOptions, + loadEarthwork, + type EarthworkTable, +} from "./B08_Quantity_Earthwork_Tables"; import { renderEarthworkSummary, renderFreeHaul, renderHaulDistance, } from "./B08_Quantity_UI_Earthwork_Sheets"; -import { - mixedRockC, - recountAdjustedSummary, - recountRockC, - splitRockColumns, - splitRockSummary, -} from "./B08_Quantity_Options_Calc"; -import { liveSoftRock, onOptionsChange } 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 }; -} - -/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ -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), - ]), -]; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; export function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -162,56 +44,6 @@ export function tableCard( return createCard({ body: [table, ...after, ...info] }); } -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; -} - function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { if (!table.rows.length) { host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); @@ -225,66 +57,25 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) ), ...(flagged.length ? [footNote(`* ${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: "토적표", - fillViewport: true, - }); + const grid = createDataTable({ ...earthworkSheetOptions(table, interval), fillViewport: true }); 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; +const SHEETS = EARTHWORK_SHEETS; type SheetId = (typeof SHEETS)[number]["id"]; export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("토공 수량을 만드는 중…")); - let table: EarthworkTable; - let interval = 20; + let loaded: Awaited>; 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; + loaded = await loadEarthwork(projectId); } catch (error) { host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`)); return; } - - // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 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(); + const { table, interval, applyRock } = loaded; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs"; diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index ee9e58c22..372561c31 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -5,45 +5,25 @@ * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. * 표 = 공용 많은 자료 표(`ui_template_data_table`). + * 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { note, tableCard } from "./B08_Quantity_UI_Earthwork"; +import { + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + type EarthworkTable, +} from "./B08_Quantity_Earthwork_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; -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 = footNote( "연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움", ); - const grid = createDataTable({ - 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: "토공집계표", - fillViewport: true, - }); + const grid = createDataTable({ ...earthworkSummaryOptions(table), fillViewport: true }); host.replaceChildren(tableCard([info], grid.root)); } @@ -53,74 +33,22 @@ function haulNote(table: EarthworkTable): HTMLElement[] { : [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; } -/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ +/** 운반 시트 한 장 — 표 + 아래 평균운반거리 줄 */ function haulSheet( host: HTMLElement, table: EarthworkTable, - groups: Array<[string, HaulList]>, - caption: string, + sheet: keyof typeof HAUL_SHEETS, ): 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, - fillViewport: true, - }); - host.replaceChildren(tableCard(haulNote(table), grid.root, averages)); + const { groups, caption } = HAUL_SHEETS[sheet](table); + const { options, averages } = haulOptions(groups, caption); + const grid = createDataTable({ ...options, fillViewport: true }); + host.replaceChildren(tableCard(haulNote(table), grid.root, averages.map(note))); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { - haulSheet( - host, - table, - [ - ["덤프트럭", table.haul.dump_truck], - ["불도우저", table.haul.dozer], - ], - "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", - ); + haulSheet(host, table, "haul"); } export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { - haulSheet( - host, - table, - [["종방향무대", table.haul.free_haul]], - "종방향 무대운반거리 — 단위 ㎥ · m", - ); + haulSheet(host, table, "free"); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index e33d2009b..c835d8c85 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -8,55 +8,32 @@ * 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버) * 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움 * 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) + * 자료 읽기 · 측점 입력 겹치기 = `B08_Quantity_Hydro_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import "./B08_Quantity_UI_Hydro.css"; -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { clearState, writeState } from "../A00_Common/b_page_state"; import { stationLabel } from "../common_util/common_util_station"; -import { stationInterval } from "./B08_Quantity_UI_Summary"; import { - buildHydroSheet, - type HydroInputs, - type HydroLink, - type HydroOptions, - type HydroResult, -} from "./B08_Quantity_UI_Hydro_Sheet"; + hydroCalc, + hydroInputs, + keyOf, + loadHydro, + unlinkedInputs as unlinked, + type Drafts, + type HydroPayload, + type HydroStation, +} from "./B08_Quantity_Hydro_Table"; +import { request } from "./B08_Quantity_Material_Tables"; +import { buildHydroSheet, type HydroInputs, type HydroResult } from "./B08_Quantity_UI_Hydro_Sheet"; -interface HydroStation { - chainage_m: number; - facility: string; - /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ - kind: string; - /** 계산서 장 — 시설이 정함(서버 station_rows) */ - sheet: HydroInputs["sheet"]; - /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ - linked: HydroLink["values"] | null; - b05: HydroLink["b05"]; -} - -type Drafts = Record>; -type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; - -export async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...init, - }); - const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; - if (!response.ok) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - return body as T; -} +export { request }; function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -65,17 +42,13 @@ function note(text: string): HTMLElement { return p; } -const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); - export async function renderHydro(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("홍수량 계산서를 불러오는 중…")); let sheet: HydroPayload; let interval = 20; + let drafts: Drafts = {}; try { - [sheet, interval] = await Promise.all([ - request(`/projects/${projectId}/quantity/hydro`), - stationInterval(projectId).catch(() => 20), - ]); + ({ sheet, interval, drafts } = await loadHydro(projectId)); } catch (error) { host.replaceChildren(note(`홍수량 계산서를 불러오지 못했습니다 — ${String(error)}`)); return; @@ -87,27 +60,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise return; } - let drafts: Drafts = readState("b08-hydro", projectId) ?? {}; - const linkedOf = (station: HydroStation): Record => station.linked ?? {}; - /** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ - const layer = (station: HydroStation, values: Partial = {}) => - Object.fromEntries( - Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), - ); - const inputsOf = (station: HydroStation): HydroInputs => - ({ - ...sheet.defaults, - ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), - ...station.linked, - // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) - ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), - sheet: station.sheet, - }) as HydroInputs; - /** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ - const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs => - Object.fromEntries( - Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), - ) as unknown as HydroInputs; + const inputsOf = (station: HydroStation): HydroInputs => hydroInputs(sheet, station, drafts); const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs"; @@ -172,10 +125,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise ): Promise => { const mine = ++seq; try { - const result = await request(`/projects/${projectId}/quantity/hydro/calc`, { - method: "POST", - body: JSON.stringify(inputs), - }); + const result = await hydroCalc(projectId, inputs); if (mine === seq) show(result); } catch (error) { if (mine === seq) show(null, `계산하지 못했습니다 — ${String(error)}`); diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 849d7b6ea..b7f7c984f 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -11,9 +11,9 @@ * 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5) * 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른 * 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5) + * 자료 읽기 · 표 짜기 = `B08_Quantity_Material_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard, @@ -21,36 +21,30 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { clearState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, - computeMaterialSheets, - DEDUCT_KEY, matched, MIX_FIELDS, type Cells, - type MaterialOverrides, type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { + loadMaterials, + materialOptions, + materialTable, + MIX_LABEL, + request, + type MaterialSheetId, + type Payload, +} from "./B08_Quantity_Material_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; -export type MaterialSheetId = "material" | "concrete" | "quantity"; +export type { MaterialSheetId }; -interface Payload { - source: SummarySource; - overrides: MaterialOverrides; - /** 좌측 패널 할증 저장본(기본 채움) */ - surcharge_pct: Cells; - /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ - notes?: string[]; - sheets: MaterialSheets; -} - -const BASIS = - "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 배합은 칸에서 고침", @@ -58,25 +52,6 @@ const HINTS: Record = { }; const HIDDEN = "값 없는 줄은 숨김"; -/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ -const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => - rows.flatMap((row, i) => (has(row) ? [i] : [])); -const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); - -async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...init, - }); - const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; - if (!response.ok) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - return body as T; -} - function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; @@ -84,14 +59,6 @@ function note(text: string): HTMLElement { return p; } -/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ -const numberColumn = (column: DataColumn): DataColumn => ({ - kind: "number", - decimals: 3, - trim: true, - ...column, -}); - /** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */ function parse(text: string): number | null | undefined { const t = text.replace(/,/g, "").trim(); @@ -100,70 +67,6 @@ function parse(text: string): number | null | undefined { return Number.isFinite(v) ? v : undefined; } -interface Table { - columns: DataColumn[]; - rows: DataRow[]; - total: DataRow | null; - frozen: number; - editable: (index: number, key: string) => boolean; - /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ - target: (index: number, key: string) => [Record | Cells, string, string | null]; - /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ - mergeSame?: string[]; -} - -function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { materials, rows, total } = s.quantity; - const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); - const rowsOut: DataRow[] = keep.flatMap((at) => { - const row = rows[at]; - const unit: DataRow = { - name: row.name, - spec: row.spec, - qty: row.qty, - unit: row.unit, - kind: "단위수량", - }; - const amount: DataRow = { kind: "수량" }; - for (const m of materials) { - unit[`m:${m.key}`] = row.units[m.key]; - amount[`m:${m.key}`] = row.amounts[m.key]; - } - return [unit, amount]; - }); - const totalRow: DataRow = { name: "계", kind: "수량" }; - for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; - return { - columns: [ - { key: "name", label: "구분", width: 120 }, - { key: "spec", label: "규격", width: 120 }, - numberColumn({ key: "qty", label: "수량", width: 72 }), - { key: "unit", label: "단위", width: 56 }, - { key: "kind", label: "구분", width: 72 }, - ...materials.map((m) => - numberColumn({ - key: `m:${m.key}`, - label: m.unit, - group: m.spec ? [m.name, m.spec] : [m.name], - title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), - width: 76, - }), - ), - ], - rows: rowsOut, - total: totalRow, - frozen: 5, - editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), - target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], - }; -} - -const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { - cement: ["시멘트", "㎥당(kg)", "수량(kg)"], - sand: ["모래", "㎥당(㎥)", "수량(㎥)"], - gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], -}; - /** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */ const EXCEL_MIX: Record = { c25: { cement: 346, sand: 0.51, gravel: 0.59 }, @@ -214,100 +117,6 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { : ""; } -function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { rows, total } = s.concrete; - const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 - const rowsOut: DataRow[] = keep.map((at) => { - const row = rows[at]; - const out: DataRow = { - name: row.name, // 위 줄과 같으면 세로 병합(201-1) - spec: row.spec, - qty: row.qty, - }; - for (const f of MIX_FIELDS) { - out[`per:${f}`] = row.per[f]; - out[`amt:${f}`] = row.amounts[f]; - } - return out; - }); - const totalRow: DataRow = { name: "계" }; - for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; - return { - columns: [ - { key: "name", label: "구분", width: 88 }, - { key: "spec", label: "규격", width: 110 }, - numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), - ...MIX_FIELDS.flatMap((f) => [ - numberColumn({ - key: `per:${f}`, - label: MIX_LABEL[f][1], - group: [MIX_LABEL[f][0]], - width: 84, - }), - numberColumn({ - key: `amt:${f}`, - label: MIX_LABEL[f][2], - group: [MIX_LABEL[f][0]], - width: 96, - }), - ]), - ], - rows: rowsOut, - total: totalRow, - frozen: 2, - mergeSame: ["name"], - editable: (_i, key) => key.startsWith("per:"), - target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], - }; -} - -function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { rows } = s.material; - const lost = new Set(s.quantity.unmatched); - const keep = shown(rows, (row) => row.qty != null || row.amount != null); - return { - columns: [ - { key: "group", label: "구분", width: 96 }, - { key: "spec", label: "규격", width: 120 }, - { key: "unit", label: "단위", width: 56 }, - numberColumn({ key: "qty", label: "수량", width: 96 }), - numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), - numberColumn({ key: "amount", label: "합계", width: 96 }), - { key: "note", label: "비고", width: 96 }, - ], - rows: keep.map((at) => { - const row = rows[at]; - return { - group: row.group, // 위 줄과 같으면 세로 병합(201-1) - spec: row.spec, - unit: row.unit, - qty: row.qty, - surcharge: row.surcharge, - amount: row.amount, - note: row.panel - ? "할증 = 좌측 패널" - : row.key === DEDUCT_KEY - ? "토공 잔토처리에서 뺌" - : lost.has(row.key) - ? "2공구 맞춤 없음" - : "", - }; - }), - total: null, - frozen: 2, - mergeSame: ["group"], - editable: (i, key) => - key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, - target: (i) => [o.surcharge, rows[keep[i]].key, null], - }; -} - -const BUILD: Record Table> = { - material: totalTable, - concrete: concreteTable, - quantity: quantityTable, -}; - /** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderMaterialSheet( host: HTMLElement, @@ -315,21 +124,15 @@ export async function renderMaterialSheet( sheet: MaterialSheetId, ): Promise { host.replaceChildren(note("자재 집계표를 불러오는 중…")); - let payload: Payload; + let loaded: Awaited>; try { - payload = await request(`/projects/${projectId}/quantity/materials`); + loaded = await loadMaterials(projectId); } catch (error) { host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`)); return; } - const draft = readState("b08-materials", projectId); - let overrides: MaterialOverrides = draft ?? payload.overrides; - const calc = (): MaterialSheets => - computeMaterialSheets( - payload.source, - overrides, - liveSurcharge(projectId, payload.surcharge_pct), - ); + let { payload, overrides } = loaded; + const calc = (): MaterialSheets => loaded.calc(payload, overrides); let sheets = calc(); const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() }); @@ -356,15 +159,9 @@ export async function renderMaterialSheet( if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); - let built = BUILD[sheet](sheets, overrides); + let built = materialTable(sheet, sheets, overrides); const table = createDataTable({ - columns: built.columns, - rows: built.rows, - total: built.total, - frozen: built.frozen, - mergeSame: built.mergeSame, - empty: "", - caption: BASIS, + ...materialOptions(built), fillViewport: true, editable: (i, key) => built.editable(i, key), editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 @@ -388,7 +185,7 @@ export async function renderMaterialSheet( const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { - built = BUILD[sheet](sheets, overrides); + built = materialTable(sheet, sheets, overrides); table.setRows(built.rows, built.total); excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets); concreteNote.textContent = concreteWhy(sheets, payload.source); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 09a8f979d..1db32d83e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -12,6 +12,7 @@ * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · * [단가연결 풀기] — 창 = 연결 없음 → 단가연결 목록 → 옵션 값 창(276 · 옛 길) · 연결 있음 → * 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · B09 운반공 줄이 받음 + * 표 열 · 줄 짜기 = `B08_Quantity_Milestone_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; @@ -21,7 +22,8 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; -import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import type { MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { DEST_TEXT, milestoneColumns, milestoneTable } from "./B08_Quantity_Milestone_Table"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { liveMilestone, @@ -31,7 +33,6 @@ import { siteInfo, } from "./B08_Quantity_UI_Milestone_State"; import { - DEST_TEXT, editRoute, pickAtCell, routeView, @@ -39,9 +40,6 @@ import { type MilestoneView, } from "./B08_Quantity_UI_Milestone_Route"; -const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; -const ROAD = ["도로 구분 (km)"]; -const SUM = ["계 (km)"]; /** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */ const LAMP = "b08-milestone__lamp"; @@ -55,32 +53,6 @@ export function refText(ref: Record | null | undefined): string return `단가연결 — ${what.trim()}`; } -/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · - * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ -export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { - const roads: DataColumn[] = withBase - ? [ - { key: "s2", label: "2차포장", group: ROAD, ...KM }, - { key: "s1", label: "1차포장", group: ROAD, ...KM }, - ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), - { key: "un", label: "비포장", group: ROAD, ...KM }, - { key: "paved", label: "포장", group: SUM, ...KM }, - { key: "unpaved", label: "비포장", group: SUM, ...KM }, - ] - : [ - { key: "paved", label: "포장", group: ROAD, ...KM }, - { key: "un", label: "비포장", group: ROAD, ...KM }, - ]; - return [ - { key: "item", label: "항목", width: 134 }, - { key: "company", label: "거래처 이름", width: 120 }, - { key: "dest", label: "목적지", width: 72 }, - ...roads, - { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, - { key: "note", label: "비고", width: 160 }, - ]; -} - /** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { const data = liveMilestone(projectId); @@ -180,17 +152,11 @@ function tableView(projectId: string): MilestoneView { }); const refresh = (): void => { const data = liveMilestone(projectId); - const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); + const { options, rows, mode: next } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; - const fresh = makeTable( - milestoneColumns( - data.bases.length > 0, - rows.some((r) => r.pv > 0), - ), - ); + const fresh = makeTable(options.columns); table.root.replaceWith(fresh.root); table = fresh; } @@ -201,7 +167,7 @@ function tableView(projectId: string): MilestoneView { L2: unpaved, })); none.hidden = rows.length > 0; - table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + table.setRows(options.rows.map((r) => ({ ...r, dest: `${r.dest} ▾` }))); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index f7ea25721..cc02d0d09 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -15,8 +15,9 @@ import { type MapContextMenuItem, } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; +import { DEST_TEXT } from "./B08_Quantity_Milestone_Table"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -54,8 +55,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void { menu.open(box.left, box.bottom, items); } -export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; - /** 그 경로를 고쳐 캐시에 */ export function editRoute( projectId: string, diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 4d74c1927..34b70dce8 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -16,50 +16,28 @@ * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * 미연결 시설 = 모달 맨 위 「조합 연결」 → 바로 저장 → 탭 몸통을 새로 그림(154-2) * 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122) + * 자료 읽기 · 표 짜기 = `B08_Quantity_Summary_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createCard, showToast } from "@ui/ui_template_elements"; -import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; -import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { createDataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; -import { pipeStructureId } from "@ui/ui_template_item_picker_project"; -import { stationLabel } from "../common_util/common_util_station"; -import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import { combosAt, unlinkedAt } from "./B08_Quantity_Summary_Data"; import { - combosAt, - comboId, - linkedOnly, - pipeOwnerId, - summaryColumns, - summaryStations, - type ComboColumn, - type ComboLabel, - type LinkedSnapshot, - type PlacedItem, - type UnlinkedFacility, - unassignedText, - unlinkedAt, - widthOf, -} from "./B08_Quantity_Summary_Data"; -import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; + LAMP_CLASS, + loadSummary, + summaryTable, + type SummaryView, +} from "./B08_Quantity_Summary_Table"; import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ -export async function stationInterval(projectId: string): Promise { - const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); - return latest.route_params?.station_interval_m || 20; -} - function note(text: string): HTMLElement { const node = document.createElement("p"); node.className = "ui-general-block__empty"; @@ -67,128 +45,14 @@ function note(text: string): HTMLElement { return node; } -/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ -interface PipeColumn { - id: string; - group: string[]; - label: string; - combos: string[]; -} -interface PipeSummary { - columns: PipeColumn[]; - rows: Array<{ chainage_m: number; values: Record }>; - total: Record; - notes: string[]; - /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ - types: string[]; - one_length_m: number | null; - pipes: PipeLength[]; - /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ - linked: Record; - /** 미연결 시설(154-1) */ - unlinked: UnlinkedFacility[]; - /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ - facilities: UnlinkedFacility[]; -} - -async function fetchPipeSummary(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { - credentials: "include", - }); - if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); - return response.json(); -} - -const PIPE_TOP = "관 공"; -/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ -const LAMP_CLASS = "b08-summary__lamp"; -/** 모두 연결 = 같은 램프 초록(247-1) */ -const LINKED_CLASS = `${LAMP_CLASS} is-linked`; -const LAMP_W = 14; -const stationKey = (station: number): number => Math.round(station * 100); -/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ -const placesOf = (values: number[]): number => - Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); - -/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ -function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { - const top = - summary.columns - .filter((column) => column.group[0] === PIPE_TOP) - .flatMap((column) => column.combos) - .map(comboPath) - .find((path) => !!path) ?? PIPE_TOP; - return summary.columns.map((column) => { - const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; - const values = [ - ...summary.rows.map((row) => row.values[column.id]), - summary.total[column.id], - ].filter((value): value is number => typeof value === "number"); - const decimals = placesOf(values); - const texts = values.map((value) => - value.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }), - ); - return { - key: column.id, - label: column.label, - group, - kind: "number", - decimals, - title: [...group, column.label].join(" › "), - width: widthOf(column.label, ...texts), - }; - }); -} - function buildTable( - stations: number[], - columns: ComboColumn[], - pipe: PipeSummary | null, - comboPath: (id: string) => string | null, - interval: number, - unlinked: readonly UnlinkedFacility[], + view: SummaryView, onRowClick: (station: number, label: string) => void, ): HTMLElement { - const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); - const total: DataRow | null = pipe?.columns.length - ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } - : null; - const labels = stations.map((station) => stationLabel(station, interval)); - const stationHead = L("B08_Quantity_Summary_Station"); - // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) - const missing = stations.map((station) => - unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), - ); - // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 - const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); + const { options, labels, missing } = summaryTable(view); const table = createDataTable({ - columns: [ - { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, - ...columns.map((column) => ({ - key: column.id, - label: column.name, - group: [column.group || L("B08_Quantity_Summary_MissingGroup")], - title: summaryTooltip(column.item), - className: column.missing ? "is-missing" : undefined, - width: widthOf(column.name), - })), - ...(pipe ? pipeColumns(pipe, comboPath) : []), - ], - // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 - rows: stations.map((station, i) => ({ - station: labels[i], - ...values.get(stationKey(station)), - })), - total, - onRowClick: (i) => onRowClick(stations[i], labels[i]), - cellClass: (i, key) => - key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, - frozen: 1, - empty: "", - caption: L("B08_Quantity_Tab_StructureSummary"), + ...options, + onRowClick: (i) => onRowClick(view.stations[i], labels[i]), fillViewport: true, }); table.root.dataset.b08Summary = "table"; @@ -220,59 +84,17 @@ async function openSheets(request: SheetsRequest): Promise { export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pipes, interval, pipe] = await Promise.all([ - fetchStructures(projectId), - // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 - fetchDetailPipePoints(projectId).then( - (response) => response.pipe_points, - () => [], - ), - stationInterval(projectId).catch(() => 20), - // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 - fetchPipeSummary(projectId).catch((error: unknown) => String(error)), - ]); - const saved = typeof pipe === "string" ? null : pipe; - // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) - const linked = saved?.linked ?? {}; - const paths = new Map( - Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), - ); - const describe = (item: SummaryItem): ComboLabel | null => { - const [group = "", ...rest] = paths.get(comboId(item)) ?? []; - return group ? { group, name: rest.join(" ") || group } : null; - }; - const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; - // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) - const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; - const placed = linkedOnly( - pipes.map((point) => ({ - ...point, - structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), - })), - linked, - ); - const mine = linkedOnly(structures.structures, linked); - const things: PlacedItem[] = [...mine, ...placed]; - const unlinked = saved?.unlinked ?? []; - const stations = summaryStations([...things, ...unlinked]); - // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) - const served = new Set(saved?.types ?? []); - const others = [ - ...mine.filter((item) => !served.has(item.type_id)), - ...placed.filter((point) => !served.has(point.facility ?? "pipe")), - ]; - const columns = summaryColumns(others, describe); - const live = (): PipeSummary | null => - saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)); - const summary = live(); + const view = await loadSummary(projectId); + const { stations, things, linked, describe, saved } = view; + const summary = view.live(); const parts: HTMLElement[] = []; - if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); + if (view.error) parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${view.error}`)); if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄 - if (!columns.length && !summary?.columns.length) + if (!view.columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => stations.length - ? buildTable(stations, columns, live(), comboPath, interval, unlinked, (station, label) => + ? buildTable(view, (station, label) => openSheets({ projectId, station: label, diff --git a/B09_Estimation/draft/B09_Draft_Print_Quantity.ts b/B09_Estimation/draft/B09_Draft_Print_Quantity.ts new file mode 100644 index 000000000..96a06d90f --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Quantity.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B09_Draft_Print_Quantity.ts + * 인쇄 미리보기(PLAN 302-3 · 초안) 「수량산출」 영역 — B08 탭 표를 화면 없이 PrintSheet 로 모음. + * + * 차례 = B08 탭 차례 — 구조물 집계표 → 자재 셋(총괄 · 콘크리트소요 · 자재수량) → 토공 넷(토공집계표 · + * 토적표 · 평균운반거리 · 종방향 무대운반거리) → 수리집수면적표(배수지점 측점마다 한 장) → 거리이정표 + * 값 = B08 화면과 같은 길(서버 GET + 화면 캐시 · [저장] 전 값이면 화면에 보이는 그 값) · + * 표 짜기 = B08 화면이 쓰는 같은 함수(`B08_Quantity_*_Table(s)`) + * 수리 = 화면 계산서(두 카드)를 구분 · 항목 · 값 표로 · 계산 = 서버(화면과 같은 POST) + * 거리이정표 = 표 + 경로 지도 그림(사용자 그림 우선 · 없으면 서버 캡처 PNG · data: 주소) + * 영역 하나가 못 읽히면 그 자리에 까닭 한 장(나머지는 그대로) + * 구조물 조합 도면은 여기 없음(도면 영역 · 302-2) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { stationLabel } from "../../common_util/common_util_station"; +import { + EARTHWORK_SHEETS, + earthworkSheetOptions, + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + loadEarthwork, +} from "../../B08_Quantity/B08_Quantity_Earthwork_Tables"; +import { + hydroCalc, + hydroInputs, + hydroTable, + loadHydro, +} from "../../B08_Quantity/B08_Quantity_Hydro_Table"; +import { + loadMaterials, + materialOptions, + materialTable, + type MaterialSheetId, +} from "../../B08_Quantity/B08_Quantity_Material_Tables"; +import { milestoneTable } from "../../B08_Quantity/B08_Quantity_Milestone_Table"; +import { loadSummary, summaryTable } from "../../B08_Quantity/B08_Quantity_Summary_Table"; +import { + liveMilestone, + loadMilestone, + routeImages, + siteInfo, +} from "../../B08_Quantity/B08_Quantity_UI_Milestone_State"; +import type { CollectSheets, PrintSheet } from "./B09_Draft_Print_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 자재 보조 탭 차례(B08 화면 `SHEETS`) */ +const MATERIAL_SHEETS: Array<[MaterialSheetId, keyof typeof ui_locales]> = [ + ["material", "B08_Quantity_Tab_MaterialTotal"], + ["concrete", "B08_Quantity_Tab_ConcreteMaterial"], + ["quantity", "B08_Quantity_Tab_MaterialQuantity"], +]; + +/** 표 한 장 — 열이 많으면 가로 쪽 */ +const sheet = (id: string, name: string, table: DataTableOptions): PrintSheet => ({ + id, + name, + table, + orientation: table.columns.length > 8 ? "landscape" : "portrait", +}); + +/** 까닭 한 장 — 못 읽은 영역 · 빈 표 자리 */ +const noteSheet = (id: string, name: string, text: string): PrintSheet => + sheet(id, name, { + columns: [{ key: "note", label: "알림", width: 480 }], + rows: [{ note: text }], + }); + +/** 영역 하나 — 실패하면 까닭 한 장 */ +const area = (id: string, name: string, make: () => Promise): Promise => + make().catch((error: unknown) => [ + noteSheet( + id, + name, + `불러오지 못했습니다 — ${error instanceof Error ? error.message : String(error)}`, + ), + ]); + +async function summarySheets(projectId: string): Promise { + const name = L("B08_Quantity_Tab_StructureSummary"); + const view = await loadSummary(projectId); + if (!view.stations.length) + return [noteSheet("summary", name, L("B08_Quantity_Summary_NoStation"))]; + return [sheet("summary", name, summaryTable(view).options)]; +} + +async function materialSheets(projectId: string): Promise { + const { payload, overrides, calc } = await loadMaterials(projectId); + const sheets = calc(payload, overrides); + return MATERIAL_SHEETS.map(([id, label]) => + sheet(`material:${id}`, L(label), materialOptions(materialTable(id, sheets, overrides))), + ); +} + +async function earthworkSheets(projectId: string): Promise { + const { table, interval } = await loadEarthwork(projectId); + return EARTHWORK_SHEETS.map(({ id, label }) => { + const key = `earthwork:${id}`; + if (id === "summary") return sheet(key, label, earthworkSummaryOptions(table)); + if (id === "sheet") + return table.rows.length + ? sheet(key, label, earthworkSheetOptions(table, interval)) + : noteSheet(key, label, "측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."); + const { groups, caption } = HAUL_SHEETS[id](table); + const { options, averages } = haulOptions(groups, caption); + // 화면 표 아래 평균운반거리 줄 = 쪽엔 본문 끝 줄(번호 칸 글) + return sheet(key, label, { + ...options, + rows: [...options.rows, ...averages.map((no) => ({ no }))], + }); + }); +} + +async function hydroSheets(projectId: string): Promise { + const head = L("B08_Quantity_Tab_Hydrology"); + const { sheet: payload, interval, drafts } = await loadHydro(projectId); + if (!payload.stations.length) + return [ + noteSheet( + "hydro", + head, + "저장된 배수지점(관 지점)이 없습니다 — 배수유역 · 종단에서 관을 저장하세요.", + ), + ]; + return Promise.all( + payload.stations.map(async (station) => { + const inputs = hydroInputs(payload, station, drafts); + const result = await hydroCalc(projectId, inputs).catch(() => null); + const label = stationLabel(station.chainage_m, interval); + return sheet( + `hydro:${station.chainage_m.toFixed(3)}`, + `${head} ${label}${station.kind ? ` ${station.kind}` : ""}`, + hydroTable(label, station.kind, inputs, result, payload), + ); + }), + ); +} + +/** 그림 → data: 주소(서버 PDF 가 쿠키 없이 그림) */ +async function dataUrl(url: string): Promise { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const bytes = new Uint8Array(await blob.arrayBuffer()); + let text = ""; + for (let i = 0; i < bytes.length; i += 0x8000) + text += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return `data:${blob.type || "image/png"};base64,${btoa(text)}`; +} + +async function milestoneSheets(projectId: string): Promise { + const name = L("B08_Quantity_Tab_Milestone"); + await loadMilestone(projectId, true); + const data = liveMilestone(projectId); + const { options } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); + const images = routeImages(); + const pictures = await Promise.all( + data.routes + .filter((route) => images[route.id]) + .map(async (route, i) => ({ + src: await dataUrl( + `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(route.id)}`, + ).catch(() => ""), + caption: route.name.trim() || route.rows[0]?.item || `거래처 ${i + 1}`, + })), + ); + return [{ ...sheet("milestone", name, options), images: pictures.filter((p) => p.src) }]; +} + +export const collectQuantitySheets: CollectSheets = async (projectId) => { + const parts = await Promise.all([ + area("summary", L("B08_Quantity_Tab_StructureSummary"), () => summarySheets(projectId)), + area("material", L("B08_Quantity_Tab_Materials"), () => materialSheets(projectId)), + area("earthwork", L("B08_Quantity_Tab_Earthwork"), () => earthworkSheets(projectId)), + area("hydro", L("B08_Quantity_Tab_Hydrology"), () => hydroSheets(projectId)), + area("milestone", L("B08_Quantity_Tab_Milestone"), () => milestoneSheets(projectId)), + ]); + return parts.flat(); +}; diff --git a/resources/tester/fixtures/b08_302_3_print_quantity.json b/resources/tester/fixtures/b08_302_3_print_quantity.json new file mode 100644 index 000000000..3e14de82f --- /dev/null +++ b/resources/tester/fixtures/b08_302_3_print_quantity.json @@ -0,0 +1,2268 @@ +{ + "_from": "검증_sub8 GET 응답(구조물 집계표만 손 견본) — test_302_3_print_quantity", + "/route/structures": { + "status": "success", + "project_id": "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a", + "revision": 0, + "structures": [] + }, + "/drainage/pipe-points": { + "pipe_points": [ + { + "chainage_m": 85.05, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1000, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 173.09, + "source": "spacing", + "options": { + "pipe_kind": "파형강관", + "pipe_diameter_mm": "1000", + "ford_width_m": 10, + "wing_in": "있음", + "wing_in_height_m": 1, + "wing_in_length_m": 2, + "wing_in_angle_deg": 45, + "wing_out": "있음", + "wing_out_height_m": 1, + "wing_out_length_m": 2, + "wing_out_angle_deg": 45 + } + }, + { + "chainage_m": 258.12, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 352.14, + "source": "stream", + "options": { + "pipe_kind": "파형강관", + "pipe_diameter_mm": "1000", + "ford_width_m": 10, + "wing_in": "있음", + "wing_in_height_m": 1, + "wing_in_length_m": 2, + "wing_in_angle_deg": 45, + "wing_out": "있음", + "wing_out_height_m": 1, + "wing_out_length_m": 2, + "wing_out_angle_deg": 45 + } + }, + { + "chainage_m": 440.24, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 533.98, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 619.71, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1500, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 720.44, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 804.18, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 899.91, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 982.64, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1500, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + } + ] + }, + "/route/latest": { + "route_params": { + "station_interval_m": 20 + } + }, + "/quantity/summary": { + "columns": [ + { + "id": "pipe:len|800", + "group": [ + "관 공", + "Φ800m/m", + "관매설" + ], + "label": "m", + "combos": [] + }, + { + "id": "pipe:cp|800", + "group": [ + "관 공", + "Φ800m/m", + "커플링" + ], + "label": "ea", + "combos": [] + } + ], + "rows": [ + { + "chainage_m": 20.0, + "values": { + "pipe:len|800": 8, + "pipe:cp|800": 1 + } + }, + { + "chainage_m": 60.0, + "values": { + "pipe:len|800": 12.5, + "pipe:cp|800": 2 + } + } + ], + "total": { + "pipe:len|800": 20.5, + "pipe:cp|800": 3 + }, + "sources": [], + "notes": [ + "견본 알림" + ], + "one_length_m": null, + "types": [ + "pipe" + ], + "pipes": [], + "linked": {}, + "unlinked": [ + { + "id": "pipe@20.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 20.0, + "slots": [ + { + "copy": "pipe@20.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@100.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 100.0, + "slots": [ + { + "copy": "pipe@100.000", + "group": null, + "item": null, + "linked": false + } + ] + } + ], + "facilities": [ + { + "id": "pipe@20.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 20.0, + "slots": [ + { + "copy": "pipe@20.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@60.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 60.0, + "slots": [ + { + "copy": "pipe@60.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@100.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 100.0, + "slots": [ + { + "copy": "pipe@100.000", + "group": null, + "item": null, + "linked": false + } + ] + } + ] + }, + "/quantity/materials": { + "source": { + "columns": [], + "total": {}, + "snapshot": { + "materials": [], + "units": {} + } + }, + "overrides": { + "units": {}, + "mix": {}, + "surcharge": {} + }, + "surcharge_pct": { + "sand": 12, + "gravel": 5, + "cement": 3 + }, + "notes": [ + "조합 사본 없는 시설 11곳 — 노랑 램프 줄을 눌러 조합 연결" + ], + "sheets": { + "quantity": { + "materials": [ + { + "key": "concrete_machine", + "name": "콘크리트", + "spec": "기계", + "unit": "㎥" + }, + { + "key": "mortar_1_3", + "name": "모르터", + "spec": "1:3", + "unit": "㎥" + } + ], + "rows": [], + "total": {}, + "unmatched": [] + }, + "concrete": { + "rows": [ + { + "key": "c25", + "name": "콘크리트", + "spec": "Φ25mm", + "qty": null, + "per": { + "cement": 346.0, + "sand": 0.487, + "gravel": 0.562 + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "c40", + "name": "콘크리트", + "spec": "Φ40mm", + "qty": null, + "per": { + "cement": 323.0, + "sand": 0.456, + "gravel": 0.612 + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "c40_plain", + "name": "콘크리트", + "spec": "Φ40mm(무근)", + "qty": null, + "per": { + "cement": null, + "sand": null, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "m12", + "name": "모르터", + "spec": "1:2", + "qty": null, + "per": { + "cement": 680.0, + "sand": 0.98, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "m13", + "name": "모르터", + "spec": "1:3", + "qty": null, + "per": { + "cement": 510.0, + "sand": 1.1, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + } + ], + "total": {} + }, + "material": { + "rows": [ + { + "key": "sand", + "group": "골재", + "spec": "모래", + "unit": "㎥", + "qty": null, + "surcharge": 12.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": true + }, + { + "key": "gravel", + "group": "골재", + "spec": "자갈", + "unit": "㎥", + "qty": null, + "surcharge": 5.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": true + }, + { + "key": "rubble", + "group": "골재", + "spec": "막자갈", + "unit": "㎥", + "qty": null, + "surcharge": 4.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "mixed_aggregate", + "group": "골재", + "spec": "혼합석", + "unit": "㎥", + "qty": null, + "surcharge": 4.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "remicon_25_24_15", + "group": "레미콘", + "spec": "25-24-15", + "unit": "㎥", + "qty": null, + "surcharge": 1.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "remicon_25_21_80", + "group": "레미콘", + "spec": "25-21-80", + "unit": "㎥", + "qty": null, + "surcharge": 2.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "remicon_25_18_80", + "group": "레미콘", + "spec": "25-18-80", + "unit": "㎥", + "qty": null, + "surcharge": 2.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d13", + "group": "이형철근", + "spec": "D13mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d16", + "group": "이형철근", + "spec": "D16mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d19", + "group": "이형철근", + "spec": "D19mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d22", + "group": "이형철근", + "spec": "D22mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d25", + "group": "이형철근", + "spec": "D25mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "wire_mesh", + "group": "와이어메쉬", + "spec": "#6, 100×100", + "unit": "㎡", + "qty": null, + "surcharge": null, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "cement", + "group": "시멘트", + "spec": "40kg/대", + "unit": "대", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 0, + "rounding": "round", + "panel": true + }, + { + "key": "sod", + "group": "떼", + "spec": "", + "unit": "㎡", + "qty": null, + "surcharge": 10.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "stone_buy_35", + "group": "깬돌", + "spec": "L3=35cm", + "unit": "ton", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "stone_buy_45", + "group": "깬돌", + "spec": "L3=45cm", + "unit": "ton", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "revet_block", + "group": "호안블럭", + "spec": "", + "unit": "㎡", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "flume_400c", + "group": "수로관", + "spec": "400C", + "unit": "본", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "steel_grating", + "group": "스틸그레이팅", + "spec": "", + "unit": "EA", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "picked_deduct", + "group": "채집석 공제", + "spec": "돌(채집) · 고임돌 · 막자갈", + "unit": "㎥", + "qty": null, + "surcharge": null, + "amount": null, + "places": 6, + "rounding": "round", + "panel": false + } + ] + } + } + }, + "/quantity/earthwork": { + "method": "average_end_area", + "conversion_factors": { + "soil": { + "loose": 1.25, + "compacted": 0.9 + }, + "ripping_rock": { + "loose": 1.35, + "compacted": 1.1649999999999998 + }, + "blasting_rock": { + "loose": 1.6, + "compacted": 1.1649999999999998 + } + }, + "rows": [ + { + "chainage_m": 0.0, + "distance_m": 0.0, + "cut_soil_area_m2": 0.2467, + "cut_soil_volume_m3": 0.0, + "cut_soil_adjusted_m3": 0.0, + "cut_rock_area_m2": 0.0, + "cut_rock_volume_m3": 0.0, + "cut_rock_adjusted_m3": 0.0, + "ditch_soil_area_m2": 0.1485, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.0, + "ditch_rock_volume_m3": 0.0, + "ditch_rock_adjusted_m3": 0.0, + "adjusted_total_m3": 0.0, + "fill_area_m2": 0.0994, + "fill_volume_m3": 0.0, + "diverted_m3": 0.0, + "balance_m3": 0.0, + "cumulative_m3": 0.0, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 0.3133420016531395, + "area_m2": null + }, + "grading_cut": { + "length_m": 0, + "area_m2": null + }, + "tree_removal_fill": { + "length_m": 0.3133420016531395, + "area_m2": null + }, + "tree_removal_cut": { + "length_m": 0, + "area_m2": null + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": null + }, + "benching": { + "length_m": 0.2585, + "area_m2": null + } + }, + "cut_soft_rock_area_m2": 0.0, + "cut_hard_rock_area_m2": 0.0, + "cut_soft_rock_volume_m3": 0.0, + "cut_hard_rock_volume_m3": 0.0, + "cut_soft_rock_adjusted_m3": 0.0, + "cut_hard_rock_adjusted_m3": 0.0, + "ditch_soft_rock_area_m2": 0.0, + "ditch_hard_rock_area_m2": 0.0, + "ditch_soft_rock_volume_m3": 0.0, + "ditch_hard_rock_volume_m3": 0.0, + "ditch_soft_rock_adjusted_m3": 0.0, + "ditch_hard_rock_adjusted_m3": 0.0 + }, + { + "chainage_m": 20.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.7856, + "cut_soil_volume_m3": 20.323, + "cut_soil_adjusted_m3": 18.2907, + "cut_rock_area_m2": 1.5424, + "cut_rock_volume_m3": 15.424, + "cut_rock_adjusted_m3": 17.968959999999996, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 1.4849999999999999, + "ditch_soil_adjusted_m3": 1.3365, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 1.4849999999999999, + "ditch_rock_adjusted_m3": 1.7300249999999995, + "adjusted_total_m3": 36.25966, + "fill_area_m2": 6.6947, + "fill_volume_m3": 67.941, + "diverted_m3": 36.25966, + "balance_m3": -31.681340000000006, + "cumulative_m3": -31.681340000000006, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 8.905743700766662, + "area_m2": 92.19085702419802 + }, + "grading_cut": { + "length_m": 4.23533123091777, + "area_m2": 42.353312309177696 + }, + "tree_removal_fill": { + "length_m": 8.905743700766662, + "area_m2": 92.19085702419802 + }, + "tree_removal_cut": { + "length_m": 4.23533123091777, + "area_m2": 42.353312309177696 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 10.6474, + "area_m2": 109.059 + } + }, + "cut_soft_rock_area_m2": 1.38816, + "cut_hard_rock_area_m2": 0.15423999999999993, + "cut_soft_rock_volume_m3": 13.881599999999999, + "cut_hard_rock_volume_m3": 1.5424000000000007, + "cut_soft_rock_adjusted_m3": 16.172063999999995, + "cut_hard_rock_adjusted_m3": 1.7968960000000003, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 1.3364999999999998, + "ditch_hard_rock_volume_m3": 0.14850000000000008, + "ditch_soft_rock_adjusted_m3": 1.5570224999999995, + "ditch_hard_rock_adjusted_m3": 0.17300249999999995 + }, + { + "chainage_m": 40.0, + "distance_m": 20.0, + "cut_soil_area_m2": 2.5631, + "cut_soil_volume_m3": 43.487, + "cut_soil_adjusted_m3": 39.1383, + "cut_rock_area_m2": 2.1052, + "cut_rock_volume_m3": 36.476, + "cut_rock_adjusted_m3": 42.49453999999999, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 81.63283999999999, + "fill_area_m2": 3.1265, + "fill_volume_m3": 98.21200000000002, + "diverted_m3": 81.63283999999999, + "balance_m3": -16.57916000000003, + "cumulative_m3": -48.260500000000036, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 5.531131936823837, + "area_m2": 144.368756375905 + }, + "grading_cut": { + "length_m": 7.171260215611091, + "area_m2": 114.06591446528861 + }, + "tree_removal_fill": { + "length_m": 5.531131936823837, + "area_m2": 144.368756375905 + }, + "tree_removal_cut": { + "length_m": 7.171260215611091, + "area_m2": 114.06591446528861 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 6.2807, + "area_m2": 169.281 + } + }, + "cut_soft_rock_area_m2": 1.89468, + "cut_hard_rock_area_m2": 0.21052000000000004, + "cut_soft_rock_volume_m3": 32.8284, + "cut_hard_rock_volume_m3": 3.647599999999997, + "cut_soft_rock_adjusted_m3": 38.24508599999999, + "cut_hard_rock_adjusted_m3": 4.249454, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 60.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.9285, + "cut_soil_volume_m3": 44.916, + "cut_soil_adjusted_m3": 40.4244, + "cut_rock_area_m2": 2.5755, + "cut_rock_volume_m3": 46.807, + "cut_rock_adjusted_m3": 54.53015499999999, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 94.954555, + "fill_area_m2": 6.1454, + "fill_volume_m3": 92.71900000000001, + "diverted_m3": 92.71900000000001, + "balance_m3": 2.235554999999991, + "cumulative_m3": -46.024945000000045, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 7.089116136811305, + "area_m2": 126.20248073635142 + }, + "grading_cut": { + "length_m": 5.436580202518828, + "area_m2": 126.07840418129918 + }, + "tree_removal_fill": { + "length_m": 7.089116136811305, + "area_m2": 126.20248073635142 + }, + "tree_removal_cut": { + "length_m": 5.436580202518828, + "area_m2": 126.07840418129918 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 7.421, + "area_m2": 137.017 + } + }, + "cut_soft_rock_area_m2": 2.3179499999999997, + "cut_hard_rock_area_m2": 0.25755000000000017, + "cut_soft_rock_volume_m3": 42.1263, + "cut_hard_rock_volume_m3": 4.680700000000002, + "cut_soft_rock_adjusted_m3": 49.077139499999994, + "cut_hard_rock_adjusted_m3": 5.453015499999999, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 80.0, + "distance_m": 20.0, + "cut_soil_area_m2": 3.3074, + "cut_soil_volume_m3": 52.359, + "cut_soil_adjusted_m3": 47.1231, + "cut_rock_area_m2": 8.608, + "cut_rock_volume_m3": 111.83500000000001, + "cut_rock_adjusted_m3": 130.28777499999998, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 177.41087499999998, + "fill_area_m2": 9.5931, + "fill_volume_m3": 157.385, + "diverted_m3": 157.385, + "balance_m3": 20.025874999999985, + "cumulative_m3": -25.99907000000006, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 10.41368758963897, + "area_m2": 175.02803726450276 + }, + "grading_cut": { + "length_m": 6.176411853142405, + "area_m2": 116.12992055661232 + }, + "tree_removal_fill": { + "length_m": 10.41368758963897, + "area_m2": 175.02803726450276 + }, + "tree_removal_cut": { + "length_m": 6.176411853142405, + "area_m2": 116.12992055661232 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 6.0, + "area_m2": 100.0 + }, + "benching": { + "length_m": 11.036, + "area_m2": 184.57 + } + }, + "cut_soft_rock_area_m2": 7.7472, + "cut_hard_rock_area_m2": 0.8608000000000002, + "cut_soft_rock_volume_m3": 100.65150000000001, + "cut_hard_rock_volume_m3": 11.183499999999995, + "cut_soft_rock_adjusted_m3": 117.25899749999999, + "cut_hard_rock_adjusted_m3": 13.02877749999999, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 85.052, + "distance_m": 5.052000000000007, + "cut_soil_area_m2": 2.3174, + "cut_soil_volume_m3": 14.20824480000002, + "cut_soil_adjusted_m3": 12.787420320000018, + "cut_rock_area_m2": 11.2426, + "cut_rock_volume_m3": 50.14261560000006, + "cut_rock_adjusted_m3": 58.416147174000066, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 0.7502220000000009, + "ditch_rock_adjusted_m3": 0.874008630000001, + "adjusted_total_m3": 71.20356749400008, + "fill_area_m2": 0.711, + "fill_volume_m3": 26.028156600000035, + "diverted_m3": 26.028156600000035, + "balance_m3": 45.17541089400005, + "cumulative_m3": 19.176340893999992, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 0.6508114627140598, + "area_m2": 27.94892460624379 + }, + "grading_cut": { + "length_m": 10.577233048615332, + "area_m2": 42.3197070218401 + }, + "tree_removal_fill": { + "length_m": 0.6508114627140598, + "area_m2": 27.94892460624379 + }, + "tree_removal_cut": { + "length_m": 10.577233048615332, + "area_m2": 42.3197070218401 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 6.0, + "area_m2": 30.31200000000004 + }, + "benching": { + "length_m": 1.7865, + "area_m2": 32.38963500000004 + } + }, + "cut_soft_rock_area_m2": 10.11834, + "cut_hard_rock_area_m2": 1.1242599999999996, + "cut_soft_rock_volume_m3": 45.12835404000006, + "cut_hard_rock_volume_m3": 5.014261560000001, + "cut_soft_rock_adjusted_m3": 52.57453245660006, + "cut_hard_rock_adjusted_m3": 5.841614717400006, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 0.6751998000000009, + "ditch_hard_rock_volume_m3": 0.07502220000000004, + "ditch_soft_rock_adjusted_m3": 0.7866077670000009, + "ditch_hard_rock_adjusted_m3": 0.08740086300000005 + }, + { + "chainage_m": 100.0, + "distance_m": 14.947999999999993, + "cut_soil_area_m2": 1.9296, + "cut_soil_volume_m3": 31.742077999999985, + "cut_soil_adjusted_m3": 28.567870199999987, + "cut_rock_area_m2": 3.8204, + "cut_rock_volume_m3": 112.58086199999994, + "cut_rock_adjusted_m3": 131.15670422999992, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.219777999999999, + "ditch_rock_adjusted_m3": 2.5860413699999985, + "adjusted_total_m3": 159.7245744299999, + "fill_area_m2": 6.678, + "fill_volume_m3": 55.22538599999998, + "diverted_m3": 55.22538599999998, + "balance_m3": 104.49918842999992, + "cumulative_m3": 123.67552932399991, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 7.473138706027457, + "area_m2": 60.718403561174064 + }, + "grading_cut": { + "length_m": 3.6186822406822006, + "area_m2": 106.10027087220972 + }, + "tree_removal_fill": { + "length_m": 7.473138706027457, + "area_m2": 60.718403561174064 + }, + "tree_removal_cut": { + "length_m": 3.6186822406822006, + "area_m2": 106.10027087220972 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 74.73999999999997 + }, + "benching": { + "length_m": 7.3335, + "area_m2": 68.16287999999996 + } + }, + "cut_soft_rock_area_m2": 3.4383599999999994, + "cut_hard_rock_area_m2": 0.3820400000000004, + "cut_soft_rock_volume_m3": 101.32277579999995, + "cut_hard_rock_volume_m3": 11.258086199999994, + "cut_soft_rock_adjusted_m3": 118.04103380699993, + "cut_hard_rock_adjusted_m3": 13.115670422999983, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 1.997800199999999, + "ditch_hard_rock_volume_m3": 0.2219777999999999, + "ditch_soft_rock_adjusted_m3": 2.3274372329999986, + "ditch_hard_rock_adjusted_m3": 0.25860413699999985 + }, + { + "chainage_m": 120.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.1459, + "cut_soil_volume_m3": 30.755, + "cut_soil_adjusted_m3": 27.6795, + "cut_rock_area_m2": 0.5294, + "cut_rock_volume_m3": 43.498000000000005, + "cut_rock_adjusted_m3": 50.675169999999994, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 78.35467, + "fill_area_m2": 1.7035, + "fill_volume_m3": 83.815, + "diverted_m3": 78.35467, + "balance_m3": -5.460329999999999, + "cumulative_m3": 118.21519932399991, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 4.7441020248562396, + "area_m2": 122.17240730883695 + }, + "grading_cut": { + "length_m": 2.184396822180933, + "area_m2": 58.03079062863134 + }, + "tree_removal_fill": { + "length_m": 4.7441020248562396, + "area_m2": 122.17240730883695 + }, + "tree_removal_cut": { + "length_m": 2.184396822180933, + "area_m2": 58.03079062863134 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 5.5333, + "area_m2": 128.668 + } + }, + "cut_soft_rock_area_m2": 0.47646, + "cut_hard_rock_area_m2": 0.05293999999999999, + "cut_soft_rock_volume_m3": 39.1482, + "cut_hard_rock_volume_m3": 4.349800000000002, + "cut_soft_rock_adjusted_m3": 45.60765299999999, + "cut_hard_rock_adjusted_m3": 5.067517000000002, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + } + ], + "totals": { + "distance_m": 1078.01, + "cut_soil_volume_m3": 2500.31308435, + "cut_soil_adjusted_m3": 2250.2817759150003, + "cut_rock_volume_m3": 3763.35761765, + "cut_rock_adjusted_m3": 4384.31162456225, + "ditch_soil_volume_m3": 1.9709999999999999, + "ditch_soil_adjusted_m3": 1.7739, + "ditch_rock_volume_m3": 156.7762425, + "ditch_rock_adjusted_m3": 182.64432251249994, + "adjusted_total_m3": 6634.59340047725, + "fill_volume_m3": 19959.30884585, + "diverted_m3": 6217.9639954512495, + "balance_m3": -13324.71544537275, + "cut_ripping_rock_volume_m3": 3763.35761765, + "cut_ripping_rock_adjusted_m3": 4384.31162456225, + "ditch_ripping_rock_volume_m3": 156.7762425, + "cut_blasting_rock_volume_m3": 0, + "cut_blasting_rock_adjusted_m3": 0, + "ditch_blasting_rock_volume_m3": 0, + "grading_fill_area_m2": 16643.147934515546, + "grading_cut_area_m2": 5638.928516414395, + "tree_removal_fill_area_m2": 16643.147934515546, + "tree_removal_cut_area_m2": 5638.928516414395, + "grubbing_area_m2": 0.0, + "road_surface_area_m2": 4976.1622767, + "benching_area_m2": 17827.2799325, + "cut_soft_rock_volume_m3": 3387.021855885, + "cut_hard_rock_volume_m3": 376.3357617649999, + "cut_soft_rock_adjusted_m3": 3945.8804621060244, + "cut_hard_rock_adjusted_m3": 438.43116245622514, + "ditch_soft_rock_volume_m3": 141.09861825, + "ditch_hard_rock_volume_m3": 15.677624250000008, + "ditch_soft_rock_adjusted_m3": 164.37989026124993, + "ditch_hard_rock_adjusted_m3": 18.264432251250014 + }, + "station_count": 8, + "summary": [ + { + "group": "흙깍기", + "kind": "토사", + "spec": "기계(굴삭기)", + "unit": "㎥", + "value": 2500.0, + "note": "" + }, + { + "group": "흙깍기", + "kind": "풍화암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "흙깍기", + "kind": "연암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": 3387.0, + "note": "" + }, + { + "group": "흙깍기", + "kind": "보통암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "흙깍기", + "kind": "경암", + "spec": "발파 + 굴삭기", + "unit": "㎥", + "value": 376.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "토사", + "spec": "기계", + "unit": "㎥", + "value": 2.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "풍화암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "연암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": 141.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "보통암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "경암", + "spec": "발파 + 굴삭기", + "unit": "㎥", + "value": 16.0, + "note": "" + }, + { + "group": "보정량계", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 6635.0, + "note": "" + }, + { + "group": "성토", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 19959.0, + "note": "" + }, + { + "group": "유용토 소계", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 6296.98399545125, + "note": "" + }, + { + "group": "횡방향유용토", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 6217.9639954512495, + "note": "유용토" + }, + { + "group": "종방향유용토", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 79.02, + "note": "유용토" + }, + { + "group": "토사운반", + "kind": "", + "spec": "BH+DT+BH", + "unit": "㎥", + "value": 174.18, + "note": "" + }, + { + "group": "토사운반", + "kind": "", + "spec": "19 TON B/D", + "unit": "㎥", + "value": 152.29999999999998, + "note": "" + }, + { + "group": "잔토처리", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": -13325.0, + "note": "" + }, + { + "group": "채집석 공제", + "kind": "", + "spec": "돌(채집) · 고임돌 · 막자갈", + "unit": "㎥", + "value": null, + "note": "잔토처리에서 뺌" + }, + { + "group": "층따기", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 17827.2799325, + "note": "" + }, + { + "group": "성토면다짐", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "표토제거", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "" + }, + { + "group": "초류종자살포", + "kind": "", + "spec": "씨드스프레이", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "법면보호공", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "" + }, + { + "group": "지장목제거", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "운반성토", + "kind": "", + "spec": "BH+DT+BH", + "unit": "㎥", + "value": null, + "note": "" + } + ], + "rock_ratios_pct": { + "연암": 90.0, + "경암": 10.0 + }, + "rock_c": { + "ripping_rock": 1.15, + "blasting_rock": 1.3 + }, + "haul": { + "dump_truck": { + "rows": [ + { + "q": 30.16, + "l": 72.15, + "ql": 2176.0440000000003 + }, + { + "q": 29.89, + "l": 77.68, + "ql": 2321.8552000000004 + }, + { + "q": 2.17, + "l": 60.95, + "ql": 132.2615 + }, + { + "q": 111.96, + "l": 178.37, + "ql": 19970.3052 + } + ], + "q": 174.18, + "ql": 24600.4659, + "average_m": 141.24 + }, + "dozer": { + "rows": [ + { + "q": 17.47, + "l": 41.23, + "ql": 720.2880999999999 + }, + { + "q": 0.26, + "l": 20.44, + "ql": 5.314400000000001 + }, + { + "q": 30.55, + "l": 42.94, + "ql": 1311.817 + }, + { + "q": 24.85, + "l": 30.37, + "ql": 754.6945000000001 + }, + { + "q": 44.3, + "l": 35.01, + "ql": 1550.9429999999998 + }, + { + "q": 33.9, + "l": 41.37, + "ql": 1402.4429999999998 + }, + { + "q": 0.97, + "l": 20.9, + "ql": 20.273 + } + ], + "q": 152.29999999999998, + "ql": 5765.772999999999, + "average_m": 37.86 + }, + "free_haul": { + "rows": [ + { + "q": 1.41, + "l": 10.0, + "ql": 14.1 + }, + { + "q": 5.85, + "l": 10.0, + "ql": 58.5 + }, + { + "q": 7.15, + "l": 12.79, + "ql": 91.4485 + }, + { + "q": 14.44, + "l": 10.23, + "ql": 147.7212 + }, + { + "q": 33.11, + "l": 10.0, + "ql": 331.1 + }, + { + "q": 5.36, + "l": 10.0, + "ql": 53.6 + }, + { + "q": 0.97, + "l": 3.56, + "ql": 3.4532 + }, + { + "q": 10.73, + "l": 10.0, + "ql": 107.30000000000001 + } + ], + "q": 79.02, + "ql": 807.2229000000001, + "average_m": 10.22 + } + }, + "haul_saved": true, + "route_id": 237, + "confirmed": true + }, + "/quantity/hydro": { + "stations": [ + { + "chainage_m": 85.052, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.010717, + "length_m": 163.9, + "height_m": 120.0, + "diameter_mm": 1000.0 + }, + "b05": { + "design_flow_m3s": 1.077, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 173.087, + "facility": "ford_bridge", + "kind": "세월교", + "sheet": "ford", + "linked": { + "area_km2": 0.141145, + "length_m": 603.3, + "height_m": 320.0, + "overflow_width_m": 10.0 + }, + "b05": { + "design_flow_m3s": 14.1846, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 258.123, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.004298, + "length_m": 102.0, + "height_m": 80.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.4319, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 352.14, + "facility": "ford_bridge", + "kind": "세월교", + "sheet": "ford", + "linked": { + "area_km2": 0.232351, + "length_m": 697.9, + "height_m": 335.0, + "overflow_width_m": 10.0 + }, + "b05": { + "design_flow_m3s": 23.3505, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 440.241, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.005078, + "length_m": 180.0, + "height_m": 100.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.5103, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 533.975, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.000164, + "length_m": 15.0, + "height_m": 10.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.0165, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 619.708, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.02902, + "length_m": 268.4, + "height_m": 150.0, + "diameter_mm": 1500.0 + }, + "b05": { + "design_flow_m3s": 2.9164, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 720.442, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.007486, + "length_m": 110.1, + "height_m": 80.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.7523, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 804.176, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.0012, + "length_m": 42.4, + "height_m": 25.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.1206, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 899.91, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.001487, + "length_m": 45.8, + "height_m": 35.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.1494, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 982.644, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.027089, + "length_m": 231.9, + "height_m": 150.0, + "diameter_mm": 1500.0 + }, + "b05": { + "design_flow_m3s": 2.7224, + "tc_minutes": 5.0 + } + } + ], + "defaults": { + "factor": 2.0, + "runoff": 0.8, + "area_km2": null, + "length_m": null, + "height_m": null, + "tc_min_minutes": 5.0, + "r24_mm": null, + "region": "", + "return_period": 100, + "pipe_kind": "파형강관", + "n": null, + "diameter_mm": null, + "slope_denominator": null, + "section": "full" + }, + "factors": [ + 2.0, + 1.5, + 1.2 + ], + "sections": { + "full": "만관", + "arc240": "240° 유효단면", + "eff70": "유효 70 %" + }, + "pipe_kinds": { + "파형강관": 0.024, + "흄관": 0.013, + "콘크리트관": 0.015 + }, + "sheet_defaults": { + "box": { + "n": 0.013, + "depth_ratio": null, + "box_width_m": null, + "box_height_m": null + }, + "ford": { + "n": 0.017, + "overflow_width_m": 10.0, + "slope_denominator": 10.0 + } + }, + "site": { + "region": { + "name": "영양군", + "full": "경상북도 영양군 일월면 용화리", + "source": "전처리 행정구역" + }, + "station": "영양군(도곡리)", + "periods": [ + 2, + 3, + 5, + 10, + 20, + 30, + 50, + 70, + 80, + 100, + 200, + 300, + 500 + ], + "r24": { + "2": 92.73, + "3": 109.55, + "5": 128.54, + "10": 152.78, + "20": 176.45, + "30": 190.25, + "50": 207.69, + "70": 219.24, + "80": 223.85, + "100": 231.56, + "200": 255.74, + "300": 270.05, + "500": 288.27 + } + }, + "saved": {} + }, + "/quantity/milestone": { + "data": { + "site": { + "mid_station_m": null, + "mid_road": "비포장", + "start_pin": null + }, + "bases": [], + "routes": [ + { + "id": "h-equip_heavy", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "equip_heavy", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "중기", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-equip_machine", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "equip_machine", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "기계", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-sand", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "sand", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "모래", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-gravel", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "gravel", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "자갈", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-cement", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "cement", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "시멘트", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-rebar", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "rebar", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "철근", + "dest": "start", + "note": "" + } + ] + } + ] + }, + "site": { + "length_m": 1078.011, + "mid_default_m": 539.005, + "start_lonlat": [ + 129.0947913, + 36.8126717 + ], + "mid_lonlat": [ + 129.0983925, + 36.8120423 + ] + }, + "table": [ + { + "route": "h-equip_heavy", + "index": 0, + "item": "중기", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-equip_machine", + "index": 0, + "item": "기계", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-sand", + "index": 0, + "item": "모래", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-gravel", + "index": 0, + "item": "자갈", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-cement", + "index": 0, + "item": "시멘트", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-rebar", + "index": 0, + "item": "철근", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + } + ], + "images": {}, + "auto_images": {}, + "place_images": {}, + "place_times": {} + }, + "/quantity/hydro/calc": { + "area_ha": 1.0717, + "r24_mm": 231.56, + "slope_ground": 0.7321537522879804, + "tc_raw_hr": 0.01857237482923163, + "tc_hr": 0.08333333333333333, + "tc_floored": true, + "intensity": 226.11736447177503, + "flow": 0.5385110655653363, + "design_flow": 1.0770221311306727, + "n": 0.024, + "depth": null, + "section_area": 0.7853981633974483, + "perimeter": 3.141592653589793, + "radius": 0.25, + "slope_bed": null, + "velocity": null, + "capacity": null, + "ok": null, + "ratio_pct": null + } +} \ No newline at end of file diff --git a/resources/tester/helper_302_3_print_quantity.mjs b/resources/tester/helper_302_3_print_quantity.mjs new file mode 100644 index 000000000..9085c3003 --- /dev/null +++ b/resources/tester/helper_302_3_print_quantity.mjs @@ -0,0 +1,152 @@ +// 302-3 인쇄 미리보기 수량산출 — 화면 코드를 node 로 묶어 모으기 결과와 B08 화면 표 짜기를 나란히 냄. +// node helper_302_3_print_quantity.mjs <저장소 뿌리> +// 표준입력 = { project, responses: {"/quantity/…": 응답}, image_b64, drafts: [[이름, 값]], screen } +// (screen = false → 화면 표 짜기 생략 · 응답을 일부러 뺀 실패 견본) +// 표준출력 끝 줄 = { sheets: [모으기 쪽 요약], screen: {B08 화면 표 짜기 요약}, posts: [hydro/calc 본문] } +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); +const input = JSON.parse(fs.readFileSync(0, "utf-8")); + +// 화면 · 세션만 흉내 — 이 길은 DOM 을 안 그림 +class Node { + constructor() { + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {}, contains: () => false }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +const storage = { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.document = { + createElement: () => new Node(), + createElementNS: () => new Node(), + createTextNode: () => new Node(), + head: new Node(), + body: new Node(), + addEventListener() {}, + documentElement: new Node(), +}; +globalThis.window = { + sessionStorage: storage, + localStorage: storage, + location: { origin: "http://localhost", hash: "" }, + addEventListener() {}, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; + +const posts = []; +const png = Buffer.from(input.image_b64, "base64"); +globalThis.fetch = async (url, init = {}) => { + const at = String(url) + .replace(/^.*\/projects\/[^/]+/, "") + .split("?")[0]; + if (at === "/quantity/hydro/calc") posts.push(JSON.parse(init.body)); + if (at === "/quantity/milestone/image") + return new Response(png, { status: 200, headers: { "Content-Type": "image/png" } }); + const body = input.responses[at]; + return body + ? new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ detail: `없음 ${at}` }), { status: 404 }); +}; + +/** 표 요약 — 열 머리(묶음 › 이름) · 줄 · 합계 */ +const shape = (t) => ({ + heads: t.columns.map((c) => [...(c.group ?? []), c.label].join("›")), + rows: t.rows, + total: t.total ?? null, + mergeSame: t.mergeSame ?? null, + frozen: t.frozen ?? 0, +}); + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-302-3-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/helper_302_3_print_quantity_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const p = input.project; + for (const [name, value] of input.drafts ?? []) mod.writeState(name, value, p); + + const sheets = (await mod.collectQuantitySheets(p)).map((s) => ({ + id: s.id, + name: s.name, + orientation: s.orientation ?? null, + images: s.images ?? [], + ...shape(s.table), + })); + + if (input.screen === false) { + console.log(JSON.stringify({ sheets, screen: null, posts })); + process.exit(0); + } + // B08 화면이 부르는 같은 함수 — 같은 응답 · 같은 캐시 + const screen = {}; + screen.summary = shape(mod.summaryTable(await mod.loadSummary(p)).options); + const m = await mod.loadMaterials(p); + const ms = m.calc(m.payload, m.overrides); + for (const id of ["material", "concrete", "quantity"]) + screen[`material:${id}`] = shape(mod.materialOptions(mod.materialTable(id, ms, m.overrides))); + const { table, interval } = await mod.loadEarthwork(p); + screen["earthwork:summary"] = shape(mod.earthworkSummaryOptions(table)); + screen["earthwork:sheet"] = shape(mod.earthworkSheetOptions(table, interval)); + for (const id of ["haul", "free"]) { + const { groups, caption } = mod.HAUL_SHEETS[id](table); + const { options, averages } = mod.haulOptions(groups, caption); + screen[`earthwork:${id}`] = { ...shape(options), averages }; + } + screen.earthworkNames = mod.EARTHWORK_SHEETS.map((s) => s.label); + const h = await mod.loadHydro(p); + screen.hydroInputs = h.sheet.stations.map((st) => mod.hydroInputs(h.sheet, st, h.drafts)); + screen.milestone = shape( + mod.milestoneTable(mod.liveMilestone(p), mod.siteInfo()?.mid_default_m ?? null).options, + ); + console.log(JSON.stringify({ sheets, screen, posts })); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/helper_302_3_print_quantity_entry.ts b/resources/tester/helper_302_3_print_quantity_entry.ts new file mode 100644 index 000000000..029e76fa7 --- /dev/null +++ b/resources/tester/helper_302_3_print_quantity_entry.ts @@ -0,0 +1,25 @@ +/* ============================================================================= + * helper_302_3_print_quantity_entry.ts + * 인쇄 미리보기 수량산출 시험(`test_302_3_print_quantity.py`)이 묶는 진입점 — + * 모으기(`collectQuantitySheets`) + B08 화면이 쓰는 같은 자료 읽기 · 표 짜기 함수 + 캐시 쓰기. + * ========================================================================== */ + +export { collectQuantitySheets } from "../../B09_Estimation/draft/B09_Draft_Print_Quantity"; +export { writeState } from "../../A00_Common/b_page_state"; +export { loadSummary, summaryTable } from "../../B08_Quantity/B08_Quantity_Summary_Table"; +export { + loadMaterials, + materialOptions, + materialTable, +} from "../../B08_Quantity/B08_Quantity_Material_Tables"; +export { + EARTHWORK_SHEETS, + earthworkSheetOptions, + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + loadEarthwork, +} from "../../B08_Quantity/B08_Quantity_Earthwork_Tables"; +export { hydroInputs, loadHydro } from "../../B08_Quantity/B08_Quantity_Hydro_Table"; +export { milestoneTable } from "../../B08_Quantity/B08_Quantity_Milestone_Table"; +export { liveMilestone, siteInfo } from "../../B08_Quantity/B08_Quantity_UI_Milestone_State"; diff --git a/resources/tester/test_110_b08_bed_slope.py b/resources/tester/test_110_b08_bed_slope.py index 27e57b27d..875c4e906 100644 --- a/resources/tester/test_110_b08_bed_slope.py +++ b/resources/tester/test_110_b08_bed_slope.py @@ -88,7 +88,9 @@ def test_연결값으로_판정이_나옴() -> None: def test_저장된_측점_null_이_연결값을_덮지_않음() -> None: """화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜.""" - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Hydro_Table.ts" + ).read_text(encoding="utf-8") assert "v != null || !(k in linkedOf(station))" in ui assert "...station.linked," in ui diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index 2979af220..ca621d888 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -270,7 +270,9 @@ def test_열_폭_맨_아래_단_글만_44_100(): def test_화면_조합_열_빼기_폭_머리_줄바꿈(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "served.has(point.facility" in ui and "served.has(item.type_id)" in ui assert "widthOf(column.label, ...texts)" in ui and "widthOf(column.name)" in ui assert "widthOf(stationHead, ...labels)" in ui diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index 8cbcd0a18..e789ec9d1 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -225,7 +225,9 @@ def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch): def test_화면_연결(): page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") assert "renderMaterialSheet(body, projectId, picked)" in page - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts" + ).read_text(encoding="utf-8") assert "computeMaterialSheets(" in ui # 조작 중 = 짝 계산 assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음 assert "일위대가 재료와 별개" in ui diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 6db3d80ed..e063a0454 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -169,10 +169,14 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): def test_표가_옵션_바뀜에_바로_다시_셈(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui assert "onOptionsChange(host," in ui - earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Earthwork_Tables.ts" + ).read_text(encoding="utf-8") assert "splitRockSummary(summary, table.totals, soft)" in earth assert "onOptionsChange(host," in earth assert ( diff --git a/resources/tester/test_124_b08_surcharge_panel.py b/resources/tester/test_124_b08_surcharge_panel.py index 52faf1c38..2789ad7b5 100644 --- a/resources/tester/test_124_b08_surcharge_panel.py +++ b/resources/tester/test_124_b08_surcharge_panel.py @@ -123,7 +123,9 @@ def test_범위_밖_모르는_칸은_거절(): def test_화면_연결(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" + ).read_text(encoding="utf-8") assert "liveSurcharge(projectId, payload.surcharge_pct)" in ui assert "onOptionsChange(host," in ui assert 'key === "surcharge" && !rows[keep[i]].panel' in ui and "할증 = 좌측 패널" in ui diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 6eb698a8f..360925cb0 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -184,7 +184,9 @@ def test_미연결_시설_칸_전부_연결_여부(root): def test_화면_조합_이름_구조물도는_사본에서(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 assert "linkedOnly(" in ui assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프 diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py index 5253fcfa4..926c7e346 100644 --- a/resources/tester/test_143_3_b08_material_cards.py +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -7,8 +7,12 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") -SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" +).read_text(encoding="utf-8") +SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" +).read_text(encoding="utf-8") def test_자재_셋_값_없는_줄_숨김(): diff --git a/resources/tester/test_154_2_b08_direct_link.py b/resources/tester/test_154_2_b08_direct_link.py index 12bd79719..8b9e34f65 100644 --- a/resources/tester/test_154_2_b08_direct_link.py +++ b/resources/tester/test_154_2_b08_direct_link.py @@ -157,5 +157,7 @@ def test_화면_초안이면_막고_고르기_모달_다시_그림(): assert ".link(slot.copy, facility.facility, item)" in ui api = (ROOT / "ui_template/ui_template_item_picker_project.ts").read_text(encoding="utf-8") assert "/link`" in api - page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "host.replaceWith(fresh)" in page and "unlinkedAt(unlinked, station)" in page diff --git a/resources/tester/test_160_2_b08_unassigned.py b/resources/tester/test_160_2_b08_unassigned.py index e2e351912..c9fe2b6b1 100644 --- a/resources/tester/test_160_2_b08_unassigned.py +++ b/resources/tester/test_160_2_b08_unassigned.py @@ -60,7 +60,9 @@ def test_빈_조합_칸_글(): def test_화면_연결(): - summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert ( 'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary ) # 칸 이름 = 모달과 같은 규칙 diff --git a/resources/tester/test_163_b08_modal.py b/resources/tester/test_163_b08_modal.py index 732c7c7a5..bc44600bd 100644 --- a/resources/tester/test_163_b08_modal.py +++ b/resources/tester/test_163_b08_modal.py @@ -11,7 +11,9 @@ ROOT = Path(__file__).resolve().parents[2] LINK = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8") DRAWING = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") CSS = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8") -MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" +).read_text(encoding="utf-8") LOCALE = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_167_1_b08_pipe_owner_id.py b/resources/tester/test_167_1_b08_pipe_owner_id.py index f56bf9186..aaa34ac00 100644 --- a/resources/tester/test_167_1_b08_pipe_owner_id.py +++ b/resources/tester/test_167_1_b08_pipe_owner_id.py @@ -51,6 +51,8 @@ def test_한_칸만_연결_때_조합_하나(): def test_화면_연결(): - summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "...(saved?.facilities ?? []).map((f) => f.id)" in summary assert "pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m)" in summary diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py index 2088141f8..1937ba50a 100644 --- a/resources/tester/test_198_1_b08_summary_lamp.py +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -13,7 +13,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TS = ROOT / "config" / "node_modules" / "typescript" -UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") +UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts" +).read_text(encoding="utf-8") PRELUDE = f""" const fs = require("fs"); diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index efe891570..5e6f376f9 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -108,10 +108,10 @@ def test_level_보기_전용_들여쓰기(): #: 적용 표 — [파일, 들어 있어야 할 글] APPLIED = [ - ("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"), + ("B08_Quantity/B08_Quantity_Earthwork_Tables.ts", 'mergeSame: ["group", "spec"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["name"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["group"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", "mergeSame: built.mergeSame"), ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'), ("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'), diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py index edd8941c1..1e7ba7e56 100644 --- a/resources/tester/test_236_1_hint_one_line.py +++ b/resources/tester/test_236_1_hint_one_line.py @@ -138,7 +138,9 @@ def test_B08_표_아래_안내는_footNote만(): summary = _read(B08 / "B08_Quantity_UI_Summary.ts") assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임 sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts") - assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음 + assert ( + "tableCard(haulNote(table), grid.root, averages.map(note))" in sheets + ) # 제작 설명 줄 없음 # ── 창 끝까지 셈 ──────────────────────────────────────────────────────────── diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index e462cbf04..ea87c7a73 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -120,7 +120,7 @@ console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D def test_표_열_기준점_따라(): got = _node( - [B08 + "UI_Milestone.ts"], + [B08 + "Milestone_Table.ts"], "console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>" " milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));", ) diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index d7013901c..6ee6d9d0f 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -166,7 +166,10 @@ def test_메인_탭_하위_탭_추가_삭제(): assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal - assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui + assert "editOnDouble: true" in ui and "milestoneTable(data, siteInfo()" in ui + assert "computeMilestone(data, midDefaultM)" in _read( + "B08_Quantity/B08_Quantity_Milestone_Table.ts" + ) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index cef51aaa2..a7844fee8 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -136,6 +136,7 @@ def test_화면_글_거래처(): modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert 'label: "거래처 이름"' in modal page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") - assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page + table = _read("B08_Quantity/B08_Quantity_Milestone_Table.ts") + assert '{ key: "company", label: "거래처 이름"' in table and "`거래처 ${" in page for name in UI: assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 diff --git a/resources/tester/test_302_3_print_quantity.py b/resources/tester/test_302_3_print_quantity.py new file mode 100644 index 000000000..4ee176b66 --- /dev/null +++ b/resources/tester/test_302_3_print_quantity.py @@ -0,0 +1,189 @@ +"""302-3 인쇄 미리보기 「수량산출」 — B08 탭 표를 화면 없이 PrintSheet 로 모음. + +견본 = 검증_sub8 서버 GET 응답(구조물 집계표만 손 견본 · `fixtures/b08_302_3_print_quantity.json`). +`helper_302_3_print_quantity.mjs` 가 화면 코드를 node 로 묶어 같은 응답 · 같은 캐시로 +모으기(`collectQuantitySheets`) 와 B08 화면이 부르는 표 짜기를 나란히 냄 → 쪽 차례 · 이름 · 열 머리 · 줄 대조. +""" + +from __future__ import annotations + +import base64 +import copy +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_302_3_print_quantity.mjs") +FIXTURE = json.loads( + (Path(__file__).with_name("fixtures") / "b08_302_3_print_quantity.json").read_text("utf-8") +) +PNG = b"\x89PNG\r\n\x1a\n302-3" +B08 = ROOT / "B08_Quantity" + + +def _run(responses: dict, drafts: list | None = None, screen: bool = True) -> dict: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + job = { + "project": "p302", + "responses": responses, + "image_b64": base64.b64encode(PNG).decode(), + "drafts": drafts or [], + "screen": screen, + } + out = subprocess.run( + ["node", str(HELPER), str(ROOT)], + input=json.dumps(job, ensure_ascii=False), + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def _responses() -> dict: + data = copy.deepcopy(FIXTURE) + routes = data["/quantity/milestone"]["data"]["routes"] + # 경로 그림 둘 — 사용자 그림 · 서버 캡처(auto) 꼴 + data["/quantity/milestone"]["images"] = { + routes[0]["id"]: "a.png", + routes[2]["id"]: "auto/b.png", + } + return data + + +@pytest.fixture(scope="module") +def base() -> dict: + return _run(_responses()) + + +def _same(sheet: dict, screen: dict) -> None: + for key in ("heads", "rows", "total", "mergeSame", "frozen"): + assert sheet[key] == screen[key], (sheet["id"], key) + + +def test_쪽_차례_이름(base): + stations = FIXTURE["/quantity/hydro"]["stations"] + names = [s["name"] for s in base["sheets"]] + head = ["구조물 집계표", "총괄자재수량집계표", "콘크리트소요자재집계표", "자재수량집계표"] + assert names[:4] == head + assert names[4:8] == base["screen"]["earthworkNames"] == [ + "토공집계표", "토적표", "평균운반거리", "종방향 무대운반거리" + ] # fmt: skip + hydro = names[8:-1] + assert len(hydro) == len(stations) == 11 # 배수지점 측점마다 한 장 + assert all(n.startswith("수리집수면적표 ") for n in hydro) + kinds = [n for n, st in zip(hydro, stations) if st["kind"]] + assert kinds and all(n.endswith(" 세월교") for n in kinds) + assert names[-1] == "거리이정표" + assert len({s["id"] for s in base["sheets"]}) == len(names) + + +def test_표_짜기_B08_화면과_같음(base): + sheets = {s["id"]: s for s in base["sheets"]} + screen = base["screen"] + _same(sheets["summary"], screen["summary"]) + for sid in ("material:material", "material:concrete", "material:quantity", + "earthwork:summary", "earthwork:sheet"): # fmt: skip + _same(sheets[sid], screen[sid]) + # 운반 = 화면 표 + 아래 평균운반거리 줄(번호 칸 글) + for sid in ("earthwork:haul", "earthwork:free"): + want = screen[sid] + assert sheets[sid]["heads"] == want["heads"] and sheets[sid]["total"] == want["total"] + assert sheets[sid]["rows"] == want["rows"] + [{"no": a} for a in want["averages"]] + assert "평균운반거리" in want["averages"][0] + _same(sheets["milestone"], {**screen["milestone"]}) + + +def test_견본_수치(base): + sheets = {s["id"]: s for s in base["sheets"]} + summary = sheets["summary"] + assert summary["heads"] == ["측점", "관 공›Φ800m/m›관매설›m", "관 공›Φ800m/m›커플링›ea"] + assert len(summary["rows"]) == 2 and summary["total"]["pipe:len|800"] == 20.5 + sheet = sheets["earthwork:sheet"] + assert ( + len(sheet["heads"]) == 35 and len(sheet["rows"]) == 8 + ) # 견본 32칸 + 측점 · 거리 · 보정량계 + assert ( + sheet["orientation"] == "landscape" + and sheets["earthwork:summary"]["orientation"] == "portrait" + ) + assert sheets["material:concrete"]["rows"] and len(sheets["material:concrete"]["rows"]) == 5 + hydro = next(s for s in base["sheets"] if s["id"].startswith("hydro:")) + assert hydro["heads"] == ["구분", "항목", "값"] + value = {r["item"]: r["value"] for r in hydro["rows"]} + calc = FIXTURE["/quantity/hydro/calc"] + assert value["Q : 홍수량 (㎥/s)"] == f"{calc['flow']:.2f}" + assert value["Qd : 설계 홍수량 (㎥/s) = Q × 2.0"] == f"{calc['design_flow']:.2f}" + assert value["R24 : 확률강우량 (mm/day)"] == f"{calc['r24_mm']:.1f}" + # 서버 계산 = 화면과 같은 입력(기본 < 장 기본 < 연결 < 저장 < 초안) + assert base["posts"] == base["screen"]["hydroInputs"] + + +def test_거리이정표_지도_그림(base): + milestone = base["sheets"][-1] + routes = FIXTURE["/quantity/milestone"]["data"]["routes"] + assert len(milestone["images"]) == 2 + for image, route in zip(milestone["images"], (routes[0], routes[2])): + head, body = image["src"].split(",", 1) + assert head == "data:image/png;base64" and base64.b64decode(body) == PNG + assert image["caption"] == (route["name"].strip() or route["rows"][0]["item"]) + + +def test_캐시_값이면_화면에_보이는_그_값(): + responses = _responses() + stations = responses["/quantity/hydro"]["stations"] + key = f"{stations[0]['chainage_m']:.3f}" + milestone = copy.deepcopy(responses["/quantity/milestone"]["data"]) + milestone["routes"][0]["name"] = "가나상회" + got = _run(responses, [["b08-hydro", {key: {"area_km2": 0.5}}], ["b08-milestone", milestone]]) + assert got["posts"][0]["area_km2"] == 0.5 == got["screen"]["hydroInputs"][0]["area_km2"] + hydro = next(s for s in got["sheets"] if s["id"] == f"hydro:{key}") + assert any(r["value"].startswith("0.5 (") for r in hydro["rows"]) + sheet = got["sheets"][-1] + assert any(r.get("company") == "가나상회" for r in sheet["rows"]) + _same(sheet, got["screen"]["milestone"]) + + +def test_영역_하나_실패_까닭_한_장(): + responses = _responses() + del responses["/quantity/earthwork"] + got = _run(responses, screen=False) + earth = [s for s in got["sheets"] if s["id"].startswith("earthwork")] + assert len(earth) == 1 and earth[0]["name"] == "토공" + assert earth[0]["rows"][0]["note"].startswith("불러오지 못했습니다") + assert got["sheets"][0]["name"] == "구조물 집계표" and got["sheets"][-1]["name"] == "거리이정표" + + +def test_B08_화면이_같은_함수를_씀(): + read = lambda name: (B08 / f"B08_Quantity_{name}.ts").read_text("utf-8") # noqa: E731 + assert "summaryTable(view)" in read("UI_Summary") and "loadSummary(projectId)" in read( + "UI_Summary" + ) + material = read("UI_Material") + assert "materialOptions(built)" in material and "loadMaterials(projectId)" in material + assert "earthworkSheetOptions(table, interval)" in read("UI_Earthwork") + sheets = read("UI_Earthwork_Sheets") + assert "earthworkSummaryOptions(table)" in sheets and "haulOptions(groups, caption)" in sheets + assert "hydroInputs(sheet, station, drafts)" in read("UI_Hydro") + assert "milestoneTable(data, siteInfo()" in read("UI_Milestone") + # 표 짜기 파일은 화면 없음 + for name in ( + "Summary_Table", + "Material_Tables", + "Earthwork_Tables", + "Hydro_Table", + "Milestone_Table", + ): + text = read(name) + assert "document." not in text and "createDataTable" not in text, name + assert len(text.splitlines()) <= 700 + printer = (ROOT / "B09_Estimation/draft/B09_Draft_Print_Quantity.ts").read_text("utf-8") + assert 'from "./B09_Draft_Print_Types"' in printer and "createDataTable" not in printer diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index c7454065a..002f80fb0 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -94,7 +94,9 @@ def test_측점_조합들(): def test_집계표는_공용_표(): # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui assert 'document.createElement("table")' not in ui diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index 8b533cf56..7463fcaca 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -209,7 +209,9 @@ def test_토공집계표_식() -> None: def test_토적표_머리_글자는_사용자_견본대로(): # 「[절토] <토사> · <연암> · 측구파기 <토사> · <암>」 — 값 · 계산은 그대로 - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Earthwork_Tables.ts" + ).read_text(encoding="utf-8") for head in ( '["절토", "연암"]', '["절토", "경암"]', diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index 87884abab..ecc2964a4 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -249,7 +249,9 @@ def test_라우터_등록(): def test_화면이_서버_칸을_그림(): """98-4 — 화면은 서버 배관 열 · 칸 · 계만 그림(계산 없음) · 배관 조합 열은 서버 열로 바뀜.""" base = Path(__file__).resolve().parents[2] - ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + base / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui # 배관 · 횡단배수 조합 열 대신 서버 열(120 — 서버가 펼친 종류 `types` 를 뺌) assert '!served.has(point.facility ?? "pipe")' in ui