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:
Stev_Wang committed 2026-11-03 21:45:38 +08:00
1 parent 8626996bb1
commit 140dc386c1
54 files changed
+9018 -147

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import {
applyCustomerFieldInputSchema,
approveCustomerFieldInputSchema,
createCustomerGroupInputSchema,
createCustomerInputSchema,
createIndustryInputSchema,
customerFieldCodeSchema,
customerFieldListQuerySchema,
customerGroupListQuerySchema,
customerListQuerySchema,
customerNameSchema,
customerShortNameSchema,
industryListQuerySchema,
setCustomerGroupsInputSchema,
setCustomerStatusInputSchema,
setIndustryStatusInputSchema,
transferCustomerInputSchema,
updateCustomerGroupInputSchema,
updateCustomerGroupMembersInputSchema,
updateCustomerInputSchema,
updateIndustryInputSchema,
} from './client.js';
const ULID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ULID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
describe('行业契约', () => {
it('新建:一级 parentId 缺省、默认排序;二级必须带 parentId', () => {
expect(createIndustryInputSchema.parse({ name: '电商' })).toEqual({
name: '电商',
sortOrder: 10,
remark: '',
});
expect(createIndustryInputSchema.parse({ name: '服饰', parentId: ULID })).toMatchObject({
parentId: ULID,
});
expect(createIndustryInputSchema.safeParse({ name: '电' }).success).toBe(false);
expect(createIndustryInputSchema.safeParse({ name: '电商', unknown: 1 }).success).toBe(false);
});
it('更新 / 状态 / 列表查询:层级与上级不可改,状态枚举与默认分页', () => {
expect(updateIndustryInputSchema.safeParse({ parentId: ULID }).success).toBe(false);
expect(
updateIndustryInputSchema.parse({ name: '电商2', sortOrder: 20, remark: '说明' }),
).toEqual({
name: '电商2',
sortOrder: 20,
remark: '说明',
});
expect(setIndustryStatusInputSchema.safeParse({ status: 'disabled' }).success).toBe(true);
expect(setIndustryStatusInputSchema.safeParse({ status: 'deleted' }).success).toBe(false);
expect(industryListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' });
});
});
describe('客户契约', () => {
it('名称 / 简称:简称必填且限制字符', () => {
expect(customerNameSchema.safeParse(' ').success).toBe(false);
expect(customerShortNameSchema.safeParse('HJ').success).toBe(true);
expect(customerShortNameSchema.safeParse('花间集').success).toBe(true);
expect(customerShortNameSchema.safeParse('H').success).toBe(false);
expect(customerShortNameSchema.safeParse('HJ!').success).toBe(false);
});
it('新建:默认不写偏好与基础字段,未知字段拒绝', () => {
const parsed = createCustomerInputSchema.parse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
});
expect(parsed.preferences).toBeUndefined();
expect(parsed.baseFields).toBeUndefined();
expect(
createCustomerInputSchema.safeParse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
extra: true,
}).success,
).toBe(false);
});
it('新建:基础字段 ≤ 10,接受完整偏好', () => {
const baseFields = Array.from({ length: 11 }, (_, index) => ({
key: `k${index}`,
value: `v${index}`,
}));
expect(
createCustomerInputSchema.safeParse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
baseFields,
}).success,
).toBe(false);
const parsed = createCustomerInputSchema.parse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
preferences: { naming: 'n', directory: 'd', specs: ['1080×1920'], taboo: ['绝对化用语'] },
baseFields: [{ key: '门店', value: '沪-001' }],
});
expect(parsed.preferences?.specs).toHaveLength(1);
});
it('更新不接受所属行业;状态 / 转交 / 组 / 列表查询口径', () => {
expect(updateCustomerInputSchema.safeParse({ industryId: ULID }).success).toBe(false);
expect(updateCustomerInputSchema.parse({ shortName: 'HJ2' })).toEqual({ shortName: 'HJ2' });
expect(setCustomerStatusInputSchema.safeParse({ status: 'paused' }).success).toBe(true);
expect(setCustomerStatusInputSchema.safeParse({ status: 'deleted' }).success).toBe(false);
expect(transferCustomerInputSchema.parse({ ownerUserId: ULID })).toEqual({ ownerUserId: ULID });
expect(setCustomerGroupsInputSchema.parse({ groupIds: [ULID] })).toEqual({ groupIds: [ULID] });
expect(customerListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' });
expect(customerListQuerySchema.parse({ status: 'ended', industry_id: ULID })).toMatchObject({
status: 'ended',
industry_id: ULID,
});
});
});
describe('客户组契约', () => {
it('名称 / 说明 / 成员批量操作 / 列表查询', () => {
expect(createCustomerGroupInputSchema.parse({ name: '电商核心组' })).toEqual({
name: '电商核心组',
});
expect(createCustomerGroupInputSchema.safeParse({ name: '组' }).success).toBe(false);
expect(updateCustomerGroupInputSchema.parse({ description: '说明' })).toEqual({
description: '说明',
});
expect(updateCustomerGroupMembersInputSchema.parse({ addCustomerIds: [ULID] })).toEqual({
addCustomerIds: [ULID],
removeCustomerIds: [],
});
expect(customerGroupListQuerySchema.parse({})).toEqual({ limit: 20 });
});
});
describe('客户字段契约', () => {
it('编码格式:小写字母开头,字母数字下划线', () => {
expect(customerFieldCodeSchema.parse('campaign_code')).toBe('campaign_code');
expect(customerFieldCodeSchema.safeParse('Campaign').success).toBe(false);
expect(customerFieldCodeSchema.safeParse('1abc').success).toBe(false);
expect(customerFieldCodeSchema.safeParse('a').success).toBe(false);
});
it('申请:单选必须有选项,进入生成默认 false', () => {
const parsed = applyCustomerFieldInputSchema.parse({
name: '活动编码',
code: 'campaign_code',
type: 'text',
purpose: '检索',
});
expect(parsed.entersGeneration).toBe(false);
expect(parsed.options).toEqual([]);
expect(
applyCustomerFieldInputSchema.safeParse({
name: '系列',
code: 'series',
type: 'select',
purpose: '检索',
}).success,
).toBe(false);
expect(
applyCustomerFieldInputSchema.parse({
name: '系列',
code: 'series',
type: 'select',
options: ['秋冬'],
purpose: '检索',
entersGeneration: true,
}).entersGeneration,
).toBe(true);
});
it('审批:驳回必须带原因;列表状态枚举', () => {
expect(approveCustomerFieldInputSchema.safeParse({ decision: 'reject' }).success).toBe(false);
expect(approveCustomerFieldInputSchema.parse({ decision: 'reject', reason: '重复' })).toEqual({
decision: 'reject',
reason: '重复',
});
expect(approveCustomerFieldInputSchema.parse({ decision: 'approve' })).toEqual({
decision: 'approve',
});
expect(customerFieldListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' });
});
});