Files
ladill-care/app/Services/Care/BloodBank/BloodBankAnalyticsService.php
T
isaaccladandCursor f688a48c37
Deploy Ladill Care / deploy (push) Successful in 37s
Add full Blood Bank specialty suite on shared shell.
Mirror Emergency: stage workflow, issue/transfusion records, analytics,
print, and action-menu stage flow without a parallel EHR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 21:36:57 +00:00

196 lines
7.9 KiB
PHP

<?php
namespace App\Services\Care\BloodBank;
use App\Models\Appointment;
use App\Models\Bill;
use App\Models\BillLineItem;
use App\Models\Organization;
use App\Models\SpecialtyClinicalRecord;
use App\Models\Visit;
use App\Services\Care\SpecialtyShellService;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class BloodBankAnalyticsService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
/**
* @return array{
* requests_today: int,
* completed_today: int,
* high_urgency_open: int,
* urgency_mix: Collection,
* products_issued: Collection,
* low_stock_flags: int,
* revenue_by_service: Collection,
* from: string,
* to: string
* }
*/
public function report(
Organization $organization,
string $ownerRef,
?int $branchId = null,
?string $from = null,
?string $to = null,
): array {
$todayStart = now()->startOfDay();
$rangeFrom = $from ? Carbon::parse($from)->startOfDay() : now()->startOfMonth();
$rangeTo = $to ? Carbon::parse($to)->endOfDay() : now()->endOfDay();
$departmentIds = $this->shell->departmentIds($organization, 'blood_bank', $ownerRef, $branchId);
$appointmentBase = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds))
->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0'))
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
$visitIds = (clone $appointmentBase)->whereNotNull('visit_id')->pluck('visit_id');
$requestsToday = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'blood_bank')
->where('record_type', 'blood_request')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('recorded_at', '>=', $todayStart)
->count();
$completedToday = (clone $appointmentBase)
->where('status', Appointment::STATUS_COMPLETED)
->where('completed_at', '>=', $todayStart)
->count();
$requestRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'blood_bank')
->where('record_type', 'blood_request')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$urgencyMix = $requestRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['urgency'] ?? 'Unknown'))
->map(fn (Collection $rows, string $urgency) => [
'urgency' => $urgency,
'total' => $rows->count(),
])
->values()
->sortByDesc('total')
->values();
$openVisitIds = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('id', $visitIds->isEmpty() ? [0] : $visitIds)
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
->pluck('id');
$highUrgencyOpen = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'blood_bank')
->where('record_type', 'blood_request')
->whereIn('visit_id', $openVisitIds->isEmpty() ? [0] : $openVisitIds)
->get()
->filter(function (SpecialtyClinicalRecord $r) {
$urgency = strtolower((string) ($r->payload['urgency'] ?? ''));
return str_contains($urgency, 'urgent')
|| str_contains($urgency, 'emergency')
|| str_contains($urgency, 'massive');
})
->count();
$issueRecords = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'blood_bank')
->where('record_type', 'issue_note')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get();
$productsIssued = $issueRecords
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['product'] ?? 'Unknown'))
->map(fn (Collection $rows, string $product) => [
'product' => $product,
'total' => $rows->sum(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['units_issued'] ?? 0)),
'issues' => $rows->count(),
])
->values()
->sortByDesc('total')
->values();
// Fall back to request issued_units when no dedicated issue notes exist.
if ($productsIssued->isEmpty()) {
$productsIssued = $requestRecords
->filter(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['issued_units'] ?? 0) > 0)
->groupBy(fn (SpecialtyClinicalRecord $r) => (string) ($r->payload['product'] ?? 'Unknown'))
->map(fn (Collection $rows, string $product) => [
'product' => $product,
'total' => $rows->sum(fn (SpecialtyClinicalRecord $r) => (int) ($r->payload['issued_units'] ?? 0)),
'issues' => $rows->count(),
])
->values()
->sortByDesc('total')
->values();
}
$lowStockFlags = SpecialtyClinicalRecord::owned($ownerRef)
->where('organization_id', $organization->id)
->where('module_key', 'blood_bank')
->where('record_type', 'inventory_note')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('recorded_at', [$rangeFrom, $rangeTo])
->get()
->filter(function (SpecialtyClinicalRecord $r) {
if (! empty($r->payload['low_stock_alert'])) {
return true;
}
foreach (['whole_blood_units', 'packed_rbc_units', 'platelet_units', 'ffp_units'] as $key) {
if (isset($r->payload[$key]) && (int) $r->payload[$key] <= 2) {
return true;
}
}
return false;
})
->count();
$serviceLabels = collect($this->shell->provisionedServices($organization, 'blood_bank'))
->pluck('label')
->filter()
->values()
->all();
$revenueByService = BillLineItem::query()
->where('owner_ref', $ownerRef)
->where('source_type', 'specialty_service')
->whereIn('description', $serviceLabels ?: ['__none__'])
->whereBetween('created_at', [$rangeFrom, $rangeTo])
->whereHas('bill', function ($q) use ($organization, $visitIds) {
$q->where('organization_id', $organization->id)
->whereIn('visit_id', $visitIds->isEmpty() ? [0] : $visitIds)
->whereNotIn('status', [Bill::STATUS_VOID]);
})
->selectRaw('description, count(*) as total, sum(total_minor) as amount_minor')
->groupBy('description')
->orderByDesc('amount_minor')
->get();
return [
'requests_today' => $requestsToday,
'completed_today' => $completedToday,
'high_urgency_open' => $highUrgencyOpen,
'urgency_mix' => $urgencyMix,
'products_issued' => $productsIssued,
'low_stock_flags' => $lowStockFlags,
'revenue_by_service' => $revenueByService,
'from' => $rangeFrom->toDateString(),
'to' => $rangeTo->toDateString(),
];
}
}