erp-retail-backend-v2/src/modules/pricing/services/promotion.service.ts
rckrdmrd a6186c4022 Migración desde erp-retail/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:11:34 -06:00

608 lines
17 KiB
TypeScript

import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
Promotion,
PromotionStatus,
PromotionType,
DiscountApplication,
} from '../entities/promotion.entity';
import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity';
export interface PromotionProductInput {
productId: string;
productCode: string;
productName: string;
variantId?: string;
role?: ProductRole;
discountPercent?: number;
discountAmount?: number;
fixedPrice?: number;
minQuantity?: number;
maxQuantity?: number;
bundleQuantity?: number;
getQuantity?: number;
}
export interface CreatePromotionInput {
code: string;
name: string;
description?: string;
type: PromotionType;
discountApplication?: DiscountApplication;
discountPercent?: number;
discountAmount?: number;
maxDiscount?: number;
buyQuantity?: number;
getQuantity?: number;
getDiscountPercent?: number;
quantityTiers?: { minQuantity: number; discountPercent: number }[];
bundlePrice?: number;
startDate: Date;
endDate?: Date;
validDays?: string[];
validHoursStart?: string;
validHoursEnd?: string;
maxUsesTotal?: number;
maxUsesPerCustomer?: number;
minOrderAmount?: number;
minQuantity?: number;
appliesToAllProducts?: boolean;
includedCategories?: string[];
excludedCategories?: string[];
excludedProducts?: string[];
includedBranches?: string[];
customerLevels?: string[];
newCustomersOnly?: boolean;
loyaltyMembersOnly?: boolean;
stackable?: boolean;
stackableWith?: string[];
priority?: number;
displayName?: string;
badgeText?: string;
badgeColor?: string;
imageUrl?: string;
posEnabled?: boolean;
ecommerceEnabled?: boolean;
termsAndConditions?: string;
products?: PromotionProductInput[];
}
export interface PromotionQueryOptions extends QueryOptions {
status?: PromotionStatus;
type?: PromotionType;
branchId?: string;
startDate?: Date;
endDate?: Date;
activeOnly?: boolean;
}
export class PromotionService extends BaseService<Promotion> {
constructor(
repository: Repository<Promotion>,
private readonly productRepository: Repository<PromotionProduct>,
private readonly dataSource: DataSource
) {
super(repository);
}
/**
* Create a new promotion with products
*/
async createPromotion(
tenantId: string,
input: CreatePromotionInput,
userId: string
): Promise<ServiceResult<Promotion>> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Check for duplicate code
const existing = await queryRunner.manager.findOne(Promotion, {
where: { tenantId, code: input.code.toUpperCase() },
});
if (existing) {
return {
success: false,
error: { code: 'DUPLICATE_CODE', message: 'Promotion code already exists' },
};
}
const promotion = queryRunner.manager.create(Promotion, {
tenantId,
code: input.code.toUpperCase(),
name: input.name,
description: input.description,
type: input.type,
status: PromotionStatus.DRAFT,
discountApplication: input.discountApplication ?? DiscountApplication.LINE,
discountPercent: input.discountPercent,
discountAmount: input.discountAmount,
maxDiscount: input.maxDiscount,
buyQuantity: input.buyQuantity,
getQuantity: input.getQuantity,
getDiscountPercent: input.getDiscountPercent,
quantityTiers: input.quantityTiers,
bundlePrice: input.bundlePrice,
startDate: input.startDate,
endDate: input.endDate,
validDays: input.validDays,
validHoursStart: input.validHoursStart,
validHoursEnd: input.validHoursEnd,
maxUsesTotal: input.maxUsesTotal,
maxUsesPerCustomer: input.maxUsesPerCustomer,
minOrderAmount: input.minOrderAmount,
minQuantity: input.minQuantity,
appliesToAllProducts: input.appliesToAllProducts ?? false,
includedCategories: input.includedCategories,
excludedCategories: input.excludedCategories,
excludedProducts: input.excludedProducts,
includedBranches: input.includedBranches,
customerLevels: input.customerLevels,
newCustomersOnly: input.newCustomersOnly ?? false,
loyaltyMembersOnly: input.loyaltyMembersOnly ?? false,
stackable: input.stackable ?? false,
stackableWith: input.stackableWith,
priority: input.priority ?? 0,
displayName: input.displayName,
badgeText: input.badgeText,
badgeColor: input.badgeColor ?? '#ff0000',
imageUrl: input.imageUrl,
posEnabled: input.posEnabled ?? true,
ecommerceEnabled: input.ecommerceEnabled ?? true,
termsAndConditions: input.termsAndConditions,
createdBy: userId,
});
const savedPromotion = await queryRunner.manager.save(promotion);
// Create promotion products
if (input.products && input.products.length > 0) {
for (const productInput of input.products) {
const promotionProduct = queryRunner.manager.create(PromotionProduct, {
tenantId,
promotionId: savedPromotion.id,
productId: productInput.productId,
productCode: productInput.productCode,
productName: productInput.productName,
variantId: productInput.variantId,
role: productInput.role ?? ProductRole.BOTH,
discountPercent: productInput.discountPercent,
discountAmount: productInput.discountAmount,
fixedPrice: productInput.fixedPrice,
minQuantity: productInput.minQuantity ?? 1,
maxQuantity: productInput.maxQuantity,
bundleQuantity: productInput.bundleQuantity ?? 1,
getQuantity: productInput.getQuantity,
});
await queryRunner.manager.save(promotionProduct);
}
}
await queryRunner.commitTransaction();
// Reload with products
const result = await this.repository.findOne({
where: { id: savedPromotion.id, tenantId },
relations: ['products'],
});
return { success: true, data: result! };
} catch (error) {
await queryRunner.rollbackTransaction();
return {
success: false,
error: {
code: 'CREATE_PROMOTION_ERROR',
message: error instanceof Error ? error.message : 'Failed to create promotion',
},
};
} finally {
await queryRunner.release();
}
}
/**
* Update promotion
*/
async updatePromotion(
tenantId: string,
id: string,
input: Partial<CreatePromotionInput>,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
// Don't allow editing active promotions (only some fields)
if (promotion.status === PromotionStatus.ACTIVE) {
const allowedFields = ['endDate', 'maxUsesTotal', 'priority', 'status'];
const inputKeys = Object.keys(input);
const hasDisallowedFields = inputKeys.some(k => !allowedFields.includes(k));
if (hasDisallowedFields) {
return {
success: false,
error: {
code: 'PROMOTION_ACTIVE',
message: 'Cannot modify active promotion. Only endDate, maxUsesTotal, priority, and status can be changed.',
},
};
}
}
// Update allowed fields
const { products, ...updateData } = input;
Object.assign(promotion, updateData, { updatedBy: userId });
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Activate promotion
*/
async activatePromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
relations: ['products'],
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status !== PromotionStatus.DRAFT && promotion.status !== PromotionStatus.SCHEDULED) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Can only activate draft or scheduled promotions' },
};
}
// Validate promotion has required data
if (!promotion.appliesToAllProducts && (!promotion.products || promotion.products.length === 0)) {
if (!promotion.includedCategories || promotion.includedCategories.length === 0) {
return {
success: false,
error: { code: 'NO_PRODUCTS', message: 'Promotion must have products or categories defined' },
};
}
}
const now = new Date();
if (promotion.startDate > now) {
promotion.status = PromotionStatus.SCHEDULED;
} else {
promotion.status = PromotionStatus.ACTIVE;
}
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Pause promotion
*/
async pausePromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status !== PromotionStatus.ACTIVE) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Can only pause active promotions' },
};
}
promotion.status = PromotionStatus.PAUSED;
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* End promotion
*/
async endPromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if ([PromotionStatus.ENDED, PromotionStatus.CANCELLED].includes(promotion.status)) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Promotion is already ended or cancelled' },
};
}
promotion.status = PromotionStatus.ENDED;
promotion.endDate = new Date();
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Cancel promotion
*/
async cancelPromotion(
tenantId: string,
id: string,
userId: string
): Promise<ServiceResult<Promotion>> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status === PromotionStatus.CANCELLED) {
return {
success: false,
error: { code: 'ALREADY_CANCELLED', message: 'Promotion is already cancelled' },
};
}
promotion.status = PromotionStatus.CANCELLED;
promotion.updatedBy = userId;
const saved = await this.repository.save(promotion);
return { success: true, data: saved };
}
/**
* Add products to promotion
*/
async addProducts(
tenantId: string,
promotionId: string,
products: PromotionProductInput[],
userId: string
): Promise<ServiceResult<PromotionProduct[]>> {
const promotion = await this.repository.findOne({
where: { id: promotionId, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status === PromotionStatus.ACTIVE) {
return {
success: false,
error: { code: 'PROMOTION_ACTIVE', message: 'Cannot add products to active promotion' },
};
}
const created: PromotionProduct[] = [];
for (const productInput of products) {
// Check if already exists
const existing = await this.productRepository.findOne({
where: {
tenantId,
promotionId,
productId: productInput.productId,
variantId: productInput.variantId ?? undefined,
},
});
if (existing) continue;
const promotionProduct = this.productRepository.create({
tenantId,
promotionId,
productId: productInput.productId,
productCode: productInput.productCode,
productName: productInput.productName,
variantId: productInput.variantId,
role: productInput.role ?? ProductRole.BOTH,
discountPercent: productInput.discountPercent,
discountAmount: productInput.discountAmount,
fixedPrice: productInput.fixedPrice,
minQuantity: productInput.minQuantity ?? 1,
maxQuantity: productInput.maxQuantity,
bundleQuantity: productInput.bundleQuantity ?? 1,
getQuantity: productInput.getQuantity,
});
const saved = await this.productRepository.save(promotionProduct);
created.push(saved);
}
return { success: true, data: created };
}
/**
* Remove product from promotion
*/
async removeProduct(
tenantId: string,
promotionId: string,
productId: string
): Promise<ServiceResult<boolean>> {
const promotion = await this.repository.findOne({
where: { id: promotionId, tenantId },
});
if (!promotion) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Promotion not found' },
};
}
if (promotion.status === PromotionStatus.ACTIVE) {
return {
success: false,
error: { code: 'PROMOTION_ACTIVE', message: 'Cannot remove products from active promotion' },
};
}
await this.productRepository.delete({
tenantId,
promotionId,
productId,
});
return { success: true, data: true };
}
/**
* Find promotions with filters
*/
async findPromotions(
tenantId: string,
options: PromotionQueryOptions
): Promise<{ data: Promotion[]; total: number }> {
const qb = this.repository.createQueryBuilder('promotion')
.leftJoinAndSelect('promotion.products', 'products')
.where('promotion.tenantId = :tenantId', { tenantId });
if (options.status) {
qb.andWhere('promotion.status = :status', { status: options.status });
}
if (options.type) {
qb.andWhere('promotion.type = :type', { type: options.type });
}
if (options.branchId) {
qb.andWhere('(promotion.includedBranches IS NULL OR promotion.includedBranches @> :branchArray)', {
branchArray: JSON.stringify([options.branchId]),
});
}
if (options.activeOnly) {
const now = new Date();
qb.andWhere('promotion.status = :activeStatus', { activeStatus: PromotionStatus.ACTIVE })
.andWhere('promotion.startDate <= :now', { now })
.andWhere('(promotion.endDate IS NULL OR promotion.endDate >= :now)', { now });
}
if (options.startDate && options.endDate) {
qb.andWhere('promotion.startDate BETWEEN :startDate AND :endDate', {
startDate: options.startDate,
endDate: options.endDate,
});
}
const page = options.page ?? 1;
const limit = options.limit ?? 20;
qb.skip((page - 1) * limit).take(limit);
qb.orderBy('promotion.priority', 'DESC')
.addOrderBy('promotion.createdAt', 'DESC');
const [data, total] = await qb.getManyAndCount();
return { data, total };
}
/**
* Get promotion with products
*/
async getPromotionWithProducts(
tenantId: string,
id: string
): Promise<Promotion | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['products'],
});
}
/**
* Increment usage count
*/
async incrementUsage(
tenantId: string,
id: string
): Promise<void> {
await this.repository.increment(
{ id, tenantId },
'currentUses',
1
);
}
/**
* Get promotion statistics
*/
async getPromotionStats(
tenantId: string,
id: string
): Promise<{
totalUses: number;
totalDiscount: number;
averageDiscount: number;
}> {
const promotion = await this.repository.findOne({
where: { id, tenantId },
});
if (!promotion) {
return { totalUses: 0, totalDiscount: 0, averageDiscount: 0 };
}
// Would need to query order data for actual discount amounts
// For now, return basic usage stats
return {
totalUses: promotion.currentUses,
totalDiscount: 0, // Would calculate from orders
averageDiscount: 0,
};
}
}