From 17869470abd5eef85a2cb9d06c63cbebd240d0e9 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Wed, 3 Jun 2026 16:56:40 +0330 Subject: [PATCH] order by admin --- .../orders/controllers/orders.controller.ts | 10 ++ .../orders/dto/admin-create-order.dto.ts | 97 ++++++++++++ .../orders/providers/orders.service.ts | 139 +++++++++++++++++- .../users/controllers/users.controller.ts | 13 ++ .../users/dto/find-user-by-phone.dto.ts | 9 ++ src/modules/users/providers/user.service.ts | 6 +- 6 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/modules/orders/dto/admin-create-order.dto.ts create mode 100644 src/modules/users/dto/find-user-by-phone.dto.ts diff --git a/src/modules/orders/controllers/orders.controller.ts b/src/modules/orders/controllers/orders.controller.ts index 93515f5..858cf8c 100644 --- a/src/modules/orders/controllers/orders.controller.ts +++ b/src/modules/orders/controllers/orders.controller.ts @@ -11,6 +11,7 @@ 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'; +import { AdminCreateOrderDto } from '../dto/admin-create-order.dto'; @ApiTags('orders') @ApiBearerAuth() @@ -64,6 +65,15 @@ export class OrdersController { } /******************** Admin Routes **********************/ + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Post('admin/orders') + @ApiOperation({ summary: 'Create an order for a user (by phone) from admin side' }) + @ApiBody({ type: AdminCreateOrderDto }) + createOrderForUser(@RestId() restId: string, @Body() dto: AdminCreateOrderDto) { + return this.ordersService.createOrderForUserByPhone(restId, dto); + } + @UseGuards(AdminAuthGuard) @Permissions(Permission.MANAGE_ORDERS) @Get('admin/orders') diff --git a/src/modules/orders/dto/admin-create-order.dto.ts b/src/modules/orders/dto/admin-create-order.dto.ts new file mode 100644 index 0000000..d147083 --- /dev/null +++ b/src/modules/orders/dto/admin-create-order.dto.ts @@ -0,0 +1,97 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsString, + IsArray, + ValidateNested, + IsNumber, + Min, + IsOptional, + ArrayMinSize, + IsNotEmpty, +} from 'class-validator'; +import type { OrderCarAddress, OrderUserAddress } from '../interface/order.interface'; + +export class AdminOrderItemDto { + @ApiProperty({ description: 'Food ID' }) + @IsString() + @IsNotEmpty() + foodId: string; + + @ApiProperty({ description: 'Quantity', minimum: 1 }) + @IsNumber() + @Min(1) + quantity: number; +} + +export class AdminCreateOrderDto { + @ApiProperty({ description: 'Phone number of the user to create the order for' }) + @IsString() + @IsNotEmpty() + userPhone: string; + + @ApiPropertyOptional({ description: 'First name — used when creating a new user' }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiPropertyOptional({ description: 'Last name — used when creating a new user' }) + @IsOptional() + @IsString() + lastName?: string; + + @ApiProperty({ type: [AdminOrderItemDto], description: 'Order items' }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => AdminOrderItemDto) + items: AdminOrderItemDto[]; + + @ApiProperty({ description: 'Delivery method ID' }) + @IsString() + @IsNotEmpty() + deliveryMethodId: string; + + @ApiProperty({ description: 'Payment method ID' }) + @IsString() + @IsNotEmpty() + paymentMethodId: string; + + @ApiPropertyOptional({ description: 'Order description or notes' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Table number (required for dine-in)' }) + @IsOptional() + @IsString() + tableNumber?: string; + + @ApiPropertyOptional({ + description: 'User delivery address (required for courier delivery)', + example: { + fullName: 'علی محمدی', + phone: '09121234567', + city: 'تهران', + province: 'تهران', + address: 'خیابان ولیعصر، پلاک ۱۲', + postalCode: '1234567890', + latitude: 35.6892, + longitude: 51.389, + }, + }) + @IsOptional() + userAddress?: OrderUserAddress; + + @ApiPropertyOptional({ + description: 'Car address (required for car delivery)', + example: { + carModel: 'پراید', + carColor: 'سفید', + plateNumber: '۱۲ ایران ۳۴۵', + phone: '09121234567', + }, + }) + @IsOptional() + carAddress?: OrderCarAddress; +} diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 549be42..2e9f2eb 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -11,7 +11,7 @@ import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/p import { Cart } from '../../cart/interfaces/cart.interface'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { PaymentsService } from '../../payments/services/payments.service'; -import { DeliveryMethodEnum } from '../../delivery/interface/delivery'; +import { DeliveryFeeTypeEnum, 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'; @@ -23,6 +23,8 @@ 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'; +import { AdminCreateOrderDto } from '../dto/admin-create-order.dto'; +import { normalizePhone } from 'src/modules/utils/phone.util'; type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number }; type ValidatedCartForOrder = { @@ -140,6 +142,98 @@ export class OrdersService { return { paymentUrl, order }; } + async createOrderForUserByPhone(restaurantId: string, dto: AdminCreateOrderDto): Promise { + const normalizedPhone = normalizePhone(dto.userPhone); + let user = await this.em.findOne(User, { phone: normalizedPhone }); + if (!user) { + user = this.em.create(User, { + phone: normalizedPhone, + firstName: dto.firstName?.trim() || normalizedPhone, + lastName: dto.lastName?.trim(), + }); + await this.em.persistAndFlush(user); + this.logger.debug(`Auto-created user ${user.id} with phone ${normalizedPhone} via admin order`); + } + + const [restaurant, delivery] = await Promise.all([ + this.getRestaurantOrFail(restaurantId), + this.getDeliveryOrFail(dto.deliveryMethodId), + ]); + + const paymentMethod = await this.getPaymentMethodOrFail(dto.paymentMethodId, restaurantId); + this.assertPaymentMethodEnabled(paymentMethod); + + this.assertAdminDeliveryRequirements(dto, delivery); + + const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId); + + const subTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0); + const itemsDiscount = orderItemsData.reduce((sum, item) => sum + item.discount * item.quantity, 0); + const deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0; + const totalDiscount = itemsDiscount; + const total = subTotal - totalDiscount + deliveryFee; + const totalItems = orderItemsData.reduce((sum, item) => sum + item.quantity, 0); + + const order = await this.em.transactional(async em => { + const order = em.create(Order, { + user, + restaurant, + deliveryMethod: delivery, + userAddress: delivery.method === DeliveryMethodEnum.DeliveryCourier ? (dto.userAddress ?? null) : null, + carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (dto.carAddress ?? null) : null, + paymentMethod, + couponDiscount: 0, + couponDetail: null, + itemsDiscount, + totalDiscount, + subTotal, + tax: 0, + deliveryFee, + total, + totalItems, + description: dto.description, + tableNumber: dto.tableNumber, + status: OrderStatus.PENDING_PAYMENT, + history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }], + }); + + em.persist(order); + + for (const itemData of orderItemsData) { + const { food, quantity, unitPrice, discount } = itemData; + this.assertFoodHasSufficientStock(food, quantity); + const totalPrice = (unitPrice - discount) * quantity; + const orderItem = em.create(OrderItem, { order, food, 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); + + const bulkReserveFoodDto: BulkReserveFoodDto = { + items: orderItemsData.map(item => ({ foodId: item.food.id, quantity: item.quantity })), + }; + await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto); + await em.flush(); + + this.logger.debug(`Admin created order ${order.id} for user ${user.id} (phone: ${normalizedPhone}, restaurant: ${restaurantId})`); + return order; + }); + + this.eventEmitter.emit( + OrderCreatedEvent.name, + new OrderCreatedEvent(order.id, restaurantId, String(order?.orderNumber) || '', order.total), + ); + + return order; + } + /** * Validates cart and prepares all required data for order creation */ @@ -453,6 +547,49 @@ export class OrdersService { } } + private async buildOrderItemsDataFromDto( + items: { foodId: string; quantity: number }[], + restaurantId: string, + ): Promise { + const orderItemsData: OrderItemData[] = []; + + for (const item of items) { + const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant', 'inventory'] }); + if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND); + + if (food.restaurant.id !== restaurantId) { + throw new BadRequestException(OrderMessage.FOOD_NOT_BELONGS_TO_RESTAURANT); + } + + this.assertFoodHasSufficientStock(food, item.quantity); + + orderItemsData.push({ + food, + quantity: item.quantity, + unitPrice: food.price || 0, + discount: food.discount || 0, + }); + } + + return orderItemsData; + } + + private assertAdminDeliveryRequirements(dto: AdminCreateOrderDto, delivery: Delivery) { + if (delivery.method === DeliveryMethodEnum.DineIn) { + if (!dto.tableNumber || dto.tableNumber.trim() === '') { + throw new BadRequestException(OrderMessage.TABLE_NUMBER_REQUIRED); + } + } + + if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !dto.userAddress) { + throw new BadRequestException(OrderMessage.ADDRESS_REQUIRED); + } + + if (delivery.method === DeliveryMethodEnum.DeliveryCar && !dto.carAddress) { + throw new BadRequestException(OrderMessage.CAR_ADDRESS_REQUIRED); + } + } + async getStats(restId: string) { return this.em.transactional(async em => { diff --git a/src/modules/users/controllers/users.controller.ts b/src/modules/users/controllers/users.controller.ts index e373525..4b71ed4 100644 --- a/src/modules/users/controllers/users.controller.ts +++ b/src/modules/users/controllers/users.controller.ts @@ -15,6 +15,7 @@ import { UserSuccessMessage } from 'src/common/enums/message.enum'; import { Permissions } from 'src/common/decorators/permissions.decorator'; import { Permission } from 'src/common/enums/permission.enum'; import { WalletService } from '../providers/wallet.service'; +import { FindUserByPhoneDto } from '../dto/find-user-by-phone.dto'; @ApiTags('User') @Controller() @@ -180,4 +181,16 @@ export class UsersController { ) { return this.userService.findAll(restId, query); } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_USERS) + @ApiOperation({ summary: 'Find a user by phone (admin)' }) + @Get('admin/users/search-by-phone') + async findUserByPhone( + @Query(new ValidationPipe({ transform: true, whitelist: true })) + query: FindUserByPhoneDto, + ) { + return this.userService.findByPhone(query.phone); + } } diff --git a/src/modules/users/dto/find-user-by-phone.dto.ts b/src/modules/users/dto/find-user-by-phone.dto.ts new file mode 100644 index 0000000..64de33a --- /dev/null +++ b/src/modules/users/dto/find-user-by-phone.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class FindUserByPhoneDto { + @ApiProperty({ description: 'User phone number' }) + @IsString() + @IsNotEmpty() + phone: string; +} diff --git a/src/modules/users/providers/user.service.ts b/src/modules/users/providers/user.service.ts index bbc2972..e177bd9 100644 --- a/src/modules/users/providers/user.service.ts +++ b/src/modules/users/providers/user.service.ts @@ -68,7 +68,11 @@ export class UserService { async findByPhone(phone: string): Promise { const normalizedPhone = normalizePhone(phone); - return this.userRepository.findOne({ phone: normalizedPhone }); + const user = await this.userRepository.findOne({ phone: normalizedPhone }); + if (!user) { + throw new NotFoundException(`User with phone ${phone} not found.`); + } + return user; } async findById(id: string): Promise {