"use server"; import { revalidateTag, 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, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { basicContract, BasicContractTemplate, basicContractTemplates, basicContractView, vendors, type BasicContractTemplate as DBBasicContractTemplate, } from "@/db/schema"; import { toast } from "sonner"; import { promises as fs } from "fs"; import path from "path"; import crypto from "crypto"; import { GetBasicContractTemplatesSchema, CreateBasicContractTemplateSchema, GetBasciContractsSchema, } from "./validations"; import { insertBasicContractTemplate, selectBasicContractTemplates, countBasicContractTemplates, deleteBasicContractTemplates, getBasicContractTemplateById, selectBasicContracts, countBasicContracts, findAllTemplates } from "./repository"; import { revalidatePath } from 'next/cache'; import { sendEmail } from "../mail/sendEmail"; import { headers } from 'next/headers'; import { filterColumns } from "@/lib/filter-columns"; import { differenceInDays, addYears, isBefore } from "date-fns"; // 템플릿 추가 export async function addTemplate( templateData: FormData | Omit ): Promise<{ success: boolean; data?: BasicContractTemplate; error?: string }> { try { // FormData인 경우 파일 추출 및 저장 처리 if (templateData instanceof FormData) { const templateName = templateData.get("templateName") as string; // 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환) const validityPeriodStr = templateData.get("validityPeriod") as string; const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; // 기본값 12개월 const status = templateData.get("status") as "ACTIVE" | "INACTIVE"; const file = templateData.get("file") as File; // 유효성 검사 if (!templateName) { return { success: false, error: "템플릿 이름은 필수입니다." }; } if (!file) { return { success: false, error: "파일은 필수입니다." }; } if (isNaN(validityPeriod) || validityPeriod < 1 || validityPeriod > 120) { return { success: false, error: "유효기간은 1~120개월 사이의 유효한 값이어야 합니다." }; } // 원본 파일 이름과 확장자 분리 const originalFileName = file.name; const fileExtension = path.extname(originalFileName); const fileNameWithoutExt = path.basename(originalFileName, fileExtension); // 해시된 파일 이름 생성 (타임스탬프 + 랜덤 해시 + 확장자) const timestamp = Date.now(); const randomHash = crypto.createHash('md5') .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) .digest('hex') .substring(0, 8); const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; // 저장 디렉토리 설정 (uploads/contracts 폴더 사용) const uploadDir = path.join(process.cwd(), "public", "basicContract", "template"); // 디렉토리가 없으면 생성 try { await fs.mkdir(uploadDir, { recursive: true }); } catch (err) { console.log("Directory already exists or creation failed:", err); } // 파일 경로 설정 const filePath = path.join(uploadDir, hashedFileName); const publicFilePath = `/basicContract/template/${hashedFileName}`; // 파일을 ArrayBuffer로 변환 const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // 파일 저장 await fs.writeFile(filePath, buffer); // DB에 저장할 데이터 구성 const formattedData = { templateName, status, validityPeriod, // 숫자로 변환된 유효기간 fileName: originalFileName, // 원본 파일 이름 filePath: publicFilePath, // 공개 접근 가능한 경로 }; // DB에 저장 const { data, error } = await createBasicContractTemplate(formattedData); if (error) { // 파일 저장 후 DB 저장 실패 시 저장된 파일 삭제 try { await fs.unlink(filePath); } catch (unlinkError) { console.error("파일 삭제 실패:", unlinkError); } return { success: false, error }; } return { success: true, data: data || undefined }; } // 기존 객체 형태인 경우 (호환성 유지) else { const formattedData = { ...templateData, status: templateData.status as "ACTIVE" | "INACTIVE", // validityPeriod가 없으면 기본값 12개월 사용 validityPeriod: templateData.validityPeriod || 12, }; const { data, error } = await createBasicContractTemplate(formattedData); if (error) { return { success: false, error }; } return { success: true, data: data || undefined }; } } catch (error) { console.error("Template add error:", error); return { success: false, error: error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다." }; } } // 기본 계약서 템플릿 목록 조회 (서버 액션) export async function getBasicContractTemplates( input: GetBasicContractTemplatesSchema ) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; const { data, total } = await db.transaction(async (tx) => { // 간소화된 구현 - 실제 filterColumns 함수는 더 복잡할 수 있습니다 let whereCondition = undefined; if (input.search) { const s = `%${input.search}%`; whereCondition = or( ilike(basicContractTemplates.templateName, s), ilike(basicContractTemplates.fileName, s), ilike(basicContractTemplates.status, s) ); } const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc( // eslint-disable-next-line @typescript-eslint/no-explicit-any basicContractTemplates[ item.id as keyof typeof basicContractTemplates ] as any ) : asc( // eslint-disable-next-line @typescript-eslint/no-explicit-any basicContractTemplates[ item.id as keyof typeof basicContractTemplates ] as any ) ) : [desc(basicContractTemplates.createdAt)]; const dataResult = await selectBasicContractTemplates(tx, { where: whereCondition, orderBy, offset, limit: input.perPage, }); const totalCount = await countBasicContractTemplates( tx, whereCondition ); return { data: dataResult, total: totalCount }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (error) { console.error("getBasicContractTemplates 에러:", error); return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: ["basic-contract-templates"], } )(); } // 템플릿 생성 (서버 액션) export async function createBasicContractTemplate( input: CreateBasicContractTemplateSchema ) { unstable_noStore(); try { const newTemplate = await db.transaction(async (tx) => { const [newTemplate] = await insertBasicContractTemplate(tx, { templateName: input.templateName, validityPeriod: input.validityPeriod, status: input.status, fileName: input.fileName, filePath: input.filePath, }); return newTemplate; }); // 캐시 무효화 revalidateTag("basic-contract-templates"); revalidateTag("template-status-counts"); return { data: newTemplate, error: null }; } catch (error) { return { data: null, error: getErrorMessage(error) }; } } //서명 계약서 저장, 김기만 프로님 추가 코드 export const saveSignedContract = async ( fileBuffer: ArrayBuffer, templateName: string, tableRowId: number ): Promise<{ result: true } | { result: false; error: string }> => { try { const originalName = `${tableRowId}_${templateName}`; const ext = path.extname(originalName); const uniqueName = uuidv4() + ext; const publicDir = path.join(process.cwd(), "public", "basicContract"); const relativePath = `/basicContract/${uniqueName}`; const absolutePath = path.join(publicDir, uniqueName); const buffer = Buffer.from(fileBuffer); await fs.mkdir(publicDir, { recursive: true }); await fs.writeFile(absolutePath, buffer); await db.transaction(async (tx) => { await tx .update(basicContract) .set({ status: "COMPLETED", fileName: originalName, filePath: relativePath, }) .where(eq(basicContract.id, tableRowId)); }); // 캐시 무효화 revalidateTag("basic-contract-requests"); revalidateTag("template-status-counts"); return { result: true }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"; return { result: false, error: errorMessage }; } }; interface RemoveTemplatesProps { ids: number[]; } interface TemplateFile { id: number; filePath: string; } export async function removeTemplates({ ids }: RemoveTemplatesProps): Promise<{ success?: boolean; error?: string }> { if (!ids || ids.length === 0) { return { error: "삭제할 템플릿이 선택되지 않았습니다." }; } // unstable_noStore를 최상단에 배치 unstable_noStore(); try { // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리 // withTransaction 대신 db.transaction 직접 사용 (createBasicContractTemplate와 일관성 유지) const templateFiles: TemplateFile[] = []; const result = await db.transaction(async (tx) => { // 각 템플릿의 파일 경로 가져오기 for (const id of ids) { const { data: template, error } = await getBasicContractTemplateById(tx, id); if (template && template.filePath) { templateFiles.push({ id: template.id, filePath: template.filePath }); } } // DB에서 템플릿 삭제 const { data, error } = await deleteBasicContractTemplates(tx, ids); if (error) { throw new Error(`템플릿 DB 삭제 실패: ${error}`); } return { data }; }); // 파일 시스템 삭제는 트랜잭션 성공 후 수행 for (const template of templateFiles) { if (template.filePath) { const absoluteFilePath = path.join(process.cwd(), 'public', template.filePath); try { await fs.access(absoluteFilePath); await fs.unlink(absoluteFilePath); } catch (fileError) { console.log(`파일 없음 또는 삭제 실패: ${template.filePath}`, fileError); // 파일 삭제 실패는 전체 작업 성공에 영향 없음 } } } revalidateTag("basic-contract-templates"); revalidateTag("template-status-counts"); // 디버깅을 위한 로그 console.log("캐시 무효화 완료:", ids); return { success: true }; } catch (error) { console.error("템플릿 삭제 중 오류 발생:", error); return { error: error instanceof Error ? error.message : "템플릿 삭제 중 오류가 발생했습니다." }; } } interface UpdateTemplateParams { id: number; formData: FormData; } export async function updateTemplate({ id, formData }: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> { unstable_noStore(); try { const templateName = formData.get("templateName") as string; const validityPeriodStr = formData.get("validityPeriod") as string; const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; const status = formData.get("status") as "ACTIVE" | "INACTIVE"; const file = formData.get("file") as File | null; if (!templateName) { return { error: "템플릿 이름은 필수입니다." }; } // 기본 업데이트 데이터 const updateData: Record = { templateName, status, validityPeriod, updatedAt: new Date(), }; // 파일이 있는 경우 처리 if (file) { // 원본 파일 이름과 확장자 분리 const originalFileName = file.name; const fileExtension = path.extname(originalFileName); const fileNameWithoutExt = path.basename(originalFileName, fileExtension); // 해시된 파일 이름 생성 const timestamp = Date.now(); const randomHash = crypto.createHash('md5') .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) .digest('hex') .substring(0, 8); const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; // 저장 디렉토리 설정 const uploadDir = path.join(process.cwd(), "public", "basicContract", "template"); // 디렉토리가 없으면 생성 try { await fs.mkdir(uploadDir, { recursive: true }); } catch (err) { console.log("Directory already exists or creation failed:", err); } // 파일 경로 설정 const filePath = path.join(uploadDir, hashedFileName); const publicFilePath = `/basicContract/template/${hashedFileName}`; // 파일을 ArrayBuffer로 변환 const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // 파일 저장 await fs.writeFile(filePath, buffer); // 기존 파일 정보 가져오기 const existingTemplate = await db.query.basicContractTemplates.findFirst({ where: eq(basicContractTemplates.id, id) }); // 기존 파일이 있다면 삭제 if (existingTemplate?.filePath) { try { const existingFilePath = path.join(process.cwd(), "public", existingTemplate.filePath); await fs.access(existingFilePath); // 파일 존재 확인 await fs.unlink(existingFilePath); // 파일 삭제 } catch (error) { console.log("기존 파일 삭제 실패 또는 파일이 없음:", error); } } // 업데이트 데이터에 파일 정보 추가 updateData.fileName = originalFileName; updateData.filePath = publicFilePath; } // DB 업데이트 await db.transaction(async (tx) => { await tx .update(basicContractTemplates) .set(updateData) .where(eq(basicContractTemplates.id, id)); }); // 캐시 무효화 (다양한 방법 시도) revalidateTag("basic-contract-templates"); revalidateTag("template-status-counts"); revalidateTag("templates"); return { success: true }; } catch (error) { console.error("템플릿 업데이트 오류:", error); return { error: error instanceof Error ? error.message : "템플릿 업데이트 중 오류가 발생했습니다." }; } } interface RequestBasicContractInfoProps { vendorIds: number[]; requestedBy: number; templateId: number; } export async function requestBasicContractInfo({ vendorIds, requestedBy, templateId }: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> { unstable_noStore(); if (!vendorIds || vendorIds.length === 0) { return { error: "요청할 협력업체가 선택되지 않았습니다." }; } if (!templateId) { return { error: "계약서 템플릿이 선택되지 않았습니다." }; } try { // 1. 선택된 템플릿 정보 가져오기 const template = await db.query.basicContractTemplates.findFirst({ where: eq(basicContractTemplates.id, templateId) }); if (!template) { return { error: "선택한 템플릿을 찾을 수 없습니다." }; } // 2. 협력업체 정보 가져오기 const vendorList = await db .select() .from(vendors) .where(inArray(vendors.id, vendorIds)); if (!vendorList || vendorList.length === 0) { return { error: "선택한 협력업체 정보를 찾을 수 없습니다." }; } // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송 const results = await Promise.all( vendorList.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { // 3-1. basic_contract 테이블에 레코드 추가 const [newContract] = await db .insert(basicContract) .values({ templateId: template.id, vendorId: vendor.id, requestedBy: requestedBy, status: "PENDING", fileName: template.fileName, // 템플릿 파일 이름 사용 filePath: template.filePath, // 템플릿 파일 경로 사용 }) .returning(); // 3-2. 협력업체에 이메일 발송 const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; // 로그인 또는 서명 페이지 URL 생성 const baseUrl = `http://${host}` const loginUrl = `${baseUrl}/partners/basic-contract`; // 사용자 언어 설정 (기본값은 한국어) const userLang = "ko"; // 이메일 발송 await sendEmail({ to: vendor.email, subject, template: "contract-sign-request", // 이메일 템플릿 이름 context: { vendorName: vendor.vendorName, contractId: newContract.id, templateName: template.templateName, loginUrl, language: userLang, }, }); return { vendorId: vendor.id, success: true }; } catch (err) { console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err); return { vendorId: vendor.id, success: false, error: getErrorMessage(err) }; } }) ); // 4. 실패한 케이스가 있는지 확인 const failedVendors = results.filter(r => r && !r.success); if (failedVendors.length > 0) { console.error("일부 협력업체 처리 실패:", failedVendors); if (failedVendors.length === vendorIds.length) { // 모든 협력업체 처리 실패 return { error: "모든 협력업체에 대한 처리가 실패했습니다." }; } else { // 일부 협력업체만 처리 실패 return { success: true, error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패` }; } } // 5. 캐시 무효화 revalidateTag("basic-contract-requests"); return { success: true }; } catch (error) { console.error("기본계약서 요청 중 오류 발생:", error); return { error: error instanceof Error ? error.message : "기본계약서 요청 처리 중 오류가 발생했습니다." }; } } export async function getBasicContracts(input: GetBasciContractsSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // const advancedTable = input.flags.includes("advancedTable"); const advancedTable = true; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: basicContractView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(basicContractView.templateName, s), ilike(basicContractView.vendorName, s) , ilike(basicContractView.vendorCode, s) , ilike(basicContractView.vendorEmail, s) , ilike(basicContractView.status, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere // and()함수로 결합 or or() 등으로 결합 ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id]) ) : [asc(basicContractView.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectBasicContracts(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countBasicContracts(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, tags: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화 } )(); } export async function getBasicContractsByVendorId( input: GetBasciContractsSchema, vendorId: number ) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // const advancedTable = input.flags.includes("advancedTable"); const advancedTable = true; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: basicContractView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(basicContractView.templateName, s), ilike(basicContractView.vendorName, s), ilike(basicContractView.vendorCode, s), ilike(basicContractView.vendorEmail, s), ilike(basicContractView.status, s) ); // 필요시 여러 칼럼 OR조건 (status, priority, etc) } // 벤더 ID 필터링 조건 추가 const vendorCondition = eq(basicContractView.vendorId, vendorId); const finalWhere = and( // 항상 벤더 ID 조건을 포함 vendorCondition, // 기존 조건들 advancedWhere, globalWhere ); const where = finalWhere; const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id]) ) : [asc(basicContractView.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectBasicContracts(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countBasicContracts(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가 { revalidate: 3600, tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화 } )(); } export async function getAllTemplates(): Promise { try { return await findAllTemplates(); } catch (err) { throw new Error("Failed to get templates"); } } interface VendorTemplateStatus { vendorId: number; vendorName: string; templateId: number; templateName: string; status: string; createdAt: Date; isExpired: boolean; // 요청이 오래되었는지 (예: 30일 이상) isUpdated: boolean; // 템플릿이 업데이트되었는지 } /** * 협력업체와 템플릿 조합에 대한 계약 요청 상태를 확인합니다. */ // 계약 상태 확인 API 함수 export async function checkContractRequestStatus( vendorIds: number[], templateIds: number[] ) { try { // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인 const requests = await db .select({ id: basicContract.id, vendorId: basicContract.vendorId, templateId: basicContract.templateId, status: basicContract.status, createdAt: basicContract.createdAt, updatedAt: basicContract.updatedAt, // completedAt 필드 추가 필요 completedAt: basicContract.completedAt, // 계약 완료 날짜 }) .from(basicContract) .where( and( inArray(basicContract.vendorId, vendorIds), inArray(basicContract.templateId, templateIds) ) ) .orderBy(desc(basicContract.createdAt)); // 협력업체 정보 가져오기 const vendorData = await db .select({ id: vendors.id, vendorName: vendors.vendorName, }) .from(vendors) .where(inArray(vendors.id, vendorIds)); // 템플릿 정보 가져오기 const templateData = await db .select({ id: basicContractTemplates.id, templateName: basicContractTemplates.templateName, updatedAt: basicContractTemplates.updatedAt, validityPeriod: basicContractTemplates.validityPeriod, // 템플릿별 유효기간(개월) }) .from(basicContractTemplates) .where(inArray(basicContractTemplates.id, templateIds)); // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑 const vendorMap = new Map(vendorData.map(v => [v.id, v])); const templateMap = new Map(templateData.map(t => [t.id, t])); const uniqueRequests = new Map(); // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용 requests.forEach(req => { const key = `${req.vendorId}-${req.templateId}`; if (!uniqueRequests.has(key)) { uniqueRequests.set(key, req); } }); // 인터페이스를 임포트하거나 이 함수 내에서/위에서 재정의 interface VendorTemplateStatus { vendorId: number; vendorName: string; templateId: number; templateName: string; status: string; createdAt: Date; completedAt?: Date; isExpired: boolean; isUpdated: boolean; isContractExpired: boolean; } // 명시적 타입 지정 const statusData: VendorTemplateStatus[] = []; // 요청 만료 기준 - 30일 const REQUEST_EXPIRATION_DAYS = 30; // 기본 계약 유효기간 - 12개월 (템플릿별로 다르게 설정 가능) const DEFAULT_CONTRACT_VALIDITY_MONTHS = 12; const now = new Date(); // 모든 협력업체-템플릿 조합에 대해 상태 확인 vendorIds.forEach(vendorId => { templateIds.forEach(templateId => { const key = `${vendorId}-${templateId}`; const request = uniqueRequests.get(key); const vendor = vendorMap.get(vendorId); const template = templateMap.get(templateId); if (!vendor || !template) return; let status = "NONE"; // 기본 상태: 요청 없음 let createdAt = new Date(); let completedAt = null; let isExpired = false; let isUpdated = false; let isContractExpired = false; if (request) { status = request.status; createdAt = request.createdAt; completedAt = request.completedAt; // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용) if (status === "PENDING") { isExpired = differenceInDays(now, createdAt) > REQUEST_EXPIRATION_DAYS; } // 요청 이후 템플릿이 업데이트되었는지 확인 if (template.updatedAt && request.createdAt) { isUpdated = template.updatedAt > request.createdAt; } // 계약 유효기간 만료 확인 (COMPLETED 상태이고 completedAt이 있는 경우) if (status === "COMPLETED" && completedAt) { // 템플릿별 유효기간 또는 기본값 사용 const validityMonths = template.validityPeriod || DEFAULT_CONTRACT_VALIDITY_MONTHS; // 계약 만료일 계산 (완료일 + 유효기간) const expiryDate = addYears(completedAt, validityMonths / 12); // 현재 날짜가 만료일 이후인지 확인 isContractExpired = isBefore(expiryDate, now); } } statusData.push({ vendorId, vendorName: vendor.vendorName, templateId, templateName: template.templateName, status, createdAt, completedAt, isExpired, isUpdated, isContractExpired, }); }); }); return { data: statusData }; } catch (error) { console.error("계약 상태 확인 중 오류:", error); return { data: [], error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다." }; } }