更新
This commit is contained in:
@@ -145,7 +145,7 @@
|
|||||||
<div class="panel-heading panel-heading--table">
|
<div class="panel-heading panel-heading--table">
|
||||||
<div>
|
<div>
|
||||||
<h2>明细数据列表</h2>
|
<h2>明细数据列表</h2>
|
||||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户与继承客户);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户、继承客户及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,7 +330,7 @@ const timeOptions = [
|
|||||||
{ label: '自定义', value: 'custom' }
|
{ label: '自定义', value: 'custom' }
|
||||||
]
|
]
|
||||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户与继承客户' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户及区间前已加过的重加' },
|
||||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||||
|
|||||||
@@ -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 contextlib import suppress
|
||||||
from typing import Any
|
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.QtGui import QGuiApplication, QIcon
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -43,71 +43,73 @@ from doctor_workstation.ui.widgets import (
|
|||||||
from doctor_workstation.video import BackendMode, launch_video_call
|
from doctor_workstation.video import BackendMode, launch_video_call
|
||||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _ChineseQtTranslator(QTranslator):
|
class _ChineseQtTranslator(QTranslator):
|
||||||
"""Guarantee Chinese labels for common Qt standard buttons.
|
"""Guarantee Chinese labels for common Qt standard buttons.
|
||||||
|
|
||||||
Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for
|
Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for
|
||||||
framework text. This small fallback also keeps release builds localized
|
framework text. This small fallback also keeps release builds localized
|
||||||
when a packager omits the optional ``.qm`` files.
|
when a packager omits the optional ``.qm`` files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_BUTTON_TEXT = {
|
_BUTTON_TEXT = {
|
||||||
"OK": "确定",
|
"OK": "确定",
|
||||||
"Open": "打开",
|
"Open": "打开",
|
||||||
"Save": "保存",
|
"Save": "保存",
|
||||||
"Save All": "全部保存",
|
"Save All": "全部保存",
|
||||||
"Cancel": "取消",
|
"Cancel": "取消",
|
||||||
"Close": "关闭",
|
"Close": "关闭",
|
||||||
"Yes": "是",
|
"Yes": "是",
|
||||||
"Yes to All": "全部确认",
|
"Yes to All": "全部确认",
|
||||||
"No": "否",
|
"No": "否",
|
||||||
"No to All": "全部否定",
|
"No to All": "全部否定",
|
||||||
"Abort": "中止",
|
"Abort": "中止",
|
||||||
"Retry": "重试",
|
"Retry": "重试",
|
||||||
"Ignore": "忽略",
|
"Ignore": "忽略",
|
||||||
"Discard": "放弃",
|
"Discard": "放弃",
|
||||||
"Help": "帮助",
|
"Help": "帮助",
|
||||||
"Apply": "应用",
|
"Apply": "应用",
|
||||||
"Reset": "重置",
|
"Reset": "重置",
|
||||||
"Restore Defaults": "恢复默认设置",
|
"Restore Defaults": "恢复默认设置",
|
||||||
"Don't Save": "不保存",
|
"Don't Save": "不保存",
|
||||||
}
|
}
|
||||||
|
|
||||||
def translate(
|
def translate(
|
||||||
self,
|
self,
|
||||||
context: str,
|
context: str,
|
||||||
source_text: str,
|
source_text: str,
|
||||||
disambiguation: str | None = None,
|
disambiguation: str | None = None,
|
||||||
n: int = -1,
|
n: int = -1,
|
||||||
) -> str:
|
) -> str | None:
|
||||||
del context, disambiguation, n
|
del context, disambiguation, n
|
||||||
return self._BUTTON_TEXT.get(source_text.replace("&", ""), "")
|
# 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.
|
||||||
def _install_chinese_translations(application: QApplication) -> None:
|
# ``None`` delegates unknown text to Qt's installed catalog/source.
|
||||||
"""Install Simplified Chinese Qt catalogs once for the whole process."""
|
return self._BUTTON_TEXT.get(source_text.replace("&", ""))
|
||||||
|
|
||||||
if getattr(application, "_doctor_workstation_chinese_translators", None):
|
|
||||||
return
|
def _install_chinese_translations(application: QApplication) -> None:
|
||||||
|
"""Install Simplified Chinese Qt catalogs once for the whole process."""
|
||||||
QLocale.setDefault(QLocale("zh_CN"))
|
|
||||||
translators: list[QTranslator] = []
|
if getattr(application, "_doctor_workstation_chinese_translators", None):
|
||||||
translations_path = QLibraryInfo.path(
|
return
|
||||||
QLibraryInfo.LibraryPath.TranslationsPath
|
|
||||||
)
|
QLocale.setDefault(QLocale("zh_CN"))
|
||||||
for catalog in ("qt_zh_CN", "qtbase_zh_CN"):
|
translators: list[QTranslator] = []
|
||||||
translator = QTranslator(application)
|
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||||||
if translator.load(catalog, translations_path):
|
for catalog in ("qt_zh_CN", "qtbase_zh_CN"):
|
||||||
application.installTranslator(translator)
|
translator = QTranslator(application)
|
||||||
translators.append(translator)
|
if translator.load(catalog, translations_path):
|
||||||
|
application.installTranslator(translator)
|
||||||
fallback = _ChineseQtTranslator(application)
|
translators.append(translator)
|
||||||
application.installTranslator(fallback)
|
|
||||||
translators.append(fallback)
|
fallback = _ChineseQtTranslator(application)
|
||||||
application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined]
|
application.installTranslator(fallback)
|
||||||
|
translators.append(fallback)
|
||||||
|
application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
class _UnconfiguredRepository:
|
class _UnconfiguredRepository:
|
||||||
@@ -264,12 +266,12 @@ class ApplicationController(QObject):
|
|||||||
|
|
||||||
def _show_login(self) -> None:
|
def _show_login(self) -> None:
|
||||||
if self.login_window is None:
|
if self.login_window is None:
|
||||||
self.login_window = LoginWindow(
|
self.login_window = LoginWindow(
|
||||||
self._base_repository(),
|
self._base_repository(),
|
||||||
self.config,
|
self.config,
|
||||||
self.demo_repository,
|
self.demo_repository,
|
||||||
credential_store=self.token_store,
|
credential_store=self.token_store,
|
||||||
)
|
)
|
||||||
self.login_window.login_succeeded.connect(self._on_login_succeeded)
|
self.login_window.login_succeeded.connect(self._on_login_succeeded)
|
||||||
self.login_window.config_changed.connect(self._on_config_changed)
|
self.login_window.config_changed.connect(self._on_config_changed)
|
||||||
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
|
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
|
||||||
@@ -277,9 +279,9 @@ class ApplicationController(QObject):
|
|||||||
else:
|
else:
|
||||||
self.login_window.repository = self._base_repository()
|
self.login_window.repository = self._base_repository()
|
||||||
self.login_window.config = self.config
|
self.login_window.config = self.config
|
||||||
if not self.login_window.demo_check.isChecked():
|
if not self.login_window.demo_check.isChecked():
|
||||||
self.login_window.active_repository = self._base_repository()
|
self.login_window.active_repository = self._base_repository()
|
||||||
self.login_window.restore_remembered_credentials()
|
self.login_window.restore_remembered_credentials()
|
||||||
self.login_window.show()
|
self.login_window.show()
|
||||||
self.login_window.raise_()
|
self.login_window.raise_()
|
||||||
self.login_window.activateWindow()
|
self.login_window.activateWindow()
|
||||||
@@ -564,40 +566,40 @@ class ApplicationController(QObject):
|
|||||||
if parent is None or self.current_repository is None:
|
if parent is None or self.current_repository is None:
|
||||||
return
|
return
|
||||||
patient_id = payload.get("patient_id")
|
patient_id = payload.get("patient_id")
|
||||||
diagnosis_id = payload.get("diagnosis_id")
|
diagnosis_id = payload.get("diagnosis_id")
|
||||||
patient_name = str(payload.get("patient_name") or "患者")
|
patient_name = str(payload.get("patient_name") or "患者")
|
||||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||||
return
|
return
|
||||||
|
|
||||||
call_key = str(diagnosis_id)
|
call_key = str(diagnosis_id)
|
||||||
existing_call = self.video_calls.get(call_key)
|
existing_call = self.video_calls.get(call_key)
|
||||||
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
||||||
qt_window = getattr(existing_call, "qt_window", None)
|
qt_window = getattr(existing_call, "qt_window", None)
|
||||||
if qt_window is not None:
|
if qt_window is not None:
|
||||||
qt_window.show()
|
qt_window.show()
|
||||||
qt_window.raise_()
|
qt_window.raise_()
|
||||||
qt_window.activateWindow()
|
qt_window.activateWindow()
|
||||||
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
|
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
|
||||||
return
|
return
|
||||||
if (
|
if (
|
||||||
call_key in self.video_pending
|
call_key in self.video_pending
|
||||||
or existing_call is not None
|
or existing_call is not None
|
||||||
or call_key in self.demo_video_dialogs
|
or call_key in self.demo_video_dialogs
|
||||||
):
|
):
|
||||||
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
||||||
return
|
return
|
||||||
|
|
||||||
closed_previous_im = False
|
closed_previous_im = False
|
||||||
if open_im:
|
if open_im:
|
||||||
for key, call in tuple(self.video_calls.items()):
|
for key, call in tuple(self.video_calls.items()):
|
||||||
if key == call_key or not getattr(call, "open_im", False):
|
if key == call_key or not getattr(call, "open_im", False):
|
||||||
continue
|
continue
|
||||||
closed_previous_im = True
|
closed_previous_im = True
|
||||||
self.video_calls.pop(key, None)
|
self.video_calls.pop(key, None)
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
call.close()
|
call.close()
|
||||||
|
|
||||||
if self.current_demo_mode:
|
if self.current_demo_mode:
|
||||||
dialog = DemoVideoDialog(patient_name, parent)
|
dialog = DemoVideoDialog(patient_name, parent)
|
||||||
@@ -611,11 +613,11 @@ class ApplicationController(QObject):
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
return
|
return
|
||||||
|
|
||||||
show_toast(
|
show_toast(
|
||||||
parent,
|
parent,
|
||||||
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
|
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
|
||||||
"info",
|
"info",
|
||||||
)
|
)
|
||||||
repository = self.current_repository
|
repository = self.current_repository
|
||||||
marker = object()
|
marker = object()
|
||||||
self.video_pending[call_key] = marker
|
self.video_pending[call_key] = marker
|
||||||
@@ -626,35 +628,35 @@ class ApplicationController(QObject):
|
|||||||
diagnosis_id=int(diagnosis_id),
|
diagnosis_id=int(diagnosis_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def request_ticket() -> None:
|
def request_ticket() -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
run_async(
|
run_async(
|
||||||
get_ticket,
|
get_ticket,
|
||||||
on_success=lambda ticket: self._launch_video(
|
on_success=lambda ticket: self._launch_video(
|
||||||
ticket,
|
ticket,
|
||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
repository=repository,
|
repository=repository,
|
||||||
call_key=call_key,
|
call_key=call_key,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._video_ticket_error(
|
on_error=lambda error: self._video_ticket_error(
|
||||||
call_key,
|
call_key,
|
||||||
marker,
|
marker,
|
||||||
parent,
|
parent,
|
||||||
error,
|
error,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tencent IM may take a brief moment to release the previous browser
|
# Tencent IM may take a brief moment to release the previous browser
|
||||||
# connection. The admin version also has only one ChatDialog instance.
|
# connection. The admin version also has only one ChatDialog instance.
|
||||||
if closed_previous_im:
|
if closed_previous_im:
|
||||||
QTimer.singleShot(400, request_ticket)
|
QTimer.singleShot(400, request_ticket)
|
||||||
else:
|
else:
|
||||||
request_ticket()
|
request_ticket()
|
||||||
|
|
||||||
def _video_ticket_error(
|
def _video_ticket_error(
|
||||||
self,
|
self,
|
||||||
@@ -681,11 +683,11 @@ class ApplicationController(QObject):
|
|||||||
diagnosis_id: Any,
|
diagnosis_id: Any,
|
||||||
patient_id: Any,
|
patient_id: Any,
|
||||||
repository: Any,
|
repository: Any,
|
||||||
call_key: str,
|
call_key: str,
|
||||||
marker: object,
|
marker: object,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
self.video_pending.pop(call_key, None)
|
self.video_pending.pop(call_key, None)
|
||||||
@@ -708,11 +710,11 @@ class ApplicationController(QObject):
|
|||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
backend_mode=mode,
|
backend_mode=mode,
|
||||||
local_dist=video_dist_path(),
|
local_dist=video_dist_path(),
|
||||||
remote_url=self.config.video_web_url or None,
|
remote_url=self.config.video_web_url or None,
|
||||||
logger=logging.getLogger("doctor_workstation.video"),
|
logger=logging.getLogger("doctor_workstation.video"),
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
LOGGER.exception("video call could not be launched")
|
LOGGER.exception("video call could not be launched")
|
||||||
show_toast(
|
show_toast(
|
||||||
@@ -788,10 +790,12 @@ def _create_application(argv: list[str]) -> QApplication:
|
|||||||
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
)
|
)
|
||||||
application = QApplication(argv)
|
with suppress(AttributeError):
|
||||||
_install_chinese_translations(application)
|
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
|
||||||
application.setApplicationName("甄养堂医生工作站")
|
application = QApplication(argv)
|
||||||
application.setApplicationDisplayName("甄养堂医生工作站")
|
_install_chinese_translations(application)
|
||||||
|
application.setApplicationName("甄养堂医生工作站")
|
||||||
|
application.setApplicationDisplayName("甄养堂医生工作站")
|
||||||
application.setOrganizationName("ZhenYangTang")
|
application.setOrganizationName("ZhenYangTang")
|
||||||
application.setOrganizationDomain("zhenyangtang.com")
|
application.setOrganizationDomain("zhenyangtang.com")
|
||||||
application.setQuitOnLastWindowClosed(True)
|
application.setQuitOnLastWindowClosed(True)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -21,6 +24,7 @@ from PySide6.QtCore import (
|
|||||||
QByteArray,
|
QByteArray,
|
||||||
QDate,
|
QDate,
|
||||||
QIODevice,
|
QIODevice,
|
||||||
|
QMarginsF,
|
||||||
QPoint,
|
QPoint,
|
||||||
QRectF,
|
QRectF,
|
||||||
Qt,
|
Qt,
|
||||||
@@ -30,16 +34,20 @@ from PySide6.QtCore import (
|
|||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
|
QFontDatabase,
|
||||||
QImage,
|
QImage,
|
||||||
QMouseEvent,
|
QMouseEvent,
|
||||||
|
QPageLayout,
|
||||||
QPageSize,
|
QPageSize,
|
||||||
QPainter,
|
QPainter,
|
||||||
|
QPdfWriter,
|
||||||
QPen,
|
QPen,
|
||||||
QTextDocument,
|
QTextDocument,
|
||||||
)
|
)
|
||||||
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDateEdit,
|
QDateEdit,
|
||||||
@@ -554,7 +562,9 @@ class MultiSelectComboBox(QComboBox):
|
|||||||
self.lineEdit().setPlaceholderText("请选择忌口内容(可多选)")
|
self.lineEdit().setPlaceholderText("请选择忌口内容(可多选)")
|
||||||
for option in options:
|
for option in options:
|
||||||
self.addItem(option, option)
|
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)
|
self.view().pressed.connect(self._toggle_item)
|
||||||
# QComboBox applies the clicked item's text after ``pressed``. Refresh
|
# QComboBox applies the clicked item's text after ``pressed``. Refresh
|
||||||
# on the next event-loop tick so the field always shows every checked
|
# 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 = QSpinBox()
|
||||||
self.usage_days.setRange(1, 365)
|
self.usage_days.setRange(1, 365)
|
||||||
self._place_field(grid, 2, 0, "单次用量", self.dosage_amount)
|
self._place_field(grid, 2, 0, "单次用量", self.dosage_amount)
|
||||||
self._main_dosage_unit_field = self._place_field(
|
self._main_dosage_unit_field = self._place_field(grid, 2, 1, "用量单位", self.dosage_unit)
|
||||||
grid, 2, 1, "用量单位", self.dosage_unit
|
|
||||||
)
|
|
||||||
self._main_dosage_unit_field.hide()
|
self._main_dosage_unit_field.hide()
|
||||||
self._main_bag_field = self._place_field(grid, 2, 2, "每次袋数", self.dosage_bag_count)
|
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)
|
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"))
|
voided = _bool(self._source.get("void_status"))
|
||||||
rejected = _int(self._source.get("audit_status"), -1) == 2
|
rejected = _int(self._source.get("audit_status"), -1) == 2
|
||||||
if voided and rejected:
|
if voided and rejected:
|
||||||
messages.append("当前处方已作废且已驳回;保存后将取消作废、清除驳回并重新进入待审核。")
|
messages.append(
|
||||||
|
"当前处方已作废且已驳回;保存后将取消作废、清除驳回并重新进入待审核。"
|
||||||
|
)
|
||||||
elif voided:
|
elif voided:
|
||||||
messages.append("当前处方已作废;保存后将取消作废并重新进入待审核。")
|
messages.append("当前处方已作废;保存后将取消作废并重新进入待审核。")
|
||||||
elif rejected:
|
elif rejected:
|
||||||
@@ -2537,7 +2547,12 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
widget.setFocus()
|
widget.setFocus()
|
||||||
return
|
return
|
||||||
length_checks = (
|
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_instruction"], 200, "用法最多 200 个字符。", self.usage_instruction),
|
||||||
(payload["usage_notes"], 200, "其他说明最多 200 个字符。", self.usage_notes),
|
(payload["usage_notes"], 200, "其他说明最多 200 个字符。", self.usage_notes),
|
||||||
)
|
)
|
||||||
@@ -2558,8 +2573,7 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self.dosage_amount.setFocus()
|
self.dosage_amount.setFocus()
|
||||||
return
|
return
|
||||||
if prescription_type == "饮片" and any(
|
if prescription_type == "饮片" and any(
|
||||||
value not in {50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0}
|
value not in {50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0} for value in dosage_values
|
||||||
for value in dosage_values
|
|
||||||
):
|
):
|
||||||
self.validation.show_message(
|
self.validation.show_message(
|
||||||
"饮片单次用量只能选择 50、100、120、150、180、200 或 250ml。",
|
"饮片单次用量只能选择 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:
|
for label, field_value, full_width in fields:
|
||||||
if full_width:
|
if full_width:
|
||||||
if pending:
|
if pending:
|
||||||
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
|
pending.extend(
|
||||||
rows.append(f'<tr>{"".join(pending)}</tr>')
|
'<td class="cr-item empty"></td>' for _ in range(3 - len(pending))
|
||||||
|
)
|
||||||
|
rows.append(f"<tr>{''.join(pending)}</tr>")
|
||||||
pending = []
|
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
|
continue
|
||||||
pending.append(field_cell(label, field_value))
|
pending.append(field_cell(label, field_value))
|
||||||
if len(pending) == 3:
|
if len(pending) == 3:
|
||||||
rows.append(f'<tr>{"".join(pending)}</tr>')
|
rows.append(f"<tr>{''.join(pending)}</tr>")
|
||||||
pending = []
|
pending = []
|
||||||
if pending:
|
if pending:
|
||||||
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(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 (
|
return (
|
||||||
'<table class="cr-section" width="100%" cellspacing="0" cellpadding="0">'
|
'<table class="cr-section" width="100%" cellspacing="0" cellpadding="0">'
|
||||||
f'<tr><td class="cr-section-title">{html.escape(title)}</td></tr>'
|
f'<tr><td class="cr-section-title">{html.escape(title)}</td></tr>'
|
||||||
'<tr><td><table class="cr-grid" width="100%" cellspacing="0" cellpadding="0">'
|
'<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>'
|
'<tr><td class="cr-rule"></td></tr></table>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2876,9 +2892,7 @@ def render_case_record_html(prescription: Any) -> str:
|
|||||||
),
|
),
|
||||||
section(
|
section(
|
||||||
"既往史",
|
"既往史",
|
||||||
(
|
(("", value("past_history"), True),),
|
||||||
("", value("past_history"), True),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
section(
|
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; }}
|
.cr-rule {{ height:1px; border-bottom:1px solid #eeeeee; }}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<table class="paper" width="100%" cellspacing="0" cellpadding="0"><tr>
|
<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>
|
</tr></table></body></html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def render_prescription_html(prescription: Any, *, print_layout: bool = False) -> str:
|
_SLIP_HOSPITAL_TITLE = "成都双流甄养堂互联网医院 处方笺"
|
||||||
"""Build the pharmacy-copy A4 slip used by preview, print and PDF export."""
|
_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)
|
source = _mapping(prescription)
|
||||||
# consumer/prescription/index.vue uses a strict 210 x 297 mm sheet. At
|
is_internal = str(variant or "internal").strip().lower() != "user"
|
||||||
# Qt's 96 logical DPI this is 794 x 1123 px; keeping the HTML width fixed
|
# Preview fills the dialog; PDF/print maps the same tables onto A4.
|
||||||
# prevents QTextDocument from stretching the medicine columns with the
|
paper_width = "100%"
|
||||||
# containing dialog.
|
paper_dimensions = 'width="100%"'
|
||||||
paper_width = "100%" if print_layout else "794px"
|
body_padding = "0" if print_layout else "8px"
|
||||||
paper_dimensions = 'width="100%"' if print_layout else 'width="794" height="1123"'
|
|
||||||
body_padding = "0" if print_layout else "12px"
|
|
||||||
body_background = "#ffffff" if print_layout else "#f5f6f8"
|
body_background = "#ffffff" if print_layout else "#f5f6f8"
|
||||||
paper_border = "0" if print_layout else "1px solid #d6d6d6"
|
paper_border = "0" if print_layout else "1px solid #d6d6d6"
|
||||||
base_font_size = "10px" if print_layout else "13px"
|
content_padding = "8mm 10mm 10mm" if print_layout else "12px 16px 16px"
|
||||||
notice_font_size = "8.5px" if print_layout else "12px"
|
base_font_size = "13px"
|
||||||
rp_font_size = "12px" if print_layout else "16px"
|
notice_font_size = "12px"
|
||||||
section_font_size = "9px" if print_layout else "12px"
|
meta_font_size = "12px"
|
||||||
bottom_height = "44px" if print_layout else "70px"
|
rp_font_size = "16px"
|
||||||
|
section_font_size = "12px"
|
||||||
|
info_padding = "9px 12px"
|
||||||
|
bottom_height = "52px"
|
||||||
herbs = _herb_rows(prescription)
|
herbs = _herb_rows(prescription)
|
||||||
dose_count = max(1, _int(source.get("dose_count"), 1))
|
dose_count = max(1, _int(source.get("dose_count"), 1))
|
||||||
main = [row for row in herbs if _formula(row.get("formula_type")) == "主方"]
|
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:
|
if explicit:
|
||||||
return display_text(explicit)
|
return display_text(explicit)
|
||||||
prescription_type = str(
|
prescription_type = str(
|
||||||
values.get("prescription_type")
|
values.get("prescription_type") or fallback.get("prescription_type") or "浓缩水丸"
|
||||||
or fallback.get("prescription_type")
|
|
||||||
or "浓缩水丸"
|
|
||||||
)
|
)
|
||||||
times = int(positive_number(values.get("times_per_day"), 3))
|
times = int(positive_number(values.get("times_per_day"), 3))
|
||||||
amount = positive_number(values.get("dosage_amount"), 10)
|
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:
|
if pair_index == 1:
|
||||||
cells.append('<td class="rp-gap"></td>')
|
cells.append('<td class="rp-gap"></td>')
|
||||||
dosage = number_text(row.get("dosage"))
|
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(
|
cells.extend(
|
||||||
(
|
(
|
||||||
f'<td class="herb-name">{esc(row.get("name"))} ({dosage}克)</td>',
|
f'<td class="herb-name">{name_html}</td>',
|
||||||
f'<td class="herb-total">{total}克</td>',
|
f'<td class="herb-total">{qty_html}</td>',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if len(pair) == 1:
|
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()
|
str(value).strip() for value in recipient_parts if str(value or "").strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
serial = next(
|
prescription_sn = (
|
||||||
(
|
str(source.get("sn") or "").strip() or str(source.get("visit_no") or "").strip() or "—"
|
||||||
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 "—",
|
|
||||||
)
|
)
|
||||||
|
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 = source.get("gender")
|
||||||
gender_text = "男" if gender in (1, "1", "男") else "女" if gender in (0, "0", "女") else "—"
|
gender_text = "男" if gender in (1, "1", "男") else "女" if gender in (0, "0", "女") else "—"
|
||||||
age = display_text(source.get("age"))
|
age = display_text(source.get("age"))
|
||||||
age_text = age if age == "—" or age.endswith("岁") else f"{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 = source.get("aux_usage")
|
||||||
aux_usage = dict(aux_usage) if isinstance(aux_usage, Mapping) else {}
|
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 ""
|
aux_usage_text = usage_text(aux_usage, fallback=source) if aux else ""
|
||||||
advice = source.get("medical_advice") or source.get("doctor_advice")
|
advice = source.get("medical_advice") or source.get("doctor_advice")
|
||||||
remark_parts = [f"共{len(herbs)}味药"]
|
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"))
|
for value in (source.get("usage_notes"), source.get("remark"))
|
||||||
if str(value or "").strip()
|
if str(value or "").strip()
|
||||||
)
|
)
|
||||||
pharmacy_remark = (
|
pharmacy_remark = source.get("pharmacy_remark") or source.get("pharmacy_note")
|
||||||
source.get("pharmacy_remark")
|
|
||||||
or source.get("pharmacy_note")
|
|
||||||
)
|
|
||||||
|
|
||||||
explicit_out = (
|
explicit_out = (
|
||||||
source.get("out_pellet_text") or source.get("out_pellet") or source.get("total_weight")
|
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)}克"
|
out_pellet = f"{number_text(total_weight)}克"
|
||||||
|
|
||||||
prescription_type = display_text(source.get("prescription_type"), "浓缩水丸")
|
prescription_type = display_text(source.get("prescription_type"), "浓缩水丸")
|
||||||
type_text = (
|
if prescription_type == "饮片":
|
||||||
f"浓缩丸-{prescription_type}"
|
type_text = "饮片"
|
||||||
if re.search(r"丸|散|膏|片", prescription_type) and not prescription_type.startswith("浓缩丸-")
|
elif re.search(r"丸|散|膏|片", prescription_type) and not prescription_type.startswith(
|
||||||
else prescription_type
|
"浓缩丸-"
|
||||||
)
|
):
|
||||||
|
type_text = f"浓缩丸-{prescription_type}"
|
||||||
|
else:
|
||||||
|
type_text = prescription_type
|
||||||
per_dose = number_text(sum(_float(row.get("dosage")) for row in herbs))
|
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 = str(source.get("doctor_signature") or "").strip()
|
||||||
signature_html = (
|
signature_html = (
|
||||||
f'<img class="signature" src="{html.escape(signature, quote=True)}" alt="医师签名" />'
|
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>'
|
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:
|
if not herb_html:
|
||||||
herb_html = (
|
herb_html = (
|
||||||
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="5">'
|
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="5">'
|
||||||
"暂无药材明细</td></tr>"
|
"暂无药材明细</td></tr>"
|
||||||
)
|
)
|
||||||
text_rows = [f"<p>主方服法:{esc(main_usage_text)}</p>"]
|
text_rows: list[str] = []
|
||||||
if aux_usage_text:
|
if is_internal:
|
||||||
text_rows.append(f"<p>辅方服法:{esc(aux_usage_text)}</p>")
|
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:
|
if advice:
|
||||||
text_rows.append(f"<p>医嘱:{esc(advice)}</p>")
|
text_rows.append(f"<p>医嘱:{esc(advice)}</p>")
|
||||||
if dietary:
|
if dietary:
|
||||||
text_rows.append(f"<p>忌口:{esc(dietary)}</p>")
|
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:
|
if pharmacy_remark:
|
||||||
text_rows.append(f'<p class="warning">药房备注:{esc(pharmacy_remark)}</p>')
|
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>')
|
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] = []
|
audit_lines: list[str] = []
|
||||||
if source.get("audit_by_name"):
|
if source.get("audit_by_name"):
|
||||||
audit_lines.append(
|
audit_lines.append(
|
||||||
@@ -3182,7 +3277,9 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
|||||||
if source.get("audit_remark"):
|
if source.get("audit_remark"):
|
||||||
audit_lines.append(f"审核意见:{esc(source.get('audit_remark'))}")
|
audit_lines.append(f"审核意见:{esc(source.get('audit_remark'))}")
|
||||||
if source.get("business_prescription_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 = ""
|
audit_html = ""
|
||||||
if audit_lines and not print_layout:
|
if audit_lines and not print_layout:
|
||||||
audit_html = f'<div class="audit">{"<br/>".join(audit_lines)}</div>'
|
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>
|
<!doctype html>
|
||||||
<html><head><meta charset="utf-8"><style>
|
<html><head><meta charset="utf-8"><style>
|
||||||
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1f1f1f;
|
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; }}
|
font-size:{base_font_size}; line-height:1.5; }}
|
||||||
.paper {{ width:{paper_width}; margin:0 auto; background:#ffffff;
|
.paper {{ width:{paper_width}; margin:0 auto; background:#ffffff;
|
||||||
border:{paper_border}; border-collapse:separate; border-spacing:0;
|
border:{paper_border}; border-collapse:separate; border-spacing:0; }}
|
||||||
box-shadow:{"none" if print_layout else "0 0 0 1px #d6d6d6"}; }}
|
.paper-content {{ padding:{content_padding}; vertical-align:top; }}
|
||||||
.paper-content {{ padding:8mm 10mm; vertical-align:top; }}
|
.rx-title {{ text-align:center; margin:4px 0 16px; color:#1f1f1f; }}
|
||||||
.notice {{ width:100%; border:1px solid #e5e7eb; border-collapse:collapse;
|
.notice {{ width:100%; border:1px solid #e5e7eb; border-collapse:collapse;
|
||||||
table-layout:fixed; background:#f3f4f6; margin:0 0 6px;
|
table-layout:fixed; background:#f3f4f6; margin:0 0 10px; }}
|
||||||
font-size:{notice_font_size}; color:#1f1f1f; }}
|
.notice-text {{ padding:10px 12px; font-size:{notice_font_size}; color:#374151;
|
||||||
.notice td {{ border:0; padding:6px 10px; }}
|
line-height:1.6; }}
|
||||||
.notice-text {{ width:49%; }}
|
.meta-item {{ padding:8px 12px 10px; vertical-align:top; border-top:1px solid #e5e7eb; }}
|
||||||
.notice-meta {{ width:51%; text-align:right; white-space:nowrap; }}
|
.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 {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
|
||||||
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; font-size:13px; }}
|
.info td {{ border:1px solid #c8c8c8; padding:{info_padding}; vertical-align:middle; font-size:13px; }}
|
||||||
.info .full {{ border-top:0; }}
|
.key {{ color:#6b7280; }}
|
||||||
.key {{ white-space:nowrap; color:#1f1f1f; }}
|
|
||||||
.rp-frame {{ width:100%; border-collapse:collapse; table-layout:fixed;
|
.rp-frame {{ width:100%; border-collapse:collapse; table-layout:fixed;
|
||||||
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
|
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 {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
|
||||||
.rp td {{ border:0; padding:3px 0; vertical-align:middle; }}
|
.rp td {{ border:0; padding:2px 0; vertical-align:middle; }}
|
||||||
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:4px; padding-bottom:4px; }}
|
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding:6px 0 8px; }}
|
||||||
.rp-indent {{ width:44px; }}
|
.rp-indent {{ width:44px; }}
|
||||||
.rp-mark {{ width:44px; padding-right:8px !important;
|
.rp-mark {{ width:44px; padding-right:8px !important;
|
||||||
font-size:{rp_font_size}; font-weight:700; color:#1f1f1f; }}
|
font-size:{rp_font_size}; font-weight:700; color:#1f1f1f; }}
|
||||||
.drug-head {{ color:#1f1f1f; }}
|
.drug-head {{ color:#4b5563; }}
|
||||||
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap;
|
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap; }}
|
||||||
font-variant-numeric:tabular-nums; }}
|
|
||||||
.rp-gap {{ width:24px; padding:0 !important; }}
|
.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; }}
|
font-size:{section_font_size}; font-weight:600; }}
|
||||||
.section.main {{ color:#409eff; }}
|
.section.main {{ color:#2563eb; }}
|
||||||
.section.aux {{ color:#e6a23c; }}
|
.section.aux {{ color:#d97706; }}
|
||||||
.herb-name {{ line-height:1.85; white-space:nowrap; color:#1f1f1f; }}
|
.herb-name {{ line-height:1.85; white-space:nowrap; color:#1f1f1f; }}
|
||||||
.empty-herbs {{ color:#8a8f98; padding:18px 6px !important; text-align:center; }}
|
.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; }}
|
.rx-text p {{ margin:0; padding:0; }}
|
||||||
.warning {{ color:#d72424; font-weight:600; }}
|
.warning {{ color:#d72424; font-weight:600; }}
|
||||||
.bottom {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
|
.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; }}
|
height:{bottom_height}; vertical-align:middle; font-size:12px; color:#1f1f1f; }}
|
||||||
.bottom .doctor {{ width:28%; vertical-align:top; }}
|
.bottom .doctor {{ width:40%; }}
|
||||||
.doctor-title {{ display:block; margin-bottom:4px; font-size:13px; }}
|
.doctor-title {{ margin-right:8px; color:#6b7280; }}
|
||||||
.doctor-name {{ display:block; margin-top:8px; font-size:13px; }}
|
.doctor-name {{ font-size:13px; }}
|
||||||
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; object-fit:contain; }}
|
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; }}
|
||||||
.meta-key {{ white-space:nowrap; margin-right:6px; }}
|
.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;
|
.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; }}
|
font-size:12px; line-height:1.6; }}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<table align="center" class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
|
<table align="center" class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
|
||||||
<table class="notice" width="100%"><tr>
|
{title_html}
|
||||||
<td class="notice-text">服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点</td>
|
<table class="notice" width="100%" cellspacing="0" cellpadding="0" bgcolor="#f3f4f6">
|
||||||
<td class="notice-meta">日期:{esc(date_text())} 编号:{esc(serial)}</td>
|
<tr><td class="notice-text" colspan="{notice_colspan}">{html.escape(notice_text)}</td></tr>
|
||||||
</tr></table>
|
{meta_rows}
|
||||||
<table class="info" width="100%">
|
</table>
|
||||||
|
<table class="info" width="100%" cellspacing="0" cellpadding="0">
|
||||||
<tr><td><span class="key">姓名</span> {esc(source.get("patient_name"))}</td>
|
<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(gender_text)}</td>
|
||||||
<td><span class="key">年龄</span> {esc(age_text)}</td>
|
<td><span class="key">年龄</span> {esc(age_text)}</td>
|
||||||
<td><span class="key">电话</span> {esc(source.get("phone"))}</td></tr>
|
<td><span class="key">电话</span> {esc(phone_text)}</td></tr>
|
||||||
<tr><td class="full" colspan="4"><span class="key">收件信息</span> {esc(recipient_text)}</td></tr>
|
<tr><td 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>
|
<tr><td colspan="4"><span class="key">临床诊断</span> {esc(source.get("clinical_diagnosis"))}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<table class="rp-frame" title="药房联" width="100%"><tr><td class="rp-padding">
|
<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%">
|
<table class="rp" width="100%" cellspacing="0" cellpadding="0">
|
||||||
<col width="44"/><col/><col width="64"/><col width="24"/><col/><col width="64"/>
|
<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>
|
<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="total-head">{qty_head}</td><td class="rp-gap"></td>
|
||||||
<td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
|
<td class="drug-head">用药 (单剂)</td><td class="total-head">{qty_head}</td></tr>
|
||||||
{herb_html}
|
{herb_html}
|
||||||
</table>
|
</table>
|
||||||
</td></tr></table>
|
</td></tr></table>
|
||||||
<div class="rx-text">{"".join(text_rows)}</div>
|
<div class="rx-text">{"".join(text_rows)}</div>
|
||||||
<table class="bottom" width="100%"><tr>
|
<table class="bottom" width="100%" cellspacing="0" cellpadding="0"><tr>
|
||||||
<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>
|
{bottom_row}
|
||||||
<td><span class="meta-key">类型:</span> {esc(type_text)}</td>
|
</tr>
|
||||||
<td><span class="meta-key">天数:</span> {dose_count}剂</td>
|
<tr><td class="hospital" colspan="{hospital_colspan}">
|
||||||
<td><span class="meta-key">单剂量:</span> {per_dose}克</td>
|
{_SLIP_COMPANY_LINE}<br/>{_SLIP_ADDRESS_LINE}
|
||||||
</tr></table>
|
</td></tr></table>
|
||||||
</td></tr></table>
|
</td></tr></table>
|
||||||
{audit_html}
|
{audit_html}
|
||||||
</body></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):
|
class _PrescriptionPaperPreview(QTextBrowser):
|
||||||
"""A4 prescription preview with the consumer page's floating watermark.
|
"""A4 prescription preview with the consumer page's floating watermark.
|
||||||
|
|
||||||
@@ -3287,17 +3585,23 @@ class _PrescriptionPaperPreview(QTextBrowser):
|
|||||||
|
|
||||||
_PAPER_WIDTH = 794
|
_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)
|
super().__init__(parent)
|
||||||
|
self._watermark_text = "处方联" if str(variant).strip().lower() == "user" else "药房联"
|
||||||
herbs = _herb_rows(prescription)
|
herbs = _herb_rows(prescription)
|
||||||
main_count = sum(
|
main_count = sum(1 for row in herbs if _formula(row.get("formula_type")) == "主方")
|
||||||
1 for row in herbs if _formula(row.get("formula_type")) == "主方"
|
|
||||||
)
|
|
||||||
aux_count = len(herbs) - main_count
|
aux_count = len(herbs) - main_count
|
||||||
medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2
|
medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2
|
||||||
section_count = int(main_count > 0) + int(aux_count > 0)
|
section_count = int(main_count > 0) + int(aux_count > 0)
|
||||||
rp_height = 65 + medicine_rows * 30 + section_count * 22
|
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
|
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
@@ -3305,24 +3609,20 @@ class _PrescriptionPaperPreview(QTextBrowser):
|
|||||||
center_y = self._watermark_document_y - self.verticalScrollBar().value()
|
center_y = self._watermark_document_y - self.verticalScrollBar().value()
|
||||||
if center_y < -90 or center_y > viewport.height() + 90:
|
if center_y < -90 or center_y > viewport.height() + 90:
|
||||||
return
|
return
|
||||||
paper_width = min(self._PAPER_WIDTH, viewport.width())
|
paper_width = viewport.width()
|
||||||
paper_left = max(0.0, (viewport.width() - paper_width) / 2)
|
paper_left = 0.0
|
||||||
painter = QPainter(viewport)
|
painter = QPainter(viewport)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
painter.translate(paper_left + paper_width / 2, center_y)
|
painter.translate(paper_left + paper_width / 2, center_y)
|
||||||
painter.rotate(-22)
|
painter.rotate(-22)
|
||||||
painter.setPen(QColor(31, 31, 31, 15))
|
painter.setPen(QColor(31, 31, 31, 15))
|
||||||
font = QFont(
|
font = QFont(_ensure_slip_fonts(), -1, QFont.Weight.Bold)
|
||||||
"Microsoft YaHei UI",
|
|
||||||
-1,
|
|
||||||
QFont.Weight.Bold,
|
|
||||||
)
|
|
||||||
font.setPixelSize(84)
|
font.setPixelSize(84)
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
QRectF(-230, -70, 460, 140),
|
QRectF(-230, -70, 460, 140),
|
||||||
Qt.AlignmentFlag.AlignCenter,
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
"药房联",
|
self._watermark_text,
|
||||||
)
|
)
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
@@ -3344,7 +3644,10 @@ class PrescriptionDetailDialog(QDialog):
|
|||||||
self.prescription = prescription
|
self.prescription = prescription
|
||||||
self.document = QTextDocument(self)
|
self.document = QTextDocument(self)
|
||||||
self.document.setDocumentMargin(0)
|
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.setWindowTitle("查看处方")
|
||||||
self.resize(920, 780)
|
self.resize(920, 780)
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
@@ -3374,47 +3677,49 @@ class PrescriptionDetailDialog(QDialog):
|
|||||||
if _bool(first_value(prescription, "void_status", "is_void", default=False)):
|
if _bool(first_value(prescription, "void_status", "is_void", default=False)):
|
||||||
void_by = display_text(first_value(prescription, "void_by_name", default=""), "—")
|
void_by = display_text(first_value(prescription, "void_by_name", default=""), "—")
|
||||||
void_time = display_text(first_value(prescription, "void_time", 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_status = _int(first_value(prescription, "audit_status", default=-1), -1)
|
||||||
audit_label = {0: "待审核", 1: "已通过", 2: "已驳回"}.get(audit_status, "未知")
|
audit_label = {0: "待审核", 1: "已通过", 2: "已驳回"}.get(audit_status, "未知")
|
||||||
status_lines.append(f"消费者处方审核:{audit_label}")
|
if not status_lines or audit_status == 2:
|
||||||
audit_remark = str(first_value(prescription, "audit_remark", default="") or "").strip()
|
status_lines.append(f"消费者处方审核:{audit_label}")
|
||||||
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 _bool(first_value(prescription, "business_prescription_audit_rejected", default=False)):
|
if _bool(first_value(prescription, "business_prescription_audit_rejected", default=False)):
|
||||||
business_remark = str(
|
status_lines.append("业务订单处方审核:已驳回")
|
||||||
first_value(prescription, "business_prescription_audit_remark", default="") or ""
|
banner_kind = (
|
||||||
).strip()
|
|
||||||
status_lines.append(
|
|
||||||
"业务订单处方审核:已驳回"
|
|
||||||
+ (f" 驳回意见:{business_remark}" if business_remark else "")
|
|
||||||
)
|
|
||||||
self.status_banner.show_message(
|
|
||||||
"\n".join(status_lines),
|
|
||||||
"danger"
|
"danger"
|
||||||
if _bool(first_value(prescription, "void_status", default=False))
|
if _bool(first_value(prescription, "void_status", default=False))
|
||||||
or audit_status == 2
|
or audit_status == 2
|
||||||
or _bool(
|
or _bool(
|
||||||
first_value(prescription, "business_prescription_audit_rejected", default=False)
|
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)
|
root.addWidget(self.status_banner)
|
||||||
self.preview = _PrescriptionPaperPreview(prescription)
|
self.preview = _PrescriptionPaperPreview(prescription, variant="internal")
|
||||||
self.preview.setObjectName("PrescriptionPaperPreview")
|
self.preview.setObjectName("PrescriptionPaperPreview")
|
||||||
self.preview.setOpenExternalLinks(False)
|
self.preview.setOpenExternalLinks(False)
|
||||||
self.preview.setStyleSheet(
|
self.preview.setStyleSheet(
|
||||||
"QTextBrowser#PrescriptionPaperPreview {"
|
"QTextBrowser#PrescriptionPaperPreview {"
|
||||||
"background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}"
|
"background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}"
|
||||||
)
|
)
|
||||||
|
self.preview.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.preview.setDocument(self.document)
|
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 = 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"))
|
case_record = _mapping(_mapping(prescription).get("case_record"))
|
||||||
self.case_document: QTextDocument | None = None
|
self.case_document: QTextDocument | None = None
|
||||||
self.case_preview: QTextBrowser | None = None
|
self.case_preview: QTextBrowser | None = None
|
||||||
@@ -3438,20 +3743,23 @@ class PrescriptionDetailDialog(QDialog):
|
|||||||
close.rejected.connect(self.reject)
|
close.rejected.connect(self.reject)
|
||||||
root.addWidget(close)
|
root.addWidget(close)
|
||||||
|
|
||||||
|
def _current_variant(self) -> str:
|
||||||
|
return "user" if self.tabs.tabText(self.tabs.currentIndex()) == "处方联" else "internal"
|
||||||
|
|
||||||
def print_slip(self) -> None:
|
def print_slip(self) -> None:
|
||||||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
printer = QPrinter(QPrinter.PrinterMode.ScreenResolution)
|
||||||
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
||||||
printer.setFullPage(True)
|
printer.setPageMargins(QMarginsF(8, 8, 8, 8), QPageLayout.Unit.Millimeter)
|
||||||
dialog = QPrintDialog(printer, self)
|
dialog = QPrintDialog(printer, self)
|
||||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
self._print_document(printer).print_(printer)
|
image = render_prescription_slip_image(
|
||||||
|
self.prescription, variant=self._current_variant()
|
||||||
def _print_document(self, printer: QPrinter) -> QTextDocument:
|
)
|
||||||
document = QTextDocument()
|
painter = QPainter(printer)
|
||||||
document.setDocumentMargin(0)
|
try:
|
||||||
document.setPageSize(printer.pageRect(QPrinter.Unit.Point).size())
|
_draw_slip_image_on_page(painter, image)
|
||||||
document.setHtml(render_prescription_html(self.prescription, print_layout=True))
|
finally:
|
||||||
return document
|
painter.end()
|
||||||
|
|
||||||
def choose_pdf_path(self) -> None:
|
def choose_pdf_path(self) -> None:
|
||||||
patient = str(first_value(self.prescription, "patient_name", default="处方"))
|
patient = str(first_value(self.prescription, "patient_name", default="处方"))
|
||||||
@@ -3470,12 +3778,33 @@ class PrescriptionDetailDialog(QDialog):
|
|||||||
output = str(path)
|
output = str(path)
|
||||||
if not output.lower().endswith(".pdf"):
|
if not output.lower().endswith(".pdf"):
|
||||||
output += ".pdf"
|
output += ".pdf"
|
||||||
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
image = render_prescription_slip_image(self.prescription, variant=self._current_variant())
|
||||||
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
|
if count_slip_ink_pixels(image) <= 0:
|
||||||
printer.setOutputFileName(output)
|
QMessageBox.warning(self, "导出失败", "处方笺未能生成可见内容,请重试。")
|
||||||
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
return
|
||||||
printer.setFullPage(True)
|
page_size = QPageSize(QPageSize.PageSizeId.A4)
|
||||||
self._print_document(printer).print_(printer)
|
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):
|
class DiagnosisDetailDialog(QDialog):
|
||||||
@@ -4153,4 +4482,6 @@ __all__ = [
|
|||||||
"parse_pasted_herbs",
|
"parse_pasted_herbs",
|
||||||
"render_case_record_html",
|
"render_case_record_html",
|
||||||
"render_prescription_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))
|
return _as_int(first_value(row, "status", default=0))
|
||||||
|
|
||||||
|
|
||||||
def prescription_action_label(row: Any) -> str:
|
def prescription_action_label(row: Any) -> str:
|
||||||
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
|
audit = _as_int(first_value(row, "prescription_audit_status", "audit_status"), -1)
|
||||||
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
|
voided = _as_int(first_value(row, "prescription_void_status", "void_status"), 0)
|
||||||
explicit = first_value(row, "has_prescription", default=None)
|
explicit = first_value(row, "has_prescription", default=None)
|
||||||
has_prescription = (
|
has_prescription = (
|
||||||
_as_bool(explicit)
|
_as_bool(explicit)
|
||||||
if explicit is not None
|
if explicit is not None
|
||||||
else _as_int(first_value(row, "prescription_id", default=0), 0) > 0
|
else _as_int(first_value(row, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
|
||||||
or audit in {0, 1, 2}
|
)
|
||||||
)
|
if not has_prescription:
|
||||||
if not has_prescription:
|
return "开方"
|
||||||
return "开方"
|
return "查看" if audit == 1 and voided != 1 else "编辑处方"
|
||||||
return "查看" if audit == 1 and voided != 1 else "编辑处方"
|
|
||||||
|
|
||||||
|
|
||||||
def _patient_cell(_value: Any, row: Any, *, can_plain: bool) -> str:
|
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 != "—"
|
if part and part != "—"
|
||||||
)
|
)
|
||||||
return (
|
return f"{display_text(first_value(row, 'patient_name'), '—')}\n{phone}" + (
|
||||||
f"{display_text(first_value(row, 'patient_name'), '—')}\n{phone}"
|
f"\n{extras}" if extras else ""
|
||||||
+ (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:
|
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:
|
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:
|
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:
|
def _offset_date(preset: str) -> date | None:
|
||||||
@@ -374,8 +376,6 @@ class AppointmentsPage(QWidget):
|
|||||||
)
|
)
|
||||||
self._native_video_capable = _supports_native_video(repository)
|
self._native_video_capable = _supports_native_video(repository)
|
||||||
self._is_admin = _is_admin_user(current_user)
|
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 = QVBoxLayout(self)
|
||||||
root.setContentsMargins(24, 20, 24, 24)
|
root.setContentsMargins(24, 20, 24, 24)
|
||||||
@@ -396,6 +396,14 @@ class AppointmentsPage(QWidget):
|
|||||||
self.poll_timer.setInterval(LIST_POLL_MS)
|
self.poll_timer.setInterval(LIST_POLL_MS)
|
||||||
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True))
|
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:
|
def _build_toolbar(self) -> QWidget:
|
||||||
frame = QFrame()
|
frame = QFrame()
|
||||||
frame.setObjectName("FilterBar")
|
frame.setObjectName("FilterBar")
|
||||||
@@ -457,7 +465,9 @@ class AppointmentsPage(QWidget):
|
|||||||
button = QPushButton(label)
|
button = QPushButton(label)
|
||||||
button.setCheckable(True)
|
button.setCheckable(True)
|
||||||
button.setProperty("variant", "chip")
|
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
|
self.date_buttons[preset] = button
|
||||||
date_row.addWidget(button)
|
date_row.addWidget(button)
|
||||||
date_row.addStretch(1)
|
date_row.addStretch(1)
|
||||||
@@ -490,9 +500,7 @@ class AppointmentsPage(QWidget):
|
|||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
|
|
||||||
actions = QHBoxLayout()
|
actions = QHBoxLayout()
|
||||||
self.edit_button = self._action_button(
|
self.edit_button = self._action_button("编辑患者", "tcm.diagnosis/edit", self._edit_patient)
|
||||||
"编辑患者", "tcm.diagnosis/edit", self._edit_patient
|
|
||||||
)
|
|
||||||
actions.addWidget(self.edit_button)
|
actions.addWidget(self.edit_button)
|
||||||
self.qr_button = self._action_button(
|
self.qr_button = self._action_button(
|
||||||
"视频二维码", "tcm.diagnosis/videoQr", self._request_video_qr
|
"视频二维码", "tcm.diagnosis/videoQr", self._request_video_qr
|
||||||
@@ -510,9 +518,7 @@ class AppointmentsPage(QWidget):
|
|||||||
"开方", "tcm.diagnosis/kaifang", self._open_prescription
|
"开方", "tcm.diagnosis/kaifang", self._open_prescription
|
||||||
)
|
)
|
||||||
actions.addWidget(self.prescription_button)
|
actions.addWidget(self.prescription_button)
|
||||||
self.case_button = self._action_button(
|
self.case_button = self._action_button("病历", "tcm.diagnosis/kaifang", self._view_case)
|
||||||
"病历", "tcm.diagnosis/kaifang", self._view_case
|
|
||||||
)
|
|
||||||
actions.addWidget(self.case_button)
|
actions.addWidget(self.case_button)
|
||||||
self.cancel_button = self._action_button(
|
self.cancel_button = self._action_button(
|
||||||
"取消挂号",
|
"取消挂号",
|
||||||
@@ -601,7 +607,9 @@ class AppointmentsPage(QWidget):
|
|||||||
dialog = QDialog(self)
|
dialog = QDialog(self)
|
||||||
dialog.setWindowTitle("自定义日期")
|
dialog.setWindowTitle("自定义日期")
|
||||||
layout = QVBoxLayout(dialog)
|
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.setCalendarPopup(True)
|
||||||
start.setDisplayFormat("yyyy-MM-dd")
|
start.setDisplayFormat("yyyy-MM-dd")
|
||||||
end = QDateEdit(QDate.fromString(self._end_date or date.today().isoformat(), "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()
|
self._fit_table_rows()
|
||||||
for row_index in range(self.table.rowCount()):
|
for row_index in range(self.table.rowCount()):
|
||||||
source_item = self.table.item(row_index, 0)
|
source_item = self.table.item(row_index, 0)
|
||||||
source = (
|
source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
||||||
source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
|
||||||
)
|
|
||||||
status = _status_value(source)
|
status = _status_value(source)
|
||||||
status_kind = {
|
status_kind = {
|
||||||
1: "warning",
|
1: "warning",
|
||||||
@@ -822,7 +828,7 @@ class AppointmentsPage(QWidget):
|
|||||||
if diagnosis_id <= 0:
|
if diagnosis_id <= 0:
|
||||||
show_toast(self, "该预约没有关联诊单信息。", "warning")
|
show_toast(self, "该预约没有关联诊单信息。", "warning")
|
||||||
return
|
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:
|
def _request_video(self) -> None:
|
||||||
if not _canonical_allowed(
|
if not _canonical_allowed(
|
||||||
@@ -882,6 +888,7 @@ class AppointmentsPage(QWidget):
|
|||||||
self._action_generation += 1
|
self._action_generation += 1
|
||||||
generation = self._action_generation
|
generation = self._action_generation
|
||||||
self.banner.show_message("正在生成视频二维码…", "info")
|
self.banner.show_message("正在生成视频二维码…", "info")
|
||||||
|
|
||||||
def _worker() -> dict[str, Any]:
|
def _worker() -> dict[str, Any]:
|
||||||
config = invoke(self.repository, "get_mini_program_config")
|
config = invoke(self.repository, "get_mini_program_config")
|
||||||
if not str(first_value(config, "app_id", default="") or "").strip():
|
if not str(first_value(config, "app_id", default="") or "").strip():
|
||||||
@@ -901,9 +908,7 @@ class AppointmentsPage(QWidget):
|
|||||||
if not url:
|
if not url:
|
||||||
raise ValueError("服务器未返回可用的二维码地址")
|
raise ValueError("服务器未返回可用的二维码地址")
|
||||||
try:
|
try:
|
||||||
result["_image_bytes"] = invoke(
|
result["_image_bytes"] = invoke(self.repository, "download_public_image", url=url)
|
||||||
self.repository, "download_public_image", url=url
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result["_image_error"] = friendly_error(exc)
|
result["_image_error"] = friendly_error(exc)
|
||||||
return result
|
return result
|
||||||
@@ -962,8 +967,7 @@ class AppointmentsPage(QWidget):
|
|||||||
else:
|
else:
|
||||||
reason = str(first_value(result, "_image_error", default="") or "").strip()
|
reason = str(first_value(result, "_image_error", default="") or "").strip()
|
||||||
image_label.setText(
|
image_label.setText(
|
||||||
"二维码图片加载失败\n请重新生成或在浏览器中打开"
|
"二维码图片加载失败\n请重新生成或在浏览器中打开" + (f"\n{reason}" if reason else "")
|
||||||
+ (f"\n{reason}" if reason else "")
|
|
||||||
)
|
)
|
||||||
image_row = QHBoxLayout()
|
image_row = QHBoxLayout()
|
||||||
image_row.addStretch(1)
|
image_row.addStretch(1)
|
||||||
@@ -1186,64 +1190,64 @@ class AppointmentsPage(QWidget):
|
|||||||
def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None:
|
def _prescription_loaded(self, existing: Any, row: Any, generation: int) -> None:
|
||||||
if generation != self._prescription_generation:
|
if generation != self._prescription_generation:
|
||||||
return
|
return
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
if existing is not None and _as_int(first_value(existing, "id", default=0)) > 0:
|
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
|
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
|
||||||
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
|
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
|
||||||
if not approved or voided:
|
if not approved or voided:
|
||||||
self._open_existing_prescription_editor(existing)
|
self._open_existing_prescription_editor(existing)
|
||||||
else:
|
else:
|
||||||
dialog = PrescriptionDetailDialog(
|
dialog = PrescriptionDetailDialog(
|
||||||
existing,
|
existing,
|
||||||
can_open_diagnosis=_canonical_allowed(
|
can_open_diagnosis=_canonical_allowed(
|
||||||
self.permissions, "tcm.diagnosis/readonlyDetail", default=False
|
self.permissions, "tcm.diagnosis/readonlyDetail", default=False
|
||||||
),
|
),
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
|
dialog.diagnosis_requested.connect(self._open_diagnosis_id)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
return
|
return
|
||||||
self._begin_case_record_load(row)
|
self._begin_case_record_load(row)
|
||||||
|
|
||||||
def _open_existing_prescription_editor(self, prescription: Any) -> None:
|
def _open_existing_prescription_editor(self, prescription: Any) -> None:
|
||||||
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
|
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
|
||||||
if prescription_id <= 0:
|
if prescription_id <= 0:
|
||||||
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
|
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
|
||||||
return
|
return
|
||||||
dialog = PrescriptionEditorDialog(
|
dialog = PrescriptionEditorDialog(
|
||||||
self.repository,
|
self.repository,
|
||||||
prescription,
|
prescription,
|
||||||
mode="edit",
|
mode="edit",
|
||||||
current_user=self.current_user,
|
current_user=self.current_user,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
||||||
if diagnosis_signal is not None:
|
if diagnosis_signal is not None:
|
||||||
diagnosis_signal.connect(self._open_diagnosis_id)
|
diagnosis_signal.connect(self._open_diagnosis_id)
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
frozen_payload = MappingProxyType(dialog.payload())
|
frozen_payload = MappingProxyType(dialog.payload())
|
||||||
self._mutation_pending = True
|
self._mutation_pending = True
|
||||||
self._action_generation += 1
|
self._action_generation += 1
|
||||||
generation = self._action_generation
|
generation = self._action_generation
|
||||||
self.banner.show_message("正在保存处方…", "info")
|
self.banner.show_message("正在保存处方…", "info")
|
||||||
run_async(
|
run_async(
|
||||||
lambda: invoke(
|
lambda: invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"update_prescription",
|
"update_prescription",
|
||||||
prescription=prescription_id,
|
prescription=prescription_id,
|
||||||
changes=frozen_payload,
|
changes=frozen_payload,
|
||||||
),
|
),
|
||||||
on_success=lambda _result: self._prescription_updated(generation),
|
on_success=lambda _result: self._prescription_updated(generation),
|
||||||
on_error=lambda error: self._action_error(error, generation),
|
on_error=lambda error: self._action_error(error, generation),
|
||||||
on_finished=lambda: self._mutation_finished(generation),
|
on_finished=lambda: self._mutation_finished(generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _prescription_updated(self, generation: int) -> None:
|
def _prescription_updated(self, generation: int) -> None:
|
||||||
if generation != self._action_generation:
|
if generation != self._action_generation:
|
||||||
return
|
return
|
||||||
self.banner.show_message("处方已保存并重新进入待审核。", "success")
|
self.banner.show_message("处方已保存并重新进入待审核。", "success")
|
||||||
self.refresh(silent=True)
|
self.refresh(silent=True)
|
||||||
|
|
||||||
def _begin_case_record_load(self, row: Any) -> None:
|
def _begin_case_record_load(self, row: Any) -> None:
|
||||||
snapshot = deepcopy(row)
|
snapshot = deepcopy(row)
|
||||||
@@ -1304,10 +1308,10 @@ class AppointmentsPage(QWidget):
|
|||||||
"visit_no": build_prescription_visit_no(
|
"visit_no": build_prescription_visit_no(
|
||||||
diagnosis_id=diagnosis_id, appointment_id=appointment_id
|
diagnosis_id=diagnosis_id, appointment_id=appointment_id
|
||||||
),
|
),
|
||||||
"tongue": authoritative("tongue", "tongue_coating", default=""),
|
"tongue": authoritative("tongue", "tongue_coating", default=""),
|
||||||
"tongue_image": authoritative("tongue_image", default=""),
|
"tongue_image": authoritative("tongue_image", default=""),
|
||||||
"pulse": authoritative("pulse", default=""),
|
"pulse": authoritative("pulse", default=""),
|
||||||
"pulse_condition": authoritative("pulse_condition", default=""),
|
"pulse_condition": authoritative("pulse_condition", default=""),
|
||||||
"clinical_diagnosis": build_prescription_clinical_diagnosis(
|
"clinical_diagnosis": build_prescription_clinical_diagnosis(
|
||||||
diagnosis, patient, record, case_record
|
diagnosis, patient, record, case_record
|
||||||
),
|
),
|
||||||
@@ -1318,21 +1322,21 @@ class AppointmentsPage(QWidget):
|
|||||||
|
|
||||||
def _open_diagnosis_id(self, diagnosis_id: int) -> None:
|
def _open_diagnosis_id(self, diagnosis_id: int) -> None:
|
||||||
if diagnosis_id > 0:
|
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:
|
def _open_prescription_editor(self, record: Any, case_record: Any) -> None:
|
||||||
seed = self._prescription_seed(record, case_record)
|
seed = self._prescription_seed(record, case_record)
|
||||||
dialog = PrescriptionEditorDialog(
|
dialog = PrescriptionEditorDialog(
|
||||||
self.repository,
|
self.repository,
|
||||||
seed,
|
seed,
|
||||||
mode="add",
|
mode="add",
|
||||||
current_user=self.current_user,
|
current_user=self.current_user,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
||||||
if diagnosis_signal is not None:
|
if diagnosis_signal is not None:
|
||||||
diagnosis_signal.connect(self._open_diagnosis_id)
|
diagnosis_signal.connect(self._open_diagnosis_id)
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
payload = dialog.payload()
|
payload = dialog.payload()
|
||||||
payload["diagnosis_id"] = seed["diagnosis_id"]
|
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))
|
return _as_bool(first_value(record, "diagnosis_confirmed", "confirmed", default=False))
|
||||||
|
|
||||||
|
|
||||||
def prescription_action_label(record: Any) -> str:
|
def prescription_action_label(record: Any) -> str:
|
||||||
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
|
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
|
||||||
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
|
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
|
||||||
explicit = first_value(record, "has_prescription", default=None)
|
explicit = first_value(record, "has_prescription", default=None)
|
||||||
has_prescription = (
|
has_prescription = (
|
||||||
_as_bool(explicit)
|
_as_bool(explicit)
|
||||||
if explicit is not None
|
if explicit is not None
|
||||||
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0
|
else _as_int(first_value(record, "prescription_id", default=0), 0) > 0 or audit in {0, 1, 2}
|
||||||
or audit in {0, 1, 2}
|
)
|
||||||
)
|
if not has_prescription:
|
||||||
if not has_prescription:
|
return "开方"
|
||||||
return "开方"
|
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
|
||||||
return "查看处方" if audit == 1 and voided != 1 else "编辑处方"
|
|
||||||
|
|
||||||
|
|
||||||
def can_void_prescription(record: Any) -> bool:
|
def can_void_prescription(record: Any) -> bool:
|
||||||
@@ -1245,8 +1244,6 @@ class ConsultationsPage(QWidget):
|
|||||||
page_layout.addWidget(card)
|
page_layout.addWidget(card)
|
||||||
page_layout.addStretch(1)
|
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 = QTimer(self)
|
||||||
self.poll_timer.setInterval(20_000)
|
self.poll_timer.setInterval(20_000)
|
||||||
self.poll_timer.timeout.connect(self._poll_refresh)
|
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)
|
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:
|
def _open_readonly(self) -> None:
|
||||||
if not _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail"):
|
if not _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail"):
|
||||||
return
|
return
|
||||||
@@ -2701,56 +2707,56 @@ class ConsultationsPage(QWidget):
|
|||||||
def _prescription_loaded(self, existing: Any, record: Any, mode: str, generation: int) -> None:
|
def _prescription_loaded(self, existing: Any, record: Any, mode: str, generation: int) -> None:
|
||||||
if generation != self._prescription_generation:
|
if generation != self._prescription_generation:
|
||||||
return
|
return
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
self._last_prescription = existing
|
self._last_prescription = existing
|
||||||
if mode == "void":
|
if mode == "void":
|
||||||
self._confirm_void(existing)
|
self._confirm_void(existing)
|
||||||
return
|
return
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
|
approved = _as_int(first_value(existing, "audit_status", "status"), -1) == 1
|
||||||
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
|
voided = _as_int(first_value(existing, "void_status", "is_void"), 0) == 1
|
||||||
if not approved or voided:
|
if not approved or voided:
|
||||||
self._open_existing_prescription_editor(existing)
|
self._open_existing_prescription_editor(existing)
|
||||||
else:
|
else:
|
||||||
detail = PrescriptionDetailDialog(
|
detail = PrescriptionDetailDialog(
|
||||||
existing,
|
existing,
|
||||||
can_open_diagnosis=_canonical_allowed(
|
can_open_diagnosis=_canonical_allowed(
|
||||||
self.permissions, "tcm.diagnosis/readonlyDetail"
|
self.permissions, "tcm.diagnosis/readonlyDetail"
|
||||||
),
|
),
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
detail.diagnosis_requested.connect(self._open_diagnosis_id)
|
detail.diagnosis_requested.connect(self._open_diagnosis_id)
|
||||||
detail.exec()
|
detail.exec()
|
||||||
return
|
return
|
||||||
self._begin_case_record_load(record)
|
self._begin_case_record_load(record)
|
||||||
|
|
||||||
def _open_existing_prescription_editor(self, prescription: Any) -> None:
|
def _open_existing_prescription_editor(self, prescription: Any) -> None:
|
||||||
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
|
prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0))
|
||||||
if prescription_id <= 0:
|
if prescription_id <= 0:
|
||||||
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
|
self.banner.show_message("处方编号不完整,无法编辑。", "danger")
|
||||||
return
|
return
|
||||||
dialog = PrescriptionEditorDialog(
|
dialog = PrescriptionEditorDialog(
|
||||||
self.repository,
|
self.repository,
|
||||||
prescription,
|
prescription,
|
||||||
mode="edit",
|
mode="edit",
|
||||||
current_user=self.current_user,
|
current_user=self.current_user,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
||||||
if diagnosis_signal is not None:
|
if diagnosis_signal is not None:
|
||||||
diagnosis_signal.connect(self._open_diagnosis_id)
|
diagnosis_signal.connect(self._open_diagnosis_id)
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
frozen_payload = MappingProxyType(dialog.payload())
|
frozen_payload = MappingProxyType(dialog.payload())
|
||||||
self._run_mutation(
|
self._run_mutation(
|
||||||
lambda: invoke(
|
lambda: invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"update_prescription",
|
"update_prescription",
|
||||||
prescription=prescription_id,
|
prescription=prescription_id,
|
||||||
changes=frozen_payload,
|
changes=frozen_payload,
|
||||||
),
|
),
|
||||||
"处方已保存并重新进入待审核。",
|
"处方已保存并重新进入待审核。",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _begin_case_record_load(self, record: Any) -> None:
|
def _begin_case_record_load(self, record: Any) -> None:
|
||||||
record_snapshot = deepcopy(record)
|
record_snapshot = deepcopy(record)
|
||||||
@@ -2819,10 +2825,10 @@ class ConsultationsPage(QWidget):
|
|||||||
"visit_no": build_prescription_visit_no(
|
"visit_no": build_prescription_visit_no(
|
||||||
diagnosis_id=diagnosis_id, appointment_id=appointment_id
|
diagnosis_id=diagnosis_id, appointment_id=appointment_id
|
||||||
),
|
),
|
||||||
"tongue": authoritative("tongue", "tongue_coating", default=""),
|
"tongue": authoritative("tongue", "tongue_coating", default=""),
|
||||||
"tongue_image": authoritative("tongue_image", default=""),
|
"tongue_image": authoritative("tongue_image", default=""),
|
||||||
"pulse": authoritative("pulse", default=""),
|
"pulse": authoritative("pulse", default=""),
|
||||||
"pulse_condition": authoritative("pulse_condition", default=""),
|
"pulse_condition": authoritative("pulse_condition", default=""),
|
||||||
"clinical_diagnosis": build_prescription_clinical_diagnosis(
|
"clinical_diagnosis": build_prescription_clinical_diagnosis(
|
||||||
diagnosis, patient, record, case_record
|
diagnosis, patient, record, case_record
|
||||||
),
|
),
|
||||||
@@ -2836,17 +2842,17 @@ class ConsultationsPage(QWidget):
|
|||||||
|
|
||||||
def _open_prescription_editor(self, record: Any, case_record: Any) -> None:
|
def _open_prescription_editor(self, record: Any, case_record: Any) -> None:
|
||||||
seed = self._prescription_seed(record, case_record)
|
seed = self._prescription_seed(record, case_record)
|
||||||
dialog = PrescriptionEditorDialog(
|
dialog = PrescriptionEditorDialog(
|
||||||
self.repository,
|
self.repository,
|
||||||
seed,
|
seed,
|
||||||
mode="add",
|
mode="add",
|
||||||
current_user=self.current_user,
|
current_user=self.current_user,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
diagnosis_signal = getattr(dialog, "diagnosis_requested", None)
|
||||||
if diagnosis_signal is not None:
|
if diagnosis_signal is not None:
|
||||||
diagnosis_signal.connect(self._open_diagnosis_id)
|
diagnosis_signal.connect(self._open_diagnosis_id)
|
||||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
payload = dialog.payload()
|
payload = dialog.payload()
|
||||||
payload["diagnosis_id"] = seed["diagnosis_id"]
|
payload["diagnosis_id"] = seed["diagnosis_id"]
|
||||||
|
|||||||
@@ -1112,7 +1112,10 @@ class PatientListWorkspace(QWidget):
|
|||||||
self.keyword_edit.setClearButtonEnabled(True)
|
self.keyword_edit.setClearButtonEnabled(True)
|
||||||
self.keyword_edit.returnPressed.connect(self.search)
|
self.keyword_edit.returnPressed.connect(self.search)
|
||||||
grid.addWidget(self.keyword_edit, 0, 0, 1, 3)
|
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("全部状态", "")
|
||||||
self.status_combo.addItem("未预约", "unbooked")
|
self.status_combo.addItem("未预约", "unbooked")
|
||||||
self.status_combo.addItem("待面诊", "pending_interview")
|
self.status_combo.addItem("待面诊", "pending_interview")
|
||||||
@@ -1464,9 +1467,7 @@ class PatientListWorkspace(QWidget):
|
|||||||
self.table.set_rows(rows)
|
self.table.set_rows(rows)
|
||||||
for row_index in range(self.table.rowCount()):
|
for row_index in range(self.table.rowCount()):
|
||||||
source_item = self.table.item(row_index, 0)
|
source_item = self.table.item(row_index, 0)
|
||||||
source = (
|
source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
||||||
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])
|
_style_table_cell(self.table, row_index, 2, _patient_status(source)[1])
|
||||||
self.pager.update_state(self._page, page_total(result, len(rows)))
|
self.pager.update_state(self._page, page_total(result, len(rows)))
|
||||||
self.content_stack.setCurrentIndex(0 if rows else 1)
|
self.content_stack.setCurrentIndex(0 if rows else 1)
|
||||||
@@ -1776,9 +1777,7 @@ class PatientOrdersWorkspace(QWidget):
|
|||||||
self.table.set_rows(rows)
|
self.table.set_rows(rows)
|
||||||
for row_index in range(self.table.rowCount()):
|
for row_index in range(self.table.rowCount()):
|
||||||
source_item = self.table.item(row_index, 0)
|
source_item = self.table.item(row_index, 0)
|
||||||
source = (
|
source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
||||||
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)
|
prescription_audit = _as_int(first_value(source, "prescription_audit_status"), -1)
|
||||||
payment_audit = _as_int(first_value(source, "payment_slip_audit_status"), -1)
|
payment_audit = _as_int(first_value(source, "payment_slip_audit_status"), -1)
|
||||||
fulfillment = _as_int(first_value(source, "fulfillment_status"), -1)
|
fulfillment = _as_int(first_value(source, "fulfillment_status"), -1)
|
||||||
@@ -1795,9 +1794,7 @@ class PatientOrdersWorkspace(QWidget):
|
|||||||
_style_table_cell(
|
_style_table_cell(
|
||||||
self.table, row_index, 4, audit_kinds.get(prescription_audit, "muted")
|
self.table, row_index, 4, audit_kinds.get(prescription_audit, "muted")
|
||||||
)
|
)
|
||||||
_style_table_cell(
|
_style_table_cell(self.table, row_index, 5, audit_kinds.get(payment_audit, "muted"))
|
||||||
self.table, row_index, 5, audit_kinds.get(payment_audit, "muted")
|
|
||||||
)
|
|
||||||
_style_table_cell(self.table, row_index, 6, fulfillment_kind)
|
_style_table_cell(self.table, row_index, 6, fulfillment_kind)
|
||||||
self.pager.update_state(self._page, page_total(result, len(rows)))
|
self.pager.update_state(self._page, page_total(result, len(rows)))
|
||||||
self.content_stack.setCurrentIndex(0 if rows else 1)
|
self.content_stack.setCurrentIndex(0 if rows else 1)
|
||||||
@@ -2179,9 +2176,7 @@ class PatientProgressWorkspace(QWidget):
|
|||||||
self.queue_table.set_rows(rows)
|
self.queue_table.set_rows(rows)
|
||||||
for row_index in range(self.queue_table.rowCount()):
|
for row_index in range(self.queue_table.rowCount()):
|
||||||
source_item = self.queue_table.item(row_index, 0)
|
source_item = self.queue_table.item(row_index, 0)
|
||||||
source = (
|
source = source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
|
||||||
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()
|
queue_status = str(first_value(source, "queue_status", default="") or "").lower()
|
||||||
status_kind = {
|
status_kind = {
|
||||||
"consulting": "success",
|
"consulting": "success",
|
||||||
@@ -2296,8 +2291,6 @@ class PatientsPage(QWidget):
|
|||||||
self.tabs.addTab(self.progress_workspace, "面诊进度")
|
self.tabs.addTab(self.progress_workspace, "面诊进度")
|
||||||
root.addWidget(self.tabs, 1)
|
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.diagnosis_requested.connect(self._open_diagnosis)
|
||||||
self.patient_workspace.appointment_requested.connect(self._book_appointment)
|
self.patient_workspace.appointment_requested.connect(self._book_appointment)
|
||||||
self.patient_workspace.assign_requested.connect(self._load_assistants)
|
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.
|
# 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)))
|
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:
|
def _open_diagnosis(self, row: Any, editable: bool) -> None:
|
||||||
diagnosis_id = self._diagnosis_id(row)
|
diagnosis_id = self._diagnosis_id(row)
|
||||||
if diagnosis_id <= 0:
|
if diagnosis_id <= 0:
|
||||||
@@ -2348,9 +2349,9 @@ class PatientsPage(QWidget):
|
|||||||
show_toast(self, "当前账号没有该诊单权限。", "danger")
|
show_toast(self, "当前账号没有该诊单权限。", "danger")
|
||||||
return
|
return
|
||||||
if editable:
|
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:
|
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:
|
def _open_order_diagnosis(self, row: Any) -> None:
|
||||||
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
|
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
|
||||||
|
|||||||
@@ -327,13 +327,18 @@ class ReceptionPage(QWidget):
|
|||||||
splitter.setSizes([350, 760])
|
splitter.setSizes([350, 760])
|
||||||
root.addWidget(splitter, 1)
|
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 = QTimer(self)
|
||||||
self.poll_timer.setInterval(5_000)
|
self.poll_timer.setInterval(5_000)
|
||||||
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True))
|
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:
|
def _build_queue_panel(self) -> QWidget:
|
||||||
panel = QFrame()
|
panel = QFrame()
|
||||||
panel.setObjectName("Card")
|
panel.setObjectName("Card")
|
||||||
@@ -1355,9 +1360,7 @@ class ReceptionPage(QWidget):
|
|||||||
image_grid.setContentsMargins(0, 0, 0, 0)
|
image_grid.setContentsMargins(0, 0, 0, 0)
|
||||||
image_grid.setHorizontalSpacing(10)
|
image_grid.setHorizontalSpacing(10)
|
||||||
image_grid.setVerticalSpacing(10)
|
image_grid.setVerticalSpacing(10)
|
||||||
for index, (image_type, caption, path_text) in enumerate(
|
for index, (image_type, caption, path_text) in enumerate(image_attachments):
|
||||||
image_attachments
|
|
||||||
):
|
|
||||||
tile = QFrame()
|
tile = QFrame()
|
||||||
tile.setObjectName("NoteAttachmentTile")
|
tile.setObjectName("NoteAttachmentTile")
|
||||||
tile_layout = QVBoxLayout(tile)
|
tile_layout = QVBoxLayout(tile)
|
||||||
@@ -1376,13 +1379,9 @@ class ReceptionPage(QWidget):
|
|||||||
label = QLabel(f"{caption} · {_attachment_name(path_text)}")
|
label = QLabel(f"{caption} · {_attachment_name(path_text)}")
|
||||||
label.setObjectName("NoteAttachmentName")
|
label.setObjectName("NoteAttachmentName")
|
||||||
label.setToolTip(path_text)
|
label.setToolTip(path_text)
|
||||||
label.setTextInteractionFlags(
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
|
||||||
)
|
|
||||||
label.setMinimumWidth(0)
|
label.setMinimumWidth(0)
|
||||||
label.setSizePolicy(
|
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
|
|
||||||
)
|
|
||||||
footer.addWidget(label, 1)
|
footer.addWidget(label, 1)
|
||||||
if self._can_note and note_id is not None:
|
if self._can_note and note_id is not None:
|
||||||
delete_button = QPushButton("删除")
|
delete_button = QPushButton("删除")
|
||||||
@@ -1448,9 +1447,7 @@ class ReceptionPage(QWidget):
|
|||||||
return
|
return
|
||||||
run_async(
|
run_async(
|
||||||
lambda: invoke(self.repository, "download_public_image", url=path),
|
lambda: invoke(self.repository, "download_public_image", url=path),
|
||||||
on_success=lambda payload: self._apply_note_thumbnail(
|
on_success=lambda payload: self._apply_note_thumbnail(payload, preview, generation),
|
||||||
payload, preview, generation
|
|
||||||
),
|
|
||||||
on_error=lambda _error: self._fail_note_thumbnail(preview, generation),
|
on_error=lambda _error: self._fail_note_thumbnail(preview, generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1493,11 +1490,7 @@ class ReceptionPage(QWidget):
|
|||||||
return
|
return
|
||||||
if not _is_image_attachment(target):
|
if not _is_image_attachment(target):
|
||||||
url = QUrl(target)
|
url = QUrl(target)
|
||||||
if (
|
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
||||||
not url.isValid()
|
|
||||||
or url.scheme().lower() not in {"https", "http"}
|
|
||||||
or not url.host()
|
|
||||||
):
|
|
||||||
show_toast(self, "附件地址无效,无法打开。", "warning", 4200)
|
show_toast(self, "附件地址无效,无法打开。", "warning", 4200)
|
||||||
return
|
return
|
||||||
if not QDesktopServices.openUrl(url):
|
if not QDesktopServices.openUrl(url):
|
||||||
@@ -1520,9 +1513,7 @@ class ReceptionPage(QWidget):
|
|||||||
on_success=lambda payload: self._show_note_image_preview(
|
on_success=lambda payload: self._show_note_image_preview(
|
||||||
target, payload, button, generation
|
target, payload, button, generation
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._note_image_preview_failed(
|
on_error=lambda error: self._note_image_preview_failed(error, button, generation),
|
||||||
error, button, generation
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _show_note_image_preview(
|
def _show_note_image_preview(
|
||||||
@@ -1974,7 +1965,7 @@ class ReceptionPage(QWidget):
|
|||||||
if context is None or context[2] is None:
|
if context is None or context[2] is None:
|
||||||
show_toast(self, "当前患者缺少诊单编号。", "danger")
|
show_toast(self, "当前患者缺少诊单编号。", "danger")
|
||||||
return
|
return
|
||||||
self.diagnosis_dialog.open_for(
|
self._ensure_diagnosis_dialog().open_for(
|
||||||
context[2], editable=True, seed=self._selected_detail or self._selected_record
|
context[2], editable=True, seed=self._selected_detail or self._selected_record
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ class ShellWindow(QMainWindow):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("甄养堂 · 医生工作站")
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.login_payload = session
|
self.login_payload = session
|
||||||
self.session = get_value(session, "session", None) or 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._fixed_tab_key: str | None = None
|
||||||
self._sidebar_collapsed = False
|
self._sidebar_collapsed = False
|
||||||
|
|
||||||
self.setWindowTitle("甄养堂 · 医生工作站")
|
|
||||||
self.setMinimumSize(1024, 640)
|
self.setMinimumSize(1024, 640)
|
||||||
self.resize(1280, 800)
|
self.resize(1280, 800)
|
||||||
|
|
||||||
@@ -1033,6 +1034,7 @@ class ShellWindow(QMainWindow):
|
|||||||
self.repository,
|
self.repository,
|
||||||
permissions=self.permissions,
|
permissions=self.permissions,
|
||||||
current_user=self.current_user,
|
current_user=self.current_user,
|
||||||
|
parent=self.stack,
|
||||||
)
|
)
|
||||||
if hasattr(page, "video_requested"):
|
if hasattr(page, "video_requested"):
|
||||||
page.video_requested.connect(lambda payload: self.video_requested.emit(payload))
|
page.video_requested.connect(lambda payload: self.video_requested.emit(payload))
|
||||||
@@ -1109,6 +1111,11 @@ class ShellWindow(QMainWindow):
|
|||||||
if callable(refresh):
|
if callable(refresh):
|
||||||
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:
|
def set_connection_state(self, online: bool, message: str = "") -> None:
|
||||||
self.connection_badge.set_status(
|
self.connection_badge.set_status(
|
||||||
message or ("服务正常" if online else "连接中断"),
|
message or ("服务正常" if online else "连接中断"),
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ from datetime import date, datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QProgressBar,
|
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
@@ -543,30 +542,74 @@ def show_toast(parent: QWidget, text: str, kind: str = "info", duration: int = 2
|
|||||||
toast.show_message(text, kind, duration)
|
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):
|
class BusyOverlay(QFrame):
|
||||||
"""Non-blocking visual guard for a card or page while a worker is active."""
|
"""Non-blocking visual guard for a card or page while a worker is active."""
|
||||||
|
|
||||||
def __init__(self, parent: QWidget, text: str = "正在加载…") -> None:
|
def __init__(self, parent: QWidget, text: str = "正在加载…") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("BusyOverlay")
|
self.setObjectName("BusyOverlay")
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.setSpacing(10)
|
layout.setSpacing(10)
|
||||||
self.label = QLabel(text)
|
self.label = QLabel(text, self)
|
||||||
self.label.setProperty("role", "muted")
|
self.label.setProperty("role", "muted")
|
||||||
progress = QProgressBar()
|
self.progress = _BusyTrack(self)
|
||||||
progress.setObjectName("BusyOverlayProgress")
|
|
||||||
progress.setRange(0, 0)
|
|
||||||
progress.setFixedWidth(140)
|
|
||||||
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
|
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()
|
self.hide()
|
||||||
|
|
||||||
def set_message(self, text: str) -> None:
|
def set_message(self, text: str) -> None:
|
||||||
self.label.setText(text)
|
self.label.setText(text)
|
||||||
|
|
||||||
def showEvent(self, event: Any) -> None:
|
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_()
|
self.raise_()
|
||||||
super().showEvent(event)
|
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")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
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 PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation import app as app_module
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
from doctor_workstation.services import DemoDoctorRepository
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
from doctor_workstation.ui.dialogs import prescription as dialog_module
|
from doctor_workstation.ui.dialogs import prescription as dialog_module
|
||||||
@@ -530,8 +533,9 @@ def test_order_payload_and_a4_print_document(
|
|||||||
assert "林晓岚" in rendered
|
assert "林晓岚" in rendered
|
||||||
assert "黄芪" in rendered
|
assert "黄芪" in rendered
|
||||||
assert "酸枣仁" in rendered
|
assert "酸枣仁" in rendered
|
||||||
assert "服药前请核对姓名、电话、医生等信息" in rendered
|
assert "服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点" in rendered
|
||||||
assert '<table align="center" class="paper" width="794" height="1123"' 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 '<col width="44"/><col/><col width="64"/><col width="24"/>' in rendered
|
||||||
assert "Rp." in rendered
|
assert "Rp." in rendered
|
||||||
assert "药房联" in rendered
|
assert "药房联" in rendered
|
||||||
@@ -539,18 +543,93 @@ def test_order_payload_and_a4_print_document(
|
|||||||
assert "辅方" in rendered
|
assert "辅方" in rendered
|
||||||
assert "105克" in rendered
|
assert "105克" in rendered
|
||||||
assert "84克" in rendered
|
assert "84克" in rendered
|
||||||
assert "单剂量:</span> 27克" in rendered
|
assert "剂量:</span> 27克" in rendered
|
||||||
assert "每天2次, 一次2袋, 每袋5g, 温水送服, 饭后" 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)
|
viewer = PrescriptionDetailDialog(prescription)
|
||||||
document_html = viewer.document.toHtml()
|
document_html = viewer.document.toHtml()
|
||||||
assert "用药 (单剂)" in document_html
|
assert "用药 (单剂)" in document_html
|
||||||
assert viewer.document.idealWidth() == pytest.approx(794.0)
|
|
||||||
assert viewer.preview.__class__.__name__ == "_PrescriptionPaperPreview"
|
assert viewer.preview.__class__.__name__ == "_PrescriptionPaperPreview"
|
||||||
|
assert [viewer.tabs.tabText(index) for index in range(2)] == ["药房联", "处方联"]
|
||||||
viewer.close()
|
viewer.close()
|
||||||
order.close()
|
order.close()
|
||||||
application.processEvents()
|
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(
|
def test_prescription_detail_can_open_immutable_case_record_tab(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -569,7 +648,9 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
|
|||||||
}
|
}
|
||||||
viewer = PrescriptionDetailDialog(prescription, initial_tab="case")
|
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.tabs.tabText(viewer.tabs.currentIndex()) == "详细病历"
|
||||||
assert viewer.case_document is not None
|
assert viewer.case_document is not None
|
||||||
case_html = viewer.case_document.toHtml()
|
case_html = viewer.case_document.toHtml()
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ class _ShellPageDouble(QWidget):
|
|||||||
*,
|
*,
|
||||||
permissions: Any,
|
permissions: Any,
|
||||||
current_user: Any,
|
current_user: Any,
|
||||||
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__(parent)
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.current_user = current_user
|
self.current_user = current_user
|
||||||
self.refresh_count = 0
|
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"
|
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(
|
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
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 PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox
|
||||||
|
|
||||||
from doctor_workstation import app as app_module
|
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_label.geometry().bottom() < window.server_url_edit.geometry().top()
|
||||||
assert window.server_url_edit.geometry().bottom() < window.timeout_label.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_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()
|
assert window.allow_self_signed_check.geometry().bottom() < window.server_hint.geometry().top()
|
||||||
|
|
||||||
window.allow_self_signed_check.setChecked(True)
|
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)
|
app_module._install_chinese_translations(application)
|
||||||
|
|
||||||
question = QMessageBox()
|
question = QMessageBox()
|
||||||
question.setStandardButtons(
|
question.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
|
|
||||||
)
|
|
||||||
assert question.button(QMessageBox.StandardButton.Yes).text() == "是"
|
assert question.button(QMessageBox.StandardButton.Yes).text() == "是"
|
||||||
assert question.button(QMessageBox.StandardButton.Cancel).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.Ok).text() == "确定"
|
||||||
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
|
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
|
||||||
assert buttons.button(QDialogButtonBox.StandardButton.Close).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:
|
def test_friendly_error_hides_english_technical_messages() -> None:
|
||||||
message = friendly_error(
|
message = friendly_error(TypeError("invoke() takes 2 positional arguments but 3 were given"))
|
||||||
TypeError("invoke() takes 2 positional arguments but 3 were given")
|
|
||||||
)
|
|
||||||
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
|
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
|
||||||
assert friendly_error(RuntimeError("API response envelope must be an object")) == (
|
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 = LoginWindow(object(), config=config, settings=settings)
|
||||||
|
|
||||||
window._on_login_error(
|
window._on_login_error(RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate"))
|
||||||
RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert window.server_toggle.isChecked()
|
assert window.server_toggle.isChecked()
|
||||||
assert not window.server_panel.isHidden()
|
assert not window.server_panel.isHidden()
|
||||||
|
|||||||
Binary file not shown.
@@ -961,6 +961,8 @@ class ConversionLogic
|
|||||||
*
|
*
|
||||||
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
|
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
|
||||||
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
||||||
|
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
|
||||||
|
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
|
||||||
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
|
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
|
||||||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
|
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
|
||||||
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||||
@@ -987,7 +989,7 @@ class ConversionLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseKey = self::requestRowsCacheKey('fans-effective-v3', [
|
$baseKey = self::requestRowsCacheKey('fans-effective-v4', [
|
||||||
$startTimestamp,
|
$startTimestamp,
|
||||||
$endTimestamp,
|
$endTimestamp,
|
||||||
self::mediaChannelCacheKey($mediaChannel),
|
self::mediaChannelCacheKey($mediaChannel),
|
||||||
@@ -1050,6 +1052,14 @@ class ConversionLogic
|
|||||||
. ' AND del_e.event_time <= ?)',
|
. ' AND del_e.event_time <= ?)',
|
||||||
['del_external_contact', $endTimestamp]
|
['del_external_contact', $endTimestamp]
|
||||||
)
|
)
|
||||||
|
->whereRaw(
|
||||||
|
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
|
||||||
|
. ' WHERE prev_e.user_id = e.user_id'
|
||||||
|
. ' AND prev_e.external_userid = e.external_userid'
|
||||||
|
. ' AND prev_e.change_type = ?'
|
||||||
|
. ' AND prev_e.event_time < ?)',
|
||||||
|
['add_external_contact', $startTimestamp]
|
||||||
|
)
|
||||||
->field(['e.user_id', 'e.external_userid'])
|
->field(['e.user_id', 'e.external_userid'])
|
||||||
->group('e.user_id, e.external_userid');
|
->group('e.user_id, e.external_userid');
|
||||||
if ($workWechatUserIds !== null) {
|
if ($workWechatUserIds !== null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user