customers
This commit is contained in:
@@ -7,17 +7,42 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CustomerPolicy } from '../authorization/policies/customer.policy';
|
||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||
import { Reserve } from '../reserves/entities/reserve.entity';
|
||||
import { ReserveStatus } from '../reserves/enums/reserve-status.enum';
|
||||
import { Salon } from '../salons/entities/salon.entity';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||
import { CreateSalonCustomerDto } from './dto/salon-customer.dto';
|
||||
import { FindSalonCustomersQueryDto } from './dto/salon-customer.dto';
|
||||
import {
|
||||
SalonCustomerDetailDto,
|
||||
SalonCustomerListResponseDto,
|
||||
SalonCustomerSummaryDto,
|
||||
} from './dto/salon-customer-response.dto';
|
||||
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||
import { Customer } from './entities/customer.entity';
|
||||
import { SalonCustomer } from './entities/salon-customer.entity';
|
||||
import {
|
||||
daysBetween,
|
||||
groupVisitsByCustomer,
|
||||
mapSegmentToClientStatus,
|
||||
parseDate,
|
||||
resolveSegment,
|
||||
sumTotalPurchase,
|
||||
} from './helpers/customer-segmentation.helper';
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
private readonly customersRepository: Repository<Customer>,
|
||||
@InjectRepository(SalonCustomer)
|
||||
private readonly salonCustomersRepository: Repository<SalonCustomer>,
|
||||
@InjectRepository(Salon)
|
||||
private readonly salonsRepository: Repository<Salon>,
|
||||
@InjectRepository(Reserve)
|
||||
private readonly reservesRepository: Repository<Reserve>,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly customerPolicy: CustomerPolicy,
|
||||
) {}
|
||||
@@ -44,6 +69,91 @@ export class CustomersService {
|
||||
return this.customersRepository.find({ relations: { user: true } });
|
||||
}
|
||||
|
||||
async findAllForSalon(
|
||||
user: AuthUser,
|
||||
query: FindSalonCustomersQueryDto,
|
||||
): Promise<SalonCustomerListResponseDto> {
|
||||
const salon = await this.resolveSalonForUser(user);
|
||||
const summaries = await this.buildSalonCustomerSummaries(salon.id);
|
||||
const filtered = this.filterSalonCustomers(summaries, query);
|
||||
|
||||
return {
|
||||
customers: filtered,
|
||||
stats: this.buildSalonCustomerStats(summaries),
|
||||
};
|
||||
}
|
||||
|
||||
async findOneForSalon(
|
||||
id: string,
|
||||
user: AuthUser,
|
||||
): Promise<SalonCustomerDetailDto> {
|
||||
const salon = await this.resolveSalonForUser(user);
|
||||
const customer = await this.findOne(id);
|
||||
|
||||
if (!(await this.customerPolicy.canRead(user, customer))) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const isLinked = await this.salonCustomersRepository.exists({
|
||||
where: { salonId: salon.id, customerId: customer.id },
|
||||
});
|
||||
|
||||
if (!isLinked) {
|
||||
throw new NotFoundException(`Customer #${id} not found in this salon`);
|
||||
}
|
||||
|
||||
return this.buildSalonCustomerDetail(customer, salon.id);
|
||||
}
|
||||
|
||||
async createForSalon(
|
||||
dto: CreateSalonCustomerDto,
|
||||
user: AuthUser,
|
||||
): Promise<SalonCustomerDetailDto> {
|
||||
const salon = await this.resolveSalonForUser(user);
|
||||
const customer = await this.findOrCreateByMobile(dto);
|
||||
await this.linkToSalon(salon.id, customer.id);
|
||||
return this.buildSalonCustomerDetail(customer, salon.id);
|
||||
}
|
||||
|
||||
async linkToSalon(salonId: string, customerId: string): Promise<void> {
|
||||
await this.salonCustomersRepository.upsert(
|
||||
{ salonId, customerId },
|
||||
{ conflictPaths: ['salonId', 'customerId'] },
|
||||
);
|
||||
}
|
||||
|
||||
async findOrCreateByMobile(dto: CreateSalonCustomerDto): Promise<Customer> {
|
||||
const existingUser = await this.usersService.findByMobile(dto.mobile);
|
||||
|
||||
if (existingUser) {
|
||||
let customer = await this.customersRepository.findOne({
|
||||
where: { userId: existingUser.id },
|
||||
relations: { user: true },
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
customer = await this.customersRepository.save(
|
||||
this.customersRepository.create({
|
||||
userId: existingUser.id,
|
||||
dateOfBirth: dto.dateOfBirth ?? '2000-01-01',
|
||||
address: dto.address ?? '-',
|
||||
}),
|
||||
);
|
||||
customer = await this.findOne(customer.id);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
mobile: dto.mobile,
|
||||
dateOfBirth: dto.dateOfBirth ?? '2000-01-01',
|
||||
address: dto.address ?? '-',
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, user?: AuthUser): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findOne({
|
||||
where: { id },
|
||||
@@ -100,4 +210,156 @@ export class CustomersService {
|
||||
const customer = await this.findOne(id);
|
||||
await this.usersService.remove(customer.userId);
|
||||
}
|
||||
|
||||
private async resolveSalonForUser(user: AuthUser): Promise<Salon> {
|
||||
if (user.role !== UserRole.SALON) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const salon = await this.salonsRepository.findOne({
|
||||
where: { ownerId: user.id },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
if (!salon) {
|
||||
throw new NotFoundException('Salon not found for this user');
|
||||
}
|
||||
|
||||
return salon;
|
||||
}
|
||||
|
||||
private async buildSalonCustomerSummaries(
|
||||
salonId: string,
|
||||
): Promise<SalonCustomerSummaryDto[]> {
|
||||
const links = await this.salonCustomersRepository.find({
|
||||
where: { salonId },
|
||||
relations: { customer: { user: true } },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
const completedReserves = await this.reservesRepository.find({
|
||||
where: { salonId, status: ReserveStatus.COMPLETED },
|
||||
relations: { items: { serviceSkill: true, service: true } },
|
||||
order: { appointmentDate: 'ASC' },
|
||||
});
|
||||
|
||||
const visitsByCustomer = groupVisitsByCustomer(completedReserves);
|
||||
const now = new Date();
|
||||
|
||||
return links.map((link) =>
|
||||
this.buildSummaryFromCustomer(
|
||||
link.customer,
|
||||
link.createdAt,
|
||||
visitsByCustomer[link.customer.id] ?? [],
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async buildSalonCustomerDetail(
|
||||
customer: Customer,
|
||||
salonId: string,
|
||||
): Promise<SalonCustomerDetailDto> {
|
||||
const link = await this.salonCustomersRepository.findOne({
|
||||
where: { salonId, customerId: customer.id },
|
||||
});
|
||||
|
||||
const completedReserves = await this.reservesRepository.find({
|
||||
where: {
|
||||
salonId,
|
||||
customerId: customer.id,
|
||||
status: ReserveStatus.COMPLETED,
|
||||
},
|
||||
relations: { items: { serviceSkill: true, service: true } },
|
||||
order: { appointmentDate: 'DESC' },
|
||||
});
|
||||
|
||||
const visits = groupVisitsByCustomer(completedReserves)[customer.id] ?? [];
|
||||
const summary = this.buildSummaryFromCustomer(
|
||||
customer,
|
||||
link?.createdAt ?? customer.createdAt,
|
||||
visits,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
const serviceHistory = completedReserves.flatMap((reserve) =>
|
||||
(reserve.items ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
serviceName: item.service?.title ?? 'خدمت',
|
||||
serviceIcon: item.service?.iconUrl ?? null,
|
||||
date: reserve.appointmentDate,
|
||||
price: reserve.totalAmount,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
...summary,
|
||||
serviceHistory,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSummaryFromCustomer(
|
||||
customer: Customer,
|
||||
membershipDate: Date,
|
||||
visits: ReturnType<typeof groupVisitsByCustomer>[string],
|
||||
referenceDate: Date,
|
||||
): SalonCustomerSummaryDto {
|
||||
const totalPurchase = sumTotalPurchase(visits);
|
||||
const visitCount = visits.length;
|
||||
const lastVisit = visits[visits.length - 1];
|
||||
const lastVisitDate = lastVisit?.appointmentDate ?? null;
|
||||
const daysSinceVisit = lastVisitDate
|
||||
? daysBetween(parseDate(lastVisitDate), referenceDate)
|
||||
: null;
|
||||
const segment = lastVisit
|
||||
? resolveSegment(daysSinceVisit ?? 0, lastVisit.renewalDays)
|
||||
: 'active';
|
||||
|
||||
return {
|
||||
id: customer.id,
|
||||
firstName: customer.user.firstName,
|
||||
lastName: customer.user.lastName,
|
||||
mobile: customer.user.mobile,
|
||||
dateOfBirth: customer.dateOfBirth,
|
||||
address: customer.address,
|
||||
membershipDate: membershipDate.toISOString(),
|
||||
totalPurchase,
|
||||
visitCount,
|
||||
lastVisitDate,
|
||||
daysSinceVisit,
|
||||
status: mapSegmentToClientStatus(segment, totalPurchase),
|
||||
};
|
||||
}
|
||||
|
||||
private filterSalonCustomers(
|
||||
customers: SalonCustomerSummaryDto[],
|
||||
query: FindSalonCustomersQueryDto,
|
||||
): SalonCustomerSummaryDto[] {
|
||||
const search = query.search?.trim().toLowerCase();
|
||||
|
||||
return customers.filter((customer) => {
|
||||
const fullName =
|
||||
`${customer.firstName} ${customer.lastName}`.toLowerCase();
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
fullName.includes(search) ||
|
||||
customer.mobile.includes(search);
|
||||
const matchesStatus =
|
||||
!query.status || customer.status === query.status;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}
|
||||
|
||||
private buildSalonCustomerStats(customers: SalonCustomerSummaryDto[]) {
|
||||
return {
|
||||
total: customers.length,
|
||||
active: customers.filter((customer) => customer.status === 'active')
|
||||
.length,
|
||||
atRisk: customers.filter((customer) => customer.status === 'at-risk')
|
||||
.length,
|
||||
lost: customers.filter((customer) => customer.status === 'lost').length,
|
||||
vip: customers.filter((customer) => customer.status === 'vip').length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user