From 4642506c0fe810934952b87e5de31aed7b19b9e6 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 10:10:47 +0330 Subject: [PATCH] feat: support multiple services per appointment (checkbox selection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appointments could only reference a single service (ManyToOne). Add an appointment_service_items join table (ManyToMany) so an appointment can carry several services; the first stays the primary service_item for backward compatibility, and toArray now also returns service_items[]. Both create endpoints (my/appointment, admin/appointment) accept service_item_uuids[] and attach all of them. A new duration_from_services flag gates the slot_end recompute: service-booking mode sends it true (slot_end = start + Σ durations); slot mode omits it so the manual end time is preserved. The admin endpoint previously ignored services entirely. Frontend: in slot mode the single service dropdown becomes a checkbox list filtered by the selected section (multi-select); service mode sends the duration flag. Migration + backend/entity tests + docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/admin/pages/AppointmentCreatePage.tsx | 113 ++++++++++------- docs/api/admin.md | 4 + docs/api/appointment.md | 12 +- migrations/Version20260716063302.php | 35 ++++++ src/Admin/Controller/AdminApiController.php | 33 +++++ .../Controller/MyAppointmentsController.php | 32 +++-- src/Appointment/Entity/Appointment.php | 29 ++++- .../AppointmentMultiServiceTest.php | 115 ++++++++++++++++++ tests/Patient/PatientListNationalCodeTest.php | 7 +- 9 files changed, 315 insertions(+), 65 deletions(-) create mode 100644 migrations/Version20260716063302.php create mode 100644 tests/Appointment/AppointmentMultiServiceTest.php diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index b61546b2..156e42cb 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -69,8 +69,10 @@ export default function AppointmentCreatePage() { // ── مشخصات سرویس const [sectionUuid, setSectionUuid] = useState(''); - const [itemUuid, setItemUuid] = useState(''); + const [serviceItemUuids, setServiceItemUuids] = useState([]); // چند سرویس در حالت اسلاتی const [staffUuid, setStaffUuid] = useState(''); + const toggleServiceItem = (uuid: string) => + setServiceItemUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]); const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); const itemsQ = useQuery>({ queryKey: ['service-items', sectionUuid], @@ -117,10 +119,10 @@ export default function AppointmentCreatePage() { patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, ...(serviceMode - ? { service_item_uuids: servicePick.serviceUuids } + ? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true } : { ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), - ...(itemUuid ? { service_item_uuid: itemUuid } : {}), + ...(serviceItemUuids.length ? { service_item_uuids: serviceItemUuids } : {}), }), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), @@ -273,51 +275,72 @@ export default function AppointmentCreatePage() { {/* مشخصات سرویس */}
مشخصات سرویس
{!serviceMode ? ( -
-
- -
- ({ value: o.uuid, label: o.name ?? '' }))} - value={sectionUuid || null} - onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} - placeholder="انتخاب بخش" - isLoading={sectionsQ.isLoading} - isClearable - height={44} - /> + <> +
+
+ +
+ ({ value: o.uuid, label: o.name ?? '' }))} + value={sectionUuid || null} + onChange={v => { setSectionUuid(v ? String(v) : ''); setServiceItemUuids([]); }} + placeholder="انتخاب بخش" + isLoading={sectionsQ.isLoading} + isClearable + height={44} + /> +
+
+
+ +
+ ({ value: o.uuid, label: o.full_name ?? '' }))} + value={staffUuid || null} + onChange={v => setStaffUuid(v ? String(v) : '')} + placeholder="انتخاب پرسنل" + isLoading={staffQ.isLoading} + isClearable + height={44} + /> +
-
- -
- ({ value: o.uuid, label: o.name ?? '' }))} - value={itemUuid || null} - onChange={v => setItemUuid(v ? String(v) : '')} - placeholder="انتخاب سرویس" - isDisabled={!sectionUuid} - isLoading={itemsQ.isLoading} - isClearable - height={44} - /> + + {/* سرویس‌ها — چند انتخابی، بر اساس بخشِ انتخاب‌شده */} + + {!sectionUuid ? ( +
ابتدا بخش را انتخاب کنید.
+ ) : itemsQ.isLoading ? ( +
در حال بارگذاری...
+ ) : (itemsQ.data?.data ?? []).length === 0 ? ( +
سرویسی در این بخش تعریف نشده است.
+ ) : ( +
+ {(itemsQ.data?.data ?? []).map(o => { + const active = serviceItemUuids.includes(o.uuid); + return ( + + ); + })}
-
-
- -
- ({ value: o.uuid, label: o.full_name ?? '' }))} - value={staffUuid || null} - onChange={v => setStaffUuid(v ? String(v) : '')} - placeholder="انتخاب پرسنل" - isLoading={staffQ.isLoading} - isClearable - height={44} - /> -
-
-
+ )} + ) : (
diff --git a/docs/api/admin.md b/docs/api/admin.md index fe4630b4..ef45d5ca 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -599,11 +599,15 @@ Create a new appointment for a patient. If no user exists with the given mobile, "patient_mobile": "09123456789", "patient_name": "علی محمدی", "patient_national_code": "0012345678", + "service_item_uuids": ["service-uuid-1", "service-uuid-2"], + "duration_from_services": false, "note": "optional note" } ``` > `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. +> +> `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست می‌شوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمی‌گردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ می‌ماند. ### Response `201` ```json diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 9abd2709..4f101d38 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -206,8 +206,9 @@ Book an appointment slot. |-------|------|----------|-------------| | `doctor_uuid` | string (UUID) | ✅ | Doctor UUID | | `slot_start` | integer | ✅ | Slot start (Unix timestamp) | -| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). در حالت سرویسی که `service_item_uuids` ارسال شود، سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه می‌کند و مقدار کلاینت نادیده گرفته می‌شود | -| `service_item_uuids` | string[] | ❌ | حالت نوبت‌دهی سرویسی: یک/چند UUID سرویسِ `bookable`. مدت نوبت = مجموع `duration_minutes` آن‌ها؛ اولین سرویس روی نوبت ثبت می‌شود. سرویسِ غیرbookable یا بدون مدت ⇒ `422` | +| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی `duration_from_services=true` باشد سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه می‌کند؛ در غیر این صورت مقدار کلاینت حفظ می‌شود | +| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس که به نوبت **پیوست** می‌شوند (چند سرویس). اولین سرویس به‌عنوان سرویسِ اصلی (`service_item`) ثبت و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422` | +| `duration_from_services` | boolean | ❌ | `true` = حالت نوبت‌دهی سرویسی: مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. پیش‌فرض `false` (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ می‌شود) | | `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else | | `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile | | `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile | @@ -594,7 +595,7 @@ Role-aware paginated list of appointments. Returns only what the authenticated u ## Clinic workflow extensions (نوبت‌ها — Figma) -New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویس), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `is_reserve` (نوبت رزرو — day-level, never occupies a slot). +New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویس‌های نوبت — چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `is_reserve` (نوبت رزرو — day-level, never occupies a slot). New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions: `pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show` @@ -628,9 +629,10 @@ Response `200`: `{ success, data: { data: } }` | 409 | slot taken or version conflict | ### POST `/api/v1/my/appointment` (extended) -Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`. +Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`. `is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`. -`service_item_uuids[]` (حالت نوبت‌دهی سرویسی، غیرِ رزرو): یک/چند سرویسِ `bookable`؛ `slot_end` سمت سرور از `slot_start + Σ duration_minutes` محاسبه می‌شود و اولین سرویس روی نوبت ثبت می‌گردد. سرویسِ غیرbookable یا بدون مدت ⇒ `422`. +`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** می‌شوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422`. +`duration_from_services: true` (حالت نوبت‌دهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود. ### GET `/api/v1/my/appointments` (extended) New query param `reserve=1` → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: `patient_uuid`, `is_reserve`, `deposit_required`, `deposit_amount_rials`, `note`, `service_section`, `service_item`, `staff` (each `{uuid, name|full_name}` or null). diff --git a/migrations/Version20260716063302.php b/migrations/Version20260716063302.php new file mode 100644 index 00000000..e018a0eb --- /dev/null +++ b/migrations/Version20260716063302.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE appointment_service_items (appointment_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_3624BA00E5B533F9 (appointment_id), INDEX IDX_3624BA00DDEB00C2 (service_item_id), PRIMARY KEY (appointment_id, service_item_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE appointment_service_items ADD CONSTRAINT FK_3624BA00E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE appointment_service_items ADD CONSTRAINT FK_3624BA00DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE appointment_service_items DROP FOREIGN KEY FK_3624BA00E5B533F9'); + $this->addSql('ALTER TABLE appointment_service_items DROP FOREIGN KEY FK_3624BA00DDEB00C2'); + $this->addSql('DROP TABLE appointment_service_items'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index ec1baac1..6712d59c 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -862,6 +862,36 @@ class AdminApiController extends BaseController $patientName = trim($data['patient_name'] ?? ''); $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); + // سرویس‌های نوبت (چند سرویس). `duration_from_services` فقط در نوبت‌دهی سرویسی + // true است و آنگاه slot_end از مجموع مدت سرویس‌ها محاسبه می‌شود؛ در حالت اسلاتی + // سرویس‌ها صرفاً پیوست می‌شوند و ساعت پایانِ دستی حفظ می‌ماند. + $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); + $computeDuration = (bool) ($data['duration_from_services'] ?? false); + $serviceItems = []; + if (!empty($serviceUuids)) { + $itemRepo = $this->em->getRepository(\App\ClinicService\Entity\ServiceItem::class); + $totalMinutes = 0; + foreach ($serviceUuids as $u) { + $item = $itemRepo->findOneBy(['uuid' => $u]); + if ($item === null) { + return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + if ($computeDuration) { + if (!$item->isBookable()) { + return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + } + if (($item->getDurationMinutes() ?? 0) <= 0) { + return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + $totalMinutes += (int) $item->getDurationMinutes(); + } + $serviceItems[] = $item; + } + if ($computeDuration) { + $slotEnd = $slotStart + $totalMinutes * 60; + } + } + if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422); } @@ -885,6 +915,9 @@ class AdminApiController extends BaseController $appointment->setPatientName($patientName); $appointment->setPatientMobile($mobile); if (!empty($data['note'])) $appointment->setNote($data['note']); + foreach ($serviceItems as $si) { + $appointment->addServiceItem($si); + } $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); if ($locationId !== null) $appointment->setAddressId($locationId); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index f5fbe9d3..90a797b7 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -71,8 +71,12 @@ class MyAppointmentsController extends BaseController // حالت نوبت‌دهی سرویسی: مدت نوبت از مجموعِ مدت سرویس‌های انتخاب‌شده تعیین // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود). - $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); - $serviceItems = []; + // `duration_from_services` فقط در نوبت‌دهی سرویسی true است: آنجا مدت نوبت از + // مجموع سرویس‌ها محاسبه و slot_end بازنویسی می‌شود. در حالت اسلاتی، سرویس‌ها + // فقط به نوبت پیوست می‌شوند و ساعت پایانِ دستی حفظ می‌شود. + $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); + $computeDuration = (bool) ($data['duration_from_services'] ?? false); + $serviceItems = []; if (!empty($serviceUuids) && !$isReserve) { $totalMinutes = 0; foreach ($serviceUuids as $u) { @@ -80,16 +84,20 @@ class MyAppointmentsController extends BaseController if ($item === null) { return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids'); } - if (!$item->isBookable()) { - return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + if ($computeDuration) { + if (!$item->isBookable()) { + return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + } + if (($item->getDurationMinutes() ?? 0) <= 0) { + return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + $totalMinutes += (int) $item->getDurationMinutes(); } - if (($item->getDurationMinutes() ?? 0) <= 0) { - return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); - } - $totalMinutes += (int) $item->getDurationMinutes(); $serviceItems[] = $item; } - $slotEnd = $slotStart + $totalMinutes * 60; + if ($computeDuration) { + $slotEnd = $slotStart + $totalMinutes * 60; + } } if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) { @@ -140,9 +148,9 @@ class MyAppointmentsController extends BaseController } $appointment->$setter($entity); } - // در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخاب‌شده. - if (!empty($serviceItems)) { - $appointment->setServiceItem($serviceItems[0]); + // پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem). + foreach ($serviceItems as $si) { + $appointment->addServiceItem($si); } if (!empty($data['deposit_required'])) { $appointment->setDepositRequired(true); diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 79c2ce79..ed6c0428 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -4,6 +4,8 @@ namespace App\Appointment\Entity; use App\Auth\Entity\User; use App\Doctor\Entity\Doctor; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use App\Appointment\Repository\AppointmentRepository; use Symfony\Component\Uid\Uuid; @@ -135,11 +137,16 @@ class Appointment #[ORM\JoinColumn(name: 'service_section_id', nullable: true, onDelete: 'SET NULL')] private ?\App\ClinicService\Entity\ServiceSection $serviceSection = null; - /** سرویس — concrete service item to perform. */ + /** سرویس — سرویس اصلی/اولِ نوبت (برای سازگاری با مصرف‌کننده‌های موجود). */ #[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)] #[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')] private ?\App\ClinicService\Entity\ServiceItem $serviceItem = null; + /** سرویس‌های نوبت — امکان انتخاب چند سرویس. serviceItem بالا همان سرویسِ اول است. */ + #[ORM\ManyToMany(targetEntity: \App\ClinicService\Entity\ServiceItem::class)] + #[ORM\JoinTable(name: 'appointment_service_items')] + private Collection $serviceItems; + /** پرسنل — staff member assigned to the appointment. */ #[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)] #[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')] @@ -174,6 +181,7 @@ class Appointment $this->slotEnd = $slotEnd; $this->createdAt = time(); $this->updatedAt = time(); + $this->serviceItems = new ArrayCollection(); $this->refreshActiveSlotKey(); } @@ -219,6 +227,21 @@ class Appointment public function getServiceSection(): ?\App\ClinicService\Entity\ServiceSection { return $this->serviceSection; } public function getServiceItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceItem; } + + /** @return Collection */ + public function getServiceItems(): Collection { return $this->serviceItems; } + + public function addServiceItem(\App\ClinicService\Entity\ServiceItem $item): self + { + if (!$this->serviceItems->contains($item)) { + $this->serviceItems->add($item); + } + // سرویسِ اصلی = اولین سرویس، تا مصرف‌کننده‌های موجود کار کنند. + if ($this->serviceItem === null) { + $this->serviceItem = $item; + } + return $this; + } public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; } public function isDepositRequired(): bool { return $this->depositRequired; } public function getDepositAmountRials(): ?int { return $this->depositAmountRials; } @@ -317,6 +340,10 @@ class Appointment 'patient_reason' => $this->patientReason, 'service_section' => $this->serviceSection ? ['uuid' => $this->serviceSection->getUuid(), 'name' => $this->serviceSection->getName()] : null, 'service_item' => $this->serviceItem ? ['uuid' => $this->serviceItem->getUuid(), 'name' => $this->serviceItem->getName()] : null, + 'service_items' => array_map( + fn(\App\ClinicService\Entity\ServiceItem $i) => ['uuid' => $i->getUuid(), 'name' => $i->getName()], + $this->serviceItems->toArray() + ), 'staff' => $this->staff ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] : null, 'deposit_required' => $this->depositRequired, 'deposit_amount_rials' => $this->depositAmountRials, diff --git a/tests/Appointment/AppointmentMultiServiceTest.php b/tests/Appointment/AppointmentMultiServiceTest.php new file mode 100644 index 00000000..cee6bf5c --- /dev/null +++ b/tests/Appointment/AppointmentMultiServiceTest.php @@ -0,0 +1,115 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + return [$owner, $doctor]; + } + + private function serviceItem(Doctor $doctor, string $name, int $minutes): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name); + $this->em->persist($section); + $item = new ServiceItem($section, $name, 500_000); + $item->setDurationMinutes($minutes)->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function nationalCode(): string + { + return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT); + } + + private function body(Doctor $doctor, int $start, int $end, array $extra): array + { + return array_merge([ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $end, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => $this->nationalCode(), + ], $extra); + } + + private function reload(string $uuid): Appointment + { + $this->em->clear(); + return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); + } + + public function testSlotModeAttachesMultipleServicesAndKeepsManualEnd(): void + { + [$owner, $doctor] = $this->doctor(); + $a = $this->serviceItem($doctor, 'تزریق ژل', 30); + $b = $this->serviceItem($doctor, 'کندلا', 20); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + $end = $start + 3_600; // ساعت پایانِ دستی: ۶۰ دقیقه + + $res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $end, [ + 'service_item_uuids' => [$a->getUuid(), $b->getUuid()], + ])); + self::assertSame(201, $this->responseCode()); + + $appt = $this->reload($res['data']['uuid']); + self::assertCount(2, $appt->getServiceItems()); + // ساعت پایان دست‌نخورده (بدون بازمحاسبه از مدت سرویس‌ها) + self::assertSame($end, $appt->getSlotEnd()); + // سرویسِ اصلی = اولین سرویس + self::assertSame($a->getUuid(), $appt->getServiceItem()?->getUuid()); + } + + public function testServiceModeRecomputesEndFromDurations(): void + { + [$owner, $doctor] = $this->doctor(); + $a = $this->serviceItem($doctor, 'تزریق ژل', 30); + $b = $this->serviceItem($doctor, 'کندلا', 20); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 60, [ + 'service_item_uuids' => [$a->getUuid(), $b->getUuid()], + 'duration_from_services' => true, + ])); + self::assertSame(201, $this->responseCode()); + + $appt = $this->reload($res['data']['uuid']); + self::assertCount(2, $appt->getServiceItems()); + // مجموع مدت ۳۰+۲۰=۵۰ دقیقه → slot_end بازمحاسبه‌شده + self::assertSame($start + 50 * 60, $appt->getSlotEnd()); + } + + public function testUnknownServiceUuidIsRejected(): void + { + [$owner, $doctor] = $this->doctor(); + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 1_800, [ + 'service_item_uuids' => ['no-such-uuid'], + ])); + self::assertSame(422, $this->responseCode()); + } +} diff --git a/tests/Patient/PatientListNationalCodeTest.php b/tests/Patient/PatientListNationalCodeTest.php index 5175ddc6..7b5795c9 100644 --- a/tests/Patient/PatientListNationalCodeTest.php +++ b/tests/Patient/PatientListNationalCodeTest.php @@ -27,10 +27,13 @@ class PatientListNationalCodeTest extends ApiTestCase { [$owner, $doctor] = $this->doctor(); + // db_test ریست نمی‌شود؛ کد ملیِ یکتا رندوم تا با اجراهای قبلی تصادم نکند. + $nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT); + $patient = $this->createUser(['ROLE_USER']); // کد ملی فقط روی پروفایل، نه روی خود کاربر $profile = new UserProfile($patient); - $profile->setNationalCode('0012345675'); + $profile->setNationalCode($nc); $this->em->persist($profile); $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); @@ -40,7 +43,7 @@ class PatientListNationalCodeTest extends ApiTestCase $res = $this->authJson('GET', '/api/v1/patients', $owner); self::assertSame(200, $this->responseCode()); self::assertSame($record->getUuid(), $res['data'][0]['uuid']); - self::assertSame('0012345675', $res['data'][0]['user_national_code']); + self::assertSame($nc, $res['data'][0]['user_national_code']); } public function testNullWhenNoNationalCodeAnywhere(): void