feat(asset-center): add asset family name code

This commit is contained in:
WuYongJie 2026-06-24 10:48:51 +08:00
parent 864b07c061
commit 350936d3c3
15 changed files with 455 additions and 116 deletions

View File

@ -48,6 +48,7 @@ const assetVariantMountPointSchema = z.object({
export const assetFamilyCreateSchema = z.object({
type: z.enum(['BOARD', 'COMPONENT', 'HARDWARE', 'PACKAGING']),
code: z.string().trim().min(1).optional(),
nameCode: z.string().trim().optional().nullable(),
name: z.string().trim().min(1),
description: z.string().optional().nullable(),
productCategoryId: z.string().trim().min(1).optional().nullable(),
@ -60,6 +61,7 @@ export const assetFamilyCreateSchema = z.object({
export const assetFamilyUpdateSchema = z.object({
code: z.string().trim().min(1).optional(),
nameCode: z.string().trim().optional().nullable(),
name: z.string().trim().min(1).optional(),
description: z.string().optional().nullable(),
productCategoryId: z.string().trim().min(1).optional().nullable(),

View File

@ -96,9 +96,12 @@ import {
normalizeAssetVariantsWithUv,
} from '@/lib/open-product/asset-variant-uv'
import {
ASSET_FAMILY_NAME_CODE_METADATA_KEY,
buildAssetVariantCodeFromSpu,
buildAssetVariantBarcode,
DEFAULT_SKU_VERSION_NO,
normalizeAssetNameCode,
readAssetFamilyNameCodeFromMetadata,
normalizeSkuVersionNo,
} from '@/lib/open-product/asset-coding'
import type { OpenProductMountPoint } from '@/lib/open-product/types'
@ -1328,17 +1331,48 @@ function resolveSeriesCodeFromAssetFamily(family: AssetFamilyDTO): string {
const directSeriesCode = family.assetSeriesCode?.trim().toUpperCase()
if (directSeriesCode) return directSeriesCode
const matched = family.code.trim().toUpperCase().match(/^([A-Z][A-Z0-9]*)-SPJK\d+$/)
const matched = family.code.trim().toUpperCase().match(/^([A-Z][A-Z0-9]*)-SPJ[A-Z0-9]*K\d+$/)
return matched?.[1] ?? ''
}
function resolveComponentSpuFromAssetFamily(family: AssetFamilyDTO): string {
const normalizedCode = family.code.trim().toUpperCase()
const matched = normalizedCode.match(/^(.+)K\d+$/)
if (matched?.[1]) return matched[1]
const seriesCode = resolveSeriesCodeFromAssetFamily(family)
return seriesCode ? `${seriesCode}-SPJ` : ''
if (seriesCode) return `${seriesCode}-SPJ`
const normalizedCode = family.code.trim().toUpperCase()
const matched = normalizedCode.match(/^(.+-SPJ)[A-Z0-9]*K\d+$/)
return matched?.[1] ?? ''
}
function readAssetFamilyNameCode(family: Pick<AssetFamilyDTO, 'code' | 'metadata' | 'nameCode' | 'assetSeriesCode'>): string {
const directNameCode = normalizeAssetNameCode(family.nameCode)
if (directNameCode) return directNameCode
const metadataNameCode = readAssetFamilyNameCodeFromMetadata(family.metadata)
if (metadataNameCode) return metadataNameCode
const seriesCode = family.assetSeriesCode?.trim().toUpperCase() ||
family.code.trim().toUpperCase().match(/^([A-Z][A-Z0-9]*)-SPJ[A-Z0-9]*K\d+$/)?.[1] ||
''
const componentSpu = seriesCode ? `${seriesCode}-SPJ` : ''
if (!componentSpu) return ''
const matched = family.code.trim().toUpperCase().match(new RegExp(`^${escapeRegExp(componentSpu)}([A-Z0-9]*)K\\d+$`))
return normalizeAssetNameCode(matched?.[1])
}
function writeAssetFamilyNameCodeToMetadata(
metadata: Record<string, unknown> | null,
nameCode: string,
): Record<string, unknown> | null {
const next = { ...(metadata ?? {}) }
const normalizedNameCode = normalizeAssetNameCode(nameCode)
if (normalizedNameCode) {
next[ASSET_FAMILY_NAME_CODE_METADATA_KEY] = normalizedNameCode
} else {
delete next[ASSET_FAMILY_NAME_CODE_METADATA_KEY]
}
return Object.keys(next).length > 0 ? next : null
}
function readAssetVariantSequence(code: string, componentSpu: string): number | null {
@ -1346,7 +1380,7 @@ function readAssetVariantSequence(code: string, componentSpu: string): number |
const matched = code
.trim()
.toUpperCase()
.match(new RegExp(`^${escapeRegExp(componentSpu)}(\\d+)$`))
.match(new RegExp(`^${escapeRegExp(componentSpu)}[A-Z0-9]*?(\\d+)$`))
if (!matched) return null
const sequence = Number.parseInt(matched[1] ?? '', 10)
@ -1371,7 +1405,7 @@ function buildNextAssetSkuGroupCodeForFamily(
sequence += 1
}
return buildAssetVariantCodeFromSpu(componentSpu, sequence)
return buildAssetVariantCodeFromSpu(componentSpu, sequence, readAssetFamilyNameCode(family))
}
function buildNextAssetVariantModelCodeForSkuGroup(
@ -2062,6 +2096,7 @@ export default function AssetCenterClient({
const [familyForm, setFamilyForm] = useState({
type: 'BOARD' as AssetFamilyDTO['type'],
code: '',
nameCode: '',
name: '',
description: '',
assetSeriesId: initialAssetSeries[0]?.id ?? '',
@ -3653,6 +3688,7 @@ export default function AssetCenterClient({
setFamilyForm({
type: defaultAssetType,
code: '',
nameCode: '',
name: '',
description: '',
assetSeriesId: selectedSeriesId ?? '',
@ -3685,6 +3721,7 @@ export default function AssetCenterClient({
setFamilyForm({
type: family.type,
code: family.code,
nameCode: readAssetFamilyNameCode(family),
name: family.name,
description: family.description ?? '',
assetSeriesId,
@ -3802,6 +3839,12 @@ export default function AssetCenterClient({
return
}
const normalizedNameCode = normalizeAssetNameCode(familyForm.nameCode)
if (familyModalMode === 'create' && !normalizedNameCode) {
toast.error('请填写名称编码')
return
}
const normalizedSelectedRoles = normalizeConnectionRoles(familyForm.selectedRoles)
const submittedAssetSeriesId = familyForm.assetSeriesId || null
const submittedCompatibleSeriesIds = submittedAssetSeriesId ? [submittedAssetSeriesId] : []
@ -3813,18 +3856,22 @@ export default function AssetCenterClient({
})
.map((definition) => definition.value)
.filter((value) => !familyRoleDefinitions.some((definition) => definition.value === value))
const metadata = buildConnectionRoleMetadata(
familyRoleDefinitions,
familyModalMode === 'edit' ? editingFamily?.metadata : null,
{
hiddenValues: hiddenRecommendedRoleValues,
},
const metadata = writeAssetFamilyNameCodeToMetadata(
buildConnectionRoleMetadata(
familyRoleDefinitions,
familyModalMode === 'edit' ? editingFamily?.metadata : null,
{
hiddenValues: hiddenRecommendedRoleValues,
},
),
normalizedNameCode,
)
setSubmitting(true)
try {
if (familyModalMode === 'create') {
const created = await createAssetFamily({
type: familyForm.type,
nameCode: normalizedNameCode,
name: familyForm.name,
description: familyForm.description || null,
assetSeriesId: submittedAssetSeriesId,
@ -3842,6 +3889,7 @@ export default function AssetCenterClient({
if (!editingFamilyId) return
const updated = await updateAssetFamily(editingFamilyId, {
name: familyForm.name,
nameCode: normalizedNameCode || null,
description: familyForm.description || null,
assetSeriesId: submittedAssetSeriesId,
compatibleSeriesIds: submittedCompatibleSeriesIds,
@ -7859,7 +7907,7 @@ export default function AssetCenterClient({
})}
</div>
</div>
<div className="grid gap-3 grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<Input
label="编码"
value={familyForm.code}
@ -7873,6 +7921,15 @@ export default function AssetCenterClient({
onChange={(e) => setFamilyForm((c) => ({ ...c, name: e.target.value }))}
placeholder="例如 单板桌桌面板"
/>
<Input
label="名称编码"
value={familyForm.nameCode}
onChange={(e) => setFamilyForm((c) => ({ ...c, nameCode: normalizeAssetNameCode(e.target.value) }))}
placeholder="例如 TM"
required={familyModalMode === 'create'}
disabled={familyModalMode === 'edit'}
readOnly={familyModalMode === 'edit'}
/>
</div>
<Select
label="归属系列"

View File

@ -258,6 +258,7 @@ describe('AssetCenterClient workflow', () => {
expect(screen.getByLabelText('编码')).toBeDisabled()
await actor.type(screen.getByLabelText('名称'), '桌面板')
await actor.type(screen.getByPlaceholderText('例如 TM'), 'tm')
await actor.click(screen.getByRole('checkbox', { name: '桌面板' }))
await actor.click(screen.getByRole('button', { name: '创建' }))
@ -265,6 +266,7 @@ describe('AssetCenterClient workflow', () => {
expect(createAssetFamily).toHaveBeenCalledWith(expect.objectContaining({
type: 'BOARD',
name: '桌面板',
nameCode: 'TM',
connectionRoles: ['desktop_surface'],
}))
})
@ -438,7 +440,7 @@ describe('AssetCenterClient workflow', () => {
await actor.click(screen.getByText('桌面板资产族'))
await actor.click(screen.getByRole('button', { name: '新增模型' }))
expect(screen.getByRole('heading', { name: '新建规格款' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '新增模型' })).toBeInTheDocument()
expect(screen.getByText('ModelUploader')).toBeInTheDocument()
})

View File

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import PackagingMaterialsClient from './PackagingMaterialsClient'
import { PACKAGING_PLACEHOLDER_MODEL_URL } from '@/lib/packaging-materials/client-helpers'
import type { AssetFamilyDTO, AssetVariantDTO } from '@/server/services'
import type { AssetFamilyDTO, AssetSeriesDTO, AssetVariantDTO } from '@/server/services'
jest.mock('next/link', () => ({
__esModule: true,
@ -97,6 +97,9 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
assetSeriesId: 'template-1',
assetSeriesCode: 'DBZ',
assetSeriesName: '单板桌',
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: {
@ -126,6 +129,22 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
}
}
const assetSeries = [
{
id: 'template-1',
code: 'DBZ',
name: '单板桌',
displayName: '单板桌 DBZ',
description: null,
componentSpu: null,
finishedProductSpu: null,
sortOrder: 1,
isActive: true,
createdAt: new Date('2026-04-02T10:00:00.000Z'),
updatedAt: new Date('2026-04-02T10:00:00.000Z'),
},
] as AssetSeriesDTO[]
function createVariant(overrides?: Partial<AssetVariantDTO>): AssetVariantDTO {
return {
id: 'variant-1',
@ -271,16 +290,17 @@ describe('PackagingMaterialsClient', () => {
expect(screen.getByRole('button', { name: /外包装类/ })).toBeInTheDocument()
})
it('新建二级分类时不再要求手填编码,而是自动生成 family code', async () => {
it('新建二级分类时按归属系列和名称编码自动生成 family code', async () => {
const actor = userEvent.setup()
createAssetFamily.mockResolvedValue(
createFamily({
id: 'family-business-1',
code: 'PACK-OUTER-001',
code: 'DBZ-SPJZXK01',
name: '大桌外箱',
metadata: {
categoryLevel1: 'outer',
assetNameCode: 'ZX',
},
}),
)
@ -289,6 +309,7 @@ describe('PackagingMaterialsClient', () => {
<PackagingMaterialsClient
initialFamilies={[createFamily()]}
initialVariants={[]}
initialAssetSeries={assetSeries}
user={user}
/>,
)
@ -299,16 +320,19 @@ describe('PackagingMaterialsClient', () => {
expect(within(dialog).queryByLabelText('品类编码')).not.toBeInTheDocument()
await actor.type(within(dialog).getByLabelText('二级分类名称'), '大桌外箱')
await actor.type(within(dialog).getByLabelText('名称编码'), 'zx')
await actor.click(within(dialog).getByRole('button', { name: '创建二级分类' }))
await waitFor(() => expect(createAssetFamily).toHaveBeenCalledTimes(1))
expect(createAssetFamily).toHaveBeenCalledWith(
expect.objectContaining({
code: expect.stringMatching(/^PACK-/),
assetSeriesId: 'template-1',
nameCode: 'ZX',
name: '大桌外箱',
metadata: {
categoryLevel1: 'outer',
assetNameCode: 'ZX',
},
}),
)
@ -345,13 +369,13 @@ describe('PackagingMaterialsClient', () => {
expect(createAssetVariant).toHaveBeenCalledWith(
expect.objectContaining({
assetFamilyId: 'family-carton',
code: '纸箱-ZX-001',
name: '桌面纸箱',
status: 'DRAFT',
modelUrl: PACKAGING_PLACEHOLDER_MODEL_URL,
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
}),
)
expect(createAssetVariant.mock.calls[0][0].code).toBeUndefined()
})
it('编辑包材切换二级分类后,保存会带上新的 assetFamilyId', async () => {

View File

@ -43,14 +43,16 @@ import {
} from '@/lib/packaging-materials/catalog'
import {
buildPackagingVariantCreateInput,
generateFamilyCode,
generatePackagingMaterialCode,
getSelectionAfterFamilyCreate,
getSelectionForFamilyId,
normalizePackagingVariantForCatalog,
readPackagingTemplateType,
type PackagingMaterialDraftInput,
} from '@/lib/packaging-materials/client-helpers'
import {
ASSET_FAMILY_NAME_CODE_METADATA_KEY,
normalizeAssetNameCode,
} from '@/lib/open-product/asset-coding'
import {
computePackagingMaterialCost,
validatePackagingMaterialDraft,
@ -73,10 +75,11 @@ import {
deleteAssetVariant,
updateAssetVariant,
} from '@/lib/open-product/platform-api'
import type { AssetFamilyDTO, AssetVariantDTO } from '@/server/services'
import type { AssetFamilyDTO, AssetSeriesDTO, AssetVariantDTO } from '@/server/services'
interface PackagingMaterialsClientProps {
initialFamilies: AssetFamilyDTO[]
initialVariants: AssetVariantDTO[]
initialAssetSeries?: AssetSeriesDTO[]
user: {
id: string
name: string
@ -427,6 +430,7 @@ function DetailItem({ label, value }: { label: string; value: string }) {
export default function PackagingMaterialsClient({
initialFamilies,
initialVariants,
initialAssetSeries = [],
user,
}: PackagingMaterialsClientProps) {
const toast = useToast()
@ -450,10 +454,11 @@ export default function PackagingMaterialsClient({
const [familyModalOpen, setFamilyModalOpen] = useState(false)
const [familyModalLevel1, setFamilyModalLevel1] = useState<Level1CategoryKey>('outer')
const [familyModalName, setFamilyModalName] = useState('')
const [familyModalNameCode, setFamilyModalNameCode] = useState('')
const [familyModalSeriesId, setFamilyModalSeriesId] = useState(initialAssetSeries[0]?.id ?? '')
const [familyError, setFamilyError] = useState<string | null>(null)
const [createDrawerOpen, setCreateDrawerOpen] = useState(false)
const [createDraft, setCreateDraft] = useState<MaterialDraft | null>(null)
const [createCodeTouched, setCreateCodeTouched] = useState(false)
const [createFoamNameTouched, setCreateFoamNameTouched] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [detailMode, setDetailMode] = useState<DetailMode>('preview')
@ -737,7 +742,6 @@ export default function PackagingMaterialsClient({
function closeCreateDrawer() {
setCreateDrawerOpen(false)
setCreateDraft(null)
setCreateCodeTouched(false)
setCreateFoamNameTouched(false)
setCreateError(null)
}
@ -766,12 +770,7 @@ export default function PackagingMaterialsClient({
draft.id = ''
draft.name = `${draft.name} 副本`
draft.status = 'DRAFT'
draft.code = draft.templateType
? generatePackagingMaterialCode({
templateType: draft.templateType,
variants,
})
: ''
draft.code = ''
if (draft.templateType === 'carton') {
draft.costPrice = ''
draft = applyCartonSuggestionToDraft(draft)
@ -787,7 +786,6 @@ export default function PackagingMaterialsClient({
}
}
setCreateDraft(draft)
setCreateCodeTouched(false)
setCreateFoamNameTouched(draft.templateType === 'foamVolume' && !!sourceVariant)
setCreateError(null)
setCreateDrawerOpen(true)
@ -801,18 +799,26 @@ export default function PackagingMaterialsClient({
setFamilyError('二级分类名称不能为空')
return
}
const normalizedNameCode = normalizeAssetNameCode(familyModalNameCode)
if (!normalizedNameCode) {
setFamilyError('名称编码不能为空')
return
}
if (!familyModalSeriesId) {
setFamilyError('请选择归属系列')
return
}
setSaving(true)
setFamilyError(null)
try {
const createdFamily = await createAssetFamily({
type: 'PACKAGING',
code: generateFamilyCode({
level1: familyModalLevel1,
existingFamilies: families,
}),
nameCode: normalizedNameCode,
name: familyModalName.trim(),
assetSeriesId: familyModalSeriesId,
metadata: {
categoryLevel1: familyModalLevel1,
[ASSET_FAMILY_NAME_CODE_METADATA_KEY]: normalizedNameCode,
},
})
const nextSelection = getSelectionAfterFamilyCreate(createdFamily, selectedLevel1)
@ -824,6 +830,7 @@ export default function PackagingMaterialsClient({
setEditDraft(null)
setFamilyModalOpen(false)
setFamilyModalName('')
setFamilyModalNameCode('')
toast.success('二级分类已创建')
} catch (error) {
const message = error instanceof Error ? error.message : '新建二级分类失败'
@ -861,6 +868,7 @@ export default function PackagingMaterialsClient({
},
templateFields: draftToCreate.templateFields,
costPrice: draftToCreate.costPrice,
requireCode: false,
})
if (validation.errors.length > 0) {
setCreateError(validation.errors[0])
@ -1538,6 +1546,8 @@ export default function PackagingMaterialsClient({
onClick={() => {
setFamilyModalLevel1(selectedLevel1)
setFamilyModalName('')
setFamilyModalNameCode('')
setFamilyModalSeriesId(initialAssetSeries[0]?.id ?? '')
setFamilyError(null)
setFamilyModalOpen(true)
}}
@ -3306,6 +3316,19 @@ export default function PackagingMaterialsClient({
}))}
onChange={(event) => setFamilyModalLevel1(event.target.value as Level1CategoryKey)}
/>
<Select
label="归属系列"
value={familyModalSeriesId}
searchable
searchPlaceholder="搜索系列名称或编码"
emptyText="没有匹配的系列"
options={initialAssetSeries.map((series) => ({
value: series.id,
label: `${series.name} · ${series.code}`,
}))}
placeholder={initialAssetSeries.length === 0 ? '暂无可用资产系列' : '请选择归属系列'}
onChange={(event) => setFamilyModalSeriesId(event.target.value)}
/>
<Input
label="二级分类名称"
aria-label="二级分类名称"
@ -3313,8 +3336,16 @@ export default function PackagingMaterialsClient({
onChange={(event) => setFamilyModalName(event.target.value)}
placeholder="例如:大件外箱 / 顶部泡沫 / 五金附件袋"
/>
<Input
label="名称编码"
aria-label="名称编码"
value={familyModalNameCode}
onChange={(event) => setFamilyModalNameCode(normalizeAssetNameCode(event.target.value))}
placeholder="例如 ZX / PM / JD"
required
/>
<div className="rounded-apple-lg border border-apple-separator px-4 py-3 text-sm text-apple-label-secondary bg-apple-bg-secondary">
编码会根据一级分类自动生成,不需要手填。
编码会按归属系列和名称编码自动生成,例如 系列-SPJZHK01。
</div>
</div>
</Modal>
@ -3371,15 +3402,7 @@ export default function PackagingMaterialsClient({
level1,
assetFamilyId: firstFamilyId,
templateType: currentTemplateMatches ? previous.templateType : '',
code:
currentTemplateMatches && !createCodeTouched && previous.templateType
? generatePackagingMaterialCode({
templateType: previous.templateType,
variants,
})
: currentTemplateMatches
? previous.code
: '',
code: currentTemplateMatches ? previous.code : '',
}
})
}}
@ -3413,15 +3436,7 @@ export default function PackagingMaterialsClient({
const nextDraft: MaterialDraft = {
...previous,
templateType,
code:
templateType && !createCodeTouched
? generatePackagingMaterialCode({
templateType,
variants,
})
: templateType
? previous.code
: '',
code: templateType ? previous.code : '',
}
if (templateType === 'foamVolume') {
return maybeApplyCreateFoamDefaultName(applyFoamDefaultsToDraft(nextDraft))
@ -3459,13 +3474,10 @@ export default function PackagingMaterialsClient({
label="包材编码"
aria-label="包材编码"
value={createDraft.code}
onChange={(event) => {
setCreateCodeTouched(true)
setCreateDraft((previous) =>
previous ? { ...previous, code: event.target.value } : previous,
)
}}
helperText="系统会先自动带出编码,你也可以手动改。"
readOnly
disabled
placeholder="保存后自动生成"
helperText="由所选二级分类的系列和名称编码自动生成。"
/>
{createDraftIsGenericLight ? (
<section className="rounded-apple-lg border border-apple-separator p-4 bg-apple-bg-secondary">

View File

@ -69,6 +69,9 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
description: null,
productCategoryId: null,
productCategoryName: null,
assetSeriesId: 'template-1',
assetSeriesCode: 'DBZ',
assetSeriesName: '单板桌',
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
connectionRoles: [],
metadata: {
@ -179,7 +182,7 @@ describe('PackagingMaterialsClient workflow', () => {
createAssetVariant.mockReset()
})
it('新增包材时打开极简建档抽屉,只显示最少字段并自动带出编码', async () => {
it('新增包材时打开极简建档抽屉,只显示最少字段并等待后端生成编码', async () => {
const actor = userEvent.setup()
render(
@ -202,7 +205,7 @@ describe('PackagingMaterialsClient workflow', () => {
await chooseOption(actor, '模板类型', '纸箱')
expect(within(drawer).getByLabelText('包材编码')).toHaveValue('纸箱-ZX-001')
expect(within(drawer).getByLabelText('包材编码')).toHaveValue('')
})
it('新建包材成功后会自动选中记录,并在列表下方展开详情区', async () => {
@ -341,7 +344,7 @@ describe('PackagingMaterialsClient workflow', () => {
createAssetVariant.mockResolvedValue(
createVariant({
id: 'variant-copy-created',
code: '纸箱-ZX-002',
code: 'DBZ-SPJZX02',
name: '桌面纸箱 副本',
}),
)
@ -377,7 +380,7 @@ describe('PackagingMaterialsClient workflow', () => {
const drawer = screen.getByRole('dialog', { name: '新建包材' })
expect(within(drawer).getByLabelText('包材名称')).toHaveValue('桌面纸箱 副本')
expect(within(drawer).getByLabelText('包材编码')).toHaveValue('纸箱-ZX-002')
expect(within(drawer).getByLabelText('包材编码')).toHaveValue('')
await actor.click(within(drawer).getByRole('button', { name: '创建包材' }))

View File

@ -5,7 +5,10 @@ import PackagingMaterialsClient from './PackagingMaterialsClient'
export default async function PackagingMaterialsPage() {
const { user } = await requireSession('/login')
const allFamilies = await assetCenterService.listFamilies()
const [allFamilies, assetSeries] = await Promise.all([
assetCenterService.listFamilies(),
assetCenterService.listSeries(),
])
const allVariants = await assetCenterService.listVariantsByFamilyIds(
allFamilies.map((f) => f.id),
)
@ -17,6 +20,7 @@ export default async function PackagingMaterialsPage() {
<PackagingMaterialsClient
initialFamilies={families}
initialVariants={variants}
initialAssetSeries={assetSeries}
user={user}
/>
)

View File

@ -5,6 +5,7 @@ import {
buildAssetVariantCode,
buildAssetVariantCodeFromSpu,
buildSeriesCodeFromName,
normalizeAssetNameCode,
normalizeSpuCode,
normalizeSkuVersionNo,
validateAssetFamilyCode,
@ -32,6 +33,11 @@ describe('asset-coding', () => {
expect(variantCode).toBe('DBZ-SPJ01')
expect(validateAssetFamilyCode(familyCode)).toBeNull()
expect(validateAssetVariantCode(variantCode)).toBeNull()
expect(buildAssetFamilyCode('DBZ', 1, 'tm')).toBe('DBZ-SPJTMK01')
expect(buildAssetVariantCode('DBZ', 1, 'tm')).toBe('DBZ-SPJTM01')
expect(validateAssetFamilyCode('DBZ-SPJTMK01')).toBeNull()
expect(validateAssetVariantCode('DBZ-SPJTM01')).toBeNull()
expect(normalizeAssetNameCode(' tm-01 ')).toBe('TM01')
expect(validateAssetFamilyCode('BOARD-TOP')).toBe('资产组型号格式应为 系列编码-SPJK01')
expect(validateAssetVariantCode('BOARD-TOP-1200')).toBe('规格款货号格式应为 系列编码-SPJ01')
expect(normalizeSkuVersionNo(null)).toBe('V01')
@ -42,6 +48,8 @@ describe('asset-coding', () => {
expect(normalizeSpuCode(' dnz0384 ')).toBe('DNZ0384')
expect(buildAssetFamilyCodeFromSpu('DNZ0384', 1)).toBe('DNZ0384K01')
expect(buildAssetVariantCodeFromSpu('DNZ0384', 2)).toBe('DNZ038402')
expect(buildAssetFamilyCodeFromSpu('DNZ0384', 1, 'zt')).toBe('DNZ0384ZTK01')
expect(buildAssetVariantCodeFromSpu('DNZ0384', 2, 'zt')).toBe('DNZ0384ZT02')
})
it('按规格款编码、供应商编码和版本号生成条码,并校验供应商编码', () => {

View File

@ -2,10 +2,11 @@ import { pinyin } from 'pinyin-pro'
export const ASSET_SPU_SEGMENT = 'SPJ'
export const DEFAULT_SKU_VERSION_NO = 'V01'
export const ASSET_FAMILY_NAME_CODE_METADATA_KEY = 'assetNameCode'
const SERIES_CODE_PATTERN = /^[A-Z][A-Z0-9]{1,15}$/
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 FAMILY_CODE_PATTERN = /^[A-Z][A-Z0-9]*-SPJ[A-Z0-9]*K\d{2,}$/
const VARIANT_CODE_PATTERN = /^[A-Z][A-Z0-9]*-SPJ[A-Z0-9]*\d{2,}$/
const SUPPLIER_CODE_PATTERN = /^[A-Z0-9]{2,12}$/
function trimUpper(value: string | null | undefined): string {
@ -20,6 +21,17 @@ export function normalizeSeriesCode(value: string | null | undefined): string {
return trimUpper(value).replace(/[^A-Z0-9]/g, '')
}
export function normalizeAssetNameCode(value: string | null | undefined): string {
return trimUpper(value).replace(/[^A-Z0-9]/g, '')
}
export function readAssetFamilyNameCodeFromMetadata(
metadata: Record<string, unknown> | null | undefined,
): string {
const rawValue = metadata?.[ASSET_FAMILY_NAME_CODE_METADATA_KEY]
return typeof rawValue === 'string' ? normalizeAssetNameCode(rawValue) : ''
}
export function buildSeriesCodeFromName(name: string): string {
const normalizedName = name.trim()
if (!normalizedName) return ''
@ -51,20 +63,36 @@ export function normalizeSpuCode(value: string | null | undefined): string {
return trimUpper(value).replace(/\s+/g, '')
}
export function buildAssetFamilyCodeFromSpu(spu: string, sequence: number): string {
return `${normalizeSpuCode(spu)}K${String(sequence).padStart(2, '0')}`
export function buildAssetFamilyCodeFromSpu(
spu: string,
sequence: number,
nameCode?: string | null,
): string {
return `${normalizeSpuCode(spu)}${normalizeAssetNameCode(nameCode)}K${String(sequence).padStart(2, '0')}`
}
export function buildAssetFamilyCode(seriesCode: string, sequence: number): string {
return buildAssetFamilyCodeFromSpu(buildSpuCode(seriesCode), sequence)
export function buildAssetFamilyCode(
seriesCode: string,
sequence: number,
nameCode?: string | null,
): string {
return buildAssetFamilyCodeFromSpu(buildSpuCode(seriesCode), sequence, nameCode)
}
export function buildAssetVariantCodeFromSpu(spu: string, sequence: number): string {
return `${normalizeSpuCode(spu)}${String(sequence).padStart(2, '0')}`
export function buildAssetVariantCodeFromSpu(
spu: string,
sequence: number,
nameCode?: string | null,
): string {
return `${normalizeSpuCode(spu)}${normalizeAssetNameCode(nameCode)}${String(sequence).padStart(2, '0')}`
}
export function buildAssetVariantCode(seriesCode: string, sequence: number): string {
return buildAssetVariantCodeFromSpu(buildSpuCode(seriesCode), sequence)
export function buildAssetVariantCode(
seriesCode: string,
sequence: number,
nameCode?: string | null,
): string {
return buildAssetVariantCodeFromSpu(buildSpuCode(seriesCode), sequence, nameCode)
}
export function normalizeSkuVersionNo(value: string | null | undefined): string {

View File

@ -184,7 +184,7 @@ export function buildPackagingVariantCreateInput(
return {
assetFamilyId: draft.assetFamilyId,
code: draft.code.trim(),
code: draft.code.trim() || undefined,
name: draft.name.trim(),
status: 'DRAFT',
sizeLabel: draft.sizeLabel.trim() || undefined,

View File

@ -95,13 +95,14 @@ export function validatePackagingMaterialDraft(args: {
dimensions: Record<string, string>
templateFields: Record<string, string>
costPrice: string
requireCode?: boolean
}): { errors: string[]; computedCost: number | null } {
const errors: string[] = []
const computedCost = args.templateType
? computePackagingMaterialCost(args.templateType, args.templateFields, args.dimensions)
: null
if (!args.code.trim()) {
if (args.requireCode !== false && !args.code.trim()) {
errors.push('包材编码不能为空')
}

View File

@ -95,6 +95,7 @@ export interface AssetSkuGroupWithRelations extends LegacyAssetSkuGroupSnapshot
id: string
code: string
name: string
metadata: Prisma.JsonValue | null
type: AssetFamily['type']
assetSeriesId: string | null
deletedAt: Date | null
@ -108,6 +109,7 @@ export interface AssetVariantWithRelations extends AssetVariant {
id: string
code: string
name: string
metadata: Prisma.JsonValue | null
type: AssetFamily['type']
assetSeriesId: string | null
deletedAt: Date | null
@ -240,6 +242,7 @@ export const assetVariantRelationInclude = {
id: true,
code: true,
name: true,
metadata: true,
type: true,
assetSeriesId: true,
deletedAt: true,
@ -503,6 +506,7 @@ class AssetSkuGroupRepositoryClass {
id: input.family.id,
code: input.family.code,
name: input.family.name,
metadata: input.family.metadata,
type: input.family.type,
assetSeriesId: input.family.assetSeriesId,
deletedAt: input.family.deletedAt,

View File

@ -144,6 +144,9 @@ jest.mock('@/lib/storage', () => {
describe('assetCenterService', () => {
const mockedPrisma = prisma as unknown as {
$transaction: jest.Mock
assetFamily: {
findMany: jest.Mock
}
assetSkuGroup: {
aggregate: jest.Mock
}
@ -269,6 +272,7 @@ describe('assetCenterService', () => {
mockedPrisma.assetSkuGroup.aggregate.mockResolvedValue({
_max: { sortOrder: 0 },
})
mockedPrisma.assetFamily.findMany.mockResolvedValue([])
mockedAssetSkuGroupRepository.findByIdWithRelations.mockResolvedValue(null as never)
mockedAssetSkuGroupRepository.findByFamilyAndCode.mockResolvedValue(null as never)
@ -760,6 +764,34 @@ describe('assetCenterService', () => {
)
})
it('uses name code when auto-generating asset family codes', async () => {
mockedAssetFamilyRepository.createWithRelations.mockResolvedValueOnce({
...defaultBoardFamily,
code: 'DBZ-SPJTMK01',
metadata: {
assetNameCode: 'TM',
},
} as never)
const family = await assetCenterService.createFamily({
type: 'BOARD',
assetSeriesId: defaultAssetSeries.id,
nameCode: 'tm',
name: '桌面板',
status: 'DRAFT',
})
expect(family.code).toBe('DBZ-SPJTMK01')
expect(mockedAssetFamilyRepository.createWithRelations).toHaveBeenCalledWith(
expect.objectContaining({
code: 'DBZ-SPJTMK01',
metadata: expect.objectContaining({
assetNameCode: 'TM',
}),
}),
)
})
it('new asset variants use sku groups for item numbers and model codes for model details', async () => {
const existingGroups = [
{
@ -791,6 +823,7 @@ describe('assetCenterService', () => {
]
mockedAssetSkuGroupRepository.findManyByFamilyIds.mockResolvedValue(existingGroups as never)
mockedAssetSkuGroupRepository.findManyBySeriesCode.mockResolvedValue(existingGroups as never)
mockedAssetSkuGroupRepository.findByFamilyAndCode.mockImplementation((assetFamilyId, code) =>
Promise.resolve(
existingGroups.find((group) => group.assetFamilyId === assetFamilyId && group.code === code) ?? null,

View File

@ -18,6 +18,7 @@ import {
} from '@/lib/open-product/asset-validation'
import { buildAssetVariantIdentityKey } from '@/lib/open-product/asset-identity'
import {
ASSET_FAMILY_NAME_CODE_METADATA_KEY,
buildAssetFamilyCodeFromSpu,
buildAssetVariantBarcode,
buildAssetVariantCodeFromSpu,
@ -25,8 +26,10 @@ import {
buildSeriesDisplayName,
DEFAULT_SKU_VERSION_NO,
normalizeSeriesCode,
normalizeAssetNameCode,
normalizeCoreIdentity,
normalizeSpuCode,
readAssetFamilyNameCodeFromMetadata,
normalizeSkuVersionNo,
normalizeSupplierCode,
validateSeriesCode,
@ -446,28 +449,88 @@ function buildAssetSeriesMetadataForUpdate(
return metadata
}
function parseAssetFamilySequenceFromComponentSpu(componentSpu: string, code: string): number | null {
function parseAssetFamilyCodePartsFromComponentSpu(
componentSpu: string,
code: string,
): { nameCode: string; sequence: number } | null {
const prefix = normalizeSpuCode(componentSpu)
const normalizedCode = code.trim().toUpperCase()
if (!prefix || !normalizedCode) return null
const match = normalizedCode.match(new RegExp(`^${escapeRegExp(prefix)}K(\\d+)$`))
const match = normalizedCode.match(new RegExp(`^${escapeRegExp(prefix)}([A-Z0-9]*)K(\\d+)$`))
if (!match) return null
const sequence = Number.parseInt(match[1] ?? '', 10)
return Number.isFinite(sequence) && sequence > 0 ? sequence : null
const sequence = Number.parseInt(match[2] ?? '', 10)
return Number.isFinite(sequence) && sequence > 0
? { nameCode: normalizeAssetNameCode(match[1]), sequence }
: null
}
function parseAssetVariantSequenceFromComponentSpu(componentSpu: string, code: string): number | null {
function parseAssetFamilySequenceFromComponentSpu(componentSpu: string, code: string): number | null {
return parseAssetFamilyCodePartsFromComponentSpu(componentSpu, code)?.sequence ?? null
}
function parseAssetVariantCodePartsFromComponentSpu(
componentSpu: string,
code: string,
expectedNameCode?: string | null,
): { nameCode: string; sequence: number } | null {
const prefix = normalizeSpuCode(componentSpu)
const normalizedCode = code.trim().toUpperCase()
if (!prefix || !normalizedCode) return null
const match = normalizedCode.match(new RegExp(`^${escapeRegExp(prefix)}(\\d+)$`))
const normalizedNameCode = normalizeAssetNameCode(expectedNameCode)
const pattern = normalizedNameCode
? `^${escapeRegExp(prefix)}${escapeRegExp(normalizedNameCode)}(\\d+)$`
: `^${escapeRegExp(prefix)}([A-Z0-9]*?)(\\d+)$`
const match = normalizedCode.match(new RegExp(pattern))
if (!match) return null
const sequence = Number.parseInt(match[1] ?? '', 10)
return Number.isFinite(sequence) && sequence > 0 ? sequence : null
const sequenceIndex = normalizedNameCode ? 1 : 2
const sequence = Number.parseInt(match[sequenceIndex] ?? '', 10)
const nameCode = normalizedNameCode || normalizeAssetNameCode(match[1])
return Number.isFinite(sequence) && sequence > 0 ? { nameCode, sequence } : null
}
function parseAssetVariantSequenceFromComponentSpu(
componentSpu: string,
code: string,
expectedNameCode?: string | null,
): number | null {
return parseAssetVariantCodePartsFromComponentSpu(componentSpu, code, expectedNameCode)?.sequence ?? null
}
function readAssetFamilyNameCodeFromSource(input: {
metadata?: Prisma.JsonValue | Record<string, unknown> | null
code?: string | null
componentSpu?: string | null
}): string {
const metadataCode = readAssetFamilyNameCodeFromMetadata(normalizeJsonRecord(input.metadata))
if (metadataCode) return metadataCode
const componentSpu = normalizeSpuCode(input.componentSpu)
if (componentSpu && input.code) {
return parseAssetFamilyCodePartsFromComponentSpu(componentSpu, input.code)?.nameCode ?? ''
}
return ''
}
function buildAssetFamilyMetadataWithNameCode(
metadata: Prisma.JsonValue | Record<string, unknown> | null | undefined,
nameCode: string | null | undefined,
): Record<string, unknown> | null | undefined {
if (nameCode === undefined) {
return normalizeJsonRecord(metadata)
}
const next = normalizeJsonRecord(metadata) ?? {}
const normalizedNameCode = normalizeAssetNameCode(nameCode)
if (normalizedNameCode) {
next[ASSET_FAMILY_NAME_CODE_METADATA_KEY] = normalizedNameCode
} else {
delete next[ASSET_FAMILY_NAME_CODE_METADATA_KEY]
}
return Object.keys(next).length > 0 ? next : null
}
function uniqueComponentSpuCandidates(...values: Array<string | null | undefined>): string[] {
@ -1939,6 +2002,11 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO {
id: record.id,
type: record.type,
code: record.code,
nameCode: readAssetFamilyNameCodeFromSource({
metadata: record.metadata,
code: record.code,
componentSpu: record.assetSeries ? resolveSeriesComponentSpu(record.assetSeries) : null,
}) || null,
name: record.name,
description: record.description,
productCategoryId: record.productCategoryId,
@ -2814,12 +2882,16 @@ class AssetCenterService {
return candidate
}
private async generateNextFamilyCode(series: AssetSeriesCodingSource): Promise<string> {
private async generateNextFamilyCode(
series: AssetSeriesCodingSource,
nameCode?: string | null,
): Promise<string> {
const normalizedSeriesCode = series.code.trim().toUpperCase()
const componentSpu = resolveSeriesComponentSpu(series)
const normalizedNameCode = normalizeAssetNameCode(nameCode)
const existingCodes = await prisma.assetFamily.findMany({
where: {
code: { startsWith: `${componentSpu}K` },
code: { startsWith: componentSpu },
deletedAt: null,
assetSeries: { is: { code: normalizedSeriesCode } },
},
@ -2832,16 +2904,16 @@ class AssetCenterService {
)
let sequence = 1
let candidate = buildAssetFamilyCodeFromSpu(componentSpu, sequence)
let candidate = buildAssetFamilyCodeFromSpu(componentSpu, sequence, normalizedNameCode)
while (usedSequences.has(sequence)) {
sequence += 1
candidate = buildAssetFamilyCodeFromSpu(componentSpu, sequence)
candidate = buildAssetFamilyCodeFromSpu(componentSpu, sequence, normalizedNameCode)
}
await this.releaseDeletedFamilyCode(candidate)
while (await assetFamilyRepository.findByCode(candidate)) {
sequence += 1
candidate = buildAssetFamilyCodeFromSpu(componentSpu, sequence)
candidate = buildAssetFamilyCodeFromSpu(componentSpu, sequence, normalizedNameCode)
if (usedSequences.has(sequence)) continue
await this.releaseDeletedFamilyCode(candidate)
}
@ -2898,29 +2970,37 @@ class AssetCenterService {
const activeFamilies = await prisma.assetFamily.findMany({
where: {
code: { startsWith: `${componentSpu}K` },
code: { startsWith: componentSpu },
deletedAt: null,
assetSeries: { is: { code: normalizedSeriesCode } },
},
select: {
id: true,
code: true,
metadata: true,
},
})
const sequencedFamilies = activeFamilies
.map((family) => ({
...family,
sequence: parseAssetFamilySequenceFromComponentSpu(componentSpu, family.code),
}))
.filter((family): family is { id: string; code: string; sequence: number } =>
family.sequence !== null,
)
.flatMap((family) => {
const sequence = parseAssetFamilySequenceFromComponentSpu(componentSpu, family.code)
if (sequence === null) return []
return [{
id: family.id,
code: family.code,
sequence,
nameCode: readAssetFamilyNameCodeFromSource({
metadata: family.metadata,
code: family.code,
componentSpu,
}),
}]
})
.sort((left, right) => left.sequence - right.sequence)
const updatedVariants: AssetVariantDTO[] = []
let nextSequence = 1
for (const family of sequencedFamilies) {
const nextCode = buildAssetFamilyCodeFromSpu(componentSpu, nextSequence)
const nextCode = buildAssetFamilyCodeFromSpu(componentSpu, nextSequence, family.nameCode)
nextSequence += 1
if (family.code === nextCode) continue
@ -2973,27 +3053,42 @@ class AssetCenterService {
private async generateNextSkuGroupCodeForFamily(family: {
id: string
code?: string | null
metadata?: Prisma.JsonValue | Record<string, unknown> | null
assetSeries: AssetSeriesCodingSource | null
}): Promise<string> {
if (!family.assetSeries) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '资产族缺少系列归属,不能生成货号')
}
const componentSpu = resolveSeriesComponentSpu(family.assetSeries)
const existingGroups = await assetSkuGroupRepository.findManyByFamilyIds([family.id])
const nameCode = readAssetFamilyNameCodeFromSource({
metadata: family.metadata,
code: family.code,
componentSpu,
})
const existingGroups = await assetSkuGroupRepository.findManyBySeriesCode(family.assetSeries.code)
const usedSequences = new Set<number>()
existingGroups.forEach((group) => {
const sequence = parseAssetVariantSequenceFromComponentSpu(componentSpu, group.code)
const sequence = parseAssetVariantSequenceFromComponentSpu(
componentSpu,
group.code,
readAssetFamilyNameCodeFromSource({
metadata: group.assetFamily.metadata,
code: group.assetFamily.code,
componentSpu,
}),
)
if (sequence !== null) usedSequences.add(sequence)
})
let sequence = 1
let candidate = buildAssetVariantCodeFromSpu(componentSpu, sequence)
let candidate = buildAssetVariantCodeFromSpu(componentSpu, sequence, nameCode)
while (
usedSequences.has(sequence) ||
await assetSkuGroupRepository.findByFamilyAndCode(family.id, candidate)
) {
sequence += 1
candidate = buildAssetVariantCodeFromSpu(componentSpu, sequence)
candidate = buildAssetVariantCodeFromSpu(componentSpu, sequence, nameCode)
}
return candidate
}
@ -3065,6 +3160,7 @@ class AssetCenterService {
select: {
id: true,
code: true,
metadata: true,
createdAt: true,
},
orderBy: [{ code: 'asc' }, { createdAt: 'asc' }],
@ -3088,10 +3184,17 @@ class AssetCenterService {
entry.preferredSequence,
usedFamilySequences,
)
const nameCode = readAssetFamilyNameCodeFromSource({
metadata: entry.family.metadata,
code: entry.family.code,
componentSpu: componentSpuCandidates.find((item) =>
parseAssetFamilyCodePartsFromComponentSpu(item, entry.family.code),
) ?? nextComponentSpu,
})
return {
id: entry.family.id,
oldCode: entry.family.code,
nextCode: buildAssetFamilyCodeFromSpu(nextComponentSpu, sequence),
nextCode: buildAssetFamilyCodeFromSpu(nextComponentSpu, sequence, nameCode),
}
})
const familyPlanById = new Map(familyPlans.map((plan) => [plan.id, plan]))
@ -3129,10 +3232,15 @@ class AssetCenterService {
usedVariantSequences,
)
const familyPlan = familyPlanById.get(entry.variant.assetFamilyId)
const nameCode = readAssetFamilyNameCodeFromSource({
metadata: entry.variant.assetFamily.metadata,
code: familyPlan?.nextCode ?? entry.variant.assetFamily.code,
componentSpu: nextComponentSpu,
})
return {
id: entry.variant.id,
oldCode: entry.variant.code,
nextCode: buildAssetVariantCodeFromSpu(nextComponentSpu, sequence),
nextCode: buildAssetVariantCodeFromSpu(nextComponentSpu, sequence, nameCode),
oldFamilyCode: entry.variant.assetFamily.code,
nextFamilyCode: familyPlan?.nextCode ?? entry.variant.assetFamily.code,
variant: entry.variant,
@ -3957,7 +4065,13 @@ class AssetCenterService {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择资产系列')
}
const code = input.code?.trim().toUpperCase() || await this.generateNextFamilyCode(series)
const requestedCode = input.code?.trim().toUpperCase() ?? ''
const nameCode = normalizeAssetNameCode(input.nameCode)
if (!requestedCode && !nameCode) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请填写名称编码')
}
const code = requestedCode || await this.generateNextFamilyCode(series, nameCode)
const name = input.name.trim()
if (!code || !name) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '资产族编码和名称不能为空')
@ -3983,7 +4097,10 @@ class AssetCenterService {
}
: undefined,
connectionRoles: input.connectionRoles ?? [],
metadata: toJsonValue(input.metadata),
metadata: toJsonValue(buildAssetFamilyMetadataWithNameCode(
input.metadata,
nameCode || (input.nameCode === undefined ? undefined : ''),
)),
status: creationWorkflow.status,
reviewedAt: creationWorkflow.reviewedAt,
reviewer: creationWorkflow.reviewer,
@ -4012,12 +4129,21 @@ class AssetCenterService {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择资产系列')
}
const nextComponentSpu = resolveSeriesComponentSpu(nextSeries)
const existingNameCode = readAssetFamilyNameCodeFromSource({
metadata: existing.metadata,
code: existing.code,
componentSpu: nextComponentSpu,
})
const nextNameCode = input.nameCode !== undefined
? normalizeAssetNameCode(input.nameCode)
: existingNameCode
const shouldAutoCode =
input.code === undefined &&
(!existing.code.startsWith(`${resolveSeriesComponentSpu(nextSeries)}K`) ||
(parseAssetFamilySequenceFromComponentSpu(nextComponentSpu, existing.code) === null ||
existing.assetSeriesId !== nextSeries.id)
const nextCode = input.code?.trim().toUpperCase() ??
(shouldAutoCode ? await this.generateNextFamilyCode(nextSeries) : existing.code)
(shouldAutoCode ? await this.generateNextFamilyCode(nextSeries, nextNameCode) : existing.code)
const nextName = input.name?.trim() ?? existing.name
if (!nextCode || !nextName) {
@ -4032,6 +4158,14 @@ class AssetCenterService {
? undefined
: [nextSeries.id]
const nextMetadata =
input.metadata !== undefined || input.nameCode !== undefined
? buildAssetFamilyMetadataWithNameCode(
input.metadata !== undefined ? input.metadata : existing.metadata,
input.nameCode !== undefined ? nextNameCode : undefined,
)
: undefined
const updated = await assetFamilyRepository.updateWithRelations(id, {
code: nextCode,
name: nextName,
@ -4050,7 +4184,7 @@ class AssetCenterService {
? undefined
: { connect: { id: nextSeries.id } },
connectionRoles: input.connectionRoles ?? undefined,
metadata: toJsonValue(input.metadata),
metadata: toJsonValue(nextMetadata),
productCategory:
input.productCategoryId === undefined
? undefined
@ -5528,7 +5662,15 @@ class AssetCenterService {
const series = existing.assetFamily.assetSeries
const seriesCode = series?.code ?? ''
const componentSpu = series ? resolveSeriesComponentSpu(series) : ''
const deletedSequence = parseAssetVariantSequenceFromComponentSpu(componentSpu, originalCode)
const deletedSequence = parseAssetVariantSequenceFromComponentSpu(
componentSpu,
originalCode,
readAssetFamilyNameCodeFromSource({
metadata: existing.assetFamily.metadata,
code: existing.assetFamily.code,
componentSpu,
}),
)
const shiftedVariants: AssetVariantDTO[] = []
if (seriesCode && deletedSequence) {
@ -5536,7 +5678,15 @@ class AssetCenterService {
const variantsToShift = activeSeriesVariants
.map((variant) => ({
variant,
sequence: parseAssetVariantSequenceFromComponentSpu(componentSpu, variant.code),
sequence: parseAssetVariantSequenceFromComponentSpu(
componentSpu,
variant.code,
readAssetFamilyNameCodeFromSource({
metadata: variant.assetFamily.metadata,
code: variant.assetFamily.code,
componentSpu,
}),
),
}))
.filter(
(entry): entry is { variant: AssetVariantWithRelations; sequence: number } =>
@ -5547,7 +5697,15 @@ class AssetCenterService {
.sort((left, right) => left.sequence - right.sequence)
for (const { variant, sequence } of variantsToShift) {
const nextCode = buildAssetVariantCodeFromSpu(componentSpu, sequence - 1)
const nextCode = buildAssetVariantCodeFromSpu(
componentSpu,
sequence - 1,
readAssetFamilyNameCodeFromSource({
metadata: variant.assetFamily.metadata,
code: variant.assetFamily.code,
componentSpu,
}),
)
if (nextCode === variant.code) continue
const dimensions = isRecord(variant.dimensions)

View File

@ -75,6 +75,7 @@ export interface AssetFamilyDTO {
id: string
type: AssetType
code: string
nameCode?: string | null
name: string
description: string | null
productCategoryId: string | null
@ -293,6 +294,7 @@ export interface SubstrateDictionaryBundleDTO {
export interface CreateAssetFamilyInput {
type: AssetType
code?: string
nameCode?: string | null
name: string
description?: string | null
productCategoryId?: string | null
@ -306,6 +308,7 @@ export interface CreateAssetFamilyInput {
export interface UpdateAssetFamilyInput {
code?: string
nameCode?: string | null
name?: string
description?: string | null
productCategoryId?: string | null