نظام النقاط بيرجع «غير مُفعَّل» رغم تفعيله في الأدمن

تقرير تحقيق جذري — قبل أي تنفيذ · تذكرة ISS-2026-9293 · مشروع moon4 · كود مرجعي moonui4 / hazemdev4 · 2026-07-27 · خطورة: عالية الميزة معطّلة بالكامل عند العميل

خلاصة في أربع جمل: العرض {program_enabled:false, point_value:1} مش عطل في القراءة ولا في تطبيق الموبايل — الموبايل بيعرض بأمانة إعداد محفوظ فعلاً بقيمة «مقفول». المشكلة إن النظام فيه أربع طبقات مستقلة بتتخلّص من تعديل الإعدادات بصمت، وكل واحدة فيهم لوحدها بتنتج نفس الـpayload بالحرف — وأنا أعدت إنتاج تلاتة منهم فعلياً.
والأرجح في تثبيتك (شركة واحدة / فروع متعددة): صف التفعيل متحفظ صح بس بنطاق فرع، ونظام النقاط بيقرأ نطاق الشركة فقط — فبيشوفه «مقفول» رغم إنه مفعَّل. السبب الجذري الحقيقي مش مفتاح مكسور، ده صنف كامل: «الحفظ بيرجع 200 نجاح من غير ما يحفظ، ومن غير ما يقول لحد». وفيه مانع تاني مستقل هيوقف البرنامج حتى بعد تصليح التفعيل — أنواع الأسعار متخزّنة كنصوص والمقارنة بتتم مع أرقام.
Four-sentence summary: The payload is not a read bug nor a mobile bug — the app faithfully renders a genuinely-stored "disabled" value. The system has three independent layers that silently discard a settings change, each of which alone reproduces the exact payload; two were reproduced live. The true root cause is a class, not a key: "save returns 200 without saving, and tells nobody." A second, independent blocker will keep the program dead even after the toggle is fixed.

١ · العرض / الأعراض

العميل دخّل إعدادات نظام النقاط كاملة في الأدمن وفعّل البرنامج (مرفق سكرين شوت لشاشة /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، وده تناقض ظاهري هو مفتاح الحل.

The client configured and enabled the program, yet the mobile app receives 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,50loyalty.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.* هو شاشة الأدمن، يبقى شركة الأدمن = شركة العميل عند هذا التثبيت.
⇒ فرضية «الإعدادات راحت لشركة تانية» مرفوضة بالدليل لهذا العرض تحديداً (لكنها عيب حقيقي قائم — القسم ٥).
The seeded default for 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' أو ''. فيه تلات آليات مستقلة بتوصل للحالة دي، وكل واحدة كافية لوحدها.

السبب الأول مُعاد إنتاجه حيًّا — الباك-إند بيتجاهل المفاتيح المجهولة بصمت ويرجّع 200

الـ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:124return; صامت تمامًا. و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, …). تلات عيوب:

  1. مش واعي بالمعاملات (transactions): قراءة جوّه معاملة بتخزّن صفوف غير مُثبَتة، ولو المعاملة اترجعت الكاش يفضل شايلها لحد ٥ دقايق.
  2. سباق قراءة/كتابة كلاسيكي: طلب قرأ الحالة القديمة، طلب تاني كتب ونظّف الكاش، الأول كتب لقطته القديمة فوق التنظيف.
  3. التنظيف بيحصل من set() بس — أي كتابة أو مسح مباشر على الجدول (سكربت، seeder، SQL) بيسيب الكاش قديم.
ملاحظة أمانة: أثناء التحقيق ظهرت على بيئة التطوير لقطة كاش فيها صف loyalty.point_value='1' مش موجود في قاعدة البيانات — وده أنتج enabled=false, point_value=1 بالحرف. لكن الحالة دي من صنعي أنا: كنت مسحت صفوف الاختبار بمسح مباشر تخطّى تنظيف الكاش. فهي برهان على الآلية، مش دليل عضوي على تثبيت العميل — والآلية نفسها قابلة للحدوث في الإنتاج عبر العيبين (١) و(٢).

السبب الرابع مُعاد إنتاجه حيًّا 🔴 الأنسب لتثبيت العميل — صف محفوظ صح بنطاق فرع لكنه غير مرئي

أكّد المالك (2026-07-27): التثبيت فيه شركة واحدة وأكتر من فرع. ده بيستبعد فرضية اختلاف الشركة نهائيًا وبتأكيد مباشر (مش استنتاج)، وبيرفع بُعد الفرع لأعلى الترجيح.

