# سجل تنفيذ التكامل بين كابيتانو ويب وكابيتانو لوجستك

**الحالة:** تم تنفيذ **الخطوات 0 و1 و2 و3 و4 و5 و6 و7 و8** (الخطوة 8: القسم 20) + **إلزام المتجر بسواجر لوجستك** (القسم 11) + **اختبار حي لمسار الطلب كاملًا عبر HTTP** (القسم 12)، (القسم 13 للخطوة 3، 14 للخطوة 4، 15 للخطوة 5، 16 للخطوة 6، 17 للخطوة 7) + **قاعدة منع الإلغاء بعد استلام الكابتن** (القسم 18)، والعمل **متوقف عند بوابة الخطوة 8** (آخر خطوات الخطة) بانتظار قبولك. **الخطوة 6 مُطفأة افتراضيًا** (`LOGISTIC_APPLY_STATUS=false`).
**التاريخ:** 2026-10-05
**الخطة المرجعية:** `docs/kapitano-web-event-listener-integration-plan.md`
**قاعدة العمل:** كل خطوة تُنفَّذ وحدها، وتنتهي ببوابة فحص وأدلة، ولا تبدأ التالية قبل موافقتك.

> **أهم ما يجب أن تعرفه في أربع نقاط**
> 1. **الخطوة 1** لم تغيّر أي ملف قائم. **الخطوات 2 إلى 5** غيّرت معًا **ستة ملفات قائمة بإضافة 81 سطرًا فقط (0 حذف، 0 تعديل)** (28 للخطوة 2 + 5 لفحص العقد اليومي + 5 لشبكة المطابقة في الخطوة 3 + 19 للخطوة 5: مسار الويبهوك، سطر اللوحة في صفحة الطلب، وجدولة التنظيف + 6 للخطوة 6: جدولة المزامنة + 6 للخطوة 7: تسجيل مستمع الدفع على حدث Eloquent + 12 لقاعدة الإلغاء في `OrderController`)، وكلها خلف العلم `LOGISTIC_ENABLED` وهو **مُطفأ**. المتجر يعمل كما كان تمامًا.
> 2. أول مكالمة موقَّعة حقيقية من كابيتانو ويب إلى لوجستك **نجحت** (الخطوة 1)، وأول **طلب حقيقي** وصل لوجستك بحقوله الصحيحة (الخطوة 2) في **اختبار حي مقابل الخوادم الحقيقية: 61 فحصًا، 0 فاشل، ثلاث مرات متتالية**.
> 3. اكتشفتُ وأصلحتُ عيبًا حرجًا في لوجستك: **رمز المتجر كان سيتوقف عن العمل بعد 30 يومًا من إصداره** (الخطوة 0).
> 4. قبل التشغيل الحقيقي لا بد من **إحداثيات الاستلام للمتاجر النشطة** (لا يملكها أي متجر اليوم) — انظر 9.11.

---

## 1. كيف ضمنتُ ألا يتأثر السلوك الحالي

وضعتُ هذه القواعد قبل البدء والتزمتُ بها في كل خطوة:

| القاعدة | كيف طُبِّقت |
|---|---|
| لا تعديل لأي ملف موجود في كابيتانو ويب **حتى نهاية الخطوة 1**. (الخطوة 2 تضيف ثلاثة تعديلات صغيرة محروسة بالعلم — انظر 9.3) | كل الملفات جديدة. الدليل: `git diff` للملفات المتتبَّعة فارغ، والمرجع الوحيد للتكامل هو الملفات الجديدة نفسها |
| التكامل يعمل فقط عند تفعيله صراحةً | علم `LOGISTIC_ENABLED` افتراضه `false`، ولا شيء في المتجر يقرؤه أصلًا في الخطوة 1 |
| لا مفتاح أجنبي ولا تعديل على جدول `orders` | الجدولان الجديدان مستقلان تمامًا؛ وعدد صفوف `orders` قبل وبعد: **3515 / 3515** |
| لا أسرار في الطباعة أو السجلات أو المستندات | الأوامر تعرض "set/MISSING" فقط؛ والعميل يخفي الرمز والسر من أي `var_dump`؛ واختبارات تتحقق من ذلك |
| تغيير لوجستك لا يمسّ إلا ما يخص المتاجر | حارس مخصَّص لمتجر واحد فقط، وجلسات المدير والسائق ومستخدمي بوابة المتجر **بقيت كما هي** (وهناك اختبارات تثبت ذلك) |
| كل تغيير يقابله اختبار، وأتأكد أن الاختبار يفشل بدون الإصلاح | أجريتُ "اختبار سلبي" بإلغاء الإصلاح مؤقتًا فسقط اختباران كما هو متوقع، ثم أعدتُ الإصلاح |

---

## 2. الخطوة 0 — متطلبات لوجستك

### 2.1 تشغيل Redis

- **لماذا:** واجهة المتاجر في لوجستك تخزّن "بصمة" كل طلب موقَّع في Redis لمنع إعادة إرساله (replay)، وإن لم يعمل Redis تُرجع الواجهة `503`.
- **ما فعلتُه:** شغّلتُ النسخة المحمولة الموجودة في `C:\Users\DELL\redis\...` كما يصف دليل التشغيل. ثم تبيّن أن **نسخة أخرى** كانت تعمل أصلًا منذ الساعة 04:09 صباحًا (ليست مني)، فأوقفتُ النسخة المكرَّرة التي شغّلتُها أنا فقط وأبقيتُ الأصلية.
- **التحقق:** `php artisan dispatch:redis-check` ← كل الصفوف `OK`.
- **ملاحظة أمنية:** النسخة الأصلية تستمع على كل الواجهات (`0.0.0.0`) بدون كلمة مرور. هذا ليس من فعلي، لكنه يستحق انتباهك على جهاز التطوير.

### 2.2 إصلاح عيب حرج: انتهاء صلاحية رمز المتجر بعد 30 يومًا

**المشكلة (ثبتت بالتجربة):**
إعداد `sanctum.expiration = 43200` (30 يومًا) يُطبَّق على **كل** الرموز، حتى رموز خوادم المتاجر. جرّبتُ الحارس الحقيقي: رمز عمره 29 يومًا يُقبَل، أما رمز عمره 31 أو 200 يوم فيُرفَض. أي أن كل متجر يتوقف عن العمل **بعد شهر** من إصدار رمزه، وبرسالة عامة (401) لا تشرح السبب.

**لماذا لم أستخدم الحل الأسهل (callback عام):**
دالة تخفيف عامة لا تعرف أي حارس يسأل، فكانت ستسمح لرمز متجر قديم بالدخول عبر حارس المدير أو السائق. لذلك أنشأتُ **حارسًا مخصَّصًا** للمتجر فقط، يحتفظ بفحص نوع المستخدم (provider) ويُلغي فقط شرط العمر.

**ما أُنجز (في مشروع لوجستك):**

| الملف | التغيير |
|---|---|
| `app/Auth/StoreTokenGuard.php` (جديد) | حارس Sanctum بلا قاعدة العمر العامة، مع الإبقاء على `expires_at` الخاص بالرمز وفحص النوع |
| `app/Providers/IntegrationServiceProvider.php` | تسجيل الحارس عند الإقلاع (سطر واحد) |
| `config/auth.php` | حارس `store` يستخدم `store-token` بدل `sanctum` (حارس المتجر فقط) |
| `config/integration.php` | إعداد `token_ttl_days` (الافتراضي 365 يومًا، `0` = بلا انتهاء) |
| `StoreCredentialService` / `StoreClientReviewService` / `IssueStoreTokenCommand` | كل مواضع إصدار رمز المتجر الثلاثة تضع الآن تاريخ انتهاء صريحًا |
| `StorePortalStoreResource` | يُرجع `expires_at` للرمز (حقل إضافي لا يكسر شيئًا) |
| `database/migrations/2026_10_05_100000_give_store_tokens_an_explicit_expiry.php` | يمنح الرموز الحالية تاريخ انتهاء (انظر أدناه) |

**قاعدة الهجرة (للحفاظ على السلوك الحالي حرفيًا):**
- رمز ما زال ضمن نافذة الـ30 يومًا (حيّ الآن) ← يبقى حيًّا ويأخذ العمر الجديد (إنشاؤه + 365 يومًا).
- رمز تجاوز النافذة (ميّت الآن) ← يُمنح انتهاءً في الماضي **فيبقى ميتًا**. لا نُحيي اعتمادًا كان متوقفًا.
- **النتيجة على قاعدة التطوير:** 6 رموز متاجر أصبح لكلٍّ منها تاريخ انتهاء؛ ورموز المدير والسائق ومستخدمي البوابة **لم تُمَس**.

**الاختبارات (`StoreTokenLifetimeTest`، 10 اختبارات):**
- يجب أن يتغير: رمز عمره 200 يوم يعمل حتى انتهائه الخاص؛ ينتهي في وقته؛ كل طرق الإصدار تضع الانتهاء؛ TTL=0 يعني بلا انتهاء؛ إعادة الإصدار تُبطل القديم.
- يجب ألا يتغير: جلسة المدير ما زالت تنتهي بعد 30 يومًا؛ جلسة مستخدم بوابة المتجر كذلك؛ رمز المتجر **لا** يُقبَل في أي حارس آخر (admin/driver/store_user) حتى لو كان قديمًا؛ ورمز المدير لا يُقبَل في حارس المتجر.
- **اختبار سلبي:** عند إعادة الحارس إلى `sanctum` مؤقتًا سقط الاختباران المتعلقان بالعمر فقط (كما يجب)، وبقيت اختبارات "يجب ألا يتغير" ناجحة. ثم أعدتُ الإصلاح.

**نتائج تشغيل الحزم المتأثرة (Redis يعمل فعليًا):**

| الحزمة | النتيجة |
|---|---|
| Integration + StorePortal + Documentation | **249 / 249** ناجحة |
| ملفات أخرى تستخدم عملاء المتاجر (Dispatch/Order/Pricing/Unit) | **90 / 90** ناجحة |

**ما لم أنفّذه بعد في هذا الإصلاح (مؤجَّل عمدًا):** إشعار المالك قبل 30 و7 أيام من الانتهاء، ومفتاح خطأ مستقل `token_expired`، وعرض تاريخ الانتهاء في واجهة البوابة. الحقل جاهز في الـAPI.

### 2.3 تسجيل المتجر المحلي وإصدار بيانات الاعتماد

- أنشأتُ عميل متجر `kapitano-web-local` ("Kapitano Web (local)") عبر أمر لوجستك نفسه، ثم **اعتمدتُه** عبر خدمة المراجعة الحقيقية (كما يفعل مدير): الحالة `approved`، نشط وحيّ.
- كُتبت بيانات الاعتماد **مباشرةً** في `.env` الخاص بكابيتانو ويب (الملف مُتجاهَل من git) دون طباعتها في أي مكان.
- **خطأ وقع وأُصلح:** أول مرة قرأ سكربتي الرمز بشكل خاطئ لأن رمز Sanctum يحتوي على الرمز `|` (شكله `1001|...`)، فخزّن 4 أحرف فقط. اكتشفتُ ذلك من طول القيمة المقنَّعة في المخرجات، فألغيتُ الرموز، وأصدرتُ رمزًا جديدًا، وصحّحتُ القراءة، وتأكدتُ من شكل الرمز قبل الكتابة. القيمة الخاطئة لم تكن صالحة لشيء.
- الرمز الحالي ينتهي بعد 365 يومًا (2027-10-05) — أي أن الإصلاح 2.2 يعمل فعليًا.

---

## 3. الخطوة 1 — أساس التكامل في كابيتانو ويب

> **كل ما يلي ملفات جديدة. لم يُعدَّل أي ملف قائم.**

### 3.1 الملفات

| الملف | وظيفته | هل يغيّر سلوكًا حاليًا؟ |
|---|---|---|
| `config/logistic.php` | إعدادات التكامل من `.env` (العلم، الوضع الآمن، العنوان، الرموز، المهل) | لا — يُحمَّل ولا يُقرأ إلا من ملفات التكامل |
| `app/Services/Logistic/Signer.php` | التوقيع `t=<وقت>,v1=<HMAC>` مطابق تمامًا لما تتوقعه لوجستك | لا |
| `app/Services/Logistic/LogisticClient.php` | **الوحيد** الذي يكلّم لوجستك: الترويسات المطلوبة، التوقيع على البايتات المرسلة نفسها، مفتاح التكرار للكتابة | لا |
| `app/Services/Logistic/LogisticResponse.php` | تفسير الرد: مفتاح الفشل، أخطاء التحقق، هل تُعاد المحاولة، هل هو رد مُعاد | لا |
| `app/Services/Logistic/LogisticConfigurationException.php` | خطأ إعداد ناقص، يذكر أسماء المفاتيح لا قيمها | لا |
| `app/Console/Commands/Logistic/HealthCommand.php` | `php artisan logistic:health` فحص موقَّع للاتصال (قراءة فقط) | لا — يُشغَّل يدويًا |
| `app/Console/Commands/Logistic/StatusCommand.php` | `php artisan logistic:status` حالة الإعدادات والطابور (بدون شبكة) | لا — يُشغَّل يدويًا |
| `database/migrations/2026_10_05_000010_create_logistic_integration_tables.php` | جدولان جديدان (انظر 3.3) | لا |
| `sqlupdates/kapitano_logistic_integration_2026-10-05.sql` | نفس الجدولين بصيغة SQL (حسب عُرف المشروع) | لا |
| `tests/Unit/Logistic/SignerTest.php`، `tests/Feature/Logistic/*` (3 ملفات) | 44 اختبارًا | لا |

### 3.2 قرارات التصميم المهمة (وسببها)

- **التوقيع على البايتات المُرسَلة نفسها:** الجسم يُحوَّل إلى JSON مرة واحدة، يُوقَّع، ويُرسَل كما هو. إعادة الترميز بعد التوقيع تجعل التوقيع خاطئًا. (الاختبار يستخدم نصًا عربيًا لإثبات ذلك.)
- **لا إعادة محاولة داخل العميل:** الفشل يُعاد كرد، والمُرسِل (في خطوات لاحقة) يقرر الموعد. لو أعاد العميل المحاولة داخل طلب ويب لتأخرت شاشة المدير بقدر بطء لوجستك — وهذا ما يجب ألا يحدث أبدًا.
- **لا استثناءات للرفض أو انقطاع الاتصال:** كلها ردود عادية (الحالة 0 = لا إجابة).
- **لا يتبع التحويلات (redirects):** حتى لا يُرسَل الرمز إلى عنوان آخر.
- **فصل القراءات المتطابقة بثانية:** لوجستك ترفض توقيعين متطابقين في الثانية نفسها كإعادة إرسال؛ العميل ينتظر نهاية الثانية عند تكرار القراءة نفسها (له اختبار).
- **إخفاء الأسرار:** `__debugInfo` في العميل والموقّع، ورسائل خطأ الشبكة لا تحمل الرمز.
- **متوافق مع PHP 8.2** (حسب `composer.json`)، فلا أستخدم ميزات 8.3.

### 3.3 قاعدة البيانات

جدولان جديدان **بدون أي علاقة بـ `orders`**:

- `logistic_order_links` — أي طلب متجر أصبح أي طلب لوجستك وحالته (`order_id` فريد: طلب واحد ← ربط واحد على الأكثر).
- `logistic_outbox` — ما يجب إرساله، مع إعادة المحاولة. المفتاح `idempotency_key` **فريد**، وهذا ما سيمنع إنشاء طلبين في لوجستك من حدث مكرر.

**طريقة التطبيق (حسب عُرف المشروع):** ملف SQL `IF NOT EXISTS` (آمن عند التكرار) + هجرة مطابقة. طبّقتُ ملف SQL على قاعدة التطوير مرتين (الثانية بلا أثر)، ثم سجّلتُ الهجرة وحدها بـ `--path`.

> ⚠️ **تحذير مهم:** لا تشغّل `php artisan migrate` العادي على قاعدة التطوير هذه. أمر `migrate:status` يعرض **مئات** هجرات "إنشاء الجداول" القديمة كمعلَّقة لأن القاعدة جاءت من نسخة SQL. استخدمتُ `--path` لهجرتي وحدها. (المشروع يرفض `migrate:fresh` تلقائيًا عند وجود طلبات، وهذا حماية جيدة.)

### 3.4 الاختبارات (كابيتانو ويب): 44 اختبارًا، 122 تأكيدًا — كلها ناجحة

- **التوقيع:** يطابق قيمًا حسبتُها بكود لوجستك نفسه (نص عربي، جسم فارغ، شكل الترويسة) — إثبات التوافق بايت ببايت.
- **العميل:** الترويسات الإلزامية؛ التوقيع على نفس البايتات؛ مفتاح التكرار للكتابة فقط؛ القراءة بلا جسم؛ عدم تكرار توقيع القراءة في الثانية نفسها؛ تصنيف الردود (201، 409 مكرر، 422 بمساره، 429/5xx قابلة لإعادة المحاولة، 401، رد غير JSON، انقطاع الاتصال بدون تسريب الرمز)؛ إخفاء الأسرار؛ رسالة الإعداد الناقص.
- **الجداول:** الإنشاء، التكرار بلا ضرر، التراجع، فرادة الربط وفرادة مفتاح التكرار، وعدم إشارة الهجرة لجدول `orders`.
- **الأوامر:** النجاح والتحذير من انحراف الساعة، تفسير كل رفض بنصيحة، عدم الإرسال عند نقص الإعداد، **وعدم طباعة أي سر**.
- **خلل في اختباري اكتشفتُه وأصلحتُه:** اختبار "لا يطبع الأمر سرًا" كان ينجح بلا معنى لأن دالة التقاط المخرجات تُرجع نصًا فارغًا. أصلحتُ الدالة وأضفتُ تأكيدًا بأن المخرجات ليست فارغة.

### 3.5 بوابة الخطوة 1: تشغيل حي (لوجستك يعمل على المنفذ 8001، والتكامل ما زال معطّلًا)

```
php artisan logistic:health
  target      : 127.0.0.1
  integration : disabled (this is a diagnostic call only; no order is sent)
OK - signed request accepted
  client = Kapitano Web (local) | accepting_orders = yes | clock_skew_seconds = 1 | tolerance = 300
```

وحالات الفشل على الخادم الحي، لكل منها نصيحة واضحة:

| الحالة | النتيجة |
|---|---|
| سرّ توقيع خاطئ | `401 signature_mismatch` ← "انسخ مفتاح التوقيع من البوابة" |
| رمز خاطئ | `401 Unauthorized` ← "الرمز خاطئ أو مُلغى أو منتهٍ" |
| لا أحد يستمع | "No answer from Logistic" ← "تحقق من العنوان وأن الخادم يعمل" |
| إعداد ناقص | "Missing in .env: LOGISTIC_TOKEN" (يذكر الاسم لا القيمة) |

`php artisan logistic:status` يعرض: التكامل معطّل، الوضع الآمن مفعّل، الرمز والأسرار "set"، والطابور فارغ.

---

## 4. أدلة عدم التأثير على السلوك الحالي (عند نهاية الخطوة 1؛ أدلة الخطوة 2 في 9.3 و9.9 و9.12)

| الفحص | النتيجة |
|---|---|
| ملفات متتبَّعة معدَّلة في كابيتانو ويب | **لا شيء** (`git diff` فارغ) |
| ملفات التكامل | كلها جديدة غير متتبَّعة (`app/Console/Commands/Logistic`، `app/Services/Logistic`، `config/logistic.php`، هجرة، اختبارات) |
| أي ملف قائم يشير إلى التكامل | **لا** (`git grep` فارغ) |
| صفوف `orders` قبل/بعد | **3515 / 3515** |
| حالة العلم | `LOGISTIC_ENABLED=false` |
| الاختبارات القائمة في كابيتانو ويب | شغّلتُ المجموعة **كاملة**: 1085 اختبارًا ← 881 ناجحًا، 130 متخطّى، **74 فاشلًا**. الفاشلة كلها في 14 ملفًا لا علاقة لها بعملي (لوحة البائع، OTP، الإشعارات، مصادر الزيارات، Paymera...). **أثبتُّ أنها موجودة مسبقًا:** نقلتُ كل ملفاتي خارج المشروع وشغّلتُ هذه الملفات الـ14 بالذات ففشل **نفس العدد (74 من 190)**، ثم أعدتُ ملفاتي. اختباراتي الـ44 كلها ناجحة |
| اختبارات لوجستك المتأثرة | 249/249 و90/90 ناجحة |

---

## 5. أمور صادفتُها وكيف تعاملتُ معها (شفافية كاملة)

1. **مجلد `vendor` في كابيتانو ويب كان قديمًا:** فرع `dev` تحدّث بـ `git pull` (قبل 53 دقيقة ويومين) وأضاف حزمة `owen-it/laravel-auditing`، لكن `vendor` بقي من الاستنساخ الأول. هذا كان سيكسر المتجر نفسه (نموذج `User` يستخدم الحزمة). نفّذتُ `composer install` فقط (مجلد `vendor` متجاهَل من git).
2. **`migrate` على قاعدة التطوير أضاف 1489 صف ترجمة:** ربط المشروع نفسه (`AppServiceProvider`) أمر `translations:sync` بنهاية أي `migrate`. هذا "إضافة فقط" لنصوص الواجهة الناقصة، ولم يغيّر شيئًا موجودًا. ذكرتُه لأنه أثر جانبي على قاعدة التطوير لم أقصده.
3. **نسختا Redis:** شرحتُ في 2.1.
4. **ملفات `*.sql` متجاهَلة في `.gitignore` لكابيتانو ويب:** الملفات السابقة `kapitano_*.sql` أُضيفت بالقوة؛ عند الـcommit استخدم `git add -f sqlupdates/kapitano_logistic_integration_2026-10-05.sql`.
5. **تحذير PHPUnit واحد** ("PHPUnit Deprecations: 1") موجود أصلًا في اختبارات المشروع الأخرى (من إعدادات phpunit)، وليس من اختباراتي.
6. **74 اختبارًا فاشلًا موجودة مسبقًا في كابيتانو ويب** (تفصيلها في القسم 4). لم أحاول إصلاحها لأنها خارج نطاق المهمة ولا علاقة لها بالتكامل، لكنك ينبغي أن تعرف بوجودها: أغلبها في لوحة البائع (`SellerOrderActionLinkTest`, `SellerPwaTest`, `SellerPushNotificationTest`...) وبعضها في OTP وTrafficSource وPaymera.
7. **لم أُجرِ أي `commit`** في أي من المشروعين، لأنك لم تطلب ذلك.

---

## 6. كيف تتحقق بنفسك

```powershell
# تأكد أن Redis ولوجستك يعملان، ثم من مجلد كابيتانو ويب:
cd C:\xampp\htdocs\kapitano_web
php artisan logistic:status
php artisan logistic:health

# اختبارات التكامل الجديدة (آمنة: SQLite في الذاكرة، لا تمس بياناتك):
php vendor/bin/phpunit tests/Unit/Logistic tests/Feature/Logistic

# اختبار حارس الرمز في لوجستك:
cd C:\xampp\htdocs\kapitano_logistic
php artisan test tests/Feature/Integration/StoreTokenLifetimeTest.php
```

## 7. كيف تتراجع عن كل شيء (إن أردت)

