update rezerv
This commit is contained in:
@@ -6,7 +6,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import {
|
||||
generateAvailableSlotStarts,
|
||||
parseGregorianYmd,
|
||||
@@ -17,11 +18,14 @@ import {
|
||||
} from '../../common/utils/booking-schedule.util';
|
||||
import { BookingEntity, BookingStatus } from '../../entities/booking.entity';
|
||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
|
||||
type FindAllFilters = {
|
||||
@@ -47,6 +51,10 @@ export class BookingsService {
|
||||
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
|
||||
@InjectRepository(BusinessAvailabilitySlotEntity)
|
||||
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
||||
@InjectRepository(BusinessServiceClassEntity)
|
||||
private readonly serviceClassRepo: Repository<BusinessServiceClassEntity>,
|
||||
@InjectRepository(BusinessServiceClassPriceEntity)
|
||||
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||
) {
|
||||
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
||||
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
||||
@@ -63,6 +71,7 @@ export class BookingsService {
|
||||
.leftJoinAndSelect('b.business', 'business')
|
||||
.leftJoinAndSelect('b.businessService', 'businessService')
|
||||
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
|
||||
.leftJoinAndSelect('b.serviceClass', 'serviceClass')
|
||||
.orderBy('b.scheduledAt', 'ASC');
|
||||
|
||||
if (actor?.role === UserRole.CUSTOMER) {
|
||||
@@ -108,6 +117,7 @@ export class BookingsService {
|
||||
customer: true,
|
||||
business: true,
|
||||
businessService: { serviceTemplate: true },
|
||||
serviceClass: true,
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
@@ -151,21 +161,21 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||
const durationMinutes = resolveServiceDurationMinutes(
|
||||
businessService.durationMinutes,
|
||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
const pricing = await this.resolveBookingPricing(
|
||||
businessService,
|
||||
dto.serviceClassId ?? null,
|
||||
);
|
||||
await this.assertSchedulableSlot({
|
||||
businessId: dto.businessId,
|
||||
businessServiceId: dto.businessServiceId,
|
||||
scheduledAt,
|
||||
durationMinutes,
|
||||
durationMinutes: pricing.durationMinutes,
|
||||
});
|
||||
|
||||
const entity = this.repo.create({
|
||||
customerId: dto.customerId,
|
||||
businessId: dto.businessId,
|
||||
businessServiceId: dto.businessServiceId,
|
||||
serviceClassId: pricing.serviceClassId,
|
||||
scheduledAt,
|
||||
note: dto.note ?? null,
|
||||
status: BookingStatus.PENDING,
|
||||
@@ -174,6 +184,93 @@ export class BookingsService {
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async createBatch(
|
||||
dto: CreateBookingBatchDto,
|
||||
actor?: AuthUser,
|
||||
): Promise<BookingEntity[]> {
|
||||
if (actor && dto.customerId !== actor.id) {
|
||||
throw new ForbiddenException('You can only book for yourself');
|
||||
}
|
||||
|
||||
const customer = await this.userRepo.findOne({
|
||||
where: { id: dto.customerId },
|
||||
});
|
||||
if (!customer) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: dto.businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const serviceIds = [
|
||||
...new Set(dto.items.map((item) => item.businessServiceId)),
|
||||
];
|
||||
if (serviceIds.length !== dto.items.length) {
|
||||
throw new BadRequestException('Duplicate services in booking batch');
|
||||
}
|
||||
|
||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||
const serviceClassId = dto.serviceClassId ?? null;
|
||||
|
||||
const resolvedItems: Array<{
|
||||
businessService: BusinessServiceEntity;
|
||||
serviceClassId: string | null;
|
||||
durationMinutes: number;
|
||||
}> = [];
|
||||
|
||||
let totalDurationMinutes = 0;
|
||||
for (const serviceId of serviceIds) {
|
||||
const businessService = await this.businessServiceRepo.findOne({
|
||||
where: { id: serviceId, businessId: dto.businessId, isActive: true },
|
||||
relations: { serviceTemplate: true },
|
||||
});
|
||||
if (!businessService) {
|
||||
throw new NotFoundException(
|
||||
`Business service ${serviceId} not found or inactive`,
|
||||
);
|
||||
}
|
||||
|
||||
const pricing = await this.resolveBookingPricing(
|
||||
businessService,
|
||||
serviceClassId,
|
||||
);
|
||||
resolvedItems.push({
|
||||
businessService,
|
||||
serviceClassId: pricing.serviceClassId,
|
||||
durationMinutes: pricing.durationMinutes,
|
||||
});
|
||||
totalDurationMinutes += pricing.durationMinutes;
|
||||
}
|
||||
|
||||
await this.assertSchedulableSlot({
|
||||
businessId: dto.businessId,
|
||||
scheduledAt,
|
||||
durationMinutes: totalDurationMinutes,
|
||||
});
|
||||
|
||||
const bookingGroupId = serviceIds.length > 1 ? randomUUID() : null;
|
||||
const note = dto.note ?? null;
|
||||
|
||||
const entities = resolvedItems.map(({ businessService, serviceClassId: classId }) =>
|
||||
this.repo.create({
|
||||
customerId: dto.customerId,
|
||||
businessId: dto.businessId,
|
||||
businessServiceId: businessService.id,
|
||||
serviceClassId: classId,
|
||||
bookingGroupId,
|
||||
scheduledAt,
|
||||
note,
|
||||
status: BookingStatus.PENDING,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.repo.save(entities);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateBookingDto,
|
||||
@@ -185,18 +282,21 @@ export class BookingsService {
|
||||
|
||||
if (dto.scheduledAt !== undefined) {
|
||||
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
|
||||
const durationMinutes = resolveServiceDurationMinutes(
|
||||
row.businessService?.durationMinutes,
|
||||
row.businessService?.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
const durationMinutes = await this.resolveGroupDuration(row);
|
||||
await this.assertSchedulableSlot({
|
||||
businessId: row.businessId,
|
||||
businessServiceId: row.businessServiceId,
|
||||
scheduledAt,
|
||||
durationMinutes,
|
||||
excludeBookingId: row.id,
|
||||
excludeBookingGroupId: row.bookingGroupId ?? undefined,
|
||||
});
|
||||
row.scheduledAt = scheduledAt;
|
||||
if (row.bookingGroupId) {
|
||||
await this.repo.update(
|
||||
{ bookingGroupId: row.bookingGroupId },
|
||||
{ scheduledAt },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.status !== undefined) {
|
||||
@@ -240,9 +340,13 @@ export class BookingsService {
|
||||
|
||||
async getAvailableSlots(
|
||||
businessId: string,
|
||||
businessServiceId: string,
|
||||
dateYmd: string,
|
||||
): Promise<{ date: string; slots: string[] }> {
|
||||
options: {
|
||||
businessServiceId?: string;
|
||||
businessServiceIds?: string[];
|
||||
serviceClassId?: string;
|
||||
},
|
||||
): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> {
|
||||
parseGregorianYmd(dateYmd);
|
||||
|
||||
const business = await this.businessRepo.findOne({
|
||||
@@ -252,32 +356,49 @@ export class BookingsService {
|
||||
throw new NotFoundException('Business not found or inactive');
|
||||
}
|
||||
|
||||
const businessService = await this.businessServiceRepo.findOne({
|
||||
where: { id: businessServiceId, businessId, isActive: true },
|
||||
relations: { serviceTemplate: true },
|
||||
});
|
||||
if (!businessService) {
|
||||
throw new NotFoundException('Business service not found or inactive');
|
||||
const serviceIds =
|
||||
options.businessServiceIds?.length
|
||||
? options.businessServiceIds
|
||||
: options.businessServiceId
|
||||
? [options.businessServiceId]
|
||||
: [];
|
||||
|
||||
if (!serviceIds.length) {
|
||||
throw new BadRequestException('At least one business service is required');
|
||||
}
|
||||
|
||||
const durationMinutes = resolveServiceDurationMinutes(
|
||||
businessService.durationMinutes,
|
||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
const serviceClassId = options.serviceClassId ?? null;
|
||||
let totalDurationMinutes = 0;
|
||||
|
||||
for (const serviceId of serviceIds) {
|
||||
const businessService = await this.businessServiceRepo.findOne({
|
||||
where: { id: serviceId, businessId, isActive: true },
|
||||
relations: { serviceTemplate: true },
|
||||
});
|
||||
if (!businessService) {
|
||||
throw new NotFoundException('Business service not found or inactive');
|
||||
}
|
||||
|
||||
const pricing = await this.resolveBookingPricing(
|
||||
businessService,
|
||||
serviceClassId,
|
||||
);
|
||||
totalDurationMinutes += pricing.durationMinutes;
|
||||
}
|
||||
|
||||
const windows = await this.loadAvailabilityWindows(businessId);
|
||||
const busy = await this.loadBusyIntervals(businessId, dateYmd);
|
||||
|
||||
const slots = generateAvailableSlotStarts({
|
||||
dateYmd,
|
||||
durationMinutes,
|
||||
durationMinutes: totalDurationMinutes,
|
||||
leadMinutes: this.minLeadMinutes,
|
||||
slotStepMinutes: 15,
|
||||
windows,
|
||||
busy,
|
||||
});
|
||||
|
||||
return { date: dateYmd, slots };
|
||||
return { date: dateYmd, slots, totalDurationMinutes };
|
||||
}
|
||||
|
||||
private async loadAvailabilityWindows(
|
||||
@@ -294,11 +415,20 @@ export class BookingsService {
|
||||
private async loadBusyIntervals(
|
||||
businessId: string,
|
||||
dateYmd: string,
|
||||
excludeBookingId?: string,
|
||||
options?: { excludeBookingId?: string; excludeBookingGroupId?: string },
|
||||
): Promise<BusyInterval[]> {
|
||||
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
|
||||
const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`);
|
||||
|
||||
let excludeGroupId = options?.excludeBookingGroupId ?? null;
|
||||
if (options?.excludeBookingId && !excludeGroupId) {
|
||||
const excluded = await this.repo.findOne({
|
||||
where: { id: options.excludeBookingId },
|
||||
select: ['id', 'bookingGroupId'],
|
||||
});
|
||||
excludeGroupId = excluded?.bookingGroupId ?? null;
|
||||
}
|
||||
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.businessService', 'bs')
|
||||
@@ -310,34 +440,202 @@ export class BookingsService {
|
||||
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
|
||||
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
|
||||
|
||||
if (excludeBookingId) {
|
||||
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
|
||||
if (options?.excludeBookingId && !excludeGroupId) {
|
||||
qb.andWhere('b.id != :excludeBookingId', {
|
||||
excludeBookingId: options.excludeBookingId,
|
||||
});
|
||||
}
|
||||
if (excludeGroupId) {
|
||||
qb.andWhere('b.bookingGroupId IS NULL OR b.bookingGroupId != :excludeGroupId', {
|
||||
excludeGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
const bookings = await qb.getMany();
|
||||
const durations = await Promise.all(
|
||||
bookings.map((b) => this.resolveBookingDuration(b)),
|
||||
);
|
||||
|
||||
return bookings.map((b) => {
|
||||
const duration = resolveServiceDurationMinutes(
|
||||
b.businessService?.durationMinutes,
|
||||
b.businessService?.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
const startMs = b.scheduledAt.getTime();
|
||||
return {
|
||||
startMs,
|
||||
endMs: startMs + duration * 60 * 1000,
|
||||
};
|
||||
const groupTotals = new Map<
|
||||
string,
|
||||
{ startMs: number; totalDuration: number }
|
||||
>();
|
||||
const singles: BusyInterval[] = [];
|
||||
|
||||
for (const [index, booking] of bookings.entries()) {
|
||||
const duration = durations[index] ?? 30;
|
||||
const startMs = booking.scheduledAt.getTime();
|
||||
|
||||
if (booking.bookingGroupId) {
|
||||
const key = `${booking.bookingGroupId}:${startMs}`;
|
||||
const existing = groupTotals.get(key);
|
||||
if (existing) {
|
||||
existing.totalDuration += duration;
|
||||
} else {
|
||||
groupTotals.set(key, { startMs, totalDuration: duration });
|
||||
}
|
||||
} else {
|
||||
singles.push({
|
||||
startMs,
|
||||
endMs: startMs + duration * 60 * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...singles,
|
||||
...Array.from(groupTotals.values()).map((group) => ({
|
||||
startMs: group.startMs,
|
||||
endMs: group.startMs + group.totalDuration * 60 * 1000,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
private async resolveBookingPricing(
|
||||
businessService: BusinessServiceEntity,
|
||||
serviceClassId: string | null,
|
||||
): Promise<{ serviceClassId: string | null; durationMinutes: number }> {
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: businessService.businessId },
|
||||
});
|
||||
const activeClassCount = await this.serviceClassRepo.count({
|
||||
where: { businessId: businessService.businessId, isActive: true },
|
||||
});
|
||||
|
||||
const usesClasses =
|
||||
business?.serviceClassesEnabled === true && activeClassCount > 0;
|
||||
|
||||
if (!usesClasses) {
|
||||
if (serviceClassId) {
|
||||
throw new BadRequestException(
|
||||
'This business does not use service classes',
|
||||
);
|
||||
}
|
||||
return {
|
||||
serviceClassId: null,
|
||||
durationMinutes: resolveServiceDurationMinutes(
|
||||
businessService.durationMinutes,
|
||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!serviceClassId) {
|
||||
throw new BadRequestException('serviceClassId is required for this business');
|
||||
}
|
||||
|
||||
const serviceClass = await this.serviceClassRepo.findOne({
|
||||
where: {
|
||||
id: serviceClassId,
|
||||
businessId: businessService.businessId,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!serviceClass) {
|
||||
throw new BadRequestException('Service class not found or inactive');
|
||||
}
|
||||
|
||||
const classPrice = await this.classPriceRepo.findOne({
|
||||
where: {
|
||||
businessServiceId: businessService.id,
|
||||
classId: serviceClassId,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!classPrice) {
|
||||
throw new BadRequestException(
|
||||
'This service is not available for the selected class',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
serviceClassId,
|
||||
durationMinutes: resolveServiceDurationMinutes(
|
||||
classPrice.durationMinutes ?? businessService.durationMinutes,
|
||||
businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveGroupDuration(booking: BookingEntity): Promise<number> {
|
||||
if (!booking.bookingGroupId) {
|
||||
return this.resolveBookingDuration(booking);
|
||||
}
|
||||
|
||||
const siblings = await this.repo.find({
|
||||
where: {
|
||||
bookingGroupId: booking.bookingGroupId,
|
||||
status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]),
|
||||
},
|
||||
relations: { businessService: { serviceTemplate: true } },
|
||||
});
|
||||
|
||||
const rows = siblings.length
|
||||
? siblings
|
||||
: [
|
||||
{
|
||||
...booking,
|
||||
businessService:
|
||||
booking.businessService ??
|
||||
(await this.businessServiceRepo.findOne({
|
||||
where: { id: booking.businessServiceId },
|
||||
relations: { serviceTemplate: true },
|
||||
})),
|
||||
} as BookingEntity,
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
total += await this.resolveBookingDuration(row);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async resolveBookingDuration(booking: BookingEntity): Promise<number> {
|
||||
if (!booking.businessService) {
|
||||
const businessService = await this.businessServiceRepo.findOne({
|
||||
where: { id: booking.businessServiceId },
|
||||
relations: { serviceTemplate: true },
|
||||
});
|
||||
if (!businessService) {
|
||||
return 30;
|
||||
}
|
||||
booking.businessService = businessService;
|
||||
}
|
||||
|
||||
if (booking.serviceClassId) {
|
||||
const classPrice = await this.classPriceRepo.findOne({
|
||||
where: {
|
||||
businessServiceId: booking.businessServiceId,
|
||||
classId: booking.serviceClassId,
|
||||
},
|
||||
});
|
||||
return resolveServiceDurationMinutes(
|
||||
classPrice?.durationMinutes ?? booking.businessService.durationMinutes,
|
||||
booking.businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
return resolveServiceDurationMinutes(
|
||||
booking.businessService.durationMinutes,
|
||||
booking.businessService.serviceTemplate?.defaultDurationMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertSchedulableSlot(params: {
|
||||
businessId: string;
|
||||
businessServiceId: string;
|
||||
scheduledAt: Date;
|
||||
durationMinutes: number;
|
||||
excludeBookingId?: string;
|
||||
excludeBookingGroupId?: string;
|
||||
}): Promise<void> {
|
||||
const { businessId, scheduledAt, durationMinutes, excludeBookingId } =
|
||||
params;
|
||||
const {
|
||||
businessId,
|
||||
scheduledAt,
|
||||
durationMinutes,
|
||||
excludeBookingId,
|
||||
excludeBookingGroupId,
|
||||
} = params;
|
||||
|
||||
this.assertLeadTime(scheduledAt);
|
||||
|
||||
@@ -355,11 +653,10 @@ export class BookingsService {
|
||||
leadMinutes: this.minLeadMinutes,
|
||||
slotStepMinutes: 15,
|
||||
windows,
|
||||
busy: await this.loadBusyIntervals(
|
||||
businessId,
|
||||
dateYmd,
|
||||
busy: await this.loadBusyIntervals(businessId, dateYmd, {
|
||||
excludeBookingId,
|
||||
),
|
||||
excludeBookingGroupId,
|
||||
}),
|
||||
});
|
||||
|
||||
const scheduledMs = scheduledAt.getTime();
|
||||
|
||||
Reference in New Issue
Block a user