608 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|