This commit is contained in:
2026-01-14 11:29:24 +03:30
parent 1075dbc44f
commit 4ab9de447a
26 changed files with 1130 additions and 1710 deletions
+2 -172
View File
@@ -2,12 +2,7 @@ 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 {
@@ -15,173 +10,8 @@ export class OrdersCrone {
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 =
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) || '',
,
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);
}
}
}
@@ -1,4 +1,4 @@
import { Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core';
import { Entity, Enum, Index, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Order } from './order.entity';
import { Product } from 'src/modules/product/entities/product.entity';
@@ -8,6 +8,9 @@ import { OrderItemStatus } from '../interface/order.interface';
@Index({ properties: ['order'] })
@Index({ properties: ['product'] })
export class OrderItem extends BaseEntity {
@PrimaryKey({ type: 'bigint', autoincrement: true })
id: bigint
@ManyToOne(() => Order)
order!: Order;
@@ -7,17 +7,22 @@ import {
Collection,
Cascade,
Enum,
PrimaryKey,
} from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { OrderStatus } from '../interface/order.interface';
import { User } from '../../user/entities/user.entity';
import { OrderItem } from './order-item.entity';
import { Payment } from 'src/modules/payment/entities/payment.entity';
import { ulid } from 'ulid';
@Entity({ tableName: 'orders' })
@Index({ properties: ['user', 'status'] })
@Index({ properties: ['status'] })
export class Order extends BaseEntity {
@PrimaryKey({type:'string',columnType:'char(26)'})
id:string=ulid()
@ManyToOne(() => User)
user!: User;
+170 -171
View File
@@ -10,8 +10,7 @@ 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);
@@ -30,184 +29,184 @@ export class OrderListeners {
// 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;
}
// 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.} and order number: ${event.orderNumber}`,
);
// @OnEvent(OrderCreatedEvent.name)
// async handleOrderCreated(event: OrderCreatedEvent) {
// try {
// this.logger.log(
// `Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
// );
const order = await this.OrderRepository.findOne(event.orderId);
if (order?.paymentMethod.method === PaymentMethodEnum.Online) {
return;
}
// 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., Permission.MANAGE_ORDERS);
const recipients = admins.map(admin => ({
adminId: admin.id,
}));
// // get admnin os restuaraant that have order permissuins
// const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
// const recipients = admins.map(admin => ({
// adminId: admin.id,
// }));
await this.notificationService.sendNotification({
: event.,
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.}`,
error instanceof Error ? error.stack : String(error),
);
}
}
// await this.notificationService.sendNotification({
// 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.}`,
// 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.} 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) {
// @OnEvent(OrderStatusChangedEvent.name)
// async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
// try {
// this.logger.log(
// `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 = [
// {
// userId: event.userId,
// },
// ];
// if (event.newStatus === OrderStatus.COMPLETED) {
if (!event?.userId) {
this.logger.log(
`User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
);
}
// if (!event?.userId) {
// this.logger.log(
// `User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
// );
// }
// const restaurant = await this.RestaurantRepository.findOne(event.);
// if (!restaurant) {
// this.logger.log(
// `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.} and order number: ${event.orderNumber}`,
// );
// return;
// }
// // const restaurant = await this.RestaurantRepository.findOne(event.);
// // if (!restaurant) {
// // this.logger.log(
// // `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.} 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.} and order number: ${event.orderNumber}`,
// );
// return;
// }
// this.userService.createWalletTransaction(event.userId, event., {
// amount: order.subTotal,
// type: WalletTransactionType.CREDIT,
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
// });
// // 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.} and order number: ${event.orderNumber}`,
// // );
// // return;
// // }
// // this.userService.createWalletTransaction(event.userId, event., {
// // amount: order.subTotal,
// // type: WalletTransactionType.CREDIT,
// // reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
// // });
await this.notificationService.sendNotification({
: event.,
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({
: event.,
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.}`,
error instanceof Error ? error.stack : String(error),
);
}
}
// await this.notificationService.sendNotification({
// : event.,
// 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({
// : event.,
// 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.}`,
// error instanceof Error ? error.stack : String(error),
// );
// }
// }
}
@@ -5,7 +5,7 @@ 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;
@@ -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(: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
async findAllPaginated( 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: } };
const where: FilterQuery<Order> = { };
// Filter by statuses
if (statuses) {
@@ -99,19 +99,7 @@ export class OrderRepository extends EntityRepository<Order> {
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, {
@@ -122,7 +110,7 @@ export class OrderRepository extends EntityRepository<Order> {
});
// Collect all (orderId, productId) pairs for efficient review lookup
const orderproductPairs: Array<{ orderId: string; productId: string }> = [];
const orderproductPairs: Array<{ orderId: string; productId: bigint }> = [];
for (const order of data) {
for (const item of order.items.getItems()) {
if (item.product?.id) {
@@ -131,30 +119,7 @@ export class OrderRepository extends EntityRepository<Order> {
}
}
// 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) {
@@ -164,7 +129,7 @@ export class OrderRepository extends EntityRepository<Order> {
// 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;
}
}
}