| ما تريد التراجع عنه | الطريقة |
|---|---|
| تكامل كابيتانو ويب بالكامل | احذف الملفات الجديدة المذكورة في 3.1، وأسقط الجدولين: `DROP TABLE logistic_outbox, logistic_order_links;` وأزل أسطر `LOGISTIC_*` من `.env`. لا شيء آخر يعتمد عليها |
| إصلاح الرمز في لوجستك | أعد `'driver' => 'sanctum'` لحارس `store` في `config/auth.php` (فيعود سلوك الـ30 يومًا). الهجرة لا يلزم التراجع عنها (تاريخ الانتهاء لا يضر) |

---

## 8. ما بعد بوابة الخطوة 1 (للتاريخ)

كانت الخطوة 2 تحتاج قراراتك حول إحداثيات الاستلام والتسليم ومبلغ التحصيل وتعدّد البائعين. لم تُرسل بدائل، فطبّقتُ **الافتراضات المقترحة** كما هي وكتبتُها بوضوح في القسم 9.2، وكلها قابلة للتغيير بإعداد واحد. القسم التالي يشرح الخطوة 2 كاملة.

---

## 9. الخطوة 2 — إنشاء الطلب في لوجستك (Create Order)

> **الخلاصة:** عندما يصبح طلب مسموح له `confirmed` في كابيتانو ويب، يُسجَّل في الطابور داخل نفس الطلب (استعلام إدخال واحد تقريبًا)، ثم يُرسَل إلى لوجستك بعد انتهاء الاستجابة، وإن فشل يُعاد تلقائيًا كل دقيقة بحسب جدول تأخير. وأي طلب لا يمكن تحويله بشكل صحيح **يُوقَف ويظهر السبب** ولا يُخمَّن شيء.
> **التكامل ما زال معطّلًا** (`LOGISTIC_ENABLED=false`). لا يتغير أي شيء في المتجر حتى تفعّله أنت.

### 9.1 الفكرة في صورة

```
 المدير/البائع يضغط "تأكيد" ──► $order->save()  (الكود القائم، كما هو)
                                   │
                                   ▼
                 خطاف Order::updated الموجود أصلًا
                  ├─ مزامنة شاشات التأكيد/التوصيل   (قائم — لم يتغير)
                  ├─ طلب تقييم الطلب عند التسليم     (قائم — لم يتغير)
                  └─ [جديد] إن كان LOGISTIC_ENABLED ► حدث OrderDeliveryStatusChanged
                                   │  (try/catch: لا يكسر تغيير الحالة أبدًا)
                                   ▼
                 المستمع QueueOrderForLogistic
                  1. هل الحالة الجديدة confirmed؟            لا ► لا شيء
                  2. هل الطلب مسموح (HandoverPolicy)؟         لا ► لا شيء
                  3. صف في logistic_outbox (مفتاح create-<رقم>)  موجود مسبقًا ► لا شيء
                  4. صف في logistic_order_links بحالة pending
                                   │
              ┌────────────────────┴─────────────────────┐
              ▼                                          ▼
   بعد إرسال الاستجابة (AfterResponse)            كل دقيقة: logistic:send (الجدولة)
   محاولة فورية لإرسال هذا الصف                   يلتقط ما لم يُرسَل أو فشل
              └────────────────────┬─────────────────────┘
                                   ▼
                 OutboxSender: يحجز الصف بـ UPDATE واحد (لا إرسال مزدوج)
                  ├─ الطلب ما زال confirmed؟  لا ► يُنهى الصف بدون إرسال
                  ├─ OrderPayloadMapper يبني الجسم   فشل ► يُوقَف بسبب واضح
                  ├─ LogisticClient: POST /orders  (توقيع + Idempotency-Key)
                  └─ تفسير الرد: نجاح / إعادة محاولة / إيقاف / تعليق
```

### 9.2 القرارات التي طبّقتُها (الافتراضات) وكيف تغيّرها

| # | القرار | ما طبّقتُه | أين يتغيّر |
|---|---|---|---|
| D1 | متى يُرسَل الطلب | عند أول مرة تصبح فيها الحالة `confirmed` (وهي نفس لحظة انتقاله إلى شاشة "توصيل الطلبات") | الخطّاف والمستمع |
| D2 | إحداثيات الاستلام | لا يوجد أي متجر لديه إحداثيات، فأضفتُ جدول `logistic_branches` وأمر `logistic:branch:set` ليُدخلها فريق التشغيل | `logistic:branch:set` |
| D3 | إحداثيات التسليم | نقطة الطلب نفسها إن وُجدت، وإلا **مركز المحافظة** مع تنبيه "الموقع تقريبي" في ملاحظة الطلب | جدول `logistic_area_centroids` |
| D4 | مبلغ التحصيل | `round(grand_total × سعر الصرف)` + الشحن **مرة واحدة** لكل عملية شراء. سعر الصرف يُقرأ من جدول `currencies` الحقيقي (150 اليوم)، لا من رقم ثابت | إعدادات العملات في المتجر |
| D5 | تعدد البائعين | طلب لوجستك واحد لكل طلب ويب، والشحن يُضاف للطلب الأول فقط | `OrderPayloadMapper` |
| D6 | من يُسمح له | **لا أحد** افتراضيًا. تفعيل التكامل لا يرسل شيئًا حتى تسمّي بائعًا أو طلبًا | `LOGISTIC_ALLOW_SELLERS` / `LOGISTIC_ALLOW_ORDER_IDS` / `LOGISTIC_ALLOW_ALL` |
| — | طلبات استلام من نقطة / ناقل خارجي (`shipping_type`) | لا تُرسَل أبدًا (ليست توصيلًا منزليًا) | `HandoverPolicy` |

> ⚠️ إحداثيات الاستلام التي استخدمتُها في الاختبار الحي **إحداثيات تطوير وهمية** (مركز دمشق) أُدخلت ثم حُذفت. قبل التشغيل الحقيقي لا بد من إحداثيات حقيقية لكل متجر نشط (67 متجرًا).

### 9.3 كيف ضمنتُ ألا ينكسر الكود القائم

**هذه أول خطوة تلمس ملفات موجودة. التعديلات ثلاثة فقط، كلها إضافة (28 سطرًا مضافًا، 0 محذوفًا، 0 معدَّلًا):**

| الملف | ما أُضيف | لماذا آمن |
|---|---|---|
| `app/Models/Order.php` (+15 سطرًا) | كتلة واحدة داخل خطّاف `updated` الموجود، **بعد** مزامنة المتابعة وطلب التقييم القائمين، تحت شرط `config('logistic.enabled')` ومحاطة بـ `try/catch` + `report()` | مع العلم مُطفأ تكلفتها مقارنة واحدة؛ ولا يمكن أن تُفشل تغيير الحالة؛ ولا تغيّر ترتيب ما كان يعمل |
| `app/Providers/EventServiceProvider.php` (+7) | ربط الحدث بمستمع واحد | لا يُطلَق الحدث أصلًا والعلم مُطفأ |
| `app/Console/Kernel.php` (+6) | جدولة `logistic:send` كل دقيقة بـ `withoutOverlapping` | الأمر لا يفعل شيئًا والعلم مُطفأ |

**ضمانات إضافية:**
- **لا HTTP داخل الطلب:** المستمع يكتب صفًّا فقط؛ الإرسال بعد الرد أو من الجدولة. تعطّل لوجستك لا يبطئ شاشة المدير.
- **الفصل:** كل المنطق في مجلدات جديدة (`Events/Logistic`, `Listeners/Logistic`, `Models/Logistic`, `Services/Logistic`, `Console/Commands/Logistic`). النموذج `Order` يعرف سطرًا واحدًا فقط: "أطلق حدثًا".
- **القياس (على قاعدة البيانات الحقيقية):** تغيير الحالة ينفّذ 6 استعلامات؛ مع التكامل يصبح 10، **الأربعة الإضافية كلها على جداول `logistic_*`** ولا استعلام إضافي على جداول المتجر. ومع التكامل مُطفأ: صفر استعلام إضافي.
- **اختبار ترتيب الكود:** اختبار يقرأ `Order.php` ويتحقق أن كتلتنا بعد الكتلتين القائمتين وتحت الشرط وداخل try/catch، فلا يغيّرها أحد لاحقًا بالخطأ.

### 9.4 الملفات الجديدة — ماذا يفعل كل ملف

#### أ) الحدث والمستمع (نقطة الدخول)

| الملف | الشرح |
|---|---|
| `app/Events/Logistic/OrderDeliveryStatusChanged.php` | حدث بسيط: "تغيّرت حالة التوصيل من X إلى Y" ومعه الطلب. **لا يقرر شيئًا** — القرار للمستمعين. بهذا لا يحتاج نموذج `Order` لأي تعديل جديد عند إضافة الإلغاء والدفع لاحقًا |
| `app/Listeners/Logistic/QueueOrderForLogistic.php` | يفعل أقل ما يمكن داخل طلب المدير: يتحقق من الحالة والسماح ثم يُدخل صفًّا في الطابور وصف الربط. **لا يبني الجسم ولا يتصل بالشبكة.** السبب: بناء الجسم يحتاج استعلامات كثيرة وقد يفشل (إحداثيات ناقصة) ويجب ألا يكتشف المدير ذلك عند الضغط؛ وبناؤه وقت الإرسال يسمح بإصلاح البيانات وإعادة الإرسال دون إعادة تأكيد الطلب |

#### ب) النماذج (جداول التكامل)

| الملف | الشرح |
|---|---|
| `app/Models/Logistic/LogisticOutbox.php` | صف = "شيء ما زال يجب إرساله". المفتاح `idempotency_key` (مثل `create-5278`) **فريد**: ضغطة مزدوجة أو إعادة تأكيد تصطدم بالمفتاح فلا ينتج صف ثانٍ، ولا طلب ثانٍ في لوجستك |
| `app/Models/Logistic/LogisticOrderLink.php` | ربط طلب المتجر بطلب لوجستك وحالته (`pending / created / needs_attention`) مع سبب الإيقاف إن وُجد. طلب متجر واحد = ربط واحد على الأكثر |
| `app/Models/Logistic/LogisticBranch.php` | نقطة استلام المتجر (إحداثيات). يملأها فريق التشغيل |
| `app/Models/Logistic/LogisticAreaCentroid.php` | مركز كل محافظة، بديل نقطة التسليم عند غيابها |

#### ج) الخدمات (قلب العمل)

| الملف | الشرح |
|---|---|
| `Services/Logistic/HandoverPolicy.php` | **من يُسمح له؟** مكان واحد للقاعدة. الافتراضي لا أحد. يستبعد طلبات الاستلام من نقطة والناقل الخارجي دائمًا |
| `Services/Logistic/OrderPayloadMapper.php` | **يترجم طلب المتجر إلى جسم `POST /orders`.** قراءة فقط، لا كتابة ولا شبكة. يرفض ما لا يستطيع ترجمته بسبب واضح بدل التخمين. يستخدم استعلامات مباشرة بدل نماذج `Product/Shop` لأنها تحمّل علاقات وتحمل خطّافات جانبية |
| `Services/Logistic/OutboxSender.php` | **المرسِل.** يحجز الصف بـ UPDATE واحد فلا يرسله مرسِلان معًا، ويرسل فقط إن كان الطلب ما زال `confirmed`، ويفسّر كل رد ممكن (انظر 9.7). لا يرمي استثناءً أبدًا إلى الطلب الجاري |
| `Services/Logistic/MoneyConverter.php` | **يحوّل الدولار إلى ليرة.** المتجر يخزّن المبالغ بالدولار ويحوّل عند العرض فقط؛ إرسال الرقم المخزَّن كان سيجعل الكابتن يحصّل 18 ليرة بدل 2700. يقرأ سعر الصرف من جدول `currencies` فيتبع تغييرات المتجر، ويرجع للإعداد `LOGISTIC_SYP_RATE` إن غاب الصف |
| `Services/Logistic/PhoneNormalizer.php` | **يوحّد الهاتف إلى `+9639…`.** لوجستك لا تضيف رمز الدولة، وبيانات المتجر خليط (`+963…`, `09…`, `963…`, `00963…`). أي رقم لا يستطيع الجزم به **يُرفض** (لا يُرسَل رقم خاطئ لكابتن) |
| `Services/Logistic/ArabicText.php` | **يجعل تهجئتين لنفس الكلمة متساويتين** (ة↔ه، أ/إ/آ→ا، ى→ي، إزالة التشكيل). ضروري لأن بيانات الطلبات تحتوي `حماة` و`حماه`، `اللاذقية` و`اللاذقيه`، `الحسكة` و`الحسكه`، `إدلب` و`ادلب`، وبدونه تضيع محافظات كاملة |
| `Services/Logistic/ShopBranchResolver.php` | **أين يستلم الكابتن؟** إحداثيات المتجر نفسه إن وُجدت، وإلا جدول `logistic_branches`، وإلا يرفض ويذكر رقم المتجر والأمر المطلوب |
| `Services/Logistic/DropoffLocator.php` | **أين يسلّم؟** نقطة الطلب (`lat_lang`) إن كانت صالحة، وإلا مركز المحافظة مع علم "تقريبي"، وإلا يرفض. تُهمَل النقاط الفاسدة (`0,0` أو خارج الكرة الأرضية). لم أستخدم عناوين العميل المحفوظة لأن فحص البيانات أثبت أنها نفس الطلبات الـ104 التي لديها نقطة أصلًا |
| `Services/Logistic/MappingException.php` | "الطلب غير جاهز بعد" بسبب برمجي ثابت (`pickup_location_missing`, `customer_phone_invalid`…) ليظهر في الأوامر ويُصلَح |

#### د) الأوامر التشغيلية

| الأمر | الاستخدام |
|---|---|
| `php artisan logistic:send [--limit=20] [--order=ID]` | يرسل ما في الطابور. تشغّله الجدولة كل دقيقة. لا يفعل شيئًا والتكامل مُطفأ. آمن للتشغيل مرتين معًا |
| `php artisan logistic:branch:set <shop_id> --lat= --lng= [--name=] [--address=]` | يحدد نقطة استلام متجر. يرفض الإحداثيات المستحيلة و`0,0`. إعادة التشغيل تستبدل النقطة |
| `php artisan logistic:branches [--missing] [--days=30]` | يعرض النقاط المسجّلة، أو (`--missing`) المتاجر التي لها طلبات حديثة وبلا نقطة — **قائمة عمل التشغيل قبل التفعيل** |
| `php artisan logistic:retry <order_id>` | يعيد طلبًا موقوفًا (`needs_attention`) إلى الطابور بعد إصلاح بياناته. يرفض طلبًا سُلّم فعلًا (حتى لا يتكرر) |
| `php artisan logistic:status` (مُحدَّث) | صار يعرض الطلبات الموقوفة **مع سبب كل واحد** |

#### هـ) قاعدة البيانات

| الملف | الشرح |
|---|---|
| `database/migrations/2026_10_05_000020_create_logistic_branch_and_area_tables.php` + `sqlupdates/kapitano_logistic_integration_step2_2026-10-05.sql` | جدولان جديدان بلا أي علاقة بجداول المتجر، ومعهما 14 محافظة (مفاتيحها مُطبَّعة مسبقًا بنفس دالة `ArabicText`). نفس الأسلوب المتَّبع في المشروع (هجرة + ملف SQL مطابق، وإعادة التشغيل لا تغيّر شيئًا). طبّقتُ ملف SQL على قاعدة التطوير مرتين |

#### و) الإعدادات (`config/logistic.php`، مضافة إليه)

`LOGISTIC_ALLOW_ALL`، `LOGISTIC_ALLOW_SELLERS`، `LOGISTIC_ALLOW_ORDER_IDS` (من يُسمح له) • `LOGISTIC_SEND_AFTER_RESPONSE` (محاولة فورية بعد الرد) • `retry_schedule` = 30 ث، 2 د، 10 د، 30 د، ساعة، 3 ساعات (مع تشويش ±20% حتى لا تنطلق كل المحاولات في ثانية واحدة بعد انتهاء عطل) • `LOGISTIC_CLAIM_TTL` • `LOGISTIC_SYP_RATE` (احتياطي) • `LOGISTIC_SAFE_NAME` / `LOGISTIC_SAFE_PHONE`.

### 9.5 قواعد التحويل (طلب المتجر ← جسم لوجستك)

| حقل لوجستك | المصدر | القاعدة |
|---|---|---|
| `external_order_id` | `orders.id` | `orders.code` ليس فريدًا فيُستخدم للعرض فقط (`external_order_number`) |
| `customer.phone` | `shipping_address.phone` | يُطبَّع إلى `+963…`؛ في **الوضع الآمن** يُستبدل برقم تجريبي (لكن الرقم الحقيقي يُفحَص أولًا) |
| `customer.name` | `shipping_address.name` | وإلا اسم الحساب؛ في الوضع الآمن يُستبدل |
| `pickup.*` | متجر البائع | انظر `ShopBranchResolver` |
| `dropoff.address` | العنوان + المدينة + المحافظة | الملاحظات تصبح `details.landmark` |
| `dropoff.lat/lng` | `lat_lang` ثم مركز المحافظة | التقريبي يُنبَّه عليه في الملاحظة |
| `payment` | `payment_type` / `payment_status` | الدفع عند الاستلام وغير مدفوع ← `cash_on_delivery` مع `amount_to_collect`؛ مدفوع (أو دفع إلكتروني مدفوع) ← `prepaid` بلا مبلغ؛ **دفع إلكتروني غير مدفوع ← يُوقَف** (غير جاهز للتوصيل) |
| `amount_to_collect` | `grand_total` (الصافي بعد الكوبون) + الشحن للطلب الأول | × سعر الصرف، ليرة صحيحة |
| `items[].unit_price` | `order_details.price ÷ quantity` | `price` هو **إجمالي السطر** وليس سعر القطعة؛ ثم × سعر الصرف |
| `items[].sku` / `variant` | `product_stocks` / `variation` | اختياريان |
| `currency` | — | دائمًا `SYP` |

### 9.6 مثال حقيقي من الاختبار الحي

طلب قيمته 9.00 دولار (قطعتان، 4.50 للقطعة) دفع عند الاستلام، متجر بإحداثيات، وللطلب نقطة تسليم:

- خزّنته لوجستك: الحالة `pending`، المصدر `store_api`، `amount_to_collect = 1350` ليرة، الوحدة `675` ليرة للقطعة × 2، الهاتف **`+963900000000`** والاسم **`عميل تجريبي`** (الوضع الآمن)، وملاحظة تحتوي `SAFE MODE`.
- الهاتف الحقيقي للطلب (`0944000111`) **لا يظهر في أي مكان** مما خُزِّن في لوجستك (فُحص نصيًا).

### 9.7 ماذا يحدث مع كل رد من لوجستك

| الرد | الفعل |
|---|---|
| 201 / 200 (حتى `Idempotency-Replayed`) | الصف `done`، ويُخزَّن `uuid` ورقم طلب لوجستك في الربط |
| انقطاع اتصال / 429 / 5xx | يعود `pending` ويُجدوَل بتأخير (30 ث، 2 د، 10 د، 30 د، 1 س، 3 س) مع تشويش؛ بعد استنفاد المحاولات يُوقَف `retries_exhausted` |
| 422 أو أي 4xx آخر | **يُوقَف** (`needs_attention`) مع أخطاء الحقول؛ إعادة نفس الطلب لن تغيّر الجواب |
| 409 مكرر | يُوقَف (تبنّي الطلب الموجود هو الخطوة 3). عمليًا لا يحدث: المفتاح ثابت فتعود لوجستك بنفس الجواب الأول |
| 401 / 403 `client_inactive` / `ip_not_allowed` / 503 `integration_paused` | **ليس خطأ الطلب**: يعود الصف كما كان **بدون استهلاك محاولة**، وتتوقف الدفعة كلها (كل صف سيحصل على نفس الجواب) |
| إعدادات ناقصة | يُحرَّر الصف (لا يبقى "sending") ويُسجَّل اسم المفتاح الناقص |
| خطأ غير متوقع (مثل عطل قاعدة بيانات) | يُحرَّر الصف ويُعاد بعد دقيقة |
| الطلب لم يعد `confirmed` عند الإرسال (أُلغي، أُلغي تأكيده، أو سُلِّم داخليًا) | **لا يُرسَل**؛ الصف يُنهى بملاحظة السبب — حتى لا يُنشأ توصيل لا يريده أحد |
| مرسِل مات أثناء الإرسال | يُستعاد الصف بعد `claim_ttl` ثانية؛ ومرسِل متأخر لا يستطيع الكتابة فوق نتيجة المالك الجديد (التحديث مشروط برمز الحجز) |

### 9.8 كيف تفعّل التكامل بأمان (للتجربة)

في `.env` الخاص بكابيتانو ويب (لا يُرفَع إلى git):

```
LOGISTIC_ENABLED=true
LOGISTIC_SAFE_MODE=true              # يبقى مفعّلًا ما دامت القاعدة نسخة من بيانات حقيقية
LOGISTIC_ALLOW_ORDER_IDS=5300        # أو LOGISTIC_ALLOW_SELLERS=122
```

ثم: `php artisan logistic:status` ثم أكّد الطلب المسموح من شاشة المدير ← يظهر في لوجستك خلال ثانية. ويجب أن يعمل cron الجدولة (`php artisan schedule:run` كل دقيقة، وهو يعمل أصلًا للأوامر الأخرى في المشروع) لأن المشروع **لا يشغّل queue worker**.

للتوسّع التدريجي: بائع واحد ← عدة بائعين ← `LOGISTIC_ALLOW_ALL=true`. للإيقاف الفوري: `LOGISTIC_ENABLED=false` (الصفوف المعلّقة تبقى وتُستأنف عند إعادة التفعيل).

### 9.9 الاختبارات

#### أ) الاختبارات الآلية (معزولة، لا تمسّ بياناتك): **195 اختبارًا، كلها ناجحة**

| الملف | عدد الاختبارات | ماذا يثبت |
|---|---|---|
| `Unit/Logistic/SignerTest` | 6 | التوقيع مطابق لما تحسبه لوجستك نفسها |
| `Unit/Logistic/PhoneNormalizerTest` | 20 | كل صيغة هاتف موجودة في البيانات، وما يجب رفضه |
| `Unit/Logistic/ArabicTextTest` | 12 | تهجئات المحافظات الفعلية تتطابق |
| `Feature/Logistic/LogisticClientTest` | 20 | العميل (الخطوة 1) |
| `Feature/Logistic/LogisticCommandsTest` | 10 | أوامر الصحة والحالة (الخطوة 1) |
| `Feature/Logistic/LogisticTablesTest` | 8 | جداول الخطوة 1 |
| `Feature/Logistic/LogisticStep2TablesTest` | 8 | جداول الخطوة 2، المحافظات الـ14، تطابق ملف SQL مع الهجرة، وكون النقاط داخل سوريا |
| `Feature/Logistic/MoneyConverterTest` | 7 | الدولار ← ليرة، التقريب، اتباع إعدادات المتجر، الاحتياطي |
| `Feature/Logistic/OrderPayloadMapperTest` | 32 | كل قاعدة تحويل وكل سبب إيقاف |
| `Feature/Logistic/QueueOrderForLogisticTest` | 16 | الخطّاف والمستمع: مُطفأ لا يفعل شيئًا، مفعّل يُدخل صفًّا واحدًا، تأكيد مرتين لا يكرّر، وفشل التكامل **لا يكسر تغيير الحالة** |
| `Feature/Logistic/OutboxSenderTest` | 32 | كل رد ممكن، الحجز، المرسِل الميت، الجدول الزمني، التشويش، الاستعادة |
| `Feature/Logistic/LogisticStep2CommandsTest` | 20 | الأوامر التشغيلية وحالات رفضها |
| `Feature/Logistic/TouchPointsTest` | 4 | نقاط اللمس الثلاث بقيت صغيرة ومحروسة وبالترتيب الصحيح، والافتراضات المشحونة "مُطفأ ولا أحد" |

