补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
253 行
10 KiB
TypeScript
253 行
10 KiB
TypeScript
import { TeamFinanceAnalyticsService } from '../../../src/modules/team/service/finance-analytics';
|
|
import { AdminTeamBillingController } from '../../../src/modules/team/controller/admin/billing';
|
|
import { TeamInvoiceEntity } from '../../../src/modules/team/entity/invoice';
|
|
import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription';
|
|
|
|
describe('【财务与运营新账统计】TeamFinanceAnalyticsService 单元测试 (TDD Red -> Green)', () => {
|
|
let service: TeamFinanceAnalyticsService;
|
|
|
|
beforeEach(() => {
|
|
service = new TeamFinanceAnalyticsService();
|
|
});
|
|
|
|
it('1. 必须提供 getOverviewAnalytics 接口并返回符合契约的数据结构', async () => {
|
|
const result = await service.getOverviewAnalytics({ period: 'month' });
|
|
expect(result).toBeDefined();
|
|
expect(result.period).toBe('month');
|
|
expect(result.summary).toBeDefined();
|
|
|
|
// CFO 指标
|
|
expect(typeof result.summary.totalInvoiced).toBe('number');
|
|
expect(typeof result.summary.totalCollected).toBe('number');
|
|
expect(typeof result.summary.pendingReconciliation).toBe('number');
|
|
expect(typeof result.summary.overdueAmount).toBe('number');
|
|
expect(typeof result.summary.refundAmount).toBe('number');
|
|
expect(typeof result.summary.estimatedTax).toBe('number');
|
|
|
|
// COO 指标
|
|
expect(typeof result.summary.mrr).toBe('number');
|
|
expect(typeof result.summary.arr).toBe('number');
|
|
expect(typeof result.summary.newAccountsCount).toBe('number');
|
|
expect(typeof result.summary.payingAccountsCount).toBe('number');
|
|
expect(typeof result.summary.expansionRate).toBe('number');
|
|
expect(typeof result.summary.churnRate).toBe('number');
|
|
|
|
// 多币种归集
|
|
expect(Array.isArray(result.currencyBreakdown)).toBe(true);
|
|
expect(result.currencyBreakdown.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// 账龄与坏账
|
|
expect(Array.isArray(result.agingAnalysis)).toBe(true);
|
|
expect(result.agingAnalysis.some((item) => item.range.includes('0-30'))).toBe(true);
|
|
|
|
// 套餐分布
|
|
expect(Array.isArray(result.planDistribution)).toBe(true);
|
|
expect(result.planDistribution.some((item) => item.plan === 'pro')).toBe(true);
|
|
|
|
// 国际区域分布
|
|
expect(Array.isArray(result.regionalRevenue)).toBe(true);
|
|
|
|
// 流水明细
|
|
expect(Array.isArray(result.recentLedger)).toBe(true);
|
|
});
|
|
|
|
it('2. 能够按指定周期 (today / week / month / quarter / year / all) 准确过滤统计', async () => {
|
|
const todayRes = await service.getOverviewAnalytics({ period: 'today' });
|
|
expect(todayRes.period).toBe('today');
|
|
|
|
const yearRes = await service.getOverviewAnalytics({ period: 'year' });
|
|
expect(yearRes.period).toBe('year');
|
|
expect(yearRes.summary.totalInvoiced).toBeGreaterThanOrEqual(todayRes.summary.totalInvoiced);
|
|
});
|
|
|
|
it('3. 能够按多币种 (USD / EUR / GBP / CNY) 进行精准汇率或单币种透视过滤', async () => {
|
|
const usdRes = await service.getOverviewAnalytics({ currency: 'USD' });
|
|
expect(usdRes).toBeDefined();
|
|
const cnyRes = await service.getOverviewAnalytics({ currency: 'CNY' });
|
|
expect(cnyRes).toBeDefined();
|
|
});
|
|
|
|
it('4. 账龄分析必须具备合理的风险分层 (低风险、中风险、高风险)', async () => {
|
|
const result = await service.getOverviewAnalytics({ period: 'all' });
|
|
const ranges = result.agingAnalysis.map((r) => r.riskLevel);
|
|
expect(ranges).toContain('low');
|
|
});
|
|
|
|
it('5. 后台 Billing 控制器必须挂载 /analytics 路由并返回标准 ok 响应', async () => {
|
|
const controller = new AdminTeamBillingController();
|
|
controller.teamFinanceAnalyticsService = service;
|
|
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
|
|
|
const response = await (controller as any).postAnalytics({ period: 'month' });
|
|
expect(response.code).toBe(1000);
|
|
expect(response.data.summary.mrr).toBeDefined();
|
|
});
|
|
|
|
it('6. 无发票/无订阅可归属时 regionalRevenue 与 recentLedger 必须为空数组,不得填演示数据', async () => {
|
|
service.teamInvoiceEntity = {
|
|
find: async () => [],
|
|
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
|
service.teamSubscriptionEntity = {
|
|
find: async () => [],
|
|
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
|
|
|
const result = await service.getOverviewAnalytics({ period: 'all' });
|
|
|
|
expect(result.regionalRevenue).toEqual([]);
|
|
expect(result.recentLedger).toEqual([]);
|
|
|
|
const ledgerDump = JSON.stringify(result.recentLedger);
|
|
expect(ledgerDump).not.toMatch(/Vanguard|Dubai Apex|Berlin Precision|Silicon Horizon|Nordic Clean/i);
|
|
|
|
const regionPercents = result.regionalRevenue.map((r) => r.percentage);
|
|
expect(regionPercents).not.toEqual([46, 28, 14, 12]);
|
|
});
|
|
|
|
it('7. 区域占比必须按已有发票币种归属真实汇总,百分比来自实际收款占比而非固定比例', async () => {
|
|
const invoices: Partial<TeamInvoiceEntity>[] = [
|
|
{
|
|
id: 11,
|
|
invoiceNo: 'INV-20260921-0001',
|
|
currency: 'USD',
|
|
totalAmount: 1000,
|
|
taxAmount: 0,
|
|
status: 'paid',
|
|
title: 'Pro Plan',
|
|
billingInfo: { companyName: 'Acme North LLC' },
|
|
tenantId: 101,
|
|
createTime: new Date('2026-09-10T10:00:00Z') as unknown as Date,
|
|
},
|
|
{
|
|
id: 12,
|
|
invoiceNo: 'INV-20260921-0002',
|
|
currency: 'EUR',
|
|
totalAmount: 500,
|
|
taxAmount: 0,
|
|
status: 'paid',
|
|
title: 'Enterprise Plan',
|
|
billingInfo: { companyName: 'Rhine Tools GmbH' },
|
|
tenantId: 102,
|
|
createTime: new Date('2026-09-11T10:00:00Z') as unknown as Date,
|
|
},
|
|
{
|
|
id: 13,
|
|
invoiceNo: 'INV-20260921-0003',
|
|
currency: 'AED',
|
|
totalAmount: 500,
|
|
taxAmount: 0,
|
|
status: 'paid',
|
|
title: 'Pro Plan',
|
|
billingInfo: { companyName: 'Gulf Bridge Trading' },
|
|
tenantId: 103,
|
|
createTime: new Date('2026-09-12T10:00:00Z') as unknown as Date,
|
|
},
|
|
];
|
|
|
|
service.teamInvoiceEntity = {
|
|
find: async () => invoices as TeamInvoiceEntity[],
|
|
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
|
service.teamSubscriptionEntity = {
|
|
find: async () => [],
|
|
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
|
|
|
const result = await service.getOverviewAnalytics({ period: 'all' });
|
|
|
|
expect(result.regionalRevenue.length).toBeGreaterThanOrEqual(1);
|
|
const totalPct = result.regionalRevenue.reduce((s, r) => s + r.percentage, 0);
|
|
expect(totalPct).toBeCloseTo(100, 0);
|
|
|
|
const byRegion = Object.fromEntries(
|
|
result.regionalRevenue.map((r) => [r.region, r])
|
|
);
|
|
// USD 1000 / 2000 = 50%, EUR 500 = 25%, AED 500 = 25%
|
|
const usdRegion = Object.values(byRegion).find((r) => r.revenue === 1000);
|
|
const eurRegion = Object.values(byRegion).find((r) => r.revenue === 500 && r.percentage === 25);
|
|
expect(usdRegion?.percentage).toBe(50);
|
|
expect(eurRegion).toBeDefined();
|
|
|
|
const fixedDemo = result.regionalRevenue.map((r) => r.percentage);
|
|
expect(fixedDemo).not.toEqual([46, 28, 14, 12]);
|
|
});
|
|
|
|
it('8. recentLedger 只能映射已有发票字段,租户名取自 billingInfo,不得出现演示租户', async () => {
|
|
const invoices: Partial<TeamInvoiceEntity>[] = [
|
|
{
|
|
id: 21,
|
|
invoiceNo: 'INV-20260920-0100',
|
|
currency: 'USD',
|
|
totalAmount: 196,
|
|
taxAmount: 0,
|
|
status: 'paid',
|
|
title: 'Pro Seat Addon',
|
|
billingInfo: { companyName: 'Orbit Studio Co.' },
|
|
tenantId: 201,
|
|
createTime: new Date('2026-09-20T08:00:00Z') as unknown as Date,
|
|
},
|
|
{
|
|
id: 22,
|
|
invoiceNo: 'INV-20260919-0099',
|
|
currency: 'EUR',
|
|
totalAmount: 588,
|
|
taxAmount: 0,
|
|
status: 'issued',
|
|
title: 'Enterprise Renewal',
|
|
billingInfo: { companyName: 'Nordic Labs AB' },
|
|
tenantId: 202,
|
|
createTime: new Date('2026-09-19T08:00:00Z') as unknown as Date,
|
|
},
|
|
];
|
|
|
|
service.teamInvoiceEntity = {
|
|
find: async () => invoices as TeamInvoiceEntity[],
|
|
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
|
service.teamSubscriptionEntity = {
|
|
find: async () => [],
|
|
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
|
|
|
const result = await service.getOverviewAnalytics({ period: 'all' });
|
|
|
|
expect(result.recentLedger.length).toBe(2);
|
|
expect(result.recentLedger.map((l) => l.invoiceNo)).toEqual([
|
|
'INV-20260920-0100',
|
|
'INV-20260919-0099',
|
|
]);
|
|
expect(result.recentLedger[0].tenantName).toBe('Orbit Studio Co.');
|
|
expect(result.recentLedger[1].tenantName).toBe('Nordic Labs AB');
|
|
expect(result.recentLedger[0].amount).toBe(196);
|
|
expect(result.recentLedger[1].status).toBe('issued');
|
|
|
|
const names = result.recentLedger.map((l) => l.tenantName).join(' ');
|
|
expect(names).not.toMatch(/Vanguard|Dubai Apex|Berlin Precision|Silicon Horizon/i);
|
|
});
|
|
|
|
it('9. 仅有未付款发票时区域营收为空,流水仍可来自真实发票', async () => {
|
|
const invoices: Partial<TeamInvoiceEntity>[] = [
|
|
{
|
|
id: 31,
|
|
invoiceNo: 'INV-20260918-0001',
|
|
currency: 'USD',
|
|
totalAmount: 299,
|
|
taxAmount: 0,
|
|
status: 'issued',
|
|
title: 'Pro Plan',
|
|
billingInfo: { companyName: 'Pending Buyer Inc.' },
|
|
tenantId: 301,
|
|
createTime: new Date('2026-09-18T08:00:00Z') as unknown as Date,
|
|
},
|
|
];
|
|
|
|
service.teamInvoiceEntity = {
|
|
find: async () => invoices as TeamInvoiceEntity[],
|
|
} as unknown as TeamFinanceAnalyticsService['teamInvoiceEntity'];
|
|
service.teamSubscriptionEntity = {
|
|
find: async () => [],
|
|
} as unknown as TeamFinanceAnalyticsService['teamSubscriptionEntity'];
|
|
|
|
const result = await service.getOverviewAnalytics({ period: 'all' });
|
|
expect(result.regionalRevenue).toEqual([]);
|
|
expect(result.recentLedger).toHaveLength(1);
|
|
expect(result.recentLedger[0].tenantName).toBe('Pending Buyer Inc.');
|
|
expect(result.recentLedger[0].invoiceNo).toBe('INV-20260918-0001');
|
|
});
|
|
});
|