From 184ceb4800f18a50a0e2c3cdc443adf2a95be2ac Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Mon, 15 Dec 2025 12:35:29 +0330 Subject: [PATCH] refactor step 2 --- src/modules/foods/entities/food.entity.ts | 11 +- .../inventory/entities/inventory.entity.ts | 7 +- src/modules/inventory/inventory.module.ts | 3 +- .../orders/controllers/orders.controller.ts | 12 +- src/modules/orders/entities/order.entity.ts | 3 +- src/modules/orders/interface/order-status.ts | 2 +- .../orders/providers/orders.service.ts | 228 +++++++----------- src/seeders/data/payment-methods.data.ts | 22 +- src/seeders/foods.seeder.ts | 2 - src/seeders/payment-methods.seeder.ts | 3 - 10 files changed, 111 insertions(+), 182 deletions(-) diff --git a/src/modules/foods/entities/food.entity.ts b/src/modules/foods/entities/food.entity.ts index 7175630..9e4968f 100644 --- a/src/modules/foods/entities/food.entity.ts +++ b/src/modules/foods/entities/food.entity.ts @@ -19,10 +19,9 @@ export class Food extends BaseEntity { @OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true }) reviews = new Collection(this); - @OneToOne(() => Inventory, inventory => inventory.food, { + @OneToOne(() => Inventory, { + mappedBy: 'food', nullable: true, - cascade: [Cascade.ALL], - orphanRemoval: true, }) inventory?: Inventory; @@ -62,12 +61,6 @@ export class Food extends BaseEntity { @Property({ type: 'boolean', default: 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 }) isActive: boolean = true; diff --git a/src/modules/inventory/entities/inventory.entity.ts b/src/modules/inventory/entities/inventory.entity.ts index 97ac760..8897990 100644 --- a/src/modules/inventory/entities/inventory.entity.ts +++ b/src/modules/inventory/entities/inventory.entity.ts @@ -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 { Food } from '../../foods/entities/food.entity'; @Entity({ tableName: 'inventory' }) export class Inventory extends BaseEntity { - @OneToOne(() => Food, food => food.inventory) + @OneToOne(() => Food, { + cascade: [Cascade.ALL], + orphanRemoval: true, + }) food!: Food; @Property({ type: 'int' }) diff --git a/src/modules/inventory/inventory.module.ts b/src/modules/inventory/inventory.module.ts index 5a89884..8cf8f75 100644 --- a/src/modules/inventory/inventory.module.ts +++ b/src/modules/inventory/inventory.module.ts @@ -4,9 +4,10 @@ import { InventoryService } from './inventory.service'; import { InventoryController } from './inventory.controller'; import { Inventory } from './entities/inventory.entity'; import { AuthModule } from '../auth/auth.module'; +import { JwtModule } from '@nestjs/jwt'; @Module({ - imports: [MikroOrmModule.forFeature([Inventory]), AuthModule], + imports: [MikroOrmModule.forFeature([Inventory]), AuthModule, JwtModule], controllers: [InventoryController], providers: [InventoryService], }) diff --git a/src/modules/orders/controllers/orders.controller.ts b/src/modules/orders/controllers/orders.controller.ts index dc48c26..5bcf458 100644 --- a/src/modules/orders/controllers/orders.controller.ts +++ b/src/modules/orders/controllers/orders.controller.ts @@ -6,6 +6,7 @@ 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-status'; @ApiTags('orders') @Controller() @@ -148,16 +149,9 @@ export class OrdersController { @ApiParam({ name: 'status', description: 'Order status', - enum: [ - 'readyForCustomerPickup', - 'readyForDineIn', - 'readyForDeliveryCar', - 'readyForDeliveryCourier', - 'shipping', - 'delivered', - ], + enum: OrderStatus, }) - 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); } } diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index 2a11903..34bb54e 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -20,7 +20,6 @@ import { UserAddress } from '../../users/entities/user-address.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { OrderItem } from './order-item.entity'; import { Delivery } from '../../delivery/entities/delivery.entity'; -import { Payment } from 'src/modules/payments/entities/payment.entity'; @Entity({ tableName: 'orders' }) @Unique({ properties: ['restaurant', 'orderNumber'] }) @@ -48,7 +47,7 @@ export class Order extends BaseEntity { @ManyToOne(() => UserAddress, { nullable: true }) address?: UserAddress; - @OneToOne(() => Payment, payment => payment.order) + @ManyToOne(() => PaymentMethod) paymentMethod: PaymentMethod; @Property({ type: 'int', nullable: true }) diff --git a/src/modules/orders/interface/order-status.ts b/src/modules/orders/interface/order-status.ts index c80ce36..ce8f73e 100644 --- a/src/modules/orders/interface/order-status.ts +++ b/src/modules/orders/interface/order-status.ts @@ -24,7 +24,7 @@ import type { CouponType } from 'src/modules/coupons/interface/coupon'; // Delivered = 'delivered', // } export enum OrderStatus { - CREATED = 'new', + NEW = 'new', PENDING_PAYMENT = 'pendingPayment', PAID = 'paid', CONFIRMED = 'confirmed', diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index eb7f54f..a7e41cc 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -92,6 +92,9 @@ export class OrdersService { const { food, quantity, unitPrice, discount } = itemData; // Stock check + if (!food.inventory) { + throw new BadRequestException(`Food ${food.title} does not have inventory`); + } if (food.inventory.availableStock < quantity) { throw new BadRequestException(`${food.title} is out of stock`); } @@ -109,8 +112,8 @@ export class OrdersService { em.persist(orderItem); - // Update food stock - food.stock -= quantity; + //reserve food stock + em.persist(food); } @@ -309,16 +312,10 @@ export class OrdersService { if (!order) { 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 - // (they're paid on delivery or already deducted) + if (!this.canTransition(order.status, OrderStatus.CONFIRMED, order.paymentMethod.method)) { + throw new BadRequestException('Invalid status transition'); + } order.status = OrderStatus.CONFIRMED; await this.em.persistAndFlush(order); @@ -330,17 +327,14 @@ export class OrdersService { if (!order) { 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'); } - if (order.status !== OrderStatus.CONFIRMED) { - throw new BadRequestException('Order must be confirmed before preparing'); - } order.status = OrderStatus.PREPARING; await this.em.persistAndFlush(order); return order; } - + // just admin can reject the order any time async rejectOrder(orderId: string, restId: string) { const order = await this.em.findOne(Order, { id: orderId, restaurant: { id: restId } }); if (!order) { @@ -356,26 +350,12 @@ export class OrdersService { if (!order) { throw new NotFoundException('Order not found'); } - let orderStatus: OrderStatus | undefined; - switch (order.deliveryMethod.method) { - case DeliveryMethodEnum.DeliveryCourier: - 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'); + if (!this.canTransition(order.status, OrderStatus.READY, order.paymentMethod.method)) { + throw new BadRequestException('Invalid status transition'); } + order.status = OrderStatus.READY; - order.status = orderStatus; await this.em.persistAndFlush(order); return order; } @@ -385,8 +365,8 @@ export class OrdersService { if (!order) { throw new NotFoundException('Order not found'); } - if (order.status !== OrderStatus.) { - throw new BadRequestException('Order is not pending'); + if (!this.canTransition(order.status, OrderStatus.CANCELED, order.paymentMethod.method)) { + throw new BadRequestException('Invalid status transition'); } order.status = OrderStatus.CANCELED; await this.em.persistAndFlush(order); @@ -399,143 +379,119 @@ export class OrdersService { throw new NotFoundException('Order not found'); } - const readyStatuses = [ - OrderStatus.ReadyForCustomerPickup, - 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}`, - ); + if (!this.canTransition(order.status, OrderStatus.COMPLETED, order.paymentMethod.method)) { + throw new BadRequestException('Invalid status transition'); } - - order.status = OrderStatus.Delivered; + order.status = OrderStatus.COMPLETED; await this.em.persistAndFlush(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 } }); if (!order) { throw new NotFoundException('Order not found'); } - // Map string status to OrderStatus enum - const statusMap: Record = { - 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`, - ); + if (!this.canTransition(order.status, status, order.paymentMethod.method)) { + throw new BadRequestException('Invalid status transition'); } - order.status = newStatus; + order.status = status; await this.em.persistAndFlush(order); return order; } - transitions = { - CREATED: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED], - PENDING_PAYMENT: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED], - PAID: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED], - CONFIRMED: [OrderStatus.PREPARING, OrderStatus.CANCELED], - PREPARING: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED], - READY: [OrderStatus.COMPLETED, OrderStatus.FAILED], - SHIPPED: [OrderStatus.COMPLETED, OrderStatus.FAILED], - } - - canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) { + transitions: Record = { + [OrderStatus.NEW]: [OrderStatus.PENDING_PAYMENT, OrderStatus.CONFIRMED, OrderStatus.CANCELED], + [OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.FAILED, OrderStatus.CANCELED], + [OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.REFUNDED], + [OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELED], + [OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.SHIPPED, OrderStatus.CANCELED], + [OrderStatus.READY]: [OrderStatus.COMPLETED, OrderStatus.FAILED], + [OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.FAILED], + [OrderStatus.COMPLETED]: [], + [OrderStatus.CANCELED]: [], + [OrderStatus.FAILED]: [], + [OrderStatus.REFUNDED]: [], + }; + + canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) { if (!this.transitions[from]?.includes(to)) return false; - + if (paymentMethod === PaymentMethodEnum.Cash) { if ([OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(to)) return false; } - + if (paymentMethod === PaymentMethodEnum.Online) { if (to === OrderStatus.CONFIRMED && from !== OrderStatus.PAID) return false; } - + return true; } - + /** * Cleanup job to handle abandoned orders (pending payment for >30 minutes) * Runs every 10 minutes to check for abandoned orders */ - @Cron(CronExpression.EVERY_10_MINUTES) - async cleanupAbandonedOrders() { - this.logger.log('Starting cleanup of abandoned orders...'); + // @Cron(CronExpression.EVERY_10_MINUTES) + // async cleanupAbandonedOrders() { + // this.logger.log('Starting cleanup of abandoned orders...'); - try { - const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); + // try { + // const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); - // Find abandoned orders: Pending status, Pending payment, created >30 minutes ago - const abandonedOrders = await this.em.find( - Order, - { - status: OrderStatus.Pending, - paymentStatus: PaymentStatusEnum.Pending, - createdAt: { $lt: thirtyMinutesAgo }, - }, - { populate: ['items', 'items.food'] }, - ); + // // Find abandoned orders: Pending status, Pending payment, created >30 minutes ago + // const abandonedOrders = await this.em.find( + // Order, + // { + // status: OrderStatus.PENDING_PAYMENT, + // paymentStatus: PaymentStatusEnum.Pending, + // createdAt: { $lt: thirtyMinutesAgo }, + // }, + // { populate: ['items', 'items.food'] }, + // ); - if (abandonedOrders.length === 0) { - this.logger.log('No abandoned orders found'); - return; - } + // if (abandonedOrders.length === 0) { + // this.logger.log('No abandoned orders found'); + // 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 - for (const order of abandonedOrders) { - await this.em.transactional(async em => { - // Load order items with food relations - await order.items.loadItems(); + // // Process each abandoned order in a transaction + // for (const order of abandonedOrders) { + // await this.em.transactional(async em => { + // // Load order items with food relations + // await order.items.loadItems(); - // Restore stock for each item - for (const item of order.items) { - const food = item.food; - if (food) { - food.stock += item.quantity; - em.persist(food); - this.logger.debug( - `Restored ${item.quantity} units of stock for food ${food.id} (${food.title || 'N/A'})`, - ); - } - } + // // Restore stock for each item + // for (const item of order.items) { + // const food = item.food; + // if (food) { + // food.inventory?.availableStock += item.quantity; + // em.persist(food); + // this.logger.debug( + // `Restored ${item.quantity} units of stock for food ${food.id} (${food.title || 'N/A'})`, + // ); + // } + // } - // Update order status to Cancelled - order.status = OrderStatus.CancelledBySystem; - em.persist(order); + // // Update order status to Cancelled + // order.status = OrderStatus.CANCELED; + // em.persist(order); - await em.flush(); - this.logger.log(`Cancelled abandoned order ${order.id}`); - }); - } + // await em.flush(); + // this.logger.log(`Cancelled abandoned order ${order.id}`); + // }); + // } - this.logger.log(`Successfully cleaned up ${abandonedOrders.length} abandoned order(s)`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const errorStack = error instanceof Error ? error.stack : undefined; - this.logger.error(`Error during cleanup of abandoned orders: ${errorMessage}`, errorStack); - throw error; - } - } + // this.logger.log(`Successfully cleaned up ${abandonedOrders.length} abandoned order(s)`); + // } catch (error) { + // const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + // const errorStack = error instanceof Error ? error.stack : undefined; + // this.logger.error(`Error during cleanup of abandoned orders: ${errorMessage}`, errorStack); + // throw error; + // } + // } } diff --git a/src/seeders/data/payment-methods.data.ts b/src/seeders/data/payment-methods.data.ts index b38df13..673a1fc 100644 --- a/src/seeders/data/payment-methods.data.ts +++ b/src/seeders/data/payment-methods.data.ts @@ -1,7 +1,6 @@ import { PaymentMethodEnum, PaymentGatewayEnum } from '../../modules/payments/interface/payment'; export interface PaymentMethodData { - title: string; method: PaymentMethodEnum; description: string; enabled: boolean; @@ -12,15 +11,14 @@ export interface PaymentMethodData { export const paymentMethodsData: PaymentMethodData[] = [ { - title: 'پرداخت در محل', - method: PaymentMethodEnum.CardOnDelivery, - description: 'پرداخت در محل تحویل', + method: PaymentMethodEnum.Online, + description: 'درگاه پرداخت زرین‌پال', enabled: true, order: 1, - gateway: null, + gateway: PaymentGatewayEnum.ZarinPal, + merchantId: 'b6f55bd0-6eae-4045-aeb8-07d084fa8f73', }, { - title: 'نقدی', method: PaymentMethodEnum.Cash, description: 'پرداخت نقدی', enabled: true, @@ -28,20 +26,10 @@ export const paymentMethodsData: PaymentMethodData[] = [ gateway: null, }, { - title: 'زرین‌پال', - method: PaymentMethodEnum.Online, - description: 'درگاه پرداخت زرین‌پال', - enabled: true, - order: 3, - gateway: PaymentGatewayEnum.ZarinPal, - merchantId: 'b6f55bd0-6eae-4045-aeb8-07d084fa8f73', - }, - { - title: 'کیف پول', method: PaymentMethodEnum.Wallet, description: 'پرداخت از کیف پول', enabled: true, - order: 4, + order: 3, gateway: null, }, ]; diff --git a/src/seeders/foods.seeder.ts b/src/seeders/foods.seeder.ts index f79d790..9bdb268 100644 --- a/src/seeders/foods.seeder.ts +++ b/src/seeders/foods.seeder.ts @@ -30,8 +30,6 @@ export class FoodsSeeder { restaurant, category, isActive: foodData.isActive, - stock: foodData.stock, - stockDefault: foodData.stockDefault, sat: false, sun: false, mon: false, diff --git a/src/seeders/payment-methods.seeder.ts b/src/seeders/payment-methods.seeder.ts index 6a6ed37..1765ce0 100644 --- a/src/seeders/payment-methods.seeder.ts +++ b/src/seeders/payment-methods.seeder.ts @@ -17,7 +17,6 @@ export class PaymentMethodsSeeder { if (!existing) { const paymentMethod = em.create(PaymentMethod, { restaurant, - title: methodData.title, method: methodData.method, description: methodData.description, enabled: methodData.enabled, @@ -29,12 +28,10 @@ export class PaymentMethodsSeeder { } else { // Update existing payment method if needed if ( - existing.title !== methodData.title || existing.description !== methodData.description || existing.enabled !== methodData.enabled || existing.order !== methodData.order ) { - existing.title = methodData.title; existing.description = methodData.description; existing.enabled = methodData.enabled; existing.order = methodData.order;