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,451 @@
|
||||
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const PREFIX = 'itqa3.';
|
||||
const API = API_PREFIX;
|
||||
|
||||
loadDotenv();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface TokenBody {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface JsonResult<T> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
interface FixtureUser {
|
||||
id: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
let phoneCounter = 0;
|
||||
let app: CreatedApiApp['app'];
|
||||
let superToken = '';
|
||||
let scopedToken = '';
|
||||
let approverToken = '';
|
||||
let scopedUserId = '';
|
||||
let rootIndustryId = '';
|
||||
let leafIndustryId = '';
|
||||
let customerId = '';
|
||||
let groupId = '';
|
||||
const createdUserIds: string[] = [];
|
||||
const createdRoleIds: string[] = [];
|
||||
|
||||
function nextPhone(): string {
|
||||
phoneCounter += 1;
|
||||
return `136${String(phoneCounter).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
async function createRole(
|
||||
suffix: string,
|
||||
permissions: string[],
|
||||
dataScope: { type: string; targetId?: string },
|
||||
): Promise<string> {
|
||||
const id = ulid();
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
code: `${PREFIX}role_${suffix}`,
|
||||
name: `${PREFIX}角色-${suffix}`,
|
||||
description: 'A3-QA-01 集成测试角色',
|
||||
status: 'ACTIVE',
|
||||
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||
dataScopes: {
|
||||
create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdRoleIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createUser(suffix: string, roleId: string): Promise<FixtureUser> {
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||
const account = `${PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `A3 集成测试${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: nextPhone(),
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId }] },
|
||||
},
|
||||
});
|
||||
createdUserIds.push(id);
|
||||
return { id, account };
|
||||
}
|
||||
|
||||
async function login(account: string): Promise<string> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/login`,
|
||||
payload: { account, password: PASSWORD },
|
||||
});
|
||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||
}
|
||||
return response.json<TokenBody>().accessToken;
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<JsonResult<T>> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||
return { status: response.statusCode, body };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.customerField.deleteMany({
|
||||
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customerGroupMember.deleteMany({
|
||||
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||
await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } });
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||
await createUser('admin', superAdminRole.id);
|
||||
const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], {
|
||||
type: 'created_by_me',
|
||||
});
|
||||
const scopedUser = await createUser('scoped', scopedRole);
|
||||
scopedUserId = scopedUser.id;
|
||||
const approverRole = await createRole(
|
||||
'approver',
|
||||
[Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE],
|
||||
{ type: 'all' },
|
||||
);
|
||||
await createUser('approver', approverRole);
|
||||
|
||||
const created = await createApp();
|
||||
app = created.app;
|
||||
await app.init();
|
||||
const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } });
|
||||
if (adminRow === null) throw new Error('fixture user missing');
|
||||
superToken = await login(`${PREFIX}admin`);
|
||||
scopedToken = await login(`${PREFIX}scoped`);
|
||||
approverToken = await login(`${PREFIX}approver`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.customerField.deleteMany({
|
||||
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customerGroupMember.deleteMany({
|
||||
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
if (createdRoleIds.length > 0) {
|
||||
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||
}
|
||||
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => {
|
||||
it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => {
|
||||
const root = await call<{ id: string; level: number; status: string }>(
|
||||
'POST',
|
||||
'/industries',
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-电商',
|
||||
sortOrder: 10,
|
||||
},
|
||||
);
|
||||
expect(root.status).toBe(201);
|
||||
expect(root.body.level).toBe(1);
|
||||
rootIndustryId = root.body.id;
|
||||
|
||||
const child = await call<{ id: string; parentId: string; level: number }>(
|
||||
'POST',
|
||||
'/industries',
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-服饰',
|
||||
parentId: rootIndustryId,
|
||||
sortOrder: 10,
|
||||
},
|
||||
);
|
||||
expect(child.status).toBe(201);
|
||||
expect(child.body.level).toBe(2);
|
||||
leafIndustryId = child.body.id;
|
||||
|
||||
const duplicate = await call<{ code: number }>('POST', '/industries', superToken, {
|
||||
name: 'ITQA3-电商',
|
||||
});
|
||||
expect(duplicate.status).toBe(409);
|
||||
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||
|
||||
const blockedStop = await call<{ code: number }>(
|
||||
'PATCH',
|
||||
`/industries/${rootIndustryId}/status`,
|
||||
superToken,
|
||||
{ status: 'disabled' },
|
||||
);
|
||||
expect(blockedStop.status).toBe(409);
|
||||
expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const blockedDelete = await call<{ code: number }>(
|
||||
'DELETE',
|
||||
`/industries/${rootIndustryId}`,
|
||||
superToken,
|
||||
);
|
||||
expect(blockedDelete.status).toBe(422);
|
||||
expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
});
|
||||
|
||||
it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => {
|
||||
const missingShort = await call<{ code: number }>('POST', '/customers', superToken, {
|
||||
industryId: leafIndustryId,
|
||||
name: 'ITQA3-花间集',
|
||||
ownerUserId: scopedUserId,
|
||||
});
|
||||
expect(missingShort.status).toBe(400);
|
||||
expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
|
||||
const customer = await call<{ id: string; genFieldCount: number }>(
|
||||
'POST',
|
||||
'/customers',
|
||||
superToken,
|
||||
{
|
||||
industryId: leafIndustryId,
|
||||
name: 'ITQA3-花间集',
|
||||
shortName: 'HJ3',
|
||||
ownerUserId: scopedUserId,
|
||||
baseFields: [{ key: '门店', value: '沪-001' }],
|
||||
},
|
||||
);
|
||||
expect(customer.status).toBe(201);
|
||||
customerId = customer.body.id;
|
||||
expect(customer.body.genFieldCount).toBe(0);
|
||||
|
||||
const duplicate = await call<{ code: number }>('POST', '/customers', superToken, {
|
||||
industryId: leafIndustryId,
|
||||
name: 'ITQA3-花间集',
|
||||
shortName: 'HJ3B',
|
||||
ownerUserId: scopedUserId,
|
||||
});
|
||||
expect(duplicate.status).toBe(409);
|
||||
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||
|
||||
const crossCustomer = await call<{ code: number }>(
|
||||
'GET',
|
||||
`/customers/${customerId}`,
|
||||
scopedToken,
|
||||
);
|
||||
expect(crossCustomer.status).toBe(404);
|
||||
expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => {
|
||||
const group = await call<{ id: string; memberCount: number }>(
|
||||
'POST',
|
||||
'/customer-groups',
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-客户组',
|
||||
description: 'A3 集成测试客户组',
|
||||
customerIds: [customerId],
|
||||
},
|
||||
);
|
||||
expect(group.status).toBe(201);
|
||||
groupId = group.body.id;
|
||||
expect(group.body.memberCount).toBe(1);
|
||||
|
||||
const again = await call<{ memberCount: number }>(
|
||||
'POST',
|
||||
`/customer-groups/${groupId}/members`,
|
||||
superToken,
|
||||
{
|
||||
addCustomerIds: [customerId],
|
||||
removeCustomerIds: [],
|
||||
},
|
||||
);
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body.memberCount).toBe(1);
|
||||
|
||||
const groupRole = await createRole('group', [Permission.CUSTOMER_READ], {
|
||||
type: 'customer_groups',
|
||||
targetId: groupId,
|
||||
});
|
||||
await createUser('group', groupRole);
|
||||
const groupToken = await login(`${PREFIX}group`);
|
||||
const visible = await call<{ items: { id: string }[] }>(
|
||||
'GET',
|
||||
`/customers?limit=50`,
|
||||
groupToken,
|
||||
);
|
||||
expect(visible.status).toBe(200);
|
||||
expect(visible.body.items.some((item) => item.id === customerId)).toBe(true);
|
||||
});
|
||||
|
||||
it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => {
|
||||
const apply = await call<{ id: string; status: string; entersGeneration: boolean }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/fields`,
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-活动编码',
|
||||
code: 'itqa3_campaign',
|
||||
type: 'text',
|
||||
purpose: '活动素材命名与检索',
|
||||
entersGeneration: true,
|
||||
},
|
||||
);
|
||||
expect(apply.status).toBe(201);
|
||||
expect(apply.body.status).toBe('pending');
|
||||
expect(apply.body.entersGeneration).toBe(true);
|
||||
|
||||
const pending = await call<{ items: { id: string; status: string }[] }>(
|
||||
'GET',
|
||||
'/customer-fields?status=pending&limit=50',
|
||||
approverToken,
|
||||
);
|
||||
expect(pending.status).toBe(200);
|
||||
expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true);
|
||||
|
||||
const rejectWithoutReason = await call<{ code: number }>(
|
||||
'POST',
|
||||
`/customer-fields/${apply.body.id}/approve`,
|
||||
approverToken,
|
||||
{ decision: 'reject' },
|
||||
);
|
||||
expect(rejectWithoutReason.status).toBe(400);
|
||||
expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
|
||||
const approve = await call<{ status: string }>(
|
||||
'POST',
|
||||
`/customer-fields/${apply.body.id}/approve`,
|
||||
approverToken,
|
||||
{ decision: 'approve' },
|
||||
);
|
||||
expect(approve.status).toBe(200);
|
||||
expect(approve.body.status).toBe('active');
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const extra = await call<{ code?: number }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/fields`,
|
||||
superToken,
|
||||
{
|
||||
name: `ITQA3-字段${index}`,
|
||||
code: `itqa3_extra_${index}`,
|
||||
type: 'text',
|
||||
purpose: '补足进入生成字段上限',
|
||||
entersGeneration: true,
|
||||
},
|
||||
);
|
||||
expect(extra.status).toBe(201);
|
||||
}
|
||||
const overflow = await call<{ code: number }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/fields`,
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-超限字段',
|
||||
code: 'itqa3_overflow',
|
||||
type: 'text',
|
||||
purpose: '第六个进入生成字段应被拒绝',
|
||||
entersGeneration: true,
|
||||
},
|
||||
);
|
||||
expect(overflow.status).toBe(400);
|
||||
expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => {
|
||||
const forbidden = await call<{ code: number }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/transfer`,
|
||||
scopedToken,
|
||||
{
|
||||
ownerUserId: scopedUserId,
|
||||
},
|
||||
);
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
|
||||
const transferred = await call<{ ownerUserId: string }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/transfer`,
|
||||
superToken,
|
||||
{ ownerUserId: createdUserIds[0] },
|
||||
);
|
||||
expect(transferred.status).toBe(200);
|
||||
expect(transferred.body.ownerUserId).toBe(createdUserIds[0]);
|
||||
});
|
||||
it('客户状态机:暂停 / 结束只读', async () => {
|
||||
const paused = await call<{ status: string }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}/status`,
|
||||
superToken,
|
||||
{
|
||||
status: 'paused',
|
||||
},
|
||||
);
|
||||
expect(paused.status).toBe(200);
|
||||
expect(paused.body.status).toBe('paused');
|
||||
const editPaused = await call<{ code: number }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}`,
|
||||
superToken,
|
||||
{
|
||||
shortName: 'HJ3C',
|
||||
},
|
||||
);
|
||||
expect(editPaused.status).toBe(409);
|
||||
expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const ended = await call<{ status: string }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}/status`,
|
||||
superToken,
|
||||
{
|
||||
status: 'ended',
|
||||
},
|
||||
);
|
||||
expect(ended.status).toBe(200);
|
||||
expect(ended.body.status).toBe('ended');
|
||||
const editEnded = await call<{ code: number }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}`,
|
||||
superToken,
|
||||
{
|
||||
shortName: 'HJ3D',
|
||||
},
|
||||
);
|
||||
expect(editEnded.status).toBe(409);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user