Add time rezerv

This commit is contained in:
2026-05-25 19:12:32 +03:30
parent 26c3e02d6f
commit 8263198d1e
12 changed files with 540 additions and 3 deletions
+176
View File
@@ -0,0 +1,176 @@
import { BadRequestException } from '@nestjs/common';
/** Iranian week: شنبه=0 … جمعه=6 */
const TEHRAN_WEEKDAY: Record<string, number> = {
Sat: 0,
Sun: 1,
Mon: 2,
Tue: 3,
Wed: 4,
Thu: 5,
Fri: 6,
};
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
export type AvailabilityWindow = {
dayOfWeek: number;
startMinute: number;
endMinute: number;
};
export type BusyInterval = {
startMs: number;
endMs: number;
};
export function parseHmToMinute(value: string): number {
const match = TIME_RE.exec(String(value).trim());
if (!match) {
throw new BadRequestException('Time must be HH:mm (24h)');
}
return Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10);
}
export function minuteToHm(minute: number): string {
const h = Math.floor(minute / 60);
const m = minute % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function parseGregorianYmd(ymd: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
throw new BadRequestException('date must be YYYY-MM-DD');
}
}
/** Tehran local datetime as UTC Date. */
export function tehranDateTime(ymd: string, hour: number, minute: number): Date {
parseGregorianYmd(ymd);
const hh = String(hour).padStart(2, '0');
const mm = String(minute).padStart(2, '0');
return new Date(`${ymd}T${hh}:${mm}:00+03:30`);
}
export function tehranYmd(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const y = parts.find((p) => p.type === 'year')?.value;
const m = parts.find((p) => p.type === 'month')?.value;
const d = parts.find((p) => p.type === 'day')?.value;
return `${y}-${m}-${d}`;
}
export function tehranDayOfWeek(date: Date): number {
const wd = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
weekday: 'short',
}).format(date);
const mapped = TEHRAN_WEEKDAY[wd];
if (mapped === undefined) {
throw new BadRequestException('Could not resolve weekday');
}
return mapped;
}
export function isSameTehranDay(a: Date, b: Date): boolean {
return tehranYmd(a) === tehranYmd(b);
}
export function intervalsOverlap(
aStart: number,
aEnd: number,
bStart: number,
bEnd: number,
): boolean {
return aStart < bEnd && bStart < aEnd;
}
export function resolveServiceDurationMinutes(
durationMinutes: number | null | undefined,
templateDefault?: number | null,
): number {
if (durationMinutes != null && durationMinutes > 0) {
return durationMinutes;
}
if (templateDefault != null && templateDefault > 0) {
return templateDefault;
}
return 30;
}
export function generateAvailableSlotStarts(params: {
dateYmd: string;
durationMinutes: number;
leadMinutes: number;
slotStepMinutes: number;
windows: AvailabilityWindow[];
busy: BusyInterval[];
now?: Date;
}): string[] {
const {
dateYmd,
durationMinutes,
leadMinutes,
slotStepMinutes,
windows,
busy,
now = new Date(),
} = params;
const dayAnchor = tehranDateTime(dateYmd, 12, 0);
const dow = tehranDayOfWeek(dayAnchor);
const dayWindows = windows.filter((w) => w.dayOfWeek === dow);
if (!dayWindows.length) {
return [];
}
const earliestMs = isSameTehranDay(dayAnchor, now)
? now.getTime() + leadMinutes * 60 * 1000
: tehranDateTime(dateYmd, 0, 0).getTime();
const slots: string[] = [];
const step = Math.max(5, slotStepMinutes);
for (const window of dayWindows) {
for (
let startMin = window.startMinute;
startMin + durationMinutes <= window.endMinute;
startMin += step
) {
const hour = Math.floor(startMin / 60);
const minute = startMin % 60;
const startDate = tehranDateTime(dateYmd, hour, minute);
const startMs = startDate.getTime();
const endMs = startMs + durationMinutes * 60 * 1000;
if (startMs < earliestMs) {
continue;
}
const windowEndMs = tehranDateTime(
dateYmd,
Math.floor(window.endMinute / 60),
window.endMinute % 60,
).getTime();
if (endMs > windowEndMs) {
continue;
}
const blocked = busy.some((b) =>
intervalsOverlap(startMs, endMs, b.startMs, b.endMs),
);
if (blocked) {
continue;
}
slots.push(startDate.toISOString());
}
}
return slots;
}
@@ -6,6 +6,7 @@ import { BusinessAttributeValueEntity } from '../../entities/business-attribute-
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessImageEntity } from '../../entities/business-image.entity';
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
@@ -16,6 +17,7 @@ export async function deleteBusinessCascade(
onBeforeDeleteBusiness?: (businessId: string) => Promise<void>,
): Promise<void> {
await manager.delete(BookingEntity, { businessId });
await manager.delete(BusinessAvailabilitySlotEntity, { businessId });
await manager.delete(BusinessServiceEntity, { businessId });
await manager.delete(BusinessAttributeValueEntity, { businessId });
await manager.delete(BusinessImageEntity, { businessId });