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
@@ -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。
|
||||
|
||||
Reference in new issue
Block a user