# معماری ماژول Automation / Workflow Engine

> وضعیت: **پیشنهاد برای تأیید — هنوز هیچ کدی نوشته نشده.**
> این سند بر اساس کد فعلی پروژه نوشته شده (`SMSService`، `SMSProviderResolver`، `MelipayamakRepository`، `AuditLogger`، `WorkspaceContext`، ماژول تارگت‌ها، `docs/deployment.md`) و هر جا چیزی از پروژه فعلی تغییر می‌کند صریحاً ذکر شده است.

---

## 0. تصمیم‌هایی که برای شروع نیاز به تأیید دارند

هر کدام یک پیش‌فرض پیشنهادی دارد؛ اگر مخالفی، همان را بگو.

| # | موضوع | پیشنهاد من | چرا |
|---|-------|-----------|-----|
| D1 | Execution به چه چیزی وصل شود؟ | `subject_type` + `subject_id` (polymorphic، پیش‌فرض `Target`) به‌جای `target_id` ثابت | موتور نباید به Target قفل شود؛ فردا Customer / Deal هم می‌تواند subject باشد. برای Target همه‌چیز همان است که خواستی. |
| D2 | ذخیرهٔ زمان‌ها | در جدول‌های اتوماسیون **UTC**، با یک Cast و یک `AutomationClock` مشترک؛ محاسبهٔ بازهٔ مجاز با تایم‌زون Workspace | بند «DateTimeها استاندارد ذخیره شوند». بقیهٔ پروژه با تایم‌زون برنامه (Tehran) ذخیره می‌کند، پس هیچ query خامی روی این جدول‌ها نباید بدون Helper نوشته شود. |
| D3 | Worker در هاست | باید مشخص شود روی هاست production یک Worker دائمی (Supervisor) هست یا فقط cron داریم | اگر فقط cron است، Worker با `queue:work --stop-when-empty --max-time=55` هر دقیقه اجرا می‌شود. معماری هر دو را پشتیبانی می‌کند اما ظرفیت و تأخیر فرق می‌کند (بخش ۶). |
| D4 | Workflow Builder در نسخهٔ ۱ | ویرایشگر **مرحله‌ای عمودی** (لیست Step با شاخه برای Condition)، که همان Node/Edge را می‌خواند و می‌نویسد. Canvas بصری (drag & drop) در فاز بعد، بدون تغییر Backend | Canvas در RTL روی Livewire پرریسک‌ترین بخش UI است و روی موبایل هم کارایی ندارد. سناریوی اصلی تو خطی است. اگر Canvas را برای نسخهٔ ۱ اجباری می‌دانی بگو. |
| D5 | نتیجهٔ نامعلوم ارسال (Worker بعد از تماس با Provider مُرد) | **هرگز خودکار دوباره ارسال نشود.** Attempt با وضعیت `unknown` بسته شود، Execution ادامه دهد و در داشبورد شمرده شود | پیامک تکراری برای مشتری بدتر از یک پیامک از دست‌رفته در یک روند ۳۰ روزه است. قابل تنظیم: `continue` (پیش‌فرض) یا `hold`. |
| D6 | Delivery Report ملی‌پیامک | در فاز آخر با اعتبارنامهٔ واقعی تأیید شود؛ معماری هم Webhook و هم Polling را پشتیبانی می‌کند | من از روی کد فعلی فقط می‌دانم `RetStatus > 0` یعنی پذیرفته شد؛ ساختار دقیق شناسهٔ پیام و گزارش تحویل را بدون تست واقعی ادعا نمی‌کنم. |
| D7 | `first_name` / `last_name` | به `targets` دو ستون اختیاری اضافه شود و در ورود اکسل هم ستون‌های اختیاری «نام خانوادگی» پشتیبانی شود. اگر خالی بود `{target.first_name}` = کل `name` (هرگز حدس/تقسیم نمی‌کنیم) | امروز فقط `name` داریم و تقسیم نام فارسی («محمد علی رضایی») قابل اعتماد نیست. |
| D8 | «جناب آقای {last_name}» در مثال | موتور جنسیت نمی‌داند؛ قبلاً هم «آقای» را از پیامک‌ها حذف کردیم. قالب‌های نمونه بدون خطاب جنسیتی نوشته می‌شوند | فقط یادآوری؛ متن هر Step دست خودت است. |

---

## 1. Architecture پیشنهادی

### اصول

1. **موتور Generic است، SMS فقط یک Action است.** هیچ کلاس هستهٔ موتور (`Engine/*`) نام SMS، Blacklist یا Daily Limit را نمی‌داند. این‌ها از طریق Interface ثبت می‌شوند.
2. **Workflow = داده.** گراف (Node/Edge) در دیتابیس ذخیره می‌شود؛ UI فقط یک ویرایشگر روی آن است.
3. **Versioning غیرقابل تغییر (immutable).** Version منتشرشده هرگز تغییر نمی‌کند؛ Executionها به Version خودشان pin هستند.
4. **Execution مستقل برای هر Subject.** هر Target چرخهٔ زمانی خودش را دارد (سناریوی بند ۲۵).
5. **Scheduler فقط Claim و Dispatch می‌کند**، Worker کار می‌کند، Provider فقط از Worker صدا زده می‌شود.
6. **تاریخچه هرگز حذف نمی‌شود** (Executionها، Eventها، Logها append-only؛ حذف = تغییر وضعیت).

### لایه‌ها

```
┌──────────────────────────── UI (Livewire + Alpine) ────────────────────────────┐
│ Workflow List/Builder · Campaigns · Blacklist · Templates · Logs · Timeline     │
└───────────────┬────────────────────────────────────────────────────────────────┘
                │ Actions (app/Actions/Automation/*) — مجوز، اعتبارسنجی، Audit، Transaction
┌───────────────▼────────────────────────────────────────────────────────────────┐
│ Engine (app/Automation/Engine)  — مستقل از Livewire/HTTP/SMS                    │
│   GraphValidator · VersionPublisher · ExecutionStateMachine · ExecutionRunner   │
│   NodeHandlers: Start | Action | Wait | Condition | End                         │
│   Claimer (Scheduler side) · Reaper                                             │
├───────────────┬──────────────┬──────────────┬──────────────┬───────────────────┤
│ Registries    │ Guards       │ Variables    │ Conditions   │ Retry             │
│ ActionRegistry│ Blacklist    │ Registry +   │ Engine +     │ RetryStrategy     │
│ TriggerReg.   │ SendWindow   │ Renderer     │ Operators    │ (Fixed → Exp.)    │
│               │ DailyLimit   │              │              │                   │
├───────────────┴──────────────┴──────────────┴──────────────┴───────────────────┤
│ Actions (plug-ins):  SendSmsAction  (بعداً: Email، Task، Webhook، WhatsApp …)   │
│ Triggers (plug-ins): ManualTrigger  (بعداً: TargetCreated، StatusChanged، …)    │
└───────────────┬────────────────────────────────────────────────────────────────┘
                │ SmsGateway (آداپتور روی SMSService/SMSRepositoryInterface موجود)
                ▼
        MelipayamakRepository / سایر Providerها  (تنظیمات از workspaces.settings.sms)
```

### چیزهایی که از پروژهٔ فعلی **بازاستفاده** می‌شود

| موجود | استفاده |
|-------|---------|
| `SMSProviderResolver` / `MelipayamakRepository` | Provider اصلی و fallback از تنظیمات Workspace؛ Step هیچ Providerی را Hard-code نمی‌کند |
| `SmsSettings` (صفحهٔ تنظیمات پیامک) | صفحهٔ ۱۳ همین صفحه است، با چند خط ارسال و نشانی Webhook |
| `audit_logs` + `AuditLogger` + `AuditEventLabel` | بند ۲۲ — جدول جدید ساخته نمی‌شود (old/new/user/entity را دارد) |
| `WorkspaceContext`, `BelongsToWorkspace`, `EstablishesWorkspaceContext` | ایزولاسیون Workspace، از جمله داخل Job ها |
| `TargetVisibility`, فیلترهای `Targets\Index` | انتخاب Target برای Campaign؛ فیلترها به یک کلاس مشترک `TargetFilters` استخراج می‌شوند تا کد کپی نشود |
| `PersianTextNormalizer`, `PhoneNumber` | نرمال‌سازی متن و شماره |
| `x-modal`, `x-date-input`, `@ldatetime`, Toast | UI (bottom-sheet روی موبایل) |
| Scheduler موجود (`routes/console.php`) | یک خط `automation:tick` هر دقیقه اضافه می‌شود |

### تغییرات لازم روی کد **موجود** (کوچک و سازگار با عقب)

1. `SMSMessage`: فیلدهای اختیاری `sender` و `idempotencyKey`.
2. `SMSResult`: فیلدهای اختیاری `providerMessageId`, `status` (`submitted|rejected|failed|unknown`), `errorCode`, `retryable`.
3. `MelipayamakRepository`: `sender` پیام را بر `from` پیش‌فرض ترجیح دهد؛ شناسهٔ پیام و کد خطا را از پاسخ استخراج کند. رفتار فعلی بقیهٔ پیامک‌های سیستم تغییر نمی‌کند.
4. `SmsSettings`: چند شمارهٔ ارسال (`senders[]`) به‌جای فقط یک `from`.
5. `targets`: ستون‌های اختیاری `first_name`, `last_name` (D7).
6. `DeleteTargets`: اگر Target Execution زنده دارد، کاربر باخبر شود و Executionها با دلیل `target_removed` لغو شوند.
7. `PermissionEnum` / `RolePermissions` / `AuditEventLabel`: مجوزها و برچسب رویدادهای جدید (تست موجود، برچسب فارسی را اجباری می‌کند).

> **یک مشاهدهٔ امنیتی خارج از محدودهٔ این کار:** رمز و کلید API پنل پیامک هر Workspace الان به‌صورت متن ساده داخل `workspaces.settings` (JSON) ذخیره می‌شود (`SmsSettings::save`). با آمدن ارسال انبوه، ارزش این اطلاعات بالاتر می‌رود. پیشنهاد می‌کنم در فاز ۰ همین‌ها را با cast رمزنگاری‌شده ذخیره کنیم؛ جداگانه تأیید بده.

---

## 2. Database Schema

قواعد عمومی: همهٔ جدول‌ها `workspace_id` (NOT NULL، index) دارند. مدل‌ها از `BelongsToWorkspace` استفاده می‌کنند. `created_at/updated_at` در همه هست و ذکر نشده. زمان‌ها UTC (D2). FK جدول‌های تاریخچه `restrictOnDelete` است.

### 2.1 تعریف Workflow

**`workflows`** — SoftDeletes

| ستون | نوع | توضیح |
|------|-----|-------|
| id, workspace_id | | |
| name, description | string, text null | |
| category | string(30) | `sms` … برای فیلتر UI؛ منطق موتور به آن وابسته نیست |
| status | enum | `draft` `active` `archived` |
| published_version_id | FK null → workflow_versions | Versionی که Campaignهای جدید می‌گیرند |
| created_by, updated_by | FK users | |

