This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Order> {
|
||||
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<OrderItemData[]> {
|
||||
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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -68,7 +68,11 @@ export class UserService {
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
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<User | null> {
|
||||
|
||||
Reference in New Issue
Block a user