Files
shanxia2.0/backend/tests/test_wechat_notice_crm_sources.py
T

364 lines
18 KiB
Python

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