**`workflow_versions`**

| ستون | نوع | توضیح |
|------|-----|-------|
| workflow_id | FK | |
| version_number | uint | unique(workflow_id, version_number) |
| status | enum | `draft` `published` `superseded` |
| settings | json | پیش‌فرض‌های Workflow: `send_window`, `daily_limit`, `retry`, `on_unknown_outcome` |
| published_at, published_by | | Version منتشرشده immutable |
| note | string null | «چه چیزی عوض شد» |

در هر لحظه برای هر Workflow حداکثر **یک draft** وجود دارد.

**`workflow_nodes`**

| ستون | نوع | توضیح |
|------|-----|-------|
| workflow_version_id | FK | |
| node_key | string(40) | شناسهٔ پایدار بین Versionها (`n_a1b2c3`)؛ پایهٔ مهاجرت Execution بین Versionها در آینده. unique(version, node_key) |
| type | enum | `start` `action` `wait` `condition` `end` |
| subtype | string(50) | `manual` / `send_sms` / `duration` / `field_rules` / `complete` … — کلید Registry |
| name | string | عنوان داخلی |
| config | json | تنظیمات مخصوص subtype؛ با Schema همان Handler/Action اعتبارسنجی می‌شود |
| consumes_budget | bool | هنگام Publish از `Action::consumesBudget()` محاسبه می‌شود (برای Scheduler، بخش ۵) |
| is_enabled | bool | Step غیرفعال = رد می‌شود (بند ۱۱) |
| position_x, position_y | int null | فقط برای Canvas آینده؛ منطق هرگز از آن استفاده نمی‌کند |

**`workflow_edges`**

| ستون | نوع | توضیح |
|------|-----|-------|
| workflow_version_id, from_node_id, to_node_id | FK | |
| port | string(30) | `default` `yes` `no` `success` `failure` … — unique(from_node_id, port) |
| label | string null | |

> Condition دو Port (`yes`/`no`) دارد. Action می‌تواند Port اختیاری `failure` داشته باشد (مسیر جایگزین بعد از اتمام Retry). این همان چیزی است که بند ۲۶ را بدون بازطراحی ممکن می‌کند.

**`workflow_templates`** — Workspace-scoped، SoftDeletes: `name`, `description`, `definition` (json قابل حمل: nodes + edges + settings)، `created_by`.
قالب‌های «سیستمی» (مثل ۳-Step Product Introduction) **در کد** نگهداری می‌شوند (`TemplateCatalog`) و هنگام «ساخت از Template» در همان Workspace کپی می‌شوند. هیچ ردیف مشترکی بین Workspaceها وجود ندارد.

### 2.2 Campaign

**`campaigns`** — SoftDeletes

| ستون | توضیح |
|------|-------|
| name, description | |
| workflow_id, workflow_version_id | Version هنگام فعال‌سازی pin می‌شود |
| source_target_list_id | FK null → target_lists (فقط منبع اولیهٔ انتخاب؛ Target کپی نمی‌شود) |
| status | `draft` `scheduled` `active` `paused` `completed` `cancelled` |
| starts_at, ends_at | null-able |
| daily_limit | uint null — روی Version پیش‌فرض را override می‌کند |
| send_window | json null — override |
| settings | json — `stop_when_target_status_in`, … |
| paused_at, paused_by | |
| created_by | |

### 2.3 Execution و تاریخچه

**`workflow_executions`** — بدون حذف (نه SoftDelete، نه Delete)

| ستون | نوع | توضیح |
|------|-----|-------|
| campaign_id, workflow_id, workflow_version_id | FK | |
| subject_type, subject_id | string, bigint | D1 |
| current_node_id | FK null | |
| cycle | uint default 1 | با Loop/Restart زیاد می‌شود؛ جزء کلید Idempotency |
| status | enum | pending, active, waiting, queued, processing, completed, failed, paused, cancelled, blacklisted |
| eligible_at | datetime | لحظه‌ای که این Step **اولین بار** مجاز شد؛ هرگز با Deferral عوض نمی‌شود (برای عدالت صف) |
| next_run_at | datetime null | |
| started_at, last_run_at, completed_at, paused_at | | |
| paused_from_status | string null | برای Resume |
| end_reason | string null | `finished` `stopped` `campaign_ended` `target_removed` `removed_by_user` … |
| retry_count | uint | برای Step فعلی؛ با ورود به Step جدید صفر می‌شود |
| revision | uint | Optimistic version؛ با هر تغییر وضعیت +۱ |
| lock_token, locked_at | string null, datetime null | Claim (بخش ۵) |
| last_error | text null | |
| metadata | json | متغیرهای runtime (مثل `discount_code`) |
| active_key | generated tinyint null | `CASE WHEN status IN (live) THEN 1 END`؛ **unique**(campaign_id, subject_type, subject_id, active_key) → یک Subject در یک Campaign هم‌زمان فقط یک Execution زنده دارد، حتی با Race |

Indexها: `(status, next_run_at)`، `(campaign_id, status, next_run_at)`، `(workspace_id, subject_type, subject_id, status)` (Conflict Detection و Timeline)، `(lock_token)`.

«زنده» = `pending, active, waiting, queued, processing, paused`.

**`workflow_execution_events`** — append-only؛ منبع Timeline صفحهٔ Target

`execution_id, campaign_id, subject_type, subject_id, node_id null, type, message, data json, actor_user_id null, occurred_at`

نوع‌ها: `enrolled, node_entered, wait_started, wait_finished, sms_attempt, retry_scheduled, deferred_window, deferred_limit, blacklisted, skipped, paused, resumed, removed, restarted, condition_evaluated, completed, failed, cancelled`.
Index: `(workspace_id, subject_type, subject_id, occurred_at)`, `(execution_id, occurred_at)`.

### 2.4 SMS

**`automation_sms_logs`** — هر Attempt یک ردیف (بند ۱۴)

`execution_id, workflow_id, campaign_id, subject_type/id, node_id (sms_step_id), phone_number, message_template, final_message, provider, sender_number, attempt_number, cycle, idempotency_key (unique), status, request_at, response_at, provider_response json, provider_message_id, error_code, error_message, retry_scheduled_at, segments`

**Immutability:** ردیف با `status=sending` و فیلدهای درخواست ساخته می‌شود. فقط **یک بار** فیلدهای نتیجه (`status, response_at, provider_*, error_*, retry_scheduled_at`) از حالت `null` پر می‌شوند؛ هر تغییر بعدی (گزارش تحویل) به `automation_sms_log_events` اضافه می‌شود و `status` فقط در جهت پیشرفت (submitted → sent → delivered) به‌روز می‌شود. مدل هر Update/Delete دیگری را رد می‌کند.

**`automation_sms_log_events`** — `sms_log_id, event, provider_status, payload json, occurred_at` (پاسخ‌های Webhook/Polling).

وضعیت‌ها: `queued, sending, submitted, sent, delivered, failed, rejected, unknown, skipped, blacklisted, cancelled`.
`retrying` در لیست تو هست، اما ذخیره نمی‌شود چون Log نباید Overwrite شود؛ «failed + retry_scheduled_at پر» در UI به‌صورت «در انتظار تلاش مجدد» نمایش داده می‌شود.

**`sms_templates`** — SoftDeletes: `name` (عنوان داخلی)، `body`، `created_by`. **کپی‌هنگام‌درج**: متن Template داخل config Step کپی می‌شود تا ویرایش Template، Workflowهای در حال اجرا را خراب نکند.

### 2.5 Blacklist، محدودیت‌ها، تنظیمات

**`blacklist_entries`**: `channel` (پیش‌فرض `sms`)، `value` (شمارهٔ نرمال‌شده 09…)، `reason` (`manual, opt_out, complaint, invalid_number, imported, other`)، `description`، `source` (`manual, import, opt_out, api, target_status`)، `is_active`، `created_by`، `removed_by`, `removed_at`. unique(workspace_id, channel, value). حذف = `is_active=false` (تاریخچه در `audit_logs`). `channel/value` به‌جای `phone_number` تا Email هم بتواند از همین استفاده کند.

**`automation_daily_counters`**: `scope_type` (`campaign|workflow|workspace`)، `scope_id`، `channel`، `day` (تاریخ روز در تایم‌زون Workspace)، `used`. unique(scope_type, scope_id, channel, day). رزرو به‌صورت اتمیک: `UPDATE … SET used = used + 1 WHERE used < :limit`.

**`automation_settings`** (یک ردیف برای هر Workspace): `enabled`, `timezone` (پیش‌فرض `workspaces.timezone`)، `default_send_window` json، `global_daily_limit` null (آمادهٔ آینده)، `opt_out_text` null، `webhook_token`.

**`automation_enrollment_batches`**: برای جریان Conflict Detection (بخش ۸): `campaign_id, subject_type, subject_ids json, status (draft|committed|discarded), decisions json, counts json, created_by`.

### 2.6 نمونهٔ config هر Node

```json
// action / send_sms
{
  "title": "پیام معرفی",
  "body": "سلام {target.first_name|همکار گرامی}\nمحصولات جدید {workspace.name} …",
  "provider": null,                       // null = Provider اصلی Workspace
  "sender": null,                         // null = خط پیش‌فرض Provider
  "send_only_in_window": true,
  "on_missing_variable": "skip",          // skip | send_blank
  "append_opt_out": false,
  "retry": { "enabled": true, "strategy": "fixed", "max_attempts": 3, "interval_seconds": 900,
             "on_exhausted": "fail" }     // stop | skip_step | continue | fail
}
// wait
{ "mode": "duration", "amount": 30, "unit": "days" }            // minutes|hours|days|weeks
{ "mode": "until", "at": "2026-10-20T09:00:00", "timezone": "Asia/Tehran" }
// condition
{ "logic": "all", "rules": [ { "field": "target.status", "operator": "in", "value": ["interested"] } ] }
// end
{ "behavior": "complete" }                                       // complete | stop
{ "behavior": "loop", "to_node": "n_first", "delay": {"amount":30,"unit":"days"}, "max_cycles": 3 }
{ "behavior": "restart", "delay": {"amount":90,"unit":"days"}, "max_cycles": 2 }
{ "behavior": "goto_workflow", "workflow_id": null }             // رزرو؛ در v1 توسط Validator رد می‌شود
```

---

## 3. Entity Relationship

