'tcm_diagnosis', 'blood' => 'tcm_blood_record', 'diet' => 'patient_diet_record', 'exercise' => 'patient_exercise_record', 'tracking_note' => 'tracking_note']; public static function apply(int $taskId, int $version, array $items, int $actor, array $info): array { FollowupAudioGate::assertMayApply(); // Before DB, root checks and the already-applied idempotent path. FollowupAudioStore::assertEnabled(); // Scope helpers perform ordinary reads. READ COMMITTED avoids a pre-lock actor/scope snapshot, // and SET TRANSACTION changes this one transaction only (never the connection/session default). $connection = Db::connect(); $pdo = $connection->getPdo(); if ($pdo && $pdo->inTransaction()) { throw new DomainException('FOLLOWUP_AUDIO_NESTED_APPLY_FORBIDDEN'); } $connection->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); $result = Db::transaction(static function () use ($taskId, $version, $items, $actor, $info): array { $task = FollowupAudioStore::lockTask($taskId); FollowupAudioGate::assertMayApply($task); FollowupAudioStore::assertEnabled((string) $task['model_key']); $info = FollowupAudioAccess::actor($actor); FollowupAudioAccess::task($taskId, $actor, $info); if ($task['status'] === 'applied') { return ['id' => $taskId, 'status' => 'applied', 'applied_items' => FollowupAudioStore::open($taskId, 'applied', $task['applied_cipher'])['items']]; } FollowupAudioStore::assertReview($task, $version); $diagnosis = self::diagnosis((int) $task['diagnosis_id'], true); self::assertPatient($task, $diagnosis); // Re-check scope after the diagnosis lock; a concurrent reassignment cannot authorize a stale request. FollowupAudioAccess::diagnosis((int) $task['diagnosis_id'], $actor, $info); $review = FollowupAudioStore::open($taskId, 'review', $task['review_cipher']); $source = FollowupAudioStore::open($taskId, 'extraction', $task['extraction_cipher']); $stereo = FollowupAudioStore::hasStereo($source); $channelRoles = FollowupAudioStore::channelRoles($source, $review); if ($stereo && $channelRoles === 'unconfirmed') { throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_REQUIRED'); } $roleAnnotation = $stereo ? ['channel_roles' => $channelRoles, 'channel_roles_annotation' => $review['channel_roles_annotation'] ?? []] : []; $merged = self::mergeItems($review['items'], $items, $source['items']); $selected = array_values(array_filter($merged, static fn (array $item): bool => $item['selected'])); if ($selected === []) { throw new DomainException('FOLLOWUP_AUDIO_NOTHING_SELECTED'); } $daily = array_filter($selected, static fn (array $item): bool => $item['kind'] !== 'diagnosis'); if ($daily !== []) { FollowupAudioAccess::diagnosis((int) $task['diagnosis_id'], $actor, $info, true); } // Lock/check EVERY selected target before writing ANY target. No partially adopted batches. $refreshed = self::refresh($task, $merged, $diagnosis, false); $stale = false; foreach ($merged as $index => $item) { if ($item['selected'] && !hash_equals($item['expected_hash'], $refreshed[$index]['expected_hash'])) { $stale = true; } } if ($stale) { foreach ($refreshed as &$item) { $item['selected'] = false; $item['needs_review'] = true; } unset($item); FollowupAudioStore::writeReview($task, $refreshed); return ['stale' => true]; // Commit the refreshed review, then report a conflict outside the transaction. } $sourceById = array_column($source['items'], null, 'id'); $touched = []; $identityValues = []; foreach ($selected as $item) { if ($item['needs_review'] || $item['evidence'] === []) { throw new DomainException('FOLLOWUP_AUDIO_REVIEW_REQUIRED'); } foreach ($item['evidence'] as $evidence) { if (!str_contains($source['transcript'], $evidence['text'])) { throw new DomainException('FOLLOWUP_AUDIO_EVIDENCE_UNVERIFIED'); } } FollowupAudioFields::validateValues($item['kind'], $item['values']); if ($item['kind'] !== 'diagnosis') { if ($item['record_date'] === null || $item['record_date'] > substr($task['recorded_at'], 0, 10)) { throw new DomainException('FOLLOWUP_AUDIO_EVENT_DATE_REQUIRED'); } FollowupAudioPolicy::strictDate($item['record_date']); } if ($item['kind'] === 'diagnosis') { foreach ($item['values'] as $key => $value) { if (isset($touched['diagnosis:' . $key])) { throw new DomainException('FOLLOWUP_AUDIO_MULTIPLE_VALUES_FOR_FIELD'); } $touched['diagnosis:' . $key] = true; if (in_array($key, FollowupAudioFields::IDENTITY_KEYS, true) && !self::equal($diagnosis[$key] ?? null, $value)) { $identityValues[$key] = $value; } } } elseif ($item['kind'] !== 'tracking_note' && $item['target_id'] !== null) { $key = $item['kind'] . ':' . $item['target_id']; if (isset($touched[$key])) { throw new DomainException('FOLLOWUP_AUDIO_MULTIPLE_EVENTS_FOR_TARGET'); } $touched[$key] = true; } } if ($identityValues !== []) { self::assertIdentityMutable($diagnosis, $identityValues, $actor, $info); } $applied = []; foreach ($selected as $item) { FollowupAudioGate::assertMayApply($task); $kind = $item['kind']; $table = self::TABLES[$kind]; $targetId = $kind === 'diagnosis' ? (int) $diagnosis['id'] : (int) ($item['target_id'] ?? 0); $before = $targetId > 0 ? Db::name($table)->where('id', $targetId)->lock(true)->find() : null; $values = FollowupAudioFields::toDatabase($kind, $item['values']); $now = time(); if ($kind === 'diagnosis') { Db::name($table)->where('id', $targetId)->update($values + ['update_time' => $now]); $action = 'update'; } elseif ($kind === 'tracking_note') { $targetId = TrackingNoteLogic::appendForDate((int) $task['diagnosis_id'], $item['record_date'], $values['content'], $actor); $action = 'append'; } else { $data = $values + ['record_date' => FollowupAudioPolicy::dayTimestamp($item['record_date']), 'update_time' => $now]; if ($kind === 'blood') { $data += ['record_time' => $item['record_time'] ?? '', 'record_time_estimated' => $item['time_estimated'] ? 1 : 0, 'record_time_period' => $item['time_period'] ?? '', 'record_time_text' => $item['time_text'], 'followup_audio_task_id' => $taskId]; } if ($targetId > 0) { // Scope and patient/date checks occurred under the locks in refresh(). Db::name($table)->where('id', $targetId)->update($data); $action = 'update'; } else { $data += ['diagnosis_id' => (int) $task['diagnosis_id'], 'patient_id' => (int) $task['patient_id'], 'create_time' => $now]; if ($kind === 'blood') { $data['source'] = 0; } $targetId = (int) Db::name($table)->insertGetId($data); $action = 'insert'; } } $after = Db::name($table)->where('id', $targetId)->find(); $record = ['item_id' => $item['id'], 'kind' => $kind, 'target_id' => $targetId, 'record_id' => $targetId, 'record_date' => $item['record_date'], 'record_time' => $item['record_time'], 'time_period' => $item['time_period'], 'time_estimated' => $item['time_estimated'], 'values' => $item['values']]; // Full transcript is NOT copied here. Only adopted item's necessary evidence survives 90-day cleanup. $evidence = array_intersect_key($sourceById[$item['id']], array_flip(['kind', 'values', 'record_date', 'record_time', 'date_text', 'time_text', 'time_period', 'time_estimated', 'evidence'])) + $roleAnnotation; Db::name('followup_audio_audit')->insert([ 'task_id' => $taskId, 'item_id' => $item['id'], 'diagnosis_id' => (int) $task['diagnosis_id'], 'actor_id' => $actor, 'kind' => $kind, 'table_name' => $table, 'record_id' => $targetId, 'action' => $action, 'source_cipher' => FollowupAudioStore::seal($taskId, 'audit-source:' . $item['id'], $evidence), 'before_cipher' => FollowupAudioStore::seal($taskId, 'audit-before:' . $item['id'], $before ? self::auditValues($kind, $before, array_keys($item['values'])) : []), 'after_cipher' => FollowupAudioStore::seal($taskId, 'audit-after:' . $item['id'], self::auditValues($kind, $after, array_keys($item['values'])) + ['adopted' => $record]), 'created_at' => $now, ]); $applied[] = $record; } Db::name('followup_audio_task')->where('id', $taskId)->update([ 'status' => 'applied', 'stage' => 'applied', 'version' => (int) $task['version'] + 1, // The completed detail must show exactly the edits/selection confirmed in this transaction. 'review_cipher' => FollowupAudioStore::seal($taskId, 'review', array_replace($review, ['items' => $merged])), 'applied_cipher' => FollowupAudioStore::seal($taskId, 'applied', ['items' => $applied] + $roleAnnotation), 'applied_at' => time(), 'updated_at' => time(), ]); return ['id' => $taskId, 'status' => 'applied', 'applied_items' => $applied]; }); if (!empty($result['stale'])) { throw new DomainException('FOLLOWUP_AUDIO_STALE_REVIEW'); } return $result; } /** Merge only editable fields; all original evidence and snapshot preconditions remain server-owned. */ public static function mergeItems(array $stored, array $submitted, array $sources): array { if (count($submitted) > 500) { throw new DomainException('FOLLOWUP_AUDIO_ITEMS_INVALID'); } $sourceById = array_column($sources, null, 'id'); $positions = array_flip(array_column($stored, 'id')); $seen = []; $editable = ['values', 'record_date', 'record_time', 'time_period', 'selected', 'target_id', 'needs_review']; foreach ($submitted as $changes) { $id = is_array($changes) ? ($changes['id'] ?? '') : ''; if (!is_string($id) || !array_key_exists($id, $positions) || isset($seen[$id]) || !isset($sourceById[$id])) { throw new DomainException('FOLLOWUP_AUDIO_ITEM_INVALID'); } $seen[$id] = true; $index = $positions[$id]; $item = $stored[$index]; foreach ($changes as $key => $value) { if (in_array($key, ['id', 'current_values', 'candidates'], true) || in_array($key, $editable, true)) { continue; } if (!array_key_exists($key, $item) || FollowupAudioPolicy::canonical([$value]) !== FollowupAudioPolicy::canonical([$item[$key]])) { throw new DomainException('FOLLOWUP_AUDIO_IMMUTABLE_FIELD'); } } if (array_key_exists('values', $changes)) { if (!is_array($changes['values'])) { throw new DomainException('FOLLOWUP_AUDIO_VALUES_INVALID'); } // A human may correct proposed fields, but this endpoint cannot invent an unrelated write without evidence. if (array_diff(array_keys($changes['values']), array_keys($sourceById[$id]['values'])) !== []) { throw new DomainException('FOLLOWUP_AUDIO_UNPROPOSED_FIELD'); } $item['values'] = FollowupAudioFields::validateValues($item['kind'], $changes['values']); } if (array_key_exists('record_date', $changes)) { $item['record_date'] = $changes['record_date'] === null || $changes['record_date'] === '' ? null : FollowupAudioPolicy::strictDate($changes['record_date']); } if (array_key_exists('record_time', $changes)) { $time = FollowupAudioPolicy::strictTime($changes['record_time']); if ($time !== $item['record_time']) { $item['time_estimated'] = $time === null; } $item['record_time'] = $time; } if (array_key_exists('time_period', $changes)) { $period = FollowupAudioPolicy::period($changes['time_period']); if ($changes['time_period'] !== null && $changes['time_period'] !== '' && $period === null) { throw new DomainException('FOLLOWUP_AUDIO_PERIOD_INVALID'); } if ($period !== $item['time_period'] && $item['time_estimated']) { $item['record_time'] = FollowupAudioPolicy::estimatedTime($period); } $item['time_period'] = $period; } foreach (['selected', 'needs_review'] as $key) { if (array_key_exists($key, $changes)) { if (!is_bool($changes[$key])) { throw new DomainException('FOLLOWUP_AUDIO_BOOLEAN_INVALID'); } $item[$key] = $changes[$key]; } } if (array_key_exists('target_id', $changes)) { if ($changes['target_id'] !== null && (!is_int($changes['target_id']) || $changes['target_id'] <= 0)) { throw new DomainException('FOLLOWUP_AUDIO_TARGET_INVALID'); } $item['target_id'] = $changes['target_id']; } // Independently reattach immutable source evidence, even when omitted in a partial draft request. $item['evidence'] = $sourceById[$id]['evidence']; $item['time_text'] = $sourceById[$id]['time_text']; $item['date_text'] = $sourceById[$id]['date_text']; $stored[$index] = $item; } return $stored; } public static function diagnosis(int $id, bool $lock = false): array { $query = Db::name('tcm_diagnosis')->where('id', $id)->whereNull('delete_time')->where('status', 1); if ($lock) { $query->lock(true); } $row = $query->find(); if (!$row) { throw new DomainException('FOLLOWUP_AUDIO_DIAGNOSIS_UNAVAILABLE'); } return $row; } public static function assertPatient(array $task, array $diagnosis): void { if ((int) $task['diagnosis_id'] !== (int) $diagnosis['id'] || (int) $task['patient_id'] !== (int) $diagnosis['patient_id']) { throw new DomainException('FOLLOWUP_AUDIO_PATIENT_CHANGED'); } } /** Capture current rows under the enclosing diagnosis lock. Snapshots include all rows at a daily event's date. */ public static function refresh(array $task, array $items, array $diagnosis, bool $initial): array { foreach ($items as &$item) { $kind = $item['kind']; $current = []; $candidates = []; if ($kind === 'diagnosis') { if ($item['target_id'] !== null && (int) $item['target_id'] !== (int) $diagnosis['id']) { throw new DomainException('FOLLOWUP_AUDIO_TARGET_INVALID'); } $item['target_id'] = (int) $diagnosis['id']; $current = FollowupAudioFields::fromDatabase($kind, $diagnosis); $snapshot = ['kind' => $kind, 'id' => (int) $diagnosis['id'], 'patient_id' => (int) $diagnosis['patient_id'], 'values' => $current]; } else { $rows = []; if ($item['record_date'] !== null) { $query = Db::name(self::TABLES[$kind])->where('diagnosis_id', (int) $task['diagnosis_id']); $query->where($kind === 'tracking_note' ? 'note_date' : 'record_date', $kind === 'tracking_note' ? $item['record_date'] : FollowupAudioPolicy::dayTimestamp($item['record_date'])); // Include deleted notes in the fingerprint: unique (diagnosis,date) must never resurrect silently. if ($kind !== 'tracking_note') { $query->whereNull('delete_time'); } $rows = $query->order('id', 'asc')->limit(1001)->lock(true)->select()->toArray(); if (count($rows) > 1000) { throw new DomainException('FOLLOWUP_AUDIO_TOO_MANY_DAY_RECORDS'); } } if ($kind === 'tracking_note' && $item['target_id'] === null && count($rows) === 1) { $item['target_id'] = (int) $rows[0]['id']; } $found = $item['target_id'] === null; foreach ($rows as $row) { if ($kind !== 'tracking_note' && (int) $row['patient_id'] !== (int) $task['patient_id']) { throw new DomainException('FOLLOWUP_AUDIO_PATIENT_CHANGED'); } $candidate = ['id' => (int) $row['id'], 'values' => FollowupAudioFields::fromDatabase($kind, $row), 'record_time' => $row['record_time'] ?? null, 'time_estimated' => (bool) ($row['record_time_estimated'] ?? false), 'time_period' => $row['record_time_period'] ?? null, 'delete_time' => $row['delete_time'] ?? null]; $candidates[] = $candidate; if ($item['target_id'] !== null && (int) $row['id'] === (int) $item['target_id']) { if (!empty($row['delete_time'])) { throw new DomainException('FOLLOWUP_AUDIO_TARGET_DELETED'); } $found = true; $current = $candidate['values']; } } if (!$found) { throw new DomainException('FOLLOWUP_AUDIO_TARGET_INVALID'); } $snapshot = ['kind' => $kind, 'diagnosis_id' => (int) $task['diagnosis_id'], 'patient_id' => (int) $task['patient_id'], 'date' => $item['record_date'], 'target_id' => $item['target_id'], 'rows' => $candidates]; } $conflict = false; $allEmpty = true; foreach ($item['values'] as $key => $value) { $old = $current[$key] ?? null; if (!self::blank($old)) { $allEmpty = false; if (!self::equal($old, $value)) { $conflict = true; } } } $item['current_values'] = array_intersect_key($current, $item['values']); $item['expected_hash'] = FollowupAudioPolicy::hash($snapshot); $item['conflict'] = $conflict; $item['possible_duplicate'] = $kind !== 'diagnosis' && $candidates !== []; $item['candidates'] = $candidates; if ($initial) { $sensitive = $kind === 'diagnosis' && array_intersect(array_keys($item['values']), FollowupAudioFields::IDENTITY_KEYS) !== []; $item['needs_review'] = $item['needs_review'] || $conflict || $item['possible_duplicate'] || $sensitive || FollowupAudioFields::requiresClinicalReview($kind, $item['values']); $item['selected'] = !$item['needs_review'] && $allEmpty && $item['evidence'] !== []; } } unset($item); return $items; } private static function assertIdentityMutable(array $diagnosis, array $values, int $actor, array $info): void { $id = (int) $diagnosis['id']; foreach (['phone', 'id_card'] as $field) { if (isset($values[$field]) && ($field === 'phone' || !self::blank($diagnosis[$field] ?? null)) && !FollowupAudioAccess::allowed($actor, $info, 'tcm.diagnosis/phonePlain')) { throw new DomainException('FOLLOWUP_AUDIO_IDENTITY_PLAIN_PERMISSION_REQUIRED'); } } // Serialize competing feature identity corrections (including absent duplicate identity rows). if (!Db::name('followup_audio_mutex')->where('id', 2)->lock(true)->find()) { throw new DomainException('FOLLOWUP_AUDIO_MIGRATION_REQUIRED'); } // Lock existing orders and re-check the legacy latest-order rule under the diagnosis lock. $orders = Db::name('tcm_prescription_order')->where('diagnosis_id', $id)->whereNull('delete_time') ->order('create_time', 'desc')->order('id', 'desc')->lock(true)->select()->toArray(); $info['admin_id'] = $actor; if (!DiagnosisLogic::canEditPatientBasicInfo($id, $info) || ($orders !== [] && (int) $orders[0]['fulfillment_status'] !== 3 && !DiagnosisLogic::hasEditPatientBasicPermission($info))) { throw new DomainException('FOLLOWUP_AUDIO_PATIENT_BASIC_LOCKED'); } foreach (['phone', 'id_card'] as $key) { if (!isset($values[$key])) { continue; } if (Db::name('tcm_diagnosis')->where($key, $values[$key])->where('id', '<>', $id)->whereNull('delete_time')->lock(true)->find()) { throw new DomainException('FOLLOWUP_AUDIO_IDENTITY_DUPLICATE'); } } } private static function auditValues(string $kind, array $row, array $keys): array { return ['id' => (int) $row['id'], 'values' => array_intersect_key(FollowupAudioFields::fromDatabase($kind, $row), array_flip($keys)), 'record_metadata' => array_intersect_key($row, array_flip(['diagnosis_id', 'patient_id', 'record_date', 'note_date', 'record_time', 'record_time_estimated', 'record_time_period', 'record_time_text', 'followup_audio_task_id', 'source']))]; } private static function blank($value): bool { return $value === null || $value === '' || $value === []; } private static function equal($a, $b): bool { if (is_array($a) && is_array($b)) { $a = array_map('strval', $a); $b = array_map('strval', $b); sort($a); sort($b); return $a === $b; } return !is_array($a) && !is_array($b) && (string) $a === (string) $b; } }