refactor step 2
This commit is contained in:
@@ -19,10 +19,9 @@ export class Food extends BaseEntity {
|
||||
@OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true })
|
||||
reviews = new Collection<Review>(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;
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,68 +379,44 @@ 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<string, OrderStatus> = {
|
||||
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],
|
||||
}
|
||||
transitions: Record<OrderStatus, OrderStatus[]> = {
|
||||
[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) {
|
||||
canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum) {
|
||||
if (!this.transitions[from]?.includes(to)) return false;
|
||||
|
||||
if (paymentMethod === PaymentMethodEnum.Cash) {
|
||||
@@ -478,64 +434,64 @@ export class OrdersService {
|
||||
* 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;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -30,8 +30,6 @@ export class FoodsSeeder {
|
||||
restaurant,
|
||||
category,
|
||||
isActive: foodData.isActive,
|
||||
stock: foodData.stock,
|
||||
stockDefault: foodData.stockDefault,
|
||||
sat: false,
|
||||
sun: false,
|
||||
mon: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user