```mermaid
erDiagram
    WORKSPACE ||--o{ WORKFLOW : owns
    WORKSPACE ||--o{ CAMPAIGN : owns
    WORKSPACE ||--o{ BLACKLIST_ENTRY : owns
    WORKSPACE ||--o{ SMS_TEMPLATE : owns
    WORKSPACE ||--|| AUTOMATION_SETTINGS : has
    WORKFLOW ||--o{ WORKFLOW_VERSION : has
    WORKFLOW_VERSION ||--o{ WORKFLOW_NODE : contains
    WORKFLOW_VERSION ||--o{ WORKFLOW_EDGE : contains
    WORKFLOW_NODE ||--o{ WORKFLOW_EDGE : "from / to"
    WORKFLOW ||--o{ CAMPAIGN : "used by"
    WORKFLOW_VERSION ||--o{ CAMPAIGN : "pinned by"
    CAMPAIGN ||--o{ WORKFLOW_EXECUTION : runs
    WORKFLOW_VERSION ||--o{ WORKFLOW_EXECUTION : "executed on"
    WORKFLOW_NODE ||--o{ WORKFLOW_EXECUTION : "current step"
    TARGET ||--o{ WORKFLOW_EXECUTION : "subject (reference only)"
    TARGET_LIST ||--o{ CAMPAIGN : "source"
    WORKFLOW_EXECUTION ||--o{ WORKFLOW_EXECUTION_EVENT : timeline
    WORKFLOW_EXECUTION ||--o{ AUTOMATION_SMS_LOG : attempts
    AUTOMATION_SMS_LOG ||--o{ AUTOMATION_SMS_LOG_EVENT : "delivery reports"
    CAMPAIGN ||--o{ AUTOMATION_DAILY_COUNTER : "limit usage"
```

Target هیچ‌جا کپی نمی‌شود؛ Execution فقط Reference دارد و متن پیام هنگام ارسال از دادهٔ **زندهٔ** Target ساخته می‌شود (و `final_message` در Log ثبت می‌ماند).

---

## 4. Workflow State Machine

### وضعیت‌ها

| وضعیت | معنی |
|-------|------|
| `pending` | عضو شده ولی Campaign هنوز شروع نشده (draft/scheduled) |
| `active` | روی یک Step است و آمادهٔ اجرا؛ `next_run_at` رسیده یا نزدیک است |
| `waiting` | پارک‌شده تا `next_run_at` آینده (بعد از Wait، Deferral پنجره/محدودیت، یا Retry) |
| `queued` | Scheduler آن را Claim و Job فرستاده |
| `processing` | Worker در حال اجرای Step |
| `paused` | توسط کاربر متوقف شده (`paused_from_status` ذخیره است) |
| `blacklisted` | شماره در Blacklist است؛ قابل ادامه بعد از حذف از Blacklist |
| `completed` | پایان (با `end_reason`: `finished` / `stopped` / …) |
| `failed` | Retry تمام و رفتار «Mark as Failed» — قابل Restart دستی |
| `cancelled` | حذف توسط کاربر یا سیستم (`target_removed`, `campaign_ended`) |

### گذارها

```mermaid
stateDiagram-v2
    [*] --> pending: Enroll (Campaign هنوز شروع نشده)
    [*] --> active: Enroll (Campaign فعال)
    pending --> active: Campaign Activate
    active --> queued: Scheduler Claim
    waiting --> queued: Scheduler Claim (next_run_at رسید)
    queued --> processing: Worker CAS (lock_token مطابق)
    queued --> waiting: Reaper (Lease منقضی؛ Job گم شد)
    processing --> waiting: Wait / Retry / Deferral
    processing --> active: Step بعدی آماده و مجاز
    processing --> completed: End (complete / stop)
    processing --> failed: Retry تمام + on_exhausted=fail
    processing --> blacklisted: Guard: شماره در Blacklist
    processing --> cancelled: Target حذف شده
    active --> paused: Pause
    waiting --> paused: Pause
    paused --> waiting: Resume
    paused --> active: Resume
    blacklisted --> active: Resume (بعد از حذف از Blacklist)
    failed --> active: Restart / Retry Step
    completed --> active: Restart (cycle + 1)
    active --> cancelled: Remove
    waiting --> cancelled: Remove
    paused --> cancelled: Remove
```

قواعد:

- **همهٔ گذارها فقط از `ExecutionStateMachine::transition(execution, from, to)`** انجام می‌شوند: `UPDATE … WHERE id=? AND status=:from AND revision=:rev` — اگر سطر تغییر نکرد، تراکنش کنار می‌رود (Race). هر گذار یک `workflow_execution_events` می‌نویسد.
- **Pause کردن Campaign هیچ Executionی را تغییر نمی‌دهد** (فقط `campaigns.status`). Scheduler Campaignهای غیر `active` را نمی‌بیند؛ پس `next_run_at`ها دست‌نخورده می‌مانند و بعد از Resume بدون هیچ Bulk Update ادامه پیدا می‌کنند.
- Pause یک Target = `execution.status=paused`.
- Node غیرفعال (`is_enabled=false`) بدون هیچ اثر جانبی رد می‌شود.

### رفتار Node ها

| Node | رفتار |
|------|-------|
| **Start** | نگهدارندهٔ Trigger و تنظیمات ورود (تأخیر اولیه). Execution از اولین Node بعد از Start شروع می‌شود. |
| **Action** | Guardها → `Action::execute()` → `ActionResult` (`success`, `failed{retryable}`, `rejected`, `skipped`, `blocked`, `deferred`) → انتخاب Port/Retry/Behavior |
| **Wait** | زمان بیدارشدن = **لحظهٔ رسیدن Execution به این Node** + مدت (یا تاریخ مشخص). پس Wait ۳۰ روزه بعد از SMS اول از **زمان واقعی ارسال همان Target** حساب می‌شود، نه شروع Campaign. `until` در گذشته = فوراً عبور. |
| **Condition** | ارزیابی درخت شرط → Port `yes`/`no`. اگر Port متناظر Edge نداشته باشد Validator هنگام Publish خطا می‌دهد. |
| **End** | `complete` / `stop` → `completed` (با `end_reason` متفاوت). `loop` → برگشت به Node مشخص (پیش‌فرض اولین Step) با `cycle+1`. `restart` → بازگشت به اولین Step، ریست `retry_count` و `metadata`، `cycle+1`. `goto_workflow` رزرو. `loop/restart` حتماً `delay ≥ ۱ ساعت` و `max_cycles` دارند (جلوگیری از حلقهٔ بی‌نهایت ارسال). |

**اعتبارسنجی گراف هنگام Publish (`GraphValidator`)**: دقیقاً یک Start؛ حداقل یک End؛ همهٔ Nodeها از Start قابل‌دسترسی؛ هر Port ضروری Edge دارد؛ هر **حلقه** در گراف باید حداقل یک Wait یا `End(loop/restart)` با تأخیر داشته باشد؛ Action ها Schema معتبر دارند؛ متغیرهای ناشناخته در متن SMS رد می‌شوند؛ `goto_workflow` رد می‌شود. علاوه بر این، در Runtime هر Job حداکثر ۵۰ Node پشت‌سرهم اجرا می‌کند (Step Budget).

---

## 5. Scheduler Architecture

**نقطهٔ ورود:** `automation:tick` هر دقیقه از Scheduler موجود (`withoutOverlapping`). Scheduler **هیچ SMSی نمی‌فرستد** و Provider را صدا نمی‌زند.

```
automation:tick
 0. Reap: Executionهای queued/processing با locked_at قدیمی‌تر از Lease → بخش ۷ و ۸
 1. Lifecycle: Campaignهای scheduled که starts_at شان رسیده → active (+ pending→active)
               Campaignهای active که ends_at شان گذشته → completed (+ Executionهای زنده → cancelled/campaign_ended)
 2. برای هر Campaign فعال (chunk، گروه‌بندی بر اساس Workspace؛ هر Workspace با Context خودش):
    a. Workspace: automation فعال؟ Provider SMS فعال و پیکربندی‌شده؟  وگرنه رد + یک Event
    b. Version ثبت‌شده هنوز موجود؟
    c. ⌛ پنجرهٔ زمانی: اگر الان بسته است →
         یک UPDATE دسته‌ای: next_run_at = بازشدن بعدی، برای Executionهای due و «مصرف‌کنندهٔ بودجه»
         (eligible_at دست نمی‌خورد) → ادامه با Campaign بعدی
    d. 📊 بودجه: remaining = min(campaign_limit, workflow_limit) - used_today   (آینده: global)
    e. Due را دو مسیر جدا انتخاب کن:
         مصرف‌کننده (node.consumes_budget=1): ORDER BY next_run_at, eligible_at, created_at, id  LIMIT remaining
         غیرمصرف‌کننده (Wait/Condition/End/…):  ORDER BY next_run_at, id                            LIMIT 500
       شرایط: status IN (active, waiting) AND next_run_at <= now AND node.is_enabled
              AND target وجود دارد (join یا Reaper) — Blacklist در Job (منبع حقیقت) بررسی می‌شود
    f. Claim اتمیک:
         UPDATE workflow_executions
            SET status='queued', lock_token=:uuid, locked_at=now, revision=revision+1
          WHERE id IN (…) AND status IN ('active','waiting') AND next_run_at <= now
         سپس SELECT … WHERE lock_token=:uuid   ← فقط همان سطرهایی که واقعاً Claim شدند
    g. dispatch(ProcessExecutionJob(executionId, lockToken)) به صف automation
```

نکات کلیدی:

- **سبک‌بودن:** Claim فقط یک `UPDATE` شرطی است (بدون `SKIP LOCKED`، پس روی MySQL/MariaDB/SQLite یکسان کار می‌کند).
- **مصرف‌کننده/غیرمصرف‌کننده:** Wait و Condition نباید پشت Daily Limit گیر کنند؛ فقط Stepهایی که بودجه مصرف می‌کنند (`consumes_budget`) محدود می‌شوند.
- **عدالت صف (سناریوی «۵۰۰ Target و Limit = ۱۰۰»):** ۴۰۰ Target باقی‌مانده اصلاً Claim/به‌روز نمی‌شوند؛ `next_run_at` شان قدیمی می‌ماند و فردا خودبه‌خود اول صف‌اند. وقتی پنجره بسته است و همه به «۰۹:۰۰ فردا» منتقل می‌شوند، `eligible_at` ترتیب اصلی را حفظ می‌کند (تساوی `next_run_at` با `ORDER BY next_run_at, eligible_at, created_at`).
- **Wait بعد از SMS:** لحظهٔ رسیدن Step بعدی (`next_run_at` و `eligible_at`) برابر زمان پایان Wait است، نه «الان»؛ پس Backlog قدیمی‌تر جلوتر می‌ماند.
- **رزرو Limit در Job هم تکرار می‌شود** (اتمیک، بخش ۷)؛ Scheduler فقط برای جلوگیری از هدررفتن Job است، منبع حقیقت Job است.
- سقف زمان هر Tick ≈ ۵۰ ثانیه؛ اگر Backlog بیشتر بود Tick بعدی ادامه می‌دهد.

### محاسبهٔ پنجره

`SendWindow` یک Value Object خالص است (بدون DB، قابل تست کامل):

