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/components/upload/CertificateReportUploader.test.tsx b/components/upload/CertificateReportUploader.test.tsx
index 6280d9a..545347e 100644
--- a/components/upload/CertificateReportUploader.test.tsx
+++ b/components/upload/CertificateReportUploader.test.tsx
@@ -5,7 +5,7 @@ import { DEFAULT_CERTIFICATE_REPORT_USAGE_RULES } from '@/lib/certificate-report
jest.mock('@/lib/chunk-upload', () => ({ chunkUpload: jest.fn() }))
-test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提交检测日期和到期时间', async (kind) => {
+test.each([['成品', true], ['成品', false], ['原材料', true], ['原材料', false]] as const)('%s上传说明与日期:手动到期时间 %s', async (kind, manualExpiry) => {
const originalFetch = global.fetch
jest.useFakeTimers().setSystemTime(new Date('2026-09-27T12:00:00'))
const { container } = render()
@@ -17,11 +17,11 @@ test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提
fireEvent.change(screen.getByLabelText('证书编号 *'), { target: { value: 'CMA-001' } })
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.click(screen.getByRole('button', { name: '2026年9月23日' }))
- fireEvent.click(screen.getByRole('button', { name: /证书到期时间/ }))
- fireEvent.click(screen.getByRole('button', { name: '选择年份和月份' }))
- fireEvent.click(screen.getByRole('button', { name: '下一年' }))
- fireEvent.click(screen.getByRole('button', { name: '9月' }))
- fireEvent.click(screen.getByRole('button', { name: '2027年9月23日' }))
+ expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('默认 2027-09-23')
+ if (manualExpiry) {
+ fireEvent.click(screen.getByRole('button', { name: /证书到期时间/ }))
+ fireEvent.click(screen.getByRole('button', { name: '2027年9月24日' }))
+ }
expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument()
expect(screen.getAllByRole('textbox').filter((input) => input.tagName === 'TEXTAREA')).toHaveLength(1)
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
@@ -35,7 +35,7 @@ test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提
const [url, init] = (global.fetch as jest.Mock).mock.calls[0]
expect(url).toBe('/api/materials/upload/certificate-report/init')
const payload = JSON.parse(init.body)
- expect(payload).toMatchObject({ reportKind: kind, certificateTestedOn: '2026-09-23', certificateExpiresOn: '2027-09-23', reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES })
+ expect(payload).toMatchObject({ reportKind: kind, certificateTestedOn: '2026-09-23', certificateExpiresOn: manualExpiry ? '2027-09-24' : '', reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES })
expect(payload).not.toHaveProperty('certificateUsageInstructions')
} finally {
jest.useRealTimers()
diff --git a/components/upload/CertificateReportUploader.tsx b/components/upload/CertificateReportUploader.tsx
index 2aff6cc..c1ec989 100644
--- a/components/upload/CertificateReportUploader.tsx
+++ b/components/upload/CertificateReportUploader.tsx
@@ -7,6 +7,7 @@ import {
CERTIFICATE_REPORT_PLATFORM_OPTIONS,
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
detectFileKindFromName,
+ getDefaultCertificateExpiryDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
@@ -42,6 +43,7 @@ export default function CertificateReportUploader({
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
+ const defaultExpiryDate = getDefaultCertificateExpiryDate(certificateTestedOn)?.toISOString().slice(0, 10) || ''
const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
const [productCategoryName, setProductCategoryName] = useState('')
const [platformName, setPlatformName] = useState('')
@@ -351,8 +353,9 @@ export default function CertificateReportUploader({
请按报告标注的检测日期选择
-
-
+
+
+
留空按检测日期顺延一年,也可自行选择日期
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() : ''