Files
clinicpro/src/Appointment/Availability/Controller/AvailabilityController.php
T
hamed c4f1f25c80 Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote.
- Eliminated unused domain event publishing for policies and waitlist in Schedule.
- Cleaned up BookingEngineSeeder by removing package and policy related logic.
- Updated SeedScenariosCommand to reflect removal of policies from output.
- Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration.
- Removed domain event assertions from tests related to resource blocking.
2026-08-01 20:50:47 +03:30

236 lines
9.0 KiB
PHP

<?php
namespace App\Appointment\Availability\Controller;
use App\Appointment\Availability\Service\AvailabilityEngine;
use App\Appointment\Availability\ValueObject\AvailableSlot;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* جستجوی وقت چندمنبعی.
*
* فقط برای محل‌هایی که صریحاً روی `booking_mode = resource` رفته‌اند. بقیه همان مسیر
* قبلی (`appointment-slots` / `appointment-service-slots`) را دارند و دست‌نخورده‌اند.
*/
#[OA\Tag(name: 'Appointment Availability')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class AvailabilityController extends BaseController
{
public function __construct(
private readonly AppointmentPlanBuilder $planner,
private readonly AvailabilityEngine $engine,
private readonly ServiceItemRepository $items,
private readonly WeeklyScheduleRepository $schedules,
private readonly DoctorRepository $doctors,
private readonly BranchResolver $branches,
) {}
#[Route('/api/v1/appointment-availability', name: 'appointment_availability', methods: ['POST'])]
public function search(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->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;
}
}