> اختبار أي وحدة يجري على قاعدة SQLite في الذاكرة (إعداد `phpunit.xml` القائم) فلا يلمس بياناتك. استخدمتُ `Http::preventStrayRequests()` فأي استدعاء شبكة غير متوقع يُفشل الاختبار بدل أن يصل لخادم حقيقي.

#### ب) الاختبار الحي (E2E) مقابل الخوادم الحقيقية: `php tests/Live/logistic_step2_e2e.php`

يشغّل **كود المتجر الحقيقي ومخططه الحقيقي** (قاعدة MySQL الفعلية بكل جداولها، بما فيها شاشات المتابعة) و**لوجستك يعمل فعليًا** (مع Redis وقاعدته)، بطلبات اختبارية موسومة `E2E-…` تُحذف في النهاية. يرفض العمل إلا إذا كانت القاعدتان على هذا الجهاز والوضع الآمن مفعّل.

| السيناريو | النتيجة |
|---|---|
| 0 فحص صحة موقَّع | ✅ |
| 1 تأكيد طلب بنقطة دقيقة عبر الخطّاف الحقيقي | ✅ الطلب يصل لوجستك بكل حقوله صحيحة (الهاتف مموَّه، النقطتان، 1350 ليرة، سطر 2×675، SAFE MODE)، **ومنطق المتجر القائم (شاشات التأكيد/التوصيل) ما زال يعمل** |
| 2 رد ضائع: إعادة إرسال نفس الصف | ✅ طلب واحد فقط في لوجستك، بنفس الـ uuid |
| 3 إلغاء التأكيد ثم إعادته | ✅ لا صف ثانٍ ولا طلب ثانٍ |
| 4 بلا نقطة تسليم + محافظة بتهجئة مختلفة (حماه) | ✅ مركز حماة + تنبيه "الموقع تقريبي" |
| 5 متجر بلا نقطة استلام ← يُوقَف بسبب واضح ← تُضاف النقطة ← `retry` ← يُنشأ | ✅ |
| 6 التكامل مُطفأ | ✅ لا صف، لا ربط، لا شيء في لوجستك |
| 7 لوجستك غير متاح | ✅ **تغيير الحالة نجح**، الصف ينتظر ومحاولة واحدة مسجَّلة، ولا يُعاد قبل موعده، ثم يُرسَل عندما يعود |
| 8 بيانات اعتماد مرفوضة | ✅ لا تُستهلك محاولة، السبب `paused:Unauthorized`، ثم يُرسَل بالرمز الصحيح |
| 9 عملية شراء واحدة بطلبين | ✅ الأول (5+2 شحن)×150 = 1050، الثاني 750 (الشحن مرة واحدة) |
| 10 دفع إلكتروني | ✅ المدفوع prepaid بلا مبلغ؛ **غير المدفوع يُوقَف** |
| 11 كلفة تغيير الحالة | ✅ 6 استعلامات ← 10 (4 إضافية على جداول التكامل فقط)؛ ومع الإطفاء صفر |
| التنظيف | ✅ كل جداول المتجر عادت إلى أرقامها الأصلية بالضبط (3515 / 4447 / 4043 / 6471 / 22757 / 29) |

**النتيجة النهائية: 61 فحصًا، 0 فاشل — وثلاث مرات متتالية على نفس النتيجة.**

### 9.10 أمور صادفتُها وكيف تعاملتُ معها (شفافية كاملة)

1. **فشل تنظيف الاختبار الحي في أول تشغيل:** استخدمتُ اسم عمود خاطئ (`follow_up_id` بدل `order_follow_up_id`) فتوقّف التنظيف وبقيت بيانات الاختبار الوهمية في قاعدة التطوير. اكتشفتُه من المخرجات، وأزلتُ بقاياها فورًا بمعاملة SQL واحدة بحسب العلامات (`E2E-%`)، وتحققتُ من الأرقام (عادت للأساس تمامًا)، ثم جعلتُ تنظيف السكربت قائمًا على العلامات فيعمل حتى لو مات التشغيل في منتصفه.
2. **فشلان في تشغيلات الاختبار الحي كانا عيبين في السكربت لا في الكود:** (أ) `Artisan::output()` تُفرغ مخزنها عند قراءتها، فقراءتها مرتين تُرجع فراغًا في الثانية؛ (ب) فحص زمني بحد 25 مللي ثانية غير مستقر لأن زمن الجهاز يتأرجح للضعف. استبدلتُ الثاني بعدّ الاستعلامات (حتمي) وأبقيتُ الزمن للمعلومة فقط.
3. **اسم دالة `link()`:** دالة مدمجة في PHP، تعارضت مع اسم مساعدي فأعدتُ تسميته.
4. **لم أشغّل إجراء "تحديث حالة التوصيل" الإداري كاملًا في اختبار معزول:** لأنه يستدعي البريد والرسائل والإشعارات وجداول كثيرة، حتى اختبارات المشروع نفسها (`OrderFollowUpTest`) تتجاوزه عمدًا. الدليل البديل: اختبار الخطّاف على الحفظ الحقيقي للنموذج + الاختبار الحي على المخطط الحقيقي. **ولم أشغّله على قاعدة التطوير** لأنه قد يرسل رسائل نصية وبريدًا لرقم الطلب.
5. **8 طلبات اختبار ملغاة بقيت في قاعدة لوجستك** (حالتها `cancelled`، وهي مسجَّلة للمتجر المحلي). لم أحذفها من نظام آخر؛ وأزلتُ فقط مهام الطابور التي سبّبتها تنظيفاتي (إشعارات بث لوحة التحكم، وإشعار إلغاء للكابتن الذي يعود فورًا لأن الطلبات غير مُسندة).
6. **لوجستك تُنشئ مهام بث للوحة التحكم عند كل طلب** (`BROADCAST_CONNECTION=reverb` وReverb غير مشغَّل محليًا). لم يؤثر ذلك على قبول الطلب (البث مؤجَّل في الطابور)، لكن إن شغّلتَ `queue:work` على لوجستك بينما Reverb متوقف فستفشل تلك المهام فقط.
7. **اختبارات كابيتانو ويب الفاشلة مسبقًا (74)** لم تتغير؛ انظر القسم 4 ونتيجة التشغيل الكامل أدناه.
8. **عند الـcommit** (لم أُجرِ أي commit في أي من المشروعين): ملفا SQL يحتاجان `git add -f` لأن `.gitignore` في كابيتانو ويب يتجاهل `*.sql`:
   `git add -f sqlupdates/kapitano_logistic_integration_2026-10-05.sql sqlupdates/kapitano_logistic_integration_step2_2026-10-05.sql`
9. **ما زال يعمل في الخلفية على جهازك:** خادم لوجستك على المنفذ 8001 (شغّلتُه للاختبار الحي) وRedis الموجود أصلًا. أوقف الخادم متى شئت؛ لا شيء في كابيتانو ويب يتطلبه والتكامل مُطفأ.

### 9.11 المخاطر والحدود المعروفة (لا تُنسى قبل التشغيل الحقيقي)

| الأمر | التفصيل |
|---|---|
| **إحداثيات الاستلام** | بدونها كل طلب متجر يُوقَف. المطلوب: قائمة (رقم المتجر، الاسم، العنوان، خط العرض، خط الطول) للمتاجر الـ67 النشطة. الأداة جاهزة: `logistic:branches --missing` ثم `logistic:branch:set` |
| **الموقع التقريبي** | نحو **92%** من الطلبات بلا نقطة تسليم، فسيصل أغلبها بمركز المحافظة. السعر والزمن المتوقع في لوجستك يعتمدان على هذه النقطة، لذلك الملاحظة تنبّه المُرسِل والكابتن. الحل الأدق لاحقًا: Geocoding أو حقل موقع إلزامي في الطلب |
| **سعر الصرف** | يُقرأ من جدول العملات (150 الآن). يجب تأكيد أنه سعر التحصيل الفعلي، لأنه يحدد ما يدفعه العميل نقدًا |
| **الإرسال بعد الرد** | يعمل داخل نفس عملية PHP بعد إرسال الرد، فيشغل عاملًا (worker) لثوانٍ إن كان لوجستك بطيئًا. المهلة القصوى 15 ثانية (3 اتصال). يمكن إيقافه (`LOGISTIC_SEND_AFTER_RESPONSE=false`) فيعتمد التوصيل على جدولة الدقيقة |
| **لا queue worker** | الاعتماد على `schedule:run` كل دقيقة. إن توقف الـcron تتأخر الطلبات التي فشلت محاولتها الفورية |
| **ما لم يُنفَّذ بعد** | الإلغاء (3)، تبنّي الطلب الموجود عند 409 (الخطوة 3)، استقبال تحديثات الحالة (الويبهوك)، الدفع المتأخر، تعديل الطلب |

### 9.12 نتيجة تشغيل مجموعة اختبارات كابيتانو ويب الكاملة بعد الخطوة 2

شغّلتُ مجموعة اختبارات كابيتانو ويب **كاملة** مرتين بسجل JUnit (بعد الخطوة 1، ثم بعد الخطوة 2) وقارنتُ **كل اختبار على حدة**:

| | بعد الخطوة 1 | بعد الخطوة 2 |
|---|---|---|
| **اختبارات المشروع القائمة** (كل ما ليس من عملي) | 1041: ناجح 837، فاشل 74، متخطّى 130 | 1041: ناجح 837، فاشل 74، متخطّى 130 |
| **اختبارات التكامل (من عملي)** | 44 كلها ناجحة | **195 كلها ناجحة** |

- **عدد الاختبارات القائمة التي تغيّرت نتيجتها: صفر.** الـ74 فاشلة هي نفسها (لوحة البائع وOTP وغيرها، موجودة قبل عملي كما أثبتُّ في القسم 5).
- أي أن الخطوة 2، رغم لمسها ثلاثة ملفات قائمة، **لم تغيّر نتيجة أي اختبار موجود**.

### 9.13 كيف تتراجع عن الخطوة 2 (إن أردت)

| ما تريد التراجع عنه | الطريقة |
|---|---|
| إيقاف التكامل فورًا | `LOGISTIC_ENABLED=false` في `.env` (لا شيء آخر مطلوب) |
| إزالة التعديلات الثلاثة على الملفات القائمة | احذف الكتلة المضافة من `Order.php` (15 سطرًا)، والسطور السبعة من `EventServiceProvider.php`، والستة من `Kernel.php`. لا يعتمد عليها شيء آخر |
| إزالة الجداول | `DROP TABLE logistic_branches, logistic_area_centroids;` (جداول الخطوة 1: `logistic_outbox, logistic_order_links`) |
| إزالة الكود الجديد | احذف المجلدات `Events/Logistic`, `Listeners/Logistic`, `Models/Logistic`, `Services/Logistic`, `Console/Commands/Logistic`, والملف `config/logistic.php` والاختبارات |

---

## 10. الحالة الآن، وما أحتاجه منك

**الحالة:** الخطوات 0 و1 و2 مكتملة وموثَّقة ومختبَرة (195 اختبارًا آليًا + 61 فحصًا حيًّا × 3 تشغيلات). **متوقف عند بوابة الخطوة 2.** التكامل مُطفأ والمتجر يعمل كما كان.

**للخطوة 3 (القراءة والمطابقة وتبنّي الطلب الموجود) لا أحتاج قرارات جديدة.** لكن قبل **تشغيل حقيقي** (وليس قبل الخطوة 3) لا بد من:

1. إحداثيات الاستلام للمتاجر الـ67 النشطة.
2. تأكيد سعر الصرف 150 كسعر التحصيل الفعلي.
3. موافقتك على أن 92% من الطلبات ستصل بموقع تقريبي في البداية، أو اختيار حل أدق.
4. تحديد أول بائع تجريبي حقيقي (`LOGISTIC_ALLOW_SELLERS`).

قل "تابع" لأبدأ الخطوة 3 (بعد مراجعة القسم 11).

---

## 11. إلزام المتجر باتباع سواجر (OpenAPI) لوجستك

**الطلب:** "أجبِر المتجر على اتباع سواجر تكامل الطلبات في لوجستك قبل الانتقال للخطوة التالية".

### 11.1 الفكرة

لوجستك **يتجاهل بصمت** أي حقل غير معروف (مثلًا `customer.phon` بدل `phone`): يرد 201 ويُنشئ الطلب **بدون** الهاتف، ولا يعلم أحد. لذلك لا يكفي أن "نكتب الكود صحيحًا"؛ يجب أن **يرفض المتجر نفسه** أي طلب يخالف السواجر **قبل خروجه من الشبكة**.

لهذا صار عقد لوجستك **نسخة مثبَّتة داخل المتجر**، و`LogisticClient` (الكلاس الوحيد الذي يكلّم لوجستك) يفحص كل طلب عليها **قبل التوقيع والإرسال**. لا يمكن لأي استدعاء (الإنشاء اليوم، الإلغاء والدفع لاحقًا) أن يتجاوزه، والعقد وسيط **إجباري** في الباني فلا يمكن إنشاء عميل بدونه.

### 11.2 ما الذي يُفحص

| الفحص | النتيجة عند المخالفة |
|---|---|
| العملية موجودة في السواجر (method + path) | يُرفض ("not in the Logistic contract") |
| الجسم يطابق المخطط **بصرامة**: النوع، `required`، `enum`، الحدود، الطول، الصيغة | يُرفض ويُذكر المسار الدقيق مثل `items.0.quantity: must be an integer` |
| **حقل غير موجود في السواجر** (خطأ إملائي أو حقل غير موثّق مثل `needs_wrapping`) | يُرفض (`is not in the contract`) |
| الترويسات الإلزامية (مثل `Idempotency-Key` في الكتابة) | يُرفض |
| قواعد مكتوبة بالنص فقط (مثل: الدفع عند الاستلام يتطلب `amount_to_collect`) | يُرفض (في `BusinessRules`) |
| كلمة مفتاحية في السواجر لا يفهمها المدقّق | يُرفض (نفشل بأمان بدل أن نتجاهل) |

عند الرفض: **لا يُبنى الطلب ولا يُوقَّع ولا يُرسل**، ويُركَن صف الصندوق (outbox) بحالة `contract_violation` مع سبب واضح، بدل إعادة المحاولة بلا جدوى.
**ردود لوجستك** تُفحص أيضًا لكن بتساهل: الفرق لا يغيّر نتيجة الطلب (قد يكون أُنشئ فعلًا) بل يُسجَّل تحذيرًا `[logistic] the response does not match the contract` لنعرف مبكرًا إن غيّر لوجستك شيئًا.

صمّام طوارئ: `LOGISTIC_CONTRACT_ENFORCE=false` يحوّل الرفض إلى تحذير في السجل مع الإرسال (للطوارئ فقط، وليس وضع تشغيل).

### 11.3 الملفات الجديدة

| الملف | وظيفته |
|---|---|
| `resources/logistic/integration-openapi.json` | نسخة السواجر المثبَّتة (من لوجستك الحي، 7 عمليات) |
| `resources/logistic/integration-openapi.lock.json` | قفل: `file_sha256` (كشف العبث بالملف) و`contract_sha256` (كشف انجراف لوجستك؛ يتجاهل `servers`) |
| `Services/Logistic/Contract/OpenApiContract.php` | يحمّل النسخة المثبَّتة ويتحقق من القفل (يفشل بأمان إن غاب أو تلف) ويفحص الطلبات والردود |
| `Services/Logistic/Contract/SchemaValidator.php` | مدقّق JSON Schema المطلوب فقط، صارم في الطلبات |
| `Services/Logistic/Contract/BusinessRules.php` | القاعدتان المكتوبتان بالنص فقط في السواجر |
| `Services/Logistic/Contract/SpecPinner.php` | يجلب السواجر الحي ويثبّته ويقارنه |
| `Services/Logistic/Contract/ContractViolationException.php` | الاستثناء الذي يحمل قائمة المخالفات |
| `Console/Commands/Logistic/ContractPinCommand.php` | `logistic:contract:pin` — تثبيت/إعادة تثبيت العقد |
| `Console/Commands/Logistic/ContractCheckCommand.php` | `logistic:contract:check` — يقارن الحي بالمثبَّت (قراءة فقط؛ رموز الخروج 0 متطابق / 1 انجراف / 2 تعذّر الفحص) |
| الاختبارات | `tests/Unit/Logistic/Contract/SchemaValidatorTest.php`, `tests/Feature/Logistic/Contract/{ContractEnforcementTest, PinnedContractTest, MapperConformsToSwaggerTest, ContractCommandsTest}.php` |

### 11.4 تعديلات على ملفات قائمة

- **كابيتانو ويب:** `app/Console/Kernel.php` — إضافة فحص يومي `logistic:contract:check --scheduled` الساعة 06:30 (5 أسطر، إضافة فقط). لا شيء آخر قائم تغيّر في هذه المهمة.
- **لوجستك (جهة الـAPI):** أضفتُ `required` إلى مخططات الردود في `StoreOrderSummary` و`StoreOrderDetail` و`Health` (كانت بلا أي حقول إلزامية، فلم يكن ممكنًا اكتشاف ردّ ناقص `uuid`). أعدتُ توليد السواجر وثبّتُّه. هذا توثيق فقط ولا يغيّر سلوك الـAPI.
- `LogisticClient` (ملف جديد من الخطوة 1) صار يتطلب العقد، و`OutboxSender` يركن الطلب المخالف.

### 11.5 اكتشافات أثناء العمل

1. **تصادم التوقيع:** توقيع لوجستك يغطي الوقت والجسم فقط (ليس المسار ولا الطريقة)، فطلبان بنفس الجسم في الثانية نفسها يحملان التوقيع نفسه ويُرفض الثاني (`signature_replayed`، 401). كل طلبات القراءة جسمها فارغ فتتصادم. اكتشفه الاختبار الحي، وأصلحتُه: العميل يتذكّر آخر ثانية وُقّع فيها كل جسم (بالبصمة) وينتظر بقية الثانية إن تكرر. الأجسام المختلفة لا تنتظر أبدًا.
2. **ذاكرة Idempotency-Key لمدة 24 ساعة:** نفس المفتاح مع جسم مختلف يعطي 409 `duplicate_external_order`؛ لذلك الاختبار الحي يستخدم مفتاحًا لكل تشغيل.
3. **ثغرات في سواجر لوجستك** (موثَّقة ومعالجة بالرفض الصارم): `additional_pickups` و`needs_wrapping` موجودان في كود لوجستك وغير موثَّقين، وقاعدتان موجودتان بالنص فقط (طُبِّقتا في `BusinessRules`).

### 11.6 الاختبارات والنتائج

| الفحص | النتيجة |
|---|---|
| اختبارات التكامل في كابيتانو ويب (Unit + Feature) | **403 اختبارًا، 1056 تأكيدًا — كلها ناجحة** |
| اختبار الطفرات (إفساد التنفيذ عمدًا للتأكد أن الاختبارات تلتقط ذلك) | 57 من 73 طفرة التُقطت؛ الباقي طفرات مكافئة/غير مؤثرة |
| الاختبار الحي مقابل الخوادم الحقيقية (`tests/Live/logistic_step2_e2e.php`، القسم 12 الجديد) | **84 فحصًا، 0 فاشل، 4 تشغيلات متتالية** بعد إصلاح التصادم |
| **مجموعة كابيتانو ويب الكاملة** (مقارنة كل اختبار على حدة بما بعد الخطوة 2) | الاختبارات القائمة: 1041 (ناجح 837، فاشل 74 سابقة، متخطّى 130) — **عدد ما تغيّرت نتيجته: صفر**. اختبارات التكامل: 195 ← **403 كلها ناجحة** |
| اختبارات لوجستك بعد تعديل التوثيق | 249/249 نجحت |
| `logistic:contract:check` الآن | "In sync" (1.0.0) |
| قاعدتا البيانات بعد الاختبارات الحية | نظيفتان (صفوف الاختبار تُحذف بالعلامة) |

أمثلة من القسم 12 الحي: حقل مكتوب خطأ (`customer.phon`)، حقل غير موثّق (`needs_wrapping`)، نوع خاطئ (`quantity: "2"`)، دفع عند الاستلام بلا مبلغ، عملية غير مدرجة، كتابة بلا Idempotency-Key — **كلها رُفضت قبل الشبكة وعدد طلبات لوجستك لم يتحرك**. وبالمقابل أثبتُّ أن لوجستك نفسه يقبل `customer.phon` بصمت (201) عند إطفاء الحارس، وهذا سبب الحارس.

### 11.7 الاستخدام والصيانة

```bash
php artisan logistic:contract:check          # هل ما زال لوجستك كما ثبّتناه؟
php artisan logistic:contract:pin            # بعد مراجعة تغيير مقصود في لوجستك: ثبّت العقد الجديد
```
عند ظهور انجراف: راجع الفرق، حدّث المُحوِّل إن لزم، ثم أعد التثبيت وشغّل الاختبارات.

### 11.8 التراجع

| المطلوب | الطريقة |
|---|---|
| تعطيل الإلزام مؤقتًا | `LOGISTIC_CONTRACT_ENFORCE=false` |
| إزالة فحص الجدولة | احذف سطور `logistic:contract:check` من `Kernel.php` |
| إزالة الميزة | احذف `Services/Logistic/Contract` و`resources/logistic` وأوامر العقد (العميل يحتاج حينها نسخة بلا عقد) |

---

## 12. اختبار حي حقيقي لمسار الطلب كاملًا عبر HTTP (قبل الخطوة 3)

**الطلب:** "شغّل السيرفرات وأنشئ طلبًا في الويب وتأكد أنه أُنشئ في لوجستك".

**الملف:** `tests/Live/logistic_real_flow_e2e.php` — تشغيل: `php tests/Live/logistic_real_flow_e2e.php` (يتطلب لوجستك شغّالًا على 8001 وRedis).

### ماذا يفعل (كل خطوة عبر HTTP حقيقي، لا حقن في قاعدة البيانات)

1. ينشئ بيانات مؤقتة موسومة `e2e-<رقم>@example.test`: بائع + متجر (مع نقطة استلام تجريبية) + منتج (نسخة من منتج حقيقي) + زبون + عنوان بإحداثيات + سلة + موظف بصلاحيتين.
2. يشغّل **سيرفر الويب الحقيقي** على `127.0.0.1:8000` كعملية ابن، ويفعّل التكامل **لهذه العملية فقط** (`LOGISTIC_ENABLED=true` و`LOGISTIC_ALLOW_SELLERS=<البائع المؤقت>`) — ملف `.env` لا يُلمس (ما زال `LOGISTIC_ENABLED=false`).
3. الزبون يطلب عبر `POST /api/v2/order/store` ← الطلب `pending` ولا يُسلَّم للوجستك بعد (تحقق منه).
4. البائع يسجّل الدخول ويؤكد عبر `POST /seller/orders/confirm`، ثم الموظف يسجّل دخول لوحة الإدارة ويؤكد عبر `POST /admin/order-follow-up/confirm` ← الحالة `confirmed`.
5. الخطاف يكتب صف outbox ويرسل إلى لوجستك ← `done` / HTTP 201 / محاولة واحدة.
6. يتحقق في **قاعدة لوجستك** وعبر **Store API** وبالبحث بـ`external_order_id`.
7. ضغط "تأكيد" مرة ثانية لا ينشئ طلبًا ثانيًا في لوجستك ولا صف outbox ثانيًا.
8. التنظيف: يلغي الطلب في لوجستك، يحذف وظائف البث التي ولّدها، ويحذف كل ما أنشأه في الويب؛ ثم يثبت عودة كل الجداول لأعدادها الأصلية.

