feat: implement route assignment management and sequencing module with interactive data visualization and editing capabilities
This commit is contained in:
@@ -1,3 +1,2 @@
|
||||
export * from './pdv.schema';
|
||||
export * from './usuario.schema';
|
||||
export * from './params.schema';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const paramsSchema = z.object({
|
||||
radioKm: z.union([z.literal(30), z.literal(50), z.literal(80)]),
|
||||
velocidad: z.number().min(15).max(80),
|
||||
});
|
||||
|
||||
export type Params = z.infer<typeof paramsSchema>;
|
||||
// Urbano denso Santo Domingo: 22-28 km/h es más realista que 46 (autopista).
|
||||
// P2-A default 28; 46 sigue disponible como "Mixto rápido" pero ya no default.
|
||||
export const DEFAULT_PARAMS: Params = { radioKm: 50, velocidad: 28 };
|
||||
@@ -17,8 +17,6 @@ export const PDV_NORMALIZED_REQUIRED = [
|
||||
'segmentacion',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'visitas_semanales',
|
||||
'duracion_visitahoras',
|
||||
'prioridad',
|
||||
] as const;
|
||||
|
||||
@@ -32,9 +30,19 @@ export const pdvRowSchema = z.object({
|
||||
}),
|
||||
latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'),
|
||||
longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'),
|
||||
visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7'),
|
||||
duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(24, 'Duración visita máximo 24'),
|
||||
visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7').optional(),
|
||||
duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(168, 'Duración visita máximo 168').optional(),
|
||||
minimo_horas_semana: z.number().min(0.01, 'Mínimo horas semana debe ser >0').max(168, 'Mínimo horas semana máximo 168').optional(),
|
||||
prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'),
|
||||
}).superRefine((data, ctx) => {
|
||||
const hasVisitsFormat = data.visitas_semanales !== undefined && data.duracion_visitahoras !== undefined;
|
||||
if (!hasVisitsFormat && data.minimo_horas_semana === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['minimo_horas_semana'],
|
||||
message: 'Incluye Mínimo horas semana o Visitas semanales + Duración visita(horas)'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PdvRow = z.infer<typeof pdvRowSchema>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -9,6 +9,20 @@ export interface SequencedRoute {
|
||||
previousPdvName: string | null;
|
||||
}
|
||||
|
||||
export interface DailyRouteSummary {
|
||||
key: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
day: string;
|
||||
routes: SequencedRoute[];
|
||||
distanceKm: number;
|
||||
travelHours: number;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
totalHours: number;
|
||||
exceedsCapacity: boolean;
|
||||
}
|
||||
|
||||
export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] {
|
||||
const groups = new Map<string, RouteAssignedResponse[]>();
|
||||
for (const route of routes) {
|
||||
@@ -23,17 +37,22 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[
|
||||
const first = group[0].json;
|
||||
let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)];
|
||||
let previousPdvName: string | null = null;
|
||||
const remaining = [...group];
|
||||
const hasManualOrder = group.every(route => Number.isFinite(route.json.visit_order));
|
||||
const remaining = hasManualOrder
|
||||
? [...group].sort((a, b) => Number(a.json.visit_order) - Number(b.json.visit_order))
|
||||
: [...group];
|
||||
|
||||
while (remaining.length) {
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < remaining.length; index++) {
|
||||
const candidate = remaining[index].json;
|
||||
const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
if (!hasManualOrder) {
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < remaining.length; index++) {
|
||||
const candidate = remaining[index].json;
|
||||
const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +74,38 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[
|
||||
return sequenced;
|
||||
}
|
||||
|
||||
export function summarizeDailyRoutes(routes: RouteAssignedResponse[]): DailyRouteSummary[] {
|
||||
const sequences = sequenceRoutes(routes);
|
||||
const grouped = new Map<string, SequencedRoute[]>();
|
||||
for (const segment of sequences) {
|
||||
const key = `${segment.route.json.usuario_id}\u0000${segment.route.json.dia}`;
|
||||
const group = grouped.get(key) ?? [];
|
||||
group.push(segment);
|
||||
grouped.set(key, group);
|
||||
}
|
||||
return Array.from(grouped, ([key, segments]) => {
|
||||
const first = segments[0].route.json;
|
||||
const distanceKm = segments.reduce((sum, segment) => sum + haversineKm(segment.origin, segment.destination), 0);
|
||||
const travelHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_desplazamiento || 0), 0);
|
||||
const workHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_trabajo || segment.route.json.hora_minima_semanal_pdv || 0), 0);
|
||||
const capacityHours = Number(first.hora_laboral_semanal_usuario || 0) / 5;
|
||||
const totalHours = workHours + travelHours;
|
||||
return {
|
||||
key,
|
||||
userId: String(first.usuario_id),
|
||||
userName: first.nombre_usuario,
|
||||
day: first.dia,
|
||||
routes: segments,
|
||||
distanceKm,
|
||||
travelHours,
|
||||
workHours,
|
||||
capacityHours,
|
||||
totalHours,
|
||||
exceedsCapacity: totalHours > capacityHours
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function haversineKm(origin: [number, number], destination: [number, number]): number {
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const [lon1, lat1] = origin;
|
||||
|
||||
Reference in New Issue
Block a user