wechat_cert_notices.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """证件到期周一、周三、周五汇总:扫描后入队,发送前复核真实证件与收件权限。"""
  2. from __future__ import annotations
  3. from datetime import date, datetime
  4. import json
  5. from zoneinfo import ZoneInfo
  6. from sqlalchemy import and_
  7. from sqlalchemy.orm import Session
  8. from app.core.config import settings
  9. from app.core.db_auth import DbUserContext
  10. from app.models.models import SysRole, SysUser, SysUserRole
  11. from app.models.pl_models import PlCert, PlDocumentReminder
  12. from app.models.wechat_notice import WechatNotice
  13. from app.services.pl_data_scope import apply_pl_scope
  14. from app.services.web_auth_roles import resolve_web_role_for_user
  15. from app.services.wechat_notice_events import enqueue_event
  16. SHANGHAI = ZoneInfo("Asia/Shanghai")
  17. def _local_time(now: datetime | None) -> datetime:
  18. # 无时区输入遵循业务本地时间;生产默认始终取上海时区。
  19. value = now or datetime.now(SHANGHAI)
  20. return value.replace(tzinfo=SHANGHAI) if value.tzinfo is None else value.astimezone(SHANGHAI)
  21. def _recipients(db: Session):
  22. return (
  23. db.query(SysUser)
  24. .join(SysUserRole, and_(
  25. SysUserRole.user_id == SysUser.id,
  26. SysUserRole.tenant_no == SysUser.tenant_no,
  27. SysUserRole.del_flag == 0,
  28. ))
  29. .join(SysRole, and_(
  30. SysRole.id == SysUserRole.role_id,
  31. SysRole.tenant_no == SysUser.tenant_no,
  32. SysRole.del_flag == 0,
  33. SysRole.status == "1",
  34. ))
  35. .filter(
  36. SysUser.status == "1", SysUser.del_flag == 0,
  37. SysRole.role_name == settings.WECHAT_OA_CERT_NOTICE_ROLE_NAME,
  38. )
  39. .distinct()
  40. .populate_existing()
  41. )
  42. def _is_notice_time(now: datetime) -> bool:
  43. # 门户预警仍每日扫描,微信只在上海时间周一、周三、周五10点(默认)之后发送。
  44. return now.weekday() in (0, 2, 4) and (now.hour, now.minute) >= (
  45. settings.WECHAT_OA_CERT_REMINDER_HOUR, settings.WECHAT_OA_CERT_REMINDER_MINUTE,
  46. )
  47. def _active_cert_counts(db: Session, user: SysUser, today: date) -> tuple[int, int]:
  48. query = db.query(PlCert.id, PlCert.expire_date, PlCert.remind_days).join(
  49. PlDocumentReminder, and_(
  50. PlDocumentReminder.cert_id == PlCert.id,
  51. PlDocumentReminder.tenant_no == PlCert.tenant_no,
  52. ),
  53. ).filter(
  54. PlCert.tenant_no == user.tenant_no,
  55. PlCert.del_flag == 0,
  56. PlCert.status == "valid",
  57. PlCert.expire_date.isnot(None),
  58. PlDocumentReminder.tenant_no == user.tenant_no,
  59. PlDocumentReminder.del_flag == 0,
  60. PlDocumentReminder.status == "pending",
  61. )
  62. context = DbUserContext(
  63. id=user.id, name=user.nick_name or user.user_no, dept_id=user.dept_id or "",
  64. # 已校验本租户接收角色,因此不会走角色解析的默认租户回退。
  65. role=resolve_web_role_for_user(db, user.id, user.tenant_no), tenant_no=user.tenant_no,
  66. )
  67. query = apply_pl_scope(query, PlDocumentReminder, context, db, user.tenant_no)
  68. # 不使用历史 days_remaining;重复 pending 按证件去重,包含仍未处理的已过期证件。
  69. upcoming, expired = 0, 0
  70. for _, expiry, remind_days in query.distinct().all():
  71. days = (expiry - today).days
  72. if days > remind_days:
  73. continue
  74. # 与提醒列表统计一致:当天到期也纳入已到期数量。
  75. if days <= 0:
  76. expired += 1
  77. else:
  78. upcoming += 1
  79. return upcoming, expired
  80. def _payload(now: datetime, upcoming: int, expired: int) -> dict:
  81. parts = []
  82. if upcoming:
  83. parts.append(f"{upcoming}项即将到期")
  84. if expired:
  85. parts.append(f"{expired}项已到期")
  86. title = ",".join(parts)
  87. if len(title) > 20:
  88. title = f"{upcoming + expired}项证件到期提醒"
  89. return {
  90. "thing5": {"value": title},
  91. "time6": {"value": now.strftime("%Y-%m-%d %H:%M:%S")},
  92. "thing3": {"value": "系统默认发出"},
  93. }
  94. def enqueue_daily_cert_notices(db: Session, *, now: datetime | None = None) -> int:
  95. """每日扫描后调用,仅通知日入队;事务由调用者提交,按租户/收件人/上海日期幂等。"""
  96. if not settings.WECHAT_OA_ENABLED or not settings.WECHAT_OA_CERT_REMINDER_ENABLED:
  97. return 0
  98. local_now = _local_time(now)
  99. if not _is_notice_time(local_now):
  100. return 0
  101. db.flush()
  102. created = 0
  103. for recipient in _recipients(db).all():
  104. upcoming, expired = _active_cert_counts(db, recipient, local_now.date())
  105. if not upcoming and not expired:
  106. continue
  107. notice = enqueue_event(
  108. db, tenant_no=recipient.tenant_no, business_type="pl_cert_expiry",
  109. business_id=recipient.id, event_id=local_now.date().isoformat(),
  110. kind="cert_daily", recipient_id=recipient.id,
  111. template_id=settings.WECHAT_OA_TEMPLATE_CERT_REMINDER,
  112. payload=_payload(local_now, upcoming, expired),
  113. )
  114. if notice is not None:
  115. created += 1
  116. return created
  117. def refresh_cert_notice(
  118. db: Session, notice: WechatNotice, *, now: datetime | None = None,
  119. ) -> bool:
  120. """发送前复核当日、收件人、角色及证件状态,time6 使用实际发送时间。"""
  121. if not settings.WECHAT_OA_ENABLED or not settings.WECHAT_OA_CERT_REMINDER_ENABLED:
  122. return False
  123. local_now = _local_time(now)
  124. if not _is_notice_time(local_now):
  125. return False
  126. if (
  127. notice.kind != "cert_daily" or notice.business_type != "pl_cert_expiry"
  128. or notice.business_id != notice.recipient_id
  129. or notice.cycle_id != local_now.date().isoformat()
  130. ):
  131. return False
  132. db.flush()
  133. recipient = _recipients(db).filter(
  134. SysUser.id == notice.recipient_id, SysUser.tenant_no == notice.tenant_no,
  135. ).first()
  136. if recipient is None:
  137. return False
  138. upcoming, expired = _active_cert_counts(db, recipient, local_now.date())
  139. if not upcoming and not expired:
  140. return False
  141. notice.payload = json.dumps(_payload(local_now, upcoming, expired), ensure_ascii=False)
  142. return True