### النتيجة: **45 فحصًا، 0 فاشل — تشغيلان متتاليان**

مثال من التشغيل: طلب الويب `#5513` (18 دولارًا، دفع عند الاستلام) ظهر في لوجستك برقم `ORD-799226`، حالته `pending`، المصدر `store_api`، الزبون **مُقنَّع** (الوضع الآمن: لا اسم ولا هاتف حقيقيان خرجا)، الاستلام من نقطة المتجر (33.5138, 36.2765)، التسليم عند دبوس الزبون (33.52, 36.30)، التحصيل 18 × 150 = **2700 ل.س**، بند واحد بكمية 2. وStore API أعاده بالـuuid وبالبحث، ومطابقًا لسواجره.

### اكتشافات

- **الأولوية للتأكيد الصحيح:** الطلب لا يُسلَّم إلا عند `confirmed`، والمتجر يشترط تأكيد **البائع أولًا** ثم الموظف ("Awaiting seller confirmation") — الاختبار يمشي المسار الحقيقي.
- واجهة الموبايل تتطلب ترويسة `System-Key`، و`SYSTEM_KEY` غير موجود في `.env` المحلي (أي أن الـAPI غير قابل للوصول محليًا كما هو)؛ الاختبار يعطي سيرفره المؤقت مفتاحًا خاصًا به دون لمس `.env`.
- زمن طلب التأكيد الأول ~3.4 ثانية (سيرفر `php -S` بارد + الاتصال بلوجستك)؛ لا يؤثر على نجاح الإرسال، لكنه يؤكد أن الإرسال متزامن بعد الاستجابة في هذه البيئة.
- في لوجستك تبقى الطلبات التجريبية **ملغاة** (لا تُحذف عمدًا؛ حالتها `cancelled`).


---

## 13. الخطوة 3 — البحث والمطابقة: تبنّي الطلب الموجود + شبكة الأمان `logistic:reconcile`

**الحالة:** منفَّذة ومختبَرة، **متوقفة عند بوابتها بانتظار موافقتك** قبل الخطوة 4 (الإلغاء).

### 13.1 المشكلة التي تحلها

في الخطوة 2، إن رفض لوجستك الإنشاء بـ **409 (مكرر)** كان الصف يُركَن `needs_attention` وينتظر إنسانًا. لكن هذا الرد غالبًا **ليس خطأ**: يعني أن الطلب **موجود فعلًا** في لوجستك؛ وصله طلبنا الأول لكن **ضاع الرد** (انقطاع، انهيار بين الرد والحفظ، أو نسي لوجستك مفتاح الـIdempotency بعد 24 ساعة). تركه معلّقًا = طلب مؤكَّد في المتجر ومُنشأ في لوجستك ولا رابط بينهما.

وهناك مشكلة ثانية: إن **فات الحدث** (نشر الكود، استثناء، تأكيد قبل تفعيل التكامل) فلا يدخل الطلب الطابور أصلًا.

### 13.2 ما بُني

| القطعة | وظيفتها |
|---|---|
| **تبنّي الطلب عند 409** (`OutboxSender::adoptExisting`) | عند 409: يسأل لوجستك `GET /orders?external_order_id=<رقم طلب المتجر>`؛ إن وجد الطلب وكان **حيًّا** يربط الـuuid ورقم الطلب والحالة، ويُنهي الصف `done` بملاحظة `adopted` — **بلا حلقة** |
| **`LogisticLookup`** (خدمة جديدة) | سؤال "هل تملك هذا الطلب؟" عبر العميل نفسه (موقَّع + مفحوص على السواجر). تُعيد: موجود / غير موجود / فشل. يُعاد استخدامها في الخطوات التالية |
| **`HandoverQueue`** (خدمة جديدة) | المكان **الوحيد** الذي يضع الطلب في الطابور (صف outbox + رابط "قيد التسليم"). يستعمله المستمع (`QueueOrderForLogistic`) والمطابِق معًا فلا يختلفان أبدًا |
| **`logistic:reconcile`** | يجد الطلبات المؤكَّدة التي **لم تُوضع في الطابور قط** ويضعها بنفس الطريقة؛ التسليم يتم في دورة `logistic:send` التالية |
| سطر في `Kernel.php` | تشغيل `logistic:reconcile` كل 5 دقائق `withoutOverlapping` (5 أسطر إضافة فقط) |
| إعداد جديد | `LOGISTIC_RECONCILE_WINDOW_HOURS` (الافتراضي 48) |

### 13.3 قواعد السلامة (ما يفعله وما **لا** يفعله)

**التبنّي:**
1. لا يُتبنّى إلا طلب **مرجعه (`external_order_id`) يطابق طلبنا** — رد 200 لا يذكر لمن الطلب لا يُصدَّق.
2. إن كان الطلب في لوجستك **ملغى/فاشل التسليم** (`cancelled` / `delivery_failed`) → **لا يُربط** بل يُركَن `existing_order_is_cancelled` مع رقم الطلب؛ لأن طلب المتجر مؤكَّد ويريد توصيلًا، وربطه بطلب ميّت يتركه بلا توصيل بصمت.
3. 409 لكن لا شيء عند البحث (404) → يُركَن `duplicate_but_not_found` ولا نخمّن.
4. تعذّر السؤال (شبكة/5xx) → يعود الصف للمحاولة لاحقًا (يعيد الإرسال، يأخذ 409، ويسأل من جديد)؛ رفض الاعتماد (401/403) → إيقاف الدفعة دون صرف محاولة.
5. **لا يُنشأ أي طلب ثانٍ أبدًا** في أي سيناريو (الاختبار الحي يعدّ طلبات لوجستك).

**المطابِق `logistic:reconcile`** يلتقط الطلب فقط إن تحققت **كل** الشروط:
- حالته الآن `confirmed` (لا ملغى ولا قيد التوصيل ولا مسلَّم)؛
- تغيّر خلال آخر 48 ساعة (شبكة لحدث فائت، **وليس** لنبش طلبات قديمة)؛
- سياسة التسليم تسمح به (قائمة البائعين/الطلبات المسموح بها، توصيل منزلي فقط)؛
- **لا صف outbox له إطلاقًا** — فأي طلب سبق وُضع أو أُرسل أو رُكن أو تُبنّي **لا يُمس** (يمكن تشغيله كل 5 دقائق بلا تكرار).
وله `--dry-run` (يعرض ما سيفعل دون فعل)، و`--hours`، و`--limit`، وهو لا يفعل شيئًا عند `LOGISTIC_ENABLED=false`.

### 13.4 الملفات

**جديدة:** `app/Services/Logistic/{LogisticLookup,HandoverQueue}.php` · `app/Console/Commands/Logistic/ReconcileCommand.php` · `tests/Feature/Logistic/ReconcileCommandTest.php` · `tests/Live/logistic_step3_e2e.php`

**معدَّلة (ملفات الخطوات السابقة، كلها ضمن التكامل):** `OutboxSender` (نتيجة `ADOPTED` جديدة + التبنّي + فصل معالجة الرفض في `refused()`)، `QueueOrderForLogistic` (صار يستدعي `HandoverQueue` بدل تكرار المنطق — سلوكه مطابق وتغطيه نفس اختباراته)، `SendCommand` (يعرض عدد المتبنّى)، `config/logistic.php`.

**ملف قائم من المتجر لُمس:** `app/Console/Kernel.php` فقط (+5 أسطر، إضافة). مجموع تعديلات الملفات القائمة الآن: **38 سطرًا، 0 حذف** في 3 ملفات.

### 13.5 الاختبارات

| الفحص | النتيجة |
|---|---|
| `OutboxSenderTest` (استُبدل اختبار "409 يُركَن" القديم بـ8 اختبارات: تبنّي، تبنّي طلب في الطريق بحالته، رفض المُلغى، 404، مرجع مختلف، تعذّر السؤال ثم نجاحه، رفض الاعتماد، العدّ المنفصل) | 39 ناجحًا |
| `ReconcileCommandTest` (10: يلتقط، لا يكرر، لا يمس المرسَل/المركون، المؤكَّد فقط، السياسة، النافذة، dry-run، الحد، التعطيل، طابور مشترك) | 10 ناجحة |
| `TouchPointsTest` (+ جدولة كل 5 دقائق بلا تداخل) | 5 ناجحة |
| **الاختبار الحي** `logistic_step3_e2e.php` مقابل لوجستك الحقيقي | **33 فحصًا، 0 فاشل** |
| **كل اختبارات التكامل (Unit + Feature)** | **421 اختبارًا، 1137 تأكيدًا — كلها ناجحة** |
| **مجموعة كابيتانو ويب الكاملة** (مقارنة كل اختبار على حدة بما بعد الخطوة 2) | الاختبارات القائمة: 1041 (ناجح 837، فاشل 74 سابقة، متخطّى 130) — **عدد ما تغيّرت نتيجته: صفر**. اختبارات التكامل: 403 ← **421 كلها ناجحة** |
| إعادة تشغيل اختبارَي الخطوة 2 (84 فحصًا) والمسار الحقيقي عبر HTTP (45 فحصًا) بعد إعادة هيكلة المستمع | **0 فاشل** في كليهما |

**ما يثبته الاختبار الحي (سيناريوهات حقيقية):**
1. **ضياع الرد:** أنشأتُ الطلب في لوجستك بمفتاح آخر دون علم المتجر، ثم أكّدتُ الطلب في المتجر ← 409 ← بحث ← **تبنّي الـuuid نفسه** (برقمه وحالته)، لوجستك يحتفظ بـ**طلب واحد** فقط، ودورة إرسال لاحقة لا تغيّر شيئًا.
2. **طلب ملغى هناك:** رُكن بسبب واضح، لم يُربط بالميّت، ولم يُنشأ مكرر.
3. **حدث فائت:** تأكيد بتحديث مباشر بلا Eloquent (لا حدث) ← `--dry-run` يسميه ولا يفعل ← `reconcile` يضعه في الطابور ولا يرسل ← دورة الإرسال تسلّمه (201) ← تكرار الاثنين لا ينشئ شيئًا.
4. **الحدود:** طلب بائع غير مسموح، وطلب مؤكَّد قبل 10 أيام ← لا يُلتقطان.

### 13.6 كيف تتراجع عن الخطوة 3

| المطلوب | الطريقة |
|---|---|
| إيقاف المطابقة فقط | احذف كتلة `logistic:reconcile` من `Kernel.php` (5 أسطر) — لا يتأثر شيء آخر |
| إيقاف الكل | `LOGISTIC_ENABLED=false` |
| إزالة الكود | احذف `LogisticLookup` و`HandoverQueue` و`ReconcileCommand`، وأعد منطق الطابور إلى المستمع، وأعد معالجة 409 إلى الركن |

### 13.7 حدود معروفة

- **لا تحقق دوري من بقاء الطلب** في لوجستك بعد الربط (لو حُذف/عُدّل هناك)؛ هذا يأتي مع **الويبهوك (الخطوة 5)**.
- النافذة الافتراضية 48 ساعة: طلب فاته الحدث وتجاوز 48 ساعة لا يلتقطه المطابِق تلقائيًا؛ يُلتقط بتشغيل يدوي `logistic:reconcile --hours=…` بعد مراجعتك له.
- لا يتحقق المطابِق من **نتيجة** الإرسال (ذلك عمل `logistic:send` وشاشة `logistic:status`).


---

## 14. الخطوة 4 — الإلغاء: إلغاء الطلب في المتجر يلغيه في لوجستك

**الحالة:** منفَّذة ومختبَرة، **متوقفة عند بوابتها بانتظار موافقتك** قبل الخطوة 5 (استقبال الويبهوك).

### 14.1 الفكرة

عند تغيّر `delivery_status` إلى `cancelled` في المتجر (من لوحة الإدارة أو غيرها — كلها تمر عبر Eloquent وبالتالي عبر نفس الخطاف الذي بُني في الخطوة 2)، يُرسَل إلى لوجستك `POST /orders/{uuid}/cancel`. توثيق لوجستك يقول: "أرسِل فورًا، فكل دقيقة تأخير = كابتن يقود إلى فرع لا شيء فيه" — لذلك يُرسَل الإلغاء **مباشرة بعد استجابة الطلب** الذي ألغى (مثل الإنشاء)، وإن فشل يُعاد على جدول **أسرع** من الإنشاء: 10 ث، 30 ث، دقيقة، دقيقتان، 5، 10، 30، 60 دقيقة (ثم يُركَن لشخص). الإنشاء: 30 ث ← 3 ساعات.

### 14.2 ما بُني

| القطعة | وظيفتها |
|---|---|
| عملية `cancel` في الـoutbox | صف بمفتاح ثابت `cancel-<رقم الطلب>` (UNIQUE) ← إلغاء واحد لكل طلب مهما تكرر الحدث |
| `QueueOrderForLogistic` (موسَّع) | عند `cancelled`: يضع إلغاءً **فقط إن كان للطلب صف إنشاء** (أي سبق وُضع لوجستك)؛ طلب لم يعرفه لوجستك لا شأن له به. **لا يُسأل عن قائمة المسموح بهم**: ما سُلِّم يبقى شأن لوجستك حتى لو تغيّرت القائمة |
| `OutboxSender::processCancel` | منطق الإلغاء كاملًا (أدناه) |
| `CancelPayload` | جسم الطلب: `reason` (سبب الإلغاء + الملاحظة، حتى 255 حرفًا كحد السواجر، وإلا "Cancelled in the shop") و`reason_code = SHOP_CANCELLED`. **في الوضع الآمن** يُرسل سبب عام فقط (الملاحظة الحرة قد تحمل اسم/هاتف الزبون) |
| `HandoverQueue::enqueueCancel` / `requeueUnsent` | وضع الإلغاء في الطابور؛ وإعادة فتح إنشاء لم يُرسل أبدًا (انظر 14.4) |
| `logistic:reconcile` (موسَّع) | يلتقط **إلغاءً فاته الحدث** (طلب ملغى الآن، له uuid، ليس ملغى في لوجستك، لا صف إلغاء له) |
| `logistic:retry <order> --cancel` و`logistic:send --order=<id> --cancel` | للتعامل اليدوي مع صف الإلغاء؛ وجدول `logistic:status` صار يُظهر العملية (create/cancel) |

### 14.3 قرارات السلامة (ما يفعله وما لا يفعله)

1. **لا يُلغى إلا طلب ما زال ملغى**: إن أُعيد فتحه قبل أن يعمل صف الإلغاء (`not_cancelled_any_more`) لا يُرسل شيء — فإلغاء توصيل يريده المتجر كارثة.
2. **الـuuid من الرابط**؛ إن لم يعرف المتجر الـuuid (ضاع رد الإنشاء): إن كان الإنشاء لم يعمل بعد/أُغلق دون إرسال ← لا شيء لإلغائه؛ إن كان **قيد الإرسال الآن** ← ينتظر (20 ث دون صرف محاولة)؛ غير ذلك ← يسأل لوجستك بـ`external_order_id` فيُلغي ما وجده الحيّ، ولا يفعل شيئًا إن لم يجد (404) أو وجده ملغى.
3. **إلغاء مرتين لا يضر**: رد 422 ("منتهٍ") يُتبع بقراءة الطلب؛ إن كان `cancelled` فالمهمة أُنجزت.
4. **طلب مُسلَّم/فاشل التسليم في لوجستك لا يُمس أبدًا**: يُركَن `cancel_refused:delivered` مع الرسالة، **وتبقى حالة الرابط `created`** (فهو فعلًا مرتبط بطلب حقيقي) ويُكتب السبب في `last_error` للرابط؛ لا يُعاد تلقائيًا.
5. 404 على uuid مربوط ← يُركَن `cancel_order_not_found` (تناقض يحتاج إنسانًا) ولا يُفترض أنه أُلغي.
6. 401/403/503 ← إيقاف الدفعة دون صرف محاولة؛ شبكة/5xx/429 ← إعادة بالجدول السريع.
7. فشل الإلغاء **لا يغيّر حالة الرابط** (يبقى مرتبطًا) — فقط الصف يحتاج شخصًا.

### 14.4 حالة حدّية اكتشفتُها وعالجتها: إلغاء ثم إعادة تأكيد

المفتاحان `create-<id>` و`cancel-<id>` **مستهلكان** بعد استعمالهما، فإعادة تأكيد طلب أُلغي كانت ستتركه مؤكَّدًا بلا توصيل:
- **ألغي في لوجستك** ثم أُعيد تأكيده ← لا يُنشأ طلب ثانٍ ولا يُرسل شيء؛ يصير الرابط `needs_attention` بسبب `reconfirmed_after_cancel` (يحتاج إنشاء توصيل جديد يدويًا) — لأن لوجستك سيرد على المفتاح القديم بالطلب الملغى نفسه.
- **ألغي قبل أن يصل لوجستك أصلًا** ثم أُعيد تأكيده ← يُعاد فتح صف الإنشاء ويُحذف صف الإلغاء المستهلك (ليمكن إلغاء لاحق)، فيُسلَّم الطلب بشكل طبيعي.

### 14.5 الملفات

**جديدة:** `app/Services/Logistic/CancelPayload.php` · `tests/Feature/Logistic/CancelOrderTest.php` · `tests/Live/logistic_step4_e2e.php`

**معدَّلة (كلها ضمن التكامل):** `LogisticOutbox` (`OP_CANCEL`, `cancelKey`, `queueCancel`)، `LogisticOrderLink` (حالة `cancelled`)، `OutboxSender`، `HandoverQueue`، `QueueOrderForLogistic`، `ReconcileCommand`، `RetryCommand`، `SendCommand`، `StatusCommand`، `config/logistic.php` (`cancel_retry_schedule`).

**ملفات المتجر القائمة:** **لم يُلمس أي ملف قائم** في هذه الخطوة (الإجمالي ما زال 38 سطرًا مضافة، 0 حذف، في ثلاثة ملفات). لا ترحيل قاعدة بيانات جديد: عمود `operation` وحالات الرابط نصية موجودة.

### 14.6 الاختبارات

| الفحص | النتيجة |
|---|---|
| `CancelOrderTest` (25: الجسم والوضع الآمن، الخطاف، التكرار، قائمة المسموح، الإرسال والمفتاح، إعادة الفتح قبل التنفيذ، لا uuid بكل حالاته، 422 مرتين، المُسلَّم، 404، الجدول السريع، 401، إلغاء ثم تأكيد، reconcile، retry، العدّ) | 25 ناجحًا |
| اختبار الطفرات (7 إفسادات مقصودة: الإلغاء رغم إعادة الفتح، اعتبار المُسلَّم ملغى، إلغاء ما لم يُسلَّم، تغيير حالة الرابط عند الفشل، عدم حذف الإلغاء المستهلك، تجاهل انتظار الإنشاء، استعمال جدول الإنشاء) | **7 من 7 التُقطت** |
| **كل اختبارات التكامل (Unit + Feature)** | **446 اختبارًا، 1226 تأكيدًا — كلها ناجحة** |
| **مجموعة كابيتانو ويب الكاملة** (مقارنة كل اختبار على حدة بما بعد الخطوة 2) | الاختبارات القائمة: 1041 (837/74 سابقة/130) — **ما تغيّرت نتيجته: صفر**. اختبارات التكامل: 421 ← **446 كلها ناجحة** |
| **الاختبار الحي** `logistic_step4_e2e.php` مقابل لوجستك الحقيقي | **39 فحصًا، 0 فاشل** |
| **المسار الحقيقي عبر HTTP** `logistic_real_flow_e2e.php` (+ قسم الإلغاء من لوحة الإدارة) | **53 فحصًا، 0 فاشل** |
| إعادة الاختبارات الحية للخطوتين 2 و3 | 84 و33 فحصًا، 0 فاشل |

**ما يثبته الاختبار الحي (سيناريوهات حقيقية):** (1) إلغاء ← صف إلغاء `done` / HTTP 200 / محاولة واحدة، لوجستك يُظهر الطلب `cancelled` بالرمز `SHOP_CANCELLED` وبسبب الوضع الآمن (**الهاتف الموجود في الملاحظة لم يخرج**)، والرابط `cancelled` بنفس uuid؛ (2) إلغاء مرتين ← صف إلغاء واحد؛ (3) ألغاه لوجستك قبلنا ← 422 ثم قراءة ثم `done`؛ (4) **مُسلَّم في لوجستك** ← يُركَن `cancel_refused:delivered`، الطلب يبقى `delivered`، الرابط `created`، ولا إعادة تلقائية؛ (5) ضاع الـuuid ← سؤال لوجستك ثم إلغاء ما يملكه وتعلّم الـuuid؛ (6) طلب لم يعرفه لوجستك ← لا شيء يُرسل؛ (7) إلغاء فاته الحدث ← `reconcile --dry-run` يسميه، `reconcile` يضعه، دورة الإرسال تلغيه، وتكرار `reconcile` لا ينشئ شيئًا؛ (8) تأكيد بعد إلغاء ← مُعلَّم لشخص، ولا طلب ثانٍ في لوجستك. **والمسار الحقيقي:** الموظف يلغي من لوحة الإدارة عبر HTTP (`POST /admin/admin/orders/{id}/cancel`، المسار مكرر البادئة في الكود القائم) ← الطلب نفسه `cancelled` في لوجستك.

### 14.7 كيف تتراجع عن الخطوة 4

| المطلوب | الطريقة |
|---|---|
| إيقاف الإلغاء فقط | احذف فرع `cancelled` من `QueueOrderForLogistic::handle` (طلبات الإنشاء لا تتأثر) |
| إيقاف الكل | `LOGISTIC_ENABLED=false` |
| إزالة الكود | احذف `CancelPayload` ودوال الإلغاء في `OutboxSender` و`HandoverQueue`، وأعد ملف الاختبار |

### 14.8 حدود معروفة

- إلغاء طلب **أُعيد تأكيده بعد إلغائه في لوجستك** يحتاج إنشاء توصيل جديد يدويًا (لا مفتاح جديد تلقائي) — حالة نادرة، ومُعلَّمة بوضوح.
- **التعديل بعد التسليم** (تغيير العنوان/البنود بعد الإرسال) ما زال خارج النطاق (الخطوة 8)؛ وإلغاء **جزئي** (إرجاع بعض البنود) لا يُلغي طلب لوجستك.
- حالة الطلب في لوجستك بعد الربط (مُسنَد/في الطريق/مُسلَّم) لا تصل المتجر بعد — الخطوة 5 (الويبهوك) ثم 6.
- اختبار "مُسلَّم" الحي يحرّك طلبًا **تجريبيًا** إلى `delivered` مباشرة في قاعدة لوجستك (لا يمكن تشغيل كابتن حقيقي من هنا)، ثم يُغلقه `cancelled` ليبقى لوجستك نظيفًا.


---

## 15. الخطوة 5 — استقبال الويبهوك (للعرض فقط)

**الحالة:** منفَّذة ومختبَرة، **متوقفة عند بوابتها بانتظار موافقتك** قبل الخطوة 6 (كتابة الحالة في طلب المتجر).

### 15.1 الفكرة

