merge: release BOM costing recovery

This commit is contained in:
WuYongJie 2026-08-04 15:13:48 +08:00
commit 16b22dcd40
4 changed files with 205 additions and 59 deletions

View File

@ -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<ReleaseNoteForUser[]>([])
const [dismissedForUserId, setDismissedForUserId] = useState<string | null>(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 (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/35 p-4 sm:p-6">
<section
role="dialog"
aria-modal="true"
aria-labelledby="release-notice-title"
className="w-full max-w-[500px] overflow-hidden rounded-[8px] border border-black/[0.08] bg-white shadow-[0_24px_64px_rgba(15,23,42,0.24)]"
>
<header className="flex items-start gap-3 border-b border-black/[0.08] px-5 py-4 sm:px-6">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-[#eaf1f3] text-[#365d66]">
<Megaphone className="h-[18px] w-[18px]" strokeWidth={1.8} />
</span>
<div className="min-w-0">
<p className="text-xs font-medium text-[#667174]">系统更新</p>
<h2 id="release-notice-title" className="mt-0.5 text-lg font-semibold text-[#252b2d]">
有 {unreadNotes.length} 条更新待阅读
</h2>
</div>
</header>
<div className="space-y-3 px-5 py-5 sm:px-6">
{unreadNotes.map((note) => (
<article key={note.id} className="border-l-2 border-[#7ba3aa] pl-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-[#778184]">
<time>{new Date(note.publishedAt).toLocaleDateString('zh-CN')}</time>
<span className="rounded-[4px] bg-[#edf5f1] px-1.5 py-0.5 text-[#3b6a54]">
{note.audienceLabel}
</span>
</div>
<h3 className="mt-1 text-sm font-semibold text-[#30383a]">{note.title}</h3>
<p className="mt-1 text-sm leading-5 text-[#657073]">{note.summary}</p>
</article>
))}
<aside
aria-labelledby="release-notice-title"
className="fixed bottom-4 right-4 z-[80] w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-[12px] border border-black/[0.08] bg-white shadow-[0_16px_36px_rgba(15,23,42,0.18)]"
>
<header className="flex items-start gap-3 border-b border-black/[0.08] px-4 py-3.5">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] bg-[#eaf1f3] text-[#365d66]">
<Megaphone className="h-4 w-4" strokeWidth={1.8} />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-[#667174]">系统更新</p>
<h2 id="release-notice-title" className="mt-0.5 text-sm font-semibold text-[#252b2d]">
有 {unreadNotes.length} 条更新待阅读
</h2>
</div>
<button
type="button"
aria-label="暂不查看系统更新"
onClick={() => setDismissedForUserId(currentUserId)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] text-[#667174] transition hover:bg-[#f1f4f4] hover:text-[#30383a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5b8791] focus-visible:ring-offset-2"
>
<X className="h-4 w-4" strokeWidth={1.8} />
</button>
</header>
<footer className="flex justify-end border-t border-black/[0.08] px-5 py-4 sm:px-6">
<button
type="button"
onClick={() => router.push('/release-notes')}
className="inline-flex h-9 items-center gap-2 rounded-[6px] bg-[#2d565f] px-3.5 text-sm font-medium text-white transition hover:bg-[#254a52]"
>
查看更新
<ArrowRight className="h-4 w-4" strokeWidth={1.8} />
</button>
</footer>
</section>
</div>
<div className="max-h-[min(42vh,18rem)] space-y-3 overflow-y-auto px-4 py-4">
{unreadNotes.map((note) => (
<article key={note.id} className="border-l border-[#7ba3aa] pl-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-[#778184]">
<time>{new Date(note.publishedAt).toLocaleDateString('zh-CN')}</time>
<span className="rounded-[4px] bg-[#edf5f1] px-1.5 py-0.5 text-[#3b6a54]">
{note.audienceLabel}
</span>
</div>
<h3 className="mt-1 text-sm font-semibold text-[#30383a]">{note.title}</h3>
<p className="mt-1 break-words text-sm leading-5 text-[#657073]">{note.summary}</p>
</article>
))}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-black/[0.08] px-4 py-3">
<button
type="button"
onClick={() => setDismissedForUserId(currentUserId)}
className="inline-flex h-9 items-center rounded-[6px] px-3 text-sm font-medium text-[#526064] transition hover:bg-[#f1f4f4] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5b8791] focus-visible:ring-offset-2"
>
暂不查看
</button>
<button
type="button"
onClick={() => {
setDismissedForUserId(currentUserId)
router.push('/release-notes')
}}
className="inline-flex h-9 items-center gap-2 rounded-[6px] bg-[#2d565f] px-3.5 text-sm font-medium text-white transition hover:bg-[#254a52] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5b8791] focus-visible:ring-offset-2"
>
查看更新
<ArrowRight className="h-4 w-4" strokeWidth={1.8} />
</button>
</footer>
</aside>
)
}

View File

@ -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(<ReleaseNotesDialog userId="user-1" />)
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(<ReleaseNotesDialog userId="user-1" />)
expect(await screen.findByRole('complementary')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
})
render(<ReleaseNotesDialog userId="user-2" />)
fireEvent.click(await screen.findByRole('button', { name: '查看更新' }))
expect(pushMock).toHaveBeenCalledWith('/release-notes')
})
})

View File

@ -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,
})
})
})

View File

@ -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,