更新
This commit is contained in:
@@ -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")
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 "连接中断"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user