diff --git a/docs/swagger.json b/docs/swagger.json index a080378..107ed87 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2952,12 +2952,12 @@ "tags": [ "KPI" ], - "summary": "Récupérer le plat le plus populaire sur une période donnée", + "summary": "Retrieve the most popular dish over a given period", "parameters": [ { "name": "restaurant_id", "in": "path", - "description": "ID du restaurant", + "description": "Restaurant ID", "required": true, "style": "simple", "explode": false, @@ -2968,7 +2968,7 @@ { "name": "timeBegin", "in": "query", - "description": "Date de début de la période d'analyse (optionnel)", + "description": "Start date of the analysis period (optional)", "required": false, "style": "form", "explode": true, @@ -2980,7 +2980,7 @@ { "name": "timeEnd", "in": "query", - "description": "Date de fin de la période d'analyse (optionnel)", + "description": "End date of the analysis period (optional)", "required": false, "style": "form", "explode": true, @@ -2992,7 +2992,7 @@ ], "responses": { "200": { - "description": "Succès - Plat le plus populaire retourné", + "description": "Success - Most popular dish returned", "content": { "application/json": { "schema": { @@ -3000,12 +3000,12 @@ "properties": { "food": { "type": "integer", - "description": "ID du plat le plus commandé", + "description": "ID of the most ordered dish", "example": 123 }, "nbrOrders": { "type": "integer", - "description": "Nombre total de commandes pour ce plat", + "description": "Total number of orders for this dish", "example": 100 } } @@ -3014,16 +3014,16 @@ } }, "400": { - "description": "Paramètres invalides" + "description": "Invalid parameters" }, "401": { - "description": "Authentification requise" + "description": "Authentication required" }, "404": { - "description": "Aucun plat commandé sur la période spécifiée" + "description": "No dishes ordered in the specified period" }, "5XX": { - "description": "Erreur interne du serveur" + "description": "Internal server error" } }, "security": [ @@ -3136,13 +3136,13 @@ "tags": [ "KPI" ], - "summary": "Prévision des ventes journalières pour chaque plat", - "description": "Retourne, pour chaque plat, une prévision du nombre de ventes par jour sur la période analysée. La date cible est optionnelle et permet d'obtenir une prévision pour un jour spécifique.", + "summary": "Daily sales forecast for each dish", + "description": "Returns, for each dish, a forecast of the number of sales per day over the analyzed period. The target date is optional and allows for a forecast for a specific day.", "parameters": [ { "name": "restaurant_id", "in": "path", - "description": "ID du restaurant", + "description": "ID of the restaurant", "required": true, "style": "simple", "explode": false, @@ -3153,7 +3153,7 @@ { "name": "date", "in": "query", - "description": "Date cible au format ISO (optionnel, ex: 2025-05-28T20:58:53.621Z)", + "description": "Target date in ISO format (optional, e.g., 2025-05-28T20:58:53.621Z)", "required": false, "schema": { "type": "string", @@ -3163,7 +3163,7 @@ ], "responses": { "200": { - "description": "Prévision des ventes par plat", + "description": "Sales forecast by dish", "content": { "application/json": { "schema": { @@ -3173,17 +3173,17 @@ "properties": { "food": { "type": "integer", - "description": "ID du plat" + "description": "ID of the dish" }, "forecast": { "type": "number", - "description": "Prévision du nombre de ventes par jour" + "description": "Forecast of the number of sales per day" } } } }, "examples": { - "Prévision": { + "Forecast": { "value": [ { "food": 12, "forecast": 15 }, { "food": 13, "forecast": 8 } @@ -3194,13 +3194,13 @@ } }, "400": { - "description": "Paramètres invalides (ID restaurant ou date)" + "description": "Invalid parameters (restaurant ID or date)" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { - "description": "Aucune commande trouvée pour un plat" + "description": "No orders found for a dish" }, "5XX": { "$ref": "#/components/responses/ServerError" @@ -3438,12 +3438,12 @@ "tags": [ "KPI" ], - "summary": "Récupérer les KPIs pour un cas d'usage spécifique (POS ou KDS)", + "summary": "Retrieve KPIs for a specific use case (POS or KDS)", "parameters": [ { "name": "restaurant_id", "in": "path", - "description": "ID du restaurant", + "description": "Restaurant ID", "required": true, "style": "simple", "explode": false, @@ -3454,7 +3454,7 @@ { "name": "useCase", "in": "query", - "description": "Cas d'usage (POS ou KDS)", + "description": "Use case (POS or KDS)", "required": true, "schema": { "type": "string", @@ -3464,7 +3464,7 @@ ], "responses": { "200": { - "description": "KPIs pour le cas d'usage demandé", + "description": "KPIs for the requested use case", "content": { "application/json": { "schema": { @@ -3472,12 +3472,12 @@ { "type": "object", "properties": { - "ordersInProgress": { "type": "integer", "description": "Nombre de commandes en cours" }, - "clientsCount": { "type": "integer", "description": "Nombre de clients sur place aujourd'hui" }, - "averageWaitingTime1h": { "type": "object", "description": "Temps d'attente moyen sur 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, - "averageWaitingTime15m": { "type": "object", "description": "Temps d'attente moyen sur 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, - "averagePrepTime1h": { "type": "object", "description": "Temps de préparation moyen sur 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, - "averagePrepTime15m": { "type": "object", "description": "Temps de préparation moyen sur 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } } + "ordersInProgress": { "type": "integer", "description": "Number of orders in progress" }, + "clientsCount": { "type": "integer", "description": "Number of clients on site today" }, + "averageWaitingTime1h": { "type": "object", "description": "Average waiting time over 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, + "averageWaitingTime15m": { "type": "object", "description": "Average waiting time over 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, + "averagePrepTime1h": { "type": "object", "description": "Average preparation time over 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, + "averagePrepTime15m": { "type": "object", "description": "Average preparation time over 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } } }, "example": { "ordersInProgress": 100, @@ -3491,12 +3491,12 @@ { "type": "object", "properties": { - "last15mOrders": { "type": "integer", "description": "Nombre de commandes sur les 15 dernières minutes" }, - "clientsCount": { "type": "integer", "description": "Nombre de clients sur place aujourd'hui" }, - "averageWaitingTime1h": { "type": "object", "description": "Temps d'attente moyen sur 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, - "averageWaitingTime15m": { "type": "object", "description": "Temps d'attente moyen sur 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, - "averagePrepTime1h": { "type": "object", "description": "Temps de préparation moyen sur 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, - "averagePrepTime15m": { "type": "object", "description": "Temps de préparation moyen sur 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } } + "last15mOrders": { "type": "integer", "description": "Number of orders over the last 15 minutes" }, + "clientsCount": { "type": "integer", "description": "Number of clients on site today" }, + "averageWaitingTime1h": { "type": "object", "description": "Average waiting time over 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, + "averageWaitingTime15m": { "type": "object", "description": "Average waiting time over 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, + "averagePrepTime1h": { "type": "object", "description": "Average preparation time over 1h", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } }, + "averagePrepTime15m": { "type": "object", "description": "Average preparation time over 15min", "properties": { "hours": { "type": "integer" }, "minutes": { "type": "integer" }, "seconds": { "type": "integer" } } } }, "example": { "last15mOrders": 20, @@ -3513,7 +3513,7 @@ } }, "400": { - "description": "Paramètres invalides" + "description": "Invalid parameters" }, "401": { "$ref": "#/components/responses/Unauthorized" @@ -3585,6 +3585,174 @@ }, "security": [{ "BearerAuth": [] }] } + }, + "/{restaurant_id}/kpi/revenues": { + "summary": "KPI Endpoints", + "get": { + "tags": [ + "KPI" + ], + "summary": "Retrieve Revenues for a Specific Period", + "description": "Returns revenues and the number of orders for a given period. If the breakdown parameter is provided, the data is segmented by time slots. The averageWaitingTime field only appears if useCase is defined.", + "parameters": [ + { + "name": "restaurant_id", + "in": "path", + "description": "Restaurant ID", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + }, + { + "name": "timeBegin", + "in": "query", + "description": "Start date of the analysis period (format: YYYY-MM-DD)", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "timeEnd", + "in": "query", + "description": "End date of the analysis period (format: YYYY-MM-DD)", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "breakdown", + "in": "query", + "description": "Duration in minutes for segmenting data by time slots (optional)", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "integer", + "example": 15 + } + }, + { + "name": "useCase", + "in": "query", + "description": "Use case (POS or KDS) - if defined, includes averageWaitingTime in the response", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "enum": ["statsPOS"] + } + } + ], + "responses": { + "200": { + "description": "Revenues for the requested period", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "description": "Response without breakdown", + "properties": { + "revenues": { + "type": "number", + "description": "Total revenue amount", + "example": 0 + }, + "ordersCount": { + "type": "integer", + "description": "Total number of orders", + "example": 50 + }, + "averageWaitingTime": { + "type": "object", + "description": "Average waiting time (only present if useCase is defined)", + "properties": { + "hours": { "type": "integer", "example": 0 }, + "minutes": { "type": "integer", "example": 15 }, + "seconds": { "type": "integer", "example": 30 } + } + } + }, + "example": { + "revenues": 0, + "ordersCount": 50, + "averageWaitingTime": { "hours": 0, "minutes": 15, "seconds": 30 } + } + }, + { + "type": "object", + "description": "Response with breakdown by time slots", + "additionalProperties": { + "type": "object", + "properties": { + "revenues": { + "type": "number", + "description": "Revenue amount for this slot", + "example": 0 + }, + "ordersCount": { + "type": "integer", + "description": "Number of orders for this slot", + "example": 5 + }, + "averageWaitingTime": { + "type": "object", + "description": "Average waiting time for this slot (only present if useCase is defined)", + "properties": { + "hours": { "type": "integer", "example": 0 }, + "minutes": { "type": "integer", "example": 12 }, + "seconds": { "type": "integer", "example": 45 } + } + } + } + }, + "example": { + "12:00": { + "revenues": 0, + "ordersCount": 5, + "averageWaitingTime": { "hours": 0, "minutes": 12, "seconds": 45 } + }, + "12:15": { + "revenues": 0, + "ordersCount": 3, + "averageWaitingTime": { "hours": 0, "minutes": 10, "seconds": 30 } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Invalid parameters (restaurant ID or date format)" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "5XX": { + "$ref": "#/components/responses/ServerError" + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } } }, "components": { diff --git a/src/modules/kpi/kpi.controller.ts b/src/modules/kpi/kpi.controller.ts index 047f84a..8bbc14a 100644 --- a/src/modules/kpi/kpi.controller.ts +++ b/src/modules/kpi/kpi.controller.ts @@ -501,4 +501,98 @@ export class KpiController { ); } } + + /** + * Get the revenues for a specific period + * @param idRestaurant - The restaurant identifier (must be positive) + * @param timeBegin - Start date of the analysis period (optional) + * @param timeEnd - End date of the analysis period (optional) + * @param breakdown - The breakdown of the analysis period (optional) + * @param useCase - The use case (POS or KDS) + * @returns The revenues for the specified period + * @throws {BadRequestException} When input parameters are invalid + * @throws {InternalServerErrorException} When server encounters an error + * @example + * GET /api/1/kpi/revenues?timeBegin=2024-01-01&timeEnd=2024-01-31&breakdown=15&useCase=POS + * // returns { "12:00": { revenues: 100, ordersCount: 10, averageWaitingTime: 10 }, "12:15": { revenues: 100, ordersCount: 10, averageWaitingTime: 10 } } + */ + @Get('revenues') + async kpiRevenues( + @Param('idRestaurant', PositiveNumberPipe) idRestaurant: number, + @Query('timeBegin', DatePipe) timeBegin: string, + @Query('timeEnd', DatePipe) timeEnd: string, + @Query('breakdown') breakdown?: number, + @Query('useCase', UseCasePipe) useCase?: string, + ) { + if (breakdown) { + const slots: { timeBegin: string; timeEnd: string }[] = []; + let current = new Date(timeBegin); + const end = new Date(timeEnd); + const orders = {}; + + while (current < end) { + const slotStart = new Date(current); + const slotEnd = new Date(current.getTime() + breakdown * 60000); + slots.push({ + timeBegin: slotStart.toISOString(), + timeEnd: (slotEnd < end ? slotEnd : end).toISOString(), + }); + current = slotEnd; + } + + for (const slot of slots) { + const date = new Date(slot.timeBegin); + const hours = date.getUTCHours().toString().padStart(2, '0'); + const minutes = date.getUTCMinutes().toString().padStart(2, '0'); + const averageWaitingTime = + useCase !== undefined + ? await this.kpiService.averageTimeOrders( + idRestaurant, + slot.timeBegin, + slot.timeEnd, + undefined, + ) + : undefined; + + const { total, ordersCount } = await this.kpiService.revenueTotal( + idRestaurant, + slot.timeBegin, + slot.timeEnd, + undefined, + ); + const orderData: any = { + revenues: total, + ordersCount: ordersCount, + }; + if (useCase !== undefined) + orderData.averageWaitingTime = averageWaitingTime; + orders[`${hours}:${minutes}`] = orderData; + } + + return orders; + } else { + const averageWaitingTime = + useCase !== undefined + ? await this.kpiService.averageTimeOrders( + idRestaurant, + timeBegin, + timeEnd, + undefined, + ) + : undefined; + + const { total, ordersCount } = await this.kpiService.revenueTotal( + idRestaurant, + timeBegin, + timeEnd, + undefined, + ); + const orders: any = { + revenues: total, + ordersCount: ordersCount, + }; + if (useCase !== undefined) orders.averageWaitingTime = averageWaitingTime; + return orders; + } + } } diff --git a/src/modules/kpi/kpi.service.ts b/src/modules/kpi/kpi.service.ts index b871fec..0a68748 100644 --- a/src/modules/kpi/kpi.service.ts +++ b/src/modules/kpi/kpi.service.ts @@ -613,4 +613,46 @@ export class KpiService extends DB { })); } } + + /** + * Calculates the total revenue of a restaurant over a given period + * @param idRestaurant - The restaurant identifier + * @param timeBegin - Start date of the period (optional) + * @param timeEnd - End date of the period (optional) + * @param channel - The order channel (optional) + * @returns The total revenue and the number of orders + */ + async revenueTotal( + idRestaurant: number, + timeBegin: string, + timeEnd: string, + channel?: string, + ): Promise<{ total: number; ordersCount: number }> { + const db = this.getDbConnection(); + let orders = await db + .collection('restaurant') + .aggregate([ + { $match: { id: idRestaurant } }, + { $unwind: '$orders' }, + { $match: { 'orders.channel': channel || { $exists: true } } }, + { $match: { 'orders.payment': { $exists: true } } }, + { $project: { _id: 0, 'orders.timePayment': 1, 'orders.total': 1 } }, + ]) + .toArray(); + + if (timeBegin && timeEnd) { + const beginDate = new Date(timeBegin); + const endDate = new Date(timeEnd); + orders = orders.filter((item) => { + const orderDate = new Date(item.orders.timePayment); + return orderDate >= beginDate && orderDate <= endDate; + }); + } + + const totalRevenue = orders.reduce((sum, item) => { + return sum + parseFloat(item.orders.total); + }, 0); + + return { total: totalRevenue, ordersCount: orders.length }; + } } diff --git a/src/modules/kpi/pipe/useCase.pipe.ts b/src/modules/kpi/pipe/useCase.pipe.ts index 060aa17..45cbff5 100644 --- a/src/modules/kpi/pipe/useCase.pipe.ts +++ b/src/modules/kpi/pipe/useCase.pipe.ts @@ -5,7 +5,8 @@ export class UseCasePipe implements PipeTransform { transform(value: any) { if (value === undefined) return value; if (typeof value !== 'string') throw new BadRequestException(); - if (value !== 'POS' && value !== 'KDS') return undefined; + if (value !== 'POS' && value !== 'KDS' && value !== 'statsPOS') + return undefined; return value; } } diff --git a/src/modules/orders/DTO/food_ordered.dto.ts b/src/modules/orders/DTO/food_ordered.dto.ts index e8e2097..16382b0 100644 --- a/src/modules/orders/DTO/food_ordered.dto.ts +++ b/src/modules/orders/DTO/food_ordered.dto.ts @@ -24,6 +24,10 @@ class ModsIngredient { @IsString() @IsNotEmpty() ingredient: string; + + @IsNumber() + @IsNotEmpty() + suppPrice: number; } export class FoodOrderedDto { diff --git a/src/modules/orders/DTO/orders.dto.ts b/src/modules/orders/DTO/orders.dto.ts index c657254..325f345 100644 --- a/src/modules/orders/DTO/orders.dto.ts +++ b/src/modules/orders/DTO/orders.dto.ts @@ -55,6 +55,10 @@ export class OrdersDto { @Type(() => PaymentDto) payment?: PaymentDto[]; + @IsOptional() + @IsString() + timePayment?: string; + @IsOptional() @IsNumber() id?: number; diff --git a/src/modules/orders/orders.service.ts b/src/modules/orders/orders.service.ts index 12654f5..a92a356 100644 --- a/src/modules/orders/orders.service.ts +++ b/src/modules/orders/orders.service.ts @@ -678,11 +678,14 @@ export class OrdersService extends DB { $unset: { 'pos_config.tables.$.orderId': '' }, }, ); - return await db - .collection('restaurant') - .updateOne( - { id: idRestaurant, 'orders.id': idOrder }, - { $set: { 'orders.$.payment': payment } }, - ); + return await db.collection('restaurant').updateOne( + { id: idRestaurant, 'orders.id': idOrder }, + { + $set: { + 'orders.$.payment': payment, + 'orders.$.timePayment': new Date().toISOString(), + }, + }, + ); } }