Listing_SuggestPrice/backend/app/services/import_service.ts

127 lines
4.3 KiB
TypeScript

import xlsx from 'xlsx'
import ProductService from '#services/product_service'
import LogService from '#services/log_service'
import NotificationService from '#services/notification_service'
import type { NotificationType } from '#models/notification'
export interface ImportRowResult {
row: number
sku?: string
status: 'created' | 'updated' | 'error'
message?: string
}
export interface ImportSummary {
total: number
created: number
updated: number
failed: number
rows: ImportRowResult[]
}
const VALID_HEADERS = ['sku', 'condition', 'qty', 'price']
/**
* Import sản phẩm từ file Excel. Cột yêu cầu: sku, condition, qty, price.
*/
export default class ImportService {
static async importFromFile(filePath: string, username: string): Promise<ImportSummary> {
const wb = xlsx.readFile(filePath)
const sheet = wb.Sheets[wb.SheetNames[0]]
const rows = xlsx.utils.sheet_to_json<Record<string, any>>(sheet, { defval: null })
return this.processRows(rows, username)
}
static async importFromBuffer(buffer: Buffer, username: string): Promise<ImportSummary> {
const wb = xlsx.read(buffer, { type: 'buffer' })
const sheet = wb.Sheets[wb.SheetNames[0]]
const rows = xlsx.utils.sheet_to_json<Record<string, any>>(sheet, { defval: null })
return this.processRows(rows, username)
}
private static async processRows(
rows: Record<string, any>[],
username: string
): Promise<ImportSummary> {
const summary: ImportSummary = { total: rows.length, created: 0, updated: 0, failed: 0, rows: [] }
for (let i = 0; i < rows.length; i++) {
const rowNo = i + 2 // +1 header, +1 1-based
const raw = this.normalizeKeys(rows[i])
const error = this.validateRow(raw)
if (error) {
summary.failed++
summary.rows.push({ row: rowNo, sku: raw.sku, status: 'error', message: error })
continue
}
try {
const { created } = await ProductService.upsert({
sku: String(raw.sku).trim(),
condition: String(raw.condition).trim(),
qty: Number(raw.qty),
price: Number(raw.price),
})
created ? summary.created++ : summary.updated++
summary.rows.push({ row: rowNo, sku: raw.sku, status: created ? 'created' : 'updated' })
} catch (e: any) {
summary.failed++
summary.rows.push({ row: rowNo, sku: raw.sku, status: 'error', message: e.message })
}
}
await LogService.record({
username,
actionName: 'Import Excel',
action: 'import',
meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed },
})
// Thông báo cho người dùng: cảnh báo nếu import trùng / có dòng lỗi, ngược lại báo thành công.
const type: NotificationType =
summary.failed > 0 ? 'error' : summary.updated > 0 ? 'warning' : 'success'
const parts: string[] = []
if (summary.created > 0) parts.push(`${summary.created} sản phẩm mới`)
if (summary.updated > 0) parts.push(`${summary.updated} sản phẩm đã tồn tại (import trùng, đã cập nhật)`)
if (summary.failed > 0) parts.push(`${summary.failed} dòng lỗi`)
await NotificationService.notify({
type,
title:
summary.updated > 0
? 'Import trùng sản phẩm đã có'
: summary.failed > 0
? 'Import Excel có lỗi'
: 'Import Excel thành công',
message: `Đã xử lý ${summary.total} dòng: ${parts.join(', ') || 'không có thay đổi'}.`,
meta: {
total: summary.total,
created: summary.created,
updated: summary.updated,
failed: summary.failed,
},
})
return summary
}
private static normalizeKeys(row: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(row)) {
out[String(k).trim().toLowerCase()] = v
}
return out
}
private static validateRow(raw: Record<string, any>): string | null {
for (const h of VALID_HEADERS) {
if (raw[h] === null || raw[h] === undefined || raw[h] === '') {
return `Thiếu cột "${h}"`
}
}
if (Number.isNaN(Number(raw.qty))) return 'qty không hợp lệ'
if (Number.isNaN(Number(raw.price))) return 'price không hợp lệ'
return null
}
}