From 937c829e48317182a45d0718b6b855b6821a0016 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 28 Jun 2026 11:35:13 +0330 Subject: [PATCH] update order --- ...gration20260628120000_DecimalQuantities.ts | 21 +++++++++ src/common/enums/message.enum.ts | 2 + .../cart/controllers/cart.controller.ts | 6 +-- src/modules/cart/dto/bulk-add-items.dto.ts | 8 ++-- src/modules/cart/dto/create-cart.dto.ts | 8 ++-- .../cart/providers/cart-item.service.ts | 27 ++++++++--- .../cart/providers/cart-validation.service.ts | 17 +++++++ src/modules/cart/providers/cart.service.ts | 9 ++++ .../orders/entities/order-item.entity.ts | 2 +- src/modules/orders/entities/order.entity.ts | 2 +- .../orders/providers/orders.service.ts | 28 +++++++++--- .../products/dto/create-product.dto.ts | 21 ++++----- .../products/entities/product.entity.ts | 6 +-- .../products/entities/variant.entity.ts | 2 +- src/modules/products/utils/quantity.utils.ts | 45 +++++++++++++++++++ 15 files changed, 164 insertions(+), 40 deletions(-) create mode 100644 database/migrations/Migration20260628120000_DecimalQuantities.ts create mode 100644 src/modules/products/utils/quantity.utils.ts diff --git a/database/migrations/Migration20260628120000_DecimalQuantities.ts b/database/migrations/Migration20260628120000_DecimalQuantities.ts new file mode 100644 index 0000000..86dbf69 --- /dev/null +++ b/database/migrations/Migration20260628120000_DecimalQuantities.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260628120000_DecimalQuantities extends Migration { + + override async up(): Promise { + 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 { + 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;`); + } + +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 8f38bcf..39b39c2 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -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] است که با این محصول سازگار نیست', diff --git a/src/modules/cart/controllers/cart.controller.ts b/src/modules/cart/controllers/cart.controller.ts index 9b2cdd0..b8afc3f 100644 --- a/src/modules/cart/controllers/cart.controller.ts +++ b/src/modules/cart/controllers/cart.controller.ts @@ -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); diff --git a/src/modules/cart/dto/bulk-add-items.dto.ts b/src/modules/cart/dto/bulk-add-items.dto.ts index 4860c4f..1a48e49 100644 --- a/src/modules/cart/dto/bulk-add-items.dto.ts +++ b/src/modules/cart/dto/bulk-add-items.dto.ts @@ -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' }) diff --git a/src/modules/cart/dto/create-cart.dto.ts b/src/modules/cart/dto/create-cart.dto.ts index e00b66b..adf405c 100644 --- a/src/modules/cart/dto/create-cart.dto.ts +++ b/src/modules/cart/dto/create-cart.dto.ts @@ -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; } diff --git a/src/modules/cart/providers/cart-item.service.ts b/src/modules/cart/providers/cart-item.service.ts index ba97f5d..f23b60b 100644 --- a/src/modules/cart/providers/cart-item.service.ts +++ b/src/modules/cart/providers/cart-item.service.ts @@ -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 { + 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); } } diff --git a/src/modules/cart/providers/cart-validation.service.ts b/src/modules/cart/providers/cart-validation.service.ts index 8653f3f..8d7874f 100644 --- a/src/modules/cart/providers/cart-validation.service.ts +++ b/src/modules/cart/providers/cart-validation.service.ts @@ -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 diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index 3da9f9b..2fd6018 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -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 { + const cart = await this.getOrCreateCart(userId, shopId); + await this.itemService.incrementByStep(cart, variantId, shopId); + return this.recalculateAndSaveCart(cart); + } + /** * Increment item quantity in cart */ diff --git a/src/modules/orders/entities/order-item.entity.ts b/src/modules/orders/entities/order-item.entity.ts index 278da53..bf4c95d 100644 --- a/src/modules/orders/entities/order-item.entity.ts +++ b/src/modules/orders/entities/order-item.entity.ts @@ -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 }) diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index 7c7a8be..a86c964 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -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 }) diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 6b8db69..0f3fb51 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -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 diff --git a/src/modules/products/dto/create-product.dto.ts b/src/modules/products/dto/create-product.dto.ts index 7a2fdfc..d7adf2b 100644 --- a/src/modules/products/dto/create-product.dto.ts +++ b/src/modules/products/dto/create-product.dto.ts @@ -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; } diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts index 61316ea..6028375 100644 --- a/src/modules/products/entities/product.entity.ts +++ b/src/modules/products/entities/product.entity.ts @@ -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 }) diff --git a/src/modules/products/entities/variant.entity.ts b/src/modules/products/entities/variant.entity.ts index 84087bc..0212c1a 100644 --- a/src/modules/products/entities/variant.entity.ts +++ b/src/modules/products/entities/variant.entity.ts @@ -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; } diff --git a/src/modules/products/utils/quantity.utils.ts b/src/modules/products/utils/quantity.utils.ts new file mode 100644 index 0000000..c38a521 --- /dev/null +++ b/src/modules/products/utils/quantity.utils.ts @@ -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; +}