حتى الآن المتجر يُرسل إلى لوجستك ولا يسمع منه. في هذه الخطوة يصبح المتجر **يستقبل** ما يقوله لوجستك عن كل طلب (استُلم، أُسنِد لكابتن، استُلمت البضاعة، في الطريق، سُلِّم، فشل التسليم، أُلغي، تعدّل) **ويعرضه** للموظفين. **للعرض فقط**: لا يتغيّر شيء في طلب المتجر نفسه — لا حالة ولا مخزون ولا إشعار ولا دفع. كتابة الحالة في الطلب خطوة 6 منفصلة بضماناتها.

### 15.2 عقد لوجستك (كما قرأتُه من كوده)

- يرسل `POST` بجسم JSON وترويسات: `X-Kapitano-Event` و`X-Kapitano-Event-Id` و`X-Kapitano-Delivery-Attempt` و`X-Kapitano-Signature` = `t=<unix>,v1=<hex>` (HMAC-SHA256 على `"<t>." + الجسم الخام` بسر الويبهوك، **وقد يحمل قيمتي `v1` أثناء تدوير السر**).
- الجسم: `event`, `event_id`, `occurred_at`, `sequence`, و`order` (نفس شكل `GET /orders/{uuid}`). الأحداث: `order.received/assigned/picked_up/on_the_way/delivered/delivery_failed/cancelled/updated/address_details` و`integration.ping`.
- يتوقع **2xx خلال 15 ث**؛ غير ذلك يعيد 6 مرات (10 ث، دقيقة، 5، 15 دقيقة، ساعة، ساعة) ثم يُسقط الحدث `dropped`.
- `sequence` = رقم **مراجعة الطلب** (يزيد مع كل تغيير مرئي للمتجر) **لكل طلب**؛ وحدث `received` **بلا sequence**.
- يرفض عنوانًا خاصًا/محليًا إلا بـ`INTEGRATION_ALLOW_PRIVATE_WEBHOOK_URLS` (للتطوير)، وفي الإنتاج يجب `https` وبورت 443 وعنوان عام.

### 15.3 ما بُني (في كابيتانو ويب)

| القطعة | وظيفتها |
|---|---|
| `POST /logistic/webhook` (`LogisticWebhookController`) | نقطة الاستقبال. **مكدّس وسطاء فارغ تقريبًا** (فقط `throttle:240,1`): لا جلسة ولا CSRF ولا مفتاح `System-Key` الخاص بتطبيق الموبايل — تُثبت هويتها بالتوقيع وحده. تقرأ **الجسم الخام** (لا أي نسخة معاد ترميزها) |
| `WebhookVerifier` | يتحقق من التوقيع: يقرأ **كل** قيم `v1` ويقارنها بكل الأسرار المحفوظة (`hash_equals`)، مع نافذة زمنية 300 ث؛ **لا سر مضبوط = رفض** (لا "تمرير") |
| `WebhookReceiver` | المنطق (15.4) |
| جدول `logistic_webhook_events` (جديد) | سجل بكل رد نداء، **`event_id` فريد** = قاعدة البيانات نفسها تمنع التطبيق مرتين. وهو أيضًا أثر تدقيق (ماذا قيل وماذا فعلنا) |
| أعمدة جديدة في `logistic_order_links` | `last_event`, `last_event_at`, `snapshot` (كتلة `order` كما وصفها لوجستك آخر مرة). (`last_status` و`last_sequence` كانا موجودين) |
| لوحة "Kapitano Logistic" في صفحة الطلب | `resources/views/logistic/_order_panel.blade.php`: حالة الطلب في لوجستك، رقمه، الكابتن، الخط الزمني، آخر نداء، حالة الإرسال/الإلغاء، وسبب أي مشكلة. **لا تظهر شيئًا** لطلب لم يذهب إلى لوجستك |
| شاشة "طلبات لوجستك" | `GET /admin/logistic/orders` (فلتر بالحالة، بحث بكود الطلب/رقم ORD، ترقيم)، خلف تسجيل الدخول + `admin` + صلاحية `view_all_orders`، **قراءة فقط**، وتحذّر من النداءات التي لم تطابق طلبًا |
| `logistic:webhooks:prune` | يحذف سجلات النداءات الأقدم من 60 يومًا (على دفعات 1000) يوميًا 05:15؛ **لا يمس** ما تعرضه الطلبات |
| `logistic:status` | صار يعرض عدد النداءات حسب النتيجة وآخر نداء |

### 15.4 قواعد الاستقبال (بالترتيب)

1. التكامل مُطفأ ← **503** (فيعيد لوجستك المحاولة لاحقًا بدل أن يضيع الحدث).
2. جسم أكبر من 1MB ← 413.
3. لا سر مضبوط ← 503 (ولا يُقبل شيء). **توقيع ناقص/خاطئ/منتهٍ/جسم معدَّل ← 401** بنفس الجواب العام دائمًا (لا يُخبَر المرسِل أي فحص فشل) ولا يُحفظ شيء.
4. جسم موقَّع لكنه ليس حدثًا صالحًا ← 400.
5. **تكرار** (`event_id` مرئي) ← 200 `duplicate` دون إعادة تطبيق (حتى لو وصل نسختان في اللحظة نفسها: المفتاح الفريد يحسم).
6. **المطابقة:** بـ`uuid` لوجستك، أو — قبل أن يكتب المتجر الـuuid (حدث `received` قد يصل قبل أن نسجّل رد الإنشاء) — بـ`external_order_id` أي رقم طلبنا. حدث يسمّي طلب لوجستك **مختلفًا** عن المربوط بطلبنا، أو uuid يخص طلبًا غير الذي يقوله المرجع ← `conflict` لا يُطبَّق أبدًا.
7. **الترتيب:** حدث `sequence` ليس أكبر من آخر ما عُرض ← `stale` (يُحفظ للسجل ولا يُعرض). `received` (بلا sequence) لا يُعرض إلا إن لم يُعرض شيء أحدث. فجوة في الأرقام مقبولة لأن كل حدث "استبدل نسختي" لا "فرق".
8. طلب غير معروف ← `unmatched` و**200** (حتى يتوقف لوجستك عن الإعادة؛ الشاشة تحذّر منها). نوع حدث غير معروف أو حقول جديدة ← `ignored`/تُهمل و**200**.
9. المطبَّق فقط يحدّث الرابط (`last_status`, `last_event`, `last_sequence`, `snapshot`) — **لا يُلمس جدول `orders` ولا `state` الرابط** (ذاك شأن المرسِل).

### 15.5 ملفات قائمة لُمست (إضافة فقط)

| الملف | الإضافة | السبب |
|---|---|---|
| `app/Providers/RouteServiceProvider.php` | +12 سطرًا | `mapLogisticRoutes()` تحمّل `routes/logistic.php` **أولًا** (فلا يظلّلها مسار شامل في `web.php`) — نقطة الويبهوك يجب ألا تقع في مجموعتي `web` ولا `api` |
| `resources/views/backend/sales/show.blade.php` | +2 | `@includeWhen(config('logistic.enabled'), 'logistic._order_panel', …)` — سطر واحد في مكان واحد |
| `app/Console/Kernel.php` | +5 | جدولة التنظيف اليومي |

**المجموع الآن في الملفات القائمة: 57 سطرًا مضافة، 0 حذف، في 5 ملفات** (الخطوات السابقة: 38 في 3 ملفات). `LOGISTIC_ENABLED=false` فتبقى الصفحة كما كانت تمامًا، ولا مسار الويبهوك يقبل شيئًا.

**قاعدة البيانات:** هجرة جديدة `2026_10_06_000010_create_logistic_webhook_tables` (+ نسخة SQL `sqlupdates/kapitano_logistic_integration_step5_2026-10-06.sql`): جدول جديد + 3 أعمدة في **جدولنا** `logistic_order_links`؛ لا يُعدَّل أي جدول للمتجر. **طبّقتُ الهجرة على قاعدة التطوير المحلية** (`php artisan migrate --path=…` لهذا الملف وحده، حتى لا يُشغَّل أي ترحيل آخر معلَّق).

### 15.6 الاختبارات

| الفحص | النتيجة |
|---|---|
| `WebhookVerifierTest` (21: المقبول، تدوير السر بقيمتين، سرّان محفوظان، سر خاطئ، جسم/ختم معدَّل، قديم/مستقبلي، ناقص، 9 صيغ مشوَّهة، مفاتيح مجهولة، لا سر، حالة الأحرف) | 21 ناجحًا |
| `WebhookReceiverTest` (23، عبر المسار الحقيقي وتوقيعات حقيقية: التوثيق، 503، تدوير، بلا جلسة/CSRF، التطبيق، **لا يمس الطلب ولا يتصل بأحد**، التكرار، الترتيب والفجوات و`received`، المطابقة بالمرجع، `unmatched`، `conflict`×2، ping، مجهول، أجسام تالفة، حجم) | 23 ناجحًا |
| `WebhookDisplayTest` (16: اللوحة، الهروب من HTML، لم يُبلَّغ بعد، المشكلة، آخر 8 نداءات، سطر التضمين، القائمة والفلاتر والتحذير والصلاحيات، رابط مشفَّر، التنظيف بأنواعه، `status`) | 16 ناجحًا |
| `TouchPointsTest` (+3: جدولة التنظيف، مكدّس الويبهوك الفارغ، التسجيل أولًا) | 8 ناجحة |
| اختبار الطفرات (14 إفسادًا مقصودًا) | **13 التُقطت**؛ الناجية واحدة **مكافئة**: حذف الفحص السريع للتكرار لا يغيّر النتيجة لأن المفتاح الفريد في قاعدة البيانات يلتقطه ويُرجع الجواب نفسه — أي أن الضمان الحقيقي في القاعدة |
| **كل اختبارات التكامل (Unit + Feature)** | **509 اختبارات، كلها ناجحة** |
| **مجموعة كابيتانو ويب الكاملة** (مقارنة كل اختبار على حدة) | الاختبارات القائمة: 1041 (837/74 سابقة/130) — **ما تغيّرت نتيجته: صفر**. التكامل: 446 ← **509** |
| **الاختبار الحي** `logistic_step5_e2e.php` | **47 فحصًا، 0 فاشل** |
| إعادة الاختبارات الحية السابقة | الخطوة 2: 84 · 3: 33 · 4: 40 · المسار الحقيقي عبر HTTP: 53 — **0 فاشل** |

**ما يثبته الاختبار الحي (سيرفران حقيقيان + كود لوجستك نفسه):** المتجر يشغّل سيرفر ويب حقيقيًا يستقبل، ولوجستك **يبني ويوقّع ويرسل ويسجّل** كل نداء بكوده الحقيقي. (1) `ping` ← 200 والمتجر سجّله (فالأسرار متطابقة والتوقيع صحيح)؛ (2) طلب حقيقي ← `order.received` يُطبَّق ويظهر حالة وsnapshot؛ (3) الرحلة كاملة: assigned ← picked_up ← on_the_way ← delivered، كل حدث بـsequence أعلى والعرض يتبعها؛ (4) إعادة إرسال حدث سبق (يرد المتجر 200 ويحفظ **سجلًا واحدًا**) وحدث قديم الـsequence (`stale` والعرض لا يرجع للوراء)؛ (5) نداء بسر خاطئ / بلا توقيع / جسم معدَّل بعد التوقيع ← **401** ولا أثر؛ (6) إلغاء من المتجر (الخطوة 4) ← يردّ لوجستك بـ`order.cancelled` ← يُعرض؛ (7) الموظف عبر HTTP: صفحة الطلب فيها اللوحة (الرقم، الحالة، آخر نداء)، والطلب الذي لم يذهب لا لوحة له، والقائمة تسرد الطلبين، **والرابط في القائمة يفتح صفحة الطلب**، والزائر غير المسجَّل يُحوَّل لتسجيل الدخول؛ (8) **الطلب "المُسلَّم" في لوجستك بقي `confirmed` في المتجر** — لم يُغيّره نداء.

### 15.7 اكتشافات أثناء العمل

1. **خطأ حقيقي التقطه الاختبار الحي:** رابط القائمة إلى صفحة الطلب كان بـ**رقم عادي** بينما صفحة المتجر الحالية تفكّ التشفير (`decrypt($id)`) فكانت ستعطي 500 — صُحِّح إلى `encrypt()` وثُبِّت باختبار يقرأ الملف، وبالاختبار الحي الذي **يتبع الرابط الفعلي**.
2. **طابور لوجستك فيه وظائف غير تخصنا** (مسارات، إشعارات كابتن) — لذلك لم أشغّل `queue:work` عامًا (كان سيشغّلها أيضًا)؛ بنيتُ مشغّلًا (`tests/Live/support/logistic_webhooks.php`) ينفّذ **وظائف `SendStoreWebhook` فقط**، ويثبّت بيئة لوجستك الخاصة ليتجاوز متغيرات المتجر الموروثة (التي كادت توجّهه لقاعدة المتجر)، ويرفض العمل إن لم تكن القاعدة `kapitano_logistic`، ولا ينفّذ حذفًا إلا بأساس قراءة ناجح.
3. أرقام `sequence` = **مراجعة الطلب** (ليست رقم صف الخط الزمني)؛ و`received` بلا رقم — فصُمِّم الترتيب على ذلك.
4. حالة التسليم في سجل لوجستك اسمها `sent` (لا `delivered`).
5. صفحة الطلب القائمة تحتاج مفتاح `email` في عنوان الشحن (يضعه الدفع الحقيقي دائمًا) — بيانات الاختبار صارت تضعه.
6. عميل الاختبار في Laravel يضيف مسار `APP_URL` الفرعي المحلي (`/kapitano_web/public`) فاستعملتُ عنوانًا مطلقًا في الاختبارات.

### 15.8 ما يلزم قبل التشغيل الحقيقي (مختلف عن بقية الخطوات)

1. **`LOGISTIC_ENABLED=true` أولًا ثم ضبط عنوان الويبهوك** في بوابة لوجستك (وإلا أجاب المتجر 503 وأعاد لوجستك 6 مرات ثم أسقط الأحداث).
2. العنوان: `https://<نطاق المتجر>/logistic/webhook` — **https وبورت 443 وعنوان عام** (قاعدة لوجستك). عنوان `APP_URL` الحالي محلي.
3. `LOGISTIC_WEBHOOK_SECRET` في `.env` الإنتاج = سر الويبهوك في البوابة (يمكن سرّان مفصولان بفاصلة أثناء التدوير).
4. **يلزم `queue:work` يعمل في لوجستك** (طابور `database`) وإلا لا تُرسَل أي ويبهوكات — قيد قائم عند لوجستك.
5. تشغيل الهجرة أو ملف SQL في الإنتاج.
6. نصوص الواجهة الجديدة (تُنشأ تلقائيًا بالإنجليزية في جدول الترجمة عند أول عرض) **لم تُترجم للعربية** — تُضاف من شاشة الترجمات.

### 15.9 كيف تتراجع عن الخطوة 5

| المطلوب | الطريقة |
|---|---|
| إيقاف الاستقبال | `LOGISTIC_ENABLED=false` (يجيب 503) أو امسح عنوان الويبهوك من بوابة لوجستك |
| إزالة اللوحة من صفحة الطلب | احذف السطرين من `show.blade.php` |
| إزالة المسار | احذف `mapLogisticRoutes` واستدعاءها من `RouteServiceProvider` وملف `routes/logistic.php` |
| إزالة الجدول والأعمدة | `DROP TABLE logistic_webhook_events;` و`ALTER TABLE logistic_order_links DROP COLUMN last_event, last_event_at, snapshot;` (أو `migrate:rollback` لهذا الملف) |

### 15.10 حدود معروفة

- **للعرض فقط:** بقاء الطلب "قيد التوصيل" في لوجستك لا يغيّر شيئًا في المتجر؛ تحديث حالة الطلب والمخزون والإشعارات من نداء لوجستك هو **الخطوة 6** وتحتاج إعادة هيكلة صغيرة لمنطق تغيير الحالة في المتحكم القائم.
- الويبهوك قد يضيع (6 محاولات ثم إسقاط): شبكة الأمان "اسأل لوجستك عن الطلبات التي طال بقاؤها غير منتهية" لم تُبنَ بعد (تأتي مع الخطوة 6).
- لا إدارة لتدوير السر من المتجر (يُضبط في `.env`).
- إشعار الموظف بنداء `unmatched`/`conflict` يقتصر على تحذير في أعلى شاشة القائمة.


---

## 16. الخطوة 6 — تقدّم لوجستك يحرّك طلب المتجر

**الحالة:** منفَّذة ومختبَرة، **متوقفة عند بوابتها بانتظار موافقتك** قبل الخطوة 7 (الدفع). وهي **مُطفأة افتراضيًا** (`LOGISTIC_APPLY_STATUS=false`): كما هي الخطوة 5 عرض فقط حتى تُشغّلها أنت.

### 16.1 قراراتك التي بُنيت عليها (من الأسئلة الأربعة)

| القرار | ما اخترتَه (وهو ما نُفِّذ) |
|---|---|
| أي أحداث تغيّر حالة طلب المتجر؟ | **التقدّم للأمام فقط**: `picked_up` ← `picked_up`، `on_the_way` ← `on_the_way`، `delivered` ← `delivered`. لا رجوع للخلف أبدًا، ولا يُلمس طلب `pending`/`cancelled`/`returned` |
| `delivery_failed`، أو إلغاء لوجستك لطلب ما زال حيًّا في المتجر | **تنبيه الموظفين دون تغيير شيء** (لا توجد في المتجر حالة "فشل التسليم" تلقائية، والإلغاء يُرجع المحفظة والمخزون ويعكس أرباح البائع فلا يُفعل آليًا) |
| الدفع عند الاستلام عند `delivered` | **لا يُعلَّم مدفوعًا** (الكابتن يستلم النقد لكنه لا يصل المتجر إلا بعد التسوية؛ المحاسب يعلّمه كما اعتاد؛ والدفع هو الخطوة 7) |
| باسم من تُسجَّل التغييرات؟ | مستخدم مخصص **"Kapitano Logistic"** |

### 16.2 الفكرة: لا إعادة هيكلة، إعادة استعمال

كانت الخطة تتوقع إعادة هيكلة لمنطق تغيير الحالة. تبيّن أن المتجر **نفسه** يستدعي طريقته `OrderController::update_delivery_status` من شاشات أخرى (شاشة "Deliver Orders" تقول في تعليقها: "نفس مسار قائمة الحالة في صفحة الطلب")، فنستدعي **الطريقة نفسها** بالصيغة نفسها (`new Request(['order_id'=>…,'status'=>…])`) كمستخدم Kapitano Logistic. ولذلك يتطابق السلوك تلقائيًا مع التغيير اليدوي: المخزون وحالة السطور وتاريخ التسليم وإشعارات الزبون/البائع/الأدمن ومطالبة التقييم وإحصاءات الأفلييت وسجل المتابعة. **لم يُنسخ ولم يُعدَّل سطر واحد من كود تغيير الحالة القائم**، ويثبّت ذلك اختبار يفحص الملف.

### 16.3 قاعدة خطرة وجدتُها في الكود القائم وكيف احتطتُ لها

طريقة تغيير الحالة القائمة تلتقط أي استثناء وتنفّذ `dd()` — و`dd()` **تُنهي عملية PHP كلها**. لو شُغِّلت داخل طلب الويبهوك أو المجدول لعطّلت كل مرة يصل فيها طلب فيه مشكلة بياناته. لذلك:
1. **عملية فرعية لكل طلب** (`StatusApplyRunner`): أسوأ ما يحدث أن تموت العملية الفرعية، فيحصي الأب الفشل ويكمل. بعد 5 إخفاقات للطلب نفسه يتوقف عنه ويضع تنبيهًا للموظف بدل إعادة المحاولة كل بضع دقائق إلى الأبد.
2. **المعاملة الواحدة** (`ShopStatusChanger`): تُنفَّذ كلها داخل `DB::transaction` فإن ماتت العملية رُجع كل شيء (يتحرك الطلب كاملًا أو لا يتحرك).
3. **نفس فحوص شاشة التسليم قبل الاستدعاء**: وجود حساب الزبون (وإلا تُنهي الطريقة العملية) وكفاية المخزون للحالات التي تحجزه.
4. تُشغَّل العملية الفرعية **بعد إرسال الرد** إلى لوجستك (فلا يتأخر جوابه) وبنفس مفاتيح الإعداد التي عند الأب (انظر 16.7).
5. **التدقيق (audit)**: حزمة التدقيق تتجاهل تشغيلات الكونسول افتراضيًا، فكان تغيير الحالة من العملية الفرعية سيمرّ بلا أثر. أمر `logistic:status:converge` يشغّل `audit.console` ليكتب نفس سجل التدقيق باسم مستخدم Logistic (وتثبّته اختبار).

### 16.4 ما بُني

| القطعة | وظيفتها |
|---|---|
| `StatusMap` | الخريطة: ثلاث حالات فقط تنتقل، مع ترتيب التقدّم في المتجر |
| `StatusConverger` | **العقل**: يقارن حالة المتجر بما يقوله لوجستك ويحرّك الطلب **فقط إن كان متأخرًا**، متساوي القدرة على التكرار (لا يطبّق خطوة مرتين) |
| `ShopStatusChanger` | **كل ما يلمس كود المتجر** في مكان واحد: الفحوص، استدعاء الطريقة القائمة بقفل وإعادة قراءة، كتابة سطر السجل |
| `LogisticActor` + `logistic:actor --create` | مستخدم "Kapitano Logistic": **نوعه `logistic`** (لا يطابق أي قائمة موظفين أو قوائم منسدلة ولا صلاحيات ولا يفتح لوحة الإدارة)، محظور، بريده `.invalid` لا يستقبل رسائل، وكلمة سر عشوائية لا يعرفها أحد |
| `LinkStatusDisplay` | يحدّث عرض الرابط (مشترك بين الويبهوك والاستعلام)، ويرفع **تنبيهًا** عند `delivery_failed` أو إلغاء لوجستك لطلب حيّ، ويزيله حين يتقدّم لوجستك بما يمحوه، ولا يكتب فوق سبب مشكلة أخرى |
| `StatusApplyRunner` | العملية الفرعية + عدّاد الإخفاق + `LOGISTIC_PHP_BINARY` (تحت Apache/php-fpm يشير `PHP_BINARY` إلى الخادم لا إلى `php` فيُبحث عن الأمر الصحيح) |
| `StatusPoller` | **شبكة الأمان للويبهوك الضائع**: يسأل لوجستك (`GET /orders/{uuid}`) عن الطلبات الحيّة الساكتة منذ 30 دقيقة، بحد أقصى 10 في الدورة ومرة واحدة لكل فاصل، ويتوقف فور رفض الاعتماد، وتنبيه إن أجاب 404 |
| `logistic:status:sync` | كل دقيقتين: (1) الاستعلام، (2) تحريك أي طلب متأخر (مثلًا ضاع تحريكه أو وصل الويبهوك والمتجر متوقف) |
| `logistic:status:converge <order>` | ما يشغّله الويبهوك والمجدول في عملية فرعية؛ ويصلح للتشغيل اليدوي |
| `logistic:status` | صار يعرض: هل التحريك مفعّل وهل المستخدم موجود |
| ويبهوك `WebhookReceiver` (من الخطوة 5) | بعد تثبيت السجل وإرسال الرد: إن كان الحدث "جديدًا" ومفتاح التحريك مفعّل، يشغّل `StatusApplyRunner` ويكتب نتيجته في ملاحظة سجل النداء (`shop: applied confirmed -> on_the_way` أو سبب عدم التحريك) |

### 16.5 القواعد التي لا تُكسَر (بالترتيب)

