# دیتابیس — تسک ۰۴ ## تغییر جدول موجود: `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` جفت شوند و بعد عدد به‌روز شود.