remove unused modules
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger';
|
||||
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';
|
||||
|
||||
@ApiTags('orders')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class OrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('public/checkout')
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiOperation({ summary: 'Checkout : create order and payment record' })
|
||||
checkout(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||
return this.ordersService.checkout(userId, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('public/orders')
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||
findAll(@RestId() restId: string, @Query() dto: FindOrdersDto, @UserId() userId: string) {
|
||||
return this.ordersService.findAllForUser(restId, dto, userId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@Get('public/orders/:orderId')
|
||||
findOne(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||
return this.ordersService.findOne(orderId, restId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Patch('public/orders/:id/:status')
|
||||
@ApiParam({
|
||||
name: 'status',
|
||||
description: 'Order status',
|
||||
enum: OrderStatus,
|
||||
})
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@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,
|
||||
@RestId() restId: string,
|
||||
) {
|
||||
return this.ordersService.changeOrderStatus(orderId, restId, 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(@RestId() restId: string, @Query() dto: FindOrdersDto) {
|
||||
return this.ordersService.findAllForAdmin(restId, 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, @RestId() restId: string) {
|
||||
return this.ordersService.findOne(orderId, restId);
|
||||
}
|
||||
|
||||
@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,
|
||||
@RestId() restId: string,
|
||||
) {
|
||||
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@ApiOperation({ summary: 'Get Stats for report page' })
|
||||
@Get('admin/orders/stats')
|
||||
findStats(@RestId() restId: string) {
|
||||
return this.ordersService.getStats(restId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@ApiOperation({ summary: 'Get product sales pie chart data for last month' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@Get('admin/orders/product-sales-pie-chart')
|
||||
getproductSalesPieChart(@RestId() restId: string) {
|
||||
return this.ordersService.getproductSalesPieChart(restId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Payment } from '../../payment/entities/payment.entity';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
|
||||
import { InventoryService } from '../../inventory/inventory.service';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { OrderStatusChangedEvent } from '../events/order.events';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersCrone {
|
||||
private readonly logger = new Logger(OrdersCrone.name);
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
// run every minute and fail pending online payments older than 15 minutes
|
||||
@Cron('*/1 * * * *', {
|
||||
name: 'failOldOnlinePayments',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
async cancelOldOnlinePendingOrders() {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
|
||||
|
||||
this.logger.debug('Searching for pending online payments older than 15 minutes');
|
||||
|
||||
const payments = await this.em.find(
|
||||
Payment,
|
||||
{
|
||||
method: PaymentMethodEnum.Online,
|
||||
status: PaymentStatusEnum.Pending,
|
||||
createdAt: { $lte: cutoff },
|
||||
},
|
||||
{ populate: ['order', 'order.items', 'order.items.product'] },
|
||||
);
|
||||
|
||||
if (!payments || payments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${payments.length} stale pending online payments`);
|
||||
|
||||
for (const p of payments) {
|
||||
try {
|
||||
await this.em.transactional(async em => {
|
||||
// reload inside transaction to avoid concurrency issues
|
||||
const payment = await em.findOne(
|
||||
Payment,
|
||||
{ id: p.id },
|
||||
{ populate: ['order', 'order.items', 'order.items.product'] },
|
||||
);
|
||||
if (!payment) return;
|
||||
if (payment.status !== PaymentStatusEnum.Pending) return;
|
||||
|
||||
payment.status = PaymentStatusEnum.Failed;
|
||||
payment.failedAt = new Date();
|
||||
|
||||
if (payment.order) {
|
||||
payment.order.status = OrderStatus.CANCELED;
|
||||
|
||||
// prepare restore payload
|
||||
const items = (payment.order as any).items || [];
|
||||
const restorePayload = {
|
||||
items: items.map((it: any) => ({ productId: it.product.id, quantity: it.quantity })),
|
||||
};
|
||||
|
||||
if (restorePayload.items.length > 0) {
|
||||
await this.inventoryService.restoreToInventory(em, restorePayload);
|
||||
}
|
||||
}
|
||||
|
||||
em.persist(payment);
|
||||
if (payment.order) em.persist(payment.order);
|
||||
await em.flush();
|
||||
this.logger.log(
|
||||
`Marked payment ${payment.id} and order ${payment.order?.id} as failed and restored inventory`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Error processing payment ${p.id}: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`OrdersCrone failed: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// run every 15 minutes to complete orders that have been in shipped/delivered statuses for more than 3 hours
|
||||
@Cron('*/15 * * * *', {
|
||||
name: 'completeOldDeliveredOrders',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
async completeOldDeliveredOrders() {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 hours ago
|
||||
|
||||
this.logger.debug('Searching for orders in shipped/delivered statuses older than 3 hours');
|
||||
|
||||
const orders = await this.em.find(
|
||||
Order,
|
||||
{
|
||||
status: {
|
||||
$in: [
|
||||
OrderStatus.SHIPPED,
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
],
|
||||
},
|
||||
updatedAt: { $lte: cutoff },
|
||||
},
|
||||
{ populate: ['restaurant'] },
|
||||
);
|
||||
|
||||
if (!orders || orders.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${orders.length} orders to mark as completed`);
|
||||
|
||||
for (const order of orders) {
|
||||
try {
|
||||
await this.em.transactional(async em => {
|
||||
// reload inside transaction to avoid concurrency issues
|
||||
const reloadedOrder = await em.findOne(
|
||||
Order,
|
||||
{ id: order.id },
|
||||
{ populate: ['restaurant', 'user'] },
|
||||
);
|
||||
if (!reloadedOrder) return;
|
||||
if (
|
||||
![
|
||||
OrderStatus.SHIPPED,
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
].includes(reloadedOrder.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = reloadedOrder.status;
|
||||
const restaurantId =
|
||||
typeof reloadedOrder.restaurant === 'string'
|
||||
? reloadedOrder.restaurant
|
||||
: reloadedOrder.restaurant.id;
|
||||
|
||||
// Update order status and history
|
||||
reloadedOrder.status = OrderStatus.COMPLETED;
|
||||
reloadedOrder.history.push({
|
||||
status: OrderStatus.COMPLETED,
|
||||
changedAt: new Date(),
|
||||
desc: 'تکمیل سفارش توسط سیستم',
|
||||
});
|
||||
|
||||
em.persist(reloadedOrder);
|
||||
await em.flush();
|
||||
|
||||
// // Emit event after transaction completes
|
||||
this.eventEmitter.emit(
|
||||
OrderStatusChangedEvent.name,
|
||||
new OrderStatusChangedEvent(
|
||||
reloadedOrder.id,
|
||||
reloadedOrder.user?.id || '',
|
||||
String(reloadedOrder.orderNumber) || '',
|
||||
restaurantId,
|
||||
previousStatus,
|
||||
OrderStatus.COMPLETED,
|
||||
'admin',
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Marked order ${reloadedOrder.id} as completed`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Error processing order ${order.id}: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`completeOldDeliveredOrders cron failed: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsIn,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentStatusEnum } from '../../payment/interface/payment';
|
||||
|
||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||
type SortOrder = (typeof sortOrderOptions)[number];
|
||||
|
||||
export class FindOrdersDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit: number = 10;
|
||||
|
||||
/**
|
||||
* ?statuses=paid,confirmed
|
||||
* ?statuses=paid&statuses=confirmed
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by order statuses',
|
||||
enum: OrderStatus,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value : value?.split(',')
|
||||
)
|
||||
@IsArray()
|
||||
@IsEnum(OrderStatus, { each: true })
|
||||
statuses?: OrderStatus[];
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentStatusEnum })
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentStatusEnum)
|
||||
paymentStatus?: PaymentStatusEnum;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: sortOrderOptions,
|
||||
default: 'desc',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(sortOrderOptions)
|
||||
order: SortOrder = 'desc';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
export class UpdateOrderStatusDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Change Status description',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
desc?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// import { PartialType } from '@nestjs/swagger';
|
||||
// import { CreateOrderDto } from './create-order.dto';
|
||||
|
||||
// export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
|
||||
export class UpdateOrderDto {}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Order } from './order.entity';
|
||||
import { product } from '../../products/entities/product.entity';
|
||||
|
||||
@Entity({ tableName: 'order_items' })
|
||||
@Index({ properties: ['order'] })
|
||||
@Index({ properties: ['product'] })
|
||||
export class OrderItem extends BaseEntity {
|
||||
@ManyToOne(() => Order)
|
||||
order!: Order;
|
||||
|
||||
@ManyToOne(() => product)
|
||||
product!: product;
|
||||
|
||||
@Property({ type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
unitPrice!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
discount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
totalPrice!: number;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
Property,
|
||||
Collection,
|
||||
Cascade,
|
||||
Enum,
|
||||
BeforeCreate,
|
||||
Unique,
|
||||
type EventArgs,
|
||||
} from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { OrderCouponDetail, OrderStatus } from '../interface/order.interface';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { PaymentMethod } from '../../payment/entities/payment-method.entity';
|
||||
import { OrderItem } from './order-item.entity';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||
import { Payment } from 'src/modules/payment/entities/payment.entity';
|
||||
|
||||
@Entity({ tableName: 'orders' })
|
||||
@Unique({ properties: ['restaurant', 'orderNumber'] })
|
||||
@Index({ properties: ['restaurant', 'status'] })
|
||||
@Index({ properties: ['user', 'status'] })
|
||||
@Index({ properties: ['restaurant', 'orderNumber'] })
|
||||
@Index({ properties: ['status'] })
|
||||
export class Order extends BaseEntity {
|
||||
@ManyToOne(() => User)
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@ManyToOne(() => Delivery)
|
||||
deliveryMethod!: Delivery;
|
||||
|
||||
@OneToMany(() => Payment, payment => payment.order, {
|
||||
cascade: [Cascade.ALL],
|
||||
orphanRemoval: true,
|
||||
})
|
||||
payments = new Collection<Payment>(this);
|
||||
|
||||
@OneToMany(() => OrderItem, item => item.order, {
|
||||
cascade: [Cascade.ALL],
|
||||
orphanRemoval: true,
|
||||
})
|
||||
items = new Collection<OrderItem>(this);
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
userAddress?: OrderUserAddress | null;
|
||||
// for car delivery
|
||||
@Property({ type: 'json', nullable: true })
|
||||
carAddress?: OrderCarAddress | null;
|
||||
|
||||
@ManyToOne(() => PaymentMethod)
|
||||
paymentMethod: PaymentMethod;
|
||||
|
||||
@Property({ type: 'int', nullable: true })
|
||||
orderNumber?: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
couponDiscount: number = 0;
|
||||
|
||||
@Property({ type: 'jsonb', nullable: true })
|
||||
couponDetail?: OrderCouponDetail | null;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
itemsDiscount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
totalDiscount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
subTotal!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
tax: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
deliveryFee: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
total!: number;
|
||||
|
||||
@Property({ type: 'int', default: 0 })
|
||||
totalItems: number = 0;
|
||||
|
||||
@Property({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
tableNumber?: string;
|
||||
|
||||
@Enum(() => OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
history: Array<{ status: OrderStatus; changedAt: Date; desc: string | null }> = [];
|
||||
|
||||
@BeforeCreate()
|
||||
async generateOrderNumber(args: EventArgs<Order>) {
|
||||
const em = args.em;
|
||||
const order = args.entity;
|
||||
|
||||
// Ensure restaurant is loaded
|
||||
if (!order.restaurant) {
|
||||
throw new Error('Restaurant must be set before creating order');
|
||||
}
|
||||
|
||||
// Get the restaurant ID (handle both entity and ID cases)
|
||||
const restaurantId = typeof order.restaurant === 'string' ? order.restaurant : order.restaurant.id;
|
||||
|
||||
// Query for max orderNumber for this restaurant
|
||||
// Using findOne with orderBy to get the highest order number
|
||||
const maxOrder = await em.findOne(
|
||||
Order,
|
||||
{ restaurant: restaurantId },
|
||||
{
|
||||
orderBy: { orderNumber: 'DESC' },
|
||||
fields: ['orderNumber'],
|
||||
},
|
||||
);
|
||||
|
||||
// Set the next order number (1 if no orders exist, otherwise max + 1)
|
||||
order.orderNumber = maxOrder?.orderNumber ? maxOrder.orderNumber + 1 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { OrderStatus, StatusTransitionRef } from '../interface/order.interface';
|
||||
|
||||
export class OrderCreatedEvent {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly orderNumber: string,
|
||||
public readonly total: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class OrderStatusChangedEvent {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly userId: string,
|
||||
public readonly orderNumber: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly previousStatus: OrderStatus,
|
||||
public readonly newStatus: OrderStatus,
|
||||
public readonly changedBy: StatusTransitionRef,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CouponType } from 'src/modules/coupons/interface/coupon';
|
||||
|
||||
export interface OrderUserAddress {
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode?: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface OrderCarAddress {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export enum OrderStatus {
|
||||
PENDING_PAYMENT = 'pendingPayment',
|
||||
PAID = 'paid',
|
||||
PREPARING = 'preparing',
|
||||
DELIVERED_TO_WAITER = 'deliveredToWaiter',
|
||||
DELIVERED_TO_RECEPTIONIST = 'deliveredToReceptionist',
|
||||
SHIPPED = 'shipped',
|
||||
COMPLETED = 'completed',
|
||||
CANCELED = 'canceled',
|
||||
}
|
||||
|
||||
export interface OrderCouponDetail {
|
||||
couponId: string;
|
||||
couponName: string;
|
||||
couponCode: string;
|
||||
type: CouponType;
|
||||
value: number;
|
||||
|
||||
maxDiscount?: number;
|
||||
}
|
||||
|
||||
export type StatusTransitionRef = 'user' | 'admin';
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { NotifTitleEnum } from 'src/modules/notification/interfaces/notification.interface';
|
||||
import { NotificationService } from 'src/modules/notification/services/notification.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OrderRepository } from '../repositories/order.repository';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum } from 'src/modules/payment/interface/payment';
|
||||
import { UserService } from 'src/modules/user/providers/user.service';
|
||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/user/interface/wallet';
|
||||
|
||||
@Injectable()
|
||||
export class OrderListeners {
|
||||
private readonly logger = new Logger(OrderListeners.name);
|
||||
private orderCreatedSmsTemplateId: string;
|
||||
private orderStatusChangedSmsTemplateId: string;
|
||||
// private orderCompletedSmsTemplateId: string;
|
||||
constructor(
|
||||
private readonly adminService: AdminRepository,
|
||||
private readonly OrderRepository: OrderRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
this.orderCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_CREATED') ?? '123';
|
||||
this.orderStatusChangedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_CHANGE') ?? '123';
|
||||
// this.orderCompletedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_COMPLETED') ?? '123';
|
||||
}
|
||||
|
||||
private getStatusFarsi(status: OrderStatus): string {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
||||
[OrderStatus.PAID]: 'پرداخت شده',
|
||||
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
||||
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
||||
[OrderStatus.SHIPPED]: 'ارسال شده',
|
||||
[OrderStatus.COMPLETED]: 'تکمیل شده',
|
||||
[OrderStatus.CANCELED]: 'لغو شده',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
@OnEvent(OrderCreatedEvent.name)
|
||||
async handleOrderCreated(event: OrderCreatedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Order created event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
|
||||
const order = await this.OrderRepository.findOne(event.orderId);
|
||||
if (order?.paymentMethod.method === PaymentMethodEnum.Online) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// get admnin os restuaraant that have order permissuins
|
||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_CREATED,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `سفارش جدید`,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_CREATED,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order created event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent(OrderStatusChangedEvent.name)
|
||||
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Order status changed event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
//TODO : REFACTOR to use queue or other way to handle this
|
||||
const recipients = [
|
||||
{
|
||||
userId: event.userId,
|
||||
},
|
||||
];
|
||||
if (event.newStatus === OrderStatus.COMPLETED) {
|
||||
|
||||
if (!event?.userId) {
|
||||
this.logger.log(
|
||||
`User not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
// const restaurant = await this.RestaurantRepository.findOne(event.restaurantId);
|
||||
// if (!restaurant) {
|
||||
// this.logger.log(
|
||||
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.restaurantId} 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}`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// increase score for user
|
||||
// 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}`,
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// this.userService.createWalletTransaction(event.userId, event.restaurantId, {
|
||||
// amount: order.subTotal,
|
||||
// type: WalletTransactionType.CREDIT,
|
||||
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
|
||||
// });
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `تغییر وضعیت سفارش`,
|
||||
content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
url: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
sms: {
|
||||
templateId: this.orderStatusChangedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
status: this.getStatusFarsi(event.newStatus),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `تغییر وضعیت سفارش`,
|
||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
url: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order status changed event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { OrdersService } from './providers/orders.service';
|
||||
import { OrdersController } from './controllers/orders.controller';
|
||||
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 { UserAddress } from '../user/entities/user-address.entity';
|
||||
import { PaymentMethod } from '../payment/entities/payment-method.entity';
|
||||
import { CartModule } from '../cart/cart.module';
|
||||
import { UtilsModule } from '../util/utils.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PaymentsModule } from '../payment/payments.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { OrderRepository } from './repositories/order.repository';
|
||||
import { OrderListeners } from './listeners/order.listeners';
|
||||
import { OrdersCrone } from './crone/order.crone';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { NotificationsModule } from '../notification/notifications.module';
|
||||
import { InventoryModule } from '../inventory/inventory.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, product, UserAddress, PaymentMethod]),
|
||||
CartModule,
|
||||
UtilsModule,
|
||||
AuthModule,
|
||||
forwardRef(() => PaymentsModule),
|
||||
JwtModule,
|
||||
AdminModule,
|
||||
NotificationsModule,
|
||||
InventoryModule,
|
||||
forwardRef(() => UserModule)
|
||||
],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone],
|
||||
exports: [OrderRepository, OrdersService],
|
||||
})
|
||||
export class OrdersModule { }
|
||||
@@ -0,0 +1,601 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { OrderItem } from '../entities/order-item.entity';
|
||||
import { User } from '../../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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { FilterQuery } from '@mikro-orm/core';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
|
||||
import { Review } from '../../review/entities/review.entity';
|
||||
|
||||
type FindOrdersOpts = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
statuses?: OrderStatus[];
|
||||
paymentStatus?: PaymentStatusEnum;
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
userId?: string;
|
||||
excludeOnlinePendingPayment?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrderRepository extends EntityRepository<Order> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, 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>> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
statuses,
|
||||
search,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy = 'createdAt',
|
||||
order = 'desc',
|
||||
userId,
|
||||
excludeOnlinePendingPayment = false,
|
||||
} = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<Order> = { restaurant: { id: restId } };
|
||||
|
||||
// Filter by statuses
|
||||
if (statuses) {
|
||||
// Ensure statuses is always an array and normalize it
|
||||
const normalizedStatuses = Array.isArray(statuses) ? statuses : [statuses];
|
||||
// Filter out any empty values
|
||||
const validStatuses = normalizedStatuses.filter((s): s is OrderStatus => !!s);
|
||||
|
||||
if (validStatuses.length > 0) {
|
||||
// Use direct equality for single value, $in for multiple values to avoid SQL generation issues
|
||||
if (validStatuses.length === 1) {
|
||||
where.status = validStatuses[0];
|
||||
} else {
|
||||
where.status = { $in: validStatuses };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
where.user = { id: userId };
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt.$gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.createdAt.$lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Search by order number or user information
|
||||
if (search) {
|
||||
const searchPattern = `%${search}%`;
|
||||
const searchConditions: any[] = [
|
||||
{ user: { firstName: { $ilike: searchPattern } } },
|
||||
{ user: { lastName: { $ilike: searchPattern } } },
|
||||
{ user: { phone: { $ilike: searchPattern } } },
|
||||
];
|
||||
|
||||
// If search is numeric, also search by orderNumber
|
||||
const numericSearch = Number(search);
|
||||
if (!isNaN(numericSearch) && isFinite(numericSearch)) {
|
||||
searchConditions.push({ orderNumber: numericSearch });
|
||||
}
|
||||
|
||||
where.$or = searchConditions;
|
||||
}
|
||||
|
||||
// Filter: Exclude orders with payment method Online and status pending_payment
|
||||
if (excludeOnlinePendingPayment) {
|
||||
const existingConditions = where.$and || [];
|
||||
where.$and = [
|
||||
...existingConditions,
|
||||
{
|
||||
$or: [
|
||||
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
|
||||
{ status: { $ne: OrderStatus.PENDING_PAYMENT } },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// First, fetch orders without reviews
|
||||
const [data, total] = await this.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'items', 'items.product'] as never,
|
||||
});
|
||||
|
||||
// Collect all (orderId, productId) pairs for efficient review lookup
|
||||
const orderproductPairs: Array<{ orderId: string; productId: string }> = [];
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
if (item.product?.id) {
|
||||
orderproductPairs.push({ orderId: order.id, productId: item.product.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all relevant reviews in a single query
|
||||
const reviewsMap: Map<string, string> = new Map();
|
||||
if (orderproductPairs.length > 0) {
|
||||
const orderIds = [...new Set(orderproductPairs.map(p => p.orderId))];
|
||||
const productIds = [...new Set(orderproductPairs.map(p => p.productId))];
|
||||
|
||||
const reviews = await this.em.find(
|
||||
Review,
|
||||
{
|
||||
order: { id: { $in: orderIds } },
|
||||
product: { id: { $in: productIds } },
|
||||
},
|
||||
{
|
||||
fields: ['id', 'order', 'product'],
|
||||
populate: ['order', 'product'],
|
||||
},
|
||||
);
|
||||
|
||||
// Create a map: key = `${orderId}-${productId}`, value = reviewId
|
||||
for (const review of reviews) {
|
||||
const key = `${review.order.id}-${review.product.id}`;
|
||||
reviewsMap.set(key, review.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Map reviewIds to products
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
if (item.product) {
|
||||
const key = `${order.id}-${item.product.id}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const product = item.product as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
product.reviewId = reviewsMap.get(key) || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user