wip: protect local material updates before mainline sync
This commit is contained in:
parent
f563bc9989
commit
5f2033ea90
@ -0,0 +1,46 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { packagingAssetVariantCreateSchema } from '../../platform/schemas'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const rawBody = await request.json().catch(() => null)
|
||||
const parsed = packagingAssetVariantCreateSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`包材货号参数不正确: ${parsed.error.message}`,
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const packagingFamilies = await assetCenterService.listFamilies({
|
||||
type: 'PACKAGING',
|
||||
})
|
||||
if (!packagingFamilies.some((family) => family.id === parsed.data.assetFamilyId)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '包材货号只能创建在包装资产族下')
|
||||
}
|
||||
|
||||
const result = await assetCenterService.createVariantWithSimilarity({
|
||||
...parsed.data,
|
||||
createdBy: ctx.user.id,
|
||||
}, {
|
||||
skipThumbnail: true,
|
||||
})
|
||||
return jsonSuccess(result, 201, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true, requireDesignWorkspaceWrite: true },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { AppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
|
||||
try {
|
||||
const componentDetail =
|
||||
await assetCenterService.ensureSeriesComponentDetailProduct(id, ctx.user)
|
||||
|
||||
return jsonSuccess({ componentDetail }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true, requireDesignWorkspaceWrite: true },
|
||||
)
|
||||
}
|
||||
@ -31,6 +31,7 @@ function readListParams(request: NextRequest): IntakeNonIntegratedProductListPar
|
||||
: searchParams.get('quickScope') === 'pending'
|
||||
? 'pending'
|
||||
: 'all',
|
||||
seriesName: searchParams.get('seriesName') ?? '',
|
||||
spu: searchParams.get('spu') ?? '',
|
||||
appearanceDesigner: searchParams.get('appearanceDesigner') ?? '',
|
||||
structureDesigner: searchParams.get('structureDesigner') ?? '',
|
||||
|
||||
33
furniture-3d-system/src/app/api/platform/schemas.test.ts
Normal file
33
furniture-3d-system/src/app/api/platform/schemas.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
assetVariantCreateSchema,
|
||||
packagingAssetVariantCreateSchema,
|
||||
} from './schemas'
|
||||
|
||||
describe('asset variant API schemas', () => {
|
||||
const basePayload = {
|
||||
assetFamilyId: 'family-packaging',
|
||||
name: '纸箱 60/40/20 黑色',
|
||||
status: 'DRAFT',
|
||||
dimensions: {},
|
||||
modelUrl: '',
|
||||
mountPoints: [],
|
||||
businessData: {
|
||||
materialMode: 'PACKAGING',
|
||||
},
|
||||
} as const
|
||||
|
||||
it('普通规格款创建仍要求模型地址', () => {
|
||||
expect(assetVariantCreateSchema.safeParse(basePayload).success).toBe(false)
|
||||
})
|
||||
|
||||
it('包材货号创建允许无模型和空挂载点', () => {
|
||||
const parsed = packagingAssetVariantCreateSchema.safeParse(basePayload)
|
||||
|
||||
expect(parsed.success).toBe(true)
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.modelUrl).toBe('')
|
||||
expect(parsed.data.mountPoints).toEqual([])
|
||||
expect(parsed.data.lossFactor).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -38,6 +38,11 @@ const assetVariantUvConfigSchema = z.object({
|
||||
version: z.literal(1),
|
||||
nodes: z.record(z.string(), assetVariantNodeUvConfigSchema),
|
||||
}) satisfies z.ZodType<AssetVariantUvConfig>
|
||||
const assetVariantMountPointSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
position: vector3Schema,
|
||||
rotation: vector3Schema.optional(),
|
||||
})
|
||||
|
||||
export const assetFamilyCreateSchema = z.object({
|
||||
type: z.enum(['BOARD', 'COMPONENT', 'HARDWARE', 'PACKAGING']),
|
||||
@ -97,13 +102,7 @@ export const assetVariantCreateSchema = z.object({
|
||||
supplierCode: z.string().trim().optional().nullable(),
|
||||
versionNo: z.string().trim().optional().nullable(),
|
||||
barcode: z.string().trim().optional().nullable(),
|
||||
mountPoints: z.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1),
|
||||
position: vector3Schema,
|
||||
rotation: vector3Schema.optional(),
|
||||
}),
|
||||
),
|
||||
mountPoints: z.array(assetVariantMountPointSchema),
|
||||
assemblyBasis: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
compatibilityRules: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
namingFields: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
@ -116,6 +115,12 @@ export const assetVariantCreateSchema = z.object({
|
||||
inheritedFromVariantId: z.string().trim().min(1).optional().nullable(),
|
||||
})
|
||||
|
||||
export const packagingAssetVariantCreateSchema = assetVariantCreateSchema.extend({
|
||||
modelUrl: z.string().trim().optional().default(''),
|
||||
mountPoints: z.array(assetVariantMountPointSchema).optional().default([]),
|
||||
lossFactor: z.number().positive().optional().nullable().default(null),
|
||||
})
|
||||
|
||||
export const assetVariantUpdateSchema = z.object({
|
||||
assetFamilyId: z.string().trim().min(1).optional(),
|
||||
code: z.string().trim().min(1).optional(),
|
||||
@ -132,13 +137,7 @@ export const assetVariantUpdateSchema = z.object({
|
||||
versionNo: z.string().trim().optional().nullable(),
|
||||
barcode: z.string().trim().optional().nullable(),
|
||||
mountPoints: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1),
|
||||
position: vector3Schema,
|
||||
rotation: vector3Schema.optional(),
|
||||
}),
|
||||
)
|
||||
.array(assetVariantMountPointSchema)
|
||||
.optional(),
|
||||
assemblyBasis: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
compatibilityRules: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
} from '@/server/services'
|
||||
import {
|
||||
createAssetVariantWithSimilarity,
|
||||
createPackagingAssetVariant,
|
||||
regenerateAssetVariantThumbnail,
|
||||
updateAssetVariant,
|
||||
} from '@/lib/open-product/platform-api'
|
||||
@ -31,6 +32,10 @@ jest.mock('next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: jest.fn() }),
|
||||
}))
|
||||
|
||||
jest.mock('@/components/ui/Modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
@ -205,6 +210,7 @@ jest.mock('@/lib/open-product/platform-api', () => ({
|
||||
createAssetFamily: jest.fn(),
|
||||
createAssetVariant: jest.fn(),
|
||||
createAssetVariantWithSimilarity: jest.fn(),
|
||||
createPackagingAssetVariant: jest.fn(),
|
||||
createSubstrate: jest.fn(),
|
||||
deleteAssetFamily: jest.fn(),
|
||||
deleteAssetVariant: jest.fn(),
|
||||
@ -227,6 +233,10 @@ const mockedCreateAssetVariantWithSimilarity =
|
||||
createAssetVariantWithSimilarity as jest.MockedFunction<
|
||||
typeof createAssetVariantWithSimilarity
|
||||
>
|
||||
const mockedCreatePackagingAssetVariant =
|
||||
createPackagingAssetVariant as jest.MockedFunction<
|
||||
typeof createPackagingAssetVariant
|
||||
>
|
||||
|
||||
function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
return {
|
||||
@ -378,6 +388,7 @@ describe('AssetCenterClient', () => {
|
||||
mockedRegenerateAssetVariantThumbnail.mockReset()
|
||||
mockedUpdateAssetVariant.mockReset()
|
||||
mockedCreateAssetVariantWithSimilarity.mockReset()
|
||||
mockedCreatePackagingAssetVariant.mockReset()
|
||||
})
|
||||
|
||||
it('草稿资产在列表里不显示草稿状态,但仍保留提审操作', () => {
|
||||
@ -539,7 +550,7 @@ describe('AssetCenterClient', () => {
|
||||
expect(screen.getByRole('button', { name: '提审' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('包装页保留包材资料库入口,但不重复展示包材资料库里的业务分类卡片', async () => {
|
||||
it('包装页不展示包材资料库入口,也不重复展示包材资料库里的业务分类卡片', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
@ -567,11 +578,137 @@ describe('AssetCenterClient', () => {
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /包装/ }))
|
||||
|
||||
expect(screen.getByText('包材资料库')).toBeInTheDocument()
|
||||
expect(screen.queryByText('包材资料库')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('运输包装族')).toBeInTheDocument()
|
||||
expect(screen.queryByText('大桌外箱')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('包装资产族新增规格款时允许无模型创建包材货号', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
mockedCreatePackagingAssetVariant.mockResolvedValue({
|
||||
variant: createBoardVariant({
|
||||
id: 'variant-packaging-new',
|
||||
assetFamilyId: 'family-packaging',
|
||||
assetFamilyCode: 'DBZ-SPJK04',
|
||||
assetFamilyName: '纸箱资产族',
|
||||
assetType: 'PACKAGING',
|
||||
code: 'DBZ-SPJ01',
|
||||
name: '款式四 120/120/6 纸箱',
|
||||
identityKey: 'PACKAGING|code=DBZ-SPJ01',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: null,
|
||||
substrateCode: null,
|
||||
substrateName: null,
|
||||
finishId: null,
|
||||
finishName: null,
|
||||
dimensions: {},
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
mountPoints: [],
|
||||
businessData: {
|
||||
materialMode: 'PACKAGING',
|
||||
skuContentFields: {
|
||||
specName: '款式四 120/120/6 纸箱',
|
||||
material: '瓦楞纸',
|
||||
},
|
||||
},
|
||||
}),
|
||||
similarCandidates: [],
|
||||
similarityNotice: null,
|
||||
})
|
||||
|
||||
render(
|
||||
<AssetCenterClient
|
||||
initialFamilies={[
|
||||
createFamily({
|
||||
id: 'family-packaging',
|
||||
type: 'PACKAGING',
|
||||
code: 'DBZ-SPJK04',
|
||||
name: '纸箱资产族',
|
||||
}),
|
||||
]}
|
||||
initialVariants={[]}
|
||||
initialSubstrates={[]}
|
||||
initialAssetSeries={assetSeries}
|
||||
finishes={[]}
|
||||
materialCategories={[]}
|
||||
metrics={[]}
|
||||
user={user}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /包装/ }))
|
||||
await actor.click(screen.getByText('纸箱资产族'))
|
||||
await actor.click(screen.getByRole('button', { name: /新增包材货号/ }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '新增包材货号' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('DBZ-SPJ01')).toBeInTheDocument()
|
||||
await actor.type(screen.getByLabelText('供应商名称'), '兰考华兰家具')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('N1280')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('DBZ-SPJ01N1280V01')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('ModelUploader')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('模型与装配参数')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('3D 模型')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('板材类型')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('包材资料')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('颜色')).not.toHaveAttribute('readonly')
|
||||
expect(screen.getByLabelText('材质')).not.toHaveAttribute('readonly')
|
||||
|
||||
await actor.type(screen.getByLabelText('长度'), '1200')
|
||||
await actor.type(screen.getByLabelText('宽度'), '1200')
|
||||
await actor.type(screen.getByLabelText('厚度'), '60')
|
||||
await actor.type(screen.getByLabelText('颜色'), '纸箱')
|
||||
await actor.type(screen.getByLabelText('材质'), '瓦楞纸')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('规格名称')).toHaveValue('款式四 120/120/6 纸箱')
|
||||
})
|
||||
await actor.click(screen.getByRole('button', { name: '创建包材货号' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCreatePackagingAssetVariant).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const payload = mockedCreatePackagingAssetVariant.mock.calls[0]?.[0]
|
||||
expect(payload).toEqual(expect.objectContaining({
|
||||
code: 'DBZ-SPJ01',
|
||||
supplierName: '兰考华兰家具',
|
||||
supplierCode: 'N1280',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
}))
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
assetFamilyId: 'family-packaging',
|
||||
name: '款式四 120/120/6 纸箱',
|
||||
status: 'DRAFT',
|
||||
dimensions: {
|
||||
length: 1200,
|
||||
width: 1200,
|
||||
thickness: 60,
|
||||
},
|
||||
modelUrl: '',
|
||||
mountPoints: [],
|
||||
lossFactor: null,
|
||||
}),
|
||||
)
|
||||
expect(payload?.businessData).toEqual(
|
||||
expect.objectContaining({
|
||||
materialMode: 'PACKAGING',
|
||||
skuContentFields: expect.objectContaining({
|
||||
model: 'DBZ-SPJK04',
|
||||
skuCode: 'DBZ-SPJ01',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
specName: '款式四 120/120/6 纸箱',
|
||||
color: '纸箱',
|
||||
material: '瓦楞纸',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('提供待审核筛选,提审后的资产可以集中查看', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -978,16 +1115,13 @@ describe('AssetCenterClient', () => {
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '保存' }))
|
||||
const duplicateDialog = within(screen.getByRole('dialog', { name: '另存为新规格款' }))
|
||||
expect(duplicateDialog.getByLabelText('编码')).toHaveValue('BOARD-030-A')
|
||||
expect(duplicateDialog.getByPlaceholderText('例如 桌面板 1200 白橡')).toHaveValue('UV 桌面板 A')
|
||||
expect(duplicateDialog.getByLabelText('尺寸标签')).toHaveValue('1200x600x18')
|
||||
expect(duplicateDialog.getByLabelText('尺寸标签')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('货号')).toHaveValue('')
|
||||
expect(duplicateDialog.getByLabelText('规格名称')).toHaveValue('UV 桌面板 A 120/60/1.8 胡桃木纹')
|
||||
expect(duplicateDialog.getByLabelText('尺寸')).toHaveValue('120/60/1.8')
|
||||
expect(duplicateDialog.getByLabelText('长度')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('宽度')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('厚度')).toBeDisabled()
|
||||
|
||||
await actor.clear(duplicateDialog.getByLabelText('编码'))
|
||||
await actor.type(duplicateDialog.getByLabelText('编码'), 'BOARD-030-B')
|
||||
await actor.click(screen.getByRole('button', { name: '另存为新规格款' }))
|
||||
|
||||
expect(mockedCreateAssetVariantWithSimilarity).toHaveBeenCalledWith(
|
||||
@ -1019,6 +1153,7 @@ describe('AssetCenterClient', () => {
|
||||
operationId: expect.stringMatching(/^variant-save-/),
|
||||
}),
|
||||
)
|
||||
expect(mockedCreateAssetVariantWithSimilarity.mock.calls[0]?.[0]).not.toHaveProperty('code')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/UV 桌面板 B/)).toBeInTheDocument()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -249,7 +249,7 @@ describe('AssetCenterClient workflow', () => {
|
||||
expect(screen.getByRole('heading', { name: '新建资产族' })).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('品类系列')).not.toBeInTheDocument()
|
||||
|
||||
await actor.type(screen.getByLabelText('编码'), 'BOARD-001')
|
||||
expect(screen.getByLabelText('编码')).toBeDisabled()
|
||||
await actor.type(screen.getByLabelText('名称'), '桌面板')
|
||||
await actor.click(screen.getByRole('checkbox', { name: '桌面板' }))
|
||||
await actor.click(screen.getByRole('button', { name: '创建' }))
|
||||
@ -257,11 +257,11 @@ describe('AssetCenterClient workflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(createAssetFamily).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'BOARD',
|
||||
code: 'BOARD-001',
|
||||
name: '桌面板',
|
||||
connectionRoles: ['desktop_surface'],
|
||||
}))
|
||||
})
|
||||
expect(jest.mocked(createAssetFamily).mock.calls[0]?.[0]).not.toHaveProperty('code')
|
||||
})
|
||||
|
||||
it('CAD 导入继续从资产族内部发起,基础资料维护改走独立资料页入口', async () => {
|
||||
@ -421,8 +421,8 @@ describe('AssetCenterClient workflow', () => {
|
||||
await actor.click(screen.getByRole('button', { name: '新增规格款' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '新建规格款' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('编码')).toHaveValue('')
|
||||
expect(screen.getAllByLabelText('名称')[0]).toHaveValue('BOARD-001-')
|
||||
expect(screen.getByLabelText('货号')).toHaveValue('')
|
||||
expect(screen.getByLabelText('规格名称')).toHaveValue('BOARD-001-')
|
||||
})
|
||||
|
||||
it('编辑资产族时会稳定回显中文连接角色,并把修改后的角色定义提交保存', async () => {
|
||||
|
||||
@ -1,13 +1,48 @@
|
||||
import { Layers3, Package, Palette, Ruler } from 'lucide-react'
|
||||
import { requireSession } from '@/lib/server-session'
|
||||
import { assetCenterService, materialService } from '@/server/services'
|
||||
import {
|
||||
assetCenterService,
|
||||
intakeNonIntegratedProductService,
|
||||
materialService,
|
||||
type AssetSeriesComponentDetailSummaryDTO,
|
||||
} from '@/server/services'
|
||||
import type { NonIntegratedProductRecord } from '@/app/intake/non-integrated/nonIntegratedStorage'
|
||||
import AssetCenterClient from './AssetCenterClient'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
function readSeriesComponentDetailSummary(
|
||||
product: NonIntegratedProductRecord,
|
||||
): AssetSeriesComponentDetailSummaryDTO | null {
|
||||
const metadata = product.metadata
|
||||
if (
|
||||
!metadata ||
|
||||
metadata.componentDetailType !== 'asset-series-components'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceSeriesCode =
|
||||
typeof metadata.sourceSeriesCode === 'string'
|
||||
? metadata.sourceSeriesCode.trim().toUpperCase()
|
||||
: product.spu.trim().toUpperCase().replace(/-SPJ$/, '')
|
||||
const sourceSeriesName =
|
||||
typeof metadata.sourceSeriesName === 'string'
|
||||
? metadata.sourceSeriesName.trim()
|
||||
: ''
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
spu: product.spu,
|
||||
sourceSeriesCode,
|
||||
sourceSeriesName,
|
||||
created: false,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AssetCenterPage() {
|
||||
const { user } = await requireSession('/login')
|
||||
const { user, authUser } = await requireSession('/login')
|
||||
|
||||
const families = await assetCenterService.listFamilies()
|
||||
const variants = await assetCenterService.listVariantsByFamilyIds(
|
||||
@ -20,6 +55,19 @@ export default async function AssetCenterPage() {
|
||||
const finishes = await materialService.findAll({ limit: 200 })
|
||||
const materialCategories = await materialService.findAllCategories()
|
||||
const assetSeries = await assetCenterService.listSeries()
|
||||
const seriesComponentDetailPage =
|
||||
await intakeNonIntegratedProductService.listPage(authUser, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
productDevelopmentKind: 'series',
|
||||
})
|
||||
const initialSeriesComponentDetails = seriesComponentDetailPage.products
|
||||
.map((product) => readSeriesComponentDetailSummary(product))
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is AssetSeriesComponentDetailSummaryDTO => item !== null,
|
||||
)
|
||||
|
||||
return (
|
||||
<AssetCenterClient
|
||||
@ -28,6 +76,7 @@ export default async function AssetCenterPage() {
|
||||
initialSubstrates={substrates}
|
||||
initialSubstrateDictionaries={substrateDictionaries}
|
||||
initialAssetSeries={assetSeries}
|
||||
initialSeriesComponentDetails={initialSeriesComponentDetails}
|
||||
finishes={finishes}
|
||||
materialCategories={materialCategories}
|
||||
metrics={[
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
CircleDot,
|
||||
Layers3,
|
||||
Loader2,
|
||||
Package,
|
||||
Search,
|
||||
Send,
|
||||
Trash2,
|
||||
@ -40,14 +41,21 @@ import {
|
||||
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
|
||||
import { ViewportToolbarButton } from '@/components/platform/ViewportStageShell'
|
||||
import { extractResponseData, isApiFailure, isRecord, parseApiErrorPayload, readJsonSafely } from '@/lib/api-response'
|
||||
import { updatePreSkuBasicInfo } from '@/lib/open-product/platform-api'
|
||||
import {
|
||||
getPreSkuPackagingMaterialOptions,
|
||||
savePreSkuPackagingPlan,
|
||||
updatePreSkuBasicInfo,
|
||||
} from '@/lib/open-product/platform-api'
|
||||
import { readPreviewHoleOverlayState, readPreviewMaterialAssignments, readPreviewWorldNodes } from '@/lib/open-product/platform-view'
|
||||
import { cn, getApiErrorMessage } from '@/lib/utils'
|
||||
import type {
|
||||
DraftGroupDTO,
|
||||
DraftGroupDetailDTO,
|
||||
DraftGroupPreSkuDTO,
|
||||
GetPreSkuPackagingMaterialOptionsDTO,
|
||||
PreSkuBasicInfoFieldKey,
|
||||
PreSkuPackagingPlanDTO,
|
||||
SavePreSkuPackagingPlanInput,
|
||||
} from '@/server/services/draft-group.service.types'
|
||||
import {
|
||||
deriveDraftMaterialSections,
|
||||
@ -83,6 +91,32 @@ type SkuContentFieldKey = keyof SeriesDraftSkuFields
|
||||
type SkuContentFormValues = Partial<Record<SkuContentFieldKey, string>>
|
||||
type SkuContentAutoSaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'
|
||||
type PackageComponentAssignments = Record<string, string[]>
|
||||
type PackageMaterialAssignments = Record<string, PackageMaterialAssignment[]>
|
||||
|
||||
interface PackageMaterialAssignment {
|
||||
assetVariantId: string
|
||||
assetFamilyId?: string | null
|
||||
code?: string | null
|
||||
name?: string | null
|
||||
quantity: number
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
interface PackagingMaterialOption {
|
||||
assetVariantId: string
|
||||
assetFamilyId: string
|
||||
assetFamilyName: string
|
||||
code: string
|
||||
name: string
|
||||
supplierName: string | null
|
||||
costPrice: number | null
|
||||
}
|
||||
|
||||
interface PackagingMaterialOptionsState {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
options: PackagingMaterialOption[]
|
||||
}
|
||||
|
||||
interface SkuContentAutoSaveState {
|
||||
status: SkuContentAutoSaveStatus
|
||||
@ -176,6 +210,7 @@ const SKU_CONTENT_AUTO_SAVE_FIELD_KEYS = [
|
||||
const FORMAL_LEFT_PANE_WIDTH_STORAGE_KEY = 'draft-groups-formal-left-pane-width'
|
||||
const FORMAL_RIGHT_PANE_WIDTH_STORAGE_KEY = 'draft-groups-formal-right-pane-width'
|
||||
const FORMAL_PACKAGE_COMPONENT_ASSIGNMENTS_STORAGE_KEY = 'draft-groups-formal-package-component-assignments'
|
||||
const FORMAL_PACKAGE_MATERIAL_ASSIGNMENTS_STORAGE_KEY = 'draft-groups-formal-package-material-assignments'
|
||||
const DEFAULT_LEFT_PANE_WIDTH = 372
|
||||
const DEFAULT_RIGHT_PANE_WIDTH = 486
|
||||
const MIN_LEFT_PANE_WIDTH = 300
|
||||
@ -747,6 +782,124 @@ function writeStoredPackageComponentAssignments(
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePackageMaterialQuantity(value: unknown) {
|
||||
const numericValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: Number.parseFloat(typeof value === 'string' ? value : '')
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) return 1
|
||||
return Math.round(numericValue * 100) / 100
|
||||
}
|
||||
|
||||
function normalizePackageMaterialAssignments(
|
||||
assignments: PackageMaterialAssignment[],
|
||||
) {
|
||||
const seen = new Set<string>()
|
||||
return assignments.flatMap((assignment) => {
|
||||
const assetVariantId = assignment.assetVariantId?.trim()
|
||||
if (!assetVariantId || seen.has(assetVariantId)) return []
|
||||
seen.add(assetVariantId)
|
||||
|
||||
return [
|
||||
{
|
||||
assetVariantId,
|
||||
assetFamilyId: assignment.assetFamilyId?.trim() || null,
|
||||
code: assignment.code?.trim() || null,
|
||||
name: assignment.name?.trim() || null,
|
||||
quantity: normalizePackageMaterialQuantity(assignment.quantity),
|
||||
notes: assignment.notes?.trim() || null,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function readStoredPackageMaterialAssignments() {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(FORMAL_PACKAGE_MATERIAL_ASSIGNMENTS_STORAGE_KEY)
|
||||
const parsed = rawValue ? JSON.parse(rawValue) : null
|
||||
if (!isRecord(parsed)) return {}
|
||||
|
||||
return Object.entries(parsed).reduce(
|
||||
(assignments, [preSkuId, value]) => {
|
||||
if (!isRecord(value)) return assignments
|
||||
assignments[preSkuId] = Object.entries(value).reduce(
|
||||
(packageAssignments, [packageKey, materials]) => {
|
||||
if (!Array.isArray(materials)) return packageAssignments
|
||||
packageAssignments[getPackageKey(packageKey)] = normalizePackageMaterialAssignments(
|
||||
materials.filter(isRecord).map((material) => ({
|
||||
assetVariantId: typeof material.assetVariantId === 'string' ? material.assetVariantId : '',
|
||||
assetFamilyId: typeof material.assetFamilyId === 'string' ? material.assetFamilyId : null,
|
||||
code: typeof material.code === 'string' ? material.code : null,
|
||||
name: typeof material.name === 'string' ? material.name : null,
|
||||
quantity: normalizePackageMaterialQuantity(material.quantity),
|
||||
notes: typeof material.notes === 'string' ? material.notes : null,
|
||||
})),
|
||||
)
|
||||
return packageAssignments
|
||||
},
|
||||
{} as PackageMaterialAssignments,
|
||||
)
|
||||
return assignments
|
||||
},
|
||||
{} as Record<string, PackageMaterialAssignments>,
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredPackageMaterialAssignments(
|
||||
assignments: Record<string, PackageMaterialAssignments>,
|
||||
) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(
|
||||
FORMAL_PACKAGE_MATERIAL_ASSIGNMENTS_STORAGE_KEY,
|
||||
JSON.stringify(assignments),
|
||||
)
|
||||
}
|
||||
|
||||
function flattenPackagingMaterialOptions(
|
||||
optionPayload: GetPreSkuPackagingMaterialOptionsDTO | null | undefined,
|
||||
): PackagingMaterialOption[] {
|
||||
return (optionPayload?.families ?? []).flatMap(({ family, variants }) =>
|
||||
variants.map((variant) => ({
|
||||
assetVariantId: variant.id,
|
||||
assetFamilyId: variant.assetFamilyId,
|
||||
assetFamilyName: family.name,
|
||||
code: variant.code,
|
||||
name: variant.name,
|
||||
supplierName: variant.supplierName,
|
||||
costPrice: variant.costPrice,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function buildPackageMaterialAssignmentsFromPackagingPlan(
|
||||
packagingPlan: PreSkuPackagingPlanDTO | null | undefined,
|
||||
): PackageMaterialAssignments {
|
||||
const boxes = packagingPlan?.boxes ?? []
|
||||
if (boxes.length === 0) return {}
|
||||
|
||||
return boxes.reduce((assignments, box, index) => {
|
||||
const packageKey = boxes.length > 1
|
||||
? getPackageKey(getPackageSuffixByIndex(index))
|
||||
: 'single'
|
||||
assignments[packageKey] = normalizePackageMaterialAssignments(
|
||||
box.materials.map((material) => ({
|
||||
assetVariantId: material.assetVariantId,
|
||||
assetFamilyId: material.assetFamilyId,
|
||||
code: material.code,
|
||||
name: material.name,
|
||||
quantity: material.quantity,
|
||||
notes: material.notes,
|
||||
})),
|
||||
)
|
||||
return assignments
|
||||
}, {} as PackageMaterialAssignments)
|
||||
}
|
||||
|
||||
function getComponentDetailCode(component: NonIntegratedSkuComponentDetail) {
|
||||
return normalizeComponentCode(component.code || component.name || component.role)
|
||||
}
|
||||
@ -1058,12 +1211,14 @@ function MaterialPreview({
|
||||
preSku,
|
||||
packageRows,
|
||||
packageAssignments,
|
||||
packageMaterialAssignments,
|
||||
saving,
|
||||
onComponentPackageChange,
|
||||
}: {
|
||||
preSku: DraftGroupPreSkuDTO
|
||||
packageRows: SkuContentPreviewRow[]
|
||||
packageAssignments: PackageComponentAssignments
|
||||
packageMaterialAssignments: PackageMaterialAssignments
|
||||
saving: boolean
|
||||
onComponentPackageChange: (componentCode: string, packageKey: string | null) => void
|
||||
}) {
|
||||
@ -1165,6 +1320,73 @@ function MaterialPreview({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="rounded-[8px] border border-[#e2e7e8] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#edf0f1] bg-[#f8fafa] px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3.5 w-3.5 text-[#47646b]" />
|
||||
<p className="text-[12px] font-black text-[#2d3335]">包装 / 包材</p>
|
||||
</div>
|
||||
<span className="rounded-[4px] bg-white px-1.5 py-0.5 text-[10px] font-black text-[#47646b]">
|
||||
{Object.values(packageMaterialAssignments).reduce(
|
||||
(total, materials) => total + normalizePackageMaterialAssignments(materials).length,
|
||||
0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{packageRows.length === 0 ? (
|
||||
<p className="px-3 py-3 text-[12px] font-semibold text-[#5a6062]/58">
|
||||
暂未生成条码包结构
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-[#edf0f1]">
|
||||
{packageRows.map((row) => {
|
||||
const packageKey = getPackageKey(row.packageSuffix)
|
||||
const materials = normalizePackageMaterialAssignments(
|
||||
packageMaterialAssignments[packageKey] ?? [],
|
||||
)
|
||||
return (
|
||||
<div key={packageKey} className="px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[12px] font-black text-[#2d3335]">
|
||||
{getPackageLabel(packageKey)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-semibold text-[#5a6062]/62">
|
||||
{row.values.barcode || row.values.skuCode || '未生成条码'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-[4px] bg-[#edf4f3] px-1.5 py-0.5 text-[10px] font-black text-[#47646b]">
|
||||
{materials.length}
|
||||
</span>
|
||||
</div>
|
||||
{materials.length > 0 ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{materials.map((material) => (
|
||||
<div
|
||||
key={material.assetVariantId}
|
||||
className="flex items-center justify-between gap-2 rounded-[5px] bg-[#f7f9f9] px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate text-[11px] font-black text-[#2d3335]">
|
||||
{[material.code, material.name].filter(Boolean).join(' · ') ||
|
||||
material.assetVariantId}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] font-black text-[#47646b]">
|
||||
x{material.quantity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 rounded-[6px] border border-dashed border-[#cfd8da] bg-[#fbfcfc] p-2 text-[11px] font-semibold text-[#5a6062]/62">
|
||||
未配置纸箱、泡沫、护角等包装 SKU
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1174,14 +1396,30 @@ function BarcodePackagePreview({
|
||||
rows,
|
||||
componentDetails,
|
||||
packageAssignments,
|
||||
packageMaterialAssignments,
|
||||
materialOptions,
|
||||
materialOptionsLoading,
|
||||
materialOptionsError,
|
||||
saving,
|
||||
onMaterialAdd,
|
||||
onMaterialRemove,
|
||||
onMaterialQuantityChange,
|
||||
}: {
|
||||
activeRow: SkuContentPreviewRow | null
|
||||
rows: SkuContentPreviewRow[]
|
||||
componentDetails: NonIntegratedSkuComponentDetail[]
|
||||
packageAssignments: PackageComponentAssignments
|
||||
packageMaterialAssignments: PackageMaterialAssignments
|
||||
materialOptions: PackagingMaterialOption[]
|
||||
materialOptionsLoading: boolean
|
||||
materialOptionsError: string | null
|
||||
saving: boolean
|
||||
onMaterialAdd: (packageKey: string, material: PackagingMaterialOption) => void
|
||||
onMaterialRemove: (packageKey: string, assetVariantId: string) => void
|
||||
onMaterialQuantityChange: (packageKey: string, assetVariantId: string, quantity: number) => void
|
||||
}) {
|
||||
const [selectedMaterialId, setSelectedMaterialId] = useState('')
|
||||
|
||||
if (!activeRow) return null
|
||||
|
||||
const packageKey = getPackageKey(activeRow.packageSuffix)
|
||||
@ -1192,6 +1430,20 @@ function BarcodePackagePreview({
|
||||
componentDetails,
|
||||
)
|
||||
const selectedComponents = getComponentDetailsByCodes(componentDetails, selectedCodes)
|
||||
const selectedMaterials = normalizePackageMaterialAssignments(
|
||||
packageMaterialAssignments[packageKey] ?? [],
|
||||
)
|
||||
const materialOptionById = new Map(
|
||||
materialOptions.map((option) => [option.assetVariantId, option]),
|
||||
)
|
||||
const selectedMaterialIds = new Set(
|
||||
selectedMaterials.map((material) => material.assetVariantId),
|
||||
)
|
||||
const availableMaterialOptions = materialOptions.filter(
|
||||
(option) => !selectedMaterialIds.has(option.assetVariantId),
|
||||
)
|
||||
const selectedMaterialOption =
|
||||
availableMaterialOptions.find((option) => option.assetVariantId === selectedMaterialId) ?? null
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-[8px] border border-[#dbe3e5] bg-[#fbfcfc] p-3">
|
||||
@ -1236,6 +1488,111 @@ function BarcodePackagePreview({
|
||||
当前包还没有分配部件,请在下方 BOM 里选择 {getPackageLabel(packageKey)}。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 rounded-[7px] border border-[#e2e7e8] bg-white p-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[12px] font-black text-[#2d3335]">当前包包装 SKU</p>
|
||||
<span className="text-[10px] font-black text-[#5a6062]/55">
|
||||
{saving ? '保存中' : `${selectedMaterials.length} 项`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<select
|
||||
aria-label={`${getPackageLabel(packageKey)}包装SKU`}
|
||||
value={selectedMaterialOption ? selectedMaterialId : ''}
|
||||
disabled={saving || materialOptionsLoading || availableMaterialOptions.length === 0}
|
||||
onChange={(event) => setSelectedMaterialId(event.target.value)}
|
||||
className="h-8 min-w-0 flex-1 rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[12px] font-black text-[#2d3335] outline-none disabled:bg-[#f7f9f9] disabled:text-[#5a6062]/55"
|
||||
>
|
||||
<option value="">
|
||||
{materialOptionsLoading
|
||||
? '读取包装SKU'
|
||||
: availableMaterialOptions.length > 0
|
||||
? '选择包装SKU'
|
||||
: '暂无可选包装SKU'}
|
||||
</option>
|
||||
{availableMaterialOptions.map((option) => (
|
||||
<option key={option.assetVariantId} value={option.assetVariantId}>
|
||||
{[option.code, option.name, option.assetFamilyName].filter(Boolean).join(' · ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !selectedMaterialOption}
|
||||
onClick={() => {
|
||||
if (!selectedMaterialOption) return
|
||||
onMaterialAdd(packageKey, selectedMaterialOption)
|
||||
setSelectedMaterialId('')
|
||||
}}
|
||||
className="inline-flex h-8 shrink-0 items-center rounded-[5px] bg-[#2d3335] px-2.5 text-[11px] font-black text-white transition hover:bg-[#47646b] disabled:cursor-not-allowed disabled:bg-[#d7dddf] disabled:text-[#5a6062]/62"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{materialOptionsError ? (
|
||||
<p className="mt-2 text-[11px] font-semibold text-rose-700">
|
||||
{materialOptionsError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{selectedMaterials.length > 0 ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{selectedMaterials.map((material) => {
|
||||
const option = materialOptionById.get(material.assetVariantId)
|
||||
const displayCode = material.code || option?.code || material.assetVariantId
|
||||
const displayName = material.name || option?.name || '包装SKU'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.assetVariantId}
|
||||
className="grid grid-cols-[minmax(0,1fr)_72px_28px] items-center gap-1.5 rounded-[5px] bg-[#f7f9f9] px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[11px] font-black text-[#2d3335]">
|
||||
{displayCode}
|
||||
</p>
|
||||
<p className="truncate text-[10px] font-semibold text-[#5a6062]/62">
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
aria-label={`${displayCode}数量`}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={material.quantity}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
onMaterialQuantityChange(
|
||||
packageKey,
|
||||
material.assetVariantId,
|
||||
normalizePackageMaterialQuantity(event.target.value),
|
||||
)
|
||||
}
|
||||
className="h-7 w-full rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[11px] font-black text-[#2d3335] outline-none disabled:bg-[#eef2f3] disabled:text-[#5a6062]/55"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除${displayCode}`}
|
||||
disabled={saving}
|
||||
onClick={() => onMaterialRemove(packageKey, material.assetVariantId)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[5px] border border-rose-200 bg-white text-rose-700 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 rounded-[6px] border border-dashed border-[#cfd8da] bg-[#fbfcfc] p-2 text-[11px] font-semibold text-[#5a6062]/62">
|
||||
当前包还没有选择纸箱、泡沫、护角等包装 SKU。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1613,6 +1970,168 @@ function assignComponentToSinglePackage(
|
||||
}, {} as PackageComponentAssignments)
|
||||
}
|
||||
|
||||
function parsePackageItemQuantity(value: string | number | null | undefined) {
|
||||
const numericValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: Number.parseInt(value?.trim() ?? '', 10)
|
||||
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : 1
|
||||
}
|
||||
|
||||
function mapComponentAssetTypeToPackagingItemType(assetType: string | null | undefined) {
|
||||
const normalizedAssetType = assetType?.trim().toUpperCase() ?? ''
|
||||
if (normalizedAssetType === 'BOARD') return 'panel' as const
|
||||
if (normalizedAssetType === 'HARDWARE') return 'hardware' as const
|
||||
if (normalizedAssetType === 'PACKAGING') return 'accessory' as const
|
||||
return 'component' as const
|
||||
}
|
||||
|
||||
function normalizePackagingBoxDimension(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1
|
||||
}
|
||||
|
||||
function normalizePackagingBoxOuterSizeMm(
|
||||
box: PreSkuPackagingPlanDTO['boxes'][number] | undefined,
|
||||
) {
|
||||
return {
|
||||
length: normalizePackagingBoxDimension(box?.outerSizeMm.length),
|
||||
width: normalizePackagingBoxDimension(box?.outerSizeMm.width),
|
||||
height: normalizePackagingBoxDimension(box?.outerSizeMm.height),
|
||||
}
|
||||
}
|
||||
|
||||
function findPackagingInputItemForComponent(
|
||||
preSku: DraftGroupPreSkuDTO,
|
||||
component: NonIntegratedSkuComponentDetail,
|
||||
) {
|
||||
const componentCode = getComponentDetailCode(component)
|
||||
const componentName = normalizeComponentCode(component.name)
|
||||
const componentRole = normalizeComponentCode(component.role)
|
||||
|
||||
return preSku.packagingInput.items.find((item) => {
|
||||
const candidates = [
|
||||
item.refCode,
|
||||
item.name,
|
||||
item.role,
|
||||
item.slotKey,
|
||||
item.selectionKey,
|
||||
].map(normalizeComponentCode)
|
||||
return candidates.some((candidate) =>
|
||||
candidate &&
|
||||
(candidate === componentCode ||
|
||||
candidate === componentName ||
|
||||
candidate === componentRole),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function buildPackagingPlanItemsForComponents(
|
||||
preSku: DraftGroupPreSkuDTO,
|
||||
components: NonIntegratedSkuComponentDetail[],
|
||||
): SavePreSkuPackagingPlanInput['boxes'][number]['items'] {
|
||||
return components.flatMap((component) => {
|
||||
const componentCode = getComponentDetailCode(component)
|
||||
const inputItem = findPackagingInputItemForComponent(preSku, component)
|
||||
const refId = inputItem?.refId || componentCode
|
||||
const refCode = inputItem?.refCode || component.code || componentCode
|
||||
const name =
|
||||
inputItem?.name ||
|
||||
component.name ||
|
||||
component.role ||
|
||||
component.code ||
|
||||
componentCode
|
||||
|
||||
if (!refId || !refCode || !name) return []
|
||||
|
||||
return [
|
||||
{
|
||||
itemType:
|
||||
inputItem?.itemType ??
|
||||
mapComponentAssetTypeToPackagingItemType(component.assetType),
|
||||
refId,
|
||||
refCode,
|
||||
name,
|
||||
slotKey: inputItem?.slotKey ?? null,
|
||||
role: inputItem?.role || component.role || null,
|
||||
selectionKey: inputItem?.selectionKey ?? null,
|
||||
quantity: inputItem?.quantity ?? parsePackageItemQuantity(component.quantity),
|
||||
notes: null,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function buildSavePackagingPlanInput({
|
||||
preSku,
|
||||
packageRows,
|
||||
packageAssignments,
|
||||
packageMaterialAssignments,
|
||||
}: {
|
||||
preSku: DraftGroupPreSkuDTO
|
||||
packageRows: SkuContentPreviewRow[]
|
||||
packageAssignments: PackageComponentAssignments
|
||||
packageMaterialAssignments: PackageMaterialAssignments
|
||||
}): SavePreSkuPackagingPlanInput {
|
||||
const allComponentDetails = readPreSkuComponentDetails(preSku)
|
||||
const resolvedPackageAssignments = buildResolvedPackageComponentAssignments(
|
||||
packageRows,
|
||||
packageAssignments,
|
||||
allComponentDetails,
|
||||
)
|
||||
const existingBoxes = preSku.packagingPlan?.boxes ?? []
|
||||
const normalizedMaterialAssignments = Object.entries(packageMaterialAssignments).reduce(
|
||||
(next, [packageKey, assignments]) => {
|
||||
next[getPackageKey(packageKey)] = normalizePackageMaterialAssignments(assignments)
|
||||
return next
|
||||
},
|
||||
{} as PackageMaterialAssignments,
|
||||
)
|
||||
|
||||
return {
|
||||
sourceType: 'DRAFT_GROUP',
|
||||
sourceRefId: preSku.draftGroupId,
|
||||
status: 'DRAFT',
|
||||
boxes: packageRows.map((row, index) => {
|
||||
const packageKey = getPackageKey(row.packageSuffix)
|
||||
const assignedComponentCodes = resolvedPackageAssignments[packageKey] ?? []
|
||||
const selectedComponents =
|
||||
packageRows.length <= 1 && assignedComponentCodes.length === 0
|
||||
? allComponentDetails
|
||||
: getComponentDetailsByCodes(
|
||||
allComponentDetails,
|
||||
assignedComponentCodes,
|
||||
)
|
||||
const existingBox = existingBoxes[index]
|
||||
|
||||
return {
|
||||
boxNumber: index + 1,
|
||||
name: getPackageLabel(packageKey),
|
||||
outerSizeMm: normalizePackagingBoxOuterSizeMm(existingBox),
|
||||
items: buildPackagingPlanItemsForComponents(preSku, selectedComponents),
|
||||
materials: normalizePackageMaterialAssignments(
|
||||
normalizedMaterialAssignments[packageKey] ?? [],
|
||||
).map((material) => ({
|
||||
assetVariantId: material.assetVariantId,
|
||||
assetFamilyId: material.assetFamilyId ?? null,
|
||||
code: material.code ?? null,
|
||||
name: material.name ?? null,
|
||||
quantity: material.quantity,
|
||||
notes: material.notes ?? null,
|
||||
})),
|
||||
estimatedWeightKg: existingBox?.estimatedWeightKg ?? null,
|
||||
estimatedCost: existingBox?.estimatedCost ?? null,
|
||||
notes: existingBox?.notes ?? null,
|
||||
}
|
||||
}),
|
||||
costSummary: null,
|
||||
processDraft: {
|
||||
packageComponentAssignments: resolvedPackageAssignments,
|
||||
packageMaterialAssignments: normalizedMaterialAssignments,
|
||||
},
|
||||
notes: preSku.packagingPlan?.notes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPushedSkuItem({
|
||||
preSku,
|
||||
sequence,
|
||||
@ -1787,7 +2306,12 @@ function SkuContentPreview({
|
||||
saveState,
|
||||
activePackageKey,
|
||||
packageAssignments,
|
||||
packageMaterialAssignments,
|
||||
packagingMaterialOptions,
|
||||
packagingMaterialOptionsLoading,
|
||||
packagingMaterialOptionsError,
|
||||
packageComponentSaving,
|
||||
packagingPlanSaving,
|
||||
formalSeriesSpu,
|
||||
formalSkuCode,
|
||||
pushDisabledReason,
|
||||
@ -1797,6 +2321,9 @@ function SkuContentPreview({
|
||||
actionError,
|
||||
onFieldChange,
|
||||
onTogglePackage,
|
||||
onPackageMaterialAdd,
|
||||
onPackageMaterialRemove,
|
||||
onPackageMaterialQuantityChange,
|
||||
onPush,
|
||||
onDeleteDraftGroup,
|
||||
}: {
|
||||
@ -1809,7 +2336,12 @@ function SkuContentPreview({
|
||||
saveState: SkuContentAutoSaveState | null
|
||||
activePackageKey: string | null
|
||||
packageAssignments: PackageComponentAssignments
|
||||
packageMaterialAssignments: PackageMaterialAssignments
|
||||
packagingMaterialOptions: PackagingMaterialOption[]
|
||||
packagingMaterialOptionsLoading: boolean
|
||||
packagingMaterialOptionsError: string | null
|
||||
packageComponentSaving: boolean
|
||||
packagingPlanSaving: boolean
|
||||
formalSeriesSpu: string
|
||||
formalSkuCode: string
|
||||
pushDisabledReason: string
|
||||
@ -1819,6 +2351,9 @@ function SkuContentPreview({
|
||||
actionError: string | null
|
||||
onFieldChange: (key: SkuContentFieldKey, value: string) => void
|
||||
onTogglePackage: (packageKey: string) => void
|
||||
onPackageMaterialAdd: (packageKey: string, material: PackagingMaterialOption) => void
|
||||
onPackageMaterialRemove: (packageKey: string, assetVariantId: string) => void
|
||||
onPackageMaterialQuantityChange: (packageKey: string, assetVariantId: string, quantity: number) => void
|
||||
onPush: () => void
|
||||
onDeleteDraftGroup: () => void
|
||||
}) {
|
||||
@ -1995,7 +2530,7 @@ function SkuContentPreview({
|
||||
onChange={(event) => onFieldChange(field.key, event.target.value)}
|
||||
className="h-8 min-w-0 flex-1 rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[12px] font-black text-[#2d3335] outline-none disabled:bg-[#f7f9f9] disabled:text-[#5a6062]/55"
|
||||
>
|
||||
<option value="">{field.placeholder}</option>
|
||||
<option value="" />
|
||||
{selectOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
@ -2005,7 +2540,7 @@ function SkuContentPreview({
|
||||
ariaLabel={field.label}
|
||||
value={value}
|
||||
options={field.options ?? []}
|
||||
placeholder={field.placeholder}
|
||||
placeholder=""
|
||||
readOnly={pushed || pushing}
|
||||
dropdownId={`supplier-options-${preSku.id}-${row.key}`}
|
||||
onChange={(nextValue) => onFieldChange(field.key, nextValue)}
|
||||
@ -2015,7 +2550,7 @@ function SkuContentPreview({
|
||||
readOnly={Boolean(isReadonly)}
|
||||
aria-label={field.label}
|
||||
value={value}
|
||||
placeholder={field.placeholder}
|
||||
placeholder=""
|
||||
onChange={(event) => onFieldChange(field.key, event.target.value)}
|
||||
className={cn(
|
||||
'h-8 min-w-0 flex-1 rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[12px] font-black text-[#2d3335] outline-none placeholder:text-[#8a9498]',
|
||||
@ -2053,7 +2588,14 @@ function SkuContentPreview({
|
||||
rows={previewRows}
|
||||
componentDetails={allComponentDetails}
|
||||
packageAssignments={packageAssignments}
|
||||
saving={packageComponentSaving}
|
||||
packageMaterialAssignments={packageMaterialAssignments}
|
||||
materialOptions={packagingMaterialOptions}
|
||||
materialOptionsLoading={packagingMaterialOptionsLoading}
|
||||
materialOptionsError={packagingMaterialOptionsError}
|
||||
saving={packageComponentSaving || packagingPlanSaving}
|
||||
onMaterialAdd={onPackageMaterialAdd}
|
||||
onMaterialRemove={onPackageMaterialRemove}
|
||||
onMaterialQuantityChange={onPackageMaterialQuantityChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -2140,7 +2682,14 @@ export default function DraftGroupsFormalClient({
|
||||
const [packageComponentAssignmentsByPreSkuId, setPackageComponentAssignmentsByPreSkuId] = useState<
|
||||
Record<string, PackageComponentAssignments>
|
||||
>(() => readStoredPackageComponentAssignments())
|
||||
const [packageMaterialAssignmentsByPreSkuId, setPackageMaterialAssignmentsByPreSkuId] = useState<
|
||||
Record<string, PackageMaterialAssignments>
|
||||
>(() => readStoredPackageMaterialAssignments())
|
||||
const [packagingMaterialOptionsByPreSkuId, setPackagingMaterialOptionsByPreSkuId] = useState<
|
||||
Record<string, PackagingMaterialOptionsState>
|
||||
>({})
|
||||
const [savingPackageComponentKey, setSavingPackageComponentKey] = useState<string | null>(null)
|
||||
const [savingPackagingPlanKey, setSavingPackagingPlanKey] = useState<string | null>(null)
|
||||
const [pushingPreSkuId, setPushingPreSkuId] = useState<string | null>(null)
|
||||
const [deletingDraftGroupId, setDeletingDraftGroupId] = useState<string | null>(null)
|
||||
const [leftPaneWidth, setLeftPaneWidth] = useState(() =>
|
||||
@ -2241,6 +2790,21 @@ export default function DraftGroupsFormalClient({
|
||||
const selectedPackageComponentAssignments = selectedPreSku
|
||||
? packageComponentAssignmentsByPreSkuId[selectedPreSku.id] ?? {}
|
||||
: {}
|
||||
const selectedPackageMaterialAssignments = selectedPreSku
|
||||
? packageMaterialAssignmentsByPreSkuId[selectedPreSku.id] ??
|
||||
buildPackageMaterialAssignmentsFromPackagingPlan(selectedPreSku.packagingPlan)
|
||||
: {}
|
||||
const selectedPackagingMaterialOptionsState = selectedPreSku
|
||||
? packagingMaterialOptionsByPreSkuId[selectedPreSku.id] ?? {
|
||||
loading: false,
|
||||
error: null,
|
||||
options: [],
|
||||
}
|
||||
: {
|
||||
loading: false,
|
||||
error: null,
|
||||
options: [],
|
||||
}
|
||||
const selectedSkuContentSaveState = selectedPreSku
|
||||
? skuContentSaveStateByPreSkuId[selectedPreSku.id] ?? null
|
||||
: null
|
||||
@ -2486,6 +3050,78 @@ export default function DraftGroupsFormalClient({
|
||||
})
|
||||
}
|
||||
|
||||
function savePackageMaterialAssignmentsForPreSku(
|
||||
preSkuId: string,
|
||||
assignments: PackageMaterialAssignments,
|
||||
) {
|
||||
const normalizedAssignments = Object.entries(assignments).reduce(
|
||||
(next, [packageKey, materials]) => {
|
||||
next[getPackageKey(packageKey)] = normalizePackageMaterialAssignments(materials)
|
||||
return next
|
||||
},
|
||||
{} as PackageMaterialAssignments,
|
||||
)
|
||||
|
||||
setPackageMaterialAssignmentsByPreSkuId((current) => {
|
||||
const next = {
|
||||
...current,
|
||||
[preSkuId]: normalizedAssignments,
|
||||
}
|
||||
writeStoredPackageMaterialAssignments(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function replacePreSkuPackagingPlan(preSkuId: string, packagingPlan: PreSkuPackagingPlanDTO) {
|
||||
setDetailById((current) => {
|
||||
const nextEntries = Object.entries(current).map(([draftGroupId, detail]) => [
|
||||
draftGroupId,
|
||||
{
|
||||
...detail,
|
||||
preSkus: detail.preSkus.map((preSku) =>
|
||||
preSku.id === preSkuId
|
||||
? {
|
||||
...preSku,
|
||||
packagingPlan,
|
||||
}
|
||||
: preSku,
|
||||
),
|
||||
},
|
||||
] as const)
|
||||
return Object.fromEntries(nextEntries)
|
||||
})
|
||||
}
|
||||
|
||||
async function syncPreSkuPackagingPlan(
|
||||
packageAssignments: PackageComponentAssignments,
|
||||
packageMaterialAssignments: PackageMaterialAssignments,
|
||||
activePackageKey: string,
|
||||
) {
|
||||
if (!selectedPreSku || selectedPackageRows.length === 0) return
|
||||
|
||||
const normalizedActivePackageKey = getPackageKey(activePackageKey)
|
||||
const savingKey = `${selectedPreSku.id}:${normalizedActivePackageKey}`
|
||||
setSavingPackagingPlanKey(savingKey)
|
||||
setActionError(null)
|
||||
|
||||
try {
|
||||
const packagingPlan = await savePreSkuPackagingPlan(
|
||||
selectedPreSku.id,
|
||||
buildSavePackagingPlanInput({
|
||||
preSku: selectedPreSku,
|
||||
packageRows: selectedPackageRows,
|
||||
packageAssignments,
|
||||
packageMaterialAssignments,
|
||||
}),
|
||||
)
|
||||
replacePreSkuPackagingPlan(selectedPreSku.id, packagingPlan)
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : '包装结构保存失败')
|
||||
} finally {
|
||||
setSavingPackagingPlanKey((current) => current === savingKey ? null : current)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPushedPackageAssignments(
|
||||
assignments: PackageComponentAssignments,
|
||||
activePackageKey: string,
|
||||
@ -2587,6 +3223,91 @@ export default function DraftGroupsFormalClient({
|
||||
if (selectedSkuPushed) {
|
||||
void syncPushedPackageAssignments(nextAssignments, packageKey ?? packageKeys[0] ?? 'single')
|
||||
}
|
||||
|
||||
void syncPreSkuPackagingPlan(
|
||||
nextAssignments,
|
||||
selectedPackageMaterialAssignments,
|
||||
packageKey ?? packageKeys[0] ?? 'single',
|
||||
)
|
||||
}
|
||||
|
||||
function handlePackageMaterialAdd(packageKey: string, material: PackagingMaterialOption) {
|
||||
if (!selectedPreSku) return
|
||||
const normalizedPackageKey = getPackageKey(packageKey)
|
||||
const currentMaterials = selectedPackageMaterialAssignments[normalizedPackageKey] ?? []
|
||||
const nextMaterials = normalizePackageMaterialAssignments([
|
||||
...currentMaterials,
|
||||
{
|
||||
assetVariantId: material.assetVariantId,
|
||||
assetFamilyId: material.assetFamilyId,
|
||||
code: material.code,
|
||||
name: material.name,
|
||||
quantity: 1,
|
||||
notes: null,
|
||||
},
|
||||
])
|
||||
const nextAssignments = {
|
||||
...selectedPackageMaterialAssignments,
|
||||
[normalizedPackageKey]: nextMaterials,
|
||||
}
|
||||
|
||||
savePackageMaterialAssignmentsForPreSku(selectedPreSku.id, nextAssignments)
|
||||
void syncPreSkuPackagingPlan(
|
||||
selectedPackageComponentAssignments,
|
||||
nextAssignments,
|
||||
normalizedPackageKey,
|
||||
)
|
||||
}
|
||||
|
||||
function handlePackageMaterialRemove(packageKey: string, assetVariantId: string) {
|
||||
if (!selectedPreSku) return
|
||||
const normalizedPackageKey = getPackageKey(packageKey)
|
||||
const nextMaterials = normalizePackageMaterialAssignments(
|
||||
(selectedPackageMaterialAssignments[normalizedPackageKey] ?? []).filter(
|
||||
(material) => material.assetVariantId !== assetVariantId,
|
||||
),
|
||||
)
|
||||
const nextAssignments = {
|
||||
...selectedPackageMaterialAssignments,
|
||||
[normalizedPackageKey]: nextMaterials,
|
||||
}
|
||||
|
||||
savePackageMaterialAssignmentsForPreSku(selectedPreSku.id, nextAssignments)
|
||||
void syncPreSkuPackagingPlan(
|
||||
selectedPackageComponentAssignments,
|
||||
nextAssignments,
|
||||
normalizedPackageKey,
|
||||
)
|
||||
}
|
||||
|
||||
function handlePackageMaterialQuantityChange(
|
||||
packageKey: string,
|
||||
assetVariantId: string,
|
||||
quantity: number,
|
||||
) {
|
||||
if (!selectedPreSku) return
|
||||
const normalizedPackageKey = getPackageKey(packageKey)
|
||||
const nextMaterials = normalizePackageMaterialAssignments(
|
||||
(selectedPackageMaterialAssignments[normalizedPackageKey] ?? []).map((material) =>
|
||||
material.assetVariantId === assetVariantId
|
||||
? {
|
||||
...material,
|
||||
quantity: normalizePackageMaterialQuantity(quantity),
|
||||
}
|
||||
: material,
|
||||
),
|
||||
)
|
||||
const nextAssignments = {
|
||||
...selectedPackageMaterialAssignments,
|
||||
[normalizedPackageKey]: nextMaterials,
|
||||
}
|
||||
|
||||
savePackageMaterialAssignmentsForPreSku(selectedPreSku.id, nextAssignments)
|
||||
void syncPreSkuPackagingPlan(
|
||||
selectedPackageComponentAssignments,
|
||||
nextAssignments,
|
||||
normalizedPackageKey,
|
||||
)
|
||||
}
|
||||
|
||||
async function handlePushSelectedSku() {
|
||||
@ -2705,6 +3426,52 @@ export default function DraftGroupsFormalClient({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPreSku) return
|
||||
const preSkuId = selectedPreSku.id
|
||||
const existingState = packagingMaterialOptionsByPreSkuId[preSkuId]
|
||||
if (existingState) return
|
||||
|
||||
let cancelled = false
|
||||
setPackagingMaterialOptionsByPreSkuId((current) => ({
|
||||
...current,
|
||||
[preSkuId]: {
|
||||
loading: true,
|
||||
error: null,
|
||||
options: [],
|
||||
},
|
||||
}))
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await getPreSkuPackagingMaterialOptions(preSkuId)
|
||||
if (cancelled) return
|
||||
setPackagingMaterialOptionsByPreSkuId((current) => ({
|
||||
...current,
|
||||
[preSkuId]: {
|
||||
loading: false,
|
||||
error: null,
|
||||
options: flattenPackagingMaterialOptions(result),
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
setPackagingMaterialOptionsByPreSkuId((current) => ({
|
||||
...current,
|
||||
[preSkuId]: {
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : '包装SKU读取失败',
|
||||
options: [],
|
||||
},
|
||||
}))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [packagingMaterialOptionsByPreSkuId, selectedPreSku])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(skuContentAutoSaveTimersRef.current).forEach((timer) => {
|
||||
@ -3156,6 +3923,10 @@ export default function DraftGroupsFormalClient({
|
||||
saveState={selectedSkuContentSaveState}
|
||||
activePackageKey={activePackageKey}
|
||||
packageAssignments={selectedPackageComponentAssignments}
|
||||
packageMaterialAssignments={selectedPackageMaterialAssignments}
|
||||
packagingMaterialOptions={selectedPackagingMaterialOptionsState.options}
|
||||
packagingMaterialOptionsLoading={selectedPackagingMaterialOptionsState.loading}
|
||||
packagingMaterialOptionsError={selectedPackagingMaterialOptionsState.error}
|
||||
packageComponentSaving={
|
||||
Boolean(
|
||||
selectedPreSku &&
|
||||
@ -3163,6 +3934,13 @@ export default function DraftGroupsFormalClient({
|
||||
savingPackageComponentKey === `${selectedPreSku.id}:${activePackageKey}`,
|
||||
)
|
||||
}
|
||||
packagingPlanSaving={
|
||||
Boolean(
|
||||
selectedPreSku &&
|
||||
activePackageKey &&
|
||||
savingPackagingPlanKey === `${selectedPreSku.id}:${activePackageKey}`,
|
||||
)
|
||||
}
|
||||
formalSeriesSpu={selectedFormalSeriesProduct?.spu ?? ''}
|
||||
formalSkuCode={selectedFormalSkuCode}
|
||||
pushDisabledReason={pushDisabledReason}
|
||||
@ -3174,6 +3952,9 @@ export default function DraftGroupsFormalClient({
|
||||
onTogglePackage={(packageKey) =>
|
||||
setActivePackageKey((current) => current === packageKey ? null : packageKey)
|
||||
}
|
||||
onPackageMaterialAdd={handlePackageMaterialAdd}
|
||||
onPackageMaterialRemove={handlePackageMaterialRemove}
|
||||
onPackageMaterialQuantityChange={handlePackageMaterialQuantityChange}
|
||||
onPush={handlePushSelectedSku}
|
||||
onDeleteDraftGroup={() =>
|
||||
handleDeleteDraftGroup(selectedDraftGroup, selectedDraftSkuName, selectedSkuPushed)
|
||||
@ -3191,6 +3972,7 @@ export default function DraftGroupsFormalClient({
|
||||
preSku={selectedPreSku}
|
||||
packageRows={selectedPackageRows}
|
||||
packageAssignments={selectedPackageComponentAssignments}
|
||||
packageMaterialAssignments={selectedPackageMaterialAssignments}
|
||||
saving={Boolean(
|
||||
selectedPreSku &&
|
||||
savingPackageComponentKey?.startsWith(`${selectedPreSku.id}:`),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -66,10 +66,10 @@ describe('alphabet library panels', () => {
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'O 0 条' }))
|
||||
await actor.click(screen.getByRole('button', { name: 'V 0 条' }))
|
||||
|
||||
expect(screen.getByText('O 组配件')).toBeInTheDocument()
|
||||
expect(screen.getByText('O 组暂未配置配件分类')).toBeInTheDocument()
|
||||
expect(screen.getByText('V 组配件')).toBeInTheDocument()
|
||||
expect(screen.getByText('V 组暂未配置配件分类')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增 A 组' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'A 自攻螺丝 0 条' }))
|
||||
@ -351,16 +351,76 @@ describe('alphabet library panels', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves H unconfigured before nut rules are confirmed', async () => {
|
||||
it('uses nut and cap nut rules for H accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'H 0 条' }))
|
||||
await actor.click(screen.getByRole('button', { name: 'H 螺母/螺帽 0 条' }))
|
||||
|
||||
expect(screen.getByText('H 组配件')).toBeInTheDocument()
|
||||
expect(screen.getByText('H 组暂未配置配件分类')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新增 H 组' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('H 组配件 · 螺母/螺帽')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '六角螺母' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '法兰螺母' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '防松螺母' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '盖形螺母' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '蝶形螺母' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '套筒螺母' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 H 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增H组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M6 镀锌 碳钢 六角螺母',
|
||||
)
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('规格')).toHaveValue('M6')
|
||||
expect(within(dialog).getByLabelText('高度/长度')).toHaveValue('')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('碳钢')
|
||||
expect(within(dialog).getByLabelText('表面处理')).toHaveValue('镀锌')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('银色')
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '套筒螺母' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('高度/长度'), {
|
||||
target: { value: '20mm' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'M6 20mm 镀锌 碳钢 套筒螺母',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('M6 20mm 镀锌 碳钢 套筒螺母')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'H',
|
||||
code: 'H0',
|
||||
name: 'M6 20mm 镀锌 碳钢 套筒螺母',
|
||||
screwCategory: '套筒螺母',
|
||||
headType: '',
|
||||
drive: '',
|
||||
tail: '',
|
||||
material: '碳钢',
|
||||
finish: '镀锌',
|
||||
tooth: '',
|
||||
diameter: 'M6',
|
||||
length: '20mm',
|
||||
color: '银色',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses caster wheel rules for I accessories', async () => {
|
||||
@ -734,13 +794,14 @@ describe('alphabet library panels', () => {
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'M 门吸/反弹器 0 条' }))
|
||||
await actor.click(screen.getByRole('button', { name: 'M 门吸/反弹器/移门轮 0 条' }))
|
||||
|
||||
expect(screen.getByText('M 组配件 · 门吸/反弹器')).toBeInTheDocument()
|
||||
expect(screen.getByText('M 组配件 · 门吸/反弹器/移门轮')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '门吸' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '反弹器' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '移门轮' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 M 组' }))
|
||||
|
||||
@ -808,6 +869,68 @@ describe('alphabet library panels', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses sliding door wheel rules inside M accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'M 门吸/反弹器/移门轮 0 条' }))
|
||||
await actor.click(screen.getByRole('button', { name: '新增 M 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增M组配件' })
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '移门轮' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'下轮 30mm 黑色 塑料 移门轮',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('位置/款式')).toHaveValue('下轮')
|
||||
expect(within(dialog).getByLabelText('规格/尺寸')).toHaveValue('30mm')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('黑色')
|
||||
expect(within(dialog).queryByLabelText('功能')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('位置/款式'), {
|
||||
target: { value: '上轮' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '25mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '白色' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'上轮 25mm 白色 塑料 移门轮',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('上轮 25mm 白色 塑料 移门轮')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'M',
|
||||
code: 'M0',
|
||||
name: '上轮 25mm 白色 塑料 移门轮',
|
||||
screwCategory: '移门轮',
|
||||
diameter: '25mm',
|
||||
tail: '上轮',
|
||||
tooth: '',
|
||||
color: '白色',
|
||||
material: '塑料',
|
||||
drive: '',
|
||||
finish: '',
|
||||
length: '',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses plug and sticker rules for N accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
@ -889,6 +1012,575 @@ describe('alphabet library panels', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses wooden dowel rules for O accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'O 木榫/木销 0 条' }))
|
||||
|
||||
expect(screen.getByText('O 组配件 · 木榫/木销')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '圆木榫' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 O 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增O组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'8mm*40mm 桦木 斜纹 圆木榫',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('圆木榫')
|
||||
expect(within(dialog).getByLabelText('规格/直径')).toHaveValue('8mm')
|
||||
expect(within(dialog).getByLabelText('长度')).toHaveValue('40mm')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('桦木')
|
||||
expect(within(dialog).getByLabelText('纹路')).toHaveValue('斜纹')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('颜色')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/直径'), {
|
||||
target: { value: '10mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('长度'), {
|
||||
target: { value: '50mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '榉木' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('纹路'), {
|
||||
target: { value: '直纹' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'10mm*50mm 榉木 直纹 圆木榫',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('10mm*50mm 榉木 直纹 圆木榫')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'O',
|
||||
code: 'O0',
|
||||
name: '10mm*50mm 榉木 直纹 圆木榫',
|
||||
screwCategory: '圆木榫',
|
||||
headType: '',
|
||||
drive: '直纹',
|
||||
tail: '',
|
||||
material: '榉木',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '10mm',
|
||||
length: '50mm',
|
||||
color: '',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses flap door and support rules for P accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'P 翻门/支撑配件 0 条' }))
|
||||
|
||||
expect(screen.getByText('P 组配件 · 翻门/支撑配件')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '液压杆' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '翻折配件' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '翻斗铰链' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 P 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增P组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'100N 245mm 上翻门 银色 液压杆',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('液压杆')
|
||||
expect(within(dialog).getByLabelText('规格/力度')).toHaveValue('100N')
|
||||
expect(within(dialog).getByLabelText('长度')).toHaveValue('245mm')
|
||||
expect(within(dialog).getByLabelText('安装方式')).toHaveValue('上翻门')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('铁')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('银色')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('槽型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '翻折配件' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'中号 缓冲 银色 翻折配件',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('规格/型号')).toHaveValue('中号')
|
||||
expect(within(dialog).getByLabelText('功能')).toHaveValue('缓冲')
|
||||
expect(within(dialog).queryByLabelText('安装方式')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '翻斗铰链' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'双层 240*310mm 白色 塑料 翻斗铰链',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('层数')).toHaveValue('双层')
|
||||
expect(within(dialog).getByLabelText('尺寸')).toHaveValue('240*310mm')
|
||||
expect(within(dialog).getByLabelText('套装方式')).toHaveValue('左右套装')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('白色')
|
||||
expect(within(dialog).queryByLabelText('功能')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('尺寸'), {
|
||||
target: { value: '260*320mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '铁' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '黑色' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'双层 260*320mm 黑色 铁 翻斗铰链',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('双层 260*320mm 黑色 铁 翻斗铰链')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'P',
|
||||
code: 'P0',
|
||||
name: '双层 260*320mm 黑色 铁 翻斗铰链',
|
||||
screwCategory: '翻斗铰链',
|
||||
headType: '',
|
||||
drive: '',
|
||||
tail: '左右套装',
|
||||
material: '铁',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '双层',
|
||||
length: '260*320mm',
|
||||
color: '黑色',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses cable box and vent hole rules for Q accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'Q 线盒/透气孔 0 条' }))
|
||||
|
||||
expect(screen.getByText('Q 组配件 · 线盒/透气孔')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '穿线盒' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '透气孔' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '无线充电盒' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 Q 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增Q组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'50mm 黑色 塑料 圆形 穿线盒',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('穿线盒')
|
||||
expect(within(dialog).getByLabelText('形状')).toHaveValue('圆形')
|
||||
expect(within(dialog).getByLabelText('规格/尺寸')).toHaveValue('50mm')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('黑色')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '透气孔' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '60mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '铁' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '银色' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'60mm 银色 铁 圆形 透气孔',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('60mm 银色 铁 圆形 透气孔')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'Q',
|
||||
code: 'Q0',
|
||||
name: '60mm 银色 铁 圆形 透气孔',
|
||||
screwCategory: '透气孔',
|
||||
headType: '',
|
||||
drive: '圆形',
|
||||
tail: '',
|
||||
material: '铁',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '60mm',
|
||||
length: '',
|
||||
color: '银色',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses hook rules for R accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'R 挂钩 0 条' }))
|
||||
|
||||
expect(screen.getByText('R 组配件 · 挂钩')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '挂钩' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 R 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增R组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'小号 普通 黑色 塑料 螺丝固定 挂钩',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('挂钩')
|
||||
expect(within(dialog).getByLabelText('款式/形状')).toHaveValue('普通')
|
||||
expect(within(dialog).getByLabelText('规格/尺寸')).toHaveValue('小号')
|
||||
expect(within(dialog).getByLabelText('安装方式')).toHaveValue('螺丝固定')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('黑色')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('款式/形状'), {
|
||||
target: { value: 'S型' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '中号' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('安装方式'), {
|
||||
target: { value: '挂入' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '铁' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'中号 S型 黑色 铁 挂入 挂钩',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('中号 S型 黑色 铁 挂入 挂钩')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'R',
|
||||
code: 'R0',
|
||||
name: '中号 S型 黑色 铁 挂入 挂钩',
|
||||
screwCategory: '挂钩',
|
||||
headType: '',
|
||||
drive: 'S型',
|
||||
tail: '挂入',
|
||||
material: '铁',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '中号',
|
||||
length: '',
|
||||
color: '黑色',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses clip and block rules for S accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'S 卡扣/挡块 0 条' }))
|
||||
|
||||
expect(screen.getByText('S 组配件 · 卡扣/挡块')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '卡扣' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '挡块' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 S 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增S组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'背板 白色 塑料 卡扣',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('卡扣')
|
||||
expect(within(dialog).getByLabelText('款式/用途')).toHaveValue('背板')
|
||||
expect(within(dialog).getByLabelText('规格/尺寸')).toHaveValue('')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('白色')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('款式/用途'), {
|
||||
target: { value: '防倒' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '30*20mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '铁' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '黑色' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'防倒 30*20mm 黑色 铁 卡扣',
|
||||
)
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '挡块' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('款式/用途'), {
|
||||
target: { value: '门挡' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '小号' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'门挡 小号 黑色 铁 挡块',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('门挡 小号 黑色 铁 挡块')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'S',
|
||||
code: 'S0',
|
||||
name: '门挡 小号 黑色 铁 挡块',
|
||||
screwCategory: '挡块',
|
||||
headType: '',
|
||||
drive: '门挡',
|
||||
tail: '',
|
||||
material: '铁',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '小号',
|
||||
length: '',
|
||||
color: '黑色',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses gasket and foot pad rules for T accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'T 垫片/脚垫 0 条' }))
|
||||
|
||||
expect(screen.getByText('T 组配件 · 垫片/脚垫')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '垫片' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '脚垫' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 T 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增T组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'圆形 φ32 塑料 垫片',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('垫片')
|
||||
expect(within(dialog).getByLabelText('款式/用途')).toHaveValue('普通')
|
||||
expect(within(dialog).getByLabelText('形状')).toHaveValue('圆形')
|
||||
expect(within(dialog).getByLabelText('规格/尺寸')).toHaveValue('φ32')
|
||||
expect(within(dialog).getByLabelText('厚度')).toHaveValue('')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('塑料')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '脚垫' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('款式/用途'), {
|
||||
target: { value: '防滑' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '12*4mm' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '橡胶' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '黑色' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'防滑 圆形 12*4mm 黑色 橡胶 脚垫',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('防滑 圆形 12*4mm 黑色 橡胶 脚垫')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'T',
|
||||
code: 'T0',
|
||||
name: '防滑 圆形 12*4mm 黑色 橡胶 脚垫',
|
||||
screwCategory: '脚垫',
|
||||
headType: '',
|
||||
drive: '防滑',
|
||||
tail: '圆形',
|
||||
material: '橡胶',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '12*4mm',
|
||||
length: '',
|
||||
color: '黑色',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses auxiliary fastener rules for U accessories', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(<AccessoryLibraryPanel />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: 'U 辅助固定件 0 条' }))
|
||||
|
||||
expect(screen.getByText('U 组配件 · 辅助固定件')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全部头型' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '全部分类' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '扎带' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '粘贴件' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '钉类' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '新增 U 组' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增U组配件' })
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'固定 150mm 黑色 尼龙 扎带',
|
||||
)
|
||||
expect(within(dialog).getByLabelText('分类')).toHaveValue('扎带')
|
||||
expect(within(dialog).getByLabelText('款式/用途')).toHaveValue('固定')
|
||||
expect(within(dialog).getByLabelText('规格/尺寸')).toHaveValue('150mm')
|
||||
expect(within(dialog).getByLabelText('材质')).toHaveValue('尼龙')
|
||||
expect(within(dialog).getByLabelText('颜色')).toHaveValue('黑色')
|
||||
expect(within(dialog).queryByLabelText('头型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('尾型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('表面处理')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('牙型')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('长度')).not.toBeInTheDocument()
|
||||
expect(within(dialog).queryByLabelText('是否预开孔')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('分类'), {
|
||||
target: { value: '粘贴件' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('款式/用途'), {
|
||||
target: { value: '粘贴' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('规格/尺寸'), {
|
||||
target: { value: '3M' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('材质'), {
|
||||
target: { value: '胶' },
|
||||
})
|
||||
fireEvent.change(within(dialog).getByLabelText('颜色'), {
|
||||
target: { value: '透明' },
|
||||
})
|
||||
|
||||
expect(within(dialog).getByLabelText('配件名称')).toHaveValue(
|
||||
'粘贴 3M 透明 胶 粘贴件',
|
||||
)
|
||||
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('粘贴 3M 透明 胶 粘贴件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const savedItems = JSON.parse(
|
||||
window.localStorage.getItem(ACCESSORY_LIBRARY_STORAGE_KEY) ?? '[]',
|
||||
)
|
||||
expect(savedItems[0]).toMatchObject({
|
||||
letter: 'U',
|
||||
code: 'U0',
|
||||
name: '粘贴 3M 透明 胶 粘贴件',
|
||||
screwCategory: '粘贴件',
|
||||
headType: '',
|
||||
drive: '粘贴',
|
||||
tail: '',
|
||||
material: '胶',
|
||||
finish: '',
|
||||
tooth: '',
|
||||
diameter: '3M',
|
||||
length: '',
|
||||
color: '透明',
|
||||
preDrill: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('filters C and D accessory groups by category shortcuts', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
|
||||
@ -340,7 +340,7 @@ type IntakeEntryTab = "integrated" | "nonIntegrated" | "stock";
|
||||
type ProductDevelopmentKind = NonNullable<
|
||||
NonIntegratedProductRecord["productDevelopmentKind"]
|
||||
>;
|
||||
type NewNonIntegratedCreateKind = "new" | "stock";
|
||||
type NewNonIntegratedCreateKind = "new" | "stock" | "seriesComponents";
|
||||
type BomLibraryKey =
|
||||
| "materialLibrary"
|
||||
| "finishLibrary"
|
||||
@ -1231,6 +1231,7 @@ type NonIntegratedQuickScope = "all" | "created" | "pending";
|
||||
type NonIntegratedProductKindFilter = "all" | "new" | "stock";
|
||||
type NonIntegratedListQuery = {
|
||||
productKind: NonIntegratedProductKindFilter;
|
||||
seriesName: string;
|
||||
spu: string;
|
||||
appearanceDesigner: string;
|
||||
structureDesigner: string;
|
||||
@ -1261,6 +1262,7 @@ function createDefaultNonIntegratedListQuery(
|
||||
): NonIntegratedListQuery {
|
||||
return {
|
||||
productKind,
|
||||
seriesName: "",
|
||||
spu: "",
|
||||
appearanceDesigner: "",
|
||||
structureDesigner: "",
|
||||
@ -1607,12 +1609,42 @@ function getPendingEntryTabFromNewProductTab(tab: {
|
||||
return "nonIntegrated";
|
||||
}
|
||||
|
||||
function readSeriesComponentDetailMetadata(product: Record<string, unknown>) {
|
||||
const metadata = product.metadata;
|
||||
if (!metadata || typeof metadata !== "object") return null;
|
||||
|
||||
const record = metadata as Record<string, unknown>;
|
||||
return record.componentDetailType === "asset-series-components"
|
||||
? record
|
||||
: null;
|
||||
}
|
||||
|
||||
function isSeriesComponentDetail(product: Record<string, unknown>) {
|
||||
return Boolean(readSeriesComponentDetailMetadata(product));
|
||||
}
|
||||
|
||||
function readSeriesComponentDetailSourceSeriesName(
|
||||
product: NonIntegratedProductRecord,
|
||||
) {
|
||||
const metadata = readSeriesComponentDetailMetadata(
|
||||
product as unknown as Record<string, unknown>,
|
||||
);
|
||||
const sourceSeriesName =
|
||||
typeof metadata?.sourceSeriesName === "string"
|
||||
? metadata.sourceSeriesName.trim()
|
||||
: "";
|
||||
|
||||
return sourceSeriesName || product.openingReference.productName.trim();
|
||||
}
|
||||
|
||||
function getProductDevelopmentDetailLabel(
|
||||
product: NonIntegratedProductRecord,
|
||||
) {
|
||||
const suffix =
|
||||
getProductDevelopmentKind(product) === "series"
|
||||
? "系列产品开发明细"
|
||||
? isSeriesComponentDetail(product as unknown as Record<string, unknown>)
|
||||
? "部件开发明细"
|
||||
: "系列产品开发明细"
|
||||
: isJstSkuImportedStockProduct(product)
|
||||
? "存量商品明细"
|
||||
: "产品开发明细";
|
||||
@ -1651,16 +1683,19 @@ function createPendingEntryWorkspaceTab(
|
||||
function createNewNonIntegratedProductWorkspaceTab(
|
||||
id = "new-non-integrated:default",
|
||||
productDevelopmentKind: ProductDevelopmentKind = "product",
|
||||
initialCreateProductKind: NewNonIntegratedCreateKind = "new",
|
||||
): IntakeWorkspaceTab {
|
||||
return {
|
||||
id,
|
||||
kind: "newNonIntegratedProduct",
|
||||
label:
|
||||
productDevelopmentKind === "series"
|
||||
? "新增系列产品"
|
||||
: "新增产品",
|
||||
initialCreateProductKind === "seriesComponents"
|
||||
? "新增部件"
|
||||
: productDevelopmentKind === "series"
|
||||
? "新增系列产品"
|
||||
: "新增产品",
|
||||
productDevelopmentKind,
|
||||
initialCreateProductKind: "new",
|
||||
initialCreateProductKind,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1930,6 +1965,11 @@ function restoreIntakeWorkspaceTab(
|
||||
return createNewNonIntegratedProductWorkspaceTab(
|
||||
tab.id,
|
||||
productDevelopmentKind,
|
||||
tab.initialCreateProductKind === "seriesComponents"
|
||||
? "seriesComponents"
|
||||
: tab.initialCreateProductKind === "stock"
|
||||
? "stock"
|
||||
: "new",
|
||||
);
|
||||
}
|
||||
if (tab.kind === "bomLibrary") {
|
||||
@ -2565,6 +2605,10 @@ function getExistingSeriesDevelopmentNames(
|
||||
return getUniqueNonEmptyValues(
|
||||
products
|
||||
.filter((product) => getProductDevelopmentKind(product) === "series")
|
||||
.filter(
|
||||
(product) =>
|
||||
!isSeriesComponentDetail(product as unknown as Record<string, unknown>),
|
||||
)
|
||||
.map(
|
||||
(product) =>
|
||||
product.openingReference.productName ||
|
||||
@ -2573,6 +2617,19 @@ function getExistingSeriesDevelopmentNames(
|
||||
);
|
||||
}
|
||||
|
||||
function getExistingSeriesComponentDetailNames(
|
||||
products: NonIntegratedProductRecord[],
|
||||
) {
|
||||
return getUniqueNonEmptyValues(
|
||||
products
|
||||
.filter((product) => getProductDevelopmentKind(product) === "series")
|
||||
.filter((product) =>
|
||||
isSeriesComponentDetail(product as unknown as Record<string, unknown>),
|
||||
)
|
||||
.map(readSeriesComponentDetailSourceSeriesName),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleSection(
|
||||
current: IntakeSection,
|
||||
section: Exclude<IntakeSection, null>,
|
||||
@ -3735,10 +3792,12 @@ function NonIntegratedPendingDetail({
|
||||
productDevelopmentKind = "product",
|
||||
fixedProductKind,
|
||||
createButtonLabel = "新增产品",
|
||||
secondaryCreateButtonLabel,
|
||||
emptyMessage = "当前还没有非集成产品,点击右上角“新增产品”开始维护资料。",
|
||||
onProductsChange,
|
||||
onOpenProductWorkspace,
|
||||
onOpenNewNonIntegratedProductWorkspace,
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace,
|
||||
}: {
|
||||
initialProducts: NonIntegratedProductRecord[];
|
||||
canDeleteProducts: boolean;
|
||||
@ -3746,6 +3805,7 @@ function NonIntegratedPendingDetail({
|
||||
productDevelopmentKind?: ProductDevelopmentKind;
|
||||
fixedProductKind?: NonIntegratedProductKindFilter;
|
||||
createButtonLabel?: string;
|
||||
secondaryCreateButtonLabel?: string;
|
||||
emptyMessage?: string;
|
||||
onProductsChange?: (products: NonIntegratedProductRecord[]) => void;
|
||||
onOpenProductWorkspace?: (
|
||||
@ -3753,6 +3813,7 @@ function NonIntegratedPendingDetail({
|
||||
mode: WorkspaceProductMode,
|
||||
) => void;
|
||||
onOpenNewNonIntegratedProductWorkspace?: () => void;
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const initialProductKind = fixedProductKind ?? "all";
|
||||
@ -3811,7 +3872,10 @@ function NonIntegratedPendingDetail({
|
||||
const skipNextListStatePersistRef = useRef(false);
|
||||
const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test";
|
||||
const effectiveProductKind = fixedProductKind ?? appliedQuery.productKind;
|
||||
const hasCreateAction = Boolean(onOpenNewNonIntegratedProductWorkspace);
|
||||
const hasCreateAction = Boolean(
|
||||
onOpenNewNonIntegratedProductWorkspace ||
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace,
|
||||
);
|
||||
|
||||
function scopeProductsByDevelopmentKind(
|
||||
nextProducts: NonIntegratedProductRecord[],
|
||||
@ -4160,6 +4224,22 @@ function NonIntegratedPendingDetail({
|
||||
const stockSourceMatched =
|
||||
effectiveProductKind !== "stock" ||
|
||||
stockSourceMatchesQuery(product, appliedQuery);
|
||||
const seriesNameMatched =
|
||||
productDevelopmentKind !== "series" ||
|
||||
productMatchesText(
|
||||
[
|
||||
readSeriesComponentDetailSourceSeriesName(product),
|
||||
readOptionalNestedString(product.metadata, "sourceSeriesName"),
|
||||
readOptionalNestedString(product.metadata, "sourceSeriesCode"),
|
||||
product.openingReference.productName,
|
||||
getNonIntegratedProductDisplayName(product),
|
||||
product.spu,
|
||||
readOptionalRecordString(
|
||||
(product as unknown as Record<string, unknown>).spuBase,
|
||||
),
|
||||
].join(" "),
|
||||
appliedQuery.seriesName,
|
||||
);
|
||||
const peopleMatched =
|
||||
productMatchesText(
|
||||
people.appearanceDesigner,
|
||||
@ -4190,11 +4270,19 @@ function NonIntegratedPendingDetail({
|
||||
productKindMatched &&
|
||||
spuMatched &&
|
||||
stockSourceMatched &&
|
||||
seriesNameMatched &&
|
||||
peopleMatched &&
|
||||
quickScopeMatched
|
||||
);
|
||||
});
|
||||
}, [activeQuickScope, appliedQuery, currentUser, effectiveProductKind, products]);
|
||||
}, [
|
||||
activeQuickScope,
|
||||
appliedQuery,
|
||||
currentUser,
|
||||
effectiveProductKind,
|
||||
productDevelopmentKind,
|
||||
products,
|
||||
]);
|
||||
const totalRows = shouldUseSharedNonIntegratedApi
|
||||
? (pageInfo?.total ?? filteredProducts.length)
|
||||
: filteredProducts.length;
|
||||
@ -4401,9 +4489,29 @@ function NonIntegratedPendingDetail({
|
||||
],
|
||||
},
|
||||
];
|
||||
const seriesProductFilterFields: typeof productFilterFields =
|
||||
isSeriesDevelopmentList
|
||||
? [
|
||||
{
|
||||
key: "seriesName",
|
||||
label: "系列名称",
|
||||
ariaLabel: "系列名称",
|
||||
placeholder: "搜索系列名称",
|
||||
},
|
||||
...productFilterFields,
|
||||
]
|
||||
: productFilterFields;
|
||||
const filterFields = isStockDevelopmentList
|
||||
? stockFilterFields
|
||||
: productFilterFields;
|
||||
: seriesProductFilterFields;
|
||||
const filterGridColumnClass =
|
||||
filterFields.length === 8
|
||||
? hasCreateAction
|
||||
? "md:grid-cols-[repeat(8,minmax(0,1fr))_72px_216px]"
|
||||
: "md:grid-cols-[repeat(8,minmax(0,1fr))_72px]"
|
||||
: hasCreateAction
|
||||
? "md:grid-cols-[repeat(7,minmax(0,1fr))_72px_216px]"
|
||||
: "md:grid-cols-[repeat(7,minmax(0,1fr))_72px]";
|
||||
const quickScopes: Array<{ key: NonIntegratedQuickScope; label: string }> = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "created", label: "我负责的" },
|
||||
@ -4439,11 +4547,7 @@ function NonIntegratedPendingDetail({
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`grid items-end gap-2 ${
|
||||
hasCreateAction
|
||||
? "md:grid-cols-[repeat(7,minmax(0,1fr))_72px_104px]"
|
||||
: "md:grid-cols-[repeat(7,minmax(0,1fr))_72px]"
|
||||
}`}
|
||||
className={`grid items-end gap-2 ${filterGridColumnClass}`}
|
||||
>
|
||||
{filterFields.map((filter) => (
|
||||
<label
|
||||
@ -4498,15 +4602,28 @@ function NonIntegratedPendingDetail({
|
||||
</Button>
|
||||
</div>
|
||||
{hasCreateAction ? (
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
className={`h-8 w-full px-3 text-xs ${INTAKE_PRIMARY_BUTTON_CLASS}`}
|
||||
onClick={onOpenNewNonIntegratedProductWorkspace}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{createButtonLabel}
|
||||
</Button>
|
||||
<div className="flex items-end justify-end gap-2">
|
||||
{onOpenNewNonIntegratedProductWorkspace ? (
|
||||
<Button
|
||||
size="small"
|
||||
className={`h-8 min-w-0 flex-1 px-3 text-xs ${INTAKE_PRIMARY_BUTTON_CLASS}`}
|
||||
onClick={onOpenNewNonIntegratedProductWorkspace}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{createButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{onOpenSecondaryNewNonIntegratedProductWorkspace &&
|
||||
secondaryCreateButtonLabel ? (
|
||||
<Button
|
||||
size="small"
|
||||
className={`h-8 min-w-0 flex-1 px-3 text-xs ${INTAKE_PRIMARY_BUTTON_CLASS}`}
|
||||
onClick={onOpenSecondaryNewNonIntegratedProductWorkspace}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{secondaryCreateButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -10080,6 +10197,8 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
const seriesDraftSkuOptions = getDraftGroupSeriesSkuOptions(props);
|
||||
const existingSeriesNames =
|
||||
getExistingSeriesDevelopmentNames(nonIntegratedProducts);
|
||||
const existingSeriesComponentDetailNames =
|
||||
getExistingSeriesComponentDetailNames(nonIntegratedProducts);
|
||||
const canViewProductDevelopmentList = canViewIntakeFormalWorkspace(
|
||||
nodePermissionOverrides,
|
||||
"productDevelopmentList",
|
||||
@ -10665,6 +10784,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
|
||||
function openNewNonIntegratedProductWorkspace(
|
||||
productDevelopmentKind: ProductDevelopmentKind = "product",
|
||||
initialCreateProductKind: NewNonIntegratedCreateKind = "new",
|
||||
) {
|
||||
if (!canUseDesignControlledProductData) {
|
||||
return;
|
||||
@ -10673,7 +10793,8 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
const existingTab = workspaceTabs.find(
|
||||
(tab) =>
|
||||
tab.kind === "newNonIntegratedProduct" &&
|
||||
tab.productDevelopmentKind === productDevelopmentKind,
|
||||
tab.productDevelopmentKind === productDevelopmentKind &&
|
||||
(tab.initialCreateProductKind ?? "new") === initialCreateProductKind,
|
||||
);
|
||||
if (existingTab) {
|
||||
syncWorkspaceState(existingTab);
|
||||
@ -10681,8 +10802,12 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
}
|
||||
|
||||
const nextTab = createNewNonIntegratedProductWorkspaceTab(
|
||||
createWorkspaceTabId("new-non-integrated", productDevelopmentKind),
|
||||
createWorkspaceTabId(
|
||||
"new-non-integrated",
|
||||
`${productDevelopmentKind}:${initialCreateProductKind}`,
|
||||
),
|
||||
productDevelopmentKind,
|
||||
initialCreateProductKind,
|
||||
);
|
||||
openWorkspaceTab(nextTab);
|
||||
}
|
||||
@ -10913,6 +11038,7 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
productDevelopmentKind={activeWorkspaceTab.productDevelopmentKind}
|
||||
seriesNameOptions={seriesNameOptions}
|
||||
existingSeriesNames={existingSeriesNames}
|
||||
existingSeriesComponentDetailNames={existingSeriesComponentDetailNames}
|
||||
seriesDraftSkuOptions={seriesDraftSkuOptions}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
initialDocumentWorkspaceView={activeWorkspaceTab.detailState?.view}
|
||||
@ -11003,9 +11129,12 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
key={activeWorkspaceTab.id}
|
||||
mode="create"
|
||||
productDevelopmentKind={activeWorkspaceTab.productDevelopmentKind}
|
||||
initialCreateProductKind="new"
|
||||
initialCreateProductKind={
|
||||
activeWorkspaceTab.initialCreateProductKind ?? "new"
|
||||
}
|
||||
seriesNameOptions={seriesNameOptions}
|
||||
existingSeriesNames={existingSeriesNames}
|
||||
existingSeriesComponentDetailNames={existingSeriesComponentDetailNames}
|
||||
seriesDraftSkuOptions={seriesDraftSkuOptions}
|
||||
materialLibraryFinishes={materialLibraryFinishes}
|
||||
currentUserRole={props.user.role}
|
||||
@ -11103,14 +11232,12 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
});
|
||||
|
||||
const { activeTab } = resolveWorkspaceTabsForOpen(workspaceTabs, nextTab);
|
||||
setWorkspaceTabs((current) =>
|
||||
resolveWorkspaceTabsForOpen(current, nextTab).tabs,
|
||||
);
|
||||
setWorkspaceTabs((current) => {
|
||||
return resolveWorkspaceTabsForOpen(current, nextTab).tabs;
|
||||
});
|
||||
syncWorkspaceState(activeTab);
|
||||
markProductWorkspaceTabLoaded(activeTab.id);
|
||||
}, [
|
||||
canViewFormalWorkspaceByTarget,
|
||||
markProductWorkspaceTabLoaded,
|
||||
props.initialOpenProductWorkspace,
|
||||
props.user,
|
||||
workspaceStateReady,
|
||||
@ -11177,7 +11304,17 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("series")
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
secondaryCreateButtonLabel="新增部件"
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () =>
|
||||
openNewNonIntegratedProductWorkspace(
|
||||
"series",
|
||||
"seriesComponents",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingEntryTab === "stock" && canViewStockDevelopmentList ? (
|
||||
@ -11370,7 +11507,17 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("series")
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
secondaryCreateButtonLabel="新增部件"
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () =>
|
||||
openNewNonIntegratedProductWorkspace(
|
||||
"series",
|
||||
"seriesComponents",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingEntryTab === "stock" && canViewStockDevelopmentList ? (
|
||||
|
||||
@ -39,7 +39,12 @@ import type { MaterialDTO } from "@/server/services";
|
||||
import { SPU_RULE_LIBRARY_STORAGE_KEY } from "../spuRuleLibraryLogic";
|
||||
import { parseZipFile } from "@/lib/zip-parser";
|
||||
import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip";
|
||||
import { fetchSubstrates } from "@/lib/open-product/platform-api";
|
||||
import {
|
||||
fetchSubstrates,
|
||||
listAssetFamilies,
|
||||
listAssetSeries,
|
||||
listAssetVariantsByFamilyIds,
|
||||
} from "@/lib/open-product/platform-api";
|
||||
import { createApprovalWorkflow } from "@/app/approval-workflows/approvalWorkflowApi";
|
||||
import { buildIntakeNodePermissionOverrideKey } from "@/lib/intake-node-permission-settings";
|
||||
|
||||
@ -169,6 +174,9 @@ jest.mock("./nonIntegratedApi", () => ({
|
||||
|
||||
jest.mock("@/lib/open-product/platform-api", () => ({
|
||||
fetchSubstrates: jest.fn(),
|
||||
listAssetFamilies: jest.fn(),
|
||||
listAssetSeries: jest.fn(),
|
||||
listAssetVariantsByFamilyIds: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/app/approval-workflows/approvalWorkflowApi", () => ({
|
||||
@ -194,6 +202,11 @@ const mockedUploadSharedNonIntegratedFile = jest.mocked(
|
||||
uploadSharedNonIntegratedFile,
|
||||
);
|
||||
const mockedFetchSubstrates = jest.mocked(fetchSubstrates);
|
||||
const mockedListAssetFamilies = jest.mocked(listAssetFamilies);
|
||||
const mockedListAssetSeries = jest.mocked(listAssetSeries);
|
||||
const mockedListAssetVariantsByFamilyIds = jest.mocked(
|
||||
listAssetVariantsByFamilyIds,
|
||||
);
|
||||
const mockedCreateApprovalWorkflow = jest.mocked(createApprovalWorkflow);
|
||||
|
||||
async function selectInlineOption(
|
||||
@ -491,6 +504,9 @@ describe("NonIntegratedDetailClient", () => {
|
||||
}));
|
||||
mockedUploadSharedNonIntegratedFile.mockReset();
|
||||
mockedFetchSubstrates.mockReset();
|
||||
mockedListAssetFamilies.mockReset();
|
||||
mockedListAssetSeries.mockReset();
|
||||
mockedListAssetVariantsByFamilyIds.mockReset();
|
||||
mockedCreateApprovalWorkflow.mockClear();
|
||||
mockedFetchSubstrates.mockResolvedValue([
|
||||
createSubstrateOption({
|
||||
@ -515,6 +531,9 @@ describe("NonIntegratedDetailClient", () => {
|
||||
systemName: "20ENF级橡胶木",
|
||||
}),
|
||||
]);
|
||||
mockedListAssetSeries.mockResolvedValue([]);
|
||||
mockedListAssetFamilies.mockResolvedValue([]);
|
||||
mockedListAssetVariantsByFamilyIds.mockResolvedValue([]);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: jest.fn(async () => undefined),
|
||||
@ -862,6 +881,139 @@ describe("NonIntegratedDetailClient", () => {
|
||||
expect(within(dialog).getAllByText("2").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps PACKAGING asset variants in series component detail sku content", async () => {
|
||||
const now = new Date("2026-06-02T08:00:00.000Z");
|
||||
mockedListAssetSeries.mockResolvedValue([
|
||||
{
|
||||
id: "series-dbz",
|
||||
code: "DBZ",
|
||||
name: "DBZ",
|
||||
displayName: "DBZ",
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
mockedListAssetFamilies.mockResolvedValue([
|
||||
{
|
||||
id: "family-board",
|
||||
type: "BOARD",
|
||||
code: "DBZ-SPJK01",
|
||||
name: "Board family",
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
{
|
||||
id: "family-packaging",
|
||||
type: "PACKAGING",
|
||||
code: "DBZ-SPJK06",
|
||||
name: "Packaging family",
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
] as never);
|
||||
mockedListAssetVariantsByFamilyIds.mockImplementation(async (familyIds) =>
|
||||
[
|
||||
{
|
||||
id: "variant-board-1",
|
||||
assetFamilyId: "family-board",
|
||||
assetFamilyCode: "DBZ-SPJK01",
|
||||
assetFamilyName: "Board family",
|
||||
assetType: "BOARD",
|
||||
code: "DBZ-SPJ01",
|
||||
name: "Board 01",
|
||||
sizeLabel: "140/60/2.5",
|
||||
modelUrl: "/models/board.glb",
|
||||
thumbnailUrl: "",
|
||||
barcode: "DBZ-SPJ01N1280V01",
|
||||
supplierName: "Supplier",
|
||||
supplierCode: "N1280",
|
||||
versionNo: "V01",
|
||||
businessData: {
|
||||
skuContentFields: {
|
||||
model: "DBZ-SPJK01",
|
||||
skuCode: "DBZ-SPJ01",
|
||||
barcode: "DBZ-SPJ01N1280V01",
|
||||
specName: "Board 01",
|
||||
size: "140/60/2.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "variant-packaging-11",
|
||||
assetFamilyId: "family-packaging",
|
||||
assetFamilyCode: "DBZ-SPJK06",
|
||||
assetFamilyName: "Packaging family",
|
||||
assetType: "PACKAGING",
|
||||
code: "DBZ-SPJ11",
|
||||
name: "Packaging 11",
|
||||
sizeLabel: "120/120/0.3",
|
||||
modelUrl: "",
|
||||
thumbnailUrl: "",
|
||||
barcode: "DBZ-SPJ11N12V01",
|
||||
supplierName: "Carton supplier",
|
||||
supplierCode: "N12",
|
||||
versionNo: "V01",
|
||||
businessData: {
|
||||
skuContentFields: {
|
||||
model: "DBZ-SPJK06",
|
||||
skuCode: "DBZ-SPJ11",
|
||||
barcode: "DBZ-SPJ11N12V01",
|
||||
specName: "Packaging 11",
|
||||
size: "120/120/0.3",
|
||||
material: "Carton",
|
||||
},
|
||||
},
|
||||
},
|
||||
].filter((variant) => familyIds.includes(variant.assetFamilyId)) as never,
|
||||
);
|
||||
|
||||
saveNonIntegratedProduct(
|
||||
createProductRecord({
|
||||
id: "DBZ-SPJ-component-detail",
|
||||
spu: "DBZ-SPJ",
|
||||
productDevelopmentKind: "series",
|
||||
metadata: {
|
||||
componentDetailType: "asset-series-components",
|
||||
sourceSeriesCode: "DBZ",
|
||||
sourceSeriesName: "DBZ",
|
||||
},
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
productName: "DBZ",
|
||||
},
|
||||
skuItems: [
|
||||
createSkuItem({
|
||||
id: "asset-variant-variant-board-1",
|
||||
skuSequence: 1,
|
||||
model: "DBZ-SPJK01",
|
||||
skuCode: "DBZ-SPJ01",
|
||||
barcode: "DBZ-SPJ01N1280V01",
|
||||
source: "asset_series_component",
|
||||
sourceTable: "asset_variant",
|
||||
sourceSkuId: "variant-board-1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="DBZ-SPJ-component-detail"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedListAssetVariantsByFamilyIds).toHaveBeenCalledWith([
|
||||
"family-board",
|
||||
"family-packaging",
|
||||
]),
|
||||
);
|
||||
expect(await screen.findByDisplayValue("DBZ-SPJ11")).toBeInTheDocument();
|
||||
expect(screen.getAllByDisplayValue("DBZ-SPJK06").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides already created series names from the create selector", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,7 @@ export type NonIntegratedProductListParams = {
|
||||
productDevelopmentKind?: 'product' | 'series'
|
||||
productKind?: 'all' | 'new' | 'stock'
|
||||
quickScope?: 'all' | 'created' | 'pending'
|
||||
seriesName?: string
|
||||
spu?: string
|
||||
appearanceDesigner?: string
|
||||
structureDesigner?: string
|
||||
|
||||
@ -618,6 +618,7 @@ export type NonIntegratedQualityInspectionSheet = {
|
||||
export type NonIntegratedProductRecord = {
|
||||
id: string;
|
||||
productDevelopmentKind?: "product" | "series";
|
||||
metadata?: Record<string, unknown>;
|
||||
spu: string;
|
||||
projectStatus: NonIntegratedProjectStatus;
|
||||
projectReview: NonIntegratedProjectReview;
|
||||
@ -1054,6 +1055,22 @@ function normalizeStringRecord(value: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMetadataRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>).filter(
|
||||
([, item]) =>
|
||||
item === null ||
|
||||
typeof item === "string" ||
|
||||
typeof item === "number" ||
|
||||
typeof item === "boolean",
|
||||
);
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function normalizeTupleMap<TLength extends 3 | 4>(
|
||||
value: unknown,
|
||||
tupleLength: TLength,
|
||||
@ -2087,6 +2104,7 @@ function toProductRecord(
|
||||
id: item.id,
|
||||
productDevelopmentKind:
|
||||
item.productDevelopmentKind === "series" ? "series" : "product",
|
||||
metadata: normalizeMetadataRecord(item.metadata),
|
||||
spu: item.spu,
|
||||
projectStatus: normalizeNonIntegratedProjectStatus(item.projectStatus),
|
||||
projectReview: normalizeNonIntegratedProjectReview(item.projectReview),
|
||||
|
||||
@ -188,6 +188,18 @@ function appendInitialOpenProduct(
|
||||
return [product, ...products]
|
||||
}
|
||||
|
||||
function mergeInitialNonIntegratedProducts(
|
||||
...productGroups: NonIntegratedProductRecord[][]
|
||||
) {
|
||||
const productMap = new Map<string, NonIntegratedProductRecord>()
|
||||
|
||||
productGroups.flat().forEach((product) => {
|
||||
productMap.set(product.id, product)
|
||||
})
|
||||
|
||||
return Array.from(productMap.values())
|
||||
}
|
||||
|
||||
export default async function IntakePage({
|
||||
searchParams,
|
||||
}: {
|
||||
@ -218,6 +230,7 @@ export default async function IntakePage({
|
||||
materialLibraryCategories,
|
||||
materialLibraryDictionaries,
|
||||
initialNonIntegratedProductPage,
|
||||
initialSeriesNonIntegratedProductPage,
|
||||
nodePermissionSettings,
|
||||
initialOpenProduct,
|
||||
] = await Promise.all([
|
||||
@ -232,6 +245,11 @@ export default async function IntakePage({
|
||||
pageSize: 50,
|
||||
productDevelopmentKind: 'product',
|
||||
}),
|
||||
intakeNonIntegratedProductService.listPage(authUser, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
productDevelopmentKind: 'series',
|
||||
}),
|
||||
intakeNodePermissionSettingsService.getSettings(),
|
||||
initialOpenProductId
|
||||
? intakeNonIntegratedProductService
|
||||
@ -240,7 +258,10 @@ export default async function IntakePage({
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
const initialNonIntegratedProducts = appendInitialOpenProduct(
|
||||
initialNonIntegratedProductPage.products,
|
||||
mergeInitialNonIntegratedProducts(
|
||||
initialNonIntegratedProductPage.products,
|
||||
initialSeriesNonIntegratedProductPage.products,
|
||||
),
|
||||
initialOpenProduct,
|
||||
)
|
||||
const initialOpenProductWorkspace = initialOpenProduct
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import { readPreSkuSkuFields } from './seriesComponentDetails'
|
||||
|
||||
describe('readPreSkuSkuFields', () => {
|
||||
it('keeps whole-product centimeter size values', () => {
|
||||
expect(
|
||||
readPreSkuSkuFields({
|
||||
displaySummary: {
|
||||
size: '140 / 60 / 75',
|
||||
},
|
||||
}).size,
|
||||
).toBe('140 / 60 / 75')
|
||||
})
|
||||
|
||||
it('converts labeled whole-product millimeter size values to centimeters', () => {
|
||||
expect(
|
||||
readPreSkuSkuFields({
|
||||
displaySummary: {
|
||||
size: '长1400mm / 宽600mm / 高750mm',
|
||||
},
|
||||
}).size,
|
||||
).toBe('140 / 60 / 75')
|
||||
})
|
||||
|
||||
it('drops legacy board-only thickness size values', () => {
|
||||
expect(
|
||||
readPreSkuSkuFields({
|
||||
displaySummary: {
|
||||
size: '长1400mm / 宽600mm / 厚25mm',
|
||||
},
|
||||
}).size,
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
@ -104,6 +104,37 @@ function readPreSkuFirstStringField(
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizePreSkuOverallSize(value: string) {
|
||||
if (!value) return "";
|
||||
if (/厚\s*\d|thickness/i.test(value)) return "";
|
||||
|
||||
const labeledMillimeterSize = {
|
||||
length: value.match(/长\s*(\d+(?:\.\d+)?)\s*mm/i)?.[1],
|
||||
width: value.match(/宽\s*(\d+(?:\.\d+)?)\s*mm/i)?.[1],
|
||||
height: value.match(/高\s*(\d+(?:\.\d+)?)\s*mm/i)?.[1],
|
||||
};
|
||||
if (
|
||||
labeledMillimeterSize.length &&
|
||||
labeledMillimeterSize.width &&
|
||||
labeledMillimeterSize.height
|
||||
) {
|
||||
return [
|
||||
labeledMillimeterSize.length,
|
||||
labeledMillimeterSize.width,
|
||||
labeledMillimeterSize.height,
|
||||
]
|
||||
.map((item) => {
|
||||
const centimeters = Math.round((Number(item) / 10) * 10) / 10;
|
||||
return Number.isInteger(centimeters)
|
||||
? String(centimeters)
|
||||
: centimeters.toFixed(1);
|
||||
})
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readPreSkuSkuFields(
|
||||
preSku: PreSkuComponentDetailSource,
|
||||
): SeriesDraftSkuFields {
|
||||
@ -115,7 +146,7 @@ export function readPreSkuSkuFields(
|
||||
barcode: readPreSkuStringField(preSku, "barcode"),
|
||||
setCount: readPreSkuStringField(preSku, "setCount"),
|
||||
specName: readPreSkuStringField(preSku, "specName"),
|
||||
size: readPreSkuStringField(preSku, "size"),
|
||||
size: normalizePreSkuOverallSize(readPreSkuStringField(preSku, "size")),
|
||||
color: readPreSkuStringField(preSku, "color"),
|
||||
material: readPreSkuStringField(preSku, "material"),
|
||||
boardMaterialType: readPreSkuStringField(preSku, "boardMaterialType"),
|
||||
|
||||
@ -25,12 +25,12 @@ describe('asset-coding', () => {
|
||||
const familyCode = buildAssetFamilyCode('DBZ', 1)
|
||||
const variantCode = buildAssetVariantCode('DBZ', 1)
|
||||
|
||||
expect(familyCode).toBe('DBZ-SPJK001')
|
||||
expect(variantCode).toBe('DBZ-SPJ00001')
|
||||
expect(familyCode).toBe('DBZ-SPJK01')
|
||||
expect(variantCode).toBe('DBZ-SPJ01')
|
||||
expect(validateAssetFamilyCode(familyCode)).toBeNull()
|
||||
expect(validateAssetVariantCode(variantCode)).toBeNull()
|
||||
expect(validateAssetFamilyCode('BOARD-TOP')).toBe('资产族编码格式应为 系列编码-SPJK001')
|
||||
expect(validateAssetVariantCode('BOARD-TOP-1200')).toBe('规格款编码格式应为 系列编码-SPJ00001')
|
||||
expect(validateAssetFamilyCode('BOARD-TOP')).toBe('资产组型号格式应为 系列编码-SPJK01')
|
||||
expect(validateAssetVariantCode('BOARD-TOP-1200')).toBe('规格款货号格式应为 系列编码-SPJ01')
|
||||
expect(normalizeSkuVersionNo(null)).toBe('V01')
|
||||
expect(normalizeSkuVersionNo('1')).toBe('V01')
|
||||
})
|
||||
@ -38,14 +38,14 @@ describe('asset-coding', () => {
|
||||
it('按规格款编码、供应商编码和版本号生成条码,并校验供应商编码', () => {
|
||||
expect(validateSupplierCode('K001')).toBeNull()
|
||||
expect(buildAssetVariantBarcode({
|
||||
variantCode: 'DBZ-SPJ00001',
|
||||
variantCode: 'DBZ-SPJ01',
|
||||
supplierCode: 'K001',
|
||||
})).toBe('DBZ-SPJ00001K001V01')
|
||||
})).toBe('DBZ-SPJ01K001V01')
|
||||
expect(buildAssetVariantBarcode({
|
||||
variantCode: 'dbz-spj00001',
|
||||
variantCode: 'dbz-spj01',
|
||||
supplierCode: 'k001',
|
||||
versionNo: '2',
|
||||
})).toBe('DBZ-SPJ00001K001V02')
|
||||
})).toBe('DBZ-SPJ01K001V02')
|
||||
expect(validateSupplierCode('K')).toBe('供应商代码必须为 2-12 位大写字母或数字')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { validateAssetFamilyForReview } from '../asset-validation'
|
||||
import {
|
||||
validateAssetFamilyForReview,
|
||||
validateAssetVariantForReview,
|
||||
} from '../asset-validation'
|
||||
|
||||
describe('validateAssetFamilyForReview', () => {
|
||||
it('组件和五金资产族提交审核时不再强制要求连接角色', () => {
|
||||
@ -31,4 +34,46 @@ describe('validateAssetFamilyForReview', () => {
|
||||
}),
|
||||
).toEqual(['资产族编码不能为空', '资产族名称不能为空'])
|
||||
})
|
||||
|
||||
it('包装规格款提交审核时不强制要求模型、挂载点、尺寸和成本', () => {
|
||||
expect(
|
||||
validateAssetVariantForReview({
|
||||
assetType: 'PACKAGING',
|
||||
code: 'DBZ-SPJ01',
|
||||
name: '纸箱 1 号',
|
||||
dimensions: {},
|
||||
modelUrl: '',
|
||||
mountPoints: [],
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('组件和五金规格款提交审核时不强制要求预估成本价', () => {
|
||||
const baseInput = {
|
||||
code: 'DBZ-SPJ01',
|
||||
name: '拉杆',
|
||||
dimensions: { length: 830, width: 40, thickness: 30 },
|
||||
modelUrl: '/models/rod.glb',
|
||||
mountPoints: [{ id: 'origin', name: 'origin', position: [0, 0, 0] }],
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: null,
|
||||
assemblyBasis: { installPattern: 'rod' },
|
||||
}
|
||||
|
||||
expect(
|
||||
validateAssetVariantForReview({
|
||||
...baseInput,
|
||||
assetType: 'COMPONENT',
|
||||
}),
|
||||
).toEqual([])
|
||||
|
||||
expect(
|
||||
validateAssetVariantForReview({
|
||||
...baseInput,
|
||||
assetType: 'HARDWARE',
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,8 +4,8 @@ export const ASSET_SPU_SEGMENT = 'SPJ'
|
||||
export const DEFAULT_SKU_VERSION_NO = 'V01'
|
||||
|
||||
const SERIES_CODE_PATTERN = /^[A-Z][A-Z0-9]{1,15}$/
|
||||
const FAMILY_CODE_PATTERN = /^[A-Z][A-Z0-9]*-SPJK\d{3}$/
|
||||
const VARIANT_CODE_PATTERN = /^[A-Z][A-Z0-9]*-SPJ\d{5}$/
|
||||
const FAMILY_CODE_PATTERN = /^[A-Z][A-Z0-9]*-SPJK\d{2,}$/
|
||||
const VARIANT_CODE_PATTERN = /^[A-Z][A-Z0-9]*-SPJ\d{2,}$/
|
||||
const SUPPLIER_CODE_PATTERN = /^[A-Z0-9]{2,12}$/
|
||||
|
||||
function trimUpper(value: string | null | undefined): string {
|
||||
@ -44,11 +44,11 @@ export function buildSpuCode(seriesCode: string): string {
|
||||
}
|
||||
|
||||
export function buildAssetFamilyCode(seriesCode: string, sequence: number): string {
|
||||
return `${buildSpuCode(seriesCode)}K${String(sequence).padStart(3, '0')}`
|
||||
return `${buildSpuCode(seriesCode)}K${String(sequence).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function buildAssetVariantCode(seriesCode: string, sequence: number): string {
|
||||
return `${buildSpuCode(seriesCode)}${String(sequence).padStart(5, '0')}`
|
||||
return `${buildSpuCode(seriesCode)}${String(sequence).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function normalizeSkuVersionNo(value: string | null | undefined): string {
|
||||
@ -90,13 +90,13 @@ export function validateSeriesCode(code: string): string | null {
|
||||
export function validateAssetFamilyCode(code: string): string | null {
|
||||
return FAMILY_CODE_PATTERN.test(trimUpper(code))
|
||||
? null
|
||||
: '资产族编码格式应为 系列编码-SPJK001'
|
||||
: '资产组型号格式应为 系列编码-SPJK01'
|
||||
}
|
||||
|
||||
export function validateAssetVariantCode(code: string): string | null {
|
||||
return VARIANT_CODE_PATTERN.test(trimUpper(code))
|
||||
? null
|
||||
: '规格款编码格式应为 系列编码-SPJ00001'
|
||||
: '规格款货号格式应为 系列编码-SPJ01'
|
||||
}
|
||||
|
||||
export function validateSupplierCode(code: string): string | null {
|
||||
|
||||
@ -59,6 +59,7 @@ export function validateAssetFamilyForReview(input: AssetFamilyValidationInput):
|
||||
|
||||
export function validateAssetVariantForReview(input: AssetVariantValidationInput): string[] {
|
||||
const errors: string[] = []
|
||||
const requiresAssemblyModel = input.assetType !== 'PACKAGING'
|
||||
|
||||
if (!input.code.trim()) {
|
||||
errors.push('规格款编码不能为空')
|
||||
@ -66,22 +67,18 @@ export function validateAssetVariantForReview(input: AssetVariantValidationInput
|
||||
if (!input.name.trim()) {
|
||||
errors.push('规格款名称不能为空')
|
||||
}
|
||||
if (!hasDimensions(input.dimensions)) {
|
||||
if (requiresAssemblyModel && !hasDimensions(input.dimensions)) {
|
||||
errors.push('规格款缺少尺寸')
|
||||
}
|
||||
if (!input.modelUrl?.trim()) {
|
||||
if (requiresAssemblyModel && !input.modelUrl?.trim()) {
|
||||
errors.push('规格款缺少 3D 模型')
|
||||
}
|
||||
if (!hasMountPoints(input.mountPoints)) {
|
||||
if (requiresAssemblyModel && !hasMountPoints(input.mountPoints)) {
|
||||
errors.push('规格款缺少挂载点')
|
||||
}
|
||||
if (input.lossFactor === null || input.lossFactor === undefined) {
|
||||
if (requiresAssemblyModel && (input.lossFactor === null || input.lossFactor === undefined)) {
|
||||
errors.push('规格款缺少损耗系数')
|
||||
}
|
||||
if (input.estimatedCostPrice === null || input.estimatedCostPrice === undefined) {
|
||||
errors.push('规格款缺少预估成本价')
|
||||
}
|
||||
|
||||
if (input.assetType === 'BOARD') {
|
||||
if (!input.substrateId) {
|
||||
errors.push('板材规格款缺少基材')
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
AssetFamilyDTO,
|
||||
AssetCadImportResult,
|
||||
AssetSeriesDTO,
|
||||
AssetSeriesComponentDetailSummaryDTO,
|
||||
AssetVariantDTO,
|
||||
AssetVariantBindingDTO,
|
||||
PreSkuBindingDTO,
|
||||
@ -27,6 +28,7 @@ import type {
|
||||
DraftGroupDTO,
|
||||
FormalizeDraftGroupInput,
|
||||
FormalizeDraftGroupResult,
|
||||
GetPreSkuPackagingMaterialOptionsDTO,
|
||||
PreSkuBasicInfoMetadataDTO,
|
||||
PreviewPreSkuPackagingPlanDTO,
|
||||
PreSkuPackagingPlanDTO,
|
||||
@ -103,6 +105,18 @@ export async function updateAssetSeries(
|
||||
return data.series
|
||||
}
|
||||
|
||||
export async function ensureAssetSeriesComponentDetail(
|
||||
assetSeriesId: string,
|
||||
): Promise<AssetSeriesComponentDetailSummaryDTO> {
|
||||
const data = await requestPlatform<{
|
||||
componentDetail: AssetSeriesComponentDetailSummaryDTO
|
||||
}>(`/api/asset-center/series/${encodeURIComponent(assetSeriesId)}/component-detail`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
return data.componentDetail
|
||||
}
|
||||
|
||||
export async function listAssetFamilies(input?: {
|
||||
assetSeriesId?: string | null
|
||||
type?: 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING' | null
|
||||
@ -297,6 +311,15 @@ export async function createAssetVariantWithSimilarity(
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPackagingAssetVariant(
|
||||
input: CreateAssetVariantInput,
|
||||
): Promise<CreateAssetVariantResultDTO> {
|
||||
return requestPlatform<CreateAssetVariantResultDTO>('/api/asset-center/packaging-variants', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAssetOperationProgress(
|
||||
operationId: string,
|
||||
): Promise<AssetOperationProgressDTO | null> {
|
||||
@ -593,6 +616,15 @@ export async function savePreSkuPackagingPlan(
|
||||
return data.packagingPlan
|
||||
}
|
||||
|
||||
export async function getPreSkuPackagingMaterialOptions(
|
||||
preSkuId: string,
|
||||
): Promise<GetPreSkuPackagingMaterialOptionsDTO> {
|
||||
const data = await requestPlatform<{ result: GetPreSkuPackagingMaterialOptionsDTO }>(
|
||||
`/api/pre-skus/${preSkuId}/packaging/material-options`,
|
||||
)
|
||||
return data.result
|
||||
}
|
||||
|
||||
export async function previewPreSkuPackagingPlan(
|
||||
preSkuId: string,
|
||||
input: SavePreSkuPackagingPlanInput,
|
||||
|
||||
@ -255,7 +255,7 @@ class AssetFamilyRepositoryClass extends BaseRepository<
|
||||
): Promise<{ items: AssetFamilyWithRelations[]; total: number }> {
|
||||
const { filter, pagination, sort } = options
|
||||
const where = this.buildWhereClause(filter)
|
||||
const orderBy = sort ? { [sort.field]: sort.order } : { updatedAt: 'desc' as const }
|
||||
const orderBy = sort ? { [sort.field]: sort.order } : [{ createdAt: 'desc' as const }, { code: 'desc' as const }]
|
||||
const skip = pagination ? (pagination.page - 1) * pagination.limit : 0
|
||||
const take = pagination?.limit
|
||||
|
||||
@ -517,7 +517,7 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
}) as Promise<AssetVariantWithRelations[]>
|
||||
}
|
||||
@ -529,7 +529,7 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
}) as Promise<AssetVariantWithRelations[]>
|
||||
}
|
||||
@ -547,7 +547,7 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
}) as Promise<AssetVariantWithRelations[]>
|
||||
}
|
||||
@ -562,7 +562,29 @@ class AssetVariantRepositoryClass extends BaseRepository<
|
||||
deletedAt: null,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { updatedAt: 'desc' }],
|
||||
orderBy: [{ assetFamilyId: 'asc' }, { createdAt: 'desc' }, { code: 'desc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
}) as Promise<AssetVariantWithRelations[]>
|
||||
}
|
||||
|
||||
async findActiveBySeriesCode(seriesCode: string): Promise<AssetVariantWithRelations[]> {
|
||||
const normalizedSeriesCode = seriesCode.trim().toUpperCase()
|
||||
if (!normalizedSeriesCode) return []
|
||||
|
||||
return this.prisma.assetVariant.findMany({
|
||||
where: {
|
||||
code: { startsWith: `${normalizedSeriesCode}-SPJ` },
|
||||
deletedAt: null,
|
||||
assetFamily: {
|
||||
is: {
|
||||
deletedAt: null,
|
||||
assetSeries: {
|
||||
is: { code: normalizedSeriesCode },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ code: 'asc' }, { updatedAt: 'asc' }],
|
||||
include: assetVariantRelationInclude,
|
||||
}) as Promise<AssetVariantWithRelations[]>
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
assetFamilyRepository,
|
||||
assetSeriesRepository,
|
||||
assetVariantRepository,
|
||||
intakeNonIntegratedProductRepository,
|
||||
materialCategoryRepository,
|
||||
materialRepository,
|
||||
substrateRepository,
|
||||
@ -39,8 +40,13 @@ jest.mock('../../repositories', () => ({
|
||||
update: jest.fn(),
|
||||
updateWithRelations: jest.fn(),
|
||||
findManyByFamilyIds: jest.fn(),
|
||||
findActiveBySeriesCode: jest.fn(),
|
||||
releaseDeletedUniqueKeys: jest.fn(),
|
||||
},
|
||||
intakeNonIntegratedProductRepository: {
|
||||
listActive: jest.fn(),
|
||||
updatePayloadByUser: jest.fn(),
|
||||
},
|
||||
substrateRepository: {
|
||||
findById: jest.fn(),
|
||||
findByCode: jest.fn(),
|
||||
@ -82,6 +88,7 @@ describe('assetCenterService', () => {
|
||||
const mockedAssetFamilyRepository = jest.mocked(assetFamilyRepository)
|
||||
const mockedAssetSeriesRepository = jest.mocked(assetSeriesRepository)
|
||||
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
|
||||
const mockedIntakeNonIntegratedProductRepository = jest.mocked(intakeNonIntegratedProductRepository)
|
||||
const mockedSubstrateRepository = jest.mocked(substrateRepository)
|
||||
const mockedMaterialRepository = jest.mocked(materialRepository)
|
||||
const mockedMaterialCategoryRepository = jest.mocked(materialCategoryRepository)
|
||||
@ -149,12 +156,17 @@ describe('assetCenterService', () => {
|
||||
mockedAssetSeriesRepository.updateSeries.mockResolvedValue(defaultAssetSeries as never)
|
||||
|
||||
mockedAssetVariantRepository.findByCode.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.findByBarcode.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.findByIdentityKey.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.findById.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.update.mockResolvedValue({} as never)
|
||||
mockedAssetVariantRepository.updateWithRelations.mockResolvedValue({} as never)
|
||||
mockedAssetVariantRepository.findManyByFamilyIds.mockResolvedValue([] as never)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([] as never)
|
||||
mockedAssetVariantRepository.releaseDeletedUniqueKeys.mockResolvedValue(undefined as never)
|
||||
mockedIntakeNonIntegratedProductRepository.listActive.mockResolvedValue([] as never)
|
||||
mockedIntakeNonIntegratedProductRepository.updatePayloadByUser.mockResolvedValue({} as never)
|
||||
mockedSubstrateRepository.findById.mockResolvedValue({
|
||||
id: 'substrate-particle-board',
|
||||
code: 'PB18',
|
||||
@ -341,6 +353,104 @@ describe('assetCenterService', () => {
|
||||
expect(result.lossFactor).toBe(1)
|
||||
})
|
||||
|
||||
it('duplicates a component variant with empty input dimensions by inheriting source dimensions', async () => {
|
||||
const componentFamily = {
|
||||
...defaultBoardFamily,
|
||||
id: 'family-component-leg',
|
||||
code: 'DBZ-SPJK03',
|
||||
name: 'leg component family',
|
||||
type: 'COMPONENT',
|
||||
}
|
||||
const inheritedVariant = {
|
||||
id: 'variant-component-source',
|
||||
assetFamilyId: componentFamily.id,
|
||||
code: 'DBZ-SPJ06',
|
||||
name: 'source component',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ06',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'AVAILABLE',
|
||||
sizeLabel: '60x590x700',
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: { length: 60, width: 590 },
|
||||
modelUrl: '/models/component-source.glb',
|
||||
thumbnailUrl: null,
|
||||
barcode: null,
|
||||
supplierName: null,
|
||||
supplierCode: null,
|
||||
versionNo: 'V01',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: { source: 'cad' },
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: 1,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
assetFamily: componentFamily,
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
}
|
||||
|
||||
mockedAssetFamilyRepository.findWithRelationsById.mockResolvedValue(componentFamily as never)
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue(inheritedVariant as never)
|
||||
mockedAssetVariantRepository.createWithRelations.mockResolvedValue({
|
||||
...inheritedVariant,
|
||||
id: 'variant-component-duplicate',
|
||||
code: 'DBZ-SPJ07',
|
||||
name: 'duplicate component',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ07',
|
||||
status: 'DRAFT',
|
||||
inheritedFromVariantId: inheritedVariant.id,
|
||||
} as never)
|
||||
|
||||
await assetCenterService.createVariant({
|
||||
assetFamilyId: componentFamily.id,
|
||||
code: 'DBZ-SPJ07',
|
||||
name: 'duplicate component',
|
||||
status: 'DRAFT',
|
||||
dimensions: {},
|
||||
businessData: {
|
||||
skuContentFields: {
|
||||
size: '60x590x700',
|
||||
},
|
||||
},
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
modelUrl: '/models/component-source.glb',
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
inheritedFromVariantId: inheritedVariant.id,
|
||||
lossFactor: 1,
|
||||
})
|
||||
|
||||
expect(mockedAssetVariantRepository.createWithRelations).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dimensions: { length: 60, width: 590, height: 700 },
|
||||
inheritedFromVariant: { connect: { id: inheritedVariant.id } },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('以规格款为模板新建时会继承 CAD 孔位和装配语义,但材质仍按新规格款保存', async () => {
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-top-template',
|
||||
@ -1895,7 +2005,7 @@ describe('assetCenterService', () => {
|
||||
})
|
||||
|
||||
it('软删除规格款时会释放编码和身份键,避免回收站继续占用新建编码', async () => {
|
||||
mockedAssetVariantRepository.findById.mockResolvedValue({
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({
|
||||
id: 'variant-board-delete',
|
||||
assetFamilyId: 'family-board-top',
|
||||
code: 'DBZ-SPJ00017',
|
||||
@ -1933,6 +2043,22 @@ describe('assetCenterService', () => {
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
assetFamily: {
|
||||
id: 'family-board-top',
|
||||
code: 'DBZ-SPJK001',
|
||||
name: '妗岄潰鏉胯祫浜ф棌',
|
||||
type: 'BOARD',
|
||||
assetSeriesId: defaultAssetSeries.id,
|
||||
deletedAt: null,
|
||||
assetSeries: defaultAssetSeries,
|
||||
},
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
} as never)
|
||||
|
||||
await assetCenterService.deleteVariant('variant-board-delete', 'user-2')
|
||||
@ -2360,4 +2486,754 @@ describe('assetCenterService', () => {
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('deletes the component-detail SKU row and shifts later asset variant codes down', async () => {
|
||||
const buildComponentVariant = (overrides: Record<string, unknown>) => ({
|
||||
id: 'variant-03',
|
||||
assetFamilyId: 'family-component',
|
||||
code: 'DBZ-SPJ03',
|
||||
name: 'component 03',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ03',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: {},
|
||||
modelUrl: '/models/component-03.glb',
|
||||
thumbnailUrl: '/thumbs/component-03.png',
|
||||
barcode: 'DBZ-SPJ03K001V01',
|
||||
supplierName: 'supplier',
|
||||
supplierCode: 'K001',
|
||||
versionNo: 'V01',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
assetFamily: {
|
||||
id: 'family-component',
|
||||
code: 'DBZ-SPJK01',
|
||||
name: 'component family',
|
||||
type: 'COMPONENT',
|
||||
assetSeriesId: defaultAssetSeries.id,
|
||||
deletedAt: null,
|
||||
assetSeries: defaultAssetSeries,
|
||||
},
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue(
|
||||
buildComponentVariant({
|
||||
id: 'variant-02',
|
||||
code: 'DBZ-SPJ02',
|
||||
name: 'component 02',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ02',
|
||||
barcode: 'DBZ-SPJ02K001V01',
|
||||
}) as never,
|
||||
)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
|
||||
buildComponentVariant({
|
||||
id: 'variant-03',
|
||||
code: 'DBZ-SPJ03',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ03',
|
||||
}),
|
||||
] as never)
|
||||
mockedAssetVariantRepository.updateWithRelations.mockResolvedValue(
|
||||
buildComponentVariant({
|
||||
id: 'variant-03',
|
||||
code: 'DBZ-SPJ02',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ02',
|
||||
barcode: 'DBZ-SPJ02K001V01',
|
||||
}) as never,
|
||||
)
|
||||
mockedIntakeNonIntegratedProductRepository.listActive.mockResolvedValue([
|
||||
{
|
||||
id: 'component-detail-product',
|
||||
updatedBy: 'user-1',
|
||||
payload: {
|
||||
id: 'component-detail-product',
|
||||
productDevelopmentKind: 'series',
|
||||
metadata: { componentDetailType: 'asset-series-components' },
|
||||
skuItems: [
|
||||
{
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: 'variant-02',
|
||||
skuCode: 'DBZ-SPJ02',
|
||||
skuSequence: 1,
|
||||
},
|
||||
{
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: 'variant-03',
|
||||
skuCode: 'DBZ-SPJ03',
|
||||
skuSequence: 2,
|
||||
specName: 'manual spec',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
await assetCenterService.deleteVariant('variant-02', 'user-2')
|
||||
|
||||
expect(mockedAssetVariantRepository.updateWithRelations).toHaveBeenCalledWith(
|
||||
'variant-03',
|
||||
expect.objectContaining({
|
||||
code: 'DBZ-SPJ02',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ02',
|
||||
barcode: 'DBZ-SPJ02K001V01',
|
||||
}),
|
||||
)
|
||||
expect(mockedIntakeNonIntegratedProductRepository.updatePayloadByUser).toHaveBeenCalled()
|
||||
const syncedPayload =
|
||||
mockedIntakeNonIntegratedProductRepository.updatePayloadByUser.mock.calls[0]?.[1] as {
|
||||
skuItems: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(syncedPayload.skuItems).toHaveLength(1)
|
||||
expect(syncedPayload.skuItems[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceSkuId: 'variant-03',
|
||||
skuCode: 'DBZ-SPJ02',
|
||||
model: 'DBZ-SPJK01',
|
||||
skuSequence: 1,
|
||||
specName: 'manual spec',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('appends newly created packaging asset variant SKU content to component detail products', async () => {
|
||||
const businessData = {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK06',
|
||||
skuCode: 'DBZ-SPJ04',
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
setCount: '1',
|
||||
specName: 'carton 120/120/6',
|
||||
size: '120/120/6',
|
||||
color: 'carton',
|
||||
material: 'corrugated',
|
||||
versionNo: 'V01',
|
||||
drawingId: 'DBZ-SPJK06V01',
|
||||
manufacturer: 'supplier',
|
||||
manufacturerCode: 'N1280',
|
||||
batchNo: 'batch1',
|
||||
},
|
||||
}
|
||||
const packagingFamily = {
|
||||
...defaultBoardFamily,
|
||||
id: 'family-packaging',
|
||||
type: 'PACKAGING',
|
||||
code: 'DBZ-SPJK06',
|
||||
name: 'packaging family',
|
||||
}
|
||||
const createdPackagingVariant = {
|
||||
id: 'variant-packaging-04',
|
||||
assetFamilyId: 'family-packaging',
|
||||
code: 'DBZ-SPJ04',
|
||||
name: 'carton 120/120/6',
|
||||
identityKey: 'PACKAGING|code=DBZ-SPJ04',
|
||||
assetType: 'PACKAGING',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: '120/120/6',
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: { length: 1200, width: 1200, thickness: 60 },
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
supplierName: 'supplier',
|
||||
supplierCode: 'N1280',
|
||||
versionNo: 'V01',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-2',
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-02T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-02T10:00:00.000Z'),
|
||||
assetFamily: packagingFamily,
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
}
|
||||
|
||||
mockedAssetFamilyRepository.findWithRelationsById.mockResolvedValue(packagingFamily as never)
|
||||
mockedAssetVariantRepository.createWithRelations.mockResolvedValue(createdPackagingVariant as never)
|
||||
mockedIntakeNonIntegratedProductRepository.listActive.mockResolvedValue([
|
||||
{
|
||||
id: 'component-detail-product',
|
||||
spu: 'DBZ-SPJ',
|
||||
updatedBy: 'user-1',
|
||||
payload: {
|
||||
id: 'component-detail-product',
|
||||
productDevelopmentKind: 'series',
|
||||
metadata: {
|
||||
componentDetailType: 'asset-series-components',
|
||||
sourceSeriesCode: 'DBZ',
|
||||
},
|
||||
spu: 'DBZ-SPJ',
|
||||
skuItems: [
|
||||
{
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: 'variant-existing',
|
||||
skuCode: 'DBZ-SPJ03',
|
||||
skuSequence: 1,
|
||||
batchNo: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
await assetCenterService.createVariantWithSimilarity(
|
||||
{
|
||||
assetFamilyId: 'family-packaging',
|
||||
code: 'DBZ-SPJ04',
|
||||
name: 'carton 120/120/6',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: '120/120/6',
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: { length: 1200, width: 1200, thickness: 60 },
|
||||
modelUrl: '',
|
||||
supplierName: 'supplier',
|
||||
supplierCode: 'N1280',
|
||||
versionNo: 'V01',
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
mountPoints: [],
|
||||
businessData,
|
||||
lossFactor: null,
|
||||
createdBy: 'user-2',
|
||||
},
|
||||
{ skipThumbnail: true },
|
||||
)
|
||||
|
||||
expect(mockedIntakeNonIntegratedProductRepository.updatePayloadByUser).toHaveBeenCalled()
|
||||
const syncedPayload =
|
||||
mockedIntakeNonIntegratedProductRepository.updatePayloadByUser.mock.calls[0]?.[1] as {
|
||||
skuItems: Array<Record<string, unknown>>
|
||||
skuBatchCount: number
|
||||
}
|
||||
expect(syncedPayload.skuBatchCount).toBe(1)
|
||||
expect(syncedPayload.skuItems).toHaveLength(2)
|
||||
expect(syncedPayload.skuItems[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceSkuId: 'variant-packaging-04',
|
||||
model: 'DBZ-SPJK06',
|
||||
skuCode: 'DBZ-SPJ04',
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
specName: 'carton 120/120/6',
|
||||
size: '120/120/6',
|
||||
color: 'carton',
|
||||
material: 'corrugated',
|
||||
boardMaterialType: '非ENF级',
|
||||
attributeCode: 'A',
|
||||
installMethod: '拆装',
|
||||
executeStandard: 'GB/T3324-2024',
|
||||
nationalStandardCompliant: '是',
|
||||
salesStatus: '销售',
|
||||
drawingId: 'DBZ-SPJK06V01',
|
||||
manufacturer: 'supplier',
|
||||
manufacturerCode: 'N1280',
|
||||
skuSequence: 2,
|
||||
batchNo: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('backfills missing packaging asset variants when ensuring an existing component detail product', async () => {
|
||||
const businessData = {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK06',
|
||||
skuCode: 'DBZ-SPJ04',
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
specName: 'carton 120/120/6',
|
||||
size: '120/120/6',
|
||||
color: 'carton',
|
||||
material: 'corrugated',
|
||||
versionNo: 'V01',
|
||||
drawingId: 'DBZ-SPJK06V01',
|
||||
manufacturer: 'supplier',
|
||||
manufacturerCode: 'N1280',
|
||||
batchNo: 'batch1',
|
||||
},
|
||||
}
|
||||
const packagingVariant = {
|
||||
id: 'variant-packaging-04',
|
||||
assetFamilyId: 'family-packaging',
|
||||
code: 'DBZ-SPJ04',
|
||||
name: 'carton 120/120/6',
|
||||
identityKey: 'PACKAGING|code=DBZ-SPJ04',
|
||||
assetType: 'PACKAGING',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: '120/120/6',
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: { length: 1200, width: 1200, thickness: 60 },
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
supplierName: 'supplier',
|
||||
supplierCode: 'N1280',
|
||||
versionNo: 'V01',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-2',
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-02T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-02T10:00:00.000Z'),
|
||||
assetFamily: {
|
||||
...defaultBoardFamily,
|
||||
id: 'family-packaging',
|
||||
type: 'PACKAGING',
|
||||
code: 'DBZ-SPJK06',
|
||||
name: 'packaging family',
|
||||
},
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
}
|
||||
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
|
||||
packagingVariant,
|
||||
] as never)
|
||||
mockedIntakeNonIntegratedProductRepository.listActive.mockResolvedValue([
|
||||
{
|
||||
id: 'component-detail-product',
|
||||
spu: 'DBZ-SPJ',
|
||||
updatedBy: 'user-1',
|
||||
payload: {
|
||||
id: 'component-detail-product',
|
||||
productDevelopmentKind: 'series',
|
||||
metadata: {
|
||||
componentDetailType: 'asset-series-components',
|
||||
sourceSeriesCode: 'DBZ',
|
||||
},
|
||||
spu: 'DBZ-SPJ',
|
||||
skuItems: [],
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
await assetCenterService.ensureSeriesComponentDetailProduct(
|
||||
defaultAssetSeries.id,
|
||||
{ id: 'user-2' } as never,
|
||||
)
|
||||
|
||||
expect(mockedIntakeNonIntegratedProductRepository.updatePayloadByUser).toHaveBeenCalled()
|
||||
const syncedPayload =
|
||||
mockedIntakeNonIntegratedProductRepository.updatePayloadByUser.mock.calls[0]?.[1] as {
|
||||
skuItems: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(syncedPayload.skuItems).toHaveLength(1)
|
||||
expect(syncedPayload.skuItems[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceSkuId: 'variant-packaging-04',
|
||||
model: 'DBZ-SPJK06',
|
||||
skuCode: 'DBZ-SPJ04',
|
||||
barcode: 'DBZ-SPJ04N1280V01',
|
||||
specName: 'carton 120/120/6',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('syncs updated asset variant SKU content back to component detail products', async () => {
|
||||
const buildComponentVariant = (overrides: Record<string, unknown>) => ({
|
||||
id: 'variant-01',
|
||||
assetFamilyId: 'family-component',
|
||||
code: 'DBZ-SPJ01',
|
||||
name: 'component 01',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ01',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: {},
|
||||
modelUrl: '/models/component-01.glb',
|
||||
thumbnailUrl: '/thumbs/component-01.png',
|
||||
barcode: null,
|
||||
supplierName: null,
|
||||
supplierCode: null,
|
||||
versionNo: 'V01',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
assetFamily: {
|
||||
id: 'family-component',
|
||||
code: 'DBZ-SPJK01',
|
||||
name: 'component family',
|
||||
type: 'COMPONENT',
|
||||
assetSeriesId: defaultAssetSeries.id,
|
||||
deletedAt: null,
|
||||
assetSeries: defaultAssetSeries,
|
||||
},
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
...overrides,
|
||||
})
|
||||
const businessData = {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK01',
|
||||
skuCode: 'DBZ-SPJ01',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
setCount: '1',
|
||||
specName: '款式一 100/60/2.5 胡桃色',
|
||||
size: '100/60/2.5',
|
||||
color: '胡桃色',
|
||||
material: '25ENF级橡胶木',
|
||||
versionNo: 'V01',
|
||||
drawingId: 'DBZ-SPJK01V01',
|
||||
manufacturer: '兰考华兰家具',
|
||||
manufacturerCode: 'N1280',
|
||||
batchNo: '批次1',
|
||||
},
|
||||
}
|
||||
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue(
|
||||
buildComponentVariant({}) as never,
|
||||
)
|
||||
mockedAssetVariantRepository.updateWithRelations.mockResolvedValue(
|
||||
buildComponentVariant({
|
||||
name: '款式一 100/60/2.5 胡桃色',
|
||||
sizeLabel: '100/60/2.5',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
supplierName: '兰考华兰家具',
|
||||
supplierCode: 'N1280',
|
||||
businessData,
|
||||
}) as never,
|
||||
)
|
||||
mockedIntakeNonIntegratedProductRepository.listActive.mockResolvedValue([
|
||||
{
|
||||
id: 'component-detail-product',
|
||||
updatedBy: 'user-1',
|
||||
payload: {
|
||||
id: 'component-detail-product',
|
||||
productDevelopmentKind: 'series',
|
||||
metadata: { componentDetailType: 'asset-series-components' },
|
||||
skuItems: [
|
||||
{
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: 'variant-01',
|
||||
skuCode: 'DBZ-SPJ01',
|
||||
skuSequence: 1,
|
||||
specName: 'old spec',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
await assetCenterService.updateVariant(
|
||||
'variant-01',
|
||||
{
|
||||
name: '款式一 100/60/2.5 胡桃色',
|
||||
sizeLabel: '100/60/2.5',
|
||||
supplierName: '兰考华兰家具',
|
||||
supplierCode: 'N1280',
|
||||
versionNo: 'V01',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
businessData,
|
||||
updatedBy: 'user-2',
|
||||
},
|
||||
{ skipThumbnail: true },
|
||||
)
|
||||
|
||||
expect(mockedIntakeNonIntegratedProductRepository.updatePayloadByUser).toHaveBeenCalled()
|
||||
const syncedPayload =
|
||||
mockedIntakeNonIntegratedProductRepository.updatePayloadByUser.mock.calls[0]?.[1] as {
|
||||
skuItems: Array<Record<string, unknown>>
|
||||
skuBatchCount: number
|
||||
}
|
||||
expect(syncedPayload.skuBatchCount).toBe(1)
|
||||
expect(syncedPayload.skuItems[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceSkuId: 'variant-01',
|
||||
model: 'DBZ-SPJK01',
|
||||
skuCode: 'DBZ-SPJ01',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
specName: '款式一 100/60/2.5 胡桃色',
|
||||
size: '100/60/2.5',
|
||||
color: '胡桃色',
|
||||
material: '25ENF级橡胶木',
|
||||
drawingId: 'DBZ-SPJK01V01',
|
||||
manufacturer: '兰考华兰家具',
|
||||
manufacturerCode: 'N1280',
|
||||
batchNo: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('syncs shared SKU fields to sibling variants in the same asset family', async () => {
|
||||
const buildComponentVariant = (overrides: Record<string, unknown>) => ({
|
||||
id: 'variant-01',
|
||||
assetFamilyId: 'family-component',
|
||||
code: 'DBZ-SPJ01',
|
||||
name: 'component 01',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ01',
|
||||
assetType: 'COMPONENT',
|
||||
status: 'DRAFT',
|
||||
sizeLabel: null,
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: {},
|
||||
modelUrl: '/models/component-01.glb',
|
||||
thumbnailUrl: '/thumbs/component-01.png',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
supplierName: 'supplier',
|
||||
supplierCode: 'N1280',
|
||||
versionNo: 'V01',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: null,
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: 'user-1',
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
rejectionReason: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-01T10:00:00.000Z'),
|
||||
assetFamily: {
|
||||
id: 'family-component',
|
||||
code: 'DBZ-SPJK01',
|
||||
name: 'component family',
|
||||
type: 'COMPONENT',
|
||||
assetSeriesId: defaultAssetSeries.id,
|
||||
deletedAt: null,
|
||||
assetSeries: defaultAssetSeries,
|
||||
},
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
...overrides,
|
||||
})
|
||||
const sourceBusinessData = {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK01',
|
||||
skuCode: 'DBZ-SPJ01',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
specName: 'style one',
|
||||
size: '100/60/2.5',
|
||||
color: 'walnut',
|
||||
material: 'rubber wood',
|
||||
versionNo: '',
|
||||
attributeCode: 'B',
|
||||
installMethod: '整装',
|
||||
executeStandard: 'GB/T3325-2024',
|
||||
nationalStandardCompliant: '',
|
||||
salesStatus: '',
|
||||
boardMaterialType: '非ENF级',
|
||||
},
|
||||
}
|
||||
const siblingBusinessData = {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK01',
|
||||
skuCode: 'DBZ-SPJ02',
|
||||
barcode: 'DBZ-SPJ02N1280V01',
|
||||
specName: 'style two',
|
||||
size: '120/60/2.5',
|
||||
color: 'black',
|
||||
material: 'steel',
|
||||
versionNo: 'V01',
|
||||
attributeCode: 'A',
|
||||
installMethod: '拆装',
|
||||
executeStandard: 'GB/T3324-2024',
|
||||
nationalStandardCompliant: '是',
|
||||
salesStatus: '销售',
|
||||
boardMaterialType: 'ENF级',
|
||||
},
|
||||
}
|
||||
const existingVariant = buildComponentVariant({
|
||||
businessData: { skuContentFields: { versionNo: 'V01' } },
|
||||
})
|
||||
const updatedSourceVariant = buildComponentVariant({
|
||||
businessData: sourceBusinessData,
|
||||
})
|
||||
const siblingVariant = buildComponentVariant({
|
||||
id: 'variant-02',
|
||||
code: 'DBZ-SPJ02',
|
||||
name: 'component 02',
|
||||
identityKey: 'COMPONENT|code=DBZ-SPJ02',
|
||||
barcode: 'DBZ-SPJ02N1280V01',
|
||||
businessData: siblingBusinessData,
|
||||
})
|
||||
|
||||
mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue(
|
||||
existingVariant as never,
|
||||
)
|
||||
mockedAssetVariantRepository.updateWithRelations
|
||||
.mockResolvedValueOnce(updatedSourceVariant as never)
|
||||
.mockResolvedValueOnce(
|
||||
buildComponentVariant({
|
||||
...siblingVariant,
|
||||
businessData: {
|
||||
skuContentFields: {
|
||||
...siblingBusinessData.skuContentFields,
|
||||
versionNo: '',
|
||||
attributeCode: 'B',
|
||||
installMethod: '整装',
|
||||
executeStandard: 'GB/T3325-2024',
|
||||
nationalStandardCompliant: '',
|
||||
salesStatus: '',
|
||||
boardMaterialType: '非ENF级',
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
mockedAssetVariantRepository.findManyByFamilyIds.mockResolvedValueOnce([
|
||||
updatedSourceVariant,
|
||||
siblingVariant,
|
||||
] as never)
|
||||
mockedIntakeNonIntegratedProductRepository.listActive.mockResolvedValue([])
|
||||
|
||||
await assetCenterService.updateVariant(
|
||||
'variant-01',
|
||||
{
|
||||
businessData: sourceBusinessData,
|
||||
updatedBy: 'user-2',
|
||||
},
|
||||
{ skipThumbnail: true },
|
||||
)
|
||||
|
||||
expect(mockedAssetVariantRepository.findManyByFamilyIds).toHaveBeenCalledWith([
|
||||
'family-component',
|
||||
])
|
||||
expect(mockedAssetVariantRepository.updateWithRelations).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'variant-02',
|
||||
expect.objectContaining({
|
||||
businessData: {
|
||||
skuContentFields: expect.objectContaining({
|
||||
model: 'DBZ-SPJK01',
|
||||
skuCode: 'DBZ-SPJ02',
|
||||
barcode: 'DBZ-SPJ02N1280V01',
|
||||
specName: 'style two',
|
||||
size: '120/60/2.5',
|
||||
color: 'black',
|
||||
material: 'steel',
|
||||
versionNo: '',
|
||||
attributeCode: 'B',
|
||||
installMethod: '整装',
|
||||
executeStandard: 'GB/T3325-2024',
|
||||
nationalStandardCompliant: '',
|
||||
salesStatus: '',
|
||||
boardMaterialType: '非ENF级',
|
||||
}),
|
||||
},
|
||||
lastModifier: { connect: { id: 'user-2' } },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -77,6 +77,7 @@ function buildLegacyCandidateWorkspace(input?: {
|
||||
modelUrl: string
|
||||
}>
|
||||
connectorCount?: number
|
||||
connectorZone?: 'main' | 'staging'
|
||||
validation?: AssemblyWorkspaceSnapshot['validation']
|
||||
}): AssemblyWorkspaceSnapshot {
|
||||
const includeDesktop = input?.includeDesktop !== false
|
||||
@ -161,7 +162,7 @@ function buildLegacyCandidateWorkspace(input?: {
|
||||
position: [index * 40, 640, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
scale: [1, 1, 1] as [number, number, number],
|
||||
zone: 'main' as const,
|
||||
zone: input?.connectorZone ?? ('main' as const),
|
||||
source: 'connector' as const,
|
||||
inventoryId: `connector:family-connector-bracket:${index + 1}`,
|
||||
slotKey: `connector-${index + 1}`,
|
||||
@ -696,6 +697,82 @@ describe('draftGroupService', () => {
|
||||
expect(result.preSkus).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps staging connector nodes in draft-group hardware selections', async () => {
|
||||
const result = await draftGroupService.createFromAutoCombination(
|
||||
{
|
||||
name: 'staging connector draft',
|
||||
categoryId: 'category-desk',
|
||||
candidates: [
|
||||
{
|
||||
key: 'family-desktop-a::family-leg-slim::staging-connector',
|
||||
defaultName: 'staging connector sku',
|
||||
familySelection: {
|
||||
desktopFamilyId: 'family-desktop-a',
|
||||
legGroupFamilyId: 'family-leg-slim',
|
||||
legRoles: {
|
||||
frontLeft: 'family-leg-slim',
|
||||
frontRight: null,
|
||||
rearLeft: null,
|
||||
rearRight: null,
|
||||
},
|
||||
connectorFamilyIds: ['family-connector-bracket'],
|
||||
},
|
||||
assemblyWorkspace: buildLegacyCandidateWorkspace({
|
||||
legs: [
|
||||
{
|
||||
slotKey: 'frontLeftLeg',
|
||||
role: 'frontLeft',
|
||||
familyId: 'family-leg-slim',
|
||||
variantId: 'variant-leg-slim-black',
|
||||
code: 'LEG-SLIM-BK-720',
|
||||
name: 'slim leg',
|
||||
modelUrl: '/models/leg-slim-720.glb',
|
||||
},
|
||||
],
|
||||
connectorCount: 1,
|
||||
connectorZone: 'staging',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
authUser,
|
||||
)
|
||||
|
||||
expect(mockedPreSkuRepository.createManyForDraftGroup).toHaveBeenCalledWith(
|
||||
'draft-group-1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variantSelection: expect.objectContaining({
|
||||
slots: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-connector-bracket',
|
||||
}),
|
||||
]),
|
||||
hardwareSelections: [
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-connector-bracket',
|
||||
assetFamilyId: 'family-connector-bracket',
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
bomPreview: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-connector-bracket',
|
||||
assetType: 'HARDWARE',
|
||||
quantity: 1,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mockedAssetVariantRepository.findByIdsWithRelations).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['variant-connector-bracket']),
|
||||
)
|
||||
expect(result.preSkus).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('存在 BLOCKED 装配工作区时,不允许进入草稿组', async () => {
|
||||
await expect(
|
||||
draftGroupService.createFromAutoCombination(
|
||||
@ -1375,7 +1452,28 @@ describe('draftGroupService', () => {
|
||||
},
|
||||
recommendedHardware: [],
|
||||
previewNodes: [],
|
||||
displaySummary: {},
|
||||
displaySummary: {
|
||||
assemblyWorkspace: {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: 'desktop-node',
|
||||
zone: 'main',
|
||||
position: [0, 737.5, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
},
|
||||
{
|
||||
nodeId: 'leg-node',
|
||||
zone: 'main',
|
||||
position: [0, 362.5, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
dimensions: { length: 50, width: 50, height: 725 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
bomPreview: [],
|
||||
weightPreview: null,
|
||||
componentCodes: [],
|
||||
@ -1469,6 +1567,7 @@ describe('draftGroupService', () => {
|
||||
color: '柚木色',
|
||||
material: '颗粒板 + 柚木色',
|
||||
specName: '桌面 1200 柚木色',
|
||||
size: '120 / 60 / 75',
|
||||
versionNo: 'V01',
|
||||
}),
|
||||
)
|
||||
|
||||
@ -14,6 +14,12 @@ jest.mock('../../repositories/intake-non-integrated-product.repository', () => (
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../../repositories/asset-center.repository', () => ({
|
||||
assetVariantRepository: {
|
||||
findActiveBySeriesCode: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../../repositories/draft-group.repository', () => ({
|
||||
draftGroupRepository: {
|
||||
findByIdWithRelations: jest.fn(),
|
||||
@ -41,10 +47,12 @@ jest.mock('../sku-quality-inspection.service', () => ({
|
||||
}))
|
||||
|
||||
import { intakeNonIntegratedProductRepository } from '../../repositories/intake-non-integrated-product.repository'
|
||||
import { assetVariantRepository } from '../../repositories/asset-center.repository'
|
||||
import { draftGroupRepository, preSkuRepository } from '../../repositories/draft-group.repository'
|
||||
import { intakeNonIntegratedProductService } from '../intake-non-integrated-product.service'
|
||||
|
||||
const mockedRepository = jest.mocked(intakeNonIntegratedProductRepository)
|
||||
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
|
||||
const mockedDraftGroupRepository = jest.mocked(draftGroupRepository)
|
||||
const mockedPreSkuRepository = jest.mocked(preSkuRepository)
|
||||
|
||||
@ -111,6 +119,89 @@ function createRecord(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function createAssetVariant(overrides: Record<string, unknown> = {}) {
|
||||
const code = typeof overrides.code === 'string' ? overrides.code : 'DBZ-SPJ11'
|
||||
const id = typeof overrides.id === 'string' ? overrides.id : `variant-${code}`
|
||||
return {
|
||||
id,
|
||||
assetFamilyId: 'family-packaging',
|
||||
assetType: 'PACKAGING',
|
||||
code,
|
||||
name: typeof overrides.name === 'string' ? overrides.name : `${code} packaging`,
|
||||
identityKey: code,
|
||||
status: 'DRAFT',
|
||||
sizeLabel: typeof overrides.sizeLabel === 'string' ? overrides.sizeLabel : '',
|
||||
substrateId: null,
|
||||
finishId: null,
|
||||
dimensions: {},
|
||||
modelUrl: '',
|
||||
thumbnailUrl: null,
|
||||
barcode: null,
|
||||
supplierName: null,
|
||||
supplierCode: 'N12',
|
||||
versionNo: 'V01',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
namingFields: null,
|
||||
businessData: {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK06',
|
||||
skuCode: code,
|
||||
barcode: `${code}N12V01`,
|
||||
specName: `${code} packaging`,
|
||||
size: typeof overrides.sizeLabel === 'string' ? overrides.sizeLabel : '',
|
||||
color: '',
|
||||
material: '',
|
||||
manufacturer: '',
|
||||
manufacturerCode: 'N12',
|
||||
versionNo: 'V01',
|
||||
attributeCode: 'N12',
|
||||
drawingId: 'DBZ-SPJK06N12V01',
|
||||
},
|
||||
},
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
lossFactor: null,
|
||||
estimatedCostPrice: null,
|
||||
inheritedFromVariantId: null,
|
||||
createdBy: null,
|
||||
submittedAt: null,
|
||||
submittedById: null,
|
||||
reviewedAt: null,
|
||||
reviewedById: null,
|
||||
rejectedAt: null,
|
||||
rejectedById: null,
|
||||
reviewNote: null,
|
||||
lastModifiedById: null,
|
||||
deletedAt: null,
|
||||
deletedById: null,
|
||||
createdAt: new Date('2026-06-02T07:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-02T07:00:00.000Z'),
|
||||
assetFamily: {
|
||||
id: 'family-packaging',
|
||||
code: 'DBZ-SPJK06',
|
||||
name: 'Packaging family',
|
||||
type: 'PACKAGING',
|
||||
assetSeriesId: 'series-dbz',
|
||||
deletedAt: null,
|
||||
assetSeries: {
|
||||
id: 'series-dbz',
|
||||
code: 'DBZ',
|
||||
name: 'Single board desk',
|
||||
},
|
||||
},
|
||||
substrate: null,
|
||||
finish: null,
|
||||
creator: null,
|
||||
submitter: null,
|
||||
reviewer: null,
|
||||
rejecter: null,
|
||||
lastModifier: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('intakeNonIntegratedProductService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
@ -119,6 +210,7 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
mockedDraftGroupRepository.delete.mockResolvedValue({} as never)
|
||||
mockedDraftGroupRepository.findByIdWithRelations.mockResolvedValue(null as never)
|
||||
mockedPreSkuRepository.findByIdWithRelations.mockResolvedValue(null as never)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([] as never)
|
||||
})
|
||||
|
||||
it('lists all shared records when permissions are relaxed', async () => {
|
||||
@ -465,6 +557,75 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
expect(mockedRepository.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends missing asset center component SKUs before saving a stale component detail payload', async () => {
|
||||
const staleSkuItems = Array.from({ length: 10 }, (_, index) => {
|
||||
const sequence = index + 1
|
||||
const skuCode = `DBZ-SPJ${String(sequence).padStart(2, '0')}`
|
||||
return {
|
||||
id: `asset-variant-variant-${sequence}`,
|
||||
skuSequence: sequence,
|
||||
skuCode,
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: `variant-${sequence}`,
|
||||
}
|
||||
})
|
||||
const componentPayload = createPayload({
|
||||
id: 'DBZ-SPJ-1780300716699',
|
||||
spu: 'DBZ-SPJ',
|
||||
productCode: 'DBZ-SPJ',
|
||||
productDevelopmentKind: 'series',
|
||||
metadata: {
|
||||
componentDetailType: 'asset-series-components',
|
||||
sourceSeriesCode: 'DBZ',
|
||||
},
|
||||
skuItems: staleSkuItems,
|
||||
skuBatchCount: 1,
|
||||
})
|
||||
const componentRecord = createRecord({
|
||||
id: 'DBZ-SPJ-1780300716699',
|
||||
spu: 'DBZ-SPJ',
|
||||
spuBase: 'DBZ-SPJ',
|
||||
payload: componentPayload,
|
||||
})
|
||||
|
||||
mockedRepository.findBySpu.mockResolvedValue(null as never)
|
||||
mockedRepository.findById.mockResolvedValue(componentRecord as never)
|
||||
mockedRepository.update.mockResolvedValue(componentRecord as never)
|
||||
mockedRepository.findActiveById.mockResolvedValue(componentRecord as never)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
|
||||
createAssetVariant({
|
||||
id: 'variant-11',
|
||||
code: 'DBZ-SPJ11',
|
||||
sizeLabel: '140/140/0.3',
|
||||
}),
|
||||
createAssetVariant({
|
||||
id: 'variant-12',
|
||||
code: 'DBZ-SPJ12',
|
||||
sizeLabel: '120/120/0.3',
|
||||
}),
|
||||
] as never)
|
||||
|
||||
await intakeNonIntegratedProductService.save(componentPayload, baseUser)
|
||||
|
||||
expect(mockedAssetVariantRepository.findActiveBySeriesCode).toHaveBeenCalledWith('DBZ')
|
||||
const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as {
|
||||
skuItems?: Array<Record<string, unknown>>
|
||||
skuBatchCount?: number
|
||||
}
|
||||
expect(updatePayload.skuItems).toHaveLength(12)
|
||||
expect(updatePayload.skuItems?.slice(-2).map((item) => item.skuCode)).toEqual([
|
||||
'DBZ-SPJ11',
|
||||
'DBZ-SPJ12',
|
||||
])
|
||||
expect(updatePayload.skuItems?.[10]).toMatchObject({
|
||||
sourceSkuId: 'variant-11',
|
||||
model: 'DBZ-SPJK06',
|
||||
barcode: 'DBZ-SPJ11N12V01',
|
||||
})
|
||||
expect(updatePayload.skuBatchCount).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects purchasers from confirming product photo through the shared save API', async () => {
|
||||
const existingConfirmations = {
|
||||
...createDefaultDocumentWorkspaceConfirmations(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -35,6 +35,14 @@ export interface AssetSeriesSummaryDTO {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AssetSeriesComponentDetailSummaryDTO {
|
||||
productId: string
|
||||
spu: string
|
||||
sourceSeriesCode: string
|
||||
sourceSeriesName: string
|
||||
created: boolean
|
||||
}
|
||||
|
||||
export interface CreateAssetSeriesInput {
|
||||
name: string
|
||||
code?: string | null
|
||||
|
||||
@ -145,24 +145,107 @@ function addConflict(
|
||||
conflicts[conflict.field] = [...(conflicts[conflict.field] ?? []), conflict]
|
||||
}
|
||||
|
||||
function formatDimensionsForBasicInfo(dimensions: Record<string, number> | null | undefined): string {
|
||||
if (!dimensions) return ''
|
||||
const labels: Record<string, string> = {
|
||||
length: '长',
|
||||
width: '宽',
|
||||
height: '高',
|
||||
depth: '深',
|
||||
thickness: '厚',
|
||||
function readPositiveNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
function readVector3(value: unknown, fallback: [number, number, number]) {
|
||||
if (!Array.isArray(value) || value.length < 3) return fallback
|
||||
const vector = value.slice(0, 3)
|
||||
return vector.every((item) => typeof item === 'number' && Number.isFinite(item))
|
||||
? (vector as [number, number, number])
|
||||
: fallback
|
||||
}
|
||||
|
||||
function readDimensionValue(
|
||||
dimensions: Record<string, unknown>,
|
||||
keys: string[],
|
||||
) {
|
||||
for (const key of keys) {
|
||||
const value = readPositiveNumber(dimensions[key])
|
||||
if (value !== null) return value
|
||||
}
|
||||
const order = ['length', 'width', 'height', 'depth', 'thickness']
|
||||
return Object.entries(dimensions)
|
||||
.filter((entry): entry is [string, number] => {
|
||||
const [, value] = entry
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
})
|
||||
.sort(([left], [right]) => order.indexOf(left) - order.indexOf(right))
|
||||
.map(([key, value]) => `${labels[key] ?? key}${Number(value.toFixed(2))}mm`)
|
||||
.join(' / ')
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function rotateLocalHalfSize(
|
||||
halfSize: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
) {
|
||||
const [x, y, z] = rotation
|
||||
const cx = Math.cos(x)
|
||||
const sx = Math.sin(x)
|
||||
const cy = Math.cos(y)
|
||||
const sy = Math.sin(y)
|
||||
const cz = Math.cos(z)
|
||||
const sz = Math.sin(z)
|
||||
const matrix = [
|
||||
[cy * cz, cz * sx * sy - cx * sz, sx * sz + cx * cz * sy],
|
||||
[cy * sz, cx * cz + sx * sy * sz, cx * sy * sz - cz * sx],
|
||||
[-sy, cy * sx, cx * cy],
|
||||
]
|
||||
|
||||
return matrix.map((row) =>
|
||||
row.reduce((total, value, index) => total + Math.abs(value) * halfSize[index], 0),
|
||||
) as [number, number, number]
|
||||
}
|
||||
|
||||
function formatCentimeterSize(valueMm: number) {
|
||||
const valueCm = Math.round((valueMm / 10) * 10) / 10
|
||||
return Number.isInteger(valueCm) ? String(valueCm) : valueCm.toFixed(1)
|
||||
}
|
||||
|
||||
function buildPreSkuOverallSizeSuggestion(preSku: PreSkuWithRelations): string {
|
||||
const displaySummary = isRecord(preSku.displaySummary) ? preSku.displaySummary : null
|
||||
const assemblyWorkspace = isRecord(displaySummary?.assemblyWorkspace)
|
||||
? displaySummary.assemblyWorkspace
|
||||
: null
|
||||
const nodes = Array.isArray(assemblyWorkspace?.nodes)
|
||||
? assemblyWorkspace.nodes
|
||||
: []
|
||||
|
||||
let min = [Infinity, Infinity, Infinity]
|
||||
let max = [-Infinity, -Infinity, -Infinity]
|
||||
let boundedNodeCount = 0
|
||||
let hasExplicitHeightDimension = false
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!isRecord(node) || node.zone === 'staging') return
|
||||
const dimensions = isRecord(node.dimensions) ? node.dimensions : null
|
||||
if (!dimensions) return
|
||||
|
||||
const localSize: [number | null, number | null, number | null] = [
|
||||
readDimensionValue(dimensions, ['length', 'diameter', 'width']),
|
||||
readDimensionValue(dimensions, ['height', 'thickness', 'depth']),
|
||||
readDimensionValue(dimensions, ['width', 'depth', 'thickness']),
|
||||
]
|
||||
if (localSize.some((value) => value === null)) return
|
||||
|
||||
const position = readVector3(node.position, [0, 0, 0])
|
||||
const rotation = readVector3(node.rotation, [0, 0, 0])
|
||||
const scale = readVector3(node.scale, [1, 1, 1])
|
||||
const halfSize = rotateLocalHalfSize(
|
||||
localSize.map((value, index) => (
|
||||
((value as number) * Math.abs(scale[index])) / 2
|
||||
)) as [number, number, number],
|
||||
rotation,
|
||||
)
|
||||
|
||||
min = min.map((value, index) => Math.min(value, position[index] - halfSize[index]))
|
||||
max = max.map((value, index) => Math.max(value, position[index] + halfSize[index]))
|
||||
boundedNodeCount += 1
|
||||
hasExplicitHeightDimension ||= readPositiveNumber(dimensions.height) !== null
|
||||
})
|
||||
|
||||
if (boundedNodeCount < 2 || !hasExplicitHeightDimension) return ''
|
||||
|
||||
const size = max.map((value, index) => value - min[index])
|
||||
if (size.some((value) => !Number.isFinite(value) || value <= 0)) return ''
|
||||
|
||||
return `${formatCentimeterSize(size[0])} / ${formatCentimeterSize(size[2])} / ${formatCentimeterSize(size[1])}`
|
||||
}
|
||||
|
||||
function resolvePrimaryVariantId(preSku: PreSkuWithRelations): string | null {
|
||||
@ -561,7 +644,7 @@ class DraftGroupService {
|
||||
setIfPresent(suggestions, 'model', snapshot?.model)
|
||||
setIfPresent(suggestions, 'formalSkuCode', snapshot?.specCode)
|
||||
setIfPresent(suggestions, 'barcode', snapshot?.barcode)
|
||||
setIfPresent(suggestions, 'size', formatDimensionsForBasicInfo(snapshot?.size))
|
||||
setIfPresent(suggestions, 'size', buildPreSkuOverallSizeSuggestion(preSku))
|
||||
setIfPresent(suggestions, 'color', snapshot?.color)
|
||||
setIfPresent(suggestions, 'material', snapshot?.material)
|
||||
setIfPresent(suggestions, 'specName', snapshot?.specName)
|
||||
|
||||
@ -165,6 +165,7 @@ export type {
|
||||
AssetFamilyDTO,
|
||||
AssetReviewActionInput,
|
||||
AssetSeriesDTO,
|
||||
AssetSeriesComponentDetailSummaryDTO,
|
||||
AssetSeriesSummaryDTO,
|
||||
AssetVariantDTO,
|
||||
CreateAssetVariantResultDTO,
|
||||
|
||||
@ -12,15 +12,25 @@ import {
|
||||
type NonIntegratedDocumentWorkspaceApprovalMap,
|
||||
type NonIntegratedDocumentWorkspaceConfirmationMap,
|
||||
type NonIntegratedProductRecord,
|
||||
type NonIntegratedSkuItem,
|
||||
type NonIntegratedUploadFile,
|
||||
} from '@/app/intake/non-integrated/nonIntegratedStorage'
|
||||
import { normalizeNonIntegratedAssetUrl } from '@/app/intake/non-integrated/nonIntegratedAssetUrl'
|
||||
import {
|
||||
buildAssetVariantBarcode,
|
||||
DEFAULT_SKU_VERSION_NO,
|
||||
normalizeSkuVersionNo,
|
||||
} from '@/lib/open-product/asset-coding'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
intakeNonIntegratedProductRepository,
|
||||
type IntakeNonIntegratedProductRepositoryClass,
|
||||
} from '../repositories/intake-non-integrated-product.repository'
|
||||
import {
|
||||
assetVariantRepository,
|
||||
type AssetVariantWithRelations,
|
||||
} from '../repositories/asset-center.repository'
|
||||
import {
|
||||
draftGroupRepository,
|
||||
preSkuRepository,
|
||||
@ -128,6 +138,7 @@ export type IntakeNonIntegratedProductListParams = {
|
||||
productDevelopmentKind?: 'product' | 'series'
|
||||
productKind?: 'all' | 'new' | 'stock'
|
||||
quickScope?: 'all' | 'created' | 'pending'
|
||||
seriesName?: string
|
||||
spu?: string
|
||||
appearanceDesigner?: string
|
||||
structureDesigner?: string
|
||||
@ -2049,6 +2060,24 @@ function buildSpuSearchWhere(spu: string): Prisma.IntakeNonIntegratedProductWher
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeriesNameSearchWhere(
|
||||
seriesName: string | undefined,
|
||||
): Prisma.IntakeNonIntegratedProductWhereInput | null {
|
||||
const keyword = trimListFilter(seriesName)
|
||||
if (!keyword) return null
|
||||
|
||||
return {
|
||||
OR: [
|
||||
jsonTextContains(['openingReference', 'productName'], keyword),
|
||||
jsonTextContains(['openingReference', 'temporaryCode'], keyword),
|
||||
jsonTextContains(['metadata', 'sourceSeriesName'], keyword),
|
||||
jsonTextContains(['metadata', 'sourceSeriesCode'], keyword),
|
||||
{ spu: { contains: keyword, mode: 'insensitive' } },
|
||||
{ spuBase: { contains: keyword, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildStockTextSearchWhere(
|
||||
value: string | undefined,
|
||||
paths: string[][],
|
||||
@ -2303,6 +2332,7 @@ function buildListFilterWhere(
|
||||
const filters = [
|
||||
buildDevelopmentKindWhere(params.productDevelopmentKind),
|
||||
buildProductKindWhere(params.productKind),
|
||||
buildSeriesNameSearchWhere(params.seriesName),
|
||||
buildSpuSearchWhere(params.spu ?? ''),
|
||||
buildQuickScopeWhere(params.quickScope, user),
|
||||
...buildStockSourceSearchWhere(params),
|
||||
@ -2398,6 +2428,339 @@ function readSourceSkuIdSet(product: SharedNonIntegratedProductRecord | null | u
|
||||
)
|
||||
}
|
||||
|
||||
const SERIES_COMPONENT_DETAIL_METADATA_TYPE = 'asset-series-components'
|
||||
const SERIES_COMPONENT_ASSET_SKU_SOURCE = 'asset_series_component'
|
||||
const ASSET_VARIANT_SOURCE_TABLE = 'asset_variant'
|
||||
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
|
||||
const NON_BOARD_ASSET_BOARD_MATERIAL_TYPE = '非ENF级'
|
||||
const DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE = 'A'
|
||||
const DEFAULT_ASSET_VARIANT_INSTALL_METHOD = '拆装'
|
||||
const DEFAULT_ASSET_VARIANT_EXECUTE_STANDARD = 'GB/T3324-2024'
|
||||
const DEFAULT_STEEL_ASSET_VARIANT_EXECUTE_STANDARD = 'GB/T3325-2024'
|
||||
const DEFAULT_ASSET_VARIANT_NATIONAL_STANDARD_COMPLIANT = '是'
|
||||
const DEFAULT_ASSET_VARIANT_SALES_STATUS = '销售'
|
||||
|
||||
function readPayloadString(item: Record<string, unknown>, key: string): string {
|
||||
const value = item[key]
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
return ''
|
||||
}
|
||||
|
||||
function readVariantSkuContentFields(
|
||||
variant: AssetVariantWithRelations,
|
||||
): Record<string, string> {
|
||||
const businessData = isRecord(variant.businessData) ? variant.businessData : null
|
||||
const rawFields = businessData?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
if (!isRecord(rawFields)) return {}
|
||||
|
||||
return Object.entries(rawFields).reduce<Record<string, string>>((fields, [key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
fields[key] = value.trim()
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
fields[key] = String(value)
|
||||
}
|
||||
return fields
|
||||
}, {})
|
||||
}
|
||||
|
||||
function getVariantSkuContentField(
|
||||
fields: Record<string, string>,
|
||||
key: string,
|
||||
fallback = '',
|
||||
): string {
|
||||
return Object.prototype.hasOwnProperty.call(fields, key)
|
||||
? fields[key] ?? ''
|
||||
: fallback
|
||||
}
|
||||
|
||||
function hasVariantSkuContentField(fields: Record<string, string>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(fields, key)
|
||||
}
|
||||
|
||||
function getVariantSkuContentFieldWithDefault(
|
||||
fields: Record<string, string>,
|
||||
key: string,
|
||||
defaultValue: string,
|
||||
): string {
|
||||
return hasVariantSkuContentField(fields, key) ? fields[key] ?? '' : defaultValue
|
||||
}
|
||||
|
||||
function getDefaultVariantExecuteStandard(assetType: string): string {
|
||||
return assetType === 'COMPONENT' || assetType === 'HARDWARE'
|
||||
? DEFAULT_STEEL_ASSET_VARIANT_EXECUTE_STANDARD
|
||||
: DEFAULT_ASSET_VARIANT_EXECUTE_STANDARD
|
||||
}
|
||||
|
||||
function getAssetVariantBoardMaterialTypeForSku(
|
||||
_assetType: string,
|
||||
fields: Record<string, string>,
|
||||
): string {
|
||||
return getVariantSkuContentFieldWithDefault(
|
||||
fields,
|
||||
'boardMaterialType',
|
||||
NON_BOARD_ASSET_BOARD_MATERIAL_TYPE,
|
||||
)
|
||||
}
|
||||
|
||||
function parseSeriesComponentBatchNo(value: string, fallback: number): number {
|
||||
const matched = value.trim().match(/\d+/)
|
||||
const parsed = matched ? Number.parseInt(matched[0] ?? '', 10) : NaN
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function createEmptyAssetSeriesComponentSkuItem(
|
||||
variant: AssetVariantWithRelations,
|
||||
skuSequence: number,
|
||||
): NonIntegratedSkuItem {
|
||||
return {
|
||||
id: `asset-variant-${variant.id}`,
|
||||
skuSequence,
|
||||
batchNo: 1,
|
||||
packageGroupId: '',
|
||||
packageSuffix: '',
|
||||
image: '',
|
||||
imageName: '',
|
||||
model: '',
|
||||
skuCode: '',
|
||||
setCount: '1',
|
||||
specName: '',
|
||||
barcode: '',
|
||||
size: '',
|
||||
color: '',
|
||||
material: '',
|
||||
boardMaterialType: '',
|
||||
versionNo: DEFAULT_SKU_VERSION_NO,
|
||||
attributeCode: '',
|
||||
drawingId: '',
|
||||
installMethod: '',
|
||||
executeStandard: '',
|
||||
nationalStandardCompliant: '',
|
||||
manufacturer: '',
|
||||
manufacturerCode: '',
|
||||
salesStatus: '',
|
||||
tagMaterial: '',
|
||||
model3dFileName: '',
|
||||
model3dDataUrl: '',
|
||||
model3dNodeOffsets: {},
|
||||
model3dNodeRotations: {},
|
||||
model3dNodeMaterialIds: {},
|
||||
model3dUvConfig: null,
|
||||
model3dPreviewNodes: [],
|
||||
componentDetails: [],
|
||||
holePoints: [],
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceSkuId: variant.id,
|
||||
contentLocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssetSeriesComponentSkuItemFromVariant(
|
||||
item: NonIntegratedSkuItem,
|
||||
variant: AssetVariantWithRelations,
|
||||
skuSequence: number,
|
||||
): NonIntegratedSkuItem {
|
||||
const itemRecord = item as unknown as Record<string, unknown>
|
||||
const normalizedCode = variant.code.trim().toUpperCase()
|
||||
const normalizedModel = variant.assetFamily.code.trim().toUpperCase()
|
||||
const normalizedModelUrl = variant.modelUrl.trim()
|
||||
const thumbnailUrl = variant.thumbnailUrl?.trim() ?? ''
|
||||
const skuContentFields = readVariantSkuContentFields(variant)
|
||||
const rawVersionNo = hasVariantSkuContentField(skuContentFields, 'versionNo')
|
||||
? skuContentFields.versionNo ?? ''
|
||||
: (variant.versionNo ?? readPayloadString(itemRecord, 'versionNo')) || DEFAULT_SKU_VERSION_NO
|
||||
const versionNo = rawVersionNo.trim() ? normalizeSkuVersionNo(rawVersionNo) : ''
|
||||
const manufacturerCode = getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'manufacturerCode',
|
||||
variant.supplierCode ?? readPayloadString(itemRecord, 'manufacturerCode'),
|
||||
).trim().toUpperCase()
|
||||
const barcode = getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'barcode',
|
||||
variant.barcode ?? (
|
||||
versionNo &&
|
||||
buildAssetVariantBarcode({
|
||||
variantCode: normalizedCode,
|
||||
supplierCode: manufacturerCode,
|
||||
versionNo,
|
||||
}) ||
|
||||
readPayloadString(itemRecord, 'barcode')
|
||||
),
|
||||
).trim().toUpperCase()
|
||||
const batchNo = parseSeriesComponentBatchNo(
|
||||
getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'batchNo',
|
||||
readPayloadString(itemRecord, 'batchNo'),
|
||||
),
|
||||
1,
|
||||
)
|
||||
const attributeCode = getVariantSkuContentFieldWithDefault(
|
||||
skuContentFields,
|
||||
'attributeCode',
|
||||
DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE,
|
||||
).toUpperCase()
|
||||
const drawingIdFallback = `${getVariantSkuContentField(skuContentFields, 'model', normalizedModel) || normalizedModel}${attributeCode}${versionNo}`
|
||||
|
||||
return {
|
||||
...item,
|
||||
skuSequence,
|
||||
model: getVariantSkuContentField(skuContentFields, 'model', normalizedModel) || normalizedModel,
|
||||
skuCode: normalizedCode,
|
||||
barcode,
|
||||
setCount: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'setCount',
|
||||
readPayloadString(itemRecord, 'setCount') || '1',
|
||||
),
|
||||
specName: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'specName',
|
||||
readPayloadString(itemRecord, 'specName') || variant.name.trim(),
|
||||
),
|
||||
size: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'size',
|
||||
readPayloadString(itemRecord, 'size') || (variant.sizeLabel ?? ''),
|
||||
),
|
||||
color: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'color',
|
||||
readPayloadString(itemRecord, 'color'),
|
||||
),
|
||||
material: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'material',
|
||||
readPayloadString(itemRecord, 'material'),
|
||||
),
|
||||
boardMaterialType: getAssetVariantBoardMaterialTypeForSku(
|
||||
variant.assetType,
|
||||
skuContentFields,
|
||||
),
|
||||
versionNo,
|
||||
attributeCode,
|
||||
drawingId: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'drawingId',
|
||||
readPayloadString(itemRecord, 'drawingId') || drawingIdFallback,
|
||||
).toUpperCase(),
|
||||
installMethod: getVariantSkuContentFieldWithDefault(
|
||||
skuContentFields,
|
||||
'installMethod',
|
||||
DEFAULT_ASSET_VARIANT_INSTALL_METHOD,
|
||||
),
|
||||
executeStandard: getVariantSkuContentFieldWithDefault(
|
||||
skuContentFields,
|
||||
'executeStandard',
|
||||
getDefaultVariantExecuteStandard(variant.assetType),
|
||||
),
|
||||
nationalStandardCompliant: getVariantSkuContentFieldWithDefault(
|
||||
skuContentFields,
|
||||
'nationalStandardCompliant',
|
||||
DEFAULT_ASSET_VARIANT_NATIONAL_STANDARD_COMPLIANT,
|
||||
),
|
||||
manufacturer: getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'manufacturer',
|
||||
readPayloadString(itemRecord, 'manufacturer') || (variant.supplierName ?? ''),
|
||||
),
|
||||
manufacturerCode,
|
||||
salesStatus: getVariantSkuContentFieldWithDefault(
|
||||
skuContentFields,
|
||||
'salesStatus',
|
||||
DEFAULT_ASSET_VARIANT_SALES_STATUS,
|
||||
),
|
||||
batchNo,
|
||||
image: thumbnailUrl,
|
||||
imageName: thumbnailUrl ? `${normalizedCode}-thumb` : '',
|
||||
model3dFileName: normalizedModelUrl ? `${normalizedCode}-model.glb` : '',
|
||||
model3dDataUrl: normalizedModelUrl,
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceSkuId: variant.id,
|
||||
}
|
||||
}
|
||||
|
||||
function readAssetSeriesComponentSourceSeriesCode(
|
||||
product: SharedNonIntegratedProductRecord,
|
||||
): string {
|
||||
const metadata = isRecord(product.metadata) ? product.metadata : null
|
||||
if (metadata?.componentDetailType !== SERIES_COMPONENT_DETAIL_METADATA_TYPE) return ''
|
||||
|
||||
const metadataSeriesCode =
|
||||
typeof metadata.sourceSeriesCode === 'string'
|
||||
? metadata.sourceSeriesCode.trim().toUpperCase()
|
||||
: ''
|
||||
if (metadataSeriesCode) return metadataSeriesCode
|
||||
|
||||
const matched = product.spu.trim().toUpperCase().match(/^([A-Z][A-Z0-9]*)-SPJ$/)
|
||||
return matched?.[1] ?? ''
|
||||
}
|
||||
|
||||
function getSkuBatchCountFromItems(
|
||||
items: NonIntegratedSkuItem[],
|
||||
fallbackBatchCount?: number,
|
||||
): number {
|
||||
const maxBatchNo = Math.max(
|
||||
1,
|
||||
...items.map((item) =>
|
||||
typeof item.batchNo === 'number' && Number.isFinite(item.batchNo)
|
||||
? Math.max(1, Math.floor(item.batchNo))
|
||||
: 1,
|
||||
),
|
||||
)
|
||||
return Math.max(
|
||||
maxBatchNo,
|
||||
typeof fallbackBatchCount === 'number' && Number.isFinite(fallbackBatchCount)
|
||||
? Math.max(1, Math.floor(fallbackBatchCount))
|
||||
: 1,
|
||||
)
|
||||
}
|
||||
|
||||
async function mergeMissingAssetSeriesComponentSkuItems(
|
||||
product: SharedNonIntegratedProductRecord,
|
||||
): Promise<SharedNonIntegratedProductRecord> {
|
||||
const seriesCode = readAssetSeriesComponentSourceSeriesCode(product)
|
||||
if (!seriesCode) return product
|
||||
|
||||
const activeVariants = await assetVariantRepository.findActiveBySeriesCode(seriesCode)
|
||||
if (activeVariants.length === 0) return product
|
||||
|
||||
const existingSourceSkuIds = new Set<string>()
|
||||
const existingSkuCodes = new Set<string>()
|
||||
product.skuItems.forEach((item) => {
|
||||
const sourceSkuId = item.sourceSkuId?.trim()
|
||||
if (sourceSkuId) existingSourceSkuIds.add(sourceSkuId)
|
||||
|
||||
const skuCode = item.skuCode?.trim().toUpperCase()
|
||||
if (skuCode) existingSkuCodes.add(skuCode)
|
||||
})
|
||||
|
||||
const missingVariants = activeVariants.filter((variant) => {
|
||||
const variantCode = variant.code.trim().toUpperCase()
|
||||
return !existingSourceSkuIds.has(variant.id) && !existingSkuCodes.has(variantCode)
|
||||
})
|
||||
if (missingVariants.length === 0) return product
|
||||
|
||||
const appendedItems = missingVariants.map((variant, index) => {
|
||||
const skuSequence = product.skuItems.length + index + 1
|
||||
return updateAssetSeriesComponentSkuItemFromVariant(
|
||||
createEmptyAssetSeriesComponentSkuItem(variant, skuSequence),
|
||||
variant,
|
||||
skuSequence,
|
||||
)
|
||||
})
|
||||
const skuItems = [...product.skuItems, ...appendedItems]
|
||||
|
||||
return {
|
||||
...product,
|
||||
skuItems,
|
||||
skuBatchCount: getSkuBatchCountFromItems(skuItems, product.skuBatchCount),
|
||||
}
|
||||
}
|
||||
|
||||
function hasIncomingSkuItems(productInput: unknown) {
|
||||
if (!isRecord(productInput)) return false
|
||||
const product = isRecord(productInput.product) ? productInput.product : productInput
|
||||
@ -2713,7 +3076,7 @@ class IntakeNonIntegratedProductService {
|
||||
|
||||
const scopedDepartmentId = intakeNonIntegratedScopeService.getScopedDepartmentId(user)
|
||||
const scopedTeamId = intakeNonIntegratedScopeService.getScopedTeamId(user)
|
||||
const persistedProduct = deletedRecordToRestore
|
||||
const persistedProductBase = deletedRecordToRestore
|
||||
? {
|
||||
...normalizedProduct,
|
||||
id: deletedRecordToRestore.id,
|
||||
@ -2727,6 +3090,9 @@ class IntakeNonIntegratedProductService {
|
||||
createdBy: existingRecord?.createdBy ?? user.id,
|
||||
updatedBy: user.id,
|
||||
}
|
||||
const persistedProduct = await mergeMissingAssetSeriesComponentSkuItems(
|
||||
persistedProductBase,
|
||||
)
|
||||
|
||||
const shouldSyncRemovedSourceSkuDraftGroups =
|
||||
Boolean(existingRecord) && hasIncomingSkuItems(productInput)
|
||||
|
||||
@ -172,7 +172,16 @@ function normalizeWorkspaceNodes(workspace: AssemblyWorkspaceSnapshot) {
|
||||
return workspace.nodes.filter((node) => node.zone === 'main')
|
||||
}
|
||||
|
||||
function assertAllMainNodesHaveAvailableVariants(
|
||||
function shouldIncludeNodeInPreSkuSelection(node: AssemblyWorkspaceNode) {
|
||||
if (node.zone === 'main') return true
|
||||
return node.source === 'connector' || node.role === 'connector'
|
||||
}
|
||||
|
||||
function normalizePreSkuSelectionNodes(workspace: AssemblyWorkspaceSnapshot) {
|
||||
return workspace.nodes.filter(shouldIncludeNodeInPreSkuSelection)
|
||||
}
|
||||
|
||||
function assertAllSelectionNodesHaveAvailableVariants(
|
||||
nodes: AssemblyWorkspaceNode[],
|
||||
variantsById: Map<string, OpenProductAssetVariant>,
|
||||
) {
|
||||
@ -265,7 +274,7 @@ class PreSkuMaterializationService {
|
||||
const selectedVariantIds = Array.from(
|
||||
new Set(
|
||||
assemblies.flatMap((assembly) =>
|
||||
normalizeWorkspaceNodes(assembly.assemblyWorkspace).map((node) => node.variantId),
|
||||
normalizePreSkuSelectionNodes(assembly.assemblyWorkspace).map((node) => node.variantId),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -290,9 +299,10 @@ class PreSkuMaterializationService {
|
||||
}
|
||||
|
||||
const mainNodes = normalizeWorkspaceNodes(assembly.assemblyWorkspace)
|
||||
assertAllMainNodesHaveAvailableVariants(mainNodes, variantsById)
|
||||
const variantSelection = buildVariantSelection(mainNodes, variantsById)
|
||||
const bom = aggregateBom(mainNodes, variantsById)
|
||||
const selectionNodes = normalizePreSkuSelectionNodes(assembly.assemblyWorkspace)
|
||||
assertAllSelectionNodesHaveAvailableVariants(selectionNodes, variantsById)
|
||||
const variantSelection = buildVariantSelection(selectionNodes, variantsById)
|
||||
const bom = aggregateBom(selectionNodes, variantsById)
|
||||
const totalWeight = toMoney(
|
||||
bom.reduce((sum, item) => sum + (item.totalWeightKg ?? 0), 0),
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user