index.coffee 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. # app/pages/Admin/Import/index.coffee
  2. ImportService = require 'app/services/ImportService'
  3. ProductService = require 'app/services/ProductService'
  4. CategoryService = require 'app/services/CategoryService'
  5. FileUpload = require 'app/components/Admin/FileUpload/index.coffee'
  6. DataTable = require 'app/components/Admin/DataTable/index.coffee'
  7. if globalThis.stylFns and globalThis.stylFns['app/pages/Admin/Import/index.styl']
  8. styleElement = document.createElement('style')
  9. styleElement.type = 'text/css'
  10. styleElement.textContent = globalThis.stylFns['app/pages/Admin/Import/index.styl']
  11. document.head.appendChild(styleElement)
  12. module.exports = {
  13. components: {
  14. 'file-upload': FileUpload
  15. 'data-table': DataTable
  16. }
  17. data: ->
  18. {
  19. currentStep: 1
  20. selectedFile: null
  21. csvFields: []
  22. csvData: []
  23. fieldMapping: {}
  24. previewData: []
  25. previewLoading: false
  26. importing: false
  27. importProgress: 0
  28. processedItems: 0
  29. totalItems: 0
  30. importStats: null
  31. importErrors: []
  32. importComplete: false
  33. newCategories: []
  34. servicesInitialized: false
  35. initializingServices: false
  36. }
  37. computed:
  38. previewColumns: ->
  39. [
  40. { key: 'name', title: 'Название' }
  41. { key: 'sku', title: 'Артикул' }
  42. { key: 'price', title: 'Цена' }
  43. { key: 'brand', title: 'Бренд' }
  44. { key: 'category', title: 'Категория' }
  45. ]
  46. methods:
  47. onFileSelect: (files) ->
  48. @selectedFile = files[0]
  49. @parseCSVFile()
  50. parseCSVFile: ->
  51. return unless @selectedFile
  52. reader = new FileReader()
  53. reader.onload = (e) =>
  54. try
  55. log '📊 Начало парсинга CSV файла'
  56. # Используем Papa Parse с настройками для русского CSV
  57. results = Papa.parse(e.target.result, {
  58. header: true
  59. delimiter: ';'
  60. skipEmptyLines: true
  61. encoding: 'UTF-8'
  62. quoteChar: '"'
  63. escapeChar: '"'
  64. dynamicTyping: false # Оставляем все как строки
  65. })
  66. if results.errors.length > 0
  67. log '⚠️ Предупреждения при парсинге CSV: ' + JSON.stringify(results.errors)
  68. @csvData = results.data.filter (row) ->
  69. # Фильтруем пустые строки и строки без обязательных полей
  70. row and row['Артикул*'] and row['Название товара'] and row['Цена, руб.*']
  71. @csvFields = Object.keys(@csvData[0] || {})
  72. log '📊 CSV файл распарсен успешно:'
  73. log 'Количество записей: ' + @csvData.length
  74. log 'Поля CSV: ' + @csvFields.join(', ')
  75. # Выводим первую запись для отладки
  76. if @csvData.length > 0
  77. log 'Пример первой записи: ' + JSON.stringify(@csvData[0], null, 2)
  78. @initializeFieldMapping()
  79. catch error
  80. log '❌ Ошибка парсинга CSV: ' + error.message
  81. console.error('Ошибка парсинга:', error)
  82. @$emit('show-notification', 'Ошибка чтения CSV файла: ' + error.message, 'error')
  83. reader.onerror = (error) =>
  84. log '❌ Ошибка чтения файла: ' + error
  85. @$emit('show-notification', 'Ошибка чтения файла', 'error')
  86. reader.readAsText(@selectedFile, 'UTF-8')
  87. initializeFieldMapping: ->
  88. @fieldMapping = {}
  89. # Автоматическое сопоставление полей по ключевым словам
  90. mappingRules = [
  91. { pattern: 'артикул', field: 'sku' }
  92. { pattern: 'название', field: 'name' }
  93. { pattern: 'цена, руб', field: 'price' }
  94. { pattern: 'цена до скидки', field: 'oldPrice' }
  95. { pattern: 'бренд', field: 'brand' }
  96. { pattern: 'тип', field: 'category' }
  97. { pattern: 'описание', field: 'description' }
  98. { pattern: 'аннотация', field: 'description' }
  99. { pattern: 'ссылка на главное фото', field: 'mainImage' }
  100. { pattern: 'ссылки на дополнительные фото', field: 'additionalImages' }
  101. ]
  102. @csvFields.forEach (csvField) =>
  103. lowerField = csvField.toLowerCase()
  104. matched = false
  105. for rule in mappingRules
  106. if lowerField.includes(rule.pattern)
  107. @fieldMapping[csvField] = rule.field
  108. matched = true
  109. break
  110. if not matched and csvField != '№'
  111. @fieldMapping[csvField] = '' # Не импортировать
  112. log '🔄 Автоматическое сопоставление полей: ' + JSON.stringify(@fieldMapping)
  113. nextStep: ->
  114. @currentStep++
  115. prevStep: ->
  116. @currentStep--
  117. validateMapping: ->
  118. requiredFields = ['name', 'sku', 'price']
  119. mappedFields = Object.values(@fieldMapping)
  120. missingFields = requiredFields.filter (field) ->
  121. not mappedFields.includes(field)
  122. if missingFields.length > 0
  123. @$emit('show-notification', 'Заполните обязательные поля: ' + missingFields.join(', '), 'error')
  124. return
  125. @generatePreview()
  126. @nextStep()
  127. generatePreview: ->
  128. @previewLoading = true
  129. try
  130. # Обрабатываем только первые 50 записей для предпросмотра
  131. @previewData = @csvData.slice(0, 50).map (row, index) =>
  132. @transformRowToProduct(row, index)
  133. # Сбор уникальных категорий
  134. categorySet = new Set()
  135. @previewData.forEach (item) ->
  136. if item.category
  137. categorySet.add(item.category)
  138. @newCategories = Array.from(categorySet)
  139. @previewLoading = false
  140. log '👀 Предпросмотр сгенерирован: ' + @previewData.length + ' товаров'
  141. log '📂 Новые категории: ' + @newCategories.join(', ')
  142. catch error
  143. log '❌ Ошибка генерации предпросмотра: ' + error
  144. console.error('Ошибка генерации предпросмотра:', error)
  145. @$emit('show-notification', 'Ошибка обработки данных: ' + error.message, 'error')
  146. @previewLoading = false
  147. transformRowToProduct: (row, index) ->
  148. # Получаем значения по маппингу
  149. sku = row[@getMappedField('sku')] || ''
  150. name = row[@getMappedField('name')] || ''
  151. price = @parsePrice(row[@getMappedField('price')])
  152. oldPrice = @parsePrice(row[@getMappedField('oldPrice')])
  153. brand = row[@getMappedField('brand')] || ''
  154. category = row[@getMappedField('category')] || ''
  155. description = row[@getMappedField('description')] || ''
  156. mainImage = row[@getMappedField('mainImage')] || ''
  157. additionalImages = row[@getMappedField('additionalImages')] || ''
  158. # Создаем базовый объект товара
  159. product = {
  160. _id: 'product:' + (sku || 'temp_' + index)
  161. name: name
  162. sku: sku
  163. price: price
  164. oldPrice: oldPrice
  165. brand: brand
  166. category: category
  167. description: description
  168. domains: [window.location.hostname]
  169. type: 'product'
  170. active: true
  171. inStock: true
  172. images: []
  173. attributes: {}
  174. createdAt: new Date().toISOString()
  175. updatedAt: new Date().toISOString()
  176. }
  177. # Обработка изображений
  178. if mainImage
  179. product.images.push({
  180. url: mainImage.trim()
  181. type: 'main'
  182. order: 0
  183. })
  184. if additionalImages
  185. # Разбиваем строку с дополнительными изображениями по переносам
  186. imageUrls = additionalImages.split('\n').filter (url) -> url.trim()
  187. imageUrls.slice(0, 10).forEach (imgUrl, imgIndex) ->
  188. if imgUrl.trim()
  189. product.images.push({
  190. url: imgUrl.trim()
  191. type: 'additional'
  192. order: imgIndex + 1
  193. })
  194. # Обработка дополнительных полей как атрибутов
  195. @csvFields.forEach (field) =>
  196. if not @fieldMapping[field] and row[field] and field != '№'
  197. product.attributes[field] = row[field].toString().trim()
  198. # Обработка Rich-контента если есть
  199. if row['Rich-контент JSON']
  200. try
  201. richContent = JSON.parse(row['Rich-контент JSON'])
  202. product.richContent = @jsonToMarkdown(richContent)
  203. catch error
  204. log '⚠️ Ошибка парсинга Rich-контента в строке ' + index + ': ' + error
  205. log '🔄 Трансформирован товар ' + index + ': ' + product.name
  206. return product
  207. parsePrice: (priceStr) ->
  208. return null unless priceStr
  209. try
  210. # Удаляем пробелы (например, "1 056,00" -> "1056,00")
  211. # Заменяем запятые на точки и удаляем все нецифровые символы кроме точки
  212. cleaned = priceStr.toString()
  213. .replace(/\s/g, '') # Удаляем пробелы
  214. .replace(',', '.') # Заменяем запятые на точки
  215. .replace(/[^\d.]/g, '') # Удаляем все кроме цифр и точек
  216. price = parseFloat(cleaned)
  217. return if isNaN(price) then null else price
  218. catch
  219. return null
  220. jsonToMarkdown: (richContent) ->
  221. markdown = ''
  222. if richContent and richContent.content
  223. richContent.content.forEach (block) ->
  224. if block.widgetName == 'raTextBlock' and block.text and block.text.items
  225. block.text.items.forEach (item) ->
  226. if item.type == 'text' and item.content
  227. markdown += item.content + '\n\n'
  228. else if item.type == 'br'
  229. markdown += '\n'
  230. return markdown.trim()
  231. getMappedField: (targetField) ->
  232. for csvField, mappedField of @fieldMapping
  233. return csvField if mappedField == targetField
  234. return ''
  235. initializeServices: ->
  236. return Promise.resolve() if @servicesInitialized
  237. if @initializingServices
  238. # Ждем завершения предыдущей инициализации
  239. return new Promise (resolve) =>
  240. checkInterval = setInterval (=>
  241. if @servicesInitialized
  242. clearInterval(checkInterval)
  243. resolve()
  244. ), 100
  245. @initializingServices = true
  246. log '🔄 Инициализация сервисов перед импортом...'
  247. try
  248. # Инициализируем все необходимые сервисы
  249. await ProductService.init()
  250. log '✅ ProductService инициализирован'
  251. await CategoryService.init()
  252. log '✅ CategoryService инициализирован'
  253. #await ImportService.init()
  254. log '✅ ImportService инициализирован'
  255. @servicesInitialized = true
  256. @initializingServices = false
  257. log '🎉 Все сервисы инициализированы'
  258. return Promise.resolve()
  259. catch error
  260. @initializingServices = false
  261. log '❌ Ошибка инициализации сервисов: ' + error
  262. throw error
  263. startImport: ->
  264. try
  265. # Сначала инициализируем сервисы
  266. @currentStep = 4
  267. @importing = true
  268. @importProgress = 0
  269. @processedItems = 0
  270. @totalItems = @csvData.length
  271. @importErrors = []
  272. @importStats = {
  273. success: 0
  274. errors: 0
  275. newCategories: 0
  276. }
  277. log '🔄 Инициализация сервисов перед импортом...'
  278. await @initializeServices()
  279. log '🚀 Начало импорта ' + @totalItems + ' товаров'
  280. @processImportBatch()
  281. catch error
  282. log '❌ Ошибка инициализации перед импортом: ' + error
  283. @importing = false
  284. @$emit('show-notification', 'Ошибка инициализации: ' + error.message, 'error')
  285. processImportBatch: (batchSize = 10, batchIndex = 0) ->
  286. batches = []
  287. for i in [0...@csvData.length] by batchSize
  288. batches.push(@csvData.slice(i, i + batchSize))
  289. processBatch = (currentBatchIndex) =>
  290. batch = batches[currentBatchIndex]
  291. return unless batch
  292. try
  293. log '🔄 Обработка пакета ' + (currentBatchIndex + 1) + ' из ' + batches.length
  294. # Трансформация данных
  295. products = batch.map (row, index) =>
  296. globalIndex = currentBatchIndex * batchSize + index
  297. product = @transformRowToProduct(row, globalIndex)
  298. log '📦 Трансформирован товар: ' + product.name + ' (SKU: ' + product.sku + ')'
  299. return product
  300. # Создание категорий
  301. await @createMissingCategories(products)
  302. # Сохранение товаров
  303. log '💾 Сохранение пакета товаров в базу...'
  304. results = await ProductService.bulkSaveProducts(products)
  305. # Логируем результат сохранения
  306. log '✅ Результат сохранения пакета:'
  307. log 'Успешно: ' + results.success.length
  308. log 'Ошибок: ' + results.errors.length
  309. if results.errors.length > 0
  310. results.errors.forEach (error) ->
  311. log '❌ Ошибка сохранения товара: ' + error.error
  312. # Обновление прогресса
  313. @processedItems += batch.length
  314. @importProgress = Math.round((@processedItems / @totalItems) * 100)
  315. @importStats.success += results.success.length
  316. @importStats.errors += results.errors.length
  317. # Сохранение ошибок
  318. results.errors.forEach (error) =>
  319. @importErrors.push({
  320. row: currentBatchIndex * batchSize + error.index
  321. message: error.error
  322. })
  323. log '📊 Прогресс импорта: ' + @importProgress + '% (' + @processedItems + ' из ' + @totalItems + ')'
  324. # Следующий пакет или завершение
  325. if currentBatchIndex < batches.length - 1
  326. setTimeout (=>
  327. @processImportBatch(batchSize, currentBatchIndex + 1)
  328. ), 500
  329. else
  330. @finishImport()
  331. catch error
  332. log '❌ Ошибка обработки пакета ' + currentBatchIndex + ': ' + error
  333. console.error('Ошибка пакета:', error)
  334. @importErrors.push({
  335. row: currentBatchIndex * batchSize
  336. message: 'Ошибка пакета: ' + error.message
  337. })
  338. @finishImport()
  339. processBatch(batchIndex)
  340. createMissingCategories: (products) ->
  341. # Собираем уникальные категории из товаров
  342. categories = []
  343. products.forEach (product) ->
  344. if product.category and product.category.trim() and not categories.includes(product.category)
  345. categories.push(product.category.trim())
  346. log '📂 Категории для создания: ' + categories.join(', ')
  347. createdCount = 0
  348. pouchService = require 'app/utils/pouch'
  349. for categoryName in categories
  350. try
  351. # Создаем простой slug
  352. slug = @simpleSlugify(categoryName)
  353. categoryId = 'category:' + slug
  354. log '🔍 Проверка категории: ' + categoryName
  355. # Проверка 1: По ID (slug)
  356. categoryExists = false
  357. try
  358. existingCategory = await pouchService.getDocument(categoryId)
  359. categoryExists = true
  360. log '⚠️ Категория существует по ID: ' + categoryName
  361. catch error
  362. if error.status != 404
  363. log '❌ Ошибка проверки по ID: ' + error.message
  364. # Проверка 2: По имени (если не нашли по ID)
  365. if not categoryExists
  366. try
  367. # Ищем категории с таким же именем
  368. categoriesResult = await pouchService.allDocs({
  369. startkey: 'category:'
  370. endkey: 'category:\ufff0'
  371. include_docs: true
  372. })
  373. sameNameCategory = categoriesResult.rows.find (row) ->
  374. row.doc.name.toLowerCase().trim() == categoryName.toLowerCase().trim()
  375. if sameNameCategory
  376. categoryExists = true
  377. log '⚠️ Категория существует по имени: ' + categoryName + ' (ID: ' + sameNameCategory.doc._id + ')'
  378. catch error
  379. log '❌ Ошибка проверки по имени: ' + error.message
  380. # Если категория существует, пропускаем создание
  381. if categoryExists
  382. continue
  383. # Создаем новую категорию
  384. log '🆕 Создание категории: ' + categoryName + ' (ID: ' + categoryId + ')'
  385. categoryDoc = {
  386. _id: categoryId
  387. type: 'category'
  388. name: categoryName
  389. slug: slug
  390. domains: [window.location.hostname]
  391. active: true
  392. order: 0
  393. description: ''
  394. createdAt: new Date().toISOString()
  395. updatedAt: new Date().toISOString()
  396. }
  397. result = await pouchService.saveDocument(categoryDoc)
  398. if result and result.ok
  399. createdCount++
  400. @importStats.newCategories++
  401. log '✅ Создана категория: ' + categoryName
  402. else
  403. log '❌ Ошибка сохранения категории'
  404. catch error
  405. if error.status == 409
  406. log '⚠️ Категория уже существует (конфликт): ' + categoryName
  407. else
  408. log '❌ Ошибка создания категории ' + categoryName + ': ' + error.message
  409. log '📊 Создано категорий: ' + createdCount
  410. return createdCount
  411. simpleSlugify: (text) ->
  412. return 'cat-' + Date.now() unless text
  413. # Простейшая транслитерация
  414. text = text.toString().trim().toLowerCase()
  415. slug = text.replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '')
  416. if slug.length == 0
  417. slug = 'cat-' + Date.now()
  418. return slug
  419. slugify: (text) ->
  420. # Базовая проверка
  421. return 'category-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5) unless text
  422. text = text.toString().trim()
  423. return 'category-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5) if text.length == 0
  424. # Простая транслитерация кириллицы
  425. translitMap = {
  426. 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
  427. 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o',
  428. 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'ts',
  429. 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu',
  430. 'я': 'ya'
  431. }
  432. # Транслитерация
  433. slug = ''
  434. for char in text.toLowerCase()
  435. if translitMap[char]
  436. slug += translitMap[char]
  437. else if char.match(/[a-z0-9]/)
  438. slug += char
  439. else if char == ' ' or char == '-'
  440. slug += '-'
  441. # Очистка slug
  442. slug = slug
  443. .replace(/\-\-+/g, '-')
  444. .replace(/^-+/, '')
  445. .replace(/-+$/, '')
  446. # Если slug пустой, создаем уникальный
  447. if not slug or slug.length == 0
  448. slug = 'category-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5)
  449. log '🔧 Создан slug: ' + text + ' -> ' + slug
  450. return slug
  451. finishImport: ->
  452. @importing = false
  453. @importComplete = true
  454. log '🎉 Импорт завершен!'
  455. log '📊 Итоговая статистика:'
  456. log 'Успешно: ' + @importStats.success
  457. log 'Ошибок: ' + @importStats.errors
  458. log 'Новых категорий: ' + @importStats.newCategories
  459. if @importStats.errors == 0
  460. message = 'Импорт завершен успешно! Обработано ' + @importStats.success + ' товаров'
  461. @$emit('show-notification', message, 'success')
  462. else
  463. message = 'Импорт завершен с ошибками. Успешно: ' + @importStats.success + ', Ошибок: ' + @importStats.errors
  464. @$emit('show-notification', message, 'warning')
  465. cancelImport: ->
  466. @importing = false
  467. @importComplete = true
  468. @$emit('show-notification', 'Импорт отменен', 'info')
  469. resetImport: ->
  470. @currentStep = 1
  471. @selectedFile = null
  472. @csvFields = []
  473. @csvData = []
  474. @fieldMapping = {}
  475. @previewData = []
  476. @importStats = null
  477. @importErrors = []
  478. @importComplete = false
  479. mounted: ->
  480. log '📥 Компонент импорта загружен'
  481. # Предварительная инициализация сервисов при загрузке компонента
  482. @initializeServices().catch (error) ->
  483. log '⚠️ Предварительная инициализация сервисов не удалась: ' + error
  484. render: (new Function '_ctx', '_cache', globalThis.renderFns['app/pages/Admin/Import/index.pug'])()
  485. }