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