Deploy Ladill Care / deploy (push) Successful in 1m23s
Show the current invoice book by status so Paid/Partial are not hidden when create dates fall outside MTD. Co-authored-by: Cursor <cursoragent@cursor.com>
469 lines
20 KiB
PHP
469 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\Appointment;
|
|
use App\Models\Bill;
|
|
use App\Models\Branch;
|
|
use App\Models\Drug;
|
|
use App\Models\DrugBatch;
|
|
use App\Models\Organization;
|
|
use App\Models\Patient;
|
|
use App\Models\Payment;
|
|
use App\Models\Visit;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Facility-admin analytics for Patients / Appointments / Inventory / Billing / Reports hubs.
|
|
* Floor roles keep operational list UIs; admins get data-driven overviews.
|
|
*/
|
|
class AdminOverviewService
|
|
{
|
|
public function __construct(
|
|
protected ReportService $reports,
|
|
protected PharmacyService $pharmacy,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* period_label: string,
|
|
* from: string,
|
|
* to: string,
|
|
* kpis: list<array{label: string, value: string, hint?: string}>,
|
|
* breakdowns: list<array{title: string, rows: list<array{label: string, value: string|int, pct?: float|null}>}>,
|
|
* alerts: list<array{severity: string, message: string}>,
|
|
* links: list<array{label: string, href: string}>
|
|
* }
|
|
*/
|
|
public function patients(Organization $organization, string $ownerRef, ?int $branchId = null): array
|
|
{
|
|
$from = now()->startOfMonth();
|
|
$to = now()->endOfDay();
|
|
$period = $this->reports->patientsReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
|
|
$total = Patient::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->count();
|
|
|
|
$today = Patient::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereDate('created_at', today())
|
|
->count();
|
|
|
|
$activeVisits = Visit::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereIn('status', [Visit::STATUS_OPEN, Visit::STATUS_IN_PROGRESS])
|
|
->count();
|
|
|
|
$byBranch = Patient::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->select('branch_id', DB::raw('count(*) as total'))
|
|
->groupBy('branch_id')
|
|
->orderByDesc('total')
|
|
->limit(8)
|
|
->get();
|
|
|
|
$branchNames = $this->branchNames($organization, $ownerRef, $byBranch->pluck('branch_id'));
|
|
|
|
return [
|
|
'period_label' => 'This month',
|
|
'from' => $from->toDateString(),
|
|
'to' => $to->toDateString(),
|
|
'kpis' => [
|
|
['label' => 'Registered patients', 'value' => number_format($total), 'hint' => 'All time'],
|
|
['label' => 'New this month', 'value' => number_format($period['new_patients']), 'hint' => 'Registrations'],
|
|
['label' => 'Visits this month', 'value' => number_format($period['total_visits'])],
|
|
['label' => 'New today', 'value' => number_format($today)],
|
|
['label' => 'Open visits', 'value' => number_format($activeVisits), 'hint' => 'In facility now'],
|
|
['label' => 'Returning this month', 'value' => number_format($period['returning_patients'])],
|
|
],
|
|
'breakdowns' => [
|
|
[
|
|
'title' => 'Patients by branch',
|
|
'rows' => $this->pctRows($byBranch->map(fn ($row) => [
|
|
'label' => $branchNames[(int) $row->branch_id] ?? 'Unassigned',
|
|
'value' => (int) $row->total,
|
|
])->all(), $total),
|
|
],
|
|
],
|
|
'alerts' => $today === 0 && $period['new_patients'] === 0
|
|
? [['severity' => 'info', 'message' => 'No new patient registrations so far this month.']]
|
|
: [],
|
|
'links' => [
|
|
['label' => 'Patients report', 'href' => route('care.reports.show', ['type' => 'patients'])],
|
|
['label' => 'Browse patient records', 'href' => route('care.patients.index', ['view' => 'list'])],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function appointments(Organization $organization, string $ownerRef, ?int $branchId = null): array
|
|
{
|
|
$from = now()->startOfMonth();
|
|
$to = now()->endOfDay();
|
|
$period = $this->reports->appointmentsReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
|
|
$baseToday = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereDate('scheduled_at', today());
|
|
|
|
$waiting = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereIn('status', [Appointment::STATUS_CHECKED_IN, Appointment::STATUS_WAITING])
|
|
->count();
|
|
|
|
$inConsult = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->where('status', Appointment::STATUS_IN_CONSULTATION)
|
|
->count();
|
|
|
|
$byStatus = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('scheduled_at', [$from, $to])
|
|
->select('status', DB::raw('count(*) as total'))
|
|
->groupBy('status')
|
|
->orderByDesc('total')
|
|
->get()
|
|
->keyBy('status');
|
|
|
|
$completionRate = $period['total'] > 0
|
|
? round(($period['completed'] / $period['total']) * 100, 1)
|
|
: 0.0;
|
|
$noShowRate = $period['total'] > 0
|
|
? round(($period['no_show'] / $period['total']) * 100, 1)
|
|
: 0.0;
|
|
|
|
$alerts = [];
|
|
if ($noShowRate >= 15) {
|
|
$alerts[] = [
|
|
'severity' => 'warning',
|
|
'message' => "No-show rate is {$noShowRate}% this month — review booking reminders.",
|
|
];
|
|
}
|
|
if ($waiting >= 20) {
|
|
$alerts[] = [
|
|
'severity' => 'warning',
|
|
'message' => "{$waiting} patients are waiting across the facility right now.",
|
|
];
|
|
}
|
|
|
|
$appointmentStatusRows = collect(config('care.appointment_statuses', []))
|
|
->map(fn (string $label, string $status) => [
|
|
'label' => $label,
|
|
'value' => (int) ($byStatus->get($status)?->total ?? 0),
|
|
])
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'period_label' => 'This month',
|
|
'from' => $from->toDateString(),
|
|
'to' => $to->toDateString(),
|
|
'kpis' => [
|
|
['label' => 'Scheduled today', 'value' => number_format((clone $baseToday)->count())],
|
|
['label' => 'Waiting now', 'value' => number_format($waiting)],
|
|
['label' => 'In consultation', 'value' => number_format($inConsult)],
|
|
['label' => 'This month', 'value' => number_format($period['total'])],
|
|
['label' => 'Completed', 'value' => number_format($period['completed']), 'hint' => $completionRate.'%'],
|
|
['label' => 'No-shows', 'value' => number_format($period['no_show']), 'hint' => $noShowRate.'%'],
|
|
],
|
|
'breakdowns' => [
|
|
[
|
|
'title' => 'Appointments by status (this month)',
|
|
'rows' => $this->pctRows($appointmentStatusRows, $period['total']),
|
|
],
|
|
],
|
|
'alerts' => $alerts,
|
|
'links' => [
|
|
['label' => 'Appointments report', 'href' => route('care.reports.show', ['type' => 'appointments'])],
|
|
['label' => 'Browse schedule', 'href' => route('care.appointments.index', ['view' => 'list'])],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function inventory(Organization $organization, string $ownerRef, ?int $branchId = null): array
|
|
{
|
|
$drugQuery = Drug::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
|
|
|
$total = (clone $drugQuery)->count();
|
|
$lowStock = $this->pharmacy->lowStock($ownerRef, $organization->id);
|
|
$expired = $this->pharmacy->expiredBatches($ownerRef, $organization->id);
|
|
|
|
if ($branchId) {
|
|
$lowStock = $lowStock->where('branch_id', $branchId)->values();
|
|
$expired = $expired->filter(
|
|
fn ($batch) => (int) ($batch->drug?->branch_id ?? 0) === $branchId
|
|
)->values();
|
|
}
|
|
|
|
$stockValue = DrugBatch::query()
|
|
->whereHas('drug', function ($q) use ($ownerRef, $organization, $branchId) {
|
|
$q->owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($dq) => $dq->where('branch_id', $branchId));
|
|
})
|
|
->where('quantity_on_hand', '>', 0)
|
|
->where(function ($q) {
|
|
$q->whereNull('expiry_date')->orWhere('expiry_date', '>=', now()->toDateString());
|
|
})
|
|
->with('drug')
|
|
->get()
|
|
->sum(function (DrugBatch $batch) {
|
|
$price = (int) ($batch->drug?->unit_price_minor ?? 0);
|
|
|
|
return $price * (int) $batch->quantity_on_hand;
|
|
});
|
|
|
|
$expiringSoon = DrugBatch::query()
|
|
->whereHas('drug', function ($q) use ($ownerRef, $organization, $branchId) {
|
|
$q->owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($dq) => $dq->where('branch_id', $branchId));
|
|
})
|
|
->where('quantity_on_hand', '>', 0)
|
|
->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(30)->toDateString()])
|
|
->count();
|
|
|
|
$topLow = $lowStock->take(8)->map(fn (Drug $drug) => [
|
|
'label' => $drug->name,
|
|
'value' => $drug->stockOnHand().' '.$drug->unit,
|
|
])->all();
|
|
|
|
$alerts = [];
|
|
if ($lowStock->isNotEmpty()) {
|
|
$alerts[] = [
|
|
'severity' => 'warning',
|
|
'message' => $lowStock->count().' drug(s) are at or below reorder level.',
|
|
];
|
|
}
|
|
if ($expired->isNotEmpty()) {
|
|
$alerts[] = [
|
|
'severity' => 'critical',
|
|
'message' => $expired->count().' expired batch(es) still have stock on hand.',
|
|
];
|
|
}
|
|
if ($expiringSoon > 0) {
|
|
$alerts[] = [
|
|
'severity' => 'info',
|
|
'message' => "{$expiringSoon} batch(es) expire within 30 days.",
|
|
];
|
|
}
|
|
|
|
$currency = strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS')));
|
|
|
|
return [
|
|
'period_label' => 'Live stock',
|
|
'from' => now()->toDateString(),
|
|
'to' => now()->toDateString(),
|
|
'kpis' => [
|
|
['label' => 'Catalog items', 'value' => number_format($total)],
|
|
['label' => 'Low stock', 'value' => number_format($lowStock->count())],
|
|
['label' => 'Expired batches', 'value' => number_format($expired->count())],
|
|
['label' => 'Expiring ≤30 days', 'value' => number_format($expiringSoon)],
|
|
['label' => 'Est. stock value', 'value' => $currency.' '.number_format($stockValue / 100, 2)],
|
|
],
|
|
'breakdowns' => [
|
|
[
|
|
'title' => 'Lowest stock items',
|
|
'rows' => array_map(fn ($row) => [
|
|
'label' => $row['label'],
|
|
'value' => $row['value'],
|
|
'pct' => null,
|
|
], $topLow),
|
|
],
|
|
],
|
|
'alerts' => $alerts,
|
|
'links' => [
|
|
['label' => 'Browse inventory list', 'href' => route('care.pharmacy.drugs.index', ['view' => 'list'])],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function billing(Organization $organization, string $ownerRef, ?int $branchId = null): array
|
|
{
|
|
$from = now()->startOfMonth();
|
|
$to = now()->endOfDay();
|
|
$period = $this->reports->financeReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
$today = $this->reports->dashboardStats($ownerRef, $organization->id, $branchId, true);
|
|
|
|
// Current book by status (not limited to invoices created this month —
|
|
// MTD create-date filtering made demos look like "100% Open").
|
|
$byStatus = Bill::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->where('status', '!=', Bill::STATUS_VOID)
|
|
->select('status', DB::raw('count(*) as total'), DB::raw('sum(balance_minor) as balance'))
|
|
->groupBy('status')
|
|
->orderByDesc('total')
|
|
->get()
|
|
->keyBy('status');
|
|
|
|
$statusLabels = config('care.bill_statuses', []);
|
|
$statusOrder = [
|
|
Bill::STATUS_OPEN,
|
|
Bill::STATUS_PARTIAL,
|
|
Bill::STATUS_PAID,
|
|
Bill::STATUS_DRAFT,
|
|
];
|
|
$statusTotal = (int) $byStatus->sum(fn ($row) => (int) $row->total);
|
|
$statusRows = [];
|
|
foreach ($statusOrder as $status) {
|
|
if (! array_key_exists($status, $statusLabels) && ! $byStatus->has($status)) {
|
|
continue;
|
|
}
|
|
$count = (int) ($byStatus->get($status)?->total ?? 0);
|
|
$statusRows[] = [
|
|
'label' => $statusLabels[$status] ?? $status,
|
|
'value' => $count,
|
|
];
|
|
}
|
|
foreach ($byStatus as $status => $row) {
|
|
if (in_array($status, $statusOrder, true)) {
|
|
continue;
|
|
}
|
|
$statusRows[] = [
|
|
'label' => $statusLabels[$status] ?? (string) $status,
|
|
'value' => (int) $row->total,
|
|
];
|
|
}
|
|
|
|
$currency = strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS')));
|
|
$money = fn (int $minor) => $currency.' '.number_format($minor / 100, 2);
|
|
|
|
$collectionRate = $period['invoiced_minor'] > 0
|
|
? round(($period['collected_minor'] / $period['invoiced_minor']) * 100, 1)
|
|
: 0.0;
|
|
|
|
$alerts = [];
|
|
if ($period['outstanding_minor'] > 0) {
|
|
$alerts[] = [
|
|
'severity' => 'warning',
|
|
'message' => 'Outstanding balances total '.$money($period['outstanding_minor']).'.',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'period_label' => 'This month',
|
|
'from' => $from->toDateString(),
|
|
'to' => $to->toDateString(),
|
|
'kpis' => [
|
|
['label' => 'Revenue today', 'value' => $money((int) $today['revenue_today_minor'])],
|
|
['label' => 'Collected this month', 'value' => $money($period['collected_minor']), 'hint' => $collectionRate.'% of invoiced'],
|
|
['label' => 'Invoiced this month', 'value' => $money($period['invoiced_minor'])],
|
|
['label' => 'Outstanding', 'value' => $money($period['outstanding_minor'])],
|
|
['label' => 'Open / partial bills', 'value' => number_format((int) $today['open_bills'])],
|
|
['label' => 'Invoices this month', 'value' => number_format($period['invoice_count'])],
|
|
],
|
|
'breakdowns' => [
|
|
[
|
|
'title' => 'Invoices by status (current book)',
|
|
'rows' => $this->pctRows($statusRows, $statusTotal),
|
|
],
|
|
],
|
|
'alerts' => $alerts,
|
|
'links' => [
|
|
['label' => 'Finance report', 'href' => route('care.reports.show', ['type' => 'finance'])],
|
|
['label' => 'Browse invoices', 'href' => route('care.bills.index', ['view' => 'list'])],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Hub snapshot for Reports index.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function reportsHub(Organization $organization, string $ownerRef, ?int $branchId = null): array
|
|
{
|
|
$from = now()->startOfMonth();
|
|
$to = now()->endOfDay();
|
|
|
|
$patients = $this->reports->patientsReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
$appointments = $this->reports->appointmentsReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
$finance = $this->reports->financeReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
$lab = $this->reports->laboratoryReport($ownerRef, $organization->id, $from, $to, $branchId);
|
|
$today = $this->reports->dashboardStats($ownerRef, $organization->id, $branchId, true);
|
|
|
|
$currency = strtoupper((string) config('care.billing.currency', config('care.currency', 'GHS')));
|
|
$money = fn (int $minor) => $currency.' '.number_format($minor / 100, 2);
|
|
|
|
return [
|
|
'period_label' => 'This month',
|
|
'from' => $from->toDateString(),
|
|
'to' => $to->toDateString(),
|
|
'kpis' => [
|
|
['label' => 'Revenue today', 'value' => $money((int) $today['revenue_today_minor'])],
|
|
['label' => 'Collected MTD', 'value' => $money($finance['collected_minor'])],
|
|
['label' => 'Outstanding', 'value' => $money($finance['outstanding_minor'])],
|
|
['label' => 'New patients MTD', 'value' => number_format($patients['new_patients'])],
|
|
['label' => 'Appointments MTD', 'value' => number_format($appointments['total'])],
|
|
['label' => 'Lab pending', 'value' => number_format($lab['pending'])],
|
|
],
|
|
'breakdowns' => [
|
|
[
|
|
'title' => 'Month-to-date snapshot',
|
|
'rows' => [
|
|
['label' => 'Visits', 'value' => number_format($patients['total_visits']), 'pct' => null],
|
|
['label' => 'Completed appointments', 'value' => number_format($appointments['completed']), 'pct' => null],
|
|
['label' => 'No-shows', 'value' => number_format($appointments['no_show']), 'pct' => null],
|
|
['label' => 'Lab completed', 'value' => number_format($lab['completed']), 'pct' => null],
|
|
['label' => 'Invoices', 'value' => number_format($finance['invoice_count']), 'pct' => null],
|
|
],
|
|
],
|
|
],
|
|
'alerts' => [],
|
|
'links' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array{label: string, value: int}> $rows
|
|
* @return list<array{label: string, value: string|int, pct: float|null}>
|
|
*/
|
|
protected function pctRows(array $rows, int $total): array
|
|
{
|
|
return array_map(function (array $row) use ($total) {
|
|
$value = (int) $row['value'];
|
|
|
|
return [
|
|
'label' => $row['label'],
|
|
'value' => number_format($value),
|
|
'pct' => $total > 0 ? round(($value / $total) * 100, 1) : null,
|
|
];
|
|
}, $rows);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, mixed> $ids
|
|
* @return array<int, string>
|
|
*/
|
|
protected function branchNames(Organization $organization, string $ownerRef, Collection $ids): array
|
|
{
|
|
return Branch::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('id', $ids->filter()->all() ?: [0])
|
|
->pluck('name', 'id')
|
|
->mapWithKeys(fn ($name, $id) => [(int) $id => (string) $name])
|
|
->all();
|
|
}
|
|
}
|