taks-d.2.1
This commit is contained in:
341
src/features/crm/reporting/server/service.ts
Normal file
341
src/features/crm/reporting/server/service.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
lte,
|
||||
ne,
|
||||
or,
|
||||
type SQL
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiryCustomers,
|
||||
crmQuotationCustomers,
|
||||
crmQuotations
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
|
||||
|
||||
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
|
||||
|
||||
interface NormalizedProjectParty {
|
||||
customerId: string;
|
||||
roleCode: string;
|
||||
}
|
||||
|
||||
function splitFilterValue(value?: string) {
|
||||
return value
|
||||
?.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
}
|
||||
|
||||
function buildRevenueAttributionFilters(
|
||||
organizationId: string,
|
||||
filters: RevenueAttributionFilters
|
||||
): SQL[] {
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const quotationTypes = splitFilterValue(filters.quotationType);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
const enquiries = splitFilterValue(filters.enquiry);
|
||||
|
||||
return [
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
isNull(crmQuotations.deletedAt),
|
||||
...(statuses.length
|
||||
? [inArray(crmQuotations.status, statuses)]
|
||||
: filters.includeCancelled
|
||||
? []
|
||||
: [ne(crmQuotations.status, 'cancelled')]),
|
||||
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
|
||||
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
|
||||
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
|
||||
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
|
||||
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
|
||||
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
|
||||
...(filters.quotationDateFrom ? [gte(crmQuotations.quotationDate, new Date(filters.quotationDateFrom))] : []),
|
||||
...(filters.quotationDateTo ? [lte(crmQuotations.quotationDate, new Date(filters.quotationDateTo))] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
ilike(crmQuotations.code, `%${filters.search}%`),
|
||||
ilike(crmQuotations.projectName, `%${filters.search}%`),
|
||||
ilike(crmQuotations.projectLocation, `%${filters.search}%`),
|
||||
ilike(crmQuotations.reference, `%${filters.search}%`),
|
||||
ilike(crmQuotations.competitor, `%${filters.search}%`)
|
||||
)!
|
||||
]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeParties(
|
||||
rows: Array<{ customerId: string; role: string }>,
|
||||
roleOptions: Awaited<ReturnType<typeof getActiveOptionsByCategory>>
|
||||
) {
|
||||
const normalized: NormalizedProjectParty[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
|
||||
|
||||
if (!resolvedRole) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${row.customerId}::${resolvedRole.code}`;
|
||||
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalized.push({
|
||||
customerId: row.customerId,
|
||||
roleCode: resolvedRole.code
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function groupNormalizedParties<TGroupKey extends string>(
|
||||
rows: Array<{ groupKey: TGroupKey; customerId: string; role: string }>,
|
||||
roleOptions: Awaited<ReturnType<typeof getActiveOptionsByCategory>>
|
||||
) {
|
||||
const grouped = new Map<TGroupKey, Array<{ customerId: string; role: string }>>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.groupKey) ?? [];
|
||||
existing.push({
|
||||
customerId: row.customerId,
|
||||
role: row.role
|
||||
});
|
||||
grouped.set(row.groupKey, existing);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...grouped.entries()].map(([groupKey, groupRows]) => [
|
||||
groupKey,
|
||||
normalizeParties(groupRows, roleOptions)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function pickAttributedParties(
|
||||
roleCode: SupportedRevenueRole,
|
||||
quotationParties: NormalizedProjectParty[],
|
||||
enquiryParties: NormalizedProjectParty[]
|
||||
) {
|
||||
const authoritativeParties = quotationParties.length > 0 ? quotationParties : enquiryParties;
|
||||
|
||||
if (roleCode === 'end_customer') {
|
||||
const endCustomers = authoritativeParties.filter((party) => party.roleCode === 'end_customer');
|
||||
|
||||
if (endCustomers.length > 0) {
|
||||
return endCustomers;
|
||||
}
|
||||
|
||||
return authoritativeParties.filter((party) => party.roleCode === 'billing_customer');
|
||||
}
|
||||
|
||||
return authoritativeParties.filter((party) => party.roleCode === roleCode);
|
||||
}
|
||||
|
||||
async function getRevenueByRole(
|
||||
organizationId: string,
|
||||
roleCode: SupportedRevenueRole,
|
||||
filters: RevenueAttributionFilters = {}
|
||||
): Promise<RevenueAttributionSummary[]> {
|
||||
const whereFilters = buildRevenueAttributionFilters(organizationId, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
|
||||
const quotations = await db
|
||||
.select({
|
||||
id: crmQuotations.id,
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
totalAmount: crmQuotations.totalAmount
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(where)
|
||||
.orderBy(asc(crmQuotations.quotationDate), asc(crmQuotations.code));
|
||||
|
||||
if (quotations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const quotationIds = quotations.map((quotation) => quotation.id);
|
||||
const enquiryIds = [...new Set(quotations.map((quotation) => quotation.enquiryId).filter(Boolean))] as string[];
|
||||
|
||||
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
|
||||
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
|
||||
db
|
||||
.select({
|
||||
quotationId: crmQuotationCustomers.quotationId,
|
||||
customerId: crmQuotationCustomers.customerId,
|
||||
role: crmQuotationCustomers.role
|
||||
})
|
||||
.from(crmQuotationCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||
inArray(crmQuotationCustomers.quotationId, quotationIds),
|
||||
isNull(crmQuotationCustomers.deletedAt)
|
||||
)
|
||||
),
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmEnquiryCustomers.enquiryId,
|
||||
customerId: crmEnquiryCustomers.customerId,
|
||||
role: crmEnquiryCustomers.role
|
||||
})
|
||||
.from(crmEnquiryCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryCustomers.organizationId, organizationId),
|
||||
inArray(crmEnquiryCustomers.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryCustomers.deletedAt)
|
||||
)
|
||||
)
|
||||
: []
|
||||
]);
|
||||
|
||||
const quotationPartyMap = groupNormalizedParties(
|
||||
quotationParties.map((row) => ({
|
||||
groupKey: row.quotationId,
|
||||
customerId: row.customerId,
|
||||
role: row.role
|
||||
})),
|
||||
roleOptions
|
||||
);
|
||||
|
||||
const enquiryPartyMap = groupNormalizedParties(
|
||||
enquiryParties.map((row) => ({
|
||||
groupKey: row.enquiryId,
|
||||
customerId: row.customerId,
|
||||
role: row.role
|
||||
})),
|
||||
roleOptions
|
||||
);
|
||||
|
||||
const attributedCustomerIds = new Set<string>();
|
||||
const attributionByQuotation = new Map<string, NormalizedProjectParty[]>();
|
||||
|
||||
for (const quotation of quotations) {
|
||||
const attributedParties = pickAttributedParties(
|
||||
roleCode,
|
||||
quotationPartyMap.get(quotation.id) ?? [],
|
||||
quotation.enquiryId ? (enquiryPartyMap.get(quotation.enquiryId) ?? []) : []
|
||||
);
|
||||
|
||||
attributionByQuotation.set(quotation.id, attributedParties);
|
||||
|
||||
for (const party of attributedParties) {
|
||||
attributedCustomerIds.add(party.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (attributedCustomerIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const customers = await db
|
||||
.select({
|
||||
id: crmCustomers.id,
|
||||
code: crmCustomers.code,
|
||||
name: crmCustomers.name
|
||||
})
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
inArray(crmCustomers.id, [...attributedCustomerIds]),
|
||||
isNull(crmCustomers.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer]));
|
||||
const summaryMap = new Map<string, RevenueAttributionSummary>();
|
||||
|
||||
for (const quotation of quotations) {
|
||||
const attributedParties = attributionByQuotation.get(quotation.id) ?? [];
|
||||
|
||||
for (const party of attributedParties) {
|
||||
const customer = customerMap.get(party.customerId);
|
||||
|
||||
if (!customer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = summaryMap.get(customer.id);
|
||||
|
||||
if (existing) {
|
||||
existing.revenue += quotation.totalAmount;
|
||||
|
||||
if (!existing.quotationIds.includes(quotation.id)) {
|
||||
existing.quotationIds.push(quotation.id);
|
||||
existing.quotationCount += 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
summaryMap.set(customer.id, {
|
||||
customerId: customer.id,
|
||||
customerCode: customer.code,
|
||||
customerName: customer.name,
|
||||
roleCode,
|
||||
revenue: quotation.totalAmount,
|
||||
quotationCount: 1,
|
||||
quotationIds: [quotation.id]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...summaryMap.values()].sort((a, b) => {
|
||||
if (b.revenue !== a.revenue) {
|
||||
return b.revenue - a.revenue;
|
||||
}
|
||||
|
||||
return a.customerName.localeCompare(b.customerName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRevenueByEndCustomer(
|
||||
organizationId: string,
|
||||
filters: RevenueAttributionFilters = {}
|
||||
) {
|
||||
return getRevenueByRole(organizationId, 'end_customer', filters);
|
||||
}
|
||||
|
||||
export async function getRevenueByBillingCustomer(
|
||||
organizationId: string,
|
||||
filters: RevenueAttributionFilters = {}
|
||||
) {
|
||||
return getRevenueByRole(organizationId, 'billing_customer', filters);
|
||||
}
|
||||
|
||||
export async function getRevenueByContractor(
|
||||
organizationId: string,
|
||||
filters: RevenueAttributionFilters = {}
|
||||
) {
|
||||
return getRevenueByRole(organizationId, 'contractor', filters);
|
||||
}
|
||||
|
||||
export async function getRevenueByConsultant(
|
||||
organizationId: string,
|
||||
filters: RevenueAttributionFilters = {}
|
||||
) {
|
||||
return getRevenueByRole(organizationId, 'consultant', filters);
|
||||
}
|
||||
22
src/features/crm/reporting/server/types.ts
Normal file
22
src/features/crm/reporting/server/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface RevenueAttributionFilters {
|
||||
search?: string;
|
||||
status?: string;
|
||||
quotationType?: string;
|
||||
branch?: string;
|
||||
customer?: string;
|
||||
enquiry?: string;
|
||||
hot?: string;
|
||||
quotationDateFrom?: string | null;
|
||||
quotationDateTo?: string | null;
|
||||
includeCancelled?: boolean;
|
||||
}
|
||||
|
||||
export interface RevenueAttributionSummary {
|
||||
customerId: string;
|
||||
customerCode: string;
|
||||
customerName: string;
|
||||
roleCode: string;
|
||||
revenue: number;
|
||||
quotationCount: number;
|
||||
quotationIds: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user