furniture-3d-system-Wu/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts

707 lines
20 KiB
TypeScript

import { Prisma, type IntakeNonIntegratedProduct, type IntakeNonIntegratedProductProfile } from '@prisma/client'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import {
canUploadNonIntegratedDetachedAsset,
canCreateNonIntegratedProductFromIntake,
canDeleteNonIntegratedProductRecordByIntakePermission,
isIntakeNonIntegratedDesignDepartmentUser,
canEditAnyNonIntegratedWorkspace,
canEditNonIntegratedWorkspace,
canDownloadNonIntegratedWorkspace,
canPushNonIntegratedProductPricingToJushuitan,
isIntakeNonIntegratedFullEditor,
type IntakeNonIntegratedPermissionProduct,
} from '@/lib/non-integrated-permissions'
import {
isExternalIntakeRecordOwner,
isExternalIntakeUser,
} from '@/lib/non-integrated-external-entry-permissions'
import {
getIntakeNodePermissionTargetKeyByWorkspaceCategory,
resolveIntakeNodePermissionAccess,
resolveIntakeNodePermissionSubjectKeys,
} from '@/lib/intake-node-permission-settings'
import { intakeNodePermissionSettingsService } from './intake-node-permission-settings.service'
import { intakeProductPhotoAccessService } from './intake-product-photo-access.service'
import {
OperationLogAction,
OperationLogStatus,
operationLogService,
} from './operation-log.service'
type IntakeNonIntegratedPermissionTarget = Pick<
IntakeNonIntegratedProduct,
| 'id'
| 'spu'
| 'createdBy'
| 'designer'
| 'purchaser'
| 'visibility'
| 'departmentId'
| 'teamId'
> & {
operator?: string | null
payload?: Prisma.JsonValue | null
profile?: IntakeNonIntegratedProductProfile | null
skuItems?: Array<{
skuItemId?: string | null
purchaseOwner?: string | null
manufacturerCode?: string | null
}>
}
const ADMIN_ROLES = new Set<AuthUser['role']>(['SUPER_ADMIN', 'ADMIN'])
const PRODUCT_FACTORY_WORKSPACE_CATEGORY = '\u4ea7\u54c1\u5b9a\u5382'
export const DEFAULT_NON_INTEGRATED_VISIBILITY = 'DEPARTMENT'
export const NON_INTEGRATED_SCOPE_ENABLED = false
export const PUBLIC_NON_INTEGRATED_DEMO_SPUS = ['CJ0267'] as const
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function findMissingTargetSkuItemIds(
record: IntakeNonIntegratedPermissionTarget,
targetSkuItemIds?: readonly string[],
) {
const targetIds = Array.from(
new Set((targetSkuItemIds ?? []).map(readText).filter(Boolean)),
)
if (targetIds.length === 0) return []
const savedSkuItemIds = new Set(
(record.skuItems ?? []).map((item) => readText(item.skuItemId)).filter(Boolean),
)
return targetIds.filter((skuItemId) => !savedSkuItemIds.has(skuItemId))
}
function readPayloadField(payload: unknown, key: string) {
return isRecord(payload) ? readText(payload[key]) : ''
}
function readOpeningReferenceField(payload: unknown, key: string) {
if (!isRecord(payload)) return ''
const openingReference = payload.openingReference
return isRecord(openingReference) ? readText(openingReference[key]) : ''
}
function normalizeSupplierCode(value: unknown) {
return readText(value).toUpperCase()
}
export function isPublicNonIntegratedDemoSpu(spu: unknown) {
const normalizedSpu = readText(spu).toUpperCase()
return PUBLIC_NON_INTEGRATED_DEMO_SPUS.some((demoSpu) => demoSpu === normalizedSpu)
}
function buildPublicNonIntegratedDemoScopeWhere():
Prisma.IntakeNonIntegratedProductWhereInput {
return { spu: { in: [...PUBLIC_NON_INTEGRATED_DEMO_SPUS] } }
}
function buildSupplierRecordScopeWhere(
supplierCode: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{
skuItems: {
some: {
manufacturerCode: supplierCode,
},
},
},
{
payload: {
path: ['factoryRows'],
array_contains: [{ supplierCode }],
},
},
],
}
}
function readPayloadFactoryRows(payload: unknown) {
if (!isRecord(payload)) return []
const rows = payload.factoryRows
return Array.isArray(rows)
? rows.filter((row): row is Record<string, unknown> => isRecord(row))
: []
}
async function recordIntakePermissionDenied(input: {
record: IntakeNonIntegratedPermissionTarget
user: AuthUser
category?: string | null
targetSkuItemIds?: readonly string[]
action: OperationLogAction
message: string
}) {
await operationLogService.record({
action: input.action,
status: OperationLogStatus.FAILED,
actorId: input.user.id,
actorName: input.user.name,
productId: input.record.id,
productSpu: input.record.spu,
workspaceCategory: input.category ?? null,
errorCode: ErrorCode.AUTH_PERMISSION_DENIED,
message: input.message,
details: {
targetSkuItemIds: input.targetSkuItemIds ?? [],
},
})
}
class IntakeNonIntegratedScopeService {
isScopeEnabled(): boolean {
return NON_INTEGRATED_SCOPE_ENABLED
}
isAdmin(user: AuthUser): boolean {
return ADMIN_ROLES.has(user.role)
}
isExternalIntakeScopeUser(user: AuthUser): boolean {
return !this.isAdmin(user) && isExternalIntakeUser(user)
}
isSupplierScopeUser(user: AuthUser): boolean {
return !this.isAdmin(user) && Boolean(user.businessRoles?.includes('SUPPLIER'))
}
getSupplierCode(user: AuthUser): string {
return normalizeSupplierCode(user.supplierCode)
}
buildSupplierRecordScopeWhere(user: AuthUser): Prisma.IntakeNonIntegratedProductWhereInput {
const supplierCode = this.getSupplierCode(user)
if (!supplierCode) {
return { id: '__unbound_supplier__' }
}
return buildSupplierRecordScopeWhere(supplierCode)
}
getScopedDepartmentId(user: AuthUser): string | undefined {
return user.managedDepartmentId ?? user.departmentId
}
getScopedTeamId(user: AuthUser): string | undefined {
return user.managedTeamId ?? user.teamId
}
buildReadScopeWhere(
user: AuthUser,
externalIntakeCreatorIds: readonly string[] = [],
): Prisma.IntakeNonIntegratedProductWhereInput | undefined {
const publicDemoScopeWhere = buildPublicNonIntegratedDemoScopeWhere()
if (this.isSupplierScopeUser(user)) {
return {
OR: [this.buildSupplierRecordScopeWhere(user), publicDemoScopeWhere],
}
}
if (this.isExternalIntakeScopeUser(user)) {
return {
OR: [{ createdBy: user.id }, publicDemoScopeWhere],
}
}
const externalIntakeRecordExclusion =
externalIntakeCreatorIds.length > 0
? {
OR: [
{
createdBy: {
notIn: Array.from(new Set(externalIntakeCreatorIds)),
},
},
publicDemoScopeWhere,
],
}
: undefined
if (!this.isScopeEnabled()) {
return externalIntakeRecordExclusion
}
if (this.isAdmin(user)) {
return undefined
}
const orConditions: Prisma.IntakeNonIntegratedProductWhereInput[] = [
{ createdBy: user.id },
{ visibility: 'PUBLIC' },
publicDemoScopeWhere,
]
const scopedDepartmentId = this.getScopedDepartmentId(user)
if (scopedDepartmentId) {
orConditions.push({
visibility: 'DEPARTMENT',
departmentId: scopedDepartmentId,
})
}
const scopedTeamId = this.getScopedTeamId(user)
if (scopedTeamId) {
orConditions.push({
visibility: 'TEAM',
teamId: scopedTeamId,
})
}
const standardScope = { OR: orConditions }
if (!externalIntakeRecordExclusion) {
return standardScope
}
return {
AND: [standardScope, externalIntakeRecordExclusion],
}
}
canReadRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
externalIntakeCreatorIds: readonly string[] = [],
): boolean {
if (isPublicNonIntegratedDemoSpu(record.spu)) return true
if (this.isSupplierScopeUser(user)) {
const supplierCode = this.getSupplierCode(user)
if (!supplierCode) return false
if (
record.skuItems?.some(
(item) => normalizeSupplierCode(item.manufacturerCode) === supplierCode,
)
) {
return true
}
return readPayloadFactoryRows(record.payload).some(
(row) => normalizeSupplierCode(row.supplierCode) === supplierCode,
)
}
if (this.isExternalIntakeScopeUser(user)) {
return isExternalIntakeRecordOwner(record, user)
}
if (this.isAdmin(user)) return true
if (externalIntakeCreatorIds.includes(record.createdBy)) return false
if (!this.isScopeEnabled()) return true
if (record.createdBy === user.id) return true
if (record.visibility === 'PUBLIC') return true
const scopedDepartmentId = this.getScopedDepartmentId(user)
if (
record.visibility === 'DEPARTMENT' &&
scopedDepartmentId &&
record.departmentId === scopedDepartmentId
) {
return true
}
const scopedTeamId = this.getScopedTeamId(user)
if (record.visibility === 'TEAM' && scopedTeamId && record.teamId === scopedTeamId) {
return true
}
return false
}
canCreateRecord(user: AuthUser): boolean {
return canCreateNonIntegratedProductFromIntake(user)
}
private buildPermissionProduct(
record: IntakeNonIntegratedPermissionTarget,
skuItemIds?: readonly string[],
): IntakeNonIntegratedPermissionProduct {
const profile = record.profile ?? null
const readProfile = (key: keyof IntakeNonIntegratedProductProfile) => {
const value = profile?.[key]
return readText(value)
}
const payloadDesigner = readPayloadField(record.payload, 'designer')
const openingDesigner = readOpeningReferenceField(record.payload, 'designer')
const persistedDesigner = readText(record.designer)
const productPlanning = readProfile('productPlanning')
const targetSkuItemIds = new Set(
(skuItemIds ?? []).map(readText).filter(Boolean),
)
const fallbackPurchaseOwner =
readProfile('purchaseOwner') || readText(record.purchaser)
const scopedSkuItems = (record.skuItems ?? [])
.filter(
(item) =>
targetSkuItemIds.size === 0 ||
targetSkuItemIds.has(readText(item.skuItemId)),
)
const skuPurchaseOwners = scopedSkuItems
.map((item) => readText(item.purchaseOwner) || fallbackPurchaseOwner)
.filter(Boolean)
const nextOpeningReference = {
productName: readProfile('productName'),
productImage: readProfile('productImageUrl'),
productCategory: readProfile('productCategory'),
salesCategory: readProfile('salesCategory'),
currentStage: readProfile('productStatus'),
year: readProfile('productYear'),
designer: openingDesigner || payloadDesigner || persistedDesigner,
structureDesigner:
readProfile('structureDesigner') ||
readOpeningReferenceField(record.payload, 'structureDesigner'),
planningOwner: productPlanning,
}
return {
createdBy: record.createdBy,
designer: persistedDesigner || payloadDesigner || openingDesigner,
purchaser: readText(record.purchaser),
purchaseOwner: readProfile('purchaseOwner'),
brandPlanning: readProfile('brandPlanning'),
spuOwner: readProfile('spuOwner'),
operator: readText(record.operator),
operationsOwner: readProfile('operationsOwner'),
productPlanning,
visualPlanning: readProfile('visualPlanning'),
visualDirector: readProfile('visualDirector'),
skuPurchaseOwners,
openingReference: nextOpeningReference,
}
}
canWriteRecord(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): boolean {
return canEditAnyNonIntegratedWorkspace(this.buildPermissionProduct(record), user)
}
canUploadDetachedAsset(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): boolean {
return canUploadNonIntegratedDetachedAsset(this.buildPermissionProduct(record), user)
}
canPushProductPricing(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): boolean {
return canPushNonIntegratedProductPricingToJushuitan(
this.buildPermissionProduct(record),
user,
)
}
canWriteWorkspaceRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
targetSkuItemIds?: readonly string[],
): boolean {
if (!category) {
return this.canWriteRecord(record, user)
}
const normalizedTargetSkuItemIds = Array.from(
new Set((targetSkuItemIds ?? []).map(readText).filter(Boolean)),
)
if (
category === PRODUCT_FACTORY_WORKSPACE_CATEGORY &&
normalizedTargetSkuItemIds.length > 0
) {
const skuItemsById = new Map(
(record.skuItems ?? []).map((item) => [readText(item.skuItemId), item]),
)
if (
normalizedTargetSkuItemIds.some((skuItemId) => !skuItemsById.has(skuItemId))
) {
return false
}
return normalizedTargetSkuItemIds.every((skuItemId) =>
canEditNonIntegratedWorkspace(
this.buildPermissionProduct(record, [skuItemId]),
user,
category,
),
)
}
return canEditNonIntegratedWorkspace(this.buildPermissionProduct(record), user, category)
}
canDownloadWorkspaceRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): boolean {
if (!category) {
return false
}
return canDownloadNonIntegratedWorkspace(
this.buildPermissionProduct(record),
user,
category,
)
}
async canWriteWorkspaceRecordWithSettings(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
targetSkuItemIds?: readonly string[],
): Promise<boolean> {
if (isIntakeNonIntegratedFullEditor(user)) {
console.info('[IntakeScope] write allowed: full editor', {
userId: user.id,
userName: user.name,
role: user.role,
category: category ?? null,
})
return true
}
const targetKey = getIntakeNodePermissionTargetKeyByWorkspaceCategory(category)
if (!targetKey) {
console.warn('[IntakeScope] write denied: category not found', {
userId: user.id,
userName: user.name,
role: user.role,
category: category ?? null,
})
return false
}
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user)
const nodePermissionAccess = resolveIntakeNodePermissionAccess(
overrides,
targetKey,
subjectKeys,
user.id,
)
if (!nodePermissionAccess.edit) {
console.warn('[IntakeScope] write denied: no edit permission', {
userId: user.id,
userName: user.name,
role: user.role,
category: category ?? null,
targetKey,
subjectKeys,
})
return false
}
if (nodePermissionAccess.override.edit) {
console.info('[IntakeScope] write allowed: override', {
userId: user.id,
userName: user.name,
role: user.role,
category: category ?? null,
targetKey,
subjectKeys,
})
return true
}
const canWrite = await this.canWriteWorkspaceRecord(record, user, category, targetSkuItemIds)
if (canWrite) {
console.info('[IntakeScope] write allowed: workspace record', {
userId: user.id,
userName: user.name,
role: user.role,
category: category ?? null,
})
}
return canWrite
}
async canDownloadWorkspaceRecordWithSettings(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): Promise<boolean> {
if (isIntakeNonIntegratedFullEditor(user)) {
return true
}
const targetKey = getIntakeNodePermissionTargetKeyByWorkspaceCategory(category)
if (!targetKey) {
return false
}
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user)
const nodePermissionAccess = resolveIntakeNodePermissionAccess(
overrides,
targetKey,
subjectKeys,
user.id,
)
if (!nodePermissionAccess.download) {
return false
}
if (nodePermissionAccess.override.download) {
return true
}
return this.canDownloadWorkspaceRecord(record, user, category)
}
async canPreviewWorkspaceRecordWithSettings(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): Promise<boolean> {
if (category === '产品实拍图' && await intakeProductPhotoAccessService.canUpload(record.id, user)) {
return true
}
if (isIntakeNonIntegratedFullEditor(user)) {
return true
}
const targetKey = getIntakeNodePermissionTargetKeyByWorkspaceCategory(category)
if (!targetKey) {
return false
}
const { overrides } = await intakeNodePermissionSettingsService.getSettings()
const subjectKeys = resolveIntakeNodePermissionSubjectKeys(user)
const nodePermissionAccess = resolveIntakeNodePermissionAccess(
overrides,
targetKey,
subjectKeys,
user.id,
)
if (!nodePermissionAccess.view) {
return false
}
if (nodePermissionAccess.override.view) {
return true
}
return this.canReadRecord(record, user)
}
async assertCanWriteRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
targetSkuItemIds?: readonly string[],
): Promise<void> {
// 先检查 SKU 是否已入库,不存在直接提示"SKU 尚未保存",不要报"没有权限"
const missingTargetSkuItemIds =
category === PRODUCT_FACTORY_WORKSPACE_CATEGORY
? findMissingTargetSkuItemIds(record, targetSkuItemIds)
: []
if (missingTargetSkuItemIds.length > 0) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
'SKU 尚未保存,请先保存基础信息后再确认产品定厂',
)
}
if (
await this.canWriteWorkspaceRecordWithSettings(
record,
user,
category,
targetSkuItemIds,
)
) {
return
}
const message = '没有权限编辑这条非集成产品记录'
await recordIntakePermissionDenied({
record,
user,
category,
targetSkuItemIds,
action: OperationLogAction.INTAKE_PRODUCT_WRITE,
message,
})
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, message)
}
assertCanUploadDetachedAsset(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
): void {
if (this.canUploadDetachedAsset(record, user)) {
return
}
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限编辑这条非集成产品记录')
}
async assertCanDownloadWorkspaceRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): Promise<void> {
if (await this.canDownloadWorkspaceRecordWithSettings(record, user, category)) {
return
}
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限下载这条非集成产品资料')
}
async assertCanPreviewWorkspaceRecord(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
category?: string | null,
): Promise<void> {
if (await this.canPreviewWorkspaceRecordWithSettings(record, user, category)) {
return
}
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限查看这条非集成产品资料')
}
assertCanPushProductPricing(
record: IntakeNonIntegratedPermissionTarget,
user: AuthUser,
): void {
if (this.canPushProductPricing(record, user)) {
return
}
const message = '没有权限推送这条非集成产品核价'
void recordIntakePermissionDenied({
record,
user,
action: OperationLogAction.JUSHUITAN_PRODUCT_PUSH,
message,
})
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, message)
}
assertCanDeleteRecord(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): void {
if (canDeleteNonIntegratedProductRecordByIntakePermission(user, record)) {
return
}
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
isIntakeNonIntegratedDesignDepartmentUser(user)
? '设计师仅可删除自己创建的产品'
: '没有权限删除这条非集成产品记录',
)
}
}
export const intakeNonIntegratedScopeService = new IntakeNonIntegratedScopeService()
export { IntakeNonIntegratedScopeService }