This commit is contained in:
phaichayon
2026-06-22 16:57:02 +07:00
parent 3f28fde39f
commit ffa5de8311
33 changed files with 6706 additions and 101 deletions

View File

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

View File

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