DEV Community

kouana
kouana

Posted on

سلة واحدة وطلبات متعددة: الخطأ الذي يقتل أي متجر متعدد التجار

بنينا سوقًا متعدد التجار: تاجر يفتح متجره ويوثَّق، ودليل متاجر، ومشترٍ يضع في سلة واحدة منتجات من بائعين مختلفين. المنصة تشرف على الجودة والشحن والاسترجاع.

الخطأ الذي يقع فيه أغلب من يبني هذا النوع يظهر في أول أسبوع تشغيل: طلب واحد يحمل منتجات من ثلاثة تجار. يبدو منطقيًا، لأن المشتري اشترى مرة واحدة ودفع مرة واحدة. ثم يصل أول سؤال من التشغيل: أحد التجار شحن والثاني تأخر والثالث نفد مخزونه. ما حالة هذا الطلب؟

لا جواب. الحالة الواحدة لا تصف ثلاثة مسارات مستقلة.

المشتري يشتري مرة، والتنفيذ يحدث مرات

النموذج الذهني للمشتري: عملية شراء واحدة. النموذج التشغيلي: عدد من الطلبات بعدد التجار، لكل واحد شحنته وحالته ومهلته وسياسة إرجاعه.

الحل ألا تختار بينهما، بل تمثّل الاثنين:

checkout (عملية الشراء)
├── order  #1  → التاجر A   (شحن، حالة، تتبع مستقل)
├── order  #2  → التاجر B
└── order  #3  → التاجر C
Enter fullscreen mode Exit fullscreen mode
Schema::create('checkouts', function (Blueprint $t) {
    $t->foreignId('customer_id')->constrained();
    $t->string('payment_reference');    // عملية دفع واحدة
    $t->unsignedInteger('grand_total'); // بالهللات، عدد صحيح
    $t->timestamps();
});

Schema::create('orders', function (Blueprint $t) {
    $t->foreignId('checkout_id')->constrained();
    $t->foreignId('vendor_id')->constrained();
    $t->string('status');               // حالة مستقلة لكل تاجر
    $t->unsignedInteger('items_total');
    $t->unsignedInteger('shipping_total');
    $t->string('tracking_number')->nullable();
});
Enter fullscreen mode Exit fullscreen mode

الدفع يرتبط بـcheckout، والتنفيذ يرتبط بـorder. من هنا يصير كل سؤال لاحق قابلًا للإجابة.

ولاحظ unsignedInteger للمبالغ: خزّن المال بأصغر وحدة كعدد صحيح. الأعداد العشرية في قواعد البيانات تُنتج فروق كسور تظهر عند جمع عمولات آلاف الطلبات.

الحالة المعروضة للعميل مشتقة لا مخزَّنة

بعد الفصل يبقى سؤال الواجهة: ماذا نعرض للمشتري في صفحة "طلباتي"؟

لا تخزّن حالة موحّدة في checkouts، لأنها ستتعارض مع الحالات الفرعية عند أول تحديث. اشتقها:

public function displayStatus(): string
{
    $statuses = $this->orders->pluck('status');

    if ($statuses->every(fn ($s) => $s === 'delivered'))  return 'مكتمل';
    if ($statuses->every(fn ($s) => $s === 'cancelled'))  return 'ملغى';
    if ($statuses->contains('shipped'))                   return 'جزء منه في الطريق';
    return 'قيد التجهيز';
}
Enter fullscreen mode Exit fullscreen mode

وأظهر التفصيل تحتها: كل تاجر بحالته ورقم تتبعه. المشتري الذي يرى "شُحن جزء منك من متجر كذا" لا يفتح تذكرة دعم. الذي يرى "قيد التجهيز" منذ أربعة أيام يفتحها.

الشحن لا يُجمع ولا يُقسم بالتساوي

كل تاجر يشحن من موقعه بسياسته. جمع تكلفة الشحن في رقم واحد على مستوى checkout يجعل الاسترجاع الجزئي مستحيلًا: لو أُلغي طلب تاجر واحد، كم تُعيد من الشحن؟

احسب الشحن على مستوى order وخزّنه هناك، والمجموع مشتق:

$shipping = $ordersByVendor->map(fn ($o) => $o->vendor
    ->shippingRuleFor($customerAddress)
    ->quote($o->weight, $o->items_total));
Enter fullscreen mode Exit fullscreen mode

القاعدة العامة: أي مبلغ قد يُسترجَع وحده يجب أن يُخزَّن في المستوى الذي يُسترجَع عنده.

العمولة تُجمَّد لحظة البيع

المنصة تأخذ نسبة. الإغراء أن تحسبها عند العرض من vendors.commission_rate.

أول مرة تعدّل نسبة تاجر، تتغير معها أرباح كل طلب سابق في التقارير، ويصير سجلك المحاسبي غير قابل للتدقيق.

$t->unsignedInteger('commission_rate_bps');  // نقاط أساس: 250 = 2.5%
$t->unsignedInteger('commission_amount');    // المبلغ كما احتُسب يومها
$t->unsignedInteger('vendor_payout');
Enter fullscreen mode Exit fullscreen mode

