summaryrefslogtreecommitdiff
path: root/lib/bidding/approval-actions.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-18 10:30:31 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-18 10:30:31 +0000
commitc4f5472b961afb237dc819f9dd3f42a7b8f71075 (patch)
treea1c0d00e46a005ff472bf1125e739bae73b0a53e /lib/bidding/approval-actions.ts
parent1d1f6010704a1d655b3007887db0fe3ac866177a (diff)
(최겸) 구매 입찰 수정, 입찰초대 결재 등록, 재입찰, 차수증가, 폐찰, 유찰취소 로직 수정, readonly 추가 등
Diffstat (limited to 'lib/bidding/approval-actions.ts')
-rw-r--r--lib/bidding/approval-actions.ts243
1 files changed, 243 insertions, 0 deletions
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
new file mode 100644
index 00000000..3a82b08f
--- /dev/null
+++ b/lib/bidding/approval-actions.ts
@@ -0,0 +1,243 @@
+/**
+ * 입찰초대 관련 결재 서버 액션
+ *
+ * ✅ 베스트 프랙티스:
+ * - 'use server' 지시어 포함 (서버 액션)
+ * - UI에서 호출하는 진입점 함수들
+ * - withApproval()을 사용하여 결재 프로세스 시작
+ * - 템플릿 변수 준비 및 입력 검증
+ * - 핸들러(Internal)에는 최소 데이터만 전달
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapBiddingInvitationToTemplateVariables } 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;
+}) {
+ // 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,
+ 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.purchaseUnit,
+ 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');
+ const variables = await mapBiddingInvitationToTemplateVariables({
+ bidding,
+ biddingItems: biddingItemsInfo,
+ vendors: data.vendors,
+ message: data.message,
+ 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 배열 (결재선)
+}) {
+ 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: data.currentUser.epId,
+ 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,
+ });
+
+ // 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;
+}