sms-setting and campain
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user