صفوف الإعدادات ليها نطاق رباعي (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 ⇒ السبب ٤ (كاش).

الفحص الحاسم رخيص وسريع — مطلوب واحد من التلاتة دول عشان نقفل الترجيح نهائيًا:

  1. جسم الطلب من تبويب Network وقت الحفظ: لو enabled غايب ⇒ سبب ١ أو ٢. لو موجود وبقيمة true والرد رجّع false ⇒ سبب ٣.
  2. استعلام قراءة فقط: SELECT company_id, branch_id, user_id, setting_key, value FROM settings WHERE setting_key LIKE 'loyalty.%'.
  3. مسح الكاش ثم إعادة النداء: لو الناتج اتغيّر من غير أي تغيير في البيانات ⇒ سبب ٣.

٤ · ليه حصلت (الآلية)

الآلية مش انحدار (regression) ومش بيانات فاسدة — دي ثغرة تصميمية في حدود الثقة:

  1. حدود غير محروسة: الـendpoint بيستخدم sometimes على كل مفتاح، وده اختيار سليم لدعم التحديث الجزئي — بس من غير أي تبليغ عن المفاتيح المرفوضة. فالنتيجة إن «الحقل مش مبعوت» و«الحقل باسم غلط» بيدّوا نفس النتيجة: 200 OK صامت.
  2. إصلاح عالج الأعراض مش الصنف: STORE-067 شاف نفس المشكلة وحلّها بإضافة خريطة أسماء للحقول الـ8 اللي اشتكى منها العميل وقتها — بدل ما يقفل الصنف نفسه (تبليغ عن أي مفتاح مجهول). فأول مفتاح خارج القايمة — وهو مفتاح التفعيل — وقع في نفس الحفرة.
  3. افتراض في الواجهة: «لو الحفظ ما اتنفّذش، المستخدم هيلاحظ» — وده غير صحيح: مفيش رسالة، والتوجل بيفضل مفتوح، فالمستخدم متأكد إنه فعّل.
  4. الكتابة مفتاح-بمفتاح خارج أي معاملة (AdminLoyaltyController:43-49) — فأي فشل في النص بيسيب حالة نصفية دايمة.
Mechanism: not a regression and not bad data — an unguarded boundary. 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 زرار الحفظ مابيظهرش أصلاً والفورم بيفضل معطّل، والباك-إند بيفرضها كمان. فمستحيل مستخدم بلا صلاحية «يبان» إنه حفظ.

٥.٥ المال FIN

البرنامج بيمسّ أرصدة قابلة للاستبدال بقيمة نقدية على الفواتير. أي إصلاح بيمسّ 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,29branch_id مقبول من أي فرع لأي شركة بلا تقييد بالشركة المثبّتة — ده المُمكِّن الأصلي لكل عيوب اختلاف الشركةعالية
StoreCouponController.php:32-36بيقرأ التهيئة مباشرة، فبيتخطّى التثبيت في قاعدة البيانات؛ ولو التهيئة فاضية بيرجّع كوبونات كل الشركاتعالية (موثّقة في الـKB)
StorePoints.php:11,19?int بيتمرّر لدالة بتطلب int — عميل بلا شركة = خطأ 500 بدل رجوع صفرمتوسطة
StoreAnalyticsOutboxService.php:110-113إعدادات الموافقة والتتبّع بتتقرا من شركة الطلب مش المثبّتةمتوسطة
SettingsService::getCompanySettings()كاش غير واعٍ بالمعاملات — بيمسّ كل الموديولات مش النقاط بسعالية

٦ · الحلول المقترحة

الخيار (أ) — إصلاح فوري للعميل فقط أضيق

نطلب منه يفتح التوجل ويحفظ ويتأكد إن الرد رجّع enabled: true، أو نضبط الصف مباشرة.

ميزة: دقايق. عيب: مابيصلحش أي سبب — نفس العطل هيتكرر مع أي مفتاح تاني ومع أي عميل تاني، والعميل هيقع بعدها فورًا في مانع أنواع الأسعار (٥.٧).

الخيار (ب) — قفل الصنف: «ممنوع حفظ صامت» مُوصى به

  1. الـendpoint يرجّع قايمة المفاتيح اللي اتطبّقت واللي اتجاهلها في الرد، وترفض الحفظ لو مفيش ولا مفتاح معروف اتبعت.
  2. إضافة alias لمفتاح التفعيل يغطي الأسماء الشائعة.
  3. الكتابة تتلفّ في معاملة واحدة — كله أو لا شيء.
  4. الواجهة: رسالة صريحة عند فشل التحقق، رسائل خطأ للحقول الأربعة الناقصة، ومعالج إغلاق لنافذة التأكيد يرجّع الحالة.

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

الخيار (ج) — (ب) + توحيد مصدر الشركة + الكاش أشمل

زيادة على (ب): تثبيت إعدادات النقاط على الشركة المثبّتة في الطرفين، تقييد فرع التسجيل، وإصلاح وعي الكاش بالمعاملات.

ميزة: بيقفل عائلة عيوب كاملة موثّقة في الـKB. عيب: بيمسّ طبقة مشتركة بيستخدمها كل النظام — يستاهل دورة مستقلة بمراجعة أعمق، مش يتحشر في تذكرة عطل.

التوصية: (ب) دلوقتي في هذه التذكرة — بيصلح العطل المُبلَّغ ويقفل الصنف اللي سبّبه، بأقل مخاطرة وبدون لمس طبقة مشتركة. مع فحص فوري لأنواع الأسعار (٥.٧) لأنه هيمنع البرنامج من الشغل بعد الإصلاح مباشرة. وبنود (ج) تتفتح تذاكر منفصلة — خصوصًا الكاش المشترك، لأن أثره على كل الموديولات ومش صح يتصلح تحت تذكرة نقاط.

٧ · خطة الإصلاح (Work Packages)

WPالنطاقالريبويعتمد علىالاختبار المُثبِتFINهجرة
WP0 أولاًتشخيص بيانات العميل: استعلام قراءة واحد على settings WHERE setting_key LIKE 'loyalty.%' لتحديد أي من الأسباب الأربعة فعّال — ولو الصف بنطاق فرع، ترحيله لنطاق الشركة (إصلاح فوري ومضمون للعميل)الـsummary يرجّع program_enabled:true مباشرة بعد الترحيللالا
WP1aنظام النقاط يقرأ الإعدادات بوعي بالفرع — أو (الأبسط والأأمن) الـendpoint العام يمنع نطاق الفرع على مفاتيح loyalty.* لأنها إعدادات على مستوى المتجر مش الفرع، فيستحيل يتخلق صف غير مرئيBEWP0اختبار: محاولة حفظ loyalty.enabled بنطاق فرع تترفض بوضوح (تفشل على الكود الحالي)لالا
WP1الـendpoint يرجّع applied[] وignored[]، ويرفض بـ422 لو مفيش مفتاح معروف؛ + alias لمفتاح التفعيل؛ + لفّ الكتابة في معاملة واحدةBEاختبار: إرسال مفتاح باسم غلط يرجّعه في ignored ولا يرجّع 200 صامت (يفشل على الكود الحالي وينجح بعد الإصلاح)لالا
WP2الواجهة: رسالة صريحة عند فشل التحقق · رسائل خطأ للحقول الرقمية الأربعة · معالج إغلاق لنافذة التأكيد · عرض المفاتيح المتجاهَلة الراجعة من WP1FEWP1ng build أخضر + فحص يدوي للمسارات الأربعةلالا
WP3تشخيص أنواع الأسعار عند العميل: نصوص ولا أرقام؛ ولو أرقام، توحيد المقارنة في customerEligible() + تحقق من price_type_idBEاختبار: عميل بنوع سعر رقمي مع إعداد بنصوص يبقى مؤهَّل بعد الإصلاحنعملا
WP4التحقق النهائي عند العميل: الحفظ يرجّع enabled:true والـsummary يرجّع program_enabled:trueWP1..3إعادة تنفيذ خطوات العرض الأصليةلالا

خارج نطاق هذه التذكرة (تذاكر منفصلة مقترحة): وعي كاش الإعدادات بالمعاملات (طبقة مشتركة) · توحيد مصدر شركة النقاط + تقييد فرع التسجيل · تسريب الكوبونات · ازدواج «قيمة النقطة» بين مفتاحين · expiry_notify_days الميّت.

٨ · قرارات تحتاج المالك

#القرارالتوصية
١نمشي بالخيار (ب) — قفل الصنف — ولا نكتفي بإصلاح فوري للعميل (أ)؟(ب). الإصلاح الفوري مش بيمنع التكرار، والعميل هيرجع بنفس الشكوى مع أول مفتاح تاني.
٢الفحص الحاسم عند العميل (جسم الطلب / استعلام قراءة / مسح كاش) — مين ينفّذه؟نطلب من العميل جسم الطلب من تبويب Network — أرخص وأسرع مُميِّز، وبيقفل الترجيح نهائيًا قبل ما نكتب سطر.
٣مانع أنواع الأسعار (٥.٧) — في نفس التذكرة ولا منفصل؟في نفس الدورة (WP3). من غيره العميل هيصلح التفعيل ويلاقي صفر نقاط، فتتحسب التذكرة «مش متصلحة».
٤إصلاح كاش الإعدادات المشترك — دلوقتي ولا تذكرة مستقلة؟تذكرة مستقلة. بيمسّ كل الموديولات؛ حشره هنا بيوسّع نطاق تذكرة عطل ويزوّد المخاطرة.
٥الأشقاء الكامنة (٥.٩) — نصلحهم دلوقتي ولا نسجّلهم؟نسجّلهم كتذاكر ونصلح منهم points_egp_rate وRegisterRequest في دورة «توحيد الشركة» — غير مرئيين على تثبيت بشركة واحدة، بس بينفجروا فورًا مع أول تثبيت متعدد الشركات.
ملاحظة منهجية: كل الاختبارات في هذا التقرير اتعملت على بيئة التطوير moonui4 بنفس الكود، وكل تعديل اتعمل للاختبار اترجع لأصله (صفر صفوف loyalty.*، الكاش نضيف). ماتكتبش ولا سطر كود — ولا اتلمس تثبيت العميل غير قراءة.