feat(material-library): 基础资料库重构 — 基材录入+饰面花色完整改造
- 基材编码自动生成 BC-0001/BC-0002 依次递增 - 基材名称自动生成:18MM E1 刨花板 北美原木纹色 双贴 - 完全相同规格(含花色)重复检测 - 实木特殊处理:无贴面,花色自动筛选实木花色分类 - 材质字段支持下拉选择+手动输入 - SubstrateFinish 多对多、surfaceOptionId 可空 - 软删除花色名称不阻塞重建
This commit is contained in:
parent
3436335b7d
commit
c3b73bdbdb
@ -436,9 +436,20 @@ model PlanningDraft {
|
||||
model Substrate {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String @unique
|
||||
name String
|
||||
description String? @db.Text
|
||||
densityKgM3 Float?
|
||||
categoryId String
|
||||
category SubstrateCategory @relation(fields: [categoryId], references: [id])
|
||||
environmentGradeId String
|
||||
environmentGrade SubstrateEnvironmentGrade @relation(fields: [environmentGradeId], references: [id])
|
||||
thicknessSpecId String
|
||||
thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id])
|
||||
surfaceOptionId String?
|
||||
surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id])
|
||||
systemNameOptionId String
|
||||
systemNameOption SubstrateSystemNameOption @relation(fields: [systemNameOptionId], references: [id])
|
||||
suggestedPrice Decimal? @db.Decimal(10, 2)
|
||||
weightKg Float?
|
||||
metadata Json?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@ -446,12 +457,111 @@ model Substrate {
|
||||
creator User? @relation("SubstrateCreator", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
assetVariants AssetVariant[]
|
||||
finishes SubstrateFinish[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isActive])
|
||||
@@index([createdBy])
|
||||
@@index([categoryId])
|
||||
@@index([environmentGradeId])
|
||||
@@index([thicknessSpecId])
|
||||
@@index([surfaceOptionId])
|
||||
@@index([systemNameOptionId])
|
||||
// 5字段唯一约束已移除:同一板材规格+不同花色=不同基材,各自有独立BC-XXXX编码
|
||||
}
|
||||
|
||||
model SubstrateFinish {
|
||||
id String @id @default(cuid())
|
||||
substrateId String
|
||||
substrate Substrate @relation(fields: [substrateId], references: [id], onDelete: Cascade)
|
||||
finishId String
|
||||
finish Material @relation(fields: [finishId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([substrateId, finishId])
|
||||
@@index([substrateId])
|
||||
@@index([finishId])
|
||||
}
|
||||
|
||||
model SubstrateCategory {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
substrates Substrate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
model SubstrateEnvironmentGrade {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
substrates Substrate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
model SubstrateThicknessSpec {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
thicknessMm Float
|
||||
sheetLengthMm Float @default(2440)
|
||||
sheetWidthMm Float @default(1220)
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
substrates Substrate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
model SubstrateSurfaceOption {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
substrates Substrate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
model SubstrateSystemNameOption {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
substrates Substrate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([sortOrder])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
model AssetFamily {
|
||||
@ -521,6 +631,8 @@ model AssetVariant {
|
||||
businessData Json?
|
||||
weightKg Float?
|
||||
costPrice Decimal? @db.Decimal(10, 2)
|
||||
lossFactor Float?
|
||||
estimatedCostPrice Decimal? @db.Decimal(10, 2)
|
||||
|
||||
inheritedFromVariantId String?
|
||||
inheritedFromVariant AssetVariant? @relation("AssetVariantInheritance", fields: [inheritedFromVariantId], references: [id], onDelete: SetNull)
|
||||
@ -554,6 +666,7 @@ model AssetVariant {
|
||||
@@index([reviewedById])
|
||||
@@index([rejectedById])
|
||||
@@index([lastModifiedById])
|
||||
@@index([inheritedFromVariantId])
|
||||
}
|
||||
|
||||
model SeriesTemplate {
|
||||
@ -1070,6 +1183,7 @@ model Material {
|
||||
|
||||
// 基本信息
|
||||
name String // 材质名称,如 "橡木纹-自然色"
|
||||
code String @unique // 花色编码
|
||||
categoryId String // 分类 ID
|
||||
category MaterialCategory @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@ -1101,6 +1215,7 @@ model Material {
|
||||
// 关联板件材质
|
||||
panelMaterials PanelMaterial[]
|
||||
assetVariants AssetVariant[] @relation("AssetVariantFinish")
|
||||
substrateFinishes SubstrateFinish[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -57,6 +57,8 @@ export const assetVariantCreateSchema = z.object({
|
||||
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
weightKg: z.number().optional().nullable(),
|
||||
costPrice: z.number().optional().nullable(),
|
||||
lossFactor: z.number().positive().optional().nullable(),
|
||||
estimatedCostPrice: z.number().optional().nullable(),
|
||||
inheritedFromVariantId: z.string().trim().min(1).optional().nullable(),
|
||||
})
|
||||
|
||||
@ -86,6 +88,8 @@ export const assetVariantUpdateSchema = z.object({
|
||||
businessData: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
weightKg: z.number().optional().nullable(),
|
||||
costPrice: z.number().optional().nullable(),
|
||||
lossFactor: z.number().positive().optional().nullable(),
|
||||
estimatedCostPrice: z.number().optional().nullable(),
|
||||
})
|
||||
|
||||
export const assetReviewActionSchema = z.object({
|
||||
@ -94,22 +98,62 @@ export const assetReviewActionSchema = z.object({
|
||||
})
|
||||
|
||||
export const substrateCreateSchema = z.object({
|
||||
code: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
code: z.string().trim().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
densityKgM3: z.number().optional().nullable(),
|
||||
categoryId: z.string().trim().min(1),
|
||||
environmentGradeId: z.string().trim().min(1),
|
||||
thicknessSpecId: z.string().trim().min(1),
|
||||
surfaceOptionId: z.string().trim().min(1).optional().nullable(),
|
||||
systemNameOptionId: z.string().trim().min(1),
|
||||
suggestedPrice: z.number().nonnegative().optional().nullable(),
|
||||
weightKg: z.number().positive().optional().nullable(),
|
||||
finishIds: z.array(z.string().trim().min(1)).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
|
||||
export const substrateUpdateSchema = z.object({
|
||||
code: z.string().trim().min(1).optional(),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
densityKgM3: z.number().optional().nullable(),
|
||||
categoryId: z.string().trim().min(1).optional(),
|
||||
environmentGradeId: z.string().trim().min(1).optional(),
|
||||
thicknessSpecId: z.string().trim().min(1).optional(),
|
||||
surfaceOptionId: z.string().trim().min(1).optional().nullable(),
|
||||
systemNameOptionId: z.string().trim().min(1).optional(),
|
||||
suggestedPrice: z.number().nonnegative().optional().nullable(),
|
||||
weightKg: z.number().positive().optional().nullable(),
|
||||
finishIds: z.array(z.string().trim().min(1)).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const substrateDictionaryTypeSchema = z.enum([
|
||||
'CATEGORY',
|
||||
'ENVIRONMENT_GRADE',
|
||||
'THICKNESS_SPEC',
|
||||
'SURFACE_OPTION',
|
||||
'SYSTEM_NAME_OPTION',
|
||||
])
|
||||
|
||||
export const substrateDictionaryCreateSchema = z.object({
|
||||
type: substrateDictionaryTypeSchema,
|
||||
name: z.string().trim().min(1),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
thicknessMm: z.number().positive().optional().nullable(),
|
||||
sheetLengthMm: z.number().positive().optional().nullable(),
|
||||
sheetWidthMm: z.number().positive().optional().nullable(),
|
||||
})
|
||||
|
||||
export const substrateDictionaryUpdateSchema = z.object({
|
||||
type: substrateDictionaryTypeSchema,
|
||||
name: z.string().trim().min(1).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
thicknessMm: z.number().positive().optional().nullable(),
|
||||
sheetLengthMm: z.number().positive().optional().nullable(),
|
||||
sheetWidthMm: z.number().positive().optional().nullable(),
|
||||
})
|
||||
|
||||
export const assetCadImportPayloadSchema = z.object({
|
||||
assetType: z.enum(['BOARD', 'COMPONENT', 'HARDWARE']),
|
||||
familyMode: assetCadImportFamilyModeSchema,
|
||||
@ -123,8 +167,7 @@ export const assetCadImportPayloadSchema = z.object({
|
||||
sizeLabel: z.string().trim().optional().nullable(),
|
||||
substrateId: z.string().trim().min(1).optional().nullable(),
|
||||
finishId: z.string().trim().min(1).optional().nullable(),
|
||||
weightKg: z.number().optional().nullable(),
|
||||
costPrice: z.number().optional().nullable(),
|
||||
lossFactor: z.number().positive().optional().nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.familyMode === 'existing' && !value.familyId) {
|
||||
ctx.addIssue({
|
||||
@ -198,6 +241,8 @@ export const createDraftGroupSchema = z.object({
|
||||
z.object({
|
||||
key: z.string().trim().min(1),
|
||||
defaultName: z.string().trim().min(1),
|
||||
candidateSnapshot: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
assemblyWorkspace: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
familySelection: z.object({
|
||||
desktopFamilyId: z.string().trim().min(1),
|
||||
legGroupFamilyId: z.string().trim().min(1).optional().nullable(),
|
||||
|
||||
@ -1,46 +1,21 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import MaterialLibraryClient from './MaterialLibraryClient'
|
||||
import { createSubstrate, updateMaterialCategory } from '@/lib/open-product/platform-api'
|
||||
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services'
|
||||
import { createSubstrate } from '@/lib/open-product/platform-api'
|
||||
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO } from '@/server/services'
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/components/ui/Modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div role="dialog" aria-label={typeof title === 'string' ? title : undefined}>
|
||||
<div>{title}</div>
|
||||
<div>{children}</div>
|
||||
<div>{footer}</div>
|
||||
</div>
|
||||
) : null,
|
||||
default: ({ isOpen, title, children, footer }: { isOpen: boolean; title?: ReactNode; children: ReactNode; footer?: ReactNode }) =>
|
||||
isOpen ? <div role="dialog" aria-label={typeof title === 'string' ? title : undefined}><div>{children}</div><div>{footer}</div></div> : null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
|
||||
@ -50,77 +25,67 @@ jest.mock('@/components/ui/DeleteConfirmDialog', () => ({
|
||||
|
||||
jest.mock('@/components/ui/Toast', () => ({
|
||||
__esModule: true,
|
||||
useToast: () => ({
|
||||
toasts: [],
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
info: jest.fn(),
|
||||
removeToast: jest.fn(),
|
||||
}),
|
||||
useToast: () => ({ toasts: [], success: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn(), removeToast: jest.fn() }),
|
||||
ToastContainer: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
|
||||
__esModule: true,
|
||||
PlatformSplitLayout: ({
|
||||
leftContent,
|
||||
rightContent,
|
||||
}: {
|
||||
leftContent: ReactNode
|
||||
rightContent: ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<div>{leftContent}</div>
|
||||
<div>{rightContent}</div>
|
||||
</div>
|
||||
),
|
||||
PlatformSplitLayout: ({ leftContent, rightContent }: { leftContent: ReactNode; rightContent: ReactNode }) => <div><div>{leftContent}</div><div>{rightContent}</div></div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/open-product/platform-api', () => ({
|
||||
__esModule: true,
|
||||
createMaterial: jest.fn(),
|
||||
createMaterialCategory: jest.fn(),
|
||||
createSubstrate: jest.fn(),
|
||||
deleteMaterial: jest.fn(),
|
||||
deleteMaterialCategory: jest.fn(),
|
||||
deleteSubstrate: jest.fn(),
|
||||
updateMaterial: jest.fn(),
|
||||
updateMaterialCategory: jest.fn(),
|
||||
updateSubstrate: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedCreateSubstrate = jest.mocked(createSubstrate)
|
||||
const mockedUpdateMaterialCategory = jest.mocked(updateMaterialCategory)
|
||||
|
||||
const dictionaries: SubstrateDictionaryBundleDTO = {
|
||||
categories: [{ id: 'cat-board', type: 'CATEGORY', name: '密度板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
environmentGrades: [{ id: 'grade-e1', type: 'ENVIRONMENT_GRADE', name: 'E1', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
thicknessSpecs: [{ id: 'th-18', type: 'THICKNESS_SPEC', name: '18', sortOrder: 0, isActive: true, thicknessMm: 18, sheetLengthMm: 2440, sheetWidthMm: 1220, createdAt: new Date(), updatedAt: new Date() }],
|
||||
surfaceOptions: [{ id: 'surface-double', type: 'SURFACE_OPTION', name: '双贴', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
systemNameOptions: [{ id: 'sys-18e1', type: 'SYSTEM_NAME_OPTION', name: '18E1级刨花板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
|
||||
}
|
||||
|
||||
function renderClient() {
|
||||
return render(
|
||||
<MaterialLibraryClient
|
||||
initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]}
|
||||
initialFinishes={[
|
||||
{
|
||||
id: 'finish-1',
|
||||
name: '白橡木纹',
|
||||
categoryId: 'cat-1',
|
||||
categoryName: '木纹',
|
||||
thumbnailUrl: null,
|
||||
textureUrl: null,
|
||||
color: '#CCCCCC',
|
||||
metalness: 0,
|
||||
roughness: 0.5,
|
||||
opacity: 1,
|
||||
textureRepeat: 1,
|
||||
textureRotation: 0,
|
||||
textureSizeMm: 600,
|
||||
isPreset: false,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-04-08T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-08T00:00:00.000Z'),
|
||||
},
|
||||
] as unknown as MaterialDTO[]}
|
||||
initialMaterialCategories={[
|
||||
{ id: 'cat-1', name: '木纹', icon: 'Texture', sortOrder: 1, materialCount: 1 },
|
||||
] as MaterialCategoryDTO[]}
|
||||
initialSubstrates={[{
|
||||
id: 'substrate-1',
|
||||
code: 'PB18',
|
||||
name: '18E1级刨花板',
|
||||
description: null,
|
||||
categoryId: 'cat-board',
|
||||
categoryName: '密度板',
|
||||
environmentGradeId: 'grade-e1',
|
||||
environmentGradeName: 'E1',
|
||||
thicknessSpecId: 'th-18',
|
||||
thicknessSpecName: '18',
|
||||
thicknessMm: 18,
|
||||
sheetLengthMm: 2440,
|
||||
sheetWidthMm: 1220,
|
||||
surfaceOptionId: 'surface-double',
|
||||
surfaceOptionName: '双贴',
|
||||
systemNameOptionId: 'sys-18e1',
|
||||
systemNameOptionName: '18E1级刨花板',
|
||||
suggestedPrice: 180,
|
||||
weightKg: null,
|
||||
finishes: [],
|
||||
metadata: null,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}] as SubstrateDTO[]}
|
||||
initialFinishes={[] as MaterialDTO[]}
|
||||
initialMaterialCategories={[] as MaterialCategoryDTO[]}
|
||||
initialSubstrateDictionaries={dictionaries}
|
||||
metrics={[]}
|
||||
/>,
|
||||
)
|
||||
@ -131,97 +96,57 @@ describe('MaterialLibraryClient', () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('新建基材后使用后端返回的完整字段刷新列表', async () => {
|
||||
it('新建基材时按规则字段提交', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedCreateSubstrate.mockResolvedValue({
|
||||
substrate: {
|
||||
id: 'substrate-2',
|
||||
code: 'PB25',
|
||||
name: '25mm 颗粒板',
|
||||
description: '后端标准说明',
|
||||
densityKgM3: 720,
|
||||
metadata: { source: 'server' },
|
||||
name: '18E1级刨花板',
|
||||
description: null,
|
||||
categoryId: 'cat-board',
|
||||
categoryName: '密度板',
|
||||
environmentGradeId: 'grade-e1',
|
||||
environmentGradeName: 'E1',
|
||||
thicknessSpecId: 'th-18',
|
||||
thicknessSpecName: '18',
|
||||
thicknessMm: 18,
|
||||
sheetLengthMm: 2440,
|
||||
sheetWidthMm: 1220,
|
||||
surfaceOptionId: 'surface-double',
|
||||
surfaceOptionName: '双贴',
|
||||
systemNameOptionId: 'sys-18e1',
|
||||
systemNameOptionName: '18E1级刨花板',
|
||||
suggestedPrice: 200,
|
||||
weightKg: null,
|
||||
finishes: [],
|
||||
metadata: null,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-04-08T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-08T10:00:00.000Z'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
renderClient()
|
||||
|
||||
// Switch to substrate tab first
|
||||
await actor.click(screen.getByRole('button', { name: /基材录入/ }))
|
||||
await actor.click(screen.getByRole('button', { name: '新建基材' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '新建基材' })
|
||||
const inputs = within(dialog).getAllByRole('textbox')
|
||||
await actor.type(inputs[0], 'PB25')
|
||||
await actor.type(inputs[1], '25mm 颗粒板')
|
||||
await actor.type(inputs[2], '前端输入的旧说明')
|
||||
await actor.type(within(dialog).getByPlaceholderText('例如 680'), '680')
|
||||
await actor.click(within(dialog).getByRole('button', { name: '创建基材' }))
|
||||
await actor.type(screen.getByLabelText('板材编码'), 'PB25')
|
||||
await actor.type(screen.getByLabelText('售价'), '200')
|
||||
await actor.click(screen.getByRole('button', { name: '创建' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCreateSubstrate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'PB25',
|
||||
name: '25mm 颗粒板',
|
||||
categoryId: 'cat-board',
|
||||
environmentGradeId: 'grade-e1',
|
||||
thicknessSpecId: 'th-18',
|
||||
surfaceOptionId: 'surface-double',
|
||||
systemNameOptionId: 'sys-18e1',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('25mm 颗粒板')).toBeInTheDocument()
|
||||
expect(screen.getByText('PB25')).toBeInTheDocument()
|
||||
expect(screen.getByText('720 kg/m³')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('编辑材质分类后,饰面搜索会立即使用新分类名', async () => {
|
||||
const actor = userEvent.setup()
|
||||
mockedUpdateMaterialCategory.mockResolvedValue({
|
||||
category: {
|
||||
id: 'cat-1',
|
||||
name: '深木纹',
|
||||
icon: 'Texture',
|
||||
sortOrder: 1,
|
||||
materialCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
renderClient()
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: '饰面花色' }))
|
||||
|
||||
const categoryTitle = screen.getAllByText('木纹').find((element) => element.tagName === 'P')
|
||||
if (!categoryTitle) {
|
||||
throw new Error('未找到分类标题')
|
||||
}
|
||||
|
||||
const categoryBlock = categoryTitle.closest('div')?.parentElement
|
||||
if (!categoryBlock) {
|
||||
throw new Error('未找到分类区块')
|
||||
}
|
||||
|
||||
const categoryButtons = within(categoryBlock).getAllByRole('button')
|
||||
await actor.click(categoryButtons[0])
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '编辑材质分类' })
|
||||
const categoryNameInput = within(dialog).getByPlaceholderText('例如 木纹、石纹、纯色')
|
||||
await actor.clear(categoryNameInput)
|
||||
await actor.type(categoryNameInput, '深木纹')
|
||||
await actor.click(within(dialog).getByRole('button', { name: '保存修改' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedUpdateMaterialCategory).toHaveBeenCalledWith(
|
||||
'cat-1',
|
||||
expect.objectContaining({ name: '深木纹' }),
|
||||
)
|
||||
})
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索分类或饰面名称...')
|
||||
await actor.clear(searchInput)
|
||||
await actor.type(searchInput, '深木纹')
|
||||
|
||||
expect(screen.getByText('白橡木纹')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,10 +6,11 @@ import MaterialLibraryClient from './MaterialLibraryClient'
|
||||
export default async function MaterialLibraryPage() {
|
||||
await requireSession('/login')
|
||||
|
||||
const [substrates, finishes, materialCategories] = await Promise.all([
|
||||
const [substrates, finishes, materialCategories, substrateDictionaries] = await Promise.all([
|
||||
assetCenterService.listSubstrates(),
|
||||
materialService.findAll({ limit: 500 }),
|
||||
materialService.findAllCategories(),
|
||||
assetCenterService.getSubstrateDictionaryBundle(),
|
||||
])
|
||||
|
||||
return (
|
||||
@ -17,21 +18,22 @@ export default async function MaterialLibraryPage() {
|
||||
initialSubstrates={substrates}
|
||||
initialFinishes={finishes}
|
||||
initialMaterialCategories={materialCategories}
|
||||
initialSubstrateDictionaries={substrateDictionaries}
|
||||
metrics={[
|
||||
{
|
||||
label: '基材',
|
||||
value: substrates.length,
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
label: '饰面花色',
|
||||
value: finishes.length,
|
||||
icon: <Palette className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: '材质分类',
|
||||
label: '花色分类',
|
||||
value: materialCategories.length,
|
||||
icon: <Layers3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: '饰面/花色',
|
||||
value: finishes.length,
|
||||
icon: <Palette className="h-4 w-4" />,
|
||||
label: '板材基材',
|
||||
value: substrates.length,
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -8,10 +8,14 @@ import type {
|
||||
CreateAssetFamilyInput,
|
||||
CreateAssetVariantInput,
|
||||
CreateDraftGroupFromAutoCombinationInput,
|
||||
CreateSubstrateDictionaryInput,
|
||||
UpdateSubstrateInput,
|
||||
MaterialCategoryDTO,
|
||||
CreateSubstrateInput,
|
||||
SubstrateDTO,
|
||||
SubstrateDictionaryBundleDTO,
|
||||
SubstrateDictionaryOptionDTO,
|
||||
UpdateSubstrateDictionaryInput,
|
||||
DraftGroupDetailDTO,
|
||||
FormalizeDraftGroupInput,
|
||||
FormalizeDraftGroupResult,
|
||||
@ -128,8 +132,7 @@ export interface ImportAssetCadPayload {
|
||||
sizeLabel?: string | null
|
||||
substrateId?: string | null
|
||||
finishId?: string | null
|
||||
weightKg?: number | null
|
||||
costPrice?: number | null
|
||||
lossFactor?: number | null
|
||||
file: File
|
||||
}
|
||||
|
||||
@ -149,8 +152,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise<Asse
|
||||
if (input.sizeLabel) formData.append('sizeLabel', input.sizeLabel)
|
||||
if (input.substrateId) formData.append('substrateId', input.substrateId)
|
||||
if (input.finishId) formData.append('finishId', input.finishId)
|
||||
if (input.weightKg != null) formData.append('weightKg', String(input.weightKg))
|
||||
if (input.costPrice != null) formData.append('costPrice', String(input.costPrice))
|
||||
if (input.lossFactor != null) formData.append('lossFactor', String(input.lossFactor))
|
||||
formData.append('file', input.file)
|
||||
|
||||
const response = await fetch('/api/asset-center/import-cad', {
|
||||
@ -194,6 +196,52 @@ export async function deleteSubstrate(id: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSubstrateDictionaries(): Promise<SubstrateDictionaryBundleDTO> {
|
||||
const data = await requestPlatform<{ dictionaries: SubstrateDictionaryBundleDTO }>(
|
||||
'/api/asset-center/substrate-dictionaries',
|
||||
)
|
||||
return data.dictionaries
|
||||
}
|
||||
|
||||
export async function createSubstrateDictionary(
|
||||
input: CreateSubstrateDictionaryInput,
|
||||
): Promise<SubstrateDictionaryOptionDTO> {
|
||||
const data = await requestPlatform<{ option: SubstrateDictionaryOptionDTO }>(
|
||||
'/api/asset-center/substrate-dictionaries',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
)
|
||||
return data.option
|
||||
}
|
||||
|
||||
export async function updateSubstrateDictionary(
|
||||
id: string,
|
||||
input: UpdateSubstrateDictionaryInput & { type: CreateSubstrateDictionaryInput['type'] },
|
||||
): Promise<SubstrateDictionaryOptionDTO> {
|
||||
const data = await requestPlatform<{ option: SubstrateDictionaryOptionDTO }>(
|
||||
`/api/asset-center/substrate-dictionaries/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
)
|
||||
return data.option
|
||||
}
|
||||
|
||||
export async function deleteSubstrateDictionary(
|
||||
id: string,
|
||||
type: CreateSubstrateDictionaryInput['type'],
|
||||
): Promise<void> {
|
||||
await requestPlatform<{ success: boolean }>(
|
||||
`/api/asset-center/substrate-dictionaries/${id}?type=${encodeURIComponent(type)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function createMaterialCategory(input: {
|
||||
name: string
|
||||
icon?: string | null
|
||||
@ -229,6 +277,7 @@ export async function deleteMaterialCategory(id: string): Promise<void> {
|
||||
|
||||
export async function createMaterial(input: {
|
||||
name: string
|
||||
code?: string
|
||||
categoryId: string
|
||||
color?: string
|
||||
thumbnailUrl?: string | null
|
||||
@ -239,10 +288,29 @@ export async function createMaterial(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadMaterialThumbnail(file: File, category?: string): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'thumbnail')
|
||||
if (category) formData.append('category', category)
|
||||
|
||||
const response = await fetch('/api/admin/materials/upload-texture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as ApiEnvelope<{ url: string }> | null
|
||||
if (!response.ok || !payload?.success || payload.data === undefined) {
|
||||
throw new Error(payload?.error?.message || '图片上传失败')
|
||||
}
|
||||
return payload.data.url
|
||||
}
|
||||
|
||||
export async function updateMaterial(
|
||||
id: string,
|
||||
input: {
|
||||
name?: string
|
||||
code?: string
|
||||
categoryId?: string
|
||||
color?: string
|
||||
thumbnailUrl?: string | null
|
||||
@ -261,7 +329,7 @@ export async function deleteMaterial(id: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateAutoCombinations(
|
||||
export async function generateAssistedAssemblyCandidates(
|
||||
input: {
|
||||
seriesTemplateId: string
|
||||
desktopFamilyIds: string[]
|
||||
@ -270,7 +338,7 @@ export async function generateAutoCombinations(
|
||||
},
|
||||
): Promise<CombinationGenerationResult> {
|
||||
const data = await requestPlatform<{ result: CombinationGenerationResult }>(
|
||||
'/api/auto-combinations/generate',
|
||||
'/api/assisted-assembly/generate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
@ -279,6 +347,8 @@ export async function generateAutoCombinations(
|
||||
return data.result
|
||||
}
|
||||
|
||||
export const generateAutoCombinations = generateAssistedAssemblyCandidates
|
||||
|
||||
export async function createDraftGroup(
|
||||
input: CreateDraftGroupFromAutoCombinationInput,
|
||||
): Promise<DraftGroupDetailDTO> {
|
||||
|
||||
@ -49,6 +49,7 @@ export function buildStandardFinishMaterialData(params: {
|
||||
|
||||
return {
|
||||
name: material.name,
|
||||
code: material.name,
|
||||
categoryId,
|
||||
thumbnailUrl: merged.thumbnailUrl || defaultThumbnailUrl,
|
||||
color: merged.color ?? material.color,
|
||||
|
||||
@ -35,6 +35,15 @@ export interface AssetVariantWithRelations extends AssetVariant {
|
||||
lastModifier: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export interface SubstrateWithRelations extends Substrate {
|
||||
category: { id: string; name: string }
|
||||
environmentGrade: { id: string; name: string }
|
||||
thicknessSpec: { id: string; name: string; thicknessMm: number; sheetLengthMm: number; sheetWidthMm: number }
|
||||
surfaceOption: { id: string; name: string } | null
|
||||
systemNameOption: { id: string; name: string }
|
||||
finishes: { id: string; finish: { id: string; name: string; code: string; thumbnailUrl: string } }[]
|
||||
}
|
||||
|
||||
export interface SubstrateFilter {
|
||||
isActive?: boolean
|
||||
search?: string
|
||||
@ -60,6 +69,23 @@ const assetVariantRelationInclude = {
|
||||
lastModifier: { select: { id: true, name: true } },
|
||||
} satisfies Prisma.AssetVariantInclude
|
||||
|
||||
const substrateRelationInclude = {
|
||||
category: { select: { id: true, name: true } },
|
||||
environmentGrade: { select: { id: true, name: true } },
|
||||
thicknessSpec: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
thicknessMm: true,
|
||||
sheetLengthMm: true,
|
||||
sheetWidthMm: true,
|
||||
},
|
||||
},
|
||||
surfaceOption: { select: { id: true, name: true } },
|
||||
systemNameOption: { select: { id: true, name: true } },
|
||||
finishes: { include: { finish: { select: { id: true, name: true, code: true, thumbnailUrl: true } } } },
|
||||
} satisfies Prisma.SubstrateInclude
|
||||
|
||||
class AssetFamilyRepositoryClass extends BaseRepository<
|
||||
AssetFamily,
|
||||
Prisma.AssetFamilyCreateInput,
|
||||
@ -271,6 +297,36 @@ class SubstrateRepositoryClass extends BaseRepository<
|
||||
return this.prisma.substrate.findUnique({ where: { code } })
|
||||
}
|
||||
|
||||
async findByIdWithRelations(id: string): Promise<SubstrateWithRelations | null> {
|
||||
return this.prisma.substrate.findUnique({
|
||||
where: { id },
|
||||
include: substrateRelationInclude,
|
||||
}) as Promise<SubstrateWithRelations | null>
|
||||
}
|
||||
|
||||
async findManyWithRelations(
|
||||
options: FindManyOptions<SubstrateFilter> = {},
|
||||
): Promise<{ items: SubstrateWithRelations[]; total: number }> {
|
||||
const { filter, pagination, sort } = options
|
||||
const where = this.buildWhereClause(filter)
|
||||
const orderBy = sort ? { [sort.field]: sort.order } : [{ name: 'asc' as const }]
|
||||
const skip = pagination ? (pagination.page - 1) * pagination.limit : 0
|
||||
const take = pagination?.limit
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.substrate.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
include: substrateRelationInclude,
|
||||
}),
|
||||
this.prisma.substrate.count({ where }),
|
||||
])
|
||||
|
||||
return { items: items as SubstrateWithRelations[], total }
|
||||
}
|
||||
|
||||
protected buildWhereClause(filter?: SubstrateFilter): Prisma.SubstrateWhereInput {
|
||||
if (!filter) return {}
|
||||
|
||||
|
||||
@ -74,6 +74,7 @@ class MaterialRepositoryClass extends BaseRepository<
|
||||
where: {
|
||||
name,
|
||||
categoryId,
|
||||
isActive: true,
|
||||
id: excludeId ? { not: excludeId } : undefined,
|
||||
},
|
||||
})
|
||||
@ -164,6 +165,18 @@ class MaterialRepositoryClass extends BaseRepository<
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前最大 HS-XXXX 编号
|
||||
*/
|
||||
async findMaxCode(): Promise<string | null> {
|
||||
const result = await this.prisma.material.findFirst({
|
||||
where: { code: { startsWith: 'HS-' } },
|
||||
orderBy: { code: 'desc' },
|
||||
select: { code: true },
|
||||
})
|
||||
return result?.code ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取材质使用数量(被多少个板件使用)
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -61,6 +61,8 @@ export interface AssetVariantDTO {
|
||||
businessData: Record<string, unknown> | null
|
||||
weightKg: number | null
|
||||
costPrice: number | null
|
||||
lossFactor: number | null
|
||||
estimatedCostPrice: number | null
|
||||
inheritedFromVariantId: string | null
|
||||
createdBy: string | null
|
||||
creatorName: string | null
|
||||
@ -74,13 +76,56 @@ export interface SubstrateDTO {
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
densityKgM3: number | null
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
environmentGradeId: string
|
||||
environmentGradeName: string
|
||||
thicknessSpecId: string
|
||||
thicknessSpecName: string
|
||||
thicknessMm: number
|
||||
sheetLengthMm: number
|
||||
sheetWidthMm: number
|
||||
surfaceOptionId: string | null
|
||||
surfaceOptionName: string | null
|
||||
systemNameOptionId: string
|
||||
systemNameOptionName: string
|
||||
suggestedPrice: number | null
|
||||
weightKg: number | null
|
||||
finishes: { id: string; name: string; code: string; thumbnailUrl: string }[]
|
||||
metadata: Record<string, unknown> | null
|
||||
isActive: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export type SubstrateDictionaryType =
|
||||
| 'CATEGORY'
|
||||
| 'ENVIRONMENT_GRADE'
|
||||
| 'THICKNESS_SPEC'
|
||||
| 'SURFACE_OPTION'
|
||||
| 'SYSTEM_NAME_OPTION'
|
||||
|
||||
export interface SubstrateDictionaryOptionDTO {
|
||||
id: string
|
||||
type: SubstrateDictionaryType
|
||||
name: string
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
thicknessMm?: number | null
|
||||
sheetLengthMm?: number | null
|
||||
sheetWidthMm?: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface SubstrateDictionaryBundleDTO {
|
||||
categories: SubstrateDictionaryOptionDTO[]
|
||||
environmentGrades: SubstrateDictionaryOptionDTO[]
|
||||
thicknessSpecs: SubstrateDictionaryOptionDTO[]
|
||||
surfaceOptions: SubstrateDictionaryOptionDTO[]
|
||||
systemNameOptions: SubstrateDictionaryOptionDTO[]
|
||||
}
|
||||
|
||||
export interface CreateAssetFamilyInput {
|
||||
type: AssetType
|
||||
code: string
|
||||
@ -124,6 +169,8 @@ export interface CreateAssetVariantInput {
|
||||
businessData?: Record<string, unknown> | null
|
||||
weightKg?: number | null
|
||||
costPrice?: number | null
|
||||
lossFactor?: number | null
|
||||
estimatedCostPrice?: number | null
|
||||
inheritedFromVariantId?: string | null
|
||||
createdBy?: string | null
|
||||
}
|
||||
@ -146,33 +193,60 @@ export interface UpdateAssetVariantInput {
|
||||
businessData?: Record<string, unknown> | null
|
||||
weightKg?: number | null
|
||||
costPrice?: number | null
|
||||
lossFactor?: number | null
|
||||
estimatedCostPrice?: number | null
|
||||
updatedBy?: string | null
|
||||
}
|
||||
|
||||
export interface CreateSubstrateInput {
|
||||
code: string
|
||||
name: string
|
||||
code?: string
|
||||
description?: string | null
|
||||
densityKgM3?: number | null
|
||||
categoryId: string
|
||||
environmentGradeId: string
|
||||
thicknessSpecId: string
|
||||
surfaceOptionId?: string | null | null
|
||||
systemNameOptionId: string
|
||||
suggestedPrice?: number | null
|
||||
weightKg?: number | null
|
||||
finishIds?: string[]
|
||||
metadata?: Record<string, unknown> | null
|
||||
createdBy?: string | null
|
||||
}
|
||||
|
||||
export interface EnsureBoardLibraryDefaultsResult {
|
||||
finishCategoryId: string
|
||||
finishNames: string[]
|
||||
substrates: SubstrateDTO[]
|
||||
}
|
||||
|
||||
export interface UpdateSubstrateInput {
|
||||
code?: string
|
||||
name?: string
|
||||
description?: string | null
|
||||
densityKgM3?: number | null
|
||||
categoryId?: string
|
||||
environmentGradeId?: string
|
||||
thicknessSpecId?: string
|
||||
surfaceOptionId?: string | null
|
||||
systemNameOptionId?: string
|
||||
suggestedPrice?: number | null
|
||||
weightKg?: number | null
|
||||
finishIds?: string[]
|
||||
metadata?: Record<string, unknown> | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface CreateSubstrateDictionaryInput {
|
||||
type: SubstrateDictionaryType
|
||||
name: string
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
thicknessMm?: number | null
|
||||
sheetLengthMm?: number | null
|
||||
sheetWidthMm?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateSubstrateDictionaryInput {
|
||||
name?: string
|
||||
sortOrder?: number
|
||||
isActive?: boolean
|
||||
thicknessMm?: number | null
|
||||
sheetLengthMm?: number | null
|
||||
sheetWidthMm?: number | null
|
||||
}
|
||||
|
||||
export interface AssetReviewActionInput {
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import { normalizeMaterialAssetPath } from '@/lib/material-asset-path'
|
||||
export interface MaterialDTO {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
categoryId: string
|
||||
categoryName?: string
|
||||
categoryIcon?: string | null
|
||||
@ -68,6 +69,7 @@ export interface MaterialCategoryWithMaterialsDTO {
|
||||
|
||||
export interface CreateMaterialInput {
|
||||
name: string
|
||||
code?: string
|
||||
categoryId: string
|
||||
thumbnailUrl?: string
|
||||
textureUrl?: string | null
|
||||
@ -132,6 +134,17 @@ class MaterialService {
|
||||
return Math.min(Math.max(value as number, 10), 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一个花色编码 HS-XXXX
|
||||
*/
|
||||
private async generateNextCode(): Promise<string> {
|
||||
const maxCode = await materialRepository.findMaxCode()
|
||||
if (!maxCode) return 'HS-0001'
|
||||
const numPart = parseInt(maxCode.split('-')[1], 10)
|
||||
if (Number.isNaN(numPart)) return 'HS-0001'
|
||||
return `HS-${String(numPart + 1).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
// ============ 材质方法 ============
|
||||
|
||||
/**
|
||||
@ -230,6 +243,7 @@ class MaterialService {
|
||||
|
||||
const createData = {
|
||||
name: input.name,
|
||||
code: input.code?.trim() || await this.generateNextCode(),
|
||||
category: { connect: { id: input.categoryId } },
|
||||
thumbnailUrl: input.thumbnailUrl || '/materials/thumbnails/default.jpg',
|
||||
textureUrl: input.textureUrl,
|
||||
@ -544,6 +558,7 @@ class MaterialService {
|
||||
private toMaterialDTO(material: {
|
||||
id: string
|
||||
name: string
|
||||
code?: string
|
||||
categoryId: string
|
||||
thumbnailUrl: string
|
||||
textureUrl: string | null
|
||||
@ -566,6 +581,7 @@ class MaterialService {
|
||||
const dto: MaterialDTO = {
|
||||
id: material.id,
|
||||
name: material.name,
|
||||
code: (material as { code?: string }).code ?? '',
|
||||
categoryId: material.categoryId,
|
||||
categoryName: material.category?.name,
|
||||
categoryIcon: material.category?.icon,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user