From c3b73bdbdbbfe4e4c74066dfd52a306641fc4d4d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Apr 2026 19:53:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(material-library):=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=B5=84=E6=96=99=E5=BA=93=E9=87=8D=E6=9E=84=20=E2=80=94=20?= =?UTF-8?q?=E5=9F=BA=E6=9D=90=E5=BD=95=E5=85=A5+=E9=A5=B0=E9=9D=A2?= =?UTF-8?q?=E8=8A=B1=E8=89=B2=E5=AE=8C=E6=95=B4=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 基材编码自动生成 BC-0001/BC-0002 依次递增 - 基材名称自动生成:18MM E1 刨花板 北美原木纹色 双贴 - 完全相同规格(含花色)重复检测 - 实木特殊处理:无贴面,花色自动筛选实木花色分类 - 材质字段支持下拉选择+手动输入 - SubstrateFinish 多对多、surfaceOptionId 可空 - 软删除花色名称不阻塞重建 --- furniture-3d-system/prisma/schema.prisma | 119 +- .../src/app/api/platform/schemas.ts | 59 +- .../MaterialLibraryClient.test.tsx | 231 +-- .../MaterialLibraryClient.tsx | 1390 +++++++++-------- .../asset-center/material-library/page.tsx | 18 +- .../src/lib/open-product/platform-api.ts | 82 +- .../src/lib/standard-finish-sync.ts | 1 + .../repositories/asset-center.repository.ts | 56 + .../repositories/material.repository.ts | 13 + .../server/services/asset-center.service.ts | 839 +++++++--- .../services/asset-center.service.types.ts | 98 +- .../src/server/services/material.service.ts | 16 + 12 files changed, 1863 insertions(+), 1059 deletions(-) diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 8f4f1933..35a307b5 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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 diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index 015f3a7c..e69d92f3 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -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(), diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx index 30d281af..67e38c10 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx @@ -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 - }) => ( - - {children} - + default: ({ href, children, ...props }: { href: string; children: ReactNode }) => ( + {children} ), })) jest.mock('@/components/ui/Modal', () => ({ __esModule: true, - default: ({ - isOpen, - title, - children, - footer, - }: { - isOpen: boolean - title?: ReactNode - children: ReactNode - footer?: ReactNode - }) => - isOpen ? ( -
-
{title}
-
{children}
-
{footer}
-
- ) : null, + default: ({ isOpen, title, children, footer }: { isOpen: boolean; title?: ReactNode; children: ReactNode; footer?: ReactNode }) => + isOpen ?
{children}
{footer}
: 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 - }) => ( -
-
{leftContent}
-
{rightContent}
-
- ), + PlatformSplitLayout: ({ leftContent, rightContent }: { leftContent: ReactNode; rightContent: ReactNode }) =>
{leftContent}
{rightContent}
, })) 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( , ) @@ -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() }) }) - diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index 951769fe..ce0d34e7 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -1,9 +1,11 @@ 'use client' import Link from 'next/link' -import { startTransition, useMemo, useState } from 'react' +import { startTransition, useMemo, useRef, useState } from 'react' import { ArrowLeft, + Check, + ImagePlus, Package2, Pencil, Plus, @@ -15,7 +17,6 @@ import { import Button from '@/components/ui/Button' import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog' import Input from '@/components/ui/Input' -import Modal from '@/components/ui/Modal' import Select from '@/components/ui/Select' import { ToastContainer, useToast } from '@/components/ui/Toast' import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' @@ -23,387 +24,390 @@ import type { PlatformMetricItem } from '@/components/platform/types' import { cn } from '@/lib/utils' import { createMaterial, - createMaterialCategory, createSubstrate, + createSubstrateDictionary, deleteMaterial, - deleteMaterialCategory, deleteSubstrate, updateMaterial, - updateMaterialCategory, updateSubstrate, + uploadMaterialThumbnail, } from '@/lib/open-product/platform-api' -import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO } from '@/server/services' +import type { + MaterialCategoryDTO, + MaterialDTO, + SubstrateDTO, + SubstrateDictionaryBundleDTO, +} from '@/server/services' -type LibraryTab = 'substrate' | 'finish' +type LibraryTab = 'finish' | 'substrate' + +type RightPanel = + | { mode: 'empty' } + | { mode: 'create-finish' } + | { mode: 'view-finish'; id: string } + | { mode: 'edit-finish'; id: string } + | { mode: 'create-substrate' } + | { mode: 'view-substrate'; id: string } + | { mode: 'edit-substrate'; id: string } interface MaterialLibraryClientProps { initialSubstrates: SubstrateDTO[] initialFinishes: MaterialDTO[] initialMaterialCategories: MaterialCategoryDTO[] + initialSubstrateDictionaries: SubstrateDictionaryBundleDTO metrics: PlatformMetricItem[] } type SubstrateFormState = { - code: string - name: string - description: string - densityKgM3: string -} - -type CategoryFormState = { - name: string - icon: string - sortOrder: string + categoryId: string + categoryText: string + environmentGradeId: string + thicknessSpecId: string + surfaceOptionId: string + systemNameOptionId: string + suggestedPrice: string + weightKg: string + finishIds: string[] + newFinishName: string } type FinishFormState = { name: string categoryId: string - color: string + thumbnailUrl: string + thumbnailPreview: string } type DeleteTarget = | { type: 'substrate'; id: string; name: string } - | { type: 'category'; id: string; name: string } | { type: 'finish'; id: string; name: string } function sortByName(items: T[]): T[] { - return [...items].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')) + return [...items].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) } -function sortMaterialCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] { - return [...items].sort((left, right) => { - const orderDiff = (left.sortOrder ?? 0) - (right.sortOrder ?? 0) - if (orderDiff !== 0) return orderDiff - return left.name.localeCompare(right.name, 'zh-CN') +function sortCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] { + return [...items].sort((a, b) => { + const diff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + return diff !== 0 ? diff : a.name.localeCompare(b.name, 'zh-CN') }) } -function groupFinishesByCategory( - finishes: MaterialDTO[], - categories: MaterialCategoryDTO[], - activeCategoryId: string, -) { - const categoryMap = new Map(categories.map((item) => [item.id, item])) - const grouped = categories - .map((category) => ({ - category, - finishes: sortByName(finishes.filter((finish) => finish.categoryId === category.id)), - })) - .filter(({ category, finishes }) => { - if (activeCategoryId === 'all') { - return finishes.length > 0 || (category.materialCount ?? 0) > 0 - } - return category.id === activeCategoryId - }) - - const uncategorized = sortByName( - finishes.filter((finish) => !finish.categoryId || !categoryMap.has(finish.categoryId)), - ) - - if (activeCategoryId === 'all' && uncategorized.length > 0) { - grouped.push({ - category: { - id: 'uncategorized', - name: '未分类', - icon: null, - sortOrder: Number.MAX_SAFE_INTEGER, - materialCount: uncategorized.length, - }, - finishes: uncategorized, - }) - } - - return grouped -} - -function toOptionalNumber(value: string): number | null { +function toNum(value: string): number | null { if (!value.trim()) return null - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null + const n = Number(value) + return Number.isFinite(n) ? n : null } -function emptySubstrateForm(): SubstrateFormState { - return { code: '', name: '', description: '', densityKgM3: '' } -} - -function emptyCategoryForm(): CategoryFormState { - return { name: '', icon: '', sortOrder: '' } -} - -function emptyFinishForm(categoryId = ''): FinishFormState { - return { name: '', categoryId, color: '#CCCCCC' } +function emptySubstrateForm(dict: SubstrateDictionaryBundleDTO): SubstrateFormState { + return { + categoryId: dict.categories[0]?.id ?? '', + categoryText: dict.categories[0]?.name ?? '', + environmentGradeId: dict.environmentGrades[0]?.id ?? '', + thicknessSpecId: dict.thicknessSpecs[0]?.id ?? '', + surfaceOptionId: dict.surfaceOptions[0]?.id ?? '', + systemNameOptionId: dict.systemNameOptions[0]?.id ?? '', + suggestedPrice: '', + weightKg: '', + finishIds: [], + newFinishName: '', + } } export default function MaterialLibraryClient({ initialSubstrates, initialFinishes, initialMaterialCategories, + initialSubstrateDictionaries, metrics, }: MaterialLibraryClientProps) { const toast = useToast() - const [activeTab, setActiveTab] = useState('substrate') + const [activeTab, setActiveTab] = useState('finish') const [searchText, setSearchText] = useState('') const [selectedCategoryId, setSelectedCategoryId] = useState('all') const [substrates, setSubstrates] = useState(sortByName(initialSubstrates)) const [finishes, setFinishes] = useState(sortByName(initialFinishes)) - const [materialCategories, setMaterialCategories] = useState( - sortMaterialCategories(initialMaterialCategories), - ) + const [categories] = useState(sortCategories(initialMaterialCategories)) + const [dictionaries] = useState(initialSubstrateDictionaries) const [submitting, setSubmitting] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(null) - const [substrateModalOpen, setSubstrateModalOpen] = useState(false) - const [substrateModalMode, setSubstrateModalMode] = useState<'create' | 'edit'>('create') - const [substrateForm, setSubstrateForm] = useState(emptySubstrateForm()) - const [editingSubstrateId, setEditingSubstrateId] = useState(null) + // Right panel state + const [panel, setPanel] = useState({ mode: 'empty' }) - const [categoryModalOpen, setCategoryModalOpen] = useState(false) - const [categoryModalMode, setCategoryModalMode] = useState<'create' | 'edit'>('create') - const [categoryForm, setCategoryForm] = useState(emptyCategoryForm()) - const [editingCategoryId, setEditingCategoryId] = useState(null) + // Form states + const [finishForm, setFinishForm] = useState({ + name: '', categoryId: '', thumbnailUrl: '', thumbnailPreview: '', + }) + const [substrateForm, setSubstrateForm] = useState( + emptySubstrateForm(initialSubstrateDictionaries), + ) + const [finishPickerOpen, setFinishPickerOpen] = useState(false) - const [finishModalOpen, setFinishModalOpen] = useState(false) - const [finishModalMode, setFinishModalMode] = useState<'create' | 'edit'>('create') - const [finishForm, setFinishForm] = useState(emptyFinishForm()) - const [editingFinishId, setEditingFinishId] = useState(null) - - const filteredSubstrates = useMemo(() => { - if (!searchText.trim()) return substrates - const keyword = searchText.trim().toLowerCase() - return substrates.filter((item) => - [item.name, item.code, item.description ?? ''].some((value) => - value.toLowerCase().includes(keyword), - ), - ) - }, [searchText, substrates]) + // Dict lookups + const dictById = useMemo(() => { + const all = [ + ...dictionaries.categories, + ...dictionaries.environmentGrades, + ...dictionaries.thicknessSpecs, + ...dictionaries.surfaceOptions, + ...dictionaries.systemNameOptions, + ] + const map = new Map() + for (const item of all) map.set(item.id, item) + return map + }, [dictionaries]) + // Filtered lists const filteredFinishes = useMemo(() => { - const keyword = searchText.trim().toLowerCase() - return finishes.filter((item) => { - const matchesCategory = selectedCategoryId === 'all' || item.categoryId === selectedCategoryId - if (!matchesCategory) return false - if (!keyword) return true - return [item.name, item.categoryName ?? ''].some((value) => - value.toLowerCase().includes(keyword), - ) + const kw = searchText.trim().toLowerCase() + return finishes.filter((f) => { + if (selectedCategoryId !== 'all' && f.categoryId !== selectedCategoryId) return false + if (!kw) return true + return [f.name, f.code, f.categoryName ?? ''].some((v) => v.toLowerCase().includes(kw)) }) }, [finishes, searchText, selectedCategoryId]) - const groupedFinishes = useMemo( - () => groupFinishesByCategory(filteredFinishes, materialCategories, selectedCategoryId), - [filteredFinishes, materialCategories, selectedCategoryId], - ) + const filteredSubstrates = useMemo(() => { + if (!searchText.trim()) return substrates + const kw = searchText.trim().toLowerCase() + return substrates.filter((s) => + [s.name, s.code, s.categoryName, s.environmentGradeName].some((v) => + v.toLowerCase().includes(kw), + ), + ) + }, [substrates, searchText]) - function openCreateSubstrateModal() { - setSubstrateModalMode('create') - setEditingSubstrateId(null) - setSubstrateForm(emptySubstrateForm()) - setSubstrateModalOpen(true) + const isSolidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText === '实木' + + // Filter finishes by material type + const filteredFinishesForSubstrate = useMemo(() => { + if (isSolidWood) { + return finishes.filter((f) => f.categoryName === '实木花色') + } + return finishes.filter((f) => f.categoryName !== '实木花色') + }, [finishes, isSolidWood]) + + // Get the default finish category for inline creation + const defaultFinishCategoryId = useMemo(() => { + if (isSolidWood) { + return categories.find((c) => c.name === '实木花色')?.id ?? '' + } + return categories.find((c) => c.name === '木纹贴面花色')?.id ?? categories[0]?.id ?? '' + }, [categories, isSolidWood]) + + const previewSubstrateName = useMemo(() => { + const cat = substrateForm.categoryText.trim() || dictById.get(substrateForm.categoryId)?.name || '' + const env = dictById.get(substrateForm.environmentGradeId)?.name ?? '' + const thick = dictionaries.thicknessSpecs.find((t) => t.id === substrateForm.thicknessSpecId) + const surface = isSolidWood ? '' : (dictById.get(substrateForm.surfaceOptionId)?.name ?? '') + const finishNames = substrateForm.finishIds + .map((fid) => finishes.find((f) => f.id === fid)?.name) + .filter(Boolean) as string[] + const newFinish = substrateForm.newFinishName.trim() + if (newFinish) finishNames.push(newFinish) + if (!cat || !env || !thick) return '' + const thickness = thick.name.toUpperCase() + const finishPart = finishNames.length > 0 ? finishNames.join(' ') : '' + return [thickness, env, cat, finishPart, surface].filter(Boolean).join(' ') + }, [substrateForm, dictById, dictionaries.thicknessSpecs, finishes, isSolidWood]) + + // Selected items + const selectedFinish = useMemo(() => { + if (panel.mode === 'view-finish' || panel.mode === 'edit-finish') { + return finishes.find((f) => f.id === panel.id) ?? null + } + return null + }, [panel, finishes]) + + const selectedSubstrate = useMemo(() => { + if (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') { + return substrates.find((s) => s.id === panel.id) ?? null + } + return null + }, [panel, substrates]) + + // ---- Actions ---- + + function selectFinish(f: MaterialDTO) { + setPanel({ mode: 'view-finish', id: f.id }) } - function openEditSubstrateModal(substrate: SubstrateDTO) { - setSubstrateModalMode('edit') - setEditingSubstrateId(substrate.id) - setSubstrateForm({ - code: substrate.code, - name: substrate.name, - description: substrate.description ?? '', - densityKgM3: substrate.densityKgM3 != null ? String(substrate.densityKgM3) : '', - }) - setSubstrateModalOpen(true) + function selectSubstrate(s: SubstrateDTO) { + setPanel({ mode: 'view-substrate', id: s.id }) } - function openCreateCategoryModal() { - setCategoryModalMode('create') - setEditingCategoryId(null) - setCategoryForm(emptyCategoryForm()) - setCategoryModalOpen(true) + function startCreateFinish() { + const defaultCat = selectedCategoryId !== 'all' ? selectedCategoryId : categories[0]?.id ?? '' + setFinishForm({ name: '', categoryId: defaultCat, thumbnailUrl: '', thumbnailPreview: '' }) + setPanel({ mode: 'create-finish' }) } - function openEditCategoryModal(category: MaterialCategoryDTO) { - setCategoryModalMode('edit') - setEditingCategoryId(category.id) - setCategoryForm({ - name: category.name, - icon: category.icon ?? '', - sortOrder: String(category.sortOrder ?? 0), - }) - setCategoryModalOpen(true) - } - - function openCreateFinishModal() { - const preferredCategoryId = - selectedCategoryId !== 'all' ? selectedCategoryId : materialCategories[0]?.id ?? '' - setFinishModalMode('create') - setEditingFinishId(null) - setFinishForm(emptyFinishForm(preferredCategoryId)) - setFinishModalOpen(true) - } - - function openEditFinishModal(finish: MaterialDTO) { - setFinishModalMode('edit') - setEditingFinishId(finish.id) + function startEditFinish(f: MaterialDTO) { setFinishForm({ - name: finish.name, - categoryId: finish.categoryId, - color: finish.color || '#CCCCCC', + name: f.name, + categoryId: f.categoryId, + thumbnailUrl: f.thumbnailUrl ?? '', + thumbnailPreview: f.thumbnailUrl ?? '', }) - setFinishModalOpen(true) + setPanel({ mode: 'edit-finish', id: f.id }) + } + + function startCreateSubstrate() { + setSubstrateForm(emptySubstrateForm(dictionaries)) + setPanel({ mode: 'create-substrate' }) + } + + function startEditSubstrate(s: SubstrateDTO) { + setSubstrateForm({ + categoryId: s.categoryId, + categoryText: s.categoryName, + environmentGradeId: s.environmentGradeId, + thicknessSpecId: s.thicknessSpecId, + surfaceOptionId: s.surfaceOptionId, + systemNameOptionId: s.systemNameOptionId, + suggestedPrice: s.suggestedPrice != null ? String(s.suggestedPrice) : '', + weightKg: s.weightKg != null ? String(s.weightKg) : '', + finishIds: s.finishes.map((f) => f.id), + newFinishName: '', + }) + setPanel({ mode: 'edit-substrate', id: s.id }) + } + + async function handleSaveFinish() { + if (!finishForm.name.trim()) { toast.error('请填写花色名称'); return } + if (!finishForm.categoryId) { toast.error('请选择花色分类'); return } + + setSubmitting(true) + try { + if (panel.mode === 'create-finish') { + const result = await createMaterial({ + name: finishForm.name.trim(), + categoryId: finishForm.categoryId, + thumbnailUrl: finishForm.thumbnailUrl.trim() || null, + color: '#CCCCCC', + }) + startTransition(() => { + setFinishes((cur) => sortByName([...cur, result])) + setPanel({ mode: 'view-finish', id: result.id }) + toast.success('花色已创建') + }) + return + } + if (panel.mode !== 'edit-finish') return + const result = await updateMaterial(panel.id, { + name: finishForm.name.trim(), + categoryId: finishForm.categoryId, + thumbnailUrl: finishForm.thumbnailUrl.trim() || null, + }) + startTransition(() => { + setFinishes((cur) => sortByName(cur.map((f) => (f.id === panel.id ? result : f)))) + setPanel({ mode: 'view-finish', id: panel.id }) + toast.success('花色已更新') + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : '保存失败') + } finally { + setSubmitting(false) + } } async function handleSaveSubstrate() { - if (!substrateForm.code.trim() || !substrateForm.name.trim()) { - toast.error('请先补齐基材编码和名称') - return + const solidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText.trim() === '实木' + if (!substrateForm.categoryText.trim() || !substrateForm.environmentGradeId || !substrateForm.thicknessSpecId || !substrateForm.systemNameOptionId) { + toast.error('请补齐必填选项'); return + } + if (!solidWood && !substrateForm.surfaceOptionId) { + toast.error('请选择贴面类型'); return } setSubmitting(true) try { - if (substrateModalMode === 'create') { - const result = await createSubstrate({ - code: substrateForm.code.trim(), - name: substrateForm.name.trim(), - description: substrateForm.description.trim() || null, - densityKgM3: toOptionalNumber(substrateForm.densityKgM3), + // Auto-create new material category if user typed a new name + let categoryId = substrateForm.categoryId + if (!categoryId && substrateForm.categoryText.trim()) { + const newCat = await createSubstrateDictionary({ + type: 'CATEGORY', + name: substrateForm.categoryText.trim(), }) + categoryId = newCat.id + } + if (!categoryId) { + toast.error('请输入或选择材质'); setSubmitting(false); return + } + // Create inline finish if name provided and not empty + let finishIds = [...substrateForm.finishIds] + const newFinishName = substrateForm.newFinishName.trim() + if (newFinishName) { + // Check if a finish with this name already exists locally + const existingFinish = finishes.find((f) => f.name === newFinishName) + if (existingFinish) { + if (!finishIds.includes(existingFinish.id)) { + finishIds = [...finishIds, existingFinish.id] + } + } else { + const newFinish = await createMaterial({ + name: newFinishName, + categoryId: defaultFinishCategoryId, + color: '#CCCCCC', + }) + startTransition(() => { + setFinishes((cur) => sortByName([...cur, newFinish])) + }) + finishIds = [...finishIds, newFinish.id] + } + } + + const payload = { + categoryId, + environmentGradeId: substrateForm.environmentGradeId, + thicknessSpecId: substrateForm.thicknessSpecId, + surfaceOptionId: solidWood ? undefined : substrateForm.surfaceOptionId, + systemNameOptionId: substrateForm.systemNameOptionId, + suggestedPrice: toNum(substrateForm.suggestedPrice), + weightKg: toNum(substrateForm.weightKg), + finishIds: finishIds.length > 0 ? finishIds : undefined, + } + + if (panel.mode === 'create-substrate') { + const result = await createSubstrate(payload) startTransition(() => { - setSubstrates((current) => sortByName([...current, result.substrate])) - setSubstrateModalOpen(false) + setSubstrates((cur) => sortByName([...cur, result.substrate])) + setPanel({ mode: 'view-substrate', id: result.substrate.id }) toast.success('基材已创建') }) return } - - if (!editingSubstrateId) return - const result = await updateSubstrate(editingSubstrateId, { - code: substrateForm.code.trim(), - name: substrateForm.name.trim(), - description: substrateForm.description.trim() || null, - densityKgM3: toOptionalNumber(substrateForm.densityKgM3), - }) - + if (panel.mode !== 'edit-substrate') return + const result = await updateSubstrate(panel.id, payload) startTransition(() => { - setSubstrates((current) => - sortByName(current.map((item) => (item.id === editingSubstrateId ? result.substrate : item))), - ) - setSubstrateModalOpen(false) + setSubstrates((cur) => sortByName(cur.map((s) => (s.id === panel.id ? result.substrate : s)))) + setPanel({ mode: 'view-substrate', id: panel.id }) toast.success('基材已更新') }) } catch (error) { - toast.error(error instanceof Error ? error.message : '基材保存失败,请稍后再试') + toast.error(error instanceof Error ? error.message : '保存失败') } finally { setSubmitting(false) } } - async function handleSaveCategory() { - if (!categoryForm.name.trim()) { - toast.error('请填写分类名称') - return - } - - setSubmitting(true) + async function handleThumbnailUpload(file: File) { + if (!file) return + setUploading(true) try { - if (categoryModalMode === 'create') { - const result = await createMaterialCategory({ - name: categoryForm.name.trim(), - icon: categoryForm.icon.trim() || null, - sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined, - }) - - startTransition(() => { - setMaterialCategories((current) => sortMaterialCategories([...current, result.category])) - setSelectedCategoryId(result.category.id) - setCategoryModalOpen(false) - toast.success('材质分类已创建') - }) - return - } - - if (!editingCategoryId) return - const result = await updateMaterialCategory(editingCategoryId, { - name: categoryForm.name.trim(), - icon: categoryForm.icon.trim() || null, - sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined, - }) - - startTransition(() => { - setMaterialCategories((current) => - sortMaterialCategories( - current.map((item) => (item.id === editingCategoryId ? result.category : item)), - ), - ) - setFinishes((current) => - sortByName( - current.map((item) => - item.categoryId === editingCategoryId - ? { - ...item, - categoryName: result.category.name, - } - : item, - ), - ), - ) - setCategoryModalOpen(false) - toast.success('材质分类已更新') - }) + const cat = categories.find((c) => c.id === finishForm.categoryId) + const url = await uploadMaterialThumbnail(file, cat?.name) + setFinishForm((c) => ({ ...c, thumbnailUrl: url, thumbnailPreview: url })) + toast.success('贴图已上传') } catch (error) { - toast.error(error instanceof Error ? error.message : '材质分类保存失败,请稍后再试') + toast.error(error instanceof Error ? error.message : '上传失败') } finally { - setSubmitting(false) - } - } - - async function handleSaveFinish() { - if (!finishForm.name.trim()) { - toast.error('请填写饰面/花色名称') - return - } - if (!finishForm.categoryId) { - toast.error('请先选择材质分类') - return - } - - setSubmitting(true) - try { - if (finishModalMode === 'create') { - const material = await createMaterial({ - name: finishForm.name.trim(), - categoryId: finishForm.categoryId, - color: finishForm.color || '#CCCCCC', - }) - - startTransition(() => { - setFinishes((current) => sortByName([...current, material])) - setSelectedCategoryId(material.categoryId) - setFinishModalOpen(false) - toast.success('饰面/花色已创建') - }) - return - } - - if (!editingFinishId) return - const material = await updateMaterial(editingFinishId, { - name: finishForm.name.trim(), - categoryId: finishForm.categoryId, - color: finishForm.color || '#CCCCCC', - }) - - startTransition(() => { - setFinishes((current) => - sortByName(current.map((item) => (item.id === editingFinishId ? material : item))), - ) - setSelectedCategoryId(material.categoryId) - setFinishModalOpen(false) - toast.success('饰面/花色已更新') - }) - } catch (error) { - toast.error(error instanceof Error ? error.message : '饰面/花色保存失败,请稍后再试') - } finally { - setSubmitting(false) + setUploading(false) } } @@ -414,455 +418,457 @@ export default function MaterialLibraryClient({ if (deleteTarget.type === 'substrate') { await deleteSubstrate(deleteTarget.id) startTransition(() => { - setSubstrates((current) => current.filter((item) => item.id !== deleteTarget.id)) + setSubstrates((cur) => cur.filter((s) => s.id !== deleteTarget.id)) + if (panel.mode !== 'empty' && 'id' in panel && panel.id === deleteTarget.id) setPanel({ mode: 'empty' }) setDeleteTarget(null) toast.success('基材已删除') }) return } - - if (deleteTarget.type === 'category') { - await deleteMaterialCategory(deleteTarget.id) - startTransition(() => { - setMaterialCategories((current) => current.filter((item) => item.id !== deleteTarget.id)) - setFinishes((current) => - sortByName( - current.map((item) => - item.categoryId === deleteTarget.id - ? { - ...item, - categoryName: undefined, - } - : item, - ), - ), - ) - if (selectedCategoryId === deleteTarget.id) { - setSelectedCategoryId('all') - } - setDeleteTarget(null) - toast.success('材质分类已删除') - }) - return - } - await deleteMaterial(deleteTarget.id) startTransition(() => { - setFinishes((current) => current.filter((item) => item.id !== deleteTarget.id)) + setFinishes((cur) => cur.filter((f) => f.id !== deleteTarget.id)) + if (panel.mode !== 'empty' && 'id' in panel && panel.id === deleteTarget.id) setPanel({ mode: 'empty' }) setDeleteTarget(null) - toast.success('饰面/花色已删除') + toast.success('花色已删除') }) } catch (error) { - toast.error(error instanceof Error ? error.message : '删除失败,请稍后再试') + toast.error(error instanceof Error ? error.message : '删除失败') } finally { setSubmitting(false) } } + // ---- Helpers ---- + function isFinishSelected(id: string) { + return (panel.mode === 'view-finish' || panel.mode === 'edit-finish') && panel.id === id + } + function isSubstrateSelected(id: string) { + return (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') && panel.id === id + } + + // ============ Render ============ + + // ----- Right panel content ----- + let rightContent: React.ReactNode + + if (panel.mode === 'empty') { + rightContent = ( +
+
+

从左侧选择一项查看详情

+

或点击「新建」添加

+
+
+ ) + } else if (panel.mode === 'view-finish' && selectedFinish) { + const f = selectedFinish + rightContent = ( +
+
+

花色详情

+
+ + +
+
+ {/* Thumbnail preview */} + {f.thumbnailUrl ? ( + {f.name} setPreviewUrl(f.thumbnailUrl)} + /> + ) : ( +
+ 无贴图 +
+ )} + {/* Detail fields */} +
+
+
+

编码

+

{f.code}

+
+
+

名称

+

{f.name}

+
+
+

分类

+

{f.categoryName ?? '-'}

+
+
+
+
+ ) + } else if (panel.mode === 'create-finish' || panel.mode === 'edit-finish') { + const isCreate = panel.mode === 'create-finish' + rightContent = ( +
+
+

+ {isCreate ? '新建花色' : '编辑花色'} +

+ +
+
+ setFinishForm((c) => ({ ...c, name: e.target.value }))} placeholder="例如 橡木纹-自然色" /> + { const file = e.target.files?.[0]; if (file) handleThumbnailUpload(file); e.target.value = '' }} /> +
+ {finishForm.thumbnailPreview ? ( + 预览 + ) : ( +
+ +
+ )} +
+ + {finishForm.thumbnailPreview && ( + + )} +
+
+
+
+ +
+
+ + ) + } else if (panel.mode === 'view-substrate' && selectedSubstrate) { + const s = selectedSubstrate + rightContent = ( +
+
+

基材详情

+
+ + +
+
+
+
+

系统名称

+

{s.name}

+
+
+

编码

{s.code}

+

材质

{s.categoryName}

+

环保等级

{s.environmentGradeName}

+

厚度

{s.thicknessMm}MM

+

贴面

{s.surfaceOptionName}

+

板尺寸

{s.sheetLengthMm}×{s.sheetWidthMm}

+

售价

{s.suggestedPrice != null ? `¥${s.suggestedPrice}` : '-'}

+

重量

{s.weightKg != null ? `${s.weightKg} kg` : '-'}

+
+ {s.finishes.length > 0 && ( +
+

关联花色

+
+ {s.finishes.map((f) => ( + + {f.thumbnailUrl && } + {f.name} + + ))} +
+
+ )} +
+
+ ) + } else if (panel.mode === 'create-substrate' || panel.mode === 'edit-substrate') { + const isCreate = panel.mode === 'create-substrate' + rightContent = ( +
+
+

+ {isCreate ? '新建基材' : '编辑基材'} +

+ +
+
+ {/* Auto-generated name preview */} + +
+
+

材质

+ { + const text = e.target.value + const match = dictionaries.categories.find((d) => d.name === text) + setSubstrateForm((c) => ({ + ...c, + categoryText: text, + categoryId: match ? match.id : '', + })) + }} + placeholder="选择或输入材质名称" + className="w-full rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary" + /> + + {dictionaries.categories.map((d) => ( + +
+ setSubstrateForm((c) => ({ ...c, thicknessSpecId: e.target.value }))} options={dictionaries.thicknessSpecs.map((d) => ({ value: d.id, label: `${d.thicknessMm}MM (${d.sheetLengthMm ?? 2440}×${d.sheetWidthMm ?? 1220})` }))} /> + {!isSolidWood && setSubstrateForm((c) => ({ ...c, suggestedPrice: e.target.value }))} placeholder="例如 180" /> + setSubstrateForm((c) => ({ ...c, weightKg: e.target.value }))} placeholder="例如 32" /> +
+ {/* Finish multi-select */} +
+
+

花色

+
+ +
+
+ {/* Inline new finish input */} +
+ setSubstrateForm((c) => ({ ...c, newFinishName: e.target.value }))} + placeholder="输入新花色名称,保存时自动创建" + className="flex-1 rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary" + /> + {substrateForm.newFinishName.trim() && ( + + )} +
+ {/* Pending new finish badge */} + {substrateForm.newFinishName.trim() && ( +
+ + + {substrateForm.newFinishName.trim()}(新建) + + +
+ )} + {substrateForm.finishIds.length > 0 && ( +
+ {substrateForm.finishIds.map((fid) => { + const f = finishes.find((x) => x.id === fid) + if (!f) return null + return ( + + {f.name} + + + ) + })} +
+ )} + {finishPickerOpen && ( +
+ {filteredFinishesForSubstrate.length === 0 ? ( +

暂无花色,请在上方输入框新建

+ ) : ( + filteredFinishesForSubstrate.map((f) => { + const selected = substrateForm.finishIds.includes(f.id) + return ( + + ) + }) + )} +
+ )} +
+
+ +
+
+
+ ) + } else { + rightContent = ( +
+

该项目已不存在

+
+ ) + } + return ( <> -
-
-

- 基础资料库 -

-

- 维护基材、分类和饰面花色,补录后返回资产中心继续操作。 -

-
- - - -
- -

- {substrates.length} 基材 - · - {materialCategories.length} 分类 - · - {finishes.length} 饰面 -

- -

- 缺基材或饰面时先来这里补录,保存后回 CAD 导入或规格款弹窗继续操作。 -

- - } - rightContent={ - <> + /* ---- LEFT: List panel ---- */ +
+ {/* Tab switcher */}
- -
+ {/* Search + new button */}
- setSearchText(event.target.value)} - placeholder={activeTab === 'substrate' ? '搜索基材名称、编码...' : '搜索分类或饰面名称...'} - className="w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary py-2 pl-9 pr-3 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary focus:border-apple-blue focus:outline-none focus:ring-1 focus:ring-apple-blue/30 transition" - /> - {searchText ? ( - - ) : null} + setSearchText(e.target.value)} + placeholder={activeTab === 'finish' ? '搜索花色...' : '搜索基材...'} + className="w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary py-2 pl-9 pr-3 text-sm text-apple-label-primary" /> + {searchText && ( + + )}
- {activeTab === 'substrate' ? ( - + {activeTab === 'finish' ? ( + ) : ( -
- - -
+ )}
- {activeTab === 'substrate' ? ( - filteredSubstrates.length === 0 ? ( -
- -

暂无基材

-

点击上方「新建基材」添加第一条记录

-
- ) : ( -
- {filteredSubstrates.map((substrate) => ( -
-

- {substrate.name} -

-
- {substrate.code} - {substrate.densityKgM3 ? {substrate.densityKgM3} kg/m³ : null} -
- - -
- ))} -
- ) - ) : ( - <> -
- + {categories.map((cat) => ( + - {materialCategories.map((category) => ( - - ))} -
- - {groupedFinishes.length === 0 ? ( -
- -

暂无饰面花色

-

点击上方「新建饰面」添加第一条记录

-
- ) : ( -
- {groupedFinishes.map((group, groupIndex) => ( -
0 && 'border-t border-apple-separator pt-4')} - > -
-
-

- {group.category.name} -

- - {group.finishes.length} - -
- {group.category.id !== 'uncategorized' ? ( -
- - -
- ) : null} -
-
- {group.finishes.map((finish) => ( -
-
-

- {finish.name} -

- - -
- ))} -
-
- ))} -
- )} - + ))} +
)} - + + {/* Scrollable list */} +
+ {activeTab === 'finish' ? ( + filteredFinishes.length === 0 ? ( +

暂无花色

+ ) : ( + filteredFinishes.map((f) => ( + + )) + ) + ) : ( + filteredSubstrates.length === 0 ? ( +

暂无基材

+ ) : ( + filteredSubstrates.map((s) => ( + + )) + ) + )} +
+
} + rightContent={rightContent} /> - setSubstrateModalOpen(false)} - title={substrateModalMode === 'create' ? '新建基材' : '编辑基材'} - size="medium" - footer={ -
- - -
- } - > -
-
- setSubstrateForm((current) => ({ ...current, code: event.target.value }))} - placeholder="例如 PB18" - /> - setSubstrateForm((current) => ({ ...current, name: event.target.value }))} - placeholder="例如 18mm 颗粒板" - /> -
- setSubstrateForm((current) => ({ ...current, description: event.target.value }))} - placeholder="可选" - /> - setSubstrateForm((current) => ({ ...current, densityKgM3: event.target.value }))} - placeholder="例如 680" - /> -
-
- - setCategoryModalOpen(false)} - title={categoryModalMode === 'create' ? '新建材质分类' : '编辑材质分类'} - size="medium" - footer={ -
- - -
- } - > -
- setCategoryForm((current) => ({ ...current, name: event.target.value }))} - placeholder="例如 木纹、石纹、纯色" - /> -
- setCategoryForm((current) => ({ ...current, icon: event.target.value }))} - placeholder="可选" - /> - setCategoryForm((current) => ({ ...current, sortOrder: event.target.value }))} - placeholder="数字越小越靠前" - /> -
-
-
- - setFinishModalOpen(false)} - title={finishModalMode === 'create' ? '新建饰面 / 花色' : '编辑饰面 / 花色'} - size="medium" - footer={ -
- - -
- } - > -
- setFinishForm((current) => ({ ...current, name: event.target.value }))} - placeholder="例如 白橡木纹" - /> - setFinishForm((current) => ({ ...current, color: event.target.value }))} - placeholder="#CCCCCC" - className="flex-1" - /> -
-
-
-
- + {/* Delete confirm */} setDeleteTarget(null)} onConfirm={handleConfirmDelete} - title={ - deleteTarget?.type === 'substrate' - ? '删除基材' - : deleteTarget?.type === 'category' - ? '删除材质分类' - : '删除饰面 / 花色' - } - description={`确认删除「${deleteTarget?.name ?? ''}」吗?删除后已引用规格款会自动回落为空值。`} + title={deleteTarget?.type === 'substrate' ? '删除基材' : '删除饰面花色'} + description={`确认删除「${deleteTarget?.name ?? ''}」吗?`} confirmText="确认删除" /> + {/* Image preview overlay */} + {previewUrl && ( +
setPreviewUrl(null)}> +
+ + 预览 e.stopPropagation()} /> +
+
+ )} + ) diff --git a/furniture-3d-system/src/app/asset-center/material-library/page.tsx b/furniture-3d-system/src/app/asset-center/material-library/page.tsx index 09183f54..e48f8556 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/page.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/page.tsx @@ -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: , + label: '饰面花色', + value: finishes.length, + icon: , }, { - label: '材质分类', + label: '花色分类', value: materialCategories.length, icon: , }, { - label: '饰面/花色', - value: finishes.length, - icon: , + label: '板材基材', + value: substrates.length, + icon: , }, ]} /> diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index 9b1c8801..d72fd971 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -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 { }) } +export async function fetchSubstrateDictionaries(): Promise { + const data = await requestPlatform<{ dictionaries: SubstrateDictionaryBundleDTO }>( + '/api/asset-center/substrate-dictionaries', + ) + return data.dictionaries +} + +export async function createSubstrateDictionary( + input: CreateSubstrateDictionaryInput, +): Promise { + 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 { + 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 { + 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 { 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 { + 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 { }) } -export async function generateAutoCombinations( +export async function generateAssistedAssemblyCandidates( input: { seriesTemplateId: string desktopFamilyIds: string[] @@ -270,7 +338,7 @@ export async function generateAutoCombinations( }, ): Promise { 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 { diff --git a/furniture-3d-system/src/lib/standard-finish-sync.ts b/furniture-3d-system/src/lib/standard-finish-sync.ts index f3ab5eab..bb9d8e63 100644 --- a/furniture-3d-system/src/lib/standard-finish-sync.ts +++ b/furniture-3d-system/src/lib/standard-finish-sync.ts @@ -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, diff --git a/furniture-3d-system/src/server/repositories/asset-center.repository.ts b/furniture-3d-system/src/server/repositories/asset-center.repository.ts index 4bfe3840..13a3a5a8 100644 --- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts +++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts @@ -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 { + return this.prisma.substrate.findUnique({ + where: { id }, + include: substrateRelationInclude, + }) as Promise + } + + async findManyWithRelations( + options: FindManyOptions = {}, + ): 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 {} diff --git a/furniture-3d-system/src/server/repositories/material.repository.ts b/furniture-3d-system/src/server/repositories/material.repository.ts index f7e67ca2..5a0591e4 100644 --- a/furniture-3d-system/src/server/repositories/material.repository.ts +++ b/furniture-3d-system/src/server/repositories/material.repository.ts @@ -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 { + const result = await this.prisma.material.findFirst({ + where: { code: { startsWith: 'HS-' } }, + orderBy: { code: 'desc' }, + select: { code: true }, + }) + return result?.code ?? null + } + /** * 获取材质使用数量(被多少个板件使用) */ diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index d425d6e1..9004d1a0 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -1,4 +1,14 @@ -import { Prisma, type AssetStatus, type Substrate } from '@prisma/client' +import { + Prisma, + type AssetStatus, + type Substrate, + type SubstrateCategory, + type SubstrateEnvironmentGrade, + type SubstrateSurfaceOption, + type SubstrateSystemNameOption, + type SubstrateThicknessSpec, +} from '@prisma/client' +import { prisma } from '@/lib/prisma' import { assertNotSelfReview, assertUserCanReviewAsset, @@ -9,25 +19,24 @@ import { validateAssetVariantForReview, } from '@/lib/open-product/asset-validation' import { buildAssetVariantIdentityKey } from '@/lib/open-product/asset-identity' -import type { OpenProductMountPoint } from '@/lib/open-product/types' import { - BOARD_LIBRARY_DEFAULTS, - BOARD_REGULAR_FINISHES, - BOARD_RUBBER_WOOD_FINISHES, -} from '@/lib/board-substrate-rules' + buildSubstrateDisplayName, + calculateEstimatedCost, +} from '@/lib/open-product/substrate-rules' +import type { OpenProductMountPoint } from '@/lib/open-product/types' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' import { assetFamilyRepository, assetVariantRepository, categoryRepository, - materialCategoryRepository, materialRepository, substrateRepository, type AssetFamilyFilter, type AssetVariantFilter, type AssetFamilyWithRelations, type AssetVariantWithRelations, + type SubstrateWithRelations, } from '../repositories' import type { AssetFamilyDTO, @@ -36,14 +45,25 @@ import type { AssetWorkflowAuditDTO, CreateAssetFamilyInput, CreateAssetVariantInput, + CreateSubstrateDictionaryInput, CreateSubstrateInput, - EnsureBoardLibraryDefaultsResult, SubstrateDTO, - UpdateSubstrateInput, + SubstrateDictionaryBundleDTO, + SubstrateDictionaryOptionDTO, + SubstrateDictionaryType, + UpdateSubstrateDictionaryInput, UpdateAssetFamilyInput, UpdateAssetVariantInput, + UpdateSubstrateInput, } from './asset-center.service.types' +type DictionaryRecord = + | SubstrateCategory + | SubstrateEnvironmentGrade + | SubstrateThicknessSpec + | SubstrateSurfaceOption + | SubstrateSystemNameOption + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -80,10 +100,6 @@ function ensureUniqueMessages(messages: string[]): string[] { return Array.from(new Set(messages)) } -function isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError { - return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' -} - function toWorkflowDTO(record: { submittedAt: Date | null submittedById: string | null @@ -135,10 +151,11 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { } } -function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { - const costPrice = - record.costPrice === null || record.costPrice === undefined ? null : Number(record.costPrice) +function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null { + return value === null || value === undefined ? null : Number(value) +} +function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { return { id: record.id, assetFamilyId: record.assetFamilyId, @@ -166,7 +183,9 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { namingFields: isRecord(record.namingFields) ? record.namingFields : null, businessData: isRecord(record.businessData) ? record.businessData : null, weightKg: record.weightKg ?? null, - costPrice, + costPrice: decimalToNumber(record.costPrice), + lossFactor: record.lossFactor ?? null, + estimatedCostPrice: decimalToNumber(record.estimatedCostPrice), inheritedFromVariantId: record.inheritedFromVariantId, createdBy: record.createdBy, creatorName: record.creator?.name ?? null, @@ -176,13 +195,33 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { } } -function toSubstrateDTO(record: Substrate): SubstrateDTO { +function toSubstrateDTO(record: SubstrateWithRelations): SubstrateDTO { return { id: record.id, code: record.code, name: record.name, description: record.description, - densityKgM3: record.densityKgM3 ?? null, + categoryId: record.categoryId, + categoryName: record.category.name, + environmentGradeId: record.environmentGradeId, + environmentGradeName: record.environmentGrade.name, + thicknessSpecId: record.thicknessSpecId, + thicknessSpecName: record.thicknessSpec.name, + thicknessMm: record.thicknessSpec.thicknessMm, + sheetLengthMm: record.thicknessSpec.sheetLengthMm, + sheetWidthMm: record.thicknessSpec.sheetWidthMm, + surfaceOptionId: record.surfaceOptionId, + surfaceOptionName: record.surfaceOption?.name ?? null, + systemNameOptionId: record.systemNameOptionId, + systemNameOptionName: record.systemNameOption.name, + suggestedPrice: decimalToNumber(record.suggestedPrice), + weightKg: record.weightKg ?? null, + finishes: (record.finishes ?? []).map((sf) => ({ + id: sf.finish.id, + name: sf.finish.name, + code: sf.finish.code, + thumbnailUrl: sf.finish.thumbnailUrl, + })), metadata: isRecord(record.metadata) ? record.metadata : null, isActive: record.isActive, createdAt: record.createdAt, @@ -190,114 +229,58 @@ function toSubstrateDTO(record: Substrate): SubstrateDTO { } } -const BOARD_FINISH_CATEGORY_NAME = '板材花色' - -const BOARD_FINISH_COLOR_MAP: Record = { - 胡桃木色: '#8d6543', - 北美原木纹色: '#c7a070', - 经典北美黑胡桃色: '#5f4635', - 大象灰色: '#8b8a86', - 荷花白色: '#f5f1ea', - 烟熏黑色: '#34302f', - 石墨灰色: '#686668', - 云霜灰色: '#d3d4d6', - 珍珠白色: '#f8f7f2', - 炭黑色: '#1d1d1d', - 榆木黑色: '#393230', - 浅黄橡木色: '#d8b178', - 莫兰橡木色: '#b38d62', - 柚木色: '#9c734e', - 胡桃色: '#73533c', - 原木色: '#d5b07d', - 黑色: '#202020', - 冰川白石纹色: '#efefec', -} - -function buildBoardFinishCreateData(name: string, categoryId: string) { +function toDictionaryOptionDTO( + type: SubstrateDictionaryType, + record: DictionaryRecord, +): SubstrateDictionaryOptionDTO { return { - name, - category: { connect: { id: categoryId } }, - thumbnailUrl: '/materials/thumbnails/default.jpg', - textureUrl: null, - normalMapUrl: null, - color: BOARD_FINISH_COLOR_MAP[name] ?? '#CCCCCC', - metalness: 0, - roughness: 0.6, - opacity: 1, - textureRepeat: 1, - textureRotation: 0, - textureSizeMm: 600, - isPreset: false, - isActive: true, - } satisfies Prisma.MaterialCreateInput -} - -async function ensureBoardFinishCategory() { - const existingCategory = await materialCategoryRepository.findByName(BOARD_FINISH_CATEGORY_NAME) - if (existingCategory) { - return existingCategory - } - - try { - return await materialCategoryRepository.create({ - name: BOARD_FINISH_CATEGORY_NAME, - icon: 'Palette', - sortOrder: 10, - isActive: true, - }) - } catch (error) { - if (isUniqueConstraintError(error)) { - const category = await materialCategoryRepository.findByName(BOARD_FINISH_CATEGORY_NAME) - if (category) { - return category - } - } - throw error + id: record.id, + type, + name: record.name, + sortOrder: record.sortOrder, + isActive: record.isActive, + thicknessMm: 'thicknessMm' in record ? record.thicknessMm : null, + sheetLengthMm: 'sheetLengthMm' in record ? record.sheetLengthMm : null, + sheetWidthMm: 'sheetWidthMm' in record ? record.sheetWidthMm : null, + createdAt: record.createdAt, + updatedAt: record.updatedAt, } } -async function ensureBoardFinish(name: string, categoryId: string) { - const existingFinish = await materialRepository.findByNameAndCategory(name, categoryId) - if (existingFinish) { - return existingFinish - } - - try { - return await materialRepository.create(buildBoardFinishCreateData(name, categoryId)) - } catch (error) { - if (isUniqueConstraintError(error)) { - const finish = await materialRepository.findByNameAndCategory(name, categoryId) - if (finish) { - return finish - } - } - throw error +function getDictionaryLabel(type: SubstrateDictionaryType): string { + switch (type) { + case 'CATEGORY': + return '材质主类' + case 'ENVIRONMENT_GRADE': + return '环保等级' + case 'THICKNESS_SPEC': + return '厚度规格' + case 'SURFACE_OPTION': + return '贴面类型' + case 'SYSTEM_NAME_OPTION': + return '系统名称' } } -async function ensureBoardSubstrate(rule: (typeof BOARD_LIBRARY_DEFAULTS)[number]) { - const existingSubstrate = await substrateRepository.findByCode(rule.code) - if (existingSubstrate) { - return existingSubstrate - } +function extractPlanarSize(dimensions: Record): { lengthMm: number | null; widthMm: number | null } { + const lengthMm = + typeof dimensions.length === 'number' + ? dimensions.length + : typeof dimensions.width === 'number' + ? dimensions.width + : null + const widthMm = + typeof dimensions.width === 'number' + ? dimensions.width + : typeof dimensions.depth === 'number' + ? dimensions.depth + : typeof dimensions.height === 'number' + ? dimensions.height + : null - try { - return await substrateRepository.create({ - code: rule.code, - name: rule.name, - description: `${rule.materialName} ${rule.environmentalGrade} ${rule.thicknessMm}mm`, - densityKgM3: null, - metadata: toJsonValue(rule), - isActive: true, - }) - } catch (error) { - if (isUniqueConstraintError(error)) { - const substrate = await substrateRepository.findByCode(rule.code) - if (substrate) { - return substrate - } - } - throw error + return { + lengthMm: Number.isFinite(lengthMm ?? NaN) ? lengthMm : null, + widthMm: Number.isFinite(widthMm ?? NaN) ? widthMm : null, } } @@ -339,25 +322,127 @@ async function ensureVariantCodeAvailable(code: string, currentId?: string): Pro } } -class AssetCenterService { - async ensureBoardLibraryDefaults(): Promise { - const finishCategory = await ensureBoardFinishCategory() +async function loadSubstrateParts(input: { + categoryId: string + environmentGradeId: string + thicknessSpecId: string + surfaceOptionId: string + systemNameOptionId: string +}) { + const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] = + await Promise.all([ + prisma.substrateCategory.findUnique({ where: { id: input.categoryId } }), + prisma.substrateEnvironmentGrade.findUnique({ where: { id: input.environmentGradeId } }), + prisma.substrateThicknessSpec.findUnique({ where: { id: input.thicknessSpecId } }), + prisma.substrateSurfaceOption.findUnique({ where: { id: input.surfaceOptionId } }), + prisma.substrateSystemNameOption.findUnique({ where: { id: input.systemNameOptionId } }), + ]) - const finishNames = Array.from( - new Set([...BOARD_REGULAR_FINISHES, ...BOARD_RUBBER_WOOD_FINISHES, '冰川白石纹色']), - ) - - await Promise.all(finishNames.map((finishName) => ensureBoardFinish(finishName, finishCategory.id))) - - const substrates = await Promise.all(BOARD_LIBRARY_DEFAULTS.map((rule) => ensureBoardSubstrate(rule))) - - return { - finishCategoryId: finishCategory.id, - finishNames, - substrates: substrates.map((item) => toSubstrateDTO(item)), - } + if (!category) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '材质主类不存在') + } + if (!environmentGrade) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '环保等级不存在') + } + if (!thicknessSpec) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '厚度规格不存在') + } + if (!surfaceOption) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '贴面类型不存在') + } + if (!systemNameOption) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '系统名称不存在') } + return { + category, + environmentGrade, + thicknessSpec, + surfaceOption, + systemNameOption, + } +} + +async function buildSubstratePayload(input: { + categoryId: string + environmentGradeId: string + thicknessSpecId: string + surfaceOptionId: string + systemNameOptionId: string +}) { + const parts = await loadSubstrateParts(input) + const generatedName = buildSubstrateDisplayName({ + thicknessLabel: parts.thicknessSpec.name, + environmentGradeLabel: parts.environmentGrade.name, + systemNameLabel: parts.systemNameOption.name, + }) + + return { + generatedName, + parts, + } +} + +async function buildSubstratePayloadWithFinishes(input: { + categoryId: string + environmentGradeId: string + thicknessSpecId: string + surfaceOptionId: string + systemNameOptionId: string + finishIds: string[] +}) { + const parts = await loadSubstrateParts(input) + + // Load finish names + const finishNames: string[] = [] + if (input.finishIds.length > 0) { + const finishes = await prisma.material.findMany({ + where: { id: { in: input.finishIds } }, + select: { name: true }, + }) + finishNames.push(...finishes.map((f) => f.name)) + } + + // Build name: 18MM E1 刨花板 北美原木色 双贴 + const thickness = parts.thicknessSpec.name.toUpperCase().replace('MM', 'MM') + const envGrade = parts.environmentGrade.name + const category = parts.category.name + const surface = parts.surfaceOption.name + const finishPart = finishNames.length > 0 ? finishNames.join(' ') : '' + + const generatedName = [thickness, envGrade, category, finishPart, surface] + .filter(Boolean) + .join(' ') + + return { generatedName, parts } +} + +async function computeEstimatedCostPrice(params: { + substrateId: string | null + dimensions: Record + lossFactor: number | null +}): Promise { + if (!params.substrateId) { + return null + } + + const substrate = await substrateRepository.findByIdWithRelations(params.substrateId) + if (!substrate) { + throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在') + } + + const size = extractPlanarSize(params.dimensions) + return calculateEstimatedCost({ + lengthMm: size.lengthMm, + widthMm: size.widthMm, + sheetLengthMm: substrate.thicknessSpec.sheetLengthMm, + sheetWidthMm: substrate.thicknessSpec.sheetWidthMm, + suggestedPrice: decimalToNumber(substrate.suggestedPrice), + lossFactor: params.lossFactor, + }) +} + +class AssetCenterService { async listFamilies(filter?: Pick) { const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined) return result.items.map((item) => toAssetFamilyDTO(item)) @@ -372,12 +457,43 @@ class AssetCenterService { } async listSubstrates(): Promise { - const result = await substrateRepository.findMany({ + const result = await substrateRepository.findManyWithRelations({ filter: { isActive: true }, }) return result.items.map((item) => toSubstrateDTO(item)) } + private async generateNextSubstrateCode(): Promise { + const result = await prisma.substrate.findFirst({ + where: { code: { startsWith: 'BC-' } }, + orderBy: { code: 'desc' }, + select: { code: true }, + }) + if (!result) return 'BC-0001' + const numPart = parseInt(result.code.split('-')[1], 10) + if (Number.isNaN(numPart)) return 'BC-0001' + return `BC-${String(numPart + 1).padStart(4, '0')}` + } + + async getSubstrateDictionaryBundle(): Promise { + const [categories, environmentGrades, thicknessSpecs, surfaceOptions, systemNameOptions] = + await Promise.all([ + prisma.substrateCategory.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }), + prisma.substrateEnvironmentGrade.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }), + prisma.substrateThicknessSpec.findMany({ orderBy: [{ sortOrder: 'asc' }, { thicknessMm: 'asc' }] }), + prisma.substrateSurfaceOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }), + prisma.substrateSystemNameOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }), + ]) + + return { + categories: categories.map((item) => toDictionaryOptionDTO('CATEGORY', item)), + environmentGrades: environmentGrades.map((item) => toDictionaryOptionDTO('ENVIRONMENT_GRADE', item)), + thicknessSpecs: thicknessSpecs.map((item) => toDictionaryOptionDTO('THICKNESS_SPEC', item)), + surfaceOptions: surfaceOptions.map((item) => toDictionaryOptionDTO('SURFACE_OPTION', item)), + systemNameOptions: systemNameOptions.map((item) => toDictionaryOptionDTO('SYSTEM_NAME_OPTION', item)), + } + } + async createFamily(input: CreateAssetFamilyInput): Promise { const code = input.code.trim() const name = input.name.trim() @@ -448,40 +564,113 @@ class AssetCenterService { } async createSubstrate(input: CreateSubstrateInput): Promise { - const code = input.code.trim() - const name = input.name.trim() - if (!code || !name) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码和名称不能为空') + // Check for exact duplicate: same 5 fields + same finishes + const inputFinishIds = new Set(input.finishIds ?? []) + const existingWithSameSpec = await prisma.substrate.findMany({ + where: { + categoryId: input.categoryId, + environmentGradeId: input.environmentGradeId, + thicknessSpecId: input.thicknessSpecId, + surfaceOptionId: input.surfaceOptionId, + systemNameOptionId: input.systemNameOptionId, + isActive: true, + }, + include: { finishes: { select: { finishId: true } } }, + }) + for (const existing of existingWithSameSpec) { + const existingFinishIds = new Set(existing.finishes.map((f) => f.finishId)) + if ( + existingFinishIds.size === inputFinishIds.size && + [...inputFinishIds].every((id) => existingFinishIds.has(id)) + ) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '基材库已有相同规格的基材,不可重复创建') + } } + // Auto-generate BC-XXXX code + const code = input.code?.trim() || await this.generateNextSubstrateCode() + if (await substrateRepository.findByCode(code)) { throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在') } - const created = await substrateRepository.create({ - code, - name, - description: trimOrNull(input.description), - densityKgM3: input.densityKgM3 ?? null, - metadata: toJsonValue(input.metadata), - isActive: true, - creator: input.createdBy ? { connect: { id: input.createdBy } } : undefined, - }) + // Build display name including finish names + const payload = input.surfaceOptionId + ? await buildSubstratePayloadWithFinishes({ + categoryId: input.categoryId, + environmentGradeId: input.environmentGradeId, + thicknessSpecId: input.thicknessSpecId, + surfaceOptionId: input.surfaceOptionId, + systemNameOptionId: input.systemNameOptionId, + finishIds: input.finishIds ?? [], + }) + : await buildSubstratePayload({ + categoryId: input.categoryId, + environmentGradeId: input.environmentGradeId, + thicknessSpecId: input.thicknessSpecId, + surfaceOptionId: input.surfaceOptionId!, + systemNameOptionId: input.systemNameOptionId, + }).then(async (p) => { + // For solid wood (no surface option), build name without surface + const finishNames: string[] = [] + if ((input.finishIds ?? []).length > 0) { + const finishRows = await prisma.material.findMany({ + where: { id: { in: input.finishIds } }, + select: { name: true }, + }) + finishNames.push(...finishRows.map((f) => f.name)) + } + const thickness = p.parts.thicknessSpec.name.toUpperCase() + const envGrade = p.parts.environmentGrade.name + const category = p.parts.category.name + const finishPart = finishNames.length > 0 ? finishNames.join(' ') : '' + const generatedName = [thickness, envGrade, category, finishPart].filter(Boolean).join(' ') + return { generatedName, parts: p.parts } + }) + + const created = await prisma.substrate.create({ + data: { + code, + name: payload.generatedName, + description: trimOrNull(input.description), + category: { connect: { id: input.categoryId } }, + environmentGrade: { connect: { id: input.environmentGradeId } }, + thicknessSpec: { connect: { id: input.thicknessSpecId } }, + surfaceOption: input.surfaceOptionId ? { connect: { id: input.surfaceOptionId } } : undefined, + systemNameOption: { connect: { id: input.systemNameOptionId } }, + suggestedPrice: input.suggestedPrice ?? null, + weightKg: input.weightKg ?? null, + finishes: input.finishIds?.length + ? { create: input.finishIds.map((finishId) => ({ finish: { connect: { id: finishId } } })) } + : undefined, + metadata: toJsonValue(input.metadata), + isActive: true, + creator: input.createdBy ? { connect: { id: input.createdBy } } : undefined, + }, + include: { + 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 } } } }, + }, + }) as SubstrateWithRelations return toSubstrateDTO(created) } async updateSubstrate(id: string, input: UpdateSubstrateInput): Promise { - const existing = await substrateRepository.findById(id) + const existing = await substrateRepository.findByIdWithRelations(id) if (!existing) { throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在') } const nextCode = input.code?.trim() ?? existing.code - const nextName = input.name?.trim() ?? existing.name - - if (!nextCode || !nextName) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码和名称不能为空') + if (!nextCode) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '基材编码不能为空') } const duplicateByCode = await substrateRepository.findByCode(nextCode) @@ -489,33 +678,98 @@ class AssetCenterService { throw new AppError(ErrorCode.VALIDATION_FAILED, '基材编码已存在') } - const duplicateByName = await substrateRepository.findMany({ - filter: { - search: nextName, - }, - }) - const matchedName = duplicateByName.items.find( - (item) => item.name === nextName && item.id !== id, - ) - if (matchedName) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '基材名称已存在') + const nextCategoryId = input.categoryId ?? existing.categoryId + const nextEnvironmentGradeId = input.environmentGradeId ?? existing.environmentGradeId + const nextThicknessSpecId = input.thicknessSpecId ?? existing.thicknessSpecId + const nextSurfaceOptionId = input.surfaceOptionId !== undefined ? input.surfaceOptionId : existing.surfaceOptionId + const nextSystemNameOptionId = input.systemNameOptionId ?? existing.systemNameOptionId + const nextFinishIds = input.finishIds ?? existing.finishes.map((f) => f.finishId) + + // Build name — with or without surface option (solid wood has no surface) + let generatedName: string + if (nextSurfaceOptionId) { + const payload = await buildSubstratePayloadWithFinishes({ + categoryId: nextCategoryId, + environmentGradeId: nextEnvironmentGradeId, + thicknessSpecId: nextThicknessSpecId, + surfaceOptionId: nextSurfaceOptionId, + systemNameOptionId: nextSystemNameOptionId, + finishIds: nextFinishIds, + }) + generatedName = payload.generatedName + } else { + const parts = await loadSubstrateParts({ + categoryId: nextCategoryId, + environmentGradeId: nextEnvironmentGradeId, + thicknessSpecId: nextThicknessSpecId, + surfaceOptionId: existing.surfaceOptionId ?? dictionaries.surfaceOptions[0]?.id ?? '', + systemNameOptionId: nextSystemNameOptionId, + }) + const finishNames: string[] = [] + if (nextFinishIds.length > 0) { + const finishRows = await prisma.material.findMany({ + where: { id: { in: nextFinishIds } }, + select: { name: true }, + }) + finishNames.push(...finishRows.map((f) => f.name)) + } + const thickness = parts.thicknessSpec.name.toUpperCase() + const finishPart = finishNames.length > 0 ? finishNames.join(' ') : '' + generatedName = [thickness, parts.environmentGrade.name, parts.category.name, finishPart].filter(Boolean).join(' ') } - const updated = await substrateRepository.update(id, { - code: nextCode, - name: nextName, - description: - input.description !== undefined ? trimOrNull(input.description) : undefined, - densityKgM3: input.densityKgM3 === undefined ? undefined : input.densityKgM3, - metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined, - isActive: input.isActive, - }) + const updated = await prisma.substrate.update({ + where: { id }, + data: { + code: nextCode, + name: generatedName, + description: + input.description !== undefined ? trimOrNull(input.description) : undefined, + category: + input.categoryId === undefined ? undefined : { connect: { id: input.categoryId } }, + environmentGrade: + input.environmentGradeId === undefined + ? undefined + : { connect: { id: input.environmentGradeId } }, + thicknessSpec: + input.thicknessSpecId === undefined + ? undefined + : { connect: { id: input.thicknessSpecId } }, + surfaceOption: + input.surfaceOptionId === undefined + ? undefined + : input.surfaceOptionId + ? { connect: { id: input.surfaceOptionId } } + : { disconnect: true }, + systemNameOption: + input.systemNameOptionId === undefined + ? undefined + : { connect: { id: input.systemNameOptionId } }, + suggestedPrice: input.suggestedPrice === undefined ? undefined : input.suggestedPrice, + weightKg: input.weightKg === undefined ? undefined : input.weightKg, + finishes: input.finishIds !== undefined + ? { deleteMany: {}, create: input.finishIds.map((finishId) => ({ finish: { connect: { id: finishId } } })) } + : undefined, + metadata: input.metadata !== undefined ? toJsonValue(input.metadata) : undefined, + isActive: input.isActive, + }, + include: { + 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 } } } }, + }, + }) as SubstrateWithRelations return toSubstrateDTO(updated) } async deleteSubstrate(id: string): Promise { - const existing = await substrateRepository.findById(id) + const existing = await substrateRepository.findByIdWithRelations(id) if (!existing) { throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '基材不存在') } @@ -524,6 +778,197 @@ class AssetCenterService { return toSubstrateDTO(existing) } + async createSubstrateDictionary(input: CreateSubstrateDictionaryInput): Promise { + const name = input.name.trim() + if (!name) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `${getDictionaryLabel(input.type)}名称不能为空`) + } + + const dataBase = { + name, + sortOrder: input.sortOrder ?? 0, + isActive: input.isActive ?? true, + } + + try { + switch (input.type) { + case 'CATEGORY': + return toDictionaryOptionDTO( + 'CATEGORY', + await prisma.substrateCategory.create({ data: dataBase }), + ) + case 'ENVIRONMENT_GRADE': + return toDictionaryOptionDTO( + 'ENVIRONMENT_GRADE', + await prisma.substrateEnvironmentGrade.create({ data: dataBase }), + ) + case 'THICKNESS_SPEC': + if (!input.thicknessMm || input.thicknessMm <= 0) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '厚度规格必须填写厚度 mm') + } + return toDictionaryOptionDTO( + 'THICKNESS_SPEC', + await prisma.substrateThicknessSpec.create({ + data: { + ...dataBase, + thicknessMm: input.thicknessMm, + sheetLengthMm: input.sheetLengthMm ?? 2440, + sheetWidthMm: input.sheetWidthMm ?? 1220, + }, + }), + ) + case 'SURFACE_OPTION': + return toDictionaryOptionDTO( + 'SURFACE_OPTION', + await prisma.substrateSurfaceOption.create({ data: dataBase }), + ) + case 'SYSTEM_NAME_OPTION': + return toDictionaryOptionDTO( + 'SYSTEM_NAME_OPTION', + await prisma.substrateSystemNameOption.create({ data: dataBase }), + ) + } + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(input.type)}名称已存在`) + } + throw error + } + } + + async updateSubstrateDictionary( + type: SubstrateDictionaryType, + id: string, + input: UpdateSubstrateDictionaryInput, + ): Promise { + try { + switch (type) { + case 'CATEGORY': + return toDictionaryOptionDTO( + 'CATEGORY', + await prisma.substrateCategory.update({ + where: { id }, + data: { + name: input.name?.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive, + }, + }), + ) + case 'ENVIRONMENT_GRADE': + return toDictionaryOptionDTO( + 'ENVIRONMENT_GRADE', + await prisma.substrateEnvironmentGrade.update({ + where: { id }, + data: { + name: input.name?.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive, + }, + }), + ) + case 'THICKNESS_SPEC': + return toDictionaryOptionDTO( + 'THICKNESS_SPEC', + await prisma.substrateThicknessSpec.update({ + where: { id }, + data: { + name: input.name?.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive, + thicknessMm: input.thicknessMm ?? undefined, + sheetLengthMm: input.sheetLengthMm ?? undefined, + sheetWidthMm: input.sheetWidthMm ?? undefined, + }, + }), + ) + case 'SURFACE_OPTION': + return toDictionaryOptionDTO( + 'SURFACE_OPTION', + await prisma.substrateSurfaceOption.update({ + where: { id }, + data: { + name: input.name?.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive, + }, + }), + ) + case 'SYSTEM_NAME_OPTION': + return toDictionaryOptionDTO( + 'SYSTEM_NAME_OPTION', + await prisma.substrateSystemNameOption.update({ + where: { id }, + data: { + name: input.name?.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive, + }, + }), + ) + } + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}不存在`) + } + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}名称已存在`) + } + throw error + } + } + + async deleteSubstrateDictionary(type: SubstrateDictionaryType, id: string): Promise { + const whereByType: Record = { + CATEGORY: { categoryId: id }, + ENVIRONMENT_GRADE: { environmentGradeId: id }, + THICKNESS_SPEC: { thicknessSpecId: id }, + SURFACE_OPTION: { surfaceOptionId: id }, + SYSTEM_NAME_OPTION: { systemNameOptionId: id }, + } + const referenceCount = await prisma.substrate.count({ where: whereByType[type] }) + if (referenceCount > 0) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}已被基材使用,不能删除`) + } + + try { + switch (type) { + case 'CATEGORY': + await prisma.substrateCategory.delete({ where: { id } }) + return + case 'ENVIRONMENT_GRADE': + await prisma.substrateEnvironmentGrade.delete({ where: { id } }) + return + case 'THICKNESS_SPEC': + await prisma.substrateThicknessSpec.delete({ where: { id } }) + return + case 'SURFACE_OPTION': + await prisma.substrateSurfaceOption.delete({ where: { id } }) + return + case 'SYSTEM_NAME_OPTION': + await prisma.substrateSystemNameOption.delete({ where: { id } }) + return + } + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new AppError(ErrorCode.VALIDATION_FAILED, `${getDictionaryLabel(type)}不存在`) + } + throw error + } + } + async createVariant(input: CreateAssetVariantInput): Promise { const family = await assetFamilyRepository.findById(input.assetFamilyId) if (!family) { @@ -554,6 +999,15 @@ class AssetCenterService { ) } + const resolvedLossFactor = input.lossFactor ?? null + const resolvedEstimatedCostPrice = + input.estimatedCostPrice ?? + await computeEstimatedCostPrice({ + substrateId: input.substrateId ?? null, + dimensions: input.dimensions, + lossFactor: resolvedLossFactor, + }) + const created = await assetVariantRepository.createWithRelations({ assetFamily: { connect: { id: input.assetFamilyId } }, code, @@ -574,6 +1028,8 @@ class AssetCenterService { businessData: toJsonValue(input.businessData), weightKg: input.weightKg ?? null, costPrice: input.costPrice ?? null, + lossFactor: resolvedLossFactor, + estimatedCostPrice: resolvedEstimatedCostPrice, inheritedFromVariant: input.inheritedFromVariantId ? { connect: { id: input.inheritedFromVariantId } } : undefined, @@ -639,6 +1095,7 @@ class AssetCenterService { input.substrateId !== undefined ? input.substrateId : existing.substrateId const nextFinishId = input.finishId !== undefined ? input.finishId : existing.finishId const nextModelUrl = input.modelUrl?.trim() ?? existing.modelUrl + const nextLossFactor = input.lossFactor !== undefined ? input.lossFactor : existing.lossFactor if ( existing.status === 'AVAILABLE' && @@ -657,6 +1114,7 @@ class AssetCenterService { compatibilityRules: isRecord(existing.compatibilityRules) ? existing.compatibilityRules : null, + lossFactor: existing.lossFactor ?? null, }, { dimensions: nextDimensions, @@ -666,6 +1124,7 @@ class AssetCenterService { mountPoints: nextMountPoints, assemblyBasis: nextAssemblyBasis, compatibilityRules: nextCompatibilityRules, + lossFactor: nextLossFactor ?? null, }, ) ) { @@ -690,6 +1149,15 @@ class AssetCenterService { ) } + const resolvedEstimatedCostPrice = + input.estimatedCostPrice !== undefined + ? input.estimatedCostPrice + : await computeEstimatedCostPrice({ + substrateId: nextSubstrateId ?? null, + dimensions: nextDimensions, + lossFactor: nextLossFactor ?? null, + }) + const updated = await assetVariantRepository.updateWithRelations(id, { assetFamily: input.assetFamilyId === undefined @@ -736,6 +1204,9 @@ class AssetCenterService { input.businessData !== undefined ? toJsonValue(input.businessData) : undefined, weightKg: input.weightKg === undefined ? undefined : input.weightKg, costPrice: input.costPrice === undefined ? undefined : input.costPrice, + lossFactor: input.lossFactor === undefined ? undefined : input.lossFactor, + estimatedCostPrice: + input.estimatedCostPrice === undefined ? resolvedEstimatedCostPrice : input.estimatedCostPrice, lastModifier: input.updatedBy ? { connect: { id: input.updatedBy } } : undefined, }) @@ -868,8 +1339,11 @@ class AssetCenterService { : [], substrateId: existing.substrateId, finishId: existing.finishId, - weightKg: existing.weightKg, - costPrice: existing.costPrice === null || existing.costPrice === undefined ? null : Number(existing.costPrice), + estimatedCostPrice: + existing.estimatedCostPrice === null || existing.estimatedCostPrice === undefined + ? null + : Number(existing.estimatedCostPrice), + lossFactor: existing.lossFactor ?? null, assemblyBasis: isRecord(existing.assemblyBasis) ? existing.assemblyBasis : null, compatibilityRules: isRecord(existing.compatibilityRules) ? existing.compatibilityRules @@ -913,8 +1387,11 @@ class AssetCenterService { : [], substrateId: existing.substrateId, finishId: existing.finishId, - weightKg: existing.weightKg, - costPrice: existing.costPrice === null || existing.costPrice === undefined ? null : Number(existing.costPrice), + estimatedCostPrice: + existing.estimatedCostPrice === null || existing.estimatedCostPrice === undefined + ? null + : Number(existing.estimatedCostPrice), + lossFactor: existing.lossFactor ?? null, assemblyBasis: isRecord(existing.assemblyBasis) ? existing.assemblyBasis : null, compatibilityRules: isRecord(existing.compatibilityRules) ? existing.compatibilityRules @@ -977,10 +1454,14 @@ export type { AssetVariantDTO, CreateAssetFamilyInput, CreateAssetVariantInput, + CreateSubstrateDictionaryInput, CreateSubstrateInput, - EnsureBoardLibraryDefaultsResult, SubstrateDTO, + SubstrateDictionaryBundleDTO, + SubstrateDictionaryOptionDTO, + SubstrateDictionaryType, UpdateAssetFamilyInput, UpdateAssetVariantInput, + UpdateSubstrateDictionaryInput, UpdateSubstrateInput, } from './asset-center.service.types' diff --git a/furniture-3d-system/src/server/services/asset-center.service.types.ts b/furniture-3d-system/src/server/services/asset-center.service.types.ts index 95092d91..65e2741a 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.types.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.types.ts @@ -61,6 +61,8 @@ export interface AssetVariantDTO { businessData: Record | 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 | 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 | 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 | 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 | 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 | 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 } diff --git a/furniture-3d-system/src/server/services/material.service.ts b/furniture-3d-system/src/server/services/material.service.ts index 4d1fb363..70fd7f7f 100644 --- a/furniture-3d-system/src/server/services/material.service.ts +++ b/furniture-3d-system/src/server/services/material.service.ts @@ -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 { + 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,