@blackcode_sa/metaestetics-api 1.12.68 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +801 -2
- package/dist/admin/index.d.ts +801 -2
- package/dist/admin/index.js +2332 -153
- package/dist/admin/index.mjs +2321 -153
- package/dist/index.d.mts +1057 -2
- package/dist/index.d.ts +1057 -2
- package/dist/index.js +4150 -2117
- package/dist/index.mjs +3832 -1810
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
- package/src/admin/analytics/analytics.admin.service.ts +278 -0
- package/src/admin/analytics/index.ts +2 -0
- package/src/admin/index.ts +6 -0
- package/src/backoffice/services/README.md +17 -0
- package/src/backoffice/services/analytics.service.proposal.md +863 -0
- package/src/backoffice/services/analytics.service.summary.md +143 -0
- package/src/services/analytics/ARCHITECTURE.md +199 -0
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
- package/src/services/analytics/QUICK_START.md +393 -0
- package/src/services/analytics/README.md +287 -0
- package/src/services/analytics/SUMMARY.md +141 -0
- package/src/services/analytics/USAGE_GUIDE.md +518 -0
- package/src/services/analytics/analytics-cloud.service.ts +222 -0
- package/src/services/analytics/analytics.service.ts +1632 -0
- package/src/services/analytics/index.ts +3 -0
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
- package/src/services/analytics/utils/grouping.utils.ts +394 -0
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
- package/src/services/appointment/appointment.service.ts +50 -6
- package/src/services/index.ts +1 -0
- package/src/types/analytics/analytics.types.ts +500 -0
- package/src/types/analytics/grouped-analytics.types.ts +148 -0
- package/src/types/analytics/index.ts +4 -0
- package/src/types/analytics/stored-analytics.types.ts +137 -0
- package/src/types/index.ts +3 -0
- package/src/types/notifications/index.ts +21 -0
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
import { Firestore, collection, query, where, getDocs, Timestamp } from 'firebase/firestore';
|
|
2
|
+
import { Auth } from 'firebase/auth';
|
|
3
|
+
import { FirebaseApp } from 'firebase/app';
|
|
4
|
+
import { BaseService } from '../base.service';
|
|
5
|
+
import { Appointment, AppointmentStatus, APPOINTMENTS_COLLECTION, PaymentStatus } from '../../types/appointment';
|
|
6
|
+
import { AppointmentService } from '../appointment/appointment.service';
|
|
7
|
+
import {
|
|
8
|
+
PractitionerAnalytics,
|
|
9
|
+
ProcedureAnalytics,
|
|
10
|
+
TimeEfficiencyMetrics,
|
|
11
|
+
CancellationMetrics,
|
|
12
|
+
NoShowMetrics,
|
|
13
|
+
RevenueMetrics,
|
|
14
|
+
RevenueTrend,
|
|
15
|
+
DurationTrend,
|
|
16
|
+
ProductUsageMetrics,
|
|
17
|
+
ProductRevenueMetrics,
|
|
18
|
+
ProductUsageByProcedure,
|
|
19
|
+
PatientAnalytics,
|
|
20
|
+
PatientLifetimeValueMetrics,
|
|
21
|
+
PatientRetentionMetrics,
|
|
22
|
+
CostPerPatientMetrics,
|
|
23
|
+
PaymentStatusBreakdown,
|
|
24
|
+
ClinicAnalytics,
|
|
25
|
+
ClinicComparisonMetrics,
|
|
26
|
+
ProcedurePopularity,
|
|
27
|
+
ProcedureProfitability,
|
|
28
|
+
CancellationReasonStats,
|
|
29
|
+
DashboardAnalytics,
|
|
30
|
+
AnalyticsDateRange,
|
|
31
|
+
AnalyticsFilters,
|
|
32
|
+
GroupingPeriod,
|
|
33
|
+
EntityType,
|
|
34
|
+
} from '../../types/analytics';
|
|
35
|
+
|
|
36
|
+
// Import utility functions
|
|
37
|
+
import { calculateAppointmentCost, calculateTotalRevenue, extractProductUsage } from './utils/cost-calculation.utils';
|
|
38
|
+
import {
|
|
39
|
+
calculateTimeEfficiency,
|
|
40
|
+
calculateAverageTimeMetrics,
|
|
41
|
+
calculateEfficiencyDistribution,
|
|
42
|
+
calculateCancellationLeadTime,
|
|
43
|
+
} from './utils/time-calculation.utils';
|
|
44
|
+
import {
|
|
45
|
+
filterByDateRange,
|
|
46
|
+
filterAppointments,
|
|
47
|
+
getCompletedAppointments,
|
|
48
|
+
getCanceledAppointments,
|
|
49
|
+
getNoShowAppointments,
|
|
50
|
+
getActiveAppointments,
|
|
51
|
+
calculatePercentage,
|
|
52
|
+
} from './utils/appointment-filtering.utils';
|
|
53
|
+
import {
|
|
54
|
+
readStoredPractitionerAnalytics,
|
|
55
|
+
readStoredProcedureAnalytics,
|
|
56
|
+
readStoredClinicAnalytics,
|
|
57
|
+
readStoredDashboardAnalytics,
|
|
58
|
+
readStoredTimeEfficiencyMetrics,
|
|
59
|
+
readStoredRevenueMetrics,
|
|
60
|
+
readStoredCancellationMetrics,
|
|
61
|
+
readStoredNoShowMetrics,
|
|
62
|
+
} from './utils/stored-analytics.utils';
|
|
63
|
+
import {
|
|
64
|
+
calculateGroupedRevenueMetrics,
|
|
65
|
+
calculateGroupedProductUsageMetrics,
|
|
66
|
+
calculateGroupedTimeEfficiencyMetrics,
|
|
67
|
+
calculateGroupedPatientBehaviorMetrics,
|
|
68
|
+
} from './utils/grouping.utils';
|
|
69
|
+
import { ReadStoredAnalyticsOptions, AnalyticsPeriod } from '../../types/analytics';
|
|
70
|
+
import {
|
|
71
|
+
GroupedRevenueMetrics,
|
|
72
|
+
GroupedProductUsageMetrics,
|
|
73
|
+
GroupedTimeEfficiencyMetrics,
|
|
74
|
+
GroupedPatientBehaviorMetrics,
|
|
75
|
+
} from '../../types/analytics/grouped-analytics.types';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* AnalyticsService provides comprehensive financial and analytical intelligence
|
|
79
|
+
* for the Clinic Admin app, including metrics about doctors, procedures,
|
|
80
|
+
* appointments, patients, products, and clinic operations.
|
|
81
|
+
*/
|
|
82
|
+
export class AnalyticsService extends BaseService {
|
|
83
|
+
private appointmentService: AppointmentService;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a new AnalyticsService instance.
|
|
87
|
+
*
|
|
88
|
+
* @param db Firestore instance
|
|
89
|
+
* @param auth Firebase Auth instance
|
|
90
|
+
* @param app Firebase App instance
|
|
91
|
+
* @param appointmentService Appointment service instance for querying appointments
|
|
92
|
+
*/
|
|
93
|
+
constructor(db: Firestore, auth: Auth, app: FirebaseApp, appointmentService: AppointmentService) {
|
|
94
|
+
super(db, auth, app);
|
|
95
|
+
this.appointmentService = appointmentService;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetches appointments with optional filters
|
|
100
|
+
*
|
|
101
|
+
* @param filters - Optional filters
|
|
102
|
+
* @param dateRange - Optional date range
|
|
103
|
+
* @returns Array of appointments
|
|
104
|
+
*/
|
|
105
|
+
private async fetchAppointments(
|
|
106
|
+
filters?: AnalyticsFilters,
|
|
107
|
+
dateRange?: AnalyticsDateRange,
|
|
108
|
+
): Promise<Appointment[]> {
|
|
109
|
+
try {
|
|
110
|
+
// Build query constraints
|
|
111
|
+
const constraints: any[] = [];
|
|
112
|
+
|
|
113
|
+
if (filters?.clinicBranchId) {
|
|
114
|
+
constraints.push(where('clinicBranchId', '==', filters.clinicBranchId));
|
|
115
|
+
}
|
|
116
|
+
if (filters?.practitionerId) {
|
|
117
|
+
constraints.push(where('practitionerId', '==', filters.practitionerId));
|
|
118
|
+
}
|
|
119
|
+
if (filters?.procedureId) {
|
|
120
|
+
constraints.push(where('procedureId', '==', filters.procedureId));
|
|
121
|
+
}
|
|
122
|
+
if (filters?.patientId) {
|
|
123
|
+
constraints.push(where('patientId', '==', filters.patientId));
|
|
124
|
+
}
|
|
125
|
+
if (dateRange) {
|
|
126
|
+
constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(dateRange.start)));
|
|
127
|
+
constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(dateRange.end)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Use AppointmentService to search appointments
|
|
131
|
+
const searchParams: any = {};
|
|
132
|
+
if (filters?.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
|
|
133
|
+
if (filters?.practitionerId) searchParams.practitionerId = filters.practitionerId;
|
|
134
|
+
if (filters?.procedureId) searchParams.procedureId = filters.procedureId;
|
|
135
|
+
if (filters?.patientId) searchParams.patientId = filters.patientId;
|
|
136
|
+
if (dateRange) {
|
|
137
|
+
searchParams.startDate = dateRange.start;
|
|
138
|
+
searchParams.endDate = dateRange.end;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = await this.appointmentService.searchAppointments(searchParams);
|
|
142
|
+
|
|
143
|
+
return result.appointments;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error('[AnalyticsService] Error fetching appointments:', error);
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ==========================================
|
|
151
|
+
// Practitioner Analytics
|
|
152
|
+
// ==========================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get practitioner performance metrics
|
|
156
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
157
|
+
*
|
|
158
|
+
* @param practitionerId - ID of the practitioner
|
|
159
|
+
* @param dateRange - Optional date range filter
|
|
160
|
+
* @param options - Options for reading stored analytics
|
|
161
|
+
* @returns Practitioner analytics object
|
|
162
|
+
*/
|
|
163
|
+
async getPractitionerAnalytics(
|
|
164
|
+
practitionerId: string,
|
|
165
|
+
dateRange?: AnalyticsDateRange,
|
|
166
|
+
options?: ReadStoredAnalyticsOptions,
|
|
167
|
+
): Promise<PractitionerAnalytics> {
|
|
168
|
+
// Try to read from stored analytics first
|
|
169
|
+
if (dateRange && options?.useCache !== false) {
|
|
170
|
+
// Determine period from date range
|
|
171
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
172
|
+
const clinicBranchId = options?.clinicBranchId; // Would need to be passed or determined
|
|
173
|
+
|
|
174
|
+
if (clinicBranchId) {
|
|
175
|
+
const stored = await readStoredPractitionerAnalytics(
|
|
176
|
+
this.db,
|
|
177
|
+
clinicBranchId,
|
|
178
|
+
practitionerId,
|
|
179
|
+
{ ...options, period },
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (stored) {
|
|
183
|
+
// Return stored data (without metadata)
|
|
184
|
+
const { metadata, ...analytics } = stored;
|
|
185
|
+
return analytics;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fall back to calculation
|
|
191
|
+
const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
|
|
192
|
+
|
|
193
|
+
const completed = getCompletedAppointments(appointments);
|
|
194
|
+
const canceled = getCanceledAppointments(appointments);
|
|
195
|
+
const noShow = getNoShowAppointments(appointments);
|
|
196
|
+
const pending = filterAppointments(appointments, { practitionerId }).filter(
|
|
197
|
+
a => a.status === AppointmentStatus.PENDING,
|
|
198
|
+
);
|
|
199
|
+
const confirmed = filterAppointments(appointments, { practitionerId }).filter(
|
|
200
|
+
a => a.status === AppointmentStatus.CONFIRMED,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
204
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
205
|
+
|
|
206
|
+
// Get unique patients
|
|
207
|
+
const uniquePatients = new Set(appointments.map(a => a.patientId));
|
|
208
|
+
const returningPatients = new Set(
|
|
209
|
+
appointments
|
|
210
|
+
.filter(a => {
|
|
211
|
+
const patientAppointments = appointments.filter(ap => ap.patientId === a.patientId);
|
|
212
|
+
return patientAppointments.length > 1;
|
|
213
|
+
})
|
|
214
|
+
.map(a => a.patientId),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Get top procedures
|
|
218
|
+
const procedureMap = new Map<string, { name: string; count: number; revenue: number }>();
|
|
219
|
+
completed.forEach(appointment => {
|
|
220
|
+
const procId = appointment.procedureId;
|
|
221
|
+
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
222
|
+
const cost = calculateAppointmentCost(appointment).cost;
|
|
223
|
+
|
|
224
|
+
if (procedureMap.has(procId)) {
|
|
225
|
+
const existing = procedureMap.get(procId)!;
|
|
226
|
+
existing.count++;
|
|
227
|
+
existing.revenue += cost;
|
|
228
|
+
} else {
|
|
229
|
+
procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const topProcedures = Array.from(procedureMap.entries())
|
|
234
|
+
.map(([procedureId, data]) => ({
|
|
235
|
+
procedureId,
|
|
236
|
+
procedureName: data.name,
|
|
237
|
+
count: data.count,
|
|
238
|
+
revenue: data.revenue,
|
|
239
|
+
}))
|
|
240
|
+
.sort((a, b) => b.count - a.count)
|
|
241
|
+
.slice(0, 10);
|
|
242
|
+
|
|
243
|
+
const practitionerName =
|
|
244
|
+
appointments.length > 0
|
|
245
|
+
? appointments[0].practitionerInfo?.name || 'Unknown'
|
|
246
|
+
: 'Unknown';
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
total: appointments.length,
|
|
250
|
+
dateRange,
|
|
251
|
+
practitionerId,
|
|
252
|
+
practitionerName,
|
|
253
|
+
totalAppointments: appointments.length,
|
|
254
|
+
completedAppointments: completed.length,
|
|
255
|
+
canceledAppointments: canceled.length,
|
|
256
|
+
noShowAppointments: noShow.length,
|
|
257
|
+
pendingAppointments: pending.length,
|
|
258
|
+
confirmedAppointments: confirmed.length,
|
|
259
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
260
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
261
|
+
averageBookedTime: timeMetrics.averageBookedDuration,
|
|
262
|
+
averageActualTime: timeMetrics.averageActualDuration,
|
|
263
|
+
timeEfficiency: timeMetrics.averageEfficiency,
|
|
264
|
+
totalRevenue,
|
|
265
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
266
|
+
currency,
|
|
267
|
+
topProcedures,
|
|
268
|
+
patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
|
|
269
|
+
uniquePatients: uniquePatients.size,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ==========================================
|
|
274
|
+
// Procedure Analytics
|
|
275
|
+
// ==========================================
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get procedure performance metrics
|
|
279
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
280
|
+
*
|
|
281
|
+
* @param procedureId - ID of the procedure (optional, if not provided returns all)
|
|
282
|
+
* @param dateRange - Optional date range filter
|
|
283
|
+
* @param options - Options for reading stored analytics
|
|
284
|
+
* @returns Procedure analytics object or array
|
|
285
|
+
*/
|
|
286
|
+
async getProcedureAnalytics(
|
|
287
|
+
procedureId?: string,
|
|
288
|
+
dateRange?: AnalyticsDateRange,
|
|
289
|
+
options?: ReadStoredAnalyticsOptions,
|
|
290
|
+
): Promise<ProcedureAnalytics | ProcedureAnalytics[]> {
|
|
291
|
+
// Try to read from stored analytics first (only for single procedure)
|
|
292
|
+
if (procedureId && dateRange && options?.useCache !== false) {
|
|
293
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
294
|
+
const clinicBranchId = options?.clinicBranchId;
|
|
295
|
+
|
|
296
|
+
if (clinicBranchId) {
|
|
297
|
+
const stored = await readStoredProcedureAnalytics(
|
|
298
|
+
this.db,
|
|
299
|
+
clinicBranchId,
|
|
300
|
+
procedureId,
|
|
301
|
+
{ ...options, period },
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
if (stored) {
|
|
305
|
+
// Return stored data (without metadata)
|
|
306
|
+
const { metadata, ...analytics } = stored;
|
|
307
|
+
return analytics;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Fall back to calculation
|
|
313
|
+
const appointments = await this.fetchAppointments(procedureId ? { procedureId } : undefined, dateRange);
|
|
314
|
+
|
|
315
|
+
if (procedureId) {
|
|
316
|
+
return this.calculateProcedureAnalytics(appointments, procedureId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Group by procedure
|
|
320
|
+
const procedureMap = new Map<string, Appointment[]>();
|
|
321
|
+
appointments.forEach(appointment => {
|
|
322
|
+
const procId = appointment.procedureId;
|
|
323
|
+
if (!procedureMap.has(procId)) {
|
|
324
|
+
procedureMap.set(procId, []);
|
|
325
|
+
}
|
|
326
|
+
procedureMap.get(procId)!.push(appointment);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return Array.from(procedureMap.entries()).map(([procId, procAppointments]) =>
|
|
330
|
+
this.calculateProcedureAnalytics(procAppointments, procId),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Calculate analytics for a specific procedure
|
|
336
|
+
*
|
|
337
|
+
* @param appointments - Appointments for the procedure
|
|
338
|
+
* @param procedureId - Procedure ID
|
|
339
|
+
* @returns Procedure analytics
|
|
340
|
+
*/
|
|
341
|
+
private calculateProcedureAnalytics(
|
|
342
|
+
appointments: Appointment[],
|
|
343
|
+
procedureId: string,
|
|
344
|
+
): ProcedureAnalytics {
|
|
345
|
+
const completed = getCompletedAppointments(appointments);
|
|
346
|
+
const canceled = getCanceledAppointments(appointments);
|
|
347
|
+
const noShow = getNoShowAppointments(appointments);
|
|
348
|
+
|
|
349
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
350
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
351
|
+
|
|
352
|
+
const firstAppointment = appointments[0];
|
|
353
|
+
const procedureInfo = firstAppointment?.procedureExtendedInfo || firstAppointment?.procedureInfo;
|
|
354
|
+
|
|
355
|
+
// Extract product usage
|
|
356
|
+
const productMap = new Map<
|
|
357
|
+
string,
|
|
358
|
+
{ name: string; brandName: string; quantity: number; revenue: number; usageCount: number }
|
|
359
|
+
>();
|
|
360
|
+
|
|
361
|
+
completed.forEach(appointment => {
|
|
362
|
+
const products = extractProductUsage(appointment);
|
|
363
|
+
products.forEach(product => {
|
|
364
|
+
if (productMap.has(product.productId)) {
|
|
365
|
+
const existing = productMap.get(product.productId)!;
|
|
366
|
+
existing.quantity += product.quantity;
|
|
367
|
+
existing.revenue += product.subtotal;
|
|
368
|
+
existing.usageCount++;
|
|
369
|
+
} else {
|
|
370
|
+
productMap.set(product.productId, {
|
|
371
|
+
name: product.productName,
|
|
372
|
+
brandName: product.brandName,
|
|
373
|
+
quantity: product.quantity,
|
|
374
|
+
revenue: product.subtotal,
|
|
375
|
+
usageCount: 1,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
382
|
+
productId,
|
|
383
|
+
productName: data.name,
|
|
384
|
+
brandName: data.brandName,
|
|
385
|
+
totalQuantity: data.quantity,
|
|
386
|
+
totalRevenue: data.revenue,
|
|
387
|
+
usageCount: data.usageCount,
|
|
388
|
+
}));
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
total: appointments.length,
|
|
392
|
+
procedureId,
|
|
393
|
+
procedureName: procedureInfo?.name || 'Unknown',
|
|
394
|
+
procedureFamily: procedureInfo?.procedureFamily || '',
|
|
395
|
+
categoryName: procedureInfo?.procedureCategoryName || '',
|
|
396
|
+
subcategoryName: procedureInfo?.procedureSubCategoryName || '',
|
|
397
|
+
technologyName: procedureInfo?.procedureTechnologyName || '',
|
|
398
|
+
totalAppointments: appointments.length,
|
|
399
|
+
completedAppointments: completed.length,
|
|
400
|
+
canceledAppointments: canceled.length,
|
|
401
|
+
noShowAppointments: noShow.length,
|
|
402
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
403
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
404
|
+
averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
405
|
+
totalRevenue,
|
|
406
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
407
|
+
currency,
|
|
408
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
409
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
410
|
+
productUsage,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Get procedure popularity metrics
|
|
416
|
+
*
|
|
417
|
+
* @param dateRange - Optional date range filter
|
|
418
|
+
* @param limit - Number of top procedures to return
|
|
419
|
+
* @returns Array of procedure popularity metrics
|
|
420
|
+
*/
|
|
421
|
+
async getProcedurePopularity(
|
|
422
|
+
dateRange?: AnalyticsDateRange,
|
|
423
|
+
limit: number = 10,
|
|
424
|
+
): Promise<ProcedurePopularity[]> {
|
|
425
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
426
|
+
const completed = getCompletedAppointments(appointments);
|
|
427
|
+
|
|
428
|
+
const procedureMap = new Map<string, { name: string; category: string; subcategory: string; technology: string; count: number }>();
|
|
429
|
+
|
|
430
|
+
completed.forEach(appointment => {
|
|
431
|
+
const procId = appointment.procedureId;
|
|
432
|
+
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
433
|
+
|
|
434
|
+
if (procedureMap.has(procId)) {
|
|
435
|
+
procedureMap.get(procId)!.count++;
|
|
436
|
+
} else {
|
|
437
|
+
procedureMap.set(procId, {
|
|
438
|
+
name: procInfo?.name || 'Unknown',
|
|
439
|
+
category: procInfo?.procedureCategoryName || '',
|
|
440
|
+
subcategory: procInfo?.procedureSubCategoryName || '',
|
|
441
|
+
technology: procInfo?.procedureTechnologyName || '',
|
|
442
|
+
count: 1,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return Array.from(procedureMap.entries())
|
|
448
|
+
.map(([procedureId, data]) => ({
|
|
449
|
+
procedureId,
|
|
450
|
+
procedureName: data.name,
|
|
451
|
+
categoryName: data.category,
|
|
452
|
+
subcategoryName: data.subcategory,
|
|
453
|
+
technologyName: data.technology,
|
|
454
|
+
appointmentCount: data.count,
|
|
455
|
+
completedCount: data.count,
|
|
456
|
+
rank: 0, // Will be set after sorting
|
|
457
|
+
}))
|
|
458
|
+
.sort((a, b) => b.appointmentCount - a.appointmentCount)
|
|
459
|
+
.slice(0, limit)
|
|
460
|
+
.map((item, index) => ({ ...item, rank: index + 1 }));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get procedure profitability metrics
|
|
465
|
+
*
|
|
466
|
+
* @param dateRange - Optional date range filter
|
|
467
|
+
* @param limit - Number of top procedures to return
|
|
468
|
+
* @returns Array of procedure profitability metrics
|
|
469
|
+
*/
|
|
470
|
+
async getProcedureProfitability(
|
|
471
|
+
dateRange?: AnalyticsDateRange,
|
|
472
|
+
limit: number = 10,
|
|
473
|
+
): Promise<ProcedureProfitability[]> {
|
|
474
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
475
|
+
const completed = getCompletedAppointments(appointments);
|
|
476
|
+
|
|
477
|
+
const procedureMap = new Map<
|
|
478
|
+
string,
|
|
479
|
+
{
|
|
480
|
+
name: string;
|
|
481
|
+
category: string;
|
|
482
|
+
subcategory: string;
|
|
483
|
+
technology: string;
|
|
484
|
+
revenue: number;
|
|
485
|
+
count: number;
|
|
486
|
+
}
|
|
487
|
+
>();
|
|
488
|
+
|
|
489
|
+
completed.forEach(appointment => {
|
|
490
|
+
const procId = appointment.procedureId;
|
|
491
|
+
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
492
|
+
const cost = calculateAppointmentCost(appointment).cost;
|
|
493
|
+
|
|
494
|
+
if (procedureMap.has(procId)) {
|
|
495
|
+
const existing = procedureMap.get(procId)!;
|
|
496
|
+
existing.revenue += cost;
|
|
497
|
+
existing.count++;
|
|
498
|
+
} else {
|
|
499
|
+
procedureMap.set(procId, {
|
|
500
|
+
name: procInfo?.name || 'Unknown',
|
|
501
|
+
category: procInfo?.procedureCategoryName || '',
|
|
502
|
+
subcategory: procInfo?.procedureSubCategoryName || '',
|
|
503
|
+
technology: procInfo?.procedureTechnologyName || '',
|
|
504
|
+
revenue: cost,
|
|
505
|
+
count: 1,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return Array.from(procedureMap.entries())
|
|
511
|
+
.map(([procedureId, data]) => ({
|
|
512
|
+
procedureId,
|
|
513
|
+
procedureName: data.name,
|
|
514
|
+
categoryName: data.category,
|
|
515
|
+
subcategoryName: data.subcategory,
|
|
516
|
+
technologyName: data.technology,
|
|
517
|
+
totalRevenue: data.revenue,
|
|
518
|
+
averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
|
|
519
|
+
appointmentCount: data.count,
|
|
520
|
+
rank: 0, // Will be set after sorting
|
|
521
|
+
}))
|
|
522
|
+
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
|
523
|
+
.slice(0, limit)
|
|
524
|
+
.map((item, index) => ({ ...item, rank: index + 1 }));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ==========================================
|
|
528
|
+
// Time Efficiency Analytics
|
|
529
|
+
// ==========================================
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
533
|
+
*
|
|
534
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
535
|
+
* @param dateRange - Optional date range filter
|
|
536
|
+
* @param filters - Optional additional filters
|
|
537
|
+
* @returns Grouped time efficiency metrics
|
|
538
|
+
*/
|
|
539
|
+
async getTimeEfficiencyMetricsByEntity(
|
|
540
|
+
groupBy: EntityType,
|
|
541
|
+
dateRange?: AnalyticsDateRange,
|
|
542
|
+
filters?: AnalyticsFilters,
|
|
543
|
+
): Promise<GroupedTimeEfficiencyMetrics[]> {
|
|
544
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
545
|
+
return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Get time efficiency metrics for appointments
|
|
550
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
551
|
+
*
|
|
552
|
+
* @param filters - Optional filters
|
|
553
|
+
* @param dateRange - Optional date range filter
|
|
554
|
+
* @param options - Options for reading stored analytics
|
|
555
|
+
* @returns Time efficiency metrics
|
|
556
|
+
*/
|
|
557
|
+
async getTimeEfficiencyMetrics(
|
|
558
|
+
filters?: AnalyticsFilters,
|
|
559
|
+
dateRange?: AnalyticsDateRange,
|
|
560
|
+
options?: ReadStoredAnalyticsOptions,
|
|
561
|
+
): Promise<TimeEfficiencyMetrics> {
|
|
562
|
+
// Try to read from stored analytics first
|
|
563
|
+
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
564
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
565
|
+
const stored = await readStoredTimeEfficiencyMetrics(
|
|
566
|
+
this.db,
|
|
567
|
+
filters.clinicBranchId,
|
|
568
|
+
{ ...options, period },
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
if (stored) {
|
|
572
|
+
// Return stored data (without metadata)
|
|
573
|
+
const { metadata, ...metrics } = stored;
|
|
574
|
+
return metrics;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Fall back to calculation
|
|
579
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
580
|
+
const completed = getCompletedAppointments(appointments);
|
|
581
|
+
|
|
582
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
583
|
+
const efficiencyDistribution = calculateEfficiencyDistribution(completed);
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
totalAppointments: completed.length,
|
|
587
|
+
appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
|
|
588
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
589
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
590
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
591
|
+
totalOverrun: timeMetrics.totalOverrun,
|
|
592
|
+
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
593
|
+
averageOverrun: timeMetrics.averageOverrun,
|
|
594
|
+
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
595
|
+
efficiencyDistribution,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ==========================================
|
|
600
|
+
// Cancellation & No-Show Analytics
|
|
601
|
+
// ==========================================
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Get cancellation metrics
|
|
605
|
+
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
606
|
+
*
|
|
607
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
608
|
+
* @param dateRange - Optional date range filter
|
|
609
|
+
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
610
|
+
* @returns Cancellation metrics grouped by specified entity
|
|
611
|
+
*/
|
|
612
|
+
async getCancellationMetrics(
|
|
613
|
+
groupBy: EntityType,
|
|
614
|
+
dateRange?: AnalyticsDateRange,
|
|
615
|
+
options?: ReadStoredAnalyticsOptions,
|
|
616
|
+
): Promise<CancellationMetrics | CancellationMetrics[]> {
|
|
617
|
+
// Try to read from stored analytics first (only for clinic-level grouping)
|
|
618
|
+
if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
|
|
619
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
620
|
+
const stored = await readStoredCancellationMetrics(
|
|
621
|
+
this.db,
|
|
622
|
+
options.clinicBranchId,
|
|
623
|
+
'clinic',
|
|
624
|
+
{ ...options, period },
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
if (stored) {
|
|
628
|
+
// Return stored data (without metadata)
|
|
629
|
+
const { metadata, ...metrics } = stored;
|
|
630
|
+
return metrics;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Fall back to calculation
|
|
635
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
636
|
+
const canceled = getCanceledAppointments(appointments);
|
|
637
|
+
|
|
638
|
+
if (groupBy === 'clinic') {
|
|
639
|
+
return this.groupCancellationsByClinic(canceled, appointments);
|
|
640
|
+
} else if (groupBy === 'practitioner') {
|
|
641
|
+
return this.groupCancellationsByPractitioner(canceled, appointments);
|
|
642
|
+
} else if (groupBy === 'patient') {
|
|
643
|
+
return this.groupCancellationsByPatient(canceled, appointments);
|
|
644
|
+
} else if (groupBy === 'technology') {
|
|
645
|
+
return this.groupCancellationsByTechnology(canceled, appointments);
|
|
646
|
+
} else {
|
|
647
|
+
return this.groupCancellationsByProcedure(canceled, appointments);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Group cancellations by clinic
|
|
653
|
+
*/
|
|
654
|
+
private groupCancellationsByClinic(
|
|
655
|
+
canceled: Appointment[],
|
|
656
|
+
allAppointments: Appointment[],
|
|
657
|
+
): CancellationMetrics[] {
|
|
658
|
+
const clinicMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
659
|
+
|
|
660
|
+
allAppointments.forEach(appointment => {
|
|
661
|
+
const clinicId = appointment.clinicBranchId;
|
|
662
|
+
const clinicName = appointment.clinicInfo?.name || 'Unknown';
|
|
663
|
+
|
|
664
|
+
if (!clinicMap.has(clinicId)) {
|
|
665
|
+
clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
|
|
666
|
+
}
|
|
667
|
+
clinicMap.get(clinicId)!.all.push(appointment);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
canceled.forEach(appointment => {
|
|
671
|
+
const clinicId = appointment.clinicBranchId;
|
|
672
|
+
if (clinicMap.has(clinicId)) {
|
|
673
|
+
clinicMap.get(clinicId)!.canceled.push(appointment);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
return Array.from(clinicMap.entries()).map(([clinicId, data]) =>
|
|
678
|
+
this.calculateCancellationMetrics(clinicId, data.name, 'clinic', data.canceled, data.all),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Group cancellations by practitioner
|
|
684
|
+
*/
|
|
685
|
+
private groupCancellationsByPractitioner(
|
|
686
|
+
canceled: Appointment[],
|
|
687
|
+
allAppointments: Appointment[],
|
|
688
|
+
): CancellationMetrics[] {
|
|
689
|
+
const practitionerMap = new Map<
|
|
690
|
+
string,
|
|
691
|
+
{ name: string; canceled: Appointment[]; all: Appointment[] }
|
|
692
|
+
>();
|
|
693
|
+
|
|
694
|
+
allAppointments.forEach(appointment => {
|
|
695
|
+
const practitionerId = appointment.practitionerId;
|
|
696
|
+
const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
|
|
697
|
+
|
|
698
|
+
if (!practitionerMap.has(practitionerId)) {
|
|
699
|
+
practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
|
|
700
|
+
}
|
|
701
|
+
practitionerMap.get(practitionerId)!.all.push(appointment);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
canceled.forEach(appointment => {
|
|
705
|
+
const practitionerId = appointment.practitionerId;
|
|
706
|
+
if (practitionerMap.has(practitionerId)) {
|
|
707
|
+
practitionerMap.get(practitionerId)!.canceled.push(appointment);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) =>
|
|
712
|
+
this.calculateCancellationMetrics(
|
|
713
|
+
practitionerId,
|
|
714
|
+
data.name,
|
|
715
|
+
'practitioner',
|
|
716
|
+
data.canceled,
|
|
717
|
+
data.all,
|
|
718
|
+
),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Group cancellations by patient
|
|
724
|
+
*/
|
|
725
|
+
private groupCancellationsByPatient(
|
|
726
|
+
canceled: Appointment[],
|
|
727
|
+
allAppointments: Appointment[],
|
|
728
|
+
): CancellationMetrics[] {
|
|
729
|
+
const patientMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
730
|
+
|
|
731
|
+
allAppointments.forEach(appointment => {
|
|
732
|
+
const patientId = appointment.patientId;
|
|
733
|
+
const patientName = appointment.patientInfo?.fullName || 'Unknown';
|
|
734
|
+
|
|
735
|
+
if (!patientMap.has(patientId)) {
|
|
736
|
+
patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
|
|
737
|
+
}
|
|
738
|
+
patientMap.get(patientId)!.all.push(appointment);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
canceled.forEach(appointment => {
|
|
742
|
+
const patientId = appointment.patientId;
|
|
743
|
+
if (patientMap.has(patientId)) {
|
|
744
|
+
patientMap.get(patientId)!.canceled.push(appointment);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
return Array.from(patientMap.entries()).map(([patientId, data]) =>
|
|
749
|
+
this.calculateCancellationMetrics(patientId, data.name, 'patient', data.canceled, data.all),
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Group cancellations by procedure
|
|
755
|
+
*/
|
|
756
|
+
private groupCancellationsByProcedure(
|
|
757
|
+
canceled: Appointment[],
|
|
758
|
+
allAppointments: Appointment[],
|
|
759
|
+
): CancellationMetrics[] {
|
|
760
|
+
const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
761
|
+
|
|
762
|
+
allAppointments.forEach(appointment => {
|
|
763
|
+
const procedureId = appointment.procedureId;
|
|
764
|
+
const procedureName = appointment.procedureInfo?.name || 'Unknown';
|
|
765
|
+
|
|
766
|
+
if (!procedureMap.has(procedureId)) {
|
|
767
|
+
procedureMap.set(procedureId, { name: procedureName, canceled: [], all: [] });
|
|
768
|
+
}
|
|
769
|
+
procedureMap.get(procedureId)!.all.push(appointment);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
canceled.forEach(appointment => {
|
|
773
|
+
const procedureId = appointment.procedureId;
|
|
774
|
+
if (procedureMap.has(procedureId)) {
|
|
775
|
+
procedureMap.get(procedureId)!.canceled.push(appointment);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) =>
|
|
780
|
+
this.calculateCancellationMetrics(
|
|
781
|
+
procedureId,
|
|
782
|
+
data.name,
|
|
783
|
+
'procedure',
|
|
784
|
+
data.canceled,
|
|
785
|
+
data.all,
|
|
786
|
+
),
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Group cancellations by technology
|
|
792
|
+
* Aggregates all procedures using the same technology across all doctors
|
|
793
|
+
*/
|
|
794
|
+
private groupCancellationsByTechnology(
|
|
795
|
+
canceled: Appointment[],
|
|
796
|
+
allAppointments: Appointment[],
|
|
797
|
+
): CancellationMetrics[] {
|
|
798
|
+
const technologyMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
|
|
799
|
+
|
|
800
|
+
allAppointments.forEach(appointment => {
|
|
801
|
+
const technologyId =
|
|
802
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
803
|
+
const technologyName =
|
|
804
|
+
appointment.procedureExtendedInfo?.procedureTechnologyName ||
|
|
805
|
+
appointment.procedureInfo?.technologyName ||
|
|
806
|
+
'Unknown';
|
|
807
|
+
|
|
808
|
+
if (!technologyMap.has(technologyId)) {
|
|
809
|
+
technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
|
|
810
|
+
}
|
|
811
|
+
technologyMap.get(technologyId)!.all.push(appointment);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
canceled.forEach(appointment => {
|
|
815
|
+
const technologyId =
|
|
816
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
817
|
+
if (technologyMap.has(technologyId)) {
|
|
818
|
+
technologyMap.get(technologyId)!.canceled.push(appointment);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
return Array.from(technologyMap.entries()).map(([technologyId, data]) =>
|
|
823
|
+
this.calculateCancellationMetrics(
|
|
824
|
+
technologyId,
|
|
825
|
+
data.name,
|
|
826
|
+
'technology',
|
|
827
|
+
data.canceled,
|
|
828
|
+
data.all,
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Calculate cancellation metrics for a specific entity
|
|
835
|
+
*/
|
|
836
|
+
private calculateCancellationMetrics(
|
|
837
|
+
entityId: string,
|
|
838
|
+
entityName: string,
|
|
839
|
+
entityType: EntityType,
|
|
840
|
+
canceled: Appointment[],
|
|
841
|
+
all: Appointment[],
|
|
842
|
+
): CancellationMetrics {
|
|
843
|
+
const canceledByPatient = canceled.filter(
|
|
844
|
+
a => a.status === AppointmentStatus.CANCELED_PATIENT,
|
|
845
|
+
).length;
|
|
846
|
+
const canceledByClinic = canceled.filter(
|
|
847
|
+
a => a.status === AppointmentStatus.CANCELED_CLINIC,
|
|
848
|
+
).length;
|
|
849
|
+
const canceledRescheduled = canceled.filter(
|
|
850
|
+
a => a.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
|
|
851
|
+
).length;
|
|
852
|
+
|
|
853
|
+
// Calculate average cancellation lead time
|
|
854
|
+
const leadTimes = canceled
|
|
855
|
+
.map(a => calculateCancellationLeadTime(a))
|
|
856
|
+
.filter((lt): lt is number => lt !== null);
|
|
857
|
+
const averageLeadTime =
|
|
858
|
+
leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
|
|
859
|
+
|
|
860
|
+
// Group cancellation reasons
|
|
861
|
+
const reasonMap = new Map<string, number>();
|
|
862
|
+
canceled.forEach(appointment => {
|
|
863
|
+
const reason = appointment.cancellationReason || 'No reason provided';
|
|
864
|
+
reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
|
|
868
|
+
reason,
|
|
869
|
+
count,
|
|
870
|
+
percentage: calculatePercentage(count, canceled.length),
|
|
871
|
+
}));
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
entityId,
|
|
875
|
+
entityName,
|
|
876
|
+
entityType,
|
|
877
|
+
totalAppointments: all.length,
|
|
878
|
+
canceledAppointments: canceled.length,
|
|
879
|
+
cancellationRate: calculatePercentage(canceled.length, all.length),
|
|
880
|
+
canceledByPatient,
|
|
881
|
+
canceledByClinic,
|
|
882
|
+
canceledByPractitioner: 0, // Not tracked in current status enum
|
|
883
|
+
canceledRescheduled,
|
|
884
|
+
averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
|
|
885
|
+
cancellationReasons,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Get no-show metrics
|
|
891
|
+
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
892
|
+
*
|
|
893
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
894
|
+
* @param dateRange - Optional date range filter
|
|
895
|
+
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
896
|
+
* @returns No-show metrics grouped by specified entity
|
|
897
|
+
*/
|
|
898
|
+
async getNoShowMetrics(
|
|
899
|
+
groupBy: EntityType,
|
|
900
|
+
dateRange?: AnalyticsDateRange,
|
|
901
|
+
options?: ReadStoredAnalyticsOptions,
|
|
902
|
+
): Promise<NoShowMetrics | NoShowMetrics[]> {
|
|
903
|
+
// Try to read from stored analytics first (only for clinic-level grouping)
|
|
904
|
+
if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
|
|
905
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
906
|
+
const stored = await readStoredNoShowMetrics(
|
|
907
|
+
this.db,
|
|
908
|
+
options.clinicBranchId,
|
|
909
|
+
'clinic',
|
|
910
|
+
{ ...options, period },
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
if (stored) {
|
|
914
|
+
// Return stored data (without metadata)
|
|
915
|
+
const { metadata, ...metrics } = stored;
|
|
916
|
+
return metrics;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Fall back to calculation
|
|
921
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
922
|
+
const noShow = getNoShowAppointments(appointments);
|
|
923
|
+
|
|
924
|
+
if (groupBy === 'clinic') {
|
|
925
|
+
return this.groupNoShowsByClinic(noShow, appointments);
|
|
926
|
+
} else if (groupBy === 'practitioner') {
|
|
927
|
+
return this.groupNoShowsByPractitioner(noShow, appointments);
|
|
928
|
+
} else if (groupBy === 'patient') {
|
|
929
|
+
return this.groupNoShowsByPatient(noShow, appointments);
|
|
930
|
+
} else if (groupBy === 'technology') {
|
|
931
|
+
return this.groupNoShowsByTechnology(noShow, appointments);
|
|
932
|
+
} else {
|
|
933
|
+
return this.groupNoShowsByProcedure(noShow, appointments);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Group no-shows by clinic
|
|
939
|
+
*/
|
|
940
|
+
private groupNoShowsByClinic(
|
|
941
|
+
noShow: Appointment[],
|
|
942
|
+
allAppointments: Appointment[],
|
|
943
|
+
): NoShowMetrics[] {
|
|
944
|
+
const clinicMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
945
|
+
|
|
946
|
+
allAppointments.forEach(appointment => {
|
|
947
|
+
const clinicId = appointment.clinicBranchId;
|
|
948
|
+
const clinicName = appointment.clinicInfo?.name || 'Unknown';
|
|
949
|
+
if (!clinicMap.has(clinicId)) {
|
|
950
|
+
clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
|
|
951
|
+
}
|
|
952
|
+
clinicMap.get(clinicId)!.all.push(appointment);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
noShow.forEach(appointment => {
|
|
956
|
+
const clinicId = appointment.clinicBranchId;
|
|
957
|
+
if (clinicMap.has(clinicId)) {
|
|
958
|
+
clinicMap.get(clinicId)!.noShow.push(appointment);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
|
|
963
|
+
entityId: clinicId,
|
|
964
|
+
entityName: data.name,
|
|
965
|
+
entityType: 'clinic' as EntityType,
|
|
966
|
+
totalAppointments: data.all.length,
|
|
967
|
+
noShowAppointments: data.noShow.length,
|
|
968
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
969
|
+
}));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Group no-shows by practitioner
|
|
974
|
+
*/
|
|
975
|
+
private groupNoShowsByPractitioner(
|
|
976
|
+
noShow: Appointment[],
|
|
977
|
+
allAppointments: Appointment[],
|
|
978
|
+
): NoShowMetrics[] {
|
|
979
|
+
const practitionerMap = new Map<
|
|
980
|
+
string,
|
|
981
|
+
{ name: string; noShow: Appointment[]; all: Appointment[] }
|
|
982
|
+
>();
|
|
983
|
+
|
|
984
|
+
allAppointments.forEach(appointment => {
|
|
985
|
+
const practitionerId = appointment.practitionerId;
|
|
986
|
+
const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
|
|
987
|
+
|
|
988
|
+
if (!practitionerMap.has(practitionerId)) {
|
|
989
|
+
practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
|
|
990
|
+
}
|
|
991
|
+
practitionerMap.get(practitionerId)!.all.push(appointment);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
noShow.forEach(appointment => {
|
|
995
|
+
const practitionerId = appointment.practitionerId;
|
|
996
|
+
if (practitionerMap.has(practitionerId)) {
|
|
997
|
+
practitionerMap.get(practitionerId)!.noShow.push(appointment);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
|
|
1002
|
+
entityId: practitionerId,
|
|
1003
|
+
entityName: data.name,
|
|
1004
|
+
entityType: 'practitioner' as EntityType,
|
|
1005
|
+
totalAppointments: data.all.length,
|
|
1006
|
+
noShowAppointments: data.noShow.length,
|
|
1007
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1008
|
+
}));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Group no-shows by patient
|
|
1013
|
+
*/
|
|
1014
|
+
private groupNoShowsByPatient(
|
|
1015
|
+
noShow: Appointment[],
|
|
1016
|
+
allAppointments: Appointment[],
|
|
1017
|
+
): NoShowMetrics[] {
|
|
1018
|
+
const patientMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1019
|
+
|
|
1020
|
+
allAppointments.forEach(appointment => {
|
|
1021
|
+
const patientId = appointment.patientId;
|
|
1022
|
+
const patientName = appointment.patientInfo?.fullName || 'Unknown';
|
|
1023
|
+
|
|
1024
|
+
if (!patientMap.has(patientId)) {
|
|
1025
|
+
patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
|
|
1026
|
+
}
|
|
1027
|
+
patientMap.get(patientId)!.all.push(appointment);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
noShow.forEach(appointment => {
|
|
1031
|
+
const patientId = appointment.patientId;
|
|
1032
|
+
if (patientMap.has(patientId)) {
|
|
1033
|
+
patientMap.get(patientId)!.noShow.push(appointment);
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
return Array.from(patientMap.entries()).map(([patientId, data]) => ({
|
|
1038
|
+
entityId: patientId,
|
|
1039
|
+
entityName: data.name,
|
|
1040
|
+
entityType: 'patient' as EntityType,
|
|
1041
|
+
totalAppointments: data.all.length,
|
|
1042
|
+
noShowAppointments: data.noShow.length,
|
|
1043
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1044
|
+
}));
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Group no-shows by procedure
|
|
1049
|
+
*/
|
|
1050
|
+
private groupNoShowsByProcedure(
|
|
1051
|
+
noShow: Appointment[],
|
|
1052
|
+
allAppointments: Appointment[],
|
|
1053
|
+
): NoShowMetrics[] {
|
|
1054
|
+
const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1055
|
+
|
|
1056
|
+
allAppointments.forEach(appointment => {
|
|
1057
|
+
const procedureId = appointment.procedureId;
|
|
1058
|
+
const procedureName = appointment.procedureInfo?.name || 'Unknown';
|
|
1059
|
+
|
|
1060
|
+
if (!procedureMap.has(procedureId)) {
|
|
1061
|
+
procedureMap.set(procedureId, { name: procedureName, noShow: [], all: [] });
|
|
1062
|
+
}
|
|
1063
|
+
procedureMap.get(procedureId)!.all.push(appointment);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
noShow.forEach(appointment => {
|
|
1067
|
+
const procedureId = appointment.procedureId;
|
|
1068
|
+
if (procedureMap.has(procedureId)) {
|
|
1069
|
+
procedureMap.get(procedureId)!.noShow.push(appointment);
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
1074
|
+
entityId: procedureId,
|
|
1075
|
+
entityName: data.name,
|
|
1076
|
+
entityType: 'procedure' as EntityType,
|
|
1077
|
+
totalAppointments: data.all.length,
|
|
1078
|
+
noShowAppointments: data.noShow.length,
|
|
1079
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1080
|
+
}));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Group no-shows by technology
|
|
1085
|
+
* Aggregates all procedures using the same technology across all doctors
|
|
1086
|
+
*/
|
|
1087
|
+
private groupNoShowsByTechnology(
|
|
1088
|
+
noShow: Appointment[],
|
|
1089
|
+
allAppointments: Appointment[],
|
|
1090
|
+
): NoShowMetrics[] {
|
|
1091
|
+
const technologyMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
|
|
1092
|
+
|
|
1093
|
+
allAppointments.forEach(appointment => {
|
|
1094
|
+
const technologyId =
|
|
1095
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
1096
|
+
const technologyName =
|
|
1097
|
+
appointment.procedureExtendedInfo?.procedureTechnologyName ||
|
|
1098
|
+
appointment.procedureInfo?.technologyName ||
|
|
1099
|
+
'Unknown';
|
|
1100
|
+
|
|
1101
|
+
if (!technologyMap.has(technologyId)) {
|
|
1102
|
+
technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
|
|
1103
|
+
}
|
|
1104
|
+
technologyMap.get(technologyId)!.all.push(appointment);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
noShow.forEach(appointment => {
|
|
1108
|
+
const technologyId =
|
|
1109
|
+
appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
|
|
1110
|
+
if (technologyMap.has(technologyId)) {
|
|
1111
|
+
technologyMap.get(technologyId)!.noShow.push(appointment);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
|
|
1116
|
+
entityId: technologyId,
|
|
1117
|
+
entityName: data.name,
|
|
1118
|
+
entityType: 'technology' as EntityType,
|
|
1119
|
+
totalAppointments: data.all.length,
|
|
1120
|
+
noShowAppointments: data.noShow.length,
|
|
1121
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
1122
|
+
}));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// ==========================================
|
|
1126
|
+
// Financial Analytics
|
|
1127
|
+
// ==========================================
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
1131
|
+
*
|
|
1132
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
1133
|
+
* @param dateRange - Optional date range filter
|
|
1134
|
+
* @param filters - Optional additional filters
|
|
1135
|
+
* @returns Grouped revenue metrics
|
|
1136
|
+
*/
|
|
1137
|
+
async getRevenueMetricsByEntity(
|
|
1138
|
+
groupBy: EntityType,
|
|
1139
|
+
dateRange?: AnalyticsDateRange,
|
|
1140
|
+
filters?: AnalyticsFilters,
|
|
1141
|
+
): Promise<GroupedRevenueMetrics[]> {
|
|
1142
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1143
|
+
return calculateGroupedRevenueMetrics(appointments, groupBy);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Get revenue metrics
|
|
1148
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
1149
|
+
*
|
|
1150
|
+
* @param filters - Optional filters
|
|
1151
|
+
* @param dateRange - Optional date range filter
|
|
1152
|
+
* @param options - Options for reading stored analytics
|
|
1153
|
+
* @returns Revenue metrics
|
|
1154
|
+
*/
|
|
1155
|
+
async getRevenueMetrics(
|
|
1156
|
+
filters?: AnalyticsFilters,
|
|
1157
|
+
dateRange?: AnalyticsDateRange,
|
|
1158
|
+
options?: ReadStoredAnalyticsOptions,
|
|
1159
|
+
): Promise<RevenueMetrics> {
|
|
1160
|
+
// Try to read from stored analytics first
|
|
1161
|
+
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
1162
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
1163
|
+
const stored = await readStoredRevenueMetrics(
|
|
1164
|
+
this.db,
|
|
1165
|
+
filters.clinicBranchId,
|
|
1166
|
+
{ ...options, period },
|
|
1167
|
+
);
|
|
1168
|
+
|
|
1169
|
+
if (stored) {
|
|
1170
|
+
// Return stored data (without metadata)
|
|
1171
|
+
const { metadata, ...metrics } = stored;
|
|
1172
|
+
return metrics;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Fall back to calculation
|
|
1177
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1178
|
+
const completed = getCompletedAppointments(appointments);
|
|
1179
|
+
|
|
1180
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1181
|
+
|
|
1182
|
+
// Calculate revenue by status
|
|
1183
|
+
const revenueByStatus: Partial<Record<AppointmentStatus, number>> = {};
|
|
1184
|
+
Object.values(AppointmentStatus).forEach(status => {
|
|
1185
|
+
const statusAppointments = appointments.filter(a => a.status === status);
|
|
1186
|
+
const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
|
|
1187
|
+
revenueByStatus[status] = statusRevenue;
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// Calculate revenue by payment status
|
|
1191
|
+
const revenueByPaymentStatus: Partial<Record<PaymentStatus, number>> = {};
|
|
1192
|
+
Object.values(PaymentStatus).forEach(paymentStatus => {
|
|
1193
|
+
const paymentAppointments = completed.filter(a => a.paymentStatus === paymentStatus);
|
|
1194
|
+
const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
|
|
1195
|
+
revenueByPaymentStatus[paymentStatus] = paymentRevenue;
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
const unpaid = completed.filter(a => a.paymentStatus === PaymentStatus.UNPAID);
|
|
1199
|
+
const refunded = completed.filter(a => a.paymentStatus === PaymentStatus.REFUNDED);
|
|
1200
|
+
|
|
1201
|
+
const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
|
|
1202
|
+
const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
|
|
1203
|
+
|
|
1204
|
+
// Calculate tax and subtotal from finalbilling if available
|
|
1205
|
+
let totalTax = 0;
|
|
1206
|
+
let totalSubtotal = 0;
|
|
1207
|
+
completed.forEach(appointment => {
|
|
1208
|
+
const costData = calculateAppointmentCost(appointment);
|
|
1209
|
+
if (costData.source === 'finalbilling') {
|
|
1210
|
+
totalTax += costData.tax || 0;
|
|
1211
|
+
totalSubtotal += costData.subtotal || 0;
|
|
1212
|
+
} else {
|
|
1213
|
+
totalSubtotal += costData.cost;
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
return {
|
|
1218
|
+
totalRevenue,
|
|
1219
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1220
|
+
totalAppointments: appointments.length,
|
|
1221
|
+
completedAppointments: completed.length,
|
|
1222
|
+
currency,
|
|
1223
|
+
revenueByStatus,
|
|
1224
|
+
revenueByPaymentStatus,
|
|
1225
|
+
unpaidRevenue,
|
|
1226
|
+
refundedRevenue,
|
|
1227
|
+
totalTax,
|
|
1228
|
+
totalSubtotal,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// ==========================================
|
|
1233
|
+
// Product Usage Analytics
|
|
1234
|
+
// ==========================================
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Get product usage metrics grouped by clinic, practitioner, procedure, or patient
|
|
1238
|
+
*
|
|
1239
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
|
|
1240
|
+
* @param dateRange - Optional date range filter
|
|
1241
|
+
* @param filters - Optional additional filters
|
|
1242
|
+
* @returns Grouped product usage metrics
|
|
1243
|
+
*/
|
|
1244
|
+
async getProductUsageMetricsByEntity(
|
|
1245
|
+
groupBy: EntityType,
|
|
1246
|
+
dateRange?: AnalyticsDateRange,
|
|
1247
|
+
filters?: AnalyticsFilters,
|
|
1248
|
+
): Promise<GroupedProductUsageMetrics[]> {
|
|
1249
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1250
|
+
return calculateGroupedProductUsageMetrics(appointments, groupBy);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Get product usage metrics
|
|
1255
|
+
*
|
|
1256
|
+
* @param productId - Optional product ID (if not provided, returns all products)
|
|
1257
|
+
* @param dateRange - Optional date range filter
|
|
1258
|
+
* @returns Product usage metrics
|
|
1259
|
+
*/
|
|
1260
|
+
async getProductUsageMetrics(
|
|
1261
|
+
productId?: string,
|
|
1262
|
+
dateRange?: AnalyticsDateRange,
|
|
1263
|
+
): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
|
|
1264
|
+
const appointments = await this.fetchAppointments(undefined, dateRange);
|
|
1265
|
+
const completed = getCompletedAppointments(appointments);
|
|
1266
|
+
|
|
1267
|
+
const productMap = new Map<
|
|
1268
|
+
string,
|
|
1269
|
+
{
|
|
1270
|
+
name: string;
|
|
1271
|
+
brandId: string;
|
|
1272
|
+
brandName: string;
|
|
1273
|
+
quantity: number;
|
|
1274
|
+
revenue: number;
|
|
1275
|
+
usageCount: number;
|
|
1276
|
+
procedureMap: Map<string, { name: string; count: number; quantity: number }>;
|
|
1277
|
+
}
|
|
1278
|
+
>();
|
|
1279
|
+
|
|
1280
|
+
completed.forEach(appointment => {
|
|
1281
|
+
const products = extractProductUsage(appointment);
|
|
1282
|
+
products.forEach(product => {
|
|
1283
|
+
if (productId && product.productId !== productId) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (!productMap.has(product.productId)) {
|
|
1288
|
+
productMap.set(product.productId, {
|
|
1289
|
+
name: product.productName,
|
|
1290
|
+
brandId: product.brandId,
|
|
1291
|
+
brandName: product.brandName,
|
|
1292
|
+
quantity: 0,
|
|
1293
|
+
revenue: 0,
|
|
1294
|
+
usageCount: 0,
|
|
1295
|
+
procedureMap: new Map(),
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const productData = productMap.get(product.productId)!;
|
|
1300
|
+
productData.quantity += product.quantity;
|
|
1301
|
+
productData.revenue += product.subtotal;
|
|
1302
|
+
productData.usageCount++;
|
|
1303
|
+
|
|
1304
|
+
// Track usage by procedure
|
|
1305
|
+
const procId = appointment.procedureId;
|
|
1306
|
+
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
1307
|
+
if (productData.procedureMap.has(procId)) {
|
|
1308
|
+
const procData = productData.procedureMap.get(procId)!;
|
|
1309
|
+
procData.count++;
|
|
1310
|
+
procData.quantity += product.quantity;
|
|
1311
|
+
} else {
|
|
1312
|
+
productData.procedureMap.set(procId, {
|
|
1313
|
+
name: procName,
|
|
1314
|
+
count: 1,
|
|
1315
|
+
quantity: product.quantity,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
const results = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
1322
|
+
productId,
|
|
1323
|
+
productName: data.name,
|
|
1324
|
+
brandId: data.brandId,
|
|
1325
|
+
brandName: data.brandName,
|
|
1326
|
+
totalQuantity: data.quantity,
|
|
1327
|
+
totalRevenue: data.revenue,
|
|
1328
|
+
averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
|
|
1329
|
+
currency: 'CHF', // Could be extracted from products
|
|
1330
|
+
usageCount: data.usageCount,
|
|
1331
|
+
averageQuantityPerAppointment:
|
|
1332
|
+
data.usageCount > 0 ? data.quantity / data.usageCount : 0,
|
|
1333
|
+
usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
|
|
1334
|
+
procedureId: procId,
|
|
1335
|
+
procedureName: procData.name,
|
|
1336
|
+
count: procData.count,
|
|
1337
|
+
totalQuantity: procData.quantity,
|
|
1338
|
+
})),
|
|
1339
|
+
}));
|
|
1340
|
+
|
|
1341
|
+
return productId ? results[0] : results;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ==========================================
|
|
1345
|
+
// Patient Analytics
|
|
1346
|
+
// ==========================================
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
|
|
1350
|
+
* Shows patient no-show and cancellation patterns per entity
|
|
1351
|
+
*
|
|
1352
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
|
|
1353
|
+
* @param dateRange - Optional date range filter
|
|
1354
|
+
* @param filters - Optional additional filters
|
|
1355
|
+
* @returns Grouped patient behavior metrics
|
|
1356
|
+
*/
|
|
1357
|
+
async getPatientBehaviorMetricsByEntity(
|
|
1358
|
+
groupBy: 'clinic' | 'practitioner' | 'procedure' | 'technology',
|
|
1359
|
+
dateRange?: AnalyticsDateRange,
|
|
1360
|
+
filters?: AnalyticsFilters,
|
|
1361
|
+
): Promise<GroupedPatientBehaviorMetrics[]> {
|
|
1362
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1363
|
+
return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Get patient analytics
|
|
1368
|
+
*
|
|
1369
|
+
* @param patientId - Optional patient ID (if not provided, returns aggregate)
|
|
1370
|
+
* @param dateRange - Optional date range filter
|
|
1371
|
+
* @returns Patient analytics
|
|
1372
|
+
*/
|
|
1373
|
+
async getPatientAnalytics(
|
|
1374
|
+
patientId?: string,
|
|
1375
|
+
dateRange?: AnalyticsDateRange,
|
|
1376
|
+
): Promise<PatientAnalytics | PatientAnalytics[]> {
|
|
1377
|
+
const appointments = await this.fetchAppointments(patientId ? { patientId } : undefined, dateRange);
|
|
1378
|
+
|
|
1379
|
+
if (patientId) {
|
|
1380
|
+
return this.calculatePatientAnalytics(appointments, patientId);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Group by patient
|
|
1384
|
+
const patientMap = new Map<string, Appointment[]>();
|
|
1385
|
+
appointments.forEach(appointment => {
|
|
1386
|
+
const patId = appointment.patientId;
|
|
1387
|
+
if (!patientMap.has(patId)) {
|
|
1388
|
+
patientMap.set(patId, []);
|
|
1389
|
+
}
|
|
1390
|
+
patientMap.get(patId)!.push(appointment);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
return Array.from(patientMap.entries()).map(([patId, patAppointments]) =>
|
|
1394
|
+
this.calculatePatientAnalytics(patAppointments, patId),
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Calculate analytics for a specific patient
|
|
1400
|
+
*/
|
|
1401
|
+
private calculatePatientAnalytics(appointments: Appointment[], patientId: string): PatientAnalytics {
|
|
1402
|
+
const completed = getCompletedAppointments(appointments);
|
|
1403
|
+
const canceled = getCanceledAppointments(appointments);
|
|
1404
|
+
const noShow = getNoShowAppointments(appointments);
|
|
1405
|
+
|
|
1406
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1407
|
+
|
|
1408
|
+
// Get appointment dates
|
|
1409
|
+
const appointmentDates = appointments
|
|
1410
|
+
.map(a => a.appointmentStartTime.toDate())
|
|
1411
|
+
.sort((a, b) => a.getTime() - b.getTime());
|
|
1412
|
+
|
|
1413
|
+
const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
|
|
1414
|
+
const lastAppointmentDate =
|
|
1415
|
+
appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
|
|
1416
|
+
|
|
1417
|
+
// Calculate average days between appointments
|
|
1418
|
+
let averageDaysBetween = null;
|
|
1419
|
+
if (appointmentDates.length > 1) {
|
|
1420
|
+
const intervals: number[] = [];
|
|
1421
|
+
for (let i = 1; i < appointmentDates.length; i++) {
|
|
1422
|
+
const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
|
|
1423
|
+
intervals.push(diffMs / (1000 * 60 * 60 * 24));
|
|
1424
|
+
}
|
|
1425
|
+
averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Get unique practitioners and clinics
|
|
1429
|
+
const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
|
|
1430
|
+
const uniqueClinics = new Set(appointments.map(a => a.clinicBranchId));
|
|
1431
|
+
|
|
1432
|
+
// Get favorite procedures
|
|
1433
|
+
const procedureMap = new Map<string, { name: string; count: number }>();
|
|
1434
|
+
completed.forEach(appointment => {
|
|
1435
|
+
const procId = appointment.procedureId;
|
|
1436
|
+
const procName = appointment.procedureInfo?.name || 'Unknown';
|
|
1437
|
+
procedureMap.set(procId, {
|
|
1438
|
+
name: procName,
|
|
1439
|
+
count: (procedureMap.get(procId)?.count || 0) + 1,
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const favoriteProcedures = Array.from(procedureMap.entries())
|
|
1444
|
+
.map(([procedureId, data]) => ({
|
|
1445
|
+
procedureId,
|
|
1446
|
+
procedureName: data.name,
|
|
1447
|
+
count: data.count,
|
|
1448
|
+
}))
|
|
1449
|
+
.sort((a, b) => b.count - a.count)
|
|
1450
|
+
.slice(0, 5);
|
|
1451
|
+
|
|
1452
|
+
const patientName = appointments.length > 0 ? appointments[0].patientInfo?.fullName || 'Unknown' : 'Unknown';
|
|
1453
|
+
|
|
1454
|
+
return {
|
|
1455
|
+
patientId,
|
|
1456
|
+
patientName,
|
|
1457
|
+
totalAppointments: appointments.length,
|
|
1458
|
+
completedAppointments: completed.length,
|
|
1459
|
+
canceledAppointments: canceled.length,
|
|
1460
|
+
noShowAppointments: noShow.length,
|
|
1461
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
1462
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
1463
|
+
totalRevenue,
|
|
1464
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1465
|
+
currency,
|
|
1466
|
+
lifetimeValue: totalRevenue,
|
|
1467
|
+
firstAppointmentDate,
|
|
1468
|
+
lastAppointmentDate,
|
|
1469
|
+
averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
|
|
1470
|
+
uniquePractitioners: uniquePractitioners.size,
|
|
1471
|
+
uniqueClinics: uniqueClinics.size,
|
|
1472
|
+
favoriteProcedures,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// ==========================================
|
|
1477
|
+
// Dashboard Analytics
|
|
1478
|
+
// ==========================================
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Determines analytics period from date range
|
|
1482
|
+
*/
|
|
1483
|
+
private determinePeriodFromDateRange(dateRange: AnalyticsDateRange): AnalyticsPeriod {
|
|
1484
|
+
const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
|
|
1485
|
+
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
1486
|
+
|
|
1487
|
+
if (diffDays <= 1) return 'daily';
|
|
1488
|
+
if (diffDays <= 7) return 'weekly';
|
|
1489
|
+
if (diffDays <= 31) return 'monthly';
|
|
1490
|
+
if (diffDays <= 365) return 'yearly';
|
|
1491
|
+
return 'all_time';
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Get comprehensive dashboard data
|
|
1496
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
1497
|
+
*
|
|
1498
|
+
* @param filters - Optional filters
|
|
1499
|
+
* @param dateRange - Optional date range filter
|
|
1500
|
+
* @param options - Options for reading stored analytics
|
|
1501
|
+
* @returns Complete dashboard analytics
|
|
1502
|
+
*/
|
|
1503
|
+
async getDashboardData(
|
|
1504
|
+
filters?: AnalyticsFilters,
|
|
1505
|
+
dateRange?: AnalyticsDateRange,
|
|
1506
|
+
options?: ReadStoredAnalyticsOptions,
|
|
1507
|
+
): Promise<DashboardAnalytics> {
|
|
1508
|
+
// Try to read from stored analytics first
|
|
1509
|
+
if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
|
|
1510
|
+
const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
|
|
1511
|
+
const stored = await readStoredDashboardAnalytics(
|
|
1512
|
+
this.db,
|
|
1513
|
+
filters.clinicBranchId,
|
|
1514
|
+
{ ...options, period },
|
|
1515
|
+
);
|
|
1516
|
+
|
|
1517
|
+
if (stored) {
|
|
1518
|
+
const { metadata, ...analytics } = stored;
|
|
1519
|
+
return analytics;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Fall back to calculation
|
|
1524
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
1525
|
+
|
|
1526
|
+
const completed = getCompletedAppointments(appointments);
|
|
1527
|
+
const canceled = getCanceledAppointments(appointments);
|
|
1528
|
+
const noShow = getNoShowAppointments(appointments);
|
|
1529
|
+
const pending = appointments.filter(a => a.status === AppointmentStatus.PENDING);
|
|
1530
|
+
const confirmed = appointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
|
|
1531
|
+
|
|
1532
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
1533
|
+
|
|
1534
|
+
// Get unique counts
|
|
1535
|
+
const uniquePatients = new Set(appointments.map(a => a.patientId));
|
|
1536
|
+
const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
|
|
1537
|
+
const uniqueProcedures = new Set(appointments.map(a => a.procedureId));
|
|
1538
|
+
|
|
1539
|
+
// Get top practitioners (limit to 5)
|
|
1540
|
+
const practitionerMetrics = await Promise.all(
|
|
1541
|
+
Array.from(uniquePractitioners)
|
|
1542
|
+
.slice(0, 5)
|
|
1543
|
+
.map(practitionerId => this.getPractitionerAnalytics(practitionerId, dateRange)),
|
|
1544
|
+
);
|
|
1545
|
+
|
|
1546
|
+
// Get top procedures (limit to 5)
|
|
1547
|
+
const procedureMetricsResults = await Promise.all(
|
|
1548
|
+
Array.from(uniqueProcedures)
|
|
1549
|
+
.slice(0, 5)
|
|
1550
|
+
.map(procedureId => this.getProcedureAnalytics(procedureId, dateRange)),
|
|
1551
|
+
);
|
|
1552
|
+
// Filter out arrays and ensure we have ProcedureAnalytics objects
|
|
1553
|
+
const procedureMetrics = procedureMetricsResults.filter(
|
|
1554
|
+
(result): result is ProcedureAnalytics => !Array.isArray(result),
|
|
1555
|
+
);
|
|
1556
|
+
|
|
1557
|
+
// Get cancellation and no-show metrics (aggregated)
|
|
1558
|
+
const cancellationMetrics = await this.getCancellationMetrics('clinic', dateRange);
|
|
1559
|
+
const noShowMetrics = await this.getNoShowMetrics('clinic', dateRange);
|
|
1560
|
+
|
|
1561
|
+
// Get time efficiency
|
|
1562
|
+
const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
|
|
1563
|
+
|
|
1564
|
+
// Get top products
|
|
1565
|
+
const productMetrics = await this.getProductUsageMetrics(undefined, dateRange);
|
|
1566
|
+
const topProducts = Array.isArray(productMetrics)
|
|
1567
|
+
? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5)
|
|
1568
|
+
: [];
|
|
1569
|
+
|
|
1570
|
+
// Get recent activity (last 10 appointments)
|
|
1571
|
+
const recentActivity = appointments
|
|
1572
|
+
.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis())
|
|
1573
|
+
.slice(0, 10)
|
|
1574
|
+
.map(appointment => {
|
|
1575
|
+
let type: 'appointment' | 'cancellation' | 'completion' | 'no_show' = 'appointment';
|
|
1576
|
+
let description = '';
|
|
1577
|
+
|
|
1578
|
+
if (appointment.status === AppointmentStatus.COMPLETED) {
|
|
1579
|
+
type = 'completion';
|
|
1580
|
+
description = `Appointment completed: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1581
|
+
} else if (
|
|
1582
|
+
appointment.status === AppointmentStatus.CANCELED_PATIENT ||
|
|
1583
|
+
appointment.status === AppointmentStatus.CANCELED_CLINIC ||
|
|
1584
|
+
appointment.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
|
|
1585
|
+
) {
|
|
1586
|
+
type = 'cancellation';
|
|
1587
|
+
description = `Appointment canceled: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1588
|
+
} else if (appointment.status === AppointmentStatus.NO_SHOW) {
|
|
1589
|
+
type = 'no_show';
|
|
1590
|
+
description = `No-show: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1591
|
+
} else {
|
|
1592
|
+
description = `Appointment ${appointment.status}: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
return {
|
|
1596
|
+
type,
|
|
1597
|
+
date: appointment.appointmentStartTime.toDate(),
|
|
1598
|
+
description,
|
|
1599
|
+
entityId: appointment.practitionerId,
|
|
1600
|
+
entityName: appointment.practitionerInfo?.name || 'Unknown',
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
return {
|
|
1605
|
+
overview: {
|
|
1606
|
+
totalAppointments: appointments.length,
|
|
1607
|
+
completedAppointments: completed.length,
|
|
1608
|
+
canceledAppointments: canceled.length,
|
|
1609
|
+
noShowAppointments: noShow.length,
|
|
1610
|
+
pendingAppointments: pending.length,
|
|
1611
|
+
confirmedAppointments: confirmed.length,
|
|
1612
|
+
totalRevenue,
|
|
1613
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
1614
|
+
currency,
|
|
1615
|
+
uniquePatients: uniquePatients.size,
|
|
1616
|
+
uniquePractitioners: uniquePractitioners.size,
|
|
1617
|
+
uniqueProcedures: uniqueProcedures.size,
|
|
1618
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
1619
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
1620
|
+
},
|
|
1621
|
+
practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
|
|
1622
|
+
procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
|
|
1623
|
+
cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
|
|
1624
|
+
noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
|
|
1625
|
+
revenueTrends: [], // TODO: Implement revenue trends
|
|
1626
|
+
timeEfficiency,
|
|
1627
|
+
topProducts,
|
|
1628
|
+
recentActivity,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|