feat(asset-center): add asset family name code
This commit is contained in:
parent
864b07c061
commit
350936d3c3
@ -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(),
|
||||
|
||||
@ -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="归属系列"
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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: '创建包材' }))
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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('按规格款编码、供应商编码和版本号生成条码,并校验供应商编码', () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('包材编码不能为空')
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user