From 18954df6565108a469fb1608ea3715dd9bb1b02d Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 1 Sep 2025 09:12:09 +0000 Subject: (대표님) 구매 기본계약, gtc 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 556 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 500 insertions(+), 56 deletions(-) (limited to 'lib/basic-contract/service.ts') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 194d27eb..8189381b 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -31,7 +31,8 @@ import { type GtcVendorClause, type GtcClause, projects, - legalWorks + legalWorks, + BasicContractView, users } from "@/db/schema"; import path from "path"; @@ -39,6 +40,9 @@ import { GetBasicContractTemplatesSchema, CreateBasicContractTemplateSchema, GetBasciContractsSchema, + GetBasciContractsVendorSchema, + GetBasciContractsByIdSchema, + updateStatusSchema, } from "./validations"; import { readFile } from "fs/promises" @@ -689,8 +693,8 @@ export async function getBasicContractsByVendorId( input: GetBasciContractsVendorSchema, vendorId: number ) { - // return unstable_cache( - // async () => { + return unstable_cache( + async () => { try { const offset = (input.page - 1) * input.perPage; @@ -757,13 +761,13 @@ export async function getBasicContractsByVendorId( // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } - // }, - // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가 - // { - // revalidate: 3600, - // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화 - // } - // )(); + }, + [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가 + { + revalidate: 3600, + tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화 + } + )(); } @@ -2115,10 +2119,10 @@ export async function getVendorGtcData(contractId?: number): Promise 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 clauses = clausesResult.map(clause => { const hasVendorData = !!clause.vendorClauseId; + const negotiationHistory = hasVendorData ? + (negotiationHistoryMap.get(clause.vendorClauseId) || []) : []; - const hasModifications = hasVendorData && ( - clause.isNumberModified || - clause.isCategoryModified || - clause.isSubtitleModified || - clause.isContentModified - ); - - const hasComment = hasVendorData && !!clause.negotiationNote; + // 코멘트가 있는 이력들만 필터링 + const commentHistory = negotiationHistory.filter(h => h.comment); + const latestComment = commentHistory[0]?.comment || null; + const hasComment = commentHistory.length > 0; return { - // 벤더 조항 ID (있는 경우만, 없으면 null) - // id: clause.vendorClauseId, id: clause.baseClauseId, vendorClauseId: clause.vendorClauseId, vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null, @@ -2174,15 +2212,16 @@ export async function getVendorGtcData(contractId?: number): Promise { try { const session = await getServerSession(authOptions); @@ -2228,10 +2267,10 @@ export async function updateVendorClause( } const companyId = session.user.companyId; - const vendorId = companyId; // companyId를 vendorId로 사용 + const vendorId = companyId; const userId = Number(session.user.id); - // 1. 기본 조항 정보 가져오기 (비교용) + // 1. 기본 조항 정보 가져오기 const baseClause = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, baseClauseId), }); @@ -2240,11 +2279,22 @@ export async function updateVendorClause( return { success: false, error: "기본 조항을 찾을 수 없습니다." }; } - // 2. 벤더 문서 ID 확보 (없으면 생성) + // 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, @@ -2268,7 +2318,7 @@ export async function updateVendorClause( return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." }; } - // 3. 수정 여부 확인 + // 4. 수정 여부 확인 const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber; const isCategoryModified = clauseData.category !== baseClause.category; const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle; @@ -2277,7 +2327,7 @@ export async function updateVendorClause( const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified; const hasComment = !!(clauseData.comment?.trim()); - // 4. 벤더 조항 데이터 준비 + // 5. 벤더 조항 데이터 준비 const vendorClauseData = { vendorDocumentId: finalVendorDocumentId, baseClauseId: baseClauseId, @@ -2286,22 +2336,19 @@ export async function updateVendorClause( sortOrder: baseClause.sortOrder, fullPath: baseClause.fullPath, - // 수정된 값들 (수정되지 않았으면 null로 저장) 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, // 수정 이유도 동일하게 저장 + editReason: clauseData.comment?.trim() || null, updatedAt: new Date(), updatedById: userId, @@ -2309,9 +2356,8 @@ export async function updateVendorClause( let finalVendorClauseId = vendorClauseId; - // 5. 벤더 조항 생성 또는 업데이트 + // 6. 벤더 조항 생성 또는 업데이트 if (vendorClauseId) { - // 기존 벤더 조항 업데이트 await db .update(gtcVendorClauses) .set(vendorClauseData) @@ -2319,7 +2365,6 @@ export async function updateVendorClause( console.log(`벤더 조항 업데이트: ${vendorClauseId}`); } else { - // 새 벤더 조항 생성 const newVendorClause = await db.insert(gtcVendorClauses).values({ ...vendorClauseData, createdById: userId, @@ -2333,21 +2378,24 @@ export async function updateVendorClause( console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`); } - // 6. 협의 이력에 기록 - if (hasAnyModifications || hasComment) { - const historyAction = hasAnyModifications ? 'modified' : 'commented'; - const historyComment = hasAnyModifications - ? `조항 수정: ${clauseData.comment || '수정 이유 없음'}` - : clauseData.comment; - + // 7. 협의 이력에 기록 (코멘트가 변경된 경우만) + if (clauseData.comment !== previousComment) { await db.insert(gtcNegotiationHistory).values({ vendorClauseId: finalVendorClauseId, - action: historyAction, - comment: historyComment?.trim(), - actorType: 'vendor', - actorId: session.user.id, + 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 + } + } }); } @@ -2365,7 +2413,6 @@ export async function updateVendorClause( }; } } - // 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경 export async function updateVendorClauseComment( clauseId: number, @@ -2635,6 +2682,140 @@ export async function requestLegalReviewAction( } } +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, @@ -2962,4 +3143,267 @@ export async function getVendorSignatureFile() { error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다." } } -} \ No newline at end of file +} + + + + +// templateName에서 project code 추출 +function extractProjectCodeFromTemplateName(templateName: string): string | null { + if (!templateName.includes('GTC')) return null; + if (templateName.toLowerCase().includes('general')) return null; + + // GTC 앞의 문자열을 추출 + const gtcIndex = templateName.indexOf('GTC'); + if (gtcIndex > 0) { + const beforeGTC = templateName.substring(0, gtcIndex).trim(); + // 마지막 단어를 project code로 간주 + const words = beforeGTC.split(/\s+/); + return words[words.length - 1]; + } + + return null; +} + +// 단일 contract에 대한 GTC 정보 확인 +async function checkGTCCommentsForContract( + templateName: string, + vendorId: number +): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> { + try { + const projectCode = extractProjectCodeFromTemplateName(templateName); + let gtcDocumentId: number | null = null; + + console.log(projectCode,"projectCode") + + // 1. GTC Document ID 찾기 + if (projectCode && projectCode.trim() !== '') { + // Project GTC인 경우 + const project = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode.trim())) + .limit(1) + + if (project.length > 0) { + const projectGtcDoc = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.projectId, project[0].id), + eq(gtcDocuments.isActive, true) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + if (projectGtcDoc.length > 0) { + gtcDocumentId = projectGtcDoc[0].id + } + } + } else { + // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우) + const standardGtcDoc = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.type, "standard"), + eq(gtcDocuments.isActive, true), + isNull(gtcDocuments.projectId) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + if (standardGtcDoc.length > 0) { + gtcDocumentId = standardGtcDoc[0].id + } + } + + console.log(gtcDocumentId,"gtcDocumentId") + + // GTC Document를 찾지 못한 경우 + if (!gtcDocumentId) { + return { gtcDocumentId: null, hasComments: false }; + } + + // 2. 코멘트 존재 여부 확인 + // gtcDocumentId로 해당 벤더의 vendor documents 찾기 + const vendorDocuments = await db + .select({ id: gtcVendorDocuments.id }) + .from(gtcVendorDocuments) + .where( + and( + eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId), + eq(gtcVendorDocuments.vendorId, vendorId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1) + + if (vendorDocuments.length === 0) { + return { gtcDocumentId, hasComments: false }; + } + + // vendor document에 연결된 clauses에서 negotiation history 확인 + const commentsExist = await db + .select({ count: gtcNegotiationHistory.id }) + .from(gtcNegotiationHistory) + .innerJoin( + gtcVendorClauses, + eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id) + ) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id), + eq(gtcVendorClauses.isActive, true), + isNotNull(gtcNegotiationHistory.comment), + ne(gtcNegotiationHistory.comment, '') + ) + ) + .limit(1) + + return { + gtcDocumentId, + hasComments: commentsExist.length > 0 + }; + + } catch (error) { + console.error('Error checking GTC comments for contract:', error); + return { gtcDocumentId: null, hasComments: false }; + } +} + +// 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집 +export async function checkGTCCommentsForContracts( + contracts: BasicContractView[] +): Promise> { + const gtcData: Record = {}; + + // GTC가 포함된 contract만 필터링 + const gtcContracts = contracts.filter(contract => + contract.templateName?.includes('GTC') + ); + + if (gtcContracts.length === 0) { + return gtcData; + } + + // Promise.all을 사용해서 병렬 처리 + const checkPromises = gtcContracts.map(async (contract) => { + try { + const result = await checkGTCCommentsForContract( + contract.templateName!, + contract.vendorId! + ); + + return { + contractId: contract.id, + gtcDocumentId: result.gtcDocumentId, + hasComments: result.hasComments + }; + } catch (error) { + console.error(`Error checking GTC for contract ${contract.id}:`, error); + return { + contractId: contract.id, + gtcDocumentId: null, + hasComments: false + }; + } + }); + + const results = await Promise.all(checkPromises); + + // 결과를 Record 형태로 변환 + results.forEach(({ contractId, gtcDocumentId, hasComments }) => { + gtcData[contractId] = { gtcDocumentId, hasComments }; + }); + + return gtcData; +} + + + +export async function updateVendorDocumentStatus( + formData: FormData | { + status: string; + vendorDocumentId: number; + documentId: number; + vendorId: number; + } +) { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증되지 않은 사용자입니다." } + } + + // 데이터 파싱 + const rawData = formData instanceof FormData + ? { + status: formData.get("status") as string, + vendorDocumentId: Number(formData.get("vendorDocumentId")), + documentId: Number(formData.get("documentId")), + vendorId: Number(formData.get("vendorId")), + } + : formData + + // 유효성 검사 + const validatedData = updateStatusSchema.safeParse(rawData) + if (!validatedData.success) { + return { success: false, error: "유효하지 않은 데이터입니다." } + } + + const { status, vendorDocumentId, documentId, vendorId } = validatedData.data + + // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인 + if (status === "complete") { + // 승인되지 않은 조항 확인 + const pendingClauses = await db + .select({ id: gtcVendorClauses.id }) + .from(gtcVendorClauses) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId), + eq(gtcVendorClauses.isActive, true), + not(eq(gtcVendorClauses.reviewStatus, "approved")), + not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외 + ) + ) + .limit(1) + + if (pendingClauses.length > 0) { + return { + success: false, + error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다." + } + } + } + + // 업데이트 실행 + await db + .update(gtcVendorDocuments) + .set({ + reviewStatus: status, + updatedAt: new Date(), + updatedById: Number(session.user.id), + // 완료 처리 시 협의 종료일 설정 + ...(status === "complete" ? { + negotiationEndDate: new Date(), + approvalDate: new Date() + } : {}) + }) + .where(eq(gtcVendorDocuments.id, vendorDocumentId)) + + // 캐시 무효화 + // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`) + + return { success: true } + } catch (error) { + console.error("Error updating vendor document status:", error) + return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." } + } +} -- cgit v1.2.3