fix: use certificate test date for inspection reports
This commit is contained in:
parent
59e21e1b99
commit
0baa951fd9
@ -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
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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第二行',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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第二段')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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月' }))
|
||||
@ -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">
|
||||
@ -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` 仍通过;
|
||||
|
||||
@ -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: '请选择有效的证书检测日期',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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('品牌')
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user