import { Provide } from '@midwayjs/core'; import { BaseService, CoolCommException } from '@cool-midway/core'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import { CrmLeadEntity } from '../entity/lead'; import { CrmLeadAuditEntity } from '../entity/audit'; /** * 自动回收执行结果接口 */ export interface AutoRecycleResult { recycledCount: number; leadIds: number[]; } /** * 团队线索公海池流转与自动分配服务 * 负责线索公海回收、超时自动扫描回收、销售原子防并发抢单、管理员指派及全链路审计追踪 */ @Provide() export class CrmLeadPoolService extends BaseService { @InjectEntityModel(CrmLeadEntity) crmLeadEntity: Repository; @InjectEntityModel(CrmLeadAuditEntity) crmLeadAuditEntity: Repository; /** * 将线索退回/手动置入公海池 * @param leadId 线索主键 ID * @param reason 退回公海原因说明 */ async recycleToPool( leadId: number, reason?: string ): Promise { const id = Number(leadId); if (!id) { throw new CoolCommException('缺少线索 ID', 400); } const lead = await this.crmLeadEntity.findOne({ where: { id } }); if (!lead) { throw new CoolCommException('潜客线索不存在', 404); } const oldUserId = lead.userId ?? null; lead.isPool = true; lead.userId = null; const saved = await this.crmLeadEntity.save(lead); const audit = new CrmLeadAuditEntity(); audit.leadId = id; audit.action = 'recycle'; audit.fromUserId = oldUserId; audit.toUserId = null; audit.reason = reason || '手动退回公海池'; await this.crmLeadAuditEntity.save(audit); return saved; } /** * 定时扫描超过指定天数未跟进的线索,自动批量归入公海池 * @param inactivityDays 未跟进超期天数阈值 */ async scanAndAutoRecycle(inactivityDays: number): Promise { if (!inactivityDays || inactivityDays <= 0) { return { recycledCount: 0, leadIds: [] }; } const thresholdMs = inactivityDays * 24 * 60 * 60 * 1000; const now = Date.now(); const thresholdDate = new Date(now - thresholdMs); let toRecycle: CrmLeadEntity[] = []; if (this.crmLeadEntity && typeof this.crmLeadEntity.createQueryBuilder === 'function') { toRecycle = await this.crmLeadEntity .createQueryBuilder('lead') .where('lead.is_pool = :isPool', { isPool: false }) .andWhere('lead.status NOT IN (:...excluded)', { excluded: ['won', 'archived'] }) .andWhere( '(COALESCE(lead.last_follow_up_time, lead.update_time, lead.create_time) <= :thresholdDate)', { thresholdDate } ) .take(500) .getMany(); } else { const allLeads = await this.crmLeadEntity.find({ where: { isPool: false } } as any); for (const lead of allLeads || []) { if (lead.isPool) continue; if (lead.status === 'won' || lead.status === 'archived') continue; const followUp = lead.lastFollowUpTime || lead.updateTime || lead.createTime; if (!followUp) continue; const diff = now - new Date(followUp).getTime(); if (diff >= thresholdMs) { toRecycle.push(lead); } } } const recycledIds: number[] = []; for (const lead of toRecycle) { const oldUserId = lead.userId ?? null; lead.isPool = true; lead.userId = null; await this.crmLeadEntity.save(lead); recycledIds.push(Number(lead.id)); const audit = new CrmLeadAuditEntity(); audit.leadId = Number(lead.id); audit.action = 'auto_recycle'; audit.fromUserId = oldUserId; audit.toUserId = null; audit.reason = `超过 ${inactivityDays} 天未跟进自动回收至公海池`; await this.crmLeadAuditEntity.save(audit); } return { recycledCount: recycledIds.length, leadIds: recycledIds }; } /** * 销售成员从公海池抢单/领用 (CAS 原子性防并发保护) * @param leadId 线索 ID * @param userId 认领销售人员 ID */ async claimLead(leadId: number, userId: number): Promise { const id = Number(leadId); const targetUid = Number(userId); if (!id) { throw new CoolCommException('缺少线索 ID', 400); } if (!targetUid) { throw new CoolCommException('缺少领用销售人员 ID', 400); } // 检查线索是否存在 const existing = await this.crmLeadEntity.findOne({ where: { id } }); if (!existing) { throw new CoolCommException('潜客线索不存在', 404); } if (!existing.isPool || existing.userId) { throw new CoolCommException('该线索已被其他销售认领或不在公海池', 409); } // 原子性条件更新:仅在线索仍在公海时方可更新 const updateResult = await this.crmLeadEntity.update( { id, isPool: true }, { isPool: false, userId: targetUid } ); if (!updateResult || updateResult.affected === 0) { throw new CoolCommException('该线索已被其他销售认领或不在公海池', 409); } const updated = await this.crmLeadEntity.findOne({ where: { id } }); const resultLead = updated || existing; resultLead.isPool = false; resultLead.userId = targetUid; // 记录领用审计日志 const audit = new CrmLeadAuditEntity(); audit.leadId = id; audit.action = 'claim'; audit.operatorId = targetUid; audit.fromUserId = null; audit.toUserId = targetUid; audit.reason = '销售公海抢单领用'; await this.crmLeadAuditEntity.save(audit); return resultLead; } /** * 管理员指定派发分配线索 * @param leadId 线索 ID * @param targetUserId 目标销售成员 ID * @param assignerId 管理员/操作人 ID */ async assignLead( leadId: number, targetUserId: number, assignerId?: number ): Promise { const id = Number(leadId); const targetUid = Number(targetUserId); if (!id) { throw new CoolCommException('缺少线索 ID', 400); } if (!targetUid) { throw new CoolCommException('缺少目标销售人员 ID', 400); } const lead = await this.crmLeadEntity.findOne({ where: { id } }); if (!lead) { throw new CoolCommException('潜客线索不存在', 404); } const oldUserId = lead.userId ?? null; lead.userId = targetUid; lead.isPool = false; const saved = await this.crmLeadEntity.save(lead); const audit = new CrmLeadAuditEntity(); audit.leadId = id; audit.action = 'assign'; audit.operatorId = assignerId ? Number(assignerId) : null; audit.fromUserId = oldUserId; audit.toUserId = targetUid; audit.reason = '管理员指定派发线索'; await this.crmLeadAuditEntity.save(audit); return saved; } /** * 查询指定线索的全生命周期流转审计追踪日志 * @param leadId 线索 ID */ async getAuditLogs(leadId: number): Promise { const id = Number(leadId); if (!id) return []; return await this.crmLeadAuditEntity.find({ where: { leadId: id }, }); } }