In service mode PATCH accepted any duration and only updated the single service_item column while the service_items collection stayed untouched, so an edit could leave an appointment with old services and a new length. A 45-minute service could be shortened to 20 and the next patient would sit on top of it. Services are now resolved before the time block (duration depends on them) and the stored end must equal start + total minutes. Reserve entries are exempt: they carry slot_start == slot_end and occupy no interval, but they do store the computed duration so a later conversion does not lose it. No convert-reserve endpoint was added: PATCH already converts a reserve to a timed appointment via rescheduleTo($start, $end, $isReserve), which refreshes active_slot_key itself. Project rule 8 — a new endpoint needs an existing one to be insufficient even after extension. Slot mode is untouched: with booking_mode = slot the duration stays null and not one of the new branches runs. Covered by an explicit test. New error codes are ERR_APPOINTMENT_003/004 (the file only had 001/002). 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>
253 lines
12 KiB
PHP
253 lines
12 KiB
PHP
<?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;
|
|
|
|
/**
|
|
* `PATCH /api/v1/appointment/{uuid}` در حالت نوبتدهی سرویسی: مدت داده است نه ورودی.
|
|
*
|
|
* پیش از این، مدت دلخواه پذیرفته میشد و فقط `service_item_uuid` تکی بهروز میشد در
|
|
* حالی که `service_items` دستنخورده میماند — یعنی نوبت با سرویسهای قبلی و مدت جدید.
|
|
*
|
|
* ⛔ در حالت اسلاتی هیچکدام از این بررسیها اجرا نمیشود؛ آخرین تست همین را میسنجد.
|
|
*/
|
|
class PatchServiceDurationTest extends ApiTestCase
|
|
{
|
|
/** @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('+2 days'));
|
|
$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): ServiceItem
|
|
{
|
|
$item = new ServiceItem($section, $name, 0);
|
|
$item->setDurationMinutes($minutes)->setBookable(true);
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
return $item;
|
|
}
|
|
|
|
/** نوبت زماندارِ تأییدشده با مدت دلخواه. */
|
|
private function appointment(Doctor $doctor, int $minutes, array $items = []): Appointment
|
|
{
|
|
$patient = $this->createUser(['ROLE_USER']);
|
|
$start = (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' 10:00');
|
|
$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 patch(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array
|
|
{
|
|
return $this->authJson('PATCH', '/api/v1/appointment/' . $appt->getUuid(), $actor, $body + [
|
|
'version' => $appt->getVersion(),
|
|
]);
|
|
}
|
|
|
|
private function at(string $time): int
|
|
{
|
|
return (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' ' . $time);
|
|
}
|
|
|
|
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
|
|
|
public function testReplacingServicesRecomputesTheStoredDuration(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 10);
|
|
$face = $this->service($section, 'لیزر صورت', 20);
|
|
$bikini = $this->service($section, 'لیزر بیکینی', 15);
|
|
$appt = $this->appointment($doctor, 20, [$face]);
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $this->at('11:00'),
|
|
'slot_end' => $this->at('11:35'), // ۳۵ = ۲۰ + ۱۵
|
|
'service_item_uuids' => [$face->getUuid(), $bikini->getUuid()],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(35, $res['data']['data']['service_total_minutes']);
|
|
self::assertSame(10, $res['data']['data']['service_buffer_minutes']);
|
|
self::assertCount(2, $res['data']['data']['service_items']);
|
|
}
|
|
|
|
public function testTheLegacySingleServiceFollowsTheList(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$old = $this->service($section, 'سرویس قدیمی', 20);
|
|
$new = $this->service($section, 'سرویس جدید', 30);
|
|
$appt = $this->appointment($doctor, 20, [$old]);
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $this->at('11:00'),
|
|
'slot_end' => $this->at('11:30'),
|
|
'service_item_uuids' => [$new->getUuid()],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame($new->getUuid(), $res['data']['data']['service_item']['uuid'],
|
|
'ستون تکی باید با فهرست همگام شود، وگرنه لیستها نام سرویس قدیمی را نشان میدهند');
|
|
}
|
|
|
|
public function testNoteOnlyPatchSkipsDurationValidation(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$item = $this->service($section, 'مشاوره', 30);
|
|
// نوبتی که مدتش با سرویسش نمیخواند (دادهٔ قدیمی).
|
|
$appt = $this->appointment($doctor, 20, [$item]);
|
|
|
|
$this->patch($appt, $owner, ['note' => 'یادداشت تازه']);
|
|
|
|
self::assertSame(200, $this->responseCode(), 'PATCH بدون slot نباید مدت را بسنجد');
|
|
}
|
|
|
|
public function testReserveEntryStoresServicesWithoutDurationCheck(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$item = $this->service($section, 'لیزر', 45);
|
|
$appt = $this->appointment($doctor, 30, [$item]);
|
|
$midnight = $this->at('00:00');
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $midnight,
|
|
'slot_end' => $midnight, // رزرو: start == end
|
|
'is_reserve' => true,
|
|
'service_item_uuids' => [$item->getUuid()],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode(), 'نوبت رزرو از بررسی مدت معاف است');
|
|
self::assertTrue($res['data']['data']['is_reserve']);
|
|
self::assertSame(45, $res['data']['data']['service_total_minutes'],
|
|
'مدت برای تبدیل بعدیِ رزرو به نوبت زماندار ذخیره میشود');
|
|
}
|
|
|
|
public function testReserveConvertsToATimedAppointmentThroughPatch(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$item = $this->service($section, 'لیزر', 30);
|
|
|
|
$patient = $this->createUser(['ROLE_USER']);
|
|
$midnight = $this->at('00:00');
|
|
$reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight);
|
|
$reserve->rescheduleTo($midnight, $midnight, true);
|
|
$reserve->replaceServiceItems([$item]);
|
|
$reserve->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
$this->em->persist($reserve);
|
|
$this->em->flush();
|
|
|
|
$res = $this->patch($reserve, $owner, [
|
|
'slot_start' => $this->at('12:00'),
|
|
'slot_end' => $this->at('12:30'),
|
|
'is_reserve' => false,
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode(), 'تبدیل رزرو با همان PATCH انجام میشود — endpoint جدید لازم نیست');
|
|
self::assertFalse($res['data']['data']['is_reserve']);
|
|
self::assertSame(30, $res['data']['data']['service_total_minutes']);
|
|
}
|
|
|
|
// ── ❌ خطا ───────────────────────────────────────────────────────────────
|
|
|
|
public function testMismatchedDurationIsRejectedWithTheCorrectMinutes(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$item = $this->service($section, 'لیزر', 45);
|
|
$appt = $this->appointment($doctor, 45, [$item]);
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $this->at('11:00'),
|
|
'slot_end' => $this->at('11:20'), // ۲۰ دقیقه بهجای ۴۵
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame(ErrorCodes::ERR_APPOINTMENT_003, $res['errors'][0]['code']);
|
|
self::assertSame('slot_end', $res['errors'][0]['field']);
|
|
self::assertStringContainsString('45', $res['errors'][0]['message'], 'پیام باید مدت درست را بگوید');
|
|
}
|
|
|
|
public function testForeignServiceIsRejected(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$mine = $this->service($section, 'سرویس خودم', 30);
|
|
$appt = $this->appointment($doctor, 30, [$mine]);
|
|
|
|
[, $otherDoctor, $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$foreign = $this->service($otherSection, 'سرویس بیگانه', 30);
|
|
self::assertNotNull($otherDoctor->getId());
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $this->at('11:00'),
|
|
'slot_end' => $this->at('11:30'),
|
|
'service_item_uuids' => [$foreign->getUuid()],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('سرویس انتخابشده به این محل نوبتدهی تعلق ندارد', $res['errors'][0]['message']);
|
|
}
|
|
|
|
// ── ⚠️ مرزی — خط سرخ ─────────────────────────────────────────────────────
|
|
|
|
public function testSlotModeAppointmentKeepsAcceptingAnyDuration(): void
|
|
{
|
|
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT);
|
|
$item = $this->service($section, 'سرویس اسلاتی', 45);
|
|
$appt = $this->appointment($doctor, 20, [$item]);
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $this->at('11:00'),
|
|
'slot_end' => $this->at('11:20'), // با مدت سرویس نمیخواند — و باید بخورد
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode(), 'حالت اسلاتی نباید هیچ اعتبارسنجی سرویسی بگیرد');
|
|
self::assertNull($res['data']['data']['service_total_minutes'], 'ستون سرویسی در حالت اسلاتی null میماند');
|
|
}
|
|
|
|
public function testServiceModeAppointmentWithoutAnyServiceIsNotBlocked(): void
|
|
{
|
|
[$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
|
|
$appt = $this->appointment($doctor, 20); // دادهٔ قدیمی: بدون سرویس
|
|
|
|
$res = $this->patch($appt, $owner, [
|
|
'slot_start' => $this->at('11:00'),
|
|
'slot_end' => $this->at('11:20'),
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode(), 'نوبت سرویسی بدون سرویس نباید قفل شود');
|
|
self::assertNull($res['data']['data']['service_total_minutes']);
|
|
}
|
|
}
|