更新
This commit is contained in:
@@ -145,7 +145,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户、继承客户及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -330,7 +330,7 @@ const timeOptions = [
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户及区间前已加过的重加' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
# -*- 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")
|
||||
@@ -1,87 +0,0 @@
|
||||
# -*- 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))
|
||||
@@ -756,7 +756,7 @@ class HerbRowWidget(QFrame):
|
||||
self.setObjectName("PrescriptionHerbCard" if card_mode else "SubtleCard")
|
||||
self.setProperty("duplicate", False)
|
||||
self._locked = locked
|
||||
self.formula_combo = QComboBox()
|
||||
self.formula_combo = QComboBox(self)
|
||||
self.formula_combo.addItem("主方", "主方")
|
||||
self.formula_combo.addItem("辅方", "辅方")
|
||||
_set_combo_data(
|
||||
@@ -769,9 +769,10 @@ class HerbRowWidget(QFrame):
|
||||
repository,
|
||||
medicine_id=first_value(herb, "medicine_id", "id", default=None),
|
||||
name=str(first_value(herb, "name", "medicine_name", default="")),
|
||||
parent=self,
|
||||
)
|
||||
self.medicine.setEnabled(not locked)
|
||||
self.dosage = QDoubleSpinBox()
|
||||
self.dosage = QDoubleSpinBox(self)
|
||||
self.dosage.setRange(0, 99999)
|
||||
self.dosage.setDecimals(1)
|
||||
self.dosage.setSingleStep(0.5)
|
||||
@@ -779,7 +780,7 @@ class HerbRowWidget(QFrame):
|
||||
self.dosage.setValue(_float(first_value(herb, "dosage", "amount", default=0)))
|
||||
self.dosage.setEnabled(not locked)
|
||||
self.dosage.setMaximumWidth(130)
|
||||
self.remove_button = QPushButton("删除")
|
||||
self.remove_button = QPushButton("删除", self)
|
||||
self.remove_button.setProperty("variant", "ghost")
|
||||
self.remove_button.setVisible(not locked)
|
||||
self.remove_button.clicked.connect(lambda: self.remove_requested.emit(self))
|
||||
@@ -871,14 +872,14 @@ class HerbEditor(QWidget):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(8)
|
||||
self.actions_host = QWidget()
|
||||
self.actions_host = QWidget(self)
|
||||
actions = QHBoxLayout(self.actions_host)
|
||||
actions.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_button = QPushButton("+ 添加主方药材")
|
||||
self.main_button = QPushButton("+ 添加主方药材", self.actions_host)
|
||||
self.main_button.setProperty("variant", "secondary")
|
||||
self.main_button.clicked.connect(lambda: self.add_row(formula_type="主方"))
|
||||
actions.addWidget(self.main_button)
|
||||
self.aux_button = QPushButton("+ 添加辅方药材")
|
||||
self.aux_button = QPushButton("+ 添加辅方药材", self.actions_host)
|
||||
self.aux_button.setProperty("variant", "secondary")
|
||||
self.aux_button.clicked.connect(lambda: self.add_row(formula_type="辅方"))
|
||||
self.aux_button.setVisible(show_formula)
|
||||
@@ -1786,7 +1787,7 @@ class PrescriptionEditorDialog(QDialog):
|
||||
copy.addWidget(subtitle)
|
||||
layout.addLayout(copy, 1)
|
||||
diagnosis_id = _int(self._source.get("diagnosis_id"), 0)
|
||||
self.diagnosis_button = QPushButton("查看患者诊单详情")
|
||||
self.diagnosis_button = QPushButton("查看患者诊单详情", header)
|
||||
self.diagnosis_button.setObjectName("PrescriptionDrawerDiagnosisButton")
|
||||
self.diagnosis_button.setVisible(diagnosis_id > 0)
|
||||
self.diagnosis_button.clicked.connect(
|
||||
@@ -1961,7 +1962,7 @@ class PrescriptionEditorDialog(QDialog):
|
||||
self.add_aux_button = QPushButton("添加辅方")
|
||||
self.add_aux_button.clicked.connect(lambda: self.herbs.add_row(formula_type="辅方"))
|
||||
actions.addWidget(self.add_aux_button)
|
||||
self.import_library_button = QPushButton("从处方库导入")
|
||||
self.import_library_button = QPushButton("从处方库导入", actions_widget)
|
||||
self.import_library_button.clicked.connect(self._import_library)
|
||||
library_default = self.permissions is None
|
||||
self.import_library_button.setVisible(
|
||||
@@ -2714,7 +2715,7 @@ def _status_text(value: Any) -> str:
|
||||
return {0: "待审核", 1: "已通过", 2: "已驳回"}.get(status, display_text(status))
|
||||
|
||||
|
||||
def render_case_record_html(prescription: Any) -> str:
|
||||
def render_case_record_html(prescription: Any, *, print_layout: bool = False) -> str:
|
||||
"""Render the immutable diagnosis snapshot as the admin A3 case sheet.
|
||||
|
||||
``QTextDocument`` only implements a deliberately small HTML/CSS subset. In
|
||||
@@ -2918,13 +2919,17 @@ def render_case_record_html(prescription: Any) -> str:
|
||||
),
|
||||
),
|
||||
]
|
||||
body_padding = "0" if print_layout else "12px"
|
||||
body_background = "#ffffff" if print_layout else "#eef1f5"
|
||||
paper_border = "0" if print_layout else "1px solid #dcdfe6"
|
||||
content_padding = "36px 42px 44px" if print_layout else "26px 30px 34px"
|
||||
return f"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body {{ margin:0; padding:12px; background:#eef1f5; color:#303133;
|
||||
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#303133;
|
||||
font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
|
||||
font-size:13px; }}
|
||||
.paper {{ width:100%; margin:0 auto; background:#ffffff; border:1px solid #dcdfe6; }}
|
||||
.paper-content {{ padding:26px 30px 34px; vertical-align:top; }}
|
||||
.paper {{ width:100%; margin:0 auto; background:#ffffff; border:{paper_border}; }}
|
||||
.paper-content {{ padding:{content_padding}; vertical-align:top; }}
|
||||
h1 {{ text-align:center; font-size:20px; font-weight:600; margin:0;
|
||||
padding:0 0 14px; border-bottom:2px solid #303133; }}
|
||||
.cr-section {{ margin-top:14px; border-collapse:collapse; }}
|
||||
@@ -3510,6 +3515,40 @@ def render_prescription_slip_image(
|
||||
return image
|
||||
|
||||
|
||||
def render_case_record_image(
|
||||
prescription: Any,
|
||||
*,
|
||||
width: int = 1123,
|
||||
scale: float = 2.0,
|
||||
) -> QImage:
|
||||
"""Rasterize the immutable detailed case record for A3 print/PDF output."""
|
||||
|
||||
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_case_record_html(prescription, print_layout=True))
|
||||
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(), 300.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)
|
||||
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."""
|
||||
|
||||
@@ -3652,12 +3691,12 @@ class PrescriptionDetailDialog(QDialog):
|
||||
self.resize(920, 780)
|
||||
root = QVBoxLayout(self)
|
||||
actions = QHBoxLayout()
|
||||
self.diagnosis_button = QPushButton("查看诊单详情")
|
||||
self.diagnosis_button = QPushButton("查看诊单详情", self)
|
||||
diagnosis_id = _int(first_value(prescription, "diagnosis_id"), 0)
|
||||
self.diagnosis_button.setVisible(can_open_diagnosis and diagnosis_id > 0)
|
||||
self.diagnosis_button.clicked.connect(lambda: self.diagnosis_requested.emit(diagnosis_id))
|
||||
actions.addWidget(self.diagnosis_button)
|
||||
self.orders_button = QPushButton("查看关联订单")
|
||||
self.orders_button = QPushButton("查看关联订单", self)
|
||||
prescription_id = _int(first_value(prescription, "id", "prescription_id"), 0)
|
||||
self.orders_button.setVisible(can_open_orders and prescription_id > 0)
|
||||
self.orders_button.clicked.connect(lambda: self.orders_requested.emit(prescription_id))
|
||||
@@ -3671,7 +3710,7 @@ class PrescriptionDetailDialog(QDialog):
|
||||
pdf_button.clicked.connect(self.choose_pdf_path)
|
||||
actions.addWidget(pdf_button)
|
||||
root.addLayout(actions)
|
||||
self.status_banner = MessageBanner()
|
||||
self.status_banner = MessageBanner(parent=self)
|
||||
self.status_banner.setObjectName("PrescriptionDetailStatusBanner")
|
||||
status_lines: list[str] = []
|
||||
if _bool(first_value(prescription, "void_status", "is_void", default=False)):
|
||||
@@ -3746,15 +3785,30 @@ class PrescriptionDetailDialog(QDialog):
|
||||
def _current_variant(self) -> str:
|
||||
return "user" if self.tabs.tabText(self.tabs.currentIndex()) == "处方联" else "internal"
|
||||
|
||||
def _is_case_record_selected(self) -> bool:
|
||||
return self.tabs.tabText(self.tabs.currentIndex()) == "详细病历"
|
||||
|
||||
def _current_output_image(self) -> QImage:
|
||||
if self._is_case_record_selected():
|
||||
return render_case_record_image(self.prescription)
|
||||
return render_prescription_slip_image(
|
||||
self.prescription,
|
||||
variant=self._current_variant(),
|
||||
)
|
||||
|
||||
def _current_page_size(self) -> QPageSize:
|
||||
page_id = (
|
||||
QPageSize.PageSizeId.A3 if self._is_case_record_selected() else QPageSize.PageSizeId.A4
|
||||
)
|
||||
return QPageSize(page_id)
|
||||
|
||||
def print_slip(self) -> None:
|
||||
printer = QPrinter(QPrinter.PrinterMode.ScreenResolution)
|
||||
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
||||
printer.setPageSize(self._current_page_size())
|
||||
printer.setPageMargins(QMarginsF(8, 8, 8, 8), QPageLayout.Unit.Millimeter)
|
||||
dialog = QPrintDialog(printer, self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
image = render_prescription_slip_image(
|
||||
self.prescription, variant=self._current_variant()
|
||||
)
|
||||
image = self._current_output_image()
|
||||
painter = QPainter(printer)
|
||||
try:
|
||||
_draw_slip_image_on_page(painter, image)
|
||||
@@ -3762,12 +3816,18 @@ class PrescriptionDetailDialog(QDialog):
|
||||
painter.end()
|
||||
|
||||
def choose_pdf_path(self) -> None:
|
||||
patient = str(first_value(self.prescription, "patient_name", default="处方"))
|
||||
prescription_id = first_value(self.prescription, "id", default="")
|
||||
suggested = f"处方-{patient}-{prescription_id}.pdf"
|
||||
patient = str(first_value(self.prescription, "patient_name", default="患者"))
|
||||
if self._is_case_record_selected():
|
||||
diagnosis_id = first_value(self.prescription, "diagnosis_id", default="")
|
||||
suggested = f"病历-{patient}-{diagnosis_id}.pdf"
|
||||
caption = "导出病历 PDF"
|
||||
else:
|
||||
prescription_id = first_value(self.prescription, "id", default="")
|
||||
suggested = f"处方-{patient}-{prescription_id}.pdf"
|
||||
caption = "导出处方 PDF"
|
||||
path, _selected = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"导出处方 PDF",
|
||||
caption,
|
||||
suggested,
|
||||
"PDF 文件 (*.pdf)",
|
||||
)
|
||||
@@ -3778,23 +3838,30 @@ class PrescriptionDetailDialog(QDialog):
|
||||
output = str(path)
|
||||
if not output.lower().endswith(".pdf"):
|
||||
output += ".pdf"
|
||||
image = render_prescription_slip_image(self.prescription, variant=self._current_variant())
|
||||
is_case_record = self._is_case_record_selected()
|
||||
output_label = "详细病历" if is_case_record else "处方笺"
|
||||
image = self._current_output_image()
|
||||
if count_slip_ink_pixels(image) <= 0:
|
||||
QMessageBox.warning(self, "导出失败", "处方笺未能生成可见内容,请重试。")
|
||||
QMessageBox.warning(self, "导出失败", f"{output_label}未能生成可见内容,请重试。")
|
||||
return
|
||||
page_size = QPageSize(QPageSize.PageSizeId.A4)
|
||||
page_size = self._current_page_size()
|
||||
page_label = "A3" if is_case_record else "A4"
|
||||
if not page_size.isValid():
|
||||
QMessageBox.warning(self, "导出失败", "无法创建有效的 A4 页面,请重试。")
|
||||
QMessageBox.warning(self, "导出失败", f"无法创建有效的 {page_label} 页面,请重试。")
|
||||
return
|
||||
writer = QPdfWriter(output)
|
||||
if not writer.setPageSize(page_size):
|
||||
QMessageBox.warning(self, "导出失败", "无法创建有效的 A4 页面,请重试。")
|
||||
QMessageBox.warning(self, "导出失败", f"无法创建有效的 {page_label} 页面,请重试。")
|
||||
return
|
||||
if (
|
||||
not writer.setPageMargins(QMarginsF(8, 8, 8, 8), QPageLayout.Unit.Millimeter)
|
||||
or not writer.pageLayout().isValid()
|
||||
):
|
||||
QMessageBox.warning(self, "导出失败", "无法设置 A4 页面边距,请重试。")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"导出失败",
|
||||
f"无法设置 {page_label} 页面边距,请重试。",
|
||||
)
|
||||
return
|
||||
writer.setResolution(300)
|
||||
painter = QPainter()
|
||||
@@ -4074,18 +4141,18 @@ class PrescriptionOrderDialog(QDialog):
|
||||
self.amount.setDecimals(2)
|
||||
self.amount.setPrefix("¥ ")
|
||||
form.addRow("订单金额 *", self.amount)
|
||||
self.internal_cost = QDoubleSpinBox()
|
||||
self.internal_cost = QDoubleSpinBox(tab)
|
||||
self.internal_cost.setRange(0, 10_000_000)
|
||||
self.internal_cost.setDecimals(2)
|
||||
self.internal_cost.setPrefix("¥ ")
|
||||
self.internal_cost.setVisible(self.can_view_internal_cost)
|
||||
self.internal_cost_label = QLabel("内部成本")
|
||||
self.internal_cost_label = QLabel("内部成本", tab)
|
||||
self.internal_cost_label.setVisible(self.can_view_internal_cost)
|
||||
form.addRow(self.internal_cost_label, self.internal_cost)
|
||||
self.remark_extra = QTextEdit()
|
||||
self.remark_extra = QTextEdit(tab)
|
||||
self.remark_extra.setMaximumHeight(80)
|
||||
self.remark_extra.setVisible(self.can_edit_pharmacy_remark)
|
||||
self.remark_extra_label = QLabel("药房备注")
|
||||
self.remark_extra_label = QLabel("药房备注", tab)
|
||||
self.remark_extra_label.setVisible(self.can_edit_pharmacy_remark)
|
||||
form.addRow(self.remark_extra_label, self.remark_extra)
|
||||
self.remark_assistant = QTextEdit()
|
||||
|
||||
@@ -427,7 +427,7 @@ class AppointmentsPage(QWidget):
|
||||
self.patient_input.returnPressed.connect(self._search)
|
||||
layout.addWidget(self.patient_input)
|
||||
|
||||
self.doctor_input = QLineEdit()
|
||||
self.doctor_input = QLineEdit(frame)
|
||||
self.doctor_input.setPlaceholderText("医生")
|
||||
self.doctor_input.setClearButtonEnabled(True)
|
||||
self.doctor_input.setMaximumWidth(120)
|
||||
@@ -572,7 +572,10 @@ class AppointmentsPage(QWidget):
|
||||
*,
|
||||
danger: bool = False,
|
||||
) -> QPushButton:
|
||||
button = QPushButton(label)
|
||||
# Permission visibility is evaluated before the toolbar layout adopts
|
||||
# the button. Give it a real owner up front so setVisible(True) cannot
|
||||
# create a transient top-level HWND on Windows.
|
||||
button = QPushButton(label, self)
|
||||
button.setProperty("variant", "danger" if danger else "secondary")
|
||||
button.setVisible(_canonical_allowed(self.permissions, permission, default=False))
|
||||
button.setEnabled(False)
|
||||
|
||||
@@ -965,7 +965,7 @@ class ConsultationsPage(QWidget):
|
||||
self.completed_button = DiagnosisChip("已完成", semantic="success")
|
||||
self.completed_button.clicked.connect(self._choose_completed)
|
||||
self.main_chip_flow.flow.addWidget(self.completed_button)
|
||||
self.pending_assign_wrap = QWidget()
|
||||
self.pending_assign_wrap = QWidget(self)
|
||||
pending_assign_wrap_layout = QHBoxLayout(self.pending_assign_wrap)
|
||||
pending_assign_wrap_layout.setContentsMargins(8, 0, 0, 0)
|
||||
pending_assign_wrap_layout.setSpacing(8)
|
||||
@@ -1135,17 +1135,17 @@ class ConsultationsPage(QWidget):
|
||||
toolbar_layout = QHBoxLayout(toolbar)
|
||||
toolbar_layout.setContentsMargins(16, 10, 16, 10)
|
||||
toolbar_layout.setSpacing(8)
|
||||
self.add_button = QPushButton("新增诊单")
|
||||
self.add_button = QPushButton("新增诊单", toolbar)
|
||||
self.add_button.setProperty("variant", "primary")
|
||||
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
|
||||
self.add_button.clicked.connect(self._add_diagnosis)
|
||||
toolbar_layout.addWidget(self.add_button)
|
||||
self.batch_assign_button = QPushButton("批量指派医助")
|
||||
self.batch_assign_button = QPushButton("批量指派医助", toolbar)
|
||||
self.batch_assign_button.setProperty("variant", "success")
|
||||
self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
|
||||
self.batch_assign_button.clicked.connect(self._batch_assign)
|
||||
toolbar_layout.addWidget(self.batch_assign_button)
|
||||
self.batch_cancel_assign_button = QPushButton("批量取消指派")
|
||||
self.batch_cancel_assign_button = QPushButton("批量取消指派", toolbar)
|
||||
self.batch_cancel_assign_button.setProperty("variant", "warning")
|
||||
self.batch_cancel_assign_button.setVisible(
|
||||
_canonical_allowed(permissions, "tcm.diagnosis/assign")
|
||||
|
||||
@@ -352,7 +352,7 @@ class _LegacyAppointmentDialog(QDialog):
|
||||
self.remark.setPlaceholderText("预约备注(可选)")
|
||||
form.addRow("备注", self.remark)
|
||||
root.addLayout(form)
|
||||
self.banner = MessageBanner()
|
||||
self.banner = MessageBanner(parent=self)
|
||||
self.banner.show_message("正在加载医生、渠道与今日挂号状态…", "info")
|
||||
root.addWidget(self.banner)
|
||||
buttons = QDialogButtonBox(
|
||||
@@ -1297,25 +1297,25 @@ class PatientListWorkspace(QWidget):
|
||||
def _build_actions(self) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
self.diagnosis_button = QPushButton("诊单")
|
||||
self.diagnosis_button = QPushButton("诊单", self)
|
||||
self.diagnosis_button.setProperty("variant", "primary")
|
||||
self.diagnosis_button.clicked.connect(self._open_selected_diagnosis)
|
||||
layout.addWidget(self.diagnosis_button)
|
||||
self.appointment_button = QPushButton("预约")
|
||||
self.appointment_button = QPushButton("预约", self)
|
||||
self.appointment_button.clicked.connect(
|
||||
lambda: self._emit_selected(self.appointment_requested)
|
||||
)
|
||||
layout.addWidget(self.appointment_button)
|
||||
self.assign_button = QPushButton("指派医助")
|
||||
self.assign_button = QPushButton("指派医助", self)
|
||||
self.assign_button.clicked.connect(lambda: self._emit_selected(self.assign_requested))
|
||||
layout.addWidget(self.assign_button)
|
||||
self.fill_id_button = QPushButton("补全身份证")
|
||||
self.fill_id_button = QPushButton("补全身份证", self)
|
||||
self.fill_id_button.clicked.connect(lambda: self._emit_selected(self.fill_id_requested))
|
||||
layout.addWidget(self.fill_id_button)
|
||||
self.orders_button = QPushButton("关联订单")
|
||||
self.orders_button = QPushButton("关联订单", self)
|
||||
self.orders_button.clicked.connect(lambda: self._emit_selected(self.orders_requested))
|
||||
layout.addWidget(self.orders_button)
|
||||
self.cancel_button = QPushButton("取消挂号")
|
||||
self.cancel_button = QPushButton("取消挂号", self)
|
||||
self.cancel_button.setProperty("variant", "danger")
|
||||
self.cancel_button.clicked.connect(lambda: self._emit_selected(self.cancel_requested))
|
||||
layout.addWidget(self.cancel_button)
|
||||
|
||||
@@ -97,7 +97,7 @@ class PrescriptionLibraryPage(QWidget):
|
||||
"我的处方库",
|
||||
"管理可复用药材组合;公开模板可被其他医生导入,禁用修改仅作用于导入后的处方。",
|
||||
)
|
||||
self.new_button = QPushButton("+ 新增处方")
|
||||
self.new_button = QPushButton("+ 新增处方", header)
|
||||
self.new_button.setProperty("variant", "primary")
|
||||
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
|
||||
self.new_button.clicked.connect(self._new_template)
|
||||
@@ -147,17 +147,17 @@ class PrescriptionLibraryPage(QWidget):
|
||||
title.setProperty("role", "sectionTitle")
|
||||
toolbar.addWidget(title)
|
||||
toolbar.addStretch(1)
|
||||
self.view_button = QPushButton("查看")
|
||||
self.view_button = QPushButton("查看", card)
|
||||
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
|
||||
self.view_button.setEnabled(False)
|
||||
self.view_button.clicked.connect(self._view_selected)
|
||||
toolbar.addWidget(self.view_button)
|
||||
self.edit_button = QPushButton("编辑")
|
||||
self.edit_button = QPushButton("编辑", card)
|
||||
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
|
||||
self.edit_button.setEnabled(False)
|
||||
self.edit_button.clicked.connect(self._edit_selected)
|
||||
toolbar.addWidget(self.edit_button)
|
||||
self.delete_button = QPushButton("删除")
|
||||
self.delete_button = QPushButton("删除", card)
|
||||
self.delete_button.setProperty("variant", "danger")
|
||||
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
|
||||
self.delete_button.setEnabled(False)
|
||||
|
||||
@@ -280,11 +280,11 @@ class PrescriptionsPage(QWidget):
|
||||
"已开处方",
|
||||
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
|
||||
)
|
||||
self.orders_button = QPushButton("业务订单")
|
||||
self.orders_button = QPushButton("业务订单", header)
|
||||
self.orders_button.setVisible(has_permission(permissions, "tcm.prescriptionOrder/lists"))
|
||||
self.orders_button.clicked.connect(lambda: self._open_orders())
|
||||
header.add_action(self.orders_button)
|
||||
self.add_button = QPushButton("+ 新增处方")
|
||||
self.add_button = QPushButton("+ 新增处方", header)
|
||||
self.add_button.setProperty("variant", "primary")
|
||||
self.add_button.setVisible(has_permission(permissions, "cf.prescription/add"))
|
||||
self.add_button.clicked.connect(self._add_prescription)
|
||||
@@ -436,7 +436,9 @@ class PrescriptionsPage(QWidget):
|
||||
*,
|
||||
danger: bool = False,
|
||||
) -> QPushButton:
|
||||
button = QPushButton(text)
|
||||
# Keep permission-driven initial visibility inside the page hierarchy;
|
||||
# otherwise Windows briefly exposes the button as its own window.
|
||||
button = QPushButton(text, self)
|
||||
if danger:
|
||||
button.setProperty("variant", "danger")
|
||||
button.setVisible(has_permission(self.permissions, permission))
|
||||
|
||||
@@ -434,13 +434,13 @@ class ReceptionPage(QWidget):
|
||||
self.video_button.setProperty("variant", "secondary")
|
||||
self.video_button.clicked.connect(self._request_video)
|
||||
action_row.addWidget(self.video_button)
|
||||
self.edit_button = QPushButton("编辑病历")
|
||||
self.edit_button = QPushButton("编辑病历", content)
|
||||
self.edit_button.setProperty("variant", "secondary")
|
||||
self.edit_button.clicked.connect(self._edit_diagnosis)
|
||||
self.edit_button.setVisible(self._can_edit)
|
||||
action_row.addWidget(self.edit_button)
|
||||
action_row.addStretch(1)
|
||||
self.complete_button = QPushButton("完成接诊")
|
||||
self.complete_button = QPushButton("完成接诊", content)
|
||||
self.complete_button.setProperty("variant", "primary")
|
||||
self.complete_button.clicked.connect(self._complete_appointment)
|
||||
self.complete_button.setVisible(self._can_complete)
|
||||
@@ -520,32 +520,32 @@ class ReceptionPage(QWidget):
|
||||
self.notes_layout.setSpacing(8)
|
||||
detail_layout.addWidget(self.notes_container)
|
||||
|
||||
self.note_edit = QTextEdit()
|
||||
self.note_edit = QTextEdit(content)
|
||||
self.note_edit.setPlaceholderText("记录本次沟通要点(最多 500 字,不会自动提交)")
|
||||
self.note_edit.setMaximumHeight(105)
|
||||
self.note_edit.textChanged.connect(self._limit_note_text)
|
||||
self.note_edit.setVisible(self._can_note)
|
||||
detail_layout.addWidget(self.note_edit)
|
||||
self.note_counter = QLabel(f"0 / {NOTE_LIMIT}")
|
||||
self.note_counter = QLabel(f"0 / {NOTE_LIMIT}", content)
|
||||
self.note_counter.setProperty("role", "muted")
|
||||
self.note_counter.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.note_counter.setVisible(self._can_note)
|
||||
detail_layout.addWidget(self.note_counter)
|
||||
|
||||
attachment_actions = QHBoxLayout()
|
||||
self.add_tongue_button = QPushButton("添加舌苔图")
|
||||
self.add_tongue_button = QPushButton("添加舌苔图", content)
|
||||
self.add_tongue_button.setProperty("variant", "secondary")
|
||||
self.add_tongue_button.clicked.connect(self._choose_tongue_images)
|
||||
self.add_tongue_button.setVisible(self._can_note)
|
||||
attachment_actions.addWidget(self.add_tongue_button)
|
||||
self.add_report_button = QPushButton("添加检查报告")
|
||||
self.add_report_button = QPushButton("添加检查报告", content)
|
||||
self.add_report_button.setProperty("variant", "secondary")
|
||||
self.add_report_button.clicked.connect(self._choose_report_files)
|
||||
self.add_report_button.setVisible(self._can_note)
|
||||
attachment_actions.addWidget(self.add_report_button)
|
||||
attachment_actions.addStretch(1)
|
||||
detail_layout.addLayout(attachment_actions)
|
||||
self.pending_attachments = QWidget()
|
||||
self.pending_attachments = QWidget(content)
|
||||
self.pending_attachments_layout = QVBoxLayout(self.pending_attachments)
|
||||
self.pending_attachments_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.pending_attachments_layout.setSpacing(4)
|
||||
@@ -554,7 +554,7 @@ class ReceptionPage(QWidget):
|
||||
|
||||
note_action = QHBoxLayout()
|
||||
note_action.addStretch(1)
|
||||
self.save_note_button = QPushButton("保存备注")
|
||||
self.save_note_button = QPushButton("保存备注", content)
|
||||
self.save_note_button.setProperty("variant", "secondary")
|
||||
self.save_note_button.clicked.connect(self._save_note)
|
||||
self.save_note_button.setVisible(self._can_note)
|
||||
|
||||
@@ -392,10 +392,10 @@ class PageHeader(QWidget):
|
||||
layout.setSpacing(16)
|
||||
text_layout = QVBoxLayout()
|
||||
text_layout.setSpacing(3)
|
||||
self.title_label = QLabel(title)
|
||||
self.title_label = QLabel(title, self)
|
||||
self.title_label.setProperty("role", "pageTitle")
|
||||
text_layout.addWidget(self.title_label)
|
||||
self.subtitle_label = QLabel(subtitle)
|
||||
self.subtitle_label = QLabel(subtitle, self)
|
||||
self.subtitle_label.setProperty("role", "muted")
|
||||
self.subtitle_label.setWordWrap(True)
|
||||
self.subtitle_label.setVisible(bool(subtitle))
|
||||
@@ -454,21 +454,21 @@ class EmptyState(QWidget):
|
||||
layout.setContentsMargins(24, 44, 24, 44)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(8)
|
||||
glyph = QLabel("○")
|
||||
glyph = QLabel("○", self)
|
||||
glyph.setObjectName("EmptyStateGlyph")
|
||||
glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
glyph.setFixedSize(44, 44)
|
||||
layout.addWidget(glyph)
|
||||
title_label = QLabel(title)
|
||||
title_label = QLabel(title, self)
|
||||
title_label.setProperty("role", "sectionTitle")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
description_label = QLabel(description)
|
||||
description_label = QLabel(description, self)
|
||||
description_label.setProperty("role", "muted")
|
||||
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
description_label.setWordWrap(True)
|
||||
layout.addWidget(description_label)
|
||||
self.action_button = QPushButton(action_text)
|
||||
self.action_button = QPushButton(action_text, self)
|
||||
self.action_button.setProperty("variant", "secondary")
|
||||
self.action_button.setVisible(bool(action_text))
|
||||
self.action_button.clicked.connect(self.action_requested)
|
||||
@@ -483,11 +483,11 @@ class MessageBanner(QFrame):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 9, 12, 9)
|
||||
layout.setSpacing(9)
|
||||
self.icon = QLabel("i")
|
||||
self.icon = QLabel("i", self)
|
||||
self.icon.setObjectName("MessageBannerIcon")
|
||||
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.icon.setFixedSize(20, 20)
|
||||
self.label = QLabel(text)
|
||||
self.label = QLabel(text, self)
|
||||
self.label.setObjectName("MessageBannerText")
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.icon)
|
||||
|
||||
@@ -7,10 +7,12 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QSettings
|
||||
from PySide6.QtCore import QEvent, QObject, QSettings
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.login import LoginWindow
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.widgets import BusyOverlay
|
||||
|
||||
|
||||
@@ -87,3 +89,41 @@ def test_login_loading_does_not_spawn_extra_windows(
|
||||
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_construction_never_shows_orphan_business_controls(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
shown: list[tuple[str, str]] = []
|
||||
|
||||
class OrphanShowRecorder(QObject):
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
||||
if (
|
||||
event.type() == QEvent.Type.Show
|
||||
and isinstance(watched, QWidget)
|
||||
and watched.parentWidget() is None
|
||||
):
|
||||
text = getattr(watched, "text", lambda: "")()
|
||||
shown.append((type(watched).__name__, str(text)))
|
||||
return False
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
recorder = OrphanShowRecorder(application)
|
||||
application.installEventFilter(recorder)
|
||||
try:
|
||||
shell = ShellWindow(
|
||||
repository,
|
||||
{
|
||||
"session": session,
|
||||
"user": session.user,
|
||||
"demo_mode": True,
|
||||
},
|
||||
permissions=session.permissions,
|
||||
)
|
||||
finally:
|
||||
application.removeEventFilter(recorder)
|
||||
|
||||
assert shown == []
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -7,10 +7,10 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
from PySide6.QtPdf import QPdfDocument
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QWidget
|
||||
|
||||
from doctor_workstation import app as app_module
|
||||
from doctor_workstation.core import PermissionSet
|
||||
@@ -354,6 +354,85 @@ def test_editor_builds_complete_add_payload(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_prescription_workflows_never_show_orphan_child_controls(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
shown: list[tuple[str, str]] = []
|
||||
|
||||
class OrphanShowRecorder(QObject):
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
||||
if (
|
||||
event.type() == QEvent.Type.Show
|
||||
and isinstance(watched, QWidget)
|
||||
and not isinstance(watched, QDialog)
|
||||
and watched.parentWidget() is None
|
||||
):
|
||||
text = getattr(watched, "text", lambda: "")()
|
||||
shown.append((type(watched).__name__, str(text)))
|
||||
return False
|
||||
|
||||
repository = SimpleNamespace(
|
||||
list_medicines=lambda **_kwargs: {
|
||||
"lists": [{"id": 31, "name": "黄芪"}],
|
||||
"count": 1,
|
||||
}
|
||||
)
|
||||
prescription = {
|
||||
"id": 12,
|
||||
"diagnosis_id": 6,
|
||||
"patient_name": "林晓岚",
|
||||
"phone": "13800000000",
|
||||
"audit_status": 1,
|
||||
"herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
recorder = OrphanShowRecorder(application)
|
||||
application.installEventFilter(recorder)
|
||||
widgets: list[QWidget] = []
|
||||
try:
|
||||
widgets.extend(
|
||||
[
|
||||
dialog_module.HerbRowWidget(
|
||||
repository,
|
||||
prescription["herbs"][0],
|
||||
show_formula=True,
|
||||
locked=False,
|
||||
),
|
||||
dialog_module.HerbEditor(
|
||||
repository,
|
||||
show_formula=True,
|
||||
show_actions=True,
|
||||
),
|
||||
PrescriptionEditorDialog(
|
||||
repository,
|
||||
prescription,
|
||||
mode="edit",
|
||||
permissions=PermissionSet(
|
||||
["cf.prescription/edit", "tcm.prescriptionLibrary/lists"]
|
||||
),
|
||||
),
|
||||
PrescriptionDetailDialog(
|
||||
prescription,
|
||||
can_open_diagnosis=True,
|
||||
can_open_orders=True,
|
||||
),
|
||||
PrescriptionOrderDialog(
|
||||
repository,
|
||||
prescription,
|
||||
can_view_internal_cost=True,
|
||||
can_edit_pharmacy_remark=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
application.removeEventFilter(recorder)
|
||||
|
||||
assert shown == []
|
||||
for widget in widgets:
|
||||
widget.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_editor_matches_admin_four_observation_fields_and_edit_context(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
@@ -667,6 +746,73 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_case_record_tab_exports_case_record_as_a3_pdf(
|
||||
application: QApplication,
|
||||
tmp_path: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
prescription = {
|
||||
"id": 12,
|
||||
"diagnosis_id": 8169,
|
||||
"patient_name": "何福萍",
|
||||
"case_record": {
|
||||
"diagnosis_id": 8169,
|
||||
"patient_name": "何福萍",
|
||||
"phone": "13800138000",
|
||||
"diagnosis_type": "follow_up",
|
||||
"local_hospital_name": "市中医院",
|
||||
"symptoms": "口渴、乏力、睡眠欠佳",
|
||||
"tongue_coating": "舌红少苔",
|
||||
"pulse": "脉细数",
|
||||
"doctor_advice": "规律复诊",
|
||||
},
|
||||
}
|
||||
case_render_calls: list[str] = []
|
||||
original_case_renderer = dialog_module.render_case_record_image
|
||||
|
||||
def render_case(*args: Any, **kwargs: Any) -> QImage:
|
||||
case_render_calls.append("case")
|
||||
return original_case_renderer(*args, **kwargs)
|
||||
|
||||
def reject_prescription_render(*_args: Any, **_kwargs: Any) -> QImage:
|
||||
pytest.fail("详细病历页签不应调用处方笺渲染器")
|
||||
|
||||
monkeypatch.setattr(dialog_module, "render_case_record_image", render_case)
|
||||
monkeypatch.setattr(
|
||||
dialog_module,
|
||||
"render_prescription_slip_image",
|
||||
reject_prescription_render,
|
||||
)
|
||||
app_module._install_chinese_translations(application)
|
||||
viewer = PrescriptionDetailDialog(prescription)
|
||||
case_index = next(
|
||||
index for index in range(viewer.tabs.count()) if viewer.tabs.tabText(index) == "详细病历"
|
||||
)
|
||||
viewer.tabs.setCurrentIndex(case_index)
|
||||
output = tmp_path / "case-record.pdf"
|
||||
|
||||
viewer.export_pdf(output)
|
||||
|
||||
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(1400, 1980))
|
||||
visible_ink = sum(
|
||||
1
|
||||
for y in range(0, rendered_page.height(), 4)
|
||||
for x in range(0, rendered_page.width(), 4)
|
||||
if (color := rendered_page.pixelColor(x, y)).alpha() > 0 and color.lightness() < 245
|
||||
)
|
||||
viewer.close()
|
||||
application.processEvents()
|
||||
|
||||
assert case_render_calls == ["case"]
|
||||
assert page_size.width() == pytest.approx(842.0, abs=2.0)
|
||||
assert page_size.height() == pytest.approx(1191.0, abs=2.0)
|
||||
assert visible_ink > 1_000
|
||||
|
||||
|
||||
def test_demo_repository_pages_render_offscreen(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -966,6 +966,7 @@ class ConversionLogic
|
||||
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
|
||||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
|
||||
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
|
||||
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
@@ -989,7 +990,7 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-effective-v4', [
|
||||
$baseKey = self::requestRowsCacheKey('fans-effective-v6', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
@@ -1070,7 +1071,7 @@ class ConversionLogic
|
||||
}
|
||||
|
||||
$pairs = $query->select()->toArray();
|
||||
$pairs = self::excludeInheritedFanPairs($pairs);
|
||||
$pairs = self::excludeUncountedFanPairs($pairs);
|
||||
|
||||
$countsByUser = [];
|
||||
foreach ($pairs as $pair) {
|
||||
@@ -1095,13 +1096,17 @@ class ConversionLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 剔除企微「继承/分配」客户:跟进人 add_way 为 201(内部成员共享)或 202(管理员/负责人分配,含在职/离职继承)。
|
||||
* 剔除不应计入加粉的跟进来源:
|
||||
* - add_way=1 扫一扫(客户通过扫一扫添加);
|
||||
* - add_way=2 搜索手机号(成员通过搜索手机号添加);
|
||||
* - add_way=3 名片分享(客户通过名片分享添加);
|
||||
* - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。
|
||||
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $pairs
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function excludeInheritedFanPairs(array $pairs): array
|
||||
private static function excludeUncountedFanPairs(array $pairs): array
|
||||
{
|
||||
if ($pairs === []) {
|
||||
return [];
|
||||
@@ -1119,8 +1124,8 @@ class ConversionLogic
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/** @var array<string, true> $inheritedKeys user_id\0external_userid */
|
||||
$inheritedKeys = [];
|
||||
/** @var array<string, true> $excludedKeys user_id\0external_userid */
|
||||
$excludedKeys = [];
|
||||
foreach (array_chunk($externalIdList, 500) as $chunk) {
|
||||
$contactRows = Db::name('qywx_external_contact')
|
||||
->whereIn('external_userid', $chunk)
|
||||
@@ -1145,19 +1150,19 @@ class ConversionLogic
|
||||
continue;
|
||||
}
|
||||
$addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0);
|
||||
if ($addWay !== 201 && $addWay !== 202) {
|
||||
if (!in_array($addWay, [1, 2, 3, 201, 202], true)) {
|
||||
continue;
|
||||
}
|
||||
$followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? ''));
|
||||
if ($followUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$inheritedKeys[$followUserId . "\0" . $extId] = true;
|
||||
$excludedKeys[$followUserId . "\0" . $extId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($inheritedKeys === []) {
|
||||
if ($excludedKeys === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
@@ -1168,7 +1173,7 @@ class ConversionLogic
|
||||
if ($userId === '' || $extId === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset($inheritedKeys[$userId . "\0" . $extId])) {
|
||||
if (isset($excludedKeys[$userId . "\0" . $extId])) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $pair;
|
||||
|
||||
Reference in New Issue
Block a user