feat(material-library): 基础资料库重构 — 基材录入+饰面花色完整改造

- 基材编码自动生成 BC-0001/BC-0002 依次递增
- 基材名称自动生成:18MM E1 刨花板 北美原木纹色 双贴
- 完全相同规格(含花色)重复检测
- 实木特殊处理:无贴面,花色自动筛选实木花色分类
- 材质字段支持下拉选择+手动输入
- SubstrateFinish 多对多、surfaceOptionId 可空
- 软删除花色名称不阻塞重建
This commit is contained in:
Codex 2026-04-10 19:53:58 +08:00
parent 3436335b7d
commit c3b73bdbdb
12 changed files with 1863 additions and 1059 deletions

View File

@ -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

View File

@ -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(),

View File

@ -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()
})
})

View File

@ -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" />,
},
]}
/>

View File

@ -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> {

View File

@ -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,

View File

@ -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 {}

View File

@ -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
}
/**
* 获取材质使用数量(被多少个板件使用)
*/

View File

@ -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
}

View File

@ -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,