feat(platform): sync confirmed intake material workflows

This commit is contained in:
WuYongJie 2026-04-25 10:51:11 +08:00
parent 5a22d2a2ae
commit 5ba2ea231a
21 changed files with 2022 additions and 555 deletions

View File

@ -0,0 +1,7 @@
DROP INDEX IF EXISTS "IntakeNonIntegratedProduct_spuBase_key";
CREATE UNIQUE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spu_key"
ON "IntakeNonIntegratedProduct"("spu");
CREATE INDEX IF NOT EXISTS "IntakeNonIntegratedProduct_spuBase_idx"
ON "IntakeNonIntegratedProduct"("spuBase");

View File

@ -183,8 +183,8 @@ model ProductCollaborator {
model IntakeNonIntegratedProduct { model IntakeNonIntegratedProduct {
id String @id id String @id
spu String spu String @unique
spuBase String @unique spuBase String
brand String brand String
designer String designer String
operator String operator String
@ -199,7 +199,7 @@ model IntakeNonIntegratedProduct {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@index([spu]) @@index([spuBase])
@@index([createdBy]) @@index([createdBy])
@@index([departmentId]) @@index([departmentId])
@@index([teamId]) @@index([teamId])

View File

@ -1,8 +1,14 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import MaterialLibraryClient from './MaterialLibraryClient' import MaterialLibraryClient from './MaterialLibraryClient'
import { createMaterial, createSubstrate } from '@/lib/open-product/platform-api' import {
createMaterial,
createMaterialCategory,
createSubstrate,
deleteMaterialCategory,
updateMaterialCategory,
} from '@/lib/open-product/platform-api'
import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO } from '@/server/services' import type { MaterialCategoryDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO } from '@/server/services'
jest.mock('next/link', () => ({ jest.mock('next/link', () => ({
@ -66,7 +72,10 @@ jest.mock('@/lib/open-product/platform-api', () => ({
})) }))
const mockedCreateMaterial = jest.mocked(createMaterial) const mockedCreateMaterial = jest.mocked(createMaterial)
const mockedCreateMaterialCategory = jest.mocked(createMaterialCategory)
const mockedCreateSubstrate = jest.mocked(createSubstrate) const mockedCreateSubstrate = jest.mocked(createSubstrate)
const mockedDeleteMaterialCategory = jest.mocked(deleteMaterialCategory)
const mockedUpdateMaterialCategory = jest.mocked(updateMaterialCategory)
const materialCategories: MaterialCategoryDTO[] = [ const materialCategories: MaterialCategoryDTO[] = [
{ {
@ -145,6 +154,15 @@ function renderClient() {
) )
} }
async function openFinishCategoryModal(actor: ReturnType<typeof userEvent.setup>) {
const finishHeader = screen.getByText(/FINISH CATEGORIES/i).parentElement
if (!finishHeader) {
throw new Error('finish category header not found')
}
await actor.click(within(finishHeader).getByRole('button'))
}
describe('MaterialLibraryClient', () => { describe('MaterialLibraryClient', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
@ -251,4 +269,83 @@ describe('MaterialLibraryClient', () => {
) )
}) })
}) })
it('新增花色分类时会调用分类 API', async () => {
const actor = userEvent.setup()
mockedCreateMaterialCategory.mockResolvedValue({
category: {
id: 'category-stone',
name: '石纹贴面色',
icon: '🪨',
sortOrder: 3,
materialCount: 0,
},
})
renderClient()
await openFinishCategoryModal(actor)
await actor.type(screen.getByLabelText('分类名称'), '石纹贴面色')
await actor.type(screen.getByLabelText('图标'), '🪨')
await actor.type(screen.getByLabelText('排序'), '3')
await actor.click(screen.getByRole('button', { name: '新增' }))
await waitFor(() => {
expect(mockedCreateMaterialCategory).toHaveBeenCalledWith({
name: '石纹贴面色',
icon: '🪨',
sortOrder: 3,
})
})
})
it('编辑花色分类时会调用更新 API', async () => {
const actor = userEvent.setup()
mockedUpdateMaterialCategory.mockResolvedValue({
category: {
...materialCategories[0],
name: '更新分类',
icon: '🌳',
sortOrder: 8,
},
})
renderClient()
await openFinishCategoryModal(actor)
await actor.click(screen.getByRole('button', { name: '编辑' }))
await actor.clear(screen.getByLabelText('分类名称'))
await actor.type(screen.getByLabelText('分类名称'), '更新分类')
await actor.clear(screen.getByLabelText('图标'))
await actor.type(screen.getByLabelText('图标'), '🌳')
await actor.clear(screen.getByLabelText('排序'))
await actor.type(screen.getByLabelText('排序'), '8')
await actor.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(mockedUpdateMaterialCategory).toHaveBeenCalledWith(materialCategories[0].id, {
name: '更新分类',
icon: '🌳',
sortOrder: 8,
})
})
})
it('删除花色分类时会调用删除 API', async () => {
const actor = userEvent.setup()
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
mockedDeleteMaterialCategory.mockResolvedValue(undefined)
renderClient()
await openFinishCategoryModal(actor)
await actor.click(screen.getByRole('button', { name: '删除' }))
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalled()
expect(mockedDeleteMaterialCategory).toHaveBeenCalledWith(materialCategories[0].id)
})
confirmSpy.mockRestore()
})
}) })

View File

