feat: add certificate report metadata and report kinds

This commit is contained in:
WuYongJie 2026-09-23 20:27:17 +08:00
parent 231db310bb
commit 8dc5ffb71b
23 changed files with 797 additions and 62 deletions

View File

@ -99,7 +99,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" && \
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/20260918_add_performance_conversion_sync/migration.sql" && \
test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -232,6 +233,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_bra
COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices ./prisma/migrations/20260914_expand_brand_knowledge_preview_slices
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=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

@ -166,6 +166,11 @@ export async function GET(
userId: true,
productModel: true,
reportTitle: true,
reportKind: true,
certificateExpiresOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
reportUsageRules: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
@ -242,7 +247,12 @@ export async function GET(
id: true,
userId: true,
productModel: true,
reportTitle: true,
reportTitle: true,
reportKind: true,
certificateExpiresOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
reportUsageRules: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
@ -373,7 +383,12 @@ export async function GET(
id: materialWithUser.id,
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
reportTitle: materialWithUser.reportTitle || null,
reportKind: materialWithUser.reportKind || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null,
reportUsageRules: materialWithUser.reportUsageRules || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
@ -463,6 +478,11 @@ export async function GET(
userId: materialWithUser.userId,
productModel: materialWithUser.productModel,
reportTitle: materialWithUser.reportTitle || null,
reportKind: materialWithUser.reportKind || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null,
reportUsageRules: materialWithUser.reportUsageRules || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
platformName: materialWithUser.platformName || '',
@ -592,6 +612,11 @@ export async function PUT(
...(formData.has('reportTitle')
? { 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('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 } : {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,
platformName: formData.get('platformName') as string | null,

View File

@ -108,6 +108,7 @@ export async function GET(request: NextRequest) {
previewUrl: true,
productModel: true,
reportTitle: true,
reportKind: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
@ -355,6 +356,7 @@ export async function GET(request: NextRequest) {
previewUrl: true,
productModel: true,
reportTitle: true,
reportKind: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,

View File

@ -125,6 +125,11 @@ export async function GET(request: NextRequest) {
previewUrl: true,
productModel: true,
reportTitle: true,
reportKind: true,
certificateExpiresOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
reportUsageRules: true,
liveDate: true,
liveHost: true,
liveHostUserId: true,
@ -356,6 +361,11 @@ export async function GET(request: NextRequest) {
zipFilePath: '',
videoSourceUrl: canAccessSource ? material.videoSourceUrl : null,
reportTitle: (material as any).reportTitle || null,
reportKind: (material as any).reportKind || null,
certificateExpiresOn: (material as any).certificateExpiresOn || null,
certificateNumber: (material as any).certificateNumber || null,
certificateUsageInstructions: (material as any).certificateUsageInstructions || null,
reportUsageRules: (material as any).reportUsageRules || null,
isAiGenerated: Boolean((material as any).isAiGenerated),
taskDetailSourceType: (material as any).taskDetailSourceType || null,
taskDetailDemandType: (material as any).taskDetailDemandType || null,

View File

@ -45,6 +45,11 @@ export async function PUT(request: NextRequest) {
...(formData.has('reportTitle')
? { 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('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 } : {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,
platformName: formData.get('platformName') as string | null,

View File

@ -0,0 +1,65 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { POST } from './route'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findUnique: jest.fn() },
material: { create: jest.fn() },
},
}))
const input = {
productModel: '',
reportTitle: '板材检测报告',
reportKind: '原材料',
productCategoryName: '板材',
platformName: '品牌',
certificateExpiresOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整说明\n第二行',
}
function request(body: unknown) {
return new NextRequest('http://localhost/api/materials/upload/certificate-report/init', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('检测报告初始化', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ username: '上传人' })
;(prisma.material.create as jest.Mock).mockResolvedValue({ id: 'report-1' })
})
test('原材料的类别、到期日、编号和完整说明保存到同一份报告', async () => {
const response = await POST(request(input))
expect(response.status).toBe(200)
expect(prisma.material.create).toHaveBeenCalledWith({
data: expect.objectContaining({
reportKind: '原材料',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整说明\n第二行',
platformName: '品牌',
}),
})
})
test('原材料没有报告名称时拒绝创建素材壳', async () => {
const response = await POST(request({ ...input, reportTitle: '' }))
expect(response.status).toBe(400)
expect(prisma.material.create).not.toHaveBeenCalled()
})
})

View File

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { CERTIFICATE_REPORT_MATERIAL_TYPE, normalizeReportTitle } from '@/lib/certificate-report'
import { CERTIFICATE_REPORT_MATERIAL_TYPE, normalizeReportTitle, validateCertificateReportMetadata } from '@/lib/certificate-report'
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'
export async function POST(request: NextRequest) {
@ -17,9 +17,18 @@ export async function POST(request: NextRequest) {
const reportTitle = normalizeReportTitle(body.reportTitle)
const productCategoryName = String(body.productCategoryName || '').trim()
const platformName = String(body.platformName || '').trim()
if (!productModel && !reportTitle) {
return NextResponse.json({ error: 'SPU 和 报告名称/检测对象 至少填写一个' }, { status: 400 })
const metadata = validateCertificateReportMetadata({
reportKind: body.reportKind,
productModel,
reportTitle,
certificateExpiresOn: body.certificateExpiresOn,
certificateNumber: body.certificateNumber,
certificateUsageInstructions: body.certificateUsageInstructions,
reportUsageRules: body.reportUsageRules,
platformName,
})
if ('error' in metadata) {
return NextResponse.json({ error: metadata.error }, { status: 400 })
}
if (!productCategoryName) {
@ -40,6 +49,7 @@ export async function POST(request: NextRequest) {
materialType: CERTIFICATE_REPORT_MATERIAL_TYPE,
productModel,
reportTitle,
...metadata.value,
categoryName: '通用',
sceneName: '通用',
platformName,

View File

@ -18,7 +18,13 @@ import {
requiresProductCategoryName,
requiresRenderer,
} from '@/lib/material-validation'
import { isCertificateReportMaterialType } from '@/lib/certificate-report'
import {
CERTIFICATE_REPORT_KIND,
CERTIFICATE_REPORT_PLATFORM_OPTIONS,
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
isCertificateReportMaterialType,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type'
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
import { parseFetchError } from '@/lib/error-handler'
@ -28,6 +34,11 @@ interface Material {
userId: string
productModel: string
reportTitle?: string | null
reportKind?: string | null
certificateExpiresOn?: string | Date | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
reportUsageRules?: string | null
liveDate?: Date | string | null
liveHost?: string | null
liveHostUserId?: string | null
@ -96,6 +107,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [previewUrl, setPreviewUrl] = useState('')
const [productModel, setProductModel] = useState('')
const [reportTitle, setReportTitle] = useState('')
const [reportKind, setReportKind] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('')
const [reportUsageRules, setReportUsageRules] = useState('')
const [liveDate, setLiveDate] = useState('')
const [liveHost, setLiveHost] = useState('')
const [liveHostUserId, setLiveHostUserId] = useState('')
@ -143,6 +159,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
: null,
productModel,
reportTitle,
reportKind,
certificateExpiresOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
categoryName,
sceneName,
platformName,
@ -166,6 +187,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
productModel,
renderer,
reportTitle,
reportKind,
certificateExpiresOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
sceneName,
taskDetailRequesterUserId,
taskDetailSourceType,
@ -175,7 +201,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const hasUnsavedChanges =
Boolean(material && initialFormSignature) &&
currentFormSignature !== initialFormSignature
const hasReferenceFailure = referenceFailures.length > 0
const hasReferenceFailure = referenceFailures.some((key) => key !== 'platforms' || !isCertificateReport)
const loadReferenceData = useCallback(async (
sourceMaterial: Material,
@ -276,6 +302,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setPreviewUrl(data.previewUrl || '')
setProductModel(data.productModel || '')
setReportTitle(data.reportTitle || '')
setReportKind(data.reportKind || '')
setCertificateExpiresOn(data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '')
setCertificateNumber(data.certificateNumber || '')
setCertificateUsageInstructions(data.certificateUsageInstructions || '')
setReportUsageRules(data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''))
setLiveDate(data.liveDate ? String(data.liveDate).slice(0, 10) : '')
setLiveHost(data.liveHost || '')
setLiveHostUserId(data.liveHostUserId || '')
@ -314,6 +345,11 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
previewImage: null,
productModel: data.productModel || '',
reportTitle: data.reportTitle || '',
reportKind: data.reportKind || '',
certificateExpiresOn: data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '',
certificateNumber: data.certificateNumber || '',
certificateUsageInstructions: data.certificateUsageInstructions || '',
reportUsageRules: data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''),
categoryName: data.categoryName || '',
sceneName: data.sceneName || '',
platformName: data.platformName || '',
@ -483,8 +519,22 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
}
if (isCertificateReport) {
if (!productModel.trim() && !reportTitle.trim()) {
setError('SPU 和报告名称 / 检测对象至少填写一个')
const metadata = validateCertificateReportMetadata({
reportKind,
productModel,
reportTitle,
certificateExpiresOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
platformName,
}, material?.materialType === materialType ? material?.platformName || '' : '')
if ('error' in metadata) {
setError(metadata.error)
return
}
if (!productCategoryName) {
setError('请选择品类')
return
}
} else if (!productModel.trim()) {
@ -535,8 +585,15 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
try {
const formData = new FormData()
formData.append('productModel', productModel)
formData.append('productModel', isCertificateReport && reportKind === CERTIFICATE_REPORT_KIND.RAW ? '' : productModel)
formData.append('reportTitle', reportTitle)
if (isCertificateReport) {
formData.append('reportKind', reportKind)
formData.append('certificateExpiresOn', certificateExpiresOn)
formData.append('certificateNumber', certificateNumber)
formData.append('certificateUsageInstructions', certificateUsageInstructions)
formData.append('reportUsageRules', reportUsageRules)
}
if (materialType === '直播切片') {
formData.append('liveDate', liveDate)
formData.append('liveHost', liveHost)
@ -738,7 +795,19 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
</div>
)}
<div>
{isCertificateReport && (
<div>
<label htmlFor={`edit-material-${params.id}-report-kind`} className="mb-2 block text-sm font-medium text-gray-700">报告类别 *</label>
<select id={`edit-material-${params.id}-report-kind`} value={reportKind} onChange={(event) => setReportKind(event.target.value)} className="input-apple" required>
<option value="">请选择报告类别</option>
<option value={CERTIFICATE_REPORT_KIND.FINISHED}>成品</option>
<option value={CERTIFICATE_REPORT_KIND.RAW}>原材料</option>
</select>
{!reportKind && <p className="mt-1 text-xs text-gray-500">历史报告需确认类别后再保存</p>}
</div>
)}
{!(isCertificateReport && reportKind === CERTIFICATE_REPORT_KIND.RAW) && <div>
<label
htmlFor={`edit-material-${params.id}-product-model`}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
@ -753,7 +822,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
className="input-apple"
required={!isCertificateReport}
/>
</div>
</div>}
{materialType === '直播切片' && (
<div className="space-y-4">
@ -803,7 +872,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
htmlFor={`edit-material-${params.id}-report-title`}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
报告名称 / 检测对象
报告名称 / 检测对象{reportKind === CERTIFICATE_REPORT_KIND.RAW ? ' *' : ''}
</label>
<input
id={`edit-material-${params.id}-report-title`}
@ -811,7 +880,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
value={reportTitle}
onChange={(event) => setReportTitle(event.target.value)}
className="input-apple"
placeholder="SPU 和此字段至少填写一个"
placeholder={reportKind === CERTIFICATE_REPORT_KIND.RAW ? '原材料检测报告必须填写' : 'SPU 和此字段至少填写一个'}
required={reportKind === CERTIFICATE_REPORT_KIND.RAW}
/>
</div>
)}
@ -826,7 +896,13 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<select
id={`edit-material-${params.id}-type`}
value={materialType}
onChange={(event) => setMaterialType(event.target.value)}
onChange={(event) => {
const nextType = event.target.value
if (isCertificateReportMaterialType(nextType) && !isCertificateReport && !reportUsageRules) {
setReportUsageRules(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
}
setMaterialType(nextType)
}}
className="input-apple"
required
>
@ -923,19 +999,30 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
className="input-apple"
disabled={
submitting ||
referenceLoading ||
referenceFailures.includes('platforms')
(!isCertificateReport && (referenceLoading || referenceFailures.includes('platforms')))
}
>
<option value="">{referenceLoading ? '正在加载平台...' : '不选择'}</option>
{platformName && !platforms.some((platform) => platform.name === platformName) && (
<option value={platformName}>{platformName}</option>
{isCertificateReport ? (
<>
<option value="">不选择</option>
{platformName && !CERTIFICATE_REPORT_PLATFORM_OPTIONS.some((option) => option === platformName) && (
<option value={platformName}>{platformName}(原有分类)</option>
)}
{CERTIFICATE_REPORT_PLATFORM_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</>
) : (
<>
<option value="">{referenceLoading ? '正在加载平台...' : '不选择'}</option>
{platformName && !platforms.some((platform) => platform.name === platformName) && (
<option value={platformName}>{platformName}</option>
)}
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</>
)}
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
@ -944,13 +1031,14 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
htmlFor={`edit-material-${params.id}-product-category`}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
品类{requiresProductCategoryName(materialType) ? ' *' : ''}
品类{requiresProductCategoryName(materialType) || isCertificateReport ? ' *' : ''}
</label>
<select
id={`edit-material-${params.id}-product-category`}
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
className="input-apple"
required={requiresProductCategoryName(materialType) || isCertificateReport}
disabled={
submitting ||
referenceLoading ||
@ -970,6 +1058,32 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
</select>
</div>
{isCertificateReport && (
<section className="space-y-5 border-t border-gray-200 pt-6" aria-label="证书信息">
<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>
<input id={`edit-material-${params.id}-certificate-expiry`} type="date" value={certificateExpiresOn} onChange={(event) => setCertificateExpiresOn(event.target.value)} className="input-apple" required />
<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>
<input id={`edit-material-${params.id}-certificate-number`} type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className="input-apple" required />
</div>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-certificate-instructions`} className="mb-2 block text-sm font-medium text-gray-700">证书使用说明 *</label>
<textarea id={`edit-material-${params.id}-certificate-instructions`} value={certificateUsageInstructions} onChange={(event) => setCertificateUsageInstructions(event.target.value)} rows={3} maxLength={5000} className="input-apple resize-y" required />
</div>
<div>
<label htmlFor={`edit-material-${params.id}-report-rules`} className="mb-2 block text-sm font-medium text-gray-700">检测报告使用规则说明 *</label>
<textarea id={`edit-material-${params.id}-report-rules`} value={reportUsageRules} onChange={(event) => setReportUsageRules(event.target.value)} rows={10} maxLength={10000} className="input-apple resize-y leading-relaxed" required />
<p className="mt-1 text-xs text-gray-500">这是一整段说明,可根据当前报告修改</p>
</div>
</section>
)}
{showRenderer && (
<div>
<label

View File

@ -235,6 +235,46 @@ describe('video material share page', () => {
})
})
describe('certificate report share page', () => {
afterAll(() => {
global.fetch = originalFetch
})
test('详情展示证书字段和未拆分的完整说明', async () => {
global.fetch = jest.fn().mockResolvedValue(createJsonResponse({
id: 'video-material-1',
userId: 'user-1',
previewUrl: '',
productModel: '',
reportTitle: '板材检测报告',
reportKind: '原材料',
certificateExpiresOn: '2027-09-23T00:00:00.000Z',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对主体',
reportUsageRules: '第一段\n第二段',
categoryName: '通用',
sceneName: '通用',
platformName: '品牌',
materialType: '证书-检测报告',
authorName: '上传人',
zipFileName: '',
zipFileSize: '0',
downloadCount: 0,
likeCount: 0,
displayImages: [],
createdAt: '2026-09-23T00:00:00.000Z',
assets: [],
})) as jest.Mock
render(<SharePage />)
expect(await screen.findByText('CMA-2026-001')).toBeInTheDocument()
expect(screen.getByText('2027-09-23')).toBeInTheDocument()
expect(screen.getByText('使用前核对主体')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent)
.toBe('第一段\n第二段')
})
})
describe('task detail share page change requests', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@ -38,6 +38,11 @@ interface Material {
previewUrl: string
productModel: string
reportTitle?: string | null
reportKind?: string | null
certificateExpiresOn?: string | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
reportUsageRules?: string | null
liveDate?: string | Date | null
liveHost?: string | null
liveHostUserId?: string | null
@ -589,6 +594,7 @@ export default function SharePage() {
? getCertificateReportDisplayTitle({
productModel: material.productModel,
reportTitle: material.reportTitle,
reportKind: material.reportKind,
})
: ''
@ -2063,10 +2069,26 @@ export default function SharePage() {
</div>
{isCertificateReportMaterialType(material.materialType) ? (
<>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">报告类别</p>
<p className="text-[15px] font-medium text-[#1a1c1d]">{material.reportKind || '待补充'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">报告名称 / 检测对象</p>
<p className="text-[15px] font-medium text-[#1a1c1d]">{material.reportTitle || '未填写'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">平台分类</p>
<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>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书编号</p>
<p className="break-all text-[15px] font-medium text-[#1a1c1d]">{material.certificateNumber || '待补充'}</p>
</div>
{material.materialType === '直播切片' && (
<>
<div>
@ -2220,6 +2242,20 @@ export default function SharePage() {
</div>
</div>
</div>
{isCertificateReportMaterialType(material.materialType) && (
<section className="mt-6 rounded-[12px] border border-[#e7e8ea] bg-white px-5 py-6 sm:px-7" aria-label="检测报告使用说明">
<div className="max-w-[75ch] space-y-6">
<div>
<h2 className="text-[17px] font-semibold text-[#1a1c1d]">证书使用说明</h2>
<p className="mt-3 whitespace-pre-wrap break-words text-[14px] leading-7 text-[#3d4246]">{material.certificateUsageInstructions || '待补充'}</p>
</div>
<div className="border-t border-[#e7e8ea] pt-6">
<h2 className="text-[17px] font-semibold text-[#1a1c1d]">检测报告使用规则说明</h2>
<p className="mt-3 whitespace-pre-wrap break-words text-[14px] leading-7 text-[#3d4246]">{material.reportUsageRules || '待补充'}</p>
</div>
</div>
</section>
)}
</>
)}
</main>

View File

@ -18,6 +18,7 @@ export interface Material {
id: string
productModel: string
reportTitle?: string | null
reportKind?: string | null
liveDate?: string | null
liveHost?: string | null
livePlatform?: string | null
@ -174,6 +175,7 @@ export default function MaterialCard({
const materialDisplayTitle = getCertificateReportDisplayTitle({
productModel: material.productModel,
reportTitle: material.reportTitle,
reportKind: material.reportKind,
})
// 素材切换时回到封面态;视频只在用户悬停时加载,避免列表首屏并发预载全部视频。

View File

@ -1,7 +1,14 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { CERTIFICATE_REPORT_MATERIAL_TYPE, detectFileKindFromName } from '@/lib/certificate-report'
import {
CERTIFICATE_REPORT_KIND,
CERTIFICATE_REPORT_MATERIAL_TYPE,
CERTIFICATE_REPORT_PLATFORM_OPTIONS,
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
detectFileKindFromName,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
interface CategoryItem {
id: string
@ -28,6 +35,11 @@ export default function CertificateReportUploader({
}: CertificateReportUploaderProps) {
const [productModel, setProductModel] = useState('')
const [reportTitle, setReportTitle] = useState('')
const [reportKind, setReportKind] = useState<string>(CERTIFICATE_REPORT_KIND.FINISHED)
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('')
const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
const [productCategoryName, setProductCategoryName] = useState('')
const [platformName, setPlatformName] = useState('')
const [files, setFiles] = useState<ReportFileItem[]>([])
@ -104,8 +116,19 @@ export default function CertificateReportUploader({
}
const handleSubmit = async () => {
if (!productModel.trim() && !reportTitle.trim()) {
setError('SPU 和 报告名称/检测对象 至少填写一个')
const submittedProductModel = reportKind === CERTIFICATE_REPORT_KIND.RAW ? '' : productModel.trim()
const metadata = validateCertificateReportMetadata({
reportKind,
productModel: submittedProductModel,
reportTitle,
certificateExpiresOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
platformName,
})
if ('error' in metadata) {
setError(metadata.error)
return
}
@ -129,8 +152,13 @@ export default function CertificateReportUploader({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productModel: productModel.trim(),
productModel: submittedProductModel,
reportTitle: reportTitle.trim(),
reportKind,
certificateExpiresOn,
certificateNumber: certificateNumber.trim(),
certificateUsageInstructions: certificateUsageInstructions.trim(),
reportUsageRules: reportUsageRules.trim(),
productCategoryName: productCategoryName.trim(),
platformName: platformName.trim(),
}),
@ -230,30 +258,57 @@ export default function CertificateReportUploader({
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">SPU</label>
<input
type="text"
value={productModel}
onChange={(event) => setProductModel(event.target.value)}
placeholder="没有 SPU 可以留空"
className="input-apple"
/>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-gray-700">报告类别 *</legend>
<div className="flex flex-wrap gap-4">
{[CERTIFICATE_REPORT_KIND.FINISHED, CERTIFICATE_REPORT_KIND.RAW].map((kind) => (
<label key={kind} className="flex min-h-11 items-center gap-2 text-sm text-gray-800">
<input
type="radio"
name="certificate-report-kind"
value={kind}
checked={reportKind === kind}
onChange={() => setReportKind(kind)}
disabled={uploading}
className="accent-[#1a1c1d]"
/>
{kind}
</label>
))}
</div>
</fieldset>
<div className={`grid grid-cols-1 gap-4 ${reportKind === CERTIFICATE_REPORT_KIND.FINISHED ? 'md:grid-cols-2' : ''}`}>
{reportKind === CERTIFICATE_REPORT_KIND.FINISHED && (
<div>
<label htmlFor="certificate-report-spu" className="mb-2 block text-sm font-medium text-gray-700">SPU</label>
<input
id="certificate-report-spu"
type="text"
value={productModel}
onChange={(event) => setProductModel(event.target.value)}
placeholder="没有 SPU 可以留空"
className="input-apple"
/>
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
报告名称 / 检测对象
<label htmlFor="certificate-report-title" className="mb-2 block text-sm font-medium text-gray-700">
报告名称 / 检测对象{reportKind === CERTIFICATE_REPORT_KIND.RAW ? ' *' : ''}
</label>
<input
id="certificate-report-title"
type="text"
value={reportTitle}
onChange={(event) => setReportTitle(event.target.value)}
placeholder="例如:E0级颗粒板检测报告"
className="input-apple"
required={reportKind === CERTIFICATE_REPORT_KIND.RAW}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">SPU 和这个字段至少填写一个</p>
<p className="mt-1 text-xs text-gray-500">
{reportKind === CERTIFICATE_REPORT_KIND.RAW ? '原材料检测报告必须填写报告名称' : 'SPU 和这个字段至少填写一个'}
</p>
</div>
</div>
@ -264,6 +319,7 @@ export default function CertificateReportUploader({
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
className="input-apple"
required
>
<option value="">请选择品类</option>
{productCategories.map((category) => (
@ -275,17 +331,42 @@ export default function CertificateReportUploader({
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">平台分类</label>
<input
type="text"
<label htmlFor="certificate-report-platform" className="mb-2 block text-sm font-medium text-gray-700">平台分类</label>
<select
id="certificate-report-platform"
value={platformName}
onChange={(event) => setPlatformName(event.target.value)}
placeholder="可选"
className="input-apple"
/>
>
<option value="">不选择</option>
{CERTIFICATE_REPORT_PLATFORM_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
</div>
<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>
<input id="certificate-report-expiry" type="date" value={certificateExpiresOn} onChange={(event) => setCertificateExpiresOn(event.target.value)} className="input-apple" required />
<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>
<input id="certificate-report-number" type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className="input-apple" required />
</div>
</div>
<div>
<label htmlFor="certificate-report-instructions" className="mb-2 block text-sm font-medium text-gray-700">证书使用说明 *</label>
<textarea id="certificate-report-instructions" value={certificateUsageInstructions} onChange={(event) => setCertificateUsageInstructions(event.target.value)} rows={3} maxLength={5000} className="input-apple resize-y" required />
</div>
<div>
<label htmlFor="certificate-report-rules" className="mb-2 block text-sm font-medium text-gray-700">检测报告使用规则说明 *</label>
<textarea id="certificate-report-rules" value={reportUsageRules} onChange={(event) => setReportUsageRules(event.target.value)} rows={10} maxLength={10000} className="input-apple resize-y leading-relaxed" required />
<p className="mt-1 text-xs text-gray-500">已预填完整说明,可按这份报告的实际情况修改</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
报告文件 *

View File

@ -2,7 +2,7 @@
## 结论
当前应用发布工具验证二十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
当前应用发布工具验证二十七份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
目标 migration:
@ -32,6 +32,9 @@
- `20260914_expand_brand_knowledge_preview_slices`
- `20260915_add_brand_knowledge_soft_delete`
- `20260918_add_performance_conversion_sync`
- `20260923_add_certificate_report_metadata`
`20260923_add_certificate_report_metadata` 仅为 `materials` 增加五个可空字段:报告类别、证书到期日、证书编号、证书使用说明和检测报告使用规则说明。不回填或修改已有素材行;旧报告需要编辑时再补录类别和证书信息。
`20260918_add_performance_conversion_sync` 只新增业绩转化经营数据快照表和每日增量同步状态表。首次同步按已完结美术流程的商品 ID 补齐六个月监控区间,后续每日重刷最近两天;租约索引用于防止多实例重复执行,不改写流程快照或上游报表库。
@ -58,7 +61,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。
@ -382,7 +385,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 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@ -398,7 +401,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

@ -0,0 +1,58 @@
import {
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
getCertificateReportDisplayTitle,
parseCertificateExpiryDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
const metadata = {
reportKind: '成品',
productModel: 'SPU-001',
reportTitle: '',
certificateExpiresOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
platformName: '大货',
}
describe('检测报告字段规则', () => {
test('成品沿用 SPU 或报告名称至少填一个,原材料必须填报告名称', () => {
expect(validateCertificateReportMetadata(metadata)).toHaveProperty('value.reportKind', '成品')
expect(validateCertificateReportMetadata({ ...metadata, reportKind: '原材料' })).toEqual({
error: '原材料检测报告必须填写报告名称',
})
expect(validateCertificateReportMetadata({
...metadata,
reportKind: '原材料',
productModel: '',
reportTitle: '板材检测报告',
})).toHaveProperty('value.reportKind', '原材料')
expect(getCertificateReportDisplayTitle({
reportKind: '原材料',
productModel: '旧 SPU',
reportTitle: '板材检测报告',
})).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('平台分类只接受报告专用选项,整段预置说明保持为一个文本值', () => {
expect(validateCertificateReportMetadata({ ...metadata, platformName: '品牌' })).toHaveProperty('value')
expect(validateCertificateReportMetadata({ ...metadata, platformName: '其它' })).toEqual({
error: '检测报告的平台分类只能选择大货或品牌',
})
expect(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES).toContain('\n7、')
expect(validateCertificateReportMetadata(metadata)).toHaveProperty(
'value.reportUsageRules',
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
)
})
})

View File

@ -176,4 +176,37 @@ describe('EditMaterialPage', () => {
await user.click(await screen.findByRole('button', { name: '放弃修改' }))
expect(mockBack).toHaveBeenCalledTimes(1)
})
test('检测报告编辑按成品和原材料切换字段,规则说明保留为一个输入框', async () => {
const user = userEvent.setup()
const report = {
...material,
materialType: '证书-检测报告',
productCategoryName: '板材',
platformName: '品牌',
reportKind: '成品',
reportTitle: '板材检测报告',
certificateExpiresOn: '2027-09-23T00:00:00.000Z',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对主体',
reportUsageRules: '第一段\n第二段',
}
global.fetch = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/materials/material-1')) return jsonResponse(report)
return jsonResponse(referencePayload(url))
}) as jest.Mock
render(<EditMaterialPage params={{ id: 'material-1' }} />)
const kindSelect = await screen.findByLabelText('报告类别 *')
expect(screen.getByLabelText('SPU')).toHaveValue('SPU-001')
expect(screen.getByLabelText('证书到期日 *')).toHaveValue('2027-09-23')
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue('第一段\n第二段')
expect(screen.getByLabelText('平台分类')).toHaveValue('品牌')
await user.selectOptions(kindSelect, '原材料')
expect(screen.queryByLabelText('SPU')).not.toBeInTheDocument()
expect(screen.getByLabelText('报告名称 / 检测对象 *')).toHaveValue('板材检测报告')
})
})

View File

@ -54,7 +54,18 @@ jest.mock('@/lib/external-auto-listing', () => ({
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/material-authorization', () => ({
canManageMaterial: jest.fn().mockResolvedValue(true),
}))
describe('material-update shared rules', () => {
const reportMetadataInput = {
reportKind: '成品',
certificateExpiresOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
}
const baseMaterial = {
id: 'material-1',
userId: 'user-1',
@ -218,6 +229,7 @@ describe('material-update shared rules', () => {
input: {
productModel: '',
reportTitle: 'E0级板材检测报告',
...reportMetadataInput,
categoryName: '',
sceneName: '',
platformName: '',
@ -235,6 +247,11 @@ describe('material-update shared rules', () => {
data: expect.objectContaining({
productModel: '',
reportTitle: 'E0级板材检测报告',
reportKind: '成品',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
categoryName: '通用',
sceneName: '通用',
materialType: '证书-检测报告',
@ -251,6 +268,11 @@ describe('material-update shared rules', () => {
productCategoryName: '板材',
productModel: '',
reportTitle: '原检测报告',
reportKind: '成品',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
renderer: '',
}
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingCertificate)
@ -284,6 +306,44 @@ describe('material-update shared rules', () => {
})
})
test('成品报告改为原材料时不保留隐藏的 SPU', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
reportKind: '成品',
reportTitle: '原报告',
})
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
...baseMaterial,
...data,
}))
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'OLD-SPU',
reportTitle: '板材检测报告',
...reportMetadataInput,
reportKind: '原材料',
categoryName: '',
sceneName: '',
platformName: '',
productCategoryName: '板材',
materialType: '证书-检测报告',
renderer: '',
authorName: '测试作者',
},
})
expect('error' in result).toBe(false)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({ productModel: '', reportKind: '原材料' }),
})
})
test('证书-检测报告同时缺少 SPU 和报告名称时应返回 400', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
@ -303,6 +363,7 @@ describe('material-update shared rules', () => {
input: {
productModel: '',
reportTitle: '',
...reportMetadataInput,
categoryName: '',
sceneName: '',
platformName: '',
@ -321,6 +382,35 @@ describe('material-update shared rules', () => {
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('历史检测报告未补类别和证书字段时拒绝保存,原值不被自动归为成品', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
reportTitle: '历史报告',
reportKind: null,
certificateExpiresOn: null,
certificateNumber: null,
certificateUsageInstructions: null,
reportUsageRules: null,
})
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'OLD-SPU',
reportTitle: '历史报告',
productCategoryName: '板材',
materialType: '证书-检测报告',
authorName: '测试作者',
},
})
expect(result).toEqual({ error: '请选择报告类别(成品或原材料)', status: 400 })
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('非任务详情类型应强制将 isFullSet 保存为 false', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({

View File

@ -1,5 +1,23 @@
export const CERTIFICATE_REPORT_MATERIAL_TYPE = '证书-检测报告'
export const CERTIFICATE_REPORT_KIND = {
FINISHED: '成品',
RAW: '原材料',
} as const
export type CertificateReportKind = (typeof CERTIFICATE_REPORT_KIND)[keyof typeof CERTIFICATE_REPORT_KIND]
export const CERTIFICATE_REPORT_PLATFORM_OPTIONS = ['大货', '品牌'] as const
// 这是每份报告的一个可编辑文本字段,编号和换行属于原文内容。
export const DEFAULT_CERTIFICATE_REPORT_USAGE_RULES = `1、检测报告各电商平台官方正常认可的有效期为1年内,需注意检测时间;
2、检测报告均为由CMA资质的机构:常州检验检测标准认证研究院 出具,委托检测单位由不同主体委托,若使用中对委托送检主体有要求的,必须要核对好对应主体是否可以使用;
3、检测报告项目检测结果仅代表当批次产品,新品和其他的款式是否能通过需送检后方能确认;
4、检测报告检测项目各有不同,常规检测项目主要为:甲醛检测、结构安全、力学性能检测等,现有检测报告因需求不同,部分报告只做了甲醛项目、或者只做了力学性能检测未做甲醛检测等,使用前需核对好使用需求和报告内容;
5、检测项报告中:规格型号主要按品类名称制作,少部分单独特殊需求的检测报告有对应产品SPU型号,使用时需注意核对;部分检测报告因特殊需求附上产品图片,需注意核对报告上的图片是否和实际需求相符;
6、检测报告文件夹中,标明为品牌的,除顾家品牌店铺外禁止使用,品牌检测报告需求对接伯阳;;
7、检测报告若有需求的,需由部门负责人申请钉钉流程:质检报告申请流程,费用需送检部门支付,可以根据需求单独做某一项,常见检测报告收费范围:甲醛检测(1600-2500元,15个工作日左右)、结构安全和力学性能(800-2000元,7个工作日左右),同时需求部门需安装准备好对应样品,样品事先可以对接:守仁确认;`
export const CERTIFICATE_REPORT_ASSET_TYPE = {
PDF: 'report-pdf',
IMAGE: 'report-image',
@ -28,16 +46,91 @@ export function normalizeReportTitle(value: string | null | undefined): string |
return normalized || null
}
export function parseCertificateExpiryDate(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
? null
: parsed
}
export function validateCertificateReportMetadata(input: {
reportKind: unknown
productModel: unknown
reportTitle: unknown
certificateExpiresOn: unknown
certificateNumber: unknown
certificateUsageInstructions: unknown
reportUsageRules: unknown
platformName: unknown
}, legacyPlatformName = ''):
| { error: string }
| { value: {
reportKind: CertificateReportKind
certificateExpiresOn: Date
certificateNumber: string
certificateUsageInstructions: string
reportUsageRules: string
} } {
const reportKind = input.reportKind
if (reportKind !== CERTIFICATE_REPORT_KIND.FINISHED && reportKind !== CERTIFICATE_REPORT_KIND.RAW) {
return { error: '请选择报告类别(成品或原材料)' }
}
const productModel = String(input.productModel || '').trim()
const reportTitle = String(input.reportTitle || '').trim()
if (reportKind === CERTIFICATE_REPORT_KIND.RAW && !reportTitle) {
return { error: '原材料检测报告必须填写报告名称' }
}
if (reportKind === CERTIFICATE_REPORT_KIND.FINISHED && !productModel && !reportTitle) {
return { error: 'SPU 和 报告名称/检测对象 至少填写一个' }
}
const certificateExpiresOn = parseCertificateExpiryDate(input.certificateExpiresOn)
if (!certificateExpiresOn) return { error: '请选择有效的证书到期日' }
const certificateNumber = typeof input.certificateNumber === 'string' ? input.certificateNumber.trim() : ''
if (!certificateNumber || certificateNumber.length > 120) {
return { error: '请填写证书编号(不超过120字)' }
}
const certificateUsageInstructions = typeof input.certificateUsageInstructions === 'string'
? input.certificateUsageInstructions.trim()
: ''
if (!certificateUsageInstructions || certificateUsageInstructions.length > 5000) {
return { error: '请填写证书使用说明(不超过5000字)' }
}
const reportUsageRules = typeof input.reportUsageRules === 'string' ? input.reportUsageRules.trim() : ''
if (!reportUsageRules || reportUsageRules.length > 10000) {
return { error: '请填写检测报告使用规则说明(不超过10000字)' }
}
const platformName = typeof input.platformName === 'string' ? input.platformName.trim() : ''
if (platformName &&
!CERTIFICATE_REPORT_PLATFORM_OPTIONS.some((option) => option === platformName) &&
platformName !== legacyPlatformName) {
return { error: '检测报告的平台分类只能选择大货或品牌' }
}
return {
value: {
reportKind,
certificateExpiresOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
},
}
}
export function getCertificateReportDisplayTitle(input: {
productModel?: string | null
reportTitle?: string | null
reportKind?: string | null
}): string {
const productModel = String(input.productModel || '').trim()
const reportTitle = String(input.reportTitle || '').trim()
if (input.reportKind === CERTIFICATE_REPORT_KIND.RAW && reportTitle) {
return reportTitle
}
if (productModel) {
return productModel
}
const reportTitle = String(input.reportTitle || '').trim()
if (reportTitle) {
return reportTitle
}

View File

@ -25,7 +25,7 @@ import { canManageMaterial } from '@/lib/material-authorization'
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import { isCertificateReportMaterialType, normalizeReportTitle } from '@/lib/certificate-report'
import { CERTIFICATE_REPORT_KIND, isCertificateReportMaterialType, normalizeReportTitle, validateCertificateReportMetadata } from '@/lib/certificate-report'
import { isLiveClipType, parseLiveDate, validateLiveClipFields } from '@/lib/live-clip'
import {
getDingtalkApprovalNoError,
@ -40,6 +40,11 @@ import {
export interface MaterialUpdateInput {
productModel: string
reportTitle?: string | null
reportKind?: string | null
certificateExpiresOn?: string | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
reportUsageRules?: string | null
categoryName?: string | null
sceneName?: string | null
platformName?: string | null
@ -188,6 +193,11 @@ 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 hasCertificateNumberField = Object.prototype.hasOwnProperty.call(input, 'certificateNumber')
const hasCertificateUsageInstructionsField = Object.prototype.hasOwnProperty.call(input, 'certificateUsageInstructions')
const hasReportUsageRulesField = Object.prototype.hasOwnProperty.call(input, 'reportUsageRules')
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
const hasDingtalkApprovalNoField = Object.prototype.hasOwnProperty.call(input, 'dingtalkApprovalNo')
const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType')
@ -214,9 +224,27 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
return { error: '缺少必填字段', status: 400 as const }
}
let certificateReportMetadata: ReturnType<typeof validateCertificateReportMetadata> | null = null
if (isCertificateReportMaterialType(materialType)) {
if (!productModel && !effectiveReportTitle) {
return { error: 'SPU 和 报告名称/检测对象 至少填写一个', status: 400 as const }
if (!productCategoryName) {
return { error: '请选择品类', status: 400 as const }
}
certificateReportMetadata = validateCertificateReportMetadata({
reportKind: hasReportKindField ? input.reportKind : material.reportKind,
productModel,
reportTitle: effectiveReportTitle,
certificateExpiresOn: hasCertificateExpiresOnField
? input.certificateExpiresOn
: material.certificateExpiresOn?.toISOString().slice(0, 10),
certificateNumber: hasCertificateNumberField ? input.certificateNumber : material.certificateNumber,
certificateUsageInstructions: hasCertificateUsageInstructionsField
? input.certificateUsageInstructions
: material.certificateUsageInstructions,
reportUsageRules: hasReportUsageRulesField ? input.reportUsageRules : material.reportUsageRules,
platformName,
}, isCertificateReportMaterialType(material.materialType) ? material.platformName : '')
if ('error' in certificateReportMetadata) {
return { error: certificateReportMetadata.error, status: 400 as const }
}
} else if (!productModel) {
return { error: '缺少必填字段', status: 400 as const }
@ -351,8 +379,18 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
const updateData: Record<string, any> = {
previewUrl,
productModel,
productModel: isCertificateReportMaterialType(materialType)
&& certificateReportMetadata
&& 'value' in certificateReportMetadata
&& certificateReportMetadata.value.reportKind === CERTIFICATE_REPORT_KIND.RAW
? ''
: productModel,
reportTitle: isCertificateReportMaterialType(materialType) ? effectiveReportTitle : null,
reportKind: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportKind : null,
certificateExpiresOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateExpiresOn : 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,
categoryName: shouldUseGeneralCategoryScene ? '通用' : (categoryName ?? material.categoryName),
sceneName: shouldUseGeneralCategoryScene ? '通用' : (sceneName ?? material.sceneName),
platformName: platformName ?? '',

View File

@ -0,0 +1,6 @@
ALTER TABLE "materials"
ADD COLUMN "report_kind" TEXT,
ADD COLUMN "certificate_expires_on" DATE,
ADD COLUMN "certificate_number" TEXT,
ADD COLUMN "certificate_usage_instructions" TEXT,
ADD COLUMN "report_usage_rules" TEXT;

View File

@ -75,6 +75,11 @@ model Material {
previewUrl String
productModel String
reportTitle String? @map("report_title")
reportKind String? @map("report_kind")
certificateExpiresOn DateTime? @db.Date @map("certificate_expires_on")
certificateNumber String? @map("certificate_number")
certificateUsageInstructions String? @map("certificate_usage_instructions")
reportUsageRules String? @map("report_usage_rules")
categoryName String
sceneName String
materialType String @default("3DMAX")

View File

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

View File

@ -35,6 +35,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260914_expand_brand_knowledge_preview_slices',
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
'20260923_add_certificate_report_metadata',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -344,7 +345,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'))
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 = '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'))
@ -494,6 +495,11 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
requireColumn('materials', 'transcode_lease_token', 'text', 'YES')
requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'uploadId', 'text', 'YES')
requireColumn('materials', 'report_kind', 'text', 'YES')
requireColumn('materials', 'certificate_expires_on', 'date', 'YES')
requireColumn('materials', 'certificate_number', 'text', 'YES')
requireColumn('materials', 'certificate_usage_instructions', 'text', 'YES')
requireColumn('materials', 'report_usage_rules', 'text', 'YES')
requireColumn('api_keys', 'secret_ciphertext', 'text', 'YES')
requireColumn('api_keys', 'secret_nonce', 'text', 'YES')
requireColumn('api_keys', 'secret_auth_tag', 'text', 'YES')

View File

@ -351,6 +351,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260914_expand_brand_knowledge_preview_slices', { checksum: 'x' }],
['20260915_add_brand_knowledge_soft_delete', { checksum: 'y' }],
['20260918_add_performance_conversion_sync', { checksum: 'z' }],
['20260923_add_certificate_report_metadata', { checksum: 'aa' }],
])
const evidence = {
migrationRows: [
@ -380,11 +381,17 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ migration_name: '20260914_expand_brand_knowledge_preview_slices', checksum: 'x', finished_at: 'now', rolled_back_at: null },
{ 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 },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'transcode_lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ 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_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' },
{ table_name: 'materials', column_name: 'task_detail_demand_type', data_type: 'text', is_nullable: 'YES', column_default: null },
{ table_name: 'materials', column_name: 'task_detail_private', data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
{ table_name: 'material_permission_pack_items', column_name: 'can_access_private', data_type: 'boolean', is_nullable: 'NO', column_default: 'false' },
@ -848,6 +855,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
'20260914_expand_brand_knowledge_preview_slices',
'20260915_add_brand_knowledge_soft_delete',
'20260918_add_performance_conversion_sync',
'20260923_add_certificate_report_metadata',
]) {
assert.match(dockerfile, new RegExp(migrationName))
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
@ -877,6 +885,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.ok(sop.includes('`20260915_add_brand_knowledge_soft_delete`'))
assert.match(deploymentSource, /'20260918_add_performance_conversion_sync'/)
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`'))
const indexesQueryStart = migratorSource.indexOf('const indexes = await prisma.$queryRawUnsafe(')
const indexesQueryEnd = migratorSource.indexOf('const constraints = await prisma.$queryRawUnsafe(', indexesQueryStart)
assert.ok(indexesQueryStart >= 0)
@ -960,8 +970,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)