feat: restore modular sku group draft workflow
This commit is contained in:
parent
d650e2f562
commit
467e4182f2
@ -0,0 +1,90 @@
|
||||
-- Add a goods-code grouping layer between AssetFamily and AssetVariant.
|
||||
-- Existing active variants are backfilled by goods code so multiple models can share one goods-code group.
|
||||
|
||||
CREATE TABLE "AssetSkuGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"assetFamilyId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AssetSkuGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "AssetVariant"
|
||||
ADD COLUMN "skuGroupId" TEXT;
|
||||
|
||||
WITH sku_group_sources AS (
|
||||
SELECT
|
||||
"assetFamilyId",
|
||||
"code",
|
||||
(ARRAY_AGG(COALESCE(NULLIF(TRIM("name"), ''), "code") ORDER BY "createdAt" ASC, "id" ASC))[1] AS "name",
|
||||
MIN("createdAt") AS "createdAt",
|
||||
MAX("updatedAt") AS "updatedAt",
|
||||
JSONB_AGG("id" ORDER BY "createdAt" ASC, "id" ASC) AS "sourceAssetVariantIds"
|
||||
FROM "AssetVariant"
|
||||
WHERE "deletedAt" IS NULL
|
||||
GROUP BY "assetFamilyId", "code"
|
||||
),
|
||||
ranked_sku_groups AS (
|
||||
SELECT
|
||||
"assetFamilyId",
|
||||
"code",
|
||||
"name",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"sourceAssetVariantIds",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "assetFamilyId"
|
||||
ORDER BY "createdAt" ASC, "code" ASC
|
||||
) AS row_no
|
||||
FROM sku_group_sources
|
||||
)
|
||||
INSERT INTO "AssetSkuGroup" (
|
||||
"id",
|
||||
"assetFamilyId",
|
||||
"code",
|
||||
"name",
|
||||
"description",
|
||||
"sortOrder",
|
||||
"metadata",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
CONCAT('asg_', MD5("assetFamilyId" || ':' || "code")),
|
||||
"assetFamilyId",
|
||||
"code",
|
||||
"name",
|
||||
NULL,
|
||||
row_no,
|
||||
jsonb_build_object('source', 'variant-backfill', 'sourceAssetVariantIds', "sourceAssetVariantIds"),
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
FROM ranked_sku_groups;
|
||||
|
||||
UPDATE "AssetVariant" AS variant
|
||||
SET "skuGroupId" = CONCAT('asg_', MD5(variant."assetFamilyId" || ':' || variant."code"))
|
||||
WHERE variant."skuGroupId" IS NULL
|
||||
AND variant."deletedAt" IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "AssetSkuGroup_assetFamilyId_code_key"
|
||||
ON "AssetSkuGroup"("assetFamilyId", "code");
|
||||
CREATE INDEX "AssetSkuGroup_assetFamilyId_idx" ON "AssetSkuGroup"("assetFamilyId");
|
||||
CREATE INDEX "AssetSkuGroup_code_idx" ON "AssetSkuGroup"("code");
|
||||
CREATE INDEX "AssetSkuGroup_sortOrder_idx" ON "AssetSkuGroup"("sortOrder");
|
||||
CREATE INDEX "AssetVariant_skuGroupId_idx" ON "AssetVariant"("skuGroupId");
|
||||
|
||||
ALTER TABLE "AssetSkuGroup"
|
||||
ADD CONSTRAINT "AssetSkuGroup_assetFamilyId_fkey"
|
||||
FOREIGN KEY ("assetFamilyId") REFERENCES "AssetFamily"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "AssetVariant"
|
||||
ADD CONSTRAINT "AssetVariant_skuGroupId_fkey"
|
||||
FOREIGN KEY ("skuGroupId") REFERENCES "AssetSkuGroup"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -929,6 +929,7 @@ model AssetFamily {
|
||||
deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull)
|
||||
|
||||
variants AssetVariant[]
|
||||
skuGroups AssetSkuGroup[]
|
||||
compatibleSeries AssetFamilySeriesCompatibility[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@ -947,6 +948,26 @@ model AssetFamily {
|
||||
@@index([deletedById])
|
||||
}
|
||||
|
||||
model AssetSkuGroup {
|
||||
id String @id @default(cuid())
|
||||
assetFamilyId String
|
||||
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
|
||||
code String
|
||||
name String
|
||||
description String? @db.Text
|
||||
sortOrder Int @default(0)
|
||||
metadata Json?
|
||||
variants AssetVariant[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([assetFamilyId, code])
|
||||
@@index([assetFamilyId])
|
||||
@@index([code])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
|
||||
model AssetSeries {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
@ -994,6 +1015,8 @@ model AssetVariant {
|
||||
id String @id @default(cuid())
|
||||
assetFamilyId String
|
||||
assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade)
|
||||
skuGroupId String?
|
||||
skuGroup AssetSkuGroup? @relation(fields: [skuGroupId], references: [id], onDelete: SetNull)
|
||||
code String @unique
|
||||
name String
|
||||
identityKey String
|
||||
@ -1051,6 +1074,7 @@ model AssetVariant {
|
||||
|
||||
@@unique([assetFamilyId, identityKey])
|
||||
@@index([assetFamilyId])
|
||||
@@index([skuGroupId])
|
||||
@@index([status])
|
||||
@@index([barcode])
|
||||
@@index([substrateId])
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { assetSkuGroupUpdateSchema } from '../../../platform/schemas'
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
const rawBody = await request.json().catch(() => null)
|
||||
const parsed = assetSkuGroupUpdateSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`货号组更新参数不正确: ${parsed.error.message}`,
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const skuGroup = await assetCenterService.updateSkuGroup(id, {
|
||||
...parsed.data,
|
||||
updatedBy: ctx.user.id,
|
||||
})
|
||||
return jsonSuccess({ skuGroup }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true, requireDesignWorkspaceWrite: true },
|
||||
)
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
try {
|
||||
await assetCenterService.deleteSkuGroup(id, ctx.user.id)
|
||||
return jsonSuccess({ success: true }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true, requireDesignWorkspaceWrite: true },
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { assetCenterService } from '@/server/services'
|
||||
import { assetSkuGroupCreateSchema } from '../../platform/schemas'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const familyIds = searchParams.getAll('familyId')
|
||||
|
||||
try {
|
||||
const skuGroups = await assetCenterService.listSkuGroupsByFamilyIds(familyIds)
|
||||
return jsonSuccess({ skuGroups }, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const rawBody = await request.json().catch(() => null)
|
||||
const parsed = assetSkuGroupCreateSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`货号组参数不正确: ${parsed.error.message}`,
|
||||
400,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const skuGroup = await assetCenterService.createSkuGroup({
|
||||
...parsed.data,
|
||||
createdBy: ctx.user.id,
|
||||
})
|
||||
return jsonSuccess({ skuGroup }, 201, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true, requireDesignWorkspaceWrite: true },
|
||||
)
|
||||
}
|
||||
@ -91,8 +91,26 @@ export const assetSeriesUpdateSchema = z.object({
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const assetSkuGroupCreateSchema = z.object({
|
||||
assetFamilyId: z.string().trim().min(1),
|
||||
code: z.string().trim().min(1),
|
||||
name: z.string().trim().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
|
||||
export const assetSkuGroupUpdateSchema = z.object({
|
||||
code: z.string().trim().min(1).optional(),
|
||||
name: z.string().trim().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
|
||||
export const assetVariantCreateSchema = z.object({
|
||||
assetFamilyId: z.string().trim().min(1),
|
||||
skuGroupId: z.string().trim().min(1).optional().nullable(),
|
||||
code: z.string().trim().min(1).optional(),
|
||||
name: z.string().trim().min(1),
|
||||
status: z.enum(['DRAFT']).optional(),
|
||||
@ -127,6 +145,7 @@ export const packagingAssetVariantCreateSchema = assetVariantCreateSchema.extend
|
||||
|
||||
export const assetVariantUpdateSchema = z.object({
|
||||
assetFamilyId: z.string().trim().min(1).optional(),
|
||||
skuGroupId: z.string().trim().min(1).optional().nullable(),
|
||||
code: z.string().trim().min(1).optional(),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
status: assetStatusSchema.optional(),
|
||||
|
||||
@ -19,10 +19,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 验证文件类型
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
if (!UPLOAD.ALLOWED_MODEL_TYPES.includes(ext as '.gltf' | '.glb' | '.dwg')) {
|
||||
if (!UPLOAD.ALLOWED_MODEL_TYPES.includes(ext as '.gltf' | '.glb' | '.obj' | '.dwg')) {
|
||||
return jsonError(
|
||||
ErrorCode.FILE_TYPE_NOT_ALLOWED,
|
||||
`不支持的文件格式 "${ext}",仅支持 .gltf 和 .glb`,
|
||||
`不支持的文件格式 "${ext}",仅支持 .gltf、.glb 和 .obj`,
|
||||
400,
|
||||
requestId,
|
||||
)
|
||||
|
||||
@ -239,6 +239,21 @@ const mockedCreatePackagingAssetVariant =
|
||||
typeof createPackagingAssetVariant
|
||||
>
|
||||
|
||||
function getVariantTreeRowByCode(code: string): HTMLElement {
|
||||
const row = screen
|
||||
.getAllByText(code)
|
||||
.map((element) => element.closest('[role="button"]'))
|
||||
.find((element): element is HTMLElement =>
|
||||
element instanceof HTMLElement && element.className.includes('ml-5'),
|
||||
)
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Cannot find variant tree row for ${code}`)
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
function createFamily(overrides?: Partial<AssetFamilyDTO>): AssetFamilyDTO {
|
||||
return {
|
||||
id: 'family-legacy-packaging',
|
||||
@ -646,28 +661,15 @@ describe('AssetCenterClient', () => {
|
||||
await actor.click(screen.getByRole('button', { name: /新增包材货号/ }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '新增包材货号' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('DBZ-SPJ01')).toBeInTheDocument()
|
||||
await actor.type(screen.getByLabelText('供应商名称'), '兰考华兰家具')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('N1280')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('DBZ-SPJ01N1280V01')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('SKU CONTENT')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('ModelUploader')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('模型与装配参数')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('3D 模型')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('板材类型')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('包材资料')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('颜色')).not.toHaveAttribute('readonly')
|
||||
expect(screen.getByLabelText('材质')).not.toHaveAttribute('readonly')
|
||||
|
||||
await actor.type(screen.getByLabelText('长度'), '1200')
|
||||
await actor.type(screen.getByLabelText('宽度'), '1200')
|
||||
await actor.type(screen.getByLabelText('厚度'), '60')
|
||||
await actor.type(screen.getByLabelText('颜色'), '纸箱')
|
||||
await actor.type(screen.getByLabelText('材质'), '瓦楞纸')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('规格名称')).toHaveValue('款式四 120/120/6 纸箱')
|
||||
})
|
||||
await actor.click(screen.getByRole('button', { name: '创建包材货号' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -675,16 +677,9 @@ describe('AssetCenterClient', () => {
|
||||
})
|
||||
|
||||
const payload = mockedCreatePackagingAssetVariant.mock.calls[0]?.[0]
|
||||
expect(payload).toEqual(expect.objectContaining({
|
||||
code: 'DBZ-SPJ01',
|
||||
supplierName: '兰考华兰家具',
|
||||
supplierCode: 'N1280',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
}))
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
assetFamilyId: 'family-packaging',
|
||||
name: '款式四 120/120/6 纸箱',
|
||||
status: 'DRAFT',
|
||||
dimensions: {
|
||||
length: 1200,
|
||||
@ -699,14 +694,6 @@ describe('AssetCenterClient', () => {
|
||||
expect(payload?.businessData).toEqual(
|
||||
expect.objectContaining({
|
||||
materialMode: 'PACKAGING',
|
||||
skuContentFields: expect.objectContaining({
|
||||
model: 'DBZ-SPJK04',
|
||||
skuCode: 'DBZ-SPJ01',
|
||||
barcode: 'DBZ-SPJ01N1280V01',
|
||||
specName: '款式四 120/120/6 纸箱',
|
||||
color: '纸箱',
|
||||
material: '瓦楞纸',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@ -870,7 +857,7 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('缩略图桌面板'))
|
||||
await actor.click(screen.getByText(/缩略图桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-020-A'))
|
||||
await actor.click(screen.getByRole('button', { name: '重生成缩略图' }))
|
||||
|
||||
expect(mockedRegenerateAssetVariantThumbnail).toHaveBeenCalledWith(
|
||||
@ -1069,7 +1056,7 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('UV 桌面板'))
|
||||
await actor.click(screen.getByText(/UV 桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-030-A'))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
@ -1117,9 +1104,9 @@ describe('AssetCenterClient', () => {
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '保存' }))
|
||||
const duplicateDialog = within(screen.getByRole('dialog', { name: '另存为新规格款' }))
|
||||
expect(duplicateDialog.getByLabelText('货号')).toHaveValue('')
|
||||
expect(duplicateDialog.getByLabelText('规格名称')).toHaveValue('UV 桌面板 A 120/60/1.8 胡桃木纹')
|
||||
expect(duplicateDialog.getByLabelText('尺寸')).toHaveValue('120/60/1.8')
|
||||
expect(duplicateDialog.queryByText('SKU CONTENT')).not.toBeInTheDocument()
|
||||
expect(duplicateDialog.queryByLabelText('货号')).not.toBeInTheDocument()
|
||||
expect(duplicateDialog.queryByLabelText('规格名称')).not.toBeInTheDocument()
|
||||
expect(duplicateDialog.getByLabelText('长度')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('宽度')).toBeDisabled()
|
||||
expect(duplicateDialog.getByLabelText('厚度')).toBeDisabled()
|
||||
@ -1158,14 +1145,14 @@ describe('AssetCenterClient', () => {
|
||||
expect(mockedCreateAssetVariantWithSimilarity.mock.calls[0]?.[0]).not.toHaveProperty('code')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/UV 桌面板 B/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('BOARD-030-B').length).toBeGreaterThan(0)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(mockedRegenerateAssetVariantThumbnail).toHaveBeenCalledWith(
|
||||
'variant-board-uv-copy',
|
||||
)
|
||||
})
|
||||
await actor.click(screen.getByText(/UV 桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-030-A'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-material-id')).toHaveTextContent('finish-1')
|
||||
expect(screen.getByTestId('preview-material-repeat')).toHaveTextContent('1')
|
||||
@ -1293,7 +1280,7 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('UV 后调桌面板'))
|
||||
await actor.click(screen.getByText(/UV 后调桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-031-A'))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
@ -1412,7 +1399,7 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('UV 草稿桌面板'))
|
||||
await actor.click(screen.getByText(/UV 草稿桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-DRAFT-UV-A'))
|
||||
await actor.click(screen.getByRole('button', { name: '同步预览节点' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /渲染参数/ }))
|
||||
@ -1519,7 +1506,7 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('右键桌面板'))
|
||||
await actor.click(screen.getByText(/右键桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-040-A'))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
expect(screen.getByText('渲染参数')).toBeInTheDocument()
|
||||
expect(screen.queryByText('提交审核')).not.toBeInTheDocument()
|
||||
@ -1575,7 +1562,7 @@ describe('AssetCenterClient', () => {
|
||||
)
|
||||
|
||||
await actor.click(screen.getByText('基材搜索桌面板'))
|
||||
await actor.click(screen.getByText(/基材搜索桌面板 A/))
|
||||
await actor.click(getVariantTreeRowByCode('BOARD-SUBSTRATE-A'))
|
||||
await actor.click(screen.getByRole('button', { name: '模拟预览右键' }))
|
||||
await actor.click(screen.getByRole('button', { name: /快捷更改基材/ }))
|
||||
await actor.type(screen.getByLabelText('搜索基材'), '胡桃')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -81,8 +81,6 @@ interface SeriesForm {
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
componentSpu: string
|
||||
finishedProductSpu: string
|
||||
sortOrder: string
|
||||
isActive: boolean
|
||||
}
|
||||
@ -204,8 +202,6 @@ function emptySeriesForm(): SeriesForm {
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
componentSpu: '',
|
||||
finishedProductSpu: '',
|
||||
sortOrder: '0',
|
||||
isActive: true,
|
||||
}
|
||||
@ -216,8 +212,6 @@ function seriesToForm(series: AssetSeriesDTO): SeriesForm {
|
||||
name: series.name,
|
||||
code: series.code,
|
||||
description: series.description ?? '',
|
||||
componentSpu: series.componentSpu ?? '',
|
||||
finishedProductSpu: series.finishedProductSpu ?? '',
|
||||
sortOrder: String(series.sortOrder ?? 0),
|
||||
isActive: series.isActive,
|
||||
}
|
||||
@ -1178,8 +1172,6 @@ export default function MaterialLibraryClient({
|
||||
name: seriesForm.name.trim(),
|
||||
code: seriesForm.code.trim() || null,
|
||||
description: seriesForm.description.trim() || null,
|
||||
componentSpu: seriesForm.componentSpu.trim().toUpperCase() || null,
|
||||
finishedProductSpu: seriesForm.finishedProductSpu.trim().toUpperCase() || null,
|
||||
sortOrder: toOptionalNumber(seriesForm.sortOrder) ?? 0,
|
||||
isActive: seriesForm.isActive,
|
||||
}
|
||||
@ -1444,28 +1436,6 @@ export default function MaterialLibraryClient({
|
||||
placeholder="留空后端自动生成首拼"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-1.5" htmlFor="asset-series-component-spu">
|
||||
<span className={mlFieldLabelClassName}>部件SPU</span>
|
||||
<input
|
||||
id="asset-series-component-spu"
|
||||
value={seriesForm.componentSpu}
|
||||
onChange={(e) => setSeriesForm((current) => ({ ...current, componentSpu: e.target.value.toUpperCase() }))}
|
||||
className={mlInputClassName}
|
||||
placeholder="例如 DNZ0384"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5" htmlFor="asset-series-finished-product-spu">
|
||||
<span className={mlFieldLabelClassName}>成品SPU</span>
|
||||
<input
|
||||
id="asset-series-finished-product-spu"
|
||||
value={seriesForm.finishedProductSpu}
|
||||
onChange={(e) => setSeriesForm((current) => ({ ...current, finishedProductSpu: e.target.value.toUpperCase() }))}
|
||||
className={mlInputClassName}
|
||||
placeholder="例如 DNZ0385"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1.5" htmlFor="asset-series-description">
|
||||
<span className={mlFieldLabelClassName}>说明</span>
|
||||
<textarea
|
||||
@ -1509,8 +1479,6 @@ export default function MaterialLibraryClient({
|
||||
{ label: '显示名', value: activeSeries.displayName },
|
||||
{ label: '名称', value: activeSeries.name },
|
||||
{ label: '编码', value: activeSeries.code },
|
||||
{ label: '部件SPU', value: activeSeries.componentSpu || '默认' },
|
||||
{ label: '成品SPU', value: activeSeries.finishedProductSpu || '默认' },
|
||||
{ label: '排序', value: activeSeries.sortOrder },
|
||||
{ label: '状态', value: activeSeries.isActive ? '启用' : '停用' },
|
||||
].map(({ label, value }) => (
|
||||
|
||||
@ -58,6 +58,9 @@ export default async function AssetCenterPage() {
|
||||
const variants = await assetCenterService.listVariantsByFamilyIds(
|
||||
families.map((family) => family.id),
|
||||
)
|
||||
const skuGroups = await assetCenterService.listSkuGroupsByFamilyIds(
|
||||
families.map((family) => family.id),
|
||||
)
|
||||
const [substrates, substrateDictionaries] = await Promise.all([
|
||||
assetCenterService.listSubstrates(),
|
||||
assetCenterService.getSubstrateDictionaryBundle(),
|
||||
@ -82,6 +85,7 @@ export default async function AssetCenterPage() {
|
||||
return (
|
||||
<AssetCenterClient
|
||||
initialFamilies={families}
|
||||
initialSkuGroups={skuGroups}
|
||||
initialVariants={variants}
|
||||
initialSubstrates={substrates}
|
||||
initialSubstrateDictionaries={substrateDictionaries}
|
||||
|
||||
@ -45,6 +45,10 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({
|
||||
<div>辅助拼装工作台</div>
|
||||
<div>库存:{value.inventory.length}</div>
|
||||
<div>节点:{value.nodes.length}</div>
|
||||
<div data-testid="workspace-node-count">{value.nodes.length}</div>
|
||||
<div data-testid="workspace-node-variant-ids">
|
||||
{value.nodes.map((node) => node.variantId).join(',')}
|
||||
</div>
|
||||
<div>当前系列:{selectedAssetSeriesId ?? 'none'}</div>
|
||||
<div>可用规格:{availableVariants?.length ?? 0}</div>
|
||||
<div>首个规格款:{value.inventory[0]?.selectedVariantId ?? 'none'}</div>
|
||||
@ -483,4 +487,170 @@ describe('AssistedAssemblyClient', () => {
|
||||
expect(screen.getByText('首个规格款:variant-board-source')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('从资产中心按货号进入时,会把该货号下所有模型明细带入工作台', async () => {
|
||||
mockSearchParams = new URLSearchParams(
|
||||
'entryMode=asset-center&source=asset-center&legFamilyIds=family-leg&skuGroupCodes=DNZ038401',
|
||||
)
|
||||
|
||||
render(
|
||||
<AssistedAssemblyClient
|
||||
user={designUser}
|
||||
families={[
|
||||
{
|
||||
id: 'family-leg',
|
||||
type: 'COMPONENT',
|
||||
code: 'LEG-A',
|
||||
name: '口桌腿',
|
||||
status: 'AVAILABLE',
|
||||
productCategoryId: 'category-desk',
|
||||
productCategoryName: '书桌',
|
||||
} as never,
|
||||
]}
|
||||
variants={[
|
||||
{
|
||||
id: 'variant-leg-black',
|
||||
assetFamilyId: 'family-leg',
|
||||
skuGroupId: 'sku-group-leg-old-1',
|
||||
skuGroupCode: 'DNZ038401',
|
||||
assetType: 'COMPONENT',
|
||||
code: 'DBZ-SPJ07',
|
||||
name: '黑色金属',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg-black.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
{
|
||||
id: 'variant-leg-white',
|
||||
assetFamilyId: 'family-leg',
|
||||
skuGroupId: 'sku-group-leg-old-2',
|
||||
skuGroupCode: 'DNZ038401',
|
||||
assetType: 'COMPONENT',
|
||||
code: 'DBZ-SPJ06',
|
||||
name: '白色金属',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg-white.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
{
|
||||
id: 'variant-leg-other-group',
|
||||
assetFamilyId: 'family-leg',
|
||||
skuGroupId: 'sku-group-leg-2',
|
||||
skuGroupCode: 'DNZ038402',
|
||||
assetType: 'COMPONENT',
|
||||
code: 'DNZ038409',
|
||||
name: '其它货号',
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg-other.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
]}
|
||||
metrics={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workspace-node-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('workspace-node-variant-ids')).toHaveTextContent(
|
||||
'variant-leg-black,variant-leg-white',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('右侧装配节点按条码分组展示,并可展开模型明细', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockSearchParams = new URLSearchParams(
|
||||
'entryMode=asset-center&source=asset-center&legFamilyIds=family-leg&skuGroupCodes=DNZ038401',
|
||||
)
|
||||
|
||||
render(
|
||||
<AssistedAssemblyClient
|
||||
user={designUser}
|
||||
families={[
|
||||
{
|
||||
id: 'family-leg',
|
||||
type: 'COMPONENT',
|
||||
code: 'LEG-A',
|
||||
name: '口桌腿',
|
||||
status: 'AVAILABLE',
|
||||
productCategoryId: 'category-desk',
|
||||
productCategoryName: '书桌',
|
||||
} as never,
|
||||
]}
|
||||
variants={[
|
||||
{
|
||||
id: 'variant-leg-black',
|
||||
assetFamilyId: 'family-leg',
|
||||
skuGroupId: 'sku-group-leg-old-1',
|
||||
skuGroupCode: 'DNZ038401',
|
||||
assetType: 'COMPONENT',
|
||||
code: 'DBZ-SPJ07',
|
||||
name: '黑色金属',
|
||||
skuGroupMetadata: {
|
||||
skuContentFields: {
|
||||
correspondingBarcode: 'DNZ038401N1440V01',
|
||||
},
|
||||
},
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg-black.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
{
|
||||
id: 'variant-leg-white',
|
||||
assetFamilyId: 'family-leg',
|
||||
skuGroupId: 'sku-group-leg-old-2',
|
||||
skuGroupCode: 'DNZ038401',
|
||||
assetType: 'COMPONENT',
|
||||
code: 'DBZ-SPJ06',
|
||||
name: '白色金属',
|
||||
skuGroupMetadata: {
|
||||
skuContentFields: {
|
||||
correspondingBarcode: 'DNZ038401N1440V01',
|
||||
},
|
||||
},
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg-white.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
compatibilityRules: null,
|
||||
} as never,
|
||||
]}
|
||||
metrics={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Assembly Barcodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('DNZ038401N1440V01')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 模型')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('黑色金属')).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(
|
||||
screen.getByRole('button', { name: '展开条码DNZ038401N1440V01模型明细' }),
|
||||
)
|
||||
|
||||
expect(screen.getByText('黑色金属')).toBeInTheDocument()
|
||||
expect(screen.getByText('白色金属')).toBeInTheDocument()
|
||||
expect(screen.getByText('DBZ-SPJ07')).toBeInTheDocument()
|
||||
expect(screen.getByText('DBZ-SPJ06')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -61,6 +61,16 @@ const EMPTY_ASSET_SERIES: AssetSeriesDTO[] = []
|
||||
|
||||
type InspectorTabKey = 'assembly' | 'ai'
|
||||
|
||||
interface AssemblyNodeBarcodeGroup {
|
||||
key: string
|
||||
barcode: string | null
|
||||
label: string
|
||||
nodes: Array<{
|
||||
node: AssemblyWorkspaceSnapshot['nodes'][number]
|
||||
variant: AssistedAssemblyVariant | null
|
||||
}>
|
||||
}
|
||||
|
||||
interface AiTagOption {
|
||||
label: string
|
||||
promptKeyword: string
|
||||
@ -207,6 +217,58 @@ function toOpenProductVariant(variant: AssistedAssemblyVariant) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssemblyNodeBarcodeGroups(
|
||||
nodes: AssemblyWorkspaceSnapshot['nodes'],
|
||||
variantsById: Map<string, AssistedAssemblyVariant>,
|
||||
) {
|
||||
const groups = new Map<string, AssemblyNodeBarcodeGroup>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const variant = variantsById.get(node.variantId) ?? null
|
||||
const barcode = resolveAssemblyNodeBarcode(variant)
|
||||
const key = barcode ? `barcode:${barcode}` : `missing:${node.nodeId}`
|
||||
const current = groups.get(key)
|
||||
|
||||
if (current) {
|
||||
current.nodes.push({ node, variant })
|
||||
return
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
key,
|
||||
barcode,
|
||||
label: barcode ?? '未填写条码',
|
||||
nodes: [{ node, variant }],
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(groups.values())
|
||||
}
|
||||
|
||||
function readSkuContentFieldFromMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
) {
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
|
||||
const rawFields = metadata.skuContentFields
|
||||
if (!rawFields || typeof rawFields !== 'object' || Array.isArray(rawFields)) return null
|
||||
const value = (rawFields as Record<string, unknown>)[key]
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveAssemblyNodeBarcode(variant: AssistedAssemblyVariant | null) {
|
||||
if (!variant) return null
|
||||
|
||||
return (
|
||||
readSkuContentFieldFromMetadata(variant.skuGroupMetadata, 'correspondingBarcode')?.toUpperCase() ||
|
||||
readSkuContentFieldFromMetadata(variant.skuGroupMetadata, 'barcode')?.toUpperCase() ||
|
||||
variant.barcode?.trim().toUpperCase() ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function resolveInitialFamilyIds(input: {
|
||||
launchParams: ManualAssemblyLaunchParams | null
|
||||
initialWorkspace: AssemblyWorkspaceSnapshot | null
|
||||
@ -276,6 +338,16 @@ function getLaunchSelectedFamilyIds(launchParams: ManualAssemblyLaunchParams | n
|
||||
)
|
||||
}
|
||||
|
||||
function getLaunchSelectedSkuGroupIds(launchParams: ManualAssemblyLaunchParams | null) {
|
||||
return orderedUnique(launchParams?.selectedSkuGroupIds ?? [])
|
||||
}
|
||||
|
||||
function getLaunchSelectedSkuGroupCodes(launchParams: ManualAssemblyLaunchParams | null) {
|
||||
return orderedUnique(launchParams?.selectedSkuGroupCodes ?? []).map((code) =>
|
||||
code.trim().toUpperCase(),
|
||||
)
|
||||
}
|
||||
|
||||
function instantiateLaunchSelectedFamilies(input: {
|
||||
workspace: AssemblyWorkspaceSnapshot
|
||||
launchParams: ManualAssemblyLaunchParams | null
|
||||
@ -315,6 +387,72 @@ function instantiateLaunchSelectedFamilies(input: {
|
||||
)
|
||||
}
|
||||
|
||||
function instantiateLaunchSelectedSkuGroups(input: {
|
||||
workspace: AssemblyWorkspaceSnapshot
|
||||
launchParams: ManualAssemblyLaunchParams | null
|
||||
variantsByFamilyId: Map<string, AssistedAssemblyVariant[]>
|
||||
}) {
|
||||
if (input.launchParams?.entryMode !== 'asset-center') {
|
||||
return input.workspace
|
||||
}
|
||||
|
||||
const selectedSkuGroupIds = getLaunchSelectedSkuGroupIds(input.launchParams)
|
||||
const selectedSkuGroupCodes = getLaunchSelectedSkuGroupCodes(input.launchParams)
|
||||
if (selectedSkuGroupIds.length === 0 && selectedSkuGroupCodes.length === 0) {
|
||||
return input.workspace
|
||||
}
|
||||
|
||||
const skuGroupIdSet = new Set(selectedSkuGroupIds)
|
||||
const skuGroupCodeSet = new Set(selectedSkuGroupCodes)
|
||||
const variants = Array.from(input.variantsByFamilyId.values())
|
||||
.flat()
|
||||
.filter((variant) => {
|
||||
const skuGroupIdMatched = Boolean(variant.skuGroupId && skuGroupIdSet.has(variant.skuGroupId))
|
||||
const skuGroupCode = variant.skuGroupCode?.trim().toUpperCase() ?? ''
|
||||
const skuGroupCodeMatched = Boolean(skuGroupCode && skuGroupCodeSet.has(skuGroupCode))
|
||||
return skuGroupIdMatched || skuGroupCodeMatched
|
||||
})
|
||||
|
||||
return variants.reduce<AssemblyWorkspaceSnapshot>((snapshot, variant) => {
|
||||
if (snapshot.nodes.some((node) => node.variantId === variant.id)) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const inventoryItem = snapshot.inventory.find((item) => item.familyId === variant.assetFamilyId)
|
||||
if (!inventoryItem) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const withSelection = updateInventoryVariantSelection(
|
||||
snapshot,
|
||||
inventoryItem.inventoryId,
|
||||
variant.id,
|
||||
)
|
||||
|
||||
const instantiated = instantiateAssemblyNode(withSelection, {
|
||||
inventoryId: inventoryItem.inventoryId,
|
||||
variant: toOpenProductVariant(variant),
|
||||
})
|
||||
const skuGroupCode = variant.skuGroupCode?.trim().toUpperCase()
|
||||
if (!skuGroupCode) {
|
||||
return instantiated
|
||||
}
|
||||
|
||||
return {
|
||||
...instantiated,
|
||||
nodes: instantiated.nodes.map((node) =>
|
||||
node.variantId === variant.id
|
||||
? {
|
||||
...node,
|
||||
code: skuGroupCode,
|
||||
label: `${node.label} · ${skuGroupCode}`,
|
||||
}
|
||||
: node,
|
||||
),
|
||||
}
|
||||
}, input.workspace)
|
||||
}
|
||||
|
||||
function formatAiRenderTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@ -525,6 +663,9 @@ export default function AssistedAssemblyClient({
|
||||
const [aiOptimExplanation, setAiOptimExplanation] = useState('')
|
||||
const [aiOptimExplanationCollapsed, setAiOptimExplanationCollapsed] = useState(false)
|
||||
const [aiPromptCollapsed, setAiPromptCollapsed] = useState(false)
|
||||
const [expandedAssemblyBarcodeKeys, setExpandedAssemblyBarcodeKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const [aiSelectedTags, setAiSelectedTags] = useState<Record<string, AiTagOption>>({})
|
||||
const [hoveredTagGroup, setHoveredTagGroup] = useState<string | null>(null)
|
||||
const [tagPopupPos, setTagPopupPos] = useState<{ top: number; left: number } | null>(null)
|
||||
@ -568,6 +709,11 @@ export default function AssistedAssemblyClient({
|
||||
return map
|
||||
}, [materialVariants])
|
||||
|
||||
const materialVariantById = useMemo(
|
||||
() => new Map(materialVariants.map((variant) => [variant.id, variant])),
|
||||
[materialVariants],
|
||||
)
|
||||
|
||||
const initialFamilyIds = useMemo(
|
||||
() =>
|
||||
resolveInitialFamilyIds({
|
||||
@ -609,6 +755,16 @@ export default function AssistedAssemblyClient({
|
||||
families: nextFamilies,
|
||||
})
|
||||
|
||||
const selectedSkuGroupIds = getLaunchSelectedSkuGroupIds(launchParams)
|
||||
const selectedSkuGroupCodes = getLaunchSelectedSkuGroupCodes(launchParams)
|
||||
if (selectedSkuGroupIds.length > 0 || selectedSkuGroupCodes.length > 0) {
|
||||
return instantiateLaunchSelectedSkuGroups({
|
||||
workspace: synced,
|
||||
launchParams,
|
||||
variantsByFamilyId: availableVariantMap,
|
||||
})
|
||||
}
|
||||
|
||||
return instantiateLaunchSelectedFamilies({
|
||||
workspace: synced,
|
||||
launchParams,
|
||||
@ -719,6 +875,23 @@ export default function AssistedAssemblyClient({
|
||||
}
|
||||
}, [workspace])
|
||||
|
||||
const assemblyNodeBarcodeGroups = useMemo(
|
||||
() => buildAssemblyNodeBarcodeGroups(workspace.nodes, materialVariantById),
|
||||
[materialVariantById, workspace.nodes],
|
||||
)
|
||||
|
||||
const toggleAssemblyBarcodeGroup = useCallback((key: string) => {
|
||||
setExpandedAssemblyBarcodeKeys((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const activeMetric = metrics[0]?.value ?? '直接进入'
|
||||
|
||||
const handleCaptureReady = useCallback((capture: (() => string | null) | null) => {
|
||||
@ -1005,7 +1178,7 @@ export default function AssistedAssemblyClient({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#5a6062]/45">
|
||||
<Layers3 className="h-3.5 w-3.5" />
|
||||
<span>Assembly Nodes</span>
|
||||
<span>Assembly Barcodes</span>
|
||||
</div>
|
||||
{workspace.nodes.length === 0 ? (
|
||||
<div className="rounded-[8px] border border-dashed border-[#47646b]/12 px-3 py-7 text-center text-[11px] text-[#5a6062]/55">
|
||||
@ -1013,18 +1186,63 @@ export default function AssistedAssemblyClient({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workspace.nodes.slice(0, 6).map((node) => (
|
||||
<div key={node.nodeId} className="rounded-[7px] bg-white px-3 py-2 ring-1 ring-black/[0.04]">
|
||||
{assemblyNodeBarcodeGroups.map((group) => {
|
||||
const isExpanded = expandedAssemblyBarcodeKeys.has(group.key)
|
||||
const mainCount = group.nodes.filter(({ node }) => node.zone === 'main').length
|
||||
return (
|
||||
<div key={group.key} className="rounded-[7px] bg-white ring-1 ring-black/[0.04]">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? '收起' : '展开'}条码${group.label}模型明细`}
|
||||
onClick={() => toggleAssemblyBarcodeGroup(group.key)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-[#5a6062]/45 transition-transform',
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[9px] font-black uppercase tracking-[0.12em] text-[#5a6062]/38">
|
||||
条码
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate font-mono text-xs font-bold text-[#2d3335]">
|
||||
{group.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[9px] font-bold text-[#5a6062]/55">
|
||||
{group.nodes.length} 模型
|
||||
</span>
|
||||
<span className="mt-1 block text-[9px] font-bold text-[#5a6062]/35">
|
||||
主装 {mainCount}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="space-y-1 border-t border-black/[0.04] px-3 pb-2 pt-1.5">
|
||||
{group.nodes.map(({ node, variant }) => (
|
||||
<div key={node.nodeId} className="rounded-[5px] bg-black/[0.018] px-2 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-apple-medium text-[#2d3335]">{node.name}</p>
|
||||
<p className="truncate text-[11px] font-apple-medium text-[#2d3335]">{node.name}</p>
|
||||
<span className="shrink-0 rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[9px] font-bold text-[#5a6062]/55">
|
||||
{node.zone === 'main' ? '主装配' : '待装'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-[#5a6062]/45">{node.code}</p>
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-[#5a6062]/45">
|
||||
{variant?.code ?? node.code}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -481,6 +481,11 @@ function buildHistoricalVariantsFromAiSnapshot(assetSnapshot: unknown): Assisted
|
||||
assetFamilyId,
|
||||
assetFamilyCode: typeof item.assetFamilyCode === 'string' ? item.assetFamilyCode : '',
|
||||
assetFamilyName,
|
||||
skuGroupId: typeof item.skuGroupId === 'string' ? item.skuGroupId : null,
|
||||
skuGroupCode: typeof item.skuGroupCode === 'string' ? item.skuGroupCode : null,
|
||||
skuGroupName: typeof item.skuGroupName === 'string' ? item.skuGroupName : null,
|
||||
skuGroupSortOrder: typeof item.skuGroupSortOrder === 'number' ? item.skuGroupSortOrder : null,
|
||||
skuGroupMetadata: isRecord(item.skuGroupMetadata) ? item.skuGroupMetadata : null,
|
||||
assetType,
|
||||
code: typeof item.code === 'string' ? item.code : '',
|
||||
name: typeof item.name === 'string' ? item.name : assetFamilyName,
|
||||
|
||||
@ -302,9 +302,17 @@ function MaterialPreview({ preSku, onItemClick }: { preSku: DraftGroupPreSkuDTO;
|
||||
className="flex w-full items-start gap-3 px-3 py-3 text-left transition hover:bg-[var(--obs-shell-surface-bg)]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[12px] font-black text-[var(--obs-heading-color)]">{item.name}</p>
|
||||
<p className="truncate text-[12px] font-black text-[var(--obs-heading-color)]">{item.code}</p>
|
||||
<p className="mt-1 truncate text-[11px] font-semibold text-[var(--obs-shell-muted)]">
|
||||
{item.code} · {item.role}
|
||||
{item.name}
|
||||
</p>
|
||||
{item.variantCodes.length > 1 ? (
|
||||
<p className="mt-1 truncate text-[11px] font-semibold text-[var(--obs-shell-muted)]">
|
||||
模型:{item.variantCodes.join(' / ')}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 truncate text-[11px] font-semibold text-[var(--obs-shell-color)]">
|
||||
对应条码:{item.correspondingBarcode}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
@ -859,6 +867,7 @@ export default function DraftGroupsClient({ draftGroups, categories, user }: Dra
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DetailField label="编码" value={detailVariant.code} />
|
||||
<DetailField label="对应条码" value={detailVariant.correspondingBarcode} />
|
||||
<DetailField label="名称" value={detailVariant.name} />
|
||||
<DetailField label="角色" value={detailVariant.role} />
|
||||
<DetailField label="资产类型" value={detailVariant.assetType === 'BOARD' ? '板材' : detailVariant.assetType === 'HARDWARE' ? '五金件' : '组件'} />
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import {
|
||||
buildDraftGroupBarcodeFromSkuCode,
|
||||
buildDraftGroupFormalSkuCode,
|
||||
normalizeDraftGroupSeriesSpuForSkuCode,
|
||||
} from './draftGroupFormalSkuCode'
|
||||
|
||||
describe('draft group modular SKU code generation', () => {
|
||||
const draftGroup = {
|
||||
createdAt: '2026-06-04T04:54:18.725Z',
|
||||
}
|
||||
|
||||
it('builds draft-group SKU codes from manual SPU, year-month, and sequence', () => {
|
||||
expect(buildDraftGroupFormalSkuCode('DNZ0384', draftGroup as never, 0)).toBe(
|
||||
'MKH-DNZ0384260600001',
|
||||
)
|
||||
expect(buildDraftGroupFormalSkuCode('DNZ0384', draftGroup as never, 2)).toBe(
|
||||
'MKH-DNZ0384260600003',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate the MKH prefix', () => {
|
||||
expect(normalizeDraftGroupSeriesSpuForSkuCode('MKH-DNZ0384')).toBe('MKH-DNZ0384')
|
||||
expect(buildDraftGroupFormalSkuCode('MKH-DNZ0384', draftGroup as never, 0)).toBe(
|
||||
'MKH-DNZ0384260600001',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the generated SKU code as the barcode for draft groups', () => {
|
||||
const skuCode = buildDraftGroupFormalSkuCode('DNZ0384', draftGroup as never, 0)
|
||||
|
||||
expect(buildDraftGroupBarcodeFromSkuCode(skuCode)).toBe('MKH-DNZ0384260600001')
|
||||
expect(buildDraftGroupBarcodeFromSkuCode(`${skuCode}A`)).toBe('MKH-DNZ0384260600001A')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -322,6 +322,9 @@ function MaterialSections({ preSku }: { preSku: DraftGroupPreSkuDTO }) {
|
||||
<p className="mt-1 truncate text-[11px] font-semibold text-[#5a6062]/62">
|
||||
{item.code} · {item.role}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-semibold text-[#47646b]">
|
||||
对应条码:{item.correspondingBarcode}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-[4px] bg-[#edf4f3] px-1.5 py-0.5 text-[10px] font-black text-[#47646b]">
|
||||
x{item.quantity}
|
||||
@ -330,6 +333,7 @@ function MaterialSections({ preSku }: { preSku: DraftGroupPreSkuDTO }) {
|
||||
<div className="mt-2 grid gap-1 text-[11px] font-semibold text-[#5a6062]/58">
|
||||
<span className="truncate">尺寸:{item.dimensions}</span>
|
||||
<span className="truncate">材质/花色:{item.material}</span>
|
||||
<span className="truncate">对应条码:{item.correspondingBarcode}</span>
|
||||
<span className="truncate">重量:{item.weight} · 成本:{item.cost}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
import type { DraftGroupPreSkuDTO } from '@/server/services/draft-group.service.types'
|
||||
import { deriveDraftMaterialSections } from './draftGroupDisplay'
|
||||
|
||||
describe('draftGroupDisplay', () => {
|
||||
it('shows the selected asset corresponding barcode in material sections', () => {
|
||||
const sections = deriveDraftMaterialSections({
|
||||
variantSelection: {
|
||||
slots: [
|
||||
{
|
||||
slotKey: 'desktop',
|
||||
role: 'desktop',
|
||||
assetFamilyId: 'family-board-1',
|
||||
variantId: 'variant-board-1',
|
||||
code: 'DBZ-SPJ01',
|
||||
name: '桌面板',
|
||||
assetType: 'BOARD',
|
||||
correspondingBarcode: 'DNZ038401N1440V01',
|
||||
dimensions: { length: 1400, width: 600, thickness: 25 },
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
modelUrl: '',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
},
|
||||
],
|
||||
hardwareSelections: [],
|
||||
},
|
||||
} as DraftGroupPreSkuDTO)
|
||||
|
||||
expect(sections.find((section) => section.key === 'BOARD')?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
correspondingBarcode: 'DNZ038401N1440V01',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('groups material preview rows by asset sku group code', () => {
|
||||
const sections = deriveDraftMaterialSections({
|
||||
variantSelection: {
|
||||
slots: [
|
||||
{
|
||||
slotKey: 'leg-1',
|
||||
role: 'leg',
|
||||
assetFamilyId: 'family-leg-1',
|
||||
variantId: 'variant-leg-1',
|
||||
skuGroupId: 'sku-group-dbz038501',
|
||||
skuGroupCode: 'DBZ038501',
|
||||
skuGroupName: 'DBZ038501',
|
||||
code: 'DBZ-SPJ06',
|
||||
name: 'black metal model',
|
||||
assetType: 'COMPONENT',
|
||||
correspondingBarcode: 'DBZ038501N1440V01',
|
||||
dimensions: { length: 600, width: 40, height: 700 },
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
modelUrl: '',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
},
|
||||
{
|
||||
slotKey: 'leg-2',
|
||||
role: 'leg',
|
||||
assetFamilyId: 'family-leg-1',
|
||||
variantId: 'variant-leg-2',
|
||||
skuGroupId: 'sku-group-dbz038501',
|
||||
skuGroupCode: 'DBZ038501',
|
||||
skuGroupName: 'DBZ038501',
|
||||
code: 'DBZ-SPJ07',
|
||||
name: 'white metal model',
|
||||
assetType: 'COMPONENT',
|
||||
correspondingBarcode: 'DBZ038501N1440V01',
|
||||
dimensions: { length: 600, width: 40, height: 700 },
|
||||
weightKg: null,
|
||||
costPrice: null,
|
||||
modelUrl: '',
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
},
|
||||
],
|
||||
hardwareSelections: [],
|
||||
},
|
||||
} as DraftGroupPreSkuDTO)
|
||||
|
||||
const componentItems = sections.find((section) => section.key === 'COMPONENT')?.items ?? []
|
||||
expect(componentItems).toHaveLength(1)
|
||||
expect(componentItems[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'DBZ038501',
|
||||
name: 'DBZ038501',
|
||||
quantity: 2,
|
||||
correspondingBarcode: 'DBZ038501N1440V01',
|
||||
variantCodes: ['DBZ-SPJ06', 'DBZ-SPJ07'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -21,12 +21,15 @@ export interface DraftMaterialItem {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
variantCodes: string[]
|
||||
variantNames: string[]
|
||||
role: string
|
||||
quantity: number
|
||||
dimensions: string
|
||||
material: string
|
||||
weight: string
|
||||
cost: string
|
||||
correspondingBarcode: string
|
||||
assetType: MaterialAssetType
|
||||
variantId: string
|
||||
assetFamilyId: string
|
||||
@ -35,6 +38,9 @@ export interface DraftMaterialItem {
|
||||
modelUrl: string
|
||||
slotKey: string | null
|
||||
selectionKey: string | null
|
||||
skuGroupId: string | null
|
||||
skuGroupCode: string | null
|
||||
skuGroupName: string | null
|
||||
}
|
||||
|
||||
export interface DraftMaterialSection {
|
||||
@ -179,18 +185,48 @@ function readSlotMaterial(item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHar
|
||||
return displayDraftValue(materialLabel)
|
||||
}
|
||||
|
||||
function normalizeGroupText(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? ''
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
function getSelectionSkuGroupCode(
|
||||
item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
|
||||
) {
|
||||
return normalizeGroupText(item.skuGroupCode)?.toUpperCase() ?? null
|
||||
}
|
||||
|
||||
function getSelectionSkuGroupName(
|
||||
item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
|
||||
) {
|
||||
return normalizeGroupText(item.skuGroupName) ?? getSelectionSkuGroupCode(item)
|
||||
}
|
||||
|
||||
function getMaterialGroupKey(
|
||||
item: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
|
||||
) {
|
||||
const skuGroupCode = getSelectionSkuGroupCode(item)
|
||||
if (skuGroupCode) return `sku-group-code:${skuGroupCode}`
|
||||
const skuGroupId = normalizeGroupText(item.skuGroupId)
|
||||
if (skuGroupId) return `sku-group-id:${skuGroupId}`
|
||||
return `variant:${item.variantId}`
|
||||
}
|
||||
|
||||
function mapSlotItem(slot: PreSkuSelectedSlotVariantDTO): DraftMaterialItem {
|
||||
const assetType = normalizeAssetType(slot.assetType)
|
||||
return {
|
||||
id: `${slot.slotKey}-${slot.variantId}`,
|
||||
code: displayDraftValue(slot.code),
|
||||
name: displayDraftValue(slot.name),
|
||||
code: displayDraftValue(getSelectionSkuGroupCode(slot) ?? slot.code),
|
||||
name: displayDraftValue(getSelectionSkuGroupName(slot) ?? slot.name),
|
||||
variantCodes: [slot.code].filter(isPresent).map((value) => String(value).trim()),
|
||||
variantNames: [slot.name].filter(isPresent).map((value) => String(value).trim()),
|
||||
role: readFirstText(slot.role, slot.slotKey),
|
||||
quantity: 1,
|
||||
dimensions: formatDimensions(slot.dimensions),
|
||||
material: readSlotMaterial(slot),
|
||||
weight: formatWeight(slot.weightKg),
|
||||
cost: formatCost(slot.costPrice),
|
||||
correspondingBarcode: readFirstText(slot.correspondingBarcode, slot.barcode),
|
||||
assetType,
|
||||
variantId: slot.variantId,
|
||||
assetFamilyId: slot.assetFamilyId,
|
||||
@ -199,20 +235,26 @@ function mapSlotItem(slot: PreSkuSelectedSlotVariantDTO): DraftMaterialItem {
|
||||
modelUrl: slot.modelUrl,
|
||||
slotKey: slot.slotKey,
|
||||
selectionKey: null,
|
||||
skuGroupId: normalizeGroupText(slot.skuGroupId),
|
||||
skuGroupCode: getSelectionSkuGroupCode(slot),
|
||||
skuGroupName: getSelectionSkuGroupName(slot),
|
||||
}
|
||||
}
|
||||
|
||||
function mapHardwareItem(hardware: PreSkuSelectedHardwareSelectionDTO): DraftMaterialItem {
|
||||
return {
|
||||
id: `${hardware.selectionKey}-${hardware.variantId}`,
|
||||
code: displayDraftValue(hardware.code),
|
||||
name: displayDraftValue(hardware.name),
|
||||
code: displayDraftValue(getSelectionSkuGroupCode(hardware) ?? hardware.code),
|
||||
name: displayDraftValue(getSelectionSkuGroupName(hardware) ?? hardware.name),
|
||||
variantCodes: [hardware.code].filter(isPresent).map((value) => String(value).trim()),
|
||||
variantNames: [hardware.name].filter(isPresent).map((value) => String(value).trim()),
|
||||
role: readFirstText(hardware.selectionKey, '五金'),
|
||||
quantity: hardware.quantity || 1,
|
||||
dimensions: formatDimensions(hardware.dimensions),
|
||||
material: readSlotMaterial(hardware),
|
||||
weight: formatWeight(hardware.weightKg),
|
||||
cost: formatCost(hardware.costPrice),
|
||||
correspondingBarcode: readFirstText(hardware.correspondingBarcode, hardware.barcode),
|
||||
assetType: 'HARDWARE',
|
||||
variantId: hardware.variantId,
|
||||
assetFamilyId: hardware.assetFamilyId,
|
||||
@ -221,6 +263,22 @@ function mapHardwareItem(hardware: PreSkuSelectedHardwareSelectionDTO): DraftMat
|
||||
modelUrl: hardware.modelUrl,
|
||||
slotKey: null,
|
||||
selectionKey: hardware.selectionKey,
|
||||
skuGroupId: normalizeGroupText(hardware.skuGroupId),
|
||||
skuGroupCode: getSelectionSkuGroupCode(hardware),
|
||||
skuGroupName: getSelectionSkuGroupName(hardware),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDraftMaterialItem(target: DraftMaterialItem, item: DraftMaterialItem) {
|
||||
target.quantity += item.quantity
|
||||
item.variantCodes.forEach((code) => {
|
||||
if (!target.variantCodes.includes(code)) target.variantCodes.push(code)
|
||||
})
|
||||
item.variantNames.forEach((name) => {
|
||||
if (!target.variantNames.includes(name)) target.variantNames.push(name)
|
||||
})
|
||||
if (target.correspondingBarcode === DRAFT_GROUP_PENDING_TEXT && item.correspondingBarcode) {
|
||||
target.correspondingBarcode = item.correspondingBarcode
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,15 +288,28 @@ export function deriveDraftMaterialSections(preSku: DraftGroupPreSkuDTO): DraftM
|
||||
COMPONENT: [],
|
||||
HARDWARE: [],
|
||||
}
|
||||
const itemByGroupKey = new Map<string, DraftMaterialItem>()
|
||||
|
||||
preSku.variantSelection.slots.forEach((slot) => {
|
||||
const item = mapSlotItem(slot)
|
||||
const addItem = (
|
||||
source: PreSkuSelectedSlotVariantDTO | PreSkuSelectedHardwareSelectionDTO,
|
||||
item: DraftMaterialItem,
|
||||
) => {
|
||||
const key = `${item.assetType}:${getMaterialGroupKey(source)}`
|
||||
const existing = itemByGroupKey.get(key)
|
||||
if (existing) {
|
||||
mergeDraftMaterialItem(existing, item)
|
||||
return
|
||||
}
|
||||
|
||||
itemByGroupKey.set(key, item)
|
||||
grouped[item.assetType].push(item)
|
||||
})
|
||||
}
|
||||
|
||||
preSku.variantSelection.hardwareSelections.forEach((hardware) => {
|
||||
grouped.HARDWARE.push(mapHardwareItem(hardware))
|
||||
})
|
||||
preSku.variantSelection.slots.forEach((slot) => addItem(slot, mapSlotItem(slot)))
|
||||
|
||||
preSku.variantSelection.hardwareSelections.forEach((hardware) =>
|
||||
addItem(hardware, mapHardwareItem(hardware)),
|
||||
)
|
||||
|
||||
return (['BOARD', 'COMPONENT', 'HARDWARE'] as const).map((key) => ({
|
||||
key,
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
export function normalizeDraftGroupSeriesSpuForSkuCode(value: string | null | undefined) {
|
||||
const normalized = (value ?? '').trim().toUpperCase()
|
||||
if (!normalized) return ''
|
||||
return normalized.startsWith('MKH-') ? normalized : `MKH-${normalized}`
|
||||
}
|
||||
|
||||
export function formatDraftGroupSkuYearMonth(value: Date | string | null | undefined) {
|
||||
const date = value ? new Date(value) : new Date()
|
||||
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date
|
||||
return `${String(safeDate.getFullYear()).slice(-2)}${String(safeDate.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function buildDraftGroupFormalSkuCode(
|
||||
seriesSpu: string,
|
||||
draftGroup: { createdAt?: Date | string | null } | null | undefined,
|
||||
index: number,
|
||||
) {
|
||||
const normalizedSeriesSpu = normalizeDraftGroupSeriesSpuForSkuCode(seriesSpu)
|
||||
if (!normalizedSeriesSpu) return ''
|
||||
return `${normalizedSeriesSpu}${formatDraftGroupSkuYearMonth(draftGroup?.createdAt)}${String(index + 1).padStart(5, '0')}`
|
||||
}
|
||||
|
||||
export function buildDraftGroupBarcodeFromSkuCode(skuCode: string) {
|
||||
const normalizedSkuCode = skuCode.trim().toUpperCase()
|
||||
if (!normalizedSkuCode) return ''
|
||||
return /^MKH-.+\d{9}[A-Z]?$/.test(normalizedSkuCode) ? normalizedSkuCode : ''
|
||||
}
|
||||
@ -1813,7 +1813,7 @@ describe('IntakeClient', () => {
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '系列产品开发明细' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '模块化产品明细' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1823,13 +1823,14 @@ describe('IntakeClient', () => {
|
||||
initialPendingEntryTab: 'integrated',
|
||||
})
|
||||
|
||||
expect(screen.getByRole('tab', { name: '系列产品开发明细' })).toHaveAttribute(
|
||||
expect(screen.getByRole('tab', { name: '模块化产品明细' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
const workspacePanel = screen.getByRole('tabpanel')
|
||||
expect(within(workspacePanel).getByRole('columnheader', { name: '系列名称' })).toBeInTheDocument()
|
||||
expect(within(workspacePanel).getByRole('button', { name: '新增部件' })).toBeInTheDocument()
|
||||
expect(within(workspacePanel).queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
|
||||
expect(within(workspacePanel).queryByRole('button', { name: '新增部件' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps BOM libraries visible but read-only entry points hidden from purchasing users', async () => {
|
||||
@ -1913,10 +1914,10 @@ describe('IntakeClient', () => {
|
||||
renderSelector({ eligibleSkuList: [] })
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
expect(within(rightPanel).getByRole('button', { name: '系列产品开发明细' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '模块化产品明细' })).toBeInTheDocument()
|
||||
expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument()
|
||||
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: '系列产品开发明细' }))
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: '模块化产品明细' }))
|
||||
|
||||
expect(screen.getByText('SER001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
|
||||
@ -1968,7 +1969,7 @@ describe('IntakeClient', () => {
|
||||
renderSelector({ eligibleSkuList: [] })
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: '系列产品开发明细' }))
|
||||
await actor.click(within(rightPanel).getByRole('button', { name: '模块化产品明细' }))
|
||||
|
||||
expect(screen.getByText('DNZ0385')).toBeInTheDocument()
|
||||
expect(screen.getByText('DNZ0384')).toBeInTheDocument()
|
||||
@ -1991,37 +1992,19 @@ describe('IntakeClient', () => {
|
||||
expect(within(rightPanel).queryByRole('button', { name: '质检确认' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a new series product workspace from the series development list', async () => {
|
||||
it('does not offer manual series product creation from the series development list', async () => {
|
||||
const actor = userEvent.setup()
|
||||
renderSelector({ eligibleSkuList: [] })
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
await actor.click(
|
||||
within(rightPanel).getByRole('button', { name: '系列产品开发明细' }),
|
||||
within(rightPanel).getByRole('button', { name: '模块化产品明细' }),
|
||||
)
|
||||
await actor.click(screen.getByRole('button', { name: '新增产品' }))
|
||||
|
||||
expect(screen.getByRole('tab', { name: '新增系列产品' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent(
|
||||
'series',
|
||||
)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '模拟创建开品单' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: '新增系列产品' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'CREATED001系列产品开发明细' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent(
|
||||
'series',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the selected draft group series name available when opening a new series product', async () => {
|
||||
it('keeps the series development list read-only for creation even with draft group series options', async () => {
|
||||
const actor = userEvent.setup()
|
||||
renderSelector({
|
||||
eligibleDraftGroups: [],
|
||||
@ -2031,16 +2014,10 @@ describe('IntakeClient', () => {
|
||||
|
||||
const rightPanel = screen.getByTestId('right-panel')
|
||||
await actor.click(
|
||||
within(rightPanel).getByRole('button', { name: '系列产品开发明细' }),
|
||||
)
|
||||
await actor.click(screen.getByRole('button', { name: '新增产品' }))
|
||||
|
||||
expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent(
|
||||
'series',
|
||||
)
|
||||
expect(screen.getByTestId('embedded-series-options')).toHaveTextContent(
|
||||
'单板桌 DBZ',
|
||||
within(rightPanel).getByRole('button', { name: '模块化产品明细' }),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: '新增产品' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: '新增系列产品' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps product and series development lists separated', async () => {
|
||||
@ -2065,7 +2042,7 @@ describe('IntakeClient', () => {
|
||||
expect(screen.queryByText('SER001')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('columnheader', { name: '系列名称' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '系列产品开发明细' }))
|
||||
await actor.click(screen.getByRole('button', { name: '模块化产品明细' }))
|
||||
|
||||
expect(screen.getByText('SER001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
|
||||
|
||||
@ -1443,7 +1443,7 @@ const REFERENCE_ESTIMATOR_GROUP_LABEL = "预估成本测算";
|
||||
const REFERENCE_ESTIMATOR_WORKSPACE_LABEL = "参考图预估成本";
|
||||
|
||||
function getPendingEntryTabLabel(tab: IntakeEntryTab) {
|
||||
if (tab === "integrated") return "系列产品开发明细";
|
||||
if (tab === "integrated") return "模块化产品明细";
|
||||
if (tab === "stock") return "存量商品明细";
|
||||
return "产品开发明细";
|
||||
}
|
||||
@ -1708,7 +1708,7 @@ function getProductDevelopmentDetailLabel(
|
||||
getProductDevelopmentKind(product) === "series"
|
||||
? isSeriesComponentDetail(product as unknown as Record<string, unknown>)
|
||||
? "部件开发明细"
|
||||
: "系列产品开发明细"
|
||||
: "模块化产品明细"
|
||||
: isJstSkuImportedStockProduct(product)
|
||||
? "存量商品明细"
|
||||
: "产品开发明细";
|
||||
@ -11405,28 +11405,13 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
productDevelopmentKind="series"
|
||||
emptyMessage={
|
||||
canUseDesignControlledProductData
|
||||
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
|
||||
? "当前还没有模块化产品,请先在草稿组推送 SKU 后承接。"
|
||||
: "当前还没有系列产品。"
|
||||
}
|
||||
onProductsChange={(nextProducts) =>
|
||||
handleScopedProductsChange("series", nextProducts)
|
||||
}
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("series")
|
||||
: undefined
|
||||
}
|
||||
secondaryCreateButtonLabel="新增部件"
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () =>
|
||||
openNewNonIntegratedProductWorkspace(
|
||||
"series",
|
||||
"seriesComponents",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingEntryTab === "stock" && canViewStockDevelopmentList ? (
|
||||
<NonIntegratedPendingDetail
|
||||
@ -11608,28 +11593,13 @@ export default function IntakeClient(props: IntakeClientProps) {
|
||||
productDevelopmentKind="series"
|
||||
emptyMessage={
|
||||
canUseDesignControlledProductData
|
||||
? "当前还没有系列产品,点击右上角“新增产品”开始维护系列资料。"
|
||||
? "当前还没有模块化产品,请先在草稿组推送 SKU 后承接。"
|
||||
: "当前还没有系列产品。"
|
||||
}
|
||||
onProductsChange={(nextProducts) =>
|
||||
handleScopedProductsChange("series", nextProducts)
|
||||
}
|
||||
onOpenProductWorkspace={openProductWorkspace}
|
||||
onOpenNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () => openNewNonIntegratedProductWorkspace("series")
|
||||
: undefined
|
||||
}
|
||||
secondaryCreateButtonLabel="新增部件"
|
||||
onOpenSecondaryNewNonIntegratedProductWorkspace={
|
||||
canUseDesignControlledProductData
|
||||
? () =>
|
||||
openNewNonIntegratedProductWorkspace(
|
||||
"series",
|
||||
"seriesComponents",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingEntryTab === "stock" && canViewStockDevelopmentList ? (
|
||||
<NonIntegratedPendingDetail
|
||||
|
||||
@ -700,12 +700,14 @@ describe("NonIntegratedDetailClient", () => {
|
||||
await actor.keyboard("{Escape}");
|
||||
|
||||
await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌系列");
|
||||
await actor.type(screen.getByLabelText("SPU"), "dnz0385");
|
||||
expect(screen.getByLabelText("SPU")).toHaveValue("DNZ0385");
|
||||
await actor.type(screen.getByLabelText("品类"), "桌类");
|
||||
await actor.type(screen.getByLabelText("类目"), "书桌");
|
||||
await actor.type(screen.getByLabelText("外观设计师"), "外观A");
|
||||
await selectInlineOption(actor, screen.getByLabelText("品牌"), "代理品牌-顾家");
|
||||
expect(screen.getByLabelText("SPU")).toHaveValue("DNZ0385");
|
||||
await actor.type(screen.getByLabelText("商品企划"), "企划A");
|
||||
expect(screen.getByLabelText("SPU")).toHaveValue("预计SPU:GBGJ-SZ0241");
|
||||
|
||||
const imageFile = new File(["image"], "series.png", { type: "image/png" });
|
||||
const fileInputs = container.querySelectorAll('input[type="file"]');
|
||||
@ -717,13 +719,13 @@ describe("NonIntegratedDetailClient", () => {
|
||||
const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[];
|
||||
|
||||
expect(savedProducts[0]?.productDevelopmentKind).toBe("series");
|
||||
expect(savedProducts[0]?.spu).toBe("GBGJ-SZ0241");
|
||||
expect(savedProducts[0]?.spu).toBe("DNZ0385");
|
||||
expect(savedProducts[0]?.openingReference.productName).toBe("单板桌系列");
|
||||
expect(savedProducts[0]?.salesCategory).toBe("桌类");
|
||||
expect(savedProducts[0]?.productCategory).toBe("书桌");
|
||||
});
|
||||
|
||||
it("uses the selected asset series finished SPU when creating a series product", async () => {
|
||||
it("keeps series product SPU manual even when the selected asset series has a finished SPU", async () => {
|
||||
const actor = userEvent.setup();
|
||||
const now = new Date("2026-06-02T08:00:00.000Z");
|
||||
mockedListAssetSeries.mockResolvedValue([
|
||||
@ -755,8 +757,9 @@ describe("NonIntegratedDetailClient", () => {
|
||||
|
||||
await selectInlineOption(actor, screen.getByLabelText("系列名称"), "单板桌 DBZ");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("SPU")).toHaveValue("DNZ0385"),
|
||||
expect(screen.getByLabelText("SPU")).toHaveValue(""),
|
||||
);
|
||||
await actor.type(screen.getByLabelText("SPU"), "DNZ0399");
|
||||
await actor.type(screen.getByLabelText("品类"), "桌类");
|
||||
await actor.type(screen.getByLabelText("类目"), "电脑桌");
|
||||
await actor.type(screen.getByLabelText("外观设计师"), "外观A");
|
||||
@ -772,13 +775,13 @@ describe("NonIntegratedDetailClient", () => {
|
||||
const savedProducts = listNonIntegratedProducts() as NonIntegratedProductRecord[];
|
||||
expect(savedProducts[0]).toMatchObject({
|
||||
productDevelopmentKind: "series",
|
||||
spu: "DNZ0385",
|
||||
productCode: "DNZ0385",
|
||||
spu: "DNZ0399",
|
||||
productCode: "DNZ0399",
|
||||
metadata: {
|
||||
sourceSeriesCode: "DBZ",
|
||||
sourceAssetSeriesId: "series-dbz",
|
||||
componentSpu: "DNZ0384",
|
||||
finishedProductSpu: "DNZ0385",
|
||||
finishedProductSpu: "DNZ0399",
|
||||
},
|
||||
openingReference: {
|
||||
productName: "单板桌 DBZ",
|
||||
@ -789,7 +792,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
});
|
||||
expect(mockedCreateApprovalWorkflow).toHaveBeenCalledWith({
|
||||
productId: savedProducts[0]?.id,
|
||||
approvalNumber: "DNZ0385",
|
||||
approvalNumber: "DNZ0399",
|
||||
title: "单板桌 DBZ",
|
||||
});
|
||||
});
|
||||
|
||||
@ -743,6 +743,7 @@ const SERIES_COMPONENT_SECONDARY_CATEGORY = "家具辅料";
|
||||
const SERIES_COMPONENT_PRODUCT_CATEGORY = "其它家具配件";
|
||||
const SERIES_COMPONENT_ASSET_SKU_SOURCE = "asset_series_component";
|
||||
const ASSET_VARIANT_SOURCE_TABLE = "asset_variant";
|
||||
const ASSET_SKU_GROUP_SOURCE_TABLE = "asset_sku_group";
|
||||
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = "skuContentFields";
|
||||
|
||||
type SeriesDraftSkuOption = {
|
||||
@ -762,6 +763,10 @@ type SeriesDraftSkuOption = {
|
||||
model3dPreviewNodes?: unknown[];
|
||||
};
|
||||
|
||||
type AssetSkuContentFields = Partial<SeriesDraftSkuFields> & {
|
||||
correspondingBarcode?: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
spu: string;
|
||||
year: string;
|
||||
@ -3742,11 +3747,11 @@ function readRecordStringIfPresent(value: Record<string, unknown>, key: string)
|
||||
return "";
|
||||
}
|
||||
|
||||
function readAssetVariantSkuContentFields(
|
||||
variant: AssetVariantDTO,
|
||||
): Partial<SeriesDraftSkuFields> {
|
||||
const rawFields = isRecordLike(variant.businessData)
|
||||
? variant.businessData[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
function readAssetSkuContentFieldsFromRecord(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
): AssetSkuContentFields {
|
||||
const rawFields = isRecordLike(record)
|
||||
? record[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
: null;
|
||||
if (!isRecordLike(rawFields)) return {};
|
||||
|
||||
@ -3770,17 +3775,44 @@ function readAssetVariantSkuContentFields(
|
||||
"manufacturerCode",
|
||||
"salesStatus",
|
||||
"batchNo",
|
||||
"dingTalkFlowNo",
|
||||
"correspondingBarcode",
|
||||
"tagMaterial",
|
||||
"model3dFileName",
|
||||
].reduce<Partial<SeriesDraftSkuFields>>((fields, key) => {
|
||||
].reduce<AssetSkuContentFields>((fields, key) => {
|
||||
const value = readRecordStringIfPresent(rawFields, key);
|
||||
if (value !== undefined) {
|
||||
fields[key as keyof SeriesDraftSkuFields] = value;
|
||||
fields[key as keyof AssetSkuContentFields] = value;
|
||||
}
|
||||
return fields;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function readAssetVariantSkuContentFields(
|
||||
variant: AssetVariantDTO,
|
||||
): AssetSkuContentFields {
|
||||
return readAssetSkuContentFieldsFromRecord(
|
||||
isRecordLike(variant.businessData) ? variant.businessData : null,
|
||||
);
|
||||
}
|
||||
|
||||
function readAssetSkuGroupSkuContentFields(
|
||||
variant: AssetVariantDTO,
|
||||
): AssetSkuContentFields {
|
||||
return readAssetSkuContentFieldsFromRecord(
|
||||
isRecordLike(variant.skuGroupMetadata) ? variant.skuGroupMetadata : null,
|
||||
);
|
||||
}
|
||||
|
||||
function mergeAssetVariantAndSkuGroupContentFields(
|
||||
variant: AssetVariantDTO,
|
||||
): AssetSkuContentFields {
|
||||
return {
|
||||
...readAssetVariantSkuContentFields(variant),
|
||||
...readAssetSkuGroupSkuContentFields(variant),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSeriesDraftSkuPreviewImage(
|
||||
option: SeriesDraftSkuOption,
|
||||
): SkuPreviewImageCandidate | null {
|
||||
@ -4204,7 +4236,7 @@ function createSkuItemFromAssetVariant(
|
||||
variant: AssetVariantDTO,
|
||||
index: number,
|
||||
): NonIntegratedSkuItem {
|
||||
const skuFields = readAssetVariantSkuContentFields(variant);
|
||||
const skuFields = mergeAssetVariantAndSkuGroupContentFields(variant);
|
||||
const skuCode = variant.code.trim().toUpperCase();
|
||||
const manufacturerCode =
|
||||
skuFields.manufacturerCode?.trim().toUpperCase() ||
|
||||
@ -4218,6 +4250,7 @@ function createSkuItemFromAssetVariant(
|
||||
skuFields.barcode?.trim().toUpperCase() ||
|
||||
variant.barcode?.trim().toUpperCase() ||
|
||||
(manufacturerCode && versionNo ? buildAutoBarcode(skuCode, manufacturerCode, versionNo) : "");
|
||||
const isStockLinkedSku = Boolean(skuFields.correspondingBarcode?.trim());
|
||||
const model = skuFields.model?.trim() || variant.assetFamilyCode.trim().toUpperCase();
|
||||
const attributeCode = readAssetVariantSkuFieldWithDefault(
|
||||
skuFields,
|
||||
@ -4285,10 +4318,44 @@ function createSkuItemFromAssetVariant(
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceSkuId: variant.id,
|
||||
contentLocked: isStockLinkedSku,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSeriesComponentSkuItemsFromAssetVariants(
|
||||
type SeriesComponentAssetSkuGroup = {
|
||||
id: string;
|
||||
hasSkuGroup: boolean;
|
||||
assetFamilyId: string;
|
||||
assetFamilyCode: string;
|
||||
assetFamilyName: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
variants: AssetVariantDTO[];
|
||||
};
|
||||
|
||||
function getSeriesComponentAssetSkuGroupId(variant: AssetVariantDTO) {
|
||||
const skuGroupId = variant.skuGroupId?.trim();
|
||||
if (skuGroupId) return skuGroupId;
|
||||
|
||||
const skuGroupCode = variant.skuGroupCode?.trim().toUpperCase();
|
||||
if (skuGroupCode) {
|
||||
return `sku-group-code:${variant.assetFamilyId}:${skuGroupCode}`;
|
||||
}
|
||||
|
||||
return `variant:${variant.id}`;
|
||||
}
|
||||
|
||||
function sortAssetVariantsWithinSkuGroup(variants: AssetVariantDTO[]) {
|
||||
return [...variants].sort(
|
||||
(left, right) =>
|
||||
getAssetCodeSequence(left.code) - getAssetCodeSequence(right.code) ||
|
||||
left.code.localeCompare(right.code, "zh-CN", { numeric: true }) ||
|
||||
left.name.localeCompare(right.name, "zh-CN", { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSeriesComponentAssetSkuGroups(
|
||||
variants: AssetVariantDTO[],
|
||||
families: AssetFamilyDTO[],
|
||||
) {
|
||||
@ -4299,10 +4366,104 @@ function buildSeriesComponentSkuItemsFromAssetVariants(
|
||||
)
|
||||
.map((family, index) => [family.id, index]),
|
||||
);
|
||||
const groupById = new Map<string, SeriesComponentAssetSkuGroup>();
|
||||
|
||||
return sortAssetVariantsForSeriesComponent(variants, familyOrderById).map(
|
||||
(variant, index) => createSkuItemFromAssetVariant(variant, index),
|
||||
variants.forEach((variant) => {
|
||||
const id = getSeriesComponentAssetSkuGroupId(variant);
|
||||
const hasSkuGroup = Boolean(
|
||||
variant.skuGroupId?.trim() || variant.skuGroupCode?.trim(),
|
||||
);
|
||||
const code = (
|
||||
variant.skuGroupCode?.trim() || variant.code.trim()
|
||||
).toUpperCase();
|
||||
const name = variant.skuGroupName?.trim() || variant.name.trim() || code;
|
||||
const current = groupById.get(id);
|
||||
if (current) {
|
||||
current.variants.push(variant);
|
||||
return;
|
||||
}
|
||||
|
||||
groupById.set(id, {
|
||||
id,
|
||||
hasSkuGroup,
|
||||
assetFamilyId: variant.assetFamilyId,
|
||||
assetFamilyCode: variant.assetFamilyCode,
|
||||
assetFamilyName: variant.assetFamilyName,
|
||||
code,
|
||||
name,
|
||||
sortOrder: variant.skuGroupSortOrder ?? Number.MAX_SAFE_INTEGER,
|
||||
variants: [variant],
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groupById.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
variants: sortAssetVariantsWithinSkuGroup(group.variants),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const familyOrderDelta =
|
||||
(familyOrderById.get(left.assetFamilyId) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(familyOrderById.get(right.assetFamilyId) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (familyOrderDelta !== 0) return familyOrderDelta;
|
||||
|
||||
const familyCodeCompare = left.assetFamilyCode.localeCompare(
|
||||
right.assetFamilyCode,
|
||||
"zh-CN",
|
||||
{ numeric: true },
|
||||
);
|
||||
if (familyCodeCompare !== 0) return familyCodeCompare;
|
||||
|
||||
const sortOrderDelta = left.sortOrder - right.sortOrder;
|
||||
if (sortOrderDelta !== 0) return sortOrderDelta;
|
||||
|
||||
return left.code.localeCompare(right.code, "zh-CN", { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
function createSkuItemFromAssetSkuGroup(
|
||||
group: SeriesComponentAssetSkuGroup,
|
||||
index: number,
|
||||
) {
|
||||
const primaryVariant =
|
||||
group.variants.find(
|
||||
(variant) => variant.code.trim().toUpperCase() === group.code,
|
||||
) ?? group.variants[0];
|
||||
if (!primaryVariant) return null;
|
||||
if (!group.hasSkuGroup) {
|
||||
return createSkuItemFromAssetVariant(primaryVariant, index);
|
||||
}
|
||||
|
||||
const skuItem = createSkuItemFromAssetVariant(primaryVariant, index);
|
||||
|
||||
return {
|
||||
...skuItem,
|
||||
id: `asset-sku-group-${group.id}`,
|
||||
skuSequence: index + 1,
|
||||
skuCode: group.code,
|
||||
specName:
|
||||
readAssetSkuGroupSkuContentFields(primaryVariant).specName?.trim() ||
|
||||
group.name,
|
||||
componentDetails: group.variants.map((variant) => ({
|
||||
code: variant.code.trim().toUpperCase(),
|
||||
name: variant.name.trim(),
|
||||
quantity: "1",
|
||||
role: variant.assetFamilyName,
|
||||
assetType: variant.assetType,
|
||||
})),
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
|
||||
sourceSkuId: group.id,
|
||||
} satisfies NonIntegratedSkuItem;
|
||||
}
|
||||
|
||||
function buildSeriesComponentSkuItemsFromAssetVariants(
|
||||
variants: AssetVariantDTO[],
|
||||
families: AssetFamilyDTO[],
|
||||
) {
|
||||
return buildSeriesComponentAssetSkuGroups(variants, families)
|
||||
.map((group, index) => createSkuItemFromAssetSkuGroup(group, index))
|
||||
.filter((item): item is NonIntegratedSkuItem => item !== null);
|
||||
}
|
||||
|
||||
function findAssetSeriesByCode(series: AssetSeriesDTO[], seriesCode: string) {
|
||||
@ -4344,7 +4505,8 @@ function isSeriesComponentAssetSkuItem(item: NonIntegratedSkuItem) {
|
||||
const sourceTable = normalizeSkuSourceMarker(item.sourceTable);
|
||||
return (
|
||||
source === SERIES_COMPONENT_ASSET_SKU_SOURCE ||
|
||||
sourceTable === ASSET_VARIANT_SOURCE_TABLE
|
||||
sourceTable === ASSET_VARIANT_SOURCE_TABLE ||
|
||||
sourceTable === ASSET_SKU_GROUP_SOURCE_TABLE
|
||||
);
|
||||
}
|
||||
|
||||
@ -4437,8 +4599,9 @@ function mergeSeriesComponentAssetSkuItems(
|
||||
model3dDataUrl: assetItem.model3dDataUrl,
|
||||
model3dUvConfig: assetItem.model3dUvConfig,
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceTable: assetItem.sourceTable || ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceSkuId: assetItem.sourceSkuId,
|
||||
contentLocked: assetItem.contentLocked === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -13251,7 +13414,9 @@ export default function NonIntegratedDetailClient({
|
||||
getSeriesComponentSpuFromSeriesName(selectedSourceSeriesName);
|
||||
const seriesProductFinishedSpu =
|
||||
isSeriesDevelopment && !isSeriesComponentCreate
|
||||
? selectedSeriesComponentAssetSeries?.finishedProductSpu
|
||||
? mode === "create"
|
||||
? metadataSeriesFinishedProductSpu
|
||||
: selectedSeriesComponentAssetSeries?.finishedProductSpu
|
||||
?.trim()
|
||||
.toUpperCase() ||
|
||||
metadataSeriesFinishedProductSpu
|
||||
@ -13291,7 +13456,7 @@ export default function NonIntegratedDetailClient({
|
||||
const developmentDetailSuffix = isSeriesDevelopment
|
||||
? isSeriesComponentDetailProduct
|
||||
? "部件开发明细"
|
||||
: "系列产品开发明细"
|
||||
: "模块化产品明细"
|
||||
: "产品开发明细";
|
||||
const developmentCreateButtonLabel = isSeriesComponentCreate
|
||||
? "创建部件"
|
||||
@ -13723,6 +13888,8 @@ export default function NonIntegratedDetailClient({
|
||||
const activeSkuBatchNo = activeSkuBatch === "all" ? null : activeSkuBatch;
|
||||
const formalSpuValue = formValues.spu.trim();
|
||||
const hasFormalSpu = formalSpuValue.length > 0;
|
||||
const isManualSeriesProductSpu =
|
||||
isSeriesDevelopment && !isSeriesComponentCreate;
|
||||
const spuRuleProductCategory =
|
||||
formValues.productCategory.trim() || openingReference.productCategory.trim();
|
||||
const createSalesCategory =
|
||||
@ -13757,11 +13924,6 @@ export default function NonIntegratedDetailClient({
|
||||
: spuRuleProductCategory
|
||||
? "未配置SPU规则"
|
||||
: "选择类目后自动预估";
|
||||
const shouldWaitForSeriesProductAssetSeries =
|
||||
isSeriesDevelopment &&
|
||||
!isSeriesComponentCreate &&
|
||||
selectedAssetSeriesCode.length > 0 &&
|
||||
!selectedSeriesComponentAssetSeries;
|
||||
const seriesCreateEstimatedSpuLabel = isSeriesComponentCreate
|
||||
? seriesComponentSpu || "选择系列后自动生成"
|
||||
: seriesProductFinishedSpu
|
||||
@ -13782,16 +13944,15 @@ export default function NonIntegratedDetailClient({
|
||||
isStockProductCreate ||
|
||||
(isSeriesComponentCreate
|
||||
? Boolean(selectedSourceSeriesName && seriesComponentSpu)
|
||||
: shouldWaitForSeriesProductAssetSeries
|
||||
? false
|
||||
: isSeriesDevelopment && seriesProductFinishedSpu
|
||||
? Boolean(createSalesCategory && createProductCategory)
|
||||
: isManualSeriesProductSpu
|
||||
? formalSpuValue.length > 0
|
||||
: Boolean(
|
||||
createSalesCategory &&
|
||||
createProductCategory &&
|
||||
estimatedSpuAssignment,
|
||||
));
|
||||
const isSpuCategoryLocked = hasFormalSpu && !isStockProductRecord;
|
||||
const isSpuCategoryLocked =
|
||||
hasFormalSpu && !isStockProductRecord && !isManualSeriesProductSpu;
|
||||
const skuCodePreviewSpu =
|
||||
seriesProductFinishedSpu || formalSpuValue || estimatedFormalSpu;
|
||||
const derivedSkuItems = useMemo(
|
||||
@ -18790,7 +18951,9 @@ export default function NonIntegratedDetailClient({
|
||||
brand: nextBrand,
|
||||
adaptableBrands: nextAdaptableBrands,
|
||||
spu: current.spu
|
||||
? applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand)
|
||||
? isManualSeriesProductSpu
|
||||
? current.spu
|
||||
: applyBrandPrefixToNonIntegratedCode(current.spu, nextBrand)
|
||||
: current.spu,
|
||||
}));
|
||||
syncBasicInfoFieldToOpeningReference(key, nextBrand, nextAdaptableBrands);
|
||||
@ -18817,6 +18980,18 @@ export default function NonIntegratedDetailClient({
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function updateManualSeriesProductSpu(value: string) {
|
||||
if (isReadOnly) return;
|
||||
|
||||
setFormValuesAndRef((current) => ({
|
||||
...current,
|
||||
spu: value.toUpperCase(),
|
||||
}));
|
||||
setSaveMessage("");
|
||||
setSaveMessageType("success");
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function handleStockProductSpuChange(value: string) {
|
||||
const normalizedValue = value.toUpperCase();
|
||||
if (!normalizedValue.trim()) {
|
||||
@ -24266,7 +24441,7 @@ export default function NonIntegratedDetailClient({
|
||||
componentSpu: getSeriesComponentSpuFromAssetSeries(
|
||||
selectedSeriesComponentAssetSeries,
|
||||
),
|
||||
finishedProductSpu: seriesProductFinishedSpu || null,
|
||||
finishedProductSpu: formalSpuValue || null,
|
||||
}
|
||||
: undefined;
|
||||
const draftProductName =
|
||||
@ -24290,10 +24465,14 @@ export default function NonIntegratedDetailClient({
|
||||
? seriesComponentSpu
|
||||
: isStockProductCreate
|
||||
? stockSpu
|
||||
: seriesProductFinishedSpu || (shouldSaveRemotely ? "" : estimatedFormalSpu);
|
||||
: isManualSeriesProductSpu
|
||||
? formalSpuValue
|
||||
: shouldSaveRemotely
|
||||
? ""
|
||||
: estimatedFormalSpu;
|
||||
let formalSpuAssignmentProductCategory = "";
|
||||
|
||||
if (!isStockProductCreate && !isSeriesComponentCreate && !seriesProductFinishedSpu) {
|
||||
if (!isStockProductCreate && !isSeriesComponentCreate && !isSeriesDevelopment) {
|
||||
const productCategory =
|
||||
formValues.productCategory.trim() ||
|
||||
openingReference.productCategory.trim();
|
||||
@ -30797,7 +30976,6 @@ function renderFactoryQualityWorkspace() {
|
||||
/>
|
||||
<BasicField
|
||||
label="品类"
|
||||
required={isSeriesDevelopment && !isSeriesComponentCreate}
|
||||
placeholder="如:桌类"
|
||||
options={basicInfoSecondaryCategoryOptions}
|
||||
value={
|
||||
@ -30815,7 +30993,6 @@ function renderFactoryQualityWorkspace() {
|
||||
/>
|
||||
<BasicField
|
||||
label="类目"
|
||||
required={isSeriesDevelopment && !isSeriesComponentCreate}
|
||||
placeholder="如:书桌"
|
||||
options={basicInfoProductCategoryOptions}
|
||||
value={
|
||||
@ -30834,10 +31011,24 @@ function renderFactoryQualityWorkspace() {
|
||||
{isSeriesDevelopment ? (
|
||||
<BasicField
|
||||
label="SPU"
|
||||
value={seriesCreateEstimatedSpuLabel}
|
||||
readOnly
|
||||
required={!isSeriesComponentCreate}
|
||||
placeholder={
|
||||
isSeriesComponentCreate
|
||||
? "选择系列后自动生成"
|
||||
: "请输入模块化SPU"
|
||||
}
|
||||
value={
|
||||
isSeriesComponentCreate
|
||||
? seriesCreateEstimatedSpuLabel
|
||||
: formValues.spu
|
||||
}
|
||||
readOnly={isReadOnly || isSeriesComponentCreate}
|
||||
compact
|
||||
onChange={() => undefined}
|
||||
onChange={
|
||||
isSeriesComponentCreate
|
||||
? () => undefined
|
||||
: updateManualSeriesProductSpu
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -31732,6 +31923,9 @@ function renderFactoryQualityWorkspace() {
|
||||
isSkuContentReadOnly ||
|
||||
isSkuItemFactoryConfirmed(item) ||
|
||||
isImportedSkuContentLocked(item);
|
||||
const isStockLinkedSkuRow =
|
||||
item.contentLocked === true &&
|
||||
isSeriesComponentAssetSkuItem(item);
|
||||
const skuImageReadOnly =
|
||||
isReadOnly ||
|
||||
isSkuItemFactoryConfirmed(item) ||
|
||||
@ -31828,6 +32022,12 @@ function renderFactoryQualityWorkspace() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="space-y-1">
|
||||
{isStockLinkedSkuRow ? (
|
||||
<span className="inline-flex rounded-[4px] bg-[#eef7f0] px-1.5 py-0.5 text-[10px] font-apple-semibold text-[#2d6a3f] ring-1 ring-[#cce8d2]">
|
||||
存量商品
|
||||
</span>
|
||||
) : null}
|
||||
<SkuRowField
|
||||
value={item.model}
|
||||
placeholder="请填写"
|
||||
@ -31847,6 +32047,7 @@ function renderFactoryQualityWorkspace() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<SkuRowField
|
||||
|
||||
@ -47,6 +47,14 @@ type AssemblyHoleVisualType =
|
||||
| 'assembly-candidate'
|
||||
| 'assembly-active'
|
||||
|
||||
interface VariantSkuGroupBrowserItem {
|
||||
key: string
|
||||
code: string
|
||||
name: string
|
||||
hasSkuGroup: boolean
|
||||
variants: AssistedAssemblyVariant[]
|
||||
}
|
||||
|
||||
const ASSEMBLY_HOLE_STATE_PRIORITY: Record<AssemblyHoleVisualType, number> = {
|
||||
'assembly-unused': 0,
|
||||
'assembly-used': 1,
|
||||
@ -239,6 +247,46 @@ function buildAvailableVariantMap(availableVariants: AssistedAssemblyVariant[])
|
||||
return map
|
||||
}
|
||||
|
||||
function normalizeSkuGroupCode(value: string | null | undefined) {
|
||||
const normalized = value?.trim().toUpperCase()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function buildVariantSkuGroupItems(variants: AssistedAssemblyVariant[]) {
|
||||
const groupMap = new Map<string, VariantSkuGroupBrowserItem>()
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const skuGroupCode = normalizeSkuGroupCode(variant.skuGroupCode)
|
||||
const skuGroupId = variant.skuGroupId?.trim() || null
|
||||
const key = skuGroupCode ? `code:${skuGroupCode}` : skuGroupId ? `group:${skuGroupId}` : `variant:${variant.id}`
|
||||
const fallbackCode = variant.code?.trim() || variant.name
|
||||
const current = groupMap.get(key)
|
||||
|
||||
if (current) {
|
||||
current.variants.push(variant)
|
||||
return
|
||||
}
|
||||
|
||||
groupMap.set(key, {
|
||||
key,
|
||||
code: skuGroupCode ?? fallbackCode,
|
||||
name: variant.skuGroupName?.trim() || variant.name,
|
||||
hasSkuGroup: Boolean(skuGroupCode || skuGroupId),
|
||||
variants: [variant],
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(groupMap.values())
|
||||
}
|
||||
|
||||
function getVariantSkuGroupAriaLabel(group: VariantSkuGroupBrowserItem) {
|
||||
const firstVariant = group.variants[0]
|
||||
if (!group.hasSkuGroup) {
|
||||
return `加载${firstVariant?.name ?? group.code}到 3D 世界`
|
||||
}
|
||||
return `加载货号${group.code}下${group.variants.length}个模型到 3D 世界`
|
||||
}
|
||||
|
||||
function getInventoryPreviewVariant(
|
||||
item: AssemblyWorkspaceInventoryItem,
|
||||
availableVariantMap: Map<string, AssistedAssemblyVariant[]>,
|
||||
@ -589,6 +637,11 @@ export function AssemblyWorkbenchV2({
|
||||
return availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []
|
||||
}, [availableVariantMap, selectedBrowserInventoryItem])
|
||||
|
||||
const selectedBrowserVariantGroups = useMemo(
|
||||
() => buildVariantSkuGroupItems(selectedBrowserVariants),
|
||||
[selectedBrowserVariants],
|
||||
)
|
||||
|
||||
const availableVariantById = useMemo(
|
||||
() => new Map(availableVariants.map((variant) => [variant.id, variant])),
|
||||
[availableVariants],
|
||||
@ -889,27 +942,26 @@ export function AssemblyWorkbenchV2({
|
||||
[closeContextMenu, pushWorkspace, readOnly],
|
||||
)
|
||||
|
||||
const handleVariantActivate = useCallback(
|
||||
(inventoryId: string, variant: AssistedAssemblyVariant) => {
|
||||
if (readOnly) return
|
||||
const handleVariantGroupActivate = useCallback(
|
||||
(inventoryId: string, group: VariantSkuGroupBrowserItem) => {
|
||||
if (readOnly || group.variants.length === 0) return
|
||||
const current = workspaceRef.current
|
||||
const inventoryItem = current.inventory.find((item) => item.inventoryId === inventoryId)
|
||||
if (!inventoryItem) return
|
||||
|
||||
const withSelection =
|
||||
inventoryItem.selectedVariantId === variant.id
|
||||
? current
|
||||
: updateInventoryVariantSelection(current, inventoryId, variant.id)
|
||||
|
||||
const next = instantiateAssemblyNode(withSelection, {
|
||||
let next = current
|
||||
group.variants.forEach((variant) => {
|
||||
next = updateInventoryVariantSelection(next, inventoryId, variant.id)
|
||||
next = instantiateAssemblyNode(next, {
|
||||
inventoryId,
|
||||
variant: toOpenProductVariant(variant),
|
||||
})
|
||||
})
|
||||
|
||||
pushWorkspace(next)
|
||||
const latestNode = next.nodes[next.nodes.length - 1]
|
||||
const addedNodeIds = next.nodes.slice(current.nodes.length).map((node) => node.nodeId)
|
||||
suppressEmptySelectionUntilRef.current = Date.now() + 320
|
||||
setSelectedNodeIds(latestNode ? [latestNode.nodeId] : [])
|
||||
setSelectedNodeIds(addedNodeIds)
|
||||
setSelectedInventoryId(inventoryId)
|
||||
setActivePreset(null)
|
||||
closeContextMenu()
|
||||
@ -1184,7 +1236,7 @@ export function AssemblyWorkbenchV2({
|
||||
updateAssetBrowserVariantPanelFrame(
|
||||
selectedBrowserInventoryItem?.inventoryId ?? null,
|
||||
undefined,
|
||||
selectedBrowserVariants.length,
|
||||
selectedBrowserVariantGroups.length,
|
||||
)
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
@ -1195,7 +1247,7 @@ export function AssemblyWorkbenchV2({
|
||||
activeAssetBrowserSource,
|
||||
activeAssetBrowserItems.length,
|
||||
selectedBrowserInventoryItem,
|
||||
selectedBrowserVariants.length,
|
||||
selectedBrowserVariantGroups.length,
|
||||
updateAssetBrowserPanelFrame,
|
||||
updateAssetBrowserVariantPanelFrame,
|
||||
])
|
||||
@ -1217,12 +1269,12 @@ export function AssemblyWorkbenchV2({
|
||||
updateAssetBrowserVariantPanelFrame(
|
||||
selectedBrowserInventoryItem.inventoryId,
|
||||
undefined,
|
||||
selectedBrowserVariants.length,
|
||||
selectedBrowserVariantGroups.length,
|
||||
)
|
||||
}, [
|
||||
activeAssetBrowserSource,
|
||||
selectedBrowserInventoryItem,
|
||||
selectedBrowserVariants.length,
|
||||
selectedBrowserVariantGroups.length,
|
||||
updateAssetBrowserVariantPanelFrame,
|
||||
])
|
||||
|
||||
@ -1344,6 +1396,7 @@ export function AssemblyWorkbenchV2({
|
||||
const previewVariant = getInventoryPreviewVariant(item, availableVariantMap)
|
||||
const selected = selectedBrowserInventoryItem?.inventoryId === item.inventoryId
|
||||
const itemVariants = availableVariantMap.get(item.familyId) ?? []
|
||||
const itemVariantGroups = buildVariantSkuGroupItems(itemVariants)
|
||||
const SourceIcon =
|
||||
ASSET_BROWSER_TABS.find((tab) => tab.source === item.source)?.icon ?? Layers3
|
||||
return (
|
||||
@ -1360,13 +1413,13 @@ export function AssemblyWorkbenchV2({
|
||||
aria-label={`选择${item.familyName}资产族`}
|
||||
aria-pressed={selected}
|
||||
onMouseEnter={(event) =>
|
||||
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariants.length)
|
||||
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariantGroups.length)
|
||||
}
|
||||
onFocus={(event) =>
|
||||
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariants.length)
|
||||
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariantGroups.length)
|
||||
}
|
||||
onClick={(event) =>
|
||||
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariants.length)
|
||||
handleBrowserInventorySelect(item.inventoryId, event.currentTarget, itemVariantGroups.length)
|
||||
}
|
||||
className={cn(
|
||||
'group relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf1f2] text-left transition duration-200 focus:outline-none',
|
||||
@ -1403,7 +1456,7 @@ export function AssemblyWorkbenchV2({
|
||||
{item.familyName}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[8px] font-bold text-white/58">
|
||||
{itemVariants.length} 款 · 入场 {item.instanceCount}
|
||||
{itemVariantGroups.length} 货号 · 入场 {item.instanceCount}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@ -1425,19 +1478,22 @@ export function AssemblyWorkbenchV2({
|
||||
className="absolute left-[104px] w-[184px] overflow-y-auto overscroll-contain pr-1"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []).length > 0 ? (
|
||||
(availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []).map((variant) => (
|
||||
{selectedBrowserVariantGroups.length > 0 ? (
|
||||
selectedBrowserVariantGroups.map((group) => {
|
||||
const previewVariant = group.variants[0]
|
||||
const isSingleFallback = !group.hasSkuGroup && group.variants.length === 1
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
key={group.key}
|
||||
type="button"
|
||||
aria-label={`加载${variant.name}到 3D 世界`}
|
||||
aria-label={getVariantSkuGroupAriaLabel(group)}
|
||||
disabled={readOnly}
|
||||
onClick={() => handleVariantActivate(selectedBrowserInventoryItem.inventoryId, variant)}
|
||||
onClick={() => handleVariantGroupActivate(selectedBrowserInventoryItem.inventoryId, group)}
|
||||
className="group relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded-[6px] bg-[#edf1f2] text-left opacity-[0.82] transition duration-200 hover:-translate-y-0.5 hover:opacity-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:translate-y-0"
|
||||
>
|
||||
<AssemblyMaterialImage
|
||||
sources={getAssistedAssemblyVariantThumbnailSources(variant)}
|
||||
alt={variant.name}
|
||||
sources={getAssistedAssemblyVariantThumbnailSources(previewVariant)}
|
||||
alt={previewVariant?.name ?? group.name}
|
||||
className="absolute inset-0 h-full w-full object-cover object-center transition duration-200 group-hover:scale-[1.03]"
|
||||
placeholder={
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#edf1f2]">
|
||||
@ -1448,14 +1504,20 @@ export function AssemblyWorkbenchV2({
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#2d3335]/78 via-[#2d3335]/20 to-transparent opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
<div className="relative flex h-full flex-col justify-end px-2 py-2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<p className="truncate text-[8px] font-black uppercase tracking-[0.08em] text-white/66">
|
||||
{getVariantMeta(variant)}
|
||||
{isSingleFallback && previewVariant ? getVariantMeta(previewVariant) : '货号'}
|
||||
</p>
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] font-black leading-[1.15] text-white">
|
||||
{variant.name}
|
||||
{isSingleFallback && previewVariant ? previewVariant.name : group.code}
|
||||
</p>
|
||||
{!isSingleFallback ? (
|
||||
<p className="mt-0.5 truncate text-[8px] font-bold text-white/62">
|
||||
{group.variants.length} 模型
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="col-span-2 flex h-[86px] w-[180px] items-center justify-center rounded-[6px] bg-white px-3 text-center text-xs font-bold text-[#5a6062]/55 ring-1 ring-black/[0.04]">
|
||||
当前资产族暂无可加载规格款
|
||||
|
||||
@ -406,10 +406,12 @@ function ControlledWorkbench({
|
||||
onSnapshotChange,
|
||||
rightDrawerContent,
|
||||
initialWorkspace,
|
||||
availableVariants = AVAILABLE_VARIANTS,
|
||||
}: {
|
||||
onSnapshotChange?: (next: AssemblyWorkspaceSnapshot) => void
|
||||
rightDrawerContent?: ReactNode
|
||||
initialWorkspace?: AssemblyWorkspaceSnapshot
|
||||
availableVariants?: AssistedAssemblyVariant[]
|
||||
}) {
|
||||
const [workspace, setWorkspace] = useState(() => initialWorkspace ?? buildWorkspace())
|
||||
|
||||
@ -420,7 +422,7 @@ function ControlledWorkbench({
|
||||
candidateName="测试工作台"
|
||||
statusTag="辅助装配工作台"
|
||||
warnings={[]}
|
||||
availableVariants={AVAILABLE_VARIANTS}
|
||||
availableVariants={availableVariants}
|
||||
onChange={(next) => {
|
||||
setWorkspace(next)
|
||||
onSnapshotChange?.(next)
|
||||
@ -430,6 +432,33 @@ function ControlledWorkbench({
|
||||
)
|
||||
}
|
||||
|
||||
function buildSkuGroupedVariants(): AssistedAssemblyVariant[] {
|
||||
const baseVariants = buildVariants()
|
||||
const legVariant = baseVariants[1]!
|
||||
|
||||
return [
|
||||
baseVariants[0]!,
|
||||
{
|
||||
...legVariant,
|
||||
id: 'variant-leg-sku-group-1',
|
||||
code: 'LEG-GROUP-001',
|
||||
name: '桌腿货号模型一',
|
||||
skuGroupId: 'sku-group-dnz038401-a',
|
||||
skuGroupCode: 'DNZ038401',
|
||||
skuGroupName: 'DNZ038401',
|
||||
} as unknown as AssistedAssemblyVariant,
|
||||
{
|
||||
...legVariant,
|
||||
id: 'variant-leg-sku-group-2',
|
||||
code: 'LEG-GROUP-002',
|
||||
name: '桌腿货号模型二',
|
||||
skuGroupId: 'sku-group-dnz038401-b',
|
||||
skuGroupCode: 'DNZ038401',
|
||||
skuGroupName: 'DNZ038401',
|
||||
} as unknown as AssistedAssemblyVariant,
|
||||
]
|
||||
}
|
||||
|
||||
function buildHoleSnapWorkspace() {
|
||||
const baseWorkspace = buildWorkspace()
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
@ -917,6 +946,46 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
expect(latestSnapshot.nodes[1]?.zone).toBe('staging')
|
||||
})
|
||||
|
||||
it('按货号展示规格组,点击货号会一次加载该货号下所有模型', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<ControlledWorkbench
|
||||
availableVariants={buildSkuGroupedVariants()}
|
||||
onSnapshotChange={onChange}
|
||||
rightDrawerContent={<div>右侧业务区</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '打开组件物料' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '选择桌腿A资产族' })).toBeInTheDocument()
|
||||
})
|
||||
await actor.click(screen.getByRole('button', { name: '选择桌腿A资产族' }))
|
||||
|
||||
const skuGroupButton = screen.getByRole('button', {
|
||||
name: '加载货号DNZ038401下2个模型到 3D 世界',
|
||||
})
|
||||
expect(skuGroupButton).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '加载桌腿货号模型一到 3D 世界' })).not.toBeInTheDocument()
|
||||
|
||||
await actor.click(skuGroupButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
|
||||
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
|
||||
})
|
||||
|
||||
const latestSnapshot = onChange.mock.calls.at(-1)?.[0] as AssemblyWorkspaceSnapshot
|
||||
expect(latestSnapshot.nodes).toHaveLength(2)
|
||||
expect(latestSnapshot.nodes.map((node) => node.variantId)).toEqual([
|
||||
'variant-leg-sku-group-1',
|
||||
'variant-leg-sku-group-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('带孔位装配语义的世界节点会保留模型原点,避免孔位坐标和模型实体错位', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ export const SUPPORTED_FORMATS = ['.gltf', '.glb'] as const
|
||||
export const MAX_FILE_SIZE_MB = 100
|
||||
|
||||
interface ModelUploaderProps {
|
||||
onUpload: (file: File) => void
|
||||
onUpload: (file: File) => void | Promise<void>
|
||||
onRemove?: () => void
|
||||
onValidationError?: (error: FileValidationError) => void
|
||||
accept?: string
|
||||
@ -63,26 +63,31 @@ export function ModelUploader({
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [error, setError] = useState<FileValidationError | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const allowedFormats = accept
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.startsWith('.'))
|
||||
const effectiveSupportedFormats = allowedFormats.length > 0 ? allowedFormats : [...SUPPORTED_FORMATS]
|
||||
|
||||
/**
|
||||
* 验证文件格式
|
||||
* 只接受 .gltf 和 .glb 扩展名
|
||||
* 默认只接受 .gltf 和 .glb,可由调用方通过 accept 扩展。
|
||||
*/
|
||||
const validateFileFormat = useCallback((file: File): FileValidationError | null => {
|
||||
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'))
|
||||
|
||||
if (!SUPPORTED_FORMATS.includes(extension as typeof SUPPORTED_FORMATS[number])) {
|
||||
if (!effectiveSupportedFormats.includes(extension)) {
|
||||
return {
|
||||
code: 'INVALID_FORMAT',
|
||||
message: `不支持的文件格式 "${extension}",请上传 GLTF 或 GLB 格式的 3D 模型文件`,
|
||||
message: `不支持的文件格式 "${extension}",请上传支持的 3D 模型或 CAD ZIP 文件`,
|
||||
details: {
|
||||
allowedFormats: [...SUPPORTED_FORMATS],
|
||||
allowedFormats: effectiveSupportedFormats,
|
||||
actualFormat: extension,
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
}, [effectiveSupportedFormats])
|
||||
|
||||
/**
|
||||
* 验证文件大小
|
||||
@ -152,7 +157,14 @@ export function ModelUploader({
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
onUpload(file)
|
||||
void Promise.resolve(onUpload(file)).catch((uploadError) => {
|
||||
const uploadValidationError: FileValidationError = {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: uploadError instanceof Error ? uploadError.message : '文件上传失败',
|
||||
}
|
||||
setError(uploadValidationError)
|
||||
onValidationError?.(uploadValidationError)
|
||||
})
|
||||
}, [validateFile, onUpload, onValidationError])
|
||||
|
||||
// 拖拽事件处理
|
||||
@ -294,8 +306,14 @@ export function ModelUploader({
|
||||
|
||||
{/* 支持的格式 */}
|
||||
<div className="flex items-center justify-center gap-3 text-xs text-apple-label-tertiary mt-2">
|
||||
<span className="px-1.5 py-0.5 bg-apple-bg-secondary rounded-apple-sm">.gltf</span>
|
||||
<span className="px-1.5 py-0.5 bg-apple-bg-secondary rounded-apple-sm">.glb</span>
|
||||
{effectiveSupportedFormats.map((format) => (
|
||||
<span
|
||||
key={format}
|
||||
className="px-1.5 py-0.5 bg-apple-bg-secondary rounded-apple-sm"
|
||||
>
|
||||
{format}
|
||||
</span>
|
||||
))}
|
||||
<span>最大 {maxSize}MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
255
furniture-3d-system/src/lib/asset-cad-variant-payload.ts
Normal file
255
furniture-3d-system/src/lib/asset-cad-variant-payload.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import type { CADExportData } from '@/lib/cad-export-processor'
|
||||
import { buildPersistedSkuCadData, type PersistedSkuCadData } from '@/lib/sku-cad-data'
|
||||
|
||||
export type CadAssetPayloadType = 'BOARD' | 'COMPONENT' | 'HARDWARE'
|
||||
|
||||
function toSafeNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function toVector3(value: unknown, fallback: [number, number, number] = [0, 0, 0]): [number, number, number] {
|
||||
if (!Array.isArray(value) || value.length < 3) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return [
|
||||
toSafeNumber(value[0]),
|
||||
toSafeNumber(value[1]),
|
||||
toSafeNumber(value[2]),
|
||||
]
|
||||
}
|
||||
|
||||
function getModelBoundsDimensions(modelBounds: unknown): [number, number, number] | null {
|
||||
if (!Array.isArray(modelBounds) || modelBounds.length < 6) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [
|
||||
Math.abs(toSafeNumber(modelBounds[3]) - toSafeNumber(modelBounds[0])),
|
||||
Math.abs(toSafeNumber(modelBounds[4]) - toSafeNumber(modelBounds[1])),
|
||||
Math.abs(toSafeNumber(modelBounds[5]) - toSafeNumber(modelBounds[2])),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildCadDataFromExport(cadExportData: CADExportData): PersistedSkuCadData | null {
|
||||
return buildPersistedSkuCadData({
|
||||
pluginVersion: cadExportData.pluginVersion,
|
||||
units: cadExportData.units,
|
||||
coordinateSystem: cadExportData.coordinateSystem,
|
||||
panels: cadExportData.panels,
|
||||
holes: cadExportData.holes,
|
||||
holeGroups: (cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
|
||||
snapPatterns: cadExportData.snapPatterns,
|
||||
snapSurfaces: cadExportData.snapSurfaces,
|
||||
mountPatterns: (cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
|
||||
hardwareTypes: cadExportData.hardwareTypes,
|
||||
hardwareSummary: cadExportData.hardwareSummary,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildCadAssetDimensions(params: {
|
||||
assetType: CadAssetPayloadType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
warnings?: string[]
|
||||
}): Record<string, number> {
|
||||
if (params.assetType === 'BOARD') {
|
||||
const panel = params.cadData?.panels[0]
|
||||
if (panel) {
|
||||
if ((params.cadData?.panels.length ?? 0) > 1) {
|
||||
params.warnings?.push('检测到多个板件,已按第一块板件尺寸导入规格款。')
|
||||
}
|
||||
return {
|
||||
length: panel.size[0],
|
||||
width: panel.size[1],
|
||||
thickness: panel.size[2],
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
|
||||
if (bounds) {
|
||||
params.warnings?.push('CAD 数据未提供板件尺寸,已按模型包围盒估算板材尺寸。')
|
||||
return {
|
||||
length: bounds[0],
|
||||
width: bounds[1],
|
||||
thickness: bounds[2],
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('板材导入至少需要一块板件或可用的模型包围盒尺寸')
|
||||
}
|
||||
|
||||
if (params.assetType === 'COMPONENT') {
|
||||
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
|
||||
if (bounds) {
|
||||
return {
|
||||
length: bounds[0],
|
||||
width: bounds[1],
|
||||
height: bounds[2],
|
||||
}
|
||||
}
|
||||
|
||||
const panel = params.cadData?.panels[0]
|
||||
if (panel) {
|
||||
params.warnings?.push('组件导入未提供整体包围盒,已按第一块板件尺寸回填组件尺寸。')
|
||||
return {
|
||||
length: panel.size[0],
|
||||
width: panel.size[1],
|
||||
height: panel.size[2],
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('组件导入缺少可用尺寸信息,请确认导出包包含包围盒或板件数据')
|
||||
}
|
||||
|
||||
const signature = params.cadExportData.hardwareTypes?.[0]?.signature
|
||||
if (signature) {
|
||||
return {
|
||||
width: signature.width,
|
||||
height: signature.height,
|
||||
depth: signature.depth,
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
|
||||
if (bounds) {
|
||||
params.warnings?.push('五金导入未提供规格签名,已按模型包围盒估算尺寸。')
|
||||
return {
|
||||
width: bounds[0],
|
||||
height: bounds[1],
|
||||
depth: bounds[2],
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('五金导入缺少可用尺寸信息,请确认导出包包含五金签名或模型包围盒')
|
||||
}
|
||||
|
||||
export function buildCadAssetSizeLabel(
|
||||
assetType: CadAssetPayloadType,
|
||||
dimensions: Record<string, number>,
|
||||
): string | null {
|
||||
if (assetType === 'BOARD') {
|
||||
const { length, width, thickness } = dimensions
|
||||
if (length && width && thickness) {
|
||||
return `${length}x${width}x${thickness}`
|
||||
}
|
||||
}
|
||||
|
||||
if (assetType === 'COMPONENT') {
|
||||
const { length, width, height } = dimensions
|
||||
if (length && width && height) {
|
||||
return `${length}x${width}x${height}`
|
||||
}
|
||||
}
|
||||
|
||||
if (assetType === 'HARDWARE') {
|
||||
const { width, height, depth } = dimensions
|
||||
if (width && height && depth) {
|
||||
return `${width}x${height}x${depth}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildCadAssetMountPoints(cadExportData: CADExportData) {
|
||||
return [
|
||||
{
|
||||
name: 'origin',
|
||||
position: toVector3(cadExportData.originOffset),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildCadAssetAssemblyBasis(params: {
|
||||
assetType: CadAssetPayloadType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
}): Record<string, unknown> {
|
||||
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
|
||||
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
|
||||
const snapPatterns =
|
||||
params.assetType === 'HARDWARE'
|
||||
? []
|
||||
: (params.cadData?.snapPatterns?.length
|
||||
? params.cadData.snapPatterns
|
||||
: (params.cadData?.mountPatterns ?? []))
|
||||
const mountPatterns =
|
||||
params.assetType === 'HARDWARE'
|
||||
? []
|
||||
: (params.cadData?.mountPatterns?.length ? params.cadData.mountPatterns : snapPatterns)
|
||||
const snapSurfaces = params.assetType === 'HARDWARE' ? [] : (params.cadData?.snapSurfaces ?? [])
|
||||
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
|
||||
const suggestedFastenerCount = holes.reduce(
|
||||
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return {
|
||||
source: 'CAD_SINGLE_ASSET_IMPORT',
|
||||
assetType: params.assetType,
|
||||
pluginVersion: params.cadExportData.pluginVersion,
|
||||
units: params.cadExportData.units,
|
||||
sourceFile: params.cadExportData.sourceFile,
|
||||
panelCount: params.cadData?.panels.length ?? 0,
|
||||
holeCount: holes.length,
|
||||
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
|
||||
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
|
||||
snapPatternCount: snapPatterns.length,
|
||||
snapSurfaceCount: snapSurfaces.length,
|
||||
mountPatternCount: mountPatterns.length,
|
||||
suggestedFastenerCount,
|
||||
holes,
|
||||
holeGroups,
|
||||
snapPatterns,
|
||||
snapSurfaces,
|
||||
mountPatterns,
|
||||
edgeFeatureCount: edgeFeatures.length,
|
||||
edgeFeatures,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCadAssetBusinessData(params: {
|
||||
assetType: CadAssetPayloadType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
zipFileName: string
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
importSource: {
|
||||
mode: 'CAD_SINGLE_ASSET_IMPORT',
|
||||
assetType: params.assetType,
|
||||
zipFileName: params.zipFileName,
|
||||
sourceFile: params.cadExportData.sourceFile,
|
||||
exportedAt: params.cadExportData.exportedAt,
|
||||
pluginVersion: params.cadExportData.pluginVersion,
|
||||
software: params.cadExportData.software,
|
||||
exportMode:
|
||||
typeof (params.cadExportData as CADExportData & { exportMode?: string }).exportMode === 'string'
|
||||
? (params.cadExportData as CADExportData & { exportMode?: string }).exportMode
|
||||
: null,
|
||||
assetTypeHint:
|
||||
typeof (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint === 'string'
|
||||
? (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint
|
||||
: null,
|
||||
},
|
||||
modelBounds: Array.isArray(params.cadExportData.modelBounds)
|
||||
? params.cadExportData.modelBounds
|
||||
: null,
|
||||
cadData: params.cadData,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCadAssetNamingFields(params: {
|
||||
assetType: CadAssetPayloadType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
assetType: params.assetType,
|
||||
sourceFile: params.cadExportData.sourceFile,
|
||||
modelFile: params.cadExportData.modelFile,
|
||||
panelType: params.cadData?.panels[0]?.panelType ?? null,
|
||||
hardwareType: params.cadData?.hardwareTypes[0]?.inferredHardwareType ?? null,
|
||||
}
|
||||
}
|
||||
@ -558,7 +558,7 @@ export const FORMAL_WORKSPACE_PERMISSION_TARGETS: IntakeNodePermissionTarget[] =
|
||||
},
|
||||
{
|
||||
key: "seriesDevelopmentList",
|
||||
label: "系列产品开发明细",
|
||||
label: "模块化产品明细",
|
||||
defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT,
|
||||
},
|
||||
{
|
||||
|
||||
@ -45,6 +45,7 @@ describe('manual-assembly-launch', () => {
|
||||
expect(launchParams).toEqual({
|
||||
entryMode: 'direct',
|
||||
source: 'assisted-assembly',
|
||||
assetSeriesId: null,
|
||||
sourceSkuId: null,
|
||||
sourceProductId: 'product-1',
|
||||
redirectedFrom: 'product-assembly',
|
||||
@ -88,4 +89,47 @@ describe('manual-assembly-launch', () => {
|
||||
})
|
||||
expect(buildManualAssemblyLegacyFeedback(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps sku group ids when asset center opens assisted assembly by item code', () => {
|
||||
const href = buildManualAssemblyLaunchHref({
|
||||
entryMode: 'asset-center',
|
||||
source: 'asset-center',
|
||||
assetSeriesId: 'series-1',
|
||||
selectedFamilyIdsByType: {
|
||||
desktopFamilyIds: [],
|
||||
legFamilyIds: ['family-leg'],
|
||||
connectorFamilyIds: [],
|
||||
},
|
||||
selectedSkuGroupIds: ['sku-group-1', 'sku-group-2'],
|
||||
selectedSkuGroupCodes: ['DNZ038401'],
|
||||
})
|
||||
|
||||
expect(href).toBe(
|
||||
'/assisted-assembly?entryMode=asset-center&source=asset-center&assetSeriesId=series-1&legFamilyIds=family-leg&skuGroupIds=sku-group-1%2Csku-group-2&skuGroupCodes=DNZ038401',
|
||||
)
|
||||
|
||||
expect(
|
||||
parseManualAssemblyLaunchParams(
|
||||
createSearchParamReader(
|
||||
'entryMode=asset-center&source=asset-center&assetSeriesId=series-1&legFamilyIds=family-leg&skuGroupIds=sku-group-1,sku-group-2&skuGroupCodes=dnz038401',
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
entryMode: 'asset-center',
|
||||
source: 'asset-center',
|
||||
assetSeriesId: 'series-1',
|
||||
sourceSkuId: null,
|
||||
sourceProductId: null,
|
||||
selectedFamilyIdsByType: {
|
||||
desktopFamilyIds: [],
|
||||
legFamilyIds: ['family-leg'],
|
||||
connectorFamilyIds: [],
|
||||
},
|
||||
selectedSkuGroupIds: ['sku-group-1', 'sku-group-2'],
|
||||
selectedSkuGroupCodes: ['DNZ038401'],
|
||||
redirectedFrom: null,
|
||||
productRecovery: null,
|
||||
recoverableSourceSkuCount: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -21,6 +21,8 @@ export interface ManualAssemblyLaunchParams {
|
||||
sourceSkuId?: string | null
|
||||
sourceProductId?: string | null
|
||||
selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType
|
||||
selectedSkuGroupIds?: string[]
|
||||
selectedSkuGroupCodes?: string[]
|
||||
redirectedFrom?: ManualAssemblyLegacyEntry | null
|
||||
productRecovery?: ManualAssemblyProductRecovery | null
|
||||
recoverableSourceSkuCount?: number | null
|
||||
@ -117,6 +119,14 @@ export function buildManualAssemblyLaunchHref(
|
||||
searchParams.set('connectorFamilyIds', params.selectedFamilyIdsByType.connectorFamilyIds.join(','))
|
||||
}
|
||||
|
||||
if (params.selectedSkuGroupIds && params.selectedSkuGroupIds.length > 0) {
|
||||
searchParams.set('skuGroupIds', params.selectedSkuGroupIds.join(','))
|
||||
}
|
||||
|
||||
if (params.selectedSkuGroupCodes && params.selectedSkuGroupCodes.length > 0) {
|
||||
searchParams.set('skuGroupCodes', params.selectedSkuGroupCodes.join(','))
|
||||
}
|
||||
|
||||
return `/assisted-assembly?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
@ -128,10 +138,16 @@ export function parseManualAssemblyLaunchParams(
|
||||
legFamilyIds: normalizeIds(searchParams.get('legFamilyIds')),
|
||||
connectorFamilyIds: normalizeIds(searchParams.get('connectorFamilyIds')),
|
||||
}
|
||||
const selectedSkuGroupIds = normalizeIds(searchParams.get('skuGroupIds'))
|
||||
const selectedSkuGroupCodes = normalizeIds(searchParams.get('skuGroupCodes')).map((code) =>
|
||||
code.toUpperCase(),
|
||||
)
|
||||
const hasSelectedFamilies =
|
||||
selectedFamilyIdsByType.desktopFamilyIds.length > 0 ||
|
||||
selectedFamilyIdsByType.legFamilyIds.length > 0 ||
|
||||
selectedFamilyIdsByType.connectorFamilyIds.length > 0
|
||||
const hasSelectedSkuGroups =
|
||||
selectedSkuGroupIds.length > 0 || selectedSkuGroupCodes.length > 0
|
||||
|
||||
const entryModeValue = searchParams.get('entryMode')?.trim()
|
||||
const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null
|
||||
@ -147,7 +163,13 @@ export function parseManualAssemblyLaunchParams(
|
||||
? entryModeValue
|
||||
: 'direct'
|
||||
|
||||
if (entryMode === 'direct' && !hasSelectedFamilies && !sourceSkuId && !redirectedFrom) {
|
||||
if (
|
||||
entryMode === 'direct' &&
|
||||
!hasSelectedFamilies &&
|
||||
!hasSelectedSkuGroups &&
|
||||
!sourceSkuId &&
|
||||
!redirectedFrom
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -157,7 +179,7 @@ export function parseManualAssemblyLaunchParams(
|
||||
? 'asset-center'
|
||||
: 'assisted-assembly'
|
||||
|
||||
return {
|
||||
const launchParams: ManualAssemblyLaunchParams = {
|
||||
entryMode,
|
||||
source,
|
||||
assetSeriesId,
|
||||
@ -168,6 +190,16 @@ export function parseManualAssemblyLaunchParams(
|
||||
productRecovery,
|
||||
recoverableSourceSkuCount,
|
||||
}
|
||||
|
||||
if (selectedSkuGroupIds.length > 0) {
|
||||
launchParams.selectedSkuGroupIds = selectedSkuGroupIds
|
||||
}
|
||||
|
||||
if (selectedSkuGroupCodes.length > 0) {
|
||||
launchParams.selectedSkuGroupCodes = selectedSkuGroupCodes
|
||||
}
|
||||
|
||||
return launchParams
|
||||
}
|
||||
|
||||
export function buildManualAssemblyLegacyFeedback(
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
AssetCadImportResult,
|
||||
AssetSeriesDTO,
|
||||
AssetSeriesComponentDetailSummaryDTO,
|
||||
AssetSkuGroupDTO,
|
||||
AssetVariantDTO,
|
||||
AssetVariantBindingDTO,
|
||||
PreSkuBindingDTO,
|
||||
@ -14,6 +15,7 @@ import type {
|
||||
AssetOperationProgressDTO,
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetSeriesInput,
|
||||
CreateAssetSkuGroupInput,
|
||||
CreateAssetVariantInput,
|
||||
CreateDraftGroupFromManualAssemblyInput,
|
||||
CreateSubstrateDictionaryInput,
|
||||
@ -36,6 +38,7 @@ import type {
|
||||
UpdatePreSkuBasicInfoInput,
|
||||
WarehouseLocationDTO,
|
||||
UpdateAssetSeriesInput,
|
||||
UpdateAssetSkuGroupInput,
|
||||
AssistedAssemblyAiRenderDTO,
|
||||
} from '@/server/services'
|
||||
import type { MaterialDTO } from '@/server/services'
|
||||
@ -165,6 +168,50 @@ export async function listAssetVariantsByFamilyIds(
|
||||
return data.variants
|
||||
}
|
||||
|
||||
export async function listAssetSkuGroupsByFamilyIds(
|
||||
familyIds: string[],
|
||||
): Promise<AssetSkuGroupDTO[]> {
|
||||
if (familyIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
familyIds.forEach((familyId) => {
|
||||
searchParams.append('familyId', familyId)
|
||||
})
|
||||
const data = await requestPlatform<{ skuGroups: AssetSkuGroupDTO[] }>(
|
||||
`/api/asset-center/sku-groups?${searchParams.toString()}`,
|
||||
)
|
||||
return data.skuGroups
|
||||
}
|
||||
|
||||
export async function createAssetSkuGroup(
|
||||
input: CreateAssetSkuGroupInput,
|
||||
): Promise<AssetSkuGroupDTO> {
|
||||
const data = await requestPlatform<{ skuGroup: AssetSkuGroupDTO }>('/api/asset-center/sku-groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
return data.skuGroup
|
||||
}
|
||||
|
||||
export async function updateAssetSkuGroup(
|
||||
id: string,
|
||||
input: UpdateAssetSkuGroupInput,
|
||||
): Promise<AssetSkuGroupDTO> {
|
||||
const data = await requestPlatform<{ skuGroup: AssetSkuGroupDTO }>(`/api/asset-center/sku-groups/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
return data.skuGroup
|
||||
}
|
||||
|
||||
export async function deleteAssetSkuGroup(id: string): Promise<void> {
|
||||
await requestPlatform<{ success: boolean }>(`/api/asset-center/sku-groups/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAssetFamily(
|
||||
id: string,
|
||||
input: Record<string, unknown>,
|
||||
|
||||
@ -28,6 +28,9 @@ export interface OpenProductAssetFamily {
|
||||
export interface OpenProductAssetVariant {
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
skuGroupId?: string | null
|
||||
skuGroupCode?: string | null
|
||||
skuGroupName?: string | null
|
||||
code: string
|
||||
name: string
|
||||
assetType: OpenProductAssetType
|
||||
|
||||
@ -61,12 +61,12 @@ export const ROLE_WEIGHTS = {
|
||||
// ============ 文件上传 ============
|
||||
|
||||
export const UPLOAD = {
|
||||
/** 最大文件大小:50MB */
|
||||
MAX_FILE_SIZE: 50 * 1024 * 1024,
|
||||
/** 最大文件大小:200MB */
|
||||
MAX_FILE_SIZE: 200 * 1024 * 1024,
|
||||
/** 允许的图片类型 */
|
||||
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
|
||||
/** 允许的模型类型 */
|
||||
ALLOWED_MODEL_TYPES: ['.gltf', '.glb', '.dwg'],
|
||||
ALLOWED_MODEL_TYPES: ['.gltf', '.glb', '.obj', '.dwg'],
|
||||
/** 缩略图尺寸 */
|
||||
THUMBNAIL_SIZE: { width: 300, height: 300 },
|
||||
} as const
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client'
|
||||
import { AssetFamily, AssetSeries, AssetSkuGroup, AssetVariant, Prisma, Substrate } from '@prisma/client'
|
||||
import { BaseRepository, FindManyOptions } from './base.repository'
|
||||
import {
|
||||
buildDeletedAssetVariantUniqueValue,
|
||||
@ -72,6 +72,23 @@ export interface AssetVariantFilter {
|
||||
finishId?: string
|
||||
}
|
||||
|
||||
export interface AssetSkuGroupFilter {
|
||||
assetFamilyId?: string
|
||||
}
|
||||
|
||||
export interface AssetSkuGroupWithRelations extends AssetSkuGroup {
|
||||
assetFamily: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
type: AssetFamily['type']
|
||||
assetSeriesId: string | null
|
||||
deletedAt: Date | null
|
||||
assetSeries: { id: string; code: string; name: string } | null
|
||||
}
|
||||
_count?: { variants: number }
|
||||
}
|
||||
|
||||
export interface AssetVariantWithRelations extends AssetVariant {
|
||||
assetFamily: {
|
||||
id: string
|
||||
@ -82,6 +99,15 @@ export interface AssetVariantWithRelations extends AssetVariant {
|
||||
deletedAt: Date | null
|
||||
assetSeries: { id: string; code: string; name: string } | null
|
||||
}
|
||||
skuGroup: {
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
metadata: Prisma.JsonValue | null
|
||||
} | null
|
||||
substrate: { id: string; code: string; name: string } | null
|
||||
finish: AssetCenterFinishRelation | null
|
||||
creator: { id: string; name: string } | null
|
||||
@ -155,6 +181,17 @@ export const assetVariantRelationInclude = {
|
||||
assetSeries: { select: { id: true, code: true, name: true, metadata: true } },
|
||||
},
|
||||
},
|
||||
skuGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
assetFamilyId: true,
|
||||
code: true,
|
||||
name: true,
|
||||
description: true,
|
||||
sortOrder: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
substrate: { select: { id: true, code: true, name: true } },
|
||||
finish: {
|
||||
select: {
|
||||
@ -184,6 +221,21 @@ export const assetVariantRelationInclude = {
|
||||
lastModifier: { select: { id: true, name: true } },
|
||||
} satisfies Prisma.AssetVariantInclude
|
||||
|
||||
export const assetSkuGroupRelationInclude = {
|
||||
assetFamily: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
type: true,
|
||||
assetSeriesId: true,
|
||||
deletedAt: true,
|
||||
assetSeries: { select: { id: true, code: true, name: true, metadata: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { variants: true } },
|
||||
} satisfies Prisma.AssetSkuGroupInclude
|
||||
|
||||
export const substrateRelationInclude = {
|
||||
category: { select: { id: true, name: true } },
|
||||
environmentGrade: { select: { id: true, name: true } },
|
||||
@ -397,6 +449,83 @@ class AssetSeriesRepositoryClass extends BaseRepository<
|
||||
}
|
||||
}
|
||||
|
||||
class AssetSkuGroupRepositoryClass extends BaseRepository<
|
||||
AssetSkuGroup,
|
||||
Prisma.AssetSkuGroupCreateInput,
|
||||
Prisma.AssetSkuGroupUpdateInput,
|
||||
AssetSkuGroupFilter
|
||||
> {
|
||||
protected modelName = 'assetSkuGroup'
|
||||
|
||||
async findByIdWithRelations(id: string): Promise<AssetSkuGroupWithRelations | null> {
|
||||
return this.prisma.assetSkuGroup.findUnique({
|
||||
where: { id },
|
||||
include: assetSkuGroupRelationInclude,
|
||||
}) as Promise<AssetSkuGroupWithRelations | null>
|
||||
}
|
||||
|
||||
async findByFamilyAndCode(
|
||||
assetFamilyId: string,
|
||||
code: string,
|
||||
): Promise<AssetSkuGroupWithRelations | null> {
|
||||
return this.prisma.assetSkuGroup.findUnique({
|
||||
where: {
|
||||
assetFamilyId_code: {
|
||||
assetFamilyId,
|
||||
code: normalizeCoreIdentity(code),
|
||||
},
|
||||
},
|
||||
include: assetSkuGroupRelationInclude,
|
||||
}) as Promise<AssetSkuGroupWithRelations | null>
|
||||
}
|
||||
|
||||
async findManyByFamilyIds(assetFamilyIds: string[]): Promise<AssetSkuGroupWithRelations[]> {
|
||||
if (assetFamilyIds.length === 0) return []
|
||||
|
||||
return this.prisma.assetSkuGroup.findMany({
|
||||
where: {
|
||||
assetFamilyId: { in: assetFamilyIds },
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
},
|
||||
orderBy: [
|
||||
{ assetFamilyId: 'asc' },
|
||||
{ sortOrder: 'asc' },
|
||||
{ code: 'asc' },
|
||||
],
|
||||
include: assetSkuGroupRelationInclude,
|
||||
}) as Promise<AssetSkuGroupWithRelations[]>
|
||||
}
|
||||
|
||||
async createWithRelations(data: Prisma.AssetSkuGroupCreateInput): Promise<AssetSkuGroupWithRelations> {
|
||||
return this.prisma.assetSkuGroup.create({
|
||||
data,
|
||||
include: assetSkuGroupRelationInclude,
|
||||
}) as Promise<AssetSkuGroupWithRelations>
|
||||
}
|
||||
|
||||
async updateWithRelations(
|
||||
id: string,
|
||||
data: Prisma.AssetSkuGroupUpdateInput,
|
||||
): Promise<AssetSkuGroupWithRelations> {
|
||||
return this.prisma.assetSkuGroup.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: assetSkuGroupRelationInclude,
|
||||
}) as Promise<AssetSkuGroupWithRelations>
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.prisma.assetSkuGroup.delete({ where: { id } })
|
||||
}
|
||||
|
||||
protected buildWhereClause(filter?: AssetSkuGroupFilter): Prisma.AssetSkuGroupWhereInput {
|
||||
return {
|
||||
assetFamilyId: filter?.assetFamilyId,
|
||||
assetFamily: { is: { deletedAt: null } },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AssetVariantRepositoryClass extends BaseRepository<
|
||||
AssetVariant,
|
||||
Prisma.AssetVariantCreateInput,
|
||||
@ -712,12 +841,14 @@ class SubstrateRepositoryClass extends BaseRepository<
|
||||
|
||||
export const assetFamilyRepository = new AssetFamilyRepositoryClass()
|
||||
export const assetSeriesRepository = new AssetSeriesRepositoryClass()
|
||||
export const assetSkuGroupRepository = new AssetSkuGroupRepositoryClass()
|
||||
export const assetVariantRepository = new AssetVariantRepositoryClass()
|
||||
export const substrateRepository = new SubstrateRepositoryClass()
|
||||
|
||||
export {
|
||||
AssetFamilyRepositoryClass,
|
||||
AssetSeriesRepositoryClass,
|
||||
AssetSkuGroupRepositoryClass,
|
||||
AssetVariantRepositoryClass,
|
||||
SubstrateRepositoryClass,
|
||||
}
|
||||
|
||||
@ -115,12 +115,15 @@ export {
|
||||
assetFamilyRelationInclude,
|
||||
assetFamilyRepository,
|
||||
assetSeriesRepository,
|
||||
assetSkuGroupRepository,
|
||||
assetSkuGroupRelationInclude,
|
||||
assetVariantRelationInclude,
|
||||
assetVariantRepository,
|
||||
substrateRelationInclude,
|
||||
substrateRepository,
|
||||
AssetFamilyRepositoryClass,
|
||||
AssetSeriesRepositoryClass,
|
||||
AssetSkuGroupRepositoryClass,
|
||||
AssetVariantRepositoryClass,
|
||||
SubstrateRepositoryClass,
|
||||
} from './asset-center.repository'
|
||||
@ -128,6 +131,8 @@ export type {
|
||||
AssetFamilyFilter,
|
||||
AssetFamilyWithRelations,
|
||||
AssetCenterFinishRelation,
|
||||
AssetSkuGroupFilter,
|
||||
AssetSkuGroupWithRelations,
|
||||
AssetVariantFilter,
|
||||
AssetVariantWithRelations,
|
||||
SubstrateFilter,
|
||||
|
||||
@ -369,6 +369,7 @@ describe('draftGroupService', () => {
|
||||
updatedAt: new Date('2026-03-30T08:00:00.000Z'),
|
||||
} as never)
|
||||
mockedAssetFamilyRepository.findByCode.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([])
|
||||
mockedAssetVariantRepository.findByCode.mockResolvedValue(null)
|
||||
mockedAssetVariantRepository.findByBarcode.mockResolvedValue(null)
|
||||
mockedProductRepository.findByModelNumber.mockResolvedValue(null)
|
||||
@ -1397,6 +1398,77 @@ describe('draftGroupService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('读取草稿组详情时会把资产货号对应条码补到部件快照', async () => {
|
||||
mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([
|
||||
{
|
||||
id: 'pre-sku-1',
|
||||
draftGroupId: 'draft-group-1',
|
||||
sequence: 1,
|
||||
defaultName: '单板桌',
|
||||
customName: null,
|
||||
status: 'DRAFT',
|
||||
sourceCandidateKey: 'candidate-1',
|
||||
familySelection: {},
|
||||
variantSelection: {
|
||||
slots: [
|
||||
{
|
||||
slotKey: 'desktop',
|
||||
role: 'desktop',
|
||||
assetFamilyId: 'family-desktop-a',
|
||||
variantId: 'variant-desktop-a-white',
|
||||
code: 'DBZ-SPJ01',
|
||||
name: '桌面板',
|
||||
assetType: 'BOARD',
|
||||
dimensions: { length: 1400, width: 600, thickness: 25 },
|
||||
weightKg: 14.5,
|
||||
costPrice: 210,
|
||||
modelUrl: '/models/top-a-1200.glb',
|
||||
mountPoints: [],
|
||||
assemblyBasis: {},
|
||||
},
|
||||
],
|
||||
hardwareSelections: [],
|
||||
},
|
||||
recommendedHardware: [],
|
||||
previewNodes: [],
|
||||
displaySummary: null,
|
||||
bomPreview: [],
|
||||
weightPreview: null,
|
||||
componentCodes: ['DBZ-SPJ01'],
|
||||
installStepPreview: [],
|
||||
notes: null,
|
||||
createdAt: new Date('2026-03-30T12:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-30T12:00:00.000Z'),
|
||||
packagingPlan: null,
|
||||
},
|
||||
] as never)
|
||||
mockedAssetVariantRepository.findByIdsWithRelations.mockResolvedValue([
|
||||
{
|
||||
id: 'variant-desktop-a-white',
|
||||
barcode: 'fallback-barcode',
|
||||
skuGroup: {
|
||||
metadata: {
|
||||
skuContentFields: {
|
||||
correspondingBarcode: 'DNZ038401N1440V01',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
const result = await draftGroupService.getById('draft-group-1')
|
||||
|
||||
expect(mockedAssetVariantRepository.findByIdsWithRelations).toHaveBeenCalledWith([
|
||||
'variant-desktop-a-white',
|
||||
])
|
||||
expect(result.preSkus[0].variantSelection.slots[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
barcode: 'FALLBACK-BARCODE',
|
||||
correspondingBarcode: 'DNZ038401N1440V01',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('读取草稿组详情时优先返回 displaySummary 中的装配状态和 warnings', async () => {
|
||||
mockedPreSkuRepository.findByDraftGroupId.mockResolvedValue([
|
||||
{
|
||||
|
||||
@ -1615,18 +1615,17 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
mockedRepository.findById.mockResolvedValue(componentRecord as never)
|
||||
mockedRepository.update.mockResolvedValue(componentRecord as never)
|
||||
mockedRepository.findActiveById.mockResolvedValue(componentRecord as never)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
|
||||
createAssetVariant({
|
||||
id: 'variant-11',
|
||||
code: 'DBZ-SPJ11',
|
||||
sizeLabel: '140/140/0.3',
|
||||
}),
|
||||
createAssetVariant({
|
||||
id: 'variant-12',
|
||||
code: 'DBZ-SPJ12',
|
||||
sizeLabel: '120/120/0.3',
|
||||
}),
|
||||
] as never)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue(
|
||||
Array.from({ length: 12 }, (_, index) => {
|
||||
const sequence = index + 1
|
||||
const skuCode = `DBZ-SPJ${String(sequence).padStart(2, '0')}`
|
||||
return createAssetVariant({
|
||||
id: `variant-${sequence}`,
|
||||
code: skuCode,
|
||||
sizeLabel: sequence === 11 ? '140/140/0.3' : sequence === 12 ? '120/120/0.3' : '',
|
||||
})
|
||||
}) as never,
|
||||
)
|
||||
|
||||
await intakeNonIntegratedProductService.save(componentPayload, baseUser)
|
||||
|
||||
@ -1648,6 +1647,115 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
expect(updatePayload.skuBatchCount).toBe(1)
|
||||
})
|
||||
|
||||
it('syncs series component detail SKU rows by asset sku group instead of model variants', async () => {
|
||||
const componentPayload = createPayload({
|
||||
id: 'DBZ-SPJ-1780300716699',
|
||||
spu: 'DBZ-SPJ',
|
||||
productCode: 'DBZ-SPJ',
|
||||
productDevelopmentKind: 'series',
|
||||
metadata: {
|
||||
componentDetailType: 'asset-series-components',
|
||||
sourceSeriesCode: 'DBZ',
|
||||
},
|
||||
skuItems: [
|
||||
createSkuItem({
|
||||
id: 'asset-variant-variant-black',
|
||||
skuSequence: 1,
|
||||
model: 'DBZ-SPJK03',
|
||||
skuCode: 'DBZ-SPJ06',
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: 'variant-black',
|
||||
}),
|
||||
createSkuItem({
|
||||
id: 'asset-variant-variant-white',
|
||||
skuSequence: 2,
|
||||
model: 'DBZ-SPJK03',
|
||||
skuCode: 'DBZ-SPJ07',
|
||||
source: 'asset_series_component',
|
||||
sourceTable: 'asset_variant',
|
||||
sourceSkuId: 'variant-white',
|
||||
}),
|
||||
],
|
||||
skuBatchCount: 1,
|
||||
})
|
||||
const componentRecord = createRecord({
|
||||
id: 'DBZ-SPJ-1780300716699',
|
||||
spu: 'DBZ-SPJ',
|
||||
spuBase: 'DBZ-SPJ',
|
||||
payload: componentPayload,
|
||||
})
|
||||
const skuGroup = {
|
||||
id: 'sku-group-dbz038501',
|
||||
assetFamilyId: 'family-packaging',
|
||||
code: 'DBZ038501',
|
||||
name: 'DBZ038501',
|
||||
description: null,
|
||||
sortOrder: 1,
|
||||
metadata: {
|
||||
skuContentFields: {
|
||||
model: 'DBZ-SPJK03',
|
||||
skuCode: 'DBZ038501',
|
||||
barcode: 'DBZ038501N12V01',
|
||||
specName: 'DBZ038501',
|
||||
manufacturerCode: 'N12',
|
||||
versionNo: 'V01',
|
||||
drawingId: 'DBZ-SPJK03AV01',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockedRepository.findBySpu.mockResolvedValue(null as never)
|
||||
mockedRepository.findById.mockResolvedValue(componentRecord as never)
|
||||
mockedRepository.update.mockResolvedValue(componentRecord as never)
|
||||
mockedRepository.findActiveById.mockResolvedValue(componentRecord as never)
|
||||
mockedAssetVariantRepository.findActiveBySeriesCode.mockResolvedValue([
|
||||
createAssetVariant({
|
||||
id: 'variant-black',
|
||||
code: 'DBZ-SPJ06',
|
||||
name: '黑色金属',
|
||||
skuGroup,
|
||||
}),
|
||||
createAssetVariant({
|
||||
id: 'variant-white',
|
||||
code: 'DBZ-SPJ07',
|
||||
name: '白色金属',
|
||||
skuGroup,
|
||||
}),
|
||||
] as never)
|
||||
|
||||
await intakeNonIntegratedProductService.save(componentPayload, baseUser)
|
||||
|
||||
const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as {
|
||||
skuItems?: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(updatePayload.skuItems).toHaveLength(1)
|
||||
expect(updatePayload.skuItems?.[0]).toMatchObject({
|
||||
id: 'asset-sku-group-sku-group-dbz038501',
|
||||
sourceTable: 'asset_sku_group',
|
||||
sourceSkuId: 'sku-group-dbz038501',
|
||||
model: 'DBZ-SPJK03',
|
||||
skuCode: 'DBZ038501',
|
||||
barcode: 'DBZ038501N12V01',
|
||||
})
|
||||
expect(updatePayload.skuItems?.[0]?.componentDetails).toEqual([
|
||||
{
|
||||
code: 'DBZ-SPJ06',
|
||||
name: '黑色金属',
|
||||
quantity: '1',
|
||||
role: 'Packaging family',
|
||||
assetType: 'PACKAGING',
|
||||
},
|
||||
{
|
||||
code: 'DBZ-SPJ07',
|
||||
name: '白色金属',
|
||||
quantity: '1',
|
||||
role: 'Packaging family',
|
||||
assetType: 'PACKAGING',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('system-syncs existing series component detail product identity and structured SKU rows', async () => {
|
||||
const existingPayload = createPayload({
|
||||
id: 'DBZ-SPJ-1780300716699',
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { validateCADExportData, type CADExportData } from '@/lib/cad-export-processor'
|
||||
import { inferCadImportDefaults } from '@/lib/asset-cad-import-defaults'
|
||||
import {
|
||||
buildCadAssetAssemblyBasis,
|
||||
buildCadAssetBusinessData,
|
||||
buildCadAssetDimensions,
|
||||
buildCadAssetMountPoints,
|
||||
buildCadAssetNamingFields,
|
||||
buildCadAssetSizeLabel,
|
||||
buildCadDataFromExport,
|
||||
} from '@/lib/asset-cad-variant-payload'
|
||||
import { normalizeCoreIdentity } from '@/lib/open-product/asset-coding'
|
||||
import { uploadToS3 } from '@/lib/storage'
|
||||
import { buildPersistedSkuCadData, type PersistedSkuCadData } from '@/lib/sku-cad-data'
|
||||
import { parseZipFile } from '@/lib/zip-parser'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { assetFamilyRepository } from '../repositories'
|
||||
@ -71,249 +79,6 @@ function sanitizeSegment(value: string): string {
|
||||
return normalized.length > 0 ? normalized : 'asset'
|
||||
}
|
||||
|
||||
function toSafeNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function toVector3(value: unknown, fallback: [number, number, number] = [0, 0, 0]): [number, number, number] {
|
||||
if (!Array.isArray(value) || value.length < 3) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return [
|
||||
toSafeNumber(value[0]),
|
||||
toSafeNumber(value[1]),
|
||||
toSafeNumber(value[2]),
|
||||
]
|
||||
}
|
||||
|
||||
function getModelBoundsDimensions(modelBounds: unknown): [number, number, number] | null {
|
||||
if (!Array.isArray(modelBounds) || modelBounds.length < 6) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [
|
||||
Math.abs(toSafeNumber(modelBounds[3]) - toSafeNumber(modelBounds[0])),
|
||||
Math.abs(toSafeNumber(modelBounds[4]) - toSafeNumber(modelBounds[1])),
|
||||
Math.abs(toSafeNumber(modelBounds[5]) - toSafeNumber(modelBounds[2])),
|
||||
]
|
||||
}
|
||||
|
||||
function buildBoardDimensions(params: {
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
warnings: string[]
|
||||
}): Record<string, number> {
|
||||
const panel = params.cadData?.panels[0]
|
||||
if (panel) {
|
||||
if ((params.cadData?.panels.length ?? 0) > 1) {
|
||||
params.warnings.push('检测到多个板件,已按第一块板件尺寸导入规格款。')
|
||||
}
|
||||
return {
|
||||
length: panel.size[0],
|
||||
width: panel.size[1],
|
||||
thickness: panel.size[2],
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
|
||||
if (bounds) {
|
||||
params.warnings.push('CAD 数据未提供板件尺寸,已按模型包围盒估算板材尺寸。')
|
||||
return {
|
||||
length: bounds[0],
|
||||
width: bounds[1],
|
||||
thickness: bounds[2],
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '板材导入至少需要一块板件或可用的模型包围盒尺寸')
|
||||
}
|
||||
|
||||
function buildComponentDimensions(params: {
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
warnings: string[]
|
||||
}): Record<string, number> {
|
||||
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
|
||||
if (bounds) {
|
||||
return {
|
||||
length: bounds[0],
|
||||
width: bounds[1],
|
||||
height: bounds[2],
|
||||
}
|
||||
}
|
||||
|
||||
const panel = params.cadData?.panels[0]
|
||||
if (panel) {
|
||||
params.warnings.push('组件导入未提供整体包围盒,已按第一块板件尺寸回填组件尺寸。')
|
||||
return {
|
||||
length: panel.size[0],
|
||||
width: panel.size[1],
|
||||
height: panel.size[2],
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '组件导入缺少可用尺寸信息,请确认导出包包含包围盒或板件数据')
|
||||
}
|
||||
|
||||
function buildHardwareDimensions(params: {
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
warnings: string[]
|
||||
}): Record<string, number> {
|
||||
const signature = params.cadExportData.hardwareTypes?.[0]?.signature
|
||||
if (signature) {
|
||||
return {
|
||||
width: signature.width,
|
||||
height: signature.height,
|
||||
depth: signature.depth,
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = getModelBoundsDimensions(params.cadExportData.modelBounds)
|
||||
if (bounds) {
|
||||
params.warnings.push('五金导入未提供规格签名,已按模型包围盒估算尺寸。')
|
||||
return {
|
||||
width: bounds[0],
|
||||
height: bounds[1],
|
||||
depth: bounds[2],
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '五金导入缺少可用尺寸信息,请确认导出包包含五金签名或模型包围盒')
|
||||
}
|
||||
|
||||
function buildDimensions(params: {
|
||||
assetType: SupportedAssetImportType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
warnings: string[]
|
||||
}): Record<string, number> {
|
||||
if (params.assetType === 'BOARD') {
|
||||
return buildBoardDimensions(params)
|
||||
}
|
||||
if (params.assetType === 'COMPONENT') {
|
||||
return buildComponentDimensions(params)
|
||||
}
|
||||
return buildHardwareDimensions(params)
|
||||
}
|
||||
|
||||
function buildDefaultSizeLabel(
|
||||
assetType: SupportedAssetImportType,
|
||||
dimensions: Record<string, number>,
|
||||
): string | null {
|
||||
if (assetType === 'BOARD') {
|
||||
const { length, width, thickness } = dimensions
|
||||
if (length && width && thickness) {
|
||||
return `${length}x${width}x${thickness}`
|
||||
}
|
||||
}
|
||||
|
||||
if (assetType === 'COMPONENT') {
|
||||
const { length, width, height } = dimensions
|
||||
if (length && width && height) {
|
||||
return `${length}x${width}x${height}`
|
||||
}
|
||||
}
|
||||
|
||||
if (assetType === 'HARDWARE') {
|
||||
const { width, height, depth } = dimensions
|
||||
if (width && height && depth) {
|
||||
return `${width}x${height}x${depth}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildMountPoints(cadExportData: CADExportData) {
|
||||
return [
|
||||
{
|
||||
name: 'origin',
|
||||
position: toVector3(cadExportData.originOffset),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildAssemblyBasis(params: {
|
||||
assetType: SupportedAssetImportType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
}): Record<string, unknown> {
|
||||
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
|
||||
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
|
||||
const snapPatterns =
|
||||
params.assetType === 'HARDWARE'
|
||||
? []
|
||||
: (params.cadData?.snapPatterns?.length
|
||||
? params.cadData.snapPatterns
|
||||
: (params.cadData?.mountPatterns ?? []))
|
||||
const mountPatterns =
|
||||
params.assetType === 'HARDWARE'
|
||||
? []
|
||||
: (params.cadData?.mountPatterns?.length ? params.cadData.mountPatterns : snapPatterns)
|
||||
const snapSurfaces = params.assetType === 'HARDWARE' ? [] : (params.cadData?.snapSurfaces ?? [])
|
||||
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
|
||||
const suggestedFastenerCount = holes.reduce(
|
||||
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return {
|
||||
source: 'CAD_SINGLE_ASSET_IMPORT',
|
||||
assetType: params.assetType,
|
||||
pluginVersion: params.cadExportData.pluginVersion,
|
||||
units: params.cadExportData.units,
|
||||
sourceFile: params.cadExportData.sourceFile,
|
||||
panelCount: params.cadData?.panels.length ?? 0,
|
||||
holeCount: holes.length,
|
||||
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
|
||||
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
|
||||
snapPatternCount: snapPatterns.length,
|
||||
snapSurfaceCount: snapSurfaces.length,
|
||||
mountPatternCount: mountPatterns.length,
|
||||
suggestedFastenerCount,
|
||||
holes,
|
||||
holeGroups,
|
||||
snapPatterns,
|
||||
snapSurfaces,
|
||||
mountPatterns,
|
||||
edgeFeatureCount: edgeFeatures.length,
|
||||
edgeFeatures,
|
||||
}
|
||||
}
|
||||
|
||||
function buildBusinessData(params: {
|
||||
assetType: SupportedAssetImportType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
zipFileName: string
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
importSource: {
|
||||
mode: 'CAD_SINGLE_ASSET_IMPORT',
|
||||
assetType: params.assetType,
|
||||
zipFileName: params.zipFileName,
|
||||
sourceFile: params.cadExportData.sourceFile,
|
||||
exportedAt: params.cadExportData.exportedAt,
|
||||
pluginVersion: params.cadExportData.pluginVersion,
|
||||
software: params.cadExportData.software,
|
||||
exportMode:
|
||||
typeof (params.cadExportData as CADExportData & { exportMode?: string }).exportMode === 'string'
|
||||
? (params.cadExportData as CADExportData & { exportMode?: string }).exportMode
|
||||
: null,
|
||||
assetTypeHint:
|
||||
typeof (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint === 'string'
|
||||
? (params.cadExportData as CADExportData & { assetTypeHint?: string }).assetTypeHint
|
||||
: null,
|
||||
},
|
||||
modelBounds: Array.isArray(params.cadExportData.modelBounds)
|
||||
? params.cadExportData.modelBounds
|
||||
: null,
|
||||
cadData: params.cadData,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImportNames(params: {
|
||||
input: ImportAssetCadInput
|
||||
cadExportData: CADExportData
|
||||
@ -342,20 +107,6 @@ function resolveImportNames(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildNamingFields(params: {
|
||||
assetType: SupportedAssetImportType
|
||||
cadData: PersistedSkuCadData | null
|
||||
cadExportData: CADExportData
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
assetType: params.assetType,
|
||||
sourceFile: params.cadExportData.sourceFile,
|
||||
modelFile: params.cadExportData.modelFile,
|
||||
panelType: params.cadData?.panels[0]?.panelType ?? null,
|
||||
hardwareType: params.cadData?.hardwareTypes[0]?.inferredHardwareType ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFamilyById(id: string): Promise<AssetFamilyDTO | null> {
|
||||
const families = await assetCenterService.listFamilies()
|
||||
return families.find((family) => family.id === id) ?? null
|
||||
@ -407,19 +158,7 @@ class AssetCadImportService {
|
||||
)
|
||||
}
|
||||
|
||||
const cadData = buildPersistedSkuCadData({
|
||||
pluginVersion: parseResult.cadExportData.pluginVersion,
|
||||
units: parseResult.cadExportData.units,
|
||||
coordinateSystem: parseResult.cadExportData.coordinateSystem,
|
||||
panels: parseResult.cadExportData.panels,
|
||||
holes: parseResult.cadExportData.holes,
|
||||
holeGroups: (parseResult.cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
|
||||
snapPatterns: parseResult.cadExportData.snapPatterns,
|
||||
snapSurfaces: parseResult.cadExportData.snapSurfaces,
|
||||
mountPatterns: (parseResult.cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
|
||||
hardwareTypes: parseResult.cadExportData.hardwareTypes,
|
||||
hardwareSummary: parseResult.cadExportData.hardwareSummary,
|
||||
})
|
||||
const cadData = buildCadDataFromExport(parseResult.cadExportData)
|
||||
|
||||
const warnings: string[] = []
|
||||
const resolvedNames = resolveImportNames({
|
||||
@ -427,12 +166,20 @@ class AssetCadImportService {
|
||||
cadExportData: parseResult.cadExportData,
|
||||
zipFileName: input.zipFile.name,
|
||||
})
|
||||
const dimensions = buildDimensions({
|
||||
let dimensions: Record<string, number>
|
||||
try {
|
||||
dimensions = buildCadAssetDimensions({
|
||||
assetType: input.assetType,
|
||||
cadData,
|
||||
cadExportData: parseResult.cadExportData,
|
||||
warnings,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
error instanceof Error ? error.message : 'CAD 导出包缺少可用尺寸信息',
|
||||
)
|
||||
}
|
||||
|
||||
const family = await this.resolveFamily(input, resolvedNames)
|
||||
const substrateId = input.assetType === 'BOARD' ? trimOrNull(input.substrateId) : null
|
||||
@ -451,23 +198,23 @@ class AssetCadImportService {
|
||||
assetFamilyId: family.id,
|
||||
code: resolvedNames.variantCode ?? undefined,
|
||||
name: resolvedNames.variantName,
|
||||
sizeLabel: trimOrNull(input.sizeLabel) ?? buildDefaultSizeLabel(input.assetType, dimensions) ?? undefined,
|
||||
sizeLabel: trimOrNull(input.sizeLabel) ?? buildCadAssetSizeLabel(input.assetType, dimensions) ?? undefined,
|
||||
substrateId,
|
||||
finishId,
|
||||
dimensions,
|
||||
modelUrl,
|
||||
mountPoints: buildMountPoints(parseResult.cadExportData),
|
||||
assemblyBasis: buildAssemblyBasis({
|
||||
mountPoints: buildCadAssetMountPoints(parseResult.cadExportData),
|
||||
assemblyBasis: buildCadAssetAssemblyBasis({
|
||||
assetType: input.assetType,
|
||||
cadData,
|
||||
cadExportData: parseResult.cadExportData,
|
||||
}),
|
||||
namingFields: buildNamingFields({
|
||||
namingFields: buildCadAssetNamingFields({
|
||||
assetType: input.assetType,
|
||||
cadData,
|
||||
cadExportData: parseResult.cadExportData,
|
||||
}),
|
||||
businessData: buildBusinessData({
|
||||
businessData: buildCadAssetBusinessData({
|
||||
assetType: input.assetType,
|
||||
cadData,
|
||||
cadExportData: parseResult.cadExportData,
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
buildSeriesDisplayName,
|
||||
DEFAULT_SKU_VERSION_NO,
|
||||
normalizeSeriesCode,
|
||||
normalizeCoreIdentity,
|
||||
normalizeSpuCode,
|
||||
normalizeSkuVersionNo,
|
||||
normalizeSupplierCode,
|
||||
@ -79,6 +80,7 @@ import {
|
||||
assetFamilyRepository,
|
||||
assetFamilyRelationInclude,
|
||||
assetSeriesRepository,
|
||||
assetSkuGroupRepository,
|
||||
assetVariantRepository,
|
||||
assetVariantRelationInclude,
|
||||
categoryRepository,
|
||||
@ -88,6 +90,7 @@ import {
|
||||
substrateRepository,
|
||||
type AssetCenterFinishRelation,
|
||||
type AssetFamilyFilter,
|
||||
type AssetSkuGroupWithRelations,
|
||||
type AssetVariantFilter,
|
||||
type AssetFamilyWithRelations,
|
||||
type AssetVariantWithRelations,
|
||||
@ -100,11 +103,13 @@ import type {
|
||||
AssetSeriesDTO,
|
||||
AssetSeriesComponentDetailSummaryDTO,
|
||||
AssetReviewActionInput,
|
||||
AssetSkuGroupDTO,
|
||||
AssetVariantDTO,
|
||||
AssetWorkflowAuditDTO,
|
||||
CreateAssetVariantResultDTO,
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetSeriesInput,
|
||||
CreateAssetSkuGroupInput,
|
||||
CreateAssetVariantInput,
|
||||
DeletedAssetFamilyDTO,
|
||||
DeletedAssetVariantDTO,
|
||||
@ -119,6 +124,7 @@ import type {
|
||||
UpdateSubstrateDictionaryInput,
|
||||
UpdateAssetFamilyInput,
|
||||
UpdateAssetSeriesInput,
|
||||
UpdateAssetSkuGroupInput,
|
||||
UpdateAssetVariantInput,
|
||||
UpdateSubstrateInput,
|
||||
} from './asset-center.service.types'
|
||||
@ -285,6 +291,7 @@ function resolveVariantDimensionsForCreate(input: {
|
||||
const SERIES_COMPONENT_DETAIL_METADATA_TYPE = 'asset-series-components'
|
||||
const SERIES_COMPONENT_ASSET_SKU_SOURCE = 'asset_series_component'
|
||||
const ASSET_VARIANT_SOURCE_TABLE = 'asset_variant'
|
||||
const ASSET_SKU_GROUP_SOURCE_TABLE = 'asset_sku_group'
|
||||
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
|
||||
const NON_BOARD_ASSET_BOARD_MATERIAL_TYPE = '非ENF级'
|
||||
const DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE = 'A'
|
||||
@ -754,7 +761,8 @@ function isSeriesComponentAssetSkuPayloadItem(item: unknown): item is Record<str
|
||||
|
||||
return (
|
||||
normalizeSourceMarker(item.source) === SERIES_COMPONENT_ASSET_SKU_SOURCE ||
|
||||
normalizeSourceMarker(item.sourceTable) === ASSET_VARIANT_SOURCE_TABLE
|
||||
normalizeSourceMarker(item.sourceTable) === ASSET_VARIANT_SOURCE_TABLE ||
|
||||
normalizeSourceMarker(item.sourceTable) === ASSET_SKU_GROUP_SOURCE_TABLE
|
||||
)
|
||||
}
|
||||
|
||||
@ -796,6 +804,30 @@ function readVariantSkuContentFields(variant: AssetVariantDTO): Record<string, s
|
||||
}, {})
|
||||
}
|
||||
|
||||
function readSkuGroupSkuContentFields(variant: AssetVariantDTO): Record<string, string> {
|
||||
const metadata = isRecord(variant.skuGroupMetadata) ? variant.skuGroupMetadata : null
|
||||
const rawFields = metadata?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
if (!isRecord(rawFields)) return {}
|
||||
|
||||
return Object.entries(rawFields).reduce<Record<string, string>>((fields, [key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
fields[key] = value.trim()
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
fields[key] = String(value)
|
||||
}
|
||||
return fields
|
||||
}, {})
|
||||
}
|
||||
|
||||
function mergeSkuGroupAndVariantSkuContentFields(
|
||||
variant: AssetVariantDTO,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...readVariantSkuContentFields(variant),
|
||||
...readSkuGroupSkuContentFields(variant),
|
||||
}
|
||||
}
|
||||
|
||||
function getVariantSkuContentField(
|
||||
fields: Record<string, string>,
|
||||
key: string,
|
||||
@ -844,18 +876,31 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
const normalizedModel = variant.assetFamilyCode.trim().toUpperCase()
|
||||
const normalizedModelUrl = variant.modelUrl.trim()
|
||||
const thumbnailUrl = variant.thumbnailUrl?.trim() ?? ''
|
||||
const skuContentFields = readVariantSkuContentFields(variant)
|
||||
const versionNo = normalizeSkuVersionNo(variant.versionNo ?? DEFAULT_SKU_VERSION_NO)
|
||||
const manufacturerCode = (variant.supplierCode ?? '').trim().toUpperCase()
|
||||
const barcode = (
|
||||
variant.barcode ??
|
||||
(manufacturerCode
|
||||
? buildAssetVariantBarcode({
|
||||
const skuContentFields = mergeSkuGroupAndVariantSkuContentFields(variant)
|
||||
const isStockLinkedSku = Boolean(
|
||||
getVariantSkuContentField(skuContentFields, 'correspondingBarcode').trim(),
|
||||
)
|
||||
const rawVersionNo = hasVariantSkuContentField(skuContentFields, 'versionNo')
|
||||
? skuContentFields.versionNo ?? ''
|
||||
: (variant.versionNo ?? readPayloadString(item, 'versionNo')) || DEFAULT_SKU_VERSION_NO
|
||||
const versionNo = rawVersionNo.trim() ? normalizeSkuVersionNo(rawVersionNo) : ''
|
||||
const manufacturerCode = getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'manufacturerCode',
|
||||
variant.supplierCode ?? readPayloadString(item, 'manufacturerCode'),
|
||||
).trim().toUpperCase()
|
||||
const barcode = getVariantSkuContentField(
|
||||
skuContentFields,
|
||||
'barcode',
|
||||
variant.barcode ?? (
|
||||
versionNo &&
|
||||
buildAssetVariantBarcode({
|
||||
variantCode: normalizedCode,
|
||||
supplierCode: manufacturerCode,
|
||||
versionNo,
|
||||
})
|
||||
: '')
|
||||
}) ||
|
||||
readPayloadString(item, 'barcode')
|
||||
),
|
||||
).trim().toUpperCase()
|
||||
const batchNo = parseSeriesComponentBatchNo(
|
||||
getVariantSkuContentField(skuContentFields, 'batchNo', readPayloadString(item, 'batchNo')),
|
||||
@ -866,12 +911,12 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
'attributeCode',
|
||||
DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE,
|
||||
).toUpperCase()
|
||||
const drawingIdFallback = `${normalizedModel}${attributeCode}${versionNo}`
|
||||
const drawingIdFallback = `${getVariantSkuContentField(skuContentFields, 'model', normalizedModel) || normalizedModel}${attributeCode}${versionNo}`
|
||||
|
||||
return {
|
||||
...item,
|
||||
skuSequence,
|
||||
model: normalizedModel,
|
||||
model: getVariantSkuContentField(skuContentFields, 'model', normalizedModel) || normalizedModel,
|
||||
skuCode: normalizedCode,
|
||||
barcode,
|
||||
setCount: getVariantSkuContentField(skuContentFields, 'setCount', readPayloadString(item, 'setCount') || '1'),
|
||||
@ -917,19 +962,133 @@ function updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceSkuId: variant.id,
|
||||
contentLocked: isStockLinkedSku,
|
||||
}
|
||||
}
|
||||
|
||||
type SeriesComponentSkuGroupSnapshot = {
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
assetFamilyCode: string
|
||||
code: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
variants: AssetVariantDTO[]
|
||||
}
|
||||
|
||||
function sortSeriesComponentSkuGroupVariants(variants: AssetVariantDTO[]) {
|
||||
return [...variants].sort((left, right) => (
|
||||
left.code.localeCompare(right.code, 'zh-CN') ||
|
||||
left.name.localeCompare(right.name, 'zh-CN')
|
||||
))
|
||||
}
|
||||
|
||||
function buildSeriesComponentSkuGroupSnapshots(
|
||||
variants: AssetVariantDTO[],
|
||||
): SeriesComponentSkuGroupSnapshot[] {
|
||||
const groupById = new Map<string, SeriesComponentSkuGroupSnapshot>()
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const normalizedGroupCode = (
|
||||
variant.skuGroupCode?.trim() ||
|
||||
variant.code.trim()
|
||||
).toUpperCase()
|
||||
const normalizedGroupName =
|
||||
variant.skuGroupName?.trim() ||
|
||||
variant.name.trim() ||
|
||||
normalizedGroupCode
|
||||
const groupId = variant.skuGroupId ?? `variant:${variant.id}`
|
||||
const current = groupById.get(groupId)
|
||||
if (current) {
|
||||
current.variants.push(variant)
|
||||
return
|
||||
}
|
||||
|
||||
groupById.set(groupId, {
|
||||
id: groupId,
|
||||
assetFamilyId: variant.assetFamilyId,
|
||||
assetFamilyCode: variant.assetFamilyCode,
|
||||
code: normalizedGroupCode,
|
||||
name: normalizedGroupName,
|
||||
sortOrder: variant.skuGroupSortOrder ?? Number.MAX_SAFE_INTEGER,
|
||||
variants: [variant],
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(groupById.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
variants: sortSeriesComponentSkuGroupVariants(group.variants),
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.assetFamilyCode.localeCompare(right.assetFamilyCode, 'zh-CN') ||
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.code.localeCompare(right.code, 'zh-CN')
|
||||
))
|
||||
}
|
||||
|
||||
function buildSeriesComponentSkuGroupDisplayCode(group: SeriesComponentSkuGroupSnapshot): string {
|
||||
const variantCodes = group.variants
|
||||
.map((variant) => variant.code.trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
if (variantCodes.length <= 1) return group.code
|
||||
|
||||
return `${group.code}(${variantCodes.join('、')})`
|
||||
}
|
||||
|
||||
function updateSeriesComponentSkuPayloadItemFromSkuGroup(
|
||||
item: Record<string, unknown>,
|
||||
group: SeriesComponentSkuGroupSnapshot,
|
||||
skuSequence: number,
|
||||
): Record<string, unknown> {
|
||||
const primaryVariant =
|
||||
group.variants.find((variant) => variant.code.trim().toUpperCase() === group.code) ??
|
||||
group.variants[0]
|
||||
if (!primaryVariant) return item
|
||||
|
||||
const nextItem = updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
item,
|
||||
primaryVariant,
|
||||
skuSequence,
|
||||
)
|
||||
const displayCode = buildSeriesComponentSkuGroupDisplayCode(group)
|
||||
|
||||
return {
|
||||
...nextItem,
|
||||
id: `asset-sku-group-${group.id}`,
|
||||
skuSequence,
|
||||
skuCode: group.code,
|
||||
specName: readPayloadString(nextItem, 'specName') || displayCode,
|
||||
componentDetails: group.variants.map((variant) => ({
|
||||
code: variant.code.trim().toUpperCase(),
|
||||
name: variant.name.trim(),
|
||||
quantity: '1',
|
||||
role: variant.assetFamilyName,
|
||||
assetType: variant.assetType,
|
||||
})),
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
|
||||
sourceSkuId: group.id,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeriesComponentSkuItemsFromVariants(
|
||||
variants: AssetVariantDTO[],
|
||||
): NonIntegratedSkuItem[] {
|
||||
return variants.map((variant, index) =>
|
||||
updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
createEmptySeriesComponentSkuPayloadItem(variant, index + 1),
|
||||
variant,
|
||||
const groups = buildSeriesComponentSkuGroupSnapshots(variants)
|
||||
|
||||
return groups.map((group, index) => {
|
||||
const primaryVariant = group.variants[0]
|
||||
if (!primaryVariant) {
|
||||
return null
|
||||
}
|
||||
|
||||
return updateSeriesComponentSkuPayloadItemFromSkuGroup(
|
||||
createEmptySeriesComponentSkuPayloadItem(primaryVariant, index + 1),
|
||||
group,
|
||||
index + 1,
|
||||
) as NonIntegratedSkuItem,
|
||||
)
|
||||
) as NonIntegratedSkuItem
|
||||
}).filter((item): item is NonIntegratedSkuItem => item !== null)
|
||||
}
|
||||
|
||||
function buildSeriesComponentDetailProductPayload(input: {
|
||||
@ -1408,6 +1567,24 @@ function toAssetSeriesDTO(record: {
|
||||
}
|
||||
}
|
||||
|
||||
function toAssetSkuGroupDTO(record: AssetSkuGroupWithRelations): AssetSkuGroupDTO {
|
||||
return {
|
||||
id: record.id,
|
||||
assetFamilyId: record.assetFamilyId,
|
||||
assetFamilyCode: record.assetFamily.code,
|
||||
assetFamilyName: record.assetFamily.name,
|
||||
assetType: record.assetFamily.type,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
sortOrder: record.sortOrder,
|
||||
metadata: isRecord(record.metadata) ? record.metadata : null,
|
||||
variantCount: record._count?.variants ?? 0,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null {
|
||||
return value === null || value === undefined ? null : Number(value)
|
||||
}
|
||||
@ -1570,6 +1747,11 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO {
|
||||
assetFamilyId: record.assetFamilyId,
|
||||
assetFamilyCode: record.assetFamily.code,
|
||||
assetFamilyName: record.assetFamily.name,
|
||||
skuGroupId: record.skuGroupId,
|
||||
skuGroupCode: record.skuGroup?.code ?? null,
|
||||
skuGroupName: record.skuGroup?.name ?? null,
|
||||
skuGroupSortOrder: record.skuGroup?.sortOrder ?? null,
|
||||
skuGroupMetadata: isRecord(record.skuGroup?.metadata) ? record.skuGroup.metadata : null,
|
||||
assetType: record.assetType,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
@ -1825,6 +2007,62 @@ async function ensureVariantCodeAvailable(code: string, currentId?: string): Pro
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssetSkuGroupCode(code: string): string {
|
||||
return normalizeCoreIdentity(code)
|
||||
}
|
||||
|
||||
async function ensureAssetSkuGroupCodeAvailable(input: {
|
||||
assetFamilyId: string
|
||||
code: string
|
||||
currentId?: string
|
||||
}): Promise<void> {
|
||||
const existing = await assetSkuGroupRepository.findByFamilyAndCode(
|
||||
input.assetFamilyId,
|
||||
input.code,
|
||||
)
|
||||
if (existing && existing.id !== input.currentId) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '同一型号下货号已存在')
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAssetSkuGroupForVariant(input: {
|
||||
assetFamilyId: string
|
||||
skuGroupId?: string | null
|
||||
defaultCode: string
|
||||
defaultName: string
|
||||
}): Promise<AssetSkuGroupWithRelations> {
|
||||
const requestedId = input.skuGroupId?.trim()
|
||||
if (requestedId) {
|
||||
const group = await assetSkuGroupRepository.findByIdWithRelations(requestedId)
|
||||
if (!group || group.assetFamily.deletedAt) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组不存在')
|
||||
}
|
||||
if (group.assetFamilyId !== input.assetFamilyId) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组必须属于当前型号')
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
const code = normalizeAssetSkuGroupCode(input.defaultCode)
|
||||
const existing = await assetSkuGroupRepository.findByFamilyAndCode(input.assetFamilyId, code)
|
||||
if (existing) return existing
|
||||
|
||||
const maxSortOrder = await prisma.assetSkuGroup.aggregate({
|
||||
where: { assetFamilyId: input.assetFamilyId },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
return assetSkuGroupRepository.createWithRelations({
|
||||
assetFamily: { connect: { id: input.assetFamilyId } },
|
||||
code,
|
||||
name: input.defaultName.trim() || code,
|
||||
sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1,
|
||||
metadata: {
|
||||
source: 'variant-default',
|
||||
} as Prisma.InputJsonValue,
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureVariantBarcodeAvailable(barcode: string | null, currentId?: string): Promise<void> {
|
||||
if (!barcode) return
|
||||
const existing = await assetVariantRepository.findByBarcode(barcode)
|
||||
@ -2999,6 +3237,114 @@ class AssetCenterService {
|
||||
return records.map((item) => toAssetVariantDTO(item))
|
||||
}
|
||||
|
||||
async listSkuGroupsByFamilyIds(assetFamilyIds: string[]): Promise<AssetSkuGroupDTO[]> {
|
||||
const records = await assetSkuGroupRepository.findManyByFamilyIds(assetFamilyIds)
|
||||
return records.map((item) => toAssetSkuGroupDTO(item))
|
||||
}
|
||||
|
||||
async createSkuGroup(input: CreateAssetSkuGroupInput): Promise<AssetSkuGroupDTO> {
|
||||
const family = await assetFamilyRepository.findWithRelationsById(input.assetFamilyId)
|
||||
if (!family) {
|
||||
throw new AppError(ErrorCode.ASSET_FAMILY_NOT_FOUND, '型号不存在')
|
||||
}
|
||||
|
||||
const code = normalizeAssetSkuGroupCode(input.code)
|
||||
const name = input.name?.trim() || code
|
||||
if (!code || !name) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '货号和名称不能为空')
|
||||
}
|
||||
|
||||
await ensureAssetSkuGroupCodeAvailable({
|
||||
assetFamilyId: family.id,
|
||||
code,
|
||||
})
|
||||
|
||||
const sortOrder = input.sortOrder ?? await (async () => {
|
||||
const maxSortOrder = await prisma.assetSkuGroup.aggregate({
|
||||
where: { assetFamilyId: family.id },
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
return (maxSortOrder._max.sortOrder ?? 0) + 1
|
||||
})()
|
||||
|
||||
const created = await assetSkuGroupRepository.createWithRelations({
|
||||
assetFamily: { connect: { id: family.id } },
|
||||
code,
|
||||
name,
|
||||
description: trimOrNull(input.description),
|
||||
sortOrder,
|
||||
metadata: toJsonValue(input.metadata),
|
||||
})
|
||||
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
|
||||
assetFamilyId: family.id,
|
||||
updatedById: input.createdBy ?? null,
|
||||
})
|
||||
|
||||
return toAssetSkuGroupDTO(created)
|
||||
}
|
||||
|
||||
async updateSkuGroup(id: string, input: UpdateAssetSkuGroupInput): Promise<AssetSkuGroupDTO> {
|
||||
const existing = await assetSkuGroupRepository.findByIdWithRelations(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组不存在')
|
||||
}
|
||||
|
||||
const code = input.code !== undefined
|
||||
? normalizeAssetSkuGroupCode(input.code)
|
||||
: existing.code
|
||||
const name = input.name !== undefined
|
||||
? input.name?.trim() || code
|
||||
: existing.name
|
||||
if (!code || !name) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '货号和名称不能为空')
|
||||
}
|
||||
|
||||
await ensureAssetSkuGroupCodeAvailable({
|
||||
assetFamilyId: existing.assetFamilyId,
|
||||
code,
|
||||
currentId: id,
|
||||
})
|
||||
|
||||
const updated = await assetSkuGroupRepository.updateWithRelations(id, {
|
||||
code,
|
||||
name,
|
||||
description:
|
||||
input.description !== undefined ? trimOrNull(input.description) : undefined,
|
||||
sortOrder: input.sortOrder ?? undefined,
|
||||
metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined,
|
||||
})
|
||||
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
|
||||
assetFamilyId: existing.assetFamilyId,
|
||||
updatedById: input.updatedBy ?? null,
|
||||
})
|
||||
|
||||
return toAssetSkuGroupDTO(updated)
|
||||
}
|
||||
|
||||
async deleteSkuGroup(id: string, deletedById?: string | null): Promise<void> {
|
||||
const existing = await assetSkuGroupRepository.findByIdWithRelations(id)
|
||||
if (!existing) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '货号组不存在')
|
||||
}
|
||||
const activeVariantCount = await prisma.assetVariant.count({
|
||||
where: {
|
||||
skuGroupId: id,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
if (activeVariantCount > 0) {
|
||||
throw new AppError(ErrorCode.VALIDATION_FAILED, '该货号下还有模型明细,不能删除')
|
||||
}
|
||||
|
||||
await assetSkuGroupRepository.deleteById(id)
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
|
||||
assetFamilyId: existing.assetFamilyId,
|
||||
updatedById: deletedById ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
async listSubstrates(): Promise<SubstrateDTO[]> {
|
||||
const result = await substrateRepository.findManyWithRelations({
|
||||
filter: { isActive: true },
|
||||
@ -3720,9 +4066,16 @@ class AssetCenterService {
|
||||
barcode: input.barcode,
|
||||
})
|
||||
await ensureVariantBarcodeAvailable(commercialFields.barcode)
|
||||
const skuGroup = await ensureAssetSkuGroupForVariant({
|
||||
assetFamilyId: input.assetFamilyId,
|
||||
skuGroupId: input.skuGroupId,
|
||||
defaultCode: code,
|
||||
defaultName: name,
|
||||
})
|
||||
|
||||
const created = await assetVariantRepository.createWithRelations({
|
||||
assetFamily: { connect: { id: input.assetFamilyId } },
|
||||
skuGroup: { connect: { id: skuGroup.id } },
|
||||
code,
|
||||
name,
|
||||
identityKey,
|
||||
@ -4001,12 +4354,24 @@ class AssetCenterService {
|
||||
input.barcode !== undefined ? input.barcode : undefined,
|
||||
})
|
||||
await ensureVariantBarcodeAvailable(commercialFields.barcode, id)
|
||||
const nextSkuGroup = await ensureAssetSkuGroupForVariant({
|
||||
assetFamilyId: nextFamilyId,
|
||||
skuGroupId:
|
||||
input.skuGroupId !== undefined
|
||||
? input.skuGroupId
|
||||
: nextFamilyId === existing.assetFamilyId
|
||||
? existing.skuGroupId
|
||||
: null,
|
||||
defaultCode: nextCode,
|
||||
defaultName: nextName,
|
||||
})
|
||||
|
||||
const updated = await assetVariantRepository.updateWithRelations(id, {
|
||||
assetFamily:
|
||||
input.assetFamilyId === undefined
|
||||
? undefined
|
||||
: { connect: { id: input.assetFamilyId } },
|
||||
skuGroup: { connect: { id: nextSkuGroup.id } },
|
||||
code: nextCode,
|
||||
name: nextName,
|
||||
identityKey,
|
||||
@ -4239,6 +4604,36 @@ class AssetCenterService {
|
||||
variant: AssetVariantDTO
|
||||
updatedById?: string | null
|
||||
}): Promise<void> {
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
|
||||
assetFamilyId: input.variant.assetFamilyId,
|
||||
updatedById: input.updatedById,
|
||||
})
|
||||
}
|
||||
|
||||
private async syncSeriesComponentDevelopmentPayloadsForFamily(input: {
|
||||
assetFamilyId: string
|
||||
updatedById?: string | null
|
||||
}): Promise<void> {
|
||||
const family = await assetFamilyRepository.findWithRelationsById(input.assetFamilyId)
|
||||
const seriesCode = family?.assetSeries?.code.trim().toUpperCase() ?? ''
|
||||
if (!seriesCode) return
|
||||
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForSeriesCode({
|
||||
seriesCode,
|
||||
updatedById: input.updatedById,
|
||||
})
|
||||
}
|
||||
|
||||
private async syncSeriesComponentDevelopmentPayloadsForSeriesCode(input: {
|
||||
seriesCode: string
|
||||
updatedById?: string | null
|
||||
}): Promise<void> {
|
||||
const seriesCode = input.seriesCode.trim().toUpperCase()
|
||||
if (!seriesCode) return
|
||||
|
||||
const variants = (await assetVariantRepository.findActiveBySeriesCode(seriesCode))
|
||||
.map((variant) => toAssetVariantDTO(variant))
|
||||
const assetSkuItems = buildSeriesComponentSkuItemsFromVariants(variants)
|
||||
const records = await intakeNonIntegratedProductRepository.listActive({
|
||||
payload: {
|
||||
path: ['metadata', 'componentDetailType'],
|
||||
@ -4248,28 +4643,22 @@ class AssetCenterService {
|
||||
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
if (!isSeriesComponentDevelopmentPayload(record.payload)) return
|
||||
const recordSpu = typeof record.spu === 'string' ? record.spu : ''
|
||||
if (!isSeriesComponentDevelopmentPayloadForSeries(record.payload, seriesCode, recordSpu)) {
|
||||
return
|
||||
}
|
||||
const currentItems = Array.isArray(record.payload.skuItems)
|
||||
? record.payload.skuItems
|
||||
: []
|
||||
if (currentItems.length === 0) return
|
||||
|
||||
let changed = false
|
||||
const nextItems = currentItems.map((item, index) => {
|
||||
if (!isSeriesComponentAssetSkuPayloadItem(item)) return item
|
||||
const sourceSkuId = typeof item.sourceSkuId === 'string' ? item.sourceSkuId.trim() : ''
|
||||
if (sourceSkuId !== input.variant.id) return item
|
||||
|
||||
changed = true
|
||||
return updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
item,
|
||||
input.variant,
|
||||
index + 1,
|
||||
)
|
||||
const manualItems = currentItems.filter((item) => !isSeriesComponentAssetSkuPayloadItem(item))
|
||||
const nextItems = [...manualItems, ...assetSkuItems].map((item, index) => {
|
||||
if (!isRecord(item)) return item
|
||||
return {
|
||||
...item,
|
||||
skuSequence: index + 1,
|
||||
}
|
||||
})
|
||||
|
||||
if (!changed) return
|
||||
|
||||
const updatedAt = new Date().toISOString()
|
||||
await intakeNonIntegratedProductRepository.updateAttachmentPayloadByUser(
|
||||
record.id,
|
||||
@ -4348,67 +4737,10 @@ class AssetCenterService {
|
||||
variant: AssetVariantDTO
|
||||
updatedById?: string | null
|
||||
}): Promise<void> {
|
||||
const variantRecord = await assetVariantRepository.findByIdWithRelations(input.variant.id)
|
||||
const seriesCode =
|
||||
variantRecord?.assetFamily?.assetSeries?.code.trim().toUpperCase() ??
|
||||
parseSeriesCodeFromAssetVariantCode(input.variant.code)
|
||||
if (!seriesCode) return
|
||||
|
||||
const records = await intakeNonIntegratedProductRepository.listActive({
|
||||
payload: {
|
||||
path: ['metadata', 'componentDetailType'],
|
||||
equals: SERIES_COMPONENT_DETAIL_METADATA_TYPE,
|
||||
},
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForFamily({
|
||||
assetFamilyId: input.variant.assetFamilyId,
|
||||
updatedById: input.updatedById,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const recordSpu = typeof record.spu === 'string' ? record.spu : ''
|
||||
if (!isSeriesComponentDevelopmentPayloadForSeries(record.payload, seriesCode, recordSpu)) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentItems = Array.isArray(record.payload.skuItems)
|
||||
? record.payload.skuItems
|
||||
: []
|
||||
const existingIndex = currentItems.findIndex((item) => {
|
||||
if (!isSeriesComponentAssetSkuPayloadItem(item)) return false
|
||||
const sourceSkuId = typeof item.sourceSkuId === 'string' ? item.sourceSkuId.trim() : ''
|
||||
return sourceSkuId === input.variant.id
|
||||
})
|
||||
|
||||
const nextItems = existingIndex >= 0
|
||||
? currentItems.map((item, index) => {
|
||||
if (index !== existingIndex || !isRecord(item)) return item
|
||||
return updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
item,
|
||||
input.variant,
|
||||
index + 1,
|
||||
)
|
||||
})
|
||||
: [
|
||||
...currentItems,
|
||||
updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
createEmptySeriesComponentSkuPayloadItem(input.variant, currentItems.length + 1),
|
||||
input.variant,
|
||||
currentItems.length + 1,
|
||||
),
|
||||
]
|
||||
|
||||
const updatedAt = new Date().toISOString()
|
||||
await intakeNonIntegratedProductRepository.updateAttachmentPayloadByUser(
|
||||
record.id,
|
||||
{
|
||||
...record.payload,
|
||||
skuItems: nextItems,
|
||||
skuBatchCount: getSeriesComponentSkuBatchCount(nextItems),
|
||||
updatedAt,
|
||||
updatedBy: input.updatedById ?? record.updatedBy,
|
||||
} as Prisma.InputJsonValue,
|
||||
input.updatedById ?? record.updatedBy,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async syncSeriesComponentDevelopmentPayloadsAfterMissingVariantBackfill(input: {
|
||||
@ -4416,60 +4748,10 @@ class AssetCenterService {
|
||||
variants: AssetVariantDTO[]
|
||||
updatedById?: string | null
|
||||
}): Promise<void> {
|
||||
const seriesCode = input.seriesCode.trim().toUpperCase()
|
||||
if (!seriesCode || input.variants.length === 0) return
|
||||
|
||||
const records = await intakeNonIntegratedProductRepository.listActive({
|
||||
payload: {
|
||||
path: ['metadata', 'componentDetailType'],
|
||||
equals: SERIES_COMPONENT_DETAIL_METADATA_TYPE,
|
||||
},
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForSeriesCode({
|
||||
seriesCode: input.seriesCode,
|
||||
updatedById: input.updatedById,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const recordSpu = typeof record.spu === 'string' ? record.spu : ''
|
||||
if (!isSeriesComponentDevelopmentPayloadForSeries(record.payload, seriesCode, recordSpu)) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentItems = Array.isArray(record.payload.skuItems)
|
||||
? record.payload.skuItems
|
||||
: []
|
||||
const existingSourceSkuIds = new Set(
|
||||
currentItems
|
||||
.map((item) => {
|
||||
if (!isSeriesComponentAssetSkuPayloadItem(item)) return ''
|
||||
return typeof item.sourceSkuId === 'string' ? item.sourceSkuId.trim() : ''
|
||||
})
|
||||
.filter(Boolean),
|
||||
)
|
||||
const missingVariants = input.variants.filter((variant) => !existingSourceSkuIds.has(variant.id))
|
||||
if (missingVariants.length === 0) return
|
||||
|
||||
const appendedItems = missingVariants.map((variant, index) => {
|
||||
const skuSequence = currentItems.length + index + 1
|
||||
return updateSeriesComponentSkuPayloadItemFromVariant(
|
||||
createEmptySeriesComponentSkuPayloadItem(variant, skuSequence),
|
||||
variant,
|
||||
skuSequence,
|
||||
)
|
||||
})
|
||||
const nextItems = [...currentItems, ...appendedItems]
|
||||
const updatedAt = new Date().toISOString()
|
||||
await intakeNonIntegratedProductRepository.updateAttachmentPayloadByUser(
|
||||
record.id,
|
||||
{
|
||||
...record.payload,
|
||||
skuItems: nextItems,
|
||||
skuBatchCount: getSeriesComponentSkuBatchCount(nextItems),
|
||||
updatedAt,
|
||||
updatedBy: input.updatedById ?? record.updatedBy,
|
||||
} as Prisma.InputJsonValue,
|
||||
input.updatedById ?? record.updatedBy,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async syncSeriesComponentDevelopmentPayloadsAfterVariantDelete(input: {
|
||||
@ -4637,11 +4919,12 @@ class AssetCenterService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.syncSeriesComponentDevelopmentPayloadsAfterVariantDelete({
|
||||
deletedVariantId: id,
|
||||
shiftedVariants,
|
||||
if (seriesCode) {
|
||||
await this.syncSeriesComponentDevelopmentPayloadsForSeriesCode({
|
||||
seriesCode,
|
||||
updatedById: deletedById ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
await assetSkuSyncService.enqueueVariantSync({
|
||||
assetVariantId: id,
|
||||
@ -5100,10 +5383,12 @@ export type {
|
||||
AssetSeriesDTO,
|
||||
AssetSeriesComponentDetailSummaryDTO,
|
||||
AssetSeriesSummaryDTO,
|
||||
AssetSkuGroupDTO,
|
||||
AssetVariantDTO,
|
||||
CreateAssetVariantResultDTO,
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetSeriesInput,
|
||||
CreateAssetSkuGroupInput,
|
||||
CreateAssetVariantInput,
|
||||
MaterialLikeDTO,
|
||||
SimilarAssetVariantCandidateDTO,
|
||||
@ -5115,6 +5400,7 @@ export type {
|
||||
SubstrateDictionaryType,
|
||||
UpdateAssetFamilyInput,
|
||||
UpdateAssetSeriesInput,
|
||||
UpdateAssetSkuGroupInput,
|
||||
UpdateAssetVariantInput,
|
||||
UpdateSubstrateDictionaryInput,
|
||||
UpdateSubstrateInput,
|
||||
|
||||
@ -93,11 +93,32 @@ export interface AssetFamilyDTO {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface AssetSkuGroupDTO {
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
assetFamilyCode: string
|
||||
assetFamilyName: string
|
||||
assetType: AssetType
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
metadata: Record<string, unknown> | null
|
||||
variantCount: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface AssetVariantDTO {
|
||||
id: string
|
||||
assetFamilyId: string
|
||||
assetFamilyCode: string
|
||||
assetFamilyName: string
|
||||
skuGroupId: string | null
|
||||
skuGroupCode: string | null
|
||||
skuGroupName: string | null
|
||||
skuGroupSortOrder: number | null
|
||||
skuGroupMetadata: Record<string, unknown> | null
|
||||
assetType: AssetType
|
||||
code: string
|
||||
name: string
|
||||
@ -296,8 +317,28 @@ export interface UpdateAssetFamilyInput {
|
||||
updatedBy?: string | null
|
||||
}
|
||||
|
||||
export interface CreateAssetSkuGroupInput {
|
||||
assetFamilyId: string
|
||||
code: string
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
sortOrder?: number | null
|
||||
metadata?: Record<string, unknown> | null
|
||||
createdBy?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateAssetSkuGroupInput {
|
||||
code?: string
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
sortOrder?: number | null
|
||||
metadata?: Record<string, unknown> | null
|
||||
updatedBy?: string | null
|
||||
}
|
||||
|
||||
export interface CreateAssetVariantInput {
|
||||
assetFamilyId: string
|
||||
skuGroupId?: string | null
|
||||
code?: string
|
||||
name: string
|
||||
status?: AssetStatus
|
||||
@ -327,6 +368,7 @@ export interface CreateAssetVariantInput {
|
||||
|
||||
export interface UpdateAssetVariantInput {
|
||||
assetFamilyId?: string
|
||||
skuGroupId?: string | null
|
||||
code?: string
|
||||
name?: string
|
||||
status?: AssetStatus
|
||||
|
||||
@ -382,6 +382,9 @@ export function buildSlotSnapshot(
|
||||
role: selection.role,
|
||||
assetFamilyId: selection.variant.assetFamilyId,
|
||||
variantId: selection.variant.id,
|
||||
skuGroupId: selection.variant.skuGroupId ?? null,
|
||||
skuGroupCode: selection.variant.skuGroupCode ?? null,
|
||||
skuGroupName: selection.variant.skuGroupName ?? null,
|
||||
code: selection.variant.code,
|
||||
name: selection.variant.name,
|
||||
assetType: selection.variant.assetType,
|
||||
@ -406,6 +409,9 @@ export function buildHardwareSnapshot(
|
||||
selectionKey: selection.selectionKey,
|
||||
assetFamilyId: selection.variant.assetFamilyId,
|
||||
variantId: selection.variant.id,
|
||||
skuGroupId: selection.variant.skuGroupId ?? null,
|
||||
skuGroupCode: selection.variant.skuGroupCode ?? null,
|
||||
skuGroupName: selection.variant.skuGroupName ?? null,
|
||||
code: selection.variant.code,
|
||||
name: selection.variant.name,
|
||||
assetType: selection.variant.assetType,
|
||||
@ -437,9 +443,14 @@ export function normalizeVariantSelection(value: unknown): PreSkuVariantSelectio
|
||||
role: typeof item.role === 'string' ? item.role : '',
|
||||
assetFamilyId: item.assetFamilyId as string,
|
||||
variantId: item.variantId as string,
|
||||
skuGroupId: readNonEmptyString(item.skuGroupId),
|
||||
skuGroupCode: readNonEmptyString(item.skuGroupCode),
|
||||
skuGroupName: readNonEmptyString(item.skuGroupName),
|
||||
code: typeof item.code === 'string' ? item.code : '',
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
assetType: typeof item.assetType === 'string' ? item.assetType : 'COMPONENT',
|
||||
barcode: readNonEmptyString(item.barcode),
|
||||
correspondingBarcode: readNonEmptyString(item.correspondingBarcode),
|
||||
substrateId: typeof item.substrateId === 'string' ? item.substrateId : null,
|
||||
finishId: typeof item.finishId === 'string' ? item.finishId : null,
|
||||
dimensions: isRecord(item.dimensions) ? (item.dimensions as Record<string, number>) : {},
|
||||
@ -460,9 +471,14 @@ export function normalizeVariantSelection(value: unknown): PreSkuVariantSelectio
|
||||
selectionKey: typeof item.selectionKey === 'string' ? item.selectionKey : '',
|
||||
assetFamilyId: item.assetFamilyId as string,
|
||||
variantId: item.variantId as string,
|
||||
skuGroupId: readNonEmptyString(item.skuGroupId),
|
||||
skuGroupCode: readNonEmptyString(item.skuGroupCode),
|
||||
skuGroupName: readNonEmptyString(item.skuGroupName),
|
||||
code: typeof item.code === 'string' ? item.code : '',
|
||||
name: typeof item.name === 'string' ? item.name : '',
|
||||
assetType: typeof item.assetType === 'string' ? item.assetType : 'HARDWARE',
|
||||
barcode: readNonEmptyString(item.barcode),
|
||||
correspondingBarcode: readNonEmptyString(item.correspondingBarcode),
|
||||
substrateId: typeof item.substrateId === 'string' ? item.substrateId : null,
|
||||
finishId: typeof item.finishId === 'string' ? item.finishId : null,
|
||||
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
|
||||
@ -516,6 +532,7 @@ export function normalizePackagingPlanBoxes(value: unknown): PreSkuPackagingPlan
|
||||
assetFamilyId: typeof entry.assetFamilyId === 'string' ? entry.assetFamilyId : '',
|
||||
code: typeof entry.code === 'string' ? entry.code : '',
|
||||
name: typeof entry.name === 'string' ? entry.name : '',
|
||||
barcode: typeof entry.barcode === 'string' ? entry.barcode : null,
|
||||
quantity: typeof entry.quantity === 'number' ? entry.quantity : 1,
|
||||
unitCost: toNullableNumber(entry.unitCost) ?? 0,
|
||||
totalCost: toNullableNumber(entry.totalCost) ?? 0,
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
productRepository,
|
||||
preSkuRepository,
|
||||
skuRepository,
|
||||
type AssetVariantWithRelations,
|
||||
type DraftGroupWithRelations,
|
||||
type PreSkuWithDraftGroupRelations,
|
||||
type PreSkuWithRelations,
|
||||
@ -33,6 +34,7 @@ import type {
|
||||
DraftGroupAssemblyInput,
|
||||
DraftGroupDTO,
|
||||
DraftGroupDetailDTO,
|
||||
DraftGroupPreSkuDTO,
|
||||
GetPreSkuPackagingMaterialOptionsDTO,
|
||||
GetPreSkuPackagingContextDTO,
|
||||
PreSkuBasicInfoConflictDTO,
|
||||
@ -149,6 +151,8 @@ const UPPERCASE_BASIC_INFO_FIELDS = new Set<PreSkuBasicInfoFieldKey>([
|
||||
'drawingId',
|
||||
])
|
||||
|
||||
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
|
||||
|
||||
function readText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
@ -159,6 +163,21 @@ function readTextOrNumber(value: unknown): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function readSkuContentFieldFromMetadata(metadata: unknown, key: string): string {
|
||||
if (!isRecord(metadata)) return ''
|
||||
const rawFields = metadata[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
if (!isRecord(rawFields)) return ''
|
||||
return readTextOrNumber(rawFields[key])
|
||||
}
|
||||
|
||||
function resolveCorrespondingBarcodeFromVariant(variant: AssetVariantWithRelations): string {
|
||||
return (
|
||||
readSkuContentFieldFromMetadata(variant.skuGroup?.metadata, 'correspondingBarcode') ||
|
||||
readSkuContentFieldFromMetadata(variant.skuGroup?.metadata, 'barcode') ||
|
||||
readText(variant.barcode)
|
||||
).toUpperCase()
|
||||
}
|
||||
|
||||
function readPositiveQuantity(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value
|
||||
@ -529,12 +548,56 @@ class DraftGroupService {
|
||||
}
|
||||
|
||||
const preSkus = await preSkuRepository.findByDraftGroupId(id)
|
||||
const preSkuDTOs = preSkus.map((item) => toDraftGroupPreSkuDTO(item))
|
||||
return {
|
||||
draftGroup: toDraftGroupDTO(draftGroup),
|
||||
preSkus: preSkus.map((item) => toDraftGroupPreSkuDTO(item)),
|
||||
preSkus: await this.enrichSelectedVariantBarcodes(preSkuDTOs),
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichSelectedVariantBarcodes(
|
||||
preSkus: DraftGroupPreSkuDTO[],
|
||||
): Promise<DraftGroupPreSkuDTO[]> {
|
||||
const selectedVariantIds = Array.from(
|
||||
new Set(
|
||||
preSkus.flatMap((preSku) => [
|
||||
...preSku.variantSelection.slots.map((slot) => slot.variantId),
|
||||
...preSku.variantSelection.hardwareSelections.map((hardware) => hardware.variantId),
|
||||
]).filter((variantId) => variantId.trim().length > 0),
|
||||
),
|
||||
)
|
||||
if (selectedVariantIds.length === 0) return preSkus
|
||||
|
||||
const variants = await assetVariantRepository.findByIdsWithRelations(selectedVariantIds)
|
||||
const barcodeByVariantId = new Map(
|
||||
variants.map((variant) => [
|
||||
variant.id,
|
||||
{
|
||||
barcode: readText(variant.barcode).toUpperCase() || null,
|
||||
correspondingBarcode: resolveCorrespondingBarcodeFromVariant(variant) || null,
|
||||
skuGroupId: variant.skuGroupId ?? null,
|
||||
skuGroupCode: readText(variant.skuGroup?.code)?.toUpperCase() || null,
|
||||
skuGroupName: readText(variant.skuGroup?.name) || null,
|
||||
},
|
||||
]),
|
||||
)
|
||||
if (barcodeByVariantId.size === 0) return preSkus
|
||||
|
||||
return preSkus.map((preSku) => ({
|
||||
...preSku,
|
||||
variantSelection: {
|
||||
slots: preSku.variantSelection.slots.map((slot) => ({
|
||||
...slot,
|
||||
...barcodeByVariantId.get(slot.variantId),
|
||||
})),
|
||||
hardwareSelections: preSku.variantSelection.hardwareSelections.map((hardware) => ({
|
||||
...hardware,
|
||||
...barcodeByVariantId.get(hardware.variantId),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async updateBasicInfo(
|
||||
id: string,
|
||||
input: UpdateDraftGroupBasicInfoInput,
|
||||
|
||||
@ -125,9 +125,14 @@ export interface PreSkuSelectedSlotVariantDTO {
|
||||
role: string
|
||||
assetFamilyId: string
|
||||
variantId: string
|
||||
skuGroupId?: string | null
|
||||
skuGroupCode?: string | null
|
||||
skuGroupName?: string | null
|
||||
code: string
|
||||
name: string
|
||||
assetType: string
|
||||
barcode?: string | null
|
||||
correspondingBarcode?: string | null
|
||||
substrateId: string | null
|
||||
finishId: string | null
|
||||
dimensions: Record<string, number>
|
||||
@ -142,9 +147,14 @@ export interface PreSkuSelectedHardwareSelectionDTO {
|
||||
selectionKey: string
|
||||
assetFamilyId: string
|
||||
variantId: string
|
||||
skuGroupId?: string | null
|
||||
skuGroupCode?: string | null
|
||||
skuGroupName?: string | null
|
||||
code: string
|
||||
name: string
|
||||
assetType: string
|
||||
barcode?: string | null
|
||||
correspondingBarcode?: string | null
|
||||
substrateId: string | null
|
||||
finishId: string | null
|
||||
quantity: number
|
||||
@ -206,6 +216,7 @@ export interface PreSkuPackagingPlanMaterialDTO {
|
||||
assetFamilyId: string
|
||||
code: string
|
||||
name: string
|
||||
barcode?: string | null
|
||||
quantity: number
|
||||
unitCost: number
|
||||
totalCost: number
|
||||
@ -241,6 +252,7 @@ export interface SavePreSkuPackagingPlanMaterialInput {
|
||||
assetFamilyId?: string | null
|
||||
code?: string | null
|
||||
name?: string | null
|
||||
barcode?: string | null
|
||||
unitCost?: number | null
|
||||
totalCost?: number | null
|
||||
notes?: string | null
|
||||
|
||||
@ -167,10 +167,12 @@ export type {
|
||||
AssetSeriesDTO,
|
||||
AssetSeriesComponentDetailSummaryDTO,
|
||||
AssetSeriesSummaryDTO,
|
||||
AssetSkuGroupDTO,
|
||||
AssetVariantDTO,
|
||||
CreateAssetVariantResultDTO,
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetSeriesInput,
|
||||
CreateAssetSkuGroupInput,
|
||||
CreateAssetVariantInput,
|
||||
MaterialLikeDTO,
|
||||
SimilarAssetVariantCandidateDTO,
|
||||
@ -182,6 +184,7 @@ export type {
|
||||
SubstrateDictionaryType,
|
||||
UpdateAssetFamilyInput,
|
||||
UpdateAssetSeriesInput,
|
||||
UpdateAssetSkuGroupInput,
|
||||
UpdateAssetVariantInput,
|
||||
UpdateSubstrateDictionaryInput,
|
||||
UpdateSubstrateInput,
|
||||
|
||||
@ -2656,11 +2656,27 @@ function buildStockTextSearchWhere(
|
||||
conditions.push({
|
||||
skuItems: {
|
||||
some: {
|
||||
OR: [
|
||||
{
|
||||
barcode: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
skuCode: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceSkuId: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -3130,6 +3146,7 @@ function readSourceSkuIdSet(product: SharedNonIntegratedProductRecord | null | u
|
||||
const SERIES_COMPONENT_DETAIL_METADATA_TYPE = 'asset-series-components'
|
||||
const SERIES_COMPONENT_ASSET_SKU_SOURCE = 'asset_series_component'
|
||||
const ASSET_VARIANT_SOURCE_TABLE = 'asset_variant'
|
||||
const ASSET_SKU_GROUP_SOURCE_TABLE = 'asset_sku_group'
|
||||
const ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY = 'skuContentFields'
|
||||
const NON_BOARD_ASSET_BOARD_MATERIAL_TYPE = '非ENF级'
|
||||
const DEFAULT_ASSET_VARIANT_ATTRIBUTE_CODE = 'A'
|
||||
@ -3146,11 +3163,10 @@ function readPayloadString(item: Record<string, unknown>, key: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function readVariantSkuContentFields(
|
||||
variant: AssetVariantWithRelations,
|
||||
function readSkuContentFieldsFromRecord(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
): Record<string, string> {
|
||||
const businessData = isRecord(variant.businessData) ? variant.businessData : null
|
||||
const rawFields = businessData?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
const rawFields = record?.[ASSET_VARIANT_SKU_CONTENT_BUSINESS_DATA_KEY]
|
||||
if (!isRecord(rawFields)) return {}
|
||||
|
||||
return Object.entries(rawFields).reduce<Record<string, string>>((fields, [key, value]) => {
|
||||
@ -3163,6 +3179,31 @@ function readVariantSkuContentFields(
|
||||
}, {})
|
||||
}
|
||||
|
||||
function readVariantSkuContentFields(
|
||||
variant: AssetVariantWithRelations,
|
||||
): Record<string, string> {
|
||||
return readSkuContentFieldsFromRecord(
|
||||
isRecord(variant.businessData) ? variant.businessData : null,
|
||||
)
|
||||
}
|
||||
|
||||
function readSkuGroupSkuContentFields(
|
||||
variant: AssetVariantWithRelations,
|
||||
): Record<string, string> {
|
||||
return readSkuContentFieldsFromRecord(
|
||||
isRecord(variant.skuGroup?.metadata) ? variant.skuGroup.metadata : null,
|
||||
)
|
||||
}
|
||||
|
||||
function mergeSkuGroupAndVariantSkuContentFields(
|
||||
variant: AssetVariantWithRelations,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...readVariantSkuContentFields(variant),
|
||||
...readSkuGroupSkuContentFields(variant),
|
||||
}
|
||||
}
|
||||
|
||||
function getVariantSkuContentField(
|
||||
fields: Record<string, string>,
|
||||
key: string,
|
||||
@ -3255,6 +3296,169 @@ function createEmptyAssetSeriesComponentSkuItem(
|
||||
}
|
||||
}
|
||||
|
||||
type AssetSeriesComponentSkuGroup = {
|
||||
id: string
|
||||
hasSkuGroup: boolean
|
||||
code: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
assetFamilyCode: string
|
||||
variants: AssetVariantWithRelations[]
|
||||
}
|
||||
|
||||
function getAssetSeriesComponentSkuGroupId(variant: AssetVariantWithRelations) {
|
||||
const skuGroupId = variant.skuGroup?.id?.trim()
|
||||
if (skuGroupId) return skuGroupId
|
||||
|
||||
const skuGroupCode = variant.skuGroup?.code?.trim().toUpperCase()
|
||||
if (skuGroupCode) {
|
||||
return `sku-group-code:${variant.assetFamilyId}:${skuGroupCode}`
|
||||
}
|
||||
|
||||
return `variant:${variant.id}`
|
||||
}
|
||||
|
||||
function getAssetVariantCodeSequence(code: string) {
|
||||
const matched = code.trim().toUpperCase().match(/(\d+)$/)
|
||||
const value = matched ? Number.parseInt(matched[1] ?? '', 10) : 0
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function sortAssetSeriesComponentSkuGroupVariants(
|
||||
variants: AssetVariantWithRelations[],
|
||||
) {
|
||||
return [...variants].sort(
|
||||
(left, right) =>
|
||||
getAssetVariantCodeSequence(left.code) - getAssetVariantCodeSequence(right.code) ||
|
||||
left.code.localeCompare(right.code, 'zh-CN', { numeric: true }) ||
|
||||
left.name.localeCompare(right.name, 'zh-CN', { numeric: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function buildAssetSeriesComponentSkuGroups(
|
||||
variants: AssetVariantWithRelations[],
|
||||
): AssetSeriesComponentSkuGroup[] {
|
||||
const groupById = new Map<string, AssetSeriesComponentSkuGroup>()
|
||||
|
||||
variants.forEach((variant) => {
|
||||
const id = getAssetSeriesComponentSkuGroupId(variant)
|
||||
const hasSkuGroup = Boolean(
|
||||
variant.skuGroup?.id?.trim() || variant.skuGroup?.code?.trim(),
|
||||
)
|
||||
const code = (variant.skuGroup?.code?.trim() || variant.code.trim()).toUpperCase()
|
||||
const name = variant.skuGroup?.name?.trim() || variant.name.trim() || code
|
||||
const current = groupById.get(id)
|
||||
if (current) {
|
||||
current.variants.push(variant)
|
||||
return
|
||||
}
|
||||
|
||||
groupById.set(id, {
|
||||
id,
|
||||
hasSkuGroup,
|
||||
code,
|
||||
name,
|
||||
sortOrder: variant.skuGroup?.sortOrder ?? Number.MAX_SAFE_INTEGER,
|
||||
assetFamilyCode: variant.assetFamily.code,
|
||||
variants: [variant],
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(groupById.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
variants: sortAssetSeriesComponentSkuGroupVariants(group.variants),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.assetFamilyCode.localeCompare(right.assetFamilyCode, 'zh-CN', {
|
||||
numeric: true,
|
||||
}) ||
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.code.localeCompare(right.code, 'zh-CN', { numeric: true }),
|
||||
)
|
||||
}
|
||||
|
||||
function updateAssetSeriesComponentSkuItemFromGroup(
|
||||
item: NonIntegratedSkuItem,
|
||||
group: AssetSeriesComponentSkuGroup,
|
||||
skuSequence: number,
|
||||
): NonIntegratedSkuItem {
|
||||
const primaryVariant =
|
||||
group.variants.find((variant) => variant.code.trim().toUpperCase() === group.code) ??
|
||||
group.variants[0]
|
||||
if (!primaryVariant) return item
|
||||
if (!group.hasSkuGroup) {
|
||||
return updateAssetSeriesComponentSkuItemFromVariant(
|
||||
item,
|
||||
primaryVariant,
|
||||
skuSequence,
|
||||
)
|
||||
}
|
||||
|
||||
const nextItem = updateAssetSeriesComponentSkuItemFromVariant(
|
||||
item,
|
||||
primaryVariant,
|
||||
skuSequence,
|
||||
)
|
||||
const groupFields = readSkuGroupSkuContentFields(primaryVariant)
|
||||
|
||||
return {
|
||||
...nextItem,
|
||||
id: `asset-sku-group-${group.id}`,
|
||||
skuSequence,
|
||||
skuCode: group.code,
|
||||
specName: getVariantSkuContentField(groupFields, 'specName', group.name),
|
||||
componentDetails: group.variants.map((variant) => ({
|
||||
code: variant.code.trim().toUpperCase(),
|
||||
name: variant.name.trim(),
|
||||
quantity: '1',
|
||||
role: variant.assetFamily.name,
|
||||
assetType: variant.assetType,
|
||||
})),
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_SKU_GROUP_SOURCE_TABLE,
|
||||
sourceSkuId: group.id,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssetSeriesComponentSourceMarker(value: string | undefined) {
|
||||
return value?.trim().toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function isAssetSeriesComponentSkuItem(item: NonIntegratedSkuItem) {
|
||||
const source = normalizeAssetSeriesComponentSourceMarker(item.source)
|
||||
const sourceTable = normalizeAssetSeriesComponentSourceMarker(item.sourceTable)
|
||||
return (
|
||||
source === SERIES_COMPONENT_ASSET_SKU_SOURCE ||
|
||||
sourceTable === ASSET_VARIANT_SOURCE_TABLE ||
|
||||
sourceTable === ASSET_SKU_GROUP_SOURCE_TABLE
|
||||
)
|
||||
}
|
||||
|
||||
function hasAssetSeriesComponentSkuContent(item: NonIntegratedSkuItem) {
|
||||
return [
|
||||
item.image,
|
||||
item.model,
|
||||
item.skuCode,
|
||||
item.specName,
|
||||
item.barcode,
|
||||
item.size,
|
||||
item.color,
|
||||
item.material,
|
||||
item.boardMaterialType ?? '',
|
||||
item.attributeCode,
|
||||
item.drawingId,
|
||||
item.installMethod,
|
||||
item.executeStandard,
|
||||
item.nationalStandardCompliant,
|
||||
item.manufacturer,
|
||||
item.manufacturerCode,
|
||||
item.salesStatus,
|
||||
item.model3dDataUrl,
|
||||
].some((value) => value.trim().length > 0)
|
||||
}
|
||||
|
||||
function updateAssetSeriesComponentSkuItemFromVariant(
|
||||
item: NonIntegratedSkuItem,
|
||||
variant: AssetVariantWithRelations,
|
||||
@ -3265,7 +3469,10 @@ function updateAssetSeriesComponentSkuItemFromVariant(
|
||||
const normalizedModel = variant.assetFamily.code.trim().toUpperCase()
|
||||
const normalizedModelUrl = variant.modelUrl.trim()
|
||||
const thumbnailUrl = variant.thumbnailUrl?.trim() ?? ''
|
||||
const skuContentFields = readVariantSkuContentFields(variant)
|
||||
const skuContentFields = mergeSkuGroupAndVariantSkuContentFields(variant)
|
||||
const isStockLinkedSku = Boolean(
|
||||
getVariantSkuContentField(skuContentFields, 'correspondingBarcode').trim(),
|
||||
)
|
||||
const rawVersionNo = hasVariantSkuContentField(skuContentFields, 'versionNo')
|
||||
? skuContentFields.versionNo ?? ''
|
||||
: (variant.versionNo ?? readPayloadString(itemRecord, 'versionNo')) || DEFAULT_SKU_VERSION_NO
|
||||
@ -3379,6 +3586,7 @@ function updateAssetSeriesComponentSkuItemFromVariant(
|
||||
source: SERIES_COMPONENT_ASSET_SKU_SOURCE,
|
||||
sourceTable: ASSET_VARIANT_SOURCE_TABLE,
|
||||
sourceSkuId: variant.id,
|
||||
contentLocked: isStockLinkedSku,
|
||||
}
|
||||
}
|
||||
|
||||
@ -3427,45 +3635,66 @@ async function mergeMissingAssetSeriesComponentSkuItems(
|
||||
const activeVariants = await assetVariantRepository.findActiveBySeriesCode(seriesCode)
|
||||
if (activeVariants.length === 0) return product
|
||||
|
||||
const activeVariantById = new Map(activeVariants.map((variant) => [variant.id, variant]))
|
||||
const activeVariantByCode = new Map(
|
||||
activeVariants.map((variant) => [variant.code.trim().toUpperCase(), variant]),
|
||||
)
|
||||
const existingSourceSkuIds = new Set<string>()
|
||||
const existingSkuCodes = new Set<string>()
|
||||
const syncedItems = product.skuItems.map((item, index) => {
|
||||
const activeGroups = buildAssetSeriesComponentSkuGroups(activeVariants)
|
||||
const groupById = new Map(activeGroups.map((group) => [group.id, group]))
|
||||
const groupByCode = new Map(activeGroups.map((group) => [group.code, group]))
|
||||
const groupByVariantId = new Map<string, AssetSeriesComponentSkuGroup>()
|
||||
const groupByVariantCode = new Map<string, AssetSeriesComponentSkuGroup>()
|
||||
activeGroups.forEach((group) => {
|
||||
group.variants.forEach((variant) => {
|
||||
groupByVariantId.set(variant.id, group)
|
||||
groupByVariantCode.set(variant.code.trim().toUpperCase(), group)
|
||||
})
|
||||
})
|
||||
|
||||
const currentItemByGroupId = new Map<string, NonIntegratedSkuItem>()
|
||||
const passthroughItems: NonIntegratedSkuItem[] = []
|
||||
|
||||
product.skuItems.forEach((item) => {
|
||||
const sourceSkuId = item.sourceSkuId?.trim() ?? ''
|
||||
const skuCode = item.skuCode?.trim().toUpperCase() ?? ''
|
||||
const matchedVariant =
|
||||
(sourceSkuId ? activeVariantById.get(sourceSkuId) : undefined) ??
|
||||
(skuCode ? activeVariantByCode.get(skuCode) : undefined)
|
||||
const nextItem = matchedVariant
|
||||
? updateAssetSeriesComponentSkuItemFromVariant(item, matchedVariant, index + 1)
|
||||
: item
|
||||
const matchedGroup =
|
||||
(sourceSkuId ? groupById.get(sourceSkuId) : undefined) ??
|
||||
(sourceSkuId ? groupByVariantId.get(sourceSkuId) : undefined) ??
|
||||
(skuCode ? groupByCode.get(skuCode) : undefined) ??
|
||||
(skuCode ? groupByVariantCode.get(skuCode) : undefined)
|
||||
|
||||
const nextSourceSkuId = nextItem.sourceSkuId?.trim()
|
||||
if (nextSourceSkuId) existingSourceSkuIds.add(nextSourceSkuId)
|
||||
if (matchedGroup) {
|
||||
if (!currentItemByGroupId.has(matchedGroup.id)) {
|
||||
currentItemByGroupId.set(matchedGroup.id, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextSkuCode = nextItem.skuCode?.trim().toUpperCase()
|
||||
if (nextSkuCode) existingSkuCodes.add(nextSkuCode)
|
||||
|
||||
return nextItem
|
||||
if (!isAssetSeriesComponentSkuItem(item) && hasAssetSeriesComponentSkuContent(item)) {
|
||||
passthroughItems.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
const missingVariants = activeVariants.filter((variant) => {
|
||||
const variantCode = variant.code.trim().toUpperCase()
|
||||
return !existingSourceSkuIds.has(variant.id) && !existingSkuCodes.has(variantCode)
|
||||
})
|
||||
const syncedItems = activeGroups
|
||||
.map((group, index) => {
|
||||
const primaryVariant = group.variants[0]
|
||||
if (!primaryVariant) return null
|
||||
const skuSequence = index + 1
|
||||
const currentItem =
|
||||
currentItemByGroupId.get(group.id) ??
|
||||
createEmptyAssetSeriesComponentSkuItem(primaryVariant, skuSequence)
|
||||
|
||||
const appendedItems = missingVariants.map((variant, index) => {
|
||||
const skuSequence = syncedItems.length + index + 1
|
||||
return updateAssetSeriesComponentSkuItemFromVariant(
|
||||
createEmptyAssetSeriesComponentSkuItem(variant, skuSequence),
|
||||
variant,
|
||||
return updateAssetSeriesComponentSkuItemFromGroup(
|
||||
currentItem,
|
||||
group,
|
||||
skuSequence,
|
||||
)
|
||||
})
|
||||
const skuItems = [...syncedItems, ...appendedItems]
|
||||
.filter((item): item is NonIntegratedSkuItem => item !== null)
|
||||
const passthroughStartSequence = syncedItems.length
|
||||
const skuItems = [
|
||||
...syncedItems,
|
||||
...passthroughItems.map((item, index) => ({
|
||||
...item,
|
||||
skuSequence: passthroughStartSequence + index + 1,
|
||||
})),
|
||||
]
|
||||
|
||||
return {
|
||||
...product,
|
||||
|
||||
@ -142,6 +142,7 @@ function materializePackagingMaterials(
|
||||
assetFamilyId: variant.assetFamilyId,
|
||||
code: variant.code,
|
||||
name: variant.name,
|
||||
barcode: toNullableTrimmedString(material.barcode) ?? toNullableTrimmedString(variant.barcode),
|
||||
quantity,
|
||||
unitCost,
|
||||
totalCost,
|
||||
|
||||
@ -31,6 +31,9 @@ export function toOpenProductVariants(
|
||||
return {
|
||||
id: record.id,
|
||||
assetFamilyId: record.assetFamilyId,
|
||||
skuGroupId: record.skuGroupId,
|
||||
skuGroupCode: record.skuGroup?.code ?? null,
|
||||
skuGroupName: record.skuGroup?.name ?? null,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
assetType: record.assetType,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user