From c7a438138d7f77d3ce21caa7e6d012e943106752 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:03:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09-print):=20=EC=9D=B8=EC=87=84=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=98=81=EC=97=AD=20=E2=80=94=20collectQu?= =?UTF-8?q?antitySheets=20=C2=B7=20B08=20=ED=83=AD=20=EC=B0=A8=EB=A1=80=20?= =?UTF-8?q?PrintSheet(=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=85=8B=20=C2=B7=20=ED=86=A0=EA=B3=B5=20=EB=84=B7?= =?UTF-8?q?=20=C2=B7=20=EC=88=98=EB=A6=AC=20=EC=B8=A1=EC=A0=90=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=C2=B7=20=EA=B1=B0=EB=A6=AC=EC=9D=B4=EC=A0=95?= =?UTF-8?q?=ED=91=9C=20+=20=EC=A7=80=EB=8F=84=20=EA=B7=B8=EB=A6=BC=20data:?= =?UTF-8?q?)=20=C2=B7=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=ED=91=9C=20=EC=A7=9C=EA=B8=B0=20=C2=B7=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=92=20=C2=B7=20=EC=98=81=EC=97=AD=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=3D=20=EA=B9=8C=EB=8B=AD=20=ED=95=9C=20=EC=9E=A5=20(302-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../draft/B09_Draft_Print_Quantity.ts | 185 ++ .../fixtures/b08_302_3_print_quantity.json | 2268 +++++++++++++++++ .../tester/helper_302_3_print_quantity.mjs | 152 ++ .../helper_302_3_print_quantity_entry.ts | 25 + resources/tester/test_302_3_print_quantity.py | 189 ++ 5 files changed, 2819 insertions(+) create mode 100644 B09_Estimation/draft/B09_Draft_Print_Quantity.ts create mode 100644 resources/tester/fixtures/b08_302_3_print_quantity.json create mode 100644 resources/tester/helper_302_3_print_quantity.mjs create mode 100644 resources/tester/helper_302_3_print_quantity_entry.ts create mode 100644 resources/tester/test_302_3_print_quantity.py 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_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