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

@@ -1,8 +1,8 @@
import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm';
import {
crmCustomers,
crmEnquiries,
crmEnquiryFollowups,
crmOpportunities,
crmOpportunityFollowups,
crmQuotations,
users
} from '@/db/schema';
@@ -89,46 +89,46 @@ export async function listPipelineReportDataset(
}> {
const requiredFilters = buildRequiredFilters(filters);
const { start, end } = getDateRange(requiredFilters);
const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] =
const [branches, productTypes, leadSources, opportunityRows, quotationRows, followupRows] =
await Promise.all([
getUserBranches(),
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
db
.select({
id: crmEnquiries.id,
code: crmEnquiries.code,
customerId: crmEnquiries.customerId,
branchId: crmEnquiries.branchId,
productType: crmEnquiries.productType,
leadChannel: crmEnquiries.leadChannel,
pipelineStage: crmEnquiries.pipelineStage,
createdAt: crmEnquiries.createdAt,
createdBy: crmEnquiries.createdBy,
assignedToUserId: crmEnquiries.assignedToUserId,
estimatedValue: crmEnquiries.estimatedValue,
poAmount: crmEnquiries.poAmount,
closedWonAt: crmEnquiries.closedWonAt,
closedLostAt: crmEnquiries.closedLostAt,
lostReason: crmEnquiries.lostReason,
id: crmOpportunities.id,
code: crmOpportunities.code,
customerId: crmOpportunities.customerId,
branchId: crmOpportunities.branchId,
productType: crmOpportunities.productType,
leadChannel: crmOpportunities.leadChannel,
pipelineStage: crmOpportunities.pipelineStage,
createdAt: crmOpportunities.createdAt,
createdBy: crmOpportunities.createdBy,
assignedToUserId: crmOpportunities.assignedToUserId,
estimatedValue: crmOpportunities.estimatedValue,
poAmount: crmOpportunities.poAmount,
closedWonAt: crmOpportunities.closedWonAt,
closedLostAt: crmOpportunities.closedLostAt,
lostReason: crmOpportunities.lostReason,
customerName: crmCustomers.name,
customerOwnerUserId: crmCustomers.ownerUserId
})
.from(crmEnquiries)
.innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId))
.from(crmOpportunities)
.innerJoin(crmCustomers, eq(crmCustomers.id, crmOpportunities.customerId))
.where(
and(
eq(crmEnquiries.organizationId, context.organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.isActive, true),
eq(crmOpportunities.organizationId, context.organizationId),
isNull(crmOpportunities.deletedAt),
eq(crmOpportunities.isActive, true),
isNull(crmCustomers.deletedAt),
eq(crmCustomers.isActive, true)
)
)
.orderBy(asc(crmEnquiries.createdAt)),
.orderBy(asc(crmOpportunities.createdAt)),
db
.select({
enquiryId: crmQuotations.enquiryId,
opportunityId: crmQuotations.opportunityId,
quotationCount: count(crmQuotations.id),
quotationValue: sql<number>`coalesce(sum(${crmQuotations.totalAmount}), 0)`
})
@@ -138,28 +138,28 @@ export async function listPipelineReportDataset(
eq(crmQuotations.organizationId, context.organizationId),
isNull(crmQuotations.deletedAt),
eq(crmQuotations.isActive, true),
sql`${crmQuotations.enquiryId} is not null`
sql`${crmQuotations.opportunityId} is not null`
)
)
.groupBy(crmQuotations.enquiryId),
.groupBy(crmQuotations.opportunityId),
db
.select({
enquiryId: crmEnquiryFollowups.enquiryId,
lastFollowupDate: sql<Date | null>`max(${crmEnquiryFollowups.followupDate})`
opportunityId: crmOpportunityFollowups.opportunityId,
lastFollowupDate: sql<Date | null>`max(${crmOpportunityFollowups.followupDate})`
})
.from(crmEnquiryFollowups)
.from(crmOpportunityFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
isNull(crmEnquiryFollowups.deletedAt)
eq(crmOpportunityFollowups.organizationId, context.organizationId),
isNull(crmOpportunityFollowups.deletedAt)
)
)
.groupBy(crmEnquiryFollowups.enquiryId)
.groupBy(crmOpportunityFollowups.opportunityId)
]);
const userIds = [
...new Set(
enquiryRows.flatMap((row) => [
opportunityRows.flatMap((row) => [
row.createdBy,
row.assignedToUserId,
row.customerOwnerUserId
@@ -178,19 +178,19 @@ export async function listPipelineReportDataset(
const leadSourceLabelMap = new Map(leadSources.map((item) => [item.id, item.label]));
const quotationMap = new Map(
quotationRows
.filter((row): row is typeof row & { enquiryId: string } => typeof row.enquiryId === 'string')
.filter((row): row is typeof row & { opportunityId: string } => typeof row.opportunityId === 'string')
.map((row) => [
row.enquiryId,
row.opportunityId,
{
quotationCount: Number(row.quotationCount ?? 0),
quotationValue: Number(row.quotationValue ?? 0)
}
])
);
const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate]));
const followupMap = new Map(followupRows.map((row) => [row.opportunityId, row.lastFollowupDate]));
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
const rows = enquiryRows
const rows = opportunityRows
.filter((row) => canAccessRecord(context, row))
.filter((row) => matchesDateRange(row.createdAt, start, end))
.filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true))
@@ -222,8 +222,8 @@ export async function listPipelineReportDataset(
const quotation = quotationMap.get(row.id);
return {
enquiryId: row.id,
enquiryCode: row.code,
opportunityId: row.id,
opportunityCode: row.code,
customerId: row.customerId,
customerName: row.customerName,
branchId: row.branchId,
@@ -260,3 +260,4 @@ export async function listPipelineReportDataset(
rows
};
}

View File

@@ -9,7 +9,7 @@ import type { ResolvedReportContext } from '../types';
const PIPELINE_STAGE_OPTIONS = [
{ id: 'lead', code: 'lead', label: 'Lead' },
{ id: 'enquiry', code: 'enquiry', label: 'Enquiry' },
{ id: 'opportunity', code: 'opportunity', label: 'Opportunity' },
{ id: 'closed_won', code: 'closed_won', label: 'Won' },
{ id: 'closed_lost', code: 'closed_lost', label: 'Lost' }
] as const;
@@ -85,3 +85,4 @@ export function parseSharedReportFilters(searchParams: URLSearchParams): CrmShar
lostReason: searchParams.get('lostReason')
};
}

View File

@@ -7,7 +7,7 @@ import type {
CrmSharedReportFilters,
CrmPipelineReportResponse,
CrmLeadAgingReportResponse,
CrmEnquiryAgingReportResponse
CrmOpportunityAgingReportResponse
} from '../api/types';
import { buildReportCatalogGroups } from './builders/catalog-builder';
import { resolveCrmAccess } from './context';
@@ -49,8 +49,8 @@ function isConvertedLead(row: PipelineDatasetRecord) {
return row.pipelineStage !== 'lead' || Boolean(row.assignedToUserId);
}
function isOpenEnquiry(row: PipelineDatasetRecord) {
return row.pipelineStage === 'enquiry';
function isOpenOpportunity(row: PipelineDatasetRecord) {
return row.pipelineStage === 'opportunity';
}
function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) {
@@ -98,8 +98,8 @@ function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) {
return [...grouped.values()].toSorted((left, right) => right.totalLeads - left.totalLeads);
}
function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
const grouped = new Map<string, CrmPipelineReportResponse['enquiryPipeline'][number]>();
function buildOpportunityPipelineRows(rows: PipelineDatasetRecord[]) {
const grouped = new Map<string, CrmPipelineReportResponse['opportunityPipeline'][number]>();
for (const row of rows) {
const key = [
@@ -115,14 +115,14 @@ function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
branchLabel: row.branchLabel,
productTypeLabel: row.productTypeLabel,
customerOwnerName: row.customerOwnerName,
openEnquiries: 0,
openOpportunities: 0,
convertedToQuotation: 0,
won: 0,
lost: 0
};
if (isOpenEnquiry(row)) {
current.openEnquiries += 1;
if (isOpenOpportunity(row)) {
current.openOpportunities += 1;
}
if (row.quotationCount > 0) {
@@ -142,8 +142,8 @@ function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) {
return [...grouped.values()].toSorted(
(left, right) =>
right.openEnquiries + right.convertedToQuotation + right.won + right.lost -
(left.openEnquiries + left.convertedToQuotation + left.won + left.lost)
right.openOpportunities + right.convertedToQuotation + right.won + right.lost -
(left.openOpportunities + left.convertedToQuotation + left.won + left.lost)
);
}
@@ -165,13 +165,13 @@ function buildLeadConversionRows(rows: PipelineDatasetRecord[]) {
productTypeLabel: row.productTypeLabel,
marketingUserName: row.createdByName,
totalLeads: 0,
convertedEnquiries: 0,
convertedOpportunities: 0,
conversionRate: 0
};
current.totalLeads += 1;
if (isConvertedLead(row)) {
current.convertedEnquiries += 1;
current.convertedOpportunities += 1;
}
grouped.set(key, current);
@@ -180,16 +180,16 @@ function buildLeadConversionRows(rows: PipelineDatasetRecord[]) {
return [...grouped.values()]
.map((row) => ({
...row,
conversionRate: toPercent(row.convertedEnquiries, row.totalLeads)
conversionRate: toPercent(row.convertedOpportunities, row.totalLeads)
}))
.toSorted((left, right) => right.totalLeads - left.totalLeads);
}
function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) {
const enquiryRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId);
const grouped = new Map<string, CrmPipelineReportResponse['enquiryConversion'][number]>();
function buildOpportunityConversionRows(rows: PipelineDatasetRecord[]) {
const opportunityRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId);
const grouped = new Map<string, CrmPipelineReportResponse['opportunityConversion'][number]>();
for (const row of enquiryRows) {
for (const row of opportunityRows) {
const key = [(row.assignedToName ?? 'Unassigned'), row.branchLabel, row.productTypeLabel].join('|');
const current =
grouped.get(key) ??
@@ -197,14 +197,14 @@ function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) {
salesmanName: row.assignedToName ?? 'Unassigned',
branchLabel: row.branchLabel,
productTypeLabel: row.productTypeLabel,
totalEnquiries: 0,
enquiriesWithQuotation: 0,
totalOpportunities: 0,
opportunitiesWithQuotation: 0,
conversionRate: 0
};
current.totalEnquiries += 1;
current.totalOpportunities += 1;
if (row.quotationCount > 0) {
current.enquiriesWithQuotation += 1;
current.opportunitiesWithQuotation += 1;
}
grouped.set(key, current);
@@ -213,14 +213,14 @@ function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) {
return [...grouped.values()]
.map((row) => ({
...row,
conversionRate: toPercent(row.enquiriesWithQuotation, row.totalEnquiries)
conversionRate: toPercent(row.opportunitiesWithQuotation, row.totalOpportunities)
}))
.toSorted((left, right) => right.totalEnquiries - left.totalEnquiries);
.toSorted((left, right) => right.totalOpportunities - left.totalOpportunities);
}
function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: boolean) {
const leadCount = rows.length;
const enquiryRows = rows.filter((row) => isConvertedLead(row));
const opportunityRows = rows.filter((row) => isConvertedLead(row));
const quotationRows = rows.filter((row) => row.quotationCount > 0);
const wonRows = rows.filter((row) => row.pipelineStage === 'closed_won');
const lostRows = rows.filter((row) => row.pipelineStage === 'closed_lost');
@@ -236,18 +236,18 @@ function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: bo
conversionRate: null
},
{
stageKey: 'enquiry' as const,
stageLabel: 'Enquiry',
count: enquiryRows.length,
value: valueOrNull(sumValues(enquiryRows, (row) => row.estimatedValue)),
conversionRate: toPercent(enquiryRows.length, leadCount)
stageKey: 'opportunity' as const,
stageLabel: 'Opportunity',
count: opportunityRows.length,
value: valueOrNull(sumValues(opportunityRows, (row) => row.estimatedValue)),
conversionRate: toPercent(opportunityRows.length, leadCount)
},
{
stageKey: 'quotation' as const,
stageLabel: 'Quotation',
count: quotationRows.length,
value: valueOrNull(sumValues(quotationRows, (row) => row.quotationValue)),
conversionRate: toPercent(quotationRows.length, enquiryRows.length)
conversionRate: toPercent(quotationRows.length, opportunityRows.length)
},
{
stageKey: 'closed_won' as const,
@@ -271,8 +271,8 @@ function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportF
const leadRows = rows
.filter((row) => row.pipelineStage === 'lead')
.map((row) => ({
leadId: row.enquiryId,
leadCode: row.enquiryCode,
leadId: row.opportunityId,
leadCode: row.opportunityCode,
customerName: row.customerName,
assignedUserName: row.assignedToName,
createdDate: row.createdAt.toISOString(),
@@ -302,41 +302,41 @@ function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportF
};
}
function buildEnquiryAging(
function buildOpportunityAging(
rows: PipelineDatasetRecord[],
filters: ParsedCrmReportFilters
): CrmEnquiryAgingReportResponse {
): CrmOpportunityAgingReportResponse {
const now = new Date();
const enquiryRows = rows
.filter((row) => row.pipelineStage === 'enquiry')
const opportunityRows = rows
.filter((row) => row.pipelineStage === 'opportunity')
.map((row) => ({
enquiryId: row.enquiryId,
enquiryCode: row.enquiryCode,
opportunityId: row.opportunityId,
opportunityCode: row.opportunityCode,
customerName: row.customerName,
salesmanName: row.assignedToName,
lastFollowupDate: row.lastFollowupDate?.toISOString() ?? null,
agingDays: getDayDifference(row.createdAt, now)
}))
.toSorted((left, right) => right.agingDays - left.agingDays);
const ages = enquiryRows.map((row) => row.agingDays);
const ages = opportunityRows.map((row) => row.agingDays);
return {
success: true,
time: new Date().toISOString(),
message: 'CRM enquiry aging report loaded successfully',
message: 'CRM opportunity aging report loaded successfully',
filters,
summary: {
openEnquiries: enquiryRows.length,
openOpportunities: opportunityRows.length,
averageAgingDays: average(ages),
maximumAgingDays: ages.length ? Math.max(...ages) : 0
},
buckets: [
{ key: '0_14', label: '0-14 Days', count: enquiryRows.filter((row) => row.agingDays <= 14).length },
{ key: '15_30', label: '15-30 Days', count: enquiryRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length },
{ key: '31_60', label: '31-60 Days', count: enquiryRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length },
{ key: '60_plus', label: '60+ Days', count: enquiryRows.filter((row) => row.agingDays > 60).length }
{ key: '0_14', label: '0-14 Days', count: opportunityRows.filter((row) => row.agingDays <= 14).length },
{ key: '15_30', label: '15-30 Days', count: opportunityRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length },
{ key: '31_60', label: '31-60 Days', count: opportunityRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length },
{ key: '60_plus', label: '60+ Days', count: opportunityRows.filter((row) => row.agingDays > 60).length }
],
rows: enquiryRows
rows: opportunityRows
};
}
@@ -417,16 +417,16 @@ export async function getCrmPipelineReportResponse(
filters: dataset.filters,
summary: {
totalLeads: dataset.rows.length,
openEnquiries: dataset.rows.filter((row) => row.pipelineStage === 'enquiry').length,
openOpportunities: dataset.rows.filter((row) => row.pipelineStage === 'opportunity').length,
quotations: dataset.rows.filter((row) => row.quotationCount > 0).length,
won: dataset.rows.filter((row) => row.pipelineStage === 'closed_won').length,
lost: dataset.rows.filter((row) => row.pipelineStage === 'closed_lost').length,
canViewRevenueValues
},
leadPipeline: buildLeadPipelineRows(dataset.rows),
enquiryPipeline: buildEnquiryPipelineRows(dataset.rows),
opportunityPipeline: buildOpportunityPipelineRows(dataset.rows),
leadConversion: buildLeadConversionRows(dataset.rows),
enquiryConversion: buildEnquiryConversionRows(dataset.rows),
opportunityConversion: buildOpportunityConversionRows(dataset.rows),
funnel: buildFunnelRows(dataset.rows, canViewRevenueValues)
};
@@ -447,14 +447,15 @@ export async function getCrmLeadAgingReportResponse(
return response;
}
export async function getCrmEnquiryAgingReportResponse(
export async function getCrmOpportunityAgingReportResponse(
context: ResolvedReportContext,
filters: CrmSharedReportFilters
): Promise<CrmEnquiryAgingReportResponse> {
): Promise<CrmOpportunityAgingReportResponse> {
const dataset = await listPipelineReportDataset(context, filters);
const response = buildEnquiryAging(dataset.rows, dataset.filters);
const response = buildOpportunityAging(dataset.rows, dataset.filters);
await auditReportView(context, 'enquiry_aging', { ...dataset.filters }, response.rows.length);
await auditReportView(context, 'opportunity_aging', { ...dataset.filters }, response.rows.length);
return response;
}

View File

@@ -39,8 +39,8 @@ export interface ParsedCrmReportFilters {
}
export interface PipelineDatasetRecord {
enquiryId: string;
enquiryCode: string;
opportunityId: string;
opportunityCode: string;
customerId: string;
customerName: string;
branchId: string | null;
@@ -66,3 +66,4 @@ export interface PipelineDatasetRecord {
closedLostAt: Date | null;
lostReason: string | null;
}