```json
{ "timezone": "Asia/Tehran",
  "days": { "sat": [["09:00","18:00"]], "sun": [["09:00","18:00"]], "mon": [["09:00","18:00"]],
            "tue": [["09:00","18:00"]], "wed": [["09:00","18:00"]],
            "thu": [["09:00","13:00"]], "fri": null } }
```

- `isOpen(t)` و `nextOpenAt(t)`: چند بازه در روز، روزهای غیرفعال، تایم‌زون غیر Tehran؛ یک بازه نمی‌تواند از نیمه‌شب عبور کند (برای آن، پایان یک روز و آغاز روز بعد را جدا تعریف کنید) (لحظه‌های ناموجود/تکراری DST با Carbon).
- مثال بند ۱۱: سررسید ۲۳:۰۰، بازه ۰۹:۰۰–۱۸:۰۰ → `nextOpenAt` = ۰۹:۰۰ نزدیک‌ترین روز مجاز.
- پنجرهٔ مؤثر = `campaign.send_window ?? version.settings.send_window ?? workspace default`. اگر هیچ‌کدام نبود: بدون محدودیت.
- روزها در UI شمسی و با «شنبه تا جمعه» نمایش داده می‌شوند.

---

## 6. Queue Architecture

```
cron → schedule:run → automation:tick ──▶ jobs table (queue: automation)
                                              │
                            queue:work automation ──▶ ProcessExecutionJob
                                                         │  (Worker)
                       ┌─────────────────────────────────┴─────────────────────────┐
                       ▼                                                            ▼
              Engine: Node Handlers                                   SmsGateway → Provider (HTTP)
              (Wait/Condition/End داخل همان Job)                       بدون هیچ Transaction باز
```

نام‌گذاری: `ProcessExecutionJob` همان «SendSMSJob» نمودار توست، ولی Generic است؛ ارسال SMS فقط یک Action داخل آن است.

| مورد | تصمیم |
|------|-------|
| Queue | `automation` روی اتصال `database` فعلی (بدون Redis). |
| `tries` | **۱** برای Job. Retry فقط در سطح Domain (Execution/Attempt) انجام می‌شود؛ Retry خودکار Laravel می‌تواند SMS را تکراری کند. |
| `timeout` | کمتر از `retry_after` اتصال صف (باید بررسی/تنظیم شود). |
| `failed()` | Attempt در حال `sending` را `unknown` می‌کند، Lock را آزاد می‌کند، Event ثبت می‌شود. |
| Rate limit | Job Middleware `RateLimited` بر اساس Workspace/Provider (مثلاً ۵ در ثانیه، قابل تنظیم) تا پنل پیامک محدود نشود. |
| هم‌پوشانی | `WithoutOverlapping("execution:{id}")` علاوه بر CAS دیتابیس (کمربند و بند). |
| Context | Job با `workspace_id` ساخته می‌شود و قبل از هر Query، `WorkspaceContext` را ست می‌کند (همان الگوی `EstablishesWorkspaceContext`). بدون این کار `BelongsToWorkspace` در Job هیچ سطری نمی‌بیند یا بدتر، Scopeها دور زده می‌شوند. |
| Worker (D3) | Supervisor: `numprocs=2` روی `--queue=automation,default`. فقط cron: `* * * * * queue:work --queue=automation,default --stop-when-empty --max-time=55`. |

### جریان Job (Idempotent)

```
handle(executionId, lockToken):
  set WorkspaceContext(workspace_id)
  ── Tx 1 ──────────────────────────────────────────────────────────────
  lock execution FOR UPDATE
  اگر lock_token ≠ token یا status ≠ queued → return  (Job کهنه/تکراری)
  status = processing (CAS با revision)
  ── حلقه Step (حداکثر ۵۰) ────────────────────────────────────────────
  node = current_node
  Start/Wait/Condition/End → Handler → StepResult → گذار → ادامه یا خروج
  Action:
     guards = action.guards()                       // Blacklist, SendWindow, DailyLimit …
     decision = guards.check()
       Block(blacklisted)  → status=blacklisted, Log(skipped/blacklisted), خروج
       Defer(until)        → next_run_at=until (eligible_at ثابت)، status=waiting، خروج
       Skip(reason)        → Log(skipped)، Port default
     ── Tx 2 ── رزرو Daily Limit (اتمیک) + ساخت SmsLog(status=sending, idempotency_key) ──
        اگر Attempt موفق (submitted/sent/delivered) برای (execution, cycle, node) از قبل هست → به‌جای ارسال، ادامه به Step بعد
        اگر idempotency_key تکراری (unique violation) → ارسال نکن؛ Attempt را بازیابی و وضعیتش را بررسی کن
     commit                                          ← هیچ Transaction هنگام تماس HTTP باز نیست
     result = SmsGateway.send(message, idempotencyKey)
     ── Tx 3 ── ثبت نتیجه روی Log (یک‌بار)، آزادسازی رزرو اگر پذیرفته نشد، Event،
                گذار بر اساس ActionResult (بخش ۷)، محاسبهٔ next_run_at
```

**جلوگیری از ارسال تکراری (بند ۲۸):** ۱) کلید یکتای `execution_id : cycle : node_id : attempt_number` با `UNIQUE` روی `idempotency_key` — تلاش دوم برای همان Attempt در سطح دیتابیس رد می‌شود. ۲) Attempt **قبل** از تماس با Provider ذخیره می‌شود؛ اگر Worker وسط کار بمیرد، ردیف `sending` باقی می‌ماند و Reaper آن را `unknown` می‌کند و **دوباره نمی‌فرستد** (D5). ۳) کلید Idempotency به Provider هم منتقل می‌شود اگر پشتیبانی کند (`SMSMessage::idempotencyKey`)؛ ملی‌پیامک احتمالاً پشتیبانی نمی‌کند و بند ۲ تنها محافظ است — این را ادعا نمی‌کنم، فقط طراحی به آن وابسته نیست.

---

## 7. Retry Logic

```
Attempt n نتیجه‌اش:
 ├─ submitted/sent/delivered → Log ثبت، retry_count=0، Port `default/success`، Step بعد
 ├─ rejected (شمارهٔ نامعتبر، متن رد شده)        → غیرقابل Retry → رفتار on_exhausted
 ├─ provider_unavailable/credit (اعتبار تمام)    → Retry مصرف نمی‌شود؛ Circuit Breaker (بخش ۱۲)
 ├─ failed (timeout، 5xx، خطای موقت) و Retry فعال و retry_count < max_attempts
 │        → Log(failed, retry_scheduled_at)، retry_count++، رزرو Limit آزاد،
 │          next_run_at = now + strategy.delay(attempt)  → status=waiting
 │          (Guardها دوباره بررسی می‌شوند: پنجره، Blacklist، Limit)
 └─ failed و (Retry غیرفعال یا تمام شده) → on_exhausted:
        stop       → completed  (end_reason = stopped_after_failure)
        skip_step  → Log(skipped)، Port default، Step بعد
        continue   → Step بعد؛ نتیجه به‌صورت `execution.last_action_status=failed` برای Conditionهای بعدی
        fail       → failed
     (اگر Edge با Port `failure` وجود دارد، قبل از این چهار رفتار، همان مسیر دنبال می‌شود)
```

