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
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro)
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۵ از ۱۰ تمام شد
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۶ از ۱۰ تمام شد (بک‌اند کامل؛ مانده: UI، backfill، مستندات)
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
@@ -31,7 +31,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ✅ | `src/Appointment/Service/ServiceBookingCalculator.php` + VO در `src/Appointment/ValueObject/ServiceBookingDuration.php` (قرارداد پروژه `ValueObject` است نه `Dto``src/Billing/ValueObject`، `src/Insurance/ValueObject`) |
| ۱.۲ | `serviceSlots()` موجود از آن استفاده می‌کند، **خروجی‌اش عوض نشده** | ✅ | ۲۰ خط از کنترلر حذف شد. بیت‌به‌بیت‌بودن: `error()` و `ExceptionSubscriber` **یک envelope یکسان** می‌سازند (`['success'=>false,'data'=>null,'errors'=>[{code,message,field?}]]`)، پس `AppException` جای `return $this->error()` نشست. شاهد: ۱۶۶ تست موجود سبز ماند، شامل `ServiceBasedSlotsTest` و `ServiceModeSectionDurationTest` که همین مسیرهای خطا را می‌سنجند |
| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ✅ | با کامنت صریح در docblock کلاس: «تسک ۰۴ همان‌جا یک خط عوض می‌کند» |
| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | | |
| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | | route ثبت شد (`app_appointment_appointment_servicereschedule`). کنترلر نازک: فقط ۴۰۴/۴۰۳/اعتبار `start` و ترجمهٔ دو استثنای همزمانی؛ منطق در سرویس. `forManagement` از `canManageContext()` می‌آید نه از `canManage()` — بیمارِ صاحب نوبت باید پنجرهٔ رزرو را رعایت کند |
| ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ✅ | سرویس‌ها **پیش از** بلوک زمان حل می‌شوند چون مدت به آن‌ها وابسته است. بررسی مدت فقط وقتی `$movingToLiveSlot` است (نوبت رزرو معاف — `slot_start == slot_end`). در حالت اسلاتی `$duration === null` می‌ماند و هیچ خطی اجرا نمی‌شود |
| ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ✅ | وقتی فهرست کامل آمد، `service_item_uuid` تکی در حلقهٔ relations **نادیده** گرفته می‌شود (`continue`) — دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد. `allowInactive` فقط وقتی فهرست **نیامده** روشن است: نوبت موجود قفل نشود ولی افزودن سرویس غیرفعال رد شود |
| ۱.۷ | ~~`ReserveConversionService` + `POST /convert-reserve`~~ → توسعهٔ `PATCH` | ✅ | **endpoint جدید ساخته نشد و توجیه نداشت** (قاعدهٔ پروژه). `PATCH` از قبل رزرو→زمان‌دار را دارد: `rescheduleTo($start,$end,$isReserve)` که خودش `refreshActiveSlotKey()` را صدا می‌زند. تست `testReserveConvertsToATimedAppointmentThroughPatch` این را می‌سنجد |
@@ -40,7 +40,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ✅ | کنار `addServiceItem()` موجود؛ `currentServiceUuids()` ترتیب ورودی را حفظ می‌کند و برای نوبت قدیمیِ فقط-تکی هم uuid می‌دهد |
| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ✅ | برخلاف `addServiceItem()` که فقط وقتی `null` است ست می‌کند، این یکی **بی‌قید** بازنویسی می‌کند (و با فهرست خالی `null` می‌شود). چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند |
| ۱.۱۲ | ~~`setIsReserve()` صدا زدن `refreshActiveSlotKey()`~~ | ✅ | **موردی برای انجام نبود:** `setIsReserve()` وجود ندارد؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` است که خودش `refreshActiveSlotKey()` را صدا می‌زند |
| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | | |
| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | | `assertStartIsOffered()`. تست `testTimeOutsideTheOfferedListIsRejected` با ۰۳:۰۰ (بیرون شیفت ۰۹–۱۸) می‌سنجد — `isSlotTaken` تنها این را نمی‌گرفت |
| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ✅ | `belongsToPair()` (نه `belongsTo()`): مسیر عمومی است و کاربر احراز‌شده ندارد. جای مقایسهٔ دستیِ `getSection()->getEntityType()` نشست — نقطهٔ واحد `tenancy.md`. ⚠️ نام property باید `itemRepo` باشد وگرنه `TenantLookupInventoryTest` آن را **نمی‌شمارد** و از تور ایمنی فرار می‌کند |
| ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ✅ | پرچم روی `calculate()`؛ غیرفعال‌بودن → `warnings[]` به‌جای ۴۲۲. مدتِ نامعتبر همچنان حتی با `allowInactive` رد می‌شود (تست دارد) |
| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ✅ | `serviceSlots()` از ۶۰ خط به ۴۰ خط؛ هیچ حلقهٔ محاسباتی در کنترلر نماند |
@@ -85,7 +85,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|---|---|---|---|
| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ✅ | `OK (3 tests, 8 assertions)` |
| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ✅ | ۱۳ تست / ۲۹ assertion سبز. شامل: جمع مدت + بافر · `endFor` بدون بافر · override منشی بدون تغییر پیش‌فرض سرویس · چهار مسیر خطا با کد/پیام/فیلد دقیق · uuid ناموجود از سرویسِ محیط دیگر **قابل تفکیک نیست** · `allowInactive` → warning · فهرست خالی → صفر · override نامعتبر (۰ و منفی) → fallback · پزشک بی‌برنامه → پیش‌فرض `slot` |
| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | | |
| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | | ۱۵ تست / ۳۴ assertion. شامل: جابه‌جایی بی‌ارسال مدت · حذف سرویس → کوچک‌شدن خودکار · **زمان فعلیِ خود نوبت با سرویس بلندتر پذیرفته می‌شود** (اثر `excludeAppointmentId`) · سرویس غیرفعالِ موجود → warning · حالت اسلاتی → `ERR_APPOINTMENT_004` · بیرون شیفت → `ERR_APPOINTMENT_001` · رزرو → پیام ارجاع به ویرایش · `start` غایب · سرویس بیگانه · افزودن سرویس غیرفعال تازه · گذشته · نوبت بی‌سرویس · نسخهٔ کهنه → ۴۰۹ · نوبت شخص دیگر → ۴۰۳ · uuid ناموجود → ۴۰۴ |
| ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ✅ | ۹ تست / ۲۴ assertion. شامل: بازمحاسبهٔ مدت با تعویض سرویس · هم‌گامی ستون تکی · PATCH فقط-یادداشت بی‌اعتبارسنجی · رزرو معاف ولی مدت‌دار · تبدیل رزرو با همان PATCH · مدت ناسازگار → `ERR_APPOINTMENT_003` با عدد درست در پیام · سرویس بیگانه → ۴۲۲ · **حالت اسلاتی هر مدتی را می‌پذیرد و ستون سرویسی `null` می‌ماند** · نوبت سرویسیِ بی‌سرویس قفل نمی‌شود |
| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | |
| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | |
@@ -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',
);
}
}
}
+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());
}
}