From b6994763055258d9ff8a8cdbcb0fa14daec55612 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 8 Aug 2026 07:14:40 +0330 Subject: [PATCH] feat(appointment): derive service section from service item when missing in appointment --- .../admin/pages/AppointmentEditPage.test.tsx | 23 +++++ assets/admin/pages/AppointmentEditPage.tsx | 27 ++---- docs/api/appointment.md | 7 ++ .../Controller/AppointmentController.php | 22 ++++- .../AppointmentDetailSectionFallbackTest.php | 87 +++++++++++++++++++ 5 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 tests/Appointment/AppointmentDetailSectionFallbackTest.php diff --git a/assets/admin/pages/AppointmentEditPage.test.tsx b/assets/admin/pages/AppointmentEditPage.test.tsx index cd2cfa23..c5c8c261 100644 --- a/assets/admin/pages/AppointmentEditPage.test.tsx +++ b/assets/admin/pages/AppointmentEditPage.test.tsx @@ -50,6 +50,29 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => { expect(screen.getByDisplayValue('یادداشت')).toBeInTheDocument(); }); + // بخش و سرویس هر دو باید برچسب واقعی نشان دهند، نه placeholder. فهرست سرویس‌ها + // از بخش می‌آید، پس بخشِ خالی یعنی سرویسِ ذخیره‌شده هم انتخاب‌نشده دیده می‌شود — + // همان باگی که در ۲۰۲۶-۰۸-۰۸ گزارش شد. + it('shows the stored section and service, not their placeholders', async () => { + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + expect(await screen.findByText('زیبایی')).toBeInTheDocument(); + expect(await screen.findByText('لیزر')).toBeInTheDocument(); + expect(screen.queryByText('انتخاب بخش')).not.toBeInTheDocument(); + expect(screen.queryByText('انتخاب سرویس')).not.toBeInTheDocument(); + }); + + // بلوک بیعانه فعلاً از فرم برداشته شده، ولی مقدارش همچنان ذخیره می‌شود + // (اثباتش در تست بعدی: deposit_required در payload هست). + it('no longer renders the deposit block', async () => { + renderEdit(); + await screen.findByText('مشخصات سرویس:'); + + expect(screen.queryByText('بیعانه:')).not.toBeInTheDocument(); + expect(screen.queryByText('بیعانه مورد نیاز است.')).not.toBeInTheDocument(); + }); + it('patches the general update endpoint with the edited values', async () => { renderEdit(); await screen.findByText('مشخصات سرویس:'); diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx index 6619865f..8cda2210 100644 --- a/assets/admin/pages/AppointmentEditPage.tsx +++ b/assets/admin/pages/AppointmentEditPage.tsx @@ -6,9 +6,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import PersianDateInput from '../components/ui/PersianDateInput'; -import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; -import { WalletChargeLink } from '../components/AppointmentActions'; import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares'; import BackButton from '../components/ui/BackButton'; @@ -16,7 +14,6 @@ import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker'; -import Switch from '../components/ui/Switch'; interface Option { uuid: string; name?: string; full_name?: string } @@ -357,24 +354,12 @@ export default function AppointmentEditPage() { )} -
بیعانه:
-
- - {depositRequired && ( - <> -
- -
-
- - - )} -
+ {/* + بخش «بیعانه» فعلاً از فرم برداشته شد (درخواست ۲۰۲۶-۰۸-۰۸). + state و ارسالش عمداً سر جایشان ماندند: مقدارِ فعلیِ نوبت هنگام هیدریت + خوانده و همان هنگام ذخیره پس فرستاده می‌شود، پس ویرایشِ بقیهٔ فیلدها + بیعانهٔ ثبت‌شده را پاک نمی‌کند. برگرداندنِ UI یعنی همین بلوک، نه بیشتر. + */}
diff --git a/docs/api/appointment.md b/docs/api/appointment.md index dabb2611..5fb34508 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -390,6 +390,13 @@ Get appointment detail. **Permission:** `AUTH` — see [Single-appointment access model](#single-appointment-access-model) +> **`service_section` اینجا استنتاجی است.** نوبتِ سرویسیِ کلینیک بخش را روی خودِ نوبت +> ذخیره نمی‌کند و فقط سرویس را نگه می‌دارد. این اندپوینت — و **فقط** این اندپوینت — وقتی +> `service_section` خالی باشد آن را از `service_item` درمی‌آورد. حدس نیست: +> `service_items.section_id` ستونی `NOT NULL` است، پس هر سرویسی دقیقاً یک بخش دارد. +> نوبتِ بدون هیچ سرویسی همچنان `null` می‌گیرد. فهرست‌ها (`GET /api/v1/appointments…`) +> عمداً این استنتاج را ندارند و رفتارشان تغییر نکرده. + ### Path Parameters | Param | Type | Description | |-------|------|-------------| diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 990e1f39..b9a9fc79 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -704,7 +704,27 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } - return $this->success(['data' => $appointment->toArray()]); + $payload = $appointment->toArray(); + + /** + * نوبتِ سرویسیِ کلینیک `service_section_id` را روی خودِ نوبت نمی‌نویسد؛ فقط + * سرویس را نگه می‌دارد. ولی فرم ویرایش فهرستِ سرویس‌ها را از **بخش** + * می‌گیرد، پس بخشِ خالی یعنی فهرست سرویس هم خالی می‌ماند و سرویسِ + * ذخیره‌شده — با اینکه مقدارش درست است — بی‌برچسب و انگار انتخاب‌نشده + * دیده می‌شود. + * + * بخش از خودِ سرویس درمی‌آید و حدس نیست: `service_items.section_id` ستونِ + * NOT NULL است، پس هر سرویسی دقیقاً یک بخش دارد. + * + * عمداً اینجاست و نه در `Appointment::toArray()`: آنجا فهرستِ نوبت‌ها هم + * از همین متد می‌خواند و نباید ناگهان بخشی نشان دهد که تا امروز نداشت. + */ + if ($payload['service_section'] === null && ($item = $appointment->getServiceItem()) !== null) { + $section = $item->getSection(); + $payload['service_section'] = ['uuid' => $section->getUuid(), 'name' => $section->getName()]; + } + + return $this->success(['data' => $payload]); } #[OA\Get( diff --git a/tests/Appointment/AppointmentDetailSectionFallbackTest.php b/tests/Appointment/AppointmentDetailSectionFallbackTest.php new file mode 100644 index 00000000..a894b2b8 --- /dev/null +++ b/tests/Appointment/AppointmentDetailSectionFallbackTest.php @@ -0,0 +1,87 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست بخش'); + $this->em->persist($doctor); + // بخش به شناسهٔ پزشک نیاز دارد، پس پزشک باید پیش از ساختش flush شود. + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش لیزر'); + $this->em->persist($section); + + $item = new ServiceItem($section, 'لیزر توتال', 0); + $item->setDurationMinutes(40)->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + return [$doctor, $section, $item]; + } + + /** ✅ موفق — بخشِ ذخیره‌نشده از روی سرویس پر می‌شود. */ + public function testSectionIsDerivedFromTheServiceItemWhenMissing(): void + { + [$doctor, $section, $item] = $this->doctorWithOneService(); + + $patient = $this->createUser(['ROLE_USER']); + $start = strtotime('+5 days 10:00'); + $appt = $this->newAppointment($doctor, $patient, $start, $start + 2400); + + $appt->replaceServiceItems([$item]); + self::assertNull($appt->getServiceSection(), 'پیش‌فرضِ این سناریو: نوبت بخش ندارد'); + $this->em->persist($appt); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/appointment/' . $appt->getUuid(), $doctor->getUser()); + self::assertSame(200, $this->responseCode()); + + $payload = $res['data']['data']; + self::assertSame($section->getUuid(), $payload['service_section']['uuid']); + self::assertSame('بخش لیزر', $payload['service_section']['name']); + self::assertSame($item->getUuid(), $payload['service_item']['uuid']); + } + + /** ⚠️ مرزی — نوبتِ بدون هیچ سرویسی چیزی برای استنتاج ندارد و `null` می‌ماند. */ + public function testSectionStaysNullWhenTheAppointmentHasNoService(): void + { + [$doctor] = $this->doctorWithOneService(); + + $patient = $this->createUser(['ROLE_USER']); + $start = strtotime('+6 days 10:00'); + $appt = $this->newAppointment($doctor, $patient, $start, $start + 1800); + $this->em->persist($appt); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/appointment/' . $appt->getUuid(), $doctor->getUser()); + self::assertSame(200, $this->responseCode()); + self::assertNull($res['data']['data']['service_section']); + } + + /** ❌ خطا — نوبتِ ناموجود همچنان ۴۰۴ است؛ fallback مسیر خطا را عوض نمی‌کند. */ + public function testUnknownAppointmentStillReturns404(): void + { + [$doctor] = $this->doctorWithOneService(); + + $this->authJson('GET', '/api/v1/appointment/00000000-0000-0000-0000-000000000000', $doctor->getUser()); + self::assertSame(404, $this->responseCode()); + } +}