task-d.4
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmQuotationCustomers,
|
||||
crmQuotations
|
||||
@@ -24,7 +25,13 @@ import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
|
||||
import type {
|
||||
OutcomeBreakdownRow,
|
||||
OutcomeRevenueFilters,
|
||||
OutcomeRevenueRecord,
|
||||
RevenueAttributionFilters,
|
||||
RevenueAttributionSummary
|
||||
} from './types';
|
||||
|
||||
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
|
||||
|
||||
@@ -378,3 +385,174 @@ export async function getRevenueByConsultant(
|
||||
) {
|
||||
return getRevenueByRole(organizationId, 'consultant', filters);
|
||||
}
|
||||
|
||||
function buildOutcomeRevenueFilters(
|
||||
organizationId: string,
|
||||
stage: 'closed_won' | 'closed_lost',
|
||||
filters: OutcomeRevenueFilters
|
||||
): SQL[] {
|
||||
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
|
||||
|
||||
return [
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
eq(crmEnquiries.pipelineStage, stage),
|
||||
...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []),
|
||||
...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []),
|
||||
...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []),
|
||||
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
|
||||
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
|
||||
];
|
||||
}
|
||||
|
||||
async function getOutcomeRevenueRecords(
|
||||
organizationId: string,
|
||||
stage: 'closed_won' | 'closed_lost',
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeRevenueRecord[]> {
|
||||
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const enquiries = await db.select().from(crmEnquiries).where(where);
|
||||
|
||||
if (!enquiries.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enquiryIds = enquiries.map((row) => row.id);
|
||||
const quotationRows = await db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
totalAmount: crmQuotations.totalAmount
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
);
|
||||
const quotationTotals = new Map<string, number>();
|
||||
|
||||
for (const row of quotationRows) {
|
||||
if (!row.enquiryId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
|
||||
}
|
||||
|
||||
return enquiries.map((row) => ({
|
||||
enquiryId: row.id,
|
||||
customerId: row.customerId,
|
||||
branchId: row.branchId,
|
||||
productType: row.productType,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
revenue: stage === 'closed_won' ? row.poAmount ?? quotationTotals.get(row.id) ?? 0 : quotationTotals.get(row.id) ?? 0,
|
||||
closedAt:
|
||||
(stage === 'closed_won' ? row.closedWonAt : row.closedLostAt)?.toISOString() ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getWonRevenue(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
) {
|
||||
return getOutcomeRevenueRecords(organizationId, 'closed_won', filters);
|
||||
}
|
||||
|
||||
export async function getLostRevenue(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
) {
|
||||
return getOutcomeRevenueRecords(organizationId, 'closed_lost', filters);
|
||||
}
|
||||
|
||||
export async function getLostByReason(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeBreakdownRow[]> {
|
||||
const records = await getLostRevenue(organizationId, filters);
|
||||
const enquiryIds = records.map((row) => row.enquiryId);
|
||||
|
||||
if (!enquiryIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [enquiries, lostReasonOptions] = await Promise.all([
|
||||
db
|
||||
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
|
||||
.from(crmEnquiries)
|
||||
.where(inArray(crmEnquiries.id, enquiryIds)),
|
||||
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
|
||||
]);
|
||||
const lostReasonMap = new Map(
|
||||
lostReasonOptions.flatMap((item) => [
|
||||
[item.id, item.label] as const,
|
||||
[item.code, item.label] as const
|
||||
])
|
||||
);
|
||||
const grouped = new Map<string, OutcomeBreakdownRow>();
|
||||
|
||||
for (const row of records) {
|
||||
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
|
||||
const key = enquiry?.lostReason ?? 'unknown';
|
||||
const current = grouped.get(key) ?? {
|
||||
key,
|
||||
label: lostReasonMap.get(key) ?? key,
|
||||
count: 0,
|
||||
revenue: 0
|
||||
};
|
||||
current.count += 1;
|
||||
current.revenue += row.revenue;
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
|
||||
}
|
||||
|
||||
export async function getLostByCompetitor(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeBreakdownRow[]> {
|
||||
const records = await getLostRevenue(organizationId, filters);
|
||||
const enquiryIds = records.map((row) => row.enquiryId);
|
||||
|
||||
if (!enquiryIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enquiries = await db
|
||||
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
|
||||
.from(crmEnquiries)
|
||||
.where(inArray(crmEnquiries.id, enquiryIds));
|
||||
const grouped = new Map<string, OutcomeBreakdownRow>();
|
||||
|
||||
for (const row of records) {
|
||||
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
|
||||
const key = enquiry?.lostCompetitor?.trim() || 'unknown';
|
||||
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
|
||||
current.count += 1;
|
||||
current.revenue += row.revenue;
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
|
||||
}
|
||||
|
||||
export async function getWinRate(
|
||||
organizationId: string,
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
) {
|
||||
const [wonRows, lostRows] = await Promise.all([
|
||||
getWonRevenue(organizationId, filters),
|
||||
getLostRevenue(organizationId, filters)
|
||||
]);
|
||||
const denominator = wonRows.length + lostRows.length;
|
||||
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((wonRows.length / denominator) * 10000) / 100;
|
||||
}
|
||||
|
||||
@@ -20,3 +20,28 @@ export interface RevenueAttributionSummary {
|
||||
quotationCount: number;
|
||||
quotationIds: string[];
|
||||
}
|
||||
|
||||
export interface OutcomeRevenueFilters {
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
branch?: string | null;
|
||||
salesman?: string | null;
|
||||
productType?: string | null;
|
||||
}
|
||||
|
||||
export interface OutcomeRevenueRecord {
|
||||
enquiryId: string;
|
||||
customerId: string;
|
||||
branchId: string | null;
|
||||
productType: string;
|
||||
assignedToUserId: string | null;
|
||||
revenue: number;
|
||||
closedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OutcomeBreakdownRow {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user