🗑️ إلغاء طلب B2B قبل استلامه في المعمل — التحليل والخطة

كانسل للريكوست اللي اتعمل بالغلط · صالح فقط قبل الـ Receive · يحرّر الدين والكريدت بدون أي قيود محاسبية

📅 2026-06-01 🔬 الإنشاء + التجميع خطوة واحدة 🧾 الفاتورة Draft — لسه مفيش Posting ⛔ بعد الـ Receive ممنوع الإلغاء

1 دورة حياة طلب الـ B2B الحالية (الواقع من الكود)

وقت ما المعمل الخارجي بيعمل Request من البورتال، الـ store() بيعمل كل ده في transaction واحدة وخطوة واحدة — مفيش خطوة تجميع منفصلة:

#اللي بيتعملالحالة الابتدائيةملاحظة
1LabRequestpendingexternal_lab_id + price_list_id + فرع رئيسي
2LabRequestInvestigation (لكل تحليل + توسعة البانل)pendingأعضاء البانل بسعر صفر
3LabSample (واحدة لكل section:specimen)collected + collected_at=nowالمعمل جمّع العينة بنفسه — فالعينة بتتولد Collected على طول
4LabSampleInvestigation pivots (+logs)collectedدي مصدر الحقيقة للورك ليست/الفليديشن
5LabInvoicedraft · type=external_lab_receivableitems posted_at = null (مفيش ترحيل)
6LabInvoiceItem لكل تحليلnet + VATبيتعمل Post وقت الـ Release بس
أهم نقطة 🔑: العينة بتتولد Collected من لحظة الإنشاء (مفيش خطوة Collect منفصلة في البورتال للطلبات الجديدة). يعني الطلب «متجمّع بس لسه ماتستلمش» من ثانية ما اتعمل. ده اللي بيحدّد نافذة الإلغاء.

2 القيد المُكتشَف: نافذة الإلغاء = من الإنشاء لحد أول Receive

إنشاء الطلب
collected — قابل للإلغاء
──────► Receive في المعمل
delivered — اتقفلت النافذة
──► Kanban → Results → Release

الـ Receive (LabSampleService::deliver()) بيعمل: العينة → delivered، الـ pivots → received، وبيطلق LabSampleReceived اللي بينشئ الـ LabResult ويدخّل التحاليل الكانبان. قبل الـ Receive: صفر نتائج، صفر بنود مرحّلة، مفيش قيد محاسبي. عشان كده الإلغاء قبل الاستلام «نظيف» تمامًا.

السؤال القديم «الإلغاء يبقى لـ pending بس ولا للـ collected؟» — اتحلّ من الكود نفسه: بما إن العينة بتتولد Collected، فلو منعنا الإلغاء على الـ collected هيبقى الطلب مينفعش يتلغي أبدًا. فالقاعدة الصح إجباريًا: يُسمح بالإلغاء طول ما العينة ماتستلمتش.

3 شرط السماح بالإلغاء (الـ Guard)

المعيار مش على request.status (لأنه بيفضل pending بعد الإنشاء ومش موثوق) — المعيار على الحقيقة الفعلية للعينات والنتائج:

لو اتكسر أي شرط → نرجّع 409/422 برسالة واضحة: «الطلب وصل المعمل بالفعل ولا يمكن إلغاؤه — تواصل مع المعمل». (في الـ B2B inbound: مفيش استلام ⟸⟹ مفيش نتائج، بس بنتحقق من الاتنين للأمان.)

4 اللي الإلغاء لازم يرجّعه (الـ Reversal)

الكيانالإجراء عند الإلغاءليه
LabRequest status → cancelled · cancelled_at · cancellation_reason يختفي من القوائم النشطة ويوضّح السبب
LabInvoice status → cancelled يشيلها من كشف الحساب والمطالبة الشهرية — تتحرّر فورًا (تفصيل في بند 6)
LabSampleInvestigation pivots status → cancelled + SoftDelete + سطر ...Log (action=cancelled) عندها SoftDeletes — حذفها بيخليها تختفي من الورك ليست/الفليديشن (اللي بيقروا الـ pivots)
LabSample status → cancelled (case جديد) يمنع الـ Receive تلقائيًا + يختفي من شاشة الاستقبال (تفصيل في بند 5)
الكريدت / الدين يرجع تلقائيًا — مفيش كود إضافي الفواتير الملغية مستثناة من collectibleBalance() وoutstandingBalance()
القيود المحاسبية (GL) لا شيء الفاتورة كانت Draft ومفيش بند مرحّل — فمفيش قيد أصلًا نعكسه
ليه الإلغاء قبل الاستلام بسيط ونظيف: مفيش LabResult، مفيش بند فاتورة مرحّل (posted_at)، ومفيش قيد يومية. بنشيل بس صفوف لسه «مسوّدة».

5 مصير العينة + الباك-ستوب على الـ Receive (مهم)

اتأكدت من الكود إن في تلتين فجوة لازم نسدّهم وإلا الإلغاء هيبقى ناقص:

الحل (أنظف خيار): نضيف حالة جديدة SampleStatus::Cancelled = 'cancelled' ونحوّل عينات الطلب الملغي ليها:

