222 lines
13 KiB
PHP
222 lines
13 KiB
PHP
<?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);
|
|
}
|
|
}
|