1. مفتاح التحريك مطفأ ← لا شيء. التكامل مطفأ ← لا شيء.
2. الرابط يجب أن يكون **حيًّا** (`created` أو تنبيه) وله uuid؛ المعلّق/الملغى/بلا uuid ← لا شيء.
3. حالة لوجستك تُترجم لحالة متجر أو لا شيء (`assigned/accepted/pending/delivery_failed/cancelled` لا مقابل لها آليًا).
4. طلب المتجر `pending`/`cancelled`/`returned` ← **لا يُلمس** ويُرفع تنبيه "لوجستك يوصّل ما يعتبره المتجر غير حيّ — قرّروا ما الصحيح".
5. المتجر متقدّم أو مساوٍ ← لا شيء (**لا رجوع للخلف**، ولا حالة خارج المعروفة).
6. قفل الصف وإعادة القراءة: إن سبقه طلب آخر ← لا شيء.
7. خطوة فائتة لا تمنع: لو وصل `delivered` ولم تصل `picked_up`/`on_the_way` ينتقل الطلب مباشرة (لوجستك هو الحقيقة).
8. **لا يمس الدفع ولا المخزون** (يُحجز عند التأكيد).
9. أي تحريك يُمنع (حساب الزبون ناقص، نقص مخزون، رفض من المتجر، استثناء) ← تنبيه واضح بسببه على لوحة الطلب والقائمة، ويزول التنبيه تلقائيًا حين ينجح التحريك.
10. **الرجوع من التنبيه:** تنبيهنا يبدأ بـ`logistic_reports_` فقط يُزال/يُستبدل؛ أي سبب آخر (مثل `cancel_refused`) لا يُمس.

**وإلغاء مُسلَّم فاشل:** عدّلتُ سلوك الخطوة 4 قليلًا: إلغاء الطلب في المتجر حين يكون توصيله في لوجستك `delivery_failed` يُعتبر **منجزًا** (لا شيء ليُلغى) بدل أن يُركَن.

**ويبهوك قديم بعد استعلام:** إن وجد الاستعلام الطلب `delivered` (بلا sequence معروف) ثم وصل نداء قديم `on_the_way` فهو `stale` (لا يتراجع العرض).

### 16.6 الملفات

**جديدة:** `app/Services/Logistic/{StatusMap, LogisticActor, LinkStatusDisplay, StatusConverger, ShopStatusChanger, StatusApplyRunner, StatusPoller}.php` · `app/Console/Commands/Logistic/{StatusConvergeCommand, StatusSyncCommand, ActorCommand}.php` · `tests/Feature/Logistic/{StatusConvergerTest, StatusFlowTest}.php`

**معدَّلة (ضمن التكامل):** `WebhookReceiver` (يستعمل العرض المشترك ويشغّل التحريك بعد الرد)، `OutboxSender` (حالة `delivery_failed`)، `StatusCommand`، `config/logistic.php`.

**ملفات المتجر القائمة:** **لم يُلمس إلا `app/Console/Kernel.php`** (+6 أسطر: جدولة `logistic:status:sync` كل دقيقتين). **المجموع الآن في الملفات القائمة: 63 سطرًا مضافة، 0 حذف، في 5 ملفات.** لا هجرة ولا جدول جديد في هذه الخطوة.

### 16.7 اكتشافات أثناء العمل (التقطها الاختبار الحي على التطبيق الحقيقي)

1. **العملية الفرعية لا ترث إعدادات الأب:** كانت تقرأ `.env` (حيث التكامل مطفأ) فتجيب "integration_disabled" والطلب لا يتحرك — خلل إنتاجي حقيقي (مثلًا لو وُضعت الإعدادات في بيئة الخادم لا في `.env`). صار الأب يمرّر **مفاتيحه الفعلية** (`ENABLED`, `APPLY_STATUS`, `SAFE_MODE`) للابن صراحةً، ويثبّت اختبار ذلك.
2. **ملاحظة سجل النداء لم تكن تذكر سبب عدم التحريك** — فتعذّر تشخيص الخلل أعلاه. صارت تكتب النتيجة دائمًا (`shop: noop integration_disabled` مثلًا).
3. تحت Apache/php-fpm `PHP_BINARY` ليس `php`: أُضيف البحث الصحيح و`LOGISTIC_PHP_BINARY`.
4. اختبارات الطفرات كشفت اختبارين ضعيفين وقوّيتهما: صدى إلغاء المتجر لا يُنبَّه عليه (يجب الاختبار بحالة رابط `created` لا `cancelled`)، وإعادة قراءة القفل في `ShopStatusChanger` (اختبار بالفئة الحقيقية).

### 16.8 الاختبارات

| الفحص | النتيجة |
|---|---|
| `StatusConvergerTest` (29: الحركة والخطوات والقفز على خطوة فائتة، التكرار، المفتاح، **لا رجوع للخلف**، حالات بلا مقابل، طلب غير حيّ+تنبيه، روابط غير صالحة، **الدفع لا يُمس**، لا مستخدم، فحوص المتجر، رفض المتجر، استثناء، "سبقه غيره"، تنبيه يزول، سبب آخر لا يُلمس) | 29 ناجحًا |
| `StatusFlowTest` (من الويبهوك إلى طلب المتجر، الرحلة خطوة خطوة، المفتاح مطفأ، stale/duplicate/unmatched لا تحرّك، فشل التحريك لا يغيّر جواب لوجستك، التنبيهات الثلاثة وزوالها، العدّاد والتخلي عن الطلب المسموم، الاستعلام: ساكت/حديث/منتهٍ/حد/404/رفض اعتماد/لا تراجع، تحريك المتأخر، مستخدم Logistic، أوامر، مفاتيح الابن، `php` الصحيح) | 34 ناجحًا |
| `TouchPointsTest` (+3) | 11 ناجحًا |
| اختبار الطفرات (23 إفسادًا: التحريك والمفتاح والرجوع للخلف والإلغاء والدفع...) | **22 التُقطت**، والناجية **مكافئة** (الويبهوك يشغّل التحريك فقط للحدث "الجديد" أصلًا لأن الطلب المسجَّل لا يُحدَّد إلا عندئذ) |
| **كل اختبارات التكامل (Unit + Feature)** | **573 اختبارًا، كلها ناجحة** |
| **مجموعة كابيتانو ويب الكاملة** (كل اختبار على حدة) | الاختبارات القائمة: 1041 (837/74 سابقة/130) — **ما تغيّرت نتيجته: صفر**. التكامل: 509 ← **573** |
| **الاختبار الحي على التطبيق الحقيقي** (المسار الحقيقي عبر HTTP، القسم 9 الجديد) | **74 فحصًا، 0 فاشل** |
| إعادة الاختبارات الحية: الخطوة 2 / 3 / 4 / 5 | 84 / 33 / 40 / 47 فحصًا — **0 فاشل** |

**ما يثبته الاختبار الحي (الطلب B يمرّ بكل الأبواب الحقيقية):** الزبون يطلب عبر API المتجر ← البائع يؤكد ← الموظف يؤكد ← يُسلَّم للوجستك. ثم **كود لوجستك نفسه** يبني ويوقّع ويرسل `order.picked_up` و`order.on_the_way` إلى سيرفر المتجر الحقيقي فيتحرك **طلب المتجر فعلًا** (وسطوره) عبر طريقة المتجر الحقيقية في عملية فرعية. ثم يُسلَّم الطلب في لوجستك **وتُفقَد ندائه** — فيجده `logistic:status:sync` بالاستعلام ويحرّك الطلب إلى `delivered`. والتحقق: تاريخ التسليم وُضع (كود المتجر نفسه)، **الدفع بقي `unpaid`**، **المخزون لم يتحرك**، سجل المتابعة فيه الخطوات الثلاث بيد "Kapitano Logistic"، **سجل التدقيق** فيه تغييرات الحالة باسم مستخدم Logistic حتى من العملية الفرعية، وتكرار التزامن لا يغيّر شيئًا، وصفحة الطلب في لوحة الإدارة تعرض اللوحة. وتنظيف كامل (الجداول والمستخدم المؤقت وعنوان لوجستك).

### 16.9 تشغيل الخطوة (بالترتيب، حين تقرر)

1. `php artisan logistic:actor --create` (مرة واحدة؛ ويمكن التحقق بـ`php artisan logistic:status`).
2. تأكد أن **المجدول يعمل** (`schedule:run` كل دقيقة) — لا يحدث الاستعلام والتحريك المتأخر بدونه — وأن `php` في سطر الأوامر هو نفسه الذي يخدم الموقع أو اضبط `LOGISTIC_PHP_BINARY`.
3. `LOGISTIC_APPLY_STATUS=true` في `.env`، **مع بقاء قائمة المسموح بهم ضيقة** (بائع تجريبي أولًا) — فالتحريك لا يمسّ إلا طلبات سُلِّمت لوجستك أصلًا.
4. راقب أول طلبات حقيقية: صفحة الطلب واللوحة، وقائمة `/admin/logistic/orders` (التنبيهات)، و`logistic:status`.

### 16.10 كيف تتراجع

| المطلوب | الطريقة |
|---|---|
| إيقاف التحريك فورًا | `LOGISTIC_APPLY_STATUS=false` (يعود عرضًا فقط؛ لا يُعاد ما تحرّك) |
| إيقاف الاستعلام أيضًا | احذف كتلة `logistic:status:sync` من `Kernel.php` (6 أسطر) أو `LOGISTIC_ENABLED=false` |
| إزالة المستخدم | `DELETE FROM users WHERE email='logistic-system@kapitano.invalid'` (سجلات التاريخ التي كتبها تبقى بالاسم المنسوخ) |
| إزالة الكود | احذف الملفات الجديدة في 16.6 وأعد `WebhookReceiver` إلى كتابة الرابط مباشرة |

### 16.11 حدود معروفة

- **حالة الطلب في المتجر تتحرك للأمام فقط**؛ لا شيء يُعيد طلبًا للخلف إن تراجع لوجستك (نادرًا ما يحدث) — يظهر ذلك في اللوحة فقط.
- **لا تحريك لـ`delivery_failed` ولا للإلغاء من لوجستك**: تنبيه فقط (قرارك). الموظف يقرر (إعادة إرسال/إرجاع/إلغاء) ثم يزول التنبيه حين يتغير الوضع.
- **الدفع لا يزامَن**: النقد الذي يجمعه الكابتن لا يُعلَّم في المتجر — الخطوة 7.
- إشعارات الزبون عند `delivered` (بريد، إشعار ويب، مطالبة التقييم) **تُرسَل فعلًا** كما في التغيير اليدوي (هذا المطلوب)، فاختبر على بائع تجريبي.
- فاصل الاستعلام 30 دقيقة (`LOGISTIC_STATUS_POLL_AFTER_MINUTES`)؛ ويُسأل 10 طلبات كحد أقصى في الدورة.


---

## 17. الخطوة 7 — الدفع: إخبار لوجستك بحالة دفع الطلب

**الحالة:** منفَّذة ومختبَرة، **متوقفة عند بوابتها بانتظار موافقتك** قبل الخطوة 8 (تعديل الطلب بعد التسليم). **تعمل مع `LOGISTIC_ENABLED` فقط** (لا مفتاح مستقل؛ وهي لا تفعل شيئًا لطلب لم يُسلَّم للوجستك).

### 17.1 ما المشكلة

نقطة الدفع في لوجستك (`POST /orders/{uuid}/payment` بحالات `paid` / `unpaid` / `refunded`) وُجدت لحالة واحدة: **الكابتن يجب ألا يجمع مبلغًا يملكه المتجر فعلًا، أو يسلّم بضاعة على طلب لم يعد مدفوعًا**. في بياناتك: **3433 من 3515 طلبًا دفع عند الاستلام**، والمحاسب يعلّم الطلب "مدفوعًا" أحيانًا **وهو ما زال في الطريق** (9 طلبات هكذا الآن). فبعد التسليم للوجستك لا بد أن يعرف.

ما يعالجه الإنشاء أصلًا (الخطوة 2): طلب دفع عند الاستلام **مدفوع وقت الإنشاء** يُرسَل `prepaid/paid` (لا تحصيل). هذه الخطوة تعالج **تغيير الحالة بعد التسليم**.

### 17.2 ما اكتشفته في بياناتك وشكّل التصميم

- **لا وجود للاسترجاع في المتجر**: `payment_status` إما `paid` أو `unpaid` فقط، ولا جداول refund. لذلك **لا يُرسَل `refunded` أبدًا** (استرجاع طلب غير مُسلَّم يتم عندك بإلغائه، والإلغاء يصل لوجستك بالخطوة 4).
- المتجر يعلّم الدفع عبر شاشة المحاسب (قاعدة: المحاسب المسند للطلب أو Super Admin) ثم `Order::save()` — فكل التغييرات تمر بحدث Eloquent.

### 17.3 التصميم

| القطعة | وظيفتها |
|---|---|
| **المستمع `QueueOrderPayment`** | يسمع **حدث Eloquent نفسه** `eloquent.updated: App\Models\Order` (مسجَّل في `EventServiceProvider`) — فلم يُعدَّل `Order.php` (ويثبّته اختبار). لا يفعل شيئًا إلا إن تغيّر `payment_status` وكان الطلب سُلِّم للوجستك. **لا يستطيع أبدًا أن يكسر حفظ المحاسب**: كل شيء داخل `try/catch` حتى بناء المساعد (اكتشفتُ باختبار أن بناءه كان قبل الحماية فنقلته) |
| **صف دفع واحد لكل طلب** (`payment-<id>`) | يُعاد تفعيله مع كل تغيير. **يُرسَل الوضع الحالي لحظة الإرسال لا لحظة الحدث**: فتسلسل التغييرات (مدفوع ← غير مدفوع ← مدفوع) وإعادات المحاولة لا يمكن أن تترك لوجستك بقيمة قديمة |
| عمود `payment_sent` (جدول الروابط — جدولنا) | آخر حالة دفع أُخبر بها لوجستك؛ يُملأ من الإنشاء ثم من كل مزامنة. هجرة جديدة `2026_10_06_000020` + `sqlupdates/…step7…sql` (**طبّقتُها على قاعدة التطوير المحلية** بهذا الملف وحده) |
| `OutboxSender::processPayment` | القواعد (17.4) |
| `logistic:reconcile` (موسَّع) | يلتقط **تغيير دفع فات الحدث** (تعديل مباشر بلا Eloquent، أو طلب سُلِّم قبل هذه الخطوة): طلب حيّ في لوجستك حالة دفعه في المتجر تخالف ما أُخبر به لوجستك |
| `logistic:send --order=<id> --payment` و`logistic:retry <id> --payment` | للتعامل اليدوي؛ وعدّاد `payment N` في مخرجات `logistic:send` |
| لوحة الطلب | سطر "Payment: told to Logistic: paid" |

### 17.4 القواعد (بالترتيب)

1. تغيّر `payment_status` على طلب **سُلِّم للوجستك** ← صف دفع (وإلا لا شيء). قائمة المسموح بهم لا تُسأل (ما سُلِّم يبقى شأن لوجستك).
2. عند الإرسال تُقرأ حالة الطلب **الآن**. إن كان **الإنشاء لم يعمل بعد** ← يحمل الإنشاء الحالة بنفسه (لا إرسال). **قيد الإرسال الآن** ← ينتظر 20 ث بلا صرف محاولة. فقد المتجر الـuuid ← يسأل لوجستك أولًا (كما في الإلغاء).
3. طلب **انتهى في لوجستك** (`delivered`/`cancelled`/`delivery_failed` أو الرابط ملغى) ← **لا يُرسَل شيء** (لا شيء يمنعه: النقد جُمع أو التوصيل انتهى؛ وتعليم المحاسب لطلب مُسلَّم لاحقًا هو استلامه النقد من التسوية، لا دفع الزبون).
4. لوجستك **يملك أصلًا** هذه الحالة ← لا شيء.
5. **حالة واحدة تُرفَع ولا تُرسَل:** الطلب أُنشئ في لوجستك **prepaid** ثم صار في المتجر `unpaid`. هذه النقطة لا تجعل الكابتن يبدأ التحصيل (يلزم تعديل المبلغ = الخطوة 8) ← **تنبيه `payment_attention_unpaid_after_prepaid`** على الرابط للموظف. وهذا التنبيه **لا تمحوه أخبار الحالة** (بادئة مختلفة عن تنبيهات الخطوة 6)، ويزول فقط حين تتوافق حالة الدفع.
6. الإرسال: `{status: paid|unpaid, reference: كود الطلب, paid_at (للمدفوع فقط)}` بمفتاح `payment-<id>-<الحالة>-<المحاولة>`؛ **مفتاح جديد لكل محاولة** لأن النداء "ضع الحالة" متساوي القدرة، والمفتاح المعاد بجسم مختلف يُرفَض 409.
7. **تغيّر الدفع أثناء الإرسال** (المحاسب غيّره والطلب في الطريق) ← يعود الصف للطابور بدل إغلاقه.
8. 404 ← يُركَن `payment_order_not_found`؛ 422 ← `payment_refused`؛ **ولا يُمس `state` الرابط** (طلب مربوط حقيقي، الصف فقط يحتاج شخصًا). 5xx/شبكة ← إعادة على **الجدول السريع** (10 ث، 30 ث، دقيقة ...)، لأن الكابتن قد يكون في الطريق؛ 401/403 ← إيقاف الدفعة دون صرف محاولة.

### 17.5 الملفات

**جديدة:** `app/Listeners/Logistic/QueueOrderPayment.php` · `database/migrations/2026_10_06_000020_add_payment_sent_to_logistic_links.php` · `sqlupdates/kapitano_logistic_integration_step7_2026-10-06.sql` · `tests/Feature/Logistic/PaymentSyncTest.php`

**معدَّلة (ضمن التكامل):** `LogisticOutbox` (`OP_PAYMENT`, `queuePayment`)، `HandoverQueue`، `OutboxSender`، `LinkStatusDisplay` (بادئة تنبيه الدفع)، `ReconcileCommand`، `SendCommand`، `RetryCommand`، `OrderLogisticView` واللوحة.

**ملفات المتجر القائمة:** **`app/Providers/EventServiceProvider.php` فقط (+6 أسطر)** لتسجيل المستمع على حدث Eloquent. **المجموع الآن: 69 سطرًا مضافة، 0 حذف، في 5 ملفات.** `Order.php` لم يُلمس في هذه الخطوة.

### 17.6 الاختبارات

| الفحص | النتيجة |
|---|---|
| `PaymentSyncTest` (32: ملاحظة التغيير، تغييرات أخرى لا تُرسَل، طلب غير مسلَّم، التعطيل، **فشل المستمع لا يصل حفظ المحاسب**، صف واحد وإعادة تفعيله، الإرسال بالمرجع والوقت، غير مدفوع بلا وقت، **القيمة لحظة الإرسال**، لا إرسال إن تطابقت، التغيّر أثناء الإرسال، الإنشاء يتذكر الدفع، طلبات منتهية، **prepaid ثم unpaid = تنبيه**، زوال التنبيه، أخبار الحالة لا تمحوه، الإنشاء لم يعمل/في الطريق، فقدان الـuuid، 404، 422، الجدول السريع، 401، reconcile بأنواعه، الأوامر، اللوحة، تسجيل المستمع) | 32 ناجحًا |
| اختبار الطفرات (18 إفسادًا) | **17 التُقطت**، والناجية **مكافئة** (الطابور يرفض صفًا ثانيًا أصلًا فالفرق سطر في المخرجات فقط) |
| **كل اختبارات التكامل (Unit + Feature)** | 605 اختبارًا، كلها ناجحة |
| **مجموعة كابيتانو ويب الكاملة** (كل اختبار على حدة) | الاختبارات القائمة: 1041 (837/74 سابقة/130) — **ما تغيّرت نتيجته: صفر**. التكامل: 573 ← **605 كلها ناجحة** (المجموع 1646 اختبارًا) |
| **الاختبار الحي على التطبيق الحقيقي** `logistic_real_flow_e2e.php` (+ القسم 10) | **87 فحصًا، 0 فاشل** |
| إعادة الاختبارات الحية: الخطوة 2 / 3 / 4 / 5 | 84 / 33 / 40 / 47 — **0 فاشل** |

**ما يثبته الاختبار الحي (الأبواب الحقيقية):** طلب C دفع عند الاستلام يمر بالطريق الكامل (API الزبون ← تأكيد البائع ← تأكيد الموظف) فيصل لوجستك **`cash_on_delivery` / `unpaid` وبمبلغ تحصيل**، والمتجر يتذكر `unpaid`. ثم **المحاسب يعلّمه مدفوعًا من شاشة المتجر الحقيقية** (`POST /admin/orders/update_payment_status`) ← صف دفع `done`/200 ← **لوجستك صار `paid`** (فلا تحصيل). ثم يعيده غير مدفوع ← لوجستك `unpaid` عبر **نفس الصف الواحد**. ثم تعديل مباشر بلا حدث ← `logistic:reconcile` يلتقطه ← دورة الإرسال تُخبر لوجستك. وطلب B (سُلِّم في لوجستك) يعلّمه المحاسب مدفوعًا بعدها ← **لا نداء لوجستك** (`logistic_order_finished:delivered`). وصفحة الطلب تعرض "told to Logistic: paid". تنظيف كامل (الجداول، الأدوار المؤقتة، المستخدمون، عنوان لوجستك).

### 17.7 اكتشافات

1. **المستمع كان بنّاؤه خارج الحماية** (حقن `HandoverQueue` في الباني): خطأ فيه كان سيظهر أثناء حفظ المحاسب. اكتشفه اختبار "فشل المستمع لا يصل الحفظ"، فأُزيل الحقن وصار كل شيء داخل `try/catch`.
2. اختبارات الطفرات اتّسق معها: اختبار تنبيه الدفع وأخبار الحالة (بادئتان منفصلتان) يمنع أن تمحو أخبار تقدّم الطلب مشكلة دفع قائمة.
3. **مجموعة الاختبارات الكاملة انتهت بخطأ "Maximum execution time of 300 seconds"** (حتى حين شُغِّلت منفردة). السبب: `FakeDiscountService` (و`ProductCategoryBulkController`) تستدعيان `@set_time_limit(300)`؛ وحين يلمسهما اختبار قائم (في المكان ~624 من 1600) يسري الحدّ **على بقية العملية كلها**. كانت المجموعة قريبة من الحد أصلًا (≈310 ث اختبارات)، واختبارات التكامل (≈130 ث) تعمل **بعد** ذلك الموضع فكانت هي ما يدفعها فوق الحد عند زيادتها. لا أستطيع تعديل الكود القائم، فصار `buildWorld()` في اختباراتي يرفع الحد (`set_time_limit(0)`) لبقية عملية الاختبار — وهذا أثر على عملية الاختبار فقط (سطر CLI الافتراضي بلا حد)، ويثبّت اكتمال المجموعة (1646 اختبارًا). وأضفتُ مفتاح `LOGISTIC_SPACE_SIGNATURES` (الافتراضي مُفعَّل): انتظار العميل ثانية بين توقيعين لجسم واحد (من الخطوة 2) تُطفئه اختبارات الـfixtures فقط لأن HTTP فيها مزيَّف.

### 17.8 قبل الاستعمال الحقيقي

