inventory

This commit is contained in:
2025-12-14 23:02:55 +03:30
parent 996cd0016c
commit 0ac1b39100
16 changed files with 453 additions and 30 deletions
+4
View File
@@ -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: [],
+8 -4
View File
@@ -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<Review>(this);
@OneToMany(() => Inventory, inventory => inventory.food, { cascade: [Cascade.ALL], orphanRemoval: true })
inventory = new Collection<Inventory>(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;
@@ -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[];
}
@@ -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[];
}
@@ -0,0 +1 @@
export class CreateInventoryDto {}
@@ -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;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateInventoryDto } from './create-inventory.dto';
export class UpdateInventoryDto extends PartialType(CreateInventoryDto) {}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,4 @@
export enum ReservationStatus {
ACTIVE = 'active',
CONFIRMED = 'confirmed',
}
@@ -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<Inventory> {
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<Inventory[]> {
return this.inventoryService.bulkSetStockForFoods(restaurantId, bulkSetStockDto);
}
}
+13
View File
@@ -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 {}
+221
View File
@@ -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<Inventory> {
// 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<Inventory[]> {
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<string, Food>();
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<string, Inventory>();
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<Reservation[]> {
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<string, Food>();
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<string, Inventory>();
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;
}
}
+34 -21
View File
@@ -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 {
@@ -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)
@@ -8,8 +8,6 @@ export class PaymentMethod extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property()
title!: string;
@Enum(() => PaymentMethodEnum)
method!: PaymentMethodEnum;