task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

View File

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

View File

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