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, conditions?: list, 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, conditions: list, 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; } }