Deploy Ladill Care / deploy (push) Successful in 1m26s
Add a template-driven assessment system with universal intake, clinical pathways, disease instruments (stroke MVP + extended, diabetes, and ten specialty packs), scoring, patient outcome trends, REST/API + FHIR export, and Enterprise org-level assessment analytics. Seed packs and design/licensing docs ship for deploy and pre-GA review.
304 lines
12 KiB
PHP
304 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\Appointment;
|
|
use App\Models\Assessment;
|
|
use App\Models\AssessmentScore;
|
|
use App\Models\Bill;
|
|
use App\Models\Diagnosis;
|
|
use App\Models\InvestigationRequest;
|
|
use App\Models\Patient;
|
|
use App\Models\PatientPathway;
|
|
use App\Models\Payment;
|
|
use App\Models\Visit;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class ReportService
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function dashboardStats(
|
|
string $ownerRef,
|
|
int $organizationId,
|
|
?int $branchId = null,
|
|
bool $includeBilling = true,
|
|
): array {
|
|
$today = now()->startOfDay();
|
|
|
|
$patientsToday = Patient::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereDate('created_at', $today)
|
|
->count();
|
|
|
|
$appointmentsToday = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereDate('scheduled_at', $today)
|
|
->count();
|
|
|
|
$openBills = 0;
|
|
$revenueToday = 0;
|
|
if ($includeBilling) {
|
|
$openBills = Bill::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
|
|
->count();
|
|
|
|
$revenueToday = Payment::owned($ownerRef)
|
|
->where('status', Payment::STATUS_PAID)
|
|
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
|
|
$q->where('organization_id', $organizationId);
|
|
if ($branchId) {
|
|
$q->where('branch_id', $branchId);
|
|
}
|
|
})
|
|
->whereDate('paid_at', $today)
|
|
->sum('amount_minor');
|
|
}
|
|
|
|
$pendingLab = InvestigationRequest::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereIn('status', InvestigationRequest::activeStatuses())
|
|
->count();
|
|
|
|
return [
|
|
'patients_today' => $patientsToday,
|
|
'appointments_today' => $appointmentsToday,
|
|
'open_bills' => $openBills,
|
|
'revenue_today_minor' => (int) $revenueToday,
|
|
'pending_lab' => $pendingLab,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function patientsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$newPatients = Patient::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('created_at', [$from, $to])
|
|
->count();
|
|
|
|
$returningPatients = Visit::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('checked_in_at', [$from, $to])
|
|
->distinct('patient_id')
|
|
->count('patient_id');
|
|
|
|
$visits = Visit::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('checked_in_at', [$from, $to])
|
|
->count();
|
|
|
|
return [
|
|
'new_patients' => $newPatients,
|
|
'returning_patients' => $returningPatients,
|
|
'total_visits' => $visits,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$base = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('scheduled_at', [$from, $to]);
|
|
|
|
return [
|
|
'total' => (clone $base)->count(),
|
|
'completed' => (clone $base)->where('status', Appointment::STATUS_COMPLETED)->count(),
|
|
'cancelled' => (clone $base)->where('status', Appointment::STATUS_CANCELLED)->count(),
|
|
'no_show' => (clone $base)->where('status', Appointment::STATUS_NO_SHOW)->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function laboratoryReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$base = InvestigationRequest::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('created_at', [$from, $to]);
|
|
|
|
$completed = (clone $base)
|
|
->whereIn('status', [InvestigationRequest::STATUS_COMPLETED, InvestigationRequest::STATUS_DELIVERED])
|
|
->get();
|
|
|
|
$turnaroundHours = $completed
|
|
->filter(fn ($r) => $r->completed_at && $r->created_at)
|
|
->map(fn ($r) => $r->created_at->diffInHours($r->completed_at))
|
|
->avg();
|
|
|
|
return [
|
|
'requested' => (clone $base)->count(),
|
|
'completed' => $completed->count(),
|
|
'pending' => (clone $base)->whereIn('status', InvestigationRequest::activeStatuses())->count(),
|
|
'avg_turnaround_hours' => $turnaroundHours ? round($turnaroundHours, 1) : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function financeReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$billed = Bill::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereBetween('created_at', [$from, $to])
|
|
->where('status', '!=', Bill::STATUS_VOID);
|
|
|
|
$payments = Payment::owned($ownerRef)
|
|
->where('status', Payment::STATUS_PAID)
|
|
->whereHas('bill', function (Builder $q) use ($organizationId, $branchId) {
|
|
$q->where('organization_id', $organizationId);
|
|
if ($branchId) {
|
|
$q->where('branch_id', $branchId);
|
|
}
|
|
})
|
|
->whereBetween('paid_at', [$from, $to]);
|
|
|
|
return [
|
|
'invoiced_minor' => (int) (clone $billed)->sum('total_minor'),
|
|
'collected_minor' => (int) (clone $payments)->sum('amount_minor'),
|
|
'outstanding_minor' => (int) Bill::owned($ownerRef)
|
|
->where('organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
|
|
->whereIn('status', [Bill::STATUS_OPEN, Bill::STATUS_PARTIAL])
|
|
->sum('balance_minor'),
|
|
'invoice_count' => (clone $billed)->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, object>
|
|
*/
|
|
public function clinicalReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to): Collection
|
|
{
|
|
return Diagnosis::owned($ownerRef)
|
|
->whereHas('consultation.visit', function (Builder $q) use ($organizationId, $from, $to) {
|
|
$q->where('organization_id', $organizationId)
|
|
->whereBetween('checked_in_at', [$from, $to]);
|
|
})
|
|
->select('description', DB::raw('count(*) as total'))
|
|
->groupBy('description')
|
|
->orderByDesc('total')
|
|
->limit(20)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Org-level assessment analytics (Enterprise / assessment_analytics).
|
|
*
|
|
* @return array{
|
|
* summary: array<string, int>,
|
|
* by_template: Collection<int, object>,
|
|
* by_pathway: Collection<int, object>,
|
|
* score_averages: Collection<int, object>
|
|
* }
|
|
*/
|
|
public function assessmentsReport(
|
|
string $ownerRef,
|
|
int $organizationId,
|
|
Carbon $from,
|
|
Carbon $to,
|
|
?int $branchId = null,
|
|
): array {
|
|
$base = Assessment::query()
|
|
->from('care_assessments')
|
|
->where('care_assessments.owner_ref', $ownerRef)
|
|
->where('care_assessments.organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('care_assessments.branch_id', $branchId))
|
|
->whereBetween('care_assessments.created_at', [$from, $to])
|
|
->whereNull('care_assessments.deleted_at');
|
|
|
|
$summary = [
|
|
'total_started' => (clone $base)->count(),
|
|
'completed' => (clone $base)->where('care_assessments.status', Assessment::STATUS_COMPLETED)->count(),
|
|
'draft' => (clone $base)->where('care_assessments.status', Assessment::STATUS_DRAFT)->count(),
|
|
'cancelled' => (clone $base)->where('care_assessments.status', Assessment::STATUS_CANCELLED)->count(),
|
|
'patients_with_assessments' => (clone $base)->distinct('care_assessments.patient_id')->count('care_assessments.patient_id'),
|
|
'pathways_activated' => PatientPathway::query()
|
|
->where('care_patient_pathways.owner_ref', $ownerRef)
|
|
->where('care_patient_pathways.organization_id', $organizationId)
|
|
->whereBetween('care_patient_pathways.activated_at', [$from, $to])
|
|
->whereNull('care_patient_pathways.deleted_at')
|
|
->count(),
|
|
];
|
|
|
|
$byTemplate = (clone $base)
|
|
->join('care_assessment_templates', 'care_assessments.template_id', '=', 'care_assessment_templates.id')
|
|
->select(
|
|
'care_assessment_templates.code as template_code',
|
|
'care_assessment_templates.name as template_name',
|
|
DB::raw('count(*) as total'),
|
|
DB::raw("sum(case when care_assessments.status = 'completed' then 1 else 0 end) as completed"),
|
|
)
|
|
->groupBy('care_assessment_templates.code', 'care_assessment_templates.name')
|
|
->orderByDesc('total')
|
|
->limit(30)
|
|
->get();
|
|
|
|
$byPathway = PatientPathway::query()
|
|
->from('care_patient_pathways')
|
|
->where('care_patient_pathways.owner_ref', $ownerRef)
|
|
->where('care_patient_pathways.organization_id', $organizationId)
|
|
->whereBetween('care_patient_pathways.activated_at', [$from, $to])
|
|
->whereNull('care_patient_pathways.deleted_at')
|
|
->join('care_clinical_pathways', 'care_patient_pathways.pathway_id', '=', 'care_clinical_pathways.id')
|
|
->select(
|
|
'care_clinical_pathways.code as pathway_code',
|
|
'care_clinical_pathways.name as pathway_name',
|
|
DB::raw('count(*) as total'),
|
|
DB::raw("sum(case when care_patient_pathways.status = 'active' then 1 else 0 end) as still_active"),
|
|
)
|
|
->groupBy('care_clinical_pathways.code', 'care_clinical_pathways.name')
|
|
->orderByDesc('total')
|
|
->limit(20)
|
|
->get();
|
|
|
|
$scoreAverages = AssessmentScore::query()
|
|
->from('care_assessment_scores')
|
|
->where('care_assessment_scores.owner_ref', $ownerRef)
|
|
->whereHas('assessment', function (Builder $q) use ($organizationId, $branchId, $from, $to) {
|
|
$q->where('care_assessments.organization_id', $organizationId)
|
|
->when($branchId, fn ($qq) => $qq->where('care_assessments.branch_id', $branchId))
|
|
->whereBetween('care_assessments.completed_at', [$from, $to]);
|
|
})
|
|
->select(
|
|
'care_assessment_scores.template_code',
|
|
DB::raw('count(*) as completions'),
|
|
DB::raw('avg(care_assessment_scores.total_score) as avg_total'),
|
|
DB::raw('avg(care_assessment_scores.max_score) as avg_max'),
|
|
)
|
|
->groupBy('care_assessment_scores.template_code')
|
|
->orderByDesc('completions')
|
|
->limit(20)
|
|
->get();
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'by_template' => $byTemplate,
|
|
'by_pathway' => $byPathway,
|
|
'score_averages' => $scoreAverages,
|
|
];
|
|
}
|
|
}
|