task-d5.5.1
This commit is contained in:
@@ -14,8 +14,8 @@ import {
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmOpportunities,
|
||||
crmOpportunityCustomers,
|
||||
crmQuotationCustomers,
|
||||
crmQuotations
|
||||
} from '@/db/schema';
|
||||
@@ -41,7 +41,7 @@ interface NormalizedProjectParty {
|
||||
}
|
||||
|
||||
type ProjectPartyTableAvailability = {
|
||||
enquiryCustomers: boolean;
|
||||
opportunityCustomers: boolean;
|
||||
quotationCustomers: boolean;
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ function buildRevenueAttributionFilters(
|
||||
const quotationTypes = splitFilterValue(filters.quotationType);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
const enquiries = splitFilterValue(filters.enquiry);
|
||||
const opportunities = splitFilterValue(filters.opportunity);
|
||||
|
||||
return [
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
@@ -75,7 +75,7 @@ function buildRevenueAttributionFilters(
|
||||
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
|
||||
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
|
||||
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
|
||||
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
|
||||
...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []),
|
||||
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
|
||||
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
|
||||
...(filters.quotationDateFrom ? [gte(crmQuotations.quotationDate, new Date(filters.quotationDateFrom))] : []),
|
||||
@@ -150,9 +150,9 @@ function groupNormalizedParties<TGroupKey extends string>(
|
||||
function pickAttributedParties(
|
||||
roleCode: SupportedRevenueRole,
|
||||
quotationParties: NormalizedProjectParty[],
|
||||
enquiryParties: NormalizedProjectParty[]
|
||||
opportunityParties: NormalizedProjectParty[]
|
||||
) {
|
||||
const authoritativeParties = quotationParties.length > 0 ? quotationParties : enquiryParties;
|
||||
const authoritativeParties = quotationParties.length > 0 ? quotationParties : opportunityParties;
|
||||
|
||||
if (roleCode === 'end_customer') {
|
||||
const endCustomers = authoritativeParties.filter((party) => party.roleCode === 'end_customer');
|
||||
@@ -176,13 +176,13 @@ async function getProjectPartyTableAvailability(): Promise<ProjectPartyTableAvai
|
||||
select table_name as "tableName"
|
||||
from information_schema.tables
|
||||
where table_schema = 'public'
|
||||
and table_name in ('crm_enquiry_customers', 'crm_quotation_customers')
|
||||
and table_name in ('crm_opportunity_customers', 'crm_quotation_customers')
|
||||
`);
|
||||
|
||||
const available = new Set(rows.map((row) => row.tableName));
|
||||
|
||||
projectPartyTableAvailability = {
|
||||
enquiryCustomers: available.has('crm_enquiry_customers'),
|
||||
opportunityCustomers: available.has('crm_opportunity_customers'),
|
||||
quotationCustomers: available.has('crm_quotation_customers')
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@ async function getRevenueByRole(
|
||||
const quotations = await db
|
||||
.select({
|
||||
id: crmQuotations.id,
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
opportunityId: crmQuotations.opportunityId,
|
||||
customerId: crmQuotations.customerId,
|
||||
totalAmount: crmQuotations.totalAmount
|
||||
})
|
||||
@@ -214,9 +214,9 @@ async function getRevenueByRole(
|
||||
}
|
||||
|
||||
const quotationIds = quotations.map((quotation) => quotation.id);
|
||||
const enquiryIds = [...new Set(quotations.map((quotation) => quotation.enquiryId).filter(Boolean))] as string[];
|
||||
const opportunityIds = [...new Set(quotations.map((quotation) => quotation.opportunityId).filter(Boolean))] as string[];
|
||||
|
||||
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
|
||||
const [roleOptions, quotationParties, opportunityParties] = await Promise.all([
|
||||
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
|
||||
tableAvailability.quotationCustomers
|
||||
? db
|
||||
@@ -234,19 +234,19 @@ async function getRevenueByRole(
|
||||
)
|
||||
)
|
||||
: [],
|
||||
tableAvailability.enquiryCustomers && enquiryIds.length
|
||||
tableAvailability.opportunityCustomers && opportunityIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmEnquiryCustomers.enquiryId,
|
||||
customerId: crmEnquiryCustomers.customerId,
|
||||
role: crmEnquiryCustomers.role
|
||||
opportunityId: crmOpportunityCustomers.opportunityId,
|
||||
customerId: crmOpportunityCustomers.customerId,
|
||||
role: crmOpportunityCustomers.role
|
||||
})
|
||||
.from(crmEnquiryCustomers)
|
||||
.from(crmOpportunityCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryCustomers.organizationId, organizationId),
|
||||
inArray(crmEnquiryCustomers.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryCustomers.deletedAt)
|
||||
eq(crmOpportunityCustomers.organizationId, organizationId),
|
||||
inArray(crmOpportunityCustomers.opportunityId, opportunityIds),
|
||||
isNull(crmOpportunityCustomers.deletedAt)
|
||||
)
|
||||
)
|
||||
: []
|
||||
@@ -261,9 +261,9 @@ async function getRevenueByRole(
|
||||
roleOptions
|
||||
);
|
||||
|
||||
const enquiryPartyMap = groupNormalizedParties(
|
||||
enquiryParties.map((row) => ({
|
||||
groupKey: row.enquiryId,
|
||||
const opportunityPartyMap = groupNormalizedParties(
|
||||
opportunityParties.map((row) => ({
|
||||
groupKey: row.opportunityId,
|
||||
customerId: row.customerId,
|
||||
role: row.role
|
||||
})),
|
||||
@@ -275,12 +275,12 @@ async function getRevenueByRole(
|
||||
|
||||
for (const quotation of quotations) {
|
||||
const quotationAttributedParties = quotationPartyMap.get(quotation.id) ?? [];
|
||||
const enquiryAttributedParties = quotation.enquiryId
|
||||
? (enquiryPartyMap.get(quotation.enquiryId) ?? [])
|
||||
const opportunityAttributedParties = quotation.opportunityId
|
||||
? (opportunityPartyMap.get(quotation.opportunityId) ?? [])
|
||||
: [];
|
||||
const attributedParties =
|
||||
quotationAttributedParties.length > 0 || enquiryAttributedParties.length > 0
|
||||
? pickAttributedParties(roleCode, quotationAttributedParties, enquiryAttributedParties)
|
||||
quotationAttributedParties.length > 0 || opportunityAttributedParties.length > 0
|
||||
? pickAttributedParties(roleCode, quotationAttributedParties, opportunityAttributedParties)
|
||||
: roleCode === 'end_customer' || roleCode === 'billing_customer'
|
||||
? [{ customerId: quotation.customerId, roleCode }]
|
||||
: [];
|
||||
@@ -391,15 +391,15 @@ function buildOutcomeRevenueFilters(
|
||||
stage: 'closed_won' | 'closed_lost',
|
||||
filters: OutcomeRevenueFilters
|
||||
): SQL[] {
|
||||
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
|
||||
const dateColumn = stage === 'closed_won' ? crmOpportunities.closedWonAt : crmOpportunities.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)] : []),
|
||||
eq(crmOpportunities.organizationId, organizationId),
|
||||
isNull(crmOpportunities.deletedAt),
|
||||
eq(crmOpportunities.pipelineStage, stage),
|
||||
...(filters.branch ? [eq(crmOpportunities.branchId, filters.branch)] : []),
|
||||
...(filters.salesman ? [eq(crmOpportunities.assignedToUserId, filters.salesman)] : []),
|
||||
...(filters.productType ? [eq(crmOpportunities.productType, filters.productType)] : []),
|
||||
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
|
||||
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
|
||||
];
|
||||
@@ -412,38 +412,38 @@ async function getOutcomeRevenueRecords(
|
||||
): 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);
|
||||
const opportunities = await db.select().from(crmOpportunities).where(where);
|
||||
|
||||
if (!enquiries.length) {
|
||||
if (!opportunities.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enquiryIds = enquiries.map((row) => row.id);
|
||||
const opportunityIds = opportunities.map((row) => row.id);
|
||||
const quotationRows = await db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
opportunityId: crmQuotations.opportunityId,
|
||||
totalAmount: crmQuotations.totalAmount
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||
inArray(crmQuotations.opportunityId, opportunityIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
);
|
||||
const quotationTotals = new Map<string, number>();
|
||||
|
||||
for (const row of quotationRows) {
|
||||
if (!row.enquiryId) {
|
||||
if (!row.opportunityId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
|
||||
quotationTotals.set(row.opportunityId, (quotationTotals.get(row.opportunityId) ?? 0) + row.totalAmount);
|
||||
}
|
||||
|
||||
return enquiries.map((row) => ({
|
||||
enquiryId: row.id,
|
||||
return opportunities.map((row) => ({
|
||||
opportunityId: row.id,
|
||||
customerId: row.customerId,
|
||||
branchId: row.branchId,
|
||||
productType: row.productType,
|
||||
@@ -473,17 +473,17 @@ export async function getLostByReason(
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeBreakdownRow[]> {
|
||||
const records = await getLostRevenue(organizationId, filters);
|
||||
const enquiryIds = records.map((row) => row.enquiryId);
|
||||
const opportunityIds = records.map((row) => row.opportunityId);
|
||||
|
||||
if (!enquiryIds.length) {
|
||||
if (!opportunityIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [enquiries, lostReasonOptions] = await Promise.all([
|
||||
const [opportunities, lostReasonOptions] = await Promise.all([
|
||||
db
|
||||
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
|
||||
.from(crmEnquiries)
|
||||
.where(inArray(crmEnquiries.id, enquiryIds)),
|
||||
.select({ id: crmOpportunities.id, lostReason: crmOpportunities.lostReason })
|
||||
.from(crmOpportunities)
|
||||
.where(inArray(crmOpportunities.id, opportunityIds)),
|
||||
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
|
||||
]);
|
||||
const lostReasonMap = new Map(
|
||||
@@ -495,8 +495,8 @@ export async function getLostByReason(
|
||||
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 opportunity = opportunities.find((item) => item.id === row.opportunityId);
|
||||
const key = opportunity?.lostReason ?? 'unknown';
|
||||
const current = grouped.get(key) ?? {
|
||||
key,
|
||||
label: lostReasonMap.get(key) ?? key,
|
||||
@@ -516,21 +516,21 @@ export async function getLostByCompetitor(
|
||||
filters: OutcomeRevenueFilters = {}
|
||||
): Promise<OutcomeBreakdownRow[]> {
|
||||
const records = await getLostRevenue(organizationId, filters);
|
||||
const enquiryIds = records.map((row) => row.enquiryId);
|
||||
const opportunityIds = records.map((row) => row.opportunityId);
|
||||
|
||||
if (!enquiryIds.length) {
|
||||
if (!opportunityIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const enquiries = await db
|
||||
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
|
||||
.from(crmEnquiries)
|
||||
.where(inArray(crmEnquiries.id, enquiryIds));
|
||||
const opportunities = await db
|
||||
.select({ id: crmOpportunities.id, lostCompetitor: crmOpportunities.lostCompetitor })
|
||||
.from(crmOpportunities)
|
||||
.where(inArray(crmOpportunities.id, opportunityIds));
|
||||
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 opportunity = opportunities.find((item) => item.id === row.opportunityId);
|
||||
const key = opportunity?.lostCompetitor?.trim() || 'unknown';
|
||||
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
|
||||
current.count += 1;
|
||||
current.revenue += row.revenue;
|
||||
@@ -556,3 +556,4 @@ export async function getWinRate(
|
||||
|
||||
return Math.round((wonRows.length / denominator) * 10000) / 100;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface RevenueAttributionFilters {
|
||||
quotationType?: string;
|
||||
branch?: string;
|
||||
customer?: string;
|
||||
enquiry?: string;
|
||||
opportunity?: string;
|
||||
hot?: string;
|
||||
quotationDateFrom?: string | null;
|
||||
quotationDateTo?: string | null;
|
||||
@@ -30,7 +30,7 @@ export interface OutcomeRevenueFilters {
|
||||
}
|
||||
|
||||
export interface OutcomeRevenueRecord {
|
||||
enquiryId: string;
|
||||
opportunityId: string;
|
||||
customerId: string;
|
||||
branchId: string | null;
|
||||
productType: string;
|
||||
@@ -45,3 +45,4 @@ export interface OutcomeBreakdownRow {
|
||||
count: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user