order refactor
This commit is contained in:
@@ -9,53 +9,35 @@ import { FindOrdersDto } from '../dto/find-orders.dto';
|
|||||||
import { OrderStatus } from '../interface/order-status';
|
import { OrderStatus } from '../interface/order-status';
|
||||||
|
|
||||||
@ApiTags('orders')
|
@ApiTags('orders')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'X-Slug',
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
default: 'zhivan',
|
||||||
|
},
|
||||||
|
})
|
||||||
@Controller()
|
@Controller()
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(private readonly ordersService: OrdersService) {}
|
constructor(private readonly ordersService: OrdersService) { }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Post('public/checkout')
|
@Post('public/checkout')
|
||||||
@ApiOperation({ summary: 'Checkout : create order and payment record' })
|
@ApiOperation({ summary: 'Checkout : create order and payment record' })
|
||||||
@ApiHeader({
|
|
||||||
name: 'X-Slug',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
default: 'zhivan',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
checkout(@UserId() userId: string, @RestId() restaurantId: string) {
|
checkout(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||||
return this.ordersService.checkout(userId, restaurantId);
|
return this.ordersService.checkout(userId, restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('public/orders')
|
@Get('public/orders')
|
||||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||||
@ApiHeader({
|
|
||||||
name: 'X-Slug',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
default: 'zhivan',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
findAll(@RestId() restId: string, @Query() dto: FindOrdersDto, @UserId() userId: string) {
|
findAll(@RestId() restId: string, @Query() dto: FindOrdersDto, @UserId() userId: string) {
|
||||||
return this.ordersService.findAllForUser(restId, dto, userId);
|
return this.ordersService.findAllForUser(restId, dto, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
@ApiOperation({ summary: 'Get an order By id for User' })
|
||||||
@ApiHeader({
|
|
||||||
name: 'X-Slug',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
default: 'zhivan',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
@Get('public/orders/:orderId')
|
@Get('public/orders/:orderId')
|
||||||
findOne(@Param('orderId') orderId: string, @RestId() restId: string) {
|
findOne(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||||
@@ -63,24 +45,14 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Patch('public/orders/:id/cancel')
|
@Patch('public/orders/:id/cancel')
|
||||||
@ApiOperation({ summary: 'Cancel an order By User' })
|
@ApiOperation({ summary: 'Cancel an order By User' })
|
||||||
@ApiHeader({
|
|
||||||
name: 'X-Slug',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
default: 'zhivan',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Order ID' })
|
@ApiParam({ name: 'id', description: 'Order ID' })
|
||||||
cancelOrder(@Param('id') id: string, @RestId() restId: string) {
|
cancelOrder(@Param('id') id: string, @RestId() restId: string) {
|
||||||
return this.ordersService.cancelOrderAsUser(id, restId);
|
return this.ordersService.cancelOrderAsUser(id, restId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('admin/orders')
|
@Get('admin/orders')
|
||||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||||
findAllAdmin(@RestId() restId: string, @Query() dto: FindOrdersDto) {
|
findAllAdmin(@RestId() restId: string, @Query() dto: FindOrdersDto) {
|
||||||
@@ -88,7 +60,6 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
@ApiOperation({ summary: 'Get an order By id for User' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
@Get('admin/orders/:orderId')
|
@Get('admin/orders/:orderId')
|
||||||
@@ -97,7 +68,6 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Patch('admin/orders/:orderId/confirm')
|
@Patch('admin/orders/:orderId/confirm')
|
||||||
@ApiOperation({ summary: 'Accept an order' })
|
@ApiOperation({ summary: 'Accept an order' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
@@ -106,7 +76,6 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Patch('admin/orders/:id/prepare')
|
@Patch('admin/orders/:id/prepare')
|
||||||
@ApiOperation({ summary: 'Prepare an order By Admin' })
|
@ApiOperation({ summary: 'Prepare an order By Admin' })
|
||||||
@ApiParam({ name: 'id', description: 'Order ID' })
|
@ApiParam({ name: 'id', description: 'Order ID' })
|
||||||
@@ -115,7 +84,6 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Patch('admin/orders/:orderId/reject')
|
@Patch('admin/orders/:orderId/reject')
|
||||||
@ApiOperation({ summary: 'Reject an order' })
|
@ApiOperation({ summary: 'Reject an order' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
@@ -142,7 +110,6 @@ export class OrdersController {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
|
||||||
@Patch('admin/orders/:orderId/:status')
|
@Patch('admin/orders/:orderId/:status')
|
||||||
@ApiOperation({ summary: 'Update an order status' })
|
@ApiOperation({ summary: 'Update an order status' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
|||||||
@@ -6,31 +6,56 @@ import { User } from '../../users/entities/user.entity';
|
|||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { CartService } from '../../cart/providers/cart.service';
|
import { CartService } from '../../cart/providers/cart.service';
|
||||||
import { OrderStatus } from '../interface/order-status';
|
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order-status';
|
||||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||||
import { Cart } from '../../cart/interfaces/cart.interface';
|
import { Cart } from '../../cart/interfaces/cart.interface';
|
||||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||||
import { PaymentsService } from '../../payments/services/payments.service';
|
import { PaymentsService } from '../../payments/services/payments.service';
|
||||||
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
||||||
import { OrderRepository } from '../repositories/order.repository';
|
import { OrderRepository } from '../repositories/order.repository';
|
||||||
import { FindOrdersDto } from '../dto/find-orders.dto';
|
import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { OrderUserAddress, OrderCarAddress } from '../interface/order-status';
|
|
||||||
|
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||||
|
|
||||||
|
type ValidatedCartForOrder = {
|
||||||
|
user: User;
|
||||||
|
restaurant: Restaurant;
|
||||||
|
delivery: Delivery;
|
||||||
|
userAddress: OrderUserAddress | null;
|
||||||
|
carAddress: OrderCarAddress | null;
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
|
orderItemsData: OrderItemData[];
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
export class OrdersService {
|
||||||
private readonly logger = new Logger(OrdersService.name);
|
private readonly logger = new Logger(OrdersService.name);
|
||||||
|
|
||||||
|
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||||
|
[OrderStatus.NEW]: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED],
|
||||||
|
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED],
|
||||||
|
[OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED],
|
||||||
|
[OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
|
||||||
|
[OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
||||||
|
[OrderStatus.READY]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
|
||||||
|
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
|
||||||
|
[OrderStatus.COMPLETED]: [],
|
||||||
|
[OrderStatus.CANCELED]: [],
|
||||||
|
[OrderStatus.FAILED]: [],
|
||||||
|
[OrderStatus.REFUNDED]: [],
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly cartService: CartService,
|
private readonly cartService: CartService,
|
||||||
private readonly paymentsService: PaymentsService,
|
|
||||||
private readonly orderRepository: OrderRepository,
|
private readonly orderRepository: OrderRepository,
|
||||||
private readonly eventEmitter2: EventEmitter2,
|
// NOTE: kept for future payment integration
|
||||||
// private readonly paymentGatewayService: PaymentGatewayService,
|
private readonly _paymentsService: PaymentsService,
|
||||||
|
// NOTE: kept for future event emission
|
||||||
|
private readonly _eventEmitter2: EventEmitter2,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkout(userId: string, restaurantId: string) {
|
async checkout(userId: string, restaurantId: string) {
|
||||||
@@ -38,31 +63,16 @@ export class OrdersService {
|
|||||||
|
|
||||||
const { order } = await this.createOrder(userId, restaurantId, cart);
|
const { order } = await this.createOrder(userId, restaurantId, cart);
|
||||||
|
|
||||||
// if (!order.paymentMethod) {
|
|
||||||
// throw new BadRequestException('Payment method is required for checkout');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const { paymentUrl } = await this.paymentsService.startPayment(order.id);
|
|
||||||
|
|
||||||
await this.cartService.clearCart(userId, restaurantId);
|
await this.cartService.clearCart(userId, restaurantId);
|
||||||
|
|
||||||
// this.eventEmitter2.emit(
|
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
|
||||||
// OrderCreatedEvent.name,
|
|
||||||
// new OrderCreatedEvent(restaurantId, order.user.id, order.id, order.total),
|
|
||||||
// );
|
|
||||||
// this.eventEmitter2.emit(
|
|
||||||
// OrderPaymentSuccessEvent.name,
|
|
||||||
// new OrderPaymentSuccessEvent(restaurantId, order.user.id, order.id, order.total),
|
|
||||||
// );
|
|
||||||
console.log('Order created event emitted 111');
|
|
||||||
return { order };
|
return { order };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrder(userId: string, restaurantId: string, cart: Cart) {
|
async createOrder(userId: string, restaurantId: string, cart: Cart) {
|
||||||
const validationResult = await this.validateCartForOrder(userId, restaurantId, cart);
|
const validationResult = await this.validateCartForOrder(userId, restaurantId, cart);
|
||||||
// Create order within a transaction
|
|
||||||
return this.em.transactional(async em => {
|
return this.em.transactional(async em => {
|
||||||
// Create order entity
|
|
||||||
const order = em.create(Order, {
|
const order = em.create(Order, {
|
||||||
user: validationResult.user,
|
user: validationResult.user,
|
||||||
restaurant: validationResult.restaurant,
|
restaurant: validationResult.restaurant,
|
||||||
@@ -86,17 +96,10 @@ export class OrdersService {
|
|||||||
|
|
||||||
em.persist(order);
|
em.persist(order);
|
||||||
|
|
||||||
// Create order items and update stock
|
|
||||||
for (const itemData of validationResult.orderItemsData) {
|
for (const itemData of validationResult.orderItemsData) {
|
||||||
const { food, quantity, unitPrice, discount } = itemData;
|
const { food, quantity, unitPrice, discount } = itemData;
|
||||||
|
|
||||||
// Stock check
|
this.assertFoodHasSufficientStock(food, quantity);
|
||||||
if (!food.inventory) {
|
|
||||||
throw new BadRequestException(`Food ${food.title} does not have inventory`);
|
|
||||||
}
|
|
||||||
if (food.inventory.availableStock < quantity) {
|
|
||||||
throw new BadRequestException(`${food.title} is out of stock`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPrice = (unitPrice - discount) * quantity;
|
const totalPrice = (unitPrice - discount) * quantity;
|
||||||
|
|
||||||
@@ -111,12 +114,9 @@ export class OrdersService {
|
|||||||
|
|
||||||
em.persist(orderItem);
|
em.persist(orderItem);
|
||||||
|
|
||||||
//reserve food stock
|
|
||||||
|
|
||||||
em.persist(food);
|
em.persist(food);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush all changes
|
|
||||||
await em.flush();
|
await em.flush();
|
||||||
return { order };
|
return { order };
|
||||||
});
|
});
|
||||||
@@ -125,126 +125,24 @@ export class OrdersService {
|
|||||||
/**
|
/**
|
||||||
* Validates cart and prepares all required data for order creation
|
* Validates cart and prepares all required data for order creation
|
||||||
*/
|
*/
|
||||||
private async validateCartForOrder(
|
private async validateCartForOrder(userId: string, restaurantId: string, cart: Cart): Promise<ValidatedCartForOrder> {
|
||||||
userId: string,
|
this.assertCartHasItems(cart);
|
||||||
restaurantId: string,
|
this.assertCartHasDeliveryMethod(cart);
|
||||||
cart: Cart,
|
this.assertCartHasPaymentMethod(cart);
|
||||||
): Promise<{
|
|
||||||
user: User;
|
|
||||||
restaurant: Restaurant;
|
|
||||||
delivery: Delivery;
|
|
||||||
userAddress: OrderUserAddress | null;
|
|
||||||
carAddress: OrderCarAddress | null;
|
|
||||||
paymentMethod: PaymentMethod;
|
|
||||||
orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }>;
|
|
||||||
}> {
|
|
||||||
// Validate cart has items
|
|
||||||
if (!cart.items || cart.items.length === 0) {
|
|
||||||
throw new BadRequestException('Cart is empty. Add items to cart before creating an order.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate address is set
|
const [user, restaurant, delivery] = await Promise.all([
|
||||||
if (!cart.deliveryMethodId) {
|
this.getUserOrFail(userId),
|
||||||
throw new NotFoundException('Delivery method not found');
|
this.getRestaurantOrFail(restaurantId),
|
||||||
}
|
this.getDeliveryOrFail(cart.deliveryMethodId!),
|
||||||
|
]);
|
||||||
|
|
||||||
// Validate payment method is set
|
this.assertMeetsMinOrderForDelivery(cart, delivery);
|
||||||
if (!cart.paymentMethodId) {
|
this.assertDeliveryMethodRequirements(cart, delivery);
|
||||||
throw new BadRequestException(
|
|
||||||
'Payment method is required. Please set a payment method before creating an order.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and load entities
|
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
||||||
const user = await this.em.findOne(User, { id: userId });
|
this.assertPaymentMethodEnabled(paymentMethod);
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
const orderItemsData = await this.buildOrderItemsData(cart, restaurantId);
|
||||||
if (!restaurant) {
|
|
||||||
throw new NotFoundException('Restaurant not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const delivery = await this.em.findOne(Delivery, { id: cart.deliveryMethodId });
|
|
||||||
if (!delivery) {
|
|
||||||
throw new NotFoundException('Delivery not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate minimum order price for delivery method
|
|
||||||
const minOrderPrice = Number(delivery.minOrderPrice) || 0;
|
|
||||||
if (minOrderPrice > 0 && cart.total < minOrderPrice) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Minimum order amount for this delivery method is ${minOrderPrice}. Current total is ${cart.total}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate table number is required for DineIn
|
|
||||||
if (delivery.method === DeliveryMethodEnum.DineIn) {
|
|
||||||
if (!cart.tableNumber || cart.tableNumber.trim() === '') {
|
|
||||||
throw new BadRequestException('Table number is required when delivery method is DineIn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) {
|
|
||||||
throw new BadRequestException('Address is required. Please set a delivery address before creating an order.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) {
|
|
||||||
throw new BadRequestException('Car address is required. Please set a car address before creating an order.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentMethod = await this.em.findOne(
|
|
||||||
PaymentMethod,
|
|
||||||
{
|
|
||||||
id: cart.paymentMethodId,
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
},
|
|
||||||
{ populate: ['restaurant'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!paymentMethod) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Payment method with ID ${cart.paymentMethodId} not found for restaurant ${restaurantId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!paymentMethod.enabled) {
|
|
||||||
throw new BadRequestException('Payment method is not enabled for this restaurant');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate stock and prepare order items data
|
|
||||||
const orderItemsData: Array<{ food: Food; quantity: number; unitPrice: number; discount: number }> = [];
|
|
||||||
|
|
||||||
for (const cartItem of cart.items) {
|
|
||||||
const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant', 'inventory'] });
|
|
||||||
if (!food) {
|
|
||||||
throw new NotFoundException(`Food with ID ${cartItem.foodId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify food belongs to the restaurant
|
|
||||||
if (food.restaurant.id !== restaurantId) {
|
|
||||||
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final stock validation
|
|
||||||
const availableStock = food.inventory?.availableStock ?? 0;
|
|
||||||
if (availableStock < cartItem.quantity) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Insufficient stock for food ${food.title || food.id}. Available: ${availableStock}, Requested: ${cartItem.quantity}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitPrice = food.price || 0;
|
|
||||||
const discount = food.discount || 0;
|
|
||||||
|
|
||||||
orderItemsData.push({
|
|
||||||
food,
|
|
||||||
quantity: cartItem.quantity,
|
|
||||||
unitPrice,
|
|
||||||
discount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
@@ -313,116 +211,64 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async confirmOrder(orderId: string, restId: string) {
|
async confirmOrder(orderId: string, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, OrderStatus.CONFIRMED);
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.canTransition(order.status, OrderStatus.CONFIRMED, order.paymentMethod.method)) {
|
|
||||||
throw new BadRequestException('Invalid status transition');
|
|
||||||
}
|
|
||||||
|
|
||||||
order.status = OrderStatus.CONFIRMED;
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareOrder(orderId: string, restId: string) {
|
async prepareOrder(orderId: string, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, OrderStatus.PREPARING);
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
}
|
|
||||||
if (!this.canTransition(order.status, OrderStatus.PREPARING, order.paymentMethod.method)) {
|
|
||||||
throw new BadRequestException('Invalid status transition');
|
|
||||||
}
|
|
||||||
order.status = OrderStatus.PREPARING;
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// just admin can reject the order any time
|
// just admin can reject the order any time
|
||||||
async rejectOrder(orderId: string, restId: string) {
|
async rejectOrder(orderId: string, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, OrderStatus.CANCELED, { skipTransitionValidation: true });
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
}
|
|
||||||
order.status = OrderStatus.CANCELED;
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async readyForDelivery(orderId: string, restId: string) {
|
async readyForDelivery(orderId: string, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, OrderStatus.READY);
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.canTransition(order.status, OrderStatus.READY, order.paymentMethod.method)) {
|
|
||||||
throw new BadRequestException('Invalid status transition');
|
|
||||||
}
|
|
||||||
order.status = OrderStatus.READY;
|
|
||||||
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelOrderAsUser(orderId: string, restId: string) {
|
async cancelOrderAsUser(orderId: string, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, OrderStatus.CANCELED);
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
}
|
|
||||||
if (!this.canTransition(order.status, OrderStatus.CANCELED, order.paymentMethod.method)) {
|
|
||||||
throw new BadRequestException('Invalid status transition');
|
|
||||||
}
|
|
||||||
order.status = OrderStatus.CANCELED;
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsDelivered(orderId: string, restId: string) {
|
async markAsDelivered(orderId: string, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, OrderStatus.COMPLETED);
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.canTransition(order.status, OrderStatus.COMPLETED, order.paymentMethod.method)) {
|
|
||||||
throw new BadRequestException('Invalid status transition');
|
|
||||||
}
|
|
||||||
order.status = OrderStatus.COMPLETED;
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
return order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(orderId: string, status: OrderStatus, restId: string) {
|
async updateStatus(orderId: string, status: OrderStatus, restId: string) {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
|
return this.changeOrderStatus(orderId, restId, status);
|
||||||
if (!order) {
|
}
|
||||||
throw new NotFoundException('Order not found');
|
|
||||||
|
private async changeOrderStatus(
|
||||||
|
orderId: string,
|
||||||
|
restId: string,
|
||||||
|
toStatus: OrderStatus,
|
||||||
|
options?: { skipTransitionValidation?: boolean },
|
||||||
|
): Promise<Order> {
|
||||||
|
const order = await this.getOrderOrFail(orderId, restId);
|
||||||
|
|
||||||
|
if (!options?.skipTransitionValidation) {
|
||||||
|
this.assertStatusTransitionAllowed(order, toStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.canTransition(order.status, status, order.paymentMethod.method)) {
|
order.status = toStatus;
|
||||||
throw new BadRequestException('Invalid status transition');
|
|
||||||
}
|
|
||||||
|
|
||||||
order.status = status;
|
|
||||||
await this.em.persistAndFlush(order);
|
await this.em.persistAndFlush(order);
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
transitions: Record<OrderStatus, OrderStatus[]> = {
|
private assertStatusTransitionAllowed(order: Order, to: OrderStatus) {
|
||||||
[OrderStatus.NEW]: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED],
|
const paymentMethod = order.paymentMethod?.method;
|
||||||
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED],
|
if (!paymentMethod) {
|
||||||
[OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED],
|
throw new BadRequestException('Order payment method is missing');
|
||||||
[OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
|
}
|
||||||
[OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.READY]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
|
|
||||||
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
|
|
||||||
[OrderStatus.COMPLETED]: [],
|
|
||||||
[OrderStatus.CANCELED]: [],
|
|
||||||
[OrderStatus.FAILED]: [],
|
|
||||||
[OrderStatus.REFUNDED]: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) {
|
if (!this.canTransition(order.status, to, paymentMethod)) {
|
||||||
if (!this.transitions[from]?.includes(to)) return false;
|
throw new BadRequestException(`Invalid status transition: ${order.status} -> ${to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) {
|
||||||
|
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
|
||||||
|
|
||||||
if (paymentMethod === PaymentMethodEnum.Cash) {
|
if (paymentMethod === PaymentMethodEnum.Cash) {
|
||||||
if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false;
|
if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false;
|
||||||
@@ -435,68 +281,132 @@ export class OrdersService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async getOrderOrFail(orderId: string, restId: string): Promise<Order> {
|
||||||
* Cleanup job to handle abandoned orders (pending payment for >30 minutes)
|
const order = await this.em.findOne(
|
||||||
* Runs every 10 minutes to check for abandoned orders
|
Order,
|
||||||
*/
|
{ id: orderId, restaurant: { id: restId } },
|
||||||
// @Cron(CronExpression.EVERY_10_MINUTES)
|
{ populate: ['paymentMethod'] },
|
||||||
// async cleanupAbandonedOrders() {
|
);
|
||||||
// this.logger.log('Starting cleanup of abandoned orders...');
|
if (!order) throw new NotFoundException('Order not found');
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
// try {
|
private assertCartHasItems(cart: Cart) {
|
||||||
// const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
if (!cart.items || cart.items.length === 0) {
|
||||||
|
throw new BadRequestException('Cart is empty. Add items to cart before creating an order.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// // Find abandoned orders: Pending status, Pending payment, created >30 minutes ago
|
private assertCartHasDeliveryMethod(cart: Cart) {
|
||||||
// const abandonedOrders = await this.em.find(
|
if (!cart.deliveryMethodId) {
|
||||||
// Order,
|
throw new NotFoundException('Delivery method not found');
|
||||||
// {
|
}
|
||||||
// status: OrderStatus.PENDING_PAYMENT,
|
}
|
||||||
// paymentStatus: PaymentStatusEnum.Pending,
|
|
||||||
// createdAt: { $lt: thirtyMinutesAgo },
|
|
||||||
// },
|
|
||||||
// { populate: ['items', 'items.food'] },
|
|
||||||
// );
|
|
||||||
|
|
||||||
// if (abandonedOrders.length === 0) {
|
private assertCartHasPaymentMethod(cart: Cart) {
|
||||||
// this.logger.log('No abandoned orders found');
|
if (!cart.paymentMethodId) {
|
||||||
// return;
|
throw new BadRequestException(
|
||||||
// }
|
'Payment method is required. Please set a payment method before creating an order.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// this.logger.log(`Found ${abandonedOrders.length} abandoned order(s) to cleanup`);
|
private async getUserOrFail(userId: string): Promise<User> {
|
||||||
|
const user = await this.em.findOne(User, { id: userId });
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
// // Process each abandoned order in a transaction
|
private async getRestaurantOrFail(restaurantId: string): Promise<Restaurant> {
|
||||||
// for (const order of abandonedOrders) {
|
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||||
// await this.em.transactional(async em => {
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
// // Load order items with food relations
|
return restaurant;
|
||||||
// await order.items.loadItems();
|
}
|
||||||
|
|
||||||
// // Restore stock for each item
|
private async getDeliveryOrFail(deliveryId: string): Promise<Delivery> {
|
||||||
// for (const item of order.items) {
|
const delivery = await this.em.findOne(Delivery, { id: deliveryId });
|
||||||
// const food = item.food;
|
if (!delivery) throw new NotFoundException('Delivery not found');
|
||||||
// if (food) {
|
return delivery;
|
||||||
// food.inventory?.availableStock += item.quantity;
|
}
|
||||||
// em.persist(food);
|
|
||||||
// this.logger.debug(
|
|
||||||
// `Restored ${item.quantity} units of stock for food ${food.id} (${food.title || 'N/A'})`,
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Update order status to Cancelled
|
private assertMeetsMinOrderForDelivery(cart: Cart, delivery: Delivery) {
|
||||||
// order.status = OrderStatus.CANCELED;
|
const minOrderPrice = Number(delivery.minOrderPrice) || 0;
|
||||||
// em.persist(order);
|
if (minOrderPrice > 0 && cart.total < minOrderPrice) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Minimum order amount for this delivery method is ${minOrderPrice}. Current total is ${cart.total}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// await em.flush();
|
private assertDeliveryMethodRequirements(cart: Cart, delivery: Delivery) {
|
||||||
// this.logger.log(`Cancelled abandoned order ${order.id}`);
|
if (delivery.method === DeliveryMethodEnum.DineIn) {
|
||||||
// });
|
if (!cart.tableNumber || cart.tableNumber.trim() === '') {
|
||||||
// }
|
throw new BadRequestException('Table number is required when delivery method is DineIn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// this.logger.log(`Successfully cleaned up ${abandonedOrders.length} abandoned order(s)`);
|
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) {
|
||||||
// } catch (error) {
|
throw new BadRequestException('Address is required. Please set a delivery address before creating an order.');
|
||||||
// const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
}
|
||||||
// const errorStack = error instanceof Error ? error.stack : undefined;
|
|
||||||
// this.logger.error(`Error during cleanup of abandoned orders: ${errorMessage}`, errorStack);
|
if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) {
|
||||||
// throw error;
|
throw new BadRequestException('Car address is required. Please set a car address before creating an order.');
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
private async getPaymentMethodOrFail(paymentMethodId: string, restaurantId: string): Promise<PaymentMethod> {
|
||||||
|
const paymentMethod = await this.em.findOne(
|
||||||
|
PaymentMethod,
|
||||||
|
{
|
||||||
|
id: paymentMethodId,
|
||||||
|
restaurant: { id: restaurantId },
|
||||||
|
},
|
||||||
|
{ populate: ['restaurant'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!paymentMethod) {
|
||||||
|
throw new NotFoundException(`Payment method with ID ${paymentMethodId} not found for restaurant ${restaurantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertPaymentMethodEnabled(paymentMethod: PaymentMethod) {
|
||||||
|
if (!paymentMethod.enabled) {
|
||||||
|
throw new BadRequestException('Payment method is not enabled for this restaurant');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildOrderItemsData(cart: Cart, restaurantId: string): Promise<OrderItemData[]> {
|
||||||
|
const orderItemsData: OrderItemData[] = [];
|
||||||
|
|
||||||
|
for (const cartItem of cart.items) {
|
||||||
|
const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant', 'inventory'] });
|
||||||
|
if (!food) throw new NotFoundException(`Food with ID ${cartItem.foodId} not found`);
|
||||||
|
|
||||||
|
if (food.restaurant.id !== restaurantId) {
|
||||||
|
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertFoodHasSufficientStock(food, cartItem.quantity);
|
||||||
|
|
||||||
|
orderItemsData.push({
|
||||||
|
food,
|
||||||
|
quantity: cartItem.quantity,
|
||||||
|
unitPrice: food.price || 0,
|
||||||
|
discount: food.discount || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderItemsData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertFoodHasSufficientStock(food: Food, quantity: number) {
|
||||||
|
const availableStock = food.inventory?.availableStock ?? 0;
|
||||||
|
if (availableStock < quantity) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Insufficient stock for food ${food.title || food.id}. Available: ${availableStock}, Requested: ${quantity}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user