From ab4974d1740db691ef591e4fab73c87ff6c16873 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 12:15:42 +0330 Subject: [PATCH] feat(resource): every resource is supervised by a doctor Supervision now lives on the resource itself instead of being asked for again at booking time, so one relation answers it everywhere. The column is deliberately separate from the existing doctor_id bridge. That bridge means "this resource IS this doctor" and isPerson() uses it to pin capacity at 1; a supervised three-seat device must not become a person resource. The FK is SET NULL rather than CASCADE because deleting a doctor should not take the clinic's laser with it. Required on create and non-clearable on update, enforced in the API where it can give a Persian message. Ownership is checked through Clinic::hasDoctor so a secretary cannot put their device under a doctor of another clinic; that returns 404, not 403, keeping foreign data invisible. The 13 existing resources are backfilled deterministically: a practice resource gets its own doctor, a clinic resource gets that clinic's first doctor. Both are editable from the resource form. Co-Authored-By: Claude Opus 5 (1M context) --- .../resources/ResourceFormModal.tsx | 29 +++++++ assets/admin/types/index.ts | 3 + docs/api/resource.md | 31 +++++++ migrations/Version20260803093000.php | 59 ++++++++++++++ .../Controller/ResourceController.php | 22 ++++- src/Resource/Entity/ClinicResource.php | 27 +++++++ src/Resource/Service/ResourceContext.php | 37 +++++++++ src/Resource/Service/ResourceService.php | 4 +- tests/Resource/ResourceCrudTest.php | 81 ++++++++++++++++++- tests/Resource/ResourceTestCase.php | 35 +++++++- 10 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 migrations/Version20260803093000.php diff --git a/assets/admin/components/resources/ResourceFormModal.tsx b/assets/admin/components/resources/ResourceFormModal.tsx index c154a74e..eb896997 100644 --- a/assets/admin/components/resources/ResourceFormModal.tsx +++ b/assets/admin/components/resources/ResourceFormModal.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState } from 'react'; import Modal from '../ui/Modal'; +import { useQuery } from '@tanstack/react-query'; import SearchableSelect from '../ui/SearchableSelect'; +import { api, type ApiResponse } from '../../lib/api'; import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types'; import { useResourceDetail } from '../../hooks/useResources'; import { formatNumber } from '../../lib/utils'; @@ -25,9 +27,21 @@ export default function ResourceFormModal({ }: Props) { // شمار نوبت‌های آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است. const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined); + // پزشکان محیط جاری برای انتخاب ناظر — همان اندپوینت احرازشده‌ای که صفحهٔ نوبت‌ها + // استفاده می‌کند، تا منشی فقط پزشکان مجازش را ببیند. + const doctorsQuery = useQuery>({ + queryKey: ['booking-doctors'], + queryFn: () => api.get('/api/v1/my/clinic-doctors'), + enabled: open, + staleTime: 60_000, + }); + const doctors = doctorsQuery.data?.data?.data ?? []; + const doctorsLoading = doctorsQuery.isLoading; + const [name, setName] = useState(''); const [addressUuid, setAddressUuid] = useState(null); const [typeUuid, setTypeUuid] = useState(null); + const [supervisorUuid, setSupervisorUuid] = useState(null); const [capacity, setCapacity] = useState('1'); const [setupMinutes, setSetupMinutes] = useState('0'); const [cleanupMinutes, setCleanupMinutes] = useState('0'); @@ -39,6 +53,7 @@ export default function ResourceFormModal({ setName(resource?.name ?? ''); setAddressUuid(resource?.address_uuid ?? null); setTypeUuid(resource?.type_uuid ?? null); + setSupervisorUuid(resource?.supervisor?.uuid ?? null); setCapacity(String(resource?.capacity ?? 1)); setSetupMinutes(String(resource?.setup_minutes ?? 0)); setCleanupMinutes(String(resource?.cleanup_minutes ?? 0)); @@ -52,6 +67,7 @@ export default function ResourceFormModal({ const parsedCapacity = Number(capacity); const invalid = name.trim() === '' || + !supervisorUuid || (!isEdit && (!addressUuid || !typeUuid)) || !Number.isFinite(parsedCapacity) || parsedCapacity < 1; @@ -69,6 +85,7 @@ export default function ResourceFormModal({ cleanup_minutes: Number(cleanupMinutes) || 0, attributes: attrs, active, + supervisor_doctor_uuid: supervisorUuid!, }; // شعبه و نوع فقط هنگام ساخت فرستاده می‌شوند؛ جفت محیطِ منبع از آدرس مشتق شده و @@ -109,6 +126,18 @@ export default function ResourceFormModal({ height={38} /> + {/* ناظر برخلاف شعبه و نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک + دستگاه عوض می‌شود، ولی محیطِ منبع نه. */} + + ({ value: d.uuid, label: d.name }))} + value={supervisorUuid} + onChange={(v) => setSupervisorUuid(v ? String(v) : null)} + placeholder="پزشک ناظر را انتخاب کنید" + isLoading={doctorsLoading} + height={38} + /> + {isEdit && ( diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index e47b4729..b588ebfc 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -989,6 +989,8 @@ export interface ClinicResource { setup_minutes: number; cleanup_minutes: number; attributes: Record; + /** پزشکِ ناظر — الزامی. جدا از پل: دستگاهِ تحت نظارت، «منبع انسانی» نمی‌شود. */ + supervisor: { uuid: string; name: string } | null; /** `null` یعنی دستگاه/تجهیزات — منبعی که پل به موجودیت دیگری ندارد */ subject_kind: 'doctor' | 'staff' | 'room' | null; subject_uuid: string | null; @@ -1002,6 +1004,7 @@ export interface ClinicResource { export interface ResourcePayload { name: string; + supervisor_doctor_uuid?: string; address_uuid?: string; type_uuid?: string; capacity?: number; diff --git a/docs/api/resource.md b/docs/api/resource.md index 3f692f13..eb4e4181 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -178,6 +178,7 @@ |---|---|---|---| | `address_uuid` | string | ✅ | محل نوبت‌دهی؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه | | `type_uuid` | string | ✅ | | +| `supervisor_doctor_uuid` | string | ✅ | پزشکِ ناظرِ منبع. باید پزشکِ همین محیط باشد وگرنه ۴۰۴ | | `name` | string | ✅ | حداکثر ۱۵۰ نویسه | | `capacity` | int | — | پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ | | `setup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ | @@ -185,6 +186,36 @@ | `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر | | `active` | bool | — | پیش‌فرض `true` | +### پزشک ناظر (2026-08) + +هر منبع **الزاماً** زیر نظر یک پزشک است؛ صفحهٔ نوبت‌ها تب منابع را زیر همان پزشک +می‌چیند، پس منبعِ بی‌ناظر جایی برای دیده‌شدن ندارد. + +- `supervisor_doctor_uuid` در ساخت الزامی است → نبودش `422` با + `field: supervisor_doctor_uuid` و پیام «انتخاب پزشک ناظر الزامی است». +- در `PATCH` **اختیاری** است، ولی اگر بیاید نمی‌تواند خالی باشد (برداشتن ناظر ممنوع). +- پزشکِ خارج از محیط → `404` («پزشک ناظر یافت نشد») — وجود دادهٔ محیط بیگانه لو نمی‌رود. +- پاسخ‌ها فیلد `supervisor` را به شکل `{uuid, name}` برمی‌گردانند (یا `null` برای + ردیف‌هایی که پزشکشان حذف شده — کلید خارجی `SET NULL` است). + +**ناظر با پلِ منبع فرق دارد.** `subject_kind`/`doctor_id` یعنی «این منبع خودِ همان +پزشک است» و `isPerson()` بر پایه‌اش ظرفیت را به ۱ قفل می‌کند. ناظرِ یک دستگاهِ +سه‌ظرفیتی نباید آن را به منبعِ انسانی تبدیل کند، پس ستون جداست. + +خروجی واقعی `GET /api/v1/resources?active=1`: + +```json +{ + "name": "اتاق ۱", + "supervisor": { "uuid": "631e81d8-0009-4e01-a40f-3029905a3f27", "name": "امیر کاظمی" }, + "subject_kind": null, + "capacity": 1 +} +``` + +**backfill:** ۱۳ منبعِ موجود در migration ناظر گرفتند — منبعِ مطب → همان پزشک، منبعِ +کلینیک → پزشکِ اولِ همان کلینیک. قابل تغییر از فرم ویرایش منبع. + `attributes` عمداً آزاد است — کلید ناشناخته پذیرفته می‌شود — ولی مقدارش باید ساده باشد. دلیل: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را با مقایسهٔ ساده روی همین مقادیر می‌سنجند؛ آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، همان چیزی که بند ۸ مستند diff --git a/migrations/Version20260803093000.php b/migrations/Version20260803093000.php new file mode 100644 index 00000000..3e380e68 --- /dev/null +++ b/migrations/Version20260803093000.php @@ -0,0 +1,59 @@ +addSql('ALTER TABLE clinic_resources ADD supervisor_id INT DEFAULT NULL'); + $this->addSql( + 'ALTER TABLE clinic_resources ' + . 'ADD CONSTRAINT FK_resource_supervisor FOREIGN KEY (supervisor_id) ' + . 'REFERENCES doctors (id) ON DELETE SET NULL' + ); + $this->addSql('CREATE INDEX idx_resource_supervisor ON clinic_resources (supervisor_id)'); + + // منبعِ یک مطب شخصی: ناظرش خودِ همان پزشک است. + $this->addSql( + 'UPDATE clinic_resources SET supervisor_id = entity_id ' + . "WHERE supervisor_id IS NULL AND entity_type = 'doctor'" + ); + + // منبعِ کلینیک: پزشکِ اولِ همان کلینیک. اپراتور می‌تواند بعداً عوضش کند. + $this->addSql( + 'UPDATE clinic_resources r SET r.supervisor_id = (' + . ' SELECT MIN(cd.doctor_id) FROM clinic_doctors cd WHERE cd.clinic_id = r.entity_id' + . ") WHERE r.supervisor_id IS NULL AND r.entity_type = 'clinic'" + ); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_resource_supervisor ON clinic_resources'); + $this->addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY FK_resource_supervisor'); + $this->addSql('ALTER TABLE clinic_resources DROP supervisor_id'); + } +} diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index bd8a6f0f..47fab478 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -81,10 +81,17 @@ class ResourceController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid'); } - $address = $this->context->address($user, $data['address_uuid']); - $type = $this->context->type($user, $data['type_uuid']); + // هر منبع زیر نظر یک پزشک است — صفحهٔ نوبت‌ها تب منابع را زیر همان پزشک می‌چیند، + // پس منبعِ بی‌ناظر جایی برای دیده‌شدن ندارد. + if (!is_string($data['supervisor_doctor_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid'); + } - return $this->success($this->service->create($address, $type, $data)->toArray(), 201); + $address = $this->context->address($user, $data['address_uuid']); + $type = $this->context->type($user, $data['type_uuid']); + $supervisor = $this->context->supervisor($user, $data['supervisor_doctor_uuid']); + + return $this->success($this->service->create($address, $type, $data, $supervisor)->toArray(), 201); } #[Route('/api/v1/resource/{uuid}', name: 'resource_show', methods: ['GET'])] @@ -118,6 +125,15 @@ class ResourceController extends BaseController $resource->setType($this->context->type($user, $data['type_uuid'])); } + // ناظر عوض می‌شود ولی برداشته نمی‌شود: فرستادن رشتهٔ خالی یعنی منبعِ بی‌ناظر. + if (array_key_exists('supervisor_doctor_uuid', $data)) { + if (!is_string($data['supervisor_doctor_uuid']) || $data['supervisor_doctor_uuid'] === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid'); + } + + $resource->setSupervisor($this->context->supervisor($user, $data['supervisor_doctor_uuid'])); + } + return $this->success($this->service->update($resource, $data)->toArray()); } diff --git a/src/Resource/Entity/ClinicResource.php b/src/Resource/Entity/ClinicResource.php index 5eec6735..662eede5 100644 --- a/src/Resource/Entity/ClinicResource.php +++ b/src/Resource/Entity/ClinicResource.php @@ -84,6 +84,20 @@ class ClinicResource #[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] private ?ClinicStaff $staff = null; + /** + * پزشکِ ناظرِ منبع — کسی که این دستگاه/اتاق زیر نظر او کار می‌کند. + * + * عمداً از `$doctor` جداست: آن **پل** است («این منبع خودِ همان پزشک است») و + * `isPerson()` بر پایه‌اش ظرفیت را به ۱ قفل می‌کند. ناظرِ یک دستگاهِ سه‌ظرفیتی + * نباید آن را به منبعِ انسانی تبدیل کند. + * + * `SET NULL` نه `CASCADE`: حذف پزشک نباید دستگاه کلینیک را پاک کند. ستون در + * دیتابیس تهی‌پذیر است تا حذف پزشک ردیف را نشکند، ولی API ناظر را الزامی می‌گیرد. + */ + #[ORM\ManyToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(name: 'supervisor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Doctor $supervisor = null; + #[ORM\Column(type: 'boolean', options: ['default' => true])] private bool $active = true; @@ -183,6 +197,16 @@ class ClinicResource return $this; } + public function getSupervisor(): ?Doctor { return $this->supervisor; } + + public function setSupervisor(?Doctor $doctor): self + { + $this->supervisor = $doctor; + $this->touch(); + + return $this; + } + /** منبعی که یک انسان است: ظرفیتش همیشه ۱ می‌ماند. */ public function isPerson(): bool { @@ -246,6 +270,9 @@ class ClinicResource 'setup_minutes' => $this->setupMinutes, 'cleanup_minutes' => $this->cleanupMinutes, 'attributes' => (object) $this->getAttributes(), + 'supervisor' => $this->supervisor === null + ? null + : ['uuid' => $this->supervisor->getUuid(), 'name' => $this->supervisor->getName()], 'subject_kind' => match (true) { $this->doctor !== null => 'doctor', $this->staff !== null => 'staff', diff --git a/src/Resource/Service/ResourceContext.php b/src/Resource/Service/ResourceContext.php index c71f052f..eed3918c 100644 --- a/src/Resource/Service/ResourceContext.php +++ b/src/Resource/Service/ResourceContext.php @@ -4,13 +4,16 @@ namespace App\Resource\Service; use App\Auth\Entity\User; use App\Doctor\Service\AddressResolver; +use App\Doctor\Entity\Doctor; use App\Doctor\Entity\DoctorAddress; +use App\Doctor\Repository\DoctorRepository; use App\Resource\Entity\ClinicResource; use App\Resource\Entity\ResourcePool; use App\Resource\Entity\ResourceType; use App\Resource\Entity\Skill; use App\Resource\Repository\ClinicResourceRepository; use App\Resource\Repository\ResourcePoolRepository; +use App\Clinic\Repository\ClinicRepository; use App\Resource\Repository\ResourceTypeRepository; use App\Resource\Repository\SkillRepository; use App\Shared\Constant\ErrorCodes; @@ -36,6 +39,8 @@ final class ResourceContext private readonly SkillRepository $skills, private readonly ResourcePoolRepository $pools, private readonly TenantOwnershipChecker $ownership, + private readonly DoctorRepository $doctors, + private readonly ClinicRepository $clinics, ) {} /** @return array{0: string, 1: int} */ @@ -54,6 +59,38 @@ final class ResourceContext return $this->owned($user, $this->types->findByUuid($uuid), 'نوع منبع یافت نشد'); } + /** + * پزشکِ ناظرِ منبع. + * + * `Doctor` تحت `TenantOwnedTrait` نیست (پزشک می‌تواند هم‌زمان عضو چند کلینیک باشد)، + * پس مالکیت با عضویت سنجیده می‌شود: در محیط کلینیک باید پزشکِ همان کلینیک باشد، و در + * مطب شخصی باید خودِ همان پزشک. بدون این، منشیِ یک کلینیک می‌توانست دستگاهش را زیر + * نظر پزشک کلینیک دیگری ببرد. + */ + public function supervisor(User $user, string $uuid): Doctor + { + $doctor = $this->doctors->findByUuid($uuid); + + if ($doctor === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک ناظر یافت نشد', 404); + } + + [$entityType, $entityId] = $this->pair($user); + + // رابطه از سمت کلینیک تعریف شده (`Clinic::$doctors`)، پس عضویت را خودِ کلینیک + // جواب می‌دهد — `Clinic::hasDoctor()`؛ کوئری تازه‌ای لازم نیست. + $clinic = $entityType === 'clinic' ? $this->clinics->find($entityId) : null; + $belongs = $entityType === 'clinic' + ? ($clinic !== null && $clinic->hasDoctor($doctor)) + : (int) $doctor->getId() === $entityId; + + if (!$belongs) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک ناظر یافت نشد', 404); + } + + return $doctor; + } + public function resource(User $user, string $uuid): ClinicResource { return $this->owned($user, $this->resources->findByUuid($uuid), 'منبع یافت نشد'); diff --git a/src/Resource/Service/ResourceService.php b/src/Resource/Service/ResourceService.php index 02fabfad..5349ed03 100644 --- a/src/Resource/Service/ResourceService.php +++ b/src/Resource/Service/ResourceService.php @@ -2,6 +2,7 @@ namespace App\Resource\Service; +use App\Doctor\Entity\Doctor; use App\Doctor\Entity\DoctorAddress; use App\Resource\Entity\ClinicResource; use App\Resource\Entity\ResourceType; @@ -57,9 +58,10 @@ final class ResourceService } /** @param array $data */ - public function create(DoctorAddress $address, ResourceType $type, array $data): ClinicResource + public function create(DoctorAddress $address, ResourceType $type, array $data, Doctor $supervisor): ClinicResource { $resource = new ClinicResource($address, $type, $this->assertName($data['name'] ?? null)); + $resource->setSupervisor($supervisor); $this->applyOptional($resource, $data); $this->em->persist($resource); diff --git a/tests/Resource/ResourceCrudTest.php b/tests/Resource/ResourceCrudTest.php index 8ba72281..03db8d55 100644 --- a/tests/Resource/ResourceCrudTest.php +++ b/tests/Resource/ResourceCrudTest.php @@ -111,15 +111,90 @@ class ResourceCrudTest extends ResourceTestCase [, , $foreignAddress] = $this->clinicWithAddress('شعبهٔ بیگانه'); + // ناظر معتبرِ محیط خودی می‌آید تا خطای آدرسِ بیگانه سنجیده شود، نه خطای فیلد کم. $this->authJson('POST', '/api/v1/resource', $user, [ - 'address_uuid' => $foreignAddress->getUuid(), - 'type_uuid' => $type->getUuid(), - 'name' => 'دستگاه', + 'address_uuid' => $foreignAddress->getUuid(), + 'type_uuid' => $type->getUuid(), + 'name' => 'دستگاه', + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), ]); self::assertSame(404, $this->responseCode()); } + /** + * هر منبع زیر نظر یک پزشک است — صفحهٔ نوبت‌ها تب منابع را زیر همان پزشک می‌چیند، + * پس منبعِ بی‌ناظر جایی برای دیده‌شدن ندارد. + */ + public function testSupervisorIsRequiredOnCreate(): void + { + [$user, , $address] = $this->clinicWithAddress(); + + $body = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $this->resourceType($address)->getUuid(), + 'name' => 'دستگاه بی‌ناظر', + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('supervisor_doctor_uuid', $body['errors'][0]['field']); + } + + public function testSupervisorIsReturnedOnTheResource(): void + { + [$user, , $address] = $this->clinicWithAddress(); + $supervisor = $this->supervisorFor($address); + + $body = $this->createResource($user, $address, $this->resourceType($address)); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($supervisor->getUuid(), $body['data']['supervisor']['uuid']); + self::assertSame($supervisor->getName(), $body['data']['supervisor']['name']); + } + + /** ناظر پلِ منبع نیست: دستگاه با ناظر نباید «منبع انسانی» شود و ظرفیتش قفل بماند. */ + public function testSupervisorDoesNotTurnADeviceIntoAPersonResource(): void + { + [$user, , $address] = $this->clinicWithAddress(); + + $body = $this->createResource($user, $address, $this->resourceType($address), ['capacity' => 3]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(3, $body['data']['capacity']); + self::assertNull($body['data']['subject_kind'], 'ناظر نباید پل شمرده شود'); + } + + /** منشیِ یک کلینیک نباید دستگاهش را زیر نظر پزشک کلینیک دیگری ببرد. */ + public function testForeignSupervisorIsNotFound(): void + { + [$user, , $address] = $this->clinicWithAddress(); + [, , $foreignAddress] = $this->clinicWithAddress('کلینیک بیگانه'); + $foreignDoctor = $this->supervisorFor($foreignAddress); + + $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $this->resourceType($address)->getUuid(), + 'name' => 'دستگاه', + 'supervisor_doctor_uuid' => $foreignDoctor->getUuid(), + ]); + + self::assertSame(404, $this->responseCode()); + } + + /** تغییر ناظر مجاز است، برداشتنش نه. */ + public function testSupervisorCannotBeCleared(): void + { + [$user, , $address] = $this->clinicWithAddress(); + $created = $this->createResource($user, $address, $this->resourceType($address)); + + $body = $this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $user, [ + 'supervisor_doctor_uuid' => '', + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('supervisor_doctor_uuid', $body['errors'][0]['field']); + } + public function testForeignResourceIsNotFound(): void { [$ownerUser, , $ownerAddress] = $this->clinicWithAddress(); diff --git a/tests/Resource/ResourceTestCase.php b/tests/Resource/ResourceTestCase.php index ebb5d59c..5cb1fdb8 100644 --- a/tests/Resource/ResourceTestCase.php +++ b/tests/Resource/ResourceTestCase.php @@ -61,6 +61,34 @@ abstract class ResourceTestCase extends ApiTestCase return [$user, $doctor, $address]; } + /** + * پزشکِ ناظرِ محیطِ همین آدرس — از وقتی ناظر روی منبع الزامی شد، هر ساختِ منبع + * یکی لازم دارد. برای کلینیک یک پزشک عضو ساخته می‌شود و برای مطب، خودِ پزشک. + */ + protected function supervisorFor(DoctorAddress $address): Doctor + { + if ($address->tenantEntityType() === 'doctor') { + return $this->em->getRepository(Doctor::class)->find($address->tenantEntityId()); + } + + $clinic = $this->em->getRepository(Clinic::class)->find($address->tenantEntityId()); + + foreach ($clinic->getDoctors() as $existing) { + return $existing; + } + + $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'پزشک ناظر'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $clinic->getDoctors()->add($doctor); + $this->em->flush(); + + return $doctor; + } + protected function resourceType(DoctorAddress $address, string $code = 'device', string $name = 'دستگاه'): ResourceType { $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name); @@ -83,9 +111,10 @@ abstract class ResourceTestCase extends ApiTestCase protected function createResource(User $user, DoctorAddress $address, ResourceType $type, array $body = []): array { return $this->authJson('POST', '/api/v1/resource', $user, $body + [ - 'address_uuid' => $address->getUuid(), - 'type_uuid' => $type->getUuid(), - 'name' => 'لیزر آلکساندرایت ۱', + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type->getUuid(), + 'name' => 'لیزر آلکساندرایت ۱', + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), ]); }