order by admin
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-06-03 16:56:40 +03:30
parent 4f8cf60174
commit 17869470ab
6 changed files with 272 additions and 2 deletions
@@ -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;
}
+138 -1
View File
@@ -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;
}
+5 -1
View File
@@ -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> {