refactor step 2

This commit is contained in:
2025-12-15 12:35:29 +03:30
parent b776f2b83a
commit 184ceb4800
10 changed files with 111 additions and 182 deletions
+2 -9
View File
@@ -19,10 +19,9 @@ export class Food extends BaseEntity {
@OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true }) @OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true })
reviews = new Collection<Review>(this); reviews = new Collection<Review>(this);
@OneToOne(() => Inventory, inventory => inventory.food, { @OneToOne(() => Inventory, {
mappedBy: 'food',
nullable: true, nullable: true,
cascade: [Cascade.ALL],
orphanRemoval: true,
}) })
inventory?: Inventory; inventory?: Inventory;
@@ -62,12 +61,6 @@ export class Food extends BaseEntity {
@Property({ type: 'boolean', default: false }) @Property({ type: 'boolean', default: false })
dinner: boolean = false; dinner: boolean = false;
// @Property({ type: 'int', default: 0 })
// stock: number = 0;
// @Property({ type: 'int', default: 0 })
// stockDefault: number = 0;
@Property({ type: 'boolean', default: true }) @Property({ type: 'boolean', default: true })
isActive: boolean = true; isActive: boolean = true;
@@ -1,10 +1,13 @@
import { Entity, Property, OneToOne } from '@mikro-orm/core'; import { Cascade, Entity, Property, OneToOne } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Food } from '../../foods/entities/food.entity'; import { Food } from '../../foods/entities/food.entity';
@Entity({ tableName: 'inventory' }) @Entity({ tableName: 'inventory' })
export class Inventory extends BaseEntity { export class Inventory extends BaseEntity {
@OneToOne(() => Food, food => food.inventory) @OneToOne(() => Food, {
cascade: [Cascade.ALL],
orphanRemoval: true,
})
food!: Food; food!: Food;
@Property({ type: 'int' }) @Property({ type: 'int' })
+2 -1
View File
@@ -4,9 +4,10 @@ import { InventoryService } from './inventory.service';
import { InventoryController } from './inventory.controller'; import { InventoryController } from './inventory.controller';
import { Inventory } from './entities/inventory.entity'; import { Inventory } from './entities/inventory.entity';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
@Module({ @Module({
imports: [MikroOrmModule.forFeature([Inventory]), AuthModule], imports: [MikroOrmModule.forFeature([Inventory]), AuthModule, JwtModule],
controllers: [InventoryController], controllers: [InventoryController],
providers: [InventoryService], providers: [InventoryService],
}) })
@@ -6,6 +6,7 @@ import { UserId } from '../../../common/decorators/user-id.decorator';
import { RestId } from 'src/common/decorators/rest-id.decorator'; import { RestId } from 'src/common/decorators/rest-id.decorator';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard'; import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { FindOrdersDto } from '../dto/find-orders.dto'; import { FindOrdersDto } from '../dto/find-orders.dto';
import { OrderStatus } from '../interface/order-status';
@ApiTags('orders') @ApiTags('orders')
@Controller() @Controller()
@@ -148,16 +149,9 @@ export class OrdersController {
@ApiParam({ @ApiParam({
name: 'status', name: 'status',
description: 'Order status', description: 'Order status',
enum: [ enum: OrderStatus,
'readyForCustomerPickup',
'readyForDineIn',
'readyForDeliveryCar',
'readyForDeliveryCourier',
'shipping',
'delivered',
],
}) })
updateStatus(@Param('orderId') orderId: string, @Param('status') status: string, @RestId() restId: string) { updateStatus(@Param('orderId') orderId: string, @Param('status') status: OrderStatus, @RestId() restId: string) {
return this.ordersService.updateStatus(orderId, status, restId); return this.ordersService.updateStatus(orderId, status, restId);
} }
} }
+1 -2
View File
@@ -20,7 +20,6 @@ import { UserAddress } from '../../users/entities/user-address.entity';
import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity';
import { OrderItem } from './order-item.entity'; import { OrderItem } from './order-item.entity';
import { Delivery } from '../../delivery/entities/delivery.entity'; import { Delivery } from '../../delivery/entities/delivery.entity';
import { Payment } from 'src/modules/payments/entities/payment.entity';
@Entity({ tableName: 'orders' }) @Entity({ tableName: 'orders' })
@Unique({ properties: ['restaurant', 'orderNumber'] }) @Unique({ properties: ['restaurant', 'orderNumber'] })
@@ -48,7 +47,7 @@ export class Order extends BaseEntity {
@ManyToOne(() => UserAddress, { nullable: true }) @ManyToOne(() => UserAddress, { nullable: true })
address?: UserAddress; address?: UserAddress;
@OneToOne(() => Payment, payment => payment.order) @ManyToOne(() => PaymentMethod)
paymentMethod: PaymentMethod; paymentMethod: PaymentMethod;
@Property({ type: 'int', nullable: true }) @Property({ type: 'int', nullable: true })
+1 -1
View File
@@ -24,7 +24,7 @@ import type { CouponType } from 'src/modules/coupons/interface/coupon';
// Delivered = 'delivered', // Delivered = 'delivered',
// } // }
export enum OrderStatus { export enum OrderStatus {
CREATED = 'new', NEW = 'new',
PENDING_PAYMENT = 'pendingPayment', PENDING_PAYMENT = 'pendingPayment',
PAID = 'paid', PAID = 'paid',
CONFIRMED = 'confirmed', CONFIRMED = 'confirmed',
+92 -136
View File
@@ -92,6 +92,9 @@ export class OrdersService {
const { food, quantity, unitPrice, discount } = itemData; const { food, quantity, unitPrice, discount } = itemData;
// Stock check // Stock check
if (!food.inventory) {
throw new BadRequestException(`Food ${food.title} does not have inventory`);
}
if (food.inventory.availableStock < quantity) { if (food.inventory.availableStock < quantity) {
throw new BadRequestException(`${food.title} is out of stock`); throw new BadRequestException(`${food.title} is out of stock`);
} }
@@ -109,8 +112,8 @@ export class OrdersService {
em.persist(orderItem); em.persist(orderItem);
// Update food stock //reserve food stock
food.stock -= quantity;
em.persist(food); em.persist(food);
} }
@@ -309,16 +312,10 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
if (order.status !== OrderStatus.CREATED) {
throw new BadRequestException('Order must be created before confirming');
}
// Wallet payments should be paid before accepting (like Online)
if (order.paymentMethod?.method === PaymentMethodEnum.Online && order.paymentStatus !== PaymentStatusEnum.Paid) {
throw new BadRequestException('Order must be paid online before accepting');
}
// Cash, CardOnDelivery, and Wallet can be accepted even if payment status is pending if (!this.canTransition(order.status, OrderStatus.CONFIRMED, order.paymentMethod.method)) {
// (they're paid on delivery or already deducted) throw new BadRequestException('Invalid status transition');
}
order.status = OrderStatus.CONFIRMED; order.status = OrderStatus.CONFIRMED;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
@@ -330,17 +327,14 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
if (!this.canTransition(order.status, OrderStatus.PREPARING, order.paymentMethod?.method)) { if (!this.canTransition(order.status, OrderStatus.PREPARING, order.paymentMethod.method)) {
throw new BadRequestException('Invalid status transition'); throw new BadRequestException('Invalid status transition');
} }
if (order.status !== OrderStatus.CONFIRMED) {
throw new BadRequestException('Order must be confirmed before preparing');
}
order.status = OrderStatus.PREPARING; order.status = OrderStatus.PREPARING;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
// just admin can reject the order any time
async rejectOrder(orderId: string, restId: string) { async rejectOrder(orderId: string, restId: string) {
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
if (!order) { if (!order) {
@@ -356,26 +350,12 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
let orderStatus: OrderStatus | undefined;
switch (order.deliveryMethod.method) { if (!this.canTransition(order.status, OrderStatus.READY, order.paymentMethod.method)) {
case DeliveryMethodEnum.DeliveryCourier: throw new BadRequestException('Invalid status transition');
orderStatus = OrderStatus.ReadyForDeliveryCourier;
break;
case DeliveryMethodEnum.DeliveryCar:
orderStatus = OrderStatus.ReadyForDeliveryCar;
break;
case DeliveryMethodEnum.DineIn:
orderStatus = OrderStatus.ReadyForDineIn;
break;
case DeliveryMethodEnum.CustomerPickup:
orderStatus = OrderStatus.ReadyForCustomerPickup;
break;
default:
throw new BadRequestException('Invalid delivery method');
} }
order.status = OrderStatus.READY;
order.status = orderStatus;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
@@ -385,8 +365,8 @@ export class OrdersService {
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
if (order.status !== OrderStatus.) { if (!this.canTransition(order.status, OrderStatus.CANCELED, order.paymentMethod.method)) {
throw new BadRequestException('Order is not pending'); throw new BadRequestException('Invalid status transition');
} }
order.status = OrderStatus.CANCELED; order.status = OrderStatus.CANCELED;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
@@ -399,143 +379,119 @@ export class OrdersService {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
const readyStatuses = [ if (!this.canTransition(order.status, OrderStatus.COMPLETED, order.paymentMethod.method)) {
OrderStatus.ReadyForCustomerPickup, throw new BadRequestException('Invalid status transition');
OrderStatus.ReadyForDineIn,
OrderStatus.ReadyForDeliveryCar,
OrderStatus.ReadyForDeliveryCourier,
];
if (!readyStatuses.includes(order.status)) {
throw new BadRequestException(
`Order must be in a ready status to mark as delivered. Current status: ${order.status}`,
);
} }
order.status = OrderStatus.COMPLETED;
order.status = OrderStatus.Delivered;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
async updateStatus(orderId: string, status: string, restId: string) { async updateStatus(orderId: string, status: OrderStatus, restId: string) {
const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } });
if (!order) { if (!order) {
throw new NotFoundException('Order not found'); throw new NotFoundException('Order not found');
} }
// Map string status to OrderStatus enum if (!this.canTransition(order.status, status, order.paymentMethod.method)) {
const statusMap: Record<string, OrderStatus> = { throw new BadRequestException('Invalid status transition');
ReadyForCustomerPickup: OrderStatus.ReadyForCustomerPickup,
readyForCustomerPickup: OrderStatus.ReadyForCustomerPickup,
ReadyForDineIn: OrderStatus.ReadyForDineIn,
readyForDineIn: OrderStatus.ReadyForDineIn,
ReadyForDeliveryCar: OrderStatus.ReadyForDeliveryCar,
readyForDeliveryCar: OrderStatus.ReadyForDeliveryCar,
ReadyForDeliveryCourier: OrderStatus.ReadyForDeliveryCourier,
readyForDeliveryCourier: OrderStatus.ReadyForDeliveryCourier,
shipping: OrderStatus.Shipping,
Delivered: OrderStatus.Delivered,
delivered: OrderStatus.Delivered,
};
const newStatus = statusMap[status];
if (!newStatus) {
throw new BadRequestException(
`Invalid status: ${status}. Allowed statuses: shipping, Delivered, ReadyForCustomerPickup, ReadyForDineIn, ReadyForDeliveryCar, ReadyForDeliveryCourier`,
);
} }
order.status = newStatus; order.status = status;
await this.em.persistAndFlush(order); await this.em.persistAndFlush(order);
return order; return order;
} }
transitions = { transitions: Record<OrderStatus, OrderStatus[]> = {
CREATED: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED], [OrderStatus.NEW]: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED],
PENDING_PAYMENT: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED], [OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED],
PAID: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED], [OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED],
CONFIRMED: [OrderStatus.PREPARING, OrderStatus.CANCELED], [OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
PREPARING: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED], [OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED],
READY: [OrderStatus.COMPLETED, OrderStatus.FAILED], [OrderStatus.READY]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
SHIPPED: [OrderStatus.COMPLETED, OrderStatus.FAILED], [OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
} [OrderStatus.COMPLETED]: [],
[OrderStatus.CANCELED]: [],
canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) { [OrderStatus.FAILED]: [],
[OrderStatus.REFUNDED]: [],
};
canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) {
if (!this.transitions[from]?.includes(to)) return false; if (!this.transitions[from]?.includes(to)) return false;
if (paymentMethod === PaymentMethodEnum.Cash) { if (paymentMethod === PaymentMethodEnum.Cash) {
if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false; if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false;
} }
if (paymentMethod === PaymentMethodEnum.Online) { if (paymentMethod === PaymentMethodEnum.Online) {
if (to === OrderStatus.CONFIRMED && from !== OrderStatus.PAID) return false; if (to === OrderStatus.CONFIRMED && from !== OrderStatus.PAID) return false;
} }
return true; return true;
} }
/** /**
* Cleanup job to handle abandoned orders (pending payment for >30 minutes) * Cleanup job to handle abandoned orders (pending payment for >30 minutes)
* Runs every 10 minutes to check for abandoned orders * Runs every 10 minutes to check for abandoned orders
*/ */
@Cron(CronExpression.EVERY_10_MINUTES) // @Cron(CronExpression.EVERY_10_MINUTES)
async cleanupAbandonedOrders() { // async cleanupAbandonedOrders() {
this.logger.log('Starting cleanup of abandoned orders...'); // this.logger.log('Starting cleanup of abandoned orders...');
try { // try {
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); // const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
// Find abandoned orders: Pending status, Pending payment, created >30 minutes ago // // Find abandoned orders: Pending status, Pending payment, created >30 minutes ago
const abandonedOrders = await this.em.find( // const abandonedOrders = await this.em.find(
Order, // Order,
{ // {
status: OrderStatus.Pending, // status: OrderStatus.PENDING_PAYMENT,
paymentStatus: PaymentStatusEnum.Pending, // paymentStatus: PaymentStatusEnum.Pending,
createdAt: { $lt: thirtyMinutesAgo }, // createdAt: { $lt: thirtyMinutesAgo },
}, // },
{ populate: ['items', 'items.food'] }, // { populate: ['items', 'items.food'] },
); // );
if (abandonedOrders.length === 0) { // if (abandonedOrders.length === 0) {
this.logger.log('No abandoned orders found'); // this.logger.log('No abandoned orders found');
return; // return;
} // }
this.logger.log(`Found ${abandonedOrders.length} abandoned order(s) to cleanup`); // this.logger.log(`Found ${abandonedOrders.length} abandoned order(s) to cleanup`);
// Process each abandoned order in a transaction // // Process each abandoned order in a transaction
for (const order of abandonedOrders) { // for (const order of abandonedOrders) {
await this.em.transactional(async em => { // await this.em.transactional(async em => {
// Load order items with food relations // // Load order items with food relations
await order.items.loadItems(); // await order.items.loadItems();
// Restore stock for each item // // Restore stock for each item
for (const item of order.items) { // for (const item of order.items) {
const food = item.food; // const food = item.food;
if (food) { // if (food) {
food.stock += item.quantity; // food.inventory?.availableStock += item.quantity;
em.persist(food); // em.persist(food);
this.logger.debug( // this.logger.debug(
`Restored ${item.quantity} units of stock for food ${food.id} (${food.title || 'N/A'})`, // `Restored ${item.quantity} units of stock for food ${food.id} (${food.title || 'N/A'})`,
); // );
} // }
} // }
// Update order status to Cancelled // // Update order status to Cancelled
order.status = OrderStatus.CancelledBySystem; // order.status = OrderStatus.CANCELED;
em.persist(order); // em.persist(order);
await em.flush(); // await em.flush();
this.logger.log(`Cancelled abandoned order ${order.id}`); // this.logger.log(`Cancelled abandoned order ${order.id}`);
}); // });
} // }
this.logger.log(`Successfully cleaned up ${abandonedOrders.length} abandoned order(s)`); // this.logger.log(`Successfully cleaned up ${abandonedOrders.length} abandoned order(s)`);
} catch (error) { // } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; // const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined; // const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Error during cleanup of abandoned orders: ${errorMessage}`, errorStack); // this.logger.error(`Error during cleanup of abandoned orders: ${errorMessage}`, errorStack);
throw error; // throw error;
} // }
} // }
} }
+5 -17
View File
@@ -1,7 +1,6 @@
import { PaymentMethodEnum, PaymentGatewayEnum } from '../../modules/payments/interface/payment'; import { PaymentMethodEnum, PaymentGatewayEnum } from '../../modules/payments/interface/payment';
export interface PaymentMethodData { export interface PaymentMethodData {
title: string;
method: PaymentMethodEnum; method: PaymentMethodEnum;
description: string; description: string;
enabled: boolean; enabled: boolean;
@@ -12,15 +11,14 @@ export interface PaymentMethodData {
export const paymentMethodsData: PaymentMethodData[] = [ export const paymentMethodsData: PaymentMethodData[] = [
{ {
title: 'پرداخت در محل', method: PaymentMethodEnum.Online,
method: PaymentMethodEnum.CardOnDelivery, description: 'درگاه پرداخت زرین‌پال',
description: 'پرداخت در محل تحویل',
enabled: true, enabled: true,
order: 1, order: 1,
gateway: null, gateway: PaymentGatewayEnum.ZarinPal,
merchantId: 'b6f55bd0-6eae-4045-aeb8-07d084fa8f73',
}, },
{ {
title: 'نقدی',
method: PaymentMethodEnum.Cash, method: PaymentMethodEnum.Cash,
description: 'پرداخت نقدی', description: 'پرداخت نقدی',
enabled: true, enabled: true,
@@ -28,20 +26,10 @@ export const paymentMethodsData: PaymentMethodData[] = [
gateway: null, gateway: null,
}, },
{ {
title: 'زرین‌پال',
method: PaymentMethodEnum.Online,
description: 'درگاه پرداخت زرین‌پال',
enabled: true,
order: 3,
gateway: PaymentGatewayEnum.ZarinPal,
merchantId: 'b6f55bd0-6eae-4045-aeb8-07d084fa8f73',
},
{
title: 'کیف پول',
method: PaymentMethodEnum.Wallet, method: PaymentMethodEnum.Wallet,
description: 'پرداخت از کیف پول', description: 'پرداخت از کیف پول',
enabled: true, enabled: true,
order: 4, order: 3,
gateway: null, gateway: null,
}, },
]; ];
-2
View File
@@ -30,8 +30,6 @@ export class FoodsSeeder {
restaurant, restaurant,
category, category,
isActive: foodData.isActive, isActive: foodData.isActive,
stock: foodData.stock,
stockDefault: foodData.stockDefault,
sat: false, sat: false,
sun: false, sun: false,
mon: false, mon: false,
-3
View File
@@ -17,7 +17,6 @@ export class PaymentMethodsSeeder {
if (!existing) { if (!existing) {
const paymentMethod = em.create(PaymentMethod, { const paymentMethod = em.create(PaymentMethod, {
restaurant, restaurant,
title: methodData.title,
method: methodData.method, method: methodData.method,
description: methodData.description, description: methodData.description,
enabled: methodData.enabled, enabled: methodData.enabled,
@@ -29,12 +28,10 @@ export class PaymentMethodsSeeder {
} else { } else {
// Update existing payment method if needed // Update existing payment method if needed
if ( if (
existing.title !== methodData.title ||
existing.description !== methodData.description || existing.description !== methodData.description ||
existing.enabled !== methodData.enabled || existing.enabled !== methodData.enabled ||
existing.order !== methodData.order existing.order !== methodData.order
) { ) {
existing.title = methodData.title;
existing.description = methodData.description; existing.description = methodData.description;
existing.enabled = methodData.enabled; existing.enabled = methodData.enabled;
existing.order = methodData.order; existing.order = methodData.order;