文件
OmniCard/backend/src/modules/team/service/handover.ts
wikiboss eec259a3a7 feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
2026-09-20 23:47:47 +08:00

251 行
8.0 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import { Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import { CardInfoEntity } from '../../card/entity/info';
import { TeamHandoverLogEntity } from '../entity/handover-log';
import { CrmLeadEntity } from '../../crm/entity/lead';
import { CrmLeadAuditEntity } from '../../crm/entity/audit';
import {
ExecuteHandoverDTO,
HandoverRequest,
} from '../../../comm/types/omni';
import { CardPortalService } from '../../card/service/portal';
/**
* 离职资产安全交接服务
* 涵盖:名片短链重定向、历史CRM线索批量迁移/公海流转、不可逆安全工单审计
*/
@Provide()
export class TeamHandoverService extends BaseService {
@InjectEntityModel(CardInfoEntity)
cardInfoEntity: Repository<CardInfoEntity>;
@InjectEntityModel(TeamHandoverLogEntity)
teamHandoverLogEntity: Repository<TeamHandoverLogEntity>;
@InjectEntityModel(CrmLeadEntity)
crmLeadEntity: Repository<CrmLeadEntity>;
@InjectEntityModel(CrmLeadAuditEntity)
crmLeadAuditEntity: Repository<CrmLeadAuditEntity>;
/**
* 执行离职资产全流程安全交接
*/
async executeHandover(
dto: ExecuteHandoverDTO
): Promise<TeamHandoverLogEntity> {
const fromUserId = Number(dto?.fromUserId);
const toUserId =
dto?.toUserId !== undefined && dto?.toUserId !== null && dto?.toUserId !== ''
? Number(dto.toUserId)
: null;
if (!fromUserId) {
throw new CoolCommException('离职员工标识不能为空');
}
if (toUserId !== null && fromUserId === toUserId) {
throw new CoolCommException('接替人不能与原负责人相同');
}
const leadAction = dto.leadAction || 'none';
if (leadAction === 'transfer' && !toUserId) {
throw new CoolCommException('转移线索时接替人不能为空');
}
// 1. 离职员工名片短链安全重定向
const redirectType = dto.redirectType || 'none';
const redirectTarget = dto.redirectTarget || null;
if (dto.cardId) {
const cardId = Number(dto.cardId);
const card = await this.cardInfoEntity.findOneBy({ id: cardId });
if (!card) {
throw new CoolCommException('名片不存在');
}
if (Number(card.userId) !== fromUserId) {
throw new CoolCommException('名片不属于原负责人');
}
card.redirectType = redirectType;
card.redirectTarget = redirectTarget;
if (toUserId && redirectType !== 'portal') {
card.userId = toUserId;
}
this.syncInternalConfigRedirect(card, redirectType, redirectTarget);
await this.cardInfoEntity.save(card);
CardPortalService.clearAllCache();
} else if (redirectType !== 'none') {
const userCards = await this.cardInfoEntity.find({
where: { userId: fromUserId },
});
for (const c of userCards) {
c.redirectType = redirectType;
c.redirectTarget = redirectTarget;
if (toUserId && redirectType !== 'portal') {
c.userId = toUserId;
}
this.syncInternalConfigRedirect(c, redirectType, redirectTarget);
await this.cardInfoEntity.save(c);
}
if (userCards.length > 0) {
CardPortalService.clearAllCache();
}
}
// 2. 历史 CRM 客户线索批量原子流转 (转移至接任人或划入公海池)
const leads = await this.crmLeadEntity.find({
where: {
userId: fromUserId,
isPool: false,
},
});
const leadIds = leads.map(l => l.id);
let migratedLeadCount = 0;
if (leads.length > 0) {
const now = new Date();
if (leadAction === 'transfer' && toUserId) {
for (const lead of leads) {
lead.userId = toUserId;
lead.isPool = false;
lead.lastFollowUpTime = now;
}
await this.crmLeadEntity.save(leads);
const audits = leads.map(lead => {
const audit = new CrmLeadAuditEntity();
audit.leadId = lead.id;
audit.action = 'assign';
audit.operatorId = dto.operatorId ? Number(dto.operatorId) : null;
audit.fromUserId = fromUserId;
audit.toUserId = toUserId;
audit.reason = dto.remark || '员工离职资产交接-转移至接任人';
return audit;
});
await this.crmLeadAuditEntity.save(audits);
migratedLeadCount = leads.length;
} else if (leadAction === 'pool') {
for (const lead of leads) {
lead.userId = null;
lead.isPool = true;
lead.lastFollowUpTime = now;
}
await this.crmLeadEntity.save(leads);
const audits = leads.map(lead => {
const audit = new CrmLeadAuditEntity();
audit.leadId = lead.id;
audit.action = 'recycle';
audit.operatorId = dto.operatorId ? Number(dto.operatorId) : null;
audit.fromUserId = fromUserId;
audit.toUserId = null;
audit.reason = dto.remark || '员工离职资产交接-划入公海池';
return audit;
});
await this.crmLeadAuditEntity.save(audits);
migratedLeadCount = leads.length;
}
}
// 3. 写入不可逆安全交接工单历史表
const handoverNo = `HO${Date.now()}${Math.floor(Math.random() * 9000 + 1000)}`;
const snapshot = JSON.stringify({
fromUserId,
toUserId,
cardId: dto.cardId ? Number(dto.cardId) : null,
leadIds,
leadAction,
redirectType,
redirectTarget,
migratedLeadCount,
handoverTime: new Date().toISOString(),
});
const log = new TeamHandoverLogEntity();
log.handoverNo = handoverNo;
log.fromUserId = fromUserId;
log.toUserId = toUserId;
log.cardId = dto.cardId ? Number(dto.cardId) : null;
log.redirectType = redirectType;
log.redirectTarget = redirectTarget;
log.leadAction = leadAction;
log.migratedLeadCount = migratedLeadCount;
log.operatorId = dto.operatorId ? Number(dto.operatorId) : null;
log.status = 'completed';
log.remark = dto.remark || null;
log.snapshot = snapshot;
return await this.teamHandoverLogEntity.save(log);
}
/**
* 调阅离职交接工单历史列表
*/
async getHandoverLogs(query?: {
fromUserId?: number;
toUserId?: number;
}): Promise<TeamHandoverLogEntity[]> {
const where: FindOptionsWhere<TeamHandoverLogEntity> = {};
if (query?.fromUserId) where.fromUserId = Number(query.fromUserId);
if (query?.toUserId) where.toUserId = Number(query.toUserId);
return await this.teamHandoverLogEntity.find({
where: Object.keys(where).length > 0 ? where : undefined,
order: { id: 'DESC' },
});
}
/**
* 兼容原有轻量 transfer 接口
*/
async transfer(req: HandoverRequest): Promise<CardInfoEntity> {
const cardId = Number(req?.cardId);
const fromUserId = Number(req?.fromUserId);
const toUserId = Number(req?.toUserId);
if (!cardId || !fromUserId || !toUserId) {
throw new CoolCommException('交接参数不完整');
}
await this.executeHandover({
cardId,
fromUserId,
toUserId,
redirectType: 'card',
redirectTarget: String(toUserId),
leadAction: 'transfer',
remark: '快速名片资产交接',
});
const card = await this.cardInfoEntity.findOneBy({ id: cardId });
if (!card) {
throw new CoolCommException('名片不存在');
}
return card;
}
/**
* 同步更新名片内部配置
*/
private syncInternalConfigRedirect(
card: CardInfoEntity,
redirectType: string,
redirectTarget: string | null
): void {
let cfg: Record<string, unknown> = {};
if (card.internalConfig) {
try {
cfg = JSON.parse(card.internalConfig) as Record<string, unknown>;
} catch {
cfg = {};
}
}
cfg.redirectType = redirectType;
cfg.redirectTarget = redirectTarget;
cfg.handoverAt = new Date().toISOString();
card.internalConfig = JSON.stringify(cfg);
}
}