492 lines
16 KiB
Python
492 lines
16 KiB
Python
"""诊单字段的中文翻译:字典项、枚举与时间戳。
|
||
|
||
后台 ``AppointmentLogic::enrichDiagnosisLabels`` 会给只读接口补一份
|
||
``<field>_text``,所以读取时永远优先用它(与 admin 的 ``makeTextOf`` 同一约定)。
|
||
处方快照一类的历史数据没有这些字段,就按字段所属字典把 code 翻成中文:字典优先
|
||
取仓储实时下发的 ``config/dict``,缺失时回退到与
|
||
``server/sql/present_illness_dict_data.sql`` / ``tcm_diagnosis.sql`` 一致的内置种子。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Mapping, Sequence
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
# 与 AppointmentLogic::enrichDiagnosisLabels 的 $singleDictFields 保持一致。
|
||
SINGLE_VALUE_DICTIONARIES: dict[str, str] = {
|
||
"diagnosis_type": "diagnosis_type",
|
||
"syndrome_type": "syndrome_type",
|
||
"diabetes_type": "diabetes_type",
|
||
"water_intake": "water_intake",
|
||
"weight_change": "weight_change",
|
||
"fatty_liver_degree": "fatty_liver_degree",
|
||
}
|
||
|
||
# 与 $multiDictFields 保持一致:值是数组或逗号/顿号分隔的字符串。
|
||
MULTI_VALUE_DICTIONARIES: dict[str, str] = {
|
||
"past_history": "past_history",
|
||
"appetite": "appetite",
|
||
"diet_condition": "diet_condition",
|
||
"body_feeling": "body_feeling",
|
||
"sleep_condition": "sleep_condition",
|
||
"eye_condition": "eye_condition",
|
||
"head_feeling": "head_feeling",
|
||
"sweat_condition": "sweat_condition",
|
||
"skin_condition": "skin_condition",
|
||
"urine_condition": "urine_condition",
|
||
"stool_condition": "stool_condition",
|
||
"kidney_condition": "kidney_condition",
|
||
}
|
||
|
||
FIELD_DICTIONARIES: dict[str, str] = {
|
||
**SINGLE_VALUE_DICTIONARIES,
|
||
**MULTI_VALUE_DICTIONARIES,
|
||
}
|
||
|
||
DICTIONARY_TYPES: tuple[str, ...] = tuple(sorted(set(FIELD_DICTIONARIES.values())))
|
||
|
||
# 内置种子:与 server/sql/present_illness_dict_data.sql、tcm_diagnosis.sql 的
|
||
# zyt_dict_data 初始数据一致;线上字典可被管理员改写,所以实时字典优先。
|
||
SEED_DICTIONARIES: dict[str, dict[str, str]] = {
|
||
"appetite": {
|
||
"dry": "干",
|
||
"bitter": "苦",
|
||
"greasy": "腻",
|
||
},
|
||
"body_feeling": {
|
||
"numbness": "麻木",
|
||
"weakness": "乏力",
|
||
"pain": "疼痛",
|
||
"cold_aversion": "畏寒",
|
||
"fever": "烧热",
|
||
},
|
||
"diagnosis_type": {
|
||
"first_visit": "初诊",
|
||
"follow_up": "复诊",
|
||
"consultation": "会诊",
|
||
},
|
||
"diet_condition": {
|
||
"overeating": "多食",
|
||
"poor_appetite": "纳呆",
|
||
"stomach_bloating": "胃胀",
|
||
"stomach_pain": "胃痛",
|
||
"acid_reflux": "反酸",
|
||
"loss_of_appetite": "食欲减退",
|
||
},
|
||
"eye_condition": {
|
||
"blurred": "模糊",
|
||
"dry": "干涩",
|
||
"tearing": "流泪",
|
||
"floaters": "飞蚊症",
|
||
"bleeding": "出血",
|
||
},
|
||
"fatty_liver_degree": {
|
||
"mild": "轻度",
|
||
"moderate": "中度",
|
||
"severe": "重度",
|
||
},
|
||
"head_feeling": {
|
||
"fatigue": "疲劳困倦",
|
||
"dizziness": "头晕",
|
||
"headache": "头痛",
|
||
"tinnitus": "耳鸣",
|
||
},
|
||
"kidney_condition": {
|
||
"soreness": "酸胀",
|
||
"pain": "疼痛",
|
||
"lower_back_pain": "腰痛",
|
||
"sexual_dysfunction": "性功能下降",
|
||
},
|
||
"past_history": {
|
||
"hypertension": "高血压",
|
||
"diabetes": "糖尿病",
|
||
"gastric_ulcer": "胃溃疡",
|
||
"hyperlipidemia": "高血脂",
|
||
"thyroid_nodule": "甲状腺结节",
|
||
"superficial_gastritis": "浅表性胃炎",
|
||
"stomach_disease": "胃病",
|
||
"cerebral_infarction": "脑梗",
|
||
"breast_nodule": "乳腺结节",
|
||
"atrophic_gastritis": "萎缩性胃炎",
|
||
"heart_disease": "心脏病",
|
||
"cerebral_ischemia": "脑缺血",
|
||
"intestinal_obstruction": "肠梗阻",
|
||
"hepatitis_a": "甲肝",
|
||
"hepatitis_b": "乙肝",
|
||
"hepatitis_c": "丙肝",
|
||
"big_three_positive": "大三阳",
|
||
"cerebral_thrombosis": "脑血栓",
|
||
"coronary_heart_disease": "冠心病",
|
||
"angina_pectoris": "心绞痛",
|
||
"palpitation": "心悸",
|
||
"renal_insufficiency": "肾功能不全",
|
||
"benign_tumor": "良性肿瘤",
|
||
"pancreatitis": "胰腺炎",
|
||
"small_three_positive": "小三阳",
|
||
"palpitations": "心慌",
|
||
"edema": "水肿",
|
||
"infectious_disease": "传染病",
|
||
"fundus_congestion": "眼底充血",
|
||
"tuberculosis": "肺结核",
|
||
"pneumonia": "肺炎",
|
||
"pulmonary_nodule": "肺结节",
|
||
"cardiac_stent": "心脏支架",
|
||
"renal_stent": "肾脏支架",
|
||
"hepatitis": "肝炎",
|
||
"tumor": "肿瘤",
|
||
"emphysema": "肺气肿",
|
||
"moderate_fatty_liver": "中度脂肪肝",
|
||
"lacunar_infarction": "腔梗",
|
||
"alcoholic_liver": "酒精肝",
|
||
"brain_atrophy": "脑萎缩",
|
||
"liver_cyst": "肝囊肿",
|
||
"stroke": "中风",
|
||
"cerebral_hemorrhage": "脑出血",
|
||
"hepatic_insufficiency": "肝功能不全",
|
||
"arterial_plaque": "动脉斑块",
|
||
"uterine_fibroids": "子宫肌瘤",
|
||
"splenomegaly": "脾大",
|
||
"gastric_perforation": "胃穿孔",
|
||
"gastric_bleeding": "胃出血",
|
||
},
|
||
"skin_condition": {
|
||
"dry": "干燥",
|
||
"itching": "瘙痒",
|
||
"peeling": "脱皮",
|
||
"edema": "水肿",
|
||
"eczema": "湿疹",
|
||
},
|
||
"sleep_condition": {
|
||
"difficulty_falling_asleep": "入睡难",
|
||
"easy_to_wake": "容易醒",
|
||
"early_waking": "早醒",
|
||
"many_dreams": "多梦",
|
||
},
|
||
"stool_condition": {
|
||
"dry": "干燥",
|
||
"constipation": "便秘",
|
||
"sticky": "粘腻",
|
||
"diarrhea": "腹泻",
|
||
},
|
||
"sweat_condition": {
|
||
"daytime_sweating": "日间出汗",
|
||
"night_sweating": "夜间出汗",
|
||
"sticky_sweat": "汗粘",
|
||
"excessive_sweating": "多汗",
|
||
},
|
||
"syndrome_type": {
|
||
"qi_deficiency": "气虚",
|
||
"blood_deficiency": "血虚",
|
||
"yin_deficiency": "阴虚",
|
||
"yang_deficiency": "阳虚",
|
||
"qi_stagnation": "气滞",
|
||
"blood_stasis": "血瘀",
|
||
"phlegm_dampness": "痰湿",
|
||
"damp_heat": "湿热",
|
||
"cold_dampness": "寒湿",
|
||
"wind_cold": "风寒",
|
||
"wind_heat": "风热",
|
||
},
|
||
"urine_condition": {
|
||
"urgency": "尿急",
|
||
"yellow_urine": "尿黄",
|
||
"foamy": "有泡",
|
||
"frequency": "尿频",
|
||
"painful": "尿痛",
|
||
"nocturia": "夜尿多",
|
||
},
|
||
"water_intake": {
|
||
"one_bottle": "1瓶矿泉水",
|
||
"one_half_bottle": "1.5瓶矿泉水",
|
||
"three_bottles": "3瓶矿泉水",
|
||
"four_bottles": "4瓶矿泉水",
|
||
},
|
||
"weight_change": {
|
||
"lose_5_jin": "瘦5斤",
|
||
"lose_10_jin": "瘦10斤",
|
||
"lose_over_10_jin": "瘦10斤以上",
|
||
},
|
||
}
|
||
|
||
_GENDER_LABELS: dict[str, str] = {
|
||
"1": "男",
|
||
"m": "男",
|
||
"male": "男",
|
||
"男": "男",
|
||
"0": "女",
|
||
"2": "女",
|
||
"f": "女",
|
||
"female": "女",
|
||
"女": "女",
|
||
}
|
||
|
||
_MARITAL_LABELS: dict[str, str] = {"0": "未婚", "1": "已婚", "2": "离异"}
|
||
|
||
# 后台 yesNoText:1 有,其余 无。
|
||
_YES_NO_FIELDS = frozenset(
|
||
{
|
||
"trauma_history",
|
||
"surgery_history",
|
||
"allergy_history",
|
||
"family_history",
|
||
"pregnancy_history",
|
||
}
|
||
)
|
||
|
||
_CREATE_SOURCE_LABELS: dict[str, str] = {
|
||
"mnp": "小程序建档",
|
||
"mnp_daily": "小程序快捷建档",
|
||
"admin": "后台创建",
|
||
"doctor": "医生创建",
|
||
}
|
||
|
||
_RECORD_SOURCE_LABELS: dict[str, str] = {"0": "医生录入", "1": "患者自录"}
|
||
|
||
# 纯内部列:启停标记、统计端展示位、排班偏移与软删除时间对医生没有意义。
|
||
INTERNAL_FIELDS = frozenset(
|
||
{
|
||
"status",
|
||
"show_card",
|
||
"revisit_slot_start_offset",
|
||
"delete_time",
|
||
"is_delete",
|
||
"is_deleted",
|
||
"sort",
|
||
"assistant_id",
|
||
"doctor_id",
|
||
"admin_id",
|
||
}
|
||
)
|
||
|
||
# 附件字段单独渲染成缩略图,不再以 URL 文本出现在字段网格里。
|
||
IMAGE_FIELDS = frozenset(
|
||
{
|
||
"tongue_images",
|
||
"report_files",
|
||
"images",
|
||
"breakfast_images",
|
||
"lunch_images",
|
||
"dinner_images",
|
||
}
|
||
)
|
||
|
||
_TIMESTAMP_SUFFIXES = ("_time", "_at", "_date")
|
||
_TIMESTAMP_MIN = 10**9 # 2001-09-09,早于本项目任何真实数据
|
||
_TIMESTAMP_MAX = 4 * 10**9 # 2096 年,之后按普通数字显示
|
||
|
||
|
||
# 只读页的单位,与 admin PatientCaseCard / BloodRecordList 一致。
|
||
_UNIT_SUFFIXES: dict[str, str] = {
|
||
"height": " cm",
|
||
"weight": " kg",
|
||
"systolic_pressure": " mmHg",
|
||
"diastolic_pressure": " mmHg",
|
||
"fasting_blood_sugar": " mmol/L",
|
||
"postprandial_blood_sugar": " mmol/L",
|
||
"other_blood_sugar": " mmol/L",
|
||
"blood_sugar": " mmol/L",
|
||
"duration": " 分钟",
|
||
"diabetes_discovery_year": "年",
|
||
}
|
||
|
||
|
||
def unit_suffix(field: str, value: Any) -> str:
|
||
"""Return the unit a numeric readonly field should carry, if any."""
|
||
|
||
suffix = _UNIT_SUFFIXES.get(str(field or "").strip())
|
||
if not suffix:
|
||
return ""
|
||
text = str(value).strip()
|
||
if not text or text.endswith(suffix.strip()):
|
||
return ""
|
||
# 只给纯数字补单位,"17多"、"五年" 这类自由文本保持原样。
|
||
normalized = text.replace(".", "", 1)
|
||
return suffix if normalized.isdigit() else ""
|
||
|
||
|
||
def format_timestamp(value: Any, *, with_time: bool = True) -> str | None:
|
||
"""把后端的 Unix 秒级时间戳转成中文界面用的日期时间。"""
|
||
|
||
if isinstance(value, bool):
|
||
return None
|
||
try:
|
||
seconds = int(str(value).strip())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if not _TIMESTAMP_MIN <= seconds <= _TIMESTAMP_MAX:
|
||
return None
|
||
moment = datetime.fromtimestamp(seconds)
|
||
return moment.strftime("%Y-%m-%d %H:%M" if with_time else "%Y-%m-%d")
|
||
|
||
|
||
def is_timestamp_field(field: str) -> bool:
|
||
return str(field or "").endswith(_TIMESTAMP_SUFFIXES)
|
||
|
||
|
||
def gender_label(value: Any) -> str | None:
|
||
return _GENDER_LABELS.get(str(value or "").strip().lower())
|
||
|
||
|
||
def split_values(value: Any) -> list[str]:
|
||
"""按后台 enrichDiagnosisLabels 的方式拆多值字段。"""
|
||
|
||
if value in (None, "", []):
|
||
return []
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
items = [str(item or "").strip() for item in value]
|
||
else:
|
||
items = [part.strip() for part in _split_text(str(value))]
|
||
return [item for item in items if item]
|
||
|
||
|
||
def _split_text(text: str) -> list[str]:
|
||
normalized = text.replace(",", ",").replace("、", ",")
|
||
return normalized.split(",")
|
||
|
||
|
||
class TermIndex:
|
||
"""字典翻译表:实时字典覆盖内置种子,两者都缺就回显原值。"""
|
||
|
||
def __init__(self, dictionaries: Mapping[str, Any] | None = None) -> None:
|
||
self._dictionaries: dict[str, dict[str, str]] = {
|
||
dictionary_type: dict(options)
|
||
for dictionary_type, options in SEED_DICTIONARIES.items()
|
||
}
|
||
self.merge(dictionaries)
|
||
|
||
def merge(self, dictionaries: Mapping[str, Any] | None) -> None:
|
||
"""并入 ``config/dict`` 下发的 ``{type: [{name, value}, ...]}``。"""
|
||
|
||
if not isinstance(dictionaries, Mapping):
|
||
return
|
||
for dictionary_type, rows in dictionaries.items():
|
||
options = _options_from_rows(rows)
|
||
if options:
|
||
self._dictionaries.setdefault(str(dictionary_type), {}).update(options)
|
||
|
||
def dictionary(self, dictionary_type: str) -> dict[str, str]:
|
||
return dict(self._dictionaries.get(str(dictionary_type), {}))
|
||
|
||
def dictionary_label(self, field: str, value: Any) -> str | None:
|
||
"""翻译一个字典字段;不是字典字段或没有可翻译内容时返回 None。"""
|
||
|
||
dictionary_type = FIELD_DICTIONARIES.get(str(field or "").strip())
|
||
if dictionary_type is None:
|
||
return None
|
||
options = self._dictionaries.get(dictionary_type, {})
|
||
items = split_values(value)
|
||
if not items:
|
||
return None
|
||
labels = [options.get(item, item) for item in items]
|
||
return "、".join(label for label in labels if label) or None
|
||
|
||
def value_label(self, field: str, value: Any) -> str | None:
|
||
"""翻译字典项或枚举;无法翻译时返回 None,由调用方回显原值。"""
|
||
|
||
key = str(field or "").strip()
|
||
if value in (None, "", [], {}):
|
||
return None
|
||
dictionary_label = self.dictionary_label(key, value)
|
||
if dictionary_label is not None:
|
||
return dictionary_label
|
||
if key in {"gender", "patient_gender", "sex"}:
|
||
return gender_label(value)
|
||
if key in {"marital_status", "marriage"}:
|
||
return _MARITAL_LABELS.get(str(value).strip())
|
||
if key in _YES_NO_FIELDS:
|
||
text = str(value).strip()
|
||
if text in {"0", "1"}:
|
||
return "有" if text == "1" else "无"
|
||
return None
|
||
if key == "create_source":
|
||
return _CREATE_SOURCE_LABELS.get(str(value).strip().lower())
|
||
if key == "source":
|
||
return _RECORD_SOURCE_LABELS.get(str(value).strip())
|
||
if is_timestamp_field(key):
|
||
return format_timestamp(value, with_time=not key.endswith("_date"))
|
||
return None
|
||
|
||
def display(self, source: Any, field: str, *, default: str = "") -> str:
|
||
"""按 admin ``textOf`` 的口径取值:先 ``<field>_text``,再字典/枚举,最后原值。"""
|
||
|
||
mapping = source if isinstance(source, Mapping) else {}
|
||
key = str(field or "").strip()
|
||
translated = mapping.get(f"{key}_text")
|
||
if translated in (None, "", []):
|
||
translated = mapping.get(f"{key}_desc")
|
||
if translated not in (None, "", []):
|
||
return _join(translated) + unit_suffix(key, translated)
|
||
raw = mapping.get(key)
|
||
if raw in (None, "", [], {}):
|
||
return default
|
||
labelled = self.value_label(key, raw)
|
||
if labelled is not None:
|
||
return labelled
|
||
rendered = _join(raw)
|
||
return rendered + unit_suffix(key, rendered)
|
||
|
||
|
||
_SHARED_INDEX = TermIndex()
|
||
|
||
|
||
def shared_terms() -> TermIndex:
|
||
"""The process-wide index every readonly screen renders through.
|
||
|
||
只读界面只用它翻译展示文案,所以共享一份即可:任何界面取回实时字典后,
|
||
处方快照那种拿不到 ``*_text`` 的旧数据也能跟着翻译正确。
|
||
"""
|
||
|
||
return _SHARED_INDEX
|
||
|
||
|
||
def merge_shared_dictionaries(dictionaries: Mapping[str, Any] | None) -> None:
|
||
_SHARED_INDEX.merge(dictionaries)
|
||
|
||
|
||
def _join(value: Any) -> str:
|
||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||
return "、".join(str(item).strip() for item in value if str(item).strip())
|
||
return str(value).strip()
|
||
|
||
|
||
def _options_from_rows(rows: Any) -> dict[str, str]:
|
||
"""从 ``config/dict`` 的行里取 ``value -> name``。"""
|
||
|
||
options: dict[str, str] = {}
|
||
if isinstance(rows, Mapping):
|
||
for value, name in rows.items():
|
||
code = str(value).strip()
|
||
label = str(name).strip()
|
||
if code and label:
|
||
options[code] = label
|
||
return options
|
||
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
|
||
return options
|
||
for row in rows:
|
||
if not isinstance(row, Mapping):
|
||
continue
|
||
code = str(row.get("value", "")).strip()
|
||
label = str(row.get("name", "")).strip()
|
||
if code and label:
|
||
options[code] = label
|
||
return options
|
||
|
||
|
||
__all__ = [
|
||
"DICTIONARY_TYPES",
|
||
"FIELD_DICTIONARIES",
|
||
"IMAGE_FIELDS",
|
||
"INTERNAL_FIELDS",
|
||
"MULTI_VALUE_DICTIONARIES",
|
||
"SEED_DICTIONARIES",
|
||
"SINGLE_VALUE_DICTIONARIES",
|
||
"TermIndex",
|
||
"format_timestamp",
|
||
"merge_shared_dictionaries",
|
||
"shared_terms",
|
||
"gender_label",
|
||
"is_timestamp_field",
|
||
"split_values",
|
||
]
|