test_business_boundaries.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. from datetime import UTC, datetime, timedelta
  2. from uuid import UUID, uuid4
  3. import pytest
  4. import pytest_asyncio
  5. from sqlalchemy import func, select
  6. from test_after_sales import scenario # noqa: F401
  7. from test_fulfillment import connected # noqa: F401
  8. from zhifu.core.errors import BusinessError
  9. from zhifu.modules.after_sales.models import ExchangeRule, OrderProjection, Ticket
  10. from zhifu.modules.after_sales.service import AfterSalesService
  11. from zhifu.modules.integrations.client import OmsClient
  12. from zhifu.modules.integrations.models import ExternalOperation, Reservation
  13. from zhifu.oms.models import Exchange
  14. pytestmark = pytest.mark.integration
  15. @pytest.fixture(scope="module")
  16. def baseline():
  17. return datetime.now(UTC)
  18. @pytest_asyncio.fixture(params=["A", "B"])
  19. async def merchant(connected, request): # noqa: F811
  20. # 两个商家分别使用独立随机租户与真实 OMS 凭据,规则值来自需求 R1~R5。
  21. name = request.param
  22. days = 7 if name == "A" else 15
  23. citation = f"商家 {name} R1~R5:签收后 {days} 天内受理功能故障,必须客户确认及主管审批。"
  24. response = await connected["call"](
  25. "POST", f"/admin/v1/stores/{connected['store']}/exchange-rules", "agent",
  26. {"expected_version": 1, "window_days": days,
  27. "product_types": ["keyboard"] if name == "A" else ["keyboard", "mouse"],
  28. "citation": citation},
  29. )
  30. assert response.status_code == 201, response.text
  31. return {**connected, "merchant": name, "days": days, "citation": citation}
  32. async def order(context, **changes):
  33. identifier = uuid4()
  34. source = {**context["source"], **changes}
  35. response = await context["warehouse"].put(
  36. f"/orders/{identifier}",
  37. json={key: value for key, value in source.items()
  38. if key not in {"store_id", "source_system"}},
  39. )
  40. assert response.status_code == 200, response.text
  41. response = await context["warehouse"].get(f"/orders/{identifier}")
  42. assert response.status_code == 200, response.text
  43. if source["signed_at"] is None:
  44. assert response.json()["signed_at"] is None
  45. else:
  46. assert datetime.fromisoformat(response.json()["signed_at"]) == datetime.fromisoformat(
  47. source["signed_at"])
  48. response = await context["call"](
  49. "PUT", f"/internal/v1/orders/{identifier}", "connector", source,
  50. )
  51. assert response.status_code == 200, response.text
  52. return {**context["proposal"], "order_id": str(identifier), "item_id": source["item_id"]}
  53. async def assert_no_execution(context, *, tickets=0, rejected_exchanges=0):
  54. async with context["app"].state.sessions() as session:
  55. for model, expected in ((Ticket, tickets), (ExternalOperation, 0), (Reservation, 0)):
  56. count = await session.scalar(select(func.count()).select_from(model).where(
  57. model.tenant_id == context["tenant"].bytes))
  58. assert count == expected, model.__name__
  59. async with context["oms_sessions"]() as session:
  60. assert await session.scalar(select(func.count()).select_from(Exchange).where(
  61. Exchange.tenant_id == context["tenant"].bytes)) == rejected_exchanges
  62. assert await session.scalar(select(func.count()).select_from(Exchange).where(
  63. Exchange.tenant_id == context["tenant"].bytes,
  64. Exchange.status.in_(["ACCEPTED", "COMPLETED"]))) == 0
  65. async def test_inclusive_time_boundary_uses_persisted_rule_and_order(merchant):
  66. baseline = datetime.now(UTC).replace(tzinfo=None)
  67. proposal = await order(
  68. merchant, signed_at=(baseline - timedelta(days=merchant["days"])).replace(
  69. tzinfo=UTC).isoformat(),
  70. )
  71. async with merchant["app"].state.sessions() as session:
  72. current = await session.get(OrderProjection, UUID(proposal["order_id"]).bytes)
  73. rule = await session.get(ExchangeRule, (
  74. merchant["tenant"].bytes, merchant["store"].bytes, 2))
  75. service = AfterSalesService(session, None)
  76. # 直接调用生产规则函数的明确时间参数,精确验证微秒边界,不更改系统时钟。
  77. service.evaluate(current, rule, 1, evaluated_at=baseline)
  78. service.evaluate(current, rule, 1, evaluated_at=baseline - timedelta(microseconds=1))
  79. with pytest.raises(BusinessError) as error:
  80. service.evaluate(current, rule, 1, evaluated_at=baseline + timedelta(microseconds=1))
  81. assert error.value.code == "RULE_NOT_SATISFIED"
  82. assert merchant["citation"] in error.value.message
  83. await assert_no_execution(merchant)
  84. async def test_same_ten_day_order_obeys_each_merchant_window(merchant, baseline):
  85. proposal = await order(
  86. merchant, signed_at=(baseline - timedelta(days=10)).isoformat())
  87. response = await merchant["call"](
  88. "POST", "/openapi/v1/after-sales/proposals", payload=proposal)
  89. accepted = merchant["merchant"] == "B"
  90. assert response.status_code == (201 if accepted else 422), response.text
  91. if accepted:
  92. assert response.json()["citation"] == merchant["citation"]
  93. assert response.json()["rule_version"] == 2
  94. assert response.json()["status"] == "DRAFT"
  95. else:
  96. assert response.json()["code"] == "RULE_NOT_SATISFIED"
  97. assert merchant["citation"] in response.json()["message"]
  98. await assert_no_execution(merchant, tickets=int(accepted))
  99. async def test_mouse_product_scope_obeys_each_merchant_rule(merchant):
  100. proposal = await order(merchant, item_id="mouse-1", product_type="mouse")
  101. response = await merchant["call"](
  102. "POST", "/openapi/v1/after-sales/proposals", payload=proposal)
  103. accepted = merchant["merchant"] == "B"
  104. assert response.status_code == (201 if accepted else 422), response.text
  105. if accepted:
  106. assert response.json()["citation"] == merchant["citation"]
  107. else:
  108. assert merchant["citation"] in response.json()["message"]
  109. await assert_no_execution(merchant, tickets=int(accepted))
  110. async def test_rejection_matrix_preserves_quantity_and_oms(merchant):
  111. cases = [
  112. ({"signed_at": None}, {}, 422, "RULE_NOT_SATISFIED"),
  113. ({"signed_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()}, {},
  114. 422, "RULE_NOT_SATISFIED"),
  115. ({"signed_at": (datetime.now(UTC) - timedelta(
  116. days=merchant["days"], seconds=1)).isoformat()}, {}, 422, "RULE_NOT_SATISFIED"),
  117. ({"available_quantity": 0}, {}, 409, "QUANTITY_CONFLICT"),
  118. ({}, {"quantity": 2}, 409, "QUANTITY_CONFLICT"),
  119. ({"product_type": "headset"}, {}, 422, "RULE_NOT_SATISFIED"),
  120. ({}, {"item_id": "unrelated-item"}, 404, "RESOURCE_NOT_FOUND"),
  121. ({}, {"quantity": 0}, 422, None),
  122. ({}, {"reason": " \n "}, 422, None),
  123. ({}, {"reason": "故障" * 1001}, 422, None),
  124. ({}, {"evaluated_at": "2026-01-01T00:00:00Z"}, 422, None),
  125. ]
  126. for source_changes, changes, status, code in cases:
  127. proposal = await order(merchant, **source_changes)
  128. response = await merchant["call"](
  129. "POST", "/openapi/v1/after-sales/proposals", payload={**proposal, **changes})
  130. assert response.status_code == status, response.text
  131. if code:
  132. assert response.json()["code"] == code
  133. if code in {"RULE_NOT_SATISFIED", "QUANTITY_CONFLICT"}:
  134. assert merchant["citation"] in response.json()["message"]
  135. async with merchant["app"].state.sessions() as session:
  136. current = await session.get(OrderProjection, UUID(proposal["order_id"]).bytes)
  137. assert current.available_quantity == source_changes.get("available_quantity", 1)
  138. await assert_no_execution(merchant)
  139. async def test_fault_description_and_rule_reference_are_saved(merchant):
  140. response = await merchant["call"](
  141. "POST", "/openapi/v1/after-sales/proposals",
  142. payload={**merchant["proposal"], "reason": " 键盘接通电源后多个按键无法输入 "})
  143. assert response.status_code == 201, response.text
  144. proposal = response.json()
  145. response = await merchant["call"]("GET", f"/openapi/v1/tickets/{proposal['ticket_id']}")
  146. assert response.status_code == 200, response.text
  147. assert response.json()["reason"] == "键盘接通电源后多个按键无法输入"
  148. assert response.json()["citation"] == merchant["citation"]
  149. assert response.json()["rule_version"] == 2
  150. await assert_no_execution(merchant, tickets=1)
  151. async def test_oms_rejects_unsigned_order_and_preserves_replay(connected): # noqa: F811
  152. for signed_at in (None, (datetime.now(UTC) + timedelta(days=1)).isoformat()):
  153. proposal = await order(connected, signed_at=signed_at)
  154. client = OmsClient("http://127.0.0.1:8100", connected["variable"], 5)
  155. try:
  156. snapshot = await client.order(UUID(proposal["order_id"]).bytes)
  157. assert (snapshot.signed_at is None) == (signed_at is None)
  158. operation = uuid4()
  159. payload = {**proposal, "subject_id": str(connected["accounts"]["customer"]),
  160. "expected_order_version": 1}
  161. result = await client.process(operation.bytes, payload)
  162. assert result.status == "FAILED" and result.reason == "ORDER_NOT_SIGNED"
  163. repeated = await client.process(operation.bytes, payload)
  164. assert repeated == result
  165. current = await client.order(UUID(proposal["order_id"]).bytes)
  166. assert current.available_quantity == 1 and current.source_version == 1
  167. finally:
  168. await client.close()
  169. await assert_no_execution(connected, rejected_exchanges=2)