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'])]