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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||||
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<string | null>(null);
|
||||
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
||||
const [supervisorUuid, setSupervisorUuid] = useState<string | null>(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}
|
||||
/>
|
||||
</Field>
|
||||
{/* ناظر برخلاف شعبه و نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک
|
||||
دستگاه عوض میشود، ولی محیطِ منبع نه. */}
|
||||
<Field label="پزشک ناظر">
|
||||
<SearchableSelect
|
||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||
value={supervisorUuid}
|
||||
onChange={(v) => setSupervisorUuid(v ? String(v) : null)}
|
||||
placeholder="پزشک ناظر را انتخاب کنید"
|
||||
isLoading={doctorsLoading}
|
||||
height={38}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
|
||||
@@ -989,6 +989,8 @@ export interface ClinicResource {
|
||||
setup_minutes: number;
|
||||
cleanup_minutes: number;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
/** پزشکِ ناظر — الزامی. جدا از پل: دستگاهِ تحت نظارت، «منبع انسانی» نمیشود. */
|
||||
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;
|
||||
|
||||
@@ -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` و تسک ۰۹ شرطهای منبع را با مقایسهٔ ساده روی
|
||||
همین مقادیر میسنجند؛ آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، همان چیزی که بند ۸ مستند
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* پزشکِ ناظر روی منبع.
|
||||
*
|
||||
* هر منبع باید زیر نظر یک پزشک باشد، ولی ۱۳ منبعِ موجود ناظری ندارند. backfill
|
||||
* قطعی و قابلتوضیح است، نه دلبخواه:
|
||||
* • منبعِ محیطِ پزشک → همان پزشکِ محیط
|
||||
* • منبعِ محیطِ کلینیک → پزشکِ اولِ همان کلینیک (کمترین `doctor_id`)
|
||||
*
|
||||
* ستون تهیپذیر میماند چون کلید خارجی `SET NULL` است: حذف یک پزشک نباید دستگاه
|
||||
* کلینیک را با خودش ببرد. «الزامی بودن» ناظر در لایهٔ API اجرا میشود، جایی که
|
||||
* میشود خطای فارسی و قابلفهم داد.
|
||||
*/
|
||||
final class Version20260803093000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add supervisor doctor to clinic resources and backfill existing rows';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -81,10 +81,17 @@ class ResourceController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid');
|
||||
}
|
||||
|
||||
// هر منبع زیر نظر یک پزشک است — صفحهٔ نوبتها تب منابع را زیر همان پزشک میچیند،
|
||||
// پس منبعِ بیناظر جایی برای دیدهشدن ندارد.
|
||||
if (!is_string($data['supervisor_doctor_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid');
|
||||
}
|
||||
|
||||
$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)->toArray(), 201);
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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), 'منبع یافت نشد');
|
||||
|
||||
@@ -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<string, mixed> $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);
|
||||
|
||||
@@ -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' => 'دستگاه',
|
||||
'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();
|
||||
|
||||
@@ -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);
|
||||
@@ -86,6 +114,7 @@ abstract class ResourceTestCase extends ApiTestCase
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'لیزر آلکساندرایت ۱',
|
||||
'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user