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,149 @@
# دیتابیس — تسک ۰۴
## تغییر جدول موجود: `service_items`
```sql
ALTER TABLE service_items
ADD COLUMN service_category_id INT NULL AFTER section_id,
ADD COLUMN session_count SMALLINT NOT NULL DEFAULT 1, -- ۱ = تک‌جلسه، >۱ = دوره‌ای
ADD COLUMN preparation_note TEXT NULL, -- «آماده‌سازی بیمار» — مستند بند ۵
ADD CONSTRAINT fk_service_items_category
FOREIGN KEY (service_category_id) REFERENCES service_categories(id) ON DELETE SET NULL,
ADD KEY idx_service_items_category (service_category_id);
```
`duration_minutes` موجود **حذف نمی‌شود** و همچنان «مدت پایهٔ سرویس» است. آیتم‌ها روی آن
اضافه می‌کنند. در حالت `booking_mode=service` فعلی هیچ تغییری در رفتار نیست چون هیچ
سرویسی گروه ندارد و `totalMinutes` برابر همان `duration_minutes` می‌ماند.
## `service_categories` — درختی
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `parent_id` | INT NULL | FK → خودش، ON DELETE RESTRICT |
| `name` | VARCHAR(150) NOT NULL | |
| `path` | VARCHAR(255) NOT NULL | materialized path: `/1/7/23/` |
| `depth` | TINYINT NOT NULL DEFAULT 0 | |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_svc_cat_tenant (entity_type, entity_id, active)
KEY idx_svc_cat_parent (parent_id, sort_order)
KEY idx_svc_cat_path (path)
```
**materialized path** به‌جای adjacency خالص: تسک ۰۹ شرط «سرویس در دستهٔ جراحی یا
زیردسته‌هایش» را می‌خواهد و با `path LIKE '/1/7/%'` یک کوئری است، نه یک پیمایش بازگشتی.
سقف عمق: ۴. در `ServiceCategoryService` اجبار شود.
## `item_groups`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | از سرویس مشتق می‌شود |
| `service_item_id` | INT NOT NULL | FK → `service_items.id` ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | «نواحی موردنظر» |
| `min_select` | SMALLINT NOT NULL DEFAULT 0 | ۰ = اختیاری |
| `max_select` | SMALLINT NULL | NULL = نامحدود |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_item_groups_tenant (entity_type, entity_id, active)
KEY idx_item_groups_service (service_item_id, sort_order)
```
## `service_options` — «آیتم» مستند
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | از request می‌آید |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `item_group_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `name` | VARCHAR(150) NOT NULL | |
| `solo_minutes` | SMALLINT NOT NULL | «زمان تنها» |
| `additional_minutes` | SMALLINT NULL | «زمان اضافه»؛ NULL → `solo_minutes` |
| `price_rials` | INT NOT NULL DEFAULT 0 | |
| `sort_order` | SMALLINT NOT NULL DEFAULT 0 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at`/`updated_at` | INT NOT NULL | |
```sql
KEY idx_service_options_tenant (entity_type, entity_id, active)
KEY idx_service_options_group (item_group_id, sort_order)
```
قید اپلیکیشنی: `additional_minutes <= solo_minutes` (زمان اضافه هرگز بیشتر از زمان تنها
نیست — آماده‌سازی که دو بار نمی‌شود). نقض → `422` با پیام روشن.
## `service_option_relations`
```sql
CREATE TABLE service_option_relations (
id INT PRIMARY KEY AUTO_INCREMENT,
source_option_id INT NOT NULL,
target_option_id INT NOT NULL,
type VARCHAR(20) NOT NULL, -- incompatible | requires
UNIQUE KEY uniq_sor (source_option_id, target_option_id, type),
KEY idx_sor_target (target_option_id, type),
CONSTRAINT fk_sor_source FOREIGN KEY (source_option_id) REFERENCES service_options(id) ON DELETE CASCADE,
CONSTRAINT fk_sor_target FOREIGN KEY (target_option_id) REFERENCES service_options(id) ON DELETE CASCADE
);
```
فرزند aggregate با ریشهٔ `ServiceOption`. قید اپلیکیشنی: `source != target`.
## `service_branch_overrides`
```sql
CREATE TABLE service_branch_overrides (
id INT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
service_item_id INT NOT NULL,
branch_id INT NOT NULL,
price_rials INT NULL, -- NULL = ارث از سرویس
duration_minutes SMALLINT NULL, -- NULL = ارث از سرویس
bookable TINYINT(1) NULL, -- NULL = ارث؛ 0 = این شعبه ارائه نمی‌دهد
created_at INT NOT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_sbo (service_item_id, branch_id),
KEY idx_sbo_tenant (entity_type, entity_id),
KEY idx_sbo_branch (branch_id),
CONSTRAINT fk_sbo_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE CASCADE,
CONSTRAINT fk_sbo_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE
);
```
سه ستون تهی‌پذیرند تا override جزئی ممکن باشد: فقط قیمت، بدون دست زدن به مدت.
## Migration و backfill
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
backfill لازم نیست: سرویس‌های موجود گروه ندارند، `DurationCalculator` مقدار
`service_items.duration_minutes` را برمی‌گرداند و رفتار حالت `service` بدون تغییر می‌ماند.
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `service_categories`, `item_groups`, `service_options`, `service_branch_overrides` | جفت tenant |
| `service_option_relations` | `AGGREGATE_CHILDREN` → ریشه `ServiceOption` |
`TenantLookupInventoryTest` شمارنده دارد؛ `findByUuid` های جدید (`ServiceOptionRepository`,
`ItemGroupRepository`) باید با `TenantOwnershipChecker` جفت شوند و بعد عدد به‌روز شود.