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