feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export * from './pdv.schema';
|
||||
export * from './usuario.schema';
|
||||
export * from './params.schema';
|
||||
@@ -0,0 +1,11 @@
|
||||
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 };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PDV_REQUIRED_HEADERS = [
|
||||
'ID',
|
||||
'Nombre del PDV',
|
||||
'Segmentación',
|
||||
'Latitud',
|
||||
'Longitud',
|
||||
'Visitas semanales',
|
||||
'Duración visita(horas)',
|
||||
'Prioridad',
|
||||
] as const;
|
||||
|
||||
export const PDV_NORMALIZED_REQUIRED = [
|
||||
'id',
|
||||
'nombre_del_pdv',
|
||||
'segmentacion',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'visitas_semanales',
|
||||
'duracion_visitahoras',
|
||||
'prioridad',
|
||||
] as const;
|
||||
|
||||
export const SEGMENTACIONES = ['AA', 'A', 'B', 'C', 'D'] as const;
|
||||
|
||||
export const pdvRowSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }),
|
||||
nombre_del_pdv: z.string().min(1, 'Nombre del PDV requerido'),
|
||||
segmentacion: z.string().refine(v => (SEGMENTACIONES as readonly string[]).includes(v.toUpperCase()), {
|
||||
message: `Segmentación debe ser ${SEGMENTACIONES.join(', ')}`,
|
||||
}),
|
||||
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'),
|
||||
prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'),
|
||||
});
|
||||
|
||||
export type PdvRow = z.infer<typeof pdvRowSchema>;
|
||||
export const PDV_MAX_ROWS = 2000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const USUARIO_REQUIRED_HEADERS = [
|
||||
'ID',
|
||||
'Nombre del empleado',
|
||||
'Latitud',
|
||||
'Longitud',
|
||||
'Horas por día usuario',
|
||||
'Horas por semana usuario',
|
||||
] as const;
|
||||
|
||||
export const USUARIO_NORMALIZED_REQUIRED = [
|
||||
'id',
|
||||
'nombre_del_empleado',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'horas_por_dia_usuario',
|
||||
'horas_por_semana_usuario',
|
||||
] as const;
|
||||
|
||||
export const usuarioRowSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }),
|
||||
nombre_del_empleado: z.string().min(1, 'Nombre del empleado requerido'),
|
||||
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'),
|
||||
horas_por_dia_usuario: z.number().min(0.5, 'Horas por día mínimo 0.5').max(24, 'Horas por día máximo 24'),
|
||||
horas_por_semana_usuario: z.number().min(1, 'Horas por semana mínimo 1').max(168, 'Horas por semana máximo 168'),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.horas_por_semana_usuario > data.horas_por_dia_usuario * 7) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Horas por semana no puede exceder 7 × horas por día',
|
||||
path: ['horas_por_semana_usuario'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
|
||||
export const USUARIO_MAX_ROWS = 50;
|
||||
@@ -0,0 +1,182 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
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['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;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningExportService {
|
||||
constructor(private routeState: RouteState) {}
|
||||
|
||||
export(): void {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = users.get(key) ?? {
|
||||
nombre: route.json.nombre_usuario,
|
||||
rutas: 0,
|
||||
distancia: 0,
|
||||
trabajo: 0,
|
||||
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
||||
};
|
||||
current.rutas++;
|
||||
current.distancia += Number(route.json.distancia) || 0;
|
||||
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
||||
users.set(key, current);
|
||||
}
|
||||
|
||||
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 workbook = XLSX.utils.book_new();
|
||||
|
||||
const summary = [
|
||||
{ Indicador: 'PDVs totales', Valor: total },
|
||||
{ Indicador: 'PDVs asignados', Valor: assigned.length },
|
||||
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length },
|
||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 },
|
||||
{ Indicador: 'Usuarios activos', Valor: users.size },
|
||||
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
||||
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
||||
{ 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 unassignedRows = unassigned.map(route => ({
|
||||
'PDV ID': route.pdv_id,
|
||||
PDV: route.pdv_nombre,
|
||||
'Mínimo horas/semana': route.minimo_horas_semana,
|
||||
Latitud: route.latitud,
|
||||
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
|
||||
}));
|
||||
|
||||
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');
|
||||
|
||||
const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx');
|
||||
}
|
||||
|
||||
private appendSheet(workbook: XLSX.WorkBook, rows: object[], name: string): void {
|
||||
const sheet = XLSX.utils.json_to_sheet(rows.length ? rows : [{ Información: 'Sin registros' }]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function normalizeHeader(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9 ]/gi, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
export function cleanNum(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') return NaN;
|
||||
if (typeof value === 'number') return value;
|
||||
const str = String(value).trim().replace(/[^0-9.\-]/g, '');
|
||||
if (str === '' || str === '-' || str === '.') return NaN;
|
||||
const num = Number(str);
|
||||
return Number.isFinite(num) ? num : NaN;
|
||||
}
|
||||
|
||||
export function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key in row) {
|
||||
const newKey = normalizeHeader(key);
|
||||
let val = row[key];
|
||||
if (typeof val === 'string') val = val.trim();
|
||||
out[newKey] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export * from './remove-special-character.utils';
|
||||
export * from './remove-special-character.utils';
|
||||
export * from './header-normalization.utils';
|
||||
export * from './palette.utils';
|
||||
export * from './route-sequencing.utils';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
||||
const str = String(usuarioId);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
hash = Math.abs(hash);
|
||||
// Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel
|
||||
const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3'];
|
||||
const idx = hash % glmPalette.length;
|
||||
// If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow
|
||||
if (hash % 2 === 0) {
|
||||
return { color: glmPalette[idx], glow: glmPalette[idx] };
|
||||
}
|
||||
const h = hash % 360;
|
||||
const s = 65 + (hash % 20);
|
||||
const l = 48 + (hash % 10);
|
||||
const hsl = `hsl(${h} ${s}% ${l}%)`;
|
||||
return { color: hsl, glow: hsl };
|
||||
}
|
||||
|
||||
export function markerStyleForUsuario(usuarioId: string | number): string {
|
||||
return colorForUsuario(usuarioId).color;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { RouteAssignedResponse } from '../../core/models/route.model';
|
||||
|
||||
export interface SequencedRoute {
|
||||
route: RouteAssignedResponse;
|
||||
origin: [number, number];
|
||||
destination: [number, number];
|
||||
order: number;
|
||||
totalStops: number;
|
||||
previousPdvName: string | null;
|
||||
}
|
||||
|
||||
export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] {
|
||||
const groups = new Map<string, RouteAssignedResponse[]>();
|
||||
for (const route of routes) {
|
||||
const key = `${route.json.usuario_id}\u0000${route.json.dia}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(route);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
const sequenced: SequencedRoute[] = [];
|
||||
for (const group of groups.values()) {
|
||||
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];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const [route] = remaining.splice(nearestIndex, 1);
|
||||
const destination: [number, number] = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
|
||||
sequenced.push({
|
||||
route,
|
||||
origin: current,
|
||||
destination,
|
||||
order: group.length - remaining.length,
|
||||
totalStops: group.length,
|
||||
previousPdvName
|
||||
});
|
||||
current = destination;
|
||||
previousPdvName = route.json.nombre_pdv;
|
||||
}
|
||||
}
|
||||
|
||||
return sequenced;
|
||||
}
|
||||
|
||||
export function haversineKm(origin: [number, number], destination: [number, number]): number {
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const [lon1, lat1] = origin;
|
||||
const [lon2, lat2] = destination;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
Reference in New Issue
Block a user