1. تطبيق الهجرة أو ملف SQL للخطوة 7 في الإنتاج (عمود واحد في جدولنا).
2. لا مفتاح جديد: تعمل مع `LOGISTIC_ENABLED`. وتأكد أن **المجدول يعمل** (للإعادات السريعة وreconcile).
3. **قرار تشغيلي لك:** تعليم المحاسب لطلب COD مدفوعًا **أثناء الطريق** سيمنع الكابتن من التحصيل (المقصود). إن كان عندكم أن "مدفوع" يعني "استلمنا نقد الكابتن" وليس "دفع الزبون"، فهذه الخطوة ستخبر لوجستك مبكرًا بأن لا تحصيل — راجع هذا مع المحاسبين قبل التفعيل.

### 17.9 كيف تتراجع

| المطلوب | الطريقة |
|---|---|
| إيقاف المزامنة | احذف تسجيل المستمع (6 أسطر في `EventServiceProvider`) — لا يتأثر شيء آخر؛ أو `LOGISTIC_ENABLED=false` |
| إزالة العمود | `ALTER TABLE logistic_order_links DROP COLUMN payment_sent;` (أو `migrate:rollback` لهذا الملف) |
| إزالة الكود | احذف `QueueOrderPayment` ودوال الدفع في `OutboxSender`/`LogisticOutbox`/`HandoverQueue` |

### 17.10 حدود معروفة

- **لا `refunded`** (لا استرجاع في المتجر).
- طلب أُنشئ prepaid وصار غير مدفوع: **تنبيه فقط**؛ تغيير المبلغ المطلوب تحصيله بعد التسليم هو الخطوة 8.
- لا مزامنة **من** لوجستك إلى المتجر للدفع (النقد الذي جمعه الكابتن لا يُعلَّم مدفوعًا في المتجر؛ تسوية النقد بينكما خارج نطاق هذا التكامل، ويبقى عمل المحاسب).


---

## 18. الإلغاء بعد استلام الكابتن + إشعار الكابتن (Firebase)

**سؤالان من المستخدم:** (1) عندما يستلم الكابتن البضاعة وهو في الطريق لا يجوز للمتجر إلغاء الطلب. (2) عند إلغاء الطلب يجب إرسال إشعار Firebase للكابتن.

### 18.1 الجواب الصريح قبل التنفيذ

| السؤال | الوضع قبل هذا العمل |
|---|---|
| منع الإلغاء بعد الاستلام | **لم يكن معالَجًا** — لا عندي ولا عند لوجستك. عقد لوجستك نفسه كان ينص: "يُقبل الإلغاء من أي مرحلة لم تنتهِ — **حتى بعد أن يستلم الكابتن البضاعة**"، وخطوة 4 عندي اتّبعت هذا العقد حرفيًا |
| إشعار Firebase للكابتن | **موجود أصلًا في لوجستك** (ليس من عملي): `OrderService::cancel` هو الباب الوحيد للإلغاء من لوحة التحكم ومن واجهة المتجر معًا، ويُعلن `OrderStatusChanged`، فيعمل المستمع `NotifyCaptainOfCancellation` (في الطابور) ويرسل `OrderCancelledNotification` عبر FCM (النوع `order_cancelled` مع رقم الطلب والسبب) للكابتن الذي يحمل الطلب فقط. اختبارات لوجستك الخاصة به (5) تنجح، ومفاتيح Firebase مضبوطة |

### 18.2 القاعدة الجديدة

**يُرفض إلغاء المتجر عندما تكون حالة الطلب في لوجستك `picked_up` أو `on_the_way`.** ما يزال الإلغاء مسموحًا في `pending` و`assigned` و`accepted` (وحينها يصل الكابتن إشعار الإلغاء). **عمليات لوجستك (الدكّان الخلفي) ما زالت قادرة على الإلغاء** في أي مرحلة — فالبضاعة في السيارة قرار للعمليات.

**في لوجستك (المصدر الموثوق):**
- `StoreOrderService::cancel` يرمي `IntegrationException::cancelInTransit()` ← **409** بالمفتاح `order_in_transit` ورسالة بالعربية والإنجليزية ("استلم الكابتن الطلب وهو في الطريق، فلم يعد بالإمكان إلغاؤه من جهتكم. تواصلوا مع عمليات كابيتانو"). لا يتغير شيء في الطلب.
- السواجر حُدّث (وصف العملية + استجابة 409) وأُعيد توليده؛ أي متجر آخر يتكامل معه يحصل على القاعدة نفسها.
- 4 اختبارات جديدة (19 في الملف): الرفض في الحالتين مع بقاء الطلب كما هو، السماح قبل الاستلام، **بقاء إلغاء العمليات ممكنًا**، وجود الرسالة باللغتين. وبطفرة (إلغاء القاعدة) فشل الاختبار فعلًا.

**في المتجر (كابيتانو ويب):**
1. **حارس قبل أي أثر جانبي** (`CancelGuard`): يمنع الإلغاء من **المسارين اللذين يستطيعان الوصول لطلب في الطريق**: زر "Cancel Order" في الإدارة (`admin_cancel`) وقائمة تغيير الحالة (`update_delivery_status` بحالة cancelled). يعتمد على آخر ما أخبره به لوجستك (الويبهوك/الاستعلام). يظهر للموظف: "استلم الكابتن الطلب وهو في الطريق، لذلك لا يمكن إلغاؤه من هنا. تواصل مع عمليات كابيتانو." ولا يتغير شيء: لا حالة ولا مخزون ولا سطور.
   - **سبب وضعه قبل الأثر الجانبي:** زر الإلغاء يعيد المخزون ويعلّم السطور، وقائمة الحالة تحرّك المخزون والسطور **قبل** حفظ الطلب؛ حارس يعمل متأخرًا كان سيترك طلبًا نصف ملغى. اختبار يفحص ترتيب الأسطر في الملف.
   - **لا يُعطّل الإلغاء أبدًا بسبب عطل فيه** (`try/catch`، وعند أي خطأ يعمل الإلغاء كما كان).
   - **مساران آخران لم ألمسهما عمدًا:** إلغاء الزبون من التطبيق/الموقع (مسموح للطلب `pending` غير المدفوع فقط، فلا يصل لطلب في الطريق)، واسترجاع Paymera (يُرجع مال الزبون؛ منعه يترك زبونًا مُسترجَعًا وطلبًا يُسلَّم) — وهنا يكون **رفض لوجستك 409** هو خط الدفاع.
2. **خط الدفاع إن كانت معرفة المتجر متأخرة** (رابط يقول `assigned` بينما صار `picked_up`): يمرّ الإلغاء في المتجر ثم يرفضه لوجستك 409 ← يُركَن صف الإلغاء بسبب `cancel_refused:in_transit` وتحذير واضح على الرابط/لوحة الطلب ("طلب المتجر ملغى لكن التوصيل ما زال جاريًا — تواصلوا مع العمليات")، ولا يُعاد تلقائيًا، **والرابط يبقى حيًّا**.
3. **عندما تلغي العمليات التوصيل** (من لوجستك) يصل ويبهوك `order.cancelled` فيُغلق صف الإلغاء المركون وتزول التحذيرات تلقائيًا (`cancelled in Logistic by dispatch`). تحذير آخر لا علاقة له (مثل الدفع) لا يُمحى.

**الترجمة:** الجملة تُنشأ بالإنجليزية تلقائيًا عبر `translate()`؛ والعربية تحتاج صفًا في جدول الترجمة، فأضفتُ `sqlupdates/kapitano_logistic_integration_step8_cancel_guard_2026-10-06.sql` (صفان، يُشغَّل أكثر من مرة بأمان) **وطبّقتُه على قاعدة التطوير**.

### 18.3 الملفات

| الجهة | الملف |
|---|---|
| لوجستك (عدّلتُ) | `app/Services/Integration/StoreOrderService.php` (القاعدة) · `app/Exceptions/Integration/IntegrationException.php` (`cancelInTransit`) · `resources/lang/{en,ar}/integration.php` · `app/OpenApi/Operations/StoreApi/Orders.php` (التوثيق) · `tests/Feature/Integration/StoreOrderLifecycleTest.php` (+4) |
| المتجر (جديد) | `app/Services/Logistic/CancelGuard.php` · `tests/Feature/Logistic/CancelGuardTest.php` (16) · `sqlupdates/…step8_cancel_guard…sql` |
| المتجر (معدَّل ضمن التكامل) | `OutboxSender` (معالجة 409)، `LinkStatusDisplay` (إغلاق الإلغاء المركون)، العقد المثبَّت `resources/logistic/integration-openapi.json` + `.lock.json` (**أعدتُ التثبيت**: فحص الانجراف كشف تغيّر عملية الإلغاء وحدها كما يجب) |
| المتجر (ملف قائم، **+12 سطرًا**) | `app/Http/Controllers/OrderController.php`: حارس في `admin_cancel` وآخر في `update_delivery_status` — **هذان أول تعديل لكود التحكم القائم** في هذا التكامل، وهو المطلوب |

**المجموع في الملفات القائمة الآن: 81 سطرًا مضافة، 0 حذف، في 6 ملفات.**

### 18.4 الاختبارات

| الفحص | النتيجة |
|---|---|
| اختبارات لوجستك الخاصة بدورة الطلب (`StoreOrderLifecycleTest`) | 19 ناجحًا (4 جديدة) + طفرة تُسقط الاختبار |
| اختبارات المتجر الجديدة `CancelGuardTest` | 16 ناجحًا؛ **8 طفرات التُقطت** (إضافة `assigned` للمنع، رفع `on_the_way`، تجاهل مفتاح التكامل، تجاهل الرابط المنتهي، عطل الحارس يمنع الإلغاء، عدم معالجة 409، عدم إغلاق الصف المركون، إزالة الحارس من المسارين) — وطفرة كشفت اختبارًا ضعيفًا (قارن `false < موضع` يمر دائمًا) فقوّيته |
| **كل اختبارات التكامل في المتجر** | **621 اختبارًا، كلها ناجحة** |
| **الاختبار الحي على التطبيق الحقيقي** `logistic_real_flow_e2e.php` (+ القسم 11) | **103 فحصًا، 0 فاشل** |
| إعادة الاختبارات الحية: الخطوة 2 / 3 / 4 / 5 | 84 / 33 / 40 / 47 — **0 فاشل** |

**ما يثبته القسم 11 الحي (طلب حقيقي بكل الأبواب):** يستلم الكابتن الطلب (لوجستك `picked_up`، والمتجر يتبعه بالويبهوك) ← الموظف يضغط **Cancel Order**: **مرفوض**، ويرى السبب في الشاشة التالية، والطلب ما زال `picked_up` غير ملغى، ولا صف إلغاء في الـoutbox ولا مخزون تحرّك ← قائمة الحالة على Cancelled: **422 بالجملة نفسها**، السطور سليمة ← **بمعرفة متأخرة** (جعلتُ الرابط يقول `assigned`) يمرّ الإلغاء في المتجر ← **لوجستك يرفض بـ409** ويُركَن الصف بـ`cancel_refused:in_transit`، **والتوصيل في لوجستك ما زال `picked_up`**، وتحذير واضح على صفحة الطلب ← **العمليات تلغي من لوجستك** ← يصل الويبهوك ويُغلق الصف المركون ويزول التحذير.

### 18.5 إشعار Firebase للكابتن — ما تحقّقتُ منه وما لم أفعله

- **تحققتُ** (بالكود واختبارات لوجستك الخاصة وإعداداته) أنه يعمل لأي إلغاء، ومنه إلغاء المتجر، **عندما يكون الكابتن مُسنَدًا** (طلب بلا كابتن لا إشعار له، وهذا مقصود).
- **لم أرسل إشعارًا حقيقيًا** لجهاز؛ ولا أستطيع من هنا (لا كابتن بجهاز حقيقي).
- **شرط التشغيل:** المستمع في **الطابور** (`ShouldQueue`) فلا يُرسَل الإشعار إلا إن كان `queue:work` يعمل في لوجستك (وهو شرط عمل الويبهوكات أيضًا؛ وحدة systemd جاهزة في `docs/NGINX-DEPLOY.md`). **في نسخة العرض المحلية** مشغّلي الخاص ينفّذ وظائف الويبهوك فقط عمدًا (طابور لوجستك فيه وظائف أخرى)، **فلا يصل إشعار الكابتن محليًا** ما لم تشغّل `php artisan queue:work` هناك.
- لا يلزم تعديل لإرسال الإشعار؛ **إن أردتَ الاطمئنان فأخبرني فأشغّل عامل الطابور في لوجستك لتجربة مع كابتن لديه رمز FCM.**

### 18.6 حدود معروفة

- **قاعدة المتجر تعتمد على آخر ما أُخبر به** (ويبهوك فوري عادة، واستعلام كل 30 دقيقة للساكتة). فاصل بسيط ممكن، وحينها يتولى رفض لوجستك 409 الحماية مع تحذير.
- **استرجاع Paymera** لا يُحجَب (انظر أعلاه).
- لا زر "Cancel Order" مخفي في الواجهة بل رسالة عند الضغط؛ إخفاؤه يحتاج تعديل ملف العرض.
- طلب أُلغي في المتجر والتوصيل جارٍ (حالة 18.2-2) يحتاج **قرار عمليات** (تلغي أو تكمل)، والمتجر يعرضه ولا يقرر.

### 18.7 كيف تتراجع

| المطلوب | الطريقة |
|---|---|
| إزالة الحارس من المتجر | احذف الكتلتين (6 أسطر لكل منهما) من `OrderController` — لا يتأثر شيء آخر |
| إزالة القاعدة من لوجستك | احذف شرط `in_array(...)` في `StoreOrderService::cancel` (وأعد توليد السواجر وثبّته) |

### 18.8 حادثة: تعديلات الخطوة 0 في لوجستك اختفت من شجرة العمل — وأُعيدت

عند تشغيل اختبارات لوجستك الخاصة بعد هذا العمل فشل 3 اختبارات في `StoreTokenLifetimeTest` (رمز المتجر لا يُعرَّف). التحقيق:

- **ما وجدته:** تعديلات الخطوة 0 على **الملفات المتتبَّعة** لم تعد موجودة (`config/auth.php` بلا حارس `store-token`، و`config/integration.php` بلا `token_ttl_days`، وهكذا في 7 ملفات). الملفات **غير المتتبَّعة** (`app/Auth/StoreTokenGuard.php` والهجرة والاختبار) كانت سليمة، وقاعدة البيانات سليمة (الهجرة مُطبَّقة، ورموز المتاجر لها انتهاء صريح).
- **السبب المرجّح:** تعديلات الخطوة 0 لم تُرسَل (commit) قط؛ وطابع التعديل لتلك الملفات 2026-10-05 23:17، أي قبل تسجيل صاحب المشروع لـcommit المسار (`6011cb7` الساعة 23:20) بثلاث دقائق — فالأرجح أن شجرة العمل نُظِّفت لتجهيز ذلك الـcommit. **لا stash ولا أثر في reflog**، فلا أستطيع الجزم. لم أمسّ أي commit لأحد.
- **الأثر لو لم أنتبه:** الحارس القديم `sanctum` كان سيعيد عيب الـ30 يومًا (رمز المتجر يتوقف بعد 30 يومًا من إصداره) — بالضبط ما أصلحته الخطوة 0.
- **ما فعلتُه:** أعدتُ تطبيق التعديلات السبعة كما هي من سجل الجلسة (المزوِّد `boot()` يسجّل الحارس، `config/auth.php`، `token_ttl_days` الافتراضي 365، `StoreCredentialService::tokenExpiry()` وتمريره في المواضع الثلاثة التي تُصدر رمزًا، وحقل `expires_at` في مورد البوابة).
- **النتيجة:** `StoreTokenLifetimeTest` + اختبارات البوابة: **118 ناجحًا**؛ وكل اختبارات لوجستك الخاصة بالتكامل والطلبات والبوابة والتوثيق: **416 ناجحًا، 0 فاشل**.
- **ما يلزمك:** هذه التعديلات **ما زالت غير مُرسَلة (uncommitted)** في لوجستك مثل بقية عمل التكامل — **يُستحسن إرسالها في commit قريبًا** حتى لا تضيع ثانية.

---

## 19. بوابة المتجر: إجمالي المنتجات والرسوم والإجمالي لم تكن تُحسب

**الشكوى:** شاشة الطلب في البوابة لا تُظهر إجمالي المنتجات ولا رسوم التوصيل ولا إجمالي الطلب (كانت تعرض «المطلوب تحصيله» فقط).

**ما تغيّر (لوجستك):** `StorePortalOrderResource` يضيف `items` (الأسطر مع إجمالي كل سطر) و`totals` = `products_total` (مجموع الأسطر) و`delivery_fee` و`total` و`total_is_computed` و`mismatch` و`currency`. القاعدة: **المبلغ المطلوب تحصيله هو الإجمالي الشامل للتوصيل** (قاعدة العمليات المعتمدة في `OrderResource`)، فلا يُضاف الرسم فوقه ثانية؛ وللطلب المدفوع مسبقًا يُحسب الإجمالي من الأجزاء ويُعلَّم `total_is_computed`؛ وإن لم يكن للمنتجات سعر لا يُخترع إجمالي. وأي فرق بين المبلغ المذكور ومجموع الأجزاء يظهر في `mismatch` ولا يُخفى. القائمة تحمّل الأسطر دفعة واحدة (استعلام واحد للصفحة).
**قرار مقصود:** اختبار «البوابة لا تكشف أكثر من الويبهوك» كان يمنع الحقلين؛ وسّعتُ القائمة المسموحة صراحةً (`items`, `totals`) لأنها بيانات المتجر نفسه ورسمه هو، لا شيء من سجلاتنا الداخلية، ووثّقتُها في السواجر (`PortalOrder`).
**اللوحة (kapitano-admin):** بطاقة «ملخص الطلب» في صفحة الطلب: جدول المنتجات، إجمالي المنتجات، رسوم التوصيل، إجمالي الطلب، وتنبيه عند اختلاف الأجزاء عن الإجمالي (ar/en).
**الاختبارات:** `StorePortalOrderTotalsTest` (5) + اختبار الإفصاح؛ StorePortal+Documentation+Integration: 258 ناجحًا؛ `tsc` و`eslint` نظيفان.
**سؤال مفتوح:** في طلب العرض: المنتجات 2,700 والمطلوب تحصيله 2,700 ورسم لوجستك 80، فيظهر تنبيه فرق 80. إن كان المقصود أن يدفع **الزبون** الرسم أيضًا فيجب أن يُضاف إلى المبلغ الذي يرسله المتجر (`OrderPayloadMapper::amountToCollect`)، وهذا قرارك.

### 19.1 إضافة: الإجمالي المحسوب في لوحة الإدارة، ومن يدفع رسم التوصيل

**الطلب:** لوحة إدارة لوجستك تعرض إجمالي الطلب محسوبًا (البضاعة + الرسم)، وبوابة المتجر تعرض الرسم منفصلًا ليعرف المتجر أنه عليه في بعض الحالات.

- **`Order::calculatedTotal()`** = مجموع الأسطر + الرسم (فارغ إن لم تكن للأسطر أسعار). **`Order::feeBearer()`** (يُشتقّ من الأرقام ولا يُخزَّن): `customer` إن غطّى المبلغ المطلوب تحصيله البضاعة والرسم، `store` إن غطّى البضاعة فقط أو كان الطلب مدفوعًا مسبقًا (لا شيء يُحصَّل)، `shared` فيما بينهما، وفارغ إن لا رسم أو لا أسعار.
- **لوحة الإدارة:** الرقم الكبير في بطاقة المال صار **البضاعة + الرسم** (محسوبًا)، وتحته «المطلوب من الزبون عند الباب» (رقم المتجر كما هو) وجملة تقول من يدفع الرسم. `settlement.calculated_total` و`settlement.fee_bearer` جديدان في الاستجابة.
- **البوابة:** الرسم في سطر مستقل مع وسم «عليكم» حين يدفعه المتجر، وإجمالي الطلب محسوبًا، و«ما يدفعه الزبون عند الباب» منفصلًا، وجملة توضح أن الرسم يُحتسب في كشف الحساب. `totals.calculated_total` و`totals.fee_bearer` جديدان.
- **حدّ مهم:** `fee_bearer` استنتاج من الأرقام، وليس قاعدة محاسبية مخزَّنة؛ ما يُحتسب فعليًا على المتجر هو ما يظهر في كشف حسابه/دفتره. إن أردتم قاعدة صريحة لكل متجر («المتجر يدفع الرسم دائمًا» مثلًا) فهذا حقل وإعداد جديد.
- **الاختبارات:** 428 ناجحًا (Order + StorePortal + Documentation + Integration)، منها اختبارات جديدة لحالات الدافع الأربع ولغياب الرسم وللوحة الإدارة؛ `tsc` و`eslint` نظيفان؛ وفُحصت البوابة في المتصفح (الإنجليزية).

### 19.2 قاعدة صريحة لمن يدفع رسم التوصيل (بدل الاستنتاج من الأرقام)

**القرار (من المستخدم):** الرسم على **الزبون في الحالة العادية**، وعلى **المتجر** في: (1) شريحة قيمة تجعل التوصيل مجانيًا للزبون (مثلًا الطلب 3000 فأكثر) — وتبقى على المتجر الفاتورة الكاملة، (2) الطلب المدفوع مسبقًا، (3) فشل التوصيل (زبون غير موجود/رفض)، (4) إلغاء الطلب **بعد** أن استلم الكابتن البضاعة. وقبل الاستلام لا يُحتسب شيء.

**ما تغيّر في لوجستك:**
- **تغيير سياسة مقصود:** كان الإعفاء بالشريحة «تتحمله الشركة» (المتجر لا يُفوتَر شيئًا والكابتن يأخذ كامل الأجر). صار `orders.fee` **دائمًا رسم المنطقة الكامل** (ما يُفوتَر على المتجر وما يستحقه الكابتن)، وتُسجَّل في عمود جديد `fee_customer_share` (هجرة `2026_10_06_100000`) **حصة الزبون** من الرسم: 1 عادي، 0 مجاني للزبون (المتجر يتحمله)، بينهما خصم جزئي (يتقاسمان). الطلبات القديمة تبقى فارغة وتُقرأ «عادية».
- **`Order::feeBearerReason()` / `feeBearer()`:** قواعد صريحة (`normal`, `value_band`, `partial_band`, `prepaid`, `failed`, `cancelled_after_pickup`) وتُرجع `customer | store | shared`؛ فارغ إن لا رسم أو أُلغي قبل الاستلام. حلّت محل الاستنتاج من المبلغ المطلوب تحصيله.
- **الإلغاء بعد الاستلام يُفوتَر:** `StoreLedgerService::recordCancelledAfterPickup` يُستدعى من `OrderService::cancel` داخل معاملة واحدة مع تغيير الحالة (على غرار الفشل)، ويحمل سبب الإلغاء في ملاحظة القيد. الإلغاء قبل الاستلام لا يكتب شيئًا. (تذكير: إلغاء المتجر نفسه بعد الاستلام ما زال **مرفوضًا** بقاعدة القسم 18؛ هذا يعني إلغاء العمليات.)
- **الواجهات:** الاستجابة (لوحة الإدارة والبوابة) فيها `fee_bearer` و`fee_bearer_reason`، والإدارة فيها `fee_customer_share`؛ والشاشتان تعرضان جملة السبب بالعربية والإنجليزية. تحذير «الفرق» يظهر الآن حين يكون الرسم على الزبون ومع ذلك لا يشمله المبلغ المطلوب منه.
- **اختبارات عُدّلت عمدًا:** 11 اختبارًا في `DeliveryFeePricingTest` و`StoreOrderUpdateTest` كانت تثبّت «الرسم صفر/نصف في الشريحة»؛ صارت تثبّت الرسم الكامل + `fee_customer_share`. جديد: `StoreCancelFeeTest` (3) وحالات الدافع الست في اختبار البوابة.
- **ما لم يتغير:** المبلغ المطلوب تحصيله ما زال رقم المتجر (كابيتانو ويب)؛ ليس على لوجستك أن يضيف الرسم إليه. إن أراد المتجر تحميل الزبون الرسم في الحالة العادية فعليه إضافته لما يرسله (`OrderPayloadMapper::amountToCollect`) — ما لم أغيّره.

