taks-d.2.1

This commit is contained in:
phaichayon
2026-06-17 14:46:52 +07:00
parent 0a484e0b45
commit 5be6c54272
39 changed files with 6254 additions and 316 deletions

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

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