حزام وأمان (إجباري بغض النظر عن الـ UI): كمان نضيف في deliver() رفض صريح لو sample->labRequest->status === Cancelled → ضمان صحة على مستوى الـ API حتى لو وصلها request قديم/مباشر.

6 أثر الكريدت — الصياغة الدقيقة

الدالةبتحسب إيهأثر الإلغاء
outstandingBalance()يحكم حد الائتمان — بنود مرحّلة فقط (posted_at)الطلب الجديد كان صفر هنا أصلًا (مفيش بند مرحّل) — مكنش بياكل من الحد
collectibleBalance()رقم كشف الحساب — balance_due لكل الفواتير المفتوحةالإلغاء يشيل الفاتورة من هنا → الرقم القابل للتحصيل ينقص بقيمة الطلب
التوصيف الصح: الإلغاء بيحرّر الطلب من كشف الحساب / الرصيد القابل للتحصيل (collectibleBalance). أما حد الائتمان (outstandingBalance = مرحّل فقط) فالطلب اللي لسه ماترحّلش مكنش مستهلكه من الأساس.

7 الباك إند — دالة إلغاء واحدة مشتركة + Endpoints

الـ LabRequestService::cancel() الحالي بيغيّر الـ status بس (مايلمسش الفاتورة) → مش كفاية للـ B2B. نعمل دالة واحدة فيها الـ reversal الكامل، يستدعيها البورتال والموظف — بدون تكرار:

// Modules/LIS/app/Services/ExternalLabPortalRequestService.php (أو Billing/Request service)
public function cancelB2bRequest(LabRequest $req, ?string $reason): LabRequest
{
    // idempotent
    if ($req->status === RequestStatus::Cancelled) return $req;

    // GUARD — حقيقة العينات/النتائج، مش الـ status
    $hasReceived = $req->samples()
        ->whereIn('status', [SampleStatus::Delivered, SampleStatus::Received])->exists();
    $hasResults  = LabResult::where('lab_request_id', $req->id)->exists();
    if ($hasReceived || $hasResults) {
        throw ValidationException::withMessages([
            'status' => [__('lis::lis.b2b_request_already_received')],
        ]);
    }

    return DB::transaction(function () use ($req, $reason) {
        $req->update([
            'status' => RequestStatus::Cancelled,
            'cancelled_at' => now(),
            'cancellation_reason' => $reason,
        ]);

        // العينات → cancelled (يمنع deliver + يختفي من الاستقبال)
        $req->samples()->update(['status' => SampleStatus::Cancelled]);

        // الـ pivots → cancelled + soft delete + log
        $pivots = LabSampleInvestigation::where('lab_request_id', $req->id)->get();
        foreach ($pivots as $p) { /* log(cancelled) ثم */ $p->update(['status'=>...Cancelled]); $p->delete(); }

        // الفاتورة → cancelled (تتحرر من كشف الحساب)
        LabInvoice::where('lab_request_id', $req->id)
            ->where('invoice_type', LabInvoiceType::ExternalLabReceivable)
            ->whereNotIn('status', [LabInvoiceStatus::Paid, LabInvoiceStatus::Cancelled])
            ->update(['status' => LabInvoiceStatus::Cancelled]);

        return $req->fresh();
    });
}

الـ Endpoints

الجهةRouteالصلاحية
بورتال B2B (أساسي) POST /lis/external-lab-portal/requests/{labRequest}/cancel المعمل يلغي طلبه هو فقط (نفس فحص findOwnedRequest)
الموظف (اختياري — يُنصح به) POST /lis/requests/{labRequest}/cancel-b2b أو نوسّع الـ cancel الحالي lis.requests.cancel — لإلغاء من شاشة الاستقبال الداخلية

8 الفرونت إند

بورتال — صفحة الطلبات

  • زر إلغاء في صف الطلب — يظهر فقط لو !is_collected_received (نشتق flag «قابل للإلغاء» من الـ API: مفيش عينة delivered + total_count==0).
  • دايلوج تأكيد فيه رقم الطلب + اسم المريض + الإجمالي + خانة سبب اختيارية.
  • بعد النجاح: toast «تم الإلغاء» + إعادة تحميل الصف + تحديث رقم كشف الحساب.
  • لو الـ API رجّع 422 (اتستلم خلاص): toast أحمر بالرسالة + إخفاء الزر.
  • خدمة ExternalLabPortalService.cancelRequest(id, reason?).
⚠️ إلغاء الطلب LR-2026-00210
المريضأحمد محمد · MRN-1042
عدد التحاليل3
الإجمالي شامل الضريبة345.00
السبب (اختياري):
اتعمل بالغلط…
تأكيد الإلغاء
رجوع

9 حالات حدّية

10 خطة الاختبار

? نقطة قرار واحدة متبقية ليك

سبب الإلغاء: أنصح يبقى اختياري (إلغاء سريع للأخطاء). تحب أخليه إجباري؟
زر الإلغاء للموظف كمان؟ أبني endpoint + زر في شاشة الاستقبال الداخلية للموظف، ولا البورتال بس يكفي دلوقتي؟
باقي القرارات اتحسمت من الكود: نافذة الإلغاء = «قبل الاستلام»، والعينة بتتحوّل لـ cancelled، والفاتورة Draft فمفيش قيود.