customers
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SalonCustomer } from '../customers/entities/salon-customer.entity';
|
||||
import { Reserve } from '../reserves/entities/reserve.entity';
|
||||
import { AuthorizationService } from './authorization.service';
|
||||
import { Permission } from './entities/permission.entity';
|
||||
@@ -13,7 +14,14 @@ import { StylistPolicy } from './policies/stylist.policy';
|
||||
import { SubscriptionPolicy } from './policies/subscription.policy';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Permission,
|
||||
UserPermission,
|
||||
Reserve,
|
||||
SalonCustomer,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
AuthorizationService,
|
||||
SalonPolicy,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
|
||||
import { Customer } from '../../customers/entities/customer.entity';
|
||||
import { SalonCustomer } from '../../customers/entities/salon-customer.entity';
|
||||
import { Reserve } from '../../reserves/entities/reserve.entity';
|
||||
import { UserRole } from '../../users/enums/user-role.enum';
|
||||
import { AuthorizationService } from '../authorization.service';
|
||||
@@ -13,6 +14,8 @@ export class CustomerPolicy {
|
||||
private readonly authorizationService: AuthorizationService,
|
||||
@InjectRepository(Reserve)
|
||||
private readonly reservesRepository: Repository<Reserve>,
|
||||
@InjectRepository(SalonCustomer)
|
||||
private readonly salonCustomersRepository: Repository<SalonCustomer>,
|
||||
) {}
|
||||
|
||||
async canRead(user: AuthUser, customer: Customer): Promise<boolean> {
|
||||
@@ -23,7 +26,10 @@ export class CustomerPolicy {
|
||||
return customer.userId === user.id;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 { CustomersService } from './customers.service';
|
||||
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 {
|
||||
ApiStandardController,
|
||||
@@ -35,6 +45,39 @@ import { Customer } from './entities/customer.entity';
|
||||
export class CustomersController {
|
||||
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)
|
||||
@Permissions(PermissionCode.CUSTOMERS_READ)
|
||||
@ApiStandardOkResponse(Customer, { isArray: true })
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthGuardsModule } from '../auth/auth-guards.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 { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { Customer } from './entities/customer.entity';
|
||||
import { SalonCustomer } from './entities/salon-customer.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Customer]),
|
||||
TypeOrmModule.forFeature([Customer, SalonCustomer, Salon, Reserve]),
|
||||
UsersModule,
|
||||
AuthorizationModule,
|
||||
AuthGuardsModule,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
await this.customersService.linkToSalon(saved.salonId, saved.customerId);
|
||||
await this.logStatusChange(saved.id, null, saved.status, user.id);
|
||||
return this.findOne(saved.id, user);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user