feat: implement cancellation policy, no-show tracking, and waitlist management

- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,140 @@
# معماری — تسک ۰۲
## ساختار فایل
```
src/Resource/
├── Controller/
│ ├── ResourceController.php
│ ├── ResourceTypeController.php
│ ├── SkillController.php
│ └── ResourcePoolController.php
├── Entity/
│ ├── ResourceType.php
│ ├── ClinicResource.php # نامِ کلاس عمداً Resource نیست (تداخل با کلمهٔ رزرو PHP نیست، ولی با Symfony/Doctrine ابهام دارد)
│ ├── Skill.php
│ ├── ResourceSkill.php
│ ├── ResourcePool.php
│ └── ResourcePoolMember.php
├── Repository/…
├── Service/
│ ├── ResourceService.php
│ ├── SkillAssignmentService.php
│ ├── ResourcePoolService.php
│ └── ResourceLinker.php # پل بین Doctor/ClinicStaff/Room و ClinicResource
└── Command/
└── BackfillResourceCommand.php
```
## `ClinicResource`
```php
#[ORM\Entity(repositoryClass: ClinicResourceRepository::class)]
#[ORM\Table(name: 'clinic_resources')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_resources_tenant')]
class ClinicResource
{
use TenantOwnedTrait;
private string $uuid;
private Branch $branch; // منابع همیشه مال شعبه‌اند — فیزیکی‌اند
private ResourceType $type;
private string $name;
private int $capacity = 1; // ظرفیت هم‌زمان
private int $setupMinutes = 0;
private int $cleanupMinutes = 0;
private array $attributes = []; // JSON آزاد: gender, device_model, floor
private bool $active = true;
// ── پل به موجودیت‌های موجود؛ حداکثر یکی غیر-null است ──
private ?Doctor $doctor = null;
private ?ClinicStaff $staff = null;
private ?Room $room = null;
}
```
### چرا پل، نه ادغام
`Doctor` و `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیل آن‌ها به زیرکلاس
`Resource` یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش:
```
Doctor 1 ──0..1 ClinicResource (type=doctor)
ClinicStaff 1 ──0..1 ClinicResource (type=staff)
Room 1 ──0..1 ClinicResource (type=room)
ClinicResource بدون پل = دستگاه/تجهیزات
```
`ResourceLinker` تنها نقطه‌ای است که این نگاشت را می‌داند:
```php
final class ResourceLinker
{
/** منبعِ متناظر با یک پرسنل؛ اگر نبود می‌سازد. */
public function forStaff(ClinicStaff $staff): ClinicResource { }
public function forDoctor(Doctor $doctor, Branch $branch): ClinicResource { }
/** برعکس: منبع → موجودیت اصلی، برای نمایش در UI. */
public function subject(ClinicResource $r): Doctor|ClinicStaff|Room|null { }
}
```
هیچ سرویس دیگری نباید مستقیم `$resource->getStaff()` را برای تصمیم‌گیری بخواند.
## مهارت‌ها — جدول، نه قانون
مستند بند ۶ صریح است: «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک **اطلاعات** است،
نه یک قانون. با ۵۰ اپراتور و ۲۰۰ سرویس، سپردنش به موتور قوانین یعنی ۱۰٬۰۰۰ قانون.
```
skills (uuid, name, tenant)
resource_skills (resource_id, skill_id, level) ← جدول واسط ساده
```
`level` (۱..۵) از روز اول هست چون تسک ۰۶ استراتژی «حفظ متخصص‌ها» را روی همین می‌سازد
و افزودنش بعداً یعنی backfill با حدس.
## استخر منابع
```php
class ResourcePool
{
use TenantOwnedTrait;
private Branch $branch; // استخر درون یک شعبه است — منبعِ شعبهٔ دیگر جایگزین نیست
private ResourceType $type; // اعضا باید هم‌نوع باشند
private string $name;
private Collection $members; // ResourcePoolMember
}
```
قاعدهٔ اعتبار در `ResourcePoolService::replaceMembers()`:
همهٔ اعضا باید `branch` و `type` یکسان با خود استخر داشته باشند، وگرنه `422`.
دلیل: تسک ۰۶ فرض می‌کند «هر عضو استخر جایگزین کامل دیگری است» — اگر یکی در شعبهٔ
دیگری باشد، بیمار در ساختمان اشتباه می‌ایستد.
## اعتبارسنجی `attributes`
JSON آزاد است ولی نه بی‌قید:
```php
// ResourceService::normalizeAttributes()
// - کلید: [a-z_]{1,40}
// - مقدار: string|int|bool|float فقط — نه آرایه، نه object
// - حداکثر ۲۰ کلید
```
دلیل محدودیت اسکالر: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را روی همین
مقادیر با مقایسهٔ ساده می‌سنجند. آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، یعنی همان چیزی که
مستند بند ۸ ممنوع کرده.
کلیدهای شناخته‌شده (قرارداد، نه اجبار): `gender`, `device_model`, `floor`, `brand`.
## پنل ادمین
- `ResourcesPage.tsx``DataTable` با فیلتر شعبه/نوع/فعال، همه در URL (`useUrlState`)
- `ResourceFormPage.tsx``SearchableSelect` برای شعبه و نوع، چیپ برای مهارت‌ها،
`PriceInput` لازم نیست، ولی برای `setup/cleanup` عدد ساده با پسوند «دقیقه»
- `SkillsPage.tsx`, `ResourceTypesPage.tsx`, `ResourcePoolsPage.tsx` — لیست‌های ساده
- همهٔ زیرصفحه‌ها `backTo` یا `<BackButton />` دارند
@@ -0,0 +1,143 @@
# دیتابیس — تسک ۰۲
## `resource_types`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `code` | VARCHAR(40) NOT NULL | `doctor`, `staff`, `room`, `device`, … |
| `name` | VARCHAR(100) NOT NULL | نام نمایشی فارسی |
| `is_system` | TINYINT(1) NOT NULL DEFAULT 0 | نوع‌های `doctor/staff/room` توسط backfill ساخته می‌شوند و حذف نمی‌شوند |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_resource_types_tenant (entity_type, entity_id, active)
UNIQUE KEY uniq_rt_tenant_code (entity_type, entity_id, code)
```
## `clinic_resources`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از `branch` مشتق می‌شود |
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE RESTRICT |
| `resource_type_id` | INT NOT NULL | FK → `resource_types.id` ON DELETE RESTRICT |
| `name` | VARCHAR(150) NOT NULL | |
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت هم‌زمان |
| `setup_minutes` | SMALLINT NOT NULL DEFAULT 0 | آماده‌سازی پیش از بیمار |
| `cleanup_minutes` | SMALLINT NOT NULL DEFAULT 0 | تمیزکاری پس از بیمار |
| `attributes` | JSON NULL | اسکالر فقط، حداکثر ۲۰ کلید |
| `doctor_id` | INT NULL UNIQUE | FK → `doctors.id` ON DELETE CASCADE |
| `staff_id` | INT NULL UNIQUE | FK → `clinic_staff.id` ON DELETE CASCADE |
| `room_id` | INT NULL UNIQUE | FK → `rooms.id` ON DELETE CASCADE |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_resources_tenant (entity_type, entity_id, active)
KEY idx_resources_branch_type (branch_id, resource_type_id, active)
UNIQUE KEY uniq_resource_doctor (doctor_id)
UNIQUE KEY uniq_resource_staff (staff_id)
UNIQUE KEY uniq_resource_room (room_id)
```
سه کلید یکتای تهی‌پذیر تضمین می‌کنند یک پزشک/پرسنل/اتاق بیش از یک منبع نگیرد.
MariaDB چند `NULL` را در UNIQUE می‌پذیرد، پس دستگاه‌های بدون پل مشکلی ندارند.
> **قید سطح اپلیکیشن (نه DB):** حداکثر یکی از `doctor_id`/`staff_id`/`room_id` غیر-NULL.
> در سازندهٔ entity اجبار شود؛ MariaDB `CHECK` چندستونی را قابل اتکا اجرا نمی‌کند.
## `skills` و `resource_skills`
```sql
CREATE TABLE skills (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
name VARCHAR(120) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
KEY idx_skills_tenant (entity_type, entity_id, active)
);
CREATE TABLE resource_skills (
id INT PRIMARY KEY AUTO_INCREMENT,
resource_id INT NOT NULL,
skill_id INT NOT NULL,
level TINYINT NOT NULL DEFAULT 1, -- 1..5
created_at INT NOT NULL,
UNIQUE KEY uniq_resource_skill (resource_id, skill_id),
KEY idx_resource_skills_skill (skill_id, level),
CONSTRAINT fk_rs_resource FOREIGN KEY (resource_id) REFERENCES clinic_resources(id) ON DELETE CASCADE,
CONSTRAINT fk_rs_skill FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE RESTRICT
);
```
`idx_resource_skills_skill (skill_id, level)` عمدی است: پرس‌وجوی داغِ تسک ۰۶
«کدام منابع مهارت X را دارند» از این سمت می‌آید.
`resource_skills` بدون ستون tenant — فرزند aggregate با ریشهٔ `ClinicResource`،
و uuid از request نمی‌گیرد (همیشه از `/resource/{uuid}/skills`).
## `resource_pools` و `resource_pool_members`
```sql
CREATE TABLE resource_pools (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
branch_id INT NOT NULL,
resource_type_id INT NOT NULL,
name VARCHAR(150) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
KEY idx_pools_tenant (entity_type, entity_id, active),
KEY idx_pools_branch (branch_id, resource_type_id),
CONSTRAINT fk_pool_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE,
CONSTRAINT fk_pool_type FOREIGN KEY (resource_type_id) REFERENCES resource_types(id) ON DELETE RESTRICT
);
CREATE TABLE resource_pool_members (
id INT PRIMARY KEY AUTO_INCREMENT,
pool_id INT NOT NULL,
resource_id INT NOT NULL,
priority SMALLINT NOT NULL DEFAULT 0, -- ترتیب ترجیح در استراتژی انتخاب
UNIQUE KEY uniq_pool_resource (pool_id, resource_id),
KEY idx_pool_members_resource (resource_id),
CONSTRAINT fk_pm_pool FOREIGN KEY (pool_id) REFERENCES resource_pools(id) ON DELETE CASCADE,
CONSTRAINT fk_pm_resource FOREIGN KEY (resource_id) REFERENCES clinic_resources(id) ON DELETE CASCADE
);
```
## Migration و backfill
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:resource:backfill # dry-run
ddev exec php bin/console app:resource:backfill --force
```
`app:resource:backfill` برای هر محیط:
1. سه `resource_type` سیستمی می‌سازد (`doctor`, `staff`, `room`) اگر نباشند
2. هر `Room` فعال → یک منبع با `capacity` همان اتاق
3. هر `ClinicStaff` فعال → یک منبع `type=staff` در شعبهٔ اول محیط
4. هر `Doctor` دارای `WeeklySchedule` در آن محیط → یک منبع `type=doctor`
idempotent باشد: اجرای دوباره چیزی دوباره نمی‌سازد.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `resource_types`, `clinic_resources`, `skills`, `resource_pools` | جفت tenant |
| `resource_skills`, `resource_pool_members` | `AGGREGATE_CHILDREN` |
@@ -0,0 +1,111 @@
# نکات پیاده‌سازی — تسک ۰۲
## ۱. ظرفیت: یک ردیف با ظرفیت ۳، نه سه ردیف
مستند بند ۶ صریح است و دلیلش در تسک ۰۶ روشن می‌شود: با سه ردیف، موتور جستجو باید سه
تقویم را ادغام کند و «کدام تخت» بشود یک تصمیم که هیچ‌کس نمی‌خواهد بگیرد. با ظرفیت ۳،
شرط اشغال یک شمارش ساده است:
```
SELECT COUNT(*) FROM resource_occupancy
WHERE resource_id = ? AND [بازه‌ها تداخل دارند] AND status IN (…)
→ اگر < capacity، جا هست
```
این را همین حالا در `docs/api/resource.md` بنویس تا کسی وسوسه نشود «اتاق ۱ تخت ۲» بسازد.
## ۲. `ClinicResource` نه `Resource`
نام کلاس `Resource` در PHP مشکل ندارد ولی در این کدبیس با `App\Shared\…` و مفهوم
«منبع API» قاطی می‌شود و در جستجوی کد نویز شدیدی می‌سازد. نام جدول `clinic_resources`
هم به همان دلیل. متغیرها و متدها می‌توانند `$resource` باشند.
## ۳. پل‌ها و `active`
`ClinicStaff.active = false` باید `ClinicResource.active` را هم false کند، وگرنه پرسنل
غیرفعال همچنان در جستجوی وقت ظاهر می‌شود. این را در `StaffService` (تسک موجود) با یک
فراخوانی به `ResourceLinker::syncActive()` انجام بده — نه با Doctrine lifecycle callback،
چون callback در `getArrayResult()` اجرا نمی‌شود و رفتار نامتقارن می‌سازد.
عکسش برقرار نیست: غیرفعال کردن منبع، پرسنل را غیرفعال نمی‌کند (پرسنل ممکن است فقط
اداری باشد).
## ۴. `setup_minutes` / `cleanup_minutes` چه هستند و چه نیستند
- **جزو نوبت بیمار نیستند** — بیمار ساعت ۱۰:۰۰ می‌آید و ۱۰:۳۰ می‌رود
- **منبع را اشغال می‌کنند** — یونیت از ۹:۵۵ تا ۱۰:۴۰ در دسترس نیست
پس در تسک ۰۷ بازهٔ ثبت‌شده در `resource_occupancy` گسترده‌تر از بازهٔ نوبت است. الان فقط
ستون را بساز و در `docs/api/resource.md` این تفاوت را بنویس؛ محاسبه‌اش کار تسک ۰۶ است.
اشتباه رایج: این را با `WeeklySchedule.meta.buffer_minutes` موجود یکی گرفتن. آن یکی
فاصلهٔ سراسری بین دو نوبتِ **پزشک** است؛ این یکی per منبع است. تا وقتی حالت `resource`
نیامده، هر دو کنار هم زندگی می‌کنند و `buffer_minutes` دست‌نخورده می‌ماند.
## ۵. مهارت را با `jobTitle` قاطی نکن
`ClinicStaff.jobTitle` متن آزاد و برای نمایش است. مهارت یک موجودیت با هویت است که در
شرط نیازمندی (تسک ۰۵) و شرط قانون (تسک ۰۹) استفاده می‌شود. هیچ‌جا `jobTitle` را برای
تصمیم‌گیری parse نکن.
## ۶. edge case ها
| حالت | رفتار درست |
|---|---|
| منبع بدون هیچ پل (دستگاه) | معتبر — حالت عادی تجهیزات |
| دو پل هم‌زمان (`doctor_id` و `staff_id`) | `422` در سازنده |
| حذف `resource_type` که منبع دارد | `422` |
| حذف `resource_type` با `is_system=1` | `422` همیشه |
| غیرفعال کردن منبعی که نوبت آیندهٔ فعال دارد | مجاز، ولی پاسخ شامل `warnings[]` با تعداد نوبت‌ها |
| استخر با صفر عضو | معتبر (در حال ساخت)، ولی تسک ۰۶ آن را «هیچ منبعی» می‌بیند |
| `capacity` روی منبع `type=doctor` بزرگ‌تر از ۱ | `422` — پزشک هم‌زمان دو بیمار ندارد |
| `level` خارج از ۱..۵ | `422` |
| مهارت محیط A روی منبع محیط B | `404` (پیش از هر بررسی: `TenantOwnershipChecker`) |
## ۷. کارایی
پرس‌وجوی داغ تسک ۰۶: «منابع فعالِ شعبهٔ X از نوع Y که مهارت Z را دارند».
```php
// ClinicResourceRepository::findEligible()
// یک کوئری با JOIN به resource_skills، بدون N+1
$qb->select('r')
->from(ClinicResource::class, 'r')
->join('r.skills', 'rs')
->where('r.branch = :branch')
->andWhere('r.type = :type')
->andWhere('r.active = true')
->andWhere('rs.skill IN (:skills)')
->groupBy('r.id')
->having('COUNT(DISTINCT rs.skill) = :skillCount'); // همهٔ مهارت‌ها، نه یکی
```
`HAVING COUNT(DISTINCT …)` عمدی است: نیازمندی «مهارت الف و ب» یعنی هر دو، نه یکی.
## ۸. تست
```
tests/Resource/ResourceCrudTest.php
- ساخت منبع + خواندن → tenant و branch درست
- منبع با شعبهٔ محیط دیگر → 404
- capacity=0 → 422 · capacity=2 روی type=doctor → 422
- دو پل هم‌زمان → 422
tests/Resource/SkillAssignmentTest.php
- PUT skills جایگزینی کامل (حذف نداده‌ها)
- level خارج بازه → 422
- حذف مهارتِ در استفاده → 422
tests/Resource/ResourcePoolTest.php
- عضو از شعبهٔ دیگر → 422
- عضو از نوع دیگر → 422
tests/Resource/ResourceEligibilityTest.php
- findEligible با دو مهارت: منبعی که فقط یکی را دارد برنمی‌گردد
tests/Resource/BackfillResourceTest.php
- idempotent: دو بار اجرا = یک بار
tests/Shared/TenantSchemaCoverageTest.php
tests/Shared/TenantLookupInventoryTest.php ← شمارنده به‌روز شود
```
## ۹. مستندات
`docs/api/resource.md` بساز. در `docs/api/staff.md` یک بخش «رابطه با منبع» اضافه کن.
`docs/architecture/tenancy.md` جدول طبقه‌بندی را به‌روز کن.
@@ -0,0 +1,75 @@
# تسک ۰۲ — مدل منبع: نوع منبع، منبع، مهارت، استخر
**فاز:** ۱ (هسته) · **وابستگی:** ۰۱ · **زمان:** ۱۴-۱۸ ساعت
---
## هدف
قانون طلایی اول مستند: «تقویم مال منبع است، نه مال پزشک». امروز تنها موجودیتی که
می‌تواند اشغال شود پزشک است و پرسنل (`ClinicStaff`) فقط یک برچسب روی سرویس و نوبت است.
این تسک لایهٔ منبع را می‌سازد: هر چیزی که ممکن است اشغال باشد — پزشک، اپراتور، دستیار،
دستگاه، اتاق، تخت، یونیت.
## وضعیت فعلی
```php
// src/Staff/Entity/ClinicStaff.php — تنها «منبع» امروز
private string $fullName;
private ?string $jobTitle; // متن آزاد، بدون معنای ساختاری
private bool $active;
private ?User $user; // برای ورود به پنل
```
بدون تقویم، بدون ظرفیت، بدون مهارت. `ServiceItem.staffMembers` یک ManyToMany به همین است
و `Appointment.staff_id` یک ارجاع تکی — هیچ‌کدام تداخل زمانی را بررسی نمی‌کنند.
## دامنه
**هست:** `ResourceType`، `Resource`، `Skill`، `ResourceSkill`، `ResourcePool`،
`ResourcePoolMember`، ویژگی‌های آزاد (`attributes` JSON)، ظرفیت هم‌زمان،
زمان آماده‌سازی/تمیزکاری per منبع، CRUD پنل، و **پل زدن `ClinicStaff` و `Doctor` و `Room`
به `Resource`**.
**نیست:** تقویم و مرخصی (تسک ۰۳)، نیازمندی منبع per بخش (تسک ۰۵)، محاسبهٔ اشغال (تسک ۰۶/۰۷).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/v1/resource-types` | نوع منبع (کلینیک خودش تعریف می‌کند) |
| PATCH/DELETE | `/api/v1/resource-type/{uuid}` | |
| GET | `/api/v1/resources` | لیست با فیلتر `branch_uuid`, `type_uuid`, `active` |
| POST | `/api/v1/resource` | ساخت منبع |
| GET/PATCH/DELETE | `/api/v1/resource/{uuid}` | |
| GET/POST | `/api/v1/skills` | مهارت‌ها |
| PATCH/DELETE | `/api/v1/skill/{uuid}` | |
| PUT | `/api/v1/resource/{uuid}/skills` | جایگزینی کامل مهارت‌های منبع |
| GET/POST | `/api/v1/resource-pools` | استخر منابع قابل جایگزینی |
| PATCH/DELETE | `/api/v1/resource-pool/{uuid}` | |
| PUT | `/api/v1/resource-pool/{uuid}/members` | جایگزینی کامل اعضا |
## معیار پذیرش
- ✅ موفق: کلینیک نوع منبع «دستگاه لیزر» می‌سازد، سه منبع از آن نوع در شعبهٔ مرکزی ثبت
می‌کند، یک استخر «لیزرهای آلکساندرایت» می‌سازد و هر سه را عضو می‌کند →
`GET /api/v1/resource-pool/{uuid}` هر سه را با `branch_uuid` برمی‌گرداند.
- ✅ موفق: مهارت «لیزر آلکساندرایت» ساخته و به دو اپراتور داده می‌شود →
`GET /api/v1/resources?skill_uuid=…` فقط همان دو را برمی‌گرداند.
- ✅ موفق: بعد از اجرای `app:resource:backfill`، هر `ClinicStaff` فعال یک `Resource` با
`type=staff` و هر `Doctor` دارای برنامه یک `Resource` با `type=doctor` دارد.
- ❌ خطا: منبع با `branch_uuid` متعلق به محیط دیگر → `404`.
- ❌ خطا: عضو کردن منبعی از شعبهٔ A در استخری که منابعش در شعبهٔ B هستند → `422`
«همهٔ اعضای استخر باید در یک شعبه باشند».
- ⚠️ مرزی: `capacity = 3` روی اتاق تزریق → یک ردیف، نه سه. `GET` مقدار ۳ را برمی‌گرداند.
- ⚠️ مرزی: `setup_minutes = 0, cleanup_minutes = 10` → معتبر.
- ⚠️ مرزی: حذف مهارتی که به منبعی داده شده → `422`؛ باید اول از منابع برداشته شود.
- ⚠️ مرزی: `attributes` با کلید ناشناخته → پذیرفته می‌شود (عمداً آزاد)، ولی مقدار غیر
اسکالر → `422`.
## خروجی
- `src/Resource/` کامل با تست
- صفحات پنل: `ResourcesPage`, `ResourceFormPage`, `ResourceTypesPage`, `SkillsPage`, `ResourcePoolsPage`
- `docs/api/resource.md`
- `app:resource:backfill` (dry-run پیش‌فرض)