- **`RetryStrategy` Interface:** `delay(int $attemptNumber, array $config): int` ثانیه. نسخهٔ ۱: `FixedIntervalStrategy`. Exponential Backoff = یک کلاس جدید و ثبت در Registry، بدون تغییر Schema.
- «Retry Count» = تعداد **تلاش مجدد** بعد از تلاش اول (Count=۳ → حداکثر ۴ تلاش).
- **Retry و Limit:** فقط Attemptی که Provider پذیرفته، بودجهٔ روزانه را مصرف می‌کند (رزرو هنگام Claim، آزادسازی وقتی پذیرفته نشد) تا Retryهای ناموفق سهمیهٔ کسی را نسوزانند.
- **Retry و پنجرهٔ زمانی:** Retry ساعت ۲۳:۰۰ به ۰۹:۰۰ منتقل می‌شود.
- هر Attempt ردیف مستقل در `automation_sms_logs` (`attempt_number`) و Event مستقل در Timeline دارد (نمونهٔ بند ۲۰: «ارسال ناموفق → Retry #1 → ارسال موفق»).
- **تفکیک Submitted/Sent/Delivered** (بند ۱۶): موفقیت Step = «Provider پذیرفت» (`submitted`)، نه صرفاً «HTTP 200». `RetStatus ≤ 0` یا بدنهٔ منفی = `rejected/failed` (منطق فعلی `responseSucceeded` حفظ می‌شود). `delivered` فقط از گزارش تحویل می‌آید و پیشروی Workflow را عوض نمی‌کند (v1). Providerی که DLR ندارد در `submitted` می‌ماند.

---

## 8. Conflict Detection Logic

جریان **دو مرحله‌ای** است؛ سیستم هرگز بدون تصمیم صریح کاربر دربارهٔ Target تکراری چیزی را اضافه/رد نمی‌کند.

```
1) Preview (بدون هیچ نوشتن جز ردیف batch)
   EnrollmentPlanner.plan(campaign, subjectIds | filter snapshot)
     subject_ids = فیلترها/Select All روی TargetVisibility → حداکثر ۲۰٬۰۰۰
     برای هر Chunk ۵۰۰ تایی:
        a. نامعتبر:      Target وجود ندارد / موبایل خالی یا نامعتبر
        b. Blacklist:    شماره در blacklist_entries فعال است
        c. همین Campaign:  Execution زنده دارد → «قبلاً عضو است» (غیرقابل اضافه؛ فقط Restart بعد از پایان)
        d. Conflict:     Execution زنده در Campaign دیگر
        e. تمیز
     خروجی: counts + لیست صفحه‌بندی‌شدهٔ هر گروه

   کوئری Conflict:
     SELECT e.subject_id, e.id, c.name AS campaign, w.name AS workflow,
            n.name AS current_step, e.next_run_at, e.status
       FROM workflow_executions e
       JOIN campaigns c ON c.id = e.campaign_id
       JOIN workflows w ON w.id = e.workflow_id
       JOIN workflow_nodes n ON n.id = e.current_node_id
      WHERE e.workspace_id = ? AND e.subject_type = 'target'
        AND e.subject_id IN (chunk) AND e.campaign_id <> ?
        AND e.status IN ('pending','active','waiting','queued','processing','paused')

2) تصمیم کاربر (UI مطابق بند ۷ تو)
   برای هر Conflict نمایش: «این شماره در Workflow دیگری فعال است — نام Workflow — مرحلهٔ فعلی — ارسال بعدی»
   گزینه‌ها: اضافه به Campaign جدید · عدم اضافه · رد همهٔ تکراری‌ها · اضافه همهٔ تکراری‌ها · مشاهدهٔ Workflowهای فعال Target
   decisions = { default: null|skip|add, overrides: { subject_id: add|skip } }

3) Commit (تراکنش در Chunk ۵۰۰ تایی، Idempotent)
   قبل از نوشتن: Conflict دوباره بررسی می‌شود؛ اگر بین Preview و Commit Conflict جدید پیدا شد و تصمیمی برایش نیست → 409 conflicts_unresolved و برگشت به مرحلهٔ ۲
   INSERT execution (active/pending) + Event enrolled؛ unique(active_key) نگهبان نهایی Race است
   Blacklisted و نامعتبر: هیچ‌وقت اضافه نمی‌شوند (نه Execution با status=blacklisted)؛ فقط گزارش می‌شوند
   API/Action بدون `on_conflict` صریح، وقتی Conflict وجود دارد → خطا (نه تصمیم پیش‌فرض)
```

Triggerهای آینده (مثلاً TargetCreated) از همان `EnrollmentPlanner` استفاده می‌کنند، با سیاست صریح `on_conflict=skip` که در **تنظیمات Campaign** ثبت است، نه در کد.

---

## 9. API Endpoints

پروژه Livewire-محور است؛ صفحات Web Route هستند و Actionها داخل Livewire صدا زده می‌شوند. فقط چند Endpoint JSON لازم است که **Builder را از UI مستقل می‌کند** و Webhookهای عمومی.

### صفحات (Web)

| Route | صفحه |
|-------|------|
| `GET /automation/workflows` | لیست Workflow |
| `GET /automation/workflows/create` | ساخت (از Template یا خالی) |
| `GET /automation/workflows/{workflow}` | جزئیات، Versionها، Campaignهای استفاده‌کننده |
| `GET /automation/workflows/{workflow}/builder` | Builder (Draft) |
| `GET /automation/campaigns` / `create` / `{campaign}` | لیست / ساخت / داشبورد |
| `GET /automation/campaigns/{campaign}/targets` | Targetهای Campaign |
| `GET /automation/campaigns/{campaign}/logs` | رویدادهای Campaign |
| `GET /automation/sms-logs` | Log تمام ارسال‌ها |
| `GET /automation/blacklist` | Blacklist (+ Import/Export) |
| `GET /automation/sms-templates` | Templateهای SMS |
| `GET /settings/sms` | همین صفحهٔ موجود، گسترش‌یافته |
| Target: تب «Automation» | Timeline در `targets/{target}` |

### JSON (برای Builder و ابزارها) — با Policy و Workspace Scope

| Method & Path | کار |
|---------------|-----|
| `GET /automation/catalog` | Nodeها، Actionها + Schema، Operatorها، Variableها، Templateها |
| `GET /automation/workflows/{w}/versions/{v}/graph` | `{nodes, edges, settings}` |
| `PUT /automation/workflows/{w}/versions/{v}/graph` | ذخیرهٔ Draft (فقط Draft؛ خطا به‌ازای Node) |
| `POST /automation/workflows/{w}/versions/{v}/validate` | اعتبارسنجی بدون Publish |
| `POST /automation/workflows/{w}/publish` | Publish Draft |
| `POST /automation/workflows/{w}/versions` | ساخت Draft جدید از Version منتشرشده |
| `POST /automation/sms/preview` | رندر متن با Target نمونه + تعداد Segment |
| `POST /automation/campaigns/{c}/enrollments/preview` | مرحلهٔ ۱ Conflict Detection |
| `PUT /automation/enrollments/{batch}/decisions` | ثبت تصمیم |
| `POST /automation/enrollments/{batch}/commit` | مرحلهٔ ۳ |
| `POST /automation/campaigns/{c}/(activate\|pause\|resume\|cancel)` | چرخهٔ حیات |
| `POST /automation/executions/{e}/(pause\|resume\|remove\|restart)` | کنترل یک Target |
| `GET /automation/targets/{target}/executions` | Workflowهای فعال Target (گزینهٔ «مشاهده») |
| `GET /automation/targets/{target}/timeline` | Timeline |
| `GET /automation/blacklist/export` · `POST /automation/blacklist/import` | Export / Import |

### Webhook (عمومی، بدون Session)

| `POST /webhooks/sms/{provider}/{token}` | گزارش تحویل / Opt-out؛ `token` مخصوص هر Workspace، مقایسهٔ Constant-time، Rate-limit، Payload خام در `automation_sms_log_events` ذخیره می‌شود. |

---

## 10. UI Pages and Components

منوی جدید «اتوماسیون» در Sidebar (پشت مجوز). همهٔ مودال‌ها Bottom-sheet روی موبایل (همان `x-modal`).

| # | صفحه | Component های کلیدی |
|---|------|--------------------|
| 1 | Workflow List | جدول، Badge وضعیت، تعداد Campaign فعال، «ساخت از Template» |
| 2 | Create Workflow | انتخاب Template / خالی |
| 3 | **Builder** | `StepList` (Nodeها به‌ترتیب، شاخهٔ Condition به‌صورت دو ستون/تودرتو)، `NodeCard`، `AddStepMenu` (درج بعد از هر Node)، `SendSmsEditor` (متن، `VariablePicker`، شمارندهٔ Segment، پیش‌نمایش، Provider/Sender، Retry)، `WaitEditor`، `ConditionRuleEditor`، `EndEditor`، `SendWindowEditor`، `ValidationPanel`، پیش‌نمایش Version/Publish |
| 4 | Workflow Details | Versionها، Diff متن‌ها، Campaignهای وابسته، Publish/Archive |
| 5–6 | Campaign List / Create | فرم، `TargetPicker` (فیلترهای موجود Targets، جستجو، Select All، انتخاب چند) |
| — | **Conflict Review** | خلاصهٔ شمارش‌ها، لیست Conflict با «نام Workflow / مرحلهٔ فعلی / ارسال بعدی»، دکمه‌های بند ۷، Drawer «Workflowهای فعال Target» |
| 7 | Campaign Dashboard | کارت‌ها: Total, Active, Waiting, Completed, Failed, Blacklisted, SMS Sent/Failed/Retried, Remaining Today, Daily Limit، جدول «ارسال‌های بعدی»، Pause/Resume |
| 8 | Campaign Targets | جدول Executionها، فیلتر وضعیت، Pause/Resume/Remove/Restart |
| 9 | Campaign Logs | Events |
| 10 | SMS Logs | جدول Attemptها با فیلتر وضعیت/تاریخ/Campaign، نمایش `provider_response` |
| 11 | Blacklist | جستجو، افزودن دستی، حذف (غیرفعال‌سازی)، Import/Export، منبع و دلیل |
| 12 | SMS Templates | CRUD + Insert در Step |
| 13 | SMS Settings | Provider، چند Sender، نشانی Webhook، متن Opt-out، پنجرهٔ پیش‌فرض |
| 14 | Target → Automation | `AutomationTimeline` (تاریخ شمسی، Eventها، Attemptها با Retry) |

تاریخ‌ها شمسی (`@ldatetime`, `x-date-input`)، ارقام Latin در فیلدهای عددی طبق الگوی فعلی.

---

## 11. Folder / Module Structure

الگوی فعلی پروژه: مدل‌ها flat در `app/Models`، Actionها در `app/Actions/<Domain>`، Livewire در `app/Livewire/<Domain>`. **هستهٔ موتور** به‌خاطر استقلال از Livewire/HTTP در یک پوشهٔ جدا می‌رود:

```
app/Automation/                       ← هستهٔ Generic (بدون وابستگی به Livewire/HTTP)
  Contracts/    WorkflowAction · WorkflowTrigger · ExecutionGuard · VariableProvider
                RetryStrategy · ConditionOperator · SmsGateway
  Engine/       ExecutionRunner · ExecutionStateMachine · Claimer · Reaper · GraphValidator
                VersionPublisher · StepResult · ExecutionContext
    Handlers/   StartHandler · ActionHandler · WaitHandler · ConditionHandler · EndHandler
  Registry/     ActionRegistry · TriggerRegistry · VariableRegistry · GuardRegistry
  Actions/      SendSmsAction (+ SendSmsConfig)              ← اولین Use Case
  Triggers/     ManualTrigger
  Conditions/   ConditionEngine · Operators/*
  Guards/       BlacklistGuard · SendWindowGuard · DailyLimitGuard
  Retry/        FixedIntervalStrategy
  Variables/    VariableRenderer · Providers/{Target,Workspace,Campaign,Execution}Variables
  Sms/          ProviderSmsGateway · DeliveryReportIngestor · SegmentCounter
  Enrollment/   EnrollmentPlanner · EnrollmentPreview · EnrollmentCommitter
  Support/      SendWindow · AutomationClock · UtcDatetime (cast) · TemplateCatalog
  AutomationServiceProvider.php
app/Jobs/Automation/                  ProcessExecutionJob · PollDeliveryReportsJob
app/Console/Commands/Automation/      AutomationTickCommand · (automation:reap)
app/Actions/Automation/               CreateWorkflow · SaveWorkflowGraph · PublishWorkflow · CreateCampaign
                                      ActivateCampaign · PauseCampaign · EnrollTargets · Add/RemoveBlacklist …
                                      (همه: Authorize + Transaction + AuditLogger)
app/Models/                           Workflow · WorkflowVersion · WorkflowNode · WorkflowEdge · WorkflowTemplate
                                      WorkflowExecution · WorkflowExecutionEvent · Campaign · AutomationSmsLog
                                      AutomationSmsLogEvent · BlacklistEntry · SmsTemplate · AutomationSetting …
app/Enums/                            ExecutionStatus · NodeType · CampaignStatus · SmsLogStatus · BlacklistReason …
app/Policies/                         WorkflowPolicy · CampaignPolicy · BlacklistPolicy · SmsTemplatePolicy
app/Livewire/Automation/              Workflows · Campaigns · Blacklist · SmsLogs · SmsTemplates · Timeline …
app/Support/Targets/TargetFilters.php ← استخراج‌شده از Targets\Index (استفادهٔ مشترک)
database/migrations/                  یک Migration برای هر گروه (بخش ۱۳)، Idempotent
tests/Feature/Automation/  tests/Unit/Automation/
```

نحوهٔ گسترش بدون تغییر هسته: Action جدید = یک کلاس `implements WorkflowAction` + یک خط ثبت در `AutomationServiceProvider`.

```php
interface WorkflowAction {
    public function key(): string;                       // 'send_sms', 'send_email', 'create_task' …
    public function label(): string;
    public function configRules(): array;                // اعتبارسنجی + Schema برای Builder
    public function guards(): array;                     // کلیدهای Guard؛ SMS: blacklist, send_window, daily_limit
    public function consumesBudget(array $config): bool;
    public function execute(ExecutionContext $ctx, array $config): ActionResult;
}
```

---

## 12. Edge Cases

**همزمانی و ارسال تکراری**
1. دو Tick هم‌زمان (Overlap) → Claim اتمیک با `lock_token`؛ فقط یکی برنده است.
2. Job دوبار Dispatch شود → CAS `queued→processing` فقط یکی را رد می‌کند؛ دومی خارج می‌شود.
3. Worker بعد از پذیرش Provider و قبل از ثبت نتیجه بمیرد → Attempt `sending` می‌ماند؛ Reaper آن را `unknown` می‌کند؛ **ارسال مجدد ندارد** (D5).
4. Lease منقضی‌شده (`queued` بدون Worker) → Reaper به `active/waiting` برمی‌گرداند؛ Executionی که Attempt `sending` دارد به `unknown` می‌رود و بازگردانده نمی‌شود.
5. Daily Limit در Race چند Job → رزرو اتمیک `UPDATE … WHERE used < limit`؛ Job بی‌بودجه Defer می‌شود بدون Attempt.
6. SQLite (تست) `lockForUpdate` را نادیده می‌گیرد → تست‌های Race باید روی CAS/Unique تکیه کنند و یک دستهٔ تست روی MySQL واقعی اجرا شود (تجربهٔ قبلی همین پروژه با تفاوت SQLite/MySQL).

**زمان**
7. Wait به «تاریخ مشخص» در گذشته → فوراً عبور. Wait مدت‌دار از لحظهٔ ورود به Step.
8. پنجره‌ای که همهٔ روزهایش غیرفعال است، یا Timezone نامعتبر → رد در Validator/تنظیمات.
9. پنجره در فاصلهٔ Claim تا اجرا بسته شد → Job دوباره چک می‌کند و Defer می‌کند.
10. Campaign تمام شد (`ends_at`) → Executionهای زنده `cancelled` با `campaign_ended` (تاریخچه می‌ماند) تا «زامبی» نشوند.
11. «روز» برای Limit = روز تقویمی Workspace (نه UTC).

**داده**
12. Target حذف شد → Execution `cancelled/target_removed`؛ `DeleteTargets` هشدار می‌دهد.
13. شمارهٔ Target بعد از عضویت عوض/نامعتبر شد → شماره همیشه هنگام ارسال از دادهٔ زنده خوانده می‌شود و Blacklist روی همان بررسی می‌شود؛ نامعتبر → `skipped`.
14. Target بعد از عضویت Blacklist شد → ارسال بعدی متوقف، `status=blacklisted`، Log «Skipped - Blacklisted»؛ بعد از حذف از Blacklist فقط دستی Resume می‌شود.
15. وضعیت Target به «عدم تمایل/شمارهٔ نامعتبر/تبدیل شد» تغییر کرد → تنظیم Campaign `stop_when_target_status_in` (پیشنهاد: پیش‌فرض خاموش، در UI روشن‌شدنی).
16. متغیر بدون مقدار (`{target.first_name}` خالی) → `{var|پیش‌فرض}` اگر نوشته شده؛ وگرنه `on_missing_variable` (پیش‌فرض `skip` با Log «Skipped – Missing variable»).
17. متن بلند: شمارندهٔ Segment در Builder (فارسی/Unicode معمولاً ۷۰ کاراکتر برای Segment اول)؛ هشدار نه رد.
18. Publish در حالی که Executionها روی Version قبلی‌اند → آن‌ها همان Version را ادامه می‌دهند؛ Version قدیمی فقط اگر هیچ Execution زنده‌ای ندارد Archive‌شدنی است. مهاجرت Execution بین Versionها (با `node_key`) بعداً.

**Provider**
19. Provider غیرفعال/بدون اعتبارنامه (`NullSMSRepository`) → Campaign اجازهٔ Activate ندارد؛ در Runtime → Defer (نه مصرف Retry) + Event.
20. اعتبار پنل تمام شد → **Circuit Breaker**: ارسال آن Workspace/Provider مثلاً ۳۰ دقیقه متوقف، بدون مصرف Retry هیچ Target، و اعلان به مالک.
21. Provider موفق گفت ولی شناسهٔ پیام ندارد → `submitted` با `provider_message_id=null`؛ Delivery Report برای آن ممکن نیست ولی Workflow ادامه می‌یابد.
22. Delivery Report دیرتر از Step بعدی می‌رسد → فقط Event و به‌روزرسانی `status` Log (رو به جلو)؛ Workflow تغییر نمی‌کند.
23. Providerهای Fallback (`ChainSMSRepository`) → همان Log با `provider` واقعی استفاده‌شده.

**قانونی/محتوا**
24. پیامک تبلیغاتی معمولاً قوانین ساعت ارسال و درج «لغو» دارد و بعضی پنل‌ها بدون آن رد می‌کنند. این را **با پنل ملی‌پیامک تأیید کنید**؛ طراحی آماده است: گزینهٔ `append_opt_out` در Step + Blacklist با `source=opt_out` + Endpoint ورودی Opt-out برای آینده.
25. Blacklist **مخصوص هر Workspace** است (طبق خواستهٔ تو)؛ شمارهٔ Opt-out در Workspace الف در Workspace ب هنوز پیامک می‌گیرد. این عمداً همین‌طور است.
26. Log شامل متن کامل پیام و شماره است (داده شخصی)؛ Retention/Purge بعداً به‌صورت تنظیم Workspace.

**امنیت/دسترسی**
27. هر Query و هر Job Workspace-scoped؛ تست ایزولاسیون برای همهٔ جدول‌ها.
28. Seller با `targets.view_own` فقط Targetهای خودش را می‌تواند در Campaign بیاورد (`TargetVisibility`).
29. Webhook: Token مخصوص Workspace، مقایسهٔ Constant-time، بدون افشای وجود/عدم‌وجود شماره در پاسخ.

---

## 13. Migration Plan (مراحل پیاده‌سازی)

هر فاز مستقل قابل Merge/Deploy است، با تست خودکار، و Migrationها **Idempotent** هستند (درس MySQL DDL غیرتراکنشی). هیچ جدول موجودی جز `targets` (دو ستون اختیاری) تغییر نمی‌کند؛ Rollback هر فاز = `migrate:rollback` همان دسته.

| فاز | محتوا | تحویل قابل‌مشاهده |
|-----|-------|-------------------|
| **۰ — پایه** ✅ | مجوزها و نقش‌ها، `AutomationClock` + Cast UTC + `HasUtcTimestamps`، `SendWindow`، توسعهٔ `SMSMessage/SMSResult/Melipayamak/Chain/Resolver` (sender، provider id، طبقه‌بندی خطا و نتیجهٔ نامعلوم)، چند Sender در `SmsSettings`، `first_name/last_name` روی Target، جدول `automation_settings` (کلید `enabled`). **به فاز ۲ منتقل شد:** Enumها، Interfaceها و Registryها (تایپ‌هایشان — `ExecutionContext`، `ActionResult` — در فاز ۲ ساخته می‌شود) و برچسب‌های Audit (هنوز رویدادی وجود ندارد). **منتظر تأیید جدا:** رمزنگاری اعتبارنامه‌ها | بدون UI جدید؛ تست واحد/Feature روی SendWindow، UTC، نتیجهٔ Provider، فرستنده‌ها، مجوزها |
| **۱ — Blacklist / Template / Variables** ✅ | جدول‌ها + Policy + صفحات Blacklist (افزودن دستی، Import، Export، حذف/فعال‌سازی دوباره) و SMS Templates؛ `BlacklistChecker`؛ `VariableRegistry` + `VariableRenderer` (`{a.b}`، نام کوتاه، `{x|پیش‌فرض}`، متغیر ناشناخته رد می‌شود) + `SegmentCounter` + پیش‌نمایش زنده؛ کلید روشن/خاموش اتوماسیون در صفحهٔ تنظیمات پیامک؛ `HasUtcTimestamps` برای `deleted_at` هم | دو صفحهٔ کاربردی مستقل (پشت کلید `enabled`) |
| **۲ — موتور (Headless)** ✅ | Node/Edge/Version، `GraphValidator`، `VersionPublisher`، `ExecutionStateMachine`، Handlerها، `ConditionEngine`، `SendSmsAction`، Guardها، Retry، Log/Event، Claimer/Reaper/`automation:tick`، `ProcessExecutionJob`، Limit اتمیک | **بدون UI؛ تست سناریو با `Carbon::setTestNow`:** سناریوی ۵۰۰ Target/Limit ۱۰۰/پنجره/Retry (بند ۲۵)، Crash بعد از ارسال، Race، Blacklist وسط راه، شیفت پنجره، Loop/Restart |
| **۳ — Workflow UI** ✅ | لیست/ساخت/Template، Builder مرحله‌ای، Details، Version/Publish | ساخت و انتشار Workflow |
| **۴ — Campaign** ✅ | استخراج `TargetFilters`، CRUD Campaign، `EnrollmentPlanner` + UI Conflict، Campaign Targets، کنترل Target، Dashboard، Logs، SMS Logs | اجرای واقعی اولین Campaign |
| **۵ — Timeline و سخت‌سازی** ✅ (به‌جز راستی‌آزمایی ملی‌پیامک) | Timeline در صفحهٔ Target، Webhook تحویل، Circuit Breaker، اعلان‌ها، پایش سلامت زمان‌بند/Worker، مستند استقرار. **منتظر پنل واقعی:** تبدیلگر فرمت ملی‌پیامک و Polling تحویل (D6) | آمادهٔ Production |
| **۶ — بعدی** (Exponential Backoff ✅، مرحلهٔ پیگیری ✅، افزودن خودکار ✅) | Canvas بصری، فیلدهای Condition خرید/سفارش، Actionهای جدید (Email/Webhook)، `goto_workflow`، مهاجرت Execution بین Version | — |

**استقرار:** `php artisan migrate` + Worker روی صف `automation` (D3) + یک خط Scheduler که از قبل با cron موجود اجرا می‌شود. مطابق `docs/deployment.md` بخش‌های ۵ و ۶ به‌روز می‌شوند.

**آنچه عمداً در نسخهٔ ۱ نیست:** Canvas بصری، Conditionهای مبتنی بر خرید، `goto_workflow`، Opt-out ورودی خودکار، Exponential Backoff، Global Daily Limit (ستونش آماده است). هیچ‌کدام نیاز به بازطراحی Schema ندارند.

---

## ۱۴. آنچه در فاز ۲ متفاوت از طرح اولیه شد

| موضوع | طرح | آنچه ساخته شد و چرا |
|-------|-----|--------------------|
| نام Job | `SendSMSJob` | `ProcessExecutionJob` (Generic)؛ ارسال SMS فقط یک Action داخل آن است. |
| Step مصرف‌کنندهٔ سقف بعد از Wait | ادامه در همان Job | همیشه به Scheduler برمی‌گردد (status=`active`)، تا Scheduler تنها جای بودجه‌بندی و ترتیب ارسال بماند. Stepهای سبک (Wait، Condition، End) داخل یک Job پشت‌سرهم اجرا می‌شوند. |
| ترتیب صف | `next_run_at, created_at` | `next_run_at, eligible_at, id` — `id` همان ترتیب ساخت است و ایندکس‌دار. |
| بودجهٔ روزانه در Scheduler | `سقف − مصرف‌شده` | `سقف − مصرف‌شده − در جریان (queued/processing)`؛ بدون این، ادعای یک دقیقه در دقیقهٔ بعد تکرار می‌شد پیش از آنکه Workerها اسلات بگیرند. محافظ نهایی همچنان رزرو اتمیک داخل Job است. |
| نتیجهٔ `unavailable` | — | کانال قابل‌استفاده نیست (اعتبار تمام / SMS خاموش): ۳۰ دقیقه انتظار، بدون مصرف Retry و بدون مصرف سقف. |
| بازیابی Worker مرده | Reaper مستقیم | Reaper فقط Execution را به `active` برمی‌گرداند؛ تشخیص «ارسال نیمه‌کاره» با خود SendSmsAction است که Attempt باقی‌مانده را `unknown` می‌بندد و دوباره نمی‌فرستد. |
| مرحلهٔ غیرفعال | رد شدن | Event `skipped` ثبت و بدون اثر جانبی رد می‌شود. |
| Loop/Restart | `max_cycles` | علاوه بر آن: فاصلهٔ حداقل ۱ ساعت و Validator حلقه‌های بدون Wait زمان‌دار را رد می‌کند. |

هنوز ساخته نشده: تبدیلگر گزارش تحویل ملی‌پیامک و Polling (نیاز به پنل واقعی)، Endpointهای JSON.

### فاز ۳ — تصمیم‌های ویرایشگر

- **ویرایشگر مرحله‌ای (D4):** ویرایشگر روی «توالی» (`WorkflowSequence`) کار می‌کند: لیست مراحل که به یک پایان می‌رسد و «شرط» لیست را می‌بندد و هر شاخه‌اش لیست جدا دارد. `compile()` آن را به Node/Edge موتور تبدیل و `decompile()` برعکس می‌کند؛ Backend هیچ‌جا به UI وابسته نیست و Canvas بصری بعداً فقط جایگزین همین لایه می‌شود.
- **گرافی که ویرایشگر نمی‌تواند بکشد** (شاخه‌های به‌هم‌پیوسته، مسیر `failure`) بی‌صدا بازنویسی نمی‌شود: با دلیل، فقط‌خواندنی نمایش داده می‌شود.
- **پیش‌نویس ناقص قابل ذخیره است**؛ مشکلات (متن خالی، متغیر ناشناخته، حلقهٔ بدون Wait…) در پنل بالای صفحه نشان داده می‌شود و انتشار را می‌بندد.
- **قالب‌ها:** سه قالب آماده در کد (`TemplateCatalog`) + قالب‌های خود Workspace (`workflow_templates`)؛ استفاده از قالب همیشه کپی با شناسهٔ مراحل جدید است. متن قالب‌های آماده عمداً بدون «آقا/خانم» و بدون ادعا دربارهٔ محصول است.
- **Audit:** ذخیرهٔ پیش‌نویس، متن قبلی و بعدی هر پیامک را در `audit_logs` نگه می‌دارد («چه کسی متن را تغییر داد»).

### فاز ۴ — تصمیم‌های کمپین

- **افزودن تارگت سه‌مرحله‌ای، بدون تصمیم خودکار:** انتخاب (دستی یا «همهٔ نتایج فیلتر» با همان `TargetFilters` صفحهٔ تارگت‌ها) ← بررسی (`EnrollmentPlanner`: آماده / از قبل در همین کمپین / در کمپین دیگر / لیست سیاه / نامعتبر) ← افزودن. نتیجهٔ بررسی در جدول `automation_enrollment_batches` نگه داشته می‌شود تا تصمیم‌ها (یک پاسخ برای همه + پاسخ جداگانه برای هر مورد) بین درخواست‌ها بماند.
- **تکراری = تصمیم صریح:** تارگتی که در کمپین دیگری فعال است هرگز خودکار اضافه یا رد نمی‌شود. دکمهٔ «افزودن» تا پاسخ‌دادن به همهٔ موارد غیرفعال است و سرور هم مستقل آن را رد می‌کند (`UnresolvedConflictsException`، پیش از نوشتن هر چیز). صفحهٔ تصمیم Workflow، مرحلهٔ فعلی، وضعیت و تاریخ ارسال بعدی کمپین دیگر را نشان می‌دهد.
- **بررسی دوباره هنگام افزودن:** آنچه بین پیش‌نمایش و افزودن عوض شده (شمارهٔ تازه در لیست سیاه، تارگتی که کمپین دیگر برداشته) اعمال می‌شود؛ مسابقهٔ همزمان با کلید یکتای `active_key` پایگاه‌داده گرفته و «همزمان اضافه شد» گزارش می‌شود. افزودن دوباره یک Batch اثری ندارد (فقط `draft` قابل افزودن است و با یک `UPDATE` شرطی گرفته می‌شود).
- **دسته‌های بزرگ:** تا ۳۰۰ تارگت همان لحظه، بیشتر از آن `CommitEnrollmentBatchJob` روی صف `automation`؛ صفحه هر ۲ ثانیه وضعیت را می‌پرسد. اگر Job بمیرد Batch به `failed` می‌رود (نه «در حال افزودن» ابدی).
- **نسخهٔ Workflow هنگام ساخت کمپین قفل می‌شود** (نه هنگام شروع) تا بتوان در حالت پیش‌نویس هم تارگت اضافه کرد؛ انتشار نسخهٔ تازهٔ Workflow روی کمپین موجود اثری ندارد. Workflow و نسخهٔ کمپین بعد از ساخت قابل تغییر نیست.
- **کنترل هر تارگت** (`ExecutionControl`): توقف / ادامه / حذف از کمپین / شروع دوباره (دور جدید؛ فقط تمام‌شده یا ناموفق). همه از `ExecutionStateMachine` می‌گذرند (Workerِ همزمان بازنده می‌شود، نه بازنویس) و در Timeline نام انجام‌دهنده ثبت می‌شود. عملیات گروهی برای موارد غیرمجاز رد نمی‌شود و ادامه می‌دهد؛ دلیل هر مورد ردشده گزارش می‌شود و در `audit_logs` یک ردیف خلاصه می‌ماند.
- **توقف خودکار:** `settings.stop_when_target_status_in` (مثلاً «علاقه‌مند»، «تبدیل شد»): پیش از هر مرحله بررسی می‌شود و اجرا با `end_reason=target_status_changed` لغو می‌شود. حذف تارگت هم اجراهای زنده‌اش را همان لحظه (`target_removed`) لغو می‌کند.
- **آمار داشبورد** از همان شمارندهٔ `automation_daily_counters` می‌آید که Guard سقف روزانه از آن رزرو می‌کند؛ عدد «باقی‌ماندهٔ امروز» هرگز با آنچه اجرا می‌شود اختلاف ندارد. تارگتی که سقف امروز جایش نداد «در نوبت ارسال» نشان داده می‌شود (نه تاریخ گذشته).
- **گزارش ارسال پیامک** مجوز جدا دارد (`automation.sms_logs.view`) چون شماره و متن همهٔ گیرندگان را دارد. کمپین‌ها و اجراها برای نقشی که فقط تارگت‌های خودش را می‌بیند، فقط همان تارگت‌ها را نشان می‌دهند.
- **نام متد Livewire:** متدی به نام `commit` روی Component کار نمی‌کند (با نام داخلی JS تداخل دارد و کلیک بی‌صدا هیچ کاری نمی‌کند؛ تست‌های PHP آن را نمی‌بینند). این متد `addToCampaign` نام دارد.

### فاز ۵ — تصمیم‌های سخت‌سازی

- **Timeline صفحهٔ تارگت** (`TargetTimeline`): کمپین‌هایی که تارگت هم‌اکنون در آن‌هاست، و زیر آن همهٔ رویدادها (افزوده‌شدن، ارسال، انتظار، تلاش مجدد، توقف، پایان) از جدیدترین؛ هر ارسال با متن واقعی پیام، شمارهٔ گیرنده، وضعیت، شناسهٔ پیام نزد پنل، خطا و زمان تلاش مجدد. نام انجام‌دهندهٔ عملیات دستی هم می‌آید. فقط وقتی اتوماسیون روشن است و کاربر مجوز دیدن کمپین‌ها دارد نمایش داده می‌شود؛ خود صفحه مثل قبل تابع دسترسی به تارگت است. نام رویدادها از `EventLabels` می‌آید (مشترک با گزارش کمپین).
- **Circuit Breaker** (`ProviderCircuitBreaker` + `ProviderCircuitGuard`): وقتی خودِ کانال پیامک قابل‌استفاده نیست (اعتبار تمام / سرویس پیامک خاموش یا تنظیم‌نشده)، اولین پیامی که این را می‌فهمد `sms_paused_until` را روی تنظیمات اتوماسیون همان Workspace می‌گذارد (`automation.provider_backoff_minutes`، پیش‌فرض ۳۰). تا آن زمان Guard اول (پیش از لیست سیاه/ساعت/سقف) هر Execution را به تعویق می‌اندازد: هیچ Attempt ثبت نمی‌شود، سقف روزانه رزرو نمی‌شود و Retry مصرف نمی‌شود. بعد از پایان مهلت، پیام بعدی «آزمایشی» است؛ اگر هنوز خراب بود دوباره بسته می‌شود. مالک فقط یک بار به‌ازای هر قطعی (و حداکثر هر ۶ ساعت) اعلان می‌گیرد و با شرط یک `UPDATE` اتمیک (نه دو Worker هم‌زمان). دکمهٔ «مشکل برطرف شد؛ ادامه» (با Audit) آن را دستی باز می‌کند. Workspaceها مستقل‌اند.
- **پایش سلامت** (`AutomationHealth` + `StatusBanner`، هر ۳۰ ثانیه خودتازه): هشدار «زمان‌بند اجرا نمی‌شود» (ضربان `automation:tick` در Cache، بیش از ۵ دقیقه قدیمی در حالی که کمپین فعال دارید) و «صف پردازش نمی‌شود» (Executionهایی که بیش از ۵ دقیقه `queued` مانده‌اند). این دو رایج‌ترین خرابی استقرارند (Cron یا Worker فراموش‌شده) و پیش‌تر بی‌صدا بودند. بنر در فهرست و داشبورد کمپین نشان داده می‌شود.
- **Webhook گزارش تحویل:** `POST /api/webhooks/sms/{provider}/{token}`. توکن مخصوص هر Workspace (`automation_settings.webhook_token`) و تنها اعتبارنامه است؛ هر رد (توکن غلط، Provider ناشناخته، اتوماسیون خاموش) دقیقاً همان ۴۰۴ است و پاسخ موفق هرگز نمی‌گوید گزارشی به پیامی خورده یا نه. حداکثر ۶۴KB و ۲۰۰ گزارش در هر درخواست، Rate-limit ۱۲۰ در دقیقه. آدرس در تنظیمات پیامک نشان داده می‌شود و «ساخت آدرس تازه» توکن را عوض و قبلی را باطل می‌کند (Audit).
- **گزارش تحویل چه می‌کند:** پیام را با `provider_message_id` (داخل همان Workspace) پیدا می‌کند، گزارش را با کلمات خود پنل در `automation_sms_log_events` نگه می‌دارد و وضعیت پیام را فقط رو به جلو (submitted → sent → delivered) می‌برد؛ گزارش تکراری یک بار ثبت می‌شود؛ گزارش دیررس تاریخچه اضافه می‌کند ولی پیام را عقب نمی‌برد. هیچ‌وقت مسیر Workflow را عوض نمی‌کند. پیام با نتیجهٔ «نامعلوم» شناسهٔ پنل ندارد، پس با گزارش تحویل هم مطابقت نمی‌خورد (باید از پنل پیگیری شود).
- **فرمت Webhook و ملی‌پیامک (D6 هنوز باز است):** تنها تبدیلگر ثبت‌شده `generic` است: فرمت خنثای خودِ این سیستم (`{"reports":[{"message_id","status":"sent|delivered|failed|rejected"}]}`) برای واسط یا اسکریپتی که به پنل وصل می‌شود. فرمت واقعی ملی‌پیامک (و Polling) بدون حساب واقعی حدس زده نشد؛ `POST .../melipayamak/...` عمداً ۴۰۴ می‌دهد. وقتی فرمت با پنل واقعی راستی‌آزمایی شد، افزودنش یک کلاس `DeliveryReportParser` و یک خط در `AutomationServiceProvider` است و بقیهٔ خط لوله آماده است.
- **اعلان‌ها:** قطع‌شدن کانال (به مالک) و پایان‌یافتن خودکار کمپین در `ends_at` (به سازندهٔ کمپین؛ `NotifyCampaignEndedJob`، چون Scheduler همهٔ Workspaceها را بدون Context می‌بیند). پایان دستی اعلان ندارد. اعلان به تمام‌شدن همهٔ مخاطبان عمداً ساخته نشد: کمپین با اتمام مخاطبان خودکار بسته نمی‌شود، چون هر لحظه می‌شود تارگت تازه به آن داد.

### فاز ۶ — تا اینجا

- **Exponential Backoff** (`ExponentialBackoffStrategy`، کلید `exponential`): هر تلاش مجدد دو برابر قبلی (مثلاً ۱۵، ۳۰، ۶۰ دقیقه)، حداقل ۱ دقیقه و حداکثر ۲۴ ساعت. در ویرایشگر مرحلهٔ پیامک، کنار فاصلهٔ تلاش مجدد قابل انتخاب است؛ پیش‌فرض همچنان «ثابت» است و Workflowهای موجود تغییری نمی‌کنند.
- **مرحلهٔ «پیگیری برای مسئول»** (`CreateFollowupAction`، زیرنوع `create_followup`): یک فعالیت پیگیری (تماس/جلسه/بازدید/پیامک/ایمیل/یادداشت/وظیفه) روی فهرست فعالیت‌های خودِ تارگت ثبت می‌کند، با عنوان و توضیحات دارای متغیر (`{name}`، `{city|نامشخص}`…) و مهلت (ساعت یا روز از لحظهٔ رسیدن به مرحله؛ صفر = همان لحظه)، و اگر تارگت مسئول دارد به او اعلان می‌دهد (قابل خاموش‌کردن). چیزی برای خود تارگت نمی‌فرستد، پس سقف روزانه مصرف نمی‌کند، به Guardهای پیامک نیاز ندارد و با بسته‌بودن کانال پیامک هم اجرا می‌شود. **امن در برابر اجرای دوباره:** فعالیت با اجرا، مرحله و دور ساخته‌شده‌اش مهر می‌خورد (`source_execution_id/node_id/cycle`) و کلید یکتای پایگاه‌داده ثبت دوم را رد می‌کند؛ دور تازه (تکرار/شروع دوباره) پیگیری تازه می‌سازد. عمداً وضعیت تارگت را عوض نمی‌کند (برخلاف ثبت دستی که «جدید» را «در حال پیگیری» می‌کند)، چون تغییر وضعیت، فیلترها و «توقف خودکار» را بی‌خبر تحت‌تأثیر می‌گذارد. فعالیت ساخته‌شده `created_by` خالی دارد و در تاریخچهٔ تارگت رویداد «پیگیری برای مسئول ثبت شد» می‌آید. در ویرایشگر از منوی «+» با گزینهٔ «پیگیری برای مسئول» اضافه می‌شود.

- **افزودن خودکار تارگت‌های تازه (Trigger کمپین):** قاعدهٔ `settings.auto_enroll` روی خود کمپین: «هر تارگت تازه‌ای که با این شرط‌ها بخواند (لیست، استان، شهر، وضعیت، اولویت) وارد کمپین شود». هر ۵ دقیقه (`Scheduler::AUTO_ENROLL_EVERY_MINUTES`) برای کمپین‌های **در حال اجرا** که قاعده دارند یک `AutoEnrollCampaignJob` (با `WithoutOverlapping`) اجرا می‌شود. طراحی‌شده تا کسی را غافلگیر نکند: (۱) **هر تارگت فقط یک بار بررسی می‌شود** (مکان‌نمای `cursor` روی شناسه)، پس تارگتی که کاربر از کمپین حذف کرده هرگز بی‌صدا برنمی‌گردد و بلوکی از تارگت‌های ردشده هم بررسی تارگت‌های بعدی را گرسنه نمی‌کند؛ (۲) پیش‌فرض فقط تارگت‌های ساخته‌شده **بعد از روشن‌شدن** قاعده بررسی می‌شوند (`since`)، و تارگت‌های فعلی فقط با گزینهٔ صریح «شامل موجودها» و با نمایش تعداد پیش از ذخیره؛ (۳) شرط خالی رد می‌شود (وگرنه همهٔ تارگت‌های آینده وارد می‌شدند)؛ (۴) تارگتِ فعال در کمپین دیگر، لیست‌سیاه و شمارهٔ نامعتبر **رد می‌شوند و اضافه نمی‌شوند** — کسی نیست که دربارهٔ تکراری تصمیم بگیرد و قاعدهٔ «سیستم هیچ تکراری را خودش حل نمی‌کند» برقرار می‌ماند؛ (۵) سقف در هر بررسی (۲۰۰) و سقف روزانهٔ قابل‌تنظیم (پیش‌فرض ۲۰۰، حداکثر ۵٬۰۰۰) که روز تقویمی Workspace می‌شمارد؛ مازاد برای فردا می‌ماند نه گم می‌شود. ویرایش قاعده بدون تغییر شرط‌ها پیشرفتش را نگه می‌دارد و تغییر شرط‌ها از نو شروع می‌کند. رویداد «افزوده شد» با برچسب `auto` و عبارت «(افزودن خودکار)» در تاریخچهٔ تارگت می‌آید و آمار آخرین بررسی روی داشبورد کمپین نشان داده می‌شود (بررسی بی‌نتیجه آن را بازنویسی نمی‌کند). اجرا با دسترسی مالک Workspace است (همهٔ تارگت‌ها را می‌بیند)؛ دسترسی «فقط تارگت‌های خودم» در این مسیر اعمال نمی‌شود.

### تکمیل کاستی‌های شناخته‌شده

- **سقف روزانهٔ کل Workspace** (`automation_settings.global_daily_limit`، قابل‌تنظیم در «تنظیمات ← پیامک»): علاوه بر سقف هر کمپین. `DailyLimitGuard` هر دو شمارنده را (کمپین سپس Workspace) رزرو می‌کند و اگر دومی جا نداشت، اسلات کمپین را پس می‌دهد و اجرا به فردا موکول می‌شود (بدون Attempt و بدون مصرف Retry). Scheduler هم گنجایش باقی‌ماندهٔ Workspace را از بودجهٔ ادعا کم می‌کند تا روز پر، دقیقه‌به‌دقیقه ادعا و رد نشود؛ رعایت نهایی با Guard است. سقف کمپین و سقف کل هر دو روی داشبورد کمپین دیده می‌شود. کمپینی که زودتر برسد اسلات را می‌گیرد (اولویت‌بندی بین کمپین‌ها ندارد).
- **لغو ورودی (Opt-out):** همان Webhook گزارش تحویل، کلید `opt_outs` را هم می‌پذیرد (`["09121234567", {"number": "…"}]`). هر شماره با هر قالب نگارشی (لاتین/فارسی، `+98`) با منبع `opt_out` و دلیل «درخواست لغو» به لیست سیاه همان Workspace می‌رود؛ Journeyهایی که هنوز در راه آن شمارهٔ هستند در ارسال بعدی متوقف می‌شوند (`blacklisted`). فقط از مسیری که توکن معتبر دارد؛ پاسخ همیشه همان `{"ok":true}` است. اینکه پنل ملی‌پیامک «پاسخ لغو» را چگونه گزارش می‌کند هنوز راستی‌آزمایی نشده؛ این مسیر فعلاً برای واسط/اسکریپتی است که آن را به فرمت خنثا تبدیل می‌کند.

### شمارهٔ تارگت: موبایل یا تلفن ثابت

هر تارگت یک شمارهٔ موبایل (`mobile`)، یک تلفن ثابت با کد شهر (`phone`)، یا هر دو دارد؛ هرگز هیچ‌کدام. هر کدام در Workspace یکتاست. تفسیر ورودی و تشخیص تکراری فقط در `TargetContact` است (فرم ساخت، فرم ویرایش و ورود اکسل یکسان رفتار می‌کنند) و شمارهٔ ثابتِ واردشده در فیلد موبایل (یا برعکس) با پیام روشن و زیر همان فیلد رد می‌شود. در ورود اکسل شماره‌ها **بر اساس ماهیتشان** دسته‌بندی می‌شوند نه ستونی که در آن نوشته شده‌اند (ستون مشترک «تلفن» با ترکیب موبایل و ثابت هم درست وارد می‌شود). پیامک فقط به موبایل می‌رود (`ExecutionContext::recipient` و `EnrollmentPlanner` هر دو `PhoneNumber::mobile` را می‌خوانند): تارگتِ فقط‌ثابت هنگام افزودن به کمپین در گروه «بدون موبایل معتبر» می‌آید و هرگز پیامک نمی‌گیرد، ولی برای پیگیری تلفنی (مثلاً مرحلهٔ «پیگیری برای مسئول») کامل قابل‌استفاده است؛ `{phone}` هم متغیر پیام است. مایگریشن `2026_09_28_090000` ستون `mobile` را اختیاری می‌کند و شماره‌هایی که پیش‌تر در ستون موبایل ذخیره شده بودند ولی با ۰۹ شروع نمی‌شدند (ثابت‌ها) را به `phone` منتقل می‌کند؛ rollback ستون `phone` (و شمارهٔ تارگت‌های فقط‌ثابت) را حذف می‌کند.
