customers
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { SalonCustomer } from '../customers/entities/salon-customer.entity';
|
||||||
import { Reserve } from '../reserves/entities/reserve.entity';
|
import { Reserve } from '../reserves/entities/reserve.entity';
|
||||||
import { AuthorizationService } from './authorization.service';
|
import { AuthorizationService } from './authorization.service';
|
||||||
import { Permission } from './entities/permission.entity';
|
import { Permission } from './entities/permission.entity';
|
||||||
@@ -13,7 +14,14 @@ import { StylistPolicy } from './policies/stylist.policy';
|
|||||||
import { SubscriptionPolicy } from './policies/subscription.policy';
|
import { SubscriptionPolicy } from './policies/subscription.policy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Permission,
|
||||||
|
UserPermission,
|
||||||
|
Reserve,
|
||||||
|
SalonCustomer,
|
||||||
|
]),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AuthorizationService,
|
AuthorizationService,
|
||||||
SalonPolicy,
|
SalonPolicy,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
|
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
|
||||||
import { Customer } from '../../customers/entities/customer.entity';
|
import { Customer } from '../../customers/entities/customer.entity';
|
||||||
|
import { SalonCustomer } from '../../customers/entities/salon-customer.entity';
|
||||||
import { Reserve } from '../../reserves/entities/reserve.entity';
|
import { Reserve } from '../../reserves/entities/reserve.entity';
|
||||||
import { UserRole } from '../../users/enums/user-role.enum';
|
import { UserRole } from '../../users/enums/user-role.enum';
|
||||||
import { AuthorizationService } from '../authorization.service';
|
import { AuthorizationService } from '../authorization.service';
|
||||||
@@ -13,6 +14,8 @@ export class CustomerPolicy {
|
|||||||
private readonly authorizationService: AuthorizationService,
|
private readonly authorizationService: AuthorizationService,
|
||||||
@InjectRepository(Reserve)
|
@InjectRepository(Reserve)
|
||||||
private readonly reservesRepository: Repository<Reserve>,
|
private readonly reservesRepository: Repository<Reserve>,
|
||||||
|
@InjectRepository(SalonCustomer)
|
||||||
|
private readonly salonCustomersRepository: Repository<SalonCustomer>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canRead(user: AuthUser, customer: Customer): Promise<boolean> {
|
async canRead(user: AuthUser, customer: Customer): Promise<boolean> {
|
||||||
@@ -23,7 +26,10 @@ export class CustomerPolicy {
|
|||||||
return customer.userId === user.id;
|
return customer.userId === user.id;
|
||||||
}
|
}
|
||||||
if (user.role === UserRole.SALON) {
|
if (user.role === UserRole.SALON) {
|
||||||
return this.hasReserveAtSalon(customer.id, user.id);
|
return (
|
||||||
|
(await this.hasReserveAtSalon(customer.id, user.id)) ||
|
||||||
|
(await this.hasLinkAtSalon(customer.id, user.id))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -49,4 +55,16 @@ export class CustomerPolicy {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hasLinkAtSalon(
|
||||||
|
customerId: string,
|
||||||
|
salonOwnerId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.salonCustomersRepository.exists({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
salon: { ownerId: salonOwnerId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -19,6 +21,14 @@ import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
|||||||
import { UserRole } from '../users/enums/user-role.enum';
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||||
|
import {
|
||||||
|
CreateSalonCustomerDto,
|
||||||
|
FindSalonCustomersQueryDto,
|
||||||
|
} from './dto/salon-customer.dto';
|
||||||
|
import {
|
||||||
|
SalonCustomerDetailDto,
|
||||||
|
SalonCustomerListResponseDto,
|
||||||
|
} from './dto/salon-customer-response.dto';
|
||||||
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
|
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
|
||||||
import {
|
import {
|
||||||
ApiStandardController,
|
ApiStandardController,
|
||||||
@@ -35,6 +45,39 @@ import { Customer } from './entities/customer.entity';
|
|||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(private readonly customersService: CustomersService) {}
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
|
@Roles(UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.CUSTOMERS_READ)
|
||||||
|
@ApiStandardOkResponse(SalonCustomerListResponseDto)
|
||||||
|
@Get('salon')
|
||||||
|
findAllForSalon(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query() query: FindSalonCustomersQueryDto,
|
||||||
|
) {
|
||||||
|
return this.customersService.findAllForSalon(user, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.CUSTOMERS_WRITE)
|
||||||
|
@ApiStandardOkResponse(SalonCustomerDetailDto)
|
||||||
|
@Post('salon')
|
||||||
|
createForSalon(
|
||||||
|
@Body() createSalonCustomerDto: CreateSalonCustomerDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.customersService.createForSalon(createSalonCustomerDto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.CUSTOMERS_READ)
|
||||||
|
@ApiStandardOkResponse(SalonCustomerDetailDto)
|
||||||
|
@Get('salon/:id')
|
||||||
|
findOneForSalon(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.customersService.findOneForSalon(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
@Permissions(PermissionCode.CUSTOMERS_READ)
|
@Permissions(PermissionCode.CUSTOMERS_READ)
|
||||||
@ApiStandardOkResponse(Customer, { isArray: true })
|
@ApiStandardOkResponse(Customer, { isArray: true })
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
||||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||||
|
import { Reserve } from '../reserves/entities/reserve.entity';
|
||||||
|
import { Salon } from '../salons/entities/salon.entity';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { CustomersController } from './customers.controller';
|
import { CustomersController } from './customers.controller';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
import { Customer } from './entities/customer.entity';
|
import { Customer } from './entities/customer.entity';
|
||||||
|
import { SalonCustomer } from './entities/salon-customer.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Customer]),
|
TypeOrmModule.forFeature([Customer, SalonCustomer, Salon, Reserve]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
AuthGuardsModule,
|
AuthGuardsModule,
|
||||||
|
|||||||
@@ -7,17 +7,42 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CustomerPolicy } from '../authorization/policies/customer.policy';
|
import { CustomerPolicy } from '../authorization/policies/customer.policy';
|
||||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
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 { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
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 { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||||
import { Customer } from './entities/customer.entity';
|
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()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Customer)
|
@InjectRepository(Customer)
|
||||||
private readonly customersRepository: Repository<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 usersService: UsersService,
|
||||||
private readonly customerPolicy: CustomerPolicy,
|
private readonly customerPolicy: CustomerPolicy,
|
||||||
) {}
|
) {}
|
||||||
@@ -44,6 +69,91 @@ export class CustomersService {
|
|||||||
return this.customersRepository.find({ relations: { user: true } });
|
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> {
|
async findOne(id: string, user?: AuthUser): Promise<Customer> {
|
||||||
const customer = await this.customersRepository.findOne({
|
const customer = await this.customersRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -100,4 +210,156 @@ export class CustomersService {
|
|||||||
const customer = await this.findOne(id);
|
const customer = await this.findOne(id);
|
||||||
await this.usersService.remove(customer.userId);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SalonCustomerStatsDto {
|
||||||
|
@ApiProperty({ example: 42 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 28 })
|
||||||
|
active: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 8 })
|
||||||
|
atRisk: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 4 })
|
||||||
|
lost: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 6 })
|
||||||
|
vip: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SalonCustomerServiceHistoryDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'کوتاهی مو' })
|
||||||
|
serviceName: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://example.com/icon.png' })
|
||||||
|
serviceIcon: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2025-06-12' })
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 850_000 })
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SalonCustomerSummaryDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'سارا' })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'احمدی' })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '09123456789' })
|
||||||
|
mobile: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '1995-03-15' })
|
||||||
|
dateOfBirth: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'تهران، ونک' })
|
||||||
|
address: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2024-01-10T10:00:00.000Z' })
|
||||||
|
membershipDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 12_500_000 })
|
||||||
|
totalPurchase: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 8 })
|
||||||
|
visitCount: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '2025-06-12' })
|
||||||
|
lastVisitDate: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 12 })
|
||||||
|
daysSinceVisit: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['active', 'at-risk', 'lost', 'vip'] })
|
||||||
|
status: 'active' | 'at-risk' | 'lost' | 'vip';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SalonCustomerListResponseDto {
|
||||||
|
@ApiProperty({ type: [SalonCustomerSummaryDto] })
|
||||||
|
customers: SalonCustomerSummaryDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: SalonCustomerStatsDto })
|
||||||
|
stats: SalonCustomerStatsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SalonCustomerDetailDto extends SalonCustomerSummaryDto {
|
||||||
|
@ApiProperty({ type: [SalonCustomerServiceHistoryDto] })
|
||||||
|
serviceHistory: SalonCustomerServiceHistoryDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSalonCustomerDto {
|
||||||
|
@ApiProperty({ example: 'سارا' })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 100)
|
||||||
|
@IsNotEmpty()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'احمدی' })
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 100)
|
||||||
|
@IsNotEmpty()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '09123456789' })
|
||||||
|
@Matches(/^09\d{9}$/)
|
||||||
|
mobile: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '1995-03-15' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateOfBirth?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'تهران، ونک' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 500)
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FindSalonCustomersQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Search by name or mobile' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['active', 'at-risk', 'lost', 'vip'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['active', 'at-risk', 'lost', 'vip'])
|
||||||
|
status?: 'active' | 'at-risk' | 'lost' | 'vip';
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Salon } from '../../salons/entities/salon.entity';
|
||||||
|
import { Customer } from './customer.entity';
|
||||||
|
|
||||||
|
@Entity('salon_customers')
|
||||||
|
@Unique(['salonId', 'customerId'])
|
||||||
|
@Index(['salonId'])
|
||||||
|
@Index(['customerId'])
|
||||||
|
export class SalonCustomer {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
salonId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Salon, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'salonId' })
|
||||||
|
salon: Salon;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
customerId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Customer, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'customerId' })
|
||||||
|
customer: Customer;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ReserveItem } from '../../reserves/entities/reserve-item.entity';
|
||||||
|
import { Reserve } from '../../reserves/entities/reserve.entity';
|
||||||
|
|
||||||
|
export const DEFAULT_RENEWAL_DAYS = 30;
|
||||||
|
export const AT_RISK_GRACE_DAYS = 15;
|
||||||
|
export const RETURNABLE_GRACE_DAYS = 30;
|
||||||
|
export const VIP_PURCHASE_THRESHOLD = 5_000_000;
|
||||||
|
|
||||||
|
export type CustomerSegment = 'active' | 'at-risk' | 'returnable' | 'lost';
|
||||||
|
|
||||||
|
export interface CustomerVisit {
|
||||||
|
appointmentDate: string;
|
||||||
|
renewalDays: number;
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupVisitsByCustomer(
|
||||||
|
reserves: Reserve[],
|
||||||
|
): Record<string, CustomerVisit[]> {
|
||||||
|
const grouped: Record<string, CustomerVisit[]> = {};
|
||||||
|
|
||||||
|
for (const reserve of reserves) {
|
||||||
|
const renewalDays = resolveRenewalDays(reserve.items);
|
||||||
|
const visit: CustomerVisit = {
|
||||||
|
appointmentDate: reserve.appointmentDate,
|
||||||
|
renewalDays,
|
||||||
|
totalAmount: reserve.totalAmount,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!grouped[reserve.customerId]) {
|
||||||
|
grouped[reserve.customerId] = [];
|
||||||
|
}
|
||||||
|
grouped[reserve.customerId].push(visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenewalDays(items: ReserveItem[] | undefined): number {
|
||||||
|
if (!items?.length) {
|
||||||
|
return DEFAULT_RENEWAL_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewalDays = items
|
||||||
|
.map((item) => item.serviceSkill?.renewalDays ?? DEFAULT_RENEWAL_DAYS)
|
||||||
|
.filter((value) => value > 0);
|
||||||
|
|
||||||
|
if (renewalDays.length === 0) {
|
||||||
|
return DEFAULT_RENEWAL_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(...renewalDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSegment(
|
||||||
|
daysSince: number,
|
||||||
|
renewalDays: number,
|
||||||
|
): CustomerSegment {
|
||||||
|
if (daysSince <= renewalDays) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
const atRiskLimit = renewalDays + AT_RISK_GRACE_DAYS;
|
||||||
|
if (daysSince <= atRiskLimit) {
|
||||||
|
return 'at-risk';
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnableLimit = atRiskLimit + RETURNABLE_GRACE_DAYS;
|
||||||
|
if (daysSince <= returnableLimit) {
|
||||||
|
return 'returnable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'lost';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBetween(from: Date, to: Date): number {
|
||||||
|
const start = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
|
||||||
|
const end = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
|
||||||
|
return Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(value: string): Date {
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumTotalPurchase(visits: CustomerVisit[]): number {
|
||||||
|
return visits.reduce((sum, visit) => sum + visit.totalAmount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVipCustomer(totalPurchase: number): boolean {
|
||||||
|
return totalPurchase >= VIP_PURCHASE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSegmentToClientStatus(
|
||||||
|
segment: CustomerSegment,
|
||||||
|
totalPurchase: number,
|
||||||
|
): 'active' | 'at-risk' | 'lost' | 'vip' {
|
||||||
|
if (isVipCustomer(totalPurchase)) {
|
||||||
|
return 'vip';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment === 'returnable' || segment === 'lost') {
|
||||||
|
return 'lost';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment === 'at-risk') {
|
||||||
|
return 'at-risk';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
TableUnique,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSalonCustomers1721100000000 implements MigrationInterface {
|
||||||
|
name = 'AddSalonCustomers1721100000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'salon_customers',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
generationStrategy: 'uuid',
|
||||||
|
default: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
{ name: 'salonId', type: 'uuid' },
|
||||||
|
{ name: 'customerId', type: 'uuid' },
|
||||||
|
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
||||||
|
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'salon_customers',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['salonId'],
|
||||||
|
referencedTableName: 'salons',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'salon_customers',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['customerId'],
|
||||||
|
referencedTableName: 'customers',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'salon_customers',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_salon_customers_salonId',
|
||||||
|
columnNames: ['salonId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'salon_customers',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_salon_customers_customerId',
|
||||||
|
columnNames: ['customerId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createUniqueConstraint(
|
||||||
|
'salon_customers',
|
||||||
|
new TableUnique({
|
||||||
|
name: 'UQ_salon_customers_salonId_customerId',
|
||||||
|
columnNames: ['salonId', 'customerId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO salon_customers ("salonId", "customerId", "createdAt", "updatedAt")
|
||||||
|
SELECT DISTINCT "salonId", "customerId", NOW(), NOW()
|
||||||
|
FROM reserves
|
||||||
|
ON CONFLICT ("salonId", "customerId") DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('salon_customers');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ export class ReservesService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.reservesRepository.save(reserve);
|
const saved = await this.reservesRepository.save(reserve);
|
||||||
|
await this.customersService.linkToSalon(saved.salonId, saved.customerId);
|
||||||
await this.logStatusChange(saved.id, null, saved.status, user.id);
|
await this.logStatusChange(saved.id, null, saved.status, user.id);
|
||||||
return this.findOne(saved.id, user);
|
return this.findOne(saved.id, user);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user