summaryrefslogtreecommitdiff
path: root/lib/dolce-v2/sync-service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/dolce-v2/sync-service.ts')
-rw-r--r--lib/dolce-v2/sync-service.ts414
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));
+ }
+}