# دیتابیس — تسک ۱۲ ## `course_protocols` | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE | | `session_count` | SMALLINT NOT NULL | | | `min_days` | SMALLINT NOT NULL | | | `ideal_days` | SMALLINT NOT NULL | | | `max_days` | SMALLINT NOT NULL | | | `prefer_same_resource` | TINYINT(1) NOT NULL DEFAULT 1 | | | `active` | TINYINT(1) NOT NULL DEFAULT 1 | | | `created_at`/`updated_at` | INT NOT NULL | | ```sql UNIQUE KEY uniq_protocol_service (service_item_id) -- یک پروتکل فعال per سرویس KEY idx_protocols_tenant (entity_type, entity_id, active) ``` قید اپلیکیشنی: `min_days <= ideal_days <= max_days` و `session_count >= 2` (دورهٔ یک‌جلسه‌ای همان نوبت تکی است). ## `course_protocol_steps` ```sql CREATE TABLE course_protocol_steps ( id INT PRIMARY KEY AUTO_INCREMENT, protocol_id INT NOT NULL, session_number SMALLINT NOT NULL, params JSON NULL, -- {"energy": 12} — اسکالر override_duration_minutes SMALLINT NULL, UNIQUE KEY uniq_step (protocol_id, session_number), CONSTRAINT fk_step_protocol FOREIGN KEY (protocol_id) REFERENCES course_protocols(id) ON DELETE CASCADE ); ``` فرزند aggregate با ریشهٔ `CourseProtocol`. ## `treatment_courses` | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `patient_record_id` | INT NOT NULL | FK ON DELETE RESTRICT | | `service_item_id` | INT NOT NULL | FK ON DELETE RESTRICT | | `protocol_id` | INT NOT NULL | FK ON DELETE RESTRICT | | `session_count` | SMALLINT NOT NULL | **snapshot** | | `min_days` | SMALLINT NOT NULL | **snapshot** | | `ideal_days` | SMALLINT NOT NULL | **snapshot** | | `max_days` | SMALLINT NOT NULL | **snapshot** | | `patient_package_id` | INT NULL | FK → `patient_packages.id` ON DELETE SET NULL | | `preferred_resource_id` | INT NULL | FK → `clinic_resources.id` ON DELETE SET NULL | | `status` | VARCHAR(12) NOT NULL DEFAULT 'active' | `active`\|`completed`\|`abandoned` | | `abandon_reason` | VARCHAR(255) NULL | | | `started_at` | INT NOT NULL | | | `completed_at` | INT NULL | | | `created_at`/`updated_at` | INT NOT NULL | | ```sql KEY idx_courses_tenant (entity_type, entity_id, status, started_at) KEY idx_courses_patient (patient_record_id, status) UNIQUE KEY uniq_active_course (patient_record_id, service_item_id, status) ``` ⚠️ `uniq_active_course` با MariaDB روی مقدار `status` کار نمی‌کند به شکلی که فقط `active` را یکتا کند (چند ردیف `completed` مجازند). راه درست: **قید اپلیکیشنی** در `CourseStarter` + کلید یکتای جزئی که MariaDB ندارد. جایگزین: یک ستون `active_course_key VARCHAR(64) NULL UNIQUE` با همان الگوی `Appointment.active_slot_key`: ```php $this->activeCourseKey = $this->status === self::STATUS_ACTIVE ? sprintf('%d:%d', $this->patient->getId(), $this->service->getId()) : null; ``` الگوی اثبات‌شدهٔ همین کدبیس — استفاده‌اش کن، دوباره اختراع نکن. ## `course_sessions` | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `course_id` | INT NOT NULL | FK ON DELETE CASCADE | | `session_number` | SMALLINT NOT NULL | | | `params` | JSON NULL | **snapshot** از `course_protocol_steps` | | `appointment_id` | INT NULL UNIQUE | FK ON DELETE SET NULL | | `status` | VARCHAR(12) NOT NULL DEFAULT 'planned' | `planned`\|`booked`\|`completed`\|`skipped` | | `completed_at` | INT NULL | | | `created_at`/`updated_at` | INT NOT NULL | | ```sql UNIQUE KEY uniq_course_session (course_id, session_number) UNIQUE KEY uniq_session_appointment (appointment_id) KEY idx_sessions_tenant (entity_type, entity_id, status) KEY idx_sessions_course (course_id, session_number) ``` `uniq_session_appointment`: یک نوبت به بیش از یک جلسهٔ دوره وصل نمی‌شود. ## تغییر `appointments` ```sql ALTER TABLE appointments ADD COLUMN course_session_id INT NULL, ADD CONSTRAINT fk_appointments_course_session FOREIGN KEY (course_session_id) REFERENCES course_sessions(id) ON DELETE SET NULL, ADD KEY idx_appointments_course_session (course_session_id); ``` دو طرفه است (`course_sessions.appointment_id` هم وجود دارد) — عمدی: لیست نوبت‌های پنل باید بدون JOIN بفهمد نوبت جزو دوره است، و صفحهٔ دوره باید بدون JOIN نوبت را پیدا کند. هر دو در `CourseSessionLinker` **هم‌زمان** ست می‌شوند؛ هیچ جای دیگری ننویسد. ## Migration ```bash ddev exec php bin/console doctrine:migrations:diff --no-interaction ddev exec php bin/console doctrine:migrations:migrate --no-interaction ``` بدون backfill. ## طبقه‌بندی tenant | جدول | وضعیت | |---|---| | `course_protocols`, `treatment_courses`, `course_sessions` | جفت tenant | | `course_protocol_steps` | `AGGREGATE_CHILDREN` → ریشه `CourseProtocol` |