背景: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;真实浏览器六页验证通过。
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|