task-d.5.6
This commit is contained in:
243
src/features/crm/opportunities/server/lifecycle.service.ts
Normal file
243
src/features/crm/opportunities/server/lifecycle.service.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user