From 12e936c0b45ffa1c8f3c02ff77961212767be9a7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 26 Aug 2025 01:17:56 +0000 Subject: (대표님) 가입, 기본계약, 벤더 (최겸) 기술영업 아이템 관련 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 506 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 506 insertions(+) (limited to 'lib/basic-contract/service.ts') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 64a50d14..58463f16 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -11,9 +11,18 @@ import { BasicContractTemplate, basicContractTemplates, basicContractView, + complianceQuestionOptions, + complianceQuestions, + complianceResponseAnswers, + complianceResponseFiles, + complianceResponses, + complianceSurveyTemplates, vendorAttachments, vendors, type BasicContractTemplate as DBBasicContractTemplate, + type NewComplianceResponse, + type NewComplianceResponseAnswer, + type NewComplianceResponseFile } from "@/db/schema"; import { @@ -1172,3 +1181,500 @@ export async function getVendorAttachments(vendorId: number) { }; } } + +// 설문조사 템플릿 전체 데이터 타입 +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(): Promise { + try { + // 1. 활성화된 첫 번째 템플릿 가져오기 + const template = await db + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.isActive, true)) + .orderBy(complianceSurveyTemplates.id) + .limit(1); + + if (!template || template.length === 0) { + console.log('활성화된 설문조사 템플릿이 없습니다.'); + return null; + } + + const templateData = template[0]; + + // 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 : '파일 업로드에 실패했습니다.' + }; + } +} \ No newline at end of file -- cgit v1.2.3