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
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user