merge: release AI render catalog scope

This commit is contained in:
WuYongJie 2026-07-30 10:32:27 +08:00
commit 400bdaf1ef
4 changed files with 348 additions and 126 deletions

View File

@ -0,0 +1,122 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findMany: jest.fn(),
},
intakeNonIntegratedProduct: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import { IntakeAiAssetRepository } from '../intake-ai-asset.repository'
import {
buildDevelopmentKindWhere,
buildProductKindWhere,
} from '../intake-product-list-filters'
const mockPrisma = prisma as unknown as {
user: {
findMany: jest.Mock
}
intakeNonIntegratedProduct: {
findMany: jest.Mock
findFirst: jest.Mock
}
}
describe('IntakeAiAssetRepository catalog scope', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPrisma.user.findMany.mockResolvedValue([
{ id: 'external-intake-1' },
{ id: 'external-intake-2' },
])
mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([])
mockPrisma.intakeNonIntegratedProduct.findFirst.mockResolvedValue(null)
})
it('lists all product-development SPUs while excluding stock, series, and external intake', async () => {
const repository = new IntakeAiAssetRepository()
await repository.findCatalogProducts('')
expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
where: {
businessRoles: {
has: 'EXTERNAL_INTAKE',
},
},
select: { id: true },
})
expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
deletedAt: null,
AND: [
buildDevelopmentKindWhere('product'),
buildProductKindWhere('new'),
{
createdBy: {
notIn: ['external-intake-1', 'external-intake-2'],
},
},
],
},
}),
)
const query = mockPrisma.intakeNonIntegratedProduct.findMany.mock.calls[0][0]
expect(query).not.toHaveProperty('take')
})
it('keeps SPU and barcode search inside the same catalog scope', async () => {
const repository = new IntakeAiAssetRepository()
await repository.findCatalogProducts(' BAR-001 ')
const query = mockPrisma.intakeNonIntegratedProduct.findMany.mock.calls[0][0]
const searchWhere = query.where.AND[3]
expect(searchWhere.OR).toEqual(
expect.arrayContaining([
{ spu: { contains: 'BAR-001', mode: 'insensitive' } },
expect.objectContaining({
skuItems: {
some: {
OR: expect.arrayContaining([
{ barcode: { contains: 'BAR-001', mode: 'insensitive' } },
]),
},
},
}),
]),
)
})
it('applies the same scope when a product workspace is opened directly', async () => {
const repository = new IntakeAiAssetRepository()
await repository.findProductById('product-1')
expect(mockPrisma.intakeNonIntegratedProduct.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'product-1',
deletedAt: null,
AND: [
buildDevelopmentKindWhere('product'),
buildProductKindWhere('new'),
{
createdBy: {
notIn: ['external-intake-1', 'external-intake-2'],
},
},
],
},
}),
)
})
})

View File

