From 425e5e444d4dc19dd12d05f8c47b542790a4433c Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 4 Aug 2026 14:43:29 +0800 Subject: [PATCH] fix(bom-costing): preserve viable cost estimates --- .../components/layout/ReleaseNotesDialog.tsx | 119 +++++++++++------- .../__tests__/ReleaseNotesDialog.test.tsx | 67 ++++++++++ .../bom-costing-result-preparer.test.ts | 43 +++++++ .../bom-costing-result-preparer.ts | 35 ++++-- 4 files changed, 205 insertions(+), 59 deletions(-) create mode 100644 furniture-3d-system/src/components/layout/__tests__/ReleaseNotesDialog.test.tsx diff --git a/furniture-3d-system/src/components/layout/ReleaseNotesDialog.tsx b/furniture-3d-system/src/components/layout/ReleaseNotesDialog.tsx index cbcc82de..0f0f63e2 100644 --- a/furniture-3d-system/src/components/layout/ReleaseNotesDialog.tsx +++ b/furniture-3d-system/src/components/layout/ReleaseNotesDialog.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { ArrowRight, Megaphone } from 'lucide-react' +import { ArrowRight, Megaphone, X } from 'lucide-react' import { useRouter } from 'next/navigation' import type { ReleaseNoteForUser } from '@/lib/release-notes' @@ -17,6 +17,9 @@ type ReleaseNotesResponse = { export function ReleaseNotesDialog({ userId, suppressed = false }: ReleaseNotesDialogProps) { const router = useRouter() const [unreadNotes, setUnreadNotes] = useState([]) + const [dismissedForUserId, setDismissedForUserId] = useState(null) + const currentUserId = userId ?? null + const dismissed = currentUserId !== null && dismissedForUserId === currentUserId useEffect(() => { if (!userId || suppressed) { @@ -39,54 +42,78 @@ export function ReleaseNotesDialog({ userId, suppressed = false }: ReleaseNotesD return () => controller.abort() }, [suppressed, userId]) - if (!userId || suppressed || unreadNotes.length === 0) return null + useEffect(() => { + if (dismissed || unreadNotes.length === 0) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setDismissedForUserId(currentUserId) + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [currentUserId, dismissed, unreadNotes.length]) + + if (!userId || suppressed || dismissed || unreadNotes.length === 0) return null return ( -
-
-
- - - -
-

系统更新

-

- 有 {unreadNotes.length} 条更新待阅读 -

-
-
- -
- {unreadNotes.map((note) => ( -
-
- - - {note.audienceLabel} - -
-

{note.title}

-

{note.summary}

-
- ))} +
-
+
+ {unreadNotes.map((note) => ( +
+
+ + + {note.audienceLabel} + +
+

{note.title}

+

{note.summary}

+
+ ))} +
+ +
+ + +
+ ) } diff --git a/furniture-3d-system/src/components/layout/__tests__/ReleaseNotesDialog.test.tsx b/furniture-3d-system/src/components/layout/__tests__/ReleaseNotesDialog.test.tsx new file mode 100644 index 00000000..e65f1741 --- /dev/null +++ b/furniture-3d-system/src/components/layout/__tests__/ReleaseNotesDialog.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ReleaseNotesDialog } from '../ReleaseNotesDialog' + +const pushMock = jest.fn() + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock }), +})) + +const unreadNote = { + id: 'release-1', + title: 'BOM 核算已更新', + summary: '现在可以直接查看自动形成的预估成本。', + audienceLabel: '全体用户', + publishedAt: '2026-08-04T00:00:00.000Z', + acknowledged: false, +} + +describe('ReleaseNotesDialog', () => { + const originalFetch = global.fetch + + beforeEach(() => { + pushMock.mockReset() + global.fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ + success: true, + data: { notes: [unreadNote] }, + }), + })) as typeof fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('把未读更新作为可关闭提醒展示,不阻塞当前工作台', async () => { + render() + + const reminder = await screen.findByRole('complementary') + expect(reminder).toBeInTheDocument() + expect(reminder).not.toHaveClass('inset-0') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '暂不查看' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '暂不查看' })) + + await waitFor(() => { + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + }) + }) + + it('支持用 Esc 暂时关闭提醒,并可跳转到更新页', async () => { + render() + + expect(await screen.findByRole('complementary')).toBeInTheDocument() + fireEvent.keyDown(window, { key: 'Escape' }) + + await waitFor(() => { + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + }) + + render() + fireEvent.click(await screen.findByRole('button', { name: '查看更新' })) + expect(pushMock).toHaveBeenCalledWith('/release-notes') + }) +}) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts index 8339f5bd..26859bd9 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.test.ts @@ -139,4 +139,47 @@ describe('BOM 核算结果准备', () => { subtotal: 12, }) }) + + it('资料库价格缺少计价所需尺寸时回退到可计算的模型备用估价', () => { + const candidates = emptyCandidates() + candidates.BOARD = [{ + libraryType: 'SUBSTRATE', + recordId: 'rubberwood-area-price', + code: 'RW-A', + name: '橡胶木', + spec: '18mm', + unit: '㎡', + unitPrice: 40, + materialFamily: '橡胶木', + pricingMode: 'AREA_SQM', + }] + const missingBoardDimensions: CompetitorAnalysisResultV1 = { + ...analysis, + skus: analysis.skus.map((sku) => ({ + ...sku, + bomLines: sku.bomLines.map((line) => + line.category === 'BOARD' + ? { + ...line, + lengthMm: undefined, + widthMm: undefined, + unit: '件', + } + : line, + ), + })), + } + + const [sku] = prepareBomCostingAnalysis(missingBoardDimensions, candidates) + const board = sku?.bomLines.find((line) => line.category === 'BOARD') + + expect(sku?.estimatedCost).toBeGreaterThan(0) + expect(board).toMatchObject({ + matchStatus: BomCostingMatchStatus.NEEDS_CONFIRMATION, + matchedLibraryType: 'EXTERNAL_ANALYSIS_ESTIMATE', + resolvedUnitPrice: 80, + calculatedQuantity: 1, + subtotal: 80, + }) + }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts index 9b23164f..8662da9d 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-result-preparer.ts @@ -101,25 +101,34 @@ function prepareLine( ? normalizePackagingKind(line.packagingKind) : null, }) - const matchedCandidate = libraryEstimate.candidate ?? externalEstimate - const matchStatus = libraryEstimate.candidate + const calculateWithCandidate = ( + candidate: BomCostingLibraryCandidate | null, + ) => calculateBomCostingLine({ + category: line.category, + packagingKind: normalizePackagingKind(line.packagingKind), + pricingMode: candidate?.pricingMode, + lengthMm: line.lengthMm, + widthMm: line.widthMm, + heightMm: line.heightMm, + quantity: line.quantity, + unitPrice: candidate?.unitPrice, + lossFactor, + }) + const libraryCalculation = calculateWithCandidate(libraryEstimate.candidate) + const canUseLibraryEstimate = libraryCalculation.subtotal !== null + const matchedCandidate = canUseLibraryEstimate + ? libraryEstimate.candidate + : externalEstimate + const calculation = canUseLibraryEstimate + ? libraryCalculation + : calculateWithCandidate(externalEstimate) + const matchStatus = canUseLibraryEstimate && libraryEstimate.candidate ? libraryEstimate.status : externalEstimate ? line.category === 'PROCESSING' || line.category === 'LABOR' ? 'MATCHED' : 'NEEDS_CONFIRMATION' : 'UNMATCHED' - const calculation = calculateBomCostingLine({ - category: line.category, - packagingKind: normalizePackagingKind(line.packagingKind), - pricingMode: matchedCandidate?.pricingMode, - lengthMm: line.lengthMm, - widthMm: line.widthMm, - heightMm: line.heightMm, - quantity: line.quantity, - unitPrice: matchedCandidate?.unitPrice, - lossFactor, - }) return { externalLineId: line.externalLineId,