/* 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> { 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 { 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 { 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 { 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 { return this.prisma.customerField.count({ where: { customerId, entersGeneration: true, status: { not: 'REJECTED' }, ...(excludeId === undefined ? {} : { id: { not: excludeId } }), }, }); } }