feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景: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;真实浏览器六页验证通过。
This commit is contained in:
1 parent
8626996bb1
commit
140dc386c1
54 files changed
+9018
-147
No files matched your search
@@ -0,0 +1,236 @@
|
||||
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`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user