test_wechat_notice_crm_sources.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """真实 SQLite 审批数据:通知只对应本轮可办理节点。"""
  2. from datetime import datetime, timedelta
  3. from importlib import import_module, util
  4. import pytest
  5. from sqlalchemy import create_engine
  6. from sqlalchemy.orm import sessionmaker
  7. from app.models.crm_models import CrmQuote, CrmQuoteApproval, CrmContract, CrmContractApproval
  8. from app.models.pl_models import PlFreightSettlement, PlFreightSettlementApproval
  9. @pytest.fixture
  10. def db():
  11. engine = create_engine("sqlite://")
  12. for model in (CrmQuote, CrmQuoteApproval, CrmContract, CrmContractApproval,
  13. PlFreightSettlement, PlFreightSettlementApproval):
  14. model.__table__.create(engine)
  15. session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
  16. yield session
  17. session.close()
  18. engine.dispose()
  19. def snapshot(db, kind):
  20. assert util.find_spec("app.services.wechat_notice_crm_sources"), "缺少 CRM 通知快照适配器"
  21. return import_module("app.services.wechat_notice_crm_sources").get_snapshot(db, kind, "doc", "t1")
  22. def seed(db, kind, mode="sequential"):
  23. common = dict(id="doc", tenant_no="t1", create_by="creator")
  24. if kind == "crm_quote":
  25. doc = CrmQuote(**common, quote_no="Q001", quote_title="报价", owner_user_id="creator", customer_id="customer", status="submitted")
  26. model, fk = CrmQuoteApproval, "quote_id"
  27. elif kind == "crm_contract":
  28. doc = CrmContract(**common, contract_no="C001", contract_name="合同", customer_id="customer", status="approving", approval_status="pending")
  29. model, fk = CrmContractApproval, "contract_id"
  30. else:
  31. doc = PlFreightSettlement(**common, settlement_no="F001", settlement_status="approving")
  32. model, fk = PlFreightSettlementApproval, "settlement_id"
  33. rows = []
  34. now = datetime(2026, 10, 27, 10)
  35. for i, uid in enumerate(("a", "b")):
  36. data = dict(id=f"task-{uid}", tenant_no="t1", create_by="submitter", approver_id=uid,
  37. approval_result="pending", step=i + 1, create_time=now, update_time=now)
  38. data[fk] = "doc"
  39. if kind != "crm_quote":
  40. data["is_current"] = int(i == 0 or mode != "sequential")
  41. if kind == "pl_freight":
  42. data["approval_type"] = mode
  43. if mode != "sequential":
  44. data["step"] = 1
  45. rows.append(model(**data))
  46. db.add_all([doc] + rows)
  47. db.commit()
  48. return doc, rows
  49. @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
  50. def test_snapshot_current_nodes_and_tenant(db, kind):
  51. doc, rows = seed(db, kind)
  52. value = snapshot(db, kind)
  53. assert [p.user_id for p in value.pending] == ["a"]
  54. assert value.applicant_id == "submitter"
  55. assert value.result is None
  56. adapter = import_module("app.services.wechat_notice_crm_sources")
  57. assert adapter.get_snapshot(db, kind, "doc", "other") is None
  58. doc.del_flag = 1
  59. db.flush()
  60. assert snapshot(db, kind) is None
  61. @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
  62. def test_invalid_document_never_exposes_residual_pending(db, kind):
  63. doc, rows = seed(db, kind)
  64. setattr(doc, "settlement_status" if kind == "pl_freight" else "status", "cancelled" if kind == "pl_freight" else "invalid")
  65. db.flush()
  66. value = snapshot(db, kind)
  67. assert value.pending == []
  68. assert value.result is None
  69. @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
  70. def test_approval_hook_observes_flushed_next_node_and_final_result(db, monkeypatch, kind):
  71. doc, rows = seed(db, kind)
  72. paths = {"crm_quote": "app.crud.crm.quote_approval", "crm_contract": "app.crud.crm.contract_approval", "pl_freight": "app.services.pl_freight_settlement_service"}
  73. mod = import_module(paths[kind])
  74. events = []
  75. monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, kind)), raising=False)
  76. for i, uid in enumerate(("a", "b")):
  77. if kind == "crm_quote":
  78. mod.approve_quote(db, rows[i].id, tenant_no="t1", approval_result="approved", user_id=uid)
  79. elif kind == "crm_contract":
  80. mod.approve_contract(db, rows[i].id, approval_result="approved", user_id=uid)
  81. else:
  82. mod.process_settlement_approval(db, tenant_no="t1", approval_id=rows[i].id, action="approve", comment="", user_id=uid)
  83. assert len(events) == i + 1, "审批事务尚未同步通知"
  84. assert [p.user_id for p in events[0].pending] == ["b"]
  85. assert events[0].result is None
  86. assert events[-1].pending == []
  87. assert events[-1].result == "approved"
  88. assert events[-1].approver_id == "b"
  89. @pytest.mark.parametrize("mode,first_result", [("parallel", None), ("or", "approved")])
  90. def test_freight_parallel_or_real_service(db, monkeypatch, mode, first_result):
  91. doc, rows = seed(db, "pl_freight", mode)
  92. assert {p.user_id for p in snapshot(db, "pl_freight").pending} == {"a", "b"}
  93. mod = import_module("app.services.pl_freight_settlement_service")
  94. events = []
  95. monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, "pl_freight")), raising=False)
  96. mod.process_settlement_approval(db, tenant_no="t1", approval_id="task-a", action="approve", comment="", user_id="a")
  97. assert events and events[-1].result == first_result
  98. assert [p.user_id for p in events[-1].pending] == (["b"] if mode == "parallel" else [])
  99. def test_contract_transfer_changes_task_identity(db, monkeypatch):
  100. doc, rows = seed(db, "crm_contract")
  101. original = snapshot(db, "crm_contract").pending[0].task_key
  102. mod = import_module("app.crud.crm.contract_approval")
  103. monkeypatch.setattr(mod, "_resolve_user_display_name", lambda *_a: "审批人")
  104. events = []
  105. monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, "crm_contract")), raising=False)
  106. mod.approve_transfer(db, "task-a", target_approver_id="c", user_id="a")
  107. mod.approve_transfer(db, "task-a", target_approver_id="a", user_id="c")
  108. assert len(events) == 2
  109. assert events[0].pending[0].user_id == "c"
  110. assert events[0].pending[0].task_key != original
  111. # Reassignment generations belong to the central outbox; domain keys stay stable.
  112. assert events[1].pending[0].task_key == original
  113. def test_parallel_task_key_stable_when_other_approver_acts(db):
  114. doc, rows = seed(db, "pl_freight", "parallel")
  115. before = snapshot(db, "pl_freight").pending[1].task_key
  116. rows[0].approval_result = "approved"
  117. rows[0].is_current = 0
  118. rows[0].update_time += timedelta(minutes=1)
  119. db.flush()
  120. assert snapshot(db, "pl_freight").pending[0].task_key == before
  121. @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
  122. def test_reject_hook_cancels_remaining_tasks(db, monkeypatch, kind):
  123. doc, rows = seed(db, kind)
  124. paths = {"crm_quote": "app.crud.crm.quote_approval", "crm_contract": "app.crud.crm.contract_approval", "pl_freight": "app.services.pl_freight_settlement_service"}
  125. mod = import_module(paths[kind])
  126. events = []
  127. monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, kind)), raising=False)
  128. if kind == "crm_quote":
  129. mod.approve_quote(db, "task-a", tenant_no="t1", approval_result="rejected", user_id="a")
  130. elif kind == "crm_contract":
  131. mod.approve_contract(db, "task-a", approval_result="rejected", user_id="a")
  132. else:
  133. mod.process_settlement_approval(db, tenant_no="t1", approval_id="task-a", action="reject", comment="", user_id="a")
  134. assert len(events) == 1
  135. assert events[0].pending == []
  136. assert events[0].result == "rejected"
  137. @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
  138. def test_real_submit_hook_new_cycle_and_withdraw(db, monkeypatch, kind):
  139. doc, rows = seed(db, kind)
  140. for row in rows:
  141. row.del_flag = 1
  142. setattr(doc, "settlement_status" if kind == "pl_freight" else "status", "pending" if kind == "pl_freight" else "draft")
  143. doc.create_by = "submitter"
  144. db.commit()
  145. paths = {"crm_quote": "app.crud.crm.quote", "crm_contract": "app.crud.crm.contract", "pl_freight": "app.services.pl_freight_settlement_service"}
  146. mod = import_module(paths[kind])
  147. events = []
  148. monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **kw: events.append((snapshot(db, kind), kw)), raising=False)
  149. if kind == "crm_quote":
  150. approval_mod = import_module("app.crud.crm.quote_approval")
  151. monkeypatch.setattr(mod, "_get_quote_in_scope", lambda *_a, **_k: doc)
  152. monkeypatch.setattr(mod, "get_quote_detail", lambda *_a, **_k: {})
  153. monkeypatch.setattr(mod, "generate_quote_pdf", lambda *_a: "")
  154. monkeypatch.setattr(approval_mod, "_resolve_user_display_name", lambda *_a: "审批人")
  155. mod.submit_quote(db, "doc", tenant_no="t1", approver_ids=["a", "b"], user_id="submitter")
  156. mod.withdraw_quote_approval(db, "doc", tenant_no="t1", user_id="submitter")
  157. elif kind == "crm_contract":
  158. approval_mod = import_module("app.crud.crm.contract_approval")
  159. monkeypatch.setattr(mod, "_get_contract_row_in_scope", lambda *_a, **_k: doc)
  160. monkeypatch.setattr(mod, "validate_framework_contract_for_submit_approval", lambda *_a, **_k: None)
  161. monkeypatch.setattr(approval_mod, "_resolve_user_display_name", lambda *_a: "审批人")
  162. mod.submit_contract_approval(db, "doc", tenant_no="t1", approver_ids=["a", "b"], user_id="submitter")
  163. mod.withdraw_contract_approval(db, "doc", tenant_no="t1", user_id="submitter")
  164. else:
  165. from app.schemas.pl.freight import FreightSettlementSubmitApprovalBody
  166. monkeypatch.setattr(mod, "_resolve_user_display_name", lambda *_a: "审批人")
  167. mod.submit_settlement_approval(db, tenant_no="t1", settlement_id="doc", body=FreightSettlementSubmitApprovalBody(approverIds=["a", "b"]), user_id="submitter", dept_id="d")
  168. mod.withdraw_settlement_approval(db, tenant_no="t1", settlement_id="doc", user_id="submitter")
  169. assert len(events) == 2
  170. assert events[0][1]["new_cycle"] is True
  171. assert [p.user_id for p in events[0][0].pending] == ["a"]
  172. assert events[1][0].pending == []
  173. assert events[1][0].result is None
  174. def enable_outbox(db, monkeypatch):
  175. from app.models.models import SysUser
  176. from app.models.wechat_notice import WechatApprovalCycle, WechatNotice
  177. from app.services import wechat_approval_notices as notices
  178. for model in (SysUser, WechatApprovalCycle, WechatNotice):
  179. model.__table__.create(db.get_bind())
  180. for uid in ("creator", "submitter", "a", "b", "c"):
  181. db.add(SysUser(id=uid, tenant_no="t1", user_no=uid, nick_name=uid))
  182. db.commit()
  183. monkeypatch.setattr(notices.settings, "WECHAT_OA_ENABLED", True)
  184. return notices, WechatNotice
  185. @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
  186. def test_real_services_integrate_with_transactional_outbox(db, monkeypatch, kind):
  187. import json
  188. doc, rows = seed(db, kind)
  189. notices, Notice = enable_outbox(db, monkeypatch)
  190. notices.sync_approval_notifications(db, kind, "doc", "t1", new_cycle=True, actor_id="submitter")
  191. db.commit()
  192. assert db.query(Notice).filter_by(status="pending").one().recipient_id == "a"
  193. for i, uid in enumerate(("a", "b")):
  194. if kind == "crm_quote":
  195. from app.crud.crm.quote_approval import approve_quote
  196. approve_quote(db, rows[i].id, tenant_no="t1", approval_result="approved", user_id=uid)
  197. elif kind == "crm_contract":
  198. from app.crud.crm.contract_approval import approve_contract
  199. approve_contract(db, rows[i].id, approval_result="approved", user_id=uid)
  200. db.commit()
  201. else:
  202. from app.services.pl_freight_settlement_service import process_settlement_approval
  203. process_settlement_approval(db, tenant_no="t1", approval_id=rows[i].id, action="approve", comment="", user_id=uid)
  204. if i == 0:
  205. assert db.query(Notice).filter_by(status="pending").one().recipient_id == "b"
  206. result = db.query(Notice).filter_by(kind="result").one()
  207. assert result.recipient_id == "submitter"
  208. assert json.loads(result.payload)["const5"]["value"] == "通过"
  209. assert json.loads(result.payload)["thing6"]["value"] == "b"
  210. assert db.query(Notice).filter_by(kind="pending", status="pending").count() == 0
  211. def test_contract_approval_and_outbox_rollback_together(db, monkeypatch):
  212. doc, rows = seed(db, "crm_contract")
  213. notices, Notice = enable_outbox(db, monkeypatch)
  214. notices.sync_approval_notifications(db, "crm_contract", "doc", "t1", new_cycle=True)
  215. db.commit()
  216. from app.crud.crm.contract_approval import approve_contract
  217. approve_contract(db, "task-a", approval_result="approved", user_id="a")
  218. assert db.query(Notice).filter_by(status="pending").one().recipient_id == "b"
  219. db.rollback()
  220. assert db.query(Notice).filter_by(status="pending").one().recipient_id == "a"
  221. assert db.query(CrmContractApproval).filter_by(id="task-a").one().approval_result == "pending"
  222. def test_contract_reassignment_back_to_original_user_has_new_notice(db, monkeypatch):
  223. seed(db, "crm_contract")
  224. notices, Notice = enable_outbox(db, monkeypatch)
  225. notices.sync_approval_notifications(db, "crm_contract", "doc", "t1", new_cycle=True)
  226. db.commit()
  227. from app.crud.crm.contract_approval import approve_transfer
  228. approve_transfer(db, "task-a", target_approver_id="c", user_id="a")
  229. approve_transfer(db, "task-a", target_approver_id="a", user_id="c")
  230. db.commit()
  231. assert db.query(Notice).filter_by(status="pending").one().recipient_id == "a"
  232. assert db.query(Notice).filter_by(recipient_id="a").count() == 2
  233. @pytest.mark.parametrize("kind,status", [
  234. ("crm_quote", "converted"), ("crm_quote", "viewed"), ("crm_quote", "expired"),
  235. ("crm_contract", "executing"), ("crm_contract", "finished"),
  236. ("pl_freight", "confirmed"),
  237. ])
  238. def test_queued_result_survives_normal_post_approval_lifecycle(db, monkeypatch, kind, status):
  239. doc, rows = seed(db, kind)
  240. notices, Notice = enable_outbox(db, monkeypatch)
  241. notices.sync_approval_notifications(db, kind, "doc", "t1", new_cycle=True)
  242. for i, row in enumerate(rows):
  243. row.approval_result = "approved"
  244. row.approval_time = datetime(2026, 10, 27, 11, i)
  245. if kind != "crm_quote":
  246. row.is_current = 0
  247. status_field = "settlement_status" if kind == "pl_freight" else "status"
  248. setattr(doc, status_field, "accepted" if kind == "crm_quote" else "approved")
  249. if kind == "crm_contract":
  250. doc.approval_status = "approved"
  251. db.flush()
  252. notices.sync_approval_notifications(db, kind, "doc", "t1", actor_id="b")
  253. db.commit()
  254. result = db.query(Notice).filter_by(kind="result").one()
  255. setattr(doc, status_field, status)
  256. db.commit()
  257. assert notices._valid_now(db, result), "后续正常业务状态不得吞掉已经产生的审批通过结果"
  258. current = snapshot(db, kind)
  259. assert current.pending == []
  260. assert current.result == "approved"
  261. assert current.occurred_at == rows[-1].approval_time
  262. setattr(doc, status_field, "cancelled" if kind == "pl_freight" else "invalid")
  263. db.commit()
  264. assert not notices._valid_now(db, result)
  265. @pytest.mark.parametrize("kind,status", [
  266. ("crm_quote", "accepted"), ("crm_quote", "converted"),
  267. ("crm_contract", "approved"), ("crm_contract", "executing"),
  268. ("pl_freight", "approved"), ("pl_freight", "confirmed"),
  269. ])
  270. @pytest.mark.parametrize("partial", [False, True])
  271. def test_status_only_or_partial_approval_is_not_a_final_result(db, kind, status, partial):
  272. doc, rows = seed(db, kind)
  273. setattr(doc, "settlement_status" if kind == "pl_freight" else "status", status)
  274. if kind == "crm_contract":
  275. doc.approval_status = "approved"
  276. if partial:
  277. rows[0].approval_result = "approved"
  278. rows[0].approval_time = datetime(2026, 10, 27, 11)
  279. else:
  280. for row in rows:
  281. row.del_flag = 1
  282. db.flush()
  283. assert snapshot(db, kind).result is None
  284. @pytest.mark.parametrize("kind,status", [
  285. ("crm_quote", "draft"), ("crm_quote", "invalid"),
  286. ("crm_contract", "draft"), ("crm_contract", "invalid"), ("crm_contract", "signed"),
  287. ("pl_freight", "pending"), ("pl_freight", "cancelled"),
  288. ])
  289. def test_closed_or_reset_states_do_not_restore_old_approved_results(db, kind, status):
  290. doc, rows = seed(db, kind)
  291. for row in rows:
  292. row.approval_result = "approved"
  293. row.approval_time = datetime(2026, 10, 27, 11)
  294. setattr(doc, "settlement_status" if kind == "pl_freight" else "status", status)
  295. if kind == "crm_contract":
  296. doc.approval_status = "approved"
  297. db.flush()
  298. assert snapshot(db, kind).result is None
  299. @pytest.mark.parametrize('result', ['approved', 'rejected'])
  300. def test_freight_result_notifies_submitter_once(db, monkeypatch, result):
  301. import json
  302. doc, rows = seed(db, 'pl_freight')
  303. notices, Notice = enable_outbox(db, monkeypatch)
  304. notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', new_cycle=True)
  305. for row in rows:
  306. row.approval_result = result
  307. row.approval_time = datetime(2026, 10, 27, 11)
  308. row.is_current = 0
  309. doc.settlement_status = result
  310. db.flush()
  311. notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', actor_id='b')
  312. notice = db.query(Notice).filter_by(kind='result').one()
  313. assert notice.recipient_id == 'submitter'
  314. assert json.loads(notice.payload)['const5']['value'] == ('通过' if result == 'approved' else '打回')
  315. assert notices._valid_now(db, notice)
  316. notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', actor_id='b')
  317. assert db.query(Notice).filter_by(kind='result').count() == 1
  318. # 已取消的历史通知不因恢复功能而重新入队。
  319. notice.status = 'cancelled'
  320. db.flush()
  321. notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', actor_id='b')
  322. assert notice.status == 'cancelled'
  323. assert db.query(Notice).filter_by(kind='result').count() == 1