Files
zyt/server/app/common/service/followupaudio/FollowupAudioApply.php
T

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;
}
}