summaryrefslogtreecommitdiff
path: root/lib/swp
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-02 14:03:34 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-02 14:03:34 +0900
commitde4c6593f0cc91c6e0c1a4e2bf9581f11f4f5c98 (patch)
tree8b3d88637309ac9fb67f79606d834364d784105b /lib/swp
parentfd5ff7a9eaea4baeacc3f4bec8254925d63bf255 (diff)
(김준회) SWP 리스트 관리 파트 오류 수정 및 요구사항 반영, 동적 상태 리스트 필터링 변경, null은 동기화 전(전송 전)으로 간주, 선택된 것만 보내도록 변경
Diffstat (limited to 'lib/swp')
-rw-r--r--lib/swp/actions.ts542
-rw-r--r--lib/swp/api-client.ts40
-rw-r--r--lib/swp/document-service.ts76
-rw-r--r--lib/swp/example-usage.ts347
-rw-r--r--lib/swp/sync-service.ts537
-rw-r--r--lib/swp/table/swp-uploaded-files-dialog.tsx13
-rw-r--r--lib/swp/vendor-actions.ts100
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 라우트에서 처리됩니다
// ============================================================================