Files
zkxw/backend/app/services/sample_collect_wx_notify.py
T
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00

574 lines
18 KiB
Python

"""协议新增样本:服务号模板消息通知。"""
from __future__ import annotations
import logging
import threading
import uuid
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Set
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import SessionLocal
from app.core.protocol_status_display import INACTIVE_PROTOCOL_STATUSES
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
BizProtocolSample,
BizProtocolSampleTransport,
BizWxNotifyLog,
SysUser,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
from app.services.web_auth_roles import list_user_ids_with_effective_role_name
logger = logging.getLogger(__name__)
NOTIFY_BIZ_TYPE = "sample_collect_notify"
NOTIFY_DATE_ONCE = "1970-01-01"
PROTOCOL_KIND_CELL = "cell_storage"
PROTOCOL_KIND_DONATION = "research_donation"
TASK_TYPE_CELL = "细胞存储"
TASK_TYPE_DONATION = "科研捐赠"
SAMPLE_STATUS_VOID = "yzf"
def sample_codes_from_payload(samples: Optional[Iterable[Any]]) -> Set[str]:
"""从请求体样本列表提取非空 sample_code(兼容 camelCase)。"""
out: Set[str] = set()
for raw in samples or []:
if isinstance(raw, dict):
code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
else:
code = (getattr(raw, "sample_code", None) or "").strip()
if code:
out.add(code)
return out
def sample_codes_from_orm_rows(rows: Optional[Iterable[Any]]) -> Set[str]:
out: Set[str] = set()
for row in rows or []:
code = (getattr(row, "sample_code", None) or "").strip()
if code:
out.add(code)
return out
def diff_new_sample_codes(
*,
before_codes: Iterable[str],
submitted_codes: Iterable[str],
) -> List[str]:
"""提交相对保存前的新增 sample_code;稳定排序便于测试。"""
before = {str(c).strip() for c in before_codes if str(c).strip()}
submitted = {str(c).strip() for c in submitted_codes if str(c).strip()}
return sorted(submitted - before)
def format_sample_collect_title(eta_time: Optional[datetime] = None) -> str:
"""工单标题:有 ETA → 样本采集(预计到达 M月D日);否则到达待定。"""
if eta_time is not None:
title = f"样本采集(预计到达 {eta_time.month}月{eta_time.day}日)"
else:
title = "样本采集(到达待定)"
return title
def format_protocol_create_time(dt: Optional[datetime]) -> str:
if not dt:
return ""
return dt.strftime("%Y年%m月%d日 %H:%M")
def _notify_enabled() -> bool:
if not bool(getattr(settings, "WX_MP_NOTIFY_ENABLED", False)):
return False
tid = (getattr(settings, "WX_MP_TEMPLATE_SAMPLE_COLLECT", "") or "").strip()
return bool(tid)
def _role_name() -> str:
return (
getattr(settings, "WX_MP_SAMPLE_COLLECT_ROLE_NAME", "") or "样本采集通知人员"
).strip() or "样本采集通知人员"
def protocol_status_blocks_notify(status: Optional[str]) -> bool:
"""已作废、属地废弃的协议单不发样本采集通知。"""
return (status or "").strip() in INACTIVE_PROTOCOL_STATUSES
def sample_status_blocks_notify(status: Optional[str]) -> bool:
"""已作废样本不发样本采集通知。"""
return (status or "").strip() == SAMPLE_STATUS_VOID
def _sample_status_by_code(samples: Iterable[Any]) -> Dict[str, str]:
out: Dict[str, str] = {}
for sample in samples or []:
code = (getattr(sample, "sample_code", None) or "").strip()
if not code:
continue
out[code] = (getattr(sample, "sample_status", None) or "").strip()
return out
def _task_type_for_kind(protocol_kind: str) -> str:
if protocol_kind == PROTOCOL_KIND_DONATION:
return TASK_TYPE_DONATION
return TASK_TYPE_CELL
def _sample_type_name_by_code(
db: Session, *, tenant_no: str, samples: Iterable[Any]
) -> Dict[str, str]:
"""按样本编码取样本类型名称。无类型或类型已删除时为空串。"""
rows = list(samples or [])
type_ids = {
(getattr(sample, "sample_type_id", None) or "").strip()
for sample in rows
if (getattr(sample, "sample_type_id", None) or "").strip()
}
name_by_id: Dict[str, str] = {}
if type_ids:
type_rows = (
db.query(BizCellSampleType)
.filter(
BizCellSampleType.id.in_(list(type_ids)),
BizCellSampleType.tenant_no == tenant_no,
BizCellSampleType.del_flag == 0,
)
.all()
)
name_by_id = {
row.id: (row.type_name or "").strip() for row in type_rows
}
out: Dict[str, str] = {}
for sample in rows:
code = (getattr(sample, "sample_code", None) or "").strip()
if not code:
continue
type_id = (getattr(sample, "sample_type_id", None) or "").strip()
out[code] = name_by_id.get(type_id, "") if type_id else ""
return out
def _biz_id(sample_code: str, user_id: str) -> str:
return f"{(sample_code or '').strip()}:{(user_id or '').strip()}"
def _already_success(
db: Session, *, tenant_no: str, sample_code: str, user_id: str
) -> bool:
row = (
db.query(BizWxNotifyLog)
.filter(
BizWxNotifyLog.tenant_no == tenant_no,
BizWxNotifyLog.biz_type == NOTIFY_BIZ_TYPE,
BizWxNotifyLog.biz_id == _biz_id(sample_code, user_id),
BizWxNotifyLog.notify_date == NOTIFY_DATE_ONCE,
BizWxNotifyLog.status == "success",
BizWxNotifyLog.del_flag == 0,
)
.first()
)
return row is not None
def _write_success_log(
db: Session,
*,
tenant_no: str,
sample_code: str,
user_id: str,
) -> None:
now = datetime.now()
db.add(
BizWxNotifyLog(
id=str(uuid.uuid4()).replace("-", "").upper(),
biz_type=NOTIFY_BIZ_TYPE,
biz_id=_biz_id(sample_code, user_id),
notify_date=NOTIFY_DATE_ONCE,
user_id=user_id or "",
status="success",
errcode=None,
errmsg=None,
tenant_no=tenant_no,
order_num=0,
remark="",
create_by=user_id or "",
create_dept_id="",
create_time=now,
update_by=user_id or "",
update_time=now,
del_flag=0,
)
)
def list_notify_recipient_users(db: Session, *, tenant_no: str) -> List[SysUser]:
"""有效拥有「样本采集通知人员」角色且已绑定服务号的用户。
有效角色 = 用户直接绑角色 ∪ 经启用职位继承(sys_user_post → sys_post → sys_post_role),
与登录权限解析一致。
"""
role_name = _role_name()
user_ids = list_user_ids_with_effective_role_name(
db, tenant_no=tenant_no, role_name=role_name
)
if not user_ids:
return []
rows = (
db.query(SysUser)
.filter(
SysUser.id.in_(list(user_ids)),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.wx_mp_openid.isnot(None),
SysUser.wx_mp_openid != "",
)
.all()
)
# 按 user_id 已去重;再按 openid 去重,避免一人多职位/多账号同微信重复推送
seen_openid: Set[str] = set()
out: List[SysUser] = []
for u in rows:
oid = (getattr(u, "wx_mp_openid", None) or "").strip()
if not oid or oid in seen_openid:
continue
seen_openid.add(oid)
out.append(u)
return out
def _load_cell_context(
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
) -> Optional[Dict[str, Any]]:
protocol = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == protocol_id,
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if not protocol:
return None
samples = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code.in_(new_codes),
)
.all()
)
code_to_id = {
(s.sample_code or "").strip(): s.id
for s in samples
if (s.sample_code or "").strip()
}
sample_ids = list(code_to_id.values())
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
if sample_ids:
transports = (
db.query(BizProtocolSampleTransport)
.filter(
BizProtocolSampleTransport.collection_protocol_id == protocol_id,
BizProtocolSampleTransport.tenant_no == tenant_no,
BizProtocolSampleTransport.del_flag == 0,
BizProtocolSampleTransport.sample_id.in_(sample_ids),
)
.all()
)
id_to_code = {sid: code for code, sid in code_to_id.items()}
for t in transports:
code = id_to_code.get((t.sample_id or "").strip()) or (
getattr(t, "sample_code", None) or ""
).strip()
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
eta_by_code[code] = t.eta_time
creator = None
if protocol.create_by:
creator = (
db.query(SysUser)
.filter(SysUser.id == protocol.create_by, SysUser.del_flag == 0)
.first()
)
return {
"create_time": protocol.create_time,
"applicant": (creator.nick_name if creator else "") or "",
"eta_by_code": eta_by_code,
"protocol_status": (protocol.protocol_status or "").strip(),
"sample_status_by_code": _sample_status_by_code(samples),
"sample_type_by_code": _sample_type_name_by_code(
db, tenant_no=tenant_no, samples=samples
),
}
def _load_donation_context(
db: Session, *, protocol_id: str, tenant_no: str, new_codes: List[str]
) -> Optional[Dict[str, Any]]:
donation = (
db.query(BizResearchDonation)
.filter(
BizResearchDonation.id == protocol_id,
BizResearchDonation.tenant_no == tenant_no,
BizResearchDonation.del_flag == 0,
)
.first()
)
if not donation:
return None
samples = (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.research_donation_id == protocol_id,
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_code.in_(new_codes),
)
.all()
)
code_to_id = {
(s.sample_code or "").strip(): s.id
for s in samples
if (s.sample_code or "").strip()
}
sample_ids = list(code_to_id.values())
eta_by_code: Dict[str, Optional[datetime]] = {c: None for c in new_codes}
if sample_ids:
transports = (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id
== protocol_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
)
.all()
)
id_to_code = {sid: code for code, sid in code_to_id.items()}
for t in transports:
code = id_to_code.get((t.sample_id or "").strip()) or (
getattr(t, "sample_code", None) or ""
).strip()
if code and code in eta_by_code and t.eta_time and eta_by_code[code] is None:
eta_by_code[code] = t.eta_time
creator = None
if donation.create_by:
creator = (
db.query(SysUser)
.filter(SysUser.id == donation.create_by, SysUser.del_flag == 0)
.first()
)
return {
"create_time": donation.create_time,
"applicant": (creator.nick_name if creator else "") or "",
"eta_by_code": eta_by_code,
"protocol_status": (donation.donation_status or "").strip(),
"sample_status_by_code": _sample_status_by_code(samples),
"sample_type_by_code": _sample_type_name_by_code(
db, tenant_no=tenant_no, samples=samples
),
}
def run_sample_collect_notify(
db: Session,
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
new_sample_codes: Iterable[str],
) -> Dict[str, int]:
"""对新增样本向角色用户发送通知。返回计数。"""
stats = {"codes": 0, "sent": 0, "skipped": 0, "failed": 0}
if not _notify_enabled():
logger.info("sample collect notify skipped: notify disabled or no template")
return stats
codes = [c for c in (str(x).strip() for x in new_sample_codes) if c]
if not codes:
return stats
stats["codes"] = len(codes)
tenant = (tenant_no or "").strip() or "zkxw"
kind = (protocol_kind or "").strip() or PROTOCOL_KIND_CELL
if kind == PROTOCOL_KIND_DONATION:
ctx = _load_donation_context(
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
)
else:
ctx = _load_cell_context(
db, protocol_id=protocol_id, tenant_no=tenant, new_codes=codes
)
if not ctx:
logger.info(
"sample collect notify skip: protocol missing id=%s kind=%s",
protocol_id,
kind,
)
return stats
protocol_status = (ctx.get("protocol_status") or "").strip()
if protocol_status_blocks_notify(protocol_status):
logger.info(
"sample collect notify skip: inactive protocol id=%s kind=%s status=%s",
protocol_id,
kind,
protocol_status,
)
stats["skipped"] = len(codes)
return stats
recipients = list_notify_recipient_users(db, tenant_no=tenant)
if not recipients:
logger.info(
"sample collect notify skip: no recipients tenant=%s role=%s",
tenant,
_role_name(),
)
return stats
from app.services.wechat_mp_message import send_sample_collect_notify
applicant = ctx.get("applicant") or ""
time_text = format_protocol_create_time(ctx.get("create_time"))
task_type = _task_type_for_kind(kind)
eta_by_code: Dict[str, Optional[datetime]] = ctx.get("eta_by_code") or {}
sample_type_by_code: Dict[str, str] = ctx.get("sample_type_by_code") or {}
sample_status_by_code: Dict[str, str] = ctx.get("sample_status_by_code") or {}
for code in codes:
if sample_status_blocks_notify(sample_status_by_code.get(code)):
logger.info(
"sample collect notify skip: voided sample code=%s protocol=%s",
code,
protocol_id,
)
stats["skipped"] += 1
continue
title = format_sample_collect_title(eta_by_code.get(code))
sample_type = (sample_type_by_code.get(code) or "").strip()
for user in recipients:
uid = (user.id or "").strip()
mp = (getattr(user, "wx_mp_openid", None) or "").strip()
if not uid or not mp:
stats["skipped"] += 1
continue
if _already_success(db, tenant_no=tenant, sample_code=code, user_id=uid):
stats["skipped"] += 1
continue
result = send_sample_collect_notify(
mp_openid=mp,
title=title,
applicant=applicant,
time_text=time_text,
task_type=task_type,
sample_type=sample_type,
pagepath="",
)
if result.get("success"):
_write_success_log(
db, tenant_no=tenant, sample_code=code, user_id=uid
)
stats["sent"] += 1
else:
logger.error(
"sample collect notify failed code=%s user=%s err=%s",
code,
uid,
result,
)
stats["failed"] += 1
try:
db.commit()
except Exception:
db.rollback()
logger.exception("sample collect notify commit failed")
raise
return stats
def notify_sample_collect_sync(
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
new_sample_codes: List[str],
) -> None:
db = None
try:
db = SessionLocal()
stats = run_sample_collect_notify(
db,
tenant_no=tenant_no,
protocol_id=protocol_id,
protocol_kind=protocol_kind,
new_sample_codes=new_sample_codes,
)
logger.info(
"sample collect notify done protocol=%s kind=%s stats=%s",
protocol_id,
protocol_kind,
stats,
)
except Exception:
logger.exception(
"sample collect notify batch failed protocol=%s kind=%s",
protocol_id,
protocol_kind,
)
finally:
if db is not None:
db.close()
def schedule_sample_collect_notify(
*,
tenant_no: str,
protocol_id: str,
protocol_kind: str,
before_codes: Iterable[str],
submitted_codes: Optional[Iterable[str]],
) -> None:
"""保存成功后调度:submitted_codes 为 None 表示本次未改样本,不推。"""
if not _notify_enabled():
logger.info("sample collect notify schedule skipped: disabled")
return
if submitted_codes is None:
return
new_codes = diff_new_sample_codes(
before_codes=before_codes, submitted_codes=submitted_codes
)
if not new_codes:
return
pid = (protocol_id or "").strip()
if not pid:
return
threading.Thread(
target=notify_sample_collect_sync,
kwargs={
"tenant_no": (tenant_no or "").strip() or "zkxw",
"protocol_id": pid,
"protocol_kind": (protocol_kind or "").strip() or PROTOCOL_KIND_CELL,
"new_sample_codes": new_codes,
},
daemon=True,
name="sample-collect-wx-notify",
).start()