import { Injectable } from '@angular/core'; import * as XLSX from 'xlsx'; import { normalizeRow, normalizeHeader, cleanNum } from '../utils/header-normalization.utils'; import { pdvRowSchema, PDV_NORMALIZED_REQUIRED, PDV_MAX_ROWS, SEGMENTACIONES } from '../schemas/pdv.schema'; import { usuarioRowSchema, USUARIO_NORMALIZED_REQUIRED, USUARIO_MAX_ROWS } from '../schemas/usuario.schema'; export interface FieldError { row: number; // 1-indexed excel row (2 = first data row) field: string; message: string; value?: unknown; } export interface PreviewResult { fileName: string; type: 'PDV' | 'Usuarios'; valid: boolean; totalRows: number; validRows: number; headers: string[]; normalizedHeaders: string[]; missingHeaders: string[]; errors: FieldError[]; duplicateIds: (string | number)[]; sampleValidRows: Record[]; } @Injectable({ providedIn: 'root' }) export class ExcelPreviewService { async previewFile(file: File, type: 'PDV' | 'Usuarios'): Promise { const buffer = await file.arrayBuffer(); const workbook = XLSX.read(buffer, { type: 'array', cellDates: true }); const sheetName = workbook.SheetNames[0]; const sheet = workbook.Sheets[sheetName]; if (!sheetName || !sheet) { throw new Error(`${type}: El archivo no contiene una hoja válida`); } const rawRows: Record[] = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }); const headers = this.extractHeaders(sheet); const normalizedHeaders = headers.map(h => normalizeHeader(String(h))); const required = type === 'PDV' ? [...PDV_NORMALIZED_REQUIRED] : [...USUARIO_NORMALIZED_REQUIRED]; const displayRequired = type === 'PDV' ? ['ID', 'Nombre del PDV', 'Segmentación', 'Latitud', 'Longitud', 'Visitas semanales', 'Duración visita(horas)', 'Prioridad'] : ['ID', 'Nombre del empleado', 'Latitud', 'Longitud', 'Horas por día usuario', 'Horas por semana usuario']; const missingHeaders: string[] = []; for (let i = 0; i < required.length; i++) { if (!normalizedHeaders.includes(required[i])) { missingHeaders.push(displayRequired[i]); } } const errors: FieldError[] = []; const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index); if (repeatedHeaders.length) { errors.push({ row: 1, field: 'headers', message: `Columnas duplicadas: ${Array.from(new Set(repeatedHeaders)).join(', ')}` }); } if (missingHeaders.length) { errors.push({ row: 1, field: 'headers', message: `Columnas faltantes: ${missingHeaders.join(', ')}` }); } if (rawRows.length === 0) { errors.push({ row: 1, field: 'file', message: `${type}: Archivo vacío o sin filas de datos` }); return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, rawRows, errors, []); } const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS; if (rawRows.length > maxRows) { errors.push({ row: 1, field: 'file', message: `${type}: Máximo ${maxRows} filas permitidas (tiene ${rawRows.length})` }); } const normalizedRows = rawRows.map(r => normalizeRow(r as Record)); const seenIds = new Map(); const rowErrors: FieldError[] = []; normalizedRows.forEach((row, idx) => { const excelRow = idx + 2; const idVal = row['id']; if (idVal !== null && idVal !== undefined && String(idVal).trim() !== '') { const key = String(idVal).trim(); if (!seenIds.has(key)) seenIds.set(key, []); seenIds.get(key)!.push(excelRow); } // Coerce numeric fields with cleanNum before zod const coerced: Record = { ...row }; if (type === 'PDV') { coerced['latitud'] = this.coerceNum(row['latitud']); coerced['longitud'] = this.coerceNum(row['longitud']); coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']); coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']); coerced['prioridad'] = this.coerceNum(row['prioridad']); if (typeof coerced['segmentacion'] === 'string') { coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase(); } // validate with zod const result = pdvRowSchema.safeParse(coerced); if (!result.success) { for (const issue of result.error.issues) { const field = String(issue.path[0] ?? 'unknown'); rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record)[field] ?? coerced[field] }); } } } else { coerced['latitud'] = this.coerceNum(row['latitud']); coerced['longitud'] = this.coerceNum(row['longitud']); coerced['horas_por_dia_usuario'] = this.coerceNum(row['horas_por_dia_usuario']); coerced['horas_por_semana_usuario'] = this.coerceNum(row['horas_por_semana_usuario']); const result = usuarioRowSchema.safeParse(coerced); if (!result.success) { for (const issue of result.error.issues) { const field = String(issue.path[0] ?? 'unknown'); rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record)[field] ?? coerced[field] }); } } } }); const duplicateIds: (string | number)[] = []; for (const [id, rows] of seenIds.entries()) { if (rows.length > 1) { duplicateIds.push(id); for (const r of rows) { rowErrors.push({ row: r, field: 'id', message: `ID duplicado: ${id}`, value: id }); } } } const allErrors = [...errors, ...rowErrors]; return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, normalizedRows, allErrors, duplicateIds); } private coerceNum(value: unknown): unknown { if (value === null || value === undefined || value === '') return value; const n = cleanNum(value); return Number.isNaN(n) ? value : n; } private extractHeaders(sheet: XLSX.WorkSheet): string[] { const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1'); const headers: string[] = []; for (let c = range.s.c; c <= range.e.c; c++) { const addr = XLSX.utils.encode_cell({ r: range.s.r, c }); const cell = sheet[addr]; headers.push(cell ? String(cell.v) : ''); } return headers.filter(h => h !== ''); } private buildResult( fileName: string, type: 'PDV' | 'Usuarios', headers: string[], normalizedHeaders: string[], missingHeaders: string[], rows: Record[], errors: FieldError[], duplicateIds: (string | number)[] ): PreviewResult { const validRows = Math.max(0, rows.length - new Set(errors.filter(e => e.field !== 'headers' && e.field !== 'file').map(e => e.row)).size); const hasHeaderErrors = missingHeaders.length > 0; const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS; const overLimit = rows.length > maxRows; const valid = !hasHeaderErrors && !overLimit && errors.length === 0; return { fileName, type, valid, totalRows: rows.length, validRows: valid ? rows.length : validRows, headers, normalizedHeaders, missingHeaders, errors, duplicateIds, sampleValidRows: rows.slice(0, 3), }; } }