"""真实 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