470 行
17 KiB
TypeScript
470 行
17 KiB
TypeScript
import { getMetadataArgsStorage } from 'typeorm';
|
|
import { CoolCommException } from '@cool-midway/core';
|
|
import { BaseEntity } from '../../../src/comm/entity/base';
|
|
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
|
|
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
|
|
import { CrmLeadAuditEntity } from '../../../src/modules/crm/entity/audit';
|
|
import { TeamHandoverLogEntity } from '../../../src/modules/team/entity/handover-log';
|
|
import { TeamHandoverService } from '../../../src/modules/team/service/handover';
|
|
import { AdminTeamHandoverController } from '../../../src/modules/team/controller/admin/handover';
|
|
import { ExecuteHandoverDTO } from '../../../src/comm/types/omni';
|
|
import { CardPortalService } from '../../../src/modules/card/service/portal';
|
|
|
|
describe('Point 19: 离职资产交接工单流程与历史线索迁移 单元测试', () => {
|
|
const metadata = getMetadataArgsStorage();
|
|
|
|
// ==========================================
|
|
// 1. 实体定义与数据库字段映射测试
|
|
// ==========================================
|
|
describe('1. 实体定义与数据库字段映射测试', () => {
|
|
it('TeamHandoverLogEntity 表名映射应为 team_handover_log 且继承 BaseEntity', () => {
|
|
const table = metadata.tables.find(t => t.target === TeamHandoverLogEntity);
|
|
expect(table).toBeDefined();
|
|
expect(table?.name).toBe('team_handover_log');
|
|
expect(TeamHandoverLogEntity.prototype instanceof BaseEntity).toBe(true);
|
|
});
|
|
|
|
it('TeamHandoverLogEntity 应映射各核心字段至下划线数据列', () => {
|
|
const columns = metadata.columns.filter(c => c.target === TeamHandoverLogEntity);
|
|
const expectedColumns: Record<string, string> = {
|
|
handoverNo: 'handover_no',
|
|
fromUserId: 'from_user_id',
|
|
toUserId: 'to_user_id',
|
|
cardId: 'card_id',
|
|
redirectType: 'redirect_type',
|
|
redirectTarget: 'redirect_target',
|
|
leadAction: 'lead_action',
|
|
migratedLeadCount: 'migrated_lead_count',
|
|
operatorId: 'operator_id',
|
|
status: 'status',
|
|
remark: 'remark',
|
|
snapshot: 'snapshot',
|
|
};
|
|
|
|
for (const [prop, colName] of Object.entries(expectedColumns)) {
|
|
const col = columns.find(c => c.propertyName === prop);
|
|
expect(col).toBeDefined();
|
|
expect(col?.options.name || col?.propertyName).toBe(colName);
|
|
}
|
|
});
|
|
|
|
it('CardInfoEntity 应支持 redirect_type 与 redirect_target 字段映射', () => {
|
|
const columns = metadata.columns.filter(c => c.target === CardInfoEntity);
|
|
const redirectTypeCol = columns.find(c => c.propertyName === 'redirectType');
|
|
const redirectTargetCol = columns.find(c => c.propertyName === 'redirectTarget');
|
|
|
|
expect(redirectTypeCol).toBeDefined();
|
|
expect(redirectTypeCol?.options.name || redirectTypeCol?.propertyName).toBe('redirect_type');
|
|
expect(redirectTargetCol).toBeDefined();
|
|
expect(redirectTargetCol?.options.name || redirectTargetCol?.propertyName).toBe('redirect_target');
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 2. 核心服务流转与业务逻辑测试
|
|
// ==========================================
|
|
describe('2. 离职资产交接业务逻辑测试 (TeamHandoverService)', () => {
|
|
let service: TeamHandoverService;
|
|
let cards: CardInfoEntity[];
|
|
let leads: CrmLeadEntity[];
|
|
let audits: CrmLeadAuditEntity[];
|
|
let handoverLogs: TeamHandoverLogEntity[];
|
|
let nextAuditId = 1;
|
|
let nextLogId = 1;
|
|
|
|
beforeEach(() => {
|
|
cards = [];
|
|
leads = [];
|
|
audits = [];
|
|
handoverLogs = [];
|
|
nextAuditId = 1;
|
|
nextLogId = 1;
|
|
|
|
// 预置名片数据:离职员工 1001 的名片
|
|
const card1 = new CardInfoEntity();
|
|
card1.id = 101;
|
|
card1.userId = 1001;
|
|
card1.slug = 'former-employee-alex';
|
|
card1.name = 'Alex Former';
|
|
card1.status = 'published';
|
|
cards.push(card1);
|
|
|
|
// 接任人 2002 的已有新名片
|
|
const card2 = new CardInfoEntity();
|
|
card2.id = 202;
|
|
card2.userId = 2002;
|
|
card2.slug = 'successor-bob';
|
|
card2.name = 'Bob Successor';
|
|
card2.status = 'published';
|
|
cards.push(card2);
|
|
|
|
// 预置离职员工名下的 CRM 线索数据
|
|
const lead1 = new CrmLeadEntity();
|
|
lead1.id = 1;
|
|
lead1.userId = 1001;
|
|
lead1.cardId = 101;
|
|
lead1.name = '重点合作客户 A';
|
|
lead1.isPool = false;
|
|
lead1.status = 'contacted';
|
|
leads.push(lead1);
|
|
|
|
const lead2 = new CrmLeadEntity();
|
|
lead2.id = 2;
|
|
lead2.userId = 1001;
|
|
lead2.cardId = 101;
|
|
lead2.name = '潜在采购客户 B';
|
|
lead2.isPool = false;
|
|
lead2.status = 'new';
|
|
leads.push(lead2);
|
|
|
|
// Mock CardInfoEntity Repository
|
|
const mockCardRepo = {
|
|
findOneBy: jest.fn(async (criteria: { id?: number; userId?: number }) => {
|
|
return cards.find(c => {
|
|
if (criteria.id !== undefined && c.id !== criteria.id) return false;
|
|
if (criteria.userId !== undefined && c.userId !== criteria.userId) return false;
|
|
return true;
|
|
}) || null;
|
|
}),
|
|
find: jest.fn(async (options?: { where?: Partial<CardInfoEntity> }) => {
|
|
if (!options?.where) return [...cards];
|
|
return cards.filter(c => {
|
|
const itemMap = c as unknown as Record<string, unknown>;
|
|
for (const [key, val] of Object.entries(options.where!)) {
|
|
if (itemMap[key] !== val) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}),
|
|
save: jest.fn(async (entity: CardInfoEntity) => {
|
|
const idx = cards.findIndex(c => c.id === entity.id);
|
|
if (idx >= 0) {
|
|
cards[idx] = Object.assign(cards[idx], entity);
|
|
return cards[idx];
|
|
}
|
|
cards.push(entity);
|
|
return entity;
|
|
}),
|
|
};
|
|
|
|
// Mock CrmLeadEntity Repository
|
|
const mockLeadRepo = {
|
|
find: jest.fn(async (options?: { where?: Partial<CrmLeadEntity> }) => {
|
|
if (!options?.where) return [...leads];
|
|
return leads.filter(l => {
|
|
const itemMap = l as unknown as Record<string, unknown>;
|
|
for (const [key, val] of Object.entries(options.where!)) {
|
|
if (itemMap[key] !== val) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}),
|
|
save: jest.fn(async (entities: CrmLeadEntity | CrmLeadEntity[]) => {
|
|
const list = Array.isArray(entities) ? entities : [entities];
|
|
for (const item of list) {
|
|
const idx = leads.findIndex(l => l.id === item.id);
|
|
if (idx >= 0) {
|
|
leads[idx] = Object.assign(leads[idx], item);
|
|
} else {
|
|
leads.push(item);
|
|
}
|
|
}
|
|
return Array.isArray(entities) ? list : list[0];
|
|
}),
|
|
};
|
|
|
|
// Mock CrmLeadAuditEntity Repository
|
|
const mockAuditRepo = {
|
|
save: jest.fn(async (entities: CrmLeadAuditEntity | CrmLeadAuditEntity[]) => {
|
|
const list = Array.isArray(entities) ? entities : [entities];
|
|
const savedList = list.map(item =>
|
|
Object.assign(new CrmLeadAuditEntity(), item, {
|
|
id: nextAuditId++,
|
|
createTime: new Date(),
|
|
})
|
|
);
|
|
audits.push(...savedList);
|
|
return Array.isArray(entities) ? savedList : savedList[0];
|
|
}),
|
|
};
|
|
|
|
// Mock TeamHandoverLogEntity Repository
|
|
const mockLogRepo = {
|
|
save: jest.fn(async (entity: TeamHandoverLogEntity) => {
|
|
const saved = Object.assign(new TeamHandoverLogEntity(), entity, {
|
|
id: nextLogId++,
|
|
createTime: new Date(),
|
|
});
|
|
handoverLogs.push(saved);
|
|
return saved;
|
|
}),
|
|
find: jest.fn(async () => {
|
|
return [...handoverLogs].reverse();
|
|
}),
|
|
findAndCount: jest.fn(async () => {
|
|
return [[...handoverLogs].reverse(), handoverLogs.length];
|
|
}),
|
|
};
|
|
|
|
service = new TeamHandoverService();
|
|
service.cardInfoEntity = mockCardRepo as unknown as TeamHandoverService['cardInfoEntity'];
|
|
service.crmLeadEntity = mockLeadRepo as unknown as TeamHandoverService['crmLeadEntity'];
|
|
service.crmLeadAuditEntity = mockAuditRepo as unknown as TeamHandoverService['crmLeadAuditEntity'];
|
|
service.teamHandoverLogEntity = mockLogRepo as unknown as TeamHandoverService['teamHandoverLogEntity'];
|
|
});
|
|
|
|
// ----------------------------------------------------
|
|
// 2.1 参数校验测试
|
|
// ----------------------------------------------------
|
|
describe('2.1 参数合法性校验', () => {
|
|
it('缺少离职人 fromUserId 时应抛出异常', async () => {
|
|
await expect(
|
|
service.executeHandover({
|
|
fromUserId: 0,
|
|
toUserId: 2002,
|
|
})
|
|
).rejects.toThrow(CoolCommException);
|
|
});
|
|
|
|
it('接任人与离职人相同时应抛出异常', async () => {
|
|
await expect(
|
|
service.executeHandover({
|
|
fromUserId: 1001,
|
|
toUserId: 1001,
|
|
leadAction: 'transfer',
|
|
})
|
|
).rejects.toThrow('接替人不能与原负责人相同');
|
|
});
|
|
|
|
it('指定 cardId 且该名片不属于离职人时应抛出安全异常', async () => {
|
|
await expect(
|
|
service.executeHandover({
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
cardId: 202, // 202 属于 2002
|
|
})
|
|
).rejects.toThrow('名片不属于原负责人');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------
|
|
// 2.2 短链重定向测试 (接替人名片 vs 企业公共微官网)
|
|
// ----------------------------------------------------
|
|
describe('2.2 名片短链安全重定向机制', () => {
|
|
it('支持定向至接替人新名片 (redirectType=card)', async () => {
|
|
const clearCacheSpy = jest.spyOn(CardPortalService, 'clearAllCache');
|
|
|
|
const log = await service.executeHandover({
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
cardId: 101,
|
|
redirectType: 'card',
|
|
redirectTarget: '202', // 重定向到接替人名片 ID
|
|
leadAction: 'none',
|
|
operatorId: 888,
|
|
remark: '离职交接名片定向至接替人',
|
|
});
|
|
|
|
expect(log.status).toBe('completed');
|
|
expect(log.redirectType).toBe('card');
|
|
expect(log.redirectTarget).toBe('202');
|
|
|
|
const card = cards.find(c => c.id === 101);
|
|
expect(card?.redirectType).toBe('card');
|
|
expect(card?.redirectTarget).toBe('202');
|
|
expect(card?.internalConfig).toContain('"redirectType":"card"');
|
|
expect(clearCacheSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('支持定向至企业公共微官网 (redirectType=portal)', async () => {
|
|
const log = await service.executeHandover({
|
|
fromUserId: 1001,
|
|
cardId: 101,
|
|
redirectType: 'portal',
|
|
redirectTarget: 'https://company.omnicard.ai/portal',
|
|
leadAction: 'none',
|
|
operatorId: 888,
|
|
remark: '定向至企业统一微官网',
|
|
});
|
|
|
|
expect(log.redirectType).toBe('portal');
|
|
expect(log.redirectTarget).toBe('https://company.omnicard.ai/portal');
|
|
|
|
const card = cards.find(c => c.id === 101);
|
|
expect(card?.redirectType).toBe('portal');
|
|
expect(card?.redirectTarget).toBe('https://company.omnicard.ai/portal');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------
|
|
// 2.3 历史 CRM 客户线索流转 (批量转移 vs 划入公海池)
|
|
// ----------------------------------------------------
|
|
describe('2.3 历史客户线索批量原子迁移与公海划分', () => {
|
|
it('批量转移至接任人名下 (leadAction=transfer)', async () => {
|
|
const log = await service.executeHandover({
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
leadAction: 'transfer',
|
|
operatorId: 888,
|
|
remark: '线索全量移交给接替销售',
|
|
});
|
|
|
|
expect(log.migratedLeadCount).toBe(2);
|
|
|
|
// 验证线索归属已全部转移给 2002
|
|
const updatedLeads = leads.filter(l => l.userId === 2002);
|
|
expect(updatedLeads.length).toBe(2);
|
|
expect(updatedLeads.every(l => l.isPool === false)).toBe(true);
|
|
|
|
// 验证审计日志记录
|
|
expect(audits.length).toBe(2);
|
|
expect(audits.every(a => a.action === 'assign')).toBe(true);
|
|
expect(audits[0].fromUserId).toBe(1001);
|
|
expect(audits[0].toUserId).toBe(2002);
|
|
expect(audits[0].operatorId).toBe(888);
|
|
});
|
|
|
|
it('历史线索直接批量划入公海池 (leadAction=pool)', async () => {
|
|
const log = await service.executeHandover({
|
|
fromUserId: 1001,
|
|
leadAction: 'pool',
|
|
operatorId: 888,
|
|
remark: '离职人员线索重回公海池重新流转',
|
|
});
|
|
|
|
expect(log.migratedLeadCount).toBe(2);
|
|
|
|
// 验证线索已重置为公海状态
|
|
const poolLeads = leads.filter(l => l.isPool === true);
|
|
expect(poolLeads.length).toBe(2);
|
|
expect(poolLeads.every(l => l.userId === null)).toBe(true);
|
|
|
|
// 验证审计日志
|
|
expect(audits.length).toBe(2);
|
|
expect(audits.every(a => a.action === 'recycle')).toBe(true);
|
|
expect(audits[0].fromUserId).toBe(1001);
|
|
expect(audits[0].toUserId).toBeNull();
|
|
});
|
|
|
|
it('无待迁移线索时,迁移计数为 0', async () => {
|
|
// 离职人员 3003 无线索
|
|
const log = await service.executeHandover({
|
|
fromUserId: 3003,
|
|
toUserId: 2002,
|
|
leadAction: 'transfer',
|
|
});
|
|
expect(log.migratedLeadCount).toBe(0);
|
|
expect(audits.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------
|
|
// 2.4 不可逆安全交接工单与审计存证
|
|
// ----------------------------------------------------
|
|
describe('2.4 安全工单流水与审计留痕 (team_handover_log)', () => {
|
|
it('应自动生成唯一不可逆工单流水号与交接快照', async () => {
|
|
const log = await service.executeHandover({
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
cardId: 101,
|
|
redirectType: 'card',
|
|
redirectTarget: '202',
|
|
leadAction: 'transfer',
|
|
operatorId: 999,
|
|
remark: '正式交接归档留痕',
|
|
});
|
|
|
|
expect(log.handoverNo).toMatch(/^HO\d+/);
|
|
expect(log.snapshot).toBeDefined();
|
|
const parsedSnapshot = JSON.parse(log.snapshot!);
|
|
expect(parsedSnapshot.fromUserId).toBe(1001);
|
|
expect(parsedSnapshot.leadIds).toEqual([1, 2]);
|
|
|
|
// 工单可被查询获取
|
|
const logs = await service.getHandoverLogs();
|
|
expect(logs.length).toBe(1);
|
|
expect(logs[0].handoverNo).toBe(log.handoverNo);
|
|
});
|
|
|
|
it('兼容保留原有 transfer(req) 方法并同步写入工单历史', async () => {
|
|
const updatedCard = await service.transfer({
|
|
cardId: 101,
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
});
|
|
|
|
expect(updatedCard.userId).toBe(2002);
|
|
expect(handoverLogs.length).toBe(1);
|
|
expect(handoverLogs[0].fromUserId).toBe(1001);
|
|
expect(handoverLogs[0].toUserId).toBe(2002);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// 3. 管理后台控制器接口测试 (AdminTeamHandoverController)
|
|
// ==========================================
|
|
describe('3. 控制器接口调用测试 (AdminTeamHandoverController)', () => {
|
|
let controller: AdminTeamHandoverController;
|
|
let service: TeamHandoverService;
|
|
|
|
beforeEach(() => {
|
|
service = new TeamHandoverService();
|
|
service.executeHandover = jest.fn(async (dto: ExecuteHandoverDTO) => {
|
|
const log = new TeamHandoverLogEntity();
|
|
log.id = 1;
|
|
log.handoverNo = 'HO202609201001';
|
|
log.fromUserId = Number(dto.fromUserId);
|
|
log.toUserId = dto.toUserId ? Number(dto.toUserId) : null;
|
|
log.status = 'completed';
|
|
log.migratedLeadCount = 3;
|
|
return log;
|
|
});
|
|
|
|
service.getHandoverLogs = jest.fn(async () => {
|
|
const log = new TeamHandoverLogEntity();
|
|
log.id = 1;
|
|
log.handoverNo = 'HO202609201001';
|
|
return [log];
|
|
});
|
|
|
|
controller = new AdminTeamHandoverController();
|
|
controller.teamHandoverService = service;
|
|
controller.ok = (data: unknown) => ({
|
|
code: 1000,
|
|
message: 'success',
|
|
data,
|
|
});
|
|
(controller as unknown as { ctx: { admin: { userId: number } } }).ctx = {
|
|
admin: { userId: 99 },
|
|
};
|
|
});
|
|
|
|
it('POST /execute 应成功执行离职交接工单流转并注入操作人 ID', async () => {
|
|
const res = await controller.execute({
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
cardId: 101,
|
|
redirectType: 'card',
|
|
redirectTarget: '202',
|
|
leadAction: 'transfer',
|
|
});
|
|
|
|
expect(res.code).toBe(1000);
|
|
expect(service.executeHandover).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
fromUserId: 1001,
|
|
toUserId: 2002,
|
|
operatorId: 99,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('POST /logs 应能查询交接历史工单列表', async () => {
|
|
const res = await controller.logs({});
|
|
expect(res.code).toBe(1000);
|
|
expect(service.getHandoverLogs).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|