From 12c1d2cbf417701c6b678c68b57f62f5f554e04a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 17:31:21 +0330 Subject: [PATCH] fix(appointment): fall back to the resource's supervising doctor on public booking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A laser device is not a doctor, so booking one from the public site sent resource_uuid and no doctor_uuid and got back "doctor_uuid یا resource_uuid الزامی است" — a message telling the caller to send something it had already sent. The panel path had resolved this from ClinicResource.supervisor since it was written; only the public path had not, and the field was defined but never read there. A resource with no supervisor now gets its own message pointing at the actual fix, instead of the generic one. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/appointment.md | 14 +++++++- .../Controller/AppointmentController.php | 19 ++++++++++- tests/Appointment/BookForResourceTest.php | 33 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 63863d52..87c19e45 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -250,7 +250,7 @@ Book an appointment slot. | `slot_start` | integer | ✅ | Slot start (Unix timestamp) | | `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). **با `service_item_uuids` نادیده گرفته می‌شود** و سرور خودش حساب می‌کند (به مقدار کلاینت اعتماد نمی‌شود)؛ در آن حالت الزامی هم نیست. بدون سرویس، مقدار کلاینت حفظ می‌شود و الزامی است | | `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس. سرویس‌ها **ذخیره** می‌شوند (`service_items`)، اولین سرویس سرویسِ اصلی (`service_item`) است، و مدت/بافر روی نوبت ثبت می‌شود (`service_total_minutes` / `service_buffer_minutes`). UUID ناموجود، سرویسِ غیرbookable، سرویس بدون مدت، یا سرویسِ محیطی دیگر ⇒ `422` | -| `resource_uuid` | string (UUID) | ⚠️ | منبعی که نوبت **برایش** گرفته می‌شود (دستگاه، اتاق، یا خودِ پزشک). اگر داده شود `doctor_uuid` اختیاری است و برای منبعِ پزشک از خودش استنتاج می‌شود؛ محل نوبت هم از شعبهٔ همان منبع می‌آید. منبع باید در همان محیط رزرو باشد و اگر سرویس انتخاب‌شده را ارائه ندهد ⇒ `422` | +| `resource_uuid` | string (UUID) | ⚠️ | منبعی که نوبت **برایش** گرفته می‌شود (دستگاه، اتاق، یا خودِ پزشک). اگر داده شود `doctor_uuid` اختیاری است: برای منبعِ پزشک از خودش استنتاج می‌شود و برای دستگاه/اتاق از **پزشک ناظرِ** همان منبع؛ محل نوبت هم از شعبهٔ همان منبع می‌آید. منبع باید در همان محیط رزرو باشد و اگر سرویس انتخاب‌شده را ارائه ندهد ⇒ `422` | | `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 | @@ -263,6 +263,18 @@ Book an appointment slot. > **`doctor_uuid` یا `resource_uuid`:** دست‌کم یکی الزامی است؛ نبودِ هر دو ⇒ `422`. مسیر > قدیمیِ فقط-`doctor_uuid` دست‌نخورده است و سایت عمومی همان را می‌فرستد. > +> **دستگاه پزشک نیست.** وقتی `resource_uuid` یک دستگاه یا اتاق است، پزشک از +> `ClinicResource.supervisor` برداشته می‌شود — اپراتور کار را می‌کند و پزشک پاسخگوی +> بالینی است. اگر منبع ناظر نداشته باشد پیام مخصوص خودش برمی‌گردد، نه پیامِ عمومیِ +> «`doctor_uuid` یا `resource_uuid` لازم است» که کلاینت هر دو را فرستاده بود: +> +> ```json +> {"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید","field":"resource_uuid"}]} +> ``` +> +> مسیر پنل (`POST /api/v1/my/appointment`) این رفتار را از قبل داشت؛ این تغییر مسیر +> عمومی را با آن هم‌تراز کرد. +> > **پاسخ:** علاوه بر فیلدهای قبلی، `resource` (`uuid`, `name`, `type`) و `service_option` > (`uuid`, `name`) برمی‌گردند. نوبت‌های پیش از مدل منبع‌محور هر دو را `null` دارند، پس > کلاینت باید با `null` کنار بیاید. diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index fc76eb42..e7566cf1 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -456,7 +456,9 @@ class AppointmentController extends BaseController /** * منبع می‌تواند جای پزشک بنشیند: نوبتِ «دستگاه لیزر ۲» پزشکی ندارد که uuidش - * فرستاده شود. اگر منبع خودش پزشک باشد، پزشک از آن استنتاج می‌شود. + * فرستاده شود. اگر منبع خودش پزشک باشد، پزشک از آن استنتاج می‌شود؛ وگرنه + * پزشکِ ناظرِ همان منبع می‌نشیند — دستگاه را اپراتور کار می‌کند و پزشک فقط + * پاسخگوی بالینی است. */ $resourceUuid = trim((string) ($data['resource_uuid'] ?? '')); $resource = null; @@ -472,6 +474,21 @@ class AppointmentController extends BaseController $doctorUuid = $resource->subject()->getUuid(); } + if ($doctorUuid === '' && $resource->getSupervisor() !== null) { + $doctorUuid = $resource->getSupervisor()->getUuid(); + } + + // پیام عمومیِ «doctor_uuid یا resource_uuid لازم است» اینجا گمراه‌کننده بود: + // کلاینت هر دو را فرستاده و مشکل نبودِ ناظر روی خودِ منبع است. + if ($doctorUuid === '') { + return $this->error( + ErrorCodes::ERR_VALIDATION_002, + 'این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید', + 422, + 'resource_uuid', + ); + } + // رزرو **برای یک منبع** یعنی رزرو در شعبهٔ همان منبع؛ فرستادن جداگانهٔ // `clinic_uuid` فقط راهی برای ناسازگار کردن این دو بود. if ($clinicUuid === null && $resource->getAddress()->getClinicId() !== null) { diff --git a/tests/Appointment/BookForResourceTest.php b/tests/Appointment/BookForResourceTest.php index e1deea60..2b68786d 100644 --- a/tests/Appointment/BookForResourceTest.php +++ b/tests/Appointment/BookForResourceTest.php @@ -105,6 +105,39 @@ class BookForResourceTest extends ApiTestCase self::assertSame('دکتر یوسفی', $row['resource']['name']); } + /** + * دستگاه لیزر پزشک نیست؛ پزشکِ ناظرش می‌نشیند. + * + * مسیر پنل این را از قبل داشت و مسیر عمومی نداشت — یعنی همان رزرو از سایت + * ۴۲۲ می‌گرفت با پیامی که می‌گفت `resource_uuid` بفرست، در حالی که فرستاده بود. + */ + public function testBookingForADeviceFallsBackToItsSupervisor(): void + { + [$clinic, $doctor, $address] = $this->clinic(); + $device = $this->resource($clinic, $address, 'دستگاه لیزر ۲'); + $device->setSupervisor($doctor); + $this->em->flush(); + + [$res] = $this->book(['resource_uuid' => $device->getUuid()]); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + self::assertSame($device->getUuid(), $res['data']['data']['resource']['uuid']); + self::assertSame($doctor->getUuid(), $res['data']['data']['doctor']['uuid']); + } + + /** پیام باید بگوید ناظر ندارد، نه اینکه uuid نفرستادی. */ + public function testADeviceWithoutASupervisorSaysSo(): void + { + [$clinic, , $address] = $this->clinic(); + $device = $this->resource($clinic, $address, 'دستگاه بی‌ناظر'); + + [$res] = $this->book(['resource_uuid' => $device->getUuid()]); + + self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + self::assertSame('resource_uuid', $res['errors'][0]['field']); + self::assertStringContainsString('پزشک ناظر', $res['errors'][0]['message']); + } + public function testTheOldDoctorOnlyPathIsUntouched(): void { [, $doctor] = $this->clinic();