From d2337b960ef6a8e5b179399fb00fa964aabaa1bf Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Sun, 27 Sep 2026 16:06:27 +0800 Subject: [PATCH] fix: default certificate expiry to one year after testing --- app/api/materials/[id]/route.ts | 8 ++++-- app/api/materials/share/route.ts | 8 ++++-- .../certificate-report/init/route.test.ts | 9 +++++++ app/edit/[id]/page.tsx | 7 +++-- app/share/[id]/page.test.tsx | 9 ++++++- app/share/[id]/page.tsx | 9 +++++-- components/upload/CertificateDatePicker.tsx | 15 ++++++----- .../upload/CertificateReportUploader.test.tsx | 14 +++++----- .../upload/CertificateReportUploader.tsx | 7 +++-- lib/__tests__/certificate-report.test.ts | 27 +++++++++++++++++-- lib/__tests__/material-edit-page.test.tsx | 11 ++++---- lib/__tests__/material-edit-routes.test.ts | 16 +++++++++++ lib/__tests__/material-update.test.ts | 24 +++++++++++++++++ .../materials-share-security.test.ts | 12 +++++++++ lib/certificate-report.ts | 21 +++++++++++++-- 15 files changed, 164 insertions(+), 33 deletions(-) diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index fb6c98e..4c6ac10 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -13,7 +13,7 @@ import { import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; import { isWebFileLike } from '@/lib/web-file'; -import { detectFileKindFromName, getCertificateReportFileKindByAssetType, isPublicCertificateReportPdf } from '@/lib/certificate-report'; +import { detectFileKindFromName, getCertificateReportFileKindByAssetType, getDefaultCertificateExpiryDate, isCertificateReportMaterialType, isPublicCertificateReportPdf } from '@/lib/certificate-report'; import { DownloadErrorCode } from '@/lib/download-errors'; import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy'; import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'; @@ -481,7 +481,11 @@ export async function GET( reportKind: materialWithUser.reportKind || null, certificateTestedOn: materialWithUser.certificateTestedOn || null, certificateNumber: materialWithUser.certificateNumber || null, - certificateExpiresOn: materialWithUser.certificateExpiresOn || null, + certificateExpiresOn: materialWithUser.certificateExpiresOn || ( + isCertificateReportMaterialType(materialWithUser.materialType) + ? getDefaultCertificateExpiryDate(materialWithUser.certificateTestedOn) + : null + ), reportUsageRules: materialWithUser.reportUsageRules || null, categoryName: materialWithUser.categoryName, sceneName: materialWithUser.sceneName, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 714725a..2e6fe9a 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -14,7 +14,7 @@ import { import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; import { canManageMaterial } from '@/lib/material-authorization'; -import { detectFileKindFromName, getCertificateReportFileKindByAssetType, isPublicCertificateReportPdf } from '@/lib/certificate-report'; +import { detectFileKindFromName, getCertificateReportFileKindByAssetType, getDefaultCertificateExpiryDate, isCertificateReportMaterialType, isPublicCertificateReportPdf } from '@/lib/certificate-report'; import { DownloadErrorCode } from '@/lib/download-errors'; import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy'; @@ -364,7 +364,11 @@ export async function GET(request: NextRequest) { reportKind: (material as any).reportKind || null, certificateTestedOn: (material as any).certificateTestedOn || null, certificateNumber: (material as any).certificateNumber || null, - certificateExpiresOn: (material as any).certificateExpiresOn || null, + certificateExpiresOn: (material as any).certificateExpiresOn || ( + isCertificateReportMaterialType(material.materialType as unknown as string) + ? getDefaultCertificateExpiryDate((material as any).certificateTestedOn) + : null + ), reportUsageRules: (material as any).reportUsageRules || null, isAiGenerated: Boolean((material as any).isAiGenerated), taskDetailSourceType: (material as any).taskDetailSourceType || null, diff --git a/app/api/materials/upload/certificate-report/init/route.test.ts b/app/api/materials/upload/certificate-report/init/route.test.ts index 59f2d64..b71015e 100644 --- a/app/api/materials/upload/certificate-report/init/route.test.ts +++ b/app/api/materials/upload/certificate-report/init/route.test.ts @@ -68,4 +68,13 @@ describe('检测报告初始化', () => { expect(response.status).toBe(400) expect(prisma.material.create).not.toHaveBeenCalled() }) + + test.each(['', undefined, null])('到期时间留空时写入按检测日期计算的日期 (%s)', async (certificateExpiresOn) => { + const response = await POST(request({ ...input, certificateTestedOn: '2024-02-29', certificateExpiresOn })) + expect(response.status).toBe(200) + expect(prisma.material.create).toHaveBeenCalledWith({ data: expect.objectContaining({ + certificateTestedOn: new Date('2024-02-29T00:00:00.000Z'), + certificateExpiresOn: new Date('2025-02-28T00:00:00.000Z'), + }) }) + }) }) diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index b8495a1..4281c61 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -23,6 +23,7 @@ import { CERTIFICATE_REPORT_KIND, CERTIFICATE_REPORT_PLATFORM_OPTIONS, DEFAULT_CERTIFICATE_REPORT_USAGE_RULES, + getDefaultCertificateExpiryDate, isCertificateReportMaterialType, validateCertificateReportMetadata, } from '@/lib/certificate-report' @@ -112,6 +113,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const [certificateTestedOn, setCertificateTestedOn] = useState('') const [certificateNumber, setCertificateNumber] = useState('') const [certificateExpiresOn, setCertificateExpiresOn] = useState('') + const defaultExpiryDate = getDefaultCertificateExpiryDate(certificateTestedOn)?.toISOString().slice(0, 10) || '' const [reportUsageRules, setReportUsageRules] = useState('') const [liveDate, setLiveDate] = useState('') const [liveHost, setLiveHost] = useState('') @@ -1069,8 +1071,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } })

请按报告标注的检测日期选择

- - + + +

留空按检测日期顺延一年,也可自行选择日期

diff --git a/app/share/[id]/page.test.tsx b/app/share/[id]/page.test.tsx index 980f5c5..69bcd79 100644 --- a/app/share/[id]/page.test.tsx +++ b/app/share/[id]/page.test.tsx @@ -240,7 +240,7 @@ describe('certificate report share page', () => { global.fetch = originalFetch }) - test('详情展示证书字段和未拆分的完整说明', async () => { + test.each([false, true])('详情展示证书字段、完整说明并按权限提供修改日期入口 (%s)', async (canEdit) => { global.fetch = jest.fn().mockResolvedValue(createJsonResponse({ id: 'video-material-1', userId: 'user-1', @@ -252,6 +252,7 @@ describe('certificate report share page', () => { certificateTestedOn: '2027-09-23T00:00:00.000Z', certificateNumber: 'CMA-2026-001', certificateUsageInstructions: '使用前核对主体', + capabilities: { canEdit, canFavorite: false, canDelete: false }, reportUsageRules: '第一段\n第二段', categoryName: '通用', sceneName: '通用', @@ -275,6 +276,12 @@ describe('certificate report share page', () => { expect(screen.queryByText('使用前核对主体')).not.toBeInTheDocument() expect(screen.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent) .toBe('第一段\n第二段') + if (canEdit) { + await userEvent.setup().click(screen.getByRole('button', { name: '修改日期' })) + expect(mockPush).toHaveBeenCalledWith('/edit/video-material-1') + } else { + expect(screen.queryByRole('button', { name: '修改日期' })).not.toBeInTheDocument() + } }) }) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 7551882..90139d1 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -2086,8 +2086,13 @@ export default function SharePage() {

{material.certificateTestedOn ? String(material.certificateTestedOn).slice(0, 10) : '待补充'}

-

证书到期时间

-

{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '待补充'}

+
+

证书到期时间

+ {material.capabilities?.canEdit && ( + + )} +
+

{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '请先补充检测日期'}

证书编号

diff --git a/components/upload/CertificateDatePicker.tsx b/components/upload/CertificateDatePicker.tsx index 802cf06..12dde98 100644 --- a/components/upload/CertificateDatePicker.tsx +++ b/components/upload/CertificateDatePicker.tsx @@ -18,20 +18,23 @@ interface CertificateDatePickerProps { value: string onChange: (value: string) => void disabled?: boolean + placeholder?: string + suggestedDate?: string } -export default function CertificateDatePicker({ id, label, value, onChange, disabled = false }: CertificateDatePickerProps) { +export default function CertificateDatePicker({ id, label, value, onChange, disabled = false, placeholder = '选择日期', suggestedDate = '' }: CertificateDatePickerProps) { const containerRef = useRef(null) const triggerRef = useRef(null) const calendarRef = useRef(null) const selectedDate = useMemo(() => parseDate(value), [value]) + const calendarDate = useMemo(() => selectedDate || parseDate(suggestedDate), [selectedDate, suggestedDate]) const [isOpen, setIsOpen] = useState(false) const [isMonthPickerOpen, setIsMonthPickerOpen] = useState(false) - const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(selectedDate || new Date())) + const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(calendarDate || new Date())) useEffect(() => { - if (selectedDate) setVisibleMonth(startOfMonth(selectedDate)) - }, [selectedDate]) + if (calendarDate) setVisibleMonth(startOfMonth(calendarDate)) + }, [calendarDate]) useEffect(() => { if (!isOpen) return @@ -92,7 +95,7 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa id={id} type="button" disabled={disabled} - aria-label={`${label},${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`} + aria-label={`${label},${selectedDate ? format(selectedDate, 'yyyy年M月d日') : suggestedDate ? placeholder : '未选择'}`} aria-haspopup="dialog" aria-expanded={isOpen} aria-controls={`${id}-calendar`} @@ -103,7 +106,7 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa className="input-apple flex min-h-[50px] items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60" > - {selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择日期'} + {selectedDate ? format(selectedDate, 'yyyy年M月d日') : placeholder}
- - + + +

留空按检测日期顺延一年,也可自行选择日期

diff --git a/lib/__tests__/certificate-report.test.ts b/lib/__tests__/certificate-report.test.ts index 4c77e7f..377a6fd 100644 --- a/lib/__tests__/certificate-report.test.ts +++ b/lib/__tests__/certificate-report.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_CERTIFICATE_REPORT_USAGE_RULES, getCertificateReportDisplayTitle, + getDefaultCertificateExpiryDate, isPublicCertificateReportPdf, parseCertificateDate, validateCertificateReportMetadata, @@ -55,8 +56,12 @@ describe('检测报告字段规则', () => { }) }) - test('到期时间独立必填,不自动计算,并拒绝早于检测日期的日期', () => { - for (const certificateExpiresOn of ['', undefined, '2028-02-30', '2028/09/23']) { + test('到期时间留空默认一年,手动填写时仍检查日期和先后顺序', () => { + for (const certificateExpiresOn of ['', undefined, null, ' ']) { + expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn })) + .toHaveProperty('value.certificateExpiresOn', new Date('2028-09-23T00:00:00.000Z')) + } + for (const certificateExpiresOn of ['2028-02-30', '2028/09/23', false, 0]) { expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn })).toEqual({ error: '请选择有效的证书到期时间' }) } expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-09-22' })) @@ -64,6 +69,24 @@ describe('检测报告字段规则', () => { expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-09-23' })) .toHaveProperty('value.certificateExpiresOn', new Date('2027-09-23T00:00:00.000Z')) expect(validateCertificateReportMetadata(metadata)).not.toHaveProperty('value.certificateUsageInstructions') + expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2030-09-23' })) + .toHaveProperty('value.certificateExpiresOn', new Date('2030-09-23T00:00:00.000Z')) + }) + + test.each([ + ['2026-05-14', '2027-05-14'], + ['2024-02-29', '2025-02-28'], + ['2023-02-28', '2024-02-28'], + ['2026-12-31', '2027-12-31'], + ])('检测日期 %s 顺延一个日历年为 %s', (testedOn, expiresOn) => { + expect(getDefaultCertificateExpiryDate(testedOn)?.toISOString().slice(0, 10)).toBe(expiresOn) + expect(getDefaultCertificateExpiryDate(new Date(`${testedOn}T00:00:00Z`))?.toISOString().slice(0, 10)).toBe(expiresOn) + }) + + test('缺少检测日期时不推测到期时间', () => { + expect(getDefaultCertificateExpiryDate(null)).toBeNull() + expect(getDefaultCertificateExpiryDate('2026-02-30')).toBeNull() + expect(getDefaultCertificateExpiryDate(new Date('invalid'))).toBeNull() }) test('平台分类只接受报告专用选项,整段预置说明保持为一个文本值', () => { diff --git a/lib/__tests__/material-edit-page.test.tsx b/lib/__tests__/material-edit-page.test.tsx index 192e575..043020b 100644 --- a/lib/__tests__/material-edit-page.test.tsx +++ b/lib/__tests__/material-edit-page.test.tsx @@ -177,7 +177,7 @@ describe('EditMaterialPage', () => { expect(mockBack).toHaveBeenCalledTimes(1) }) - test('检测报告编辑按成品和原材料切换字段,规则说明保留为一个输入框', async () => { + test.each([false, true])('检测报告编辑并保存日期,清空时恢复默认一年:%s', async (clearExpiry) => { const user = userEvent.setup() const report = { ...material, @@ -203,7 +203,7 @@ describe('EditMaterialPage', () => { const kindSelect = await screen.findByLabelText('报告类别 *') expect(screen.getByLabelText('SPU')).toHaveValue('SPU-001') expect(screen.getByLabelText('证书检测日期 *')).toHaveTextContent('2027年9月23日') - expect(screen.getByLabelText('证书到期时间 *')).toHaveTextContent('2028年9月23日') + expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('2028年9月23日') expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument() expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue('第一段\n第二段') expect(screen.getByLabelText('平台分类')).toHaveValue('品牌') @@ -211,13 +211,14 @@ describe('EditMaterialPage', () => { await user.selectOptions(kindSelect, '原材料') expect(screen.queryByLabelText('SPU')).not.toBeInTheDocument() expect(screen.getByLabelText('报告名称 / 检测对象 *')).toHaveValue('板材检测报告') - await user.click(screen.getByLabelText('证书到期时间 *')) - await user.click(screen.getByRole('button', { name: '2028年9月24日' })) + await user.click(screen.getByLabelText('证书到期时间(选填)')) + await user.click(screen.getByRole('button', { name: clearExpiry ? '清除' : '2028年9月24日' })) + if (clearExpiry) expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('默认 2028-09-23') fireEvent.submit(screen.getByLabelText('报告名称 / 检测对象 *').closest('form')!) await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true)) const [, init] = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init?.method === 'PUT')! expect(init.body.get('certificateTestedOn')).toBe('2027-09-23') - expect(init.body.get('certificateExpiresOn')).toBe('2028-09-24') + expect(init.body.get('certificateExpiresOn')).toBe(clearExpiry ? '' : '2028-09-24') expect(init.body.get('reportUsageRules')).toBe('第一段\n第二段') expect(init.body.has('certificateUsageInstructions')).toBe(false) }) diff --git a/lib/__tests__/material-edit-routes.test.ts b/lib/__tests__/material-edit-routes.test.ts index 5595615..3682c86 100644 --- a/lib/__tests__/material-edit-routes.test.ts +++ b/lib/__tests__/material-edit-routes.test.ts @@ -123,6 +123,22 @@ describe('material edit routes', () => { return formData } + test.each(['', '?intent=edit'])('报告详情推算未填到期时间,编辑保留空值以允许随检测日期计算 (%s)', async (query) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + id: 'material-1', userId: 'user-1', materialType: '证书-检测报告', + productModel: 'SPU-001', authorName: '测试作者', previewUrl: '', + categoryName: '通用', sceneName: '通用', + certificateTestedOn: new Date('2026-05-14T00:00:00Z'), certificateExpiresOn: null, + zipFileName: '', zipFilePath: '', zipFileSize: BigInt(0), + createdAt: new Date('2026-05-14T00:00:00Z'), assets: [], + }) + const response = await getMaterialById(new NextRequest(`http://localhost/api/materials/material-1${query}`), { params: { id: 'material-1' } }) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.certificateExpiresOn).toBe(query ? null : '2027-05-14T00:00:00.000Z') + expect(body.canEdit).toBe(true) + }) + function createPutRequest(url: string, formData: FormData) { return new NextRequest(url, { method: 'PUT', diff --git a/lib/__tests__/material-update.test.ts b/lib/__tests__/material-update.test.ts index 19e10ca..7b9978d 100644 --- a/lib/__tests__/material-update.test.ts +++ b/lib/__tests__/material-update.test.ts @@ -259,6 +259,30 @@ describe('material-update shared rules', () => { }) }) + test('清空到期时间后保存,服务端根据检测日期计算一年', async () => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ + ...baseMaterial, + materialType: '证书-检测报告', + certificateExpiresOn: new Date('2030-01-01T00:00:00Z'), + }) + ;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({ ...baseMaterial, ...data })) + const result = await updateMaterialWithSharedRules({ + materialId: 'material-1', operatorUserId: 'user-1', operatorRole: 'USER', + input: { + ...reportMetadataInput, + certificateTestedOn: '2024-02-29', + certificateExpiresOn: '', + productModel: 'SPU-001', productCategoryName: '板材', + materialType: '证书-检测报告', authorName: '测试作者', + }, + }) + expect('error' in result).toBe(false) + expect(prisma.material.update).toHaveBeenCalledWith({ where: { id: 'material-1' }, data: expect.objectContaining({ + certificateTestedOn: new Date('2024-02-29T00:00:00Z'), + certificateExpiresOn: new Date('2025-02-28T00:00:00Z'), + }) }) + }) + test('旧编辑调用未提交报告名称时应保留证书原值', async () => { const existingCertificate = { ...baseMaterial, diff --git a/lib/__tests__/materials-share-security.test.ts b/lib/__tests__/materials-share-security.test.ts index b4d5a34..3f4b555 100644 --- a/lib/__tests__/materials-share-security.test.ts +++ b/lib/__tests__/materials-share-security.test.ts @@ -125,6 +125,18 @@ describe('materials share security', () => { }) }) + test.each([null, new Date('2029-05-14T00:00:00.000Z')])('报告详情为未填到期日的记录计算一年,保留手动日期 (%s)', async (expiresOn) => { + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({ + materialType: '证书-检测报告', + certificateTestedOn: new Date('2026-05-14T00:00:00.000Z'), + certificateExpiresOn: expiresOn, + })) + const response = await GET(new NextRequest('https://sc.jiansun.vip/api/materials/share?id=material-1')) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.certificateExpiresOn).toBe(expiresOn?.toISOString() || '2027-05-14T00:00:00.000Z') + }) + test.each([null, { user: { id: 'admin-1' } }])('公开检测报告返回 PDF 和图片,保留源文件隔离 (%j)', async (session) => { ;(getServerSession as jest.Mock).mockResolvedValue(session) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'SYSTEM_ADMIN' }) diff --git a/lib/certificate-report.ts b/lib/certificate-report.ts index 8ee5be3..db9945f 100644 --- a/lib/certificate-report.ts +++ b/lib/certificate-report.ts @@ -73,12 +73,25 @@ export function parseCertificateDate(value: unknown): Date | null { : parsed } +export function getDefaultCertificateExpiryDate(testedOn: string | Date | null | undefined): Date | null { + const dateText = testedOn instanceof Date + ? (Number.isNaN(testedOn.getTime()) ? '' : testedOn.toISOString().slice(0, 10)) + : testedOn?.slice(0, 10) + const testedDate = parseCertificateDate(dateText) + if (!testedDate) return null + const expiresDate = new Date(testedDate) + expiresDate.setUTCFullYear(testedDate.getUTCFullYear() + 1) + // 闰年 2 月 29 日顺延到次年 2 月末,避免溢出到 3 月。 + if (expiresDate.getUTCMonth() !== testedDate.getUTCMonth()) expiresDate.setUTCDate(0) + return expiresDate +} + export function validateCertificateReportMetadata(input: { reportKind: unknown productModel: unknown reportTitle: unknown certificateTestedOn: unknown - certificateExpiresOn: unknown + certificateExpiresOn?: unknown certificateNumber: unknown reportUsageRules: unknown platformName: unknown @@ -105,7 +118,11 @@ export function validateCertificateReportMetadata(input: { } const certificateTestedOn = parseCertificateDate(input.certificateTestedOn) if (!certificateTestedOn) return { error: '请选择有效的证书检测日期' } - const certificateExpiresOn = parseCertificateDate(input.certificateExpiresOn) + const expiryIsEmpty = input.certificateExpiresOn == null + || (typeof input.certificateExpiresOn === 'string' && input.certificateExpiresOn.trim() === '') + const certificateExpiresOn = expiryIsEmpty + ? getDefaultCertificateExpiryDate(certificateTestedOn) + : parseCertificateDate(input.certificateExpiresOn) if (!certificateExpiresOn) return { error: '请选择有效的证书到期时间' } if (certificateExpiresOn < certificateTestedOn) return { error: '证书到期时间不能早于检测日期' } const certificateNumber = typeof input.certificateNumber === 'string' ? input.certificateNumber.trim() : ''