user module

This commit is contained in:
2026-01-13 19:59:06 +03:30
parent 6522acff5f
commit d630cb844a
62 changed files with 1137 additions and 3040 deletions
@@ -3,11 +3,9 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } fr
import { OrdersService } from '../providers/orders.service';
import { AuthGuard } from '../../auth/guards/auth.guard';
import { UserId } from '../../../common/decorators/user-id.decorator';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { FindOrdersDto } from '../dto/find-orders.dto';
import { OrderStatus } from '../interface/order.interface';
import { API_HEADER_SLUG } from 'src/common/constants/index';
import { UpdateOrderStatusDto } from '../dto/update-order-status.dto';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
@@ -18,103 +16,103 @@ import { Permission } from 'src/common/enums/permission.enum';
export class OrdersController {
constructor(private readonly ordersService: OrdersService) { }
@UseGuards(AuthGuard)
@Post('public/checkout')
// @UseGuards(AuthGuard)
// @Post('public/checkout')
@ApiOperation({ summary: 'Checkout : create order and payment record' })
checkout(@UserId() userId: string, @RestId() restaurantId: string) {
return this.ordersService.checkout(userId, restaurantId);
}
// @ApiOperation({ summary: 'Checkout : create order and payment record' })
// checkout(@UserId() userId: string,) {
// return this.ordersService.checkout(userId);
// }
@UseGuards(AuthGuard)
@Get('public/orders')
// @UseGuards(AuthGuard)
// @Get('public/orders')
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
findAll(, @Query() dto: FindOrdersDto, @UserId() userId: string) {
return this.ordersService.findAllForUser(restId, dto, userId);
}
// @ApiOperation({ summary: 'Get all orders with pagination and filters' })
// findAll(@Query() dto: FindOrdersDto, @UserId() userId: string) {
// return this.ordersService.findAllForUser(dto, userId);
// }
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Get an order By id for User' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
// @UseGuards(AuthGuard)
// @ApiOperation({ summary: 'Get an order By id for User' })
// @ApiParam({ name: 'orderId', description: 'Order ID' })
@Get('public/orders/:orderId')
findOne(@Param('orderId') orderId: string,) {
return this.ordersService.findOne(orderId, restId);
}
// @Get('public/orders/:orderId')
// findOne(@Param('orderId') orderId: string,) {
// return this.ordersService.findOne(orderId,);
// }
@UseGuards(AuthGuard)
@Patch('public/orders/:id/:status')
@ApiParam({
name: 'status',
description: 'Order status',
enum: OrderStatus,
})
// @UseGuards(AuthGuard)
// @Patch('public/orders/:id/:status')
// @ApiParam({
// name: 'status',
// description: 'Order status',
// enum: OrderStatus,
// })
@ApiBody({ type: UpdateOrderStatusDto })
@ApiOperation({ summary: 'Update status of an order By User' })
@ApiParam({ name: 'id', description: 'Order ID' })
cancelOrder(
@Body() dto: UpdateOrderStatusDto,
@Param('id') orderId: string,
@Param('status') status: OrderStatus,
,
) {
return this.ordersService.changeOrderStatus(orderId, restId, status, 'user', dto?.desc);
}
// @ApiBody({ type: UpdateOrderStatusDto })
// @ApiOperation({ summary: 'Update status of an order By User' })
// @ApiParam({ name: 'id', description: 'Order ID' })
// cancelOrder(
// @Body() dto: UpdateOrderStatusDto,
// @Param('id') orderId: string,
// @Param('status') status: OrderStatus,
// ,
// ) {
// return this.ordersService.changeOrderStatus(orderId, , status, 'user', dto?.desc);
// }
/******************** Admin Routes **********************/
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Get('admin/orders')
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
findAllAdmin(, @Query() dto: FindOrdersDto) {
return this.ordersService.findAllForAdmin(restId, dto);
}
// /******************** Admin Routes **********************/
// @UseGuards(AdminAuthGuard)
// @Permissions(Permission.MANAGE_ORDERS)
// @Get('admin/orders')
// @ApiOperation({ summary: 'Get all orders with pagination and filters' })
// findAllAdmin(, @Query() dto: FindOrdersDto) {
// return this.ordersService.findAllForAdmin(, dto);
// }
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@ApiOperation({ summary: 'Get an order By id for User' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@Get('admin/orders/:orderId')
findOneAsAdmin(@Param('orderId') orderId: string,) {
return this.ordersService.findOne(orderId, restId);
}
// @UseGuards(AdminAuthGuard)
// @Permissions(Permission.MANAGE_ORDERS)
// @ApiOperation({ summary: 'Get an order By id for User' })
// @ApiParam({ name: 'orderId', description: 'Order ID' })
// @Get('admin/orders/:orderId')
// findOneAsAdmin(@Param('orderId') orderId: string,) {
// return this.ordersService.findOne(orderId,);
// }
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/:status')
@ApiOperation({ summary: 'Update an order status' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: UpdateOrderStatusDto })
@ApiParam({
name: 'status',
description: 'Order status',
enum: OrderStatus,
})
updateStatus(
@Param('orderId') orderId: string,
@Body() dto: UpdateOrderStatusDto,
@Param('status') status: OrderStatus,
,
) {
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
}
// @UseGuards(AdminAuthGuard)
// @Permissions(Permission.MANAGE_ORDERS)
// @Patch('admin/orders/:orderId/:status')
// @ApiOperation({ summary: 'Update an order status' })
// @ApiParam({ name: 'orderId', description: 'Order ID' })
// @ApiBody({ type: UpdateOrderStatusDto })
// @ApiParam({
// name: 'status',
// description: 'Order status',
// enum: OrderStatus,
// })
// updateStatus(
// @Param('orderId') orderId: string,
// @Body() dto: UpdateOrderStatusDto,
// @Param('status') status: OrderStatus,
// ,
// ) {
// return this.ordersService.changeOrderStatus(orderId, , status, 'admin', dto?.desc);
// }
@UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS)
@ApiOperation({ summary: 'Get Stats for report page' })
@Get('admin/orders/stats')
findStats() {
return this.ordersService.getStats(restId);
}
// @UseGuards(AdminAuthGuard)
// @Permissions(Permission.VIEW_REPORTS)
// @ApiOperation({ summary: 'Get Stats for report page' })
// @Get('admin/orders/stats')
// findStats() {
// return this.ordersService.getStats();
// }
@UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS)
@ApiOperation({ summary: 'Get product sales pie chart data for last month' })
// @UseGuards(AdminAuthGuard)
// @Permissions(Permission.VIEW_REPORTS)
// @ApiOperation({ summary: 'Get product sales pie chart data for last month' })
@Get('admin/orders/product-sales-pie-chart')
getproductSalesPieChart() {
return this.ordersService.getproductSalesPieChart(restId);
}
// @Get('admin/orders/product-sales-pie-chart')
// getproductSalesPieChart() {
// return this.ordersService.getproductSalesPieChart();
// }
}
+5 -5
View File
@@ -144,10 +144,10 @@ export class OrdersCrone {
}
const previousStatus = reloadedOrder.status;
const restaurantId =
typeof reloadedOrder.restaurant === 'string'
? reloadedOrder.restaurant
: reloadedOrder.restaurant.id;
const =
typeof reloadedOrder.restaurant === 'string'
? reloadedOrder.restaurant
: reloadedOrder.restaurant.id;
// Update order status and history
reloadedOrder.status = OrderStatus.COMPLETED;
@@ -167,7 +167,7 @@ export class OrdersCrone {
reloadedOrder.id,
reloadedOrder.user?.id || '',
String(reloadedOrder.orderNumber) || '',
restaurantId,
,
previousStatus,
OrderStatus.COMPLETED,
'admin',
+4 -4
View File
@@ -3,10 +3,10 @@ import type { OrderStatus, StatusTransitionRef } from '../interface/order.interf
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly restaurantId: string,
public readonly: string,
public readonly orderNumber: string,
public readonly total: number,
) {}
) { }
}
export class OrderStatusChangedEvent {
@@ -14,9 +14,9 @@ export class OrderStatusChangedEvent {
public readonly orderId: string,
public readonly userId: string,
public readonly orderNumber: string,
public readonly restaurantId: string,
public readonly: string,
public readonly previousStatus: OrderStatus,
public readonly newStatus: OrderStatus,
public readonly changedBy: StatusTransitionRef,
) {}
) { }
}
+14 -14
View File
@@ -48,7 +48,7 @@ export class OrderListeners {
async handleOrderCreated(event: OrderCreatedEvent) {
try {
this.logger.log(
`Order created event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
`Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
);
const order = await this.OrderRepository.findOne(event.orderId);
@@ -58,13 +58,13 @@ export class OrderListeners {
// get admnin os restuaraant that have order permissuins
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
const recipients = admins.map(admin => ({
adminId: admin.id,
}));
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
: event.,
message: {
title: NotifTitleEnum.ORDER_CREATED,
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
@@ -92,7 +92,7 @@ export class OrderListeners {
});
} catch (error) {
this.logger.error(
`Failed to send notification for order created event: ${event.restaurantId}`,
`Failed to send notification for order created event: ${event.}`,
error instanceof Error ? error.stack : String(error),
);
}
@@ -102,7 +102,7 @@ export class OrderListeners {
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
try {
this.logger.log(
`Order status changed event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
`Order status changed event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
);
//TODO : REFACTOR to use queue or other way to handle this
const recipients = [
@@ -114,21 +114,21 @@ export class OrderListeners {
if (!event?.userId) {
this.logger.log(
`User not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
`User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
);
}
// const restaurant = await this.RestaurantRepository.findOne(event.restaurantId);
// const restaurant = await this.RestaurantRepository.findOne(event.);
// if (!restaurant) {
// this.logger.log(
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
// );
// return;
// }
// const score = restaurant.score;
// if (!score) {
// this.logger.log(
// `Score not found for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
// `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`,
// );
// return;
// }
@@ -137,18 +137,18 @@ export class OrderListeners {
// const order = await this.OrderRepository.findOne(event.orderId);
// if (!order) {
// this.logger.log(
// `Order not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
// `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
// );
// return;
// }
// this.userService.createWalletTransaction(event.userId, event.restaurantId, {
// this.userService.createWalletTransaction(event.userId, event., {
// amount: order.subTotal,
// type: WalletTransactionType.CREDIT,
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
// });
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
: event.,
message: {
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
@@ -175,7 +175,7 @@ export class OrderListeners {
});
} else {
await this.notificationService.sendNotification({
restaurantId: event.restaurantId,
: event.,
message: {
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
@@ -204,7 +204,7 @@ export class OrderListeners {
}
} catch (error) {
this.logger.error(
`Failed to send notification for order status changed event: ${event.restaurantId}`,
`Failed to send notification for order status changed event: ${event.}`,
error instanceof Error ? error.stack : String(error),
);
}
+9 -572
View File
@@ -3,599 +3,36 @@ import { EntityManager } from '@mikro-orm/postgresql';
import { Order } from '../entities/order.entity';
import { OrderItem } from '../entities/order-item.entity';
import { User } from '../../user/entities/user.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.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 '../../payment/interface/payment';
import { Cart } from '../../cart/interfaces/cart.interface';
import { PaymentMethod } from '../../payment/entities/payment-method.entity';
import { PaymentsService } from '../../payment/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/payment/entities/payment.entity';
import { InventoryService } from 'src/modules/inventory/inventory.service';
import { BulkReserveproductDto } from 'src/modules/inventory/dto/bulk-reserve-product.dto';
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;
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.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 inventoryService: InventoryService,
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,
restaurant: validated.restaurant,
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.assertproductHasSufficientStock(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);
// reserve stock based on payment method.
const bulkReserveproductDto: BulkReserveproductDto = {
items: validated.orderItemsData.map(item => ({
productId: item.product.id,
quantity: item.quantity,
})),
};
await this.inventoryService.deductFromInventory(em, bulkReserveproductDto);
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),
);
return { paymentUrl, 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);
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
this.assertPaymentMethodEnabled(paymentMethod);
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,
});
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',
'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<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;
}
/* 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<Order> {
const order = await this.em.findOne(
Order,
{ id: orderId, restaurant: { 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<User> {
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<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 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 product = await this.em.findOne(product, { id: cartItem.productId }, { populate: ['restaurant', 'inventory'] });
if (!product) throw new NotFoundException(OrderMessage.product_NOT_FOUND);
if (product.restaurant.id !== restaurantId) {
throw new BadRequestException(OrderMessage.product_NOT_BELONGS_TO_RESTAURANT);
}
this.assertproductHasSufficientStock(product, cartItem.quantity);
orderItemsData.push({
product,
quantity: cartItem.quantity,
unitPrice: product.price || 0,
discount: product.discount || 0,
});
}
return orderItemsData;
}
private assertproductHasSufficientStock(product: product, quantity: number) {
const availableStock = product.inventory?.availableStock ?? 0;
if (availableStock < quantity) {
throw new BadRequestException(OrderMessage.product_NOT_FOUND);
}
}
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.PAID,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
OrderStatus.SHIPPED,
OrderStatus.COMPLETED,
],
},
});
// 2. Active products count
const activeproducts = await em.count(product, {
restaurant: { id: restId },
isActive: true,
});
// 3. Total clients count (distinct users with orders for this restaurant)
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 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,
activeproducts,
totalClients,
totalRevenue,
};
});
}
async getproductSalesPieChart(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 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 product_id,
f.title as product_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.product_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) => ({
productId: item.product_id,
productTitle: item.product_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,
};
});
}
}
@@ -31,7 +31,7 @@ export class OrderRepository extends EntityRepository<Order> {
* Find orders with pagination and optional filters.
* Supports: statuses, paymentStatus, search (orderNumber), date range, ordering.
*/
async findAllPaginated(restId: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
async findAllPaginated(: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
const {
page = 1,
limit = 10,
@@ -47,7 +47,7 @@ export class OrderRepository extends EntityRepository<Order> {
const offset = (page - 1) * limit;
const where: FilterQuery<Order> = { restaurant: { id: restId } };
const where: FilterQuery<Order> = { restaurant: { id: } };
// Filter by statuses
if (statuses) {