fix: use certificate test date for inspection reports

This commit is contained in:
WuYongJie 2026-09-24 11:14:25 +08:00
parent 59e21e1b99
commit 0baa951fd9
23 changed files with 118 additions and 95 deletions

View File

@ -100,7 +100,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices/migration.sql" && \
test -f "/app/prisma/migrations/20260915_add_brand_knowledge_soft_delete/migration.sql" && \
test -f "/app/prisma/migrations/20260918_add_performance_conversion_sync/migration.sql" && \
test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql"
test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \
test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -234,6 +235,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_bra
COPY --from=builder --chown=root:root /app/prisma/migrations/20260915_add_brand_knowledge_soft_delete ./prisma/migrations/20260915_add_brand_knowledge_soft_delete
COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_performance_conversion_sync ./prisma/migrations/20260918_add_performance_conversion_sync
COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata
COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs

View File

@ -167,7 +167,7 @@ export async function GET(
productModel: true,
reportTitle: true,
reportKind: true,
certificateExpiresOn: true,
certificateTestedOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
reportUsageRules: true,
@ -249,7 +249,7 @@ export async function GET(
productModel: true,
reportTitle: true,
reportKind: true,
certificateExpiresOn: true,
certificateTestedOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
reportUsageRules: true,
@ -385,7 +385,7 @@ export async function GET(
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
reportKind: materialWithUser.reportKind || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
certificateTestedOn: materialWithUser.certificateTestedOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null,
reportUsageRules: materialWithUser.reportUsageRules || null,
@ -479,7 +479,7 @@ export async function GET(
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
reportKind: materialWithUser.reportKind || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
certificateTestedOn: materialWithUser.certificateTestedOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null,
reportUsageRules: materialWithUser.reportUsageRules || null,
@ -613,7 +613,7 @@ export async function PUT(
? { reportTitle: formData.get('reportTitle') as string | null }
: {}),
...(formData.has('reportKind') ? { reportKind: formData.get('reportKind') as string | null } : {}),
...(formData.has('certificateExpiresOn') ? { certificateExpiresOn: formData.get('certificateExpiresOn') as string | null } : {}),
...(formData.has('certificateTestedOn') ? { certificateTestedOn: formData.get('certificateTestedOn') as string | null } : {}),
...(formData.has('certificateNumber') ? { certificateNumber: formData.get('certificateNumber') as string | null } : {}),
...(formData.has('certificateUsageInstructions') ? { certificateUsageInstructions: formData.get('certificateUsageInstructions') as string | null } : {}),
...(formData.has('reportUsageRules') ? { reportUsageRules: formData.get('reportUsageRules') as string | null } : {}),

View File

@ -126,7 +126,7 @@ export async function GET(request: NextRequest) {
productModel: true,
reportTitle: true,
reportKind: true,
certificateExpiresOn: true,
certificateTestedOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
reportUsageRules: true,
@ -362,7 +362,7 @@ export async function GET(request: NextRequest) {
videoSourceUrl: canAccessSource ? material.videoSourceUrl : null,
reportTitle: (material as any).reportTitle || null,
reportKind: (material as any).reportKind || null,
certificateExpiresOn: (material as any).certificateExpiresOn || null,
certificateTestedOn: (material as any).certificateTestedOn || null,
certificateNumber: (material as any).certificateNumber || null,
certificateUsageInstructions: (material as any).certificateUsageInstructions || null,
reportUsageRules: (material as any).reportUsageRules || null,

View File

@ -46,7 +46,7 @@ export async function PUT(request: NextRequest) {
? { reportTitle: formData.get('reportTitle') as string | null }
: {}),
...(formData.has('reportKind') ? { reportKind: formData.get('reportKind') as string | null } : {}),
...(formData.has('certificateExpiresOn') ? { certificateExpiresOn: formData.get('certificateExpiresOn') as string | null } : {}),
...(formData.has('certificateTestedOn') ? { certificateTestedOn: formData.get('certificateTestedOn') as string | null } : {}),
...(formData.has('certificateNumber') ? { certificateNumber: formData.get('certificateNumber') as string | null } : {}),
...(formData.has('certificateUsageInstructions') ? { certificateUsageInstructions: formData.get('certificateUsageInstructions') as string | null } : {}),
...(formData.has('reportUsageRules') ? { reportUsageRules: formData.get('reportUsageRules') as string | null } : {}),

View File

@ -20,7 +20,7 @@ const input = {
reportKind: '原材料',
productCategoryName: '板材',
platformName: '品牌',
certificateExpiresOn: '2027-09-23',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整说明\n第二行',
@ -48,7 +48,7 @@ describe('检测报告初始化', () => {
expect(prisma.material.create).toHaveBeenCalledWith({
data: expect.objectContaining({
reportKind: '原材料',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整说明\n第二行',

View File

@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
reportKind: body.reportKind,
productModel,
reportTitle,
certificateExpiresOn: body.certificateExpiresOn,
certificateTestedOn: body.certificateTestedOn,
certificateNumber: body.certificateNumber,
certificateUsageInstructions: body.certificateUsageInstructions,
reportUsageRules: body.reportUsageRules,

View File

@ -7,7 +7,7 @@ import Header from '@/components/Header'
import SearchableUserSelect from '@/components/SearchableUserSelect'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import CertificateExpiryDatePicker from '@/components/upload/CertificateExpiryDatePicker'
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
import {
canManuallyReplacePreview,
getSelectableMaterialTypeOptions,
@ -36,7 +36,7 @@ interface Material {
productModel: string
reportTitle?: string | null
reportKind?: string | null
certificateExpiresOn?: string | Date | null
certificateTestedOn?: string | Date | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
reportUsageRules?: string | null
@ -109,7 +109,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [productModel, setProductModel] = useState('')
const [reportTitle, setReportTitle] = useState('')
const [reportKind, setReportKind] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('')
const [reportUsageRules, setReportUsageRules] = useState('')
@ -161,7 +161,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
productModel,
reportTitle,
reportKind,
certificateExpiresOn,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
@ -189,7 +189,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
renderer,
reportTitle,
reportKind,
certificateExpiresOn,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
@ -304,7 +304,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setProductModel(data.productModel || '')
setReportTitle(data.reportTitle || '')
setReportKind(data.reportKind || '')
setCertificateExpiresOn(data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '')
setCertificateTestedOn(data.certificateTestedOn ? String(data.certificateTestedOn).slice(0, 10) : '')
setCertificateNumber(data.certificateNumber || '')
setCertificateUsageInstructions(data.certificateUsageInstructions || '')
setReportUsageRules(data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''))
@ -347,7 +347,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
productModel: data.productModel || '',
reportTitle: data.reportTitle || '',
reportKind: data.reportKind || '',
certificateExpiresOn: data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '',
certificateTestedOn: data.certificateTestedOn ? String(data.certificateTestedOn).slice(0, 10) : '',
certificateNumber: data.certificateNumber || '',
certificateUsageInstructions: data.certificateUsageInstructions || '',
reportUsageRules: data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''),
@ -524,7 +524,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
reportKind,
productModel,
reportTitle,
certificateExpiresOn,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
@ -590,7 +590,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
formData.append('reportTitle', reportTitle)
if (isCertificateReport) {
formData.append('reportKind', reportKind)
formData.append('certificateExpiresOn', certificateExpiresOn)
formData.append('certificateTestedOn', certificateTestedOn)
formData.append('certificateNumber', certificateNumber)
formData.append('certificateUsageInstructions', certificateUsageInstructions)
formData.append('reportUsageRules', reportUsageRules)
@ -1064,9 +1064,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<h2 className="text-lg font-semibold text-gray-900">证书信息</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor={`edit-material-${params.id}-certificate-expiry`} className="mb-2 block text-sm font-medium text-gray-700">证书到期日 *</label>
<CertificateExpiryDatePicker id={`edit-material-${params.id}-certificate-expiry`} value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={submitting} />
<p className="mt-1 text-xs text-gray-500">按证书标注日期选择,不自动推算有效期</p>
<label htmlFor={`edit-material-${params.id}-certificate-tested-on`} className="mb-2 block text-sm font-medium text-gray-700">证书检测日期 *</label>
<CertificateTestDatePicker id={`edit-material-${params.id}-certificate-tested-on`} value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={submitting} />
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-certificate-number`} className="mb-2 block text-sm font-medium text-gray-700">证书编号 *</label>

View File

@ -248,7 +248,8 @@ describe('certificate report share page', () => {
productModel: '',
reportTitle: '板材检测报告',
reportKind: '原材料',
certificateExpiresOn: '2027-09-23T00:00:00.000Z',
certificateExpiresOn: '2028-09-23T00:00:00.000Z',
certificateTestedOn: '2027-09-23T00:00:00.000Z',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对主体',
reportUsageRules: '第一段\n第二段',
@ -269,6 +270,7 @@ describe('certificate report share page', () => {
render(<SharePage />)
expect(await screen.findByText('CMA-2026-001')).toBeInTheDocument()
expect(screen.getByText('2027-09-23')).toBeInTheDocument()
expect(screen.queryByText('2028-09-23')).not.toBeInTheDocument()
expect(screen.getByText('使用前核对主体')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent)
.toBe('第一段\n第二段')

View File

@ -39,7 +39,7 @@ interface Material {
productModel: string
reportTitle?: string | null
reportKind?: string | null
certificateExpiresOn?: string | null
certificateTestedOn?: string | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
reportUsageRules?: string | null
@ -2082,8 +2082,8 @@ export default function SharePage() {
<p className="text-[15px] font-medium text-[#1a1c1d]">{material.platformName || '未填写'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书到期日</p>
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '待补充'}</p>
<p className="mb-1 text-[12px] text-[#86898e]">证书检测日期</p>
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateTestedOn ? String(material.certificateTestedOn).slice(0, 10) : '待补充'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书编号</p>

View File

@ -9,7 +9,7 @@ import {
detectFileKindFromName,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
import CertificateExpiryDatePicker from '@/components/upload/CertificateExpiryDatePicker'
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
interface CategoryItem {
id: string
@ -37,7 +37,7 @@ export default function CertificateReportUploader({
const [productModel, setProductModel] = useState('')
const [reportTitle, setReportTitle] = useState('')
const [reportKind, setReportKind] = useState<string>(CERTIFICATE_REPORT_KIND.FINISHED)
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('')
const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
@ -122,7 +122,7 @@ export default function CertificateReportUploader({
reportKind,
productModel: submittedProductModel,
reportTitle,
certificateExpiresOn,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
@ -156,7 +156,7 @@ export default function CertificateReportUploader({
productModel: submittedProductModel,
reportTitle: reportTitle.trim(),
reportKind,
certificateExpiresOn,
certificateTestedOn,
certificateNumber: certificateNumber.trim(),
certificateUsageInstructions: certificateUsageInstructions.trim(),
reportUsageRules: reportUsageRules.trim(),
@ -343,9 +343,9 @@ export default function CertificateReportUploader({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label htmlFor="certificate-report-expiry" className="mb-2 block text-sm font-medium text-gray-700">证书到期日 *</label>
<CertificateExpiryDatePicker id="certificate-report-expiry" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={uploading} />
<p className="mt-1 text-xs text-gray-500">请按证书标注日期选择,不自动推算有效期</p>
<label htmlFor="certificate-report-tested-on" className="mb-2 block text-sm font-medium text-gray-700">证书检测日期 *</label>
<CertificateTestDatePicker id="certificate-report-tested-on" value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={uploading} />
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
</div>
<div>
<label htmlFor="certificate-report-number" className="mb-2 block text-sm font-medium text-gray-700">证书编号 *</label>

View File

@ -1,58 +1,58 @@
import { fireEvent, render, screen } from '@testing-library/react'
import CertificateExpiryDatePicker from '@/components/upload/CertificateExpiryDatePicker'
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
describe('CertificateExpiryDatePicker', () => {
describe('CertificateTestDatePicker', () => {
test('日历图标可反复展开和收起,Escape 也会关闭', () => {
render(<CertificateExpiryDatePicker id="expiry" value="2026-09-23" onChange={jest.fn()} />)
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={jest.fn()} />)
const trigger = screen.getByRole('button', { name: /证书到期日/ })
const trigger = screen.getByRole('button', { name: /证书检测日期/ })
const icon = trigger.querySelector('svg')
expect(icon).not.toBeNull()
fireEvent.click(icon!)
expect(screen.getByRole('dialog', { name: '选择证书到期日' })).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: '选择证书检测日期' })).toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(icon!)
expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(icon!)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
test('按日历选择日期时返回统一格式,并关闭日历', () => {
const onChange = jest.fn()
render(<CertificateExpiryDatePicker id="expiry" value="2026-09-23" onChange={onChange} />)
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /证书到期日/ }))
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
expect(screen.getByText('2026年9月')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '下个月' }))
expect(screen.getByText('2026年10月')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '2026年10月5日' }))
expect(onChange).toHaveBeenCalledWith('2026-10-05')
expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument()
})
test('点击日历外部会关闭,不改动已选择的日期', () => {
const onChange = jest.fn()
render(<CertificateExpiryDatePicker id="expiry" value="2026-09-23" onChange={onChange} />)
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /证书到期日/ }))
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
test('可以直接切换年份和月份选择较远的到期日', () => {
test('可以直接切换年份和月份选择较远的检测日期', () => {
const onChange = jest.fn()
render(<CertificateExpiryDatePicker id="expiry" value="2026-09-23" onChange={onChange} />)
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /证书到期日/ }))
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.click(screen.getByRole('button', { name: '选择年份和月份' }))
fireEvent.click(screen.getByRole('button', { name: '下一年' }))
fireEvent.click(screen.getByRole('button', { name: '12月' }))

View File

@ -12,14 +12,14 @@ function parseDate(value: string): Date | null {
return isValid(date) && format(date, 'yyyy-MM-dd') === value ? date : null
}
interface CertificateExpiryDatePickerProps {
interface CertificateTestDatePickerProps {
id: string
value: string
onChange: (value: string) => void
disabled?: boolean
}
export default function CertificateExpiryDatePicker({ id, value, onChange, disabled = false }: CertificateExpiryDatePickerProps) {
export default function CertificateTestDatePicker({ id, value, onChange, disabled = false }: CertificateTestDatePickerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const calendarRef = useRef<HTMLDivElement>(null)
@ -91,7 +91,7 @@ export default function CertificateExpiryDatePicker({ id, value, onChange, disab
id={id}
type="button"
disabled={disabled}
aria-label={`证书到期日,${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`}
aria-label={`证书检测日期,${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={`${id}-calendar`}
@ -102,7 +102,7 @@ export default function CertificateExpiryDatePicker({ id, value, onChange, disab
className="input-apple flex min-h-[50px] items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
<span className={selectedDate ? 'text-[#1a1c1d]' : 'text-gray-500'}>
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择到期日期'}
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择检测日期'}
</span>
<CalendarDays className="h-[18px] w-[18px] shrink-0 text-[#5c6470]" aria-hidden="true" />
</button>
@ -112,7 +112,7 @@ export default function CertificateExpiryDatePicker({ id, value, onChange, disab
ref={calendarRef}
id={`${id}-calendar`}
role="dialog"
aria-label="选择证书到期日"
aria-label="选择证书检测日期"
className="absolute left-1/2 top-full z-40 mt-2 w-[320px] max-w-[calc(100vw-3rem)] -translate-x-1/2 rounded-[12px] border border-[#e7e8ea] bg-white p-3 text-[#1a1c1d] shadow-[0_12px_28px_rgba(26,28,29,0.10)]"
>
<div className="mb-3 flex items-center justify-between gap-2">

View File

@ -2,7 +2,7 @@
## 结论
当前应用发布工具验证二十七份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
当前应用发布工具验证二十八份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
目标 migration:
@ -33,9 +33,12 @@
- `20260915_add_brand_knowledge_soft_delete`
- `20260918_add_performance_conversion_sync`
- `20260923_add_certificate_report_metadata`
- `20260924_add_certificate_tested_on`
`20260923_add_certificate_report_metadata` 仅为 `materials` 增加五个可空字段:报告类别、证书到期日、证书编号、证书使用说明和检测报告使用规则说明。不回填或修改已有素材行;旧报告需要编辑时再补录类别和证书信息。
`20260924_add_certificate_tested_on` 增加独立的可空“证书检测日期”字段。原“到期日”保留为历史数据,但不再作为检测日期展示或写入;由于两者语义不同,不自动回填。已上传的报告需根据原件补录检测日期。
`20260918_add_performance_conversion_sync` 只新增业绩转化经营数据快照表和每日增量同步状态表。首次同步按已完结美术流程的商品 ID 补齐六个月监控区间,后续每日重刷最近两天;租约索引用于防止多实例重复执行,不改写流程快照或上游报表库。
`20260915_add_brand_knowledge_soft_delete` 只为品牌知识增加可空删除时间和查询索引。删除操作仅写入删除时间,主文件和预览图继续保留;列表、详情、下载、预览、点赞和收藏均排除已删除内容。
@ -61,7 +64,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、二十七条目标 migration 和迁移后审计;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、二十八条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
@ -385,7 +388,7 @@ docker run --rm \
"$MIGRATOR_IMAGE" --apply
```
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对二十七条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告五个可空字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对二十八条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@ -401,7 +404,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
只有以下全部成立,才进入应用首次安全上线步骤:
- `applied.json` 的 `verified=true`;
- 二十七条 target migration 均 `completed=true`,source/database checksum 一致;
- 二十八条 target migration 均 `completed=true`,source/database checksum 一致;
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
- 再次运行 `--audit` 仍通过;

View File

@ -1,7 +1,7 @@
import {
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
getCertificateReportDisplayTitle,
parseCertificateExpiryDate,
parseCertificateTestDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
@ -9,7 +9,7 @@ const metadata = {
reportKind: '成品',
productModel: 'SPU-001',
reportTitle: '',
certificateExpiresOn: '2027-09-23',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
@ -35,12 +35,12 @@ describe('检测报告字段规则', () => {
})).toBe('板材检测报告')
})
test('到期日必须是真实的 YYYY-MM-DD 日期', () => {
expect(parseCertificateExpiryDate('2027-02-28')?.toISOString().slice(0, 10)).toBe('2027-02-28')
expect(parseCertificateExpiryDate('2027-02-30')).toBeNull()
expect(parseCertificateExpiryDate('2027/02/28')).toBeNull()
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-02-30' })).toEqual({
error: '请选择有效的证书到期日',
test('检测日期必须是真实的 YYYY-MM-DD 日期', () => {
expect(parseCertificateTestDate('2027-02-28')?.toISOString().slice(0, 10)).toBe('2027-02-28')
expect(parseCertificateTestDate('2027-02-30')).toBeNull()
expect(parseCertificateTestDate('2027/02/28')).toBeNull()
expect(validateCertificateReportMetadata({ ...metadata, certificateTestedOn: '2027-02-30' })).toEqual({
error: '请选择有效的证书检测日期',
})
})

View File

@ -186,7 +186,8 @@ describe('EditMaterialPage', () => {
platformName: '品牌',
reportKind: '成品',
reportTitle: '板材检测报告',
certificateExpiresOn: '2027-09-23T00:00:00.000Z',
certificateExpiresOn: '2028-09-23T00:00:00.000Z',
certificateTestedOn: '2027-09-23T00:00:00.000Z',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对主体',
reportUsageRules: '第一段\n第二段',
@ -201,7 +202,8 @@ 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('2027年9月23日')
expect(screen.queryByText('2028年9月23日')).not.toBeInTheDocument()
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue('第一段\n第二段')
expect(screen.getByLabelText('平台分类')).toHaveValue('品牌')

View File

@ -61,7 +61,7 @@ jest.mock('@/lib/material-authorization', () => ({
describe('material-update shared rules', () => {
const reportMetadataInput = {
reportKind: '成品',
certificateExpiresOn: '2027-09-23',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
@ -248,7 +248,7 @@ describe('material-update shared rules', () => {
productModel: '',
reportTitle: 'E0级板材检测报告',
reportKind: '成品',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
@ -269,7 +269,7 @@ describe('material-update shared rules', () => {
productModel: '',
reportTitle: '原检测报告',
reportKind: '成品',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
@ -382,16 +382,17 @@ describe('material-update shared rules', () => {
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('历史检测报告未补类别和证书字段时拒绝保存,原值不被自动归为成品', async () => {
test('历史到期日不得被当作检测日期,缺少检测日期时拒绝保存', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
reportTitle: '历史报告',
reportKind: null,
certificateExpiresOn: null,
certificateNumber: null,
certificateUsageInstructions: null,
reportUsageRules: null,
reportKind: '成品',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateTestedOn: null,
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
})
const result = await updateMaterialWithSharedRules({
@ -407,7 +408,7 @@ describe('material-update shared rules', () => {
},
})
expect(result).toEqual({ error: '请选择报告类别(成品或原材料)', status: 400 })
expect(result).toEqual({ error: '请选择有效的证书检测日期', status: 400 })
expect(prisma.material.update).not.toHaveBeenCalled()
})

View File

@ -46,7 +46,7 @@ export function normalizeReportTitle(value: string | null | undefined): string |
return normalized || null
}
export function parseCertificateExpiryDate(value: unknown): Date | null {
export function parseCertificateTestDate(value: unknown): Date | null {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const parsed = new Date(`${value}T00:00:00.000Z`)
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value
@ -58,7 +58,7 @@ export function validateCertificateReportMetadata(input: {
reportKind: unknown
productModel: unknown
reportTitle: unknown
certificateExpiresOn: unknown
certificateTestedOn: unknown
certificateNumber: unknown
certificateUsageInstructions: unknown
reportUsageRules: unknown
@ -67,7 +67,7 @@ export function validateCertificateReportMetadata(input: {
| { error: string }
| { value: {
reportKind: CertificateReportKind
certificateExpiresOn: Date
certificateTestedOn: Date
certificateNumber: string
certificateUsageInstructions: string
reportUsageRules: string
@ -84,8 +84,8 @@ export function validateCertificateReportMetadata(input: {
if (reportKind === CERTIFICATE_REPORT_KIND.FINISHED && !productModel && !reportTitle) {
return { error: 'SPU 和 报告名称/检测对象 至少填写一个' }
}
const certificateExpiresOn = parseCertificateExpiryDate(input.certificateExpiresOn)
if (!certificateExpiresOn) return { error: '请选择有效的证书到期日' }
const certificateTestedOn = parseCertificateTestDate(input.certificateTestedOn)
if (!certificateTestedOn) return { error: '请选择有效的证书检测日期' }
const certificateNumber = typeof input.certificateNumber === 'string' ? input.certificateNumber.trim() : ''
if (!certificateNumber || certificateNumber.length > 120) {
return { error: '请填写证书编号(不超过120字)' }
@ -109,7 +109,7 @@ export function validateCertificateReportMetadata(input: {
return {
value: {
reportKind,
certificateExpiresOn,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,

View File

@ -41,7 +41,7 @@ export interface MaterialUpdateInput {
productModel: string
reportTitle?: string | null
reportKind?: string | null
certificateExpiresOn?: string | null
certificateTestedOn?: string | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
reportUsageRules?: string | null
@ -194,7 +194,7 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
const material = editable.material as any
const hasReportTitleField = Object.prototype.hasOwnProperty.call(input, 'reportTitle')
const hasReportKindField = Object.prototype.hasOwnProperty.call(input, 'reportKind')
const hasCertificateExpiresOnField = Object.prototype.hasOwnProperty.call(input, 'certificateExpiresOn')
const hasCertificateTestedOnField = Object.prototype.hasOwnProperty.call(input, 'certificateTestedOn')
const hasCertificateNumberField = Object.prototype.hasOwnProperty.call(input, 'certificateNumber')
const hasCertificateUsageInstructionsField = Object.prototype.hasOwnProperty.call(input, 'certificateUsageInstructions')
const hasReportUsageRulesField = Object.prototype.hasOwnProperty.call(input, 'reportUsageRules')
@ -233,9 +233,9 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
reportKind: hasReportKindField ? input.reportKind : material.reportKind,
productModel,
reportTitle: effectiveReportTitle,
certificateExpiresOn: hasCertificateExpiresOnField
? input.certificateExpiresOn
: material.certificateExpiresOn?.toISOString().slice(0, 10),
certificateTestedOn: hasCertificateTestedOnField
? input.certificateTestedOn
: material.certificateTestedOn?.toISOString().slice(0, 10),
certificateNumber: hasCertificateNumberField ? input.certificateNumber : material.certificateNumber,
certificateUsageInstructions: hasCertificateUsageInstructionsField
? input.certificateUsageInstructions
@ -387,7 +387,7 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
: productModel,
reportTitle: isCertificateReportMaterialType(materialType) ? effectiveReportTitle : null,
reportKind: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportKind : null,
certificateExpiresOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateExpiresOn : null,
certificateTestedOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateTestedOn : null,
certificateNumber: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateNumber : null,
certificateUsageInstructions: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateUsageInstructions : null,
reportUsageRules: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportUsageRules : null,

View File

@ -0,0 +1,3 @@
-- Preserve previously stored expiry dates. Detection dates must be entered from each report.
ALTER TABLE "materials"
ADD COLUMN "certificate_tested_on" DATE;

View File

@ -77,6 +77,7 @@ model Material {
reportTitle String? @map("report_title")
reportKind String? @map("report_kind")
certificateExpiresOn DateTime? @db.Date @map("certificate_expires_on")
certificateTestedOn DateTime? @db.Date @map("certificate_tested_on")
certificateNumber String? @map("certificate_number")
certificateUsageInstructions String? @map("certificate_usage_instructions")
reportUsageRules String? @map("report_usage_rules")

View File

@ -1520,6 +1520,7 @@ const expectedMigrationNames = new Set([
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
'20260923_add_certificate_report_metadata',
'20260924_add_certificate_tested_on',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)

View File

@ -36,6 +36,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
'20260923_add_certificate_report_metadata',
'20260924_add_certificate_tested_on',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -345,7 +346,7 @@ async function queryEvidence(prisma, localMigrations) {
FROM information_schema.columns
WHERE table_schema = current_schema()
AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at'))
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules'))
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_tested_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules'))
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
@ -497,6 +498,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
requireColumn('materials', 'uploadId', 'text', 'YES')
requireColumn('materials', 'report_kind', 'text', 'YES')
requireColumn('materials', 'certificate_expires_on', 'date', 'YES')
requireColumn('materials', 'certificate_tested_on', 'date', 'YES')
requireColumn('materials', 'certificate_number', 'text', 'YES')
requireColumn('materials', 'certificate_usage_instructions', 'text', 'YES')
requireColumn('materials', 'report_usage_rules', 'text', 'YES')

View File

@ -352,6 +352,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260915_add_brand_knowledge_soft_delete', { checksum: 'y' }],
['20260918_add_performance_conversion_sync', { checksum: 'z' }],
['20260923_add_certificate_report_metadata', { checksum: 'aa' }],
['20260924_add_certificate_tested_on', { checksum: 'ab' }],
])
const evidence = {
migrationRows: [
@ -382,6 +383,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ migration_name: '20260915_add_brand_knowledge_soft_delete', checksum: 'y', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260918_add_performance_conversion_sync', checksum: 'z', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260923_add_certificate_report_metadata', checksum: 'aa', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260924_add_certificate_tested_on', checksum: 'ab', finished_at: 'now', rolled_back_at: null },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
@ -389,6 +391,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ table_name: 'materials', column_name: 'uploadId', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'report_kind', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'certificate_expires_on', data_type: 'date', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'certificate_tested_on', data_type: 'date', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'certificate_number', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'certificate_usage_instructions', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'report_usage_rules', data_type: 'text', is_nullable: 'YES' },
@ -856,6 +859,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
'20260923_add_certificate_report_metadata',
'20260924_add_certificate_tested_on',
]) {
assert.match(dockerfile, new RegExp(migrationName))
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
@ -887,6 +891,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.ok(sop.includes('`20260918_add_performance_conversion_sync`'))
assert.match(deploymentSource, /'20260923_add_certificate_report_metadata'/)
assert.ok(sop.includes('`20260923_add_certificate_report_metadata`'))
assert.match(deploymentSource, /'20260924_add_certificate_tested_on'/)
assert.ok(sop.includes('`20260924_add_certificate_tested_on`'))
const indexesQueryStart = migratorSource.indexOf('const indexes = await prisma.$queryRawUnsafe(')
const indexesQueryEnd = migratorSource.indexOf('const constraints = await prisma.$queryRawUnsafe(', indexesQueryStart)
assert.ok(indexesQueryStart >= 0)
@ -970,8 +976,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
assert.match(sop, /二十七份目标 migration/)
assert.match(sop, /二十七条目标 migration/)
assert.match(sop, /二十八份目标 migration/)
assert.match(sop, /二十八条目标 migration/)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)