feat(practice-domain): add practice domains and let a clinic select one

A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.

Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.

Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:06:29 +03:30
co-authored by Claude Opus 5
parent 1d43475724
commit 85985b04a0
13 changed files with 736 additions and 23 deletions
+32 -9
View File
@@ -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 داده‌ای لازم است تا نوبت‌های فعالِ منبع‌دار موجود ردیف اشغال بگیرند.
### ۶. تعریف فیلد روی نوع منبع
@@ -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.
+1
View File
@@ -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 |
+12 -2
View File
@@ -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` به هیچ حوزه‌ای اشاره نمی‌کند |
---
+157
View File
@@ -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": "<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}
```
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260806121429 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add practice_domains and let a clinic point at one';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -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]);
+13
View File
@@ -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,
@@ -0,0 +1,113 @@
<?php
namespace App\PracticeDomain\Controller;
use App\PracticeDomain\Entity\PracticeDomain;
use App\PracticeDomain\Repository\PracticeDomainRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'PracticeDomain')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PracticeDomainController extends BaseController
{
public function __construct(
private readonly PracticeDomainRepository $domains,
private readonly EntityManagerInterface $em,
) {}
/**
* فهرست حوزه‌ها برای انتخاب در تنظیمات کلینیک.
*
* غیرفعال‌ها فقط برای ادمین پلتفرم برمی‌گردند؛ مدیر کلینیک نباید حوزه‌ای را
* انتخاب کند که پلتفرم بازنشسته‌اش کرده.
*/
#[Route('/api/v1/practice-domains', name: 'practice_domain_list', methods: ['GET'])]
public function list(): JsonResponse
{
$includeInactive = $this->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());
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\PracticeDomain\Entity;
use App\PracticeDomain\Repository\PracticeDomainRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* حوزهٔ فعالیت یک محیط درمانی — زیبایی، دندانپزشکی، ارتوپدی.
*
* برخلاف {@see \App\Specialty\Entity\Specialty} که برچسبی توصیفی برای سایت عمومی
* است، این یک کلید پیکربندی است: `TreatmentWorkflow` روی `code` سوار می‌شود، پس کد
* بعد از ساخت تغییر نمی‌کند — تغییرش یعنی گم شدن بی‌صدای workflow.
*/
#[ORM\Entity(repositoryClass: PracticeDomainRepository::class)]
#[ORM\Table(name: 'practice_domains')]
#[ORM\UniqueConstraint(name: 'uq_practice_domains_code', columns: ['code'])]
#[ORM\Index(columns: ['active', 'sort_order'], name: 'idx_practice_domains_active')]
class PracticeDomain
{
public const CODE_PATTERN = '/^[a-z0-9_]{1,40}$/';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 40)]
private string $code;
#[ORM\Column(type: 'string', length: 100)]
private string $name;
#[ORM\Column(name: 'sort_order', type: 'smallint', options: ['default' => 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(); }
}
@@ -0,0 +1,48 @@
<?php
namespace App\PracticeDomain\Repository;
use App\PracticeDomain\Entity\PracticeDomain;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PracticeDomain>
*/
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();
}
}
+1
View File
@@ -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 بازنویسی‌اش می‌کند',
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace App\Tests\PracticeDomain;
use App\Clinic\Entity\Clinic;
use App\PracticeDomain\Entity\PracticeDomain;
use App\Tests\ApiTestCase;
/**
* `practice_domains` سراسری است و `code` در کل جدول یکتاست، و db_test هم هرگز ریست
* نمی‌شود — پس هر تست کد خودش را می‌سازد و ادعاهایش «عضویت» را می‌سنجند نه برابریِ
* کل فهرست.
*/
class PracticeDomainTest extends ApiTestCase
{
private function uniqueCode(string $prefix): string
{
return $prefix . '_' . bin2hex(random_bytes(4));
}
private function newDomain(string $code, string $name, bool $active = true): PracticeDomain
{
$domain = new PracticeDomain($code, $name);
$domain->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']);
}
}