diff --git a/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx b/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx index d8c8cab..48800ad 100644 --- a/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx +++ b/src/app/(dashboard)/finance/product-master/product-master-page-client.tsx @@ -25,7 +25,6 @@ import { ProductMasterTable, } from "@/components/finance/cost-product-master" import { BulkImportDialog } from "@/components/finance/costing/bulk-import-dialog" -import { ParamsOnlyImportDialog } from "@/components/finance/costing/params-only-import-dialog" import { ImportDialog } from "@/components/finance/costing/import-dialog" import { ProductTypeCombobox } from "@/components/finance/comboboxes" import { DataTablePagination } from "@/components/shared" @@ -55,6 +54,7 @@ export default function ProductMasterPageClient() { const [importOpen, setImportOpen] = useState(false) const [bulkImportOpen, setBulkImportOpen] = useState(false) const [paramsImportOpen, setParamsImportOpen] = useState(false) + // Both bulk menu items open the same unified ETL dialog, differing only by kind. const [editing, setEditing] = useState(null) const [bulkExportLoading, setBulkExportLoading] = useState(false) @@ -232,8 +232,8 @@ export default function ProductMasterPageClient() { onOpenChange={setDeactivateOpen} product={editing} /> - - + + ) } diff --git a/src/app/api/v1/finance/costing/import/bulk_params_only/route.ts b/src/app/api/v1/finance/costing/import/bulk_params_only/route.ts deleted file mode 100644 index 6cee208..0000000 --- a/src/app/api/v1/finance/costing/import/bulk_params_only/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -// POST /api/v1/finance/costing/import/bulk_params_only -// Accepts multipart/form-data ("file" field) to avoid JSON number-array inflation: -// a 50 MB binary file as JSON becomes ~200 MB — this sends the actual binary bytes. -// Falls back to JSON body (small files only) for backward compatibility. -// Returns: { base, data: { jobId: number } } -import { NextRequest, NextResponse } from "next/server" -import { - getCostDataImportClient, - createMetadataFromRequest, - isGrpcError, - handleGrpcError, -} from "@/lib/grpc" - -export async function POST(request: NextRequest) { - try { - const metadata = createMetadataFromRequest(request) - const client = getCostDataImportClient() - - let fileContent: Uint8Array - let fileName: string - - const contentType = request.headers.get("content-type") ?? "" - - if (contentType.includes("multipart/form-data")) { - const formData = await request.formData() - const file = formData.get("file") as File | null - if (!file) { - return NextResponse.json( - { - base: { - isSuccess: false, - statusCode: "400", - message: "Missing 'file' field in form data", - validationErrors: [], - }, - }, - { status: 400 }, - ) - } - fileContent = new Uint8Array(await file.arrayBuffer()) - fileName = file.name - } else { - // Legacy JSON fallback - const body = await request.json() - fileContent = new Uint8Array(body.fileContent as number[]) - fileName = body.fileName ?? "" - } - - const res = await client.importBulkParamsOnly( - { fileContent, fileName }, - metadata, - ) - - return NextResponse.json({ - base: res.base, - data: { jobId: res.jobId, status: res.status }, - }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - console.error("Error importing bulk params:", error) - return NextResponse.json( - { - base: { - isSuccess: false, - statusCode: "500", - message: "Failed to import params", - validationErrors: [], - }, - }, - { status: 500 }, - ) - } -} diff --git a/src/app/api/v1/finance/costing/import/bulk_product_routing/route.ts b/src/app/api/v1/finance/costing/import/bulk_product_routing/route.ts deleted file mode 100644 index 8c66313..0000000 --- a/src/app/api/v1/finance/costing/import/bulk_product_routing/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -// POST /api/v1/finance/costing/import/bulk_product_routing -// Import product master + routing data from an Excel file. -// Accepts JSON body: { fileContent: number[], fileName: string, duplicateAction?: string } -// Returns: { base, data: { jobId: number } } - -import { NextRequest, NextResponse } from "next/server" -import { - getCostDataImportClient, - createMetadataFromRequest, - isGrpcError, - handleGrpcError, -} from "@/lib/grpc" - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const metadata = createMetadataFromRequest(request) - const fileContent = new Uint8Array(body.fileContent as number[]) - const fileName: string = body.fileName ?? "" - const duplicateAction: string = body.duplicateAction ?? "update" - - const res = await getCostDataImportClient().importBulkProductRouting( - { fileContent, fileName, duplicateAction }, - metadata, - ) - - return NextResponse.json({ - base: res.base, - data: { jobId: res.jobId, status: res.status }, - }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - console.error("Error importing bulk product routing:", error) - return NextResponse.json( - { - base: { - isSuccess: false, - statusCode: "500", - message: "Failed to import bulk product routing", - validationErrors: [], - }, - }, - { status: 500 }, - ) - } -} diff --git a/src/app/api/v1/finance/costing/import/start/route.ts b/src/app/api/v1/finance/costing/import/start/route.ts new file mode 100644 index 0000000..9bf3457 --- /dev/null +++ b/src/app/api/v1/finance/costing/import/start/route.ts @@ -0,0 +1,59 @@ +// POST /api/v1/finance/costing/import/start +// Starts an async ETL import job for an already-uploaded object (from the +// presigned upload). Accepts JSON body: +// { kind: "product_routing" | "params_only", objectKey: string, fileName?: string } + +import { NextRequest, NextResponse } from "next/server" +import { + getCostDataImportClient, + createMetadataFromRequest, + isGrpcError, + handleGrpcError, +} from "@/lib/grpc" +import { ImportKind } from "@/types/generated/finance/v1/cost_import" + +function toImportKind(kind: string): ImportKind { + if (kind === "product_routing") return ImportKind.IMPORT_KIND_PRODUCT_ROUTING + if (kind === "params_only") return ImportKind.IMPORT_KIND_PARAMS_ONLY + return ImportKind.IMPORT_KIND_UNSPECIFIED +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const metadata = createMetadataFromRequest(request) + + const res = await getCostDataImportClient().startCostingImport( + { + kind: toImportKind(body.kind), + objectKey: body.objectKey ?? "", + fileName: body.fileName ?? "", + duplicateAction: body.duplicateAction ?? "", + }, + metadata, + ) + + if (res.base && !res.base.isSuccess) { + return NextResponse.json({ base: res.base }, { status: 422 }) + } + + return NextResponse.json({ + base: res.base, + data: { jobId: res.jobId, status: res.status }, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error starting costing import:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to start import", + validationErrors: [], + }, + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/v1/finance/costing/import/upload-url/route.ts b/src/app/api/v1/finance/costing/import/upload-url/route.ts new file mode 100644 index 0000000..d4aa638 --- /dev/null +++ b/src/app/api/v1/finance/costing/import/upload-url/route.ts @@ -0,0 +1,56 @@ +// POST /api/v1/finance/costing/import/upload-url +// Returns a presigned PUT URL so the browser can upload a bulk-import file +// (xlsx or zipped CSV) directly to object storage, bypassing the BFF/gRPC path. +// Accepts JSON body: { kind: "product_routing" | "params_only", fileName: string } + +import { NextRequest, NextResponse } from "next/server" +import { + getCostDataImportClient, + createMetadataFromRequest, + isGrpcError, + handleGrpcError, +} from "@/lib/grpc" +import { ImportKind } from "@/types/generated/finance/v1/cost_import" + +function toImportKind(kind: string): ImportKind { + if (kind === "product_routing") return ImportKind.IMPORT_KIND_PRODUCT_ROUTING + if (kind === "params_only") return ImportKind.IMPORT_KIND_PARAMS_ONLY + return ImportKind.IMPORT_KIND_UNSPECIFIED +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const metadata = createMetadataFromRequest(request) + + const res = await getCostDataImportClient().getImportUploadURL( + { kind: toImportKind(body.kind), fileName: body.fileName ?? "" }, + metadata, + ) + + if (res.base && !res.base.isSuccess) { + return NextResponse.json({ base: res.base }, { status: 422 }) + } + + return NextResponse.json({ + base: res.base, + uploadUrl: res.uploadUrl, + objectKey: res.objectKey, + expiresInSeconds: res.expiresInSeconds, + }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error getting import upload URL:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to get upload URL", + validationErrors: [], + }, + }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/v1/finance/costing/validate/bulk_product_routing/route.ts b/src/app/api/v1/finance/costing/validate/bulk_product_routing/route.ts deleted file mode 100644 index 698a289..0000000 --- a/src/app/api/v1/finance/costing/validate/bulk_product_routing/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -// POST /api/v1/finance/costing/validate/bulk_product_routing -// Validate a bulk product routing Excel file before importing. -// Accepts JSON body: { fileContent: number[], fileName: string } -// Returns: { isValid: boolean, sheets: BulkSheetValidationResult[] } - -import { NextRequest, NextResponse } from "next/server" -import { - getCostDataImportClient, - createMetadataFromRequest, - isGrpcError, - handleGrpcError, -} from "@/lib/grpc" - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const metadata = createMetadataFromRequest(request) - const fileContent = new Uint8Array(body.fileContent as number[]) - const fileName: string = body.fileName ?? "" - - const res = await getCostDataImportClient().validateBulkProductRoutingFile( - { fileContent, fileName }, - metadata, - ) - - // Surface backend errors (e.g. timeout on very large files) as a non-2xx response - // so the frontend can display the message rather than showing an empty result. - if (res.base && !res.base.isSuccess) { - return NextResponse.json({ base: res.base }, { status: 422 }) - } - - return NextResponse.json({ - isValid: res.isValid, - sheets: res.sheets.map((sheet) => ({ - sheetName: sheet.sheetName, - totalRows: sheet.totalRows, - errorCount: sheet.errorCount, - warningCount: sheet.warningCount, - sampleErrors: sheet.sampleErrors.map((e) => ({ - rowNumber: e.rowNumber, - field: e.field, - message: e.message, - })), - })), - }) - } catch (error) { - if (isGrpcError(error)) return handleGrpcError(error) - console.error("Error validating bulk product routing file:", error) - return NextResponse.json( - { - base: { - isSuccess: false, - statusCode: "500", - message: "Failed to validate bulk product routing file", - validationErrors: [], - }, - }, - { status: 500 }, - ) - } -} diff --git a/src/components/finance/costing/bulk-import-dialog.tsx b/src/components/finance/costing/bulk-import-dialog.tsx index c2241a0..d3b5ce0 100644 --- a/src/components/finance/costing/bulk-import-dialog.tsx +++ b/src/components/finance/costing/bulk-import-dialog.tsx @@ -1,12 +1,10 @@ "use client" -import React, { useEffect, useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" import { - AlertCircle, CheckCircle2, - ChevronDown, - ChevronRight, Download, + ExternalLink, FileSpreadsheet, Loader2, Upload, @@ -15,619 +13,315 @@ import { import { useRouter } from "next/navigation" import { toast } from "sonner" -import { Dialog } from "@/components/ui/dialog" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label" import { Progress } from "@/components/ui/progress" +import { StatusBadge } from "@/components/common/status-badge" import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { - ScrollableDialogContent, - ScrollableDialogHeader, - ScrollableDialogBody, - ScrollableDialogFooter, -} from "@/components/common/scrollable-dialog" -import { DialogTitle } from "@/components/ui/dialog" -import { - bulkImportProductMasterRouting, - downloadBulkProductRoutingTemplate, - exportBulkProductRouting, - validateBulkProductRoutingFile, + getImportUploadURL, + putToPresignedUrl, + startCostingImport, getImportJob, + downloadBulkProductRoutingTemplate, + downloadParamsOnlyTemplate, } from "@/services/finance/cost-import-api" -import { - chunkExcelFile, - BULK_ROUTING_CONFIG, - type ChunkResult, -} from "@/lib/excel/chunked-importer" -import type { BulkValidationResult } from "@/types/finance/cost-import" +import type { CostImportJob, ImportKindKey } from "@/types/finance/cost-import" export interface BulkImportDialogProps { open: boolean onOpenChange: (open: boolean) => void + /** Which dataset to import. Defaults to product_routing. */ + kind?: ImportKindKey } -// "validate" flow: upload → validating → validated → submitting → done -// "chunked" flow: upload → parsing → ready → chunked_importing → chunked_done | chunked_failed -type Step = - | "upload" - | "validating" | "validated" | "submitting" | "done" - | "parsing" | "ready" | "chunked_importing" | "chunked_done" | "chunked_failed" +type Step = "upload" | "uploading" | "starting" | "processing" | "done" | "partial" | "failed" + +interface KindConfig { + title: string + accept: string + fileHint: string + templateLabel: string + downloadTemplate: () => Promise +} -interface ChunkStatus { - index: number - keyCount: number - status: "pending" | "uploading" | "polling" | "done" | "partial" | "failed" +const KIND_CONFIG: Record = { + product_routing: { + title: "Bulk Import — Product Master & Routing", + accept: ".xlsx,.zip", + fileHint: "Format: .xlsx (6 sheet) atau .zip berisi CSV per sheet", + templateLabel: "Template (xlsx)", + downloadTemplate: downloadBulkProductRoutingTemplate, + }, + params_only: { + title: "Bulk Import — Params Only", + accept: ".zip", + fileHint: "Format: .zip berisi product_parameters.csv + applicable_params.csv", + templateLabel: "Template (xlsx)", + downloadTemplate: downloadParamsOnlyTemplate, + }, } -const VALIDATE_SIZE_LIMIT = 5 * 1024 * 1024 // 5 MB — matches server-side validate limit const POLL_MS = 3_000 const MAX_POLL_ERRORS = 5 -export function BulkImportDialog({ open, onOpenChange }: BulkImportDialogProps) { +export function BulkImportDialog({ open, onOpenChange, kind = "product_routing" }: BulkImportDialogProps) { const fileRef = useRef(null) const router = useRouter() const abortRef = useRef(false) + const cfg = KIND_CONFIG[kind] const [file, setFile] = useState(null) const [step, setStep] = useState("upload") - const [validation, setValidation] = useState(null) - const [expandedSheets, setExpandedSheets] = useState>(new Set()) - const [jobId, setJobId] = useState(null) + const [uploadPct, setUploadPct] = useState(0) + const [job, setJob] = useState(null) const [templateLoading, setTemplateLoading] = useState(false) - // Chunked state - const [parseStage, setParseStage] = useState("") - const [parsed, setParsed] = useState(null) - const [chunks, setChunks] = useState([]) - const [currentIdx, setCurrentIdx] = useState(0) - const [totalInserted, setTotalInserted] = useState(0) - const [failedChunks, setFailedChunks] = useState([]) - function resetState() { abortRef.current = false setFile(null) setStep("upload") - setValidation(null) - setExpandedSheets(new Set()) - setJobId(null) - setParsed(null) - setChunks([]) - setCurrentIdx(0) - setTotalInserted(0) - setFailedChunks([]) - setParseStage("") + setUploadPct(0) + setJob(null) if (fileRef.current) fileRef.current.value = "" } - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { if (open) resetState() }, [open]) + useEffect(() => { if (open) resetState() }, [open, kind]) function handleFileChange(e: React.ChangeEvent) { - const selected = e.target.files?.[0] ?? null - setFile(selected) - setValidation(null) - setStep("upload") + setFile(e.target.files?.[0] ?? null) } async function handleDownloadTemplate() { setTemplateLoading(true) try { - await downloadBulkProductRoutingTemplate() + await cfg.downloadTemplate() } catch (e) { - toast.error(`Template download failed: ${String(e)}`) + toast.error(`Template download gagal: ${String(e)}`) } finally { setTemplateLoading(false) } } - async function handleValidate() { - if (!file) return - setStep("validating") - try { - const result = await validateBulkProductRoutingFile(file) - setValidation(result) - setStep("validated") - } catch (e) { - toast.error(`Validation failed: ${String(e)}`) - setStep("upload") + async function pollUntilDone(jobId: number) { + let pollErrors = 0 + while (!abortRef.current) { + await new Promise((r) => setTimeout(r, POLL_MS)) + if (abortRef.current) return + try { + const j = await getImportJob(jobId) + setJob(j) + if (j.status === "DONE") { setStep("done"); return } + if (j.status === "PARTIAL") { setStep("partial"); return } + if (j.status === "FAILED") { setStep("failed"); return } + } catch { + if (++pollErrors >= MAX_POLL_ERRORS) { + toast.error("Gagal memantau status job — cek halaman Import Jobs.") + setStep("failed") + return + } + } } } async function handleImport() { if (!file) return - setStep("submitting") - try { - const result = await bulkImportProductMasterRouting(file, "update") - setJobId(result.jobId) - setStep("done") - toast.success(`Import queued — Job #${result.jobId}`) - } catch (e) { - toast.error(`Import failed: ${String(e)}`) - setStep("validated") - } - } - - // ── Chunked import flow ──────────────────────────────────────────────────── - async function handleChunkedImport() { - if (!file) return - setStep("parsing") + abortRef.current = false try { - const result = await chunkExcelFile(file, BULK_ROUTING_CONFIG, setParseStage) - setParsed(result) - setChunks(result.chunks.map((c) => ({ index: c.index, keyCount: c.keyCount, status: "pending" }))) - setStep("ready") + setStep("uploading") + setUploadPct(0) + const { uploadUrl, objectKey } = await getImportUploadURL(kind, file.name) + await putToPresignedUrl(uploadUrl, file, setUploadPct) + + setStep("starting") + const { jobId } = await startCostingImport(kind, objectKey, file.name) + toast.success(`Import dijadwalkan — Job #${jobId}`) + + setStep("processing") + setJob({ jobId } as CostImportJob) + await pollUntilDone(jobId) } catch (e) { - toast.error(`Parse failed: ${String(e)}`) + toast.error(`Import gagal: ${String(e)}`) setStep("upload") } } - async function startChunkedImport(fromChunk = 0) { - if (!parsed) return - abortRef.current = false - setStep("chunked_importing") - const failed: number[] = [] - - for (let i = fromChunk; i < parsed.chunks.length; i++) { - if (abortRef.current) break - setCurrentIdx(i) - updateChunk(i, "uploading") - - const chunk = parsed.chunks[i] - const chunkFile = new File([chunk.blob], chunk.fileName, { - type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }) - - let cJobId: number - try { - const res = await bulkImportProductMasterRouting(chunkFile, "update") - cJobId = res.jobId - updateChunk(i, "polling") - } catch (e) { - toast.error(`Chunk ${i + 1} upload failed: ${String(e)}`) - updateChunk(i, "failed") - failed.push(i) - continue - } - - let pollErrors = 0 - let done = false - while (!abortRef.current && !done) { - await new Promise((r) => setTimeout(r, POLL_MS)) - try { - const job = await getImportJob(cJobId) - if (job.status === "DONE" || job.status === "PARTIAL" || job.status === "FAILED") { - updateChunk(i, job.status !== "FAILED" ? (job.status === "DONE" ? "done" : "partial") : "failed") - if (job.status !== "FAILED") setTotalInserted((n) => n + (job.success ?? 0)) - else failed.push(i) - done = true - } - } catch { - if (++pollErrors >= MAX_POLL_ERRORS) { updateChunk(i, "failed"); failed.push(i); done = true } - } - } - } - - setFailedChunks(failed) - setStep(failed.length === 0 ? "chunked_done" : "chunked_failed") - } - - function updateChunk(index: number, status: ChunkStatus["status"]) { - setChunks((prev) => prev.map((c) => (c.index === index ? { ...c, status } : c))) - } - function handleClose() { abortRef.current = true resetState() onOpenChange(false) } - function toggleSheet(sheetName: string) { - setExpandedSheets((prev) => { - const next = new Set(prev) - if (next.has(sheetName)) { - next.delete(sheetName) - } else { - next.add(sheetName) - } - return next - }) - } - - const hasErrors = validation?.sheets.some((s) => s.errorCount > 0) ?? false - const isValidating = step === "validating" - const isSubmitting = step === "submitting" - const isDone = step === "done" - const canImport = step === "validated" && !hasErrors && (validation?.isValid ?? false) - const isChunkedFlow = ["parsing","ready","chunked_importing","chunked_done","chunked_failed"].includes(step) - const fileTooLargeForValidate = (file?.size ?? 0) > VALIDATE_SIZE_LIMIT - const doneCount = chunks.filter((c) => c.status === "done" || c.status === "partial").length - const progressPct = parsed ? Math.round((doneCount / parsed.chunks.length) * 100) : 0 + const busy = step === "uploading" || step === "starting" || step === "processing" + const terminal = step === "done" || step === "partial" || step === "failed" + const processedPct = + job && job.totalRows > 0 ? Math.round((job.processed / job.totalRows) * 100) : 0 return ( - - - - Bulk Import — Product Master & Routing - + { if (!v) handleClose() }}> + + + {cfg.title} + + +
+ {/* Template download + file picker — only before upload starts */} + {step === "upload" && ( + <> +
+
+ +
+

Import Template

+

Unduh template & isi sesuai kolom

+
+
+ +
- - {/* Template Download — hidden once job submitted */} - {!isDone && ( -
-
- -
-

Import Template

-

- Download template Excel dengan 6 sheet yang diperlukan -

+
+ +
fileRef.current?.click()} + > + + {file ? ( +
+ + {file.name} + + ({(file.size / 1024 / 1024).toFixed(1)} MB) + +
+ ) : ( + <> + +

Klik untuk memilih file

+

{cfg.fileHint}

+ + )}
+

+ File diunggah langsung ke storage lalu diproses di server (streaming) — ukuran besar tidak masalah. +

- -
+ )} - {/* File Upload — hidden once job submitted */} - {!isDone && ( -
- -
fileRef.current?.click()} - > - - {file ? ( -
- - {file.name} - - ({(file.size / 1024).toFixed(1)} KB) - -
- ) : ( - <> - -

- Klik untuk memilih atau drag & drop file Excel -

-

- Format: .xlsx -

- - )} + {/* Uploading progress */} + {step === "uploading" && ( +
+
+ Mengunggah file… + {uploadPct}%
+
)} - {/* Validating spinner */} - {isValidating && ( + {/* Starting */} + {step === "starting" && (
- - Memvalidasi file… + Menjadwalkan job…
)} - {/* Validation results table */} - {validation && !isValidating && ( -
-
- {validation.isValid ? ( - - ) : ( - - )} - - {validation.isValid - ? "File valid — siap diimport" - : "Validasi gagal — perbaiki error sebelum import"} + {/* Processing — poll job */} + {step === "processing" && ( +
+
+ + Memproses + {job?.jobId ? ` — Job #${job.jobId}` : ""} + {job && }
- -
- - - - Sheet - Row - Error - Warning - - - - - {validation.sheets.map((sheet) => ( - - 0 ? "bg-destructive/5" : undefined} - > - {sheet.sheetName} - {sheet.totalRows} - - {sheet.errorCount > 0 ? ( - - {sheet.errorCount} - - ) : ( - 0 - )} - - - {sheet.warningCount > 0 ? ( - - {sheet.warningCount} - - ) : ( - 0 - )} - - - {sheet.sampleErrors.length > 0 && ( - - )} - - - - {/* Sample errors */} - {expandedSheets.has(sheet.sheetName) && - sheet.sampleErrors.map((err, i) => ( - - - Row {err.rowNumber}{err.field ? ` [${err.field}]` : ""}: {err.message} - - - ))} - - ))} - -
-
-
- )} - - {/* Done (single-shot) */} - {isDone && jobId !== null && ( -
- - Import dijadwalkan sebagai Job #{jobId}. Notifikasi akan dikirim saat selesai. -
- )} - - {/* File too large warning */} - {file && step === "upload" && fileTooLargeForValidate && ( -
- - - File {(file.size / 1024 / 1024).toFixed(1)} MB melebihi batas validasi 5 MB. - Gunakan Chunked Import untuk melanjutkan. - -
- )} - - {/* Chunked: parsing */} - {step === "parsing" && ( -
- -
-

Parsing file…

-

{parseStage}

-
-
- )} - - {/* Chunked: ready */} - {step === "ready" && parsed && ( -
-

File parsed

-
-

Products

{parsed.totalKeys.toLocaleString()}

-

Total rows

{parsed.totalRows.toLocaleString()}

-

Chunks

{parsed.chunks.length}

-
+ {job && job.totalRows > 0 && ( + <> + +

+ {job.processed.toLocaleString()} / {job.totalRows.toLocaleString()} baris diproses +

+ + )}

- ~{BULK_ROUTING_CONFIG.chunkSize} products per chunk · 6 sheets each + Dialog boleh ditutup — notifikasi dikirim saat selesai.

-
- - If routing references intermediate products from other chunks, those rows - will show as "missing product" warnings (non-fatal). -
-
- )} - - {/* Chunked: importing */} - {step === "chunked_importing" && parsed && ( -
-
- Chunk {currentIdx + 1} / {parsed.chunks.length} - {totalInserted.toLocaleString()} rows imported -
- -
-
- {chunks.map((c) => )} -
-
)} - {/* Chunked: done */} - {step === "chunked_done" && ( -
- -
-

- Import complete — {totalInserted.toLocaleString()} rows imported -

- -
-
+ {/* Terminal states */} + {terminal && job && ( + )} +
- {/* Chunked: failed */} - {step === "chunked_failed" && ( -
-
- -
-

- {failedChunks.length} chunk{failedChunks.length > 1 ? "s" : ""} failed - {totalInserted > 0 && ` — ${totalInserted.toLocaleString()} rows already imported`} -

-
-
-
-
- {chunks.map((c) => )} -
-
-
- )} - - - - - - {/* Validate flow buttons */} - {file && step === "upload" && !fileTooLargeForValidate && ( - )} - {canImport && ( - )} - - {/* Chunked flow buttons */} - {file && step === "upload" && ( - - )} - {step === "ready" && ( - - )} - {step === "chunked_importing" && ( - - )} - {step === "chunked_failed" && failedChunks.length > 0 && ( - - )} - - + +
) } -/** - * Standalone "Export All" button that queues an async bulk export job. - */ -export function BulkExportButton({ - productTypeCodes, -}: { - productTypeCodes?: string[] -}) { - const [loading, setLoading] = useState(false) - - async function handleExport() { - setLoading(true) - try { - const result = await exportBulkProductRouting({ productTypeCodes }) - toast.success(`Export queued — Job #${result.jobId}`) - } catch (e) { - toast.error(`Export failed: ${String(e)}`) - } finally { - setLoading(false) - } +function ResultPanel({ step, job }: { step: Step; job: CostImportJob }) { + if (step === "failed") { + return ( +
+ +
+

Import gagal — Job #{job.jobId}

+

Buka halaman Import Jobs untuk detail.

+
+
+ ) } - + const isPartial = step === "partial" return ( - +
+ +
+

+ {isPartial ? "Selesai dengan sebagian error" : "Import selesai"} — Job #{job.jobId} +

+

+ {job.success.toLocaleString()} sukses + {job.failed > 0 && ` · ${job.failed.toLocaleString()} gagal`} + {job.skipped > 0 && ` · ${job.skipped.toLocaleString()} dilewati`} +

+ {job.errorFileUrl && ( + + Unduh laporan error + + )} +
+
) } - -function RoutingChunkBadge({ chunk, current }: { chunk: ChunkStatus; current: boolean }) { - const color: Record = { - pending: "bg-muted text-muted-foreground", - uploading: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", - polling: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200", - done: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200", - partial: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-200", - failed: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200", - } - return ( - - {chunk.index + 1} - {current && (chunk.status === "uploading" || chunk.status === "polling") && ( - - )} - - ) -} - -export type { BulkValidationResult } diff --git a/src/components/finance/costing/params-only-import-dialog.tsx b/src/components/finance/costing/params-only-import-dialog.tsx deleted file mode 100644 index d32557f..0000000 --- a/src/components/finance/costing/params-only-import-dialog.tsx +++ /dev/null @@ -1,399 +0,0 @@ -"use client" - -import { useEffect, useRef, useState } from "react" -import { - AlertCircle, - CheckCircle2, - Download, - FileSpreadsheet, - Loader2, - Upload, - XCircle, -} from "lucide-react" -import { useRouter } from "next/navigation" -import { toast } from "sonner" - -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Label } from "@/components/ui/label" -import { Progress } from "@/components/ui/progress" -import { - bulkImportParamsOnly, - downloadParamsOnlyTemplate, - getImportJob, -} from "@/services/finance/cost-import-api" -import { - chunkExcelFile, - PARAMS_ONLY_CONFIG, - type ChunkResult, -} from "@/lib/excel/chunked-importer" - -interface Props { - open: boolean - onOpenChange: (open: boolean) => void -} - -type Step = "upload" | "parsing" | "ready" | "importing" | "done" | "failed" - -interface ChunkStatus { - index: number - productCount: number - rowCount: number - status: "pending" | "uploading" | "polling" | "done" | "partial" | "failed" - jobId?: number - inserted?: number -} - -const POLL_MS = 3_000 -const MAX_POLL_ERRORS = 5 - -export function ParamsOnlyImportDialog({ open, onOpenChange }: Props) { - const fileRef = useRef(null) - const router = useRouter() - const abortRef = useRef(false) - - const [file, setFile] = useState(null) - const [parseStage, setParseStage] = useState("") - const [parsed, setParsed] = useState(null) - const [step, setStep] = useState("upload") - const [chunks, setChunks] = useState([]) - const [currentIdx, setCurrentIdx] = useState(0) - const [totalInserted, setTotalInserted] = useState(0) - const [failedChunks, setFailedChunks] = useState([]) - const [templateLoading, setTemplateLoading] = useState(false) - - useEffect(() => { - if (open) reset() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]) - - function reset() { - abortRef.current = false - setFile(null) - setParsed(null) - setStep("upload") - setChunks([]) - setCurrentIdx(0) - setTotalInserted(0) - setFailedChunks([]) - setParseStage("") - if (fileRef.current) fileRef.current.value = "" - } - - async function handleParse(f: File) { - setFile(f) - setStep("parsing") - try { - const result = await chunkExcelFile(f, PARAMS_ONLY_CONFIG, setParseStage) - setParsed(result) - setChunks( - result.chunks.map((c) => ({ - index: c.index, - productCount: c.keyCount, - rowCount: c.rowCount, - status: "pending", - })) - ) - setStep("ready") - } catch (e) { - toast.error(`Failed to parse file: ${String(e)}`) - setStep("upload") - } - } - - async function startImport(fromChunk = 0) { - if (!parsed) return - abortRef.current = false - setStep("importing") - const failed: number[] = [] - - for (let i = fromChunk; i < parsed.chunks.length; i++) { - if (abortRef.current) break - setCurrentIdx(i) - updateChunk(i, { status: "uploading" }) - - const chunk = parsed.chunks[i] - const chunkFile = new File([chunk.blob], chunk.fileName, { - type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }) - - let jobId: number - try { - const res = await bulkImportParamsOnly(chunkFile) - jobId = res.jobId - updateChunk(i, { status: "polling", jobId }) - } catch (e) { - toast.error(`Chunk ${i + 1} upload failed: ${String(e)}`) - updateChunk(i, { status: "failed" }) - failed.push(i) - continue - } - - let pollErrors = 0 - let done = false - while (!abortRef.current && !done) { - await delay(POLL_MS) - try { - const job = await getImportJob(jobId) - if (job.status === "DONE" || job.status === "PARTIAL" || job.status === "FAILED") { - const ok = job.status !== "FAILED" - updateChunk(i, { - status: ok ? (job.status === "DONE" ? "done" : "partial") : "failed", - inserted: job.success, - }) - if (ok) setTotalInserted((n) => n + (job.success ?? 0)) - else failed.push(i) - done = true - } - } catch { - pollErrors++ - if (pollErrors >= MAX_POLL_ERRORS) { - updateChunk(i, { status: "failed" }) - failed.push(i) - done = true - } - } - } - } - - setFailedChunks(failed) - setStep(failed.length === 0 ? "done" : "failed") - } - - function updateChunk(index: number, patch: Partial) { - setChunks((prev) => - prev.map((c) => (c.index === index ? { ...c, ...patch } : c)) - ) - } - - const doneCount = chunks.filter((c) => c.status === "done" || c.status === "partial").length - const progressPct = parsed ? Math.round((doneCount / parsed.chunks.length) * 100) : 0 - - async function handleTemplate() { - setTemplateLoading(true) - try { await downloadParamsOnlyTemplate() } - catch (e) { toast.error(`Template download failed: ${String(e)}`) } - finally { setTemplateLoading(false) } - } - - function handleClose() { - abortRef.current = true - reset() - onOpenChange(false) - } - - return ( - { if (!v) handleClose() }}> - - - Import Params Only (Bulk) - - -
- - {/* Upload */} - {step === "upload" && ( - <> -
-
- -
-

Params-Only Template

-

- product_parameters + product_applicable_params -

-
-
- -
- -
- -
fileRef.current?.click()} - > - { const f = e.target.files?.[0]; if (f) void handleParse(f) }} /> - -

Click to select — any size

-

- File split automatically into {PARAMS_ONLY_CONFIG.chunkSize ?? 300}-product chunks -

-
-
- -
- - - Large files are split and uploaded chunk-by-chunk. Each chunk runs - independently — failed chunks can be retried without restarting. - -
- - )} - - {/* Parsing */} - {step === "parsing" && ( -
- -
-

Parsing file in browser…

-

{parseStage}

-

Large files may take 30–60 s

-
- {file &&

{file.name} — {(file.size / 1024 / 1024).toFixed(1)} MB

} -
- )} - - {/* Ready */} - {step === "ready" && parsed && ( - <> -
-

File parsed

-
-

Products

{parsed.totalKeys.toLocaleString()}

-

Total rows

{parsed.totalRows.toLocaleString()}

-

Chunks

{parsed.chunks.length}

-
-

- ~{PARAMS_ONLY_CONFIG.chunkSize ?? 300} products per chunk · ~{Math.round(parsed.totalRows / parsed.chunks.length).toLocaleString()} rows each · est. 3–8 min per chunk -

-
-
- - Products must already exist from a prior bulk routing import. -
- - )} - - {/* Importing */} - {step === "importing" && parsed && ( -
-
- Chunk {currentIdx + 1} / {parsed.chunks.length} - {totalInserted.toLocaleString()} rows imported -
- -

{doneCount}/{parsed.chunks.length} complete ({progressPct}%)

-
-
- {chunks.map((c) => ( - - ))} -
-
-
- )} - - {/* Done */} - {step === "done" && ( -
- -
-

- Import complete — {totalInserted.toLocaleString()} rows imported -

-

- {parsed?.chunks.length} chunks processed - {chunks.filter((c) => c.status === "partial").length > 0 && - ` · ${chunks.filter((c) => c.status === "partial").length} with partial errors`} -

- -
-
- )} - - {/* Failed */} - {step === "failed" && ( -
-
- -
-

- {failedChunks.length} chunk{failedChunks.length > 1 ? "s" : ""} failed - {totalInserted > 0 && ` — ${totalInserted.toLocaleString()} rows already imported`} -

-

- Failed: chunks {failedChunks.map((i) => i + 1).join(", ")} -

-
-
-
-
- {chunks.map((c) => )} -
-
-
- )} -
- - - {(step === "upload" || step === "done" || step === "failed") && ( - - )} - {step === "ready" && ( - <> - - - - )} - {step === "importing" && ( - - )} - {step === "failed" && failedChunks.length > 0 && ( - - )} - -
-
- ) -} - -function ChunkBadge({ chunk, current }: { chunk: ChunkStatus; current: boolean }) { - const color: Record = { - pending: "bg-muted text-muted-foreground", - uploading: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", - polling: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200", - done: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200", - partial: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-200", - failed: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200", - } - return ( - - {chunk.index + 1} - {current && (chunk.status === "uploading" || chunk.status === "polling") && ( - - )} - - ) -} - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} diff --git a/src/lib/excel/chunked-importer.ts b/src/lib/excel/chunked-importer.ts deleted file mode 100644 index 5873180..0000000 --- a/src/lib/excel/chunked-importer.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * chunked-importer.ts — Generic browser-side Excel chunker. - * - * Uses a Web Worker (chunked-importer.worker.ts) to parse large Excel files - * off the main thread so the UI stays responsive. - * - * ### Usage - * - * ```ts - * import { chunkExcelFile, PARAMS_ONLY_CONFIG } from "@/lib/excel/chunked-importer" - * - * const result = await chunkExcelFile(file, PARAMS_ONLY_CONFIG, (stage) => - * console.log(stage) - * ) - * // result.chunks is ParsedChunk[] ready to upload - * ``` - * - * ### Adding a new import format - * - * Create a new `ChunkerConfig` constant — no code changes required: - * - * ```ts - * export const MY_FORMAT_CONFIG: ChunkerConfig = { - * sheetGroups: [ - * { baseName: "my_sheet", outputName: "my_sheet" }, - * ], - * keyColumn: "my_id_column", - * chunkSize: 500, - * } - * ``` - */ - -export type { SheetGroupConfig } from "./chunked-importer.worker" - -export interface ChunkerConfig { - /** Sheet groups to parse and merge. */ - sheetGroups: import("./chunked-importer.worker").SheetGroupConfig[] - /** Column name used to group rows per unique key (e.g. "legacy_oracle_sys_id"). */ - keyColumn: string - /** Number of unique key values per output chunk. Default: 300. */ - chunkSize?: number -} - -export interface ParsedChunk { - index: number - blob: Blob - fileName: string - /** Number of unique key values in this chunk. */ - keyCount: number - rowCount: number -} - -export interface ChunkResult { - totalKeys: number - totalRows: number - chunks: ParsedChunk[] -} - -// ── Built-in configurations ──────────────────────────────────────────────── - -/** Params-only import: product_parameters + product_applicable_params. */ -export const PARAMS_ONLY_CONFIG: ChunkerConfig = { - sheetGroups: [ - { baseName: "product_parameters", outputName: "product_parameters" }, - { baseName: "product_applicable_params", outputName: "product_applicable_params" }, - ], - keyColumn: "legacy_oracle_sys_id", - chunkSize: 300, -} - -/** - * Bulk product + routing import (product_master + route sheets). - * - * route_sequences and route_rms use "route_head_legacy_product_id" as their - * key column (same value as legacy_oracle_sys_id — the root product ID). - */ -export const BULK_ROUTING_CONFIG: ChunkerConfig = { - sheetGroups: [ - { baseName: "product_master", outputName: "product_master" }, - { baseName: "product_parameters", outputName: "product_parameters" }, - { baseName: "product_applicable_params", outputName: "product_applicable_params" }, - { baseName: "route_head", outputName: "route_head" }, - { baseName: "route_sequences", outputName: "route_sequences", keyColumn: "route_head_legacy_product_id" }, - { baseName: "route_rms", outputName: "route_rms", keyColumn: "route_head_legacy_product_id" }, - ], - keyColumn: "legacy_oracle_sys_id", - chunkSize: 200, -} - -// ── Core function ────────────────────────────────────────────────────────── - -const XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - -/** - * Parse an Excel file using a Web Worker and split into upload-ready chunks. - * - * @param file The Excel file selected by the user. - * @param config Which sheets to parse, which column is the key, chunk size. - * @param onProgress Optional callback for stage strings ("Parsing…", "Indexing…"). - * @returns Resolved when all chunks are built; rejects on parse error. - */ -export function chunkExcelFile( - file: File, - config: ChunkerConfig, - onProgress?: (stage: string) => void -): Promise { - return new Promise((resolve, reject) => { - const worker = new Worker( - new URL("./chunked-importer.worker", import.meta.url) - ) - - const chunks: ParsedChunk[] = [] - let totalKeys = 0 - let totalRows = 0 - let expectedChunks = 0 - - worker.onmessage = (e: MessageEvent) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const msg = e.data - - switch (msg.type) { - case "progress": - onProgress?.(msg.stage as string) - break - - case "ready": - totalKeys = msg.totalKeys as number - totalRows = msg.totalRows as number - expectedChunks = msg.totalChunks as number - onProgress?.(`Building chunks (0/${expectedChunks})…`) - worker.postMessage({ type: "next" }) - break - - case "chunk": { - const blob = new Blob([msg.buffer as ArrayBuffer], { type: XLSX_MIME }) - chunks.push({ - index: msg.index as number, - blob, - fileName: msg.fileName as string, - keyCount: msg.keyCount as number, - rowCount: msg.rowCount as number, - }) - onProgress?.(`Building chunks (${chunks.length}/${expectedChunks})…`) - worker.postMessage({ type: "next" }) - break - } - - case "done": - worker.terminate() - resolve({ totalKeys, totalRows, chunks }) - break - - case "error": - worker.terminate() - reject(new Error(msg.message as string)) - break - } - } - - worker.onerror = (err) => { - worker.terminate() - reject(new Error(`Worker error: ${err.message}`)) - } - - // Transfer the ArrayBuffer — zero-copy, buffer is detached in main thread. - file.arrayBuffer().then((buffer) => { - worker.postMessage( - { - type: "start", - buffer, - keyColumn: config.keyColumn, - sheetGroups: config.sheetGroups, - chunkSize: config.chunkSize ?? 300, - baseName: file.name.replace(/\.xlsx?$/i, ""), - }, - [buffer] - ) - }).catch(reject) - }) -} diff --git a/src/lib/excel/chunked-importer.worker.ts b/src/lib/excel/chunked-importer.worker.ts deleted file mode 100644 index 03ea1d5..0000000 --- a/src/lib/excel/chunked-importer.worker.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * chunked-importer.worker.ts — Generic Web Worker for chunking any Excel import file. - * - * Accepts a flexible SheetGroupConfig so the same worker binary handles any - * import format: params-only, product-routing, RM pricing, etc. - * - * Protocol (all messages are structured-cloneable): - * - * Main → Worker StartMessage — start parsing - * Worker → Main ProgressMsg — stage updates (multiple) - * Worker → Main ReadyMsg — parsing done; N chunks ready - * Main → Worker NextMessage — request next chunk - * Worker → Main ChunkMsg — one chunk (ArrayBuffer transferred, zero-copy) - * Worker → Main DoneMsg — all chunks sent - * Worker → Main ErrorMsg — fatal error - */ -import * as XLSX from "xlsx" - -// ── Public message types (re-exported via chunked-importer.ts) ───────────── - -export interface SheetGroupConfig { - /** Base name to match — exact match wins, otherwise contains-match (case-insensitive). */ - baseName: string - /** Sheet name written into each output chunk file. */ - outputName: string - /** - * Per-sheet key column override. Falls back to StartMessage.keyColumn when omitted. - * Use when a sheet uses a different column name for the same logical key - * (e.g. route_sequences uses "route_head_legacy_product_id" instead of "legacy_oracle_sys_id"). - */ - keyColumn?: string -} - -export interface StartMessage { - type: "start" - /** Raw ArrayBuffer of the .xlsx file — transferred (zero-copy). */ - buffer: ArrayBuffer - /** Column name used to group rows into per-key-value buckets. */ - keyColumn: string - /** Sheet groups to parse and merge. */ - sheetGroups: SheetGroupConfig[] - /** Number of unique key values per chunk. */ - chunkSize: number - /** Stem used to generate chunk file names (e.g. "params_2026"). */ - baseName: string -} - -export interface NextMessage { - type: "next" -} - -export interface ProgressMsg { - type: "progress" - stage: string -} - -export interface ReadyMsg { - type: "ready" - totalKeys: number - totalRows: number - totalChunks: number -} - -export interface ChunkMsg { - type: "chunk" - index: number - total: number - /** Transferred ArrayBuffer — detached from worker heap after postMessage. */ - buffer: ArrayBuffer - fileName: string - keyCount: number - rowCount: number -} - -export interface DoneMsg { - type: "done" -} - -export interface ErrorMsg { - type: "error" - message: string -} - -export type WorkerOutMsg = ProgressMsg | ReadyMsg | ChunkMsg | DoneMsg | ErrorMsg -type WorkerInMsg = StartMessage | NextMessage - -// ── Internal state ───────────────────────────────────────────────────────── - -interface ParsedGroup { - headers: unknown[] - rowsByKey: Map - outputName: string -} - -let groups: ParsedGroup[] = [] -let masterKeys: string[] = [] -let currentChunkSize = 300 -let currentBaseName = "" -let remaining: number[] = [] // chunk indices yet to be sent - -// ── Message handler ──────────────────────────────────────────────────────── - -self.onmessage = (e: MessageEvent) => { - if (e.data.type === "start") { - handleStart(e.data).catch((err: unknown) => { - emit({ type: "error", message: String(err) }) - }) - } else if (e.data.type === "next") { - sendNextChunk() - } -} - -// ── Parse ────────────────────────────────────────────────────────────────── - -async function handleStart(msg: StartMessage) { - currentChunkSize = msg.chunkSize - currentBaseName = msg.baseName - groups = [] - masterKeys = [] - - emit({ type: "progress", stage: "Parsing Excel file… (please wait)" }) - - const wb = XLSX.read(new Uint8Array(msg.buffer), { type: "array", dense: true }) - - const keySet = new Set() - let grandTotal = 0 - - for (const groupCfg of msg.sheetGroups) { - emit({ type: "progress", stage: `Indexing "${groupCfg.baseName}"…` }) - - const matching = findMatchingSheets(wb, groupCfg.baseName) - if (matching.length === 0) continue - - let headers: unknown[] = [] - let keyColIdx = -1 - const rowsByKey = new Map() - - for (let si = 0; si < matching.length; si++) { - const ws = wb.Sheets[matching[si]] - const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null }) - - if (si === 0) { - headers = (rows[0] as (string | null)[]).map((h) => - h != null ? String(h).trim() : "" - ) - const effectiveKeyCol = groupCfg.keyColumn ?? msg.keyColumn - keyColIdx = (headers as string[]).indexOf(effectiveKeyCol) - if (keyColIdx === -1) { - emit({ - type: "error", - message: `Sheet "${matching[0]}" does not contain key column "${effectiveKeyCol}". Found: ${headers.filter(Boolean).join(", ")}`, - }) - return - } - } - - const startRow = si === 0 ? 1 : 1 // skip header in all sheets - for (let r = startRow; r < rows.length; r++) { - const row = rows[r] as unknown[] - if (!row || row.every((v) => v == null || v === "")) continue - const rawKey = row[keyColIdx] - if (rawKey == null || rawKey === "") continue - const key = String(rawKey).trim() - if (!rowsByKey.has(key)) rowsByKey.set(key, []) - rowsByKey.get(key)!.push(row) - grandTotal++ - if (!keySet.has(key)) { - keySet.add(key) - masterKeys.push(key) - } - } - } - - groups.push({ headers, rowsByKey, outputName: groupCfg.outputName }) - } - - if (groups.length === 0) { - emit({ type: "error", message: "No matching sheets found. Check sheet names." }) - return - } - - const totalChunks = Math.ceil(masterKeys.length / currentChunkSize) - remaining = Array.from({ length: totalChunks }, (_, i) => i) - - emit({ - type: "ready", - totalKeys: masterKeys.length, - totalRows: grandTotal, - totalChunks, - }) -} - -// ── Chunk generation ─────────────────────────────────────────────────────── - -function sendNextChunk() { - if (remaining.length === 0) { - emit({ type: "done" }) - return - } - - const idx = remaining.shift()! - const start = idx * currentChunkSize - const batchKeys = masterKeys.slice(start, start + currentChunkSize) - const total = Math.ceil(masterKeys.length / currentChunkSize) - - const chunkWb = XLSX.utils.book_new() - let chunkRows = 0 - - for (const group of groups) { - const data: unknown[][] = [group.headers] - for (const key of batchKeys) { - const rows = group.rowsByKey.get(key) ?? [] - data.push(...rows) - chunkRows += rows.length - } - XLSX.utils.book_append_sheet(chunkWb, XLSX.utils.aoa_to_sheet(data), group.outputName) - } - - const rawArr = XLSX.write(chunkWb, { - type: "array", - bookType: "xlsx", - compression: true, - }) as number[] - - // Copy into a plain ArrayBuffer so it is transferable (zero-copy to main thread). - const ab = new Uint8Array(rawArr).buffer as ArrayBuffer - - const msg: ChunkMsg = { - type: "chunk", - index: idx, - total, - buffer: ab, - fileName: `${currentBaseName}_chunk_${String(idx + 1).padStart(3, "0")}.xlsx`, - keyCount: batchKeys.length, - rowCount: chunkRows, - } - - // Transfer ab — removed from worker heap, no copy on the wire. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(self as any).postMessage(msg, [ab]) -} - -// ── Helpers ──────────────────────────────────────────────────────────────── - -function findMatchingSheets(wb: XLSX.WorkBook, baseName: string): string[] { - const lower = baseName.toLowerCase() - const exact = wb.SheetNames.filter((n) => n === baseName) - if (exact.length > 0) { - // Include the exact match PLUS any continuation sheets (e.g. "params_2") - const extras = wb.SheetNames.filter( - (n) => n !== baseName && n.toLowerCase().includes(lower) - ).sort() - return [...exact, ...extras] - } - return wb.SheetNames.filter((n) => n.toLowerCase().includes(lower)).sort() -} - -function emit(msg: WorkerOutMsg) { - self.postMessage(msg) -} diff --git a/src/services/finance/cost-import-api.ts b/src/services/finance/cost-import-api.ts index 2ecd952..2b62ad3 100644 --- a/src/services/finance/cost-import-api.ts +++ b/src/services/finance/cost-import-api.ts @@ -5,9 +5,7 @@ import type { SyncImportResult, AsyncImportResponse, ImportEntity, - BulkValidationResult, - BulkSheetValidationResult, - BulkRowError, + ImportKindKey, } from "@/types/finance/cost-import" const BASE = "/api/v1/finance/costing" @@ -145,53 +143,78 @@ export async function listImportJobs( } } -// ── Bulk Product Routing ──────────────────────────────────────────────────── +// ── Bulk ETL import (v2 — presigned upload) ───────────────────────────────── /** - * Queue a bulk import of product master + routing data from an Excel file. - * Returns a job ID that can be polled with getImportJob(). + * Step 1 of the ETL import flow: ask the backend for a presigned PUT URL so the + * browser can upload the file directly to object storage (bypassing the BFF and + * gRPC message path entirely — no size inflation, no OOM). */ -export async function bulkImportProductMasterRouting( - file: File, - duplicateAction?: string, -): Promise<{ jobId: number; status: string }> { - const fileContent = Array.from(new Uint8Array(await file.arrayBuffer())) - const res = await fetch(`${BASE}/import/bulk_product_routing`, { +export async function getImportUploadURL( + kind: ImportKindKey, + fileName: string, +): Promise<{ uploadUrl: string; objectKey: string; expiresInSeconds: number }> { + const res = await fetch(`${BASE}/import/upload-url`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - fileContent, - fileName: file.name, - duplicateAction: duplicateAction ?? "update", - }), + body: JSON.stringify({ kind, fileName }), }) - if (!res.ok) throw new Error(`Bulk import failed: ${res.status}`) - const json = await res.json() + const json = await res.json().catch(() => null) + if (!res.ok || json?.base?.isSuccess === false) { + throw new Error(json?.base?.message || `Failed to get upload URL: ${res.status}`) + } return { - jobId: json.data?.jobId ?? json.data?.job_id ?? 0, - status: json.data?.status ?? "", + uploadUrl: json.uploadUrl ?? json.upload_url ?? "", + objectKey: json.objectKey ?? json.object_key ?? "", + expiresInSeconds: Number(json.expiresInSeconds ?? json.expires_in_seconds ?? 0), } } /** - * Queue a params-only bulk import (product_parameters + product_applicable_params). - * Products must already exist from a prior bulk_product_routing import. - * Uses multipart/form-data to send binary bytes without JSON inflation overhead. - * Returns a job ID that can be polled with getImportJob(). + * Step 2 of the ETL import flow: PUT the file straight to the presigned MinIO URL + * with upload progress. Uses XMLHttpRequest because fetch() cannot report upload + * progress. Requires MinIO CORS to allow PUT from the app origin. */ -export async function bulkImportParamsOnly( +export function putToPresignedUrl( + uploadUrl: string, file: File, + onProgress?: (percent: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("PUT", uploadUrl, true) + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)) + } + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve() + else reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)) + } + xhr.onerror = () => + reject(new Error("Upload failed — check network / storage CORS configuration")) + xhr.send(file) + }) +} + +/** + * Step 3 of the ETL import flow: tell the backend to start the async ETL job for + * the already-uploaded object. Returns the job ID to poll with getImportJob(). + */ +export async function startCostingImport( + kind: ImportKindKey, + objectKey: string, + fileName: string, ): Promise<{ jobId: number; status: string }> { - const formData = new FormData() - formData.append("file", file) - const res = await fetch(`${BASE}/import/bulk_params_only`, { + const res = await fetch(`${BASE}/import/start`, { method: "POST", - body: formData, // binary multipart — no Content-Type header needed (browser sets boundary) + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, objectKey, fileName }), }) - if (!res.ok) throw new Error(`Params-only import failed: ${res.status}`) - const json = await res.json() - if (!json.base?.isSuccess) { - throw new Error(json.base?.message || `Import failed`) + const json = await res.json().catch(() => null) + if (!res.ok || json?.base?.isSuccess === false) { + throw new Error(json?.base?.message || `Failed to start import: ${res.status}`) } return { jobId: json.data?.jobId ?? json.data?.job_id ?? 0, @@ -214,62 +237,6 @@ export async function downloadParamsOnlyTemplate(): Promise { URL.revokeObjectURL(url) } -/** - * Validate a bulk product routing Excel file without importing. - * Returns a per-sheet summary with error/warning counts and sample errors. - */ -export async function validateBulkProductRoutingFile( - file: File, -): Promise { - const fileContent = Array.from(new Uint8Array(await file.arrayBuffer())) - const res = await fetch(`${BASE}/validate/bulk_product_routing`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileContent, fileName: file.name }), - }) - if (!res.ok) { - const errJson = await res.json().catch(() => null) - throw new Error(errJson?.base?.message || `Validation failed: ${res.status}`) - } - const json = await res.json() - - if (!json.base?.isSuccess && json.base?.isSuccess !== undefined) { - throw new Error(json.base?.message || "Validation request failed") - } - - // Normalise: handle both camelCase (gRPC-gateway) and snake_case (raw proto) - const isValid: boolean = json.isValid ?? json.is_valid ?? false - const rawSheets: unknown[] = Array.isArray(json.sheets) ? json.sheets : [] - - const sheets: BulkSheetValidationResult[] = rawSheets.map((s) => { - const sheet = s as Record - const rawErrors: unknown[] = Array.isArray(sheet.sampleErrors) - ? (sheet.sampleErrors as unknown[]) - : Array.isArray(sheet.sample_errors) - ? (sheet.sample_errors as unknown[]) - : [] - - const sampleErrors: BulkRowError[] = rawErrors.map((e) => { - const err = e as Record - return { - rowNumber: Number(err.rowNumber ?? err.row_number ?? 0), - field: String(err.field ?? ""), - message: String(err.message ?? ""), - } - }) - - return { - sheetName: String(sheet.sheetName ?? sheet.sheet_name ?? ""), - totalRows: Number(sheet.totalRows ?? sheet.total_rows ?? 0), - errorCount: Number(sheet.errorCount ?? sheet.error_count ?? 0), - warningCount: Number(sheet.warningCount ?? sheet.warning_count ?? 0), - sampleErrors, - } - }) - - return { isValid, sheets } -} - /** * Queue an async export of product master + routing data to MinIO. * When productSysIds is provided, exports only those products and their full diff --git a/src/types/finance/cost-import.ts b/src/types/finance/cost-import.ts index 38b92f5..32691bc 100644 --- a/src/types/finance/cost-import.ts +++ b/src/types/finance/cost-import.ts @@ -101,24 +101,12 @@ export const ENTITY_BULK_PRODUCT_ROUTING = "bulk_product_routing" /** Entity constant for the bulk export job (product master + routing). */ export const ENTITY_BULK_PRODUCT_ROUTING_EXPORT = "bulk_product_routing_export" -/** Row-level error from a single sheet during bulk validation. */ -export interface BulkRowError { - rowNumber: number - field: string - message: string -} +// ── v2 ETL presigned-upload import ────────────────────────────────────────── -/** Per-sheet result from ValidateBulkProductRoutingFile. */ -export interface BulkSheetValidationResult { - sheetName: string - totalRows: number - errorCount: number - warningCount: number - sampleErrors: BulkRowError[] -} - -/** Full result from ValidateBulkProductRoutingFile. */ -export interface BulkValidationResult { - isValid: boolean - sheets: BulkSheetValidationResult[] -} +/** + * ImportKindKey is the client-facing key for the dataset an ETL import carries. + * It maps 1:1 to the backend proto `ImportKind` enum: + * "product_routing" → IMPORT_KIND_PRODUCT_ROUTING (xlsx or zipped CSV) + * "params_only" → IMPORT_KIND_PARAMS_ONLY (zip of CSV files) + */ +export type ImportKindKey = "product_routing" | "params_only" diff --git a/src/types/generated/finance/v1/cost_import.ts b/src/types/generated/finance/v1/cost_import.ts index 5d39441..30b7834 100644 --- a/src/types/generated/finance/v1/cost_import.ts +++ b/src/types/generated/finance/v1/cost_import.ts @@ -10,6 +10,49 @@ import { BaseResponse, PaginationRequest, PaginationResponse } from "../../commo export const protobufPackage = "finance.v1"; +/** ImportKind identifies which dataset a presigned upload / import job carries. */ +export enum ImportKind { + /** IMPORT_KIND_UNSPECIFIED - Default — invalid, must be set by the caller. */ + IMPORT_KIND_UNSPECIFIED = 0, + /** IMPORT_KIND_PRODUCT_ROUTING - Product master + routing dataset (xlsx or zip CSV). */ + IMPORT_KIND_PRODUCT_ROUTING = 1, + /** IMPORT_KIND_PARAMS_ONLY - Params-only dataset (zip of CSV files). */ + IMPORT_KIND_PARAMS_ONLY = 2, + UNRECOGNIZED = -1, +} + +export function importKindFromJSON(object: any): ImportKind { + switch (object) { + case 0: + case "IMPORT_KIND_UNSPECIFIED": + return ImportKind.IMPORT_KIND_UNSPECIFIED; + case 1: + case "IMPORT_KIND_PRODUCT_ROUTING": + return ImportKind.IMPORT_KIND_PRODUCT_ROUTING; + case 2: + case "IMPORT_KIND_PARAMS_ONLY": + return ImportKind.IMPORT_KIND_PARAMS_ONLY; + case -1: + case "UNRECOGNIZED": + default: + return ImportKind.UNRECOGNIZED; + } +} + +export function importKindToJSON(object: ImportKind): string { + switch (object) { + case ImportKind.IMPORT_KIND_UNSPECIFIED: + return "IMPORT_KIND_UNSPECIFIED"; + case ImportKind.IMPORT_KIND_PRODUCT_ROUTING: + return "IMPORT_KIND_PRODUCT_ROUTING"; + case ImportKind.IMPORT_KIND_PARAMS_ONLY: + return "IMPORT_KIND_PARAMS_ONLY"; + case ImportKind.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + export interface CostImportJob { jobId: number; entity: string; @@ -137,7 +180,7 @@ export interface ImportBulkProductRoutingResponse { * ImportBulkParamsOnlyRequest imports product_parameters + product_applicable_params * from a file that does NOT contain a product_master sheet. Products must already * exist in the database from a prior bulk import. Supports split part-sheets - * (e.g. "product_parameters_p1" + "_p2"). Max 50 MB. + * (e.g. "product_parameters_p1" + "_p2"). Max 200 MB. */ export interface ImportBulkParamsOnlyRequest { fileContent: Uint8Array; @@ -181,6 +224,44 @@ export interface ExportBulkProductRoutingResponse { status: string; } +/** + * GetImportUploadURLRequest asks for a presigned PUT URL to upload an import + * file directly to object storage (bypassing the BFF and gRPC message path). + */ +export interface GetImportUploadURLRequest { + kind: ImportKind; + fileName: string; +} + +/** + * GetImportUploadURLResponse returns the presigned PUT URL plus the object key + * to pass back to StartCostingImport once the upload completes. + */ +export interface GetImportUploadURLResponse { + base: BaseResponse | undefined; + uploadUrl: string; + objectKey: string; + expiresInSeconds: number; +} + +/** + * StartCostingImportRequest starts an async ETL import job for an already + * uploaded object (identified by object_key from GetImportUploadURL). + */ +export interface StartCostingImportRequest { + kind: ImportKind; + objectKey: string; + fileName: string; + duplicateAction: string; +} + +/** StartCostingImportResponse returns the created async job ID and its status. */ +export interface StartCostingImportResponse { + base: BaseResponse | undefined; + jobId: number; + status: string; +} + function createBaseCostImportJob(): CostImportJob { return { jobId: 0, @@ -2859,6 +2940,426 @@ export const ExportBulkProductRoutingResponse: MessageFns = { + encode(message: GetImportUploadURLRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.kind !== 0) { + writer.uint32(8).int32(message.kind); + } + if (message.fileName !== "") { + writer.uint32(18).string(message.fileName); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetImportUploadURLRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetImportUploadURLRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.kind = reader.int32() as any; + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.fileName = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): GetImportUploadURLRequest { + return { + kind: isSet(object.kind) ? importKindFromJSON(object.kind) : 0, + fileName: isSet(object.fileName) + ? globalThis.String(object.fileName) + : isSet(object.file_name) + ? globalThis.String(object.file_name) + : "", + }; + }, + + toJSON(message: GetImportUploadURLRequest): unknown { + const obj: any = {}; + if (message.kind !== 0) { + obj.kind = importKindToJSON(message.kind); + } + if (message.fileName !== "") { + obj.fileName = message.fileName; + } + return obj; + }, + + create(base?: DeepPartial): GetImportUploadURLRequest { + return GetImportUploadURLRequest.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): GetImportUploadURLRequest { + const message = createBaseGetImportUploadURLRequest(); + message.kind = object.kind ?? 0; + message.fileName = object.fileName ?? ""; + return message; + }, +}; + +function createBaseGetImportUploadURLResponse(): GetImportUploadURLResponse { + return { base: undefined, uploadUrl: "", objectKey: "", expiresInSeconds: 0 }; +} + +export const GetImportUploadURLResponse: MessageFns = { + encode(message: GetImportUploadURLResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.base !== undefined) { + BaseResponse.encode(message.base, writer.uint32(10).fork()).join(); + } + if (message.uploadUrl !== "") { + writer.uint32(18).string(message.uploadUrl); + } + if (message.objectKey !== "") { + writer.uint32(26).string(message.objectKey); + } + if (message.expiresInSeconds !== 0) { + writer.uint32(32).int64(message.expiresInSeconds); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetImportUploadURLResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetImportUploadURLResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.base = BaseResponse.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.uploadUrl = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.objectKey = reader.string(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.expiresInSeconds = longToNumber(reader.int64()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): GetImportUploadURLResponse { + return { + base: isSet(object.base) ? BaseResponse.fromJSON(object.base) : undefined, + uploadUrl: isSet(object.uploadUrl) + ? globalThis.String(object.uploadUrl) + : isSet(object.upload_url) + ? globalThis.String(object.upload_url) + : "", + objectKey: isSet(object.objectKey) + ? globalThis.String(object.objectKey) + : isSet(object.object_key) + ? globalThis.String(object.object_key) + : "", + expiresInSeconds: isSet(object.expiresInSeconds) + ? globalThis.Number(object.expiresInSeconds) + : isSet(object.expires_in_seconds) + ? globalThis.Number(object.expires_in_seconds) + : 0, + }; + }, + + toJSON(message: GetImportUploadURLResponse): unknown { + const obj: any = {}; + if (message.base !== undefined) { + obj.base = BaseResponse.toJSON(message.base); + } + if (message.uploadUrl !== "") { + obj.uploadUrl = message.uploadUrl; + } + if (message.objectKey !== "") { + obj.objectKey = message.objectKey; + } + if (message.expiresInSeconds !== 0) { + obj.expiresInSeconds = Math.round(message.expiresInSeconds); + } + return obj; + }, + + create(base?: DeepPartial): GetImportUploadURLResponse { + return GetImportUploadURLResponse.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): GetImportUploadURLResponse { + const message = createBaseGetImportUploadURLResponse(); + message.base = (object.base !== undefined && object.base !== null) + ? BaseResponse.fromPartial(object.base) + : undefined; + message.uploadUrl = object.uploadUrl ?? ""; + message.objectKey = object.objectKey ?? ""; + message.expiresInSeconds = object.expiresInSeconds ?? 0; + return message; + }, +}; + +function createBaseStartCostingImportRequest(): StartCostingImportRequest { + return { kind: 0, objectKey: "", fileName: "", duplicateAction: "" }; +} + +export const StartCostingImportRequest: MessageFns = { + encode(message: StartCostingImportRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.kind !== 0) { + writer.uint32(8).int32(message.kind); + } + if (message.objectKey !== "") { + writer.uint32(18).string(message.objectKey); + } + if (message.fileName !== "") { + writer.uint32(26).string(message.fileName); + } + if (message.duplicateAction !== "") { + writer.uint32(34).string(message.duplicateAction); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): StartCostingImportRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStartCostingImportRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.kind = reader.int32() as any; + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.objectKey = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.fileName = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.duplicateAction = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): StartCostingImportRequest { + return { + kind: isSet(object.kind) ? importKindFromJSON(object.kind) : 0, + objectKey: isSet(object.objectKey) + ? globalThis.String(object.objectKey) + : isSet(object.object_key) + ? globalThis.String(object.object_key) + : "", + fileName: isSet(object.fileName) + ? globalThis.String(object.fileName) + : isSet(object.file_name) + ? globalThis.String(object.file_name) + : "", + duplicateAction: isSet(object.duplicateAction) + ? globalThis.String(object.duplicateAction) + : isSet(object.duplicate_action) + ? globalThis.String(object.duplicate_action) + : "", + }; + }, + + toJSON(message: StartCostingImportRequest): unknown { + const obj: any = {}; + if (message.kind !== 0) { + obj.kind = importKindToJSON(message.kind); + } + if (message.objectKey !== "") { + obj.objectKey = message.objectKey; + } + if (message.fileName !== "") { + obj.fileName = message.fileName; + } + if (message.duplicateAction !== "") { + obj.duplicateAction = message.duplicateAction; + } + return obj; + }, + + create(base?: DeepPartial): StartCostingImportRequest { + return StartCostingImportRequest.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): StartCostingImportRequest { + const message = createBaseStartCostingImportRequest(); + message.kind = object.kind ?? 0; + message.objectKey = object.objectKey ?? ""; + message.fileName = object.fileName ?? ""; + message.duplicateAction = object.duplicateAction ?? ""; + return message; + }, +}; + +function createBaseStartCostingImportResponse(): StartCostingImportResponse { + return { base: undefined, jobId: 0, status: "" }; +} + +export const StartCostingImportResponse: MessageFns = { + encode(message: StartCostingImportResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.base !== undefined) { + BaseResponse.encode(message.base, writer.uint32(10).fork()).join(); + } + if (message.jobId !== 0) { + writer.uint32(16).int64(message.jobId); + } + if (message.status !== "") { + writer.uint32(26).string(message.status); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): StartCostingImportResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStartCostingImportResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.base = BaseResponse.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.jobId = longToNumber(reader.int64()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.status = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): StartCostingImportResponse { + return { + base: isSet(object.base) ? BaseResponse.fromJSON(object.base) : undefined, + jobId: isSet(object.jobId) + ? globalThis.Number(object.jobId) + : isSet(object.job_id) + ? globalThis.Number(object.job_id) + : 0, + status: isSet(object.status) ? globalThis.String(object.status) : "", + }; + }, + + toJSON(message: StartCostingImportResponse): unknown { + const obj: any = {}; + if (message.base !== undefined) { + obj.base = BaseResponse.toJSON(message.base); + } + if (message.jobId !== 0) { + obj.jobId = Math.round(message.jobId); + } + if (message.status !== "") { + obj.status = message.status; + } + return obj; + }, + + create(base?: DeepPartial): StartCostingImportResponse { + return StartCostingImportResponse.fromPartial(base ?? {}); + }, + fromPartial(object: DeepPartial): StartCostingImportResponse { + const message = createBaseStartCostingImportResponse(); + message.base = (object.base !== undefined && object.base !== null) + ? BaseResponse.fromPartial(object.base) + : undefined; + message.jobId = object.jobId ?? 0; + message.status = object.status ?? ""; + return message; + }, +}; + export type CostDataImportServiceDefinition = typeof CostDataImportServiceDefinition; export const CostDataImportServiceDefinition = { name: "CostDataImportService", @@ -2964,6 +3465,30 @@ export const CostDataImportServiceDefinition = { responseStream: false, options: {}, }, + /** + * GetImportUploadURL returns a presigned PUT URL for direct browser upload of + * an import file to object storage. + */ + getImportUploadURL: { + name: "GetImportUploadURL", + requestType: GetImportUploadURLRequest, + requestStream: false, + responseType: GetImportUploadURLResponse, + responseStream: false, + options: {}, + }, + /** + * StartCostingImport starts an async ETL import job for a previously uploaded + * object (referenced by object_key). + */ + startCostingImport: { + name: "StartCostingImport", + requestType: StartCostingImportRequest, + requestStream: false, + responseType: StartCostingImportResponse, + responseStream: false, + options: {}, + }, }, } as const;