gongxiangp2/lib/history-sku-csv-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

769 lines
22 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 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
}