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,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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user