import { eq } from 'drizzle-orm'; import { crmOpportunities } from '@/db/schema'; import { auditAction } from '@/features/foundation/audit-log/service'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; import type { OpportunityMarkCancelledPayload, OpportunityMarkLostPayload, OpportunityMarkNoQuotationPayload, OpportunityMarkWonPayload, OpportunityReopenPayload } from '../api/types'; import { assertOpportunityBelongsToOrganization, type OpportunityAccessContext } from './service'; type OpportunityRow = typeof crmOpportunities.$inferSelect; function isClosed(opportunity: OpportunityRow) { return (opportunity.outcomeStatus ?? 'open') !== 'open'; } function assertCanClose(opportunity: OpportunityRow) { if (opportunity.pipelineStage !== 'opportunity') { throw new AuthError('Opportunity must be in opportunity stage', 400); } if (isClosed(opportunity)) { throw new AuthError('Opportunity is already closed', 400); } } function buildLegacyPipelineStage(outcomeStatus: OpportunityRow['outcomeStatus']) { if (outcomeStatus === 'won') return 'closed_won' as const; if (outcomeStatus === 'lost') return 'closed_lost' as const; return 'opportunity' as const; } async function updateClosedOpportunity( opportunity: OpportunityRow, organizationId: string, userId: string, payload: | { outcomeStatus: 'won'; reason: string | null; action: 'mark_opportunity_won'; values: OpportunityMarkWonPayload } | { outcomeStatus: 'lost'; reason: string; action: 'mark_opportunity_lost'; values: OpportunityMarkLostPayload } | { outcomeStatus: 'cancelled'; reason: string; action: 'mark_opportunity_cancelled'; values: OpportunityMarkCancelledPayload; } | { outcomeStatus: 'no_quotation'; reason: string; action: 'mark_opportunity_no_quotation'; values: OpportunityMarkNoQuotationPayload; } ) { assertCanClose(opportunity); const now = new Date(); const [updated] = await db .update(crmOpportunities) .set({ status: 'closed', outcomeStatus: payload.outcomeStatus, pipelineStage: buildLegacyPipelineStage(payload.outcomeStatus), closedAt: now, closedWonAt: payload.outcomeStatus === 'won' ? now : null, closedLostAt: payload.outcomeStatus === 'lost' ? now : null, closedByUserId: userId, poNumber: payload.outcomeStatus === 'won' ? payload.values.poNumber.trim() : null, poDate: payload.outcomeStatus === 'won' && payload.values.poDate ? new Date(payload.values.poDate) : null, poAmount: payload.outcomeStatus === 'won' ? payload.values.poAmount ?? null : null, poCurrency: payload.outcomeStatus === 'won' ? payload.values.poCurrency?.trim() || null : null, lostReason: payload.outcomeStatus === 'lost' ? payload.values.lostReason : null, lostDetail: payload.outcomeStatus === 'lost' ? payload.values.lostDetail?.trim() || null : null, lostCompetitor: payload.outcomeStatus === 'lost' ? payload.values.lostCompetitor?.trim() || null : null, lostRemark: payload.outcomeStatus === 'lost' ? payload.values.lostRemark?.trim() || null : null, cancelReason: payload.outcomeStatus === 'cancelled' ? payload.values.cancelReason : null, noQuotationReason: payload.outcomeStatus === 'no_quotation' ? payload.values.noQuotationReason : null, updatedAt: now, updatedBy: userId }) .where(eq(crmOpportunities.id, opportunity.id)) .returning(); await auditAction({ organizationId, branchId: updated.branchId, userId, entityType: 'crm_opportunity', entityId: updated.id, action: payload.action, afterData: { previousStage: opportunity.status, previousOutcomeStatus: opportunity.outcomeStatus ?? 'open', newStage: updated.status, newOutcomeStatus: updated.outcomeStatus, reason: payload.reason, actedBy: userId, actedAt: now.toISOString(), closedAt: updated.closedAt?.toISOString() ?? null } }); return updated; } export async function markOpportunityWon( id: string, organizationId: string, userId: string, payload: OpportunityMarkWonPayload, accessContext?: OpportunityAccessContext ) { const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); if (!payload.poNumber.trim()) { throw new AuthError('PO number required', 400); } return updateClosedOpportunity(opportunity, organizationId, userId, { outcomeStatus: 'won', action: 'mark_opportunity_won', reason: payload.remark?.trim() || null, values: payload }); } export async function markOpportunityLost( id: string, organizationId: string, userId: string, payload: OpportunityMarkLostPayload, accessContext?: OpportunityAccessContext ) { const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); return updateClosedOpportunity(opportunity, organizationId, userId, { outcomeStatus: 'lost', action: 'mark_opportunity_lost', reason: payload.lostReason, values: payload }); } export async function markOpportunityCancelled( id: string, organizationId: string, userId: string, payload: OpportunityMarkCancelledPayload, accessContext?: OpportunityAccessContext ) { const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); return updateClosedOpportunity(opportunity, organizationId, userId, { outcomeStatus: 'cancelled', action: 'mark_opportunity_cancelled', reason: payload.cancelReason, values: payload }); } export async function markOpportunityNoQuotation( id: string, organizationId: string, userId: string, payload: OpportunityMarkNoQuotationPayload, accessContext?: OpportunityAccessContext ) { const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); return updateClosedOpportunity(opportunity, organizationId, userId, { outcomeStatus: 'no_quotation', action: 'mark_opportunity_no_quotation', reason: payload.noQuotationReason, values: payload }); } export async function reopenOpportunity( id: string, organizationId: string, userId: string, payload: OpportunityReopenPayload, accessContext?: OpportunityAccessContext ) { const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); if (!isClosed(opportunity)) { throw new AuthError('Only closed opportunities can be reopened', 400); } const now = new Date(); const [updated] = await db .update(crmOpportunities) .set({ status: 'quotation_created', outcomeStatus: 'open', pipelineStage: 'opportunity', closedAt: null, closedWonAt: null, closedLostAt: null, closedByUserId: null, poNumber: null, poDate: null, poAmount: null, poCurrency: null, lostReason: null, lostDetail: null, lostCompetitor: null, lostRemark: null, cancelReason: null, noQuotationReason: null, updatedAt: now, updatedBy: userId }) .where(eq(crmOpportunities.id, opportunity.id)) .returning(); await auditAction({ organizationId, branchId: updated.branchId, userId, entityType: 'crm_opportunity', entityId: updated.id, action: 'reopen_opportunity', afterData: { previousStage: opportunity.status, previousOutcomeStatus: opportunity.outcomeStatus ?? 'open', newStage: updated.status, newOutcomeStatus: updated.outcomeStatus, reason: payload.reopenReason.trim(), actedBy: userId, actedAt: now.toISOString() } }); return updated; }