task-k.1
This commit is contained in:
21
src/features/crm/reports/server/builders/catalog-builder.ts
Normal file
21
src/features/crm/reports/server/builders/catalog-builder.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CrmReportCatalogGroup, CrmReportDefinitionRecord } from '../../api/types';
|
||||
|
||||
export function buildReportCatalogGroups(
|
||||
items: CrmReportDefinitionRecord[]
|
||||
): CrmReportCatalogGroup[] {
|
||||
const grouped = new Map<string, CrmReportCatalogGroup>();
|
||||
|
||||
for (const item of items) {
|
||||
const current =
|
||||
grouped.get(item.category) ??
|
||||
{
|
||||
category: item.category,
|
||||
categoryLabel: item.categoryLabel,
|
||||
items: []
|
||||
};
|
||||
current.items.push(item);
|
||||
grouped.set(item.category, current);
|
||||
}
|
||||
|
||||
return [...grouped.values()];
|
||||
}
|
||||
33
src/features/crm/reports/server/context.ts
Normal file
33
src/features/crm/reports/server/context.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ResolvedReportContext } from './types';
|
||||
|
||||
export function resolveCrmAccess(input: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membership: {
|
||||
role: string;
|
||||
businessRole: string;
|
||||
businessRoles?: string[];
|
||||
permissions?: string[] | null;
|
||||
branchScopeIds?: string[] | null;
|
||||
productTypeScopeIds?: string[] | null;
|
||||
ownershipScope?: string | null;
|
||||
branchScopeMode?: string | null;
|
||||
productScopeMode?: string | null;
|
||||
approvalAuthority?: string | null;
|
||||
};
|
||||
}): ResolvedReportContext {
|
||||
return {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
membershipRole: input.membership.role,
|
||||
businessRole: input.membership.businessRole,
|
||||
businessRoles: input.membership.businessRoles ?? [input.membership.businessRole],
|
||||
permissions: input.membership.permissions ?? [],
|
||||
branchScopeIds: input.membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: input.membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: input.membership.branchScopeMode ?? 'all',
|
||||
productScopeMode: input.membership.productScopeMode ?? 'all',
|
||||
approvalAuthority: input.membership.approvalAuthority ?? null
|
||||
};
|
||||
}
|
||||
42
src/features/crm/reports/server/datasets/catalog.ts
Normal file
42
src/features/crm/reports/server/datasets/catalog.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { crmReportDefinitions } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type { ReportRegistryRow, ResolvedReportContext } from '../types';
|
||||
|
||||
export async function listReportRegistry(
|
||||
context: ResolvedReportContext
|
||||
): Promise<ReportRegistryRow[]> {
|
||||
const [rows, categoryOptions] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmReportDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(crmReportDefinitions.organizationId, context.organizationId),
|
||||
eq(crmReportDefinitions.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmReportDefinitions.category), asc(crmReportDefinitions.name)),
|
||||
getActiveOptionsByCategory('crm_report_category', {
|
||||
organizationId: context.organizationId
|
||||
})
|
||||
]);
|
||||
const categoryMap = new Map(
|
||||
categoryOptions.flatMap((item) => [
|
||||
[item.id, item.label] as const,
|
||||
[item.code, item.label] as const
|
||||
])
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
categoryLabel: categoryMap.get(row.category) ?? row.category,
|
||||
isSystem: row.isSystem,
|
||||
isActive: row.isActive
|
||||
}));
|
||||
}
|
||||
44
src/features/crm/reports/server/exports/service.ts
Normal file
44
src/features/crm/reports/server/exports/service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
|
||||
function escapeCsv(value: string | number | null | undefined) {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function buildReportCsv(rows: string[][]) {
|
||||
return rows.map((row) => row.map((cell) => escapeCsv(cell)).join(',')).join('\n');
|
||||
}
|
||||
|
||||
export function buildReportExcel(rows: string[][]) {
|
||||
const body = rows
|
||||
.map(
|
||||
(row, rowIndex) =>
|
||||
`<tr>${row
|
||||
.map((cell) => (rowIndex === 0 ? `<th>${cell}</th>` : `<td>${cell}</td>`))
|
||||
.join('')}</tr>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8" /></head><body><table>${body}</table></body></html>`;
|
||||
}
|
||||
|
||||
export async function auditReportExport(input: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
reportCode: string;
|
||||
format: 'csv' | 'xls';
|
||||
filters?: Record<string, unknown>;
|
||||
}) {
|
||||
await auditAction({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
entityType: 'crm_report',
|
||||
entityId: input.reportCode,
|
||||
action: input.format === 'xls' ? 'export_excel' : 'export_csv',
|
||||
afterData: {
|
||||
reportCode: input.reportCode,
|
||||
filters: input.filters ?? {},
|
||||
userId: input.userId
|
||||
}
|
||||
});
|
||||
}
|
||||
71
src/features/crm/reports/server/filters/shared.ts
Normal file
71
src/features/crm/reports/server/filters/shared.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { crmCustomers, memberships, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import type { CrmReportFilterMetadata } from '../../api/types';
|
||||
import type { ResolvedReportContext } from '../types';
|
||||
|
||||
const PIPELINE_STAGE_OPTIONS = [
|
||||
{ id: 'lead', code: 'lead', label: 'Lead' },
|
||||
{ id: 'enquiry', code: 'enquiry', label: 'Enquiry' },
|
||||
{ id: 'closed_won', code: 'closed_won', label: 'Won' },
|
||||
{ id: 'closed_lost', code: 'closed_lost', label: 'Lost' }
|
||||
] as const;
|
||||
|
||||
const OUTCOME_OPTIONS = [
|
||||
{ id: 'open', code: 'open', label: 'Open' },
|
||||
{ id: 'won', code: 'won', label: 'Won' },
|
||||
{ id: 'lost', code: 'lost', label: 'Lost' }
|
||||
] as const;
|
||||
|
||||
export async function buildSharedReportFilters(
|
||||
context: ResolvedReportContext
|
||||
): Promise<CrmReportFilterMetadata> {
|
||||
const [branches, productTypes, leadSources, lostReasons, customers, memberRows] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
|
||||
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
|
||||
getActiveOptionsByCategory('crm_lost_reason', { organizationId: context.organizationId }),
|
||||
db
|
||||
.select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name, branchId: crmCustomers.branchId })
|
||||
.from(crmCustomers)
|
||||
.where(eq(crmCustomers.organizationId, context.organizationId))
|
||||
.orderBy(asc(crmCustomers.name)),
|
||||
db
|
||||
.select({
|
||||
userId: memberships.userId,
|
||||
name: users.name
|
||||
})
|
||||
.from(memberships)
|
||||
.innerJoin(users, eq(users.id, memberships.userId))
|
||||
.where(eq(memberships.organizationId, context.organizationId))
|
||||
.orderBy(asc(users.name))
|
||||
]);
|
||||
|
||||
const visibleBranches = branches.filter((item) => hasBranchScopeAccess(context, item.id));
|
||||
const visibleProductTypes = productTypes.filter((item) => hasProductScopeAccess(context, item.id));
|
||||
const visibleCustomers = customers.filter((item) => hasBranchScopeAccess(context, item.branchId));
|
||||
const uniqueUsers = [...new Map(memberRows.map((row) => [row.userId, row])).values()];
|
||||
|
||||
return {
|
||||
branches: visibleBranches.map((item) => ({ id: item.id, code: item.code, label: item.name })),
|
||||
productTypes: visibleProductTypes.map((item) => ({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
label: item.label
|
||||
})),
|
||||
sales: uniqueUsers.map((item) => ({ id: item.userId, name: item.name })),
|
||||
customers: visibleCustomers.map((item) => ({
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
label: item.name
|
||||
})),
|
||||
customerOwners: uniqueUsers.map((item) => ({ id: item.userId, name: item.name })),
|
||||
leadSources: leadSources.map((item) => ({ id: item.id, code: item.code, label: item.label })),
|
||||
pipelineStages: PIPELINE_STAGE_OPTIONS.map((item) => ({ ...item })),
|
||||
outcomes: OUTCOME_OPTIONS.map((item) => ({ ...item })),
|
||||
lostReasons: lostReasons.map((item) => ({ id: item.id, code: item.code, label: item.label }))
|
||||
};
|
||||
}
|
||||
78
src/features/crm/reports/server/service.ts
Normal file
78
src/features/crm/reports/server/service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import type {
|
||||
CrmReportCatalogResponse,
|
||||
CrmReportFiltersResponse,
|
||||
CrmReportsResponse
|
||||
} from '../api/types';
|
||||
import { buildReportCatalogGroups } from './builders/catalog-builder';
|
||||
import { resolveCrmAccess } from './context';
|
||||
import { listReportRegistry } from './datasets/catalog';
|
||||
import { buildSharedReportFilters } from './filters/shared';
|
||||
import type { ResolvedReportContext } from './types';
|
||||
|
||||
export function buildResolvedReportContext(input: Parameters<typeof resolveCrmAccess>[0]) {
|
||||
return resolveCrmAccess(input);
|
||||
}
|
||||
|
||||
async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record<string, unknown>) {
|
||||
await auditAction({
|
||||
organizationId: context.organizationId,
|
||||
userId: context.userId,
|
||||
entityType: 'crm_report',
|
||||
entityId: reportCode,
|
||||
action: 'view',
|
||||
afterData: {
|
||||
reportCode,
|
||||
filters: filters ?? {},
|
||||
userId: context.userId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCrmReportsResponse(
|
||||
context: ResolvedReportContext
|
||||
): Promise<CrmReportsResponse> {
|
||||
const items = await listReportRegistry(context);
|
||||
const groups = buildReportCatalogGroups(items);
|
||||
|
||||
await auditReportView(context, 'report_center');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM reports loaded successfully',
|
||||
items,
|
||||
groups
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCrmReportCatalogResponse(
|
||||
context: ResolvedReportContext
|
||||
): Promise<CrmReportCatalogResponse> {
|
||||
const items = await listReportRegistry(context);
|
||||
const groups = buildReportCatalogGroups(items);
|
||||
|
||||
await auditReportView(context, 'report_catalog');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM report catalog loaded successfully',
|
||||
groups
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCrmReportFiltersResponse(
|
||||
context: ResolvedReportContext
|
||||
): Promise<CrmReportFiltersResponse> {
|
||||
const filters = await buildSharedReportFilters(context);
|
||||
|
||||
await auditReportView(context, 'report_filters');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'CRM report filters loaded successfully',
|
||||
filters
|
||||
};
|
||||
}
|
||||
25
src/features/crm/reports/server/types.ts
Normal file
25
src/features/crm/reports/server/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface ResolvedReportContext {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
businessRoles: string[];
|
||||
permissions: string[];
|
||||
branchScopeIds: string[];
|
||||
productTypeScopeIds: string[];
|
||||
ownershipScope: string;
|
||||
branchScopeMode: string;
|
||||
productScopeMode: string;
|
||||
approvalAuthority: string | null;
|
||||
}
|
||||
|
||||
export interface ReportRegistryRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user