diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-26 18:09:18 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-26 18:09:18 +0900 |
| commit | 8547034e6d82e4d1184f35af2dbff67180d89dc8 (patch) | |
| tree | 2e1835040f39adc7d0c410a108ebb558f9971a8b /lib/dolce-v2/sync-service.ts | |
| parent | 3131dce1f0c90d960f53bd384045b41023064bc4 (diff) | |
(김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등
Diffstat (limited to 'lib/dolce-v2/sync-service.ts')
| -rw-r--r-- | lib/dolce-v2/sync-service.ts | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/lib/dolce-v2/sync-service.ts b/lib/dolce-v2/sync-service.ts new file mode 100644 index 00000000..ea56b239 --- /dev/null +++ b/lib/dolce-v2/sync-service.ts @@ -0,0 +1,414 @@ +"use server"; + +import fs from "fs/promises"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; +import db from "@/db/db"; +import { dolceSyncList } from "@/db/schema/dolce/dolce"; +import { eq, and } from "drizzle-orm"; +import { + dolceApiCall, + uploadFilesToDetailDrawing as apiUploadFiles, + saveB4MappingBatch as apiSaveB4Mapping, + DetailDwgEditRequest, + B4MappingSaveItem +} from "@/lib/dolce/actions"; // 기존 API 호출 로직 재사용 (타입 등) + +const LOCAL_UPLOAD_DIR = process.env.DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY || "/evcp/data/dolce"; + +// 파일 저장 결과 인터페이스 +interface SavedFile { + originalName: string; + localPath: string; + size: number; + mimeType?: string; +} + +/** + * 로컬 디렉토리 준비 + */ +async function ensureUploadDir() { + try { + await fs.access(LOCAL_UPLOAD_DIR); + } catch { + await fs.mkdir(LOCAL_UPLOAD_DIR, { recursive: true }); + } +} + +/** + * 로컬에 파일 저장 + */ +async function saveFileToLocal(file: File): Promise<SavedFile> { + await ensureUploadDir(); + + const buffer = Buffer.from(await file.arrayBuffer()); + const uniqueName = `${uuidv4()}_${file.name}`; + const localPath = path.join(LOCAL_UPLOAD_DIR, uniqueName); + + await fs.writeFile(localPath, buffer); + + return { + originalName: file.name, + localPath, + size: file.size, + mimeType: file.type, + }; +} + +/** + * 동기화 아이템 DB 저장 (버퍼링) + */ +export async function saveToLocalBuffer(params: { + type: "ADD_DETAIL" | "MOD_DETAIL" | "ADD_FILE" | "B4_BULK"; + projectNo: string; + userId: string; + userName?: string; // [추가] + vendorCode?: string; // [추가] + drawingNo?: string; + uploadId?: string; // 상세도면 추가/수정/파일추가 시 필수 + metaData: any; // API 호출에 필요한 데이터 + files?: File[]; // 업로드할 파일들 (있으면 로컬 저장) +}) { + const { type, projectNo, userId, userName, vendorCode, drawingNo, uploadId, metaData, files } = params; + + // 1. 파일 로컬 저장 처리 + const savedFiles: SavedFile[] = []; + if (files && files.length > 0) { + for (const file of files) { + const saved = await saveFileToLocal(file); + savedFiles.push(saved); + } + } + + // 2. Payload 구성 + const payload = { + meta: metaData, + files: savedFiles, + }; + + // 3. DB 저장 + const [inserted] = await db.insert(dolceSyncList).values({ + type, + projectNo, + drawingNo, + uploadId, + userId, + userName, // [추가] + vendorCode, // [추가] + payload, + isSynced: false, + }).returning(); + + return inserted; +} + +/** + * 개별 아이템 동기화 실행 + */ +export async function syncItem(id: string) { + // 1. 아이템 조회 + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) throw new Error("Item not found"); + if (item.isSynced) return { success: true, message: "Already synced" }; + + const payload = item.payload as { meta: any; files: SavedFile[] }; + const { meta, files } = payload; + + try { + // 2. 타입별 API 호출 수행 + if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") { + // 상세도면 추가/수정 + // meta: { dwgList: DetailDwgEditRequest[], userId, userNm, vendorCode, email } + + // 상세도면 메타데이터 전송 + await dolceApiCall("DetailDwgReceiptMgmtEdit", { + DwgList: meta.dwgList, + UserID: meta.userId, + UserNM: meta.userNm, + VENDORCODE: meta.vendorCode, + EMAIL: meta.email, + }); + + // 파일이 있다면 전송 (ADD_DETAIL의 경우) + if (files && files.length > 0) { + // uploadId는 meta.dwgList[0].UploadId 에 있다고 가정 + const uploadId = meta.dwgList[0]?.UploadId; + if (uploadId) { + await uploadLocalFiles(uploadId, meta.userId, files); + } + } + + } else if (item.type === "ADD_FILE") { + // 파일 추가 + // meta: { uploadId, userId } + await uploadLocalFiles(meta.uploadId, meta.userId, files); + + } else if (item.type === "B4_BULK") { + // B4 일괄 업로드 (메타데이터 + 파일) + // meta: { mappingSaveLists: B4MappingSaveItem[], userInfo: {...} } + + // 파일 먼저 업로드 (각 파일별로 uploadId가 다를 수 있음 - payload 구조에 따라 다름) + // B4 Bulk의 경우, meta.mappingSaveLists에 UploadId가 있고, files와 1:1 매칭되거나 그룹핑되어야 함. + // 여기서는 복잡도를 줄이기 위해, payload.files 순서와 mappingSaveLists 순서가 같거나 + // meta 정보 안에 파일 매핑 정보가 있다고 가정해야 함. + + // *설계 단순화*: B4 Bulk의 경우 파일별로 saveToLocalBuffer를 따로 부르지 않고 한방에 불렀다면, + // 여기서 순회하며 처리. + + // 1. 파일 업로드 + // B4 일괄 업로드 로직은 파일 업로드 -> 결과 수신 -> 매핑 저장 순서임. + // 하지만 여기서는 이미 메타데이터가 만들어져 있으므로, + // 파일 업로드(UploadId 기준) -> 매핑 저장 순으로 진행. + + // 파일마다 UploadId가 다를 수 있으므로 Grouping 필요 + const fileMap = new Map<string, SavedFile>(); + files.forEach(f => fileMap.set(f.originalName, f)); + + // UploadId별 파일 그룹핑 + const uploadGroups = new Map<string, { userId: string; files: SavedFile[] }>(); + + for (const mapping of meta.mappingSaveLists as B4MappingSaveItem[]) { + if (!uploadGroups.has(mapping.UploadId)) { + uploadGroups.set(mapping.UploadId, { userId: meta.userInfo.userId, files: [] }); + } + const savedFile = fileMap.get(mapping.FileNm); + if (savedFile) { + uploadGroups.get(mapping.UploadId)!.files.push(savedFile); + } + } + + // 그룹별 파일 업로드 수행 + for (const [uploadId, group] of uploadGroups.entries()) { + if (group.files.length > 0) { + await uploadLocalFiles(uploadId, group.userId, group.files); + } + } + + // 2. 매핑 정보 저장 + await apiSaveB4Mapping(meta.mappingSaveLists, meta.userInfo); + } + + // 3. 성공 처리 (DB 업데이트 + 로컬 파일 삭제) + await db.update(dolceSyncList) + .set({ + isSynced: true, + syncAttempts: (item.syncAttempts || 0) + 1, + responseCode: "200", + response: "Success", + updatedAt: new Date() + }) + .where(eq(dolceSyncList.id, id)); + + // 로컬 파일 삭제 + if (files && files.length > 0) { + for (const file of files) { + try { + await fs.unlink(file.localPath); + } catch (e) { + console.error(`Failed to delete local file: ${file.localPath}`, e); + } + } + } + + return { success: true }; + + } catch (error) { + console.error(`Sync failed for item ${id}:`, error); + + // 실패 처리 + await db.update(dolceSyncList) + .set({ + syncAttempts: (item.syncAttempts || 0) + 1, + lastError: error instanceof Error ? error.message : "Unknown error", + updatedAt: new Date() + }) + .where(eq(dolceSyncList.id, id)); + + throw error; + } +} + +/** + * 로컬 파일들을 실제 서버로 업로드하는 헬퍼 함수 + * (기존 uploadFilesToDetailDrawing 로직을 로컬 파일용으로 변형) + */ +async function uploadLocalFiles(uploadId: string, userId: string, files: SavedFile[]) { + // 1. 기존 파일 시퀀스 확인 등은 생략하고 바로 PWPUploadService 호출 + // (기존 API 액션 재사용이 어려우므로 여기서 fetch로 직접 구현) + + // 기존 파일 개수 조회 (Seq 생성을 위해) + const existingFiles = await dolceApiCall<{ + FileInfoListResult: Array<{ FileSeq: string }>; + }>("FileInfoList", { + uploadId: uploadId, + }); + const startSeq = existingFiles.FileInfoListResult.length + 1; + + const uploadResults = []; + const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileId = uuidv4(); + + // 로컬 파일 읽기 + const fileBuffer = await fs.readFile(file.localPath); + + // 업로드 API 호출 + 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: fileBuffer, + }); + + if (!uploadResponse.ok) throw new Error(`File upload failed: ${uploadResponse.status}`); + + const fileRelativePath = await uploadResponse.text(); + + uploadResults.push({ + FileId: fileId, + UploadId: uploadId, + FileSeq: startSeq + i, + FileName: file.originalName, + FileRelativePath: fileRelativePath, + FileSize: file.size, + FileCreateDT: new Date().toISOString(), + FileWriteDT: new Date().toISOString(), + OwnerUserId: userId, + }); + } + + // 결과 통보 + 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("Upload notification failed"); + + const resultText = await resultResponse.text(); + if (resultText !== "Success") throw new Error(`Upload notification failed: ${resultText}`); +} + +/** + * 로컬 파일 다운로드 (View용) + */ +export async function getLocalFile(fileId: string): Promise<{ buffer: Buffer; fileName: string }> { + // Format: LOCAL_{id}_{index} + const parts = fileId.replace("LOCAL_", "").split("_"); + if (parts.length < 2) throw new Error("Invalid file ID format"); + + const id = parts[0]; + const index = parseInt(parts[1]); + + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) throw new Error("Item not found"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as { files: SavedFile[]; meta: any }; + if (!payload.files || !payload.files[index]) { + throw new Error("File not found in item"); + } + + const file = payload.files[index]; + + try { + const buffer = await fs.readFile(file.localPath); + return { + buffer, + fileName: file.originalName + }; + } catch (e) { + console.error(`Failed to read local file: ${file.localPath}`, e); + throw new Error("Failed to read local file"); + } +} + +/** + * 로컬 아이템 삭제 (상세도면 삭제용) + * 관련 파일도 로컬 디스크에서 삭제 + */ +export async function deleteLocalItem(id: string) { + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) return; + + // Delete files from disk + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as { files?: SavedFile[] }; + if (payload.files) { + for (const file of payload.files) { + try { + await fs.unlink(file.localPath); + } catch (e) { + console.warn(`Failed to delete local file: ${file.localPath}`, e); + } + } + } + + // Delete DB entry + await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id)); +} + +/** + * 로컬 파일 삭제 (개별 파일 삭제용) + */ +export async function deleteLocalFileFromItem(fileId: string) { + // Format: LOCAL_{id}_{index} + const parts = fileId.replace("LOCAL_", "").split("_"); + if (parts.length < 2) throw new Error("Invalid file ID format"); + + const id = parts[0]; + const index = parseInt(parts[1]); + + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) throw new Error("Item not found"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as { files: SavedFile[]; meta: any }; + if (!payload.files || !payload.files[index]) { + return; + } + + // Delete file from disk + const fileToDelete = payload.files[index]; + try { + await fs.unlink(fileToDelete.localPath); + } catch (e) { + console.warn(`Failed to delete local file: ${fileToDelete.localPath}`, e); + } + + // Remove from payload + const newFiles = [...payload.files]; + newFiles.splice(index, 1); // Remove at index + + // Update DB + await db.update(dolceSyncList) + .set({ + payload: { + ...payload, + files: newFiles + }, + updatedAt: new Date() + }) + .where(eq(dolceSyncList.id, id)); + + // If no files left and it was ADD_FILE type, delete the item + if (newFiles.length === 0 && item.type === "ADD_FILE") { + await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id)); + } +} |
