"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,like } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { basicContract, BasicContractTemplate, basicContractTemplates, basicContractView, complianceQuestionOptions, complianceQuestions, complianceResponseAnswers, complianceResponseFiles, complianceResponses, complianceSurveyTemplates, vendorAttachments, basicContractTemplateStatsView, type BasicContractTemplate as DBBasicContractTemplate, type NewComplianceResponse, type NewComplianceResponseAnswer, type NewComplianceResponseFile, gtcVendorDocuments, gtcVendorClauses, gtcClauses, gtcDocuments, vendors, gtcNegotiationHistory, type GtcVendorClause, type GtcClause, projects, legalWorks, BasicContractView, users } from "@/db/schema"; import path from "path"; import { GetBasicContractTemplatesSchema, CreateBasicContractTemplateSchema, GetBasciContractsSchema, GetBasciContractsVendorSchema, GetBasciContractsByIdSchema, updateStatusSchema, } from "./validations"; import { readFile } from "fs/promises" import { insertBasicContractTemplate, selectBasicContractTemplates, countBasicContractTemplates, deleteBasicContractTemplates, getBasicContractTemplateById, selectBasicContracts, countBasicContracts, findAllTemplates, countBasicContractsById, selectBasicContractsById, selectBasicContractsVendor, countBasicContractsVendor } 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"; import { deleteFile, saveBuffer, saveFile } from "@/lib/file-stroage"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" // 템플릿 추가 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 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: "파일은 필수입니다." }; } const saveResult = await saveFile({file, directory:"basicContract/template" }); if (!saveResult.success) { return { success: false, error: saveResult.error }; } // DB에 저장할 데이터 구성 const formattedData = { templateName, status, fileName: file.name, filePath: saveResult.publicPath! }; // DB에 저장 const { data, error } = await createBasicContractTemplate(formattedData); if (error) { // DB 저장 실패 시 파일 삭제 await deleteFile(saveResult.publicPath!); 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) => { const advancedWhere = filterColumns({ table: basicContractTemplates, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere = undefined; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(basicContractTemplates.templateName, s), ilike(basicContractTemplates.fileName, s), ilike(basicContractTemplates.status, s) ); } const whereCondition = and(advancedWhere, globalWhere); 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 [row] = await insertBasicContractTemplate(tx, { templateName: input.templateName, revision: input.revision || 1, status: input.status || "ACTIVE", // 📝 null 처리 추가 fileName: input.fileName || null, filePath: input.filePath || null, }); return row; }); return { data: newTemplate, error: null }; } catch (error) { console.log(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}`; // ArrayBuffer를 File 객체로 변환 const file = new File([fileBuffer], originalName); // ✅ 서명된 계약서 저장 // 개발: /project/public/basicContract/signed/ // 프로덕션: /nas_evcp/basicContract/signed/ const saveResult = await saveFile({file,directory: "basicContract/signed" ,originalName:originalName}); if (!saveResult.success) { return { result: false, error: saveResult.error! }; } console.log(`✅ 서명된 계약서 저장됨: ${saveResult.filePath}`); await db.transaction(async (tx) => { await tx .update(basicContract) .set({ status: "COMPLETED", fileName: originalName, filePath: saveResult.publicPath, // 웹 접근 경로 저장 }) .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) { const deleted = await deleteFile(template.filePath); if (deleted) { console.log(`✅ 파일 삭제됨: ${template.filePath}`); } else { console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`); } } 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; } const SCOPE_KEYS = [ "shipBuildingApplicable", "windApplicable", "pcApplicable", "nbApplicable", "rcApplicable", "gyApplicable", "sysApplicable", "infraApplicable", ] as const; function getBool(fd: FormData, key: string, defaultValue = false) { const v = fd.get(key); if (v === null) return defaultValue; return v === "true"; } export async function updateTemplate({ id, formData }: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> { unstable_noStore(); try { // 필수값 const templateName = formData.get("templateName") as string | null; if (!templateName) { return { error: "템플릿 이름은 필수입니다." }; } // 선택/추가 필드 파싱 const revisionStr = formData.get("revision")?.toString() ?? "1"; const revision = Number(revisionStr) || 1; const legalReviewRequired = getBool(formData, "legalReviewRequired", false); // status는 프런트에서 ACTIVE만 넣고 있으나, 없으면 기존값 유지 or 기본값 설정 const status = (formData.get("status") as "ACTIVE" | "INACTIVE" | null) ?? "ACTIVE"; // validityPeriod가 이제 필요없다면 제거하시고, 사용한다면 파싱 그대로 const validityPeriodStr = formData.get("validityPeriod")?.toString(); const validityPeriod = validityPeriodStr ? Number(validityPeriodStr) : undefined; // Scope booleans const scopeData: Record = {}; for (const key of SCOPE_KEYS) { scopeData[key] = getBool(formData, key, false); } // 파일 처리 const file = formData.get("file") as File | null; let fileName: string | undefined = undefined; let filePath: string | undefined = undefined; if (file) { // 1) 새 파일 저장 const saveResult = await saveFile({ file, directory: "basicContract/template" }); if (!saveResult.success) { return { success: false, error: saveResult.error }; } fileName = file.name; filePath = saveResult.publicPath; // 2) 기존 파일 삭제 const existingTemplate = await db.query.basicContractTemplates.findFirst({ where: eq(basicContractTemplates.id, id), }); if (existingTemplate?.filePath) { const deleted = await deleteFile(existingTemplate.filePath); if (deleted) { console.log(`✅ 기존 파일 삭제됨: ${existingTemplate.filePath}`); } else { console.log(`⚠️ 기존 파일 삭제 실패: ${existingTemplate.filePath}`); } } } // 업데이트할 데이터 구성 const updateData: Record = { templateName, revision, legalReviewRequired, status, updatedAt: new Date(), ...scopeData, }; if (validityPeriod !== undefined) { updateData.validityPeriod = validityPeriod; } if (fileName && filePath) { updateData.fileName = fileName; updateData.filePath = filePath; } // 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 { const isComplianceTemplate = template.templateName?.includes('준법'); let selectedTemplateId = template.id; let selectedTemplate = template; if (isComplianceTemplate) { const vendorUser = await db.query.users.findFirst({ where: and( eq(users.email, vendor.email), eq(users.domain, 'partners') ) }); const userLanguage = vendorUser?.language || 'en'; // 기본값은 영어 if (userLanguage === 'ko') { // 한글 준법서약 템플릿 찾기 const koreanTemplate = await db.query.basicContractTemplates.findFirst({ where: and( sql`${basicContractTemplates.templateName} LIKE '%준법%'`, sql`${basicContractTemplates.templateName} NOT LIKE '%영문%'`, eq(basicContractTemplates.status, 'ACTIVE') ) }); if (koreanTemplate) { selectedTemplateId = koreanTemplate.id; selectedTemplate = koreanTemplate; } } else { // 영문 준법서약 템플릿 찾기 const englishTemplate = await db.query.basicContractTemplates.findFirst({ where: and( sql`${basicContractTemplates.templateName} LIKE '%준법%'`, sql`${basicContractTemplates.templateName} LIKE '%영문%'`, eq(basicContractTemplates.status, 'ACTIVE') ) }); if (englishTemplate) { selectedTemplateId = englishTemplate.id; selectedTemplate = englishTemplate; console.log(`✅ 영어 사용자 ${vendor.vendorName}에게 영문 준법서약 템플릿 전송`); } } } // 3-1. basic_contract 테이블에 레코드 추가 const [newContract] = await db .insert(basicContract) .values({ templateId: selectedTemplateId, // 언어별로 선택된 템플릿 ID 사용 vendorId: vendor.id, requestedBy: requestedBy, status: "PENDING", fileName: selectedTemplate.fileName, // 선택된 템플릿 파일 이름 사용 filePath: selectedTemplate.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: basicContractTemplateStatsView, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(basicContractTemplateStatsView.templateName, 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(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id]) ) : [asc(basicContractTemplateStatsView.lastActivityDate)]; // 트랜잭션 내부에서 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) { console.log(err) // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화 } )(); } export async function getBasicContractsByVendorId( input: GetBasciContractsVendorSchema, 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 selectBasicContractsVendor(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countBasicContractsVendor(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 getBasicContractsByTemplateId( input: GetBasciContractsByIdSchema, templateId: number ) { // return unstable_cache( // async () => { try { console.log(input.sort) 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 templateCondition = eq(basicContractView.templateId, templateId); const finalWhere = and( // 항상 벤더 ID 조건을 포함 templateCondition, // 기존 조건들 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 selectBasicContractsById(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countBasicContractsById(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트\ console.log(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 : "계약 상태 확인 중 오류가 발생했습니다." }; } } /** * ID로 기본계약서 템플릿 조회 */ export async function getBasicContractTemplateByIdService(id: string) { try { const templateId = parseInt(id); if (isNaN(templateId)) { return null; } const templates = await db .select() .from(basicContractTemplates) .where(eq(basicContractTemplates.id, templateId)) .limit(1); if (templates.length === 0) { return null; } return templates[0]; } catch (error) { console.error("템플릿 조회 오류:", error); return null; } } /** * 템플릿 파일 저장 서버 액션 */ 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(basicContractTemplates) .where(eq(basicContractTemplates.id, templateId)) .limit(1); if (existingTemplate.length === 0) { return { error: "템플릿을 찾을 수 없습니다." }; } const template = existingTemplate[0]; // 파일 저장 로직 (실제 파일 시스템에 저장) const { writeFile, mkdir } = await import("fs/promises"); const { join } = await import("path"); const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes); // 기존 파일 경로 사용 (덮어쓰기) 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); // 데이터베이스 업데이트 (수정일시만 업데이트) await db .update(basicContractTemplates) .set({ updatedAt: new Date(), }) .where(eq(basicContractTemplates.id, templateId)); // 캐시 무효화 revalidatePath(`/evcp/basic-contract-template/${templateId}`); revalidateTag("basic-contract-templates"); return { success: true, message: "템플릿이 성공적으로 저장되었습니다." }; } catch (error) { console.error("템플릿 저장 오류:", error); return { error: "저장 중 오류가 발생했습니다." }; } } /** * 템플릿 페이지 새로고침 서버 액션 */ export async function refreshTemplatePage(templateId: string) { revalidatePath(`/evcp/basic-contract-template/${templateId}`); revalidateTag("basic-contract-templates"); } // 새 리비전 생성 함수 export async function createBasicContractTemplateRevision(input: any) { unstable_noStore(); try { // 기본 템플릿 존재 확인 const baseTemplate = await db .select() .from(basicContractTemplates) .where(eq(basicContractTemplates.id, input.baseTemplateId)) .limit(1); if (baseTemplate.length === 0) { return { data: null, error: "기본 템플릿을 찾을 수 없습니다." }; } // 같은 템플릿 이름에 해당 리비전이 이미 존재하는지 확인 const existingRevision = await db .select() .from(basicContractTemplates) .where( and( eq(basicContractTemplates.templateName, input.templateName), eq(basicContractTemplates.revision, input.revision) ) ) .limit(1); if (existingRevision.length > 0) { return { data: null, error: `${input.templateName} v${input.revision} 리비전이 이미 존재합니다.` }; } // 새 리비전이 기존 리비전들보다 큰 번호인지 확인 const maxRevision = await db .select({ maxRev: basicContractTemplates.revision }) .from(basicContractTemplates) .where(eq(basicContractTemplates.templateName, input.templateName)) .orderBy(desc(basicContractTemplates.revision)) .limit(1); if (maxRevision.length > 0 && input.revision <= maxRevision[0].maxRev) { return { data: null, error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision[0].maxRev})보다 커야 합니다.` }; } const newRevision = await db.transaction(async (tx) => { const [row] = await insertBasicContractTemplate(tx, { templateName: input.templateName, revision: input.revision, legalReviewRequired: input.legalReviewRequired, status: "ACTIVE", fileName: input.fileName, filePath: input.filePath, validityPeriod: null, }); return row; }); //기존 템플릿의 이전 리비전은 비활성으로 변경 await db.update(basicContractTemplates).set({ status: "DISPOSED", }).where(and(eq(basicContractTemplates.templateName, input.templateName),ne(basicContractTemplates.revision, input.revision))); //캐시 무효화 revalidateTag("basic-contract-templates"); return { data: newRevision, error: null }; } catch (error) { return { data: null, error: getErrorMessage(error) }; } } // 1) 전체 basicContractTemplates 조회 export async function getALLBasicContractTemplates() { return db .select() .from(basicContractTemplates) .where(eq(basicContractTemplates.status,"ACTIVE")) .orderBy(desc(basicContractTemplates.createdAt)); } // 2) 등록된 templateName만 중복 없이 가져오기 export async function getExistingTemplateNames(): Promise { try { const templates = await db .select({ templateName: basicContractTemplates.templateName }) .from(basicContractTemplates) .where( and( eq(basicContractTemplates.status, 'ACTIVE'), // GTC가 아닌 것들만 중복 체크 (GTC는 프로젝트별로 여러 개 허용) not(like(basicContractTemplates.templateName, '% GTC')) ) ); return templates.map(t => t.templateName); } catch (error) { console.error('Failed to fetch existing template names:', error); throw new Error('기존 템플릿 이름을 가져오는데 실패했습니다.'); } } export async function getExistingTemplateNamesById(id:number): Promise { const rows = await db .select({ templateName: basicContractTemplates.templateName, }) .from(basicContractTemplates) .where(and(eq(basicContractTemplates.status,"ACTIVE"),eq(basicContractTemplates.id,id))) .limit(1) return rows[0].templateName; } export async function getVendorAttachments(vendorId: number) { try { const attachments = await db .select() .from(vendorAttachments) .where( and( eq(vendorAttachments.vendorId, vendorId), eq(vendorAttachments.attachmentType, "NDA_ATTACHMENT") ) ); console.log(attachments,"attachments") return { success: true, data: attachments }; } catch (error) { console.error("Error fetching vendor attachments:", error); return { success: false, data: [], error: "Failed to fetch vendor attachments" }; } } // 설문조사 템플릿 전체 데이터 타입 export interface SurveyTemplateWithQuestions { id: number; name: string; description: string | null; version: string; questions: SurveyQuestion[]; } export interface SurveyQuestion { id: number; questionNumber: string; questionText: string; questionType: string; isRequired: boolean; hasDetailText: boolean; hasFileUpload: boolean; parentQuestionId: number | null; conditionalValue: string | null; displayOrder: number; options: SurveyQuestionOption[]; } export interface SurveyQuestionOption { id: number; optionValue: string; optionText: string; allowsOtherInput: boolean; displayOrder: number; } /** * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기 */ export async function getActiveSurveyTemplate(language: string = 'ko'): Promise { try { // 1. 활성화된 템플릿 가져오기 (언어에 따라 필터링) const templates = await db .select() .from(complianceSurveyTemplates) .where(eq(complianceSurveyTemplates.isActive, true)) .orderBy(complianceSurveyTemplates.id); if (!templates || templates.length === 0) { console.log('활성화된 설문조사 템플릿이 없습니다.'); return null; } // 언어에 따라 적절한 템플릿 선택 let templateData; if (language === 'en') { // 영문 템플릿 찾기 (이름에 '영문' 또는 'English' 포함) templateData = templates.find(t => t.name.includes('영문') || t.name.toLowerCase().includes('english') || t.name.toLowerCase().includes('en') ); } else { // 한글 템플릿 찾기 (영문이 아닌 것) templateData = templates.find(t => !t.name.includes('영문') && !t.name.toLowerCase().includes('english') && !t.name.toLowerCase().includes('en') ); } // 적절한 템플릿을 찾지 못하면 첫 번째 템플릿 사용 if (!templateData) { console.log(`언어 '${language}'에 맞는 템플릿을 찾지 못해 기본 템플릿을 사용합니다.`); templateData = templates[0]; } console.log(`✅ 선택된 설문조사 템플릿: ${templateData.name} (언어: ${language})`); // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순) const questions = await db .select() .from(complianceQuestions) .where(eq(complianceQuestions.templateId, templateData.id)) .orderBy(asc(complianceQuestions.displayOrder)); // 3. 각 질문의 옵션들 가져오기 const questionIds = questions.map(q => q.id); const allOptions = questionIds.length > 0 ? await db .select() .from(complianceQuestionOptions) .where(inArray(complianceQuestionOptions.questionId, questionIds)) .orderBy( complianceQuestionOptions.questionId, asc(complianceQuestionOptions.displayOrder) ) : []; // 4. 질문별로 옵션들 그룹화 const optionsByQuestionId = allOptions.reduce((acc, option) => { if (!acc[option.questionId]) { acc[option.questionId] = []; } acc[option.questionId].push({ id: option.id, optionValue: option.optionValue, optionText: option.optionText, allowsOtherInput: option.allowsOtherInput, displayOrder: option.displayOrder, }); return acc; }, {} as Record); // 5. 최종 데이터 구성 const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({ id: question.id, questionNumber: question.questionNumber, questionText: question.questionText, questionType: question.questionType, isRequired: question.isRequired, hasDetailText: question.hasDetailText, hasFileUpload: question.hasFileUpload, parentQuestionId: question.parentQuestionId, conditionalValue: question.conditionalValue, displayOrder: question.displayOrder, options: optionsByQuestionId[question.id] || [], })); return { id: templateData.id, name: templateData.name, description: templateData.description, version: templateData.version, questions: questionsWithOptions, }; } catch (error) { console.error('설문조사 템플릿 로드 실패:', error); return null; } } /** * 특정 템플릿 ID로 설문조사 템플릿 가져오기 */ export async function getSurveyTemplateById(templateId: number): Promise { try { const template = await db .select() .from(complianceSurveyTemplates) .where(eq(complianceSurveyTemplates.id, templateId)) .limit(1); if (!template || template.length === 0) { return null; } const templateData = template[0]; const questions = await db .select() .from(complianceQuestions) .where(eq(complianceQuestions.templateId, templateId)) .orderBy(asc(complianceQuestions.displayOrder)); const questionIds = questions.map(q => q.id); const allOptions = questionIds.length > 0 ? await db .select() .from(complianceQuestionOptions) .where( complianceQuestionOptions.questionId.in ? complianceQuestionOptions.questionId.in(questionIds) : eq(complianceQuestionOptions.questionId, questionIds[0]) ) .orderBy( complianceQuestionOptions.questionId, asc(complianceQuestionOptions.displayOrder) ) : []; const optionsByQuestionId = allOptions.reduce((acc, option) => { if (!acc[option.questionId]) { acc[option.questionId] = []; } acc[option.questionId].push({ id: option.id, optionValue: option.optionValue, optionText: option.optionText, allowsOtherInput: option.allowsOtherInput, displayOrder: option.displayOrder, }); return acc; }, {} as Record); const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({ id: question.id, questionNumber: question.questionNumber, questionText: question.questionText, questionType: question.questionType, isRequired: question.isRequired, hasDetailText: question.hasDetailText, hasFileUpload: question.hasFileUpload, parentQuestionId: question.parentQuestionId, conditionalValue: question.conditionalValue, displayOrder: question.displayOrder, options: optionsByQuestionId[question.id] || [], })); return { id: templateData.id, name: templateData.name, description: templateData.description, version: templateData.version, questions: questionsWithOptions, }; } catch (error) { console.error('설문조사 템플릿 로드 실패:', error); return null; } } // 설문 답변 데이터 타입 정의 export interface SurveyAnswerData { questionId: number; answerValue?: string; detailText?: string; otherText?: string; files?: File[]; } // 설문조사 완료 요청 데이터 타입 export interface CompleteSurveyRequest { contractId: number; templateId: number; answers: SurveyAnswerData[]; progressStatus?: any; // 진행 상태 정보 (옵션) } // 서버 액션: 설문조사 완료 처리 export async function completeSurvey(data: CompleteSurveyRequest) { try { console.log('🚀 설문조사 완료 처리 시작:', { contractId: data.contractId, templateId: data.templateId, answersCount: data.answers?.length || 0 }); // 입력 검증 if (!data.contractId || !data.templateId || !data.answers?.length) { throw new Error('필수 데이터가 누락되었습니다.'); } // 트랜잭션으로 처리 const result = await db.transaction(async (tx) => { // 1. complianceResponses 테이블 upsert console.log('📋 complianceResponses 처리 중...'); // 기존 응답 확인 const existingResponse = await tx .select() .from(complianceResponses) .where( and( eq(complianceResponses.basicContractId, data.contractId), eq(complianceResponses.templateId, data.templateId) ) ) .limit(1); let responseId: number; if (existingResponse.length > 0) { // 기존 응답 업데이트 const updateData = { status: 'COMPLETED' as const, completedAt: new Date(), updatedAt: new Date() }; await tx .update(complianceResponses) .set(updateData) .where(eq(complianceResponses.id, existingResponse[0].id)); responseId = existingResponse[0].id; console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`); } else { // 새 응답 생성 const newResponse: NewComplianceResponse = { basicContractId: data.contractId, templateId: data.templateId, status: 'COMPLETED', completedAt: new Date() }; const insertResult = await tx .insert(complianceResponses) .values(newResponse) .returning({ id: complianceResponses.id }); responseId = insertResult[0].id; console.log(`✅ 새 응답 생성 완료: ID ${responseId}`); } // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요) console.log('🗑️ 기존 답변들 삭제 중...'); // 먼저 기존 답변에 연결된 파일들 삭제 const existingAnswers = await tx .select({ id: complianceResponseAnswers.id }) .from(complianceResponseAnswers) .where(eq(complianceResponseAnswers.responseId, responseId)); if (existingAnswers.length > 0) { const answerIds = existingAnswers.map(a => a.id); // 파일들 먼저 삭제 for (const answerId of answerIds) { await tx .delete(complianceResponseFiles) .where(eq(complianceResponseFiles.answerId, answerId)); } // 답변들 삭제 await tx .delete(complianceResponseAnswers) .where(eq(complianceResponseAnswers.responseId, responseId)); } // 3. 새로운 답변들 생성 console.log('📝 새로운 답변들 생성 중...'); const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = []; for (const answer of data.answers) { // 빈 답변은 스킵 (선택적 질문의 경우) if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) { continue; } const newAnswer: NewComplianceResponseAnswer = { responseId, questionId: answer.questionId, answerValue: answer.answerValue || null, detailText: answer.detailText || null, otherText: answer.otherText || null, // percentageValue는 필요시 추가 처리 }; const answerResult = await tx .insert(complianceResponseAnswers) .values(newAnswer) .returning({ id: complianceResponseAnswers.id }); const answerId = answerResult[0].id; createdAnswers.push({ questionId: answer.questionId, answerId, files: answer.files }); console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`); } // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요) console.log('📎 파일 업로드 처리 중...'); for (const answerWithFiles of createdAnswers) { if (answerWithFiles.files && answerWithFiles.files.length > 0) { for (const file of answerWithFiles.files) { // TODO: 실제 파일 저장 로직 구현 필요 // 현재는 파일 메타데이터만 저장 // 파일 저장 경로 생성 (예시) const fileName = file.name; const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`; const fileUpload = await saveFile({file,filePath }) const newFile: NewComplianceResponseFile = { answerId: answerWithFiles.answerId, fileName, filePath, fileSize: file.size, mimeType: file.type || 'application/octet-stream' }; await tx .insert(complianceResponseFiles) .values(newFile); console.log(`📎 파일 메타데이터 저장: ${fileName}`); } } } return { responseId, answersCount: createdAnswers.length, success: true }; }); console.log('🎉 설문조사 완료 처리 성공:', result); return { success: true, message: '설문조사가 성공적으로 완료되었습니다.', data: result }; } catch (error) { console.error('❌ 설문조사 완료 처리 실패:', error); return { success: false, message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.', data: null }; } } // 설문조사 응답 조회 서버 액션 export async function getSurveyResponse(contractId: number, templateId: number) { try { const response = await db .select() .from(complianceResponses) .where( and( eq(complianceResponses.basicContractId, contractId), eq(complianceResponses.templateId, templateId) ) ) .limit(1); if (response.length === 0) { return { success: true, data: null }; } // 답변들과 파일들도 함께 조회 const answers = await db .select({ id: complianceResponseAnswers.id, questionId: complianceResponseAnswers.questionId, answerValue: complianceResponseAnswers.answerValue, detailText: complianceResponseAnswers.detailText, otherText: complianceResponseAnswers.otherText, percentageValue: complianceResponseAnswers.percentageValue, }) .from(complianceResponseAnswers) .where(eq(complianceResponseAnswers.responseId, response[0].id)); // 각 답변의 파일들 조회 const answersWithFiles = await Promise.all( answers.map(async (answer) => { const files = await db .select() .from(complianceResponseFiles) .where(eq(complianceResponseFiles.answerId, answer.id)); return { ...answer, files }; }) ); return { success: true, data: { response: response[0], answers: answersWithFiles } }; } catch (error) { console.error('❌ 설문조사 응답 조회 실패:', error); return { success: false, message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.', data: null }; } } // 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직) export async function uploadSurveyFile(file: File, contractId: number, answerId: number) { try { // TODO: 실제 파일 저장 구현 // 예: AWS S3, 로컬 파일시스템, 등등 // 현재는 예시 구현 const fileName = `${Date.now()}-${file.name}`; const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`; // 실제로는 여기서 파일을 물리적으로 저장해야 함 // const savedPath = await saveFileToStorage(file, filePath); return { success: true, filePath, fileName: file.name, fileSize: file.size, mimeType: file.type }; } catch (error) { console.error('❌ 파일 업로드 실패:', error); return { success: false, message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.' }; } } // 기존 응답 조회를 위한 타입 export interface ExistingResponse { responseId: number; status: string; completedAt: string | null; answers: { questionId: number; answerValue: string | null; detailText: string | null; otherText: string | null; files: Array<{ id: number; fileName: string; filePath: string; fileSize: number; }>; }[]; } // 기존 응답 조회 서버 액션 export async function getExistingSurveyResponse( contractId: number, templateId: number ): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> { try { // 1. 해당 계약서의 응답 조회 const response = await db .select() .from(complianceResponses) .where( and( eq(complianceResponses.basicContractId, contractId), eq(complianceResponses.templateId, templateId) ) ) .limit(1); if (!response || response.length === 0) { return { success: true, data: null }; } const responseData = response[0]; // 2. 해당 응답의 모든 답변 조회 const answers = await db .select({ questionId: complianceResponseAnswers.questionId, answerValue: complianceResponseAnswers.answerValue, detailText: complianceResponseAnswers.detailText, otherText: complianceResponseAnswers.otherText, answerId: complianceResponseAnswers.id, }) .from(complianceResponseAnswers) .where(eq(complianceResponseAnswers.responseId, responseData.id)); // 3. 각 답변의 파일들 조회 const answerIds = answers.map(a => a.answerId); const files = answerIds.length > 0 ? await db .select() .from(complianceResponseFiles) .where(inArray(complianceResponseFiles.answerId, answerIds)) : []; // 4. 답변별 파일 그룹화 const filesByAnswerId = files.reduce((acc, file) => { if (!acc[file.answerId]) { acc[file.answerId] = []; } acc[file.answerId].push({ id: file.id, fileName: file.fileName, filePath: file.filePath, fileSize: file.fileSize || 0, }); return acc; }, {} as Record>); // 5. 최종 데이터 구성 const answersWithFiles = answers.map(answer => ({ questionId: answer.questionId, answerValue: answer.answerValue, detailText: answer.detailText, otherText: answer.otherText, files: filesByAnswerId[answer.answerId] || [], })); return { success: true, data: { responseId: responseData.id, status: responseData.status, completedAt: responseData.completedAt?.toISOString() || null, answers: answersWithFiles, }, }; } catch (error) { console.error('기존 설문 응답 조회 실패:', error); return { success: false, data: null, message: '기존 응답을 불러오는데 실패했습니다.' }; } } export type GtcVendorData = { vendorDocument: { id: number; name: string; description: string | null; version: string; reviewStatus: string; vendorId: number; baseDocumentId: number; vendorName: string; vendorCode: string; }; clauses: Array<{ id: number; baseClauseId: number; vendorDocumentId: number; parentId: number | null; depth: number; sortOrder: string; fullPath: string | null; reviewStatus: string; negotiationNote: string | null; isExcluded: boolean; // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값) effectiveItemNumber: string; effectiveCategory: string | null; effectiveSubtitle: string; effectiveContent: string | null; // 기본 조항 정보 baseItemNumber: string; baseCategory: string | null; baseSubtitle: string; baseContent: string | null; // 수정 여부 hasModifications: boolean; isNumberModified: boolean; isCategoryModified: boolean; isSubtitleModified: boolean; isContentModified: boolean; // 코멘트 관련 hasComment: boolean; pendingComment: string | null; }>; }; /** * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다. * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용) * @returns GTC 벤더 데이터 또는 null */ export async function getVendorGtcData(contractId?: number): Promise { try { const session = await getServerSession(authOptions); if (!session?.user?.companyId) { throw new Error("회사 정보가 없습니다."); } console.log(contractId, "contractId"); const companyId = session.user.companyId; const vendorId = companyId; // companyId를 vendorId로 사용 // 1. 계약 정보 가져오기 const existingContract = await db.query.basicContract.findFirst({ where: eq(basicContract.id, contractId), }); if (!existingContract) { throw new Error("계약을 찾을 수 없습니다."); } // 2. 계약 템플릿 정보 가져오기 const existingContractTemplate = await db.query.basicContractTemplates.findFirst({ where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음 }); if (!existingContractTemplate) { throw new Error("계약 템플릿을 찾을 수 없습니다."); } // 3. General 타입인지 확인 const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general'); let targetBaseDocumentId: number; if (isGeneral) { // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용 const standardGtcDoc = await db.query.gtcDocuments.findFirst({ where: and( eq(gtcDocuments.type, 'standard'), eq(gtcDocuments.isActive, true) ), orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선 }); if (!standardGtcDoc) { throw new Error("표준 GTC 문서를 찾을 수 없습니다."); } targetBaseDocumentId = standardGtcDoc.id; console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`); } else { // General이 아닌 경우: 프로젝트별 GTC 문서 사용 const projectCode = existingContractTemplate.templateName.split(" ")[0]; if (!projectCode) { throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다."); } // 프로젝트 찾기 const existingProject = await db.query.projects.findFirst({ where: eq(projects.code, projectCode), }); if (!existingProject) { throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`); } // 해당 프로젝트의 GTC 문서 찾기 const projectGtcDoc = await db.query.gtcDocuments.findFirst({ where: and( eq(gtcDocuments.type, 'project'), eq(gtcDocuments.projectId, existingProject.id), eq(gtcDocuments.isActive, true) ), orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선 }); if (!projectGtcDoc) { console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`); // 프로젝트별 GTC가 없으면 표준 GTC 사용 const standardGtcDoc = await db.query.gtcDocuments.findFirst({ where: and( eq(gtcDocuments.type, 'standard'), eq(gtcDocuments.isActive, true) ), orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] }); if (!standardGtcDoc) { throw new Error("표준 GTC 문서도 찾을 수 없습니다."); } targetBaseDocumentId = standardGtcDoc.id; } else { targetBaseDocumentId = projectGtcDoc.id; console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`); } } // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌) const vendorDocResult = await db .select({ id: gtcVendorDocuments.id, name: gtcVendorDocuments.name, description: gtcVendorDocuments.description, version: gtcVendorDocuments.version, reviewStatus: gtcVendorDocuments.reviewStatus, vendorId: gtcVendorDocuments.vendorId, baseDocumentId: gtcVendorDocuments.baseDocumentId, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, }) .from(gtcVendorDocuments) .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id)) .where( and( eq(gtcVendorDocuments.vendorId, vendorId), eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId), eq(gtcVendorDocuments.isActive, true) ) ) .limit(1); // 벤더 문서가 없으면 기본 정보로 생성 const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : { id: null, // 벤더 문서가 아직 생성되지 않음 name: `GTC 검토 (벤더 ID: ${vendorId})`, description: "기본 GTC 조항 검토", version: "1.0", reviewStatus: "pending", vendorId: vendorId, baseDocumentId: targetBaseDocumentId, vendorName: "Unknown Vendor", vendorCode: "UNKNOWN" }; if (vendorDocResult.length === 0) { console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`); } // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께) const clausesResult = await db .select({ // 기본 조항 정보 (메인) baseClauseId: gtcClauses.id, baseItemNumber: gtcClauses.itemNumber, baseCategory: gtcClauses.category, baseSubtitle: gtcClauses.subtitle, baseContent: gtcClauses.content, baseParentId: gtcClauses.parentId, baseDepth: gtcClauses.depth, baseSortOrder: gtcClauses.sortOrder, baseFullPath: gtcClauses.fullPath, // 벤더 조항 정보 (있는 경우만) vendorClauseId: gtcVendorClauses.id, vendorDocumentId: gtcVendorClauses.vendorDocumentId, reviewStatus: gtcVendorClauses.reviewStatus, negotiationNote: gtcVendorClauses.negotiationNote, isExcluded: gtcVendorClauses.isExcluded, // 수정된 값들 (있는 경우만) modifiedItemNumber: gtcVendorClauses.modifiedItemNumber, modifiedCategory: gtcVendorClauses.modifiedCategory, modifiedSubtitle: gtcVendorClauses.modifiedSubtitle, modifiedContent: gtcVendorClauses.modifiedContent, // 수정 여부 isNumberModified: gtcVendorClauses.isNumberModified, isCategoryModified: gtcVendorClauses.isCategoryModified, isSubtitleModified: gtcVendorClauses.isSubtitleModified, isContentModified: gtcVendorClauses.isContentModified, }) .from(gtcClauses) .leftJoin(gtcVendorClauses, and( eq(gtcVendorClauses.baseClauseId, gtcClauses.id), vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`, eq(gtcVendorClauses.isActive, true) )) .where( and( eq(gtcClauses.documentId, targetBaseDocumentId), eq(gtcClauses.isActive, true) ) ) .orderBy(gtcClauses.sortOrder); let negotiationHistoryMap = new Map(); if (vendorDocument.id) { const vendorClauseIds = clausesResult .filter(c => c.vendorClauseId) .map(c => c.vendorClauseId); if (vendorClauseIds.length > 0) { const histories = await db .select({ vendorClauseId: gtcNegotiationHistory.vendorClauseId, action: gtcNegotiationHistory.action, previousStatus: gtcNegotiationHistory.previousStatus, newStatus: gtcNegotiationHistory.newStatus, comment: gtcNegotiationHistory.comment, actorType: gtcNegotiationHistory.actorType, actorId: gtcNegotiationHistory.actorId, actorName: gtcNegotiationHistory.actorName, actorEmail: gtcNegotiationHistory.actorEmail, createdAt: gtcNegotiationHistory.createdAt, changedFields: gtcNegotiationHistory.changedFields, }) .from(gtcNegotiationHistory) .leftJoin(users, eq(gtcNegotiationHistory.actorId, users.id)) .where(inArray(gtcNegotiationHistory.vendorClauseId, vendorClauseIds)) .orderBy(desc(gtcNegotiationHistory.createdAt)); // 벤더 조항별로 이력 그룹화 histories.forEach(history => { if (!negotiationHistoryMap.has(history.vendorClauseId)) { negotiationHistoryMap.set(history.vendorClauseId, []); } negotiationHistoryMap.get(history.vendorClauseId).push(history); }); } } // 6. 데이터 변환 및 추가 정보 계산 const clauses = clausesResult.map(clause => { const hasVendorData = !!clause.vendorClauseId; const negotiationHistory = hasVendorData ? (negotiationHistoryMap.get(clause.vendorClauseId) || []) : []; // 코멘트가 있는 이력들만 필터링 const commentHistory = negotiationHistory.filter(h => h.comment); const latestComment = commentHistory[0]?.comment || null; const hasComment = commentHistory.length > 0; return { id: clause.baseClauseId, vendorClauseId: clause.vendorClauseId, vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null, // 기본 조항의 계층 구조 정보 사용 parentId: clause.baseParentId, depth: clause.baseDepth, sortOrder: clause.baseSortOrder, fullPath: clause.baseFullPath, // 상태 정보 (벤더 데이터가 있는 경우만) reviewStatus: clause.reviewStatus || 'pending', negotiationNote: clause.negotiationNote, isExcluded: clause.isExcluded || false, // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값) effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber, effectiveCategory: clause.modifiedCategory || clause.baseCategory, effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle, effectiveContent: clause.modifiedContent || clause.baseContent, // 기본 조항 정보 baseItemNumber: clause.baseItemNumber, baseCategory: clause.baseCategory, baseSubtitle: clause.baseSubtitle, baseContent: clause.baseContent, // 수정 여부 // hasModifications, isNumberModified: clause.isNumberModified || false, isCategoryModified: clause.isCategoryModified || false, isSubtitleModified: clause.isSubtitleModified || false, isContentModified: clause.isContentModified || false, hasComment, latestComment, commentHistory, // 전체 코멘트 이력 negotiationHistory, // 전체 협의 이력 }; }); return { vendorDocument, clauses, }; } catch (error) { console.error('GTC 벤더 데이터 가져오기 실패:', error); throw error; } } interface ClauseUpdateData { itemNumber: string; category: string | null; subtitle: string; content: string | null; comment: string; } interface VendorDocument { id: number | null; vendorId: number; baseDocumentId: number; name: string; description: string; version: string; } export async function updateVendorClause( baseClauseId: number, vendorClauseId: number | null, clauseData: any, vendorDocument: any ): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> { try { const session = await getServerSession(authOptions); if (!session?.user?.companyId) { return { success: false, error: "회사 정보가 없습니다." }; } const companyId = session.user.companyId; const vendorId = companyId; const userId = Number(session.user.id); // 1. 기본 조항 정보 가져오기 const baseClause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, baseClauseId), }); if (!baseClause) { return { success: false, error: "기본 조항을 찾을 수 없습니다." }; } // 2. 이전 코멘트 가져오기 (vendorClauseId가 있는 경우) let previousComment = null; if (vendorClauseId) { const previousData = await db .select({ comment: gtcVendorClauses.negotiationNote }) .from(gtcVendorClauses) .where(eq(gtcVendorClauses.id, vendorClauseId)) .limit(1); previousComment = previousData?.[0]?.comment || null; } // 3. 벤더 문서 ID 확보 (없으면 생성) let finalVendorDocumentId = vendorDocument?.id; if (!finalVendorDocumentId && vendorDocument) { const newVendorDoc = await db.insert(gtcVendorDocuments).values({ vendorId: vendorId, baseDocumentId: vendorDocument.baseDocumentId, name: vendorDocument.name, description: vendorDocument.description, version: vendorDocument.version, reviewStatus: 'reviewing', createdById: userId, updatedById: userId, }).returning({ id: gtcVendorDocuments.id }); if (newVendorDoc.length === 0) { return { success: false, error: "벤더 문서 생성에 실패했습니다." }; } finalVendorDocumentId = newVendorDoc[0].id; console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`); } if (!finalVendorDocumentId) { return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." }; } // 4. 수정 여부 확인 const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber; const isCategoryModified = clauseData.category !== baseClause.category; const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle; const isContentModified = clauseData.content !== baseClause.content; const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified; const hasComment = !!(clauseData.comment?.trim()); // 5. 벤더 조항 데이터 준비 const vendorClauseData = { vendorDocumentId: finalVendorDocumentId, baseClauseId: baseClauseId, parentId: baseClause.parentId, depth: baseClause.depth, sortOrder: baseClause.sortOrder, fullPath: baseClause.fullPath, modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null, modifiedCategory: isCategoryModified ? clauseData.category : null, modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null, modifiedContent: isContentModified ? clauseData.content : null, isNumberModified, isCategoryModified, isSubtitleModified, isContentModified, reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft', negotiationNote: clauseData.comment?.trim() || null, editReason: clauseData.comment?.trim() || null, updatedAt: new Date(), updatedById: userId, }; let finalVendorClauseId = vendorClauseId; // 6. 벤더 조항 생성 또는 업데이트 if (vendorClauseId) { await db .update(gtcVendorClauses) .set(vendorClauseData) .where(eq(gtcVendorClauses.id, vendorClauseId)); console.log(`벤더 조항 업데이트: ${vendorClauseId}`); } else { const newVendorClause = await db.insert(gtcVendorClauses).values({ ...vendorClauseData, createdById: userId, }).returning({ id: gtcVendorClauses.id }); if (newVendorClause.length === 0) { return { success: false, error: "벤더 조항 생성에 실패했습니다." }; } finalVendorClauseId = newVendorClause[0].id; console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`); } // 7. 협의 이력에 기록 (코멘트가 변경된 경우만) if (clauseData.comment !== previousComment) { await db.insert(gtcNegotiationHistory).values({ vendorClauseId: finalVendorClauseId, action: previousComment ? "modified" : "commented", comment: clauseData.comment || null, previousStatus: null, newStatus: 'reviewing', actorType: "vendor", actorId: userId, actorName: session.user.name, actorEmail: session.user.email, changedFields: { comment: { from: previousComment, to: clauseData.comment || null } } }); } return { success: true, vendorClauseId: finalVendorClauseId, vendorDocumentId: finalVendorDocumentId }; } catch (error) { console.error('벤더 조항 업데이트 실패:', error); return { success: false, error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' }; } } // 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경 export async function updateVendorClauseComment( clauseId: number, comment: string ): Promise<{ success: boolean; error?: string }> { console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.'); // 기본 조항 정보 가져오기 const baseClause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, clauseId), }); if (!baseClause) { return { success: false, error: "기본 조항을 찾을 수 없습니다." }; } // 기존 벤더 조항 찾기 const session = await getServerSession(authOptions); const vendorId = session?.user?.companyId; const existingVendorClause = await db.query.gtcVendorClauses.findFirst({ where: and( eq(gtcVendorClauses.baseClauseId, clauseId), eq(gtcVendorClauses.isActive, true) ), with: { vendorDocument: true } }); const clauseData: ClauseUpdateData = { itemNumber: baseClause.itemNumber, category: baseClause.category, subtitle: baseClause.subtitle, content: baseClause.content, comment: comment, }; const result = await updateVendorClause( clauseId, existingVendorClause?.id || null, clauseData, existingVendorClause?.vendorDocument || undefined ); return { success: result.success, error: result.error }; } /** * 벤더 조항 코멘트들의 상태 체크 */ export async function checkVendorClausesCommentStatus( vendorDocumentId: number ): Promise<{ hasComments: boolean; commentCount: number }> { try { const clausesWithComments = await db .select({ id: gtcVendorClauses.id, negotiationNote: gtcVendorClauses.negotiationNote }) .from(gtcVendorClauses) .where( and( eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId), eq(gtcVendorClauses.isActive, true) ) ); const commentCount = clausesWithComments.filter( clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0 ).length; return { hasComments: commentCount > 0, commentCount, }; } catch (error) { console.error('벤더 조항 코멘트 상태 체크 실패:', error); return { hasComments: false, commentCount: 0 }; } } /** * 특정 템플릿의 기본 정보를 조회하는 서버 액션 * @param templateId - 조회할 템플릿의 ID * @returns 템플릿 기본 정보 또는 null */ export async function getBasicContractTemplateInfo(templateId: number) { try { const templateInfo = await db .select({ templateId: basicContractTemplates.id, templateName: basicContractTemplates.templateName, revision: basicContractTemplates.revision, status: basicContractTemplates.status, legalReviewRequired: basicContractTemplates.legalReviewRequired, validityPeriod: basicContractTemplates.validityPeriod, fileName: basicContractTemplates.fileName, filePath: basicContractTemplates.filePath, createdAt: basicContractTemplates.createdAt, updatedAt: basicContractTemplates.updatedAt, createdBy: basicContractTemplates.createdBy, updatedBy: basicContractTemplates.updatedBy, disposedAt: basicContractTemplates.disposedAt, restoredAt: basicContractTemplates.restoredAt, }) .from(basicContractTemplates) .where(eq(basicContractTemplates.id, templateId)) .then((res) => res[0] || null) return templateInfo } catch (error) { console.error("Error fetching template info:", error) return null } } /** * 카테고리 자동 분류 함수 */ function getCategoryFromTemplateName(templateName: string | null): string { if (!templateName) return "기타" const templateNameLower = templateName.toLowerCase() if (templateNameLower.includes("준법")) { return "CP" } else if (templateNameLower.includes("gtc")) { return "GTC" } return "기타" } /** * 법무검토 요청 서버 액션 */ export async function requestLegalReviewAction( contractIds: number[], reviewNote?: string ): Promise<{ success: boolean; message: string; data?: any }> { try { // 세션 확인 const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, message: "로그인이 필요합니다." } } // 계약서 정보 조회 const contracts = await db .select({ id: basicContractView.id, vendorId: basicContractView.vendorId, vendorCode: basicContractView.vendorCode, vendorName: basicContractView.vendorName, templateName: basicContractView.templateName, legalReviewRequired: basicContractView.legalReviewRequired, legalReviewRequestedAt: basicContractView.legalReviewRequestedAt, }) .from(basicContractView) .where(inArray(basicContractView.id, contractIds)) if (contracts.length === 0) { return { success: false, message: "선택된 계약서를 찾을 수 없습니다." } } // 법무검토 요청 가능한 계약서 필터링 const eligibleContracts = contracts.filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt ) if (eligibleContracts.length === 0) { return { success: false, message: "법무검토 요청 가능한 계약서가 없습니다." } } const currentDate = new Date() const reviewer = session.user.name || session.user.email || "알 수 없음" // 트랜잭션으로 처리 const results = await db.transaction(async (tx) => { const legalWorkResults = [] const contractUpdateResults = [] // 각 계약서에 대해 legalWorks 레코드 생성 for (const contract of eligibleContracts) { const category = getCategoryFromTemplateName(contract.templateName) // legalWorks에 레코드 삽입 const legalWorkResult = await tx.insert(legalWorks).values({ basicContractId: contract.id, // 레퍼런스 ID category: category, status: "신규등록", vendorId: contract.vendorId, vendorCode: contract.vendorCode, vendorName: contract.vendorName || "업체명 없음", isUrgent: false, consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식 reviewer: reviewer, hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정 createdAt: currentDate, updatedAt: currentDate, }).returning({ id: legalWorks.id }) legalWorkResults.push(legalWorkResult[0]) // basicContract 테이블의 legalReviewRequestedAt 업데이트 const contractUpdateResult = await tx .update(basicContract) .set({ legalReviewRequestedAt: currentDate, updatedAt: currentDate, }) .where(eq(basicContract.id, contract.id)) .returning({ id: basicContract.id }) contractUpdateResults.push(contractUpdateResult[0]) } return { legalWorks: legalWorkResults, contractUpdates: contractUpdateResults } }) console.log("법무검토 요청 완료:", { requestedBy: reviewer, contractIds: eligibleContracts.map(c => c.id), legalWorkIds: results.legalWorks.map(r => r.id), reviewNote, }) return { success: true, message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`, data: { processedCount: eligibleContracts.length, totalRequested: contractIds.length, legalWorkIds: results.legalWorks.map(r => r.id), } } } catch (error) { console.error("법무검토 요청 중 오류:", error) return { success: false, message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.", } } } export async function resendContractsAction(contractIds: number[]) { try { // 세션 확인 const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error('인증이 필요합니다.') } // 계약서 정보 조회 const contracts = await db .select({ id: basicContract.id, vendorId: basicContract.vendorId, fileName: basicContract.fileName, deadline: basicContract.deadline, status: basicContract.status, createdAt: basicContract.createdAt, }) .from(basicContract) .where(inArray(basicContract.id, contractIds)) if (contracts.length === 0) { throw new Error('발송할 계약서를 찾을 수 없습니다.') } // 각 계약서에 대해 이메일 발송 const emailPromises = contracts.map(async (contract) => { // 벤더 정보 조회 const vendor = await db .select({ id: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, country: vendors.country, email: vendors.email, }) .from(vendors) .where(eq(vendors.id, contract.vendorId!)) .limit(1) if (!vendor[0]) { console.error(`벤더를 찾을 수 없습니다: vendorId ${contract.vendorId}`) return null } // 벤더 연락처 조회 (Primary 연락처 우선, 없으면 첫 번째 연락처) const contacts = await db .select({ contactName: vendorContacts.contactName, contactEmail: vendorContacts.contactEmail, isPrimary: vendorContacts.isPrimary, }) .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendor[0].id)) .orderBy(vendorContacts.isPrimary) // 이메일 수신자 결정 (Primary 연락처 > 첫 번째 연락처 > 벤더 기본 이메일) const primaryContact = contacts.find(c => c.isPrimary) const recipientEmail = primaryContact?.contactEmail || contacts[0]?.contactEmail || vendor[0].email const recipientName = primaryContact?.contactName || contacts[0]?.contactName || vendor[0].vendorName if (!recipientEmail) { console.error(`이메일 주소를 찾을 수 없습니다: vendorId ${vendor[0].id}`) return null } // 언어 결정 (한국 = 한글, 그 외 = 영어) const isKorean = vendor[0].country === 'KR' const template = isKorean ? 'contract-reminder-kr' : 'contract-reminder-en' const subject = isKorean ? '[eVCP] 계약서 서명 요청 리마인더' : '[eVCP] Contract Signature Reminder' // 마감일 포맷팅 const deadlineDate = new Date(contract.deadline) const formattedDeadline = isKorean ? `${deadlineDate.getFullYear()}년 ${deadlineDate.getMonth() + 1}월 ${deadlineDate.getDate()}일` : deadlineDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) // 남은 일수 계산 const today = new Date() today.setHours(0, 0, 0, 0) const deadline = new Date(contract.deadline) deadline.setHours(0, 0, 0, 0) const daysRemaining = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) // 이메일 발송 await sendEmail({ from: session.user.email, to: recipientEmail, subject, template, context: { recipientName, vendorName: vendor[0].vendorName, vendorCode: vendor[0].vendorCode, contractFileName: contract.fileName, deadline: formattedDeadline, daysRemaining, senderName: session.user.name || session.user.email, senderEmail: session.user.email, // 계약서 링크 (실제 환경에 맞게 수정 필요) contractLink: `${process.env.NEXT_PUBLIC_APP_URL}/contracts/${contract.id}`, }, }) console.log(`리마인더 이메일 발송 완료: ${recipientEmail} (계약서 ID: ${contract.id})`) return { contractId: contract.id, email: recipientEmail } }) const results = await Promise.allSettled(emailPromises) // 성공/실패 카운트 const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null).length const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value === null)).length if (failed > 0) { console.warn(`${failed}건의 이메일 발송 실패`) } return { success: true, message: `${successful}건의 리마인더 이메일을 발송했습니다.`, successful, failed, } } catch (error) { console.error('계약서 재발송 중 오류:', error) throw new Error('계약서 재발송 중 오류가 발생했습니다.') } } export async function processBuyerSignatureAction( contractId: number, signedFileData: ArrayBuffer, fileName: string ): Promise<{ success: boolean; message: string; data?: any }> { try { // 세션 확인 const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, message: "로그인이 필요합니다." } } // 계약서 정보 조회 및 상태 확인 const contract = await db .select() .from(basicContractView) .where(eq(basicContractView.id, contractId)) .limit(1) if (contract.length === 0) { return { success: false, message: "계약서를 찾을 수 없습니다." } } const contractData = contract[0] // 최종승인 가능 상태 확인 if (contractData.completedAt !== null) { return { success: false, message: "이미 완료된 계약서입니다." } } if (!contractData.signedFilePath) { return { success: false, message: "협력업체 서명이 완료되지 않았습니다." } } if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) { return { success: false, message: "법무검토가 완료되지 않았습니다." } } // 파일 저장 로직 (기존 파일 덮어쓰기) const saveResult = await saveBuffer({ buffer: signedFileData, fileName: fileName, directory: "basicContract/signed" }); if (!saveResult.success) { return { success: false, message: `파일 저장 중 오류가 발생했습니다: ${saveResult.error}` } } const currentDate = new Date() // 계약서 상태 업데이트 const updatedContract = await db .update(basicContract) .set({ buyerSignedAt: currentDate, completedAt: currentDate, status: "COMPLETED", updatedAt: currentDate, filePath: saveResult.publicPath, // 웹 접근 가능한 경로로 업데이트 }) .where(eq(basicContract.id, contractId)) .returning() // 캐시 재검증 revalidatePath("/contracts") console.log("구매자 서명 및 최종승인 완료:", { contractId, buyerSigner: session.user.name || session.user.email, completedAt: currentDate, }) return { success: true, message: "계약서 최종승인이 완료되었습니다.", data: { contractId, completedAt: currentDate, } } } catch (error) { console.error("구매자 서명 처리 중 오류:", error) return { success: false, message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.", } } } /** * 일괄 최종승인 (서명 다이얼로그 호출용) */ export async function prepareFinalApprovalAction( contractIds: number[] ): Promise<{ success: boolean; message: string; contracts?: any[] }> { try { // 세션 확인 const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, message: "로그인이 필요합니다." } } // 계약서 정보 조회 const contracts = await db .select() .from(basicContractView) .where(inArray(basicContractView.id, contractIds)) if (contracts.length === 0) { return { success: false, message: "선택된 계약서를 찾을 수 없습니다." } } // 최종승인 가능한 계약서 필터링 const eligibleContracts = contracts.filter(contract => { if (contract.completedAt !== null || !contract.signedFilePath) { return false } if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { return false } return true }) if (eligibleContracts.length === 0) { return { success: false, message: "최종승인 가능한 계약서가 없습니다." } } // 서명 다이얼로그에서 사용할 수 있는 형태로 변환 const contractsForSigning = eligibleContracts.map(contract => ({ id: contract.id, templateName: contract.templateName, signedFilePath: contract.signedFilePath, signedFileName: contract.signedFileName, vendorName: contract.vendorName, vendorCode: contract.vendorCode, requestedByName: "구매팀", // 최종승인자 표시 createdAt: contract.createdAt, // 다른 필요한 필드들... })) return { success: true, message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`, contracts: contractsForSigning } } catch (error) { console.error("최종승인 준비 중 오류:", error) return { success: false, message: "최종승인 준비 중 오류가 발생했습니다.", } } } /** * 서명 없이 승인만 처리 (간단한 승인 방식) */ export async function quickFinalApprovalAction( contractIds: number[] ): Promise<{ success: boolean; message: string; data?: any }> { try { // 세션 확인 const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, message: "로그인이 필요합니다." } } // 계약서 정보 조회 const contracts = await db .select() .from(basicContract) .where(inArray(basicContract.id, contractIds)) // 승인 가능한 계약서 필터링 const eligibleContracts = contracts.filter(contract => { if (contract.completedAt !== null || !contract.signedFilePath) { return false } if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { return false } return true }) if (eligibleContracts.length === 0) { return { success: false, message: "최종승인 가능한 계약서가 없습니다." } } const currentDate = new Date() const approver = session.user.name || session.user.email || "알 수 없음" // 일괄 업데이트 const updatedContracts = await db .update(basicContract) .set({ buyerSignedAt: currentDate, completedAt: currentDate, status: "COMPLETED", updatedAt: currentDate, }) .where(inArray(basicContract.id, eligibleContracts.map(c => c.id))) .returning({ id: basicContract.id }) // 캐시 재검증 revalidatePath("/contracts") console.log("일괄 최종승인 완료:", { approver, contractIds: updatedContracts.map(c => c.id), completedAt: currentDate, }) return { success: true, message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`, data: { processedCount: updatedContracts.length, contractIds: updatedContracts.map(c => c.id), } } } catch (error) { console.error("일괄 최종승인 중 오류:", error) return { success: false, message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.", } } } export async function getVendorSignatureFile() { try { // 세션에서 사용자 정보 가져오기 const session = await getServerSession(authOptions) if (!session?.user?.companyId) { throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.") } // 조건에 맞는 vendor attachment 찾기 const signatureAttachment = await db.query.vendorAttachments.findFirst({ where: and( eq(vendorAttachments.vendorId, session.user.companyId), eq(vendorAttachments.attachmentType, "SIGNATURE") ) }) if (!signatureAttachment) { return { success: false, error: "서명 파일을 찾을 수 없습니다." } } // 파일 읽기 let filePath: string; const nasPath = process.env.NAS_PATH || "/evcp_nas" if (process.env.NODE_ENV === 'production') { // ✅ 프로덕션: NAS 경로 사용 filePath = path.join(nasPath, signatureAttachment.filePath); } else { // 개발: public 폴더 filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath); } const fileBuffer = await readFile(filePath) // Base64로 인코딩 const base64File = fileBuffer.toString('base64') return { success: true, data: { id: signatureAttachment.id, fileName: signatureAttachment.fileName, fileType: signatureAttachment.fileType, base64: base64File, // 웹에서 사용할 수 있는 data URL 형식도 제공 dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}` } } } catch (error) { console.error("서명 파일 조회 중 오류:", error) console.log("서명 파일 조회 중 오류:", error) return { success: false, error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다." } } } // templateName에서 project code 추출 function extractProjectCodeFromTemplateName(templateName: string): string | null { if (!templateName.includes('GTC')) return null; if (templateName.toLowerCase().includes('general')) return null; // GTC 앞의 문자열을 추출 const gtcIndex = templateName.indexOf('GTC'); if (gtcIndex > 0) { const beforeGTC = templateName.substring(0, gtcIndex).trim(); // 마지막 단어를 project code로 간주 const words = beforeGTC.split(/\s+/); return words[words.length - 1]; } return null; } // 단일 contract에 대한 GTC 정보 확인 async function checkGTCCommentsForContract( templateName: string, vendorId: number ): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> { try { const projectCode = extractProjectCodeFromTemplateName(templateName); let gtcDocumentId: number | null = null; console.log(projectCode,"projectCode") // 1. GTC Document ID 찾기 if (projectCode && projectCode.trim() !== '') { // Project GTC인 경우 const project = await db .select({ id: projects.id }) .from(projects) .where(eq(projects.code, projectCode.trim())) .limit(1) if (project.length > 0) { const projectGtcDoc = await db .select({ id: gtcDocuments.id }) .from(gtcDocuments) .where( and( eq(gtcDocuments.projectId, project[0].id), eq(gtcDocuments.isActive, true) ) ) .orderBy(desc(gtcDocuments.revision)) .limit(1) if (projectGtcDoc.length > 0) { gtcDocumentId = projectGtcDoc[0].id } } } else { // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우) const standardGtcDoc = await db .select({ id: gtcDocuments.id }) .from(gtcDocuments) .where( and( eq(gtcDocuments.type, "standard"), eq(gtcDocuments.isActive, true), isNull(gtcDocuments.projectId) ) ) .orderBy(desc(gtcDocuments.revision)) .limit(1) if (standardGtcDoc.length > 0) { gtcDocumentId = standardGtcDoc[0].id } } console.log(gtcDocumentId,"gtcDocumentId") // GTC Document를 찾지 못한 경우 if (!gtcDocumentId) { return { gtcDocumentId: null, hasComments: false }; } // 2. 코멘트 존재 여부 확인 // gtcDocumentId로 해당 벤더의 vendor documents 찾기 const vendorDocuments = await db .select({ id: gtcVendorDocuments.id }) .from(gtcVendorDocuments) .where( and( eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId), eq(gtcVendorDocuments.vendorId, vendorId), eq(gtcVendorDocuments.isActive, true) ) ) .limit(1) if (vendorDocuments.length === 0) { return { gtcDocumentId, hasComments: false }; } // vendor document에 연결된 clauses에서 negotiation history 확인 const commentsExist = await db .select({ count: gtcNegotiationHistory.id }) .from(gtcNegotiationHistory) .innerJoin( gtcVendorClauses, eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id) ) .where( and( eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id), eq(gtcVendorClauses.isActive, true), isNotNull(gtcNegotiationHistory.comment), ne(gtcNegotiationHistory.comment, '') ) ) .limit(1) return { gtcDocumentId, hasComments: commentsExist.length > 0 }; } catch (error) { console.error('Error checking GTC comments for contract:', error); return { gtcDocumentId: null, hasComments: false }; } } // 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집 export async function checkGTCCommentsForContracts( contracts: BasicContractView[] ): Promise> { const gtcData: Record = {}; // GTC가 포함된 contract만 필터링 const gtcContracts = contracts.filter(contract => contract.templateName?.includes('GTC') ); if (gtcContracts.length === 0) { return gtcData; } // Promise.all을 사용해서 병렬 처리 const checkPromises = gtcContracts.map(async (contract) => { try { const result = await checkGTCCommentsForContract( contract.templateName!, contract.vendorId! ); return { contractId: contract.id, gtcDocumentId: result.gtcDocumentId, hasComments: result.hasComments }; } catch (error) { console.error(`Error checking GTC for contract ${contract.id}:`, error); return { contractId: contract.id, gtcDocumentId: null, hasComments: false }; } }); const results = await Promise.all(checkPromises); // 결과를 Record 형태로 변환 results.forEach(({ contractId, gtcDocumentId, hasComments }) => { gtcData[contractId] = { gtcDocumentId, hasComments }; }); return gtcData; } export async function updateVendorDocumentStatus( formData: FormData | { status: string; vendorDocumentId: number; documentId: number; vendorId: number; } ) { try { // 세션 확인 const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, error: "인증되지 않은 사용자입니다." } } // 데이터 파싱 const rawData = formData instanceof FormData ? { status: formData.get("status") as string, vendorDocumentId: Number(formData.get("vendorDocumentId")), documentId: Number(formData.get("documentId")), vendorId: Number(formData.get("vendorId")), } : formData // 유효성 검사 const validatedData = updateStatusSchema.safeParse(rawData) if (!validatedData.success) { return { success: false, error: "유효하지 않은 데이터입니다." } } const { status, vendorDocumentId, documentId, vendorId } = validatedData.data // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인 if (status === "complete") { // 승인되지 않은 조항 확인 const pendingClauses = await db .select({ id: gtcVendorClauses.id }) .from(gtcVendorClauses) .where( and( eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId), eq(gtcVendorClauses.isActive, true), not(eq(gtcVendorClauses.reviewStatus, "approved")), not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외 ) ) .limit(1) if (pendingClauses.length > 0) { return { success: false, error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다." } } } // 업데이트 실행 await db .update(gtcVendorDocuments) .set({ reviewStatus: status, updatedAt: new Date(), updatedById: Number(session.user.id), // 완료 처리 시 협의 종료일 설정 ...(status === "complete" ? { negotiationEndDate: new Date(), approvalDate: new Date() } : {}) }) .where(eq(gtcVendorDocuments.id, vendorDocumentId)) // 캐시 무효화 // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`) return { success: true } } catch (error) { console.error("Error updating vendor document status:", error) return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." } } } interface SaveDocumentParams { documentId: number pdfBuffer: Uint8Array originalFileName: string vendor: { vendorName: string } userId: number } export async function saveGtcDocumentAction({ documentId, pdfBuffer, originalFileName, vendor }: SaveDocumentParams) { try { console.log("📄 GTC 문서 저장 시작:", { documentId, documentIdType: typeof documentId, vendorName: vendor.vendorName, originalFileName, bufferSize: pdfBuffer.length }) // documentId 유효성 검사 if (!documentId || isNaN(Number(documentId))) { throw new Error(`유효하지 않은 문서 ID: ${documentId}`) } // 기본계약 존재 여부 확인 const existingContract = await db.query.basicContract.findFirst({ where: eq(basicContract.id, documentId), }) if (!existingContract) { throw new Error(`기본계약을 찾을 수 없습니다. ID: ${documentId}`) } console.log("📋 기존 계약 정보:", { contractId: existingContract.id, templateId: existingContract.templateId, vendorId: existingContract.vendorId, currentStatus: existingContract.status, currentFileName: existingContract.fileName, currentFilePath: existingContract.filePath }) const session = await getServerSession(authOptions) if (!session?.user) { return { success: false, error: "인증되지 않은 사용자입니다." } } const userId = Number(session.user.id); // 1. PDF 파일명 생성 const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거 const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf` // 2. 파일 저장 (공용 파일 저장 함수 사용) const saveResult = await saveBuffer({ buffer: Buffer.from(pdfBuffer), fileName, directory: 'basic-contracts', originalName: fileName, userId: userId.toString() }) if (!saveResult.success) { throw new Error(saveResult.error || '파일 저장 실패') } // 3. 데이터베이스 업데이트 - 트랜잭션으로 처리하고 결과 확인 const updateResult = await db.update(basicContract) .set({ fileName: saveResult.fileName!, filePath: saveResult.publicPath!, status: 'PENDING', // 기존 서명 관련 timestamp들 리셋 vendorSignedAt: null, buyerSignedAt: null, legalReviewRequestedAt: null, legalReviewCompletedAt: null, updatedAt: new Date() }) .where(eq(basicContract.id, documentId)) .returning({ id: basicContract.id, fileName: basicContract.fileName, filePath: basicContract.filePath }) // DB 업데이트 성공 여부 확인 if (!updateResult || updateResult.length === 0) { throw new Error(`기본계약 ID ${documentId}를 찾을 수 없거나 업데이트에 실패했습니다.`) } console.log("✅ GTC 문서 저장 완료:", { documentId, updatedRecord: updateResult[0], fileName: saveResult.fileName, filePath: saveResult.publicPath, fileSize: saveResult.fileSize }) // 캐시 무효화 revalidateTag("basic-contract-requests") revalidateTag("basic-contracts") revalidatePath("/partners/basic-contract") return { success: true, fileName: saveResult.fileName, filePath: saveResult.publicPath, fileSize: saveResult.fileSize, documentId: updateResult[0].id } } catch (error) { console.error("❌ GTC 문서 저장 실패:", error) return { success: false, error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다' } } }