order refactored
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import type { OrderCouponDetail, OrderUserAddress, OrderCarAddress } from 'src/modules/orders/interface/order.interface';
|
import type { OrderCouponDetail, OrderUserAddress } from 'src/modules/orders/interface/order.interface';
|
||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
foodId: string;
|
foodId: string;
|
||||||
@@ -29,7 +29,6 @@ export interface Cart {
|
|||||||
totalDiscount: number;
|
totalDiscount: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|
||||||
carAddress?: OrderCarAddress | null;
|
|
||||||
userAddress?: OrderUserAddress | null;
|
userAddress?: OrderUserAddress | null;
|
||||||
|
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { Product } from ../../../products/entities/product.entity';
|
|
||||||
import { Shop } from ../../../shops/entities/shop.entity';
|
|
||||||
import { UserAddress } from '../../../users/entities/user-address.entity';
|
|
||||||
import { User } from '../../../users/entities/user.entity';
|
|
||||||
import { PaymentMethod } from '../../../payments/entities/payment-method.entity';
|
|
||||||
import { Delivery } from '../../../delivery/entities/delivery.entity';
|
|
||||||
import { DeliveryMethodEnum } from '../../../delivery/interface/delivery';
|
|
||||||
import { PointTransaction } from '../../../users/entities/point-transaction.entity';
|
|
||||||
import { Order } from '../../../orders/entities/order.entity';
|
|
||||||
import { OrderStatus } from '../../../orders/interface/order.interface';
|
|
||||||
import { Cart } from '../interfaces/cart.interface';
|
|
||||||
import { GeographicUtils } from '../utils/geographic.utils';
|
|
||||||
import { CartMessage } from 'src/common/enums/message.enum';
|
|
||||||
import { WalletTransactionRepository } from 'src/modules/users/repositories/wallet-transaction.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CartValidationService {
|
|
||||||
constructor(
|
|
||||||
private readonly em: EntityManager,
|
|
||||||
private readonly walletTransactionRepository: WalletTransactionRepository,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate product exists and belongs to shop
|
|
||||||
*/
|
|
||||||
async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise<Product> {
|
|
||||||
const product = await this.em.findOne(Product, { id: foodId }, { populate: ['shop'] });
|
|
||||||
if (!product) {
|
|
||||||
throw new NotFoundException(CartMessage.FOOD_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product.shop.id !== restaurantId) {
|
|
||||||
throw new BadRequestException(CartMessage.FOOD_NOT_BELONGS_TO_RESTAURANT);
|
|
||||||
}
|
|
||||||
|
|
||||||
return product;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user or throw if not found
|
|
||||||
*/
|
|
||||||
async getUserOrFail(userId: string): Promise<User> {
|
|
||||||
const user = await this.em.findOne(User, { id: userId });
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(CartMessage.USER_NOT_FOUND);
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user address or throw if not found
|
|
||||||
*/
|
|
||||||
async getUserAddressOrFail(addressId: string): Promise<UserAddress> {
|
|
||||||
const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] });
|
|
||||||
if (!address) {
|
|
||||||
throw new NotFoundException(CartMessage.ADDRESS_NOT_FOUND);
|
|
||||||
}
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get product or throw if not found
|
|
||||||
*/
|
|
||||||
async getFoodOrFail(foodId: string): Promise<Product> {
|
|
||||||
const product = await this.em.findOne(Product, { id: foodId });
|
|
||||||
if (!product) {
|
|
||||||
throw new NotFoundException(CartMessage.FOOD_NOT_FOUND);
|
|
||||||
}
|
|
||||||
return product;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get shop or throw if not found
|
|
||||||
*/
|
|
||||||
async getRestaurantOrFail(restaurantId: string): Promise<Shop> {
|
|
||||||
const shop = await this.em.findOne(Shop, { id: restaurantId });
|
|
||||||
if (!shop) {
|
|
||||||
throw new NotFoundException(CartMessage.RESTAURANT_NOT_FOUND);
|
|
||||||
}
|
|
||||||
return shop;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate address belongs to user
|
|
||||||
*/
|
|
||||||
validateAddressOwnership(address: UserAddress, userId: string): void {
|
|
||||||
if (address.user.id !== userId) {
|
|
||||||
throw new BadRequestException(CartMessage.ADDRESS_NOT_BELONGS_TO_USER);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert address is inside shop service area
|
|
||||||
*/
|
|
||||||
async assertAddressInsideServiceArea(
|
|
||||||
restaurantId: string,
|
|
||||||
latitude?: number | null,
|
|
||||||
longitude?: number | null,
|
|
||||||
): Promise<void> {
|
|
||||||
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
|
||||||
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
|
|
||||||
}
|
|
||||||
|
|
||||||
const shop = await this.getRestaurantOrFail(restaurantId);
|
|
||||||
|
|
||||||
const serviceArea = shop.serviceArea;
|
|
||||||
// If no service area is defined, assume service is available everywhere
|
|
||||||
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
|
|
||||||
|
|
||||||
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
|
||||||
const rings = serviceArea.coordinates;
|
|
||||||
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
|
||||||
|
|
||||||
const ring = rings[0];
|
|
||||||
const point: [number, number] = [Number(longitude), Number(latitude)];
|
|
||||||
|
|
||||||
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
|
||||||
const polygon: [number, number][] = ring.map(
|
|
||||||
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
|
|
||||||
throw new BadRequestException(CartMessage.ADDRESS_OUTSIDE_SERVICE_AREA);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get delivery method for shop or throw if not found
|
|
||||||
*/
|
|
||||||
async getDeliveryMethodForRestaurantOrFail(
|
|
||||||
restaurantId: string,
|
|
||||||
deliveryMethodId: string,
|
|
||||||
): Promise<Delivery> {
|
|
||||||
const deliveryMethod = await this.em.findOne(Delivery, {
|
|
||||||
id: deliveryMethodId,
|
|
||||||
shop: { id: restaurantId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!deliveryMethod) {
|
|
||||||
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND_FOR_RESTAURANT);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deliveryMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get enabled delivery method or throw if not found or disabled
|
|
||||||
*/
|
|
||||||
async getEnabledDeliveryMethodOrFail(restaurantId: string, deliveryMethodId: string): Promise<Delivery> {
|
|
||||||
const deliveryMethod = await this.em.findOne(
|
|
||||||
Delivery,
|
|
||||||
{ id: deliveryMethodId, shop: { id: restaurantId } },
|
|
||||||
{ populate: ['shop'] },
|
|
||||||
);
|
|
||||||
if (!deliveryMethod) {
|
|
||||||
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND);
|
|
||||||
}
|
|
||||||
if (!deliveryMethod.enabled) {
|
|
||||||
throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED);
|
|
||||||
}
|
|
||||||
return deliveryMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert delivery method matches expected type
|
|
||||||
*/
|
|
||||||
assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void {
|
|
||||||
if (actual !== expected) {
|
|
||||||
throw new BadRequestException(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require delivery method ID or throw
|
|
||||||
*/
|
|
||||||
requireDeliveryMethodId(cart: Cart, message: string): string {
|
|
||||||
if (!cart.deliveryMethodId) {
|
|
||||||
throw new BadRequestException(message);
|
|
||||||
}
|
|
||||||
return cart.deliveryMethodId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get enabled payment method or throw if not found or disabled
|
|
||||||
*/
|
|
||||||
async getEnabledPaymentMethodOrFail(restaurantId: string, paymentMethodId: string): Promise<PaymentMethod> {
|
|
||||||
const paymentMethod = await this.em.findOne(
|
|
||||||
PaymentMethod,
|
|
||||||
{ id: paymentMethodId, shop: { id: restaurantId } },
|
|
||||||
{ populate: ['shop'] },
|
|
||||||
);
|
|
||||||
if (!paymentMethod) {
|
|
||||||
throw new NotFoundException(CartMessage.PAYMENT_METHOD_NOT_FOUND);
|
|
||||||
}
|
|
||||||
if (!paymentMethod.enabled) {
|
|
||||||
throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_ENABLED);
|
|
||||||
}
|
|
||||||
return paymentMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert wallet has enough balance
|
|
||||||
*/
|
|
||||||
async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
|
|
||||||
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restaurantId);
|
|
||||||
|
|
||||||
if (balance < amount) {
|
|
||||||
throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert coupon usage limit
|
|
||||||
*/
|
|
||||||
async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise<void> {
|
|
||||||
if (!maxUsesPerUser) return;
|
|
||||||
|
|
||||||
const ordersThatUsedTheCouponCount = await this.em.count(Order, {
|
|
||||||
user: { id: userId },
|
|
||||||
couponDetail: { couponId: couponId },
|
|
||||||
status: { $ne: OrderStatus.CANCELED },
|
|
||||||
deletedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ordersThatUsedTheCouponCount >= maxUsesPerUser) {
|
|
||||||
throw new BadRequestException(CartMessage.COUPON_MAX_USES_REACHED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert coupon matches cart if restricted
|
|
||||||
*/
|
|
||||||
async assertCouponMatchesCartIfRestricted(
|
|
||||||
cart: Cart,
|
|
||||||
foodCategories?: string[] | null,
|
|
||||||
products?: string[] | null,
|
|
||||||
): Promise<void> {
|
|
||||||
const categoryRestriction = foodCategories?.filter(Boolean) ?? [];
|
|
||||||
const foodRestriction = products?.filter(Boolean) ?? [];
|
|
||||||
const hasCategoryRestriction = categoryRestriction.length > 0;
|
|
||||||
const hasFoodRestriction = foodRestriction.length > 0;
|
|
||||||
if (!hasCategoryRestriction && !hasFoodRestriction) return;
|
|
||||||
|
|
||||||
const foodMap = await this.getFoodsInCartWithCategories(cart);
|
|
||||||
|
|
||||||
for (const item of cart.items) {
|
|
||||||
const product = foodMap.get(item.foodId);
|
|
||||||
if (!product) continue;
|
|
||||||
|
|
||||||
const matchesCategory = hasCategoryRestriction && product.category && categoryRestriction.includes(product.category.id);
|
|
||||||
const matchesFood = hasFoodRestriction && foodRestriction.includes(product.id);
|
|
||||||
if (matchesCategory || matchesFood) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException(CartMessage.COUPON_CANNOT_BE_APPLIED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get products in cart with categories
|
|
||||||
*/
|
|
||||||
async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Product>> {
|
|
||||||
const foodIds = cart.items.map(item => item.foodId);
|
|
||||||
if (foodIds.length === 0) return new Map();
|
|
||||||
|
|
||||||
const products = await this.em.find(Product, { id: { $in: foodIds } }, { populate: ['category'] });
|
|
||||||
return new Map(products.map(f => [f.id, f]));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ import { CartRepository } from '../repositories/cart.repository';
|
|||||||
import { CartValidationService } from './cart-validation.service';
|
import { CartValidationService } from './cart-validation.service';
|
||||||
import { CartCalculationService } from './cart-calculation.service';
|
import { CartCalculationService } from './cart-calculation.service';
|
||||||
import { CartItemService } from './cart-item.service';
|
import { CartItemService } from './cart-item.service';
|
||||||
import { SetCarDeliveryDto } from '../dto/set-car-delivery.dto';
|
|
||||||
import { CartMessage } from 'src/common/enums/message.enum';
|
import { CartMessage } from 'src/common/enums/message.enum';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -42,34 +41,12 @@ export class CartService {
|
|||||||
|
|
||||||
// Handle each delivery method with its specific requirements
|
// Handle each delivery method with its specific requirements
|
||||||
switch (deliveryMethod.method) {
|
switch (deliveryMethod.method) {
|
||||||
case DeliveryMethodEnum.DineIn:
|
|
||||||
// DineIn requires table number and clears incompatible fields
|
|
||||||
if (!tableNumber) {
|
|
||||||
throw new BadRequestException(CartMessage.TABLE_NUMBER_REQUIRED);
|
|
||||||
}
|
|
||||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
|
||||||
cart.tableNumber = tableNumber;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case DeliveryMethodEnum.CustomerPickup:
|
case DeliveryMethodEnum.CustomerPickup:
|
||||||
// CustomerPickup just clears incompatible fields
|
// CustomerPickup just clears incompatible fields
|
||||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup);
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case DeliveryMethodEnum.DeliveryCar:
|
|
||||||
// DeliveryCar requires carAddress and clears incompatible fields
|
|
||||||
if (!carAddress) {
|
|
||||||
throw new BadRequestException(CartMessage.CAR_ADDRESS_REQUIRED);
|
|
||||||
}
|
|
||||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
|
||||||
const user = await this.validationService.getUserOrFail(userId);
|
|
||||||
cart.carAddress = {
|
|
||||||
carModel: carAddress.carModel,
|
|
||||||
carColor: carAddress.carColor,
|
|
||||||
plateNumber: carAddress.plateNumber,
|
|
||||||
phone: user.phone,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
case DeliveryMethodEnum.DeliveryCourier:
|
case DeliveryMethodEnum.DeliveryCourier:
|
||||||
// DeliveryCourier requires addressId and clears incompatible fields
|
// DeliveryCourier requires addressId and clears incompatible fields
|
||||||
@@ -364,61 +341,6 @@ export class CartService {
|
|||||||
return this.saveTouchedCart(cart);
|
return this.saveTouchedCart(cart);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set table number for cart
|
|
||||||
*/
|
|
||||||
async setTableNumber(userId: string, restaurantId: string, tableNumber: string): Promise<Cart> {
|
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
|
||||||
|
|
||||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
|
||||||
cart,
|
|
||||||
'Delivery method must be set before setting table number',
|
|
||||||
);
|
|
||||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
|
||||||
restaurantId,
|
|
||||||
deliveryMethodId,
|
|
||||||
);
|
|
||||||
this.validationService.assertDeliveryMethod(
|
|
||||||
deliveryMethod.method,
|
|
||||||
DeliveryMethodEnum.DineIn,
|
|
||||||
'Delivery method must be DineIn',
|
|
||||||
);
|
|
||||||
|
|
||||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
|
||||||
cart.tableNumber = tableNumber;
|
|
||||||
return this.saveTouchedCart(cart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set car delivery for cart
|
|
||||||
*/
|
|
||||||
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
|
|
||||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
|
||||||
|
|
||||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
|
||||||
cart,
|
|
||||||
'Delivery method must be set before setting car delivery',
|
|
||||||
);
|
|
||||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
|
||||||
restaurantId,
|
|
||||||
deliveryMethodId,
|
|
||||||
);
|
|
||||||
this.validationService.assertDeliveryMethod(
|
|
||||||
deliveryMethod.method,
|
|
||||||
DeliveryMethodEnum.DeliveryCar,
|
|
||||||
'Delivery method must be DeliveryCar',
|
|
||||||
);
|
|
||||||
const user = await this.validationService.getUserOrFail(userId);
|
|
||||||
|
|
||||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
|
||||||
cart.carAddress = {
|
|
||||||
carModel: setCarDeliveryDto.carModel,
|
|
||||||
carColor: setCarDeliveryDto.carColor,
|
|
||||||
plateNumber: setCarDeliveryDto.plateNumber,
|
|
||||||
phone: user.phone,
|
|
||||||
};
|
|
||||||
return this.saveTouchedCart(cart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear cart from cache
|
* Clear cart from cache
|
||||||
@@ -431,30 +353,20 @@ export class CartService {
|
|||||||
* Clears cart fields that are not applicable for a given delivery method.
|
* Clears cart fields that are not applicable for a given delivery method.
|
||||||
*/
|
*/
|
||||||
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
|
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
|
||||||
if (method === DeliveryMethodEnum.DineIn) {
|
|
||||||
cart.userAddress = null;
|
|
||||||
cart.carAddress = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === DeliveryMethodEnum.CustomerPickup) {
|
if (method === DeliveryMethodEnum.CustomerPickup) {
|
||||||
cart.userAddress = null;
|
cart.userAddress = null;
|
||||||
cart.carAddress = null;
|
|
||||||
cart.tableNumber = undefined;
|
cart.tableNumber = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === DeliveryMethodEnum.DeliveryCourier) {
|
if (method === DeliveryMethodEnum.DeliveryCourier) {
|
||||||
cart.carAddress = null;
|
|
||||||
cart.tableNumber = undefined;
|
cart.tableNumber = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === DeliveryMethodEnum.DeliveryCar) {
|
|
||||||
cart.userAddress = null;
|
|
||||||
cart.tableNumber = undefined;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class OrdersCrone {
|
|||||||
status: PaymentStatusEnum.Pending,
|
status: PaymentStatusEnum.Pending,
|
||||||
createdAt: { $lte: cutoff },
|
createdAt: { $lte: cutoff },
|
||||||
},
|
},
|
||||||
{ populate: ['order', 'order.items', 'order.items.product'] },
|
{ populate: ['order', 'order.items', 'order.items.variant'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!payments || payments.length === 0) {
|
if (!payments || payments.length === 0) {
|
||||||
@@ -51,7 +51,7 @@ export class OrdersCrone {
|
|||||||
const payment = await em.findOne(
|
const payment = await em.findOne(
|
||||||
Payment,
|
Payment,
|
||||||
{ id: p.id },
|
{ id: p.id },
|
||||||
{ populate: ['order', 'order.items', 'order.items.product'] },
|
{ populate: ['order', 'order.items', 'order.items.variant'] },
|
||||||
);
|
);
|
||||||
if (!payment) return;
|
if (!payment) return;
|
||||||
if (payment.status !== PaymentStatusEnum.Pending) return;
|
if (payment.status !== PaymentStatusEnum.Pending) return;
|
||||||
@@ -96,8 +96,6 @@ export class OrdersCrone {
|
|||||||
status: {
|
status: {
|
||||||
$in: [
|
$in: [
|
||||||
OrderStatus.SHIPPED,
|
OrderStatus.SHIPPED,
|
||||||
OrderStatus.DELIVERED_TO_WAITER,
|
|
||||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
updatedAt: { $lte: cutoff },
|
updatedAt: { $lte: cutoff },
|
||||||
@@ -124,8 +122,6 @@ export class OrdersCrone {
|
|||||||
if (
|
if (
|
||||||
![
|
![
|
||||||
OrderStatus.SHIPPED,
|
OrderStatus.SHIPPED,
|
||||||
OrderStatus.DELIVERED_TO_WAITER,
|
|
||||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
|
||||||
].includes(reloadedOrder.status)
|
].includes(reloadedOrder.status)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
|
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { Order } from './order.entity';
|
import { Order } from './order.entity';
|
||||||
import { Product } from '../../products/entities/product.entity';
|
import { Variant } from '../../products/entities/variant.entity';
|
||||||
|
|
||||||
@Entity({ tableName: 'order_items' })
|
@Entity({ tableName: 'order_items' })
|
||||||
@Index({ properties: ['order'] })
|
|
||||||
@Index({ properties: ['product'] })
|
|
||||||
export class OrderItem extends BaseEntity {
|
export class OrderItem extends BaseEntity {
|
||||||
@ManyToOne(() => Order)
|
@ManyToOne(() => Order)
|
||||||
order!: Order;
|
order!: Order;
|
||||||
|
|
||||||
@ManyToOne(() => Product)
|
@ManyToOne(() => Variant)
|
||||||
product!: Product;
|
variant!: Variant;
|
||||||
|
|
||||||
@Property({ type: 'int' })
|
@Property({ type: 'int' })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Shop } from '../../shops/entities/shop.entity';
|
|||||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||||
import { OrderItem } from './order-item.entity';
|
import { OrderItem } from './order-item.entity';
|
||||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
import { OrderUserAddress } from '../interface/order.interface';
|
||||||
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
||||||
|
|
||||||
@Entity({ tableName: 'orders' })
|
@Entity({ tableName: 'orders' })
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export class OrderListeners {
|
|||||||
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
||||||
[OrderStatus.PAID]: 'پرداخت شده',
|
[OrderStatus.PAID]: 'پرداخت شده',
|
||||||
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
||||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
[OrderStatus.DELIVERED]: 'تحویل شده',
|
||||||
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
|
||||||
[OrderStatus.SHIPPED]: 'ارسال شده',
|
[OrderStatus.SHIPPED]: 'ارسال شده',
|
||||||
[OrderStatus.COMPLETED]: 'تکمیل شده',
|
[OrderStatus.COMPLETED]: 'تکمیل شده',
|
||||||
[OrderStatus.CANCELED]: 'لغو شده',
|
[OrderStatus.CANCELED]: 'لغو شده',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { OrderItem } from './entities/order-item.entity';
|
|||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { Shop } from '../shops/entities/shop.entity';
|
import { Shop } from '../shops/entities/shop.entity';
|
||||||
import { Product } from '../products/entities/product.entity';
|
import { Product } from '../products/entities/product.entity';
|
||||||
|
import { Variant } from '../products/entities/variant.entity';
|
||||||
import { UserAddress } from '../users/entities/user-address.entity';
|
import { UserAddress } from '../users/entities/user-address.entity';
|
||||||
import { PaymentMethod } from '../payments/entities/payment-method.entity';
|
import { PaymentMethod } from '../payments/entities/payment-method.entity';
|
||||||
import { CartModule } from '../cart/cart.module';
|
import { CartModule } from '../cart/cart.module';
|
||||||
@@ -20,10 +21,12 @@ import { OrdersCrone } from './crone/order.crone';
|
|||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { UserModule } from '../users/user.module';
|
import { UserModule } from '../users/user.module';
|
||||||
|
import { ShopsModule } from '../shops/shops.module';
|
||||||
|
import { DeliveryModule } from '../delivery/delivery.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MikroOrmModule.forFeature([Order, OrderItem, User, Shop, Product, UserAddress, PaymentMethod]),
|
MikroOrmModule.forFeature([Order, OrderItem, User, Shop, Product, Variant, UserAddress, PaymentMethod]),
|
||||||
CartModule,
|
CartModule,
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -31,7 +34,9 @@ import { UserModule } from '../users/user.module';
|
|||||||
JwtModule,
|
JwtModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
forwardRef(() => UserModule)
|
forwardRef(() => UserModule),
|
||||||
|
ShopsModule,
|
||||||
|
DeliveryModule
|
||||||
],
|
],
|
||||||
controllers: [OrdersController],
|
controllers: [OrdersController],
|
||||||
providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone],
|
providers: [OrdersService, OrderRepository, OrderListeners, OrdersCrone],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Order } from '../entities/order.entity';
|
|||||||
import { OrderItem } from '../entities/order-item.entity';
|
import { OrderItem } from '../entities/order-item.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Shop } from '../../shops/entities/shop.entity';
|
import { Shop } from '../../shops/entities/shop.entity';
|
||||||
import { Product } from '../../products/entities/product.entity';
|
import { Variant } from '../../products/entities/variant.entity';
|
||||||
import { CartService } from '../../cart/providers/cart.service';
|
import { CartService } from '../../cart/providers/cart.service';
|
||||||
import { OrderStatus, OrderUserAddress } from '../interface/order.interface';
|
import { OrderStatus, OrderUserAddress } from '../interface/order.interface';
|
||||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||||
@@ -26,7 +26,7 @@ import { ShopService } from 'src/modules/shops/providers/shops.service';
|
|||||||
import { DeliveryService } from 'src/modules/delivery/providers/delivery.service';
|
import { DeliveryService } from 'src/modules/delivery/providers/delivery.service';
|
||||||
import { PaymentMethodService } from 'src/modules/payments/services/payment-method.service';
|
import { PaymentMethodService } from 'src/modules/payments/services/payment-method.service';
|
||||||
|
|
||||||
type OrderItemData = { product: Product; quantity: number; unitPrice: number; discount: number };
|
type OrderItemData = { variant: Variant; quantity: number; unitPrice: number; discount: number };
|
||||||
|
|
||||||
type ValidatedCartForOrder = {
|
type ValidatedCartForOrder = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -91,14 +91,14 @@ export class OrdersService {
|
|||||||
em.persist(order);
|
em.persist(order);
|
||||||
|
|
||||||
for (const itemData of validated.orderItemsData) {
|
for (const itemData of validated.orderItemsData) {
|
||||||
const { product, quantity, unitPrice, discount } = itemData;
|
const { variant, quantity, unitPrice, discount } = itemData;
|
||||||
|
|
||||||
|
|
||||||
const totalPrice = (unitPrice - discount) * quantity;
|
const totalPrice = (unitPrice - discount) * quantity;
|
||||||
|
|
||||||
const orderItem = em.create(OrderItem, {
|
const orderItem = em.create(OrderItem, {
|
||||||
order,
|
order,
|
||||||
product,
|
variant,
|
||||||
quantity,
|
quantity,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
discount,
|
discount,
|
||||||
@@ -211,7 +211,7 @@ export class OrdersService {
|
|||||||
'paymentMethod',
|
'paymentMethod',
|
||||||
'payments',
|
'payments',
|
||||||
'items',
|
'items',
|
||||||
'items.product',
|
'items.variant',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -366,18 +366,18 @@ export class OrdersService {
|
|||||||
const orderItemsData: OrderItemData[] = [];
|
const orderItemsData: OrderItemData[] = [];
|
||||||
|
|
||||||
for (const cartItem of cart.items) {
|
for (const cartItem of cart.items) {
|
||||||
const product = await this.em.findOne(Product, { id: cartItem.foodId }, { populate: ['shop'] });
|
const variant = await this.em.findOne(Variant, { id: cartItem.foodId }, { populate: ['product', 'product.shop'] });
|
||||||
if (!product) throw new NotFoundException(OrderMessage.PRODUCT_NOT_FOUND);
|
if (!variant) throw new NotFoundException(OrderMessage.PRODUCT_NOT_FOUND);
|
||||||
|
|
||||||
if (product.shop.id !== shopId) {
|
if (variant.product.shop.id !== shopId) {
|
||||||
throw new BadRequestException(OrderMessage.PRODUCT_NOT_BELONGS_TO_SHOP);
|
throw new BadRequestException(OrderMessage.PRODUCT_NOT_BELONGS_TO_SHOP);
|
||||||
}
|
}
|
||||||
|
|
||||||
orderItemsData.push({
|
orderItemsData.push({
|
||||||
product,
|
variant,
|
||||||
quantity: cartItem.quantity,
|
quantity: cartItem.quantity,
|
||||||
unitPrice: product.price || 0,
|
unitPrice: variant.price || 0,
|
||||||
discount: product.discount || 0,
|
discount: variant.product.discount || 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ export class OrdersService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Active products count
|
// 2. Active products count
|
||||||
const activeFoods = await em.count(Product, {
|
const activeFoods = await em.count('Product', {
|
||||||
shop: { id: shopId },
|
shop: { id: shopId },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
@@ -490,7 +490,8 @@ export class OrdersService {
|
|||||||
COALESCE(SUM(oi.total_price), 0)::numeric as total_revenue
|
COALESCE(SUM(oi.total_price), 0)::numeric as total_revenue
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
INNER JOIN orders o ON oi.order_id = o.id
|
INNER JOIN orders o ON oi.order_id = o.id
|
||||||
INNER JOIN products f ON oi.food_id = f.id
|
INNER JOIN variants v ON oi.variant_id = v.id
|
||||||
|
INNER JOIN products f ON v.product_id = f.id
|
||||||
WHERE o.restaurant_id = ?
|
WHERE o.restaurant_id = ?
|
||||||
AND o.created_at >= ?
|
AND o.created_at >= ?
|
||||||
AND o.created_at <= ?
|
AND o.created_at <= ?
|
||||||
|
|||||||
@@ -118,30 +118,30 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
populate: ['user', 'shop', 'deliveryMethod', 'paymentMethod', 'items', 'items.product'] as never,
|
populate: ['user', 'shop', 'deliveryMethod', 'paymentMethod', 'items', 'items.variant'] as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect all (orderId, foodId) pairs for efficient review lookup
|
// Collect all (orderId, variantId) pairs for efficient review lookup
|
||||||
const orderFoodPairs: Array<{ orderId: string; foodId: string }> = [];
|
const orderVariantPairs: Array<{ orderId: string; variantId: string }> = [];
|
||||||
for (const order of data) {
|
for (const order of data) {
|
||||||
for (const item of order.items.getItems()) {
|
for (const item of order.items.getItems()) {
|
||||||
if (item.product?.id) {
|
if (item.variant?.id) {
|
||||||
orderFoodPairs.push({ orderId: order.id, foodId: item.product.id });
|
orderVariantPairs.push({ orderId: order.id, variantId: item.variant.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all relevant reviews in a single query
|
// Fetch all relevant reviews in a single query
|
||||||
const reviewsMap: Map<string, string> = new Map();
|
const reviewsMap: Map<string, string> = new Map();
|
||||||
if (orderFoodPairs.length > 0) {
|
if (orderVariantPairs.length > 0) {
|
||||||
const orderIds = [...new Set(orderFoodPairs.map(p => p.orderId))];
|
const orderIds = [...new Set(orderVariantPairs.map(p => p.orderId))];
|
||||||
const foodIds = [...new Set(orderFoodPairs.map(p => p.foodId))];
|
const variantIds = [...new Set(orderVariantPairs.map(p => p.variantId))];
|
||||||
|
|
||||||
const reviews = await this.em.find(
|
const reviews = await this.em.find(
|
||||||
Review,
|
Review,
|
||||||
{
|
{
|
||||||
order: { id: { $in: orderIds } },
|
order: { id: { $in: orderIds } },
|
||||||
product: { id: { $in: foodIds } },
|
product: { id: { $in: variantIds } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fields: ['id', 'order', 'product'],
|
fields: ['id', 'order', 'product'],
|
||||||
@@ -149,22 +149,23 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a map: key = `${orderId}-${foodId}`, value = reviewId
|
// Create a map: key = `${orderId}-${variantId}`, value = reviewId
|
||||||
|
// Note: Reviews still reference products, so we need to map through variant.product
|
||||||
for (const review of reviews) {
|
for (const review of reviews) {
|
||||||
const key = `${review.order.id}-${review.product.id}`;
|
const key = `${review.order.id}-${review.product.id}`;
|
||||||
reviewsMap.set(key, review.id);
|
reviewsMap.set(key, review.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map reviewIds to products
|
// Map reviewIds to variants (through variant.product for now, as reviews still reference products)
|
||||||
for (const order of data) {
|
for (const order of data) {
|
||||||
for (const item of order.items.getItems()) {
|
for (const item of order.items.getItems()) {
|
||||||
if (item.product) {
|
if (item.variant?.product) {
|
||||||
const key = `${order.id}-${item.product.id}`;
|
const key = `${order.id}-${item.variant.product.id}`;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const product = item.product as any;
|
const variant = item.variant as any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
product.reviewId = reviewsMap.get(key) || null;
|
variant.reviewId = reviewsMap.get(key) || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class ProductRepository extends EntityRepository<Product> {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
populate: ['category'],
|
populate: ['category','variants'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ export class ReviewService {
|
|||||||
throw new NotFoundException('Order not found or does not belong to the current user');
|
throw new NotFoundException('Order not found or does not belong to the current user');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the product is in the order
|
// Verify the product is in the order (check through variant.product)
|
||||||
const orderItem = await this.em.findOne(OrderItem, {
|
const orderItem = await this.em.findOne(OrderItem, {
|
||||||
order: { id: orderId },
|
order: { id: orderId },
|
||||||
product: { id: foodId },
|
variant: { product: { id: foodId } },
|
||||||
|
}, {
|
||||||
|
populate: ['variant', 'variant.product'],
|
||||||
});
|
});
|
||||||
if (!orderItem) {
|
if (!orderItem) {
|
||||||
throw new BadRequestException('Product is not in the specified order');
|
throw new BadRequestException('Product is not in the specified order');
|
||||||
|
|||||||
Reference in New Issue
Block a user