'use server' import db from "@/db/db"; import { evaluationSubmissions, vendors, generalEvaluationResponses, esgEvaluationResponses, vendorEvaluationAttachments, EvaluationSubmission, GeneralEvaluationResponse, NewGeneralEvaluationResponse, generalEvaluations, GeneralEvaluation, EsgEvaluationItem, EsgAnswerOption, EsgEvaluationResponse, esgEvaluations, esgAnswerOptions, esgEvaluationItems } from "@/db/schema"; import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg} from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetEvaluationsSubmitSchema } from "./validation"; // 평가 제출 목록 조회용 뷰 타입 export type EvaluationSubmissionWithVendor = EvaluationSubmission & { vendor: { id: number; vendorCode: string; vendorName: string; countryCode: string; contactEmail: string; }; _count: { generalResponses: number; esgResponses: number; attachments: number; }; }; /** * 평가 제출 목록을 조회합니다 */ export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema) { try { const offset = (input.page - 1) * input.perPage; // 고급 필터링 const advancedWhere = filterColumns({ table: evaluationSubmissions, filters: input.filters, joinOperator: input.joinOperator, }); // 전역 검색 let globalWhere: SQL | undefined; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(evaluationSubmissions.submissionId, s), ilike(vendors.vendorName, s), ilike(vendors.vendorCode, s), ilike(evaluationSubmissions.submissionStatus, s), ilike(evaluationSubmissions.evaluationRound, s) ); } const finalWhere = and( advancedWhere, globalWhere, eq(evaluationSubmissions.isActive, true) ); // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => { return item.desc ? desc(evaluationSubmissions[item.id]) : asc(evaluationSubmissions[item.id]); }) : [desc(evaluationSubmissions.createdAt)]; // 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 메인 데이터 조회 const data = await tx .select({ id: evaluationSubmissions.id, submissionId: evaluationSubmissions.submissionId, companyId: evaluationSubmissions.companyId, evaluationYear: evaluationSubmissions.evaluationYear, evaluationRound: evaluationSubmissions.evaluationRound, submissionStatus: evaluationSubmissions.submissionStatus, submittedAt: evaluationSubmissions.submittedAt, reviewedAt: evaluationSubmissions.reviewedAt, reviewedBy: evaluationSubmissions.reviewedBy, reviewComments: evaluationSubmissions.reviewComments, averageEsgScore: evaluationSubmissions.averageEsgScore, totalGeneralItems: evaluationSubmissions.totalGeneralItems, completedGeneralItems: evaluationSubmissions.completedGeneralItems, totalEsgItems: evaluationSubmissions.totalEsgItems, completedEsgItems: evaluationSubmissions.completedEsgItems, isActive: evaluationSubmissions.isActive, createdAt: evaluationSubmissions.createdAt, updatedAt: evaluationSubmissions.updatedAt, // Vendor 정보 vendor: { id: vendors.id, vendorCode: vendors.vendorCode, vendorName: vendors.vendorName, countryCode: vendors.country, contactEmail: vendors.email, }, }) .from(evaluationSubmissions) .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) .where(finalWhere) .orderBy(...orderBy) .limit(input.perPage) .offset(offset); // 각 제출에 대한 응답/첨부파일 수 조회 const dataWithCounts = await Promise.all( data.map(async (submission) => { const [generalCount, esgCount, attachmentCount] = await Promise.all([ tx .select({ count: count() }) .from(generalEvaluationResponses) .where( and( eq(generalEvaluationResponses.submissionId, submission.id), eq(generalEvaluationResponses.isActive, true) ) ) .then(result => result[0]?.count || 0), tx .select({ count: count() }) .from(esgEvaluationResponses) .where( and( eq(esgEvaluationResponses.submissionId, submission.id), eq(esgEvaluationResponses.isActive, true) ) ) .then(result => result[0]?.count || 0), tx .select({ count: count() }) .from(vendorEvaluationAttachments) .where( and( eq(vendorEvaluationAttachments.submissionId, submission.id), eq(vendorEvaluationAttachments.isActive, true) ) ) .then(result => result[0]?.count || 0), ]); return { ...submission, _count: { generalResponses: generalCount, esgResponses: esgCount, attachments: attachmentCount, }, }; }) ); // 총 개수 조회 const totalResult = await tx .select({ count: count() }) .from(evaluationSubmissions) .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) .where(finalWhere); const total = totalResult[0]?.count || 0; return { data: dataWithCounts, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { console.error('Error in getEvaluationSubmissions:', err); return { data: [], pageCount: 0 }; } } /** * 특정 평가 제출의 상세 정보를 조회합니다 */ export async function getEvaluationSubmissionById(id: number) { try { const result = await db .select() .from(evaluationSubmissions) .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) .where( and( eq(evaluationSubmissions.id, id), eq(evaluationSubmissions.isActive, true) ) ) .limit(1); if (result.length === 0) { return null; } const submission = result[0]; // 응답 데이터도 함께 조회 const [generalResponses, esgResponses, attachments] = await Promise.all([ db .select() .from(generalEvaluationResponses) .where( and( eq(generalEvaluationResponses.submissionId, id), eq(generalEvaluationResponses.isActive, true) ) ), db .select() .from(esgEvaluationResponses) .where( and( eq(esgEvaluationResponses.submissionId, id), eq(esgEvaluationResponses.isActive, true) ) ), db .select() .from(vendorEvaluationAttachments) .where( and( eq(vendorEvaluationAttachments.submissionId, id), eq(vendorEvaluationAttachments.isActive, true) ) ), ]); return { ...submission, generalResponses, esgResponses, attachments, }; } catch (err) { console.error('Error in getEvaluationSubmissionById:', err); return null; } } /** * 평가 제출의 완성도를 확인합니다 (간단 버전) */ export async function getEvaluationSubmissionCompleteness(submissionId: number) { const result = await db.transaction(async (tx) => { // 제출 정보 조회 const submissionInfo = await tx .select({ submissionId: evaluationSubmissions.id, countryCode: vendors.country, averageEsgScore: evaluationSubmissions.averageEsgScore, totalGeneralItems: evaluationSubmissions.totalGeneralItems, completedGeneralItems: evaluationSubmissions.completedGeneralItems, totalEsgItems: evaluationSubmissions.totalEsgItems, completedEsgItems: evaluationSubmissions.completedEsgItems, }) .from(evaluationSubmissions) .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) .where(eq(evaluationSubmissions.id, submissionId)) .limit(1); if (submissionInfo.length === 0) { throw new Error("Submission not found"); } const info = submissionInfo[0]; const isKorean = info.countryCode === 'KR'; // 🔄 실제 평가 항목 수 조회 (전체 시스템에 등록된 평가 항목 수) const [actualGeneralTotal, actualEsgTotal] = await Promise.all([ // 활성화된 일반평가 항목 수 tx .select({ count: count() }) .from(generalEvaluations) .where(eq(generalEvaluations.isActive, true)) .then(result => result[0]?.count || 0), // 활성화된 ESG 평가 항목 수 (한국 업체인 경우에만) isKorean ? tx .select({ count: count() }) .from(esgEvaluationItems) .innerJoin(esgEvaluations, eq(esgEvaluationItems.esgEvaluationId, esgEvaluations.id)) .where( and( eq(esgEvaluationItems.isActive, true), eq(esgEvaluations.isActive, true) ) ) .then(result => result[0]?.count || 0) : Promise.resolve(0) ]); // 실시간 완성도 계산 (실제 응답된 것만) const [generalStats, esgStats] = await Promise.all([ tx .select({ total: count(), completed: sql`COUNT(CASE WHEN ${generalEvaluationResponses.responseText} IS NOT NULL AND ${generalEvaluationResponses.responseText} != '' THEN 1 END)`, }) .from(generalEvaluationResponses) .where( and( eq(generalEvaluationResponses.submissionId, submissionId), eq(generalEvaluationResponses.isActive, true) ) ), isKorean ? tx .select({ total: count(), completed: count(esgEvaluationResponses.selectedScore), averageScore: avg(esgEvaluationResponses.selectedScore), }) .from(esgEvaluationResponses) .where( and( eq(esgEvaluationResponses.submissionId, submissionId), eq(esgEvaluationResponses.isActive, true) ) ) : Promise.resolve([{ total: 0, completed: 0, averageScore: null }]) ]); // 실제 완료된 항목 수 const generalCompleted = generalStats[0]?.completed || 0; const esgCompleted = esgStats[0]?.completed || 0; const esgAverage = parseFloat(esgStats[0]?.averageScore?.toString() || '0'); // 🎯 실제 평가 항목 수를 기준으로 완성도 계산 return { general: { total: actualGeneralTotal, completed: generalCompleted, percentage: actualGeneralTotal > 0 ? (generalCompleted / actualGeneralTotal) * 100 : 0, isComplete: actualGeneralTotal > 0 && generalCompleted === actualGeneralTotal, }, esg: { total: actualEsgTotal, completed: esgCompleted, percentage: actualEsgTotal > 0 ? (esgCompleted / actualEsgTotal) * 100 : 0, averageScore: esgAverage, isComplete: actualEsgTotal === 0 || esgCompleted === actualEsgTotal, }, overall: { isComplete: (actualGeneralTotal > 0 && generalCompleted === actualGeneralTotal) && (actualEsgTotal === 0 || esgCompleted === actualEsgTotal), totalItems: actualGeneralTotal + actualEsgTotal, completedItems: generalCompleted + esgCompleted, }, }; }); return result; } /** * 평가 제출 상태를 업데이트합니다 (완성도 검증 포함) */ export async function updateEvaluationSubmissionStatus( submissionId: number, newStatus: string, reviewData?: { reviewedBy: string; reviewComments?: string; } ) { return await db.transaction(async (tx) => { // 제출 시에는 완성도 검증 if (newStatus === 'submitted') { const completeness = await getEvaluationSubmissionCompleteness(submissionId); if (!completeness.overall.isComplete) { throw new Error( `평가가 완료되지 않았습니다. ` + `일반평가: ${completeness.general.completed}/${completeness.general.total}, ` + `ESG평가: ${completeness.esg.completed}/${completeness.esg.total}` ); } } // 상태 업데이트 const updateData: any = { submissionStatus: newStatus, updatedAt: new Date(), }; if (newStatus === 'submitted') { updateData.submittedAt = new Date(); } if (reviewData) { updateData.reviewedAt = new Date(); updateData.reviewedBy = reviewData.reviewedBy; updateData.reviewComments = reviewData.reviewComments; } const [updatedSubmission] = await tx .update(evaluationSubmissions) .set(updateData) .where(eq(evaluationSubmissions.id, submissionId)) .returning(); return updatedSubmission; }); } export type GeneralEvaluationFormData = { submission: { id: number; submissionId: string; vendorName: string; submissionStatus: string; }; evaluations: Array<{ evaluation: GeneralEvaluation; response: GeneralEvaluationResponse | null; attachments: Array<{ id: number; fileId: string; originalFileName: string; fileSize: number; mimeType: string | null; createdAt: Date; }>; }>; }; /** * 일반평가 폼 데이터를 조회하고, 응답 레코드가 없으면 생성합니다 */ export async function getGeneralEvaluationFormData(submissionId: number): Promise { return await db.transaction(async (tx) => { // 1. 제출 정보 조회 const submissionResult = await tx .select({ id: evaluationSubmissions.id, submissionId: evaluationSubmissions.submissionId, vendorName: vendors.vendorName, submissionStatus: evaluationSubmissions.submissionStatus, }) .from(evaluationSubmissions) .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) .where(eq(evaluationSubmissions.id, submissionId)) .limit(1); if (submissionResult.length === 0) { throw new Error("제출 정보를 찾을 수 없습니다."); } const submission = submissionResult[0]; // 2. 활성화된 일반평가 항목들 조회 const activeEvaluations = await tx .select() .from(generalEvaluations) .where(eq(generalEvaluations.isActive, true)) .orderBy(asc(generalEvaluations.serialNumber)); // 3. 기존 응답들 조회 const existingResponses = await tx .select() .from(generalEvaluationResponses) .where( and( eq(generalEvaluationResponses.submissionId, submissionId), eq(generalEvaluationResponses.isActive, true) ) ); // 4. 응답이 없는 평가 항목들에 대해 빈 응답 레코드 생성 const responseMap = new Map(existingResponses.map(r => [r.generalEvaluationId, r])); const missingResponses: NewGeneralEvaluationResponse[] = []; for (const evaluation of activeEvaluations) { if (!responseMap.has(evaluation.id)) { missingResponses.push({ submissionId, generalEvaluationId: evaluation.id, responseText: '', hasAttachments: false, }); } } // 5. 누락된 응답 레코드들 생성 let newResponses: GeneralEvaluationResponse[] = []; if (missingResponses.length > 0) { newResponses = await tx .insert(generalEvaluationResponses) .values(missingResponses) .returning(); } // 6. 응답 맵 업데이트 newResponses.forEach(response => { responseMap.set(response.generalEvaluationId, response); }); // 7. 각 응답의 첨부파일들 조회 const evaluationData = await Promise.all( activeEvaluations.map(async (evaluation) => { const response = responseMap.get(evaluation.id) || null; let attachments: any[] = []; if (response) { attachments = await tx .select({ id: vendorEvaluationAttachments.id, fileId: vendorEvaluationAttachments.fileId, originalFileName: vendorEvaluationAttachments.originalFileName, fileSize: vendorEvaluationAttachments.fileSize, mimeType: vendorEvaluationAttachments.mimeType, createdAt: vendorEvaluationAttachments.createdAt, }) .from(vendorEvaluationAttachments) .where( and( eq(vendorEvaluationAttachments.generalEvaluationResponseId, response.id), eq(vendorEvaluationAttachments.isActive, true) ) ) .orderBy(desc(vendorEvaluationAttachments.createdAt)); } return { evaluation, response, attachments, }; }) ); return { submission, evaluations: evaluationData, }; }); } /** * 일반평가 응답을 저장합니다 */ export async function saveGeneralEvaluationResponse(data: { responseId: number; responseText: string; hasAttachments?: boolean; }) { try { const [updatedResponse] = await db .update(generalEvaluationResponses) .set({ responseText: data.responseText, hasAttachments: data.hasAttachments || false, updatedAt: new Date(), }) .where(eq(generalEvaluationResponses.id, data.responseId)) .returning(); return updatedResponse; } catch (error) { console.error('Error saving general evaluation response:', error); throw error; } } /** * 평가 제출의 진행률과 ESG 평균점수를 계산합니다 */ export async function recalculateEvaluationProgress(submissionId: number) { try { return await db.transaction(async (tx) => { // 1. 일반평가 진행률 계산 (점수 계산 제거) const generalProgressResult = await tx .select({ totalItems: count(), completedItems: sql`COUNT(CASE WHEN ${generalEvaluationResponses.responseText} IS NOT NULL AND ${generalEvaluationResponses.responseText} != '' THEN 1 END)`, }) .from(generalEvaluationResponses) .where( and( eq(generalEvaluationResponses.submissionId, submissionId), eq(generalEvaluationResponses.isActive, true) ) ); const generalStats = generalProgressResult[0]; const totalGeneralItems = generalStats.totalItems || 0; const completedGeneralItems = generalStats.completedItems || 0; // 2. ESG 평가 평균 점수 계산 const esgScoreResult = await tx .select({ averageScore: avg(esgEvaluationResponses.selectedScore), totalItems: count(), completedItems: count(esgEvaluationResponses.selectedScore), }) .from(esgEvaluationResponses) .where( and( eq(esgEvaluationResponses.submissionId, submissionId), eq(esgEvaluationResponses.isActive, true) ) ); const esgStats = esgScoreResult[0]; const averageEsgScore = parseFloat(esgStats.averageScore?.toString() || '0'); const totalEsgItems = esgStats.totalItems || 0; const completedEsgItems = esgStats.completedItems || 0; // 3. submission 테이블 업데이트 const [updatedSubmission] = await tx .update(evaluationSubmissions) .set({ // ❌ averageGeneralScore 제거 averageEsgScore: averageEsgScore > 0 ? averageEsgScore.toString() : null, totalGeneralItems, completedGeneralItems, totalEsgItems, completedEsgItems, updatedAt: new Date(), }) .where(eq(evaluationSubmissions.id, submissionId)) .returning(); return { submission: updatedSubmission, stats: { general: { total: totalGeneralItems, completed: completedGeneralItems, percentage: totalGeneralItems > 0 ? (completedGeneralItems / totalGeneralItems) * 100 : 0, }, esg: { average: averageEsgScore, total: totalEsgItems, completed: completedEsgItems, percentage: totalEsgItems > 0 ? (completedEsgItems / totalEsgItems) * 100 : 0, }, }, }; }); } catch (error) { console.error('Error recalculating evaluation progress:', error); throw error; } } // ================================================================ // ESG평가 관련 서버 액션들 // ================================================================ export type EsgEvaluationFormData = { submission: { id: number; submissionId: string; vendorName: string; submissionStatus: string; }; evaluations: Array<{ evaluation: { id: number; serialNumber: string; category: string; inspectionItem: string; }; items: Array<{ item: EsgEvaluationItem; answerOptions: EsgAnswerOption[]; response: EsgEvaluationResponse | null; }>; }>; }; /** * ESG평가 폼 데이터를 조회합니다 (응답은 실시간 생성) */ export async function getEsgEvaluationFormData(submissionId: number): Promise { return await db.transaction(async (tx) => { // 1. 제출 정보 조회 const submissionResult = await tx .select({ id: evaluationSubmissions.id, submissionId: evaluationSubmissions.submissionId, vendorName: vendors.vendorName, submissionStatus: evaluationSubmissions.submissionStatus, }) .from(evaluationSubmissions) .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) .where(eq(evaluationSubmissions.id, submissionId)) .limit(1); if (submissionResult.length === 0) { throw new Error("제출 정보를 찾을 수 없습니다."); } const submission = submissionResult[0]; // 2. 활성화된 ESG 평가표들 조회 const activeEsgEvaluations = await tx .select({ id: esgEvaluations.id, serialNumber: esgEvaluations.serialNumber, category: esgEvaluations.category, inspectionItem: esgEvaluations.inspectionItem, }) .from(esgEvaluations) .where(eq(esgEvaluations.isActive, true)) .orderBy(asc(esgEvaluations.serialNumber)); // 3. 각 ESG 평가표의 항목들과 답변 옵션들 조회 const evaluationData = await Promise.all( activeEsgEvaluations.map(async (evaluation) => { // 평가 항목들 조회 const items = await tx .select() .from(esgEvaluationItems) .where( and( eq(esgEvaluationItems.esgEvaluationId, evaluation.id), eq(esgEvaluationItems.isActive, true) ) ) .orderBy(asc(esgEvaluationItems.orderIndex)); // 각 항목의 답변 옵션들과 기존 응답 조회 const itemsWithOptions = await Promise.all( items.map(async (item) => { // 답변 옵션들 조회 const answerOptions = await tx .select() .from(esgAnswerOptions) .where( and( eq(esgAnswerOptions.esgEvaluationItemId, item.id), eq(esgAnswerOptions.isActive, true) ) ) .orderBy(asc(esgAnswerOptions.orderIndex)); // 기존 응답 조회 const existingResponse = await tx .select() .from(esgEvaluationResponses) .where( and( eq(esgEvaluationResponses.submissionId, submissionId), eq(esgEvaluationResponses.esgEvaluationItemId, item.id), eq(esgEvaluationResponses.isActive, true) ) ) .limit(1); return { item, answerOptions, response: existingResponse[0] || null, }; }) ); return { evaluation, items: itemsWithOptions, }; }) ); return { submission, evaluations: evaluationData, }; }); } /** * ESG평가 응답을 저장합니다 */ export async function saveEsgEvaluationResponse(data: { submissionId: number; esgEvaluationItemId: number; esgAnswerOptionId: number; selectedScore: number; additionalComments?: string; }) { try { return await db.transaction(async (tx) => { // 기존 응답이 있는지 확인 const existingResponse = await tx .select() .from(esgEvaluationResponses) .where( and( eq(esgEvaluationResponses.submissionId, data.submissionId), eq(esgEvaluationResponses.esgEvaluationItemId, data.esgEvaluationItemId), eq(esgEvaluationResponses.isActive, true) ) ) .limit(1); if (existingResponse.length > 0) { // 기존 응답 업데이트 const [updatedResponse] = await tx .update(esgEvaluationResponses) .set({ esgAnswerOptionId: data.esgAnswerOptionId, selectedScore: data.selectedScore.toString(), additionalComments: data.additionalComments || null, updatedAt: new Date(), }) .where(eq(esgEvaluationResponses.id, existingResponse[0].id)) .returning(); return updatedResponse; } else { // 새 응답 생성 const [newResponse] = await tx .insert(esgEvaluationResponses) .values({ submissionId: data.submissionId, esgEvaluationItemId: data.esgEvaluationItemId, esgAnswerOptionId: data.esgAnswerOptionId, selectedScore: data.selectedScore.toString(), additionalComments: data.additionalComments || null, }) .returning(); return newResponse; } }); } catch (error) { console.error('Error saving ESG evaluation response:', error); throw error; } } export async function updateAttachmentStatus(responseId: number) { try { // 활성 첨부파일 개수 확인 const attachmentCount = await db .select({ count: vendorEvaluationAttachments.id }) .from(vendorEvaluationAttachments) .where( and( eq(vendorEvaluationAttachments.generalEvaluationResponseId, responseId), eq(vendorEvaluationAttachments.isActive, true) ) ) const hasAttachments = attachmentCount.length > 0 // 응답 테이블의 hasAttachments 필드 업데이트 await db .update(generalEvaluationResponses) .set({ hasAttachments, updatedAt: new Date() }) .where(eq(generalEvaluationResponses.id, responseId)) return { hasAttachments, count: attachmentCount.length } } catch (error) { console.error('Error updating attachment status:', error) throw error } }