gongxiangp2/lib/history-material-import.ts
chenpei 9cd3c0f231 feat: 任务详情无条码开关 + 上传面板信息密度优化 + 历史导入/脚本补全
- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验
- SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示
- 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签
- 补充历史导入相关脚本和工具文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:01:08 +08:00

1083 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { prisma } from '@/lib/prisma'
import {
completeProductPackageUpload,
type ProductPackageImageInput,
type ProductPackageSourceInput,
} from '@/lib/product-package-completion'
import { createMaterialLog } from '@/lib/material-log'
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import path from 'path'
import { promises as fs } from 'fs'
import crypto from 'crypto'
import { execFile } from 'child_process'
import { promisify } from 'util'
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
const SKIPPED_FILE_EXTENSIONS = new Set([
'.xlsx',
'.xls',
'.psd',
'.psb',
'.tif',
'.tiff',
'.zip',
'.rar',
'.7z',
'.pdf',
'.log',
'.db',
'.lnk',
])
const IGNORED_DIR_PATTERNS = [
/@eadir/i,
/策划方案/i,
/产品资料/i,
/模特图/i,
/废稿/i,
/字体/i,
/勿用/i,
/错误勿用/i,
/图片有误/i,
/作废/i,
/3D建模/i,
/DingTalkAppData/i,
/^PsTool$/i,
/^Cache$/i,
/RECYCLE/i,
]
const CANDIDATE_DIR_PATTERNS = [
/详情全套/i,
/全套详情/i,
/视觉全套/i,
/平面全套/i,
/全案/i,
/全套a图/i,
/全套视觉方案/i,
/全套视觉策划/i,
/全套视觉实拍方案/i,
/全套视觉/i,
]
const MAIN_HINT_PATTERNS = [/主图/i, /新主图/i]
const SKU_HINT_PATTERNS = [/sku/i, /sku图/i]
const DETAIL_HINT_PATTERNS = [/详情/i, /详情图/i, /详情页/i]
const MAIN_FILE_EXCLUDE_PATTERNS = [/功能主图/i, /微详情/i]
const DETAIL_FILE_EXCLUDE_PATTERNS = [/模特/i]
const STORE_DIR_IGNORES = new Set([
'.recycle',
'@eaDir',
'$NSEC.RECYCLE.BIN',
'DingTalkAppData',
'PsTool',
'营销策划方案',
'3D66',
])
const execFileAsync = promisify(execFile)
const HISTORY_ZIP_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_ZIP_TIMEOUT_MS || 180000)
const HISTORY_ALL_ZIP_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_ALL_ZIP_TIMEOUT_MS || 300000)
const BRANDED_SPU_BODY_PATTERN = '[A-Z]{2,5}-[A-Z]{1,6}\\d{3,5}(?:K\\d{1,3})?'
const UNBRANDED_SPU_BODY_PATTERN = '[A-Z]{1,6}\\d{3,5}(?:K\\d{1,3})?'
const COMBO_SPU_TOKEN_PATTERN = new RegExp(
`(?<![A-Z0-9])${BRANDED_SPU_BODY_PATTERN}(?:\\s*[++]\\s*(?:${BRANDED_SPU_BODY_PATTERN}|${UNBRANDED_SPU_BODY_PATTERN}))+(?![A-Z0-9])`,
'i',
)
const BLOCKED_READY_SOURCE_PATH_PATTERNS = [
{ pattern: /错误勿用/i, label: '错误勿用' },
{ pattern: /废稿/i, label: '废稿' },
{ pattern: /勿用/i, label: '勿用' },
{ pattern: /图片有误/i, label: '图片有误' },
{ pattern: /作废/i, label: '作废' },
{ pattern: /3D建模/i, label: '3D建模' },
]
export type HistoryImportStatus = 'ready' | 'needs_review' | 'skipped'
export interface HistoryImportRecord {
fingerprint: string
spu: string
sourcePath: string
relativeSourcePath: string
guessedStore: string
productCategoryName: string
authorName: string
taskDetailSourceType?: string
storeConfidence: number
storeCandidates: string[]
mainCount: number
skuCount: number
detailCount: number
sourceCount?: number
status: HistoryImportStatus
skipReason: string | null
mainImages: ProductPackageImageInput[]
skuImages: ProductPackageImageInput[]
detailImages: ProductPackageImageInput[]
psdSources?: ProductPackageSourceInput[]
seenInYya: boolean
}
export interface ScanHistoryMaterialsOptions {
materialsRoot: string
cpRoot?: string
yyaRoot?: string
limit?: number
spuFilter?: string[]
}
export interface ImportHistoryMaterialsOptions {
records: HistoryImportRecord[]
operator: {
id: string
username: string
realName: string | null
}
apply?: boolean
limit?: number
}
export interface ImportHistoryMaterialsResultItem {
fingerprint: string
spu: string
sourcePath: string
guessedStore: string
materialId: string | null
status: 'imported' | 'dry_run' | 'skipped' | 'failed'
reason: string | null
}
interface StoreIndex {
cpStoresBySpu: Map<string, Map<string, number>>
yyaSpus: Set<string>
}
interface FileEntry {
fullPath: string
relativePath: string
fileName: string
fileSize: number
}
interface SanitizedHistoryImportRecord {
record: HistoryImportRecord
skippedEmptyFiles: Array<{
group: 'main' | 'sku' | 'detail' | 'psd'
fileName: string
localFilePath?: string
}>
skippedInvalidFiles: Array<{
group: 'main' | 'sku' | 'detail'
fileName: string
fileSize: number
localFilePath?: string
reason: string
}>
}
function normalizeSpu(value: string) {
return value.trim().toUpperCase().replace(/+/g, '+').replace(/\s*\+\s*/g, '+')
}
function expandComboSpuToken(token: string) {
const parts = normalizeSpu(token).split('+').filter(Boolean)
const first = parts[0] || ''
const prefix = first.includes('-') ? first.split('-')[0] : ''
if (parts.length < 2 || !prefix) {
return null
}
const expanded = parts.map((part, index) => {
if (index === 0 || part.includes('-')) {
return part
}
return `${prefix}-${part}`
})
return expanded.join('+')
}
function inferComboSpuFromPath(sourcePath: string) {
const match = sourcePath.match(COMBO_SPU_TOKEN_PATTERN)
if (!match) {
return null
}
return expandComboSpuToken(match[0])
}
function isSpuName(name: string) {
return /^[A-Za-z]{1,5}\d{3,5}$/i.test(name.trim())
}
function matchesAny(input: string, patterns: RegExp[]) {
return patterns.some((pattern) => pattern.test(input))
}
function shouldIgnoreDir(fullPath: string) {
return matchesAny(fullPath, IGNORED_DIR_PATTERNS)
}
function shouldSkipFile(filePath: string) {
const ext = path.extname(filePath).toLowerCase()
return SKIPPED_FILE_EXTENSIONS.has(ext) || !IMAGE_EXTENSIONS.has(ext)
}
function makeFingerprint(spu: string, sourcePath: string) {
return crypto.createHash('sha1').update(`${normalizeSpu(spu)}|${sourcePath}`).digest('hex')
}
function scoreStoreByPath(sourcePath: string, stores: string[]) {
const hits = new Map<string, number>()
for (const store of stores) {
if (!store || store === '其他') {
continue
}
if (sourcePath.includes(store)) {
hits.set(store, (hits.get(store) || 0) + 3)
}
}
return hits
}
async function existsDir(dirPath: string) {
try {
const stat = await fs.stat(dirPath)
return stat.isDirectory()
} catch {
return false
}
}
async function walkDirectories(root: string, maxDepth = 6) {
const result: string[] = []
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
while (queue.length > 0) {
const current = queue.shift()!
if (current.depth > maxDepth) {
continue
}
let entries
try {
entries = await fs.readdir(current.dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
const fullPath = path.join(current.dir, entry.name)
if (shouldIgnoreDir(fullPath)) {
continue
}
result.push(fullPath)
queue.push({ dir: fullPath, depth: current.depth + 1 })
}
}
return result
}
async function walkFiles(root: string, maxDepth = 5) {
const result: FileEntry[] = []
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
while (queue.length > 0) {
const current = queue.shift()!
if (current.depth > maxDepth) {
continue
}
let entries
try {
entries = await fs.readdir(current.dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const fullPath = path.join(current.dir, entry.name)
if (entry.isDirectory()) {
if (shouldIgnoreDir(fullPath)) {
continue
}
queue.push({ dir: fullPath, depth: current.depth + 1 })
continue
}
if (shouldSkipFile(fullPath)) {
continue
}
try {
const stat = await fs.stat(fullPath)
result.push({
fullPath,
relativePath: path.relative(root, fullPath),
fileName: entry.name,
fileSize: stat.size,
})
} catch {
// ignore unreadable file
}
}
}
return result
}
async function buildStoreIndex(
cpRoot: string | undefined,
yyaRoot: string | undefined,
spus: string[],
): Promise<StoreIndex> {
const cpStoresBySpu = new Map<string, Map<string, number>>()
const yyaSpus = new Set<string>()
const normalizedSpus = Array.from(new Set(spus.map(normalizeSpu)))
if (cpRoot && (await existsDir(cpRoot)) && normalizedSpus.length > 0) {
const fastMatches = await searchPathsByRipgrep(cpRoot, normalizedSpus)
if (fastMatches.length > 0) {
for (const matchPath of fastMatches) {
const relativePath = path.relative(cpRoot, matchPath)
const [storeName] = relativePath.split(path.sep)
if (!storeName || STORE_DIR_IGNORES.has(storeName)) {
continue
}
const normalizedPath = matchPath.toUpperCase()
for (const spu of normalizedSpus) {
if (!normalizedPath.includes(spu)) {
continue
}
let stores = cpStoresBySpu.get(spu)
if (!stores) {
stores = new Map<string, number>()
cpStoresBySpu.set(spu, stores)
}
stores.set(storeName, (stores.get(storeName) || 0) + 1)
}
}
} else {
const storeDirs = await fs.readdir(cpRoot, { withFileTypes: true })
for (const entry of storeDirs) {
if (!entry.isDirectory() || STORE_DIR_IGNORES.has(entry.name)) {
continue
}
const storeName = entry.name.trim()
const storePath = path.join(cpRoot, entry.name)
const directories = await walkDirectories(storePath, 5)
for (const dirPath of directories) {
const normalizedPath = dirPath.toUpperCase()
for (const spu of normalizedSpus) {
if (!normalizedPath.includes(spu)) {
continue
}
let stores = cpStoresBySpu.get(spu)
if (!stores) {
stores = new Map<string, number>()
cpStoresBySpu.set(spu, stores)
}
stores.set(storeName, (stores.get(storeName) || 0) + 1)
}
}
}
}
}
if (yyaRoot && (await existsDir(yyaRoot)) && normalizedSpus.length > 0) {
const fastMatches = await searchPathsByRipgrep(yyaRoot, normalizedSpus)
if (fastMatches.length > 0) {
for (const matchPath of fastMatches) {
const normalizedPath = matchPath.toUpperCase()
for (const spu of normalizedSpus) {
if (normalizedPath.includes(spu)) {
yyaSpus.add(spu)
}
}
}
} else {
const directories = await walkDirectories(yyaRoot, 6)
for (const dirPath of directories) {
const normalizedPath = dirPath.toUpperCase()
for (const spu of normalizedSpus) {
if (normalizedPath.includes(spu)) {
yyaSpus.add(spu)
}
}
}
}
}
return {
cpStoresBySpu,
yyaSpus,
}
}
async function searchPathsByRipgrep(root: string, spus: string[]) {
try {
const normalizedSpus = Array.from(
new Set(
spus
.map((spu) => normalizeSpu(spu))
.filter(Boolean),
),
)
if (normalizedSpus.length === 0) {
return []
}
const args = ['--files', root]
for (const spu of normalizedSpus) {
args.push('-g', `*${spu}*`)
args.push('-g', `*${spu.toLowerCase()}*`)
}
const { stdout } = await execFileAsync(
'rg',
args,
{
cwd: process.cwd(),
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
},
)
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
function pickStore(spu: string, sourcePath: string, storeIndex: StoreIndex) {
const cpStores = storeIndex.cpStoresBySpu.get(normalizeSpu(spu))
const pathHits = scoreStoreByPath(sourcePath, cpStores ? Array.from(cpStores.keys()) : [])
if (cpStores) {
for (const [store, count] of Array.from(cpStores.entries())) {
pathHits.set(store, (pathHits.get(store) || 0) + count)
}
}
const candidates = Array.from(pathHits.entries()).sort((a, b) => b[1] - a[1])
if (candidates.length === 0) {
return {
guessedStore: '其他',
storeConfidence: 0,
storeCandidates: [],
}
}
const [bestStore, bestScore] = candidates[0]
const secondScore = candidates[1]?.[1] || 0
const storeConfidence = bestScore <= 0 ? 0 : Math.min(1, (bestScore - secondScore + 1) / (bestScore + 1))
return {
guessedStore: bestStore || '其他',
storeConfidence,
storeCandidates: candidates.map(([store]) => store),
}
}
function findSkuCode(spu: string, fileName: string, sortOrder: number) {
const normalizedSpu = normalizeSpu(spu)
const normalizedName = fileName.toUpperCase()
const exactMatch = normalizedName.match(new RegExp(`(${normalizedSpu}\\d{2,3})`, 'i'))
if (exactMatch?.[1]) {
return exactMatch[1].toUpperCase()
}
const skuMatch = normalizedName.match(/SKU[_-]?(\d{1,3})/i)
if (skuMatch?.[1]) {
return `${normalizedSpu}${skuMatch[1].padStart(2, '0')}`
}
return `${normalizedSpu}${String(sortOrder + 1).padStart(2, '0')}`
}
function classifyFileGroup(relativePath: string) {
const normalized = relativePath.replace(/\\/g, '/')
if (MAIN_FILE_EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized))) {
return null
}
if (DETAIL_FILE_EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized))) {
return null
}
if (matchesAny(normalized, SKU_HINT_PATTERNS)) {
return 'sku' as const
}
if (matchesAny(normalized, DETAIL_HINT_PATTERNS)) {
return 'detail' as const
}
if (matchesAny(normalized, MAIN_HINT_PATTERNS)) {
return 'main' as const
}
return null
}
function sortByNaturalFileName<T extends { fileName: string }>(items: T[]) {
return [...items].sort((left, right) =>
left.fileName.localeCompare(right.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }),
)
}
function buildAssetInputs(spu: string, files: FileEntry[], group: 'main' | 'sku' | 'detail') {
const sorted = sortByNaturalFileName(files)
const seenSkuCodes = new Map<string, number>()
return sorted.map((file, index) => {
if (group !== 'sku') {
return {
localFilePath: file.fullPath,
fileName: file.fileName,
fileSize: file.fileSize,
sortOrder: index,
}
}
const baseSkuCode = findSkuCode(spu, file.fileName, index)
const conflictCount = seenSkuCodes.get(baseSkuCode) || 0
seenSkuCodes.set(baseSkuCode, conflictCount + 1)
const skuCode = conflictCount === 0 ? baseSkuCode : `${baseSkuCode}-${conflictCount + 1}`
return {
localFilePath: file.fullPath,
fileName: file.fileName,
fileSize: file.fileSize,
sortOrder: index,
skuCode,
barcodes: [],
}
})
}
async function findCandidateDirs(spuDir: string) {
const allDirs = await walkDirectories(spuDir, 4)
const fullSetDirs = allDirs.filter((dirPath) => matchesAny(path.basename(dirPath), CANDIDATE_DIR_PATTERNS))
const sorted = Array.from(new Set(fullSetDirs)).sort((left, right) => left.length - right.length)
const collapsed: string[] = []
for (const candidate of sorted) {
const normalizedCandidate = `${path.normalize(candidate)}${path.sep}`
const hasAncestor = collapsed.some((existing) => normalizedCandidate.startsWith(`${path.normalize(existing)}${path.sep}`))
if (!hasAncestor) {
collapsed.push(candidate)
}
}
return collapsed
}
async function buildRecordsForSpuDir(spuDir: string, materialsRoot: string, storeIndex: StoreIndex) {
const spu = normalizeSpu(path.basename(spuDir))
const candidateDirs = await findCandidateDirs(spuDir)
const records: HistoryImportRecord[] = []
for (const candidateDir of candidateDirs) {
if (shouldIgnoreDir(candidateDir)) {
continue
}
const files = await walkFiles(candidateDir, 5)
const grouped = {
main: [] as FileEntry[],
sku: [] as FileEntry[],
detail: [] as FileEntry[],
}
for (const file of files) {
const group = classifyFileGroup(file.relativePath)
if (!group) {
continue
}
grouped[group].push(file)
}
const totalImages = grouped.main.length + grouped.sku.length + grouped.detail.length
if (totalImages === 0) {
continue
}
const isFullSet = isTaskDetailFullSetFromUploadGroups({
main: grouped.main,
sku: grouped.sku,
detail: grouped.detail,
})
const storeGuess = pickStore(spu, candidateDir, storeIndex)
const status: HistoryImportStatus = !isFullSet
? 'skipped'
: storeGuess.storeCandidates.length > 1 && storeGuess.storeConfidence < 0.4
? 'needs_review'
: 'ready'
records.push({
fingerprint: makeFingerprint(spu, candidateDir),
spu,
sourcePath: candidateDir,
relativeSourcePath: path.relative(materialsRoot, candidateDir),
guessedStore: storeGuess.guessedStore,
productCategoryName: '',
authorName: '历史导入',
storeConfidence: storeGuess.storeConfidence,
storeCandidates: storeGuess.storeCandidates,
mainCount: grouped.main.length,
skuCount: grouped.sku.length,
detailCount: grouped.detail.length,
status,
skipReason: isFullSet ? null : '不满足项目内全套定义:需同时拥有主图、SKU 图、详情图',
mainImages: buildAssetInputs(spu, grouped.main, 'main'),
skuImages: buildAssetInputs(spu, grouped.sku, 'sku'),
detailImages: buildAssetInputs(spu, grouped.detail, 'detail'),
seenInYya: storeIndex.yyaSpus.has(spu),
})
}
if (records.length === 0) {
records.push({
fingerprint: makeFingerprint(spu, spuDir),
spu,
sourcePath: spuDir,
relativeSourcePath: path.relative(materialsRoot, spuDir),
guessedStore: '其他',
productCategoryName: '',
authorName: '历史导入',
storeConfidence: 0,
storeCandidates: [],
mainCount: 0,
skuCount: 0,
detailCount: 0,
status: 'skipped',
skipReason: '未识别到可导入的主图、SKU 图或详情图',
mainImages: [],
skuImages: [],
detailImages: [],
seenInYya: storeIndex.yyaSpus.has(spu),
})
}
return records
}
export async function scanHistoryMaterials(options: ScanHistoryMaterialsOptions) {
const materialsRoot = options.materialsRoot
const spuFilter = options.spuFilter?.map(normalizeSpu)
const spuDirs = await findSpuDirectories(materialsRoot, spuFilter)
const limitedSpuDirs = options.limit ? spuDirs.slice(0, options.limit) : spuDirs
const storeIndex = await buildStoreIndex(
options.cpRoot,
options.yyaRoot,
limitedSpuDirs.map((dirPath) => path.basename(dirPath)),
)
const records: HistoryImportRecord[] = []
for (const spuDir of limitedSpuDirs) {
const spuRecords = await buildRecordsForSpuDir(spuDir, materialsRoot, storeIndex)
records.push(...spuRecords)
}
return records.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath, 'zh-Hans-CN'))
}
async function findSpuDirectories(materialsRoot: string, spuFilter?: string[]) {
if (spuFilter && spuFilter.length > 0) {
const fastFiles = await searchPathsByRipgrep(materialsRoot, spuFilter)
const dirs = new Set<string>()
if (fastFiles.length > 0) {
for (const filePath of fastFiles) {
const absoluteFilePath = path.isAbsolute(filePath) ? filePath : path.join(materialsRoot, filePath)
const parts = path.normalize(absoluteFilePath).split(path.sep)
for (let index = 0; index < parts.length; index += 1) {
const part = normalizeSpu(parts[index])
if (!isSpuName(part)) {
continue
}
if (!spuFilter.includes(part)) {
continue
}
const resolvedDir = parts.slice(0, index + 1).join(path.sep)
dirs.add(resolvedDir)
}
}
}
if (dirs.size > 0) {
return Array.from(dirs)
}
}
const allDirs = await walkDirectories(materialsRoot, 5)
return allDirs.filter((dirPath) => {
const spu = normalizeSpu(path.basename(dirPath))
if (!isSpuName(spu)) {
return false
}
return !spuFilter || spuFilter.length === 0 || spuFilter.includes(spu)
})
}
async function ensureCategoryAndScene(defaultCategory: string, defaultScene: string) {
const [category, scene] = await Promise.all([
prisma.category.findUnique({ where: { name: defaultCategory } }),
prisma.scene.findUnique({ where: { name: defaultScene } }),
])
if (!category) {
await prisma.category.create({ data: { name: defaultCategory } })
}
if (!scene) {
await prisma.scene.create({ data: { name: defaultScene } })
}
}
async function ensureCategoryAndSceneNames(categoryName: string, sceneName: string) {
const normalizedCategoryName = categoryName.trim() || '其他'
const normalizedSceneName = sceneName.trim() || '其他'
const [category, scene] = await Promise.all([
prisma.category.findUnique({ where: { name: normalizedCategoryName } }),
prisma.scene.findUnique({ where: { name: normalizedSceneName } }),
])
if (!category) {
await prisma.category.create({ data: { name: normalizedCategoryName } })
}
if (!scene) {
await prisma.scene.create({ data: { name: normalizedSceneName } })
}
}
async function hasImportedHistoryRecord(record: HistoryImportRecord) {
const existingByFingerprint = await prisma.materialLog.findFirst({
where: {
action: 'HISTORY_IMPORT',
material: {
deletedAt: null,
},
changes: {
contains: record.fingerprint,
} as any,
},
select: { id: true },
})
if (existingByFingerprint) {
return true
}
const existingBySourcePath = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT logs.id
FROM "material_logs" AS logs
INNER JOIN "materials" AS materials
ON materials.id = logs."materialId"
WHERE logs.action = 'HISTORY_IMPORT'
AND materials."deletedAt" IS NULL
AND logs.changes IS NOT NULL
AND logs.changes::jsonb->>'sourcePath' = ${record.sourcePath}
LIMIT 1
`
return existingBySourcePath.length > 0
}
function getBlockedReadySourceReason(record: HistoryImportRecord) {
const haystack = [
record.sourcePath,
record.relativeSourcePath,
record.skipReason || '',
].join('\n')
const matched = BLOCKED_READY_SOURCE_PATH_PATTERNS.find(({ pattern }) => pattern.test(haystack))
if (!matched) {
return null
}
return `来源路径包含“${matched.label}”,即使台账标记 ready 也禁止正式导入`
}
function getSpuMismatchReason(record: HistoryImportRecord) {
const inferredComboSpu = inferComboSpuFromPath(record.sourcePath)
if (!inferredComboSpu || normalizeSpu(record.spu) === inferredComboSpu) {
return null
}
return `来源路径包含组合 SPU“${inferredComboSpu}”,但台账 SPU 是“${record.spu}”,禁止按单 SPU 导入`
}
async function createHistoryImportMaterial(record: HistoryImportRecord, operatorUserId: string) {
return prisma.material.create({
data: {
productModel: record.spu,
categoryName: record.guessedStore || '其他',
sceneName: '其他',
platformName: '',
productCategoryName: record.productCategoryName || '',
materialType: '任务-详情',
previewUrl: '',
authorName: record.authorName || '历史导入',
userId: operatorUserId,
renderer: '',
isFullSet: false,
isAiGenerated: false,
taskDetailSourceType: normalizeTaskDetailSourceType(record.taskDetailSourceType) || '渲染',
} as any,
})
}
function filterNonEmptyAssets<T extends { fileName: string; fileSize: number; localFilePath?: string }>(
group: 'main' | 'sku' | 'detail' | 'psd',
assets: T[] | undefined,
skippedEmptyFiles: SanitizedHistoryImportRecord['skippedEmptyFiles'],
) {
return (assets || []).filter((asset) => {
if (asset.fileSize > 0) {
return true
}
skippedEmptyFiles.push({
group,
fileName: asset.fileName,
localFilePath: asset.localFilePath,
})
return false
})
}
async function filterReadableImageAssets<T extends { fileName: string; fileSize: number; localFilePath?: string }>(
group: 'main' | 'sku' | 'detail',
assets: T[],
skippedInvalidFiles: SanitizedHistoryImportRecord['skippedInvalidFiles'],
) {
if (assets.length === 0) {
return assets
}
const sharp = await import('sharp')
const readableAssets: T[] = []
for (const asset of assets) {
if (!asset.localFilePath) {
readableAssets.push(asset)
continue
}
try {
await sharp
.default(asset.localFilePath)
.resize(1, 1, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 1 })
.toBuffer()
readableAssets.push(asset)
} catch (error) {
skippedInvalidFiles.push({
group,
fileName: asset.fileName,
fileSize: asset.fileSize,
localFilePath: asset.localFilePath,
reason: error instanceof Error ? error.message : '图片无法读取',
})
}
}
return readableAssets
}
async function sanitizeHistoryImportRecord(record: HistoryImportRecord): Promise<SanitizedHistoryImportRecord> {
const skippedEmptyFiles: SanitizedHistoryImportRecord['skippedEmptyFiles'] = []
const skippedInvalidFiles: SanitizedHistoryImportRecord['skippedInvalidFiles'] = []
const nonEmptyMainImages = filterNonEmptyAssets('main', record.mainImages, skippedEmptyFiles)
const nonEmptySkuImages = filterNonEmptyAssets('sku', record.skuImages, skippedEmptyFiles)
const nonEmptyDetailImages = filterNonEmptyAssets('detail', record.detailImages, skippedEmptyFiles)
const sanitizedRecord: HistoryImportRecord = {
...record,
mainImages: await filterReadableImageAssets('main', nonEmptyMainImages, skippedInvalidFiles),
skuImages: await filterReadableImageAssets('sku', nonEmptySkuImages, skippedInvalidFiles),
detailImages: await filterReadableImageAssets('detail', nonEmptyDetailImages, skippedInvalidFiles),
psdSources: filterNonEmptyAssets('psd', record.psdSources, skippedEmptyFiles),
}
if (
sanitizedRecord.mainImages.length === 0 ||
sanitizedRecord.skuImages.length === 0 ||
sanitizedRecord.detailImages.length === 0
) {
throw new Error(
`过滤 0 字节文件后不满足导入条件:主图=${sanitizedRecord.mainImages.length} SKU=${sanitizedRecord.skuImages.length} 详情=${sanitizedRecord.detailImages.length}`,
)
}
return { record: sanitizedRecord, skippedEmptyFiles, skippedInvalidFiles }
}
export async function importHistoryMaterials(options: ImportHistoryMaterialsOptions) {
if (options.apply) {
await ensureCategoryAndScene('其他', '其他')
}
const candidates = options.records
.filter((record) => {
if (options.apply) {
return record.status === 'ready'
}
return record.status !== 'skipped'
})
.slice(0, options.limit || options.records.length)
const results: ImportHistoryMaterialsResultItem[] = []
for (const record of candidates) {
const blockedReadySourceReason = getBlockedReadySourceReason(record)
if (blockedReadySourceReason) {
results.push({
fingerprint: record.fingerprint,
spu: record.spu,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
materialId: null,
status: 'skipped',
reason: blockedReadySourceReason,
})
continue
}
const spuMismatchReason = getSpuMismatchReason(record)
if (spuMismatchReason) {
results.push({
fingerprint: record.fingerprint,
spu: record.spu,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
materialId: null,
status: 'skipped',
reason: spuMismatchReason,
})
continue
}
if (await hasImportedHistoryRecord(record)) {
results.push({
fingerprint: record.fingerprint,
spu: record.spu,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
materialId: null,
status: 'skipped',
reason: '该来源目录已导入过',
})
continue
}
if (!options.apply) {
results.push({
fingerprint: record.fingerprint,
spu: record.spu,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
materialId: null,
status: 'dry_run',
reason: null,
})
continue
}
try {
const sanitized = await sanitizeHistoryImportRecord(record)
await ensureCategoryAndSceneNames(record.guessedStore || '其他', '其他')
const material = await createHistoryImportMaterial(sanitized.record, options.operator.id)
await completeProductPackageUpload({
materialId: material.id,
taskNo: null,
operatorUserId: options.operator.id,
assets: {
main: sanitized.record.mainImages,
sku: sanitized.record.skuImages,
detail: sanitized.record.detailImages,
psd: sanitized.record.psdSources || [],
},
options: {
allowSkuWithoutBarcodes: true,
zipTimeoutMs: HISTORY_ZIP_TIMEOUT_MS,
allZipTimeoutMs: HISTORY_ALL_ZIP_TIMEOUT_MS,
},
})
await createMaterialLog({
materialId: material.id,
userId: options.operator.id,
action: 'HISTORY_IMPORT',
changes: {
fingerprint: record.fingerprint,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
storeCandidates: record.storeCandidates,
sourceCount: record.sourceCount || record.psdSources?.length || 0,
taskDetailSourceType: normalizeTaskDetailSourceType(record.taskDetailSourceType) || '渲染',
seenInYya: record.seenInYya,
skippedEmptyFiles: sanitized.skippedEmptyFiles,
skippedInvalidFiles: sanitized.skippedInvalidFiles,
},
})
results.push({
fingerprint: record.fingerprint,
spu: record.spu,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
materialId: material.id,
status: 'imported',
reason: null,
})
} catch (error) {
results.push({
fingerprint: record.fingerprint,
spu: record.spu,
sourcePath: record.sourcePath,
guessedStore: record.guessedStore,
materialId: null,
status: 'failed',
reason: error instanceof Error ? error.message : '导入失败',
})
}
}
return results
}
export async function resolveImportOperator(identifier: string) {
const normalized = identifier.trim()
if (!normalized) {
throw new Error('缺少导入执行人标识')
}
const user = await prisma.user.findFirst({
where: {
archivedAt: null,
OR: [{ email: normalized }, { username: normalized }, { realName: normalized }],
},
select: {
id: true,
username: true,
realName: true,
},
})
if (!user) {
throw new Error(`未找到导入执行人:${identifier}`)
}
return user
}