service.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. from collections.abc import Callable
  2. from dataclasses import replace
  3. from datetime import datetime
  4. from typing import Any
  5. from app.core.errors import AppError
  6. from app.core.identifiers import new_ulid
  7. from app.domains.attribution.models import LeadAttribution, OrderAttribution
  8. from app.domains.attribution.repository import AttributionRepository
  9. from app.domains.enrollment.models import EnrollmentOrder
  10. from app.domains.identity.models import H5User
  11. class AttributionService:
  12. """第一阶段使用首次有效触点归因,订单创建时固化归因快照。"""
  13. def __init__(
  14. self,
  15. repository: AttributionRepository,
  16. clock: Callable[[], datetime],
  17. ) -> None:
  18. self._repository = repository
  19. self._clock = clock
  20. def capture_visit(
  21. self,
  22. user: H5User,
  23. referral_code: str,
  24. ) -> dict[str, Any]:
  25. code = self._repository.get_promotion_code(referral_code.strip().upper())
  26. if code is None or code.status != "ACTIVE":
  27. raise AppError("REFERRAL_CODE_INVALID", "推广码无效或已停用", 422)
  28. now = self._clock()
  29. existing = self._repository.get_lead_by_user(user.id)
  30. if existing is None:
  31. lead = LeadAttribution(
  32. id=new_ulid(),
  33. h5_user_id=user.id,
  34. salesperson_id=code.salesperson_id,
  35. promotion_code_id=code.id,
  36. source_code=code.code,
  37. visit_count=1,
  38. first_touch_at=now,
  39. last_touch_at=now,
  40. )
  41. else:
  42. lead = replace(
  43. existing,
  44. visit_count=existing.visit_count + 1,
  45. last_touch_at=now,
  46. )
  47. self._repository.save_lead(lead)
  48. return {
  49. "source_code": lead.source_code,
  50. "attributed": True,
  51. "first_touch": existing is None,
  52. }
  53. def record_order(self, order: EnrollmentOrder) -> None:
  54. if self._repository.get_order_attribution(order.id) is not None:
  55. return
  56. lead = self._repository.get_lead_by_user(order.user_id)
  57. if lead is None:
  58. return
  59. self._repository.save_order_attribution(
  60. OrderAttribution(
  61. id=new_ulid(),
  62. order_id=order.id,
  63. h5_user_id=order.user_id,
  64. salesperson_id=lead.salesperson_id,
  65. promotion_code_id=lead.promotion_code_id,
  66. source_code=lead.source_code,
  67. amount_cents=order.amount_cents,
  68. attributed_at=self._clock(),
  69. )
  70. )
  71. def performance(self, admin_user_id: str | None = None) -> dict[str, Any]:
  72. salespersons = [
  73. item
  74. for item in self._repository.list_salespersons()
  75. if item.status == "ACTIVE"
  76. and (admin_user_id is None or item.admin_user_id == admin_user_id)
  77. ]
  78. salesperson_ids = {item.id for item in salespersons}
  79. leads = [
  80. item for item in self._repository.list_leads() if item.salesperson_id in salesperson_ids
  81. ]
  82. orders = [
  83. item
  84. for item in self._repository.list_order_attributions()
  85. if item.salesperson_id in salesperson_ids
  86. ]
  87. items = []
  88. for salesperson in salespersons:
  89. owned_leads = [item for item in leads if item.salesperson_id == salesperson.id]
  90. owned_orders = [item for item in orders if item.salesperson_id == salesperson.id]
  91. visit_count = sum(item.visit_count for item in owned_leads)
  92. lead_count = len(owned_leads)
  93. order_count = len(owned_orders)
  94. premium_cents = sum(item.amount_cents for item in owned_orders)
  95. items.append(
  96. {
  97. "salesperson_id": salesperson.id,
  98. "admin_user_id": salesperson.admin_user_id,
  99. "salesperson_code": salesperson.code,
  100. "salesperson_name": salesperson.name,
  101. "visit_count": visit_count,
  102. "lead_count": lead_count,
  103. "order_count": order_count,
  104. "premium_cents": premium_cents,
  105. "visit_conversion_rate": round(lead_count / visit_count * 100, 1)
  106. if visit_count
  107. else 0.0,
  108. "lead_conversion_rate": round(order_count / lead_count * 100, 1)
  109. if lead_count
  110. else 0.0,
  111. "average_premium_cents": premium_cents // order_count if order_count else 0,
  112. }
  113. )
  114. items.sort(
  115. key=lambda item: (item["premium_cents"], item["order_count"]),
  116. reverse=True,
  117. )
  118. visit_count = sum(item.visit_count for item in leads)
  119. lead_count = len(leads)
  120. order_count = len(orders)
  121. premium_cents = sum(item.amount_cents for item in orders)
  122. return {
  123. "salesperson_count": len(salespersons),
  124. "visit_count": visit_count,
  125. "lead_count": lead_count,
  126. "order_count": order_count,
  127. "premium_cents": premium_cents,
  128. "visit_conversion_rate": round(lead_count / visit_count * 100, 1)
  129. if visit_count
  130. else 0.0,
  131. "lead_conversion_rate": round(order_count / lead_count * 100, 1) if lead_count else 0.0,
  132. "average_premium_cents": premium_cents // order_count if order_count else 0,
  133. "items": items,
  134. }
  135. def order_ids(self, admin_user_id: str | None = None) -> set[str]:
  136. salesperson_ids = {
  137. item.id
  138. for item in self._repository.list_salespersons()
  139. if admin_user_id is None or item.admin_user_id == admin_user_id
  140. }
  141. return {
  142. item.order_id
  143. for item in self._repository.list_order_attributions()
  144. if item.salesperson_id in salesperson_ids
  145. }
  146. def list_codes(self, admin_user_id: str | None = None) -> dict[str, Any]:
  147. salespersons = {
  148. item.id: item
  149. for item in self._repository.list_salespersons()
  150. if admin_user_id is None or item.admin_user_id == admin_user_id
  151. }
  152. items = [
  153. {
  154. "code": item.code,
  155. "channel": item.channel,
  156. "status": item.status,
  157. "salesperson_id": salesperson.id,
  158. "salesperson_code": salesperson.code,
  159. "salesperson_name": salesperson.name,
  160. "created_at": item.created_at,
  161. }
  162. for item in self._repository.list_promotion_codes()
  163. if (salesperson := salespersons.get(item.salesperson_id)) is not None
  164. ]
  165. return {"items": items, "total": len(items)}