diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-12 08:01:02 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-12 08:01:02 +0000 |
| commit | a9575387c3a765a1a65ebc179dae16a21af6eb25 (patch) | |
| tree | 347f2b17b07039080fb2f116460004ba0b75a779 /lib/general-contract-template/service.ts | |
| parent | 47e527f5f763658600696ee58451fb666e692f5a (diff) | |
(임수민) 일반 계약 템플릿 구현 및 basic contract 필터 수정
Diffstat (limited to 'lib/general-contract-template/service.ts')
| -rw-r--r-- | lib/general-contract-template/service.ts | 626 |
1 files changed, 626 insertions, 0 deletions
diff --git a/lib/general-contract-template/service.ts b/lib/general-contract-template/service.ts new file mode 100644 index 00000000..9b3eda68 --- /dev/null +++ b/lib/general-contract-template/service.ts @@ -0,0 +1,626 @@ +"use server"; + +import { revalidateTag, revalidatePath, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { getErrorMessage } from "@/lib/handle-error"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { asc, desc, ilike, inArray, and, or, eq, type SQL, sql } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +import { GeneralContractTemplate, generalContractTemplates, users } from "@/db/schema"; +import { deleteFile, saveFile } from "@/lib/file-stroage"; +import { + selectContractTemplates, + selectContractTemplatesWithUsers, + countContractTemplates, + insertContractTemplate, + getContractTemplateById as getContractTemplateByIdFromRepo, + updateContractTemplate, + deleteContractTemplates, + findAllContractTemplates +} from "./repository"; +import { + GetContractTemplatesSchema, + CreateContractTemplateSchema, + UpdateContractTemplateSchema, + DeleteContractTemplateSchema +} from "./validations"; + +// ---------------------------------------------------------------------------------------------------- + +/* HELPER FUNCTION FOR GETTING CURRENT USER ID */ +async function getCurrentUserId(): Promise<number> { + try { + const session = await getServerSession(authOptions); + return session?.user?.id ? Number(session.user.id) : 3; // 기본값 3, 실제 환경에서는 적절한 기본값 설정 + } catch (error) { + console.error('Error getting current user ID:', error); + return 3; // 기본값 3 + } +} + +// ---------------------------------------------------------------------------------------------------- + +// ================================================================================= +// Contract Template Functions +// ================================================================================= + +export async function getContractTemplates( + input: GetContractTemplatesSchema +) { + return unstable_cache( + async () => { + try { + const { data, total } = await db.transaction(async (tx) => { + // 필터 조건 구성 + let whereCondition: any = undefined; + + if (input.search) { + const s = `%${input.search}%`; + whereCondition = or( + ilike(generalContractTemplates.contractTemplateName, s), + ilike(generalContractTemplates.contractTemplateType, s), + ilike(generalContractTemplates.fileName, s) + ); + } + + // 필터 추가 (기본적으로 모든 상태 표시) + let statusCondition: any = undefined; + + if (input.filters && input.filters.length > 0) { + const statusFilter = input.filters.find(f => f.id === 'status'); + if (statusFilter && statusFilter.value.length > 0) { + // statusFilter.value가 문자열이면 배열로 변환 + const statusValues = Array.isArray(statusFilter.value) + ? statusFilter.value + : [statusFilter.value]; + + // "ALL"이 포함되어 있으면 상태 필터를 제거 (모든 상태 표시) + if (statusValues.includes('ALL')) { + statusCondition = undefined; + } else { + statusCondition = inArray(generalContractTemplates.status, statusValues); + } + } + + // 다른 필터들 처리 + const otherFilters = input.filters.filter(f => f.id !== 'status' && f.value.length > 0); + for (const filter of otherFilters) { + let filterCondition: any; + + // 계약문서명은 부분 일치 검색 (ilike) + if (filter.id === 'contractTemplateName') { + const searchValue = `%${filter.value}%`; + filterCondition = ilike(generalContractTemplates.contractTemplateName, searchValue); + } else { + // 다른 필터들은 정확히 일치 검색 (inArray) + filterCondition = inArray( + generalContractTemplates[filter.id as keyof typeof generalContractTemplates] as any, + filter.value + ); + } + + whereCondition = whereCondition + ? and(whereCondition, filterCondition) + : filterCondition; + } + } + + // 최종 where 조건 + if (statusCondition) { + if (whereCondition) { + whereCondition = and(whereCondition, statusCondition); + } else { + whereCondition = statusCondition; + } + } + + + // 정렬 조건 + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(generalContractTemplates[item.id as keyof typeof generalContractTemplates] as any) + : asc(generalContractTemplates[item.id as keyof typeof generalContractTemplates] as any) + ) + : [desc(generalContractTemplates.createdAt)]; + + // 데이터 조회 (사용자 정보 포함) + const offset = (input.page - 1) * input.perPage; + const dataResult = await selectContractTemplatesWithUsers(tx, { + where: whereCondition, + orderBy, + offset, + limit: input.perPage, + }); + + // 총 개수 조회 + const totalCount = await countContractTemplates(tx, whereCondition); + + return { data: dataResult, total: totalCount }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (error) { + console.error("getContractTemplates 에러:", error); + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["general-contract-templates"], + } + )(); +} + +/** + * ID로 계약 템플릿 조회 + */ +export async function getContractTemplateById(id: string) { + return unstable_cache( + async () => { + try { + const template = await db + .select() + .from(generalContractTemplates) + .where(eq(generalContractTemplates.id, parseInt(id))) + .limit(1); + return template[0] || null; + } catch (error) { + console.error("getContractTemplateById 에러:", error); + return null; + } + }, + [id], + { revalidate: 3600, tags: ["general-contract-templates"] } + )(); +} + +/** + * 템플릿 이름 조회 + */ +export async function getExistingTemplateNamesById(id: number): Promise<string> { + const rows = await db + .select({ + contractTemplateName: generalContractTemplates.contractTemplateName, + }) + .from(generalContractTemplates) + .where(and(eq(generalContractTemplates.status, "ACTIVE"), eq(generalContractTemplates.id, id))) + .limit(1); + + return rows[0]?.contractTemplateName || ""; +} + +/** + * 템플릿 파일 저장 서버 액션 + */ +export async function saveTemplateFile(templateId: number, formData: FormData) { + try { + const file = formData.get("file") as File; + + if (!file) { + return { error: "파일이 필요합니다." }; + } + + // 기존 템플릿 정보 조회 + const existingTemplate = await db + .select() + .from(generalContractTemplates) + .where(eq(generalContractTemplates.id, templateId)) + .limit(1); + + if (existingTemplate.length === 0) { + return { error: "템플릿을 찾을 수 없습니다." }; + } + + const template = existingTemplate[0]; + if (!template.filePath) { + return { error: "파일 경로가 없습니다." }; + } + + // 파일 저장 로직 (실제 파일 시스템에 저장) + const { writeFile, mkdir } = await import("fs/promises"); + const { join } = await import("path"); + + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // 기존 파일 경로 사용 (덮어쓰기) - general-contract-templates 경로로 통일 + const uploadPath = join(process.cwd(), "public", template.filePath.replace(/^\//, "")); + + // 디렉토리 확인 및 생성 + const dirPath = uploadPath.substring(0, uploadPath.lastIndexOf('/')); + await mkdir(dirPath, { recursive: true }); + + // 파일 저장 + await writeFile(uploadPath, buffer); + + // 캐시 무효화 (목록/상세 모두 고려) + revalidatePath(`/evcp/general-contract-template/${templateId}`); + revalidateTag("general-contract-templates"); + + return { success: true, message: "파일이 성공적으로 저장되었습니다." }; + } catch (error) { + console.error("saveTemplateFile 에러:", error); + return { error: error instanceof Error ? error.message : "파일 저장 중 오류가 발생했습니다." }; + } +} + +// 새 리비전 생성 (basic-contract의 createBasicContractTemplateRevision 패턴 반영) +export async function createGeneralContractTemplateRevision(input: { + baseTemplateId: number; + contractTemplateType: string; + contractTemplateName: string; + revision: number; + legalReviewRequired: boolean; + status: 'ACTIVE' | 'INACTIVE' | 'DISPOSED'; + fileName: string; + filePath: string; +}) { + unstable_noStore(); + + try { + // 기본 템플릿 존재 확인 + const base = await db + .select() + .from(generalContractTemplates) + .where(eq(generalContractTemplates.id, input.baseTemplateId)) + .limit(1); + if (base.length === 0) { + return { data: null, error: '기본 템플릿을 찾을 수 없습니다.' }; + } + + // 동일 이름/타입에 해당 리비전이 이미 존재하는지 검사 + const exists = await db + .select({ rev: generalContractTemplates.revision }) + .from(generalContractTemplates) + .where( + and( + eq(generalContractTemplates.contractTemplateName, input.contractTemplateName), + eq(generalContractTemplates.contractTemplateType, input.contractTemplateType), + eq(generalContractTemplates.revision, input.revision) + ) + ) + .limit(1); + if (exists.length > 0) { + return { data: null, error: `${input.contractTemplateName} v${input.revision} 리비전이 이미 존재합니다.` }; + } + + // 기존 리비전 확인 - baseTemplateId가 있으면 해당 템플릿의 최대 리비전을 찾음 + let maxRevision = 0; + if (input.baseTemplateId) { + const max = await db + .select({ rev: generalContractTemplates.revision }) + .from(generalContractTemplates) + .where(eq(generalContractTemplates.id, input.baseTemplateId)) + .limit(1); + maxRevision = max[0]?.rev ?? 0; + } else { + // baseTemplateId가 없으면 이름과 타입으로 찾음 + const max = await db + .select({ rev: generalContractTemplates.revision }) + .from(generalContractTemplates) + .where( + and( + eq(generalContractTemplates.contractTemplateName, input.contractTemplateName), + eq(generalContractTemplates.contractTemplateType, input.contractTemplateType) + ) + ) + .orderBy(desc(generalContractTemplates.revision)) + .limit(1); + maxRevision = max[0]?.rev ?? 0; + } + + if (input.revision <= maxRevision) { + return { data: null, error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision})보다 커야 합니다.` }; + } + + const currentUserId = await getCurrentUserId(); + + const newRow = await db.transaction(async (tx) => { + const [row] = await insertContractTemplate(tx, { + contractTemplateType: input.contractTemplateType, + contractTemplateName: input.contractTemplateName, + revision: input.revision, + status: input.status, + legalReviewRequired: input.legalReviewRequired, + fileName: input.fileName, + filePath: input.filePath, + createdAt: new Date(), + createdBy: currentUserId, + updatedAt: new Date(), + updatedBy: currentUserId, + }); + return row; + }); + + revalidateTag('general-contract-templates'); + return { data: newRow, error: null }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + +// Contract Template 생성 +export async function createContractTemplate(input: CreateContractTemplateSchema) { + unstable_noStore(); + + try { + // 현재 로그인한 사용자 ID 가져오기 + const currentUserId = await getCurrentUserId(); + + const newTemplate = await db.transaction(async (tx) => { + const [row] = await insertContractTemplate(tx, { + contractTemplateType: (input as any).contractTemplateType ?? (input as any).contractType, + contractTemplateName: (input as any).contractTemplateName ?? (input as any).contractName, + revision: input.revision || 1, + status: input.status || "ACTIVE", + legalReviewRequired: input.legalReviewRequired || false, + fileName: input.fileName || null, + filePath: input.filePath || null, + createdAt: new Date(), + createdBy: currentUserId, + updatedAt: new Date(), + updatedBy: currentUserId, + }); + return row; + }); + + revalidateTag("general-contract-templates"); + revalidatePath("/evcp/general-contract-template"); + return { data: newTemplate, error: null }; + } catch (error) { + console.log(error); + return { data: null, error: getErrorMessage(error) }; + } +} + +// Contract Template 수정 +// Basic Contract 방식과 동일한 통합 업데이트 함수 +export async function updateTemplate({ + id, + formData +}: { + id: number; + formData: FormData; +}): Promise<{ success?: boolean; error?: string }> { + unstable_noStore(); + + try { + // 필수값 파싱 + const contractTemplateType = formData.get("contractTemplateType") as string | null; + const contractTemplateName = formData.get("contractTemplateName") as string | null; + const legalReviewRequired = formData.get("legalReviewRequired") === "true"; + + // 리비전 처리: basic-contract와 동일하게 FormData에서 그대로 사용 (없으면 1) + + if (!contractTemplateType || !contractTemplateName) { + return { error: "계약 종류와 문서명은 필수입니다." }; + } + + // 기존 템플릿 조회 + const existing = await db + .select() + .from(generalContractTemplates) + .where(eq(generalContractTemplates.id, id)) + .limit(1); + + if (existing.length === 0) { + return { error: "템플릿을 찾을 수 없습니다." }; + } + + const prev = existing[0] as any; + + // 모든 경우에 기존 레코드 업데이트 (새 리비전 생성하지 않음) + + // 파일 처리 + const file = formData.get("file") as File | null; + let fileName: string | undefined = undefined; + let filePath: string | undefined = undefined; + + if (file) { + // 1) 새 파일 저장 (원본 파일명 유지 + 충돌 시 접미사) + const { mkdir, writeFile, access } = await import('fs/promises'); + const { join, extname, basename } = await import('path'); + + const ext = extname(file.name); + const base = basename(file.name, ext) + .replace(/[<>:"'|?*\\\/]/g, '_') + .replace(/[\x00-\x1f]/g, '') + .replace(/\s+/g, ' ') + .trim() + .substring(0, 200); + + const dirAbs = join(process.cwd(), 'public', 'general-contract-templates'); + await mkdir(dirAbs, { recursive: true }); + + let candidate = `${base}${ext}`; + let absPath = join(dirAbs, candidate); + let counter = 1; + while (true) { + try { + await access(absPath); + candidate = `${base} (${counter})${ext}`; + absPath = join(dirAbs, candidate); + counter += 1; + } catch { + break; + } + } + + const bytes = await file.arrayBuffer(); + await writeFile(absPath, Buffer.from(bytes)); + + fileName = candidate; + filePath = `/general-contract-templates/${candidate}`; + + // 2) 기존 파일 삭제 + if (prev.filePath) { + const deleted = await deleteFile(prev.filePath); + if (deleted) { + console.log(`✅ 기존 파일 삭제됨: ${prev.filePath}`); + } else { + console.log(`⚠️ 기존 파일 삭제 실패: ${prev.filePath}`); + } + } + } + + // 모든 경우에 기존 레코드 업데이트 (revision은 위 정책에 따라 결정) + const currentUserId = await getCurrentUserId(); + + // 리비전 처리: FormData에 있으면 사용, 없으면 현재 리비전 + 1 + const revisionFromForm = formData.get("revision")?.toString(); + let nextRevision: number; + + if (revisionFromForm) { + nextRevision = Number(revisionFromForm) || 1; + } else { + nextRevision = (prev.revision ?? 0) + 1; // FormData에 없으면 현재 리비전 + 1 + } + + const updateData: Record<string, any> = { + contractTemplateType, + contractTemplateName, + legalReviewRequired, + revision: nextRevision, // 최종 결정된 리비전 사용 + updatedAt: new Date(), + updatedBy: currentUserId, + }; + + if (fileName && filePath) { + updateData.fileName = fileName; + updateData.filePath = filePath; + } + + await db.transaction(async (tx) => { + await tx + .update(generalContractTemplates) + .set(updateData) + .where(eq(generalContractTemplates.id, id)); + }); + + revalidateTag('general-contract-templates'); + revalidatePath('/evcp/general-contract-template'); + return { success: true }; + } catch (error) { + console.error("템플릿 업데이트 오류:", error); + return { + error: error instanceof Error + ? error.message + : "템플릿 업데이트 중 오류가 발생했습니다.", + }; + } +} + +// 기존 함수는 호환성을 위해 유지 +export async function updateContractTemplateById( + id: number, + input: UpdateContractTemplateSchema +) { + unstable_noStore(); + + try { + const currentUserId = await getCurrentUserId(); + const updatedTemplate = await db.transaction(async (tx) => { + const [row] = await updateContractTemplate(tx, id, input, currentUserId); + return row; + }); + + revalidateTag("general-contract-templates"); + revalidatePath("/evcp/general-contract-template"); + return { data: updatedTemplate, error: null }; + } catch (error) { + console.log(error); + return { data: null, error: getErrorMessage(error) }; + } +} + +// Contract Template 삭제 +export async function removeTemplates({ + ids +}: { + ids: number[]; +}): Promise<{ success?: boolean; error?: string }> { + if (!ids || ids.length === 0) { + return { error: "삭제할 템플릿이 선택되지 않았습니다." }; + } + + // unstable_noStore를 최상단에 배치 + unstable_noStore(); + + try { + // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리 + // withTransaction 대신 db.transaction 직접 사용 (createContractTemplate와 일관성 유지) + const templateFiles: { id: number; filePath: string }[] = []; + + const result = await db.transaction(async (tx) => { + // 각 템플릿의 파일 경로 가져오기 + for (const id of ids) { + const template = await getContractTemplateByIdFromRepo(tx, id); + if (template && template.filePath) { + templateFiles.push({ + id: template.id, + filePath: template.filePath + }); + } + } + + // DB에서 템플릿 삭제 + const { data, error } = await deleteContractTemplates(tx, ids); + + if (error) { + throw new Error(`템플릿 DB 삭제 실패: ${error}`); + } + + return { data }; + }); + + // 파일 시스템 삭제는 트랜잭션 성공 후 수행 + for (const template of templateFiles) { + const deleted = await deleteFile(template.filePath); + + if (deleted) { + console.log(`✅ 파일 삭제됨: ${template.filePath}`); + } else { + console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`); + } + } + + revalidateTag("general-contract-templates"); + revalidateTag("template-status-counts"); + + // 디버깅을 위한 로그 + console.log("캐시 무효화 완료:", ids); + + return { success: true }; + } catch (error) { + console.error("템플릿 삭제 중 오류 발생:", error); + return { + error: error instanceof Error + ? error.message + : "템플릿 삭제 중 오류가 발생했습니다." + }; + } +} + +// 모든 활성 Contract Template 목록 (간단한 선택용) +export async function getAllActiveContractTemplates() { + unstable_noStore(); + + try { + const templates = await db.transaction(async (tx) => { + return await findAllContractTemplates(tx); + }); + + return { data: templates, error: null }; + } catch (error) { + console.log(error); + return { data: [], error: getErrorMessage(error) }; + } +} + |
