fix: stabilize intake navigation requests
This commit is contained in:
parent
6eb624f8e4
commit
d374470151
@ -1,6 +1,7 @@
|
||||
import {
|
||||
cleanupSharedNonIntegratedAiWhiteBackgroundManifest,
|
||||
generateSharedNonIntegratedAiWhiteBackgroundSession,
|
||||
listSharedNonIntegratedProductPage,
|
||||
uploadDetachedNonIntegratedFile,
|
||||
uploadSharedNonIntegratedAiWhiteBackgroundSession,
|
||||
} from './nonIntegratedApi'
|
||||
@ -80,6 +81,34 @@ describe('nonIntegratedApi uploads', () => {
|
||||
restoreXMLHttpRequest()
|
||||
})
|
||||
|
||||
it('clamps list pageSize to 100 before sending the request', async () => {
|
||||
const fetchMock = jest.fn(async () =>
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
products: [],
|
||||
pageInfo: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
setFetch(fetchMock as typeof fetch)
|
||||
|
||||
await listSharedNonIntegratedProductPage({
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
spu: 'SZ0242',
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'/api/intake/non-integrated?page=1&pageSize=100&spu=SZ0242',
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads detached files without a product id through the temporary upload endpoint', async () => {
|
||||
const fetchMock = jest.fn(async () =>
|
||||
jsonResponse({
|
||||
|
||||
@ -19,6 +19,7 @@ type ApiEnvelope<T> = {
|
||||
const NON_INTEGRATED_UPLOAD_CHUNK_BYTES = 512 * 1024
|
||||
const NON_INTEGRATED_API_TIMEOUT_MS = 60_000
|
||||
const NON_INTEGRATED_AI_WHITE_BACKGROUND_TIMEOUT_MS = 420_000
|
||||
const NON_INTEGRATED_MAX_LIST_PAGE_SIZE = 100
|
||||
|
||||
export type NonIntegratedUploadProgress = {
|
||||
loadedBytes: number
|
||||
@ -211,6 +212,13 @@ function normalizeUploadPercent(value: number) {
|
||||
return Math.min(100, Math.max(0, Math.round(value)))
|
||||
}
|
||||
|
||||
function normalizeListPageSizeParam(value: unknown) {
|
||||
const pageSize = Number(value)
|
||||
if (!Number.isFinite(pageSize) || pageSize <= 0) return undefined
|
||||
|
||||
return Math.min(NON_INTEGRATED_MAX_LIST_PAGE_SIZE, Math.max(1, Math.floor(pageSize)))
|
||||
}
|
||||
|
||||
function reportFileUploadProgress(
|
||||
onProgress: NonIntegratedUploadProgressHandler | undefined,
|
||||
fileSize: number,
|
||||
@ -293,7 +301,18 @@ export async function listSharedNonIntegratedProductPage(
|
||||
params: NonIntegratedProductListParams = {},
|
||||
) {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalizedParams: Record<string, unknown> = { ...params }
|
||||
|
||||
if (params.pageSize !== undefined) {
|
||||
const pageSize = normalizeListPageSizeParam(params.pageSize)
|
||||
if (pageSize === undefined) {
|
||||
delete normalizedParams.pageSize
|
||||
} else {
|
||||
normalizedParams.pageSize = pageSize
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(normalizedParams).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
import {
|
||||
clearIntakeReferenceLibraryClientCacheForTests,
|
||||
listIntakeReferenceLibraryItems,
|
||||
replaceIntakeReferenceLibraryItems,
|
||||
} from './intake-reference-library-client'
|
||||
import type { IntakeReferenceLibraryItemDTO } from '@/server/services'
|
||||
|
||||
const fetchMock = jest.fn()
|
||||
|
||||
function createItem(overrides: Partial<IntakeReferenceLibraryItemDTO> = {}): IntakeReferenceLibraryItemDTO {
|
||||
return {
|
||||
id: 'ACCESSORY-A001',
|
||||
libraryType: 'ACCESSORY',
|
||||
itemKey: 'A001',
|
||||
code: 'A001',
|
||||
name: '螺丝',
|
||||
category: '配件',
|
||||
letter: 'A',
|
||||
payload: {},
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
createdAt: '2026-06-16T00:00:00.000Z',
|
||||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function okResponse(items: IntakeReferenceLibraryItemDTO[]) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { items },
|
||||
}),
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('intake-reference-library-client', () => {
|
||||
let nowSpy: jest.SpyInstance<number, []>
|
||||
|
||||
beforeEach(() => {
|
||||
clearIntakeReferenceLibraryClientCacheForTests()
|
||||
fetchMock.mockReset()
|
||||
fetchMock.mockResolvedValue(okResponse([createItem()]))
|
||||
global.fetch = fetchMock as never
|
||||
nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('dedupes in-flight requests and reuses a fresh client cache entry', async () => {
|
||||
const [first, second] = await Promise.all([
|
||||
listIntakeReferenceLibraryItems('ACCESSORY'),
|
||||
listIntakeReferenceLibraryItems('ACCESSORY'),
|
||||
])
|
||||
const cached = await listIntakeReferenceLibraryItems('ACCESSORY')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(first).toEqual(second)
|
||||
expect(cached).toEqual(first)
|
||||
})
|
||||
|
||||
it('expires the client cache after 5 minutes', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(okResponse([createItem({ itemKey: 'A001', code: 'A001' })]))
|
||||
.mockResolvedValueOnce(okResponse([createItem({ itemKey: 'A002', code: 'A002' })]))
|
||||
|
||||
const first = await listIntakeReferenceLibraryItems('ACCESSORY')
|
||||
nowSpy.mockReturnValue(5 * 60 * 1000 + 1)
|
||||
const second = await listIntakeReferenceLibraryItems('ACCESSORY')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(first[0]?.code).toBe('A001')
|
||||
expect(second[0]?.code).toBe('A002')
|
||||
})
|
||||
|
||||
it('invalidates same-type cached list after replaceAll', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(okResponse([createItem({ itemKey: 'OLD', code: 'OLD' })]))
|
||||
.mockResolvedValueOnce(okResponse([createItem({ itemKey: 'NEW', code: 'NEW' })]))
|
||||
.mockResolvedValueOnce(okResponse([createItem({ itemKey: 'FRESH', code: 'FRESH' })]))
|
||||
|
||||
await listIntakeReferenceLibraryItems('ACCESSORY')
|
||||
await replaceIntakeReferenceLibraryItems('ACCESSORY', [
|
||||
{
|
||||
itemKey: 'NEW',
|
||||
code: 'NEW',
|
||||
name: '新配件',
|
||||
},
|
||||
])
|
||||
const fresh = await listIntakeReferenceLibraryItems('ACCESSORY')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(fresh[0]?.code).toBe('FRESH')
|
||||
})
|
||||
})
|
||||
@ -37,6 +37,22 @@ export type IntakeReferenceLibraryClientItem = {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const REFERENCE_LIBRARY_CLIENT_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
type ReferenceLibraryClientCacheEntry = {
|
||||
expiresAt: number
|
||||
promise: Promise<IntakeReferenceLibraryItemDTO[]>
|
||||
}
|
||||
|
||||
const referenceLibraryClientCache = new Map<
|
||||
IntakeReferenceLibraryType,
|
||||
ReferenceLibraryClientCacheEntry
|
||||
>()
|
||||
|
||||
export function clearIntakeReferenceLibraryClientCacheForTests() {
|
||||
referenceLibraryClientCache.clear()
|
||||
}
|
||||
|
||||
function readText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
@ -131,16 +147,37 @@ export async function replaceAlphabetReferenceLibraryItems(
|
||||
export async function listIntakeReferenceLibraryItems(
|
||||
libraryType: IntakeReferenceLibraryType,
|
||||
): Promise<IntakeReferenceLibraryItemDTO[]> {
|
||||
const data = await requestReferenceLibrary<{ items: IntakeReferenceLibraryItemDTO[] }>(
|
||||
const now = Date.now()
|
||||
const cached = referenceLibraryClientCache.get(libraryType)
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise
|
||||
}
|
||||
|
||||
const promise = requestReferenceLibrary<{ items: IntakeReferenceLibraryItemDTO[] }>(
|
||||
`/api/intake/reference-library?type=${encodeURIComponent(libraryType)}`,
|
||||
)
|
||||
return data.items
|
||||
).then((data) => data.items)
|
||||
|
||||
referenceLibraryClientCache.set(libraryType, {
|
||||
expiresAt: now + REFERENCE_LIBRARY_CLIENT_CACHE_TTL_MS,
|
||||
promise,
|
||||
})
|
||||
|
||||
promise.catch(() => {
|
||||
if (referenceLibraryClientCache.get(libraryType)?.promise === promise) {
|
||||
referenceLibraryClientCache.delete(libraryType)
|
||||
}
|
||||
})
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function replaceIntakeReferenceLibraryItems(
|
||||
libraryType: IntakeReferenceLibraryType,
|
||||
items: IntakeReferenceLibraryClientItem[],
|
||||
): Promise<IntakeReferenceLibraryItemDTO[]> {
|
||||
referenceLibraryClientCache.delete(libraryType)
|
||||
|
||||
const data = await requestReferenceLibrary<{ items: IntakeReferenceLibraryItemDTO[] }>(
|
||||
'/api/intake/reference-library',
|
||||
{
|
||||
@ -160,6 +197,7 @@ export async function replaceIntakeReferenceLibraryItems(
|
||||
}),
|
||||
},
|
||||
)
|
||||
referenceLibraryClientCache.delete(libraryType)
|
||||
return data.items
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$transaction: jest.fn(),
|
||||
intakeNonIntegratedProduct: {
|
||||
count: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
@ -10,6 +11,14 @@ jest.mock('@/lib/prisma', () => ({
|
||||
createMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
intakeNonIntegratedPricingRow: {
|
||||
createMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
intakeNonIntegratedProductLimitRow: {
|
||||
createMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -19,6 +28,7 @@ import { IntakeNonIntegratedProductRepositoryClass } from '../intake-non-integra
|
||||
const mockPrisma = prisma as unknown as {
|
||||
$transaction: jest.Mock
|
||||
intakeNonIntegratedProduct: {
|
||||
count: jest.Mock
|
||||
findMany: jest.Mock
|
||||
create: jest.Mock
|
||||
update: jest.Mock
|
||||
@ -27,6 +37,14 @@ const mockPrisma = prisma as unknown as {
|
||||
createMany: jest.Mock
|
||||
deleteMany: jest.Mock
|
||||
}
|
||||
intakeNonIntegratedPricingRow: {
|
||||
createMany: jest.Mock
|
||||
deleteMany: jest.Mock
|
||||
}
|
||||
intakeNonIntegratedProductLimitRow: {
|
||||
createMany: jest.Mock
|
||||
deleteMany: jest.Mock
|
||||
}
|
||||
}
|
||||
|
||||
describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => {
|
||||
@ -98,6 +116,35 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('lists active pages with parallel reads instead of wrapping read queries in a transaction', async () => {
|
||||
const repository = new IntakeNonIntegratedProductRepositoryClass()
|
||||
const records = [
|
||||
{
|
||||
id: 'active-1',
|
||||
spu: 'DNZ0001',
|
||||
},
|
||||
]
|
||||
mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue(records)
|
||||
mockPrisma.intakeNonIntegratedProduct.count.mockResolvedValue(42)
|
||||
|
||||
const result = await repository.listActivePage({
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ records, total: 42 })
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 20,
|
||||
take: 20,
|
||||
}),
|
||||
)
|
||||
expect(mockPrisma.intakeNonIntegratedProduct.count).toHaveBeenCalledWith({
|
||||
where: expect.any(Object),
|
||||
})
|
||||
})
|
||||
|
||||
it('creates structured SKU rows after creating the product record', async () => {
|
||||
const repository = new IntakeNonIntegratedProductRepositoryClass()
|
||||
mockPrisma.intakeNonIntegratedProduct.create.mockResolvedValue({
|
||||
@ -168,6 +215,14 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => {
|
||||
callOrder.push('sku.createMany')
|
||||
return { count: 1 }
|
||||
})
|
||||
mockPrisma.intakeNonIntegratedPricingRow.deleteMany.mockImplementation(async () => {
|
||||
callOrder.push('pricing.deleteMany')
|
||||
return { count: 0 }
|
||||
})
|
||||
mockPrisma.intakeNonIntegratedProductLimitRow.deleteMany.mockImplementation(async () => {
|
||||
callOrder.push('productLimit.deleteMany')
|
||||
return { count: 0 }
|
||||
})
|
||||
|
||||
await repository.update('non-integrated-1', {
|
||||
payload: {},
|
||||
@ -188,7 +243,13 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => {
|
||||
},
|
||||
} as never)
|
||||
|
||||
expect(callOrder).toEqual(['product.update', 'sku.deleteMany', 'sku.createMany'])
|
||||
expect(callOrder).toEqual([
|
||||
'product.update',
|
||||
'sku.deleteMany',
|
||||
'pricing.deleteMany',
|
||||
'productLimit.deleteMany',
|
||||
'sku.createMany',
|
||||
])
|
||||
expect(mockPrisma.intakeNonIntegratedProduct.update).toHaveBeenCalledWith({
|
||||
where: { id: 'non-integrated-1' },
|
||||
data: expect.not.objectContaining({
|
||||
@ -198,6 +259,12 @@ describe('IntakeNonIntegratedProductRepositoryClass.findDeletedBySpu', () => {
|
||||
expect(mockPrisma.intakeNonIntegratedSkuItem.deleteMany).toHaveBeenCalledWith({
|
||||
where: { productId: 'non-integrated-1' },
|
||||
})
|
||||
expect(mockPrisma.intakeNonIntegratedPricingRow.deleteMany).toHaveBeenCalledWith({
|
||||
where: { productId: 'non-integrated-1' },
|
||||
})
|
||||
expect(mockPrisma.intakeNonIntegratedProductLimitRow.deleteMany).toHaveBeenCalledWith({
|
||||
where: { productId: 'non-integrated-1' },
|
||||
})
|
||||
expect(mockPrisma.intakeNonIntegratedSkuItem.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
|
||||
@ -336,8 +336,7 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
|
||||
: activeWhere
|
||||
const skip = (params.page - 1) * params.pageSize
|
||||
|
||||
const [records, total] = await this.prisma.$transaction([
|
||||
this.prisma.intakeNonIntegratedProduct.findMany({
|
||||
const recordsPromise = this.prisma.intakeNonIntegratedProduct.findMany({
|
||||
where,
|
||||
include: {
|
||||
profile: true,
|
||||
@ -362,8 +361,12 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
|
||||
},
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
}),
|
||||
this.prisma.intakeNonIntegratedProduct.count({ where }),
|
||||
})
|
||||
const totalPromise = this.prisma.intakeNonIntegratedProduct.count({ where })
|
||||
|
||||
const [records, total] = await Promise.all([
|
||||
recordsPromise,
|
||||
totalPromise,
|
||||
])
|
||||
|
||||
return { records, total }
|
||||
|
||||
@ -407,6 +407,25 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('caps non-integrated list page size at 100 before hitting the repository', async () => {
|
||||
mockedRepository.listActivePage.mockResolvedValue({
|
||||
records: [],
|
||||
total: 0,
|
||||
} as never)
|
||||
|
||||
const result = await intakeNonIntegratedProductService.listPage(baseUser, {
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
})
|
||||
|
||||
expect(mockedRepository.listActivePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageSize: 100,
|
||||
}),
|
||||
)
|
||||
expect(result.pageInfo.pageSize).toBe(100)
|
||||
})
|
||||
|
||||
it('does not let external intake users read another user record by id', async () => {
|
||||
mockedRepository.findActiveById.mockResolvedValue(
|
||||
createRecord({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { intakeReferenceLibraryService } from '../intake-reference-library.service'
|
||||
import { IntakeReferenceLibraryService } from '../intake-reference-library.service'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
@ -13,6 +13,7 @@ jest.mock('@/lib/prisma', () => ({
|
||||
}))
|
||||
|
||||
const mockedPrisma = jest.mocked(prisma)
|
||||
let service: IntakeReferenceLibraryService
|
||||
|
||||
function createLibraryRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@ -37,6 +38,7 @@ function createLibraryRow(overrides: Record<string, unknown> = {}) {
|
||||
describe('IntakeReferenceLibraryService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
service = new IntakeReferenceLibraryService()
|
||||
mockedPrisma.$transaction.mockImplementation(async (callback) =>
|
||||
callback({
|
||||
intakeReferenceLibraryItem: {
|
||||
@ -48,8 +50,68 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
mockedPrisma.intakeReferenceLibraryItem.findMany.mockResolvedValue([createLibraryRow()] as never)
|
||||
})
|
||||
|
||||
it('list 在短 TTL 内复用同类型查询,避免同页并发重复打数据库', async () => {
|
||||
let resolveRows: (rows: ReturnType<typeof createLibraryRow>[]) => void = () => {}
|
||||
mockedPrisma.intakeReferenceLibraryItem.findMany.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveRows = resolve
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const first = service.list('PACKAGING_MATERIAL')
|
||||
const second = service.list('PACKAGING_MATERIAL')
|
||||
|
||||
expect(mockedPrisma.intakeReferenceLibraryItem.findMany).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRows([createLibraryRow()])
|
||||
const [firstResult, secondResult] = await Promise.all([first, second])
|
||||
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
expect(firstResult[0]).toEqual(expect.objectContaining({
|
||||
libraryType: 'PACKAGING_MATERIAL',
|
||||
code: 'A001',
|
||||
}))
|
||||
})
|
||||
|
||||
it('replaceAll 成功后立即失效同类型缓存并返回新列表', async () => {
|
||||
mockedPrisma.intakeReferenceLibraryItem.findMany
|
||||
.mockResolvedValueOnce([
|
||||
createLibraryRow({
|
||||
code: 'OLD',
|
||||
itemKey: 'OLD',
|
||||
name: '旧纸箱',
|
||||
}),
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
createLibraryRow({
|
||||
code: 'NEW',
|
||||
itemKey: 'NEW',
|
||||
name: '新纸箱',
|
||||
}),
|
||||
] as never)
|
||||
|
||||
await service.list('PACKAGING_MATERIAL')
|
||||
const result = await service.replaceAll({
|
||||
libraryType: 'PACKAGING_MATERIAL',
|
||||
userId: 'user-1',
|
||||
items: [
|
||||
{
|
||||
itemKey: 'NEW',
|
||||
code: 'NEW',
|
||||
name: '新纸箱',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockedPrisma.intakeReferenceLibraryItem.findMany).toHaveBeenCalledTimes(2)
|
||||
expect(result[0]).toEqual(expect.objectContaining({
|
||||
itemKey: 'NEW',
|
||||
code: 'NEW',
|
||||
}))
|
||||
})
|
||||
|
||||
it('replaceAll 使用数据库事务保存资料库条目,而不是浏览器本地缓存', async () => {
|
||||
const result = await intakeReferenceLibraryService.replaceAll({
|
||||
const result = await service.replaceAll({
|
||||
libraryType: 'PACKAGING_MATERIAL',
|
||||
userId: 'user-1',
|
||||
items: [
|
||||
@ -105,7 +167,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
it('replaceAll 拒绝同一资料库内重复编码,避免服务端继续制造脏数据', async () => {
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'ACCESSORY',
|
||||
items: [
|
||||
{ itemKey: 'screw-a', code: 'A001', name: '螺丝 A' },
|
||||
@ -119,7 +181,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
it('replaceAll 拒绝用列表顺序兜底 itemKey,避免资料身份漂移', async () => {
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'ACCESSORY',
|
||||
items: [
|
||||
{ name: '缺少稳定身份的配件' },
|
||||
@ -132,7 +194,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
it('replaceAll 不允许用数据库 id 兜底业务身份,避免前端临时 id 变成资料主键', async () => {
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'ACCESSORY',
|
||||
items: [
|
||||
{ id: 'temporary-client-id', name: '临时配件' },
|
||||
@ -145,7 +207,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
it('replaceAll 拒绝只有 itemKey 但缺少 code 的资料行', async () => {
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'ACCESSORY',
|
||||
items: [
|
||||
{ itemKey: 'ACC001', name: '缺少编码的配件' },
|
||||
@ -157,7 +219,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
})
|
||||
|
||||
it('replaceAll 缺少 itemKey 时只允许用稳定 code 兜底业务身份', async () => {
|
||||
await intakeReferenceLibraryService.replaceAll({
|
||||
await service.replaceAll({
|
||||
libraryType: 'ACCESSORY',
|
||||
userId: 'user-1',
|
||||
items: [
|
||||
@ -184,7 +246,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
})
|
||||
|
||||
it('replaceAll 保存资料库时用结构化列覆盖 payload 里的旧身份字段', async () => {
|
||||
await intakeReferenceLibraryService.replaceAll({
|
||||
await service.replaceAll({
|
||||
libraryType: 'ACCESSORY',
|
||||
userId: 'user-1',
|
||||
items: [
|
||||
@ -231,7 +293,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
it('SPU_RULE 必须有产品品类和有效号段前缀', async () => {
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'SPU_RULE',
|
||||
items: [
|
||||
{ itemKey: '餐桌', code: '12', name: '餐桌' },
|
||||
@ -240,7 +302,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
).rejects.toThrow('SPU 规则号段前缀无效')
|
||||
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'SPU_RULE',
|
||||
items: [
|
||||
{ itemKey: '餐桌', code: 'CZ', name: '' },
|
||||
@ -253,7 +315,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
it('SPU_RULE 保存时用结构化 code/name 规范化 payload,并拒绝重复产品品类', async () => {
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'SPU_RULE',
|
||||
items: [
|
||||
{ itemKey: '餐桌-a', code: 'CZA', name: '餐桌' },
|
||||
@ -265,7 +327,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
expect(mockedPrisma.$transaction).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
intakeReferenceLibraryService.replaceAll({
|
||||
service.replaceAll({
|
||||
libraryType: 'SPU_RULE',
|
||||
items: [
|
||||
{ itemKey: 'desk-a', code: 'DSA', name: 'Desk' },
|
||||
@ -276,7 +338,7 @@ describe('IntakeReferenceLibraryService', () => {
|
||||
|
||||
expect(mockedPrisma.$transaction).not.toHaveBeenCalled()
|
||||
|
||||
await intakeReferenceLibraryService.replaceAll({
|
||||
await service.replaceAll({
|
||||
libraryType: 'SPU_RULE',
|
||||
userId: 'user-1',
|
||||
items: [
|
||||
|
||||
@ -17,6 +17,7 @@ const PAYLOAD_IDENTITY_MIRROR_LIBRARY_TYPES = new Set<IntakeReferenceLibraryType
|
||||
'ACCESSORY',
|
||||
'ALPHABET_LIBRARY',
|
||||
])
|
||||
const LIST_CACHE_TTL_MS = 60_000
|
||||
|
||||
export type IntakeReferenceLibraryItemDTO = {
|
||||
id: string
|
||||
@ -53,6 +54,11 @@ type NormalizedReferenceLibraryItem = {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
type ReferenceLibraryListCacheEntry = {
|
||||
expiresAt: number
|
||||
promise: Promise<IntakeReferenceLibraryItemDTO[]>
|
||||
}
|
||||
|
||||
function normalizeLibraryType(value: string): IntakeReferenceLibraryType {
|
||||
const normalized = value.trim().toUpperCase()
|
||||
if (!INTAKE_REFERENCE_LIBRARY_TYPES.includes(normalized as IntakeReferenceLibraryType)) {
|
||||
@ -266,13 +272,34 @@ function toDTO(row: Awaited<ReturnType<typeof prisma.intakeReferenceLibraryItem.
|
||||
}
|
||||
|
||||
class IntakeReferenceLibraryService {
|
||||
private readonly listCache = new Map<IntakeReferenceLibraryType, ReferenceLibraryListCacheEntry>()
|
||||
|
||||
async list(libraryTypeInput: string): Promise<IntakeReferenceLibraryItemDTO[]> {
|
||||
const libraryType = normalizeLibraryType(libraryTypeInput)
|
||||
const rows = await prisma.intakeReferenceLibraryItem.findMany({
|
||||
const now = Date.now()
|
||||
const cached = this.listCache.get(libraryType)
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise
|
||||
}
|
||||
|
||||
const promise = prisma.intakeReferenceLibraryItem.findMany({
|
||||
where: { libraryType },
|
||||
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }, { name: 'asc' }, { itemKey: 'asc' }],
|
||||
}).then((rows) => rows.map(toDTO))
|
||||
|
||||
this.listCache.set(libraryType, {
|
||||
expiresAt: now + LIST_CACHE_TTL_MS,
|
||||
promise,
|
||||
})
|
||||
return rows.map(toDTO)
|
||||
|
||||
promise.catch(() => {
|
||||
if (this.listCache.get(libraryType)?.promise === promise) {
|
||||
this.listCache.delete(libraryType)
|
||||
}
|
||||
})
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
async replaceAll(input: ReplaceIntakeReferenceLibraryInput): Promise<IntakeReferenceLibraryItemDTO[]> {
|
||||
@ -283,6 +310,8 @@ class IntakeReferenceLibraryService {
|
||||
const itemKeys = items.map((item) => item.itemKey)
|
||||
const userId = input.userId?.trim() || null
|
||||
|
||||
this.listCache.delete(libraryType)
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (itemKeys.length > 0) {
|
||||
await tx.intakeReferenceLibraryItem.deleteMany({
|
||||
@ -332,6 +361,7 @@ class IntakeReferenceLibraryService {
|
||||
}
|
||||
})
|
||||
|
||||
this.listCache.delete(libraryType)
|
||||
return this.list(libraryType)
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,11 +164,33 @@ copy_legacy_env_if_needed() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
prepare_standalone_runtime() {
|
||||
local app_root="$1"
|
||||
local standalone_root="$app_root/.next/standalone"
|
||||
|
||||
if [ ! -f "$standalone_root/server.js" ]; then
|
||||
echo "Missing Next standalone server: $standalone_root/server.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$app_root/.next/static" ]; then
|
||||
echo "Missing Next static assets: $app_root/.next/static" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$standalone_root/.next"
|
||||
rm -rf -- "$standalone_root/.next/static" "$standalone_root/public"
|
||||
cp -a "$app_root/.next/static" "$standalone_root/.next/static"
|
||||
if [ -d "$app_root/public" ]; then
|
||||
cp -a "$app_root/public" "$standalone_root/public"
|
||||
fi
|
||||
}
|
||||
|
||||
start_pm2_release() {
|
||||
local release_root="$1"
|
||||
local app_root="$release_root/__PROJECT_SUBDIR__"
|
||||
local start_command
|
||||
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT"
|
||||
prepare_standalone_runtime "$app_root"
|
||||
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js"
|
||||
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
|
||||
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
|
||||
pm2 save >/dev/null
|
||||
|
||||
@ -222,11 +222,33 @@ copy_legacy_env_if_needed() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
prepare_standalone_runtime() {
|
||||
local app_root="$1"
|
||||
local standalone_root="$app_root/.next/standalone"
|
||||
|
||||
if [ ! -f "$standalone_root/server.js" ]; then
|
||||
echo "Missing Next standalone server: $standalone_root/server.js" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$app_root/.next/static" ]; then
|
||||
echo "Missing Next static assets: $app_root/.next/static" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$standalone_root/.next"
|
||||
rm -rf -- "$standalone_root/.next/static" "$standalone_root/public"
|
||||
cp -a "$app_root/.next/static" "$standalone_root/.next/static"
|
||||
if [ -d "$app_root/public" ]; then
|
||||
cp -a "$app_root/public" "$standalone_root/public"
|
||||
fi
|
||||
}
|
||||
|
||||
start_pm2_release() {
|
||||
local release_root="$1"
|
||||
local app_root="$release_root/$PROJECT_SUBDIR"
|
||||
local start_command
|
||||
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT"
|
||||
prepare_standalone_runtime "$app_root"
|
||||
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js"
|
||||
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
|
||||
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
|
||||
pm2 save >/dev/null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user