refactor cart service and DTOs to improve item handling and delivery method compatibility
This commit is contained in:
@@ -97,8 +97,11 @@ export class CartService {
|
||||
this.validateStock(food, newQuantity);
|
||||
cart.items[existingItemIndex] = {
|
||||
...existingItem,
|
||||
foodTitle: food.title,
|
||||
price: food.price || 0,
|
||||
discount: food.discount || 0,
|
||||
quantity: newQuantity,
|
||||
totalPrice: this.calculateItemTotalPrice(food, newQuantity),
|
||||
totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity),
|
||||
};
|
||||
} else {
|
||||
cart.items.push(this.createCartItem(food, quantity));
|
||||
@@ -135,8 +138,11 @@ export class CartService {
|
||||
|
||||
cart.items[itemIndex] = {
|
||||
...existingItem,
|
||||
foodTitle: food.title,
|
||||
price: food.price || 0,
|
||||
discount: food.discount || 0,
|
||||
quantity: newQuantity,
|
||||
totalPrice: this.calculateItemTotalPrice(food, newQuantity),
|
||||
totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,8 +170,11 @@ export class CartService {
|
||||
this.validateStock(food, newQuantity);
|
||||
cart.items[existingItemIndex] = {
|
||||
...existingItem,
|
||||
foodTitle: food.title,
|
||||
price: food.price || 0,
|
||||
discount: food.discount || 0,
|
||||
quantity: newQuantity,
|
||||
totalPrice: this.calculateItemTotalPrice(food, newQuantity),
|
||||
totalPrice: this.calculateItemTotalPrice(food.price || 0, food.discount || 0, newQuantity),
|
||||
};
|
||||
} else {
|
||||
cart.items.push(this.createCartItem(food, addItemDto.quantity));
|
||||
@@ -323,6 +332,19 @@ export class CartService {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
if (!cart.deliveryMethodId) throw new BadRequestException('Delivery method must be set before setting address');
|
||||
const deliveryMethod = await this.em.findOne(Delivery, {
|
||||
id: cart.deliveryMethodId,
|
||||
restaurant: { id: restaurantId },
|
||||
});
|
||||
if (!deliveryMethod) {
|
||||
throw new NotFoundException(
|
||||
`Delivery method with ID ${cart.deliveryMethodId} not found for restaurant ${restaurantId}`,
|
||||
);
|
||||
}
|
||||
if (deliveryMethod.method !== DeliveryMethodEnum.DeliveryCourier) {
|
||||
throw new BadRequestException('Delivery method must be DeliveryCourier');
|
||||
}
|
||||
|
||||
// Find and validate address belongs to user
|
||||
const address = await this.em.findOne(UserAddress, { id: setAddressDto.addressId }, { populate: ['user'] });
|
||||
if (!address) {
|
||||
@@ -341,9 +363,12 @@ export class CartService {
|
||||
city: address.city,
|
||||
province: address.province || '',
|
||||
postalCode: address.postalCode || undefined,
|
||||
fullName: address.user.firstName + ' ' + address.user.lastName || '',
|
||||
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
||||
phone: address.user.phone,
|
||||
};
|
||||
// DeliveryCourier implies: no car + no dine-in table
|
||||
cart.carAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
cart.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.saveCart(cart);
|
||||
@@ -423,6 +448,7 @@ export class CartService {
|
||||
}
|
||||
|
||||
cart.deliveryMethodId = setDeliveryMethodDto.deliveryMethodId;
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
|
||||
|
||||
// Recalculate cart totals (this will update deliveryFee based on delivery method)
|
||||
await this.recalculateCartTotals(cart);
|
||||
@@ -468,6 +494,9 @@ export class CartService {
|
||||
}
|
||||
|
||||
cart.tableNumber = setTableNumberDto.tableNumber;
|
||||
// DineIn implies: no delivery addresses
|
||||
cart.userAddress = null;
|
||||
cart.carAddress = null;
|
||||
cart.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.saveCart(cart);
|
||||
@@ -507,6 +536,9 @@ export class CartService {
|
||||
plateNumber: setCarDeliveryDto.plateNumber,
|
||||
phone: user.phone,
|
||||
};
|
||||
// DeliveryCar implies: no courier address + no dine-in table
|
||||
cart.userAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
cart.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.saveCart(cart);
|
||||
@@ -523,11 +555,16 @@ export class CartService {
|
||||
let totalItems = 0;
|
||||
|
||||
for (const item of cart.items) {
|
||||
const itemPrice = item.price * item.quantity;
|
||||
const itemDiscount = item.discount * item.quantity;
|
||||
const unitPrice = Number(item.price) || 0;
|
||||
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||
const itemPrice = unitPrice * item.quantity;
|
||||
const itemDiscount = unitDiscount * item.quantity;
|
||||
subTotal += itemPrice;
|
||||
itemsDiscount += itemDiscount;
|
||||
totalItems += item.quantity;
|
||||
|
||||
// Keep item.totalPrice consistent with price/discount/quantity
|
||||
item.totalPrice = itemPrice - itemDiscount;
|
||||
}
|
||||
|
||||
cart.subTotal = subTotal;
|
||||
@@ -537,58 +574,106 @@ export class CartService {
|
||||
let couponDiscount = 0;
|
||||
|
||||
if (cart.coupon) {
|
||||
// Get the full coupon entity to check foodCategories and foods
|
||||
const coupon = await this.couponService.findById(cart.coupon.couponId);
|
||||
|
||||
// Determine which items are eligible for the coupon
|
||||
let eligibleItemsTotal = 0;
|
||||
let eligibleItemsDiscount = 0;
|
||||
|
||||
// If coupon has foodCategories or foods restrictions, filter items
|
||||
const hasCategoryRestriction = coupon.foodCategories && coupon.foodCategories.length > 0;
|
||||
const hasFoodRestriction = coupon.foods && coupon.foods.length > 0;
|
||||
|
||||
if (hasCategoryRestriction || hasFoodRestriction) {
|
||||
// Fetch all foods in cart with their categories
|
||||
const foodIds = cart.items.map(item => item.foodId);
|
||||
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
|
||||
const foodMap = new Map(foods.map(f => [f.id, f]));
|
||||
|
||||
for (const item of cart.items) {
|
||||
const food = foodMap.get(item.foodId);
|
||||
if (!food) continue;
|
||||
|
||||
// Check if item matches coupon criteria
|
||||
const matchesCategory =
|
||||
hasCategoryRestriction && food.category && coupon.foodCategories!.includes(food.category.id);
|
||||
const matchesFood = hasFoodRestriction && coupon.foods!.includes(food.id);
|
||||
|
||||
// Item is eligible if it matches category OR food criteria
|
||||
if (matchesCategory || matchesFood) {
|
||||
const itemPrice = item.price * item.quantity;
|
||||
const itemDiscount = item.discount * item.quantity;
|
||||
eligibleItemsTotal += itemPrice;
|
||||
eligibleItemsDiscount += itemDiscount;
|
||||
}
|
||||
// Get the full coupon entity to check restrictions.
|
||||
// If coupon was deleted/disabled, fail gracefully by removing it from cart.
|
||||
let coupon:
|
||||
| {
|
||||
id: string;
|
||||
type: CouponType;
|
||||
value: number;
|
||||
maxDiscount?: number;
|
||||
isActive?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
foodCategories?: string[];
|
||||
foods?: string[];
|
||||
}
|
||||
} else {
|
||||
// No restrictions, apply to all items
|
||||
eligibleItemsTotal = subTotal;
|
||||
eligibleItemsDiscount = itemsDiscount;
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
const c = await this.couponService.findById(cart.coupon.couponId);
|
||||
coupon = {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
value: c.value,
|
||||
maxDiscount: c.maxDiscount,
|
||||
isActive: c.isActive,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
foodCategories: c.foodCategories,
|
||||
foods: c.foods,
|
||||
};
|
||||
} catch {
|
||||
cart.coupon = undefined;
|
||||
}
|
||||
|
||||
const priceAfterItemDiscount = eligibleItemsTotal - eligibleItemsDiscount;
|
||||
|
||||
if (cart.coupon.type === CouponType.PERCENTAGE) {
|
||||
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
|
||||
// Apply maxDiscount limit if set
|
||||
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
|
||||
discount = cart.coupon.maxDiscount;
|
||||
if (coupon) {
|
||||
// Basic validity checks (applyCoupon already does full validation)
|
||||
const now = new Date();
|
||||
if (coupon.isActive === false) {
|
||||
cart.coupon = undefined;
|
||||
}
|
||||
if (coupon.startDate && now < coupon.startDate) {
|
||||
cart.coupon = undefined;
|
||||
}
|
||||
if (coupon.endDate && now > coupon.endDate) {
|
||||
cart.coupon = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (cart.coupon) {
|
||||
// Determine which items are eligible for the coupon
|
||||
let eligibleItemsTotal = 0;
|
||||
let eligibleItemsDiscount = 0;
|
||||
|
||||
// If coupon has foodCategories or foods restrictions, filter items
|
||||
const hasCategoryRestriction = coupon!.foodCategories && coupon!.foodCategories.length > 0;
|
||||
const hasFoodRestriction = coupon!.foods && coupon!.foods.length > 0;
|
||||
|
||||
if (hasCategoryRestriction || hasFoodRestriction) {
|
||||
// Fetch all foods in cart with their categories
|
||||
const foodIds = cart.items.map(item => item.foodId);
|
||||
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
|
||||
const foodMap = new Map(foods.map(f => [f.id, f]));
|
||||
|
||||
for (const item of cart.items) {
|
||||
const food = foodMap.get(item.foodId);
|
||||
if (!food) continue;
|
||||
|
||||
// Check if item matches coupon criteria
|
||||
const matchesCategory =
|
||||
hasCategoryRestriction && food.category && coupon!.foodCategories!.includes(food.category.id);
|
||||
const matchesFood = hasFoodRestriction && coupon!.foods!.includes(food.id);
|
||||
|
||||
// Item is eligible if it matches category OR food criteria
|
||||
if (matchesCategory || matchesFood) {
|
||||
const unitPrice = Number(item.price) || 0;
|
||||
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||
const itemPrice = unitPrice * item.quantity;
|
||||
const itemDiscount = unitDiscount * item.quantity;
|
||||
eligibleItemsTotal += itemPrice;
|
||||
eligibleItemsDiscount += itemDiscount;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No restrictions, apply to all items
|
||||
eligibleItemsTotal = subTotal;
|
||||
eligibleItemsDiscount = itemsDiscount;
|
||||
}
|
||||
|
||||
const priceAfterItemDiscount = Math.max(0, eligibleItemsTotal - eligibleItemsDiscount);
|
||||
|
||||
if (cart.coupon.type === CouponType.PERCENTAGE) {
|
||||
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
|
||||
// Apply maxDiscount limit if set
|
||||
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
|
||||
discount = cart.coupon.maxDiscount;
|
||||
}
|
||||
couponDiscount = discount;
|
||||
} else {
|
||||
// FIXED amount discount
|
||||
couponDiscount = Math.min(cart.coupon.value, priceAfterItemDiscount);
|
||||
}
|
||||
couponDiscount = discount;
|
||||
} else {
|
||||
// FIXED amount discount
|
||||
couponDiscount = Math.min(cart.coupon.value, priceAfterItemDiscount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +686,7 @@ export class CartService {
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: cart.restaurantId });
|
||||
if (restaurant && restaurant.vat && restaurant.vat > 0) {
|
||||
// Tax is calculated on the price after discounts
|
||||
const priceAfterDiscounts = subTotal - cart.totalDiscount;
|
||||
const priceAfterDiscounts = Math.max(0, subTotal - cart.totalDiscount);
|
||||
tax = (priceAfterDiscounts * Number(restaurant.vat)) / 100;
|
||||
}
|
||||
|
||||
@@ -618,11 +703,41 @@ export class CartService {
|
||||
cart.deliveryFee = deliveryFee;
|
||||
|
||||
// Calculate total: total = subtotal – totalDiscount + tax + deliveryFee
|
||||
cart.total = subTotal - cart.totalDiscount + tax + cart.deliveryFee;
|
||||
cart.total = Math.max(0, subTotal - cart.totalDiscount) + tax + cart.deliveryFee;
|
||||
cart.totalItems = totalItems;
|
||||
cart.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cart fields that are not applicable for a given delivery method.
|
||||
*/
|
||||
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
|
||||
if (method === DeliveryMethodEnum.DineIn) {
|
||||
cart.userAddress = null;
|
||||
cart.carAddress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === DeliveryMethodEnum.CustomerPickup) {
|
||||
cart.userAddress = null;
|
||||
cart.carAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === DeliveryMethodEnum.DeliveryCourier) {
|
||||
cart.carAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === DeliveryMethodEnum.DeliveryCar) {
|
||||
cart.userAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a Cart
|
||||
*/
|
||||
@@ -726,11 +841,11 @@ export class CartService {
|
||||
/**
|
||||
* Calculate total price for a cart item
|
||||
*/
|
||||
private calculateItemTotalPrice(food: Food, quantity: number): number {
|
||||
const itemPrice = food.price || 0;
|
||||
const itemDiscount = food.discount || 0;
|
||||
const itemTotalPrice = itemPrice * quantity;
|
||||
const itemTotalDiscount = itemDiscount * quantity;
|
||||
private calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number {
|
||||
const safeUnitPrice = Number(unitPrice) || 0;
|
||||
const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice);
|
||||
const itemTotalPrice = safeUnitPrice * quantity;
|
||||
const itemTotalDiscount = safeUnitDiscount * quantity;
|
||||
return itemTotalPrice - itemTotalDiscount;
|
||||
}
|
||||
|
||||
@@ -747,7 +862,7 @@ export class CartService {
|
||||
quantity,
|
||||
price: itemPrice,
|
||||
discount: itemDiscount,
|
||||
totalPrice: this.calculateItemTotalPrice(food, quantity),
|
||||
totalPrice: this.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user