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>
322 lines
12 KiB
PHP
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.');
|
|
}
|
|
}
|
|
}
|