Files
ladill-care/app/Services/Care/Dentistry/DentalTreatmentPlanService.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

322 lines
12 KiB
PHP

<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalPlanItem;
use App\Models\DentalTreatmentPlan;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use App\Services\Care\SpecialtyShellService;
use Illuminate\Support\Collection;
class DentalTreatmentPlanService
{
public function __construct(
protected SpecialtyShellService $shell,
) {}
public function activePlanForPatient(Organization $organization, Patient $patient, string $ownerRef): ?DentalTreatmentPlan
{
return DentalTreatmentPlan::owned($ownerRef)
->where('organization_id', $organization->id)
->where('patient_id', $patient->id)
->whereIn('status', [
DentalTreatmentPlan::STATUS_DRAFT,
DentalTreatmentPlan::STATUS_ACCEPTED,
DentalTreatmentPlan::STATUS_IN_PROGRESS,
])
->with('items')
->latest('id')
->first();
}
/**
* @return Collection<int, DentalTreatmentPlan>
*/
public function plansForPatient(Organization $organization, Patient $patient, string $ownerRef, int $limit = 20): Collection
{
return DentalTreatmentPlan::owned($ownerRef)
->where('organization_id', $organization->id)
->where('patient_id', $patient->id)
->with('items')
->latest('id')
->limit($limit)
->get();
}
public function ensurePlan(
Organization $organization,
Patient $patient,
string $ownerRef,
?Visit $visit = null,
?string $actorRef = null,
): DentalTreatmentPlan {
$plan = $this->activePlanForPatient($organization, $patient, $ownerRef);
if ($plan) {
return $plan;
}
$plan = DentalTreatmentPlan::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $patient->id,
'visit_id' => $visit?->id,
'status' => DentalTreatmentPlan::STATUS_DRAFT,
'title' => 'Treatment plan',
'created_by' => $actorRef ?? $ownerRef,
]);
AuditLogger::record($ownerRef, 'dental.plan.created', $organization->id, $actorRef, DentalTreatmentPlan::class, $plan->id);
return $plan->fresh('items');
}
/**
* @param array{tooth_code?: ?string, surfaces?: list<string>, procedure_code: string, priority?: int, notes?: ?string} $data
*/
public function addItem(
DentalTreatmentPlan $plan,
Organization $organization,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalPlanItem {
$this->assertPlanMutable($plan);
$code = (string) ($data['procedure_code'] ?? '');
$service = collect($this->shell->provisionedServices($organization, 'dentistry'))
->first(fn ($s) => ($s['code'] ?? '') === $code)
?? DentalCatalog::defaultServices()[$code] ?? null;
if (! $service) {
throw new \InvalidArgumentException("Unknown dental procedure [{$code}].");
}
$tooth = $data['tooth_code'] ?? null;
if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) {
throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}].");
}
$surfaces = array_values(array_intersect(
array_map('strtoupper', $data['surfaces'] ?? []),
DentalCatalog::surfaces(),
));
$item = DentalPlanItem::create([
'treatment_plan_id' => $plan->id,
'tooth_code' => $tooth,
'surfaces' => $surfaces,
'procedure_code' => $code,
'label' => $service['label'],
'priority' => (int) ($data['priority'] ?? 50),
'status' => $plan->status === DentalTreatmentPlan::STATUS_DRAFT
? DentalPlanItem::STATUS_PROPOSED
: DentalPlanItem::STATUS_ACCEPTED,
'estimate_minor' => (int) ($service['amount_minor'] ?? 0),
'notes' => $data['notes'] ?? null,
]);
$this->recalculateEstimate($plan);
AuditLogger::record($ownerRef, 'dental.plan.item_added', $organization->id, $actorRef, DentalPlanItem::class, $item->id);
return $item;
}
/**
* @param array{tooth_code?: ?string, surfaces?: list<string>, procedure_code?: string, priority?: int, notes?: ?string} $data
*/
public function updateItem(
DentalPlanItem $item,
Organization $organization,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalPlanItem {
if ($item->status === DentalPlanItem::STATUS_DONE) {
throw new \InvalidArgumentException('Completed plan items cannot be edited.');
}
if ($item->status === DentalPlanItem::STATUS_CANCELLED) {
throw new \InvalidArgumentException('Cancelled plan items cannot be edited.');
}
$plan = $item->plan;
$this->assertPlanMutable($plan);
$updates = [];
if (array_key_exists('procedure_code', $data) && $data['procedure_code']) {
$code = (string) $data['procedure_code'];
$service = collect($this->shell->provisionedServices($organization, 'dentistry'))
->first(fn ($s) => ($s['code'] ?? '') === $code)
?? DentalCatalog::defaultServices()[$code] ?? null;
if (! $service) {
throw new \InvalidArgumentException("Unknown dental procedure [{$code}].");
}
$updates['procedure_code'] = $code;
$updates['label'] = $service['label'];
$updates['estimate_minor'] = (int) ($service['amount_minor'] ?? 0);
}
if (array_key_exists('tooth_code', $data)) {
$tooth = $data['tooth_code'];
if ($tooth && ! DentalCatalog::isValidToothCode($tooth)) {
throw new \InvalidArgumentException("Invalid FDI tooth code [{$tooth}].");
}
$updates['tooth_code'] = $tooth ?: null;
}
if (array_key_exists('surfaces', $data)) {
$updates['surfaces'] = array_values(array_intersect(
array_map('strtoupper', $data['surfaces'] ?? []),
DentalCatalog::surfaces(),
));
}
if (array_key_exists('priority', $data) && $data['priority'] !== null) {
$updates['priority'] = (int) $data['priority'];
}
if (array_key_exists('notes', $data)) {
$updates['notes'] = $data['notes'];
}
$item->update($updates);
$this->recalculateEstimate($plan);
AuditLogger::record($ownerRef, 'dental.plan.item_updated', $organization->id, $actorRef, DentalPlanItem::class, $item->id);
return $item->fresh();
}
public function cancelItem(
DentalPlanItem $item,
string $ownerRef,
?string $actorRef = null,
): DentalPlanItem {
if ($item->status === DentalPlanItem::STATUS_DONE) {
throw new \InvalidArgumentException('Completed plan items cannot be cancelled.');
}
$item->update(['status' => DentalPlanItem::STATUS_CANCELLED]);
$plan = $item->plan;
if ($plan) {
$this->recalculateEstimate($plan);
if ($plan->items()->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])->doesntExist()
&& $plan->items()->where('status', DentalPlanItem::STATUS_DONE)->exists()) {
$plan->update([
'status' => DentalTreatmentPlan::STATUS_COMPLETED,
'completed_at' => now(),
]);
}
}
AuditLogger::record($ownerRef, 'dental.plan.item_cancelled', $plan?->organization_id, $actorRef, DentalPlanItem::class, $item->id);
return $item->fresh();
}
public function accept(DentalTreatmentPlan $plan, string $ownerRef, ?string $actorRef = null): DentalTreatmentPlan
{
if ($plan->status !== DentalTreatmentPlan::STATUS_DRAFT) {
throw new \InvalidArgumentException('Only draft plans can be accepted.');
}
$plan->update([
'status' => DentalTreatmentPlan::STATUS_ACCEPTED,
'accepted_at' => now(),
]);
$plan->items()
->where('status', DentalPlanItem::STATUS_PROPOSED)
->update(['status' => DentalPlanItem::STATUS_ACCEPTED]);
AuditLogger::record($ownerRef, 'dental.plan.accepted', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id);
return $plan->fresh('items');
}
public function rejectPlan(
DentalTreatmentPlan $plan,
string $ownerRef,
?string $reason = null,
?string $actorRef = null,
): DentalTreatmentPlan {
if (! in_array($plan->status, [DentalTreatmentPlan::STATUS_DRAFT, DentalTreatmentPlan::STATUS_ACCEPTED], true)) {
throw new \InvalidArgumentException('Only draft or accepted plans can be rejected.');
}
$plan->update([
'status' => DentalTreatmentPlan::STATUS_REJECTED,
'rejected_at' => now(),
'rejection_reason' => $reason,
]);
$plan->items()
->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])
->update(['status' => DentalPlanItem::STATUS_CANCELLED]);
AuditLogger::record($ownerRef, 'dental.plan.rejected', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id);
return $plan->fresh('items');
}
public function cancelPlan(
DentalTreatmentPlan $plan,
string $ownerRef,
?string $actorRef = null,
): DentalTreatmentPlan {
if ($plan->status === DentalTreatmentPlan::STATUS_COMPLETED) {
throw new \InvalidArgumentException('Completed plans cannot be cancelled.');
}
$plan->items()
->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])
->update(['status' => DentalPlanItem::STATUS_CANCELLED]);
$plan->update(['status' => DentalTreatmentPlan::STATUS_CANCELLED]);
$plan->delete();
AuditLogger::record($ownerRef, 'dental.plan.cancelled', $plan->organization_id, $actorRef, DentalTreatmentPlan::class, $plan->id);
return $plan;
}
public function markItemDone(DentalPlanItem $item): DentalPlanItem
{
$item->update(['status' => DentalPlanItem::STATUS_DONE]);
$plan = $item->plan;
if ($plan && $plan->items()->whereNotIn('status', [DentalPlanItem::STATUS_DONE, DentalPlanItem::STATUS_CANCELLED])->doesntExist()) {
$plan->update([
'status' => DentalTreatmentPlan::STATUS_COMPLETED,
'completed_at' => now(),
]);
} elseif ($plan && in_array($plan->status, [DentalTreatmentPlan::STATUS_ACCEPTED, DentalTreatmentPlan::STATUS_DRAFT], true)) {
$plan->update(['status' => DentalTreatmentPlan::STATUS_IN_PROGRESS]);
}
return $item->fresh();
}
public function recalculateEstimate(DentalTreatmentPlan $plan): void
{
$total = (int) $plan->items()
->whereNotIn('status', [DentalPlanItem::STATUS_CANCELLED])
->sum('estimate_minor');
$plan->update(['estimate_minor' => $total]);
}
protected function assertPlanMutable(?DentalTreatmentPlan $plan): void
{
if (! $plan) {
throw new \InvalidArgumentException('Plan not found.');
}
if (in_array($plan->status, [
DentalTreatmentPlan::STATUS_COMPLETED,
DentalTreatmentPlan::STATUS_REJECTED,
DentalTreatmentPlan::STATUS_CANCELLED,
], true)) {
throw new \InvalidArgumentException('This treatment plan is closed.');
}
}
}