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:
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user