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

This commit is contained in:
2026-07-18 20:56:29 +03:30
parent 01ae67248a
commit 17223ced06
11 changed files with 801 additions and 54 deletions
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body } from '@nestjs/common';
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger';
import { OrdersService } from '../providers/orders.service';
import { AuthGuard } from '../../auth/guards/auth.guard';
@@ -13,8 +13,11 @@ import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
@ApiTags('orders')
@ApiBearerAuth()
@@ -94,6 +97,78 @@ export class OrdersController {
return this.ordersService.findOne(orderId, restId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/fees')
@ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminUpdateOrderFeesDto })
updateOrderFees(
@Param('orderId') orderId: string,
@Body() dto: AdminUpdateOrderFeesDto,
@RestId() restId: string,
) {
return this.ordersService.updateOrderFees(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/items')
@ApiOperation({ summary: 'Add an item to an existing order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminAddOrderItemDto })
addOrderItem(
@Param('orderId') orderId: string,
@Body() dto: AdminAddOrderItemDto,
@RestId() restId: string,
) {
return this.ordersService.addOrderItem(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/items/:itemId')
@ApiOperation({ summary: 'Update quantity of an order item' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiParam({ name: 'itemId', description: 'Order item ID' })
@ApiBody({ type: AdminUpdateOrderItemDto })
updateOrderItem(
@Param('orderId') orderId: string,
@Param('itemId') itemId: string,
@Body() dto: AdminUpdateOrderItemDto,
@RestId() restId: string,
) {
return this.ordersService.updateOrderItemQuantity(orderId, restId, itemId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Delete('admin/orders/:orderId/items/:itemId')
@ApiOperation({ summary: 'Remove an item from an order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiParam({ name: 'itemId', description: 'Order item ID' })
removeOrderItem(
@Param('orderId') orderId: string,
@Param('itemId') itemId: string,
@RestId() restId: string,
) {
return this.ordersService.removeOrderItem(orderId, restId, itemId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/refund')
@ApiOperation({ summary: 'Refund order payment(s) by amount' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminRefundOrderDto })
refundOrder(
@Param('orderId') orderId: string,
@Body() dto: AdminRefundOrderDto,
@RestId() restId: string,
) {
return this.ordersService.refundOrder(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/:status')
@@ -114,20 +189,6 @@ export class OrdersController {
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/fees')
@ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminUpdateOrderFeesDto })
updateOrderFees(
@Param('orderId') orderId: string,
@Body() dto: AdminUpdateOrderFeesDto,
@RestId() restId: string,
) {
return this.ordersService.updateOrderFees(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.VIEW_REPORTS)
@ApiOperation({ summary: 'Get Stats for report page' })
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
export class AdminAddOrderItemDto {
@ApiProperty({ description: 'Food ID' })
@IsString()
@IsNotEmpty()
foodId: string;
@ApiProperty({ description: 'Quantity', minimum: 1, default: 1 })
@IsNumber()
@Min(1)
quantity: number;
}
@@ -0,0 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class AdminRefundOrderDto {
@ApiProperty({ description: 'Refund amount', minimum: 1 })
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@ApiPropertyOptional({ description: 'Refund description / reason' })
@IsOptional()
@IsString()
description?: string;
}
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, Min } from 'class-validator';
export class AdminUpdateOrderItemDto {
@ApiProperty({ description: 'New quantity', minimum: 1 })
@IsNumber()
@Min(1)
quantity: number;
}
@@ -21,6 +21,7 @@ export class OrderItem extends BaseEntity {
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
discount: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0 })
totalPrice!: number;
/** (unitPrice - discount) * quantity. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
totalPrice: number = 0;
}
+21 -4
View File
@@ -70,14 +70,17 @@ export class Order extends BaseEntity {
@Property({ type: 'jsonb', nullable: true })
couponDetail?: OrderCouponDetail | null;
/** Sum of item discounts. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
itemsDiscount: number = 0;
/** couponDiscount + itemsDiscount. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
totalDiscount: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0 })
subTotal!: number;
/** Sum of unitPrice * quantity. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
subTotal: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
tax: number = 0;
@@ -91,9 +94,23 @@ export class Order extends BaseEntity {
@Property({ type: 'int', nullable: true })
prepareTime?: number | null;
@Property({ type: 'decimal', precision: 10, scale: 0 })
total!: number;
/** GREATEST(0, subTotal - totalDiscount) + tax + deliveryFee + packingFee. Maintained by DB triggers. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
total: number = 0;
/** Sum of payments with status `paid`. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
paidAmount: number = 0;
/** Remaining amount due (`total - paidAmount`). Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
balance: number = 0;
/** Sum of payments with status `refunded`. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
refundedAmount: number = 0;
/** Sum of item quantities. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'int', default: 0 })
totalItems: number = 0;
+286 -32
View File
@@ -24,12 +24,17 @@ import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food.
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 { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
import { CashShiftsService } from './cash-shifts.service';
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum';
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
type ValidatedCartForOrder = {
@@ -80,19 +85,22 @@ export class OrdersService {
paymentMethod: validated.paymentMethod,
couponDiscount: cart.couponDiscount || 0,
couponDetail: cart.coupon,
itemsDiscount: cart.itemsDiscount || 0,
totalDiscount: cart.totalDiscount || 0,
subTotal: cart.subTotal || 0,
tax: cart.tax || 0,
deliveryFee: cart.deliveryFee || 0,
packingFee: 0,
total: cart.total || 0,
totalItems: cart.totalItems || 0,
description: cart.description,
printInvoice: cart.printInvoice ?? false,
tableNumber: cart.tableNumber,
status: OrderStatus.NEW,
history: [{ status: OrderStatus.NEW, changedAt: new Date() }],
subTotal: 0,
total: 0,
totalItems: 0,
totalDiscount: 0,
itemsDiscount: 0,
paidAmount: 0,
balance: 0,
refundedAmount: 0,
});
em.persist(order);
@@ -102,20 +110,22 @@ export class OrdersService {
this.assertFoodHasSufficientStock(food, quantity);
const totalPrice = (unitPrice - discount) * quantity;
const orderItem = em.create(OrderItem, {
order,
food,
quantity,
unitPrice,
discount,
totalPrice,
totalPrice: 0,
});
em.persist(orderItem);
}
// Flush so DB triggers compute subTotal / total / totalItems from items
await em.flush();
await em.refresh(order);
const payment = em.create(Payment, {
order,
amount: order.total,
@@ -171,18 +181,16 @@ export class OrdersService {
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
const subTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
// Validate admin discount against item totals (not persisted — DB recomputes order money fields)
const itemsSubTotal = 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 packingFee = dto.packingFee ?? 0;
const adminDiscount = dto.discountAmount ?? 0;
const maxDiscount = subTotal - itemsDiscount;
const maxDiscount = itemsSubTotal - itemsDiscount;
if (adminDiscount > maxDiscount) {
throw new BadRequestException(OrderMessage.DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL);
}
const totalDiscount = itemsDiscount + adminDiscount;
const total = subTotal - totalDiscount + deliveryFee + packingFee;
const totalItems = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
const order = await this.em.transactional(async em => {
const order = em.create(Order, {
@@ -194,19 +202,22 @@ export class OrdersService {
paymentMethod,
couponDiscount: adminDiscount,
couponDetail: null,
itemsDiscount,
totalDiscount,
subTotal,
tax: 0,
deliveryFee,
packingFee,
total,
totalItems,
description: dto.description,
printInvoice: false,
tableNumber: dto.tableNumber,
status: OrderStatus.COMPLETED,
history: [{ status: OrderStatus.NEW, changedAt: new Date() }],
subTotal: 0,
total: 0,
totalItems: 0,
totalDiscount: 0,
itemsDiscount: 0,
paidAmount: 0,
balance: 0,
refundedAmount: 0,
});
em.persist(order);
@@ -214,11 +225,13 @@ export class OrdersService {
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 });
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0 });
em.persist(orderItem);
}
await em.flush();
await em.refresh(order);
const payment = em.create(Payment, {
order,
amount: order.total,
@@ -270,20 +283,136 @@ export class OrdersService {
order.prepareTime = dto.prepareTime;
}
// total / balance / pending payment.amount are recalculated by DB triggers
await this.em.persistAndFlush(order);
if (feesChanged) {
order.total = order.subTotal - order.totalDiscount + order.deliveryFee + order.packingFee + order.tax;
const pendingPayment = await this.em.findOne(Payment, {
order: { id: orderId },
status: PaymentStatusEnum.Pending,
});
if (pendingPayment) {
pendingPayment.amount = order.total;
}
await this.em.refresh(order);
}
await this.em.persistAndFlush(order);
return order;
}
async addOrderItem(orderId: string, restId: string, dto: AdminAddOrderItemDto): Promise<Order> {
const order = await this.getOrderWithItemsOrFail(orderId, restId);
this.assertOrderEditable(order);
const existingItem = order.items.getItems().find(item => item.food.id === dto.foodId);
if (existingItem) {
const newQuantity = existingItem.quantity + dto.quantity;
return this.updateOrderItemQuantity(orderId, restId, existingItem.id, { quantity: newQuantity });
}
const [itemData] = await this.buildOrderItemsDataFromDto([{ foodId: dto.foodId, quantity: dto.quantity }], restId);
await this.em.transactional(async em => {
const orderItem = em.create(OrderItem, {
order,
food: itemData.food,
quantity: itemData.quantity,
unitPrice: itemData.unitPrice,
discount: itemData.discount,
totalPrice: 0,
});
em.persist(orderItem);
await this.inventoryService.deductFromInventory(em, {
items: [{ foodId: itemData.food.id, quantity: itemData.quantity }],
});
await em.flush();
});
return this.findOne(orderId, restId);
}
async updateOrderItemQuantity(
orderId: string,
restId: string,
itemId: string,
dto: AdminUpdateOrderItemDto,
): Promise<Order> {
const order = await this.getOrderWithItemsOrFail(orderId, restId);
this.assertOrderEditable(order);
const orderItem = order.items.getItems().find(item => item.id === itemId);
if (!orderItem) {
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
}
if (orderItem.quantity === dto.quantity) {
return this.findOne(orderId, restId);
}
const delta = dto.quantity - orderItem.quantity;
const foodId = orderItem.food.id;
if (delta > 0) {
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['inventory'] });
if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND);
this.assertFoodHasSufficientStock(food, delta);
}
await this.em.transactional(async em => {
orderItem.quantity = dto.quantity;
em.persist(orderItem);
if (delta > 0) {
await this.inventoryService.deductFromInventory(em, {
items: [{ foodId, quantity: delta }],
});
} else {
await this.inventoryService.restoreToInventory(em, {
items: [{ foodId, quantity: Math.abs(delta) }],
});
}
await em.flush();
});
return this.findOne(orderId, restId);
}
async removeOrderItem(orderId: string, restId: string, itemId: string): Promise<Order> {
const order = await this.getOrderWithItemsOrFail(orderId, restId);
this.assertOrderEditable(order);
const items = order.items.getItems();
if (items.length <= 1) {
throw new BadRequestException(OrderMessage.CANNOT_REMOVE_LAST_ORDER_ITEM);
}
const orderItem = items.find(item => item.id === itemId);
if (!orderItem) {
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
}
const foodId = orderItem.food.id;
const quantity = orderItem.quantity;
await this.em.transactional(async em => {
await this.inventoryService.restoreToInventory(em, {
items: [{ foodId, quantity }],
});
em.remove(orderItem);
await em.flush();
});
return this.findOne(orderId, restId);
}
private assertOrderEditable(order: Order) {
if ([OrderStatus.CANCELED, OrderStatus.COMPLETED].includes(order.status)) {
throw new BadRequestException(OrderMessage.CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER);
}
}
private async getOrderWithItemsOrFail(orderId: string, restId: string): Promise<Order> {
const order = await this.em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['items', 'items.food', 'items.food.inventory'] },
);
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
return order;
}
@@ -368,6 +497,7 @@ export class OrdersService {
'carAddress',
'paymentMethod',
'cashShift',
'cashShift.admin',
'payments',
'items',
'items.food',
@@ -414,6 +544,130 @@ export class OrdersService {
);
return order;
}
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
return this.em.transactional(async em => {
const order = await em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['payments', 'user', 'restaurant', 'paymentMethod'] },
);
if (!order) {
throw new NotFoundException(OrderMessage.NOT_FOUND);
}
const paidAmount = Number(order.paidAmount ?? 0);
const balance = Number(order.balance ?? 0);
const isCanceled = order.status === OrderStatus.CANCELED;
const canRefund = balance < 0 || (isCanceled && paidAmount > 0);
if (!canRefund) {
throw new BadRequestException(OrderMessage.REFUND_NOT_ALLOWED);
}
const amount = Number(dto.amount);
if (!amount || amount <= 0) {
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
}
if (amount > paidAmount) {
throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_PAID);
}
if (!isCanceled && balance < 0 && amount > Math.abs(balance)) {
throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_OVERPAYMENT);
}
const paidPayments = order.payments
.getItems()
.filter(p => p.status === PaymentStatusEnum.Paid && Number(p.amount) > 0)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
if (paidPayments.length === 0) {
throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND);
}
let remaining = amount;
let walletRefundAmount = 0;
for (const payment of paidPayments) {
if (remaining <= 0) break;
const paymentAmount = Number(payment.amount);
const refundFromPayment = Math.min(paymentAmount, remaining);
if (refundFromPayment === paymentAmount) {
payment.status = PaymentStatusEnum.Refunded;
if (dto.description) {
payment.description = dto.description;
}
} else {
payment.amount = paymentAmount - refundFromPayment;
const refundPayment = em.create(Payment, {
order,
amount: refundFromPayment,
method: payment.method,
gateway: payment.gateway ?? null,
status: PaymentStatusEnum.Refunded,
description: dto.description ?? null,
referenceId: payment.referenceId ?? null,
transactionId: payment.transactionId ?? null,
});
em.persist(refundPayment);
}
if (payment.method === PaymentMethodEnum.Wallet) {
walletRefundAmount += refundFromPayment;
}
remaining -= refundFromPayment;
em.persist(payment);
}
if (remaining > 0) {
throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND);
}
if (walletRefundAmount > 0) {
if (!order.user) {
throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
}
const latestWalletTx = await em.findOne(
WalletTransaction,
{
user: { id: order.user.id },
restaurant: { id: order.restaurant.id },
},
{ orderBy: { createdAt: 'DESC' } },
);
if (!latestWalletTx) {
throw new NotFoundException(PaymentMessage.REFUND_WALLET_NOT_FOUND);
}
const newBalance = Number(latestWalletTx.balance ?? 0) + walletRefundAmount;
const creditTx = em.create(WalletTransaction, {
user: order.user,
restaurant: order.restaurant,
amount: walletRefundAmount,
type: WalletTransactionType.CREDIT,
reason: WalletTransactionReason.ORDER_REFUND,
balance: newBalance,
});
em.persist(creditTx);
}
await em.flush();
return em.findOneOrFail(
Order,
{ id: orderId },
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
);
});
}
/* Helpers */
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
const paymentMethod = order.paymentMethod?.method;
@@ -124,7 +124,7 @@ export class OrderRepository extends EntityRepository<Order> {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'payments'] as never,
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'cashShift.admin', 'payments'] as never,
});
const totalPages = Math.ceil(total / limit);