diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
| commit | 10f90dc68dec42e9a64e081cc0dce6a484447290 (patch) | |
| tree | 5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib/gtc-contract/service.ts | |
| parent | 792fb0c21136eededecf52b5b4aa1a252bdc4bfb (diff) | |
(대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule
Diffstat (limited to 'lib/gtc-contract/service.ts')
| -rw-r--r-- | lib/gtc-contract/service.ts | 520 |
1 files changed, 517 insertions, 3 deletions
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index 308c52bf..4d11ad0a 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -1,13 +1,13 @@ 'use server' import { revalidateTag, unstable_cache } from "next/cache" -import { and, desc, asc, eq, or, ilike, count, max , inArray} from "drizzle-orm" +import { and, desc, asc, eq, or, ilike, count, max , inArray, isNotNull, notInArray} from "drizzle-orm" import db from "@/db/db" -import { gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { GtcClauseTreeView, gtcClauses, gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" import { projects } from "@/db/schema/projects" import { users } from "@/db/schema/users" import { filterColumns } from "@/lib/filter-columns" -import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema } from "./validations" +import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations" /** * 프로젝트 존재 여부 확인 @@ -330,4 +330,518 @@ export async function getGtcDocumentById(id: number) { tags: [`gtc-document-${id}`, "gtc-documents"], } )() +} + +// 복제 함수 +export async function cloneGtcDocument( + data: CloneGtcDocumentSchema & { createdById: number } +): Promise<{ data?: GtcDocument; error?: string }> { + try { + return await db.transaction(async (tx) => { + // 1. 원본 문서 조회 + const [sourceDocument] = await tx + .select() + .from(gtcDocuments) + .where(eq(gtcDocuments.id, data.sourceDocumentId)) + + if (!sourceDocument) { + throw new Error("원본 문서를 찾을 수 없습니다.") + } + + // 2. 새로운 리비전 번호 계산 + const nextRevision = await getNextRevision(data.type, data.projectId || undefined) + + // 3. 새 문서 생성 + const [newDocument] = await tx + .insert(gtcDocuments) + .values({ + type: data.type, + projectId: data.projectId, + title: data.title || sourceDocument.title, + revision: nextRevision, + fileName: sourceDocument.fileName, // 파일 정보도 복사 + filePath: sourceDocument.filePath, + fileSize: sourceDocument.fileSize, + createdById: data.createdById, + updatedById: data.createdById, + editReason: data.editReason || `${sourceDocument.title || 'GTC 문서'} v${sourceDocument.revision}에서 복제`, + isActive: true, + }) + .returning() + + // 4. 원본 문서의 모든 clauses 조회 + const sourceClauses = await tx + .select() + .from(gtcClauses) + .where(eq(gtcClauses.documentId, data.sourceDocumentId)) + .orderBy(gtcClauses.sortOrder) + + // 5. clauses 복제 (ID 매핑을 위한 Map 생성) + if (sourceClauses.length > 0) { + const clauseIdMapping = new Map<number, number>() + + // 첫 번째 pass: 모든 clauses를 복사하고 ID 매핑 생성 + for (const sourceClause of sourceClauses) { + const [newClause] = await tx + .insert(gtcClauses) + .values({ + documentId: newDocument.id, + parentId: null, // 첫 번째 pass에서는 null로 설정 + itemNumber: sourceClause.itemNumber, + category: sourceClause.category, + subtitle: sourceClause.subtitle, + content: sourceClause.content, + sortOrder: sourceClause.sortOrder, + depth: sourceClause.depth, + fullPath: sourceClause.fullPath, + images: sourceClause.images, + isActive: sourceClause.isActive, + createdById: data.createdById, + updatedById: data.createdById, + editReason: data.editReason || "문서 복제", + }) + .returning() + + clauseIdMapping.set(sourceClause.id, newClause.id) + } + + // 두 번째 pass: parentId 관계 설정 + for (const sourceClause of sourceClauses) { + if (sourceClause.parentId) { + const newParentId = clauseIdMapping.get(sourceClause.parentId) + const newClauseId = clauseIdMapping.get(sourceClause.id) + + if (newParentId && newClauseId) { + await tx + .update(gtcClauses) + .set({ parentId: newParentId }) + .where(eq(gtcClauses.id, newClauseId)) + } + } + } + } + + revalidateTag("gtc-documents") + revalidateTag(`gtc-clauses-${newDocument.id}`) + + return { data: newDocument } + }) + } catch (error) { + console.error("Error cloning GTC document:", error) + return { + error: error instanceof Error + ? error.message + : "문서 복제 중 오류가 발생했습니다." + } + } +} + + +// 새 함수: GTC 문서가 없는 프로젝트만 조회 +export async function getAvailableProjectsForGtc(): Promise<ProjectForFilter[]> { + // 이미 GTC 문서가 있는 프로젝트 ID들 조회 + const projectsWithGtc = await db + .selectDistinct({ + projectId: gtcDocuments.projectId + }) + .from(gtcDocuments) + .where(isNotNull(gtcDocuments.projectId)) + + const usedProjectIds = projectsWithGtc + .map(row => row.projectId) + .filter((id): id is number => id !== null) + + // GTC 문서가 없는 프로젝트들만 반환 + if (usedProjectIds.length === 0) { + // 사용된 프로젝트가 없으면 모든 프로젝트 반환 + return await getProjectsForSelect() + } + + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(notInArray(projects.id, usedProjectIds)) + .orderBy(projects.name) +} + +// 복제시 사용할 함수: 특정 프로젝트는 제외하고 조회 +export async function getAvailableProjectsForGtcExcluding(excludeProjectId?: number): Promise<ProjectForFilter[]> { + // 이미 GTC 문서가 있는 프로젝트 ID들 조회 + const projectsWithGtc = await db + .selectDistinct({ + projectId: gtcDocuments.projectId + }) + .from(gtcDocuments) + .where(isNotNull(gtcDocuments.projectId)) + + let usedProjectIds = projectsWithGtc + .map(row => row.projectId) + .filter((id): id is number => id !== null) + + // 제외할 프로젝트 ID가 있다면 사용된 ID 목록에서 제거 (복제시 원본 프로젝트는 선택 가능) + if (excludeProjectId) { + usedProjectIds = usedProjectIds.filter(id => id !== excludeProjectId) + } + + // GTC 문서가 없는 프로젝트들만 반환 + if (usedProjectIds.length === 0) { + return await getProjectsForSelect() + } + + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(notInArray(projects.id, usedProjectIds)) + .orderBy(projects.name) +} + +export async function hasStandardGtcDocument(): Promise<boolean> { + const result = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where(eq(gtcDocuments.type, "standard")) + .limit(1) + + return result.length > 0 +} + +export async function getAllGtcClausesForExport(documentId: number): Promise<GtcClauseTreeView[]> { + try { + // 실제 데이터베이스 쿼리 로직을 여기에 구현 + // 예시: 문서 ID에 해당하는 모든 조항을 트리 뷰 형태로 가져오기 + const clauses = await db + .select() + .from(gtcClauses) + .where(eq(gtcClauses.documentId, documentId)) + // 여기에 필요한 JOIN, ORDER BY 등을 추가 + .orderBy(gtcClauses.sortOrder) + + // GtcClauseTreeView 형태로 변환하여 반환 + return clauses.map((clause) => ({ + ...clause, + // 필요한 추가 필드들을 여기에 매핑 + })) as GtcClauseTreeView[] + } catch (error) { + console.error("Failed to fetch GTC clauses for export:", error) + throw new Error("Failed to fetch GTC clauses for export") + } +} + + +interface ImportGtcClauseData { + itemNumber: string + subtitle: string + content?: string + category?: string + sortOrder?: number + parentId?: number | null + depth?: number + fullPath?: string + images?: any[] + isActive?: boolean + editReason?: string +} + +interface ImportResult { + success: boolean + importedCount: number + errors: string[] + duplicates: string[] +} + +/** + * Excel에서 가져온 GTC 조항들을 데이터베이스에 저장 + */ +export async function importGtcClausesFromExcel( + documentId: number, + data: Partial<GtcClauseTreeView>[], + userId: number = 1 // TODO: 실제 사용자 ID로 교체 +): Promise<ImportResult> { + const result: ImportResult = { + success: false, + importedCount: 0, + errors: [], + duplicates: [] + } + + try { + // 데이터 검증 및 변환 + const validData: ImportGtcClauseData[] = [] + + for (let i = 0; i < data.length; i++) { + const item = data[i] + const rowNumber = i + 1 + + // 필수 필드 검증 + if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') { + result.errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`) + continue + } + + if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') { + result.errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`) + continue + } + + // 중복 채번 체크 (같은 문서 내에서, 같은 부모 하에서) + const existingClause = await db + .select({ id: gtcClauses.id, itemNumber: gtcClauses.itemNumber }) + .from(gtcClauses) + .where( + and( + eq(gtcClauses.documentId, documentId), + eq(gtcClauses.parentId, item.parentId || null), + eq(gtcClauses.itemNumber, item.itemNumber.trim()) + ) + ) + .limit(1) + + if (existingClause.length > 0) { + result.duplicates.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`) + continue + } + + // 상위 조항 ID 검증 (제공된 경우) + if (item.parentId && typeof item.parentId === 'number') { + const parentExists = await db + .select({ id: gtcClauses.id }) + .from(gtcClauses) + .where( + and( + eq(gtcClauses.documentId, documentId), + eq(gtcClauses.id, item.parentId) + ) + ) + .limit(1) + + if (parentExists.length === 0) { + result.errors.push(`${rowNumber}행: 상위 조항 ID ${item.parentId}를 찾을 수 없습니다.`) + continue + } + } + + // sortOrder를 decimal로 변환 + let sortOrder = 0 + if (item.sortOrder !== undefined) { + if (typeof item.sortOrder === 'number') { + sortOrder = item.sortOrder + } else if (typeof item.sortOrder === 'string') { + const parsed = parseFloat(item.sortOrder) + if (!isNaN(parsed)) { + sortOrder = parsed + } + } + } else { + // 기본값: (현재 인덱스 + 1) * 10 + sortOrder = (validData.length + 1) * 10 + } + + // depth 계산 (parentId가 있으면 부모의 depth + 1, 아니면 0) + let depth = 0 + if (item.parentId) { + const parentClause = await db + .select({ depth: gtcClauses.depth }) + .from(gtcClauses) + .where(eq(gtcClauses.id, item.parentId)) + .limit(1) + + if (parentClause.length > 0) { + depth = (parentClause[0].depth || 0) + 1 + } + } + + // 유효한 데이터 추가 + validData.push({ + itemNumber: item.itemNumber.trim(), + subtitle: item.subtitle.trim(), + content: item.content?.toString().trim() || null, + category: item.category?.toString().trim() || null, + sortOrder: sortOrder, + parentId: item.parentId || null, + isActive: typeof item.isActive === 'boolean' ? item.isActive : true, + editReason: item.editReason?.toString().trim() || null, + }) + } + + // 오류가 있거나 중복이 있으면 가져오기 중단 + if (result.errors.length > 0 || result.duplicates.length > 0) { + return result + } + + // 트랜잭션으로 데이터 저장 + await db.transaction(async (tx) => { + for (const clauseData of validData) { + try { + // depth 재계산 (저장 시점에서) + let finalDepth = 0 + if (clauseData.parentId) { + const parentClause = await tx + .select({ depth: gtcClauses.depth }) + .from(gtcClauses) + .where(eq(gtcClauses.id, clauseData.parentId)) + .limit(1) + + if (parentClause.length > 0) { + finalDepth = (parentClause[0].depth || 0) + 1 + } + } + + await tx.insert(gtcClauses).values({ + documentId, + parentId: clauseData.parentId, + itemNumber: clauseData.itemNumber, + category: clauseData.category, + subtitle: clauseData.subtitle, + content: clauseData.content, + sortOrder:clauseData.sortOrder? clauseData.sortOrder.toString() :"0", // decimal로 저장 + depth: finalDepth, + fullPath: null, // 추후 별도 로직에서 생성 + images: null, // Excel 가져오기에서는 이미지 제외 + isActive: clauseData.isActive, + createdById: userId, + updatedById: userId, + editReason: clauseData.editReason, + createdAt: new Date(), + updatedAt: new Date(), + }) + + result.importedCount++ + } catch (insertError) { + result.errors.push(`"${clauseData.subtitle}" 저장 중 오류: ${insertError instanceof Error ? insertError.message : '알 수 없는 오류'}`) + } + } + }) + + result.success = result.importedCount > 0 && result.errors.length === 0 + + return result + + } catch (error) { + result.errors.push(`가져오기 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + return result + } +} + +/** + * Excel 가져오기 전 데이터 유효성 검사 + */ +export async function validateGtcClausesImport( + documentId: number, + data: Partial<GtcClauseTreeView>[] +): Promise<{ + valid: boolean + errors: string[] + warnings: string[] + summary: { + totalRows: number + validRows: number + duplicateCount: number + errorCount: number + } +}> { + const errors: string[] = [] + const warnings: string[] = [] + let validRows = 0 + let duplicateCount = 0 + + try { + // 기존 채번들 가져오기 (중복 체크용) + const existingItems = await db + .select({ + itemNumber: gtcClauses.itemNumber, + parentId: gtcClauses.parentId + }) + .from(gtcClauses) + .where(eq(gtcClauses.documentId, documentId)) + + // 채번-부모ID 조합으로 중복 체크 세트 생성 + const existingItemSet = new Set( + existingItems.map(item => `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}`) + ) + + // 같은 파일 내에서의 중복 체크를 위한 세트 + const currentFileItems = new Set<string>() + + for (let i = 0; i < data.length; i++) { + const item = data[i] + const rowNumber = i + 1 + let hasError = false + + // 필수 필드 검증 + if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') { + errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`) + hasError = true + } + + if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') { + errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`) + hasError = true + } + + if (item.itemNumber && item.itemNumber.trim() !== '') { + const itemKey = `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}` + + // DB에 이미 존재하는지 체크 + if (existingItemSet.has(itemKey)) { + warnings.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`) + duplicateCount++ + } + + // 현재 파일 내에서 중복인지 체크 + if (currentFileItems.has(itemKey)) { + errors.push(`${rowNumber}행: 채번 "${item.itemNumber}"가 파일 내에서 중복됩니다.`) + hasError = true + } else { + currentFileItems.add(itemKey) + } + } + + // 숫자 필드 검증 + if (item.sortOrder !== undefined) { + const sortOrderNum = typeof item.sortOrder === 'string' ? parseFloat(item.sortOrder) : item.sortOrder + if (typeof sortOrderNum !== 'number' || isNaN(sortOrderNum) || sortOrderNum < 0) { + warnings.push(`${rowNumber}행: 순서는 0 이상의 숫자여야 합니다.`) + } + } + + if (!hasError) { + validRows++ + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + summary: { + totalRows: data.length, + validRows, + duplicateCount, + errorCount: errors.length + } + } + + } catch (error) { + errors.push(`유효성 검사 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + + return { + valid: false, + errors, + warnings, + summary: { + totalRows: data.length, + validRows: 0, + duplicateCount: 0, + errorCount: errors.length + } + } + } }
\ No newline at end of file |
