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
@@ -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',
);
}
}
}