From b603555df7ea1486a6292baa059b041dd3cf25fc Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 15 May 2026 14:39:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E7=B3=BB=E5=88=97=E4=BC=98=E5=85=88=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 144 ++++++ furniture-3d-system/prisma/schema.prisma | 87 ++-- .../app/__tests__/draft-groups-page.test.tsx | 4 +- .../app/__tests__/home-client-delete.test.tsx | 2 +- .../src/app/__tests__/intake-page.test.tsx | 12 +- .../src/app/__tests__/page-nav.test.tsx | 14 +- .../app/api/asset-center/families/route.ts | 2 + .../app/api/asset-center/import-cad/route.ts | 2 + .../series}/route.ts | 6 +- .../src/app/api/platform/schemas.ts | 13 +- .../AssetCenterClient.preview-gizmo.test.tsx | 24 +- .../asset-center/AssetCenterClient.test.tsx | 43 +- .../app/asset-center/AssetCenterClient.tsx | 418 ++++++++++++++++-- .../AssetCenterClient.workflow.test.tsx | 82 ++-- .../MaterialLibraryClient.tsx | 230 ++++++---- .../PackagingMaterialsClient.test.tsx | 2 +- ...PackagingMaterialsClient.workflow.test.tsx | 2 +- .../src/app/asset-center/page.tsx | 6 +- .../AssistedAssemblyClient.test.tsx | 2 +- .../AssistedAssemblyClient.tsx | 3 + .../src/app/assisted-assembly/page.tsx | 3 +- .../app/home/components/HomeDraftsList.tsx | 2 +- .../src/app/home/home-client.types.ts | 2 +- .../src/app/intake/IntakeClient.test.tsx | 10 +- .../src/app/intake/IntakeClient.tsx | 8 +- .../integratedDraftGroupAdapter.test.ts | 6 +- .../integrated/integratedDraftGroupAdapter.ts | 2 +- .../integrated/integratedSeriesLoader.test.ts | 6 +- furniture-3d-system/src/app/intake/page.tsx | 2 +- .../lib/open-product/assembly-workspace.ts | 6 + .../open-product/manual-assembly-launch.ts | 7 + .../src/lib/open-product/platform-api.ts | 2 + .../src/lib/open-product/types.ts | 1 - .../client-helpers.test.ts | 2 +- .../repositories/asset-center.repository.ts | 96 +++- .../repositories/draft-group.repository.ts | 8 +- .../src/server/repositories/index.ts | 5 +- .../series-template.repository.ts | 53 --- .../asset-cad-import.service.test.ts | 5 +- .../__tests__/asset-center.service.test.ts | 2 - .../__tests__/asset-review.service.test.ts | 2 - .../__tests__/combination.service.test.ts | 258 ----------- .../draft-group.manual-assembly.test.ts | 4 +- .../__tests__/draft-group.service.test.ts | 57 +-- .../open-platform-demo-seed.service.test.ts | 147 +----- .../services/asset-cad-import.service.ts | 2 + .../server/services/asset-center.service.ts | 105 ++++- .../services/asset-center.service.types.ts | 23 +- .../server/services/combination.service.ts | 216 --------- .../services/combination.service.types.ts | 44 -- .../src/server/services/draft-group.mapper.ts | 8 +- .../server/services/draft-group.service.ts | 3 + .../services/draft-group.service.types.ts | 7 +- .../src/server/services/index.ts | 13 +- .../open-platform-demo-seed.service.ts | 116 +++-- .../services/series-template.service.ts | 108 ----- .../single-board-desk-materialization.ts | 39 -- 57 files changed, 1221 insertions(+), 1257 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql rename furniture-3d-system/src/app/api/{series-templates => asset-center/series}/route.ts (73%) delete mode 100644 furniture-3d-system/src/server/repositories/series-template.repository.ts delete mode 100644 furniture-3d-system/src/server/services/__tests__/combination.service.test.ts delete mode 100644 furniture-3d-system/src/server/services/combination.service.ts delete mode 100644 furniture-3d-system/src/server/services/combination.service.types.ts delete mode 100644 furniture-3d-system/src/server/services/series-template.service.ts diff --git a/furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql b/furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql new file mode 100644 index 00000000..de1d55fd --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql @@ -0,0 +1,144 @@ +-- Asset Center: replace legacy SeriesTemplate with AssetSeries compatibility scope. + +CREATE TABLE "AssetSeries" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "productCategoryId" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AssetSeries_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AssetSeries_code_key" ON "AssetSeries"("code"); +CREATE INDEX "AssetSeries_productCategoryId_idx" ON "AssetSeries"("productCategoryId"); +CREATE INDEX "AssetSeries_createdBy_idx" ON "AssetSeries"("createdBy"); +CREATE INDEX "AssetSeries_sortOrder_idx" ON "AssetSeries"("sortOrder"); +CREATE INDEX "AssetSeries_isActive_idx" ON "AssetSeries"("isActive"); + +ALTER TABLE "AssetSeries" + ADD CONSTRAINT "AssetSeries_productCategoryId_fkey" + FOREIGN KEY ("productCategoryId") REFERENCES "ProductCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AssetSeries" + ADD CONSTRAINT "AssetSeries_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +INSERT INTO "AssetSeries" ( + "id", + "code", + "name", + "description", + "productCategoryId", + "sortOrder", + "isActive", + "metadata", + "createdBy", + "createdAt", + "updatedAt" +) +SELECT + "id", + "code", + "name", + "description", + "categoryId", + ROW_NUMBER() OVER (ORDER BY "createdAt", "code") - 1, + "isActive", + jsonb_build_object('migratedFrom', 'SeriesTemplate', 'legacySeriesName', "seriesName"), + "createdBy", + "createdAt", + "updatedAt" +FROM "SeriesTemplate" +ON CONFLICT ("code") DO NOTHING; + +INSERT INTO "AssetSeries" ( + "id", + "code", + "name", + "sortOrder", + "isActive", + "metadata" +) +SELECT + 'asset-series-' || md5(series_code), + series_code, + series_code, + 1000 + ROW_NUMBER() OVER (ORDER BY series_code), + true, + jsonb_build_object('migratedFrom', 'AssetFamily.applicableSeriesCodes') +FROM ( + SELECT DISTINCT unnest("applicableSeriesCodes") AS series_code + FROM "AssetFamily" + WHERE array_length("applicableSeriesCodes", 1) > 0 +) source +WHERE series_code IS NOT NULL + AND btrim(series_code) <> '' +ON CONFLICT ("code") DO NOTHING; + +CREATE TABLE "AssetFamilySeriesCompatibility" ( + "id" TEXT NOT NULL, + "assetFamilyId" TEXT NOT NULL, + "assetSeriesId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AssetFamilySeriesCompatibility_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key" + ON "AssetFamilySeriesCompatibility"("assetFamilyId", "assetSeriesId"); +CREATE INDEX "AssetFamilySeriesCompatibility_assetFamilyId_idx" + ON "AssetFamilySeriesCompatibility"("assetFamilyId"); +CREATE INDEX "AssetFamilySeriesCompatibility_assetSeriesId_idx" + ON "AssetFamilySeriesCompatibility"("assetSeriesId"); + +ALTER TABLE "AssetFamilySeriesCompatibility" + ADD CONSTRAINT "AssetFamilySeriesCompatibility_assetFamilyId_fkey" + FOREIGN KEY ("assetFamilyId") REFERENCES "AssetFamily"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "AssetFamilySeriesCompatibility" + ADD CONSTRAINT "AssetFamilySeriesCompatibility_assetSeriesId_fkey" + FOREIGN KEY ("assetSeriesId") REFERENCES "AssetSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "AssetFamilySeriesCompatibility" ( + "id", + "assetFamilyId", + "assetSeriesId", + "createdAt" +) +SELECT + 'afsc-' || md5(f."id" || ':' || s."id"), + f."id", + s."id", + COALESCE(f."createdAt", CURRENT_TIMESTAMP) +FROM "AssetFamily" f +CROSS JOIN LATERAL unnest(f."applicableSeriesCodes") AS series_code +JOIN "AssetSeries" s ON s."code" = series_code +WHERE btrim(series_code) <> '' +ON CONFLICT ("assetFamilyId", "assetSeriesId") DO NOTHING; + +ALTER TABLE "DraftGroup" ADD COLUMN "assetSeriesId" TEXT; +UPDATE "DraftGroup" +SET "assetSeriesId" = "seriesTemplateId" +WHERE "seriesTemplateId" IS NOT NULL + AND EXISTS ( + SELECT 1 FROM "AssetSeries" s WHERE s."id" = "DraftGroup"."seriesTemplateId" + ); + +DROP INDEX IF EXISTS "DraftGroup_seriesTemplateId_idx"; +ALTER TABLE "DraftGroup" DROP CONSTRAINT IF EXISTS "DraftGroup_seriesTemplateId_fkey"; +ALTER TABLE "DraftGroup" DROP COLUMN IF EXISTS "seriesTemplateId"; +CREATE INDEX "DraftGroup_assetSeriesId_idx" ON "DraftGroup"("assetSeriesId"); +ALTER TABLE "DraftGroup" + ADD CONSTRAINT "DraftGroup_assetSeriesId_fkey" + FOREIGN KEY ("assetSeriesId") REFERENCES "AssetSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AssetFamily" DROP COLUMN IF EXISTS "applicableSeriesCodes"; + +DROP TABLE IF EXISTS "SeriesTemplate" CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 63515117..f4cc1d6e 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -27,7 +27,7 @@ model ProductCategory { planningTemplates PlanningTemplate[] @relation("PlanningTemplateCategory") planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCategory") assetFamilies AssetFamily[] @relation("AssetFamilyCategory") - seriesTemplates SeriesTemplate[] @relation("SeriesTemplateCategory") + assetSeries AssetSeries[] @relation("AssetSeriesCategory") draftGroups DraftGroup[] @relation("DraftGroupCategory") createdAt DateTime @default(now()) @@ -599,7 +599,6 @@ model AssetFamily { description String? @db.Text productCategoryId String? productCategory ProductCategory? @relation("AssetFamilyCategory", fields: [productCategoryId], references: [id]) - applicableSeriesCodes String[] @default([]) connectionRoles String[] @default([]) metadata Json? status AssetStatus @default(DRAFT) @@ -623,6 +622,7 @@ model AssetFamily { deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) variants AssetVariant[] + compatibleSeries AssetFamilySeriesCompatibility[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -639,6 +639,48 @@ model AssetFamily { @@index([deletedById]) } +model AssetSeries { + id String @id @default(cuid()) + code String @unique + name String + description String? @db.Text + + productCategoryId String? + category ProductCategory? @relation("AssetSeriesCategory", fields: [productCategoryId], references: [id]) + + sortOrder Int @default(0) + isActive Boolean @default(true) + metadata Json? + + createdBy String? + creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull) + + compatibleFamilies AssetFamilySeriesCompatibility[] + draftGroups DraftGroup[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productCategoryId]) + @@index([createdBy]) + @@index([sortOrder]) + @@index([isActive]) +} + +model AssetFamilySeriesCompatibility { + id String @id @default(cuid()) + assetFamilyId String + assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) + assetSeriesId String + assetSeries AssetSeries @relation(fields: [assetSeriesId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([assetFamilyId, assetSeriesId]) + @@index([assetFamilyId]) + @@index([assetSeriesId]) +} + model AssetVariant { id String @id @default(cuid()) assetFamilyId String @@ -707,39 +749,6 @@ model AssetVariant { @@index([deletedById]) } -model SeriesTemplate { - id String @id @default(cuid()) - code String @unique - name String - seriesName String - description String? @db.Text - - categoryId String? - category ProductCategory? @relation("SeriesTemplateCategory", fields: [categoryId], references: [id]) - - slotSchema Json - namingRule Json? - recommendationRule Json? - previewRule Json? - intakeRule Json? - - isPreset Boolean @default(false) - isActive Boolean @default(true) - - createdBy String? - creator User? @relation("SeriesTemplateCreator", fields: [createdBy], references: [id], onDelete: SetNull) - - draftGroups DraftGroup[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([categoryId]) - @@index([createdBy]) - @@index([isPreset]) - @@index([isActive]) -} - model DraftGroup { id String @id @default(cuid()) code String @unique @@ -749,8 +758,8 @@ model DraftGroup { categoryId String? category ProductCategory? @relation("DraftGroupCategory", fields: [categoryId], references: [id]) - seriesTemplateId String? - seriesTemplate SeriesTemplate? @relation(fields: [seriesTemplateId], references: [id], onDelete: SetNull) + assetSeriesId String? + assetSeries AssetSeries? @relation(fields: [assetSeriesId], references: [id], onDelete: SetNull) status DraftGroupStatus @default(DRAFT) summary Json @@ -765,7 +774,7 @@ model DraftGroup { updatedAt DateTime @updatedAt @@index([categoryId]) - @@index([seriesTemplateId]) + @@index([assetSeriesId]) @@index([createdBy]) @@index([status]) } @@ -1060,7 +1069,7 @@ model User { rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter") modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier") deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter") - createdSeriesTemplates SeriesTemplate[] @relation("SeriesTemplateCreator") + createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator") createdDraftGroups DraftGroup[] @relation("DraftGroupCreator") collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") diff --git a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx index 359a8a85..52ffc5ed 100644 --- a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx @@ -52,7 +52,7 @@ jest.mock('@/server/services', () => ({ name: '进行中的草稿', description: null, categoryName: '单板桌', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', status: 'DRAFT', summary: { selectedCount: 2 }, creatorName: '测试用户', @@ -64,7 +64,7 @@ jest.mock('@/server/services', () => ({ name: '已归档的草稿', description: null, categoryName: '单板桌', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', status: 'ARCHIVED', summary: null, creatorName: '测试用户', diff --git a/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx b/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx index a479225c..c8605b8e 100644 --- a/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx +++ b/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx @@ -167,7 +167,7 @@ const mockDraftGroup: DraftGroupSummary = { name: '我的草稿', description: '桌类方案', categoryName: '单板桌', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', status: 'DRAFT', summary: { selectedCount: 2 }, creatorName: '测试用户', diff --git a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx index 9ae74b8d..2fcd72a3 100644 --- a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx @@ -44,35 +44,35 @@ jest.mock('@/server/services', () => ({ code: 'DG-001', name: '包装中草稿', status: 'PACKAGING', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-2', code: 'DG-002', name: '待入库草稿', status: 'READY_FOR_INTAKE', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-3', code: 'DG-002', name: '未包装草稿', status: 'DRAFT', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-4', code: 'DG-003', name: '已入库草稿', status: 'FORMALIZED', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-5', code: 'DG-004', name: '已归档草稿', status: 'ARCHIVED', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, ]), getById: jest.fn().mockResolvedValue({ @@ -81,7 +81,7 @@ jest.mock('@/server/services', () => ({ code: 'DG-001', name: '包装中草稿', status: 'PACKAGING', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, preSkus: [], }), diff --git a/furniture-3d-system/src/app/__tests__/page-nav.test.tsx b/furniture-3d-system/src/app/__tests__/page-nav.test.tsx index 0dd055c4..e1230448 100644 --- a/furniture-3d-system/src/app/__tests__/page-nav.test.tsx +++ b/furniture-3d-system/src/app/__tests__/page-nav.test.tsx @@ -70,9 +70,9 @@ jest.mock('@/server/services/draft-group.service', () => ({ description: null, categoryId: null, categoryName: '单板桌', - seriesTemplateId: 'template-1', - seriesTemplateCode: 'single-board-desk', - seriesTemplateName: '单板桌', + assetSeriesId: 'template-1', + assetSeriesCode: 'single-board-desk', + assetSeriesName: '单板桌', status: 'DRAFT', summary: { selectedCount: 2 }, notes: null, @@ -88,9 +88,9 @@ jest.mock('@/server/services/draft-group.service', () => ({ description: null, categoryId: null, categoryName: '单板桌', - seriesTemplateId: 'template-1', - seriesTemplateCode: 'single-board-desk', - seriesTemplateName: '单板桌', + assetSeriesId: 'template-1', + assetSeriesCode: 'single-board-desk', + assetSeriesName: '单板桌', status: 'DRAFT', summary: null, notes: null, @@ -112,7 +112,7 @@ describe('首页工作台数据注入', () => { }) it('应只把当前账号的草稿和正式产品注入首页工作台', async () => { - render(await HomePage()) + render(await HomePage({ searchParams: Promise.resolve({}) })) expect(screen.getByText('HomeClient')).toBeInTheDocument() expect(mockedGetProducts).toHaveBeenCalledWith( diff --git a/furniture-3d-system/src/app/api/asset-center/families/route.ts b/furniture-3d-system/src/app/api/asset-center/families/route.ts index c902038b..8d1c7421 100644 --- a/furniture-3d-system/src/app/api/asset-center/families/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/families/route.ts @@ -11,6 +11,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const type = searchParams.get('type') const status = searchParams.get('status') + const assetSeriesId = searchParams.get('assetSeriesId')?.trim() || undefined try { const families = await assetCenterService.listFamilies({ @@ -22,6 +23,7 @@ export async function GET(request: NextRequest) { status && ['DRAFT', 'PENDING_REVIEW', 'REJECTED', 'AVAILABLE', 'DISABLED'].includes(status) ? (status as 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED') : undefined, + assetSeriesId, }) return jsonSuccess({ families }, 200, ctx.requestId) } catch (error) { diff --git a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts index 831f57a4..aebe79d6 100644 --- a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts @@ -57,6 +57,7 @@ export async function POST(request: NextRequest) { familyCode?: string | null familyName?: string | null familyDescription?: string | null + assetSeriesId?: string | null connectionRoles?: string[] variantCode?: string | null variantName?: string | null @@ -75,6 +76,7 @@ export async function POST(request: NextRequest) { familyCode: formData.get('familyCode'), familyName: formData.get('familyName'), familyDescription: formData.get('familyDescription'), + assetSeriesId: formData.get('assetSeriesId'), connectionRoles, variantCode: formData.get('variantCode'), variantName: formData.get('variantName'), diff --git a/furniture-3d-system/src/app/api/series-templates/route.ts b/furniture-3d-system/src/app/api/asset-center/series/route.ts similarity index 73% rename from furniture-3d-system/src/app/api/series-templates/route.ts rename to furniture-3d-system/src/app/api/asset-center/series/route.ts index 86511c31..4382492f 100644 --- a/furniture-3d-system/src/app/api/series-templates/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/series/route.ts @@ -1,15 +1,15 @@ import { NextRequest } from 'next/server' import { AppError } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { seriesTemplateService } from '@/server/services' +import { assetCenterService } from '@/server/services' export async function GET(request: NextRequest) { return handleRequest( request, async (ctx) => { try { - const templates = await seriesTemplateService.listTemplates() - return jsonSuccess({ templates }, 200, ctx.requestId) + const series = await assetCenterService.listSeries() + return jsonSuccess({ series }, 200, ctx.requestId) } catch (error) { if (error instanceof AppError) { return jsonError(error.code, error.message, error.statusCode, ctx.requestId) diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index d40cf78e..605efb58 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -45,7 +45,7 @@ export const assetFamilyCreateSchema = z.object({ name: z.string().trim().min(1), description: z.string().optional().nullable(), productCategoryId: z.string().trim().min(1).optional().nullable(), - applicableSeriesCodes: z.array(z.string().trim().min(1)).optional(), + compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(), connectionRoles: z.array(z.string().trim().min(1)).optional(), metadata: z.record(z.string(), z.unknown()).optional().nullable(), status: z.enum(['DRAFT']).optional(), @@ -56,7 +56,7 @@ export const assetFamilyUpdateSchema = z.object({ name: z.string().trim().min(1).optional(), description: z.string().optional().nullable(), productCategoryId: z.string().trim().min(1).optional().nullable(), - applicableSeriesCodes: z.array(z.string().trim().min(1)).optional(), + compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(), connectionRoles: z.array(z.string().trim().min(1)).optional(), metadata: z.record(z.string(), z.unknown()).optional().nullable(), status: assetStatusSchema.optional(), @@ -189,6 +189,7 @@ export const assetCadImportPayloadSchema = z.object({ familyCode: z.string().trim().min(1).optional().nullable(), familyName: z.string().trim().min(1).optional().nullable(), familyDescription: z.string().optional().nullable(), + assetSeriesId: z.string().trim().min(1).optional().nullable(), connectionRoles: z.array(z.string().trim().min(1)).optional(), variantCode: z.string().trim().min(1).optional().nullable(), variantName: z.string().trim().min(1).optional().nullable(), @@ -232,17 +233,11 @@ export const assetCadImportPayloadSchema = z.object({ } }) -export const generateCombinationSchema = z.object({ - seriesTemplateId: z.string().trim().min(1), - desktopFamilyIds: z.array(z.string().trim().min(1)).min(1), - legFamilyIds: z.array(z.string().trim().min(1)).optional(), - connectorFamilyIds: z.array(z.string().trim().min(1)).optional(), -}) - export const createDraftGroupSchema = z.object({ name: z.string().trim().min(1), description: z.string().optional().nullable(), categoryId: z.string().trim().min(1).optional().nullable(), + assetSeriesId: z.string().trim().min(1).optional().nullable(), notes: z.string().optional().nullable(), assemblies: z.array( z.object({ diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx index 3970a9af..7b017610 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx @@ -6,7 +6,7 @@ import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, - SeriesTemplateDTO, + AssetSeriesDTO, SubstrateDTO, } from '@/server/services' @@ -265,7 +265,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -333,14 +333,18 @@ const user = { department: null, } -const seriesTemplates = [ +const assetSeries = [ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, -] as SeriesTemplateDTO[] +] as AssetSeriesDTO[] describe('AssetCenterClient 预览操作区', () => { it('加载模型节点后不会默认选中第一个节点', async () => { @@ -351,7 +355,7 @@ describe('AssetCenterClient 预览操作区', () => { initialFamilies={[createFamily()]} initialVariants={[createVariant()]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -413,7 +417,7 @@ describe('AssetCenterClient 预览操作区', () => { }), ]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -498,7 +502,7 @@ describe('AssetCenterClient 预览操作区', () => { updatedAt: new Date('2026-04-09T10:00:00.000Z'), }, ] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -523,7 +527,7 @@ describe('AssetCenterClient 预览操作区', () => { initialFamilies={[createFamily()]} initialVariants={[createVariant()]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -567,7 +571,7 @@ describe('AssetCenterClient 预览操作区', () => { initialFamilies={[createFamily()]} initialVariants={[createVariant()]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} 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 3d5a3b30..cb54a4f6 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx @@ -6,7 +6,7 @@ import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, - SeriesTemplateDTO, + AssetSeriesDTO, SubstrateDTO, } from '@/server/services' import { @@ -237,7 +237,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -354,14 +354,18 @@ const user = { department: null, } -const seriesTemplates = [ +const assetSeries = [ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, -] as SeriesTemplateDTO[] +] as AssetSeriesDTO[] describe('AssetCenterClient', () => { beforeEach(() => { @@ -379,7 +383,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-001', name: '桌面板资产族', - applicableSeriesCodes: ['single-board-desk'], }), ]} initialVariants={[ @@ -433,7 +436,7 @@ describe('AssetCenterClient', () => { }, ]} initialSubstrates={[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -456,7 +459,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-010', name: '可用桌面板资产族', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -511,7 +513,7 @@ describe('AssetCenterClient', () => { }, ]} initialSubstrates={[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -549,7 +551,7 @@ describe('AssetCenterClient', () => { ]} initialVariants={[] as AssetVariantDTO[]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -575,7 +577,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-002', name: '待审核桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'PENDING_REVIEW', }), createFamily({ @@ -583,12 +584,11 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-003', name: '草稿桌面板', - applicableSeriesCodes: ['single-board-desk'], }), ]} initialVariants={[] as AssetVariantDTO[]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -662,7 +662,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-020', name: '缩略图桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -717,7 +716,7 @@ describe('AssetCenterClient', () => { }, ]} initialSubstrates={[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -780,7 +779,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-030', name: 'UV 桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -888,7 +886,7 @@ describe('AssetCenterClient', () => { ], }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[ { id: 'finish-1', @@ -1114,7 +1112,7 @@ describe('AssetCenterClient', () => { ], }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[ { id: 'finish-1', @@ -1206,7 +1204,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-DRAFT-UV', name: 'UV 草稿桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -1234,7 +1231,7 @@ describe('AssetCenterClient', () => { name: '25mm 颗粒板', }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[ { id: 'finish-1', @@ -1305,7 +1302,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-040', name: '右键桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -1371,7 +1367,7 @@ describe('AssetCenterClient', () => { name: '25mm 颗粒板', }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -1403,7 +1399,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-SUBSTRATE', name: '基材搜索桌面板', - applicableSeriesCodes: ['single-board-desk'], }), ]} initialVariants={[ @@ -1428,7 +1423,7 @@ describe('AssetCenterClient', () => { name: '胡桃木基材', }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index 99f0fe33..be4af06a 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -89,9 +89,9 @@ import type { AssetCadImportResult, AssetFamilyDTO, AssetOperationProgressDTO, + AssetSeriesDTO, SimilarAssetVariantCandidateDTO, AssetVariantDTO, - SeriesTemplateDTO, SubstrateDTO, SubstrateDictionaryBundleDTO, } from '@/server/services' @@ -103,7 +103,7 @@ interface AssetCenterClientProps { initialVariants: AssetVariantDTO[] initialSubstrates: SubstrateDTO[] initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO - initialSeriesTemplates: SeriesTemplateDTO[] + initialAssetSeries: AssetSeriesDTO[] finishes: MaterialDTO[] materialCategories: MaterialCategoryDTO[] metrics: PlatformMetricItem[] @@ -708,7 +708,10 @@ function buildAssetVariantHoleOverlayState(variant: AssetVariantDTO | null): { } } -// ---- 分类 Tab 定义 ---- +// ---- 系列与分类筛选 ---- + +const ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY = 'asset-center:selected-series-id' +type AssetTypeFilter = AssetFamilyDTO['type'] function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean { return family.type === 'PACKAGING' && getLevel1FromFamily(family.metadata ?? null) !== null @@ -716,7 +719,7 @@ function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean { function getVisibleFamiliesForTab( families: AssetFamilyDTO[], - activeTab: AssetFamilyDTO['type'], + activeTab: AssetTypeFilter, ): AssetFamilyDTO[] { return families.filter((family) => { if (family.type !== activeTab) return false @@ -725,7 +728,19 @@ function getVisibleFamiliesForTab( }) } -const TYPE_TABS: { type: AssetFamilyDTO['type']; label: string; icon: React.ReactNode }[] = [ +function familyBelongsToSeries(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { + if (!series) return false + const compatibleSeries = family.compatibleSeries ?? [] + return compatibleSeries.some((item) => item.id === series.id || item.code === series.code) +} + +function familyVisibleInSeriesContext(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { + if (isPackagingMaterialLibraryFamily(family)) return false + if (family.type === 'HARDWARE') return true + return familyBelongsToSeries(family, series) +} + +const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode }[] = [ { type: 'BOARD', label: '板材', icon: }, { type: 'COMPONENT', label: '组件', icon: }, { type: 'HARDWARE', label: '五金', icon: }, @@ -942,7 +957,7 @@ export default function AssetCenterClient({ initialVariants, initialSubstrates, initialSubstrateDictionaries: _initialSubstrateDictionaries, - initialSeriesTemplates, + initialAssetSeries, finishes: initialFinishes, materialCategories: _initialMaterialCategories, metrics, @@ -959,8 +974,14 @@ export default function AssetCenterClient({ const [selectedVariantId, setSelectedVariantId] = useState(null) const [submitting, setSubmitting] = useState(false) - // Tab 状态 - const [activeTab, setActiveTab] = useState('BOARD') + // 系列与分类筛选状态 + const [selectedSeriesId, setSelectedSeriesId] = useState( + initialAssetSeries[0]?.id ?? null, + ) + const [seriesPickerOpen, setSeriesPickerOpen] = useState(false) + const [seriesSearchText, setSeriesSearchText] = useState('') + const seriesPickerRef = useRef(null) + const [activeTab, setActiveTab] = useState('BOARD') const [assemblySelectedFamilyIds, setAssemblySelectedFamilyIds] = useState>(new Set()) // 搜索 @@ -978,7 +999,7 @@ export default function AssetCenterClient({ code: '', name: '', description: '', - applicableSeriesCode: initialSeriesTemplates[0]?.code ?? '', + compatibleSeriesIds: initialAssetSeries[0]?.id ? [initialAssetSeries[0].id] : [], selectedRoles: [] as string[], }) const [familyRoleDefinitions, setFamilyRoleDefinitions] = useState([]) @@ -1220,6 +1241,62 @@ export default function AssetCenterClient({ setVariantUvEditorForm(nextForm) }, [activeVariantUvNodeName]) + useEffect(() => { + const availableIds = new Set(initialAssetSeries.map((series) => series.id)) + const savedSeriesId = window.localStorage.getItem(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY) + if (savedSeriesId && availableIds.has(savedSeriesId)) { + setSelectedSeriesId(savedSeriesId) + return + } + + setSelectedSeriesId((current) => + current && availableIds.has(current) ? current : initialAssetSeries[0]?.id ?? null, + ) + }, [initialAssetSeries]) + + useEffect(() => { + if (selectedSeriesId) { + window.localStorage.setItem(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY, selectedSeriesId) + } + }, [selectedSeriesId]) + + useEffect(() => { + if (!seriesPickerOpen) return + + function handlePointerDown(event: PointerEvent) { + const target = event.target + if ( + target instanceof Node && + seriesPickerRef.current && + !seriesPickerRef.current.contains(target) + ) { + setSeriesPickerOpen(false) + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setSeriesPickerOpen(false) + } + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [seriesPickerOpen]) + + const handleSelectSeries = useCallback((seriesId: string) => { + setSelectedSeriesId(seriesId) + setSeriesPickerOpen(false) + setSeriesSearchText('') + setActiveTab('BOARD') + setSearchText('') + setReviewFilter('ALL') + }, []) + const variantsByFamilyId = useMemo(() => { const map = new Map() variants.forEach((variant) => { @@ -1230,8 +1307,56 @@ export default function AssetCenterClient({ return map }, [variants]) + const selectedSeries = useMemo( + () => initialAssetSeries.find((series) => series.id === selectedSeriesId) ?? null, + [initialAssetSeries, selectedSeriesId], + ) + + const seriesAssetCounts = useMemo(() => { + const map = new Map() + initialAssetSeries.forEach((series) => { + const count = families.filter( + (family) => + familyBelongsToSeries(family, series) && + family.type !== 'HARDWARE' && + !isPackagingMaterialLibraryFamily(family), + ).length + map.set(series.id, count) + }) + return map + }, [families, initialAssetSeries]) + + const filteredAssetSeries = useMemo(() => { + const keyword = seriesSearchText.trim().toLowerCase() + if (!keyword) return initialAssetSeries + return initialAssetSeries.filter((series) => { + return ( + series.name.toLowerCase().includes(keyword) || + series.code.toLowerCase().includes(keyword) || + (series.description ?? '').toLowerCase().includes(keyword) + ) + }) + }, [initialAssetSeries, seriesSearchText]) + + const selectedSeriesAssetCount = selectedSeries + ? seriesAssetCounts.get(selectedSeries.id) ?? 0 + : 0 + + const currentSeriesFamilies = useMemo(() => { + if (!selectedSeries) return [] + return families.filter((family) => familyVisibleInSeriesContext(family, selectedSeries)) + }, [families, selectedSeries]) + + useEffect(() => { + const visibleFamilyIds = new Set(currentSeriesFamilies.map((family) => family.id)) + setAssemblySelectedFamilyIds((current) => { + const next = new Set(Array.from(current).filter((id) => visibleFamilyIds.has(id))) + return next.size === current.size ? current : next + }) + }, [currentSeriesFamilies]) + const searchedFamilies = useMemo(() => { - const filtered = getVisibleFamiliesForTab(families, activeTab) + const filtered = getVisibleFamiliesForTab(currentSeriesFamilies, activeTab) if (!searchText.trim()) return filtered const keyword = searchText.trim().toLowerCase() return filtered.filter( @@ -1239,7 +1364,7 @@ export default function AssetCenterClient({ family.name.toLowerCase().includes(keyword) || family.code.toLowerCase().includes(keyword), ) - }, [families, activeTab, searchText]) + }, [currentSeriesFamilies, activeTab, searchText]) const currentFamilies = useMemo( () => @@ -1289,6 +1414,7 @@ export default function AssetCenterClient({ return buildManualAssemblyLaunchHref({ entryMode: 'asset-center', source: 'asset-center', + assetSeriesId: selectedSeriesId, selectedFamilyIdsByType: { desktopFamilyIds: selectedFamiliesForAssembly .filter((family) => family.type === 'BOARD') @@ -1301,7 +1427,7 @@ export default function AssetCenterClient({ .map((family) => family.id), }, }) - }, [selectedFamiliesForAssembly]) + }, [selectedFamiliesForAssembly, selectedSeriesId]) const selectedCadImportFamily = useMemo( () => families.find((family) => family.id === cadImportForm.familyId) ?? null, @@ -1558,27 +1684,35 @@ export default function AssetCenterClient({ // ---- 资产族操作 ---- function openCreateFamilyModal() { - const defaultSeriesCode = initialSeriesTemplates[0]?.code ?? '' + if (!selectedSeries) { + toast.warning('请先选择系列') + return + } + const defaultSeriesIds = selectedSeriesId ? [selectedSeriesId] : [] + const defaultSeriesCode = selectedSeries?.code ?? null + const defaultAssetType = activeTab setFamilyModalMode('create') setFamilyForm({ - type: activeTab, + type: defaultAssetType, code: '', name: '', description: '', - applicableSeriesCode: defaultSeriesCode, + compatibleSeriesIds: defaultAssetType === 'HARDWARE' ? [] : defaultSeriesIds, selectedRoles: [], }) setFamilyRoleDefinitions( mergeConnectionRoleDefinitions({ - assetType: activeTab, - seriesCode: defaultSeriesCode, + assetType: defaultAssetType, + seriesCode: defaultAssetType === 'HARDWARE' ? null : defaultSeriesCode, }), ) setFamilyModalOpen(true) } function openEditFamilyModal(family: AssetFamilyDTO) { - const applicableSeriesCode = family.applicableSeriesCodes[0] ?? '' + const compatibleSeries = family.compatibleSeries ?? [] + const compatibleSeriesIds = compatibleSeries.map((series) => series.id) + const primarySeriesCode = compatibleSeries[0]?.code ?? null const normalizedRoles = normalizeConnectionRoles(family.connectionRoles) setFamilyModalMode('edit') setEditingFamilyId(family.id) @@ -1587,13 +1721,13 @@ export default function AssetCenterClient({ code: family.code, name: family.name, description: family.description ?? '', - applicableSeriesCode, + compatibleSeriesIds, selectedRoles: normalizedRoles, }) setFamilyRoleDefinitions( mergeConnectionRoleDefinitions({ assetType: family.type, - seriesCode: applicableSeriesCode, + seriesCode: primarySeriesCode, selectedRoles: normalizedRoles, metadata: family.metadata, }), @@ -1641,7 +1775,7 @@ export default function AssetCenterClient({ value, label, assetType: familyForm.type, - seriesCode: familyForm.applicableSeriesCode || null, + seriesCode: initialAssetSeries.find((series) => familyForm.compatibleSeriesIds.includes(series.id))?.code ?? null, builtIn: false, }, ]) @@ -1653,7 +1787,11 @@ export default function AssetCenterClient({ setFamilyRoleDefinitions((current) => current.map((definition) => definition.value === editingRoleValue - ? { ...definition, label, seriesCode: familyForm.applicableSeriesCode || null } + ? { + ...definition, + label, + seriesCode: initialAssetSeries.find((series) => familyForm.compatibleSeriesIds.includes(series.id))?.code ?? null, + } : definition, ), ) @@ -1673,10 +1811,21 @@ export default function AssetCenterClient({ } async function handleSaveFamily() { + if (familyForm.type !== 'HARDWARE' && familyForm.compatibleSeriesIds.length === 0) { + toast.error('请选择至少一个兼容系列') + return + } + const normalizedSelectedRoles = normalizeConnectionRoles(familyForm.selectedRoles) + const submittedCompatibleSeriesIds = + familyForm.type === 'HARDWARE' ? [] : familyForm.compatibleSeriesIds + const primarySeriesCode = + familyForm.type === 'HARDWARE' + ? null + : initialAssetSeries.find((series) => submittedCompatibleSeriesIds.includes(series.id))?.code ?? null const hiddenRecommendedRoleValues = getStandardConnectionRoleDefinitions({ assetType: familyForm.type, - seriesCode: familyForm.applicableSeriesCode || null, + seriesCode: primarySeriesCode, }) .map((definition) => definition.value) .filter((value) => !familyRoleDefinitions.some((definition) => definition.value === value)) @@ -1695,9 +1844,7 @@ export default function AssetCenterClient({ code: familyForm.code, name: familyForm.name, description: familyForm.description || null, - applicableSeriesCodes: familyForm.applicableSeriesCode - ? [familyForm.applicableSeriesCode] - : [], + compatibleSeriesIds: submittedCompatibleSeriesIds, connectionRoles: normalizedSelectedRoles, metadata, status: 'DRAFT', @@ -1713,9 +1860,7 @@ export default function AssetCenterClient({ code: familyForm.code, name: familyForm.name, description: familyForm.description || null, - applicableSeriesCodes: familyForm.applicableSeriesCode - ? [familyForm.applicableSeriesCode] - : [], + compatibleSeriesIds: submittedCompatibleSeriesIds, connectionRoles: normalizedSelectedRoles, metadata, }) @@ -2495,9 +2640,12 @@ export default function AssetCenterClient({ } setFamilyRoleDefinitions((current) => { + const primarySeriesCode = initialAssetSeries.find( + (series) => familyForm.compatibleSeriesIds.includes(series.id), + )?.code ?? null const merged = mergeConnectionRoleDefinitions({ assetType: familyForm.type, - seriesCode: familyForm.applicableSeriesCode || null, + seriesCode: primarySeriesCode, selectedRoles: familyForm.selectedRoles, metadata: familyModalMode === 'edit' ? editingFamily?.metadata : null, }) @@ -2523,11 +2671,12 @@ export default function AssetCenterClient({ }) }, [ editingFamily?.metadata, - familyForm.applicableSeriesCode, + familyForm.compatibleSeriesIds, familyForm.selectedRoles, familyForm.type, familyModalMode, familyModalOpen, + initialAssetSeries, ]) useEffect(() => { @@ -2577,6 +2726,7 @@ export default function AssetCenterClient({ familyName: cadImportForm.familyMode === 'create' ? cadImportForm.familyName : null, familyDescription: cadImportForm.familyMode === 'create' ? cadImportForm.familyDescription : null, + assetSeriesId: selectedSeriesId, connectionRoles: cadImportForm.familyMode === 'create' ? cadImportConnectionRoles @@ -3001,10 +3151,112 @@ export default function AssetCenterClient({ V2.6.4 + {/* 系列入口 */} +
+
+ + 系列 + + + 共 {initialAssetSeries.length} 个 + +
+ + + {seriesPickerOpen && ( +
+
+
+ + setSeriesSearchText(event.target.value)} + placeholder="搜索系列名称或编码" + className="h-8 w-full rounded-[6px] bg-black/[0.025] pl-8 pr-2 text-xs text-[#2d3335] outline-none ring-1 ring-black/[0.04] placeholder:text-[#5a6062]/38 focus:bg-white focus:ring-black/[0.12]" + /> +
+
+
+ {filteredAssetSeries.length > 0 ? ( + filteredAssetSeries.map((series) => { + const active = selectedSeriesId === series.id + const count = seriesAssetCounts.get(series.id) ?? 0 + return ( + + ) + }) + ) : ( +
+ 没有匹配的系列 +
+ )} +
+
+ )} +
+ {/* 分类 Tab */}
{TYPE_TABS.map((tab) => { - const count = getVisibleFamiliesForTab(families, tab.type).length + const count = getVisibleFamiliesForTab(currentSeriesFamilies, tab.type).length const isActive = activeTab === tab.type return ( -

+

先建资产族,再在资产族里面新建或导入带模型的规格款。

@@ -3167,6 +3424,7 @@ export default function AssetCenterClient({
toggleFamilyExpand(family.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -4105,12 +4363,50 @@ export default function AssetCenterClient({ } >
+
+

资产类型

+
+ {TYPE_TABS.map((tab) => { + const type = tab.type as AssetFamilyDTO['type'] + const active = familyForm.type === type + return ( + + ) + })} +
+
setFamilyForm((c) => ({ ...c, code: e.target.value }))} - placeholder={`例如 ${activeTab}-001`} + placeholder={`例如 ${familyForm.type}-001`} />
+ {familyForm.type !== 'HARDWARE' ? ( +
+
+

兼容系列

+ + 已选 {familyForm.compatibleSeriesIds.length} 个 + +
+
+ {initialAssetSeries.map((series) => { + const checked = familyForm.compatibleSeriesIds.includes(series.id) + return ( + + ) + })} +
+
+ ) : null}
diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx index ea0090b4..c34e1a21 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx @@ -10,7 +10,7 @@ import { updateAssetFamily, } from '@/lib/open-product/platform-api' import { parseZipFile } from '@/lib/zip-parser' -import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services' +import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, AssetSeriesDTO, SubstrateDTO } from '@/server/services' jest.mock('next/link', () => ({ __esModule: true, @@ -138,14 +138,18 @@ function renderClient() { initialFamilies={[]} initialVariants={[] as AssetVariantDTO[]} initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', @@ -215,7 +219,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: null, status: 'DRAFT', @@ -247,7 +251,6 @@ describe('AssetCenterClient workflow', () => { type: 'BOARD', code: 'BOARD-001', name: '桌面板', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desktop_surface'], })) }) @@ -267,7 +270,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: { connectionRoleDefinitions: [ @@ -296,14 +299,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', @@ -370,7 +377,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -390,14 +397,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[] as unknown as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -430,7 +441,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['cross_support'], metadata: { connectionRoleDefinitions: [ @@ -462,7 +473,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['cross_support'], metadata: { connectionRoleDefinitions: [ @@ -492,14 +503,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[] as unknown as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -533,7 +548,6 @@ describe('AssetCenterClient workflow', () => { expect(updateAssetFamily).toHaveBeenCalledWith( 'family-board-1', expect.objectContaining({ - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['cross_support'], metadata: expect.objectContaining({ connectionRoleDefinitions: expect.arrayContaining([ @@ -559,7 +573,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -625,7 +639,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: { connectionRoleDefinitions: [ @@ -654,14 +668,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', @@ -747,7 +765,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: null, status: 'AVAILABLE', @@ -817,7 +835,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: null, status: 'PENDING_REVIEW', @@ -892,14 +910,18 @@ describe('AssetCenterClient workflow', () => { }, ] as AssetVariantDTO[]} initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', 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 e676e00c..5ae0d4ac 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 @@ -21,6 +21,7 @@ import { cn } from '@/lib/utils' import { MaterialCategoryDTO, + MaterialLikeDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO, @@ -221,6 +222,10 @@ const mlUploadButtonClassName = 'cursor-pointer rounded-md border border-[#101719]/20 bg-white px-2.5 py-1.5 text-[10px] font-apple-black text-[#101719] ring-1 ring-black/[0.04] transition hover:bg-black/[0.045]' const mlTagClassName = 'inline-flex items-center gap-1 rounded-md border border-black/10 bg-black/[0.035] px-2.5 py-1 text-xs font-apple-bold text-[#263033]' +const mlIconActionButtonClassName = + 'inline-flex h-8 w-8 items-center justify-center rounded-md border border-black/[0.06] bg-white text-[#5f6d70] transition hover:border-black/[0.12] hover:bg-black/[0.035] hover:text-[#101719] focus:outline-none focus:ring-2 focus:ring-black/[0.08]' +const mlDangerIconActionButtonClassName = + 'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-white text-[#a44640] transition hover:border-[#a44640]/20 hover:bg-[#a44640]/8 focus:outline-none focus:ring-2 focus:ring-[#a44640]/15' function formatFinishOptionLabel(finish: { name: string; code: string; categoryName?: string | null }): string { return [finish.name, finish.code, finish.categoryName].filter(Boolean).join(' · ') @@ -230,7 +235,7 @@ function firstUsableMaterialImageUrl(...urls: Array) return urls.find((url) => url && !url.includes('/materials/thumbnails/default.svg')) ?? null } -function MaterialSurfacePreview({ item, className }: { item: MaterialDTO, className?: string }) { +function MaterialSurfacePreview({ item, className }: { item: MaterialDTO | MaterialLikeDTO, className?: string }) { const imageUrl = firstUsableMaterialImageUrl(item.textureUrl, item.thumbnailUrl) return ( @@ -305,6 +310,37 @@ function FinishWorkbenchPreviewCard({ form }: { form: FinishForm }) { ) } +function SubstrateSlabPreview({ substrate }: { substrate: SubstrateDTO }) { + const finish = substrate.finishes[0] ?? null + const surfaceUrl = finish ? firstUsableMaterialImageUrl(finish.textureUrl, finish.thumbnailUrl) : null + + return ( +
+
+
+
+
+

板材基材

+

{substrate.name}

+
+ + {substrate.thicknessSpecName} + +
+
+ ) +} + function FinishParameterSliderField({ id, label, value, min = 0, max = 1, step = 0.01, disabled, onChange }: { @@ -1192,18 +1228,24 @@ export default function MaterialLibraryClient({
{isView && activeSubstrate ? ( <> - - + + + ) : null} {isCreate || isEdit ? ( @@ -1426,10 +1468,10 @@ export default function MaterialLibraryClient({
) : activeSubstrate ? ( -
-
+
+

基础信息

-
+
{[ { label: '编码', value: activeSubstrate.code }, { label: '材质主类', value: activeSubstrate.categoryName }, @@ -1439,7 +1481,7 @@ export default function MaterialLibraryClient({ { label: '板尺寸', value: `${activeSubstrate.sheetLengthMm} × ${activeSubstrate.sheetWidthMm}` }, { label: '采购价', - value: activeSubstrate.suggestedPrice ?? '缺价格', + value: activeSubstrate.suggestedPrice != null ? activeSubstrate.suggestedPrice : '缺价格', missing: activeSubstrate.suggestedPrice == null, }, { @@ -1447,14 +1489,15 @@ export default function MaterialLibraryClient({ value: activeSubstrate.weightKg != null ? `${activeSubstrate.weightKg} kg` : '缺重量', missing: activeSubstrate.weightKg == null, }, + { label: '系统名称', value: activeSubstrate.systemNameOptionName ?? '--' }, ].map(({ label, value, missing }) => ( -
- {label} +
+ {label} @@ -1464,19 +1507,29 @@ export default function MaterialLibraryClient({ ))}
-
-

关联花色

-
+ +
+

绑定花色

+
{activeSubstrate.finishes.length === 0 ? ( - 暂无关联花色 +
+ 暂无关联花色 +
) : ( activeSubstrate.finishes.slice(0, 1).map((finish) => ( - +
{finish.textureUrl || finish.thumbnailUrl ? ( - - ) : null} - {formatFinishOptionLabel(finish)} - + + ) : ( + + )} + + {finish.name} + + {finish.code} · {finish.categoryName ?? '未分类'} + + +
)) )}
@@ -1705,12 +1758,15 @@ export default function MaterialLibraryClient({ className="h-10 w-full rounded-lg border border-black/10 bg-white pl-10 pr-3 text-sm font-apple-medium text-[#263033] outline-none transition placeholder:text-[#7a878a]/70 focus:border-[#101719]/30 focus:ring-4 focus:ring-black/[0.04]" />
- {metrics.slice(0, 2).map((metric) => ( -
-

{metric.label}

-

{metric.value}

-
- ))} +
+ {metrics.slice(0, 2).map((metric, index) => ( + + {index > 0 ? : null} + {metric.label} + {metric.value} + + ))} +
) : ( -
-
- {activeWorkbenchSubstrate ? ( -
+
+ {activeWorkbenchSubstrate ? ( +
+ +

当前基材

-

+

{activeWorkbenchSubstrate.name}

+

+ {[activeWorkbenchSubstrate.categoryName, activeWorkbenchSubstrate.environmentGradeName, activeWorkbenchSubstrate.surfaceOptionName] + .filter(Boolean) + .join(' / ')} +

-
+
{[ ['编码', activeWorkbenchSubstrate.code], ['尺寸', `${activeWorkbenchSubstrate.sheetLengthMm} × ${activeWorkbenchSubstrate.sheetWidthMm}`], ['厚度', activeWorkbenchSubstrate.thicknessSpecName], ['花色', activeWorkbenchSubstrate.finishes[0]?.name ?? '未绑定'], ].map(([label, value]) => ( -
+

{label}

{value}

))}
- ) : ( -
- -

暂无基材数据

-
- )} -
-
-
-
-

基材列表

-

共 {filteredSubstrates.length}

-
-
+ ) : ( +
+ +

暂无基材数据

+
+ )} + +
+
+ 基材 + 类别 + 厚度 + 成本 + 状态 +
+
{filteredSubstrates.map((substrate) => { const missingPrice = substrate.suggestedPrice == null const missingWeight = substrate.weightKg == null - const missingRequiredData = missingPrice || missingWeight const selected = (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') && panel.id === substrate.id + const finish = substrate.finishes[0] ?? null return ( ) })} {filteredSubstrates.length === 0 ? ( -

当前没有匹配的基材。

+

当前没有匹配的基材。

) : null}
diff --git a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx index a451be9a..9a37eb3a 100644 --- a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx @@ -97,7 +97,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: { categoryLevel1: 'outer', diff --git a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx index 93705694..4c649790 100644 --- a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx +++ b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx @@ -69,7 +69,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: { categoryLevel1: 'outer', diff --git a/furniture-3d-system/src/app/asset-center/page.tsx b/furniture-3d-system/src/app/asset-center/page.tsx index d2a05683..a2a8fbc4 100644 --- a/furniture-3d-system/src/app/asset-center/page.tsx +++ b/furniture-3d-system/src/app/asset-center/page.tsx @@ -1,6 +1,6 @@ import { Layers3, Package, Palette, Ruler } from 'lucide-react' import { requireSession } from '@/lib/server-session' -import { assetCenterService, materialService, seriesTemplateService } from '@/server/services' +import { assetCenterService, materialService } from '@/server/services' import AssetCenterClient from './AssetCenterClient' export const dynamic = 'force-dynamic' @@ -19,7 +19,7 @@ export default async function AssetCenterPage() { ]) const finishes = await materialService.findAll({ limit: 200 }) const materialCategories = await materialService.findAllCategories() - const seriesTemplates = await seriesTemplateService.listTemplates() + const assetSeries = await assetCenterService.listSeries() return ( { }), ) }) - expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).not.toHaveProperty('seriesTemplateId') + expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).toHaveProperty('assetSeriesId', null) }) it('资产中心带料进入时,会优先保留来源资产族的草稿规格款作为默认选择', async () => { diff --git a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx index a920571f..0799950d 100644 --- a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx @@ -198,6 +198,7 @@ export default function AssistedAssemblyClient({ const firstCategoryFamily = selectedFamilies.find((family) => family.productCategoryId) return syncManualAssemblyWorkspace(snapshot, { + assetSeriesId: launchParams?.assetSeriesId ?? initialWorkspace?.assetSeriesId ?? null, productCategoryId: initialWorkspace?.productCategoryId ?? firstCategoryFamily?.productCategoryId ?? null, productCategoryName: initialWorkspace?.productCategoryName ?? firstCategoryFamily?.productCategoryName ?? null, @@ -215,6 +216,7 @@ export default function AssistedAssemblyClient({ entryMode, families, initialWorkspace, + launchParams?.assetSeriesId, launchParams?.sourceProductId, launchParams?.sourceSkuId, legIds, @@ -263,6 +265,7 @@ export default function AssistedAssemblyClient({ const result = await createDraftGroup({ name: draftGroupName, categoryId: workspace.productCategoryId, + assetSeriesId: workspace.assetSeriesId ?? null, assemblies: [ { key: `${entryMode}:${desktopIds.join(',')}`, diff --git a/furniture-3d-system/src/app/assisted-assembly/page.tsx b/furniture-3d-system/src/app/assisted-assembly/page.tsx index d893653f..c62204da 100644 --- a/furniture-3d-system/src/app/assisted-assembly/page.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/page.tsx @@ -223,6 +223,7 @@ function buildSourceSkuWorkspaceFallback(input: { }) const baseSnapshot = buildManualAssemblyWorkspace({ + assetSeriesId: null, productCategoryId: null, productCategoryName: null, source: getAssemblyWorkspaceSourceFromEntryMode('source-sku'), @@ -237,7 +238,6 @@ function buildSourceSkuWorkspaceFallback(input: { assetType: family.type, connectionRoles: family.connectionRoles, productCategoryId: family.productCategoryId, - applicableSeriesCodes: family.applicableSeriesCodes, })), defaultVariantIdsByFamilyId, ), @@ -462,6 +462,7 @@ async function resolveSourceSkuContext(input: { launchParams: { entryMode: 'source-sku' as const, source: 'assisted-assembly' as const, + assetSeriesId: null, sourceSkuId: sku.id, sourceProductId: sku.productId, redirectedFrom: input.redirectedFrom ?? null, diff --git a/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx b/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx index c256f051..041768e8 100644 --- a/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx +++ b/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx @@ -95,7 +95,7 @@ export default function HomeDraftsList({ {getDraftStatusLabel(draftGroup.status)} - {draftGroup.categoryName || draftGroup.seriesTemplateName} + {draftGroup.categoryName || draftGroup.assetSeriesName}

diff --git a/furniture-3d-system/src/app/home/home-client.types.ts b/furniture-3d-system/src/app/home/home-client.types.ts index fd8b26df..876fcdff 100644 --- a/furniture-3d-system/src/app/home/home-client.types.ts +++ b/furniture-3d-system/src/app/home/home-client.types.ts @@ -44,7 +44,7 @@ export interface DraftGroupSummary { name: string description?: string | null categoryName?: string | null - seriesTemplateName: string + assetSeriesName: string status: string summary?: Record | null creatorName: string diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index bd888448..7574f2ce 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -249,7 +249,7 @@ function renderSelector(props?: { { id: 'draft-group-1', name: 'Draft Group', - seriesTemplateName: 'Single board desk', + assetSeriesName: 'Single board desk', code: 'DG-001', }, ] @@ -332,7 +332,7 @@ function renderEditor() { id: 'draft-group-1', name: 'Draft Group', categoryId: 'category-desk', - seriesTemplateName: 'Single board desk', + assetSeriesName: 'Single board desk', categoryName: 'Desk', code: 'DG-001', }, @@ -1698,13 +1698,13 @@ describe('IntakeClient', () => { { id: 'draft-group-1', name: 'Nordic Draft Group', - seriesTemplateName: 'Nordic Desk', + assetSeriesName: 'Nordic Desk', code: 'DG-001', }, { id: 'draft-group-2', name: 'Cloud Draft Group', - seriesTemplateName: 'Cloud Sofa', + assetSeriesName: 'Cloud Sofa', code: 'DG-002', }, ], @@ -1761,7 +1761,7 @@ describe('IntakeClient', () => { const eligibleDraftGroups = Array.from({ length: 26 }, (_, index) => ({ id: `draft-group-${index + 1}`, name: `Draft Group ${index + 1}`, - seriesTemplateName: `Series ${index + 1}`, + assetSeriesName: `Series ${index + 1}`, code: `DG-${String(index + 1).padStart(3, '0')}`, })) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index dd7c6be9..785a838c 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -120,7 +120,7 @@ interface IntakeClientSelectorProps { eligibleDraftGroups: { id: string; name: string; - seriesTemplateName: string; + assetSeriesName: string; code: string; }[]; eligibleSkuList?: WorkbenchSku[]; @@ -780,7 +780,7 @@ function buildIntegratedSeriesRows(params: { readPreSkuTextField(firstPreSku, "spu"))) || draftGroup.code || "SPU-001", - seriesName: draftGroup.seriesTemplateName || draftGroup.name, + seriesName: draftGroup.assetSeriesName || draftGroup.name, draftGroupName: draftGroup.name, designer: "", operator: "", @@ -803,7 +803,7 @@ function buildIntegratedSeriesRows(params: { const rows: IntegratedPendingSeriesRow[] = eligibleDraftGroups.map( (draftGroup, index) => { - const bucketKey = `${draftGroup.name}::${draftGroup.seriesTemplateName}`; + const bucketKey = `${draftGroup.name}::${draftGroup.assetSeriesName}`; const matchedSkus = skuBuckets.get(bucketKey) ?? []; const firstSku = matchedSkus[0]; @@ -814,7 +814,7 @@ function buildIntegratedSeriesRows(params: { firstSku?.spuCode || draftGroup.code || `SPU-${String(index + 1).padStart(3, "0")}`, - seriesName: draftGroup.seriesTemplateName || draftGroup.name, + seriesName: draftGroup.assetSeriesName || draftGroup.name, draftGroupName: draftGroup.name, designer: "", operator: "", diff --git a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts index ed44b310..7e263dee 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts @@ -15,9 +15,9 @@ function buildDraftGroupDetail( description: null, categoryId: 'category-1', categoryName: '书桌', - seriesTemplateId: 'series-template-1', - seriesTemplateCode: 'SERIES-001', - seriesTemplateName: '集成书桌系列', + assetSeriesId: 'series-template-1', + assetSeriesCode: 'SERIES-001', + assetSeriesName: '集成书桌系列', status: 'DRAFT', summary: null, notes: null, diff --git a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts index 2921032e..82046946 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts @@ -59,7 +59,7 @@ function readSeriesName(detail: DraftGroupDetailDTO) { return familySeriesName.trim() } - return detail.draftGroup.seriesTemplateName?.trim() || detail.draftGroup.name.trim() + return detail.draftGroup.assetSeriesName?.trim() || detail.draftGroup.name.trim() } function pickBaseSpu(detail: DraftGroupDetailDTO) { diff --git a/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts b/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts index 706e268b..f6b74288 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts @@ -13,9 +13,9 @@ function buildDraftGroup(id: string, status: DraftGroupDTO['status']): DraftGrou description: null, categoryId: 'category-1', categoryName: '桌类', - seriesTemplateId: `series-template-${id}`, - seriesTemplateCode: `SERIES-${id}`, - seriesTemplateName: `系列-${id}`, + assetSeriesId: `series-template-${id}`, + assetSeriesCode: `SERIES-${id}`, + assetSeriesName: `系列-${id}`, status, summary: null, notes: null, diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 0fdc34a3..df121ae4 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -114,7 +114,7 @@ export default async function IntakePage({ name: preSku.customName || preSku.defaultName, seriesName: readPreSkuTextField(preSku, 'seriesName') || - detail.draftGroup.seriesTemplateName || + detail.draftGroup.assetSeriesName || detail.draftGroup.name, draftGroupName: detail.draftGroup.name, status: mapWorkbenchStatus(preSku.status), diff --git a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts index d8d98db2..5f1d25b1 100644 --- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -163,6 +163,7 @@ export interface AssemblyAutoSnapCandidate { export interface AssemblyWorkspaceSnapshot { version: '2026-04-13' | '2026-04-09' | '2026-04-08' + assetSeriesId?: string | null productCategoryId?: string | null productCategoryName?: string | null workflowMode: 'assisted-manual' @@ -199,6 +200,7 @@ export interface AssemblyWorkspaceFamilyInput { } interface BuildManualAssemblyWorkspaceInput { + assetSeriesId?: string | null productCategoryId: string | null productCategoryName: string | null source: AssemblyWorkspaceSource @@ -1817,6 +1819,7 @@ export function buildManualAssemblyWorkspace( const snapshot: AssemblyWorkspaceSnapshot = { version: '2026-04-13', + assetSeriesId: input.assetSeriesId ?? null, productCategoryId: input.productCategoryId, productCategoryName: input.productCategoryName, workflowMode: 'assisted-manual', @@ -1848,6 +1851,7 @@ export function syncManualAssemblyWorkspace( if ( snapshot.productCategoryId !== input.productCategoryId || + (snapshot.assetSeriesId ?? null) !== (input.assetSeriesId ?? null) || snapshot.source !== input.source || snapshot.entryMode !== input.entryMode || (snapshot.sourceSkuId ?? null) !== (input.sourceSkuId ?? null) || @@ -1884,6 +1888,7 @@ export function syncManualAssemblyWorkspace( { ...snapshot, productCategoryId: input.productCategoryId, + assetSeriesId: input.assetSeriesId ?? null, productCategoryName: input.productCategoryName, workflowMode: 'assisted-manual', entryMode: input.entryMode, @@ -1926,6 +1931,7 @@ export function buildSingleBoardDeskAssemblyWorkspace( }) const snapshot = buildManualAssemblyWorkspace({ + assetSeriesId: null, productCategoryId: null, productCategoryName: null, source: 'assisted-assembly', diff --git a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts index d088dea9..2618af0c 100644 --- a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts +++ b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts @@ -17,6 +17,7 @@ export type ManualAssemblyProductRecovery = export interface ManualAssemblyLaunchParams { entryMode: AssemblyWorkspaceEntryMode source: AssemblyWorkspaceSource + assetSeriesId?: string | null sourceSkuId?: string | null sourceProductId?: string | null selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType @@ -84,6 +85,10 @@ export function buildManualAssemblyLaunchHref( searchParams.set('sourceProductId', params.sourceProductId) } + if (params.assetSeriesId) { + searchParams.set('assetSeriesId', params.assetSeriesId) + } + if (params.redirectedFrom) { searchParams.set('redirectedFrom', params.redirectedFrom) } @@ -131,6 +136,7 @@ export function parseManualAssemblyLaunchParams( const entryModeValue = searchParams.get('entryMode')?.trim() const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null const sourceProductId = searchParams.get('sourceProductId')?.trim() ?? null + const assetSeriesId = searchParams.get('assetSeriesId')?.trim() ?? null const redirectedFrom = normalizeLegacyEntry(searchParams.get('redirectedFrom')?.trim()) const productRecovery = normalizeProductRecovery(searchParams.get('productRecovery')?.trim()) const recoverableSourceSkuCount = normalizePositiveInteger( @@ -154,6 +160,7 @@ export function parseManualAssemblyLaunchParams( return { entryMode, source, + assetSeriesId, sourceSkuId, sourceProductId, selectedFamilyIdsByType, diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index 4f0a2fff..5ba519db 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -240,6 +240,7 @@ export interface ImportAssetCadPayload { familyCode?: string | null familyName?: string | null familyDescription?: string | null + assetSeriesId?: string | null connectionRoles?: string[] variantCode: string variantName: string @@ -258,6 +259,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise 0) { formData.append('connectionRoles', JSON.stringify(input.connectionRoles)) } diff --git a/furniture-3d-system/src/lib/open-product/types.ts b/furniture-3d-system/src/lib/open-product/types.ts index b7798023..5a1e3eb7 100644 --- a/furniture-3d-system/src/lib/open-product/types.ts +++ b/furniture-3d-system/src/lib/open-product/types.ts @@ -23,7 +23,6 @@ export interface OpenProductAssetFamily { assetType: OpenProductAssetType connectionRoles: string[] productCategoryId?: string | null - applicableSeriesCodes?: string[] } export interface OpenProductAssetVariant { diff --git a/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts b/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts index bdbbf819..33255368 100644 --- a/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts +++ b/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts @@ -19,7 +19,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: { categoryLevel1: 'outer', diff --git a/furniture-3d-system/src/server/repositories/asset-center.repository.ts b/furniture-3d-system/src/server/repositories/asset-center.repository.ts index 772b1b72..ca4977df 100644 --- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts +++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts @@ -1,4 +1,4 @@ -import { AssetFamily, AssetVariant, Prisma, Substrate } from '@prisma/client' +import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' import { buildDeletedAssetVariantUniqueValue, @@ -32,6 +32,7 @@ export interface AssetFamilyFilter { type?: AssetFamily['type'] status?: AssetFamily['status'] productCategoryId?: string + assetSeriesId?: string search?: string } @@ -42,6 +43,16 @@ export interface AssetFamilyWithRelations extends AssetFamily { reviewer: { id: string; name: string } | null rejecter: { id: string; name: string } | null lastModifier: { id: string; name: string } | null + compatibleSeries: Array<{ + assetSeries: { + id: string + code: string + name: string + description: string | null + sortOrder: number + isActive: boolean + } + }> } export interface AssetVariantFilter { @@ -80,6 +91,21 @@ export interface SubstrateFilter { export const assetFamilyRelationInclude = { productCategory: { select: { id: true, name: true } }, + compatibleSeries: { + select: { + assetSeries: { + select: { + id: true, + code: true, + name: true, + description: true, + sortOrder: true, + isActive: true, + }, + }, + }, + orderBy: { assetSeries: { sortOrder: 'asc' } }, + }, creator: { select: { id: true, name: true } }, submitter: { select: { id: true, name: true } }, reviewer: { select: { id: true, name: true } }, @@ -249,21 +275,75 @@ class AssetFamilyRepositoryClass extends BaseRepository< return { deletedAt: null } } + const and: Prisma.AssetFamilyWhereInput[] = [] + if (filter.assetSeriesId && filter.type !== 'HARDWARE') { + const seriesScope: Prisma.AssetFamilyWhereInput = { + compatibleSeries: { + some: { + assetSeriesId: filter.assetSeriesId, + assetSeries: { isActive: true }, + }, + }, + } + and.push( + filter.type + ? seriesScope + : { + OR: [ + { type: 'HARDWARE' }, + seriesScope, + ], + }, + ) + } + + if (filter.search) { + and.push({ + OR: [ + { name: { contains: filter.search, mode: 'insensitive' } }, + { code: { contains: filter.search, mode: 'insensitive' } }, + ], + }) + } + return { deletedAt: null, type: filter.type, status: filter.status, productCategoryId: filter.productCategoryId, - OR: filter.search - ? [ - { name: { contains: filter.search, mode: 'insensitive' } }, - { code: { contains: filter.search, mode: 'insensitive' } }, - ] - : undefined, + AND: and.length > 0 ? and : undefined, } } } +class AssetSeriesRepositoryClass extends BaseRepository< + AssetSeries, + Prisma.AssetSeriesCreateInput, + Prisma.AssetSeriesUpdateInput +> { + protected modelName = 'assetSeries' + + async findAllActive(): Promise { + return this.prisma.assetSeries.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }) + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) return [] + + return this.prisma.assetSeries.findMany({ + where: { id: { in: ids }, isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }) + } + + async createSeries(data: Prisma.AssetSeriesCreateInput): Promise { + return this.prisma.assetSeries.create({ data }) + } +} + class AssetVariantRepositoryClass extends BaseRepository< AssetVariant, Prisma.AssetVariantCreateInput, @@ -530,11 +610,13 @@ class SubstrateRepositoryClass extends BaseRepository< } export const assetFamilyRepository = new AssetFamilyRepositoryClass() +export const assetSeriesRepository = new AssetSeriesRepositoryClass() export const assetVariantRepository = new AssetVariantRepositoryClass() export const substrateRepository = new SubstrateRepositoryClass() export { AssetFamilyRepositoryClass, + AssetSeriesRepositoryClass, AssetVariantRepositoryClass, SubstrateRepositoryClass, } diff --git a/furniture-3d-system/src/server/repositories/draft-group.repository.ts b/furniture-3d-system/src/server/repositories/draft-group.repository.ts index fabcce72..94e7d980 100644 --- a/furniture-3d-system/src/server/repositories/draft-group.repository.ts +++ b/furniture-3d-system/src/server/repositories/draft-group.repository.ts @@ -3,7 +3,7 @@ import { BaseRepository } from './base.repository' export interface DraftGroupWithRelations extends DraftGroup { category: { id: string; name: string } | null - seriesTemplate: { id: string; code: string; name: string; seriesName: string } | null + assetSeries: { id: string; code: string; name: string } | null creator: { id: string; name: string } } @@ -23,7 +23,7 @@ class DraftGroupRepositoryClass extends BaseRepository< where: { id }, include: { category: { select: { id: true, name: true } }, - seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } }, + assetSeries: { select: { id: true, code: true, name: true } }, creator: { select: { id: true, name: true } }, }, }) as Promise @@ -34,7 +34,7 @@ class DraftGroupRepositoryClass extends BaseRepository< orderBy: { updatedAt: 'desc' }, include: { category: { select: { id: true, name: true } }, - seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } }, + assetSeries: { select: { id: true, code: true, name: true } }, creator: { select: { id: true, name: true } }, }, }) as Promise @@ -45,7 +45,7 @@ class DraftGroupRepositoryClass extends BaseRepository< data, include: { category: { select: { id: true, name: true } }, - seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } }, + assetSeries: { select: { id: true, code: true, name: true } }, creator: { select: { id: true, name: true } }, }, }) as Promise diff --git a/furniture-3d-system/src/server/repositories/index.ts b/furniture-3d-system/src/server/repositories/index.ts index 7b0d0ece..315c3f97 100644 --- a/furniture-3d-system/src/server/repositories/index.ts +++ b/furniture-3d-system/src/server/repositories/index.ts @@ -114,11 +114,13 @@ export type { export { assetFamilyRelationInclude, assetFamilyRepository, + assetSeriesRepository, assetVariantRelationInclude, assetVariantRepository, substrateRelationInclude, substrateRepository, AssetFamilyRepositoryClass, + AssetSeriesRepositoryClass, AssetVariantRepositoryClass, SubstrateRepositoryClass, } from './asset-center.repository' @@ -132,9 +134,6 @@ export type { SubstrateWithRelations, } from './asset-center.repository' -export { seriesTemplateRepository, SeriesTemplateRepositoryClass } from './series-template.repository' -export type { SeriesTemplateWithRelations } from './series-template.repository' - export { draftGroupRepository, preSkuRepository, diff --git a/furniture-3d-system/src/server/repositories/series-template.repository.ts b/furniture-3d-system/src/server/repositories/series-template.repository.ts deleted file mode 100644 index 07011a4b..00000000 --- a/furniture-3d-system/src/server/repositories/series-template.repository.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Prisma, SeriesTemplate } from '@prisma/client' -import { BaseRepository } from './base.repository' - -export interface SeriesTemplateWithRelations extends SeriesTemplate { - category: { id: string; name: string } | null - creator: { id: string; name: string } | null -} - -class SeriesTemplateRepositoryClass extends BaseRepository< - SeriesTemplate, - Prisma.SeriesTemplateCreateInput, - Prisma.SeriesTemplateUpdateInput -> { - protected modelName = 'seriesTemplate' - - async findByCode(code: string): Promise { - return this.prisma.seriesTemplate.findUnique({ where: { code } }) - } - - async findByIdWithRelations(id: string): Promise { - return this.prisma.seriesTemplate.findUnique({ - where: { id }, - include: { - category: { select: { id: true, name: true } }, - creator: { select: { id: true, name: true } }, - }, - }) as Promise - } - - async findAllActiveWithRelations(): Promise { - return this.prisma.seriesTemplate.findMany({ - where: { isActive: true }, - orderBy: [{ isPreset: 'desc' }, { updatedAt: 'desc' }], - include: { - category: { select: { id: true, name: true } }, - creator: { select: { id: true, name: true } }, - }, - }) as Promise - } - - async createWithRelations(data: Prisma.SeriesTemplateCreateInput): Promise { - return this.prisma.seriesTemplate.create({ - data, - include: { - category: { select: { id: true, name: true } }, - creator: { select: { id: true, name: true } }, - }, - }) as Promise - } -} - -export const seriesTemplateRepository = new SeriesTemplateRepositoryClass() -export { SeriesTemplateRepositoryClass } diff --git a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts index 620a307d..ecfe1b68 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts @@ -54,7 +54,6 @@ function createFamilyDTO(overrides: Record = {}) { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -245,6 +244,7 @@ describe('assetCadImportService', () => { familyCode: 'BOARD-TOP', familyName: '桌面板', familyDescription: '单板桌桌面板', + assetSeriesId: 'series-single-board-desk', connectionRoles: ['desktop'], variantCode: 'BOARD-1200', variantName: '桌面板 1200', @@ -259,6 +259,7 @@ describe('assetCadImportService', () => { code: 'BOARD-TOP', name: '桌面板', description: '单板桌桌面板', + compatibleSeriesIds: ['series-single-board-desk'], connectionRoles: ['desktop'], createdBy: 'user-1', }) @@ -483,6 +484,7 @@ describe('assetCadImportService', () => { familyName: '', variantCode: '', variantName: '', + assetSeriesId: 'series-single-board-desk', substrateId: 'substrate-board', finishId: 'finish-oak', zipFile: createZipFile('140台面.zip'), @@ -495,6 +497,7 @@ describe('assetCadImportService', () => { code: 'BOARD-TOP_PANEL', name: '顶板', description: null, + compatibleSeriesIds: ['series-single-board-desk'], connectionRoles: [], createdBy: 'user-1', }) diff --git a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts index 94f27394..683cea68 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts @@ -1861,7 +1861,6 @@ describe('assetCenterService', () => { description: '单板桌桌面板', productCategoryId: 'category-table', productCategory: { id: 'category-table', name: '餐桌' }, - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desktop'], metadata: { source: 'asset-center' }, status: 'AVAILABLE', @@ -1903,7 +1902,6 @@ describe('assetCenterService', () => { code: 'BOARD-TOP', name: '桌面板资产族', status: 'AVAILABLE', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desktop'], }), ]) diff --git a/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts index 3bb73c67..4751b630 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts @@ -67,7 +67,6 @@ describe('assetCenterService review workflow', () => { name: '桌腿资产族', description: null, productCategoryId: null, - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desk_leg'], metadata: null, status: 'DRAFT', @@ -147,7 +146,6 @@ describe('assetCenterService review workflow', () => { name: '桌腿资产族', description: null, productCategoryId: null, - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desk_leg'], metadata: null, status: data.status ?? 'DRAFT', diff --git a/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts b/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts deleted file mode 100644 index 448f62ae..00000000 --- a/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { combinationService } from '../combination.service' -import { - assetFamilyRepository, - assetVariantRepository, - seriesTemplateRepository, -} from '../../repositories' - -jest.mock('../../repositories', () => ({ - assetFamilyRepository: { - findByIdsWithRelations: jest.fn(), - }, - assetVariantRepository: { - findAvailableByFamilyIds: jest.fn(), - }, - seriesTemplateRepository: { - findByIdWithRelations: jest.fn(), - }, -})) - -describe('combinationService', () => { - const mockedAssetFamilyRepository = jest.mocked(assetFamilyRepository) - const mockedAssetVariantRepository = jest.mocked(assetVariantRepository) - const mockedSeriesTemplateRepository = jest.mocked(seriesTemplateRepository) - - beforeEach(() => { - jest.clearAllMocks() - - mockedSeriesTemplateRepository.findByIdWithRelations.mockResolvedValue({ - id: 'template-single-board-desk', - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - previewRule: { - desktopHeight: 720, - desktopPosition: [0, 720, 0], - legOffsets: { - frontLeft: [-450, 360, 250], - frontRight: [450, 360, 250], - rearLeft: [-450, 360, -250], - rearRight: [450, 360, -250], - }, - }, - } as never) - - mockedAssetFamilyRepository.findByIdsWithRelations.mockResolvedValue([ - { - id: 'family-desktop-a', - code: 'TOP-A', - name: '桌面A', - type: 'BOARD', - connectionRoles: ['desktop'], - }, - { - id: 'family-leg-a', - code: 'LEG-A', - name: '桌腿A', - type: 'COMPONENT', - connectionRoles: ['desk_leg'], - }, - { - id: 'family-connector-a', - code: 'CT-A', - name: '连接件A', - type: 'HARDWARE', - connectionRoles: ['desk_leg_connector'], - }, - ] as never) - - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ - { - id: 'variant-desktop-a', - assetFamilyId: 'family-desktop-a', - code: 'TOP-A-1200', - name: '桌面A 1200', - assetType: 'BOARD', - status: 'AVAILABLE', - dimensions: { length: 1200, width: 600, thickness: 25 }, - modelUrl: '/models/top-a.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 18, - costPrice: 200, - assemblyBasis: null, - }, - { - id: 'variant-leg-a', - assetFamilyId: 'family-leg-a', - code: 'LEG-A-720', - name: '桌腿A 720', - assetType: 'COMPONENT', - status: 'AVAILABLE', - dimensions: { length: 60, width: 60, height: 720 }, - modelUrl: '/models/leg-a.glb', - mountPoints: [{ name: 'origin', position: [0, 360, 0] }], - substrateId: null, - finishId: null, - weightKg: 2.5, - costPrice: 38, - assemblyBasis: null, - }, - { - id: 'variant-connector-a', - assetFamilyId: 'family-connector-a', - code: 'CT-A-001', - name: '连接件A', - assetType: 'HARDWARE', - status: 'AVAILABLE', - dimensions: { length: 80, width: 40, thickness: 3 }, - modelUrl: '/models/connector-a.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 0.3, - costPrice: 5, - assemblyBasis: null, - }, - ] as never) - }) - - it('返回可直接驱动预览的候选结果', async () => { - const result = await combinationService.generateCandidates({ - seriesTemplateId: 'template-single-board-desk', - desktopFamilyIds: ['family-desktop-a'], - legFamilyIds: ['family-leg-a'], - connectorFamilyIds: ['family-connector-a'], - }) - - expect(result.totalCount).toBe(1) - expect(result.candidates[0]).toEqual( - expect.objectContaining({ - previewMode: 'composite-ready', - assemblyResultStatus: 'ASSEMBLABLE', - statusTag: '可直接预览', - componentCodes: ['TOP-A-1200', 'LEG-A-720', 'CT-A-001'], - combinationItems: expect.arrayContaining([ - expect.objectContaining({ label: '桌面板', value: '桌面A', quantity: 1 }), - expect.objectContaining({ label: '桌腿', value: '桌腿A', quantity: 4 }), - ]), - previewNodes: expect.arrayContaining([ - expect.objectContaining({ role: 'desktop', modelUrl: '/models/top-a.glb' }), - expect.objectContaining({ role: 'frontLeft', modelUrl: '/models/leg-a.glb' }), - ]), - candidateSnapshot: expect.objectContaining({ - key: 'family-desktop-a::family-leg-a', - templateCode: 'single-board-desk', - }), - assemblyWorkspace: expect.objectContaining({ - workflowMode: 'assisted-manual', - nodes: expect.arrayContaining([ - expect.objectContaining({ role: 'desktop', zone: 'main' }), - expect.objectContaining({ role: 'frontLeft', zone: 'staging' }), - ]), - }), - }), - ) - }) - - it('同一资产族存在多个可用规格款时返回警告而不是崩溃', async () => { - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ - { - id: 'variant-desktop-a', - assetFamilyId: 'family-desktop-a', - code: 'TOP-A-1200', - name: '桌面A 1200', - assetType: 'BOARD', - status: 'AVAILABLE', - dimensions: { length: 1200, width: 600, thickness: 25 }, - modelUrl: '/models/top-a.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 18, - costPrice: 200, - assemblyBasis: null, - }, - { - id: 'variant-desktop-a-v2', - assetFamilyId: 'family-desktop-a', - code: 'TOP-A-1400', - name: '桌面A 1400', - assetType: 'BOARD', - status: 'AVAILABLE', - dimensions: { length: 1400, width: 700, thickness: 25 }, - modelUrl: '/models/top-a-1400.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 20, - costPrice: 220, - assemblyBasis: null, - }, - { - id: 'variant-leg-a', - assetFamilyId: 'family-leg-a', - code: 'LEG-A-720', - name: '桌腿A 720', - assetType: 'COMPONENT', - status: 'AVAILABLE', - dimensions: { length: 60, width: 60, height: 720 }, - modelUrl: '/models/leg-a.glb', - mountPoints: [{ name: 'origin', position: [0, 360, 0] }], - substrateId: null, - finishId: null, - weightKg: 2.5, - costPrice: 38, - assemblyBasis: null, - }, - ] as never) - - const result = await combinationService.generateCandidates({ - seriesTemplateId: 'template-single-board-desk', - desktopFamilyIds: ['family-desktop-a'], - legFamilyIds: ['family-leg-a'], - }) - - expect(result.candidates[0].assemblyResultStatus).toBe('ASSEMBLABLE_WITH_WARNINGS') - expect(result.candidates[0].assemblyWarnings).toContain( - '桌面A 存在多个可用规格款,当前按最新规格款预览', - ) - expect(result.candidates[0].previewNodes.length).toBeGreaterThan(0) - }) - - it('缺少必选桌面规格款时返回阻断状态和警告', async () => { - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ - { - id: 'variant-leg-a', - assetFamilyId: 'family-leg-a', - code: 'LEG-A-720', - name: '桌腿A 720', - assetType: 'COMPONENT', - status: 'AVAILABLE', - dimensions: { length: 60, width: 60, height: 720 }, - modelUrl: '/models/leg-a.glb', - mountPoints: [{ name: 'origin', position: [0, 360, 0] }], - substrateId: null, - finishId: null, - weightKg: 2.5, - costPrice: 38, - assemblyBasis: null, - }, - ] as never) - - const result = await combinationService.generateCandidates({ - seriesTemplateId: 'template-single-board-desk', - desktopFamilyIds: ['family-desktop-a'], - legFamilyIds: ['family-leg-a'], - }) - - expect(result.candidates[0]).toEqual( - expect.objectContaining({ - assemblyResultStatus: 'BLOCKED', - statusTag: '缺少规格款', - }), - ) - expect(result.candidates[0].assemblyWarnings).toContain('桌面A 缺少可用规格款') - }) -}) diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts index 55ba16b1..9072e147 100644 --- a/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts @@ -90,8 +90,8 @@ describe('draftGroupService manual assembly', () => { status: 'DRAFT', summary: null, notes: null, - seriesTemplateId: 'template-1', - seriesTemplate: { id: 'template-1', code: 'single-board-desk', name: '单板桌' }, + assetSeriesId: 'template-1', + assetSeries: { id: 'template-1', code: 'single-board-desk', name: '单板桌' }, creator: { id: 'user-1', name: '测试用户' }, createdBy: 'user-1', createdAt: new Date('2026-04-09T12:00:00Z'), diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts index e029074b..1f77616b 100644 --- a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts @@ -5,7 +5,6 @@ import { draftGroupRepository, preSkuRepository, preSkuPackagingPlanRepository, - seriesTemplateRepository, } from '../../repositories' import type { AssemblyWorkspaceSnapshot } from '@/lib/open-product/assembly-workspace' @@ -32,9 +31,6 @@ jest.mock('../../repositories', () => ({ preSkuPackagingPlanRepository: { upsertByPreSkuId: jest.fn(), }, - seriesTemplateRepository: { - findByIdWithRelations: jest.fn(), - }, })) function buildLegacyCandidateWorkspace(input?: { @@ -209,7 +205,6 @@ describe('draftGroupService', () => { const mockedDraftGroupRepository = jest.mocked(draftGroupRepository) const mockedPreSkuRepository = jest.mocked(preSkuRepository) const mockedPreSkuPackagingPlanRepository = jest.mocked(preSkuPackagingPlanRepository) - const mockedSeriesTemplateRepository = jest.mocked(seriesTemplateRepository) const authUser = { id: 'user-1', @@ -256,7 +251,6 @@ describe('draftGroupService', () => { name: '纸箱', description: null, productCategoryId: null, - applicableSeriesCodes: [], connectionRoles: [], metadata: null, status: 'AVAILABLE', @@ -273,7 +267,6 @@ describe('draftGroupService', () => { name: '泡沫垫', description: null, productCategoryId: null, - applicableSeriesCodes: [], connectionRoles: [], metadata: null, status: 'AVAILABLE', @@ -287,32 +280,6 @@ describe('draftGroupService', () => { total: 2, } as never) - mockedSeriesTemplateRepository.findByIdWithRelations.mockResolvedValue({ - id: 'series-template-1', - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - categoryId: 'category-desk', - slotSchema: {}, - namingRule: {}, - recommendationRule: {}, - previewRule: { - desktopHeight: 720, - desktopPosition: [0, 720, 0], - legOffsets: { - frontLeft: [-450, 360, 250], - frontRight: [450, 360, 250], - rearLeft: [-450, 360, -250], - rearRight: [450, 360, -250], - }, - }, - intakeRule: {}, - isPreset: true, - isActive: true, - createdAt: new Date('2026-03-30T08:00:00.000Z'), - updatedAt: new Date('2026-03-30T08:00:00.000Z'), - } as never) - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ { id: 'variant-desktop-a-white', @@ -506,14 +473,14 @@ describe('draftGroupService', () => { id: 'draft-group-1', name: '云序桌系第一组', categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'DRAFT', createdBy: 'user-1', summary: { selectedCount: 2 }, createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌' }, + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌' }, creator: { id: 'user-1', name: '规划员' }, } as never) @@ -531,7 +498,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'DRAFT', summary: { selectedCount: 1 }, notes: null, @@ -539,7 +506,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1306,7 +1273,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'PACKAGING', summary: { selectedCount: 2 }, notes: null, @@ -1314,7 +1281,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1767,7 +1734,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'READY_FOR_INTAKE', summary: { selectedCount: 2 }, notes: null, @@ -1775,7 +1742,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1861,7 +1828,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'PACKAGING', summary: { selectedCount: 2 }, notes: null, @@ -1869,7 +1836,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1969,7 +1936,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'FORMALIZED', summary: { selectedCount: 2 }, notes: null, @@ -1977,7 +1944,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', diff --git a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts index f86808b8..f1fe84b9 100644 --- a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts @@ -2,15 +2,12 @@ import type { PreSkuPackagingInputDTO } from '../draft-group.service.types' import { buildSeedPackagingPlanInput, openPlatformDemoSeedService, - pickSeedCandidates, } from '../open-platform-demo-seed.service' import { DEFAULT_STANDARD_FINISH } from '@/lib/standard-finish-catalog' import { prisma } from '@/lib/prisma' import { assetCenterService } from '../asset-center.service' -import { combinationService } from '../combination.service' import { draftGroupService } from '../draft-group.service' import { intakeService } from '../intake.service' -import { seriesTemplateService } from '../series-template.service' import { warehouseService } from '../warehouse.service' jest.mock('bcryptjs', () => ({ @@ -76,6 +73,7 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('../asset-center.service', () => ({ assetCenterService: { + listSeries: jest.fn(), createFamily: jest.fn(), updateFamily: jest.fn(), submitFamilyForReview: jest.fn(), @@ -88,21 +86,9 @@ jest.mock('../asset-center.service', () => ({ }, })) -jest.mock('../series-template.service', () => ({ - seriesTemplateService: { - listTemplates: jest.fn(), - }, -})) - -jest.mock('../combination.service', () => ({ - combinationService: { - generateCandidates: jest.fn(), - }, -})) - jest.mock('../draft-group.service', () => ({ draftGroupService: { - createFromAutoCombination: jest.fn(), + createFromManualAssembly: jest.fn(), savePackagingPlan: jest.fn(), getById: jest.fn(), }, @@ -126,8 +112,6 @@ describe('openPlatformDemoSeedService', () => { const defaultFinishCategoryName = DEFAULT_STANDARD_FINISH.category.name const mockedPrisma = jest.mocked(prisma) const mockedAssetCenterService = jest.mocked(assetCenterService) - const mockedSeriesTemplateService = jest.mocked(seriesTemplateService) - const mockedCombinationService = jest.mocked(combinationService) const mockedDraftGroupService = jest.mocked(draftGroupService) const mockedIntakeService = jest.mocked(intakeService) const mockedWarehouseService = jest.mocked(warehouseService) @@ -136,20 +120,6 @@ describe('openPlatformDemoSeedService', () => { jest.clearAllMocks() }) - it('会优先挑首尾两个候选,避免种子数据只落同一类组合', () => { - const candidates = [ - { key: 'candidate-1' }, - { key: 'candidate-2' }, - { key: 'candidate-3' }, - { key: 'candidate-4' }, - ] as Array<{ key: string }> - - expect(pickSeedCandidates(candidates).map((item) => item.key)).toEqual([ - 'candidate-1', - 'candidate-4', - ]) - }) - it('生成包装计划输入时会保留逐件 slotKey / role / selectionKey', () => { const packagingInput: PreSkuPackagingInputDTO = { preSkuId: 'pre-sku-1', @@ -336,6 +306,18 @@ describe('openPlatformDemoSeedService', () => { code: 'PB18', name: '18E1级颗粒板', } as never) + mockedAssetCenterService.listSeries.mockResolvedValue([ + { + id: 'asset-series-1', + code: 'single-board-desk', + name: '单板桌', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), + }, + ] as never) mockedAssetCenterService.createFamily.mockImplementation(async (input) => ({ id: `${input.code}-id`, @@ -345,7 +327,6 @@ describe('openPlatformDemoSeedService', () => { description: input.description ?? null, productCategoryId: input.productCategoryId ?? null, productCategoryName: '单板桌', - applicableSeriesCodes: input.applicableSeriesCodes ?? [], connectionRoles: input.connectionRoles ?? [], metadata: input.metadata ?? null, status: 'DRAFT', @@ -376,7 +357,6 @@ describe('openPlatformDemoSeedService', () => { description: input.description ?? null, productCategoryId: input.productCategoryId ?? null, productCategoryName: '单板桌', - applicableSeriesCodes: input.applicableSeriesCodes ?? [], connectionRoles: input.connectionRoles ?? [], metadata: input.metadata ?? null, status: 'DRAFT', @@ -407,7 +387,6 @@ describe('openPlatformDemoSeedService', () => { description: null, productCategoryId: 'category-desk', productCategoryName: '单板桌', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: [], metadata: null, status: 'PENDING_REVIEW', @@ -438,7 +417,6 @@ describe('openPlatformDemoSeedService', () => { description: null, productCategoryId: 'category-desk', productCategoryName: '单板桌', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: [], metadata: null, status: 'AVAILABLE', @@ -655,67 +633,6 @@ describe('openPlatformDemoSeedService', () => { updatedAt: new Date('2026-04-01T09:20:00.000Z'), })) - mockedSeriesTemplateService.listTemplates.mockResolvedValue([ - { - id: 'series-template-1', - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - }, - ] as never) - - mockedCombinationService.generateCandidates.mockResolvedValue({ - seriesTemplateId: 'series-template-1', - totalCount: 4, - candidates: [ - { - key: 'candidate-desktop-a-slim', - defaultName: '云序-桌面 A-细腿', - familySelection: { - desktopFamilyId: 'family-desktop-a', - legGroupFamilyId: 'family-leg-slim', - legRoles: { - frontLeft: 'family-leg-slim', - frontRight: 'family-leg-slim', - rearLeft: 'family-leg-slim', - rearRight: 'family-leg-slim', - }, - connectorFamilyIds: ['family-connector'], - }, - }, - { - key: 'candidate-desktop-a-round', - defaultName: '云序-桌面 A-圆腿', - familySelection: { - desktopFamilyId: 'family-desktop-a', - legGroupFamilyId: 'family-leg-round', - legRoles: { - frontLeft: 'family-leg-round', - frontRight: 'family-leg-round', - rearLeft: 'family-leg-round', - rearRight: 'family-leg-round', - }, - connectorFamilyIds: ['family-connector'], - }, - }, - { - key: 'candidate-desktop-b-round', - defaultName: '云序-桌面 B-圆腿', - familySelection: { - desktopFamilyId: 'family-desktop-b', - legGroupFamilyId: 'family-leg-round', - legRoles: { - frontLeft: 'family-leg-round', - frontRight: 'family-leg-round', - rearLeft: 'family-leg-round', - rearRight: 'family-leg-round', - }, - connectorFamilyIds: ['family-connector'], - }, - }, - ], - } as never) - const packagingInputA: PreSkuPackagingInputDTO = { preSkuId: 'pre-sku-1', defaultName: '云序-桌面 A-细腿', @@ -743,14 +660,7 @@ describe('openPlatformDemoSeedService', () => { }, ], } - const packagingInputB: PreSkuPackagingInputDTO = { - ...packagingInputA, - preSkuId: 'pre-sku-2', - defaultName: '云序-桌面 B-圆腿', - sourceCandidateKey: 'candidate-desktop-b-round', - } - - mockedDraftGroupService.createFromAutoCombination.mockResolvedValue({ + mockedDraftGroupService.createFromManualAssembly.mockResolvedValue({ draftGroup: { id: 'draft-group-1', code: 'DG-SEED-001', @@ -763,12 +673,6 @@ describe('openPlatformDemoSeedService', () => { customName: null, packagingInput: packagingInputA, }, - { - id: 'pre-sku-2', - defaultName: '云序-桌面 B-圆腿', - customName: null, - packagingInput: packagingInputB, - }, ], } as never) mockedDraftGroupService.savePackagingPlan.mockResolvedValue({ @@ -793,17 +697,6 @@ describe('openPlatformDemoSeedService', () => { boxes: [{ boxNumber: 1, name: '标准包装箱 1' }], }, }, - { - id: 'pre-sku-2', - defaultName: '云序-桌面 B-圆腿', - customName: null, - status: 'DRAFT', - packagingInput: packagingInputB, - packagingPlan: { - id: 'packaging-plan-2', - boxes: [{ boxNumber: 1, name: '标准包装箱 1' }], - }, - }, ], } as never) mockedIntakeService.formalizeDraftGroup.mockResolvedValue({ @@ -825,18 +718,18 @@ describe('openPlatformDemoSeedService', () => { const result = await openPlatformDemoSeedService.seed() - expect(mockedDraftGroupService.createFromAutoCombination).toHaveBeenCalledWith( + expect(mockedDraftGroupService.createFromManualAssembly).toHaveBeenCalledWith( expect.objectContaining({ name: '开放平台演示草稿组', categoryId: 'category-desk', - candidates: expect.arrayContaining([ - expect.objectContaining({ key: 'candidate-desktop-a-slim' }), - expect.objectContaining({ key: 'candidate-desktop-b-round' }), + assetSeriesId: 'asset-series-1', + assemblies: expect.arrayContaining([ + expect.objectContaining({ key: 'seed-desktop-a' }), ]), }), expect.objectContaining({ id: 'user-planner' }), ) - expect(mockedDraftGroupService.savePackagingPlan).toHaveBeenCalledTimes(2) + expect(mockedDraftGroupService.savePackagingPlan).toHaveBeenCalledTimes(1) expect(mockedIntakeService.formalizeDraftGroup).toHaveBeenCalledWith( expect.objectContaining({ draftGroupId: 'draft-group-1', diff --git a/furniture-3d-system/src/server/services/asset-cad-import.service.ts b/furniture-3d-system/src/server/services/asset-cad-import.service.ts index ebca0c9b..98aafbe2 100644 --- a/furniture-3d-system/src/server/services/asset-cad-import.service.ts +++ b/furniture-3d-system/src/server/services/asset-cad-import.service.ts @@ -23,6 +23,7 @@ interface ImportAssetCadInput { familyCode?: string | null familyName?: string | null familyDescription?: string | null + assetSeriesId?: string | null connectionRoles?: string[] variantCode?: string | null variantName?: string | null @@ -522,6 +523,7 @@ class AssetCadImportService { code: familyCode, name: familyName, description: trimOrNull(input.familyDescription), + compatibleSeriesIds: input.assetSeriesId ? [input.assetSeriesId] : [], connectionRoles: input.connectionRoles ?? [], createdBy: input.createdBy, }) diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index 7b31cb6c..f7a9a205 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -50,6 +50,7 @@ import type { AuthUser } from '@/server/core/types' import { assetFamilyRepository, assetFamilyRelationInclude, + assetSeriesRepository, assetVariantRepository, assetVariantRelationInclude, categoryRepository, @@ -67,6 +68,7 @@ import type { AssetFamilyDTO, AssetTrashCleanupResultDTO, AssetTrashDataDTO, + AssetSeriesDTO, AssetReviewActionInput, AssetVariantDTO, AssetWorkflowAuditDTO, @@ -269,6 +271,16 @@ function toWorkflowDTO(record: { } function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { + const compatibleSeries = (record.compatibleSeries ?? []) + .map((item) => item.assetSeries) + .filter((series) => series.isActive) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, 'zh-CN')) + .map((series) => ({ + id: series.id, + code: series.code, + name: series.name, + })) + return { id: record.id, type: record.type, @@ -277,7 +289,7 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { description: record.description, productCategoryId: record.productCategoryId, productCategoryName: record.productCategory?.name ?? null, - applicableSeriesCodes: record.applicableSeriesCodes, + compatibleSeries, connectionRoles: record.connectionRoles, metadata: isRecord(record.metadata) ? record.metadata : null, status: record.status, @@ -289,6 +301,28 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { } } +function toAssetSeriesDTO(record: { + id: string + code: string + name: string + description: string | null + sortOrder: number + isActive: boolean + createdAt: Date + updatedAt: Date +}): AssetSeriesDTO { + return { + id: record.id, + code: record.code, + name: record.name, + description: record.description, + sortOrder: record.sortOrder, + isActive: record.isActive, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} + function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null { return value === null || value === undefined ? null : Number(value) } @@ -666,6 +700,26 @@ async function ensureFamilyCodeAvailable(code: string, currentId?: string): Prom } } +async function ensureActiveSeriesIds( + ids: string[] | undefined, + options?: { required?: boolean }, +): Promise { + const normalizedIds = Array.from(new Set((ids ?? []).map((id) => id.trim()).filter(Boolean))) + if (normalizedIds.length === 0) { + if (options?.required) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择至少一个兼容系列') + } + return [] + } + + const existing = await assetSeriesRepository.findByIds(normalizedIds) + if (existing.length !== normalizedIds.length) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '兼容系列不存在或已停用') + } + + return normalizedIds +} + async function ensureVariantCodeAvailable(code: string, currentId?: string): Promise { const existing = await assetVariantRepository.findByCode(code) if (existing && existing.id !== currentId) { @@ -880,7 +934,24 @@ class AssetCenterService { } } - async listFamilies(filter?: Pick) { + async listSeries(): Promise { + const records = await assetSeriesRepository.findAllActive() + if (records.length > 0) { + return records.map((item) => toAssetSeriesDTO(item)) + } + + const created = await assetSeriesRepository.createSeries({ + code: 'single-board-desk', + name: '单板桌', + description: '资产中心默认系列,可在后续系列管理中调整。', + sortOrder: 0, + isActive: true, + metadata: { source: 'default-seed' }, + }) + return [toAssetSeriesDTO(created)] + } + + async listFamilies(filter?: Pick) { const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined) return result.items.map((item) => toAssetFamilyDTO(item)) } @@ -942,13 +1013,23 @@ class AssetCenterService { await ensureFamilyCodeAvailable(code) await ensureCategoryExists(input.productCategoryId ?? null) + const compatibleSeriesIds = + input.type === 'HARDWARE' + ? [] + : await ensureActiveSeriesIds(input.compatibleSeriesIds, { required: true }) const created = await assetFamilyRepository.createWithRelations({ type: input.type, code, name, description: trimOrNull(input.description), - applicableSeriesCodes: input.applicableSeriesCodes ?? [], + compatibleSeries: compatibleSeriesIds.length > 0 + ? { + create: compatibleSeriesIds.map((assetSeriesId) => ({ + assetSeries: { connect: { id: assetSeriesId } }, + })), + } + : undefined, connectionRoles: input.connectionRoles ?? [], metadata: toJsonValue(input.metadata), status: ensureDraftCreationStatus(input.status), @@ -977,13 +1058,26 @@ class AssetCenterService { await ensureFamilyCodeAvailable(nextCode, id) await ensureCategoryExists(input.productCategoryId ?? existing.productCategoryId) + const compatibleSeriesIds = + existing.type === 'HARDWARE' + ? [] + : input.compatibleSeriesIds === undefined + ? undefined + : await ensureActiveSeriesIds(input.compatibleSeriesIds, { required: true }) const updated = await assetFamilyRepository.updateWithRelations(id, { code: nextCode, name: nextName, description: input.description !== undefined ? trimOrNull(input.description) : undefined, - applicableSeriesCodes: input.applicableSeriesCodes ?? undefined, + compatibleSeries: compatibleSeriesIds === undefined + ? undefined + : { + deleteMany: {}, + create: compatibleSeriesIds.map((assetSeriesId) => ({ + assetSeries: { connect: { id: assetSeriesId } }, + })), + }, connectionRoles: input.connectionRoles ?? undefined, metadata: toJsonValue(input.metadata), productCategory: @@ -2387,10 +2481,13 @@ export { AssetCenterService } export type { AssetFamilyDTO, AssetReviewActionInput, + AssetSeriesDTO, + AssetSeriesSummaryDTO, AssetVariantDTO, CreateAssetVariantResultDTO, CreateAssetFamilyInput, CreateAssetVariantInput, + MaterialLikeDTO, SimilarAssetVariantCandidateDTO, CreateSubstrateDictionaryInput, CreateSubstrateInput, diff --git a/furniture-3d-system/src/server/services/asset-center.service.types.ts b/furniture-3d-system/src/server/services/asset-center.service.types.ts index a9c673d6..c42cf1a2 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.types.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.types.ts @@ -17,6 +17,23 @@ export interface AssetWorkflowAuditDTO { lastModifiedByName: string | null } +export interface AssetSeriesDTO { + id: string + code: string + name: string + description: string | null + sortOrder: number + isActive: boolean + createdAt: Date + updatedAt: Date +} + +export interface AssetSeriesSummaryDTO { + id: string + code: string + name: string +} + export interface AssetFamilyDTO { id: string type: AssetType @@ -25,7 +42,7 @@ export interface AssetFamilyDTO { description: string | null productCategoryId: string | null productCategoryName: string | null - applicableSeriesCodes: string[] + compatibleSeries: AssetSeriesSummaryDTO[] connectionRoles: string[] metadata: Record | null status: AssetStatus @@ -214,7 +231,7 @@ export interface CreateAssetFamilyInput { name: string description?: string | null productCategoryId?: string | null - applicableSeriesCodes?: string[] + compatibleSeriesIds?: string[] connectionRoles?: string[] metadata?: Record | null status?: AssetStatus @@ -226,7 +243,7 @@ export interface UpdateAssetFamilyInput { name?: string description?: string | null productCategoryId?: string | null - applicableSeriesCodes?: string[] + compatibleSeriesIds?: string[] connectionRoles?: string[] metadata?: Record | null status?: AssetStatus diff --git a/furniture-3d-system/src/server/services/combination.service.ts b/furniture-3d-system/src/server/services/combination.service.ts deleted file mode 100644 index 85bd0c88..00000000 --- a/furniture-3d-system/src/server/services/combination.service.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { generateSingleBoardDeskCandidates } from '@/lib/open-product/single-board-desk' -import { - buildSingleBoardDeskAssemblyWorkspace, - type AssemblyFastenerRecommendation, -} from '@/lib/open-product/assembly-workspace' -import { AppError, ErrorCode } from '@/server/core/errors' -import { - assetFamilyRepository, - assetVariantRepository, - seriesTemplateRepository, -} from '../repositories' -import { - buildAvailableVariantCounts, - buildSingleBoardDeskTemplate, - materializeSingleBoardDeskCandidate, - toOpenProductVariants, -} from './single-board-desk-materialization' -import type { - CombinationCandidateItemDTO, - CombinationCandidateDTO, - CombinationGenerationResult, - GenerateCombinationInput, -} from './combination.service.types' - -function buildCombinationItems(candidate: ReturnType['candidates'][number]) { - const items: CombinationCandidateItemDTO[] = [ - { - label: '桌面板', - value: candidate.slotSummary.find((item) => item.label === '桌面板')?.value ?? '未配置', - quantity: 1, - role: 'desktop', - }, - ] - - const legFamilyIds = Object.values(candidate.familySelection.legRoles).filter( - (value): value is string => Boolean(value), - ) - const uniqueLegFamilyIds = Array.from(new Set(legFamilyIds)) - - if (uniqueLegFamilyIds.length === 0) { - items.push({ - label: '桌腿', - value: '未启用', - quantity: 0, - }) - } else { - uniqueLegFamilyIds.forEach((legFamilyId) => { - const quantity = legFamilyIds.filter((value) => value === legFamilyId).length - const legSummary = candidate.slotSummary.find((item) => item.label === '桌腿组')?.value ?? legFamilyId - items.push({ - label: '桌腿', - value: legSummary, - quantity, - }) - }) - } - - if (candidate.familySelection.connectorFamilyIds.length > 0) { - items.push({ - label: '连接件', - value: '系统推荐', - quantity: legFamilyIds.length, - }) - } - - return items -} - -function buildFastenerRecommendations( - candidate: ReturnType['candidates'][number], - componentCodes: string[], -): AssemblyFastenerRecommendation[] { - const connectorFamilyIds = candidate.familySelection.connectorFamilyIds - if (connectorFamilyIds.length === 0) { - return [] - } - - const quantity = Object.values(candidate.familySelection.legRoles).filter(Boolean).length - - return connectorFamilyIds.map((assetFamilyId, index) => ({ - selectionKey: `connector:${assetFamilyId}:${index + 1}`, - assetFamilyId, - quantity, - label: - componentCodes.find((code) => code.includes('CT-')) ?? - `推荐连接件 ${index + 1}`, - })) -} - -class CombinationService { - async generateCandidates(input: GenerateCombinationInput): Promise { - const template = await seriesTemplateRepository.findByIdWithRelations(input.seriesTemplateId) - if (!template) { - throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '系列模板不存在') - } - if (template.code !== 'single-board-desk') { - throw new AppError(ErrorCode.VALIDATION_FAILED, '当前版本只支持单板桌辅助装配') - } - if (input.desktopFamilyIds.length === 0) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '至少选择一个桌面板资产族') - } - - const familyIds = Array.from( - new Set([ - ...input.desktopFamilyIds, - ...(input.legFamilyIds ?? []), - ...(input.connectorFamilyIds ?? []), - ]), - ) - const families = await assetFamilyRepository.findByIdsWithRelations(familyIds) - const familyById = new Map(families.map((item) => [item.id, item])) - const familyNameById = new Map(families.map((item) => [item.id, item.name])) - const templateConfig = buildSingleBoardDeskTemplate(template) - - const result = generateSingleBoardDeskCandidates({ - template: templateConfig, - desktopFamilies: input.desktopFamilyIds - .map((id) => familyById.get(id)) - .filter((item): item is NonNullable => Boolean(item)) - .map((item) => ({ - id: item.id, - code: item.code, - name: item.name, - assetType: item.type, - connectionRoles: item.connectionRoles, - })), - legFamilies: (input.legFamilyIds ?? []) - .map((id) => familyById.get(id)) - .filter((item): item is NonNullable => Boolean(item)) - .map((item) => ({ - id: item.id, - code: item.code, - name: item.name, - assetType: item.type, - connectionRoles: item.connectionRoles, - })), - connectorFamilies: (input.connectorFamilyIds ?? []) - .map((id) => familyById.get(id)) - .filter((item): item is NonNullable => Boolean(item)) - .map((item) => ({ - id: item.id, - code: item.code, - name: item.name, - assetType: item.type, - connectionRoles: item.connectionRoles, - })), - }) - - const availableVariants = await assetVariantRepository.findAvailableByFamilyIds(familyIds) - const openProductVariants = toOpenProductVariants(availableVariants) - const availableVariantCounts = buildAvailableVariantCounts(openProductVariants) - - const candidates: CombinationCandidateDTO[] = result.candidates.map((candidate) => { - const { materialized, assemblyWarnings, assemblyResultStatus } = - materializeSingleBoardDeskCandidate({ - template: templateConfig, - candidate, - variants: openProductVariants, - availableVariantCounts, - familyNameById, - }) - const statusTag = - assemblyResultStatus === 'BLOCKED' - ? '缺少规格款' - : assemblyWarnings.length > 0 - ? '可预览但需确认' - : '可直接预览' - const combinationItems = buildCombinationItems(candidate) - const fastenerRecommendations = buildFastenerRecommendations( - candidate, - materialized.componentCodes, - ) - - return { - ...candidate, - previewMode: 'composite-ready', - statusTag, - estimatedCost: materialized.costPrice, - previewNodes: materialized.previewNodes, - assemblyResultStatus, - assemblyWarnings, - componentCodes: materialized.componentCodes, - bomPreview: materialized.bom, - installStepPreview: materialized.installStepPreview, - combinationItems, - candidateSnapshot: { - key: candidate.key, - defaultName: candidate.defaultName, - templateCode: templateConfig.code, - combinationItems, - }, - assemblyWorkspace: buildSingleBoardDeskAssemblyWorkspace({ - candidateKey: candidate.key, - defaultName: candidate.defaultName, - previewNodes: materialized.previewNodes, - slotSelections: materialized.selectedSlots, - connectorRecommendations: fastenerRecommendations, - }), - } - }) - - return { - seriesTemplateId: template.id, - totalCount: candidates.length, - candidates, - } - } -} - -export const combinationService = new CombinationService() -export { CombinationService } -export type { - CombinationCandidateDTO, - CombinationGenerationResult, - GenerateCombinationInput, -} from './combination.service.types' diff --git a/furniture-3d-system/src/server/services/combination.service.types.ts b/furniture-3d-system/src/server/services/combination.service.types.ts deleted file mode 100644 index 60551259..00000000 --- a/furniture-3d-system/src/server/services/combination.service.types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { SingleBoardDeskCandidate } from '@/lib/open-product/single-board-desk' -import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract' -import type { - AssemblyWorkspaceSnapshot, - CombinationCandidateSnapshot, -} from '@/lib/open-product/assembly-workspace' -import type { - InstallPreviewStep, - PreSkuBomItem, - PreSkuPreviewNode, -} from '@/lib/open-product/types' - -export interface GenerateCombinationInput { - seriesTemplateId: string - desktopFamilyIds: string[] - legFamilyIds?: string[] - connectorFamilyIds?: string[] -} - -export interface CombinationCandidateItemDTO { - label: string - value: string - quantity: number - role?: string -} - -export interface CombinationCandidateDTO extends SingleBoardDeskCandidate { - previewMode: 'composite-ready' - previewNodes: PreSkuPreviewNode[] - assemblyResultStatus: AssemblyResultStatus - assemblyWarnings: string[] - componentCodes: string[] - bomPreview: PreSkuBomItem[] - installStepPreview: InstallPreviewStep[] - combinationItems: CombinationCandidateItemDTO[] - candidateSnapshot: CombinationCandidateSnapshot - assemblyWorkspace: AssemblyWorkspaceSnapshot -} - -export interface CombinationGenerationResult { - seriesTemplateId: string - totalCount: number - candidates: CombinationCandidateDTO[] -} diff --git a/furniture-3d-system/src/server/services/draft-group.mapper.ts b/furniture-3d-system/src/server/services/draft-group.mapper.ts index 6a0a2060..925c5119 100644 --- a/furniture-3d-system/src/server/services/draft-group.mapper.ts +++ b/furniture-3d-system/src/server/services/draft-group.mapper.ts @@ -174,6 +174,8 @@ export function normalizeAssemblyWorkspace(value: unknown): AssemblyWorkspaceSna return { version: '2026-04-13', + assetSeriesId: + typeof value.assetSeriesId === 'string' ? value.assetSeriesId : null, productCategoryId: typeof value.productCategoryId === 'string' ? value.productCategoryId : null, productCategoryName: @@ -226,9 +228,9 @@ export function toDraftGroupDTO(record: DraftGroupWithRelations): DraftGroupDTO description: record.description, categoryId: record.categoryId, categoryName: record.category?.name ?? null, - seriesTemplateId: record.seriesTemplateId ?? '', - seriesTemplateCode: record.seriesTemplate?.code ?? '', - seriesTemplateName: record.seriesTemplate?.name ?? '', + assetSeriesId: record.assetSeriesId ?? '', + assetSeriesCode: record.assetSeries?.code ?? '', + assetSeriesName: record.assetSeries?.name ?? '', status: record.status, summary: isRecord(record.summary) ? record.summary : null, notes: record.notes, diff --git a/furniture-3d-system/src/server/services/draft-group.service.ts b/furniture-3d-system/src/server/services/draft-group.service.ts index 4bfcc27a..735fc8af 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.ts @@ -151,6 +151,8 @@ class DraftGroupService { const assemblies = normalizeAssemblies(input) const resolvedCategoryId = input.categoryId ?? assemblies[0]?.assemblyWorkspace.productCategoryId ?? null + const resolvedAssetSeriesId = + input.assetSeriesId ?? assemblies[0]?.assemblyWorkspace.assetSeriesId ?? null if (!input.name.trim()) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '草稿组名称不能为空') @@ -170,6 +172,7 @@ class DraftGroupService { } as Prisma.InputJsonValue, notes: input.notes?.trim() || null, category: resolvedCategoryId ? { connect: { id: resolvedCategoryId } } : undefined, + assetSeries: resolvedAssetSeriesId ? { connect: { id: resolvedAssetSeriesId } } : undefined, creator: { connect: { id: user.id } }, }) diff --git a/furniture-3d-system/src/server/services/draft-group.service.types.ts b/furniture-3d-system/src/server/services/draft-group.service.types.ts index a1cd7b97..197d0f3c 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.types.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.types.ts @@ -41,6 +41,7 @@ export interface CreateDraftGroupFromManualAssemblyInput { name: string description?: string | null categoryId?: string | null + assetSeriesId?: string | null assemblies?: DraftGroupCandidateInput[] candidates?: DraftGroupCandidateInput[] notes?: string | null @@ -55,9 +56,9 @@ export interface DraftGroupDTO { description: string | null categoryId: string | null categoryName: string | null - seriesTemplateId: string - seriesTemplateCode: string - seriesTemplateName: string + assetSeriesId: string + assetSeriesCode: string + assetSeriesName: string status: DraftGroupStatus summary: Record | null notes: string | null diff --git a/furniture-3d-system/src/server/services/index.ts b/furniture-3d-system/src/server/services/index.ts index 9d61d6d2..90482cb8 100644 --- a/furniture-3d-system/src/server/services/index.ts +++ b/furniture-3d-system/src/server/services/index.ts @@ -162,10 +162,13 @@ export type { export type { AssetFamilyDTO, AssetReviewActionInput, + AssetSeriesDTO, + AssetSeriesSummaryDTO, AssetVariantDTO, CreateAssetVariantResultDTO, CreateAssetFamilyInput, CreateAssetVariantInput, + MaterialLikeDTO, SimilarAssetVariantCandidateDTO, CreateSubstrateDictionaryInput, CreateSubstrateInput, @@ -186,16 +189,6 @@ export type { SupportedAssetImportType, } from './asset-cad-import.service' -export { seriesTemplateService, SeriesTemplateService } from './series-template.service' -export type { SeriesTemplateDTO } from './series-template.service' - -export { combinationService, CombinationService } from './combination.service' -export type { - CombinationCandidateDTO, - CombinationGenerationResult, - GenerateCombinationInput, -} from './combination.service' - export { draftGroupService, DraftGroupService } from './draft-group.service' export type { CreateDraftGroupFromAutoCombinationInput, diff --git a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts index 990bf2c9..297ca29b 100644 --- a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts +++ b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts @@ -10,6 +10,11 @@ import { buildStandardFinishMaterialData } from '@/lib/standard-finish-sync' import type { OpenProductMountPoint } from '@/lib/open-product/types' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' +import { + buildAssemblyWorkspaceFamilies, + buildManualAssemblyWorkspace, + instantiateAssemblyNode, +} from '@/lib/open-product/assembly-workspace' import { assetCenterService } from './asset-center.service' import type { AssetFamilyDTO, @@ -17,15 +22,13 @@ import type { CreateAssetFamilyInput, CreateAssetVariantInput, } from './asset-center.service.types' -import { combinationService } from './combination.service' import { draftGroupService } from './draft-group.service' import type { - DraftGroupCandidateInput, + DraftGroupAssemblyInput, PreSkuPackagingInputDTO, SavePreSkuPackagingPlanInput, } from './draft-group.service.types' import { intakeService } from './intake.service' -import { seriesTemplateService } from './series-template.service' import { warehouseService } from './warehouse.service' const OPEN_PLATFORM_DEMO_SEED_MARKER = '[seed:open-platform-demo/v1]' @@ -148,15 +151,6 @@ function toAuthUser(user: { } } -export function pickSeedCandidates(candidates: T[]): T[] { - if (candidates.length <= 2) { - return candidates - } - - const picked = [candidates[0], candidates[candidates.length - 1]] - return picked[0].key === picked[1].key ? [picked[0]] : picked -} - export function buildSeedPackagingPlanInput( packagingInput: PreSkuPackagingInputDTO, materials: { @@ -397,11 +391,11 @@ async function ensureFamilyAvailable( const family = existing ? await assetCenterService.updateFamily(existing.id, { code: definition.code, - name: definition.name, - description: definition.description ?? null, - productCategoryId: definition.productCategoryId ?? null, - applicableSeriesCodes: definition.applicableSeriesCodes ?? [], - connectionRoles: definition.connectionRoles ?? [], + name: definition.name, + description: definition.description ?? null, + productCategoryId: definition.productCategoryId ?? null, + compatibleSeriesIds: definition.compatibleSeriesIds ?? [], + connectionRoles: definition.connectionRoles ?? [], metadata: definition.metadata ?? null, updatedBy: creator.id, }) @@ -534,7 +528,10 @@ async function cleanupPreviousSeedArtifacts(): Promise { } } -function buildDemoFamilyDefinitions(categoryId: string): Record { +function buildDemoFamilyDefinitions( + categoryId: string, + assetSeriesId: string, +): Record { return { desktopA: { type: 'BOARD', @@ -542,7 +539,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record { - const familyDefinitions = buildDemoFamilyDefinitions(input.categoryId) + const familyDefinitions = buildDemoFamilyDefinitions(input.categoryId, input.assetSeriesId) const desktopAFamily = await ensureFamilyAvailable( familyDefinitions.desktopA, @@ -1018,8 +1016,16 @@ class OpenPlatformDemoSeedService { await cleanupPreviousSeedArtifacts() + const assetSeries = (await assetCenterService.listSeries()).find( + (item) => item.code === 'single-board-desk', + ) + if (!assetSeries) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '未找到单板桌资产系列') + } + const assets = await ensureDemoAssets({ categoryId: category.id, + assetSeriesId: assetSeries.id, substrateId: substrateRecord.id, finish: { id: finishBundle.material.id, @@ -1029,30 +1035,52 @@ class OpenPlatformDemoSeedService { reviewer, }) - const templates = await seriesTemplateService.listTemplates() - const template = templates.find((item) => item.code === 'single-board-desk') - if (!template) { - throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '未找到单板桌系列模板') - } - - const generated = await combinationService.generateCandidates({ - seriesTemplateId: template.id, - desktopFamilyIds: [assets.desktopA.family.id, assets.desktopB.family.id], - legFamilyIds: [assets.legSlim.family.id, assets.legRound.family.id], - connectorFamilyIds: [assets.connector.family.id], + const defaultVariantIdsByFamilyId = new Map([ + [assets.desktopA.family.id, assets.desktopA.variant.id], + ]) + const baseWorkspace = buildManualAssemblyWorkspace({ + assetSeriesId: assetSeries.id, + productCategoryId: category.id, + productCategoryName: category.name, + source: 'assisted-assembly', + entryMode: 'direct', + families: buildAssemblyWorkspaceFamilies( + [ + { ...assets.desktopA.family, assetType: assets.desktopA.family.type }, + ], + defaultVariantIdsByFamilyId, + ), }) + const desktopInventory = baseWorkspace.inventory.find( + (item) => item.familyId === assets.desktopA.family.id, + ) + const assemblyWorkspace = desktopInventory + ? instantiateAssemblyNode(baseWorkspace, { + inventoryId: desktopInventory.inventoryId, + variant: assets.desktopA.variant, + }) + : baseWorkspace + const selectedAssemblies: DraftGroupAssemblyInput[] = [ + { + key: 'seed-desktop-a', + defaultName: assets.desktopA.variant.name, + source: 'assisted-assembly', + assemblyWorkspace, + selectedFamilyIdsByType: { + desktopFamilyIds: [assets.desktopA.family.id], + legFamilyIds: [], + connectorFamilyIds: [], + }, + }, + ] - const selectedCandidates = pickSeedCandidates(generated.candidates as DraftGroupCandidateInput[]) - if (selectedCandidates.length === 0) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '辅助装配没有生成候选,无法创建演示草稿组') - } - - const draftGroupDetail = await draftGroupService.createFromAutoCombination( + const draftGroupDetail = await draftGroupService.createFromManualAssembly( { name: OPEN_PLATFORM_DEMO_DRAFT_GROUP_NAME, description: '开放平台主链联调用演示草稿组', categoryId: category.id, - candidates: selectedCandidates, + assetSeriesId: assetSeries.id, + assemblies: selectedAssemblies, notes: `${OPEN_PLATFORM_DEMO_SEED_MARKER} 自动创建`, }, planner, diff --git a/furniture-3d-system/src/server/services/series-template.service.ts b/furniture-3d-system/src/server/services/series-template.service.ts deleted file mode 100644 index 5a0bdc24..00000000 --- a/furniture-3d-system/src/server/services/series-template.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Prisma } from '@prisma/client' -import { AppError, ErrorCode } from '@/server/core/errors' -import { SINGLE_BOARD_DESK_SLOT_SCHEMA } from '@/lib/open-product/assembly-contract' -import { seriesTemplateRepository } from '../repositories' - -export interface SeriesTemplateDTO { - id: string - code: string - name: string - seriesName: string - description: string | null - categoryId: string | null - categoryName: string | null - slotSchema: Record - namingRule: Record | null - recommendationRule: Record | null - previewRule: Record | null - intakeRule: Record | null - isPreset: boolean - isActive: boolean - createdAt: Date - updatedAt: Date -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function toDTO( - record: NonNullable>>, -): SeriesTemplateDTO { - return { - id: record.id, - code: record.code, - name: record.name, - seriesName: record.seriesName, - description: record.description, - categoryId: record.categoryId, - categoryName: record.category?.name ?? null, - slotSchema: isRecord(record.slotSchema) ? record.slotSchema : {}, - namingRule: isRecord(record.namingRule) ? record.namingRule : null, - recommendationRule: isRecord(record.recommendationRule) ? record.recommendationRule : null, - previewRule: isRecord(record.previewRule) ? record.previewRule : null, - intakeRule: isRecord(record.intakeRule) ? record.intakeRule : null, - isPreset: record.isPreset, - isActive: record.isActive, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - } -} - -class SeriesTemplateService { - private async ensurePresetSingleBoardDeskTemplate(): Promise { - const existing = await seriesTemplateRepository.findByCode('single-board-desk') - if (existing) return - - await seriesTemplateRepository.createWithRelations({ - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - description: '一期主链固定模板:桌面板 + 四个独立腿位 + 自动连接件推荐', - slotSchema: { - slots: SINGLE_BOARD_DESK_SLOT_SCHEMA, - } as unknown as Prisma.InputJsonValue, - namingRule: { - defaultPattern: '{seriesName}-{desktopFamilyName}-{legFamilyName}', - }, - recommendationRule: { - autoConnectorRoles: ['desk_leg_connector'], - }, - previewRule: { - desktopHeight: 720, - desktopPosition: [0, 720, 0], - legOffsets: { - frontLeft: [-450, 360, 250], - frontRight: [450, 360, 250], - rearLeft: [-450, 360, -250], - rearRight: [450, 360, -250], - }, - }, - intakeRule: { - allowCreateProduct: true, - allowAttachExistingProduct: true, - }, - isPreset: true, - isActive: true, - }) - } - - async listTemplates(): Promise { - await this.ensurePresetSingleBoardDeskTemplate() - const records = await seriesTemplateRepository.findAllActiveWithRelations() - return records.map((record) => toDTO(record)) - } - - async getById(id: string): Promise { - await this.ensurePresetSingleBoardDeskTemplate() - const record = await seriesTemplateRepository.findByIdWithRelations(id) - if (!record) { - throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '系列模板不存在') - } - - return toDTO(record) - } -} - -export const seriesTemplateService = new SeriesTemplateService() -export { SeriesTemplateService } diff --git a/furniture-3d-system/src/server/services/single-board-desk-materialization.ts b/furniture-3d-system/src/server/services/single-board-desk-materialization.ts index f8d7e7df..27db6437 100644 --- a/furniture-3d-system/src/server/services/single-board-desk-materialization.ts +++ b/furniture-3d-system/src/server/services/single-board-desk-materialization.ts @@ -5,50 +5,11 @@ import { } from '@/lib/open-product/single-board-desk' import type { OpenProductAssetVariant } from '@/lib/open-product/types' import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract' -import { seriesTemplateRepository } from '../repositories' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export function buildSingleBoardDeskTemplate( - record: NonNullable>>, -): SingleBoardDeskTemplate { - const previewRule = isRecord(record.previewRule) ? record.previewRule : {} - const legOffsets = isRecord(previewRule.legOffsets) ? previewRule.legOffsets : {} - - return { - code: record.code, - name: record.name, - displayName: record.name, - naming: { - seriesName: record.seriesName, - }, - previewLayout: { - desktopHeight: - typeof previewRule.desktopHeight === 'number' ? previewRule.desktopHeight : 720, - desktopPosition: Array.isArray(previewRule.desktopPosition) - ? (previewRule.desktopPosition as [number, number, number]) - : [0, 720, 0], - legOffsets: { - frontLeft: Array.isArray(legOffsets.frontLeft) - ? (legOffsets.frontLeft as [number, number, number]) - : [-450, 360, 250], - frontRight: Array.isArray(legOffsets.frontRight) - ? (legOffsets.frontRight as [number, number, number]) - : [450, 360, 250], - rearLeft: Array.isArray(legOffsets.rearLeft) - ? (legOffsets.rearLeft as [number, number, number]) - : [-450, 360, -250], - rearRight: Array.isArray(legOffsets.rearRight) - ? (legOffsets.rearRight as [number, number, number]) - : [450, 360, -250], - }, - }, - autoConnectorRoles: ['desk_leg_connector'], - } -} - export function toOpenProductVariants( records: Awaited>, ): OpenProductAssetVariant[] {