تحليل جذر المشكلة: الصور المرفوعة كـ Attachments من داخل Moon ERP تُخزَّن على قرص خاص غير منشور، ولا يمكن لأي وسم <img> في متجر عام أن يقرأها — بينما تطبيق الإدارة يقرأها بطريقة مختلفة تمامًا لا يمكن تكرارها في المتجر.
المنتج له صورة ظاهرة داخل Moon ERP، لكن نفس المنتج يظهر في المتجر الإلكتروني بدون صورة (صورة بديلة رمادية).
storage/app/private/attachments/… — وهو مجلد خاص لا يخدمه الويب سيرفر إطلاقًا.
تطبيق الإدارة يعرضها لأنه لا يستخدم رابط الصورة أصلًا: هو ينزّلها كـ blob عبر طلب XHR موثّق بالتوكن.
المتجر يستخدم <img src> عادي، ووسم <img> لا يرسل أي هيدر توثيق — فالطلب يرجع 404/401 دائمًا.
أي أن هذه ليست مشكلة في كود المتجر، ولا في التصميم، ولا في الكاش. هي فجوة معمارية في طبقة تخزين الصور في الباك-إند، وستظهر بنفس الشكل في تطبيق الموبايل وأي مستهلك عام آخر.
GET /api/attachments/{id}/download (+ X-Authorization: Bearer)Storage::download($attachment->file_path) — يقرأ من القرص الخاصURL.createObjectURL() → [src]="blobUrl" ✔ تظهر
<img src="https://…/storage/attachments/1/2026-02/abc.png"> (بدون أي هيدر — المتصفح لا يرسلها في img)public/storage → symlink إلى storage/app/publicstorage/app/private/attachments/… ✘ 404
أ) رفع المرفق يذهب للقرص الافتراضي (الخاص): Modules/Core/app/Http/Controllers/AttachmentController.php:106-115
$directory = "attachments/{$companyId}/".now()->format('Y-m');
$path = $file->store($directory); // ← بدون تحديد disk ⇒ يستخدم FILESYSTEM_DISK
...
'file_path' => $path,
وفي .env: FILESYSTEM_DISK=local. وفي config/filesystems.php:33-45:
'local' => root: storage_path('app/private') ← خاص، بلا رابط عام 'public' => root: storage_path('app/public'), url: https://moonui4.elbaset.com/moon-erp-be/storage ← المنشور فعليًا
ب) الأكسسور يبني رابطًا عامًا لمسار خاص — وهنا الخطأ المباشر: Modules/Core/app/Models/Product.php:207-222
public function getImageUrlAttribute(): ?string
{
if ($this->image) {
return url('storage/'.$this->image); // ✔ يعمل لو المسار داخل القرص العام
}
$attachment = ... $this->attachments()->first();
if ($attachment) {
return url('storage/'.$attachment->file_path); // ✘ file_path = attachments/… على القرص الخاص ⇒ 404 مضمون
}
return null;
}
/storage/ (التي تشير للقرص العام) فوق مسار يعيش على القرص الخاص. النتيجة رابط صحيح الشكل، خاطئ المحتوى — وهذا أسوأ من إرجاع null، لأن الواجهة تظنه رابطًا سليمًا وتحاول تحميله بدل عرض البديل فورًا.التعليق في Modules/WebStore/app/Console/FixImagePaths.php:11-19 يصف المشكلة حرفيًا:
historical records store their image in the generic Attachment path
(attachments/{company}/{Y-m}/…). Those files live on the private `local`
disk, so /storage/attachments/… returns 404 for the storefront/mobile apps.
New uploads are already copied into the public area by
ensurePubliclyServable on save, but pre-existing rows were never re-saved.
أي أن الفريق شخّص المشكلة سابقًا (STORE-040) وكتب لها أمر إصلاح، لكن الإصلاح جزئي — انظر الفجوة في القسم ٤.
demo/products/pNNN.png على القرص العام، وكل الـ18 رابطًا ترجع HTTP 200 (تم التحقق بـcurl -sI على كل رابط)، وجدول attachments فارغ تمامًا.
/home/moonui من هنا، فهذا يخالف عزل بيئات التطوير المتوازية.
هناك نظامان لتخزين الصور في Moon ERP، وُلدا في وقتين مختلفين ولم يتم توحيدهما:
| المسار | أين يُخزَّن | كيف يُقرأ | يصلح لمتجر عام؟ |
|---|---|---|---|
عمود products.imageالرفع من شاشة المنتج |
storage/app/public/uploads/products/…قرص عام |
رابط مباشر عبر symlink | نعم ✔ |
جدول attachmentsالرفع من واجهة المرفقات العامة |
storage/app/private/attachments/…قرص خاص |
API موثّق: GET /api/attachments/{id}/download |
لا ✘ |
الأول مصمَّم للنشر العام. الثاني مصمَّم للمستندات الداخلية (فواتير، عقود، تقارير) ولذلك هو خاص عمدًا وهذا صحيح أمنيًا. المشكلة أن صور المنتجات تُرفع أحيانًا عبر المسار الثاني، ثم يحاول getImageUrlAttribute() تقديمها كأنها من المسار الأول.
attachments/ يحتوي — بحكم تصميمه — على مستندات داخلية لكل الشركات. فتحه للعامة يعني تسريبها كلها. لذلك أي حل مقبول يجب أن ينقل/ينسخ صور المنتجات فقط إلى المنطقة العامة، لا أن يوسّع صلاحية المجلد.
attachments أي علامة تقول «ده صورة المنتج»؟لا. ولا واحدة. ده كل أعمدة الجدول — مقروءة من قاعدة البيانات مباشرةً:
id · company_id · attachable_type · attachable_id · file_name
file_path · file_size · mime_type · uploaded_by
created_at · updated_at · deleted_at
type · collection · is_primary · is_main · label · title · position · sort_order.
يعني مافيش أي طريقة مباشرة نعرف بيها إن مرفقًا بعينه هو صورة المنتج الرئيسية، ولا حتى إنه صورة "مقصودة للعرض" أصلًا.
الإشارات المتاحة الوحيدة، وكل واحدة فيها ضعف:
| الإشارة | تنفع لإيه | ضعفها |
|---|---|---|
mime_type LIKE 'image/%' | تستبعد الـPDF والوورد بثقة | لا تفرّق بين صورة المنتج وصورة إيصال/شهادة تحليل مرفقة |
attachable_type = Product | تحصر النطاق في المنتجات | لا تحدد أيّ صورة هي الرئيسية لو فيه أكتر من واحدة |
file_name | ممكن يحمل الكود (SKU-123.jpg) | اسم الملف الأصلي من جهاز المستخدم — غير مضمون إطلاقًا |
ترتيب id / created_at | "الأقدم = الرئيسية" | افتراض وليس حقيقة. ده بالظبط اللي الكود بيعمله دلوقتي وهو مصدر عيب اختيار الـPDF |
حاجة تانية خالص. مافيش أي تفريع. النظامان منفصلان تمامًا ولا يعرف أحدهما الآخر.
الدليل — HandlesImageUpload::resolveImageField() في app/Http/Controllers/Concerns/HandlesImageUpload.php:19-22:
if ($request->hasFile($field)) {
$stored = $request->file($field)->store("uploads/{$folder}", 'public');
// ← يكتب على القرص العام صراحةً، ولا يُنشئ أي صف Attachment
return $stored;
}
وبحث شامل عن كل مواضع إنشاء المرفقات في المشروع كله:
$ grep -rn "Attachment::query()->create\|attachments()->create" Modules app Modules/Core/.../AttachmentController.php:110 ← الـAPI العام Modules/Clinic/.../SubmitExternalResult.php:51 ← نتائج معامل خارجية Modules/Production/.../MfgCoaController.php:291 ← شهادات تحليل Modules/Production/.../MfgTollContractController.php:391 ← عقود → ولا موضع واحد لصور المنتجات.
POST /api/products) بيعمل حاجتين بس:
(١) يحفظ الملف في storage/app/public/uploads/products/ على القرص العام،
(٢) يحطّ المسار النسبي في عمود products.image (وصفوف product_images للمعرض).
صفر تعامل مع جدول attachments.
getImageUrlAttribute() بيحاول يعمل «جسر» بين النظامين في فرعه التاني — وبيفشل، لأنه بيبني رابط القرص العام فوق مسار القرص الخاص.
هو الجسر الوحيد بين النظامين، وهو مكسور.
النقل الحرفي (نسخ ملفات attachments/ ونخلي الصفوف زي ما هي) لن يُصلِح شيئًا — لأن العطل مش في مكان الملف وبس، هو في إن لا وجود لعَلَم يربط المرفق بكونه صورة المنتج. الخيارات الواقعية:
| الطريقة | الحكم | |
|---|---|---|
| أ | الترقية (Promotion): ننسخ ملف المرفق-الصورة إلى المنطقة العامة ونكتب مساره في products.image — فيتحوّل لمنتج «عادي» له صورة زي أي منتج مرفوع من شاشة مون. |
مُوصى به — يوحّد النظامين، ويخلي نتيجة الاستيراد مطابقة تمامًا للرفع اليدوي. وهو المسار اللي أمر FixImagePaths بيعمله فعلًا في تمريرته التانية. |
| ب | إضافة العَلَم الناقص: عمود جديد على attachments (مثلاً is_product_image أو collection)، ويقرأ منه الأكسسور. |
يحتاج migration — أنظف مفاهيميًا وبيحل المشكلة لكل الموديلات مستقبلًا، لكنه تغيير في جدول مشترك بيستخدمه ٤ موديولات. |
| ج | فتح مجلد attachments للعامة. |
مرفوض — بيسرّب المستندات الداخلية لكل الشركات. مذكور هنا فقط لاستبعاده صراحةً. |
(أ) و(ب) مش متعارضين: ممكن (أ) دلوقتي لأنها بتحل الاستيراد فورًا بلا migration، و(ب) لاحقًا كتنظيف معماري.
Moon ERP عنده أصلًا نظام كامل لصور المنتجات المتعددة، بعلامات صحيحة، على القرص العام — ومُهمَل تمامًا:
$ SHOW COLUMNS FROM product_images; id · product_id · image · position · created_at · updated_at $ SELECT COUNT(*) FROM product_images; 0 ← صفر صفوف. الجدول موجود ومربوط ولم يُستعمل قط.
والعلاقة موجودة ومرتّبة في Modules/Core/app/Models/Product.php:198-201:
public function images(): HasMany
{
return $this->hasMany(ProductImage::class)->orderBy('position');
}
migration:
products.image ← الصورة الرئيسية (علامة «ده الصورة الأساسية» = وجودها في هذا العمود)product_images.position ← ترتيب صور المعرض (علامة «ده الصورة رقم ٢، ٣، ٤…»)ProductController:92-108: الصورة الأولى تُرقَّى إلى image والباقي يدخل product_images بترتيبه).
products.image + product_images — موجودًا وجاهزًا وفارغًا.
| # | الخطوة | لماذا |
|---|---|---|
| ١ | الاستيراد يكتب في المسار الصحيح: ملفات الصور تُنسَخ إلى storage/app/public/uploads/products/ بأسماء فريدة (مبنية على id/hash وليس basename)، ثم الصورة الأولى → products.image، وباقي الصور → product_images بـposition تصاعدي. |
نتيجة مطابقة تمامًا للرفع اليدوي من شاشة مون. صفر تعامل مع المرفقات. |
| ٢ | إصلاح ProductImageResource — انظر التحذير أسفله. |
بدونه المعرض يفشل حتى لو البيانات صحيحة ١٠٠٪. |
| ٣ | حذف الجسر المكسور من getImageUrlAttribute() (الفرع الثاني الذي يبني رابطًا عامًا لمسار خاص)، أو جعله يُرجِع null. |
بعد (١) لا داعي له إطلاقًا، ووجوده يعيد إنتاج المشكلة صامتًا. |
| ٤ | سدّ الباب: منع/تحذير عند رفع صورة منتج عبر واجهة المرفقات. | وإلا تتكرر نفس الغلطة في أول استيراد قادم. |
Modules/WebStore/app/Http/Resources/ProductImageResource.php:13 يعيد المسار الخام بلا بناء رابط:
'image' => $this->image, // → "uploads/products/x.jpg" — مسار نسبي مكسور
بينما كل المصادر الأخرى في نفس المجلد تبني الرابط: url('storage/'.$this->image).
النتيجة: الصورة الرئيسية تعمل، وكل صور المعرض تفشل — ولأن الأصناف اليوم بلا معرض، العطل كامن وغير مرئي وسينفجر لحظة وصول أول منتج متعدد الصور.
is_product_image على المرفقات (ق٥ سقط)، ولا لفتح مجلد المرفقات، ولا حتى لأمر الترحيل FixImagePaths — فهو يعالج بيانات لن نُبقي عليها. العلامات المطلوبة موجودة بالفعل: products.image للرئيسية وposition للترتيب.
الأمر webstore:fix-image-paths موجود ويعالج جزءًا من المشكلة، لكنه لا يكفي. ما يغطيه وما لا يغطيه:
| الحالة | يغطيها الأمر؟ | التفصيل |
|---|---|---|
products.image LIKE 'attachments/%' | نعم | ينسخ الملف إلى uploads/products/ ويصحّح العمود. |
image IS NULL + يوجد مرفق | نعم | يرقّي أول مرفق إلى عمود image. |
| المرفق ليس صورة أصلًا (PDF، Word) | لا | خلل حقيقي: يأخذ أول مرفق حسب الـid بلا أي فحص لنوع الملف. كارت داتا PDF مرفوع قبل الصورة سيصبح "صورة المنتج". ونفس العيب في getImageUrlAttribute() نفسه. |
| الملف المصدر غير موجود على القرص | يُبلّغ فقط | يطبع [missing source] ويتخطاه. هذه الصفوف تبقى مكسورة للأبد ولا حل برمجي لها — الملف ضاع. |
معرض الصور product_images | لا | الأمر يعالج ٧ نماذج، وليس ProductImage من بينها. |
| الأمر غير موصول بأي مسار تلقائي | يدوي | ليس في local-deploy.sh ولا في الـscheduler. لو نُسي بعد الاستيراد، لن يعمل شيء. |
ensurePubliclyServable لأن تلك تعمل فقط عند الحفظ من الـController، لا عند الإدخال المجمّع بالـseeder/import. أي أن المشكلة ستتكرر بالكامل ما لم يُعالَج الاستيراد نفسه.
| الملف | الدور |
|---|---|
Modules/Core/app/Models/Product.php:207 | الأكسسور image_url — مصدر الرابط الخاطئ (الفرع الثاني) |
Modules/Core/app/Http/Controllers/AttachmentController.php:106 | $file->store() بلا disk ⇒ يهبط على القرص الخاص |
app/Http/Controllers/Concerns/HandlesImageUpload.php:56 | ensurePubliclyServable() — العلاج الصحيح، لكنه يعمل عند الحفظ من Controller فقط |
Modules/WebStore/app/Console/FixImagePaths.php | أمر الترحيل (STORE-040) — تغطية جزئية |
Modules/WebStore/app/Http/Resources/StorefrontProductResource.php:31…/StorefrontProductListResource.php:20 | يمرّران image_url للمتجر كما هو |
Modules/WebStore/app/Http/Resources/ProductImageResource.php:13 | يعيد image الخام بدون بناء رابط — يحتاج مراجعة |
projects/storefront/src/app/shared/product-image.component.ts | واجهة المتجر — سليمة، لا تحتاج تعديلًا (بها بالفعل بديل عند الفشل) |
mime_type LIKE 'image/%'.basename($path) فقط. ملفان بنفس الاسم من شركتين مختلفتين ⇒ واحد يدهس الآخر، وتظهر صورة شركة في متجر شركة أخرى. خطر تسريب حقيقي عند التشغيل متعدد الشركات..htaccess؛ رابط صحّحناه قد يظل مكسورًا في متصفح العميل. أسماء الملفات ثابتة وليست مبصومة.مقترحة فقط. لن يُكتب أي كود قبل موافقتك.
| WP | العمل | الريبو | لماذا |
|---|---|---|---|
| WP-IMG-1 | إصلاح الأكسسور ليصدق. في Product::getImageUrlAttribute(): لا تُرجِع رابطًا لمسار على القرص الخاص. إما تُرجَع null (فيظهر البديل فورًا وبصدق)، أو يُبنى رابط عبر مسار عام مخصَّص للصور. + فلترة المرفقات على image/%. |
BE | يوقف "الرابط الكاذب" من المنبع. أرخص خطوة وأعلاها أثرًا. |
| WP-IMG-2 | تحصين أمر الترحيل: فلترة نوع الملف، اسم هدف فريد (id/hash بدل basename) لمنع الدهس، تغطية product_images، وتقرير --dry-run يذكر الحجم المطلوب وعدد الملفات المفقودة. |
BE | يجعل تشغيله على ١٧ ألف صف آمنًا بدل مقامرة. |
| WP-IMG-3 | سدّ باب التكرار: جعل نشر الصورة يحدث في طبقة الموديل/الخدمة المشتركة (لا في الـController فقط) حتى يمرّ عليها أي استيراد أو seeder. | BE | القاعدة المتبعة في المشروع: الإصلاح في الطبقة المشتركة، لا شاشة بشاشة. |
| WP-IMG-4 | ربطه باستيراد مون: أن ينقل الاستيراد الصور مباشرةً إلى المنطقة العامة بأسماء فريدة، ويُشغَّل تقرير تحقق بعده. | BE | وإلا تعود المشكلة كاملةً مع أول استيراد. |
الترتيب مقصود: WP-IMG-1 وحده يجعل السلوك صادقًا فورًا (بديل بدل صورة مكسورة) حتى لو تأخر الباقي.
أن يُرجِع الأكسسور null بدل رابط سيقع 404.
البديل يظهر فورًا وبلا وميض، والواجهة تقول الحقيقة. البديل الآخر (ترك الرابط الكاذب) يخفي المشكلة ويبطئ الصفحة.
عند وجود عدة مرفقات لمنتج: أول صورة حسب الـid؟ أم يُعتمد فقط ما رُفع من شاشة المنتج؟
الوضع الحالي يأخذ أول مرفق أيًا كان نوعه — وهذا خطأ محسوم.
النسخ آمن لكنه يضاعف المساحة (١٧ ألف صورة). النقل يوفّر لكنه يكسر أي مرجع قديم للمرفق.
أحتاج قياس الحجم الفعلي أولًا قبل التوصية.
الجدول مافيهوش عَلَم يقول ده صورة المنتج (القسم ٣٫٥). المقترح: أول مرفق نوعه image/% حسب أقدم id، ويُرقَّى إلى products.image.
لو ده مش كافي لبياناتك الحقيقية، البديل إضافة عمود is_product_image بـmigration — قرارك.
الصفوف التي ضاع ملفها لا حل برمجي لها. أُخرج قائمة بالأصناف المتأثرة؟
وسؤال معلّق من قبل، صار الآن مرتبطًا مباشرةً: استيراد منتجات مون — بصور أم بدونها؟ كل الأصناف أم عينة؟ وهل هي بيانات عميل حقيقية (لأن نشرها في متجر عام قرار مختلف تمامًا)؟
| البند | |
|---|---|
| تحقّق | مواضع الكود كلها المذكورة أعلاه — قُرئت سطرًا بسطر، والأسطر مثبتة. |
| تحقّق | إعدادات الأقراص، وجود الـsymlink ووجهته، وأن storage/app/private لا يحوي إلا .gitignore. |
| تحقّق | أرقام moonui4: ٢٤ منتج، ١٨ بصورة تجريبية، صفر مرفقات، و١٨/١٨ رابطًا ترجع 200. |
| تحقّق | --dry-run لا يكتب شيئًا (كل الكتابات داخل if (! $dryRun)) — وشُغّل فأعطى أصفارًا. |
| استُنتج | أن بيانات مون الحقيقية تستخدم مسار المرفقات — مبني على تعليق STORE-040 الصريح، وليس على فحص مباشر (فحص moonui ممنوع من هنا). |
| استُنتج | أن الاستيراد المجمّع لن يمرّ على ensurePubliclyServable — مبني على قراءة الكود، ولم يُختبر باستيراد فعلي. |
| لم يُتحقق | لم يُفتح متصفح. كل ما سبق على مستوى الكود وقاعدة البيانات وcurl. لم أرَ الصفحة بعيني. |
تم إعداده على moonui4 · فرع hazemdev4 · 2026-07-19 — لم يُعدَّل أي ملف كود ولم تُنفَّذ أي كتابة على قاعدة البيانات.