364 lines
18 KiB
Python
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
|