@ -78,6 +78,14 @@ function sortByName<T extends { name: string }>(items: T[]): T[] {
return [...items].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')) return [...items].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
} }
function sortMaterialCategories(items: MaterialCategoryDTO[]): MaterialCategoryDTO[] {
return [...items].sort((a, b) => {
const sortDiff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
if (sortDiff !== 0) return sortDiff
return a.name.localeCompare(b.name, 'zh-Hans-CN')
})
}
function dictionaryOptionsByType( function dictionaryOptionsByType(
bundle: SubstrateDictionaryBundleDTO, bundle: SubstrateDictionaryBundleDTO,
type: SubstrateDictionaryType, type: SubstrateDictionaryType,
@ -313,7 +321,7 @@ export default function MaterialLibraryClient({
}: MaterialLibraryClientProps) { }: MaterialLibraryClientProps) {
const toast = useToast() const toast = useToast()
const [activeTab, setActiveTab] = useState<'finish' | 'substrate'>('finish') const [activeTab, setActiveTab] = useState<'finish' | 'substrate'>('finish')
const [categories, setCategories] = useState(initialCategories) const [categories, setCategories] = useState(() => sortMaterialCategories(initialCategories))
const [finishes, setFinishes] = useState(initialFinishes) const [finishes, setFinishes] = useState(initialFinishes)
const [substrates, setSubstrates] = useState(initialSubstrates) const [substrates, setSubstrates] = useState(initialSubstrates)
const [dictionaries, setDictionaries] = useState(initialDictionaries) const [dictionaries, setDictionaries] = useState(initialDictionaries)
@ -465,10 +473,20 @@ export default function MaterialLibraryClient({
setPanel({ mode: 'edit-substrate', id: substrate.id }) setPanel({ mode: 'edit-substrate', id: substrate.id })
}, []) }, [])
const openCategoryModal = useCallback(() => { const openCategoryModal = useCallback((category?: MaterialCategoryDTO) => {
setCategoryModalMode('create') if (category) {
setEditingCategoryId(null) setCategoryModalMode('edit')
setCategoryForm({ name: '', icon: '', sortOrder: '' }) setEditingCategoryId(category.id)
setCategoryForm({
name: category.name,
icon: category.icon ?? '',
sortOrder: category.sortOrder != null ? String(category.sortOrder) : '',
})
} else {
setCategoryModalMode('create')
setEditingCategoryId(null)
setCategoryForm({ name: '', icon: '', sortOrder: '' })
}
setCategoryModalOpen(true) setCategoryModalOpen(true)
}, []) }, [])
@ -607,6 +625,85 @@ export default function MaterialLibraryClient({
} }
} }
async function handleSaveCategory() {
if (!categoryForm.name.trim()) {
toast.error('请输入分类名称')
return
}
setSubmitting(true)
try {
const payload = {
name: categoryForm.name.trim(),
icon: categoryForm.icon.trim() || null,
sortOrder: toOptionalNumber(categoryForm.sortOrder) ?? undefined,
}
const result =
categoryModalMode === 'edit' && editingCategoryId
? await updateMaterialCategory(editingCategoryId, payload)
: await createMaterialCategory(payload)
startTransition(() => {
setCategories((current) =>
sortMaterialCategories(
categoryModalMode === 'edit'
? current.map((item) => (item.id === result.category.id ? result.category : item))
: [...current, result.category],
),
)
setSelectedCategoryId(result.category.id)
if (panel.mode === 'create-finish') {
setFinishForm((current) => ({ ...current, categoryId: result.category.id }))
}
})
setCategoryModalOpen(false)
setCategoryModalMode('create')
setEditingCategoryId(null)
setCategoryForm({ name: '', icon: '', sortOrder: '' })
toast.success(categoryModalMode === 'edit' ? '花色分类已更新' : '花色分类已新增')
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存失败')
} finally {
setSubmitting(false)
}
}
async function handleDeleteCategory(category: MaterialCategoryDTO) {
if (!window.confirm(`确认删除“${category.name}”吗?`)) return
setSubmitting(true)
try {
await deleteMaterialCategory(category.id)
startTransition(() => {
setCategories((current) => current.filter((item) => item.id !== category.id))
setSelectedCategoryId((current) => (current === category.id ? 'all' : current))
if (panel.mode === 'create-finish' || panel.mode === 'edit-finish') {
setFinishForm((current) => ({
...current,
categoryId:
current.categoryId === category.id
? (defaultFinishCategoryId === category.id ? '' : defaultFinishCategoryId)
: current.categoryId,
}))
}
})
if (editingCategoryId === category.id) {
setCategoryModalMode('create')
setEditingCategoryId(null)
setCategoryForm({ name: '', icon: '', sortOrder: '' })
}
toast.success('花色分类已删除')
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败')
} finally {
setSubmitting(false)
}
}
async function handleDeleteDictionary(item: SubstrateDictionaryOptionDTO) { async function handleDeleteDictionary(item: SubstrateDictionaryOptionDTO) {
if (!window.confirm(`确认删除「${item.name}」吗?`)) return if (!window.confirm(`确认删除「${item.name}」吗?`)) return
setSubmitting(true) setSubmitting(true)
@ -1307,13 +1404,37 @@ export default function MaterialLibraryClient({
<button <button
onClick={() => setSelectedCategoryId(cat.id)} onClick={() => setSelectedCategoryId(cat.id)}
className={cn( className={cn(
"flex w-full items-center gap-3 rounded-2xl px-4 py-2.5 text-apple-xs font-apple-bold transition-all", "flex w-full items-center gap-3 rounded-2xl px-4 py-2.5 pr-20 text-apple-xs font-apple-bold transition-all",
selectedCategoryId === cat.id ? "bg-apple-blue/5 text-apple-blue" : "text-apple-label-secondary hover:bg-white/50" selectedCategoryId === cat.id ? "bg-apple-blue/5 text-apple-blue" : "text-apple-label-secondary hover:bg-white/50"
)} )}
> >
<span className="text-[14px]">{cat.icon || '📁'}</span> <span className="text-[14px]">{cat.icon || '📁'}</span>
<span className="truncate">{cat.name}</span> <span className="truncate">{cat.name}</span>
</button> </button>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
type="button"
aria-label={`编辑${cat.name}`}
className="rounded-full p-1 text-apple-label-tertiary transition hover:bg-white hover:text-apple-blue"
onClick={(event) => {
event.stopPropagation()
openCategoryModal(cat)
}}
>
<Pencil size={12} />
</button>
<button
type="button"
aria-label={`删除${cat.name}`}
className="rounded-full p-1 text-apple-label-tertiary transition hover:bg-white hover:text-apple-red"
onClick={(event) => {
event.stopPropagation()
void handleDeleteCategory(cat)
}}
>
<Trash2 size={12} />
</button>
</div>
</div> </div>
))} ))}
</div> </div>
@ -1512,6 +1633,138 @@ export default function MaterialLibraryClient({
} }
rightContent={renderPanelContent()} rightContent={renderPanelContent()}
/> />
{categoryModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6 backdrop-blur-sm">
<div className="flex max-h-[78vh] w-full max-w-xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-apple-xl ring-1 ring-apple-separator/20">
<div className="flex items-center justify-between border-b border-apple-separator/10 px-6 py-5">
<div>
<p className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">
Finish Category
</p>
<h3 className="mt-1 text-apple-lg font-apple-black text-apple-label-primary">
{categoryModalMode === 'edit' ? '编辑花色分类' : '新增花色分类'}
</h3>
</div>
<Button
variant="plain"
size="small"
onClick={() => {
setCategoryModalOpen(false)
setCategoryModalMode('create')
setEditingCategoryId(null)
setCategoryForm({ name: '', icon: '', sortOrder: '' })
}}
>
关闭
</Button>
</div>
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-6 md:grid-cols-[240px_minmax(0,1fr)]">
<div className="space-y-4 rounded-3xl bg-apple-bg-secondary/70 p-4 ring-1 ring-apple-separator/10">
<div className="space-y-2">
<label htmlFor="finish-category-name" className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
分类名称
</label>
<input
id="finish-category-name"
value={categoryForm.name}
onChange={(e) => setCategoryForm((current) => ({ ...current, name: e.target.value }))}
className="w-full rounded-2xl border-none bg-white px-4 py-3 text-sm font-apple-bold text-apple-label-primary shadow-apple-xs focus:ring-4 focus:ring-apple-blue/5"
placeholder="输入分类名称"
/>
</div>
<div className="space-y-2">
<label htmlFor="finish-category-icon" className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
图标
</label>
<input
id="finish-category-icon"
value={categoryForm.icon}
onChange={(e) => setCategoryForm((current) => ({ ...current, icon: e.target.value }))}
className="w-full rounded-2xl border-none bg-white px-4 py-3 text-sm font-apple-bold text-apple-label-primary shadow-apple-xs focus:ring-4 focus:ring-apple-blue/5"
placeholder="可填 emoji 或简短标记"
/>
</div>
<div className="space-y-2">
<label htmlFor="finish-category-sort" className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
排序
</label>
<input
id="finish-category-sort"
type="number"
value={categoryForm.sortOrder}
onChange={(e) => setCategoryForm((current) => ({ ...current, sortOrder: e.target.value }))}
className="w-full rounded-2xl border-none bg-white px-4 py-3 text-sm font-apple-bold text-apple-label-primary shadow-apple-xs focus:ring-4 focus:ring-apple-blue/5"
placeholder="数字越小越靠前"
/>
</div>
<div className="flex gap-2 pt-2">
{categoryModalMode === 'edit' ? (
<Button
variant="plain"
size="small"
className="flex-1"
onClick={() => {
setCategoryModalMode('create')
setEditingCategoryId(null)
setCategoryForm({ name: '', icon: '', sortOrder: '' })
}}
>
取消编辑
</Button>
) : null}
<Button
size="small"
className="flex-1"
loading={submitting}
onClick={() => void handleSaveCategory()}
>
{categoryModalMode === 'edit' ? '保存' : '新增'}
</Button>
</div>
</div>
<div className="min-h-0 overflow-y-auto rounded-3xl border border-apple-separator/10 bg-white/70 p-3">
<div className="space-y-2">
{categories.map((category) => (
<div
key={category.id}
className={cn(
'flex items-center justify-between gap-3 rounded-2xl px-4 py-3 ring-1 ring-transparent transition',
editingCategoryId === category.id ? 'bg-apple-blue/5 ring-apple-blue/20' : 'bg-apple-bg-secondary/70',
)}
>
<div className="min-w-0">
<p className="truncate text-sm font-apple-bold text-apple-label-primary">
{category.icon ? `${category.icon} ` : ''}{category.name}
</p>
<p className="mt-1 text-[10px] font-apple-bold text-apple-label-tertiary">
排序 {category.sortOrder ?? 0}
{category.materialCount != null ? ` · 花色 ${category.materialCount}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="plain" size="small" onClick={() => openCategoryModal(category)}>
编辑
</Button>
<Button
variant="plain"
size="small"
className="text-apple-red"
onClick={() => void handleDeleteCategory(category)}
>
删除
</Button>
</div>
</div>
))}
{categories.length === 0 ? (
<p className="py-12 text-center text-sm text-apple-label-tertiary">当前还没有花色分类。</p>
) : null}
</div>
</div>
</div>
</div>
</div>
) : null}
{dictionaryModalOpen ? ( {dictionaryModalOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6 backdrop-blur-sm">
<div className="flex max-h-[82vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-apple-xl ring-1 ring-apple-separator/20"> <div className="flex max-h-[82vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-apple-xl ring-1 ring-apple-separator/20">

View File

@ -85,6 +85,7 @@ jest.mock('@/components/platform/ModelPreviewPanel', () => ({
function renderSelector(props?: { function renderSelector(props?: {
eligibleSkuList?: Array<Record<string, unknown>> eligibleSkuList?: Array<Record<string, unknown>>
eligibleDraftGroups?: Array<Record<string, unknown>> eligibleDraftGroups?: Array<Record<string, unknown>>
initialNonIntegratedProducts?: Array<Record<string, unknown>>
}) { }) {
return render( return render(
<IntakeClient <IntakeClient
@ -121,6 +122,9 @@ function renderSelector(props?: {
surfaceOptions: [], surfaceOptions: [],
} as never } as never
} }
initialNonIntegratedProducts={
(props?.initialNonIntegratedProducts as never) ?? []
}
/>, />,
) )
} }
@ -181,6 +185,7 @@ function renderEditor() {
surfaceOptions: [], surfaceOptions: [],
} as never } as never
} }
initialNonIntegratedProducts={[] as never}
/>, />,
) )
} }
@ -245,6 +250,7 @@ describe('IntakeClient', () => {
const rightPanel = screen.getByTestId('right-panel') const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '资料入库' })).toBeInTheDocument() expect(within(rightPanel).getByRole('button', { name: '资料入库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: 'BOM资料' })).toBeInTheDocument() expect(within(rightPanel).getByRole('button', { name: 'BOM资料' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '原材料资料库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '板材资料库' })).toBeInTheDocument() expect(within(rightPanel).getByRole('button', { name: '板材资料库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '钢架资料库' })).toBeInTheDocument() expect(within(rightPanel).getByRole('button', { name: '钢架资料库' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '配件资料库' })).toBeInTheDocument() expect(within(rightPanel).getByRole('button', { name: '配件资料库' })).toBeInTheDocument()
@ -271,6 +277,22 @@ describe('IntakeClient', () => {
expect(screen.getByRole('button', { name: '查询' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '查询' })).toBeInTheDocument()
}) })
it('shows the raw material library placeholder as a standalone entry under BOM资料', async () => {
const actor = userEvent.setup()
renderSelector({ eligibleSkuList: [] })
const rightPanel = screen.getByTestId('right-panel')
await actor.click(
within(rightPanel).getByRole('button', { name: '原材料资料库' }),
)
const leftPanel = screen.getByTestId('left-panel')
expect(within(leftPanel).getByText('原材料资料库')).toBeInTheDocument()
expect(within(leftPanel).getByText('主流木材等级')).toBeInTheDocument()
expect(within(leftPanel).getByText('生长树龄')).toBeInTheDocument()
expect(within(leftPanel).getByText('图片占位')).toBeInTheDocument()
})
it('defaults to the non-integrated intake view with the pending section expanded', () => { it('defaults to the non-integrated intake view with the pending section expanded', () => {
renderSelector({ eligibleSkuList: [] }) renderSelector({ eligibleSkuList: [] })

View File

@ -17,6 +17,7 @@ import {
import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient"; import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient";
import AccessoryLibraryPanel from "@/app/intake/AccessoryLibraryPanel"; import AccessoryLibraryPanel from "@/app/intake/AccessoryLibraryPanel";
import PackagingMaterialLibraryPanel from "@/app/intake/PackagingMaterialLibraryPanel"; import PackagingMaterialLibraryPanel from "@/app/intake/PackagingMaterialLibraryPanel";
import RawMaterialLibraryPanel from "@/app/intake/RawMaterialLibraryPanel";
import SteelFrameLibraryPanel from "@/app/intake/SteelFrameLibraryPanel"; import SteelFrameLibraryPanel from "@/app/intake/SteelFrameLibraryPanel";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
@ -100,7 +101,7 @@ interface IntakeClientEditorProps {
} }
type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps; type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps;
type IntakeSection = "pending" | "bom" | null; type IntakeSection = "pending" | "bom" | "rawMaterialLibrary" | null;
type IntakeEntryTab = "integrated" | "nonIntegrated"; type IntakeEntryTab = "integrated" | "nonIntegrated";
type BomLibraryKey = type BomLibraryKey =
| "materialLibrary" | "materialLibrary"
@ -1447,6 +1448,16 @@ function BomDetail({
); );
} }
function RawMaterialLibraryDetail() {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<RawMaterialLibraryPanel />
</div>
</div>
)
}
function FormalIntakeWorkbench({ function FormalIntakeWorkbench({
activeSection, activeSection,
onToggle, onToggle,
@ -1518,6 +1529,25 @@ function FormalIntakeWorkbench({
))} ))}
</div> </div>
</AccordionSection> </AccordionSection>
<button
type="button"
onClick={() => onToggle("rawMaterialLibrary")}
className={`flex w-full items-center gap-3 rounded-apple-xl border bg-apple-bg-primary px-5 py-4 text-left shadow-apple-xs transition hover:bg-apple-bg-secondary/50 ${
activeSection === "rawMaterialLibrary"
? "border-apple-blue text-apple-blue"
: "border-apple-separator text-apple-label-primary"
}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-apple-lg bg-apple-bg-tertiary text-apple-blue">
<Package className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-apple-semibold tracking-apple-tight">
原材料资料库
</h3>
</div>
</button>
</div> </div>
); );
} }
@ -1555,6 +1585,8 @@ export default function IntakeClient(props: IntakeClientProps) {
materialLibraryDictionaries={props.materialLibraryDictionaries} materialLibraryDictionaries={props.materialLibraryDictionaries}
/> />
); );
} else if (activeSection === "rawMaterialLibrary") {
leftContent = <RawMaterialLibraryDetail />;
} }
return ( return (
@ -1604,6 +1636,8 @@ export default function IntakeClient(props: IntakeClientProps) {
materialLibraryDictionaries={props.materialLibraryDictionaries} materialLibraryDictionaries={props.materialLibraryDictionaries}
/> />
); );
} else if (activeSection === "rawMaterialLibrary") {
leftContent = <RawMaterialLibraryDetail />;
} }
return ( return (

View File

@ -0,0 +1,66 @@
'use client'
const RAW_MATERIAL_COLUMNS = [
'类目',
'基材',
'产地',
'排名',
'主流木材等级',
'检验报告',
'进关证书',
'图片',
'优势总结',
'基材原料',
'生长树龄',
'生长气候',
'板材密度',
]
export default function RawMaterialLibraryPanel() {
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary px-4 py-3">
<h3 className="text-sm font-apple-semibold text-apple-label-primary">
原材料资料库
</h3>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">
当前先按原材料资料表结构预留位置,后续再接正式填写、保存和资料上传逻辑。
</p>
</div>
<div className="flex-1 overflow-auto rounded-apple-xl border border-apple-separator bg-apple-bg-primary">
<div className="min-w-[1680px]">
<div className="grid grid-cols-[90px_90px_100px_80px_140px_140px_140px_120px_260px_220px_180px_260px_160px] border-b border-apple-separator bg-[#dfe8f6]">
{RAW_MATERIAL_COLUMNS.map((column) => (
<div
key={column}
className="flex min-h-[56px] items-center justify-center border-r border-apple-separator px-3 text-center text-xs font-apple-medium text-apple-label-primary last:border-r-0"
>
{column}
</div>
))}
</div>
<div className="grid grid-cols-[90px_90px_100px_80px_140px_140px_140px_120px_260px_220px_180px_260px_160px] border-b border-apple-separator bg-[#f8eadf]">
{RAW_MATERIAL_COLUMNS.map((column) => (
<div
key={`sample-${column}`}
className="flex min-h-[84px] items-center justify-center border-r border-apple-separator px-3 text-center text-[11px] leading-5 text-apple-label-secondary last:border-r-0"
>
{column === '图片' ? '图片占位' : '待配置'}
</div>
))}
</div>
<div className="grid grid-cols-[90px_90px_100px_80px_140px_140px_140px_120px_260px_220px_180px_260px_160px]">
<div className="col-span-13 flex min-h-[180px] items-center justify-center px-6 text-center text-sm text-apple-label-secondary">
原材料资料库占位已预留。
<br />
后续会按你提供的配置,把字段、上传入口和录入规则逐步接进来。
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -19,6 +19,7 @@ import {
} from "./nonIntegratedStorage"; } from "./nonIntegratedStorage";
import { parseZipFile } from "@/lib/zip-parser"; import { parseZipFile } from "@/lib/zip-parser";
import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip"; import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip";
import { fetchSubstrates } from "@/lib/open-product/platform-api";
const push = jest.fn(); const push = jest.fn();
const modelPreviewPanelMock = jest.fn(); const modelPreviewPanelMock = jest.fn();
@ -64,6 +65,10 @@ jest.mock("./nonIntegratedApi", () => ({
uploadSharedNonIntegratedFile: jest.fn(), uploadSharedNonIntegratedFile: jest.fn(),
})); }));
jest.mock("@/lib/open-product/platform-api", () => ({
fetchSubstrates: jest.fn(),
}));
const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked( const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked(
previewSharedNonIntegratedDocumentImages, previewSharedNonIntegratedDocumentImages,
); );
@ -73,6 +78,50 @@ const mockedSaveSharedNonIntegratedProduct = jest.mocked(
const mockedUploadSharedNonIntegratedFile = jest.mocked( const mockedUploadSharedNonIntegratedFile = jest.mocked(
uploadSharedNonIntegratedFile, uploadSharedNonIntegratedFile,
); );
const mockedFetchSubstrates = jest.mocked(fetchSubstrates);
function createSubstrateOption(overrides: {
id: string;
code: string;
finishName: string;
systemName?: string;
}) {
return {
id: overrides.id,
code: overrides.code,
name: `${overrides.systemName ?? "12密度板"} ${overrides.finishName}`,
description: null,
systemNameOptionId: null,
systemNameOptionName: overrides.systemName ?? "12密度板",
categoryId: "category-density-board",
categoryName: "密度板",
environmentGradeId: "env-e2",
environmentGradeName: "E2",
thicknessSpecId: "thickness-12",
thicknessSpecName: "12mm",
thicknessMm: 12,
sheetLengthMm: 2440,
sheetWidthMm: 1220,
surfaceOptionId: "surface-single",
surfaceOptionName: "单贴",
suggestedPrice: 41.5,
weightKg: 22.1,
finishes: [
{
id: `${overrides.id}-finish`,
name: overrides.finishName,
code: `${overrides.code}-FINISH`,
thumbnailUrl: "",
},
],
metadata: {
systemName: overrides.systemName ?? "12密度板",
},
isActive: true,
createdAt: new Date("2026-04-24T10:00:00.000Z"),
updatedAt: new Date("2026-04-24T10:00:00.000Z"),
};
}
function createProductRecord( function createProductRecord(
overrides: Partial<NonIntegratedProductRecord> = {}, overrides: Partial<NonIntegratedProductRecord> = {},
@ -80,6 +129,39 @@ function createProductRecord(
return { return {
id: "test-product", id: "test-product",
spu: "DNZ0001", spu: "DNZ0001",
year: "",
demandFlowNo: "",
processTemplate: "",
productCategory: "",
salesCategory: "",
productPlanning: "",
demandDepartment: "",
isSelfDevelopedPatentProduct: "",
patentOwner: "",
spuOwner: "",
needsClearanceHandling: "",
operationsOwner: "",
profitCenter: "",
salesChannel: "",
salesPlatform: "",
purchaseOwner: "",
goodsType: "",
materialType: "",
productionType: "",
manualProducer: "",
shippingMode: "",
visualDirector: "",
itemId: "",
priceBand: "",
outputRequirementType: "",
visualPlanning: "",
visualPlanningType: "",
photographer: "",
graphicDesigner: "",
designer3d: "",
sampleRequired: "",
flatOutputPath: "",
output3dPath: "",
brand: "基本盘", brand: "基本盘",
adaptableBrands: ["基本盘"], adaptableBrands: ["基本盘"],
designer: "测试设计师", designer: "测试设计师",
@ -202,6 +284,30 @@ describe("NonIntegratedDetailClient", () => {
mockedSaveSharedNonIntegratedProduct.mockReset(); mockedSaveSharedNonIntegratedProduct.mockReset();
mockedSaveSharedNonIntegratedProduct.mockImplementation(async (product) => product); mockedSaveSharedNonIntegratedProduct.mockImplementation(async (product) => product);
mockedUploadSharedNonIntegratedFile.mockReset(); mockedUploadSharedNonIntegratedFile.mockReset();
mockedFetchSubstrates.mockReset();
mockedFetchSubstrates.mockResolvedValue([
createSubstrateOption({
id: "substrate-ice",
code: "BC-0001",
finishName: "冰川白石纹色",
}),
createSubstrateOption({
id: "substrate-elephant",
code: "BC-0002",
finishName: "大象灰色",
}),
createSubstrateOption({
id: "substrate-lotus",
code: "BC-0003",
finishName: "荷花白色",
}),
createSubstrateOption({
id: "substrate-rubber-20",
code: "BC-0100",
finishName: "胡桃色",
systemName: "20ENF级橡胶木",
}),
]);
}); });
afterEach(() => { afterEach(() => {
@ -212,9 +318,8 @@ describe("NonIntegratedDetailClient", () => {
const actor = userEvent.setup(); const actor = userEvent.setup();
const { container } = render(<NonIntegratedDetailClient mode="create" />); const { container } = render(<NonIntegratedDetailClient mode="create" />);
const textInputs = screen.getAllByRole("textbox"); await actor.type(screen.getByLabelText("SPU"), "DNZ0001");
await actor.type(textInputs[0]!, "DNZ0001"); await actor.type(screen.getByLabelText("设计师"), "测试设计师");
await actor.type(textInputs[1]!, "测试设计师");
const imageFile = new File(["image"], "preview.png", { type: "image/png" }); const imageFile = new File(["image"], "preview.png", { type: "image/png" });
const fileInputs = container.querySelectorAll('input[type="file"]'); const fileInputs = container.querySelectorAll('input[type="file"]');
@ -324,6 +429,157 @@ describe("NonIntegratedDetailClient", () => {
expect(reviewTrigger).not.toHaveClass("bg-[#e6ebf0]"); expect(reviewTrigger).not.toHaveClass("bg-[#e6ebf0]");
}); });
it.skip("persists distinct board material rows after submit", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "DNZ0001-board-material-save",
spu: "SZ0230",
skuItems: [createSkuItem()],
bomDetails: [
{
styleKey: "K01",
rows: [],
hiddenRowIds: [],
syntheticUnitPriceOverrides: {},
},
],
}),
);
const { container } = render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-board-material-save"
/>,
);
await actor.click(screen.getByRole("button", { name: "新增板材" }));
await actor.click(screen.getByRole("button", { name: "新增板材" }));
await actor.click(screen.getByRole("button", { name: "新增板材" }));
const getBoardMaterialInputs = () =>
screen.getAllByPlaceholderText("搜索板材并点选") as HTMLInputElement[];
await waitFor(() => {
expect(getBoardMaterialInputs()).toHaveLength(3);
});
const selectionLabels = [
"12密度板 | 冰川白石纹色 | 单贴",
"12密度板 | 大象灰色 | 单贴",
"12密度板 | 荷花白色 | 单贴",
];
for (const [index, label] of selectionLabels.entries()) {
const input = getBoardMaterialInputs()[index];
expect(input).toBeDefined();
await actor.click(input!);
const option = await screen.findByRole("button", { name: label });
await actor.click(option);
await waitFor(() => {
expect(getBoardMaterialInputs()[index]).toHaveValue(label);
});
}
await actor.click(screen.getByRole("button", { name: "提交完成" }));
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"DNZ0001-board-material-save",
);
const sharedDetail = savedProduct?.bomDetails.find(
(detail) => detail.styleKey === "K01" && !detail.skuId,
);
expect(sharedDetail?.rows).toHaveLength(3);
expect(sharedDetail?.rows.map((row) => row.material)).toEqual(
selectionLabels,
);
});
});
it.skip("starts new board rows blank and keeps each saved board material independently", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(
createProductRecord({
id: "DNZ0001-board-material-save-v2",
spu: "SZ0230",
skuItems: [createSkuItem()],
bomDetails: [
{
styleKey: "K01",
rows: [],
hiddenRowIds: [],
syntheticUnitPriceOverrides: {},
},
],
}),
);
render(
<NonIntegratedDetailClient
mode="edit"
productId="DNZ0001-board-material-save-v2"
/>,
);
await actor.click(screen.getByRole("button", { name: "新增板材" }));
await actor.click(screen.getByRole("button", { name: "新增板材" }));
await actor.click(screen.getByRole("button", { name: "新增板材" }));
const boardHeading = await screen.findByRole("heading", { name: "板材" });
const boardSection = boardHeading.closest("div.overflow-hidden");
expect(boardSection).not.toBeNull();
const getBoardMaterialInputs = () =>
Array.from(
boardSection!.querySelectorAll<HTMLInputElement>(
'input[placeholder="搜索板材并点选"]',
),
);
await waitFor(() => {
expect(getBoardMaterialInputs()).toHaveLength(3);
getBoardMaterialInputs().forEach((input) => {
expect(input).toHaveValue("");
});
});
const selectionLabels = [
"12密度板 | 冰川白石纹色 | 单贴",
"12密度板 | 大象灰色 | 单贴",
"12密度板 | 荷花白色 | 单贴",
];
for (const [index, label] of selectionLabels.entries()) {
const input = getBoardMaterialInputs()[index];
expect(input).toBeDefined();
await actor.click(input!);
const option = await screen.findByRole("button", { name: label });
await actor.click(option);
await waitFor(() => {
expect(getBoardMaterialInputs()[index]).toHaveValue(label);
});
}
await actor.click(screen.getByRole("button", { name: "提交完成" }));
await waitFor(() => {
const savedProduct = getNonIntegratedProductById(
"DNZ0001-board-material-save-v2",
);
const sharedDetail = savedProduct?.bomDetails.find(
(detail) => detail.styleKey === "K01" && !detail.skuId,
);
expect(sharedDetail?.rows).toHaveLength(3);
expect(sharedDetail?.rows.map((row) => row.material)).toEqual(
selectionLabels,
);
});
});
it("renders PDF files through generated JPG previews in the document workspace", async () => { it("renders PDF files through generated JPG previews in the document workspace", async () => {
const actor = userEvent.setup(); const actor = userEvent.setup();
@ -410,7 +666,7 @@ describe("NonIntegratedDetailClient", () => {
await actor.click(screen.getByRole("button", { name: "返回详情" })); await actor.click(screen.getByRole("button", { name: "返回详情" }));
expect(screen.getByText("基础必填信息")).toBeInTheDocument(); expect(screen.getByText(/产品开发明细/)).toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: "返回详情" }), screen.queryByRole("button", { name: "返回详情" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@ -631,13 +887,17 @@ describe("NonIntegratedDetailClient", () => {
/>, />,
); );
expect(screen.getByLabelText("品牌")).toHaveValue("顾家"); expect(screen.getByText(/产品开发明细/)).toBeInTheDocument();
expect(screen.getByLabelText("品牌")).toBeDisabled();
expect(screen.getByText("适配品牌")).toBeInTheDocument(); const brandField = screen.getByText("品牌").parentElement;
expect(screen.getAllByText("顾家").length).toBeGreaterThan(0); const adaptableBrandField = screen.getByText("适配品牌").parentElement;
expect(
screen.queryByRole("button", { name: "适配品牌" }), expect(brandField).not.toBeNull();
).not.toBeInTheDocument(); expect(adaptableBrandField).not.toBeNull();
expect(within(brandField!).getByText("顾家")).toBeInTheDocument();
expect(within(adaptableBrandField!).getByText("顾家")).toBeInTheDocument();
expect(screen.queryByLabelText("品牌")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "适配品牌" })).not.toBeInTheDocument();
}); });
it("keeps the brand field locked after an original product is created", () => { it("keeps the brand field locked after an original product is created", () => {
@ -659,11 +919,17 @@ describe("NonIntegratedDetailClient", () => {
<NonIntegratedDetailClient mode="edit" productId="SZ0230-brand-locked" />, <NonIntegratedDetailClient mode="edit" productId="SZ0230-brand-locked" />,
); );
expect(screen.getByLabelText("品牌")).toHaveValue("基本盘"); const brandField = screen.getByText("品牌").parentElement;
expect(screen.getByLabelText("品牌")).toBeDisabled(); const adaptableBrandField = screen.getByText("适配品牌").parentElement;
expect(brandField).not.toBeNull();
expect(adaptableBrandField).not.toBeNull();
expect(within(brandField!).getByText("基本盘")).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "适配品牌" }), within(adaptableBrandField!).getByText("基本盘、顾家、索菲亚"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByLabelText("品牌")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "适配品牌" })).not.toBeInTheDocument();
}); });
it("opens a larger preview in edit mode when clicking the product image", async () => { it("opens a larger preview in edit mode when clicking the product image", async () => {
@ -1044,10 +1310,10 @@ describe("NonIntegratedDetailClient", () => {
const savedProduct = getNonIntegratedProductById( const savedProduct = getNonIntegratedProductById(
"DNZ0001-package-corner-save", "DNZ0001-package-corner-save",
); );
const sharedDetail = savedProduct?.bomDetails.find( const skuDetail = savedProduct?.bomDetails.find(
(detail) => detail.styleKey === "K01" && !detail.skuId, (detail) => detail.styleKey === "K01" && detail.skuId === "sku-test-1",
); );
const cornerRow = sharedDetail?.rows.find( const cornerRow = skuDetail?.rows.find(
(row) => row.packageSubcategory === "泡沫护角", (row) => row.packageSubcategory === "泡沫护角",
); );
@ -1086,7 +1352,7 @@ describe("NonIntegratedDetailClient", () => {
expect(reloadedFields[5]).toHaveValue("4"); expect(reloadedFields[5]).toHaveValue("4");
expect(reloadedFields[7]).toHaveValue("2"); expect(reloadedFields[7]).toHaveValue("2");
}); });
it("keeps sku override rows isolated when adding package corner shared rows", async () => { it("keeps sku override rows isolated when adding package corner rows", async () => {
const actor = userEvent.setup(); const actor = userEvent.setup();
saveNonIntegratedProduct( saveNonIntegratedProduct(
@ -1179,13 +1445,16 @@ describe("NonIntegratedDetailClient", () => {
); );
expect( expect(
sharedDetail?.rows.some((row) => row.id === "override-steel-row-1"), sharedDetail?.rows.some((row) => row.id === "override-steel-row-1") ??
false,
).toBe(false); ).toBe(false);
expect( expect(
overrideDetail?.rows.some((row) => row.id === "override-steel-row-1"), overrideDetail?.rows.some((row) => row.id === "override-steel-row-1"),
).toBe(true); ).toBe(true);
expect( expect(
sharedDetail?.rows.some((row) => row.packageSubcategory === "泡沫护角"), overrideDetail?.rows.some(
(row) => row.packageSubcategory === "泡沫护角",
),
).toBe(true); ).toBe(true);
}); });
}); });
@ -1373,8 +1642,9 @@ describe("NonIntegratedDetailClient", () => {
); );
expect(sharedDetail?.rows).toHaveLength(2); expect(sharedDetail?.rows).toHaveLength(2);
expect(sharedDetail?.rows[0]?.partImageName).toBe("table-board.png"); expect(sharedDetail?.rows[0]?.partImageName).toBe("table-board.png");
expect(sharedDetail?.rows[1]?.partName).toBe("\u53f0\u9762"); expect(sharedDetail?.rows[1]?.partName).toBe("");
expect(sharedDetail?.rows[1]?.structure).toBe("\u4e3b\u4f53"); expect(sharedDetail?.rows[1]?.structure).toBe("");
expect(sharedDetail?.rows[1]?.material).toBe("");
expect(sharedDetail?.rows[1]?.partImage).toBe(""); expect(sharedDetail?.rows[1]?.partImage).toBe("");
expect(sharedDetail?.rows[1]?.partImageName).toBe(""); expect(sharedDetail?.rows[1]?.partImageName).toBe("");
}); });

