From 410a210404a2f935583cbe40b49936a374ab1855 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 28 Jun 2026 12:58:05 +0330 Subject: [PATCH] update order as admin --- src/common/enums/message.enum.ts | 2 + .../orders/controllers/orders.controller.ts | 17 ++ .../dto/update-order-item-quantity.dto.ts | 16 ++ src/modules/orders/orders.module.ts | 4 +- .../orders/providers/orders.service.ts | 149 ++++++++++++++++++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/modules/orders/dto/update-order-item-quantity.dto.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 39b39c2..a26eb07 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -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 { diff --git a/src/modules/orders/controllers/orders.controller.ts b/src/modules/orders/controllers/orders.controller.ts index faf9373..a946436 100644 --- a/src/modules/orders/controllers/orders.controller.ts +++ b/src/modules/orders/controllers/orders.controller.ts @@ -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' }) diff --git a/src/modules/orders/dto/update-order-item-quantity.dto.ts b/src/modules/orders/dto/update-order-item-quantity.dto.ts new file mode 100644 index 0000000..237b52a --- /dev/null +++ b/src/modules/orders/dto/update-order-item-quantity.dto.ts @@ -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; +} diff --git a/src/modules/orders/orders.module.ts b/src/modules/orders/orders.module.ts index f5e1b03..487fd3d 100644 --- a/src/modules/orders/orders.module.ts +++ b/src/modules/orders/orders.module.ts @@ -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), diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 0f3fb51..003979d 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -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 { + 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 { + 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 { + 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) {