diff --git a/admin/src/views/first_visit/conversion/index.vue b/admin/src/views/first_visit/conversion/index.vue index 3a835bab1..8c8a7c5f1 100644 --- a/admin/src/views/first_visit/conversion/index.vue +++ b/admin/src/views/first_visit/conversion/index.vue @@ -145,7 +145,7 @@

明细数据列表

-

展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户与继承客户);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线

+

展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户、继承客户及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线

{{ dashboard.rows.length }} 个顶层节点
@@ -330,7 +330,7 @@ const timeOptions = [ { label: '自定义', value: 'custom' } ] const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [ - { key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户与继承客户' }, + { key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户及区间前已加过的重加' }, { key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' }, { key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' }, { key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' }, diff --git a/app/artifacts/debug_lxh_psb.py b/app/artifacts/debug_lxh_psb.py new file mode 100644 index 000000000..29bb5685e --- /dev/null +++ b/app/artifacts/debug_lxh_psb.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +import json +import sys +from collections import defaultdict +from datetime import datetime + +import pymysql + +sys.stdout.reconfigure(encoding="utf-8") + +conn = pymysql.connect( + host="127.0.0.1", user="root", password="root", database="zyt", charset="utf8mb4" +) +cur = conn.cursor(pymysql.cursors.DictCursor) + +START, END = 1786464000, 1786550399 # 2026-08-12 + + +def q(sql, args=None): + cur.execute(sql, args or ()) + return cur.fetchall() + + +def parse_follow(follow): + if isinstance(follow, str) and follow: + try: + return json.loads(follow) + except Exception: + return [] + return follow if isinstance(follow, list) else [] + + +def contact_info(ext, user_id): + rows = q( + "SELECT id, name, follow_users, delete_time FROM zyt_qywx_external_contact WHERE external_userid=%s ORDER BY id DESC", + (ext,), + ) + name = None + remark = None + add_way = None + still = False + createtime = None + for c in rows: + if name is None: + name = c.get("name") + for fu in parse_follow(c.get("follow_users")): + if not isinstance(fu, dict): + continue + uid = str(fu.get("userid") or fu.get("UserId") or "") + if uid == user_id: + still = True + remark = fu.get("remark") or fu.get("Remark") or remark + add_way = fu.get("add_way", fu.get("AddWay")) + createtime = fu.get("createtime") or fu.get("create_time") or createtime + if remark: + break + return { + "name": name, + "remark": remark, + "add_way": add_way, + "still_following": still, + "follow_createtime": createtime, + "follow_createtime_at": datetime.fromtimestamp(int(createtime)).strftime("%Y-%m-%d %H:%M:%S") + if createtime + else None, + "contact_rows": len(rows), + } + + +def events_for(user_id, ext): + return q( + """ + SELECT change_type, FROM_UNIXTIME(event_time) AS event_at + FROM zyt_qywx_external_contact_event + WHERE user_id=%s AND external_userid=%s + AND change_type IN ('add_external_contact','del_follow_user','del_external_contact','msg_audit_approved') + ORDER BY event_time + """, + (user_id, ext), + ) + + +def analyze(label, user_id): + print(f"\n========== {label} / {user_id} ==========") + admins = q( + "SELECT id, name, work_wechat_userid, disable, delete_time FROM zyt_admin WHERE work_wechat_userid=%s OR name LIKE %s", + (user_id, f"%{label}%"), + ) + print("admin", json.dumps(admins, ensure_ascii=False, default=str)) + + raw = q( + """ + SELECT e.external_userid, COUNT(*) AS add_events, + FROM_UNIXTIME(MIN(e.event_time)) AS first_in_day, + FROM_UNIXTIME(MAX(e.event_time)) AS last_in_day + FROM zyt_qywx_external_contact_event e + WHERE e.change_type='add_external_contact' + AND e.user_id=%s + AND e.event_time BETWEEN %s AND %s + AND e.external_userid <> '' + GROUP BY e.external_userid + ORDER BY MIN(e.event_time) + """, + (user_id, START, END), + ) + print("raw unique adds", len(raw)) + + def filtered(extra_prev=False): + sql = """ + SELECT e.external_userid, MIN(e.event_time) AS first_add, + FROM_UNIXTIME(MIN(e.event_time)) AS first_add_at + FROM zyt_qywx_external_contact_event e + WHERE e.change_type='add_external_contact' + AND e.user_id=%s + AND e.event_time BETWEEN %s AND %s + AND e.external_userid <> '' + AND EXISTS ( + SELECT 1 FROM zyt_qywx_external_contact_event audit_e + WHERE audit_e.user_id=e.user_id + AND audit_e.external_userid=e.external_userid + AND audit_e.change_type='msg_audit_approved' + AND audit_e.event_time >= e.event_time + AND audit_e.event_time <= %s + ) + AND NOT EXISTS ( + SELECT 1 FROM zyt_qywx_external_contact_event del_e + WHERE del_e.user_id=e.user_id + AND del_e.external_userid=e.external_userid + AND del_e.change_type='del_external_contact' + AND del_e.event_time >= e.event_time + AND del_e.event_time <= %s + ) + """ + args = [user_id, START, END, END, END] + if extra_prev: + sql += """ + AND NOT EXISTS ( + SELECT 1 FROM zyt_qywx_external_contact_event prev_e + WHERE prev_e.user_id=e.user_id + AND prev_e.external_userid=e.external_userid + AND prev_e.change_type='add_external_contact' + AND prev_e.event_time < %s + ) + """ + args.append(START) + sql += " GROUP BY e.external_userid ORDER BY first_add" + return q(sql, args) + + old_pairs = filtered(False) + new_pairs = filtered(True) + print("system OLD (before readd fix)", len(old_pairs)) + print("system NEW (after readd fix)", len(new_pairs)) + + old_ids = {p["external_userid"] for p in old_pairs} + new_ids = {p["external_userid"] for p in new_pairs} + raw_ids = {p["external_userid"] for p in raw} + + dropped_no_audit_or_del = raw_ids - old_ids + dropped_readd = old_ids - new_ids + + print("\n--- dropped vs raw (no audit / customer-deleted) ---") + for ext in sorted(dropped_no_audit_or_del, key=lambda x: next(r["first_in_day"] for r in raw if r["external_userid"] == x)): + info = contact_info(ext, user_id) + ev = events_for(user_id, ext) + print(json.dumps({"ext": ext, **info, "events": ev, "raw": next(r for r in raw if r["external_userid"] == ext)}, ensure_ascii=False, default=str)) + + print("\n--- dropped as re-add (old counted, new excluded) ---") + for ext in sorted(dropped_readd): + info = contact_info(ext, user_id) + ev = events_for(user_id, ext) + print(json.dumps({"ext": ext, **info, "events": ev}, ensure_ascii=False, default=str)) + + print("\n--- inheritance add_way 201/202 among NEW pairs ---") + for p in new_pairs: + info = contact_info(p["external_userid"], user_id) + if info["add_way"] in (201, 202): + print(json.dumps({"ext": p["external_userid"], **info, "first_add_at": p["first_add_at"]}, ensure_ascii=False, default=str)) + + print("\n--- NEW counted list ---") + for p in new_pairs: + info = contact_info(p["external_userid"], user_id) + types = [e["change_type"] for e in events_for(user_id, p["external_userid"])] + print(json.dumps({ + "first_add_at": p["first_add_at"], + "name": info["name"], + "remark": info["remark"], + "add_way": info["add_way"], + "follow_createtime_at": info["follow_createtime_at"], + "still_following": info["still_following"], + "event_types": sorted(set(types)), + }, ensure_ascii=False, default=str)) + + # people in old but check missing audit on the in-day add specifically + print("\n--- raw adds missing audit after THAT add or del_external ---") + for r in raw: + ext = r["external_userid"] + if ext in old_ids: + continue + ev = events_for(user_id, ext) + print(json.dumps({"ext": ext, "raw": r, "events": ev, **contact_info(ext, user_id)}, ensure_ascii=False, default=str)) + + +analyze("李晓焕", "LiXiaoHuan") +analyze("彭世博", "GeiWoNaHaoDeA") diff --git a/app/artifacts/debug_lxh_psb2.py b/app/artifacts/debug_lxh_psb2.py new file mode 100644 index 000000000..34a9e78c2 --- /dev/null +++ b/app/artifacts/debug_lxh_psb2.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +import json +import sys +import pymysql + +sys.stdout.reconfigure(encoding="utf-8") +conn = pymysql.connect(host="127.0.0.1", user="root", password="root", database="zyt", charset="utf8mb4") +cur = conn.cursor(pymysql.cursors.DictCursor) + +def q(sql, args=None): + cur.execute(sql, args or ()) + return cur.fetchall() + +print("=== dept bindings ===") +print(json.dumps(q(""" +SELECT a.id, a.name, a.work_wechat_userid, d.id AS dept_id, d.name AS dept_name +FROM zyt_admin a +LEFT JOIN zyt_admin_dept ad ON ad.admin_id=a.id +LEFT JOIN zyt_dept d ON d.id=ad.dept_id AND d.delete_time IS NULL +WHERE a.work_wechat_userid IN ('LiXiaoHuan','GeiWoNaHaoDeA','mu') +ORDER BY a.id, d.id +"""), ensure_ascii=False, default=str, indent=2)) + +print("=== 员工@ contact type ===") +print(json.dumps(q(""" +SELECT c.id, c.external_userid, c.name, c.type, c.corp_name, c.gender +FROM zyt_qywx_external_contact_event e +JOIN zyt_qywx_external_contact c ON c.external_userid=e.external_userid +WHERE e.user_id='LiXiaoHuan' AND e.change_type='add_external_contact' + AND e.event_time BETWEEN 1786464000 AND 1786550399 + AND (c.name LIKE '%@%' OR c.name LIKE '%员工%') +GROUP BY c.id +"""), ensure_ascii=False, default=str, indent=2)) + +print("=== today Aug 13 counts ===") +start, end = 1786550400, 1786636799 +for uid, name in [("LiXiaoHuan","李晓焕"),("GeiWoNaHaoDeA","彭世博")]: + cur.execute(""" + SELECT COUNT(*) AS c FROM ( + SELECT e.external_userid + FROM zyt_qywx_external_contact_event e + WHERE e.change_type='add_external_contact' AND e.user_id=%s + AND e.event_time BETWEEN %s AND %s AND e.external_userid<>'' + AND EXISTS ( + SELECT 1 FROM zyt_qywx_external_contact_event a + WHERE a.user_id=e.user_id AND a.external_userid=e.external_userid + AND a.change_type='msg_audit_approved' AND a.event_time>=e.event_time AND a.event_time<=%s + ) + AND NOT EXISTS ( + SELECT 1 FROM zyt_qywx_external_contact_event d + WHERE d.user_id=e.user_id AND d.external_userid=e.external_userid + AND d.change_type='del_external_contact' AND d.event_time>=e.event_time AND d.event_time<=%s + ) + AND NOT EXISTS ( + SELECT 1 FROM zyt_qywx_external_contact_event p + WHERE p.user_id=e.user_id AND p.external_userid=e.external_userid + AND p.change_type='add_external_contact' AND p.event_time<%s + ) + GROUP BY e.external_userid + ) t + """, (uid, start, end, end, end, start)) + print(name, "today unique", cur.fetchone()) + cur.execute(""" + SELECT COUNT(*) AS raw FROM ( + SELECT external_userid FROM zyt_qywx_external_contact_event + WHERE change_type='add_external_contact' AND user_id=%s + AND event_time BETWEEN %s AND %s GROUP BY external_userid + ) t + """, (uid, start, end)) + print(name, "today raw unique", cur.fetchone()) + +print("=== personal yeji 8/12 open/fans ===") +print(json.dumps(q(""" +SELECT a.name, y.yeji_date, y.add_fans_count, y.total_open_count, y.media_source, y.creator_id +FROM zyt_personal_yeji y +JOIN zyt_admin a ON a.id=y.creator_id +WHERE a.work_wechat_userid IN ('LiXiaoHuan','GeiWoNaHaoDeA') + AND y.yeji_date BETWEEN '2026-08-12' AND '2026-08-13' +ORDER BY a.name, y.yeji_date +"""), ensure_ascii=False, default=str, indent=2)) + +print("=== 李晓焕 add_way mix ===") +print(json.dumps(q(""" +SELECT change_type, COUNT(*) c FROM zyt_qywx_external_contact_event +WHERE user_id='LiXiaoHuan' AND event_time BETWEEN 1786464000 AND 1786550399 +GROUP BY change_type +"""), ensure_ascii=False, default=str)) diff --git a/app/artifacts/prescription_slip_debug.pdf b/app/artifacts/prescription_slip_debug.pdf new file mode 100644 index 000000000..eb64d3637 Binary files /dev/null and b/app/artifacts/prescription_slip_debug.pdf differ diff --git a/app/artifacts/prescription_slip_debug.png b/app/artifacts/prescription_slip_debug.png new file mode 100644 index 000000000..11adcc745 Binary files /dev/null and b/app/artifacts/prescription_slip_debug.png differ diff --git a/app/src/doctor_workstation/app.py b/app/src/doctor_workstation/app.py index 939b4a5f5..aeaeb738e 100644 --- a/app/src/doctor_workstation/app.py +++ b/app/src/doctor_workstation/app.py @@ -9,7 +9,7 @@ import time from contextlib import suppress from typing import Any -from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator +from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator from PySide6.QtGui import QGuiApplication, QIcon from PySide6.QtWidgets import ( QApplication, @@ -43,71 +43,73 @@ from doctor_workstation.ui.widgets import ( from doctor_workstation.video import BackendMode, launch_video_call from doctor_workstation.video.window import WEBENGINE_AVAILABLE -LOGGER = logging.getLogger(__name__) - - -class _ChineseQtTranslator(QTranslator): - """Guarantee Chinese labels for common Qt standard buttons. - - Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for - framework text. This small fallback also keeps release builds localized - when a packager omits the optional ``.qm`` files. - """ - - _BUTTON_TEXT = { - "OK": "确定", - "Open": "打开", - "Save": "保存", - "Save All": "全部保存", - "Cancel": "取消", - "Close": "关闭", - "Yes": "是", - "Yes to All": "全部确认", - "No": "否", - "No to All": "全部否定", - "Abort": "中止", - "Retry": "重试", - "Ignore": "忽略", - "Discard": "放弃", - "Help": "帮助", - "Apply": "应用", - "Reset": "重置", - "Restore Defaults": "恢复默认设置", - "Don't Save": "不保存", - } - - def translate( - self, - context: str, - source_text: str, - disambiguation: str | None = None, - n: int = -1, - ) -> str: - del context, disambiguation, n - return self._BUTTON_TEXT.get(source_text.replace("&", ""), "") - - -def _install_chinese_translations(application: QApplication) -> None: - """Install Simplified Chinese Qt catalogs once for the whole process.""" - - if getattr(application, "_doctor_workstation_chinese_translators", None): - return - - QLocale.setDefault(QLocale("zh_CN")) - translators: list[QTranslator] = [] - translations_path = QLibraryInfo.path( - QLibraryInfo.LibraryPath.TranslationsPath - ) - for catalog in ("qt_zh_CN", "qtbase_zh_CN"): - translator = QTranslator(application) - if translator.load(catalog, translations_path): - application.installTranslator(translator) - translators.append(translator) - - fallback = _ChineseQtTranslator(application) - application.installTranslator(fallback) - translators.append(fallback) - application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined] +LOGGER = logging.getLogger(__name__) + + +class _ChineseQtTranslator(QTranslator): + """Guarantee Chinese labels for common Qt standard buttons. + + Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for + framework text. This small fallback also keeps release builds localized + when a packager omits the optional ``.qm`` files. + """ + + _BUTTON_TEXT = { + "OK": "确定", + "Open": "打开", + "Save": "保存", + "Save All": "全部保存", + "Cancel": "取消", + "Close": "关闭", + "Yes": "是", + "Yes to All": "全部确认", + "No": "否", + "No to All": "全部否定", + "Abort": "中止", + "Retry": "重试", + "Ignore": "忽略", + "Discard": "放弃", + "Help": "帮助", + "Apply": "应用", + "Reset": "重置", + "Restore Defaults": "恢复默认设置", + "Don't Save": "不保存", + } + + def translate( + self, + context: str, + source_text: str, + disambiguation: str | None = None, + n: int = -1, + ) -> str | None: + del context, disambiguation, n + # Returning an empty string tells Qt that an unknown source string has + # a valid, deliberately empty translation. That also erased internal + # values such as QPageSize's "A4", producing zero-sized PDF pages. + # ``None`` delegates unknown text to Qt's installed catalog/source. + return self._BUTTON_TEXT.get(source_text.replace("&", "")) + + +def _install_chinese_translations(application: QApplication) -> None: + """Install Simplified Chinese Qt catalogs once for the whole process.""" + + if getattr(application, "_doctor_workstation_chinese_translators", None): + return + + QLocale.setDefault(QLocale("zh_CN")) + translators: list[QTranslator] = [] + translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + for catalog in ("qt_zh_CN", "qtbase_zh_CN"): + translator = QTranslator(application) + if translator.load(catalog, translations_path): + application.installTranslator(translator) + translators.append(translator) + + fallback = _ChineseQtTranslator(application) + application.installTranslator(fallback) + translators.append(fallback) + application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined] class _UnconfiguredRepository: @@ -264,12 +266,12 @@ class ApplicationController(QObject): def _show_login(self) -> None: if self.login_window is None: - self.login_window = LoginWindow( - self._base_repository(), - self.config, - self.demo_repository, - credential_store=self.token_store, - ) + self.login_window = LoginWindow( + self._base_repository(), + self.config, + self.demo_repository, + credential_store=self.token_store, + ) self.login_window.login_succeeded.connect(self._on_login_succeeded) self.login_window.config_changed.connect(self._on_config_changed) self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed) @@ -277,9 +279,9 @@ class ApplicationController(QObject): else: self.login_window.repository = self._base_repository() self.login_window.config = self.config - if not self.login_window.demo_check.isChecked(): - self.login_window.active_repository = self._base_repository() - self.login_window.restore_remembered_credentials() + if not self.login_window.demo_check.isChecked(): + self.login_window.active_repository = self._base_repository() + self.login_window.restore_remembered_credentials() self.login_window.show() self.login_window.raise_() self.login_window.activateWindow() @@ -564,40 +566,40 @@ class ApplicationController(QObject): if parent is None or self.current_repository is None: return patient_id = payload.get("patient_id") - diagnosis_id = payload.get("diagnosis_id") - patient_name = str(payload.get("patient_name") or "患者") - open_im = str(payload.get("mode") or "video").lower() == "im" + diagnosis_id = payload.get("diagnosis_id") + patient_name = str(payload.get("patient_name") or "患者") + open_im = str(payload.get("mode") or "video").lower() == "im" if patient_id in (None, "") or diagnosis_id in (None, ""): show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200) return - call_key = str(diagnosis_id) - existing_call = self.video_calls.get(call_key) - if open_im and existing_call is not None and getattr(existing_call, "open_im", False): - qt_window = getattr(existing_call, "qt_window", None) - if qt_window is not None: - qt_window.show() - qt_window.raise_() - qt_window.activateWindow() - show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200) - return - if ( - call_key in self.video_pending - or existing_call is not None - or call_key in self.demo_video_dialogs - ): - show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600) - return - - closed_previous_im = False - if open_im: - for key, call in tuple(self.video_calls.items()): - if key == call_key or not getattr(call, "open_im", False): - continue - closed_previous_im = True - self.video_calls.pop(key, None) - with suppress(Exception): - call.close() + call_key = str(diagnosis_id) + existing_call = self.video_calls.get(call_key) + if open_im and existing_call is not None and getattr(existing_call, "open_im", False): + qt_window = getattr(existing_call, "qt_window", None) + if qt_window is not None: + qt_window.show() + qt_window.raise_() + qt_window.activateWindow() + show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200) + return + if ( + call_key in self.video_pending + or existing_call is not None + or call_key in self.demo_video_dialogs + ): + show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600) + return + + closed_previous_im = False + if open_im: + for key, call in tuple(self.video_calls.items()): + if key == call_key or not getattr(call, "open_im", False): + continue + closed_previous_im = True + self.video_calls.pop(key, None) + with suppress(Exception): + call.close() if self.current_demo_mode: dialog = DemoVideoDialog(patient_name, parent) @@ -611,11 +613,11 @@ class ApplicationController(QObject): dialog.show() return - show_toast( - parent, - "正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…", - "info", - ) + show_toast( + parent, + "正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…", + "info", + ) repository = self.current_repository marker = object() self.video_pending[call_key] = marker @@ -626,35 +628,35 @@ class ApplicationController(QObject): diagnosis_id=int(diagnosis_id), ) - def request_ticket() -> None: - if self.video_pending.get(call_key) is not marker: - return - run_async( - get_ticket, - on_success=lambda ticket: self._launch_video( - ticket, - diagnosis_id=diagnosis_id, - patient_id=patient_id, - repository=repository, - call_key=call_key, - marker=marker, - open_im=open_im, - patient_name=patient_name, - ), - on_error=lambda error: self._video_ticket_error( - call_key, - marker, - parent, - error, - ), - ) - - # Tencent IM may take a brief moment to release the previous browser - # connection. The admin version also has only one ChatDialog instance. - if closed_previous_im: - QTimer.singleShot(400, request_ticket) - else: - request_ticket() + def request_ticket() -> None: + if self.video_pending.get(call_key) is not marker: + return + run_async( + get_ticket, + on_success=lambda ticket: self._launch_video( + ticket, + diagnosis_id=diagnosis_id, + patient_id=patient_id, + repository=repository, + call_key=call_key, + marker=marker, + open_im=open_im, + patient_name=patient_name, + ), + on_error=lambda error: self._video_ticket_error( + call_key, + marker, + parent, + error, + ), + ) + + # Tencent IM may take a brief moment to release the previous browser + # connection. The admin version also has only one ChatDialog instance. + if closed_previous_im: + QTimer.singleShot(400, request_ticket) + else: + request_ticket() def _video_ticket_error( self, @@ -681,11 +683,11 @@ class ApplicationController(QObject): diagnosis_id: Any, patient_id: Any, repository: Any, - call_key: str, - marker: object, - open_im: bool = False, - patient_name: str = "患者", - ) -> None: + call_key: str, + marker: object, + open_im: bool = False, + patient_name: str = "患者", + ) -> None: if self.video_pending.get(call_key) is not marker: return self.video_pending.pop(call_key, None) @@ -708,11 +710,11 @@ class ApplicationController(QObject): patient_id=patient_id, backend_mode=mode, local_dist=video_dist_path(), - remote_url=self.config.video_web_url or None, - logger=logging.getLogger("doctor_workstation.video"), - open_im=open_im, - patient_name=patient_name, - ) + remote_url=self.config.video_web_url or None, + logger=logging.getLogger("doctor_workstation.video"), + open_im=open_im, + patient_name=patient_name, + ) except Exception as error: LOGGER.exception("video call could not be launched") show_toast( @@ -788,10 +790,12 @@ def _create_application(argv: list[str]) -> QApplication: QGuiApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) - application = QApplication(argv) - _install_chinese_translations(application) - application.setApplicationName("甄养堂医生工作站") - application.setApplicationDisplayName("甄养堂医生工作站") + with suppress(AttributeError): + QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True) + application = QApplication(argv) + _install_chinese_translations(application) + application.setApplicationName("甄养堂医生工作站") + application.setApplicationDisplayName("甄养堂医生工作站") application.setOrganizationName("ZhenYangTang") application.setOrganizationDomain("zhenyangtang.com") application.setQuitOnLastWindowClosed(True) diff --git a/app/src/doctor_workstation/ui/dialogs/prescription.py b/app/src/doctor_workstation/ui/dialogs/prescription.py index b0e9a4ea7..b38a57457 100644 --- a/app/src/doctor_workstation/ui/dialogs/prescription.py +++ b/app/src/doctor_workstation/ui/dialogs/prescription.py @@ -10,7 +10,10 @@ from __future__ import annotations import base64 import html import json +import math +import os import re +import sys from collections.abc import Iterable, Mapping, Sequence from datetime import date, datetime from pathlib import Path @@ -21,6 +24,7 @@ from PySide6.QtCore import ( QByteArray, QDate, QIODevice, + QMarginsF, QPoint, QRectF, Qt, @@ -30,16 +34,20 @@ from PySide6.QtCore import ( from PySide6.QtGui import ( QColor, QFont, + QFontDatabase, QImage, QMouseEvent, + QPageLayout, QPageSize, QPainter, + QPdfWriter, QPen, QTextDocument, ) from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtWidgets import ( QAbstractItemView, + QApplication, QCheckBox, QComboBox, QDateEdit, @@ -554,7 +562,9 @@ class MultiSelectComboBox(QComboBox): self.lineEdit().setPlaceholderText("请选择忌口内容(可多选)") for option in options: self.addItem(option, option) - self.setItemData(self.count() - 1, Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole) + self.setItemData( + self.count() - 1, Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole + ) self.view().pressed.connect(self._toggle_item) # QComboBox applies the clicked item's text after ``pressed``. Refresh # on the next event-loop tick so the field always shows every checked @@ -2017,9 +2027,7 @@ class PrescriptionEditorDialog(QDialog): self.usage_days = QSpinBox() self.usage_days.setRange(1, 365) self._place_field(grid, 2, 0, "单次用量", self.dosage_amount) - self._main_dosage_unit_field = self._place_field( - grid, 2, 1, "用量单位", self.dosage_unit - ) + self._main_dosage_unit_field = self._place_field(grid, 2, 1, "用量单位", self.dosage_unit) self._main_dosage_unit_field.hide() self._main_bag_field = self._place_field(grid, 2, 2, "每次袋数", self.dosage_bag_count) self._main_decoction_field = self._place_field(grid, 3, 0, "代煎", self.need_decoction) @@ -2259,7 +2267,9 @@ class PrescriptionEditorDialog(QDialog): voided = _bool(self._source.get("void_status")) rejected = _int(self._source.get("audit_status"), -1) == 2 if voided and rejected: - messages.append("当前处方已作废且已驳回;保存后将取消作废、清除驳回并重新进入待审核。") + messages.append( + "当前处方已作废且已驳回;保存后将取消作废、清除驳回并重新进入待审核。" + ) elif voided: messages.append("当前处方已作废;保存后将取消作废并重新进入待审核。") elif rejected: @@ -2537,7 +2547,12 @@ class PrescriptionEditorDialog(QDialog): widget.setFocus() return length_checks = ( - (payload["clinical_diagnosis"], 500, "临床诊断最多 500 个字符。", self.clinical_diagnosis), + ( + payload["clinical_diagnosis"], + 500, + "临床诊断最多 500 个字符。", + self.clinical_diagnosis, + ), (payload["usage_instruction"], 200, "用法最多 200 个字符。", self.usage_instruction), (payload["usage_notes"], 200, "其他说明最多 200 个字符。", self.usage_notes), ) @@ -2558,8 +2573,7 @@ class PrescriptionEditorDialog(QDialog): self.dosage_amount.setFocus() return if prescription_type == "饮片" and any( - value not in {50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0} - for value in dosage_values + value not in {50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0} for value in dosage_values ): self.validation.show_message( "饮片单次用量只能选择 50、100、120、150、180、200 或 250ml。", @@ -2781,23 +2795,25 @@ def render_case_record_html(prescription: Any) -> str: for label, field_value, full_width in fields: if full_width: if pending: - pending.extend('' for _ in range(3 - len(pending))) - rows.append(f'{"".join(pending)}') + pending.extend( + '' for _ in range(3 - len(pending)) + ) + rows.append(f"{''.join(pending)}") pending = [] - rows.append(f'{field_cell(label, field_value, colspan=3)}') + rows.append(f"{field_cell(label, field_value, colspan=3)}") continue pending.append(field_cell(label, field_value)) if len(pending) == 3: - rows.append(f'{"".join(pending)}') + rows.append(f"{''.join(pending)}") pending = [] if pending: pending.extend('' for _ in range(3 - len(pending))) - rows.append(f'{"".join(pending)}') + rows.append(f"{''.join(pending)}") return ( '' f'' '' + f"{''.join(rows)}
{html.escape(title)}
' - f'{"".join(rows)}
" '' ) @@ -2876,9 +2892,7 @@ def render_case_record_html(prescription: Any) -> str: ), section( "既往史", - ( - ("", value("past_history"), True), - ), + (("", value("past_history"), True),), ), section( "其他病史", @@ -2925,29 +2939,45 @@ h1 {{ text-align:center; font-size:20px; font-weight:600; margin:0; .cr-rule {{ height:1px; border-bottom:1px solid #eeeeee; }} - +

甄养堂 详细病历

{''.join(sections)}

甄养堂 详细病历

{"".join(sections)}
""" -def render_prescription_html(prescription: Any, *, print_layout: bool = False) -> str: - """Build the pharmacy-copy A4 slip used by preview, print and PDF export.""" +_SLIP_HOSPITAL_TITLE = "成都双流甄养堂互联网医院 处方笺" +_SLIP_COMPANY_LINE = "成都双流甄养堂互联网医院有限公司 联系方式:4001667339" +_SLIP_ADDRESS_LINE = "地址:四川省成都市双流区黄甲街道黄龙大道二段280号" + + +def render_prescription_html( + prescription: Any, + *, + print_layout: bool = False, + variant: str = "internal", +) -> str: + """Build the A4 slip used by preview, print and PDF export. + + ``variant`` matches admin ``order_list.vue``: ``internal`` is 药房联, + ``user`` is 处方联. Layout is expressed with HTML tables because + ``QTextDocument`` does not implement flex/grid. + """ source = _mapping(prescription) - # consumer/prescription/index.vue uses a strict 210 x 297 mm sheet. At - # Qt's 96 logical DPI this is 794 x 1123 px; keeping the HTML width fixed - # prevents QTextDocument from stretching the medicine columns with the - # containing dialog. - paper_width = "100%" if print_layout else "794px" - paper_dimensions = 'width="100%"' if print_layout else 'width="794" height="1123"' - body_padding = "0" if print_layout else "12px" + is_internal = str(variant or "internal").strip().lower() != "user" + # Preview fills the dialog; PDF/print maps the same tables onto A4. + paper_width = "100%" + paper_dimensions = 'width="100%"' + body_padding = "0" if print_layout else "8px" body_background = "#ffffff" if print_layout else "#f5f6f8" paper_border = "0" if print_layout else "1px solid #d6d6d6" - base_font_size = "10px" if print_layout else "13px" - notice_font_size = "8.5px" if print_layout else "12px" - rp_font_size = "12px" if print_layout else "16px" - section_font_size = "9px" if print_layout else "12px" - bottom_height = "44px" if print_layout else "70px" + content_padding = "8mm 10mm 10mm" if print_layout else "12px 16px 16px" + base_font_size = "13px" + notice_font_size = "12px" + meta_font_size = "12px" + rp_font_size = "16px" + section_font_size = "12px" + info_padding = "9px 12px" + bottom_height = "52px" herbs = _herb_rows(prescription) dose_count = max(1, _int(source.get("dose_count"), 1)) main = [row for row in herbs if _formula(row.get("formula_type")) == "主方"] @@ -2993,9 +3023,7 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - if explicit: return display_text(explicit) prescription_type = str( - values.get("prescription_type") - or fallback.get("prescription_type") - or "浓缩水丸" + values.get("prescription_type") or fallback.get("prescription_type") or "浓缩水丸" ) times = int(positive_number(values.get("times_per_day"), 3)) amount = positive_number(values.get("dosage_amount"), 10) @@ -3040,11 +3068,16 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - if pair_index == 1: cells.append('') dosage = number_text(row.get("dosage")) - total = number_text(_float(row.get("dosage")) * dose_count) + if is_internal: + name_html = f"{esc(row.get('name'))} ({dosage}克)" + qty_html = f"{number_text(_float(row.get('dosage')) * dose_count)}克" + else: + name_html = esc(row.get("name")) + qty_html = f"{dosage}克" cells.extend( ( - f'{esc(row.get("name"))} ({dosage}克)', - f'{total}克', + f'{name_html}', + f'{qty_html}', ) ) if len(pair) == 1: @@ -3081,30 +3114,27 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - str(value).strip() for value in recipient_parts if str(value or "").strip() ) - serial = next( - ( - source.get(key) - for key in ( - "order_no", - "serial_no", - "serial_number", - "no", - "prescription_no", - "sn", - "visit_no", - ) - if source.get(key) - ), - f"G{source.get('id')}" if source.get("id") else "—", + prescription_sn = ( + str(source.get("sn") or "").strip() or str(source.get("visit_no") or "").strip() or "—" ) + appointment_id = _int(source.get("appointment_id"), 0) + flow_text = str(appointment_id) if appointment_id > 0 else "—" + business_no = str(source.get("order_no") or "").strip() gender = source.get("gender") gender_text = "男" if gender in (1, "1", "男") else "女" if gender in (0, "0", "女") else "—" age = display_text(source.get("age")) age_text = age if age == "—" or age.endswith("岁") else f"{age}岁" + phone_text = source.get("phone") or source.get("recipient_phone") aux_usage = source.get("aux_usage") aux_usage = dict(aux_usage) if isinstance(aux_usage, Mapping) else {} - main_usage_text = display_text(source.get("usage_text"), "") or usage_text(source, fallback=source) + aux_library = str( + aux_usage.get("prescription_name") or aux_usage.get("library_name") or "" + ).strip() + aux_title = f"辅方({aux_library})" if is_internal and aux_library else "辅方" + main_usage_text = display_text(source.get("usage_text"), "") or usage_text( + source, fallback=source + ) aux_usage_text = usage_text(aux_usage, fallback=source) if aux else "" advice = source.get("medical_advice") or source.get("doctor_advice") remark_parts = [f"共{len(herbs)}味药"] @@ -3113,10 +3143,7 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - for value in (source.get("usage_notes"), source.get("remark")) if str(value or "").strip() ) - pharmacy_remark = ( - source.get("pharmacy_remark") - or source.get("pharmacy_note") - ) + pharmacy_remark = source.get("pharmacy_remark") or source.get("pharmacy_note") explicit_out = ( source.get("out_pellet_text") or source.get("out_pellet") or source.get("total_weight") @@ -3142,12 +3169,29 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - out_pellet = f"{number_text(total_weight)}克" prescription_type = display_text(source.get("prescription_type"), "浓缩水丸") - type_text = ( - f"浓缩丸-{prescription_type}" - if re.search(r"丸|散|膏|片", prescription_type) and not prescription_type.startswith("浓缩丸-") - else prescription_type - ) + if prescription_type == "饮片": + type_text = "饮片" + elif re.search(r"丸|散|膏|片", prescription_type) and not prescription_type.startswith( + "浓缩丸-" + ): + type_text = f"浓缩丸-{prescription_type}" + else: + type_text = prescription_type per_dose = number_text(sum(_float(row.get("dosage")) for row in herbs)) + days_value = next( + ( + source.get(key) + for key in ("medication_days", "usage_days") + if source.get(key) not in (None, "") and str(source.get(key)).strip() != "" + ), + None, + ) + if days_value is not None: + days_text = f"{days_value} 天" + elif source.get("dose_count") not in (None, "") and str(source.get("dose_count")).strip(): + days_text = f"{source.get('dose_count')}剂" + else: + days_text = "—" signature = str(source.get("doctor_signature") or "").strip() signature_html = ( f'医师签名' @@ -3155,25 +3199,76 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - else f'{esc(source.get("doctor_name"))}' ) - herb_html = herb_group(main, "主方", "main") + herb_group(aux, "辅方", "aux") + herb_html = herb_group(main, "主方", "main") + herb_group(aux, aux_title, "aux") if not herb_html: herb_html = ( '' "暂无药材明细" ) - text_rows = [f"

主方服法:{esc(main_usage_text)}

"] - if aux_usage_text: - text_rows.append(f"

辅方服法:{esc(aux_usage_text)}

") + text_rows: list[str] = [] + if is_internal: + if aux_usage_text: + text_rows.append(f"

主服法:{esc(main_usage_text)}

") + text_rows.append(f"

辅服法:{esc(aux_usage_text)}

") + else: + text_rows.append(f"

服法:{esc(main_usage_text)}

") if advice: text_rows.append(f"

医嘱:{esc(advice)}

") if dietary: text_rows.append(f"

忌口:{esc(dietary)}

") - text_rows.append(f"

备注:{esc(' '.join(remark_parts))}

") + if is_internal: + text_rows.append(f"

备注:{esc(' '.join(remark_parts))}

") if pharmacy_remark: text_rows.append(f'

药房备注:{esc(pharmacy_remark)}

') - if out_pellet: + if is_internal and out_pellet and prescription_type != "饮片": text_rows.append(f'

出丸:{esc(out_pellet)}

') + slip_title = "药房联" if is_internal else _SLIP_HOSPITAL_TITLE + copy_label = "药房联" if is_internal else "处方联" + qty_head = "总量" if is_internal else "用量" + notice_text = ( + "服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点" + if is_internal + else "服药前请核对姓名、电话、医生等信息以及医嘱等要点" + ) + + def meta_td(label: str, value: Any, *, split: bool = False) -> str: + klass = "meta-item meta-split" if split else "meta-item" + return ( + f'' + f'{html.escape(label)}
' + f"{esc(value)}" + ) + + meta_rows = ( + f"{meta_td('日期:', date_text())}" + f"{meta_td('处方编号:', prescription_sn, split=True)}" + f"{meta_td('流转编号(挂号):', flow_text)}" + f"{meta_td('业务单号:', business_no or '—', split=True)}" + ) + if is_internal: + bottom_row = ( + f'医师{signature_html}' + f'类型: {esc(type_text)}' + f'天数: {html.escape(days_text)}' + f'剂量: {per_dose}克' + ) + hospital_colspan = 4 + else: + bottom_row = ( + f'医师{signature_html}' + f'天数: {html.escape(days_text)}' + ) + hospital_colspan = 2 + + family = html.escape(_ensure_slip_fonts()) + title_html = ( + f'

{html.escape(slip_title)}

' + if is_internal + else f'

{html.escape(slip_title)}

' + ) + notice_colspan = 2 + audit_lines: list[str] = [] if source.get("audit_by_name"): audit_lines.append( @@ -3182,7 +3277,9 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) - if source.get("audit_remark"): audit_lines.append(f"审核意见:{esc(source.get('audit_remark'))}") if source.get("business_prescription_audit_remark"): - audit_lines.append(f"业务订单审核意见:{esc(source.get('business_prescription_audit_remark'))}") + audit_lines.append( + f"业务订单审核意见:{esc(source.get('business_prescription_audit_remark'))}" + ) audit_html = "" if audit_lines and not print_layout: audit_html = f'
{"
".join(audit_lines)}
' @@ -3191,91 +3288,292 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
- - - -
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点日期:{esc(date_text())}  编号:{esc(serial)}
- +{title_html} +
+ +{meta_rows} +
{html.escape(notice_text)}
+ - - - + + +
姓名 {esc(source.get("patient_name"))} 性别 {esc(gender_text)} 年龄 {esc(age_text)}电话 {esc(source.get("phone"))}
收件信息 {esc(recipient_text)}
临床诊断 {esc(source.get("clinical_diagnosis"))}
电话 {esc(phone_text)}
收件信息 {esc(recipient_text)}
临床诊断 {esc(source.get("clinical_diagnosis"))}
-
- +
+ - - + + {herb_html}
Rp.用药 (单剂)总量用药 (单剂)总量
{qty_head}用药 (单剂){qty_head}
{"".join(text_rows)}
- - - - - -
医师{signature_html}类型: {esc(type_text)}天数: {dose_count}剂单剂量: {per_dose}克
+ +{bottom_row} + +
+{_SLIP_COMPANY_LINE}
{_SLIP_ADDRESS_LINE} +
{audit_html} """ +_SLIP_FONT_FAMILY = "Microsoft YaHei" +_SLIP_FONT_LOADED = False + + +def _ensure_slip_fonts() -> str: + """Register a CJK font so rasterized PDF/print is not tofu or blank.""" + + global _SLIP_FONT_FAMILY, _SLIP_FONT_LOADED + if _SLIP_FONT_LOADED: + return _SLIP_FONT_FAMILY + _SLIP_FONT_LOADED = True + candidates: list[Path] = [] + if sys.platform == "win32": + fonts = ( + Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or r"C:\Windows") + / "Fonts" + ) + candidates.extend( + fonts / name for name in ("msyh.ttc", "msyhbd.ttc", "simhei.ttf", "simsun.ttc") + ) + elif sys.platform == "darwin": + candidates.extend( + Path(name) + for name in ( + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Light.ttc", + "/Library/Fonts/Arial Unicode.ttf", + ) + ) + for path in candidates: + if not path.is_file(): + continue + font_id = QFontDatabase.addApplicationFont(str(path)) + if font_id == -1: + continue + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + _SLIP_FONT_FAMILY = families[0] + break + _SLIP_FONT_FAMILY = _pick_cjk_family(_SLIP_FONT_FAMILY) + return _SLIP_FONT_FAMILY + + +def _pick_cjk_family(preferred: str) -> str: + """Prefer a family Qt can actually resolve, so CJK does not vanish.""" + + names = ( + preferred, + "Microsoft YaHei", + "Microsoft YaHei UI", + "微软雅黑", + "SimHei", + "NSimSun", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Noto Sans CJK SC", + "Source Han Sans SC", + ) + has_family = getattr(QFontDatabase, "hasFamily", None) + for name in names: + if not name: + continue + available = False + if callable(has_family): + try: + available = bool(has_family(name)) + except TypeError: + available = bool(QFontDatabase().hasFamily(name)) + else: + available = name in set(QFontDatabase.families()) + if available: + return name + app = QApplication.instance() + if app is not None and app.font().family(): + return app.font().family() + return preferred or "sans-serif" + + +def _printable_slip_image(image: QImage) -> QImage: + """QPdfWriter drops ARGB-premultiplied images; RGB32 keeps the ink.""" + + rgb = image.convertToFormat(QImage.Format.Format_RGB32) + rgb.setDevicePixelRatio(1.0) + return rgb + + +def render_prescription_slip_image( + prescription: Any, + *, + variant: str = "internal", + width: int = 794, + scale: float = 2.0, +) -> QImage: + """Rasterize the slip with the same font engine as the on-screen preview. + + QPdfWriter cannot embed CJK glyphs from QTextDocument, so print/PDF must + paint this image rather than calling ``QTextDocument.print_``. + """ + + document = QTextDocument() + document.setDocumentMargin(0) + family = _ensure_slip_fonts() + default_font = QFont(family) + default_font.setPixelSize(13) + default_font.setStyleHint(QFont.StyleHint.SansSerif) + document.setDefaultFont(default_font) + document.setTextWidth(float(width)) + document.setHtml(render_prescription_html(prescription, print_layout=True, variant=variant)) + size = document.size() + pixel_w = max(1, int(math.ceil(max(size.width(), float(width)) * scale))) + pixel_h = max(1, int(math.ceil(max(size.height(), 200.0) * scale))) + image = QImage(pixel_w, pixel_h, QImage.Format.Format_RGB32) + image.setDevicePixelRatio(1.0) + image.fill(QColor("#ffffff")) + painter = QPainter(image) + try: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) + painter.scale(scale, scale) + document.drawContents(painter) + _paint_slip_watermark( + painter, + prescription, + variant=variant, + paper_width=float(width), + scale=1.0, + ) + finally: + painter.end() + return image + + +def count_slip_ink_pixels(image: QImage, *, step: int = 3, ceiling: int = 245) -> int: + """Count sampled pixels that are not paper-white. Used to catch blank exports.""" + + if image.isNull() or image.width() <= 0 or image.height() <= 0: + return 0 + ink = 0 + for y in range(0, image.height(), step): + for x in range(0, image.width(), step): + if image.pixelColor(x, y).lightness() < ceiling: + ink += 1 + return ink + + +def _paint_slip_watermark( + painter: QPainter, + prescription: Any, + *, + variant: str, + paper_width: float, + scale: float, +) -> None: + herbs = _herb_rows(prescription) + main_count = sum(1 for row in herbs if _formula(row.get("formula_type")) == "主方") + aux_count = max(0, len(herbs) - main_count) + medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2 + section_count = int(main_count > 0) + int(aux_count > 0) + rp_height = 65 + medicine_rows * 30 + section_count * 22 + center_y = (280 + rp_height // 2) * scale + painter.save() + painter.translate(paper_width * scale / 2, center_y) + painter.rotate(-22) + painter.setPen(QColor(31, 31, 31, 15)) + font = QFont(_ensure_slip_fonts(), -1, QFont.Weight.Bold) + font.setPixelSize(int(84 * scale)) + painter.setFont(font) + label = "处方联" if str(variant).strip().lower() == "user" else "药房联" + painter.drawText( + QRectF(-230 * scale, -70 * scale, 460 * scale, 140 * scale), + Qt.AlignmentFlag.AlignCenter, + label, + ) + painter.restore() + + +def _draw_slip_image_on_page(painter: QPainter, image: QImage) -> None: + rgb = _printable_slip_image(image) + page = painter.viewport() + if page.width() <= 0 or page.height() <= 0: + page = painter.window() + if rgb.isNull(): + return + if page.width() <= 0 or page.height() <= 0: + painter.drawImage(0, 0, rgb) + return + fitted = rgb.scaled( + page.width(), + page.height(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + painter.fillRect(page, QColor("#ffffff")) + painter.drawImage(page.x(), page.y(), fitted) + + class _PrescriptionPaperPreview(QTextBrowser): """A4 prescription preview with the consumer page's floating watermark. @@ -3287,17 +3585,23 @@ class _PrescriptionPaperPreview(QTextBrowser): _PAPER_WIDTH = 794 - def __init__(self, prescription: Any, parent: QWidget | None = None) -> None: + def __init__( + self, + prescription: Any, + parent: QWidget | None = None, + *, + variant: str = "internal", + ) -> None: super().__init__(parent) + self._watermark_text = "处方联" if str(variant).strip().lower() == "user" else "药房联" herbs = _herb_rows(prescription) - main_count = sum( - 1 for row in herbs if _formula(row.get("formula_type")) == "主方" - ) + main_count = sum(1 for row in herbs if _formula(row.get("formula_type")) == "主方") aux_count = len(herbs) - main_count medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2 section_count = int(main_count > 0) + int(aux_count > 0) rp_height = 65 + medicine_rows * 30 + section_count * 22 - self._watermark_document_y = 150 + rp_height // 2 + # Title + stacked notice sit above Rp; keep the watermark in the herb block. + self._watermark_document_y = 280 + rp_height // 2 def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override super().paintEvent(event) @@ -3305,24 +3609,20 @@ class _PrescriptionPaperPreview(QTextBrowser): center_y = self._watermark_document_y - self.verticalScrollBar().value() if center_y < -90 or center_y > viewport.height() + 90: return - paper_width = min(self._PAPER_WIDTH, viewport.width()) - paper_left = max(0.0, (viewport.width() - paper_width) / 2) + paper_width = viewport.width() + paper_left = 0.0 painter = QPainter(viewport) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.translate(paper_left + paper_width / 2, center_y) painter.rotate(-22) painter.setPen(QColor(31, 31, 31, 15)) - font = QFont( - "Microsoft YaHei UI", - -1, - QFont.Weight.Bold, - ) + font = QFont(_ensure_slip_fonts(), -1, QFont.Weight.Bold) font.setPixelSize(84) painter.setFont(font) painter.drawText( QRectF(-230, -70, 460, 140), Qt.AlignmentFlag.AlignCenter, - "药房联", + self._watermark_text, ) painter.end() @@ -3344,7 +3644,10 @@ class PrescriptionDetailDialog(QDialog): self.prescription = prescription self.document = QTextDocument(self) self.document.setDocumentMargin(0) - self.document.setHtml(render_prescription_html(prescription)) + self.document.setHtml(render_prescription_html(prescription, variant="internal")) + self.user_document = QTextDocument(self) + self.user_document.setDocumentMargin(0) + self.user_document.setHtml(render_prescription_html(prescription, variant="user")) self.setWindowTitle("查看处方") self.resize(920, 780) root = QVBoxLayout(self) @@ -3374,47 +3677,49 @@ class PrescriptionDetailDialog(QDialog): if _bool(first_value(prescription, "void_status", "is_void", default=False)): void_by = display_text(first_value(prescription, "void_by_name", default=""), "—") void_time = display_text(first_value(prescription, "void_time", default=""), "—") - status_lines.append(f"当前处方已作废 作废人:{void_by} 作废时间:{void_time}") + status_lines.append(f"已作废 作废人:{void_by} {void_time}") audit_status = _int(first_value(prescription, "audit_status", default=-1), -1) audit_label = {0: "待审核", 1: "已通过", 2: "已驳回"}.get(audit_status, "未知") - status_lines.append(f"消费者处方审核:{audit_label}") - audit_remark = str(first_value(prescription, "audit_remark", default="") or "").strip() - audit_by = str(first_value(prescription, "audit_by_name", default="") or "").strip() - audit_time = str(first_value(prescription, "audit_time", default="") or "").strip() - if audit_by or audit_time or audit_remark: - status_lines.append( - f"审核人:{audit_by or '—'} 审核时间:{audit_time or '—'}" - + (f" 审核意见:{audit_remark}" if audit_remark else "") - ) + if not status_lines or audit_status == 2: + status_lines.append(f"消费者处方审核:{audit_label}") if _bool(first_value(prescription, "business_prescription_audit_rejected", default=False)): - business_remark = str( - first_value(prescription, "business_prescription_audit_remark", default="") or "" - ).strip() - status_lines.append( - "业务订单处方审核:已驳回" - + (f" 驳回意见:{business_remark}" if business_remark else "") - ) - self.status_banner.show_message( - "\n".join(status_lines), + status_lines.append("业务订单处方审核:已驳回") + banner_kind = ( "danger" if _bool(first_value(prescription, "void_status", default=False)) or audit_status == 2 or _bool( first_value(prescription, "business_prescription_audit_rejected", default=False) ) - else "info", + else "success" + if audit_status == 1 + else "warning" + if audit_status == 0 + else "info" ) + self.status_banner.show_message(" ".join(status_lines), banner_kind) root.addWidget(self.status_banner) - self.preview = _PrescriptionPaperPreview(prescription) + self.preview = _PrescriptionPaperPreview(prescription, variant="internal") self.preview.setObjectName("PrescriptionPaperPreview") self.preview.setOpenExternalLinks(False) self.preview.setStyleSheet( "QTextBrowser#PrescriptionPaperPreview {" "background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}" ) + self.preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.preview.setDocument(self.document) + self.user_preview = _PrescriptionPaperPreview(prescription, variant="user") + self.user_preview.setObjectName("PrescriptionUserPaperPreview") + self.user_preview.setOpenExternalLinks(False) + self.user_preview.setStyleSheet( + "QTextBrowser#PrescriptionUserPaperPreview {" + "background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}" + ) + self.user_preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.user_preview.setDocument(self.user_document) self.tabs = QTabWidget() - self.tabs.addTab(self.preview, "处方") + self.tabs.addTab(self.preview, "药房联") + self.tabs.addTab(self.user_preview, "处方联") case_record = _mapping(_mapping(prescription).get("case_record")) self.case_document: QTextDocument | None = None self.case_preview: QTextBrowser | None = None @@ -3438,20 +3743,23 @@ class PrescriptionDetailDialog(QDialog): close.rejected.connect(self.reject) root.addWidget(close) + def _current_variant(self) -> str: + return "user" if self.tabs.tabText(self.tabs.currentIndex()) == "处方联" else "internal" + def print_slip(self) -> None: - printer = QPrinter(QPrinter.PrinterMode.HighResolution) + printer = QPrinter(QPrinter.PrinterMode.ScreenResolution) printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) - printer.setFullPage(True) + printer.setPageMargins(QMarginsF(8, 8, 8, 8), QPageLayout.Unit.Millimeter) dialog = QPrintDialog(printer, self) if dialog.exec() == QDialog.DialogCode.Accepted: - self._print_document(printer).print_(printer) - - def _print_document(self, printer: QPrinter) -> QTextDocument: - document = QTextDocument() - document.setDocumentMargin(0) - document.setPageSize(printer.pageRect(QPrinter.Unit.Point).size()) - document.setHtml(render_prescription_html(self.prescription, print_layout=True)) - return document + image = render_prescription_slip_image( + self.prescription, variant=self._current_variant() + ) + painter = QPainter(printer) + try: + _draw_slip_image_on_page(painter, image) + finally: + painter.end() def choose_pdf_path(self) -> None: patient = str(first_value(self.prescription, "patient_name", default="处方")) @@ -3470,12 +3778,33 @@ class PrescriptionDetailDialog(QDialog): output = str(path) if not output.lower().endswith(".pdf"): output += ".pdf" - printer = QPrinter(QPrinter.PrinterMode.HighResolution) - printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) - printer.setOutputFileName(output) - printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) - printer.setFullPage(True) - self._print_document(printer).print_(printer) + image = render_prescription_slip_image(self.prescription, variant=self._current_variant()) + if count_slip_ink_pixels(image) <= 0: + QMessageBox.warning(self, "导出失败", "处方笺未能生成可见内容,请重试。") + return + page_size = QPageSize(QPageSize.PageSizeId.A4) + if not page_size.isValid(): + QMessageBox.warning(self, "导出失败", "无法创建有效的 A4 页面,请重试。") + return + writer = QPdfWriter(output) + if not writer.setPageSize(page_size): + QMessageBox.warning(self, "导出失败", "无法创建有效的 A4 页面,请重试。") + return + if ( + not writer.setPageMargins(QMarginsF(8, 8, 8, 8), QPageLayout.Unit.Millimeter) + or not writer.pageLayout().isValid() + ): + QMessageBox.warning(self, "导出失败", "无法设置 A4 页面边距,请重试。") + return + writer.setResolution(300) + painter = QPainter() + if not painter.begin(writer): + QMessageBox.warning(self, "导出失败", "无法写入 PDF 文件,请检查路径是否可写。") + return + try: + _draw_slip_image_on_page(painter, image) + finally: + painter.end() class DiagnosisDetailDialog(QDialog): @@ -4153,4 +4482,6 @@ __all__ = [ "parse_pasted_herbs", "render_case_record_html", "render_prescription_html", + "render_prescription_slip_image", + "count_slip_ink_pixels", ] diff --git a/app/src/doctor_workstation/ui/pages/appointments.py b/app/src/doctor_workstation/ui/pages/appointments.py index 5cf456fe4..c8d8c78de 100644 --- a/app/src/doctor_workstation/ui/pages/appointments.py +++ b/app/src/doctor_workstation/ui/pages/appointments.py @@ -228,19 +228,18 @@ def _status_value(row: Any) -> int: return _as_int(first_value(row, "status", default=0)) -def prescription_action_label(row: Any) -> str: - audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1) - voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0) - explicit = first_value(row, "has_prescription", default=None) - has_prescription = ( - _as_bool(explicit) - if explicit is not None - else _as_int(first_value(row, "prescription_id", default=0), 0) > 0 - or audit in {0, 1, 2} - ) - if not has_prescription: - return "开方" - return "查看" if audit == 1 and voided != 1 else "编辑处方" +def prescription_action_label(row: Any) -> str: + audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1) + voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0) + explicit = first_value(row, "has_prescription", default=None) + has_prescription = ( + _as_bool(explicit) + if explicit is not None + else _as_int(first_value(row, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2} + ) + if not has_prescription: + return "开方" + return "查看" if audit == 1 and voided != 1 else "编辑处方" def _patient_cell(_value: Any, row: Any, *, can_plain: bool) -> str: @@ -270,9 +269,8 @@ def _patient_cell(_value: Any, row: Any, *, can_plain: bool) -> str: ) if part and part != "—" ) - return ( - f"{display_text(first_value(row, 'patient_name'), '—')}\n{phone}" - + (f"\n{extras}" if extras else "") + return f"{display_text(first_value(row, 'patient_name'), '—')}\n{phone}" + ( + f"\n{extras}" if extras else "" ) @@ -284,7 +282,9 @@ def _datetime_cell(_value: Any, row: Any) -> str: def _confirmed_cell(_value: Any, row: Any) -> str: - return "已确认" if _as_bool(first_value(row, "diagnosis_confirmed", default=False)) else "未确认" + return ( + "已确认" if _as_bool(first_value(row, "diagnosis_confirmed", default=False)) else "未确认" + ) def _prescription_cell(_value: Any, row: Any) -> str: @@ -296,7 +296,9 @@ def _prescription_cell(_value: Any, row: Any) -> str: def _status_cell(_value: Any, row: Any) -> str: - return display_text(first_value(row, "status_desc"), str(first_value(row, "status", default="—"))) + return display_text( + first_value(row, "status_desc"), str(first_value(row, "status", default="—")) + ) def _offset_date(preset: str) -> date | None: @@ -374,8 +376,6 @@ class AppointmentsPage(QWidget): ) self._native_video_capable = _supports_native_video(repository) self._is_admin = _is_admin_user(current_user) - self.diagnosis_dialog = DiagnosisDialog(repository, self, permissions=permissions) - self.diagnosis_dialog.saved.connect(lambda: self.refresh(silent=True)) root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 24) @@ -396,6 +396,14 @@ class AppointmentsPage(QWidget): self.poll_timer.setInterval(LIST_POLL_MS) self.poll_timer.timeout.connect(lambda: self.refresh(silent=True)) + def _diagnosis_dialog(self) -> DiagnosisDialog: + dialog = getattr(self, "_diagnosis_dialog_impl", None) + if dialog is None: + dialog = DiagnosisDialog(self.repository, self, permissions=self.permissions) + dialog.saved.connect(lambda: self.refresh(silent=True)) + self._diagnosis_dialog_impl = dialog + return dialog + def _build_toolbar(self) -> QWidget: frame = QFrame() frame.setObjectName("FilterBar") @@ -457,7 +465,9 @@ class AppointmentsPage(QWidget): button = QPushButton(label) button.setCheckable(True) button.setProperty("variant", "chip") - button.clicked.connect(lambda _checked=False, value=preset: self._set_date_preset(value)) + button.clicked.connect( + lambda _checked=False, value=preset: self._set_date_preset(value) + ) self.date_buttons[preset] = button date_row.addWidget(button) date_row.addStretch(1) @@ -490,9 +500,7 @@ class AppointmentsPage(QWidget): layout.setSpacing(8) actions = QHBoxLayout() - self.edit_button = self._action_button( - "编辑患者", "tcm.diagnosis/edit", self._edit_patient - ) + self.edit_button = self._action_button("编辑患者", "tcm.diagnosis/edit", self._edit_patient) actions.addWidget(self.edit_button) self.qr_button = self._action_button( "视频二维码", "tcm.diagnosis/videoQr", self._request_video_qr @@ -510,9 +518,7 @@ class AppointmentsPage(QWidget): "开方", "tcm.diagnosis/kaifang", self._open_prescription ) actions.addWidget(self.prescription_button) - self.case_button = self._action_button( - "病历", "tcm.diagnosis/kaifang", self._view_case - ) + self.case_button = self._action_button("病历", "tcm.diagnosis/kaifang", self._view_case) actions.addWidget(self.case_button) self.cancel_button = self._action_button( "取消挂号", @@ -601,7 +607,9 @@ class AppointmentsPage(QWidget): dialog = QDialog(self) dialog.setWindowTitle("自定义日期") layout = QVBoxLayout(dialog) - start = QDateEdit(QDate.fromString(self._start_date or date.today().isoformat(), "yyyy-MM-dd")) + start = QDateEdit( + QDate.fromString(self._start_date or date.today().isoformat(), "yyyy-MM-dd") + ) start.setCalendarPopup(True) start.setDisplayFormat("yyyy-MM-dd") end = QDateEdit(QDate.fromString(self._end_date or date.today().isoformat(), "yyyy-MM-dd")) @@ -719,9 +727,7 @@ class AppointmentsPage(QWidget): self._fit_table_rows() for row_index in range(self.table.rowCount()): source_item = self.table.item(row_index, 0) - source = ( - source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None - ) + source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None status = _status_value(source) status_kind = { 1: "warning", @@ -822,7 +828,7 @@ class AppointmentsPage(QWidget): if diagnosis_id <= 0: show_toast(self, "该预约没有关联诊单信息。", "warning") return - self.diagnosis_dialog.open_for(diagnosis_id, editable=True, seed=row) + self._diagnosis_dialog().open_for(diagnosis_id, editable=True, seed=row) def _request_video(self) -> None: if not _canonical_allowed( @@ -882,6 +888,7 @@ class AppointmentsPage(QWidget): self._action_generation += 1 generation = self._action_generation self.banner.show_message("正在生成视频二维码…", "info") + def _worker() -> dict[str, Any]: config = invoke(self.repository, "get_mini_program_config") if not str(first_value(config, "app_id", default="") or "").strip(): @@ -901,9 +908,7 @@ class AppointmentsPage(QWidget): if not url: raise ValueError("服务器未返回可用的二维码地址") try: - result["_image_bytes"] = invoke( - self.repository, "download_public_image", url=url - ) + result["_image_bytes"] = invoke(self.repository, "download_public_image", url=url) except Exception as exc: result["_image_error"] = friendly_error(exc) return result @@ -962,8 +967,7 @@ class AppointmentsPage(QWidget): else: reason = str(first_value(result, "_image_error", default="") or "").strip() image_label.setText( - "二维码图片加载失败\n请重新生成或在浏览器中打开" - + (f"\n{reason}" if reason else "") + "二维码图片加载失败\n请重新生成或在浏览器中打开" + (f"\n{reason}" if reason else "") ) image_row = QHBoxLayout() image_row.addStretch(1) @@ -1186,64 +1190,64 @@ class AppointmentsPage(QWidget): def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None: if generation != self._prescription_generation: return - self.banner.clear() - if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0: - approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1 - voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1 - if not approved or voided: - self._open_existing_prescription_editor(existing) - else: - dialog = PrescriptionDetailDialog( - existing, - can_open_diagnosis=_canonical_allowed( - self.permissions, "tcm.diagnosis/readonlyDetail", default=False - ), - parent=self, - ) - dialog.diagnosis_requested.connect(self._open_diagnosis_id) - dialog.exec() - return - self._begin_case_record_load(row) - - def _open_existing_prescription_editor(self, prescription: Any) -> None: - prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0)) - if prescription_id <= 0: - self.banner.show_message("处方编号不完整,无法编辑。", "danger") - return - dialog = PrescriptionEditorDialog( - self.repository, - prescription, - mode="edit", - current_user=self.current_user, - parent=self, - ) - diagnosis_signal = getattr(dialog, "diagnosis_requested", None) - if diagnosis_signal is not None: - diagnosis_signal.connect(self._open_diagnosis_id) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - frozen_payload = MappingProxyType(dialog.payload()) - self._mutation_pending = True - self._action_generation += 1 - generation = self._action_generation - self.banner.show_message("正在保存处方…", "info") - run_async( - lambda: invoke( - self.repository, - "update_prescription", - prescription=prescription_id, - changes=frozen_payload, - ), - on_success=lambda _result: self._prescription_updated(generation), - on_error=lambda error: self._action_error(error, generation), - on_finished=lambda: self._mutation_finished(generation), - ) - - def _prescription_updated(self, generation: int) -> None: - if generation != self._action_generation: - return - self.banner.show_message("处方已保存并重新进入待审核。", "success") - self.refresh(silent=True) + self.banner.clear() + if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0: + approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1 + voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1 + if not approved or voided: + self._open_existing_prescription_editor(existing) + else: + dialog = PrescriptionDetailDialog( + existing, + can_open_diagnosis=_canonical_allowed( + self.permissions, "tcm.diagnosis/readonlyDetail", default=False + ), + parent=self, + ) + dialog.diagnosis_requested.connect(self._open_diagnosis_id) + dialog.exec() + return + self._begin_case_record_load(row) + + def _open_existing_prescription_editor(self, prescription: Any) -> None: + prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0)) + if prescription_id <= 0: + self.banner.show_message("处方编号不完整,无法编辑。", "danger") + return + dialog = PrescriptionEditorDialog( + self.repository, + prescription, + mode="edit", + current_user=self.current_user, + parent=self, + ) + diagnosis_signal = getattr(dialog, "diagnosis_requested", None) + if diagnosis_signal is not None: + diagnosis_signal.connect(self._open_diagnosis_id) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + frozen_payload = MappingProxyType(dialog.payload()) + self._mutation_pending = True + self._action_generation += 1 + generation = self._action_generation + self.banner.show_message("正在保存处方…", "info") + run_async( + lambda: invoke( + self.repository, + "update_prescription", + prescription=prescription_id, + changes=frozen_payload, + ), + on_success=lambda _result: self._prescription_updated(generation), + on_error=lambda error: self._action_error(error, generation), + on_finished=lambda: self._mutation_finished(generation), + ) + + def _prescription_updated(self, generation: int) -> None: + if generation != self._action_generation: + return + self.banner.show_message("处方已保存并重新进入待审核。", "success") + self.refresh(silent=True) def _begin_case_record_load(self, row: Any) -> None: snapshot = deepcopy(row) @@ -1304,10 +1308,10 @@ class AppointmentsPage(QWidget): "visit_no": build_prescription_visit_no( diagnosis_id=diagnosis_id, appointment_id=appointment_id ), - "tongue": authoritative("tongue", "tongue_coating", default=""), - "tongue_image": authoritative("tongue_image", default=""), - "pulse": authoritative("pulse", default=""), - "pulse_condition": authoritative("pulse_condition", default=""), + "tongue": authoritative("tongue", "tongue_coating", default=""), + "tongue_image": authoritative("tongue_image", default=""), + "pulse": authoritative("pulse", default=""), + "pulse_condition": authoritative("pulse_condition", default=""), "clinical_diagnosis": build_prescription_clinical_diagnosis( diagnosis, patient, record, case_record ), @@ -1318,21 +1322,21 @@ class AppointmentsPage(QWidget): def _open_diagnosis_id(self, diagnosis_id: int) -> None: if diagnosis_id > 0: - self.diagnosis_dialog.open_view_only(diagnosis_id) + self._diagnosis_dialog().open_view_only(diagnosis_id) def _open_prescription_editor(self, record: Any, case_record: Any) -> None: seed = self._prescription_seed(record, case_record) - dialog = PrescriptionEditorDialog( + dialog = PrescriptionEditorDialog( self.repository, seed, mode="add", current_user=self.current_user, - parent=self, - ) - diagnosis_signal = getattr(dialog, "diagnosis_requested", None) - if diagnosis_signal is not None: - diagnosis_signal.connect(self._open_diagnosis_id) - if dialog.exec() != QDialog.DialogCode.Accepted: + parent=self, + ) + diagnosis_signal = getattr(dialog, "diagnosis_requested", None) + if diagnosis_signal is not None: + diagnosis_signal.connect(self._open_diagnosis_id) + if dialog.exec() != QDialog.DialogCode.Accepted: return payload = dialog.payload() payload["diagnosis_id"] = seed["diagnosis_id"] diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index 6e1604e89..d3d026ab0 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -270,19 +270,18 @@ def is_diagnosis_confirmed(record: Any) -> bool: return _as_bool(first_value(record, "diagnosis_confirmed", "confirmed", default=False)) -def prescription_action_label(record: Any) -> str: - audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1) - voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0) - explicit = first_value(record, "has_prescription", default=None) - has_prescription = ( - _as_bool(explicit) - if explicit is not None - else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 - or audit in {0, 1, 2} - ) - if not has_prescription: - return "开方" - return "查看处方" if audit == 1 and voided != 1 else "编辑处方" +def prescription_action_label(record: Any) -> str: + audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1) + voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0) + explicit = first_value(record, "has_prescription", default=None) + has_prescription = ( + _as_bool(explicit) + if explicit is not None + else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2} + ) + if not has_prescription: + return "开方" + return "查看处方" if audit == 1 and voided != 1 else "编辑处方" def can_void_prescription(record: Any) -> bool: @@ -1245,8 +1244,6 @@ class ConsultationsPage(QWidget): page_layout.addWidget(card) page_layout.addStretch(1) - self._diagnosis_dialog = DiagnosisDialog(repository, self) - self._diagnosis_dialog.saved.connect(lambda: self.refresh(silent=True)) self.poll_timer = QTimer(self) self.poll_timer.setInterval(20_000) self.poll_timer.timeout.connect(self._poll_refresh) @@ -2567,6 +2564,15 @@ class ConsultationsPage(QWidget): ) self.video_button.setEnabled(has_record and is_video_available(record) and valid_ids) + @property + def _diagnosis_dialog(self) -> DiagnosisDialog: + dialog = getattr(self, "_diagnosis_dialog_impl", None) + if dialog is None: + dialog = DiagnosisDialog(self.repository, self) + dialog.saved.connect(lambda: self.refresh(silent=True)) + self._diagnosis_dialog_impl = dialog + return dialog + def _open_readonly(self) -> None: if not _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail"): return @@ -2701,56 +2707,56 @@ class ConsultationsPage(QWidget): def _prescription_loaded(self, existing: Any, record: Any, mode: str, generation: int) -> None: if generation != self._prescription_generation: return - self.banner.clear() - self._last_prescription = existing - if mode == "void": - self._confirm_void(existing) - return - if existing is not None: - approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1 - voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1 - if not approved or voided: - self._open_existing_prescription_editor(existing) - else: - detail = PrescriptionDetailDialog( - existing, - can_open_diagnosis=_canonical_allowed( - self.permissions, "tcm.diagnosis/readonlyDetail" - ), - parent=self, - ) - detail.diagnosis_requested.connect(self._open_diagnosis_id) - detail.exec() - return - self._begin_case_record_load(record) - - def _open_existing_prescription_editor(self, prescription: Any) -> None: - prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0)) - if prescription_id <= 0: - self.banner.show_message("处方编号不完整,无法编辑。", "danger") - return - dialog = PrescriptionEditorDialog( - self.repository, - prescription, - mode="edit", - current_user=self.current_user, - parent=self, - ) - diagnosis_signal = getattr(dialog, "diagnosis_requested", None) - if diagnosis_signal is not None: - diagnosis_signal.connect(self._open_diagnosis_id) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - frozen_payload = MappingProxyType(dialog.payload()) - self._run_mutation( - lambda: invoke( - self.repository, - "update_prescription", - prescription=prescription_id, - changes=frozen_payload, - ), - "处方已保存并重新进入待审核。", - ) + self.banner.clear() + self._last_prescription = existing + if mode == "void": + self._confirm_void(existing) + return + if existing is not None: + approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1 + voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1 + if not approved or voided: + self._open_existing_prescription_editor(existing) + else: + detail = PrescriptionDetailDialog( + existing, + can_open_diagnosis=_canonical_allowed( + self.permissions, "tcm.diagnosis/readonlyDetail" + ), + parent=self, + ) + detail.diagnosis_requested.connect(self._open_diagnosis_id) + detail.exec() + return + self._begin_case_record_load(record) + + def _open_existing_prescription_editor(self, prescription: Any) -> None: + prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0)) + if prescription_id <= 0: + self.banner.show_message("处方编号不完整,无法编辑。", "danger") + return + dialog = PrescriptionEditorDialog( + self.repository, + prescription, + mode="edit", + current_user=self.current_user, + parent=self, + ) + diagnosis_signal = getattr(dialog, "diagnosis_requested", None) + if diagnosis_signal is not None: + diagnosis_signal.connect(self._open_diagnosis_id) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + frozen_payload = MappingProxyType(dialog.payload()) + self._run_mutation( + lambda: invoke( + self.repository, + "update_prescription", + prescription=prescription_id, + changes=frozen_payload, + ), + "处方已保存并重新进入待审核。", + ) def _begin_case_record_load(self, record: Any) -> None: record_snapshot = deepcopy(record) @@ -2819,10 +2825,10 @@ class ConsultationsPage(QWidget): "visit_no": build_prescription_visit_no( diagnosis_id=diagnosis_id, appointment_id=appointment_id ), - "tongue": authoritative("tongue", "tongue_coating", default=""), - "tongue_image": authoritative("tongue_image", default=""), - "pulse": authoritative("pulse", default=""), - "pulse_condition": authoritative("pulse_condition", default=""), + "tongue": authoritative("tongue", "tongue_coating", default=""), + "tongue_image": authoritative("tongue_image", default=""), + "pulse": authoritative("pulse", default=""), + "pulse_condition": authoritative("pulse_condition", default=""), "clinical_diagnosis": build_prescription_clinical_diagnosis( diagnosis, patient, record, case_record ), @@ -2836,17 +2842,17 @@ class ConsultationsPage(QWidget): def _open_prescription_editor(self, record: Any, case_record: Any) -> None: seed = self._prescription_seed(record, case_record) - dialog = PrescriptionEditorDialog( + dialog = PrescriptionEditorDialog( self.repository, seed, mode="add", current_user=self.current_user, - parent=self, - ) - diagnosis_signal = getattr(dialog, "diagnosis_requested", None) - if diagnosis_signal is not None: - diagnosis_signal.connect(self._open_diagnosis_id) - if dialog.exec() != QDialog.DialogCode.Accepted: + parent=self, + ) + diagnosis_signal = getattr(dialog, "diagnosis_requested", None) + if diagnosis_signal is not None: + diagnosis_signal.connect(self._open_diagnosis_id) + if dialog.exec() != QDialog.DialogCode.Accepted: return payload = dialog.payload() payload["diagnosis_id"] = seed["diagnosis_id"] diff --git a/app/src/doctor_workstation/ui/pages/patients.py b/app/src/doctor_workstation/ui/pages/patients.py index 04e8315d3..3123a8a13 100644 --- a/app/src/doctor_workstation/ui/pages/patients.py +++ b/app/src/doctor_workstation/ui/pages/patients.py @@ -1112,7 +1112,10 @@ class PatientListWorkspace(QWidget): self.keyword_edit.setClearButtonEnabled(True) self.keyword_edit.returnPressed.connect(self.search) grid.addWidget(self.keyword_edit, 0, 0, 1, 3) - self.status_combo = QComboBox() + # Compatibility-only control: it is intentionally hidden and never + # inserted into a layout, so it needs an explicit parent to avoid + # becoming a transient top-level Windows HWND during page creation. + self.status_combo = QComboBox(card) self.status_combo.addItem("全部状态", "") self.status_combo.addItem("未预约", "unbooked") self.status_combo.addItem("待面诊", "pending_interview") @@ -1464,9 +1467,7 @@ class PatientListWorkspace(QWidget): self.table.set_rows(rows) for row_index in range(self.table.rowCount()): source_item = self.table.item(row_index, 0) - source = ( - source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None - ) + source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None _style_table_cell(self.table, row_index, 2, _patient_status(source)[1]) self.pager.update_state(self._page, page_total(result, len(rows))) self.content_stack.setCurrentIndex(0 if rows else 1) @@ -1776,9 +1777,7 @@ class PatientOrdersWorkspace(QWidget): self.table.set_rows(rows) for row_index in range(self.table.rowCount()): source_item = self.table.item(row_index, 0) - source = ( - source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None - ) + source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None prescription_audit = _as_int(first_value(source, "prescription_audit_status"), -1) payment_audit = _as_int(first_value(source, "payment_slip_audit_status"), -1) fulfillment = _as_int(first_value(source, "fulfillment_status"), -1) @@ -1795,9 +1794,7 @@ class PatientOrdersWorkspace(QWidget): _style_table_cell( self.table, row_index, 4, audit_kinds.get(prescription_audit, "muted") ) - _style_table_cell( - self.table, row_index, 5, audit_kinds.get(payment_audit, "muted") - ) + _style_table_cell(self.table, row_index, 5, audit_kinds.get(payment_audit, "muted")) _style_table_cell(self.table, row_index, 6, fulfillment_kind) self.pager.update_state(self._page, page_total(result, len(rows))) self.content_stack.setCurrentIndex(0 if rows else 1) @@ -2179,9 +2176,7 @@ class PatientProgressWorkspace(QWidget): self.queue_table.set_rows(rows) for row_index in range(self.queue_table.rowCount()): source_item = self.queue_table.item(row_index, 0) - source = ( - source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None - ) + source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None queue_status = str(first_value(source, "queue_status", default="") or "").lower() status_kind = { "consulting": "success", @@ -2296,8 +2291,6 @@ class PatientsPage(QWidget): self.tabs.addTab(self.progress_workspace, "面诊进度") root.addWidget(self.tabs, 1) - self.diagnosis_dialog = DiagnosisDialog(repository, self) - self.diagnosis_dialog.saved.connect(self._after_mutation) self.patient_workspace.diagnosis_requested.connect(self._open_diagnosis) self.patient_workspace.appointment_requested.connect(self._book_appointment) self.patient_workspace.assign_requested.connect(self._load_assistants) @@ -2338,6 +2331,14 @@ class PatientsPage(QWidget): # Never fall back to patient_id — that id is a different resource. return _as_int(first_value(row, "diagnosis_id", default=first_value(row, "id", default=0))) + def _ensure_diagnosis_dialog(self) -> DiagnosisDialog: + dialog = getattr(self, "diagnosis_dialog", None) + if dialog is None: + dialog = DiagnosisDialog(self.repository, self) + dialog.saved.connect(self._after_mutation) + self.diagnosis_dialog = dialog + return dialog + def _open_diagnosis(self, row: Any, editable: bool) -> None: diagnosis_id = self._diagnosis_id(row) if diagnosis_id <= 0: @@ -2348,9 +2349,9 @@ class PatientsPage(QWidget): show_toast(self, "当前账号没有该诊单权限。", "danger") return if editable: - self.diagnosis_dialog.open_for(diagnosis_id, editable=True, seed=row) + self._ensure_diagnosis_dialog().open_for(diagnosis_id, editable=True, seed=row) else: - self.diagnosis_dialog.open_view_only(diagnosis_id, seed=row) + self._ensure_diagnosis_dialog().open_view_only(diagnosis_id, seed=row) def _open_order_diagnosis(self, row: Any) -> None: editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit") diff --git a/app/src/doctor_workstation/ui/pages/reception.py b/app/src/doctor_workstation/ui/pages/reception.py index f8cfab25c..247895ac4 100644 --- a/app/src/doctor_workstation/ui/pages/reception.py +++ b/app/src/doctor_workstation/ui/pages/reception.py @@ -327,13 +327,18 @@ class ReceptionPage(QWidget): splitter.setSizes([350, 760]) root.addWidget(splitter, 1) - self.diagnosis_dialog = DiagnosisDialog(repository, self) - self.diagnosis_dialog.saved.connect(self._diagnosis_saved) - self.poll_timer = QTimer(self) self.poll_timer.setInterval(5_000) self.poll_timer.timeout.connect(lambda: self.refresh(silent=True)) + def _ensure_diagnosis_dialog(self) -> DiagnosisDialog: + dialog = getattr(self, "diagnosis_dialog", None) + if dialog is None: + dialog = DiagnosisDialog(self.repository, self) + dialog.saved.connect(self._diagnosis_saved) + self.diagnosis_dialog = dialog + return dialog + def _build_queue_panel(self) -> QWidget: panel = QFrame() panel.setObjectName("Card") @@ -1355,9 +1360,7 @@ class ReceptionPage(QWidget): image_grid.setContentsMargins(0, 0, 0, 0) image_grid.setHorizontalSpacing(10) image_grid.setVerticalSpacing(10) - for index, (image_type, caption, path_text) in enumerate( - image_attachments - ): + for index, (image_type, caption, path_text) in enumerate(image_attachments): tile = QFrame() tile.setObjectName("NoteAttachmentTile") tile_layout = QVBoxLayout(tile) @@ -1376,13 +1379,9 @@ class ReceptionPage(QWidget): label = QLabel(f"{caption} · {_attachment_name(path_text)}") label.setObjectName("NoteAttachmentName") label.setToolTip(path_text) - label.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse - ) + label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) label.setMinimumWidth(0) - label.setSizePolicy( - QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred - ) + label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) footer.addWidget(label, 1) if self._can_note and note_id is not None: delete_button = QPushButton("删除") @@ -1448,9 +1447,7 @@ class ReceptionPage(QWidget): return run_async( lambda: invoke(self.repository, "download_public_image", url=path), - on_success=lambda payload: self._apply_note_thumbnail( - payload, preview, generation - ), + on_success=lambda payload: self._apply_note_thumbnail(payload, preview, generation), on_error=lambda _error: self._fail_note_thumbnail(preview, generation), ) @@ -1493,11 +1490,7 @@ class ReceptionPage(QWidget): return if not _is_image_attachment(target): url = QUrl(target) - if ( - not url.isValid() - or url.scheme().lower() not in {"https", "http"} - or not url.host() - ): + if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host(): show_toast(self, "附件地址无效,无法打开。", "warning", 4200) return if not QDesktopServices.openUrl(url): @@ -1520,9 +1513,7 @@ class ReceptionPage(QWidget): on_success=lambda payload: self._show_note_image_preview( target, payload, button, generation ), - on_error=lambda error: self._note_image_preview_failed( - error, button, generation - ), + on_error=lambda error: self._note_image_preview_failed(error, button, generation), ) def _show_note_image_preview( @@ -1974,7 +1965,7 @@ class ReceptionPage(QWidget): if context is None or context[2] is None: show_toast(self, "当前患者缺少诊单编号。", "danger") return - self.diagnosis_dialog.open_for( + self._ensure_diagnosis_dialog().open_for( context[2], editable=True, seed=self._selected_detail or self._selected_record ) diff --git a/app/src/doctor_workstation/ui/shell.py b/app/src/doctor_workstation/ui/shell.py index a69182e24..31d5280e0 100644 --- a/app/src/doctor_workstation/ui/shell.py +++ b/app/src/doctor_workstation/ui/shell.py @@ -559,6 +559,8 @@ class ShellWindow(QMainWindow): parent: QWidget | None = None, ) -> None: super().__init__(parent) + self.setWindowTitle("甄养堂 · 医生工作站") + self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) self.repository = repository self.login_payload = session self.session = get_value(session, "session", None) or session @@ -595,7 +597,6 @@ class ShellWindow(QMainWindow): self._fixed_tab_key: str | None = None self._sidebar_collapsed = False - self.setWindowTitle("甄养堂 · 医生工作站") self.setMinimumSize(1024, 640) self.resize(1280, 800) @@ -1033,6 +1034,7 @@ class ShellWindow(QMainWindow): self.repository, permissions=self.permissions, current_user=self.current_user, + parent=self.stack, ) if hasattr(page, "video_requested"): page.video_requested.connect(lambda payload: self.video_requested.emit(payload)) @@ -1109,6 +1111,11 @@ class ShellWindow(QMainWindow): if callable(refresh): refresh() + def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API + if visible: + self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False) + super().setVisible(visible) + def set_connection_state(self, online: bool, message: str = "") -> None: self.connection_badge.set_status( message or ("服务正常" if online else "连接中断"), diff --git a/app/src/doctor_workstation/ui/widgets.py b/app/src/doctor_workstation/ui/widgets.py index fe55d1553..ffca2639d 100644 --- a/app/src/doctor_workstation/ui/widgets.py +++ b/app/src/doctor_workstation/ui/widgets.py @@ -10,13 +10,12 @@ from datetime import date, datetime from typing import Any from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot -from PySide6.QtGui import QResizeEvent +from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent from PySide6.QtWidgets import ( QAbstractItemView, QFrame, QHBoxLayout, QLabel, - QProgressBar, QPushButton, QSizePolicy, QTableWidget, @@ -543,30 +542,74 @@ def show_toast(parent: QWidget, text: str, kind: str = "info", duration: int = 2 toast.show_message(text, kind, duration) +class _BusyTrack(QWidget): + """Indeterminate bar painted in-process, without Windows QProgressBar HWNDs. + + ``QProgressBar.setRange(0, 0)`` uses the native Vista/Win11 animation, which + creates extra untitled windows titled with the application name. + """ + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setObjectName("BusyOverlayProgress") + self.setFixedSize(140, 6) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._phase = 0.0 + self._timer = QTimer(self) + self._timer.setInterval(32) + self._timer.timeout.connect(self._tick) + + def showEvent(self, event: Any) -> None: + self._timer.start() + super().showEvent(event) + + def hideEvent(self, event: Any) -> None: + self._timer.stop() + super().hideEvent(event) + + def _tick(self) -> None: + self._phase = (self._phase + 0.035) % 1.0 + self.update() + + def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + rect = self.rect() + painter.setBrush(QColor("#D8DEEA")) + painter.drawRoundedRect(rect, 3, 3) + chunk_width = max(36, int(rect.width() * 0.32)) + span = rect.width() + chunk_width + x = int(self._phase * span) - chunk_width + painter.setBrush(QColor("#4F63D9")) + painter.drawRoundedRect(x, 0, chunk_width, rect.height(), 3, 3) + + class BusyOverlay(QFrame): """Non-blocking visual guard for a card or page while a worker is active.""" def __init__(self, parent: QWidget, text: str = "正在加载…") -> None: super().__init__(parent) self.setObjectName("BusyOverlay") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setSpacing(10) - self.label = QLabel(text) + self.label = QLabel(text, self) self.label.setProperty("role", "muted") - progress = QProgressBar() - progress.setObjectName("BusyOverlayProgress") - progress.setRange(0, 0) - progress.setFixedWidth(140) + self.progress = _BusyTrack(self) layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter) - layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignCenter) self.hide() def set_message(self, text: str) -> None: self.label.setText(text) def showEvent(self, event: Any) -> None: - self.setGeometry(self.parentWidget().rect()) + parent = self.parentWidget() + if parent is not None: + self.setGeometry(parent.rect()) self.raise_() super().showEvent(event) diff --git a/app/tests/test_busy_overlay.py b/app/tests/test_busy_overlay.py new file mode 100644 index 000000000..c0acadd8d --- /dev/null +++ b/app/tests/test_busy_overlay.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication, QWidget + +from doctor_workstation.ui.login import LoginWindow +from doctor_workstation.ui.widgets import BusyOverlay + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +def _visible_windows(application: QApplication) -> list[QWidget]: + return [ + widget + for widget in application.topLevelWidgets() + if widget.isWindow() and widget.isVisible() + ] + + +def test_busy_overlay_children_are_not_windows(application: QApplication) -> None: + host = QWidget() + host.resize(360, 240) + host.show() + application.processEvents() + before = {id(widget) for widget in _visible_windows(application)} + + overlay = BusyOverlay(host, "正在验证账号…") + overlay.setVisible(True) + application.processEvents() + + assert overlay.parentWidget() is host + assert not overlay.isWindow() + assert overlay.progress.objectName() == "BusyOverlayProgress" + assert overlay.progress.parentWidget() is overlay + assert overlay.label.parentWidget() is overlay + assert not overlay.progress.isWindow() + assert not overlay.label.isWindow() + extra = [ + widget + for widget in _visible_windows(application) + if id(widget) not in before and widget is not host + ] + assert extra == [] + + overlay.setVisible(False) + host.close() + application.processEvents() + + +def test_login_loading_does_not_spawn_extra_windows( + application: QApplication, tmp_path: Any +) -> None: + settings = QSettings(str(tmp_path / "login.ini"), QSettings.Format.IniFormat) + window = LoginWindow( + object(), + config=SimpleNamespace( + api_base_url="https://127.0.0.1:9", + request_timeout=30, + demo_mode=False, + remembered_account="admin", + ), + settings=settings, + ) + window.show() + application.processEvents() + before = {id(widget) for widget in _visible_windows(application)} + + window._set_loading(True) + application.processEvents() + + extra = [widget for widget in _visible_windows(application) if id(widget) not in before] + assert extra == [] + assert window.busy_overlay.isVisible() + assert not window.busy_overlay.isWindow() + assert not window.busy_overlay.progress.isWindow() + assert window.busy_overlay.label.text() == "正在验证账号…" + + window.close() + application.processEvents() diff --git a/app/tests/test_prescription_ui.py b/app/tests/test_prescription_ui.py index 2c9feb67d..7fd1cd7eb 100644 --- a/app/tests/test_prescription_ui.py +++ b/app/tests/test_prescription_ui.py @@ -7,9 +7,12 @@ from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import Qt +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QColor, QImage, QPainter +from PySide6.QtPdf import QPdfDocument from PySide6.QtWidgets import QApplication +from doctor_workstation import app as app_module from doctor_workstation.core import PermissionSet from doctor_workstation.services import DemoDoctorRepository from doctor_workstation.ui.dialogs import prescription as dialog_module @@ -530,8 +533,9 @@ def test_order_payload_and_a4_print_document( assert "林晓岚" in rendered assert "黄芪" in rendered assert "酸枣仁" in rendered - assert "服药前请核对姓名、电话、医生等信息" in rendered - assert '' in rendered assert "Rp." in rendered assert "药房联" in rendered @@ -539,18 +543,93 @@ def test_order_payload_and_a4_print_document( assert "辅方" in rendered assert "105克" in rendered assert "84克" in rendered - assert "单剂量: 27克" in rendered + assert "剂量: 27克" in rendered assert "每天2次, 一次2袋, 每袋5g, 温水送服, 饭后" in rendered + assert "主服法:" in rendered + assert "辅服法:" in rendered + assert "处方编号:" in rendered + assert "流转编号(挂号):" in rendered + assert "成都双流甄养堂互联网医院有限公司 联系方式:4001667339" in rendered + assert "四川省成都市双流区黄甲街道黄龙大道二段280号" in rendered + user_rendered = render_prescription_html(prescription, variant="user") + assert "成都双流甄养堂互联网医院 处方笺" in user_rendered + assert "用量" in user_rendered + assert "总量" not in user_rendered + assert "主服法" not in user_rendered + assert "类型:" not in user_rendered + assert "黄芪 (15克)" not in user_rendered + assert "黄芪" in user_rendered viewer = PrescriptionDetailDialog(prescription) document_html = viewer.document.toHtml() assert "用药 (单剂)" in document_html - assert viewer.document.idealWidth() == pytest.approx(794.0) assert viewer.preview.__class__.__name__ == "_PrescriptionPaperPreview" + assert [viewer.tabs.tabText(index) for index in range(2)] == ["药房联", "处方联"] viewer.close() order.close() application.processEvents() +def test_prescription_pdf_export_writes_visible_content( + application: QApplication, + tmp_path: Any, +) -> None: + prescription = { + "id": 12, + "sn": "CF-12", + "patient_name": "林晓岚", + "phone": "13800000000", + "gender": 0, + "age": 33, + "clinical_diagnosis": "脾气虚", + "doctor_name": "周医生", + "audit_status": 1, + "dose_count": 7, + "herbs": [{"name": "黄芪", "dosage": 15, "formula_type": "主方"}], + } + image = dialog_module.render_prescription_slip_image(prescription) + ink = dialog_module.count_slip_ink_pixels(image) + page = QImage(1240, 1754, QImage.Format.Format_RGB32) + page.fill(QColor("#ffffff")) + painter = QPainter(page) + dialog_module._draw_slip_image_on_page(painter, image) + painter.end() + page_ink = dialog_module.count_slip_ink_pixels(page) + png_path = tmp_path / "prescription-slip.png" + assert image.save(str(png_path), "PNG") + viewer = PrescriptionDetailDialog(prescription) + output = tmp_path / "prescription-slip.pdf" + app_module._install_chinese_translations(application) + viewer.export_pdf(output) + payload = output.read_bytes() + pdf = QPdfDocument() + assert pdf.load(str(output)) == QPdfDocument.Error.None_ + assert pdf.pageCount() == 1 + page_size = pdf.pagePointSize(0) + rendered_page = pdf.render(0, QSize(1240, 1754)) + visible_ink = sum( + 1 + for y in range(0, rendered_page.height(), 3) + for x in range(0, rendered_page.width(), 3) + if (color := rendered_page.pixelColor(x, y)).alpha() > 0 and color.lightness() < 245 + ) + viewer.close() + application.processEvents() + + assert dialog_module._ensure_slip_fonts() + assert image.format() == QImage.Format.Format_RGB32 + assert image.width() >= 700 + assert image.height() >= 400 + assert ink > 200 + assert page_ink > 200 + assert png_path.stat().st_size > 40_000 + assert payload.startswith(b"%PDF") + assert b"/Image" in payload or b"/XObject" in payload + assert len(payload) > 50_000 + assert page_size.width() == pytest.approx(595.0, abs=2.0) + assert page_size.height() == pytest.approx(842.0, abs=2.0) + assert visible_ink > 1_000 + + def test_prescription_detail_can_open_immutable_case_record_tab( application: QApplication, ) -> None: @@ -569,7 +648,9 @@ def test_prescription_detail_can_open_immutable_case_record_tab( } viewer = PrescriptionDetailDialog(prescription, initial_tab="case") - assert viewer.tabs.count() == 2 + assert viewer.tabs.count() == 3 + assert viewer.tabs.tabText(0) == "药房联" + assert viewer.tabs.tabText(1) == "处方联" assert viewer.tabs.tabText(viewer.tabs.currentIndex()) == "详细病历" assert viewer.case_document is not None case_html = viewer.case_document.toHtml() diff --git a/app/tests/test_shell_contract.py b/app/tests/test_shell_contract.py index 3bb2b536d..011b066f9 100644 --- a/app/tests/test_shell_contract.py +++ b/app/tests/test_shell_contract.py @@ -19,8 +19,9 @@ class _ShellPageDouble(QWidget): *, permissions: Any, current_user: Any, + parent: QWidget | None = None, ) -> None: - super().__init__() + super().__init__(parent) self.permissions = permissions self.current_user = current_user self.refresh_count = 0 @@ -93,6 +94,12 @@ def test_shell_matches_admin_geometry_at_both_acceptance_sizes( assert image.pixelColor(220, 110).name().lower() == "#f5f7fb" +def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None: + for page in shell_window.pages.values(): + assert page.parentWidget() is shell_window.stack + assert not page.isWindow() + + def test_every_visible_page_navigates_and_visited_tabs_track_active_page( shell_window: ShellWindow, ) -> None: diff --git a/app/tests/test_ui_contract.py b/app/tests/test_ui_contract.py index 3913f3886..9ead53f57 100644 --- a/app/tests/test_ui_contract.py +++ b/app/tests/test_ui_contract.py @@ -3,7 +3,8 @@ from __future__ import annotations from types import SimpleNamespace from typing import Any -from PySide6.QtCore import QSettings +from PySide6.QtCore import QCoreApplication, QSettings +from PySide6.QtGui import QPageSize from PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox from doctor_workstation import app as app_module @@ -319,7 +320,9 @@ def test_server_settings_panel_keeps_controls_separated_at_minimum_window( assert window.server_url_label.geometry().bottom() < window.server_url_edit.geometry().top() assert window.server_url_edit.geometry().bottom() < window.timeout_label.geometry().top() assert window.timeout_spin.geometry().right() < window.save_server_button.geometry().left() - assert window.timeout_label.geometry().bottom() < window.allow_self_signed_check.geometry().top() + assert ( + window.timeout_label.geometry().bottom() < window.allow_self_signed_check.geometry().top() + ) assert window.allow_self_signed_check.geometry().bottom() < window.server_hint.geometry().top() window.allow_self_signed_check.setChecked(True) @@ -425,9 +428,7 @@ def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None: app_module._install_chinese_translations(application) question = QMessageBox() - question.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) + question.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) assert question.button(QMessageBox.StandardButton.Yes).text() == "是" assert question.button(QMessageBox.StandardButton.Cancel).text() == "取消" @@ -439,12 +440,12 @@ def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None: assert buttons.button(QDialogButtonBox.StandardButton.Ok).text() == "确定" assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存" assert buttons.button(QDialogButtonBox.StandardButton.Close).text() == "关闭" + assert QCoreApplication.translate("QPageSize", "A4") == "A4" + assert QPageSize(QPageSize.PageSizeId.A4).isValid() def test_friendly_error_hides_english_technical_messages() -> None: - message = friendly_error( - TypeError("invoke() takes 2 positional arguments but 3 were given") - ) + message = friendly_error(TypeError("invoke() takes 2 positional arguments but 3 were given")) assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。" assert friendly_error(RuntimeError("API response envelope must be an object")) == ( "服务器返回的数据格式不正确,请联系管理员检查接口。" @@ -463,9 +464,7 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None: ) window = LoginWindow(object(), config=config, settings=settings) - window._on_login_error( - RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate") - ) + window._on_login_error(RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate")) assert window.server_toggle.isChecked() assert not window.server_panel.isHidden() diff --git a/app/处方-张卫君-7556.pdf b/app/处方-张卫君-7556.pdf new file mode 100644 index 000000000..6e8d33628 Binary files /dev/null and b/app/处方-张卫君-7556.pdf differ diff --git a/server/app/adminapi/logic/stats/ConversionLogic.php b/server/app/adminapi/logic/stats/ConversionLogic.php index 2b3cef6ae..1ad7596c9 100755 --- a/server/app/adminapi/logic/stats/ConversionLogic.php +++ b/server/app/adminapi/logic/stats/ConversionLogic.php @@ -961,6 +961,8 @@ class ConversionLogic * * 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数): * - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重; + * - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间, + * 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计); * - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件); * - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。 * 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中; @@ -987,7 +989,7 @@ class ConversionLogic } } - $baseKey = self::requestRowsCacheKey('fans-effective-v3', [ + $baseKey = self::requestRowsCacheKey('fans-effective-v4', [ $startTimestamp, $endTimestamp, self::mediaChannelCacheKey($mediaChannel), @@ -1050,6 +1052,14 @@ class ConversionLogic . ' AND del_e.event_time <= ?)', ['del_external_contact', $endTimestamp] ) + ->whereRaw( + 'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e' + . ' WHERE prev_e.user_id = e.user_id' + . ' AND prev_e.external_userid = e.external_userid' + . ' AND prev_e.change_type = ?' + . ' AND prev_e.event_time < ?)', + ['add_external_contact', $startTimestamp] + ) ->field(['e.user_id', 'e.external_userid']) ->group('e.user_id, e.external_userid'); if ($workWechatUserIds !== null) {