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 { UpdateOrderStatusDto } from '../dto/update-order-status.dto';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
|
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
|
||||||
|
|
||||||
@ApiTags('orders')
|
@ApiTags('orders')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -64,6 +65,15 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/******************** Admin Routes **********************/
|
/******************** 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)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
@Get('admin/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 { Cart } from '../../cart/interfaces/cart.interface';
|
||||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||||
import { PaymentsService } from '../../payments/services/payments.service';
|
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 { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
import { OrderRepository } from '../repositories/order.repository';
|
import { OrderRepository } from '../repositories/order.repository';
|
||||||
import { FindOrdersDto } from '../dto/find-orders.dto';
|
import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||||
@@ -23,6 +23,8 @@ import { StatusTransitionRef } from '../interface/order.interface';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
||||||
import { OrderMessage } from 'src/common/enums/message.enum';
|
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 OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||||
|
|
||||||
type ValidatedCartForOrder = {
|
type ValidatedCartForOrder = {
|
||||||
@@ -140,6 +142,98 @@ export class OrdersService {
|
|||||||
return { paymentUrl, order };
|
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
|
* 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) {
|
async getStats(restId: string) {
|
||||||
return this.em.transactional(async em => {
|
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 { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
import { WalletService } from '../providers/wallet.service';
|
import { WalletService } from '../providers/wallet.service';
|
||||||
|
import { FindUserByPhoneDto } from '../dto/find-user-by-phone.dto';
|
||||||
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -180,4 +181,16 @@ export class UsersController {
|
|||||||
) {
|
) {
|
||||||
return this.userService.findAll(restId, query);
|
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> {
|
async findByPhone(phone: string): Promise<User | null> {
|
||||||
const normalizedPhone = normalizePhone(phone);
|
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> {
|
async findById(id: string): Promise<User | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user