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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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` به هیچ حوزهای اشاره نمیکند |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}
|
||||
```
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 بازنویسیاش میکند',
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user