feat: add certificate report metadata and report kinds
This commit is contained in:
parent
231db310bb
commit
8dc5ffb71b
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
// 素材切换时回到封面态;视频只在用户悬停时加载,避免列表首屏并发预载全部视频。
|
||||
|
||||
@ -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">
|
||||
报告文件 *
|
||||
|
||||
@ -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` 仍通过;
|
||||
|
||||
58
lib/__tests__/certificate-report.test.ts
Normal file
58
lib/__tests__/certificate-report.test.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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('板材检测报告')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 }) => ({
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 ?? '',
|
||||
|
||||
@ -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;
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user