كانسل للريكوست اللي اتعمل بالغلط · صالح فقط قبل الـ Receive · يحرّر الدين والكريدت بدون أي قيود محاسبية
وقت ما المعمل الخارجي بيعمل Request من البورتال، الـ store() بيعمل كل ده في
transaction واحدة وخطوة واحدة — مفيش خطوة تجميع منفصلة:
| # | اللي بيتعمل | الحالة الابتدائية | ملاحظة |
|---|---|---|---|
| 1 | LabRequest | pending | external_lab_id + price_list_id + فرع رئيسي |
| 2 | LabRequestInvestigation (لكل تحليل + توسعة البانل) | pending | أعضاء البانل بسعر صفر |
| 3 | LabSample (واحدة لكل section:specimen) | collected + collected_at=now | المعمل جمّع العينة بنفسه — فالعينة بتتولد Collected على طول |
| 4 | LabSampleInvestigation pivots (+logs) | collected | دي مصدر الحقيقة للورك ليست/الفليديشن |
| 5 | LabInvoice | draft · type=external_lab_receivable | items posted_at = null (مفيش ترحيل) |
| 6 | LabInvoiceItem لكل تحليل | net + VAT | بيتعمل Post وقت الـ Release بس |
الـ Receive (LabSampleService::deliver()) بيعمل: العينة → delivered، الـ pivots → received،
وبيطلق LabSampleReceived اللي بينشئ الـ LabResult ويدخّل التحاليل الكانبان.
قبل الـ Receive: صفر نتائج، صفر بنود مرحّلة، مفيش قيد محاسبي. عشان كده الإلغاء قبل الاستلام «نظيف» تمامًا.
المعيار مش على request.status (لأنه بيفضل pending بعد الإنشاء ومش موثوق) — المعيار على الحقيقة الفعلية للعينات والنتائج:
delivered أو received — كل العينات لسه collected.LabResult للطلب (النتائج بتتولد وقت الاستلام بس).request.status ≠ cancelled — منع الإلغاء المزدوج (idempotent).| الكيان | الإجراء عند الإلغاء | ليه |
|---|---|---|
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)، ومفيش قيد يومية.
بنشيل بس صفوف لسه «مسوّدة».
اتأكدت من الكود إن في تلتين فجوة لازم نسدّهم وإلا الإلغاء هيبقى ناقص:
deliver() بيتحقق بس من sample->status->canDeliver() (= collected). مابيبصش على حالة الطلب →
يعني عينة طلب ملغي تفضل قابلة للاستلام.status بس → عينة collected لطلب ملغي هتفضل ظاهرة.الحل (أنظف خيار): نضيف حالة جديدة SampleStatus::Cancelled = 'cancelled' ونحوّل عينات الطلب الملغي ليها:
canDeliver() بترجع false تلقائيًا لأي حالة غير collected → الاستلام يتمنع من غير أي تعديل تاني. ✅status=collected في الاستقبال مش هيجيبها. ✅label() match (exhaustive) + مفتاح ترجمة sample_statuses.cancelled.deliver() رفض صريح لو
sample->labRequest->status === Cancelled → ضمان صحة على مستوى الـ API حتى لو وصلها request قديم/مباشر.
| الدالة | بتحسب إيه | أثر الإلغاء |
|---|---|---|
outstandingBalance() | يحكم حد الائتمان — بنود مرحّلة فقط (posted_at) | الطلب الجديد كان صفر هنا أصلًا (مفيش بند مرحّل) — مكنش بياكل من الحد |
collectibleBalance() | رقم كشف الحساب — balance_due لكل الفواتير المفتوحة | الإلغاء يشيل الفاتورة من هنا → الرقم القابل للتحصيل ينقص بقيمة الطلب |
collectibleBalance).
أما حد الائتمان (outstandingBalance = مرحّل فقط) فالطلب اللي لسه ماترحّلش مكنش مستهلكه من الأساس.
الـ 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();
});
}
| الجهة | Route | الصلاحية |
|---|---|---|
| بورتال B2B (أساسي) | POST /lis/external-lab-portal/requests/{labRequest}/cancel |
المعمل يلغي طلبه هو فقط (نفس فحص findOwnedRequest) |
| الموظف (اختياري — يُنصح به) | POST /lis/requests/{labRequest}/cancel-b2b أو نوسّع الـ cancel الحالي |
lis.requests.cancel — لإلغاء من شاشة الاستقبال الداخلية |
!is_collected_received (نشتق flag «قابل للإلغاء» من الـ API: مفيش عينة delivered + total_count==0).ExternalLabPortalService.cancelRequest(id, reason?).whereNotIn(Paid) بيحميها.collectibleBalance قبل وبعد.POST …/{id}/cancel → 200 · الطلب cancelled · العينات cancelled · الـ pivots اختفت · الفاتورة cancelled · collectibleBalance رجع نقص.deliver على عينة الطلب الملغي → مرفوض (422).cancel → 422 «اتستلم خلاص».cancelled، والفاتورة Draft فمفيش قيود.