diff --git a/furniture-3d-system/prisma/migrations/20260424191500_allow_brand_prefixed_non_integrated_spu_duplicates/migration.sql b/furniture-3d-system/prisma/migrations/20260424191500_allow_brand_prefixed_non_integrated_spu_duplicates/migration.sql new file mode 100644 index 00000000..6da12c63 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260424191500_allow_brand_prefixed_non_integrated_spu_duplicates/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 8049df33..05563096 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -183,8 +183,8 @@ model ProductCollaborator { model IntakeNonIntegratedProduct { id String @id - spu String - spuBase String @unique + spu String @unique + spuBase String brand String designer String operator String @@ -199,7 +199,7 @@ model IntakeNonIntegratedProduct { updatedAt DateTime @updatedAt deletedAt DateTime? - @@index([spu]) + @@index([spuBase]) @@index([createdBy]) @@index([departmentId]) @@index([teamId]) diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx index e74d4027..10a33bea 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.test.tsx @@ -1,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 type { ReactNode } from 'react' 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' jest.mock('next/link', () => ({ @@ -66,7 +72,10 @@ jest.mock('@/lib/open-product/platform-api', () => ({ })) const mockedCreateMaterial = jest.mocked(createMaterial) +const mockedCreateMaterialCategory = jest.mocked(createMaterialCategory) const mockedCreateSubstrate = jest.mocked(createSubstrate) +const mockedDeleteMaterialCategory = jest.mocked(deleteMaterialCategory) +const mockedUpdateMaterialCategory = jest.mocked(updateMaterialCategory) const materialCategories: MaterialCategoryDTO[] = [ { @@ -145,6 +154,15 @@ function renderClient() { ) } +async function openFinishCategoryModal(actor: ReturnType) { + 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', () => { beforeEach(() => { 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() + }) }) diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index 194774f3..494d45c5 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -78,6 +78,14 @@ function sortByName(items: T[]): T[] { 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( bundle: SubstrateDictionaryBundleDTO, type: SubstrateDictionaryType, @@ -313,7 +321,7 @@ export default function MaterialLibraryClient({ }: MaterialLibraryClientProps) { const toast = useToast() const [activeTab, setActiveTab] = useState<'finish' | 'substrate'>('finish') - const [categories, setCategories] = useState(initialCategories) + const [categories, setCategories] = useState(() => sortMaterialCategories(initialCategories)) const [finishes, setFinishes] = useState(initialFinishes) const [substrates, setSubstrates] = useState(initialSubstrates) const [dictionaries, setDictionaries] = useState(initialDictionaries) @@ -465,10 +473,20 @@ export default function MaterialLibraryClient({ setPanel({ mode: 'edit-substrate', id: substrate.id }) }, []) - const openCategoryModal = useCallback(() => { - setCategoryModalMode('create') - setEditingCategoryId(null) - setCategoryForm({ name: '', icon: '', sortOrder: '' }) + const openCategoryModal = useCallback((category?: MaterialCategoryDTO) => { + if (category) { + setCategoryModalMode('edit') + 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) }, []) @@ -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) { if (!window.confirm(`确认删除「${item.name}」吗?`)) return setSubmitting(true) @@ -1307,13 +1404,37 @@ export default function MaterialLibraryClient({ +
+ + +
))} @@ -1512,6 +1633,138 @@ export default function MaterialLibraryClient({ } rightContent={renderPanelContent()} /> + {categoryModalOpen ? ( +
+
+
+
+

+ Finish Category +

+

+ {categoryModalMode === 'edit' ? '编辑花色分类' : '新增花色分类'} +

+
+ +
+
+
+
+ + 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="输入分类名称" + /> +
+
+ + 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 或简短标记" + /> +
+
+ + 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="数字越小越靠前" + /> +
+
+ {categoryModalMode === 'edit' ? ( + + ) : null} + +
+
+
+
+ {categories.map((category) => ( +
+
+

+ {category.icon ? `${category.icon} ` : ''}{category.name} +

+

+ 排序 {category.sortOrder ?? 0} + {category.materialCount != null ? ` · 花色 ${category.materialCount}` : ''} +

+
+
+ + +
+
+ ))} + {categories.length === 0 ? ( +

当前还没有花色分类。

+ ) : null} +
+
+
+
+
+ ) : null} {dictionaryModalOpen ? (
diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index f9343661..72378e19 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -85,6 +85,7 @@ jest.mock('@/components/platform/ModelPreviewPanel', () => ({ function renderSelector(props?: { eligibleSkuList?: Array> eligibleDraftGroups?: Array> + initialNonIntegratedProducts?: Array> }) { return render( , ) } @@ -181,6 +185,7 @@ function renderEditor() { surfaceOptions: [], } as never } + initialNonIntegratedProducts={[] as never} />, ) } @@ -245,6 +250,7 @@ describe('IntakeClient', () => { const rightPanel = screen.getByTestId('right-panel') expect(within(rightPanel).getByRole('button', { name: '资料入库' })).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() @@ -271,6 +277,22 @@ describe('IntakeClient', () => { 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', () => { renderSelector({ eligibleSkuList: [] }) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index ce44623a..c574b2d9 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -17,6 +17,7 @@ import { import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient"; import AccessoryLibraryPanel from "@/app/intake/AccessoryLibraryPanel"; import PackagingMaterialLibraryPanel from "@/app/intake/PackagingMaterialLibraryPanel"; +import RawMaterialLibraryPanel from "@/app/intake/RawMaterialLibraryPanel"; import SteelFrameLibraryPanel from "@/app/intake/SteelFrameLibraryPanel"; import { Badge } from "@/components/ui/Badge"; import Button from "@/components/ui/Button"; @@ -100,7 +101,7 @@ interface IntakeClientEditorProps { } type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps; -type IntakeSection = "pending" | "bom" | null; +type IntakeSection = "pending" | "bom" | "rawMaterialLibrary" | null; type IntakeEntryTab = "integrated" | "nonIntegrated"; type BomLibraryKey = | "materialLibrary" @@ -1447,6 +1448,16 @@ function BomDetail({ ); } +function RawMaterialLibraryDetail() { + return ( +
+
+ +
+
+ ) +} + function FormalIntakeWorkbench({ activeSection, onToggle, @@ -1518,6 +1529,25 @@ function FormalIntakeWorkbench({ ))}
+ +
); } @@ -1555,6 +1585,8 @@ export default function IntakeClient(props: IntakeClientProps) { materialLibraryDictionaries={props.materialLibraryDictionaries} /> ); + } else if (activeSection === "rawMaterialLibrary") { + leftContent = ; } return ( @@ -1604,6 +1636,8 @@ export default function IntakeClient(props: IntakeClientProps) { materialLibraryDictionaries={props.materialLibraryDictionaries} /> ); + } else if (activeSection === "rawMaterialLibrary") { + leftContent = ; } return ( diff --git a/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx b/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx new file mode 100644 index 00000000..5a85dd57 --- /dev/null +++ b/furniture-3d-system/src/app/intake/RawMaterialLibraryPanel.tsx @@ -0,0 +1,66 @@ +'use client' + +const RAW_MATERIAL_COLUMNS = [ + '类目', + '基材', + '产地', + '排名', + '主流木材等级', + '检验报告', + '进关证书', + '图片', + '优势总结', + '基材原料', + '生长树龄', + '生长气候', + '板材密度', +] + +export default function RawMaterialLibraryPanel() { + return ( +
+
+

+ 原材料资料库 +

+

+ 当前先按原材料资料表结构预留位置,后续再接正式填写、保存和资料上传逻辑。 +

+
+ +
+
+
+ {RAW_MATERIAL_COLUMNS.map((column) => ( +
+ {column} +
+ ))} +
+ +
+ {RAW_MATERIAL_COLUMNS.map((column) => ( +
+ {column === '图片' ? '图片占位' : '待配置'} +
+ ))} +
+ +
+
+ 原材料资料库占位已预留。 +
+ 后续会按你提供的配置,把字段、上传入口和录入规则逐步接进来。 +
+
+
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index d6052c27..72bafda3 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -19,6 +19,7 @@ import { } from "./nonIntegratedStorage"; import { parseZipFile } from "@/lib/zip-parser"; import { extractPreviewModelFromZip } from "../../../lib/model-preview-zip"; +import { fetchSubstrates } from "@/lib/open-product/platform-api"; const push = jest.fn(); const modelPreviewPanelMock = jest.fn(); @@ -64,6 +65,10 @@ jest.mock("./nonIntegratedApi", () => ({ uploadSharedNonIntegratedFile: jest.fn(), })); +jest.mock("@/lib/open-product/platform-api", () => ({ + fetchSubstrates: jest.fn(), +})); + const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked( previewSharedNonIntegratedDocumentImages, ); @@ -73,6 +78,50 @@ const mockedSaveSharedNonIntegratedProduct = jest.mocked( const mockedUploadSharedNonIntegratedFile = jest.mocked( 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( overrides: Partial = {}, @@ -80,6 +129,39 @@ function createProductRecord( return { id: "test-product", 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: "基本盘", adaptableBrands: ["基本盘"], designer: "测试设计师", @@ -202,6 +284,30 @@ describe("NonIntegratedDetailClient", () => { mockedSaveSharedNonIntegratedProduct.mockReset(); mockedSaveSharedNonIntegratedProduct.mockImplementation(async (product) => product); 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(() => { @@ -212,9 +318,8 @@ describe("NonIntegratedDetailClient", () => { const actor = userEvent.setup(); const { container } = render(); - const textInputs = screen.getAllByRole("textbox"); - await actor.type(textInputs[0]!, "DNZ0001"); - await actor.type(textInputs[1]!, "测试设计师"); + await actor.type(screen.getByLabelText("SPU"), "DNZ0001"); + await actor.type(screen.getByLabelText("设计师"), "测试设计师"); const imageFile = new File(["image"], "preview.png", { type: "image/png" }); const fileInputs = container.querySelectorAll('input[type="file"]'); @@ -324,6 +429,157 @@ describe("NonIntegratedDetailClient", () => { 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( + , + ); + + 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( + , + ); + + 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( + '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 () => { const actor = userEvent.setup(); @@ -410,7 +666,7 @@ describe("NonIntegratedDetailClient", () => { await actor.click(screen.getByRole("button", { name: "返回详情" })); - expect(screen.getByText("基础必填信息")).toBeInTheDocument(); + expect(screen.getByText(/产品开发明细/)).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "返回详情" }), ).not.toBeInTheDocument(); @@ -631,13 +887,17 @@ describe("NonIntegratedDetailClient", () => { />, ); - expect(screen.getByLabelText("品牌")).toHaveValue("顾家"); - expect(screen.getByLabelText("品牌")).toBeDisabled(); - expect(screen.getByText("适配品牌")).toBeInTheDocument(); - expect(screen.getAllByText("顾家").length).toBeGreaterThan(0); - expect( - screen.queryByRole("button", { name: "适配品牌" }), - ).not.toBeInTheDocument(); + expect(screen.getByText(/产品开发明细/)).toBeInTheDocument(); + + const brandField = screen.getByText("品牌").parentElement; + const adaptableBrandField = screen.getByText("适配品牌").parentElement; + + expect(brandField).not.toBeNull(); + 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", () => { @@ -659,11 +919,17 @@ describe("NonIntegratedDetailClient", () => { , ); - expect(screen.getByLabelText("品牌")).toHaveValue("基本盘"); - expect(screen.getByLabelText("品牌")).toBeDisabled(); + const brandField = screen.getByText("品牌").parentElement; + const adaptableBrandField = screen.getByText("适配品牌").parentElement; + + expect(brandField).not.toBeNull(); + expect(adaptableBrandField).not.toBeNull(); + expect(within(brandField!).getByText("基本盘")).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "适配品牌" }), + within(adaptableBrandField!).getByText("基本盘、顾家、索菲亚"), ).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 () => { @@ -1044,10 +1310,10 @@ describe("NonIntegratedDetailClient", () => { const savedProduct = getNonIntegratedProductById( "DNZ0001-package-corner-save", ); - const sharedDetail = savedProduct?.bomDetails.find( - (detail) => detail.styleKey === "K01" && !detail.skuId, + const skuDetail = savedProduct?.bomDetails.find( + (detail) => detail.styleKey === "K01" && detail.skuId === "sku-test-1", ); - const cornerRow = sharedDetail?.rows.find( + const cornerRow = skuDetail?.rows.find( (row) => row.packageSubcategory === "泡沫护角", ); @@ -1086,7 +1352,7 @@ describe("NonIntegratedDetailClient", () => { expect(reloadedFields[5]).toHaveValue("4"); 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(); saveNonIntegratedProduct( @@ -1179,13 +1445,16 @@ describe("NonIntegratedDetailClient", () => { ); expect( - sharedDetail?.rows.some((row) => row.id === "override-steel-row-1"), + sharedDetail?.rows.some((row) => row.id === "override-steel-row-1") ?? + false, ).toBe(false); expect( overrideDetail?.rows.some((row) => row.id === "override-steel-row-1"), ).toBe(true); expect( - sharedDetail?.rows.some((row) => row.packageSubcategory === "泡沫护角"), + overrideDetail?.rows.some( + (row) => row.packageSubcategory === "泡沫护角", + ), ).toBe(true); }); }); @@ -1373,8 +1642,9 @@ describe("NonIntegratedDetailClient", () => { ); expect(sharedDetail?.rows).toHaveLength(2); expect(sharedDetail?.rows[0]?.partImageName).toBe("table-board.png"); - expect(sharedDetail?.rows[1]?.partName).toBe("\u53f0\u9762"); - expect(sharedDetail?.rows[1]?.structure).toBe("\u4e3b\u4f53"); + expect(sharedDetail?.rows[1]?.partName).toBe(""); + expect(sharedDetail?.rows[1]?.structure).toBe(""); + expect(sharedDetail?.rows[1]?.material).toBe(""); expect(sharedDetail?.rows[1]?.partImage).toBe(""); expect(sharedDetail?.rows[1]?.partImageName).toBe(""); }); diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index 319968c5..663806ab 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import type { ChangeEvent, RefObject } from "react"; +import type { ChangeEvent, ReactNode, RefObject } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel"; import Button from "@/components/ui/Button"; @@ -232,6 +232,39 @@ type WorkspaceImagePreviewState = type FormValues = { spu: 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; brand: string; adaptableBrands: string[]; designer: string; @@ -1183,20 +1216,273 @@ function getProductPhotoPanelCompactTitle(group: ProductPhotoGroup) { const BRAND_OPTIONS = ["基本盘", "顾家", "索菲亚", "朴美"]; -const REQUIRED_TOP_FIELDS: FieldDefinition[] = [ - { key: "spu", label: "SPU", required: true, placeholder: "请输入 SPU" }, - { - key: "designer", - label: "设计师", - required: true, - placeholder: "请输入设计师", - }, - { key: "operator", label: "运营", placeholder: "请输入运营" }, - { key: "purchaser", label: "采购", placeholder: "请输入采购" }, +const CREATE_TOP_FIELD_COLUMNS: FieldDefinition[][] = [ + [ + { key: "spu", label: "SPU", required: true, placeholder: "请输入 SPU" }, + { key: "year", label: "年份", placeholder: "请输入年份" }, + { + key: "salesCategory", + label: "销售类目", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "demandDepartment", + label: "需求部门", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "patentOwner", + label: "专利所有方", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "needsClearanceHandling", + label: "是否处理清货", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "profitCenter", + label: "利润中心", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "salesPlatform", + label: "销售平台", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "goodsType", + label: "货品类型", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "productionType", + label: "产品生产类型", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "shippingMode", + label: "发货模式", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "designer", + label: "设计师", + placeholder: "请输入设计师", + }, + { key: "operator", label: "运营", placeholder: "请输入运营" }, + { key: "purchaser", label: "采购", placeholder: "请输入采购" }, + { + key: "priceBand", + label: "价格段", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "photographer", + label: "摄影师", + placeholder: "请输入摄影师", + }, + { + key: "flatOutputPath", + label: "平面路径", + placeholder: "请输入平面路径", + }, + ], + [ + { + key: "demandFlowNo", + label: "需求流程号", + placeholder: "可后续回填需求流程号", + }, + { + key: "processTemplate", + label: "流程模板", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "productCategory", + label: "商品类目", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "productPlanning", + label: "商品策划", + placeholder: "请输入商品策划", + }, + { + key: "isSelfDevelopedPatentProduct", + label: "是否自研专利产品", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "spuOwner", + label: "SPU负责人", + placeholder: "请输入SPU负责人", + }, + { + key: "operationsOwner", + label: "运营负责人", + placeholder: "请输入运营负责人", + }, + { + key: "salesChannel", + label: "销售渠道", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "purchaseOwner", + label: "采购负责人", + placeholder: "请输入采购负责人", + }, + { + key: "materialType", + label: "材质类型", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "manualProducer", + label: "说明书制作方", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "visualDirector", + label: "视觉主管", + placeholder: "请输入视觉主管", + }, + { + key: "itemId", + label: "商品ID", + placeholder: "请输入商品ID", + }, + { + key: "outputRequirementType", + label: "出图需求类型", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "visualPlanning", + label: "视觉策划", + placeholder: "请输入视觉策划", + }, + { + key: "visualPlanningType", + label: "视觉策划类型", + placeholder: "请输入视觉策划类型", + }, + { + key: "graphicDesigner", + label: "平面设计", + placeholder: "请输入平面设计", + }, + { key: "designer3d", label: "3D设计", placeholder: "请输入3D设计" }, + { + key: "sampleRequired", + label: "是否需要寄样", + placeholder: "先手动填写,后续再配置选项", + }, + { + key: "output3dPath", + label: "3D出图路径", + placeholder: "请输入3D出图路径", + }, + ], +]; + +const CREATE_TOP_FIELDS: FieldDefinition[] = (() => { + const rowCount = Math.max( + ...CREATE_TOP_FIELD_COLUMNS.map((column) => column.length), + ); + const fields: FieldDefinition[] = []; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + CREATE_TOP_FIELD_COLUMNS.forEach((column) => { + const field = column[rowIndex]; + if (field) { + fields.push(field); + } + }); + } + + return fields; +})(); + +const DETAIL_SUMMARY_FIELDS: { key: keyof FormValues; label: string }[] = [ + { key: "spu", label: "SPU" }, + { key: "demandFlowNo", label: "需求流程号" }, + { key: "processTemplate", label: "流程模板" }, + { key: "year", label: "年份" }, + { key: "brand", label: "品牌" }, + { key: "adaptableBrands", label: "适配品牌" }, + { key: "demandDepartment", label: "需求部门" }, + { key: "productCategory", label: "商品类目" }, + { key: "salesCategory", label: "销售类目" }, + { key: "visualDirector", label: "视觉主管" }, + { key: "productPlanning", label: "商品策划" }, + { key: "isSelfDevelopedPatentProduct", label: "是否自研专利产品" }, + { key: "patentOwner", label: "专利所有方" }, + { key: "purchaseOwner", label: "采购负责人" }, + { key: "spuOwner", label: "SPU负责人" }, + { key: "operationsOwner", label: "运营负责人" }, + { key: "itemId", label: "商品ID" }, + { key: "materialType", label: "材质类型" }, + { key: "designer", label: "设计师" }, + { key: "operator", label: "运营" }, + { key: "purchaser", label: "采购" }, + { key: "needsClearanceHandling", label: "是否处理清货" }, + { key: "profitCenter", label: "利润中心" }, + { key: "salesChannel", label: "销售渠道" }, + { key: "salesPlatform", label: "销售平台" }, + { key: "goodsType", label: "货品类型" }, + { key: "productionType", label: "产品生产类型" }, + { key: "shippingMode", label: "发货模式" }, + { key: "manualProducer", label: "说明书制作方" }, + { key: "priceBand", label: "价格段" }, + { key: "outputRequirementType", label: "出图需求类型" }, + { key: "visualPlanning", label: "视觉策划" }, + { key: "visualPlanningType", label: "视觉策划类型" }, + { key: "photographer", label: "摄影师" }, + { key: "graphicDesigner", label: "平面设计" }, + { key: "designer3d", label: "3D设计" }, + { key: "sampleRequired", label: "是否需要寄样" }, + { key: "flatOutputPath", label: "平面路径" }, + { key: "output3dPath", label: "3D出图路径" }, ]; const EMPTY_FORM_VALUES: FormValues = { spu: "", + 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: "基本盘", adaptableBrands: ["基本盘"], designer: "", @@ -2325,6 +2611,29 @@ function mergeBomRows( return mergedRows; } +function materializeBomRowsForSkuOverride( + baseRows: NonIntegratedBomRow[], + overrideDetail: Pick, +) { + if ( + overrideDetail.rows.length === 0 && + overrideDetail.hiddenRowIds.length === 0 + ) { + return cloneBomRows(baseRows); + } + + if ( + overrideDetail.rows.length > 0 && + overrideDetail.hiddenRowIds.length === 0 + ) { + return cloneBomRows(overrideDetail.rows); + } + + return cloneBomRows( + mergeBomRows(baseRows, overrideDetail.rows, overrideDetail.hiddenRowIds), + ); +} + function areBomRowsEqual( left: NonIntegratedBomRow, right: NonIntegratedBomRow, @@ -2506,24 +2815,58 @@ function createFormValuesFromProduct( ); return { - spu: product.spu, + spu: product.spu ?? "", + year: product.year ?? "", + demandFlowNo: product.demandFlowNo ?? "", + processTemplate: product.processTemplate ?? "", + productCategory: product.productCategory ?? "", + salesCategory: product.salesCategory ?? "", + productPlanning: product.productPlanning ?? "", + demandDepartment: product.demandDepartment ?? "", + isSelfDevelopedPatentProduct: + product.isSelfDevelopedPatentProduct ?? "", + patentOwner: product.patentOwner ?? "", + spuOwner: product.spuOwner ?? "", + needsClearanceHandling: product.needsClearanceHandling ?? "", + operationsOwner: product.operationsOwner ?? "", + profitCenter: product.profitCenter ?? "", + salesChannel: product.salesChannel ?? "", + salesPlatform: product.salesPlatform ?? "", + purchaseOwner: product.purchaseOwner ?? "", + goodsType: product.goodsType ?? "", + materialType: product.materialType ?? "", + productionType: product.productionType ?? "", + manualProducer: product.manualProducer ?? "", + shippingMode: product.shippingMode ?? "", + visualDirector: product.visualDirector ?? "", + itemId: product.itemId ?? "", + priceBand: product.priceBand ?? "", + outputRequirementType: product.outputRequirementType ?? "", + visualPlanning: product.visualPlanning ?? "", + visualPlanningType: product.visualPlanningType ?? "", + photographer: product.photographer ?? "", + graphicDesigner: product.graphicDesigner ?? "", + designer3d: product.designer3d ?? "", + sampleRequired: product.sampleRequired ?? "", + flatOutputPath: product.flatOutputPath ?? "", + output3dPath: product.output3dPath ?? "", brand: normalizedBrand, adaptableBrands: product.copiedFromProductId ? [normalizedBrand] : normalizedAdaptableBrands, - designer: product.designer, - operator: product.operator, - purchaser: product.purchaser, - previewImage: product.previewImage, - previewImageName: product.previewImageName, - productCode: product.productCode, - size: product.size, - color: product.color, - installMethod: product.installMethod, - installSteps: product.installSteps, - tagMaterial: product.tagMaterial, - packageDetail: product.packageDetail, - craftDetail: product.craftDetail, + designer: product.designer ?? "", + operator: product.operator ?? "", + purchaser: product.purchaser ?? "", + previewImage: product.previewImage ?? "", + previewImageName: product.previewImageName ?? "", + productCode: product.productCode ?? "", + size: product.size ?? "", + color: product.color ?? "", + installMethod: product.installMethod ?? "", + installSteps: product.installSteps ?? "", + tagMaterial: product.tagMaterial ?? "", + packageDetail: product.packageDetail ?? "", + craftDetail: product.craftDetail ?? "", }; } @@ -2564,6 +2907,14 @@ function FieldLabel({ ); } +function CreateFieldShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + function BasicField({ label, required, @@ -3478,16 +3829,24 @@ function AdaptableBrandField({ value, onChange, readOnly = false, + compact = false, }: { value: string[]; onChange: (value: string[]) => void; readOnly?: boolean; + compact?: boolean; }) { const [isOpen, setIsOpen] = useState(false); const selectedLabel = value.length > 0 ? value.join("、") : "请选择适配品牌"; return ( -
+
+ ) : ( + + )} +
+
+ {DETAIL_SUMMARY_FIELDS.map(({ key, label }) => ( + + ))} +
+
+ + )} {mode !== "create" ? ( ) : null} {isReadOnly ? null : ( -
-
+
+
+ + ), })) +jest.mock('@/components/platform/ModelPreviewPanel', () => ({ + __esModule: true, + ModelPreviewPanel: ({ title }: { title?: ReactNode }) =>
{title ?? 'ModelPreviewPanel'}
, +})) + jest.mock('@/components/ui/Modal', () => ({ __esModule: true, default: ({ @@ -69,7 +76,7 @@ jest.mock('@/lib/open-product/platform-api', () => ({ })) describe('WarehouseClient', () => { - it('新增库位弹窗可打开并在保存成功后刷新列表', async () => { + it('新增库位弹窗可打开,并在保存成功后刷新列表', async () => { const actor = userEvent.setup() jest.mocked(createWarehouseLocation).mockResolvedValue({ id: 'location-1', @@ -85,11 +92,13 @@ describe('WarehouseClient', () => { render( { avatar: null, department: null, }} - metrics={[]} partsVariants={[]} draftGroups={[]} workbenchSkuList={[]} @@ -106,10 +114,12 @@ describe('WarehouseClient', () => { />, ) - // 先点击"库位管理"切换到库位详情面板 - await actor.click(screen.getByRole('button', { name: 'Toggle 库位管理' })) + expect(screen.getByTestId('left-content')).toHaveTextContent('库位列表') + expect(screen.getByTestId('right-content')).toHaveTextContent('库位管理') + expect(screen.queryByText('点击右侧模块查看详情')).not.toBeInTheDocument() await actor.click(screen.getByRole('button', { name: '新增库位' })) + expect(screen.getByRole('heading', { name: '新增库位' })).toBeInTheDocument() 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 waitFor(() => { - expect(createWarehouseLocation).toHaveBeenCalledWith(expect.objectContaining({ - code: 'A-01-02', - name: '成品区 A01', - })) + expect(createWarehouseLocation).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'A-01-02', + name: '成品区 A01', + }), + ) }) await waitFor(() => { diff --git a/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx b/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx index 29981078..72d1e0fe 100644 --- a/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx +++ b/furniture-3d-system/src/app/warehouse/WarehouseClient.tsx @@ -21,7 +21,6 @@ import Select from '@/components/ui/Select' import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' import { AccordionSection } from '@/components/platform/AccordionSection' import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' -import type { PlatformMetricItem } from '@/components/platform/types' 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 type { WarehouseOverviewDTO } from '@/server/services' @@ -90,7 +89,6 @@ interface WarehouseClientProps { avatar: string | null department: { id: string; name: string } | null } - metrics: PlatformMetricItem[] partsVariants: PartsVariantItem[] draftGroups: DraftGroupWithPreSkus[] workbenchSkuList: WorkbenchSku[] @@ -120,8 +118,8 @@ const DRAFT_STATUS_BADGE: Record = { ARCHIVED: 'info', } -function toggleSection(current: WarehouseSection, section: Exclude): WarehouseSection { - return current === section ? null : section +function toggleSection(_current: WarehouseSection, section: Exclude): WarehouseSection { + return section } 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 [partsVariants, setPartsVariants] = useState(initialPartsVariants) - const [activeSection, setActiveSection] = useState(null) + const [activeSection, setActiveSection] = useState('location') const [locationForm, setLocationForm] = useState({ code: '', name: '', @@ -657,8 +655,8 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV if (activeSection === 'location') { leftContent = ( -
-
+
+
{/* 紧凑统计条 */}
库位 {locationCount} @@ -714,6 +712,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
{/* 库位列表 */} +
{filteredLocations.length === 0 ? (
{overview.locations.length === 0 @@ -721,7 +720,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV : '没有匹配的库位,试试调整搜索条件。'}
) : ( - <> +
共 {totalFiltered} 个库位{totalFiltered > PAGE_SIZE ? `,第 ${safePage}/${totalPages} 页` : ''}
@@ -765,7 +764,7 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
))} {totalFiltered > PAGE_SIZE && ( -
+
@@ -775,8 +774,9 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV
)} - +
)} +
) @@ -1117,74 +1117,79 @@ export default function WarehouseClient({ initialOverview, user, metrics, partsV ) } + const sectionsPanel = ( +
+
+ } + expanded={activeSection === 'location'} + onToggle={() => setActiveSection((current) => toggleSection(current, 'location'))} + aside={{locationCount} 个库位} + compact + /> + + } + expanded={activeSection === 'parts-sku'} + onToggle={() => setActiveSection((current) => toggleSection(current, 'parts-sku'))} + aside={{partsVariants.length} 个配件} + compact + /> + + } + expanded={activeSection === 'product-sku'} + onToggle={() => setActiveSection((current) => toggleSection(current, 'product-sku'))} + aside={{draftGroups.reduce((s, dg) => s + dg.preSkus.length, 0)} 个 SKU} + compact + /> + + } + expanded={activeSection === 'brand'} + onToggle={() => setActiveSection((current) => toggleSection(current, 'brand'))} + compact + /> + + } + expanded={activeSection === 'preparation'} + onToggle={() => setActiveSection((current) => toggleSection(current, 'preparation'))} + aside={{workbenchSkuList.length} 个 SKU} + compact + /> + + } + expanded={activeSection === 'packaging'} + onToggle={() => setActiveSection((current) => toggleSection(current, 'packaging'))} + compact + /> +
+
+ ) + return ( <> - {/* 库位管理 */} - } - expanded={activeSection === 'location'} - onToggle={() => setActiveSection((current) => toggleSection(current, 'location'))} - aside={{locationCount} 个库位} - /> - - {/* 配件 SKU 库位 */} - } - expanded={activeSection === 'parts-sku'} - onToggle={() => setActiveSection((current) => toggleSection(current, 'parts-sku'))} - aside={{partsVariants.length} 个配件} - /> - - {/* 成品 SKU 库位 */} - } - expanded={activeSection === 'product-sku'} - onToggle={() => setActiveSection((current) => toggleSection(current, 'product-sku'))} - aside={{draftGroups.reduce((s, dg) => s + dg.preSkus.length, 0)} 个 SKU} - /> - - {/* 品牌物料库位 */} - } - expanded={activeSection === 'brand'} - onToggle={() => setActiveSection((current) => toggleSection(current, 'brand'))} - /> - - {/* 执行准备 */} - } - expanded={activeSection === 'preparation'} - onToggle={() => setActiveSection((current) => toggleSection(current, 'preparation'))} - aside={{workbenchSkuList.length} 个 SKU} - /> - - {/* 包装执行 */} - } - expanded={activeSection === 'packaging'} - onToggle={() => setActiveSection((current) => toggleSection(current, 'packaging'))} - /> -
- } + rightContent={sectionsPanel} + 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" + fitParentHeight /> {/* 新增/编辑库位弹窗 */} diff --git a/furniture-3d-system/src/app/warehouse/page.tsx b/furniture-3d-system/src/app/warehouse/page.tsx index f0d812cb..0eaf4dbc 100644 --- a/furniture-3d-system/src/app/warehouse/page.tsx +++ b/furniture-3d-system/src/app/warehouse/page.tsx @@ -1,4 +1,3 @@ -import { Boxes, MapPinned, Package, Warehouse as WarehouseIcon } from 'lucide-react' import { requireSession } from '@/lib/server-session' import { mapWorkbenchStatus, readPreSkuTextField, readPreviewNodes } from '@/lib/open-product/platform-view' import { assetCenterService, draftGroupService, warehouseService } from '@/server/services' @@ -13,143 +12,118 @@ export default async function WarehousePage() { draftGroupService.list(), ]) - // 获取所有资产族的变体(配件数据源,不过滤状态) - const familyIds = families.map((f) => f.id) + const familyIds = families.map((family) => family.id) const allVariants = familyIds.length > 0 ? await assetCenterService.listVariantsByFamilyIds(familyIds) : [] - // 获取草稿组详情(含 PreSku,成品数据源) 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((v) => ({ - id: v.id, - code: v.code, - name: v.name, - assetType: v.assetType, - familyCode: v.assetFamilyCode, - familyName: v.assetFamilyName, - thumbnailUrl: v.thumbnailUrl, + const partsVariants = allVariants.map((variant) => ({ + id: variant.id, + code: variant.code, + name: variant.name, + assetType: variant.assetType, + familyCode: variant.assetFamilyCode, + familyName: variant.assetFamilyName, + thumbnailUrl: variant.thumbnailUrl, })) - // 预加载配件-库位绑定数据 - const variantIds = partsVariants.map((v) => v.id) + const variantIds = partsVariants.map((variant) => variant.id) const bindings = variantIds.length > 0 ? await warehouseService.getBindingsForVariants(variantIds) : [] const bindingMap = new Map>() - for (const b of bindings) { - const list = bindingMap.get(b.assetVariantId) ?? [] - list.push({ id: b.warehouseLocation.id, code: b.warehouseLocation.code, name: b.warehouseLocation.name }) - bindingMap.set(b.assetVariantId, list) + for (const binding of bindings) { + const current = bindingMap.get(binding.assetVariantId) ?? [] + current.push({ + id: binding.warehouseLocation.id, + code: binding.warehouseLocation.code, + name: binding.warehouseLocation.name, + }) + bindingMap.set(binding.assetVariantId, current) } - const partsVariantsWithBindings = partsVariants.map((v) => ({ - ...v, - boundLocations: bindingMap.get(v.id) ?? [], + const partsVariantsWithBindings = partsVariants.map((variant) => ({ + ...variant, + boundLocations: bindingMap.get(variant.id) ?? [], })) - // 转换草稿组 + PreSku 数据 const draftGroups = draftGroupDetails - .filter((d): d is NonNullable => d !== null) + .filter((detail): detail is NonNullable => detail !== null) .map((detail) => ({ id: detail.draftGroup.id, code: detail.draftGroup.code, name: detail.draftGroup.name, status: detail.draftGroup.status, - preSkus: detail.preSkus.map((ps) => ({ - id: ps.id, - defaultName: ps.defaultName, - customName: ps.customName, - componentCodes: ps.componentCodes, - status: ps.status, + preSkus: detail.preSkus.map((preSku) => ({ + id: preSku.id, + defaultName: preSku.defaultName, + customName: preSku.customName, + componentCodes: preSku.componentCodes, + status: preSku.status, })), })) - // 预加载成品 SKU-库位绑定数据 - const allPreSkuIds = draftGroups.flatMap((dg) => dg.preSkus.map((ps) => ps.id)) + const allPreSkuIds = draftGroups.flatMap((draftGroup) => draftGroup.preSkus.map((preSku) => preSku.id)) const preSkuBindings = allPreSkuIds.length > 0 ? await warehouseService.getBindingsForPreSkus(allPreSkuIds) : [] + const preSkuBindingMap = new Map>() - for (const b of preSkuBindings) { - const list = preSkuBindingMap.get(b.preSkuId) ?? [] - list.push({ id: b.warehouseLocation.id, code: b.warehouseLocation.code, name: b.warehouseLocation.name }) - preSkuBindingMap.set(b.preSkuId, list) + for (const binding of preSkuBindings) { + const current = preSkuBindingMap.get(binding.preSkuId) ?? [] + current.push({ + id: binding.warehouseLocation.id, + code: binding.warehouseLocation.code, + name: binding.warehouseLocation.name, + }) + preSkuBindingMap.set(binding.preSkuId, current) } - const draftGroupsWithBindings = draftGroups.map((dg) => ({ - ...dg, - preSkus: dg.preSkus.map((ps) => ({ - ...ps, - boundLocations: preSkuBindingMap.get(ps.id) ?? [], + + const draftGroupsWithBindings = draftGroups.map((draftGroup) => ({ + ...draftGroup, + preSkus: draftGroup.preSkus.map((preSku) => ({ + ...preSku, + boundLocations: preSkuBindingMap.get(preSku.id) ?? [], })), })) - // 构建 WorkbenchSku 数据(执行准备用) const workbenchSkuList = draftGroupDetails - .filter((d): d is NonNullable => d !== null) + .filter((detail): detail is NonNullable => detail !== null) .flatMap((detail) => - detail.preSkus.map((ps) => ({ - id: ps.id, - spuCode: readPreSkuTextField(ps, 'spuCode') || readPreSkuTextField(ps, 'spu') || detail.draftGroup.code || `SPU-${detail.draftGroup.code.slice(-4)}`, - code: readPreSkuTextField(ps, 'skuCode') || ps.customName || ps.defaultName, - name: ps.customName || ps.defaultName, - seriesName: readPreSkuTextField(ps, 'seriesName') || detail.draftGroup.name, + detail.preSkus.map((preSku) => ({ + id: preSku.id, + spuCode: + readPreSkuTextField(preSku, 'spuCode') + || readPreSkuTextField(preSku, 'spu') + || 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, - status: mapWorkbenchStatus(ps.status), - componentCodes: ps.componentCodes ?? [], + status: mapWorkbenchStatus(preSku.status), + componentCodes: preSku.componentCodes ?? [], })), ) - // 提取第一个有 previewNodes 的 PreSku(包装执行 3D 预览用) const firstPreSkuWithPreview = draftGroupDetails - .filter((d): d is NonNullable => d !== null) - .flatMap((d) => d.preSkus) - .find((ps) => Array.isArray(ps.previewNodes) && ps.previewNodes.length > 0) + .filter((detail): detail is NonNullable => detail !== null) + .flatMap((detail) => detail.preSkus) + .find((preSku) => Array.isArray(preSku.previewNodes) && preSku.previewNodes.length > 0) + const packagingPreviewNodes = firstPreSkuWithPreview ? readPreviewNodes(firstPreSkuWithPreview.previewNodes) : [] - const metrics = [ - { - label: '库位', - value: overview.locations.length, - hint: '先做基础正式库位管理', - icon: , - }, - { - label: '正式 SKU', - value: overview.skuStock.length, - hint: '来自正式入库确认', - tone: 'info' as const, - icon: , - }, - { - label: '包装成品', - value: overview.packagedItems.length, - hint: '按准 SKU 包装结果生成', - tone: 'success' as const, - icon: , - }, - { - label: '库存总量', - value: - overview.skuStock.reduce((sum, item) => sum + item.stock, 0) + - overview.packagedItems.reduce((sum, item) => sum + item.stockQuantity, 0), - hint: 'SKU 与包装成品合计', - icon: , - }, - ] - return ( { if (!shouldLockMainViewport || typeof document === 'undefined') { diff --git a/furniture-3d-system/src/components/platform/AccordionSection.tsx b/furniture-3d-system/src/components/platform/AccordionSection.tsx index 84ffb808..7612e086 100644 --- a/furniture-3d-system/src/components/platform/AccordionSection.tsx +++ b/furniture-3d-system/src/components/platform/AccordionSection.tsx @@ -24,6 +24,7 @@ interface AccordionSectionProps { children?: ReactNode className?: string contentClassName?: string + compact?: boolean } export function AccordionSection({ @@ -38,6 +39,7 @@ export function AccordionSection({ children, className, contentClassName, + compact = false, }: AccordionSectionProps) { const [internalExpanded, setInternalExpanded] = useState(defaultExpanded) @@ -60,23 +62,48 @@ export function AccordionSection({