scheduler.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import logging
  2. from datetime import datetime, timedelta
  3. from apscheduler.schedulers.background import BackgroundScheduler
  4. from sqlalchemy import text
  5. from app.database import get_db
  6. from app.models.crm_customer import CrmCustomer, CrmCustomerPool
  7. from app.models.crm_payment import CrmPaymentPlan
  8. from app.models.crm_visit import CrmVisit
  9. from app.utils import next_id
  10. logger = logging.getLogger(__name__)
  11. PROTECT_MIN_FOLLOW_COUNT = 3
  12. PROTECT_REMIND_DAYS = 3
  13. PAYMENT_REMIND_DAYS = 7
  14. def _get_tenant_ids() -> list[str]:
  15. """Get all active tenant IDs."""
  16. db = next(get_db())
  17. try:
  18. rows = db.execute(text("SELECT id FROM sys_tenant WHERE status = '0' AND del_flag = '0'")).fetchall()
  19. return [str(r[0]) for r in rows]
  20. finally:
  21. db.close()
  22. # ──────────────────────────────────────────────────────────────────────
  23. # Customer Pool Tasks
  24. # ──────────────────────────────────────────────────────────────────────
  25. def check_protect_period_expired():
  26. """Check customers whose protect period has expired. Runs daily at 1am."""
  27. logger.info("======== Start: check protect period expired ========")
  28. db = next(get_db())
  29. try:
  30. now = datetime.now()
  31. tenants = _get_tenant_ids()
  32. recycle_count = 0
  33. qualified_count = 0
  34. for tenant_id in tenants:
  35. customers = db.query(CrmCustomer).filter(
  36. CrmCustomer.del_flag == 0,
  37. CrmCustomer.tenant_id == tenant_id,
  38. CrmCustomer.protect_time.isnot(None),
  39. CrmCustomer.protect_time <= now,
  40. CrmCustomer.status == 'normal',
  41. CrmCustomer.owner_user_id.isnot(None),
  42. ).all()
  43. for c in customers:
  44. follow_count = c.follow_count or 0
  45. if follow_count < PROTECT_MIN_FOLLOW_COUNT:
  46. _recycle_to_pool(db, c, f"保护期到期未达标:跟进{follow_count}次,要求{PROTECT_MIN_FOLLOW_COUNT}次")
  47. recycle_count += 1
  48. else:
  49. c.protect_time = None
  50. db.commit()
  51. qualified_count += 1
  52. logger.info(f"======== Protect period check done: total={recycle_count + qualified_count}, qualified={qualified_count}, recycled={recycle_count} ========")
  53. except Exception as e:
  54. db.rollback()
  55. logger.error(f"Protect period check failed: {e}")
  56. finally:
  57. db.close()
  58. def remind_protect_period():
  59. """Remind about customers whose protect period expires in 3 days. Runs daily at 9am."""
  60. logger.info("======== Start: remind protect period ========")
  61. db = next(get_db())
  62. try:
  63. now = datetime.now()
  64. deadline = now + timedelta(days=PROTECT_REMIND_DAYS)
  65. tenants = _get_tenant_ids()
  66. remind_count = 0
  67. for tenant_id in tenants:
  68. customers = db.query(CrmCustomer).filter(
  69. CrmCustomer.del_flag == 0,
  70. CrmCustomer.tenant_id == tenant_id,
  71. CrmCustomer.protect_time.isnot(None),
  72. CrmCustomer.protect_time.between(now, deadline),
  73. CrmCustomer.status == 'normal',
  74. CrmCustomer.owner_user_id.isnot(None),
  75. ).all()
  76. for c in customers:
  77. follow_count = c.follow_count or 0
  78. if follow_count < PROTECT_MIN_FOLLOW_COUNT:
  79. logger.info(f"Remind owner [{c.owner_user_name}]: customer [{c.customer_name}] protect period expiring in {PROTECT_REMIND_DAYS} days, follow {follow_count}/{PROTECT_MIN_FOLLOW_COUNT}")
  80. remind_count += 1
  81. logger.info(f"======== Protect period remind done: {remind_count} reminders ========")
  82. except Exception as e:
  83. logger.error(f"Protect period remind failed: {e}")
  84. finally:
  85. db.close()
  86. def auto_recycle_customers():
  87. """Auto-recycle long-unfollowed customers to pool. Runs daily at 2am."""
  88. logger.info("======== Start: auto recycle customers ========")
  89. db = next(get_db())
  90. try:
  91. now = datetime.now()
  92. tenants = _get_tenant_ids()
  93. recycle_count = 0
  94. for tenant_id in tenants:
  95. # Rule 1: New customers created >15 days ago with 0 follows
  96. new_deadline = now - timedelta(days=15)
  97. new_customers = db.query(CrmCustomer).filter(
  98. CrmCustomer.del_flag == 0,
  99. CrmCustomer.tenant_id == tenant_id,
  100. CrmCustomer.create_time <= new_deadline,
  101. CrmCustomer.status == 'normal',
  102. CrmCustomer.owner_user_id.isnot(None),
  103. CrmCustomer.level != 'key',
  104. ).filter(
  105. (CrmCustomer.last_time.is_(None)) | (CrmCustomer.follow_count == 0)
  106. ).all()
  107. for c in new_customers:
  108. _recycle_to_pool(db, c, "自动回收:创建后15天未跟进")
  109. recycle_count += 1
  110. # Rule 2: Old customers last followed >30 days ago
  111. old_deadline = now - timedelta(days=30)
  112. old_customers = db.query(CrmCustomer).filter(
  113. CrmCustomer.del_flag == 0,
  114. CrmCustomer.tenant_id == tenant_id,
  115. CrmCustomer.last_time.isnot(None),
  116. CrmCustomer.last_time <= old_deadline,
  117. CrmCustomer.status == 'normal',
  118. CrmCustomer.owner_user_id.isnot(None),
  119. CrmCustomer.level != 'key',
  120. ).filter(
  121. (CrmCustomer.opportunity_count == 0) | (CrmCustomer.opportunity_count.is_(None))
  122. ).all()
  123. for c in old_customers:
  124. days = (now - c.last_time).days if c.last_time else 0
  125. _recycle_to_pool(db, c, f"自动回收:最后跟进后{days}天未再跟进")
  126. recycle_count += 1
  127. logger.info(f"======== Auto recycle done: {recycle_count} customers recycled ========")
  128. except Exception as e:
  129. db.rollback()
  130. logger.error(f"Auto recycle failed: {e}")
  131. finally:
  132. db.close()
  133. def remind_auto_recycle():
  134. """Warn about customers approaching auto-recycle. Runs daily at 8am."""
  135. logger.info("======== Start: remind auto recycle ========")
  136. db = next(get_db())
  137. try:
  138. now = datetime.now()
  139. tenants = _get_tenant_ids()
  140. remind_count = 0
  141. for tenant_id in tenants:
  142. # Rule 1: New customers created >=12 days with 0 follows
  143. warn_deadline = now - timedelta(days=12)
  144. new_customers = db.query(CrmCustomer).filter(
  145. CrmCustomer.del_flag == 0,
  146. CrmCustomer.tenant_id == tenant_id,
  147. CrmCustomer.create_time <= warn_deadline,
  148. CrmCustomer.create_time >= now - timedelta(days=15),
  149. CrmCustomer.status == 'normal',
  150. CrmCustomer.owner_user_id.isnot(None),
  151. CrmCustomer.level != 'key',
  152. ).filter(
  153. (CrmCustomer.last_time.is_(None)) | (CrmCustomer.follow_count == 0)
  154. ).all()
  155. for c in new_customers:
  156. days = (now - c.create_time).days if c.create_time else 0
  157. remaining = 15 - days
  158. if 0 < remaining <= 3:
  159. logger.info(f"Remind [{c.owner_user_name}]: customer [{c.customer_name}] created {days}d ago, {remaining}d until auto-recycle")
  160. remind_count += 1
  161. # Rule 2: Old customers last followed 27-30 days ago
  162. old_customers = db.query(CrmCustomer).filter(
  163. CrmCustomer.del_flag == 0,
  164. CrmCustomer.tenant_id == tenant_id,
  165. CrmCustomer.last_time.isnot(None),
  166. CrmCustomer.last_time <= now - timedelta(days=27),
  167. CrmCustomer.last_time >= now - timedelta(days=30),
  168. CrmCustomer.status == 'normal',
  169. CrmCustomer.owner_user_id.isnot(None),
  170. CrmCustomer.level != 'key',
  171. ).all()
  172. for c in old_customers:
  173. days = (now - c.last_time).days if c.last_time else 0
  174. remaining = 30 - days
  175. if 0 < remaining <= 3:
  176. logger.info(f"Remind [{c.owner_user_name}]: customer [{c.customer_name}] last follow {days}d ago, {remaining}d until auto-recycle")
  177. remind_count += 1
  178. logger.info(f"======== Auto recycle remind done: {remind_count} reminders ========")
  179. except Exception as e:
  180. logger.error(f"Auto recycle remind failed: {e}")
  181. finally:
  182. db.close()
  183. def _recycle_to_pool(db, customer: CrmCustomer, reason: str):
  184. """Move customer back to public pool."""
  185. original_owner_id = customer.owner_user_id
  186. original_owner_name = customer.owner_user_name
  187. customer.status = 'pool'
  188. customer.owner_user_id = None
  189. customer.owner_user_name = None
  190. customer.into_pool_time = datetime.now()
  191. customer.into_pool_by = 0
  192. customer.into_pool_reason = reason
  193. customer.protect_time = None
  194. customer.claim_time = None
  195. pool_record = CrmCustomerPool(
  196. id=next_id(),
  197. customer_id=customer.id,
  198. customer_name=customer.customer_name,
  199. operation_type='recycle',
  200. from_user_id=original_owner_id,
  201. from_user_name=original_owner_name,
  202. reason=reason,
  203. create_by=0,
  204. create_by_name='系统',
  205. create_time=datetime.now(),
  206. tenant_id=customer.tenant_id,
  207. )
  208. db.add(pool_record)
  209. db.commit()
  210. logger.info(f"Customer [{customer.customer_name}] recycled to pool, previous owner: {original_owner_name}, reason: {reason}")
  211. # ──────────────────────────────────────────────────────────────────────
  212. # Payment Plan Tasks
  213. # ──────────────────────────────────────────────────────────────────────
  214. def check_overdue_payment_plans():
  215. """Mark payment plans as overdue. Runs daily at 1am."""
  216. logger.info("======== Start: check overdue payment plans ========")
  217. db = next(get_db())
  218. try:
  219. today = datetime.now().date()
  220. tenants = _get_tenant_ids()
  221. updated = 0
  222. for tenant_id in tenants:
  223. plans = db.query(CrmPaymentPlan).filter(
  224. CrmPaymentPlan.del_flag == 0,
  225. CrmPaymentPlan.tenant_id == tenant_id,
  226. CrmPaymentPlan.plan_date < today,
  227. CrmPaymentPlan.status.in_(['pending', 'partial']),
  228. CrmPaymentPlan.is_overdue == 0,
  229. ).all()
  230. for p in plans:
  231. overdue_days = (today - p.plan_date).days
  232. p.is_overdue = 1
  233. p.overdue_days = overdue_days
  234. updated += 1
  235. level = "轻微" if overdue_days <= 7 else "一般" if overdue_days <= 15 else "严重" if overdue_days <= 30 else "恶意"
  236. logger.info(f"Overdue [{level}]: plan {p.plan_no}, customer {p.customer_name}, {overdue_days}d overdue")
  237. db.commit()
  238. logger.info(f"======== Overdue check done: {updated} plans updated ========")
  239. except Exception as e:
  240. db.rollback()
  241. logger.error(f"Overdue check failed: {e}")
  242. finally:
  243. db.close()
  244. def send_payment_reminder():
  245. """Send payment reminders for plans due within 7 days. Runs daily at 9am."""
  246. logger.info("======== Start: send payment reminders ========")
  247. db = next(get_db())
  248. try:
  249. today = datetime.now().date()
  250. deadline = today + timedelta(days=PAYMENT_REMIND_DAYS)
  251. tenants = _get_tenant_ids()
  252. remind_count = 0
  253. for tenant_id in tenants:
  254. plans = db.query(CrmPaymentPlan).filter(
  255. CrmPaymentPlan.del_flag == 0,
  256. CrmPaymentPlan.tenant_id == tenant_id,
  257. CrmPaymentPlan.plan_date >= today,
  258. CrmPaymentPlan.plan_date <= deadline,
  259. CrmPaymentPlan.status.in_(['pending', 'partial']),
  260. CrmPaymentPlan.is_overdue == 0,
  261. ).all()
  262. for p in plans:
  263. days_left = (p.plan_date - today).days
  264. if p.remind_status != 'sent' or days_left == 0:
  265. logger.info(f"Remind [{p.owner_user_name}]: plan {p.plan_no}, customer {p.customer_name}, {days_left}d until due, amount {p.plan_amount}")
  266. p.remind_status = 'sent'
  267. p.last_remind_time = datetime.now()
  268. remind_count += 1
  269. db.commit()
  270. logger.info(f"======== Payment remind done: {remind_count} reminders ========")
  271. except Exception as e:
  272. db.rollback()
  273. logger.error(f"Payment remind failed: {e}")
  274. finally:
  275. db.close()
  276. def reset_remind_status():
  277. """Reset remind status for expired plans. Runs daily at 2am."""
  278. logger.info("======== Start: reset remind status ========")
  279. db = next(get_db())
  280. try:
  281. today = datetime.now().date()
  282. tenants = _get_tenant_ids()
  283. reset_count = 0
  284. for tenant_id in tenants:
  285. plans = db.query(CrmPaymentPlan).filter(
  286. CrmPaymentPlan.del_flag == 0,
  287. CrmPaymentPlan.tenant_id == tenant_id,
  288. CrmPaymentPlan.plan_date < today,
  289. CrmPaymentPlan.remind_status == 'sent',
  290. CrmPaymentPlan.is_overdue == 0,
  291. ).all()
  292. for p in plans:
  293. p.remind_status = 'expired'
  294. reset_count += 1
  295. db.commit()
  296. logger.info(f"======== Remind status reset done: {reset_count} records ========")
  297. except Exception as e:
  298. db.rollback()
  299. logger.error(f"Remind status reset failed: {e}")
  300. finally:
  301. db.close()
  302. # ──────────────────────────────────────────────────────────────────────
  303. # Visit Reminder Task
  304. # ──────────────────────────────────────────────────────────────────────
  305. def send_visit_reminder():
  306. """Send reminders for upcoming visits. Runs daily at 9am."""
  307. logger.info("======== Start: send visit reminders ========")
  308. db = next(get_db())
  309. try:
  310. now = datetime.now()
  311. deadline = now + timedelta(days=7)
  312. tenants = _get_tenant_ids()
  313. remind_count = 0
  314. for tenant_id in tenants:
  315. visits = db.query(CrmVisit).filter(
  316. CrmVisit.del_flag == 0,
  317. CrmVisit.tenant_id == tenant_id,
  318. CrmVisit.status.in_(['0', 'pending']),
  319. CrmVisit.plan_visit_date.isnot(None),
  320. CrmVisit.plan_visit_date <= deadline,
  321. ).all()
  322. for v in visits:
  323. days_left = (v.plan_visit_date - now).days if v.plan_visit_date else 0
  324. urgency = "今日" if days_left == 0 else "明日" if days_left == 1 else f"{days_left}天后"
  325. logger.info(f"Remind [{v.owner_user_name}]: visit {v.visit_no}, customer {v.customer_name}, {urgency} ({v.visit_type})")
  326. remind_count += 1
  327. logger.info(f"======== Visit remind done: {remind_count} reminders ========")
  328. except Exception as e:
  329. logger.error(f"Visit remind failed: {e}")
  330. finally:
  331. db.close()
  332. # ──────────────────────────────────────────────────────────────────────
  333. # Scheduler Setup
  334. # ──────────────────────────────────────────────────────────────────────
  335. _scheduler: BackgroundScheduler | None = None
  336. def start_scheduler():
  337. global _scheduler
  338. if _scheduler is not None:
  339. return
  340. _scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
  341. # 1am: Protect period check + Overdue payment check
  342. _scheduler.add_job(check_protect_period_expired, 'cron', hour=1, minute=0, id='check_protect_period')
  343. _scheduler.add_job(check_overdue_payment_plans, 'cron', hour=1, minute=5, id='check_overdue_payment')
  344. # 2am: Auto recycle + Reset remind status
  345. _scheduler.add_job(auto_recycle_customers, 'cron', hour=2, minute=0, id='auto_recycle_customers')
  346. _scheduler.add_job(reset_remind_status, 'cron', hour=2, minute=5, id='reset_remind_status')
  347. # 8am: Auto recycle warning
  348. _scheduler.add_job(remind_auto_recycle, 'cron', hour=8, minute=0, id='remind_auto_recycle')
  349. # 9am: Protect period remind + Payment remind + Visit remind
  350. _scheduler.add_job(remind_protect_period, 'cron', hour=9, minute=0, id='remind_protect_period')
  351. _scheduler.add_job(send_payment_reminder, 'cron', hour=9, minute=5, id='send_payment_reminder')
  352. _scheduler.add_job(send_visit_reminder, 'cron', hour=9, minute=10, id='send_visit_reminder')
  353. _scheduler.start()
  354. logger.info("Scheduler started with 8 jobs")