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
+310
View File
@@ -0,0 +1,310 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Tests\ApiTestCase;
/**
* `POST /api/v1/appointment/{uuid}/service-reschedule` — کلاینت مدت نمی‌فرستد.
*
* تفاوتش با `PATCH`: آنجا کلاینت باید `slot_end` درست را از قبل بداند؛ اینجا فقط
* `start` می‌دهد. همین است که ورودی دستیِ ساعت را از فرم ویرایش حذف می‌کند.
*/
class ServiceRescheduleTest extends ApiTestCase
{
private const DAY_OFFSET = '+2 days';
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:ServiceSection} */
private function doctorInMode(string $mode, int $buffer = 0): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر جابه‌جایی سرویسی');
$this->em->persist($doctor);
$this->em->flush();
$date = date('Y-m-d', strtotime(self::DAY_OFFSET));
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
$schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true, 'start_time' => '09:00', 'end_time' => '18:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => $mode, 'buffer_minutes' => $buffer]);
$this->em->persist($schedule);
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش جابه‌جایی');
$this->em->persist($section);
$this->em->flush();
return [$owner, $doctor, $section];
}
private function service(ServiceSection $section, string $name, int $minutes, bool $bookable = true): ServiceItem
{
$item = new ServiceItem($section, $name, 0);
$item->setDurationMinutes($minutes)->setBookable($bookable);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function at(string $time): int
{
return (int) strtotime(date('Y-m-d', strtotime(self::DAY_OFFSET)) . ' ' . $time);
}
private function appointment(Doctor $doctor, string $time, int $minutes, array $items = []): Appointment
{
$patient = $this->createUser(['ROLE_USER']);
$start = $this->at($time);
$appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
if ($items !== []) {
$appt->replaceServiceItems($items);
}
$this->em->persist($appt);
$this->em->flush();
return $appt;
}
private function move(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array
{
return $this->authJson('POST', '/api/v1/appointment/' . $appt->getUuid() . '/service-reschedule', $actor, $body);
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testMovingKeepsDurationWithoutTheClientSendingIt(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
$res = $this->move($appt, $owner, ['start' => $this->at('14:00')]);
self::assertSame(200, $this->responseCode());
self::assertSame(30, $res['data']['total_duration_minutes']);
self::assertSame($this->at('14:00'), $res['data']['slot_start']);
self::assertSame($this->at('14:30'), $res['data']['slot_end'], 'مدت را سرور حساب کرد');
}
public function testDroppingAServiceShrinksTheDurationAutomatically(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$face = $this->service($section, 'صورت', 20);
$bikini = $this->service($section, 'بیکینی', 15);
$appt = $this->appointment($doctor, '10:00', 35, [$face, $bikini]);
$res = $this->move($appt, $owner, [
'start' => $this->at('14:00'),
'service_item_uuids' => [$face->getUuid()],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(20, $res['data']['total_duration_minutes'], 'کلاینت هیچ عددی نفرستاد');
self::assertSame($this->at('14:20'), $res['data']['slot_end']);
}
public function testCurrentTimeIsOfferedBackToTheAppointmentItself(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$short = $this->service($section, 'کوتاه', 30);
$long = $this->service($section, 'بلند', 60);
$appt = $this->appointment($doctor, '10:00', 30, [$short]);
// همان ساعت ۱۰:۰۰، سرویس بلندتر — بدون exclude، نوبت خودش را اشغال می‌دید.
$res = $this->move($appt, $owner, [
'start' => $this->at('10:00'),
'service_item_uuids' => [$long->getUuid()],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(60, $res['data']['total_duration_minutes']);
self::assertSame($this->at('11:00'), $res['data']['slot_end']);
}
public function testInactiveServiceOfAnExistingAppointmentYieldsAWarningNotAnError(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر بازنشسته', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
// کلینیک سرویس را غیرفعال می‌کند؛ نوبت موجود باید همچنان جابه‌جا شود.
$item->setBookable(false);
$this->em->flush();
$res = $this->move($appt, $owner, ['start' => $this->at('14:00')]);
self::assertSame(200, $this->responseCode(), 'نوبت موجود نباید برای همیشه قفل شود');
self::assertCount(1, $res['data']['warnings']);
self::assertStringContainsString('لیزر بازنشسته', $res['data']['warnings'][0]);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testSlotModeAppointmentIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT);
$item = $this->service($section, 'سرویس', 30);
$appt = $this->appointment($doctor, '10:00', 20, [$item]);
$res = $this->move($appt, $owner, ['start' => $this->at('14:00')]);
self::assertSame(422, $this->responseCode());
self::assertSame(ErrorCodes::ERR_APPOINTMENT_004, $res['errors'][0]['code']);
}
public function testTimeOutsideTheOfferedListIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
// ۰۳:۰۰ بیرون از شیفت ۰۹:۰۰–۱۸:۰۰ است. `isSlotTaken` تنها این را نمی‌گرفت.
$res = $this->move($appt, $owner, ['start' => $this->at('03:00')]);
self::assertSame(422, $this->responseCode());
self::assertSame(ErrorCodes::ERR_APPOINTMENT_001, $res['errors'][0]['code']);
self::assertSame('start', $res['errors'][0]['field']);
}
public function testReserveEntryIsRejectedWithAPointerToPatch(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$midnight = $this->at('00:00');
$patient = $this->createUser(['ROLE_USER']);
$reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight);
$reserve->rescheduleTo($midnight, $midnight, true);
$reserve->replaceServiceItems([$item]);
$this->em->persist($reserve);
$this->em->flush();
$res = $this->move($reserve, $owner, ['start' => $this->at('14:00')]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('ویرایش نوبت', $res['errors'][0]['message']);
}
public function testMissingStartIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
$res = $this->move($appt, $owner, []);
self::assertSame(422, $this->responseCode());
self::assertSame('start', $res['errors'][0]['field']);
}
public function testForeignServiceIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$mine = $this->service($section, 'خودم', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$mine]);
[, , $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$foreign = $this->service($otherSection, 'بیگانه', 30);
$res = $this->move($appt, $owner, [
'start' => $this->at('14:00'),
'service_item_uuids' => [$foreign->getUuid()],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $res['errors'][0]['message']);
}
public function testAddingAnInactiveServiceExplicitlyIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$active = $this->service($section, 'فعال', 30);
$inactive = $this->service($section, 'غیرفعال', 20, bookable: false);
$appt = $this->appointment($doctor, '10:00', 30, [$active]);
$res = $this->move($appt, $owner, [
'start' => $this->at('14:00'),
'service_item_uuids' => [$active->getUuid(), $inactive->getUuid()],
]);
self::assertSame(422, $this->responseCode(), 'افزودن سرویس غیرفعال تازه مجاز نیست');
self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $res['errors'][0]['message']);
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testPastStartIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
$res = $this->move($appt, $owner, ['start' => time() - 3600]);
self::assertSame(422, $this->responseCode());
self::assertSame('زمان انتخابی در گذشته است', $res['errors'][0]['message']);
}
public function testAppointmentWithoutAnyServiceIsRejectedWithAClearReason(): void
{
[$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$appt = $this->appointment($doctor, '10:00', 30);
$res = $this->move($appt, $owner, ['start' => $this->at('14:00')]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('سرویسی ندارد', $res['errors'][0]['message']);
}
public function testStaleVersionYieldsConflict(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
$res = $this->move($appt, $owner, [
'start' => $this->at('14:00'),
'version' => $appt->getVersion() + 5,
]);
self::assertSame(409, $this->responseCode());
self::assertSame(ErrorCodes::ERR_CONFLICT_001, $res['errors'][0]['code']);
}
public function testAnotherUsersAppointmentIsForbidden(): void
{
[, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, '10:00', 30, [$item]);
$stranger = $this->createUser(['ROLE_USER']);
$this->move($appt, $stranger, ['start' => $this->at('14:00')]);
self::assertSame(403, $this->responseCode());
}
public function testUnknownAppointmentIsNotFound(): void
{
[$owner] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$this->authJson(
'POST',
'/api/v1/appointment/00000000-0000-4000-8000-000000000000/service-reschedule',
$owner,
['start' => $this->at('14:00')],
);
self::assertSame(404, $this->responseCode());
}
}