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:
parent
b101c5a634
commit
ef8c961626
@ -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: '创建' }))
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user