diff options
Diffstat (limited to 'lib/dolce/actions.ts')
| -rw-r--r-- | lib/dolce/actions.ts | 914 |
1 files changed, 914 insertions, 0 deletions
diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts new file mode 100644 index 00000000..a9cda76a --- /dev/null +++ b/lib/dolce/actions.ts @@ -0,0 +1,914 @@ +"use server"; + +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import db from "@/db/db"; +import { vendors, users, contracts, projects } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; + +// ============================================================================ +// 타입 정의 +// ============================================================================ + +// B3 벤더 도면 아이템 +export interface DwgReceiptItem { + AppDwg_PlanDate: string; + AppDwg_ResultDate: string; + CreateDt: string; + CreateUserENM: string | null; + CreateUserId: string | null; + CreateUserNo: string; + Discipline: string; + DrawingKind: string; + DrawingMoveGbn: string; + DrawingName: string; + DrawingNo: string; + Manager: string; + ManagerENM: string; + ManagerNo: string; + ProjectNo: string; + RegisterGroup: number; + RegisterGroupId: number; + VendorCode: string; + VendorName: string; + WorDwg_PlanDate: string; + WorDwg_ResultDate: string; +} + +// B4 (GTT) 벤더 도면 아이템 +export interface GttDwgReceiptItem { + CGbn: string | null; + CreateDt: string; + CreateUserENM: string; + CreateUserId: string; + CreateUserNo: string; + DGbn: string | null; + DegreeGbn: string | null; + DeptGbn: string | null; + Discipline: string; + DrawingKind: string; + DrawingMoveGbn: string; // "도면제출" 또는 "도면입수" + DrawingName: string; + DrawingNo: string; + GTTInput_PlanDate: string | null; + GTTInput_ResultDate: string | null; + GTTPreDwg_PlanDate: string | null; + GTTPreDwg_ResultDate: string | null; + GTTWorkingDwg_PlanDate: string | null; + GTTWorkingDwg_ResultDate: string | null; + JGbn: string | null; + Manager: string; + ManagerENM: string; + ManagerNo: string; + ProjectNo: string; + RegisterGroup: number; + RegisterGroupId: number; + SGbn: string | null; + SHIDrawingNo: string | null; +} + +// 통합 도면 아이템 타입 +export type UnifiedDwgReceiptItem = DwgReceiptItem | GttDwgReceiptItem; + +// 타입 가드는 클라이언트에서 직접 DrawingKind === "B4" 체크로 대체 +// (서버 액션 export 함수는 반드시 async여야 하므로 타입 가드는 export 불가) + +export interface DetailDwgReceiptItem { + Category: string; + CategoryENM: string; + CategoryNM: string; + CreateDt: string; + CreateUserENM: string; + CreateUserId: string; + CreateUserNM: string; + Discipline: string; + DrawingKind: string; + DrawingName: string; + DrawingNo: string; + DrawingRevNo: string; + DrawingUsage: string; + DrawingUsageENM: string | null; + DrawingUsageNM: string; + Manager: string; + Mode: string | null; + OFDC_NO: string; + ProjectNo: string; + Receiver: string | null; + RegCompanyCode: string | null; + RegCompanyENM: string | null; + RegCompanyNM: string | null; + RegisterDesc: string; + RegisterGroup: number; + RegisterGroupId: number; + RegisterId: number; + RegisterKind: string; + RegisterKindENM: string | null; + RegisterKindNM: string; + RegisterSerialNo: number; + SHINote: string | null; + Status: string; + UploadId: string; +} + +export interface FileInfoItem { + CreateDt: string; + CreateUserId: string; + Deleted: string; + FileDescription: string; + FileId: string; + FileName: string; + FileRelativePath: string; + FileSeq: string; + FileServerId: string; + FileSize: string; + FileTitle: string | null; + FileWriteDT: string; + OwnerUserId: string; + SourceDrmYn: string | null; + SystemId: string; + TableName: string; + UploadId: string; + UseYn: string; +} + +export interface DetailDwgEditRequest { + Mode: "ADD" | "MOD"; + Status: string; + RegisterId: number; + ProjectNo: string; + Discipline: string; + DrawingKind: string; + DrawingNo: string; + DrawingName: string; + RegisterGroupId: number; + RegisterSerialNo: number; + RegisterKind: string; + DrawingRevNo: string; + Category: string; + Receiver: string | null; + Manager: string; + RegisterDesc: string; + UploadId: string; + RegCompanyCode: string; +} + +// ============================================================================ +// 유틸리티 함수 +// ============================================================================ + +async function dolceApiCall<T>(endpoint: string, body: Record<string, unknown>): Promise<T> { + const url = `${DOLCE_API_URL}/Services/VDCSWebService.svc/${endpoint}`; + + console.log(`[DOLCE API] Calling ${endpoint}:`, JSON.stringify(body, null, 2)); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`DOLCE API 오류 (${endpoint}): ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log(`[DOLCE API] Response from ${endpoint}:`, JSON.stringify(data, null, 2)); + + return data; +} + +// ============================================================================ +// 서버 액션 +// ============================================================================ + +/** + * 1. 도면 리스트 조회 (B3 및 B4 통합) + */ +export async function fetchDwgReceiptList(params: { + project: string; + drawingKind: string; + drawingMoveGbn?: string; + drawingNo?: string; + drawingName?: string; + drawingVendor?: string; + discipline?: string; +}): Promise<UnifiedDwgReceiptItem[]> { + try { + const response = await dolceApiCall<{ + DwgReceiptMgmtResult: { + FMEADwgList: unknown[]; + GTTDwgList: GttDwgReceiptItem[]; + VendorDwgList: DwgReceiptItem[]; + }; + }>("DwgReceiptMgmt", { + project: params.project, + drawingKind: params.drawingKind, + drawingMoveGbn: params.drawingMoveGbn || "", + drawingNo: params.drawingNo || "", + drawingName: params.drawingName || "", + drawingVendor: params.drawingVendor || "", + discipline: params.discipline || "", + }); + + // B4(GTT)인 경우 GTTDwgList 반환, B3인 경우 VendorDwgList 반환 + if (params.drawingKind === "B4") { + return response.DwgReceiptMgmtResult.GTTDwgList; + } else { + return response.DwgReceiptMgmtResult.VendorDwgList; + } + } catch (error) { + console.error("도면 리스트 조회 실패:", error); + throw error; + } +} + +/** + * 2. 상세도면 리스트 조회 + */ +export async function fetchDetailDwgReceiptList(params: { + project: string; + drawingNo: string; + discipline: string; + drawingKind: string; + userId?: string; +}): Promise<DetailDwgReceiptItem[]> { + try { + const response = await dolceApiCall<{ + DetailDwgReceiptMgmtResult: DetailDwgReceiptItem[]; + }>("DetailDwgReceiptMgmt", { + project: params.project, + drawingNo: params.drawingNo, + discipline: params.discipline, + drawingKind: params.drawingKind, + userId: params.userId || "", + }); + + return response.DetailDwgReceiptMgmtResult; + } catch (error) { + console.error("상세도면 리스트 조회 실패:", error); + throw error; + } +} + +/** + * 3. 개별 상세도면 파일 리스트 조회 + */ +export async function fetchFileInfoList(uploadId: string): Promise<FileInfoItem[]> { + try { + const response = await dolceApiCall<{ + FileInfoListResult: FileInfoItem[]; + }>("FileInfoList", { + uploadId, + }); + + // UseYn이 "True"인 파일만 반환 + return response.FileInfoListResult.filter((file) => file.UseYn === "True"); + } catch (error) { + console.error("파일 리스트 조회 실패:", error); + throw error; + } +} + +/** + * 4. 상세도면 추가/수정 + */ +export async function editDetailDwgReceipt(params: { + dwgList: DetailDwgEditRequest[]; + userId: string; + userNm: string; + vendorCode: string; + email: string; +}): Promise<number> { + try { + const response = await dolceApiCall<{ + DetailDwgReceiptMmgtEditResult: number; + }>("DetailDwgReceiptMgmtEdit", { + DwgList: params.dwgList, + UserID: params.userId, + UserNM: params.userNm, + VENDORCODE: params.vendorCode, + EMAIL: params.email, + }); + + return response.DetailDwgReceiptMmgtEditResult; + } catch (error) { + console.error("상세도면 수정 실패:", error); + throw error; + } +} + +/** + * 5. 파일 다운로드 (프록시) + */ +export async function downloadDolceFile(params: { + fileId: string; + userId: string; + fileName: string; +}): Promise<{ blob: Blob; fileName: string }> { + try { + const { createDolceDownloadKey } = await import("./crypto-utils"); + + // DES 암호화된 키 생성 + const encryptedKey = createDolceDownloadKey( + params.fileId, + params.userId, + params.fileName + ); + + const url = `${DOLCE_API_URL}/Download.aspx`; + + // 디버깅용 상세 로그 + const plainText = `${params.fileId}↔${params.userId}↔${params.fileName}`; + console.log("[DOLCE] 파일 다운로드 상세:", { + fileId: params.fileId, + userId: params.userId, + fileName: params.fileName, + plainText, + encryptedKey, + fullUrl: `${url}?key=${encryptedKey}`, + }); + + // 주의: encodeURIComponent 사용하지 않음! + // C#에서는 +를 |||로 변환 후 그대로 URL에 사용 + const response = await fetch(`${url}?key=${encryptedKey}`, { + method: "GET", + }); + + console.log("[DOLCE] 응답 상태:", response.status, response.statusText); + console.log("[DOLCE] Content-Type:", response.headers.get("content-type")); + + if (!response.ok) { + const errorText = await response.text(); + console.error("[DOLCE] 에러 응답:", errorText); + throw new Error(`파일 다운로드 실패: ${response.status} - ${errorText}`); + } + + // HTML/텍스트 응답인 경우 에러일 수 있음 + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("text") || contentType.includes("html")) { + const errorText = await response.text(); + console.error("[DOLCE] 텍스트 응답 (에러):", errorText); + throw new Error(`예상치 못한 응답: ${errorText.substring(0, 200)}`); + } + + const blob = await response.blob(); + console.log("[DOLCE] 다운로드 성공:", blob.size, "bytes"); + + return { + blob, + fileName: params.fileName, + }; + } catch (error) { + console.error("[DOLCE] 파일 다운로드 실패:", error); + throw error; + } +} + +/** + * 벤더 세션 정보 조회 + */ +export async function getVendorSessionInfo() { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("로그인이 필요합니다"); + } + + // DB에서 사용자 정보 조회 + const userId = parseInt(session.user.id); + const userInfo = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { + id: true, + name: true, + email: true, + companyId: true, + }, + }); + + if (!userInfo || !userInfo.companyId) { + throw new Error("벤더 정보를 찾을 수 없습니다"); + } + + // 벤더 정보 조회 + const vendorInfo = await db.query.vendors.findFirst({ + where: eq(vendors.id, userInfo.companyId), + columns: { + id: true, + vendorCode: true, + vendorName: true, + }, + }); + + if (!vendorInfo) { + throw new Error("벤더 정보를 찾을 수 없습니다"); + } + + // GTT 벤더 확인 (A0016193) + const drawingKind = vendorInfo.vendorCode === "A0016193" ? "B4" : "B3"; + + return { + userId: String(userInfo.id), + userName: userInfo.name || "", + email: userInfo.email || "", + vendorCode: vendorInfo.vendorCode || "", + vendorName: vendorInfo.vendorName || "", + drawingKind, // B3 또는 B4 + }; + } catch (error) { + console.error("세션 정보 조회 실패:", error); + throw error; + } +} + +/** + * 벤더의 프로젝트 목록 조회 (계약이 있는 프로젝트) + */ +export async function fetchVendorProjects(): Promise< + Array<{ code: string; name: string }> +> { + try { + const vendorInfo = await getVendorSessionInfo(); + + // 벤더와 계약이 있는 프로젝트 목록 조회 + const projectList = await db + .selectDistinct({ + code: projects.code, + name: projects.name, + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) + .where(eq(vendors.vendorCode, vendorInfo.vendorCode)); + + return projectList.map((p) => ({ + code: p.code || "", + name: p.name || "", + })); + } catch (error) { + console.error("프로젝트 목록 조회 실패:", error); + throw error; + } +} + +// ============================================================================ +// B4 일괄 업로드 관련 +// ============================================================================ + +/** + * MatchBatchFileDwg 매핑 체크 아이템 + */ +export interface MappingCheckItem { + DrawingNo: string; + RevNo: string; + FileNm: string; +} + +/** + * MatchBatchFileDwg 응답 아이템 + */ +export interface MappingCheckResult { + CGbn: string | null; + Category: string | null; + CheckBox: string; + CreateDt: string | null; + CreateUserId: string | null; + DGbn: string | null; + DegreeGbn: string | null; + DeptGbn: string | null; + Discipline: string | null; + DrawingKind: string; + DrawingMoveGbn: string | null; + DrawingName: string | null; + DrawingNo: string; + DrawingUsage: string | null; + FileNm: string; + JGbn: string | null; + Manager: string | null; + MappingYN: "Y" | "N"; + NewOrNot: string | null; + ProjectNo: string; + RegisterGroup: number; + RegisterGroupId: number; + RegisterKindCode: string | null; + RegisterSerialNo: number; + RevNo: string | null; + SGbn: string | null; + Status: string | null; + UploadId: string | null; +} + +/** + * B4 일괄 업로드 결과 + */ +export interface B4BulkUploadResult { + success: boolean; + successCount?: number; + failCount?: number; + error?: string; + results?: Array<{ + drawingNo: string; + revNo: string; + fileName: string; + success: boolean; + error?: string; + }>; +} + +/** + * B4 파일 매핑 상태 확인 (MatchBatchFileDwg) + */ +export async function checkB4MappingStatus( + projectNo: string, + fileList: MappingCheckItem[] +): Promise<MappingCheckResult[]> { + try { + const response = await dolceApiCall<{ + MatchBatchFileDwgResult: MappingCheckResult[]; + }>("MatchBatchFileDwg", { + ProjectNo: projectNo, + DrawingKind: "B4", + drawingMoveGbn: "도면입수", + FileList: fileList.map((item) => ({ + ProjectNo: projectNo, + DrawingKind: "B4", + DrawingNo: item.DrawingNo, + RevNo: item.RevNo, + FileNm: item.FileNm, + })), + }); + + return response.MatchBatchFileDwgResult; + } catch (error) { + console.error("매핑 상태 확인 실패:", error); + throw error; + } +} + +/** + * 상세도면에 파일 업로드 결과 + */ +export interface UploadFilesResult { + success: boolean; + uploadedCount?: number; + error?: string; +} + +/** + * 상세도면에 파일 업로드 (기존 UploadId에 파일 추가) + */ +export async function uploadFilesToDetailDrawing( + formData: FormData +): Promise<UploadFilesResult> { + try { + const uploadId = formData.get("uploadId") as string; + const userId = formData.get("userId") as string; + const fileCount = parseInt(formData.get("fileCount") as string); + + if (!uploadId || !userId || !fileCount) { + throw new Error("필수 파라미터가 누락되었습니다"); + } + + const uploadResults: Array<{ + FileId: string; + UploadId: string; + FileSeq: number; + FileName: string; + FileRelativePath: string; + FileSize: number; + FileCreateDT: string; + FileWriteDT: string; + OwnerUserId: string; + }> = []; + + // 기존 파일 개수 조회 (FileSeq를 이어서 사용하기 위함) + const existingFiles = await dolceApiCall<{ + FileInfoListResult: Array<{ FileSeq: string }>; + }>("FileInfoList", { + uploadId: uploadId, + }); + + const startSeq = existingFiles.FileInfoListResult.length + 1; + + // 각 파일 업로드 + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File; + if (!file) continue; + + const fileId = crypto.randomUUID(); + + // 파일을 ArrayBuffer로 변환 + const arrayBuffer = await file.arrayBuffer(); + + // PWPUploadService.ashx 호출 + const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; + + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + }, + body: arrayBuffer, + }); + + if (!uploadResponse.ok) { + throw new Error( + `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` + ); + } + + const fileRelativePath = await uploadResponse.text(); + + uploadResults.push({ + FileId: fileId, + UploadId: uploadId, + FileSeq: startSeq + i, + FileName: file.name, + FileRelativePath: fileRelativePath, + FileSize: file.size, + FileCreateDT: new Date().toISOString(), + FileWriteDT: new Date().toISOString(), + OwnerUserId: userId, + }); + } + + // 업로드 완료 통지 (PWPUploadResultService.ashx) + const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; + + const resultResponse = await fetch(resultServiceUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(uploadResults), + }); + + if (!resultResponse.ok) { + throw new Error( + `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}` + ); + } + + const resultText = await resultResponse.text(); + if (resultText !== "Success") { + throw new Error(`업로드 완료 통지 실패: ${resultText}`); + } + + return { + success: true, + uploadedCount: fileCount, + }; + } catch (error) { + console.error("파일 업로드 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + +/** + * B4 파일 일괄 업로드 + * 주의: formData를 사용하여 대용량 파일 처리 + */ +export async function bulkUploadB4Files( + formData: FormData +): Promise<B4BulkUploadResult> { + try { + // FormData에서 메타데이터 추출 + const projectNo = formData.get("projectNo") as string; + const userId = formData.get("userId") as string; + const fileCount = parseInt(formData.get("fileCount") as string); + const registerKind = formData.get("registerKind") as string; + + if (!projectNo || !userId || !fileCount || !registerKind) { + throw new Error("필수 파라미터가 누락되었습니다"); + } + + const results: Array<{ + drawingNo: string; + revNo: string; + fileName: string; + success: boolean; + error?: string; + }> = []; + + let successCount = 0; + let failCount = 0; + + // 파일별로 그룹화 (DrawingNo + RevNo 기준) + const uploadGroups = new Map< + string, + Array<{ + file: File; + drawingNo: string; + revNo: string; + fileName: string; + registerGroupId: number; + }> + >(); + + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File; + const drawingNo = formData.get(`drawingNo_${i}`) as string; + const revNo = formData.get(`revNo_${i}`) as string; + const fileName = formData.get(`fileName_${i}`) as string; + const registerGroupId = parseInt( + formData.get(`registerGroupId_${i}`) as string + ); + + if (!file || !drawingNo || !revNo) { + continue; + } + + const groupKey = `${drawingNo}_${revNo}`; + if (!uploadGroups.has(groupKey)) { + uploadGroups.set(groupKey, []); + } + + uploadGroups.get(groupKey)!.push({ + file, + drawingNo, + revNo, + fileName, + registerGroupId, + }); + } + + // 각 그룹별로 업로드 처리 + for (const [, files] of uploadGroups.entries()) { + const { drawingNo, revNo, registerGroupId } = files[0]; + + try { + // 1. UploadId 생성 (그룹당 하나) + const uploadId = crypto.randomUUID(); + + // 2. 파일 업로드 (PWPUploadService.ashx) + const uploadResults: Array<{ + FileId: string; + UploadId: string; + FileSeq: number; + FileName: string; + FileRelativePath: string; + FileSize: number; + FileCreateDT: string; + FileWriteDT: string; + OwnerUserId: string; + }> = []; + + for (let i = 0; i < files.length; i++) { + const fileInfo = files[i]; + const fileId = crypto.randomUUID(); + + // 파일을 ArrayBuffer로 변환 + const arrayBuffer = await fileInfo.file.arrayBuffer(); + + // PWPUploadService.ashx 호출 + const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; + + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + }, + body: arrayBuffer, + }); + + if (!uploadResponse.ok) { + throw new Error( + `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` + ); + } + + const fileRelativePath = await uploadResponse.text(); + + uploadResults.push({ + FileId: fileId, + UploadId: uploadId, + FileSeq: i + 1, + FileName: fileInfo.file.name, + FileRelativePath: fileRelativePath, + FileSize: fileInfo.file.size, + FileCreateDT: new Date().toISOString(), + FileWriteDT: new Date().toISOString(), + OwnerUserId: userId, + }); + } + + // 3. 업로드 완료 통지 (PWPUploadResultService.ashx) + const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; + + const resultResponse = await fetch(resultServiceUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(uploadResults), + }); + + if (!resultResponse.ok) { + throw new Error( + `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}` + ); + } + + const resultText = await resultResponse.text(); + if (resultText !== "Success") { + throw new Error(`업로드 완료 통지 실패: ${resultText}`); + } + + // 4. 매핑 현황 재조회 (MatchBatchFileDwg) + const mappingCheckResults = await checkB4MappingStatus(projectNo, [ + { + DrawingNo: drawingNo, + RevNo: revNo, + FileNm: files[0].fileName, + }, + ]); + + const mappingData = mappingCheckResults[0]; + if (!mappingData || mappingData.RegisterGroupId === 0) { + throw new Error("매핑 정보를 찾을 수 없습니다"); + } + + // 5. 매핑 정보 저장 (MatchBatchFileDwgEdit) + await dolceApiCall("MatchBatchFileDwgEdit", { + mappingSaveLists: [ + { + CGbn: mappingData.CGbn, + Category: mappingData.Category, + CheckBox: "0", + DGbn: mappingData.DGbn, + DegreeGbn: mappingData.DegreeGbn, + DeptGbn: mappingData.DeptGbn, + Discipline: mappingData.Discipline, + DrawingKind: "B4", + DrawingMoveGbn: "도면입수", + DrawingName: mappingData.DrawingName, + DrawingNo: drawingNo, + DrawingUsage: "입수용", + FileNm: files[0].fileName, + JGbn: mappingData.JGbn, + Manager: mappingData.Manager || "970043", + MappingYN: "Y", + NewOrNot: "N", + ProjectNo: projectNo, + RegisterGroup: 0, + RegisterGroupId: registerGroupId, + RegisterKindCode: registerKind, // 사용자가 선택한 RegisterKind 사용 + RegisterSerialNo: mappingData.RegisterSerialNo, + RevNo: revNo, + SGbn: mappingData.SGbn, + UploadId: uploadId, + }, + ], + UserID: parseInt(userId), + }); + + // 성공 처리 + for (const fileInfo of files) { + results.push({ + drawingNo, + revNo, + fileName: fileInfo.fileName, + success: true, + }); + successCount++; + } + } catch (error) { + // 실패 처리 + const errorMessage = + error instanceof Error ? error.message : "알 수 없는 오류"; + + for (const fileInfo of files) { + results.push({ + drawingNo, + revNo, + fileName: fileInfo.fileName, + success: false, + error: errorMessage, + }); + failCount++; + } + } + } + + return { + success: successCount > 0, + successCount, + failCount, + results, + }; + } catch (error) { + console.error("일괄 업로드 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + |
