seed_business_data.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. from dataclasses import dataclass
  2. from datetime import UTC, datetime, timedelta
  3. from hashlib import sha256
  4. from app.commands.seed import build_seed_manifest, stable_id
  5. from app.core.config import Settings
  6. from app.domains.attribution.models import LeadAttribution, OrderAttribution
  7. from app.domains.enrollment.models import (
  8. EnrollmentDraft,
  9. EnrollmentOrder,
  10. PaymentTransaction,
  11. Policy,
  12. Quote,
  13. UserConfirmation,
  14. )
  15. from app.domains.identity.models import H5User
  16. from app.infrastructure.mysql.attribution_repositories import (
  17. SqlAlchemyAttributionRepository,
  18. )
  19. from app.infrastructure.mysql.enrollment_repositories import (
  20. SqlAlchemyEnrollmentRepository,
  21. )
  22. from app.infrastructure.mysql.repositories import SqlAlchemyIdentityRepository
  23. from app.infrastructure.mysql.sessions import (
  24. DatabaseName,
  25. create_mysql_engine,
  26. create_session_factory,
  27. )
  28. @dataclass(frozen=True, slots=True)
  29. class BusinessCase:
  30. applicant: str
  31. insured: str
  32. relationship: str
  33. product_code: str
  34. plan_code: str
  35. status: str
  36. days_ago: int
  37. policy_status: str | None = None
  38. payment_status: str | None = None
  39. BASE_CASES: tuple[BusinessCase, ...] = (
  40. BusinessCase(
  41. "周明远", "周明远", "SELF", "MED-BASIC", "BASIC", "ISSUED", 0, "ACTIVE", "SUCCEEDED"
  42. ),
  43. BusinessCase(
  44. "林晓雨",
  45. "林建国",
  46. "PARENT",
  47. "MED-SENIOR",
  48. "SENIOR_STANDARD",
  49. "ISSUED",
  50. 1,
  51. "ACTIVE",
  52. "SUCCEEDED",
  53. ),
  54. BusinessCase(
  55. "陈子昂", "王思雅", "SPOUSE", "MED-UPGRADE", "STANDARD", "ISSUED", 2, "ACTIVE", "SUCCEEDED"
  56. ),
  57. BusinessCase(
  58. "赵清和", "赵安安", "CHILD", "ACC-FAMILY", "FAMILY", "ISSUED", 3, "ACTIVE", "SUCCEEDED"
  59. ),
  60. BusinessCase(
  61. "许文博", "许文博", "SELF", "ACC-FAMILY", "INDIVIDUAL", "ISSUED", 4, "ACTIVE", "SUCCEEDED"
  62. ),
  63. BusinessCase(
  64. "郑嘉宁",
  65. "郑国强",
  66. "PARENT",
  67. "MED-SENIOR",
  68. "SENIOR_STANDARD",
  69. "ISSUED",
  70. 5,
  71. "ACTIVE",
  72. "SUCCEEDED",
  73. ),
  74. BusinessCase(
  75. "孙语桐", "方俊杰", "SPOUSE", "MED-UPGRADE", "ENHANCED", "ISSUED", 7, "ACTIVE", "SUCCEEDED"
  76. ),
  77. BusinessCase(
  78. "何志诚", "何沐阳", "CHILD", "ACC-FAMILY", "FAMILY", "ISSUED", 9, "ACTIVE", "SUCCEEDED"
  79. ),
  80. BusinessCase(
  81. "蒋若溪", "蒋若溪", "SELF", "MED-UPGRADE", "STANDARD", "ISSUED", 12, "ACTIVE", "SUCCEEDED"
  82. ),
  83. BusinessCase(
  84. "唐景行",
  85. "唐德华",
  86. "PARENT",
  87. "MED-SENIOR",
  88. "SENIOR_STANDARD",
  89. "ISSUED",
  90. 15,
  91. "ACTIVE",
  92. "SUCCEEDED",
  93. ),
  94. BusinessCase(
  95. "邓舒涵", "宋嘉树", "SPOUSE", "MED-BASIC", "BASIC", "ISSUED", 20, "ACTIVE", "SUCCEEDED"
  96. ),
  97. BusinessCase(
  98. "梁书航", "梁小满", "CHILD", "ACC-FAMILY", "FAMILY", "ISSUED", 25, "ACTIVE", "SUCCEEDED"
  99. ),
  100. BusinessCase(
  101. "顾念安", "顾念安", "SELF", "MED-BASIC", "BASIC", "ISSUED", 30, "CANCELLED", "SUCCEEDED"
  102. ),
  103. BusinessCase(
  104. "马致远",
  105. "马国庆",
  106. "PARENT",
  107. "MED-SENIOR",
  108. "SENIOR_STANDARD",
  109. "ISSUED",
  110. 430,
  111. "EXPIRED",
  112. "SUCCEEDED",
  113. ),
  114. BusinessCase(
  115. "罗婉清", "罗婉清", "SELF", "MED-UPGRADE", "ENHANCED", "PAID", 1, None, "SUCCEEDED"
  116. ),
  117. BusinessCase("谢云舟", "沈知夏", "SPOUSE", "MED-BASIC", "BASIC", "PAID", 3, None, "SUCCEEDED"),
  118. BusinessCase(
  119. "苏曼青",
  120. "苏曼青",
  121. "SELF",
  122. "ACC-FAMILY",
  123. "INDIVIDUAL",
  124. "PENDING_PAYMENT",
  125. 0,
  126. None,
  127. "CREATED",
  128. ),
  129. BusinessCase(
  130. "白敬亭",
  131. "白国良",
  132. "PARENT",
  133. "MED-SENIOR",
  134. "SENIOR_STANDARD",
  135. "PENDING_PAYMENT",
  136. 2,
  137. None,
  138. None,
  139. ),
  140. BusinessCase(
  141. "陆星河", "陆小禾", "CHILD", "ACC-FAMILY", "FAMILY", "PENDING_PAYMENT", 5, None, "CREATED"
  142. ),
  143. BusinessCase(
  144. "秦知意", "秦知意", "SELF", "MED-UPGRADE", "STANDARD", "PENDING_PAYMENT", 8, None, None
  145. ),
  146. BusinessCase(
  147. "夏予川", "顾安然", "SPOUSE", "MED-BASIC", "BASIC", "PENDING_PAYMENT", 11, None, "CREATED"
  148. ),
  149. BusinessCase("叶听澜", "叶听澜", "SELF", "MED-BASIC", "BASIC", "CANCELLED", 4, None, "FAILED"),
  150. BusinessCase(
  151. "程砚秋",
  152. "程建军",
  153. "PARENT",
  154. "MED-SENIOR",
  155. "SENIOR_STANDARD",
  156. "CANCELLED",
  157. 13,
  158. None,
  159. "FAILED",
  160. ),
  161. BusinessCase("江望舒", "江小鱼", "CHILD", "ACC-FAMILY", "FAMILY", "CANCELLED", 21, None, None),
  162. )
  163. GENERATED_NAMES = (
  164. "沈亦辰",
  165. "温书瑶",
  166. "乔景川",
  167. "孟知微",
  168. "裴言澈",
  169. "楚星晚",
  170. "傅时安",
  171. "宋予宁",
  172. "贺云深",
  173. "姜晚晴",
  174. "顾承泽",
  175. "许清欢",
  176. "陆闻舟",
  177. "苏念初",
  178. "程屿森",
  179. "叶舒然",
  180. "周砚北",
  181. "林语棠",
  182. "陈遇白",
  183. "赵南乔",
  184. "唐慕川",
  185. "梁初夏",
  186. "郑怀瑾",
  187. "孙云舒",
  188. "何景明",
  189. "蒋依澜",
  190. "邓星野",
  191. "罗芷晴",
  192. "马明谦",
  193. "谢知夏",
  194. "秦远洲",
  195. "白若宁",
  196. "夏云川",
  197. "顾安禾",
  198. "叶庭深",
  199. "程书妍",
  200. "江叙白",
  201. "周晚意",
  202. "林嘉树",
  203. "陈思源",
  204. "赵安澜",
  205. "唐清越",
  206. "梁知行",
  207. "郑初晴",
  208. "孙景和",
  209. "何语心",
  210. "蒋承安",
  211. "邓若溪",
  212. )
  213. def _generated_business_cases() -> tuple[BusinessCase, ...]:
  214. relationships = ("SELF", "PARENT", "SPOUSE", "CHILD")
  215. product_plans = (
  216. ("MED-BASIC", "BASIC"),
  217. ("MED-BASIC", "BASIC"),
  218. ("MED-UPGRADE", "STANDARD"),
  219. ("MED-UPGRADE", "ENHANCED"),
  220. ("MED-SENIOR", "SENIOR_STANDARD"),
  221. ("ACC-FAMILY", "INDIVIDUAL"),
  222. ("ACC-FAMILY", "FAMILY"),
  223. )
  224. generated: list[BusinessCase] = []
  225. for offset, applicant in enumerate(GENERATED_NAMES, start=1):
  226. index = len(BASE_CASES) + offset
  227. relationship = relationships[index % len(relationships)]
  228. product_code, plan_code = product_plans[index % len(product_plans)]
  229. if product_code == "MED-SENIOR":
  230. relationship = "PARENT"
  231. selector = index % 20
  232. if selector in {0, 7, 14, 18}:
  233. status = "PENDING_PAYMENT"
  234. policy_status = None
  235. payment_status = "CREATED" if index % 2 else None
  236. elif selector in {9, 19}:
  237. status = "CANCELLED"
  238. policy_status = None
  239. payment_status = "FAILED" if index % 2 else None
  240. elif selector == 11:
  241. status = "PAID"
  242. policy_status = None
  243. payment_status = "SUCCEEDED"
  244. else:
  245. status = "ISSUED"
  246. policy_status = "ACTIVE"
  247. payment_status = "SUCCEEDED"
  248. insured = (
  249. applicant
  250. if relationship == "SELF"
  251. else GENERATED_NAMES[(offset + 11) % len(GENERATED_NAMES)]
  252. )
  253. generated.append(
  254. BusinessCase(
  255. applicant=applicant,
  256. insured=insured,
  257. relationship=relationship,
  258. product_code=product_code,
  259. plan_code=plan_code,
  260. status=status,
  261. days_ago=(index * 7 + (index // 3) * 5) % 120,
  262. policy_status=policy_status,
  263. payment_status=payment_status,
  264. )
  265. )
  266. return tuple(generated)
  267. CASES = BASE_CASES + _generated_business_cases()
  268. ATTRIBUTION_PATTERN: tuple[int | None, ...] = (
  269. 0,
  270. 0,
  271. 0,
  272. 1,
  273. 0,
  274. 1,
  275. 2,
  276. 0,
  277. 1,
  278. 2,
  279. 3,
  280. 0,
  281. 1,
  282. 2,
  283. 3,
  284. 4,
  285. 0,
  286. 1,
  287. 2,
  288. 3,
  289. 4,
  290. 5,
  291. None,
  292. None,
  293. )
  294. LEAD_ONLY_COUNT = 42
  295. PREMIUMS = {
  296. ("MED-BASIC", "BASIC"): 23900,
  297. ("MED-UPGRADE", "STANDARD"): 39900,
  298. ("MED-UPGRADE", "ENHANCED"): 59900,
  299. ("MED-SENIOR", "SENIOR_STANDARD"): 19900,
  300. ("ACC-FAMILY", "INDIVIDUAL"): 9900,
  301. ("ACC-FAMILY", "FAMILY"): 15900,
  302. }
  303. def run_seed(settings: Settings | None = None) -> dict[str, int]:
  304. resolved_settings = settings or Settings()
  305. engine = create_mysql_engine(resolved_settings, DatabaseName.CORE)
  306. factory = create_session_factory(engine)
  307. identities = SqlAlchemyIdentityRepository(factory)
  308. enrollment = SqlAlchemyEnrollmentRepository(factory)
  309. attribution = SqlAlchemyAttributionRepository(factory)
  310. manifest = build_seed_manifest()
  311. for salesperson in manifest.salespersons:
  312. attribution.save_salesperson(salesperson)
  313. for seed_code in manifest.promotion_codes:
  314. attribution.save_promotion_code(seed_code)
  315. products = {product.product_code: product for product in manifest.products}
  316. published_versions = {
  317. product.product_code: next(
  318. version
  319. for version in manifest.versions
  320. if version.product_id == product.id and version.status == "PUBLISHED"
  321. )
  322. for product in manifest.products
  323. }
  324. anchor = datetime(2026, 7, 26, 16, 30, tzinfo=UTC)
  325. customer_ids: dict[str, str] = {}
  326. for index, case in enumerate(CASES, start=1):
  327. mobile = f"1886001{index:04d}"
  328. user_id = stable_id(f"business-user:{mobile}")
  329. customer_ids[mobile] = user_id
  330. identities.save_h5_user(
  331. H5User(
  332. id=user_id,
  333. mobile=mobile,
  334. mobile_masked=f"{mobile[:3]}****{mobile[-4:]}",
  335. display_name=case.applicant,
  336. status="ACTIVE",
  337. created_at=anchor - timedelta(days=case.days_ago + 8),
  338. )
  339. )
  340. promotion_index = ATTRIBUTION_PATTERN[(index - 1) % len(ATTRIBUTION_PATTERN)]
  341. attributed_code = (
  342. manifest.promotion_codes[promotion_index] if promotion_index is not None else None
  343. )
  344. if attributed_code is not None:
  345. attribution.save_lead(
  346. LeadAttribution(
  347. id=stable_id(f"business-lead:{index}"),
  348. h5_user_id=user_id,
  349. salesperson_id=attributed_code.salesperson_id,
  350. promotion_code_id=attributed_code.id,
  351. source_code=attributed_code.code,
  352. visit_count=1 + index % 4,
  353. first_touch_at=anchor - timedelta(days=case.days_ago + 8),
  354. last_touch_at=anchor - timedelta(days=case.days_ago + 1),
  355. )
  356. )
  357. product = products[case.product_code]
  358. version = published_versions[case.product_code]
  359. plan = next(item for item in version.plans if item.code == case.plan_code)
  360. created_at = anchor - timedelta(
  361. days=case.days_ago,
  362. hours=(index * 3) % 12,
  363. minutes=(index * 7) % 60,
  364. )
  365. premium_cents = PREMIUMS[(case.product_code, case.plan_code)]
  366. quote_id = stable_id(f"business-quote:{index}")
  367. draft_id = stable_id(f"business-draft:{index}")
  368. confirmation_id = stable_id(f"business-confirmation:{index}")
  369. order_id = stable_id(f"business-order:{index}")
  370. id_no_suffix = f"{index:04d}"
  371. applicant_snapshot = {
  372. "name": case.applicant,
  373. "id_no": f"51010419880101{id_no_suffix}",
  374. }
  375. insured_snapshot = {
  376. "name": case.insured,
  377. "id_no": f"51010419920102{id_no_suffix}",
  378. }
  379. enrollment.save_quote(
  380. Quote(
  381. id=quote_id,
  382. user_id=user_id,
  383. product_id=product.id,
  384. product_version_id=version.id,
  385. plan_id=plan.id,
  386. insured_age=65 if case.relationship == "PARENT" else 35,
  387. insured_region_code="510100",
  388. occupation_code="GENERAL",
  389. relationship=case.relationship,
  390. premium_cents=premium_cents,
  391. currency="CNY",
  392. rule_version="eligibility-v1",
  393. rate_version="rate-v1",
  394. status="USED",
  395. expires_at=created_at + timedelta(minutes=30),
  396. created_at=created_at - timedelta(minutes=12),
  397. )
  398. )
  399. enrollment.save_draft(
  400. EnrollmentDraft(
  401. id=draft_id,
  402. user_id=user_id,
  403. quote_id=quote_id,
  404. applicant=applicant_snapshot,
  405. insured=insured_snapshot,
  406. contact={"mobile": mobile},
  407. status="CONFIRMED",
  408. expires_at=created_at + timedelta(hours=24),
  409. created_at=created_at - timedelta(minutes=8),
  410. )
  411. )
  412. enrollment.save_confirmation(
  413. UserConfirmation(
  414. id=confirmation_id,
  415. user_id=user_id,
  416. draft_id=draft_id,
  417. token_hash=sha256(f"business-confirmation-token:{index}".encode()).hexdigest(),
  418. status="USED",
  419. expires_at=created_at + timedelta(minutes=10),
  420. created_at=created_at - timedelta(minutes=3),
  421. )
  422. )
  423. order = EnrollmentOrder(
  424. id=order_id,
  425. order_no=f"ORD-{created_at:%Y%m%d}-B{index:07d}",
  426. user_id=user_id,
  427. quote_id=quote_id,
  428. draft_id=draft_id,
  429. confirmation_id=confirmation_id,
  430. idempotency_key=f"business-order-{index:04d}",
  431. product_id=product.id,
  432. product_version_id=version.id,
  433. plan_id=plan.id,
  434. applicant_snapshot=applicant_snapshot,
  435. insured_snapshot=insured_snapshot,
  436. amount_cents=premium_cents,
  437. currency="CNY",
  438. status=case.status,
  439. created_at=created_at,
  440. )
  441. enrollment.save_order(order)
  442. if attributed_code is not None:
  443. attribution.save_order_attribution(
  444. OrderAttribution(
  445. id=stable_id(f"business-order-attribution:{index}"),
  446. order_id=order.id,
  447. h5_user_id=user_id,
  448. salesperson_id=attributed_code.salesperson_id,
  449. promotion_code_id=attributed_code.id,
  450. source_code=attributed_code.code,
  451. amount_cents=premium_cents,
  452. attributed_at=created_at,
  453. )
  454. )
  455. if case.payment_status is not None:
  456. payment_id = stable_id(f"business-payment:{index}")
  457. succeeded_at = (
  458. created_at + timedelta(minutes=2) if case.payment_status == "SUCCEEDED" else None
  459. )
  460. enrollment.save_payment(
  461. PaymentTransaction(
  462. id=payment_id,
  463. payment_no=f"PAY-{created_at:%Y%m%d}-B{index:07d}",
  464. order_id=order_id,
  465. user_id=user_id,
  466. transaction_type="PAYMENT",
  467. provider="LOCAL_MOCK_PAY",
  468. idempotency_key=f"business-payment-{index:04d}",
  469. amount_cents=premium_cents,
  470. currency="CNY",
  471. status=case.payment_status,
  472. provider_transaction_no=(
  473. f"MP-{created_at:%Y%m%d}-B{index:07d}"
  474. if case.payment_status == "SUCCEEDED"
  475. else None
  476. ),
  477. succeeded_at=succeeded_at,
  478. created_at=created_at + timedelta(minutes=1),
  479. )
  480. )
  481. if case.policy_status is not None:
  482. coverage_start = (created_at + timedelta(days=1)).date()
  483. coverage_end = coverage_start + timedelta(days=365)
  484. enrollment.save_policy(
  485. Policy(
  486. id=stable_id(f"business-policy:{index}"),
  487. policy_no=f"POL-{created_at:%Y%m%d}-B{index:07d}",
  488. order_id=order_id,
  489. user_id=user_id,
  490. product_id=product.id,
  491. product_version_id=version.id,
  492. plan_id=plan.id,
  493. premium_cents=premium_cents,
  494. currency="CNY",
  495. coverage_start=coverage_start,
  496. coverage_end=coverage_end,
  497. status=case.policy_status,
  498. issued_at=created_at + timedelta(minutes=4),
  499. )
  500. )
  501. for index in range(1, LEAD_ONLY_COUNT + 1):
  502. mobile = f"1887002{index:04d}"
  503. user_id = stable_id(f"business-lead-only-user:{mobile}")
  504. promotion_index = ATTRIBUTION_PATTERN[
  505. (index * 5 + index // 4) % (len(ATTRIBUTION_PATTERN) - 2)
  506. ]
  507. if promotion_index is None:
  508. continue
  509. promotion_code = manifest.promotion_codes[promotion_index]
  510. created_days_ago = (index * 11 + 3) % 150
  511. identities.save_h5_user(
  512. H5User(
  513. id=user_id,
  514. mobile=mobile,
  515. mobile_masked=f"{mobile[:3]}****{mobile[-4:]}",
  516. display_name=f"咨询客户{index:02d}",
  517. status="ACTIVE",
  518. created_at=anchor - timedelta(days=created_days_ago),
  519. )
  520. )
  521. attribution.save_lead(
  522. LeadAttribution(
  523. id=stable_id(f"business-lead-only:{index}"),
  524. h5_user_id=user_id,
  525. salesperson_id=promotion_code.salesperson_id,
  526. promotion_code_id=promotion_code.id,
  527. source_code=promotion_code.code,
  528. visit_count=1 + (index * 3) % 7,
  529. first_touch_at=anchor - timedelta(days=created_days_ago),
  530. last_touch_at=anchor - timedelta(days=max(0, created_days_ago - (index % 12))),
  531. )
  532. )
  533. engine.dispose()
  534. return {
  535. "customers": len(customer_ids) + LEAD_ONLY_COUNT,
  536. "orders": len(CASES),
  537. "policies": sum(case.policy_status is not None for case in CASES),
  538. "payments": sum(case.payment_status is not None for case in CASES),
  539. "leads": sum(
  540. ATTRIBUTION_PATTERN[(index - 1) % len(ATTRIBUTION_PATTERN)] is not None
  541. for index in range(1, len(CASES) + 1)
  542. )
  543. + LEAD_ONLY_COUNT,
  544. "attributed_orders": sum(
  545. ATTRIBUTION_PATTERN[(index - 1) % len(ATTRIBUTION_PATTERN)] is not None
  546. for index in range(1, len(CASES) + 1)
  547. ),
  548. }
  549. if __name__ == "__main__":
  550. result = run_seed()
  551. print(
  552. "业务数据写入完成:"
  553. f"{result['customers']} 位投保用户,"
  554. f"{result['orders']} 笔订单,"
  555. f"{result['payments']} 条支付流水,"
  556. f"{result['policies']} 张保单,"
  557. f"{result['leads']} 条推广线索,"
  558. f"{result['attributed_orders']} 笔推广归因订单。"
  559. )