wechat_completion_notices.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """真实发放/支付事件通知:业务事务入队,不依赖审批周期或补发历史。"""
  2. from dataclasses import dataclass
  3. from datetime import datetime
  4. import json
  5. from urllib.parse import quote
  6. from sqlalchemy import inspect
  7. from app.core.config import settings
  8. from app.models.bx_models import BxAdvanceApply, BxReimburseApply
  9. from app.models.models import SysUser
  10. from app.models.pl_models import (
  11. PlFreightSettlement, PlFreightSettlementApproval, PlRemittanceVoucher,
  12. PlVehicleRechargeApply, PlVehicleRechargeRecord,
  13. )
  14. from app.models.wechat_notice import WechatNotice
  15. from app.services.wechat_notice_events import enqueue_event
  16. @dataclass
  17. class Completion:
  18. name: str
  19. number: str
  20. recipient: str
  21. occurred_at: datetime
  22. record_id: str = ''
  23. page: str = ''
  24. def _document(db, model, biz_id, tenant_no):
  25. return db.query(model).filter_by(id=biz_id, tenant_no=tenant_no, del_flag=0).first()
  26. def _completion(db, biz_type, biz_id, tenant_no, *, record_id=None):
  27. if biz_type in ('bx_advance', 'bx_reimburse'):
  28. advance = biz_type == 'bx_advance'
  29. row = _document(db, BxAdvanceApply if advance else BxReimburseApply, biz_id, tenant_no)
  30. if row is None:
  31. return None
  32. when = row.issue_time if advance else row.pay_time
  33. complete = (row.status == 'issued' and row.issue_status == 'issued' if advance else
  34. row.status == 'processed' and row.pay_status == 'paid')
  35. if not complete or not when:
  36. return None
  37. domain = 'advance' if advance else 'reimburse'
  38. return Completion('备用金发放' if advance else '报销支付', row.apply_code or '',
  39. row.applicant_id, when,
  40. page=f'pages/bx/{domain}/detail?id={quote(str(row.id), safe="")}')
  41. if biz_type == 'pl_freight':
  42. row = _document(db, PlFreightSettlement, biz_id, tenant_no)
  43. if row is None or row.settlement_status != 'confirmed' or not record_id:
  44. return None
  45. record = db.query(PlRemittanceVoucher).filter_by(
  46. id=record_id, settlement_id=biz_id, tenant_no=tenant_no, del_flag=0,
  47. ).first()
  48. if record is None or not record.create_time:
  49. return None
  50. approval = db.query(PlFreightSettlementApproval).filter(
  51. PlFreightSettlementApproval.settlement_id == biz_id,
  52. PlFreightSettlementApproval.tenant_no == tenant_no,
  53. PlFreightSettlementApproval.del_flag == 0,
  54. PlFreightSettlementApproval.create_by.isnot(None),
  55. PlFreightSettlementApproval.create_by != '',
  56. ).order_by(PlFreightSettlementApproval.step, PlFreightSettlementApproval.create_time,
  57. PlFreightSettlementApproval.id).first()
  58. recipient = approval.create_by if approval else row.create_by
  59. return Completion('运费发放', row.settlement_no or '', recipient or '', record.create_time, record.id)
  60. if biz_type == 'pl_recharge':
  61. row = _document(db, PlVehicleRechargeApply, biz_id, tenant_no)
  62. if row is None or row.status != 'disbursed':
  63. return None
  64. query = db.query(PlVehicleRechargeRecord).filter_by(
  65. apply_id=biz_id, tenant_no=tenant_no, del_flag=0,
  66. )
  67. if record_id:
  68. query = query.filter(PlVehicleRechargeRecord.id == record_id)
  69. record = query.first()
  70. if record is None or not record.create_time:
  71. return None
  72. return Completion('充值发放', row.apply_no or '', row.applicant_id, record.create_time, record.id,
  73. f'pages/pl/vehicle-recharge-apply/form?id={quote(str(row.id), safe="")}')
  74. return None
  75. def sync_completion_notification(db, biz_type, biz_id, tenant_no, *, actor_id='',
  76. event_id=None, occurred_at=None):
  77. """只由真实完成/撤销调用。充值显式UUID标识本次关联,防止同秒换绑复用事件。"""
  78. # 默认禁用且尚未安装通知表时,不给原业务增加迁移依赖。
  79. # 已安装的队列仍取消旧事件,防止停用期间撤销/换绑后恢复发送旧通知。
  80. if not settings.WECHAT_OA_ENABLED and not inspect(db.connection()).has_table(WechatNotice.__tablename__):
  81. return None
  82. db.flush()
  83. snapshot = _completion(db, biz_type, biz_id, tenant_no,
  84. record_id=event_id if biz_type == 'pl_freight' else None)
  85. if biz_type in ('bx_advance', 'bx_reimburse'):
  86. event_id = 'first-completion'
  87. # 完成状态取消或新关联产生时,即使总开关关闭,也要取消原待发通知。
  88. pending = db.query(WechatNotice).filter_by(
  89. tenant_no=tenant_no, business_type=biz_type, business_id=biz_id,
  90. kind='completion', del_flag=0,
  91. ).filter(WechatNotice.status.in_(('pending', 'sending'))).all()
  92. for notice in pending:
  93. if snapshot is None or (event_id and notice.cycle_id != event_id):
  94. notice.status, notice.last_error = 'cancelled', '发放或支付记录已撤销或变更'
  95. # 独立失效标记不被旧worker的瞬时失败重试覆盖;保留原记录ID供审计。
  96. notice.result = 'revoked'
  97. if snapshot is None or not event_id or not settings.WECHAT_OA_ENABLED:
  98. return None
  99. # 充值记录后续挂申请时,真实处理时间是本次挂接时间,而不是旧记录创建时间。
  100. when = occurred_at if biz_type == 'pl_recharge' else snapshot.occurred_at
  101. if not when:
  102. return None
  103. number = snapshot.number.strip()
  104. payload = {
  105. 'thing3': {'value': snapshot.name},
  106. 'thing4': {'value': number},
  107. 'thing5': {'value': '支付完成' if biz_type == 'bx_reimburse' else '发放完成'},
  108. 'time12': {'value': when.strftime('%Y-%m-%d %H:%M:%S')},
  109. }
  110. notice = enqueue_event(
  111. db, tenant_no=tenant_no, business_type=biz_type, business_id=biz_id,
  112. event_id=event_id, kind='completion', recipient_id=snapshot.recipient,
  113. template_id=settings.WECHAT_OA_TEMPLATE_COMPLETION, payload=payload,
  114. page=snapshot.page, actor_id=actor_id,
  115. invalid_error='单号为空或超过20字符' if not number or len(number) > 20 else None,
  116. )
  117. if notice is not None:
  118. notice.task_key = snapshot.record_id
  119. db.flush()
  120. return notice
  121. def valid_completion_notice(db, notice) -> bool:
  122. """发送前验证本租户实际完成记录与原申请人;旧审批周期不参与判断。"""
  123. if notice.kind != 'completion' or notice.status == 'cancelled' or notice.del_flag:
  124. return False
  125. if notice.appid != settings.WECHAT_OA_APPID:
  126. return False
  127. # 领取发送后可能发生撤销;重读持久化状态,不信任worker内存中的旧sending对象。
  128. current = db.query(WechatNotice.status, WechatNotice.cycle_id, WechatNotice.task_key, WechatNotice.result).filter_by(
  129. id=notice.id, tenant_no=notice.tenant_no, del_flag=0,
  130. ).first()
  131. if (not current or current.status == 'cancelled' or current.result == 'revoked'
  132. or current.cycle_id != notice.cycle_id or current.task_key != notice.task_key):
  133. return False
  134. if not db.query(SysUser.id).filter_by(
  135. id=notice.recipient_id, tenant_no=notice.tenant_no, status='1', del_flag=0,
  136. ).first():
  137. return False
  138. snapshot = _completion(db, notice.business_type, notice.business_id,
  139. notice.tenant_no, record_id=notice.task_key)
  140. if snapshot is None or snapshot.recipient != notice.recipient_id:
  141. return False
  142. try:
  143. payload = json.loads(notice.payload)
  144. if payload['thing4']['value'] != snapshot.number.strip():
  145. return False
  146. if notice.business_type != 'pl_recharge':
  147. return payload['time12']['value'] == snapshot.occurred_at.strftime('%Y-%m-%d %H:%M:%S')
  148. return bool(notice.task_key)
  149. except (KeyError, TypeError, ValueError):
  150. return False