/** * 입찰초대 관련 결재 서버 액션 * * ✅ 베스트 프랙티스: * - 'use server' 지시어 포함 (서버 액션) * - UI에서 호출하는 진입점 함수들 * - withApproval()을 사용하여 결재 프로세스 시작 * - 템플릿 변수 준비 및 입력 검증 * - 핸들러(Internal)에는 최소 데이터만 전달 */ 'use server'; import { ApprovalSubmissionSaga } from '@/lib/approval'; import { mapBiddingInvitationToTemplateVariables, mapBiddingClosureToTemplateVariables, mapBiddingAwardToTemplateVariables } from './handlers'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** * 입찰초대 결재를 거쳐 입찰등록을 처리하는 서버 액션 * * ✅ 사용법 (클라이언트 컴포넌트에서): * ```typescript * const result = await requestBiddingInvitationWithApproval({ * biddingId: 123, * vendors: [...], * message: "입찰 초대 메시지", * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, * approvers: ['EP002', 'EP003'] * }); * * if (result.status === 'pending_approval') { * toast.success(`입찰초대 결재가 상신되었습니다. (ID: ${result.approvalId})`); * } * ``` */ /** * 입찰초대 결재를 위한 공통 데이터 준비 헬퍼 함수 */ export async function prepareBiddingApprovalData(data: { biddingId: number; vendors: Array<{ vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string; vendorEmail?: string | null; contactPerson?: string | null; contactEmail?: string | null; ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; biddingCompanyId: number; biddingId: number; }>; message?: string; specificationMeeting?: { meetingDate: string | null; meetingTime: string | null; location: string | null; address: string | null; contactPerson: string | null; contactPhone: string | null; contactEmail: string | null; agenda: string | null; materials: string | null; notes: string | null; }; }) { // 1. 입찰 정보 조회 (템플릿 변수용) debugLog('[BiddingInvitationApproval] 입찰 정보 조회 시작'); const { default: db } = await import('@/db/db'); const { biddings, prItemsForBidding } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); const biddingInfo = await db .select({ id: biddings.id, title: biddings.title, biddingNumber: biddings.biddingNumber, projectName: biddings.projectName, itemName: biddings.itemName, biddingType: biddings.biddingType, awardCount: biddings.awardCount, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, submissionStartDate: biddings.submissionStartDate, submissionEndDate: biddings.submissionEndDate, hasSpecificationMeeting: biddings.hasSpecificationMeeting, isUrgent: biddings.isUrgent, remarks: biddings.remarks, targetPrice: biddings.targetPrice, }) .from(biddings) .where(eq(biddings.id, data.biddingId)) .limit(1); if (biddingInfo.length === 0) { debugError('[BiddingInvitationApproval] 입찰 정보를 찾을 수 없음'); throw new Error('입찰 정보를 찾을 수 없습니다'); } const bidding = biddingInfo[0]; // 입찰 대상 자재 정보 조회 const biddingItemsInfo = await db .select({ id: prItemsForBidding.id, projectName: prItemsForBidding.projectInfo, materialGroup: prItemsForBidding.materialGroupNumber, materialGroupName: prItemsForBidding.materialGroupInfo, materialCode: prItemsForBidding.materialNumber, materialCodeName: prItemsForBidding.materialInfo, quantity: prItemsForBidding.quantity, purchasingUnit: prItemsForBidding.priceUnit, targetUnitPrice: prItemsForBidding.targetUnitPrice, quantityUnit: prItemsForBidding.quantityUnit, totalWeight: prItemsForBidding.totalWeight, weightUnit: prItemsForBidding.weightUnit, budget: prItemsForBidding.budgetAmount, targetAmount: prItemsForBidding.targetAmount, currency: prItemsForBidding.targetCurrency, }) .from(prItemsForBidding) .where(eq(prItemsForBidding.biddingId, data.biddingId)); debugLog('[BiddingInvitationApproval] 입찰 정보 조회 완료', { biddingId: bidding.id, title: bidding.title, itemCount: biddingItemsInfo.length, }); // 2. 템플릿 변수 매핑 debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 시작'); const requestedAt = new Date(); const { mapBiddingInvitationToTemplateVariables } = await import('./handlers'); // 사양설명회 정보가 전달되지 않았는데 입찰에 사양설명회가 있는 경우 DB에서 조회 let specMeetingInfo = data.specificationMeeting; if (!specMeetingInfo && bidding.hasSpecificationMeeting) { const { specificationMeetings } = await import('@/db/schema'); const meetings = await db .select() .from(specificationMeetings) .where(eq(specificationMeetings.biddingId, data.biddingId)) .limit(1); if (meetings.length > 0) { const m = meetings[0]; specMeetingInfo = { meetingDate: m.meetingDate ? m.meetingDate.toISOString() : null, meetingTime: m.meetingTime, location: m.location, address: m.address, contactPerson: m.contactPerson, contactPhone: m.contactPhone, contactEmail: m.contactEmail, agenda: m.agenda, materials: m.materials, notes: m.notes }; } } const variables = await mapBiddingInvitationToTemplateVariables({ bidding: { ...bidding, projectName: bidding.projectName || undefined, itemName: bidding.itemName || undefined, awardCount: bidding.awardCount || undefined, bidPicName: bidding.bidPicName || undefined, supplyPicName: bidding.supplyPicName || undefined, targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined, remarks: bidding.remarks || undefined, submissionStartDate: bidding.submissionStartDate || undefined, submissionEndDate: bidding.submissionEndDate || undefined, hasSpecificationMeeting: bidding.hasSpecificationMeeting || undefined, isUrgent: bidding.isUrgent || undefined, }, biddingItems: biddingItemsInfo.map(item => ({ ...item, projectName: item.projectName || undefined, materialGroup: item.materialGroup || undefined, materialGroupName: item.materialGroupName || undefined, materialCode: item.materialCode || undefined, materialCodeName: item.materialCodeName || undefined, quantity: item.quantity ? Number(item.quantity) : undefined, purchasingUnit: item.purchasingUnit || undefined, targetUnitPrice: item.targetUnitPrice ? Number(item.targetUnitPrice) : undefined, quantityUnit: item.quantityUnit || undefined, totalWeight: item.totalWeight ? Number(item.totalWeight) : undefined, weightUnit: item.weightUnit || undefined, budget: item.budget ? Number(item.budget) : undefined, targetAmount: item.targetAmount ? Number(item.targetAmount) : undefined, currency: item.currency || undefined, })), vendors: data.vendors, message: data.message, specificationMeeting: specMeetingInfo, requestedAt, }); debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 완료', { variableKeys: Object.keys(variables), }); return { bidding, biddingItems: biddingItemsInfo, variables, }; } export async function requestBiddingInvitationWithApproval(data: { biddingId: number; vendors: Array<{ vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string; vendorEmail?: string | null; contactPerson?: string | null; contactEmail?: string | null; ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; biddingCompanyId: number; biddingId: number; }>; message?: string; currentUser: { id: number; epId: string | null; email?: string }; approvers?: string[]; // Knox EP ID 배열 (결재선) specificationMeeting?: { meetingDate: string | null; meetingTime: string | null; location: string | null; address: string | null; contactPerson: string | null; contactPhone: string | null; contactEmail: string | null; agenda: string | null; materials: string | null; notes: string | null; }; }) { debugLog('[BiddingInvitationApproval] 입찰초대 결재 서버 액션 시작', { biddingId: data.biddingId, vendorCount: data.vendors.length, userId: data.currentUser.id, hasEpId: !!data.currentUser.epId, }); // 1. 입력 검증 if (!data.currentUser.epId) { debugError('[BiddingInvitationApproval] Knox EP ID 없음'); throw new Error('Knox EP ID가 필요합니다'); } if (data.vendors.length === 0) { debugError('[BiddingInvitationApproval] 선정된 업체 없음'); throw new Error('입찰 초대할 업체를 선택해주세요'); } // 2. 입찰 상태를 결재 진행중으로 변경 debugLog('[BiddingInvitationApproval] 입찰 상태 변경 시작'); const { default: db } = await import('@/db/db'); const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); await db .update(biddings) .set({ status: 'approval_pending', // 결재 진행중 상태 // updatedBy: String(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리 updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); debugLog('[BiddingInvitationApproval] 입찰 상태 변경 완료', { biddingId: data.biddingId, newStatus: 'approval_pending' }); // 3. 결재 데이터 준비 const { bidding, biddingItems: biddingItemsInfo, variables } = await prepareBiddingApprovalData({ biddingId: data.biddingId, vendors: data.vendors, message: data.message, specificationMeeting: data.specificationMeeting, }); // 4. 결재 워크플로우 시작 (Saga 패턴) debugLog('[BiddingInvitationApproval] ApprovalSubmissionSaga 생성'); const saga = new ApprovalSubmissionSaga( // actionType: 핸들러를 찾을 때 사용할 키 'bidding_invitation', // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) { biddingId: data.biddingId, vendors: data.vendors, message: data.message, currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 }, // approvalConfig: 결재 상신 정보 (템플릿 포함) { title: `입찰초대 - ${bidding.title}`, description: `${bidding.title} 입찰 초대 결재`, templateName: '입찰초대 결재', // 한국어 템플릿명 variables, // 치환할 변수들 approvers: data.approvers, currentUser: data.currentUser, } ); debugLog('[BiddingInvitationApproval] Saga 실행 시작'); const result = await saga.execute(); debugSuccess('[BiddingInvitationApproval] 입찰초대 결재 워크플로우 완료', { approvalId: result.approvalId, pendingActionId: result.pendingActionId, status: result.status, }); return result; } /** * 폐찰 결재를 거쳐 입찰 폐찰을 처리하는 서버 액션 * * ✅ 사용법 (클라이언트 컴포넌트에서): * ```typescript * const result = await requestBiddingClosureWithApproval({ * biddingId: 123, * description: "폐찰 사유", * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, * approvers: ['EP002', 'EP003'] * }); * * if (result.status === 'pending_approval') { * toast.success(`폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); * } * ``` */ /** * 폐찰 결재를 위한 공통 데이터 준비 헬퍼 함수 */ export async function prepareBiddingClosureApprovalData(data: { biddingId: number; description: string; }) { // 1. 입찰 정보 조회 (템플릿 변수용) debugLog('[BiddingClosureApproval] 입찰 정보 조회 시작'); const { default: db } = await import('@/db/db'); const { biddings, prItemsForBidding, biddingCompanies, vendors } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); const biddingInfo = await db .select({ id: biddings.id, title: biddings.title, biddingNumber: biddings.biddingNumber, projectName: biddings.projectName, itemName: biddings.itemName, biddingType: biddings.biddingType, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, targetPrice: biddings.targetPrice, }) .from(biddings) .where(eq(biddings.id, data.biddingId)) .limit(1); if (biddingInfo.length === 0) { debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음'); throw new Error('입찰 정보를 찾을 수 없습니다'); } const bidding = biddingInfo[0]; // 입찰 대상 자재 정보 조회 const biddingItemsInfo = await db .select({ id: prItemsForBidding.id, materialCode: prItemsForBidding.materialNumber, materialCodeName: prItemsForBidding.materialInfo, quantity: prItemsForBidding.quantity, quantityUnit: prItemsForBidding.quantityUnit, targetUnitPrice: prItemsForBidding.targetUnitPrice, currency: prItemsForBidding.targetCurrency, }) .from(prItemsForBidding) .where(eq(prItemsForBidding.biddingId, data.biddingId)); // 입찰 참여 업체 정보 조회 const vendorSubmissions = await db .select({ vendorId: vendors.id, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, finalQuoteAmount: biddingCompanies.finalQuoteAmount, submitted: biddingCompanies.isBiddingParticipated, targetPrice: biddingCompanies.preQuoteAmount, // 사전견적 금액을 내정가로 사용 }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) .where(eq(biddingCompanies.biddingId, data.biddingId)); debugLog('[BiddingClosureApproval] 입찰 정보 조회 완료', { biddingId: data.biddingId, title: bidding.title, itemCount: biddingItemsInfo.length, vendorCount: vendorSubmissions.length, }); // 2. 템플릿 변수 매핑 debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 시작'); const requestedAt = new Date(); const { mapBiddingClosureToTemplateVariables } = await import('./handlers'); const variables = await mapBiddingClosureToTemplateVariables({ bidding, biddingItems: biddingItemsInfo, vendorSubmissions, description: data.description, requestedAt, }); debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 완료', { variableKeys: Object.keys(variables), }); return { bidding, biddingItems: biddingItemsInfo, variables, }; } export async function requestBiddingClosureWithApproval(data: { biddingId: number; description: string; files?: File[]; currentUser: { id: number; epId: string | null; email?: string }; approvers?: string[]; // Knox EP ID 배열 (결재선) }) { debugLog('[BiddingClosureApproval] 폐찰 결재 서버 액션 시작', { biddingId: data.biddingId, description: data.description, userId: data.currentUser.id, hasEpId: !!data.currentUser.epId, }); // 1. 입력 검증 if (!data.currentUser.epId) { debugError('[BiddingClosureApproval] Knox EP ID 없음'); throw new Error('Knox EP ID가 필요합니다'); } if (!data.description.trim()) { debugError('[BiddingClosureApproval] 폐찰 사유 없음'); throw new Error('폐찰 사유를 입력해주세요'); } // 2. DB 및 스키마 import (입찰초대 결재 로직 패턴과 동일) debugLog('[BiddingClosureApproval] DB 연결 및 스키마 import'); const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); // 유찰상태인지 확인 const biddingResult = await db .select() .from(biddings) .where(eq(biddings.id, data.biddingId)) .limit(1); if (biddingResult.length === 0) { debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음'); throw new Error('입찰 정보를 찾을 수 없습니다'); } const bidding = biddingResult[0]; if (bidding.status !== 'bidding_disposal') { debugError('[BiddingClosureApproval] 유찰 상태가 아닙니다.'); throw new Error('유찰 상태인 입찰만 폐찰할 수 있습니다.'); } // 3. 입찰 상태를 결재 진행중으로 변경 debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); await db .update(biddings) .set({ status: 'approval_pending', // 폐찰 결재 진행중 상태 // updatedBy: Number(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리 updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); debugLog('[BiddingClosureApproval] 입찰 상태 변경 완료', { biddingId: data.biddingId, newStatus: 'approval_pending' }); // 3. 결재 데이터 준비 const { bidding: approvalBidding, biddingItems, variables } = await prepareBiddingClosureApprovalData({ biddingId: data.biddingId, description: data.description, }); // 4. 결재 워크플로우 시작 (Saga 패턴) debugLog('[BiddingClosureApproval] ApprovalSubmissionSaga 생성'); const saga = new ApprovalSubmissionSaga( // actionType: 핸들러를 찾을 때 사용할 키 'bidding_closure', // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) { biddingId: data.biddingId, description: data.description, files: data.files, currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 }, // approvalConfig: 결재 상신 정보 (템플릿 포함) { title: `폐찰 - ${approvalBidding.title}`, description: `${approvalBidding.title} 입찰 폐찰 결재`, templateName: '폐찰 품의 요청서', // 한국어 템플릿명 variables, // 치환할 변수들 approvers: data.approvers, currentUser: data.currentUser, } ); debugLog('[BiddingClosureApproval] Saga 실행 시작'); const result = await saga.execute(); debugSuccess('[BiddingClosureApproval] 폐찰 결재 워크플로우 완료', { approvalId: result.approvalId, pendingActionId: result.pendingActionId, status: result.status, }); return result; } /** * 낙찰 결재를 거쳐 입찰 낙찰을 처리하는 서버 액션 * * ✅ 사용법 (클라이언트 컴포넌트에서): * ```typescript * const result = await requestBiddingAwardWithApproval({ * biddingId: 123, * selectionReason: "낙찰 사유", * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, * approvers: ['EP002', 'EP003'] * }); * * if (result.status === 'pending_approval') { * toast.success(`낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); * } * ``` */ /** * 낙찰할 업체 정보 조회 헬퍼 함수 */ export async function getAwardedCompaniesForApproval(biddingId: number) { const { default: db } = await import('@/db/db'); const { biddingCompanies, vendors } = await import('@/db/schema'); const { eq, and } = await import('drizzle-orm'); const awardedCompanies = await db .select({ companyId: biddingCompanies.companyId, companyName: vendors.vendorName, finalQuoteAmount: biddingCompanies.finalQuoteAmount, awardRatio: biddingCompanies.awardRatio }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) .where(and( eq(biddingCompanies.biddingId, biddingId), eq(biddingCompanies.isWinner, true) )); return awardedCompanies.map(company => ({ companyId: company.companyId, companyName: company.companyName || '', finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'), awardRatio: parseFloat(company.awardRatio?.toString() || '0') })); } /** * 낙찰 결재를 위한 공통 데이터 준비 헬퍼 함수 */ export async function prepareBiddingAwardApprovalData(data: { biddingId: number; selectionReason: string; awardedCompanies?: Array<{ companyId: number; companyName: string | null; finalQuoteAmount: number; awardRatio: number; }>; }) { // 1. 입찰 정보 조회 (템플릿 변수용) debugLog('[BiddingAwardApproval] 입찰 정보 조회 시작'); const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); const biddingInfo = await db .select({ id: biddings.id, title: biddings.title, }) .from(biddings) .where(eq(biddings.id, data.biddingId)) .limit(1); if (biddingInfo.length === 0) { debugError('[BiddingAwardApproval] 입찰 정보를 찾을 수 없음'); throw new Error('입찰 정보를 찾을 수 없습니다'); } debugLog('[BiddingAwardApproval] 입찰 정보 조회 완료', { biddingId: data.biddingId, title: biddingInfo[0].title, }); // 낙찰할 업체 정보 조회 (파라미터로 제공되지 않은 경우) const awardedCompanies = data.awardedCompanies || await getAwardedCompaniesForApproval(data.biddingId); if (awardedCompanies.length === 0) { debugError('[BiddingAwardApproval] 낙찰된 업체가 없습니다.'); throw new Error('낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.'); } // 2. 템플릿 변수 매핑 debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 시작'); const requestedAt = new Date(); const { mapBiddingAwardToTemplateVariables } = await import('./handlers'); const variables = await mapBiddingAwardToTemplateVariables({ biddingId: data.biddingId, selectionReason: data.selectionReason, requestedAt, awardedCompanies, // 낙찰 업체 정보 전달 }); debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 완료', { variableKeys: Object.keys(variables), }); return { bidding: biddingInfo[0], variables, }; } export async function requestBiddingAwardWithApproval(data: { biddingId: number; selectionReason: string; awardedCompanies: Array<{ companyId: number; companyName: string | null; finalQuoteAmount: number; awardRatio: number; }>; currentUser: { id: number; epId: string | null; email?: string }; approvers?: string[]; // Knox EP ID 배열 (결재선) }) { debugLog('[BiddingAwardApproval] 낙찰 결재 서버 액션 시작', { biddingId: data.biddingId, selectionReason: data.selectionReason, userId: data.currentUser.id, hasEpId: !!data.currentUser.epId, }); // 1. 입력 검증 if (!data.currentUser.epId) { debugError('[BiddingAwardApproval] Knox EP ID 없음'); throw new Error('Knox EP ID가 필요합니다'); } if (!data.selectionReason.trim()) { debugError('[BiddingAwardApproval] 낙찰 사유 없음'); throw new Error('낙찰 사유를 입력해주세요'); } // 2. 입찰 상태를 결재 진행중으로 변경 debugLog('[BiddingAwardApproval] 입찰 상태 변경 시작'); const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); await db .update(biddings) .set({ status: 'approval_pending', // 낙찰 결재 진행중 상태 // updatedBy: Number(data.currentUser.id), // 기존 등록자 유지를 위해 주석 처리 updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); debugLog('[BiddingAwardApproval] 입찰 상태 변경 완료', { biddingId: data.biddingId, newStatus: 'approval_pending' }); // 3. 결재 데이터 준비 const { bidding, variables } = await prepareBiddingAwardApprovalData({ biddingId: data.biddingId, selectionReason: data.selectionReason, awardedCompanies: data.awardedCompanies, }); // 4. 결재 워크플로우 시작 (Saga 패턴) debugLog('[BiddingAwardApproval] ApprovalSubmissionSaga 생성'); const saga = new ApprovalSubmissionSaga( // actionType: 핸들러를 찾을 때 사용할 키 'bidding_award', // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) { biddingId: data.biddingId, selectionReason: data.selectionReason, awardedCompanies: data.awardedCompanies, // ✅ 결재 상신 시점의 낙찰 대상 정보 currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 }, // approvalConfig: 결재 상신 정보 (템플릿 포함) { title: `낙찰 - ${bidding.title}`, description: `${bidding.title} 입찰 낙찰 결재`, templateName: '입찰 결과 업체 선정 품의 요청서', // 한국어 템플릿명 variables, // 치환할 변수들 approvers: data.approvers, currentUser: data.currentUser, } ); debugLog('[BiddingAwardApproval] Saga 실행 시작'); const result = await saga.execute(); debugSuccess('[BiddingAwardApproval] 낙찰 결재 워크플로우 완료', { approvalId: result.approvalId, pendingActionId: result.pendingActionId, status: result.status, }); return result; }