feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。 改动: - 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。 - 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。 - 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。 - 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。 - 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。 影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。 回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。 验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
This commit is contained in:
1 parent
8626996bb1
commit
140dc386c1
54 files changed
+9018
-147
No files matched your search
@@ -0,0 +1,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user