Ship commercial Dentistry suite with odontogram and treatment plans.
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:
isaacclad
2026-07-18 16:33:34 +00:00
co-authored by Cursor
parent a00a8249ad
commit 0edd653187
37 changed files with 2906 additions and 35 deletions
@@ -0,0 +1,319 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Visit;
use App\Services\Care\Dentistry\DentalAnalyticsService;
use App\Services\Care\Dentistry\DentalCatalog;
use App\Services\Care\Dentistry\DentalChartService;
use App\Services\Care\Dentistry\DentalImagingService;
use App\Services\Care\Dentistry\DentalProcedureService;
use App\Services\Care\Dentistry\DentalTreatmentPlanService;
use App\Services\Care\Dentistry\DentalVisitNoteService;
use App\Services\Care\SpecialtyModuleService;
use App\Services\Care\SpecialtyShellService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DentistryWorkspaceController extends Controller
{
use ScopesToAccount;
protected function assertDentistryAccess(Request $request, SpecialtyModuleService $modules): void
{
$organization = $this->organization($request);
abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403);
}
protected function assertVisit(Request $request, Visit $visit): void
{
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
$this->authorizeBranch($request, (int) $visit->branch_id);
}
public function updateTooth(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalChartService $charts,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'tooth_code' => ['required', 'string', 'max:8'],
'status' => ['required', 'string', 'in:present,missing,extracted,implant'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'conditions' => ['nullable', 'array'],
'conditions.*' => ['string', 'max:32'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
try {
$charts->updateTooth(
$chart,
$validated['tooth_code'],
$validated,
$owner,
$this->actorRef($request),
$visit,
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'odontogram'])
->with('success', 'Tooth '.$validated['tooth_code'].' updated.');
}
public function addPlanItem(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'procedure_code' => ['required', 'string', 'max:64'],
'tooth_code' => ['nullable', 'string', 'max:8'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'priority' => ['nullable', 'integer', 'min:1', 'max:100'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->ensurePlan($organization, $visit->patient, $owner, $visit, $this->actorRef($request));
try {
$plans->addItem($plan, $organization, $validated, $owner, $this->actorRef($request));
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Added to treatment plan.');
}
public function acceptPlan(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalTreatmentPlanService $plans,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
if (! $plan) {
return back()->with('error', 'No open treatment plan.');
}
$plans->accept($plan, $owner, $this->actorRef($request));
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'plan'])
->with('success', 'Treatment plan accepted.');
}
public function completeProcedure(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalProcedureService $procedures,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'procedure_code' => ['required', 'string', 'max:64'],
'tooth_code' => ['nullable', 'string', 'max:8'],
'surfaces' => ['nullable', 'array'],
'surfaces.*' => ['string', 'max:2'],
'anesthesia' => ['nullable', 'string', 'max:32'],
'notes' => ['nullable', 'string', 'max:2000'],
'plan_item_id' => ['nullable', 'integer'],
'bill' => ['nullable', 'boolean'],
]);
try {
$procedures->complete(
$this->organization($request),
$visit,
[
...$validated,
'bill' => $request->boolean('bill', true),
],
$this->ownerRef($request),
$this->actorRef($request),
);
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'treat'])
->with('success', 'Procedure recorded and billed.');
}
public function saveNotes(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalVisitNoteService $notes,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'chief_complaint' => ['nullable', 'string', 'max:5000'],
'soft_tissue' => ['nullable', 'string', 'max:5000'],
'occlusion' => ['nullable', 'string', 'max:5000'],
'notes' => ['nullable', 'string', 'max:5000'],
]);
$notes->upsert(
$this->organization($request),
$visit,
$validated,
$this->ownerRef($request),
$this->actorRef($request),
);
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'notes'])
->with('success', 'Visit notes saved.');
}
public function uploadImage(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalImagingService $imaging,
DentalProcedureService $procedures,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$validated = $request->validate([
'attachment' => ['required', 'file', 'max:10240', 'mimes:pdf,jpeg,jpg,png,webp'],
'modality' => ['required', 'string', 'in:pa,bw,opg,photo'],
'tooth_codes' => ['nullable', 'array'],
'tooth_codes.*' => ['string', 'max:8'],
'caption' => ['nullable', 'string', 'max:255'],
'add_fee' => ['nullable', 'boolean'],
]);
try {
$imaging->upload(
$this->organization($request),
$visit,
$validated['attachment'],
$validated,
$this->ownerRef($request),
$this->actorRef($request),
);
if ($request->boolean('add_fee')) {
$feeCode = match ($validated['modality']) {
'pa' => 'den.xray_pa',
'bw' => 'den.xray_bw',
'opg' => 'den.xray_opg',
default => 'den.photo',
};
$procedures->complete(
$this->organization($request),
$visit,
[
'procedure_code' => $feeCode,
'tooth_code' => ($validated['tooth_codes'][0] ?? null),
'bill' => true,
'notes' => 'Imaging fee',
],
$this->ownerRef($request),
$this->actorRef($request),
);
}
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
return redirect()
->route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'imaging'])
->with('success', 'Image uploaded.');
}
public function reports(
Request $request,
SpecialtyModuleService $modules,
SpecialtyShellService $shell,
DentalAnalyticsService $analytics,
): View {
$this->assertDentistryAccess($request, $modules);
$organization = $this->organization($request);
$member = $this->member($request);
$branchScope = app(\App\Services\Care\OrganizationResolver::class)->branchScope($member);
return view('care.specialty.dentistry.reports', [
'moduleKey' => 'dentistry',
'definition' => $modules->definition('dentistry'),
'report' => $analytics->report($organization, $this->ownerRef($request), $branchScope),
'modalities' => DentalCatalog::modalities(),
'currency' => config('care.billing.currency', 'GHS'),
'shellNav' => $shell->navItems('dentistry'),
]);
}
public function printChart(
Request $request,
Visit $visit,
SpecialtyModuleService $modules,
DentalChartService $charts,
DentalTreatmentPlanService $plans,
): StreamedResponse|View {
$this->authorizeAbility($request, 'consultations.view');
$this->assertDentistryAccess($request, $modules);
$this->assertVisit($request, $visit);
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visit->loadMissing('patient');
$chart = $charts->chartForPatient($organization, $visit->patient, $owner);
$plan = $plans->activePlanForPatient($organization, $visit->patient, $owner);
return view('care.specialty.dentistry.print', [
'visit' => $visit,
'patient' => $visit->patient,
'chart' => $chart,
'teethMap' => $charts->teethMap($chart),
'plan' => $plan,
'rows' => DentalCatalog::odontogramRows(),
'conditions' => DentalCatalog::conditions(),
]);
}
}
@@ -283,9 +283,11 @@ class QueueController extends Controller
if ($specialtyContext !== CareQueueContexts::CONSULTATION && $appointment->visit) {
$clinical = app(\App\Services\Care\SpecialtyClinicalRecordService::class);
$shell = app(\App\Services\Care\SpecialtyShellService::class);
$preferredTab = collect(array_keys($shell->workspaceTabs($specialtyContext)))
->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null)
?? 'clinical_notes';
$preferredTab = $specialtyContext === 'dentistry'
? 'odontogram'
: (collect(array_keys($shell->workspaceTabs($specialtyContext)))
->first(fn (string $tab) => $clinical->recordTypeForTab($specialtyContext, $tab) !== null)
?? 'clinical_notes');
return redirect()
->route('care.specialty.workspace', [
@@ -155,9 +155,11 @@ class SpecialtyModuleController extends Controller
if ($openConsultation && $openConsultation->status !== \App\Models\Consultation::STATUS_COMPLETED) {
$clinical = app(SpecialtyClinicalRecordService::class);
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
$preferredTab = collect(array_keys($shellTabs))
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
?? 'clinical_notes';
$preferredTab = $module === 'dentistry'
? 'odontogram'
: (collect(array_keys($shellTabs))
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
?? 'clinical_notes');
return redirect()
->route('care.specialty.workspace', [
@@ -196,9 +198,11 @@ class SpecialtyModuleController extends Controller
$visit = $visit->fresh() ?? $appointment->visit;
$clinical = app(SpecialtyClinicalRecordService::class);
$shellTabs = app(SpecialtyShellService::class)->workspaceTabs($module);
$preferredTab = collect(array_keys($shellTabs))
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
?? 'clinical_notes';
$preferredTab = $module === 'dentistry'
? 'odontogram'
: (collect(array_keys($shellTabs))
->first(fn (string $tab) => $clinical->recordTypeForTab($module, $tab) !== null)
?? 'clinical_notes');
return redirect()
->route('care.specialty.workspace', [
@@ -503,6 +507,18 @@ class SpecialtyModuleController extends Controller
$clinicalFields = [];
$clinicalAlerts = [];
$visitDocuments = collect();
$dentalChart = null;
$dentalTeethMap = [];
$dentalPlan = null;
$dentalProcedures = collect();
$dentalImages = collect();
$dentalVisitNote = null;
$dentalCatalog = [];
$dentalRows = [];
$dentalConditions = [];
$dentalStatuses = [];
$dentalSurfaces = [];
$dentalModalities = [];
$activeTab = (string) $request->query('tab', array_key_first($shell->workspaceTabs($module)) ?: 'overview');
$clinical = app(SpecialtyClinicalRecordService::class);
@@ -553,6 +569,47 @@ class SpecialtyModuleController extends Controller
$clinicalFields = $clinical->fieldsFor($module, $recordType);
}
$clinicalAlerts = $clinical->alertsForVisit($workspaceVisit, $module);
if ($module === 'dentistry' && $workspaceVisit->patient) {
$chartService = app(\App\Services\Care\Dentistry\DentalChartService::class);
$planService = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
$analytics = app(\App\Services\Care\Dentistry\DentalAnalyticsService::class);
$dentalChart = $chartService->chartForPatient($organization, $workspaceVisit->patient, $owner);
$dentalTeethMap = $chartService->teethMap($dentalChart);
$dentalPlan = $planService->activePlanForPatient($organization, $workspaceVisit->patient, $owner);
$dentalProcedures = \App\Models\DentalProcedure::owned($owner)
->where('visit_id', $workspaceVisit->id)
->orderByDesc('id')
->get();
$dentalImages = \App\Models\DentalImage::owned($owner)
->where(function ($q) use ($workspaceVisit) {
$q->where('visit_id', $workspaceVisit->id)
->orWhere(function ($q2) use ($workspaceVisit) {
$q2->where('patient_id', $workspaceVisit->patient_id)
->whereNull('visit_id');
});
})
->with('attachment')
->orderByDesc('id')
->limit(40)
->get();
$dentalVisitNote = \App\Models\DentalVisitNote::owned($owner)
->where('visit_id', $workspaceVisit->id)
->first();
$dentalCatalog = $shell->provisionedServices($organization, 'dentistry');
$dentalRows = \App\Services\Care\Dentistry\DentalCatalog::odontogramRows();
$dentalConditions = \App\Services\Care\Dentistry\DentalCatalog::conditions();
$dentalStatuses = \App\Services\Care\Dentistry\DentalCatalog::toothStatuses();
$dentalSurfaces = \App\Services\Care\Dentistry\DentalCatalog::surfaces();
$dentalModalities = \App\Services\Care\Dentistry\DentalCatalog::modalities();
$clinicalAlerts = array_merge(
$clinicalAlerts,
$analytics->alertsForPatient($organization, $workspaceVisit->patient, $owner),
);
}
if ($patientHeader !== null) {
$patientHeader['clinical_alerts'] = $clinicalAlerts;
}
@@ -593,6 +650,18 @@ class SpecialtyModuleController extends Controller
'clinicalFields' => $clinicalFields,
'clinicalAlerts' => $clinicalAlerts,
'visitDocuments' => $visitDocuments,
'dentalChart' => $dentalChart,
'dentalTeethMap' => $dentalTeethMap,
'dentalPlan' => $dentalPlan,
'dentalProcedures' => $dentalProcedures,
'dentalImages' => $dentalImages,
'dentalVisitNote' => $dentalVisitNote,
'dentalCatalog' => $dentalCatalog,
'dentalRows' => $dentalRows,
'dentalConditions' => $dentalConditions,
'dentalStatuses' => $dentalStatuses,
'dentalSurfaces' => $dentalSurfaces,
'dentalModalities' => $dentalModalities,
'queueStubs' => $modules->queueStubsFor($organization, $module),
'branchId' => $branchId,
'branchLabel' => $branchLabel,
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class DentalChart extends Model
{
use BelongsToOwner;
protected $table = 'care_dental_charts';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id',
'notation', 'charted_at', 'charted_by',
];
protected function casts(): array
{
return ['charted_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (DentalChart $chart) {
if (! $chart->uuid) {
$chart->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function teeth(): HasMany
{
return $this->hasMany(DentalTooth::class, 'dental_chart_id');
}
public function events(): HasMany
{
return $this->hasMany(DentalChartEvent::class, 'dental_chart_id');
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class DentalChartEvent extends Model
{
use BelongsToOwner;
protected $table = 'care_dental_chart_events';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'dental_chart_id', 'patient_id',
'visit_id', 'tooth_code', 'event_type', 'payload', 'recorded_by', 'recorded_at',
];
protected function casts(): array
{
return [
'payload' => 'array',
'recorded_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalChartEvent $event) {
if (! $event->uuid) {
$event->uuid = (string) Str::uuid();
}
});
}
public function chart(): BelongsTo
{
return $this->belongsTo(DentalChart::class, 'dental_chart_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class DentalImage extends Model
{
use BelongsToOwner;
public const MODALITY_PA = 'pa';
public const MODALITY_BW = 'bw';
public const MODALITY_OPG = 'opg';
public const MODALITY_PHOTO = 'photo';
protected $table = 'care_dental_images';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id',
'patient_attachment_id', 'modality', 'tooth_codes', 'caption',
'uploaded_by', 'captured_at',
];
protected function casts(): array
{
return [
'tooth_codes' => 'array',
'captured_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalImage $image) {
if (! $image->uuid) {
$image->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function attachment(): BelongsTo
{
return $this->belongsTo(PatientAttachment::class, 'patient_attachment_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class DentalPlanItem extends Model
{
public const STATUS_PROPOSED = 'proposed';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DONE = 'done';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_dental_plan_items';
protected $fillable = [
'uuid', 'treatment_plan_id', 'tooth_code', 'surfaces', 'procedure_code',
'label', 'priority', 'status', 'estimate_minor', 'bill_line_item_id', 'notes',
];
protected function casts(): array
{
return ['surfaces' => 'array'];
}
protected static function booted(): void
{
static::creating(function (DentalPlanItem $item) {
if (! $item->uuid) {
$item->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function plan(): BelongsTo
{
return $this->belongsTo(DentalTreatmentPlan::class, 'treatment_plan_id');
}
public function billLineItem(): BelongsTo
{
return $this->belongsTo(BillLineItem::class, 'bill_line_item_id');
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class DentalProcedure extends Model
{
use BelongsToOwner;
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $table = 'care_dental_procedures';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id', 'plan_item_id',
'tooth_code', 'surfaces', 'procedure_code', 'label', 'anesthesia', 'status',
'amount_minor', 'bill_line_item_id', 'provider_ref', 'notes', 'performed_at',
];
protected function casts(): array
{
return [
'surfaces' => 'array',
'performed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalProcedure $procedure) {
if (! $procedure->uuid) {
$procedure->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function planItem(): BelongsTo
{
return $this->belongsTo(DentalPlanItem::class, 'plan_item_id');
}
public function billLineItem(): BelongsTo
{
return $this->belongsTo(BillLineItem::class, 'bill_line_item_id');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DentalTooth extends Model
{
public const STATUS_PRESENT = 'present';
public const STATUS_MISSING = 'missing';
public const STATUS_EXTRACTED = 'extracted';
public const STATUS_IMPLANT = 'implant';
protected $table = 'care_dental_teeth';
protected $fillable = [
'dental_chart_id', 'tooth_code', 'status', 'surfaces', 'conditions', 'notes',
];
protected function casts(): array
{
return [
'surfaces' => 'array',
'conditions' => 'array',
];
}
public function chart(): BelongsTo
{
return $this->belongsTo(DentalChart::class, 'dental_chart_id');
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class DentalTreatmentPlan extends Model
{
use BelongsToOwner, SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
protected $table = 'care_dental_treatment_plans';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'visit_id',
'status', 'title', 'estimate_minor', 'accepted_at', 'completed_at', 'created_by',
];
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
'completed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (DentalTreatmentPlan $plan) {
if (! $plan->uuid) {
$plan->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function items(): HasMany
{
return $this->hasMany(DentalPlanItem::class, 'treatment_plan_id')->orderBy('priority')->orderBy('id');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class DentalVisitNote extends Model
{
use BelongsToOwner;
protected $table = 'care_dental_visit_notes';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'visit_id', 'patient_id',
'chief_complaint', 'soft_tissue', 'occlusion', 'notes',
'recorded_by', 'recorded_at',
];
protected function casts(): array
{
return ['recorded_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (DentalVisitNote $note) {
if (! $note->uuid) {
$note->uuid = (string) Str::uuid();
}
});
}
public function visit(): BelongsTo
{
return $this->belongsTo(Visit::class, 'visit_id');
}
public function patient(): BelongsTo
{
return $this->belongsTo(Patient::class, 'patient_id');
}
}
+76
View File
@@ -949,9 +949,85 @@ class DemoTenantSeeder
}
}
$this->seedDentistryClinicalDemo($organization, $ownerRef);
return $count;
}
private function seedDentistryClinicalDemo(Organization $organization, string $ownerRef): void
{
$departmentIds = Department::owned($ownerRef)
->where('organization_id', $organization->id)
->where('type', 'dental')
->where('is_active', true)
->pluck('id');
if ($departmentIds->isEmpty()) {
return;
}
$appointment = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_IN_CONSULTATION])
->whereNotNull('visit_id')
->with(['patient', 'visit'])
->latest('id')
->first();
if (! $appointment?->patient || ! $appointment->visit) {
return;
}
$charts = app(\App\Services\Care\Dentistry\DentalChartService::class);
$plans = app(\App\Services\Care\Dentistry\DentalTreatmentPlanService::class);
$procedures = app(\App\Services\Care\Dentistry\DentalProcedureService::class);
$chart = $charts->chartForPatient($organization, $appointment->patient, $ownerRef);
$charts->updateTooth($chart, '16', [
'status' => 'present',
'surfaces' => ['O'],
'conditions' => ['caries'],
'notes' => 'Demo occlusal caries',
], $ownerRef, $ownerRef, $appointment->visit);
$plan = $plans->ensurePlan($organization, $appointment->patient, $ownerRef, $appointment->visit, $ownerRef);
if ($plan->items()->count() === 0) {
$plans->addItem($plan, $organization, [
'procedure_code' => 'den.fill',
'tooth_code' => '16',
'surfaces' => ['O'],
'priority' => 10,
'notes' => 'Demo filling',
], $ownerRef, $ownerRef);
$plans->accept($plan->fresh('items'), $ownerRef, $ownerRef);
}
$completedPatient = Appointment::owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('department_id', $departmentIds)
->where('status', Appointment::STATUS_COMPLETED)
->whereNotNull('visit_id')
->with('visit')
->latest('id')
->first();
if ($completedPatient?->visit && ! \App\Models\DentalProcedure::owned($ownerRef)->where('visit_id', $completedPatient->visit_id)->exists()) {
try {
$procedures->complete($organization, $completedPatient->visit, [
'procedure_code' => 'den.fill',
'tooth_code' => '26',
'surfaces' => ['O'],
'anesthesia' => 'Local',
'bill' => true,
'notes' => 'Demo completed fill',
], $ownerRef, $ownerRef);
} catch (\Throwable) {
// Billing may be constrained in partial seeds — chart/plan still valuable.
}
}
}
/**
* @param list<Branch> $branches
* @param array<int, list<Department>> $departments
@@ -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;
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Care\Dentistry;
/**
* FDI adult dentition helpers and catalog labels for dentistry.
*/
class DentalCatalog
{
/** @return list<string> */
public static function adultToothCodes(): array
{
$codes = [];
foreach ([18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28] as $n) {
$codes[] = (string) $n;
}
foreach ([48, 47, 46, 45, 44, 43, 42, 41, 31, 32, 33, 34, 35, 36, 37, 38] as $n) {
$codes[] = (string) $n;
}
return $codes;
}
/**
* @return array{upper: list<string>, lower: list<string>}
*/
public static function odontogramRows(): array
{
return [
'upper' => ['18', '17', '16', '15', '14', '13', '12', '11', '21', '22', '23', '24', '25', '26', '27', '28'],
'lower' => ['48', '47', '46', '45', '44', '43', '42', '41', '31', '32', '33', '34', '35', '36', '37', '38'],
];
}
/** @return list<string> */
public static function surfaces(): array
{
return ['M', 'O', 'D', 'B', 'L', 'I'];
}
/** @return array<string, string> */
public static function conditions(): array
{
return [
'caries' => 'Caries',
'filled' => 'Filled',
'crown' => 'Crown',
'root_canal' => 'Root canal',
'fracture' => 'Fracture',
'watch' => 'Watch',
'missing' => 'Missing',
'implant' => 'Implant',
];
}
/** @return array<string, string> */
public static function toothStatuses(): array
{
return [
'present' => 'Present',
'missing' => 'Missing',
'extracted' => 'Extracted',
'implant' => 'Implant',
];
}
/** @return array<string, string> */
public static function modalities(): array
{
return [
'pa' => 'Periapical (PA)',
'bw' => 'Bitewing (BW)',
'opg' => 'OPG / panoramic',
'photo' => 'Clinical photo',
];
}
/** @return array<string, array{label: string, amount_minor: int, type: string}> */
public static function defaultServices(): array
{
return [
'den.consult' => ['label' => 'Dental consultation', 'amount_minor' => 5000, 'type' => 'consultation'],
'den.exam' => ['label' => 'Dental examination', 'amount_minor' => 4000, 'type' => 'consultation'],
'den.cleaning' => ['label' => 'Scaling / polishing', 'amount_minor' => 8000, 'type' => 'procedure'],
'den.fill' => ['label' => 'Filling', 'amount_minor' => 12000, 'type' => 'procedure'],
'den.extraction' => ['label' => 'Extraction', 'amount_minor' => 15000, 'type' => 'procedure'],
'den.rct' => ['label' => 'Root canal treatment', 'amount_minor' => 35000, 'type' => 'procedure'],
'den.crown' => ['label' => 'Crown', 'amount_minor' => 45000, 'type' => 'procedure'],
'den.bridge' => ['label' => 'Bridge unit', 'amount_minor' => 40000, 'type' => 'procedure'],
'den.implant_consult' => ['label' => 'Implant consultation', 'amount_minor' => 10000, 'type' => 'consultation'],
'den.perio' => ['label' => 'Periodontal treatment', 'amount_minor' => 20000, 'type' => 'procedure'],
'den.xray_pa' => ['label' => 'Periapical X-ray', 'amount_minor' => 3000, 'type' => 'imaging'],
'den.xray_bw' => ['label' => 'Bitewing X-ray', 'amount_minor' => 3500, 'type' => 'imaging'],
'den.xray_opg' => ['label' => 'OPG / panoramic X-ray', 'amount_minor' => 8000, 'type' => 'imaging'],
'den.photo' => ['label' => 'Clinical photography', 'amount_minor' => 2000, 'type' => 'imaging'],
];
}
public static function isValidToothCode(string $code): bool
{
return in_array($code, self::adultToothCodes(), true);
}
}
@@ -0,0 +1,170 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalChart;
use App\Models\DentalChartEvent;
use App\Models\DentalTooth;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use Illuminate\Support\Facades\DB;
class DentalChartService
{
public function chartForPatient(Organization $organization, Patient $patient, string $ownerRef): DentalChart
{
$chart = DentalChart::owned($ownerRef)
->where('organization_id', $organization->id)
->where('patient_id', $patient->id)
->with('teeth')
->first();
if ($chart) {
return $chart;
}
return DB::transaction(function () use ($organization, $patient, $ownerRef) {
$chart = DentalChart::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $patient->id,
'notation' => 'fdi',
'charted_at' => now(),
'charted_by' => $ownerRef,
]);
foreach (DentalCatalog::adultToothCodes() as $code) {
DentalTooth::create([
'dental_chart_id' => $chart->id,
'tooth_code' => $code,
'status' => DentalTooth::STATUS_PRESENT,
'surfaces' => [],
'conditions' => [],
]);
}
return $chart->fresh('teeth');
});
}
/**
* @param array{status?: string, surfaces?: list<string>, conditions?: list<string>, notes?: ?string} $data
*/
public function updateTooth(
DentalChart $chart,
string $toothCode,
array $data,
string $ownerRef,
?string $actorRef = null,
?Visit $visit = null,
): DentalTooth {
if (! DentalCatalog::isValidToothCode($toothCode)) {
throw new \InvalidArgumentException("Invalid FDI tooth code [{$toothCode}].");
}
$tooth = $chart->teeth()->where('tooth_code', $toothCode)->first();
if (! $tooth) {
$tooth = DentalTooth::create([
'dental_chart_id' => $chart->id,
'tooth_code' => $toothCode,
'status' => DentalTooth::STATUS_PRESENT,
'surfaces' => [],
'conditions' => [],
]);
}
$before = [
'status' => $tooth->status,
'surfaces' => $tooth->surfaces ?? [],
'conditions' => $tooth->conditions ?? [],
'notes' => $tooth->notes,
];
$status = $data['status'] ?? $tooth->status;
if (! array_key_exists($status, DentalCatalog::toothStatuses())) {
throw new \InvalidArgumentException("Invalid tooth status [{$status}].");
}
$surfaces = array_values(array_intersect(
array_map('strtoupper', $data['surfaces'] ?? ($tooth->surfaces ?? [])),
DentalCatalog::surfaces(),
));
$allowedConditions = array_keys(DentalCatalog::conditions());
$conditions = array_values(array_intersect(
$data['conditions'] ?? ($tooth->conditions ?? []),
$allowedConditions,
));
if (in_array($status, [DentalTooth::STATUS_MISSING, DentalTooth::STATUS_EXTRACTED], true)) {
$surfaces = [];
if (! in_array('missing', $conditions, true)) {
$conditions[] = 'missing';
}
}
$tooth->update([
'status' => $status,
'surfaces' => $surfaces,
'conditions' => $conditions,
'notes' => $data['notes'] ?? $tooth->notes,
]);
$chart->update([
'charted_at' => now(),
'charted_by' => $actorRef ?? $ownerRef,
]);
$after = [
'status' => $tooth->status,
'surfaces' => $tooth->surfaces ?? [],
'conditions' => $tooth->conditions ?? [],
'notes' => $tooth->notes,
];
DentalChartEvent::create([
'owner_ref' => $ownerRef,
'organization_id' => $chart->organization_id,
'dental_chart_id' => $chart->id,
'patient_id' => $chart->patient_id,
'visit_id' => $visit?->id,
'tooth_code' => $toothCode,
'event_type' => 'tooth_updated',
'payload' => ['before' => $before, 'after' => $after],
'recorded_by' => $actorRef ?? $ownerRef,
'recorded_at' => now(),
]);
AuditLogger::record(
$ownerRef,
'dental.chart.tooth_updated',
$chart->organization_id,
$actorRef,
DentalTooth::class,
$tooth->id,
['tooth_code' => $toothCode],
);
return $tooth->fresh();
}
/**
* @return array<string, array{status: string, surfaces: list<string>, conditions: list<string>, notes: ?string}>
*/
public function teethMap(DentalChart $chart): array
{
$map = [];
foreach ($chart->teeth as $tooth) {
$map[$tooth->tooth_code] = [
'status' => $tooth->status,
'surfaces' => $tooth->surfaces ?? [],
'conditions' => $tooth->conditions ?? [],
'notes' => $tooth->notes,
];
}
return $map;
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalImage;
use App\Models\Organization;
use App\Models\PatientAttachment;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class DentalImagingService
{
/**
* @param array{modality: string, tooth_codes?: list<string>, caption?: ?string, bill_fee?: bool} $meta
*/
public function upload(
Organization $organization,
Visit $visit,
UploadedFile $file,
array $meta,
string $ownerRef,
?string $actorRef = null,
): DentalImage {
$visit->loadMissing('patient');
$modality = (string) ($meta['modality'] ?? '');
if (! array_key_exists($modality, DentalCatalog::modalities())) {
throw new \InvalidArgumentException("Invalid imaging modality [{$modality}].");
}
$toothCodes = array_values(array_filter(
$meta['tooth_codes'] ?? [],
fn ($c) => DentalCatalog::isValidToothCode((string) $c),
));
$path = $file->store("care/patients/{$visit->patient_id}/dental", 'public');
$attachment = PatientAttachment::create([
'owner_ref' => $ownerRef,
'patient_id' => $visit->patient_id,
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'document_type' => 'dental_imaging_'.$modality,
'uploaded_by' => $actorRef ?? $ownerRef,
]);
$image = DentalImage::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $visit->patient_id,
'visit_id' => $visit->id,
'patient_attachment_id' => $attachment->id,
'modality' => $modality,
'tooth_codes' => $toothCodes,
'caption' => $meta['caption'] ?? null,
'uploaded_by' => $actorRef ?? $ownerRef,
'captured_at' => now(),
]);
AuditLogger::record(
$ownerRef,
'dental.image.uploaded',
$organization->id,
$actorRef,
DentalImage::class,
$image->id,
);
return $image->fresh('attachment');
}
public function url(DentalImage $image): ?string
{
$path = $image->attachment?->file_path;
if (! $path) {
return null;
}
return Storage::disk('public')->url($path);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalPlanItem;
use App\Models\DentalProcedure;
use App\Models\DentalTreatmentPlan;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
use App\Services\Care\BillService;
use App\Services\Care\SpecialtyShellService;
class DentalProcedureService
{
public function __construct(
protected SpecialtyShellService $shell,
protected BillService $bills,
protected DentalTreatmentPlanService $plans,
) {}
/**
* @param array{
* procedure_code: string,
* tooth_code?: ?string,
* surfaces?: list<string>,
* anesthesia?: ?string,
* notes?: ?string,
* plan_item_id?: ?int,
* bill?: bool
* } $data
*/
public function complete(
Organization $organization,
Visit $visit,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalProcedure {
$visit->loadMissing('patient');
$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(),
));
$planItem = null;
if (! empty($data['plan_item_id'])) {
$planItem = DentalPlanItem::query()->find((int) $data['plan_item_id']);
if ($planItem && $planItem->plan?->patient_id !== $visit->patient_id) {
throw new \InvalidArgumentException('Plan item does not belong to this patient.');
}
}
$label = $service['label'];
if ($tooth) {
$label .= ' · tooth '.$tooth;
if ($surfaces !== []) {
$label .= ' ('.implode('', $surfaces).')';
}
}
$amount = (int) ($service['amount_minor'] ?? 0);
$procedure = DentalProcedure::create([
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'patient_id' => $visit->patient_id,
'visit_id' => $visit->id,
'plan_item_id' => $planItem?->id,
'tooth_code' => $tooth,
'surfaces' => $surfaces,
'procedure_code' => $code,
'label' => $label,
'anesthesia' => $data['anesthesia'] ?? null,
'status' => DentalProcedure::STATUS_COMPLETED,
'amount_minor' => $amount,
'bill_line_item_id' => null,
'provider_ref' => $actorRef ?? $ownerRef,
'notes' => $data['notes'] ?? null,
'performed_at' => now(),
]);
if ($data['bill'] ?? true) {
$bill = $this->bills->generateFromVisit($visit, $ownerRef, $actorRef);
$bill = $this->bills->addManualLineItem($bill, $ownerRef, [
'type' => $service['type'] ?? 'procedure',
'description' => $label,
'quantity' => 1,
'unit_price_minor' => $amount,
'source_type' => 'dental_procedure',
'source_id' => $procedure->id,
], $actorRef);
$billLineId = $bill->lineItems()->where('source_type', 'dental_procedure')->where('source_id', $procedure->id)->value('id');
$procedure->update(['bill_line_item_id' => $billLineId]);
if ($planItem && $billLineId) {
$planItem->update(['bill_line_item_id' => $billLineId]);
}
}
if ($planItem) {
$this->plans->markItemDone($planItem);
$plan = $planItem->plan;
if ($plan && $plan->status === DentalTreatmentPlan::STATUS_ACCEPTED) {
$plan->update(['status' => DentalTreatmentPlan::STATUS_IN_PROGRESS]);
}
}
AuditLogger::record(
$ownerRef,
'dental.procedure.completed',
$organization->id,
$actorRef,
DentalProcedure::class,
$procedure->id,
);
return $procedure->fresh();
}
}
@@ -0,0 +1,149 @@
<?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;
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();
}
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 {
$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;
}
public function accept(DentalTreatmentPlan $plan, string $ownerRef, ?string $actorRef = null): DentalTreatmentPlan
{
$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 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]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Services\Care\Dentistry;
use App\Models\DentalVisitNote;
use App\Models\Organization;
use App\Models\Visit;
use App\Services\Care\AuditLogger;
class DentalVisitNoteService
{
/**
* @param array{chief_complaint?: ?string, soft_tissue?: ?string, occlusion?: ?string, notes?: ?string} $data
*/
public function upsert(
Organization $organization,
Visit $visit,
array $data,
string $ownerRef,
?string $actorRef = null,
): DentalVisitNote {
$visit->loadMissing('patient');
$note = DentalVisitNote::owned($ownerRef)
->where('visit_id', $visit->id)
->first();
$attributes = [
'owner_ref' => $ownerRef,
'organization_id' => $organization->id,
'visit_id' => $visit->id,
'patient_id' => $visit->patient_id,
'chief_complaint' => $data['chief_complaint'] ?? null,
'soft_tissue' => $data['soft_tissue'] ?? null,
'occlusion' => $data['occlusion'] ?? null,
'notes' => $data['notes'] ?? null,
'recorded_by' => $actorRef ?? $ownerRef,
'recorded_at' => now(),
];
if ($note) {
$note->update($attributes);
} else {
$note = DentalVisitNote::create($attributes);
}
AuditLogger::record($ownerRef, 'dental.notes.saved', $organization->id, $actorRef, DentalVisitNote::class, $note->id);
return $note->fresh();
}
}
@@ -163,6 +163,10 @@ class SpecialtyModuleService
return false;
}
if (app(CarePermissions::class)->isAdmin($member)) {
return true;
}
$roles = $definition['roles'] ?? [];
if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) {
return false;
+25 -4
View File
@@ -72,12 +72,20 @@ class SpecialtyShellService
*/
public function provisionedServices(Organization $organization, string $moduleKey): array
{
$catalog = collect($this->catalogServices($moduleKey))->keyBy(fn ($s) => (string) ($s['code'] ?? ''));
$stored = data_get($organization->settings, "specialty_module_provisioning.{$moduleKey}.services");
if (is_array($stored) && $stored !== []) {
return array_values($stored);
if (is_array($stored)) {
foreach ($stored as $service) {
$code = (string) ($service['code'] ?? '');
if ($code === '') {
continue;
}
$prior = $catalog->get($code, []);
$catalog->put($code, array_merge(is_array($prior) ? $prior : [], $service));
}
}
return $this->catalogServices($moduleKey);
return $catalog->filter(fn ($s, $code) => $code !== '')->values()->all();
}
/**
@@ -271,7 +279,20 @@ class SpecialtyShellService
$clinicalOpen = match ($moduleKey) {
'emergency' => $clinical->countOpenByType($organization, 'emergency', 'triage', $branchScope),
'blood_bank' => $clinical->countOpenByType($organization, 'blood_bank', 'blood_request', $branchScope),
'dentistry' => $clinical->countOpenByType($organization, 'dentistry', 'odontogram', $branchScope),
'dentistry' => \App\Models\DentalPlanItem::query()
->whereHas('plan', function ($q) use ($organization, $ownerRef) {
$q->owned($ownerRef)
->where('organization_id', $organization->id)
->whereIn('status', [
\App\Models\DentalTreatmentPlan::STATUS_ACCEPTED,
\App\Models\DentalTreatmentPlan::STATUS_IN_PROGRESS,
]);
})
->whereIn('status', [
\App\Models\DentalPlanItem::STATUS_ACCEPTED,
\App\Models\DentalPlanItem::STATUS_PROPOSED,
])
->count(),
default => 0,
};