import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/postgresql'; import { Order } from '../entities/order.entity'; import { OrderItem } from '../entities/order-item.entity'; import { User } from '../../../users/entities/user.entity'; import { Shop } from '../../../shops/entities/shop.entity'; import { Product } from '../../../products/entities/product.entity'; import { CartService } from '../../../cart/providers/cart.service'; import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface'; import { PaymentMethodEnum, PaymentStatusEnum } from '../../../payments/interface/payment'; import { Cart } from '../../../cart/interfaces/cart.interface'; import { PaymentMethod } from '../../../payments/entities/payment-method.entity'; import { PaymentsService } from '../../../payments/services/payments.service'; import { DeliveryMethodEnum } from '../../../delivery/interface/delivery'; import { Delivery } from '../../../delivery/entities/delivery.entity'; import { OrderRepository } from '../repositories/order.repository'; import { FindOrdersDto } from '../dto/find-orders.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { Payment } from 'src/modules/payments/entities/payment.entity'; import { StatusTransitionRef } from '../interface/order.interface'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events'; import { OrderMessage } from 'src/common/enums/message.enum'; type OrderItemData = { product: Product; quantity: number; unitPrice: number; discount: number }; type ValidatedCartForOrder = { user: User; shop: Shop; delivery: Delivery; userAddress: OrderUserAddress | null; carAddress: OrderCarAddress | null; paymentMethod: PaymentMethod; orderItemsData: OrderItemData[]; }; @Injectable() export class OrdersService { private readonly logger = new Logger(OrdersService.name); private static readonly STATUS_TRANSITIONS: Record = { [OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.PREPARING], [OrderStatus.PAID]: [OrderStatus.PREPARING, OrderStatus.CANCELED], [OrderStatus.PREPARING]: [OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.SHIPPED, OrderStatus.CANCELED], [OrderStatus.DELIVERED_TO_WAITER]: [OrderStatus.COMPLETED, OrderStatus.CANCELED], [OrderStatus.DELIVERED_TO_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED], [OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.CANCELED], [OrderStatus.COMPLETED]: [OrderStatus.CANCELED], [OrderStatus.CANCELED]: [], }; constructor( private readonly em: EntityManager, private readonly cartService: CartService, private readonly orderRepository: OrderRepository, private readonly paymentsService: PaymentsService, private readonly eventEmitter: EventEmitter2, ) { } async checkout(userId: string, restaurantId: string) { const cart = await this.cartService.findOneOrFail(userId, restaurantId); const validated = await this.validateCartForOrder(userId, restaurantId, cart); const order = await this.em.transactional(async em => { const order = em.create(Order, { user: validated.user, shop: validated.shop, deliveryMethod: validated.delivery, userAddress: validated.userAddress, carAddress: validated.carAddress, paymentMethod: validated.paymentMethod, couponDiscount: cart.couponDiscount || 0, couponDetail: cart.coupon, itemsDiscount: cart.itemsDiscount || 0, totalDiscount: cart.totalDiscount || 0, subTotal: cart.subTotal || 0, tax: cart.tax || 0, deliveryFee: cart.deliveryFee || 0, total: cart.total || 0, totalItems: cart.totalItems || 0, description: cart.description, tableNumber: cart.tableNumber, status: OrderStatus.PENDING_PAYMENT, history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }], }); em.persist(order); for (const itemData of validated.orderItemsData) { const { product, quantity, unitPrice, discount } = itemData; this.assertFoodHasSufficientStock(product, quantity); const totalPrice = (unitPrice - discount) * quantity; const orderItem = em.create(OrderItem, { order, product, quantity, unitPrice, discount, totalPrice, }); em.persist(orderItem); } const payment = em.create(Payment, { order, amount: order.total, status: PaymentStatusEnum.Pending, method: order.paymentMethod.method, gateway: order.paymentMethod.gateway ?? null, }); em.persist(payment); await em.flush(); this.logger.debug(`Order ${order.id} created for user ${userId} (shop ${restaurantId})`); return order; }); await this.cartService.clearCart(userId, restaurantId); const { paymentUrl } = await this.paymentsService.payOrder(order.id); this.eventEmitter.emit( OrderCreatedEvent.name, new OrderCreatedEvent(order.id, restaurantId, String(order?.orderNumber) || '', order.total), ); return { paymentUrl, order }; } /** * Validates cart and prepares all required data for order creation */ private async validateCartForOrder(userId: string, restaurantId: string, cart: Cart): Promise { this.assertCartHasItems(cart); this.assertCartHasDeliveryMethod(cart); this.assertCartHasPaymentMethod(cart); const [user, shop, delivery] = await Promise.all([ this.getUserOrFail(userId), this.getRestaurantOrFail(restaurantId), this.getDeliveryOrFail(cart.deliveryMethodId!), ]); this.assertMeetsMinOrderForDelivery(cart, delivery); this.assertDeliveryMethodRequirements(cart, delivery); const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId); this.assertPaymentMethodEnabled(paymentMethod); const orderItemsData = await this.buildOrderItemsData(cart, restaurantId); return { user, shop, delivery, paymentMethod, userAddress: delivery.method === DeliveryMethodEnum.DeliveryCourier ? (cart?.userAddress ?? null) : null, carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (cart?.carAddress ?? null) : null, orderItemsData, }; } async findAllForUser(restId: string, dto: FindOrdersDto, userId: string): Promise> { const result = await this.orderRepository.findAllPaginated(restId, { page: dto.page, limit: dto.limit, statuses: dto.statuses, paymentStatus: dto.paymentStatus, search: dto.search, startDate: dto.startDate, endDate: dto.endDate, orderBy: dto.orderBy, order: dto.order, userId, }); return result; } async findAllForAdmin(restId: string, dto: FindOrdersDto): Promise> { const result = await this.orderRepository.findAllPaginated(restId, { page: dto.page, limit: dto.limit, statuses: dto.statuses, paymentStatus: dto.paymentStatus, search: dto.search, startDate: dto.startDate, endDate: dto.endDate, orderBy: dto.orderBy, order: dto.order, excludeOnlinePendingPayment: true, }); return result; } async findOne(id: string, restId: string): Promise { const order = await this.orderRepository.findOne( { id, shop: { id: restId } }, { populate: [ 'user', 'shop', 'deliveryMethod', 'userAddress', 'carAddress', 'paymentMethod', 'payments', 'items', 'items.product', ], }, ); if (!order) { throw new NotFoundException(OrderMessage.NOT_FOUND); } return order; } async changeOrderStatus( orderId: string, restId: string, toStatus: OrderStatus, ref: StatusTransitionRef, desc?: string, ): Promise { const order = await this.getOrderOrFail(orderId, restId); // Store previous status before changing it const previousStatus = order.status; this.assertStatusTransitionAllowed(order, toStatus, ref); order.status = toStatus; order.history.push({ status: toStatus, changedAt: new Date(), desc: desc || null }); await this.em.persistAndFlush(order); this.eventEmitter.emit( OrderStatusChangedEvent.name, new OrderStatusChangedEvent( orderId, order.user?.id || '', String(order?.orderNumber) || '', restId, previousStatus, toStatus, ref, ), ); return order; } /* Helpers */ private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) { const paymentMethod = order.paymentMethod?.method; if (!paymentMethod) { throw new BadRequestException(OrderMessage.PAYMENT_METHOD_MISSING); } if (!this.canTransition(order.status, to, paymentMethod, ref, order.deliveryMethod.method)) { throw new BadRequestException(OrderMessage.INVALID_STATUS_TRANSITION); } } private canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum, ref: 'user' | 'admin', deliveryMethod: DeliveryMethodEnum) { if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false; if (to === OrderStatus.CANCELED) { // only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(from)) { return false; } else if (ref === 'admin') { return true; } } // only allow orders with status of PENDING_PAYMENT and payment // method of cash are allowed to move to CONFIRMED directly if ( from == OrderStatus.PENDING_PAYMENT && to == OrderStatus.PREPARING && paymentMethod !== PaymentMethodEnum.Cash ) { return false; } /** * Only allow orders with status of PREPARING to be shipped if the delivery method is DeliveryCourier */ if ( from == OrderStatus.PREPARING && to == OrderStatus.SHIPPED && deliveryMethod !== DeliveryMethodEnum.DeliveryCourier ) { return false; } if ( from == OrderStatus.PREPARING && to == OrderStatus.DELIVERED_TO_WAITER && deliveryMethod !== DeliveryMethodEnum.DineIn && deliveryMethod !== DeliveryMethodEnum.DeliveryCar ) { return false; } if ( from == OrderStatus.PREPARING && to == OrderStatus.DELIVERED_TO_RECEPTIONIST && deliveryMethod !== DeliveryMethodEnum.CustomerPickup ) { return false; } if (paymentMethod === PaymentMethodEnum.Online) { if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false; } return true; } private async getOrderOrFail(orderId: string, restId: string): Promise { const order = await this.em.findOne( Order, { id: orderId, shop: { id: restId } }, { populate: ['paymentMethod', 'deliveryMethod', 'user'] }, ); if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND); return order; } private assertCartHasItems(cart: Cart) { if (!cart.items || cart.items.length === 0) { throw new BadRequestException(OrderMessage.CART_EMPTY); } } private assertCartHasDeliveryMethod(cart: Cart) { if (!cart.deliveryMethodId) { throw new NotFoundException(OrderMessage.DELIVERY_METHOD_NOT_FOUND); } } private assertCartHasPaymentMethod(cart: Cart) { if (!cart.paymentMethodId) { throw new BadRequestException(OrderMessage.PAYMENT_METHOD_REQUIRED); } } private async getUserOrFail(userId: string): Promise { const user = await this.em.findOne(User, { id: userId }); if (!user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND); return user; } private async getRestaurantOrFail(restaurantId: string): Promise { const shop = await this.em.findOne(Shop, { id: restaurantId }); if (!shop) throw new NotFoundException(OrderMessage.RESTAURANT_NOT_FOUND); return shop; } private async getDeliveryOrFail(deliveryId: string): Promise { const delivery = await this.em.findOne(Delivery, { id: deliveryId }); if (!delivery) throw new NotFoundException(OrderMessage.DELIVERY_NOT_FOUND); return delivery; } private assertMeetsMinOrderForDelivery(cart: Cart, delivery: Delivery) { const minOrderPrice = Number(delivery.minOrderPrice) || 0; if (minOrderPrice > 0 && cart.total < minOrderPrice) { throw new BadRequestException(OrderMessage.MIN_ORDER_AMOUNT_NOT_MET); } } private assertDeliveryMethodRequirements(cart: Cart, delivery: Delivery) { if (delivery.method === DeliveryMethodEnum.DineIn) { if (!cart.tableNumber || cart.tableNumber.trim() === '') { throw new BadRequestException(OrderMessage.TABLE_NUMBER_REQUIRED); } } if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) { throw new BadRequestException(OrderMessage.ADDRESS_REQUIRED); } if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) { throw new BadRequestException(OrderMessage.CAR_ADDRESS_REQUIRED); } } private async getPaymentMethodOrFail(paymentMethodId: string, restaurantId: string): Promise { const paymentMethod = await this.em.findOne( PaymentMethod, { id: paymentMethodId, shop: { id: restaurantId }, }, { populate: ['shop'] }, ); if (!paymentMethod) { throw new NotFoundException(`Payment method with ID ${paymentMethodId} not found for shop ${restaurantId}`); } return paymentMethod; } private assertPaymentMethodEnabled(paymentMethod: PaymentMethod) { if (!paymentMethod.enabled) { throw new BadRequestException(OrderMessage.PAYMENT_METHOD_NOT_ENABLED); } } private async buildOrderItemsData(cart: Cart, restaurantId: string): Promise { const orderItemsData: OrderItemData[] = []; for (const cartItem of cart.items) { const product = await this.em.findOne(Product, { id: cartItem.foodId }, { populate: ['shop'] }); if (!product) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND); if (product.shop.id !== restaurantId) { throw new BadRequestException(OrderMessage.FOOD_NOT_BELONGS_TO_RESTAURANT); } orderItemsData.push({ product, quantity: cartItem.quantity, unitPrice: product.price || 0, discount: product.discount || 0, }); } return orderItemsData; } async getStats(restId: string) { return this.em.transactional(async em => { // 1. Total orders count (excluding pending and canceled) const totalOrders = await em.count(Order, { shop: { id: restId }, status: { $in: [ OrderStatus.PAID, OrderStatus.PREPARING, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.SHIPPED, OrderStatus.COMPLETED, ], }, }); // 2. Active products count const activeFoods = await em.count(Product, { shop: { id: restId }, isActive: true, }); // 3. Total clients count (distinct users with orders for this shop) const clientsResult = await em.execute( ` SELECT COUNT(DISTINCT o.user_id) as count FROM orders o WHERE o.restaurant_id = ? `, [restId], ); const totalClients = Number(clientsResult[0]?.count || 0); // 4. Total revenue (sum of paid payments for orders of this shop) const revenueResult = await em.execute( ` SELECT COALESCE(SUM(p.amount), 0)::numeric as total FROM payments p INNER JOIN orders o ON p.order_id = o.id WHERE o.restaurant_id = ? AND p.status = 'paid' `, [restId], ); const totalRevenue = Number(revenueResult[0]?.total || 0); return { totalOrders, activeFoods, totalClients, totalRevenue, }; }); } async getFoodSalesPieChart(restId: string) { return this.em.transactional(async em => { // Use last 30 days instead of just last month to be more inclusive const now = new Date(); const endDate = new Date(now); endDate.setHours(23, 59, 59, 999); const startDate = new Date(now); startDate.setDate(startDate.getDate() - 30); startDate.setHours(0, 0, 0, 0); // Calculate actual last month for the period display const firstDayOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); firstDayOfLastMonth.setHours(0, 0, 0, 0); const lastDayOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); this.logger.debug( `Product sales pie chart query params: restId=${restId}, startDate=${startDate.toISOString()}, endDate=${endDate.toISOString()}`, ); // Diagnostic query to check what orders exist const diagnosticResult = await em.execute( ` SELECT o.id, o.status, o.created_at, COUNT(oi.id) as item_count FROM orders o LEFT JOIN order_items oi ON oi.order_id = o.id WHERE o.restaurant_id = ? GROUP BY o.id, o.status, o.created_at ORDER BY o.created_at DESC LIMIT 10 `, [restId], ); this.logger.debug(`Diagnostic: Found ${diagnosticResult.length} recent orders for shop ${restId}`); if (diagnosticResult.length > 0) { this.logger.debug(`Sample order: status=${diagnosticResult[0].status}, created_at=${diagnosticResult[0].created_at}`); } // Query order items from orders in the date range // Only include completed orders (excluding canceled and pending) const result = await em.execute( ` SELECT f.id as food_id, f.title as food_title, COALESCE(SUM(oi.quantity), 0)::int as total_quantity, COALESCE(SUM(oi.total_price), 0)::numeric as total_revenue FROM order_items oi INNER JOIN orders o ON oi.order_id = o.id INNER JOIN products f ON oi.food_id = f.id WHERE o.restaurant_id = ? AND o.created_at >= ? AND o.created_at <= ? AND o.status NOT IN ('pendingPayment', 'canceled') GROUP BY f.id, f.title ORDER BY total_revenue DESC `, [restId, startDate, endDate], ); this.logger.debug(`Product sales pie chart query returned ${result.length} rows`); // Calculate total revenue for percentage calculation const totalRevenue = result.reduce((sum: number, item: any) => { return sum + Number(item.total_revenue || 0); }, 0); // Format data for pie chart and calculate percentages const chartData = result.map((item: any) => ({ foodId: item.food_id, foodTitle: item.food_title || 'Unknown', quantity: Number(item.total_quantity || 0), revenue: Number(item.total_revenue || 0), percentage: totalRevenue > 0 ? Number(((Number(item.total_revenue || 0) / totalRevenue) * 100).toFixed(2)) : 0, })); return { period: { startDate: firstDayOfLastMonth.toISOString(), endDate: lastDayOfLastMonth.toISOString(), }, totalRevenue, data: chartData, }; }); } }