This commit is contained in:
2025-12-15 11:15:08 +03:30
parent 9d677622f2
commit b776f2b83a
12 changed files with 80 additions and 60 deletions
-2
View File
@@ -22,7 +22,6 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { PagerModule } from './modules/pager/pager.module'; import { PagerModule } from './modules/pager/pager.module';
import { ContactModule } from './modules/contact/contact.module'; import { ContactModule } from './modules/contact/contact.module';
import { ReservationsModule } from './modules/reservations/reservations.module';
import { InventoryModule } from './modules/inventory/inventory.module'; import { InventoryModule } from './modules/inventory/inventory.module';
@Module({ @Module({
@@ -54,7 +53,6 @@ import { InventoryModule } from './modules/inventory/inventory.module';
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
PagerModule, PagerModule,
ContactModule, ContactModule,
ReservationsModule,
InventoryModule, InventoryModule,
], ],
controllers: [], controllers: [],
+2 -2
View File
@@ -3,8 +3,8 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
export function getSwaggerConfig(app: INestApplication) { export function getSwaggerConfig(app: INestApplication) {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Tahavol API') .setTitle('D-menu API')
.setDescription('API documentation for Tahavol backend') .setDescription('API documentation for D-menu backend')
.setVersion('1.0') .setVersion('1.0')
.addBearerAuth() // optional: for JWT endpoints .addBearerAuth() // optional: for JWT endpoints
.build(); .build();
+16 -14
View File
@@ -21,7 +21,6 @@ import { Cart, CartItem } from '../interfaces/cart.interface';
import { CouponService } from 'src/modules/coupons/providers/coupon.service'; import { CouponService } from 'src/modules/coupons/providers/coupon.service';
import { OrderCouponDetail } from 'src/modules/orders/interface/order-status'; import { OrderCouponDetail } from 'src/modules/orders/interface/order-status';
import { CouponType } from 'src/modules/coupons/interface/coupon'; import { CouponType } from 'src/modules/coupons/interface/coupon';
import { Order } from '../../orders/entities/order.entity';
@Injectable() @Injectable()
export class CartService { export class CartService {
@@ -95,7 +94,7 @@ export class CartService {
const cart = await this.getOrCreateCart(userId, restaurantId); const cart = await this.getOrCreateCart(userId, restaurantId);
// Find food // Find food
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant'] }); const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] });
if (!food) { if (!food) {
throw new NotFoundException(`Food with ID ${foodId} not found`); throw new NotFoundException(`Food with ID ${foodId} not found`);
} }
@@ -106,8 +105,9 @@ export class CartService {
} }
// Check stock availability // Check stock availability
if (food.stock < addItemDto.quantity) { const availableStock = food.inventory?.availableStock ?? 0;
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); if (availableStock < addItemDto.quantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
} }
// Check if item already exists in cart // Check if item already exists in cart
@@ -119,8 +119,8 @@ export class CartService {
const newQuantity = existingItem.quantity + addItemDto.quantity; const newQuantity = existingItem.quantity + addItemDto.quantity;
// Check stock for new total quantity // Check stock for new total quantity
if (food.stock < newQuantity) { if (availableStock < newQuantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
} }
const itemPrice = food.price || 0; const itemPrice = food.price || 0;
@@ -221,7 +221,7 @@ export class CartService {
// Process each item // Process each item
for (const addItemDto of bulkAddItemsDto.items) { for (const addItemDto of bulkAddItemsDto.items) {
// Find food // Find food
const food = await this.em.findOne(Food, { id: addItemDto.foodId }, { populate: ['restaurant'] }); const food = await this.em.findOne(Food, { id: addItemDto.foodId }, { populate: ['restaurant', 'inventory'] });
if (!food) { if (!food) {
throw new NotFoundException(`Food with ID ${addItemDto.foodId} not found`); throw new NotFoundException(`Food with ID ${addItemDto.foodId} not found`);
} }
@@ -232,8 +232,9 @@ export class CartService {
} }
// Check stock availability // Check stock availability
if (food.stock < addItemDto.quantity) { const availableStock = food.inventory?.availableStock ?? 0;
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); if (availableStock < addItemDto.quantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
} }
// Check if item already exists in cart // Check if item already exists in cart
@@ -245,8 +246,8 @@ export class CartService {
const newQuantity = existingItem.quantity + addItemDto.quantity; const newQuantity = existingItem.quantity + addItemDto.quantity;
// Check stock for new total quantity // Check stock for new total quantity
if (food.stock < newQuantity) { if (availableStock < newQuantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
} }
const itemPrice = food.price || 0; const itemPrice = food.price || 0;
@@ -307,14 +308,15 @@ export class CartService {
const item = cart.items[itemIndex]; const item = cart.items[itemIndex];
// Find food to check stock // Find food to check stock
const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant'] }); const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant', 'inventory'] });
if (!food) { if (!food) {
throw new NotFoundException(`Food with ID ${item.foodId} not found`); throw new NotFoundException(`Food with ID ${item.foodId} not found`);
} }
// Check stock availability // Check stock availability
if (food.stock < updateItemDto.quantity) { const availableStock = food.inventory?.availableStock ?? 0;
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); if (availableStock < updateItemDto.quantity) {
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
} }
// Update quantity // Update quantity
+8 -4
View File
@@ -1,4 +1,4 @@
import { Cascade, Collection, Entity, Index, ManyToOne, OneToMany, Property } from '@mikro-orm/core'; import { Cascade, Collection, Entity, Index, ManyToOne, OneToMany, Property, OneToOne } from '@mikro-orm/core';
import { Category } from './category.entity'; import { Category } from './category.entity';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity'; import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity';
@@ -14,13 +14,17 @@ export class Food extends BaseEntity {
restaurant: Restaurant; restaurant: Restaurant;
@ManyToOne(() => Category) @ManyToOne(() => Category)
category!: Category; category: Category;
@OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true }) @OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true })
reviews = new Collection<Review>(this); reviews = new Collection<Review>(this);
@OneToMany(() => Inventory, inventory => inventory.food, { cascade: [Cascade.ALL], orphanRemoval: true }) @OneToOne(() => Inventory, inventory => inventory.food, {
inventory = new Collection<Inventory>(this); nullable: true,
cascade: [Cascade.ALL],
orphanRemoval: true,
})
inventory?: Inventory;
@Property({ nullable: true }) @Property({ nullable: true })
title?: string; title?: string;
@@ -44,9 +44,6 @@ export class FoodService {
noon: rest.noon ?? false, noon: rest.noon ?? false,
dinner: rest.dinner ?? false, dinner: rest.dinner ?? false,
desc: rest.desc, desc: rest.desc,
// pickup/stock
stock: rest.stock ?? 0,
stockDefault: rest.stockDefault ?? 0,
isActive: rest.isActive ?? true, isActive: rest.isActive ?? true,
inPlaceServe: rest.inPlaceServe ?? false, inPlaceServe: rest.inPlaceServe ?? false,
pickupServe: rest.pickupServe ?? false, pickupServe: rest.pickupServe ?? false,
@@ -1,17 +1,12 @@
import { Entity, ManyToOne, Property, Unique } from '@mikro-orm/core'; import { Entity, Property, OneToOne } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { Food } from '../../foods/entities/food.entity'; import { Food } from '../../foods/entities/food.entity';
@Entity({ tableName: 'inventory' }) @Entity({ tableName: 'inventory' })
@Unique({ properties: ['food', 'restaurant'] })
export class Inventory extends BaseEntity { export class Inventory extends BaseEntity {
@ManyToOne(() => Food, { unique: true }) @OneToOne(() => Food, food => food.inventory)
food!: Food; food!: Food;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property({ type: 'int' }) @Property({ type: 'int' })
totalStock!: number; totalStock!: number;
+3 -8
View File
@@ -32,8 +32,7 @@ export class InventoryService {
// Find or create inventory record // Find or create inventory record
let inventory = await this.em.findOne(Inventory, { let inventory = await this.em.findOne(Inventory, {
food: { id: foodId }, food: { id: foodId, restaurant: { id: restaurantId } },
restaurant: { id: restaurantId },
}); });
if (!inventory) { if (!inventory) {
@@ -45,7 +44,6 @@ export class InventoryService {
inventory = this.em.create(Inventory, { inventory = this.em.create(Inventory, {
food, food,
restaurant,
totalStock: setStockDto.totalStock, totalStock: setStockDto.totalStock,
availableStock: setStockDto.availableStock, availableStock: setStockDto.availableStock,
}); });
@@ -93,8 +91,7 @@ export class InventoryService {
// Load all existing inventories in one query // Load all existing inventories in one query
const existingInventories = await this.em.find(Inventory, { const existingInventories = await this.em.find(Inventory, {
food: { id: { $in: foodIds } }, food: { id: { $in: foodIds }, restaurant: { id: restaurantId } },
restaurant: { id: restaurantId },
}); });
const inventoryMap = new Map<string, Inventory>(); const inventoryMap = new Map<string, Inventory>();
@@ -118,7 +115,6 @@ export class InventoryService {
// Create new inventory record // Create new inventory record
inventory = this.em.create(Inventory, { inventory = this.em.create(Inventory, {
food, food,
restaurant,
totalStock: item.totalStock, totalStock: item.totalStock,
availableStock: item.availableStock, availableStock: item.availableStock,
}); });
@@ -173,8 +169,7 @@ export class InventoryService {
// Load all existing inventories in one query // Load all existing inventories in one query
const existingInventories = await this.em.find(Inventory, { const existingInventories = await this.em.find(Inventory, {
food: { id: { $in: foodIds } }, food: { id: { $in: foodIds }, restaurant: { id: restaurantId } },
restaurant: { id: restaurantId },
}); });
const inventoryMap = new Map<string, Inventory>(); const inventoryMap = new Map<string, Inventory>();
@@ -101,7 +101,7 @@ export class OrdersController {
@ApiOperation({ summary: 'Accept an order' }) @ApiOperation({ summary: 'Accept an order' })
@ApiParam({ name: 'orderId', description: 'Order ID' }) @ApiParam({ name: 'orderId', description: 'Order ID' })
acceptOrder(@Param('orderId') orderId: string, @RestId() restId: string) { acceptOrder(@Param('orderId') orderId: string, @RestId() restId: string) {
return this.ordersService.acceptOrder(orderId, restId); return this.ordersService.confirmOrder(orderId, restId);
} }
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
+3 -2
View File
@@ -20,6 +20,7 @@ import { UserAddress } from '../../users/entities/user-address.entity';
import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity';
import { OrderItem } from './order-item.entity'; import { OrderItem } from './order-item.entity';
import { Delivery } from '../../delivery/entities/delivery.entity'; import { Delivery } from '../../delivery/entities/delivery.entity';
import { Payment } from 'src/modules/payments/entities/payment.entity';
@Entity({ tableName: 'orders' }) @Entity({ tableName: 'orders' })
@Unique({ properties: ['restaurant', 'orderNumber'] }) @Unique({ properties: ['restaurant', 'orderNumber'] })
@@ -47,8 +48,8 @@ export class Order extends BaseEntity {
@ManyToOne(() => UserAddress, { nullable: true }) @ManyToOne(() => UserAddress, { nullable: true })
address?: UserAddress; address?: UserAddress;
@ManyToOne(() => PaymentMethod, { nullable: true }) @OneToOne(() => Payment, payment => payment.order)
paymentMethod?: PaymentMethod; paymentMethod: PaymentMethod;
@Property({ type: 'int', nullable: true }) @Property({ type: 'int', nullable: true })
orderNumber?: number; orderNumber?: number;
+2 -1
View File
@@ -24,7 +24,7 @@ import type { CouponType } from 'src/modules/coupons/interface/coupon';
// Delivered = 'delivered', // Delivered = 'delivered',
// } // }
export enum OrderStatus { export enum OrderStatus {
CREATED = 'created', CREATED = 'new',
PENDING_PAYMENT = 'pendingPayment', PENDING_PAYMENT = 'pendingPayment',
PAID = 'paid', PAID = 'paid',
CONFIRMED = 'confirmed', CONFIRMED = 'confirmed',
@@ -32,6 +32,7 @@ export enum OrderStatus {
READY = 'ready', READY = 'ready',
SHIPPED = 'shipped', SHIPPED = 'shipped',
COMPLETED = 'completed', COMPLETED = 'completed',
CANCELED = 'canceled', CANCELED = 'canceled',
FAILED = 'failed', FAILED = 'failed',
REFUNDED = 'refunded', REFUNDED = 'refunded',
+42 -14
View File
@@ -81,7 +81,7 @@ export class OrdersService {
totalItems: cart.totalItems || 0, totalItems: cart.totalItems || 0,
description: cart.description, description: cart.description,
tableNumber: cart.tableNumber, tableNumber: cart.tableNumber,
status: OrderStatus.Pending, status: OrderStatus.NEW,
paymentStatus: PaymentStatusEnum.Pending, paymentStatus: PaymentStatusEnum.Pending,
}); });
@@ -92,7 +92,7 @@ export class OrdersService {
const { food, quantity, unitPrice, discount } = itemData; const { food, quantity, unitPrice, discount } = itemData;
// Stock check // Stock check
if (food.stock < quantity) { if (food.inventory.availableStock < quantity) {
throw new BadRequestException(`${food.title} is out of stock`); throw new BadRequestException(`${food.title} is out of stock`);
} }
@@ -221,7 +221,7 @@ export class OrdersService {
const orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }> = []; const orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }> = [];
for (const cartItem of cart.items) { for (const cartItem of cart.items) {
const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant'] }); const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant', 'inventory'] });
if (!food) { if (!food) {
throw new NotFoundException(`Food with ID ${cartItem.foodId} not found`); throw new NotFoundException(`Food with ID ${cartItem.foodId} not found`);
} }
@@ -232,9 +232,10 @@ export class OrdersService {
} }
// Final stock validation // Final stock validation
if (food.stock < cartItem.quantity) { const availableStock = food.inventory?.availableStock ?? 0;
if (availableStock < cartItem.quantity) {
throw new BadRequestException( throw new BadRequestException(
`Insufficient stock for food ${food.title || food.id}. Available: ${food.stock}, Requested: ${cartItem.quantity}`, `Insufficient stock for food ${food.title || food.id}. Available: ${availableStock}, Requested: ${cartItem.quantity}`,
); );
} }
@@ -303,13 +304,13 @@ export class OrdersService {
return order; return order;
} }
async acceptOrder(orderId: string, restId: string) { async confirmOrder(orderId: string, restId: string) {
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
if (order.status !== OrderStatus.Pending) { if (order.status !== OrderStatus.CREATED) {
throw new BadRequestException('Order must be pending before accepting'); throw new BadRequestException('Order must be created before confirming');
} }
// Wallet payments should be paid before accepting (like Online) // Wallet payments should be paid before accepting (like Online)
if (order.paymentMethod?.method === PaymentMethodEnum.Online && order.paymentStatus !== PaymentStatusEnum.Paid) { if (order.paymentMethod?.method === PaymentMethodEnum.Online && order.paymentStatus !== PaymentStatusEnum.Paid) {
@@ -319,7 +320,7 @@ export class OrdersService {
// Cash, CardOnDelivery, and Wallet can be accepted even if payment status is pending // Cash, CardOnDelivery, and Wallet can be accepted even if payment status is pending
// (they're paid on delivery or already deducted) // (they're paid on delivery or already deducted)
order.status = OrderStatus.Confirmed; order.status = OrderStatus.CONFIRMED;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
@@ -329,10 +330,13 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
if (order.status !== OrderStatus.Confirmed) { if (!this.canTransition(order.status, OrderStatus.PREPARING, order.paymentMethod?.method)) {
throw new BadRequestException('Invalid status transition');
}
if (order.status !== OrderStatus.CONFIRMED) {
throw new BadRequestException('Order must be confirmed before preparing'); throw new BadRequestException('Order must be confirmed before preparing');
} }
order.status = OrderStatus.Preparing; order.status = OrderStatus.PREPARING;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
@@ -342,7 +346,7 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
order.status = OrderStatus.RejectedByRestaurant; order.status = OrderStatus.CANCELED;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
@@ -381,10 +385,10 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
if (order.status !== OrderStatus.Pending) { if (order.status !== OrderStatus.) {
throw new BadRequestException('Order is not pending'); throw new BadRequestException('Order is not pending');
} }
order.status = OrderStatus.CancelledByUser; order.status = OrderStatus.CANCELED;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
@@ -446,6 +450,30 @@ export class OrdersService {
return order; return order;
} }
transitions = {
CREATED: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED],
PENDING_PAYMENT: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED],
PAID: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED],
CONFIRMED: [OrderStatus.PREPARING, OrderStatus.CANCELED],
PREPARING: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED],
READY: [OrderStatus.COMPLETED, OrderStatus.FAILED],
SHIPPED: [OrderStatus.COMPLETED, OrderStatus.FAILED],
}
canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) {
if (!this.transitions[from]?.includes(to)) return false;
if (paymentMethod === PaymentMethodEnum.Cash) {
if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false;
}
if (paymentMethod === PaymentMethodEnum.Online) {
if (to === OrderStatus.CONFIRMED && from !== OrderStatus.PAID) return false;
}
return true;
}
/** /**
* Cleanup job to handle abandoned orders (pending payment for >30 minutes) * Cleanup job to handle abandoned orders (pending payment for >30 minutes)
* Runs every 10 minutes to check for abandoned orders * Runs every 10 minutes to check for abandoned orders
@@ -1,7 +1,6 @@
export enum PaymentMethodEnum { export enum PaymentMethodEnum {
Online = 'Online', Online = 'Online',
Cash = 'Cash', Cash = 'Cash',
CardOnDelivery = 'CardOnDelivery',
Wallet = 'Wallet', Wallet = 'Wallet',
} }
export enum PaymentStatusEnum { export enum PaymentStatusEnum {