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

+1
View File
@@ -4,6 +4,7 @@ export * from './errors/codes.js';
export * from './naming.js';
export * from './permissions.js';
export * from './schemas/api.js';
export * from './schemas/client.js';
export * from './schemas/common.js';
export * from './schemas/identity.js';
export * from './schemas/roles.js';
+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' });
});
});
+437
View File
@@ -0,0 +1,437 @@
/**
* 客户与行业共享契约(A3:两级行业、客户档案与状态机、客户组、客户级字段)
* 依据:PRD R-20 / R-07、§5.4.2 / §5.4.9;技术架构 §5.3 / §5.4.1 / §6.3.2;ADR-002。
*
* 约定:
* - query 用 snake_case;写 body 用 camelCase 且 `.strict()`;
* - 行业最多两级;一级 parentId 为空,二级 parentId 指向一级;
* - 客户 `industryId` 必须指向末级行业;客户不可跨行业移动(update 不接收 industryId);
* - 基础字段 ≤ 10(JSONB 键值);进入生成字段 ≤ 5(审批通过后生效);
* - 客户状态 active 在服务 / paused 暂停 / ended 结束(结束不可逆)。
*/
import { z } from 'zod';
import { cursorPaginationQuerySchema } from './api.js';
import { isoDateTimeSchema, ulidSchema } from './common.js';
// ---------------------------------------------------------------------------
// 枚举
// ---------------------------------------------------------------------------
export const INDUSTRY_STATUSES = ['active', 'disabled'] as const;
export const industryStatusSchema = z.enum(INDUSTRY_STATUSES);
export type IndustryStatus = z.infer<typeof industryStatusSchema>;
export const industryLevelSchema = z.union([z.literal(1), z.literal(2)]);
export type IndustryLevel = z.infer<typeof industryLevelSchema>;
export const CUSTOMER_STATUSES = ['active', 'paused', 'ended'] as const;
export const customerStatusSchema = z.enum(CUSTOMER_STATUSES);
export type CustomerStatus = z.infer<typeof customerStatusSchema>;
export const CUSTOMER_FIELD_TYPES = ['text', 'number', 'date', 'select'] as const;
export const customerFieldTypeSchema = z.enum(CUSTOMER_FIELD_TYPES);
export type CustomerFieldType = z.infer<typeof customerFieldTypeSchema>;
export const CUSTOMER_FIELD_STATUSES = ['pending', 'active', 'rejected', 'disabled'] as const;
export const customerFieldStatusSchema = z.enum(CUSTOMER_FIELD_STATUSES);
export type CustomerFieldStatus = z.infer<typeof customerFieldStatusSchema>;
/** 上限:只存系统配置前的共享常量(A4 迁入 system_configs,R-26) */
export const CUSTOMER_BASE_FIELD_LIMIT = 10;
export const CUSTOMER_GEN_FIELD_LIMIT = 5;
// ---------------------------------------------------------------------------
// 行业
// ---------------------------------------------------------------------------
export const industryNameSchema = z
.string()
.trim()
.min(2, '行业名称至少 2 个字符')
.max(20, '行业名称最多 20 个字符');
export const industrySortSchema = z.coerce.number().int().min(0).max(9999);
export const industryRemarkSchema = z.string().trim().max(200, '备注最多 200 个字符');
export const industryNodeSchema = z.object({
id: ulidSchema,
parentId: ulidSchema.nullable(),
level: industryLevelSchema,
name: z.string().min(1),
sortOrder: z.number().int(),
status: industryStatusSchema,
remark: z.string(),
customerCount: z.number().int().nonnegative(),
assetCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
});
export type IndustryNode = z.infer<typeof industryNodeSchema>;
export const industryTreeItemSchema = industryNodeSchema.extend({
children: z.array(industryNodeSchema),
});
export type IndustryTreeItem = z.infer<typeof industryTreeItemSchema>;
/** 行业引用信息:删除 / 停用前的引用清单 */
export const industryReferencesSchema = z.object({
childCount: z.number().int().nonnegative(),
activeChildCount: z.number().int().nonnegative(),
customerCount: z.number().int().nonnegative(),
customers: z.array(z.object({ id: ulidSchema, name: z.string(), status: customerStatusSchema })),
assetCount: z.number().int().nonnegative(),
});
export type IndustryReferences = z.infer<typeof industryReferencesSchema>;
export const industryDetailSchema = industryNodeSchema.extend({
parentName: z.string().nullable(),
references: industryReferencesSchema,
});
export type IndustryDetail = z.infer<typeof industryDetailSchema>;
export const industryListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
status: z.enum(['all', 'active', 'disabled']).default('all'),
parent_id: ulidSchema.optional(),
});
export type IndustryListQuery = z.infer<typeof industryListQuerySchema>;
export const createIndustryInputSchema = z
.object({
name: industryNameSchema,
parentId: ulidSchema.nullable().optional(),
sortOrder: industrySortSchema.default(10),
remark: industryRemarkSchema.default(''),
})
.strict();
export type CreateIndustryInput = z.infer<typeof createIndustryInputSchema>;
/** 行业层级与上级不可变;名称 / 排序 / 备注可改(状态走独立接口) */
export const updateIndustryInputSchema = z
.object({
name: industryNameSchema.optional(),
sortOrder: industrySortSchema.optional(),
remark: industryRemarkSchema.optional(),
})
.strict();
export type UpdateIndustryInput = z.infer<typeof updateIndustryInputSchema>;
export const setIndustryStatusInputSchema = z
.object({
status: industryStatusSchema,
})
.strict();
export type SetIndustryStatusInput = z.infer<typeof setIndustryStatusInputSchema>;
// ---------------------------------------------------------------------------
// 客户
// ---------------------------------------------------------------------------
export const customerNameSchema = z
.string()
.trim()
.min(1, '请输入客户名称')
.max(128, '客户名称最多 128 个字符');
/** 简称用于全部自动命名:中文 / 字母 / 数字 / 下划线 / 短横线,2–20 字符 */
export const customerShortNameSchema = z
.string()
.trim()
.min(2, '客户简称至少 2 个字符')
.max(20, '客户简称最多 20 个字符')
.regex(/^[\u4e00-\u9fa5A-Za-z0-9_-]+$/, '客户简称只能包含中文、字母、数字、下划线或短横线');
export const customerContactNameSchema = z.string().trim().max(32).optional();
export const customerContactPhoneSchema = z.string().trim().max(32).optional();
export const customerRemarkSchema = z.string().trim().max(500).optional();
export const customerPreferencesSchema = z
.object({
naming: z.string().trim().max(120).default(''),
directory: z.string().trim().max(200).default(''),
specs: z.array(z.string().trim().max(32)).max(10).default([]),
taboo: z.array(z.string().trim().max(32)).max(20).default([]),
})
.strict();
export type CustomerPreferences = z.infer<typeof customerPreferencesSchema>;
export const baseFieldSchema = z
.object({
key: z.string().trim().min(1, '字段键不能为空').max(32),
value: z.string().trim().max(200),
})
.strict();
export type BaseField = z.infer<typeof baseFieldSchema>;
export const customerGroupRefSchema = z.object({
id: ulidSchema,
name: z.string().min(1),
});
export type CustomerGroupRef = z.infer<typeof customerGroupRefSchema>;
export const customerFieldSchema = z.object({
id: ulidSchema,
customerId: ulidSchema,
name: z.string().min(1),
code: z.string().min(1),
type: customerFieldTypeSchema,
options: z.array(z.string()).default([]),
purpose: z.string(),
entersGeneration: z.boolean(),
status: customerFieldStatusSchema,
applicantName: z.string(),
approverName: z.string().nullable(),
appliedAt: isoDateTimeSchema,
approvedAt: isoDateTimeSchema.nullable(),
rejectReason: z.string().nullable(),
});
export type CustomerField = z.infer<typeof customerFieldSchema>;
/** 审批队列列表项:附带客户上下文,避免前端再逐条查客户 */
export const customerFieldListItemSchema = customerFieldSchema.extend({
customerName: z.string().min(1),
customerShortName: z.string().min(1),
industryPath: z.string().min(1),
});
export type CustomerFieldListItem = z.infer<typeof customerFieldListItemSchema>;
export const customerSummarySchema = z.object({
id: ulidSchema,
name: z.string().min(1),
shortName: z.string().min(1),
industryId: ulidSchema,
industryName: z.string().min(1),
industryPath: z.string().min(1),
status: customerStatusSchema,
ownerUserId: ulidSchema,
ownerName: z.string().min(1),
groups: z.array(customerGroupRefSchema),
baseFieldCount: z.number().int().nonnegative(),
genFieldCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
});
export type CustomerSummary = z.infer<typeof customerSummarySchema>;
export const customerDetailSchema = customerSummarySchema.extend({
contactName: z.string().nullable(),
contactPhone: z.string().nullable(),
preferences: customerPreferencesSchema,
baseFields: z.array(baseFieldSchema),
fields: z.array(customerFieldSchema),
remark: z.string().nullable(),
createdAt: isoDateTimeSchema,
});
export type CustomerDetail = z.infer<typeof customerDetailSchema>;
export const customerListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
industry_id: ulidSchema.optional(),
status: z.enum(['all', 'active', 'paused', 'ended']).default('all'),
owner_user_id: ulidSchema.optional(),
group_id: ulidSchema.optional(),
});
export type CustomerListQuery = z.infer<typeof customerListQuerySchema>;
export const createCustomerInputSchema = z
.object({
industryId: ulidSchema,
name: customerNameSchema,
shortName: customerShortNameSchema,
ownerUserId: ulidSchema,
contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(),
remark: customerRemarkSchema,
})
.strict();
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>;
/** 所属行业不可修改(客户不可跨行业移动),状态走独立接口 */
export const updateCustomerInputSchema = z
.object({
name: customerNameSchema.optional(),
shortName: customerShortNameSchema.optional(),
ownerUserId: ulidSchema.optional(),
contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(),
remark: customerRemarkSchema,
})
.strict();
export type UpdateCustomerInput = z.infer<typeof updateCustomerInputSchema>;
export const setCustomerStatusInputSchema = z
.object({
status: customerStatusSchema,
})
.strict();
export type SetCustomerStatusInput = z.infer<typeof setCustomerStatusInputSchema>;
export const transferCustomerInputSchema = z
.object({
ownerUserId: ulidSchema,
})
.strict();
export type TransferCustomerInput = z.infer<typeof transferCustomerInputSchema>;
// ---------------------------------------------------------------------------
// 客户组
// ---------------------------------------------------------------------------
export const customerGroupNameSchema = z
.string()
.trim()
.min(2, '客户组名称至少 2 个字符')
.max(64, '客户组名称最多 64 个字符');
export const customerGroupDescSchema = z.string().trim().max(255).default('');
export const customerGroupSummarySchema = z.object({
id: ulidSchema,
name: z.string().min(1),
description: z.string(),
memberCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
});
export type CustomerGroupSummary = z.infer<typeof customerGroupSummarySchema>;
export const customerGroupDetailSchema = customerGroupSummarySchema.extend({
members: z.array(
z.object({
id: ulidSchema,
name: z.string(),
shortName: z.string(),
industryPath: z.string(),
status: customerStatusSchema,
ownerName: z.string(),
}),
),
});
export type CustomerGroupDetail = z.infer<typeof customerGroupDetailSchema>;
export const customerGroupListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
});
export type CustomerGroupListQuery = z.infer<typeof customerGroupListQuerySchema>;
export const createCustomerGroupInputSchema = z
.object({
name: customerGroupNameSchema,
description: customerGroupDescSchema.optional(),
customerIds: z.array(ulidSchema).max(500).optional(),
})
.strict();
export type CreateCustomerGroupInput = z.infer<typeof createCustomerGroupInputSchema>;
export const updateCustomerGroupInputSchema = z
.object({
name: customerGroupNameSchema.optional(),
description: customerGroupDescSchema.optional(),
})
.strict();
export type UpdateCustomerGroupInput = z.infer<typeof updateCustomerGroupInputSchema>;
export const updateCustomerGroupMembersInputSchema = z
.object({
addCustomerIds: z.array(ulidSchema).max(500).default([]),
removeCustomerIds: z.array(ulidSchema).max(500).default([]),
})
.strict();
export type UpdateCustomerGroupMembersInput = z.infer<typeof updateCustomerGroupMembersInputSchema>;
export const setCustomerGroupsInputSchema = z
.object({
groupIds: z.array(ulidSchema).max(50),
})
.strict();
export type SetCustomerGroupsInput = z.infer<typeof setCustomerGroupsInputSchema>;
// ---------------------------------------------------------------------------
// 客户级字段
// ---------------------------------------------------------------------------
export const customerFieldCodeSchema = z
.string()
.trim()
.min(2, '字段编码至少 2 个字符')
.max(32, '字段编码最多 32 个字符')
.regex(/^[a-z][a-z0-9_]{1,31}$/, '字段编码需小写字母开头,只能包含小写字母、数字与下划线');
export const customerFieldNameSchema = z
.string()
.trim()
.min(1, '请输入字段名称')
.max(32, '字段名称最多 32 个字符');
export const customerFieldPurposeSchema = z
.string()
.trim()
.min(1, '请填写用途说明')
.max(200, '用途说明最多 200 个字符');
export const applyCustomerFieldInputSchema = z
.object({
name: customerFieldNameSchema,
code: customerFieldCodeSchema,
type: customerFieldTypeSchema,
options: z.array(z.string().trim().min(1).max(32)).max(20).default([]),
purpose: customerFieldPurposeSchema,
entersGeneration: z.boolean().default(false),
})
.strict()
.refine((input) => input.type !== 'select' || input.options.length > 0, {
message: '单选字段至少提供一个选项',
path: ['options'],
});
export type ApplyCustomerFieldInput = z.infer<typeof applyCustomerFieldInputSchema>;
export const approveCustomerFieldInputSchema = z
.object({
decision: z.enum(['approve', 'reject']),
reason: z.string().trim().max(200).optional(),
})
.strict()
.refine((input) => input.decision !== 'reject' || (input.reason ?? '').length > 0, {
message: '驳回时必须填写原因',
path: ['reason'],
});
export type ApproveCustomerFieldInput = z.infer<typeof approveCustomerFieldInputSchema>;
export const customerFieldListQuerySchema = cursorPaginationQuerySchema.extend({
customer_id: ulidSchema.optional(),
status: z.enum(['all', 'pending', 'active', 'rejected', 'disabled']).default('all'),
});
export type CustomerFieldListQuery = z.infer<typeof customerFieldListQuerySchema>;