# دیتابیس — تسک ۱۱ ## `packages` — تعریف | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `name` | VARCHAR(200) NOT NULL | «۶ جلسه لیزر فول‌بادی» | | `session_count` | SMALLINT NOT NULL | تعداد جلسه | | `price_rials` | BIGINT NOT NULL | **BIGINT** — پکیج بزرگ از سقف INT عبور می‌کند | | `validity_days` | SMALLINT NULL | اعتبار از تاریخ خرید؛ NULL = بی‌پایان | | `active` | TINYINT(1) NOT NULL DEFAULT 1 | | | `created_at`/`updated_at` | INT NOT NULL | | ```sql KEY idx_packages_tenant (entity_type, entity_id, active) ``` ## `package_services` ```sql CREATE TABLE package_services ( id INT PRIMARY KEY AUTO_INCREMENT, package_id INT NOT NULL, service_item_id INT NOT NULL, UNIQUE KEY uniq_pkg_service (package_id, service_item_id), CONSTRAINT fk_pkgs_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE, CONSTRAINT fk_pkgs_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE RESTRICT ); ``` `ON DELETE RESTRICT` روی سرویس: حذف سرویسی که در پکیج فروخته‌شده هست، اعتبار بیماران را بی‌معنا می‌کند. قید اپلیکیشنی: پکیج باید حداقل یک سرویس داشته باشد → `422` هنگام ساخت. ## `patient_packages` — نمونهٔ خریداری‌شده | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `package_id` | INT NOT NULL | FK ON DELETE RESTRICT | | `patient_record_id` | INT NOT NULL | FK → `patient_records.id` ON DELETE RESTRICT | | `session_count` | SMALLINT NOT NULL | snapshot تعداد لحظهٔ خرید | | `price_paid_rials` | BIGINT NOT NULL | snapshot قیمت پرداختی | | `payment_id` | INT NULL | FK → `payments.id` ON DELETE SET NULL | | `purchased_at` | INT NOT NULL | مبنای FIFO | | `valid_to` | INT NULL | محاسبه‌شده از `validity_days` لحظهٔ خرید | | `created_at`/`updated_at` | INT NOT NULL | | ```sql KEY idx_pp_tenant (entity_type, entity_id, purchased_at) KEY idx_pp_patient (patient_record_id, valid_to) ``` > ⛔ **هیچ ستون `remaining_sessions` یا `used_count` نیست و نباید باشد.** > `session_count` فقط snapshot تعریف است، نه مانده. `session_count` و `price_paid_rials` کپی می‌شوند (قانون پنجم مستند): تغییر تعریف پکیج فردا، پکیج فروخته‌شدهٔ دیروز را عوض نمی‌کند. ## `session_credit_ledger` — دفتر | ستون | نوع | توضیح | |---|---|---| | `id` | BIGINT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `patient_package_id` | INT NOT NULL | FK ON DELETE RESTRICT | | `kind` | VARCHAR(15) NOT NULL | `purchase`\|`consume`\|`refund`\|`adjustment`\|`expiry` | | `delta` | SMALLINT NOT NULL | مثبت یا منفی — هرگز صفر | | `appointment_id` | INT NULL | FK ON DELETE SET NULL | | `service_item_id` | INT NULL | FK ON DELETE SET NULL — کدام سرویس مصرف کرد | | `reason` | VARCHAR(255) NULL | اجباری برای `adjustment` | | `created_by` | INT NULL | FK → `users.id` ON DELETE SET NULL | | `created_at` | INT NOT NULL | | ```sql KEY idx_scl_package (patient_package_id, created_at) KEY idx_scl_tenant (entity_type, entity_id, created_at) KEY idx_scl_appt (appointment_id) UNIQUE KEY uniq_scl_consume (appointment_id, kind) -- ← جلوگیری از مصرف دوباره ``` `uniq_scl_consume` مهم است: `confirm` تسک ۰۷ idempotent است و اگر دوبار اجرا شود، دو ردیف `consume` نباید ثبت شود. `NULL` های `appointment_id` در UNIQUE مشکلی ندارند (چند `purchase` بدون نوبت مجازند). **ردیف‌ها هرگز حذف یا ویرایش نمی‌شوند.** append-only. اصلاح = ردیف جدید. ## هیچ تغییری در جدول‌های دیگر `price_snapshot_lines.kind` از قبل مقدار `package` را دارد (تسک ۰۸). ## Migration ```bash ddev exec php bin/console doctrine:migrations:diff --no-interaction ddev exec php bin/console doctrine:migrations:migrate --no-interaction ``` بدون backfill — هیچ پکیجی از قبل وجود ندارد. ## تست schema ```php // tests/Package/LedgerSchemaTest.php public function testNoStoredBalanceColumnExists(): void { $columns = $this->schemaManager->listTableColumns('patient_packages'); foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) { self::assertArrayNotHasKey($forbidden, $columns, 'مانده باید از دفتر محاسبه شود، نه ذخیره'); } } ``` تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی» می‌ایستد. ## طبقه‌بندی tenant | جدول | وضعیت | |---|---| | `packages`, `patient_packages`, `session_credit_ledger` | جفت tenant | | `package_services` | `AGGREGATE_CHILDREN` → ریشه `Package` | ⚠️ برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، دفتر اعتبار جفت tenant واقعی می‌گیرد: اعتبار جلسهٔ کلینیک الف در کلینیک ب معنا ندارد. دلیلش را در `docs/architecture/tenancy.md` کنار توضیح کیف پول اضافه کن.