- 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.
236 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|