1290 lines
43 KiB
TypeScript
1290 lines
43 KiB
TypeScript
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, readonly OrderStatus[]> = {
|
|
[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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<ValidatedCartForOrder> {
|
|
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<PaginatedResult<Order>> {
|
|
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<PaginatedResult<Order>> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<void> {
|
|
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<string>('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<User> {
|
|
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<OrderUserAddress> {
|
|
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<Restaurant> {
|
|
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<Delivery> {
|
|
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<Payment, 'description' | 'attachments'> {
|
|
|
|
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<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(OrderMessage.PAYMENT_METHOD_NOT_ENABLED);
|
|
}
|
|
}
|
|
|
|
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(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<OrderItemData[]> {
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
date: row.date,
|
|
orderCount: Number(row.order_count || 0),
|
|
totalAmount: Number(row.total_amount || 0),
|
|
}));
|
|
}
|
|
}
|