# دیتابیس — تسک ۰۸ ## `price_lists` | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `branch_id` | INT NULL | NULL = همهٔ شعب محیط | | `name` | VARCHAR(150) NOT NULL | «نیمهٔ دوم ۱۴۰۵» | | `valid_from` | INT NOT NULL | نیمه‌شب روز شروع | | `valid_to` | INT NULL | NULL = بی‌پایان | | `status` | VARCHAR(10) NOT NULL DEFAULT 'draft' | `draft`\|`active`\|`archived` | | `created_at`/`updated_at` | INT NOT NULL | | ```sql KEY idx_price_lists_tenant (entity_type, entity_id, status, valid_from) KEY idx_price_lists_branch (branch_id, status, valid_from) ``` تداخل بازه در سطح اپلیکیشن بررسی می‌شود (`activate`)، نه DB — MariaDB محدودیت بازه‌ای ندارد و راه سطل زمانی تسک ۰۷ اینجا بی‌مورد است چون تعداد لیست‌ها کم و تغییرشان نادر است. ## `price_list_items` ```sql CREATE TABLE price_list_items ( id INT PRIMARY KEY AUTO_INCREMENT, price_list_id INT NOT NULL, service_item_id INT NULL, -- قیمت سرویس service_option_id INT NULL, -- قیمت آیتم price_rials INT NOT NULL, UNIQUE KEY uniq_pli_service (price_list_id, service_item_id), UNIQUE KEY uniq_pli_option (price_list_id, service_option_id), KEY idx_pli_list (price_list_id), CONSTRAINT fk_pli_list FOREIGN KEY (price_list_id) REFERENCES price_lists(id) ON DELETE CASCADE, CONSTRAINT fk_pli_service FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE CASCADE, CONSTRAINT fk_pli_option FOREIGN KEY (service_option_id) REFERENCES service_options(id) ON DELETE CASCADE ); ``` دقیقاً یکی از `service_item_id` / `service_option_id` غیر-NULL (قید اپلیکیشنی). فرزند aggregate با ریشهٔ `PriceList`. ## `price_snapshots` | ستون | نوع | توضیح | |---|---|---| | `id` | INT PK AI | | | `uuid` | VARCHAR(36) UNIQUE | | | `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | | | `appointment_id` | INT NOT NULL UNIQUE | FK ON DELETE CASCADE — یک snapshot per نوبت | | `base_rials` | INT NOT NULL | | | `options_rials` | INT NOT NULL DEFAULT 0 | | | `discount_rials` | INT NOT NULL DEFAULT 0 | | | `insurance_base_rials` | INT NOT NULL DEFAULT 0 | | | `insurance_supplementary_rials` | INT NOT NULL DEFAULT 0 | | | `tax_rials` | INT NOT NULL DEFAULT 0 | | | `final_rials` | INT NOT NULL | | | `deposit_rials` | INT NOT NULL DEFAULT 0 | | | `applied_policy_ids` | JSON NULL | `[{id, version}]` | | `price_list_id` | INT NULL | FK SET NULL — کدام لیست مبنا بود | | `created_at` | INT NOT NULL | | ```sql UNIQUE KEY uniq_snapshot_appointment (appointment_id) KEY idx_snapshot_tenant (entity_type, entity_id, created_at) ``` `UNIQUE` روی `appointment_id`: یک نوبت یک فاکتور رزرو دارد. `reschedule` نوبت **جدید** می‌سازد (تسک ۰۷) پس snapshot جدید هم می‌گیرد و قدیمی سالم می‌ماند. ⚠️ همهٔ مبالغ `INT` ریال. `DECIMAL` یا `FLOAT` ننویس — بقیهٔ پروژه (`price_rials`, `visit_price_rials`, `deposit_amount_rials`) همه `INT` ریال‌اند و قاطی کردن دو نوع یعنی خطای گردکردن در جمع فاکتور. ## `price_snapshot_lines` ```sql CREATE TABLE price_snapshot_lines ( id INT PRIMARY KEY AUTO_INCREMENT, snapshot_id INT NOT NULL, kind VARCHAR(15) NOT NULL, -- base|option|discount|insurance|tax|package label VARCHAR(200) NOT NULL, -- snapshot متنی — نام لحظهٔ ثبت amount_rials INT NOT NULL, -- منفی برای تخفیف و سهم بیمه source_type VARCHAR(20) NULL, -- service|option|discount_rule|policy|insurance source_id INT NULL, -- بدون FK — منبع ممکن است حذف شود sort_order SMALLINT NOT NULL DEFAULT 0, KEY idx_psl_snapshot (snapshot_id, sort_order), CONSTRAINT fk_psl_snapshot FOREIGN KEY (snapshot_id) REFERENCES price_snapshots(id) ON DELETE CASCADE ); ``` `source_id` **بدون FK** عمدی: قانون تخفیف ممکن است فردا حذف شود ولی فاکتور دیروز باید همان‌طور بماند. `label` هم به همین دلیل کپی متنی است، نه JOIN. ## `deposit_policies` ```sql CREATE TABLE deposit_policies ( 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 NULL, -- NULL = پیش‌فرض محیط mode VARCHAR(10) NOT NULL, -- none|fixed|percent value INT NOT NULL DEFAULT 0, min_rials INT NULL, max_rials INT NULL, active TINYINT(1) NOT NULL DEFAULT 1, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE KEY uniq_deposit_scope (entity_type, entity_id, service_item_id), KEY idx_deposit_tenant (entity_type, entity_id, active) ); ``` ## تغییر جدول موجود هیچ. `appointments.visit_price_rials`, `deposit_required`, `deposit_amount_rials`, `insurance_base_id`, `insurance_supplementary_id` همه استفاده می‌شوند و کافی‌اند. `Tariff` هم دست‌نخورده می‌ماند و در زنجیرهٔ `PriceResolver` سطر ۴ است. ## Migration ```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:pricing:backfill-snapshots --force ``` `app:pricing:backfill-snapshots` برای نوبت‌های `confirmed` آینده که snapshot ندارند، یکی از `visit_price_rials` موجود می‌سازد (یک ردیف `base`). بدون آن، صفحهٔ فاکتور برای نوبت‌های موجود خالی است. ## طبقه‌بندی tenant | جدول | وضعیت | |---|---| | `price_lists`, `price_snapshots`, `deposit_policies` | جفت tenant | | `price_list_items`, `price_snapshot_lines` | `AGGREGATE_CHILDREN` |