370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { In, Repository } from 'typeorm';
|
|
import {
|
|
DEFAULT_RENEWAL_DAYS,
|
|
daysBetween,
|
|
isVipCustomer,
|
|
parseDate,
|
|
resolveRenewalDays,
|
|
resolveSegment,
|
|
} from '../customers/helpers/customer-segmentation.helper';
|
|
import { SalonCustomer } from '../customers/entities/salon-customer.entity';
|
|
import { Reserve } from '../reserves/entities/reserve.entity';
|
|
import { ReserveStatus } from '../reserves/enums/reserve-status.enum';
|
|
import { ServiceSuggestion } from '../suggestions/entities/service-suggestion.entity';
|
|
import { SuggestionType } from '../suggestions/enums/suggestion-type.enum';
|
|
import {
|
|
BIRTHDAY_UPCOMING_WINDOW_DAYS,
|
|
INACTIVE_CUSTOMER_DAYS_THRESHOLD,
|
|
RENEWAL_REMINDER_LEAD_DAYS,
|
|
} from './constants/campaign-types.constants';
|
|
import { CampaignType } from './enums/campaign-type.enum';
|
|
|
|
export interface SalonCustomerContext {
|
|
customerId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
mobile: string;
|
|
dateOfBirth: string;
|
|
totalPurchase: number;
|
|
visitCount: number;
|
|
lastVisitDate: string | null;
|
|
daysSinceVisit: number | null;
|
|
renewalDays: number;
|
|
daysUntilRenewal: number | null;
|
|
lastServiceTitle: string | null;
|
|
lastServiceSkillIds: string[];
|
|
purchasedServiceSkillIds: Set<string>;
|
|
}
|
|
|
|
export interface CampaignTargetCustomer {
|
|
customerId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
mobile: string;
|
|
totalPurchase: number;
|
|
visitCount: number;
|
|
lastVisitDate: string | null;
|
|
daysSinceVisit: number | null;
|
|
estimatedRevenue: number;
|
|
meta: Record<string, string>;
|
|
}
|
|
|
|
function estimateVisitRevenue(context: {
|
|
totalPurchase: number;
|
|
visitCount: number;
|
|
}): number {
|
|
if (context.visitCount <= 0) {
|
|
return 0;
|
|
}
|
|
return Math.round(context.totalPurchase / context.visitCount);
|
|
}
|
|
|
|
function daysUntilNextBirthday(dateOfBirth: string, now: Date): number {
|
|
const parts = dateOfBirth.split('-').map(Number);
|
|
const month = parts[1];
|
|
const day = parts[2];
|
|
const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
let next = Date.UTC(now.getFullYear(), month - 1, day);
|
|
if (next < todayUtc) {
|
|
next = Date.UTC(now.getFullYear() + 1, month - 1, day);
|
|
}
|
|
|
|
return Math.round((next - todayUtc) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
@Injectable()
|
|
export class CampaignTargetingService {
|
|
constructor(
|
|
@InjectRepository(SalonCustomer)
|
|
private readonly salonCustomersRepository: Repository<SalonCustomer>,
|
|
@InjectRepository(Reserve)
|
|
private readonly reservesRepository: Repository<Reserve>,
|
|
@InjectRepository(ServiceSuggestion)
|
|
private readonly suggestionsRepository: Repository<ServiceSuggestion>,
|
|
) {}
|
|
|
|
async getSalonCustomerContexts(
|
|
salonId: string,
|
|
): Promise<SalonCustomerContext[]> {
|
|
const links = await this.salonCustomersRepository.find({
|
|
where: { salonId },
|
|
relations: { customer: { user: true } },
|
|
});
|
|
|
|
if (links.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const customerIds = links.map((link) => link.customerId);
|
|
const completedReserves = await this.reservesRepository.find({
|
|
where: {
|
|
salonId,
|
|
customerId: In(customerIds),
|
|
status: ReserveStatus.COMPLETED,
|
|
},
|
|
relations: { items: { serviceSkill: true, service: true } },
|
|
order: { appointmentDate: 'ASC' },
|
|
});
|
|
|
|
const reservesByCustomer = new Map<string, Reserve[]>();
|
|
for (const reserve of completedReserves) {
|
|
const list = reservesByCustomer.get(reserve.customerId) ?? [];
|
|
list.push(reserve);
|
|
reservesByCustomer.set(reserve.customerId, list);
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
return links.map((link) => {
|
|
const reserves = reservesByCustomer.get(link.customerId) ?? [];
|
|
const totalPurchase = reserves.reduce(
|
|
(sum, reserve) => sum + reserve.totalAmount,
|
|
0,
|
|
);
|
|
const lastReserve = reserves[reserves.length - 1];
|
|
const lastVisitDate = lastReserve?.appointmentDate ?? null;
|
|
const daysSinceVisit = lastVisitDate
|
|
? daysBetween(parseDate(lastVisitDate), now)
|
|
: null;
|
|
const renewalDays = lastReserve
|
|
? resolveRenewalDays(lastReserve.items)
|
|
: DEFAULT_RENEWAL_DAYS;
|
|
const daysUntilRenewal =
|
|
daysSinceVisit !== null ? renewalDays - daysSinceVisit : null;
|
|
const lastServiceTitle = lastReserve?.items?.[0]?.service?.title ?? null;
|
|
const lastServiceSkillIds = lastReserve
|
|
? [...new Set(lastReserve.items.map((item) => item.serviceSkillId))]
|
|
: [];
|
|
const purchasedServiceSkillIds = new Set(
|
|
reserves.flatMap((reserve) =>
|
|
reserve.items.map((item) => item.serviceSkillId),
|
|
),
|
|
);
|
|
|
|
return {
|
|
customerId: link.customerId,
|
|
firstName: link.customer.user.firstName,
|
|
lastName: link.customer.user.lastName,
|
|
mobile: link.customer.user.mobile,
|
|
dateOfBirth: link.customer.dateOfBirth,
|
|
totalPurchase,
|
|
visitCount: reserves.length,
|
|
lastVisitDate,
|
|
daysSinceVisit,
|
|
renewalDays,
|
|
daysUntilRenewal,
|
|
lastServiceTitle,
|
|
lastServiceSkillIds,
|
|
purchasedServiceSkillIds,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getTargetsForType(
|
|
type: CampaignType,
|
|
salonId: string,
|
|
): Promise<CampaignTargetCustomer[]> {
|
|
const contexts = await this.getSalonCustomerContexts(salonId);
|
|
return this.filterContextsForType(type, contexts);
|
|
}
|
|
|
|
async filterContextsForType(
|
|
type: CampaignType,
|
|
contexts: SalonCustomerContext[],
|
|
): Promise<CampaignTargetCustomer[]> {
|
|
switch (type) {
|
|
case CampaignType.RETURNING_CUSTOMERS:
|
|
return this.buildReturningCustomersTargets(contexts, {
|
|
exactOffsetsOnly: false,
|
|
});
|
|
case CampaignType.BIRTHDAY:
|
|
return this.buildBirthdayTargets(contexts);
|
|
case CampaignType.AT_RISK:
|
|
return this.buildAtRiskTargets(contexts);
|
|
case CampaignType.VIP:
|
|
return this.buildVipTargets(contexts);
|
|
case CampaignType.INACTIVE:
|
|
return this.buildInactiveTargets(contexts);
|
|
case CampaignType.COMPLEMENTARY_SERVICE:
|
|
return this.buildComplementaryServiceTargets(contexts);
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
buildReturningCustomersTargets(
|
|
contexts: SalonCustomerContext[],
|
|
options: { exactOffsetsOnly: boolean },
|
|
): CampaignTargetCustomer[] {
|
|
return contexts
|
|
.filter((context) => context.daysUntilRenewal !== null)
|
|
.filter((context) => {
|
|
const daysUntilRenewal = context.daysUntilRenewal as number;
|
|
if (options.exactOffsetsOnly) {
|
|
return (
|
|
daysUntilRenewal === 0 ||
|
|
daysUntilRenewal === RENEWAL_REMINDER_LEAD_DAYS
|
|
);
|
|
}
|
|
return (
|
|
daysUntilRenewal >= 0 &&
|
|
daysUntilRenewal <= RENEWAL_REMINDER_LEAD_DAYS
|
|
);
|
|
})
|
|
.map((context) =>
|
|
this.toTargetCustomer(context, {
|
|
daysUntilRenewal: String(context.daysUntilRenewal ?? ''),
|
|
lastServiceName: context.lastServiceTitle ?? 'خدمت قبلی',
|
|
}),
|
|
);
|
|
}
|
|
|
|
buildBirthdayTargets(
|
|
contexts: SalonCustomerContext[],
|
|
now: Date = new Date(),
|
|
options: { exactDayOnly?: boolean } = {},
|
|
): CampaignTargetCustomer[] {
|
|
return contexts
|
|
.map((context) => ({
|
|
context,
|
|
daysUntilBirthday: daysUntilNextBirthday(context.dateOfBirth, now),
|
|
}))
|
|
.filter(({ daysUntilBirthday }) =>
|
|
options.exactDayOnly
|
|
? daysUntilBirthday === 0
|
|
: daysUntilBirthday <= BIRTHDAY_UPCOMING_WINDOW_DAYS,
|
|
)
|
|
.map(({ context, daysUntilBirthday }) =>
|
|
this.toTargetCustomer(context, {
|
|
daysUntilBirthday: String(daysUntilBirthday),
|
|
}),
|
|
);
|
|
}
|
|
|
|
buildAtRiskTargets(
|
|
contexts: SalonCustomerContext[],
|
|
): CampaignTargetCustomer[] {
|
|
return contexts
|
|
.filter(
|
|
(context) =>
|
|
context.daysSinceVisit !== null &&
|
|
resolveSegment(context.daysSinceVisit, context.renewalDays) ===
|
|
'at-risk',
|
|
)
|
|
.map((context) =>
|
|
this.toTargetCustomer(context, {
|
|
daysSinceVisit: String(context.daysSinceVisit ?? ''),
|
|
}),
|
|
);
|
|
}
|
|
|
|
buildVipTargets(contexts: SalonCustomerContext[]): CampaignTargetCustomer[] {
|
|
return contexts
|
|
.filter((context) => isVipCustomer(context.totalPurchase))
|
|
.map((context) =>
|
|
this.toTargetCustomer(context, {
|
|
totalPurchase: context.totalPurchase.toLocaleString('fa-IR'),
|
|
}),
|
|
);
|
|
}
|
|
|
|
buildInactiveTargets(
|
|
contexts: SalonCustomerContext[],
|
|
): CampaignTargetCustomer[] {
|
|
return contexts
|
|
.filter(
|
|
(context) =>
|
|
context.daysSinceVisit !== null &&
|
|
context.daysSinceVisit > INACTIVE_CUSTOMER_DAYS_THRESHOLD,
|
|
)
|
|
.map((context) =>
|
|
this.toTargetCustomer(context, {
|
|
daysSinceVisit: String(context.daysSinceVisit ?? ''),
|
|
}),
|
|
);
|
|
}
|
|
|
|
async buildComplementaryServiceTargets(
|
|
contexts: SalonCustomerContext[],
|
|
): Promise<CampaignTargetCustomer[]> {
|
|
const candidateContexts = contexts.filter(
|
|
(context) => context.lastServiceSkillIds.length > 0,
|
|
);
|
|
if (candidateContexts.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const serviceSkillIds = [
|
|
...new Set(
|
|
candidateContexts.flatMap((context) => context.lastServiceSkillIds),
|
|
),
|
|
];
|
|
|
|
const suggestions = await this.suggestionsRepository.find({
|
|
where: {
|
|
serviceSkillId: In(serviceSkillIds),
|
|
isActive: true,
|
|
type: SuggestionType.COMPLEMENTARY,
|
|
},
|
|
relations: { suggestedServiceSkill: { service: true } },
|
|
order: { displayPriority: 'ASC' },
|
|
});
|
|
|
|
const suggestionsBySkill = new Map<string, ServiceSuggestion[]>();
|
|
for (const suggestion of suggestions) {
|
|
const list = suggestionsBySkill.get(suggestion.serviceSkillId) ?? [];
|
|
list.push(suggestion);
|
|
suggestionsBySkill.set(suggestion.serviceSkillId, list);
|
|
}
|
|
|
|
const targets: CampaignTargetCustomer[] = [];
|
|
|
|
for (const context of candidateContexts) {
|
|
const matchingSuggestion = context.lastServiceSkillIds
|
|
.flatMap((skillId) => suggestionsBySkill.get(skillId) ?? [])
|
|
.find(
|
|
(suggestion) =>
|
|
!context.purchasedServiceSkillIds.has(
|
|
suggestion.suggestedServiceSkillId,
|
|
),
|
|
);
|
|
|
|
if (!matchingSuggestion) {
|
|
continue;
|
|
}
|
|
|
|
targets.push(
|
|
this.toTargetCustomer(context, {
|
|
suggestedServiceName:
|
|
matchingSuggestion.suggestedServiceSkill?.service?.title ??
|
|
matchingSuggestion.suggestedServiceSkill?.title ??
|
|
'خدمت مکمل',
|
|
}),
|
|
);
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
private toTargetCustomer(
|
|
context: SalonCustomerContext,
|
|
meta: Record<string, string>,
|
|
): CampaignTargetCustomer {
|
|
return {
|
|
customerId: context.customerId,
|
|
firstName: context.firstName,
|
|
lastName: context.lastName,
|
|
mobile: context.mobile,
|
|
totalPurchase: context.totalPurchase,
|
|
visitCount: context.visitCount,
|
|
lastVisitDate: context.lastVisitDate,
|
|
daysSinceVisit: context.daysSinceVisit,
|
|
estimatedRevenue: estimateVisitRevenue(context),
|
|
meta,
|
|
};
|
|
}
|
|
}
|