From 2f1bef8eeff5d6cd30c4de808402893deb35335d Mon Sep 17 00:00:00 2001 From: 0-Zz-ang Date: Wed, 19 Nov 2025 09:50:12 +0900 Subject: 준법설문조사 리비전관리 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 7368 ++++++++++++++++++++--------------------- 1 file changed, 3684 insertions(+), 3684 deletions(-) (limited to 'lib/basic-contract/service.ts') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index d7b3edc8..eb3d49f5 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -1,3685 +1,3685 @@ -"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, - vendorContacts, - 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, saveDRMFile } from "@/lib/file-stroage"; -import { decryptWithServerAction } from "@/components/drm/drmUtils"; -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 { - // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해) - const existingTemplate = await db.query.basicContractTemplates.findFirst({ - where: eq(basicContractTemplates.id, id), - }); - - if (!existingTemplate) { - return { error: "템플릿을 찾을 수 없습니다." }; - } - - // 필수값 - const templateName = formData.get("templateName") as string | null; - if (!templateName) { - return { error: "템플릿 이름은 필수입니다." }; - } - - // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지 - const revisionStr = formData.get("revision")?.toString(); - const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision; - - // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인 - if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) { - const duplicateCheck = await db.query.basicContractTemplates.findFirst({ - where: and( - eq(basicContractTemplates.templateName, templateName), - eq(basicContractTemplates.revision, revision), - ne(basicContractTemplates.id, id) // 자기 자신은 제외 - ), - }); - - if (duplicateCheck) { - return { - error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.` - }; - } - } - - 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) 새 파일 저장 (DRM 해제 로직 적용) - const saveResult = await saveDRMFile( - file, - decryptWithServerAction, - 'basicContract/template' - ); - - if (!saveResult.success) { - return { success: false, error: saveResult.error }; - } - fileName = file.name; - filePath = saveResult.publicPath; - - // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨) - 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, - basicContractId?: 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가 없는 경우) - console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`); - 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) - - console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`); - - if (standardGtcDoc.length > 0) { - gtcDocumentId = standardGtcDoc[0].id - console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`); - } else { - console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`); - } - } - - console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`) - - // GTC Document를 찾지 못한 경우 - if (basicContractId) { - console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`); - const { agreementComments } = await import("@/db/schema"); - const newComments = await db - .select({ id: agreementComments.id }) - .from(agreementComments) - .where( - and( - eq(agreementComments.basicContractId, basicContractId), - eq(agreementComments.isDeleted, false) - ) - ) - .limit(1); - - console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`); - - if (newComments.length > 0) { - console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`); - return { - gtcDocumentId, // null일 수 있음 - hasComments: true - }; - } - - console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`); - } - - // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음) - if (!gtcDocumentId) { - console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`); - 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: 'basicContract', - 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 : '문서 저장 중 오류가 발생했습니다' - } - } +"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, + vendorContacts, + 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, saveDRMFile } from "@/lib/file-stroage"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; +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 { + // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해) + const existingTemplate = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, id), + }); + + if (!existingTemplate) { + return { error: "템플릿을 찾을 수 없습니다." }; + } + + // 필수값 + const templateName = formData.get("templateName") as string | null; + if (!templateName) { + return { error: "템플릿 이름은 필수입니다." }; + } + + // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지 + const revisionStr = formData.get("revision")?.toString(); + const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision; + + // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인 + if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) { + const duplicateCheck = await db.query.basicContractTemplates.findFirst({ + where: and( + eq(basicContractTemplates.templateName, templateName), + eq(basicContractTemplates.revision, revision), + ne(basicContractTemplates.id, id) // 자기 자신은 제외 + ), + }); + + if (duplicateCheck) { + return { + error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.` + }; + } + } + + 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) 새 파일 저장 (DRM 해제 로직 적용) + const saveResult = await saveDRMFile( + file, + decryptWithServerAction, + 'basicContract/template' + ); + + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } + fileName = file.name; + filePath = saveResult.publicPath; + + // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨) + 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, + basicContractId?: 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가 없는 경우) + console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`); + 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) + + console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`); + + if (standardGtcDoc.length > 0) { + gtcDocumentId = standardGtcDoc[0].id + console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`); + } else { + console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`); + } + } + + console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`) + + // GTC Document를 찾지 못한 경우 + if (basicContractId) { + console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`); + const { agreementComments } = await import("@/db/schema"); + const newComments = await db + .select({ id: agreementComments.id }) + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ) + .limit(1); + + console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`); + + if (newComments.length > 0) { + console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`); + return { + gtcDocumentId, // null일 수 있음 + hasComments: true + }; + } + + console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`); + } + + // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음) + if (!gtcDocumentId) { + console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`); + 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: 'basicContract', + 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 : '문서 저장 중 오류가 발생했습니다' + } + } } \ No newline at end of file -- cgit v1.2.3