خزّن النسبة المطبَّقة والمبلغ الناتج داخل الطلب نفسه. الجدول المرجعي يخدم الطلبات القادمة فقط. المبدأ نفسه ينطبق على الضريبة وعلى أي تعريفة متغيرة.

المنتج الواحد عند تجار مختلفين

في السوق المتعدد يبيع أكثر من تاجر المنتج ذاته بسعر ومخزون مختلفين. إن جعلت السعر والمخزون حقلين في جدول products، فأنت تفترض بائعًا واحدًا، وستعيد بناء الجدول لاحقًا.

افصل التعريف عن العرض:

products            # التعريف: اسم، وصف، صور، تصنيف، باركود
└── vendor_offers   # العرض: تاجر + سعر + مخزون + مدة تجهيز
Enter fullscreen mode Exit fullscreen mode

صفحة المنتج تعرض التعريف مرة واحدة، وتحته قائمة العروض. السلة تشير إلى vendor_offer_id لا إلى product_id، وإلا لم تعرف من الذي يشحن.

توثيق التاجر قرار مسجَّل لا علم منطقي

المنصة تضمن الجودة، ومعنى ذلك أن انضمام تاجر يمر بمراجعة. خانة is_verified تجيب عن "هل هو موثق" ولا تجيب عن "من وثّقه ومتى ولماذا سُحب التوثيق".

اجعلها سجل قرارات، والحالة الحالية مشتقة من آخر قرار. التكلفة جدول إضافي، والعائد أنك تستطيع مراجعة أي نزاع بعد أشهر بدل الاعتماد على الذاكرة.

الاسترجاع الجزئي: الاختبار الذي يكشف النموذج كله

هنا تُقاس صحة كل ما سبق. المشتري دفع مبلغًا واحدًا بعملية واحدة عند البوابة، ثم أراد إرجاع صنف واحد من تاجر واحد.

بوابة الدفع تعرف عملية واحدة برقم واحد. أنت تحتاج أن تسترجع جزءًا منها، وأن تعرف من أي تاجر يُخصم، وكم من الشحن يُعاد، وكم عمولة تتنازل عنها المنصة.

الاسترجاع يصبح ممكنًا لأن كل رقم مخزَّن في مستواه الصحيح:

$refund = [
    'items'      => $line->unit_price * $qty,
    'shipping'   => $order->refundableShipping($qty),   // من مستوى الطلب
    'commission' => $line->commission_amount_share($qty), // مجمَّدة وقت البيع
];
$refund['customer_total'] = $refund['items'] + $refund['shipping'];
$refund['vendor_debit']   = $refund['customer_total'] - $refund['commission'];

$gateway->refund($checkout->payment_reference, $refund['customer_total']);
Enter fullscreen mode Exit fullscreen mode

المبلغ المعاد إلى بطاقة المشتري يخرج من عملية الدفع الأصلية. أما توزيع الخسارة بين التاجر والمنصة فيقع في التسوية، وهي عملية منفصلة تمامًا عن حركة البطاقة.

لو كان الشحن مجموعًا على مستوى عملية الشراء، والعمولة محسوبة من الجدول المرجعي، لما أمكن اشتقاق أي من هذه الأرقام. لذلك يُقاس نموذج البيانات في المتاجر بقدرته على تنفيذ استرجاع جزئي، لا بقدرته على إتمام عملية شراء.

مصيدة الحجز عند الدفع

بين لحظة الضغط على "ادفع" ولحظة تأكيد بوابة الدفع تمر ثوانٍ. إن خصمت المخزون بعد التأكيد فقط، فمشترٍ آخر قد يأخذ آخر قطعة أثناءها.

احجز المخزون قبل استدعاء البوابة، بعملية شرطية ذرية تفشل بوضوح:

$reserved = VendorOffer::where('id', $offerId)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);

if ($reserved === 0) {
    throw new OutOfStock($offerId);
}
Enter fullscreen mode Exit fullscreen mode

وأطلِق مهمة مجدولة تعيد المحجوز إن لم يكتمل الدفع خلال مهلة. الحجز بلا انتهاء صلاحية يحوّل السلال المهجورة إلى مخزون مجمَّد.

الخلاصة

كل قرار هنا يتعلق بمستوى التخزين: أين يعيش الشحن، وأين تعيش الحالة، وأين تُجمَّد العمولة. المتجر يعمل ظاهريًا مع النموذج الخاطئ، ويظهر الثمن عند أول استرجاع جزئي وعند أول تقرير مالي.

نبني في رؤية أنظمة تصميم متجر الكتروني ومتاجر متعددة التجار، والدرس الذي يتكرر في كل مشروع: ارسم دورة حياة الطلب كاملة على ورقة، من الإضافة إلى السلة حتى الاسترجاع بعد شهر، قبل أن تكتب أول ترحيل.

Top comments (0)