['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('餐后2小时血糖(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, // Match the existing VARCHAR columns; never rely on SQL truncation at adoption. 'diet' => ['breakfast' => $text('早餐', 500), 'lunch' => $text('午餐', 500), 'dinner' => $text('晚餐', 500), 'note' => $text('备注', 255)], 'exercise' => ['exercise_type' => $text('运动方式', 100), 'duration' => $number('时长(分钟)', 1, 1440, true), 'intensity' => $select('运动强度', [1 => '低强度', 2 => '中强度', 3 => '高强度']), 'note' => $text('备注', 255)], 'tracking_note' => ['content' => $text('跟踪备注', 10000)], ]; } public static function catalog(): array { $catalog = []; foreach (self::definitions() as $kind => $definitions) { $catalog[$kind] = []; foreach ($definitions as $key => $definition) { // Expose the same constraints used by validateValues(), not a second schema. $field = ['key' => $key] + $definition + self::recordingContract($kind, $key, $definition); if (isset($definition['dictionary'])) { $field['options'] = self::dictionary($definition['dictionary']); } elseif (isset($definition['options'])) { $field['options'] = $definition['options']; } $catalog[$kind][] = $field; } } return $catalog; } /** Extraction guidance only: this never coerces evidence or changes accepted/storage values. */ private static function recordingContract(string $kind, string $key, array $definition): array { $contract = [ 'semantic' => $definition['label'], 'temporal_scope' => $kind === 'diagnosis' ? '诊单资料或当前状态,保留明确的历史与近期限定' : '有可靠日期的单次事件,不按同日或同值自动合并', 'missing_policy' => '未提及或不确定则省略该键;不填表单默认值、0、正常、无、空串或空数组', 'source_policy' => '仅提炼已核对主体的明确事实;提问、建议、假设、家属自身经历及旧诊单不作为本次患者事实', 'recording_rules' => [], ]; $rules = &$contract['recording_rules']; if ($definition['type'] === 'text') { $scalar = ($kind === 'diagnosis' && in_array($key, ['patient_name', 'phone', 'id_card', 'region', 'local_hospital_name'], true)) || ($kind === 'exercise' && $key === 'exercise_type'); $rules[] = $scalar ? '标量文本只输出明确名称、号码、地区或运动类型本身,不加自述、字段标签或句末标点;不猜姓名、药名或机构全称' : '整理为简洁记录句,保留主体、否定范围、时间与程度;原话只在证据中保留,不添加未述事实'; $rules[] = '超过max字数应待核实,不静默截断或丢失限定条件'; } elseif ($definition['type'] === 'number') { $rules[] = '仅输出明确单值且单位可靠的数值;不从范围取均值、不补单位、不裁剪数值;min/max是存储约束而非医学正常范围'; $contract['decimal_places'] = !empty($definition['integer']) ? 0 : 2; } elseif (in_array($definition['type'], ['select', 'multiselect'], true)) { $contract['enum_no_match_policy'] = '仅按本次options的真实label/value明确匹配;无匹配或字典为空则省略该键,并在uncertainties说明,必要时在合适补充文本保留事实'; $rules[] = $definition['type'] === 'multiselect' ? '输出真实value数组,不输出标签或口语句子;空数组不表示无' : '输出真实value,保留其类型,不猜枚举、不选最接近选项'; } if ($kind === 'diagnosis') { $rules[] = '同一字段每批一个候选;文字及多选均整列替换,未提及旧值不等于删除,需人工核对最终值'; } elseif (in_array($kind, ['diet', 'exercise'], true)) { $rules[] = '日期须有依据;当前仅保存事件日期,明确钟点或时段保留在note,不假装具备时刻级存储'; } $units = ['fasting_blood_sugar' => 'mmol/L', 'postprandial_blood_sugar' => 'mmol/L', 'other_blood_sugar' => 'mmol/L', 'systolic_pressure' => 'mmHg', 'diastolic_pressure' => 'mmHg', 'height' => 'cm', 'weight' => 'kg', 'duration' => '分钟', 'age' => '岁']; if (isset($units[$key])) { $contract['unit'] = $units[$key]; } $specific = [ 'diagnosis' => [ 'appetite' => ['口腔感觉,不是食欲或饭量', '口干、口苦等仅匹配当前口腔字典;进食量路由diet_condition或symptoms;局部否认不扩张为全部正常'], 'diabetes_discovery_year' => ['发现糖尿病的患病史/病程文本,不是发现年份', '如“自述发现糖尿病约2年”;不换算四位年份或当地医院确诊日期'], 'current_medications' => ['当前用药状态或方案,不是某日执行记录或处方', '按明确药名、规格、剂量、频次整理;缺项不补、近音药名不猜;近期未服药不等于从未用药或未注射胰岛素;历史停药与当前恢复分开'], 'diagnosis_date' => ['当地医院明确诊断日期', '输出YYYY-MM-DD;不使用回访/上传日期,不从病程推算日期'], 'diagnosis_type' => ['初诊、复诊或会诊的就诊类型,不是疾病诊断', '仅明确本次就诊类型才匹配当前字典;不从患病时间、症状、曾经就诊或回访动作推断初复诊'], 'local_hospital_diagnosis' => ['明确当地医院诊断结果', '仅使用本字段固定字符串options,不使用diabetes_type字典;不从血糖或症状推断诊断'], 'local_hospital_name' => ['明确当地就诊医院名称', '只记已明确医院,不从地区或诊断推测医院'], 'fatty_liver_degree' => ['明确报告的脂肪肝程度', '仅明确既有诊断分级匹配字典;不依据症状或体重推断分级'], 'weight_change' => ['近一个月体重变化', '时间范围必须匹配近一个月;无明显下降不等于字典中的下降选项,无匹配保留补充文本'], 'water_intake' => ['每日饮水量', '仅明确匹配当前选项;2瓶不向邻近选项取整,单瓶容量未述不推毫升数'], 'symptoms' => ['现病史补充:近期症状、时间、变化与限定', '不堆积所有转写,不混入既往病史;字典已承载类别时只补充必要限定信息'], 'remark' => ['病史补充:既往病史及其限定', '局部否定或未确证历史保留限定,不改写为整体无病史'], 'past_history' => ['明确既往病史', '仅明确既往疾病陈述匹配字典,不从风险、家属疾病或科普推断患者病史'], 'diet_condition' => ['进食/饮食情况,不是口腔感觉', '易饥但进食不多不直接等同多食或食欲减退;无精确选项保留symptoms'], ], 'blood' => [ 'postprandial_blood_sugar' => ['餐后2小时血糖', '仅明确餐后2小时且单位可靠才投影;仅“饭后8.2”不能填此字段,间隔或单位缺失列待核实'], 'other_blood_sugar' => ['其他测量条件的血糖', '单位可靠且单值明确才投影;餐后间隔未知需在remark保留条件并审核,不改称餐后2小时'], 'western_medicine' => ['某日西药实际服用事件', '与diagnosis.current_medications当前方案分流;不把建议、计划或既有处方当作已服用'], 'insulin' => ['某日胰岛素实际注射事件', '保留明确剂量、单位与时段;品种未述不补品种,不将一次注射升级为长期方案'], 'remark' => ['本次测量或用药事件补充', '保留测量条件、执行时间及待核实限定,不填无关完整病史'], ], 'diet' => [ 'breakfast' => ['早餐食物与明确数量', '早餐/早饭同义归并,输出breakfast别名,不输出breakfast_foods或图片;不估热量'], 'lunch' => ['午餐食物与明确数量', '午餐/午饭/中饭同义归并,输出lunch别名,不输出lunch_foods或图片;不估热量'], 'dinner' => ['晚餐食物与明确数量', '晚餐/晚饭同义归并,输出dinner别名,不输出dinner_foods或图片;未吃晚餐不等于全天未进食'], 'note' => ['饮食补充、加餐与明确时间原文', '加餐/夜宵不强归三餐;保留食物及明确数量,不添加未提及餐次'], ], 'exercise' => [ 'exercise_type' => ['运动方式文本', '按明确语义归并散步、慢跑、游泳、瑜伽、骑自行车、打太极拳;其他方式以其他加note保留具体描述;不据方式推强度'], 'duration' => ['明确运动时长(整数分钟)', '半小时可规范为30分钟;时长未知不补,未运动不生成duration=0,按事实选择跟踪备注'], 'intensity' => ['明确报告的运动强度', '仅明确低/中/高强度才映射1/2/3;散步不等于低强度,不继承UI默认1'], 'note' => ['运动补充与明确时间原文', '保留具体运动描述与不确定限定,不猜强度或频次'], ], 'tracking_note' => [ 'content' => ['随访联系、执行反馈或无对应字段的历史事件摘要', '按事件日期追加而非覆盖;优先映射合适结构字段,不把所有事实堆入备注;不生成付款、就诊、预约完成等流程状态'], ], ]; if (isset($specific[$kind][$key])) { [$contract['semantic'], $rule] = $specific[$kind][$key]; $rules[] = $rule; } if ($key === 'fasting_blood_sugar') { $rules[] = '必须明确空腹条件;有日期的重复测量优先blood,不自动双写诊单快照'; } if ($kind === 'diagnosis' && in_array($key, ['trauma_history', 'surgery_history', 'allergy_history', 'family_history', 'pregnancy_history'], true)) { $rules[] = '0/1仅用于整体有无的明确回答;青霉素不过敏不等于allergy_history=0,父亲无糖尿病不等于family_history=0'; } if ($kind === 'diagnosis' && in_array($key, array_merge(self::IDENTITY_KEYS, ['marital_status', 'region']), true)) { $rules[] = '身份资料只作为独立核对候选,不从病情推断;遵守既有锁定、审核与修改权限'; } return $contract; } /** 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); } }