feat: implement route assignment management and sequencing module with interactive data visualization and editing capabilities

This commit is contained in:
2026-08-31 17:56:33 -04:00
parent cd5c57c5ee
commit 9f42175afc
29 changed files with 458 additions and 218 deletions
@@ -52,6 +52,13 @@ export class ExcelPreviewService {
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);
@@ -92,6 +99,7 @@ export class ExcelPreviewService {
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();
@@ -139,6 +147,33 @@ export class ExcelPreviewService {
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[] = [];
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import { RouteState } from '../../core/route-state/route';
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
@Injectable({ providedIn: 'root' })
export class PlanningExportService {
@@ -28,8 +29,9 @@ export class PlanningExportService {
}
const total = assigned.length + unassigned.length;
const totalDistance = assigned.reduce((sum, route) => sum + (Number(route.json.distancia) || 0), 0);
const totalTravel = assigned.reduce((sum, route) => sum + (Number(route.json.horas_desplazamiento) || 0), 0);
const dailyRoutes = summarizeDailyRoutes(assigned);
const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0);
const workbook = XLSX.utils.book_new();
const summary = [
@@ -43,16 +45,18 @@ export class PlanningExportService {
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }
];
const assignedRows = assigned.map(route => ({
'Usuario ID': route.json.usuario_id,
Usuario: route.json.nombre_usuario,
'PDV ID': route.json.pdv_id,
PDV: route.json.nombre_pdv,
Día: route.json.dia,
'Distancia (km)': route.json.distancia,
'Desplazamiento (h)': route.json.horas_desplazamiento,
'Horas trabajo': route.json.horas_trabajo,
'Horas disponibles': route.json.hora_laboral_semanal_usuario
const assignedRows = sequenceRoutes(assigned).map(segment => ({
'Usuario ID': segment.route.json.usuario_id,
Usuario: segment.route.json.nombre_usuario,
'PDV ID': segment.route.json.pdv_id,
PDV: segment.route.json.nombre_pdv,
Día: segment.route.json.dia,
'Distancia (km)': segment.route.json.distancia,
'Desplazamiento (h)': segment.route.json.horas_desplazamiento,
'Horas trabajo': segment.route.json.horas_trabajo,
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
'Orden visita': segment.order,
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`
}));
const unassignedRows = unassigned.map(route => ({
'PDV ID': route.pdv_id,
@@ -62,20 +66,34 @@ export class PlanningExportService {
Longitud: route.longitud,
Motivo: route.motivo
}));
const userRows = Array.from(users, ([id, user]) => ({
'Usuario ID': id,
Usuario: user.nombre,
Rutas: user.rutas,
'Distancia (km)': Number(user.distancia.toFixed(2)),
'Horas trabajo': Number(user.trabajo.toFixed(2)),
'Capacidad (h)': user.capacidad,
'Utilización (%)': user.capacidad ? Number(((user.trabajo / user.capacidad) * 100).toFixed(1)) : 0
}));
const userRows = Array.from(users, ([id, user]) => {
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
return {
'Usuario ID': id,
Usuario: user.nombre,
Rutas: user.rutas,
'Distancia (km)': Number(user.distancia.toFixed(2)),
'Horas trabajo': Number(user.trabajo.toFixed(2)),
'Capacidad (h)': user.capacidad,
'Utilización (%)': Number(utilization.toFixed(1)),
Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK'
};
});
this.appendSheet(workbook, summary, 'Resumen');
this.appendSheet(workbook, assignedRows, 'Rutas asignadas');
this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar');
this.appendSheet(workbook, userRows, 'Carga por usuario');
this.appendSheet(workbook, dailyRoutes.map(route => ({
Usuario: route.userName,
Día: route.day,
Paradas: route.routes.length,
'Distancia secuencial (km)': Number(route.distanceKm.toFixed(2)),
'Desplazamiento secuencial (h)': Number(route.travelHours.toFixed(2)),
'Carga total (h)': Number(route.totalHours.toFixed(2)),
'Capacidad diaria (h)': Number(route.capacityHours.toFixed(2)),
Estado: route.exceedsCapacity ? 'Excede capacidad' : 'OK'
})), 'Rutas diarias');
const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx');