feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,426 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { BaseEntity } from '../../../src/comm/entity/base';
|
||||
import { TeamInvoiceEntity } from '../../../src/modules/team/entity/invoice';
|
||||
import { TeamInvoiceService } from '../../../src/modules/team/service/invoice';
|
||||
import { AdminTeamBillingController } from '../../../src/modules/team/controller/admin/billing';
|
||||
import { CreateInvoiceDTO, SupportedCurrency } from '../../../src/comm/types/omni';
|
||||
|
||||
describe('Point 24: 国际多币种发票生成与历史收据导出 单元测试', () => {
|
||||
const metadata = getMetadataArgsStorage();
|
||||
|
||||
// ==========================================
|
||||
// 1. 实体定义与数据库字段映射测试
|
||||
// ==========================================
|
||||
describe('1. 实体定义与数据库字段映射测试', () => {
|
||||
it('TeamInvoiceEntity 表名映射应为 team_invoice 且继承 BaseEntity', () => {
|
||||
const table = metadata.tables.find(t => t.target === TeamInvoiceEntity);
|
||||
expect(table).toBeDefined();
|
||||
expect(table?.name).toBe('team_invoice');
|
||||
expect(TeamInvoiceEntity.prototype instanceof BaseEntity).toBe(true);
|
||||
});
|
||||
|
||||
it('TeamInvoiceEntity 应映射各核心字段至下划线数据列', () => {
|
||||
const columns = metadata.columns.filter(c => c.target === TeamInvoiceEntity);
|
||||
const expectedColumns: Record<string, string> = {
|
||||
invoiceNo: 'invoice_no',
|
||||
orderId: 'order_id',
|
||||
currency: 'currency',
|
||||
currencySymbol: 'currency_symbol',
|
||||
subtotal: 'subtotal',
|
||||
taxRate: 'tax_rate',
|
||||
taxAmount: 'tax_amount',
|
||||
totalAmount: 'total_amount',
|
||||
title: 'title',
|
||||
items: 'items',
|
||||
billingInfo: 'billing_info',
|
||||
status: 'status',
|
||||
paidAt: 'paid_at',
|
||||
htmlTemplate: 'html_template',
|
||||
textTemplate: 'text_template',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 2. 发票流水号算法规范测试
|
||||
// ==========================================
|
||||
describe('2. 发票流水号生成算法测试', () => {
|
||||
let service: TeamInvoiceService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TeamInvoiceService();
|
||||
});
|
||||
|
||||
it('流水号应严格遵循 INV-YYYYMMDD-XXXX 格式规范', () => {
|
||||
const invoiceNo = service.generateInvoiceNumber(new Date('2026-09-20'), 1);
|
||||
expect(invoiceNo).toMatch(/^INV-\d{8}-[A-Z0-9]{4}$/);
|
||||
expect(invoiceNo).toBe('INV-20260920-0001');
|
||||
});
|
||||
|
||||
it('当日流水号自增序列溢出或大数时应正确填充与生成', () => {
|
||||
const invoiceNo42 = service.generateInvoiceNumber(new Date('2026-09-20'), 42);
|
||||
expect(invoiceNo42).toBe('INV-20260920-0042');
|
||||
|
||||
const invoiceNo9999 = service.generateInvoiceNumber(new Date('2026-09-20'), 9999);
|
||||
expect(invoiceNo9999).toBe('INV-20260920-9999');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 3. 税费折算与金融级精确计算测试
|
||||
// ==========================================
|
||||
describe('3. 税费与净额精确折算测试', () => {
|
||||
let service: TeamInvoiceService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TeamInvoiceService();
|
||||
});
|
||||
|
||||
it('不含税模式下应根据小计和税率精确计算税额与含税总额', () => {
|
||||
const calc = service.calculateTaxAndTotals({
|
||||
subtotal: 100,
|
||||
taxRate: 0.1, // 10%
|
||||
isTaxInclusive: false,
|
||||
});
|
||||
|
||||
expect(calc.subtotal).toBe(100.0);
|
||||
expect(calc.taxAmount).toBe(10.0);
|
||||
expect(calc.totalAmount).toBe(110.0);
|
||||
});
|
||||
|
||||
it('含税总额模式下应根据总额和税率精确逆向拆解净额与税额', () => {
|
||||
const calc = service.calculateTaxAndTotals({
|
||||
totalAmount: 110,
|
||||
taxRate: 0.1,
|
||||
isTaxInclusive: true,
|
||||
});
|
||||
|
||||
expect(calc.subtotal).toBe(100.0);
|
||||
expect(calc.taxAmount).toBe(10.0);
|
||||
expect(calc.totalAmount).toBe(110.0);
|
||||
});
|
||||
|
||||
it('复杂小数与税率计算时必须四舍五入防浮点数精度误差', () => {
|
||||
const calc = service.calculateTaxAndTotals({
|
||||
subtotal: 99.99,
|
||||
taxRate: 0.0825, // 8.25%
|
||||
isTaxInclusive: false,
|
||||
});
|
||||
|
||||
// 99.99 * 0.0825 = 8.249175 -> 四舍五入为 8.25
|
||||
expect(calc.taxAmount).toBe(8.25);
|
||||
expect(calc.totalAmount).toBe(108.24);
|
||||
});
|
||||
|
||||
it('零税率或免税交易应正确处理', () => {
|
||||
const calc = service.calculateTaxAndTotals({
|
||||
subtotal: 50,
|
||||
taxRate: 0,
|
||||
});
|
||||
|
||||
expect(calc.subtotal).toBe(50.0);
|
||||
expect(calc.taxAmount).toBe(0.0);
|
||||
expect(calc.totalAmount).toBe(50.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. 多币种支持与符号格式化测试
|
||||
// ==========================================
|
||||
describe('4. 国际主流币种(USD, EUR, GBP, CNY)与符号匹配测试', () => {
|
||||
let service: TeamInvoiceService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TeamInvoiceService();
|
||||
});
|
||||
|
||||
const testCurrencies: Array<{
|
||||
code: SupportedCurrency;
|
||||
expectedSymbol: string;
|
||||
formattedSample: string;
|
||||
}> = [
|
||||
{ code: 'USD', expectedSymbol: '$', formattedSample: '$100.00' },
|
||||
{ code: 'EUR', expectedSymbol: '€', formattedSample: '€100.00' },
|
||||
{ code: 'GBP', expectedSymbol: '£', formattedSample: '£100.00' },
|
||||
{ code: 'CNY', expectedSymbol: '¥', formattedSample: '¥100.00' },
|
||||
];
|
||||
|
||||
testCurrencies.forEach(({ code, expectedSymbol, formattedSample }) => {
|
||||
it(`币种 ${code} 应匹配符号 ${expectedSymbol} 并正确格式化`, () => {
|
||||
const symbol = service.getCurrencySymbol(code);
|
||||
expect(symbol).toBe(expectedSymbol);
|
||||
|
||||
const formatted = service.formatCurrency(100, code);
|
||||
expect(formatted).toBe(formattedSample);
|
||||
});
|
||||
});
|
||||
|
||||
it('应容错小写币种输入并归一化', () => {
|
||||
const symbol = service.getCurrencySymbol('usd' as SupportedCurrency);
|
||||
expect(symbol).toBe('$');
|
||||
});
|
||||
|
||||
it('未知币种应安全回退默认美元 USD ($)', () => {
|
||||
const symbol = service.getCurrencySymbol('UNKNOWN' as SupportedCurrency);
|
||||
expect(symbol).toBe('$');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 5. 打印模板输出测试 (瑞士国际主义风格)
|
||||
// ==========================================
|
||||
describe('5. 打印模板生成测试 (瑞士国际主义风格)', () => {
|
||||
let service: TeamInvoiceService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TeamInvoiceService();
|
||||
});
|
||||
|
||||
it('生成的 HTML 模板应包含发票核心字段且遵循克制排版无 emoji', () => {
|
||||
const html = service.renderHtmlTemplate({
|
||||
invoiceNo: 'INV-20260920-0001',
|
||||
issueDate: '2026-09-20',
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
subtotal: 100.0,
|
||||
taxRate: 0.1,
|
||||
taxAmount: 10.0,
|
||||
totalAmount: 110.0,
|
||||
billingInfo: {
|
||||
companyName: 'Acme Global Ltd',
|
||||
taxId: 'US-99887766',
|
||||
address: '100 Market St, San Francisco, CA',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'OmniCard Enterprise Plan (Annual)',
|
||||
quantity: 1,
|
||||
unitPrice: 100.0,
|
||||
amount: 100.0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(html).toContain('INV-20260920-0001');
|
||||
expect(html).toContain('Acme Global Ltd');
|
||||
expect(html).toContain('$100.00');
|
||||
expect(html).toContain('$10.00');
|
||||
expect(html).toContain('$110.00');
|
||||
expect(html).toContain('OmniCard Enterprise Plan (Annual)');
|
||||
// 瑞士国际主义风格验证:无 emoji,包含 Inter 或 Helvetica 字体定义
|
||||
expect(html).toMatch(/Inter|Helvetica/i);
|
||||
expect(html).not.toMatch(/[\uD800-\uDBFF][\uDC00-\uDFFF]/); // 禁止 emoji
|
||||
});
|
||||
|
||||
it('生成的纯文本模板应结构化对齐', () => {
|
||||
const text = service.renderTextTemplate({
|
||||
invoiceNo: 'INV-20260920-0001',
|
||||
issueDate: '2026-09-20',
|
||||
currency: 'EUR',
|
||||
currencySymbol: '€',
|
||||
subtotal: 80.0,
|
||||
taxRate: 0.2,
|
||||
taxAmount: 16.0,
|
||||
totalAmount: 96.0,
|
||||
billingInfo: {
|
||||
companyName: 'Berlin Tech GmbH',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Pro Subscription',
|
||||
quantity: 1,
|
||||
unitPrice: 80.0,
|
||||
amount: 80.0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text).toContain('INVOICE / RECEIPT');
|
||||
expect(text).toContain('INV-20260920-0001');
|
||||
expect(text).toContain('Berlin Tech GmbH');
|
||||
expect(text).toContain('€80.00');
|
||||
expect(text).toContain('€96.00');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 6. 服务层发票生成与历史查询 (TeamInvoiceService)
|
||||
// ==========================================
|
||||
describe('6. 业务服务层发票生成与查询测试', () => {
|
||||
let service: TeamInvoiceService;
|
||||
let invoices: TeamInvoiceEntity[];
|
||||
let nextId = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
invoices = [];
|
||||
nextId = 1;
|
||||
|
||||
const mockRepo = {
|
||||
count: jest.fn(async () => invoices.length),
|
||||
save: jest.fn(async (entity: TeamInvoiceEntity) => {
|
||||
const saved = Object.assign(new TeamInvoiceEntity(), entity, {
|
||||
id: nextId++,
|
||||
createTime: new Date(),
|
||||
updateTime: new Date(),
|
||||
});
|
||||
invoices.push(saved);
|
||||
return saved;
|
||||
}),
|
||||
find: jest.fn(async (options?: { where?: Partial<TeamInvoiceEntity> }) => {
|
||||
if (!options?.where) return [...invoices];
|
||||
return invoices.filter(item => {
|
||||
const itemMap = item as unknown as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(options.where!)) {
|
||||
if (itemMap[key] !== val) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
service = new TeamInvoiceService();
|
||||
service.teamInvoiceEntity = mockRepo as unknown as TeamInvoiceService['teamInvoiceEntity'];
|
||||
});
|
||||
|
||||
it('generateInvoice 应校验租户 ID 并成功创建发票', async () => {
|
||||
await expect(
|
||||
service.generateInvoice(0, {
|
||||
title: '企业版订阅',
|
||||
subtotal: 100,
|
||||
} as CreateInvoiceDTO)
|
||||
).rejects.toThrow(CoolCommException);
|
||||
|
||||
const orderData: CreateInvoiceDTO = {
|
||||
orderId: 'ORD-2026-001',
|
||||
currency: 'USD',
|
||||
title: 'OmniCard Enterprise Plan',
|
||||
subtotal: 200,
|
||||
taxRate: 0.1,
|
||||
billingInfo: {
|
||||
companyName: 'Global Corp',
|
||||
taxId: 'TAX-12345',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
description: 'Team Seats x 10',
|
||||
quantity: 1,
|
||||
unitPrice: 200,
|
||||
amount: 200,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.generateInvoice(101, orderData);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.tenantId).toBe(101);
|
||||
expect(result.invoiceNo).toMatch(/^INV-\d{8}-\d{4}$/);
|
||||
expect(result.currency).toBe('USD');
|
||||
expect(result.currencySymbol).toBe('$');
|
||||
expect(result.subtotal).toBe(200.0);
|
||||
expect(result.taxAmount).toBe(20.0);
|
||||
expect(result.totalAmount).toBe(220.0);
|
||||
expect(result.htmlTemplate).toBeDefined();
|
||||
expect(result.textTemplate).toBeDefined();
|
||||
});
|
||||
|
||||
it('listInvoices 应严格按租户隔离并返回历史发票列表', async () => {
|
||||
// 租户 101 的两张发票
|
||||
await service.generateInvoice(101, {
|
||||
subtotal: 100,
|
||||
currency: 'USD',
|
||||
title: 'Invoice 1',
|
||||
});
|
||||
await service.generateInvoice(101, {
|
||||
subtotal: 200,
|
||||
currency: 'EUR',
|
||||
title: 'Invoice 2',
|
||||
});
|
||||
|
||||
// 租户 202 的一张发票
|
||||
await service.generateInvoice(202, {
|
||||
subtotal: 300,
|
||||
currency: 'GBP',
|
||||
title: 'Invoice 3',
|
||||
});
|
||||
|
||||
const list101 = await service.listInvoices(101);
|
||||
expect(list101.length).toBe(2);
|
||||
expect(list101.every(inv => inv.tenantId === 101)).toBe(true);
|
||||
|
||||
const list202 = await service.listInvoices(202);
|
||||
expect(list202.length).toBe(1);
|
||||
expect(list202[0].tenantId).toBe(202);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 7. 控制器接口调用测试 (AdminTeamBillingController)
|
||||
// ==========================================
|
||||
describe('7. 控制器发票接口测试 (AdminTeamBillingController)', () => {
|
||||
let controller: AdminTeamBillingController;
|
||||
let invoiceService: TeamInvoiceService;
|
||||
|
||||
beforeEach(() => {
|
||||
invoiceService = new TeamInvoiceService();
|
||||
invoiceService.generateInvoice = jest.fn(async (tenantId: number, dto: CreateInvoiceDTO) => {
|
||||
const inv = new TeamInvoiceEntity();
|
||||
inv.id = 1;
|
||||
inv.tenantId = tenantId;
|
||||
inv.invoiceNo = 'INV-20260920-0001';
|
||||
inv.totalAmount = 100;
|
||||
inv.currency = (dto.currency as SupportedCurrency) || 'USD';
|
||||
inv.currencySymbol = '$';
|
||||
return inv;
|
||||
});
|
||||
|
||||
invoiceService.listInvoices = jest.fn(async (tenantId: number) => {
|
||||
const inv = new TeamInvoiceEntity();
|
||||
inv.id = 1;
|
||||
inv.tenantId = tenantId;
|
||||
inv.invoiceNo = 'INV-20260920-0001';
|
||||
return [inv];
|
||||
});
|
||||
|
||||
controller = new AdminTeamBillingController();
|
||||
controller.teamInvoiceService = invoiceService;
|
||||
controller.ok = (data: unknown) => ({
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data,
|
||||
});
|
||||
(controller as unknown as { ctx: { admin: { tenantId: number } } }).ctx = {
|
||||
admin: { tenantId: 888 },
|
||||
};
|
||||
});
|
||||
|
||||
it('POST /invoice/generate 应成功生成发票并关联当前租户', async () => {
|
||||
const res = await controller.generateInvoice({
|
||||
title: 'Enterprise Plan',
|
||||
subtotal: 100,
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
expect(res.code).toBe(1000);
|
||||
expect(invoiceService.generateInvoice).toHaveBeenCalledWith(
|
||||
888,
|
||||
expect.objectContaining({
|
||||
title: 'Enterprise Plan',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('POST /invoice/list 应返回当前租户的发票历史收据列表', async () => {
|
||||
const res = await controller.listInvoices({});
|
||||
expect(res.code).toBe(1000);
|
||||
expect(invoiceService.listInvoices).toHaveBeenCalledWith(888);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户