feat(booking): add service-aware reschedule endpoint

POST /api/v1/appointment/{uuid}/service-reschedule takes only a start time and
derives the length from the appointment's services. PATCH also validates the
duration, but the client must already know the correct slot_end; not needing that
knowledge is what lets the edit form drop its manual time inputs.

The start must be a member of getServiceStartTimes(), not merely free:
isSlotTaken() reports collisions with other appointments, while the offered list
also applies shift bounds, holidays, date overrides, the booking window and the
buffer. Without it a secretary could park an appointment at 3am.

forManagement comes from canManageContext(), not canManage(): a patient moving
their own appointment must still respect the public booking window.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 13:03:04 +03:30
co-authored by Claude Opus 5
parent 231735162e
commit bfe7f36a45
4 changed files with 503 additions and 4 deletions
@@ -44,6 +44,7 @@ class AppointmentController extends BaseController
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -1223,6 +1224,62 @@ class AppointmentController extends BaseController
return $this->success(['data' => $appointment->toArray()]);
}
/**
* جابه‌جایی سرویس‌آگاه — فقط حالت نوبت‌دهی سرویسی.
*
* کلاینت مدت نمی‌فرستد: `start` می‌دهد و سرور مدت را از سرویس‌های نوبت (یا فهرست
* صریحِ درخواست) حساب می‌کند. این تفاوت با `PATCH` است که کلاینت باید `slot_end`
* درست را از قبل بداند.
*
* POST /api/v1/appointment/{uuid}/service-reschedule
*/
#[Route('/api/v1/appointment/{uuid}/service-reschedule', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$appointment = $this->appointmentRepo->findByUuid($uuid);
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
}
if (!$this->canManage($appointment, $user)) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$start = (int) ($data['start'] ?? 0);
if ($start <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'زمان شروع الزامی است', 422, 'start');
}
$serviceUuids = array_key_exists('service_item_uuids', $data)
? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
: null;
try {
$duration = $this->rescheduleService->reschedule(
$appointment,
$start,
$serviceUuids,
(array) ($data['durations'] ?? []),
$this->accessChecker->canManageContext($user, $appointment->getDoctor(), $appointment->getClinic()),
array_key_exists('version', $data) ? (int) $data['version'] : null,
);
} catch (OptimisticLockException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'start');
}
return $this->success([
'uuid' => $appointment->getUuid(),
'slot_start' => $appointment->getSlotStart(),
'slot_end' => $appointment->getSlotEnd(),
'total_duration_minutes' => $duration->totalMinutes,
'buffer_minutes' => $duration->bufferMinutes,
'warnings' => $duration->warnings,
]);
}
// ── Timeline: رویدادهای یک نوبت ───────────────────────────────────────────
#[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])]
@@ -0,0 +1,132 @@
<?php
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\ValueObject\ServiceBookingDuration;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\OptimisticLockException;
/**
* جابه‌جایی سرویس‌آگاهِ یک نوبت: کلاینت **فقط زمان شروع** می‌فرستد و مدت را سرور از
* سرویس‌های نوبت حساب می‌کند.
*
* `PATCH /appointment/{uuid}` هم مدت را اعتبارسنجی می‌کند، ولی کلاینت باید `slot_end`
* درست را از قبل بداند. اینجا آن دانش لازم نیست — و همین تفاوت است که ورودی دستیِ
* ساعت را از فرم ویرایش حذف می‌کند.
*/
final class ServiceRescheduleService
{
public function __construct(
private readonly AppointmentRepository $appointments,
private readonly ServiceBookingCalculator $calculator,
private readonly SlotCalculatorService $slots,
) {}
/**
* @param string[]|null $serviceUuids null = همان سرویس‌های فعلی نوبت
* @param array<string, int|string> $durationOverrides
*
* @throws AppException|OptimisticLockException
*/
public function reschedule(
Appointment $appointment,
int $start,
?array $serviceUuids = null,
array $durationOverrides = [],
bool $forManagement = false,
?int $expectedVersion = null,
): ServiceBookingDuration {
$doctor = $appointment->getDoctor();
$clinic = $appointment->getClinic();
if (!$this->calculator->isServiceMode($doctor, $clinic)) {
throw new AppException(
ErrorCodes::ERR_APPOINTMENT_004,
'این نوبت در حالت نوبت‌دهی سرویسی نیست',
422,
);
}
// نوبت رزرو زمان ندارد؛ تبدیلش به نوبت زمان‌دار کار `PATCH` با `is_reserve: false` است.
if ($appointment->isReserve()) {
throw new AppException(
ErrorCodes::ERR_APPOINTMENT_004,
'نوبت رزرو زمان ندارد؛ برای تعیین زمان از ویرایش نوبت استفاده کنید',
422,
);
}
if ($start < time()) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'زمان انتخابی در گذشته است', 422, 'start');
}
$requested = $serviceUuids ?? $appointment->currentServiceUuids();
if ($requested === []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
'این نوبت سرویسی ندارد؛ ابتدا سرویس آن را ثبت کنید',
422,
'service_item_uuids',
);
}
$duration = $this->calculator->calculate(
$doctor,
$clinic,
$requested,
$durationOverrides,
// سرویس‌های موجودِ نوبت ممکن است غیرفعال شده باشند؛ نوبت نباید قفل شود.
// ولی فهرستِ صریحِ درخواست باید همه فعال باشند.
allowInactive: $serviceUuids === null,
);
$this->assertStartIsOffered($appointment, $start, $duration->totalMinutes, $forManagement);
$appointment->rescheduleTo($start, $duration->endFor($start));
$appointment->replaceServiceItems($duration->serviceItems);
$appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes);
if ($expectedVersion !== null) {
$this->appointments->saveWithLock($appointment, $expectedVersion);
} else {
$this->appointments->save($appointment);
}
return $duration;
}
/**
* زمان باید واقعاً یکی از زمان‌های پیشنهادی باشد، نه فقط «اشغال نیست».
*
* `isSlotTaken()` تنها تداخل با نوبت دیگر را می‌گوید؛ `getServiceStartTimes()` علاوه
* بر آن شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط
* اول، منشی می‌توانست نوبت را ساعت ۳ بامداد بگذارد.
*/
private function assertStartIsOffered(
Appointment $appointment,
int $start,
int $totalMinutes,
bool $forManagement,
): void {
$offered = $this->slots->getServiceStartTimes(
$appointment->getDoctor(),
date('Y-m-d', $start),
$totalMinutes,
$appointment->getClinic(),
$forManagement,
$appointment->getId(),
);
if (!in_array($start, array_map('intval', array_column($offered, 'start')), true)) {
throw new AppException(
ErrorCodes::ERR_APPOINTMENT_001,
'این زمان برای مدت انتخابی در دسترس نیست',
422,
'start',
);
}
}
}