feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio;
use DomainException;
use think\facade\Db;
/** The sole write allow-list. Absent values are never interpreted as normal/negative. */
final class FollowupAudioFields
{
public const IDENTITY_KEYS = ['patient_name', 'id_card', 'phone', 'gender', 'age'];
private static function definitions(): array
{
$text = static fn (string $label, int $max = 2000): array => ['label' => $label, 'type' => 'text', 'max' => $max];
$number = static fn (string $label, float $min, float $max, bool $integer = false): array =>
['label' => $label, 'type' => 'number', 'min' => $min, 'max' => $max, 'integer' => $integer];
$dict = static fn (string $label, string $dictionary, bool $multi = false): array =>
['label' => $label, 'type' => $multi ? 'multiselect' : 'select', 'dictionary' => $dictionary,
'max' => $dictionary === 'past_history' ? 10000 : ($dictionary === 'diabetes_type' ? 255 : ($multi && $dictionary !== 'appetite' ? 100 : 50))];
$select = static function (string $label, array $options): array {
$items = [];
foreach ($options as $value => $name) { $items[] = ['label' => $name, 'value' => $value]; }
return ['label' => $label, 'type' => 'select', 'options' => $items];
};
$blood = [
'fasting_blood_sugar' => $number('空腹血糖(mmol/L)', 0.01, 999.99),
'postprandial_blood_sugar' => $number('餐后血糖(mmol/L)', 0.01, 999.99),
'other_blood_sugar' => $number('其他血糖(mmol/L)', 0.01, 999.99),
'systolic_pressure' => $number('收缩压(mmHg)', 1, 999, true),
'diastolic_pressure' => $number('舒张压(mmHg)', 1, 999, true),
'western_medicine' => $text('西药', 255), 'insulin' => $text('胰岛素', 255),
'remark' => $text('备注', 255),
];
$diagnosis = [
'patient_name' => $text('姓名', 50), 'phone' => $text('手机号', 11), 'id_card' => $text('身份证号', 18),
'gender' => $select('性别', [0 => '女', 1 => '男']), 'age' => $number('年龄', 0, 150, true),
'marital_status' => $select('婚姻状态', [0 => '未婚', 1 => '已婚', 2 => '离异']),
'height' => $number('身高(cm)', 1, 300, true), 'weight' => $number('体重(kg)', 0.1, 999.99),
'region' => $text('地区', 100), 'fasting_blood_sugar' => $blood['fasting_blood_sugar'],
'systolic_pressure' => $blood['systolic_pressure'], 'diastolic_pressure' => $blood['diastolic_pressure'],
'diagnosis_type' => $dict('诊断类型', 'diagnosis_type'),
'current_medications' => $text('在用药物'), 'diagnosis_date' => ['label' => '当地医院诊断日期', 'type' => 'date'],
'diabetes_discovery_year' => $text('发现糖尿病患病史', 50),
// Match the existing diagnosis editor's stored string values, not the unrelated diabetes_type dictionary.
'local_hospital_diagnosis' => ['label' => '当地医院诊断结果', 'type' => 'multiselect', 'max' => 255,
'options' => array_map(static fn (string $value): array => ['label' => $value, 'value' => $value],
['糖尿病', '消渴病', '糖尿病前期'])],
'local_hospital_name' => $text('当地就诊医院名称', 255),
'appetite' => $dict('口腔感觉', 'appetite', true),
'water_intake' => $dict('每日饮水量', 'water_intake'), 'weight_change' => $dict('体重变化', 'weight_change'),
'fatty_liver_degree' => $dict('脂肪肝程度', 'fatty_liver_degree'),
'symptoms' => $text('现病史补充', 10000), 'past_history' => $dict('既往史', 'past_history', true),
'remark' => $text('病史补充', 10000),
];
foreach (['diet_condition' => '饮食情况', 'body_feeling' => '肢体感觉', 'sleep_condition' => '睡眠情况',
'eye_condition' => '眼睛情况', 'head_feeling' => '头部感觉', 'sweat_condition' => '出汗情况',
'skin_condition' => '皮肤情况', 'urine_condition' => '小便情况', 'stool_condition' => '大便情况',
'kidney_condition' => '腰肾情况'] as $key => $label) {
$diagnosis[$key] = $dict($label, $key, true);
}
foreach (['trauma_history' => '外伤史', 'surgery_history' => '手术史', 'allergy_history' => '过敏史',
'family_history' => '家族病史', 'pregnancy_history' => '妊娠哺乳史'] as $key => $label) {
$diagnosis[$key] = $select($label, [0 => '无', 1 => '有']);
}
return [
'diagnosis' => $diagnosis, 'blood' => $blood,
'diet' => ['breakfast' => $text('早餐'), 'lunch' => $text('午餐'), 'dinner' => $text('晚餐'), 'note' => $text('备注')],
'exercise' => ['exercise_type' => $text('运动方式', 100), 'duration' => $number('时长(分钟)', 1, 1440, true),
'intensity' => $select('运动强度', [1 => '低强度', 2 => '中强度', 3 => '高强度']), 'note' => $text('备注')],
'tracking_note' => ['content' => $text('跟踪备注', 10000)],
];
}
public static function catalog(): array
{
$catalog = [];
foreach (self::definitions() as $kind => $definitions) {
$catalog[$kind] = [];
foreach ($definitions as $key => $definition) {
$field = ['key' => $key, 'label' => $definition['label'], 'type' => $definition['type']];
if (isset($definition['dictionary'])) {
$field['options'] = self::dictionary($definition['dictionary']);
} elseif (isset($definition['options'])) {
$field['options'] = $definition['options'];
}
$catalog[$kind][] = $field;
}
}
return $catalog;
}
/** Clinical assertions always require an explicit human review, even when the destination is blank. */
public static function requiresClinicalReview(string $kind, array $values): bool
{
if ($kind === 'diagnosis') {
return array_diff(array_keys($values), ['height', 'weight', 'fasting_blood_sugar',
'systolic_pressure', 'diastolic_pressure']) !== [];
}
return $kind === 'blood' && array_intersect(array_keys($values), ['western_medicine', 'insulin', 'remark']) !== [];
}
/** Extraction aliases are not physical diet columns. */
public static function databaseKey(string $kind, string $key): string
{
return $kind === 'diet' && in_array($key, ['breakfast', 'lunch', 'dinner'], true) ? $key . '_foods' : $key;
}
public static function diagnosisKeys(): array { return array_keys(self::definitions()['diagnosis']); }
public static function keys(string $kind): array
{
$definitions = self::definitions();
if (!isset($definitions[$kind])) { throw new DomainException('FOLLOWUP_AUDIO_KIND_INVALID'); }
return array_keys($definitions[$kind]);
}
public static function validateValues(string $kind, array $values): array
{
$definitions = self::definitions();
if (!isset($definitions[$kind]) || $values === [] || count($values) > 64) {
throw new DomainException('FOLLOWUP_AUDIO_VALUES_INVALID');
}
$result = [];
foreach ($values as $key => $value) {
if (!is_string($key) || !isset($definitions[$kind][$key]) || $value === null || is_bool($value)) {
throw new DomainException('FOLLOWUP_AUDIO_FIELD_INVALID');
}
$definition = $definitions[$kind][$key];
if ($definition['type'] === 'number') {
if ((!is_string($value) && !is_int($value) && !is_float($value)) || !is_numeric($value)
|| !is_finite((float) $value) || (float) $value < $definition['min'] || (float) $value > $definition['max']
|| (!empty($definition['integer']) && floor((float) $value) !== (float) $value)
|| round((float) $value, 2) !== (float) $value) {
throw new DomainException('FOLLOWUP_AUDIO_NUMBER_INVALID');
}
$result[$key] = !empty($definition['integer']) ? (int) $value : (float) $value;
} elseif (in_array($definition['type'], ['select', 'multiselect'], true)) {
$options = isset($definition['dictionary']) ? self::dictionary($definition['dictionary']) : $definition['options'];
$allowed = [];
foreach ($options as $option) { $allowed[(string) $option['value']] = $option['value']; }
$candidates = $definition['type'] === 'multiselect' ? $value : [$value];
if (!is_array($candidates) || $candidates === [] || count($candidates) > 100) {
throw new DomainException('FOLLOWUP_AUDIO_OPTION_INVALID');
}
$selected = [];
foreach ($candidates as $candidate) {
if ((!is_string($candidate) && !is_int($candidate)) || !array_key_exists((string) $candidate, $allowed)) {
throw new DomainException('FOLLOWUP_AUDIO_OPTION_INVALID');
}
$selected[(string) $candidate] = $allowed[(string) $candidate];
}
if (isset($definition['max']) && mb_strlen(implode(',', $selected), 'UTF-8') > $definition['max']) {
throw new DomainException('FOLLOWUP_AUDIO_OPTION_TOO_LONG');
}
$result[$key] = $definition['type'] === 'multiselect' ? array_values($selected) : array_values($selected)[0];
} elseif ($definition['type'] === 'date') {
$result[$key] = FollowupAudioPolicy::strictDate($value);
} else {
if (!is_string($value) || trim($value) === '' || mb_strlen($value, 'UTF-8') > $definition['max']
|| preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value)) {
throw new DomainException('FOLLOWUP_AUDIO_TEXT_INVALID');
}
$result[$key] = trim($value);
}
}
if (isset($result['phone']) && !preg_match('/^1[3-9]\d{9}$/D', $result['phone'])) {
throw new DomainException('FOLLOWUP_AUDIO_PHONE_INVALID');
}
if (isset($result['id_card'])) {
$card = strtoupper($result['id_card']);
if (!preg_match('/^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[0-9X]$/D', $card)
|| !checkdate((int) substr($card, 10, 2), (int) substr($card, 12, 2), (int) substr($card, 6, 4))) {
throw new DomainException('FOLLOWUP_AUDIO_ID_CARD_INVALID');
}
$result['id_card'] = $card;
}
return $result;
}
/** Convert selected values to the existing database representation, never to a new schema. */
public static function toDatabase(string $kind, array $values): array
{
$result = self::validateValues($kind, $values);
foreach ($result as $key => $value) {
if (is_array($value)) { $result[$key] = implode(',', $value); }
if ($kind === 'diagnosis' && $key === 'diagnosis_date') {
$result[$key] = FollowupAudioPolicy::dayTimestamp($value);
}
$column = self::databaseKey($kind, $key);
if ($column !== $key) { $result[$column] = $result[$key]; unset($result[$key]); }
}
return $result;
}
public static function fromDatabase(string $kind, array $row): array
{
$values = [];
foreach (self::definitions()[$kind] as $key => $definition) {
$value = $row[self::databaseKey($kind, $key)] ?? null;
if ($value !== null && $definition['type'] === 'multiselect') {
$value = $value === '' ? [] : explode(',', (string) $value);
} elseif ($value !== null && $value !== '' && $definition['type'] === 'number') {
$value = !empty($definition['integer']) ? (int) $value : (float) $value;
} elseif ($value && $definition['type'] === 'date') {
$value = (new \DateTimeImmutable('@' . (int) $value))->setTimezone(new \DateTimeZone('Asia/Shanghai'))->format('Y-m-d');
}
$values[$key] = $value;
}
return $values;
}
private static function dictionary(string $type): array
{
$rows = Db::name('dict_data')->where('type_value', $type)->where('status', 1)->order('sort', 'asc')->order('id', 'asc')
->field(['name', 'value'])->select()->toArray();
return array_map(static fn (array $row): array => ['label' => (string) $row['name'], 'value' => (string) $row['value']], $rows);
}
}