From ef4c533ebacc2cdc97e518f30e9a9350004fcdfb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 28 Apr 2025 02:13:30 +0000 Subject: ~20250428 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 957 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 957 insertions(+) create mode 100644 lib/basic-contract/service.ts (limited to 'lib/basic-contract/service.ts') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts new file mode 100644 index 00000000..09f8f119 --- /dev/null +++ b/lib/basic-contract/service.ts @@ -0,0 +1,957 @@ +"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 : "계약 상태 확인 중 오류가 발생했습니다." + }; + } +} \ No newline at end of file -- cgit v1.2.3