@blackcode_sa/metaestetics-api 1.12.67 → 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/backoffice/index.d.mts +40 -0
- package/dist/backoffice/index.d.ts +40 -0
- package/dist/backoffice/index.js +118 -18
- package/dist/backoffice/index.mjs +118 -20
- package/dist/index.d.mts +1097 -2
- package/dist/index.d.ts +1097 -2
- package/dist/index.js +4224 -2091
- package/dist/index.mjs +3941 -1821
- 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/backoffice/services/category.service.ts +49 -6
- package/src/backoffice/services/subcategory.service.ts +50 -6
- package/src/backoffice/services/technology.service.ts +53 -6
- 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/services/procedure/procedure.service.ts +3 -3
- 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,347 @@
|
|
|
1
|
+
import { Firestore, Timestamp, doc, getDoc } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
ANALYTICS_COLLECTION,
|
|
4
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
5
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
6
|
+
CLINIC_ANALYTICS_SUBCOLLECTION,
|
|
7
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
8
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
9
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
10
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
11
|
+
REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
12
|
+
AnalyticsPeriod,
|
|
13
|
+
ReadStoredAnalyticsOptions,
|
|
14
|
+
StoredPractitionerAnalytics,
|
|
15
|
+
StoredProcedureAnalytics,
|
|
16
|
+
StoredClinicAnalytics,
|
|
17
|
+
StoredDashboardAnalytics,
|
|
18
|
+
StoredTimeEfficiencyMetrics,
|
|
19
|
+
StoredCancellationMetrics,
|
|
20
|
+
StoredNoShowMetrics,
|
|
21
|
+
StoredRevenueMetrics,
|
|
22
|
+
} from '../../../types/analytics';
|
|
23
|
+
import { CLINICS_COLLECTION } from '../../../types/clinic';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Checks if stored analytics data is fresh enough to use
|
|
27
|
+
*
|
|
28
|
+
* @param computedAt - Timestamp when analytics were computed
|
|
29
|
+
* @param maxAgeHours - Maximum age in hours
|
|
30
|
+
* @returns True if data is fresh enough
|
|
31
|
+
*/
|
|
32
|
+
export function isAnalyticsDataFresh(computedAt: Timestamp, maxAgeHours: number): boolean {
|
|
33
|
+
const now = Timestamp.now();
|
|
34
|
+
const ageMs = now.toMillis() - computedAt.toMillis();
|
|
35
|
+
const ageHours = ageMs / (1000 * 60 * 60);
|
|
36
|
+
return ageHours <= maxAgeHours;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reads stored analytics document
|
|
41
|
+
*
|
|
42
|
+
* @param db - Firestore instance
|
|
43
|
+
* @param clinicBranchId - Clinic branch ID
|
|
44
|
+
* @param subcollection - Analytics subcollection name
|
|
45
|
+
* @param documentId - Document ID
|
|
46
|
+
* @param period - Analytics period
|
|
47
|
+
* @returns Stored analytics document or null
|
|
48
|
+
*/
|
|
49
|
+
async function readStoredAnalytics<T>(
|
|
50
|
+
db: Firestore,
|
|
51
|
+
clinicBranchId: string,
|
|
52
|
+
subcollection: string,
|
|
53
|
+
documentId: string,
|
|
54
|
+
period: AnalyticsPeriod,
|
|
55
|
+
): Promise<T | null> {
|
|
56
|
+
try {
|
|
57
|
+
const docRef = doc(
|
|
58
|
+
db,
|
|
59
|
+
CLINICS_COLLECTION,
|
|
60
|
+
clinicBranchId,
|
|
61
|
+
ANALYTICS_COLLECTION,
|
|
62
|
+
subcollection,
|
|
63
|
+
period,
|
|
64
|
+
documentId,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const docSnap = await getDoc(docRef);
|
|
68
|
+
if (!docSnap.exists()) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return docSnap.data() as T;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error(`[StoredAnalytics] Error reading ${subcollection}/${period}/${documentId}:`, error);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads stored practitioner analytics
|
|
81
|
+
*/
|
|
82
|
+
export async function readStoredPractitionerAnalytics(
|
|
83
|
+
db: Firestore,
|
|
84
|
+
clinicBranchId: string,
|
|
85
|
+
practitionerId: string,
|
|
86
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
87
|
+
): Promise<StoredPractitionerAnalytics | null> {
|
|
88
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
89
|
+
|
|
90
|
+
if (!useCache) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stored = await readStoredAnalytics<StoredPractitionerAnalytics>(
|
|
95
|
+
db,
|
|
96
|
+
clinicBranchId,
|
|
97
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
98
|
+
practitionerId,
|
|
99
|
+
period,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!stored) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if data is fresh enough
|
|
107
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return stored;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reads stored procedure analytics
|
|
116
|
+
*/
|
|
117
|
+
export async function readStoredProcedureAnalytics(
|
|
118
|
+
db: Firestore,
|
|
119
|
+
clinicBranchId: string,
|
|
120
|
+
procedureId: string,
|
|
121
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
122
|
+
): Promise<StoredProcedureAnalytics | null> {
|
|
123
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
124
|
+
|
|
125
|
+
if (!useCache) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const stored = await readStoredAnalytics<StoredProcedureAnalytics>(
|
|
130
|
+
db,
|
|
131
|
+
clinicBranchId,
|
|
132
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
133
|
+
procedureId,
|
|
134
|
+
period,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!stored) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return stored;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reads stored clinic analytics
|
|
150
|
+
*/
|
|
151
|
+
export async function readStoredClinicAnalytics(
|
|
152
|
+
db: Firestore,
|
|
153
|
+
clinicBranchId: string,
|
|
154
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
155
|
+
): Promise<StoredClinicAnalytics | null> {
|
|
156
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
157
|
+
|
|
158
|
+
if (!useCache) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const stored = await readStoredAnalytics<StoredClinicAnalytics>(
|
|
163
|
+
db,
|
|
164
|
+
clinicBranchId,
|
|
165
|
+
CLINIC_ANALYTICS_SUBCOLLECTION,
|
|
166
|
+
'current',
|
|
167
|
+
period,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (!stored) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return stored;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Reads stored dashboard analytics
|
|
183
|
+
*/
|
|
184
|
+
export async function readStoredDashboardAnalytics(
|
|
185
|
+
db: Firestore,
|
|
186
|
+
clinicBranchId: string,
|
|
187
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
188
|
+
): Promise<StoredDashboardAnalytics | null> {
|
|
189
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
190
|
+
|
|
191
|
+
if (!useCache) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stored = await readStoredAnalytics<StoredDashboardAnalytics>(
|
|
196
|
+
db,
|
|
197
|
+
clinicBranchId,
|
|
198
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
199
|
+
'current',
|
|
200
|
+
period,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (!stored) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return stored;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reads stored time efficiency metrics
|
|
216
|
+
*/
|
|
217
|
+
export async function readStoredTimeEfficiencyMetrics(
|
|
218
|
+
db: Firestore,
|
|
219
|
+
clinicBranchId: string,
|
|
220
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
221
|
+
): Promise<StoredTimeEfficiencyMetrics | null> {
|
|
222
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
223
|
+
|
|
224
|
+
if (!useCache) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stored = await readStoredAnalytics<StoredTimeEfficiencyMetrics>(
|
|
229
|
+
db,
|
|
230
|
+
clinicBranchId,
|
|
231
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
232
|
+
'current',
|
|
233
|
+
period,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (!stored) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return stored;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reads stored revenue metrics
|
|
249
|
+
*/
|
|
250
|
+
export async function readStoredRevenueMetrics(
|
|
251
|
+
db: Firestore,
|
|
252
|
+
clinicBranchId: string,
|
|
253
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
254
|
+
): Promise<StoredRevenueMetrics | null> {
|
|
255
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
256
|
+
|
|
257
|
+
if (!useCache) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const stored = await readStoredAnalytics<StoredRevenueMetrics>(
|
|
262
|
+
db,
|
|
263
|
+
clinicBranchId,
|
|
264
|
+
REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
265
|
+
'current',
|
|
266
|
+
period,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (!stored) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return stored;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Reads stored cancellation metrics
|
|
282
|
+
*/
|
|
283
|
+
export async function readStoredCancellationMetrics(
|
|
284
|
+
db: Firestore,
|
|
285
|
+
clinicBranchId: string,
|
|
286
|
+
entityType: string,
|
|
287
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
288
|
+
): Promise<StoredCancellationMetrics | null> {
|
|
289
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
290
|
+
|
|
291
|
+
if (!useCache) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const stored = await readStoredAnalytics<StoredCancellationMetrics>(
|
|
296
|
+
db,
|
|
297
|
+
clinicBranchId,
|
|
298
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
299
|
+
entityType,
|
|
300
|
+
period,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (!stored) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return stored;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Reads stored no-show metrics
|
|
316
|
+
*/
|
|
317
|
+
export async function readStoredNoShowMetrics(
|
|
318
|
+
db: Firestore,
|
|
319
|
+
clinicBranchId: string,
|
|
320
|
+
entityType: string,
|
|
321
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
322
|
+
): Promise<StoredNoShowMetrics | null> {
|
|
323
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
324
|
+
|
|
325
|
+
if (!useCache) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const stored = await readStoredAnalytics<StoredNoShowMetrics>(
|
|
330
|
+
db,
|
|
331
|
+
clinicBranchId,
|
|
332
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
333
|
+
entityType,
|
|
334
|
+
period,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
if (!stored) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return stored;
|
|
346
|
+
}
|
|
347
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Appointment, AppointmentStatus } from '../../../types/appointment';
|
|
2
|
+
import { Timestamp } from 'firebase/firestore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculates time efficiency metrics for an appointment
|
|
6
|
+
*
|
|
7
|
+
* @param appointment - The appointment to calculate metrics for
|
|
8
|
+
* @returns Time efficiency data or null if insufficient data
|
|
9
|
+
*/
|
|
10
|
+
export function calculateTimeEfficiency(appointment: Appointment): {
|
|
11
|
+
bookedDuration: number; // minutes
|
|
12
|
+
actualDuration: number; // minutes
|
|
13
|
+
efficiency: number; // percentage
|
|
14
|
+
overrun: number; // minutes (positive if actual > booked)
|
|
15
|
+
underutilization: number; // minutes (positive if booked > actual)
|
|
16
|
+
} | null {
|
|
17
|
+
const startTime = appointment.appointmentStartTime;
|
|
18
|
+
const endTime = appointment.appointmentEndTime;
|
|
19
|
+
|
|
20
|
+
if (!startTime || !endTime) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Calculate booked duration in minutes
|
|
25
|
+
const bookedDurationMs = endTime.toMillis() - startTime.toMillis();
|
|
26
|
+
const bookedDuration = Math.round(bookedDurationMs / (1000 * 60));
|
|
27
|
+
|
|
28
|
+
// Use actual duration if available, otherwise use booked duration
|
|
29
|
+
const actualDuration = appointment.actualDurationMinutes || bookedDuration;
|
|
30
|
+
|
|
31
|
+
// Calculate efficiency percentage
|
|
32
|
+
const efficiency = bookedDuration > 0 ? (actualDuration / bookedDuration) * 100 : 100;
|
|
33
|
+
|
|
34
|
+
// Calculate overrun (positive if actual > booked)
|
|
35
|
+
const overrun = actualDuration > bookedDuration ? actualDuration - bookedDuration : 0;
|
|
36
|
+
|
|
37
|
+
// Calculate underutilization (positive if booked > actual)
|
|
38
|
+
const underutilization = bookedDuration > actualDuration ? bookedDuration - actualDuration : 0;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
bookedDuration,
|
|
42
|
+
actualDuration,
|
|
43
|
+
efficiency,
|
|
44
|
+
overrun,
|
|
45
|
+
underutilization,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Calculates average time metrics from an array of appointments
|
|
51
|
+
*
|
|
52
|
+
* @param appointments - Array of appointments
|
|
53
|
+
* @returns Average time metrics
|
|
54
|
+
*/
|
|
55
|
+
export function calculateAverageTimeMetrics(appointments: Appointment[]): {
|
|
56
|
+
averageBookedDuration: number;
|
|
57
|
+
averageActualDuration: number;
|
|
58
|
+
averageEfficiency: number;
|
|
59
|
+
totalOverrun: number;
|
|
60
|
+
totalUnderutilization: number;
|
|
61
|
+
averageOverrun: number;
|
|
62
|
+
averageUnderutilization: number;
|
|
63
|
+
appointmentsWithActualTime: number;
|
|
64
|
+
} {
|
|
65
|
+
if (appointments.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
averageBookedDuration: 0,
|
|
68
|
+
averageActualDuration: 0,
|
|
69
|
+
averageEfficiency: 0,
|
|
70
|
+
totalOverrun: 0,
|
|
71
|
+
totalUnderutilization: 0,
|
|
72
|
+
averageOverrun: 0,
|
|
73
|
+
averageUnderutilization: 0,
|
|
74
|
+
appointmentsWithActualTime: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let totalBookedDuration = 0;
|
|
79
|
+
let totalActualDuration = 0;
|
|
80
|
+
let totalOverrun = 0;
|
|
81
|
+
let totalUnderutilization = 0;
|
|
82
|
+
let appointmentsWithActualTime = 0;
|
|
83
|
+
|
|
84
|
+
appointments.forEach(appointment => {
|
|
85
|
+
const timeData = calculateTimeEfficiency(appointment);
|
|
86
|
+
if (timeData) {
|
|
87
|
+
totalBookedDuration += timeData.bookedDuration;
|
|
88
|
+
totalActualDuration += timeData.actualDuration;
|
|
89
|
+
totalOverrun += timeData.overrun;
|
|
90
|
+
totalUnderutilization += timeData.underutilization;
|
|
91
|
+
|
|
92
|
+
if (appointment.actualDurationMinutes !== undefined) {
|
|
93
|
+
appointmentsWithActualTime++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const count = appointments.length;
|
|
99
|
+
const averageBookedDuration = count > 0 ? totalBookedDuration / count : 0;
|
|
100
|
+
const averageActualDuration = count > 0 ? totalActualDuration / count : 0;
|
|
101
|
+
const averageEfficiency = averageBookedDuration > 0 ? (averageActualDuration / averageBookedDuration) * 100 : 0;
|
|
102
|
+
const averageOverrun = count > 0 ? totalOverrun / count : 0;
|
|
103
|
+
const averageUnderutilization = count > 0 ? totalUnderutilization / count : 0;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
averageBookedDuration: Math.round(averageBookedDuration),
|
|
107
|
+
averageActualDuration: Math.round(averageActualDuration),
|
|
108
|
+
averageEfficiency: Math.round(averageEfficiency * 100) / 100,
|
|
109
|
+
totalOverrun,
|
|
110
|
+
totalUnderutilization,
|
|
111
|
+
averageOverrun: Math.round(averageOverrun),
|
|
112
|
+
averageUnderutilization: Math.round(averageUnderutilization),
|
|
113
|
+
appointmentsWithActualTime,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Groups appointments by efficiency ranges
|
|
119
|
+
*
|
|
120
|
+
* @param appointments - Array of appointments
|
|
121
|
+
* @returns Distribution of appointments by efficiency range
|
|
122
|
+
*/
|
|
123
|
+
export function calculateEfficiencyDistribution(appointments: Appointment[]): Array<{
|
|
124
|
+
range: string;
|
|
125
|
+
count: number;
|
|
126
|
+
percentage: number;
|
|
127
|
+
}> {
|
|
128
|
+
const ranges = [
|
|
129
|
+
{ label: '0-50%', min: 0, max: 50 },
|
|
130
|
+
{ label: '50-75%', min: 50, max: 75 },
|
|
131
|
+
{ label: '75-100%', min: 75, max: 100 },
|
|
132
|
+
{ label: '100%+', min: 100, max: Infinity },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const distribution = ranges.map(range => ({
|
|
136
|
+
range: range.label,
|
|
137
|
+
count: 0,
|
|
138
|
+
percentage: 0,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
let validCount = 0;
|
|
142
|
+
|
|
143
|
+
appointments.forEach(appointment => {
|
|
144
|
+
const timeData = calculateTimeEfficiency(appointment);
|
|
145
|
+
if (timeData) {
|
|
146
|
+
validCount++;
|
|
147
|
+
const efficiency = timeData.efficiency;
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
150
|
+
if (efficiency >= ranges[i].min && efficiency < ranges[i].max) {
|
|
151
|
+
distribution[i].count++;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Calculate percentages
|
|
159
|
+
if (validCount > 0) {
|
|
160
|
+
distribution.forEach(item => {
|
|
161
|
+
item.percentage = Math.round((item.count / validCount) * 100 * 100) / 100;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return distribution;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Calculates cancellation lead time in hours
|
|
170
|
+
*
|
|
171
|
+
* @param appointment - The appointment to calculate lead time for
|
|
172
|
+
* @returns Lead time in hours, or null if insufficient data
|
|
173
|
+
*/
|
|
174
|
+
export function calculateCancellationLeadTime(appointment: Appointment): number | null {
|
|
175
|
+
if (!appointment.cancellationTime || !appointment.appointmentStartTime) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cancellationTime = appointment.cancellationTime.toMillis();
|
|
180
|
+
const appointmentTime = appointment.appointmentStartTime.toMillis();
|
|
181
|
+
const diffMs = appointmentTime - cancellationTime;
|
|
182
|
+
|
|
183
|
+
// Return positive hours (time before appointment)
|
|
184
|
+
return Math.max(0, diffMs / (1000 * 60 * 60));
|
|
185
|
+
}
|
|
186
|
+
|
|
@@ -2123,10 +2123,58 @@ export class AppointmentService extends BaseService {
|
|
|
2123
2123
|
showNoShow: false,
|
|
2124
2124
|
});
|
|
2125
2125
|
|
|
2126
|
+
// Also get appointments that have recommendations but might not be COMPLETED yet
|
|
2127
|
+
// (e.g., doctor finalized but appointment status is still CONFIRMED/CHECKED_IN)
|
|
2128
|
+
// Get all patient appointments that are past their end time
|
|
2129
|
+
const now = new Date();
|
|
2130
|
+
const allPastAppointments = await this.getPatientAppointments(patientId, {
|
|
2131
|
+
endDate: now,
|
|
2132
|
+
status: [
|
|
2133
|
+
AppointmentStatus.COMPLETED,
|
|
2134
|
+
AppointmentStatus.CONFIRMED,
|
|
2135
|
+
AppointmentStatus.CHECKED_IN,
|
|
2136
|
+
AppointmentStatus.IN_PROGRESS,
|
|
2137
|
+
],
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
// Filter to only include appointments that are past their end time AND have recommendations
|
|
2141
|
+
const appointmentsWithRecommendations = allPastAppointments.appointments.filter(
|
|
2142
|
+
appointment => {
|
|
2143
|
+
const endTime = appointment.appointmentEndTime?.toMillis
|
|
2144
|
+
? appointment.appointmentEndTime.toMillis()
|
|
2145
|
+
: appointment.appointmentEndTime?.seconds
|
|
2146
|
+
? appointment.appointmentEndTime.seconds * 1000
|
|
2147
|
+
: null;
|
|
2148
|
+
|
|
2149
|
+
if (!endTime) return false;
|
|
2150
|
+
|
|
2151
|
+
const isPastEndTime = endTime < now.getTime();
|
|
2152
|
+
const hasRecommendations =
|
|
2153
|
+
(appointment.metadata?.recommendedProcedures?.length || 0) > 0;
|
|
2154
|
+
|
|
2155
|
+
return isPastEndTime && hasRecommendations;
|
|
2156
|
+
},
|
|
2157
|
+
);
|
|
2158
|
+
|
|
2159
|
+
// Combine and deduplicate by appointment ID
|
|
2160
|
+
const allAppointmentsMap = new Map<string, Appointment>();
|
|
2161
|
+
|
|
2162
|
+
// Add completed appointments
|
|
2163
|
+
pastAppointments.appointments.forEach(apt => {
|
|
2164
|
+
allAppointmentsMap.set(apt.id, apt);
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
// Add appointments with recommendations (will overwrite if duplicate)
|
|
2168
|
+
appointmentsWithRecommendations.forEach(apt => {
|
|
2169
|
+
allAppointmentsMap.set(apt.id, apt);
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
const allAppointments = Array.from(allAppointmentsMap.values());
|
|
2173
|
+
|
|
2126
2174
|
const recommendations: NextStepsRecommendation[] = [];
|
|
2127
2175
|
|
|
2128
|
-
// Iterate through
|
|
2129
|
-
for (const appointment of
|
|
2176
|
+
// Iterate through all appointments and extract recommendations
|
|
2177
|
+
for (const appointment of allAppointments) {
|
|
2130
2178
|
// Filter by clinic if specified
|
|
2131
2179
|
if (options?.clinicBranchId && appointment.clinicBranchId !== options.clinicBranchId) {
|
|
2132
2180
|
continue;
|
|
@@ -2181,10 +2229,6 @@ export class AppointmentService extends BaseService {
|
|
|
2181
2229
|
? recommendations.slice(0, options.limit)
|
|
2182
2230
|
: recommendations;
|
|
2183
2231
|
|
|
2184
|
-
console.log(
|
|
2185
|
-
`[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for patient ${patientId}`,
|
|
2186
|
-
);
|
|
2187
|
-
|
|
2188
2232
|
return limitedRecommendations;
|
|
2189
2233
|
} catch (error) {
|
|
2190
2234
|
console.error(
|
package/src/services/index.ts
CHANGED
|
@@ -1497,9 +1497,9 @@ export class ProcedureService extends BaseService {
|
|
|
1497
1497
|
// Get references to related entities (Category, Subcategory, Technology)
|
|
1498
1498
|
// For consultation, we don't need a product
|
|
1499
1499
|
const [category, subcategory, technology] = await Promise.all([
|
|
1500
|
-
this.categoryService.
|
|
1501
|
-
this.subcategoryService.
|
|
1502
|
-
this.technologyService.
|
|
1500
|
+
this.categoryService.getByIdInternal(data.categoryId),
|
|
1501
|
+
this.subcategoryService.getByIdInternal(data.categoryId, data.subcategoryId),
|
|
1502
|
+
this.technologyService.getByIdInternal(data.technologyId),
|
|
1503
1503
|
]);
|
|
1504
1504
|
|
|
1505
1505
|
if (!category || !subcategory || !technology) {
|