task-d.5.6

This commit is contained in:
phaichayon
2026-06-25 13:09:21 +07:00
parent c2a74b6764
commit 65dd0e2e56
27 changed files with 1583 additions and 179 deletions

View 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;
}