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 = { 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 }) => { if (!options?.where) return [...cards]; return cards.filter(c => { const itemMap = c as unknown as Record; 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 }) => { if (!options?.where) return [...leads]; return leads.filter(l => { const itemMap = l as unknown as Record; 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(); }); }); });