218 lines
9.2 KiB
TypeScript
218 lines
9.2 KiB
TypeScript
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<string, unknown>[];
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class ExcelPreviewService {
|
|
|
|
async previewFile(file: File, type: 'PDV' | 'Usuarios'): Promise<PreviewResult> {
|
|
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<string, unknown>[] = 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]);
|
|
}
|
|
}
|
|
if (type === 'PDV') {
|
|
const hasVisitsFormat = normalizedHeaders.includes('visitas_semanales') && normalizedHeaders.includes('duracion_visitahoras');
|
|
const hasWeeklyHours = normalizedHeaders.includes('minimo_horas_semana');
|
|
if (!hasVisitsFormat && !hasWeeklyHours) {
|
|
missingHeaders.push('Mínimo horas semana o Visitas semanales + Duración visita(horas)');
|
|
}
|
|
}
|
|
|
|
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<string, unknown>));
|
|
const seenIds = new Map<string, number[]>();
|
|
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<string, unknown> = { ...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['minimo_horas_semana'] = this.coerceNum(row['minimo_horas_semana']);
|
|
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<string, unknown>)[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<string, unknown>)[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;
|
|
}
|
|
|
|
async preparePdvForOptimizer(file: File): Promise<File> {
|
|
const buffer = await file.arrayBuffer();
|
|
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
|
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true })
|
|
.map(row => normalizeRow(row as Record<string, unknown>));
|
|
const normalized = rows.map(row => {
|
|
const weeklyHours = cleanNum(row['minimo_horas_semana']);
|
|
const visits = cleanNum(row['visitas_semanales']);
|
|
const duration = cleanNum(row['duracion_visitahoras']);
|
|
return {
|
|
'ID': row['id'],
|
|
'Nombre del PDV': row['nombre_del_pdv'],
|
|
'Segmentación': row['segmentacion'],
|
|
'Latitud': cleanNum(row['latitud']),
|
|
'Longitud': cleanNum(row['longitud']),
|
|
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
|
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
|
|
'Prioridad': cleanNum(row['prioridad'])
|
|
};
|
|
});
|
|
const output = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(output, XLSX.utils.json_to_sheet(normalized), 'PDV');
|
|
const data = XLSX.write(output, { bookType: 'xlsx', type: 'array' });
|
|
return new File([data], file.name, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
}
|
|
|
|
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<string, unknown>[],
|
|
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),
|
|
};
|
|
}
|
|
}
|