diff --git a/.claude/prompt/laser-treatment-plan.md b/.claude/prompt/laser-treatment-plan.md index c68f26cb..56fdf9a7 100644 --- a/.claude/prompt/laser-treatment-plan.md +++ b/.claude/prompt/laser-treatment-plan.md @@ -224,26 +224,49 @@ if ($doctorUuid === '' && $resource->getSupervisor() !== null) { «این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید». پیام فعلی (`doctor_uuid یا resource_uuid ...`) گمراه‌کننده است. -### ۵. آزادسازی کلید اسلات برای نوبت‌های منبع‌دار +### ۵. رزرو منبع مستقل از پزشک -در `Appointment::refreshActiveSlotKey()`: +**این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.** + +آنچه با داده تأیید شد: + +- دو مسیر رزرو داریم و هیچ‌کدام ردیف دیگری نمی‌سازد. + مسیر پنل روی `appointments.resource_id` می‌نشیند، مسیر hold روی `resource_occupancy`. +- `bookAtomically` روی **پزشک** قفل می‌گیرد و `isSlotTaken` تداخل بازه‌ای را فقط روی پزشک + می‌سنجد. منبع در آن کوئری نیست. +- در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶، + کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است. +- برنامه هفتگی پزشک مانع نیست؛ `resolveSlotLocationId` فقط `null` برمی‌گرداند. + +پنج تغییر: + +۱. مسیر پنل هنگام رزرو `ResourceOccupancy` بسازد، همان‌طور که `HoldService` می‌سازد. + منطق مشترک در یک سرویس باشد، در دو جا کپی نشود. + +۲. لغو یا انقضای نوبت، ردیف اشغال را `released` کند. + +۳. در `Appointment::refreshActiveSlotKey()` وقتی منبع هست کلید `null` بماند: ```php $this->activeSlotKey = (!$this->isReserve - && $this->resource === null // ← شرط جدید + && $this->resource === null && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)) ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) : null; ``` -`setResource()` باید `refreshActiveSlotKey()` را صدا بزند، وگرنه نوبتی که اول ساخته -و بعد منبعش ست می‌شود کلیدش باقی می‌ماند. +`setResource()` باید `refreshActiveSlotKey()` را صدا بزند. -**قبل از این تغییر:** همه مسیرهای ساخت نوبت را فهرست کن و مشخص کن کدام‌ها `resource` -ست نمی‌کنند. آن‌ها بعد از این تغییر همچنان با کلید پزشک محافظت می‌شوند — این درست است، -ولی باید مستند شود که کدام‌ها هستند. ADR-0003 روی همین هشدار داده. +۴. `bookAtomically` وقتی نوبت منبع دارد روی پزشک قفل نگیرد و `isSlotTaken` را صدا نزند. + تضمین یکتایی از `uniq_bucket_resource_seat` می‌آید که ظرفیت و `seat` را می‌فهمد. -migration لازم نیست؛ ستون بدون تغییر می‌ماند و فقط منطق پرشدنش عوض می‌شود. +۵. شعبه نوبتِ منبع‌دار از `ClinicResource.getAddress()` بیاید، نه از برنامه پزشک. + +**قبل از شروع:** همه مسیرهای ساخت نوبت را فهرست کن و بنویس کدام‌ها منبع ست نمی‌کنند. +آن‌ها کلید پزشک و قفل پزشک را نگه می‌دارند. این فهرست باید در گزارش بیاید. + +migration برای `active_slot_key` لازم نیست. برای ردیف‌های اشغالِ گذشتهٔ مسیر پنل یک +migration داده‌ای لازم است تا نوبت‌های فعالِ منبع‌دار موجود ردیف اشغال بگیرند. ### ۶. تعریف فیلد روی نوع منبع diff --git a/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md b/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md index 8f9b3741..6a9d8cbc 100644 --- a/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md +++ b/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md @@ -1,19 +1,35 @@ -# Resource-backed appointments are guarded by occupancy, not the doctor slot key +# Resource bookings are guarded by occupancy, not by the doctor -An appointment's `active_slot_key` is `doctor_id:slot_start` under a unique index, which assumes the -doctor is the thing being occupied. Once a doctor supervises several devices that assumption breaks: -the second booking in the same hour on a different device is rejected. Resource occupancy already -guards those bookings at the database level via `uniq_bucket_resource_seat (resource_id, bucket_at, -seat)`, so an appointment that carries a resource leaves `active_slot_key` null and lets occupancy be -the sole authority; only resourceless legacy bookings keep the doctor key. +Booking a resource is independent of the doctor's calendar: a laser device has its own availability +and the doctor attached to it only supervises. The booking code does not reflect that. +`bookAtomically` takes a pessimistic lock on the doctor and rejects any interval that overlaps +another booking of the same doctor, ignoring which resource was chosen, so a clinic whose devices +share one supervising doctor cannot run two of them at once. In the current database every tenant is +in that position — clinic 2's six resources all point at doctor 6, clinic 3's three at doctor 9. + +The fix is to make resource occupancy the guard for resource bookings and stop deriving their +protection from the doctor. Concretely: the panel booking path writes `ResourceOccupancy` rows the +way the hold-based engine already does, cancellation releases them, `active_slot_key` stays null +whenever an appointment carries a resource, and doctor-level locking applies only to bookings with no +resource. The appointment's branch is then taken from the resource's own address rather than from a +matching slot in the doctor's weekly schedule. ## Considered Options -Rekeying on the resource (`r{resource_id}:{slot_start}`) was rejected because it silently defeats -`ClinicResource.capacity`: a room seating three would reject its second patient, and the unique index -knows nothing about seats, buffers, or setup and cleanup time. +Rekeying `active_slot_key` on the resource (`r{resource_id}:{slot_start}`) was rejected because it +silently defeats `ClinicResource.capacity`: a room seating three would reject its second patient, and +a unique index knows nothing about seats, buffers, or setup and cleanup time. + +Teaching `isSlotTaken` about resources was rejected as a half-measure. It leaves two booking paths +storing occupancy in two different places — `appointments.resource_id` for the panel, +`resource_occupancy` for the engine — which `ResourceBookingSlotService::busyIntervals` already has +to union by hand. Every later fix would then have to be written twice. ## Consequences -Any booking path that omits the resource falls back to the doctor key. Those paths have to be found -and made resource-aware, or they end up with weaker protection than they have today. +The panel path gains a database-level guard it never had: today its only resource check is an +application-level `isFree()` call with no constraint behind it, so two concurrent requests can both +pass it. Unifying on occupancy also lets `busyIntervals` stop reading two sources. + +Any booking path that omits the resource keeps the doctor key and the doctor lock. Those paths must +be enumerated when this lands, so none of them silently ends up with weaker protection. diff --git a/docs/api/README.md b/docs/api/README.md index 2072136d..9e3ff125 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -81,6 +81,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [auth.md](auth.md) | Authentication — OTP, Login, JWT | 8 | | [doctor.md](doctor.md) | Doctor profile & addresses | 11 | | [clinic.md](clinic.md) | Clinics | 7 | +| [practice-domain.md](practice-domain.md) | Practice domains — a clinic's field of practice | 3 | | [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 | | [resource.md](resource.md) | Resources, types, skills, pools | 16 | | [resource-calendar.md](resource-calendar.md) | Resource calendars, exceptions, national holidays | 9 | diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 6ff4904e..527db35d 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -193,10 +193,19 @@ Update a clinic. | `uuid` | string (UUID) | Clinic UUID | ### Request Body -Same fields as POST — all optional. +Same fields as POST — all optional — plus: + +| Field | Type | Description | +|-------|------|-------------| +| `practice_domain_uuid` | string (UUID) \| `""` \| `null` | حوزهٔ فعالیت کلینیک. رشتهٔ خالی یا `null` یعنی «پاک کن»؛ نبودنِ کلید یعنی «دست نزن». uuid ناشناس ۴۲۲ می‌گیرد، نه رد شدن بی‌صدا. ← [practice-domain.md](./practice-domain.md) | ### Response `200` -Updated clinic object (same structure as GET). +Updated clinic object (same structure as GET). Carries `practice_domain` — the full domain object, or +`null` when unset: + +```json +{"uuid":"8c7bfd18-9159-11f1-b98b-f28fd8aa5db5","code":"beauty","name":"کلینیک زیبایی","sort_order":0,"active":true} +``` ### Errors | Code | HTTP | Description | @@ -204,6 +213,7 @@ Updated clinic object (same structure as GET). | `ERR_AUTH_001` | 401 | Missing token | | `ERR_FORBIDDEN_001` | 403 | Not the owner | | `ERR_NOT_FOUND_001` | 404 | Clinic not found | +| `ERR_VALIDATION_002` | 422 | `practice_domain_uuid` به هیچ حوزه‌ای اشاره نمی‌کند | --- diff --git a/docs/api/practice-domain.md b/docs/api/practice-domain.md new file mode 100644 index 00000000..fe268b01 --- /dev/null +++ b/docs/api/practice-domain.md @@ -0,0 +1,157 @@ +# Practice Domain API + +> **Prefix:** `/api/v1/practice-domains`, `/api/v1/practice-domain` + +A practice domain is the field a clinic operates in — beauty, dentistry, orthopaedics. It is a +configuration key, not a marketing label: treatment workflows bind to its `code`, so the code is +immutable once created. It is deliberately **not** `Specialty`, which stays a descriptive label for +the public booking site. + +The table is global (registered in `GlobalTables::ENTITIES`); a clinic points at zero or one of its +rows through `clinics.practice_domain_id`, which is nullable. `NULL` means "not configured" and keeps +today's behaviour — it is never an error. + +--- + +## GET `/api/v1/practice-domains` + +List domains, ordered by `sort_order` then `name`. + +**Permission:** `IS_AUTHENTICATED_FULLY`. Callers without `ROLE_ADMIN` see only `active` rows, so a +clinic manager cannot pick a domain the platform has retired. `ROLE_ADMIN` sees inactive ones too, to +be able to switch them back on. + +### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "8c7bfd18-9159-11f1-b98b-f28fd8aa5db5", + "code": "beauty", + "name": "کلینیک زیبایی", + "sort_order": 0, + "active": true + } + ] +} +``` + +**Errors:** +| Code | HTTP | توضیح | +|------|------|-------| +| ERR_AUTH_001 | 401 | بدون توکن | + +--- + +## POST `/api/v1/practice-domains` + +Create a domain. + +**Permission:** `ROLE_ADMIN` (platform admin only). A clinic manager creating its own domain would +produce a domain with no workflow behind it, and the misconfiguration would stay invisible until the +first protocol-driven booking. + +### Request Body (`application/json`) +| Field | Type | Required | توضیح | +|---|---|---|---| +| `code` | string | ✅ | `^[a-z0-9_]{1,40}$`، یکتا در کل جدول، بعد از ساخت تغییرناپذیر | +| `name` | string | ✅ | نام نمایشی فارسی | +| `sort_order` | int | ❌ | پیش‌فرض `0` | + +```json +{ "code": "dental", "name": "دندانپزشکی", "sort_order": 2 } +``` + +### Response `201` +```json +{ + "success": true, + "data": { + "uuid": "af5063de-753f-444a-ba95-05ec2ffe13be", + "code": "dental", + "name": "دندانپزشکی", + "sort_order": 2, + "active": true + } +} +``` + +**Errors:** +| Code | HTTP | توضیح | +|------|------|-------| +| ERR_VALIDATION_001 | 422 | بدنهٔ نامعتبر، یا کد خارج از الگو، یا کد تکراری (field: `code`) | +| ERR_VALIDATION_002 | 422 | `name` خالی است (field: `name`) | +| ERR_FORBIDDEN_001 | 403 | کاربر `ROLE_ADMIN` نیست | +| ERR_AUTH_001 | 401 | بدون توکن | + +Real 422 for a duplicate code: +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"حوزه فعالیتی با این کد از قبل وجود دارد","field":"code"}]} +``` + +--- + +## PATCH `/api/v1/practice-domain/{uuid}` + +Update a domain's display fields. + +**Permission:** `ROLE_ADMIN`. + +**`code` is ignored if sent.** Workflow implementations are resolved by code, so renaming it would +silently detach a live clinic from its workflow. + +### Request Body (`application/json`) +| Field | Type | توضیح | +|---|---|---| +| `name` | string | فقط اگر ناتهی باشد اعمال می‌شود | +| `sort_order` | int | | +| `active` | bool | | + +### Response `200` +```json +{ + "success": true, + "data": { + "uuid": "af5063de-753f-444a-ba95-05ec2ffe13be", + "code": "dental", + "name": "دندان‌پزشکی", + "sort_order": 5, + "active": true + } +} +``` + +**Errors:** +| Code | HTTP | توضیح | +|------|------|-------| +| ERR_VALIDATION_001 | 422 | بدنهٔ درخواست نامعتبر است | +| ERR_VALIDATION_002 | 404 | حوزه فعالیت یافت نشد | +| ERR_FORBIDDEN_001 | 403 | کاربر `ROLE_ADMIN` نیست | + +--- + +## Assigning a domain to a clinic + +There is no dedicated endpoint. The existing `PATCH /api/v1/clinic/{uuid}` accepts one more key — +see [clinic.md](./clinic.md). + +| Body | اثر | +|---|---| +| `"practice_domain_uuid": ""` | حوزه ست می‌شود | +| `"practice_domain_uuid": ""` یا `null` | حوزه پاک می‌شود | +| کلید اصلاً نباشد | حوزهٔ فعلی دست‌نخورده می‌ماند | + +An unknown uuid is rejected rather than ignored, because a silently dropped selection would only +surface at the first protocol-driven booking: + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_002","message":"حوزه فعالیت یافت نشد","field":"practice_domain_uuid"}]} +``` + +`GET /api/v1/clinic/{uuid}` returns the current value under `data.data.practice_domain`, `null` when +unset: + +```json +{"uuid":"8c7bfd18-9159-11f1-b98b-f28fd8aa5db5","code":"beauty","name":"کلینیک زیبایی","sort_order":0,"active":true} +``` diff --git a/migrations/Version20260806121429.php b/migrations/Version20260806121429.php new file mode 100644 index 00000000..24b0c787 --- /dev/null +++ b/migrations/Version20260806121429.php @@ -0,0 +1,55 @@ +addSql(<<<'SQL' + CREATE TABLE practice_domains ( + id INT AUTO_INCREMENT NOT NULL, + uuid VARCHAR(36) NOT NULL, + code VARCHAR(40) NOT NULL, + name VARCHAR(100) NOT NULL, + sort_order SMALLINT DEFAULT 0 NOT NULL, + active TINYINT DEFAULT 1 NOT NULL, + created_at INT NOT NULL, + updated_at INT NOT NULL, + UNIQUE INDEX UNIQ_820CA399D17F50A6 (uuid), + UNIQUE INDEX uq_practice_domains_code (code), + INDEX idx_practice_domains_active (active, sort_order), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 + SQL); + + // NULL یعنی «حوزه‌ای انتخاب نشده» و همان رفتار امروز؛ کلینیک‌های موجود + // عمداً مقدار پیش‌فرض نمی‌گیرند. + $this->addSql('ALTER TABLE clinics ADD practice_domain_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE clinics ADD CONSTRAINT FK_D7053B66613299D9 FOREIGN KEY (practice_domain_id) REFERENCES practice_domains (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_D7053B66613299D9 ON clinics (practice_domain_id)'); + + $this->addSql( + 'INSERT INTO practice_domains (uuid, code, name, sort_order, active, created_at, updated_at) VALUES (UUID(), :code, :name, 0, 1, :now, :now)', + ['code' => 'beauty', 'name' => 'کلینیک زیبایی', 'now' => time()], + ); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE clinics DROP FOREIGN KEY FK_D7053B66613299D9'); + $this->addSql('DROP INDEX IDX_D7053B66613299D9 ON clinics'); + $this->addSql('ALTER TABLE clinics DROP practice_domain_id'); + $this->addSql('DROP TABLE practice_domains'); + } +} diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index d2e15c49..7f872ed1 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -16,8 +16,10 @@ use App\Insurance\Repository\InsuranceRepository; use App\Location\Repository\CityRepository; use App\Location\Repository\ProvinceRepository; use App\Specialty\Repository\SpecialtyRepository; +use App\PracticeDomain\Repository\PracticeDomainRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use App\Shared\Exception\AppException; use App\Shared\Service\FileValidatorService; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; @@ -43,6 +45,7 @@ class ClinicController extends BaseController private readonly CityRepository $cityRepo, private readonly UserRepository $userRepo, private readonly WeeklyScheduleRepository $scheduleRepo, + private readonly PracticeDomainRepository $practiceDomains, private readonly \App\Clinic\Repository\ClinicDoctorPermissionRepository $permRepo, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly FileValidatorService $fileValidator, @@ -532,6 +535,20 @@ class ClinicController extends BaseController if (array_key_exists('latitude', $data)) $clinic->setLatitude((float) $data['latitude']); if (array_key_exists('longitude', $data)) $clinic->setLongitude((float) $data['longitude']); + // حوزهٔ فعالیت: رشتهٔ خالی یا null یعنی «پاک کن»، کلید نبودن یعنی «دست نزن». + // uuid ناشناس بی‌صدا رد نمی‌شود چون انتخابِ نادرست تا اولین نوبتِ پروتکل‌دار + // پیدا نمی‌شد — و آن‌وقت مدیر فکر می‌کرد تنظیمش ذخیره شده. + if (array_key_exists('practice_domain_uuid', $data)) { + $domainUuid = is_string($data['practice_domain_uuid']) ? trim($data['practice_domain_uuid']) : ''; + $domain = $domainUuid === '' ? null : $this->practiceDomains->findByUuid($domainUuid); + + if ($domainUuid !== '' && $domain === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'حوزه فعالیت یافت نشد', 422, 'practice_domain_uuid'); + } + + $clinic->setPracticeDomain($domain); + } + // Location if (!empty($data['state']) && is_array($data['state'])) { $clinic->setProvinceId((int) $data['state'][0]); diff --git a/src/Clinic/Entity/Clinic.php b/src/Clinic/Entity/Clinic.php index 74806eb5..1c6dd536 100644 --- a/src/Clinic/Entity/Clinic.php +++ b/src/Clinic/Entity/Clinic.php @@ -36,6 +36,16 @@ class Clinic #[ORM\Column(type: 'string', length: 255, nullable: true)] private ?string $name = null; + /** + * حوزهٔ فعالیت کلینیک — تعیین می‌کند کدام `TreatmentWorkflow` صدا زده شود. + * + * `null` یعنی تنظیم‌نشده و رفتار پیش‌فرض، نه خطا: کلینیک‌های موجود بدون انتخاب + * حوزه باید دقیقاً مثل امروز کار کنند. + */ + #[ORM\ManyToOne(targetEntity: \App\PracticeDomain\Entity\PracticeDomain::class)] + #[ORM\JoinColumn(name: 'practice_domain_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?\App\PracticeDomain\Entity\PracticeDomain $practiceDomain = null; + #[ORM\Column(type: 'text', nullable: true)] private ?string $info = null; @@ -153,6 +163,7 @@ class Clinic public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } public function getDoctors(): Collection { return $this->doctors; } + public function getPracticeDomain(): ?\App\PracticeDomain\Entity\PracticeDomain { return $this->practiceDomain; } public function hasDoctor(Doctor $doctor): bool { return $this->doctors->contains($doctor); } @@ -184,6 +195,7 @@ class Clinic public function setSocialMedia(?array $v): self { $this->socialMedia = $v; $this->touch(); return $this; } public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; } public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; } + public function setPracticeDomain(?\App\PracticeDomain\Entity\PracticeDomain $v): self { $this->practiceDomain = $v; $this->touch(); return $this; } private function touch(): void { $this->updatedAt = time(); } @@ -231,6 +243,7 @@ class Clinic ], $this->specialties->toArray()), 'doctors' => $this->doctors->count(), 'doctor_list' => null, + 'practice_domain' => $this->practiceDomain?->toArray(), 'city' => $cityData ? [$cityData] : [], 'state' => $provinceData ? [$provinceData] : [], 'location' => $address, diff --git a/src/PracticeDomain/Controller/PracticeDomainController.php b/src/PracticeDomain/Controller/PracticeDomainController.php new file mode 100644 index 00000000..07ac255a --- /dev/null +++ b/src/PracticeDomain/Controller/PracticeDomainController.php @@ -0,0 +1,113 @@ +isGranted('ROLE_ADMIN'); + + return $this->success(array_map( + static fn (PracticeDomain $d): array => $d->toArray(), + $this->domains->findOrdered($includeInactive), + )); + } + + #[Route('/api/v1/practice-domains', name: 'practice_domain_create', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function create(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $code = is_string($data['code'] ?? null) ? trim($data['code']) : ''; + $name = is_string($data['name'] ?? null) ? trim($data['name']) : ''; + + if (preg_match(PracticeDomain::CODE_PATTERN, $code) !== 1) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد فقط حروف کوچک انگلیسی، عدد و زیرخط می‌پذیرد', 422, 'code'); + } + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام حوزه فعالیت الزامی است', 422, 'name'); + } + + if ($this->domains->findByCode($code) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'حوزه فعالیتی با این کد از قبل وجود دارد', 422, 'code'); + } + + $domain = new PracticeDomain($code, $name); + + if (isset($data['sort_order'])) { + $domain->setSortOrder((int) $data['sort_order']); + } + + $this->em->persist($domain); + $this->em->flush(); + + return $this->success($domain->toArray(), 201); + } + + #[Route('/api/v1/practice-domain/{uuid}', name: 'practice_domain_update', methods: ['PATCH'])] + #[IsGranted('ROLE_ADMIN')] + public function update(string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $domain = $this->domains->findByUuid($uuid); + + if ($domain === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'حوزه فعالیت یافت نشد', 404); + } + + // `code` تغییر نمی‌کند: پیاده‌سازی‌های TreatmentWorkflow روی همین کد سوار + // می‌شوند و عوض کردنش workflow را بی‌صدا از کار می‌اندازد. + if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { + $domain->setName(trim($data['name'])); + } + + if (isset($data['sort_order'])) { + $domain->setSortOrder((int) $data['sort_order']); + } + + if (array_key_exists('active', $data)) { + $domain->setActive((bool) $data['active']); + } + + $this->em->flush(); + + return $this->success($domain->toArray()); + } +} diff --git a/src/PracticeDomain/Entity/PracticeDomain.php b/src/PracticeDomain/Entity/PracticeDomain.php new file mode 100644 index 00000000..49d207d6 --- /dev/null +++ b/src/PracticeDomain/Entity/PracticeDomain.php @@ -0,0 +1,91 @@ + 0])] + private int $sortOrder = 0; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $code, string $name) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->code = $code; + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getCode(): string { return $this->code; } + public function getName(): string { return $this->name; } + public function getSortOrder(): int { return $this->sortOrder; } + public function isActive(): bool { return $this->active; } + + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setSortOrder(int $v): self { $this->sortOrder = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + /** + * @param bool|null $hasWorkflow آیا پیاده‌سازی workflow برای این کد ثبت شده؛ null یعنی پرسیده نشده + */ + public function toArray(?bool $hasWorkflow = null): array + { + $data = [ + 'uuid' => $this->uuid, + 'code' => $this->code, + 'name' => $this->name, + 'sort_order' => $this->sortOrder, + 'active' => $this->active, + ]; + + if ($hasWorkflow !== null) { + $data['has_workflow'] = $hasWorkflow; + } + + return $data; + } + + private function touch(): void { $this->updatedAt = time(); } +} diff --git a/src/PracticeDomain/Repository/PracticeDomainRepository.php b/src/PracticeDomain/Repository/PracticeDomainRepository.php new file mode 100644 index 00000000..4826178d --- /dev/null +++ b/src/PracticeDomain/Repository/PracticeDomainRepository.php @@ -0,0 +1,48 @@ + + */ +class PracticeDomainRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PracticeDomain::class); + } + + public function findByUuid(string $uuid): ?PracticeDomain + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function findByCode(string $code): ?PracticeDomain + { + return $this->findOneBy(['code' => $code]); + } + + /** + * @param bool $includeInactive مدیر پلتفرم غیرفعال‌ها را هم می‌بیند تا بتواند دوباره فعالشان کند + * + * @return PracticeDomain[] + */ + public function findOrdered(bool $includeInactive = false): array + { + $qb = $this->createQueryBuilder('d'); + + if (!$includeInactive) { + $qb->where('d.active = true'); + } + + return $qb + ->orderBy('d.sortOrder', 'ASC') + ->addOrderBy('d.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 444a1ad9..c153acfa 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -25,6 +25,7 @@ final class GlobalTables \App\Location\Entity\Province::class => 'تقسیمات کشوری', \App\Location\Entity\City::class => 'تقسیمات کشوری', \App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصص‌ها', + \App\PracticeDomain\Entity\PracticeDomain::class => 'تاکسونومی سراسری حوزهٔ فعالیت؛ محیط آن را انتخاب می‌کند نه مالکش، و کدش لنگرِ پیاده‌سازی‌های TreatmentWorkflow است', \App\DoctorService\Entity\DoctorService::class => 'تاکسونومی سراسری خدمات، وابسته به تخصص نه به محیط', \App\Insurance\Entity\Insurance::class => 'فهرست بیمه‌های کشور', \App\Insurance\Entity\InsuranceCoverageDefault::class => 'پیش‌فرض پوشش بیمه در سطح کشور؛ هر محیط با TenantInsurance بازنویسی‌اش می‌کند', diff --git a/tests/PracticeDomain/PracticeDomainTest.php b/tests/PracticeDomain/PracticeDomainTest.php new file mode 100644 index 00000000..aaf4bed2 --- /dev/null +++ b/tests/PracticeDomain/PracticeDomainTest.php @@ -0,0 +1,168 @@ +setActive($active); + $this->em->persist($domain); + $this->em->flush(); + + return $domain; + } + + public function testAdminCreatesADomainAndOthersCanListIt(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $code = $this->uniqueCode('beauty'); + + $created = $this->authJson('POST', '/api/v1/practice-domains', $admin, [ + 'code' => $code, + 'name' => 'کلینیک زیبایی', + ]); + + self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); + self::assertSame($code, $created['data']['code']); + + $clinicUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $list = $this->authJson('GET', '/api/v1/practice-domains', $clinicUser); + + self::assertSame(200, $this->responseCode()); + self::assertContains($code, array_column($list['data'], 'code')); + } + + public function testDuplicateCodeIsRejected(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $code = $this->uniqueCode('beauty'); + $this->newDomain($code, 'کلینیک زیبایی'); + + $body = $this->authJson('POST', '/api/v1/practice-domains', $admin, [ + 'code' => $code, + 'name' => 'تکراری', + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('code', $body['errors'][0]['field']); + } + + public function testInvalidCodeIsRejected(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + + $body = $this->authJson('POST', '/api/v1/practice-domains', $admin, [ + 'code' => 'Beauty Clinic', + 'name' => 'x', + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('code', $body['errors'][0]['field']); + } + + public function testNonAdminCannotCreateOrUpdate(): void + { + $clinicUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $domain = $this->newDomain($this->uniqueCode('beauty'), 'کلینیک زیبایی'); + + $this->authJson('POST', '/api/v1/practice-domains', $clinicUser, [ + 'code' => $this->uniqueCode('dental'), + 'name' => 'دندانپزشکی', + ]); + self::assertSame(403, $this->responseCode()); + + $this->authJson('PATCH', '/api/v1/practice-domain/' . $domain->getUuid(), $clinicUser, ['name' => 'nope']); + self::assertSame(403, $this->responseCode()); + } + + /** کد لنگرِ TreatmentWorkflow است؛ ویرایشش workflow را بی‌صدا از کار می‌اندازد. */ + public function testCodeCannotBeChanged(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $code = $this->uniqueCode('beauty'); + $domain = $this->newDomain($code, 'کلینیک زیبایی'); + + $body = $this->authJson('PATCH', '/api/v1/practice-domain/' . $domain->getUuid(), $admin, [ + 'code' => 'hijacked', + 'name' => 'نام تازه', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame($code, $body['data']['code']); + self::assertSame('نام تازه', $body['data']['name']); + } + + /** مدیر کلینیک نباید حوزه‌ای را ببیند که پلتفرم بازنشسته‌اش کرده. */ + public function testInactiveDomainIsHiddenFromNonAdmins(): void + { + $liveCode = $this->uniqueCode('beauty'); + $retiredCode = $this->uniqueCode('retired'); + $this->newDomain($liveCode, 'کلینیک زیبایی'); + $this->newDomain($retiredCode, 'بازنشسته', false); + + $clinicUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $codes = array_column($this->authJson('GET', '/api/v1/practice-domains', $clinicUser)['data'], 'code'); + self::assertContains($liveCode, $codes); + self::assertNotContains($retiredCode, $codes); + + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $adminCodes = array_column($this->authJson('GET', '/api/v1/practice-domains', $admin)['data'], 'code'); + self::assertContains($retiredCode, $adminCodes); + } + + public function testClinicPatchAssignsClearsAndRejectsUnknownDomain(): void + { + $domain = $this->newDomain($this->uniqueCode('beauty'), 'کلینیک زیبایی'); + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک آزمون'); + $this->em->persist($clinic); + $this->em->flush(); + + $uuid = $clinic->getUuid(); + $uri = '/api/v1/clinic/' . $uuid; + $domainId = $domain->getId(); + // درخواستِ کرنل روی EntityManager دیگری می‌نویسد، پس identity map محلی کهنه + // می‌ماند و بدون clear همان نمونهٔ قبلی برمی‌گردد نه وضعیت واقعیِ دیتابیس. + $reload = function () use ($uuid): ?Clinic { + $this->em->clear(); + + return $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]); + }; + + $this->authJson('PATCH', $uri, $owner, ['practice_domain_uuid' => $domain->getUuid()]); + self::assertSame(200, $this->responseCode()); + self::assertSame($domainId, $reload()?->getPracticeDomain()?->getId()); + + // کلید نبودن یعنی «دست نزن» + $this->authJson('PATCH', $uri, $owner, ['info' => 'توضیح']); + self::assertSame($domainId, $reload()?->getPracticeDomain()?->getId()); + + // رشتهٔ خالی یعنی «پاک کن» + $this->authJson('PATCH', $uri, $owner, ['practice_domain_uuid' => '']); + self::assertNull($reload()?->getPracticeDomain()); + + // uuid ناشناس بی‌صدا رد نمی‌شود + $body = $this->authJson('PATCH', $uri, $owner, [ + 'practice_domain_uuid' => '00000000-0000-0000-0000-000000000000', + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('practice_domain_uuid', $body['errors'][0]['field']); + } +}