diff --git a/Dockerfile b/Dockerfile index b1c49e8..2ed1ff3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 764a39e..f02b086 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -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, diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 1c5c474..00152b2 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -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, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index fae9b9a..ca8745e 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -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, diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index b2e7e20..e54d616 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -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, diff --git a/app/api/materials/upload/certificate-report/init/route.test.ts b/app/api/materials/upload/certificate-report/init/route.test.ts new file mode 100644 index 0000000..343e6b1 --- /dev/null +++ b/app/api/materials/upload/certificate-report/init/route.test.ts @@ -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() + }) +}) diff --git a/app/api/materials/upload/certificate-report/init/route.ts b/app/api/materials/upload/certificate-report/init/route.ts index 77e3c4d..d845611 100644 --- a/app/api/materials/upload/certificate-report/init/route.ts +++ b/app/api/materials/upload/certificate-report/init/route.ts @@ -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, diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 5652e1d..b308c15 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -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 } }) )} -
+ {isCertificateReport && ( +
+ + + {!reportKind &&

历史报告需确认类别后再保存

} +
+ )} + + {!(isCertificateReport && reportKind === CERTIFICATE_REPORT_KIND.RAW) &&
+
} {materialType === '直播切片' && (
@@ -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 ? ' *' : ''} setReportTitle(event.target.value)} className="input-apple" - placeholder="SPU 和此字段至少填写一个" + placeholder={reportKind === CERTIFICATE_REPORT_KIND.RAW ? '原材料检测报告必须填写' : 'SPU 和此字段至少填写一个'} + required={reportKind === CERTIFICATE_REPORT_KIND.RAW} />
)} @@ -826,7 +896,13 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) @@ -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 ? ' *' : ''} + {isCertificateReport && ( +
+

证书信息

+
+
+ + setCertificateExpiresOn(event.target.value)} className="input-apple" required /> +

按证书标注日期选择,不自动推算有效期

+
+
+ + setCertificateNumber(event.target.value)} maxLength={120} className="input-apple" required /> +
+
+
+ +