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
@@ -21,7 +21,8 @@
|
||||
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
|
||||
| **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 |
|
||||
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
|
||||
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限点目录与六类数据范围) |
|
||||
| **A3 报告** | [灵犀_A3客户与行业目录_实施报告_V0.1.md](docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md) | V0.1 | A3 完成范围、两级行业决策、混搭 UI、测试与遗留 |
|
||||
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限;ADR-002 两级行业目录) |
|
||||
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
|
||||
|
||||
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
|
||||
|
||||
@@ -7,6 +7,7 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
||||
import type { AppConfig } from './config/env';
|
||||
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||
import { RedisModule } from './infra/redis/redis.module';
|
||||
import { ClientModule } from './modules/client/client.module';
|
||||
import { IdentityModule } from './modules/identity/identity.module';
|
||||
import { PlatformModule } from './modules/platform/platform.module';
|
||||
|
||||
@@ -49,6 +50,7 @@ import { PlatformModule } from './modules/platform/platform.module';
|
||||
RedisModule,
|
||||
SecurityModule,
|
||||
IdentityModule,
|
||||
ClientModule,
|
||||
PlatformModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PermissionCatalogService } from './catalog/permission-catalog.service';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
|
||||
import { ScopeResolver } from './scope/scope-resolver.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
@@ -15,8 +14,8 @@ import { TokenService } from './token.service';
|
||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
|
||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*
|
||||
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
|
||||
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
|
||||
* `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3);
|
||||
* ScopeResolver / 守卫链只依赖端口,不感知客户模块。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -32,7 +31,6 @@ import { TokenService } from './token.service';
|
||||
JwtAuthGuard,
|
||||
SpecialPermissionGuard,
|
||||
ScopeGuard,
|
||||
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
|
||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
baseFieldSchema,
|
||||
type CustomerDetail,
|
||||
type CustomerField,
|
||||
type CustomerGroupDetail,
|
||||
type CustomerGroupSummary,
|
||||
customerPreferencesSchema,
|
||||
type CustomerStatus,
|
||||
type CustomerSummary,
|
||||
type IndustryNode,
|
||||
type IndustryStatus,
|
||||
} from '@lingxi/shared';
|
||||
import type { CustomerFieldStatus, IndustryStatus as PrismaIndustryStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
export function isoOrNull(value: Date | null): string | null {
|
||||
return value === null ? null : value.toISOString();
|
||||
}
|
||||
|
||||
export function mapIndustryStatus(value: PrismaIndustryStatus): IndustryStatus {
|
||||
return value === 'ACTIVE' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
export function mapCustomerStatus(value: string): CustomerStatus {
|
||||
if (value === 'PAUSED') return 'paused';
|
||||
if (value === 'ENDED') return 'ended';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export function mapFieldStatus(value: CustomerFieldStatus): CustomerField['status'] {
|
||||
if (value === 'ACTIVE') return 'active';
|
||||
if (value === 'REJECTED') return 'rejected';
|
||||
if (value === 'DISABLED') return 'disabled';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export interface IndustryRow {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
level: number;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
status: PrismaIndustryStatus;
|
||||
remark: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export function toIndustryNode(
|
||||
row: IndustryRow,
|
||||
customerCount: number,
|
||||
assetCount = 0,
|
||||
): IndustryNode {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
level: row.level === 2 ? 2 : 1,
|
||||
name: row.name,
|
||||
sortOrder: row.sortOrder,
|
||||
status: mapIndustryStatus(row.status),
|
||||
remark: row.remark,
|
||||
customerCount,
|
||||
assetCount,
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerSummaryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
industryId: string;
|
||||
status: string;
|
||||
ownerUserId: string;
|
||||
updatedAt: Date;
|
||||
industry: { name: string; parent: { name: string } | null };
|
||||
owner: { name: string };
|
||||
customFields: unknown;
|
||||
groupMembers: { group: { id: string; name: string } }[];
|
||||
fields: { entersGeneration: boolean; status: CustomerFieldStatus }[];
|
||||
}
|
||||
|
||||
function industryPath(row: Pick<CustomerSummaryRow, 'industry'>): string {
|
||||
return row.industry.parent === null
|
||||
? row.industry.name
|
||||
: `${row.industry.parent.name} / ${row.industry.name}`;
|
||||
}
|
||||
|
||||
function genFieldCount(row: Pick<CustomerSummaryRow, 'fields'>): number {
|
||||
return row.fields.filter((field) => field.entersGeneration && field.status !== 'REJECTED').length;
|
||||
}
|
||||
|
||||
const baseFieldsSchema = z.array(baseFieldSchema);
|
||||
|
||||
export function toCustomerSummary(row: CustomerSummaryRow): CustomerSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
shortName: row.shortName,
|
||||
industryId: row.industryId,
|
||||
industryName: row.industry.name,
|
||||
industryPath: industryPath(row),
|
||||
status: mapCustomerStatus(row.status),
|
||||
ownerUserId: row.ownerUserId,
|
||||
ownerName: row.owner.name,
|
||||
groups: row.groupMembers.map((member) => ({ id: member.group.id, name: member.group.name })),
|
||||
baseFieldCount: parseBaseFields(row.customFields).length,
|
||||
genFieldCount: genFieldCount(row),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerDetailRow extends CustomerSummaryRow {
|
||||
contactName: string | null;
|
||||
contactPhone: string | null;
|
||||
preferences: unknown;
|
||||
customFields: unknown;
|
||||
remark: string | null;
|
||||
createdAt: Date;
|
||||
fields: CustomerFieldRow[];
|
||||
}
|
||||
|
||||
export interface CustomerFieldRow {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
options: unknown;
|
||||
purpose: string;
|
||||
entersGeneration: boolean;
|
||||
status: CustomerFieldStatus;
|
||||
applicant: { name: string };
|
||||
approver: { name: string } | null;
|
||||
appliedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
rejectReason: string | null;
|
||||
}
|
||||
|
||||
const fieldTypeSchema = z.enum(['text', 'number', 'date', 'select']);
|
||||
const optionsSchema = z.array(z.string());
|
||||
|
||||
export function toCustomerField(row: CustomerFieldRow): CustomerField {
|
||||
const options = optionsSchema.safeParse(row.options);
|
||||
return {
|
||||
id: row.id,
|
||||
customerId: row.customerId,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
type: fieldTypeSchema.safeParse(row.type).success ? fieldTypeSchema.parse(row.type) : 'text',
|
||||
options: options.success ? options.data : [],
|
||||
purpose: row.purpose,
|
||||
entersGeneration: row.entersGeneration,
|
||||
status: mapFieldStatus(row.status),
|
||||
applicantName: row.applicant.name,
|
||||
approverName: row.approver?.name ?? null,
|
||||
appliedAt: iso(row.appliedAt),
|
||||
approvedAt: isoOrNull(row.approvedAt),
|
||||
rejectReason: row.rejectReason,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBaseFields(value: unknown): CustomerDetail['baseFields'] {
|
||||
const parsed = baseFieldsSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : [];
|
||||
}
|
||||
|
||||
function parsePreferences(value: unknown): CustomerDetail['preferences'] {
|
||||
const parsed = customerPreferencesSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : { naming: '', directory: '', specs: [], taboo: [] };
|
||||
}
|
||||
|
||||
export function toCustomerDetail(row: CustomerDetailRow): CustomerDetail {
|
||||
const baseFields = parseBaseFields(row.customFields);
|
||||
return {
|
||||
...toCustomerSummary({
|
||||
...row,
|
||||
fields: row.fields,
|
||||
}),
|
||||
baseFieldCount: baseFields.length,
|
||||
contactName: row.contactName,
|
||||
contactPhone: row.contactPhone,
|
||||
preferences: parsePreferences(row.preferences),
|
||||
baseFields,
|
||||
fields: row.fields.map((field) => toCustomerField(field)),
|
||||
remark: row.remark,
|
||||
createdAt: iso(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerGroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
updatedAt: Date;
|
||||
_count: { members: number };
|
||||
}
|
||||
|
||||
export function toCustomerGroupSummary(row: CustomerGroupRow): CustomerGroupSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
memberCount: row._count.members,
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerGroupMemberRow {
|
||||
customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
status: string;
|
||||
industry: { name: string; parent: { name: string } | null };
|
||||
owner: { name: string };
|
||||
};
|
||||
}
|
||||
|
||||
export function toCustomerGroupDetail(
|
||||
row: CustomerGroupRow,
|
||||
members: CustomerGroupMemberRow[],
|
||||
): CustomerGroupDetail {
|
||||
return {
|
||||
...toCustomerGroupSummary(row),
|
||||
members: members.map((member) => ({
|
||||
id: member.customer.id,
|
||||
name: member.customer.name,
|
||||
shortName: member.customer.shortName,
|
||||
industryPath:
|
||||
member.customer.industry.parent === null
|
||||
? member.customer.industry.name
|
||||
: `${member.customer.industry.parent.name} / ${member.customer.industry.name}`,
|
||||
status: mapCustomerStatus(member.customer.status),
|
||||
ownerName: member.customer.owner.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CUSTOMER_DIRECTORY } from '../../common/security/scope/customer-directory';
|
||||
import {
|
||||
CustomerFieldsController,
|
||||
CustomerFieldsOfCustomerController,
|
||||
} from './customer-fields.controller';
|
||||
import { CustomerFieldsService } from './customer-fields.service';
|
||||
import { CustomerGroupsController } from './customer-groups.controller';
|
||||
import { CustomerGroupsService } from './customer-groups.service';
|
||||
import { CustomersController, CustomerTransferController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { IndustriesController } from './industries.controller';
|
||||
import { IndustriesService } from './industries.service';
|
||||
import { PrismaCustomerDirectory } from './prisma-customer-directory.service';
|
||||
|
||||
/**
|
||||
* client 模块(A3):两级行业、客户档案与状态机、客户组、客户级字段。
|
||||
*
|
||||
* 全局导出 `CUSTOMER_DIRECTORY` 真实实现,供 SecurityModule 的 ScopeResolver 解析六类
|
||||
* 数据范围;client 本身不反向依赖 security 模块,避免循环依赖。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [
|
||||
IndustriesController,
|
||||
CustomersController,
|
||||
CustomerTransferController,
|
||||
CustomerGroupsController,
|
||||
CustomerFieldsController,
|
||||
CustomerFieldsOfCustomerController,
|
||||
],
|
||||
providers: [
|
||||
IndustriesService,
|
||||
CustomersService,
|
||||
CustomerGroupsService,
|
||||
CustomerFieldsService,
|
||||
PrismaCustomerDirectory,
|
||||
{ provide: CUSTOMER_DIRECTORY, useExisting: PrismaCustomerDirectory },
|
||||
],
|
||||
exports: [CUSTOMER_DIRECTORY, IndustriesService, CustomersService],
|
||||
})
|
||||
export class ClientModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
type ApplyCustomerFieldInput,
|
||||
applyCustomerFieldInputSchema,
|
||||
type ApproveCustomerFieldInput,
|
||||
approveCustomerFieldInputSchema,
|
||||
type CustomerField,
|
||||
type CustomerFieldListItem,
|
||||
type CustomerFieldListQuery,
|
||||
customerFieldListQuerySchema,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
} from '@lingxi/shared';
|
||||
import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import {
|
||||
Audit,
|
||||
CurrentScope,
|
||||
CurrentUser,
|
||||
RequirePermissions,
|
||||
} from '../../common/security/decorators';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { CustomerFieldsService } from './customer-fields.service';
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
return auth;
|
||||
}
|
||||
|
||||
@ApiTags('client-customer-fields')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customer-fields')
|
||||
export class CustomerFieldsController {
|
||||
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '字段审批队列(待审批 / 已生效 / 已驳回)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerFieldListQuerySchema)) query: CustomerFieldListQuery,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerFieldListItem>> {
|
||||
return this.fields.list(query, scope);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE)
|
||||
@Audit('client.customer_field.approve')
|
||||
@ApiOperation({ summary: '审批客户级字段(通过 / 驳回;驳回原因必填)' })
|
||||
approve(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(approveCustomerFieldInputSchema)) input: ApproveCustomerFieldInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
return this.fields.approve(id, input, requireAuth(auth).userId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('client-customer-fields')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customers')
|
||||
export class CustomerFieldsOfCustomerController {
|
||||
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
|
||||
|
||||
@Get(':id/fields')
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '客户级字段列表(基础字段在 customers.custom_fields)' })
|
||||
list(
|
||||
@Param('id') id: string,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerField[]> {
|
||||
return this.fields.listByCustomer(id, scope);
|
||||
}
|
||||
|
||||
@Post(':id/fields')
|
||||
@RequirePermissions(Permission.CUSTOMER_FIELD_APPLY)
|
||||
@Audit('client.customer_field.apply')
|
||||
@ApiOperation({ summary: '申请客户级字段(进入生成字段需审批;每客户 ≤ 5)' })
|
||||
apply(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(applyCustomerFieldInputSchema)) input: ApplyCustomerFieldInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
return this.fields.apply(id, input, requireAuth(auth).userId, scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */
|
||||
import {
|
||||
type ApplyCustomerFieldInput,
|
||||
type ApproveCustomerFieldInput,
|
||||
CUSTOMER_GEN_FIELD_LIMIT,
|
||||
type CustomerField,
|
||||
type CustomerFieldListItem,
|
||||
type CustomerFieldListQuery,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
|
||||
|
||||
const FIELD_INCLUDE = {
|
||||
applicant: { select: { name: true } },
|
||||
approver: { select: { name: true } },
|
||||
} satisfies Prisma.CustomerFieldInclude;
|
||||
|
||||
const FIELD_LIST_INCLUDE = {
|
||||
...FIELD_INCLUDE,
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortName: true,
|
||||
industry: { include: { parent: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.CustomerFieldInclude;
|
||||
|
||||
function industryPath(customer: {
|
||||
industry: { name: string; parent: { name: string } | null };
|
||||
}): string {
|
||||
return customer.industry.parent === null
|
||||
? customer.industry.name
|
||||
: `${customer.industry.parent.name} / ${customer.industry.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批);
|
||||
* 本表只承载进入生成 / 需要审批的字段(≤5)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerFieldsService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
query: CustomerFieldListQuery,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerFieldListItem>> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const rows = await this.prisma.customerField.findMany({
|
||||
where: {
|
||||
customer: {
|
||||
deletedAt: null,
|
||||
...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }),
|
||||
},
|
||||
...(query.customer_id === undefined ? {} : { customerId: query.customer_id }),
|
||||
...(query.status === 'all'
|
||||
? {}
|
||||
: {
|
||||
status:
|
||||
query.status === 'pending'
|
||||
? 'PENDING'
|
||||
: query.status === 'active'
|
||||
? 'ACTIVE'
|
||||
: query.status === 'rejected'
|
||||
? 'REJECTED'
|
||||
: 'DISABLED',
|
||||
}),
|
||||
},
|
||||
include: FIELD_LIST_INCLUDE,
|
||||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||
take: query.limit + 1,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return {
|
||||
items: page.map((row) => ({
|
||||
...toCustomerField(row as CustomerFieldRow),
|
||||
customerName: row.customer.name,
|
||||
customerShortName: row.customer.shortName,
|
||||
industryPath: industryPath(row.customer),
|
||||
})),
|
||||
next_cursor: next,
|
||||
};
|
||||
}
|
||||
|
||||
async listByCustomer(
|
||||
customerId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerField[]> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
if (!isCustomerVisible(scope, customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const rows = await this.prisma.customerField.findMany({
|
||||
where: { customerId, customer: { deletedAt: null } },
|
||||
include: FIELD_INCLUDE,
|
||||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||
});
|
||||
return rows.map((row) => toCustomerField(row as CustomerFieldRow));
|
||||
}
|
||||
|
||||
async apply(
|
||||
customerId: string,
|
||||
input: ApplyCustomerFieldInput,
|
||||
actorId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
if (!isCustomerVisible(scope, customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const customer = await this.prisma.customer.findFirst({
|
||||
where: { id: customerId, deletedAt: null },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (customer === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
if (customer.status !== 'ACTIVE') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档',
|
||||
});
|
||||
}
|
||||
const duplicate = await this.prisma.customerField.findUnique({
|
||||
where: { customerId_code: { customerId, code: input.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '该客户下字段编码已存在',
|
||||
});
|
||||
}
|
||||
if (input.entersGeneration) {
|
||||
const used = await this.genUsedCount(customerId);
|
||||
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const id = ulid();
|
||||
await this.prisma.customerField.create({
|
||||
data: {
|
||||
id,
|
||||
customerId,
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
type: input.type,
|
||||
options: input.options,
|
||||
purpose: input.purpose,
|
||||
entersGeneration: input.entersGeneration,
|
||||
status: input.entersGeneration ? 'PENDING' : 'ACTIVE',
|
||||
applicantUserId: actorId,
|
||||
...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }),
|
||||
},
|
||||
});
|
||||
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: FIELD_INCLUDE,
|
||||
});
|
||||
return toCustomerField(row);
|
||||
}
|
||||
|
||||
async approve(
|
||||
id: string,
|
||||
input: ApproveCustomerFieldInput,
|
||||
actorId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const existing = await this.prisma.customerField.findUnique({
|
||||
where: { id },
|
||||
include: { customer: { select: { id: true, deletedAt: true } } },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
|
||||
if (existing === null || existing.customer.deletedAt !== null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||||
}
|
||||
if (!isCustomerVisible(scope, existing.customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||||
}
|
||||
if (existing.status !== 'PENDING') {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' });
|
||||
}
|
||||
if (input.decision === 'approve' && existing.entersGeneration) {
|
||||
const used = await this.genUsedCount(existing.customerId, id);
|
||||
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.prisma.customerField.update({
|
||||
where: { id },
|
||||
data:
|
||||
input.decision === 'approve'
|
||||
? {
|
||||
status: 'ACTIVE',
|
||||
approverUserId: actorId,
|
||||
approvedAt: new Date(),
|
||||
rejectReason: null,
|
||||
}
|
||||
: {
|
||||
status: 'REJECTED',
|
||||
approverUserId: actorId,
|
||||
approvedAt: new Date(),
|
||||
rejectReason: input.reason ?? '',
|
||||
},
|
||||
});
|
||||
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: FIELD_INCLUDE,
|
||||
});
|
||||
return toCustomerField(row);
|
||||
}
|
||||
|
||||
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
|
||||
return this.prisma.customerField.count({
|
||||
where: {
|
||||
customerId,
|
||||
entersGeneration: true,
|
||||
status: { not: 'REJECTED' },
|
||||
...(excludeId === undefined ? {} : { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type CreateCustomerGroupInput,
|
||||
createCustomerGroupInputSchema,
|
||||
type CustomerGroupDetail,
|
||||
type CustomerGroupListQuery,
|
||||
customerGroupListQuerySchema,
|
||||
type CustomerGroupSummary,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type UpdateCustomerGroupInput,
|
||||
updateCustomerGroupInputSchema,
|
||||
type UpdateCustomerGroupMembersInput,
|
||||
updateCustomerGroupMembersInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||
import { CustomerGroupsService } from './customer-groups.service';
|
||||
|
||||
@ApiTags('client-customer-groups')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customer-groups')
|
||||
export class CustomerGroupsController {
|
||||
constructor(@Inject(CustomerGroupsService) private readonly groups: CustomerGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@ApiOperation({ summary: '客户组列表(授权与数据范围最小单位)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerGroupListQuerySchema)) query: CustomerGroupListQuery,
|
||||
): Promise<ListResponse<CustomerGroupSummary>> {
|
||||
return this.groups.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@ApiOperation({ summary: '客户组详情与成员' })
|
||||
detail(@Param('id') id: string): Promise<CustomerGroupDetail> {
|
||||
return this.groups.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.create')
|
||||
@ApiOperation({ summary: '新建客户组' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createCustomerGroupInputSchema)) input: CreateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
return this.groups.create(input);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.update')
|
||||
@ApiOperation({ summary: '更新客户组名称 / 说明' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateCustomerGroupInputSchema)) input: UpdateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
return this.groups.update(id, input);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.delete')
|
||||
@ApiOperation({ summary: '删除客户组(被角色数据范围引用时拒绝)' })
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
await this.groups.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/members')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.members')
|
||||
@ApiOperation({ summary: '批量添加 / 移除成员(幂等)' })
|
||||
updateMembers(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateCustomerGroupMembersInputSchema))
|
||||
input: UpdateCustomerGroupMembersInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
return this.groups.updateMembers(id, input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
type CreateCustomerGroupInput,
|
||||
type CustomerGroupDetail,
|
||||
type CustomerGroupListQuery,
|
||||
type CustomerGroupSummary,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
ulid,
|
||||
type UpdateCustomerGroupInput,
|
||||
type UpdateCustomerGroupMembersInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import {
|
||||
type CustomerGroupRow,
|
||||
toCustomerGroupDetail,
|
||||
toCustomerGroupSummary,
|
||||
} from './client.mapper';
|
||||
|
||||
const GROUP_INCLUDE = {
|
||||
_count: { select: { members: true } },
|
||||
} satisfies Prisma.CustomerGroupInclude;
|
||||
|
||||
const GROUP_MEMBER_INCLUDE = {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortName: true,
|
||||
status: true,
|
||||
industry: { include: { parent: true } },
|
||||
owner: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.CustomerGroupMemberInclude;
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
|
||||
@Injectable()
|
||||
export class CustomerGroupsService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where:
|
||||
query.keyword === undefined
|
||||
? {}
|
||||
: { name: { contains: query.keyword, mode: 'insensitive' } },
|
||||
include: GROUP_INCLUDE,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: query.limit + 1,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return {
|
||||
items: page.map((row) => toCustomerGroupSummary(row as CustomerGroupRow)),
|
||||
next_cursor: next,
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<CustomerGroupDetail> {
|
||||
const row = await this.findByIdOrThrow(id);
|
||||
const members = await this.prisma.customerGroupMember.findMany({
|
||||
where: { groupId: id },
|
||||
include: GROUP_MEMBER_INCLUDE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return toCustomerGroupDetail(row, members);
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||
await this.assertNameUnique(input.name, null);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.customerGroup.create({
|
||||
data: {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description ?? '',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (input.customerIds !== undefined && input.customerIds.length > 0) {
|
||||
await this.addMembers(id, input.customerIds);
|
||||
}
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||
await this.findByIdOrThrow(id);
|
||||
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.description === undefined ? {} : { description: input.description }),
|
||||
},
|
||||
});
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id);
|
||||
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
|
||||
if (referenced > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '该客户组已被角色数据范围引用,无法删除;请先调整角色范围',
|
||||
});
|
||||
}
|
||||
await this.prisma.customerGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async updateMembers(
|
||||
id: string,
|
||||
input: UpdateCustomerGroupMembersInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
await this.findByIdOrThrow(id);
|
||||
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.customerGroupMember.deleteMany({
|
||||
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
|
||||
}),
|
||||
this.prisma.customerGroupMember.createMany({
|
||||
data: [...new Set(input.addCustomerIds)].map((customerId) => ({
|
||||
groupId: id,
|
||||
customerId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }),
|
||||
]);
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
private async addMembers(id: string, customerIds: readonly string[]): Promise<void> {
|
||||
await this.assertCustomersExist(customerIds);
|
||||
await this.prisma.customerGroupMember.createMany({
|
||||
data: [...new Set(customerIds)].map((customerId) => ({ groupId: id, customerId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCustomersExist(customerIds: readonly string[]): Promise<void> {
|
||||
const found = await this.prisma.customer.findMany({
|
||||
where: { id: { in: [...new Set(customerIds)] }, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== new Set(customerIds).size) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在不存在的客户' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findByIdOrThrow(id: string): Promise<CustomerGroupRow> {
|
||||
const row = await this.prisma.customerGroup.findUnique({
|
||||
where: { id },
|
||||
include: GROUP_INCLUDE,
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户组不存在' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertNameUnique(name: string, excludeId: string | null): Promise<void> {
|
||||
const duplicate = await this.prisma.customerGroup.findFirst({
|
||||
where: { name, ...(excludeId === null ? {} : { id: { not: excludeId } }) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
type CreateCustomerInput,
|
||||
createCustomerInputSchema,
|
||||
type CustomerDetail,
|
||||
type CustomerListQuery,
|
||||
customerListQuerySchema,
|
||||
type CustomerSummary,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type SetCustomerGroupsInput,
|
||||
setCustomerGroupsInputSchema,
|
||||
type SetCustomerStatusInput,
|
||||
setCustomerStatusInputSchema,
|
||||
type TransferCustomerInput,
|
||||
transferCustomerInputSchema,
|
||||
type TransferCustomersInput,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateCustomerInput,
|
||||
updateCustomerInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import {
|
||||
Audit,
|
||||
CurrentScope,
|
||||
CurrentUser,
|
||||
RequirePermissions,
|
||||
} from '../../common/security/decorators';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
@ApiTags('client-customers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customers')
|
||||
export class CustomersController {
|
||||
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerSummary>> {
|
||||
return this.customers.list(query, scope);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '客户详情(跨客户访问统一 404)' })
|
||||
detail(
|
||||
@Param('id') id: string,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.detail(id, scope);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.create')
|
||||
@ApiOperation({ summary: '新建客户(简称必填;行业必须为末级行业)' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createCustomerInputSchema)) input: CreateCustomerInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.create(input, requireAuth(auth).userId, scope);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.update')
|
||||
@ApiOperation({ summary: '更新客户档案(所属行业不可改;暂停 / 结束只读)' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateCustomerInputSchema)) input: UpdateCustomerInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.update(id, input, scope, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.status')
|
||||
@ApiOperation({ summary: '客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)' })
|
||||
setStatus(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setCustomerStatusInputSchema)) input: SetCustomerStatusInput,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.setStatus(id, input, scope);
|
||||
}
|
||||
|
||||
@Post(':id/transfer')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_TRANSFER)
|
||||
@Audit('client.customer.transfer')
|
||||
@ApiOperation({ summary: '变更客户负责人(写审计)' })
|
||||
transfer(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(transferCustomerInputSchema)) input: TransferCustomerInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.transfer(id, input, scope, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Put(':id/groups')
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE, Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.groups')
|
||||
@ApiOperation({ summary: '设置客户所属客户组(覆盖式)' })
|
||||
setGroups(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setCustomerGroupsInputSchema)) input: SetCustomerGroupsInput,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.setGroups(id, input, scope);
|
||||
}
|
||||
|
||||
@Post(':id/apply-industry-pack')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.apply_industry_pack')
|
||||
@ApiOperation({ summary: '套用行业资产包(B0 批次提供;当前返回业务提示)' })
|
||||
applyIndustryPack(): never {
|
||||
return this.customers.applyIndustryPack();
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户停用前的客户转交:保留 A1 路径 /users/{id}/transfer-customers,由 client 模块落地 */
|
||||
@ApiTags('client-customers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class CustomerTransferController {
|
||||
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
|
||||
|
||||
@Post(':id/transfer-customers')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_TRANSFER, Permission.USER_MANAGE)
|
||||
@Audit('identity.user.transfer_customers')
|
||||
@ApiOperation({ summary: '把用户名下客户转交给另一负责人' })
|
||||
async transfer(
|
||||
@Param('id') fromUserId: string,
|
||||
@Body(new ZodValidationPipe(transferCustomersInputSchema)) input: TransferCustomersInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<{ transferred: number }> {
|
||||
return this.customers.transferByUser(
|
||||
fromUserId,
|
||||
input.toUserId,
|
||||
input.customerIds,
|
||||
requireAuth(auth).permissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 客户状态机 / 权限与字段上限集中校验,拆分会丢失事务边界 */
|
||||
import {
|
||||
type CreateCustomerInput,
|
||||
CUSTOMER_BASE_FIELD_LIMIT,
|
||||
type CustomerDetail,
|
||||
type CustomerListQuery,
|
||||
type CustomerSummary,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type SetCustomerGroupsInput,
|
||||
type SetCustomerStatusInput,
|
||||
type TransferCustomerInput,
|
||||
ulid,
|
||||
type UpdateCustomerInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
|
||||
import { IndustriesService } from './industries.service';
|
||||
|
||||
const CUSTOMER_INCLUDE = {
|
||||
industry: { include: { parent: true } },
|
||||
owner: { select: { name: true } },
|
||||
groupMembers: { include: { group: true } },
|
||||
fields: { select: { entersGeneration: true, status: true } },
|
||||
} satisfies Prisma.CustomerInclude;
|
||||
|
||||
const CUSTOMER_DETAIL_INCLUDE = {
|
||||
...CUSTOMER_INCLUDE,
|
||||
fields: {
|
||||
include: { applicant: { select: { name: true } }, approver: { select: { name: true } } },
|
||||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||
},
|
||||
} satisfies Prisma.CustomerInclude;
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
function scopeWhere(scope: ScopeContext): Prisma.CustomerWhereInput {
|
||||
return scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } };
|
||||
}
|
||||
|
||||
function hasPermission(permissions: readonly string[], code: string): boolean {
|
||||
return permissions.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户档案(A3-BE-02 / A3-BE-05):状态机、负责人 / 对接人、交付偏好、
|
||||
* 客户组归属、客户转交;所有读写都带 ScopeContext 客户维度。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: CustomerListQuery,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerSummary>> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const industryIds =
|
||||
query.industry_id === undefined
|
||||
? undefined
|
||||
: await this.industrySubtreeIds(query.industry_id);
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...scopeWhere(scope),
|
||||
...(query.keyword === undefined
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||
{ shortName: { contains: query.keyword, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
...(industryIds === undefined ? {} : { industryId: { in: industryIds } }),
|
||||
...(query.status === 'all'
|
||||
? {}
|
||||
: {
|
||||
status:
|
||||
query.status === 'active'
|
||||
? 'ACTIVE'
|
||||
: query.status === 'paused'
|
||||
? 'PAUSED'
|
||||
: 'ENDED',
|
||||
}),
|
||||
...(query.owner_user_id === undefined ? {} : { ownerUserId: query.owner_user_id }),
|
||||
...(query.group_id === undefined
|
||||
? {}
|
||||
: { groupMembers: { some: { groupId: query.group_id } } }),
|
||||
},
|
||||
include: CUSTOMER_INCLUDE,
|
||||
orderBy: { id: 'desc' },
|
||||
take: query.limit + 1,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return {
|
||||
items: page.map((row) => toCustomerSummary(row as CustomerSummaryRow)),
|
||||
next_cursor: next,
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string, scopeInput: ScopeContext | null): Promise<CustomerDetail> {
|
||||
return this.findVisibleOrThrow(id, requireScopeContext(scopeInput));
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateCustomerInput,
|
||||
actorId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
await this.industries.assertLeafIndustry(input.industryId);
|
||||
await this.ensureOwner(input.ownerUserId);
|
||||
const baseFields = input.baseFields ?? [];
|
||||
this.assertBaseFieldLimit(baseFields.length);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.customer.create({
|
||||
data: {
|
||||
id,
|
||||
industryId: input.industryId,
|
||||
name: input.name,
|
||||
shortName: input.shortName,
|
||||
ownerUserId: input.ownerUserId,
|
||||
contactName: input.contactName ?? null,
|
||||
contactPhone: input.contactPhone ?? null,
|
||||
preferences: input.preferences ?? {},
|
||||
customFields: baseFields,
|
||||
remark: input.remark ?? null,
|
||||
createdBy: actorId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '该末级行业下已存在同名客户',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateCustomerInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
permissions: readonly string[],
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const existing = await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (existing.status !== 'ACTIVE') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: existing.status === 'PAUSED' ? '客户已暂停,档案只读' : '客户已结束,数据只读归档',
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.ownerUserId !== undefined &&
|
||||
input.ownerUserId !== existing.ownerUserId &&
|
||||
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)
|
||||
) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PERMISSION_DENIED,
|
||||
message: '变更负责人需要 customer:transfer 权限',
|
||||
});
|
||||
}
|
||||
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
|
||||
await this.ensureOwner(input.ownerUserId);
|
||||
}
|
||||
if (input.baseFields !== undefined) this.assertBaseFieldLimit(input.baseFields.length);
|
||||
if (input.name !== undefined && input.name !== existing.name) {
|
||||
const duplicate = await this.prisma.customer.findFirst({
|
||||
where: {
|
||||
industryId: existing.industryId,
|
||||
name: input.name,
|
||||
id: { not: id },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '该末级行业下已存在同名客户',
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.shortName === undefined ? {} : { shortName: input.shortName }),
|
||||
...(input.ownerUserId === undefined ? {} : { ownerUserId: input.ownerUserId }),
|
||||
...(input.contactName === undefined ? {} : { contactName: input.contactName }),
|
||||
...(input.contactPhone === undefined ? {} : { contactPhone: input.contactPhone }),
|
||||
...(input.preferences === undefined ? {} : { preferences: input.preferences }),
|
||||
...(input.baseFields === undefined ? {} : { customFields: input.baseFields }),
|
||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||
},
|
||||
});
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
id: string,
|
||||
input: SetCustomerStatusInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const existing = await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (existing.status === 'ENDED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '客户已结束,不能再变更状态',
|
||||
});
|
||||
}
|
||||
if (existing.status === input.status) {
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
const target =
|
||||
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
|
||||
await this.prisma.customer.update({ where: { id }, data: { status: target } });
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
async transfer(
|
||||
id: string,
|
||||
input: TransferCustomerInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
permissions: readonly string[],
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
if (!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
message: '缺少客户转交权限',
|
||||
});
|
||||
}
|
||||
const existing = await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (existing.status === 'ENDED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '客户已结束,不能转交',
|
||||
});
|
||||
}
|
||||
await this.ensureOwner(input.ownerUserId);
|
||||
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } });
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
/** 用户停用前批量转交:只转交 from 用户名下、且在指定列表中的客户 */
|
||||
async transferByUser(
|
||||
fromUserId: string,
|
||||
toUserId: string,
|
||||
customerIds: readonly string[] | undefined,
|
||||
permissions: readonly string[],
|
||||
): Promise<{ transferred: number }> {
|
||||
if (
|
||||
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER) &&
|
||||
!hasPermission(permissions, Permission.USER_MANAGE)
|
||||
) {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '缺少客户转交权限' });
|
||||
}
|
||||
await this.ensureOwner(toUserId);
|
||||
const result = await this.prisma.customer.updateMany({
|
||||
where: {
|
||||
ownerUserId: fromUserId,
|
||||
deletedAt: null,
|
||||
status: { not: 'ENDED' },
|
||||
...(customerIds === undefined ? {} : { id: { in: [...customerIds] } }),
|
||||
},
|
||||
data: { ownerUserId: toUserId },
|
||||
});
|
||||
return { transferred: result.count };
|
||||
}
|
||||
|
||||
async setGroups(
|
||||
id: string,
|
||||
input: SetCustomerGroupsInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (input.groupIds.length > 0) {
|
||||
const found = await this.prisma.customerGroup.findMany({
|
||||
where: { id: { in: input.groupIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== new Set(input.groupIds).size) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
|
||||
}
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
|
||||
this.prisma.customerGroupMember.createMany({
|
||||
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
|
||||
}),
|
||||
]);
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
applyIndustryPack(): never {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '行业资产包将在 B0 批次提供;当前可先完成客户创建',
|
||||
});
|
||||
}
|
||||
|
||||
private async findRow(id: string): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
ownerUserId: string;
|
||||
industryId: string;
|
||||
name: string;
|
||||
}> {
|
||||
const row = await this.prisma.customer.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true, status: true, ownerUserId: true, industryId: true, name: true },
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async findVisibleOrThrow(id: string, scope: ScopeContext): Promise<CustomerDetail> {
|
||||
if (!isCustomerVisible(scope, id)) {
|
||||
// A3-BE-05:跨客户访问统一 404,不暴露存在性
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const row = await this.prisma.customer.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: CUSTOMER_DETAIL_INCLUDE,
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
return toCustomerDetail(row);
|
||||
}
|
||||
|
||||
private assertVisible(scope: ScopeContext, id: string): void {
|
||||
if (!isCustomerVisible(scope, id)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureOwner(userId: string): Promise<void> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (user === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '客户负责人不存在或已停用',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertBaseFieldLimit(count: number): void {
|
||||
if (count > CUSTOMER_BASE_FIELD_LIMIT) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${count} 个`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async industrySubtreeIds(industryId: string): Promise<string[]> {
|
||||
const industry = await this.prisma.industry.findUnique({
|
||||
where: { id: industryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (industry === null) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
|
||||
}
|
||||
const children = await this.prisma.industry.findMany({
|
||||
where: { parentId: industryId },
|
||||
select: { id: true },
|
||||
});
|
||||
return [industryId, ...children.map((child) => child.id)];
|
||||
}
|
||||
|
||||
/** 供其他模块复用的归属校验(A3-BE-05):不泄露跨客户资源存在性 */
|
||||
async assertCustomerVisible(customerId: string, scope: ScopeContext): Promise<void> {
|
||||
if (!isCustomerVisible(scope, customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const count = await this.prisma.customer.count({ where: { id: customerId, deletedAt: null } });
|
||||
if (count === 0) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type CreateIndustryInput,
|
||||
createIndustryInputSchema,
|
||||
type IndustryDetail,
|
||||
type IndustryListQuery,
|
||||
industryListQuerySchema,
|
||||
type IndustryTreeItem,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type SetIndustryStatusInput,
|
||||
setIndustryStatusInputSchema,
|
||||
type UpdateIndustryInput,
|
||||
updateIndustryInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||
import { IndustriesService } from './industries.service';
|
||||
|
||||
@ApiTags('client-industries')
|
||||
@ApiBearerAuth()
|
||||
@Controller('industries')
|
||||
export class IndustriesController {
|
||||
constructor(@Inject(IndustriesService) private readonly industries: IndustriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '两级行业树(一级 → 二级)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(industryListQuerySchema)) query: IndustryListQuery,
|
||||
): Promise<ListResponse<IndustryTreeItem>> {
|
||||
return this.industries.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '行业详情与引用(子行业 / 客户 / 资产)' })
|
||||
detail(@Param('id') id: string): Promise<IndustryDetail> {
|
||||
return this.industries.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.create')
|
||||
@ApiOperation({ summary: '新建一级 / 二级行业' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createIndustryInputSchema)) input: CreateIndustryInput,
|
||||
): Promise<IndustryDetail> {
|
||||
return this.industries.create(input);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.update')
|
||||
@ApiOperation({ summary: '更新行业名称 / 排序 / 备注(层级与上级不可变)' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateIndustryInputSchema)) input: UpdateIndustryInput,
|
||||
): Promise<IndustryDetail> {
|
||||
return this.industries.update(id, input);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.status')
|
||||
@ApiOperation({ summary: '停用 / 启用行业(有启用子行业时禁止停用)' })
|
||||
setStatus(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setIndustryStatusInputSchema)) input: SetIndustryStatusInput,
|
||||
): Promise<IndustryDetail> {
|
||||
return this.industries.setStatus(id, input);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.delete')
|
||||
@ApiOperation({ summary: '删除行业(仅无子行业 / 客户 / 资产时)' })
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
await this.industries.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
type CreateIndustryInput,
|
||||
ErrorCode,
|
||||
type IndustryDetail,
|
||||
type IndustryListQuery,
|
||||
type IndustryReferences,
|
||||
type IndustryTreeItem,
|
||||
type ListResponse,
|
||||
type SetIndustryStatusInput,
|
||||
ulid,
|
||||
type UpdateIndustryInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { type IndustryRow, toIndustryNode } from './client.mapper';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
/**
|
||||
* 行业目录(A3-BE-01 / ADR-002):两级树、名称唯一、停用 / 删除引用保护。
|
||||
* 资产数在 B1 资产表落地前固定为 0;接口已保留字段,后续接入不改契约。
|
||||
*/
|
||||
@Injectable()
|
||||
export class IndustriesService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
|
||||
const rows = await this.prisma.industry.findMany({
|
||||
where: {
|
||||
...(query.keyword === undefined
|
||||
? {}
|
||||
: { name: { contains: query.keyword, mode: 'insensitive' } }),
|
||||
...(query.status === 'all'
|
||||
? {}
|
||||
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||
...(query.parent_id === undefined ? {} : { parentId: query.parent_id }),
|
||||
},
|
||||
orderBy: [{ level: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
const counts = await this.customerCounts();
|
||||
const roots = rows.filter((row) => row.level === 1);
|
||||
const childrenByParent = new Map<string, IndustryRow[]>();
|
||||
for (const row of rows) {
|
||||
if (row.level !== 2 || row.parentId === null) continue;
|
||||
const list = childrenByParent.get(row.parentId) ?? [];
|
||||
list.push(row);
|
||||
childrenByParent.set(row.parentId, list);
|
||||
}
|
||||
const items = roots.map((root) => {
|
||||
const children = (childrenByParent.get(root.id) ?? []).map((child) =>
|
||||
toIndustryNode(child, counts.get(child.id) ?? 0),
|
||||
);
|
||||
const own = counts.get(root.id) ?? 0;
|
||||
const total = children.reduce((sum, child) => sum + child.customerCount, own);
|
||||
return {
|
||||
...toIndustryNode(root, total),
|
||||
children,
|
||||
};
|
||||
});
|
||||
return { items, next_cursor: null };
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<IndustryDetail> {
|
||||
const row = await this.findOrThrow(id);
|
||||
const parent =
|
||||
row.parentId === null
|
||||
? null
|
||||
: await this.prisma.industry.findUnique({ where: { id: row.parentId } });
|
||||
const references = await this.references(id);
|
||||
return {
|
||||
...toIndustryNode(row, references.customerCount),
|
||||
parentName: parent?.name ?? null,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
async references(id: string): Promise<IndustryReferences> {
|
||||
const children = await this.prisma.industry.findMany({
|
||||
where: { parentId: id },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
const customerWhere: Prisma.CustomerWhereInput = {
|
||||
deletedAt: null,
|
||||
OR: [{ industryId: id }, { industry: { parentId: id } }],
|
||||
};
|
||||
const [customerCount, customers] = await Promise.all([
|
||||
this.prisma.customer.count({ where: customerWhere }),
|
||||
this.prisma.customer.findMany({
|
||||
where: customerWhere,
|
||||
select: { id: true, name: true, status: true },
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
childCount: children.length,
|
||||
activeChildCount: children.filter((child) => child.status === 'ACTIVE').length,
|
||||
customerCount,
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
status:
|
||||
customer.status === 'PAUSED'
|
||||
? 'paused'
|
||||
: customer.status === 'ENDED'
|
||||
? 'ended'
|
||||
: 'active',
|
||||
})),
|
||||
assetCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
|
||||
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
|
||||
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
|
||||
const parent = await this.findOrThrow(input.parentId);
|
||||
if (parent.level !== 1) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '行业最多两级,二级行业下不能再建子行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.assertNameUnique(input.name, level === 1 ? null : (input.parentId ?? null), null);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.industry.create({
|
||||
data: {
|
||||
id,
|
||||
parentId: level === 1 ? null : (input.parentId ?? null),
|
||||
level,
|
||||
name: input.name,
|
||||
sortOrder: input.sortOrder,
|
||||
remark: input.remark,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: level === 1 ? '行业名称已存在' : '该上级行业下已存在同名行业',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateIndustryInput): Promise<IndustryDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (input.name !== undefined && input.name !== existing.name) {
|
||||
await this.assertNameUnique(input.name, existing.parentId, id);
|
||||
}
|
||||
await this.prisma.industry.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||
},
|
||||
});
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async setStatus(id: string, input: SetIndustryStatusInput): Promise<IndustryDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (existing.status === 'DISABLED' && input.status === 'active') {
|
||||
const parent =
|
||||
existing.parentId === null
|
||||
? null
|
||||
: await this.prisma.industry.findUnique({ where: { id: existing.parentId } });
|
||||
if (parent !== null && parent.status === 'DISABLED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '上级行业已停用,请先启用上级行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.status === 'disabled') {
|
||||
const activeChildren = await this.prisma.industry.count({
|
||||
where: { parentId: id, status: 'ACTIVE' },
|
||||
});
|
||||
if (activeChildren > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: `该行业下仍有 ${activeChildren} 个启用中的二级行业,请先停用或调整子级`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.prisma.industry.update({
|
||||
where: { id },
|
||||
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
|
||||
});
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const references = await this.references(id);
|
||||
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: `无法删除:该行业下有 ${references.childCount} 个二级行业、${references.customerCount} 个客户`,
|
||||
details: references,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.prisma.industry.delete({ where: { id } });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '该行业仍被引用,无法删除,只能停用',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */
|
||||
async assertLeafIndustry(id: string): Promise<void> {
|
||||
const industry = await this.findOrThrow(id);
|
||||
if (industry.status === 'DISABLED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '行业已停用,新建客户不可选择',
|
||||
});
|
||||
}
|
||||
if (industry.level === 1) {
|
||||
const childCount = await this.prisma.industry.count({ where: { parentId: id } });
|
||||
if (childCount > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '该一级行业下存在二级行业,客户只能归属到末级行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrThrow(id: string): Promise<IndustryRow> {
|
||||
const row = await this.prisma.industry.findUnique({ where: { id } });
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertNameUnique(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
excludeId: string | null,
|
||||
): Promise<void> {
|
||||
const duplicate = await this.prisma.industry.findFirst({
|
||||
where: {
|
||||
name,
|
||||
parentId,
|
||||
...(excludeId === null ? {} : { id: { not: excludeId } }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: parentId === null ? '行业名称已存在' : '该上级行业下已存在同名行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async customerCounts(): Promise<Map<string, number>> {
|
||||
const grouped = await this.prisma.customer.groupBy({
|
||||
by: ['industryId'],
|
||||
where: { deletedAt: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return new Map(grouped.map((item) => [item.industryId, item._count._all]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { CustomerDirectory } from '../../common/security/scope/customer-directory';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
|
||||
/**
|
||||
* 真实客户目录(A3 替换 A2 的 EmptyCustomerDirectory)。
|
||||
* 只读,供 ScopeResolver 解析六类数据范围;不参与业务写操作。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaCustomerDirectory implements CustomerDirectory {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async findOwnedCustomerIds(userId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { ownerUserId: userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]> {
|
||||
if (customerGroupIds.length === 0) return [];
|
||||
const rows = await this.prisma.customerGroupMember.findMany({
|
||||
where: { groupId: { in: [...customerGroupIds] }, customer: { deletedAt: null } },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return [...new Set(rows.map((row) => row.customerId))].sort();
|
||||
}
|
||||
|
||||
async findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]> {
|
||||
if (departmentIds.length === 0) return [];
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
owner: { departmentId: { in: [...departmentIds] } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findCreatedCustomerIds(userId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { createdBy: userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
}
|
||||
@@ -616,7 +616,7 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => {
|
||||
it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => {
|
||||
const adminLogin = await login(created.app, manager.account);
|
||||
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||||
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
||||
@@ -639,7 +639,9 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
const scopes = userLogin.body.me.dataScopes;
|
||||
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||||
for (const scope of scopes) {
|
||||
expect(scope.customerIds).toEqual([]);
|
||||
expect(Array.isArray(scope.customerIds)).toBe(true);
|
||||
}
|
||||
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
|
||||
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -259,8 +259,6 @@ describe('UsersService', () => {
|
||||
const disabled = userFixture({ status: 'DISABLED' });
|
||||
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
|
||||
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
expect(() => service.transferCustomers()).toThrow(AppException);
|
||||
});
|
||||
|
||||
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateUserInput,
|
||||
updateUserInputSchema,
|
||||
type UserDetail,
|
||||
@@ -110,15 +109,4 @@ export class UsersController {
|
||||
): Promise<UserDetail> {
|
||||
return this.users.enable(id, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Post(':id/transfer-customers')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.USER_MANAGE)
|
||||
@Audit('identity.user.transfer_customers')
|
||||
@ApiOperation({ summary: '客户转交(占位)', description: 'A3 客户模块落地前返回 42201' })
|
||||
transferCustomers(
|
||||
@Body(new ZodValidationPipe(transferCustomersInputSchema)) _input: unknown,
|
||||
): never {
|
||||
return this.users.transferCustomers();
|
||||
}
|
||||
}
|
||||
@@ -180,14 +180,6 @@ export class UsersService {
|
||||
return this.detail(id, permissions);
|
||||
}
|
||||
|
||||
/** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */
|
||||
transferCustomers(): never {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '客户转交将在客户模块(A3)提供',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
|
||||
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
type ApplyCustomerFieldInput,
|
||||
applyCustomerFieldInputSchema,
|
||||
type ApproveCustomerFieldInput,
|
||||
approveCustomerFieldInputSchema,
|
||||
type CreateCustomerGroupInput,
|
||||
createCustomerGroupInputSchema,
|
||||
type CreateCustomerInput,
|
||||
createCustomerInputSchema,
|
||||
type CreateIndustryInput,
|
||||
createIndustryInputSchema,
|
||||
type CustomerDetail,
|
||||
customerDetailSchema,
|
||||
type CustomerField,
|
||||
type CustomerFieldListItem,
|
||||
customerFieldListItemSchema,
|
||||
type CustomerFieldListQuery,
|
||||
customerFieldSchema,
|
||||
type CustomerGroupDetail,
|
||||
customerGroupDetailSchema,
|
||||
type CustomerGroupListQuery,
|
||||
type CustomerGroupSummary,
|
||||
customerGroupSummarySchema,
|
||||
type CustomerListQuery,
|
||||
type CustomerSummary,
|
||||
customerSummarySchema,
|
||||
type IndustryDetail,
|
||||
industryDetailSchema,
|
||||
type IndustryListQuery,
|
||||
type IndustryTreeItem,
|
||||
industryTreeItemSchema,
|
||||
listResponseSchema,
|
||||
type SetCustomerGroupsInput,
|
||||
setCustomerGroupsInputSchema,
|
||||
type SetCustomerStatusInput,
|
||||
setCustomerStatusInputSchema,
|
||||
type SetIndustryStatusInput,
|
||||
setIndustryStatusInputSchema,
|
||||
type TransferCustomerInput,
|
||||
transferCustomerInputSchema,
|
||||
type UpdateCustomerGroupInput,
|
||||
updateCustomerGroupInputSchema,
|
||||
type UpdateCustomerGroupMembersInput,
|
||||
updateCustomerGroupMembersInputSchema,
|
||||
type UpdateCustomerInput,
|
||||
updateCustomerInputSchema,
|
||||
type UpdateIndustryInput,
|
||||
updateIndustryInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
const industryListResponseSchema = listResponseSchema(industryTreeItemSchema);
|
||||
const customerListResponseSchema = listResponseSchema(customerSummarySchema);
|
||||
const groupListResponseSchema = listResponseSchema(customerGroupSummarySchema);
|
||||
const fieldListResponseSchema = listResponseSchema(customerFieldListItemSchema);
|
||||
|
||||
function searchParams(entries: Record<string, string | number | undefined>): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||
}
|
||||
const search = params.toString();
|
||||
return search === '' ? '' : `?${search}`;
|
||||
}
|
||||
|
||||
export async function fetchIndustryList(
|
||||
query: Partial<IndustryListQuery> = {},
|
||||
): Promise<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.industries}${searchParams({
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
parent_id: query.parent_id,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
})}`,
|
||||
);
|
||||
return industryListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchIndustryDetail(id: string): Promise<IndustryDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.industry(id));
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createIndustry(input: CreateIndustryInput): Promise<IndustryDetail> {
|
||||
const body = createIndustryInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.industries, { method: 'POST', body });
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateIndustry(
|
||||
id: string,
|
||||
input: UpdateIndustryInput,
|
||||
): Promise<IndustryDetail> {
|
||||
const body = updateIndustryInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.industry(id), { method: 'PATCH', body });
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function setIndustryStatus(
|
||||
id: string,
|
||||
input: SetIndustryStatusInput,
|
||||
): Promise<IndustryDetail> {
|
||||
const body = setIndustryStatusInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.industryStatus(id), { method: 'PATCH', body });
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function deleteIndustry(id: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.industry(id), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchCustomerList(
|
||||
query: Partial<CustomerListQuery> = {},
|
||||
): Promise<{ items: CustomerSummary[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.customers}${searchParams({
|
||||
keyword: query.keyword,
|
||||
industry_id: query.industry_id,
|
||||
status: query.status,
|
||||
owner_user_id: query.owner_user_id,
|
||||
group_id: query.group_id,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
})}`,
|
||||
);
|
||||
return customerListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchCustomerDetail(id: string): Promise<CustomerDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.customer(id));
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createCustomer(input: CreateCustomerInput): Promise<CustomerDetail> {
|
||||
const body = createCustomerInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customers, { method: 'POST', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateCustomer(
|
||||
id: string,
|
||||
input: UpdateCustomerInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = updateCustomerInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customer(id), { method: 'PATCH', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function setCustomerStatus(
|
||||
id: string,
|
||||
input: SetCustomerStatusInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = setCustomerStatusInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerStatus(id), { method: 'PATCH', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function transferCustomer(
|
||||
id: string,
|
||||
input: TransferCustomerInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = transferCustomerInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerTransfer(id), { method: 'POST', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function setCustomerGroups(
|
||||
id: string,
|
||||
input: SetCustomerGroupsInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = setCustomerGroupsInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroups(id), { method: 'PUT', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function applyIndustryPack(id: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.customerApplyPack(id), { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchCustomerGroupList(
|
||||
query: Partial<CustomerGroupListQuery> = {},
|
||||
): Promise<{ items: CustomerGroupSummary[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.customerGroupsList}${searchParams({ keyword: query.keyword, limit: query.limit, cursor: query.cursor })}`,
|
||||
);
|
||||
return groupListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchCustomerGroupDetail(id: string): Promise<CustomerGroupDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroup(id));
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createCustomerGroup(
|
||||
input: CreateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
const body = createCustomerGroupInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroupsList, { method: 'POST', body });
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateCustomerGroup(
|
||||
id: string,
|
||||
input: UpdateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
const body = updateCustomerGroupInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroup(id), { method: 'PATCH', body });
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateCustomerGroupMembers(
|
||||
id: string,
|
||||
input: UpdateCustomerGroupMembersInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
const body = updateCustomerGroupMembersInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroupMembers(id), {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function deleteCustomerGroup(id: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.customerGroup(id), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchCustomerFieldList(
|
||||
query: Partial<CustomerFieldListQuery> = {},
|
||||
): Promise<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.customerFields}${searchParams({
|
||||
customer_id: query.customer_id,
|
||||
status: query.status,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
})}`,
|
||||
);
|
||||
return fieldListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchCustomerFields(customerId: string): Promise<CustomerField[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId));
|
||||
const schema = listResponseSchema(customerFieldSchema);
|
||||
// 后端该接口返回数组;列表信封仅用于 /customer-fields
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => customerFieldSchema.parse(item));
|
||||
}
|
||||
return schema.parse(payload).items;
|
||||
}
|
||||
|
||||
export async function applyCustomerField(
|
||||
customerId: string,
|
||||
input: ApplyCustomerFieldInput,
|
||||
): Promise<CustomerField> {
|
||||
const body = applyCustomerFieldInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId), {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
return customerFieldSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function approveCustomerField(
|
||||
id: string,
|
||||
input: ApproveCustomerFieldInput,
|
||||
): Promise<CustomerField> {
|
||||
const body = approveCustomerFieldInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerFieldApprove(id), {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
return customerFieldSchema.parse(payload);
|
||||
}
|
||||
@@ -28,4 +28,28 @@ export const ENDPOINTS = {
|
||||
roles: '/roles',
|
||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||
permissions: '/permissions',
|
||||
// A3 client
|
||||
industries: '/industries',
|
||||
industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`,
|
||||
industryStatus: (industryId: string): string =>
|
||||
`/industries/${encodeURIComponent(industryId)}/status`,
|
||||
customers: '/customers',
|
||||
customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`,
|
||||
customerStatus: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/status`,
|
||||
customerTransfer: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/transfer`,
|
||||
customerGroups: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/groups`,
|
||||
customerApplyPack: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/apply-industry-pack`,
|
||||
customerGroupsList: '/customer-groups',
|
||||
customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`,
|
||||
customerGroupMembers: (groupId: string): string =>
|
||||
`/customer-groups/${encodeURIComponent(groupId)}/members`,
|
||||
customerFields: '/customer-fields',
|
||||
customerFieldsOf: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/fields`,
|
||||
customerFieldApprove: (fieldId: string): string =>
|
||||
`/customer-fields/${encodeURIComponent(fieldId)}/approve`,
|
||||
} as const;
|
||||
+706
-37
@@ -177,26 +177,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 客户转交(占位)
|
||||
* @description A3 客户模块落地前返回 42201
|
||||
*/
|
||||
post: operations['UsersController_transferCustomers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/tree': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -366,6 +346,287 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 两级行业树(一级 → 二级) */
|
||||
get: operations['IndustriesController_list'];
|
||||
put?: never;
|
||||
/** 新建一级 / 二级行业 */
|
||||
post: operations['IndustriesController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 行业详情与引用(子行业 / 客户 / 资产) */
|
||||
get: operations['IndustriesController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** 删除行业(仅无子行业 / 客户 / 资产时) */
|
||||
delete: operations['IndustriesController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新行业名称 / 排序 / 备注(层级与上级不可变) */
|
||||
patch: operations['IndustriesController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries/{id}/status': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 停用 / 启用行业(有启用子行业时禁止停用) */
|
||||
patch: operations['IndustriesController_setStatus'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选) */
|
||||
get: operations['CustomersController_list'];
|
||||
put?: never;
|
||||
/** 新建客户(简称必填;行业必须为末级行业) */
|
||||
post: operations['CustomersController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户详情(跨客户访问统一 404) */
|
||||
get: operations['CustomersController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新客户档案(所属行业不可改;暂停 / 结束只读) */
|
||||
patch: operations['CustomersController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/status': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆) */
|
||||
patch: operations['CustomersController_setStatus'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/transfer': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 变更客户负责人(写审计) */
|
||||
post: operations['CustomersController_transfer'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/groups': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** 设置客户所属客户组(覆盖式) */
|
||||
put: operations['CustomersController_setGroups'];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/apply-industry-pack': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 套用行业资产包(B0 批次提供;当前返回业务提示) */
|
||||
post: operations['CustomersController_applyIndustryPack'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 把用户名下客户转交给另一负责人 */
|
||||
post: operations['CustomerTransferController_transfer'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户组列表(授权与数据范围最小单位) */
|
||||
get: operations['CustomerGroupsController_list'];
|
||||
put?: never;
|
||||
/** 新建客户组 */
|
||||
post: operations['CustomerGroupsController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户组详情与成员 */
|
||||
get: operations['CustomerGroupsController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** 删除客户组(被角色数据范围引用时拒绝) */
|
||||
delete: operations['CustomerGroupsController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新客户组名称 / 说明 */
|
||||
patch: operations['CustomerGroupsController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups/{id}/members': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 批量添加 / 移除成员(幂等) */
|
||||
post: operations['CustomerGroupsController_updateMembers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-fields': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 字段审批队列(待审批 / 已生效 / 已驳回) */
|
||||
get: operations['CustomerFieldsController_list'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-fields/{id}/approve': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 审批客户级字段(通过 / 驳回;驳回原因必填) */
|
||||
post: operations['CustomerFieldsController_approve'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/fields': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户级字段列表(基础字段在 customers.custom_fields) */
|
||||
get: operations['CustomerFieldsOfCustomerController_list'];
|
||||
put?: never;
|
||||
/** 申请客户级字段(进入生成字段需审批;每客户 ≤ 5) */
|
||||
post: operations['CustomerFieldsOfCustomerController_apply'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -684,23 +945,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_transferCustomers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_tree: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -890,6 +1134,431 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_setStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_setStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_transfer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_setGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_applyIndustryPack: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerTransferController_transfer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_updateMembers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsController_approve: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsOfCustomerController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsOfCustomerController_apply: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -13,4 +13,17 @@ export const queryKeys = {
|
||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||
permissions: ['permissions'] as const,
|
||||
// A3 client
|
||||
industries: ['industries'] as const,
|
||||
industryList: (query: object) => ['industries', 'list', query] as const,
|
||||
industry: (industryId: string) => ['industries', 'detail', industryId] as const,
|
||||
customers: ['customers'] as const,
|
||||
customerList: (query: object) => ['customers', 'list', query] as const,
|
||||
customer: (customerId: string) => ['customers', 'detail', customerId] as const,
|
||||
customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const,
|
||||
customerGroups: ['customer-groups'] as const,
|
||||
customerGroupList: (query: object) => ['customer-groups', 'list', query] as const,
|
||||
customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const,
|
||||
customerFields: (query: object) => ['customer-fields', 'list', query] as const,
|
||||
customerFieldsRoot: ['customer-fields'] as const,
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import type {
|
||||
ApplyCustomerFieldInput,
|
||||
ApproveCustomerFieldInput,
|
||||
CreateCustomerGroupInput,
|
||||
CreateCustomerInput,
|
||||
CreateIndustryInput,
|
||||
CustomerDetail,
|
||||
CustomerField,
|
||||
CustomerFieldListItem,
|
||||
CustomerFieldListQuery,
|
||||
CustomerGroupDetail,
|
||||
CustomerGroupSummary,
|
||||
CustomerListQuery,
|
||||
CustomerSummary,
|
||||
IndustryDetail,
|
||||
IndustryListQuery,
|
||||
IndustryTreeItem,
|
||||
SetCustomerGroupsInput,
|
||||
SetCustomerStatusInput,
|
||||
SetIndustryStatusInput,
|
||||
TransferCustomerInput,
|
||||
UpdateCustomerGroupInput,
|
||||
UpdateCustomerGroupMembersInput,
|
||||
UpdateCustomerInput,
|
||||
UpdateIndustryInput,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
type UseMutationResult,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
applyCustomerField,
|
||||
approveCustomerField,
|
||||
createCustomer,
|
||||
createCustomerGroup,
|
||||
createIndustry,
|
||||
deleteCustomerGroup,
|
||||
deleteIndustry,
|
||||
fetchCustomerDetail,
|
||||
fetchCustomerFieldList,
|
||||
fetchCustomerFields,
|
||||
fetchCustomerGroupDetail,
|
||||
fetchCustomerGroupList,
|
||||
fetchCustomerList,
|
||||
fetchIndustryDetail,
|
||||
fetchIndustryList,
|
||||
setCustomerGroups,
|
||||
setCustomerStatus,
|
||||
setIndustryStatus,
|
||||
transferCustomer,
|
||||
updateCustomer,
|
||||
updateCustomerGroup,
|
||||
updateCustomerGroupMembers,
|
||||
updateIndustry,
|
||||
} from '@/api/customers';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
|
||||
export function useIndustriesQuery(
|
||||
query: Partial<IndustryListQuery> = {},
|
||||
): UseQueryResult<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.industryList(query),
|
||||
queryFn: () => fetchIndustryList(query),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndustryDetailQuery(id: string | null): UseQueryResult<IndustryDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.industry(id ?? ''),
|
||||
queryFn: () => fetchIndustryDetail(id ?? ''),
|
||||
enabled: id !== null && id !== '',
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomersQuery(
|
||||
query: Partial<CustomerListQuery> = {},
|
||||
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerList(query),
|
||||
queryFn: () => fetchCustomerList(query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerDetailQuery(id: string | null): UseQueryResult<CustomerDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customer(id ?? ''),
|
||||
queryFn: () => fetchCustomerDetail(id ?? ''),
|
||||
enabled: id !== null && id !== '',
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerGroupsQuery(): UseQueryResult<{
|
||||
items: CustomerGroupSummary[];
|
||||
next_cursor: string | null;
|
||||
}> {
|
||||
const query = { limit: 100 };
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerGroupList(query),
|
||||
queryFn: () => fetchCustomerGroupList(query),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerGroupDetailQuery(
|
||||
id: string | null,
|
||||
): UseQueryResult<CustomerGroupDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerGroup(id ?? ''),
|
||||
queryFn: () => fetchCustomerGroupDetail(id ?? ''),
|
||||
enabled: id !== null && id !== '',
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerFieldsQuery(
|
||||
query: Partial<CustomerFieldListQuery> = {},
|
||||
): UseQueryResult<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerFields(query),
|
||||
queryFn: () => fetchCustomerFieldList(query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerFieldsOfQuery(
|
||||
customerId: string | null,
|
||||
): UseQueryResult<CustomerField[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerFieldsOf(customerId ?? ''),
|
||||
queryFn: () => fetchCustomerFields(customerId ?? ''),
|
||||
enabled: customerId !== null && customerId !== '',
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateClient(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.industries });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.customers });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.customerGroups });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.customerFieldsRoot });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateIndustryMutation(): UseMutationResult<
|
||||
IndustryDetail,
|
||||
Error,
|
||||
CreateIndustryInput
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: createIndustry,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIndustryMutation(): UseMutationResult<
|
||||
IndustryDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateIndustryInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateIndustry(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetIndustryStatusMutation(): UseMutationResult<
|
||||
IndustryDetail,
|
||||
Error,
|
||||
{ id: string; input: SetIndustryStatusInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => setIndustryStatus(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteIndustryMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteIndustry,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomerMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
CreateCustomerInput
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: createCustomer,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomerMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateCustomerInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateCustomer(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetCustomerStatusMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: SetCustomerStatusInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => setCustomerStatus(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferCustomerMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: TransferCustomerInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => transferCustomer(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetCustomerGroupsMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: SetCustomerGroupsInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => setCustomerGroups(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomerGroupMutation(): UseMutationResult<
|
||||
CustomerGroupDetail,
|
||||
Error,
|
||||
CreateCustomerGroupInput
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: createCustomerGroup,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomerGroupMutation(): UseMutationResult<
|
||||
CustomerGroupDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateCustomerGroupInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateCustomerGroup(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomerGroupMembersMutation(): UseMutationResult<
|
||||
CustomerGroupDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateCustomerGroupMembersInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateCustomerGroupMembers(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomerGroupMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteCustomerGroup,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyCustomerFieldMutation(): UseMutationResult<
|
||||
CustomerField,
|
||||
Error,
|
||||
{ customerId: string; input: ApplyCustomerFieldInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ customerId, input }) => applyCustomerField(customerId, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveCustomerFieldMutation(): UseMutationResult<
|
||||
CustomerField,
|
||||
Error,
|
||||
{ id: string; input: ApproveCustomerFieldInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => approveCustomerField(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '@/styles/users.css';
|
||||
import '@/styles/roles.css';
|
||||
import '@/styles/role-editor.css';
|
||||
import '@/styles/departments.css';
|
||||
import '@/styles/client.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -53,6 +53,20 @@ describe('导航配置', () => {
|
||||
expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理');
|
||||
});
|
||||
|
||||
it('客户与行业菜单使用查看权限,名称按 A3 口径更名', () => {
|
||||
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
|
||||
const customersItem = systemItems.find((item) => item.key === 'customers');
|
||||
const industriesItem = systemItems.find((item) => item.key === 'industries');
|
||||
expect(customersItem?.label).toBe('客户管理');
|
||||
expect(customersItem?.permission).toBe('customer:read');
|
||||
expect(industriesItem?.label).toBe('行业管理');
|
||||
expect(industriesItem?.permission).toBe('customer:read');
|
||||
expect(
|
||||
findActiveNav(NAV_MODULES, '/system/customers/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.item?.label,
|
||||
).toBe('客户管理');
|
||||
expect(findActiveNav(NAV_MODULES, '/system/industries')?.item?.label).toBe('行业管理');
|
||||
});
|
||||
|
||||
it('按路径定位当前模块与菜单', () => {
|
||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||
expect(active?.module.key).toBe('material');
|
||||
|
||||
@@ -273,19 +273,27 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: '客户',
|
||||
label: '客户管理',
|
||||
path: '/system/customers',
|
||||
icon: IconGlobal,
|
||||
permission: Permission.CUSTOMER_MANAGE,
|
||||
description: '客户、行业归属与数据范围',
|
||||
permission: Permission.CUSTOMER_READ,
|
||||
description: '客户档案、状态与交付偏好',
|
||||
},
|
||||
{
|
||||
key: 'customer-groups',
|
||||
label: '客户组',
|
||||
path: '/system/customer-groups',
|
||||
icon: IconApps,
|
||||
permission: Permission.CUSTOMER_GROUP_MANAGE,
|
||||
description: '客户组与批量授权单位',
|
||||
},
|
||||
{
|
||||
key: 'industries',
|
||||
label: '行业树',
|
||||
label: '行业管理',
|
||||
path: '/system/industries',
|
||||
icon: IconStack,
|
||||
permission: Permission.INDUSTRY_MANAGE,
|
||||
description: '行业分类树与行业包绑定',
|
||||
permission: Permission.CUSTOMER_READ,
|
||||
description: '两级行业树、状态与客户归属',
|
||||
},
|
||||
{
|
||||
key: 'dicts',
|
||||
@@ -338,9 +346,9 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
{
|
||||
key: 'customer-fields',
|
||||
label: '客户字段',
|
||||
path: '/system/customer-fields',
|
||||
path: '/system/customer-fields/approvals',
|
||||
icon: IconTask,
|
||||
permission: Permission.CUSTOMER_MANAGE,
|
||||
permission: Permission.CUSTOMER_FIELD_APPROVE,
|
||||
description: '客户级扩展字段与审批',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerFieldListItem, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useApplyCustomerFieldMutation,
|
||||
useApproveCustomerFieldMutation,
|
||||
useCustomerFieldsQuery,
|
||||
useCustomersQuery,
|
||||
} from '@/hooks/queries/use-client';
|
||||
|
||||
function statusTag(status: CustomerFieldListItem['status']): ReactNode {
|
||||
const cls =
|
||||
status === 'active'
|
||||
? 'active'
|
||||
: status === 'pending'
|
||||
? 'paused'
|
||||
: status === 'rejected'
|
||||
? 'ended'
|
||||
: 'disabled';
|
||||
const label =
|
||||
status === 'active'
|
||||
? '已生效'
|
||||
: status === 'pending'
|
||||
? '待审批'
|
||||
: status === 'rejected'
|
||||
? '已驳回'
|
||||
: '已停用';
|
||||
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function ApplyModal({ onClose }: { onClose: () => void }): ReactNode {
|
||||
const customers = useCustomersQuery({ limit: 100, status: 'active' });
|
||||
const apply = useApplyCustomerFieldMutation();
|
||||
const [customerId, setCustomerId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (
|
||||
customerId === undefined ||
|
||||
name.trim() === '' ||
|
||||
code.trim() === '' ||
|
||||
purpose.trim() === ''
|
||||
) {
|
||||
setError('请完整填写客户、字段名称、编码与用途');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apply.mutateAsync({
|
||||
customerId,
|
||||
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||
});
|
||||
notifySuccess('字段申请已提交,等待审批');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="申请客户字段"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="提交申请"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>申请客户</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={customerId}
|
||||
placeholder="选择客户"
|
||||
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||
value: customer.id,
|
||||
label: customer.name,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setCustomerId(String(value));
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段编码</label>
|
||||
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段类型</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={type}
|
||||
optionList={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'select', label: '单选' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setType(value as typeof type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>用途说明</label>
|
||||
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectModal({
|
||||
field,
|
||||
onClose,
|
||||
}: {
|
||||
field: CustomerFieldListItem;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (reason.trim() === '') {
|
||||
setError('驳回原因必填');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await approve.mutateAsync({ id: field.id, input: { decision: 'reject', reason } });
|
||||
notifySuccess('已驳回,申请人将收到通知');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '驳回失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="驳回字段申请"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ type: 'danger' }}
|
||||
>
|
||||
<div className="lx-client-hint">
|
||||
{field.customerName} · {field.name}({field.code})
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>驳回原因</label>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
placeholder="例如:与基础字段重复,仅需检索无需进入生成"
|
||||
/>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户字段审批(选定方案 A:全宽表格 + 审批弹窗) */
|
||||
export function CustomerFieldsPage(): ReactNode {
|
||||
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
|
||||
const fields = useCustomerFieldsQuery({ limit: 100, status });
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
|
||||
|
||||
const items = fields.data?.items ?? [];
|
||||
|
||||
if (fields.isPending) return <PageSkeleton rows={8} />;
|
||||
|
||||
const approveField = (field: CustomerFieldListItem): void => {
|
||||
Modal.confirm({
|
||||
title: '通过字段申请',
|
||||
content: `通过后「${field.name}(${field.code})」立即生效并进入客户生成链路,操作会写入审计。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await approve.mutateAsync({ id: field.id, input: { decision: 'approve' } });
|
||||
notifySuccess('已通过审批,字段已生效');
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '审批失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>客户字段审批</h1>
|
||||
<p>客户级字段分两层:基础字段仅检索报表、无需审批;进入生成字段必须申请 → 审批 → 生效</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setApplyOpen(true);
|
||||
}}
|
||||
>
|
||||
申请字段
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
<section className="lx-client-filters">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={status}
|
||||
optionList={[
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'pending', label: '待审批' },
|
||||
{ value: 'active', label: '已生效' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setStatus(value as typeof status);
|
||||
}}
|
||||
/>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
共 {items.length} 条申请
|
||||
</span>
|
||||
</section>
|
||||
<section className="lx-client-table-card">
|
||||
{items.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无字段申请</b>
|
||||
<span>客户提交进入生成字段的申请后,会出现在这里等待审批</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="lx-client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>字段</th>
|
||||
<th>类型</th>
|
||||
<th>进入生成</th>
|
||||
<th>申请人</th>
|
||||
<th>申请时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((field) => (
|
||||
<tr key={field.id}>
|
||||
<td>
|
||||
<b>{field.customerName}</b>
|
||||
<span className="lx-client-sub">{field.industryPath}</span>
|
||||
</td>
|
||||
<td>
|
||||
<b>{field.name}</b>
|
||||
<span className="lx-client-sub">{field.code}</span>
|
||||
</td>
|
||||
<td>{field.type}</td>
|
||||
<td>{field.entersGeneration ? '是' : '否'}</td>
|
||||
<td>{field.applicantName}</td>
|
||||
<td className="lx-client-muted">
|
||||
{field.appliedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>{statusTag(field.status)}</td>
|
||||
<td>
|
||||
{field.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRejectField(field);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
approveField(field);
|
||||
}}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="lx-client-muted">已处理</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="lx-client-tablefoot">
|
||||
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ 5 个
|
||||
</div>
|
||||
</section>
|
||||
{applyOpen ? (
|
||||
<ApplyModal
|
||||
onClose={() => {
|
||||
setApplyOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{rejectField !== null ? (
|
||||
<RejectModal
|
||||
field={rejectField}
|
||||
onClose={() => {
|
||||
setRejectField(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { CustomerGroupDetail } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useCreateCustomerGroupMutation,
|
||||
useCustomerGroupDetailQuery,
|
||||
useCustomerGroupsQuery,
|
||||
useCustomersQuery,
|
||||
useDeleteCustomerGroupMutation,
|
||||
useUpdateCustomerGroupMembersMutation,
|
||||
useUpdateCustomerGroupMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
|
||||
function GroupFormModal({
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
group?: CustomerGroupDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const create = useCreateCustomerGroupMutation();
|
||||
const update = useUpdateCustomerGroupMutation();
|
||||
const [name, setName] = useState(group?.name ?? '');
|
||||
const [description, setDescription] = useState(group?.description ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim().length < 2) {
|
||||
setError('客户组名称至少 2 个字符');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (group !== undefined) {
|
||||
await update.mutateAsync({ id: group.id, input: { name, description } });
|
||||
notifySuccess('客户组已保存');
|
||||
} else {
|
||||
await create.mutateAsync({ name, description });
|
||||
notifySuccess('客户组已创建');
|
||||
}
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={group === undefined ? '新建客户组' : '编辑客户组'}
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>客户组名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:电商核心组" />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>说明</label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="说明客户组的用途与授权范围"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersModal({
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
group: CustomerGroupDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const customers = useCustomersQuery({ limit: 100 });
|
||||
const update = useUpdateCustomerGroupMembersMutation();
|
||||
const currentIds = group.members.map((member) => member.id);
|
||||
const [selected, setSelected] = useState<string[]>(currentIds);
|
||||
const submit = async (): Promise<void> => {
|
||||
const addCustomerIds = selected.filter((id) => !currentIds.includes(id));
|
||||
const removeCustomerIds = currentIds.filter((id) => !selected.includes(id));
|
||||
try {
|
||||
await update.mutateAsync({ id: group.id, input: { addCustomerIds, removeCustomerIds } });
|
||||
notifySuccess(
|
||||
`已更新客户组成员(新增 ${addCustomerIds.length} / 移除 ${removeCustomerIds.length})`,
|
||||
);
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="管理客户组成员"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存成员"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>成员客户</label>
|
||||
<Select
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||
value: customer.id,
|
||||
label: `${customer.name}(${customer.industryPath})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelected(value as string[]);
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
调整成员会立即改变引用该客户组的角色可见客户集合;变更写入审计。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户组(选定方案 B:左列表 + 右成员详情) */
|
||||
export function CustomerGroupsPage(): ReactNode {
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const activeId = selectedId ?? groups.data?.items[0]?.id ?? null;
|
||||
const detail = useCustomerGroupDetailQuery(activeId);
|
||||
const remove = useDeleteCustomerGroupMutation();
|
||||
const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
|
||||
|
||||
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
|
||||
if (groups.isPending) return <PageSkeleton rows={6} />;
|
||||
|
||||
const deleteGroup = (): void => {
|
||||
if (detail.data === undefined) return;
|
||||
Modal.confirm({
|
||||
title: '删除客户组',
|
||||
content: '删除前需确认无角色数据范围引用;删除后不可恢复。',
|
||||
okButtonProps: { type: 'danger' },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await remove.mutateAsync(detail.data.id);
|
||||
notifySuccess('客户组已删除');
|
||||
setSelectedId(null);
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>客户组</h1>
|
||||
<p>把客户按行业、团队或服务等级分组;客户组是批量授权与数据范围的最小单位</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setModal('create');
|
||||
}}
|
||||
>
|
||||
新建客户组
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-split">
|
||||
<section className="lx-client-left">
|
||||
<div className="lx-client-left__head">
|
||||
<b>客户组</b>
|
||||
<span>{items.length} 个 · 覆盖 9 个客户</span>
|
||||
</div>
|
||||
<div className="lx-client-left__body">
|
||||
{items.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无客户组</b>
|
||||
<span>新建客户组,把客户按行业 / 团队分组</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={`lx-client-tree__row${group.id === activeId ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedId(group.id);
|
||||
}}
|
||||
style={{ height: 46 }}
|
||||
>
|
||||
<span className="lx-client-tree__name">
|
||||
<b style={{ display: 'block', fontWeight: 500 }}>{group.name}</b>
|
||||
<span style={{ fontSize: 11, color: 'var(--lx-color-text-secondary)' }}>
|
||||
{group.description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lx-client-tree__count">{group.memberCount} 个</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="lx-client-left__foot">共 {items.length} 个客户组 · 授权最小单位</div>
|
||||
</section>
|
||||
<section className="lx-client-right">
|
||||
{detail.data === undefined ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>选择客户组</b>
|
||||
<span>从左侧选择客户组查看成员</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lx-client-right__head">
|
||||
<div>
|
||||
<h2>{detail.data.name}</h2>
|
||||
<p>
|
||||
{detail.data.description || '暂无说明'} · {detail.data.memberCount} 个客户
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('members');
|
||||
}}
|
||||
>
|
||||
管理成员
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('edit');
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="danger" onClick={deleteGroup}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<div className="lx-client-hint">
|
||||
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
|
||||
</div>
|
||||
<div className="lx-client-memberlist">
|
||||
{detail.data.members.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无成员</div>
|
||||
) : null}
|
||||
{detail.data.members.map((member) => (
|
||||
<div key={member.id} className="lx-client-memberrow">
|
||||
<b>{member.name}</b>
|
||||
<span>
|
||||
{member.industryPath} · {member.ownerName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{modal === 'create' ? (
|
||||
<GroupFormModal
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'edit' && detail.data !== undefined ? (
|
||||
<GroupFormModal
|
||||
group={detail.data}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'members' && detail.data !== undefined ? (
|
||||
<MembersModal
|
||||
group={detail.data}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerDetail, type CustomerField, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconBack } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useApplyCustomerFieldMutation,
|
||||
useCustomerDetailQuery,
|
||||
useCustomerFieldsOfQuery,
|
||||
useCustomerGroupsQuery,
|
||||
useSetCustomerGroupsMutation,
|
||||
useSetCustomerStatusMutation,
|
||||
useTransferCustomerMutation,
|
||||
useUpdateCustomerMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||
|
||||
function Section({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>{title}</b>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldStatusTag({ field }: { field: CustomerField }): ReactNode {
|
||||
const cls =
|
||||
field.status === 'active'
|
||||
? 'active'
|
||||
: field.status === 'pending'
|
||||
? 'paused'
|
||||
: field.status === 'rejected'
|
||||
? 'ended'
|
||||
: 'disabled';
|
||||
const label =
|
||||
field.status === 'active'
|
||||
? '已生效'
|
||||
: field.status === 'pending'
|
||||
? '待审批'
|
||||
: field.status === 'rejected'
|
||||
? '已驳回'
|
||||
: '已停用';
|
||||
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function TransferModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const transfer = useTransferCustomerMutation();
|
||||
const [ownerUserId, setOwnerUserId] = useState(customer.ownerUserId);
|
||||
const submit = async (): Promise<void> => {
|
||||
try {
|
||||
await transfer.mutateAsync({ id: customer.id, input: { ownerUserId } });
|
||||
notifySuccess('客户负责人已转交,已写入审计');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '转交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="变更客户负责人"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="确认转交"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>新的客户负责人</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={ownerUserId}
|
||||
optionList={(users.data?.items ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name}(${user.departmentName})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(String(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
转交后该客户会从原负责人的「负责客户」范围移出,并写入审计。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseFieldModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const update = useUpdateCustomerMutation();
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const submit = async (): Promise<void> => {
|
||||
if (key.trim() === '') return;
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: customer.id,
|
||||
input: { baseFields: [...customer.baseFields, { key: key.trim(), value: value.trim() }] },
|
||||
});
|
||||
notifySuccess('基础字段已保存');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal visible title="添加基础字段" onCancel={onClose} onOk={() => void submit()} okText="保存">
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>字段键</label>
|
||||
<Input value={key} onChange={setKey} placeholder="如:门店编号" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段值</label>
|
||||
<Input value={value} onChange={setValue} placeholder="如:沪-012" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyFieldModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const apply = useApplyCustomerFieldMutation();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim() === '' || code.trim() === '' || purpose.trim() === '') {
|
||||
setError('请填写字段名称、编码与用途说明');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apply.mutateAsync({
|
||||
customerId: customer.id,
|
||||
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||
});
|
||||
notifySuccess('字段申请已提交,等待审批');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="申请客户字段"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="提交申请"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>字段名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段编码</label>
|
||||
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段类型</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={type}
|
||||
optionList={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'select', label: '单选' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setType(value as typeof type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>用途说明</label>
|
||||
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 5 个。</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const update = useSetCustomerGroupsMutation();
|
||||
const [groupIds, setGroupIds] = useState<string[]>(customer.groups.map((group) => group.id));
|
||||
const submit = async (): Promise<void> => {
|
||||
try {
|
||||
await update.mutateAsync({ id: customer.id, input: { groupIds } });
|
||||
notifySuccess('客户组归属已更新');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="管理客户所属客户组"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>客户组</label>
|
||||
<Select
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
value={groupIds}
|
||||
optionList={(groups.data?.items ?? []).map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setGroupIds(value as string[]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
客户组是数据范围授权的最小单位;调整会立即影响引用该客户组的角色可见范围。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户详情(选定方案 A:独立三栏档案页) */
|
||||
export function CustomerDetailPage(): ReactNode {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const detail = useCustomerDetailQuery(id ?? null);
|
||||
const fields = useCustomerFieldsOfQuery(id ?? null);
|
||||
const setStatus = useSetCustomerStatusMutation();
|
||||
const update = useUpdateCustomerMutation();
|
||||
const [modal, setModal] = useState<'transfer' | 'base' | 'field' | 'groups' | null>(null);
|
||||
|
||||
if (detail.isPending) return <PageSkeleton rows={8} />;
|
||||
if (detail.error) {
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<div className="lx-client-empty">
|
||||
<b>客户加载失败</b>
|
||||
<span>{detail.error.message}</span>
|
||||
<Button onClick={() => void navigate(ROUTES.system.customers)}>返回客户列表</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const customer = detail.data;
|
||||
const canWrite = customer.status === 'active';
|
||||
const changeStatus = (target: 'paused' | 'ended' | 'active'): void => {
|
||||
Modal.confirm({
|
||||
title:
|
||||
target === 'paused' ? '暂停客户服务' : target === 'ended' ? '结束客户服务' : '恢复客户服务',
|
||||
content:
|
||||
target === 'paused'
|
||||
? '暂停后档案只读,不能发起新的生成任务;可随时恢复。'
|
||||
: target === 'ended'
|
||||
? '结束后全部数据只读归档,且不能恢复;如继续合作需新建客户。'
|
||||
: '恢复后档案可编辑,并可以重新发起生成任务。',
|
||||
okButtonProps: target === 'ended' ? { type: 'danger' } : undefined,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await setStatus.mutateAsync({ id: customer.id, input: { status: target } });
|
||||
notifySuccess(
|
||||
target === 'paused'
|
||||
? '客户已暂停'
|
||||
: target === 'ended'
|
||||
? '客户已结束并归档'
|
||||
: '客户已恢复为在服务',
|
||||
);
|
||||
void detail.refetch();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const removeBaseField = async (index: number): Promise<void> => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: customer.id,
|
||||
input: {
|
||||
baseFields: customer.baseFields.filter((_item, itemIndex) => itemIndex !== index),
|
||||
},
|
||||
});
|
||||
notifySuccess('基础字段已删除');
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
};
|
||||
const fieldRows = fields.data ?? customer.fields;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<a
|
||||
className="lx-client-back"
|
||||
href={ROUTES.system.customers}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate(ROUTES.system.customers);
|
||||
}}
|
||||
>
|
||||
<IconBack size={14} /> 返回客户列表
|
||||
</a>
|
||||
<h1>
|
||||
{customer.name} <CustomerStatusTag status={customer.status} />
|
||||
</h1>
|
||||
<p>
|
||||
{customer.shortName} · {customer.industryPath} · 负责人 {customer.ownerName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_TRANSFER}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('transfer');
|
||||
}}
|
||||
disabled={customer.status === 'ended'}
|
||||
>
|
||||
变更负责人
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
{customer.status === 'active' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
changeStatus('paused');
|
||||
}}
|
||||
>
|
||||
暂停服务
|
||||
</Button>
|
||||
) : null}
|
||||
{customer.status === 'paused' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
changeStatus('active');
|
||||
}}
|
||||
>
|
||||
恢复服务
|
||||
</Button>
|
||||
) : null}
|
||||
{customer.status !== 'ended' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
changeStatus('ended');
|
||||
}}
|
||||
>
|
||||
结束服务
|
||||
</Button>
|
||||
) : null}
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{customer.status !== 'active' ? (
|
||||
<div
|
||||
className={`lx-client-banner lx-client-banner--${customer.status === 'paused' ? 'paused' : 'ended'}`}
|
||||
>
|
||||
{customer.status === 'paused'
|
||||
? '客户已暂停:档案只读,不能发起新的生成任务。恢复为「在服务」后可继续。'
|
||||
: '客户已结束服务:全部数据只读归档,不能编辑或发起任务。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lx-client-grid">
|
||||
<aside className="lx-client-anchors">
|
||||
<a href="#basic">基本信息</a>
|
||||
<a href="#prefs">交付偏好</a>
|
||||
<a href="#base-fields">基础字段</a>
|
||||
<a href="#gen-fields">进入生成</a>
|
||||
<a href="#groups">客户组</a>
|
||||
<a href="#asset">资产包</a>
|
||||
</aside>
|
||||
<div className="lx-client-panel">
|
||||
<Section title="基本信息">
|
||||
<div className="lx-client-kv" id="basic">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户名称</span>
|
||||
<b>{customer.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户简称</span>
|
||||
<b>{customer.shortName}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>所属行业</span>
|
||||
<b>{customer.industryPath}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>服务状态</span>
|
||||
<b>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户负责人</span>
|
||||
<b>{customer.ownerName}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户侧对接人</span>
|
||||
<b>
|
||||
{customer.contactName ?? '—'} {customer.contactPhone ?? ''}
|
||||
</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>创建时间</span>
|
||||
<b>{customer.createdAt.slice(0, 10)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>最近更新</span>
|
||||
<b>{customer.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
{customer.remark !== null ? (
|
||||
<div className="lx-client-hint">{customer.remark}</div>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section title="交付偏好">
|
||||
<div className="lx-client-kv" id="prefs">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>命名习惯</span>
|
||||
<b>{customer.preferences.naming || '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>目录结构</span>
|
||||
<b>{customer.preferences.directory || '—'}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
常用规格:{customer.preferences.specs.join('、') || '—'}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
素材禁忌:{customer.preferences.taboo.join('、') || '—'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="基础字段"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canWrite || customer.baseFields.length >= 10}
|
||||
onClick={() => {
|
||||
setModal('base');
|
||||
}}
|
||||
>
|
||||
添加基础字段
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div id="base-fields">
|
||||
{customer.baseFields.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无基础字段</div>
|
||||
) : null}
|
||||
{customer.baseFields.map((field, index) => (
|
||||
<div key={`${field.key}-${index}`} className="lx-client-memberrow">
|
||||
<b>{field.key}</b>
|
||||
<span>{field.value}</span>
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
disabled={!canWrite}
|
||||
onClick={() => void removeBaseField(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ 10 个(
|
||||
{customer.baseFields.length}/10)。
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="进入生成字段"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!canWrite || customer.genFieldCount >= 5}
|
||||
onClick={() => {
|
||||
setModal('field');
|
||||
}}
|
||||
>
|
||||
申请字段
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div id="gen-fields">
|
||||
{fieldRows.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无进入生成字段</div>
|
||||
) : null}
|
||||
{fieldRows.map((field) => (
|
||||
<div key={field.id} className="lx-client-memberrow">
|
||||
<b>{field.name}</b>
|
||||
<span>
|
||||
{field.code} · {field.type} · {field.purpose}
|
||||
</span>
|
||||
<FieldStatusTag field={field} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
需申请 → 审批 → 生效;每客户 ≤ 5 个({customer.genFieldCount}/5)。
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="客户组"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setModal('groups');
|
||||
}}
|
||||
>
|
||||
管理客户组
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div className="lx-client-chips" id="groups">
|
||||
{customer.groups.length === 0 ? (
|
||||
<span className="lx-client-hint">未加入客户组</span>
|
||||
) : null}
|
||||
{customer.groups.map((group) => (
|
||||
<span key={group.id} className="lx-client-chip">
|
||||
{group.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="行业资产包">
|
||||
<div className="lx-client-assetempty" id="asset">
|
||||
<b>该行业尚未发布可用行业包</b>
|
||||
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并替换为客户 Logo 与警示语。
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<aside className="lx-client-side">
|
||||
<section className="lx-client-sidecard">
|
||||
<h3>服务状态</h3>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>进入生成字段</span>
|
||||
<b>{customer.genFieldCount} / 5</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>基础字段</span>
|
||||
<b>{customer.baseFieldCount} / 10</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户组</span>
|
||||
<b>{customer.groups.length} 个</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产包</span>
|
||||
<b>待行业包发布</b>
|
||||
</div>
|
||||
</section>
|
||||
<section className="lx-client-sidecard">
|
||||
<h3>审计提示</h3>
|
||||
<div className="lx-client-hint">
|
||||
客户状态、负责人、字段与客户组的变更都会写入审计,记录前后值与操作人。
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{modal === 'transfer' ? (
|
||||
<TransferModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'base' ? (
|
||||
<BaseFieldModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'field' ? (
|
||||
<ApplyFieldModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'groups' ? (
|
||||
<GroupsModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Select, Steps } from '@douyinfe/semi-ui-19';
|
||||
import type { CreateCustomerInput } from '@lingxi/shared';
|
||||
import { notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useCreateCustomerMutation, useIndustriesQuery } from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { customerDetailPath, ROUTES } from '@/routes';
|
||||
|
||||
const STEPS = ['选择行业', '基础信息', '交付偏好', '行业资产包'];
|
||||
|
||||
/** 新建客户(选定方案 B:左侧步骤导航 + 右侧表单) */
|
||||
export function CustomerNewPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const industries = useIndustriesQuery({ limit: 100 });
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const create = useCreateCustomerMutation();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [shortName, setShortName] = useState('');
|
||||
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||
const [contactName, setContactName] = useState('');
|
||||
const [contactPhone, setContactPhone] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [naming, setNaming] = useState('客户名_方向_日期_序号');
|
||||
const [directory, setDirectory] = useState('客户名/年份/方向');
|
||||
const [specs, setSpecs] = useState('1080×1920,1080×1080');
|
||||
const [taboo, setTaboo] = useState('绝对化用语,未授权肖像');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const industryOptions = useMemo(() => {
|
||||
const options: { value: string; label: string; leaf: boolean }[] = [];
|
||||
for (const root of industries.data?.items ?? []) {
|
||||
if (root.status !== 'active') continue;
|
||||
if (root.children.length === 0) {
|
||||
options.push({ value: root.id, label: root.name, leaf: true });
|
||||
} else {
|
||||
for (const child of root.children) {
|
||||
if (child.status === 'active')
|
||||
options.push({ value: child.id, label: `${root.name} / ${child.name}`, leaf: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [industries.data]);
|
||||
|
||||
const next = (): void => {
|
||||
setError(null);
|
||||
if (current === 0 && industryId === undefined) {
|
||||
setError('请选择客户归属行业');
|
||||
return;
|
||||
}
|
||||
if (current === 1) {
|
||||
if (name.trim() === '') {
|
||||
setError('请输入客户名称');
|
||||
return;
|
||||
}
|
||||
if (shortName.trim().length < 2) {
|
||||
setError('客户简称必填,用于自动命名(42201)');
|
||||
return;
|
||||
}
|
||||
if (ownerUserId === undefined) {
|
||||
setError('请选择客户负责人');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrent((step) => Math.min(step + 1, 3));
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (industryId === undefined || ownerUserId === undefined) return;
|
||||
const input: CreateCustomerInput = {
|
||||
industryId,
|
||||
name,
|
||||
shortName,
|
||||
ownerUserId,
|
||||
contactName: contactName === '' ? undefined : contactName,
|
||||
contactPhone: contactPhone === '' ? undefined : contactPhone,
|
||||
remark: remark === '' ? undefined : remark,
|
||||
preferences: {
|
||||
naming,
|
||||
directory,
|
||||
specs: specs
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== ''),
|
||||
taboo: taboo
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== ''),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const created = await create.mutateAsync(input);
|
||||
notifySuccess('客户已创建');
|
||||
void navigate(customerDetailPath(created.id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (industries.isPending) return <PageSkeleton rows={6} />;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>新建客户</h1>
|
||||
<p>分四步创建客户:选择行业 → 基础信息 → 交付偏好 → 行业资产包</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button onClick={() => void navigate(ROUTES.system.customers)}>取消</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-newgrid">
|
||||
<aside className="lx-client-stepnav">
|
||||
<Steps current={current} direction="vertical">
|
||||
{STEPS.map((title) => (
|
||||
<Steps.Step key={title} title={title} />
|
||||
))}
|
||||
</Steps>
|
||||
</aside>
|
||||
<section className="lx-client-formcard">
|
||||
<div className="lx-client-formcard__body">
|
||||
{current === 0 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>
|
||||
客户归属行业 <i>*</i>
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={industryId}
|
||||
placeholder="选择末级行业"
|
||||
optionList={industryOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setIndustryId(String(value));
|
||||
}}
|
||||
/>
|
||||
<div className="lx-client-hint">
|
||||
有子行业时必须选择二级;一级行业无子行业时可直接选择。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{current === 1 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>
|
||||
客户名称 <i>*</i>
|
||||
</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:花间集" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>
|
||||
客户简称 <i>*</i>
|
||||
</label>
|
||||
<Input value={shortName} onChange={setShortName} placeholder="如:HJ" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>
|
||||
客户负责人 <i>*</i>
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={ownerUserId}
|
||||
optionList={(users.data?.items ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name}(${user.departmentName})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(String(value));
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>客户侧对接人</label>
|
||||
<Input value={contactName} onChange={setContactName} placeholder="如:林晓" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>对接人电话</label>
|
||||
<Input
|
||||
value={contactPhone}
|
||||
onChange={setContactPhone}
|
||||
placeholder="如:138****2210"
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>备注</label>
|
||||
<Input
|
||||
value={remark}
|
||||
onChange={setRemark}
|
||||
placeholder="客户偏好、合作背景等(可选)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{current === 2 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>命名习惯</label>
|
||||
<Input value={naming} onChange={setNaming} />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>目录结构</label>
|
||||
<Input value={directory} onChange={setDirectory} />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>常用规格(逗号分隔)</label>
|
||||
<Input value={specs} onChange={setSpecs} />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>素材禁忌(逗号分隔)</label>
|
||||
<Input value={taboo} onChange={setTaboo} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{current === 3 ? (
|
||||
<div className="lx-client-assetempty">
|
||||
<b>该行业尚未发布可用行业包</b>
|
||||
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并自动替换为客户 Logo
|
||||
与警示语。当前可以先完成客户创建,行业包发布后回客户详情套用。
|
||||
</div>
|
||||
) : null}
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lx-client-formcard__foot">
|
||||
<span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span>
|
||||
<span className="spacer" />
|
||||
{current > 0 ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrent((step) => step - 1);
|
||||
}}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
) : null}
|
||||
{current < 3 ? (
|
||||
<Button type="primary" onClick={next}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" loading={create.isPending} onClick={() => void submit()}>
|
||||
创建客户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CustomerStatus } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const LABELS: Record<CustomerStatus, string> = {
|
||||
active: '在服务',
|
||||
paused: '暂停',
|
||||
ended: '结束',
|
||||
};
|
||||
|
||||
export function CustomerStatusTag({ status }: { status: CustomerStatus }): ReactNode {
|
||||
return <span className={`lx-client-tag lx-client-tag--${status}`}>{LABELS[status]}</span>;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerSummary, Permission } from '@lingxi/shared';
|
||||
import { PageSkeleton } from '@lingxi/ui';
|
||||
import { IconAdd, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useCustomerGroupsQuery,
|
||||
useCustomersQuery,
|
||||
useIndustriesQuery,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { customerDetailPath, customerNewPath, ROUTES } from '@/routes';
|
||||
|
||||
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '在服务' },
|
||||
{ value: 'paused', label: '暂停' },
|
||||
{ value: 'ended', label: '结束' },
|
||||
];
|
||||
|
||||
/** 客户管理(选定方案 A:全宽表格 + 独立详情页) */
|
||||
export function CustomersPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<'all' | 'active' | 'paused' | 'ended'>('all');
|
||||
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||
const [groupId, setGroupId] = useState<string | undefined>();
|
||||
|
||||
const industries = useIndustriesQuery({ limit: 100 });
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const customers = useCustomersQuery({
|
||||
limit: 100,
|
||||
...(keyword.trim() === '' ? {} : { keyword: keyword.trim() }),
|
||||
...(industryId === undefined ? {} : { industry_id: industryId }),
|
||||
...(status === 'all' ? {} : { status }),
|
||||
...(ownerUserId === undefined ? {} : { owner_user_id: ownerUserId }),
|
||||
...(groupId === undefined ? {} : { group_id: groupId }),
|
||||
});
|
||||
|
||||
const industryOptions = useMemo(() => {
|
||||
const options: { value: string; label: string }[] = [];
|
||||
for (const root of industries.data?.items ?? []) {
|
||||
options.push({ value: root.id, label: root.name });
|
||||
for (const child of root.children) {
|
||||
options.push({ value: child.id, label: `${root.name} / ${child.name}` });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [industries.data]);
|
||||
|
||||
const rows = customers.data?.items ?? [];
|
||||
|
||||
const openDetail = (customer: CustomerSummary): void => {
|
||||
void navigate(customerDetailPath(customer.id));
|
||||
};
|
||||
|
||||
if (customers.isPending) return <PageSkeleton rows={8} />;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>客户管理</h1>
|
||||
<p>客户是资产与生产任务的隔离单元;暂停后只读,结束服务后全部数据只读归档</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
onClick={() => void navigate(customerNewPath())}
|
||||
>
|
||||
新建客户
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="lx-client-filters" aria-label="筛选客户">
|
||||
<Input
|
||||
style={{ width: 220 }}
|
||||
value={keyword}
|
||||
prefix={<IconSearch size={16} />}
|
||||
placeholder="搜索客户名称或简称"
|
||||
showClear
|
||||
onChange={setKeyword}
|
||||
aria-label="搜索客户"
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={industryId}
|
||||
placeholder="全部行业"
|
||||
optionList={[{ value: '', label: '全部行业' }, ...industryOptions]}
|
||||
onChange={(value) => {
|
||||
setIndustryId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={status}
|
||||
optionList={STATUS_OPTIONS}
|
||||
onChange={(value) => {
|
||||
setStatus(value as typeof status);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={ownerUserId}
|
||||
placeholder="全部负责人"
|
||||
optionList={[
|
||||
{ value: '', label: '全部负责人' },
|
||||
...(users.data?.items ?? []).map((user) => ({ value: user.id, label: user.name })),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={groupId}
|
||||
placeholder="全部客户组"
|
||||
optionList={[
|
||||
{ value: '', label: '全部客户组' },
|
||||
...(groups.data?.items ?? []).map((group) => ({ value: group.id, label: group.name })),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setGroupId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setIndustryId(undefined);
|
||||
setStatus('all');
|
||||
setOwnerUserId(undefined);
|
||||
setGroupId(undefined);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
共 {rows.length} 个客户
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="lx-client-table-card">
|
||||
{customers.error ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>客户加载失败</b>
|
||||
<span>{customers.error.message}</span>
|
||||
<Button onClick={() => void customers.refetch()}>重试</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无客户</b>
|
||||
<span>按当前筛选条件没有客户,试试重置筛选</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="lx-client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>所属行业</th>
|
||||
<th>状态</th>
|
||||
<th>负责人</th>
|
||||
<th>客户组</th>
|
||||
<th>进入生成</th>
|
||||
<th>基础字段</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((customer) => (
|
||||
<tr
|
||||
key={customer.id}
|
||||
onClick={() => {
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<b>{customer.name}</b>
|
||||
<span className="lx-client-sub">{customer.shortName}</span>
|
||||
</td>
|
||||
<td>{customer.industryPath}</td>
|
||||
<td>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</td>
|
||||
<td>{customer.ownerName}</td>
|
||||
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`lx-client-count${customer.genFieldCount >= 5 ? ' is-full' : ''}`}
|
||||
>
|
||||
{customer.genFieldCount}/5
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="lx-client-count">{customer.baseFieldCount}/10</span>
|
||||
</td>
|
||||
<td className="lx-client-muted">
|
||||
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{customers.error ? null : (
|
||||
<div className="lx-client-tablefoot">
|
||||
<span>客户名称在同一末级行业下唯一;客户不可跨行业移动</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={() => void navigate(ROUTES.system.customerGroups)}
|
||||
>
|
||||
管理客户组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||
import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useCreateIndustryMutation,
|
||||
useDeleteIndustryMutation,
|
||||
useIndustriesQuery,
|
||||
useIndustryDetailQuery,
|
||||
useSetIndustryStatusMutation,
|
||||
useUpdateIndustryMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
|
||||
type ModalState =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'child'; parentId: string }
|
||||
| { kind: 'edit'; industry: IndustryDetail }
|
||||
| null;
|
||||
|
||||
function statusTag(status: string): ReactNode {
|
||||
return (
|
||||
<span className={`lx-client-tag lx-client-tag--${status === 'active' ? 'active' : 'disabled'}`}>
|
||||
{status === 'active' ? '启用' : '停用'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryFormModal({
|
||||
state,
|
||||
parentName,
|
||||
onClose,
|
||||
}: {
|
||||
state: Exclude<ModalState, null>;
|
||||
parentName?: string;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const create = useCreateIndustryMutation();
|
||||
const update = useUpdateIndustryMutation();
|
||||
const isEdit = state.kind === 'edit';
|
||||
const [name, setName] = useState(isEdit ? state.industry.name : '');
|
||||
const [sortOrder, setSortOrder] = useState(isEdit ? String(state.industry.sortOrder) : '10');
|
||||
const [remark, setRemark] = useState(isEdit ? state.industry.remark : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim().length < 2) {
|
||||
setError('行业名称至少 2 个字符');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({
|
||||
id: state.industry.id,
|
||||
input: { name, sortOrder: Number(sortOrder), remark },
|
||||
});
|
||||
notifySuccess('行业已保存');
|
||||
} else {
|
||||
await create.mutateAsync({
|
||||
name,
|
||||
sortOrder: Number(sortOrder),
|
||||
remark,
|
||||
...(state.kind === 'child' ? { parentId: state.parentId } : {}),
|
||||
});
|
||||
notifySuccess('行业已创建');
|
||||
}
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={isEdit ? '编辑行业' : state.kind === 'child' ? '添加二级行业' : '新建一级行业'}
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>
|
||||
行业名称 <i>*</i>
|
||||
</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:电商、服饰鞋包" />
|
||||
{error !== null ? (
|
||||
<span style={{ color: 'var(--lx-color-danger)', fontSize: 12 }}>{error}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{state.kind === 'child' ? (
|
||||
<div className="lx-client-field is-full">
|
||||
<label>上级行业</label>
|
||||
<Input value={parentName ?? ''} disabled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lx-client-field">
|
||||
<label>排序</label>
|
||||
<Input value={sortOrder} onChange={setSortOrder} />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>备注</label>
|
||||
<Input value={remark} onChange={setRemark} placeholder="行业说明(可选)" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryTree({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: {
|
||||
items: readonly IndustryTreeItem[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="lx-client-tree">
|
||||
{items.map((root) => (
|
||||
<div key={root.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`lx-client-tree__row${root.id === activeId ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(root.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-client-tree__name">{root.name}</span>
|
||||
<span className="lx-client-tree__count">{root.children.length}</span>
|
||||
</button>
|
||||
{root.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
className={`lx-client-tree__row is-child${child.id === activeId ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(child.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-client-tree__name">{child.name}</span>
|
||||
<span className="lx-client-tree__count">{child.customerCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryDetailPanel({
|
||||
detail,
|
||||
canManage,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onRefresh,
|
||||
}: {
|
||||
detail: IndustryDetail;
|
||||
canManage: boolean;
|
||||
onAddChild: () => void;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
}): ReactNode {
|
||||
const setStatus = useSetIndustryStatusMutation();
|
||||
const remove = useDeleteIndustryMutation();
|
||||
const toggle = (): void => {
|
||||
Modal.confirm({
|
||||
title: detail.status === 'active' ? '停用行业' : '启用行业',
|
||||
content:
|
||||
detail.status === 'active'
|
||||
? '停用后新建客户不可选择该行业;已有客户数据不受影响。'
|
||||
: '启用后新建客户可以选择该行业。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await setStatus.mutateAsync({
|
||||
id: detail.id,
|
||||
input: { status: detail.status === 'active' ? 'disabled' : 'active' },
|
||||
});
|
||||
notifySuccess(detail.status === 'active' ? '行业已停用' : '行业已启用');
|
||||
onRefresh();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const removeIndustry = (): void => {
|
||||
Modal.confirm({
|
||||
title: '删除行业',
|
||||
content: '仅无子行业、无客户、无资产时可删除;否则只能停用。',
|
||||
okButtonProps: { type: 'danger' },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await remove.mutateAsync(detail.id);
|
||||
notifySuccess('行业已删除');
|
||||
onRefresh();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lx-client-right__head">
|
||||
<div>
|
||||
<h2>
|
||||
{detail.name} {statusTag(detail.status)}
|
||||
</h2>
|
||||
<p>
|
||||
{detail.level === 1 ? '一级行业' : '二级行业'}
|
||||
{detail.parentName !== null ? ` · 上级 ${detail.parentName}` : ''} · 排序{' '}
|
||||
{detail.sortOrder}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
{canManage ? (
|
||||
<>
|
||||
{detail.level === 1 ? <Button onClick={onAddChild}>添加二级</Button> : null}
|
||||
<Button onClick={onEdit}>编辑</Button>
|
||||
<Button onClick={toggle}>{detail.status === 'active' ? '停用' : '启用'}</Button>
|
||||
<Button type="danger" onClick={removeIndustry}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>基本信息</b>
|
||||
</div>
|
||||
<div className="lx-client-kv">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>行业名称</span>
|
||||
<b>{detail.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>层级</span>
|
||||
<b>{detail.level === 1 ? '一级行业' : '二级行业'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>上级行业</span>
|
||||
<b>{detail.parentName ?? '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>状态</span>
|
||||
<b>{statusTag(detail.status)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户数</span>
|
||||
<b>{detail.customerCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>子行业数</span>
|
||||
<b>{detail.references.childCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产数</span>
|
||||
<b>{detail.assetCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>更新时间</span>
|
||||
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
{detail.remark !== '' ? <div className="lx-client-hint">{detail.remark}</div> : null}
|
||||
</section>
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>引用客户</b>
|
||||
<span style={{ fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
{detail.references.customerCount} 个
|
||||
</span>
|
||||
</div>
|
||||
{detail.references.customers.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无客户</div>
|
||||
) : (
|
||||
<div className="lx-client-chips">
|
||||
{detail.references.customers.map((customer) => (
|
||||
<span key={customer.id} className="lx-client-chip">
|
||||
{customer.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="lx-client-hint">
|
||||
删除前需先处理子行业、客户与资产;有启用中的二级行业时禁止停用。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 行业管理(选定方案 B:左两级树 + 右详情面板) */
|
||||
export function IndustriesPage(): ReactNode {
|
||||
const canManage = usePermission(Permission.CUSTOMER_MANAGE);
|
||||
const list = useIndustriesQuery({ limit: 100 });
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const activeId = selectedId ?? list.data?.items[0]?.id ?? null;
|
||||
const detail = useIndustryDetailQuery(activeId);
|
||||
const [modal, setModal] = useState<ModalState>(null);
|
||||
const items = useMemo(() => list.data?.items ?? [], [list.data]);
|
||||
|
||||
const refresh = (): void => {
|
||||
void list.refetch();
|
||||
void detail.refetch();
|
||||
};
|
||||
|
||||
if (list.isPending) return <PageSkeleton rows={8} />;
|
||||
if (list.error) {
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<div className="lx-client-empty">
|
||||
<b>行业加载失败</b>
|
||||
<span>{list.error.message}</span>
|
||||
<Button onClick={() => void list.refetch()}>重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>行业管理</h1>
|
||||
<p>维护两级行业树;客户挂末级行业,删除前需先处理子行业、客户与资产</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setModal({ kind: 'root' });
|
||||
}}
|
||||
>
|
||||
新建一级行业
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-split">
|
||||
<section className="lx-client-left">
|
||||
<div className="lx-client-left__head">
|
||||
<b>行业树</b>
|
||||
<span>
|
||||
两级 · 共 {items.length + items.reduce((sum, item) => sum + item.children.length, 0)}{' '}
|
||||
个
|
||||
</span>
|
||||
</div>
|
||||
<div className="lx-client-left__body">
|
||||
{items.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无行业</b>
|
||||
<span>先创建一级行业,再添加二级行业</span>
|
||||
</div>
|
||||
) : (
|
||||
<IndustryTree items={items} activeId={activeId} onSelect={setSelectedId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="lx-client-left__foot">
|
||||
一级 {items.length} · 二级 {items.reduce((sum, item) => sum + item.children.length, 0)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="lx-client-right">
|
||||
{detail.isPending || activeId === null ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>选择行业</b>
|
||||
<span>从左侧选择一级或二级行业查看详情</span>
|
||||
</div>
|
||||
) : detail.error ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>行业详情加载失败</b>
|
||||
<span>{detail.error.message}</span>
|
||||
<Button onClick={() => void detail.refetch()}>重试</Button>
|
||||
</div>
|
||||
) : (
|
||||
<IndustryDetailPanel
|
||||
detail={detail.data}
|
||||
canManage={canManage}
|
||||
onAddChild={() => {
|
||||
setModal({ kind: 'child', parentId: detail.data.id });
|
||||
}}
|
||||
onEdit={() => {
|
||||
setModal({ kind: 'edit', industry: detail.data });
|
||||
}}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{modal !== null ? (
|
||||
<IndustryFormModal
|
||||
state={modal}
|
||||
parentName={
|
||||
modal.kind === 'child'
|
||||
? items.find((item) => item.id === modal.parentId)?.name
|
||||
: undefined
|
||||
}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 供路由复用的空态 */
|
||||
export function IndustriesEmpty(): ReactNode {
|
||||
return <div className="lx-client-empty">暂无行业</div>;
|
||||
}
|
||||
@@ -44,6 +44,36 @@ const RoleNewPage = lazy(async () => {
|
||||
return { default: module.RoleNewPage };
|
||||
});
|
||||
|
||||
const IndustriesPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/industries/IndustriesPage');
|
||||
return { default: module.IndustriesPage };
|
||||
});
|
||||
|
||||
const CustomersPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomersPage');
|
||||
return { default: module.CustomersPage };
|
||||
});
|
||||
|
||||
const CustomerNewPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomerNewPage');
|
||||
return { default: module.CustomerNewPage };
|
||||
});
|
||||
|
||||
const CustomerDetailPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomerDetailPage');
|
||||
return { default: module.CustomerDetailPage };
|
||||
});
|
||||
|
||||
const CustomerGroupsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customer-groups/CustomerGroupsPage');
|
||||
return { default: module.CustomerGroupsPage };
|
||||
});
|
||||
|
||||
const CustomerFieldsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customer-fields/CustomerFieldsPage');
|
||||
return { default: module.CustomerFieldsPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
@@ -134,6 +164,54 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/industries',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<IndustriesPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<CustomersPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers/new',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_MANAGE}>
|
||||
{withSkeleton(<CustomerNewPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<CustomerDetailPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customer-groups',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||
{withSkeleton(<CustomerGroupsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customer-fields/approvals',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_FIELD_APPROVE}>
|
||||
{withSkeleton(<CustomerFieldsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':moduleKey/:itemKey?',
|
||||
lazy: async () => ({
|
||||
|
||||
@@ -16,6 +16,12 @@ export const ROUTES = {
|
||||
roles: '/system/roles',
|
||||
roleNew: '/system/roles/new',
|
||||
roleDetail: '/system/roles/:id',
|
||||
industries: '/system/industries',
|
||||
customers: '/system/customers',
|
||||
customerNew: '/system/customers/new',
|
||||
customerDetail: '/system/customers/:id',
|
||||
customerGroups: '/system/customer-groups',
|
||||
customerFields: '/system/customer-fields/approvals',
|
||||
},
|
||||
unauthorized: '/401',
|
||||
forbidden: '/403',
|
||||
@@ -34,6 +40,12 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.roles
|
||||
| typeof ROUTES.system.roleNew
|
||||
| typeof ROUTES.system.roleDetail
|
||||
| typeof ROUTES.system.industries
|
||||
| typeof ROUTES.system.customers
|
||||
| typeof ROUTES.system.customerNew
|
||||
| typeof ROUTES.system.customerDetail
|
||||
| typeof ROUTES.system.customerGroups
|
||||
| typeof ROUTES.system.customerFields
|
||||
| typeof ROUTES.unauthorized
|
||||
| typeof ROUTES.forbidden
|
||||
| typeof ROUTES.notFound
|
||||
@@ -49,6 +61,16 @@ export function roleDetailPath(roleId: string): string {
|
||||
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
|
||||
}
|
||||
|
||||
/** 客户详情深链:`/system/customers/{id}` */
|
||||
export function customerDetailPath(customerId: string): string {
|
||||
return `${ROUTES.system.customers}/${encodeURIComponent(customerId)}`;
|
||||
}
|
||||
|
||||
/** 新建客户(四步向导) */
|
||||
export function customerNewPath(): string {
|
||||
return ROUTES.system.customerNew;
|
||||
}
|
||||
|
||||
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
|
||||
export function roleNewPath(sourceRoleId?: string): string {
|
||||
return sourceRoleId === undefined
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
/* A3 客户与行业页面(混搭布局:客户域 A / 行业与客户组 B / 字段审批 A) */
|
||||
.lx-client-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.lx-client-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.lx-client-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lx-client-header p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--lx-text-2, #8f959e);
|
||||
}
|
||||
.lx-client-header__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.lx-client-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lx-client-split {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lx-client-left {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--lx-border, #e5e8ef);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-left__head {
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-left__head b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.lx-client-left__head span {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-left__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.lx-client-left__foot {
|
||||
height: 40px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--lx-border, #e5e8ef);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-right__head {
|
||||
flex: none;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.lx-client-right__head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.lx-client-right__head p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-right__head .lx-client-header__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lx-client-right__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.lx-client-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.lx-client-tree__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #646a73;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.lx-client-tree__row:hover {
|
||||
background: #f2f4f7;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-tree__row.is-active {
|
||||
background: #e8f1ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.lx-client-tree__row.is-child {
|
||||
padding-left: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.lx-client-tree__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lx-client-tree__count {
|
||||
font-size: 11px;
|
||||
color: #8f959e;
|
||||
}
|
||||
.lx-client-tree__row.is-active .lx-client-tree__count {
|
||||
color: #1677ff;
|
||||
}
|
||||
.lx-client-table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 24px;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-empty b {
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.lx-client-anchors {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.lx-client-anchors a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-anchors a:hover {
|
||||
background: #f2f4f7;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.lx-client-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.lx-client-sidecard {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.lx-client-sidecard h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lx-client-kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 28px;
|
||||
}
|
||||
.lx-client-kv__item span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-kv__item b {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lx-client-section {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-section:last-child {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.lx-client-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.lx-client-section__head b {
|
||||
font-size: 14px;
|
||||
}
|
||||
.lx-client-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.lx-client-tag--active {
|
||||
background: #e8ffea;
|
||||
color: #008a24;
|
||||
}
|
||||
.lx-client-tag--paused {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-tag--ended {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-tag--disabled {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-banner--paused {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-banner--ended {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.lx-client-chip {
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #eef1f6;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.lx-client-newgrid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lx-client-stepnav {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.lx-client-formcard {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-formcard__body {
|
||||
padding: 20px 22px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.lx-client-formcard__foot {
|
||||
height: 60px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-formcard__foot .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.lx-client-formgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
}
|
||||
.lx-client-formgrid .is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.lx-client-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.lx-client-field > label {
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-field > label i {
|
||||
color: #f53f3f;
|
||||
font-style: normal;
|
||||
}
|
||||
.lx-client-memberlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.lx-client-memberrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.lx-client-memberrow b {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lx-client-memberrow span {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-assetempty {
|
||||
border: 1px dashed #d0d5dd;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-assetempty b {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.lx-client-grid {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
}
|
||||
.lx-client-grid .lx-client-side {
|
||||
display: none;
|
||||
}
|
||||
.lx-client-left {
|
||||
width: 330px;
|
||||
}
|
||||
}
|
||||
.lx-client-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.lx-client-table th {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
text-align: left;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lx-client-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
color: #646a73;
|
||||
vertical-align: middle;
|
||||
line-height: 20px;
|
||||
}
|
||||
.lx-client-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.lx-client-table tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
.lx-client-table td b {
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-muted {
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lx-client-count.is-full {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-tablefoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-tablefoot .semi-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lx-client-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.lx-client-back:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
@@ -652,13 +652,13 @@ flowchart LR
|
||||
|
||||
#### 5.4.2 客户管理
|
||||
|
||||
**行业与客户是资产划分的两级目录**:行业 = 客户的业务属性(电商 / 金融等),客户 = 行业下的具体服务对象。信息模块以「行业 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。
|
||||
**行业与客户是资产划分的两级目录**:行业本身为两级树(一级行业 = 业务属性,如电商 / 金融;二级行业 = 细分分类,如服饰鞋包 / 信贷),客户挂末级行业。信息模块以「行业树 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。
|
||||
|
||||
最小集:客户名称 / 简称(**简称必填**,用于全部自动命名)、所属行业(绑定一个行业包,可申请客户级覆盖)、服务状态(在服务 / 暂停 / 结束)、内部负责人与客户侧对接人、交付偏好(命名习惯、目录结构、常用规格、素材禁忌)、客户资产包、自定义字段(分两层,见下)。
|
||||
|
||||
- **自定义字段分两层**:基础信息字段由运营录入时自由追加键值对(如门店编号、内部产品代码),每客户 ≤ 10 个,**只用于检索与报表,永不进入生成**;需要进入生成或参与校核的字段走 5.4.9 审批(≤ 5 个)。
|
||||
- **唯一性**:行业名称全局唯一;客户名称在同一行业内唯一。
|
||||
- **不可跨行业移动**:客户必须归属一个行业,调整行业走「停用 + 新建」;删除行业前须校验其下无客户、无资产,否则只能停用。
|
||||
- **唯一性**:一级行业名称全局唯一;二级行业名称在同一父级下唯一(不同一级行业下可重名);客户名称在同一末级行业下唯一。
|
||||
- **不可跨行业移动**:客户必须归属一个末级行业,调整行业走「停用 + 新建」;删除行业前须校验其下无子行业、无客户、无资产,否则只能停用;有启用中的子行业时禁止停用。
|
||||
- **新建客户时可套用行业资产包**:选行业 → 带出模板、场景、人物、提示词、卖点 → 替换为客户 Logo 与警示语。这是"新客户冷启动 ≤ 1 个工作日"的落地方式。
|
||||
- **客户组**:把客户按行业 / 团队 / 服务等级分组;客户组是批量授权与数据范围的最小单位,避免逐个客户分配权限(见 5.4.3)。
|
||||
- 客户暂停时只读,不能发起新生成;结束时全部只读,资产转"只读资产"。
|
||||
@@ -785,9 +785,10 @@ flowchart LR
|
||||
| 脚本方向 | 口播 / 剧情 / 测评 / 种草 / 情景…,可按行业收窄 |
|
||||
| 情绪与风格 | 亲和 / 专业 / 激情 / 幽默 / 悬念 |
|
||||
| 人群标签 | 年龄 / 性别 / 身份 / 消费力,可扩展 |
|
||||
| 行业二级 | 电商 → 服饰鞋包 / 美妆个护…;金融 → 信贷 / 保险 / 理财 |
|
||||
| 素材形态 | 展示 / 口播 / 剧情 / 图文动效 / 信息图 |
|
||||
|
||||
> 注:「行业二级」不再作为字典项,改由 `industries` 两级行业树承载(ADR-002);其余字典口径不变。
|
||||
|
||||
**变更规则**:字典变更**只影响新数据,历史数据保留快照**;否则改一次字典,历史统计全部失真。
|
||||
|
||||
**通用枚举模型**:所有字典共用一张枚举表(类型 + 编码 + 名称 + 父编码 + 所属行业 + 排序 + 启用),一张表同时承载行业二级树与受控词表;编码不可改、名称可改;按行业作用域继承——查询取「本行业 + 父行业 + 通用」三层合并,行业包只覆盖差异项。
|
||||
@@ -948,7 +949,7 @@ flowchart LR
|
||||
| R-17 | 超级管理员保护 | 超管角色不可删改停用;授予超管须二次确认并审计;建议 ≤ 2 人 |
|
||||
| R-18 | 职责互斥 | 生成与审核、行业包编辑与发布、字段申请与审批不可同属一个角色(超级管理员除外) |
|
||||
| R-19 | 视频方向与脚本方向边界 | 脚本方向决定"说什么"(钩子与结构),视频方向决定"怎么呈现"(口播 / 剧情 / 产品展示);二者并列、不合并 |
|
||||
| R-20 | 行业 / 客户目录 | 行业是客户的业务属性,两级目录为全部资产的归属根节点;客户不可跨行业移动,行业名称全局唯一、客户名称同一行业内唯一 |
|
||||
| R-20 | 行业 / 客户目录 | 行业本身为两级树(一级行业 → 二级行业),客户挂末级行业;行业与客户构成资产归属的两级目录;客户不可跨行业移动,一级行业名称全局唯一、二级行业名称在同一父级下唯一、客户名称在同一末级行业下唯一 |
|
||||
| R-21 | 脚本编号唯一 | 过审入库时按 `客户编码-YYMMDD+序号` 原子分配;草稿不占号;编号一经分配不重用、不可改 |
|
||||
| R-22 | 客户审核链接安全 | 免登录链接须签名 + 有效期 + 限范围(只读 / 可改稿);链接可随时作废;改稿保留原稿快照与操作人 |
|
||||
| R-23 | 提示词与模型配置版本化 | 保存即快照、可回滚;任务创建时记录所用版本号,事后可复现「当时为什么生成成这样」 |
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# ADR-002 行业目录:由单层改为两级树,客户挂末级行业
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已接受(2026-11-03,用户确认 A3 口径) |
|
||||
| 决策人 | 产品 / 技术负责人(用户) |
|
||||
| 记录人 | 工程 Lead |
|
||||
| 关联 | 开发计划 V0.1 §6.4 A3、§7.4、§12.1 M0 DoD、附录 C;PRD V1.6 §5.4.2 / §5.4.5、R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-001 |
|
||||
| 影响范围 | `client` 模块、`industry` 模块(行业包绑定)、`catalog` / `script` / `media` / `asset` 的行业维度、数据范围与统计报表、A4 字典口径、开发计划与 PRD/架构文档 |
|
||||
|
||||
## 背景
|
||||
|
||||
PRD R-20 与技术架构 §5.3 均把 `industries` 描述为**单层行业目录**(「行业 = 客户的业务属性」,名称全局唯一),「行业二级(电商 → 服饰鞋包 / 美妆个护)」在 PRD §5.4.5 中作为 A4 字典项处理;开发计划 §6.4 A3-BE-01 也按单层 `industries` 描述。
|
||||
|
||||
A3 开卡前用户确认:行业目录需要**两级树**(一级行业 → 二级行业),客户归属到末级行业。该口径与既有文档冲突,且影响客户归属、行业包匹配、数据范围、统计与 A4 字典边界,属于 AGENTS §0.4 要求先提 ADR 的决策。
|
||||
|
||||
## 决策
|
||||
|
||||
### D1 行业为两级树,最多两级
|
||||
|
||||
`industries` 表增加:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `parent_id` | ulid FK → industries,可空 | 一级行业为空;二级行业指向一级行业 |
|
||||
| `level` | smallint | 1 / 2;由服务端按 `parent_id` 推导并校验 |
|
||||
| `sort_order` | int | 同级排序,默认 10 |
|
||||
| `status` | varchar | 启用 / 停用 |
|
||||
| `remark` | varchar(200) | 备注 |
|
||||
|
||||
约束:
|
||||
|
||||
- 仅允许两级;二级行业下不可再建子行业(应用层校验 + 数据库可选触发器兜底)。
|
||||
- 一级行业 `parent_id IS NULL AND level = 1`;二级行业 `parent_id NOT NULL AND level = 2`。
|
||||
- 客户只挂末级行业,详见 D2。
|
||||
|
||||
### D2 客户挂末级行业,行业包按祖先回退
|
||||
|
||||
- 客户 `industry_id` 指向**末级行业**:有子行业的一级行业不可被客户选择;一级行业无子行业时可直接选择。
|
||||
- 客户创建后不可跨行业移动;调整行业走「停用 + 新建」(PRD R-20 原口径不变)。
|
||||
- 行业包匹配按「精确行业 → 一级行业 → 默认」回退(B0 落地);行业顾问/行业指南同样按「二级 → 一级 → default → 内置准则」匹配(PRD §5.4.7 / §7.x)。
|
||||
- 行业级资产包、行业级字段覆盖等按「客户所属末级行业 → 一级行业」继承。
|
||||
|
||||
### D3 行业名称唯一性:一级全局唯一 + 二级同父唯一
|
||||
|
||||
- 一级行业名称全局唯一。
|
||||
- 二级行业名称在同一 `parent_id` 下唯一;不同一级行业下允许重名(如「其他」)。
|
||||
- 客户名称仍在同一末级行业下唯一(`(industry_id, name)` 唯一,原口径不变)。
|
||||
|
||||
### D4 删除与停用:只停用 + 引用保护
|
||||
|
||||
- 删除:无子行业、无客户、无资产时才可删除;否则只能停用。
|
||||
- 停用:有启用中的子行业时禁止停用,提示先处理子级;有客户时允许停用,停用后新建客户不可选该行业,已有客户数据不受影响。
|
||||
- 停用一级行业不级联停用子级;需逐级处理,避免一次操作影响面过大。
|
||||
|
||||
### D5 权限复用现有 30 个权限点
|
||||
|
||||
A3 行业管理不新增权限点:
|
||||
|
||||
- 查看:`customer:read`;写(新建 / 编辑 / 停用 / 删除):`customer:manage`。
|
||||
- 开发计划附录 C 写的 `industry_pack:view` 在 A2 定稿的 30 个权限点中不存在,按本 ADR 回写为 `customer:read`;`industry:manage` 仍表示行业包编辑(B0)。
|
||||
|
||||
### D6 「行业二级」从 A4 字典中移除
|
||||
|
||||
PRD §5.4.5 的「行业二级」字典项改由 `industries` 两级树承载;A4 字典保留其他受控词表。A4-BE-01 的「三层合并(本行业 + 父行业 + 通用)」中的「行业作用域」按两级树的祖先链解释。
|
||||
|
||||
## 备选方案
|
||||
|
||||
| 方案 | 结论 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 保持单层行业(PRD/架构原文) | 未采用 | 用户明确要求两级;单层无法表达「电商 → 服饰鞋包」的行业分类 |
|
||||
| 单层落地 + 预留 `parent_id` | 未采用 | 本期 UI 与客户选择就要两级联动,预留字段会造成前后端口径分叉 |
|
||||
| 行业二级放 A4 字典 | 未采用 | 字典项没有行业归属/状态/引用保护,且客户 `industry_id` 无法稳定指向二级;A3 就需要行业树维护 |
|
||||
| **两级树 + 客户挂末级(本 ADR)** | 采用 | 与用户口径一致;行业包按祖先回退,保留单层时期的继承语义 |
|
||||
|
||||
## 后果
|
||||
|
||||
### 正面
|
||||
|
||||
- 行业分类可表达一级/二级,客户归属更精确;行业包可在任一级配置并向下继承。
|
||||
- 行业二级不再依赖 A4 字典,A3 即可交付完整行业树维护。
|
||||
- 权限点不新增,复用 A2 定稿目录,治理成本低。
|
||||
|
||||
### 负面 / 代价
|
||||
|
||||
- 与 PRD R-20、技术架构 `industries`、开发计划 A3/A4/M0 DoD 的原文冲突,必须同步修订,否则文档与实现不一致。
|
||||
- 客户归属校验从「行业存在」升级为「行业存在且为末级」,A2 `CustomerDirectory` 与数据范围解析需按末级行业返回客户。
|
||||
- 行业统计、报表、行业包匹配需处理祖先链,查询复杂度上升。
|
||||
- 历史数据若已按单层行业写入(当前本地库无行业数据),需 `parent_id` 置空并标记为一级行业;A3 首次建表,不涉及存量迁移。
|
||||
|
||||
### 迁移 / 兼容策略
|
||||
|
||||
A3 首次创建 `industries` 表即按两级结构落地,采用 expand → migrate → contract:
|
||||
|
||||
1. 建表含 `parent_id` / `level` / `sort_order` / `status` / `remark`;
|
||||
2. 迁移脚本对存量行业(如有)设 `parent_id = NULL, level = 1`;
|
||||
3. 应用层先写 `level`,后续若需严格约束再补触发器 / 检查约束;
|
||||
4. 回滚:删除新增列即可回到单层语义(客户 `industry_id` 仍指向原行业)。
|
||||
|
||||
## 待办 / 回写清单
|
||||
|
||||
| 文件 | 章节 | 需要改成什么 |
|
||||
| --- | --- | --- |
|
||||
| `docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md` | R-20 | 明确行业两级树、客户挂末级、一级全局唯一 / 二级同父唯一 |
|
||||
| 同上 | §5.4.2 | 客户所属行业改为末级行业;补充两级树与停用/删除规则 |
|
||||
| 同上 | §5.4.5 | 「行业二级」从字典表移除,注明由 `industries` 两级树承载 |
|
||||
| 同上 | §5.4.7 / §7.x 行业指南 | 明确「二级 → 一级 → default」回退 |
|
||||
| `docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md` | §5.3 表清单 | `industries` 增加 `parent_id` / `level`,约束改为「最多两级、一级全局唯一、二级同父唯一」 |
|
||||
| 同上 | §5.4.1 `customers` | `industry_id` 指向末级行业;补充「客户不可挂有子行业的一级行业」 |
|
||||
| 同上 | §6.3.2 | 行业接口权限改为 `customer:read` / `customer:manage`;补充两级树与删除/停用校验 |
|
||||
| `docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md` | §6.4 A3-BE-01 | 改为两级行业树;删除/停用规则按 ADR-002 D4 |
|
||||
| 同上 | §7.4 | 页面口径补充「一级/二级树、客户挂末级」 |
|
||||
| 同上 | §12.1 M0 DoD | 「行业两级」改为「行业两级树(一级 → 二级),客户挂末级」 |
|
||||
| 同上 | 附录 C | `/system/industries` 权限改为 `customer:read` / `customer:manage` |
|
||||
| `docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md` | §5 遗留 1 | 注明 A3 接入真实客户目录时按末级行业返回客户 |
|
||||
| `README.md` / `AGENTS.md` | 文档索引 / 事实基线 | 视需要补充 ADR-002 与行业口径变更 |
|
||||
|
||||
> 文档同步按 AGENTS §0.4「先改文档,再改代码」执行;本 ADR 先于 A3 后端实现落地。
|
||||
|
||||
## 关联
|
||||
|
||||
- [ADR-001 权限点目录与六类数据范围](ADR-001-权限点目录与六类数据范围.md)
|
||||
- 开发计划 V0.1 §6.4 A3、§7.4、§12.1
|
||||
- PRD V1.6 R-20、§5.4.2、§5.4.5
|
||||
- 技术架构 V0.4 §5.3、§5.4.1、§6.3.2
|
||||
- 前端原型:`design/a3-ui-v1/`(三套候选,待用户选择)
|
||||
@@ -6,6 +6,7 @@
|
||||
| 编号 | 决策 | 状态 | 日期 | 关联 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 |
|
||||
| [ADR-002](ADR-002-两级行业目录.md) | 行业目录由单层改为两级树(一级 → 二级),客户挂末级行业,一级全局唯一 / 二级同父唯一,只停用 + 引用保护,「行业二级」从 A4 字典移除 | 已接受 | 2026-11-03 | A3 / A4 / B0 |
|
||||
|
||||
## 新增流程
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# 灵犀 A3 客户与行业目录 · 实施报告 V0.1
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 实施日期 | 2026-11-03 |
|
||||
| 批次 | A3(客户与行业目录) |
|
||||
| 状态 | 已完成,门禁通过;未推送 |
|
||||
| 依据 | 开发计划 V0.1 §6.4 / §7.4 / §12.1 / 附录 C;PRD V1.6 §5.4.2 / §5.4.9、R-07 / R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-002 |
|
||||
| 决策记录 | [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) |
|
||||
| 视觉基线 | A0「方向 A · 云白蓝」;A3 用户选定混搭:**行业管理 B / 客户列表 A / 客户详情 A / 新建客户 B / 客户组 B / 字段审批 A** |
|
||||
|
||||
---
|
||||
|
||||
## 1 范围与完成度
|
||||
|
||||
| 任务 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| A3-BE-01 | 两级行业树(一级 → 二级)、名称唯一、停用/删除引用保护 | ✅ |
|
||||
| A3-BE-02 | 客户档案、简称必填、同末级行业唯一、状态机(在服务 / 暂停 / 结束) | ✅ |
|
||||
| A3-BE-03 | 客户组与成员,客户组作为数据范围最小单位 | ✅ |
|
||||
| A3-BE-04 | 客户级字段申请 / 审批,进入生成 ≤ 5,超限 40001 | ✅ |
|
||||
| A3-BE-05 | 客户上下文与归属校验,跨客户 404;真实 `CustomerDirectory` | ✅ |
|
||||
| A3-FE-01 | 行业管理(B:左两级树 + 右详情) | ✅ |
|
||||
| A3-FE-02 | 客户列表 / 详情(A)、四步新建(B) | ✅ |
|
||||
| A3-FE-03 | 客户组(B:左列表 + 右成员) | ✅ |
|
||||
| A3-FE-04 | 字段申请与审批(A:全宽表格 + 审批弹窗) | ✅ |
|
||||
| A3-QA-01 | 唯一性 / 状态 / 范围 / 字段上限用例 + 浏览器 e2e | ✅ |
|
||||
|
||||
> 用户确认口径:两级行业树、客户挂末级行业、一级全局唯一 + 二级同父唯一、只停用 + 引用保护、字段双层、资产包留入口、客户转交纳入 A3、复用现有 30 个权限点。
|
||||
|
||||
---
|
||||
|
||||
## 2 交付物
|
||||
|
||||
### 2.1 文档与决策
|
||||
|
||||
- 新增 [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) 并登记 ADR 索引;含 PRD / 技术架构 / 开发计划回写清单。
|
||||
- 同步 PRD R-20 / §5.4.2 / §5.4.5、技术架构 §5.3 / §5.4.1 / §6.3.2、开发计划 §6.4 / §7.4 / §12.1 / 附录 C。
|
||||
- 前端原型:`design/a3-ui-v1/`(三套候选 + 独立评审 + 用户选定混搭);实现截图:`design/a3-ui-v1/impl/`。
|
||||
|
||||
### 2.2 共享契约(`packages/shared`)
|
||||
|
||||
- `schemas/client.ts`:行业两级树 / 引用、客户档案与状态、客户组、客户级字段、转交、列表 query;写接口 `.strict()`。
|
||||
- 上限常量 `CUSTOMER_BASE_FIELD_LIMIT = 10`、`CUSTOMER_GEN_FIELD_LIMIT = 5`(A4 迁入 `system_configs`,R-26)。
|
||||
- `schemas/client.spec.ts`:行业 / 客户 / 客户组 / 字段契约覆盖。
|
||||
|
||||
### 2.3 数据库与种子
|
||||
|
||||
- 迁移 `prisma/migrations/20261103130048_a3_client_industry_customer/`(expand):
|
||||
- `industries`:`parent_id` / `level` / `sort_order` / `status` / `remark`;部分唯一索引(一级全局唯一、二级同父唯一);两级 CHECK 与非自引用 CHECK;
|
||||
- `customers`:`industry_id`、`short_name`、状态、负责人、对接人、偏好 JSONB、基础字段 JSONB、软删除;
|
||||
- `customer_groups` / `customer_group_members`;
|
||||
- `customer_fields`:进入生成与审批状态、申请人 / 审批人、驳回原因。
|
||||
- `prisma/seed.ts`:幂等写入 电商(服饰鞋包 / 美妆个护)、金融(信贷)、2 个客户、1 个客户组、4 条客户字段(1 条待审批)。
|
||||
- `prisma/SEED.md` 待同步(见遗留)。
|
||||
|
||||
### 2.4 后端(`apps/api/src/modules/client`)
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET/POST /industries`、`GET/PATCH/DELETE /industries/{id}`、`PATCH /industries/{id}/status` | 两级树、引用、停用/删除保护 |
|
||||
| `GET/POST /customers`、`GET/PATCH /customers/{id}`、`PATCH /customers/{id}/status` | 列表(ScopeContext 客户维度)、详情(跨客户 404)、状态机 |
|
||||
| `POST /customers/{id}/transfer`、`PUT /customers/{id}/groups`、`POST /customers/{id}/apply-industry-pack` | 转交、客户组、B0 空态 |
|
||||
| `GET/POST /customer-groups`、`GET/PATCH/DELETE /customer-groups/{id}`、`POST /customer-groups/{id}/members` | 客户组与成员幂等 |
|
||||
| `GET /customer-fields`、`POST /customer-fields/{id}/approve`、`GET/POST /customers/{id}/fields` | 审批队列、通过/驳回(驳回原因必填) |
|
||||
| `POST /users/{id}/transfer-customers` | 保留 A1 路径,由 client 模块落地批量转交 |
|
||||
|
||||
- 真实 `PrismaCustomerDirectory` 替换 `EmptyCustomerDirectory`;六类数据范围解析接入客户 / 客户组 / 部门 / 创建人。
|
||||
- 客户上下文:所有客户读写携带 `ScopeContext`;跨客户统一 404;客户组删除前校验 `data_scopes` 引用。
|
||||
- 审计:行业 / 客户 / 客户组 / 字段 / 转交均带 `@Audit()` 结构化日志(A5 落库)。
|
||||
- 测试:`client.int.spec.ts` 6 条(行业唯一/停用停用、客户唯一/跨客户/状态机、客户组范围、字段审批/超限、转交、状态只读)。
|
||||
|
||||
### 2.5 前端(`apps/web`)
|
||||
|
||||
| 页面 | 路由 | 选定布局 |
|
||||
| --- | --- | --- |
|
||||
| 行业管理 | `/system/industries` | B:左两级树 + 右详情(白底卡片) |
|
||||
| 客户列表 | `/system/customers` | A:全宽表格 + 筛选条 |
|
||||
| 客户详情 | `/system/customers/:id` | A:左锚点 + 中列 + 右侧状态 |
|
||||
| 新建客户 | `/system/customers/new` | B:左侧步骤 + 右侧表单,四步可回退 |
|
||||
| 客户组 | `/system/customer-groups` | B:左列表 + 右成员 |
|
||||
| 客户字段审批 | `/system/customer-fields/approvals` | A:全宽表格 + 通过/驳回弹窗 |
|
||||
|
||||
- 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `customer_field:approve`。
|
||||
- 样式:`styles/client.css` 复用 `--lx-*` token;未新增依赖、未内联 SVG(图标走 `@lingxi/ui/icons`)。
|
||||
- API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。
|
||||
|
||||
---
|
||||
|
||||
## 3 验证结果
|
||||
|
||||
| 门禁 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 格式 | `pnpm format:check` | ✅ |
|
||||
| Lint | `pnpm lint` | ✅ 0 warning |
|
||||
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
|
||||
| 单测 | `pnpm test` | ✅ shared 131 / api 90 / web 125,共 346 |
|
||||
| 集成 | `pnpm test:int` | ✅ 5 文件 / 39 条(含 A3 client 6 条) |
|
||||
| 浏览器 e2e | `A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts` | ✅ 1 passed(11.6s) |
|
||||
| 构建 | `pnpm build` | ✅ 8/8 tasks |
|
||||
| OpenAPI | `pnpm openapi:export` | ✅ 新增 industries / customers / customer-groups / customer-fields 路径 |
|
||||
|
||||
浏览器验证(真实 API + PG + Chrome):登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户 → 客户组 → 字段审批六页均正常渲染;控制台仅 favicon 404。截图:`design/a3-ui-v1/impl/`。
|
||||
|
||||
---
|
||||
|
||||
## 4 关键口径与实现说明
|
||||
|
||||
1. **两级行业**:`level` 由 `parent_id` 推导;数据库 CHECK 保证「parent 为空 ⇔ level=1」,服务层禁止二级下再建子级;名称唯一通过部分唯一索引兜底。
|
||||
2. **客户挂末级**:`assertLeafIndustry` 校验;客户更新不接收 `industryId`,跨行业移动走停用 + 新建。
|
||||
3. **状态机**:在服务 ⇄ 暂停;在服务 / 暂停 → 结束;结束不可逆。暂停 / 结束客户更新、字段申请、转交均返回 40903。
|
||||
4. **字段双层**:基础字段在 `customers.custom_fields` JSONB(≤10,免审批);`customer_fields` 只承载结构化 / 进入生成字段,进入生成需审批,按「占用槽位」计数(含待审批、不含已驳回)。
|
||||
5. **数据范围**:`CustomerDirectory` 真实实现;客户列表按 `customerIds` 过滤;单条跨客户读统一 404;客户组数据范围用例已验证。
|
||||
6. **客户转交**:`/customers/{id}/transfer` 需 `customer:transfer`;`/users/{id}/transfer-customers` 保留 A1 路径,允许 `customer:transfer` 或 `user:manage`。
|
||||
7. **资产包**:`POST /customers/{id}/apply-industry-pack` 当前返回 42201「B0 批次提供」,前端显示空态,不伪造数据。
|
||||
|
||||
---
|
||||
|
||||
## 5 已知差异与遗留
|
||||
|
||||
| 编号 | 项 | 说明 / 建议 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 行业资产包套用 | 依赖 B0/B1,当前接口返回明确业务提示 + 前端空态;B0 落地后接通。 |
|
||||
| 2 | 审计落库 | A3 变更走 `@Audit()` 结构化 Pino 日志;审计表、检索、导出按 A5。 |
|
||||
| 3 | 字段上限配置 | 共享常量 `10 / 5`;A4 `system_configs` 落地后迁入,前端上限读配置接口(R-26)。 |
|
||||
| 4 | 客户级字段类型 | 仅白名单 文本 / 数字 / 日期 / 单选;选项在原型中简化,完整枚举来源随 A4 字典。 |
|
||||
| 5 | 行业资产数 | `assetCount` 固定 0;B1 资产表落地后接入。 |
|
||||
| 6 | 客户列表分页 | 前端一次拉 100 条(A3 规模);B2 后按大数据量补游标分页 UI。 |
|
||||
| 7 | 客户详情面包屑 | 由导航派生,末级显示「客户管理」而非客户名;与 A2 角色编辑器同源问题,后续统一。 |
|
||||
| 8 | 状态截图 | 原型阶段 A/B/C 状态图已覆盖;实现截图以主态 + e2e 为准,暂停/结束状态由 API int + e2e 覆盖。 |
|
||||
| 9 | `prisma/SEED.md` | 需补 A3 行业 / 客户 / 客户组 / 字段种子说明(本次未改)。 |
|
||||
| 10 | `AppModule` 的 worker | `pnpm dev` 下 worker 因 `REDIS_URL` 缺失退出,API/Web 正常;A0 遗留,非 A3 范围。 |
|
||||
|
||||
---
|
||||
|
||||
## 6 回滚方式
|
||||
|
||||
1. 代码:回滚本批 commit(client 模块、前端页面、shared schema、路由 / 导航、迁移文件、seed、ADR 与文档)。
|
||||
2. 数据库:A3 为新增表,回滚可保留表(不影响 A1/A2);如需彻底回滚,执行 down SQL 删除 `customer_fields → customer_group_members → customer_groups → customers → industries`(先备份种子数据)。
|
||||
3. 配置:`CUSTOMER_DIRECTORY` 改回 `EmptyCustomerDirectory` 即恢复 A2 空客户集行为(需同时回滚 client 模块 provider)。
|
||||
4. 前端:回滚路由 / 导航与 `pages/system/{industries,customers,customer-groups,customer-fields}`,恢复「客户 / 行业树」旧菜单。
|
||||
|
||||
---
|
||||
|
||||
## 7 本地运行
|
||||
|
||||
```bash
|
||||
pnpm check:dev
|
||||
pnpm db:init
|
||||
pnpm dev
|
||||
# 浏览器 http://127.0.0.1:5173 → 管理中心 / 行业管理、客户管理、客户组、客户字段
|
||||
```
|
||||
|
||||
A3 e2e(需先起 `pnpm dev`):
|
||||
|
||||
```bash
|
||||
A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||||
```
|
||||
@@ -10,7 +10,7 @@
|
||||
| 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 |
|
||||
| 版本 | V0.1(初稿,待评审) |
|
||||
| 日期 | 2026-11-02 |
|
||||
| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);下一步 A3 |
|
||||
| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);A3 客户与行业目录已完成(2026-11-03);下一步 A4 |
|
||||
| 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) |
|
||||
| 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 |
|
||||
| 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** |
|
||||
@@ -381,7 +381,7 @@ flowchart LR
|
||||
| **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 |
|
||||
| **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 |
|
||||
| **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 |
|
||||
| **A3** | 未开始 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
|
||||
| **A3** | ✅ 已完成 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
|
||||
| **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 |
|
||||
| **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 |
|
||||
| **A6** | 未开始 | 任务中心与队列骨架 | A0 / A1 | 6 | 4–5 日 | W2–W3 | 提交 → SSE → 完成 / 失败;幂等;取消 / 重试 |
|
||||
@@ -576,7 +576,7 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
|
||||
|
||||
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| A3-BE-01 | 行业目录 | BE | `industries`;名称全局唯一;删除前校验无客户 / 无资产,否则只能停用;行业不可跨行业移动校验 | A2-BE-04 | 重名 40902;有客户时删除被拒;停用后新建客户不可选 | 0.5 |
|
||||
| A3-BE-01 | 行业目录(两级) | BE | `industries`;一级 → 二级最多两级;一级名称全局唯一、二级同父唯一;客户只挂末级行业;删除前校验无子行业 / 客户 / 资产,否则只能停用;有启用子行业时禁止停用 | A2-BE-04 | 重名 40902;有子行业 / 客户时删除被拒;有启用子行业停用被拒;停用后新建客户不可选 | 0.5 |
|
||||
| A3-BE-02 | 客户档案与状态机 | BE | `customers`;简称必填、`(industry_id, name)` 唯一;服务状态(在服务 / 暂停 / 结束);负责人 / 对接人;交付偏好;套用行业资产包入口 | A3-BE-01 | 简称缺失 42201;暂停只读、不能发起新任务;结束全只读 | 1.0 |
|
||||
| A3-BE-03 | 客户组 | BE | `customer_groups` + `customer_group_members`;客户组作为授权与数据范围最小单位 | A3-BE-02 | 成员增删幂等;数据范围引用客户组后可见集合正确 | 0.5 |
|
||||
| A3-BE-04 | 客户级字段申请与审批 | BE | `customer_fields`;申请(定义 + 用途 + 是否进入生成)→ 审批 → 生效;每客户 ≤ 5 个进入生成字段、基础字段 ≤ 10 | A3-BE-02 | 超限 40001(字段校验失败);未审批字段不进入生成;审批与变更进审计 | 1.0 |
|
||||
@@ -589,6 +589,8 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
|
||||
|
||||
**本批门禁**:可建行业 → 客户 → 客户组;唯一性与状态机生效;客户字段审批可用;所有查询带客户维度。
|
||||
|
||||
> **A3 完成情况(2026-11-03)**:9/9 项 WBS 通过。两级行业树(ADR-002)、客户档案与状态机、客户组、客户级字段双层与审批、客户转交、真实 `CustomerDirectory`、客户上下文与跨客户 404 全部落地;前端按用户选定混搭实现(行业 B / 客户列表 A / 客户详情 A / 新建 B / 客户组 B / 字段 A),侧栏更名「客户管理」「行业管理」并新增「客户组」。门禁:`format:check / lint / typecheck / test` 全绿(shared 131 / api 90 / web 125,共 346),`test:int` 39 项(5 文件,含 A3 client 6 条),A3 浏览器 e2e 1/1(11.6s),`build` 8/8;实现记录、截图与遗留见 [A3 实施报告](./灵犀_A3客户与行业目录_实施报告_V0.1.md)。已知遗留:B0 行业资产包套用、A5 审计落库、A4 字段上限配置、客户详情面包屑末级名称。
|
||||
|
||||
### 6.5 A4 数据字典与系统配置
|
||||
|
||||
**目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**:P-06、P-13(部分)、PRD §5.4.5;`R-10` / `R-01` / `R-26`;架构 §5.3 / §5.7。
|
||||
@@ -952,7 +954,7 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
|
||||
| --- | --- |
|
||||
| 页面 | `/industries`、`/customers`、`/customers/new`(分步)、`/customers/:id`、`/customer-groups`、`/customer-fields/approvals` |
|
||||
| 接口 | `GET/POST/PATCH /industries`、`GET/POST/PATCH /customers`、`/customer-groups`、`/customers/{id}/fields`、`/customer-fields/{id}/approve`、`POST /customers/{id}/apply-industry-pack` |
|
||||
| 关键规则 | 行业名称全局唯一;客户名称同行业内唯一;**简称必填**(用于全部自动命名);客户必须归属一个行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 |
|
||||
| 关键规则 | 行业两级树;一级名称全局唯一、二级同父唯一;客户名称同末级行业唯一;**简称必填**(用于全部自动命名);客户必须归属末级行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 |
|
||||
| 交互要点 | 新建客户为分步表单(行业 → 档案 → 交付偏好 → 资产包),可回退;状态为暂停 / 结束时顶部只读横幅;客户组作为授权单位,批量授权入口清晰 |
|
||||
| 验收 | ① 重名返回 40902;② 简称缺失 42201;③ 暂停客户不能提交新任务;④ 客户组授权后范围正确;⑤ 字段超限 40001、未审批字段不进入生成 |
|
||||
|
||||
@@ -1232,7 +1234,7 @@ infra/:compose、Nginx、监控、备份脚本占位
|
||||
- [ ] 超管首登强制改密;用户 / 角色 / 部门可用;
|
||||
- [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用;
|
||||
- [ ] SoD 三对规则生效且有拒绝用例;
|
||||
- [ ] 行业两级、客户 / 客户组 / 客户端字段审批可用;
|
||||
- [ ] 行业两级树(一级 → 二级,客户挂末级)、客户 / 客户组 / 客户端字段审批可用;
|
||||
- [ ] 通用字典三层合并、编码不可改、历史快照可用;
|
||||
- [ ] 审计七类关键动作留痕、只读、导出留痕;
|
||||
- [ ] AI 四层配置 + Mock 全链路 + 密钥不回显;
|
||||
@@ -1379,7 +1381,7 @@ infra/:compose、Nginx、监控、备份脚本占位
|
||||
| `/system/users`、`/system/users/new`、`/system/users/:id` | 用户与部门 | `user:view` / `user:manage` | A1 |
|
||||
| `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 |
|
||||
| `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 |
|
||||
| `/system/industries` | 行业管理 | `industry_pack:view` / `customer:manage` | A3 |
|
||||
| `/system/industries` | 行业管理 | `customer:read` / `customer:manage` | A3 |
|
||||
| `/system/customers`、`/system/customers/new`、`/system/customers/:id` | 客户管理 | `customer:view` / `customer:manage` | A3 |
|
||||
| `/system/customer-groups` | 客户组 | `customer_group:manage` | A3 |
|
||||
| `/system/customer-fields/approvals` | 字段审批 | `customer_field:approve` | A3 |
|
||||
|
||||
@@ -830,7 +830,7 @@ erDiagram
|
||||
| 组织权限 | `role_permissions` | 角色 → 权限点 | 复合主键 |
|
||||
| 组织权限 | `user_roles` | 用户 → 角色(可多) | 复合主键 |
|
||||
| 组织权限 | `data_scopes` | 角色数据范围 | `scope_type` + 目标 ID |
|
||||
| 客户与行业 | `industries` | 行业目录(业务属性) | 名称全局唯一 |
|
||||
| 客户与行业 | `industries` | 行业目录(两级树:一级 → 二级) | 一级名称全局唯一;二级同一父级下唯一;最多两级 |
|
||||
| 客户与行业 | `customers` | 客户 | `(industry_id, name)` 唯一;`short_name` 必填 |
|
||||
| 客户与行业 | `customer_groups` | 客户组 | 授权与范围最小单位 |
|
||||
| 客户与行业 | `customer_group_members` | 客户组 ↔ 客户 | 复合主键 |
|
||||
@@ -900,7 +900,7 @@ erDiagram
|
||||
| **字段** | **类型** | **说明** |
|
||||
| --- | --- | --- |
|
||||
| `id` | ulid PK | — |
|
||||
| `industry_id` | ulid FK → industries | 不可为空;跨行业移动禁止(应用层 + 触发器可选) |
|
||||
| `industry_id` | ulid FK → industries | 不可为空;必须指向**末级行业**(有子行业的一级行业不可选);跨行业移动禁止(应用层 + 触发器可选) |
|
||||
| `name` | text | 同行业内唯一 |
|
||||
| `short_name` | text | **必填**,用于自动命名,需满足通用字符规则 |
|
||||
| `status` | enum | 在服务 / 暂停 / 结束 |
|
||||
@@ -1102,7 +1102,7 @@ sequenceDiagram
|
||||
|
||||
| **方法** | **路径** | **权限点** | **说明** |
|
||||
| --- | --- | --- | --- |
|
||||
| GET/POST/PATCH | `/industries`、`/industries/{id}` | `industry_pack:view` / `customer:manage` | 行业目录;删除前校验 |
|
||||
| GET/POST/PATCH | `/industries`、`/industries/{id}` | `customer:read` / `customer:manage` | 两级行业树;删除前校验无子行业 / 客户 / 资产;有启用子行业禁止停用 |
|
||||
| GET/POST/PATCH | `/customers`、`/customers/{id}` | `customer:view` / `customer:manage` | 客户;`short_name` 必填 |
|
||||
| GET/POST | `/customer-groups`、`/customer-groups/{id}/members` | `customer_group:manage` | 客户组与成员 |
|
||||
| POST | `/customers/{id}/apply-industry-pack` | `customer:manage` | 套用行业资产包(冷启动) |
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
|
||||
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
|
||||
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
|
||||
| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` |
|
||||
|
||||
## A1 账号生命周期用例
|
||||
|
||||
@@ -60,3 +61,19 @@ A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
|
||||
- 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。
|
||||
- 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。
|
||||
- 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。
|
||||
|
||||
## A3 客户与行业用例(A3-QA-01)
|
||||
|
||||
覆盖:登录注入 → 行业管理 / 客户列表 / 新建客户 / 客户组 / 字段审批五页标题 → 客户列表有数据 → 点击行进入客户详情深链。
|
||||
|
||||
### 前置与运行
|
||||
|
||||
1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
|
||||
2. 用例自建临时超管 `qa3.e2e`(Prisma 写入,结束后删除),不依赖本地账号密码。
|
||||
|
||||
```bash
|
||||
A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||||
```
|
||||
|
||||
- 可选覆盖:`A3_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A3_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
|
||||
- 截图:`design/a3-ui-v1/impl/`(实施报告引用)。
|
||||
@@ -0,0 +1,120 @@
|
||||
/// <reference lib="dom" />
|
||||
/**
|
||||
* A3-QA-01 · 客户与行业目录端到端用例(真实 API + 真实 PG + 真实浏览器)
|
||||
*
|
||||
* 覆盖:登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户向导 → 客户组 → 字段审批。
|
||||
* 门控:仅 A3_E2E=1 时执行;默认不跑,避免影响 CI / smoke。
|
||||
*
|
||||
* 前置:API 3000、Web 5173(`pnpm dev`);用例自建 `qa3.e2e` 超管账号并在结束后删除。
|
||||
* 运行:A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const API_BASE = process.env.A3_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
|
||||
const WEB_BASE = process.env.A3_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
|
||||
const ENABLED = process.env.A3_E2E === '1';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const ACCOUNT = 'qa3.e2e';
|
||||
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function ulid(): string {
|
||||
let time = Date.now();
|
||||
const timeChars = new Array<string>(10);
|
||||
for (let index = 9; index >= 0; index -= 1) {
|
||||
timeChars[index] = ENCODING[time % 32] ?? '0';
|
||||
time = Math.floor(time / 32);
|
||||
}
|
||||
const random = randomBytes(16);
|
||||
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
|
||||
}
|
||||
|
||||
test.describe('A3 客户与行业目录', () => {
|
||||
test.skip(!ENABLED, 'A3_E2E=1 时执行;需要 API 3000 + Web 5173');
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const [role, department] = await Promise.all([
|
||||
prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }),
|
||||
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
|
||||
]);
|
||||
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
name: 'A3 端到端',
|
||||
account: ACCOUNT,
|
||||
email: `${ACCOUNT}@lingxi.cn`,
|
||||
phone: '13500008888',
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId: role.id }] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test('行业 / 客户 / 客户组 / 字段审批六页可访问且数据加载', async ({ page, request }) => {
|
||||
const login = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: { account: ACCOUNT, password: PASSWORD },
|
||||
});
|
||||
expect(login.ok()).toBe(true);
|
||||
const session = (await login.json()) as { accessToken: string; refreshToken: string };
|
||||
const meResponse = await request.get(`${API_BASE}/me`, {
|
||||
headers: { authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
expect(meResponse.ok()).toBe(true);
|
||||
const me = (await meResponse.json()) as {
|
||||
user: { id: string; name: string; account: string };
|
||||
roles: { name: string }[];
|
||||
permissions: string[];
|
||||
};
|
||||
await page.addInitScript(
|
||||
(value) => {
|
||||
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
|
||||
},
|
||||
{
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
user: {
|
||||
id: me.user.id,
|
||||
name: me.user.name,
|
||||
account: me.user.account,
|
||||
roleName: me.roles[0]?.name,
|
||||
},
|
||||
permissions: me.permissions,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
);
|
||||
|
||||
const pages = [
|
||||
{ path: '/system/industries', title: '行业管理' },
|
||||
{ path: '/system/customers', title: '客户管理' },
|
||||
{ path: '/system/customers/new', title: '新建客户' },
|
||||
{ path: '/system/customer-groups', title: '客户组' },
|
||||
{ path: '/system/customer-fields/approvals', title: '客户字段审批' },
|
||||
];
|
||||
for (const item of pages) {
|
||||
await page.goto(`${WEB_BASE}${item.path}`, { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('h1').first()).toHaveText(item.title);
|
||||
}
|
||||
|
||||
await page.goto(`${WEB_BASE}/system/customers`, { waitUntil: 'networkidle' });
|
||||
const customerRows = page.locator('.lx-client-table tbody tr');
|
||||
const count = await customerRows.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
await customerRows.first().click();
|
||||
await expect(page).toHaveURL(/\/system\/customers\/[A-Z0-9]+$/);
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
+482
-21
@@ -203,27 +203,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}/transfer-customers": {
|
||||
"post": {
|
||||
"description": "A3 客户模块落地前返回 42201",
|
||||
"operationId": "UsersController_transferCustomers",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户转交(占位)",
|
||||
"tags": [
|
||||
"identity-users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/departments/tree": {
|
||||
"get": {
|
||||
"description": "memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true",
|
||||
@@ -461,6 +440,488 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/industries": {
|
||||
"get": {
|
||||
"operationId": "IndustriesController_list",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "两级行业树(一级 → 二级)",
|
||||
"tags": [
|
||||
"client-industries"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "IndustriesController_create",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "新建一级 / 二级行业",
|
||||
"tags": [
|
||||
"client-industries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/industries/{id}": {
|
||||
"get": {
|
||||
"operationId": "IndustriesController_detail",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "行业详情与引用(子行业 / 客户 / 资产)",
|
||||
"tags": [
|
||||
"client-industries"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "IndustriesController_update",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "更新行业名称 / 排序 / 备注(层级与上级不可变)",
|
||||
"tags": [
|
||||
"client-industries"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "IndustriesController_remove",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "删除行业(仅无子行业 / 客户 / 资产时)",
|
||||
"tags": [
|
||||
"client-industries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/industries/{id}/status": {
|
||||
"patch": {
|
||||
"operationId": "IndustriesController_setStatus",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "停用 / 启用行业(有启用子行业时禁止停用)",
|
||||
"tags": [
|
||||
"client-industries"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers": {
|
||||
"get": {
|
||||
"operationId": "CustomersController_list",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "CustomersController_create",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "新建客户(简称必填;行业必须为末级行业)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers/{id}": {
|
||||
"get": {
|
||||
"operationId": "CustomersController_detail",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户详情(跨客户访问统一 404)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "CustomersController_update",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "更新客户档案(所属行业不可改;暂停 / 结束只读)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers/{id}/status": {
|
||||
"patch": {
|
||||
"operationId": "CustomersController_setStatus",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers/{id}/transfer": {
|
||||
"post": {
|
||||
"operationId": "CustomersController_transfer",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "变更客户负责人(写审计)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers/{id}/groups": {
|
||||
"put": {
|
||||
"operationId": "CustomersController_setGroups",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "设置客户所属客户组(覆盖式)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers/{id}/apply-industry-pack": {
|
||||
"post": {
|
||||
"operationId": "CustomersController_applyIndustryPack",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "套用行业资产包(B0 批次提供;当前返回业务提示)",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/{id}/transfer-customers": {
|
||||
"post": {
|
||||
"operationId": "CustomerTransferController_transfer",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "把用户名下客户转交给另一负责人",
|
||||
"tags": [
|
||||
"client-customers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customer-groups": {
|
||||
"get": {
|
||||
"operationId": "CustomerGroupsController_list",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户组列表(授权与数据范围最小单位)",
|
||||
"tags": [
|
||||
"client-customer-groups"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "CustomerGroupsController_create",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "新建客户组",
|
||||
"tags": [
|
||||
"client-customer-groups"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customer-groups/{id}": {
|
||||
"get": {
|
||||
"operationId": "CustomerGroupsController_detail",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户组详情与成员",
|
||||
"tags": [
|
||||
"client-customer-groups"
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "CustomerGroupsController_update",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "更新客户组名称 / 说明",
|
||||
"tags": [
|
||||
"client-customer-groups"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "CustomerGroupsController_remove",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "删除客户组(被角色数据范围引用时拒绝)",
|
||||
"tags": [
|
||||
"client-customer-groups"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customer-groups/{id}/members": {
|
||||
"post": {
|
||||
"operationId": "CustomerGroupsController_updateMembers",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "批量添加 / 移除成员(幂等)",
|
||||
"tags": [
|
||||
"client-customer-groups"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customer-fields": {
|
||||
"get": {
|
||||
"operationId": "CustomerFieldsController_list",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "字段审批队列(待审批 / 已生效 / 已驳回)",
|
||||
"tags": [
|
||||
"client-customer-fields"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customer-fields/{id}/approve": {
|
||||
"post": {
|
||||
"operationId": "CustomerFieldsController_approve",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "审批客户级字段(通过 / 驳回;驳回原因必填)",
|
||||
"tags": [
|
||||
"client-customer-fields"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/customers/{id}/fields": {
|
||||
"get": {
|
||||
"operationId": "CustomerFieldsOfCustomerController_list",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "客户级字段列表(基础字段在 customers.custom_fields)",
|
||||
"tags": [
|
||||
"client-customer-fields"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "CustomerFieldsOfCustomerController_apply",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "申请客户级字段(进入生成字段需审批;每客户 ≤ 5)",
|
||||
"tags": [
|
||||
"client-customer-fields"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
||||
|
||||
@@ -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>;
|
||||
+4
-3
@@ -50,12 +50,13 @@ pnpm db:seed # 只跑种子;幂等,不覆盖已有密码
|
||||
|
||||
- A1 记录的角色 / 用户种子已延续;A2 在其上补齐权限点目录、特殊权限口径(默认关闭、显式授予)与六类数据范围。
|
||||
- 角色权限数按原型 `design/a2-ui-v1/brief.md` 对齐(12/9/4/2/4/3/5/4/5,特殊授予 8 项);用户仍为 A1 的 11 个示例账号,原型「绑定用户 28 名」属 UI 示例数据,未扩种用户。
|
||||
- 字典(A4)、客户 / 客户组(A3)、行业包(A3)仍留待后续批次建表,不在本种子内。
|
||||
- `data_scopes` 只存角色声明的范围与授权对象;解析后的可见客户集合由 `CustomerDirectory` 端口在请求期计算,A3 前为空集(种子不写客户 id)。
|
||||
- A3 起新增客户与行业种子:两级行业「电商(服饰鞋包 / 美妆个护)」「金融(信贷)」、客户「花间集 / 素研」、客户组「电商核心组」、4 条客户级字段(1 条待审批);按业务唯一键幂等写入,不覆盖人工调整。
|
||||
- 字典(A4)、行业包(B0)仍留待后续批次建表,不在本种子内。
|
||||
- `data_scopes` 只存角色声明的范围与授权对象;A3 起由真实 `CustomerDirectory` 在请求期解析(客户负责人 / 客户组 / 部门 / 创建人),种子不写解析结果。
|
||||
- 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户;`customer_groups` 范围暂无可指向的客户组。
|
||||
- 客户转交已由 A3 client 模块落地(`/customers/{id}/transfer` 与 `/users/{id}/transfer-customers`),不再返回占位错误;客户组范围可指向种子客户组。
|
||||
- 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。
|
||||
- `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。
|
||||
@@ -0,0 +1,157 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IndustryStatus" AS ENUM ('ACTIVE', 'DISABLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CustomerStatus" AS ENUM ('ACTIVE', 'PAUSED', 'ENDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CustomerFieldStatus" AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'DISABLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "industries" (
|
||||
"id" CHAR(26) NOT NULL,
|
||||
"parent_id" CHAR(26),
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"name" VARCHAR(64) NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 10,
|
||||
"status" "IndustryStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"remark" VARCHAR(200) NOT NULL DEFAULT '',
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "industries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "customers" (
|
||||
"id" CHAR(26) NOT NULL,
|
||||
"industry_id" CHAR(26) NOT NULL,
|
||||
"name" VARCHAR(128) NOT NULL,
|
||||
"short_name" VARCHAR(32) NOT NULL,
|
||||
"status" "CustomerStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"owner_user_id" CHAR(26) NOT NULL,
|
||||
"contact_name" VARCHAR(32),
|
||||
"contact_phone" VARCHAR(32),
|
||||
"preferences" JSONB NOT NULL DEFAULT '{}',
|
||||
"custom_fields" JSONB NOT NULL DEFAULT '[]',
|
||||
"remark" VARCHAR(500),
|
||||
"created_by" CHAR(26),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "customer_groups" (
|
||||
"id" CHAR(26) NOT NULL,
|
||||
"name" VARCHAR(64) NOT NULL,
|
||||
"description" VARCHAR(255) NOT NULL DEFAULT '',
|
||||
"created_by" CHAR(26),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "customer_groups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "customer_group_members" (
|
||||
"group_id" CHAR(26) NOT NULL,
|
||||
"customer_id" CHAR(26) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "customer_group_members_pkey" PRIMARY KEY ("group_id","customer_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "customer_fields" (
|
||||
"id" CHAR(26) NOT NULL,
|
||||
"customer_id" CHAR(26) NOT NULL,
|
||||
"name" VARCHAR(32) NOT NULL,
|
||||
"code" VARCHAR(32) NOT NULL,
|
||||
"type" VARCHAR(16) NOT NULL,
|
||||
"options" JSONB NOT NULL DEFAULT '[]',
|
||||
"purpose" VARCHAR(200) NOT NULL,
|
||||
"enters_generation" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "CustomerFieldStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"applicant_user_id" CHAR(26) NOT NULL,
|
||||
"approver_user_id" CHAR(26),
|
||||
"applied_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"approved_at" TIMESTAMPTZ(3),
|
||||
"reject_reason" VARCHAR(200),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "customer_fields_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "industries_parent_id_sort_order_idx" ON "industries"("parent_id", "sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "industries_level_status_idx" ON "industries"("level", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customers_owner_user_id_status_idx" ON "customers"("owner_user_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customers_status_updated_at_idx" ON "customers"("status", "updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customers_deleted_at_idx" ON "customers"("deleted_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "customers_industry_id_name_key" ON "customers"("industry_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "customer_groups_name_key" ON "customer_groups"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customer_group_members_customer_id_idx" ON "customer_group_members"("customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customer_fields_status_applied_at_idx" ON "customer_fields"("status", "applied_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customer_fields_customer_id_status_idx" ON "customer_fields"("customer_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "customer_fields_customer_id_code_key" ON "customer_fields"("customer_id", "code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "industries" ADD CONSTRAINT "industries_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "industries"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_industry_id_fkey" FOREIGN KEY ("industry_id") REFERENCES "industries"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customer_group_members" ADD CONSTRAINT "customer_group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "customer_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customer_group_members" ADD CONSTRAINT "customer_group_members_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customer_fields" ADD CONSTRAINT "customer_fields_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customer_fields" ADD CONSTRAINT "customer_fields_applicant_user_id_fkey" FOREIGN KEY ("applicant_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customer_fields" ADD CONSTRAINT "customer_fields_approver_user_id_fkey" FOREIGN KEY ("approver_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- A3 / ADR-002:行业名称唯一性(一级全局唯一,二级同父唯一)与两级结构校验
|
||||
CREATE UNIQUE INDEX "industries_root_name_key" ON "industries"("name") WHERE "parent_id" IS NULL;
|
||||
CREATE UNIQUE INDEX "industries_child_name_key" ON "industries"("parent_id", "name") WHERE "parent_id" IS NOT NULL;
|
||||
|
||||
ALTER TABLE "industries"
|
||||
ADD CONSTRAINT "industries_level_check"
|
||||
CHECK (("parent_id" IS NULL AND "level" = 1) OR ("parent_id" IS NOT NULL AND "level" = 2));
|
||||
ALTER TABLE "industries"
|
||||
ADD CONSTRAINT "industries_no_self_parent_check" CHECK ("id" <> "parent_id");
|
||||
+172
-35
@@ -15,16 +15,16 @@ datasource db {
|
||||
|
||||
/// 事务性发件箱:领域事件先落库,再由 worker 投递(技术架构 §5.3)
|
||||
model OutboxEvent {
|
||||
id String @id @db.Char(26)
|
||||
aggregateType String @map("aggregate_type") @db.VarChar(64)
|
||||
aggregateId String @map("aggregate_id") @db.Char(26)
|
||||
eventType String @map("event_type") @db.VarChar(128)
|
||||
payloadJson Json @map("payload_json") @db.JsonB
|
||||
traceId String? @map("trace_id") @db.VarChar(64)
|
||||
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
|
||||
attempts Int @default(0)
|
||||
lastError String? @map("last_error") @db.Text
|
||||
id String @id @db.Char(26)
|
||||
aggregateType String @map("aggregate_type") @db.VarChar(64)
|
||||
aggregateId String @map("aggregate_id") @db.Char(26)
|
||||
eventType String @map("event_type") @db.VarChar(128)
|
||||
payloadJson Json @map("payload_json") @db.JsonB
|
||||
traceId String? @map("trace_id") @db.VarChar(64)
|
||||
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
|
||||
attempts Int @default(0)
|
||||
lastError String? @map("last_error") @db.Text
|
||||
|
||||
@@index([publishedAt, occurredAt])
|
||||
@@index([aggregateType, aggregateId])
|
||||
@@ -33,14 +33,14 @@ model OutboxEvent {
|
||||
|
||||
/// 请求幂等键:任务提交必须带 Idempotency-Key(TTL 24h)
|
||||
model IdempotencyKey {
|
||||
id String @id @db.Char(26)
|
||||
scope String @db.VarChar(64)
|
||||
key String @db.VarChar(128)
|
||||
requestHash String @map("request_hash") @db.VarChar(64)
|
||||
responseStatus Int? @map("response_status")
|
||||
responseBody Json? @map("response_body") @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
id String @id @db.Char(26)
|
||||
scope String @db.VarChar(64)
|
||||
key String @db.VarChar(128)
|
||||
requestHash String @map("request_hash") @db.VarChar(64)
|
||||
responseStatus Int? @map("response_status")
|
||||
responseBody Json? @map("response_body") @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([scope, key])
|
||||
@@index([expiresAt])
|
||||
@@ -49,7 +49,7 @@ model IdempotencyKey {
|
||||
|
||||
/// 部门:两级组织(一级部门 → 小组),父子用 parent_id 关联(A1 identity)
|
||||
model Department {
|
||||
id String @id @db.Char(26)
|
||||
id String @id @db.Char(26)
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(64)
|
||||
parentId String? @map("parent_id") @db.Char(26)
|
||||
@@ -61,8 +61,8 @@ model Department {
|
||||
|
||||
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
|
||||
children Department[] @relation("DepartmentTree")
|
||||
leader User? @relation("DepartmentLeader", fields: [leaderUserId], references: [id])
|
||||
users User[] @relation("UserDepartment")
|
||||
leader User? @relation("DepartmentLeader", fields: [leaderUserId], references: [id])
|
||||
users User[] @relation("UserDepartment")
|
||||
|
||||
@@unique([parentId, name])
|
||||
@@index([parentId, sortOrder])
|
||||
@@ -94,10 +94,14 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
department Department @relation("UserDepartment", fields: [departmentId], references: [id])
|
||||
roles UserRole[]
|
||||
refreshTokens RefreshToken[]
|
||||
ledDepartments Department[] @relation("DepartmentLeader")
|
||||
department Department @relation("UserDepartment", fields: [departmentId], references: [id])
|
||||
roles UserRole[]
|
||||
refreshTokens RefreshToken[]
|
||||
ledDepartments Department[] @relation("DepartmentLeader")
|
||||
ownedCustomers Customer[] @relation("CustomerOwner")
|
||||
createdCustomers Customer[] @relation("CustomerCreator")
|
||||
appliedCustomerFields CustomerField[] @relation("CustomerFieldApplicant")
|
||||
approvedCustomerFields CustomerField[] @relation("CustomerFieldApprover")
|
||||
|
||||
@@index([departmentId, status])
|
||||
@@index([status, lastLoginAt])
|
||||
@@ -211,16 +215,149 @@ model RefreshToken {
|
||||
|
||||
/// 系统配置:阈值 / 上限类数字唯一存放处(PRD R-26),带版本与变更审计
|
||||
model SystemConfig {
|
||||
id String @id @db.Char(26)
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue Json @map("config_value") @db.JsonB
|
||||
valueType String @map("value_type") @db.VarChar(16)
|
||||
description String @default("") @db.VarChar(255)
|
||||
version Int @default(1)
|
||||
isEditable Boolean @default(true) @map("is_editable")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
updatedBy String? @map("updated_by") @db.Char(26)
|
||||
id String @id @db.Char(26)
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue Json @map("config_value") @db.JsonB
|
||||
valueType String @map("value_type") @db.VarChar(16)
|
||||
description String @default("") @db.VarChar(255)
|
||||
version Int @default(1)
|
||||
isEditable Boolean @default(true) @map("is_editable")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
updatedBy String? @map("updated_by") @db.Char(26)
|
||||
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A3 客户与行业(client 模块;ADR-002 两级行业)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum IndustryStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
/// 行业两级树:一级 parent_id 为空,二级 parent_id 指向一级;最多两级。
|
||||
/// 名称唯一性由迁移中的部分唯一索引保证:一级全局唯一、二级同父唯一。
|
||||
model Industry {
|
||||
id String @id @db.Char(26)
|
||||
parentId String? @map("parent_id") @db.Char(26)
|
||||
level Int @default(1)
|
||||
name String @db.VarChar(64)
|
||||
sortOrder Int @default(10) @map("sort_order")
|
||||
status IndustryStatus @default(ACTIVE)
|
||||
remark String @default("") @db.VarChar(200)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
parent Industry? @relation("IndustryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Industry[] @relation("IndustryTree")
|
||||
customers Customer[]
|
||||
|
||||
@@index([parentId, sortOrder])
|
||||
@@index([level, status])
|
||||
@@map("industries")
|
||||
}
|
||||
|
||||
enum CustomerStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
ENDED
|
||||
}
|
||||
|
||||
/// 客户:隔离单元;industry_id 必须指向末级行业;软删除保留历史。
|
||||
model Customer {
|
||||
id String @id @db.Char(26)
|
||||
industryId String @map("industry_id") @db.Char(26)
|
||||
name String @db.VarChar(128)
|
||||
shortName String @map("short_name") @db.VarChar(32)
|
||||
status CustomerStatus @default(ACTIVE)
|
||||
ownerUserId String @map("owner_user_id") @db.Char(26)
|
||||
contactName String? @map("contact_name") @db.VarChar(32)
|
||||
contactPhone String? @map("contact_phone") @db.VarChar(32)
|
||||
preferences Json @default("{}") @db.JsonB
|
||||
customFields Json @default("[]") @map("custom_fields") @db.JsonB
|
||||
remark String? @db.VarChar(500)
|
||||
createdBy String? @map("created_by") @db.Char(26)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
industry Industry @relation(fields: [industryId], references: [id], onDelete: Restrict)
|
||||
owner User @relation("CustomerOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
|
||||
creator User? @relation("CustomerCreator", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
groupMembers CustomerGroupMember[]
|
||||
fields CustomerField[]
|
||||
|
||||
@@unique([industryId, name])
|
||||
@@index([ownerUserId, status])
|
||||
@@index([status, updatedAt])
|
||||
@@index([deletedAt])
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
/// 客户组:授权与数据范围的最小单位。
|
||||
model CustomerGroup {
|
||||
id String @id @db.Char(26)
|
||||
name String @unique @db.VarChar(64)
|
||||
description String @default("") @db.VarChar(255)
|
||||
createdBy String? @map("created_by") @db.Char(26)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
members CustomerGroupMember[]
|
||||
|
||||
@@map("customer_groups")
|
||||
}
|
||||
|
||||
/// 客户组 ↔ 客户(复合主键)
|
||||
model CustomerGroupMember {
|
||||
groupId String @map("group_id") @db.Char(26)
|
||||
customerId String @map("customer_id") @db.Char(26)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([groupId, customerId])
|
||||
@@index([customerId])
|
||||
@@map("customer_group_members")
|
||||
}
|
||||
|
||||
enum CustomerFieldStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
REJECTED
|
||||
DISABLED
|
||||
}
|
||||
|
||||
/// 客户级字段:只有 enters_generation=true 的字段需要审批;
|
||||
/// 基础字段存 customers.custom_fields(JSONB),不进入本表。
|
||||
model CustomerField {
|
||||
id String @id @db.Char(26)
|
||||
customerId String @map("customer_id") @db.Char(26)
|
||||
name String @db.VarChar(32)
|
||||
code String @db.VarChar(32)
|
||||
type String @db.VarChar(16)
|
||||
options Json @default("[]") @db.JsonB
|
||||
purpose String @db.VarChar(200)
|
||||
entersGeneration Boolean @default(false) @map("enters_generation")
|
||||
status CustomerFieldStatus @default(PENDING)
|
||||
applicantUserId String @map("applicant_user_id") @db.Char(26)
|
||||
approverUserId String? @map("approver_user_id") @db.Char(26)
|
||||
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(3)
|
||||
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
|
||||
rejectReason String? @map("reject_reason") @db.VarChar(200)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
applicant User @relation("CustomerFieldApplicant", fields: [applicantUserId], references: [id], onDelete: Restrict)
|
||||
approver User? @relation("CustomerFieldApprover", fields: [approverUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([customerId, code])
|
||||
@@index([status, appliedAt])
|
||||
@@index([customerId, status])
|
||||
@@map("customer_fields")
|
||||
}
|
||||
+222
@@ -9,6 +9,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
|
||||
@@ -607,6 +608,226 @@ async function seedDepartmentLeaders(): Promise<void> {
|
||||
console.log('[seed] 部门负责人就绪(幂等)');
|
||||
}
|
||||
|
||||
/**
|
||||
* A3 客户与行业种子(幂等):
|
||||
* - 两级行业:电商(服饰鞋包 / 美妆个护)、金融(信贷);
|
||||
* - 2 个客户(花间集、素研)与 1 个客户组(电商核心组);
|
||||
* - 花间集 3 条客户级字段:已生效 2(1 条进入生成 + 1 条仅检索)、待审批 1。
|
||||
* 不覆盖人工调整值:按业务唯一键查找,存在则只补齐缺失关系。
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- A3 种子按行业 / 客户 / 客户组 / 字段顺序幂等写入 */
|
||||
async function seedClient(): Promise<void> {
|
||||
const admin = await prisma.user.findUnique({ where: { account: 'admin' }, select: { id: true } });
|
||||
const chen = await prisma.user.findUnique({
|
||||
where: { account: 'chen.mo' },
|
||||
select: { id: true },
|
||||
});
|
||||
const lin = await prisma.user.findUnique({
|
||||
where: { account: 'lin.qing' },
|
||||
select: { id: true },
|
||||
});
|
||||
const jiang = await prisma.user.findUnique({
|
||||
where: { account: 'jiang.lan' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (admin === null || chen === null || lin === null || jiang === null) {
|
||||
throw new Error('A3 种子依赖 admin / chen.mo / lin.qing / jiang.lan 账号,请先执行 A1 种子');
|
||||
}
|
||||
const adminId = admin.id;
|
||||
|
||||
async function upsertIndustry(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
level: number,
|
||||
sortOrder: number,
|
||||
remark: string,
|
||||
): Promise<string> {
|
||||
const existing = await prisma.industry.findFirst({
|
||||
where: { name, parentId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) return existing.id;
|
||||
const id = ulid();
|
||||
await prisma.industry.create({
|
||||
data: { id, name, parentId, level, sortOrder, remark, status: 'ACTIVE' },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
const ec = await upsertIndustry('电商', null, 1, 10, '电商行业客户,覆盖服饰、美妆等类目');
|
||||
const apparel = await upsertIndustry('服饰鞋包', ec, 2, 10, '服饰、鞋履、箱包类目');
|
||||
const beauty = await upsertIndustry('美妆个护', ec, 2, 20, '护肤、彩妆、个护类目');
|
||||
const fin = await upsertIndustry('金融', null, 1, 20, '金融行业客户,合规要求高');
|
||||
await upsertIndustry('信贷', fin, 2, 10, '消费贷、经营贷、信用卡');
|
||||
|
||||
async function upsertCustomer(input: {
|
||||
name: string;
|
||||
shortName: string;
|
||||
industryId: string;
|
||||
ownerUserId: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
remark: string;
|
||||
preferences: Prisma.InputJsonValue;
|
||||
customFields: Prisma.InputJsonValue;
|
||||
}): Promise<string> {
|
||||
const existing = await prisma.customer.findFirst({
|
||||
where: { industryId: input.industryId, name: input.name, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) return existing.id;
|
||||
const id = ulid();
|
||||
await prisma.customer.create({
|
||||
data: {
|
||||
id,
|
||||
industryId: input.industryId,
|
||||
name: input.name,
|
||||
shortName: input.shortName,
|
||||
status: 'ACTIVE',
|
||||
ownerUserId: input.ownerUserId,
|
||||
contactName: input.contactName,
|
||||
contactPhone: input.contactPhone,
|
||||
preferences: input.preferences,
|
||||
customFields: input.customFields,
|
||||
remark: input.remark,
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
const huajianji = await upsertCustomer({
|
||||
name: '花间集',
|
||||
shortName: 'HJ',
|
||||
industryId: apparel,
|
||||
ownerUserId: chen.id,
|
||||
contactName: '林晓',
|
||||
contactPhone: '138****2210',
|
||||
remark: '秋冬新品主推,素材需保留品牌金色描边。',
|
||||
preferences: {
|
||||
naming: '客户名_方向_日期_序号',
|
||||
directory: '花间集/2026/秋冬',
|
||||
specs: ['1080×1920', '1080×1080'],
|
||||
taboo: ['绝对化用语', '未授权肖像'],
|
||||
},
|
||||
customFields: [
|
||||
{ key: '门店编号', value: '沪-012' },
|
||||
{ key: '内部产品代码', value: 'HJ-2026' },
|
||||
],
|
||||
});
|
||||
const suyan = await upsertCustomer({
|
||||
name: '素研',
|
||||
shortName: 'SY',
|
||||
industryId: beauty,
|
||||
ownerUserId: lin.id,
|
||||
contactName: '周航',
|
||||
contactPhone: '139****8832',
|
||||
remark: '以成分卖点为主,需规避医疗功效表述。',
|
||||
preferences: {
|
||||
naming: '客户名_产品线_日期_序号',
|
||||
directory: '素研/2026/Q4',
|
||||
specs: ['1080×1920'],
|
||||
taboo: ['医疗功效表述'],
|
||||
},
|
||||
customFields: [{ key: '门店编号', value: '杭-008' }],
|
||||
});
|
||||
|
||||
async function upsertGroup(): Promise<void> {
|
||||
const existing = await prisma.customerGroup.findUnique({
|
||||
where: { name: '电商核心组' },
|
||||
select: { id: true },
|
||||
});
|
||||
const groupId = existing?.id ?? ulid();
|
||||
if (existing === null) {
|
||||
await prisma.customerGroup.create({
|
||||
data: {
|
||||
id: groupId,
|
||||
name: '电商核心组',
|
||||
description: '电商行业核心客户,作为数据范围授权单位',
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.customerGroupMember.createMany({
|
||||
data: [huajianji, suyan].map((customerId) => ({ groupId, customerId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await upsertGroup();
|
||||
|
||||
const fieldSeeds = [
|
||||
{
|
||||
customerId: huajianji,
|
||||
name: '商品系列',
|
||||
code: 'series',
|
||||
type: 'select',
|
||||
options: ['秋冬', '春夏'],
|
||||
purpose: '卖点与脚本分组,按系列匹配脚本模板',
|
||||
entersGeneration: true,
|
||||
status: 'ACTIVE' as const,
|
||||
approverUserId: jiang.id,
|
||||
},
|
||||
{
|
||||
customerId: huajianji,
|
||||
name: '门店编号',
|
||||
code: 'store_code',
|
||||
type: 'text',
|
||||
options: [],
|
||||
purpose: '门店素材检索与报表拆分',
|
||||
entersGeneration: false,
|
||||
status: 'ACTIVE' as const,
|
||||
approverUserId: jiang.id,
|
||||
},
|
||||
{
|
||||
customerId: huajianji,
|
||||
name: '活动编码',
|
||||
code: 'campaign_code',
|
||||
type: 'text',
|
||||
options: [],
|
||||
purpose: '活动素材命名与检索,用于区分不同投放批次',
|
||||
entersGeneration: true,
|
||||
status: 'PENDING' as const,
|
||||
approverUserId: null,
|
||||
},
|
||||
{
|
||||
customerId: suyan,
|
||||
name: '产品线',
|
||||
code: 'product_line',
|
||||
type: 'select',
|
||||
options: ['精华', '面霜'],
|
||||
purpose: '按产品线匹配脚本方向与卖点',
|
||||
entersGeneration: true,
|
||||
status: 'ACTIVE' as const,
|
||||
approverUserId: jiang.id,
|
||||
},
|
||||
];
|
||||
for (const field of fieldSeeds) {
|
||||
const existing = await prisma.customerField.findUnique({
|
||||
where: { customerId_code: { customerId: field.customerId, code: field.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) continue;
|
||||
await prisma.customerField.create({
|
||||
data: {
|
||||
id: ulid(),
|
||||
customerId: field.customerId,
|
||||
name: field.name,
|
||||
code: field.code,
|
||||
type: field.type,
|
||||
options: field.options,
|
||||
purpose: field.purpose,
|
||||
entersGeneration: field.entersGeneration,
|
||||
status: field.status,
|
||||
applicantUserId: chen.id,
|
||||
approverUserId: field.approverUserId,
|
||||
approvedAt: field.status === 'ACTIVE' ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log('[seed] A3 客户与行业就绪(幂等)');
|
||||
}
|
||||
/* eslint-enable max-lines-per-function */
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await seedConfigs();
|
||||
await seedPermissions();
|
||||
@@ -614,6 +835,7 @@ async function main(): Promise<void> {
|
||||
await seedRoles();
|
||||
await seedUsers();
|
||||
await seedDepartmentLeaders();
|
||||
await seedClient();
|
||||
}
|
||||
|
||||
void main()
|
||||
|
||||
Reference in new issue
Block a user