Files
ladill-care/app/Services/Care/Dentistry/DentalAnalyticsService.php
T
isaaccladandCursor ce782382c5
Deploy Ladill Care / deploy (push) Successful in 31s
Complete Dentistry commercial suite with PMS depth.
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>
2026-07-18 17:11:15 +00:00

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;
}
}