| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- """真实 SQLite 审批数据:通知只对应本轮可办理节点。"""
- from datetime import datetime, timedelta
- from importlib import import_module, util
- import pytest
- from sqlalchemy import create_engine
- from sqlalchemy.orm import sessionmaker
- from app.models.crm_models import CrmQuote, CrmQuoteApproval, CrmContract, CrmContractApproval
- from app.models.pl_models import PlFreightSettlement, PlFreightSettlementApproval
- @pytest.fixture
- def db():
- engine = create_engine("sqlite://")
- for model in (CrmQuote, CrmQuoteApproval, CrmContract, CrmContractApproval,
- PlFreightSettlement, PlFreightSettlementApproval):
- model.__table__.create(engine)
- session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
- yield session
- session.close()
- engine.dispose()
- def snapshot(db, kind):
- assert util.find_spec("app.services.wechat_notice_crm_sources"), "缺少 CRM 通知快照适配器"
- return import_module("app.services.wechat_notice_crm_sources").get_snapshot(db, kind, "doc", "t1")
- def seed(db, kind, mode="sequential"):
- common = dict(id="doc", tenant_no="t1", create_by="creator")
- if kind == "crm_quote":
- doc = CrmQuote(**common, quote_no="Q001", quote_title="报价", owner_user_id="creator", customer_id="customer", status="submitted")
- model, fk = CrmQuoteApproval, "quote_id"
- elif kind == "crm_contract":
- doc = CrmContract(**common, contract_no="C001", contract_name="合同", customer_id="customer", status="approving", approval_status="pending")
- model, fk = CrmContractApproval, "contract_id"
- else:
- doc = PlFreightSettlement(**common, settlement_no="F001", settlement_status="approving")
- model, fk = PlFreightSettlementApproval, "settlement_id"
- rows = []
- now = datetime(2026, 10, 27, 10)
- for i, uid in enumerate(("a", "b")):
- data = dict(id=f"task-{uid}", tenant_no="t1", create_by="submitter", approver_id=uid,
- approval_result="pending", step=i + 1, create_time=now, update_time=now)
- data[fk] = "doc"
- if kind != "crm_quote":
- data["is_current"] = int(i == 0 or mode != "sequential")
- if kind == "pl_freight":
- data["approval_type"] = mode
- if mode != "sequential":
- data["step"] = 1
- rows.append(model(**data))
- db.add_all([doc] + rows)
- db.commit()
- return doc, rows
- @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
- def test_snapshot_current_nodes_and_tenant(db, kind):
- doc, rows = seed(db, kind)
- value = snapshot(db, kind)
- assert [p.user_id for p in value.pending] == ["a"]
- assert value.applicant_id == "submitter"
- assert value.result is None
- adapter = import_module("app.services.wechat_notice_crm_sources")
- assert adapter.get_snapshot(db, kind, "doc", "other") is None
- doc.del_flag = 1
- db.flush()
- assert snapshot(db, kind) is None
- @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
- def test_invalid_document_never_exposes_residual_pending(db, kind):
- doc, rows = seed(db, kind)
- setattr(doc, "settlement_status" if kind == "pl_freight" else "status", "cancelled" if kind == "pl_freight" else "invalid")
- db.flush()
- value = snapshot(db, kind)
- assert value.pending == []
- assert value.result is None
- @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
- def test_approval_hook_observes_flushed_next_node_and_final_result(db, monkeypatch, kind):
- doc, rows = seed(db, kind)
- paths = {"crm_quote": "app.crud.crm.quote_approval", "crm_contract": "app.crud.crm.contract_approval", "pl_freight": "app.services.pl_freight_settlement_service"}
- mod = import_module(paths[kind])
- events = []
- monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, kind)), raising=False)
- for i, uid in enumerate(("a", "b")):
- if kind == "crm_quote":
- mod.approve_quote(db, rows[i].id, tenant_no="t1", approval_result="approved", user_id=uid)
- elif kind == "crm_contract":
- mod.approve_contract(db, rows[i].id, approval_result="approved", user_id=uid)
- else:
- mod.process_settlement_approval(db, tenant_no="t1", approval_id=rows[i].id, action="approve", comment="", user_id=uid)
- assert len(events) == i + 1, "审批事务尚未同步通知"
- assert [p.user_id for p in events[0].pending] == ["b"]
- assert events[0].result is None
- assert events[-1].pending == []
- assert events[-1].result == "approved"
- assert events[-1].approver_id == "b"
- @pytest.mark.parametrize("mode,first_result", [("parallel", None), ("or", "approved")])
- def test_freight_parallel_or_real_service(db, monkeypatch, mode, first_result):
- doc, rows = seed(db, "pl_freight", mode)
- assert {p.user_id for p in snapshot(db, "pl_freight").pending} == {"a", "b"}
- mod = import_module("app.services.pl_freight_settlement_service")
- events = []
- monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, "pl_freight")), raising=False)
- mod.process_settlement_approval(db, tenant_no="t1", approval_id="task-a", action="approve", comment="", user_id="a")
- assert events and events[-1].result == first_result
- assert [p.user_id for p in events[-1].pending] == (["b"] if mode == "parallel" else [])
- def test_contract_transfer_changes_task_identity(db, monkeypatch):
- doc, rows = seed(db, "crm_contract")
- original = snapshot(db, "crm_contract").pending[0].task_key
- mod = import_module("app.crud.crm.contract_approval")
- monkeypatch.setattr(mod, "_resolve_user_display_name", lambda *_a: "审批人")
- events = []
- monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, "crm_contract")), raising=False)
- mod.approve_transfer(db, "task-a", target_approver_id="c", user_id="a")
- mod.approve_transfer(db, "task-a", target_approver_id="a", user_id="c")
- assert len(events) == 2
- assert events[0].pending[0].user_id == "c"
- assert events[0].pending[0].task_key != original
- # Reassignment generations belong to the central outbox; domain keys stay stable.
- assert events[1].pending[0].task_key == original
- def test_parallel_task_key_stable_when_other_approver_acts(db):
- doc, rows = seed(db, "pl_freight", "parallel")
- before = snapshot(db, "pl_freight").pending[1].task_key
- rows[0].approval_result = "approved"
- rows[0].is_current = 0
- rows[0].update_time += timedelta(minutes=1)
- db.flush()
- assert snapshot(db, "pl_freight").pending[0].task_key == before
- @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
- def test_reject_hook_cancels_remaining_tasks(db, monkeypatch, kind):
- doc, rows = seed(db, kind)
- paths = {"crm_quote": "app.crud.crm.quote_approval", "crm_contract": "app.crud.crm.contract_approval", "pl_freight": "app.services.pl_freight_settlement_service"}
- mod = import_module(paths[kind])
- events = []
- monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **_k: events.append(snapshot(db, kind)), raising=False)
- if kind == "crm_quote":
- mod.approve_quote(db, "task-a", tenant_no="t1", approval_result="rejected", user_id="a")
- elif kind == "crm_contract":
- mod.approve_contract(db, "task-a", approval_result="rejected", user_id="a")
- else:
- mod.process_settlement_approval(db, tenant_no="t1", approval_id="task-a", action="reject", comment="", user_id="a")
- assert len(events) == 1
- assert events[0].pending == []
- assert events[0].result == "rejected"
- @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
- def test_real_submit_hook_new_cycle_and_withdraw(db, monkeypatch, kind):
- doc, rows = seed(db, kind)
- for row in rows:
- row.del_flag = 1
- setattr(doc, "settlement_status" if kind == "pl_freight" else "status", "pending" if kind == "pl_freight" else "draft")
- doc.create_by = "submitter"
- db.commit()
- paths = {"crm_quote": "app.crud.crm.quote", "crm_contract": "app.crud.crm.contract", "pl_freight": "app.services.pl_freight_settlement_service"}
- mod = import_module(paths[kind])
- events = []
- monkeypatch.setattr(mod, "sync_approval_notifications", lambda *_a, **kw: events.append((snapshot(db, kind), kw)), raising=False)
- if kind == "crm_quote":
- approval_mod = import_module("app.crud.crm.quote_approval")
- monkeypatch.setattr(mod, "_get_quote_in_scope", lambda *_a, **_k: doc)
- monkeypatch.setattr(mod, "get_quote_detail", lambda *_a, **_k: {})
- monkeypatch.setattr(mod, "generate_quote_pdf", lambda *_a: "")
- monkeypatch.setattr(approval_mod, "_resolve_user_display_name", lambda *_a: "审批人")
- mod.submit_quote(db, "doc", tenant_no="t1", approver_ids=["a", "b"], user_id="submitter")
- mod.withdraw_quote_approval(db, "doc", tenant_no="t1", user_id="submitter")
- elif kind == "crm_contract":
- approval_mod = import_module("app.crud.crm.contract_approval")
- monkeypatch.setattr(mod, "_get_contract_row_in_scope", lambda *_a, **_k: doc)
- monkeypatch.setattr(mod, "validate_framework_contract_for_submit_approval", lambda *_a, **_k: None)
- monkeypatch.setattr(approval_mod, "_resolve_user_display_name", lambda *_a: "审批人")
- mod.submit_contract_approval(db, "doc", tenant_no="t1", approver_ids=["a", "b"], user_id="submitter")
- mod.withdraw_contract_approval(db, "doc", tenant_no="t1", user_id="submitter")
- else:
- from app.schemas.pl.freight import FreightSettlementSubmitApprovalBody
- monkeypatch.setattr(mod, "_resolve_user_display_name", lambda *_a: "审批人")
- mod.submit_settlement_approval(db, tenant_no="t1", settlement_id="doc", body=FreightSettlementSubmitApprovalBody(approverIds=["a", "b"]), user_id="submitter", dept_id="d")
- mod.withdraw_settlement_approval(db, tenant_no="t1", settlement_id="doc", user_id="submitter")
- assert len(events) == 2
- assert events[0][1]["new_cycle"] is True
- assert [p.user_id for p in events[0][0].pending] == ["a"]
- assert events[1][0].pending == []
- assert events[1][0].result is None
- def enable_outbox(db, monkeypatch):
- from app.models.models import SysUser
- from app.models.wechat_notice import WechatApprovalCycle, WechatNotice
- from app.services import wechat_approval_notices as notices
- for model in (SysUser, WechatApprovalCycle, WechatNotice):
- model.__table__.create(db.get_bind())
- for uid in ("creator", "submitter", "a", "b", "c"):
- db.add(SysUser(id=uid, tenant_no="t1", user_no=uid, nick_name=uid))
- db.commit()
- monkeypatch.setattr(notices.settings, "WECHAT_OA_ENABLED", True)
- return notices, WechatNotice
- @pytest.mark.parametrize("kind", ["crm_quote", "crm_contract", "pl_freight"])
- def test_real_services_integrate_with_transactional_outbox(db, monkeypatch, kind):
- import json
- doc, rows = seed(db, kind)
- notices, Notice = enable_outbox(db, monkeypatch)
- notices.sync_approval_notifications(db, kind, "doc", "t1", new_cycle=True, actor_id="submitter")
- db.commit()
- assert db.query(Notice).filter_by(status="pending").one().recipient_id == "a"
- for i, uid in enumerate(("a", "b")):
- if kind == "crm_quote":
- from app.crud.crm.quote_approval import approve_quote
- approve_quote(db, rows[i].id, tenant_no="t1", approval_result="approved", user_id=uid)
- elif kind == "crm_contract":
- from app.crud.crm.contract_approval import approve_contract
- approve_contract(db, rows[i].id, approval_result="approved", user_id=uid)
- db.commit()
- else:
- from app.services.pl_freight_settlement_service import process_settlement_approval
- process_settlement_approval(db, tenant_no="t1", approval_id=rows[i].id, action="approve", comment="", user_id=uid)
- if i == 0:
- assert db.query(Notice).filter_by(status="pending").one().recipient_id == "b"
- result = db.query(Notice).filter_by(kind="result").one()
- assert result.recipient_id == "submitter"
- assert json.loads(result.payload)["const5"]["value"] == "通过"
- assert json.loads(result.payload)["thing6"]["value"] == "b"
- assert db.query(Notice).filter_by(kind="pending", status="pending").count() == 0
- def test_contract_approval_and_outbox_rollback_together(db, monkeypatch):
- doc, rows = seed(db, "crm_contract")
- notices, Notice = enable_outbox(db, monkeypatch)
- notices.sync_approval_notifications(db, "crm_contract", "doc", "t1", new_cycle=True)
- db.commit()
- from app.crud.crm.contract_approval import approve_contract
- approve_contract(db, "task-a", approval_result="approved", user_id="a")
- assert db.query(Notice).filter_by(status="pending").one().recipient_id == "b"
- db.rollback()
- assert db.query(Notice).filter_by(status="pending").one().recipient_id == "a"
- assert db.query(CrmContractApproval).filter_by(id="task-a").one().approval_result == "pending"
- def test_contract_reassignment_back_to_original_user_has_new_notice(db, monkeypatch):
- seed(db, "crm_contract")
- notices, Notice = enable_outbox(db, monkeypatch)
- notices.sync_approval_notifications(db, "crm_contract", "doc", "t1", new_cycle=True)
- db.commit()
- from app.crud.crm.contract_approval import approve_transfer
- approve_transfer(db, "task-a", target_approver_id="c", user_id="a")
- approve_transfer(db, "task-a", target_approver_id="a", user_id="c")
- db.commit()
- assert db.query(Notice).filter_by(status="pending").one().recipient_id == "a"
- assert db.query(Notice).filter_by(recipient_id="a").count() == 2
- @pytest.mark.parametrize("kind,status", [
- ("crm_quote", "converted"), ("crm_quote", "viewed"), ("crm_quote", "expired"),
- ("crm_contract", "executing"), ("crm_contract", "finished"),
- ("pl_freight", "confirmed"),
- ])
- def test_queued_result_survives_normal_post_approval_lifecycle(db, monkeypatch, kind, status):
- doc, rows = seed(db, kind)
- notices, Notice = enable_outbox(db, monkeypatch)
- notices.sync_approval_notifications(db, kind, "doc", "t1", new_cycle=True)
- for i, row in enumerate(rows):
- row.approval_result = "approved"
- row.approval_time = datetime(2026, 10, 27, 11, i)
- if kind != "crm_quote":
- row.is_current = 0
- status_field = "settlement_status" if kind == "pl_freight" else "status"
- setattr(doc, status_field, "accepted" if kind == "crm_quote" else "approved")
- if kind == "crm_contract":
- doc.approval_status = "approved"
- db.flush()
- notices.sync_approval_notifications(db, kind, "doc", "t1", actor_id="b")
- db.commit()
- result = db.query(Notice).filter_by(kind="result").one()
- setattr(doc, status_field, status)
- db.commit()
- assert notices._valid_now(db, result), "后续正常业务状态不得吞掉已经产生的审批通过结果"
- current = snapshot(db, kind)
- assert current.pending == []
- assert current.result == "approved"
- assert current.occurred_at == rows[-1].approval_time
- setattr(doc, status_field, "cancelled" if kind == "pl_freight" else "invalid")
- db.commit()
- assert not notices._valid_now(db, result)
- @pytest.mark.parametrize("kind,status", [
- ("crm_quote", "accepted"), ("crm_quote", "converted"),
- ("crm_contract", "approved"), ("crm_contract", "executing"),
- ("pl_freight", "approved"), ("pl_freight", "confirmed"),
- ])
- @pytest.mark.parametrize("partial", [False, True])
- def test_status_only_or_partial_approval_is_not_a_final_result(db, kind, status, partial):
- doc, rows = seed(db, kind)
- setattr(doc, "settlement_status" if kind == "pl_freight" else "status", status)
- if kind == "crm_contract":
- doc.approval_status = "approved"
- if partial:
- rows[0].approval_result = "approved"
- rows[0].approval_time = datetime(2026, 10, 27, 11)
- else:
- for row in rows:
- row.del_flag = 1
- db.flush()
- assert snapshot(db, kind).result is None
- @pytest.mark.parametrize("kind,status", [
- ("crm_quote", "draft"), ("crm_quote", "invalid"),
- ("crm_contract", "draft"), ("crm_contract", "invalid"), ("crm_contract", "signed"),
- ("pl_freight", "pending"), ("pl_freight", "cancelled"),
- ])
- def test_closed_or_reset_states_do_not_restore_old_approved_results(db, kind, status):
- doc, rows = seed(db, kind)
- for row in rows:
- row.approval_result = "approved"
- row.approval_time = datetime(2026, 10, 27, 11)
- setattr(doc, "settlement_status" if kind == "pl_freight" else "status", status)
- if kind == "crm_contract":
- doc.approval_status = "approved"
- db.flush()
- assert snapshot(db, kind).result is None
- @pytest.mark.parametrize('result', ['approved', 'rejected'])
- def test_freight_result_notifies_submitter_once(db, monkeypatch, result):
- import json
- doc, rows = seed(db, 'pl_freight')
- notices, Notice = enable_outbox(db, monkeypatch)
- notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', new_cycle=True)
- for row in rows:
- row.approval_result = result
- row.approval_time = datetime(2026, 10, 27, 11)
- row.is_current = 0
- doc.settlement_status = result
- db.flush()
- notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', actor_id='b')
- notice = db.query(Notice).filter_by(kind='result').one()
- assert notice.recipient_id == 'submitter'
- assert json.loads(notice.payload)['const5']['value'] == ('通过' if result == 'approved' else '打回')
- assert notices._valid_now(db, notice)
- notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', actor_id='b')
- assert db.query(Notice).filter_by(kind='result').count() == 1
- # 已取消的历史通知不因恢复功能而重新入队。
- notice.status = 'cancelled'
- db.flush()
- notices.sync_approval_notifications(db, 'pl_freight', 'doc', 't1', actor_id='b')
- assert notice.status == 'cancelled'
- assert db.query(Notice).filter_by(kind='result').count() == 1
|