feat: subir json del flujo de n8n #1
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;
|
||||
usuario_id: string;
|
||||
pdv_id: number;
|
||||
visit_order?: number;
|
||||
}
|
||||
export interface RouteNoAssignedResponse {
|
||||
pdv_id: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
RouteAssignedResponse,
|
||||
RouteNoAssignedResponse
|
||||
} from '../models/route.model';
|
||||
import { summarizeDailyRoutes } from '../../shared/utils/route-sequencing.utils';
|
||||
|
||||
const STORAGE_ASSIGNED = 'rp_assigned_routes';
|
||||
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_SELECTED = 'rp_selected_route';
|
||||
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
|
||||
export interface UnassignedAssignment {
|
||||
userId: string;
|
||||
day: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RouteState {
|
||||
@@ -69,13 +76,18 @@ export class RouteState {
|
||||
}
|
||||
|
||||
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));
|
||||
if (!profile) return;
|
||||
const newRoute: RouteAssignedResponse = {
|
||||
json: {
|
||||
dia: day,
|
||||
distancia: distance,
|
||||
horas_desplazamiento: duration,
|
||||
this.addAssignedFromUnassignedMany(route, [{ userId, day, distance, duration }]);
|
||||
}
|
||||
|
||||
addAssignedFromUnassignedMany(route: RouteNoAssignedResponse, assignments: UnassignedAssignment[]): void {
|
||||
const routes = this.assignedRoutes.getValue();
|
||||
const newRoutes = assignments.map(assignment => {
|
||||
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_minima_semanal_pdv: route.minimo_horas_semana,
|
||||
horas_trabajo: String(route.minimo_horas_semana),
|
||||
@@ -87,9 +99,10 @@ export class RouteState {
|
||||
nombre_pdv: route.pdv_nombre,
|
||||
usuario_id: profile.json.usuario_id,
|
||||
pdv_id: route.pdv_id
|
||||
}
|
||||
};
|
||||
this.setDraftAssignedRoutes([...this.assignedRoutes.getValue(), newRoute]);
|
||||
} } as RouteAssignedResponse;
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -98,6 +111,57 @@ export class RouteState {
|
||||
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 {
|
||||
const original = this.loadOriginalAssigned();
|
||||
this.setDraftAssignedRoutes(original);
|
||||
@@ -112,11 +176,18 @@ export class RouteState {
|
||||
const optimized = [...current]
|
||||
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
|
||||
.map(route => {
|
||||
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
|
||||
const user = profiles
|
||||
.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);
|
||||
load.set(userId, (load.get(userId) ?? 0) + 1);
|
||||
load.set(userId, (load.get(userId) ?? 0) + routeHours);
|
||||
return {
|
||||
json: {
|
||||
...route.json,
|
||||
|
||||
@@ -4,14 +4,10 @@ import { Injectable } from '@angular/core';
|
||||
export class PlanningInputService {
|
||||
private pdvFile: 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.usuariosFile = usuariosFile;
|
||||
this.radioKm = radioKm;
|
||||
this.velocidad = velocidad;
|
||||
}
|
||||
|
||||
getFormData(): FormData | null {
|
||||
@@ -19,8 +15,6 @@ export class PlanningInputService {
|
||||
const data = new FormData();
|
||||
data.append('pdv_file', this.pdvFile, this.pdvFile.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
</div>
|
||||
</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
|
||||
#dt
|
||||
|
||||
@@ -36,6 +36,7 @@ export class AssignedRouteComponent {
|
||||
editUserId = '';
|
||||
editDay = '';
|
||||
hasLocalChanges = false;
|
||||
editError: string | null = null;
|
||||
reoptLoading = false;
|
||||
reoptError: string | null = null;
|
||||
|
||||
@@ -113,6 +114,7 @@ export class AssignedRouteComponent {
|
||||
this.editingRoute = route;
|
||||
this.editUserId = String(route.json.usuario_id);
|
||||
this.editDay = route.json.dia;
|
||||
this.editError = null;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
@@ -121,6 +123,16 @@ export class AssignedRouteComponent {
|
||||
|
||||
saveEdit(): void {
|
||||
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.hasLocalChanges = true;
|
||||
this.editingRoute = null;
|
||||
|
||||
@@ -101,31 +101,6 @@
|
||||
</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-error" *ngIf="uploadError">
|
||||
<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 { ApiResponse } from '../../core/models/route.model';
|
||||
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
|
||||
import { DEFAULT_PARAMS } from '../../shared/schemas/params.schema';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
||||
import { PlanningInputService } from '../../core/services/planning-input.service';
|
||||
@@ -30,32 +29,13 @@ export class HomeComponent {
|
||||
isLoading = false;
|
||||
private calcSub: Subscription | null = null;
|
||||
|
||||
// P2-A params
|
||||
radioKm: 30 | 50 | 80 = DEFAULT_PARAMS.radioKm;
|
||||
velocidad: number = DEFAULT_PARAMS.velocidad;
|
||||
|
||||
constructor(
|
||||
private rutasService: RutasService,
|
||||
private routeState: RouteState,
|
||||
private previewService: ExcelPreviewService,
|
||||
private planningInput: PlanningInputService,
|
||||
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) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -69,7 +49,7 @@ export class HomeComponent {
|
||||
return;
|
||||
}
|
||||
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.pdvPreviewLoading = true;
|
||||
this.pdvPreview = null;
|
||||
@@ -110,7 +90,7 @@ export class HomeComponent {
|
||||
return;
|
||||
}
|
||||
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.usuariosPreviewLoading = true;
|
||||
this.usuariosPreview = null;
|
||||
@@ -172,19 +152,27 @@ export class HomeComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
cargarRutas() {
|
||||
async cargarRutas() {
|
||||
if (!this.canSubmit) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.uploadError = null;
|
||||
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();
|
||||
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('radio_km', String(this.radioKm));
|
||||
formData.append('velocidad', String(this.velocidad));
|
||||
|
||||
this.calcSub?.unsubscribe();
|
||||
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
||||
|
||||
@@ -29,6 +29,22 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
|
||||
+3
-4
@@ -25,10 +25,9 @@
|
||||
<option value="">Usuario</option>
|
||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
<select [(ngModel)]="assignmentDay[row.pdv_id]" class="inline-select">
|
||||
<option value="">Día</option>
|
||||
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
|
||||
</select>
|
||||
<div class="day-checkboxes">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<i *ngIf="assigningId !== row.pdv_id" class="pi pi-plus"></i>
|
||||
|
||||
+23
-7
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TableModule } from 'primeng/table';
|
||||
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 { OrsService } from '../../../core/services/ors.service';
|
||||
|
||||
@@ -21,7 +21,7 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
||||
cols: { field: string; header: string }[] = [];
|
||||
private subs: Subscription[] = [];
|
||||
assignmentUser: Record<string, string> = {};
|
||||
assignmentDay: Record<string, string> = {};
|
||||
assignmentDays: Record<string, string[]> = {};
|
||||
assigningId: number | null = null;
|
||||
actionError: string | null = null;
|
||||
|
||||
@@ -62,11 +62,18 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
||||
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> {
|
||||
const userId = this.assignmentUser[String(route.pdv_id)];
|
||||
const day = this.assignmentDay[String(route.pdv_id)];
|
||||
if (!userId || !day) {
|
||||
this.actionError = 'Selecciona usuario y día antes de asignar.';
|
||||
const days = this.assignmentDays[String(route.pdv_id)] ?? [];
|
||||
if (!userId || !days.length) {
|
||||
this.actionError = 'Selecciona usuario y al menos un día antes de asignar.';
|
||||
return;
|
||||
}
|
||||
const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
|
||||
@@ -76,13 +83,22 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
||||
try {
|
||||
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
|
||||
const destination = [Number(route.longitud), Number(route.latitud)];
|
||||
const assignments: UnassignedAssignment[] = [];
|
||||
for (const day of days) {
|
||||
const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
|
||||
const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson;
|
||||
const summary = geo?.features?.[0]?.properties?.summary;
|
||||
if (!summary) throw new Error('ORS no devolvió una ruta vial');
|
||||
this.routesState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
|
||||
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.assignmentDay[String(route.pdv_id)];
|
||||
delete this.assignmentDays[String(route.pdv_id)];
|
||||
} catch {
|
||||
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||
} finally {
|
||||
|
||||
@@ -180,15 +180,26 @@
|
||||
}
|
||||
|
||||
.utilization-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(70px, 1fr);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: block;
|
||||
flex: 1 1 140px;
|
||||
min-width: 140px;
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
@@ -202,6 +213,19 @@
|
||||
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 {
|
||||
margin-top: 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>
|
||||
</thead>
|
||||
<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.routes }}</td>
|
||||
<td>{{ load.distance | number:'1.0-1' }} km</td>
|
||||
<td>{{ load.workHours | number:'1.0-1' }} h</td>
|
||||
<td>
|
||||
<div class="utilization-cell">
|
||||
<div class="utilization-cell" [ngClass]="utilizationClass(load)">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
||||
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
|
||||
interface KpiSummary {
|
||||
@@ -28,12 +29,15 @@ interface ReasonSummary {
|
||||
}
|
||||
|
||||
interface UserLoad {
|
||||
id: string;
|
||||
name: string;
|
||||
routes: number;
|
||||
distance: number;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
status: 'ok' | 'warning' | 'over-capacity';
|
||||
alertMessage: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -71,6 +75,10 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
return Math.min(Math.max(value, 0), 100);
|
||||
}
|
||||
|
||||
utilizationClass(load: UserLoad): string {
|
||||
return load.status;
|
||||
}
|
||||
|
||||
exportPlanning(): void {
|
||||
this.exportService.export();
|
||||
}
|
||||
@@ -79,9 +87,10 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const totalPdvs = assigned.length + unassigned.length;
|
||||
const totalDistance = assigned.reduce((sum, route) => sum + this.numberValue(route.json.distancia), 0);
|
||||
const totalTravelHours = assigned.reduce((sum, route) => sum + this.numberValue(route.json.horas_desplazamiento), 0);
|
||||
const totalWorkHours = assigned.reduce((sum, route) => sum + this.parseHours(route.json.horas_trabajo), 0);
|
||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 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 days = new Set(assigned.map(route => route.json.dia)).size;
|
||||
const loads = new Map<string, UserLoad>();
|
||||
@@ -89,25 +98,34 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = loads.get(key) ?? {
|
||||
id: key,
|
||||
name: route.json.nombre_usuario,
|
||||
routes: 0,
|
||||
distance: 0,
|
||||
workHours: 0,
|
||||
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
|
||||
utilization: 0
|
||||
utilization: 0,
|
||||
status: 'ok',
|
||||
alertMessage: ''
|
||||
};
|
||||
current.routes++;
|
||||
current.distance += this.numberValue(route.json.distancia);
|
||||
current.workHours += this.parseHours(route.json.horas_trabajo);
|
||||
// Sequential distance and work are aggregated per user below.
|
||||
loads.set(key, current);
|
||||
}
|
||||
|
||||
this.userLoads = Array.from(loads.values())
|
||||
.map(load => ({
|
||||
...load,
|
||||
utilization: load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0
|
||||
}))
|
||||
.sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||
this.userLoads = Array.from(loads.values()).map(load => {
|
||||
const userDays = dailyRoutes.filter(day => day.userId === load.id);
|
||||
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
||||
load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0;
|
||||
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 assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
||||
const averageUtilization = this.userLoads.length
|
||||
@@ -147,11 +165,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
return {
|
||||
totalPdvs: 0,
|
||||
|
||||
+10
-43
@@ -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 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -353,6 +355,14 @@
|
||||
.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; }
|
||||
|
||||
.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 {
|
||||
background: transparent;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
@@ -511,46 +521,3 @@
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
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; }
|
||||
|
||||
+17
-5
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
</button>
|
||||
<h2 class="sidebar-title">Mapa de rutas</h2>
|
||||
@@ -98,7 +98,7 @@
|
||||
</dl>
|
||||
<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>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 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>
|
||||
@@ -108,7 +108,7 @@
|
||||
<section class="info-card" *ngIf="selectedRouteDetail">
|
||||
<div class="info-card-header">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,6 +135,9 @@
|
||||
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
|
||||
</dl>
|
||||
<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>
|
||||
</div>
|
||||
<div class="reassign-form" *ngIf="showReassignForm">
|
||||
@@ -147,12 +150,21 @@
|
||||
</div>
|
||||
</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">
|
||||
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
|
||||
<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"
|
||||
(click)="selectRouteFromList(r)">
|
||||
(click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
|
||||
<span class="idx">{{ routeVisitOrder(r) }}</span>
|
||||
<div class="li-main">
|
||||
<strong>{{ r.json.nombre_pdv }}</strong>
|
||||
|
||||
+58
-8
@@ -5,11 +5,11 @@ import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Subscription, Subject } from 'rxjs';
|
||||
import * as L from 'leaflet';
|
||||
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 { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
|
||||
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 {
|
||||
data: RouteAssignedResponse;
|
||||
@@ -71,7 +71,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
editUnassignedLng = 0;
|
||||
editUnassignedReason = '';
|
||||
assignmentUser: Record<string, string> = {};
|
||||
assignmentDay: Record<string, string> = {};
|
||||
assignmentDays: Record<string, string[]> = {};
|
||||
assigningUnassignedId: number | null = null;
|
||||
unassignedActionError: string | null = null;
|
||||
reassigningRoute = false;
|
||||
@@ -94,6 +94,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
return sum.toFixed(0);
|
||||
}
|
||||
|
||||
get dailySummaries(): DailyRouteSummary[] {
|
||||
return summarizeDailyRoutes(this.filteredRoutes);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
@@ -517,6 +521,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
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> {
|
||||
const route = this.selectedRouteDetail;
|
||||
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 summary = response?.geojson?.features?.[0]?.properties?.summary;
|
||||
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, {
|
||||
distance: summary.distance / 1000,
|
||||
duration: summary.duration / 3600
|
||||
@@ -586,6 +611,15 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
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 {
|
||||
const segment = this.selectedMapSegment;
|
||||
if (!segment) return '-';
|
||||
@@ -621,6 +655,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
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 {
|
||||
this.selectedUnassigned = route;
|
||||
this.selectedRouteDetail = null;
|
||||
@@ -670,9 +711,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
|
||||
async assignUnassigned(route: RouteNoAssignedResponse): Promise<void> {
|
||||
const userId = this.assignmentUser[String(route.pdv_id)];
|
||||
const day = this.assignmentDay[String(route.pdv_id)];
|
||||
if (!userId || !day) {
|
||||
this.unassignedActionError = 'Selecciona usuario y día antes de asignar.';
|
||||
const days = this.assignmentDays[String(route.pdv_id)] ?? [];
|
||||
if (!userId || !days.length) {
|
||||
this.unassignedActionError = 'Selecciona usuario y al menos un día antes de asignar.';
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
|
||||
const destination = [Number(route.longitud), Number(route.latitud)];
|
||||
const assignments: UnassignedAssignment[] = [];
|
||||
for (const day of days) {
|
||||
const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
|
||||
const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string });
|
||||
const summary = geo?.geojson?.features?.[0]?.properties?.summary;
|
||||
if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial');
|
||||
this.routeState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
|
||||
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;
|
||||
delete this.assignmentUser[String(route.pdv_id)];
|
||||
delete this.assignmentDay[String(route.pdv_id)];
|
||||
delete this.assignmentDays[String(route.pdv_id)];
|
||||
} catch {
|
||||
this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||
} finally {
|
||||
|
||||
@@ -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]) => ({
|
||||
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 (%)': user.capacidad ? Number(((user.trabajo / user.capacidad) * 100).toFixed(1)) : 0
|
||||
}));
|
||||
'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,10 +37,14 @@ 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;
|
||||
if (!hasManualOrder) {
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < remaining.length; index++) {
|
||||
const candidate = remaining[index].json;
|
||||
@@ -36,6 +54,7 @@ export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [route] = remaining.splice(nearestIndex, 1);
|
||||
const destination: [number, number] = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
||||
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||
requestTimeoutMs: 30000,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user