تقرير تحقيق جذري — قبل أي تنفيذ · تذكرة ISS-2026-9293 · مشروع moon4 · كود مرجعي moonui4 / hazemdev4 · 2026-07-27 · خطورة: عالية الميزة معطّلة بالكامل عند العميل
{program_enabled:false, point_value:1} مش عطل في القراءة ولا في تطبيق الموبايل — الموبايل بيعرض بأمانة إعداد محفوظ فعلاً بقيمة «مقفول».
المشكلة إن النظام فيه أربع طبقات مستقلة بتتخلّص من تعديل الإعدادات بصمت، وكل واحدة فيهم لوحدها بتنتج نفس الـpayload بالحرف — وأنا أعدت إنتاج تلاتة منهم فعلياً.
العميل دخّل إعدادات نظام النقاط كاملة في الأدمن وفعّل البرنامج (مرفق سكرين شوت لشاشة /app/webstore/loyalty: التوجل مفتوح · نقطة لكل وحدة = 1 · قيمة النقطة = 1 · أساس الكسب = الإجمالي · نوع الحد = مبلغ ثابت · حد الاستبدال = 20 · أقل فاتورة = 200 · أقل نقاط = 100 · الصلاحية = 12 شهر · تنبيه = 30 يوم · سياسة المرتجع = خصم النقاط · أنواع الأسعار = retail, wholesale).
ومع ذلك تطبيق الموبايل (Flutter) بيستقبل من GET /api/store/loyalty/summary على tarshoby.elbaset.com:
{data: {balance: 0, balance_value: 0, total_earned: 0, total_redeemed: 0,
total_expired: 0, total_adjusted: 0, last_earned_at: null,
last_redeemed_at: null, next_expiry_at: null,
program_enabled: false, ← المتوقع true
point_value: 1, ← ده محفوظ فعلاً
history: []}}
| البند | التفصيل |
|---|---|
| المتوقع | program_enabled: true وشاشة النقاط تشتغل في التطبيق |
| الفعلي | program_enabled: false → التطبيق بيخفي القسم كله فيبان «فاضي» |
| الشاشات المتأثرة | تطبيق الموبايل (نقاطي) · متجر الويب · أي سطح بيقرأ /store/loyalty/* |
| الأدوار | كل عملاء المتجر — مش متعلق بدور ولا بجهاز |
| من إمتى | غير محدد؛ الإعدادات نفسها اتضافت في STORE-066 (2026-07-08) |
| الخطورة | عالية — الميزة اللي العميل ضبطها معطّلة بالكامل |
الملاحظة اللي وجّهت التحقيق كله: balance: 0 وhistory: [] طبيعيين لعميل لسه ماكسبش نقاط. المؤشّر الحقيقي الوحيد هو program_enabled: false — ومعاه point_value: 1، وده تناقض ظاهري هو مفتاح الحل.
program_enabled:false with point_value:1. Zero balance/history are expected for a customer who never earned. The real signal is the false flag alongside a saved point value.تتبّع كامل من العرض للبيانات، كل قفزة باللي اترصد فيها فعلاً:
| # | القفزة | الملف / الرمز | اللي اترصد |
|---|---|---|---|
| ١ | الموبايل يستقبل الرد | سجل Flutter المرفق | 200 + program_enabled:false — الاستدعاء نجح، فمفيش مشكلة شبكة أو صلاحية |
| ٢ | الـendpoint العام | Storefront/LoyaltyController.php:38-39 summary() | بيقرأ settings($customer->company_id)->enabled و->pointValue — قراءة مباشرة بلا منطق إضافي |
| ٣ | طبقة الإعدادات المكتوبة | Support/LoyaltySettings.php:38 for($companyId) | بتقرأ مفاتيح loyalty.* لكل شركة، وبترجع الافتراضي لو المفتاح مش موجود |
| ٤ | الافتراضيات المزروعة | هجرة 2026_07_08_100002_seed_loyalty_setting_definitions.php:48,50 | loyalty.enabled افتراضي '0' · loyalty.point_value افتراضي '0' |
| ٥ | خدمة الإعدادات | Modules/Core/app/Services/SettingsService.php get()/set() | الـboolean بيتخزن '1'/'0' والقراءة filter_var — التحويل سليم تماماً، مش هو السبب |
| ٦ | الكاتب الوحيد | Admin/AdminLoyaltyController.php:41-49 updateSettings() | المصدر الوحيد في النظام كله اللي بيكتب صفوف loyalty.* — مفيش أي seeder أو migration بيكتب قيم |
| ٧ | التحقق من الطلب | Http/Requests/UpdateLoyaltySettingsRequest.php:67 | 'enabled' => ['sometimes','boolean'] — وsometimes معناها: لو المفتاح مش موجود، عدّي بصمت |
| ٨ | الواجهة | features/webstore/loyalty/webstore-loyalty-settings.component.ts:121-138 | بتبعت الحقول المتغيّرة بس، وبتطلب تأكيد، وبترجع صامتة لو الفورم غير صالح |
point_value هو 0، والعميل استقبل 1. يعني الرقم ده محفوظ فعلاً وبيتقرا من نفس المكان. وبما إن الكاتب الوحيد لصفوف loyalty.* هو شاشة الأدمن، يبقى شركة الأدمن = شركة العميل عند هذا التثبيت.
point_value is 0 and the client received 1, so that value is genuinely stored and read from the same place; since the admin screen is the only writer of loyalty.*, the admin and customer companies coincide on this install. The "wrong company" hypothesis is therefore refuted for this symptom — though it remains a real latent defect.الـpayload المُبلَّغ بيتطلب حالة واحدة بالظبط: الإعدادات الفعّالة للشركة اللي بتتقرا فيها point_value = 1 بينما enabled غايب أو '0' أو ''. فيه تلات آليات مستقلة بتوصل للحالة دي، وكل واحدة كافية لوحدها.
الـendpoint بيقبل أسماء مختصرة «ودّية» لـ8 حقول عبر خريطة ALIASES (UpdateLoyaltySettingsRequest.php)، لكن أي مفتاح خارج القايمة بيتسقط بصمت — بدون خطأ وبدون تبليغ. وenabled مالوش أي alias.
ALIASES = [
'earn_rate' => 'earn_points_per_amount',
'max_usage_type' => 'max_redeem_type',
'max_usage_value' => 'max_redeem_value',
'min_invoice_amount' => 'min_invoice_for_redeem',
'min_points_to_use' => 'min_points_to_redeem',
'expiry_type' => 'expiry_unit',
'expiry_value' => 'expiry_amount',
'eligible_customer_types'=> 'eligible_price_type_ids',
];
// ← مفيش أي مدخل لمفتاح التفعيل
إعادة الإنتاج الفعلية (على بيئة التطوير بنفس الكود، واتنضّفت بعدها):
PUT /api/store/admin/loyalty/settings
{"is_enabled":true, "active":true, "loyalty_enabled":true, "point_value":1}
→ HTTP 200 ← نجاح ظاهري كامل
→ enabled = false ← اتسقط بصمت
→ point_value = 1 ← اتحفظ
النتيجة مطابقة حرفيًا لـpayload العميل.
ودي مش فرضية نظرية: نفس العطل بالظبط حصل قبل كده وهو موثّق في رسالة الكوميت 7a488884d (STORE-067، 2026-07-18):
PUT /loyalty/settings بأسماء بديهية (earn_rate، max_usage_type…) مش مطابقة للمفاتيح المعتمدة، فالقيم اتسقطت بصمت واترجعت كافتراضيات.»
يعني الإصلاح ده عالج 8 حقول كان تطبيق الموبايل بيبعتها — ونسي مفتاح التفعيل نفسه. ومُبلِّغ التذكرة الحالية على تطبيق Flutter، نفس صنف العميل اللي اتعمل عشانه الإصلاح.
أربع مسارات في شاشة الأدمن بتلغي الحفظ كليًا أو جزئيًا من غير أي رسالة للمستخدم، والتوجل فاضل مفتوح على الشاشة:
| المسار | الموقع | اللي بيحصل |
|---|---|---|
| نافذة التأكيد اتقفلت | component.ts:133-138 | فيه accept بس — مفيش reject ولا onHide. Esc أو ✕ = ولا طلب ولا تنبيه ولا رجوع للحالة الأصلية |
| الفورم غير صالح | component.ts:124 | return; صامت تمامًا. و4 حقول رقمية مالهاش أي رسالة خطأ في القالب أصلاً — فحقل مفضّي = زرار حفظ ميّت بلا أي مؤشر |
| فاصلة زايدة في قائمة | component.ts:272-282 | "3," بتخلّي الفورم كله غير صالح، والرسالة بتظهر جنب حقل تاني مالوش علاقة |
| الفورم فاضل معطّل | component.ts:259-262 | الفورم المعطّل بيرجّع invalid=false → payload فاضي → رسالة مضلّلة «مفيش تغييرات للحفظ» |
وكمان مفيش حارس «تغييرات غير محفوظة» — الخروج من الشاشة بيرمي التوجل بصمت، والزيارة الجاية بتعرض «مقفول»، واللي العميل بيحسّه كإن «الإعداد بيرجع لوحده».
PUT نفسه (component.ts:187)، فأي مفتاح الباك-إند تجاهله بيرجع يقفل قدام عين المستخدم.
enabled: true.
SettingsService::getCompanySettings() بيغلّف قراءة كل إعدادات الشركة في Cache::remember('settings.company.{id}', 300, …). تلات عيوب:
set() بس — أي كتابة أو مسح مباشر على الجدول (سكربت، seeder، SQL) بيسيب الكاش قديم.loyalty.point_value='1' مش موجود في قاعدة البيانات — وده أنتج enabled=false, point_value=1 بالحرف. لكن الحالة دي من صنعي أنا: كنت مسحت صفوف الاختبار بمسح مباشر تخطّى تنظيف الكاش. فهي برهان على الآلية، مش دليل عضوي على تثبيت العميل — والآلية نفسها قابلة للحدوث في الإنتاج عبر العيبين (١) و(٢).
صفوف الإعدادات ليها نطاق رباعي (setting_key, company_id, branch_id, user_id)، لكن LoyaltySettings::for() بينادي get($key, $companyId) بـbranchId=null, userId=null — يعني بيشوف صف الشركة فقط. وسلسلة الرجوع في SettingsService::get() بتتخطّى صفوف الفروع تمامًا لما ما يتبعتش فرع.
والحالة دي قابلة للحدوث فعلاً: الـendpoint العام للإعدادات Modules/Core/app/Http/Controllers/SettingController.php بيقبل أي setting_key (بلا قائمة مسموحات على الكتابة) ومعاه branch_id وuser_id من جسم الطلب. فأي حفظ لمفتاح loyalty.* من مسار فيه سياق فرع بيخلق صف صحيح تمامًا وغير مرئي لنظام النقاط.
إعادة الإنتاج الفعلية (بيئة التطوير، واتنضّفت بعدها):
-- الصفوف الفعلية في قاعدة البيانات
loyalty.enabled value='1' branch_id=1 ← محفوظ وقيمته صحيحة
loyalty.point_value value='1' branch_id=NULL
-- اللي نظام النقاط بيشوفه فعليًا
program_enabled = false ← الصف موجود بس غير مرئي
point_value = 1
النتيجة مطابقة حرفيًا لـpayload العميل.
ده أخطر الأسباب الأربعة لأن كل المؤشرات بتقول إن الإعداد متحفظ صح: الصف موجود، قيمته 1، وأي شاشة واعية بالفرع هتعرضه مفعَّل — بينما نظام النقاط عمره ما هيشوفه.
| # | السبب | الترجيح | ليه |
|---|---|---|---|
| ١ | صف بنطاق فرع غير مرئي | الأعلى | التثبيت متعدد الفروع بتأكيد المالك؛ مُعاد إنتاجه حرفيًا؛ وبيفسّر ليه العميل متأكد إنه فعّل (الإعداد فعلاً متحفظ صح) |
| ٢ | إسقاط صامت لمفتاح باسم غير مطابق (باك-إند) | عالٍ | سابقة موثّقة بنفس الآلية لنفس الـendpoint لنفس صنف العميل (موبايل)، ومُعاد إنتاجه حرفيًا |
| ٣ | إلغاء صامت للحفظ (واجهة الأدمن) | متوسط | 4 مسارات مؤكدة بالكود؛ بيتطلب محاولتين حفظ |
| ٤ | كاش قديم/ملوّث | متوسط | الآلية مؤكدة؛ محتاج فحص A/B بمسح الكاش |
SELECT setting_key, value, company_id, branch_id, user_id
FROM settings WHERE setting_key LIKE 'loyalty.%';
• صف loyalty.enabled بـbranch_id مش NULL ⇒ السبب ١ (والحل فوري ومضمون).branch_id=NULL وقيمته '1' ومع ذلك الـAPI بيرجّع false ⇒ السبب ٤ (كاش).
الفحص الحاسم رخيص وسريع — مطلوب واحد من التلاتة دول عشان نقفل الترجيح نهائيًا:
enabled غايب ⇒ سبب ١ أو ٢. لو موجود وبقيمة true والرد رجّع false ⇒ سبب ٣.SELECT company_id, branch_id, user_id, setting_key, value FROM settings WHERE setting_key LIKE 'loyalty.%'.الآلية مش انحدار (regression) ومش بيانات فاسدة — دي ثغرة تصميمية في حدود الثقة:
sometimes على كل مفتاح، وده اختيار سليم لدعم التحديث الجزئي — بس من غير أي تبليغ عن المفاتيح المرفوضة. فالنتيجة إن «الحقل مش مبعوت» و«الحقل باسم غلط» بيدّوا نفس النتيجة: 200 OK صامت.STORE-067 شاف نفس المشكلة وحلّها بإضافة خريطة أسماء للحقول الـ8 اللي اشتكى منها العميل وقتها — بدل ما يقفل الصنف نفسه (تبليغ عن أي مفتاح مجهول). فأول مفتاح خارج القايمة — وهو مفتاح التفعيل — وقع في نفس الحفرة.AdminLoyaltyController:43-49) — فأي فشل في النص بيسيب حالة نصفية دايمة.sometimes validation plus zero unknown-key reporting makes "field omitted" and "field misnamed" indistinguishable, both answering 200. STORE-067 patched the symptom (an alias map for the 8 complained-about fields) instead of closing the class, so the first key outside that map — the enable flag — fell into the same hole. The FE compounds it by never telling the user a save was skipped, and the writer loops key-by-key outside a transaction.التثبيت شركة واحدة / فروع متعددة (تأكيد المالك). مفيش صفوف فاسدة محتاجة إصلاح رجعي واسع — المطلوب صف واحد صحيح لـloyalty.enabled بنطاق الشركة (branch_id = NULL).
الخطر المحدّد: LoyaltySettings::for() بيقرأ نطاق الشركة فقط، فصف بنطاق فرع بيبقى موجود وصحيح وغير مرئي تمامًا لنظام النقاط — وأي شاشة واعية بالفرع هتعرضه «مفعَّل»، وده بيخلّي العميل متأكد إنه ضبطه صح. أُعيد إنتاج الحالة دي حرفيًا (القسم ٣، السبب الرابع).
الإصلاح على مستوى البيانات = ترحيل صف الفرع لنطاق الشركة (أو إعادة الحفظ من الشاشة الصحيحة)، مش إضافة صف جديد فوقه — عشان ما نسيبش صفّين متعارضين.
| الموقع | الشركة المستخدمة | سليم؟ |
|---|---|---|
Storefront/LoyaltyController.php:38,39,60 | شركة العميل | شاذ — الوحيد اللي بيقرأ إعدادات من العميل |
Admin/AdminLoyaltyController.php:33,41,48,59 | شركة الموظف | شاذ — الكتابة والقراءة على شركة تانية |
LoyaltyPointsService.php:63,234 (كسب/عكس) | شركة الطلب | متّسق داخليًا |
| كل كنترولرات إعدادات المتجر التانية | StoreCompany::id() المثبّتة | الاصطلاح الصحيح |
ولا نداء واحد في نظام النقاط بيستخدم الشركة المثبّتة — رغم إن ده اصطلاح كل باقي إعدادات المتجر.
loyalty.expiry_notify_days — إعداد ميّت: بيتكتب ويتخزّن ويترجع في الرد، ومفيش أي كود بيستهلكه. العميل ضبطه على 30 يوم ومش هيوصله ولا تنبيه واحد.
وكمان store.points_egp_rate بيمثّل «قيمة النقطة» تاني بجانب loyalty.point_value، والاتنين بيتقروا من شركتين مختلفتين ومن غير أي توفيق بينهم.
اتنفت كسبب: من غير webstore.loyalty.update زرار الحفظ مابيظهرش أصلاً والفورم بيفضل معطّل، والباك-إند بيفرضها كمان. فمستحيل مستخدم بلا صلاحية «يبان» إنه حفظ.
البرنامج بيمسّ أرصدة قابلة للاستبدال بقيمة نقدية على الفواتير. أي إصلاح بيمسّ point_value أو الكسب أو الاستبدال يتصنّف مالي ويحتاج مراجعة إضافية. الإصلاح الأساسي هنا (تبليغ عن مفاتيح مجهولة + رسائل واجهة) مش مالي لأنه مابيغيّرش أي حساب.
غير متعلق — الأرقام والأعلام مش مترجمة. الرسائل الجديدة المقترحة في الواجهة هتحتاج مفاتيح في ar.json/en.json.
retail, wholesale. لكن LoyaltySettings::customerEligible() بيقارن (string) $priceTypeId بتاع العميل بالقايمة دي مقارنة صارمة.
price_type_id رقمي (وده الشكل الطبيعي)، فالمقارنة in_array('3', ['retail','wholesale'], true) هترجع false دايمًا ⇒ كل العملاء غير مؤهَّلين ⇒ صفر نقاط تُكتسب وصفر تُستبدل حتى بعد ما التفعيل يشتغل.
price_type_id في تسجيل العميل غير مُتحقَّق منه (بلا exists وبلا نطاق شركة).
⇒ تصليح التفعيل لوحده مش هيرضّي العميل. لازم يتحقق من شكل أنواع الأسعار عنده في نفس الدورة.
| الموقع | المشكلة | الخطورة |
|---|---|---|
StoreCustomerResource.php:35 | بيقرأ store.points_egp_rate من شركة العميل، بينما StoreSettingsController بيقدّم نفس المفتاح من الشركة المثبّتة — سطحان بيختلفوا حتميًا | عالية |
RegisterRequest.php:18-22,29 | branch_id مقبول من أي فرع لأي شركة بلا تقييد بالشركة المثبّتة — ده المُمكِّن الأصلي لكل عيوب اختلاف الشركة | عالية |
StoreCouponController.php:32-36 | بيقرأ التهيئة مباشرة، فبيتخطّى التثبيت في قاعدة البيانات؛ ولو التهيئة فاضية بيرجّع كوبونات كل الشركات | عالية (موثّقة في الـKB) |
StorePoints.php:11,19 | ?int بيتمرّر لدالة بتطلب int — عميل بلا شركة = خطأ 500 بدل رجوع صفر | متوسطة |
StoreAnalyticsOutboxService.php:110-113 | إعدادات الموافقة والتتبّع بتتقرا من شركة الطلب مش المثبّتة | متوسطة |
SettingsService::getCompanySettings() | كاش غير واعٍ بالمعاملات — بيمسّ كل الموديولات مش النقاط بس | عالية |
نطلب منه يفتح التوجل ويحفظ ويتأكد إن الرد رجّع enabled: true، أو نضبط الصف مباشرة.
ميزة: دقايق. عيب: مابيصلحش أي سبب — نفس العطل هيتكرر مع أي مفتاح تاني ومع أي عميل تاني، والعميل هيقع بعدها فورًا في مانع أنواع الأسعار (٥.٧).
ميزة: بيقفل الصنف كله لكل الحقول ولكل العملاء، وبيخلّي أي تكرار مستقبلي مرئي فورًا. عيب: شغل أوسع شوية، وبيمسّ عقد الرد (إضافة فقط، متوافق للخلف).
زيادة على (ب): تثبيت إعدادات النقاط على الشركة المثبّتة في الطرفين، تقييد فرع التسجيل، وإصلاح وعي الكاش بالمعاملات.
ميزة: بيقفل عائلة عيوب كاملة موثّقة في الـKB. عيب: بيمسّ طبقة مشتركة بيستخدمها كل النظام — يستاهل دورة مستقلة بمراجعة أعمق، مش يتحشر في تذكرة عطل.
| WP | النطاق | الريبو | يعتمد على | الاختبار المُثبِت | FIN | هجرة |
|---|---|---|---|---|---|---|
| WP0 أولاً | تشخيص بيانات العميل: استعلام قراءة واحد على settings WHERE setting_key LIKE 'loyalty.%' لتحديد أي من الأسباب الأربعة فعّال — ولو الصف بنطاق فرع، ترحيله لنطاق الشركة (إصلاح فوري ومضمون للعميل) | — | — | الـsummary يرجّع program_enabled:true مباشرة بعد الترحيل | لا | لا |
| WP1a | نظام النقاط يقرأ الإعدادات بوعي بالفرع — أو (الأبسط والأأمن) الـendpoint العام يمنع نطاق الفرع على مفاتيح loyalty.* لأنها إعدادات على مستوى المتجر مش الفرع، فيستحيل يتخلق صف غير مرئي | BE | WP0 | اختبار: محاولة حفظ loyalty.enabled بنطاق فرع تترفض بوضوح (تفشل على الكود الحالي) | لا | لا |
| WP1 | الـendpoint يرجّع applied[] وignored[]، ويرفض بـ422 لو مفيش مفتاح معروف؛ + alias لمفتاح التفعيل؛ + لفّ الكتابة في معاملة واحدة | BE | — | اختبار: إرسال مفتاح باسم غلط يرجّعه في ignored ولا يرجّع 200 صامت (يفشل على الكود الحالي وينجح بعد الإصلاح) | لا | لا |
| WP2 | الواجهة: رسالة صريحة عند فشل التحقق · رسائل خطأ للحقول الرقمية الأربعة · معالج إغلاق لنافذة التأكيد · عرض المفاتيح المتجاهَلة الراجعة من WP1 | FE | WP1 | ng build أخضر + فحص يدوي للمسارات الأربعة | لا | لا |
| WP3 | تشخيص أنواع الأسعار عند العميل: نصوص ولا أرقام؛ ولو أرقام، توحيد المقارنة في customerEligible() + تحقق من price_type_id | BE | — | اختبار: عميل بنوع سعر رقمي مع إعداد بنصوص يبقى مؤهَّل بعد الإصلاح | نعم | لا |
| WP4 | التحقق النهائي عند العميل: الحفظ يرجّع enabled:true والـsummary يرجّع program_enabled:true | — | WP1..3 | إعادة تنفيذ خطوات العرض الأصلية | لا | لا |
خارج نطاق هذه التذكرة (تذاكر منفصلة مقترحة): وعي كاش الإعدادات بالمعاملات (طبقة مشتركة) · توحيد مصدر شركة النقاط + تقييد فرع التسجيل · تسريب الكوبونات · ازدواج «قيمة النقطة» بين مفتاحين · expiry_notify_days الميّت.
| # | القرار | التوصية |
|---|---|---|
| ١ | نمشي بالخيار (ب) — قفل الصنف — ولا نكتفي بإصلاح فوري للعميل (أ)؟ | (ب). الإصلاح الفوري مش بيمنع التكرار، والعميل هيرجع بنفس الشكوى مع أول مفتاح تاني. |
| ٢ | الفحص الحاسم عند العميل (جسم الطلب / استعلام قراءة / مسح كاش) — مين ينفّذه؟ | نطلب من العميل جسم الطلب من تبويب Network — أرخص وأسرع مُميِّز، وبيقفل الترجيح نهائيًا قبل ما نكتب سطر. |
| ٣ | مانع أنواع الأسعار (٥.٧) — في نفس التذكرة ولا منفصل؟ | في نفس الدورة (WP3). من غيره العميل هيصلح التفعيل ويلاقي صفر نقاط، فتتحسب التذكرة «مش متصلحة». |
| ٤ | إصلاح كاش الإعدادات المشترك — دلوقتي ولا تذكرة مستقلة؟ | تذكرة مستقلة. بيمسّ كل الموديولات؛ حشره هنا بيوسّع نطاق تذكرة عطل ويزوّد المخاطرة. |
| ٥ | الأشقاء الكامنة (٥.٩) — نصلحهم دلوقتي ولا نسجّلهم؟ | نسجّلهم كتذاكر ونصلح منهم points_egp_rate وRegisterRequest في دورة «توحيد الشركة» — غير مرئيين على تثبيت بشركة واحدة، بس بينفجروا فورًا مع أول تثبيت متعدد الشركات. |
moonui4 بنفس الكود، وكل تعديل اتعمل للاختبار اترجع لأصله (صفر صفوف loyalty.*، الكاش نضيف). ماتكتبش ولا سطر كود — ولا اتلمس تثبيت العميل غير قراءة.