---

## 20. الخطوة 8: تعديل سلة الطلب بعد التسليم للوجستك

**الهدف (من الخطة):** كل قفل في لوجستك يُرجع 409 الموثّق، ويُسجَّل ولا يُعاد المحاولة.

### 20.1 ماذا يعدّل المتجر بعد التأكيد فعلًا؟
بحثتُ في كابيتانو ويب: الباب الوحيد لتغيير محتوى الطلب بعد التأكيد هو شاشة الإدارة **تغيير الكمية** (`/admin/order/update-qty`) و**حذف منتج** (`/admin/order/delete-item`). لا توجد شاشة لتعديل عنوان التسليم أو بيانات الزبون بعد التأكيد، فلم أبنِ لهما مسارًا (ولن يُبنى شيء بلا باب يستعمله).

### 20.2 ما بُني (كله في ملفات جديدة تقريبًا)
- **المستمع `QueueOrderEdit`** (مسجَّل على أحداث Eloquent نفسها: `updated` للطلب عند تغيّر `grand_total`، و`created/updated/deleted` لسطر الطلب) — فلم يُعدَّل `Order` ولا `OrderDetail`. يكتب صفًّا واحدًا في الـoutbox لكل طلب (`edit-<id>`)، ويُرسَل بعد الاستجابة، وكل ما فيه محروس بـtry/catch فلا يصل خطؤه إلى من يحفظ.
- **صف واحد لكل طلب يُعاد استعماله** (كالدفع): ما يُرسَل هو **السلة كما هي لحظة الإرسال** لا لحظة التغيير، فتعديلان متتاليان يُرسَل منهما الأخير فقط، وترتيب إعادة المحاولة لا يهم.
- **لا يُرسَل إلا إن اختلفت السلة عمّا قبله لوجستك** (جسم الإنشاء أو آخر تعديل مقبول، المحفوظ في عمود `payload`): كمية رُدّت إلى ما كانت عليه لا ترسل شيئًا.
- **الإرسال:** `PATCH /orders/{uuid}` بـ`items` (استبدال كامل للسلة) + `payment.amount_to_collect` (للدفع عند الاستلام فقط). مفتاح التكرار يتبع الجسم (نفس الجسم = نفس المفتاح).
- **الأقفال:** 409 (`order_already_collected` عند استلام الكابتن، `order_not_editable` عند انتهاء الطلب، `order_in_transit`) و422 → **تُركَن** بسبب `edit_refused:<المفتاح>` **ولا تُعاد**، ويظهر على الطلب تحذير بالنص: «لوجستك لم يقبل تغيير هذا الطلب… السلة في التوصيل ما زالت القديمة — تواصلوا مع عمليات كابيتانو ليُبلَّغ الكابتن». ويزول التحذير تلقائيًا حين يقبل لوجستك تعديلًا لاحقًا. 5xx/شبكة → إعادة محاولة بالجدول المعتاد.
- **حالات «لا شيء يُرسَل»:** الطلب لم يُنشأ في لوجستك بعد (الإنشاء سيقرأ السلة الجديدة)، أو توصيل منتهٍ/ملغى في لوجستك، أو لا فرق.
- **أدوات:** `LogisticOutbox::queueEdit` · `HandoverQueue::enqueueEdit` · `LinkStatusDisplay::raise/clearEdit` (بادئة `edit_attention_`) · `OutboxSender::processEdit` · تسجيل المستمع في `EventServiceProvider` (+8 أسطر في ملف قائم).

### 20.3 الأدلة
| الفحص | النتيجة |
|---|---|
| اختبارات `EditSyncTest` الجديدة (الشاشة، الإرسال، عدم التكرار، «تغيّرت أثناء الإرسال»، الأقفال الثلاثة + 422، المسح التلقائي للتحذير، الطلب المنتهي، قبل الإنشاء) | **19 ناجحًا** |
| **طفرات** (تعطيل ركن الأقفال، تعطيل المقارنة، تعطيل إنصات الأسطر، تعطيل فحص «سُلِّم للوجستك»، عدم رفع التحذير، إرسال توصيل منتهٍ) | **كلها التُقطت** (واحدة نجت أولًا، فأضفتُ اختبارين لتغيير سطر وحده وللإضافة/الحذف) |
| كل اختبارات `tests/Feature/Logistic` في المتجر | **500 ناجحًا** |
| **المجموعة الكاملة للمتجر** | 1681 اختبارًا: **القائمة 1041 لم تتغير نتيجة أي منها (صفر)** (الـ74 فاشلة قبل عملي كما هي)، و**اختباراتنا 640 كلها ناجحة** |
| **الاختبار الحي** على الخوادم الحقيقية — القسم 12 الجديد | **116 فحصًا، 0 فاشل**: الكمية 3 ← لوجستك يحمل 3 وثلاثة أضعاف المبلغ؛ ثم 2 عبر الصف نفسه؛ الكابتن يستلم؛ التغيير إلى 5 ← **لوجستك يرفض 409**، سلته ما زالت 2، تحذير على صفحة الطلب، ولا إعادة محاولة |

### 20.4 حدود معروفة
1. **المتجر لا يمنع التعديل بعد الاستلام** (كما يفعل مع الإلغاء): يسمح ثم يسجّل رفض لوجستك. منعه ممكن بحارس في `OrderController::updateQty/deleteItem` (+~6 أسطر لكل) — **قرارك**.
2. **تغيّر الكوبون على طلب شقيق** (طلب متعدد البائعين يُعيد توزيع الخصم) لا يُلحَظ، لأنه لا يغيّر سطرًا في ذلك الطلب؛ يلتقطه فقط تعديل لاحق. نادر، ويحتاج حقنًا في أداة الكوبون.
3. **الرسوم لا تُرسَل في التعديل**: لوجستك يعيد تسعير الرسم بنفسه عند تغيّر السلة.
4. **عنوان التسليم/بيانات الزبون**: لا باب لها في المتجر اليوم (انظر 20.1).

### 20.5 كيف تتراجع
احذف تسجيل `QueueOrderEdit` الأربعة من `EventServiceProvider` — تتوقف الأوامر الجديدة فورًا ولا يتأثر غيرها.

### 20.6 المتابعة: حارس تعديل السلة بعد استلام الكابتن (قرارك على الحد 20.4-1)

نفس منطق منع الإلغاء (القسم 18): شاشتا **تغيير الكمية** و**حذف منتج** في المتجر تسألان أولًا وترفضان **قبل أن تمسّا أي شيء** (لا مخزون ولا سطر ولا مجموع) حين يقول آخر ما أخبره به لوجستك إن الكابتن استلم البضاعة أو في الطريق: «استلم الكابتن الطلب، لذلك لا يمكن تغيير منتجاته من هنا. تواصل مع عمليات كابيتانو.»

- **كود جديد:** `EditGuard` (يتشارك مع `CancelGuard` دالة `inTransit` واحدة فلا يختلف تعريف «في الطريق» بينهما) · `sqlupdates/…step8_edit_guard_2026-10-07.sql` (صفّا الترجمة ar/en، **طُبِّق على قاعدة التطوير**).
- **الأثر على ملف قائم:** `OrderController` **+10 أسطر** (5 في كل من `updateQty` و`deleteItem`)، فيصير مجموع تعديلات هذا الملف 22 سطرًا.
- **لا يُعطَّل التعديل بسبب عطل فيه** (نفس ضمانة الإلغاء). ومعرفة المتجر المتأخرة يغطيها 409 لوجستك كما في 20.2 (يبقى اختبارها حيًا: القسم 12-هـ).
- **اختبارات:** 6 جديدة في `CancelGuardTest` (الحالتان، المسموح قبل الاستلام، المنتهي/غير المسلَّم/العطل، الترجمة، وترتيب الأسطر: السؤال قبل المخزون وقبل الحذف).

---

## 21. الاختبار الشامل من البداية للنهاية (ملء الفجوات)

**الطلب:** بعد اكتمال التكامل، اختبار شامل بلا فجوات.

### 21.1 أولًا: جرد الفجوات (ما كان غير مُختبَر حيًا قبل هذا القسم)
الاختبارات الحية السابقة (الأقسام 0–12 والخطوات 2–5) كانت تحرّك حالة الطلب في لوجستك **بتعديل مباشر لقاعدة البيانات** (`move`) ولا تمرّ بواجهات لوجستك الحقيقية. لذلك لم تكن مُختبَرة حيًا:
1. المسار الحقيقي: **موزّع يسند ← كابتن يقبل ويستلم ويتحرك ويسلّم** عبر واجهات لوجستك نفسها وقيود المحاسبة التي تنتج عنه.
2. **دفتر المتجر** (النقد المحصَّل + الرسم) من تسليم حقيقي.
3. **بوابة المتجر** برؤية المالك الحقيقي (تسجيل دخول، تفاصيل الطلب، الإجمالي المحسوب، الرسم ومن يدفعه).
4. **لوحة إدارة لوجستك** (تفاصيل الطلب والتسوية).
5. **قواعد الرسم**: شريحة القيمة، فشل التوصيل، الإلغاء بعد/قبل الاستلام.
6. **إشعار الكابتن** عند الإلغاء، وتحرير الكابتن.
7. **عزل المتاجر** و401/404، وانتهاء رمز المتجر، وانجراف العقد.

### 21.2 ما أُضيف (الأقسام 13–18 في `logistic_real_flow_e2e.php` + مساعد `tests/Live/support/logistic_actors.php`)
المساعد يُنشئ **موزّعًا وكابتنًا مؤقتين** بنماذج لوجستك نفسها (ورمزيهما)، وشريحة قيمة اختبارية، ويحذف **كل أثرهما** في النهاية بحسب uuid الطلبات (قيود الدفتر، السجل، الأسطر، المهام المنتظرة، مفاتيح Redis للكابتن).
- **13** أشخاص حقيقيون + تسجيل دخول حقيقي لمالك المتجر في البوابة.
- **14** توصيل عادي كامل: إسناد الموزّع ← قبول ← استلام ← في الطريق ← تسليم بنقد؛ **المتجر يتبع كل خطوة** (حالات الطلب في المتجر تتغير بكوده)، **قيدان في الدفتر** (نقد + رسم سالب)، البوابة تعرض المنتجات والرسم منفصلًا والإجمالي المحسوب والرسم على **الزبون**، ولوحة الإدارة تعرض الإجمالي المحسوب ومن يدفع.
- **15** فشل التوصيل: الرسم وحده على المتجر (والسبب في القيد)، البوابة تقول «المتجر/failed»، وطلب المتجر **لا يتغير** (يُنبَّه فقط).
- **16** إلغاء الموزّع بعد الاستلام: الرسم على المتجر بسببه، **مهمة إشعار الكابتن (Firebase) في الطابور**، الكابتن يتحرر، والمتجر يُبلَّغ؛ وإلغاء قبل الاستلام: **لا رسم**.
- **17** شريحة القيمة: رسم المنطقة **كاملًا** ويُحمَّل المتجر (`value_band`، حصة الزبون 0)، ثم تُزال الشريحة.
- **18** عزل: قائمة المالك لمتجره فقط، طلب متجر آخر **404**، بلا تسجيل **401**، رمز المتجر لا يفتح واجهة الموزّع، **انتهاء رمز المتجر ≈ سنة**، **عدم انجراف العقد**، و`logistic:status`، ولا طلب عالق.

### 21.3 النتائج
| الفحص | النتيجة |
|---|---|
| الاختبار الحي الشامل `logistic_real_flow_e2e.php` (الأقسام 0–18) | **164 فحصًا، 0 فاشل**؛ ونظافة تامة بعده (لا موزّع/كابتن/شريحة/طلب/قيد باقٍ، وعدّادات المتجر عادت لما كانت) |
| الاختبارات الحية للخطوات 2 / 3 / 4 / 5 | **84 / 33 / 40 / 47 — 0 فاشل** (الخطوة 2 فشل فيها أولًا فحصا «عدّادات المتجر» بسبب **بقايا مستخدمين وطلبات وهمية من تشغيلين أوقفتُهما أنا يدويًا في منتصفهما** — لا عيب في الكود؛ نظّفتُ البقايا بالاسم والتاريخ وأعدتُ التشغيل فنجحت 84/0) |
| مجموعة اختبارات التكامل في المتجر | 509 ناجحًا؛ والمجموعة الكاملة للمتجر: **القائمة 1041 لم تتغير نتيجة أي منها** |
| اختبارات لوجستك (الطلبات/التسعير/البوابة/التكامل/التوثيق) | 467 ناجحًا |

### 21.4 ما لم يُغطَّ حيًا — بصراحة
1. **إرسال Firebase الفعلي**: يُتحقَّق من أن مهمة إشعار الكابتن **مُدرَجة في الطابور** فقط؛ لا إرسال لجهاز حقيقي (لا جهاز، وإرسال رمز مزيَّف إلى Google ليس اختبارًا مفيدًا). ويلزم `queue:work` في الإنتاج.
2. **تطبيق الكابتن الحقيقي** وواجهة الموزّع في المتصفح: اختُبرت **واجهاتهما البرمجية** الحقيقية لا الشاشات. (لوحة البوابة فُحصت في المتصفح سابقًا؛ لوحة الإدارة لم أفتحها لغياب كلمة مرور المدير.)
3. **الطلب المدفوع مسبقًا** في الرحلة الكاملة (الرسم على المتجر لأنه مدفوع): مُغطّى باختبارات الوحدة/الميزة لا بتشغيل حي.
4. **الدفع عبر بوابات الدفع الحقيقية (Paymera)** و**الإنتاج الحقيقي** (Redis/Reverb/شبكة حقيقية): بيئة محلية فقط.
5. **حدّان معروفان**: إلغاء المتجر نفسه بعد الاستلام يُرفض (القسم 18) فلا يُجرَّب حيًا إلا رفضه؛ وإعادة توزيع الكوبون على طلب شقيق (20.4-2).
6. **أخطاء قائمة ليست من عملي** ظهرت في مجموعة لوجستك الكاملة: اختبار `DispatchSettingKeyTest` (ثابت `EmployeeEarningShare` حُذف في عمل آخر)، و`RoutePlanLifecycleE2ETest`، و`PushManagementApiTest`، واختبارات `Parallel*` (جدول اختبار تالف بسبب تشغيلات متزامنة) — لم ألمس تلك الملفات.

---

## 22. العملة: الليرة السورية فقط (إلزام)

**الطلب:** مبلغ الطلب في التكامل بالليرة السورية فقط؛ ولو أُرسل بالدولار يجب منعه.

**الوضع قبل العمل:** المتجر يخزّن بالدولار ويحوّل بمعدل جدول العملات إلى ليرة صحيحة (18$ ← 2700 ل.س) ويُرسل `currency: SYP` دائمًا. لكن **لوجستك كان يقبل أي عملة من 3 أحرف** (`USD` مثلًا) فيحفظها على الطلب؛ وكابتن يطلب «18» بدل «2700» ودفتر يجمع عملتين معًا.

**ما تغيّر:**
- **لوجستك:** قاعدة واحدة `DeploymentCurrency` (عملة النشر في `config/money.php` = SYP) تُطبَّق على **إنشاء الطلب** و**تعديله**؛ أي عملة أخرى ← **422** برسالة بالعربية/الإنجليزية («تُسعَّر الطلبات بـ SYP فقط. حوّل المبالغ… قبل الإرسال»). حالة الأحرف لا تهم (`syp` = `SYP`) ويُخزَّن `SYP`؛ وبلا عملة ← `SYP`. وSwagger صار `enum: [SYP]` بشرح أن لوجستك **لا يحوّل** وأن كل المبالغ (`unit_price`, `amount_to_collect`, `delivery_fee`) يجب أن تصل بالليرة.
- **المتجر:** أعدتُ تثبيت العقد (انجراف متوقع في عمليتي `POST /orders` و`PATCH /orders/{order}` فقط)، فصار **المتجر نفسه يرفض قبل الإرسال** أي عملة غير SYP. وأضفتُ اختبارًا يثبت أن كل مبلغ يُرسَل بليرات كاملة وأن العملة SYP **حتى لو خلا جدول معدلات الصرف** (يُستعمل المعدل الاحتياطي 150).
- **حدّ صريح:** لوجستك لا يستطيع **اكتشاف** مبلغ بالدولار مكتوب عليه «SYP» (لا يعرف نية المرسل)؛ الحماية هي: المتجر يحوّل، والعقد يمنع التسمية الخاطئة. وشاشات **الموظفين** (إنشاء طلب من اللوحة، تسويات الدفتر) لم أغيّرها لأنها خارج التكامل.
- **الاختبارات:** لوجستك: 274 ناجحًا (التكامل + التوثيق + البوابة)، منها اختبارات جديدة: رفض USD عند الإنشاء ولا يُنشأ شيء، قبول `syp`، الغياب = SYP، ورفض USD عند التعديل. المتجر: 511 ناجحًا في `tests/Feature/Logistic`.

---

## 23. الطلبات المغلَّفة (collect → wrap → deliver)

**السؤال:** هل عالجتَ الطلب الذي يحتاج تغليفًا؟ يجب أن يسلك مسار إنشاء مختلفًا.

**الجواب الصريح قبل العمل:** لوجستك **يدعم** ذلك أصلًا (علم `needs_wrapping` عند الإنشاء ← طلبان مترابطان: رحلة جمع من المورّد إلى مكان التغليف، ثم رحلة توصيل الطرد المغلَّف). لكن **المتجر لم يكن يرسل العلم أبدًا** (لا مفهوم «يحتاج تغليفًا» في المتجر)، والعلم لم يكن موثَّقًا في Swagger فكان العقد المثبَّت سيرفضه. وبوابة المتجر لا تسمح لصاحب المتجر بتحديد مكان التغليف (كان من اللوحة الإدارية فقط).

**القرارات (من المستخدم):** التغليف **لكل طلب** (الزبون أو الموظف يضع علامة)، و**مكان التغليف يُضبط من بوابة المتجر** ويؤخذ تلقائيًا عند إنشاء الطلب الذي يحتاج تغليفًا.

**ما بُني:**
- **لوجستك:** `PUT /api/store-portal/settings/wrapping` (المالك فقط): الاسم (اختياري) + العنوان + الإحداثيان **معًا أو لا شيء** (مسح الكل يُطفئ التغليف؛ والاسم لا يبقى بلا مكان)، موثَّق في Swagger. وأضفتُ `needs_wrapping` إلى توثيق إنشاء الطلب (وأن تغييره بعد الإرسال غير ممكن).
- **لوحة البوابة:** بطاقة «مكان التغليف» في الإعدادات (الاسم، العنوان، خط العرض/الطول) بالعربية والإنجليزية.
- **المتجر:** عمود `orders.needs_wrapping` (هجرة + ملف SQL فيه الترجمة)؛ **الزبون** يضع علامة «تغليف هذا الطلب قبل التوصيل» في صفحة الدفع (وAPI التطبيق يقبل `needs_wrapping`)؛ **الموظف** يضعها في صفحة الطلب **قبل التسليم للوجستك فقط**؛ والمحوِّل يرسل `needs_wrapping: true` **للطلب المغلَّف فقط** (الطلب العادي كما كان حرفيًا). وأعدتُ تثبيت العقد.
- **إن لم يكن للمتجر مكان تغليف:** يرفض لوجستك بـ422 `wrapping_not_configured`؛ فيُركَن الطلب بسبب واضح: «اضبط مكان التغليف في بوابة المتجر ثم شغّل `logistic:retry <الطلب>`».
- **بعد التسليم للوجستك لا يمكن** إضافة التغليف لطلب أو إزالته (طلب واحد لا يتحول لاثنين): يرفض المتجر ويقول السبب.
- **ما لم يتغير:** كل ما بُني (إلغاء، تعديل، دفع، حارس ما بعد الاستلام، الإشعارات) يعمل على **رحلة التوصيل** التي يعرفها المتجر؛ ورحلة الجمع لا يعرفها المتجر ولا تكتب في دفتره، وإلغاء الطلب يلغي الرحلتين (من تصميم لوجستك).

---

## 24. الزبون هو من يقرّر التغليف

**الطلب:** الزبون مسؤول عن التغليف: إن اختاره يُرسَل الطلب إلى لوجستك كطلب مغلَّف، وإن لم يختره فالطلب عادي. حدِّث الـAPIs، واختبر، وتأكد أن لا شيء آخر تأثّر.

**القرار:** صار قرار التغليف **للزبون وحده**: العلامة في صفحة الدفع (الموقع) و`needs_wrapping` في API التطبيق. وأزلتُ قدرة الموظف على تغييرها (صفحة الطلب في الإدارة تعرضها **للقراءة فقط**: «طلب الزبون التغليف: نعم/لا»)، وحذفتُ مسار الموظف وتحكّمه واختباراته.

**متى يُعرَض الخيار على الزبون؟ (`WrappingOption` — مكان واحد يقرّر)** عندما يُقبَل فعلًا طلب مغلَّف:
1. التكامل مفعّل؛
2. كل طلب ستنتجه السلة يذهب إلى لوجستك (البائع مسموح ومن نوع «توصيل للمنزل» لا نقطة استلام ولا شركة شحن)؛
3. **للمتجر مكان تغليف في بوابته** — ولوجستك صار يقول ذلك في `GET /health` (حقل جديد `wrapping_configured`)، فلا يرى الزبون خيارًا سيُرفض بعد أن قيل له إن طلبه قُبل.
الجواب يُخزَّن 5 دقائق (والأخير المعروف يوم كامل)، ومن لم يسمع من لوجستك قط لا يُعرَض له الخيار. وبعد أن يحفظ المتجر مكان التغليف قد يتأخر ظهور الخيار حتى 5 دقائق.

**الـAPIs (كلها إضافات لا تكسر شيئًا):**
- `POST /api/v2/order/store`: يقبل `needs_wrapping` (1/true/on/yes). **طلب التغليف حين لا يكون متاحًا يُرفض برسالة واضحة ولا يُسقَط بصمت** (طلب يصل بلا تغليف بعد أن طلبه الزبون أسوأ من طلب لم يُنشأ). والاستجابة فيها `needs_wrapping`.
- `GET /api/v2/wrapping/availability` (جديد): هل يُعرَض الخيار لسلة هذا الزبون؟ دائمًا 200 بـ`available: true|false`.
- `purchase-history` و`purchase-history-details/{id}`: حقل جديد `needs_wrapping`.
- لوجستك: `wrapping_configured` في `/health` (موثَّق في Swagger، والعقد أُعيد تثبيته).

**منع الأخطاء:** طلب لا يُنشأ إلا بالعلامة؛ بلا علامة يُرسَل بلا أي حقل تغليف (بايت بايت كما كان). ولحماية صفحة الدفع: قفل يجعل عملية واحدة تسأل لوجستك فقط، وإن ردّ لوجستك «توقيع مكرر» (عمليتان وقّعتا القراءة نفسها في الثانية نفسها) ننتظر ثانية ونسأل مرة ثانية بدل أن يُفهم رفضًا — وقد كشفها الاختبار الحي.
