summaryrefslogtreecommitdiff
path: root/lib/gtc-contract/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gtc-contract/service.ts')
-rw-r--r--lib/gtc-contract/service.ts520
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