feat: subir json del flujo de n8n #1

Merged
EidanThen merged 3 commits from dev into main 2026-08-31 22:19:08 +00:00
29 changed files with 458 additions and 218 deletions
Showing only changes of commit 9f42175afc - Show all commits
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -26,6 +26,7 @@ export interface RouteAssignedJson {
nombre_pdv: string; nombre_pdv: string;
usuario_id: string; usuario_id: string;
pdv_id: number; pdv_id: number;
visit_order?: number;
} }
export interface RouteNoAssignedResponse { export interface RouteNoAssignedResponse {
pdv_id: number; pdv_id: number;
+83 -12
View File
@@ -4,6 +4,7 @@ import {
RouteAssignedResponse, RouteAssignedResponse,
RouteNoAssignedResponse RouteNoAssignedResponse
} from '../models/route.model'; } from '../models/route.model';
import { summarizeDailyRoutes } from '../../shared/utils/route-sequencing.utils';
const STORAGE_ASSIGNED = 'rp_assigned_routes'; const STORAGE_ASSIGNED = 'rp_assigned_routes';
const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original'; const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
@@ -11,6 +12,12 @@ const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original'; const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
const STORAGE_SELECTED = 'rp_selected_route'; const STORAGE_SELECTED = 'rp_selected_route';
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
export interface UnassignedAssignment {
userId: string;
day: string;
distance: number;
duration: number;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class RouteState { export class RouteState {
@@ -69,13 +76,18 @@ export class RouteState {
} }
addAssignedFromUnassigned(route: RouteNoAssignedResponse, userId: string, day: string, distance: number, duration: number): void { addAssignedFromUnassigned(route: RouteNoAssignedResponse, userId: string, day: string, distance: number, duration: number): void {
const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId)); this.addAssignedFromUnassignedMany(route, [{ userId, day, distance, duration }]);
if (!profile) return; }
const newRoute: RouteAssignedResponse = {
json: { addAssignedFromUnassignedMany(route: RouteNoAssignedResponse, assignments: UnassignedAssignment[]): void {
dia: day, const routes = this.assignedRoutes.getValue();
distancia: distance, const newRoutes = assignments.map(assignment => {
horas_desplazamiento: duration, const profile = routes.find(item => String(item.json.usuario_id) === String(assignment.userId));
if (!profile) return null;
return { json: {
dia: assignment.day,
distancia: assignment.distance,
horas_desplazamiento: assignment.duration,
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
hora_minima_semanal_pdv: route.minimo_horas_semana, hora_minima_semanal_pdv: route.minimo_horas_semana,
horas_trabajo: String(route.minimo_horas_semana), horas_trabajo: String(route.minimo_horas_semana),
@@ -87,9 +99,10 @@ export class RouteState {
nombre_pdv: route.pdv_nombre, nombre_pdv: route.pdv_nombre,
usuario_id: profile.json.usuario_id, usuario_id: profile.json.usuario_id,
pdv_id: route.pdv_id pdv_id: route.pdv_id
} } } as RouteAssignedResponse;
}; });
this.setDraftAssignedRoutes([...this.assignedRoutes.getValue(), newRoute]); if (newRoutes.some(item => !item)) return;
this.setDraftAssignedRoutes([...routes, ...(newRoutes as RouteAssignedResponse[])]);
this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id)); this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id));
} }
@@ -98,6 +111,57 @@ export class RouteState {
this.setDraftNoAssignedRoutes(updated); this.setDraftNoAssignedRoutes(updated);
} }
canAssignToDay(
userId: string,
day: string,
requiredHours: number,
excluding?: RouteAssignedResponse,
additionalTravelHours = 0
): { allowed: boolean; totalHours: number; capacityHours: number } {
const userRoute = this.assignedRoutes.getValue().find(route => String(route.json.usuario_id) === String(userId));
if (!userRoute) return { allowed: false, totalHours: 0, capacityHours: 0 };
const weeklyCapacity = Number(userRoute.json.hora_laboral_semanal_usuario || 0);
const weeklyLoad = this.assignedRoutes.getValue()
.filter(route => String(route.json.usuario_id) === String(userId) && route !== excluding)
.reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours;
if (weeklyLoad > weeklyCapacity) {
return { allowed: false, totalHours: weeklyLoad, capacityHours: weeklyCapacity };
}
const existing = this.assignedRoutes.getValue().filter(route =>
String(route.json.usuario_id) === String(userId) && route.json.dia === day && route !== excluding
);
const capacityHours = Number(userRoute.json.hora_laboral_semanal_usuario || 0) / 5;
const workHours = existing.reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours;
const existingTravelHours = summarizeDailyRoutes(existing).reduce((sum, item) => sum + item.travelHours, 0);
const totalHours = workHours + existingTravelHours + additionalTravelHours;
return { allowed: totalHours <= capacityHours, totalHours, capacityHours };
}
moveRouteVisit(route: RouteAssignedResponse, direction: -1 | 1): void {
const group = this.assignedRoutes.getValue().filter(item =>
String(item.json.usuario_id) === String(route.json.usuario_id) && item.json.dia === route.json.dia
);
const sequence = summarizeDailyRoutes(group)[0]?.routes ?? [];
const index = sequence.findIndex(item => item.route === route);
const target = index + direction;
if (index < 0 || target < 0 || target >= sequence.length) return;
const ordered = [...sequence];
[ordered[index], ordered[target]] = [ordered[target], ordered[index]];
const orderByRoute = new Map<RouteAssignedResponse, number>(ordered.map((item, position) => [item.route, position + 1]));
this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(item => {
const order = orderByRoute.get(item);
return order ? { json: { ...item.json, visit_order: order } } : item;
}));
}
resetRouteVisitOrder(userId: string, day: string): void {
this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(route =>
String(route.json.usuario_id) === String(userId) && route.json.dia === day
? { json: { ...route.json, visit_order: undefined } }
: route
));
}
restoreOriginalAssignedRoutes(): void { restoreOriginalAssignedRoutes(): void {
const original = this.loadOriginalAssigned(); const original = this.loadOriginalAssigned();
this.setDraftAssignedRoutes(original); this.setDraftAssignedRoutes(original);
@@ -112,11 +176,18 @@ export class RouteState {
const optimized = [...current] const optimized = [...current]
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv)) .sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
.map(route => { .map(route => {
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
const user = profiles const user = profiles
.slice() .slice()
.sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0]; .filter(profile => {
const userId = String(profile.json.usuario_id);
const capacity = Number(profile.json.hora_laboral_semanal_usuario || 0);
return (load.get(userId) ?? 0) + routeHours <= capacity;
})
.sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0]
?? profiles.slice().sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0];
const userId = String(user.json.usuario_id); const userId = String(user.json.usuario_id);
load.set(userId, (load.get(userId) ?? 0) + 1); load.set(userId, (load.get(userId) ?? 0) + routeHours);
return { return {
json: { json: {
...route.json, ...route.json,
@@ -4,14 +4,10 @@ import { Injectable } from '@angular/core';
export class PlanningInputService { export class PlanningInputService {
private pdvFile: File | null = null; private pdvFile: File | null = null;
private usuariosFile: File | null = null; private usuariosFile: File | null = null;
private radioKm = 50;
private velocidad = 28;
setFiles(pdvFile: File, usuariosFile: File, radioKm: number, velocidad: number): void { setFiles(pdvFile: File, usuariosFile: File): void {
this.pdvFile = pdvFile; this.pdvFile = pdvFile;
this.usuariosFile = usuariosFile; this.usuariosFile = usuariosFile;
this.radioKm = radioKm;
this.velocidad = velocidad;
} }
getFormData(): FormData | null { getFormData(): FormData | null {
@@ -19,8 +15,6 @@ export class PlanningInputService {
const data = new FormData(); const data = new FormData();
data.append('pdv_file', this.pdvFile, this.pdvFile.name); data.append('pdv_file', this.pdvFile, this.pdvFile.name);
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name); data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
data.append('radio_km', String(this.radioKm));
data.append('velocidad', String(this.velocidad));
return data; return data;
} }
@@ -33,6 +33,7 @@
</div> </div>
</div> </div>
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div> <div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
<div class="table-error" *ngIf="editError"><i class="pi pi-exclamation-triangle"></i> {{ editError }}</div>
<p-table <p-table
#dt #dt
@@ -36,6 +36,7 @@ export class AssignedRouteComponent {
editUserId = ''; editUserId = '';
editDay = ''; editDay = '';
hasLocalChanges = false; hasLocalChanges = false;
editError: string | null = null;
reoptLoading = false; reoptLoading = false;
reoptError: string | null = null; reoptError: string | null = null;
@@ -113,6 +114,7 @@ export class AssignedRouteComponent {
this.editingRoute = route; this.editingRoute = route;
this.editUserId = String(route.json.usuario_id); this.editUserId = String(route.json.usuario_id);
this.editDay = route.json.dia; this.editDay = route.json.dia;
this.editError = null;
} }
cancelEdit(): void { cancelEdit(): void {
@@ -121,6 +123,16 @@ export class AssignedRouteComponent {
saveEdit(): void { saveEdit(): void {
if (!this.editingRoute || !this.editUserId || !this.editDay) return; if (!this.editingRoute || !this.editUserId || !this.editDay) return;
const capacity = this.routesState.canAssignToDay(
this.editUserId,
this.editDay,
Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0),
this.editingRoute
);
if (!capacity.allowed) {
this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
return;
}
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay); this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
this.hasLocalChanges = true; this.hasLocalChanges = true;
this.editingRoute = null; this.editingRoute = null;
@@ -101,31 +101,6 @@
</div> </div>
</div> </div>
<div class="params-card">
<h3 class="params-title"><i class="pi pi-sliders-h"></i> Parámetros de optimización</h3>
<div class="params-grid">
<div class="param-field">
<label class="param-label" for="radioKm">Radio máximo por PDV</label>
<select id="radioKm" class="param-select" [(ngModel)]="radioKm" (ngModelChange)="onRadioChange()">
<option [ngValue]="30">30 km — Urbano denso</option>
<option [ngValue]="50">50 km — Urbano (default)</option>
<option [ngValue]="80">80 km — Rural disperso</option>
</select>
<small class="param-hint">PDV sin usuario dentro del radio queda sin asignar</small>
</div>
<div class="param-field">
<label class="param-label" for="velocidad">Velocidad promedio</label>
<select id="velocidad" class="param-select" [(ngModel)]="velocidad" (ngModelChange)="onVelocidadChange()">
<option [ngValue]="22">22 km/h — Centro / tráfico denso</option>
<option [ngValue]="28">28 km/h — Urbano (default)</option>
<option [ngValue]="35">35 km/h — Mixto</option>
<option [ngValue]="46">46 km/h — Ruta rápida</option>
</select>
<small class="param-hint">Usado si ORS vial no disponible. En ciudad 22-28 es más real que 46</small>
</div>
</div>
</div>
<div class="upload-actions"> <div class="upload-actions">
<div class="upload-error" *ngIf="uploadError"> <div class="upload-error" *ngIf="uploadError">
<i class="pi pi-exclamation-triangle"></i> <i class="pi pi-exclamation-triangle"></i>
@@ -7,7 +7,6 @@ import { RutasService } from '../../core/services/router.service';
import { RouteState } from '../../core/route-state/route'; import { RouteState } from '../../core/route-state/route';
import { ApiResponse } from '../../core/models/route.model'; import { ApiResponse } from '../../core/models/route.model';
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
import { DEFAULT_PARAMS } from '../../shared/schemas/params.schema';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
import { PlanningInputService } from '../../core/services/planning-input.service'; import { PlanningInputService } from '../../core/services/planning-input.service';
@@ -30,32 +29,13 @@ export class HomeComponent {
isLoading = false; isLoading = false;
private calcSub: Subscription | null = null; private calcSub: Subscription | null = null;
// P2-A params
radioKm: 30 | 50 | 80 = DEFAULT_PARAMS.radioKm;
velocidad: number = DEFAULT_PARAMS.velocidad;
constructor( constructor(
private rutasService: RutasService, private rutasService: RutasService,
private routeState: RouteState, private routeState: RouteState,
private previewService: ExcelPreviewService, private previewService: ExcelPreviewService,
private planningInput: PlanningInputService, private planningInput: PlanningInputService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
) { ) {}
try {
const raw = sessionStorage.getItem('rp_params');
if (raw) {
const p = JSON.parse(raw) as { radioKm?: number; velocidad?: number };
if ([30, 50, 80].includes(p.radioKm as number)) this.radioKm = p.radioKm as 30|50|80;
if (typeof p.velocidad === 'number' && p.velocidad >= 20 && p.velocidad <= 80) this.velocidad = p.velocidad;
}
} catch { /* ignore */ }
}
onRadioChange() { this.persistParams(); }
onVelocidadChange() { this.persistParams(); }
private persistParams() {
try { sessionStorage.setItem('rp_params', JSON.stringify({ radioKm: this.radioKm, velocidad: this.velocidad })); } catch { /* ignore */ }
}
async onPdvFileChange(event: Event) { async onPdvFileChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@@ -69,7 +49,7 @@ export class HomeComponent {
return; return;
} }
this.pdvFile = file; this.pdvFile = file;
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile, this.radioKm, this.velocidad); if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile);
this.uploadError = null; this.uploadError = null;
this.pdvPreviewLoading = true; this.pdvPreviewLoading = true;
this.pdvPreview = null; this.pdvPreview = null;
@@ -110,7 +90,7 @@ export class HomeComponent {
return; return;
} }
this.usuariosFile = file; this.usuariosFile = file;
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file, this.radioKm, this.velocidad); if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file);
this.uploadError = null; this.uploadError = null;
this.usuariosPreviewLoading = true; this.usuariosPreviewLoading = true;
this.usuariosPreview = null; this.usuariosPreview = null;
@@ -172,19 +152,27 @@ export class HomeComponent {
return null; return null;
} }
cargarRutas() { async cargarRutas() {
if (!this.canSubmit) return; if (!this.canSubmit) return;
this.isLoading = true; this.isLoading = true;
this.uploadError = null; this.uploadError = null;
this.routeState.setLoading(true); this.routeState.setLoading(true);
this.planningInput.setFiles(this.pdvFile!, this.usuariosFile!, this.radioKm, this.velocidad); let pdvForOptimizer: File;
try {
pdvForOptimizer = await this.previewService.preparePdvForOptimizer(this.pdvFile!);
} catch {
this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.';
this.routeState.setLoading(false);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!);
const formData = new FormData(); const formData = new FormData();
formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name); formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name); formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
formData.append('radio_km', String(this.radioKm));
formData.append('velocidad', String(this.velocidad));
this.calcSub?.unsubscribe(); this.calcSub?.unsubscribe();
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
@@ -29,6 +29,22 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
.day-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 3px 5px;
align-items: center;
}
.day-checkboxes label {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--glm-acero);
font-size: 0.68rem;
font-weight: 600;
}
.spinner-inline { .spinner-inline {
display: inline-block; display: inline-block;
width: 13px; width: 13px;
@@ -25,10 +25,9 @@
<option value="">Usuario</option> <option value="">Usuario</option>
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option> <option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
</select> </select>
<select [(ngModel)]="assignmentDay[row.pdv_id]" class="inline-select"> <div class="day-checkboxes">
<option value="">Día</option> <label *ngFor="let day of dayOptions"><input type="checkbox" [checked]="(assignmentDays[row.pdv_id] || []).includes(day)" (change)="toggleDay(row.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label>
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option> </div>
</select>
<button type="button" class="btn-icon" (click)="assignRoute(row)" [disabled]="assigningId === row.pdv_id" title="Asignar y calcular ruta"> <button type="button" class="btn-icon" (click)="assignRoute(row)" [disabled]="assigningId === row.pdv_id" title="Asignar y calcular ruta">
<span *ngIf="assigningId === row.pdv_id" class="spinner-inline"></span> <span *ngIf="assigningId === row.pdv_id" class="spinner-inline"></span>
<i *ngIf="assigningId !== row.pdv_id" class="pi pi-plus"></i> <i *ngIf="assigningId !== row.pdv_id" class="pi pi-plus"></i>
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { RouteNoAssignedResponse } from '../../../core/models/route.model'; import { RouteNoAssignedResponse } from '../../../core/models/route.model';
import { RouteState } from '../../../core/route-state/route'; import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { OrsService } from '../../../core/services/ors.service'; import { OrsService } from '../../../core/services/ors.service';
@@ -21,7 +21,7 @@ export class NoAssignedRouteComponent implements OnDestroy {
cols: { field: string; header: string }[] = []; cols: { field: string; header: string }[] = [];
private subs: Subscription[] = []; private subs: Subscription[] = [];
assignmentUser: Record<string, string> = {}; assignmentUser: Record<string, string> = {};
assignmentDay: Record<string, string> = {}; assignmentDays: Record<string, string[]> = {};
assigningId: number | null = null; assigningId: number | null = null;
actionError: string | null = null; actionError: string | null = null;
@@ -62,11 +62,18 @@ export class NoAssignedRouteComponent implements OnDestroy {
return this.routesState.getDiasUnicos(); return this.routesState.getDiasUnicos();
} }
toggleDay(pdvId: number, day: string, checked: boolean): void {
const key = String(pdvId);
const days = new Set(this.assignmentDays[key] ?? []);
checked ? days.add(day) : days.delete(day);
this.assignmentDays[key] = Array.from(days);
}
async assignRoute(route: RouteNoAssignedResponse): Promise<void> { async assignRoute(route: RouteNoAssignedResponse): Promise<void> {
const userId = this.assignmentUser[String(route.pdv_id)]; const userId = this.assignmentUser[String(route.pdv_id)];
const day = this.assignmentDay[String(route.pdv_id)]; const days = this.assignmentDays[String(route.pdv_id)] ?? [];
if (!userId || !day) { if (!userId || !days.length) {
this.actionError = 'Selecciona usuario y día antes de asignar.'; this.actionError = 'Selecciona usuario y al menos un día antes de asignar.';
return; return;
} }
const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
@@ -76,13 +83,22 @@ export class NoAssignedRouteComponent implements OnDestroy {
try { try {
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)]; const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
const destination = [Number(route.longitud), Number(route.latitud)]; const destination = [Number(route.longitud), Number(route.latitud)];
const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); const assignments: UnassignedAssignment[] = [];
const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson; for (const day of days) {
const summary = geo?.features?.[0]?.properties?.summary; const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
if (!summary) throw new Error('ORS no devolvió una ruta vial'); const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson;
this.routesState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600); const summary = geo?.features?.[0]?.properties?.summary;
if (!summary) throw new Error('ORS no devolvió una ruta vial');
const capacity = this.routesState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600);
if (!capacity.allowed) {
this.actionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
return;
}
assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 });
}
this.routesState.addAssignedFromUnassignedMany(route, assignments);
delete this.assignmentUser[String(route.pdv_id)]; delete this.assignmentUser[String(route.pdv_id)];
delete this.assignmentDay[String(route.pdv_id)]; delete this.assignmentDays[String(route.pdv_id)];
} catch { } catch {
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
} finally { } finally {
@@ -180,15 +180,26 @@
} }
.utilization-cell { .utilization-cell {
display: grid; display: flex;
grid-template-columns: 48px minmax(70px, 1fr);
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 130px; min-width: 220px;
}
.utilization-cell > span:first-child {
flex: 0 0 56px;
}
.capacity-warning {
cursor: help;
font-size: 0.9rem;
line-height: 1;
} }
.utilization-bar { .utilization-bar {
display: block; display: block;
flex: 1 1 140px;
min-width: 140px;
height: 7px; height: 7px;
overflow: hidden; overflow: hidden;
border-radius: 10px; border-radius: 10px;
@@ -202,6 +213,19 @@
background: var(--glm-verde); background: var(--glm-verde);
} }
.utilization-cell.warning .utilization-bar span {
background: var(--glm-naranja);
}
.utilization-cell.over-capacity .utilization-bar span {
background: #c62828;
}
.load-over-capacity td {
color: #8f1616;
font-weight: 600;
}
.reasons-card { .reasons-card {
margin-top: 14px; margin-top: 14px;
padding: 12px 14px; padding: 12px 14px;
@@ -64,15 +64,16 @@
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr> <tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let load of userLoads"> <tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'">
<td>{{ load.name }}</td> <td>{{ load.name }}</td>
<td>{{ load.routes }}</td> <td>{{ load.routes }}</td>
<td>{{ load.distance | number:'1.0-1' }} km</td> <td>{{ load.distance | number:'1.0-1' }} km</td>
<td>{{ load.workHours | number:'1.0-1' }} h</td> <td>{{ load.workHours | number:'1.0-1' }} h</td>
<td> <td>
<div class="utilization-cell"> <div class="utilization-cell" [ngClass]="utilizationClass(load)">
<span>{{ load.utilization | number:'1.0-1' }}%</span> <span>{{ load.utilization | number:'1.0-1' }}%</span>
<span class="utilization-bar"><span [style.width.%]="barWidth(load.utilization)"></span></span> <span class="capacity-warning" *ngIf="load.status === 'over-capacity'" [title]="load.alertMessage" aria-label="Alerta de capacidad">⚠️</span>
<span class="utilization-bar" [title]="load.alertMessage"><span [style.width.%]="barWidth(load.utilization)"></span></span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { RouteState } from '../../../core/route-state/route'; import { RouteState } from '../../../core/route-state/route';
import { PlanningExportService } from '../../../shared/services/planning-export.service'; import { PlanningExportService } from '../../../shared/services/planning-export.service';
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
import { RouteAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse } from '../../../core/models/route.model';
interface KpiSummary { interface KpiSummary {
@@ -28,12 +29,15 @@ interface ReasonSummary {
} }
interface UserLoad { interface UserLoad {
id: string;
name: string; name: string;
routes: number; routes: number;
distance: number; distance: number;
workHours: number; workHours: number;
capacityHours: number; capacityHours: number;
utilization: number; utilization: number;
status: 'ok' | 'warning' | 'over-capacity';
alertMessage: string;
} }
@Component({ @Component({
@@ -71,6 +75,10 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
return Math.min(Math.max(value, 0), 100); return Math.min(Math.max(value, 0), 100);
} }
utilizationClass(load: UserLoad): string {
return load.status;
}
exportPlanning(): void { exportPlanning(): void {
this.exportService.export(); this.exportService.export();
} }
@@ -79,9 +87,10 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
const assigned = this.routeState.getAssignedSnapshot(); const assigned = this.routeState.getAssignedSnapshot();
const unassigned = this.routeState.getNoAssignedSnapshot(); const unassigned = this.routeState.getNoAssignedSnapshot();
const totalPdvs = assigned.length + unassigned.length; const totalPdvs = assigned.length + unassigned.length;
const totalDistance = assigned.reduce((sum, route) => sum + this.numberValue(route.json.distancia), 0); const dailyRoutes = summarizeDailyRoutes(assigned);
const totalTravelHours = assigned.reduce((sum, route) => sum + this.numberValue(route.json.horas_desplazamiento), 0); const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
const totalWorkHours = assigned.reduce((sum, route) => sum + this.parseHours(route.json.horas_trabajo), 0); const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0);
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size; const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
const days = new Set(assigned.map(route => route.json.dia)).size; const days = new Set(assigned.map(route => route.json.dia)).size;
const loads = new Map<string, UserLoad>(); const loads = new Map<string, UserLoad>();
@@ -89,25 +98,34 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
for (const route of assigned) { for (const route of assigned) {
const key = String(route.json.usuario_id); const key = String(route.json.usuario_id);
const current = loads.get(key) ?? { const current = loads.get(key) ?? {
id: key,
name: route.json.nombre_usuario, name: route.json.nombre_usuario,
routes: 0, routes: 0,
distance: 0, distance: 0,
workHours: 0, workHours: 0,
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
utilization: 0 utilization: 0,
status: 'ok',
alertMessage: ''
}; };
current.routes++; current.routes++;
current.distance += this.numberValue(route.json.distancia); // Sequential distance and work are aggregated per user below.
current.workHours += this.parseHours(route.json.horas_trabajo);
loads.set(key, current); loads.set(key, current);
} }
this.userLoads = Array.from(loads.values()) this.userLoads = Array.from(loads.values()).map(load => {
.map(load => ({ const userDays = dailyRoutes.filter(day => day.userId === load.id);
...load, load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
utilization: load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0 load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
})) load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0;
.sort((a, b) => b.routes - a.routes || b.workHours - a.workHours); load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
load.alertMessage = load.status === 'over-capacity'
? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.`
: load.status === 'warning'
? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
return load;
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
const routeCounts = this.userLoads.map(load => load.routes); const routeCounts = this.userLoads.map(load => load.routes);
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0; const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
const averageUtilization = this.userLoads.length const averageUtilization = this.userLoads.length
@@ -147,11 +165,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
return Number.isFinite(result) ? result : 0; return Number.isFinite(result) ? result : 0;
} }
private parseHours(value: number | string | null | undefined): number {
const result = Number.parseFloat(String(value ?? ''));
return Number.isFinite(result) ? result : 0;
}
private emptySummary(): KpiSummary { private emptySummary(): KpiSummary {
return { return {
totalPdvs: 0, totalPdvs: 0,
@@ -213,6 +213,8 @@
.pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; } .pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
.pending-edit-grid input, .pending-edit-grid input,
.pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; } .pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
.day-checkboxes { display: flex; flex-wrap: wrap; gap: 4px; }
.day-checkboxes label { display: inline-flex; align-items: center; gap: 3px; color: var(--glm-acero); font-size: 0.72rem; font-weight: 600; }
.wide-field { grid-column: 1 / -1; } .wide-field { grid-column: 1 / -1; }
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } .pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
@@ -353,6 +355,14 @@
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; } .reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; } .reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; }
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
.daily-route:last-child { border-bottom: 0; }
.daily-route span, .daily-route small { color: var(--glm-acero); }
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
.btn-icon { .btn-icon {
background: transparent; background: transparent;
border: 1px solid var(--glm-gris-medio); border: 1px solid var(--glm-gris-medio);
@@ -511,46 +521,3 @@
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
.location-pin {
position: relative;
width: 30px;
height: 38px;
background: #c9252d;
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
}
.location-pin::before {
content: '';
position: absolute;
inset: 3px 3px 7px;
background: var(--pin-color, #ff424a);
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
}
.location-pin::after {
content: '';
position: absolute;
top: 8px;
left: 8px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #620000;
}
.location-pin span {
position: absolute;
z-index: 1;
top: 8px;
left: 8px;
width: 14px;
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
}
.unassigned-pin { --pin-color: #ff424a; }
@@ -11,7 +11,7 @@
</div> </div>
<div class="sidebar-header"> <div class="sidebar-header">
<button type="button" class="back-btn" (click)="goBack()"> <button type="button" class="back-btn" (click)="goBack()" aria-label="Volver a la planificación">
<i class="pi pi-arrow-left"></i> Volver <i class="pi pi-arrow-left"></i> Volver
</button> </button>
<h2 class="sidebar-title">Mapa de rutas</h2> <h2 class="sidebar-title">Mapa de rutas</h2>
@@ -98,7 +98,7 @@
</dl> </dl>
<div class="pending-edit-grid"> <div class="pending-edit-grid">
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label> <label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
<label>Día<select [(ngModel)]="assignmentDay[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label> <label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
</div> </div>
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div> <div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
</ng-template> </ng-template>
@@ -108,7 +108,7 @@
<section class="info-card" *ngIf="selectedRouteDetail"> <section class="info-card" *ngIf="selectedRouteDetail">
<div class="info-card-header"> <div class="info-card-header">
<h3>Detalle de la ruta</h3> <h3>Detalle de la ruta</h3>
<button type="button" class="btn-icon" (click)="clearSelection()" title="Cerrar detalle"> <button type="button" class="btn-icon" (click)="clearSelection()" title="Cerrar detalle" aria-label="Cerrar detalle de ruta">
<i class="pi pi-times"></i> <i class="pi pi-times"></i>
</button> </button>
</div> </div>
@@ -135,6 +135,9 @@
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div> <div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
</dl> </dl>
<div class="reassign-actions" *ngIf="!showReassignForm"> <div class="reassign-actions" *ngIf="!showReassignForm">
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button>
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button>
<button type="button" class="btn-secondary" (click)="resetSelectedDayOrder()"><i class="pi pi-refresh"></i> Orden automático</button>
<button type="button" class="btn-secondary" (click)="startReassignRoute()"><i class="pi pi-user-edit"></i> Reasignar ruta</button> <button type="button" class="btn-secondary" (click)="startReassignRoute()"><i class="pi pi-user-edit"></i> Reasignar ruta</button>
</div> </div>
<div class="reassign-form" *ngIf="showReassignForm"> <div class="reassign-form" *ngIf="showReassignForm">
@@ -147,12 +150,21 @@
</div> </div>
</section> </section>
<section class="daily-routes-card" *ngIf="dailySummaries.length">
<h3>Rutas diarias</h3>
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity">
<strong>{{ day.userName }} · {{ day.day }}</strong>
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</span>
<small [attr.aria-label]="day.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
</article>
</section>
<div class="routes-list" *ngIf="filteredRoutes.length"> <div class="routes-list" *ngIf="filteredRoutes.length">
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3> <h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
<ul> <ul>
<li *ngFor="let r of filteredRoutes; let i = index" <li *ngFor="let r of filteredRoutes; let i = index" tabindex="0" role="button"
[class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia" [class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia"
(click)="selectRouteFromList(r)"> (click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
<span class="idx">{{ routeVisitOrder(r) }}</span> <span class="idx">{{ routeVisitOrder(r) }}</span>
<div class="li-main"> <div class="li-main">
<strong>{{ r.json.nombre_pdv }}</strong> <strong>{{ r.json.nombre_pdv }}</strong>
@@ -5,11 +5,11 @@ import { Router, ActivatedRoute } from '@angular/router';
import { Subscription, Subject } from 'rxjs'; import { Subscription, Subject } from 'rxjs';
import * as L from 'leaflet'; import * as L from 'leaflet';
import 'leaflet.heat'; import 'leaflet.heat';
import { RouteState } from '../../../core/route-state/route'; import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
import { OrsService } from '../../../core/services/ors.service'; import { OrsService } from '../../../core/services/ors.service';
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
import { colorForUsuario } from '../../../shared/utils/palette.utils'; import { colorForUsuario } from '../../../shared/utils/palette.utils';
import { haversineKm, sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
interface MapRoute { interface MapRoute {
data: RouteAssignedResponse; data: RouteAssignedResponse;
@@ -71,7 +71,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
editUnassignedLng = 0; editUnassignedLng = 0;
editUnassignedReason = ''; editUnassignedReason = '';
assignmentUser: Record<string, string> = {}; assignmentUser: Record<string, string> = {};
assignmentDay: Record<string, string> = {}; assignmentDays: Record<string, string[]> = {};
assigningUnassignedId: number | null = null; assigningUnassignedId: number | null = null;
unassignedActionError: string | null = null; unassignedActionError: string | null = null;
reassigningRoute = false; reassigningRoute = false;
@@ -94,6 +94,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
return sum.toFixed(0); return sum.toFixed(0);
} }
get dailySummaries(): DailyRouteSummary[] {
return summarizeDailyRoutes(this.filteredRoutes);
}
constructor( constructor(
private routeState: RouteState, private routeState: RouteState,
private orsService: OrsService, private orsService: OrsService,
@@ -517,6 +521,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.reassignError = null; this.reassignError = null;
} }
moveSelectedVisit(direction: -1 | 1): void {
if (!this.selectedRouteDetail) return;
this.routeState.moveRouteVisit(this.selectedRouteDetail, direction);
}
resetSelectedDayOrder(): void {
if (!this.selectedRouteDetail) return;
this.routeState.resetRouteVisitOrder(this.selectedRouteDetail.json.usuario_id, this.selectedRouteDetail.json.dia);
}
async reassignRoute(): Promise<void> { async reassignRoute(): Promise<void> {
const route = this.selectedRouteDetail; const route = this.selectedRouteDetail;
if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return; if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return;
@@ -540,6 +554,17 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined; const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined;
const summary = response?.geojson?.features?.[0]?.properties?.summary; const summary = response?.geojson?.features?.[0]?.properties?.summary;
if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial'); if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial');
const capacity = this.routeState.canAssignToDay(
this.reassignUserId,
this.reassignDay,
Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0),
route,
summary.duration / 3600
);
if (!capacity.allowed) {
this.reassignError = `La reasignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
return;
}
this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, { this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, {
distance: summary.distance / 1000, distance: summary.distance / 1000,
duration: summary.duration / 3600 duration: summary.duration / 3600
@@ -586,6 +611,15 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-'; return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-';
} }
get canMoveSelectedVisitUp(): boolean {
return (this.selectedMapSegment?.visitOrder ?? 0) > 1;
}
get canMoveSelectedVisitDown(): boolean {
const segment = this.selectedMapSegment;
return !!segment && segment.visitOrder < segment.totalStops;
}
get selectedSegmentOrigin(): string { get selectedSegmentOrigin(): string {
const segment = this.selectedMapSegment; const segment = this.selectedMapSegment;
if (!segment) return '-'; if (!segment) return '-';
@@ -621,6 +655,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
return this.routeState.getDiasUnicos(); return this.routeState.getDiasUnicos();
} }
toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void {
const key = String(pdvId);
const days = new Set(this.assignmentDays[key] ?? []);
checked ? days.add(day) : days.delete(day);
this.assignmentDays[key] = Array.from(days);
}
selectUnassigned(route: RouteNoAssignedResponse): void { selectUnassigned(route: RouteNoAssignedResponse): void {
this.selectedUnassigned = route; this.selectedUnassigned = route;
this.selectedRouteDetail = null; this.selectedRouteDetail = null;
@@ -670,9 +711,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
async assignUnassigned(route: RouteNoAssignedResponse): Promise<void> { async assignUnassigned(route: RouteNoAssignedResponse): Promise<void> {
const userId = this.assignmentUser[String(route.pdv_id)]; const userId = this.assignmentUser[String(route.pdv_id)];
const day = this.assignmentDay[String(route.pdv_id)]; const days = this.assignmentDays[String(route.pdv_id)] ?? [];
if (!userId || !day) { if (!userId || !days.length) {
this.unassignedActionError = 'Selecciona usuario y día antes de asignar.'; this.unassignedActionError = 'Selecciona usuario y al menos un día antes de asignar.';
return; return;
} }
const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
@@ -682,14 +723,23 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
try { try {
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)]; const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
const destination = [Number(route.longitud), Number(route.latitud)]; const destination = [Number(route.longitud), Number(route.latitud)];
const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); const assignments: UnassignedAssignment[] = [];
const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string }); for (const day of days) {
const summary = geo?.geojson?.features?.[0]?.properties?.summary; const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial'); const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string });
this.routeState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600); const summary = geo?.geojson?.features?.[0]?.properties?.summary;
if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial');
const capacity = this.routeState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600);
if (!capacity.allowed) {
this.unassignedActionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
return;
}
assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 });
}
this.routeState.addAssignedFromUnassignedMany(route, assignments);
this.selectedUnassigned = null; this.selectedUnassigned = null;
delete this.assignmentUser[String(route.pdv_id)]; delete this.assignmentUser[String(route.pdv_id)];
delete this.assignmentDay[String(route.pdv_id)]; delete this.assignmentDays[String(route.pdv_id)];
} catch { } catch {
this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
} finally { } finally {
@@ -1,3 +1,2 @@
export * from './pdv.schema'; export * from './pdv.schema';
export * from './usuario.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', 'segmentacion',
'latitud', 'latitud',
'longitud', 'longitud',
'visitas_semanales',
'duracion_visitahoras',
'prioridad', 'prioridad',
] as const; ] 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'), 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'), 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'), 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(24, 'Duración visita máximo 24'), 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'), 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>; export type PdvRow = z.infer<typeof pdvRowSchema>;
@@ -52,6 +52,13 @@ export class ExcelPreviewService {
missingHeaders.push(displayRequired[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 errors: FieldError[] = [];
const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index); 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['longitud'] = this.coerceNum(row['longitud']);
coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']); coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']);
coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']); coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']);
coerced['minimo_horas_semana'] = this.coerceNum(row['minimo_horas_semana']);
coerced['prioridad'] = this.coerceNum(row['prioridad']); coerced['prioridad'] = this.coerceNum(row['prioridad']);
if (typeof coerced['segmentacion'] === 'string') { if (typeof coerced['segmentacion'] === 'string') {
coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase(); coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase();
@@ -139,6 +147,33 @@ export class ExcelPreviewService {
return Number.isNaN(n) ? value : n; 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[] { private extractHeaders(sheet: XLSX.WorkSheet): string[] {
const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1'); const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1');
const headers: string[] = []; const headers: string[] = [];
@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { RouteState } from '../../core/route-state/route'; import { RouteState } from '../../core/route-state/route';
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlanningExportService { export class PlanningExportService {
@@ -28,8 +29,9 @@ export class PlanningExportService {
} }
const total = assigned.length + unassigned.length; const total = assigned.length + unassigned.length;
const totalDistance = assigned.reduce((sum, route) => sum + (Number(route.json.distancia) || 0), 0); const dailyRoutes = summarizeDailyRoutes(assigned);
const totalTravel = assigned.reduce((sum, route) => sum + (Number(route.json.horas_desplazamiento) || 0), 0); 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 workbook = XLSX.utils.book_new();
const summary = [ const summary = [
@@ -43,16 +45,18 @@ export class PlanningExportService {
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) } { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }
]; ];
const assignedRows = assigned.map(route => ({ const assignedRows = sequenceRoutes(assigned).map(segment => ({
'Usuario ID': route.json.usuario_id, 'Usuario ID': segment.route.json.usuario_id,
Usuario: route.json.nombre_usuario, Usuario: segment.route.json.nombre_usuario,
'PDV ID': route.json.pdv_id, 'PDV ID': segment.route.json.pdv_id,
PDV: route.json.nombre_pdv, PDV: segment.route.json.nombre_pdv,
Día: route.json.dia, Día: segment.route.json.dia,
'Distancia (km)': route.json.distancia, 'Distancia (km)': segment.route.json.distancia,
'Desplazamiento (h)': route.json.horas_desplazamiento, 'Desplazamiento (h)': segment.route.json.horas_desplazamiento,
'Horas trabajo': route.json.horas_trabajo, 'Horas trabajo': segment.route.json.horas_trabajo,
'Horas disponibles': route.json.hora_laboral_semanal_usuario '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 => ({ const unassignedRows = unassigned.map(route => ({
'PDV ID': route.pdv_id, 'PDV ID': route.pdv_id,
@@ -62,20 +66,34 @@ export class PlanningExportService {
Longitud: route.longitud, Longitud: route.longitud,
Motivo: route.motivo Motivo: route.motivo
})); }));
const userRows = Array.from(users, ([id, user]) => ({ const userRows = Array.from(users, ([id, user]) => {
'Usuario ID': id, const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
Usuario: user.nombre, return {
Rutas: user.rutas, 'Usuario ID': id,
'Distancia (km)': Number(user.distancia.toFixed(2)), Usuario: user.nombre,
'Horas trabajo': Number(user.trabajo.toFixed(2)), Rutas: user.rutas,
'Capacidad (h)': user.capacidad, 'Distancia (km)': Number(user.distancia.toFixed(2)),
'Utilización (%)': user.capacidad ? Number(((user.trabajo / user.capacidad) * 100).toFixed(1)) : 0 '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, summary, 'Resumen');
this.appendSheet(workbook, assignedRows, 'Rutas asignadas'); this.appendSheet(workbook, assignedRows, 'Rutas asignadas');
this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar'); this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar');
this.appendSheet(workbook, userRows, 'Carga por usuario'); 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' }); const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx'); saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx');
@@ -9,6 +9,20 @@ export interface SequencedRoute {
previousPdvName: string | null; 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[] { export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] {
const groups = new Map<string, RouteAssignedResponse[]>(); const groups = new Map<string, RouteAssignedResponse[]>();
for (const route of routes) { for (const route of routes) {
@@ -23,17 +37,22 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[
const first = group[0].json; const first = group[0].json;
let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)]; let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)];
let previousPdvName: string | null = null; 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) { while (remaining.length) {
let nearestIndex = 0; let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY; if (!hasManualOrder) {
for (let index = 0; index < remaining.length; index++) { let nearestDistance = Number.POSITIVE_INFINITY;
const candidate = remaining[index].json; for (let index = 0; index < remaining.length; index++) {
const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]); const candidate = remaining[index].json;
if (distance < nearestDistance) { const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]);
nearestDistance = distance; if (distance < nearestDistance) {
nearestIndex = index; nearestDistance = distance;
nearestIndex = index;
}
} }
} }
@@ -55,6 +74,38 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[
return sequenced; 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 { export function haversineKm(origin: [number, number], destination: [number, number]): number {
const toRadians = (value: number) => value * Math.PI / 180; const toRadians = (value: number) => value * Math.PI / 180;
const [lon1, lat1] = origin; const [lon1, lat1] = origin;
@@ -1,6 +1,6 @@
export const environment = { export const environment = {
production: false, production: false,
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
orsBaseUrl: 'http://localhost:18080/ors/v2', orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000, requestTimeoutMs: 30000,
}; };