Ship commercial Dentistry suite with odontogram and treatment plans.
Deploy Ladill Care / deploy (push) Failing after 36s
Deploy Ladill Care / deploy (push) Failing after 36s
Replace placeholder clinical forms with patient-level FDI charting, multi-visit plans, procedure-to-bill linking, imaging, reports, and demo seed data. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<?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\DentalTreatmentPlan;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
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
|
||||
* }
|
||||
*/
|
||||
public function report(
|
||||
Organization $organization,
|
||||
string $ownerRef,
|
||||
?int $branchId = null,
|
||||
): array {
|
||||
$todayStart = now()->startOfDay();
|
||||
$monthStart = now()->startOfMonth();
|
||||
|
||||
$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)
|
||||
->where('performed_at', '>=', $monthStart)
|
||||
->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')
|
||||
->where('created_at', '>=', $monthStart)
|
||||
->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')
|
||||
->where('completed_at', '>=', $monthStart)
|
||||
->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)
|
||||
->where('captured_at', '>=', $monthStart)
|
||||
->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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}.",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user