feat(基础资料库): 实现 5 字段唯一约束收口

## 5 字段唯一约束最终方案

createSubstrate 新增合并逻辑:

1. **完全重复** (相同 5 字段 + 相同花色) → 拒绝,报错「基材库已有相同规格的基材,不可重复创建」
2. **花色不同** (相同 5 字段 + 不同花色) → 合并新花色到已有基材,重建名称
3. **规格不同** (5 字段不同) → 新建基材,自动编码 BC-XXXX

5 字段 = categoryId + environmentGradeId + thicknessSpecId + surfaceOptionId + systemNameOptionId
实木 (无 surfaceOptionId) 按 null 处理,纳入 5 字段匹配

## 验证

- TypeScript 编译: 0 errors
- 基础资料库测试: 48/48 passed
This commit is contained in:
Codex 2026-04-11 11:01:14 +08:00
parent b101c5a634
commit ef8c961626
5 changed files with 71 additions and 32 deletions

View File

@ -134,7 +134,7 @@ describe('MaterialLibraryClient', () => {
// Switch to substrate tab first
await actor.click(screen.getByRole('button', { name: /基材录入/ }))
await actor.click(screen.getByRole('button', { name: '新建基材' }))
await actor.click(screen.getByRole('button', { name: /新建/ }))
await actor.type(screen.getByLabelText('售价'), '200')
await actor.click(screen.getByRole('button', { name: '创建' }))

View File

@ -47,6 +47,7 @@ export function PlatformSplitLayout({
}: PlatformSplitLayoutProps) {
return (
<div className={cn('flex h-[calc(100vh-56px)] flex-col overflow-hidden', className)}>
{/* 页面标题区(如果有) */}
{title ? (
<header className="shrink-0 px-6 pt-5 pb-3">
<div className="flex items-end justify-between">
@ -76,6 +77,7 @@ export function PlatformSplitLayout({
</header>
) : null}
{/* 指标条 */}
{metrics && metrics.length > 0 ? (
<div className="shrink-0 px-6 pb-2">
<div className="flex items-center gap-4">
@ -99,6 +101,7 @@ export function PlatformSplitLayout({
</div>
) : null}
{/* 反馈提示 */}
{feedback ? (
<div className="shrink-0 px-6 pb-2">
<div
@ -122,6 +125,7 @@ export function PlatformSplitLayout({
</div>
) : null}
{/* 主体:左右分栏 */}
<main className="flex min-h-0 flex-1 overflow-hidden px-8 pb-6">
<section
className="mx-auto flex min-h-0 w-full max-w-[1680px] flex-1 overflow-hidden rounded-lg"
@ -129,7 +133,10 @@ export function PlatformSplitLayout({
backgroundColor: 'var(--color-surface-container)',
}}
>
{/* 左侧:3D 预览区 */}
<div className="min-w-0 flex-1 p-3">{leftContent}</div>
{/* 右侧:检查器面板 */}
<aside
className={cn(
'w-[var(--layout-right-panel-width)] max-w-[var(--layout-right-panel-max-width)] shrink-0 overflow-y-auto border-l p-4',

View File

@ -3,7 +3,6 @@ import { assetCenterService } from '../asset-center.service'
import {
assetFamilyRepository,
assetVariantRepository,
materialCategoryRepository,
materialRepository,
substrateRepository,
} from '../../repositories'
@ -22,17 +21,10 @@ jest.mock('../../repositories', () => ({
substrateRepository: {
findById: jest.fn(),
findByCode: jest.fn(),
create: jest.fn(),
findByIdWithRelations: jest.fn(),
},
materialRepository: {
findById: jest.fn(),
findByNameAndCategory: jest.fn(),
create: jest.fn(),
},
materialCategoryRepository: {
findByName: jest.fn(),
create: jest.fn(),
},
}))
@ -41,7 +33,6 @@ describe('assetCenterService', () => {
const mockedAssetVariantRepository = jest.mocked(assetVariantRepository)
const mockedSubstrateRepository = jest.mocked(substrateRepository)
const mockedMaterialRepository = jest.mocked(materialRepository)
const mockedMaterialCategoryRepository = jest.mocked(materialCategoryRepository)
beforeEach(() => {
jest.clearAllMocks()

View File

@ -586,9 +586,8 @@ class AssetCenterService {
}
async createSubstrate(input: CreateSubstrateInput): Promise<SubstrateDTO> {
// Check for exact duplicate: same 5 fields + same finishes
const inputFinishIds = new Set(input.finishIds ?? [])
const existingWithSameSpec = await prisma.substrate.findMany({
// Look for existing substrate with same 5-field spec
const existing = await prisma.substrate.findFirst({
where: {
categoryId: input.categoryId,
environmentGradeId: input.environmentGradeId,
@ -598,18 +597,60 @@ class AssetCenterService {
isActive: true,
},
include: { finishes: { select: { finishId: true } } },
}) as Array<Substrate & { finishes: Array<{ finishId: string }> }>
for (const existing of existingWithSameSpec) {
}) as (Substrate & { finishes: Array<{ finishId: string }> }) | null
if (existing) {
// Same spec exists — merge new finishes and check for exact duplicate
const existingFinishIds = new Set(existing.finishes.map((f) => f.finishId))
if (
existingFinishIds.size === inputFinishIds.size &&
[...inputFinishIds].every((id) => existingFinishIds.has(id))
) {
const newFinishIds = (input.finishIds ?? []).filter((id) => !existingFinishIds.has(id))
// Exact duplicate: same spec + same finishes → reject
if (newFinishIds.length === 0 && (input.finishIds ?? []).length === existingFinishIds.size) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '基材库已有相同规格的基材,不可重复创建')
}
// Merge new finishes into existing substrate
if (newFinishIds.length > 0) {
const allFinishIds = [...existingFinishIds, ...newFinishIds]
const payload = input.surfaceOptionId
? await buildSubstratePayloadWithFinishes({
categoryId: existing.categoryId,
environmentGradeId: existing.environmentGradeId,
thicknessSpecId: existing.thicknessSpecId,
surfaceOptionId: input.surfaceOptionId!,
systemNameOptionId: existing.systemNameOptionId,
finishIds: allFinishIds,
})
: await (async () => {
const [cat, env, thick] = await Promise.all([
prisma.substrateCategory.findUnique({ where: { id: existing.categoryId } }),
prisma.substrateEnvironmentGrade.findUnique({ where: { id: existing.environmentGradeId } }),
prisma.substrateThicknessSpec.findUnique({ where: { id: existing.thicknessSpecId } }),
])
const finishNames: string[] = []
const rows = await prisma.material.findMany({ where: { id: { in: allFinishIds } }, select: { name: true } })
finishNames.push(...rows.map((r) => r.name))
const thickness = thick!.name.toUpperCase()
const finishPart = finishNames.length > 0 ? finishNames.join(' ') : ''
return { generatedName: [thickness, env!.name, cat!.name, finishPart].filter(Boolean).join(' ') }
})()
await prisma.substrate.update({
where: { id: existing.id },
data: {
name: payload.generatedName,
finishes: {
create: newFinishIds.map((finishId) => ({ finish: { connect: { id: finishId } } })),
},
},
})
}
const updated = await substrateRepository.findByIdWithRelations(existing.id)
return toSubstrateDTO(updated!)
}
// Auto-generate BC-XXXX code
// New spec — auto-generate BC-XXXX code
const code = input.code?.trim() || await this.generateNextSubstrateCode()
if (await substrateRepository.findByCode(code)) {

View File

@ -1,6 +1,6 @@
/**
* Service 模块导出
*
*
* @created 2026-01-30
*/
@ -48,20 +48,20 @@ export type {
} from './common-component.service'
export { departmentService, DepartmentService } from './department.service'
export type {
CreateDepartmentInput,
UpdateDepartmentInput,
DepartmentDTO,
DepartmentTreeNode
export type {
CreateDepartmentInput,
UpdateDepartmentInput,
DepartmentDTO,
DepartmentTreeNode
} from './department.service'
export { teamService, TeamService } from './team.service'
export type {
CreateTeamInput,
UpdateTeamInput,
TeamDTO,
TeamListOptions,
TeamListResult
export type {
CreateTeamInput,
UpdateTeamInput,
TeamDTO,
TeamListOptions,
TeamListResult
} from './team.service'
export { materialService, MaterialService } from './material.service'
export type {