Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
858 lines
25 KiB
TypeScript
858 lines
25 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
CreateLoyaltyRewardRequest,
|
|
CreateLoyaltyTierRequest,
|
|
LoyaltyLedgerDto,
|
|
LoyaltyMemberDto,
|
|
LoyaltyMessageResult,
|
|
LoyaltyRewardDto,
|
|
LoyaltySettingsDto,
|
|
LoyaltyStatsDto,
|
|
LoyaltyTierDto,
|
|
UpdateLoyaltyRewardRequest,
|
|
UpdateLoyaltySettingsRequest,
|
|
UpdateLoyaltyTierRequest,
|
|
} from '@mento/shared';
|
|
import ExcelJS from 'exceljs';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { buildSearchWhere, ci } from '../common/search';
|
|
import { type Actor, ownerWhere } from '../common/record-scope';
|
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
|
|
|
const DEFAULT_TIERS = [
|
|
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
|
|
{ name: 'نقرهای', minPoints: 500, color: '#8b95a5' },
|
|
{ name: 'طلایی', minPoints: 2000, color: '#d4a017' },
|
|
] as const;
|
|
|
|
const DEFAULT_REWARDS = [
|
|
{
|
|
name: 'تخفیف ویژه',
|
|
description: 'اعمال روی فاکتور بعدی',
|
|
pointsCost: 500,
|
|
color: '#14b8a6',
|
|
},
|
|
{
|
|
name: 'مشاوره رایگان',
|
|
description: 'یک جلسه مشاوره اختصاصی',
|
|
pointsCost: 1000,
|
|
color: '#8b5cf6',
|
|
},
|
|
{
|
|
name: 'هدیه باشگاه',
|
|
description: 'هدیه منتخب باشگاه مشتریان',
|
|
pointsCost: 2000,
|
|
color: '#eab308',
|
|
},
|
|
] as const;
|
|
|
|
const DAY_MS = 86_400_000;
|
|
|
|
@Injectable()
|
|
export class LoyaltyService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly sms: SmsGatewayService,
|
|
) {}
|
|
|
|
async ensureDefaults(organizationId: string): Promise<void> {
|
|
await Promise.all([
|
|
this.ensureDefaultTiers(organizationId),
|
|
this.ensureDefaultRewards(organizationId),
|
|
this.ensureSettings(organizationId),
|
|
]);
|
|
const unassigned = await this.prisma.person.count({
|
|
where: { organizationId, loyaltyTierId: null },
|
|
});
|
|
if (unassigned > 0) await this.recomputeAllTiers(organizationId);
|
|
}
|
|
|
|
async ensureDefaultTiers(organizationId: string): Promise<void> {
|
|
const count = await this.prisma.loyaltyTier.count({
|
|
where: { organizationId },
|
|
});
|
|
if (count > 0) return;
|
|
await this.prisma.loyaltyTier.createMany({
|
|
data: DEFAULT_TIERS.map((t, position) => ({
|
|
organizationId,
|
|
name: t.name,
|
|
minPoints: t.minPoints,
|
|
color: t.color,
|
|
position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
async ensureDefaultRewards(organizationId: string): Promise<void> {
|
|
const count = await this.prisma.loyaltyReward.count({
|
|
where: { organizationId },
|
|
});
|
|
if (count > 0) return;
|
|
await this.prisma.loyaltyReward.createMany({
|
|
data: DEFAULT_REWARDS.map((r, position) => ({
|
|
organizationId,
|
|
name: r.name,
|
|
description: r.description,
|
|
pointsCost: r.pointsCost,
|
|
color: r.color,
|
|
position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
async ensureSettings(organizationId: string) {
|
|
const existing = await this.prisma.loyaltySettings.findUnique({
|
|
where: { organizationId },
|
|
});
|
|
if (existing) return existing;
|
|
return this.prisma.loyaltySettings.create({
|
|
data: { organizationId },
|
|
});
|
|
}
|
|
|
|
async getSettings(organizationId: string): Promise<LoyaltySettingsDto> {
|
|
const row = await this.ensureSettings(organizationId);
|
|
return {
|
|
enabled: row.enabled,
|
|
welcomePoints: row.welcomePoints,
|
|
notifyOnAdjust: row.notifyOnAdjust,
|
|
};
|
|
}
|
|
|
|
async updateSettings(
|
|
organizationId: string,
|
|
dto: UpdateLoyaltySettingsRequest,
|
|
): Promise<LoyaltySettingsDto> {
|
|
await this.ensureSettings(organizationId);
|
|
const row = await this.prisma.loyaltySettings.update({
|
|
where: { organizationId },
|
|
data: {
|
|
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
|
|
...(dto.welcomePoints !== undefined
|
|
? { welcomePoints: Math.max(0, Math.floor(dto.welcomePoints)) }
|
|
: {}),
|
|
...(dto.notifyOnAdjust !== undefined
|
|
? { notifyOnAdjust: dto.notifyOnAdjust }
|
|
: {}),
|
|
},
|
|
});
|
|
return {
|
|
enabled: row.enabled,
|
|
welcomePoints: row.welcomePoints,
|
|
notifyOnAdjust: row.notifyOnAdjust,
|
|
};
|
|
}
|
|
|
|
async getStats(actor: Actor): Promise<LoyaltyStatsDto> {
|
|
await this.ensureDefaults(actor.organizationId);
|
|
const scope = {
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
};
|
|
const now = new Date();
|
|
const monthStart = startOfMonth(now);
|
|
const prevMonthStart = startOfMonth(addMonths(monthStart, -1));
|
|
const prevMonthEnd = monthStart;
|
|
const retentionFrom = new Date(now.getTime() - 90 * DAY_MS);
|
|
const prevRetentionEnd = monthStart;
|
|
const prevRetentionFrom = new Date(prevRetentionEnd.getTime() - 90 * DAY_MS);
|
|
|
|
const [
|
|
memberCount,
|
|
membersAtMonthStart,
|
|
pointsThis,
|
|
pointsPrev,
|
|
rewardsThis,
|
|
rewardsPrev,
|
|
people,
|
|
] = await Promise.all([
|
|
this.prisma.person.count({ where: scope }),
|
|
this.prisma.person.count({
|
|
where: { ...scope, createdAt: { lt: monthStart } },
|
|
}),
|
|
this.prisma.loyaltyLedger.aggregate({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
delta: { gt: 0 },
|
|
createdAt: { gte: monthStart },
|
|
},
|
|
_sum: { delta: true },
|
|
}),
|
|
this.prisma.loyaltyLedger.aggregate({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
delta: { gt: 0 },
|
|
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
|
|
},
|
|
_sum: { delta: true },
|
|
}),
|
|
this.prisma.loyaltyRedemption.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
createdAt: { gte: monthStart },
|
|
},
|
|
}),
|
|
this.prisma.loyaltyRedemption.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
|
|
},
|
|
}),
|
|
this.prisma.person.findMany({
|
|
where: scope,
|
|
select: { id: true, updatedAt: true },
|
|
}),
|
|
]);
|
|
|
|
const lastMap = await this.lastActivityMap(
|
|
actor.organizationId,
|
|
people.map((p) => p.id),
|
|
);
|
|
const activeNow = people.filter((p) => {
|
|
const at = lastMap.get(p.id) ?? p.updatedAt;
|
|
return at >= retentionFrom;
|
|
}).length;
|
|
const activePrev = people.filter((p) => {
|
|
const at = lastMap.get(p.id) ?? p.updatedAt;
|
|
return at >= prevRetentionFrom && at < prevRetentionEnd;
|
|
}).length;
|
|
|
|
const retentionRate = memberCount
|
|
? Math.round((activeNow / memberCount) * 100)
|
|
: 0;
|
|
const prevRetention = membersAtMonthStart
|
|
? Math.round((activePrev / membersAtMonthStart) * 100)
|
|
: 0;
|
|
|
|
const pointsIssued = pointsThis._sum.delta ?? 0;
|
|
const pointsPrevVal = pointsPrev._sum.delta ?? 0;
|
|
|
|
return {
|
|
memberCount,
|
|
memberDeltaPct: pctChange(memberCount, membersAtMonthStart),
|
|
pointsIssued,
|
|
pointsIssuedDeltaPct: pctChange(pointsIssued, pointsPrevVal),
|
|
rewardsUsed: rewardsThis,
|
|
rewardsUsedDeltaPct: pctChange(rewardsThis, rewardsPrev),
|
|
retentionRate,
|
|
retentionDeltaPct: pctChange(retentionRate, prevRetention),
|
|
};
|
|
}
|
|
|
|
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
|
|
await this.ensureDefaults(organizationId);
|
|
const rows = await this.prisma.loyaltyTier.findMany({
|
|
where: { organizationId },
|
|
orderBy: [{ minPoints: 'desc' }, { position: 'asc' }],
|
|
include: { _count: { select: { people: true } } },
|
|
});
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
minPoints: r.minPoints,
|
|
position: r.position,
|
|
color: r.color,
|
|
memberCount: r._count.people,
|
|
}));
|
|
}
|
|
|
|
async createTier(
|
|
organizationId: string,
|
|
dto: CreateLoyaltyTierRequest,
|
|
): Promise<LoyaltyTierDto> {
|
|
await this.ensureDefaults(organizationId);
|
|
const maxPos = await this.prisma.loyaltyTier.aggregate({
|
|
where: { organizationId },
|
|
_max: { position: true },
|
|
});
|
|
const row = await this.prisma.loyaltyTier.create({
|
|
data: {
|
|
organizationId,
|
|
name: dto.name.trim(),
|
|
minPoints: Math.max(0, Math.floor(dto.minPoints)),
|
|
color: emptyToNull(dto.color),
|
|
position: (maxPos._max.position ?? -1) + 1,
|
|
},
|
|
});
|
|
await this.recomputeAllTiers(organizationId);
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
minPoints: row.minPoints,
|
|
position: row.position,
|
|
color: row.color,
|
|
memberCount: 0,
|
|
};
|
|
}
|
|
|
|
async updateTier(
|
|
organizationId: string,
|
|
id: string,
|
|
dto: UpdateLoyaltyTierRequest,
|
|
): Promise<LoyaltyTierDto> {
|
|
const existing = await this.prisma.loyaltyTier.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!existing) throw new NotFoundException('سطح پیدا نشد');
|
|
|
|
const row = await this.prisma.loyaltyTier.update({
|
|
where: { id },
|
|
data: {
|
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
|
...(dto.minPoints !== undefined
|
|
? { minPoints: Math.max(0, Math.floor(dto.minPoints)) }
|
|
: {}),
|
|
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
|
|
},
|
|
include: { _count: { select: { people: true } } },
|
|
});
|
|
await this.recomputeAllTiers(organizationId);
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
minPoints: row.minPoints,
|
|
position: row.position,
|
|
color: row.color,
|
|
memberCount: row._count.people,
|
|
};
|
|
}
|
|
|
|
async deleteTier(organizationId: string, id: string): Promise<void> {
|
|
const existing = await this.prisma.loyaltyTier.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!existing) throw new NotFoundException('سطح پیدا نشد');
|
|
await this.prisma.person.updateMany({
|
|
where: { organizationId, loyaltyTierId: id },
|
|
data: { loyaltyTierId: null },
|
|
});
|
|
await this.prisma.loyaltyTier.delete({ where: { id } });
|
|
await this.recomputeAllTiers(organizationId);
|
|
}
|
|
|
|
async listMembers(
|
|
actor: Actor,
|
|
q?: string,
|
|
tierId?: string,
|
|
): Promise<LoyaltyMemberDto[]> {
|
|
await this.ensureDefaults(actor.organizationId);
|
|
const rows = await this.prisma.person.findMany({
|
|
where: this.memberWhere(actor, q, tierId),
|
|
include: {
|
|
company: { select: { id: true, name: true } },
|
|
loyaltyTier: {
|
|
select: { id: true, name: true, minPoints: true, color: true },
|
|
},
|
|
},
|
|
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
|
|
take: 400,
|
|
});
|
|
const lastMap = await this.lastActivityMap(
|
|
actor.organizationId,
|
|
rows.map((r) => r.id),
|
|
);
|
|
return rows.map((r) => this.toMemberDto(r, lastMap.get(r.id) ?? r.updatedAt));
|
|
}
|
|
|
|
async exportMembers(
|
|
actor: Actor,
|
|
q?: string,
|
|
tierId?: string,
|
|
): Promise<Buffer> {
|
|
const members = await this.listMembers(actor, q, tierId);
|
|
const wb = new ExcelJS.Workbook();
|
|
const sheet = wb.addWorksheet('اعضای باشگاه');
|
|
sheet.columns = [
|
|
{ header: 'نام', key: 'fullName', width: 24 },
|
|
{ header: 'شرکت', key: 'company', width: 24 },
|
|
{ header: 'موبایل', key: 'mobile', width: 16 },
|
|
{ header: 'ایمیل', key: 'email', width: 28 },
|
|
{ header: 'سطح', key: 'tier', width: 14 },
|
|
{ header: 'امتیاز', key: 'points', width: 12 },
|
|
{ header: 'آخرین فعالیت', key: 'lastActivity', width: 22 },
|
|
];
|
|
for (const m of members) {
|
|
sheet.addRow({
|
|
fullName: m.fullName,
|
|
company: m.company?.name ?? '',
|
|
mobile: m.mobile ?? '',
|
|
email: m.email ?? '',
|
|
tier: m.loyaltyTier?.name ?? '',
|
|
points: m.loyaltyPoints,
|
|
lastActivity: m.lastActivityAt ?? '',
|
|
});
|
|
}
|
|
const buf = await wb.xlsx.writeBuffer();
|
|
return Buffer.from(buf);
|
|
}
|
|
|
|
async listLedger(
|
|
actor: Actor,
|
|
personId: string,
|
|
): Promise<LoyaltyLedgerDto[]> {
|
|
const person = await this.prisma.person.findFirst({
|
|
where: {
|
|
id: personId,
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
|
const rows = await this.prisma.loyaltyLedger.findMany({
|
|
where: { personId, organizationId: actor.organizationId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 80,
|
|
});
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
delta: r.delta,
|
|
reason: r.reason,
|
|
source: r.source,
|
|
createdAt: r.createdAt.toISOString(),
|
|
}));
|
|
}
|
|
|
|
async adjustPoints(
|
|
actor: Actor,
|
|
personId: string,
|
|
delta: number,
|
|
reason?: string,
|
|
source = 'manual',
|
|
): Promise<LoyaltyMemberDto> {
|
|
if (!Number.isFinite(delta) || delta === 0) {
|
|
throw new BadRequestException('مقدار امتیاز نامعتبر است');
|
|
}
|
|
const person = await this.prisma.person.findFirst({
|
|
where: {
|
|
id: personId,
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
});
|
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
|
|
|
const updated = await this.applyDelta(
|
|
actor.organizationId,
|
|
personId,
|
|
Math.trunc(delta),
|
|
reason,
|
|
source,
|
|
);
|
|
const settings = await this.ensureSettings(actor.organizationId);
|
|
if (settings.notifyOnAdjust && updated.mobile) {
|
|
const sign = delta > 0 ? '+' : '';
|
|
void this.sms.send({
|
|
organizationId: actor.organizationId,
|
|
phone: updated.mobile,
|
|
text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
|
|
source: 'loyalty',
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
async bulkAdjust(
|
|
actor: Actor,
|
|
personIds: string[],
|
|
delta: number,
|
|
reason?: string,
|
|
): Promise<{ updated: number }> {
|
|
const unique = [...new Set(personIds.filter(Boolean))];
|
|
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
|
|
if (!Number.isFinite(delta) || delta === 0) {
|
|
throw new BadRequestException('مقدار امتیاز نامعتبر است');
|
|
}
|
|
let updated = 0;
|
|
for (const id of unique) {
|
|
try {
|
|
await this.adjustPoints(actor, id, delta, reason, 'bulk');
|
|
updated += 1;
|
|
} catch {
|
|
/* skip inaccessible */
|
|
}
|
|
}
|
|
return { updated };
|
|
}
|
|
|
|
async sendMessage(
|
|
actor: Actor,
|
|
personIds: string[],
|
|
text: string,
|
|
): Promise<LoyaltyMessageResult> {
|
|
const unique = [...new Set(personIds.filter(Boolean))];
|
|
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
|
|
const body = text.trim();
|
|
if (body.length < 2) throw new BadRequestException('متن پیام کوتاه است');
|
|
|
|
const people = await this.prisma.person.findMany({
|
|
where: {
|
|
id: { in: unique },
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true, mobile: true },
|
|
});
|
|
let sent = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
for (const p of people) {
|
|
if (!p.mobile) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
const result = await this.sms.send({
|
|
organizationId: actor.organizationId,
|
|
phone: p.mobile,
|
|
text: body,
|
|
source: 'loyalty',
|
|
});
|
|
if (result.ok) sent += 1;
|
|
else failed += 1;
|
|
}
|
|
skipped += Math.max(0, unique.length - people.length);
|
|
return { sent, skipped, failed };
|
|
}
|
|
|
|
/** برای اتوماسیون — بدون ACL مالکیت */
|
|
async addPointsByOrg(
|
|
organizationId: string,
|
|
personId: string,
|
|
points: number,
|
|
reason = 'اتوماسیون',
|
|
): Promise<void> {
|
|
if (!personId || !points) return;
|
|
const person = await this.prisma.person.findFirst({
|
|
where: { id: personId, organizationId },
|
|
});
|
|
if (!person) return;
|
|
await this.applyDelta(organizationId, personId, Math.trunc(points), reason, 'automation');
|
|
}
|
|
|
|
async applyWelcomePoints(organizationId: string, personId: string) {
|
|
const settings = await this.ensureSettings(organizationId);
|
|
if (!settings.enabled || settings.welcomePoints <= 0) {
|
|
const person = await this.prisma.person.findFirst({
|
|
where: { id: personId, organizationId },
|
|
select: { loyaltyPoints: true },
|
|
});
|
|
if (!person) return;
|
|
const tierId = await this.resolveTierId(organizationId, person.loyaltyPoints);
|
|
await this.prisma.person.update({
|
|
where: { id: personId },
|
|
data: { loyaltyTierId: tierId },
|
|
});
|
|
return;
|
|
}
|
|
await this.applyDelta(
|
|
organizationId,
|
|
personId,
|
|
settings.welcomePoints,
|
|
'امتیاز خوشآمدگویی',
|
|
'welcome',
|
|
);
|
|
}
|
|
|
|
async resolveTierId(
|
|
organizationId: string,
|
|
points: number,
|
|
): Promise<string | null> {
|
|
await this.ensureDefaultTiers(organizationId);
|
|
const tier = await this.prisma.loyaltyTier.findFirst({
|
|
where: { organizationId, minPoints: { lte: points } },
|
|
orderBy: { minPoints: 'desc' },
|
|
});
|
|
return tier?.id ?? null;
|
|
}
|
|
|
|
async listRewards(organizationId: string): Promise<LoyaltyRewardDto[]> {
|
|
await this.ensureDefaults(organizationId);
|
|
const rows = await this.prisma.loyaltyReward.findMany({
|
|
where: { organizationId },
|
|
orderBy: [{ position: 'asc' }, { pointsCost: 'asc' }],
|
|
include: { _count: { select: { redemptions: true } } },
|
|
});
|
|
return rows.map((r) => this.toRewardDto(r));
|
|
}
|
|
|
|
async createReward(
|
|
organizationId: string,
|
|
dto: CreateLoyaltyRewardRequest,
|
|
): Promise<LoyaltyRewardDto> {
|
|
await this.ensureDefaults(organizationId);
|
|
const maxPos = await this.prisma.loyaltyReward.aggregate({
|
|
where: { organizationId },
|
|
_max: { position: true },
|
|
});
|
|
const row = await this.prisma.loyaltyReward.create({
|
|
data: {
|
|
organizationId,
|
|
name: dto.name.trim(),
|
|
description: emptyToNull(dto.description),
|
|
pointsCost: Math.max(1, Math.floor(dto.pointsCost)),
|
|
isActive: dto.isActive ?? true,
|
|
color: emptyToNull(dto.color),
|
|
position: (maxPos._max.position ?? -1) + 1,
|
|
},
|
|
include: { _count: { select: { redemptions: true } } },
|
|
});
|
|
return this.toRewardDto(row);
|
|
}
|
|
|
|
async updateReward(
|
|
organizationId: string,
|
|
id: string,
|
|
dto: UpdateLoyaltyRewardRequest,
|
|
): Promise<LoyaltyRewardDto> {
|
|
const existing = await this.prisma.loyaltyReward.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
|
|
const row = await this.prisma.loyaltyReward.update({
|
|
where: { id },
|
|
data: {
|
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
|
...('description' in dto
|
|
? { description: emptyToNull(dto.description ?? null) }
|
|
: {}),
|
|
...(dto.pointsCost !== undefined
|
|
? { pointsCost: Math.max(1, Math.floor(dto.pointsCost)) }
|
|
: {}),
|
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
|
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
|
|
},
|
|
include: { _count: { select: { redemptions: true } } },
|
|
});
|
|
return this.toRewardDto(row);
|
|
}
|
|
|
|
async deleteReward(organizationId: string, id: string): Promise<void> {
|
|
const existing = await this.prisma.loyaltyReward.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
|
|
const used = await this.prisma.loyaltyRedemption.count({
|
|
where: { rewardId: id },
|
|
});
|
|
if (used > 0) {
|
|
await this.prisma.loyaltyReward.update({
|
|
where: { id },
|
|
data: { isActive: false },
|
|
});
|
|
return;
|
|
}
|
|
await this.prisma.loyaltyReward.delete({ where: { id } });
|
|
}
|
|
|
|
async redeemReward(
|
|
actor: Actor,
|
|
rewardId: string,
|
|
personId: string,
|
|
): Promise<LoyaltyMemberDto> {
|
|
const reward = await this.prisma.loyaltyReward.findFirst({
|
|
where: { id: rewardId, organizationId: actor.organizationId },
|
|
});
|
|
if (!reward || !reward.isActive) {
|
|
throw new NotFoundException('پاداش پیدا نشد');
|
|
}
|
|
const person = await this.prisma.person.findFirst({
|
|
where: {
|
|
id: personId,
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
});
|
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
|
if (person.loyaltyPoints < reward.pointsCost) {
|
|
throw new BadRequestException('امتیاز عضو برای این پاداش کافی نیست');
|
|
}
|
|
|
|
await this.prisma.loyaltyRedemption.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
personId,
|
|
rewardId,
|
|
pointsSpent: reward.pointsCost,
|
|
},
|
|
});
|
|
return this.applyDelta(
|
|
actor.organizationId,
|
|
personId,
|
|
-reward.pointsCost,
|
|
`پاداش: ${reward.name}`,
|
|
'reward',
|
|
);
|
|
}
|
|
|
|
private async applyDelta(
|
|
organizationId: string,
|
|
personId: string,
|
|
delta: number,
|
|
reason: string | undefined,
|
|
source: string,
|
|
): Promise<LoyaltyMemberDto> {
|
|
const person = await this.prisma.person.findFirst({
|
|
where: { id: personId, organizationId },
|
|
});
|
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
|
const next = Math.max(0, person.loyaltyPoints + delta);
|
|
const tierId = await this.resolveTierId(organizationId, next);
|
|
const [updated] = await this.prisma.$transaction([
|
|
this.prisma.person.update({
|
|
where: { id: personId },
|
|
data: { loyaltyPoints: next, loyaltyTierId: tierId },
|
|
include: {
|
|
company: { select: { id: true, name: true } },
|
|
loyaltyTier: {
|
|
select: { id: true, name: true, minPoints: true, color: true },
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.loyaltyLedger.create({
|
|
data: {
|
|
organizationId,
|
|
personId,
|
|
delta,
|
|
reason: emptyToNull(reason),
|
|
source,
|
|
},
|
|
}),
|
|
]);
|
|
return this.toMemberDto(updated, updated.updatedAt);
|
|
}
|
|
|
|
private memberWhere(
|
|
actor: Actor,
|
|
q?: string,
|
|
tierId?: string,
|
|
): Prisma.PersonWhereInput {
|
|
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
|
{ firstName: ci(t) },
|
|
{ lastName: ci(t) },
|
|
{ mobile: { contains: t } },
|
|
{ phone: { contains: t } },
|
|
{ email: ci(t) },
|
|
{ sourceRef: ci(t) },
|
|
{ company: { name: ci(t) } },
|
|
]);
|
|
return {
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
...(tierId ? { loyaltyTierId: tierId } : {}),
|
|
...(search ? { AND: [search] } : {}),
|
|
};
|
|
}
|
|
|
|
private async lastActivityMap(organizationId: string, personIds: string[]) {
|
|
const map = new Map<string, Date>();
|
|
if (!personIds.length) return map;
|
|
const rows = await this.prisma.activity.groupBy({
|
|
by: ['personId'],
|
|
where: {
|
|
organizationId,
|
|
personId: { in: personIds },
|
|
},
|
|
_max: { updatedAt: true, completedAt: true, createdAt: true },
|
|
});
|
|
for (const r of rows) {
|
|
if (!r.personId) continue;
|
|
const dates = [r._max.updatedAt, r._max.completedAt, r._max.createdAt].filter(
|
|
(d): d is Date => !!d,
|
|
);
|
|
if (!dates.length) continue;
|
|
map.set(r.personId, new Date(Math.max(...dates.map((d) => d.getTime()))));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
private async recomputeAllTiers(organizationId: string) {
|
|
const people = await this.prisma.person.findMany({
|
|
where: { organizationId },
|
|
select: { id: true, loyaltyPoints: true },
|
|
});
|
|
const tiers = await this.prisma.loyaltyTier.findMany({
|
|
where: { organizationId },
|
|
orderBy: { minPoints: 'desc' },
|
|
});
|
|
for (const p of people) {
|
|
const tier = tiers.find((t) => t.minPoints <= p.loyaltyPoints) ?? null;
|
|
await this.prisma.person.update({
|
|
where: { id: p.id },
|
|
data: { loyaltyTierId: tier?.id ?? null },
|
|
});
|
|
}
|
|
}
|
|
|
|
private toMemberDto(
|
|
r: {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
mobile: string | null;
|
|
email: string | null;
|
|
loyaltyPoints: number;
|
|
loyaltyTier?: LoyaltyMemberDto['loyaltyTier'];
|
|
company?: LoyaltyMemberDto['company'];
|
|
},
|
|
lastActivity: Date,
|
|
): LoyaltyMemberDto {
|
|
return {
|
|
id: r.id,
|
|
fullName: `${r.firstName} ${r.lastName}`.trim(),
|
|
mobile: r.mobile,
|
|
email: r.email,
|
|
loyaltyPoints: r.loyaltyPoints,
|
|
loyaltyTier: r.loyaltyTier ?? null,
|
|
company: r.company ?? null,
|
|
lastActivityAt: lastActivity.toISOString(),
|
|
};
|
|
}
|
|
|
|
private toRewardDto(r: {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
pointsCost: number;
|
|
isActive: boolean;
|
|
position: number;
|
|
color: string | null;
|
|
_count: { redemptions: number };
|
|
}): LoyaltyRewardDto {
|
|
return {
|
|
id: r.id,
|
|
name: r.name,
|
|
description: r.description,
|
|
pointsCost: r.pointsCost,
|
|
isActive: r.isActive,
|
|
position: r.position,
|
|
color: r.color,
|
|
redemptionCount: r._count.redemptions,
|
|
};
|
|
}
|
|
}
|
|
|
|
function emptyToNull(v?: string | null) {
|
|
if (v == null) return null;
|
|
const t = v.trim();
|
|
return t ? t : null;
|
|
}
|
|
|
|
function startOfMonth(d: Date) {
|
|
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
|
}
|
|
|
|
function addMonths(d: Date, n: number) {
|
|
return new Date(d.getFullYear(), d.getMonth() + n, 1, 0, 0, 0, 0);
|
|
}
|
|
|
|
function pctChange(current: number, previous: number): number | null {
|
|
if (previous <= 0 && current <= 0) return 0;
|
|
if (previous <= 0) return current > 0 ? 100 : 0;
|
|
return Math.round(((current - previous) / previous) * 100);
|
|
}
|