feat(platform): sync confirmed intake material workflows
This commit is contained in:
parent
5a22d2a2ae
commit
5ba2ea231a
@ -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");
|
||||||
@ -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])
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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: [] })
|
||||||
|
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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("");
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 新增/编辑库位弹窗 */}
|
{/* 新增/编辑库位弹窗 */}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user