背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。 改动: - 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。 - 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。 - 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。 - 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。 - 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。 影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。 回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。 验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
|
|
import { setUnauthorizedHandler } from './client';
|
|
import {
|
|
applyCustomerField,
|
|
applyIndustryPack,
|
|
approveCustomerField,
|
|
createCustomer,
|
|
createCustomerGroup,
|
|
createIndustry,
|
|
deleteCustomerGroup,
|
|
deleteIndustry,
|
|
fetchCustomerDetail,
|
|
fetchCustomerFieldList,
|
|
fetchCustomerFields,
|
|
fetchCustomerGroupDetail,
|
|
fetchCustomerGroupList,
|
|
fetchCustomerList,
|
|
fetchIndustryDetail,
|
|
fetchIndustryList,
|
|
setCustomerGroups,
|
|
setCustomerStatus,
|
|
setIndustryStatus,
|
|
transferCustomer,
|
|
updateCustomer,
|
|
updateCustomerGroup,
|
|
updateCustomerGroupMembers,
|
|
updateIndustry,
|
|
} from './customers';
|
|
|
|
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
|
const ID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
|
const NOW = '2026-11-03T10:00:00.000Z';
|
|
|
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
|
new Response(status === 204 ? null : JSON.stringify(body), {
|
|
status,
|
|
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
const industryNode = {
|
|
id: ID,
|
|
parentId: null,
|
|
level: 1,
|
|
name: '电商',
|
|
sortOrder: 10,
|
|
status: 'active',
|
|
remark: '',
|
|
customerCount: 1,
|
|
assetCount: 0,
|
|
updatedAt: NOW,
|
|
};
|
|
const industryTree = { ...industryNode, children: [] };
|
|
const industryDetail = {
|
|
...industryNode,
|
|
parentName: null,
|
|
references: {
|
|
childCount: 0,
|
|
activeChildCount: 0,
|
|
customerCount: 1,
|
|
customers: [],
|
|
assetCount: 0,
|
|
},
|
|
};
|
|
|
|
const customerSummary = {
|
|
id: ID,
|
|
name: '花间集',
|
|
shortName: 'HJ',
|
|
industryId: ID,
|
|
industryName: '服饰鞋包',
|
|
industryPath: '电商 / 服饰鞋包',
|
|
status: 'active',
|
|
ownerUserId: ID2,
|
|
ownerName: '陈默',
|
|
groups: [],
|
|
baseFieldCount: 1,
|
|
genFieldCount: 1,
|
|
updatedAt: NOW,
|
|
};
|
|
const customerDetail = {
|
|
...customerSummary,
|
|
contactName: '林晓',
|
|
contactPhone: '138****2210',
|
|
preferences: { naming: '', directory: '', specs: [], taboo: [] },
|
|
baseFields: [{ key: '门店', value: '沪-001' }],
|
|
fields: [],
|
|
remark: null,
|
|
createdAt: NOW,
|
|
};
|
|
const groupSummary = {
|
|
id: ID,
|
|
name: '电商核心组',
|
|
description: '',
|
|
memberCount: 0,
|
|
updatedAt: NOW,
|
|
};
|
|
const groupDetail = { ...groupSummary, members: [] };
|
|
const field = {
|
|
id: ID,
|
|
customerId: ID2,
|
|
name: '活动编码',
|
|
code: 'campaign_code',
|
|
type: 'text',
|
|
options: [],
|
|
purpose: '检索',
|
|
entersGeneration: true,
|
|
status: 'pending',
|
|
applicantName: '陈默',
|
|
approverName: null,
|
|
appliedAt: NOW,
|
|
approvedAt: null,
|
|
rejectReason: null,
|
|
};
|
|
const fieldListItem = {
|
|
...field,
|
|
customerName: '花间集',
|
|
customerShortName: 'HJ',
|
|
industryPath: '电商 / 服饰鞋包',
|
|
};
|
|
|
|
function lastCall(): { url: string; init: RequestInit | undefined } {
|
|
const fetchMock = vi.mocked(globalThis.fetch);
|
|
const call = fetchMock.mock.calls.at(-1);
|
|
const input = call?.[0];
|
|
const url =
|
|
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
|
|
return { url, init: call?.[1] };
|
|
}
|
|
|
|
function lastBody(): unknown {
|
|
const body = lastCall().init?.body;
|
|
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
window.localStorage.clear();
|
|
useAuthStore.getState().clearSession();
|
|
setUnauthorizedHandler(null);
|
|
useAuthStore.getState().setSession({
|
|
accessToken: 'token',
|
|
user: { id: ID2, name: '陈默', account: 'chen.mo' },
|
|
permissions: ['customer:read'],
|
|
});
|
|
});
|
|
|
|
describe('客户与行业接口封装', () => {
|
|
it('行业:列表 / 详情 / 新建 / 更新 / 状态 / 删除', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(jsonResponse({ items: [industryTree], next_cursor: null }))
|
|
.mockResolvedValueOnce(jsonResponse(industryDetail))
|
|
.mockResolvedValueOnce(jsonResponse(industryDetail, 201))
|
|
.mockResolvedValueOnce(jsonResponse(industryDetail))
|
|
.mockResolvedValueOnce(jsonResponse(industryDetail))
|
|
.mockResolvedValueOnce(jsonResponse(undefined, 204));
|
|
|
|
await fetchIndustryList({ keyword: '电商', limit: 100 });
|
|
expect(lastCall().url).toContain('/industries?keyword=');
|
|
await fetchIndustryDetail(ID);
|
|
await createIndustry({ name: '电商', sortOrder: 10, remark: '' });
|
|
expect(lastBody()).toEqual({ name: '电商', sortOrder: 10, remark: '' });
|
|
await updateIndustry(ID, { name: '电商2' });
|
|
await setIndustryStatus(ID, { status: 'disabled' });
|
|
await deleteIndustry(ID);
|
|
expect(lastCall().url).toContain(`/industries/${ID}`);
|
|
});
|
|
|
|
it('客户:列表 / 详情 / 新建 / 更新 / 状态 / 转交 / 组 / 资产包', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(jsonResponse({ items: [customerSummary], next_cursor: null }))
|
|
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
|
.mockResolvedValueOnce(jsonResponse(customerDetail, 201))
|
|
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
|
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
|
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
|
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
|
.mockResolvedValueOnce(jsonResponse({}, 200));
|
|
|
|
await fetchCustomerList({ status: 'active', limit: 100 });
|
|
expect(lastCall().url).toContain('/customers?status=active');
|
|
await fetchCustomerDetail(ID);
|
|
await createCustomer({
|
|
industryId: ID,
|
|
name: '花间集',
|
|
shortName: 'HJ',
|
|
ownerUserId: ID2,
|
|
});
|
|
await updateCustomer(ID, { shortName: 'HJ2' });
|
|
await setCustomerStatus(ID, { status: 'paused' });
|
|
await transferCustomer(ID, { ownerUserId: ID2 });
|
|
await setCustomerGroups(ID, { groupIds: [ID] });
|
|
await applyIndustryPack(ID);
|
|
expect(lastCall().url).toContain(`/customers/${ID}/apply-industry-pack`);
|
|
});
|
|
|
|
it('客户组:列表 / 详情 / 新建 / 更新 / 成员 / 删除', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(jsonResponse({ items: [groupSummary], next_cursor: null }))
|
|
.mockResolvedValueOnce(jsonResponse(groupDetail))
|
|
.mockResolvedValueOnce(jsonResponse(groupDetail, 201))
|
|
.mockResolvedValueOnce(jsonResponse(groupDetail))
|
|
.mockResolvedValueOnce(jsonResponse(groupDetail))
|
|
.mockResolvedValueOnce(jsonResponse(undefined, 204));
|
|
|
|
await fetchCustomerGroupList();
|
|
await fetchCustomerGroupDetail(ID);
|
|
await createCustomerGroup({ name: '电商核心组', description: '' });
|
|
await updateCustomerGroup(ID, { description: '说明' });
|
|
await updateCustomerGroupMembers(ID, { addCustomerIds: [ID2], removeCustomerIds: [] });
|
|
await deleteCustomerGroup(ID);
|
|
});
|
|
|
|
it('客户字段:审批队列 / 客户字段 / 申请 / 审批', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(jsonResponse({ items: [fieldListItem], next_cursor: null }))
|
|
.mockResolvedValueOnce(jsonResponse([field]))
|
|
.mockResolvedValueOnce(jsonResponse(field, 201))
|
|
.mockResolvedValueOnce(jsonResponse(field));
|
|
|
|
await fetchCustomerFieldList({ status: 'pending', limit: 100 });
|
|
await fetchCustomerFields(ID);
|
|
await applyCustomerField(ID, {
|
|
name: '活动编码',
|
|
code: 'campaign_code',
|
|
type: 'text',
|
|
options: [],
|
|
purpose: '检索',
|
|
entersGeneration: true,
|
|
});
|
|
await approveCustomerField(ID, { decision: 'approve' });
|
|
expect(lastCall().url).toContain(`/customer-fields/${ID}/approve`);
|
|
});
|
|
});
|