Deploy Ladill Care / deploy (push) Successful in 31s
Add plan lifecycle, visit stages with chair/recovery points, primary dentition charting, imaging void, revenue reports, perio exams, lab cases, and recalls. Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
6.7 KiB
PHP
183 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care\Dentistry;
|
|
|
|
use App\Models\Appointment;
|
|
use App\Models\BillLineItem;
|
|
use App\Models\DentalImage;
|
|
use App\Models\DentalPlanItem;
|
|
use App\Models\DentalProcedure;
|
|
use App\Models\DentalRecall;
|
|
use App\Models\DentalTreatmentPlan;
|
|
use App\Models\Organization;
|
|
use App\Models\Patient;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class DentalAnalyticsService
|
|
{
|
|
/**
|
|
* @return array{
|
|
* procedures_today: Collection,
|
|
* procedures_month: Collection,
|
|
* revenue_by_code: Collection,
|
|
* unfinished_items: int,
|
|
* avg_chair_minutes: ?float,
|
|
* imaging_by_modality: 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();
|
|
|
|
$procBase = DentalProcedure::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('status', DentalProcedure::STATUS_COMPLETED);
|
|
|
|
$proceduresToday = (clone $procBase)
|
|
->where('performed_at', '>=', $todayStart)
|
|
->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor')
|
|
->groupBy('procedure_code', 'label')
|
|
->orderByDesc('total')
|
|
->get();
|
|
|
|
$proceduresMonth = (clone $procBase)
|
|
->whereBetween('performed_at', [$rangeFrom, $rangeTo])
|
|
->selectRaw('procedure_code, label, count(*) as total, sum(amount_minor) as amount_minor')
|
|
->groupBy('procedure_code', 'label')
|
|
->orderByDesc('total')
|
|
->get();
|
|
|
|
$revenueByCode = BillLineItem::query()
|
|
->where('owner_ref', $ownerRef)
|
|
->where('source_type', 'dental_procedure')
|
|
->whereBetween('created_at', [$rangeFrom, $rangeTo])
|
|
->selectRaw('description, sum(total_minor) as amount_minor, count(*) as total')
|
|
->groupBy('description')
|
|
->orderByDesc('amount_minor')
|
|
->limit(20)
|
|
->get();
|
|
|
|
$unfinished = DentalPlanItem::query()
|
|
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
|
|
$q->owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereIn('status', [
|
|
DentalTreatmentPlan::STATUS_ACCEPTED,
|
|
DentalTreatmentPlan::STATUS_IN_PROGRESS,
|
|
]);
|
|
})
|
|
->whereIn('status', [DentalPlanItem::STATUS_ACCEPTED, DentalPlanItem::STATUS_PROPOSED])
|
|
->count();
|
|
|
|
$chairQuery = Appointment::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('status', Appointment::STATUS_COMPLETED)
|
|
->whereNotNull('started_at')
|
|
->whereNotNull('completed_at')
|
|
->whereBetween('completed_at', [$rangeFrom, $rangeTo])
|
|
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
|
|
|
|
$avgChair = null;
|
|
$samples = (clone $chairQuery)->limit(200)->get(['started_at', 'completed_at']);
|
|
if ($samples->isNotEmpty()) {
|
|
$avgChair = round($samples->avg(fn ($a) => $a->started_at->diffInMinutes($a->completed_at)), 1);
|
|
}
|
|
|
|
$imaging = DentalImage::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereBetween('captured_at', [$rangeFrom, $rangeTo])
|
|
->selectRaw('modality, count(*) as total')
|
|
->groupBy('modality')
|
|
->orderByDesc('total')
|
|
->get();
|
|
|
|
return [
|
|
'procedures_today' => $proceduresToday,
|
|
'procedures_month' => $proceduresMonth,
|
|
'revenue_by_code' => $revenueByCode,
|
|
'unfinished_items' => $unfinished,
|
|
'avg_chair_minutes' => $avgChair,
|
|
'imaging_by_modality' => $imaging,
|
|
'from' => $rangeFrom->toDateString(),
|
|
'to' => $rangeTo->toDateString(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array{code: string, severity: string, message: string}>
|
|
*/
|
|
public function alertsForPatient(Organization $organization, Patient $patient, string $ownerRef): array
|
|
{
|
|
$alerts = [];
|
|
|
|
$plan = DentalTreatmentPlan::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('patient_id', $patient->id)
|
|
->whereIn('status', [
|
|
DentalTreatmentPlan::STATUS_ACCEPTED,
|
|
DentalTreatmentPlan::STATUS_IN_PROGRESS,
|
|
])
|
|
->with('items')
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($plan && $plan->accepted_at && $plan->accepted_at->lt(now()->subDays(30))) {
|
|
$open = $plan->items->whereIn('status', [
|
|
DentalPlanItem::STATUS_ACCEPTED,
|
|
DentalPlanItem::STATUS_PROPOSED,
|
|
])->count();
|
|
if ($open > 0) {
|
|
$alerts[] = [
|
|
'code' => 'dental.plan.stale',
|
|
'severity' => 'warning',
|
|
'message' => "Accepted treatment plan has {$open} unfinished item(s) older than 30 days.",
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($plan) {
|
|
$high = $plan->items
|
|
->where('priority', '<=', 20)
|
|
->whereIn('status', [DentalPlanItem::STATUS_ACCEPTED, DentalPlanItem::STATUS_PROPOSED]);
|
|
foreach ($high->take(3) as $item) {
|
|
$tooth = $item->tooth_code ? " tooth {$item->tooth_code}" : '';
|
|
$alerts[] = [
|
|
'code' => 'dental.plan.high_priority',
|
|
'severity' => 'warning',
|
|
'message' => "High-priority planned: {$item->label}{$tooth}.",
|
|
];
|
|
}
|
|
}
|
|
|
|
$overdue = DentalRecall::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('patient_id', $patient->id)
|
|
->whereIn('status', [DentalRecall::STATUS_SCHEDULED, DentalRecall::STATUS_DUE])
|
|
->where('due_on', '<', now()->toDateString())
|
|
->orderBy('due_on')
|
|
->limit(3)
|
|
->get();
|
|
|
|
foreach ($overdue as $recall) {
|
|
$reason = $recall->reason ?: 'Recall';
|
|
$alerts[] = [
|
|
'code' => 'dental.recall.overdue',
|
|
'severity' => 'warning',
|
|
'message' => "Overdue recall ({$reason}) due {$recall->due_on->format('d M Y')}.",
|
|
];
|
|
}
|
|
|
|
return $alerts;
|
|
}
|
|
}
|