feat: restore modular sku group draft workflow

This commit is contained in:
WuYongJie 2026-06-06 09:18:07 +08:00
parent d650e2f562
commit 467e4182f2
48 changed files with 5466 additions and 1342 deletions

View File

@ -0,0 +1,90 @@
-- Add a goods-code grouping layer between AssetFamily and AssetVariant.
-- Existing active variants are backfilled by goods code so multiple models can share one goods-code group.
CREATE TABLE "AssetSkuGroup" (
"id" TEXT NOT NULL,
"assetFamilyId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AssetSkuGroup_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AssetVariant"
ADD COLUMN "skuGroupId" TEXT;
WITH sku_group_sources AS (
SELECT
"assetFamilyId",
"code",
(ARRAY_AGG(COALESCE(NULLIF(TRIM("name"), ''), "code") ORDER BY "createdAt" ASC, "id" ASC))[1] AS "name",
MIN("createdAt") AS "createdAt",
MAX("updatedAt") AS "updatedAt",
JSONB_AGG("id" ORDER BY "createdAt" ASC, "id" ASC) AS "sourceAssetVariantIds"
FROM "AssetVariant"
WHERE "deletedAt" IS NULL
GROUP BY "assetFamilyId", "code"
),
ranked_sku_groups AS (
SELECT
"assetFamilyId",
"code",
"name",
"createdAt",
"updatedAt",
"sourceAssetVariantIds",
ROW_NUMBER() OVER (
PARTITION BY "assetFamilyId"
ORDER BY "createdAt" ASC, "code" ASC
) AS row_no
FROM sku_group_sources
)
INSERT INTO "AssetSkuGroup" (
"id",
"assetFamilyId",
"code",
"name",
"description",
"sortOrder",
"metadata",
"createdAt",
"updatedAt"
)
SELECT
CONCAT('asg_', MD5("assetFamilyId" || ':' || "code")),
"assetFamilyId",
"code",
"name",
NULL,
row_no,
jsonb_build_object('source', 'variant-backfill', 'sourceAssetVariantIds', "sourceAssetVariantIds"),
"createdAt",
"updatedAt"
FROM ranked_sku_groups;
UPDATE "AssetVariant" AS variant
SET "skuGroupId" = CONCAT('asg_', MD5(variant."assetFamilyId" || ':' || variant."code"))
WHERE variant."skuGroupId" IS NULL
AND variant."deletedAt" IS NULL;
CREATE UNIQUE INDEX "AssetSkuGroup_assetFamilyId_code_key"
ON "AssetSkuGroup"("assetFamilyId", "code");
CREATE INDEX "AssetSkuGroup_assetFamilyId_idx" ON "AssetSkuGroup"("assetFamilyId");
CREATE INDEX "AssetSkuGroup_code_idx" ON "AssetSkuGroup"("code");
CREATE INDEX "AssetSkuGroup_sortOrder_idx" ON "AssetSkuGroup"("sortOrder");
CREATE INDEX "AssetVariant_skuGroupId_idx" ON "AssetVariant"("skuGroupId");
ALTER TABLE "AssetSkuGroup"
ADD CONSTRAINT "AssetSkuGroup_assetFamilyId_fkey"
FOREIGN KEY ("assetFamilyId") REFERENCES "AssetFamily"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AssetVariant"
ADD CONSTRAINT "AssetVariant_skuGroupId_fkey"
FOREIGN KEY ("skuGroupId") REFERENCES "AssetSkuGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -929,6 +929,7 @@ model AssetFamily {
deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
variants AssetVariant[]
skuGroups AssetSkuGroup[]
compatibleSeries AssetFamilySeriesCompatibility[]
createdAt DateTime @default(now())
@ -947,6 +948,26 @@ model AssetFamily {
@@index([deletedById])
}
model AssetSkuGroup {
id String @id @default(cuid())
assetFamilyId String
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
code String
name String
description String? @db.Text
sortOrder Int @default(0)
metadata Json?
variants AssetVariant[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([assetFamilyId, code])
@@index([assetFamilyId])
@@index([code])
@@index([sortOrder])
}
model AssetSeries {
id String @id @default(cuid())
code String @unique
@ -994,6 +1015,8 @@ model AssetVariant {
id String @id @default(cuid())
assetFamilyId String
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
skuGroupId String?
skuGroup AssetSkuGroup? @relation(fields: [skuGroupId], references: [id], onDelete: SetNull)
code String @unique
name String
identityKey String
@ -1051,6 +1074,7 @@ model AssetVariant {
@@unique([assetFamilyId, identityKey])
@@index([assetFamilyId])
@@index([skuGroupId])
@@index([status])
@@index([barcode])
@@index([substrateId])

View File

@ -0,0 +1,63 @@
import type { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService } from '@/server/services'
import { assetSkuGroupUpdateSchema } from '../../../platform/schemas'
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
const rawBody = await request.json().catch(() => null)
const parsed = assetSkuGroupUpdateSchema.safeParse(rawBody)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
`货号组更新参数不正确: ${parsed.error.message}`,
400,
ctx.requestId,
)
}
try {
const skuGroup = await assetCenterService.updateSkuGroup(id, {
...parsed.data,
updatedBy: ctx.user.id,
})
return jsonSuccess({ skuGroup }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true, requireDesignWorkspaceWrite: true },
)
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
try {
await assetCenterService.deleteSkuGroup(id, ctx.user.id)
return jsonSuccess({ success: true }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true, requireDesignWorkspaceWrite: true },
)
}

View File

@ -0,0 +1,58 @@
import type { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService } from '@/server/services'
import { assetSkuGroupCreateSchema } from '../../platform/schemas'
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const { searchParams } = new URL(request.url)
const familyIds = searchParams.getAll('familyId')
try {
const skuGroups = await assetCenterService.listSkuGroupsByFamilyIds(familyIds)
return jsonSuccess({ skuGroups }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const rawBody = await request.json().catch(() => null)
const parsed = assetSkuGroupCreateSchema.safeParse(rawBody)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
`货号组参数不正确: ${parsed.error.message}`,
400,
ctx.requestId,
)
}
try {
const skuGroup = await assetCenterService.createSkuGroup({
...parsed.data,
createdBy: ctx.user.id,
})
return jsonSuccess({ skuGroup }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true, requireDesignWorkspaceWrite: true },
)
}

View File

@ -91,8 +91,26 @@ export const assetSeriesUpdateSchema = z.object({
isActive: z.boolean().optional(),
})
export const assetSkuGroupCreateSchema = z.object({
assetFamilyId: z.string().trim().min(1),
code: z.string().trim().min(1),
name: z.string().trim().optional().nullable(),
description: z.string().optional().nullable(),
sortOrder: z.number().int().optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
})
export const assetSkuGroupUpdateSchema = z.object({
code: z.string().trim().min(1).optional(),
name: z.string().trim().optional().nullable(),
description: z.string().optional().nullable(),
sortOrder: z.number().int().optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
})
export const assetVariantCreateSchema = z.object({
assetFamilyId: z.string().trim().min(1),
skuGroupId: z.string().trim().min(1).optional().nullable(),
code: z.string().trim().min(1).optional(),
name: z.string().trim().min(1),
status: z.enum(['DRAFT']).optional(),
@ -127,6 +145,7 @@ export const packagingAssetVariantCreateSchema = assetVariantCreateSchema.extend
export const assetVariantUpdateSchema = z.object({
assetFamilyId: z.string().trim().min(1).optional(),
skuGroupId: z.string().trim().min(1).optional().nullable(),
code: z.string().trim().min(1).optional(),
name: z.string().trim().min(1).optional(),
status: assetStatusSchema.optional(),

View File

@ -19,10 +19,10 @@ export async function POST(request: NextRequest) {
// 验证文件类型
const ext = path.extname(file.name).toLowerCase()
if (!UPLOAD.ALLOWED_MODEL_TYPES.includes(ext as '.gltf' | '.glb' | '.dwg')) {
if (!UPLOAD.ALLOWED_MODEL_TYPES.includes(ext as '.gltf' | '.glb' | '.obj' | '.dwg')) {
return jsonError(
ErrorCode.FILE_TYPE_NOT_ALLOWED,
`不支持的文件格式 "${ext}",仅支持 .gltf 和 .glb`,
`不支持的文件格式 "${ext}",仅支持 .gltf、.glb 和 .obj`,
400,
requestId,
)

View File

@ -239,6 +239,21 @@ const mockedCreatePackagingAssetVariant =
typeof createPackagingAssetVariant
>
function getVariantTreeRowByCode(code: string): HTMLElement {
const row = screen
.getAllByText(code)
.map((element) => element.closest('[role="button"]'))
.find((element): element is HTMLElement =>
element instanceof HTMLElement && element.className.includes('ml-5'),
)
if (!row) {
throw new Error(`Cannot find variant tree row for ${code}`)
}
return row
}
function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
return {
id: 'family-legacy-packaging',
@ -646,28 +661,15 @@ describe('AssetCenterClient', () => {
await actor.click(screen.getByRole('button', { name: /新增包材货号/ }))
expect(screen.getByRole('dialog', { name: '新增包材货号' })).toBeInTheDocument()
expect(screen.getByDisplayValue('DBZ-SPJ01')).toBeInTheDocument()
await actor.type(screen.getByLabelText('供应商名称'), '兰考华兰家具')
await waitFor(() => {
expect(screen.getByDisplayValue('N1280')).toBeInTheDocument()
expect(screen.getByDisplayValue('DBZ-SPJ01N1280V01')).toBeInTheDocument()
})
expect(screen.queryByText('SKU CONTENT')).not.toBeInTheDocument()
expect(screen.queryByText('ModelUploader')).not.toBeInTheDocument()
expect(screen.queryByText('模型与装配参数')).not.toBeInTheDocument()
expect(screen.queryByText('3D 模型')).not.toBeInTheDocument()
expect(screen.queryByText('板材类型')).not.toBeInTheDocument()
expect(screen.getByText('包材资料')).toBeInTheDocument()
expect(screen.getByLabelText('颜色')).not.toHaveAttribute('readonly')
expect(screen.getByLabelText('材质')).not.toHaveAttribute('readonly')
await actor.type(screen.getByLabelText('长度'), '1200')
await actor.type(screen.getByLabelText('宽度'), '1200')
await actor.type(screen.getByLabelText('厚度'), '60')
await actor.type(screen.getByLabelText('颜色'), '纸箱')
await actor.type(screen.getByLabelText('材质'), '瓦楞纸')
await waitFor(() => {
expect(screen.getByLabelText('规格名称')).toHaveValue('款式四 120/120/6 纸箱')
})
await actor.click(screen.getByRole('button', { name: '创建包材货号' }))
await waitFor(() => {
@ -675,16 +677,9 @@ describe('AssetCenterClient', () => {
})
const payload = mockedCreatePackagingAssetVariant.mock.calls[0]?.[0]
expect(payload).toEqual(expect.objectContaining({
code: 'DBZ-SPJ01',
supplierName: '兰考华兰家具',
supplierCode: 'N1280',
barcode: 'DBZ-SPJ01N1280V01',
}))
expect(payload).toEqual(
expect.objectContaining({
assetFamilyId: 'family-packaging',
name: '款式四 120/120/6 纸箱',
status: 'DRAFT',
dimensions: {
length: 1200,
@ -699,14 +694,6 @@ describe('AssetCenterClient', () => {
expect(payload?.businessData).toEqual(
expect.objectContaining({
materialMode: 'PACKAGING',
skuContentFields: expect.objectContaining({
model: 'DBZ-SPJK04',
skuCode: 'DBZ-SPJ01',
barcode: 'DBZ-SPJ01N1280V01',
specName: '款式四 120/120/6 纸箱',
color: '纸箱',
material: '瓦楞纸',
}),
}),
)
})
@ -870,7 +857,7 @@ describe('AssetCenterClient', () => {
)
await actor.click(screen.getByText('缩略图桌面板'))
await actor.click(screen.getByText(/缩略图桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-020-A'))
await actor.click(screen.getByRole('button', { name: '重生成缩略图' }))
expect(mockedRegenerateAssetVariantThumbnail).toHaveBeenCalledWith(
@ -1069,7 +1056,7 @@ describe('AssetCenterClient', () => {
)
await actor.click(screen.getByText('UV 桌面板'))
await actor.click(screen.getByText(/UV 桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-030-A'))
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
@ -1117,9 +1104,9 @@ describe('AssetCenterClient', () => {
await actor.click(screen.getByRole('button', { name: '保存' }))
const duplicateDialog = within(screen.getByRole('dialog', { name: '另存为新规格款' }))
expect(duplicateDialog.getByLabelText('货号')).toHaveValue('')
expect(duplicateDialog.getByLabelText('规格名称')).toHaveValue('UV 桌面板 A 120/60/1.8 胡桃木纹')
expect(duplicateDialog.getByLabelText('尺寸')).toHaveValue('120/60/1.8')
expect(duplicateDialog.queryByText('SKU CONTENT')).not.toBeInTheDocument()
expect(duplicateDialog.queryByLabelText('货号')).not.toBeInTheDocument()
expect(duplicateDialog.queryByLabelText('规格名称')).not.toBeInTheDocument()
expect(duplicateDialog.getByLabelText('长度')).toBeDisabled()
expect(duplicateDialog.getByLabelText('宽度')).toBeDisabled()
expect(duplicateDialog.getByLabelText('厚度')).toBeDisabled()
@ -1158,14 +1145,14 @@ describe('AssetCenterClient', () => {
expect(mockedCreateAssetVariantWithSimilarity.mock.calls[0]?.[0]).not.toHaveProperty('code')
await waitFor(() => {
expect(screen.getByText(/UV 桌面板 B/)).toBeInTheDocument()
expect(screen.getAllByText('BOARD-030-B').length).toBeGreaterThan(0)
})
await waitFor(() => {
expect(mockedRegenerateAssetVariantThumbnail).toHaveBeenCalledWith(
'variant-board-uv-copy',
)
})
await actor.click(screen.getByText(/UV 桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-030-A'))
await waitFor(() => {
expect(screen.getByTestId('preview-material-id')).toHaveTextContent('finish-1')
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
@ -1293,7 +1280,7 @@ describe('AssetCenterClient', () => {
)
await actor.click(screen.getByText('UV 后调桌面板'))
await actor.click(screen.getByText(/UV 后调桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-031-A'))
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
@ -1412,7 +1399,7 @@ describe('AssetCenterClient', () => {
)
await actor.click(screen.getByText('UV 草稿桌面板'))
await actor.click(screen.getByText(/UV 草稿桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-DRAFT-UV-A'))
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
@ -1519,7 +1506,7 @@ describe('AssetCenterClient', () => {
)
await actor.click(screen.getByText('右键桌面板'))
await actor.click(screen.getByText(/右键桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-040-A'))
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
expect(screen.getByText('渲染参数')).toBeInTheDocument()
expect(screen.queryByText('提交审核')).not.toBeInTheDocument()
@ -1575,7 +1562,7 @@ describe('AssetCenterClient', () => {
)
await actor.click(screen.getByText('基材搜索桌面板'))
await actor.click(screen.getByText(/基材搜索桌面板 A/))
await actor.click(getVariantTreeRowByCode('BOARD-SUBSTRATE-A'))
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
await actor.click(screen.getByRole('button', { name: /快捷更改基材/ }))
await actor.type(screen.getByLabelText('搜索基材'), '胡桃')

File diff suppressed because it is too large Load Diff

View File

@ -81,8 +81,6 @@ interface SeriesForm {
name: string
code: string
description: string
componentSpu: string
finishedProductSpu: string
sortOrder: string
isActive: boolean
}
@ -204,8 +202,6 @@ function emptySeriesForm(): SeriesForm {
name: '',
code: '',
description: '',
componentSpu: '',
finishedProductSpu: '',
sortOrder: '0',
isActive: true,
}
@ -216,8 +212,6 @@ function seriesToForm(series: AssetSeriesDTO): SeriesForm {
name: series.name,
code: series.code,
description: series.description ?? '',
componentSpu: series.componentSpu ?? '',
finishedProductSpu: series.finishedProductSpu ?? '',
sortOrder: String(series.sortOrder ?? 0),
isActive: series.isActive,
}
@ -1178,8 +1172,6 @@ export default function MaterialLibraryClient({
name: seriesForm.name.trim(),
code: seriesForm.code.trim() || null,
description: seriesForm.description.trim() || null,
componentSpu: seriesForm.componentSpu.trim().toUpperCase() || null,
finishedProductSpu: seriesForm.finishedProductSpu.trim().toUpperCase() || null,
sortOrder: toOptionalNumber(seriesForm.sortOrder) ?? 0,
isActive: seriesForm.isActive,
}
@ -1444,28 +1436,6 @@ export default function MaterialLibraryClient({
placeholder="留空后端自动生成首拼"
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1.5" htmlFor="asset-series-component-spu">
<span className={mlFieldLabelClassName}>部件SPU</span>
<input
id="asset-series-component-spu"
value={seriesForm.componentSpu}
onChange={(e) => setSeriesForm((current) => ({ ...current, componentSpu: e.target.value.toUpperCase() }))}
className={mlInputClassName}
placeholder="例如 DNZ0384"
/>
</label>
<label className="space-y-1.5" htmlFor="asset-series-finished-product-spu">
<span className={mlFieldLabelClassName}>成品SPU</span>
<input
id="asset-series-finished-product-spu"
value={seriesForm.finishedProductSpu}
onChange={(e) => setSeriesForm((current) => ({ ...current, finishedProductSpu: e.target.value.toUpperCase() }))}
className={mlInputClassName}
placeholder="例如 DNZ0385"
/>
</label>
</div>
<label className="space-y-1.5" htmlFor="asset-series-description">
<span className={mlFieldLabelClassName}>说明</span>
<textarea
@ -1509,8 +1479,6 @@ export default function MaterialLibraryClient({
{ label: '显示名', value: activeSeries.displayName },
{ label: '名称', value: activeSeries.name },
{ label: '编码', value: activeSeries.code },
{ label: '部件SPU', value: activeSeries.componentSpu || '默认' },
{ label: '成品SPU', value: activeSeries.finishedProductSpu || '默认' },
{ label: '排序', value: activeSeries.sortOrder },
{ label: '状态', value: activeSeries.isActive ? '启用' : '停用' },
].map(({ label, value }) => (

View File

@ -58,6 +58,9 @@ export default async function AssetCenterPage() {
const variants = await assetCenterService.listVariantsByFamilyIds(
families.map((family) => family.id),
)
const skuGroups = await assetCenterService.listSkuGroupsByFamilyIds(
families.map((family) => family.id),
)
const [substrates, substrateDictionaries] = await Promise.all([
assetCenterService.listSubstrates(),
assetCenterService.getSubstrateDictionaryBundle(),
@ -82,6 +85,7 @@ export default async function AssetCenterPage() {
return (
<AssetCenterClient
initialFamilies={families}
initialSkuGroups={skuGroups}
initialVariants={variants}
initialSubstrates={substrates}
initialSubstrateDictionaries={substrateDictionaries}

View File

@ -45,6 +45,10 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({
<div>辅助拼装工作台</div>
<div>库存:{value.inventory.length}</div>
<div>节点:{value.nodes.length}</div>
<div data-testid="workspace-node-count">{value.nodes.length}</div>
<div data-testid="workspace-node-variant-ids">
{value.nodes.map((node) => node.variantId).join(',')}
</div>
<div>当前系列:{selectedAssetSeriesId ?? 'none'}</div>
<div>可用规格:{availableVariants?.length ?? 0}</div>
<div>首个规格款:{value.inventory[0]?.selectedVariantId ?? 'none'}</div>
@ -483,4 +487,170 @@ describe('AssistedAssemblyClient', () => {
expect(screen.getByText('首个规格款:variant-board-source')).toBeInTheDocument()
})
})
it('从资产中心按货号进入时,会把该货号下所有模型明细带入工作台', async () => {
mockSearchParams = new URLSearchParams(
'entryMode=asset-center&source=asset-center&legFamilyIds=family-leg&skuGroupCodes=DNZ038401',
)
render(
<AssistedAssemblyClient
user={designUser}
families={[
{
id: 'family-leg',
type: 'COMPONENT',
code: 'LEG-A',
name: '口桌腿',
status: 'AVAILABLE',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
]}
variants={[
{
id: 'variant-leg-black',
assetFamilyId: 'family-leg',
skuGroupId: 'sku-group-leg-old-1',
skuGroupCode: 'DNZ038401',
assetType: 'COMPONENT',
code: 'DBZ-SPJ07',
name: '黑色金属',
status: 'AVAILABLE',
modelUrl: '/leg-black.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
compatibilityRules: null,
} as never,
{
id: 'variant-leg-white',
assetFamilyId: 'family-leg',
skuGroupId: 'sku-group-leg-old-2',
skuGroupCode: 'DNZ038401',
assetType: 'COMPONENT',
code: 'DBZ-SPJ06',
name: '白色金属',
status: 'AVAILABLE',
modelUrl: '/leg-white.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
compatibilityRules: null,
} as never,
{
id: 'variant-leg-other-group',
assetFamilyId: 'family-leg',
skuGroupId: 'sku-group-leg-2',
skuGroupCode: 'DNZ038402',
assetType: 'COMPONENT',
code: 'DNZ038409',
name: '其它货号',
status: 'AVAILABLE',
modelUrl: '/leg-other.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
compatibilityRules: null,
} as never,
]}
metrics={[]}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('workspace-node-count')).toHaveTextContent('2')
expect(screen.getByTestId('workspace-node-variant-ids')).toHaveTextContent(
'variant-leg-black,variant-leg-white',
)
})
})
it('右侧装配节点按条码分组展示,并可展开模型明细', async () => {
const actor = userEvent.setup()
mockSearchParams = new URLSearchParams(
'entryMode=asset-center&source=asset-center&legFamilyIds=family-leg&skuGroupCodes=DNZ038401',
)
render(
<AssistedAssemblyClient
user={designUser}
families={[
{
id: 'family-leg',
type: 'COMPONENT',
code: 'LEG-A',
name: '口桌腿',
status: 'AVAILABLE',
productCategoryId: 'category-desk',
productCategoryName: '书桌',
} as never,
]}
variants={[
{
id: 'variant-leg-black',
assetFamilyId: 'family-leg',
skuGroupId: 'sku-group-leg-old-1',
skuGroupCode: 'DNZ038401',
assetType: 'COMPONENT',
code: 'DBZ-SPJ07',
name: '黑色金属',
skuGroupMetadata: {
skuContentFields: {
correspondingBarcode: 'DNZ038401N1440V01',
},
},
status: 'AVAILABLE',
modelUrl: '/leg-black.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
compatibilityRules: null,
} as never,
{
id: 'variant-leg-white',
assetFamilyId: 'family-leg',
skuGroupId: 'sku-group-leg-old-2',
skuGroupCode: 'DNZ038401',
assetType: 'COMPONENT',
code: 'DBZ-SPJ06',
name: '白色金属',
skuGroupMetadata: {
skuContentFields: {
correspondingBarcode: 'DNZ038401N1440V01',
},
},
status: 'AVAILABLE',
modelUrl: '/leg-white.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
compatibilityRules: null,
} as never,
]}
metrics={[]}
/>,
)
await waitFor(() => {
expect(screen.getByText('Assembly Barcodes')).toBeInTheDocument()
expect(screen.getByText('DNZ038401N1440V01')).toBeInTheDocument()
expect(screen.getByText('2 模型')).toBeInTheDocument()
})
expect(screen.queryByText('黑色金属')).not.toBeInTheDocument()
await actor.click(
screen.getByRole('button', { name: '展开条码DNZ038401N1440V01模型明细' }),
)
expect(screen.getByText('黑色金属')).toBeInTheDocument()
expect(screen.getByText('白色金属')).toBeInTheDocument()
expect(screen.getByText('DBZ-SPJ07')).toBeInTheDocument()
expect(screen.getByText('DBZ-SPJ06')).toBeInTheDocument()
})
})

View File

@ -61,6 +61,16 @@ const EMPTY_ASSET_SERIES: AssetSeriesDTO[] = []
type InspectorTabKey = 'assembly' | 'ai'
interface AssemblyNodeBarcodeGroup {
key: string
barcode: string | null
label: string
nodes: Array<{
node: AssemblyWorkspaceSnapshot['nodes'][number]
variant: AssistedAssemblyVariant | null
}>
}
interface AiTagOption {
label: string
promptKeyword: string
@ -207,6 +217,58 @@ function toOpenProductVariant(variant: AssistedAssemblyVariant) {
}
}
function buildAssemblyNodeBarcodeGroups(
nodes: AssemblyWorkspaceSnapshot['nodes'],
variantsById: Map<string, AssistedAssemblyVariant>,
) {
const groups = new Map<string, AssemblyNodeBarcodeGroup>()
nodes.forEach((node) => {
const variant = variantsById.get(node.variantId) ?? null
const barcode = resolveAssemblyNodeBarcode(variant)
const key = barcode ? `barcode:${barcode}` : `missing:${node.nodeId}`
const current = groups.get(key)
if (current) {
current.nodes.push({ node, variant })
return
}
groups.set(key, {
key,
barcode,
label: barcode ?? '未填写条码',
nodes: [{ node, variant }],
})
})
return Array.from(groups.values())
}
function readSkuContentFieldFromMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
) {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
const rawFields = metadata.skuContentFields
if (!rawFields || typeof rawFields !== 'object' || Array.isArray(rawFields)) return null
const value = (rawFields as Record<string, unknown>)[key]
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return null
}
function resolveAssemblyNodeBarcode(variant: AssistedAssemblyVariant | null) {
if (!variant) return null
return (
readSkuContentFieldFromMetadata(variant.skuGroupMetadata, 'correspondingBarcode')?.toUpperCase() ||
readSkuContentFieldFromMetadata(variant.skuGroupMetadata, 'barcode')?.toUpperCase() ||
variant.barcode?.trim().toUpperCase() ||
null
)
}
function resolveInitialFamilyIds(input: {
launchParams: ManualAssemblyLaunchParams | null
initialWorkspace: AssemblyWorkspaceSnapshot | null
@ -276,6 +338,16 @@ function getLaunchSelectedFamilyIds(launchParams: ManualAssemblyLaunchParams | n
)
}
function getLaunchSelectedSkuGroupIds(launchParams: ManualAssemblyLaunchParams | null) {
return orderedUnique(launchParams?.selectedSkuGroupIds ?? [])
}
function getLaunchSelectedSkuGroupCodes(launchParams: ManualAssemblyLaunchParams | null) {
return orderedUnique(launchParams?.selectedSkuGroupCodes ?? []).map((code) =>
code.trim().toUpperCase(),
)
}
function instantiateLaunchSelectedFamilies(input: {
workspace: AssemblyWorkspaceSnapshot
launchParams: ManualAssemblyLaunchParams | null
@ -315,6 +387,72 @@ function instantiateLaunchSelectedFamilies(input: {
)
}
function instantiateLaunchSelectedSkuGroups(input: {
workspace: AssemblyWorkspaceSnapshot
launchParams: ManualAssemblyLaunchParams | null
variantsByFamilyId: Map<string, AssistedAssemblyVariant[]>
}) {
if (input.launchParams?.entryMode !== 'asset-center') {
return input.workspace
}
const selectedSkuGroupIds = getLaunchSelectedSkuGroupIds(input.launchParams)
const selectedSkuGroupCodes = getLaunchSelectedSkuGroupCodes(input.launchParams)
if (selectedSkuGroupIds.length === 0 && selectedSkuGroupCodes.length === 0) {
return input.workspace
}
const skuGroupIdSet = new Set(selectedSkuGroupIds)
const skuGroupCodeSet = new Set(selectedSkuGroupCodes)
const variants = Array.from(input.variantsByFamilyId.values())
.flat()
.filter((variant) => {
const skuGroupIdMatched = Boolean(variant.skuGroupId && skuGroupIdSet.has(variant.skuGroupId))
const skuGroupCode = variant.skuGroupCode?.trim().toUpperCase() ?? ''
const skuGroupCodeMatched = Boolean(skuGroupCode && skuGroupCodeSet.has(skuGroupCode))
return skuGroupIdMatched || skuGroupCodeMatched
})
return variants.reduce<AssemblyWorkspaceSnapshot>((snapshot, variant) => {
if (snapshot.nodes.some((node) => node.variantId === variant.id)) {
return snapshot
}
const inventoryItem = snapshot.inventory.find((item) => item.familyId === variant.assetFamilyId)
if (!inventoryItem) {
return snapshot
}
const withSelection = updateInventoryVariantSelection(
snapshot,
inventoryItem.inventoryId,
variant.id,
)
const instantiated = instantiateAssemblyNode(withSelection, {
inventoryId: inventoryItem.inventoryId,
variant: toOpenProductVariant(variant),
})
const skuGroupCode = variant.skuGroupCode?.trim().toUpperCase()
if (!skuGroupCode) {
return instantiated
}
return {
...instantiated,
nodes: instantiated.nodes.map((node) =>
node.variantId === variant.id
? {
...node,
code: skuGroupCode,
label: `${node.label} · ${skuGroupCode}`,
}
: node,
),
}
}, input.workspace)
}
function formatAiRenderTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
@ -525,6 +663,9 @@ export default function AssistedAssemblyClient({
const [aiOptimExplanation, setAiOptimExplanation] = useState('')
const [aiOptimExplanationCollapsed, setAiOptimExplanationCollapsed] = useState(false)
const [aiPromptCollapsed, setAiPromptCollapsed] = useState(false)
const [expandedAssemblyBarcodeKeys, setExpandedAssemblyBarcodeKeys] = useState<Set<string>>(
() => new Set(),
)
const [aiSelectedTags, setAiSelectedTags] = useState<Record<string, AiTagOption>>({})
const [hoveredTagGroup, setHoveredTagGroup] = useState<string | null>(null)
const [tagPopupPos, setTagPopupPos] = useState<{ top: number; left: number } | null>(null)
@ -568,6 +709,11 @@ export default function AssistedAssemblyClient({
return map
}, [materialVariants])
const materialVariantById = useMemo(
() => new Map(materialVariants.map((variant) => [variant.id, variant])),
[materialVariants],
)
const initialFamilyIds = useMemo(
() =>
resolveInitialFamilyIds({
@ -609,6 +755,16 @@ export default function AssistedAssemblyClient({
families: nextFamilies,
})
const selectedSkuGroupIds = getLaunchSelectedSkuGroupIds(launchParams)
const selectedSkuGroupCodes = getLaunchSelectedSkuGroupCodes(launchParams)
if (selectedSkuGroupIds.length > 0 || selectedSkuGroupCodes.length > 0) {
return instantiateLaunchSelectedSkuGroups({
workspace: synced,
launchParams,
variantsByFamilyId: availableVariantMap,
})
}
return instantiateLaunchSelectedFamilies({
workspace: synced,
launchParams,
@ -719,6 +875,23 @@ export default function AssistedAssemblyClient({
}
}, [workspace])
const assemblyNodeBarcodeGroups = useMemo(
() => buildAssemblyNodeBarcodeGroups(workspace.nodes, materialVariantById),
[materialVariantById, workspace.nodes],
)
const toggleAssemblyBarcodeGroup = useCallback((key: string) => {
setExpandedAssemblyBarcodeKeys((current) => {
const next = new Set(current)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}, [])
const activeMetric = metrics[0]?.value ?? '直接进入'
const handleCaptureReady = useCallback((capture: (() => string | null) | null) => {
@ -1005,7 +1178,7 @@ export default function AssistedAssemblyClient({
<div className="space-y-2">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/45">
<Layers3 className="h-3.5 w-3.5" />
<span>Assembly Nodes</span>
<span>Assembly Barcodes</span>
</div>
{workspace.nodes.length === 0 ? (
<div className="rounded-[8px] border border-dashed border-[#47646b]/12 px-3 py-7 text-center text-[11px] text-[#5a6062]/55">
@ -1013,17 +1186,62 @@ export default function AssistedAssemblyClient({
</div>
) : (
<div className="space-y-2">
{workspace.nodes.slice(0, 6).map((node) => (
<div key={node.nodeId} className="rounded-[7px] bg-white px-3 py-2 ring-1 ring-black/[0.04]">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-apple-medium text-[#2d3335]">{node.name}</p>
<span className="shrink-0 rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[9px] font-bold text-[#5a6062]/55">
{node.zone === 'main' ? '主装配' : '待装'}
</span>
{assemblyNodeBarcodeGroups.map((group) => {
const isExpanded = expandedAssemblyBarcodeKeys.has(group.key)
const mainCount = group.nodes.filter(({ node }) => node.zone === 'main').length
return (
<div key={group.key} className="rounded-[7px] bg-white ring-1 ring-black/[0.04]">
<button
type="button"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? '收起' : '展开'}条码${group.label}模型明细`}
onClick={() => toggleAssemblyBarcodeGroup(group.key)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-[#5a6062]/45 transition-transform',
isExpanded && 'rotate-90',
)}
strokeWidth={1.6}
/>
<span className="min-w-0 flex-1">
<span className="block text-[9px] font-black uppercase tracking-[0.12em] text-[#5a6062]/38">
条码
</span>
<span className="mt-0.5 block truncate font-mono text-xs font-bold text-[#2d3335]">
{group.label}
</span>
</span>
<span className="shrink-0 text-right">
<span className="block rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[9px] font-bold text-[#5a6062]/55">
{group.nodes.length} 模型
</span>
<span className="mt-1 block text-[9px] font-bold text-[#5a6062]/35">
主装 {mainCount}
</span>
</span>
</button>
{isExpanded ? (
<div className="space-y-1 border-t border-black/[0.04] px-3 pb-2 pt-1.5">
{group.nodes.map(({ node, variant }) => (
<div key={node.nodeId} className="rounded-[5px] bg-black/[0.018] px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-[11px] font-apple-medium text-[#2d3335]">{node.name}</p>
<span className="shrink-0 rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[9px] font-bold text-[#5a6062]/55">
{node.zone === 'main' ? '主装配' : '待装'}
</span>
</div>
<p className="mt-0.5 truncate font-mono text-[10px] text-[#5a6062]/45">
{variant?.code ?? node.code}
</p>
</div>
))}
</div>
) : null}
</div>
<p className="mt-0.5 truncate font-mono text-[10px] text-[#5a6062]/45">{node.code}</p>
</div>
))}
)
})}
</div>
)}
</div>

View File

@ -481,6 +481,11 @@ function buildHistoricalVariantsFromAiSnapshot(assetSnapshot: unknown): Assisted
assetFamilyId,
assetFamilyCode: typeof item.assetFamilyCode === 'string' ? item.assetFamilyCode : '',
assetFamilyName,
skuGroupId: typeof item.skuGroupId === 'string' ? item.skuGroupId : null,
skuGroupCode: typeof item.skuGroupCode === 'string' ? item.skuGroupCode : null,
skuGroupName: typeof item.skuGroupName === 'string' ? item.skuGroupName : null,
skuGroupSortOrder: typeof item.skuGroupSortOrder === 'number' ? item.skuGroupSortOrder : null,
skuGroupMetadata: isRecord(item.skuGroupMetadata) ? item.skuGroupMetadata : null,
assetType,
code: typeof item.code === 'string' ? item.code : '',
name: typeof item.name === 'string' ? item.name : assetFamilyName,

View File

@ -302,9 +302,17 @@ function MaterialPreview({ preSku, onItemClick }: { preSku: DraftGroupPreSkuDTO;
className="flex w-full items-start gap-3 px-3 py-3 text-left transition hover:bg-[var(--obs-shell-surface-bg)]"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-black text-[var(--obs-heading-color)]">{item.name}</p>
<p className="truncate text-[12px] font-black text-[var(--obs-heading-color)]">{item.code}</p>
<p className="mt-1 truncate text-[11px] font-semibold text-[var(--obs-shell-muted)]">
{item.code} · {item.role}
{item.name}
</p>
{item.variantCodes.length > 1 ? (
<p className="mt-1 truncate text-[11px] font-semibold text-[var(--obs-shell-muted)]">
模型:{item.variantCodes.join(' / ')}
</p>
) : null}
<p className="mt-1 truncate text-[11px] font-semibold text-[var(--obs-shell-color)]">
对应条码:{item.correspondingBarcode}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -859,6 +867,7 @@ export default function DraftGroupsClient({ draftGroups, categories, user }: Dra
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<DetailField label="编码" value={detailVariant.code} />
<DetailField label="对应条码" value={detailVariant.correspondingBarcode} />
<DetailField label="名称" value={detailVariant.name} />
<DetailField label="角色" value={detailVariant.role} />
<DetailField label="资产类型" value={detailVariant.assetType === 'BOARD' ? '板材' : detailVariant.assetType === 'HARDWARE' ? '五金件' : '组件'} />

View File

@ -0,0 +1,34 @@
import {
buildDraftGroupBarcodeFromSkuCode,
buildDraftGroupFormalSkuCode,
normalizeDraftGroupSeriesSpuForSkuCode,
} from './draftGroupFormalSkuCode'
describe('draft group modular SKU code generation', () => {
const draftGroup = {
createdAt: '2026-06-04T04:54:18.725Z',
}
it('builds draft-group SKU codes from manual SPU, year-month, and sequence', () => {
expect(buildDraftGroupFormalSkuCode('DNZ0384', draftGroup as never, 0)).toBe(
'MKH-DNZ0384260600001',
)
expect(buildDraftGroupFormalSkuCode('DNZ0384', draftGroup as never, 2)).toBe(
'MKH-DNZ0384260600003',
)
})
it('does not duplicate the MKH prefix', () => {
expect(normalizeDraftGroupSeriesSpuForSkuCode('MKH-DNZ0384')).toBe('MKH-DNZ0384')
expect(buildDraftGroupFormalSkuCode('MKH-DNZ0384', draftGroup as never, 0)).toBe(
'MKH-DNZ0384260600001',
)
})
it('uses the generated SKU code as the barcode for draft groups', () => {
const skuCode = buildDraftGroupFormalSkuCode('DNZ0384', draftGroup as never, 0)
expect(buildDraftGroupBarcodeFromSkuCode(skuCode)).toBe('MKH-DNZ0384260600001')
expect(buildDraftGroupBarcodeFromSkuCode(`${skuCode}A`)).toBe('MKH-DNZ0384260600001A')
})
})

View File

@ -322,6 +322,9 @@ function MaterialSections({ preSku }: { preSku: DraftGroupPreSkuDTO }) {
<p className="mt-1 truncate text-[11px] font-semibold text-[#5a6062]/62">
{item.code} · {item.role}
</p>
<p className="mt-1 truncate text-[11px] font-semibold text-[#47646b]">
对应条码:{item.correspondingBarcode}
</p>
</div>
<span className="shrink-0 rounded-[4px] bg-[#edf4f3] px-1.5 py-0.5 text-[10px] font-black text-[#47646b]">
x{item.quantity}
@ -330,6 +333,7 @@ function MaterialSections({ preSku }: { preSku: DraftGroupPreSkuDTO }) {
<div className="mt-2 grid gap-1 text-[11px] font-semibold text-[#5a6062]/58">
<span className="truncate">尺寸:{item.dimensions}</span>
<span className="truncate">材质/花色:{item.material}</span>
<span className="truncate">对应条码:{item.correspondingBarcode}</span>
<span className="truncate">重量:{item.weight} · 成本:{item.cost}</span>
</div>
</div>

View File

@ -0,0 +1,96 @@
import type { DraftGroupPreSkuDTO } from '@/server/services/draft-group.service.types'
import { deriveDraftMaterialSections } from './draftGroupDisplay'
describe('draftGroupDisplay', () => {
it('shows the selected asset corresponding barcode in material sections', () => {
const sections = deriveDraftMaterialSections({
variantSelection: {
slots: [
{
slotKey: 'desktop',
role: 'desktop',
assetFamilyId: 'family-board-1',
variantId: 'variant-board-1',
code: 'DBZ-SPJ01',
name: '桌面板',
assetType: 'BOARD',
correspondingBarcode: 'DNZ038401N1440V01',
dimensions: { length: 1400, width: 600, thickness: 25 },
weightKg: null,
costPrice: null,
modelUrl: '',
mountPoints: [],
assemblyBasis: null,
},
],
hardwareSelections: [],
},
} as DraftGroupPreSkuDTO)
expect(sections.find((section) => section.key === 'BOARD')?.items[0]).toEqual(
expect.objectContaining({
correspondingBarcode: 'DNZ038401N1440V01',
}),
)
})
it('groups material preview rows by asset sku group code', () => {
const sections = deriveDraftMaterialSections({
variantSelection: {
slots: [
{
slotKey: 'leg-1',
role: 'leg',
assetFamilyId: 'family-leg-1',
variantId: 'variant-leg-1',
skuGroupId: 'sku-group-dbz038501',
skuGroupCode: 'DBZ038501',
skuGroupName: 'DBZ038501',
code: 'DBZ-SPJ06',
name: 'black metal model',
assetType: 'COMPONENT',
correspondingBarcode: 'DBZ038501N1440V01',
dimensions: { length: 600, width: 40, height: 700 },
weightKg: null,
costPrice: null,
modelUrl: '',
mountPoints: [],
assemblyBasis: null,
},
{
slotKey: 'leg-2',
role: 'leg',
assetFamilyId: 'family-leg-1',
variantId: 'variant-leg-2',
skuGroupId: 'sku-group-dbz038501',
skuGroupCode: 'DBZ038501',
skuGroupName: 'DBZ038501',
code: 'DBZ-SPJ07',
name: 'white metal model',
assetType: 'COMPONENT',
correspondingBarcode: 'DBZ038501N1440V01',
dimensions: { length: 600, width: 40, height: 700 },
weightKg: null,
costPrice: null,
modelUrl: '',
mountPoints: [],
assemblyBasis: null,
},
],
hardwareSelections: [],
},
} as DraftGroupPreSkuDTO)
const componentItems = sections.find((section) => section.key === 'COMPONENT')?.items ?? []
expect(componentItems).toHaveLength(1)
expect(componentItems[0]).toEqual(
expect.objectContaining({
code: 'DBZ038501',
name: 'DBZ038501',
quantity: 2,
correspondingBarcode: 'DBZ038501N1440V01',
variantCodes: ['DBZ-SPJ06', 'DBZ-SPJ07'],
}),
)
})
})

View File

@ -21,12 +21,15 @@ export interface DraftMaterialItem {
id: string
code: string
name: string
variantCodes: string[]
variantNames: string[]
role: string
quantity: number
dimensions: string
material: string
weight: string
cost: string
correspondingBarcode: string
assetType: MaterialAssetType
variantId: string
assetFamilyId: string
@ -35,6 +38,9 @@ export interface DraftMaterialItem {
modelUrl: string
slotKey: string | null
selectionKey: string | null
skuGroupId: string | null
skuGroupCode: string | null
skuGroupName: string | null
}
export interface DraftMaterialSection {
@ -179,18 +185,48 @@ function readSlotMaterial(item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHar
return displayDraftValue(materialLabel)
}
function normalizeGroupText(value: string | null | undefined) {
const normalized = value?.trim() ?? ''
return normalized.length > 0 ? normalized : null
}
function getSelectionSkuGroupCode(
item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
) {
return normalizeGroupText(item.skuGroupCode)?.toUpperCase() ?? null
}
function getSelectionSkuGroupName(
item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
) {
return normalizeGroupText(item.skuGroupName) ?? getSelectionSkuGroupCode(item)
}
function getMaterialGroupKey(
item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
) {
const skuGroupCode = getSelectionSkuGroupCode(item)
if (skuGroupCode) return `sku-group-code:${skuGroupCode}`
const skuGroupId = normalizeGroupText(item.skuGroupId)
if (skuGroupId) return `sku-group-id:${skuGroupId}`
return `variant:${item.variantId}`
}
function mapSlotItem(slot: PreSkuSelectedSlotVariantDTO): DraftMaterialItem {
const assetType = normalizeAssetType(slot.assetType)
return {
id: `${slot.slotKey}-${slot.variantId}`,
code: displayDraftValue(slot.code),
name: displayDraftValue(slot.name),
code: displayDraftValue(getSelectionSkuGroupCode(slot) ?? slot.code),
name: displayDraftValue(getSelectionSkuGroupName(slot) ?? slot.name),
variantCodes: [slot.code].filter(isPresent).map((value) => String(value).trim()),
variantNames: [slot.name].filter(isPresent).map((value) => String(value).trim()),
role: readFirstText(slot.role, slot.slotKey),
quantity: 1,
dimensions: formatDimensions(slot.dimensions),
material: readSlotMaterial(slot),
weight: formatWeight(slot.weightKg),
cost: formatCost(slot.costPrice),
correspondingBarcode: readFirstText(slot.correspondingBarcode, slot.barcode),
assetType,
variantId: slot.variantId,
assetFamilyId: slot.assetFamilyId,
@ -199,20 +235,26 @@ function mapSlotItem(slot: PreSkuSelectedSlotVariantDTO): DraftMaterialItem {
modelUrl: slot.modelUrl,
slotKey: slot.slotKey,
selectionKey: null,
skuGroupId: normalizeGroupText(slot.skuGroupId),
skuGroupCode: getSelectionSkuGroupCode(slot),
skuGroupName: getSelectionSkuGroupName(slot),
}
}
function mapHardwareItem(hardware: PreSkuSelectedHardwareSelectionDTO): DraftMaterialItem {
return {
id: `${hardware.selectionKey}-${hardware.variantId}`,
code: displayDraftValue(hardware.code),
name: displayDraftValue(hardware.name),
code: displayDraftValue(getSelectionSkuGroupCode(hardware) ?? hardware.code),
name: displayDraftValue(getSelectionSkuGroupName(hardware) ?? hardware.name),
variantCodes: [hardware.code].filter(isPresent).map((value) => String(value).trim()),
variantNames: [hardware.name].filter(isPresent).map((value) => String(value).trim()),
role: readFirstText(hardware.selectionKey, '五金'),
quantity: hardware.quantity || 1,
dimensions: formatDimensions(hardware.dimensions),
material: readSlotMaterial(hardware),
weight: formatWeight(hardware.weightKg),
cost: formatCost(hardware.costPrice),
correspondingBarcode: readFirstText(hardware.correspondingBarcode, hardware.barcode),
assetType: 'HARDWARE',
variantId: hardware.variantId,
assetFamilyId: hardware.assetFamilyId,
@ -221,6 +263,22 @@ function mapHardwareItem(hardware: PreSkuSelectedHardwareSelectionDTO): DraftMat
modelUrl: hardware.modelUrl,
slotKey: null,
selectionKey: hardware.selectionKey,
skuGroupId: normalizeGroupText(hardware.skuGroupId),
skuGroupCode: getSelectionSkuGroupCode(hardware),
skuGroupName: getSelectionSkuGroupName(hardware),
}
}
function mergeDraftMaterialItem(target: DraftMaterialItem, item: DraftMaterialItem) {
target.quantity += item.quantity
item.variantCodes.forEach((code) => {
if (!target.variantCodes.includes(code)) target.variantCodes.push(code)
})
item.variantNames.forEach((name) => {
if (!target.variantNames.includes(name)) target.variantNames.push(name)
})
if (target.correspondingBarcode === DRAFT_GROUP_PENDING_TEXT && item.correspondingBarcode) {
target.correspondingBarcode = item.correspondingBarcode
}
}
@ -230,15 +288,28 @@ export function deriveDraftMaterialSections(preSku: DraftGroupPreSkuDTO): DraftM
COMPONENT: [],
HARDWARE: [],
}
const itemByGroupKey = new Map<string, DraftMaterialItem>()
preSku.variantSelection.slots.forEach((slot) => {
const item = mapSlotItem(slot)
const addItem = (
source: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
item: DraftMaterialItem,
) => {
const key = `${item.assetType}:${getMaterialGroupKey(source)}`
const existing = itemByGroupKey.get(key)
if (existing) {
mergeDraftMaterialItem(existing, item)
return
}
itemByGroupKey.set(key, item)
grouped[item.assetType].push(item)
})
}
preSku.variantSelection.hardwareSelections.forEach((hardware) => {
grouped.HARDWARE.push(mapHardwareItem(hardware))
})
preSku.variantSelection.slots.forEach((slot) => addItem(slot, mapSlotItem(slot)))
preSku.variantSelection.hardwareSelections.forEach((hardware) =>
addItem(hardware, mapHardwareItem(hardware)),
)
return (['BOARD', 'COMPONENT', 'HARDWARE'] as const).map((key) => ({
key,

View File

@ -0,0 +1,27 @@
export function normalizeDraftGroupSeriesSpuForSkuCode(value: string | null | undefined) {
const normalized = (value ?? '').trim().toUpperCase()
if (!normalized) return ''
return normalized.startsWith('MKH-') ? normalized : `MKH-${normalized}`
}
export function formatDraftGroupSkuYearMonth(value: Date | string | null | undefined) {
const date = value ? new Date(value) : new Date()
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date
return `${String(safeDate.getFullYear()).slice(-2)}${String(safeDate.getMonth() + 1).padStart(2, '0')}`
}
export function buildDraftGroupFormalSkuCode(
seriesSpu: string,
draftGroup: { createdAt?: Date | string | null } | null | undefined,
index: number,
) {
const normalizedSeriesSpu = normalizeDraftGroupSeriesSpuForSkuCode(seriesSpu)
if (!normalizedSeriesSpu) return ''
return `${normalizedSeriesSpu}${formatDraftGroupSkuYearMonth(draftGroup?.createdAt)}${String(index + 1).padStart(5, '0')}`
}
export function buildDraftGroupBarcodeFromSkuCode(skuCode: string) {
const normalizedSkuCode = skuCode.trim().toUpperCase()
if (!normalizedSkuCode) return ''
return /^MKH-.+\d{9}[A-Z]?$/.test(normalizedSkuCode) ? normalizedSkuCode : ''
}

View File

@ -1813,7 +1813,7 @@ describe('IntakeClient', () => {
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '系列产品开发明细' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '模块化产品明细' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument()
})
@ -1823,13 +1823,14 @@ describe('IntakeClient', () => {
initialPendingEntryTab: 'integrated',
})
expect(screen.getByRole('tab', { name: '系列产品开发明细' })).toHaveAttribute(
expect(screen.getByRole('tab', { name: '模块化产品明细' })).toHaveAttribute(
'aria-selected',
'true',
)
const workspacePanel = screen.getByRole('tabpanel')
expect(within(workspacePanel).getByRole('columnheader', { name: '系列名称' })).toBeInTheDocument()
expect(within(workspacePanel).getByRole('button', { name: '新增部件' })).toBeInTheDocument()
expect(within(workspacePanel).queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
expect(within(workspacePanel).queryByRole('button', { name: '新增部件' })).not.toBeInTheDocument()
})
it('keeps BOM libraries visible but read-only entry points hidden from purchasing users', async () => {
@ -1913,10 +1914,10 @@ describe('IntakeClient', () => {
renderSelector({ eligibleSkuList: [] })
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '系列产品开发明细' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '模块化产品明细' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
await actor.click(within(rightPanel).getByRole('button', { name: '系列产品开发明细' }))
await actor.click(within(rightPanel).getByRole('button', { name: '模块化产品明细' }))
expect(screen.getByText('SER001')).toBeInTheDocument()
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
@ -1968,7 +1969,7 @@ describe('IntakeClient', () => {
renderSelector({ eligibleSkuList: [] })
const rightPanel = screen.getByTestId('right-panel')
await actor.click(within(rightPanel).getByRole('button', { name: '系列产品开发明细' }))
await actor.click(within(rightPanel).getByRole('button', { name: '模块化产品明细' }))
expect(screen.getByText('DNZ0385')).toBeInTheDocument()
expect(screen.getByText('DNZ0384')).toBeInTheDocument()
@ -1991,37 +1992,19 @@ describe('IntakeClient', () => {
expect(within(rightPanel).queryByRole('button', { name: '质检确认' })).not.toBeInTheDocument()
})
it('opens a new series product workspace from the series development list', async () => {
it('does not offer manual series product creation from the series development list', async () => {
const actor = userEvent.setup()
renderSelector({ eligibleSkuList: [] })
const rightPanel = screen.getByTestId('right-panel')
await actor.click(
within(rightPanel).getByRole('button', { name: '系列产品开发明细' }),
within(rightPanel).getByRole('button', { name: '模块化产品明细' }),
)
await actor.click(screen.getByRole('button', { name: '新增产品' }))
expect(screen.getByRole('tab', { name: '新增系列产品' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent(
'series',
)
await actor.click(screen.getByRole('button', { name: '模拟创建开品单' }))
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
expect(screen.queryByRole('tab', { name: '新增系列产品' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'CREATED001系列产品开发明细' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent(
'series',
)
})
it('keeps the selected draft group series name available when opening a new series product', async () => {
it('keeps the series development list read-only for creation even with draft group series options', async () => {
const actor = userEvent.setup()
renderSelector({
eligibleDraftGroups: [],
@ -2031,16 +2014,10 @@ describe('IntakeClient', () => {
const rightPanel = screen.getByTestId('right-panel')
await actor.click(
within(rightPanel).getByRole('button', { name: '系列产品开发明细' }),
)
await actor.click(screen.getByRole('button', { name: '新增产品' }))
expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent(
'series',
)
expect(screen.getByTestId('embedded-series-options')).toHaveTextContent(
'单板桌 DBZ',
within(rightPanel).getByRole('button', { name: '模块化产品明细' }),
)
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
expect(screen.queryByRole('tab', { name: '新增系列产品' })).not.toBeInTheDocument()
})
it('keeps product and series development lists separated', async () => {
@ -2065,7 +2042,7 @@ describe('IntakeClient', () => {
expect(screen.queryByText('SER001')).not.toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: '系列名称' })).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '系列产品开发明细' }))
await actor.click(screen.getByRole('button', { name: '模块化产品明细' }))
expect(screen.getByText('SER001')).toBeInTheDocument()
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()

View File

@ -1443,7 +1443,7 @@ const REFERENCE_ESTIMATOR_GROUP_LABEL = "预估成本测算";
const REFERENCE_ESTIMATOR_WORKSPACE_LABEL = "参考图预估成本";
function getPendingEntryTabLabel(tab: IntakeEntryTab) {
if (tab === "integrated") return "系列产品开发明细";
if (tab === "integrated") return "模块化产品明细";
if (tab === "stock") return "存量商品明细";
return "产品开发明细";
}
@ -1708,7 +1708,7 @@ function getProductDevelopmentDetailLabel(
getProductDevelopmentKind(product) === "series"
? isSeriesComponentDetail(product as unknown as Record<string, unknown>)
? "部件开发明细"
: "系列产品开发明细"
: "模块化产品明细"
: isJstSkuImportedStockProduct(product)
? "存量商品明细"
: "产品开发明细";
@ -11405,28 +11405,13 @@ export default function IntakeClient(props: IntakeClientProps) {
productDevelopmentKind="series"
emptyMessage={
canUseDesignControlledProductData
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
? "当前还没有模块化产品,请先在草稿组推送 SKU 后承接。"
: "当前还没有系列产品。"
}
onProductsChange={(nextProducts) =>
handleScopedProductsChange("series", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () => openNewNonIntegratedProductWorkspace("series")
: undefined
}
secondaryCreateButtonLabel="新增部件"
onOpenSecondaryNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () =>
openNewNonIntegratedProductWorkspace(
"series",
"seriesComponents",
)
: undefined
}
/>
) : pendingEntryTab === "stock" && canViewStockDevelopmentList ? (
<NonIntegratedPendingDetail
@ -11608,28 +11593,13 @@ export default function IntakeClient(props: IntakeClientProps) {
productDevelopmentKind="series"
emptyMessage={
canUseDesignControlledProductData
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
? "当前还没有模块化产品,请先在草稿组推送 SKU 后承接。"
: "当前还没有系列产品。"
}
onProductsChange={(nextProducts) =>
handleScopedProductsChange("series", nextProducts)
}
onOpenProductWorkspace={openProductWorkspace}
onOpenNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () => openNewNonIntegratedProductWorkspace("series")
: undefined
}
secondaryCreateButtonLabel="新增部件"
onOpenSecondaryNewNonIntegratedProductWorkspace={
canUseDesignControlledProductData
? () =>
openNewNonIntegratedProductWorkspace(
"series",
"seriesComponents",
)
: undefined
}
/>
) : pendingEntryTab === "stock" && canViewStockDevelopmentList ? (
<NonIntegratedPendingDetail

View File

@ -700,12 +700,14 @@ describe("NonIntegratedDetailClient", () => {
await actor.keyboard("{Escape}");
await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌系列");
await actor.type(screen.getByLabelText("SPU"), "dnz0385");
expect(screen.getByLabelText("SPU")).toHaveValue("DNZ0385");
await actor.type(screen.getByLabelText("品类"), "桌类");
await actor.type(screen.getByLabelText("类目"), "书桌");
await actor.type(screen.getByLabelText("外观设计师"), "外观A");
await selectInlineOption(actor, screen.getByLabelText("品牌"), "代理品牌-顾家");
expect(screen.getByLabelText("SPU")).toHaveValue("DNZ0385");
await actor.type(screen.getByLabelText("商品企划"), "企划A");
expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:GBGJ-SZ0241");
const imageFile = new File(["image"], "series.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]');
@ -717,13 +719,13 @@ describe("NonIntegratedDetailClient", () => {
const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[];
expect(savedProducts[0]?.productDevelopmentKind).toBe("series");
expect(savedProducts[0]?.spu).toBe("GBGJ-SZ0241");
expect(savedProducts[0]?.spu).toBe("DNZ0385");
expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列");
expect(savedProducts[0]?.salesCategory).toBe("桌类");
expect(savedProducts[0]?.productCategory).toBe("书桌");
});
it("uses the selected asset series finished SPU when creating a series product", async () => {
it("keeps series product SPU manual even when the selected asset series has a finished SPU", async () => {
const actor = userEvent.setup();
const now = new Date("2026-06-02T08:00:00.000Z");
mockedListAssetSeries.mockResolvedValue([
@ -755,8 +757,9 @@ describe("NonIntegratedDetailClient", () => {
await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌 DBZ");
await waitFor(() =>
expect(screen.getByLabelText("SPU")).toHaveValue("DNZ0385"),
expect(screen.getByLabelText("SPU")).toHaveValue(""),
);
await actor.type(screen.getByLabelText("SPU"), "DNZ0399");
await actor.type(screen.getByLabelText("品类"), "桌类");
await actor.type(screen.getByLabelText("类目"), "电脑桌");
await actor.type(screen.getByLabelText("外观设计师"), "外观A");
@ -772,13 +775,13 @@ describe("NonIntegratedDetailClient", () => {
const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[];
expect(savedProducts[0]).toMatchObject({
productDevelopmentKind: "series",
spu: "DNZ0385",
productCode: "DNZ0385",
spu: "DNZ0399",
productCode: "DNZ0399",
metadata: {
sourceSeriesCode: "DBZ",
sourceAssetSeriesId: "series-dbz",
componentSpu: "DNZ0384",
finishedProductSpu: "DNZ0385",
finishedProductSpu: "DNZ0399",
},
openingReference: {
productName: "单板桌 DBZ",
@ -789,7 +792,7 @@ describe("NonIntegratedDetailClient", () => {
});
expect(mockedCreateApprovalWorkflow).toHaveBeenCalledWith({
productId: savedProducts[0]?.id,
approvalNumber: "DNZ0385",
approvalNumber: "DNZ0399",
title: "单板桌 DBZ",
});
});

View File

@ -743,6 +743,7 @@ const SERIES_COMPONENT_SECONDARY_CATEGORY = "家具辅料";
const SERIES_COMPONENT_PRODUCT_CATEGORY = "其它家具配件";
const SERIES_COMPONENT_ASSET_SKU_SOURCE = "asset_series_component";
const ASSET_VARIANT_SOURCE_TABLE = "asset_variant";
const ASSET_SKU_GROUP_SOURCE_TABLE = "asset_sku_group";
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = "skuContentFields";
type SeriesDraftSkuOption = {
@ -762,6 +763,10 @@ type SeriesDraftSkuOption = {
model3dPreviewNodes?: unknown[];
};
type AssetSkuContentFields = Partial<SeriesDraftSkuFields> & {
correspondingBarcode?: string;
};
type FormValues = {
spu: string;
year: string;
@ -3742,11 +3747,11 @@ function readRecordStringIfPresent(value: Record<string, unknown>, key: string)
return "";
}
function readAssetVariantSkuContentFields(
variant: AssetVariantDTO,
): Partial<SeriesDraftSkuFields> {
const rawFields = isRecordLike(variant.businessData)
? variant.businessData[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
function readAssetSkuContentFieldsFromRecord(
record: Record<string, unknown> | null | undefined,
): AssetSkuContentFields {
const rawFields = isRecordLike(record)
? record[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
: null;
if (!isRecordLike(rawFields)) return {};
@ -3770,17 +3775,44 @@ function readAssetVariantSkuContentFields(
"manufacturerCode",
"salesStatus",
"batchNo",
"dingTalkFlowNo",
"correspondingBarcode",
"tagMaterial",
"model3dFileName",
].reduce<Partial<SeriesDraftSkuFields>>((fields, key) => {
].reduce<AssetSkuContentFields>((fields, key) => {
const value = readRecordStringIfPresent(rawFields, key);
if (value !== undefined) {
fields[key as keyof SeriesDraftSkuFields] = value;
fields[key as keyof AssetSkuContentFields] = value;
}
return fields;
}, {});
}
function readAssetVariantSkuContentFields(
variant: AssetVariantDTO,
): AssetSkuContentFields {
return readAssetSkuContentFieldsFromRecord(
isRecordLike(variant.businessData) ? variant.businessData : null,
);
}
function readAssetSkuGroupSkuContentFields(
variant: AssetVariantDTO,
): AssetSkuContentFields {
return readAssetSkuContentFieldsFromRecord(
isRecordLike(variant.skuGroupMetadata) ? variant.skuGroupMetadata : null,
);
}
function mergeAssetVariantAndSkuGroupContentFields(
variant: AssetVariantDTO,
): AssetSkuContentFields {
return {
...readAssetVariantSkuContentFields(variant),
...readAssetSkuGroupSkuContentFields(variant),
};
}
function resolveSeriesDraftSkuPreviewImage(
option: SeriesDraftSkuOption,
): SkuPreviewImageCandidate | null {
@ -4204,7 +4236,7 @@ function createSkuItemFromAssetVariant(
variant: AssetVariantDTO,
index: number,
): NonIntegratedSkuItem {
const skuFields = readAssetVariantSkuContentFields(variant);
const skuFields = mergeAssetVariantAndSkuGroupContentFields(variant);
const skuCode = variant.code.trim().toUpperCase();
const manufacturerCode =
skuFields.manufacturerCode?.trim().toUpperCase() ||
@ -4218,6 +4250,7 @@ function createSkuItemFromAssetVariant(
skuFields.barcode?.trim().toUpperCase() ||
variant.barcode?.trim().toUpperCase() ||
(manufacturerCode && versionNo ? buildAutoBarcode(skuCode, manufacturerCode, versionNo) : "");
const isStockLinkedSku = Boolean(skuFields.correspondingBarcode?.trim());
const model = skuFields.model?.trim() || variant.assetFamilyCode.trim().toUpperCase();
const attributeCode = readAssetVariantSkuFieldWithDefault(
skuFields,
@ -4285,10 +4318,44 @@ function createSkuItemFromAssetVariant(
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
sourceSkuId: variant.id,
contentLocked: isStockLinkedSku,
};
}
function buildSeriesComponentSkuItemsFromAssetVariants(
type SeriesComponentAssetSkuGroup = {
id: string;
hasSkuGroup: boolean;
assetFamilyId: string;
assetFamilyCode: string;
assetFamilyName: string;
code: string;
name: string;
sortOrder: number;
variants: AssetVariantDTO[];
};
function getSeriesComponentAssetSkuGroupId(variant: AssetVariantDTO) {
const skuGroupId = variant.skuGroupId?.trim();
if (skuGroupId) return skuGroupId;
const skuGroupCode = variant.skuGroupCode?.trim().toUpperCase();
if (skuGroupCode) {
return `sku-group-code:${variant.assetFamilyId}:${skuGroupCode}`;
}
return `variant:${variant.id}`;
}
function sortAssetVariantsWithinSkuGroup(variants: AssetVariantDTO[]) {
return [...variants].sort(
(left, right) =>
getAssetCodeSequence(left.code) - getAssetCodeSequence(right.code) ||
left.code.localeCompare(right.code, "zh-CN", { numeric: true }) ||
left.name.localeCompare(right.name, "zh-CN", { numeric: true }),
);
}
function buildSeriesComponentAssetSkuGroups(
variants: AssetVariantDTO[],
families: AssetFamilyDTO[],
) {
@ -4299,10 +4366,104 @@ function buildSeriesComponentSkuItemsFromAssetVariants(
)
.map((family, index) => [family.id, index]),
);
const groupById = new Map<string, SeriesComponentAssetSkuGroup>();
return sortAssetVariantsForSeriesComponent(variants, familyOrderById).map(
(variant, index) => createSkuItemFromAssetVariant(variant, index),
);
variants.forEach((variant) => {
const id = getSeriesComponentAssetSkuGroupId(variant);
const hasSkuGroup = Boolean(
variant.skuGroupId?.trim() || variant.skuGroupCode?.trim(),
);
const code = (
variant.skuGroupCode?.trim() || variant.code.trim()
).toUpperCase();
const name = variant.skuGroupName?.trim() || variant.name.trim() || code;
const current = groupById.get(id);
if (current) {
current.variants.push(variant);
return;
}
groupById.set(id, {
id,
hasSkuGroup,
assetFamilyId: variant.assetFamilyId,
assetFamilyCode: variant.assetFamilyCode,
assetFamilyName: variant.assetFamilyName,
code,
name,
sortOrder: variant.skuGroupSortOrder ?? Number.MAX_SAFE_INTEGER,
variants: [variant],
});
});
return Array.from(groupById.values())
.map((group) => ({
...group,
variants: sortAssetVariantsWithinSkuGroup(group.variants),
}))
.sort((left, right) => {
const familyOrderDelta =
(familyOrderById.get(left.assetFamilyId) ?? Number.MAX_SAFE_INTEGER) -
(familyOrderById.get(right.assetFamilyId) ?? Number.MAX_SAFE_INTEGER);
if (familyOrderDelta !== 0) return familyOrderDelta;
const familyCodeCompare = left.assetFamilyCode.localeCompare(
right.assetFamilyCode,
"zh-CN",
{ numeric: true },
);
if (familyCodeCompare !== 0) return familyCodeCompare;
const sortOrderDelta = left.sortOrder - right.sortOrder;
if (sortOrderDelta !== 0) return sortOrderDelta;
return left.code.localeCompare(right.code, "zh-CN", { numeric: true });
});
}
function createSkuItemFromAssetSkuGroup(
group: SeriesComponentAssetSkuGroup,
index: number,
) {
const primaryVariant =
group.variants.find(
(variant) => variant.code.trim().toUpperCase() === group.code,
) ?? group.variants[0];
if (!primaryVariant) return null;
if (!group.hasSkuGroup) {
return createSkuItemFromAssetVariant(primaryVariant, index);
}
const skuItem = createSkuItemFromAssetVariant(primaryVariant, index);
return {
...skuItem,
id: `asset-sku-group-${group.id}`,
skuSequence: index + 1,
skuCode: group.code,
specName:
readAssetSkuGroupSkuContentFields(primaryVariant).specName?.trim() ||
group.name,
componentDetails: group.variants.map((variant) => ({
code: variant.code.trim().toUpperCase(),
name: variant.name.trim(),
quantity: "1",
role: variant.assetFamilyName,
assetType: variant.assetType,
})),
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
sourceSkuId: group.id,
} satisfies NonIntegratedSkuItem;
}
function buildSeriesComponentSkuItemsFromAssetVariants(
variants: AssetVariantDTO[],
families: AssetFamilyDTO[],
) {
return buildSeriesComponentAssetSkuGroups(variants, families)
.map((group, index) => createSkuItemFromAssetSkuGroup(group, index))
.filter((item): item is NonIntegratedSkuItem => item !== null);
}
function findAssetSeriesByCode(series: AssetSeriesDTO[], seriesCode: string) {
@ -4344,7 +4505,8 @@ function isSeriesComponentAssetSkuItem(item: NonIntegratedSkuItem) {
const sourceTable = normalizeSkuSourceMarker(item.sourceTable);
return (
source === SERIES_COMPONENT_ASSET_SKU_SOURCE ||
sourceTable === ASSET_VARIANT_SOURCE_TABLE
sourceTable === ASSET_VARIANT_SOURCE_TABLE ||
sourceTable === ASSET_SKU_GROUP_SOURCE_TABLE
);
}
@ -4437,8 +4599,9 @@ function mergeSeriesComponentAssetSkuItems(
model3dDataUrl: assetItem.model3dDataUrl,
model3dUvConfig: assetItem.model3dUvConfig,
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
sourceTable: assetItem.sourceTable || ASSET_VARIANT_SOURCE_TABLE,
sourceSkuId: assetItem.sourceSkuId,
contentLocked: assetItem.contentLocked === true,
};
});
}
@ -13251,10 +13414,12 @@ export default function NonIntegratedDetailClient({
getSeriesComponentSpuFromSeriesName(selectedSourceSeriesName);
const seriesProductFinishedSpu =
isSeriesDevelopment && !isSeriesComponentCreate
? selectedSeriesComponentAssetSeries?.finishedProductSpu
?.trim()
.toUpperCase() ||
metadataSeriesFinishedProductSpu
? mode === "create"
? metadataSeriesFinishedProductSpu
: selectedSeriesComponentAssetSeries?.finishedProductSpu
?.trim()
.toUpperCase() ||
metadataSeriesFinishedProductSpu
: "";
const seriesComponentProductName =
getSeriesComponentProductName(selectedSourceSeriesName);
@ -13291,7 +13456,7 @@ export default function NonIntegratedDetailClient({
const developmentDetailSuffix = isSeriesDevelopment
? isSeriesComponentDetailProduct
? "部件开发明细"
: "系列产品开发明细"
: "模块化产品明细"
: "产品开发明细";
const developmentCreateButtonLabel = isSeriesComponentCreate
? "创建部件"
@ -13723,6 +13888,8 @@ export default function NonIntegratedDetailClient({
const activeSkuBatchNo = activeSkuBatch === "all" ? null : activeSkuBatch;
const formalSpuValue = formValues.spu.trim();
const hasFormalSpu = formalSpuValue.length > 0;
const isManualSeriesProductSpu =
isSeriesDevelopment && !isSeriesComponentCreate;
const spuRuleProductCategory =
formValues.productCategory.trim() || openingReference.productCategory.trim();
const createSalesCategory =
@ -13757,11 +13924,6 @@ export default function NonIntegratedDetailClient({
: spuRuleProductCategory
? "未配置SPU规则"
: "选择类目后自动预估";
const shouldWaitForSeriesProductAssetSeries =
isSeriesDevelopment &&
!isSeriesComponentCreate &&
selectedAssetSeriesCode.length > 0 &&
!selectedSeriesComponentAssetSeries;
const seriesCreateEstimatedSpuLabel = isSeriesComponentCreate
? seriesComponentSpu || "选择系列后自动生成"
: seriesProductFinishedSpu
@ -13782,16 +13944,15 @@ export default function NonIntegratedDetailClient({
isStockProductCreate ||
(isSeriesComponentCreate
? Boolean(selectedSourceSeriesName && seriesComponentSpu)
: shouldWaitForSeriesProductAssetSeries
? false
: isSeriesDevelopment && seriesProductFinishedSpu
? Boolean(createSalesCategory && createProductCategory)
: Boolean(
createSalesCategory &&
createProductCategory &&
estimatedSpuAssignment,
));
const isSpuCategoryLocked = hasFormalSpu && !isStockProductRecord;
: isManualSeriesProductSpu
? formalSpuValue.length > 0
: Boolean(
createSalesCategory &&
createProductCategory &&
estimatedSpuAssignment,
));
const isSpuCategoryLocked =
hasFormalSpu && !isStockProductRecord && !isManualSeriesProductSpu;
const skuCodePreviewSpu =
seriesProductFinishedSpu || formalSpuValue || estimatedFormalSpu;
const derivedSkuItems = useMemo(
@ -18790,7 +18951,9 @@ export default function NonIntegratedDetailClient({
brand: nextBrand,
adaptableBrands: nextAdaptableBrands,
spu: current.spu
? applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand)
? isManualSeriesProductSpu
? current.spu
: applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand)
: current.spu,
}));
syncBasicInfoFieldToOpeningReference(key, nextBrand, nextAdaptableBrands);
@ -18817,6 +18980,18 @@ export default function NonIntegratedDetailClient({
scheduleAutoSave();
}
function updateManualSeriesProductSpu(value: string) {
if (isReadOnly) return;
setFormValuesAndRef((current) => ({
...current,
spu: value.toUpperCase(),
}));
setSaveMessage("");
setSaveMessageType("success");
scheduleAutoSave();
}
function handleStockProductSpuChange(value: string) {
const normalizedValue = value.toUpperCase();
if (!normalizedValue.trim()) {
@ -24266,7 +24441,7 @@ export default function NonIntegratedDetailClient({
componentSpu: getSeriesComponentSpuFromAssetSeries(
selectedSeriesComponentAssetSeries,
),
finishedProductSpu: seriesProductFinishedSpu || null,
finishedProductSpu: formalSpuValue || null,
}
: undefined;
const draftProductName =
@ -24290,10 +24465,14 @@ export default function NonIntegratedDetailClient({
? seriesComponentSpu
: isStockProductCreate
? stockSpu
: seriesProductFinishedSpu || (shouldSaveRemotely ? "" : estimatedFormalSpu);
: isManualSeriesProductSpu
? formalSpuValue
: shouldSaveRemotely
? ""
: estimatedFormalSpu;
let formalSpuAssignmentProductCategory = "";
if (!isStockProductCreate && !isSeriesComponentCreate && !seriesProductFinishedSpu) {
if (!isStockProductCreate && !isSeriesComponentCreate && !isSeriesDevelopment) {
const productCategory =
formValues.productCategory.trim() ||
openingReference.productCategory.trim();
@ -30797,7 +30976,6 @@ function renderFactoryQualityWorkspace() {
/>
<BasicField
label="品类"
required={isSeriesDevelopment && !isSeriesComponentCreate}
placeholder="如:桌类"
options={basicInfoSecondaryCategoryOptions}
value={
@ -30815,7 +30993,6 @@ function renderFactoryQualityWorkspace() {
/>
<BasicField
label="类目"
required={isSeriesDevelopment && !isSeriesComponentCreate}
placeholder="如:书桌"
options={basicInfoProductCategoryOptions}
value={
@ -30834,10 +31011,24 @@ function renderFactoryQualityWorkspace() {
{isSeriesDevelopment ? (
<BasicField
label="SPU"
value={seriesCreateEstimatedSpuLabel}
readOnly
required={!isSeriesComponentCreate}
placeholder={
isSeriesComponentCreate
? "选择系列后自动生成"
: "请输入模块化SPU"
}
value={
isSeriesComponentCreate
? seriesCreateEstimatedSpuLabel
: formValues.spu
}
readOnly={isReadOnly || isSeriesComponentCreate}
compact
onChange={() => undefined}
onChange={
isSeriesComponentCreate
? () => undefined
: updateManualSeriesProductSpu
}
/>
) : (
<>
@ -31732,6 +31923,9 @@ function renderFactoryQualityWorkspace() {
isSkuContentReadOnly ||
isSkuItemFactoryConfirmed(item) ||
isImportedSkuContentLocked(item);
const isStockLinkedSkuRow =
item.contentLocked === true &&
isSeriesComponentAssetSkuItem(item);
const skuImageReadOnly =
isReadOnly ||
isSkuItemFactoryConfirmed(item) ||
@ -31828,25 +32022,32 @@ function renderFactoryQualityWorkspace() {
</div>
</td>
<td className="px-2 py-2">
<SkuRowField
value={item.model}
placeholder="请填写"
readOnly={skuRowReadOnly}
columnWidth={skuColumnWidths.model}
onColumnResize={(width) =>
handleSkuColumnResize(
"model",
width,
)
}
onChange={(value) =>
updateSkuItem(
item.id,
"model",
value,
)
}
/>
<div className="space-y-1">
{isStockLinkedSkuRow ? (
<span className="inline-flex rounded-[4px] bg-[#eef7f0] px-1.5 py-0.5 text-[10px] font-apple-semibold text-[#2d6a3f] ring-1 ring-[#cce8d2]">
存量商品
</span>
) : null}
<SkuRowField
value={item.model}
placeholder="请填写"
readOnly={skuRowReadOnly}
columnWidth={skuColumnWidths.model}
onColumnResize={(width) =>
handleSkuColumnResize(
"model",
width,
)
}
onChange={(value) =>
updateSkuItem(
item.id,
"model",
value,
)
}
/>
</div>
</td>
<td className="px-2 py-2">
<SkuRowField

View File

@ -47,6 +47,14 @@ type AssemblyHoleVisualType =
| 'assembly-candidate'
| 'assembly-active'
interface VariantSkuGroupBrowserItem {
key: string
code: string
name: string
hasSkuGroup: boolean
variants: AssistedAssemblyVariant[]
}
const ASSEMBLY_HOLE_STATE_PRIORITY: Record<AssemblyHoleVisualType, number> = {
'assembly-unused': 0,
'assembly-used': 1,
@ -239,6 +247,46 @@ function buildAvailableVariantMap(availableVariants: AssistedAssemblyVariant[])
return map
}
function normalizeSkuGroupCode(value: string | null | undefined) {
const normalized = value?.trim().toUpperCase()
return normalized || null
}
function buildVariantSkuGroupItems(variants: AssistedAssemblyVariant[]) {
const groupMap = new Map<string, VariantSkuGroupBrowserItem>()
variants.forEach((variant) => {
const skuGroupCode = normalizeSkuGroupCode(variant.skuGroupCode)
const skuGroupId = variant.skuGroupId?.trim() || null
const key = skuGroupCode ? `code:${skuGroupCode}` : skuGroupId ? `group:${skuGroupId}` : `variant:${variant.id}`
const fallbackCode = variant.code?.trim() || variant.name
const current = groupMap.get(key)
if (current) {
current.variants.push(variant)
return
}
groupMap.set(key, {
key,
code: skuGroupCode ?? fallbackCode,
name: variant.skuGroupName?.trim() || variant.name,
hasSkuGroup: Boolean(skuGroupCode || skuGroupId),
variants: [variant],
})
})
return Array.from(groupMap.values())
}
function getVariantSkuGroupAriaLabel(group: VariantSkuGroupBrowserItem) {
const firstVariant = group.variants[0]
if (!group.hasSkuGroup) {
return `加载${firstVariant?.name ?? group.code}到 3D 世界`
}
return `加载货号${group.code}下${group.variants.length}个模型到 3D 世界`
}
function getInventoryPreviewVariant(
item: AssemblyWorkspaceInventoryItem,
availableVariantMap: Map<string, AssistedAssemblyVariant[]>,
@ -589,6 +637,11 @@ export function AssemblyWorkbenchV2({
return availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []
}, [availableVariantMap, selectedBrowserInventoryItem])
const selectedBrowserVariantGroups = useMemo(
() => buildVariantSkuGroupItems(selectedBrowserVariants),
[selectedBrowserVariants],
)
const availableVariantById = useMemo(
() => new Map(availableVariants.map((variant) => [variant.id, variant])),
[availableVariants],
@ -889,27 +942,26 @@ export function AssemblyWorkbenchV2({
[closeContextMenu, pushWorkspace, readOnly],
)
const handleVariantActivate = useCallback(
(inventoryId: string, variant: AssistedAssemblyVariant) => {
if (readOnly) return
const handleVariantGroupActivate = useCallback(
(inventoryId: string, group: VariantSkuGroupBrowserItem) => {
if (readOnly || group.variants.length === 0) return
const current = workspaceRef.current
const inventoryItem = current.inventory.find((item) => item.inventoryId === inventoryId)
if (!inventoryItem) return
const withSelection =
inventoryItem.selectedVariantId === variant.id
? current
: updateInventoryVariantSelection(current, inventoryId, variant.id)
const next = instantiateAssemblyNode(withSelection, {
inventoryId,
variant: toOpenProductVariant(variant),
let next = current
group.variants.forEach((variant) => {
next = updateInventoryVariantSelection(next, inventoryId, variant.id)
next = instantiateAssemblyNode(next, {
inventoryId,
variant: toOpenProductVariant(variant),
})
})
pushWorkspace(next)
const latestNode = next.nodes[next.nodes.length - 1]
const addedNodeIds = next.nodes.slice(current.nodes.length).map((node) => node.nodeId)
suppressEmptySelectionUntilRef.current = Date.now() + 320
setSelectedNodeIds(latestNode ? [latestNode.nodeId] : [])
setSelectedNodeIds(addedNodeIds)
setSelectedInventoryId(inventoryId)
setActivePreset(null)
closeContextMenu()
@ -1184,7 +1236,7 @@ export function AssemblyWorkbenchV2({
updateAssetBrowserVariantPanelFrame(
selectedBrowserInventoryItem?.inventoryId ?? null,
undefined,
selectedBrowserVariants.length,
selectedBrowserVariantGroups.length,
)
}
window.addEventListener('resize', handleResize)
@ -1195,7 +1247,7 @@ export function AssemblyWorkbenchV2({
activeAssetBrowserSource,
activeAssetBrowserItems.length,
selectedBrowserInventoryItem,
selectedBrowserVariants.length,
selectedBrowserVariantGroups.length,
updateAssetBrowserPanelFrame,
updateAssetBrowserVariantPanelFrame,
])
@ -1217,12 +1269,12 @@ export function AssemblyWorkbenchV2({
updateAssetBrowserVariantPanelFrame(
selectedBrowserInventoryItem.inventoryId,
undefined,
selectedBrowserVariants.length,
selectedBrowserVariantGroups.length,
)
}, [
activeAssetBrowserSource,
selectedBrowserInventoryItem,
selectedBrowserVariants.length,
selectedBrowserVariantGroups.length,
updateAssetBrowserVariantPanelFrame,
])
@ -1344,6 +1396,7 @@ export function AssemblyWorkbenchV2({
const previewVariant = getInventoryPreviewVariant(item, availableVariantMap)
const selected = selectedBrowserInventoryItem?.inventoryId === item.inventoryId
const itemVariants = availableVariantMap.get(item.familyId) ?? []
const itemVariantGroups = buildVariantSkuGroupItems(itemVariants)
const SourceIcon =
ASSET_BROWSER_TABS.find((tab) => tab.source === item.source)?.icon ?? Layers3
return (
@ -1360,13 +1413,13 @@ export function AssemblyWorkbenchV2({
aria-label={`选择${item.familyName}资产族`}
aria-pressed={selected}
onMouseEnter={(event) =>
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariants.length)
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariantGroups.length)
}
onFocus={(event) =>
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariants.length)
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariantGroups.length)
}
onClick={(event) =>
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariants.length)
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariantGroups.length)
}
className={cn(
'group relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf1f2] text-left transition duration-200 focus:outline-none',
@ -1403,7 +1456,7 @@ export function AssemblyWorkbenchV2({
{item.familyName}
</p>
<p className="mt-0.5 truncate text-[8px] font-bold text-white/58">
{itemVariants.length} 款 · 入场 {item.instanceCount}
{itemVariantGroups.length} 货号 · 入场 {item.instanceCount}
</p>
</div>
</button>
@ -1425,37 +1478,46 @@ export function AssemblyWorkbenchV2({
className="absolute left-[104px] w-[184px] overflow-y-auto overscroll-contain pr-1"
>
<div className="grid grid-cols-2 gap-2">
{(availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []).length > 0 ? (
(availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []).map((variant) => (
<button
key={variant.id}
type="button"
aria-label={`加载${variant.name}到 3D 世界`}
disabled={readOnly}
onClick={() => handleVariantActivate(selectedBrowserInventoryItem.inventoryId, variant)}
className="group relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf1f2] text-left opacity-[0.82] transition duration-200 hover:-translate-y-0.5 hover:opacity-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:translate-y-0"
>
<AssemblyMaterialImage
sources={getAssistedAssemblyVariantThumbnailSources(variant)}
alt={variant.name}
className="absolute inset-0 h-full w-full object-cover object-center transition duration-200 group-hover:scale-[1.03]"
placeholder={
<div className="absolute inset-0 flex items-center justify-center bg-[#edf1f2]">
<Layers3 className="h-6 w-6 text-[#5a6062]/45" strokeWidth={1.5} />
</div>
}
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#2d3335]/78 via-[#2d3335]/20 to-transparent opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100" />
<div className="relative flex h-full flex-col justify-end px-2 py-2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100">
<p className="truncate text-[8px] font-black uppercase tracking-[0.08em] text-white/66">
{getVariantMeta(variant)}
</p>
<p className="mt-0.5 line-clamp-2 text-[11px] font-black leading-[1.15] text-white">
{variant.name}
</p>
</div>
</button>
))
{selectedBrowserVariantGroups.length > 0 ? (
selectedBrowserVariantGroups.map((group) => {
const previewVariant = group.variants[0]
const isSingleFallback = !group.hasSkuGroup && group.variants.length === 1
return (
<button
key={group.key}
type="button"
aria-label={getVariantSkuGroupAriaLabel(group)}
disabled={readOnly}
onClick={() => handleVariantGroupActivate(selectedBrowserInventoryItem.inventoryId, group)}
className="group relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf1f2] text-left opacity-[0.82] transition duration-200 hover:-translate-y-0.5 hover:opacity-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:translate-y-0"
>
<AssemblyMaterialImage
sources={getAssistedAssemblyVariantThumbnailSources(previewVariant)}
alt={previewVariant?.name ?? group.name}
className="absolute inset-0 h-full w-full object-cover object-center transition duration-200 group-hover:scale-[1.03]"
placeholder={
<div className="absolute inset-0 flex items-center justify-center bg-[#edf1f2]">
<Layers3 className="h-6 w-6 text-[#5a6062]/45" strokeWidth={1.5} />
</div>
}
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#2d3335]/78 via-[#2d3335]/20 to-transparent opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100" />
<div className="relative flex h-full flex-col justify-end px-2 py-2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100">
<p className="truncate text-[8px] font-black uppercase tracking-[0.08em] text-white/66">
{isSingleFallback && previewVariant ? getVariantMeta(previewVariant) : '货号'}
</p>
<p className="mt-0.5 line-clamp-2 text-[11px] font-black leading-[1.15] text-white">
{isSingleFallback && previewVariant ? previewVariant.name : group.code}
</p>
{!isSingleFallback ? (
<p className="mt-0.5 truncate text-[8px] font-bold text-white/62">
{group.variants.length} 模型
</p>
) : null}
</div>
</button>
)
})
) : (
<div className="col-span-2 flex h-[86px] w-[180px] items-center justify-center rounded-[6px] bg-white px-3 text-center text-xs font-bold text-[#5a6062]/55 ring-1 ring-black/[0.04]">
当前资产族暂无可加载规格款

View File

@ -406,10 +406,12 @@ function ControlledWorkbench({
onSnapshotChange,
rightDrawerContent,
initialWorkspace,
availableVariants = AVAILABLE_VARIANTS,
}: {
onSnapshotChange?: (next: AssemblyWorkspaceSnapshot) => void
rightDrawerContent?: ReactNode
initialWorkspace?: AssemblyWorkspaceSnapshot
availableVariants?: AssistedAssemblyVariant[]
}) {
const [workspace, setWorkspace] = useState(() => initialWorkspace ?? buildWorkspace())
@ -420,7 +422,7 @@ function ControlledWorkbench({
candidateName="测试工作台"
statusTag="辅助装配工作台"
warnings={[]}
availableVariants={AVAILABLE_VARIANTS}
availableVariants={availableVariants}
onChange={(next) => {
setWorkspace(next)
onSnapshotChange?.(next)
@ -430,6 +432,33 @@ function ControlledWorkbench({
)
}
function buildSkuGroupedVariants(): AssistedAssemblyVariant[] {
const baseVariants = buildVariants()
const legVariant = baseVariants[1]!
return [
baseVariants[0]!,
{
...legVariant,
id: 'variant-leg-sku-group-1',
code: 'LEG-GROUP-001',
name: '桌腿货号模型一',
skuGroupId: 'sku-group-dnz038401-a',
skuGroupCode: 'DNZ038401',
skuGroupName: 'DNZ038401',
} as unknown as AssistedAssemblyVariant,
{
...legVariant,
id: 'variant-leg-sku-group-2',
code: 'LEG-GROUP-002',
name: '桌腿货号模型二',
skuGroupId: 'sku-group-dnz038401-b',
skuGroupCode: 'DNZ038401',
skuGroupName: 'DNZ038401',
} as unknown as AssistedAssemblyVariant,
]
}
function buildHoleSnapWorkspace() {
const baseWorkspace = buildWorkspace()
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
@ -917,6 +946,46 @@ describe('AssemblyWorkbenchV2', () => {
expect(latestSnapshot.nodes[1]?.zone).toBe('staging')
})
it('按货号展示规格组,点击货号会一次加载该货号下所有模型', async () => {
const actor = userEvent.setup()
const onChange = jest.fn()
render(
<ControlledWorkbench
availableVariants={buildSkuGroupedVariants()}
onSnapshotChange={onChange}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
await actor.click(screen.getByRole('button', { name: '打开组件物料' }))
await waitFor(() => {
expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '选择桌腿A资产族' })).toBeInTheDocument()
})
await actor.click(screen.getByRole('button', { name: '选择桌腿A资产族' }))
const skuGroupButton = screen.getByRole('button', {
name: '加载货号DNZ038401下2个模型到 3D 世界',
})
expect(skuGroupButton).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '加载桌腿货号模型一到 3D 世界' })).not.toBeInTheDocument()
await actor.click(skuGroupButton)
await waitFor(() => {
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
})
const latestSnapshot = onChange.mock.calls.at(-1)?.[0] as AssemblyWorkspaceSnapshot
expect(latestSnapshot.nodes).toHaveLength(2)
expect(latestSnapshot.nodes.map((node) => node.variantId)).toEqual([
'variant-leg-sku-group-1',
'variant-leg-sku-group-2',
])
})
it('带孔位装配语义的世界节点会保留模型原点,避免孔位坐标和模型实体错位', async () => {
const actor = userEvent.setup()

View File

@ -31,7 +31,7 @@ export const SUPPORTED_FORMATS = ['.gltf', '.glb'] as const
export const MAX_FILE_SIZE_MB = 100
interface ModelUploaderProps {
onUpload: (file: File) => void
onUpload: (file: File) => void | Promise<void>
onRemove?: () => void
onValidationError?: (error: FileValidationError) => void
accept?: string
@ -63,26 +63,31 @@ export function ModelUploader({
const [isDragging, setIsDragging] = useState(false)
const [error, setError] = useState<FileValidationError | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const allowedFormats = accept
.split(',')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.startsWith('.'))
const effectiveSupportedFormats = allowedFormats.length > 0 ? allowedFormats : [...SUPPORTED_FORMATS]
/**
* 验证文件格式
* 只接受 .gltf 和 .glb 扩展名
* 默认只接受 .gltf 和 .glb,可由调用方通过 accept 扩展。
*/
const validateFileFormat = useCallback((file: File): FileValidationError | null => {
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'))
if (!SUPPORTED_FORMATS.includes(extension as typeof SUPPORTED_FORMATS[number])) {
if (!effectiveSupportedFormats.includes(extension)) {
return {
code: 'INVALID_FORMAT',
message: `不支持的文件格式 "${extension}",请上传 GLTF 或 GLB 格式的 3D 模型文件`,
message: `不支持的文件格式 "${extension}",请上传支持的 3D 模型或 CAD ZIP 文件`,
details: {
allowedFormats: [...SUPPORTED_FORMATS],
allowedFormats: effectiveSupportedFormats,
actualFormat: extension,
},
}
}
return null
}, [])
}, [effectiveSupportedFormats])
/**
* 验证文件大小
@ -152,7 +157,14 @@ export function ModelUploader({
return
}
setError(null)
onUpload(file)
void Promise.resolve(onUpload(file)).catch((uploadError) => {
const uploadValidationError: FileValidationError = {
code: 'UNKNOWN_ERROR',
message: uploadError instanceof Error ? uploadError.message : '文件上传失败',
}
setError(uploadValidationError)
onValidationError?.(uploadValidationError)
})
}, [validateFile, onUpload, onValidationError])
// 拖拽事件处理
@ -294,8 +306,14 @@ export function ModelUploader({
{/* 支持的格式 */}
<div className="flex items-center justify-center gap-3 text-xs text-apple-label-tertiary mt-2">
<span className="px-1.5 py-0.5 bg-apple-bg-secondary rounded-apple-sm">.gltf</span>
<span className="px-1.5 py-0.5 bg-apple-bg-secondary rounded-apple-sm">.glb</span>
{effectiveSupportedFormats.map((format) => (
<span
key={format}
className="px-1.5 py-0.5 bg-apple-bg-secondary rounded-apple-sm"
>
{format}
</span>
))}
<span>最大 {maxSize}MB</span>
</div>
</div>

View File

@ -0,0 +1,255 @@
import type { CADExportData } from '@/lib/cad-export-processor'
import { buildPersistedSkuCadData, type PersistedSkuCadData } from '@/lib/sku-cad-data'
export type CadAssetPayloadType = 'BOARD' | 'COMPONENT' | 'HARDWARE'
function toSafeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function toVector3(value: unknown, fallback: [number, number, number] = [0, 0, 0]): [number, number, number] {
if (!Array.isArray(value) || value.length < 3) {
return fallback
}
return [
toSafeNumber(value[0]),
toSafeNumber(value[1]),
toSafeNumber(value[2]),
]
}
function getModelBoundsDimensions(modelBounds: unknown): [number, number, number] | null {
if (!Array.isArray(modelBounds) || modelBounds.length < 6) {
return null
}
return [
Math.abs(toSafeNumber(modelBounds[3]) - toSafeNumber(modelBounds[0])),
Math.abs(toSafeNumber(modelBounds[4]) - toSafeNumber(modelBounds[1])),
Math.abs(toSafeNumber(modelBounds[5]) - toSafeNumber(modelBounds[2])),
]
}
export function buildCadDataFromExport(cadExportData: CADExportData): PersistedSkuCadData | null {
return buildPersistedSkuCadData({
pluginVersion: cadExportData.pluginVersion,
units: cadExportData.units,
coordinateSystem: cadExportData.coordinateSystem,
panels: cadExportData.panels,
holes: cadExportData.holes,
holeGroups: (cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
snapPatterns: cadExportData.snapPatterns,
snapSurfaces: cadExportData.snapSurfaces,
mountPatterns: (cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
hardwareTypes: cadExportData.hardwareTypes,
hardwareSummary: cadExportData.hardwareSummary,
})
}
export function buildCadAssetDimensions(params: {
assetType: CadAssetPayloadType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
warnings?: string[]
}): Record<string, number> {
if (params.assetType === 'BOARD') {
const panel = params.cadData?.panels[0]
if (panel) {
if ((params.cadData?.panels.length ?? 0) > 1) {
params.warnings?.push('检测到多个板件,已按第一块板件尺寸导入规格款。')
}
return {
length: panel.size[0],
width: panel.size[1],
thickness: panel.size[2],
}
}
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
if (bounds) {
params.warnings?.push('CAD 数据未提供板件尺寸,已按模型包围盒估算板材尺寸。')
return {
length: bounds[0],
width: bounds[1],
thickness: bounds[2],
}
}
throw new Error('板材导入至少需要一块板件或可用的模型包围盒尺寸')
}
if (params.assetType === 'COMPONENT') {
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
if (bounds) {
return {
length: bounds[0],
width: bounds[1],
height: bounds[2],
}
}
const panel = params.cadData?.panels[0]
if (panel) {
params.warnings?.push('组件导入未提供整体包围盒,已按第一块板件尺寸回填组件尺寸。')
return {
length: panel.size[0],
width: panel.size[1],
height: panel.size[2],
}
}
throw new Error('组件导入缺少可用尺寸信息,请确认导出包包含包围盒或板件数据')
}
const signature = params.cadExportData.hardwareTypes?.[0]?.signature
if (signature) {
return {
width: signature.width,
height: signature.height,
depth: signature.depth,
}
}
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
if (bounds) {
params.warnings?.push('五金导入未提供规格签名,已按模型包围盒估算尺寸。')
return {
width: bounds[0],
height: bounds[1],
depth: bounds[2],
}
}
throw new Error('五金导入缺少可用尺寸信息,请确认导出包包含五金签名或模型包围盒')
}
export function buildCadAssetSizeLabel(
assetType: CadAssetPayloadType,
dimensions: Record<string, number>,
): string | null {
if (assetType === 'BOARD') {
const { length, width, thickness } = dimensions
if (length && width && thickness) {
return `${length}x${width}x${thickness}`
}
}
if (assetType === 'COMPONENT') {
const { length, width, height } = dimensions
if (length && width && height) {
return `${length}x${width}x${height}`
}
}
if (assetType === 'HARDWARE') {
const { width, height, depth } = dimensions
if (width && height && depth) {
return `${width}x${height}x${depth}`
}
}
return null
}
export function buildCadAssetMountPoints(cadExportData: CADExportData) {
return [
{
name: 'origin',
position: toVector3(cadExportData.originOffset),
},
]
}
export function buildCadAssetAssemblyBasis(params: {
assetType: CadAssetPayloadType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
}): Record<string, unknown> {
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
const snapPatterns =
params.assetType === 'HARDWARE'
? []
: (params.cadData?.snapPatterns?.length
? params.cadData.snapPatterns
: (params.cadData?.mountPatterns ?? []))
const mountPatterns =
params.assetType === 'HARDWARE'
? []
: (params.cadData?.mountPatterns?.length ? params.cadData.mountPatterns : snapPatterns)
const snapSurfaces = params.assetType === 'HARDWARE' ? [] : (params.cadData?.snapSurfaces ?? [])
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
const suggestedFastenerCount = holes.reduce(
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
0,
)
return {
source: 'CAD_SINGLE_ASSET_IMPORT',
assetType: params.assetType,
pluginVersion: params.cadExportData.pluginVersion,
units: params.cadExportData.units,
sourceFile: params.cadExportData.sourceFile,
panelCount: params.cadData?.panels.length ?? 0,
holeCount: holes.length,
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
snapPatternCount: snapPatterns.length,
snapSurfaceCount: snapSurfaces.length,
mountPatternCount: mountPatterns.length,
suggestedFastenerCount,
holes,
holeGroups,
snapPatterns,
snapSurfaces,
mountPatterns,
edgeFeatureCount: edgeFeatures.length,
edgeFeatures,
}
}
export function buildCadAssetBusinessData(params: {
assetType: CadAssetPayloadType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
zipFileName: string
}): Record<string, unknown> {
return {
importSource: {
mode: 'CAD_SINGLE_ASSET_IMPORT',
assetType: params.assetType,
zipFileName: params.zipFileName,
sourceFile: params.cadExportData.sourceFile,
exportedAt: params.cadExportData.exportedAt,
pluginVersion: params.cadExportData.pluginVersion,
software: params.cadExportData.software,
exportMode:
typeof (params.cadExportData as CADExportData & { exportMode?: string }).exportMode === 'string'
? (params.cadExportData as CADExportData & { exportMode?: string }).exportMode
: null,
assetTypeHint:
typeof (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint === 'string'
? (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint
: null,
},
modelBounds: Array.isArray(params.cadExportData.modelBounds)
? params.cadExportData.modelBounds
: null,
cadData: params.cadData,
}
}
export function buildCadAssetNamingFields(params: {
assetType: CadAssetPayloadType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
}): Record<string, unknown> {
return {
assetType: params.assetType,
sourceFile: params.cadExportData.sourceFile,
modelFile: params.cadExportData.modelFile,
panelType: params.cadData?.panels[0]?.panelType ?? null,
hardwareType: params.cadData?.hardwareTypes[0]?.inferredHardwareType ?? null,
}
}

View File

@ -558,7 +558,7 @@ export const FORMAL_WORKSPACE_PERMISSION_TARGETS: IntakeNodePermissionTarget[] =
},
{
key: "seriesDevelopmentList",
label: "系列产品开发明细",
label: "模块化产品明细",
defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
},
{

View File

@ -45,6 +45,7 @@ describe('manual-assembly-launch', () => {
expect(launchParams).toEqual({
entryMode: 'direct',
source: 'assisted-assembly',
assetSeriesId: null,
sourceSkuId: null,
sourceProductId: 'product-1',
redirectedFrom: 'product-assembly',
@ -88,4 +89,47 @@ describe('manual-assembly-launch', () => {
})
expect(buildManualAssemblyLegacyFeedback(null)).toBeNull()
})
it('keeps sku group ids when asset center opens assisted assembly by item code', () => {
const href = buildManualAssemblyLaunchHref({
entryMode: 'asset-center',
source: 'asset-center',
assetSeriesId: 'series-1',
selectedFamilyIdsByType: {
desktopFamilyIds: [],
legFamilyIds: ['family-leg'],
connectorFamilyIds: [],
},
selectedSkuGroupIds: ['sku-group-1', 'sku-group-2'],
selectedSkuGroupCodes: ['DNZ038401'],
})
expect(href).toBe(
'/assisted-assembly?entryMode=asset-center&source=asset-center&assetSeriesId=series-1&legFamilyIds=family-leg&skuGroupIds=sku-group-1%2Csku-group-2&skuGroupCodes=DNZ038401',
)
expect(
parseManualAssemblyLaunchParams(
createSearchParamReader(
'entryMode=asset-center&source=asset-center&assetSeriesId=series-1&legFamilyIds=family-leg&skuGroupIds=sku-group-1,sku-group-2&skuGroupCodes=dnz038401',
),
),
).toEqual({
entryMode: 'asset-center',
source: 'asset-center',
assetSeriesId: 'series-1',
sourceSkuId: null,
sourceProductId: null,
selectedFamilyIdsByType: {
desktopFamilyIds: [],
legFamilyIds: ['family-leg'],
connectorFamilyIds: [],
},
selectedSkuGroupIds: ['sku-group-1', 'sku-group-2'],
selectedSkuGroupCodes: ['DNZ038401'],
redirectedFrom: null,
productRecovery: null,
recoverableSourceSkuCount: null,
})
})
})

View File

@ -21,6 +21,8 @@ export interface ManualAssemblyLaunchParams {
sourceSkuId?: string | null
sourceProductId?: string | null
selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType
selectedSkuGroupIds?: string[]
selectedSkuGroupCodes?: string[]
redirectedFrom?: ManualAssemblyLegacyEntry | null
productRecovery?: ManualAssemblyProductRecovery | null
recoverableSourceSkuCount?: number | null
@ -117,6 +119,14 @@ export function buildManualAssemblyLaunchHref(
searchParams.set('connectorFamilyIds', params.selectedFamilyIdsByType.connectorFamilyIds.join(','))
}
if (params.selectedSkuGroupIds && params.selectedSkuGroupIds.length > 0) {
searchParams.set('skuGroupIds', params.selectedSkuGroupIds.join(','))
}
if (params.selectedSkuGroupCodes && params.selectedSkuGroupCodes.length > 0) {
searchParams.set('skuGroupCodes', params.selectedSkuGroupCodes.join(','))
}
return `/assisted-assembly?${searchParams.toString()}`
}
@ -128,10 +138,16 @@ export function parseManualAssemblyLaunchParams(
legFamilyIds: normalizeIds(searchParams.get('legFamilyIds')),
connectorFamilyIds: normalizeIds(searchParams.get('connectorFamilyIds')),
}
const selectedSkuGroupIds = normalizeIds(searchParams.get('skuGroupIds'))
const selectedSkuGroupCodes = normalizeIds(searchParams.get('skuGroupCodes')).map((code) =>
code.toUpperCase(),
)
const hasSelectedFamilies =
selectedFamilyIdsByType.desktopFamilyIds.length > 0 ||
selectedFamilyIdsByType.legFamilyIds.length > 0 ||
selectedFamilyIdsByType.connectorFamilyIds.length > 0
const hasSelectedSkuGroups =
selectedSkuGroupIds.length > 0 || selectedSkuGroupCodes.length > 0
const entryModeValue = searchParams.get('entryMode')?.trim()
const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null
@ -147,7 +163,13 @@ export function parseManualAssemblyLaunchParams(
? entryModeValue
: 'direct'
if (entryMode === 'direct' && !hasSelectedFamilies && !sourceSkuId && !redirectedFrom) {
if (
entryMode === 'direct' &&
!hasSelectedFamilies &&
!hasSelectedSkuGroups &&
!sourceSkuId &&
!redirectedFrom
) {
return null
}
@ -157,7 +179,7 @@ export function parseManualAssemblyLaunchParams(
? 'asset-center'
: 'assisted-assembly'
return {
const launchParams: ManualAssemblyLaunchParams = {
entryMode,
source,
assetSeriesId,
@ -168,6 +190,16 @@ export function parseManualAssemblyLaunchParams(
productRecovery,
recoverableSourceSkuCount,
}
if (selectedSkuGroupIds.length > 0) {
launchParams.selectedSkuGroupIds = selectedSkuGroupIds
}
if (selectedSkuGroupCodes.length > 0) {
launchParams.selectedSkuGroupCodes = selectedSkuGroupCodes
}
return launchParams
}
export function buildManualAssemblyLegacyFeedback(

View File

@ -5,6 +5,7 @@ import type {
AssetCadImportResult,
AssetSeriesDTO,
AssetSeriesComponentDetailSummaryDTO,
AssetSkuGroupDTO,
AssetVariantDTO,
AssetVariantBindingDTO,
PreSkuBindingDTO,
@ -14,6 +15,7 @@ import type {
AssetOperationProgressDTO,
CreateAssetFamilyInput,
CreateAssetSeriesInput,
CreateAssetSkuGroupInput,
CreateAssetVariantInput,
CreateDraftGroupFromManualAssemblyInput,
CreateSubstrateDictionaryInput,
@ -36,6 +38,7 @@ import type {
UpdatePreSkuBasicInfoInput,
WarehouseLocationDTO,
UpdateAssetSeriesInput,
UpdateAssetSkuGroupInput,
AssistedAssemblyAiRenderDTO,
} from '@/server/services'
import type { MaterialDTO } from '@/server/services'
@ -165,6 +168,50 @@ export async function listAssetVariantsByFamilyIds(
return data.variants
}
export async function listAssetSkuGroupsByFamilyIds(
familyIds: string[],
): Promise<AssetSkuGroupDTO[]> {
if (familyIds.length === 0) {
return []
}
const searchParams = new URLSearchParams()
familyIds.forEach((familyId) => {
searchParams.append('familyId', familyId)
})
const data = await requestPlatform<{ skuGroups: AssetSkuGroupDTO[] }>(
`/api/asset-center/sku-groups?${searchParams.toString()}`,
)
return data.skuGroups
}
export async function createAssetSkuGroup(
input: CreateAssetSkuGroupInput,
): Promise<AssetSkuGroupDTO> {
const data = await requestPlatform<{ skuGroup: AssetSkuGroupDTO }>('/api/asset-center/sku-groups', {
method: 'POST',
body: JSON.stringify(input),
})
return data.skuGroup
}
export async function updateAssetSkuGroup(
id: string,
input: UpdateAssetSkuGroupInput,
): Promise<AssetSkuGroupDTO> {
const data = await requestPlatform<{ skuGroup: AssetSkuGroupDTO }>(`/api/asset-center/sku-groups/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
})
return data.skuGroup
}
export async function deleteAssetSkuGroup(id: string): Promise<void> {
await requestPlatform<{ success: boolean }>(`/api/asset-center/sku-groups/${id}`, {
method: 'DELETE',
})
}
export async function updateAssetFamily(
id: string,
input: Record<string, unknown>,

View File

@ -28,6 +28,9 @@ export interface OpenProductAssetFamily {
export interface OpenProductAssetVariant {
id: string
assetFamilyId: string
skuGroupId?: string | null
skuGroupCode?: string | null
skuGroupName?: string | null
code: string
name: string
assetType: OpenProductAssetType

View File

@ -61,12 +61,12 @@ export const ROLE_WEIGHTS = {
// ============ 文件上传 ============
export const UPLOAD = {
/** 最大文件大小:50MB */
MAX_FILE_SIZE: 50 * 1024 * 1024,
/** 最大文件大小:200MB */
MAX_FILE_SIZE: 200 * 1024 * 1024,
/** 允许的图片类型 */
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
/** 允许的模型类型 */
ALLOWED_MODEL_TYPES: ['.gltf', '.glb', '.dwg'],
ALLOWED_MODEL_TYPES: ['.gltf', '.glb', '.obj', '.dwg'],
/** 缩略图尺寸 */
THUMBNAIL_SIZE: { width: 300, height: 300 },
} as const

View File

@ -1,4 +1,4 @@
import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client'
import { AssetFamily, AssetSeries, AssetSkuGroup, AssetVariant, Prisma, Substrate } from '@prisma/client'
import { BaseRepository, FindManyOptions } from './base.repository'
import {
buildDeletedAssetVariantUniqueValue,
@ -72,6 +72,23 @@ export interface AssetVariantFilter {
finishId?: string
}
export interface AssetSkuGroupFilter {
assetFamilyId?: string
}
export interface AssetSkuGroupWithRelations extends AssetSkuGroup {
assetFamily: {
id: string
code: string
name: string
type: AssetFamily['type']
assetSeriesId: string | null
deletedAt: Date | null
assetSeries: { id: string; code: string; name: string } | null
}
_count?: { variants: number }
}
export interface AssetVariantWithRelations extends AssetVariant {
assetFamily: {
id: string
@ -82,6 +99,15 @@ export interface AssetVariantWithRelations extends AssetVariant {
deletedAt: Date | null
assetSeries: { id: string; code: string; name: string } | null
}
skuGroup: {
id: string
assetFamilyId: string
code: string
name: string
description: string | null
sortOrder: number
metadata: Prisma.JsonValue | null
} | null
substrate: { id: string; code: string; name: string } | null
finish: AssetCenterFinishRelation | null
creator: { id: string; name: string } | null
@ -155,6 +181,17 @@ export const assetVariantRelationInclude = {
assetSeries: { select: { id: true, code: true, name: true, metadata: true } },
},
},
skuGroup: {
select: {
id: true,
assetFamilyId: true,
code: true,
name: true,
description: true,
sortOrder: true,
metadata: true,
},
},
substrate: { select: { id: true, code: true, name: true } },
finish: {
select: {
@ -184,6 +221,21 @@ export const assetVariantRelationInclude = {
lastModifier: { select: { id: true, name: true } },
} satisfies Prisma.AssetVariantInclude
export const assetSkuGroupRelationInclude = {
assetFamily: {
select: {
id: true,
code: true,
name: true,
type: true,
assetSeriesId: true,
deletedAt: true,
assetSeries: { select: { id: true, code: true, name: true, metadata: true } },
},
},
_count: { select: { variants: true } },
} satisfies Prisma.AssetSkuGroupInclude
export const substrateRelationInclude = {
category: { select: { id: true, name: true } },
environmentGrade: { select: { id: true, name: true } },
@ -397,6 +449,83 @@ class AssetSeriesRepositoryClass extends BaseRepository<
}
}
class AssetSkuGroupRepositoryClass extends BaseRepository<
AssetSkuGroup,
Prisma.AssetSkuGroupCreateInput,
Prisma.AssetSkuGroupUpdateInput,
AssetSkuGroupFilter
> {
protected modelName = 'assetSkuGroup'
async findByIdWithRelations(id: string): Promise<AssetSkuGroupWithRelations | null> {
return this.prisma.assetSkuGroup.findUnique({
where: { id },
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations | null>
}
async findByFamilyAndCode(
assetFamilyId: string,
code: string,
): Promise<AssetSkuGroupWithRelations | null> {
return this.prisma.assetSkuGroup.findUnique({
where: {
assetFamilyId_code: {
assetFamilyId,
code: normalizeCoreIdentity(code),
},
},
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations | null>
}
async findManyByFamilyIds(assetFamilyIds: string[]): Promise<AssetSkuGroupWithRelations[]> {
if (assetFamilyIds.length === 0) return []
return this.prisma.assetSkuGroup.findMany({
where: {
assetFamilyId: { in: assetFamilyIds },
assetFamily: { is: { deletedAt: null } },
},
orderBy: [
{ assetFamilyId: 'asc' },
{ sortOrder: 'asc' },
{ code: 'asc' },
],
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations[]>
}
async createWithRelations(data: Prisma.AssetSkuGroupCreateInput): Promise<AssetSkuGroupWithRelations> {
return this.prisma.assetSkuGroup.create({
data,
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations>
}
async updateWithRelations(
id: string,
data: Prisma.AssetSkuGroupUpdateInput,
): Promise<AssetSkuGroupWithRelations> {
return this.prisma.assetSkuGroup.update({
where: { id },
data,
include: assetSkuGroupRelationInclude,
}) as Promise<AssetSkuGroupWithRelations>
}
async deleteById(id: string): Promise<void> {
await this.prisma.assetSkuGroup.delete({ where: { id } })
}
protected buildWhereClause(filter?: AssetSkuGroupFilter): Prisma.AssetSkuGroupWhereInput {
return {
assetFamilyId: filter?.assetFamilyId,
assetFamily: { is: { deletedAt: null } },
}
}
}
class AssetVariantRepositoryClass extends BaseRepository<
AssetVariant,
Prisma.AssetVariantCreateInput,
@ -712,12 +841,14 @@ class SubstrateRepositoryClass extends BaseRepository<
export const assetFamilyRepository = new AssetFamilyRepositoryClass()
export const assetSeriesRepository = new AssetSeriesRepositoryClass()
export const assetSkuGroupRepository = new AssetSkuGroupRepositoryClass()
export const assetVariantRepository = new AssetVariantRepositoryClass()
export const substrateRepository = new SubstrateRepositoryClass()
export {
AssetFamilyRepositoryClass,
AssetSeriesRepositoryClass,
AssetSkuGroupRepositoryClass,
AssetVariantRepositoryClass,
SubstrateRepositoryClass,
}

View File

@ -115,12 +115,15 @@ export {
assetFamilyRelationInclude,
assetFamilyRepository,
assetSeriesRepository,
assetSkuGroupRepository,
assetSkuGroupRelationInclude,
assetVariantRelationInclude,
assetVariantRepository,
substrateRelationInclude,
substrateRepository,
AssetFamilyRepositoryClass,
AssetSeriesRepositoryClass,
AssetSkuGroupRepositoryClass,
AssetVariantRepositoryClass,
SubstrateRepositoryClass,
} from './asset-center.repository'
@ -128,6 +131,8 @@ export type {
AssetFamilyFilter,
AssetFamilyWithRelations,
AssetCenterFinishRelation,
AssetSkuGroupFilter,
AssetSkuGroupWithRelations,
AssetVariantFilter,
AssetVariantWithRelations,
SubstrateFilter,

View File

@ -369,6 +369,7 @@ describe('draftGroupService', () => {
updatedAt: new Date('2026-03-30T08:00:00.000Z'),
} as never)
mockedAssetFamilyRepository.findByCode.mockResolvedValue(null)
mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([])
mockedAssetVariantRepository.findByCode.mockResolvedValue(null)
mockedAssetVariantRepository.findByBarcode.mockResolvedValue(null)
mockedProductRepository.findByModelNumber.mockResolvedValue(null)
@ -1397,6 +1398,77 @@ describe('draftGroupService', () => {
)
})
it('读取草稿组详情时会把资产货号对应条码补到部件快照', async () => {
mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([
{
id: 'pre-sku-1',
draftGroupId: 'draft-group-1',
sequence: 1,
defaultName: '单板桌',
customName: null,
status: 'DRAFT',
sourceCandidateKey: 'candidate-1',
familySelection: {},
variantSelection: {
slots: [
{
slotKey: 'desktop',
role: 'desktop',
assetFamilyId: 'family-desktop-a',
variantId: 'variant-desktop-a-white',
code: 'DBZ-SPJ01',
name: '桌面板',
assetType: 'BOARD',
dimensions: { length: 1400, width: 600, thickness: 25 },
weightKg: 14.5,
costPrice: 210,
modelUrl: '/models/top-a-1200.glb',
mountPoints: [],
assemblyBasis: {},
},
],
hardwareSelections: [],
},
recommendedHardware: [],
previewNodes: [],
displaySummary: null,
bomPreview: [],
weightPreview: null,
componentCodes: ['DBZ-SPJ01'],
installStepPreview: [],
notes: null,
createdAt: new Date('2026-03-30T12:00:00.000Z'),
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
packagingPlan: null,
},
] as never)
mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([
{
id: 'variant-desktop-a-white',
barcode: 'fallback-barcode',
skuGroup: {
metadata: {
skuContentFields: {
correspondingBarcode: 'DNZ038401N1440V01',
},
},
},
},
] as never)
const result = await draftGroupService.getById('draft-group-1')
expect(mockedAssetVariantRepository.findByIdsWithRelations).toHaveBeenCalledWith([
'variant-desktop-a-white',
])
expect(result.preSkus[0].variantSelection.slots[0]).toEqual(
expect.objectContaining({
barcode: 'FALLBACK-BARCODE',
correspondingBarcode: 'DNZ038401N1440V01',
}),
)
})
it('读取草稿组详情时优先返回 displaySummary 中的装配状态和 warnings', async () => {
mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([
{

View File

@ -1615,18 +1615,17 @@ describe('intakeNonIntegratedProductService', () => {
mockedRepository.findById.mockResolvedValue(componentRecord as never)
mockedRepository.update.mockResolvedValue(componentRecord as never)
mockedRepository.findActiveById.mockResolvedValue(componentRecord as never)
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
createAssetVariant({
id: 'variant-11',
code: 'DBZ-SPJ11',
sizeLabel: '140/140/0.3',
}),
createAssetVariant({
id: 'variant-12',
code: 'DBZ-SPJ12',
sizeLabel: '120/120/0.3',
}),
] as never)
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue(
Array.from({ length: 12 }, (_, index) => {
const sequence = index + 1
const skuCode = `DBZ-SPJ${String(sequence).padStart(2, '0')}`
return createAssetVariant({
id: `variant-${sequence}`,
code: skuCode,
sizeLabel: sequence === 11 ? '140/140/0.3' : sequence === 12 ? '120/120/0.3' : '',
})
}) as never,
)
await intakeNonIntegratedProductService.save(componentPayload, baseUser)
@ -1648,6 +1647,115 @@ describe('intakeNonIntegratedProductService', () => {
expect(updatePayload.skuBatchCount).toBe(1)
})
it('syncs series component detail SKU rows by asset sku group instead of model variants', async () => {
const componentPayload = createPayload({
id: 'DBZ-SPJ-1780300716699',
spu: 'DBZ-SPJ',
productCode: 'DBZ-SPJ',
productDevelopmentKind: 'series',
metadata: {
componentDetailType: 'asset-series-components',
sourceSeriesCode: 'DBZ',
},
skuItems: [
createSkuItem({
id: 'asset-variant-variant-black',
skuSequence: 1,
model: 'DBZ-SPJK03',
skuCode: 'DBZ-SPJ06',
source: 'asset_series_component',
sourceTable: 'asset_variant',
sourceSkuId: 'variant-black',
}),
createSkuItem({
id: 'asset-variant-variant-white',
skuSequence: 2,
model: 'DBZ-SPJK03',
skuCode: 'DBZ-SPJ07',
source: 'asset_series_component',
sourceTable: 'asset_variant',
sourceSkuId: 'variant-white',
}),
],
skuBatchCount: 1,
})
const componentRecord = createRecord({
id: 'DBZ-SPJ-1780300716699',
spu: 'DBZ-SPJ',
spuBase: 'DBZ-SPJ',
payload: componentPayload,
})
const skuGroup = {
id: 'sku-group-dbz038501',
assetFamilyId: 'family-packaging',
code: 'DBZ038501',
name: 'DBZ038501',
description: null,
sortOrder: 1,
metadata: {
skuContentFields: {
model: 'DBZ-SPJK03',
skuCode: 'DBZ038501',
barcode: 'DBZ038501N12V01',
specName: 'DBZ038501',
manufacturerCode: 'N12',
versionNo: 'V01',
drawingId: 'DBZ-SPJK03AV01',
},
},
}
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(componentRecord as never)
mockedRepository.update.mockResolvedValue(componentRecord as never)
mockedRepository.findActiveById.mockResolvedValue(componentRecord as never)
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
createAssetVariant({
id: 'variant-black',
code: 'DBZ-SPJ06',
name: '黑色金属',
skuGroup,
}),
createAssetVariant({
id: 'variant-white',
code: 'DBZ-SPJ07',
name: '白色金属',
skuGroup,
}),
] as never)
await intakeNonIntegratedProductService.save(componentPayload, baseUser)
const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as {
skuItems?: Array<Record<string, unknown>>
}
expect(updatePayload.skuItems).toHaveLength(1)
expect(updatePayload.skuItems?.[0]).toMatchObject({
id: 'asset-sku-group-sku-group-dbz038501',
sourceTable: 'asset_sku_group',
sourceSkuId: 'sku-group-dbz038501',
model: 'DBZ-SPJK03',
skuCode: 'DBZ038501',
barcode: 'DBZ038501N12V01',
})
expect(updatePayload.skuItems?.[0]?.componentDetails).toEqual([
{
code: 'DBZ-SPJ06',
name: '黑色金属',
quantity: '1',
role: 'Packaging family',
assetType: 'PACKAGING',
},
{
code: 'DBZ-SPJ07',
name: '白色金属',
quantity: '1',
role: 'Packaging family',
assetType: 'PACKAGING',
},
])
})
it('system-syncs existing series component detail product identity and structured SKU rows', async () => {
const existingPayload = createPayload({
id: 'DBZ-SPJ-1780300716699',

View File

@ -1,8 +1,16 @@
import { validateCADExportData, type CADExportData } from '@/lib/cad-export-processor'
import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults'
import {
buildCadAssetAssemblyBasis,
buildCadAssetBusinessData,
buildCadAssetDimensions,
buildCadAssetMountPoints,
buildCadAssetNamingFields,
buildCadAssetSizeLabel,
buildCadDataFromExport,
} from '@/lib/asset-cad-variant-payload'
import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding'
import { uploadToS3 } from '@/lib/storage'
import { buildPersistedSkuCadData, type PersistedSkuCadData } from '@/lib/sku-cad-data'
import { parseZipFile } from '@/lib/zip-parser'
import { AppError, ErrorCode } from '@/server/core/errors'
import { assetFamilyRepository } from '../repositories'
@ -71,249 +79,6 @@ function sanitizeSegment(value: string): string {
return normalized.length > 0 ? normalized : 'asset'
}
function toSafeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function toVector3(value: unknown, fallback: [number, number, number] = [0, 0, 0]): [number, number, number] {
if (!Array.isArray(value) || value.length < 3) {
return fallback
}
return [
toSafeNumber(value[0]),
toSafeNumber(value[1]),
toSafeNumber(value[2]),
]
}
function getModelBoundsDimensions(modelBounds: unknown): [number, number, number] | null {
if (!Array.isArray(modelBounds) || modelBounds.length < 6) {
return null
}
return [
Math.abs(toSafeNumber(modelBounds[3]) - toSafeNumber(modelBounds[0])),
Math.abs(toSafeNumber(modelBounds[4]) - toSafeNumber(modelBounds[1])),
Math.abs(toSafeNumber(modelBounds[5]) - toSafeNumber(modelBounds[2])),
]
}
function buildBoardDimensions(params: {
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
warnings: string[]
}): Record<string, number> {
const panel = params.cadData?.panels[0]
if (panel) {
if ((params.cadData?.panels.length ?? 0) > 1) {
params.warnings.push('检测到多个板件,已按第一块板件尺寸导入规格款。')
}
return {
length: panel.size[0],
width: panel.size[1],
thickness: panel.size[2],
}
}
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
if (bounds) {
params.warnings.push('CAD 数据未提供板件尺寸,已按模型包围盒估算板材尺寸。')
return {
length: bounds[0],
width: bounds[1],
thickness: bounds[2],
}
}
throw new AppError(ErrorCode.VALIDATION_FAILED, '板材导入至少需要一块板件或可用的模型包围盒尺寸')
}
function buildComponentDimensions(params: {
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
warnings: string[]
}): Record<string, number> {
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
if (bounds) {
return {
length: bounds[0],
width: bounds[1],
height: bounds[2],
}
}
const panel = params.cadData?.panels[0]
if (panel) {
params.warnings.push('组件导入未提供整体包围盒,已按第一块板件尺寸回填组件尺寸。')
return {
length: panel.size[0],
width: panel.size[1],
height: panel.size[2],
}
}
throw new AppError(ErrorCode.VALIDATION_FAILED, '组件导入缺少可用尺寸信息,请确认导出包包含包围盒或板件数据')
}
function buildHardwareDimensions(params: {
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
warnings: string[]
}): Record<string, number> {
const signature = params.cadExportData.hardwareTypes?.[0]?.signature
if (signature) {
return {
width: signature.width,
height: signature.height,
depth: signature.depth,
}
}
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
if (bounds) {
params.warnings.push('五金导入未提供规格签名,已按模型包围盒估算尺寸。')
return {
width: bounds[0],
height: bounds[1],
depth: bounds[2],
}
}
throw new AppError(ErrorCode.VALIDATION_FAILED, '五金导入缺少可用尺寸信息,请确认导出包包含五金签名或模型包围盒')
}
function buildDimensions(params: {
assetType: SupportedAssetImportType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
warnings: string[]
}): Record<string, number> {
if (params.assetType === 'BOARD') {
return buildBoardDimensions(params)
}
if (params.assetType === 'COMPONENT') {
return buildComponentDimensions(params)
}
return buildHardwareDimensions(params)
}
function buildDefaultSizeLabel(
assetType: SupportedAssetImportType,
dimensions: Record<string, number>,
): string | null {
if (assetType === 'BOARD') {
const { length, width, thickness } = dimensions
if (length && width && thickness) {
return `${length}x${width}x${thickness}`
}
}
if (assetType === 'COMPONENT') {
const { length, width, height } = dimensions
if (length && width && height) {
return `${length}x${width}x${height}`
}
}
if (assetType === 'HARDWARE') {
const { width, height, depth } = dimensions
if (width && height && depth) {
return `${width}x${height}x${depth}`
}
}
return null
}
function buildMountPoints(cadExportData: CADExportData) {
return [
{
name: 'origin',
position: toVector3(cadExportData.originOffset),
},
]
}
function buildAssemblyBasis(params: {
assetType: SupportedAssetImportType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
}): Record<string, unknown> {
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
const snapPatterns =
params.assetType === 'HARDWARE'
? []
: (params.cadData?.snapPatterns?.length
? params.cadData.snapPatterns
: (params.cadData?.mountPatterns ?? []))
const mountPatterns =
params.assetType === 'HARDWARE'
? []
: (params.cadData?.mountPatterns?.length ? params.cadData.mountPatterns : snapPatterns)
const snapSurfaces = params.assetType === 'HARDWARE' ? [] : (params.cadData?.snapSurfaces ?? [])
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
const suggestedFastenerCount = holes.reduce(
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
0,
)
return {
source: 'CAD_SINGLE_ASSET_IMPORT',
assetType: params.assetType,
pluginVersion: params.cadExportData.pluginVersion,
units: params.cadExportData.units,
sourceFile: params.cadExportData.sourceFile,
panelCount: params.cadData?.panels.length ?? 0,
holeCount: holes.length,
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
snapPatternCount: snapPatterns.length,
snapSurfaceCount: snapSurfaces.length,
mountPatternCount: mountPatterns.length,
suggestedFastenerCount,
holes,
holeGroups,
snapPatterns,
snapSurfaces,
mountPatterns,
edgeFeatureCount: edgeFeatures.length,
edgeFeatures,
}
}
function buildBusinessData(params: {
assetType: SupportedAssetImportType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
zipFileName: string
}): Record<string, unknown> {
return {
importSource: {
mode: 'CAD_SINGLE_ASSET_IMPORT',
assetType: params.assetType,
zipFileName: params.zipFileName,
sourceFile: params.cadExportData.sourceFile,
exportedAt: params.cadExportData.exportedAt,
pluginVersion: params.cadExportData.pluginVersion,
software: params.cadExportData.software,
exportMode:
typeof (params.cadExportData as CADExportData & { exportMode?: string }).exportMode === 'string'
? (params.cadExportData as CADExportData & { exportMode?: string }).exportMode
: null,
assetTypeHint:
typeof (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint === 'string'
? (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint
: null,
},
modelBounds: Array.isArray(params.cadExportData.modelBounds)
? params.cadExportData.modelBounds
: null,
cadData: params.cadData,
}
}
function resolveImportNames(params: {
input: ImportAssetCadInput
cadExportData: CADExportData
@ -342,20 +107,6 @@ function resolveImportNames(params: {
}
}
function buildNamingFields(params: {
assetType: SupportedAssetImportType
cadData: PersistedSkuCadData | null
cadExportData: CADExportData
}): Record<string, unknown> {
return {
assetType: params.assetType,
sourceFile: params.cadExportData.sourceFile,
modelFile: params.cadExportData.modelFile,
panelType: params.cadData?.panels[0]?.panelType ?? null,
hardwareType: params.cadData?.hardwareTypes[0]?.inferredHardwareType ?? null,
}
}
async function loadFamilyById(id: string): Promise<AssetFamilyDTO | null> {
const families = await assetCenterService.listFamilies()
return families.find((family) => family.id === id) ?? null
@ -407,19 +158,7 @@ class AssetCadImportService {
)
}
const cadData = buildPersistedSkuCadData({
pluginVersion: parseResult.cadExportData.pluginVersion,
units: parseResult.cadExportData.units,
coordinateSystem: parseResult.cadExportData.coordinateSystem,
panels: parseResult.cadExportData.panels,
holes: parseResult.cadExportData.holes,
holeGroups: (parseResult.cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
snapPatterns: parseResult.cadExportData.snapPatterns,
snapSurfaces: parseResult.cadExportData.snapSurfaces,
mountPatterns: (parseResult.cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
hardwareTypes: parseResult.cadExportData.hardwareTypes,
hardwareSummary: parseResult.cadExportData.hardwareSummary,
})
const cadData = buildCadDataFromExport(parseResult.cadExportData)
const warnings: string[] = []
const resolvedNames = resolveImportNames({
@ -427,12 +166,20 @@ class AssetCadImportService {
cadExportData: parseResult.cadExportData,
zipFileName: input.zipFile.name,
})
const dimensions = buildDimensions({
assetType: input.assetType,
cadData,
cadExportData: parseResult.cadExportData,
warnings,
})
let dimensions: Record<string, number>
try {
dimensions = buildCadAssetDimensions({
assetType: input.assetType,
cadData,
cadExportData: parseResult.cadExportData,
warnings,
})
} catch (error) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
error instanceof Error ? error.message : 'CAD 导出包缺少可用尺寸信息',
)
}
const family = await this.resolveFamily(input, resolvedNames)
const substrateId = input.assetType === 'BOARD' ? trimOrNull(input.substrateId) : null
@ -451,23 +198,23 @@ class AssetCadImportService {
assetFamilyId: family.id,
code: resolvedNames.variantCode ?? undefined,
name: resolvedNames.variantName,
sizeLabel: trimOrNull(input.sizeLabel) ?? buildDefaultSizeLabel(input.assetType, dimensions) ?? undefined,
sizeLabel: trimOrNull(input.sizeLabel) ?? buildCadAssetSizeLabel(input.assetType, dimensions) ?? undefined,
substrateId,
finishId,
dimensions,
modelUrl,
mountPoints: buildMountPoints(parseResult.cadExportData),
assemblyBasis: buildAssemblyBasis({
mountPoints: buildCadAssetMountPoints(parseResult.cadExportData),
assemblyBasis: buildCadAssetAssemblyBasis({
assetType: input.assetType,
cadData,
cadExportData: parseResult.cadExportData,
}),
namingFields: buildNamingFields({
namingFields: buildCadAssetNamingFields({
assetType: input.assetType,
cadData,
cadExportData: parseResult.cadExportData,
}),
businessData: buildBusinessData({
businessData: buildCadAssetBusinessData({
assetType: input.assetType,
cadData,
cadExportData: parseResult.cadExportData,

View File

@ -25,6 +25,7 @@ import {
buildSeriesDisplayName,
DEFAULT_SKU_VERSION_NO,
normalizeSeriesCode,
normalizeCoreIdentity,
normalizeSpuCode,
normalizeSkuVersionNo,
normalizeSupplierCode,
@ -79,6 +80,7 @@ import {
assetFamilyRepository,
assetFamilyRelationInclude,
assetSeriesRepository,
assetSkuGroupRepository,
assetVariantRepository,
assetVariantRelationInclude,
categoryRepository,
@ -88,6 +90,7 @@ import {
substrateRepository,
type AssetCenterFinishRelation,
type AssetFamilyFilter,
type AssetSkuGroupWithRelations,
type AssetVariantFilter,
type AssetFamilyWithRelations,
type AssetVariantWithRelations,
@ -100,11 +103,13 @@ import type {
AssetSeriesDTO,
AssetSeriesComponentDetailSummaryDTO,
AssetReviewActionInput,
AssetSkuGroupDTO,
AssetVariantDTO,
AssetWorkflowAuditDTO,
CreateAssetVariantResultDTO,
CreateAssetFamilyInput,
CreateAssetSeriesInput,
CreateAssetSkuGroupInput,
CreateAssetVariantInput,
DeletedAssetFamilyDTO,
DeletedAssetVariantDTO,
@ -119,6 +124,7 @@ import type {
UpdateSubstrateDictionaryInput,
UpdateAssetFamilyInput,
UpdateAssetSeriesInput,
UpdateAssetSkuGroupInput,
UpdateAssetVariantInput,
UpdateSubstrateInput,
} from './asset-center.service.types'
@ -285,6 +291,7 @@ function resolveVariantDimensionsForCreate(input: {
const SERIES_COMPONENT_DETAIL_METADATA_TYPE = 'asset-series-components'
const SERIES_COMPONENT_ASSET_SKU_SOURCE = 'asset_series_component'
const ASSET_VARIANT_SOURCE_TABLE = 'asset_variant'
const ASSET_SKU_GROUP_SOURCE_TABLE = 'asset_sku_group'
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
const NON_BOARD_ASSET_BOARD_MATERIAL_TYPE = '非ENF级'
const DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE = 'A'
@ -754,7 +761,8 @@ function isSeriesComponentAssetSkuPayloadItem(item: unknown): item is Record<str
return (
normalizeSourceMarker(item.source) === SERIES_COMPONENT_ASSET_SKU_SOURCE ||
normalizeSourceMarker(item.sourceTable) === ASSET_VARIANT_SOURCE_TABLE
normalizeSourceMarker(item.sourceTable) === ASSET_VARIANT_SOURCE_TABLE ||
normalizeSourceMarker(item.sourceTable) === ASSET_SKU_GROUP_SOURCE_TABLE
)
}
@ -796,6 +804,30 @@ function readVariantSkuContentFields(variant: AssetVariantDTO): Record<string, s
}, {})
}
function readSkuGroupSkuContentFields(variant: AssetVariantDTO): Record<string, string> {
const metadata = isRecord(variant.skuGroupMetadata) ? variant.skuGroupMetadata : null
const rawFields = metadata?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
if (!isRecord(rawFields)) return {}
return Object.entries(rawFields).reduce<Record<string, string>>((fields, [key, value]) => {
if (typeof value === 'string') {
fields[key] = value.trim()
} else if (typeof value === 'number' && Number.isFinite(value)) {
fields[key] = String(value)
}
return fields
}, {})
}
function mergeSkuGroupAndVariantSkuContentFields(
variant: AssetVariantDTO,
): Record<string, string> {
return {
...readVariantSkuContentFields(variant),
...readSkuGroupSkuContentFields(variant),
}
}
function getVariantSkuContentField(
fields: Record<string, string>,
key: string,
@ -844,18 +876,31 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
const normalizedModel = variant.assetFamilyCode.trim().toUpperCase()
const normalizedModelUrl = variant.modelUrl.trim()
const thumbnailUrl = variant.thumbnailUrl?.trim() ?? ''
const skuContentFields = readVariantSkuContentFields(variant)
const versionNo = normalizeSkuVersionNo(variant.versionNo ?? DEFAULT_SKU_VERSION_NO)
const manufacturerCode = (variant.supplierCode ?? '').trim().toUpperCase()
const barcode = (
variant.barcode ??
(manufacturerCode
? buildAssetVariantBarcode({
variantCode: normalizedCode,
supplierCode: manufacturerCode,
versionNo,
})
: '')
const skuContentFields = mergeSkuGroupAndVariantSkuContentFields(variant)
const isStockLinkedSku = Boolean(
getVariantSkuContentField(skuContentFields, 'correspondingBarcode').trim(),
)
const rawVersionNo = hasVariantSkuContentField(skuContentFields, 'versionNo')
? skuContentFields.versionNo ?? ''
: (variant.versionNo ?? readPayloadString(item, 'versionNo')) || DEFAULT_SKU_VERSION_NO
const versionNo = rawVersionNo.trim() ? normalizeSkuVersionNo(rawVersionNo) : ''
const manufacturerCode = getVariantSkuContentField(
skuContentFields,
'manufacturerCode',
variant.supplierCode ?? readPayloadString(item, 'manufacturerCode'),
).trim().toUpperCase()
const barcode = getVariantSkuContentField(
skuContentFields,
'barcode',
variant.barcode ?? (
versionNo &&
buildAssetVariantBarcode({
variantCode: normalizedCode,
supplierCode: manufacturerCode,
versionNo,
}) ||
readPayloadString(item, 'barcode')
),
).trim().toUpperCase()
const batchNo = parseSeriesComponentBatchNo(
getVariantSkuContentField(skuContentFields, 'batchNo', readPayloadString(item, 'batchNo')),
@ -866,12 +911,12 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
'attributeCode',
DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE,
).toUpperCase()
const drawingIdFallback = `${normalizedModel}${attributeCode}${versionNo}`
const drawingIdFallback = `${getVariantSkuContentField(skuContentFields, 'model', normalizedModel) || normalizedModel}${attributeCode}${versionNo}`
return {
...item,
skuSequence,
model: normalizedModel,
model: getVariantSkuContentField(skuContentFields, 'model', normalizedModel) || normalizedModel,
skuCode: normalizedCode,
barcode,
setCount: getVariantSkuContentField(skuContentFields, 'setCount', readPayloadString(item, 'setCount') || '1'),
@ -917,19 +962,133 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
sourceSkuId: variant.id,
contentLocked: isStockLinkedSku,
}
}
type SeriesComponentSkuGroupSnapshot = {
id: string
assetFamilyId: string
assetFamilyCode: string
code: string
name: string
sortOrder: number
variants: AssetVariantDTO[]
}
function sortSeriesComponentSkuGroupVariants(variants: AssetVariantDTO[]) {
return [...variants].sort((left, right) => (
left.code.localeCompare(right.code, 'zh-CN') ||
left.name.localeCompare(right.name, 'zh-CN')
))
}
function buildSeriesComponentSkuGroupSnapshots(
variants: AssetVariantDTO[],
): SeriesComponentSkuGroupSnapshot[] {
const groupById = new Map<string, SeriesComponentSkuGroupSnapshot>()
variants.forEach((variant) => {
const normalizedGroupCode = (
variant.skuGroupCode?.trim() ||
variant.code.trim()
).toUpperCase()
const normalizedGroupName =
variant.skuGroupName?.trim() ||
variant.name.trim() ||
normalizedGroupCode
const groupId = variant.skuGroupId ?? `variant:${variant.id}`
const current = groupById.get(groupId)
if (current) {
current.variants.push(variant)
return
}
groupById.set(groupId, {
id: groupId,
assetFamilyId: variant.assetFamilyId,
assetFamilyCode: variant.assetFamilyCode,
code: normalizedGroupCode,
name: normalizedGroupName,
sortOrder: variant.skuGroupSortOrder ?? Number.MAX_SAFE_INTEGER,
variants: [variant],
})
})
return Array.from(groupById.values())
.map((group) => ({
...group,
variants: sortSeriesComponentSkuGroupVariants(group.variants),
}))
.sort((left, right) => (
left.assetFamilyCode.localeCompare(right.assetFamilyCode, 'zh-CN') ||
left.sortOrder - right.sortOrder ||
left.code.localeCompare(right.code, 'zh-CN')
))
}
function buildSeriesComponentSkuGroupDisplayCode(group: SeriesComponentSkuGroupSnapshot): string {
const variantCodes = group.variants
.map((variant) => variant.code.trim().toUpperCase())
.filter(Boolean)
if (variantCodes.length <= 1) return group.code
return `${group.code}(${variantCodes.join('、')})`
}
function updateSeriesComponentSkuPayloadItemFromSkuGroup(
item: Record<string, unknown>,
group: SeriesComponentSkuGroupSnapshot,
skuSequence: number,
): Record<string, unknown> {
const primaryVariant =
group.variants.find((variant) => variant.code.trim().toUpperCase() === group.code) ??
group.variants[0]
if (!primaryVariant) return item
const nextItem = updateSeriesComponentSkuPayloadItemFromVariant(
item,
primaryVariant,
skuSequence,
)
const displayCode = buildSeriesComponentSkuGroupDisplayCode(group)
return {
...nextItem,
id: `asset-sku-group-${group.id}`,
skuSequence,
skuCode: group.code,
specName: readPayloadString(nextItem, 'specName') || displayCode,
componentDetails: group.variants.map((variant) => ({
code: variant.code.trim().toUpperCase(),
name: variant.name.trim(),
quantity: '1',
role: variant.assetFamilyName,
assetType: variant.assetType,
})),
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
sourceSkuId: group.id,
}
}
function buildSeriesComponentSkuItemsFromVariants(
variants: AssetVariantDTO[],
): NonIntegratedSkuItem[] {
return variants.map((variant, index) =>
updateSeriesComponentSkuPayloadItemFromVariant(
createEmptySeriesComponentSkuPayloadItem(variant, index + 1),
variant,
const groups = buildSeriesComponentSkuGroupSnapshots(variants)
return groups.map((group, index) => {
const primaryVariant = group.variants[0]
if (!primaryVariant) {
return null
}
return updateSeriesComponentSkuPayloadItemFromSkuGroup(
createEmptySeriesComponentSkuPayloadItem(primaryVariant, index + 1),
group,
index + 1,
) as NonIntegratedSkuItem,
)
) as NonIntegratedSkuItem
}).filter((item): item is NonIntegratedSkuItem => item !== null)
}
function buildSeriesComponentDetailProductPayload(input: {
@ -1408,6 +1567,24 @@ function toAssetSeriesDTO(record: {
}
}
function toAssetSkuGroupDTO(record: AssetSkuGroupWithRelations): AssetSkuGroupDTO {
return {
id: record.id,
assetFamilyId: record.assetFamilyId,
assetFamilyCode: record.assetFamily.code,
assetFamilyName: record.assetFamily.name,
assetType: record.assetFamily.type,
code: record.code,
name: record.name,
description: record.description,
sortOrder: record.sortOrder,
metadata: isRecord(record.metadata) ? record.metadata : null,
variantCount: record._count?.variants ?? 0,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null {
return value === null || value === undefined ? null : Number(value)
}
@ -1570,6 +1747,11 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
assetFamilyId: record.assetFamilyId,
assetFamilyCode: record.assetFamily.code,
assetFamilyName: record.assetFamily.name,
skuGroupId: record.skuGroupId,
skuGroupCode: record.skuGroup?.code ?? null,
skuGroupName: record.skuGroup?.name ?? null,
skuGroupSortOrder: record.skuGroup?.sortOrder ?? null,
skuGroupMetadata: isRecord(record.skuGroup?.metadata) ? record.skuGroup.metadata : null,
assetType: record.assetType,
code: record.code,
name: record.name,
@ -1825,6 +2007,62 @@ async function ensureVariantCodeAvailable(code: string, currentId?: string): Pro
}
}
function normalizeAssetSkuGroupCode(code: string): string {
return normalizeCoreIdentity(code)
}
async function ensureAssetSkuGroupCodeAvailable(input: {
assetFamilyId: string
code: string
currentId?: string
}): Promise<void> {
const existing = await assetSkuGroupRepository.findByFamilyAndCode(
input.assetFamilyId,
input.code,
)
if (existing && existing.id !== input.currentId) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '同一型号下货号已存在')
}
}
async function ensureAssetSkuGroupForVariant(input: {
assetFamilyId: string
skuGroupId?: string | null
defaultCode: string
defaultName: string
}): Promise<AssetSkuGroupWithRelations> {
const requestedId = input.skuGroupId?.trim()
if (requestedId) {
const group = await assetSkuGroupRepository.findByIdWithRelations(requestedId)
if (!group || group.assetFamily.deletedAt) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组不存在')
}
if (group.assetFamilyId !== input.assetFamilyId) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组必须属于当前型号')
}
return group
}
const code = normalizeAssetSkuGroupCode(input.defaultCode)
const existing = await assetSkuGroupRepository.findByFamilyAndCode(input.assetFamilyId, code)
if (existing) return existing
const maxSortOrder = await prisma.assetSkuGroup.aggregate({
where: { assetFamilyId: input.assetFamilyId },
_max: { sortOrder: true },
})
return assetSkuGroupRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
code,
name: input.defaultName.trim() || code,
sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1,
metadata: {
source: 'variant-default',
} as Prisma.InputJsonValue,
})
}
async function ensureVariantBarcodeAvailable(barcode: string | null, currentId?: string): Promise<void> {
if (!barcode) return
const existing = await assetVariantRepository.findByBarcode(barcode)
@ -2999,6 +3237,114 @@ class AssetCenterService {
return records.map((item) => toAssetVariantDTO(item))
}
async listSkuGroupsByFamilyIds(assetFamilyIds: string[]): Promise<AssetSkuGroupDTO[]> {
const records = await assetSkuGroupRepository.findManyByFamilyIds(assetFamilyIds)
return records.map((item) => toAssetSkuGroupDTO(item))
}
async createSkuGroup(input: CreateAssetSkuGroupInput): Promise<AssetSkuGroupDTO> {
const family = await assetFamilyRepository.findWithRelationsById(input.assetFamilyId)
if (!family) {
throw new AppError(ErrorCode.ASSET_FAMILY_NOT_FOUND, '型号不存在')
}
const code = normalizeAssetSkuGroupCode(input.code)
const name = input.name?.trim() || code
if (!code || !name) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '货号和名称不能为空')
}
await ensureAssetSkuGroupCodeAvailable({
assetFamilyId: family.id,
code,
})
const sortOrder = input.sortOrder ?? await (async () => {
const maxSortOrder = await prisma.assetSkuGroup.aggregate({
where: { assetFamilyId: family.id },
_max: { sortOrder: true },
})
return (maxSortOrder._max.sortOrder ?? 0) + 1
})()
const created = await assetSkuGroupRepository.createWithRelations({
assetFamily: { connect: { id: family.id } },
code,
name,
description: trimOrNull(input.description),
sortOrder,
metadata: toJsonValue(input.metadata),
})
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
assetFamilyId: family.id,
updatedById: input.createdBy ?? null,
})
return toAssetSkuGroupDTO(created)
}
async updateSkuGroup(id: string, input: UpdateAssetSkuGroupInput): Promise<AssetSkuGroupDTO> {
const existing = await assetSkuGroupRepository.findByIdWithRelations(id)
if (!existing) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组不存在')
}
const code = input.code !== undefined
? normalizeAssetSkuGroupCode(input.code)
: existing.code
const name = input.name !== undefined
? input.name?.trim() || code
: existing.name
if (!code || !name) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '货号和名称不能为空')
}
await ensureAssetSkuGroupCodeAvailable({
assetFamilyId: existing.assetFamilyId,
code,
currentId: id,
})
const updated = await assetSkuGroupRepository.updateWithRelations(id, {
code,
name,
description:
input.description !== undefined ? trimOrNull(input.description) : undefined,
sortOrder: input.sortOrder ?? undefined,
metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined,
})
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
assetFamilyId: existing.assetFamilyId,
updatedById: input.updatedBy ?? null,
})
return toAssetSkuGroupDTO(updated)
}
async deleteSkuGroup(id: string, deletedById?: string | null): Promise<void> {
const existing = await assetSkuGroupRepository.findByIdWithRelations(id)
if (!existing) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组不存在')
}
const activeVariantCount = await prisma.assetVariant.count({
where: {
skuGroupId: id,
deletedAt: null,
},
})
if (activeVariantCount > 0) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '该货号下还有模型明细,不能删除')
}
await assetSkuGroupRepository.deleteById(id)
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
assetFamilyId: existing.assetFamilyId,
updatedById: deletedById ?? null,
})
}
async listSubstrates(): Promise<SubstrateDTO[]> {
const result = await substrateRepository.findManyWithRelations({
filter: { isActive: true },
@ -3720,9 +4066,16 @@ class AssetCenterService {
barcode: input.barcode,
})
await ensureVariantBarcodeAvailable(commercialFields.barcode)
const skuGroup = await ensureAssetSkuGroupForVariant({
assetFamilyId: input.assetFamilyId,
skuGroupId: input.skuGroupId,
defaultCode: code,
defaultName: name,
})
const created = await assetVariantRepository.createWithRelations({
assetFamily: { connect: { id: input.assetFamilyId } },
skuGroup: { connect: { id: skuGroup.id } },
code,
name,
identityKey,
@ -4001,12 +4354,24 @@ class AssetCenterService {
input.barcode !== undefined ? input.barcode : undefined,
})
await ensureVariantBarcodeAvailable(commercialFields.barcode, id)
const nextSkuGroup = await ensureAssetSkuGroupForVariant({
assetFamilyId: nextFamilyId,
skuGroupId:
input.skuGroupId !== undefined
? input.skuGroupId
: nextFamilyId === existing.assetFamilyId
? existing.skuGroupId
: null,
defaultCode: nextCode,
defaultName: nextName,
})
const updated = await assetVariantRepository.updateWithRelations(id, {
assetFamily:
input.assetFamilyId === undefined
? undefined
: { connect: { id: input.assetFamilyId } },
skuGroup: { connect: { id: nextSkuGroup.id } },
code: nextCode,
name: nextName,
identityKey,
@ -4239,6 +4604,36 @@ class AssetCenterService {
variant: AssetVariantDTO
updatedById?: string | null
}): Promise<void> {
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
assetFamilyId: input.variant.assetFamilyId,
updatedById: input.updatedById,
})
}
private async syncSeriesComponentDevelopmentPayloadsForFamily(input: {
assetFamilyId: string
updatedById?: string | null
}): Promise<void> {
const family = await assetFamilyRepository.findWithRelationsById(input.assetFamilyId)
const seriesCode = family?.assetSeries?.code.trim().toUpperCase() ?? ''
if (!seriesCode) return
await this.syncSeriesComponentDevelopmentPayloadsForSeriesCode({
seriesCode,
updatedById: input.updatedById,
})
}
private async syncSeriesComponentDevelopmentPayloadsForSeriesCode(input: {
seriesCode: string
updatedById?: string | null
}): Promise<void> {
const seriesCode = input.seriesCode.trim().toUpperCase()
if (!seriesCode) return
const variants = (await assetVariantRepository.findActiveBySeriesCode(seriesCode))
.map((variant) => toAssetVariantDTO(variant))
const assetSkuItems = buildSeriesComponentSkuItemsFromVariants(variants)
const records = await intakeNonIntegratedProductRepository.listActive({
payload: {
path: ['metadata', 'componentDetailType'],
@ -4248,28 +4643,22 @@ class AssetCenterService {
await Promise.all(
records.map(async (record) => {
if (!isSeriesComponentDevelopmentPayload(record.payload)) return
const recordSpu = typeof record.spu === 'string' ? record.spu : ''
if (!isSeriesComponentDevelopmentPayloadForSeries(record.payload, seriesCode, recordSpu)) {
return
}
const currentItems = Array.isArray(record.payload.skuItems)
? record.payload.skuItems
: []
if (currentItems.length === 0) return
let changed = false
const nextItems = currentItems.map((item, index) => {
if (!isSeriesComponentAssetSkuPayloadItem(item)) return item
const sourceSkuId = typeof item.sourceSkuId === 'string' ? item.sourceSkuId.trim() : ''
if (sourceSkuId !== input.variant.id) return item
changed = true
return updateSeriesComponentSkuPayloadItemFromVariant(
item,
input.variant,
index + 1,
)
const manualItems = currentItems.filter((item) => !isSeriesComponentAssetSkuPayloadItem(item))
const nextItems = [...manualItems, ...assetSkuItems].map((item, index) => {
if (!isRecord(item)) return item
return {
...item,
skuSequence: index + 1,
}
})
if (!changed) return
const updatedAt = new Date().toISOString()
await intakeNonIntegratedProductRepository.updateAttachmentPayloadByUser(
record.id,
@ -4348,67 +4737,10 @@ class AssetCenterService {
variant: AssetVariantDTO
updatedById?: string | null
}): Promise<void> {
const variantRecord = await assetVariantRepository.findByIdWithRelations(input.variant.id)
const seriesCode =
variantRecord?.assetFamily?.assetSeries?.code.trim().toUpperCase() ??
parseSeriesCodeFromAssetVariantCode(input.variant.code)
if (!seriesCode) return
const records = await intakeNonIntegratedProductRepository.listActive({
payload: {
path: ['metadata', 'componentDetailType'],
equals: SERIES_COMPONENT_DETAIL_METADATA_TYPE,
},
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
assetFamilyId: input.variant.assetFamilyId,
updatedById: input.updatedById,
})
await Promise.all(
records.map(async (record) => {
const recordSpu = typeof record.spu === 'string' ? record.spu : ''
if (!isSeriesComponentDevelopmentPayloadForSeries(record.payload, seriesCode, recordSpu)) {
return
}
const currentItems = Array.isArray(record.payload.skuItems)
? record.payload.skuItems
: []
const existingIndex = currentItems.findIndex((item) => {
if (!isSeriesComponentAssetSkuPayloadItem(item)) return false
const sourceSkuId = typeof item.sourceSkuId === 'string' ? item.sourceSkuId.trim() : ''
return sourceSkuId === input.variant.id
})
const nextItems = existingIndex >= 0
? currentItems.map((item, index) => {
if (index !== existingIndex || !isRecord(item)) return item
return updateSeriesComponentSkuPayloadItemFromVariant(
item,
input.variant,
index + 1,
)
})
: [
...currentItems,
updateSeriesComponentSkuPayloadItemFromVariant(
createEmptySeriesComponentSkuPayloadItem(input.variant, currentItems.length + 1),
input.variant,
currentItems.length + 1,
),
]
const updatedAt = new Date().toISOString()
await intakeNonIntegratedProductRepository.updateAttachmentPayloadByUser(
record.id,
{
...record.payload,
skuItems: nextItems,
skuBatchCount: getSeriesComponentSkuBatchCount(nextItems),
updatedAt,
updatedBy: input.updatedById ?? record.updatedBy,
} as Prisma.InputJsonValue,
input.updatedById ?? record.updatedBy,
)
}),
)
}
private async syncSeriesComponentDevelopmentPayloadsAfterMissingVariantBackfill(input: {
@ -4416,60 +4748,10 @@ class AssetCenterService {
variants: AssetVariantDTO[]
updatedById?: string | null
}): Promise<void> {
const seriesCode = input.seriesCode.trim().toUpperCase()
if (!seriesCode || input.variants.length === 0) return
const records = await intakeNonIntegratedProductRepository.listActive({
payload: {
path: ['metadata', 'componentDetailType'],
equals: SERIES_COMPONENT_DETAIL_METADATA_TYPE,
},
await this.syncSeriesComponentDevelopmentPayloadsForSeriesCode({
seriesCode: input.seriesCode,
updatedById: input.updatedById,
})
await Promise.all(
records.map(async (record) => {
const recordSpu = typeof record.spu === 'string' ? record.spu : ''
if (!isSeriesComponentDevelopmentPayloadForSeries(record.payload, seriesCode, recordSpu)) {
return
}
const currentItems = Array.isArray(record.payload.skuItems)
? record.payload.skuItems
: []
const existingSourceSkuIds = new Set(
currentItems
.map((item) => {
if (!isSeriesComponentAssetSkuPayloadItem(item)) return ''
return typeof item.sourceSkuId === 'string' ? item.sourceSkuId.trim() : ''
})
.filter(Boolean),
)
const missingVariants = input.variants.filter((variant) => !existingSourceSkuIds.has(variant.id))
if (missingVariants.length === 0) return
const appendedItems = missingVariants.map((variant, index) => {
const skuSequence = currentItems.length + index + 1
return updateSeriesComponentSkuPayloadItemFromVariant(
createEmptySeriesComponentSkuPayloadItem(variant, skuSequence),
variant,
skuSequence,
)
})
const nextItems = [...currentItems, ...appendedItems]
const updatedAt = new Date().toISOString()
await intakeNonIntegratedProductRepository.updateAttachmentPayloadByUser(
record.id,
{
...record.payload,
skuItems: nextItems,
skuBatchCount: getSeriesComponentSkuBatchCount(nextItems),
updatedAt,
updatedBy: input.updatedById ?? record.updatedBy,
} as Prisma.InputJsonValue,
input.updatedById ?? record.updatedBy,
)
}),
)
}
private async syncSeriesComponentDevelopmentPayloadsAfterVariantDelete(input: {
@ -4637,11 +4919,12 @@ class AssetCenterService {
}
}
await this.syncSeriesComponentDevelopmentPayloadsAfterVariantDelete({
deletedVariantId: id,
shiftedVariants,
updatedById: deletedById ?? null,
})
if (seriesCode) {
await this.syncSeriesComponentDevelopmentPayloadsForSeriesCode({
seriesCode,
updatedById: deletedById ?? null,
})
}
await assetSkuSyncService.enqueueVariantSync({
assetVariantId: id,
@ -5100,10 +5383,12 @@ export type {
AssetSeriesDTO,
AssetSeriesComponentDetailSummaryDTO,
AssetSeriesSummaryDTO,
AssetSkuGroupDTO,
AssetVariantDTO,
CreateAssetVariantResultDTO,
CreateAssetFamilyInput,
CreateAssetSeriesInput,
CreateAssetSkuGroupInput,
CreateAssetVariantInput,
MaterialLikeDTO,
SimilarAssetVariantCandidateDTO,
@ -5115,6 +5400,7 @@ export type {
SubstrateDictionaryType,
UpdateAssetFamilyInput,
UpdateAssetSeriesInput,
UpdateAssetSkuGroupInput,
UpdateAssetVariantInput,
UpdateSubstrateDictionaryInput,
UpdateSubstrateInput,

View File

@ -93,11 +93,32 @@ export interface AssetFamilyDTO {
updatedAt: Date
}
export interface AssetSkuGroupDTO {
id: string
assetFamilyId: string
assetFamilyCode: string
assetFamilyName: string
assetType: AssetType
code: string
name: string
description: string | null
sortOrder: number
metadata: Record<string, unknown> | null
variantCount: number
createdAt: Date
updatedAt: Date
}
export interface AssetVariantDTO {
id: string
assetFamilyId: string
assetFamilyCode: string
assetFamilyName: string
skuGroupId: string | null
skuGroupCode: string | null
skuGroupName: string | null
skuGroupSortOrder: number | null
skuGroupMetadata: Record<string, unknown> | null
assetType: AssetType
code: string
name: string
@ -296,8 +317,28 @@ export interface UpdateAssetFamilyInput {
updatedBy?: string | null
}
export interface CreateAssetSkuGroupInput {
assetFamilyId: string
code: string
name?: string | null
description?: string | null
sortOrder?: number | null
metadata?: Record<string, unknown> | null
createdBy?: string | null
}
export interface UpdateAssetSkuGroupInput {
code?: string
name?: string | null
description?: string | null
sortOrder?: number | null
metadata?: Record<string, unknown> | null
updatedBy?: string | null
}
export interface CreateAssetVariantInput {
assetFamilyId: string
skuGroupId?: string | null
code?: string
name: string
status?: AssetStatus
@ -327,6 +368,7 @@ export interface CreateAssetVariantInput {
export interface UpdateAssetVariantInput {
assetFamilyId?: string
skuGroupId?: string | null
code?: string
name?: string
status?: AssetStatus

View File

@ -382,6 +382,9 @@ export function buildSlotSnapshot(
role: selection.role,
assetFamilyId: selection.variant.assetFamilyId,
variantId: selection.variant.id,
skuGroupId: selection.variant.skuGroupId ?? null,
skuGroupCode: selection.variant.skuGroupCode ?? null,
skuGroupName: selection.variant.skuGroupName ?? null,
code: selection.variant.code,
name: selection.variant.name,
assetType: selection.variant.assetType,
@ -406,6 +409,9 @@ export function buildHardwareSnapshot(
selectionKey: selection.selectionKey,
assetFamilyId: selection.variant.assetFamilyId,
variantId: selection.variant.id,
skuGroupId: selection.variant.skuGroupId ?? null,
skuGroupCode: selection.variant.skuGroupCode ?? null,
skuGroupName: selection.variant.skuGroupName ?? null,
code: selection.variant.code,
name: selection.variant.name,
assetType: selection.variant.assetType,
@ -437,9 +443,14 @@ export function normalizeVariantSelection(value: unknown): PreSkuVariantSelectio
role: typeof item.role === 'string' ? item.role : '',
assetFamilyId: item.assetFamilyId as string,
variantId: item.variantId as string,
skuGroupId: readNonEmptyString(item.skuGroupId),
skuGroupCode: readNonEmptyString(item.skuGroupCode),
skuGroupName: readNonEmptyString(item.skuGroupName),
code: typeof item.code === 'string' ? item.code : '',
name: typeof item.name === 'string' ? item.name : '',
assetType: typeof item.assetType === 'string' ? item.assetType : 'COMPONENT',
barcode: readNonEmptyString(item.barcode),
correspondingBarcode: readNonEmptyString(item.correspondingBarcode),
substrateId: typeof item.substrateId === 'string' ? item.substrateId : null,
finishId: typeof item.finishId === 'string' ? item.finishId : null,
dimensions: isRecord(item.dimensions) ? (item.dimensions as Record<string, number>) : {},
@ -460,9 +471,14 @@ export function normalizeVariantSelection(value: unknown): PreSkuVariantSelectio
selectionKey: typeof item.selectionKey === 'string' ? item.selectionKey : '',
assetFamilyId: item.assetFamilyId as string,
variantId: item.variantId as string,
skuGroupId: readNonEmptyString(item.skuGroupId),
skuGroupCode: readNonEmptyString(item.skuGroupCode),
skuGroupName: readNonEmptyString(item.skuGroupName),
code: typeof item.code === 'string' ? item.code : '',
name: typeof item.name === 'string' ? item.name : '',
assetType: typeof item.assetType === 'string' ? item.assetType : 'HARDWARE',
barcode: readNonEmptyString(item.barcode),
correspondingBarcode: readNonEmptyString(item.correspondingBarcode),
substrateId: typeof item.substrateId === 'string' ? item.substrateId : null,
finishId: typeof item.finishId === 'string' ? item.finishId : null,
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
@ -516,6 +532,7 @@ export function normalizePackagingPlanBoxes(value: unknown): PreSkuPackagingPlan
assetFamilyId: typeof entry.assetFamilyId === 'string' ? entry.assetFamilyId : '',
code: typeof entry.code === 'string' ? entry.code : '',
name: typeof entry.name === 'string' ? entry.name : '',
barcode: typeof entry.barcode === 'string' ? entry.barcode : null,
quantity: typeof entry.quantity === 'number' ? entry.quantity : 1,
unitCost: toNullableNumber(entry.unitCost) ?? 0,
totalCost: toNullableNumber(entry.totalCost) ?? 0,

View File

@ -13,6 +13,7 @@ import {
productRepository,
preSkuRepository,
skuRepository,
type AssetVariantWithRelations,
type DraftGroupWithRelations,
type PreSkuWithDraftGroupRelations,
type PreSkuWithRelations,
@ -33,6 +34,7 @@ import type {
DraftGroupAssemblyInput,
DraftGroupDTO,
DraftGroupDetailDTO,
DraftGroupPreSkuDTO,
GetPreSkuPackagingMaterialOptionsDTO,
GetPreSkuPackagingContextDTO,
PreSkuBasicInfoConflictDTO,
@ -149,6 +151,8 @@ const UPPERCASE_BASIC_INFO_FIELDS = new Set<PreSkuBasicInfoFieldKey>([
'drawingId',
])
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
function readText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
@ -159,6 +163,21 @@ function readTextOrNumber(value: unknown): string {
return ''
}
function readSkuContentFieldFromMetadata(metadata: unknown, key: string): string {
if (!isRecord(metadata)) return ''
const rawFields = metadata[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
if (!isRecord(rawFields)) return ''
return readTextOrNumber(rawFields[key])
}
function resolveCorrespondingBarcodeFromVariant(variant: AssetVariantWithRelations): string {
return (
readSkuContentFieldFromMetadata(variant.skuGroup?.metadata, 'correspondingBarcode') ||
readSkuContentFieldFromMetadata(variant.skuGroup?.metadata, 'barcode') ||
readText(variant.barcode)
).toUpperCase()
}
function readPositiveQuantity(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value
@ -529,12 +548,56 @@ class DraftGroupService {
}
const preSkus = await preSkuRepository.findByDraftGroupId(id)
const preSkuDTOs = preSkus.map((item) => toDraftGroupPreSkuDTO(item))
return {
draftGroup: toDraftGroupDTO(draftGroup),
preSkus: preSkus.map((item) => toDraftGroupPreSkuDTO(item)),
preSkus: await this.enrichSelectedVariantBarcodes(preSkuDTOs),
}
}
private async enrichSelectedVariantBarcodes(
preSkus: DraftGroupPreSkuDTO[],
): Promise<DraftGroupPreSkuDTO[]> {
const selectedVariantIds = Array.from(
new Set(
preSkus.flatMap((preSku) => [
...preSku.variantSelection.slots.map((slot) => slot.variantId),
...preSku.variantSelection.hardwareSelections.map((hardware) => hardware.variantId),
]).filter((variantId) => variantId.trim().length > 0),
),
)
if (selectedVariantIds.length === 0) return preSkus
const variants = await assetVariantRepository.findByIdsWithRelations(selectedVariantIds)
const barcodeByVariantId = new Map(
variants.map((variant) => [
variant.id,
{
barcode: readText(variant.barcode).toUpperCase() || null,
correspondingBarcode: resolveCorrespondingBarcodeFromVariant(variant) || null,
skuGroupId: variant.skuGroupId ?? null,
skuGroupCode: readText(variant.skuGroup?.code)?.toUpperCase() || null,
skuGroupName: readText(variant.skuGroup?.name) || null,
},
]),
)
if (barcodeByVariantId.size === 0) return preSkus
return preSkus.map((preSku) => ({
...preSku,
variantSelection: {
slots: preSku.variantSelection.slots.map((slot) => ({
...slot,
...barcodeByVariantId.get(slot.variantId),
})),
hardwareSelections: preSku.variantSelection.hardwareSelections.map((hardware) => ({
...hardware,
...barcodeByVariantId.get(hardware.variantId),
})),
},
}))
}
async updateBasicInfo(
id: string,
input: UpdateDraftGroupBasicInfoInput,

View File

@ -125,9 +125,14 @@ export interface PreSkuSelectedSlotVariantDTO {
role: string
assetFamilyId: string
variantId: string
skuGroupId?: string | null
skuGroupCode?: string | null
skuGroupName?: string | null
code: string
name: string
assetType: string
barcode?: string | null
correspondingBarcode?: string | null
substrateId: string | null
finishId: string | null
dimensions: Record<string, number>
@ -142,9 +147,14 @@ export interface PreSkuSelectedHardwareSelectionDTO {
selectionKey: string
assetFamilyId: string
variantId: string
skuGroupId?: string | null
skuGroupCode?: string | null
skuGroupName?: string | null
code: string
name: string
assetType: string
barcode?: string | null
correspondingBarcode?: string | null
substrateId: string | null
finishId: string | null
quantity: number
@ -206,6 +216,7 @@ export interface PreSkuPackagingPlanMaterialDTO {
assetFamilyId: string
code: string
name: string
barcode?: string | null
quantity: number
unitCost: number
totalCost: number
@ -241,6 +252,7 @@ export interface SavePreSkuPackagingPlanMaterialInput {
assetFamilyId?: string | null
code?: string | null
name?: string | null
barcode?: string | null
unitCost?: number | null
totalCost?: number | null
notes?: string | null

View File

@ -167,10 +167,12 @@ export type {
AssetSeriesDTO,
AssetSeriesComponentDetailSummaryDTO,
AssetSeriesSummaryDTO,
AssetSkuGroupDTO,
AssetVariantDTO,
CreateAssetVariantResultDTO,
CreateAssetFamilyInput,
CreateAssetSeriesInput,
CreateAssetSkuGroupInput,
CreateAssetVariantInput,
MaterialLikeDTO,
SimilarAssetVariantCandidateDTO,
@ -182,6 +184,7 @@ export type {
SubstrateDictionaryType,
UpdateAssetFamilyInput,
UpdateAssetSeriesInput,
UpdateAssetSkuGroupInput,
UpdateAssetVariantInput,
UpdateSubstrateDictionaryInput,
UpdateSubstrateInput,

View File

@ -2656,10 +2656,26 @@ function buildStockTextSearchWhere(
conditions.push({
skuItems: {
some: {
barcode: {
contains: keyword,
mode: 'insensitive',
},
OR: [
{
barcode: {
contains: keyword,
mode: 'insensitive',
},
},
{
skuCode: {
contains: keyword,
mode: 'insensitive',
},
},
{
sourceSkuId: {
contains: keyword,
mode: 'insensitive',
},
},
],
},
},
})
@ -3130,6 +3146,7 @@ function readSourceSkuIdSet(product: SharedNonIntegratedProductRecord | null | u
const SERIES_COMPONENT_DETAIL_METADATA_TYPE = 'asset-series-components'
const SERIES_COMPONENT_ASSET_SKU_SOURCE = 'asset_series_component'
const ASSET_VARIANT_SOURCE_TABLE = 'asset_variant'
const ASSET_SKU_GROUP_SOURCE_TABLE = 'asset_sku_group'
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
const NON_BOARD_ASSET_BOARD_MATERIAL_TYPE = '非ENF级'
const DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE = 'A'
@ -3146,11 +3163,10 @@ function readPayloadString(item: Record<string, unknown>, key: string): string {
return ''
}
function readVariantSkuContentFields(
variant: AssetVariantWithRelations,
function readSkuContentFieldsFromRecord(
record: Record<string, unknown> | null | undefined,
): Record<string, string> {
const businessData = isRecord(variant.businessData) ? variant.businessData : null
const rawFields = businessData?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
const rawFields = record?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
if (!isRecord(rawFields)) return {}
return Object.entries(rawFields).reduce<Record<string, string>>((fields, [key, value]) => {
@ -3163,6 +3179,31 @@ function readVariantSkuContentFields(
}, {})
}
function readVariantSkuContentFields(
variant: AssetVariantWithRelations,
): Record<string, string> {
return readSkuContentFieldsFromRecord(
isRecord(variant.businessData) ? variant.businessData : null,
)
}
function readSkuGroupSkuContentFields(
variant: AssetVariantWithRelations,
): Record<string, string> {
return readSkuContentFieldsFromRecord(
isRecord(variant.skuGroup?.metadata) ? variant.skuGroup.metadata : null,
)
}
function mergeSkuGroupAndVariantSkuContentFields(
variant: AssetVariantWithRelations,
): Record<string, string> {
return {
...readVariantSkuContentFields(variant),
...readSkuGroupSkuContentFields(variant),
}
}
function getVariantSkuContentField(
fields: Record<string, string>,
key: string,
@ -3255,6 +3296,169 @@ function createEmptyAssetSeriesComponentSkuItem(
}
}
type AssetSeriesComponentSkuGroup = {
id: string
hasSkuGroup: boolean
code: string
name: string
sortOrder: number
assetFamilyCode: string
variants: AssetVariantWithRelations[]
}
function getAssetSeriesComponentSkuGroupId(variant: AssetVariantWithRelations) {
const skuGroupId = variant.skuGroup?.id?.trim()
if (skuGroupId) return skuGroupId
const skuGroupCode = variant.skuGroup?.code?.trim().toUpperCase()
if (skuGroupCode) {
return `sku-group-code:${variant.assetFamilyId}:${skuGroupCode}`
}
return `variant:${variant.id}`
}
function getAssetVariantCodeSequence(code: string) {
const matched = code.trim().toUpperCase().match(/(\d+)$/)
const value = matched ? Number.parseInt(matched[1] ?? '', 10) : 0
return Number.isFinite(value) ? value : 0
}
function sortAssetSeriesComponentSkuGroupVariants(
variants: AssetVariantWithRelations[],
) {
return [...variants].sort(
(left, right) =>
getAssetVariantCodeSequence(left.code) - getAssetVariantCodeSequence(right.code) ||
left.code.localeCompare(right.code, 'zh-CN', { numeric: true }) ||
left.name.localeCompare(right.name, 'zh-CN', { numeric: true }),
)
}
function buildAssetSeriesComponentSkuGroups(
variants: AssetVariantWithRelations[],
): AssetSeriesComponentSkuGroup[] {
const groupById = new Map<string, AssetSeriesComponentSkuGroup>()
variants.forEach((variant) => {
const id = getAssetSeriesComponentSkuGroupId(variant)
const hasSkuGroup = Boolean(
variant.skuGroup?.id?.trim() || variant.skuGroup?.code?.trim(),
)
const code = (variant.skuGroup?.code?.trim() || variant.code.trim()).toUpperCase()
const name = variant.skuGroup?.name?.trim() || variant.name.trim() || code
const current = groupById.get(id)
if (current) {
current.variants.push(variant)
return
}
groupById.set(id, {
id,
hasSkuGroup,
code,
name,
sortOrder: variant.skuGroup?.sortOrder ?? Number.MAX_SAFE_INTEGER,
assetFamilyCode: variant.assetFamily.code,
variants: [variant],
})
})
return Array.from(groupById.values())
.map((group) => ({
...group,
variants: sortAssetSeriesComponentSkuGroupVariants(group.variants),
}))
.sort(
(left, right) =>
left.assetFamilyCode.localeCompare(right.assetFamilyCode, 'zh-CN', {
numeric: true,
}) ||
left.sortOrder - right.sortOrder ||
left.code.localeCompare(right.code, 'zh-CN', { numeric: true }),
)
}
function updateAssetSeriesComponentSkuItemFromGroup(
item: NonIntegratedSkuItem,
group: AssetSeriesComponentSkuGroup,
skuSequence: number,
): NonIntegratedSkuItem {
const primaryVariant =
group.variants.find((variant) => variant.code.trim().toUpperCase() === group.code) ??
group.variants[0]
if (!primaryVariant) return item
if (!group.hasSkuGroup) {
return updateAssetSeriesComponentSkuItemFromVariant(
item,
primaryVariant,
skuSequence,
)
}
const nextItem = updateAssetSeriesComponentSkuItemFromVariant(
item,
primaryVariant,
skuSequence,
)
const groupFields = readSkuGroupSkuContentFields(primaryVariant)
return {
...nextItem,
id: `asset-sku-group-${group.id}`,
skuSequence,
skuCode: group.code,
specName: getVariantSkuContentField(groupFields, 'specName', group.name),
componentDetails: group.variants.map((variant) => ({
code: variant.code.trim().toUpperCase(),
name: variant.name.trim(),
quantity: '1',
role: variant.assetFamily.name,
assetType: variant.assetType,
})),
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
sourceSkuId: group.id,
}
}
function normalizeAssetSeriesComponentSourceMarker(value: string | undefined) {
return value?.trim().toLowerCase() ?? ''
}
function isAssetSeriesComponentSkuItem(item: NonIntegratedSkuItem) {
const source = normalizeAssetSeriesComponentSourceMarker(item.source)
const sourceTable = normalizeAssetSeriesComponentSourceMarker(item.sourceTable)
return (
source === SERIES_COMPONENT_ASSET_SKU_SOURCE ||
sourceTable === ASSET_VARIANT_SOURCE_TABLE ||
sourceTable === ASSET_SKU_GROUP_SOURCE_TABLE
)
}
function hasAssetSeriesComponentSkuContent(item: NonIntegratedSkuItem) {
return [
item.image,
item.model,
item.skuCode,
item.specName,
item.barcode,
item.size,
item.color,
item.material,
item.boardMaterialType ?? '',
item.attributeCode,
item.drawingId,
item.installMethod,
item.executeStandard,
item.nationalStandardCompliant,
item.manufacturer,
item.manufacturerCode,
item.salesStatus,
item.model3dDataUrl,
].some((value) => value.trim().length > 0)
}
function updateAssetSeriesComponentSkuItemFromVariant(
item: NonIntegratedSkuItem,
variant: AssetVariantWithRelations,
@ -3265,7 +3469,10 @@ function updateAssetSeriesComponentSkuItemFromVariant(
const normalizedModel = variant.assetFamily.code.trim().toUpperCase()
const normalizedModelUrl = variant.modelUrl.trim()
const thumbnailUrl = variant.thumbnailUrl?.trim() ?? ''
const skuContentFields = readVariantSkuContentFields(variant)
const skuContentFields = mergeSkuGroupAndVariantSkuContentFields(variant)
const isStockLinkedSku = Boolean(
getVariantSkuContentField(skuContentFields, 'correspondingBarcode').trim(),
)
const rawVersionNo = hasVariantSkuContentField(skuContentFields, 'versionNo')
? skuContentFields.versionNo ?? ''
: (variant.versionNo ?? readPayloadString(itemRecord, 'versionNo')) || DEFAULT_SKU_VERSION_NO
@ -3379,6 +3586,7 @@ function updateAssetSeriesComponentSkuItemFromVariant(
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
sourceSkuId: variant.id,
contentLocked: isStockLinkedSku,
}
}
@ -3427,45 +3635,66 @@ async function mergeMissingAssetSeriesComponentSkuItems(
const activeVariants = await assetVariantRepository.findActiveBySeriesCode(seriesCode)
if (activeVariants.length === 0) return product
const activeVariantById = new Map(activeVariants.map((variant) => [variant.id, variant]))
const activeVariantByCode = new Map(
activeVariants.map((variant) => [variant.code.trim().toUpperCase(), variant]),
)
const existingSourceSkuIds = new Set<string>()
const existingSkuCodes = new Set<string>()
const syncedItems = product.skuItems.map((item, index) => {
const activeGroups = buildAssetSeriesComponentSkuGroups(activeVariants)
const groupById = new Map(activeGroups.map((group) => [group.id, group]))
const groupByCode = new Map(activeGroups.map((group) => [group.code, group]))
const groupByVariantId = new Map<string, AssetSeriesComponentSkuGroup>()
const groupByVariantCode = new Map<string, AssetSeriesComponentSkuGroup>()
activeGroups.forEach((group) => {
group.variants.forEach((variant) => {
groupByVariantId.set(variant.id, group)
groupByVariantCode.set(variant.code.trim().toUpperCase(), group)
})
})
const currentItemByGroupId = new Map<string, NonIntegratedSkuItem>()
const passthroughItems: NonIntegratedSkuItem[] = []
product.skuItems.forEach((item) => {
const sourceSkuId = item.sourceSkuId?.trim() ?? ''
const skuCode = item.skuCode?.trim().toUpperCase() ?? ''
const matchedVariant =
(sourceSkuId ? activeVariantById.get(sourceSkuId) : undefined) ??
(skuCode ? activeVariantByCode.get(skuCode) : undefined)
const nextItem = matchedVariant
? updateAssetSeriesComponentSkuItemFromVariant(item, matchedVariant, index + 1)
: item
const matchedGroup =
(sourceSkuId ? groupById.get(sourceSkuId) : undefined) ??
(sourceSkuId ? groupByVariantId.get(sourceSkuId) : undefined) ??
(skuCode ? groupByCode.get(skuCode) : undefined) ??
(skuCode ? groupByVariantCode.get(skuCode) : undefined)
const nextSourceSkuId = nextItem.sourceSkuId?.trim()
if (nextSourceSkuId) existingSourceSkuIds.add(nextSourceSkuId)
if (matchedGroup) {
if (!currentItemByGroupId.has(matchedGroup.id)) {
currentItemByGroupId.set(matchedGroup.id, item)
}
return
}
const nextSkuCode = nextItem.skuCode?.trim().toUpperCase()
if (nextSkuCode) existingSkuCodes.add(nextSkuCode)
return nextItem
if (!isAssetSeriesComponentSkuItem(item) && hasAssetSeriesComponentSkuContent(item)) {
passthroughItems.push(item)
}
})
const missingVariants = activeVariants.filter((variant) => {
const variantCode = variant.code.trim().toUpperCase()
return !existingSourceSkuIds.has(variant.id) && !existingSkuCodes.has(variantCode)
})
const syncedItems = activeGroups
.map((group, index) => {
const primaryVariant = group.variants[0]
if (!primaryVariant) return null
const skuSequence = index + 1
const currentItem =
currentItemByGroupId.get(group.id) ??
createEmptyAssetSeriesComponentSkuItem(primaryVariant, skuSequence)
const appendedItems = missingVariants.map((variant, index) => {
const skuSequence = syncedItems.length + index + 1
return updateAssetSeriesComponentSkuItemFromVariant(
createEmptyAssetSeriesComponentSkuItem(variant, skuSequence),
variant,
skuSequence,
)
})
const skuItems = [...syncedItems, ...appendedItems]
return updateAssetSeriesComponentSkuItemFromGroup(
currentItem,
group,
skuSequence,
)
})
.filter((item): item is NonIntegratedSkuItem => item !== null)
const passthroughStartSequence = syncedItems.length
const skuItems = [
...syncedItems,
...passthroughItems.map((item, index) => ({
...item,
skuSequence: passthroughStartSequence + index + 1,
})),
]
return {
...product,

View File

@ -142,6 +142,7 @@ function materializePackagingMaterials(
assetFamilyId: variant.assetFamilyId,
code: variant.code,
name: variant.name,
barcode: toNullableTrimmedString(material.barcode) ?? toNullableTrimmedString(variant.barcode),
quantity,
unitCost,
totalCost,

View File

@ -31,6 +31,9 @@ export function toOpenProductVariants(
return {
id: record.id,
assetFamilyId: record.assetFamilyId,
skuGroupId: record.skuGroupId,
skuGroupCode: record.skuGroup?.code ?? null,
skuGroupName: record.skuGroup?.name ?? null,
code: record.code,
name: record.name,
assetType: record.assetType,