diff --git a/src/app.module.ts b/src/app.module.ts index 7c23489..8451a0b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { EventEmitterModule } from '@nestjs/event-emitter'; import { PagerModule } from './modules/pager/pager.module'; import { ContactModule } from './modules/contact/contact.module'; +import { ReservationsModule } from './modules/reservations/reservations.module'; +import { InventoryModule } from './modules/inventory/inventory.module'; @Module({ imports: [ @@ -52,6 +54,8 @@ import { ContactModule } from './modules/contact/contact.module'; EventEmitterModule.forRoot(), PagerModule, ContactModule, + ReservationsModule, + InventoryModule, ], controllers: [], providers: [], diff --git a/src/modules/foods/entities/food.entity.ts b/src/modules/foods/entities/food.entity.ts index f01fe55..f7f9421 100644 --- a/src/modules/foods/entities/food.entity.ts +++ b/src/modules/foods/entities/food.entity.ts @@ -3,6 +3,7 @@ import { Category } from './category.entity'; import { BaseEntity } from '../../../common/entities/base.entity'; import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity'; import { Review } from 'src/modules/review/entities/review.entity'; +import { Inventory } from 'src/modules/inventory/entities/inventory.entity'; @Entity({ tableName: 'foods' }) @Index({ properties: ['restaurant', 'isActive'] }) @@ -18,6 +19,9 @@ export class Food extends BaseEntity { @OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true }) reviews = new Collection(this); + @OneToMany(() => Inventory, inventory => inventory.food, { cascade: [Cascade.ALL], orphanRemoval: true }) + inventory = new Collection(this); + @Property({ nullable: true }) title?: string; @@ -54,11 +58,11 @@ export class Food extends BaseEntity { @Property({ type: 'boolean', default: false }) dinner: boolean = false; - @Property({ type: 'int', default: 0 }) - stock: number = 0; + // @Property({ type: 'int', default: 0 }) + // stock: number = 0; - @Property({ type: 'int', default: 0 }) - stockDefault: number = 0; + // @Property({ type: 'int', default: 0 }) + // stockDefault: number = 0; @Property({ type: 'boolean', default: true }) isActive: boolean = true; diff --git a/src/modules/inventory/dto/bulk-reserve-food.dto.ts b/src/modules/inventory/dto/bulk-reserve-food.dto.ts new file mode 100644 index 0000000..edbdd32 --- /dev/null +++ b/src/modules/inventory/dto/bulk-reserve-food.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsString, IsNumber, Min, IsDate } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class BulkReserveFoodItemDto { + @ApiProperty({ example: 'food-123', description: 'Food ID' }) + @IsNotEmpty() + @IsString() + foodId!: string; + + @ApiProperty({ example: 5, description: 'Quantity to reserve' }) + @IsNotEmpty() + @IsNumber() + @Min(1) + @Type(() => Number) + quantity!: number; + + @ApiProperty({ example: '2024-12-31T23:59:59Z', description: 'Reservation expiration date' }) + @IsNotEmpty() + @IsDate() + @Type(() => Date) + expiresAt!: Date; +} + +export class BulkReserveFoodDto { + @ApiProperty({ + description: 'Array of food reservations to create', + type: [BulkReserveFoodItemDto], + example: [ + { foodId: 'food-123', quantity: 5, expiresAt: '2024-12-31T23:59:59Z' }, + { foodId: 'food-789', quantity: 3, expiresAt: '2024-12-31T23:59:59Z' }, + ], + }) + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1, { message: 'At least one item is required' }) + @ValidateNested({ each: true }) + @Type(() => BulkReserveFoodItemDto) + items!: BulkReserveFoodItemDto[]; +} diff --git a/src/modules/inventory/dto/bulk-set-stock.dto.ts b/src/modules/inventory/dto/bulk-set-stock.dto.ts new file mode 100644 index 0000000..a4daf35 --- /dev/null +++ b/src/modules/inventory/dto/bulk-set-stock.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; +import { SetStockDto } from './set-stock.dto'; + +export class BulkSetStockItemDto extends SetStockDto { + @ApiProperty({ example: 'food-123', description: 'Food ID' }) + @IsNotEmpty() + foodId!: string; +} + +export class BulkSetStockDto { + @ApiProperty({ + description: 'Array of stock items to set', + type: [BulkSetStockItemDto], + example: [ + { foodId: 'food-123', totalStock: 100, availableStock: 80 }, + { foodId: 'food-456', totalStock: 50, availableStock: 45 }, + ], + }) + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1, { message: 'At least one item is required' }) + @ValidateNested({ each: true }) + @Type(() => BulkSetStockItemDto) + items!: BulkSetStockItemDto[]; +} diff --git a/src/modules/inventory/dto/create-inventory.dto.ts b/src/modules/inventory/dto/create-inventory.dto.ts new file mode 100644 index 0000000..2ebf1f1 --- /dev/null +++ b/src/modules/inventory/dto/create-inventory.dto.ts @@ -0,0 +1 @@ +export class CreateInventoryDto {} diff --git a/src/modules/inventory/dto/set-stock.dto.ts b/src/modules/inventory/dto/set-stock.dto.ts new file mode 100644 index 0000000..6b06cd9 --- /dev/null +++ b/src/modules/inventory/dto/set-stock.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SetStockDto { + @ApiProperty({ example: 100, description: 'Total stock quantity' }) + @IsNumber() + @Min(0) + @Type(() => Number) + totalStock!: number; + + @ApiProperty({ example: 80, description: 'Available stock quantity' }) + @IsNumber() + @Min(0) + @Type(() => Number) + availableStock!: number; +} diff --git a/src/modules/inventory/dto/update-inventory.dto.ts b/src/modules/inventory/dto/update-inventory.dto.ts new file mode 100644 index 0000000..8b78399 --- /dev/null +++ b/src/modules/inventory/dto/update-inventory.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateInventoryDto } from './create-inventory.dto'; + +export class UpdateInventoryDto extends PartialType(CreateInventoryDto) {} diff --git a/src/modules/inventory/entities/inventory.entity.ts b/src/modules/inventory/entities/inventory.entity.ts new file mode 100644 index 0000000..75d0bdd --- /dev/null +++ b/src/modules/inventory/entities/inventory.entity.ts @@ -0,0 +1,20 @@ +import { Entity, ManyToOne, Property, Unique } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; +import { Food } from '../../foods/entities/food.entity'; + +@Entity({ tableName: 'inventory' }) +@Unique({ properties: ['food', 'restaurant'] }) +export class Inventory extends BaseEntity { + @ManyToOne(() => Food, { unique: true }) + food!: Food; + + @ManyToOne(() => Restaurant) + restaurant!: Restaurant; + + @Property({ type: 'int' }) + totalStock!: number; + + @Property({ type: 'int' }) + availableStock!: number; +} diff --git a/src/modules/inventory/entities/reservation.entity.ts b/src/modules/inventory/entities/reservation.entity.ts new file mode 100644 index 0000000..d73dacc --- /dev/null +++ b/src/modules/inventory/entities/reservation.entity.ts @@ -0,0 +1,23 @@ +import { Entity, Enum, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Food } from '../../foods/entities/food.entity'; +import { Order } from '../../orders/entities/order.entity'; +import { ReservationStatus } from '../inteface/reservation'; + +@Entity({ tableName: 'reservations' }) +export class Reservation extends BaseEntity { + @ManyToOne(() => Food) + food!: Food; + + @ManyToOne(() => Order) + order!: Order; + + @Property({ type: 'int' }) + quantity!: number; + + @Property({ type: 'date' }) + expiresAt!: Date; + + @Enum(() => ReservationStatus) + status: ReservationStatus = ReservationStatus.ACTIVE; +} diff --git a/src/modules/inventory/inteface/reservation.ts b/src/modules/inventory/inteface/reservation.ts new file mode 100644 index 0000000..411575c --- /dev/null +++ b/src/modules/inventory/inteface/reservation.ts @@ -0,0 +1,4 @@ +export enum ReservationStatus { + ACTIVE = 'active', + CONFIRMED = 'confirmed', +} diff --git a/src/modules/inventory/inventory.controller.ts b/src/modules/inventory/inventory.controller.ts new file mode 100644 index 0000000..39e332d --- /dev/null +++ b/src/modules/inventory/inventory.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Body, Patch, Param, Post, UseGuards } from '@nestjs/common'; +import { InventoryService } from './inventory.service'; +import { SetStockDto } from './dto/set-stock.dto'; +import { BulkSetStockDto } from './dto/bulk-set-stock.dto'; +import { ApiTags, ApiOperation, ApiBody, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; +import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; +import { RestId } from 'src/common/decorators'; +import { Inventory } from './entities/inventory.entity'; + +@ApiTags('inventory') +@Controller() +export class InventoryController { + constructor(private readonly inventoryService: InventoryService) {} + + @Patch('admin/inventory/food/:foodId/stock') + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Set available and total stock for a food item' }) + @ApiParam({ name: 'foodId', description: 'Food ID' }) + @ApiBody({ type: SetStockDto }) + setStockForFood( + @Param('foodId') foodId: string, + @RestId() restaurantId: string, + @Body() setStockDto: SetStockDto, + ): Promise { + return this.inventoryService.setStockForFood(foodId, restaurantId, setStockDto); + } + + @Post('admin/inventory/foods/stock/bulk') + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk set available and total stock for multiple food items' }) + @ApiBody({ type: BulkSetStockDto }) + bulkSetStockForFoods(@RestId() restaurantId: string, @Body() bulkSetStockDto: BulkSetStockDto): Promise { + return this.inventoryService.bulkSetStockForFoods(restaurantId, bulkSetStockDto); + } +} diff --git a/src/modules/inventory/inventory.module.ts b/src/modules/inventory/inventory.module.ts new file mode 100644 index 0000000..5a89884 --- /dev/null +++ b/src/modules/inventory/inventory.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { InventoryService } from './inventory.service'; +import { InventoryController } from './inventory.controller'; +import { Inventory } from './entities/inventory.entity'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [MikroOrmModule.forFeature([Inventory]), AuthModule], + controllers: [InventoryController], + providers: [InventoryService], +}) +export class InventoryModule {} diff --git a/src/modules/inventory/inventory.service.ts b/src/modules/inventory/inventory.service.ts new file mode 100644 index 0000000..c9943e3 --- /dev/null +++ b/src/modules/inventory/inventory.service.ts @@ -0,0 +1,221 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { SetStockDto } from './dto/set-stock.dto'; +import { BulkSetStockDto } from './dto/bulk-set-stock.dto'; +import { BulkReserveFoodDto } from './dto/bulk-reserve-food.dto'; +import { Inventory } from './entities/inventory.entity'; +import { Reservation } from './entities/reservation.entity'; +import { Food } from '../foods/entities/food.entity'; +import { Restaurant } from '../restaurants/entities/restaurant.entity'; +import { Order } from '../orders/entities/order.entity'; + +@Injectable() +export class InventoryService { + constructor(private readonly em: EntityManager) {} + + async setStockForFood(foodId: string, restaurantId: string, setStockDto: SetStockDto): Promise { + // Validate that availableStock doesn't exceed totalStock + if (setStockDto.availableStock > setStockDto.totalStock) { + throw new BadRequestException('Available stock cannot exceed total stock'); + } + + // Find food and verify it belongs to the restaurant + const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant'] }); + if (!food) { + throw new NotFoundException(`Food with ID ${foodId} not found`); + } + + if (food.restaurant.id !== restaurantId) { + throw new BadRequestException(`Food does not belong to restaurant ${restaurantId}`); + } + + // Find or create inventory record + let inventory = await this.em.findOne(Inventory, { + food: { id: foodId }, + restaurant: { id: restaurantId }, + }); + + if (!inventory) { + // Create new inventory record + const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); + if (!restaurant) { + throw new NotFoundException(`Restaurant with ID ${restaurantId} not found`); + } + + inventory = this.em.create(Inventory, { + food, + restaurant, + totalStock: setStockDto.totalStock, + availableStock: setStockDto.availableStock, + }); + } else { + // Update existing inventory record + inventory.totalStock = setStockDto.totalStock; + inventory.availableStock = setStockDto.availableStock; + } + + await this.em.flush(); + + return inventory; + } + + async bulkSetStockForFoods(restaurantId: string, bulkSetStockDto: BulkSetStockDto): Promise { + const { items } = bulkSetStockDto; + + // Validate all items first + for (const item of items) { + if (item.availableStock > item.totalStock) { + throw new BadRequestException(`Available stock cannot exceed total stock for food ${item.foodId}`); + } + } + + // Get all food IDs + const foodIds = items.map(item => item.foodId); + + // Load all foods in one query + const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['restaurant'] }); + + // Verify all foods exist and belong to the restaurant + const foodMap = new Map(); + for (const food of foods) { + if (food.restaurant.id !== restaurantId) { + throw new BadRequestException(`Food ${food.id} does not belong to restaurant ${restaurantId}`); + } + foodMap.set(food.id, food); + } + + // Check for missing foods + const missingFoodIds = foodIds.filter(id => !foodMap.has(id)); + if (missingFoodIds.length > 0) { + throw new NotFoundException(`Foods not found: ${missingFoodIds.join(', ')}`); + } + + // Load all existing inventories in one query + const existingInventories = await this.em.find(Inventory, { + food: { id: { $in: foodIds } }, + restaurant: { id: restaurantId }, + }); + + const inventoryMap = new Map(); + for (const inventory of existingInventories) { + inventoryMap.set(inventory.food.id, inventory); + } + + // Get restaurant once + const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); + if (!restaurant) { + throw new NotFoundException(`Restaurant with ID ${restaurantId} not found`); + } + + // Process all items + const results: Inventory[] = []; + for (const item of items) { + const food = foodMap.get(item.foodId)!; + let inventory = inventoryMap.get(item.foodId); + + if (!inventory) { + // Create new inventory record + inventory = this.em.create(Inventory, { + food, + restaurant, + totalStock: item.totalStock, + availableStock: item.availableStock, + }); + } else { + // Update existing inventory record + inventory.totalStock = item.totalStock; + inventory.availableStock = item.availableStock; + } + + results.push(inventory); + } + + // Flush all changes at once + await this.em.flush(); + + return results; + } + + async bulkReserveFood( + restaurantId: string, + orderId: string, + bulkReserveFoodDto: BulkReserveFoodDto, + ): Promise { + const { items } = bulkReserveFoodDto; + + // Get all unique food IDs + const foodIds = [...new Set(items.map(item => item.foodId))]; + + // Load all foods in one query + const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['restaurant'] }); + + // Verify all foods exist and belong to the restaurant + const foodMap = new Map(); + for (const food of foods) { + if (food.restaurant.id !== restaurantId) { + throw new BadRequestException(`Food ${food.id} does not belong to restaurant ${restaurantId}`); + } + foodMap.set(food.id, food); + } + + // Check for missing foods + const missingFoodIds = foodIds.filter(id => !foodMap.has(id)); + if (missingFoodIds.length > 0) { + throw new NotFoundException(`Foods not found: ${missingFoodIds.join(', ')}`); + } + + // Load order + const order = await this.em.findOne(Order, { id: orderId }); + if (!order) { + throw new NotFoundException(`Order with ID ${orderId} not found`); + } + + // Load all existing inventories in one query + const existingInventories = await this.em.find(Inventory, { + food: { id: { $in: foodIds } }, + restaurant: { id: restaurantId }, + }); + + const inventoryMap = new Map(); + for (const inventory of existingInventories) { + inventoryMap.set(inventory.food.id, inventory); + } + + // Validate stock availability and create reservations + const reservations: Reservation[] = []; + for (const item of items) { + const food = foodMap.get(item.foodId)!; + const inventory = inventoryMap.get(item.foodId); + + // Check if inventory exists + if (!inventory) { + throw new NotFoundException(`Inventory not found for food ${item.foodId}`); + } + + // Check if available stock is sufficient + if (inventory.availableStock < item.quantity) { + throw new BadRequestException( + `Insufficient stock for food ${item.foodId}. Available: ${inventory.availableStock}, Requested: ${item.quantity}`, + ); + } + + // Create reservation record + const reservation = this.em.create(Reservation, { + food, + order, + quantity: item.quantity, + expiresAt: item.expiresAt, + }); + + reservations.push(reservation); + + // Update available stock (decrease by quantity) + inventory.availableStock -= item.quantity; + } + + // Flush all changes at once + await this.em.flush(); + + return reservations; + } +} diff --git a/src/modules/orders/interface/order-status.ts b/src/modules/orders/interface/order-status.ts index abed1d8..2dae0a9 100644 --- a/src/modules/orders/interface/order-status.ts +++ b/src/modules/orders/interface/order-status.ts @@ -1,27 +1,40 @@ import type { CouponType } from 'src/modules/coupons/interface/coupon'; +// export enum OrderStatus { +// // Initial status +// Pending = 'pending', + +// // Cancellation statuses +// CancelledBySystem = 'cancelledBySystem', +// CancelledByUser = 'cancelledByUser', +// RejectedByRestaurant = 'rejectedByRestaurant', + +// // Active processing statuses +// Confirmed = 'confirmed', +// Preparing = 'preparing', + +// // Ready for pickup/delivery statuses +// ReadyForCustomerPickup = 'readyForCustomerPickup', +// ReadyForDineIn = 'readyForDineIn', +// ReadyForDeliveryCar = 'readyForDeliveryCar', +// ReadyForDeliveryCourier = 'readyForDeliveryCourier', + +// Shipping = 'shipping', +// // Final status +// Delivered = 'delivered', +// } export enum OrderStatus { - // Initial status - Pending = 'pending', - - // Cancellation statuses - CancelledBySystem = 'cancelledBySystem', - CancelledByUser = 'cancelledByUser', - RejectedByRestaurant = 'rejectedByRestaurant', - - // Active processing statuses - Confirmed = 'confirmed', - Preparing = 'preparing', - - // Ready for pickup/delivery statuses - ReadyForCustomerPickup = 'readyForCustomerPickup', - ReadyForDineIn = 'readyForDineIn', - ReadyForDeliveryCar = 'readyForDeliveryCar', - ReadyForDeliveryCourier = 'readyForDeliveryCourier', - - Shipping = 'shipping', - // Final status - Delivered = 'delivered', + CREATED = 'created', + PENDING_PAYMENT = 'pendingPayment', + PAID = 'paid', + CONFIRMED = 'confirmed', + PREPARING = 'preparing', + READY = 'ready', + SHIPPED = 'shipped', + COMPLETED = 'completed', + CANCELED = 'canceled', + FAILED = 'failed', + REFUNDED = 'refunded', } export interface OrderCouponDetail { diff --git a/src/modules/payments/dto/create-payment-method.dto.ts b/src/modules/payments/dto/create-payment-method.dto.ts index 17a23f9..24acc82 100644 --- a/src/modules/payments/dto/create-payment-method.dto.ts +++ b/src/modules/payments/dto/create-payment-method.dto.ts @@ -3,9 +3,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { PaymentMethodEnum, PaymentGatewayEnum } from '../interface/payment'; export class CreatePaymentMethodDto { - @ApiProperty({ description: 'Payment method title' }) - @IsString() - title!: string; @ApiProperty({ description: 'Payment method', enum: PaymentMethodEnum }) @IsEnum(PaymentMethodEnum) diff --git a/src/modules/payments/entities/payment-method.entity.ts b/src/modules/payments/entities/payment-method.entity.ts index 4cb1ced..8f3cebf 100644 --- a/src/modules/payments/entities/payment-method.entity.ts +++ b/src/modules/payments/entities/payment-method.entity.ts @@ -8,8 +8,6 @@ export class PaymentMethod extends BaseEntity { @ManyToOne(() => Restaurant) restaurant!: Restaurant; - @Property() - title!: string; @Enum(() => PaymentMethodEnum) method!: PaymentMethodEnum;