update order as admin
This commit is contained in:
@@ -730,6 +730,8 @@ export const enum OrderMessage {
|
||||
PAYMENT_METHOD_NOT_ENABLED = 'روش پرداخت برای این رستوران فعال نیست',
|
||||
PRODUCT_NOT_FOUND = 'محصول یافت نشد',
|
||||
PRODUCT_NOT_BELONGS_TO_SHOP = 'محصول به این فروشگاه تعلق ندارد',
|
||||
ORDER_ITEM_NOT_FOUND = 'آیتم سفارش یافت نشد',
|
||||
ORDER_NOT_EDITABLE = 'سفارش در این وضعیت قابل ویرایش نیست',
|
||||
}
|
||||
|
||||
export const enum CartMessage {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
||||
import { UpdateOrderStatusDto } from '../dto/update-order-status.dto';
|
||||
import { UpdateOrderItemQuantityDto } from '../dto/update-order-item-quantity.dto';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
|
||||
@@ -94,6 +95,22 @@ export class OrdersController {
|
||||
return this.ordersService.changeOrderStatus(orderId, shopId, status, 'admin', dto?.desc);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_ORDERS)
|
||||
@Patch('admin/orders/:orderId/items/:orderItemId/quantity')
|
||||
@ApiOperation({ summary: 'Update order item quantity' })
|
||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||
@ApiParam({ name: 'orderItemId', description: 'Order Item ID' })
|
||||
@ApiBody({ type: UpdateOrderItemQuantityDto })
|
||||
updateOrderItemQuantity(
|
||||
@Param('orderId') orderId: string,
|
||||
@Param('orderItemId') orderItemId: string,
|
||||
@Body() dto: UpdateOrderItemQuantityDto,
|
||||
@ShopId() shopId: string,
|
||||
) {
|
||||
return this.ordersService.updateOrderItemQuantity(orderId, orderItemId, shopId, dto.quantity);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@ApiOperation({ summary: 'Get Stats for report page' })
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateOrderItemQuantityDto {
|
||||
@ApiProperty({
|
||||
description: 'New quantity for the order item (supports decimals for variable products)',
|
||||
example: 2,
|
||||
minimum: 0.001,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 3 })
|
||||
@Min(0.001)
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -23,11 +23,13 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { UserModule } from '../users/user.module';
|
||||
import { ShopsModule } from '../shops/shops.module';
|
||||
import { DeliveryModule } from '../delivery/delivery.module';
|
||||
|
||||
import { CouponModule } from '../coupons/coupon.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Order, OrderItem, User, Shop, Product, Variant, UserAddress, PaymentMethod]),
|
||||
forwardRef(() => CartModule),
|
||||
CouponModule,
|
||||
UtilsModule,
|
||||
AuthModule,
|
||||
forwardRef(() => PaymentsModule),
|
||||
|
||||
@@ -25,6 +25,8 @@ import { DeliveryService } from 'src/modules/delivery/providers/delivery.service
|
||||
import { PaymentMethodService } from 'src/modules/payments/services/payment-method.service';
|
||||
import { OrderItemStatus } from '../enum/order-item.enum';
|
||||
import { getMinQuantity, isValidProductQuantity, roundQuantity } from '../../products/utils/quantity.utils';
|
||||
import { CouponService } from 'src/modules/coupons/providers/coupon.service';
|
||||
import { CouponType } from 'src/modules/coupons/interface/coupon';
|
||||
|
||||
type OrderItemData = { variant: Variant; quantity: number; unitPrice: number; discount: number };
|
||||
|
||||
@@ -61,6 +63,7 @@ export class OrdersService {
|
||||
private readonly paymentMethodService: PaymentMethodService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly userService: UserService,
|
||||
private readonly couponService: CouponService,
|
||||
) { }
|
||||
|
||||
async checkout(userId: string, shopId: string) {
|
||||
@@ -251,7 +254,153 @@ export class OrdersService {
|
||||
);
|
||||
return order;
|
||||
}
|
||||
|
||||
async updateOrderItemQuantity(
|
||||
orderId: string,
|
||||
orderItemId: string,
|
||||
shopId: string,
|
||||
quantity: number,
|
||||
): Promise<Order> {
|
||||
const order = await this.em.findOne(
|
||||
Order,
|
||||
{ id: orderId, shop: { id: shopId } },
|
||||
{
|
||||
populate: [
|
||||
'shop',
|
||||
'items',
|
||||
'items.variant',
|
||||
'items.variant.product',
|
||||
'items.variant.product.shop',
|
||||
'items.variant.product.variants',
|
||||
],
|
||||
},
|
||||
);
|
||||
if (!order) {
|
||||
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
this.assertOrderEditable(order);
|
||||
|
||||
const orderItem = order.items.getItems().find(item => item.id === orderItemId);
|
||||
if (!orderItem) {
|
||||
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
|
||||
}
|
||||
|
||||
const normalizedQuantity = roundQuantity(quantity);
|
||||
this.assertValidOrderItemQuantity(order, orderItem, shopId, normalizedQuantity);
|
||||
|
||||
orderItem.quantity = normalizedQuantity;
|
||||
orderItem.totalPrice = (Number(orderItem.unitPrice) - Number(orderItem.discount)) * normalizedQuantity;
|
||||
|
||||
await this.recalculateOrderTotals(order);
|
||||
await this.em.flush();
|
||||
|
||||
return this.findOne(orderId, shopId);
|
||||
}
|
||||
|
||||
/* Helpers */
|
||||
private assertOrderEditable(order: Order) {
|
||||
if ([OrderStatus.COMPLETED, OrderStatus.CANCELED].includes(order.status)) {
|
||||
throw new BadRequestException(OrderMessage.ORDER_NOT_EDITABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidOrderItemQuantity(
|
||||
order: Order,
|
||||
orderItem: OrderItem,
|
||||
shopId: string,
|
||||
quantity: number,
|
||||
) {
|
||||
const variant = orderItem.variant;
|
||||
|
||||
if (variant.product.shop.id !== shopId) {
|
||||
throw new BadRequestException(OrderMessage.PRODUCT_NOT_BELONGS_TO_SHOP);
|
||||
}
|
||||
|
||||
|
||||
if (quantity < getMinQuantity(variant.product)) {
|
||||
throw new BadRequestException(CartMessage.MIN_PURCHASE_NOT_MET);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async recalculateOrderTotals(order: Order): Promise<void> {
|
||||
let subTotal = 0;
|
||||
let itemsDiscount = 0;
|
||||
let totalItems = 0;
|
||||
|
||||
for (const item of order.items.getItems()) {
|
||||
const unitPrice = Number(item.unitPrice) || 0;
|
||||
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||
subTotal += unitPrice * item.quantity;
|
||||
itemsDiscount += unitDiscount * item.quantity;
|
||||
totalItems += item.quantity;
|
||||
item.totalPrice = (unitPrice - unitDiscount) * item.quantity;
|
||||
}
|
||||
|
||||
order.subTotal = subTotal;
|
||||
order.itemsDiscount = itemsDiscount;
|
||||
order.totalItems = totalItems;
|
||||
order.couponDiscount = await this.calculateOrderCouponDiscount(order);
|
||||
order.totalDiscount = order.couponDiscount + order.itemsDiscount;
|
||||
|
||||
const amountAfterDiscounts = Math.max(0, subTotal - order.totalDiscount);
|
||||
const vat = order.shop?.vat ? Number(order.shop.vat) : 0;
|
||||
order.tax = vat > 0 ? (amountAfterDiscounts * vat) / 100 : 0;
|
||||
order.total = amountAfterDiscounts + order.tax + Number(order.deliveryFee || 0);
|
||||
}
|
||||
|
||||
private async calculateOrderCouponDiscount(order: Order): Promise<number> {
|
||||
const couponDetail = order.couponDetail;
|
||||
if (!couponDetail) return 0;
|
||||
|
||||
let eligibleSubTotal = order.subTotal;
|
||||
let eligibleItemsDiscount = order.itemsDiscount;
|
||||
|
||||
try {
|
||||
const coupon = await this.couponService.findById(couponDetail.couponId);
|
||||
const hasCategoryRestriction = (coupon.productCategories?.length ?? 0) > 0;
|
||||
const hasFoodRestriction = (coupon.products?.length ?? 0) > 0;
|
||||
|
||||
if (hasCategoryRestriction || hasFoodRestriction) {
|
||||
eligibleSubTotal = 0;
|
||||
eligibleItemsDiscount = 0;
|
||||
|
||||
for (const item of order.items.getItems()) {
|
||||
const product = item.variant.product;
|
||||
await this.em.populate(product, ['category']);
|
||||
|
||||
const matchesCategory =
|
||||
hasCategoryRestriction &&
|
||||
product.category &&
|
||||
(coupon.productCategories ?? []).includes(product.category.id);
|
||||
const matchesFood = hasFoodRestriction && (coupon.products ?? []).includes(product.id);
|
||||
|
||||
if (matchesCategory || matchesFood) {
|
||||
const unitPrice = Number(item.unitPrice) || 0;
|
||||
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||
eligibleSubTotal += unitPrice * item.quantity;
|
||||
eligibleItemsDiscount += unitDiscount * item.quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to order-wide totals when coupon lookup fails.
|
||||
}
|
||||
|
||||
const priceAfterItemDiscount = Math.max(0, eligibleSubTotal - eligibleItemsDiscount);
|
||||
|
||||
if (couponDetail.type === CouponType.PERCENTAGE) {
|
||||
let discount = (priceAfterItemDiscount * couponDetail.value) / 100;
|
||||
if (couponDetail.maxDiscount && discount > couponDetail.maxDiscount) {
|
||||
discount = couponDetail.maxDiscount;
|
||||
}
|
||||
return discount;
|
||||
}
|
||||
|
||||
return Math.min(couponDetail.value, priceAfterItemDiscount);
|
||||
}
|
||||
|
||||
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
|
||||
const paymentMethod = order.paymentMethod?.method;
|
||||
if (!paymentMethod) {
|
||||
|
||||
Reference in New Issue
Block a user