From 08952ae26f9f119c53f883baae9865cd86100797 Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sat, 10 May 2025 23:45:50 -0400 Subject: [PATCH 1/9] add sales prediction model --- src/app.module.ts | 2 ++ src/sales/sales.controller.ts | 13 ++++++++ src/sales/sales.module.ts | 12 +++++++ src/sales/sales.service.ts | 61 +++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 src/sales/sales.controller.ts create mode 100644 src/sales/sales.module.ts create mode 100644 src/sales/sales.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index a23fa01..d63fa27 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { CartModule } from './cart/cart.module'; import { RecommendationModule } from './recommendation/recommendation.module'; import { ReportsModule } from './reports/reports.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { SalesModule } from './sales/sales.module'; @Module({ imports: [ @@ -67,6 +68,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; CartModule, RecommendationModule, ReportsModule, + SalesModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts new file mode 100644 index 0000000..22d2418 --- /dev/null +++ b/src/sales/sales.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { SalesService } from './sales.service'; + +@Controller('sales') +export class SalesController { + constructor(private readonly salesService: SalesService) {} + + @Get('predict') + async predict(@Query('days') days: string) { + const daysAhead = parseInt(days, 10) || 7; + return await this.salesService.predictNext(daysAhead); + } +} diff --git a/src/sales/sales.module.ts b/src/sales/sales.module.ts new file mode 100644 index 0000000..694b680 --- /dev/null +++ b/src/sales/sales.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Order } from 'src/order/entities/order.entity'; +import { SalesController } from './sales.controller'; +import { SalesService } from './sales.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Order])], + controllers: [SalesController], + providers: [SalesService], +}) +export class SalesModule {} diff --git a/src/sales/sales.service.ts b/src/sales/sales.service.ts new file mode 100644 index 0000000..1dfd28e --- /dev/null +++ b/src/sales/sales.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Order, OrderStatus } from '../order/entities/order.entity'; +import { Repository } from 'typeorm'; +import * as tf from '@tensorflow/tfjs'; +@Injectable() +export class SalesService { + constructor( + @InjectRepository(Order) + private readonly orderRepository: Repository, + ) {} + + async getDailySales(): Promise<{ date: string; total: number }[]> { + const sales = await this.orderRepository + .createQueryBuilder('order') + .select('DATE(order.createdAt)', 'date') + .addSelect('SUM(order.totalPrice)', 'total') + .where('order.status = :status', { status: OrderStatus.COMPLETED }) + .groupBy('DATE(order.createdAt)') + .orderBy('DATE(order.createdAt)', 'ASC') + .getRawMany<{ date: string; total: string }>(); + + return sales.map((row) => ({ + date: row.date, + total: parseInt(row.total), + })); + } + + async predictNext(daysAhead: number = 7) { + const salesData = await this.getDailySales(); + if (salesData.length < 2) return []; + + const inputs = salesData.map((_, i) => i); + const labels = salesData.map((s) => s.total); + + const xs = tf.tensor2d(inputs, [inputs.length, 1]); + const ys = tf.tensor2d(labels, [labels.length, 1]); + + const model = tf.sequential(); + model.add(tf.layers.dense({ inputShape: [1], units: 1 })); + model.compile({ optimizer: 'sgd', loss: 'meanSquaredError' }); + + await model.fit(xs, ys, { epochs: 300 }); + + const futureDays = Array.from( + { length: daysAhead }, + (_, i) => inputs.length + i, + ); + const predictions = model.predict( + tf.tensor2d(futureDays, [daysAhead, 1]), + ) as tf.Tensor; + + const predictedValues = await predictions.array(); + return futureDays.map((i, index) => ({ + dayIndex: i, + predictedTotal: parseInt( + (predictedValues as number[][])[index][0].toFixed(2), + ), + })); + } +} From 89b400492770c41856bbdf849edb6e40faf630cb Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sun, 11 May 2025 17:15:05 -0400 Subject: [PATCH 2/9] add days without orders to the model --- src/sales/sales.service.ts | 52 +++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/sales/sales.service.ts b/src/sales/sales.service.ts index 1dfd28e..733dd52 100644 --- a/src/sales/sales.service.ts +++ b/src/sales/sales.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Order, OrderStatus } from '../order/entities/order.entity'; import { Repository } from 'typeorm'; import * as tf from '@tensorflow/tfjs'; + @Injectable() export class SalesService { constructor( @@ -26,8 +27,35 @@ export class SalesService { })); } + fillMissingDates(data: { date: string; total: number }[]) { + if (data.length < 2) return data; + + const filled = []; + const start = new Date(data[0].date); + const yesterday = new Date(); + yesterday.setHours(0, 0, 0, 0); + yesterday.setDate(yesterday.getDate() - 1); + + const dateMap = new Map( + data.map((d) => [new Date(d.date).toISOString().split('T')[0], d.total]), + ); + + for (let d = new Date(start); d <= yesterday; d.setDate(d.getDate() + 1)) { + const dateStr = d.toISOString().split('T')[0]; + + filled.push({ + date: dateStr, + total: dateMap.get(dateStr) ?? 0, + }); + } + + return filled; + } + async predictNext(daysAhead: number = 7) { - const salesData = await this.getDailySales(); + const rawSalesData = await this.getDailySales(); + const salesData = this.fillMissingDates(rawSalesData); + if (salesData.length < 2) return []; const inputs = salesData.map((_, i) => i); @@ -46,16 +74,26 @@ export class SalesService { { length: daysAhead }, (_, i) => inputs.length + i, ); + const predictions = model.predict( tf.tensor2d(futureDays, [daysAhead, 1]), ) as tf.Tensor; const predictedValues = await predictions.array(); - return futureDays.map((i, index) => ({ - dayIndex: i, - predictedTotal: parseInt( - (predictedValues as number[][])[index][0].toFixed(2), - ), - })); + + return futureDays.map((_, index) => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() + index); + + const formatted = date.toISOString().split('T')[0]; + + return { + date: formatted, + predictedTotal: parseInt( + (predictedValues as number[][])[index][0].toFixed(2), + ), + }; + }); } } From 282791fbcbc974dcfdb2f939089a8c377182b5c6 Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sun, 11 May 2025 17:36:57 -0400 Subject: [PATCH 3/9] add DTOs --- src/sales/dto/predict-sales.dto.ts | 18 ++++++++++++++++++ src/sales/sales.controller.ts | 6 +++--- src/sales/sales.service.ts | 7 ++++--- 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 src/sales/dto/predict-sales.dto.ts diff --git a/src/sales/dto/predict-sales.dto.ts b/src/sales/dto/predict-sales.dto.ts new file mode 100644 index 0000000..53587d4 --- /dev/null +++ b/src/sales/dto/predict-sales.dto.ts @@ -0,0 +1,18 @@ +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class PredictSalesDTO { + @IsOptional() + @IsInt() + @Min(1) + daysAhead?: number = 7; +} + +export class DailySaleDTO { + date: string; + total: number; +} + +export class PredictedSaleDTO { + date: string; + predictedTotal: number; +} diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts index 22d2418..ce6e36e 100644 --- a/src/sales/sales.controller.ts +++ b/src/sales/sales.controller.ts @@ -1,13 +1,13 @@ import { Controller, Get, Query } from '@nestjs/common'; import { SalesService } from './sales.service'; +import { PredictedSaleDTO, PredictSalesDTO } from './dto/predict-sales.dto'; @Controller('sales') export class SalesController { constructor(private readonly salesService: SalesService) {} @Get('predict') - async predict(@Query('days') days: string) { - const daysAhead = parseInt(days, 10) || 7; - return await this.salesService.predictNext(daysAhead); + async predict(@Query() query: PredictSalesDTO): Promise { + return await this.salesService.predictNext(query.daysAhead || 7); } } diff --git a/src/sales/sales.service.ts b/src/sales/sales.service.ts index 733dd52..24e5d4c 100644 --- a/src/sales/sales.service.ts +++ b/src/sales/sales.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Order, OrderStatus } from '../order/entities/order.entity'; import { Repository } from 'typeorm'; import * as tf from '@tensorflow/tfjs'; +import { DailySaleDTO, PredictedSaleDTO } from './dto/predict-sales.dto'; @Injectable() export class SalesService { @@ -11,7 +12,7 @@ export class SalesService { private readonly orderRepository: Repository, ) {} - async getDailySales(): Promise<{ date: string; total: number }[]> { + async getDailySales(): Promise { const sales = await this.orderRepository .createQueryBuilder('order') .select('DATE(order.createdAt)', 'date') @@ -27,7 +28,7 @@ export class SalesService { })); } - fillMissingDates(data: { date: string; total: number }[]) { + fillMissingDates(data: DailySaleDTO[]): DailySaleDTO[] { if (data.length < 2) return data; const filled = []; @@ -52,7 +53,7 @@ export class SalesService { return filled; } - async predictNext(daysAhead: number = 7) { + async predictNext(daysAhead: number = 7): Promise { const rawSalesData = await this.getDailySales(); const salesData = this.fillMissingDates(rawSalesData); From 0721f388618f458a8f31457b4d103ee1c9ed2f4f Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sun, 11 May 2025 17:49:30 -0400 Subject: [PATCH 4/9] refactor sales service --- src/sales/sales.controller.ts | 8 +++++++- src/sales/sales.service.ts | 16 ++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts index ce6e36e..588f61d 100644 --- a/src/sales/sales.controller.ts +++ b/src/sales/sales.controller.ts @@ -8,6 +8,12 @@ export class SalesController { @Get('predict') async predict(@Query() query: PredictSalesDTO): Promise { - return await this.salesService.predictNext(query.daysAhead || 7); + const salesData = await this.salesService.getDailySales(); + const dailySales = this.salesService.fillMissingDates(salesData); + + return await this.salesService.predictNext( + query.daysAhead || 7, + dailySales, + ); } } diff --git a/src/sales/sales.service.ts b/src/sales/sales.service.ts index 24e5d4c..bad7072 100644 --- a/src/sales/sales.service.ts +++ b/src/sales/sales.service.ts @@ -53,14 +53,14 @@ export class SalesService { return filled; } - async predictNext(daysAhead: number = 7): Promise { - const rawSalesData = await this.getDailySales(); - const salesData = this.fillMissingDates(rawSalesData); - - if (salesData.length < 2) return []; - - const inputs = salesData.map((_, i) => i); - const labels = salesData.map((s) => s.total); + async predictNext( + daysAhead: number = 7, + dailySales: DailySaleDTO[], + ): Promise { + if (dailySales.length < 2) return []; + + const inputs = dailySales.map((_, i) => i); + const labels = dailySales.map((s) => s.total); const xs = tf.tensor2d(inputs, [inputs.length, 1]); const ys = tf.tensor2d(labels, [labels.length, 1]); From 42a03e46cfa3d1f1acba094a8dc7b6c105be74cb Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sun, 11 May 2025 18:01:34 -0400 Subject: [PATCH 5/9] restrict endpoint access to ADMIN and BRANCH_ADMIN roles --- src/sales/sales.controller.ts | 8 +++++++- src/sales/sales.module.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts index 588f61d..b7b0279 100644 --- a/src/sales/sales.controller.ts +++ b/src/sales/sales.controller.ts @@ -1,11 +1,17 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { SalesService } from './sales.service'; import { PredictedSaleDTO, PredictSalesDTO } from './dto/predict-sales.dto'; +import { AuthGuard } from 'src/auth/auth.guard'; +import { RolesGuard } from 'src/auth/roles.guard'; +import { Roles } from 'src/auth/roles.decorador'; +import { UserRole } from 'src/user/entities/user.entity'; @Controller('sales') export class SalesController { constructor(private readonly salesService: SalesService) {} + @UseGuards(AuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) @Get('predict') async predict(@Query() query: PredictSalesDTO): Promise { const salesData = await this.salesService.getDailySales(); diff --git a/src/sales/sales.module.ts b/src/sales/sales.module.ts index 694b680..193092b 100644 --- a/src/sales/sales.module.ts +++ b/src/sales/sales.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Order } from 'src/order/entities/order.entity'; import { SalesController } from './sales.controller'; import { SalesService } from './sales.service'; +import { AuthModule } from 'src/auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Order])], + imports: [TypeOrmModule.forFeature([Order]), AuthModule], controllers: [SalesController], providers: [SalesService], }) From bb28006fca96586933868f18c562ce1e2ba6940e Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sun, 11 May 2025 18:25:40 -0400 Subject: [PATCH 6/9] add swagger documentation --- src/sales/dto/predict-sales.dto.ts | 7 ++++++- src/sales/sales.controller.ts | 29 +++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/sales/dto/predict-sales.dto.ts b/src/sales/dto/predict-sales.dto.ts index 53587d4..bdbb87e 100644 --- a/src/sales/dto/predict-sales.dto.ts +++ b/src/sales/dto/predict-sales.dto.ts @@ -1,10 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsInt, IsOptional, Min } from 'class-validator'; export class PredictSalesDTO { @IsOptional() @IsInt() @Min(1) - daysAhead?: number = 7; + days?: number = 7; } export class DailySaleDTO { @@ -13,6 +14,10 @@ export class DailySaleDTO { } export class PredictedSaleDTO { + @ApiProperty() date: string; + + @ApiProperty() + @IsInt() predictedTotal: number; } diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts index b7b0279..fd07b0d 100644 --- a/src/sales/sales.controller.ts +++ b/src/sales/sales.controller.ts @@ -5,6 +5,12 @@ import { AuthGuard } from 'src/auth/auth.guard'; import { RolesGuard } from 'src/auth/roles.guard'; import { Roles } from 'src/auth/roles.decorador'; import { UserRole } from 'src/user/entities/user.entity'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, +} from '@nestjs/swagger'; @Controller('sales') export class SalesController { @@ -13,13 +19,28 @@ export class SalesController { @UseGuards(AuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BRANCH_ADMIN) @Get('predict') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Predict future daily sales', + description: + 'Returns sales predictions starting from today for the number of future days specified.', + }) + @ApiQuery({ + name: 'days', + required: false, + description: 'Number of future days to predict (default: 7)', + type: Number, + example: 5, + }) + @ApiResponse({ + status: 200, + description: 'List of predicted daily sales values', + type: [PredictedSaleDTO], + }) async predict(@Query() query: PredictSalesDTO): Promise { const salesData = await this.salesService.getDailySales(); const dailySales = this.salesService.fillMissingDates(salesData); - return await this.salesService.predictNext( - query.daysAhead || 7, - dailySales, - ); + return await this.salesService.predictNext(query.days || 7, dailySales); } } From d602559eb79336a8e1f662da4c4d45d0c92b6171 Mon Sep 17 00:00:00 2001 From: Abraham <1001.28021547.ucla@gmail.com> Date: Sun, 11 May 2025 18:40:14 -0400 Subject: [PATCH 7/9] fix query optional --- src/sales/dto/predict-sales.dto.ts | 6 ++---- src/sales/sales.controller.ts | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/sales/dto/predict-sales.dto.ts b/src/sales/dto/predict-sales.dto.ts index bdbb87e..1dbfeb5 100644 --- a/src/sales/dto/predict-sales.dto.ts +++ b/src/sales/dto/predict-sales.dto.ts @@ -1,11 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsOptional, Min } from 'class-validator'; +import { IsInt, IsOptional } from 'class-validator'; export class PredictSalesDTO { @IsOptional() - @IsInt() - @Min(1) - days?: number = 7; + days?: string = '7'; } export class DailySaleDTO { diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts index fd07b0d..5fd1472 100644 --- a/src/sales/sales.controller.ts +++ b/src/sales/sales.controller.ts @@ -41,6 +41,7 @@ export class SalesController { const salesData = await this.salesService.getDailySales(); const dailySales = this.salesService.fillMissingDates(salesData); - return await this.salesService.predictNext(query.days || 7, dailySales); + const days = query.days ? parseInt(query.days, 10) || 7 : 7; + return await this.salesService.predictNext(days, dailySales); } } From ca4aa12a1634ae8fdcce19efa13caf13e8e6105f Mon Sep 17 00:00:00 2001 From: Andres Alvarez Date: Sat, 17 May 2025 17:34:11 -0400 Subject: [PATCH 8/9] Fix epoch times and add branch id to filter --- src/sales/sales.service.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/sales/sales.service.ts b/src/sales/sales.service.ts index bad7072..cd48143 100644 --- a/src/sales/sales.service.ts +++ b/src/sales/sales.service.ts @@ -12,15 +12,18 @@ export class SalesService { private readonly orderRepository: Repository, ) {} - async getDailySales(): Promise { - const sales = await this.orderRepository + async getDailySales(branchId?: string): Promise { + const query = this.orderRepository .createQueryBuilder('order') .select('DATE(order.createdAt)', 'date') .addSelect('SUM(order.totalPrice)', 'total') .where('order.status = :status', { status: OrderStatus.COMPLETED }) .groupBy('DATE(order.createdAt)') - .orderBy('DATE(order.createdAt)', 'ASC') - .getRawMany<{ date: string; total: string }>(); + .orderBy('DATE(order.createdAt)', 'ASC'); + if (branchId) { + query.andWhere('order.branchId = :branchId', { branchId }); + } + const sales = await query.getRawMany<{ date: string; total: string }>(); return sales.map((row) => ({ date: row.date, @@ -69,7 +72,7 @@ export class SalesService { model.add(tf.layers.dense({ inputShape: [1], units: 1 })); model.compile({ optimizer: 'sgd', loss: 'meanSquaredError' }); - await model.fit(xs, ys, { epochs: 300 }); + await model.fit(xs, ys, { epochs: 3 }); const futureDays = Array.from( { length: daysAhead }, From 391694a40b9b67c8ae4ccd77d5e413b46b47f6d2 Mon Sep 17 00:00:00 2001 From: Andres Alvarez Date: Sat, 17 May 2025 17:39:26 -0400 Subject: [PATCH 9/9] Get user branch if the user is branch admin --- src/sales/sales.controller.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts index 5fd1472..edfda33 100644 --- a/src/sales/sales.controller.ts +++ b/src/sales/sales.controller.ts @@ -1,7 +1,7 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { SalesService } from './sales.service'; import { PredictedSaleDTO, PredictSalesDTO } from './dto/predict-sales.dto'; -import { AuthGuard } from 'src/auth/auth.guard'; +import { AuthGuard, CustomRequest } from 'src/auth/auth.guard'; import { RolesGuard } from 'src/auth/roles.guard'; import { Roles } from 'src/auth/roles.decorador'; import { UserRole } from 'src/user/entities/user.entity'; @@ -37,10 +37,16 @@ export class SalesController { description: 'List of predicted daily sales values', type: [PredictedSaleDTO], }) - async predict(@Query() query: PredictSalesDTO): Promise { - const salesData = await this.salesService.getDailySales(); + async predict( + @Req() req: CustomRequest, + @Query() query: PredictSalesDTO, + ): Promise { + let branchId: string | undefined; + if (req.user.role === UserRole.BRANCH_ADMIN) { + branchId = req.user.branch.id; + } + const salesData = await this.salesService.getDailySales(branchId); const dailySales = this.salesService.fillMissingDates(salesData); - const days = query.days ? parseInt(query.days, 10) || 7 : 7; return await this.salesService.predictNext(days, dailySales); }