test_attribution.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. from datetime import UTC, datetime
  2. from fastapi.testclient import TestClient
  3. from app.commands.seed import build_seed_manifest
  4. from app.core.config import Settings
  5. from app.core.passwords import PasswordService
  6. from app.domains.agent.repository import InMemoryAgentRepository
  7. from app.domains.attribution.models import (
  8. OrderAttribution,
  9. PromotionCode,
  10. Salesperson,
  11. )
  12. from app.domains.attribution.repository import InMemoryAttributionRepository
  13. from app.domains.attribution.service import AttributionService
  14. from app.domains.catalog.repository import InMemoryCatalogRepository
  15. from app.domains.enrollment.models import EnrollmentOrder
  16. from app.domains.enrollment.repository import InMemoryEnrollmentRepository
  17. from app.domains.identity.models import AdminUser, H5User
  18. from app.domains.identity.repository import InMemoryIdentityRepository
  19. from app.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"