getContent(), true); if (!is_array($data)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } foreach (['service_uuid', 'branch_uuid'] as $field) { if (!is_string($data[$field] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field); } } if (!is_numeric($data['from'] ?? null) || !is_numeric($data['to'] ?? null)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلدهای from و to الزامی‌اند', 422, 'from'); } $from = (int) $data['from']; $to = (int) $data['to']; if ($to < $from) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'to باید بعد از from باشد', 422, 'to'); } if (intdiv($to - $from, 86400) + 1 > AvailabilityEngine::MAX_DAYS) { return $this->error( ErrorCodes::ERR_VALIDATION_001, sprintf('بازهٔ درخواستی حداکثر %d روز است', AvailabilityEngine::MAX_DAYS), 422, 'to', ); } $address = $this->branches->resolve($user, $data['branch_uuid']); $service = $this->requireItem($user, $data['service_uuid']); $this->assertResourceMode($data['doctor_uuid'] ?? null, $address); $selected = []; foreach (($data['item_uuids'] ?? []) as $itemUuid) { if (!is_string($itemUuid)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'item_uuids باید فهرستی از uuid باشد', 422, 'item_uuids'); } $selected[] = $this->requireItem($user, $itemUuid); } $plan = $this->planner->build( $service, $selected, $address, is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null, ); $step = is_numeric($data['step_minutes'] ?? null) ? (int) $data['step_minutes'] : AvailabilityEngine::DEFAULT_STEP_MINUTES; $slots = $this->engine->search( $plan, $address, $from, $to, $step, null, $this->strategyFor($data['doctor_uuid'] ?? null), ); return $this->success([ 'plan' => $plan->toArray(), 'slots' => array_map(static fn (AvailableSlot $s): array => $s->toArray(), $slots), // فهرست خالی خطا نیست: ممکن است واقعاً ظرفیتی نباشد. دلیلش صریح می‌آید // تا کلاینت مجبور نباشد از خالی بودن حدس بزند. 'reason' => $slots === [] ? 'no_capacity_in_range' : null, ]); } /** * نمای ماه: فقط «این روز ظرفیت دارد یا نه». عمداً سبک است — تقویم ماهانه نباید * تخصیص منبع هر زمان را بسازد. */ #[Route('/api/v1/appointment-availability/month', name: 'appointment_availability_month', methods: ['GET'])] public function month(#[CurrentUser] User $user, Request $request): JsonResponse { $serviceUuid = $request->query->get('service_uuid'); $branchUuid = $request->query->get('branch_uuid'); $from = $request->query->get('from'); $to = $request->query->get('to'); if (!is_string($serviceUuid) || !is_string($branchUuid) || !is_numeric($from) || !is_numeric($to)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'service_uuid، branch_uuid، from و to الزامی‌اند', 422, 'service_uuid'); } $address = $this->branches->resolve($user, $branchUuid); $service = $this->requireItem($user, $serviceUuid); $this->assertResourceMode($request->query->get('doctor_uuid'), $address); $plan = $this->planner->build($service, [], $address); $slots = $this->engine->search($plan, $address, (int) $from, (int) $to); $days = []; foreach ($slots as $slot) { $midnight = (new \DateTimeImmutable('@' . $slot->start)) ->setTimezone(new \DateTimeZone($address->getTimezone())) ->setTime(0, 0) ->getTimestamp(); $days[$midnight] = true; } return $this->success(['days' => array_keys($days)]); } /** * فقط محلی که صریحاً روی حالت منبع رفته این مسیر را دارد. * * @throws AppException */ /** * استراتژی ترتیب منابع از تنظیمات همان محل می‌آید. * * کلید ناشناخته یا نبودِ برنامهٔ هفتگی به پیش‌فرض برمی‌گردد — تنظیماتِ ناقص نباید * جستجوی وقت را بخواباند. */ private function strategyFor(mixed $doctorUuid): ?string { if (!is_string($doctorUuid) || $doctorUuid === '') { return null; } $doctor = $this->doctors->findOneBy(['uuid' => $doctorUuid]); if ($doctor === null) { return null; } foreach ($this->schedules->findAllByDoctor($doctor) as $schedule) { $meta = $schedule->getMeta(); if (($meta['booking_mode'] ?? null) === WeeklySchedule::MODE_RESOURCE) { return is_string($meta['resource_strategy'] ?? null) ? $meta['resource_strategy'] : null; } } return null; } private function assertResourceMode(mixed $doctorUuid, DoctorAddress $address): void { if (!is_string($doctorUuid) || $doctorUuid === '') { return; // بدون پزشک، حالت از برنامهٔ هفتگی قابل استنتاج نیست } $doctor = $this->doctors->findOneBy(['uuid' => $doctorUuid]); if ($doctor === null) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); } foreach ($this->schedules->findAllByDoctor($doctor) as $schedule) { if (($schedule->getMeta()['booking_mode'] ?? null) === WeeklySchedule::MODE_RESOURCE) { return; } } throw new AppException( ErrorCodes::ERR_WRONG_BOOKING_MODE, 'این محل هنوز روی نوبت‌دهی چندمنبعی نیست', 422, 'doctor_uuid', ); } private function requireItem(User $user, string $uuid): ServiceItem { $item = $this->items->findByUuid($uuid); [$entityType, $entityId] = $this->branches->pair($user); if ($item === null || $item->getSection()->getEntityType() !== $entityType || $item->getSection()->getEntityId() !== $entityId ) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); } return $item; } }