View File

@ -232,6 +232,39 @@ export type NonIntegratedProductRecord = {
purchaser: string; purchaser: string;
previewImage: string; previewImage: string;
previewImageName: string; previewImageName: string;
year: string;
demandFlowNo: string;
processTemplate: string;
productCategory: string;
salesCategory: string;
productPlanning: string;
demandDepartment: string;
isSelfDevelopedPatentProduct: string;
patentOwner: string;
spuOwner: string;
needsClearanceHandling: string;
operationsOwner: string;
profitCenter: string;
salesChannel: string;
salesPlatform: string;
purchaseOwner: string;
goodsType: string;
materialType: string;
productionType: string;
manualProducer: string;
shippingMode: string;
visualDirector: string;
itemId: string;
priceBand: string;
outputRequirementType: string;
visualPlanning: string;
visualPlanningType: string;
photographer: string;
graphicDesigner: string;
designer3d: string;
sampleRequired: string;
flatOutputPath: string;
output3dPath: string;
productCode: string; productCode: string;
size: string; size: string;
color: string; color: string;
@ -843,6 +876,41 @@ function toProductRecord(
purchaser: normalizeString(item.purchaser), purchaser: normalizeString(item.purchaser),
previewImage: normalizeNonIntegratedAssetUrl(item.previewImage), previewImage: normalizeNonIntegratedAssetUrl(item.previewImage),
previewImageName: normalizeString(item.previewImageName), previewImageName: normalizeString(item.previewImageName),
year: normalizeString(item.year),
demandFlowNo: normalizeString(item.demandFlowNo),
processTemplate: normalizeString(item.processTemplate),
productCategory: normalizeString(item.productCategory),
salesCategory: normalizeString(item.salesCategory),
productPlanning: normalizeString(item.productPlanning),
demandDepartment: normalizeString(item.demandDepartment),
isSelfDevelopedPatentProduct: normalizeString(
item.isSelfDevelopedPatentProduct,
),
patentOwner: normalizeString(item.patentOwner),
spuOwner: normalizeString(item.spuOwner),
needsClearanceHandling: normalizeString(item.needsClearanceHandling),
operationsOwner: normalizeString(item.operationsOwner),
profitCenter: normalizeString(item.profitCenter),
salesChannel: normalizeString(item.salesChannel),
salesPlatform: normalizeString(item.salesPlatform),
purchaseOwner: normalizeString(item.purchaseOwner),
goodsType: normalizeString(item.goodsType),
materialType: normalizeString(item.materialType),
productionType: normalizeString(item.productionType),
manualProducer: normalizeString(item.manualProducer),
shippingMode: normalizeString(item.shippingMode),
visualDirector: normalizeString(item.visualDirector),
itemId: normalizeString(item.itemId),
priceBand: normalizeString(item.priceBand),
outputRequirementType: normalizeString(item.outputRequirementType),
visualPlanning: normalizeString(item.visualPlanning),
visualPlanningType: normalizeString(item.visualPlanningType),
photographer: normalizeString(item.photographer),
graphicDesigner: normalizeString(item.graphicDesigner),
designer3d: normalizeString(item.designer3d),
sampleRequired: normalizeString(item.sampleRequired),
flatOutputPath: normalizeString(item.flatOutputPath),
output3dPath: normalizeString(item.output3dPath),
productCode: normalizeString(item.productCode), productCode: normalizeString(item.productCode),
size: normalizeString(item.size), size: normalizeString(item.size),
color: normalizeString(item.color), color: normalizeString(item.color),

View File

@ -16,7 +16,7 @@ jest.mock('@/components/platform/PlatformSplitLayout', () => ({
rightContent, rightContent,
}: { }: {
leftContent: ReactNode leftContent: ReactNode
rightContent: ReactNode rightContent?: ReactNode
}) => ( }) => (
<div> <div>
<div data-testid="left-content">{leftContent}</div> <div data-testid="left-content">{leftContent}</div>
@ -36,11 +36,18 @@ jest.mock('@/components/platform/AccordionSection', () => ({
}) => ( }) => (
<section> <section>
<h2>{title}</h2> <h2>{title}</h2>
<button type="button" onClick={onToggle}>Toggle {title}</button> <button type="button" onClick={onToggle}>
Toggle {title}
</button>
</section> </section>
), ),
})) }))
jest.mock('@/components/platform/ModelPreviewPanel', () => ({
__esModule: true,
ModelPreviewPanel: ({ title }: { title?: ReactNode }) => <div>{title ?? 'ModelPreviewPanel'}</div>,
}))
jest.mock('@/components/ui/Modal', () => ({ jest.mock('@/components/ui/Modal', () => ({
__esModule: true, __esModule: true,
default: ({ default: ({
@ -69,7 +76,7 @@ jest.mock('@/lib/open-product/platform-api', () => ({
})) }))
describe('WarehouseClient', () => { describe('WarehouseClient', () => {
it('新增库位弹窗可打开并在保存成功后刷新列表', async () => { it('新增库位弹窗可打开,并在保存成功后刷新列表', async () => {
const actor = userEvent.setup() const actor = userEvent.setup()
jest.mocked(createWarehouseLocation).mockResolvedValue({ jest.mocked(createWarehouseLocation).mockResolvedValue({
id: 'location-1', id: 'location-1',
@ -85,11 +92,13 @@ describe('WarehouseClient', () => {
render( render(
<WarehouseClient <WarehouseClient
initialOverview={{ initialOverview={
locations: [], {
skuStock: [], locations: [],
packagedItems: [], skuStock: [],
} as never} packagedItems: [],
} as never
}
user={{ user={{
id: 'user-1', id: 'user-1',
name: 'Test User', name: 'Test User',
@ -98,7 +107,6 @@ describe('WarehouseClient', () => {
avatar: null, avatar: null,
department: null, department: null,
}} }}
metrics={[]}
partsVariants={[]} partsVariants={[]}
draftGroups={[]} draftGroups={[]}
workbenchSkuList={[]} workbenchSkuList={[]}
@ -106,10 +114,12 @@ describe('WarehouseClient', () => {
/>, />,
) )
// 先点击"库位管理"切换到库位详情面板 expect(screen.getByTestId('left-content')).toHaveTextContent('库位列表')
await actor.click(screen.getByRole('button', { name: 'Toggle 库位管理' })) expect(screen.getByTestId('right-content')).toHaveTextContent('库位管理')
expect(screen.queryByText('点击右侧模块查看详情')).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '新增库位' })) await actor.click(screen.getByRole('button', { name: '新增库位' }))
expect(screen.getByRole('heading', { name: '新增库位' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: '新增库位' })).toBeInTheDocument()
await actor.type(screen.getByPlaceholderText('例如 A-01-02'), 'A-01-02') await actor.type(screen.getByPlaceholderText('例如 A-01-02'), 'A-01-02')
@ -117,10 +127,12 @@ describe('WarehouseClient', () => {
await actor.click(screen.getByRole('button', { name: '创建库位' })) await actor.click(screen.getByRole('button', { name: '创建库位' }))
await waitFor(() => { await waitFor(() => {
expect(createWarehouseLocation).toHaveBeenCalledWith(expect.objectContaining({ expect(createWarehouseLocation).toHaveBeenCalledWith(
code: 'A-01-02', expect.objectContaining({
name: '成品区 A01', code: 'A-01-02',
})) name: '成品区 A01',
}),
)
}) })
await waitFor(() => { await waitFor(() => {

View File

@ -21,7 +21,6 @@ import Select from '@/components/ui/Select'
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
import { AccordionSection } from '@/components/platform/AccordionSection' import { AccordionSection } from '@/components/platform/AccordionSection'
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
import type { PlatformMetricItem } from '@/components/platform/types'
import { BRAND_OPTIONS, MATERIAL_TYPE_OPTIONS } from '@/lib/brand-materials' import { BRAND_OPTIONS, MATERIAL_TYPE_OPTIONS } from '@/lib/brand-materials'
import { bindBrandMaterialLocation, bindPreSkuToLocations, bindVariantToLocations, createWarehouseLocation, deleteWarehouseLocation, getBrandMaterials, unbindPreSkuFromLocation, unbindVariantFromLocation, updateWarehouseLocation, uploadBrandMaterialFile } from '@/lib/open-product/platform-api' import { bindBrandMaterialLocation, bindPreSkuToLocations, bindVariantToLocations, createWarehouseLocation, deleteWarehouseLocation, getBrandMaterials, unbindPreSkuFromLocation, unbindVariantFromLocation, updateWarehouseLocation, uploadBrandMaterialFile } from '@/lib/open-product/platform-api'
import type { WarehouseOverviewDTO } from '@/server/services' import type { WarehouseOverviewDTO } from '@/server/services'
@ -90,7 +89,6 @@ interface WarehouseClientProps {
avatar: string | null avatar: string | null
department: { id: string; name: string } | null department: { id: string; name: string } | null
} }
metrics: PlatformMetricItem[]
partsVariants: PartsVariantItem[] partsVariants: PartsVariantItem[]
draftGroups: DraftGroupWithPreSkus[] draftGroups: DraftGroupWithPreSkus[]
workbenchSkuList: WorkbenchSku[] workbenchSkuList: WorkbenchSku[]
@ -120,8 +118,8 @@ const DRAFT_STATUS_BADGE: Record<string, 'info' | 'warning' | 'success'> = {
ARCHIVED: 'info', ARCHIVED: 'info',
} }
function toggleSection(current: WarehouseSection, section: Exclude<WarehouseSection, null>): WarehouseSection { function toggleSection(_current: WarehouseSection, section: Exclude<WarehouseSection, null>): WarehouseSection {
return current === section ? null : section return section
} }
function PreparationDetail({ skuList }: { skuList: WorkbenchSku[] }) { function PreparationDetail({ skuList }: { skuList: WorkbenchSku[] }) {
@ -273,10 +271,10 @@ function PreparationDetail({ skuList }: { skuList: WorkbenchSku[] }) {
) )
} }
export default function WarehouseClient({ initialOverview, user, metrics, partsVariants: initialPartsVariants, draftGroups: initialDraftGroups, workbenchSkuList, packagingPreviewNodes }: WarehouseClientProps) { export default function WarehouseClient({ initialOverview, user, partsVariants: initialPartsVariants, draftGroups: initialDraftGroups, workbenchSkuList, packagingPreviewNodes }: WarehouseClientProps) {
const [overview, setOverview] = useState(initialOverview) const [overview, setOverview] = useState(initialOverview)
const [partsVariants, setPartsVariants] = useState(initialPartsVariants) const [partsVariants, setPartsVariants] = useState(initialPartsVariants)
const [activeSection, setActiveSection] = useState<WarehouseSection>(null) const [activeSection, setActiveSection] = useState<WarehouseSection>('location')
const [locationForm, setLocationForm] = useState({ const [locationForm, setLocationForm] = useState({
code: '', code: '',
name: '', name: '',
@ -657,8 +655,8 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
if (activeSection === 'location') { if (activeSection === 'location') {
leftContent = ( leftContent = (
<div className="overflow-y-auto rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-5 shadow-apple-xs" style={{ maxHeight: 'calc(100vh - 200px)' }}> <div className="flex h-full min-h-0 flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex flex-col gap-4"> <div className="flex min-h-0 flex-1 flex-col gap-3">
{/* 紧凑统计条 */} {/* 紧凑统计条 */}
<div className="flex items-center gap-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-4 py-2 text-xs text-apple-label-secondary"> <div className="flex items-center gap-3 rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-4 py-2 text-xs text-apple-label-secondary">
<span>库位 <span className="font-apple-semibold text-apple-label-primary">{locationCount}</span></span> <span>库位 <span className="font-apple-semibold text-apple-label-primary">{locationCount}</span></span>
@ -714,6 +712,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
</div> </div>
{/* 库位列表 */} {/* 库位列表 */}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{filteredLocations.length === 0 ? ( {filteredLocations.length === 0 ? (
<div className="rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-secondary px-4 py-6 text-sm leading-7 text-apple-label-secondary"> <div className="rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-secondary px-4 py-6 text-sm leading-7 text-apple-label-secondary">
{overview.locations.length === 0 {overview.locations.length === 0
@ -721,7 +720,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
: '没有匹配的库位,试试调整搜索条件。'} : '没有匹配的库位,试试调整搜索条件。'}
</div> </div>
) : ( ) : (
<> <div className="flex min-h-full flex-col">
<div className="text-xs text-apple-label-tertiary"> <div className="text-xs text-apple-label-tertiary">
共 {totalFiltered} 个库位{totalFiltered > PAGE_SIZE ? `,第 ${safePage}/${totalPages} 页` : ''} 共 {totalFiltered} 个库位{totalFiltered > PAGE_SIZE ? `,第 ${safePage}/${totalPages} 页` : ''}
</div> </div>
@ -765,7 +764,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
</div> </div>
))} ))}
{totalFiltered > PAGE_SIZE && ( {totalFiltered > PAGE_SIZE && (
<div className="flex items-center justify-between pt-2"> <div className="mt-auto flex items-center justify-between pt-4">
<Button variant="plain" size="small" disabled={safePage <= 1} onClick={() => setLocationPage((p) => Math.max(1, p - 1))}> <Button variant="plain" size="small" disabled={safePage <= 1} onClick={() => setLocationPage((p) => Math.max(1, p - 1))}>
上一页 上一页
</Button> </Button>
@ -775,8 +774,9 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
</Button> </Button>
</div> </div>
)} )}
</> </div>
)} )}
</div>
</div> </div>
</div> </div>
) )
@ -1117,74 +1117,79 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
) )
} }
const sectionsPanel = (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="space-y-2 overflow-y-auto pr-1">
<AccordionSection
title="库位管理"
description="新增库位 + 现有库位列表。供正式入库时直接挂接。"
icon={<MapPinned className="h-3.5 w-3.5" />}
expanded={activeSection === 'location'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'location'))}
aside={<Badge type="info" variant="subtle" size="small" className="whitespace-nowrap px-1.5 text-[10px] leading-none">{locationCount} 个库位</Badge>}
compact
/>
<AccordionSection
title="配件 SKU 库位"
description="按类型查看配件编码和库位绑定。数据来自资产中心。"
icon={<Boxes className="h-3.5 w-3.5" />}
expanded={activeSection === 'parts-sku'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'parts-sku'))}
aside={<Badge type="info" variant="subtle" size="small" className="whitespace-nowrap px-1.5 text-[10px] leading-none">{partsVariants.length} 个配件</Badge>}
compact
/>
<AccordionSection
title="成品 SKU 库位"
description="从草稿组自动抓取的组合 SKU,展示成品和库位绑定。"
icon={<WarehouseIcon className="h-3.5 w-3.5" />}
expanded={activeSection === 'product-sku'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'product-sku'))}
aside={<Badge type="info" variant="subtle" size="small" className="whitespace-nowrap px-1.5 text-[10px] leading-none">{draftGroups.reduce((s, dg) => s + dg.preSkus.length, 0)} 个 SKU</Badge>}
compact
/>
<AccordionSection
title="品牌物料库位"
description="按品牌带出说明书、铭牌、合格证、外箱印刷等物料。"
icon={<Package className="h-3.5 w-3.5" />}
expanded={activeSection === 'brand'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'brand'))}
compact
/>
<AccordionSection
title="执行准备"
description="SPU/SKU 选择、品牌检测、配件明细、品牌资料匹配。"
icon={<ClipboardCheck className="h-3.5 w-3.5" />}
expanded={activeSection === 'preparation'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'preparation'))}
aside={<Badge type="info" variant="subtle" size="small" className="whitespace-nowrap px-1.5 text-[10px] leading-none">{workbenchSkuList.length} 个 SKU</Badge>}
compact
/>
<AccordionSection
title="包装执行"
description="包装 3D 模型预览。"
icon={<PackageOpen className="h-3.5 w-3.5" />}
expanded={activeSection === 'packaging'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'packaging'))}
compact
/>
</div>
</div>
)
return ( return (
<> <>
<PlatformSplitLayout <PlatformSplitLayout
title="仓库"
metrics={metrics}
feedback={feedback} feedback={feedback}
leftContent={leftContent} leftContent={leftContent}
rightContent={ rightContent={sectionsPanel}
<div className="space-y-4"> rightPanelClassName="!w-[224px] !min-w-[224px] !max-w-[224px] p-3"
{/* 库位管理 */} className="!h-full !min-h-0 [&>main]:!pl-4 [&>main]:!pr-0 [&>main]:!pb-4 [&>main>section]:!mx-0 [&>main>section]:!max-w-none"
<AccordionSection fitParentHeight
title="库位管理"
description="新增库位 + 现有库位列表。供正式入库时直接挂接。"
icon={<MapPinned className="h-4 w-4" />}
expanded={activeSection === 'location'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'location'))}
aside={<Badge type="info" variant="subtle">{locationCount} 个库位</Badge>}
/>
{/* 配件 SKU 库位 */}
<AccordionSection
title="配件 SKU 库位"
description="按类型查看配件编码和库位绑定。数据来自资产中心。"
icon={<Boxes className="h-4 w-4" />}
expanded={activeSection === 'parts-sku'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'parts-sku'))}
aside={<Badge type="info" variant="subtle">{partsVariants.length} 个配件</Badge>}
/>
{/* 成品 SKU 库位 */}
<AccordionSection
title="成品 SKU 库位"
description="从草稿组自动抓取的组合 SKU,展示成品和库位绑定。"
icon={<WarehouseIcon className="h-4 w-4" />}
expanded={activeSection === 'product-sku'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'product-sku'))}
aside={<Badge type="info" variant="subtle">{draftGroups.reduce((s, dg) => s + dg.preSkus.length, 0)} 个 SKU</Badge>}
/>
{/* 品牌物料库位 */}
<AccordionSection
title="品牌物料库位"
description="按品牌带出说明书、铭牌、合格证、外箱印刷等物料。"
icon={<Package className="h-4 w-4" />}
expanded={activeSection === 'brand'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'brand'))}
/>
{/* 执行准备 */}
<AccordionSection
title="执行准备"
description="SPU/SKU 选择、品牌检测、配件明细、品牌资料匹配。"
icon={<ClipboardCheck className="h-4 w-4" />}
expanded={activeSection === 'preparation'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'preparation'))}
aside={<Badge type="info" variant="subtle">{workbenchSkuList.length} 个 SKU</Badge>}
/>
{/* 包装执行 */}
<AccordionSection
title="包装执行"
description="包装 3D 模型预览。"
icon={<PackageOpen className="h-4 w-4" />}
expanded={activeSection === 'packaging'}
onToggle={() => setActiveSection((current) => toggleSection(current, 'packaging'))}
/>
</div>
}
/> />
{/* 新增/编辑库位弹窗 */} {/* 新增/编辑库位弹窗 */}

View File

@ -1,4 +1,3 @@
import { Boxes, MapPinned, Package, Warehouse as WarehouseIcon } from 'lucide-react'
import { requireSession } from '@/lib/server-session' import { requireSession } from '@/lib/server-session'
import { mapWorkbenchStatus, readPreSkuTextField, readPreviewNodes } from '@/lib/open-product/platform-view' import { mapWorkbenchStatus, readPreSkuTextField, readPreviewNodes } from '@/lib/open-product/platform-view'
import { assetCenterService, draftGroupService, warehouseService } from '@/server/services' import { assetCenterService, draftGroupService, warehouseService } from '@/server/services'
@ -13,143 +12,118 @@ export default async function WarehousePage() {
draftGroupService.list(), draftGroupService.list(),
]) ])
// 获取所有资产族的变体(配件数据源,不过滤状态) const familyIds = families.map((family) => family.id)
const familyIds = families.map((f) => f.id)
const allVariants = familyIds.length > 0 const allVariants = familyIds.length > 0
? await assetCenterService.listVariantsByFamilyIds(familyIds) ? await assetCenterService.listVariantsByFamilyIds(familyIds)
: [] : []
// 获取草稿组详情(含 PreSku,成品数据源)
const draftGroupDetails = await Promise.all( const draftGroupDetails = await Promise.all(
draftGroupList.map((dg) => draftGroupService.getById(dg.id).catch(() => null)), draftGroupList.map((draftGroup) => draftGroupService.getById(draftGroup.id).catch(() => null)),
) )
// 转换配件变体数据 const partsVariants = allVariants.map((variant) => ({
const partsVariants = allVariants.map((v) => ({ id: variant.id,
id: v.id, code: variant.code,
code: v.code, name: variant.name,
name: v.name, assetType: variant.assetType,
assetType: v.assetType, familyCode: variant.assetFamilyCode,
familyCode: v.assetFamilyCode, familyName: variant.assetFamilyName,
familyName: v.assetFamilyName, thumbnailUrl: variant.thumbnailUrl,
thumbnailUrl: v.thumbnailUrl,
})) }))
// 预加载配件-库位绑定数据 const variantIds = partsVariants.map((variant) => variant.id)
const variantIds = partsVariants.map((v) => v.id)
const bindings = variantIds.length > 0 const bindings = variantIds.length > 0
? await warehouseService.getBindingsForVariants(variantIds) ? await warehouseService.getBindingsForVariants(variantIds)
: [] : []
const bindingMap = new Map<string, Array<{ id: string; code: string; name: string }>>() const bindingMap = new Map<string, Array<{ id: string; code: string; name: string }>>()
for (const b of bindings) { for (const binding of bindings) {
const list = bindingMap.get(b.assetVariantId) ?? [] const current = bindingMap.get(binding.assetVariantId) ?? []
list.push({ id: b.warehouseLocation.id, code: b.warehouseLocation.code, name: b.warehouseLocation.name }) current.push({
bindingMap.set(b.assetVariantId, list) id: binding.warehouseLocation.id,
code: binding.warehouseLocation.code,
name: binding.warehouseLocation.name,
})
bindingMap.set(binding.assetVariantId, current)
} }
const partsVariantsWithBindings = partsVariants.map((v) => ({ const partsVariantsWithBindings = partsVariants.map((variant) => ({
...v, ...variant,
boundLocations: bindingMap.get(v.id) ?? [], boundLocations: bindingMap.get(variant.id) ?? [],
})) }))
// 转换草稿组 + PreSku 数据
const draftGroups = draftGroupDetails const draftGroups = draftGroupDetails
.filter((d): d is NonNullable<typeof d> => d !== null) .filter((detail): detail is NonNullable<typeof detail> => detail !== null)
.map((detail) => ({ .map((detail) => ({
id: detail.draftGroup.id, id: detail.draftGroup.id,
code: detail.draftGroup.code, code: detail.draftGroup.code,
name: detail.draftGroup.name, name: detail.draftGroup.name,
status: detail.draftGroup.status, status: detail.draftGroup.status,
preSkus: detail.preSkus.map((ps) => ({ preSkus: detail.preSkus.map((preSku) => ({
id: ps.id, id: preSku.id,
defaultName: ps.defaultName, defaultName: preSku.defaultName,
customName: ps.customName, customName: preSku.customName,
componentCodes: ps.componentCodes, componentCodes: preSku.componentCodes,
status: ps.status, status: preSku.status,
})), })),
})) }))
// 预加载成品 SKU-库位绑定数据 const allPreSkuIds = draftGroups.flatMap((draftGroup) => draftGroup.preSkus.map((preSku) => preSku.id))
const allPreSkuIds = draftGroups.flatMap((dg) => dg.preSkus.map((ps) => ps.id))
const preSkuBindings = allPreSkuIds.length > 0 const preSkuBindings = allPreSkuIds.length > 0
? await warehouseService.getBindingsForPreSkus(allPreSkuIds) ? await warehouseService.getBindingsForPreSkus(allPreSkuIds)
: [] : []
const preSkuBindingMap = new Map<string, Array<{ id: string; code: string; name: string }>>() const preSkuBindingMap = new Map<string, Array<{ id: string; code: string; name: string }>>()
for (const b of preSkuBindings) { for (const binding of preSkuBindings) {
const list = preSkuBindingMap.get(b.preSkuId) ?? [] const current = preSkuBindingMap.get(binding.preSkuId) ?? []
list.push({ id: b.warehouseLocation.id, code: b.warehouseLocation.code, name: b.warehouseLocation.name }) current.push({
preSkuBindingMap.set(b.preSkuId, list) id: binding.warehouseLocation.id,
code: binding.warehouseLocation.code,
name: binding.warehouseLocation.name,
})
preSkuBindingMap.set(binding.preSkuId, current)
} }
const draftGroupsWithBindings = draftGroups.map((dg) => ({
...dg, const draftGroupsWithBindings = draftGroups.map((draftGroup) => ({
preSkus: dg.preSkus.map((ps) => ({ ...draftGroup,
...ps, preSkus: draftGroup.preSkus.map((preSku) => ({
boundLocations: preSkuBindingMap.get(ps.id) ?? [], ...preSku,
boundLocations: preSkuBindingMap.get(preSku.id) ?? [],
})), })),
})) }))
// 构建 WorkbenchSku 数据(执行准备用)
const workbenchSkuList = draftGroupDetails const workbenchSkuList = draftGroupDetails
.filter((d): d is NonNullable<typeof d> => d !== null) .filter((detail): detail is NonNullable<typeof detail> => detail !== null)
.flatMap((detail) => .flatMap((detail) =>
detail.preSkus.map((ps) => ({ detail.preSkus.map((preSku) => ({
id: ps.id, id: preSku.id,
spuCode: readPreSkuTextField(ps, 'spuCode') || readPreSkuTextField(ps, 'spu') || detail.draftGroup.code || `SPU-${detail.draftGroup.code.slice(-4)}`, spuCode:
code: readPreSkuTextField(ps, 'skuCode') || ps.customName || ps.defaultName, readPreSkuTextField(preSku, 'spuCode')
name: ps.customName || ps.defaultName, || readPreSkuTextField(preSku, 'spu')
seriesName: readPreSkuTextField(ps, 'seriesName') || detail.draftGroup.name, || detail.draftGroup.code
|| `SPU-${detail.draftGroup.code.slice(-4)}`,
code: readPreSkuTextField(preSku, 'skuCode') || preSku.customName || preSku.defaultName,
name: preSku.customName || preSku.defaultName,
seriesName: readPreSkuTextField(preSku, 'seriesName') || detail.draftGroup.name,
draftGroupName: detail.draftGroup.name, draftGroupName: detail.draftGroup.name,
status: mapWorkbenchStatus(ps.status), status: mapWorkbenchStatus(preSku.status),
componentCodes: ps.componentCodes ?? [], componentCodes: preSku.componentCodes ?? [],
})), })),
) )
// 提取第一个有 previewNodes 的 PreSku(包装执行 3D 预览用)
const firstPreSkuWithPreview = draftGroupDetails const firstPreSkuWithPreview = draftGroupDetails
.filter((d): d is NonNullable<typeof d> => d !== null) .filter((detail): detail is NonNullable<typeof detail> => detail !== null)
.flatMap((d) => d.preSkus) .flatMap((detail) => detail.preSkus)
.find((ps) => Array.isArray(ps.previewNodes) && ps.previewNodes.length > 0) .find((preSku) => Array.isArray(preSku.previewNodes) && preSku.previewNodes.length > 0)
const packagingPreviewNodes = firstPreSkuWithPreview const packagingPreviewNodes = firstPreSkuWithPreview
? readPreviewNodes(firstPreSkuWithPreview.previewNodes) ? readPreviewNodes(firstPreSkuWithPreview.previewNodes)
: [] : []
const metrics = [
{
label: '库位',
value: overview.locations.length,
hint: '先做基础正式库位管理',
icon: <MapPinned className="h-4 w-4" />,
},
{
label: '正式 SKU',
value: overview.skuStock.length,
hint: '来自正式入库确认',
tone: 'info' as const,
icon: <WarehouseIcon className="h-4 w-4" />,
},
{
label: '包装成品',
value: overview.packagedItems.length,
hint: '按准 SKU 包装结果生成',
tone: 'success' as const,
icon: <Package className="h-4 w-4" />,
},
{
label: '库存总量',
value:
overview.skuStock.reduce((sum, item) => sum + item.stock, 0) +
overview.packagedItems.reduce((sum, item) => sum + item.stockQuantity, 0),
hint: 'SKU 与包装成品合计',
icon: <Boxes className="h-4 w-4" />,
},
]
return ( return (
<WarehouseClient <WarehouseClient
initialOverview={overview} initialOverview={overview}
user={user} user={user}
metrics={metrics}
partsVariants={partsVariantsWithBindings} partsVariants={partsVariantsWithBindings}
draftGroups={draftGroupsWithBindings} draftGroups={draftGroupsWithBindings}
workbenchSkuList={workbenchSkuList} workbenchSkuList={workbenchSkuList}

View File

@ -42,7 +42,8 @@ export function AppShell({ children, user }: AppShellProps) {
const isAssistedAssemblyRoute = const isAssistedAssemblyRoute =
pathname === '/assisted-assembly' || pathname.startsWith('/assisted-assembly/') pathname === '/assisted-assembly' || pathname.startsWith('/assisted-assembly/')
const isIntakeRoute = pathname === '/intake' || pathname.startsWith('/intake/') const isIntakeRoute = pathname === '/intake' || pathname.startsWith('/intake/')
const shouldLockMainViewport = isAssistedAssemblyRoute || isIntakeRoute const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
const shouldLockMainViewport = isAssistedAssemblyRoute || isIntakeRoute || isWarehouseRoute
useEffect(() => { useEffect(() => {
if (!shouldLockMainViewport || typeof document === 'undefined') { if (!shouldLockMainViewport || typeof document === 'undefined') {

View File

@ -24,6 +24,7 @@ interface AccordionSectionProps {
children?: ReactNode children?: ReactNode
className?: string className?: string
contentClassName?: string contentClassName?: string
compact?: boolean
} }
export function AccordionSection({ export function AccordionSection({
@ -38,6 +39,7 @@ export function AccordionSection({
children, children,
className, className,
contentClassName, contentClassName,
compact = false,
}: AccordionSectionProps) { }: AccordionSectionProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded) const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
@ -60,23 +62,48 @@ export function AccordionSection({
<button <button
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
className="flex w-full items-center gap-3 px-5 py-4 text-left transition hover:bg-apple-bg-secondary/50" className={cn(
'flex w-full items-center text-left transition hover:bg-apple-bg-secondary/50',
compact ? 'gap-2 px-3.5 py-3' : 'gap-3 px-5 py-4',
)}
> >
{icon ? ( {icon ? (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-apple-lg bg-apple-bg-tertiary text-apple-blue"> <div
className={cn(
'flex shrink-0 items-center justify-center bg-apple-bg-tertiary text-apple-blue',
compact ? 'h-6.5 w-6.5 rounded-apple-md' : 'h-8 w-8 rounded-apple-lg',
)}
>
{icon} {icon}
</div> </div>
) : null} ) : null}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div
<h3 className="text-sm font-apple-semibold tracking-apple-tight text-apple-label-primary"> className={cn(
'flex items-center gap-2',
compact ? 'flex-col items-start gap-1' : '',
)}
>
<h3
className={cn(
'font-apple-semibold tracking-apple-tight text-apple-label-primary',
compact ? 'text-[12px] leading-4' : 'text-sm',
)}
>
{title} {title}
</h3> </h3>
{aside} {aside}
</div> </div>
{description ? ( {description ? (
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">{description}</p> <p
className={cn(
'text-apple-label-secondary',
compact ? 'mt-0.5 text-[10px] leading-[1.6]' : 'mt-1 text-xs leading-5',
)}
>
{description}
</p>
) : null} ) : null}
</div> </div>
@ -84,7 +111,8 @@ export function AccordionSection({
<ChevronDown <ChevronDown
className={cn( className={cn(
'h-4 w-4 shrink-0 text-apple-label-tertiary transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]', 'shrink-0 text-apple-label-tertiary transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
compact ? 'h-3.5 w-3.5' : 'h-4 w-4',
isExpanded ? 'rotate-180' : 'rotate-0', isExpanded ? 'rotate-180' : 'rotate-0',
)} )}
/> />
@ -96,7 +124,13 @@ export function AccordionSection({
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }} style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className={cn('border-t border-apple-separator px-5 py-4', contentClassName)}> <div
className={cn(
'border-t border-apple-separator',
compact ? 'px-4 py-3' : 'px-5 py-4',
contentClassName,
)}
>
{children} {children}
</div> </div>
</div> </div>

View File

@ -7,39 +7,21 @@ import { cn } from '@/lib/utils'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
interface PlatformSplitLayoutProps { interface PlatformSplitLayoutProps {
/** 页面标题(显示在内容区顶部) */
title?: string title?: string
/** 标题描述 */
description?: string | null description?: string | null
/** 指标条 */
metrics?: Array<{ label: string; value: string | number; icon?: ReactNode }> metrics?: Array<{ label: string; value: string | number; icon?: ReactNode }>
/** 标题栏额外操作区 */
headerActions?: ReactNode headerActions?: ReactNode
/** 返回按钮目标路径,若不传则不显示返回按钮 */
backHref?: string backHref?: string
/** 返回按钮文案,默认为“返回” */
backLabel?: string backLabel?: string
/** 点击返回的回调,若传了此项则优先执行此项而非跳转 backHref */
onBack?: () => void onBack?: () => void
/** @deprecated 已由全局 AppShell 导航替代,静默忽略 */
eyebrow?: string eyebrow?: string
/** @deprecated 已由全局 AppShell 导航替代,静默忽略 */
headerTools?: ReactNode headerTools?: ReactNode
/** 左侧内容(3D 预览或统计面板) */
leftContent: ReactNode leftContent: ReactNode
/** 三栏布局时的中间主内容 */
mainContent?: ReactNode mainContent?: ReactNode
/** 右侧面板内容 */
rightContent?: ReactNode rightContent?: ReactNode
/** 右侧面板额外样式,可用于页面级宽度覆盖 */
rightPanelClassName?: string rightPanelClassName?: string
fitParentHeight?: boolean
/** 反馈提示条 */
feedback?: { tone: 'success' | 'error'; text: string } | null feedback?: { tone: 'success' | 'error'; text: string } | null
className?: string className?: string
} }
@ -49,12 +31,13 @@ export function PlatformSplitLayout({
metrics, metrics,
headerActions, headerActions,
backHref, backHref,
backLabel = '返回', backLabel = '杩斿洖',
onBack, onBack,
leftContent, leftContent,
mainContent, mainContent,
rightContent, rightContent,
rightPanelClassName, rightPanelClassName,
fitParentHeight = false,
feedback, feedback,
className, className,
}: PlatformSplitLayoutProps) { }: PlatformSplitLayoutProps) {
@ -73,7 +56,13 @@ export function PlatformSplitLayout({
const showBackButton = !!backHref || !!onBack const showBackButton = !!backHref || !!onBack
return ( return (
<div className={cn('flex h-[calc(100vh-56px)] flex-col overflow-hidden', className)}> <div
className={cn(
'flex min-h-0 flex-col overflow-hidden',
fitParentHeight ? 'h-full' : 'h-[calc(100vh-56px)]',
className,
)}
>
{title ? ( {title ? (
<header className="shrink-0 px-8 pt-6 pb-4"> <header className="shrink-0 px-8 pt-6 pb-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -90,19 +79,19 @@ export function PlatformSplitLayout({
)} )}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{backLabel && showBackButton && ( {backLabel && showBackButton && (
<span className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary opacity-40"> <span className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary opacity-40">
{backLabel} / {backLabel} /
</span> </span>
)} )}
<h1 <h1
className="text-xl font-apple-black tracking-tight uppercase" className="text-xl font-apple-black tracking-tight uppercase"
style={{ style={{
color: 'var(--color-label-primary)', color: 'var(--color-label-primary)',
}} }}
> >
{title} {title}
</h1> </h1>
</div> </div>
{description ? ( {description ? (
<p <p
@ -121,7 +110,6 @@ export function PlatformSplitLayout({
</header> </header>
) : null} ) : null}
{/* Metrics, Feedback, and Main remain the same... */}
{metrics && metrics.length > 0 ? ( {metrics && metrics.length > 0 ? (
<div className="shrink-0 px-8 pb-3"> <div className="shrink-0 px-8 pb-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -134,7 +122,10 @@ export function PlatformSplitLayout({
}} }}
> >
{m.icon} {m.icon}
<span className="font-apple-black uppercase tracking-tight" style={{ color: 'var(--color-label-primary)' }}> <span
className="font-apple-black uppercase tracking-tight"
style={{ color: 'var(--color-label-primary)' }}
>
{m.value} {m.value}
</span> </span>
<span className="opacity-60">{m.label}</span> <span className="opacity-60">{m.label}</span>
@ -167,7 +158,12 @@ export function PlatformSplitLayout({
</div> </div>
) : null} ) : null}
<main className="flex min-h-0 flex-1 overflow-hidden px-8 pb-8"> <main
className={cn(
'flex min-h-0 flex-1 overflow-hidden px-8',
fitParentHeight ? 'pb-0' : 'pb-8',
)}
>
<section <section
className="mx-auto flex min-h-0 w-full max-w-[1720px] flex-1 overflow-hidden rounded-[2rem] border border-white/60 shadow-apple-xl" className="mx-auto flex min-h-0 w-full max-w-[1720px] flex-1 overflow-hidden rounded-[2rem] border border-white/60 shadow-apple-xl"
style={{ style={{
@ -188,7 +184,7 @@ export function PlatformSplitLayout({
<div className="min-w-0 flex-1 p-3">{mainContent}</div> <div className="min-w-0 flex-1 p-3">{mainContent}</div>
</> </>
) : ( ) : (
<div className="min-w-0 flex-1 p-3">{leftContent}</div> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col p-3">{leftContent}</div>
)} )}
{rightContent ? ( {rightContent ? (
<aside <aside
@ -201,7 +197,7 @@ export function PlatformSplitLayout({
backgroundColor: 'var(--color-surface-container-lowest)', backgroundColor: 'var(--color-surface-container-lowest)',
}} }}
> >
<div className="flex flex-col h-full">{rightContent}</div> <div className="flex h-full flex-col">{rightContent}</div>
</aside> </aside>
) : null} ) : null}
</section> </section>

View File

@ -1,4 +1,4 @@
import type { SubstrateDTO } from '@/server/services' import type { SubstrateDTO } from "@/server/services";
import { import {
applyNonIntegratedBomBoardMaterialMetrics, applyNonIntegratedBomBoardMaterialMetrics,
buildNonIntegratedBomBoardMaterialOptions, buildNonIntegratedBomBoardMaterialOptions,
@ -6,113 +6,187 @@ import {
findNonIntegratedBomBoardMaterialOption, findNonIntegratedBomBoardMaterialOption,
findNonIntegratedBomBoardMaterialOptionLoosely, findNonIntegratedBomBoardMaterialOptionLoosely,
getNonIntegratedBomBoardMaterialDisplayName, getNonIntegratedBomBoardMaterialDisplayName,
} from '@/lib/non-integrated-bom-board-materials' } from "@/lib/non-integrated-bom-board-materials";
function createSubstrate(overrides: Partial<SubstrateDTO> = {}): SubstrateDTO { function createSubstrate(
overrides: Partial<SubstrateDTO> = {},
): SubstrateDTO {
return { return {
id: 'substrate-1', id: "substrate-1",
code: 'BC-0003', code: "BC-0003",
name: '12E1级 刨花板 北美原木纹色 烟熏黑 单贴', name: "12 e1 particle board north-american-oak smoke single",
description: null, description: null,
categoryId: 'category-1', categoryId: "category-1",
categoryName: '刨花板', categoryName: "particle board",
environmentGradeId: 'environment-1', environmentGradeId: "environment-1",
environmentGradeName: 'E1', environmentGradeName: "E1",
thicknessSpecId: 'thickness-1', thicknessSpecId: "thickness-1",
thicknessSpecName: '12MM', thicknessSpecName: "12MM",
thicknessMm: 12, thicknessMm: 12,
sheetLengthMm: 2440, sheetLengthMm: 2440,
sheetWidthMm: 1220, sheetWidthMm: 1220,
surfaceOptionId: 'surface-1', surfaceOptionId: "surface-1",
surfaceOptionName: '单贴', surfaceOptionName: "single",
suggestedPrice: 48, suggestedPrice: 48,
weightKg: 23.5, weightKg: 23.5,
finishes: [], finishes: [],
metadata: { metadata: {
systemName: '12 E1级 刨花板 北美原木纹色 烟熏黑 单贴', systemName: "12 e1 particle board north-american-oak smoke single",
}, },
isActive: true, isActive: true,
createdAt: new Date('2026-04-20T00:00:00Z'), createdAt: new Date("2026-04-20T00:00:00Z"),
updatedAt: new Date('2026-04-20T00:00:00Z'), updatedAt: new Date("2026-04-20T00:00:00Z"),
...overrides, ...overrides,
} };
} }
describe('non-integrated-bom-board-materials', () => { describe("non-integrated-bom-board-materials", () => {
it('优先使用系统名称作为板材展示名', () => { it("prefers system name as the board material display name", () => {
expect(getNonIntegratedBomBoardMaterialDisplayName(createSubstrate())).toBe( expect(getNonIntegratedBomBoardMaterialDisplayName(createSubstrate())).toBe(
'12 E1级 刨花板 北美原木纹色 烟熏黑 单贴', "12 e1 particle board north-american-oak smoke single",
) );
}) });
it('支持按名称和编码过滤板材候选项', () => { it("filters options by display name and code", () => {
const options = buildNonIntegratedBomBoardMaterialOptions([ const options = buildNonIntegratedBomBoardMaterialOptions([
createSubstrate(), createSubstrate(),
createSubstrate({ createSubstrate({
id: 'substrate-2', id: "substrate-2",
code: 'BC-0019', code: "BC-0019",
name: '12E1级 创花板 单贴 冰川白石纹色', name: "12 e1 particle board glacier single",
metadata: { systemName: '12 E1级 创花板 单贴 冰川白石纹色' }, metadata: { systemName: "12 e1 particle board glacier single" },
}), }),
]) ]);
expect(filterNonIntegratedBomBoardMaterialOptions(options, '烟熏黑')).toHaveLength(1) expect(
expect(filterNonIntegratedBomBoardMaterialOptions(options, 'BC-0019')).toHaveLength(1) filterNonIntegratedBomBoardMaterialOptions(options, "smoke"),
}) ).toHaveLength(1);
expect(
filterNonIntegratedBomBoardMaterialOptions(options, "BC-0019"),
).toHaveLength(1);
});
it('支持用旧的部分名称匹配到完整板材并回填完整颜色贴面信息', () => { it("maps a legacy partial board material label to the full option", () => {
const options = buildNonIntegratedBomBoardMaterialOptions([ const options = buildNonIntegratedBomBoardMaterialOptions([
createSubstrate({ createSubstrate({
metadata: { systemName: '12E1级创花板' }, metadata: { systemName: "12 e1 particle board" },
categoryName: '刨花板', categoryName: "particle board",
surfaceOptionName: '双贴', surfaceOptionName: "double",
finishes: [ finishes: [
{ id: 'finish-1', name: '北美原木纹色', code: 'finish-1', thumbnailUrl: '' }, {
{ id: 'finish-2', name: '烟熏黑', code: 'finish-2', thumbnailUrl: '' }, id: "finish-1",
name: "north-american-oak",
code: "finish-1",
thumbnailUrl: "",
},
{
id: "finish-2",
name: "smoke",
code: "finish-2",
thumbnailUrl: "",
},
], ],
}), }),
]) ]);
const matched = findNonIntegratedBomBoardMaterialOptionLoosely( const matched = findNonIntegratedBomBoardMaterialOptionLoosely(
options, options,
'12E1级创花板 | 北美原木纹色 | 双贴', "12 e1 particle board | north-american-oak | double",
) );
const nextRow = applyNonIntegratedBomBoardMaterialMetrics( const nextRow = applyNonIntegratedBomBoardMaterialMetrics(
{ {
material: '12E1级创花板 | 北美原木纹色 | 双贴', material: "12 e1 particle board | north-american-oak | double",
length: '1400', length: "1400",
width: '600', width: "600",
quantity: '1', quantity: "1",
unitPrice: '', unitPrice: "",
unitWeight: '', unitWeight: "",
}, },
matched, matched,
) );
expect(matched?.selectionLabel).toBe('12E1级创花板 | 刨花板 | 北美原木纹色 | 烟熏黑 | 双贴') expect(matched?.selectionLabel).toBe(
expect(nextRow.material).toBe('12E1级创花板 | 刨花板 | 北美原木纹色 | 烟熏黑 | 双贴') "12 e1 particle board | north-american-oak | smoke | double",
expect(nextRow.unitPrice).toBe('16.1247') );
expect(nextRow.unitWeight).toBe('6.63') expect(nextRow.material).toBe(
}) "12 e1 particle board | north-american-oak | smoke | double",
);
expect(nextRow.unitPrice).toBe("16.1247");
expect(nextRow.unitWeight).toBe("6.63");
});
it('选择板材后会根据当前尺寸和数量自动带出单价与重量', () => { it("prefers an exact board material over a loose partial match", () => {
const options = buildNonIntegratedBomBoardMaterialOptions([createSubstrate()]) const options = buildNonIntegratedBomBoardMaterialOptions([
const matched = findNonIntegratedBomBoardMaterialOption(options, 'BC-0003') createSubstrate({
id: "substrate-1",
code: "BC-0001",
name: "12 density board",
categoryName: "density board",
surfaceOptionName: "single",
metadata: { systemName: "12 density board" },
finishes: [
{
id: "finish-1",
name: "walnut",
code: "finish-1",
thumbnailUrl: "",
},
],
}),
createSubstrate({
id: "substrate-2",
code: "BC-0100",
name: "20 enf rubberwood",
categoryName: "rubberwood",
environmentGradeName: "ENF",
thicknessMm: 20,
surfaceOptionName: "double",
metadata: { systemName: "20 enf rubberwood" },
finishes: [
{
id: "finish-2",
name: "walnut",
code: "finish-2",
thumbnailUrl: "",
},
],
}),
]);
const matched = findNonIntegratedBomBoardMaterialOptionLoosely(
options,
"20 enf rubberwood | walnut | double",
);
expect(matched?.code).toBe("BC-0100");
expect(matched?.selectionLabel).toBe(
"20 enf rubberwood | walnut | double",
);
});
it("fills price and weight after selecting a board material", () => {
const options = buildNonIntegratedBomBoardMaterialOptions([
createSubstrate(),
]);
const matched = findNonIntegratedBomBoardMaterialOption(options, "BC-0003");
const nextRow = applyNonIntegratedBomBoardMaterialMetrics( const nextRow = applyNonIntegratedBomBoardMaterialMetrics(
{ {
material: 'BC-0003', material: "BC-0003",
length: '1400', length: "1400",
width: '600', width: "600",
quantity: '1', quantity: "1",
unitPrice: '', unitPrice: "",
unitWeight: '', unitWeight: "",
}, },
matched, matched,
) );
expect(nextRow.material).toBe('12 E1级 刨花板 北美原木纹色 烟熏黑 单贴') expect(nextRow.material).toBe(
expect(nextRow.unitPrice).toBe('16.1247') "12 e1 particle board north-american-oak smoke single",
expect(nextRow.unitWeight).toBe('6.63') );
}) expect(nextRow.unitPrice).toBe("16.1247");
}) expect(nextRow.unitWeight).toBe("6.63");
});
});

View File

@ -204,6 +204,11 @@ export function findNonIntegratedBomBoardMaterialOptionLoosely(
options: NonIntegratedBomBoardMaterialOption[], options: NonIntegratedBomBoardMaterialOption[],
value: string, value: string,
) { ) {
const exactMatch = findNonIntegratedBomBoardMaterialOption(options, value)
if (exactMatch) {
return exactMatch
}
const normalizedValue = normalizeSearchText(value) const normalizedValue = normalizeSearchText(value)
if (!normalizedValue || normalizedValue.length < 3) return null if (!normalizedValue || normalizedValue.length < 3) return null

View File

@ -37,10 +37,10 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
}) })
} }
async findBySpuBase(spuBase: string, excludeId?: string) { async findBySpu(spu: string, excludeId?: string) {
return this.prisma.intakeNonIntegratedProduct.findFirst({ return this.prisma.intakeNonIntegratedProduct.findFirst({
where: { where: {
spuBase, spu,
deletedAt: null, deletedAt: null,
...(excludeId ...(excludeId
? { ? {
@ -53,10 +53,10 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository<
}) })
} }
async findDeletedBySpuBase(spuBase: string, excludeId?: string) { async findDeletedBySpu(spu: string, excludeId?: string) {
return this.prisma.intakeNonIntegratedProduct.findFirst({ return this.prisma.intakeNonIntegratedProduct.findFirst({
where: { where: {
spuBase, spu,
deletedAt: { deletedAt: {
not: null, not: null,
}, },

View File

@ -1,4 +1,3 @@
import { ErrorCode } from '@/server/core/errors'
import { createDefaultDocumentWorkspaceConfirmations } from '@/app/intake/non-integrated/nonIntegratedStorage' import { createDefaultDocumentWorkspaceConfirmations } from '@/app/intake/non-integrated/nonIntegratedStorage'
jest.mock('../../repositories/intake-non-integrated-product.repository', () => ({ jest.mock('../../repositories/intake-non-integrated-product.repository', () => ({
@ -6,8 +5,8 @@ jest.mock('../../repositories/intake-non-integrated-product.repository', () => (
listActive: jest.fn(), listActive: jest.fn(),
findActiveById: jest.fn(), findActiveById: jest.fn(),
findById: jest.fn(), findById: jest.fn(),
findBySpuBase: jest.fn(), findBySpu: jest.fn(),
findDeletedBySpuBase: jest.fn(), findDeletedBySpu: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
updatePayloadByUser: jest.fn(), updatePayloadByUser: jest.fn(),
@ -23,7 +22,7 @@ const mockedRepository = jest.mocked(intakeNonIntegratedProductRepository)
const baseUser = { const baseUser = {
id: 'creator-1', id: 'creator-1',
email: 'creator@example.com', email: 'creator@example.com',
name: '创建者', name: 'Creator',
role: 'DESIGNER' as const, role: 'DESIGNER' as const,
status: 'ACTIVE' as const, status: 'ACTIVE' as const,
departmentId: 'dept-1', departmentId: 'dept-1',
@ -34,11 +33,11 @@ function createPayload(overrides: Record<string, unknown> = {}) {
return { return {
id: 'non-integrated-1', id: 'non-integrated-1',
spu: 'DNZ0001', spu: 'DNZ0001',
brand: '基本盘', brand: 'Base',
adaptableBrands: ['基本盘'], adaptableBrands: ['Base'],
designer: '测试设计师', designer: 'Designer',
operator: '测试运营', operator: 'Operator',
purchaser: '测试采购', purchaser: 'Purchaser',
previewImage: 'data:image/png;base64,test-image', previewImage: 'data:image/png;base64,test-image',
previewImageName: 'preview.png', previewImageName: 'preview.png',
productCode: '', productCode: '',
@ -65,10 +64,10 @@ function createRecord(overrides: Record<string, unknown> = {}) {
id: 'non-integrated-1', id: 'non-integrated-1',
spu: 'DNZ0001', spu: 'DNZ0001',
spuBase: 'DNZ0001', spuBase: 'DNZ0001',
brand: '基本盘', brand: 'Base',
designer: '测试设计师', designer: 'Designer',
operator: '测试运营', operator: 'Operator',
purchaser: '测试采购', purchaser: 'Purchaser',
payload: createPayload(overrides.payload as Record<string, unknown> | undefined), payload: createPayload(overrides.payload as Record<string, unknown> | undefined),
visibility: 'DEPARTMENT', visibility: 'DEPARTMENT',
departmentId: 'dept-1', departmentId: 'dept-1',
@ -87,7 +86,7 @@ describe('intakeNonIntegratedProductService', () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
it('权限停用时列表查询应返回全部共享记录', async () => { it('lists all shared records when permissions are relaxed', async () => {
mockedRepository.listActive.mockResolvedValue([createRecord()] as never) mockedRepository.listActive.mockResolvedValue([createRecord()] as never)
const products = await intakeNonIntegratedProductService.list(baseUser) const products = await intakeNonIntegratedProductService.list(baseUser)
@ -96,7 +95,7 @@ describe('intakeNonIntegratedProductService', () => {
expect(mockedRepository.listActive).toHaveBeenCalledWith(undefined) expect(mockedRepository.listActive).toHaveBeenCalledWith(undefined)
}) })
it('权限停用时不同部门用户也能读取共享记录', async () => { it('allows reading a shared record from another department when permissions are relaxed', async () => {
mockedRepository.findActiveById.mockResolvedValue( mockedRepository.findActiveById.mockResolvedValue(
createRecord({ createRecord({
createdBy: 'creator-2', createdBy: 'creator-2',
@ -109,7 +108,7 @@ describe('intakeNonIntegratedProductService', () => {
...baseUser, ...baseUser,
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@example.com', email: 'viewer@example.com',
name: '查看者', name: 'Viewer',
teamId: 'team-9', teamId: 'team-9',
}) })
@ -117,10 +116,10 @@ describe('intakeNonIntegratedProductService', () => {
expect(product?.spu).toBe('DNZ0001') expect(product?.spu).toBe('DNZ0001')
}) })
it('新建记录时会写入部门与团队作用域字段', async () => { it('writes scoped department and team fields when creating a record', async () => {
const payload = createPayload({ id: 'non-integrated-create' }) const payload = createPayload({ id: 'non-integrated-create' })
mockedRepository.findBySpuBase.mockResolvedValue(null as never) mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findDeletedBySpuBase.mockResolvedValue(null as never) mockedRepository.findDeletedBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(null as never) mockedRepository.findById.mockResolvedValue(null as never)
mockedRepository.create.mockResolvedValue(createRecord({ id: 'non-integrated-create' }) as never) mockedRepository.create.mockResolvedValue(createRecord({ id: 'non-integrated-create' }) as never)
mockedRepository.findActiveById.mockResolvedValue( mockedRepository.findActiveById.mockResolvedValue(
@ -144,8 +143,60 @@ describe('intakeNonIntegratedProductService', () => {
) )
}) })
it('权限停用时普通用户也可以更新共享记录', async () => { it('allows creating a copied product when the target brand prefix changes the full SPU', async () => {
mockedRepository.findBySpuBase.mockResolvedValue(null as never) const payload = createPayload({
id: 'non-integrated-prefixed-create',
spu: 'GBGJ-SZ0230',
brand: '顾家',
adaptableBrands: ['顾家'],
})
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findDeletedBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue(null as never)
mockedRepository.create.mockResolvedValue(
createRecord({
id: 'non-integrated-prefixed-create',
spu: 'GBGJ-SZ0230',
spuBase: 'SZ0230',
brand: '顾家',
payload: createPayload({
id: 'non-integrated-prefixed-create',
spu: 'GBGJ-SZ0230',
brand: '顾家',
adaptableBrands: ['顾家'],
}),
}) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'non-integrated-prefixed-create',
spu: 'GBGJ-SZ0230',
spuBase: 'SZ0230',
brand: '顾家',
payload: createPayload({
id: 'non-integrated-prefixed-create',
spu: 'GBGJ-SZ0230',
brand: '顾家',
adaptableBrands: ['顾家'],
}),
}) as never,
)
await intakeNonIntegratedProductService.save(payload, baseUser)
expect(mockedRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'non-integrated-prefixed-create',
spu: 'GBGJ-SZ0230',
spuBase: 'SZ0230',
brand: '顾家',
}),
)
})
it('allows updating a shared record when permissions are relaxed', async () => {
mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findById.mockResolvedValue( mockedRepository.findById.mockResolvedValue(
createRecord({ createRecord({
createdBy: 'creator-2', createdBy: 'creator-2',
@ -155,7 +206,7 @@ describe('intakeNonIntegratedProductService', () => {
mockedRepository.findActiveById.mockResolvedValue( mockedRepository.findActiveById.mockResolvedValue(
createRecord({ createRecord({
payload: createPayload({ payload: createPayload({
designer: '部门同事', designer: 'Teammate',
}), }),
}) as never, }) as never,
) )
@ -165,16 +216,16 @@ describe('intakeNonIntegratedProductService', () => {
...baseUser, ...baseUser,
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@example.com', email: 'viewer@example.com',
name: '部门同事', name: 'Teammate',
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
designer: '部门同事', designer: 'Teammate',
}) })
expect(mockedRepository.update).toHaveBeenCalled() expect(mockedRepository.update).toHaveBeenCalled()
}) })
it('权限停用时普通用户也可以删除共享记录', async () => { it('allows deleting a shared record when permissions are relaxed', async () => {
mockedRepository.findActiveById.mockResolvedValue(createRecord() as never) mockedRepository.findActiveById.mockResolvedValue(createRecord() as never)
mockedRepository.softDeleteByUser.mockResolvedValue({} as never) mockedRepository.softDeleteByUser.mockResolvedValue({} as never)
@ -183,7 +234,7 @@ describe('intakeNonIntegratedProductService', () => {
...baseUser, ...baseUser,
id: 'viewer-1', id: 'viewer-1',
email: 'viewer@example.com', email: 'viewer@example.com',
name: '普通同事', name: 'Viewer',
}), }),
).resolves.toBeUndefined() ).resolves.toBeUndefined()
@ -193,7 +244,7 @@ describe('intakeNonIntegratedProductService', () => {
) )
}) })
it('上传文件会把文件元数据写回共享产品 payload', async () => { it('writes uploaded file metadata back into the shared payload', async () => {
const uploadedPayload = createPayload({ const uploadedPayload = createPayload({
uploadFiles: [ uploadFiles: [
{ {
@ -272,7 +323,7 @@ describe('intakeNonIntegratedProductService', () => {
}) })
}) })
it('PPT 预览结果会回写到共享产品 payload,而不是依赖静态目录真相', async () => { it('writes presentation preview metadata into the shared payload', async () => {
const previewSourceFile = { const previewSourceFile = {
id: 'file-1', id: 'file-1',
name: 'slides.pptx', name: 'slides.pptx',
@ -354,7 +405,7 @@ describe('intakeNonIntegratedProductService', () => {
}) })
}) })
it('reuses a soft-deleted record when recreating the same SPU', async () => { it('reuses a soft-deleted record when recreating the same full SPU', async () => {
const payload = createPayload({ const payload = createPayload({
id: 'non-integrated-recreate', id: 'non-integrated-recreate',
spu: 'DNZ0001', spu: 'DNZ0001',
@ -362,8 +413,8 @@ describe('intakeNonIntegratedProductService', () => {
updatedAt: '2026-04-22T10:00:00.000Z', updatedAt: '2026-04-22T10:00:00.000Z',
}) })
mockedRepository.findBySpuBase.mockResolvedValue(null as never) mockedRepository.findBySpu.mockResolvedValue(null as never)
mockedRepository.findDeletedBySpuBase.mockResolvedValue( mockedRepository.findDeletedBySpu.mockResolvedValue(
createRecord({ createRecord({
id: 'non-integrated-deleted', id: 'non-integrated-deleted',
deletedAt: new Date('2026-04-21T10:00:00.000Z'), deletedAt: new Date('2026-04-21T10:00:00.000Z'),

View File

@ -412,7 +412,7 @@ function pickUploadFileMetadata(input: AttachUploadFileInput): AttachUploadFileM
} }
} }
function isSpuBaseUniqueConstraintError(error: unknown) { function isSpuUniqueConstraintError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
} }
@ -462,15 +462,13 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存') throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
} }
const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedProduct.spu) const normalizedSpu = normalizedProduct.spu.trim().toUpperCase()
if (!normalizedSpuBase) { if (!normalizedSpu) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'SPU 不能为空') throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'SPU 不能为空')
} }
const duplicateRecord = await this.repository.findBySpuBase( const normalizedSpuBase = stripNonIntegratedBrandPrefix(normalizedSpu)
normalizedSpuBase, const duplicateRecord = await this.repository.findBySpu(normalizedSpu, normalizedProduct.id)
normalizedProduct.id,
)
if (duplicateRecord) { if (duplicateRecord) {
throw new AppError( throw new AppError(
ErrorCode.VALIDATION_FAILED, ErrorCode.VALIDATION_FAILED,
@ -480,7 +478,7 @@ class IntakeNonIntegratedProductService {
const deletedRecordToRestore = existingRecordCandidate const deletedRecordToRestore = existingRecordCandidate
? null ? null
: await this.repository.findDeletedBySpuBase(normalizedSpuBase, normalizedProduct.id) : await this.repository.findDeletedBySpu(normalizedSpu, normalizedProduct.id)
const existingRecord = existingRecordCandidate const existingRecord = existingRecordCandidate
const scopedDepartmentId = intakeNonIntegratedScopeService.getScopedDepartmentId(user) const scopedDepartmentId = intakeNonIntegratedScopeService.getScopedDepartmentId(user)
@ -489,15 +487,19 @@ class IntakeNonIntegratedProductService {
? { ? {
...normalizedProduct, ...normalizedProduct,
id: deletedRecordToRestore.id, id: deletedRecordToRestore.id,
spu: normalizedSpu,
}
: {
...normalizedProduct,
spu: normalizedSpu,
} }
: normalizedProduct
try { try {
if (existingRecord) { if (existingRecord) {
intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user) intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user)
await this.repository.update(existingRecord.id, { await this.repository.update(existingRecord.id, {
spu: persistedProduct.spu, spu: normalizedSpu,
spuBase: normalizedSpuBase, spuBase: normalizedSpuBase,
brand: persistedProduct.brand ?? '', brand: persistedProduct.brand ?? '',
designer: persistedProduct.designer, designer: persistedProduct.designer,
@ -517,7 +519,7 @@ class IntakeNonIntegratedProductService {
}) })
} else if (deletedRecordToRestore) { } else if (deletedRecordToRestore) {
await this.repository.update(deletedRecordToRestore.id, { await this.repository.update(deletedRecordToRestore.id, {
spu: persistedProduct.spu, spu: normalizedSpu,
spuBase: normalizedSpuBase, spuBase: normalizedSpuBase,
brand: persistedProduct.brand ?? '', brand: persistedProduct.brand ?? '',
designer: persistedProduct.designer, designer: persistedProduct.designer,
@ -535,7 +537,7 @@ class IntakeNonIntegratedProductService {
} else { } else {
await this.repository.create({ await this.repository.create({
id: persistedProduct.id, id: persistedProduct.id,
spu: persistedProduct.spu, spu: normalizedSpu,
spuBase: normalizedSpuBase, spuBase: normalizedSpuBase,
brand: persistedProduct.brand ?? '', brand: persistedProduct.brand ?? '',
designer: persistedProduct.designer, designer: persistedProduct.designer,
@ -551,7 +553,7 @@ class IntakeNonIntegratedProductService {
}) })
} }
} catch (error) { } catch (error) {
if (isSpuBaseUniqueConstraintError(error)) { if (isSpuUniqueConstraintError(error)) {
throw new AppError( throw new AppError(
ErrorCode.VALIDATION_FAILED, ErrorCode.VALIDATION_FAILED,
NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, NON_INTEGRATED_DUPLICATE_SPU_MESSAGE,