363 lines
23 KiB
PHP
363 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\followupaudio;
|
|
|
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
|
use DomainException;
|
|
use think\facade\Db;
|
|
|
|
/** Transactional human adoption. The model can propose values; it cannot choose record IDs or overwrite snapshots. */
|
|
final class FollowupAudioApply
|
|
{
|
|
private const TABLES = ['diagnosis' => '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,
|
|
'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;
|
|
}
|
|
}
|