import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; 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 { UserAddress } from '../../users/entities/user-address.entity'; import { UserRestaurant } from '../../users/entities/user-restuarant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Food } from '../../foods/entities/food.entity'; import { CartService } from '../../cart/providers/cart.service'; import { CartValidationService } from '../../cart/providers/cart-validation.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 { DeliveryFeeTypeEnum, 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 { InventoryService } from 'src/modules/inventory/inventory.service'; import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food.dto'; import { StatusTransitionRef } from '../interface/order.interface'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events'; import { AdminCreateOrderDto } from '../dto/admin-create-order.dto'; import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto'; import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto'; import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto'; import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto'; import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto'; import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto'; import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto'; import { CashShiftsService } from './cash-shifts.service'; import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity'; import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet'; import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum'; import { SmsQueueService } from 'src/modules/notifications/services/sms-queue.service'; 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() export class OrdersService { private readonly logger = new Logger(OrdersService.name); private static readonly STATUS_TRANSITIONS: Record = { [OrderStatus.NEW]: [ OrderStatus.CANCELED, OrderStatus.PREPARING], [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 cartValidationService: CartValidationService, private readonly orderRepository: OrderRepository, private readonly paymentsService: PaymentsService, private readonly inventoryService: InventoryService, private readonly eventEmitter: EventEmitter2, private readonly cashShiftsService: CashShiftsService, private readonly smsQueueService: SmsQueueService, private readonly configService: ConfigService, ) { } 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, restaurant: validated.restaurant, deliveryMethod: validated.delivery, userAddress: validated.userAddress, carAddress: validated.carAddress, paymentMethod: validated.paymentMethod, couponDiscount: cart.couponDiscount || 0, couponDetail: cart.coupon, tax: cart.tax || 0, deliveryFee: cart.deliveryFee || 0, packingFee: 0, description: cart.description, printInvoice: cart.printInvoice ?? false, tableNumber: cart.tableNumber, status: OrderStatus.NEW, history: [{ status: OrderStatus.NEW, changedAt: new Date() }], subTotal: 0, total: 0, totalItems: 0, totalDiscount: 0, itemsDiscount: 0, paidAmount: 0, balance: 0, refundedAmount: 0, }); em.persist(order); for (const itemData of validated.orderItemsData) { const { food, quantity, unitPrice, discount } = itemData; this.assertFoodHasSufficientStock(food, quantity); const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0, }); em.persist(orderItem); } // Flush so DB triggers compute subTotal / total / totalItems from items await em.flush(); await em.refresh(order); const payment = em.create(Payment, { order, amount: order.total, status: PaymentStatusEnum.Pending, method: order.paymentMethod.method, gateway: order.paymentMethod.gateway ?? null, ...this.buildCreditCardPaymentFields(validated.paymentMethod, cart.paymentDesc, cart.attachments), }); em.persist(payment); // reserve stock based on payment method. const bulkReserveFoodDto: BulkReserveFoodDto = { items: validated.orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity, })), }; await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto); await this.cashShiftsService.assignOrderToOpenShift(order, em); await em.flush(); this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${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, 'user'), ); return { paymentUrl, order }; } async createOrderForUser(restaurantId: string, dto: AdminCreateOrderDto): Promise { const user = dto.userId ? await this.getUserOrFail(dto.userId) : null; const [restaurant, delivery] = await Promise.all([ this.getRestaurantOrFail(restaurantId), this.getDeliveryOrFail(dto.deliveryMethodId), ]); const paymentMethod = await this.getPaymentMethodOrFail(dto.paymentMethodId, restaurantId); this.assertPaymentMethodEnabled(paymentMethod); this.assertAdminDeliveryRequirements(dto, delivery); const userAddress = delivery.method === DeliveryMethodEnum.DeliveryCourier ? await this.resolveUserAddressForOrder(user!, dto.addressId!) : null; if (userAddress) { await this.cartValidationService.assertAddressInsideServiceArea( restaurantId, userAddress.latitude, userAddress.longitude, ); } const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId); // Validate admin discount against item totals (not persisted — DB recomputes order money fields) const itemsSubTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0); const itemsDiscount = orderItemsData.reduce((sum, item) => sum + item.discount * item.quantity, 0); const deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0; const packingFee = dto.packingFee ?? 0; const adminDiscount = dto.discountAmount ?? 0; const maxDiscount = itemsSubTotal - itemsDiscount; if (adminDiscount > maxDiscount) { throw new BadRequestException(OrderMessage.DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL); } const order = await this.em.transactional(async em => { const order = em.create(Order, { user, restaurant, deliveryMethod: delivery, userAddress, carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (dto.carAddress ?? null) : null, paymentMethod, couponDiscount: adminDiscount, couponDetail: null, tax: 0, deliveryFee, packingFee, description: dto.description, printInvoice: false, tableNumber: dto.tableNumber, status: OrderStatus.COMPLETED, history: [{ status: OrderStatus.NEW, changedAt: new Date() }], subTotal: 0, total: 0, totalItems: 0, totalDiscount: 0, itemsDiscount: 0, paidAmount: 0, balance: 0, refundedAmount: 0, }); em.persist(order); for (const itemData of orderItemsData) { const { food, quantity, unitPrice, discount } = itemData; this.assertFoodHasSufficientStock(food, quantity); const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0 }); em.persist(orderItem); } await em.flush(); await em.refresh(order); const payment = em.create(Payment, { order, amount: order.total, status: PaymentStatusEnum.Paid, method: order.paymentMethod.method, gateway: order.paymentMethod.gateway ?? null, }); em.persist(payment); const bulkReserveFoodDto: BulkReserveFoodDto = { items: orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity })), }; await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto); await this.cashShiftsService.assignOrderToOpenShift(order, em); await em.flush(); this.logger.debug(`Admin created order ${order.id} for user ${user?.id || 'anonymous'} (restaurant: ${restaurantId})`); return order; }); this.eventEmitter.emit( OrderCreatedEvent.name, new OrderCreatedEvent(order.id, restaurantId, String(order?.orderNumber) || '', order.total, 'restaurant'), ); return order; } async updateOrderFees(orderId: string, restId: string, dto: AdminUpdateOrderFeesDto): Promise { if (dto.deliveryFee === undefined && dto.packingFee === undefined && dto.prepareTime === undefined) { throw new BadRequestException(OrderMessage.NO_FIELDS_TO_UPDATE); } const order = await this.getOrderOrFail(orderId, restId); if ([OrderStatus.CANCELED, OrderStatus.COMPLETED].includes(order.status)) { throw new BadRequestException(OrderMessage.CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER); } const feesChanged = dto.deliveryFee !== undefined || dto.packingFee !== undefined; if (dto.deliveryFee !== undefined) { order.deliveryFee = dto.deliveryFee; } if (dto.packingFee !== undefined) { order.packingFee = dto.packingFee; } if (dto.prepareTime !== undefined) { order.prepareTime = dto.prepareTime; } // total / balance / pending payment.amount are recalculated by DB triggers await this.em.persistAndFlush(order); if (feesChanged) { await this.em.refresh(order); } return order; } async addOrderItem(orderId: string, restId: string, dto: AdminAddOrderItemDto): Promise { const order = await this.getOrderWithItemsOrFail(orderId, restId); this.assertOrderEditable(order); const existingItem = order.items.getItems().find(item => item.food.id === dto.foodId); if (existingItem) { const newQuantity = existingItem.quantity + dto.quantity; return this.updateOrderItemQuantity(orderId, restId, existingItem.id, { quantity: newQuantity }); } const [itemData] = await this.buildOrderItemsDataFromDto([{ foodId: dto.foodId, quantity: dto.quantity }], restId); await this.em.transactional(async em => { const orderItem = em.create(OrderItem, { order, food: itemData.food, quantity: itemData.quantity, unitPrice: itemData.unitPrice, discount: itemData.discount, totalPrice: 0, }); em.persist(orderItem); await this.inventoryService.deductFromInventory(em, { items: [{ foodId: itemData.food.id, quantity: itemData.quantity }], }); await em.flush(); }); return this.findOne(orderId, restId); } async updateOrderItemQuantity( orderId: string, restId: string, itemId: string, dto: AdminUpdateOrderItemDto, ): Promise { const order = await this.getOrderWithItemsOrFail(orderId, restId); this.assertOrderEditable(order); const orderItem = order.items.getItems().find(item => item.id === itemId); if (!orderItem) { throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND); } if (orderItem.quantity === dto.quantity) { return this.findOne(orderId, restId); } const delta = dto.quantity - orderItem.quantity; const foodId = orderItem.food.id; if (delta > 0) { const food = await this.em.findOne(Food, { id: foodId }, { populate: ['inventory'] }); if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND); this.assertFoodHasSufficientStock(food, delta); } await this.em.transactional(async em => { orderItem.quantity = dto.quantity; em.persist(orderItem); if (delta > 0) { await this.inventoryService.deductFromInventory(em, { items: [{ foodId, quantity: delta }], }); } else { await this.inventoryService.restoreToInventory(em, { items: [{ foodId, quantity: Math.abs(delta) }], }); } await em.flush(); }); return this.findOne(orderId, restId); } async removeOrderItem(orderId: string, restId: string, itemId: string): Promise { const order = await this.getOrderWithItemsOrFail(orderId, restId); this.assertOrderEditable(order); const items = order.items.getItems(); if (items.length <= 1) { throw new BadRequestException(OrderMessage.CANNOT_REMOVE_LAST_ORDER_ITEM); } const orderItem = items.find(item => item.id === itemId); if (!orderItem) { throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND); } const foodId = orderItem.food.id; const quantity = orderItem.quantity; await this.em.transactional(async em => { await this.inventoryService.restoreToInventory(em, { items: [{ foodId, quantity }], }); em.remove(orderItem); await em.flush(); }); return this.findOne(orderId, restId); } private assertOrderEditable(order: Order) { if ([OrderStatus.CANCELED, OrderStatus.COMPLETED].includes(order.status)) { throw new BadRequestException(OrderMessage.CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER); } } private async getOrderWithItemsOrFail(orderId: string, restId: string): Promise { const order = await this.em.findOne( Order, { id: orderId, restaurant: { id: restId } }, { populate: ['items', 'items.food', 'items.food.inventory'] }, ); if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND); return 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, restaurant, delivery] = await Promise.all([ this.getUserOrFail(userId), this.getRestaurantOrFail(restaurantId), this.getDeliveryOrFail(cart.deliveryMethodId!), ]); this.assertMeetsMinOrderForDelivery(cart, delivery); this.assertDeliveryMethodRequirements(cart, delivery); if (delivery.method === DeliveryMethodEnum.DeliveryCourier && cart.userAddress) { await this.cartValidationService.assertAddressInsideServiceArea( restaurantId, cart.userAddress.latitude, cart.userAddress.longitude, ); } const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId); this.assertPaymentMethodEnabled(paymentMethod); this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments); const orderItemsData = await this.buildOrderItemsData(cart, restaurantId); return { user, restaurant, 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, cashShiftId: dto.cashShiftId, }); return result; } async findOne(id: string, restId: string): Promise { const order = await this.orderRepository.findOne( { id, restaurant: { id: restId } }, { populate: [ 'user', 'restaurant', 'deliveryMethod', 'userAddress', 'carAddress', 'paymentMethod', 'cashShift', 'cashShift.admin', 'payments', 'items', 'items.food', ], }, ); 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; } async addPayment(orderId: string, restId: string, dto: AdminAddPaymentDto): Promise { return this.em.transactional(async em => { const order = await em.findOne( Order, { id: orderId, restaurant: { id: restId } }, { populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] }, ); if (!order) { throw new NotFoundException(OrderMessage.NOT_FOUND); } if (order.status === OrderStatus.CANCELED) { throw new BadRequestException(OrderMessage.ADD_PAYMENT_NOT_ALLOWED); } const balance = Number(order.balance ?? 0); if (balance <= 0) { throw new BadRequestException(OrderMessage.ADD_PAYMENT_NO_BALANCE); } const amount = Number(dto.amount); if (!amount || amount <= 0) { throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO); } const payment = em.create(Payment, { order, amount, method: PaymentMethodEnum.Cash, gateway: null, status: PaymentStatusEnum.Paid, paidAt: new Date(), description: dto.description?.trim() || null, }); em.persist(payment); await em.flush(); return em.findOneOrFail( Order, { id: orderId }, { populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] }, ); }); } async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise { return this.em.transactional(async em => { const order = await em.findOne( Order, { id: orderId, restaurant: { id: restId } }, { populate: ['payments', 'user', 'restaurant', 'paymentMethod'] }, ); if (!order) { throw new NotFoundException(OrderMessage.NOT_FOUND); } const paidAmount = Number(order.paidAmount ?? 0); const balance = Number(order.balance ?? 0); const isCanceled = order.status === OrderStatus.CANCELED; const canRefund = balance < 0 || (isCanceled && paidAmount > 0); if (!canRefund) { throw new BadRequestException(OrderMessage.REFUND_NOT_ALLOWED); } const amount = Number(dto.amount); if (!amount || amount <= 0) { throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO); } if (amount > paidAmount) { throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_PAID); } if (!isCanceled && balance < 0 && amount > Math.abs(balance)) { throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_OVERPAYMENT); } const paidPayments = order.payments .getItems() .filter(p => p.status === PaymentStatusEnum.Paid && Number(p.amount) > 0) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); if (paidPayments.length === 0) { throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND); } let remaining = amount; let walletRefundAmount = 0; for (const payment of paidPayments) { if (remaining <= 0) break; const paymentAmount = Number(payment.amount); const refundFromPayment = Math.min(paymentAmount, remaining); if (refundFromPayment === paymentAmount) { payment.status = PaymentStatusEnum.Refunded; if (dto.description) { payment.description = dto.description; } } else { payment.amount = paymentAmount - refundFromPayment; const refundPayment = em.create(Payment, { order, amount: refundFromPayment, method: payment.method, gateway: payment.gateway ?? null, status: PaymentStatusEnum.Refunded, description: dto.description ?? null, referenceId: payment.referenceId ?? null, transactionId: payment.transactionId ?? null, }); em.persist(refundPayment); } if (payment.method === PaymentMethodEnum.Wallet) { walletRefundAmount += refundFromPayment; } remaining -= refundFromPayment; em.persist(payment); } if (remaining > 0) { throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND); } if (walletRefundAmount > 0) { if (!order.user) { throw new NotFoundException(OrderMessage.USER_NOT_FOUND); } const latestWalletTx = await em.findOne( WalletTransaction, { user: { id: order.user.id }, restaurant: { id: order.restaurant.id }, }, { orderBy: { createdAt: 'DESC' } }, ); if (!latestWalletTx) { throw new NotFoundException(PaymentMessage.REFUND_WALLET_NOT_FOUND); } const newBalance = Number(latestWalletTx.balance ?? 0) + walletRefundAmount; const creditTx = em.create(WalletTransaction, { user: order.user, restaurant: order.restaurant, amount: walletRefundAmount, type: WalletTransactionType.CREDIT, reason: WalletTransactionReason.ORDER_REFUND, balance: newBalance, }); em.persist(creditTx); } await em.flush(); return em.findOneOrFail( Order, { id: orderId }, { populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] }, ); }); } /* 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 NEW are allowed to be canceled by user if (ref === 'user' && ![OrderStatus.NEW].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.NEW && to == OrderStatus.PREPARING && paymentMethod !== PaymentMethodEnum.Cash && paymentMethod !== PaymentMethodEnum.CreditCard ) { 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; } return true; } private async getOrderOrFail(orderId: string, restId: string): Promise { const order = await this.em.findOne( Order, { id: orderId, restaurant: { id: restId } }, { populate: ['paymentMethod', 'deliveryMethod', 'user', 'restaurant'] }, ); if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND); return order; } async sendPleaseComeSms(orderId: string, restId: string): Promise { const order = await this.getOrderOrFail(orderId, restId); const user = order.user; if (!user) { throw new BadRequestException(OrderMessage.PLEASE_COME_USER_MISSING); } if (!user.phone) { throw new BadRequestException(OrderMessage.PLEASE_COME_PHONE_MISSING); } const templateId = this.configService.get('SMS_PATTERN_PLEASE_COME'); if (!templateId) { throw new BadRequestException(OrderMessage.PLEASE_COME_SMS_PATTERN_MISSING); } this.logger.log( `Queueing please-come SMS for order ${orderId} to user ${user.id} (${user.phone})`, ); await this.smsQueueService.enqueue({ phone: user.phone, templateId, restaurantId: restId, quantity: 1, params: { name: user.firstName ?? '', rest: order.restaurant?.name ?? '', }, }); } 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 resolveUserAddressForOrder(user: User, addressId: string): Promise { const address = await this.em.findOne(UserAddress, { id: addressId, user: { id: user.id }, }); if (!address) { throw new NotFoundException(`Address with ID ${addressId} not found for user ${user.id}.`); } return { fullName: [user.firstName, user.lastName].filter(Boolean).join(' '), phone: address.phone || user.phone, address: address.address, postalCode: address.postalCode ?? undefined, latitude: address.latitude, longitude: address.longitude, }; } private async getRestaurantOrFail(restaurantId: string): Promise { const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); if (!restaurant) throw new NotFoundException(OrderMessage.RESTAURANT_NOT_FOUND); return restaurant; } 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 buildCreditCardPaymentFields( paymentMethod: PaymentMethod, paymentDesc?: string | null, attachments?: string[] | null, ): Pick { return { ...(attachments?.length ? { attachments } : {}), ...(paymentDesc?.trim() ? { description: paymentDesc.trim() } : {}), }; } private assertCreditCardPaymentDetails( paymentMethod: PaymentMethod, paymentDesc?: string | null, attachments?: string[] | null, ): void { if (paymentMethod.method !== PaymentMethodEnum.CreditCard) { return; } const hasDescription = Boolean(paymentDesc?.trim()); const hasAttachments = Boolean(attachments?.length); if (!hasDescription && !hasAttachments) { throw new BadRequestException(OrderMessage.CREDIT_CARD_PAYMENT_DETAILS_REQUIRED); } } 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, 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(OrderMessage.PAYMENT_METHOD_NOT_ENABLED); } } private async buildOrderItemsData(cart: Cart, restaurantId: string): Promise { 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(OrderMessage.FOOD_NOT_FOUND); if (food.restaurant.id !== restaurantId) { throw new BadRequestException(OrderMessage.FOOD_NOT_BELONGS_TO_RESTAURANT); } 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(OrderMessage.FOOD_NOT_FOUND); } } private async buildOrderItemsDataFromDto( items: { foodId: string; quantity: number }[], restaurantId: string, ): Promise { const orderItemsData: OrderItemData[] = []; for (const item of items) { const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant', 'inventory'] }); if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND); if (food.restaurant.id !== restaurantId) { throw new BadRequestException(OrderMessage.FOOD_NOT_BELONGS_TO_RESTAURANT); } this.assertFoodHasSufficientStock(food, item.quantity); orderItemsData.push({ food, quantity: item.quantity, unitPrice: food.price || 0, discount: food.discount || 0, }); } return orderItemsData; } private assertAdminDeliveryRequirements(dto: AdminCreateOrderDto, delivery: Delivery) { if (delivery.method === DeliveryMethodEnum.DineIn) { if (!dto.tableNumber || dto.tableNumber.trim() === '') { throw new BadRequestException(OrderMessage.TABLE_NUMBER_REQUIRED); } } if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !dto.addressId) { throw new BadRequestException(OrderMessage.ADDRESS_REQUIRED); } if (delivery.method === DeliveryMethodEnum.DeliveryCar && !dto.carAddress) { throw new BadRequestException(OrderMessage.CAR_ADDRESS_REQUIRED); } if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !dto.userId) { throw new BadRequestException(OrderMessage.USER_REQUIRED); } } async getStats(restId: string) { return this.em.transactional(async em => { // 1. Total orders count (excluding pending and canceled) const totalOrders = await em.count(Order, { restaurant: { id: restId }, status: { $in: [ OrderStatus.PREPARING, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.SHIPPED, OrderStatus.COMPLETED, ], }, }); // 2. Active foods count const activeFoods = await em.count(Food, { restaurant: { id: restId }, isActive: true, }); // 3. Total clients count (users linked to this restaurant) const totalClients = await em.count(UserRestaurant, { restaurant: { id: restId }, }); // 4. Total revenue (sum of paid payments for orders of this restaurant) 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( `Food 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 restaurant ${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 foods 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 LIMIT 5 `, [restId, startDate, endDate], ); this.logger.debug(`Food 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, }; }); } async getFoodOrderReport(restId: string, dto: FoodOrderReportDto) { const { from, to, sortBy = FoodOrderReportSortBy.Count } = dto; const params: unknown[] = [restId]; const dateFilters: string[] = []; if (from) { params.push(new Date(from)); dateFilters.push(`AND o.created_at >= ?`); } if (to) { params.push(new Date(to)); dateFilters.push(`AND o.created_at <= ?`); } const orderColumn = sortBy === FoodOrderReportSortBy.Price ? 'f.price' : 'count'; const result = await this.em.execute( ` SELECT f.id as food_id, f.title as food_title, f.price as food_price, COALESCE(SUM(oi.quantity), 0)::int as count, COALESCE(SUM(oi.total_price), 0)::numeric as total_price FROM order_items oi INNER JOIN orders o ON oi.order_id = o.id INNER JOIN foods f ON oi.food_id = f.id WHERE o.restaurant_id = ? ${dateFilters.join('\n ')} AND o.status NOT IN ('pendingPayment', 'canceled') GROUP BY f.id, f.title, f.price HAVING SUM(oi.quantity) > 0 ORDER BY ${orderColumn} DESC `, params, ); return result.map((row: Record) => ({ food: { id: row.food_id, title: row.food_title, }, count: Number(row.count || 0), price: Number(row.food_price || 0), totalPrice: Number(row.total_price || 0), })); } async getDailyOrderReport(restId: string, dto: DailyOrderReportDto) { const { from, to, sortBy = DailyOrderReportSortBy.Date } = dto; const params: unknown[] = [restId]; const dateFilters: string[] = []; if (from) { params.push(new Date(from)); dateFilters.push(`AND o.created_at >= ?`); } if (to) { params.push(new Date(to)); dateFilters.push(`AND o.created_at <= ?`); } const orderColumn = { [DailyOrderReportSortBy.Date]: `DATE_TRUNC('day', CAST(o.created_at AS timestamp))`, [DailyOrderReportSortBy.TotalAmount]: 'total_amount', [DailyOrderReportSortBy.OrderCount]: 'order_count', }[sortBy]; const result = await this.em.execute( ` SELECT TO_CHAR(DATE_TRUNC('day', CAST(o.created_at AS timestamp)), 'YYYY-MM-DD') as "date", COUNT(o.id)::int as order_count, COALESCE(SUM(o.total), 0)::numeric as total_amount FROM orders o WHERE o.restaurant_id = ? ${dateFilters.join('\n ')} AND o.status NOT IN ('pendingPayment', 'canceled') GROUP BY DATE_TRUNC('day', CAST(o.created_at AS timestamp)) ORDER BY ${orderColumn} DESC `, params, ); return result.map((row: Record) => ({ date: row.date, orderCount: Number(row.order_count || 0), totalAmount: Number(row.total_amount || 0), })); } }