wip: protect local material updates before mainline sync

This commit is contained in:
WuYongJie 2026-06-03 10:04:59 +08:00
parent f563bc9989
commit 5f2033ea90
35 changed files with 9618 additions and 527 deletions

View File

@ -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 },
)
}

View File

@ -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 },
)
}

View File

@ -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') ?? '',

View 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()
}
})
})

View File

@ -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(),

View File

@ -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

View File

@ -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 () => {

View File

@ -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={[

View File

@ -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

View File

@ -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()

View File

@ -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 ? (

View File

@ -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();

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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('')
})
})

View File

@ -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"),

View File

@ -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 位大写字母或数字')
})
})

View File

@ -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([])
})
})

View File

@ -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 {

View File

@ -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('板材规格款缺少基材')

View File

@ -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,

View File

@ -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[]>
}

View File

@ -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' } },
}),
)
})
})

View File

@ -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',
}),
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -165,6 +165,7 @@ export type {
AssetFamilyDTO,
AssetReviewActionInput,
AssetSeriesDTO,
AssetSeriesComponentDetailSummaryDTO,
AssetSeriesSummaryDTO,
AssetVariantDTO,
CreateAssetVariantResultDTO,

View File

@ -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)

View File

@ -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),
)