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 */ 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, 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, 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.'); } } }