- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验 - SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示 - 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签 - 补充历史导入相关脚本和工具文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
769 lines
22 KiB
TypeScript
769 lines
22 KiB
TypeScript
import path from 'path'
|
||
import { promises as fs } from 'fs'
|
||
import crypto from 'crypto'
|
||
import type { ProductPackageImageInput } from '@/lib/product-package-completion'
|
||
import type { HistoryImportRecord, HistoryImportStatus } from '@/lib/history-material-import'
|
||
import { getShopPrefixMap, parseSPU } from '@/lib/spu-parser'
|
||
|
||
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,
|
||
/DingTalkAppData/i,
|
||
/^PsTool$/i,
|
||
/^Cache$/i,
|
||
/RECYCLE/i,
|
||
]
|
||
const EXCLUDED_FILE_NAME_PATTERNS = [
|
||
/通道/i,
|
||
/遮罩/i,
|
||
/\balpha\b/i,
|
||
/材质/i,
|
||
/测试/i,
|
||
/过程/i,
|
||
/副本/i,
|
||
/拷贝/i,
|
||
/导出/i,
|
||
/未修/i,
|
||
/旧版/i,
|
||
/mask/i,
|
||
]
|
||
|
||
const BRANDED_SPU_TOKEN_PATTERN = /([A-Za-z]{2,5}-[A-Za-z]{1,5}\d{3,5})/g
|
||
const PRIMARY_SPU_TOKEN_PATTERN = /([A-Za-z]{2,3}\d{4})/g
|
||
const FALLBACK_SPU_TOKEN_PATTERN = /([A-Za-z]{1,5}\d{3,5})/g
|
||
const IGNORED_SPU_PREFIXES = ['TASK', 'SKU', 'IMG']
|
||
const EXTRA_SHOP_PREFIX_MAP: Record<string, string> = {
|
||
GBJP: '顾家',
|
||
}
|
||
const EXTRA_CATEGORY_CODE_MAP: Record<string, string> = {
|
||
BI: '边几',
|
||
CFZWJ: '置物架',
|
||
SMC: '床',
|
||
}
|
||
const CATEGORY_CODE_PATTERN = /^([A-Z]{1,5})\d/
|
||
const AUTHOR_SEGMENT_EXCLUDES = new Set([
|
||
'亿家木',
|
||
'亿家达',
|
||
'卓和',
|
||
'卓禾',
|
||
'朴美',
|
||
'橡野里',
|
||
'欧意朗',
|
||
'索菲亚',
|
||
'顾家',
|
||
'详情全套',
|
||
'详情全案',
|
||
'视觉全套',
|
||
'平面全套',
|
||
'详情',
|
||
'详情页',
|
||
'主图',
|
||
])
|
||
|
||
type ParsedSkuCsvRow = {
|
||
sourceRoot: string
|
||
spu: string
|
||
directoryName: string
|
||
relativePath: string
|
||
windowsPath: string
|
||
skuDirPath: string
|
||
parentPath: string
|
||
grandparentPath: string
|
||
siblingDetailDirNames: string[]
|
||
siblingMainDirNames: string[]
|
||
siblingDirNames: string[]
|
||
}
|
||
|
||
type FileEntry = {
|
||
fullPath: string
|
||
relativePath: string
|
||
fileName: string
|
||
fileSize: number
|
||
}
|
||
|
||
export interface BuildHistoryImportRecordsFromSkuCsvOptions {
|
||
csvPath: string
|
||
limit?: number
|
||
sourceRoots?: string[]
|
||
visualCenterAuthors?: string[]
|
||
}
|
||
|
||
function normalizeSpu(value: string) {
|
||
return value.trim().toUpperCase()
|
||
}
|
||
|
||
function matchesIgnoredDir(fullPath: string) {
|
||
return IGNORED_DIR_PATTERNS.some((pattern) => pattern.test(fullPath))
|
||
}
|
||
|
||
function shouldSkipFile(filePath: string) {
|
||
const ext = path.extname(filePath).toLowerCase()
|
||
const fileName = path.basename(filePath)
|
||
return (
|
||
SKIPPED_FILE_EXTENSIONS.has(ext) ||
|
||
!IMAGE_EXTENSIONS.has(ext) ||
|
||
EXCLUDED_FILE_NAME_PATTERNS.some((pattern) => pattern.test(fileName))
|
||
)
|
||
}
|
||
|
||
function parseCsvLine(line: string) {
|
||
const result: string[] = []
|
||
let current = ''
|
||
let inQuotes = false
|
||
|
||
for (let index = 0; index < line.length; index += 1) {
|
||
const char = line[index]
|
||
const next = line[index + 1]
|
||
|
||
if (char === '"') {
|
||
if (inQuotes && next === '"') {
|
||
current += '"'
|
||
index += 1
|
||
} else {
|
||
inQuotes = !inQuotes
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (char === ',' && !inQuotes) {
|
||
result.push(current)
|
||
current = ''
|
||
continue
|
||
}
|
||
|
||
current += char
|
||
}
|
||
|
||
result.push(current)
|
||
return result
|
||
}
|
||
|
||
function parseJsonArray(value: string) {
|
||
const trimmed = String(value || '').trim()
|
||
if (!trimmed) {
|
||
return []
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(trimmed)
|
||
return Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function getHeaderIndexes(header: string[]) {
|
||
const normalized = header.map((item) => item.replace(/^\uFEFF/, ''))
|
||
const pickIndex = (name: string) => normalized.indexOf(name)
|
||
|
||
return {
|
||
sourceRoot: pickIndex('source_root(来源根路径)'),
|
||
spu: pickIndex('spu(SPU)'),
|
||
directoryName: pickIndex('directory_name(目录名称)'),
|
||
relativePath: pickIndex('relative_path(相对路径)'),
|
||
windowsPath: pickIndex('windows_path(Windows路径)'),
|
||
skuDirPath: pickIndex('sku_dir_path(SKU目录完整路径)'),
|
||
parentPath: pickIndex('parent_path(上级目录)'),
|
||
grandparentPath: pickIndex('grandparent_path(上上级目录)'),
|
||
siblingDetailDirNames: pickIndex('sibling_detail_dir_names_json(同级详情目录)'),
|
||
siblingMainDirNames: pickIndex('sibling_main_dir_names_json(同级主图目录)'),
|
||
siblingDirNames: pickIndex('sibling_dir_names_json(同级全部目录)'),
|
||
}
|
||
}
|
||
|
||
async function parseSkuCsv(csvPath: string) {
|
||
const raw = await fs.readFile(csvPath, 'utf8')
|
||
const lines = raw.split(/\r?\n/).filter((line) => line.length > 0)
|
||
if (lines.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const header = parseCsvLine(lines[0])
|
||
const indexes = getHeaderIndexes(header)
|
||
|
||
return lines.slice(1).map((line) => {
|
||
const row = parseCsvLine(line)
|
||
return {
|
||
sourceRoot: row[indexes.sourceRoot] || '',
|
||
spu: normalizeSpu(row[indexes.spu] || ''),
|
||
directoryName: row[indexes.directoryName] || '',
|
||
relativePath: row[indexes.relativePath] || '',
|
||
windowsPath: row[indexes.windowsPath] || '',
|
||
skuDirPath: row[indexes.skuDirPath] || '',
|
||
parentPath: row[indexes.parentPath] || '',
|
||
grandparentPath: row[indexes.grandparentPath] || '',
|
||
siblingDetailDirNames: parseJsonArray(row[indexes.siblingDetailDirNames] || ''),
|
||
siblingMainDirNames: parseJsonArray(row[indexes.siblingMainDirNames] || ''),
|
||
siblingDirNames: parseJsonArray(row[indexes.siblingDirNames] || ''),
|
||
} satisfies ParsedSkuCsvRow
|
||
})
|
||
}
|
||
|
||
function inferSpuFromText(value: string) {
|
||
const text = String(value || '')
|
||
const isStandaloneToken = (matchIndex: number, token: string) => {
|
||
const prevChar = matchIndex > 0 ? text[matchIndex - 1] : ''
|
||
const nextChar = text[matchIndex + token.length] || ''
|
||
if (/[A-Za-z0-9]/.test(prevChar)) {
|
||
return false
|
||
}
|
||
if (!/[A-Za-z0-9]/.test(nextChar)) {
|
||
return true
|
||
}
|
||
return /^K\d{1,2}(?![A-Za-z0-9])/i.test(text.slice(matchIndex + token.length))
|
||
}
|
||
|
||
BRANDED_SPU_TOKEN_PATTERN.lastIndex = 0
|
||
const brandedMatches = Array.from(text.matchAll(BRANDED_SPU_TOKEN_PATTERN))
|
||
if (brandedMatches.length > 0) {
|
||
const shopPrefixMap = { ...getShopPrefixMap(), ...EXTRA_SHOP_PREFIX_MAP }
|
||
for (let index = brandedMatches.length - 1; index >= 0; index -= 1) {
|
||
const currentMatch = brandedMatches[index]
|
||
const rawToken = currentMatch?.[1] || ''
|
||
const token = normalizeSpu(rawToken)
|
||
const [shopPrefix, skuPart] = token.split('-')
|
||
const matchIndex = currentMatch?.index ?? -1
|
||
if (
|
||
token &&
|
||
shopPrefixMap[shopPrefix] &&
|
||
skuPart &&
|
||
matchIndex >= 0 &&
|
||
isStandaloneToken(matchIndex, rawToken)
|
||
) {
|
||
return token
|
||
}
|
||
}
|
||
}
|
||
|
||
PRIMARY_SPU_TOKEN_PATTERN.lastIndex = 0
|
||
const primaryMatches = Array.from(text.matchAll(PRIMARY_SPU_TOKEN_PATTERN))
|
||
if (primaryMatches.length > 0) {
|
||
for (let index = primaryMatches.length - 1; index >= 0; index -= 1) {
|
||
const currentMatch = primaryMatches[index]
|
||
const token = normalizeSpu(currentMatch?.[1] || '')
|
||
const matchIndex = currentMatch?.index ?? -1
|
||
if (
|
||
token &&
|
||
matchIndex >= 0 &&
|
||
isStandaloneToken(matchIndex, currentMatch?.[1] || '') &&
|
||
!IGNORED_SPU_PREFIXES.some((prefix) => token.startsWith(prefix))
|
||
) {
|
||
return token
|
||
}
|
||
}
|
||
}
|
||
|
||
FALLBACK_SPU_TOKEN_PATTERN.lastIndex = 0
|
||
const fallbackMatches = Array.from(text.matchAll(FALLBACK_SPU_TOKEN_PATTERN))
|
||
if (fallbackMatches.length > 0) {
|
||
for (let index = fallbackMatches.length - 1; index >= 0; index -= 1) {
|
||
const currentMatch = fallbackMatches[index]
|
||
const token = normalizeSpu(currentMatch?.[1] || '')
|
||
const matchIndex = currentMatch?.index ?? -1
|
||
if (
|
||
token &&
|
||
matchIndex >= 0 &&
|
||
isStandaloneToken(matchIndex, currentMatch?.[1] || '') &&
|
||
!IGNORED_SPU_PREFIXES.some((prefix) => token.startsWith(prefix))
|
||
) {
|
||
return token
|
||
}
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function hasStandaloneSpuToken(text: string, token: string) {
|
||
const source = String(text || '')
|
||
const normalizedToken = normalizeSpu(token)
|
||
if (!source || !normalizedToken) {
|
||
return false
|
||
}
|
||
|
||
const upperSource = source.toUpperCase()
|
||
const upperToken = normalizedToken.toUpperCase()
|
||
let searchIndex = upperSource.indexOf(upperToken)
|
||
while (searchIndex >= 0) {
|
||
const prevChar = searchIndex > 0 ? upperSource[searchIndex - 1] : ''
|
||
const nextChar = upperSource[searchIndex + upperToken.length] || ''
|
||
if (!/[A-Z]/.test(prevChar) && !/[A-Z0-9]/.test(nextChar)) {
|
||
return true
|
||
}
|
||
searchIndex = upperSource.indexOf(upperToken, searchIndex + 1)
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function inferSpu(row: ParsedSkuCsvRow) {
|
||
const candidates = [
|
||
row.relativePath,
|
||
row.skuDirPath,
|
||
row.parentPath,
|
||
row.grandparentPath,
|
||
row.windowsPath,
|
||
]
|
||
|
||
if (row.spu) {
|
||
const normalizedRowSpu = normalizeSpu(row.spu)
|
||
const rowSpuLooksUsable =
|
||
normalizedRowSpu &&
|
||
!IGNORED_SPU_PREFIXES.some((prefix) => normalizedRowSpu.startsWith(prefix)) &&
|
||
(/^[A-Z]{2,5}-[A-Z]{1,5}\d{3,5}$/.test(normalizedRowSpu) ||
|
||
/^[A-Z]{1,5}\d{3,5}$/.test(normalizedRowSpu))
|
||
|
||
if (rowSpuLooksUsable) {
|
||
return normalizedRowSpu
|
||
}
|
||
}
|
||
|
||
for (const candidate of candidates) {
|
||
const inferred = inferSpuFromText(candidate)
|
||
if (inferred) {
|
||
return inferred
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function extractSkuSuffixFromFileName(fileName: string) {
|
||
const normalizedName = String(fileName || '').toUpperCase()
|
||
|
||
const fullSkuMatch = normalizedName.match(/([A-Z]{2,5}\d{6,7})/)
|
||
if (fullSkuMatch?.[1]) {
|
||
return fullSkuMatch[1].slice(-2)
|
||
}
|
||
|
||
const skuHintMatch = normalizedName.match(/SKU[_-]?(\d{1,2})/i)
|
||
if (skuHintMatch?.[1]) {
|
||
return skuHintMatch[1].padStart(2, '0')
|
||
}
|
||
|
||
const pureNumberMatch = normalizedName.match(/(?:^|[^0-9])(\d{6})(?:[^0-9]|$)/)
|
||
if (pureNumberMatch?.[1]) {
|
||
return pureNumberMatch[1].slice(-2)
|
||
}
|
||
|
||
const trailingNumberMatch = normalizedName.match(/(?:^|[^0-9])(\d{1,2})(?:[^0-9]|$)/)
|
||
if (trailingNumberMatch?.[1]) {
|
||
return trailingNumberMatch[1].padStart(2, '0')
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function pickPreferredDirName(names: string[], type: 'detail' | 'main') {
|
||
const deduped = Array.from(new Set(names.map((name) => name.trim()).filter(Boolean)))
|
||
if (deduped.length === 0) {
|
||
return ''
|
||
}
|
||
|
||
if (type === 'detail') {
|
||
const detailPriority = ['详情页', '详情', 'images']
|
||
for (const preferred of detailPriority) {
|
||
const exact = deduped.find((name) => name === preferred)
|
||
if (exact) {
|
||
return exact
|
||
}
|
||
}
|
||
return deduped[0]
|
||
}
|
||
|
||
const mainPriority = ['主图', '新主图']
|
||
for (const preferred of mainPriority) {
|
||
const exact = deduped.find((name) => name === preferred)
|
||
if (exact) {
|
||
return exact
|
||
}
|
||
}
|
||
return deduped[0]
|
||
}
|
||
|
||
async function walkFiles(root: string, maxDepth = 4) {
|
||
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: any[]
|
||
try {
|
||
entries = await fs.readdir(current.dir, { withFileTypes: true })
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
const entryName = String(entry.name)
|
||
const fullPath = path.join(current.dir, entryName)
|
||
if (entry.isDirectory()) {
|
||
if (matchesIgnoredDir(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: entryName,
|
||
fileSize: stat.size,
|
||
})
|
||
} catch {
|
||
// ignore unreadable file
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
function dedupeFiles(files: FileEntry[]) {
|
||
const seen = new Set<string>()
|
||
const result: FileEntry[] = []
|
||
|
||
for (const file of files) {
|
||
const key = path.normalize(file.fullPath).toLowerCase()
|
||
if (seen.has(key)) {
|
||
continue
|
||
}
|
||
seen.add(key)
|
||
result.push(file)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
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 findSkuCode(spu: string, fileName: string, sortOrder: number) {
|
||
const normalizedSpu = normalizeSpu(spu)
|
||
const normalizedName = fileName.toUpperCase()
|
||
const exactMatch = normalizedSpu ? normalizedName.match(new RegExp(`(${normalizedSpu}\\d{2})`, 'i')) : null
|
||
if (exactMatch?.[1]) {
|
||
return exactMatch[1].toUpperCase()
|
||
}
|
||
|
||
const extractedSuffix = extractSkuSuffixFromFileName(fileName)
|
||
if (normalizedSpu && extractedSuffix) {
|
||
return `${normalizedSpu}${extractedSuffix}`
|
||
}
|
||
|
||
return normalizedSpu ? `${normalizedSpu}${String(sortOrder + 1).padStart(2, '0')}` : `SKU${String(sortOrder + 1).padStart(2, '0')}`
|
||
}
|
||
|
||
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,
|
||
} satisfies ProductPackageImageInput
|
||
}
|
||
|
||
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: [],
|
||
} satisfies ProductPackageImageInput
|
||
})
|
||
}
|
||
|
||
function inferStoreBySpu(spu: string, row?: ParsedSkuCsvRow) {
|
||
const sourceRootStore = inferStoreBySourceRoot(row)
|
||
if (sourceRootStore) {
|
||
return {
|
||
guessedStore: sourceRootStore,
|
||
storeConfidence: 1,
|
||
storeCandidates: [sourceRootStore],
|
||
}
|
||
}
|
||
|
||
const normalizedSpu = normalizeSpu(spu)
|
||
const spuParseResult = parseSPU(normalizedSpu)
|
||
if (spuParseResult.shopMatched && spuParseResult.shopName) {
|
||
return {
|
||
guessedStore: spuParseResult.shopName,
|
||
storeConfidence: 1,
|
||
storeCandidates: [spuParseResult.shopName],
|
||
}
|
||
}
|
||
|
||
const storeProbeText = [
|
||
normalizedSpu,
|
||
row?.relativePath || '',
|
||
row?.skuDirPath || '',
|
||
row?.parentPath || '',
|
||
row?.grandparentPath || '',
|
||
row?.windowsPath || '',
|
||
]
|
||
.join('\n')
|
||
.toUpperCase()
|
||
const shopPrefixMap = { ...getShopPrefixMap(), ...EXTRA_SHOP_PREFIX_MAP }
|
||
const matchedPrefix = Object.keys(shopPrefixMap)
|
||
.sort((left, right) => right.length - left.length)
|
||
.find((prefix) => new RegExp(`(^|[^A-Z0-9])${prefix}-`).test(storeProbeText))
|
||
|
||
if (matchedPrefix) {
|
||
const storeName = shopPrefixMap[matchedPrefix]
|
||
return {
|
||
guessedStore: storeName,
|
||
storeConfidence: 1,
|
||
storeCandidates: [storeName],
|
||
}
|
||
}
|
||
|
||
return {
|
||
guessedStore: '其他',
|
||
storeConfidence: 0,
|
||
storeCandidates: [],
|
||
}
|
||
}
|
||
|
||
function inferStoreBySourceRoot(row?: ParsedSkuCsvRow) {
|
||
const sourceRoot = row?.sourceRoot || ''
|
||
const probes = [
|
||
['顾家项目', '顾家'],
|
||
['金牌项目', '顾家'],
|
||
['朴美项目', '朴美'],
|
||
['索菲亚项目', '索菲亚'],
|
||
] as const
|
||
|
||
const matched = probes.find(([keyword]) => sourceRoot.includes(keyword))
|
||
return matched?.[1] || ''
|
||
}
|
||
|
||
function inferProductCategoryBySpu(spu: string) {
|
||
const spuParseResult = parseSPU(spu)
|
||
if (spuParseResult.categoryMatched) {
|
||
return spuParseResult.categoryName
|
||
}
|
||
|
||
const normalizedSpu = normalizeSpu(spu)
|
||
const skuPart = normalizedSpu.includes('-') ? normalizedSpu.split('-').pop() || '' : normalizedSpu
|
||
const skuPartParseResult = parseSPU(skuPart)
|
||
if (skuPartParseResult.categoryMatched) {
|
||
return skuPartParseResult.categoryName
|
||
}
|
||
|
||
const categoryCode = skuPart.match(CATEGORY_CODE_PATTERN)?.[1] || ''
|
||
return EXTRA_CATEGORY_CODE_MAP[categoryCode] || ''
|
||
}
|
||
|
||
function normalizeAuthorToken(value: string) {
|
||
return value.replace(/[\s()()_\-]+/g, '').trim().toLowerCase()
|
||
}
|
||
|
||
function matchVisualCenterAuthor(row: ParsedSkuCsvRow, visualCenterAuthors: string[]) {
|
||
const authors = visualCenterAuthors
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.map((item) => ({
|
||
original: item,
|
||
normalized: normalizeAuthorToken(item),
|
||
}))
|
||
|
||
if (authors.length === 0) {
|
||
return ''
|
||
}
|
||
|
||
const segments = row.relativePath
|
||
.split('/')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
|
||
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||
const segment = segments[index]
|
||
const normalizedSegment = normalizeAuthorToken(segment)
|
||
if (!normalizedSegment) {
|
||
continue
|
||
}
|
||
|
||
for (const author of authors) {
|
||
if (!author.normalized) {
|
||
continue
|
||
}
|
||
if (normalizedSegment.includes(author.normalized)) {
|
||
return author.original
|
||
}
|
||
}
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function inferAuthorFromRow(row: ParsedSkuCsvRow, visualCenterAuthors: string[]) {
|
||
const matchedAuthor = matchVisualCenterAuthor(row, visualCenterAuthors)
|
||
if (matchedAuthor) {
|
||
return matchedAuthor
|
||
}
|
||
|
||
const candidates = row.relativePath
|
||
.split('/')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
|
||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||
const candidate = candidates[index]
|
||
if (/^(sku|newsku|新版sku|新sku)$/i.test(candidate)) {
|
||
continue
|
||
}
|
||
|
||
const chineseMatches = candidate.match(/[\u4e00-\u9fa5]{2,4}/g) || []
|
||
for (const item of chineseMatches.reverse()) {
|
||
if (!item || AUTHOR_SEGMENT_EXCLUDES.has(item) || /详情|视觉|平面|主图/.test(item)) {
|
||
continue
|
||
}
|
||
return item
|
||
}
|
||
}
|
||
|
||
return '历史导入'
|
||
}
|
||
|
||
function makeFingerprint(spu: string, sourcePath: string) {
|
||
return crypto.createHash('sha1').update(`${normalizeSpu(spu)}|${sourcePath}`).digest('hex')
|
||
}
|
||
|
||
function normalizeDuplicatePathKey(relativeSourcePath: string) {
|
||
const segments = relativeSourcePath
|
||
.split(/[\\/]+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
|
||
if (segments.length > 1 && segments[0] === segments[1]) {
|
||
segments.shift()
|
||
}
|
||
|
||
return segments.join('/').toLowerCase()
|
||
}
|
||
|
||
function makeDuplicateKey(spu: string, relativeSourcePath: string) {
|
||
return `${normalizeSpu(spu)}|${normalizeDuplicatePathKey(relativeSourcePath)}`
|
||
}
|
||
|
||
export async function buildHistoryImportRecordsFromSkuCsv(
|
||
options: BuildHistoryImportRecordsFromSkuCsvOptions,
|
||
) {
|
||
const rows = await parseSkuCsv(options.csvPath)
|
||
const filteredRows = rows.filter((row) => {
|
||
if (!options.sourceRoots || options.sourceRoots.length === 0) {
|
||
return true
|
||
}
|
||
return options.sourceRoots.includes(row.sourceRoot)
|
||
})
|
||
|
||
const limitedRows = options.limit ? filteredRows.slice(0, options.limit) : filteredRows
|
||
const visualCenterAuthors = Array.from(
|
||
new Set((options.visualCenterAuthors || []).map((item) => item.trim()).filter(Boolean)),
|
||
)
|
||
const records: HistoryImportRecord[] = []
|
||
const seenDuplicateKeys = new Set<string>()
|
||
|
||
for (const row of limitedRows) {
|
||
const spu = inferSpu(row)
|
||
const duplicateKey = makeDuplicateKey(spu || row.directoryName || 'UNKNOWN', row.relativePath)
|
||
if (seenDuplicateKeys.has(duplicateKey)) {
|
||
continue
|
||
}
|
||
seenDuplicateKeys.add(duplicateKey)
|
||
|
||
const detailDirName = pickPreferredDirName(row.siblingDetailDirNames, 'detail')
|
||
const mainDirName = pickPreferredDirName(row.siblingMainDirNames, 'main')
|
||
|
||
const skuFiles = dedupeFiles(await walkFiles(row.skuDirPath, 4))
|
||
const detailFiles = detailDirName ? dedupeFiles(await walkFiles(path.join(row.parentPath, detailDirName), 4)) : []
|
||
const mainFiles = mainDirName ? dedupeFiles(await walkFiles(path.join(row.parentPath, mainDirName), 4)) : []
|
||
|
||
const storeGuess = inferStoreBySpu(spu, row)
|
||
const hasFullSet = mainFiles.length > 0 && skuFiles.length > 0 && detailFiles.length > 0
|
||
const productCategoryName = inferProductCategoryBySpu(spu)
|
||
const authorName = inferAuthorFromRow(row, visualCenterAuthors)
|
||
|
||
let status: HistoryImportStatus = 'ready'
|
||
let skipReason: string | null = null
|
||
|
||
if (!hasFullSet) {
|
||
status = 'skipped'
|
||
skipReason = '不满足历史导入要求:需同时拥有同级主图、SKU 图、详情图'
|
||
} else if (!spu) {
|
||
status = 'needs_review'
|
||
skipReason = 'SPU 无法自动识别'
|
||
}
|
||
|
||
records.push({
|
||
fingerprint: makeFingerprint(spu || row.directoryName || 'UNKNOWN', row.skuDirPath),
|
||
spu,
|
||
sourcePath: row.skuDirPath,
|
||
relativeSourcePath: row.relativePath,
|
||
guessedStore: storeGuess.guessedStore,
|
||
productCategoryName,
|
||
authorName,
|
||
storeConfidence: storeGuess.storeConfidence,
|
||
storeCandidates: storeGuess.storeCandidates,
|
||
mainCount: mainFiles.length,
|
||
skuCount: skuFiles.length,
|
||
detailCount: detailFiles.length,
|
||
status,
|
||
skipReason,
|
||
mainImages: buildAssetInputs(spu, mainFiles, 'main'),
|
||
skuImages: buildAssetInputs(spu, skuFiles, 'sku'),
|
||
detailImages: buildAssetInputs(spu, detailFiles, 'detail'),
|
||
seenInYya: row.sourceRoot.includes('/mnt/history-yya'),
|
||
})
|
||
}
|
||
|
||
return records
|
||
}
|