['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); } }