diff --git a/src/app.module.ts b/src/app.module.ts index 66a8fb4..c709ed9 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'; import { ActivePrincipleModule } from './active-principle/active-principle.module'; @Module({ @@ -68,6 +69,7 @@ import { ActivePrincipleModule } from './active-principle/active-principle.modul CartModule, RecommendationModule, ReportsModule, + SalesModule, ActivePrincipleModule, ], controllers: [AppController], diff --git a/src/sales/dto/predict-sales.dto.ts b/src/sales/dto/predict-sales.dto.ts new file mode 100644 index 0000000..1dbfeb5 --- /dev/null +++ b/src/sales/dto/predict-sales.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional } from 'class-validator'; + +export class PredictSalesDTO { + @IsOptional() + days?: string = '7'; +} + +export class DailySaleDTO { + date: string; + total: number; +} + +export class PredictedSaleDTO { + @ApiProperty() + date: string; + + @ApiProperty() + @IsInt() + predictedTotal: number; +} diff --git a/src/sales/sales.controller.ts b/src/sales/sales.controller.ts new file mode 100644 index 0000000..edfda33 --- /dev/null +++ b/src/sales/sales.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { SalesService } from './sales.service'; +import { PredictedSaleDTO, PredictSalesDTO } from './dto/predict-sales.dto'; +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'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, +} from '@nestjs/swagger'; + +@Controller('sales') +export class SalesController { + constructor(private readonly salesService: SalesService) {} + + @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( + @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); + } +} diff --git a/src/sales/sales.module.ts b/src/sales/sales.module.ts new file mode 100644 index 0000000..193092b --- /dev/null +++ b/src/sales/sales.module.ts @@ -0,0 +1,13 @@ +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'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Order]), AuthModule], + 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..cd48143 --- /dev/null +++ b/src/sales/sales.service.ts @@ -0,0 +1,103 @@ +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'; +import { DailySaleDTO, PredictedSaleDTO } from './dto/predict-sales.dto'; + +@Injectable() +export class SalesService { + constructor( + @InjectRepository(Order) + private readonly orderRepository: Repository, + ) {} + + 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'); + if (branchId) { + query.andWhere('order.branchId = :branchId', { branchId }); + } + const sales = await query.getRawMany<{ date: string; total: string }>(); + + return sales.map((row) => ({ + date: row.date, + total: parseInt(row.total), + })); + } + + fillMissingDates(data: DailySaleDTO[]): DailySaleDTO[] { + 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, + 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]); + + 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: 3 }); + + 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((_, 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), + ), + }; + }); + } +}