@blackcode_sa/metaestetics-api 1.13.0 → 1.13.2
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 +106 -1
- package/dist/admin/index.d.ts +106 -1
- package/dist/admin/index.js +1303 -130
- package/dist/admin/index.mjs +1303 -130
- package/dist/index.d.mts +360 -2
- package/dist/index.d.ts +360 -2
- package/dist/index.js +3422 -1888
- package/dist/index.mjs +3121 -1588
- package/package.json +1 -1
- package/src/services/analytics/README.md +17 -0
- package/src/services/analytics/TRENDS.md +380 -0
- package/src/services/analytics/analytics.service.ts +540 -30
- package/src/services/analytics/index.ts +1 -0
- package/src/services/analytics/review-analytics.service.ts +941 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +32 -4
- package/src/services/analytics/utils/grouping.utils.ts +40 -0
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
- package/src/services/appointment/appointment.service.ts +9 -0
- package/src/services/procedure/procedure.service.ts +419 -4
- package/src/services/reviews/reviews.service.ts +58 -7
- package/src/types/analytics/analytics.types.ts +98 -1
- package/src/types/analytics/grouped-analytics.types.ts +25 -0
|
@@ -67,7 +67,11 @@ export function calculateAppointmentCost(appointment: Appointment): {
|
|
|
67
67
|
/**
|
|
68
68
|
* Calculates total revenue from an array of appointments
|
|
69
69
|
*
|
|
70
|
-
*
|
|
70
|
+
* IMPORTANT: This function should only be called with COMPLETED appointments.
|
|
71
|
+
* Only completed procedures generate revenue. Confirmed, pending, canceled, and no-show
|
|
72
|
+
* appointments should be filtered out before calling this function.
|
|
73
|
+
*
|
|
74
|
+
* @param appointments - Array of appointments (should be filtered to COMPLETED only)
|
|
71
75
|
* @returns Total revenue and currency
|
|
72
76
|
*/
|
|
73
77
|
export function calculateTotalRevenue(appointments: Appointment[]): {
|
|
@@ -96,8 +100,19 @@ export function calculateTotalRevenue(appointments: Appointment[]): {
|
|
|
96
100
|
/**
|
|
97
101
|
* Extracts product usage from appointment metadata
|
|
98
102
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
103
|
+
* IMPORTANT: This function should only be called with COMPLETED appointments.
|
|
104
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
105
|
+
* Only completed procedures generate product usage and revenue.
|
|
106
|
+
*
|
|
107
|
+
* NOTE ON PRICING:
|
|
108
|
+
* - Price overrides (priceOverrideAmount) are always applied if available
|
|
109
|
+
* - Subtotal is recalculated to ensure price overrides are reflected
|
|
110
|
+
* - Product revenue shows SUBTOTAL (before tax) - tax is applied at appointment level
|
|
111
|
+
* - Tax is included in appointment revenue (finalbilling.finalPrice) but not in product revenue
|
|
112
|
+
* - Product subtotals should sum to finalbilling.subtotalAll (before tax)
|
|
113
|
+
*
|
|
114
|
+
* @param appointment - The appointment to extract products from (should be COMPLETED)
|
|
115
|
+
* @returns Array of product usage data with subtotal (before tax)
|
|
101
116
|
*/
|
|
102
117
|
export function extractProductUsage(appointment: Appointment): Array<{
|
|
103
118
|
productId: string;
|
|
@@ -131,9 +146,22 @@ export function extractProductUsage(appointment: Appointment): Array<{
|
|
|
131
146
|
Object.values(zonesData).forEach(items => {
|
|
132
147
|
items.forEach(item => {
|
|
133
148
|
if (item.type === 'item' && item.productId) {
|
|
149
|
+
// Always use priceOverrideAmount if available, otherwise use price
|
|
150
|
+
// This ensures price overrides are properly applied to product calculations
|
|
134
151
|
const price = item.priceOverrideAmount || item.price || 0;
|
|
135
152
|
const quantity = item.quantity || 1;
|
|
136
|
-
|
|
153
|
+
|
|
154
|
+
// Always recalculate subtotal based on the actual price (with override)
|
|
155
|
+
// This ensures price overrides are reflected in product revenue
|
|
156
|
+
// Use stored subtotal only if it matches the calculated value (to handle rounding)
|
|
157
|
+
const calculatedSubtotal = price * quantity;
|
|
158
|
+
const storedSubtotal = item.subtotal || 0;
|
|
159
|
+
|
|
160
|
+
// Use stored subtotal if it's close to calculated (within 0.01 for rounding differences)
|
|
161
|
+
// Otherwise recalculate to ensure price override is applied correctly
|
|
162
|
+
const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01
|
|
163
|
+
? storedSubtotal
|
|
164
|
+
: calculatedSubtotal;
|
|
137
165
|
|
|
138
166
|
products.push({
|
|
139
167
|
productId: item.productId,
|
|
@@ -101,6 +101,9 @@ export function groupAppointmentsByEntity(
|
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
103
|
* Calculates grouped revenue metrics
|
|
104
|
+
*
|
|
105
|
+
* IMPORTANT: Only COMPLETED appointments are included in revenue calculations.
|
|
106
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from financial metrics.
|
|
104
107
|
*/
|
|
105
108
|
export function calculateGroupedRevenueMetrics(
|
|
106
109
|
appointments: Appointment[],
|
|
@@ -139,6 +142,15 @@ export function calculateGroupedRevenueMetrics(
|
|
|
139
142
|
}
|
|
140
143
|
});
|
|
141
144
|
|
|
145
|
+
// Get practitioner info when grouping by procedure
|
|
146
|
+
let practitionerId: string | undefined;
|
|
147
|
+
let practitionerName: string | undefined;
|
|
148
|
+
if (entityType === 'procedure' && entityAppointments.length > 0) {
|
|
149
|
+
const firstAppointment = entityAppointments[0];
|
|
150
|
+
practitionerId = firstAppointment.practitionerId;
|
|
151
|
+
practitionerName = firstAppointment.practitionerInfo?.name;
|
|
152
|
+
}
|
|
153
|
+
|
|
142
154
|
return {
|
|
143
155
|
entityId,
|
|
144
156
|
entityName: data.name,
|
|
@@ -153,12 +165,18 @@ export function calculateGroupedRevenueMetrics(
|
|
|
153
165
|
refundedRevenue,
|
|
154
166
|
totalTax,
|
|
155
167
|
totalSubtotal,
|
|
168
|
+
...(practitionerId && { practitionerId }),
|
|
169
|
+
...(practitionerName && { practitionerName }),
|
|
156
170
|
};
|
|
157
171
|
});
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/**
|
|
161
175
|
* Calculates grouped product usage metrics
|
|
176
|
+
*
|
|
177
|
+
* IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
|
|
178
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
179
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
|
|
162
180
|
*/
|
|
163
181
|
export function calculateGroupedProductUsageMetrics(
|
|
164
182
|
appointments: Appointment[],
|
|
@@ -220,6 +238,15 @@ export function calculateGroupedProductUsageMetrics(
|
|
|
220
238
|
const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
|
|
221
239
|
const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
|
|
222
240
|
|
|
241
|
+
// Get practitioner info when grouping by procedure
|
|
242
|
+
let practitionerId: string | undefined;
|
|
243
|
+
let practitionerName: string | undefined;
|
|
244
|
+
if (entityType === 'procedure' && entityAppointments.length > 0) {
|
|
245
|
+
const firstAppointment = entityAppointments[0];
|
|
246
|
+
practitionerId = firstAppointment.practitionerId;
|
|
247
|
+
practitionerName = firstAppointment.practitionerInfo?.name;
|
|
248
|
+
}
|
|
249
|
+
|
|
223
250
|
return {
|
|
224
251
|
entityId,
|
|
225
252
|
entityName: data.name,
|
|
@@ -231,6 +258,8 @@ export function calculateGroupedProductUsageMetrics(
|
|
|
231
258
|
averageProductsPerAppointment:
|
|
232
259
|
entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
|
|
233
260
|
topProducts,
|
|
261
|
+
...(practitionerId && { practitionerId }),
|
|
262
|
+
...(practitionerName && { practitionerName }),
|
|
234
263
|
};
|
|
235
264
|
});
|
|
236
265
|
}
|
|
@@ -253,6 +282,15 @@ export function calculateGroupedTimeEfficiencyMetrics(
|
|
|
253
282
|
|
|
254
283
|
const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
|
|
255
284
|
|
|
285
|
+
// Get practitioner info when grouping by procedure
|
|
286
|
+
let practitionerId: string | undefined;
|
|
287
|
+
let practitionerName: string | undefined;
|
|
288
|
+
if (entityType === 'procedure' && entityAppointments.length > 0) {
|
|
289
|
+
const firstAppointment = entityAppointments[0];
|
|
290
|
+
practitionerId = firstAppointment.practitionerId;
|
|
291
|
+
practitionerName = firstAppointment.practitionerInfo?.name;
|
|
292
|
+
}
|
|
293
|
+
|
|
256
294
|
return {
|
|
257
295
|
entityId,
|
|
258
296
|
entityName: data.name,
|
|
@@ -266,6 +304,8 @@ export function calculateGroupedTimeEfficiencyMetrics(
|
|
|
266
304
|
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
267
305
|
averageOverrun: timeMetrics.averageOverrun,
|
|
268
306
|
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
307
|
+
...(practitionerId && { practitionerId }),
|
|
308
|
+
...(practitionerName && { practitionerName }),
|
|
269
309
|
};
|
|
270
310
|
});
|
|
271
311
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Appointment } from '../../../types/appointment';
|
|
2
|
+
import { Timestamp } from 'firebase/firestore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Trend period type
|
|
6
|
+
*/
|
|
7
|
+
export type TrendPeriod = 'week' | 'month' | 'quarter' | 'year';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Period information for trend grouping
|
|
11
|
+
*/
|
|
12
|
+
export interface PeriodInfo {
|
|
13
|
+
period: string; // e.g., "2024-W01", "2024-01", "2024-Q1", "2024"
|
|
14
|
+
startDate: Date;
|
|
15
|
+
endDate: Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Calculates the start and end dates for a given period
|
|
20
|
+
*/
|
|
21
|
+
export function getPeriodDates(date: Date, period: TrendPeriod): PeriodInfo {
|
|
22
|
+
const year = date.getFullYear();
|
|
23
|
+
const month = date.getMonth();
|
|
24
|
+
const day = date.getDate();
|
|
25
|
+
|
|
26
|
+
let startDate: Date;
|
|
27
|
+
let endDate: Date;
|
|
28
|
+
let periodString: string;
|
|
29
|
+
|
|
30
|
+
switch (period) {
|
|
31
|
+
case 'week': {
|
|
32
|
+
// Get Monday of the week
|
|
33
|
+
const dayOfWeek = date.getDay();
|
|
34
|
+
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
|
35
|
+
startDate = new Date(year, month, diff);
|
|
36
|
+
startDate.setHours(0, 0, 0, 0);
|
|
37
|
+
|
|
38
|
+
endDate = new Date(startDate);
|
|
39
|
+
endDate.setDate(endDate.getDate() + 6);
|
|
40
|
+
endDate.setHours(23, 59, 59, 999);
|
|
41
|
+
|
|
42
|
+
// ISO week format: YYYY-Www
|
|
43
|
+
const weekNumber = getWeekNumber(date);
|
|
44
|
+
periodString = `${year}-W${weekNumber.toString().padStart(2, '0')}`;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'month': {
|
|
49
|
+
startDate = new Date(year, month, 1);
|
|
50
|
+
startDate.setHours(0, 0, 0, 0);
|
|
51
|
+
|
|
52
|
+
endDate = new Date(year, month + 1, 0);
|
|
53
|
+
endDate.setHours(23, 59, 59, 999);
|
|
54
|
+
|
|
55
|
+
periodString = `${year}-${(month + 1).toString().padStart(2, '0')}`;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case 'quarter': {
|
|
60
|
+
const quarter = Math.floor(month / 3);
|
|
61
|
+
const quarterStartMonth = quarter * 3;
|
|
62
|
+
|
|
63
|
+
startDate = new Date(year, quarterStartMonth, 1);
|
|
64
|
+
startDate.setHours(0, 0, 0, 0);
|
|
65
|
+
|
|
66
|
+
endDate = new Date(year, quarterStartMonth + 3, 0);
|
|
67
|
+
endDate.setHours(23, 59, 59, 999);
|
|
68
|
+
|
|
69
|
+
periodString = `${year}-Q${quarter + 1}`;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case 'year': {
|
|
74
|
+
startDate = new Date(year, 0, 1);
|
|
75
|
+
startDate.setHours(0, 0, 0, 0);
|
|
76
|
+
|
|
77
|
+
endDate = new Date(year, 11, 31);
|
|
78
|
+
endDate.setHours(23, 59, 59, 999);
|
|
79
|
+
|
|
80
|
+
periodString = `${year}`;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
period: periodString,
|
|
87
|
+
startDate,
|
|
88
|
+
endDate,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Gets ISO week number for a date
|
|
94
|
+
*/
|
|
95
|
+
function getWeekNumber(date: Date): number {
|
|
96
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
97
|
+
const dayNum = d.getUTCDay() || 7;
|
|
98
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
99
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
100
|
+
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Groups appointments by trend period
|
|
105
|
+
*/
|
|
106
|
+
export function groupAppointmentsByPeriod(
|
|
107
|
+
appointments: Appointment[],
|
|
108
|
+
period: TrendPeriod,
|
|
109
|
+
): Map<string, Appointment[]> {
|
|
110
|
+
const periodMap = new Map<string, Appointment[]>();
|
|
111
|
+
|
|
112
|
+
appointments.forEach(appointment => {
|
|
113
|
+
const appointmentDate = appointment.appointmentStartTime.toDate();
|
|
114
|
+
const periodInfo = getPeriodDates(appointmentDate, period);
|
|
115
|
+
const periodKey = periodInfo.period;
|
|
116
|
+
|
|
117
|
+
if (!periodMap.has(periodKey)) {
|
|
118
|
+
periodMap.set(periodKey, []);
|
|
119
|
+
}
|
|
120
|
+
periodMap.get(periodKey)!.push(appointment);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return periodMap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generates all periods between start and end date for a given period type
|
|
128
|
+
*/
|
|
129
|
+
export function generatePeriods(
|
|
130
|
+
startDate: Date,
|
|
131
|
+
endDate: Date,
|
|
132
|
+
period: TrendPeriod,
|
|
133
|
+
): PeriodInfo[] {
|
|
134
|
+
const periods: PeriodInfo[] = [];
|
|
135
|
+
const current = new Date(startDate);
|
|
136
|
+
|
|
137
|
+
while (current <= endDate) {
|
|
138
|
+
const periodInfo = getPeriodDates(current, period);
|
|
139
|
+
|
|
140
|
+
// Only add if period overlaps with our date range
|
|
141
|
+
if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
|
|
142
|
+
periods.push(periodInfo);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Move to next period
|
|
146
|
+
switch (period) {
|
|
147
|
+
case 'week':
|
|
148
|
+
current.setDate(current.getDate() + 7);
|
|
149
|
+
break;
|
|
150
|
+
case 'month':
|
|
151
|
+
current.setMonth(current.getMonth() + 1);
|
|
152
|
+
break;
|
|
153
|
+
case 'quarter':
|
|
154
|
+
current.setMonth(current.getMonth() + 3);
|
|
155
|
+
break;
|
|
156
|
+
case 'year':
|
|
157
|
+
current.setFullYear(current.getFullYear() + 1);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Calculates percentage change between two values
|
|
167
|
+
* @param current - Current value
|
|
168
|
+
* @param previous - Previous value
|
|
169
|
+
* @returns Percentage change (positive = increase, negative = decrease)
|
|
170
|
+
*/
|
|
171
|
+
export function calculatePercentageChange(current: number, previous: number): number {
|
|
172
|
+
if (previous === 0) {
|
|
173
|
+
return current > 0 ? 100 : 0;
|
|
174
|
+
}
|
|
175
|
+
return ((current - previous) / previous) * 100;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Gets trend direction and percentage change
|
|
180
|
+
*/
|
|
181
|
+
export interface TrendChange {
|
|
182
|
+
value: number;
|
|
183
|
+
previousValue: number;
|
|
184
|
+
percentageChange: number;
|
|
185
|
+
direction: 'up' | 'down' | 'stable';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getTrendChange(current: number, previous: number): TrendChange {
|
|
189
|
+
const percentageChange = calculatePercentageChange(current, previous);
|
|
190
|
+
const direction: 'up' | 'down' | 'stable' =
|
|
191
|
+
percentageChange > 0.01 ? 'up' : percentageChange < -0.01 ? 'down' : 'stable';
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
value: current,
|
|
195
|
+
previousValue: previous,
|
|
196
|
+
percentageChange: Math.abs(percentageChange),
|
|
197
|
+
direction,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
@@ -1833,6 +1833,12 @@ export class AppointmentService extends BaseService {
|
|
|
1833
1833
|
finalizationNotes: null,
|
|
1834
1834
|
};
|
|
1835
1835
|
|
|
1836
|
+
// Update payment status if billing data exists but status is NOT_APPLICABLE
|
|
1837
|
+
// This handles cases where appointment was created with price 0 but billing was added later
|
|
1838
|
+
const shouldUpdatePaymentStatus =
|
|
1839
|
+
finalbilling.finalPrice > 0 &&
|
|
1840
|
+
appointment.paymentStatus === PaymentStatus.NOT_APPLICABLE;
|
|
1841
|
+
|
|
1836
1842
|
const updateData: UpdateAppointmentData = {
|
|
1837
1843
|
metadata: {
|
|
1838
1844
|
selectedZones: currentMetadata.selectedZones,
|
|
@@ -1848,6 +1854,9 @@ export class AppointmentService extends BaseService {
|
|
|
1848
1854
|
finalbilling,
|
|
1849
1855
|
finalizationNotes: currentMetadata.finalizationNotes,
|
|
1850
1856
|
},
|
|
1857
|
+
...(shouldUpdatePaymentStatus && {
|
|
1858
|
+
paymentStatus: PaymentStatus.UNPAID,
|
|
1859
|
+
}),
|
|
1851
1860
|
updatedAt: serverTimestamp(),
|
|
1852
1861
|
};
|
|
1853
1862
|
|