update order as admin

This commit is contained in:
2026-06-28 12:58:05 +03:30
parent 937c829e48
commit 410a210404
5 changed files with 187 additions and 1 deletions
+2
View File
@@ -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;
}
+2
View File
@@ -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) {