test_attribution.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. from datetime import UTC, datetime
  2. from fastapi.testclient import TestClient
  3. from zbt.commands.seed import build_seed_manifest
  4. from zbt.core.config import Settings
  5. from zbt.core.passwords import PasswordService
  6. from zbt.domains.agent.repository import InMemoryAgentRepository
  7. from zbt.domains.attribution.models import (
  8. OrderAttribution,
  9. PromotionCode,
  10. Salesperson,
  11. )
  12. from zbt.domains.attribution.repository import InMemoryAttributionRepository
  13. from zbt.domains.attribution.service import AttributionService
  14. from zbt.domains.catalog.repository import InMemoryCatalogRepository
  15. from zbt.domains.enrollment.models import EnrollmentOrder
  16. from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository
  17. from zbt.domains.identity.models import AdminUser, H5User
  18. from zbt.domains.identity.repository import InMemoryIdentityRepository
  19. from zbt.main import create_app
  20. NOW = datetime(2026, 7, 26, 9, tzinfo=UTC)
  21. def _seed_attribution(
  22. repository: InMemoryAttributionRepository,
  23. *,
  24. admin_user_id: str = "01ADMIN0000000000000000001",
  25. ) -> tuple[Salesperson, PromotionCode]:
  26. salesperson = Salesperson(
  27. id="01SALES0000000000000000001",
  28. admin_user_id=admin_user_id,
  29. code="SALES-A",
  30. name="业务员A",
  31. status="ACTIVE",
  32. created_at=NOW,
  33. )
  34. code = PromotionCode(
  35. id="01PROMO0000000000000000001",
  36. code="ZBT-SALES-A",
  37. salesperson_id=salesperson.id,
  38. channel="GROUND_PROMOTION",
  39. status="ACTIVE",
  40. created_at=NOW,
  41. )
  42. repository.save_salesperson(salesperson)
  43. repository.save_promotion_code(code)
  44. return salesperson, code
  45. def _order(order_id: str, user_id: str, amount_cents: int = 19900) -> EnrollmentOrder:
  46. return EnrollmentOrder(
  47. id=order_id,
  48. order_no=f"ORD-{order_id[-4:]}",
  49. user_id=user_id,
  50. quote_id=f"QUOTE-{order_id}"[-26:],
  51. draft_id=f"DRAFT-{order_id}"[-26:],
  52. confirmation_id=f"CONFIRM-{order_id}"[-26:],
  53. idempotency_key=f"IDEMPOTENCY-{order_id}",
  54. product_id="01PRODUCT00000000000000001",
  55. product_version_id="01VERSION00000000000000001",
  56. plan_id="01PLAN0000000000000000001",
  57. applicant_snapshot={
  58. "name": "周明远",
  59. "id_no": "510104198801011234",
  60. },
  61. insured_snapshot={
  62. "name": "周建国",
  63. "id_no": "510104196001011234",
  64. },
  65. amount_cents=amount_cents,
  66. currency="CNY",
  67. status="ISSUED",
  68. created_at=NOW,
  69. )
  70. def test_first_touch_attribution_is_stable_and_order_snapshot_is_idempotent() -> None:
  71. repository = InMemoryAttributionRepository()
  72. salesperson, code = _seed_attribution(repository)
  73. service = AttributionService(repository, lambda: NOW)
  74. user = H5User(
  75. id="01H5USER000000000000000001",
  76. mobile="18800000001",
  77. mobile_masked="188****0001",
  78. display_name="体验用户",
  79. status="ACTIVE",
  80. created_at=NOW,
  81. )
  82. first = service.capture_visit(user, code.code)
  83. second = service.capture_visit(user, code.code)
  84. order = _order("01ORDER0000000000000000001", user.id)
  85. service.record_order(order)
  86. service.record_order(order)
  87. assert first["first_touch"] is True
  88. assert second["first_touch"] is False
  89. lead = repository.get_lead_by_user(user.id)
  90. assert lead is not None
  91. assert lead.salesperson_id == salesperson.id
  92. assert lead.visit_count == 2
  93. performance = service.performance(salesperson.admin_user_id)
  94. assert performance["visit_count"] == 2
  95. assert performance["lead_count"] == 1
  96. assert performance["order_count"] == 1
  97. assert performance["premium_cents"] == 19900
  98. assert service.order_ids(salesperson.admin_user_id) == {order.id}
  99. def test_h5_referral_login_and_admin_self_scope_form_a_closed_loop() -> None:
  100. identities = InMemoryIdentityRepository()
  101. admin = AdminUser(
  102. id="01ADMIN0000000000000000001",
  103. username="sales_a",
  104. password_hash=PasswordService().hash("zaq1XSW@"),
  105. display_name="业务员A",
  106. status="ACTIVE",
  107. roles=("SALESPERSON",),
  108. permissions=(
  109. "dashboard:read",
  110. "order:read",
  111. "attribution:read",
  112. "product:read",
  113. "product:write",
  114. ),
  115. data_scope="SELF",
  116. )
  117. identities.save_admin_user(admin)
  118. attribution = InMemoryAttributionRepository()
  119. salesperson, code = _seed_attribution(attribution, admin_user_id=admin.id)
  120. catalog = InMemoryCatalogRepository()
  121. manifest = build_seed_manifest()
  122. for product in manifest.products:
  123. catalog.save_product(product)
  124. for version in manifest.versions:
  125. catalog.save_version(version)
  126. enrollment = InMemoryEnrollmentRepository()
  127. app = create_app(
  128. settings=Settings(app_env="test"),
  129. identity_repository=identities,
  130. catalog_repository=catalog,
  131. agent_repository=InMemoryAgentRepository(),
  132. enrollment_repository=enrollment,
  133. attribution_repository=attribution,
  134. )
  135. with TestClient(app) as client:
  136. h5_login = client.post(
  137. "/api/v1/h5/auth/login",
  138. json={
  139. "mobile": "18800000001",
  140. "code": "147258",
  141. "referral_code": code.code,
  142. },
  143. )
  144. assert h5_login.status_code == 200
  145. h5_user_id = h5_login.json()["data"]["user"]["id"]
  146. assert h5_login.json()["data"]["attribution"]["attributed"] is True
  147. owned_order = _order("01ORDER0000000000000000001", h5_user_id)
  148. other_order = _order("01ORDER0000000000000000002", "01OTHERUSER0000000000000001")
  149. enrollment.save_order(owned_order)
  150. enrollment.save_order(other_order)
  151. attribution.save_order_attribution(
  152. OrderAttribution(
  153. id="01ATTR00000000000000000001",
  154. order_id=owned_order.id,
  155. h5_user_id=h5_user_id,
  156. salesperson_id=salesperson.id,
  157. promotion_code_id=code.id,
  158. source_code=code.code,
  159. amount_cents=owned_order.amount_cents,
  160. attributed_at=NOW,
  161. )
  162. )
  163. admin_login = client.post(
  164. "/api/v1/admin/auth/login",
  165. json={"username": "sales_a", "password": "zaq1XSW@"},
  166. )
  167. headers = {
  168. "Authorization": (f"Bearer {admin_login.json()['data']['tokens']['access_token']}")
  169. }
  170. orders = client.get("/api/v1/admin/orders", headers=headers)
  171. overview = client.get(
  172. "/api/v1/admin/attribution/overview",
  173. headers=headers,
  174. )
  175. products = client.get("/api/v1/admin/products", headers=headers)
  176. product_id = products.json()["data"]["items"][0]["product_id"]
  177. disabled = client.put(
  178. f"/api/v1/admin/products/{product_id}/status",
  179. headers=headers,
  180. json={"status": "INACTIVE"},
  181. )
  182. assert orders.status_code == 200
  183. assert orders.json()["data"]["total"] == 1
  184. visible_order = orders.json()["data"]["items"][0]
  185. assert visible_order["order_id"] == owned_order.id
  186. assert visible_order["applicant"]["id_no"] == "510104********1234"
  187. assert overview.json()["data"]["salesperson_count"] == 1
  188. assert overview.json()["data"]["order_count"] == 1
  189. assert disabled.status_code == 200
  190. assert disabled.json()["data"]["status"] == "INACTIVE"