feat: 重构资产中心系列优先入口
This commit is contained in:
parent
1f2d2af257
commit
b603555df7
@ -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;
|
||||
@ -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")
|
||||
|
||||
@ -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: '测试用户',
|
||||
|
||||
@ -167,7 +167,7 @@ const mockDraftGroup: DraftGroupSummary = {
|
||||
name: '我的草稿',
|
||||
description: '桌类方案',
|
||||
categoryName: '单板桌',
|
||||
seriesTemplateName: '单板桌',
|
||||
assetSeriesName: '单板桌',
|
||||
status: 'DRAFT',
|
||||
summary: { selectedCount: 2 },
|
||||
creatorName: '测试用户',
|
||||
|
||||
@ -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: [],
|
||||
}),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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)
|
||||
@ -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({
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
AssetFamilyDTO,
|
||||
AssetVariantDTO,
|
||||
MaterialDTO,
|
||||
SeriesTemplateDTO,
|
||||
AssetSeriesDTO,
|
||||
SubstrateDTO,
|
||||
} from '@/server/services'
|
||||
|
||||
@ -265,7 +265,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): 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={[]}
|
||||
|
||||
@ -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>): 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={[]}
|
||||
|
||||
@ -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: <Layers3 className="h-3.5 w-3.5" /> },
|
||||
{ type: 'COMPONENT', label: '组件', icon: <Shapes className="h-3.5 w-3.5" /> },
|
||||
{ type: 'HARDWARE', label: '五金', icon: <Link2 className="h-3.5 w-3.5" /> },
|
||||
@ -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<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Tab 状态
|
||||
const [activeTab, setActiveTab] = useState<AssetFamilyDTO['type']>('BOARD')
|
||||
// 系列与分类筛选状态
|
||||
const [selectedSeriesId, setSelectedSeriesId] = useState<string | null>(
|
||||
initialAssetSeries[0]?.id ?? null,
|
||||
)
|
||||
const [seriesPickerOpen, setSeriesPickerOpen] = useState(false)
|
||||
const [seriesSearchText, setSeriesSearchText] = useState('')
|
||||
const seriesPickerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<AssetTypeFilter>('BOARD')
|
||||
const [assemblySelectedFamilyIds, setAssemblySelectedFamilyIds] = useState<Set<string>>(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<ConnectionRoleDefinition[]>([])
|
||||
@ -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<string, AssetVariantDTO[]>()
|
||||
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<string, number>()
|
||||
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
|
||||
</span>
|
||||
</div>
|
||||
{/* 系列入口 */}
|
||||
<div className="relative space-y-2" ref={seriesPickerRef}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.14em] text-[#5a6062]/55">
|
||||
系列
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-[#5a6062]/45">
|
||||
共 {initialAssetSeries.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={seriesPickerOpen}
|
||||
onClick={() => setSeriesPickerOpen((open) => !open)}
|
||||
className="flex h-10 w-full items-center justify-between gap-3 rounded-[6px] bg-black/[0.025] px-3 text-left text-[#2d3335] ring-1 ring-black/[0.04] transition hover:bg-black/[0.04] focus:outline-none focus:ring-black/[0.12]"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-bold">
|
||||
{selectedSeries?.name ?? '请选择系列'}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate font-mono text-[9px] font-bold uppercase tracking-[0.08em] text-[#5a6062]/45">
|
||||
{selectedSeries?.code ?? 'NO-SERIES'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="rounded-[4px] bg-white px-1.5 py-0.5 text-[9px] font-bold leading-none text-[#5a6062]/60 ring-1 ring-black/[0.04]">
|
||||
{selectedSeriesAssetCount}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-[#5a6062]/55 transition',
|
||||
seriesPickerOpen && 'rotate-180 text-[#2d3335]',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{seriesPickerOpen && (
|
||||
<div className="absolute left-0 right-0 top-full z-30 mt-1.5 overflow-hidden rounded-[8px] border border-black/[0.06] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)]">
|
||||
<div className="border-b border-black/[0.04] p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[#5a6062]/35" />
|
||||
<input
|
||||
value={seriesSearchText}
|
||||
onChange={(event) => 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]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div role="listbox" className="max-h-64 overflow-y-auto p-1">
|
||||
{filteredAssetSeries.length > 0 ? (
|
||||
filteredAssetSeries.map((series) => {
|
||||
const active = selectedSeriesId === series.id
|
||||
const count = seriesAssetCounts.get(series.id) ?? 0
|
||||
return (
|
||||
<button
|
||||
key={series.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => handleSelectSeries(series.id)}
|
||||
className={cn(
|
||||
'flex min-h-9 w-full items-center justify-between gap-3 rounded-[6px] px-2.5 py-2 text-left transition',
|
||||
active
|
||||
? 'bg-[#2d3335] text-white'
|
||||
: 'text-[#2d3335] hover:bg-black/[0.035]',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-bold">{series.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 block truncate font-mono text-[9px] font-bold uppercase tracking-[0.08em]',
|
||||
active ? 'text-white/55' : 'text-[#5a6062]/42',
|
||||
)}
|
||||
>
|
||||
{series.code}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-[4px] px-1.5 py-0.5 text-[9px] font-bold leading-none',
|
||||
active ? 'bg-white/15 text-white' : 'bg-black/[0.04] text-[#5a6062]/60',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-5 text-center text-xs text-[#5a6062]/50">
|
||||
没有匹配的系列
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分类 Tab */}
|
||||
<div className="flex rounded-[6px] bg-black/[0.03] p-0.5 ring-1 ring-black/5">
|
||||
{TYPE_TABS.map((tab) => {
|
||||
const count = getVisibleFamiliesForTab(families, tab.type).length
|
||||
const count = getVisibleFamiliesForTab(currentSeriesFamilies, tab.type).length
|
||||
const isActive = activeTab === tab.type
|
||||
return (
|
||||
<button
|
||||
@ -3103,6 +3355,7 @@ export default function AssetCenterClient({
|
||||
size="small"
|
||||
ariaLabel="新建资产族"
|
||||
onClick={openCreateFamilyModal}
|
||||
disabled={!selectedSeries}
|
||||
className="h-9 w-9 rounded-lg p-0 active:scale-90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -3122,7 +3375,7 @@ export default function AssetCenterClient({
|
||||
<div className="flex items-center gap-2">
|
||||
<Package2 className="h-4 w-4 text-[#2d3335]" />
|
||||
<div>
|
||||
<p className="text-sm font-apple-medium text-[#2d3335]">包材资料库</p>
|
||||
<p className="text-sm font-semibold text-[#2d3335]">包材资料库</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-[#5a6062]/60" />
|
||||
@ -3130,16 +3383,20 @@ export default function AssetCenterClient({
|
||||
)}
|
||||
{currentFamilies.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-apple-label-tertiary">
|
||||
{searchText
|
||||
<p className="text-sm text-[#5a6062]/55">
|
||||
{!selectedSeries
|
||||
? '请先选择系列'
|
||||
: searchText
|
||||
? `未找到匹配「${searchText}」的资产族`
|
||||
: reviewFilter === 'PENDING_REVIEW'
|
||||
? '当前没有待审核资产'
|
||||
: reviewFilter === 'REJECTED'
|
||||
? '当前没有已驳回资产'
|
||||
: '暂无该类型资产族'}
|
||||
: activeTab === 'HARDWARE'
|
||||
? '暂无五金件'
|
||||
: '当前系列暂无该类型资产族'}
|
||||
</p>
|
||||
{!searchText && reviewFilter === 'ALL' && (
|
||||
{selectedSeries && !searchText && reviewFilter === 'ALL' && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
@ -3148,7 +3405,7 @@ export default function AssetCenterClient({
|
||||
>
|
||||
创建第一个资产族
|
||||
</button>
|
||||
<p className="text-[11px] text-apple-label-tertiary">
|
||||
<p className="text-[11px] text-[#5a6062]/45">
|
||||
先建资产族,再在资产族里面新建或导入带模型的规格款。
|
||||
</p>
|
||||
</div>
|
||||
@ -3167,6 +3424,7 @@ export default function AssetCenterClient({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`展开资产族 ${family.code}`}
|
||||
onClick={() => toggleFamilyExpand(family.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@ -4105,12 +4363,50 @@ export default function AssetCenterClient({
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-[#2d3335]">资产类型</p>
|
||||
<div className="grid grid-cols-4 gap-1 rounded-[6px] bg-black/[0.03] p-0.5 ring-1 ring-black/5">
|
||||
{TYPE_TABS.map((tab) => {
|
||||
const type = tab.type as AssetFamilyDTO['type']
|
||||
const active = familyForm.type === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFamilyForm((current) => ({
|
||||
...current,
|
||||
type,
|
||||
compatibleSeriesIds:
|
||||
type === 'HARDWARE'
|
||||
? []
|
||||
: current.compatibleSeriesIds.length > 0
|
||||
? current.compatibleSeriesIds
|
||||
: selectedSeriesId
|
||||
? [selectedSeriesId]
|
||||
: [],
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center gap-1.5 rounded-[4px] px-2 text-[10px] font-bold transition',
|
||||
active
|
||||
? 'bg-white text-[#2d3335] shadow-sm'
|
||||
: 'text-[#5a6062]/50 hover:text-[#2d3335]',
|
||||
)}
|
||||
>
|
||||
<span className="h-3.5 w-3.5">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<Input
|
||||
label="编码"
|
||||
value={familyForm.code}
|
||||
onChange={(e) => setFamilyForm((c) => ({ ...c, code: e.target.value }))}
|
||||
placeholder={`例如 ${activeTab}-001`}
|
||||
placeholder={`例如 ${familyForm.type}-001`}
|
||||
/>
|
||||
<Input
|
||||
label="名称"
|
||||
@ -4119,6 +4415,50 @@ export default function AssetCenterClient({
|
||||
placeholder="例如 单板桌桌面板"
|
||||
/>
|
||||
</div>
|
||||
{familyForm.type !== 'HARDWARE' ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-[#2d3335]">兼容系列</p>
|
||||
<span className="text-[11px] text-[#5a6062]/55">
|
||||
已选 {familyForm.compatibleSeriesIds.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{initialAssetSeries.map((series) => {
|
||||
const checked = familyForm.compatibleSeriesIds.includes(series.id)
|
||||
return (
|
||||
<label
|
||||
key={series.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-[6px] px-3 py-2 text-xs transition ring-1',
|
||||
checked
|
||||
? 'bg-[#2d3335] text-white ring-[#2d3335]'
|
||||
: 'bg-black/[0.025] text-[#2d3335] ring-black/[0.04] hover:bg-black/[0.045]',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
setFamilyForm((current) => ({
|
||||
...current,
|
||||
compatibleSeriesIds: event.target.checked
|
||||
? Array.from(new Set([...current.compatibleSeriesIds, series.id]))
|
||||
: current.compatibleSeriesIds.filter((id) => id !== series.id),
|
||||
}))
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-bold">{series.name}</span>
|
||||
<span className={cn('font-mono text-[10px]', checked ? 'text-white/60' : 'text-[#5a6062]/45')}>
|
||||
{series.code}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<details className="group rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/30">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<string | null | undefined>)
|
||||
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 (
|
||||
<div className="relative h-full min-h-[176px] overflow-hidden rounded-md bg-[#f3f5f5] ring-1 ring-black/[0.05]">
|
||||
<div
|
||||
className="absolute inset-x-8 top-10 h-[72px] rotate-[-7deg] rounded-[5px] shadow-[0_18px_42px_rgba(17,19,21,0.12)] ring-1 ring-black/[0.08]"
|
||||
style={{
|
||||
backgroundColor: finish?.color ?? '#d8ddde',
|
||||
backgroundImage: surfaceUrl
|
||||
? `linear-gradient(135deg, rgba(255,255,255,0.18), rgba(0,0,0,0.06)), url(${surfaceUrl})`
|
||||
: 'linear-gradient(135deg, rgba(255,255,255,0.42), rgba(0,0,0,0.08))',
|
||||
backgroundSize: surfaceUrl ? '100% 100%, cover' : '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-12 top-[108px] h-5 rotate-[-7deg] rounded-b-[5px] bg-black/[0.08] blur-[10px]" />
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] font-apple-black text-[#66777c]">板材基材</p>
|
||||
<p className="mt-1 truncate text-sm font-apple-black text-[#101719]">{substrate.name}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-white px-2 py-1 text-[10px] font-apple-black text-[#263033] ring-1 ring-black/[0.06]">
|
||||
{substrate.thicknessSpecName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FinishParameterSliderField({
|
||||
id, label, value, min = 0, max = 1, step = 0.01, disabled, onChange
|
||||
}: {
|
||||
@ -1192,18 +1228,24 @@ export default function MaterialLibraryClient({
|
||||
<div className="flex gap-2">
|
||||
{isView && activeSubstrate ? (
|
||||
<>
|
||||
<Button variant="plain" size="small" aria-label="编辑当前基材" onClick={() => startEditSubstrate(activeSubstrate)}>
|
||||
<Pencil className="h-4 w-4 mr-1.5" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="编辑当前基材"
|
||||
title="编辑"
|
||||
className={mlIconActionButtonClassName}
|
||||
onClick={() => startEditSubstrate(activeSubstrate)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除当前基材"
|
||||
className="text-apple-red"
|
||||
title="删除"
|
||||
className={mlDangerIconActionButtonClassName}
|
||||
onClick={() => setDeleteTarget({ type: 'substrate', id: activeSubstrate.id, name: activeSubstrate.name })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" /> 删除
|
||||
</Button>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{isCreate || isEdit ? (
|
||||
@ -1426,10 +1468,10 @@ export default function MaterialLibraryClient({
|
||||
</div>
|
||||
</div>
|
||||
) : activeSubstrate ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-black/10 bg-white p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className={mlEyebrowClassName}>基础信息</p>
|
||||
<div className="mt-4 grid gap-3 text-sm">
|
||||
<div className="mt-3 divide-y divide-black/[0.06] border-y border-black/[0.06]">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-apple-bold text-[#687477]">{label}</span>
|
||||
<div key={label} className="grid grid-cols-[96px_minmax(0,1fr)] items-center gap-3 py-2.5">
|
||||
<span className="text-[11px] font-apple-bold text-[#687477]">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-right text-sm font-apple-black',
|
||||
'truncate text-right text-sm font-apple-black',
|
||||
missing
|
||||
? 'rounded-full bg-apple-red/10 px-2 py-0.5 text-apple-red'
|
||||
? 'text-[#a44640]'
|
||||
: 'text-[#263033]',
|
||||
)}
|
||||
>
|
||||
@ -1464,19 +1507,29 @@ export default function MaterialLibraryClient({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/10 bg-white p-6">
|
||||
<p className={mlEyebrowClassName}>关联花色</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
|
||||
<div>
|
||||
<p className={mlEyebrowClassName}>绑定花色</p>
|
||||
<div className="mt-3">
|
||||
{activeSubstrate.finishes.length === 0 ? (
|
||||
<span className="text-xs text-[#7f9297]">暂无关联花色</span>
|
||||
<div className="rounded-md bg-black/[0.025] px-3 py-3 text-xs font-apple-bold text-[#7f9297] ring-1 ring-black/[0.04]">
|
||||
暂无关联花色
|
||||
</div>
|
||||
) : (
|
||||
activeSubstrate.finishes.slice(0, 1).map((finish) => (
|
||||
<span key={finish.id} className={mlTagClassName}>
|
||||
<div key={finish.id} className="flex items-center gap-3 rounded-md bg-black/[0.025] px-3 py-3 ring-1 ring-black/[0.04]">
|
||||
{finish.textureUrl || finish.thumbnailUrl ? (
|
||||
<img src={finish.textureUrl ?? finish.thumbnailUrl ?? ''} alt="" className="h-4 w-4 rounded object-cover" />
|
||||
) : null}
|
||||
{formatFinishOptionLabel(finish)}
|
||||
</span>
|
||||
<img src={finish.textureUrl ?? finish.thumbnailUrl ?? ''} alt="" className="h-10 w-10 rounded object-cover ring-1 ring-black/[0.06]" />
|
||||
) : (
|
||||
<span className="h-10 w-10 rounded bg-black/[0.04] ring-1 ring-black/[0.06]" style={{ backgroundColor: finish.color }} />
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-apple-black text-[#101719]">{finish.name}</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] font-apple-bold text-[#687477]">
|
||||
{finish.code} · {finish.categoryName ?? '未分类'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@ -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]"
|
||||
/>
|
||||
</div>
|
||||
{metrics.slice(0, 2).map((metric) => (
|
||||
<div key={metric.label} className="hidden h-10 min-w-[94px] items-center justify-between gap-3 rounded-lg border border-black/10 bg-white px-3 text-left xl:flex">
|
||||
<p className="truncate text-[10px] font-apple-black text-[#687477]">{metric.label}</p>
|
||||
<p className="shrink-0 text-sm font-apple-black text-[#101719]">{metric.value}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="hidden h-10 items-center gap-3 px-1 text-[11px] font-apple-black text-[#687477] xl:flex">
|
||||
{metrics.slice(0, 2).map((metric, index) => (
|
||||
<span key={metric.label} className="flex items-center gap-1.5">
|
||||
{index > 0 ? <span className="h-3 w-px bg-black/10" /> : null}
|
||||
<span>{metric.label}</span>
|
||||
<span className="text-[#101719]">{metric.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="filled"
|
||||
size="small"
|
||||
@ -1850,52 +1906,60 @@ export default function MaterialLibraryClient({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white p-4">
|
||||
{activeWorkbenchSubstrate ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-4">
|
||||
{activeWorkbenchSubstrate ? (
|
||||
<div className="grid min-h-[188px] grid-cols-[minmax(220px,0.34fr)_minmax(0,1fr)] gap-4">
|
||||
<SubstrateSlabPreview substrate={activeWorkbenchSubstrate} />
|
||||
<div className="flex min-w-0 flex-col justify-between rounded-md bg-white p-4 ring-1 ring-black/[0.06]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] font-apple-black text-[#66777c]">当前基材</p>
|
||||
<h2 className="mt-1 truncate text-base font-apple-black tracking-tight text-[#101719]">
|
||||
<h2 className="mt-1 truncate text-xl font-apple-black tracking-tight text-[#101719]">
|
||||
{activeWorkbenchSubstrate.name}
|
||||
</h2>
|
||||
<p className="mt-2 truncate text-[11px] font-apple-bold text-[#687477]">
|
||||
{[activeWorkbenchSubstrate.categoryName, activeWorkbenchSubstrate.environmentGradeName, activeWorkbenchSubstrate.surfaceOptionName]
|
||||
.filter(Boolean)
|
||||
.join(' / ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-[520px] shrink-0 grid-cols-4 gap-2">
|
||||
<div className="mt-4 grid grid-cols-4 divide-x divide-black/[0.06] border-y border-black/[0.06]">
|
||||
{[
|
||||
['编码', activeWorkbenchSubstrate.code],
|
||||
['尺寸', `${activeWorkbenchSubstrate.sheetLengthMm} × ${activeWorkbenchSubstrate.sheetWidthMm}`],
|
||||
['厚度', activeWorkbenchSubstrate.thicknessSpecName],
|
||||
['花色', activeWorkbenchSubstrate.finishes[0]?.name ?? '未绑定'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-0 rounded-lg bg-black/[0.035] px-3 py-2 ring-1 ring-black/5">
|
||||
<div key={label} className="min-w-0 px-3 py-2.5 first:pl-0 last:pr-0">
|
||||
<p className="text-[8px] font-apple-black text-[#7f9297]">{label}</p>
|
||||
<p className="mt-0.5 truncate text-xs font-apple-black text-[#101719]">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-[#8b9ca0]">
|
||||
<Layers className="h-5 w-5 text-[#536267]" />
|
||||
<p className="text-sm font-apple-bold">暂无基材数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[9px] font-apple-black text-[#66777c]">基材列表</p>
|
||||
<h3 className="mt-1 text-sm font-apple-black text-[#101719]">共 {filteredSubstrates.length}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3 rounded-md bg-white py-8 text-[#8b9ca0] ring-1 ring-black/[0.06]">
|
||||
<Layers className="h-5 w-5 text-[#536267]" />
|
||||
<p className="text-sm font-apple-bold">暂无基材数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 overflow-hidden rounded-md bg-white ring-1 ring-black/[0.06]">
|
||||
<div className="grid grid-cols-[minmax(240px,1.7fr)_120px_112px_112px_132px] items-center border-b border-black/[0.06] px-4 py-3 text-[9px] font-apple-black text-[#66777c]">
|
||||
<span>基材</span>
|
||||
<span>类别</span>
|
||||
<span>厚度</span>
|
||||
<span>成本</span>
|
||||
<span className="text-right">状态</span>
|
||||
</div>
|
||||
<div className="max-h-full min-h-0 overflow-y-auto divide-y divide-black/[0.055]">
|
||||
{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 (
|
||||
<button
|
||||
@ -1903,39 +1967,43 @@ export default function MaterialLibraryClient({
|
||||
type="button"
|
||||
onClick={() => setPanel({ mode: 'view-substrate', id: substrate.id })}
|
||||
className={cn(
|
||||
'ml-substrate-register-row flex w-full items-center justify-between rounded-lg border px-4 py-3 text-left transition',
|
||||
missingRequiredData ? 'ml-substrate-register-row-missing' : null,
|
||||
selected ? 'ml-substrate-register-row-selected' : null,
|
||||
'grid w-full grid-cols-[minmax(240px,1.7fr)_120px_112px_112px_132px] items-center gap-0 px-4 py-3 text-left transition hover:bg-black/[0.025]',
|
||||
selected ? 'bg-black/[0.035] shadow-[inset_2px_0_0_#101719]' : null,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-apple-black text-[#101719]">{substrate.name}</p>
|
||||
<p className="mt-1 text-[10px] font-apple-black text-[#66777c]">{substrate.code}</p>
|
||||
<p className="mt-1.5 truncate text-[11px] font-apple-bold text-[#8b9ca0]">
|
||||
{[substrate.categoryName, substrate.environmentGradeName, substrate.thicknessSpecName, substrate.surfaceOptionName]
|
||||
.filter(Boolean)
|
||||
.join(' / ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex shrink-0 items-end gap-2 text-xs font-apple-bold">
|
||||
<span className={cn(
|
||||
'ml-substrate-register-badge rounded-md px-2 py-0.5',
|
||||
missingPrice ? 'ml-substrate-register-badge-warning' : 'ml-substrate-register-badge-ok',
|
||||
)}>
|
||||
{missingPrice ? '缺价格' : substrate.suggestedPrice}
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
className="h-8 w-8 shrink-0 rounded bg-cover bg-center ring-1 ring-black/[0.06]"
|
||||
style={{
|
||||
backgroundColor: finish?.color ?? '#d8ddde',
|
||||
backgroundImage: finish?.textureUrl ? `url(${finish.textureUrl})` : undefined,
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-apple-black text-[#101719]">{substrate.name}</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] font-apple-black text-[#66777c]">{substrate.code}</span>
|
||||
</span>
|
||||
<span className={cn(
|
||||
'ml-substrate-register-badge rounded-md px-2 py-0.5 text-[10px]',
|
||||
missingWeight ? 'ml-substrate-register-badge-warning' : 'ml-substrate-register-badge-muted',
|
||||
)}>
|
||||
{missingWeight ? '缺重量' : `${substrate.weightKg}kg`}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span className="truncate text-xs font-apple-bold text-[#4f5c5f]">{substrate.categoryName}</span>
|
||||
<span className="truncate text-xs font-apple-black text-[#263033]">{substrate.thicknessSpecName}</span>
|
||||
<span className="truncate text-xs font-apple-black text-[#263033]">
|
||||
{missingPrice ? '缺价格' : substrate.suggestedPrice}
|
||||
</span>
|
||||
<span className="flex justify-end gap-1.5">
|
||||
{missingPrice ? (
|
||||
<span className="rounded-md bg-[#a44640]/10 px-2 py-0.5 text-[10px] font-apple-black text-[#a44640]">缺价</span>
|
||||
) : null}
|
||||
{missingWeight ? (
|
||||
<span className="rounded-md bg-[#a44640]/10 px-2 py-0.5 text-[10px] font-apple-black text-[#a44640]">缺重</span>
|
||||
) : (
|
||||
<span className="rounded-md bg-black/[0.04] px-2 py-0.5 text-[10px] font-apple-black text-[#66777c]">{substrate.weightKg}kg</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredSubstrates.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-black/10 p-16 text-center text-sm font-apple-bold text-[#7f9297]">当前没有匹配的基材。</p>
|
||||
<p className="p-16 text-center text-sm font-apple-bold text-[#7f9297]">当前没有匹配的基材。</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -97,7 +97,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
description: null,
|
||||
productCategoryId: null,
|
||||
productCategoryName: null,
|
||||
applicableSeriesCodes: [],
|
||||
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
|
||||
connectionRoles: [],
|
||||
metadata: {
|
||||
categoryLevel1: 'outer',
|
||||
|
||||
@ -69,7 +69,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
description: null,
|
||||
productCategoryId: null,
|
||||
productCategoryName: null,
|
||||
applicableSeriesCodes: [],
|
||||
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
|
||||
connectionRoles: [],
|
||||
metadata: {
|
||||
categoryLevel1: 'outer',
|
||||
|
||||
@ -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 (
|
||||
<AssetCenterClient
|
||||
@ -27,7 +27,7 @@ export default async function AssetCenterPage() {
|
||||
initialVariants={variants}
|
||||
initialSubstrates={substrates}
|
||||
initialSubstrateDictionaries={substrateDictionaries}
|
||||
initialSeriesTemplates={seriesTemplates}
|
||||
initialAssetSeries={assetSeries}
|
||||
finishes={finishes}
|
||||
materialCategories={materialCategories}
|
||||
metrics={[
|
||||
|
||||
@ -214,7 +214,7 @@ describe('AssistedAssemblyClient', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).not.toHaveProperty('seriesTemplateId')
|
||||
expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).toHaveProperty('assetSeriesId', null)
|
||||
})
|
||||
|
||||
it('资产中心带料进入时,会优先保留来源资产族的草稿规格款作为默认选择', async () => {
|
||||
|
||||
@ -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(',')}`,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -95,7 +95,7 @@ export default function HomeDraftsList({
|
||||
{getDraftStatusLabel(draftGroup.status)}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-[#5a6062]/58">
|
||||
{draftGroup.categoryName || draftGroup.seriesTemplateName}
|
||||
{draftGroup.categoryName || draftGroup.assetSeriesName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-[15px] font-black leading-5 tracking-tight text-[#2d3335]">
|
||||
|
||||
@ -44,7 +44,7 @@ export interface DraftGroupSummary {
|
||||
name: string
|
||||
description?: string | null
|
||||
categoryName?: string | null
|
||||
seriesTemplateName: string
|
||||
assetSeriesName: string
|
||||
status: string
|
||||
summary?: Record<string, unknown> | null
|
||||
creatorName: string
|
||||
|
||||
@ -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')}`,
|
||||
}))
|
||||
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Asse
|
||||
if (input.familyCode) formData.append('familyCode', input.familyCode)
|
||||
if (input.familyName) formData.append('familyName', input.familyName)
|
||||
if (input.familyDescription) formData.append('familyDescription', input.familyDescription)
|
||||
if (input.assetSeriesId) formData.append('assetSeriesId', input.assetSeriesId)
|
||||
if (input.connectionRoles && input.connectionRoles.length > 0) {
|
||||
formData.append('connectionRoles', JSON.stringify(input.connectionRoles))
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ export interface OpenProductAssetFamily {
|
||||
assetType: OpenProductAssetType
|
||||
connectionRoles: string[]
|
||||
productCategoryId?: string | null
|
||||
applicableSeriesCodes?: string[]
|
||||
}
|
||||
|
||||
export interface OpenProductAssetVariant {
|
||||
|
||||
@ -19,7 +19,7 @@ function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
description: null,
|
||||
productCategoryId: null,
|
||||
productCategoryName: null,
|
||||
applicableSeriesCodes: [],
|
||||
compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }],
|
||||
connectionRoles: [],
|
||||
metadata: {
|
||||
categoryLevel1: 'outer',
|
||||
|
||||
@ -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<AssetSeries[]> {
|
||||
return this.prisma.assetSeries.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<AssetSeries[]> {
|
||||
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<AssetSeries> {
|
||||
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,
|
||||
}
|
||||
|
||||
@ -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<DraftGroupWithRelations | null>
|
||||
@ -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<DraftGroupWithRelations[]>
|
||||
@ -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<DraftGroupWithRelations>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<SeriesTemplate | null> {
|
||||
return this.prisma.seriesTemplate.findUnique({ where: { code } })
|
||||
}
|
||||
|
||||
async findByIdWithRelations(id: string): Promise<SeriesTemplateWithRelations | null> {
|
||||
return this.prisma.seriesTemplate.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
category: { select: { id: true, name: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
},
|
||||
}) as Promise<SeriesTemplateWithRelations | null>
|
||||
}
|
||||
|
||||
async findAllActiveWithRelations(): Promise<SeriesTemplateWithRelations[]> {
|
||||
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<SeriesTemplateWithRelations[]>
|
||||
}
|
||||
|
||||
async createWithRelations(data: Prisma.SeriesTemplateCreateInput): Promise<SeriesTemplateWithRelations> {
|
||||
return this.prisma.seriesTemplate.create({
|
||||
data,
|
||||
include: {
|
||||
category: { select: { id: true, name: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
},
|
||||
}) as Promise<SeriesTemplateWithRelations>
|
||||
}
|
||||
}
|
||||
|
||||
export const seriesTemplateRepository = new SeriesTemplateRepositoryClass()
|
||||
export { SeriesTemplateRepositoryClass }
|
||||
@ -54,7 +54,6 @@ function createFamilyDTO(overrides: Record<string, unknown> = {}) {
|
||||
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',
|
||||
})
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
])
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 缺少可用规格款')
|
||||
})
|
||||
})
|
||||
@ -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'),
|
||||
|
||||
@ -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: '单板桌',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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<string[]> {
|
||||
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<void> {
|
||||
const existing = await assetVariantRepository.findByCode(code)
|
||||
if (existing && existing.id !== currentId) {
|
||||
@ -880,7 +934,24 @@ class AssetCenterService {
|
||||
}
|
||||
}
|
||||
|
||||
async listFamilies(filter?: Pick<AssetFamilyFilter, 'type' | 'status'>) {
|
||||
async listSeries(): Promise<AssetSeriesDTO[]> {
|
||||
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<AssetFamilyFilter, 'type' | 'status' | 'assetSeriesId'>) {
|
||||
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,
|
||||
|
||||
@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null
|
||||
status?: AssetStatus
|
||||
|
||||
@ -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<typeof generateSingleBoardDeskCandidates>['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<typeof generateSingleBoardDeskCandidates>['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<CombinationGenerationResult> {
|
||||
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<typeof item> => 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<typeof item> => 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<typeof item> => 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'
|
||||
@ -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[]
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 } },
|
||||
})
|
||||
|
||||
|
||||
@ -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<string, unknown> | null
|
||||
notes: string | null
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<T extends { key: string }>(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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFamilyDefinition> {
|
||||
function buildDemoFamilyDefinitions(
|
||||
categoryId: string,
|
||||
assetSeriesId: string,
|
||||
): Record<string, DemoFamilyDefinition> {
|
||||
return {
|
||||
desktopA: {
|
||||
type: 'BOARD',
|
||||
@ -542,7 +539,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示桌面板 A',
|
||||
description: '开放平台演示桌面板资产族 A',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: ['desktop_surface'],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -556,7 +553,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示桌面板 B',
|
||||
description: '开放平台演示桌面板资产族 B',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: ['desktop_surface'],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -570,7 +567,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示细腿组',
|
||||
description: '开放平台演示细腿资产族',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: ['desk_leg'],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -583,7 +580,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示圆腿组',
|
||||
description: '开放平台演示圆腿资产族',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: ['desk_leg'],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -596,7 +593,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示桌腿连接件',
|
||||
description: '开放平台演示连接件资产族',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: ['desk_leg_connector'],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -609,7 +606,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示纸箱',
|
||||
description: '开放平台演示纸箱资产族',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: [],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -622,7 +619,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record<string, DemoFami
|
||||
name: '演示泡沫护角',
|
||||
description: '开放平台演示泡沫护角资产族',
|
||||
productCategoryId: categoryId,
|
||||
applicableSeriesCodes: ['single-board-desk'],
|
||||
compatibleSeriesIds: [assetSeriesId],
|
||||
connectionRoles: [],
|
||||
metadata: {
|
||||
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,
|
||||
@ -887,6 +884,7 @@ function buildDemoVariantDefinitions(input: {
|
||||
|
||||
async function ensureDemoAssets(input: {
|
||||
categoryId: string
|
||||
assetSeriesId: string
|
||||
substrateId: string
|
||||
finish: {
|
||||
id: string
|
||||
@ -895,7 +893,7 @@ async function ensureDemoAssets(input: {
|
||||
creator: AuthUser
|
||||
reviewer: AuthUser
|
||||
}): Promise<DemoAssetBundle> {
|
||||
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<string, string | null>([
|
||||
[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,
|
||||
|
||||
@ -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<string, unknown>
|
||||
namingRule: Record<string, unknown> | null
|
||||
recommendationRule: Record<string, unknown> | null
|
||||
previewRule: Record<string, unknown> | null
|
||||
intakeRule: Record<string, unknown> | null
|
||||
isPreset: boolean
|
||||
isActive: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function toDTO(
|
||||
record: NonNullable<Awaited<ReturnType<typeof seriesTemplateRepository.findByIdWithRelations>>>,
|
||||
): 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<void> {
|
||||
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<SeriesTemplateDTO[]> {
|
||||
await this.ensurePresetSingleBoardDeskTemplate()
|
||||
const records = await seriesTemplateRepository.findAllActiveWithRelations()
|
||||
return records.map((record) => toDTO(record))
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<SeriesTemplateDTO> {
|
||||
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 }
|
||||
@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function buildSingleBoardDeskTemplate(
|
||||
record: NonNullable<Awaited<ReturnType<typeof seriesTemplateRepository.findByIdWithRelations>>>,
|
||||
): 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<ReturnType<typeof import('../repositories').assetVariantRepository.findAvailableByFamilyIds>>,
|
||||
): OpenProductAssetVariant[] {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user