This commit is contained in:
Your Name
2026-08-13 11:54:00 +08:00
parent 07188e82f8
commit 72a5300b08
19 changed files with 1441 additions and 577 deletions
+204
View File
@@ -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")
+87
View File
@@ -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))
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+157 -153
View File
@@ -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)
@@ -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('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
rows.append(f'<tr>{"".join(pending)}</tr>')
pending.extend(
'<td class="cr-item empty"></td>' for _ in range(3 - len(pending))
)
rows.append(f"<tr>{''.join(pending)}</tr>")
pending = []
rows.append(f'<tr>{field_cell(label, field_value, colspan=3)}</tr>')
rows.append(f"<tr>{field_cell(label, field_value, colspan=3)}</tr>")
continue
pending.append(field_cell(label, field_value))
if len(pending) == 3:
rows.append(f'<tr>{"".join(pending)}</tr>')
rows.append(f"<tr>{''.join(pending)}</tr>")
pending = []
if pending:
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
rows.append(f'<tr>{"".join(pending)}</tr>')
rows.append(f"<tr>{''.join(pending)}</tr>")
return (
'<table class="cr-section" width="100%" cellspacing="0" cellpadding="0">'
f'<tr><td class="cr-section-title">{html.escape(title)}</td></tr>'
'<tr><td><table class="cr-grid" width="100%" cellspacing="0" cellpadding="0">'
f'{"".join(rows)}</table></td></tr>'
f"{''.join(rows)}</table></td></tr>"
'<tr><td class="cr-rule"></td></tr></table>'
)
@@ -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; }}
</style></head><body>
<table class="paper" width="100%" cellspacing="0" cellpadding="0"><tr>
<td class="paper-content"><h1>甄养堂 详细病历</h1>{''.join(sections)}</td>
<td class="paper-content"><h1>甄养堂 详细病历</h1>{"".join(sections)}</td>
</tr></table></body></html>
"""
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('<td class="rp-gap"></td>')
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'<td class="herb-name">{esc(row.get("name"))} ({dosage}克)</td>',
f'<td class="herb-total">{total}</td>',
f'<td class="herb-name">{name_html}</td>',
f'<td class="herb-total">{qty_html}</td>',
)
)
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'<img class="signature" src="{html.escape(signature, quote=True)}" alt="医师签名" />'
@@ -3155,25 +3199,76 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
else f'<span class="doctor-name">{esc(source.get("doctor_name"))}</span>'
)
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 = (
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="5">'
"暂无药材明细</td></tr>"
)
text_rows = [f"<p>主方服法:{esc(main_usage_text)}</p>"]
if aux_usage_text:
text_rows.append(f"<p>辅方服法:{esc(aux_usage_text)}</p>")
text_rows: list[str] = []
if is_internal:
if aux_usage_text:
text_rows.append(f"<p>主服法:{esc(main_usage_text)}</p>")
text_rows.append(f"<p>辅服法:{esc(aux_usage_text)}</p>")
else:
text_rows.append(f"<p>服法:{esc(main_usage_text)}</p>")
if advice:
text_rows.append(f"<p>医嘱:{esc(advice)}</p>")
if dietary:
text_rows.append(f"<p>忌口:{esc(dietary)}</p>")
text_rows.append(f"<p>备注:{esc(' '.join(remark_parts))}</p>")
if is_internal:
text_rows.append(f"<p>备注:{esc(' '.join(remark_parts))}</p>")
if pharmacy_remark:
text_rows.append(f'<p class="warning">药房备注:{esc(pharmacy_remark)}</p>')
if out_pellet:
if is_internal and out_pellet and prescription_type != "饮片":
text_rows.append(f'<p class="warning">出丸:{esc(out_pellet)}</p>')
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'<td class="{klass}" width="50%" valign="top">'
f'<font color="#6b7280">{html.escape(label)}</font><br/>'
f"<b>{esc(value)}</b></td>"
)
meta_rows = (
f"<tr>{meta_td('日期:', date_text())}"
f"{meta_td('处方编号:', prescription_sn, split=True)}</tr>"
f"<tr>{meta_td('流转编号(挂号):', flow_text)}"
f"{meta_td('业务单号:', business_no or '', split=True)}</tr>"
)
if is_internal:
bottom_row = (
f'<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>'
f'<td><span class="meta-key">类型:</span> {esc(type_text)}</td>'
f'<td><span class="meta-key">天数:</span> {html.escape(days_text)}</td>'
f'<td><span class="meta-key">剂量:</span> {per_dose}克</td>'
)
hospital_colspan = 4
else:
bottom_row = (
f'<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>'
f'<td><span class="meta-key">天数:</span> {html.escape(days_text)}</td>'
)
hospital_colspan = 2
family = html.escape(_ensure_slip_fonts())
title_html = (
f'<p class="rx-title" align="center"><font face="{family}" size="6"><b>{html.escape(slip_title)}</b></font></p>'
if is_internal
else f'<p class="rx-title" align="center"><font face="{family}" size="5"><b>{html.escape(slip_title)}</b></font></p>'
)
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'<div class="audit">{"<br/>".join(audit_lines)}</div>'
@@ -3191,91 +3288,292 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
<!doctype html>
<html><head><meta charset="utf-8"><style>
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1f1f1f;
font-family:"Microsoft YaHei","PingFang SC",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-family:"{family}","Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
font-size:{base_font_size}; line-height:1.5; }}
.paper {{ width:{paper_width}; margin:0 auto; background:#ffffff;
border:{paper_border}; border-collapse:separate; border-spacing:0;
box-shadow:{"none" if print_layout else "0 0 0 1px #d6d6d6"}; }}
.paper-content {{ padding:8mm 10mm; vertical-align:top; }}
border:{paper_border}; border-collapse:separate; border-spacing:0; }}
.paper-content {{ padding:{content_padding}; vertical-align:top; }}
.rx-title {{ text-align:center; margin:4px 0 16px; color:#1f1f1f; }}
.notice {{ width:100%; border:1px solid #e5e7eb; border-collapse:collapse;
table-layout:fixed; background:#f3f4f6; margin:0 0 6px;
font-size:{notice_font_size}; color:#1f1f1f; }}
.notice td {{ border:0; padding:6px 10px; }}
.notice-text {{ width:49%; }}
.notice-meta {{ width:51%; text-align:right; white-space:nowrap; }}
table-layout:fixed; background:#f3f4f6; margin:0 0 10px; }}
.notice-text {{ padding:10px 12px; font-size:{notice_font_size}; color:#374151;
line-height:1.6; }}
.meta-item {{ padding:8px 12px 10px; vertical-align:top; border-top:1px solid #e5e7eb; }}
.meta-split {{ border-left:1px solid #d1d5db; }}
.meta-label {{ color:#6b7280; font-size:{meta_font_size}; margin:0; padding:0; }}
.meta-value {{ color:#111827; font-size:{meta_font_size}; font-weight:600;
margin:2px 0 0; padding:0; }}
.info {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; font-size:13px; }}
.info .full {{ border-top:0; }}
.key {{ white-space:nowrap; color:#1f1f1f; }}
.info td {{ border:1px solid #c8c8c8; padding:{info_padding}; vertical-align:middle; font-size:13px; }}
.key {{ color:#6b7280; }}
.rp-frame {{ width:100%; border-collapse:collapse; table-layout:fixed;
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
.rp-padding {{ padding:8px 10px 16px; }}
.rp-padding {{ padding:6px 10px 12px; }}
.rp {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.rp td {{ border:0; padding:3px 0; vertical-align:middle; }}
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:4px; padding-bottom:4px; }}
.rp td {{ border:0; padding:2px 0; vertical-align:middle; }}
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding:6px 0 8px; }}
.rp-indent {{ width:44px; }}
.rp-mark {{ width:44px; padding-right:8px !important;
font-size:{rp_font_size}; font-weight:700; color:#1f1f1f; }}
.drug-head {{ color:#1f1f1f; }}
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap;
font-variant-numeric:tabular-nums; }}
.drug-head {{ color:#4b5563; }}
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap; }}
.rp-gap {{ width:24px; padding:0 !important; }}
.section {{ padding-top:7px !important; padding-bottom:1px !important;
.section {{ padding-top:8px !important; padding-bottom:2px !important;
font-size:{section_font_size}; font-weight:600; }}
.section.main {{ color:#409eff; }}
.section.aux {{ color:#e6a23c; }}
.section.main {{ color:#2563eb; }}
.section.aux {{ color:#d97706; }}
.herb-name {{ line-height:1.85; white-space:nowrap; color:#1f1f1f; }}
.empty-herbs {{ color:#8a8f98; padding:18px 6px !important; text-align:center; }}
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:10px 12px; line-height:1.85; font-size:13px; }}
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:10px 12px; line-height:1.75; font-size:13px; }}
.rx-text p {{ margin:0; padding:0; }}
.warning {{ color:#d72424; font-weight:600; }}
.bottom {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:6px 10px;
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:8px 12px;
height:{bottom_height}; vertical-align:middle; font-size:12px; color:#1f1f1f; }}
.bottom .doctor {{ width:28%; vertical-align:top; }}
.doctor-title {{ display:block; margin-bottom:4px; font-size:13px; }}
.doctor-name {{ display:block; margin-top:8px; font-size:13px; }}
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; object-fit:contain; }}
.meta-key {{ white-space:nowrap; margin-right:6px; }}
.bottom .doctor {{ width:40%; }}
.doctor-title {{ margin-right:8px; color:#6b7280; }}
.doctor-name {{ font-size:13px; }}
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; }}
.meta-key {{ color:#6b7280; margin-right:6px; }}
.hospital {{ height:auto; vertical-align:top; padding:10px 12px; font-size:12px;
color:#6b7280; line-height:1.65; }}
.audit {{ width:{paper_width}; margin:12px auto 0; padding:8px 12px; color:#6b7280;
border:1px dashed #d4d4d4; background:#fafafa; border-radius:4px;
border:1px dashed #d4d4d4; background:#fafafa;
font-size:12px; line-height:1.6; }}
</style></head><body>
<table align="center" class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
<table class="notice" width="100%"><tr>
<td class="notice-text">服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点</td>
<td class="notice-meta">日期:{esc(date_text())}  编号:{esc(serial)}</td>
</tr></table>
<table class="info" width="100%">
{title_html}
<table class="notice" width="100%" cellspacing="0" cellpadding="0" bgcolor="#f3f4f6">
<tr><td class="notice-text" colspan="{notice_colspan}">{html.escape(notice_text)}</td></tr>
{meta_rows}
</table>
<table class="info" width="100%" cellspacing="0" cellpadding="0">
<tr><td><span class="key">姓名</span> {esc(source.get("patient_name"))}</td>
<td><span class="key">性别</span> {esc(gender_text)}</td>
<td><span class="key">年龄</span> {esc(age_text)}</td>
<td><span class="key">电话</span> {esc(source.get("phone"))}</td></tr>
<tr><td class="full" colspan="4"><span class="key">收件信息</span> {esc(recipient_text)}</td></tr>
<tr><td class="full" colspan="4"><span class="key">临床诊断</span> {esc(source.get("clinical_diagnosis"))}</td></tr>
<td><span class="key">电话</span> {esc(phone_text)}</td></tr>
<tr><td colspan="4"><span class="key">收件信息</span> {esc(recipient_text)}</td></tr>
<tr><td colspan="4"><span class="key">临床诊断</span> {esc(source.get("clinical_diagnosis"))}</td></tr>
</table>
<table class="rp-frame" title="药房联" width="100%"><tr><td class="rp-padding">
<table class="rp" width="100%">
<table class="rp-frame" title="{html.escape(copy_label)}" width="100%" cellspacing="0" cellpadding="0"><tr><td class="rp-padding">
<table class="rp" width="100%" cellspacing="0" cellpadding="0">
<col width="44"/><col/><col width="64"/><col width="24"/><col/><col width="64"/>
<tr class="rp-head"><td class="rp-mark">Rp.</td><td class="drug-head">用药 (单剂)</td>
<td class="total-head">总量</td><td class="rp-gap"></td>
<td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
<td class="total-head">{qty_head}</td><td class="rp-gap"></td>
<td class="drug-head">用药 (单剂)</td><td class="total-head">{qty_head}</td></tr>
{herb_html}
</table>
</td></tr></table>
<div class="rx-text">{"".join(text_rows)}</div>
<table class="bottom" width="100%"><tr>
<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>
<td><span class="meta-key">类型:</span> {esc(type_text)}</td>
<td><span class="meta-key">天数:</span> {dose_count}剂</td>
<td><span class="meta-key">单剂量:</span> {per_dose}克</td>
</tr></table>
<table class="bottom" width="100%" cellspacing="0" cellpadding="0"><tr>
{bottom_row}
</tr>
<tr><td class="hospital" colspan="{hospital_colspan}">
{_SLIP_COMPANY_LINE}<br/>{_SLIP_ADDRESS_LINE}
</td></tr></table>
</td></tr></table>
{audit_html}
</body></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",
]
@@ -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"]
@@ -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"]
+18 -17
View File
@@ -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")
@@ -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
)
+8 -1
View File
@@ -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 "连接中断"),
+52 -9
View File
@@ -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)
+89
View File
@@ -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()
+87 -6
View File
@@ -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 '<table align="center" class="paper" width="794" height="1123"' in rendered
assert "服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点" in rendered
assert '<table align="center" class="paper" width="100%"' in rendered
assert 'height="1123"' not in rendered
assert '<col width="44"/><col/><col width="64"/><col width="24"/>' 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 "剂量:</span> 27克" in rendered
assert "剂量:</span> 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()
+8 -1
View File
@@ -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:
+10 -11
View File
@@ -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()
Binary file not shown.