This commit is contained in:
Your Name
2026-08-13 14:22:19 +08:00
parent 72a5300b08
commit cbe71905c0
16 changed files with 347 additions and 375 deletions
-204
View File
@@ -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")
-87
View File
@@ -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)
+8 -8
View File
@@ -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)
+41 -1
View File
@@ -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()
+148 -2
View File
@@ -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.