update order

This commit is contained in:
2026-06-28 11:35:13 +03:30
parent 9e8a1d4c93
commit 937c829e48
15 changed files with 164 additions and 40 deletions
@@ -0,0 +1,21 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260628120000_DecimalQuantities extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "order_items" alter column "quantity" type numeric(10,3) using "quantity"::numeric(10,3);`);
this.addSql(`alter table "orders" alter column "total_items" type numeric(10,3) using "total_items"::numeric(10,3);`);
this.addSql(`alter table "products" alter column "min_purchase_quantity" type numeric(10,3) using "min_purchase_quantity"::numeric(10,3);`);
this.addSql(`alter table "products" alter column "max_purchase_quantity" type numeric(10,3) using "max_purchase_quantity"::numeric(10,3);`);
this.addSql(`alter table "variants" alter column "stock" type numeric(10,3) using "stock"::numeric(10,3);`);
}
override async down(): Promise<void> {
this.addSql(`alter table "order_items" alter column "quantity" type int using round("quantity")::int;`);
this.addSql(`alter table "orders" alter column "total_items" type int using round("total_items")::int;`);
this.addSql(`alter table "products" alter column "min_purchase_quantity" type int using round("min_purchase_quantity")::int;`);
this.addSql(`alter table "products" alter column "max_purchase_quantity" type int using round("max_purchase_quantity")::int;`);
this.addSql(`alter table "variants" alter column "stock" type int using round("stock")::int;`);
}
}
+2
View File
@@ -761,6 +761,8 @@ export const enum CartMessage {
ITEM_NOT_FOUND = 'آیتم در سبد خرید یافت نشد',
DELIVERY_METHOD_NOT_FOUND_FOR_SHOP = 'روش ارسال برای این فروشگاه یافت نشد',
COUPON_CANNOT_BE_APPLIED = 'این کوپن قابل اعمال بر روی آیتم‌های سبد خرید شما نیست. لطفا آیتم‌هایی از دسته‌بندی‌ها یا غذاهای مشخص شده اضافه کنید',
MIN_PURCHASE_NOT_MET = 'مقدار درخواستی کمتر از حداقل مجاز خرید این محصول است',
QUANTITY_STEP_MISMATCH = 'مقدار باید مضربی از گام خرید محصول باشد',
PRODUCTS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER = 'تمام محصولات باید قابلیت تحویل پیک داشته باشند. محصولات زیر این قابلیت را ندارند: [productTitles]',
PRODUCT_ONLY_AVAILABLE_DURING_MEAL_TIMES = 'محصول [productTitle] فقط در ساعات وعده‌های غذایی (صبحانه، ناهار، عصرانه یا شام) در دسترس است. زمان فعلی خارج از ساعات وعده‌های غذایی است',
PRODUCT_ONLY_AVAILABLE_FOR_MEAL_TYPES = 'محصول [productTitle] فقط برای [allowedMealTypes] در دسترس است. زمان فعلی [mealType] است که با این محصول سازگار نیست',
@@ -24,14 +24,14 @@ export class CartController {
}
@Post('variant/:variantId/increment')
@ApiOperation({ summary: 'Increment item quantity in cart by 1' })
@ApiOperation({ summary: 'Increment item quantity in cart by product purchase step' })
@ApiHeader(API_HEADER_SLUG)
async incrementItem(@UserId() userId: string, @ShopId() shopId: string, @Param('variantId') variantId: string) {
return this.cartService.incrementItem(userId, shopId, variantId, 1);
return this.cartService.incrementItemByStep(userId, shopId, variantId);
}
@Post('variant/:variantId/decrement')
@ApiOperation({ summary: 'Decrement item quantity in cart by 1 (removes item if quantity reaches 0)' })
@ApiOperation({ summary: 'Decrement item quantity by product purchase step (removes item if quantity reaches 0)' })
@ApiHeader(API_HEADER_SLUG)
async decrementItem(@UserId() userId: string, @ShopId() shopId: string, @Param('variantId') variantId: string) {
return this.cartService.decrementItem(userId, shopId, variantId);
+4 -4
View File
@@ -1,14 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, Min, IsString } from 'class-validator';
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsNumber, Min, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { CartMessage } from 'src/common/enums/message.enum';
class AddItemToCartDto {
@ApiProperty({ description: 'Quantity of the product item', example: 1, minimum: 1 })
@ApiProperty({ description: 'Quantity of the product item (supports decimals for variable products)', example: 1.5, minimum: 0.001 })
@IsNotEmpty()
@Type(() => Number)
@IsInt()
@Min(1)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0.001)
quantity!: number;
@ApiProperty({ description: 'Product ID' })
+4 -4
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsArray, ValidateNested, IsInt, Min } from 'class-validator';
import { IsNotEmpty, IsString, IsArray, ValidateNested, IsNumber, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class CartItemDto {
@@ -8,11 +8,11 @@ export class CartItemDto {
@IsString()
variantId!: string;
@ApiProperty({ description: 'Quantity of the product item', example: 2, minimum: 1 })
@ApiProperty({ description: 'Quantity of the product item (supports decimals for variable products)', example: 1.5, minimum: 0.001 })
@IsNotEmpty()
@Type(() => Number)
@IsInt()
@Min(1)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0.001)
quantity!: number;
}
@@ -6,6 +6,7 @@ import { CartCalculationService } from './cart-calculation.service';
import { CartMessage } from 'src/common/enums/message.enum';
import { ProductService } from 'src/modules/products/providers/product.service';
import { Variant } from 'src/modules/products/entities/variant.entity';
import { getQuantityStep, roundQuantity } from 'src/modules/products/utils/quantity.utils';
@Injectable()
export class CartItemService {
@@ -22,16 +23,17 @@ export class CartItemService {
const itemPrice = variant.price || 0;
const itemDiscount = variant.product.discount || 0;
const image = variant.product.images?.[0];
const normalizedQuantity = roundQuantity(quantity);
return {
variantId: variant.id,
productTitle: variant.product.title,
variantValue: variant.value,
image,
quantity,
quantity: normalizedQuantity,
price: itemPrice,
discount: itemDiscount,
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, normalizedQuantity),
};
}
@@ -42,16 +44,17 @@ export class CartItemService {
const itemPrice = variant.price || 0;
const itemDiscount = variant.product.discount || 0;
const image = variant.product.images?.[0];
const normalizedQuantity = roundQuantity(quantity);
return {
...(previous ?? ({} as CartItem)),
variantId: variant.id,
productTitle: variant.product.title,
variantValue: variant.value,
image,
quantity,
quantity: normalizedQuantity,
price: itemPrice,
discount: itemDiscount,
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, normalizedQuantity),
};
}
@@ -83,11 +86,20 @@ export class CartItemService {
}
const existingItem = cart.items[index];
const newQuantity = existingItem.quantity + quantityToAdd;
const newQuantity = roundQuantity(existingItem.quantity + quantityToAdd);
cart.items[index] = this.buildCartItemFromFood(variant, newQuantity, existingItem);
}
/**
* Increment item by product purchase step (1 for fixed, purchasePitch for variable)
*/
async incrementByStep(cart: Cart, variantId: string, shopId: string): Promise<void> {
const variant = await this.productService.findOrFailVariant(variantId);
const step = getQuantityStep(variant.product);
await this.addOrIncrementItem(cart, variantId, shopId, step);
}
/**
* Remove item from cart
*/
@@ -109,14 +121,15 @@ export class CartItemService {
}
const existingItem = cart.items[itemIndex];
const newQuantity = existingItem.quantity - 1;
const variant = await this.productService.findOrFailVariant(variantId);
const step = getQuantityStep(variant.product);
const newQuantity = roundQuantity(existingItem.quantity - step);
if (newQuantity <= 0) {
cart.items.splice(itemIndex, 1);
return;
}
const variant = await this.productService.findOrFailVariant(variantId);
cart.items[itemIndex] = this.buildCartItemFromFood(variant, newQuantity, existingItem);
}
}
@@ -16,6 +16,7 @@ import { ShopService } from 'src/modules/shops/providers/shops.service';
import { DeliveryService } from 'src/modules/delivery/providers/delivery.service';
import { PaymentMethodService } from 'src/modules/payments/services/payment-method.service';
import { Variant } from 'src/modules/products/entities/variant.entity';
import { getMinQuantity, isValidProductQuantity, roundQuantity } from 'src/modules/products/utils/quantity.utils';
@Injectable()
export class CartValidationService {
@@ -53,6 +54,8 @@ export class CartValidationService {
}
}
this.assertValidQuantity(variant, existingQuantityInCart + quantityToAdd);
const maxPurchase = variant.product.maxPurchaseQuantity;
if (maxPurchase != null && maxPurchase > 0) {
const productVariantIds = variant.product.variants.getItems().map((v) => v.id);
@@ -70,6 +73,20 @@ export class CartValidationService {
return variant;
}
assertValidQuantity(variant: Variant, totalQuantity: number): void {
const product = variant.product;
const quantity = roundQuantity(totalQuantity);
if (!isValidProductQuantity(product, quantity)) {
throw new BadRequestException(CartMessage.QUANTITY_STEP_MISMATCH);
}
const minPurchase = getMinQuantity(product);
if (quantity < minPurchase) {
throw new BadRequestException(CartMessage.MIN_PURCHASE_NOT_MET);
}
}
/**
* Validate address belongs to user
@@ -153,6 +153,15 @@ export class CartService {
return cart;
}
/**
* Increment item quantity in cart by product purchase step
*/
async incrementItemByStep(userId: string, shopId: string, variantId: string): Promise<Cart> {
const cart = await this.getOrCreateCart(userId, shopId);
await this.itemService.incrementByStep(cart, variantId, shopId);
return this.recalculateAndSaveCart(cart);
}
/**
* Increment item quantity in cart
*/
@@ -12,7 +12,7 @@ export class OrderItem extends BaseEntity {
@ManyToOne(() => Variant)
variant!: Variant;
@Property({ type: 'int' })
@Property({ type: 'decimal', precision: 10, scale: 3 })
quantity!: number;
@Property({ type: 'decimal', precision: 10, scale: 0 })
+1 -1
View File
@@ -75,7 +75,7 @@ export class Order extends BaseEntity {
@Property({ type: 'decimal', precision: 10, scale: 0 })
total!: number;
@Property({ type: 'int', default: 0 })
@Property({ type: 'decimal', precision: 10, scale: 3, default: 0 })
totalItems: number = 0;
@Property({ type: 'text', nullable: true })
+22 -6
View File
@@ -24,6 +24,7 @@ import { ShopService } from 'src/modules/shops/providers/shops.service';
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';
type OrderItemData = { variant: Variant; quantity: number; unitPrice: number; discount: number };
@@ -67,6 +68,11 @@ export class OrdersService {
const validated = await this.validateCartForOrder(userId, shopId, cart);
const order = await this.em.transactional(async em => {
const needsConfirmation = validated.orderItemsData.some(
({ variant }) => variant.product.needAdminAcceptance,
);
const initialStatus = needsConfirmation ? OrderStatus.NEED_CONFIRMATION : OrderStatus.PENDING_PAYMENT;
const order = em.create(Order, {
user: validated.user,
shop: validated.shop,
@@ -83,8 +89,8 @@ export class OrdersService {
total: cart.total || 0,
totalItems: cart.totalItems || 0,
description: cart.description,
status: OrderStatus.PENDING_PAYMENT,
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
status: initialStatus,
history: [{ status: initialStatus, changedAt: new Date() }],
});
em.persist(order);
@@ -101,7 +107,9 @@ export class OrdersService {
unitPrice,
discount,
totalPrice,
status: OrderItemStatus.normal,
status: variant.product.needAdminAcceptance
? OrderItemStatus.needConfirmation
: OrderItemStatus.normal,
});
em.persist(orderItem);
@@ -366,11 +374,19 @@ export class OrdersService {
throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK);
}
const quantity = roundQuantity(cartItem.quantity);
if (!isValidProductQuantity(variant.product, quantity)) {
throw new BadRequestException(CartMessage.QUANTITY_STEP_MISMATCH);
}
if (quantity < getMinQuantity(variant.product)) {
throw new BadRequestException(CartMessage.MIN_PURCHASE_NOT_MET);
}
const maxPurchase = variant.product.maxPurchaseQuantity;
if (maxPurchase != null && maxPurchase > 0) {
const productId = variant.product.id;
const currentProductTotal = productQuantityMap.get(productId) ?? 0;
const newProductTotal = currentProductTotal + cartItem.quantity;
const newProductTotal = currentProductTotal + quantity;
if (newProductTotal > maxPurchase) {
throw new BadRequestException(CartMessage.MAX_PURCHASE_EXCEEDED);
}
@@ -379,7 +395,7 @@ export class OrdersService {
orderItemsData.push({
variant,
quantity: cartItem.quantity,
quantity,
unitPrice: variant.price || 0,
discount: variant.product.discount || 0,
});
@@ -490,7 +506,7 @@ export class OrdersService {
SELECT
f.id as food_id,
f.title as food_title,
COALESCE(SUM(oi.quantity), 0)::int as total_quantity,
COALESCE(SUM(oi.quantity), 0)::numeric as total_quantity,
COALESCE(SUM(oi.total_price), 0)::numeric as total_revenue
FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
+11 -10
View File
@@ -36,7 +36,7 @@ export class CreateVariantDto {
price: number;
@IsOptional()
@IsInt()
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ description: 'Available stock; omit or null for unlimited', example: 10 })
@@ -119,17 +119,18 @@ export class CreateProductDto {
order?: number;
@IsOptional()
@IsInt()
@Min(1)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0.001)
@Type(() => Number)
@ApiPropertyOptional({ description: 'Maximum quantity per order; omit or null for unlimited', example: 5 })
maxPurchaseQuantity?: number | null;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: '1000' })
@Type(() => String)
purchasePitch?: string | null;
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 0.5 })
purchasePitch?: number | null;
@IsNotEmpty()
@IsEnum(PricingType)
@@ -142,10 +143,10 @@ export class CreateProductDto {
purchaseUnit?: PurchaseUnit;
@IsOptional()
@IsInt()
@Min(1)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0.001)
@Type(() => Number)
@ApiPropertyOptional({ description: 'Minimum quantity per order; omit or null for unlimited', example: 1 })
@ApiPropertyOptional({ description: 'Minimum quantity per order; omit or null for unlimited', example: 0.5 })
minPurchaseQuantity?: number | null;
}
@@ -58,10 +58,10 @@ export class Product extends BaseEntity {
/** Maximum quantity per order; null means unlimited */
@Property({ type: 'int', nullable: true, default: null })
@Property({ type: 'decimal', precision: 10, scale: 3, nullable: true, default: null })
maxPurchaseQuantity: number | null = null;
@Property({ type: 'int', nullable: true, default: null })
@Property({ type: 'decimal', precision: 10, scale: 3, nullable: true, default: null })
minPurchaseQuantity: number | null = null;
// برای کالا هایی مثل گوشت متغیر میشه
@@ -79,7 +79,7 @@ export class Product extends BaseEntity {
nullable: true,
default: null,
})
purchasePitch?: string | null = null;
purchasePitch?: number | null = null;
//
@Property({ type: 'boolean', default: false })
@@ -15,6 +15,6 @@ export class Variant extends BaseEntity {
price?: number;
/** Available quantity; null means unlimited */
@Property({ type: 'int', nullable: true, default: null })
@Property({ type: 'decimal', precision: 10, scale: 3, nullable: true, default: null })
stock: number | null = null;
}
@@ -0,0 +1,45 @@
import { Product } from '../entities/product.entity';
import { PricingType } from '../enum/pricing.enum';
const QUANTITY_SCALE = 3;
const SCALE_FACTOR = 10 ** QUANTITY_SCALE;
export function getQuantityStep(product: Product): number {
if (product.pricingType === PricingType.VARIABLE && product.purchasePitch != null && product.purchasePitch > 0) {
return product.purchasePitch;
}
return 1;
}
export function getMinQuantity(product: Product): number {
if (product.minPurchaseQuantity != null && product.minPurchaseQuantity > 0) {
return product.minPurchaseQuantity;
}
return product.pricingType === PricingType.VARIABLE ? getQuantityStep(product) : 1;
}
export function roundQuantity(value: number): number {
return Math.round(value * SCALE_FACTOR) / SCALE_FACTOR;
}
export function isQuantityAlignedToPitch(quantity: number, pitch: number): boolean {
if (pitch <= 0) return true;
const scaledQty = Math.round(roundQuantity(quantity) * SCALE_FACTOR);
const scaledPitch = Math.round(roundQuantity(pitch) * SCALE_FACTOR);
return scaledQty % scaledPitch === 0;
}
export function isValidProductQuantity(product: Product, quantity: number): boolean {
const qty = roundQuantity(quantity);
if (qty <= 0) return false;
if (product.pricingType === PricingType.FIXED && !Number.isInteger(qty)) {
return false;
}
if (product.pricingType === PricingType.VARIABLE && product.purchasePitch != null && product.purchasePitch > 0) {
return isQuantityAlignedToPitch(qty, product.purchasePitch);
}
return true;
}