feat(appointments): backend for clinic workflow (Figma نوبتها) — phase A
Extend Appointment for the clinic-facing appointments area:
- New nullable relations service_section/service_item/staff (بخش/سرویس/پرسنل)
plus deposit_required/deposit_amount_rials (بیعانه) and is_reserve.
- New statuses following_up (در حال پیگیری) and salon (سالن) with day-of
transition rules; reserve entries never occupy a slot (several reserves may
share one day), enforced in refreshActiveSlotKey.
- rescheduleTo(slotStart, slotEnd, isReserve) keeps active_slot_key consistent
for جا به جایی and reserve transfers.
- New PATCH /api/v1/appointment/{uuid}: partial update covering edit, slot
move (409 on taken slot, race backstop on the unique key), reserve toggle,
patient swap (جایگزینی) and optional status transition; optimistic lock via
version like the status endpoint.
- POST /my/appointment now accepts the workflow fields and is_reserve
(day-level entry: no past-slot rule, no atomic slot booking); GET
/my/appointments gains reserve=1 and returns the new fields per row.
Migration Version20260713195434 (+ mirrored on db_test). Docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* POST /api/v1/my/appointment extensions (workflow fields + reserve entries)
|
||||
* and the GET /api/v1/my/appointments ?reserve list split.
|
||||
*/
|
||||
class AppointmentCreateReserveTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor} */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
public function testCreateWithWorkflowFields(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی');
|
||||
$item = new ServiceItem($section, 'لیزر فول بادی');
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'سحر ایمانی');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$start = time() + 86_400;
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 2_400,
|
||||
'patient_mobile' => '09' . random_int(100000000, 999999999),
|
||||
'patient_name' => 'مریم اسکندری',
|
||||
'service_section_uuid' => $section->getUuid(),
|
||||
'service_item_uuid' => $item->getUuid(),
|
||||
'staff_uuid' => $staff->getUuid(),
|
||||
'deposit_required' => true,
|
||||
'deposit_amount_rials' => 5_000_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertFalse($res['data']['is_reserve']);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner);
|
||||
$row = $list['data'][0];
|
||||
self::assertSame('لیزر فول بادی', $row['service_item']['name']);
|
||||
self::assertSame('سحر ایمانی', $row['staff']['full_name']);
|
||||
self::assertTrue($row['deposit_required']);
|
||||
}
|
||||
|
||||
public function testReserveEntriesSkipSlotRulesAndAreListedSeparately(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$day = strtotime('today midnight'); // past for a slot booking — fine for a reserve
|
||||
|
||||
// two reserves on the same day must both succeed (no slot occupation)
|
||||
foreach (['ساغر صابری', 'پریسا همتی'] as $name) {
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $day,
|
||||
'slot_end' => $day,
|
||||
'patient_mobile' => '09' . random_int(100000000, 999999999),
|
||||
'patient_name' => $name,
|
||||
'is_reserve' => true,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
$reserves = $this->authJson('GET', '/api/v1/my/appointments?reserve=1&limit=50', $owner);
|
||||
self::assertCount(2, $reserves['data']);
|
||||
self::assertTrue($reserves['data'][0]['is_reserve']);
|
||||
|
||||
// the regular list must not contain reserve entries
|
||||
$regular = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner);
|
||||
self::assertCount(0, $regular['data']);
|
||||
}
|
||||
|
||||
public function testUnknownServiceUuidIs422(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$start = time() + 86_400;
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => '09' . random_int(100000000, 999999999),
|
||||
'patient_name' => 'x',
|
||||
'service_item_uuid' => 'missing-uuid',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/appointment/{uuid} — general update used by ویرایش,
|
||||
* جا به جایی, انتقال به رزرو and جایگزینی نوبت.
|
||||
*/
|
||||
class AppointmentUpdateTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: Appointment} */
|
||||
private function booking(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$appointment = new Appointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $appointment];
|
||||
}
|
||||
|
||||
public function testUpdatesWorkflowFields(): void
|
||||
{
|
||||
[$owner, $doctor, $appointment] = $this->booking();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی');
|
||||
$item = new ServiceItem($section, 'لیزر توتال');
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'سحر ایمانی');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'service_section_uuid' => $section->getUuid(),
|
||||
'service_item_uuid' => $item->getUuid(),
|
||||
'staff_uuid' => $staff->getUuid(),
|
||||
'deposit_required' => true,
|
||||
'deposit_amount_rials' => 5_000_000,
|
||||
'note' => 'یادداشت',
|
||||
'patient_name' => 'مریم خلیلی',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$d = $res['data']['data'];
|
||||
self::assertSame('زیبایی', $d['service_section']['name']);
|
||||
self::assertSame('لیزر توتال', $d['service_item']['name']);
|
||||
self::assertSame('سحر ایمانی', $d['staff']['full_name']);
|
||||
self::assertTrue($d['deposit_required']);
|
||||
self::assertSame('مریم خلیلی', $d['patient_name']);
|
||||
}
|
||||
|
||||
public function testRescheduleMovesSlot(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$newStart = time() + 2 * 86_400;
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'slot_start' => $newStart, 'slot_end' => $newStart + 1_800,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($newStart, $res['data']['data']['slot_start']);
|
||||
}
|
||||
|
||||
public function testRescheduleRejectsTakenSlot(): void
|
||||
{
|
||||
[$owner, $doctor, $appointment] = $this->booking();
|
||||
|
||||
$otherStart = time() + 3 * 86_400;
|
||||
$this->em->persist(new Appointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'slot_start' => $otherStart, 'slot_end' => $otherStart + 1_800,
|
||||
]);
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testTransferToReserveAndBack(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$day = strtotime('tomorrow midnight');
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'is_reserve' => true, 'slot_start' => $day, 'slot_end' => $day,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['data']['is_reserve']);
|
||||
|
||||
$back = time() + 4 * 86_400;
|
||||
$version = $res['data']['data']['version'];
|
||||
$res2 = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'is_reserve' => false, 'slot_start' => $back, 'slot_end' => $back + 1_800, 'version' => $version,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($res2['data']['data']['is_reserve']);
|
||||
}
|
||||
|
||||
public function testRejectsHalfSlotPair(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'slot_start' => time() + 86_400,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testForbiddenForStranger(): void
|
||||
{
|
||||
[, , $appointment] = $this->booking();
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $stranger, ['note' => 'x']);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testUnknownStaffUuidIs422(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'staff_uuid' => 'no-such-uuid',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Clinic-workflow additions on Appointment: following_up/salon statuses,
|
||||
* service section/item + staff relations, deposit fields, reserve flag,
|
||||
* and rescheduleTo keeping active_slot_key consistent.
|
||||
*/
|
||||
class AppointmentWorkflowFieldsTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function newBooking(Doctor $doctor, int $start): Appointment
|
||||
{
|
||||
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||
$this->em->persist($a);
|
||||
$this->em->flush();
|
||||
|
||||
return $a;
|
||||
}
|
||||
|
||||
public function testDayOfWorkflowTransitions(): void
|
||||
{
|
||||
$a = $this->newBooking($this->makeDoctor(), time() + 86_400);
|
||||
|
||||
$a->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$a->transitionTo(Appointment::STATUS_FOLLOWING_UP);
|
||||
$a->transitionTo(Appointment::STATUS_SALON);
|
||||
$a->transitionTo(Appointment::STATUS_COMPLETED);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(Appointment::STATUS_COMPLETED, $a->getStatus());
|
||||
}
|
||||
|
||||
public function testPendingCannotJumpToSalon(): void
|
||||
{
|
||||
$a = $this->newBooking($this->makeDoctor(), time() + 86_400);
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$a->transitionTo(Appointment::STATUS_SALON);
|
||||
}
|
||||
|
||||
public function testWorkflowFieldsPersistAndSerialize(): void
|
||||
{
|
||||
$a = $this->newBooking($this->makeDoctor(), time() + 86_400);
|
||||
|
||||
$a->setDepositRequired(true)
|
||||
->setDepositAmountRials(5_000_000);
|
||||
$a->rescheduleTo($a->getSlotStart(), $a->getSlotEnd(), true);
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $a->getUuid()]);
|
||||
$arr = $reloaded->toArray();
|
||||
|
||||
self::assertTrue($arr['deposit_required']);
|
||||
self::assertSame(5_000_000, $arr['deposit_amount_rials']);
|
||||
self::assertTrue($arr['is_reserve']);
|
||||
self::assertNull($arr['service_section']);
|
||||
self::assertNull($arr['staff']);
|
||||
}
|
||||
|
||||
public function testRescheduleFreesTheOldSlot(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = time() + 86_400;
|
||||
|
||||
$first = $this->newBooking($doctor, $start);
|
||||
$first->rescheduleTo($start + 3_600, $start + 5_400);
|
||||
$this->em->flush();
|
||||
|
||||
// old slot must be free again — a new live booking on it succeeds
|
||||
$second = $this->newBooking($doctor, $start);
|
||||
self::assertNotNull($second->getId());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user