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 = 'آیتم در سبد خرید یافت نشد', ITEM_NOT_FOUND = 'آیتم در سبد خرید یافت نشد',
DELIVERY_METHOD_NOT_FOUND_FOR_SHOP = 'روش ارسال برای این فروشگاه یافت نشد', DELIVERY_METHOD_NOT_FOUND_FOR_SHOP = 'روش ارسال برای این فروشگاه یافت نشد',
COUPON_CANNOT_BE_APPLIED = 'این کوپن قابل اعمال بر روی آیتم‌های سبد خرید شما نیست. لطفا آیتم‌هایی از دسته‌بندی‌ها یا غذاهای مشخص شده اضافه کنید', COUPON_CANNOT_BE_APPLIED = 'این کوپن قابل اعمال بر روی آیتم‌های سبد خرید شما نیست. لطفا آیتم‌هایی از دسته‌بندی‌ها یا غذاهای مشخص شده اضافه کنید',
MIN_PURCHASE_NOT_MET = 'مقدار درخواستی کمتر از حداقل مجاز خرید این محصول است',
QUANTITY_STEP_MISMATCH = 'مقدار باید مضربی از گام خرید محصول باشد',
PRODUCTS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER = 'تمام محصولات باید قابلیت تحویل پیک داشته باشند. محصولات زیر این قابلیت را ندارند: [productTitles]', PRODUCTS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER = 'تمام محصولات باید قابلیت تحویل پیک داشته باشند. محصولات زیر این قابلیت را ندارند: [productTitles]',
PRODUCT_ONLY_AVAILABLE_DURING_MEAL_TIMES = 'محصول [productTitle] فقط در ساعات وعده‌های غذایی (صبحانه، ناهار، عصرانه یا شام) در دسترس است. زمان فعلی خارج از ساعات وعده‌های غذایی است', PRODUCT_ONLY_AVAILABLE_DURING_MEAL_TIMES = 'محصول [productTitle] فقط در ساعات وعده‌های غذایی (صبحانه، ناهار، عصرانه یا شام) در دسترس است. زمان فعلی خارج از ساعات وعده‌های غذایی است',
PRODUCT_ONLY_AVAILABLE_FOR_MEAL_TYPES = 'محصول [productTitle] فقط برای [allowedMealTypes] در دسترس است. زمان فعلی [mealType] است که با این محصول سازگار نیست', PRODUCT_ONLY_AVAILABLE_FOR_MEAL_TYPES = 'محصول [productTitle] فقط برای [allowedMealTypes] در دسترس است. زمان فعلی [mealType] است که با این محصول سازگار نیست',
@@ -24,14 +24,14 @@ export class CartController {
} }
@Post('variant/:variantId/increment') @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) @ApiHeader(API_HEADER_SLUG)
async incrementItem(@UserId() userId: string, @ShopId() shopId: string, @Param('variantId') variantId: string) { 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') @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) @ApiHeader(API_HEADER_SLUG)
async decrementItem(@UserId() userId: string, @ShopId() shopId: string, @Param('variantId') variantId: string) { async decrementItem(@UserId() userId: string, @ShopId() shopId: string, @Param('variantId') variantId: string) {
return this.cartService.decrementItem(userId, shopId, variantId); return this.cartService.decrementItem(userId, shopId, variantId);
+4 -4
View File
@@ -1,14 +1,14 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { Type } from 'class-transformer';
import { CartMessage } from 'src/common/enums/message.enum'; import { CartMessage } from 'src/common/enums/message.enum';
class AddItemToCartDto { 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() @IsNotEmpty()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsNumber({ maxDecimalPlaces: 3 })
@Min(1) @Min(0.001)
quantity!: number; quantity!: number;
@ApiProperty({ description: 'Product ID' }) @ApiProperty({ description: 'Product ID' })
+4 -4
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; 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'; import { Type } from 'class-transformer';
export class CartItemDto { export class CartItemDto {
@@ -8,11 +8,11 @@ export class CartItemDto {
@IsString() @IsString()
variantId!: string; 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() @IsNotEmpty()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsNumber({ maxDecimalPlaces: 3 })
@Min(1) @Min(0.001)
quantity!: number; quantity!: number;
} }
@@ -6,6 +6,7 @@ import { CartCalculationService } from './cart-calculation.service';
import { CartMessage } from 'src/common/enums/message.enum'; import { CartMessage } from 'src/common/enums/message.enum';
import { ProductService } from 'src/modules/products/providers/product.service'; import { ProductService } from 'src/modules/products/providers/product.service';
import { Variant } from 'src/modules/products/entities/variant.entity'; import { Variant } from 'src/modules/products/entities/variant.entity';
import { getQuantityStep, roundQuantity } from 'src/modules/products/utils/quantity.utils';
@Injectable() @Injectable()
export class CartItemService { export class CartItemService {
@@ -22,16 +23,17 @@ export class CartItemService {
const itemPrice = variant.price || 0; const itemPrice = variant.price || 0;
const itemDiscount = variant.product.discount || 0; const itemDiscount = variant.product.discount || 0;
const image = variant.product.images?.[0]; const image = variant.product.images?.[0];
const normalizedQuantity = roundQuantity(quantity);
return { return {
variantId: variant.id, variantId: variant.id,
productTitle: variant.product.title, productTitle: variant.product.title,
variantValue: variant.value, variantValue: variant.value,
image, image,
quantity, quantity: normalizedQuantity,
price: itemPrice, price: itemPrice,
discount: itemDiscount, 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 itemPrice = variant.price || 0;
const itemDiscount = variant.product.discount || 0; const itemDiscount = variant.product.discount || 0;
const image = variant.product.images?.[0]; const image = variant.product.images?.[0];
const normalizedQuantity = roundQuantity(quantity);
return { return {
...(previous ?? ({} as CartItem)), ...(previous ?? ({} as CartItem)),
variantId: variant.id, variantId: variant.id,
productTitle: variant.product.title, productTitle: variant.product.title,
variantValue: variant.value, variantValue: variant.value,
image, image,
quantity, quantity: normalizedQuantity,
price: itemPrice, price: itemPrice,
discount: itemDiscount, 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 existingItem = cart.items[index];
const newQuantity = existingItem.quantity + quantityToAdd; const newQuantity = roundQuantity(existingItem.quantity + quantityToAdd);
cart.items[index] = this.buildCartItemFromFood(variant, newQuantity, existingItem); 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 * Remove item from cart
*/ */
@@ -109,14 +121,15 @@ export class CartItemService {
} }
const existingItem = cart.items[itemIndex]; 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) { if (newQuantity <= 0) {
cart.items.splice(itemIndex, 1); cart.items.splice(itemIndex, 1);
return; return;
} }
const variant = await this.productService.findOrFailVariant(variantId);
cart.items[itemIndex] = this.buildCartItemFromFood(variant, newQuantity, existingItem); 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 { 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';
import { Variant } from 'src/modules/products/entities/variant.entity'; import { Variant } from 'src/modules/products/entities/variant.entity';
import { getMinQuantity, isValidProductQuantity, roundQuantity } from 'src/modules/products/utils/quantity.utils';
@Injectable() @Injectable()
export class CartValidationService { export class CartValidationService {
@@ -53,6 +54,8 @@ export class CartValidationService {
} }
} }
this.assertValidQuantity(variant, existingQuantityInCart + quantityToAdd);
const maxPurchase = variant.product.maxPurchaseQuantity; const maxPurchase = variant.product.maxPurchaseQuantity;
if (maxPurchase != null && maxPurchase > 0) { if (maxPurchase != null && maxPurchase > 0) {
const productVariantIds = variant.product.variants.getItems().map((v) => v.id); const productVariantIds = variant.product.variants.getItems().map((v) => v.id);
@@ -70,6 +73,20 @@ export class CartValidationService {
return variant; 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 * Validate address belongs to user
@@ -153,6 +153,15 @@ export class CartService {
return cart; 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 * Increment item quantity in cart
*/ */
@@ -12,7 +12,7 @@ export class OrderItem extends BaseEntity {
@ManyToOne(() => Variant) @ManyToOne(() => Variant)
variant!: Variant; variant!: Variant;
@Property({ type: 'int' }) @Property({ type: 'decimal', precision: 10, scale: 3 })
quantity!: number; quantity!: number;
@Property({ type: 'decimal', precision: 10, scale: 0 }) @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 }) @Property({ type: 'decimal', precision: 10, scale: 0 })
total!: number; total!: number;
@Property({ type: 'int', default: 0 }) @Property({ type: 'decimal', precision: 10, scale: 3, default: 0 })
totalItems: number = 0; totalItems: number = 0;
@Property({ type: 'text', nullable: true }) @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 { 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';
import { OrderItemStatus } from '../enum/order-item.enum'; 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 }; 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 validated = await this.validateCartForOrder(userId, shopId, cart);
const order = await this.em.transactional(async em => { 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, { const order = em.create(Order, {
user: validated.user, user: validated.user,
shop: validated.shop, shop: validated.shop,
@@ -83,8 +89,8 @@ export class OrdersService {
total: cart.total || 0, total: cart.total || 0,
totalItems: cart.totalItems || 0, totalItems: cart.totalItems || 0,
description: cart.description, description: cart.description,
status: OrderStatus.PENDING_PAYMENT, status: initialStatus,
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }], history: [{ status: initialStatus, changedAt: new Date() }],
}); });
em.persist(order); em.persist(order);
@@ -101,7 +107,9 @@ export class OrdersService {
unitPrice, unitPrice,
discount, discount,
totalPrice, totalPrice,
status: OrderItemStatus.normal, status: variant.product.needAdminAcceptance
? OrderItemStatus.needConfirmation
: OrderItemStatus.normal,
}); });
em.persist(orderItem); em.persist(orderItem);
@@ -366,11 +374,19 @@ export class OrdersService {
throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK); 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; const maxPurchase = variant.product.maxPurchaseQuantity;
if (maxPurchase != null && maxPurchase > 0) { if (maxPurchase != null && maxPurchase > 0) {
const productId = variant.product.id; const productId = variant.product.id;
const currentProductTotal = productQuantityMap.get(productId) ?? 0; const currentProductTotal = productQuantityMap.get(productId) ?? 0;
const newProductTotal = currentProductTotal + cartItem.quantity; const newProductTotal = currentProductTotal + quantity;
if (newProductTotal > maxPurchase) { if (newProductTotal > maxPurchase) {
throw new BadRequestException(CartMessage.MAX_PURCHASE_EXCEEDED); throw new BadRequestException(CartMessage.MAX_PURCHASE_EXCEEDED);
} }
@@ -379,7 +395,7 @@ export class OrdersService {
orderItemsData.push({ orderItemsData.push({
variant, variant,
quantity: cartItem.quantity, quantity,
unitPrice: variant.price || 0, unitPrice: variant.price || 0,
discount: variant.product.discount || 0, discount: variant.product.discount || 0,
}); });
@@ -490,7 +506,7 @@ export class OrdersService {
SELECT SELECT
f.id as food_id, f.id as food_id,
f.title as food_title, 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 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
+11 -10
View File
@@ -36,7 +36,7 @@ export class CreateVariantDto {
price: number; price: number;
@IsOptional() @IsOptional()
@IsInt() @IsNumber({ maxDecimalPlaces: 3 })
@Min(0) @Min(0)
@Type(() => Number) @Type(() => Number)
@ApiPropertyOptional({ description: 'Available stock; omit or null for unlimited', example: 10 }) @ApiPropertyOptional({ description: 'Available stock; omit or null for unlimited', example: 10 })
@@ -119,17 +119,18 @@ export class CreateProductDto {
order?: number; order?: number;
@IsOptional() @IsOptional()
@IsInt() @IsNumber({ maxDecimalPlaces: 3 })
@Min(1) @Min(0.001)
@Type(() => Number) @Type(() => Number)
@ApiPropertyOptional({ description: 'Maximum quantity per order; omit or null for unlimited', example: 5 }) @ApiPropertyOptional({ description: 'Maximum quantity per order; omit or null for unlimited', example: 5 })
maxPurchaseQuantity?: number | null; maxPurchaseQuantity?: number | null;
@IsOptional() @IsOptional()
@IsString() @IsNumber({ maxDecimalPlaces: 3 })
@ApiPropertyOptional({ example: '1000' }) @Min(0)
@Type(() => String) @Type(() => Number)
purchasePitch?: string | null; @ApiPropertyOptional({ example: 0.5 })
purchasePitch?: number | null;
@IsNotEmpty() @IsNotEmpty()
@IsEnum(PricingType) @IsEnum(PricingType)
@@ -142,10 +143,10 @@ export class CreateProductDto {
purchaseUnit?: PurchaseUnit; purchaseUnit?: PurchaseUnit;
@IsOptional() @IsOptional()
@IsInt() @IsNumber({ maxDecimalPlaces: 3 })
@Min(1) @Min(0.001)
@Type(() => Number) @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; minPurchaseQuantity?: number | null;
} }
@@ -58,10 +58,10 @@ export class Product extends BaseEntity {
/** Maximum quantity per order; null means unlimited */ /** 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; 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; minPurchaseQuantity: number | null = null;
// برای کالا هایی مثل گوشت متغیر میشه // برای کالا هایی مثل گوشت متغیر میشه
@@ -79,7 +79,7 @@ export class Product extends BaseEntity {
nullable: true, nullable: true,
default: null, default: null,
}) })
purchasePitch?: string | null = null; purchasePitch?: number | null = null;
// //
@Property({ type: 'boolean', default: false }) @Property({ type: 'boolean', default: false })
@@ -15,6 +15,6 @@ export class Variant extends BaseEntity {
price?: number; price?: number;
/** Available quantity; null means unlimited */ /** 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; 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;
}