diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 522af30f..f0e096da 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -13,11 +13,25 @@ import { } from './non-integrated/nonIntegratedStorage' const push = jest.fn() +const mockAoaToSheet = jest.fn((rows: unknown[][]) => ({ rows })) +const mockBookNew = jest.fn(() => ({})) +const mockBookAppendSheet = jest.fn() +const mockXlsxWrite = jest.fn(() => new ArrayBuffer(8)) jest.mock('next/navigation', () => ({ useRouter: () => ({ push }), })) +jest.mock('xlsx', () => ({ + __esModule: true, + utils: { + aoa_to_sheet: mockAoaToSheet, + book_new: mockBookNew, + book_append_sheet: mockBookAppendSheet, + }, + write: mockXlsxWrite, +})) + jest.mock('@/components/platform/PlatformSplitLayout', () => ({ __esModule: true, PlatformSplitLayout: ({ @@ -262,8 +276,13 @@ function createNonIntegratedProduct(overrides: Record = {}) { describe('IntakeClient', () => { beforeEach(() => { + jest.useRealTimers() window.localStorage.clear() jest.restoreAllMocks() + mockAoaToSheet.mockClear() + mockBookNew.mockClear() + mockBookAppendSheet.mockClear() + mockXlsxWrite.mockClear() push.mockReset() }) @@ -311,12 +330,121 @@ describe('IntakeClient', () => { const leftPanel = screen.getByTestId('left-panel') expect(screen.getByRole('tab', { name: '产品开发进度' })).toBeInTheDocument() expect(within(leftPanel).getByText('产品开发进度总览')).toBeInTheDocument() - expect(within(leftPanel).getByRole('button', { name: /总进度/ })).toBeInTheDocument() + expect(within(leftPanel).getByRole('button', { name: /状态总览/ })).toBeInTheDocument() expect(within(leftPanel).getByText('全部产品明细')).toBeInTheDocument() expect(within(leftPanel).getByText('SZ0230')).toBeInTheDocument() }) - it('filters product progress rows by clicking a ring dimension item', async () => { + it('shows running total days and node elapsed days in the product progress table', async () => { + jest.useFakeTimers({ now: new Date('2026-04-29T00:00:00.000Z') }) + const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + + try { + renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [ + createNonIntegratedProduct({ + id: 'duration-product', + spu: 'Duration SKU', + createdAt: '2026-04-28T00:00:00.000Z', + updatedAt: '2026-04-28T00:00:00.000Z', + documentWorkspaceApprovedAt: { + [NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0]]: + '2026-04-28T12:00:00.000Z', + }, + }), + ], + }) + + const rightPanel = screen.getByTestId('right-panel') + await actor.click( + within(rightPanel).getByRole('button', { name: /产品开发进度/ }), + ) + + const row = within(screen.getByTestId('left-panel')) + .getByText('Duration SKU') + .closest('tr') + + expect(row).not.toBeNull() + expect( + within(row as HTMLElement).getByText('1.00 天'), + ).toBeInTheDocument() + expect( + within(row as HTMLElement).getByText('0.50 天'), + ).toBeInTheDocument() + } finally { + jest.useRealTimers() + } + }) + + it('exports current product progress rows as an Excel workbook', async () => { + jest.useFakeTimers({ now: new Date('2026-04-29T00:00:00.000Z') }) + const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + const originalCreateObjectURL = URL.createObjectURL + const originalRevokeObjectURL = URL.revokeObjectURL + const createObjectURL = jest.fn(() => 'blob:product-progress') + const revokeObjectURL = jest.fn() + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) + + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectURL, + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectURL, + }) + + try { + renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [ + createNonIntegratedProduct({ + id: 'export-product', + spu: 'Export SKU', + createdAt: '2026-04-28T00:00:00.000Z', + updatedAt: '2026-04-28T00:00:00.000Z', + documentWorkspaceApprovedAt: { + [NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES[0]]: + '2026-04-28T12:00:00.000Z', + }, + }), + ], + }) + + const rightPanel = screen.getByTestId('right-panel') + await actor.click( + within(rightPanel).getByRole('button', { name: /产品开发进度/ }), + ) + await actor.click(screen.getByRole('button', { name: '导出明细' })) + + await waitFor(() => expect(mockXlsxWrite).toHaveBeenCalled()) + + const exportedRows = mockAoaToSheet.mock.calls[0]?.[0] as unknown[][] + expect(exportedRows[0]).toContain('总耗时') + expect(exportedRows.flat()).toContain('Export SKU') + expect(exportedRows.flat()).toContain('1.00 天') + expect(exportedRows.flat()).toContain('0.50 天') + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(clickSpy).toHaveBeenCalled() + expect(revokeObjectURL).toHaveBeenCalledWith('blob:product-progress') + } finally { + clickSpy.mockRestore() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: originalCreateObjectURL, + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: originalRevokeObjectURL, + }) + jest.useRealTimers() + } + }) + + it('filters product progress rows by clicking a status overview dimension item', async () => { const actor = userEvent.setup() renderSelector({ @@ -341,11 +469,10 @@ describe('IntakeClient', () => { ) const leftPanel = screen.getByTestId('left-panel') - await actor.click(within(leftPanel).getByRole('button', { name: /环形视图/ })) await actor.click(within(leftPanel).getByRole('button', { name: /基本盘 1 个/ })) expect( - within(leftPanel).getByText('按品牌查看明细:基本盘'), + within(leftPanel).getByText('全部产品明细:基本盘'), ).toBeInTheDocument() expect(within(leftPanel).getByText('SZ0230')).toBeInTheDocument() expect(within(leftPanel).queryByText('GBGJ-SZ0230')).not.toBeInTheDocument() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 9366dacc..9fe9338e 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -2,7 +2,14 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { + type KeyboardEvent, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Check, Copy, @@ -237,8 +244,7 @@ function DocumentStatusMark({ ); } -type NonIntegratedDocumentStatusCategory = - NonIntegratedWorkflowNodeCategory; +type NonIntegratedDocumentStatusCategory = NonIntegratedWorkflowNodeCategory; const NON_INTEGRATED_DOCUMENT_STATUS_COLUMNS = [ ...NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES, @@ -743,9 +749,7 @@ function getDocumentWorkspaceCountByCategory( product.uploadFiles.forEach((file) => { const category = file.category as NonIntegratedDocumentWorkspaceCategory; - if ( - NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(category) - ) { + if (NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(category)) { uploadFileCountByCategory[category] += 1; } }); @@ -782,8 +786,11 @@ function getDocumentWorkspaceCountByCategory( 包装细节: uploadFileCountByCategory["包装细节"] + (hasPackagingContentForList(product) ? 1 : 0), - [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: - hasBasicInfoContentForList(product) ? 1 : 0, + [NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY]: hasBasicInfoContentForList( + product, + ) + ? 1 + : 0, }; } @@ -894,13 +901,9 @@ function getCurrentNodeOwnerCandidates( } if ( - [ - "产品定厂", - "样包测量", - "产品核价", - "工厂质检", - "检测报告", - ].includes(category) + ["产品定厂", "样包测量", "产品核价", "工厂质检", "检测报告"].includes( + category, + ) ) { return getUniqueNonEmptyValues([ product.purchaser, @@ -957,7 +960,7 @@ function isDocumentWorkspaceApprovedForList( ) { return Boolean( product.documentWorkspaceConfirmations?.[category] && - product.documentWorkspaceApprovedAt?.[category], + product.documentWorkspaceApprovedAt?.[category], ); } @@ -1549,7 +1552,8 @@ function NonIntegratedPendingDetail({ ); if (shouldUseSharedNonIntegratedApi) { - const savedProduct = await saveSharedNonIntegratedProduct(copiedProduct); + const savedProduct = + await saveSharedNonIntegratedProduct(copiedProduct); const nextProducts = [ savedProduct, ...products.filter((product) => product.id !== savedProduct.id), @@ -1697,7 +1701,7 @@ function NonIntegratedPendingDetail({
- +
@@ -1871,7 +1875,9 @@ function NonIntegratedPendingDetail({ variant="plain" size="small" aria-label="修改" - title={isFlowAbandoned ? "已废弃,只能查看" : "修改"} + title={ + isFlowAbandoned ? "已废弃,只能查看" : "修改" + } className={TABLE_ACTION_BUTTON_PRIMARY} onClick={() => onOpenProductWorkspace?.( @@ -1880,17 +1886,16 @@ function NonIntegratedPendingDetail({ ) } > -