@ -1,5 +1,9 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
buildDevelopmentKindWhere,
buildProductKindWhere,
} from './intake-product-list-filters'
const skuAiProfileInclude = {
currentModelVersion: {
@ -43,14 +47,27 @@ export interface CreateModelVersionInput {
createdBy: string
}
export class IntakeAiAssetRepository {
findCatalogProducts(query: string, take = 200) {
const normalizedQuery = query.trim()
return prisma.intakeNonIntegratedProduct.findMany({
where: {
deletedAt: null,
...(normalizedQuery
? {
function buildCatalogWhere(input: {
query?: string
productId?: string
excludedCreatorIds: string[]
}): Prisma.IntakeNonIntegratedProductWhereInput {
const normalizedQuery = input.query?.trim() ?? ''
const developmentWhere = buildDevelopmentKindWhere('product')
const productKindWhere = buildProductKindWhere('new')
return {
...(input.productId ? { id: input.productId } : {}),
deletedAt: null,
AND: [
...(developmentWhere ? [developmentWhere] : []),
...(productKindWhere ? [productKindWhere] : []),
...(input.excludedCreatorIds.length > 0
? [{ createdBy: { notIn: input.excludedCreatorIds } }]
: []),
...(normalizedQuery
? [
{
OR: [
{ spu: { contains: normalizedQuery, mode: 'insensitive' as const } },
{ spuBase: { contains: normalizedQuery, mode: 'insensitive' as const } },
@ -71,18 +88,40 @@ export class IntakeAiAssetRepository {
},
},
],
}
: {}),
},
]
: []),
],
}
}
export class IntakeAiAssetRepository {
private async findExcludedCatalogCreatorIds() {
const users = await prisma.user.findMany({
where: {
businessRoles: {
has: 'EXTERNAL_INTAKE',
},
},
select: { id: true },
})
return users.map((user) => user.id)
}
async findCatalogProducts(query: string) {
const excludedCreatorIds = await this.findExcludedCatalogCreatorIds()
return prisma.intakeNonIntegratedProduct.findMany({
where: buildCatalogWhere({ query, excludedCreatorIds }),
include: catalogProductInclude,
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
take,
})
}
findProductById(productId: string) {
async findProductById(productId: string) {
const excludedCreatorIds = await this.findExcludedCatalogCreatorIds()
return prisma.intakeNonIntegratedProduct.findFirst({
where: { id: productId, deletedAt: null },
where: buildCatalogWhere({ productId, excludedCreatorIds }),
include: catalogProductInclude,
})
}

View File

@ -0,0 +1,169 @@
import { Prisma } from '@prisma/client'
type ProductDevelopmentKind = 'product' | 'series' | undefined
type ProductKind = 'all' | 'new' | 'stock' | undefined
const JST_SKU_SOURCE_PATHS = [
['source'],
['sourceTable'],
['importSource'],
['externalSource'],
['metadata', 'source'],
['metadata', 'sourceTable'],
['metadata', 'importSource'],
['metadata', 'externalSource'],
] as const
function jsonTextEquals(
path: string[],
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
payload: {
path,
equals: value,
},
}
}
function jsonTextMissingOrNotEquals(
path: string[],
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{
NOT: jsonTextEquals(path, value),
},
{
payload: {
path,
equals: Prisma.AnyNull,
},
},
],
}
}
function profileTextEquals(
field: string,
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
profile: {
is: {
[field]: value,
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput
}
function profileTextNonEmpty(field: string): Prisma.IntakeNonIntegratedProductWhereInput {
return {
profile: {
is: {
[field]: {
not: '',
},
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput
}
function payloadJsonWhenProfileMissing(
condition: Prisma.IntakeNonIntegratedProductWhereInput,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
AND: [
{
profile: null,
},
condition,
],
}
}
function profileTextNotEqualsWhenPresent(
field: string,
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
AND: [
{
NOT: {
profile: null,
},
},
{
OR: [
{
NOT: profileTextEquals(field, value),
},
{
profile: {
is: {
[field]: '',
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput,
],
},
],
}
}
export function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{ id: { startsWith: 'jst-stock-' } },
profileTextNonEmpty('sourceSpu'),
...JST_SKU_SOURCE_PATHS.map((path) =>
payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')),
),
],
}
}
export function buildDevelopmentKindWhere(
productDevelopmentKind: ProductDevelopmentKind,
): Prisma.IntakeNonIntegratedProductWhereInput | null {
if (productDevelopmentKind === 'series') {
return {
OR: [
profileTextEquals('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')),
],
}
}
if (productDevelopmentKind === 'product') {
return {
OR: [
profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(
jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series'),
),
],
}
}
return null
}
export function buildProductKindWhere(
productKind: ProductKind,
): Prisma.IntakeNonIntegratedProductWhereInput | null {
const stockWhere = buildStockProductWhere()
if (productKind === 'stock') {
return stockWhere
}
if (productKind === 'new') {
return {
NOT: stockWhere,
}
}
return null
}

View File

@ -51,6 +51,11 @@ import {
intakeNonIntegratedProductRepository,
type IntakeNonIntegratedProductRepositoryClass,
} from '../repositories/intake-non-integrated-product.repository'
import {
buildDevelopmentKindWhere,
buildProductKindWhere,
buildStockProductWhere,
} from '../repositories/intake-product-list-filters'
import { userRepository } from '../repositories/user.repository'
import {
assetSkuGroupRepository,
@ -3066,25 +3071,6 @@ function jsonTextContains(
}
}
function jsonTextMissingOrNotEquals(
path: string[],
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{
NOT: jsonTextEquals(path, value),
},
{
payload: {
path,
equals: Prisma.AnyNull,
},
},
],
}
}
function profileTextEquals(
field: string,
value: string,
@ -3139,35 +3125,6 @@ function payloadJsonWhenProfileMissing(
}
}
function profileTextNotEqualsWhenPresent(
field: string,
value: string,
): Prisma.IntakeNonIntegratedProductWhereInput {
return {
AND: [
{
NOT: {
profile: null,
},
},
{
OR: [
{
NOT: profileTextEquals(field, value),
},
{
profile: {
is: {
[field]: '',
},
},
} as Prisma.IntakeNonIntegratedProductWhereInput,
],
},
],
}
}
function payloadTextContainsWhenProfileMissing(path: string[], keyword: string) {
return payloadJsonWhenProfileMissing(jsonTextContains(path, keyword))
}
@ -3184,71 +3141,6 @@ function buildTextContainsAny(
}
}
const JST_SKU_SOURCE_PATHS = [
['source'],
['sourceTable'],
['importSource'],
['externalSource'],
['metadata', 'source'],
['metadata', 'sourceTable'],
['metadata', 'importSource'],
['metadata', 'externalSource'],
] as const
function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput {
return {
OR: [
{ id: { startsWith: 'jst-stock-' } },
profileTextNonEmpty('sourceSpu'),
...JST_SKU_SOURCE_PATHS.map((path) =>
payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')),
),
],
}
}
function buildDevelopmentKindWhere(
productDevelopmentKind: IntakeNonIntegratedProductListParams['productDevelopmentKind'],
): Prisma.IntakeNonIntegratedProductWhereInput | null {
if (productDevelopmentKind === 'series') {
return {
OR: [
profileTextEquals('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')),
],
}
}
if (productDevelopmentKind === 'product') {
return {
OR: [
profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'),
payloadJsonWhenProfileMissing(jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series')),
],
}
}
return null
}
function buildProductKindWhere(
productKind: IntakeNonIntegratedProductListParams['productKind'],
): Prisma.IntakeNonIntegratedProductWhereInput | null {
const stockWhere = buildStockProductWhere()
if (productKind === 'stock') {
return stockWhere
}
if (productKind === 'new') {
return {
NOT: stockWhere,
}
}
return null
}
function buildSpuSearchWhere(spu: string): Prisma.IntakeNonIntegratedProductWhereInput | null {
const keyword = trimListFilter(spu)
if (!keyword) return null