diff options
Diffstat (limited to 'lib/swp')
| -rw-r--r-- | lib/swp/actions.ts | 542 | ||||
| -rw-r--r-- | lib/swp/api-client.ts | 40 | ||||
| -rw-r--r-- | lib/swp/document-service.ts | 76 | ||||
| -rw-r--r-- | lib/swp/example-usage.ts | 347 | ||||
| -rw-r--r-- | lib/swp/sync-service.ts | 537 | ||||
| -rw-r--r-- | lib/swp/table/swp-uploaded-files-dialog.tsx | 13 | ||||
| -rw-r--r-- | lib/swp/vendor-actions.ts | 100 |
7 files changed, 222 insertions, 1433 deletions
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts deleted file mode 100644 index 6962884e..00000000 --- a/lib/swp/actions.ts +++ /dev/null @@ -1,542 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schema/SWP/swp-documents"; -import { eq, and, sql, like, desc, asc, type SQL } from "drizzle-orm"; -import { fetchSwpProjectData } from "./api-client"; -import { syncSwpProject } from "./sync-service"; -import * as fs from "fs/promises"; -import * as path from "path"; -import { debugLog, debugError, debugWarn, debugSuccess } from "@/lib/debug-utils"; - -// ============================================================================ -// 타입 정의 -// ============================================================================ - -export interface SwpTableFilters { - projNo?: string; - docNo?: string; - docTitle?: string; - pkgNo?: string; - vndrCd?: string; - stage?: string; -} - -export interface SwpTableParams { - page: number; - pageSize: number; - sortBy?: string; - sortOrder?: "asc" | "desc"; - filters?: SwpTableFilters; -} - -export interface SwpDocumentWithStats { - DOC_NO: string; - DOC_TITLE: string; - PROJ_NO: string; - PROJ_NM: string | null; - PKG_NO: string | null; - VNDR_CD: string | null; - CPY_NM: string | null; - LTST_REV_NO: string | null; - STAGE: string | null; - LTST_ACTV_STAT: string | null; - sync_status: "synced" | "pending" | "error"; - last_synced_at: Date; - revision_count: number; - file_count: number; -} - -// ============================================================================ -// 서버 액션: 문서 목록 조회 (페이지네이션 + 검색) -// ============================================================================ - -export async function fetchSwpDocuments(params: SwpTableParams) { - const { page, pageSize, sortBy = "last_synced_at", sortOrder = "desc", filters } = params; - const offset = (page - 1) * pageSize; - - try { - // WHERE 조건 구성 - const conditions: SQL<unknown>[] = []; - - if (filters?.projNo) { - conditions.push(like(swpDocuments.PROJ_NO, `%${filters.projNo}%`)); - } - if (filters?.docNo) { - conditions.push(like(swpDocuments.DOC_NO, `%${filters.docNo}%`)); - } - if (filters?.docTitle) { - conditions.push(like(swpDocuments.DOC_TITLE, `%${filters.docTitle}%`)); - } - if (filters?.pkgNo) { - conditions.push(like(swpDocuments.PKG_NO, `%${filters.pkgNo}%`)); - } - if (filters?.vndrCd) { - conditions.push(like(swpDocuments.VNDR_CD, `%${filters.vndrCd}%`)); - } - if (filters?.stage) { - conditions.push(eq(swpDocuments.STAGE, filters.stage)); - } - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - // 총 개수 조회 - const totalResult = await db - .select({ count: sql<number>`count(*)::int` }) - .from(swpDocuments) - .where(whereClause); - - const total = totalResult[0]?.count || 0; - - // 정렬 컬럼 결정 - const orderByColumn = - sortBy === "DOC_NO" ? swpDocuments.DOC_NO : - sortBy === "DOC_TITLE" ? swpDocuments.DOC_TITLE : - sortBy === "PROJ_NO" ? swpDocuments.PROJ_NO : - sortBy === "PKG_NO" ? swpDocuments.PKG_NO : - sortBy === "STAGE" ? swpDocuments.STAGE : - swpDocuments.last_synced_at; - - // 데이터 조회 (Drizzle query builder 사용) - const documents = await db - .select({ - DOC_NO: swpDocuments.DOC_NO, - DOC_TITLE: swpDocuments.DOC_TITLE, - PROJ_NO: swpDocuments.PROJ_NO, - PROJ_NM: swpDocuments.PROJ_NM, - PKG_NO: swpDocuments.PKG_NO, - VNDR_CD: swpDocuments.VNDR_CD, - CPY_NM: swpDocuments.CPY_NM, - LTST_REV_NO: swpDocuments.LTST_REV_NO, - STAGE: swpDocuments.STAGE, - LTST_ACTV_STAT: swpDocuments.LTST_ACTV_STAT, - sync_status: swpDocuments.sync_status, - last_synced_at: swpDocuments.last_synced_at, - revision_count: sql<number>`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, - file_count: sql<number>`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, - }) - .from(swpDocuments) - .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO)) - .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) - .where(whereClause) - .groupBy( - swpDocuments.DOC_NO, - swpDocuments.DOC_TITLE, - swpDocuments.PROJ_NO, - swpDocuments.PROJ_NM, - swpDocuments.PKG_NO, - swpDocuments.VNDR_CD, - swpDocuments.CPY_NM, - swpDocuments.LTST_REV_NO, - swpDocuments.STAGE, - swpDocuments.LTST_ACTV_STAT, - swpDocuments.sync_status, - swpDocuments.last_synced_at - ) - .orderBy(sortOrder === "desc" ? desc(orderByColumn) : asc(orderByColumn)) - .limit(pageSize) - .offset(offset); - - return { - data: documents, - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } catch (error) { - console.error("[fetchSwpDocuments] 오류:", error); - throw new Error("문서 목록 조회 실패 [SWP API에서 실패가 발생했습니다. 담당자에게 문의하세요]"); - } -} - -// ============================================================================ -// 서버 액션: 문서의 리비전 목록 조회 -// ============================================================================ - -export async function fetchDocumentRevisions(docNo: string) { - try { - const revisions = await db - .select({ - id: swpDocumentRevisions.id, - DOC_NO: swpDocumentRevisions.DOC_NO, - REV_NO: swpDocumentRevisions.REV_NO, - STAGE: swpDocumentRevisions.STAGE, - ACTV_NO: swpDocumentRevisions.ACTV_NO, - OFDC_NO: swpDocumentRevisions.OFDC_NO, - sync_status: swpDocumentRevisions.sync_status, - last_synced_at: swpDocumentRevisions.last_synced_at, - file_count: sql<number>`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, - }) - .from(swpDocumentRevisions) - .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) - .where(eq(swpDocumentRevisions.DOC_NO, docNo)) - .groupBy( - swpDocumentRevisions.id, - swpDocumentRevisions.DOC_NO, - swpDocumentRevisions.REV_NO, - swpDocumentRevisions.STAGE, - swpDocumentRevisions.ACTV_NO, - swpDocumentRevisions.OFDC_NO, - swpDocumentRevisions.sync_status, - swpDocumentRevisions.last_synced_at - ) - .orderBy(desc(swpDocumentRevisions.REV_NO)); - - return revisions; - } catch (error) { - console.error("[fetchDocumentRevisions] 오류:", error); - throw new Error("리비전 목록 조회 실패"); - } -} - -// ============================================================================ -// 서버 액션: 리비전의 파일 목록 조회 -// ============================================================================ - -export async function fetchRevisionFiles(revisionId: number) { - try { - const files = await db - .select({ - id: swpDocumentFiles.id, - FILE_NM: swpDocumentFiles.FILE_NM, - FILE_SEQ: swpDocumentFiles.FILE_SEQ, - FILE_SZ: swpDocumentFiles.FILE_SZ, - FLD_PATH: swpDocumentFiles.FLD_PATH, - STAT: swpDocumentFiles.STAT, - STAT_NM: swpDocumentFiles.STAT_NM, - sync_status: swpDocumentFiles.sync_status, - created_at: swpDocumentFiles.created_at, - }) - .from(swpDocumentFiles) - .where(eq(swpDocumentFiles.revision_id, revisionId)) - .orderBy(asc(swpDocumentFiles.FILE_SEQ)); - - return files; - } catch (error) { - console.error("[fetchRevisionFiles] 오류:", error); - throw new Error("파일 목록 조회 실패"); - } -} - -// ============================================================================ -// 서버 액션: 프로젝트 동기화 -// ============================================================================ - -export async function syncSwpProjectAction(projectNo: string, docGb: "M" | "V" = "V") { - try { - console.log(`[syncSwpProjectAction] 시작: ${projectNo}`); - - // 1. API에서 데이터 조회 - const { documents, files } = await fetchSwpProjectData(projectNo, docGb); - - // 2. 동기화 실행 - const result = await syncSwpProject(projectNo, documents, files); - - console.log(`[syncSwpProjectAction] 완료:`, result.stats); - - return result; - } catch (error) { - console.error("[syncSwpProjectAction] 오류:", error); - throw new Error( - error instanceof Error ? error.message : "동기화 실패" - ); - } -} - -// ============================================================================ -// 서버 액션: 프로젝트 목록 조회 (필터용) -// ============================================================================ - -export async function fetchProjectList() { - try { - const projects = await db - .select({ - PROJ_NO: swpDocuments.PROJ_NO, - PROJ_NM: swpDocuments.PROJ_NM, - doc_count: sql<number>`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, - }) - .from(swpDocuments) - .groupBy(swpDocuments.PROJ_NO, swpDocuments.PROJ_NM) - .orderBy(desc(sql`COUNT(DISTINCT ${swpDocuments.DOC_NO})`)); - - return projects; - } catch (error) { - console.error("[fetchProjectList] 오류:", error); - return []; - } -} - -// ============================================================================ -// 서버 액션: 통계 조회 -// ============================================================================ - -export async function fetchSwpStats(projNo?: string) { - try { - const whereClause = projNo ? eq(swpDocuments.PROJ_NO, projNo) : undefined; - - const stats = await db - .select({ - total_documents: sql<number>`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`, - total_revisions: sql<number>`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`, - total_files: sql<number>`COUNT(DISTINCT ${swpDocumentFiles.id})::int`, - last_sync: sql<Date>`MAX(${swpDocuments.last_synced_at})`, - }) - .from(swpDocuments) - .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO)) - .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id)) - .where(whereClause); - - return stats[0] || { - total_documents: 0, - total_revisions: 0, - total_files: 0, - last_sync: null, - }; - } catch (error) { - console.error("[fetchSwpStats] 오류:", error); - return { - total_documents: 0, - total_revisions: 0, - total_files: 0, - last_sync: null, - }; - } -} - -// ============================================================================ -// 서버 액션: 파일 다운로드 -// ============================================================================ - -export interface DownloadFileResult { - success: boolean; - data?: Uint8Array; - fileName?: string; - mimeType?: string; - error?: string; -} - -export async function downloadSwpFile(fileId: number): Promise<DownloadFileResult> { - try { - debugLog(`[downloadSwpFile] 다운로드 시작`, { fileId }); - - // 1. 파일 정보 조회 - const fileInfo = await db - .select({ - FILE_NM: swpDocumentFiles.FILE_NM, - FLD_PATH: swpDocumentFiles.FLD_PATH, - }) - .from(swpDocumentFiles) - .where(eq(swpDocumentFiles.id, fileId)) - .limit(1); - - if (!fileInfo || fileInfo.length === 0) { - debugError(`[downloadSwpFile] 파일 정보 없음`, { fileId }); - return { - success: false, - error: "파일 정보를 찾을 수 없습니다.", - }; - } - - const { FILE_NM, FLD_PATH } = fileInfo[0]; - debugLog(`[downloadSwpFile] 파일 정보 조회 완료`, { FILE_NM, FLD_PATH }); - - if (!FLD_PATH || !FILE_NM) { - debugError(`[downloadSwpFile] 파일 경로 또는 이름 없음`, { FILE_NM, FLD_PATH }); - return { - success: false, - error: "파일 경로 또는 파일명이 없습니다.", - }; - } - - // 2. NFS 마운트 경로 확인 - const nfsBasePath = process.env.SWP_MOUNT_DIR; - if (!nfsBasePath) { - console.error( - '[downloadSwpFile] SWP_MOUNT_DIR 환경변수가 설정되지 않았습니다.' - ); - return { - success: false, - error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.", - }; - } - - // 3. 전체 파일 경로 생성 - // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리 - // Windows 스타일 백슬래시를 리눅스 슬래시로 변환 - const normalizedFldPath = FLD_PATH.replace(/\\/g, '/'); - const fullPath = path.join(nfsBasePath, normalizedFldPath, FILE_NM); - - console.log("[downloadSwpFile] 파일 다운로드 시도:", { - fileId, - FILE_NM, - FLD_PATH, - normalizedFldPath, - fullPath, - }); - - // 4. 파일 존재 여부 확인 - try { - await fs.access(fullPath, fs.constants.R_OK); - } catch (accessError) { - console.error("[downloadSwpFile] 파일 접근 불가:", accessError); - return { - success: false, - error: `파일을 찾을 수 없습니다: ${FILE_NM}`, - }; - } - - // 5. 파일 읽기 - debugLog(`[downloadSwpFile] 파일 읽기 시작`, { fullPath }); - const fileBuffer = await fs.readFile(fullPath); - - debugLog(`[downloadSwpFile] 파일 Buffer 읽기 완료`, { - bufferLength: fileBuffer.length, - isBuffer: Buffer.isBuffer(fileBuffer), - bufferType: typeof fileBuffer, - constructor: fileBuffer.constructor.name, - first20Bytes: fileBuffer.slice(0, 20).toString('hex') - }); - - const fileData = new Uint8Array(fileBuffer); - - debugLog(`[downloadSwpFile] Uint8Array 변환 완료`, { - uint8ArrayLength: fileData.length, - uint8ArrayType: typeof fileData, - constructor: fileData.constructor.name, - first20Bytes: Array.from(fileData.slice(0, 20)), - jsonStringified: JSON.stringify(fileData).substring(0, 100) + '...' - }); - - // 6. MIME 타입 결정 - const mimeType = getMimeType(FILE_NM); - - console.log("[downloadSwpFile] 파일 다운로드 성공:", { - fileName: FILE_NM, - size: fileData.length, - mimeType, - }); - - debugSuccess(`[downloadSwpFile] 다운로드 성공`, { - fileName: FILE_NM, - dataLength: fileData.length, - mimeType, - returnDataType: typeof fileData, - isUint8Array: fileData instanceof Uint8Array - }); - - return { - success: true, - data: fileData, - fileName: FILE_NM, - mimeType, - }; - } catch (error) { - console.error("[downloadSwpFile] 오류:", error); - debugError(`[downloadSwpFile] 다운로드 실패`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - return { - success: false, - error: error instanceof Error ? error.message : "파일 다운로드 실패", - }; - } -} - -// ============================================================================ -// 헬퍼: MIME 타입 결정 -// ============================================================================ - -function getMimeType(fileName: string): string { - const ext = path.extname(fileName).toLowerCase(); - - const mimeTypes: Record<string, string> = { - ".pdf": "application/pdf", - ".doc": "application/msword", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xls": "application/vnd.ms-excel", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".ppt": "application/vnd.ms-powerpoint", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".txt": "text/plain", - ".csv": "text/csv", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".zip": "application/zip", - ".rar": "application/x-rar-compressed", - ".7z": "application/x-7z-compressed", - ".dwg": "application/acad", - ".dxf": "application/dxf", - }; - - return mimeTypes[ext] || "application/octet-stream"; -} - -// ============================================================================ -// 서버 액션: 벤더 업로드 파일 목록 조회 -// ============================================================================ - -export async function fetchVendorUploadedFiles(projNo: string, vndrCd: string) { - try { - debugLog(`[fetchVendorUploadedFiles] 조회 시작`, { projNo, vndrCd }); - - // fetchGetExternalInboxList 호출 - const { fetchGetExternalInboxList } = await import("./api-client"); - const files = await fetchGetExternalInboxList({ - projNo, - vndrCd, - }); - - debugLog(`[fetchVendorUploadedFiles] 조회 완료`, { - fileCount: files.length - }); - - return files; - } catch (error) { - debugError(`[fetchVendorUploadedFiles] 조회 실패`, { error }); - throw new Error( - error instanceof Error ? error.message : "업로드 파일 목록 조회 실패" - ); - } -} - -// ============================================================================ -// 서버 액션: 벤더 업로드 파일 취소 -// ============================================================================ - -export interface CancelUploadedFileParams { - boxSeq: string; - actvSeq: string; - userId: string; -} - -export async function cancelVendorUploadedFile(params: CancelUploadedFileParams) { - try { - debugLog(`[cancelVendorUploadedFile] 취소 시작`, params); - - const { callSaveInBoxListCancelStatus } = await import("./api-client"); - const cancelCount = await callSaveInBoxListCancelStatus({ - boxSeq: params.boxSeq, - actvSeq: params.actvSeq, - chgr: `evcp${params.userId}`, - }); - - debugSuccess(`[cancelVendorUploadedFile] 취소 완료`, { - ...params, - cancelCount - }); - - return { - success: true, - cancelCount - }; - } catch (error) { - debugError(`[cancelVendorUploadedFile] 취소 실패`, { error }); - throw new Error( - error instanceof Error ? error.message : "파일 취소 실패" - ); - } -} diff --git a/lib/swp/api-client.ts b/lib/swp/api-client.ts index 4943a42a..1befa217 100644 --- a/lib/swp/api-client.ts +++ b/lib/swp/api-client.ts @@ -1,5 +1,45 @@ "use server"; +/** + * SWP API Client + * + * ## 목적 + * 외부 SWP 시스템의 REST API를 호출하는 저수준 클라이언트입니다. + * + * ## 설계 원칙 + * 1. **단순 API 호출만 담당** - 비즈니스 로직은 document-service.ts에서 처리 + * 2. **타입 안전성** - API 응답 타입을 명확히 정의 + * 3. **에러 핸들링** - API 실패 시 명확한 에러 메시지 제공 + * 4. **디버깅 지원** - 모든 API 호출을 debugLog로 추적 + * + * ## 주요 API + * - `fetchGetVDRDocumentList`: 문서 마스터 조회 (필터링 지원) + * - `fetchGetExternalInboxList`: 파일 정보 조회 (업로드된 파일 포함) + * - `fetchGetActivityFileList`: Rev-Activity-File 계층 구조 조회 + * - `callSaveInBoxList`: 파일 업로드 메타데이터 등록 + * - `callSaveInBoxListCancelStatus`: Standby 파일 취소 + * + * ## 사용 예시 + * ```typescript + * // 문서 목록 조회 + * const documents = await fetchGetVDRDocumentList({ + * proj_no: "SN2190", + * doc_gb: "V", + * vndrCd: "SE00100" + * }); + * + * // 파일 목록 조회 + * const files = await fetchGetExternalInboxList({ + * projNo: "SN2190", + * vndrCd: "SE00100" + * }); + * ``` + * + * @see lib/swp/document-service.ts - 비즈니스 로직 레이어 + * @see lib/swp/vendor-actions.ts - 서버 액션 (권한 체크) + * @see lib/swp/README.md - 전체 시스템 문서 + */ + // ============================================================================ // SWP API 클라이언트 // ============================================================================ diff --git a/lib/swp/document-service.ts b/lib/swp/document-service.ts index 49e4da4c..f83488d9 100644 --- a/lib/swp/document-service.ts +++ b/lib/swp/document-service.ts @@ -1,5 +1,81 @@ "use server"; +/** + * SWP Document Service + * + * ## 목적 + * SWP API 응답을 가공하여 프론트엔드에 최적화된 데이터 구조를 제공합니다. + * + * ## 역할 + * 1. **데이터 변환**: API 응답을 UI 친화적 구조로 변환 + * 2. **데이터 집계**: 여러 API 호출 결과를 조합 (예: 문서 + 파일 개수) + * 3. **비즈니스 로직**: 파일 다운로드, 취소 가능 여부 판단 등 + * 4. **에러 처리**: API 실패 시 사용자 친화적 에러 메시지 반환 + * + * ## 주요 함수 + * + * ### 조회 + * - `getDocumentList(projNo, vndrCd)`: 문서 목록 + 파일 개수 집계 + * - `getDocumentDetail(projNo, docNo)`: Rev → Activity → File 3단계 트리 구조 + * + * ### 액션 + * - `cancelStandbyFile(boxSeq, actvSeq, userId)`: Standby 파일 취소 (SCW01만) + * - `downloadDocumentFile(projNo, ownDocNo, fileName)`: NFS에서 파일 다운로드 + * + * ## 데이터 구조 + * + * ### DocumentListItem (문서 목록) + * ```typescript + * { + * DOC_NO: string, + * DOC_TITLE: string, + * LTST_REV_NO: string, + * STAGE: string, + * fileCount: number, // 전체 파일 개수 + * standbyFileCount: number, // 업로드 대기 중 (SCW01) + * ... + * } + * ``` + * + * ### DocumentDetail (문서 상세) + * ```typescript + * { + * docNo: string, + * revisions: [ + * { + * revNo: "04", + * stage: "IFC", + * activities: [ + * { + * actvNo: "ACTV123", + * type: "External Outbox", + * files: [ + * { + * fileNm: "drawing.pdf", + * stat: "COM01", + * canDownload: true, + * canCancel: false, + * ... + * } + * ] + * } + * ] + * } + * ] + * } + * ``` + * + * ## 설계 원칙 + * 1. **API 호출 최소화**: 필요한 데이터를 한 번에 조회 후 가공 + * 2. **타입 안전성**: 모든 반환 타입을 명확히 정의 + * 3. **UI 친화적**: 프론트엔드가 바로 사용 가능한 구조 + * 4. **에러 복원력**: API 실패 시에도 부분 데이터 제공 시도 + * + * @see lib/swp/api-client.ts - 저수준 API 호출 + * @see lib/swp/vendor-actions.ts - 서버 액션 (권한 체크 추가) + * @see lib/swp/README.md - 전체 시스템 문서 + */ + import { fetchGetVDRDocumentList, fetchGetExternalInboxList, diff --git a/lib/swp/example-usage.ts b/lib/swp/example-usage.ts deleted file mode 100644 index 8e1791f7..00000000 --- a/lib/swp/example-usage.ts +++ /dev/null @@ -1,347 +0,0 @@ -"use server"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -/** - * SWP 문서 관리 시스템 사용 예제 - * - * 이 파일은 실제 사용 시나리오를 보여주는 예제입니다. - */ - -import { - fetchSwpProjectData, - analyzeSwpData, - getSwpFileDownloadUrl, -} from "./api-client"; -import { - syncSwpProject, - getProjectDocumentsHierarchy, - getDocumentRevisions, - getRevisionFiles, - getProjectSyncStatus, -} from "./sync-service"; -import db from "@/db/db"; -import { sql } from "drizzle-orm"; - -// ============================================================================ -// 예제 1: 프로젝트 전체 동기화 -// ============================================================================ - -export async function example1_FullProjectSync(projectNo: string) { - console.log("=== 예제 1: 프로젝트 전체 동기화 ===\n"); - - // 1. API에서 데이터 조회 - console.log(`📡 API 호출 중...`); - const { documents, files } = await fetchSwpProjectData(projectNo, "V"); - - // 2. 데이터 분석 - const stats: Awaited<ReturnType<typeof analyzeSwpData>> = await analyzeSwpData(projectNo, documents, files); - console.log(`📊 데이터 분석:`); - console.log(` - 문서: ${stats.documentCount}개`); - console.log(` - 리비전: ${stats.revisionCount}개`); - console.log(` - 파일: ${stats.fileCount}개`); - console.log(` - 평균 파일/문서: ${stats.avgFilesPerDoc.toFixed(2)}개`); - console.log( - ` - 총 용량: ${(stats.totalFileSize / 1024 / 1024).toFixed(2)} MB` - ); - console.log(` - 스테이지:`, stats.stages); - console.log(` - 파일 타입:`, stats.fileTypes); - - // 3. 동기화 실행 - console.log(`\n💾 동기화 시작...`); - const syncResult = await syncSwpProject(projectNo, documents, files); - - if (syncResult.success) { - console.log(`✅ 동기화 완료 (${syncResult.duration}ms)`); - console.log(` - 문서: +${syncResult.stats.documents.inserted}개`); - console.log(` - 리비전: +${syncResult.stats.revisions.inserted}개`); - console.log(` - 파일: +${syncResult.stats.files.inserted}개`); - } else { - console.error(`❌ 동기화 실패:`); - syncResult.errors.forEach((err) => console.error(` - ${err}`)); - } - - return syncResult; -} - -// ============================================================================ -// 예제 2: 계층 구조 조회 및 UI 렌더링 -// ============================================================================ - -export async function example2_HierarchyView(projectNo: string) { - console.log("=== 예제 2: 계층 구조 조회 ===\n"); - - // 1. 계층 뷰 조회 - const result = await getProjectDocumentsHierarchy(projectNo); - const documents = result.rows as any[]; - - console.log(`📁 문서 ${documents.length}개 조회됨\n`); - - // 2. 첫 3개 문서만 출력 (예제) - documents.slice(0, 3).forEach((doc) => { - console.log(`📄 ${doc.doc_no}`); - console.log(` 제목: ${doc.doc_title}`); - console.log(` 최신 리비전: ${doc.ltst_rev_no}`); - console.log(` 리비전 수: ${doc.revision_count}개`); - - const revisions = JSON.parse(doc.revisions || "[]"); - revisions.slice(0, 2).forEach((rev: any) => { - console.log(` 📋 REV ${rev.revNo} (${rev.stage})`); - console.log(` 파일: ${rev.fileCount}개`); - - const files = rev.files || []; - files.forEach((file: any) => { - console.log(` 📎 ${file.fileNm} (${file.fileSz} bytes)`); - }); - }); - console.log(); - }); - - return documents; -} - -// ============================================================================ -// 예제 3: 특정 문서의 리비전 조회 -// ============================================================================ - -export async function example3_DocumentRevisions(docNo: string) { - console.log("=== 예제 3: 문서 리비전 조회 ===\n"); - - // 1. 리비전 목록 조회 - const revisions = await getDocumentRevisions(docNo); - - console.log(`📄 문서: ${docNo}`); - console.log(`📋 리비전: ${revisions.length}개\n`); - - // 2. 각 리비전별 파일 조회 - for (const rev of revisions) { - const files = await getRevisionFiles(rev.id); - - console.log(`REV ${rev.REV_NO} (${rev.STAGE})`); - console.log(` 파일: ${files.length}개`); - console.log(` Activity: ${rev.ACTV_NO || "N/A"}`); - console.log(` OFDC: ${rev.OFDC_NO}`); - console.log(` 동기화: ${rev.sync_status} (${rev.last_synced_at})`); - - files.forEach((file) => { - console.log(` 📎 ${file.FILE_NM}`); - console.log(` 크기: ${file.FILE_SZ} bytes`); - console.log(` 경로: ${file.FLD_PATH}`); - console.log(` 상태: ${file.STAT_NM}`); - }); - console.log(); - } - - return revisions; -} - -// ============================================================================ -// 예제 4: 파일 검색 (플랫 뷰 활용) -// ============================================================================ - -export async function example4_SearchFiles( - projectNo: string, - fileNamePattern: string -) { - console.log("=== 예제 4: 파일 검색 ===\n"); - - // 1. 플랫 뷰에서 검색 - const result = await db.execute(sql` - SELECT - "DOC_NO", - "DOC_TITLE", - "REV_NO", - "STAGE", - "FILE_NM", - "FILE_SZ", - "FLD_PATH", - "STAT_NM" - FROM swp.v_swp_documents_flat - WHERE "PROJ_NO" = ${projectNo} - AND "FILE_NM" ILIKE ${`%${fileNamePattern}%`} - ORDER BY "DOC_NO", "REV_NO" DESC - LIMIT 20 - `); - - console.log(`🔍 검색어: "${fileNamePattern}"`); - console.log(`📊 결과: ${result.rowCount}개\n`); - - result.rows.forEach((row: any) => { - console.log(`📄 ${row.DOC_NO} (${row.DOC_TITLE})`); - console.log(` REV ${row.REV_NO} (${row.STAGE})`); - console.log(` 📎 ${row.FILE_NM} (${row.FILE_SZ} bytes)`); - console.log(` 상태: ${row.STAT_NM}`); - console.log(); - }); - - return result.rows; -} - -// ============================================================================ -// 예제 5: 파일 다운로드 URL 생성 -// ============================================================================ - -export async function example5_FileDownload(revisionId: number) { - console.log("=== 예제 5: 파일 다운로드 ===\n"); - - // 1. 리비전의 파일 조회 - const files = await getRevisionFiles(revisionId); - - console.log(`📋 리비전 ID: ${revisionId}`); - console.log(`📎 파일: ${files.length}개\n`); - - // 2. 다운로드 URL 생성 - const fileUrls = await Promise.all( - files - .filter((file) => file.FLD_PATH && file.FILE_NM) - .map(async (file) => ({ - fileName: file.FILE_NM, - downloadUrl: await getSwpFileDownloadUrl({ - FLD_PATH: file.FLD_PATH!, - FILE_NM: file.FILE_NM, - }), - size: file.FILE_SZ, - })) - ); - - fileUrls.forEach((item) => { - console.log(`📎 ${item.fileName}`); - console.log(` URL: ${item.downloadUrl}`); - console.log(` 크기: ${item.size} bytes`); - console.log(); - }); - - return fileUrls; -} - -// ============================================================================ -// 예제 6: 동기화 상태 모니터링 -// ============================================================================ - -export async function example6_SyncMonitoring(projectNo: string) { - console.log("=== 예제 6: 동기화 상태 모니터링 ===\n"); - - // 1. 프로젝트 동기화 상태 조회 - const result = await getProjectSyncStatus(projectNo); - const status = result.rows[0] as any; - - console.log(`📊 프로젝트: ${status.proj_no} (${status.proj_nm})`); - console.log(`\n📈 통계:`); - console.log(` - 문서: ${status.total_documents}개`); - console.log(` - 리비전: ${status.total_revisions}개`); - console.log(` - 파일: ${status.total_files}개`); - - console.log(`\n✅ 동기화 상태:`); - console.log(` - 문서: ${status.docs_synced}개 완료`); - console.log(` - 대기: ${status.docs_pending}개`); - console.log(` - 오류: ${status.docs_error}개`); - - console.log(`\n🕐 마지막 동기화: ${status.last_sync_time}`); - - return status; -} - -// ============================================================================ -// 예제 7: 스테이지별 문서 통계 -// ============================================================================ - -export async function example7_StageStatistics(projectNo: string) { - console.log("=== 예제 7: 스테이지별 통계 ===\n"); - - const result = await db.execute(sql` - SELECT - "STAGE", - COUNT(DISTINCT "DOC_NO")::int as doc_count, - COUNT(DISTINCT "REV_NO")::int as rev_count, - COUNT(*)::int as file_count - FROM swp.v_swp_documents_flat - WHERE "PROJ_NO" = ${projectNo} - AND "STAGE" IS NOT NULL - GROUP BY "STAGE" - ORDER BY "STAGE" - `); - - console.log(`📊 프로젝트: ${projectNo}\n`); - - result.rows.forEach((row: any) => { - console.log(`📌 ${row.STAGE}`); - console.log(` 문서: ${row.doc_count}개`); - console.log(` 리비전: ${row.rev_count}개`); - console.log(` 파일: ${row.file_count}개`); - console.log(); - }); - - return result.rows; -} - -// ============================================================================ -// 예제 8: 증분 동기화 (변경된 항목만) -// ============================================================================ - -export async function example8_IncrementalSync(projectNo: string) { - console.log("=== 예제 8: 증분 동기화 ===\n"); - - // 1. 마지막 동기화 시간 확인 - const lastSyncResult = await db.execute(sql` - SELECT MAX(last_synced_at) as last_sync - FROM swp.swp_documents - WHERE "PROJ_NO" = ${projectNo} - `); - - const lastSync = lastSyncResult.rows[0] as any; - console.log(`🕐 마지막 동기화: ${lastSync.last_sync || "없음"}`); - - // 2. 전체 동기화 (API는 증분 제공 안하므로) - console.log(`📡 전체 데이터 조회 중...`); - const { documents, files } = await fetchSwpProjectData(projectNo, "V"); - - // 3. 동기화 (upsert로 변경된 항목만 업데이트됨) - console.log(`💾 동기화 시작...`); - const syncResult = await syncSwpProject(projectNo, documents, files); - - console.log(`\n📊 결과:`); - console.log( - ` - 신규 문서: ${syncResult.stats.documents.inserted}개 (기존: ${syncResult.stats.documents.updated}개)` - ); - console.log( - ` - 신규 리비전: ${syncResult.stats.revisions.inserted}개 (기존: ${syncResult.stats.revisions.updated}개)` - ); - console.log( - ` - 신규 파일: ${syncResult.stats.files.inserted}개 (기존: ${syncResult.stats.files.updated}개)` - ); - - return syncResult; -} - -// ============================================================================ -// 전체 시나리오 실행 -// ============================================================================ - -export async function runAllExamples(projectNo: string = "SN2190") { - console.log("╔═══════════════════════════════════════════╗"); - console.log("║ SWP 문서 관리 시스템 사용 예제 ║"); - console.log("╚═══════════════════════════════════════════╝\n"); - - try { - // 예제 1: 전체 동기화 - await example1_FullProjectSync(projectNo); - console.log("\n" + "=".repeat(50) + "\n"); - - // 예제 2: 계층 구조 조회 - await example2_HierarchyView(projectNo); - console.log("\n" + "=".repeat(50) + "\n"); - - // 예제 6: 동기화 상태 - await example6_SyncMonitoring(projectNo); - console.log("\n" + "=".repeat(50) + "\n"); - - // 예제 7: 스테이지별 통계 - await example7_StageStatistics(projectNo); - - console.log("\n✅ 모든 예제 실행 완료!"); - } catch (error) { - console.error("\n❌ 오류 발생:", error); - throw error; - } -} - diff --git a/lib/swp/sync-service.ts b/lib/swp/sync-service.ts deleted file mode 100644 index 787b28ae..00000000 --- a/lib/swp/sync-service.ts +++ /dev/null @@ -1,537 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { eq, and, sql } from "drizzle-orm"; -import { - swpDocuments, - swpDocumentRevisions, - swpDocumentFiles, - type SwpDocumentInsert, - type SwpDocumentRevisionInsert, - type SwpDocumentFileInsert, -} from "@/db/schema/SWP/swp-documents"; - -// ============================================================================ -// API 응답 타입 정의 -// ============================================================================ - -export interface SwpDocumentApiResponse { - // 필수 필드 - DOC_NO: string; - DOC_TITLE: string; - PROJ_NO: string; - CPY_CD: string; - CPY_NM: string; - PIC_NM: string; - PIC_DEPTNM: string; - SKL_CD: string; - CRTER: string; - CRTE_DTM: string; - CHGR: string; - CHG_DTM: string; - - // 선택적 필드 (null 가능) - DOC_GB: string | null; - DOC_TYPE: string | null; - OWN_DOC_NO: string | null; - SHI_DOC_NO: string | null; - PROJ_NM: string | null; - PKG_NO: string | null; - MAT_CD: string | null; - MAT_NM: string | null; - DISPLN: string | null; - CTGRY: string | null; - VNDR_CD: string | null; - PIC_DEPTCD: string | null; - LTST_REV_NO: string | null; - LTST_REV_SEQ: string | null; - LTST_ACTV_STAT: string | null; - STAGE: string | null; - MOD_TYPE: string | null; - ACT_TYPE_NM: string | null; - USE_YN: string | null; - REV_DTM: string | null; -} - -export interface SwpFileApiResponse { - // 필수 필드 - OWN_DOC_NO: string; - REV_NO: string; - STAGE: string; - FILE_NM: string; - FILE_SEQ: string; - CRTER: string; - CRTE_DTM: string; - CHGR: string; - CHG_DTM: string; - - // 선택적 필드 (null 가능) - FILE_SZ: string | null; - FLD_PATH: string | null; - ACTV_NO: string | null; - ACTV_SEQ: string | null; - BOX_SEQ: string | null; - OFDC_NO: string | null; - PROJ_NO: string | null; - PKG_NO: string | null; - VNDR_CD: string | null; - CPY_CD: string | null; - STAT: string | null; - STAT_NM: string | null; - IDX: string | null; -} - -// ============================================================================ -// 동기화 결과 타입 -// ============================================================================ - -export interface SyncResult { - success: boolean; - projectNo: string; - stats: { - documents: { - total: number; - inserted: number; - updated: number; - }; - revisions: { - total: number; - inserted: number; - updated: number; - }; - files: { - total: number; - inserted: number; - updated: number; - }; - }; - errors: string[]; - duration: number; -} - -// ============================================================================ -// 동기화 메인 함수 -// ============================================================================ - -export async function syncSwpProject( - projectNo: string, - documents: SwpDocumentApiResponse[], - files: SwpFileApiResponse[] -): Promise<SyncResult> { - const startTime = Date.now(); - const errors: string[] = []; - const stats = { - documents: { total: 0, inserted: 0, updated: 0 }, - revisions: { total: 0, inserted: 0, updated: 0 }, - files: { total: 0, inserted: 0, updated: 0 }, - }; - - try { - // 트랜잭션으로 일괄 처리 - await db.transaction(async (tx) => { - // 1. 문서 동기화 - console.log(`[SYNC] 문서 동기화 시작: ${documents.length}개`); - for (const doc of documents) { - try { - const result = await upsertDocument(tx, doc); - stats.documents.total++; - if (result.inserted) stats.documents.inserted++; - if (result.updated) stats.documents.updated++; - } catch (error) { - errors.push( - `문서 ${doc.DOC_NO} 동기화 실패: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - // 2. 리비전별로 파일 그룹핑 - const revisionMap = new Map<string, SwpFileApiResponse[]>(); - for (const file of files) { - const key = `${file.OWN_DOC_NO}|${file.REV_NO}`; - if (!revisionMap.has(key)) { - revisionMap.set(key, []); - } - revisionMap.get(key)!.push(file); - } - - // 3. 리비전 및 파일 동기화 - console.log(`[SYNC] 리비전 동기화 시작: ${revisionMap.size}개`); - for (const [key, revFiles] of revisionMap) { - const [docNo, revNo] = key.split("|"); - const firstFile = revFiles[0]; - - try { - // 리비전 생성/업데이트 - const revisionResult = await upsertRevision(tx, docNo, firstFile); - stats.revisions.total++; - if (revisionResult.inserted) stats.revisions.inserted++; - if (revisionResult.updated) stats.revisions.updated++; - - const revisionId = revisionResult.id; - - // 파일들 생성/업데이트 - for (const file of revFiles) { - try { - const fileResult = await upsertFile(tx, revisionId, docNo, file); - stats.files.total++; - if (fileResult.inserted) stats.files.inserted++; - if (fileResult.updated) stats.files.updated++; - } catch (error) { - errors.push( - `파일 ${file.FILE_NM} 동기화 실패: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } catch (error) { - errors.push( - `리비전 ${docNo}-${revNo} 동기화 실패: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - console.log( - `[SYNC] 동기화 완료: 문서 ${stats.documents.total}, 리비전 ${stats.revisions.total}, 파일 ${stats.files.total}` - ); - }); - - return { - success: errors.length === 0, - projectNo, - stats, - errors, - duration: Date.now() - startTime, - }; - } catch (error) { - return { - success: false, - projectNo, - stats, - errors: [ - ...errors, - `트랜잭션 실패: ${error instanceof Error ? error.message : String(error)}`, - ], - duration: Date.now() - startTime, - }; - } -} - -// ============================================================================ -// Upsert 헬퍼 함수들 -// ============================================================================ - -async function upsertDocument( - tx: any, - doc: SwpDocumentApiResponse -): Promise<{ id: string; inserted: boolean; updated: boolean }> { - const data: SwpDocumentInsert = { - DOC_NO: doc.DOC_NO, - PROJ_NO: doc.PROJ_NO, - DOC_TITLE: doc.DOC_TITLE, - DOC_GB: doc.DOC_GB || null, - DOC_TYPE: doc.DOC_TYPE || null, - OWN_DOC_NO: doc.OWN_DOC_NO || null, - SHI_DOC_NO: doc.SHI_DOC_NO || null, - PROJ_NM: doc.PROJ_NM || null, - PKG_NO: doc.PKG_NO || null, - MAT_CD: doc.MAT_CD || null, - MAT_NM: doc.MAT_NM || null, - DISPLN: doc.DISPLN || null, - CTGRY: doc.CTGRY || null, - VNDR_CD: doc.VNDR_CD || null, - CPY_CD: doc.CPY_CD, - CPY_NM: doc.CPY_NM, - PIC_NM: doc.PIC_NM, - PIC_DEPTCD: doc.PIC_DEPTCD || null, - PIC_DEPTNM: doc.PIC_DEPTNM, - LTST_REV_NO: doc.LTST_REV_NO || null, - LTST_REV_SEQ: doc.LTST_REV_SEQ || null, - LTST_ACTV_STAT: doc.LTST_ACTV_STAT || null, - STAGE: doc.STAGE || null, - SKL_CD: doc.SKL_CD, - MOD_TYPE: doc.MOD_TYPE || null, - ACT_TYPE_NM: doc.ACT_TYPE_NM || null, - USE_YN: doc.USE_YN || null, - CRTER: doc.CRTER, - CRTE_DTM: doc.CRTE_DTM, - CHGR: doc.CHGR, - CHG_DTM: doc.CHG_DTM, - REV_DTM: doc.REV_DTM || null, - sync_status: "synced", - last_synced_at: new Date(), - updated_at: new Date(), - }; - - // 기존 문서 확인 (복합키: DOC_NO + PROJ_NO) - const existing = await tx - .select() - .from(swpDocuments) - .where( - and( - eq(swpDocuments.DOC_NO, doc.DOC_NO), - eq(swpDocuments.PROJ_NO, doc.PROJ_NO) - ) - ) - .limit(1); - - if (existing.length > 0) { - // 업데이트 - await tx - .update(swpDocuments) - .set(data) - .where( - and( - eq(swpDocuments.DOC_NO, doc.DOC_NO), - eq(swpDocuments.PROJ_NO, doc.PROJ_NO) - ) - ); - return { id: `${doc.DOC_NO}|${doc.PROJ_NO}`, inserted: false, updated: true }; - } else { - // 삽입 - await tx.insert(swpDocuments).values(data); - return { id: `${doc.DOC_NO}|${doc.PROJ_NO}`, inserted: true, updated: false }; - } -} - -async function upsertRevision( - tx: any, - docNo: string, - file: SwpFileApiResponse -): Promise<{ id: number; inserted: boolean; updated: boolean }> { - const data: Omit<SwpDocumentRevisionInsert, "id"> = { - DOC_NO: docNo, - REV_NO: file.REV_NO, - STAGE: file.STAGE, - ACTV_NO: file.ACTV_NO || null, - ACTV_SEQ: file.ACTV_SEQ || null, - BOX_SEQ: file.BOX_SEQ || null, - OFDC_NO: file.OFDC_NO || null, - PROJ_NO: file.PROJ_NO || null, - PKG_NO: file.PKG_NO || null, - VNDR_CD: file.VNDR_CD || null, - CPY_CD: file.CPY_CD || null, - sync_status: "synced", - last_synced_at: new Date(), - updated_at: new Date(), - }; - - // 기존 리비전 확인 - const existing = await tx - .select() - .from(swpDocumentRevisions) - .where( - and( - eq(swpDocumentRevisions.DOC_NO, docNo), - eq(swpDocumentRevisions.REV_NO, file.REV_NO) - ) - ) - .limit(1); - - if (existing.length > 0) { - // 업데이트 - await tx - .update(swpDocumentRevisions) - .set(data) - .where(eq(swpDocumentRevisions.id, existing[0].id)); - return { id: existing[0].id, inserted: false, updated: true }; - } else { - // 삽입 - const result = await tx - .insert(swpDocumentRevisions) - .values(data) - .returning({ id: swpDocumentRevisions.id }); - return { id: result[0].id, inserted: true, updated: false }; - } -} - -async function upsertFile( - tx: any, - revisionId: number, - docNo: string, - file: SwpFileApiResponse -): Promise<{ id: number; inserted: boolean; updated: boolean }> { - const data: Omit<SwpDocumentFileInsert, "id"> = { - revision_id: revisionId, - DOC_NO: docNo, - FILE_NM: file.FILE_NM, - FILE_SEQ: file.FILE_SEQ, - FILE_SZ: file.FILE_SZ || null, - FLD_PATH: file.FLD_PATH || null, - STAT: file.STAT || null, - STAT_NM: file.STAT_NM || null, - IDX: file.IDX || null, - ACTV_NO: file.ACTV_NO || null, - CRTER: file.CRTER, - CRTE_DTM: file.CRTE_DTM, - CHGR: file.CHGR, - CHG_DTM: file.CHG_DTM, - sync_status: "synced", - last_synced_at: new Date(), - updated_at: new Date(), - }; - - // 기존 파일 확인 (revision + fileSeq로 unique) - const existing = await tx - .select() - .from(swpDocumentFiles) - .where( - and( - eq(swpDocumentFiles.revision_id, revisionId), - eq(swpDocumentFiles.FILE_SEQ, file.FILE_SEQ) - ) - ) - .limit(1); - - if (existing.length > 0) { - // 업데이트 - await tx - .update(swpDocumentFiles) - .set(data) - .where(eq(swpDocumentFiles.id, existing[0].id)); - return { id: existing[0].id, inserted: false, updated: true }; - } else { - // 삽입 - const result = await tx - .insert(swpDocumentFiles) - .values(data) - .returning({ id: swpDocumentFiles.id }); - return { id: result[0].id, inserted: true, updated: false }; - } -} - -// ============================================================================ -// 조회 헬퍼 함수들 -// ============================================================================ - -/** - * 프로젝트의 문서 계층 구조 조회 (복잡한 JSON 집계는 SQL 직접 실행) - */ -export async function getProjectDocumentsHierarchy(projectNo: string) { - return db.execute(sql` - SELECT - d."DOC_NO", - d."DOC_TITLE", - d."PROJ_NO", - d."PROJ_NM", - d."PKG_NO", - d."VNDR_CD", - d."CPY_NM", - d."MAT_NM", - d."LTST_REV_NO", - d."LTST_ACTV_STAT", - d.sync_status, - d.last_synced_at, - - COALESCE( - json_agg( - json_build_object( - 'id', r.id, - 'revNo', r."REV_NO", - 'stage', r."STAGE", - 'actvNo', r."ACTV_NO", - 'ofdcNo', r."OFDC_NO", - 'syncStatus', r.sync_status, - 'fileCount', ( - SELECT COUNT(*)::int - FROM swp.swp_document_files f2 - WHERE f2.revision_id = r.id - ), - 'files', ( - SELECT COALESCE(json_agg( - json_build_object( - 'id', f.id, - 'fileNm', f."FILE_NM", - 'fileSeq', f."FILE_SEQ", - 'fileSz', f."FILE_SZ", - 'fldPath', f."FLD_PATH", - 'stat', f."STAT", - 'statNm', f."STAT_NM", - 'syncStatus', f.sync_status, - 'createdAt', f.created_at - ) - ORDER BY f."FILE_SEQ" - ), '[]'::json) - FROM swp.swp_document_files f - WHERE f.revision_id = r.id - ) - ) - ORDER BY r."REV_NO" DESC - ) FILTER (WHERE r.id IS NOT NULL), - '[]'::json - ) as revisions, - - COUNT(DISTINCT r.id)::int as revision_count, - COUNT(f.id)::int as total_file_count - - FROM swp.swp_documents d - LEFT JOIN swp.swp_document_revisions r ON d."DOC_NO" = r."DOC_NO" - LEFT JOIN swp.swp_document_files f ON r.id = f.revision_id - WHERE d."PROJ_NO" = ${projectNo} - GROUP BY - d."DOC_NO", - d."DOC_TITLE", - d."PROJ_NO", - d."PROJ_NM", - d."PKG_NO", - d."VNDR_CD", - d."CPY_NM", - d."MAT_NM", - d."LTST_REV_NO", - d."LTST_ACTV_STAT", - d.sync_status, - d.last_synced_at - ORDER BY d."DOC_NO" - `); -} - -/** - * 특정 문서의 모든 리비전 조회 - */ -export async function getDocumentRevisions(docNo: string) { - return db - .select() - .from(swpDocumentRevisions) - .where(eq(swpDocumentRevisions.DOC_NO, docNo)) - .orderBy(sql`${swpDocumentRevisions.REV_NO} DESC`); -} - -/** - * 특정 리비전의 모든 파일 조회 - */ -export async function getRevisionFiles(revisionId: number) { - return db - .select() - .from(swpDocumentFiles) - .where(eq(swpDocumentFiles.revision_id, revisionId)) - .orderBy(swpDocumentFiles.FILE_SEQ); -} - -/** - * 프로젝트 동기화 상태 조회 - */ -export async function getProjectSyncStatus(projectNo: string) { - return db.execute(sql` - SELECT - d."PROJ_NO", - d."PROJ_NM", - - COUNT(DISTINCT d."DOC_NO")::int as total_documents, - COUNT(DISTINCT r.id)::int as total_revisions, - COUNT(f.id)::int as total_files, - - COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'synced')::int as docs_synced, - COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'pending')::int as docs_pending, - COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'error')::int as docs_error, - - COUNT(DISTINCT r.id) FILTER (WHERE r.sync_status = 'synced')::int as revs_synced, - COUNT(f.id) FILTER (WHERE f.sync_status = 'synced')::int as files_synced, - - MAX(d.last_synced_at) as last_sync_time - - FROM swp.swp_documents d - LEFT JOIN swp.swp_document_revisions r ON d."DOC_NO" = r."DOC_NO" - LEFT JOIN swp.swp_document_files f ON r.id = f.revision_id - WHERE d."PROJ_NO" = ${projectNo} - GROUP BY d."PROJ_NO", d."PROJ_NM" - `); -} - diff --git a/lib/swp/table/swp-uploaded-files-dialog.tsx b/lib/swp/table/swp-uploaded-files-dialog.tsx index 25a798b6..14d69df4 100644 --- a/lib/swp/table/swp-uploaded-files-dialog.tsx +++ b/lib/swp/table/swp-uploaded-files-dialog.tsx @@ -14,12 +14,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { useToast } from "@/hooks/use-toast"; import { FileText, ChevronRight, ChevronDown, X, Loader2, RefreshCw } from "lucide-react"; -import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../actions"; +import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../vendor-actions"; import type { SwpFileApiResponse } from "../api-client"; interface SwpUploadedFilesDialogProps { projNo: string; - vndrCd: string; + vndrCd: string; // UI 표시용으로만 사용 userId: string; } @@ -87,18 +87,19 @@ export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFi // 파일 목록 조회 const loadFiles = () => { - if (!projNo || !vndrCd) { + if (!projNo) { toast({ variant: "destructive", title: "조회 불가", - description: "프로젝트와 업체 정보가 필요합니다.", + description: "프로젝트 정보가 필요합니다.", }); return; } startLoading(async () => { try { - const result = await fetchVendorUploadedFiles(projNo, vndrCd); + // vndrCd는 서버에서 세션으로 자동 조회 + const result = await fetchVendorUploadedFiles(projNo); setFiles(result); toast({ title: "조회 완료", @@ -189,7 +190,7 @@ export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFi return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant="outline" size="sm" disabled={!projNo || !vndrCd}> + <Button variant="outline" size="sm" disabled={!projNo}> <FileText className="h-4 w-4 mr-2" /> 업로드 파일 관리 </Button> diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts index f65ed007..f87c41a8 100644 --- a/lib/swp/vendor-actions.ts +++ b/lib/swp/vendor-actions.ts @@ -1,5 +1,17 @@ "use server"; +/** + * SWP Vendor Actions + * + * 벤더 페이지(제출)에서 사용하는 서버 액션 모음입니다. + * - 다운로드 및 업로드는 서버액션의 데이터 직렬화 문제로, 별도의 API Route로 분리함 + * - 간단한 API 호출은 서버 액션으로 관리 + * + * 1. 파일 메타정보 업로드 + * 2. 파일 업로드 취소 + * + */ + import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import db from "@/db/db"; @@ -145,7 +157,7 @@ export async function fetchVendorDocuments(projNo?: string): Promise<DocumentLis } catch (error) { debugError("문서 목록 조회 실패", error); console.error("[fetchVendorDocuments] 오류:", error); - throw new Error("문서 목록 조회 실패 [담당자에게 문의하세요]"); + throw new Error("문서 목록 조회 실패 [SWP 담당자에게 문의하세요]"); } } @@ -334,6 +346,92 @@ export async function fetchVendorSwpStats(projNo?: string) { } // ============================================================================ +// 벤더가 업로드한 파일 목록 조회 (Inbox) +// ============================================================================ + +export async function fetchVendorUploadedFiles(projNo: string) { + debugProcess("벤더 업로드 파일 목록 조회 시작", { projNo }); + + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + debugError("벤더 정보 없음 - 업로드 파일 조회 실패"); + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + if (!projNo) { + debugWarn("프로젝트 번호 없음"); + return []; + } + + debugLog("업로드 파일 목록 조회 시작", { + projNo, + vendorCode: vendorInfo.vendorCode + }); + + // api-client의 fetchGetExternalInboxList 사용 + const { fetchGetExternalInboxList } = await import("./api-client"); + const files = await fetchGetExternalInboxList({ + projNo, + vndrCd: vendorInfo.vendorCode, + }); + + debugSuccess("업로드 파일 목록 조회 성공", { count: files.length }); + return files; + } catch (error) { + debugError("업로드 파일 목록 조회 실패", error); + console.error("[fetchVendorUploadedFiles] 오류:", error); + throw new Error("업로드 파일 목록 조회 실패"); + } +} + +// ============================================================================ +// 벤더가 업로드한 파일 취소 (userId 파라미터 버전) +// ============================================================================ + +export interface CancelVendorUploadedFileParams { + boxSeq: string; + actvSeq: string; + userId: string; +} + +export async function cancelVendorUploadedFile(params: CancelVendorUploadedFileParams) { + debugProcess("벤더 업로드 파일 취소 시작", params); + + try { + const vendorInfo = await getVendorSessionInfo(); + + if (!vendorInfo) { + debugError("벤더 정보 없음"); + throw new Error("벤더 정보를 찾을 수 없습니다."); + } + + // api-client의 callSaveInBoxListCancelStatus 사용 + const { callSaveInBoxListCancelStatus } = await import("./api-client"); + const cancelCount = await callSaveInBoxListCancelStatus({ + boxSeq: params.boxSeq, + actvSeq: params.actvSeq, + chgr: `evcp${params.userId}`, + }); + + debugSuccess("업로드 파일 취소 완료", { + ...params, + cancelCount + }); + + return { + success: true, + cancelCount + }; + } catch (error) { + debugError("업로드 파일 취소 실패", error); + console.error("[cancelVendorUploadedFile] 오류:", error); + throw new Error("파일 취소 실패"); + } +} + +// ============================================================================ // 주의: 파일 업로드는 /api/swp/upload 라우트에서 처리됩니다 // ============================================================================ |
