diff --git a/furniture-3d-system/prisma/migrations/20260604143000_add_asset_sku_groups/migration.sql b/furniture-3d-system/prisma/migrations/20260604143000_add_asset_sku_groups/migration.sql new file mode 100644 index 00000000..52f7b309 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260604143000_add_asset_sku_groups/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index f7bf042d..5a66b7f5 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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]) diff --git a/furniture-3d-system/src/app/api/asset-center/sku-groups/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/sku-groups/[id]/route.ts new file mode 100644 index 00000000..db9a46c0 --- /dev/null +++ b/furniture-3d-system/src/app/api/asset-center/sku-groups/[id]/route.ts @@ -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 }, + ) +} diff --git a/furniture-3d-system/src/app/api/asset-center/sku-groups/route.ts b/furniture-3d-system/src/app/api/asset-center/sku-groups/route.ts new file mode 100644 index 00000000..3b830082 --- /dev/null +++ b/furniture-3d-system/src/app/api/asset-center/sku-groups/route.ts @@ -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 }, + ) +} diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index 7a5a7861..16e12f21 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -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(), diff --git a/furniture-3d-system/src/app/api/upload/model/route.ts b/furniture-3d-system/src/app/api/upload/model/route.ts index 5b55a710..159e7e3b 100644 --- a/furniture-3d-system/src/app/api/upload/model/route.ts +++ b/furniture-3d-system/src/app/api/upload/model/route.ts @@ -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, ) diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx index a078a9ae..42997be8 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx @@ -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 { 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('搜索基材'), '胡桃') diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index 27501b8e..3119b6f1 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -37,6 +37,11 @@ import Input, { Textarea } from '@/components/ui/Input' import Modal from '@/components/ui/Modal' import Select from '@/components/ui/Select' import type { SeriesDraftSkuFields } from '@/app/intake/seriesComponentDetails' +import { listSharedNonIntegratedProductPage } from '@/app/intake/non-integrated/nonIntegratedApi' +import type { + NonIntegratedProductRecord, + NonIntegratedSkuItem, +} from '@/app/intake/non-integrated/nonIntegratedStorage' import { EXECUTION_STANDARD_OPTIONS } from '@/app/intake/non-integrated/executionStandardDirectory' import { buildNonIntegratedBomSteelFrameOptions, @@ -99,11 +104,26 @@ import { PACKAGING_PLACEHOLDER_MODEL_URL } from '@/lib/packaging-materials/const import { getLevel1FromFamily } from '@/lib/packaging-materials/catalog' import { canOperateDesignWorkspace as canOperateDesignWorkspaceForUser } from '@/lib/role-scope' import { cn } from '@/lib/utils' +import { + buildCadAssetAssemblyBasis, + buildCadAssetBusinessData, + buildCadAssetDimensions, + buildCadAssetMountPoints, + buildCadAssetNamingFields, + buildCadAssetSizeLabel, + buildCadDataFromExport, + type CadAssetPayloadType, +} from '@/lib/asset-cad-variant-payload' +import { + validateCADExportData, +} from '@/lib/cad-export-processor' import { createAssetFamily, + createAssetSkuGroup, createAssetVariantWithSimilarity, createPackagingAssetVariant, deleteAssetFamily, + deleteAssetSkuGroup, deleteAssetVariant, ensureAssetSeriesComponentDetail, getAssetOperationProgress, @@ -113,6 +133,7 @@ import { reviewAssetFamily, reviewAssetVariant, updateAssetFamily, + updateAssetSkuGroup, updateAssetVariant, uploadModelFile, } from '@/lib/open-product/platform-api' @@ -127,6 +148,7 @@ import type { AssetOperationProgressDTO, AssetSeriesComponentDetailSummaryDTO, AssetSeriesDTO, + AssetSkuGroupDTO, MaterialLikeDTO, SimilarAssetVariantCandidateDTO, AssetVariantDTO, @@ -138,6 +160,7 @@ import type { interface AssetCenterClientProps { initialFamilies: AssetFamilyDTO[] + initialSkuGroups?: AssetSkuGroupDTO[] initialVariants: AssetVariantDTO[] initialSubstrates: SubstrateDTO[] initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO @@ -227,6 +250,26 @@ function sortAssetVariantsByCreatedTime(items: AssetVariantDTO[]): AssetVariantD return sortByCreatedDesc(items) } +function sortAssetSkuGroups(items: AssetSkuGroupDTO[]): AssetSkuGroupDTO[] { + return [...items].sort((left, right) => ( + left.assetFamilyId.localeCompare(right.assetFamilyId, 'zh-CN') || + left.sortOrder - right.sortOrder || + left.code.localeCompare(right.code, 'zh-CN', { numeric: true }) + )) +} + +type AssetSkuGroupTreeNode = AssetSkuGroupDTO & { + variants: AssetVariantDTO[] +} + +function sortAssetSkuGroupTreeNodes(items: AssetSkuGroupTreeNode[]): AssetSkuGroupTreeNode[] { + return [...items].sort((left, right) => ( + left.sortOrder - right.sortOrder || + left.code.localeCompare(right.code, 'zh-CN', { numeric: true }) || + left.name.localeCompare(right.name, 'zh-CN') + )) +} + function createAssetOperationId(prefix: string): string { const cryptoApi = globalThis.crypto if (typeof cryptoApi?.randomUUID === 'function') { @@ -1018,7 +1061,7 @@ function readAssetVariantSkuContentFields( const rawFields = businessData[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY] if (!isObjectRecord(rawFields)) return {} - return ASSET_VARIANT_SKU_FIELD_DEFINITIONS.reduce( + const fields = ASSET_VARIANT_SKU_FIELD_DEFINITIONS.reduce( (fields, definition) => { const value = rawFields[definition.key] if (typeof value === 'string') { @@ -1030,6 +1073,53 @@ function readAssetVariantSkuContentFields( }, {} as Partial>, ) + const correspondingBarcode = rawFields[ASSET_VARIANT_CORRESPONDING_BARCODE_FIELD_KEY] + if (typeof correspondingBarcode === 'string') { + fields.correspondingBarcode = correspondingBarcode.trim() + } else if (typeof correspondingBarcode === 'number' && Number.isFinite(correspondingBarcode)) { + fields.correspondingBarcode = String(correspondingBarcode) + } + + return fields +} + +function readAssetSkuGroupSkuContentFields( + metadata: Record | null | undefined, +): Partial> { + if (!isObjectRecord(metadata)) return {} + const rawFields = metadata[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY] + if (!isObjectRecord(rawFields)) return {} + + const fields = ASSET_VARIANT_SKU_FIELD_DEFINITIONS.reduce( + (fields, definition) => { + const value = rawFields[definition.key] + if (typeof value === 'string') { + fields[definition.key] = value + } else if (typeof value === 'number' && Number.isFinite(value)) { + fields[definition.key] = String(value) + } + return fields + }, + {} as Partial>, + ) + + const correspondingBarcode = rawFields[ASSET_VARIANT_CORRESPONDING_BARCODE_FIELD_KEY] + if (typeof correspondingBarcode === 'string') { + fields.correspondingBarcode = correspondingBarcode.trim() + } else if (typeof correspondingBarcode === 'number' && Number.isFinite(correspondingBarcode)) { + fields.correspondingBarcode = String(correspondingBarcode) + } + + return fields +} + +function buildAssetSkuGroupMetadata( + existingMetadata: Record | null | undefined, + fields: AssetSkuContentFields, +): Record { + const base = isObjectRecord(existingMetadata) ? { ...existingMetadata } : {} + base[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY] = fields + return base } function readAssetVariantMaterialMode( @@ -1086,7 +1176,7 @@ const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode } { type: 'PACKAGING', label: '包装', icon: }, ] -type AssetVariantSkuFieldKey = Extract< +type AssetVariantBaseSkuFieldKey = Extract< keyof SeriesDraftSkuFields, | 'model' | 'skuCode' @@ -1109,6 +1199,9 @@ type AssetVariantSkuFieldKey = Extract< | 'batchNo' | 'dingTalkFlowNo' > +type AssetVariantSkuFieldKey = + | AssetVariantBaseSkuFieldKey + | 'correspondingBarcode' interface AssetVariantSkuFieldDefinition { label: string @@ -1136,6 +1229,7 @@ const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields' const ASSET_VARIANT_MATERIAL_MODE_BUSINESS_DATA_KEY = 'materialMode' const ASSET_VARIANT_STEEL_FRAME_ITEM_ID_BUSINESS_DATA_KEY = 'steelFrameItemId' const ASSET_VARIANT_PACKAGING_VARIANT_ID_BUSINESS_DATA_KEY = 'packagingMaterialVariantId' +const ASSET_VARIANT_CORRESPONDING_BARCODE_FIELD_KEY = 'correspondingBarcode' type VariantMaterialMode = 'BOARD' | 'STEEL' | 'PACKAGING' @@ -1167,6 +1261,12 @@ const ASSET_VARIANT_SKU_TABLE_IMAGE_WIDTH = 74 const ASSET_VARIANT_SKU_TABLE_CELL_PADDING_WIDTH = 16 const ASSET_VARIANT_SKU_TABLE_COLUMN_MAX_WIDTH = 420 const ASSET_VARIANT_SKU_AUTO_SAVE_DEBOUNCE_MS = 800 +const ASSET_SKU_GROUP_READONLY_FIELD_KEYS = new Set([ + 'model', + 'skuCode', + 'manufacturerCode', + 'batchNo', +]) type VariantMaterialModeOption = readonly [VariantMaterialMode, string] const ASSET_VARIANT_MATERIAL_MODE_OPTIONS: ReadonlyArray = [ ['BOARD', '板材'], @@ -1238,6 +1338,7 @@ function emptyVariantForm( ) { return { assetFamilyId, + skuGroupId: '', materialMode, model: identityPrefix.endsWith('-') ? identityPrefix.slice(0, -1) : identityPrefix, code: '', @@ -1267,6 +1368,7 @@ function emptyVariantForm( supplierCode: '', versionNo: DEFAULT_SKU_VERSION_NO, barcode: '', + correspondingBarcode: '', length: '', width: '', thickness: '', @@ -1282,6 +1384,75 @@ function emptyVariantForm( } type VariantForm = ReturnType +type AssetSkuContentFields = Record + +function createDefaultAssetSkuContentFields(input?: { + model?: string + skuCode?: string + materialMode?: VariantMaterialMode +}): AssetSkuContentFields { + const materialMode = input?.materialMode ?? 'BOARD' + return { + model: input?.model?.trim().toUpperCase() ?? '', + skuCode: input?.skuCode?.trim().toUpperCase() ?? '', + barcode: '', + correspondingBarcode: '', + setCount: '1', + specName: '', + size: '', + color: '', + material: '', + boardMaterialType: NON_BOARD_ASSET_VARIANT_BOARD_MATERIAL_TYPE, + versionNo: DEFAULT_SKU_VERSION_NO, + attributeCode: DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE, + drawingId: '', + installMethod: DEFAULT_ASSET_VARIANT_INSTALL_METHOD, + executeStandard: getDefaultVariantExecuteStandard(materialMode), + nationalStandardCompliant: DEFAULT_ASSET_VARIANT_NATIONAL_STANDARD_COMPLIANT, + manufacturer: '', + manufacturerCode: '', + salesStatus: DEFAULT_ASSET_VARIANT_SALES_STATUS, + batchNo: '批次1', + dingTalkFlowNo: '', + } +} + +function completeAssetSkuContentFields( + fields: Partial>, + defaults?: Partial>, +): AssetSkuContentFields { + const base = createDefaultAssetSkuContentFields({ + model: defaults?.model, + skuCode: defaults?.skuCode, + }) + return { + ...base, + ...defaults, + ...fields, + model: (fields.model ?? defaults?.model ?? base.model).trim().toUpperCase(), + skuCode: (fields.skuCode ?? defaults?.skuCode ?? base.skuCode).trim().toUpperCase(), + barcode: (fields.barcode ?? defaults?.barcode ?? base.barcode).trim().toUpperCase(), + correspondingBarcode: normalizeAssetVariantCorrespondingBarcode( + fields.correspondingBarcode ?? defaults?.correspondingBarcode ?? base.correspondingBarcode, + ), + versionNo: normalizeSkuVersionNo(fields.versionNo ?? defaults?.versionNo ?? base.versionNo), + attributeCode: (fields.attributeCode ?? defaults?.attributeCode ?? base.attributeCode).trim().toUpperCase(), + drawingId: (fields.drawingId ?? defaults?.drawingId ?? base.drawingId).trim().toUpperCase(), + manufacturerCode: (fields.manufacturerCode ?? defaults?.manufacturerCode ?? base.manufacturerCode).trim().toUpperCase(), + } +} + +interface VariantCadImportDraft { + assetType: CadAssetPayloadType + zipFileName: string + modelFileName: string + dimensions: Record + mountPoints: AssetVariantDTO['mountPoints'] + assemblyBasis: Record + namingFields: Record + businessData: Record + warnings: string[] +} function resolveVariantBoardMaterialTypeForSku(form: VariantForm) { return form.boardMaterialType.trim() @@ -1312,12 +1483,167 @@ function resolveSupplierCodeByName(name: string) { return SUPPLIER_CODE_BY_NAME.get(name.trim())?.trim().toUpperCase() ?? '' } +type CorrespondingStockSkuMatch = { + product: NonIntegratedProductRecord + skuItem: NonIntegratedSkuItem +} + +function normalizeAssetVariantCorrespondingBarcode(value: string) { + return value.trim().toUpperCase() +} + +function readNonIntegratedProductMetadataText( + product: NonIntegratedProductRecord, + key: string, +) { + const metadata = isObjectRecord(product.metadata) ? product.metadata : null + const value = metadata?.[key] + if (typeof value === 'string') return value.trim() + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + return '' +} + +function findCorrespondingStockSkuMatch( + products: NonIntegratedProductRecord[], + rawBarcode: string, +): CorrespondingStockSkuMatch | null { + const barcode = normalizeAssetVariantCorrespondingBarcode(rawBarcode) + if (!barcode) return null + + for (const product of products) { + const matchedSku = product.skuItems.find((skuItem) => { + const skuCode = normalizeAssetVariantCorrespondingBarcode(skuItem.skuCode) + const skuBarcode = normalizeAssetVariantCorrespondingBarcode(skuItem.barcode) + const sourceSkuId = normalizeAssetVariantCorrespondingBarcode(skuItem.sourceSkuId ?? '') + return skuCode === barcode || skuBarcode === barcode || sourceSkuId === barcode + }) + if (matchedSku) return { product, skuItem: matchedSku } + + const metadataSourceSkuId = normalizeAssetVariantCorrespondingBarcode( + readNonIntegratedProductMetadataText(product, 'sourceSkuId'), + ) + if (metadataSourceSkuId === barcode && product.skuItems[0]) { + return { product, skuItem: product.skuItems[0] } + } + } + + return null +} + +function resolveCorrespondingStockSkuBarcode( + skuItem: NonIntegratedSkuItem, + rawLookupValue: string, +) { + return normalizeAssetVariantCorrespondingBarcode( + skuItem.barcode || + skuItem.sourceSkuId || + skuItem.skuCode || + rawLookupValue, + ) +} + +function applyCorrespondingStockSkuToVariantForm( + current: VariantForm, + match: CorrespondingStockSkuMatch, + rawBarcode: string, +): VariantForm { + const skuItem = match.skuItem + const stockBarcode = resolveCorrespondingStockSkuBarcode(skuItem, rawBarcode) + const supplierName = skuItem.manufacturer.trim() + const supplierCode = + skuItem.manufacturerCode.trim().toUpperCase() || + (supplierName ? resolveSupplierCodeByName(supplierName) : '') + const salesStatus = + skuItem.salesStatus.trim() || + readNonIntegratedProductMetadataText(match.product, 'sourceSalesStatus') + + return { + ...current, + correspondingBarcode: stockBarcode, + barcode: stockBarcode || current.barcode, + model: skuItem.model.trim().toUpperCase() || current.model, + name: skuItem.specName.trim() || current.name, + setCount: skuItem.setCount.trim() || current.setCount, + sizeLabel: skuItem.size.trim() || current.sizeLabel, + color: skuItem.color.trim() || current.color, + material: skuItem.material.trim() || current.material, + boardMaterialType: current.materialMode === 'BOARD' + ? skuItem.boardMaterialType?.trim() || current.boardMaterialType + : current.boardMaterialType, + versionNo: skuItem.versionNo.trim() || current.versionNo, + attributeCode: skuItem.attributeCode.trim().toUpperCase() || current.attributeCode, + drawingId: skuItem.drawingId.trim().toUpperCase() || current.drawingId, + installMethod: skuItem.installMethod.trim() || current.installMethod, + executeStandard: skuItem.executeStandard.trim() || current.executeStandard, + nationalStandardCompliant: + skuItem.nationalStandardCompliant.trim() || current.nationalStandardCompliant, + supplierName: supplierName || current.supplierName, + supplierCode: supplierCode || current.supplierCode, + salesStatus: salesStatus || current.salesStatus, + } +} + +function applyCorrespondingStockSkuToSkuContentFields( + current: AssetSkuContentFields, + match: CorrespondingStockSkuMatch, + rawBarcode: string, + materialMode: VariantMaterialMode, +): AssetSkuContentFields { + const skuItem = match.skuItem + const stockBarcode = resolveCorrespondingStockSkuBarcode(skuItem, rawBarcode) + const supplierName = skuItem.manufacturer.trim() + const supplierCode = + skuItem.manufacturerCode.trim().toUpperCase() || + (supplierName ? resolveSupplierCodeByName(supplierName) : '') + const salesStatus = + skuItem.salesStatus.trim() || + readNonIntegratedProductMetadataText(match.product, 'sourceSalesStatus') + + return completeAssetSkuContentFields( + { + ...current, + correspondingBarcode: stockBarcode, + barcode: stockBarcode || current.barcode, + model: skuItem.model.trim().toUpperCase() || current.model, + setCount: skuItem.setCount.trim() || current.setCount, + specName: skuItem.specName.trim() || current.specName, + size: skuItem.size.trim() || current.size, + color: skuItem.color.trim() || current.color, + material: skuItem.material.trim() || current.material, + boardMaterialType: + materialMode === 'BOARD' + ? skuItem.boardMaterialType?.trim() || current.boardMaterialType + : current.boardMaterialType || NON_BOARD_ASSET_VARIANT_BOARD_MATERIAL_TYPE, + versionNo: skuItem.versionNo.trim() || current.versionNo, + attributeCode: skuItem.attributeCode.trim().toUpperCase() || current.attributeCode, + drawingId: skuItem.drawingId.trim().toUpperCase() || current.drawingId, + installMethod: skuItem.installMethod.trim() || current.installMethod, + executeStandard: + skuItem.executeStandard.trim() || + current.executeStandard || + getDefaultVariantExecuteStandard(materialMode), + nationalStandardCompliant: + skuItem.nationalStandardCompliant.trim() || current.nationalStandardCompliant, + manufacturer: supplierName || current.manufacturer, + manufacturerCode: supplierCode || current.manufacturerCode, + salesStatus: salesStatus || current.salesStatus, + }, + { + model: current.model, + skuCode: current.skuCode, + }, + ) +} + function resolveVariantSkuVersionNo(form: VariantForm) { const currentValue = form.versionNo.trim() return currentValue ? normalizeSkuVersionNo(currentValue) : '' } function resolveVariantBarcode(form: VariantForm) { + const explicitBarcode = form.barcode.trim().toUpperCase() + if (explicitBarcode) return explicitBarcode + if (!form.supplierCode.trim()) return '' const versionNo = resolveVariantSkuVersionNo(form) if (!versionNo) return '' @@ -1335,39 +1661,41 @@ function resolveVariantDrawingId(form: VariantForm) { return `${model}${form.attributeCode.trim().toUpperCase()}${resolveVariantSkuVersionNo(form)}` } -function buildVariantSkuContentBusinessData( +function resolveAssetSkuContentBarcode(fields: AssetSkuContentFields) { + const explicitBarcode = fields.barcode.trim().toUpperCase() + if (explicitBarcode) return explicitBarcode + + const skuCode = fields.skuCode.trim().toUpperCase() + const manufacturerCode = fields.manufacturerCode.trim().toUpperCase() + if (!skuCode || !manufacturerCode) return '' + + return buildAssetVariantBarcode({ + variantCode: skuCode, + supplierCode: manufacturerCode, + versionNo: normalizeSkuVersionNo(fields.versionNo), + }) +} + +function resolveAssetSkuContentDrawingId(fields: AssetSkuContentFields) { + const explicitDrawingId = fields.drawingId.trim().toUpperCase() + if (explicitDrawingId) return explicitDrawingId + + const model = fields.model.trim().toUpperCase() + if (!model) return '' + return `${model}${fields.attributeCode.trim().toUpperCase()}${normalizeSkuVersionNo(fields.versionNo)}` +} + +function buildVariantModelBusinessData( form: VariantForm, existingBusinessData?: Record | null, ): Record { const base = isObjectRecord(existingBusinessData) ? { ...existingBusinessData } : {} - const versionNo = resolveVariantSkuVersionNo(form) base[ASSET_VARIANT_MATERIAL_MODE_BUSINESS_DATA_KEY] = form.materialMode base[ASSET_VARIANT_STEEL_FRAME_ITEM_ID_BUSINESS_DATA_KEY] = form.materialMode === 'STEEL' ? form.steelFrameItemId.trim() : '' base[ASSET_VARIANT_PACKAGING_VARIANT_ID_BUSINESS_DATA_KEY] = form.materialMode === 'PACKAGING' ? form.packagingMaterialVariantId.trim() : '' - base[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY] = { - model: form.model.trim(), - skuCode: form.code.trim(), - barcode: resolveVariantBarcode(form), - setCount: form.setCount.trim(), - specName: form.name.trim(), - size: form.sizeLabel.trim(), - color: form.color.trim(), - material: form.material.trim(), - boardMaterialType: resolveVariantBoardMaterialTypeForSku(form), - versionNo, - attributeCode: form.attributeCode.trim().toUpperCase(), - drawingId: resolveVariantDrawingId(form), - installMethod: form.installMethod.trim(), - executeStandard: form.executeStandard.trim(), - nationalStandardCompliant: form.nationalStandardCompliant.trim(), - manufacturer: form.supplierName.trim(), - manufacturerCode: form.supplierCode.trim().toUpperCase(), - salesStatus: form.salesStatus.trim(), - batchNo: form.batchNo.trim() || '批次1', - dingTalkFlowNo: form.dingTalkFlowNo.trim(), - } satisfies Partial> + delete base[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY] return base } @@ -1531,6 +1859,7 @@ function matchesReviewVisibilityFilter(status: string, filter: ReviewVisibilityF export default function AssetCenterClient({ initialFamilies, + initialSkuGroups = [], initialVariants, initialSubstrates, initialSubstrateDictionaries: _initialSubstrateDictionaries, @@ -1547,6 +1876,7 @@ export default function AssetCenterClient({ // 数据状态 const [families, setFamilies] = useState(() => sortAssetFamiliesByCreatedTime(initialFamilies)) + const [skuGroups, setSkuGroups] = useState(() => sortAssetSkuGroups(initialSkuGroups)) const [variants, setVariants] = useState(() => sortAssetVariantsByCreatedTime(normalizeAssetVariantsWithUv(initialVariants)), ) @@ -1585,6 +1915,7 @@ export default function AssetCenterClient({ // 资产族展开状态(记录哪些资产族展开了规格款) const [expandedFamilyIds, setExpandedFamilyIds] = useState>(new Set()) + const [expandedSkuGroupIds, setExpandedSkuGroupIds] = useState>(new Set()) // 资产族弹窗 const [familyModalOpen, setFamilyModalOpen] = useState(false) @@ -1603,6 +1934,17 @@ export default function AssetCenterClient({ const [roleEditorMode, setRoleEditorMode] = useState<'create' | 'edit'>('create') const [editingRoleValue, setEditingRoleValue] = useState(null) const [roleEditorLabel, setRoleEditorLabel] = useState('') + const [skuGroupModalOpen, setSkuGroupModalOpen] = useState(false) + const [skuGroupModalMode, setSkuGroupModalMode] = useState<'create' | 'edit'>('create') + const [editingSkuGroupId, setEditingSkuGroupId] = useState(null) + const [skuGroupForm, setSkuGroupForm] = useState({ + assetFamilyId: '', + code: '', + name: '', + description: '', + sortOrder: '', + skuContentFields: createDefaultAssetSkuContentFields(), + }) // 删除目标状态 const [deleteTarget, setDeleteTarget] = useState<{ @@ -1617,6 +1959,7 @@ export default function AssetCenterClient({ const [packagingVariantModalOpen, setPackagingVariantModalOpen] = useState(false) const [variantModalMode, setVariantModalMode] = useState<'create' | 'edit'>('create') const [variantForm, setVariantForm] = useState(emptyVariantForm('')) + const [variantCadImportDraft, setVariantCadImportDraft] = useState(null) const [editingVariantId, setEditingVariantId] = useState(null) const [variantSkuColumnWidths, setVariantSkuColumnWidths] = useState( createDefaultAssetVariantSkuColumnWidths, @@ -1670,11 +2013,14 @@ export default function AssetCenterClient({ ) const [variantCreateUvConfig, setVariantCreateUvConfig] = useState(null) const [draftVariantSubstrateId, setDraftVariantSubstrateId] = useState(null) + const [correspondingBarcodeLookupLoading, setCorrespondingBarcodeLookupLoading] = useState(false) const variantUvEditorFormRef = useRef(variantUvEditorForm) const variantSaveProgressPollerRef = useRef(null) const variantSkuAutoSaveTimerRef = useRef(null) const variantSkuAutoSaveRequestRef = useRef(0) const lastVariantSkuAutoSaveSignatureRef = useRef('') + const correspondingBarcodeLookupRequestRef = useRef(0) + const lastPulledCorrespondingBarcodeRef = useRef('') const [draftVariantUvConfig, setDraftVariantUvConfig] = useState(null) const draftVariantUvConfigRef = useRef(null) const activeVariantUvNodeNameRef = useRef(null) @@ -1971,6 +2317,74 @@ export default function AssetCenterClient({ return map }, [variants]) + const skuGroupsByFamilyId = useMemo(() => { + const map = new Map() + skuGroups.forEach((group) => { + const current = map.get(group.assetFamilyId) ?? [] + current.push(group) + map.set(group.assetFamilyId, current) + }) + map.forEach((familyGroups, familyId) => { + map.set(familyId, sortAssetSkuGroups(familyGroups)) + }) + return map + }, [skuGroups]) + + const skuGroupTreeByFamilyId = useMemo(() => { + const map = new Map() + const groupById = new Map(skuGroups.map((group) => [group.id, group])) + + families.forEach((family) => { + const familyGroups = skuGroupsByFamilyId.get(family.id) ?? [] + const nodesById = new Map() + familyGroups.forEach((group) => { + nodesById.set(group.id, { ...group, variants: [] }) + }) + + const familyVariants = variantsByFamilyId.get(family.id) ?? [] + familyVariants.forEach((variant) => { + const persistedGroup = variant.skuGroupId + ? groupById.get(variant.skuGroupId) ?? null + : null + const groupId = persistedGroup?.id ?? `variant:${variant.id}` + const existingNode = nodesById.get(groupId) + if (existingNode) { + existingNode.variants.push(variant) + return + } + + nodesById.set(groupId, { + id: groupId, + assetFamilyId: family.id, + assetFamilyCode: family.code, + assetFamilyName: family.name, + assetType: family.type, + code: (variant.skuGroupCode ?? variant.code).trim().toUpperCase(), + name: variant.skuGroupName?.trim() || variant.name, + description: null, + sortOrder: variant.skuGroupSortOrder ?? Number.MAX_SAFE_INTEGER, + metadata: null, + variantCount: 1, + createdAt: variant.createdAt, + updatedAt: variant.updatedAt, + variants: [variant], + }) + }) + + map.set( + family.id, + sortAssetSkuGroupTreeNodes( + Array.from(nodesById.values()).map((node) => ({ + ...node, + variants: sortAssetVariantsByCreatedTime(node.variants), + })), + ), + ) + }) + + return map + }, [families, skuGroups, skuGroupsByFamilyId, variantsByFamilyId]) + const selectedSeries = useMemo( () => initialAssetSeries.find((series) => series.id === selectedSeriesId) ?? null, [initialAssetSeries, selectedSeriesId], @@ -2168,6 +2582,24 @@ export default function AssetCenterClient({ }) }, [selectedFamiliesForAssembly, selectedSeriesId]) + const buildSkuGroupAssemblyLaunchHref = useCallback( + (family: AssetFamilyDTO, group: AssetSkuGroupTreeNode) => { + return buildManualAssemblyLaunchHref({ + entryMode: 'asset-center', + source: 'asset-center', + assetSeriesId: selectedSeriesId, + selectedFamilyIdsByType: { + desktopFamilyIds: family.type === 'BOARD' ? [family.id] : [], + legFamilyIds: family.type === 'COMPONENT' ? [family.id] : [], + connectorFamilyIds: family.type === 'HARDWARE' ? [family.id] : [], + }, + selectedSkuGroupIds: group.id.startsWith('variant:') ? [] : [group.id], + selectedSkuGroupCodes: [group.code], + }) + }, + [selectedSeriesId], + ) + const selectedCadImportFamily = useMemo( () => families.find((family) => family.id === cadImportForm.familyId) ?? null, [families, cadImportForm.familyId], @@ -2187,6 +2619,10 @@ export default function AssetCenterClient({ ?? selectedVariantSubstrate?.finishes.find((finish) => finish.id === variantForm.finishId) ?? null }, [finishes, selectedVariantSubstrate, variantForm.finishId]) + const variantFormSkuGroupOptions = useMemo( + () => skuGroupsByFamilyId.get(variantForm.assetFamilyId) ?? [], + [skuGroupsByFamilyId, variantForm.assetFamilyId], + ) const boardSubstrateOptions = useMemo( () => substrates.filter((substrate) => !isSteelLikeSubstrate(substrate)), [substrates], @@ -2457,18 +2893,333 @@ export default function AssetCenterClient({ return familyVariants.filter((variant) => matchesReviewVisibilityFilter(variant.status, filter)) } + function getSkuGroupTreeForFamily( + familyId: string, + filter: ReviewVisibilityFilter = 'ALL', + ) { + const familyGroups = skuGroupTreeByFamilyId.get(familyId) ?? [] + if (filter === 'ALL') return familyGroups + + return familyGroups + .map((group) => ({ + ...group, + variants: group.variants.filter((variant) => + matchesReviewVisibilityFilter(variant.status, filter), + ), + })) + .filter((group) => group.variants.length > 0) + } + + function resolveVariantSkuGroupNodeId(variant: Pick) { + return variant.skuGroupId ?? `variant:${variant.id}` + } + + function resolveVariantDisplayCode(variant: AssetVariantDTO, group: AssetSkuGroupTreeNode) { + return (variant.skuGroupCode ?? (!group.id.startsWith('variant:') ? group.code : null) ?? variant.code) + .trim() + .toUpperCase() + } + + function buildVariantModelNameFromSkuGroupCode(skuGroupCode: string, sequence: number) { + const normalizedSkuGroupCode = skuGroupCode.trim().toUpperCase() + if (!normalizedSkuGroupCode) return '' + return `${normalizedSkuGroupCode}模型${sequence}` + } + + function resolveVariantModelDisplayName( + variant: AssetVariantDTO, + group: AssetSkuGroupTreeNode, + variantIndex: number, + ) { + const skuGroupCode = resolveVariantDisplayCode(variant, group) + return ( + buildVariantModelNameFromSkuGroupCode(skuGroupCode, variantIndex + 1) || + variant.name.trim() || + variant.code.trim().toUpperCase() + ) + } + + function buildNextVariantModelNameForSkuGroup(familyId: string, skuGroupId: string) { + const skuGroup = (skuGroupsByFamilyId.get(familyId) ?? []).find((group) => group.id === skuGroupId) + if (!skuGroup) return '' + const existingCount = (variantsByFamilyId.get(familyId) ?? []).filter( + (variant) => resolveVariantSkuGroupNodeId(variant) === skuGroupId, + ).length + return buildVariantModelNameFromSkuGroupCode(skuGroup.code, existingCount + 1) + } + + function upsertSkuGroupFromVariant(variant: AssetVariantDTO) { + if (!variant.skuGroupId || !variant.skuGroupCode) return + + const nextGroup: AssetSkuGroupDTO = { + id: variant.skuGroupId, + assetFamilyId: variant.assetFamilyId, + assetFamilyCode: variant.assetFamilyCode, + assetFamilyName: variant.assetFamilyName, + assetType: variant.assetType, + code: variant.skuGroupCode, + name: variant.skuGroupName ?? variant.skuGroupCode, + description: null, + sortOrder: variant.skuGroupSortOrder ?? Number.MAX_SAFE_INTEGER, + metadata: null, + variantCount: 0, + createdAt: variant.createdAt, + updatedAt: variant.updatedAt, + } + + setSkuGroups((current) => { + const exists = current.some((group) => group.id === nextGroup.id) + return sortAssetSkuGroups( + exists + ? current.map((group) => (group.id === nextGroup.id ? { ...group, ...nextGroup } : group)) + : [...current, nextGroup], + ) + }) + } + function toggleFamilyExpand(familyId: string) { + const familyGroupIds = (skuGroupTreeByFamilyId.get(familyId) ?? []) + .filter((group) => group.variants.length > 0) + .map((group) => group.id) setExpandedFamilyIds((prev) => { const next = new Set(prev) if (next.has(familyId)) { next.delete(familyId) } else { next.add(familyId) + if (familyGroupIds.length > 0) { + setExpandedSkuGroupIds((current) => { + const expandedGroups = new Set(current) + familyGroupIds.forEach((groupId) => expandedGroups.add(groupId)) + return expandedGroups + }) + } } return next }) } + function toggleSkuGroupExpand(groupId: string) { + setExpandedSkuGroupIds((prev) => { + const next = new Set(prev) + if (next.has(groupId)) { + next.delete(groupId) + } else { + next.add(groupId) + } + return next + }) + } + + function openCreateSkuGroupModal(family: AssetFamilyDTO) { + if (!canOperateDesignWorkspace) { + showReadOnlyWarning() + return + } + + const familyGroups = skuGroupTreeByFamilyId.get(family.id) ?? [] + correspondingBarcodeLookupRequestRef.current += 1 + setCorrespondingBarcodeLookupLoading(false) + setSkuGroupModalMode('create') + setEditingSkuGroupId(null) + setSkuGroupForm({ + assetFamilyId: family.id, + code: '', + name: '', + description: '', + sortOrder: String(familyGroups.length + 1), + skuContentFields: createDefaultAssetSkuContentFields({ + model: family.code, + skuCode: '', + materialMode: family.type === 'BOARD' ? 'BOARD' : 'STEEL', + }), + }) + setSkuGroupModalOpen(true) + } + + async function openEditSkuGroupModal(group: AssetSkuGroupTreeNode) { + if (!canOperateDesignWorkspace) { + showReadOnlyWarning() + return + } + if (group.id.startsWith('variant:')) { + const fallbackVariant = group.variants[0] ?? null + if (!fallbackVariant || group.assetType !== 'PACKAGING') { + toast.warning('请先保存一次模型明细,系统会生成可编辑货号组') + return + } + + setSubmitting(true) + try { + const updatedVariant = normalizeAssetVariantWithUv( + await updateAssetVariant(fallbackVariant.id, {}, { skipThumbnail: true }), + ) + startTransition(() => { + setVariants((current) => + current.map((variant) => (variant.id === updatedVariant.id ? updatedVariant : variant)), + ) + upsertSkuGroupFromVariant(updatedVariant) + if (updatedVariant.skuGroupId) { + setExpandedSkuGroupIds((current) => new Set(current).add(updatedVariant.skuGroupId as string)) + } + }) + if (!updatedVariant.skuGroupId || !updatedVariant.skuGroupCode) { + toast.error('包材货号补全失败,请稍后再试') + return + } + openEditSkuGroupModal({ + id: updatedVariant.skuGroupId, + assetFamilyId: updatedVariant.assetFamilyId, + assetFamilyCode: updatedVariant.assetFamilyCode, + assetFamilyName: updatedVariant.assetFamilyName, + assetType: updatedVariant.assetType, + code: updatedVariant.skuGroupCode, + name: updatedVariant.skuGroupName ?? updatedVariant.skuGroupCode, + description: null, + sortOrder: updatedVariant.skuGroupSortOrder ?? group.sortOrder, + metadata: null, + variantCount: group.variants.length, + createdAt: updatedVariant.createdAt, + updatedAt: updatedVariant.updatedAt, + variants: [updatedVariant], + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : '包材货号补全失败,请稍后再试') + } finally { + setSubmitting(false) + } + return + } + + correspondingBarcodeLookupRequestRef.current += 1 + setCorrespondingBarcodeLookupLoading(false) + setSkuGroupModalMode('edit') + setEditingSkuGroupId(group.id) + const fallbackVariant = group.variants[0] ?? null + const fallbackFields = fallbackVariant + ? readAssetVariantSkuContentFields(fallbackVariant.businessData) + : {} + const groupFields = readAssetSkuGroupSkuContentFields(group.metadata) + setSkuGroupForm({ + assetFamilyId: group.assetFamilyId, + code: group.code, + name: group.name, + description: group.description ?? '', + sortOrder: String(group.sortOrder ?? 0), + skuContentFields: completeAssetSkuContentFields( + Object.keys(groupFields).length > 0 ? groupFields : fallbackFields, + { + model: group.assetFamilyCode, + skuCode: group.code, + specName: group.name, + }, + ), + }) + setSkuGroupModalOpen(true) + } + + function closeSkuGroupModal() { + correspondingBarcodeLookupRequestRef.current += 1 + setCorrespondingBarcodeLookupLoading(false) + setSkuGroupModalOpen(false) + setEditingSkuGroupId(null) + } + + async function handleSaveSkuGroup() { + if (!canOperateDesignWorkspace) { + showReadOnlyWarning() + return + } + const code = skuGroupForm.code.trim().toUpperCase() + const name = skuGroupForm.name.trim() + if (!skuGroupForm.assetFamilyId || !code) { + toast.error('请填写货号') + return + } + + setSubmitting(true) + try { + const skuContentFields = completeAssetSkuContentFields(skuGroupForm.skuContentFields, { + model: skuGroupForm.skuContentFields.model || code, + skuCode: code, + specName: name || code, + }) + const resolvedSkuContentFields = { + ...skuContentFields, + barcode: skuContentFields.barcode || resolveAssetSkuContentBarcode(skuContentFields), + drawingId: skuContentFields.drawingId || resolveAssetSkuContentDrawingId(skuContentFields), + } + const payload = { + code, + name: name || code, + description: skuGroupForm.description.trim() || null, + sortOrder: toOptionalNumber(skuGroupForm.sortOrder) ?? 0, + metadata: buildAssetSkuGroupMetadata( + skuGroups.find((group) => group.id === editingSkuGroupId)?.metadata, + resolvedSkuContentFields, + ), + } + if (skuGroupModalMode === 'create') { + const created = await createAssetSkuGroup({ + assetFamilyId: skuGroupForm.assetFamilyId, + ...payload, + }) + startTransition(() => { + setSkuGroups((current) => sortAssetSkuGroups([...current, created])) + setExpandedFamilyIds((current) => new Set(current).add(created.assetFamilyId)) + setExpandedSkuGroupIds((current) => new Set(current).add(created.id)) + closeSkuGroupModal() + toast.success('货号已创建') + }) + } else if (editingSkuGroupId) { + const updated = await updateAssetSkuGroup(editingSkuGroupId, payload) + startTransition(() => { + setSkuGroups((current) => + sortAssetSkuGroups(current.map((group) => ( + group.id === updated.id ? updated : group + ))), + ) + closeSkuGroupModal() + toast.success('货号已更新') + }) + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '货号保存失败,请稍后再试') + } finally { + setSubmitting(false) + } + } + + async function handleDeleteSkuGroup(group: AssetSkuGroupTreeNode) { + if (!canOperateDesignWorkspace) { + showReadOnlyWarning() + return + } + if (group.id.startsWith('variant:')) return + if (group.variants.length > 0) { + toast.error('该货号下还有模型明细,不能删除') + return + } + + setSubmitting(true) + try { + await deleteAssetSkuGroup(group.id) + startTransition(() => { + setSkuGroups((current) => current.filter((item) => item.id !== group.id)) + setExpandedSkuGroupIds((current) => { + const next = new Set(current) + next.delete(group.id) + return next + }) + toast.success('货号已删除') + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : '货号删除失败,请稍后再试') + } finally { + setSubmitting(false) + } + } + function openCadImportModal(family: AssetFamilyDTO) { if (!canOperateDesignWorkspace) { showReadOnlyWarning() @@ -2837,34 +3588,46 @@ export default function AssetCenterClient({ clearVariantSkuAutoSaveTimer() variantSkuAutoSaveRequestRef.current += 1 lastVariantSkuAutoSaveSignatureRef.current = '' + correspondingBarcodeLookupRequestRef.current += 1 + lastPulledCorrespondingBarcodeRef.current = '' + setCorrespondingBarcodeLookupLoading(false) setVariantSkuAutoSaveStatus('idle') } - function openCreateVariantModal(family: AssetFamilyDTO) { + function openCreateVariantModal(family: AssetFamilyDTO, skuGroupId = '') { if (!canOperateDesignWorkspace) { showReadOnlyWarning() return } if (family.type === 'PACKAGING') { - openCreatePackagingVariantModal(family) + openCreatePackagingVariantModal(family, skuGroupId) return } resetVariantSkuAutoSaveState() + setVariantCadImportDraft(null) setVariantModalMode('create') setEditingVariantId(null) setVariantCreateUvConfig(null) - setVariantForm(emptyVariantForm( - family.id, - buildVariantIdentityPrefix(family.code), - family.type === 'BOARD' - ? 'BOARD' - : 'STEEL', - )) + const nextForm = { + ...emptyVariantForm( + family.id, + buildVariantIdentityPrefix(family.code), + family.type === 'BOARD' + ? 'BOARD' + : 'STEEL', + ), + skuGroupId, + } + const nextModelName = skuGroupId ? buildNextVariantModelNameForSkuGroup(family.id, skuGroupId) : '' + if (nextModelName) { + nextForm.name = nextModelName + } + setVariantForm(nextForm) setExpandedFamilyIds((prev) => new Set(prev).add(family.id)) setVariantModalOpen(true) } - function openCreatePackagingVariantModal(family: AssetFamilyDTO) { + function openCreatePackagingVariantModal(family: AssetFamilyDTO, skuGroupId = '') { if (!canOperateDesignWorkspace) { showReadOnlyWarning() return @@ -2875,8 +3638,10 @@ export default function AssetCenterClient({ 'PACKAGING', ) nextForm.code = buildNextAssetVariantCodeForFamily(family, variants) + nextForm.skuGroupId = skuGroupId resetVariantSkuAutoSaveState() + setVariantCadImportDraft(null) setVariantModalMode('create') setEditingVariantId(null) setVariantCreateUvConfig(null) @@ -2902,12 +3667,14 @@ export default function AssetCenterClient({ const nextUvConfig = normalizeAssetVariantUvConfig(draft?.uvConfig ?? variant.uvConfig ?? null) const skuContentFields = readAssetVariantSkuContentFields(variant.businessData) resetVariantSkuAutoSaveState() + setVariantCadImportDraft(null) setVariantModalMode('create') setEditingVariantId(null) setVariantCreateUvConfig(nextUvConfig) const materialMode = inferVariantMaterialMode(variant, draft?.substrateId ?? variant.substrateId) setVariantForm({ assetFamilyId: variant.assetFamilyId, + skuGroupId: variant.skuGroupId ?? '', materialMode, model: skuContentFields.model ?? variant.assetFamilyCode, code: '', @@ -2969,6 +3736,7 @@ export default function AssetCenterClient({ DEFAULT_SKU_VERSION_NO, ), barcode: variant.barcode ?? skuContentFields.barcode ?? '', + correspondingBarcode: '', length: String(dims.length ?? ''), width: String(dims.width ?? ''), thickness: String(dims.thickness ?? ''), @@ -2990,6 +3758,7 @@ export default function AssetCenterClient({ return } resetVariantSkuAutoSaveState() + setVariantCadImportDraft(null) setVariantModalMode('edit') setEditingVariantId(variant.id) setVariantCreateUvConfig(null) @@ -2997,8 +3766,12 @@ export default function AssetCenterClient({ const mp = variant.mountPoints?.[0] const skuContentFields = readAssetVariantSkuContentFields(variant.businessData) const materialMode = inferVariantMaterialMode(variant) + const correspondingBarcode = skuContentFields.correspondingBarcode ?? '' + lastPulledCorrespondingBarcodeRef.current = + normalizeAssetVariantCorrespondingBarcode(correspondingBarcode) setVariantForm({ assetFamilyId: variant.assetFamilyId, + skuGroupId: variant.skuGroupId ?? '', materialMode, model: skuContentFields.model ?? variant.assetFamilyCode, code: variant.code, @@ -3060,6 +3833,7 @@ export default function AssetCenterClient({ DEFAULT_SKU_VERSION_NO, ), barcode: variant.barcode ?? skuContentFields.barcode ?? '', + correspondingBarcode, modelUrl: variant.modelUrl ?? '', length: String(dims.length ?? ''), width: String(dims.width ?? ''), @@ -3079,8 +3853,12 @@ export default function AssetCenterClient({ clearVariantSaveProgressPolling() clearVariantSkuAutoSaveTimer() variantSkuAutoSaveRequestRef.current += 1 + correspondingBarcodeLookupRequestRef.current += 1 + lastPulledCorrespondingBarcodeRef.current = '' setVariantSaveProgress(null) setVariantSkuAutoSaveStatus('idle') + setCorrespondingBarcodeLookupLoading(false) + setVariantCadImportDraft(null) setVariantModalOpen(false) setEditingVariantId(null) setVariantCreateUvConfig(null) @@ -3090,8 +3868,12 @@ export default function AssetCenterClient({ clearVariantSaveProgressPolling() clearVariantSkuAutoSaveTimer() variantSkuAutoSaveRequestRef.current += 1 + correspondingBarcodeLookupRequestRef.current += 1 + lastPulledCorrespondingBarcodeRef.current = '' setVariantSaveProgress(null) setVariantSkuAutoSaveStatus('idle') + setCorrespondingBarcodeLookupLoading(false) + setVariantCadImportDraft(null) setPackagingVariantModalOpen(false) setEditingVariantId(null) setVariantCreateUvConfig(null) @@ -3137,6 +3919,7 @@ export default function AssetCenterClient({ function buildVariantSkuUpdatePayload( existingBusinessData?: Record | null, + extraBusinessData?: Record | null, ) { const versionNo = resolveVariantSkuVersionNo(variantForm) const barcode = resolveVariantBarcode({ @@ -3154,13 +3937,18 @@ export default function AssetCenterClient({ return { name: normalizedForm.name, sizeLabel: normalizedForm.sizeLabel || null, - supplierName: normalizedForm.supplierName || null, - supplierCode: normalizedForm.supplierCode || null, - versionNo, - barcode: barcode || null, - businessData: buildVariantSkuContentBusinessData( + supplierName: null, + supplierCode: null, + versionNo: null, + barcode: null, + businessData: buildVariantModelBusinessData( normalizedForm, - existingBusinessData, + extraBusinessData + ? { + ...(isObjectRecord(existingBusinessData) ? existingBusinessData : {}), + ...extraBusinessData, + } + : existingBusinessData, ), } } @@ -3236,6 +4024,90 @@ export default function AssetCenterClient({ variants, ]) + async function uploadVariantModelOrCadZip(file: File) { + const lowerName = file.name.toLowerCase() + if (!lowerName.endsWith('.zip')) { + const url = await uploadModelFile(file) + setVariantCadImportDraft(null) + setVariantForm((current) => ({ ...current, modelUrl: url })) + return + } + + const family = families.find((item) => item.id === variantForm.assetFamilyId) ?? null + if (!family || !isImportableAssetType(family.type)) { + throw new Error('当前资产族不支持 CAD ZIP 导入') + } + + const parsed = await parseZipFile(file) + if (!parsed.success || !parsed.modelFile || !parsed.cadExportData) { + throw new Error(parsed.error || 'CAD ZIP 解析失败') + } + + const validation = validateCADExportData(parsed.cadExportData) + if (!validation.valid) { + throw new Error(`CAD 数据校验失败: ${validation.errors.join(';')}`) + } + + const cadData = buildCadDataFromExport(parsed.cadExportData) + const warnings: string[] = [] + const dimensions = buildCadAssetDimensions({ + assetType: family.type, + cadData, + cadExportData: parsed.cadExportData, + warnings, + }) + const mountPoints = buildCadAssetMountPoints(parsed.cadExportData) + const modelUrl = await uploadModelFile(parsed.modelFile) + const sizeLabel = buildCadAssetSizeLabel(family.type, dimensions) + const businessData = buildCadAssetBusinessData({ + assetType: family.type, + cadData, + cadExportData: parsed.cadExportData, + zipFileName: file.name, + }) + + setVariantCadImportDraft({ + assetType: family.type, + zipFileName: file.name, + modelFileName: parsed.modelFile.name, + dimensions, + mountPoints, + assemblyBasis: buildCadAssetAssemblyBasis({ + assetType: family.type, + cadData, + cadExportData: parsed.cadExportData, + }), + namingFields: buildCadAssetNamingFields({ + assetType: family.type, + cadData, + cadExportData: parsed.cadExportData, + }), + businessData, + warnings, + }) + setVariantForm((current) => ({ + ...current, + modelUrl, + sizeLabel: current.sizeLabel.trim() || sizeLabel || current.sizeLabel, + length: dimensions.length != null ? String(dimensions.length) : current.length, + width: dimensions.width != null ? String(dimensions.width) : current.width, + thickness: dimensions.thickness != null + ? String(dimensions.thickness) + : dimensions.height != null + ? String(dimensions.height) + : dimensions.depth != null + ? String(dimensions.depth) + : current.thickness, + mountPointName: mountPoints[0]?.name ?? current.mountPointName, + mountX: String(mountPoints[0]?.position?.[0] ?? current.mountX), + mountY: String(mountPoints[0]?.position?.[1] ?? current.mountY), + mountZ: String(mountPoints[0]?.position?.[2] ?? current.mountZ), + })) + + toast.success(`CAD ZIP 已解析,模型 ${parsed.modelFile.name} 已上传`) + warnings.forEach((warning) => toast.warning(warning)) + } + function applyInheritedVariant(variantId: string) { const inheritedVariant = variants.find((v) => v.id === variantId) if (!inheritedVariant) return @@ -3314,6 +4186,8 @@ export default function AssetCenterClient({ return variantForm.code case 'barcode': return variantForm.barcode || resolveVariantBarcode(variantForm) + case 'correspondingBarcode': + return variantForm.correspondingBarcode case 'setCount': return variantForm.setCount case 'specName': @@ -3356,6 +4230,11 @@ export default function AssetCenterClient({ switch (key) { case 'barcode': return { ...current, barcode: value.toUpperCase() } + case 'correspondingBarcode': + return { + ...current, + correspondingBarcode: normalizeAssetVariantCorrespondingBarcode(value), + } case 'setCount': return { ...current, setCount: value } case 'specName': @@ -3398,10 +4277,293 @@ export default function AssetCenterClient({ }) } + function getSkuGroupSkuFieldValue(key: AssetVariantSkuFieldKey) { + const fields = skuGroupForm.skuContentFields + switch (key) { + case 'barcode': + return fields.barcode || resolveAssetSkuContentBarcode(fields) + case 'drawingId': + return fields.drawingId || resolveAssetSkuContentDrawingId(fields) + case 'versionNo': + return normalizeSkuVersionNo(fields.versionNo) + default: + return fields[key] ?? '' + } + } + + function updateSkuGroupSkuFieldValue(key: AssetVariantSkuFieldKey, value: string) { + setSkuGroupForm((current) => { + const fields = current.skuContentFields + switch (key) { + case 'barcode': + return { + ...current, + skuContentFields: { ...fields, barcode: value.toUpperCase() }, + } + case 'correspondingBarcode': + return { + ...current, + skuContentFields: { + ...fields, + correspondingBarcode: normalizeAssetVariantCorrespondingBarcode(value), + }, + } + case 'skuCode': + return current + case 'model': + return { + ...current, + skuContentFields: { ...fields, model: value.toUpperCase() }, + } + case 'attributeCode': + return { + ...current, + skuContentFields: { ...fields, attributeCode: value.toUpperCase() }, + } + case 'drawingId': + return { + ...current, + skuContentFields: { ...fields, drawingId: value.toUpperCase() }, + } + case 'manufacturer': { + const supplierCode = resolveSupplierCodeByName(value) + return { + ...current, + skuContentFields: { + ...fields, + manufacturer: value, + manufacturerCode: supplierCode, + }, + } + } + case 'manufacturerCode': + case 'batchNo': + return current + default: + return { + ...current, + skuContentFields: { ...fields, [key]: value }, + } + } + }) + } + + function getVariantFormSkuGroupLookupCode() { + if (!variantForm.skuGroupId) return '' + const selectedGroup = + variantFormSkuGroupOptions.find((group) => group.id === variantForm.skuGroupId) ?? null + return selectedGroup?.code.trim().toUpperCase() ?? '' + } + + function getCorrespondingStockSkuLookupValue() { + return ( + getVariantFormSkuGroupLookupCode() || + normalizeAssetVariantCorrespondingBarcode(variantForm.correspondingBarcode) + ) + } + + function getSkuGroupFormMaterialMode(assetFamilyId = skuGroupForm.assetFamilyId): VariantMaterialMode { + const family = families.find((item) => item.id === assetFamilyId) ?? null + if (family?.type === 'PACKAGING') return 'PACKAGING' + return family?.type === 'BOARD' ? 'BOARD' : 'STEEL' + } + + function getSkuGroupCorrespondingStockSkuLookupValue() { + return ( + normalizeAssetVariantCorrespondingBarcode(skuGroupForm.skuContentFields.correspondingBarcode) || + skuGroupForm.code.trim().toUpperCase() + ) + } + + async function pullSkuGroupCorrespondingStockSku( + rawLookupValue = getSkuGroupCorrespondingStockSkuLookupValue(), + ) { + const lookupValue = normalizeAssetVariantCorrespondingBarcode(rawLookupValue) + if (!lookupValue) { + toast.error('请先填写货号或对应存量资料条码') + return + } + + const requestId = correspondingBarcodeLookupRequestRef.current + 1 + correspondingBarcodeLookupRequestRef.current = requestId + setCorrespondingBarcodeLookupLoading(true) + + try { + const { products } = await listSharedNonIntegratedProductPage({ + page: 1, + pageSize: 20, + productKind: 'stock', + stockSku: lookupValue, + }) + if (correspondingBarcodeLookupRequestRef.current !== requestId) return + + const match = findCorrespondingStockSkuMatch(products, lookupValue) + if (!match) { + toast.error(`未找到对应存量明细:${lookupValue}`) + return + } + + setSkuGroupForm((current) => ({ + ...current, + skuContentFields: applyCorrespondingStockSkuToSkuContentFields( + current.skuContentFields, + match, + lookupValue, + getSkuGroupFormMaterialMode(current.assetFamilyId), + ), + })) + + const stockIdentity = + match.skuItem.skuCode.trim() || + match.skuItem.barcode.trim() || + lookupValue + toast.success(`已拉取存量明细:${stockIdentity}`) + } catch (error) { + if (correspondingBarcodeLookupRequestRef.current !== requestId) return + toast.error(error instanceof Error ? error.message : '存量明细拉取失败') + } finally { + if (correspondingBarcodeLookupRequestRef.current === requestId) { + setCorrespondingBarcodeLookupLoading(false) + } + } + } + + function isVariantFormLinkedToCorrespondingStockSku() { + const barcode = normalizeAssetVariantCorrespondingBarcode(variantForm.correspondingBarcode) + return Boolean(barcode && barcode === lastPulledCorrespondingBarcodeRef.current) + } + + async function pullCorrespondingStockSku( + rawBarcode = variantForm.correspondingBarcode, + options: { showEmptyError?: boolean } = {}, + ) { + const barcode = normalizeAssetVariantCorrespondingBarcode(rawBarcode) + if (!barcode) { + if (options.showEmptyError !== false) { + toast.error('请先选择归属货号或填写对应条码') + } + return + } + + const requestId = correspondingBarcodeLookupRequestRef.current + 1 + correspondingBarcodeLookupRequestRef.current = requestId + setCorrespondingBarcodeLookupLoading(true) + + try { + const { products } = await listSharedNonIntegratedProductPage({ + page: 1, + pageSize: 20, + productKind: 'stock', + stockSku: barcode, + }) + if (correspondingBarcodeLookupRequestRef.current !== requestId) return + + const match = findCorrespondingStockSkuMatch(products, barcode) + if (!match) { + toast.error(`未找到对应存量明细:${barcode}`) + return + } + + setVariantForm((current) => + applyCorrespondingStockSkuToVariantForm(current, match, barcode), + ) + const stockBarcode = resolveCorrespondingStockSkuBarcode(match.skuItem, barcode) + lastPulledCorrespondingBarcodeRef.current = stockBarcode + const stockIdentity = + match.skuItem.skuCode.trim() || + match.skuItem.barcode.trim() || + barcode + toast.success(`已拉取存量明细:${stockIdentity}`) + } catch (error) { + if (correspondingBarcodeLookupRequestRef.current !== requestId) return + toast.error(error instanceof Error ? error.message : '存量明细拉取失败') + } finally { + if (correspondingBarcodeLookupRequestRef.current === requestId) { + setCorrespondingBarcodeLookupLoading(false) + } + } + } + + function handleCorrespondingBarcodeBlur(value: string) { + const barcode = normalizeAssetVariantCorrespondingBarcode(value) + if (!barcode || barcode === lastPulledCorrespondingBarcodeRef.current) return + + void pullCorrespondingStockSku(barcode, { showEmptyError: false }) + } + + function renderCorrespondingBarcodeControl() { + const value = variantForm.correspondingBarcode + const skuGroupLookupCode = getVariantFormSkuGroupLookupCode() + const lookupValue = getCorrespondingStockSkuLookupValue() + const canLookup = Boolean(lookupValue) + + return ( +
+

对应条码

+
+ handleCorrespondingBarcodeBlur(event.target.value)} + onChange={(event) => + setVariantForm((current) => ({ + ...current, + correspondingBarcode: normalizeAssetVariantCorrespondingBarcode(event.target.value), + })) + } + className="h-10 min-w-0 flex-1 rounded-md border border-[var(--obs-shell-border)] bg-white px-3 text-sm font-black text-[#2d3335] outline-none placeholder:text-[#8a9498] focus:border-[#8fa2a8]" + /> + +
+
+ ) + } + + function renderSkuGroupCorrespondingBarcodeControl() { + const value = skuGroupForm.skuContentFields.correspondingBarcode + const skuGroupCode = skuGroupForm.code.trim().toUpperCase() + const lookupValue = getSkuGroupCorrespondingStockSkuLookupValue() + const canLookup = Boolean(lookupValue) + + return ( +
+

对应存量资料条码

+
+ updateSkuGroupSkuFieldValue('correspondingBarcode', event.target.value)} + className="h-11 min-w-0 flex-1 rounded-apple-md border border-apple-separator bg-apple-bg-secondary px-4 text-apple-base font-apple-system font-black tracking-apple-tighter text-apple-label-primary outline-none placeholder:text-apple-label-tertiary hover:bg-apple-bg-tertiary hover:border-apple-label-tertiary focus:border-2 focus:border-apple-blue focus:bg-white focus:px-[15px]" + /> + +
+
+ ) + } + function renderVariantSkuContentTable() { const editingVariant = editingVariantId ? variants.find((item) => item.id === editingVariantId) ?? null : null + const isLinkedToCorrespondingStockSku = isVariantFormLinkedToCorrespondingStockSku() const skuFieldDefinitions = isPackagingVariantForm ? ASSET_VARIANT_SKU_FIELD_DEFINITIONS.filter((field) => field.key !== 'boardMaterialType') : ASSET_VARIANT_SKU_FIELD_DEFINITIONS @@ -3413,9 +4575,16 @@ export default function AssetCenterClient({

SKU CONTENT

产品信息

- - 1 行 - +
+ {isLinkedToCorrespondingStockSku ? ( + + 存量商品 · {variantForm.correspondingBarcode} + + ) : null} + + 1 行 + +
@@ -3472,7 +4641,9 @@ export default function AssetCenterClient({ const value = getVariantSkuFieldValue(field.key) const isEditableDerivedField = field.key === 'color' || field.key === 'material' - const isReadonly = field.control === 'readonly' && !isEditableDerivedField + const isReadonly = + isLinkedToCorrespondingStockSku || + (field.control === 'readonly' && !isEditableDerivedField) const isFieldDisabled = isReadonly || (variantForm.materialMode !== 'BOARD' && field.key === 'boardMaterialType') @@ -3525,16 +4696,161 @@ export default function AssetCenterClient({ value={value} options={field.options ?? []} placeholder={field.placeholder} + readOnly={isFieldDisabled} dropdownId={`asset-variant-supplier-options-${variantForm.assetFamilyId || 'new'}`} onChange={(nextValue) => updateVariantSkuFieldValue(field.key, nextValue)} /> + ) : ( + updateVariantSkuFieldValue(field.key, event.target.value)} + className={cn( + 'h-9 min-w-0 flex-1 rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[13px] font-black text-[#2d3335] outline-none placeholder:text-[#8a9498]', + isReadonly && 'bg-[#f7f9f9] text-[#5a6062]/70', + )} + /> + )} + + + + ) + })} + + +
+
+ + ) + } + + function renderSkuGroupSkuContentTable() { + const isSkuGroupSkuContentLocked = Boolean( + normalizeAssetVariantCorrespondingBarcode( + skuGroupForm.skuContentFields.correspondingBarcode, + ), + ) + + return ( +
+
+
+

SKU CONTENT

+

产品信息

+
+ + 1 行 + +
+
+ + + + + + {ASSET_VARIANT_SKU_FIELD_DEFINITIONS.map((field) => { + const columnWidth = getAssetVariantSkuFieldWidth(variantSkuColumnWidths, field) + return ( + + ) + })} + + + + + + + {ASSET_VARIANT_SKU_FIELD_DEFINITIONS.map((field) => { + const value = getSkuGroupSkuFieldValue(field.key) + const isReadonly = + isSkuGroupSkuContentLocked || ASSET_SKU_GROUP_READONLY_FIELD_KEYS.has(field.key) + const columnWidth = getAssetVariantSkuFieldWidth(variantSkuColumnWidths, field) + const columnMinWidth = getAssetVariantSkuFieldMinWidth(field) + const selectOptions = + field.control === 'select' + ? Array.from(new Set([ + ...(value && !(field.options ?? []).includes(value) ? [value] : []), + ...(field.options ?? []), + ])) + : [] + + return ( +
+ 序号 + + 图片 + + {field.label} +
+ 1 + +
+ 暂无图片 +
+
+ + setVariantSkuColumnWidths((current) => ({ + ...current, + [field.key]: normalizeAssetVariantSkuColumnWidth(field, nextWidth), + })) + } + > +
+ {field.control === 'select' ? ( + + ) : field.control === 'combobox' ? ( + updateSkuGroupSkuFieldValue(field.key, nextValue)} + /> ) : ( updateVariantSkuFieldValue(field.key, event.target.value)} + onChange={(event) => updateSkuGroupSkuFieldValue(field.key, event.target.value)} className={cn( 'h-9 min-w-0 flex-1 rounded-[5px] border border-[#dbe3e5] bg-white px-2 text-[13px] font-black text-[#2d3335] outline-none placeholder:text-[#8a9498]', isReadonly && 'bg-[#f7f9f9] text-[#5a6062]/70', @@ -3599,14 +4915,15 @@ export default function AssetCenterClient({ } setSubmitting(true) try { - const dimensions = toNumberRecord({ + const formDimensions = toNumberRecord({ length: variantForm.length, width: variantForm.width, thickness: variantForm.thickness, }) + const dimensions = variantCadImportDraft?.dimensions ?? formDimensions const variantMountPoints: AssetVariantDTO['mountPoints'] = isPackagingVariantForm ? [] - : [ + : variantCadImportDraft?.mountPoints ?? [ { name: variantForm.mountPointName || 'origin', position: [ @@ -3621,13 +4938,14 @@ export default function AssetCenterClient({ : toOptionalNumber(variantForm.lossFactor) if (variantModalMode === 'create') { - const skuPayload = buildVariantSkuUpdatePayload() + const skuPayload = buildVariantSkuUpdatePayload(null, variantCadImportDraft?.businessData) const progress = buildInitialVariantSaveProgress(isDuplicateVariantMode) setVariantSaveProgress(progress) startVariantSaveProgressPolling(progress.operationId) const createdResult = await createAssetVariantWithSimilarity({ assetFamilyId: variantForm.assetFamilyId, + skuGroupId: variantForm.skuGroupId || null, name: skuPayload.name, status: 'DRAFT', sizeLabel: skuPayload.sizeLabel || undefined, @@ -3640,6 +4958,8 @@ export default function AssetCenterClient({ dimensions, modelUrl: isPackagingVariantForm ? '' : variantForm.modelUrl, mountPoints: variantMountPoints, + assemblyBasis: variantCadImportDraft?.assemblyBasis, + namingFields: variantCadImportDraft?.namingFields, lossFactor: variantLossFactor, businessData: skuPayload.businessData, inheritedFromVariantId: variantForm.inheritedFromVariantId || null, @@ -3664,10 +4984,12 @@ export default function AssetCenterClient({ const created = normalizeAssetVariantWithUv(createdResult.variant) const createdUvConfig = normalizeAssetVariantUvConfig(created.uvConfig ?? null) void refreshVariantThumbnailAfterCreate(created) + upsertSkuGroupFromVariant(created) startTransition(() => { setVariants((current) => sortAssetVariantsByCreatedTime([created, ...current])) setExpandedFamilyIds((prev) => new Set(prev).add(created.assetFamilyId)) + setExpandedSkuGroupIds((prev) => new Set(prev).add(resolveVariantSkuGroupNodeId(created))) draftVariantUvConfigRef.current = createdUvConfig setDraftVariantUvConfig(createdUvConfig) setDraftVariantSubstrateId(created.substrateId ?? null) @@ -3693,16 +5015,23 @@ export default function AssetCenterClient({ } else { if (!editingVariantId) return const editingVariant = variants.find((item) => item.id === editingVariantId) ?? null - const skuPayload = buildVariantSkuUpdatePayload(editingVariant?.businessData) + const skuPayload = buildVariantSkuUpdatePayload( + editingVariant?.businessData, + variantCadImportDraft?.businessData, + ) const updated = normalizeAssetVariantWithUv(await updateAssetVariant(editingVariantId, { ...skuPayload, + skuGroupId: variantForm.skuGroupId || null, substrateId: resolvedVariantSubstrateId, finishId: resolvedVariantFinishId, dimensions, modelUrl: isPackagingVariantForm ? undefined : variantForm.modelUrl || undefined, mountPoints: isPackagingVariantForm ? undefined : variantMountPoints, + assemblyBasis: isPackagingVariantForm ? undefined : variantCadImportDraft?.assemblyBasis, + namingFields: isPackagingVariantForm ? undefined : variantCadImportDraft?.namingFields, lossFactor: isPackagingVariantForm ? undefined : variantLossFactor, })) + upsertSkuGroupFromVariant(updated) startTransition(() => { setVariants((current) => current.map((v) => (v.id === editingVariantId ? updated : v)), @@ -3753,6 +5082,7 @@ export default function AssetCenterClient({ const skuPayload = buildVariantSkuUpdatePayload() const createdResult = await createPackagingAssetVariant({ assetFamilyId: variantForm.assetFamilyId, + skuGroupId: variantForm.skuGroupId || null, code: variantForm.code || undefined, name: skuPayload.name, status: 'DRAFT', @@ -3771,10 +5101,12 @@ export default function AssetCenterClient({ inheritedFromVariantId: null, }) const created = normalizeAssetVariantWithUv(createdResult.variant) + upsertSkuGroupFromVariant(created) startTransition(() => { setVariants((current) => sortAssetVariantsByCreatedTime([created, ...current])) setExpandedFamilyIds((prev) => new Set(prev).add(created.assetFamilyId)) + setExpandedSkuGroupIds((prev) => new Set(prev).add(resolveVariantSkuGroupNodeId(created))) setSelectedVariantId(created.id) closePackagingVariantModal() toast.success('包材货号已创建') @@ -5129,7 +6461,7 @@ export default function AssetCenterClient({ ) : ( currentFamilies.map((family) => { const totalFamilyVariants = getVariantsForFamily(family.id) - const familyVariants = getVariantsForFamily(family.id, reviewFilter) + const familySkuGroups = getSkuGroupTreeForFamily(family.id, reviewFilter) const isExpanded = expandedFamilyIds.has(family.id) const isSelectedForAssembly = assemblySelectedFamilyIds.has(family.id) @@ -5258,101 +6590,213 @@ export default function AssetCenterClient({ {/* 规格款列表(展开时) */} {isExpanded && (
- {familyVariants.length > 0 ? ( + {familySkuGroups.length > 0 ? (
- {familyVariants.map((variant) => { - const isSelected = selectedVariantId === variant.id + {familySkuGroups.map((group) => { + const isGroupExpanded = expandedSkuGroupIds.has(group.id) + const hasMultipleVariants = group.variants.length > 1 + const groupSelected = group.variants.some((variant) => variant.id === selectedVariantId) + const groupName = group.name.trim() + const singleVariantName = group.variants[0]?.name.trim() ?? '' + const shouldShowGroupName = + groupName.length > 0 && + (hasMultipleVariants || + group.variants.length === 0 || + groupName !== singleVariantName) + const groupSummary = + group.variants.length > 0 + ? `${group.variants.length} 个模型明细` + : '暂无模型明细' return ( -
setSelectedVariantId(variant.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setSelectedVariantId(variant.id) - } - }} - className={cn( - 'flex cursor-pointer items-center gap-2 border-l-[3px] px-5 py-2.5 outline-none transition focus-visible:ring-1 focus-visible:ring-[#2d3335]/15', - isSelected - ? 'border-l-[#101719] bg-white' - : 'border-l-transparent hover:bg-white/70', - )} - > -
-
-

- {variant.name} +

+
toggleSkuGroupExpand(group.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleSkuGroupExpand(group.id) + } + }} + className={cn( + 'flex cursor-pointer items-center gap-2 px-5 py-2.5 outline-none transition focus-visible:ring-1 focus-visible:ring-[#2d3335]/15', + groupSelected ? 'bg-white' : 'hover:bg-white/70', + )} + > + {isGroupExpanded ? ( + + ) : ( + + )} +
+

+ {group.code} +

+

+ {shouldShowGroupName ? `${groupName} · ${groupSummary}` : groupSummary}

-

- {variant.code} - {variant.sizeLabel ? ` · ${variant.sizeLabel}` : ''} - {variant.status === 'REJECTED' && variant.workflow.rejectionReason - ? ` · 驳回:${variant.workflow.rejectionReason}` - : ''} -

-
-
- {renderCompactStatus(variant.status, `variant-status-${variant.id}`)} - {canOperateDesignWorkspace && (variant.status === 'DRAFT' || variant.status === 'REJECTED') ? ( +
- ) : null} - {variant.status === 'PENDING_REVIEW' && currentUserCanReviewAssets ? ( - <> + + + {!group.id.startsWith('variant:') && group.variants.length === 0 ? ( - - - ) : null} - + ) : null} +
+ {isGroupExpanded ? ( +
+ {group.variants.length > 0 ? ( + group.variants.map((variant, variantIndex) => { + const isSelected = selectedVariantId === variant.id + const variantDisplayName = resolveVariantModelDisplayName( + variant, + group, + variantIndex, + ) + return ( +
setSelectedVariantId(variant.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setSelectedVariantId(variant.id) + } + }} + className={cn( + 'ml-5 flex cursor-pointer items-center gap-2 border-l-[3px] px-5 py-2 outline-none transition focus-visible:ring-1 focus-visible:ring-[#2d3335]/15', + isSelected + ? 'border-l-[#101719] bg-white' + : 'border-l-[#dbe3e5] hover:bg-white/70', + )} + > +
+

+ {variantDisplayName} + {variant.code} +

+
+
+ {renderCompactStatus(variant.status, `variant-status-${variant.id}`)} + {canOperateDesignWorkspace && (variant.status === 'DRAFT' || variant.status === 'REJECTED') ? ( + + ) : null} + {variant.status === 'PENDING_REVIEW' && currentUserCanReviewAssets ? ( + <> + + + + ) : null} + +
+
+ ) + }) + ) : ( +

+ 当前货号下暂无模型明细。 +

+ )} +
+ ) : null}
) })} @@ -5361,9 +6805,18 @@ export default function AssetCenterClient({
+ + +
+ } + > +
+ {renderSkuGroupSkuContentTable()} +
+ { + const nextCode = event.target.value.toUpperCase() + setSkuGroupForm((current) => ({ + ...current, + code: nextCode, + skuContentFields: { + ...current.skuContentFields, + skuCode: nextCode, + }, + })) + }} + placeholder="例如 DBZ-SPJ06 或 DNZ038406" + /> + { + const nextName = event.target.value + setSkuGroupForm((current) => ({ + ...current, + name: nextName, + skuContentFields: { + ...current.skuContentFields, + specName: current.skuContentFields.specName || nextName, + }, + })) + }} + placeholder="例如 口桌腿黑白金属" + /> + {renderSkuGroupCorrespondingBarcodeControl()} +
+
+ + setRoleEditorOpen(false)} diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index 02b65564..6da2340b 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -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="留空后端自动生成首拼" /> -
- - -