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:
@@ -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