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 { const wb = xlsx.readFile(filePath) const sheet = wb.Sheets[wb.SheetNames[0]] const rows = xlsx.utils.sheet_to_json>(sheet, { defval: null }) return this.processRows(rows, username) } static async importFromBuffer(buffer: Buffer, username: string): Promise { const wb = xlsx.read(buffer, { type: 'buffer' }) const sheet = wb.Sheets[wb.SheetNames[0]] const rows = xlsx.utils.sheet_to_json>(sheet, { defval: null }) return this.processRows(rows, username) } private static async processRows( rows: Record[], username: string ): Promise { 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): Record { const out: Record = {} for (const [k, v] of Object.entries(row)) { out[String(k).trim().toLowerCase()] = v } return out } private static validateRow(raw: Record): 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 } }