diff --git a/Proyecto/Mexico-aguascalientes/PDV aguascaleintes.xlsx b/Proyecto/Mexico-aguascalientes/PDV aguascaleintes.xlsx new file mode 100644 index 0000000..47febcc Binary files /dev/null and b/Proyecto/Mexico-aguascalientes/PDV aguascaleintes.xlsx differ diff --git a/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Detalle_visitas_aguascalientes_limpio.xlsx b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Detalle_visitas_aguascalientes_limpio.xlsx new file mode 100644 index 0000000..0f09226 Binary files /dev/null and b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Detalle_visitas_aguascalientes_limpio.xlsx differ diff --git a/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/PDV_aguascalientes_import.xlsx b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/PDV_aguascalientes_import.xlsx new file mode 100644 index 0000000..cb96547 Binary files /dev/null and b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/PDV_aguascalientes_import.xlsx differ diff --git a/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Usuarios_aguascalientes_import_actualizado.xlsx b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Usuarios_aguascalientes_import_actualizado.xlsx new file mode 100644 index 0000000..958dd00 Binary files /dev/null and b/Proyecto/Mexico-aguascalientes/aguascalientes-modificado/Usuarios_aguascalientes_import_actualizado.xlsx differ diff --git a/Proyecto/Mexico-aguascalientes/detalle visitas aguascaleintes.xlsx b/Proyecto/Mexico-aguascalientes/detalle visitas aguascaleintes.xlsx new file mode 100644 index 0000000..afac906 Binary files /dev/null and b/Proyecto/Mexico-aguascalientes/detalle visitas aguascaleintes.xlsx differ diff --git a/Proyecto/Mexico-aguascalientes/usuarios.xlsx b/Proyecto/Mexico-aguascalientes/usuarios.xlsx new file mode 100644 index 0000000..d11d863 Binary files /dev/null and b/Proyecto/Mexico-aguascalientes/usuarios.xlsx differ diff --git a/n8n-workflow/GenerarReporteRutasPDF.json b/n8n-workflow/GenerarReporteRutasPDF.json index 55ebdfa..d6a0ca4 100644 --- a/n8n-workflow/GenerarReporteRutasPDF.json +++ b/n8n-workflow/GenerarReporteRutasPDF.json @@ -10,7 +10,10 @@ }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, - "position": [-600, 260], + "position": [ + -600, + 260 + ], "id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015", "name": "Webhook: Generate PDF" }, @@ -20,18 +23,24 @@ }, "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-360, 260], + "position": [ + -360, + 260 + ], "id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e", "name": "Validate Report Payload", "onError": "continueErrorOutput" }, { "parameters": { - "jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `${escape(key)}: ${escape(value)}`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `${escape(route.usuario)}${escape(route.dia)}${escape(route.orden)}${escape(route.pdv)}${escape(route.origen)}${escape(route.distanciaKm)} km${escape(route.horasTrabajo)} h`).join('') || 'Sin rutas asignadas para los filtros seleccionados.';\nconst unassignedRows = payload.unassignedPdvs.map(route => `${escape(route.pdv_id)}${escape(route.pdv_nombre)}${escape(route.minimo_horas_semana)} h${escape(route.motivo)}${escape(route.recomendacion)}`).join('') || 'No hay PDVs sin asignar para los filtros seleccionados.';\nconst summary = payload.summary ?? {};\nconst html = `

Reporte de rutas

${escape(payload.project.name)}
${escape(payload.project.country)}${payload.project.description ? `
${escape(payload.project.description)}` : ''}
Generado: ${escape(payload.generatedAt)}

${filters ? `
Filtros aplicados:
${filters}
` : '

Filtros aplicados: Ninguno

'}
${escape(summary.totalPdvs)}PDVs totales
${escape(summary.assignedPdvs)}PDVs asignados
${escape(summary.unassignedPdvs)}PDVs sin asignar
${escape(summary.coveragePercent)}%Cobertura
${escape(summary.totalDistanceKm)} kmDistancia
${escape(summary.totalWorkHours)} hTrabajo

Rutas asignadas

${routeRows}
UsuarioDíaOrdenPDVOrigenDistanciaTrabajo

PDVs sin asignar

${unassignedRows}
PDV IDPDVHoras/semanaMotivoRecomendación

Route Planner · GomezLee Marketing

`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];" + "jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `${escape(key)}: ${escape(value)}`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `${escape(route.usuario)}${escape(route.dia)}${escape(route.orden)}${escape(route.pdv)}${escape(route.origen)}${escape(route.distanciaKm)} km${escape(route.horasTrabajo)} h${route.costoCombustible != null ? `${escape(route.monedaCombustible)} ${escape(route.costoCombustible)}` : '\\u2014'}`).join('') || 'Sin rutas asignadas para los filtros seleccionados.';\nconst unassignedRows = payload.unassignedPdvs.map(route => `${escape(route.pdv_id)}${escape(route.pdv_nombre)}${escape(route.minimo_horas_semana)} h${escape(route.motivo)}${escape(route.recomendacion)}`).join('') || 'No hay PDVs sin asignar para los filtros seleccionados.';\nconst summary = payload.summary ?? {};const fuel = payload.fuel ?? {};\nconst html = `

Reporte de rutas

${escape(payload.project.name)}
${escape(payload.project.country)}${payload.project.description ? `
${escape(payload.project.description)}` : ''}
Generado: ${escape(payload.generatedAt)}

${filters ? `
Filtros aplicados:
${filters}
` : '

Filtros aplicados: Ninguno

'}
${escape(summary.totalPdvs)}PDVs totales
${escape(summary.assignedPdvs)}PDVs asignados
${escape(summary.unassignedPdvs)}PDVs sin asignar
${escape(summary.coveragePercent)}%Cobertura
${escape(summary.totalDistanceKm)} kmDistancia
${escape(summary.totalWorkHours)} hTrabajo
${fuel.configured ? `${escape(summary.fuelLiters)} L` : '\\u2014'}Combustible
${fuel.configured ? `${escape(summary.fuelCurrency)} ${escape(summary.fuelCost)}` : '\\u2014'}Costo combustible

Rutas asignadas

${routeRows}
UsuarioDíaOrdenPDVOrigenDistanciaTrabajoCosto comb.

PDVs sin asignar

${unassignedRows}
PDV IDPDVHoras/semanaMotivoRecomendación

Route Planner · GomezLee Marketing

`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [-100, 260], + "position": [ + -100, + 260 + ], "id": "9258c394-683f-4569-8ced-af2337ec8dba", "name": "Build Report HTML", "onError": "continueErrorOutput", @@ -65,7 +74,10 @@ }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, - "position": [180, 260], + "position": [ + 180, + 260 + ], "id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897", "name": "Convert HTML to PDF", "retryOnFail": true, @@ -91,7 +103,10 @@ }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [460, 200], + "position": [ + 460, + 200 + ], "id": "93928c3e-1e9c-4f77-a644-c156fae907a5", "name": "Respond PDF" }, @@ -105,7 +120,10 @@ }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [-100, 460], + "position": [ + -100, + 460 + ], "id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14", "name": "Respond Validation Error" }, @@ -119,16 +137,80 @@ }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [460, 430], + "position": [ + 460, + 430 + ], "id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc", "name": "Respond PDF Error" } ], "connections": { - "Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] }, - "Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] }, - "Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] }, - "Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] } + "Webhook: Generate PDF": { + "main": [ + [ + { + "node": "Validate Report Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Report Payload": { + "main": [ + [ + { + "node": "Build Report HTML", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond Validation Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Report HTML": { + "main": [ + [ + { + "node": "Convert HTML to PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond Validation Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Convert HTML to PDF": { + "main": [ + [ + { + "node": "Respond PDF", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond PDF Error", + "type": "main", + "index": 0 + } + ] + ] + } }, "active": false, "settings": { @@ -138,4 +220,4 @@ "templateCredsSetupCompleted": true }, "tags": [] -} +} \ No newline at end of file diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json index ab44024..30ee6dd 100644 --- a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -167,7 +167,7 @@ }, { "parameters": { - "jsCode": "const validationItems = $('Code in JavaScript').all();\nconst pdvResult = validationItems[0]?.json;\nconst userResult = validationItems[1]?.json;\nconst matrixResponses = $input.all();\nconst matrixRequests = $('Prepare ORS Matrix Requests').all();\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction normalizeRows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const text = String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, '');\n const parsed = Number(text);\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction restDays(value) {\n if (Array.isArray(value)) return value.map(String);\n try { const parsed = JSON.parse(String(value ?? '[]')); if (Array.isArray(parsed)) return parsed.map(String); } catch {}\n return String(value ?? '').split(',').map(day => day.trim()).filter(Boolean);\n}\n\nconst scheduleDays = {\n LUN_DOM_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'],\n LUN_SAB_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_SAB_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_VIE_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes']\n};\nconst segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n\nconst pdvs = normalizeRows(pdvResult.data).map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n nombre: String(pdv.nombre_del_pdv ?? ''),\n segmentacion: String(pdv.segmentacion ?? 'D').toUpperCase(),\n latitud: number(pdv.latitud), longitud: number(pdv.longitud),\n visitas: Math.max(1, Math.min(7, Math.round(number(pdv.visitas_semanales) || 1))),\n duracion: number(pdv.duracion_visitahoras ?? pdv.duracion_visita_horas ?? pdv.duracion_visita),\n prioridad: Math.max(1, Math.min(5, Math.round(number(pdv.prioridad) || 5))),\n activo: active(pdv.activo),\n pais: String(pdv.pais ?? pdv.country ?? ''), ciudad: String(pdv.ciudad ?? ''),\n estado: String(pdv.estado ?? ''), provincia: String(pdv.provincia ?? ''),\n region: String(pdv.region ?? pdv.regional ?? ''), zona: String(pdv.zona ?? ''),\n metro: String(pdv.metro_de_prueba ?? '')\n})).filter(pdv => pdv.activo && pdv.duracion > 0);\n\nconst users = normalizeRows(userResult.data).map((user, index) => {\n const jornada = String(user.jornada ?? 'LUN_VIE_40');\n const descansos = new Set(restDays(user.dias_descanso).map(day => header(day)));\n const days = (scheduleDays[jornada] ?? scheduleDays.LUN_VIE_40).filter(day => !descansos.has(header(day)));\n return {\n id: String(user.id ?? index + 1), nombre: String(user.nombre_del_empleado ?? ''),\n latitud: number(user.latitud), longitud: number(user.longitud),\n horasDia: number(user.horas_por_dia_usuario), horasSemana: number(user.horas_por_semana_usuario),\n descanso: Math.max(0, Math.round(number(user.horas_descanso_por_dia))),\n maxVisitas: Math.max(1, Math.round(number(user.visitas_maximas_por_dia) || 3)),\n jornada, days, activo: active(user.usuario_activo ?? user.usuarioactivo),\n pais: String(user.pais ?? user.country ?? ''), ciudad: String(user.ciudad ?? ''),\n estado: String(user.estado ?? ''), provincia: String(user.provincia ?? ''),\n region: String(user.region ?? user.regional ?? ''), zona: String(user.zona ?? '')\n };\n}).filter(user => user.activo && user.days.length && user.horasDia > user.descanso && user.horasSemana > 0);\n\nconst country = matrixRequests[0]?.json?.country ?? 'REPUBLICA DOMINICANA';\nfunction territory(pdv) {\n return { pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro };\n}\n\nconst road = new Map();\nfor (let i = 0; i < matrixResponses.length; i++) {\n const request = matrixRequests[i]?.json;\n const response = matrixResponses[i]?.json;\n const userIds = request?.userIds ?? (request?.userId ? [request.userId] : []);\n for (let u = 0; u < userIds.length; u++) {\n const distances = response?.distances?.[u] ?? [];\n const durations = response?.durations?.[u] ?? [];\n for (let p = 0; p < (request?.pdvIds?.length ?? 0); p++) {\n const distanceKm = Number(distances[p]);\n const durationSeconds = Number(durations[p]);\n if (Number.isFinite(distanceKm) && Number.isFinite(durationSeconds)) road.set(userIds[u] + '|' + request.pdvIds[p], { distanceKm, durationSeconds });\n }\n }\n}\n\nconst remaining = new Map(users.map(user => [user.id, user.horasSemana]));\nconst pending = new Set(pdvs.map(pdv => pdv.id));\nconst weeklyAssignments = [];\nconst noAssigned = [];\n\nconst candidatesByUser = new Map(users.map(user => {\n const candidates = pdvs.map(pdv => {\n const metric = road.get(user.id + '|' + pdv.id);\n if (!metric) return null;\n const workHours = pdv.duracion * pdv.visitas;\n const estimatedTravelHours = metric.durationSeconds / 3600 * pdv.visitas;\n return {\n pdv,\n metric,\n totalHours: workHours + estimatedTravelHours,\n score: (segmentWeight[pdv.segmentacion] ?? 0) + (6 - pdv.prioridad) * 6\n };\n }).filter(Boolean).sort((a, b) => a.metric.durationSeconds - b.metric.durationSeconds || a.metric.distanceKm - b.metric.distanceKm || b.score - a.score);\n return [user.id, candidates];\n}));\nconst candidateIndex = new Map(users.map(user => [user.id, 0]));\n\nwhile (pending.size) {\n const options = [];\n for (const user of users) {\n const candidates = candidatesByUser.get(user.id) ?? [];\n let index = candidateIndex.get(user.id) ?? 0;\n while (index < candidates.length) {\n const candidate = candidates[index];\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) {\n index++;\n continue;\n }\n options.push({ user, candidate });\n break;\n }\n candidateIndex.set(user.id, index);\n }\n options.sort((a, b) => b.candidate.metric.durationSeconds - a.candidate.metric.durationSeconds);\n if (!options.length) break;\n let assignedThisRound = false;\n for (const option of options) {\n const { user, candidate } = option;\n if (!pending.has(candidate.pdv.id) || candidate.totalHours > (remaining.get(user.id) ?? 0) + 0.001) continue;\n weeklyAssignments.push({ user, pdv: candidate.pdv, metric: candidate.metric });\n remaining.set(user.id, (remaining.get(user.id) ?? 0) - candidate.totalHours);\n pending.delete(candidate.pdv.id);\n candidateIndex.set(user.id, (candidateIndex.get(user.id) ?? 0) + 1);\n assignedThisRound = true;\n }\n if (!assignedThisRound) break;\n}\n\nfor (const pdv of pdvs.filter(item => pending.has(item.id))) noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'No hay empleado con capacidad semanal considerando el recorrido real' });\n\nconst daily = new Map();\nconst assigned = [];\nfunction dayState(userId, day) {\n const key = userId + '|' + day;\n if (!daily.has(key)) daily.set(key, { hours: 0, visits: 0 });\n return daily.get(key);\n}\nfunction preferredDays(days, count) {\n if (count >= days.length) return days.slice(0, count);\n return Array.from({ length: count }, (_, index) => days[Math.floor(index * days.length / count)]);\n}\n\nfor (const assignment of weeklyAssignments) {\n const { user, pdv, metric } = assignment;\n if (pdv.visitas > user.days.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Las visitas semanales exceden los días laborables disponibles' });\n continue;\n }\n const selected = [];\n const preferred = preferredDays(user.days, pdv.visitas);\n for (let visit = 0; visit < pdv.visitas; visit++) {\n const orderedDays = [preferred[visit], ...user.days.filter(day => day !== preferred[visit])];\n const day = orderedDays.find(candidate => {\n if (selected.includes(candidate)) return false;\n const state = dayState(user.id, candidate);\n return state.visits < user.maxVisitas && state.hours + pdv.duracion + metric.durationSeconds / 3600 <= user.horasDia - user.descanso + 0.001;\n });\n if (!day) { selected.length = 0; break; }\n selected.push(day);\n }\n if (!selected.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Excede capacidad diaria o visitas máximas considerando el recorrido real' });\n continue;\n }\n for (const day of selected) {\n const state = dayState(user.id, day);\n state.hours += pdv.duracion + metric.durationSeconds / 3600;\n state.visits++;\n assigned.push({ json: {\n usuario_id: user.id, nombre_usuario: user.nombre, latitud_usuario: user.latitud, longitud_usuario: user.longitud,\n pais_usuario: user.pais || country, ciudad_usuario: user.ciudad, estado_usuario: user.estado, provincia_usuario: user.provincia, region_usuario: user.region, zona_usuario: user.zona,\n hora_laboral_semanal_usuario: user.horasSemana, horas_por_dia_usuario: user.horasDia, horas_descanso_por_dia: user.descanso,\n jornada_codigo: user.jornada, pdv_id: pdv.id, nombre_pdv: pdv.nombre, latitud_pdv: pdv.latitud, longitud_pdv: pdv.longitud,\n pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro,\n hora_minima_semanal_pdv: pdv.duracion * pdv.visitas, dia: day, horas_trabajo: Number(pdv.duracion.toFixed(2)),\n horas_desplazamiento: Number((metric.durationSeconds / 3600).toFixed(4)), distancia: Number(metric.distanceKm.toFixed(2))\n } });\n }\n}\n\nconst groups = new Map();\nfor (const route of assigned) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n}\nfor (const routes of groups.values()) routes.sort((a, b) => a.json.horas_desplazamiento - b.json.horas_desplazamiento).forEach((route, index) => route.json.visit_order = index + 1);\n\nreturn [{ json: { assigned } }, { json: { noAssigned } }];" + "jsCode": "const validationItems = $('Code in JavaScript').all();\nconst pdvResult = validationItems[0]?.json;\nconst userResult = validationItems[1]?.json;\nconst matrixResponses = $input.all();\nconst matrixRequests = $('Prepare ORS Matrix Requests').all();\n\nfunction header(value) {\n return String(value).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\nfunction normalizeRows(data) {\n return data.map(item => Object.fromEntries(Object.entries(item.json).map(([key, value]) => [header(key), typeof value === 'string' ? value.trim() : value])));\n}\nfunction number(value) {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0;\n const text = String(value ?? '').trim().replace(',', '.').replace(/[^0-9.\\-]/g, '');\n const parsed = Number(text);\n return Number.isFinite(parsed) ? parsed : 0;\n}\nfunction active(value) {\n return !['n', 'no', '0', 'false'].includes(String(value ?? 'si').trim().toLowerCase());\n}\nfunction restDays(value) {\n if (Array.isArray(value)) return value.map(String);\n try { const parsed = JSON.parse(String(value ?? '[]')); if (Array.isArray(parsed)) return parsed.map(String); } catch {}\n return String(value ?? '').split(',').map(day => day.trim()).filter(Boolean);\n}\n\nconst scheduleDays = {\n LUN_DOM_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'],\n LUN_SAB_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_SAB_44: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'],\n LUN_VIE_40: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes']\n};\nconst segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n\nconst pdvs = normalizeRows(pdvResult.data).map((pdv, index) => ({\n id: String(pdv.id ?? index + 1),\n nombre: String(pdv.nombre_del_pdv ?? ''),\n segmentacion: String(pdv.segmentacion ?? 'D').toUpperCase(),\n latitud: number(pdv.latitud), longitud: number(pdv.longitud),\n visitas: Math.max(1, Math.min(7, Math.round(number(pdv.visitas_semanales) || 1))),\n duracion: number(pdv.duracion_visitahoras ?? pdv.duracion_visita_horas ?? pdv.duracion_visita),\n prioridad: Math.max(1, Math.min(5, Math.round(number(pdv.prioridad) || 5))),\n activo: active(pdv.activo),\n pais: String(pdv.pais ?? pdv.country ?? ''), ciudad: String(pdv.ciudad ?? ''),\n estado: String(pdv.estado ?? ''), provincia: String(pdv.provincia ?? ''),\n region: String(pdv.region ?? pdv.regional ?? ''), zona: String(pdv.zona ?? ''),\n metro: String(pdv.metro_de_prueba ?? '')\n})).filter(pdv => pdv.activo && pdv.duracion > 0);\n\nconst users = normalizeRows(userResult.data).map((user, index) => {\n const jornada = String(user.jornada ?? 'LUN_VIE_40');\n const descansos = new Set(restDays(user.dias_descanso).map(day => header(day)));\n const days = (scheduleDays[jornada] ?? scheduleDays.LUN_VIE_40).filter(day => !descansos.has(header(day)));\n return {\n id: String(user.id ?? index + 1), nombre: String(user.nombre_del_empleado ?? ''),\n latitud: number(user.latitud), longitud: number(user.longitud),\n horasDia: number(user.horas_por_dia_usuario), horasSemana: number(user.horas_por_semana_usuario),\n descanso: Math.max(0, Math.round(number(user.horas_descanso_por_dia))),\n maxVisitas: Math.max(1, Math.round(number(user.visitas_maximas_por_dia) || 3)),\n jornada, days, activo: active(user.usuario_activo ?? user.usuarioactivo),\n pais: String(user.pais ?? user.country ?? ''), ciudad: String(user.ciudad ?? ''),\n estado: String(user.estado ?? ''), provincia: String(user.provincia ?? ''),\n region: String(user.region ?? user.regional ?? ''), zona: String(user.zona ?? '')\n };\n}).filter(user => user.activo && user.days.length && user.horasDia > user.descanso && user.horasSemana > 0);\n\nconst country = matrixRequests[0]?.json?.country ?? 'REPUBLICA DOMINICANA';\nfunction territory(pdv) {\n return { pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro };\n}\n\nconst road = new Map();\nfor (let i = 0; i < matrixResponses.length; i++) {\n const request = matrixRequests[i]?.json;\n const response = matrixResponses[i]?.json;\n const userIds = request?.userIds ?? (request?.userId ? [request.userId] : []);\n for (let u = 0; u < userIds.length; u++) {\n const distances = response?.distances?.[u] ?? [];\n const durations = response?.durations?.[u] ?? [];\n for (let p = 0; p < (request?.pdvIds?.length ?? 0); p++) {\n const distanceKm = Number(distances[p]);\n const durationSeconds = Number(durations[p]);\n if (Number.isFinite(distanceKm) && Number.isFinite(durationSeconds)) road.set(userIds[u] + '|' + request.pdvIds[p], { distanceKm, durationSeconds });\n }\n }\n}\n\nconst remaining = new Map(users.map(user => [user.id, user.horasSemana]));\nconst pending = new Set(pdvs.map(pdv => pdv.id));\nconst weeklyAssignments = [];\nconst noAssigned = [];\n\n// Asignación por prioridad estricta: prioridad 1 primero; un nivel solo se\n// intenta cuando los superiores ya salieron o no caben en nadie. Empate:\n// segmentación (AA > A > B > C > D), luego carga semanal menor, luego cercanía.\nconst orderedPdvs = [...pdvs].sort((a, b) =>\n a.prioridad - b.prioridad ||\n (segmentWeight[b.segmentacion] ?? 0) - (segmentWeight[a.segmentacion] ?? 0) ||\n (a.duracion * a.visitas) - (b.duracion * b.visitas)\n);\n\nfor (const pdv of orderedPdvs) {\n const workHours = pdv.duracion * pdv.visitas;\n const options = [];\n for (const user of users) {\n const metric = road.get(user.id + '|' + pdv.id);\n if (!metric) continue;\n const estimatedTravelHours = metric.durationSeconds / 3600 * pdv.visitas;\n const totalHours = workHours + estimatedTravelHours;\n if (totalHours > (remaining.get(user.id) ?? 0) + 0.001) continue;\n options.push({ user, metric, totalHours });\n }\n if (!options.length) continue;\n options.sort((a, b) =>\n a.metric.durationSeconds - b.metric.durationSeconds ||\n a.metric.distanceKm - b.metric.distanceKm ||\n (remaining.get(b.user.id) ?? 0) - (remaining.get(a.user.id) ?? 0)\n );\n const best = options[0];\n weeklyAssignments.push({ user: best.user, pdv, metric: best.metric });\n remaining.set(best.user.id, (remaining.get(best.user.id) ?? 0) - best.totalHours);\n pending.delete(pdv.id);\n}\n\nfor (const pdv of pdvs.filter(item => pending.has(item.id))) noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'No hay empleado con capacidad semanal considerando el recorrido real' });\n\nconst daily = new Map();\nconst assigned = [];\nfunction dayState(userId, day) {\n const key = userId + '|' + day;\n if (!daily.has(key)) daily.set(key, { hours: 0, visits: 0 });\n return daily.get(key);\n}\nfunction preferredDays(days, count) {\n if (count >= days.length) return days.slice(0, count);\n return Array.from({ length: count }, (_, index) => days[Math.floor(index * days.length / count)]);\n}\n\nfor (const assignment of weeklyAssignments) {\n const { user, pdv, metric } = assignment;\n if (pdv.visitas > user.days.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Las visitas semanales exceden los días laborables disponibles' });\n continue;\n }\n const selected = [];\n const preferred = preferredDays(user.days, pdv.visitas);\n for (let visit = 0; visit < pdv.visitas; visit++) {\n const orderedDays = [preferred[visit], ...user.days.filter(day => day !== preferred[visit])];\n const day = orderedDays.find(candidate => {\n if (selected.includes(candidate)) return false;\n const state = dayState(user.id, candidate);\n return state.visits < user.maxVisitas && state.hours + pdv.duracion + metric.durationSeconds / 3600 <= user.horasDia - user.descanso + 0.001;\n });\n if (!day) { selected.length = 0; break; }\n selected.push(day);\n }\n if (!selected.length) {\n noAssigned.push({ pdv_id: pdv.id, pdv_nombre: pdv.nombre, minimo_horas_semana: pdv.duracion * pdv.visitas, latitud: pdv.latitud, longitud: pdv.longitud, ...territory(pdv), motivo: 'Excede capacidad diaria o visitas máximas considerando el recorrido real' });\n continue;\n }\n for (const day of selected) {\n const state = dayState(user.id, day);\n state.hours += pdv.duracion + metric.durationSeconds / 3600;\n state.visits++;\n assigned.push({ json: {\n usuario_id: user.id, nombre_usuario: user.nombre, latitud_usuario: user.latitud, longitud_usuario: user.longitud,\n pais_usuario: user.pais || country, ciudad_usuario: user.ciudad, estado_usuario: user.estado, provincia_usuario: user.provincia, region_usuario: user.region, zona_usuario: user.zona,\n hora_laboral_semanal_usuario: user.horasSemana, horas_por_dia_usuario: user.horasDia, horas_descanso_por_dia: user.descanso,\n jornada_codigo: user.jornada, pdv_id: pdv.id, nombre_pdv: pdv.nombre, latitud_pdv: pdv.latitud, longitud_pdv: pdv.longitud,\n pais: pdv.pais || country, ciudad: pdv.ciudad, estado: pdv.estado, provincia: pdv.provincia, region: pdv.region, zona: pdv.zona, metro: pdv.metro,\n hora_minima_semanal_pdv: pdv.duracion * pdv.visitas, dia: day, horas_trabajo: Number(pdv.duracion.toFixed(2)),\n prioridad: pdv.prioridad, segmentacion: pdv.segmentacion,\n horas_desplazamiento: Number((metric.durationSeconds / 3600).toFixed(4)), distancia: Number(metric.distanceKm.toFixed(2))\n } });\n }\n}\n\nconst groups = new Map();\nfor (const route of assigned) {\n const key = route.json.usuario_id + '|' + route.json.dia;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push(route);\n}\nfor (const routes of groups.values()) routes.sort((a, b) => a.json.horas_desplazamiento - b.json.horas_desplazamiento).forEach((route, index) => route.json.visit_order = index + 1);\n\nreturn [{ json: { assigned } }, { json: { noAssigned } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -636,4 +636,4 @@ }, "id": "IkKMqgb7uZuRk9d9", "tags": [] -} +} \ No newline at end of file diff --git a/route-planner/angular.json b/route-planner/angular.json index 76c3c59..53aa712 100644 --- a/route-planner/angular.json +++ b/route-planner/angular.json @@ -30,7 +30,7 @@ "production": { "budgets": [ { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, - { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" } + { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "15kB" } ], "outputHashing": "all", "fileReplacements": [ diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index b13cf52..453d278 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -19,7 +19,7 @@ export const routes: Routes = [ { path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, { path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] }, { path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] }, - { path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, - { path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, + { path: 'home/assigned-route', redirectTo: 'home', pathMatch: 'full' }, + { path: 'home/mapa', redirectTo: 'home', pathMatch: 'full' }, { path: '**', redirectTo: 'login' } ]; diff --git a/route-planner/src/app/core/models/project.model.ts b/route-planner/src/app/core/models/project.model.ts index 95e79ce..97791f4 100644 --- a/route-planner/src/app/core/models/project.model.ts +++ b/route-planner/src/app/core/models/project.model.ts @@ -3,6 +3,8 @@ export interface RuteroProyecto { nombre: string; descripcion: string | null; pais: string | null; + rendimiento_km_l: number | null; + precio_por_litro: number | null; propietario_id: string; estado: 'optimizado' | 'archivado' | null; version: number; diff --git a/route-planner/src/app/core/models/route.model.ts b/route-planner/src/app/core/models/route.model.ts index 03cff79..ed4b121 100644 --- a/route-planner/src/app/core/models/route.model.ts +++ b/route-planner/src/app/core/models/route.model.ts @@ -27,6 +27,8 @@ export interface RouteAssignedJson { usuario_id: string; pdv_id: number; visit_order?: number; + prioridad?: number; + segmentacion?: string; geojson?: unknown; horas_por_dia_usuario?: number; horas_descanso_por_dia?: number; diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index 0f95cf2..1e4c0b0 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -201,9 +201,17 @@ export class RouteState { const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values()); if (profiles.length < 2) return; + const segmentWeight: Record = { AA: 100, A: 80, B: 60, C: 40, D: 20 }; + const priorityOf = (route: (typeof current)[number]): number => { + const value = Number(route.json.prioridad); + return Number.isFinite(value) ? value : 5; + }; + const segmentOf = (route: (typeof current)[number]): number => + segmentWeight[String(route.json.segmentacion ?? 'D').toUpperCase()] ?? 20; const load = new Map(profiles.map(profile => [String(profile.json.usuario_id), 0])); const optimized = [...current] - .sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv)) + .sort((a, b) => priorityOf(a) - priorityOf(b) || segmentOf(b) - segmentOf(a) || + 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 diff --git a/route-planner/src/app/core/services/ors.service.ts b/route-planner/src/app/core/services/ors.service.ts index 79c8296..8778695 100644 --- a/route-planner/src/app/core/services/ors.service.ts +++ b/route-planner/src/app/core/services/ors.service.ts @@ -24,6 +24,7 @@ const MAX_CONCURRENT_REQUESTS = 2; export class OrsService { private geoCache = new Map(); + private failedCache = new Map(); constructor(private http: HttpClient) { } @@ -37,7 +38,8 @@ export class OrsService { isJson = false ) { const body = { - coordinates: [origin, destination] + coordinates: [origin, destination], + radiuses: [1000, 1000] }; const baseUrl = this.isMexico(origin) || this.isMexico(destination) ? environment.orsMexicoBaseUrl @@ -57,6 +59,14 @@ export class OrsService { return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86; } + private orsErrorMessage(error: unknown): string { + const err = error as { message?: string; error?: { error?: { message?: string; code?: number }; message?: string } }; + const serverMessage = err?.error?.error?.message ?? err?.error?.message; + if (serverMessage) return String(serverMessage); + if (err?.message) return String(err.message); + return 'Error ORS'; + } + async processPairs( pares: { pdv_id: number; @@ -99,6 +109,17 @@ export class OrsService { }) ); } + if (isJson && this.failedCache.has(key)) { + const errOut = { pdv_id: pair.pdv_id, error: this.failedCache.get(key) ?? 'Sin conexión vial (ORS).' }; + return of(errOut).pipe( + map(data => { + completed++; + progressSubject.next({ completed, total: pares.length, item: data as unknown }); + if (completed === pares.length) progressSubject.complete(); + return data as unknown; + }) + ); + } return this.fetchRoute(pair.origin, pair.destination, isJson).pipe( delay(300), map(data => { @@ -124,9 +145,11 @@ export class OrsService { } }), catchError(error => { + const message = this.orsErrorMessage(error); + if (isJson) this.failedCache.set(key, message); const errOut = { pdv_id: pair.pdv_id, - error: (error as Error).message || 'Error ORS' + error: message }; completed++; progressSubject.next({ completed, total: pares.length, item: errOut }); @@ -151,5 +174,6 @@ export class OrsService { clearCache() { this.geoCache.clear(); + this.failedCache.clear(); } } diff --git a/route-planner/src/app/core/services/project.service.ts b/route-planner/src/app/core/services/project.service.ts index 05f36b1..3c89866 100644 --- a/route-planner/src/app/core/services/project.service.ts +++ b/route-planner/src/app/core/services/project.service.ts @@ -17,7 +17,7 @@ export class ProjectService { return data as RuteroProyecto; } - async update(id: string, changes: Partial>): Promise { + async update(id: string, changes: Partial>): Promise { const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id); if (error) throw error; } @@ -44,6 +44,8 @@ export class ProjectService { nombre: nombre.trim(), descripcion: descripcion.trim() || null, pais: pais.trim(), + rendimiento_km_l: null, + precio_por_litro: null, propietario_id: ownerId, estado: null, version: 1, diff --git a/route-planner/src/app/private/employees/employees.component.css b/route-planner/src/app/private/employees/employees.component.css index 1f26c83..fa07d29 100644 --- a/route-planner/src/app/private/employees/employees.component.css +++ b/route-planner/src/app/private/employees/employees.component.css @@ -18,6 +18,6 @@ .employee-form-grid input::placeholder { color: #71808a; opacity: 1; } .employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; } .form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; } -.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; } -.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; } +.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); font-size: .85rem; font-weight: 600; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; } +.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { position:relative; z-index:30; display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font-size:.85rem; font-weight:600; text-transform:none; }.bulk-actions select { min-width:200px; }.bulk-actions select option,.employee-form-grid select option,.editing-row select option { color:var(--glm-gris-oscuro); background:#fff; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; } @media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } } diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts index 53fb747..bbba6af 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts @@ -5,7 +5,9 @@ import { Subscription } from 'rxjs'; import * as L from 'leaflet'; import { RouteState } from '../../../core/route-state/route'; import { OrsService } from '../../../core/services/ors.service'; +import { ProjectService } from '../../../core/services/project.service'; import { RouteAssignedResponse } from '../../../core/models/route.model'; +import { centerForCountry } from '../../../shared/utils/country-center.utils'; import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; import { colorForUsuario } from '../../../shared/utils/palette.utils'; @@ -45,6 +47,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie private activatedRoute: ActivatedRoute, private routeState: RouteState, private orsService: OrsService, + private projects: ProjectService, private cdr: ChangeDetectorRef ) {} @@ -117,7 +120,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie if (territoryBounds?.isValid()) { this.fitRouteBounds(territoryBounds); } else { - this.map.setView([18.4861, -69.9312], 13); + const projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? ''; + void this.projects.get(projectId).then(project => { + const center = centerForCountry(project?.pais ?? ''); + this.map.setView([center.lat, center.lng], 10); + }).catch(() => { + this.map.setView([19.4326, -99.1332], 5); + }); } const pdvPane = this.map.createPane('pdvPane'); pdvPane.style.zIndex = '650'; diff --git a/route-planner/src/app/private/home/home.component.css b/route-planner/src/app/private/home/home.component.css index 0a0e79c..dbd8384 100644 --- a/route-planner/src/app/private/home/home.component.css +++ b/route-planner/src/app/private/home/home.component.css @@ -36,9 +36,86 @@ .projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; } .home-container { - max-width: 1000px; + max-width: 1600px; margin: 0 auto; - padding: 24px; + padding: 20px 24px 32px; +} + +.planning-toolbar-card { + padding: 16px 20px; +} + +.planning-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; +} + +.toolbar-summary { + margin: 0; + flex: 1 1 260px; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; +} + +.planning-toolbar-card .upload-error { + margin-top: 12px; +} + +.fuel-config { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } +.fuel-config label { display: grid; gap: 5px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; } +.fuel-config input { border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; padding: 8px; width: 180px; } +.fuel-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .82rem; } + +.dashboard-map-card { + padding: 20px; +} + +.dashboard-map-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 16px; + margin-bottom: 12px; +} + +.dashboard-map-header .section-title { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.dashboard-map-hint { + margin: 0; + color: var(--glm-acero); + font-size: 0.85rem; +} + +.dashboard-map-panel { + position: relative; + min-height: 480px; +} + +.dashboard-map-panel app-route-overview-map { + display: block; + position: relative; + height: 620px; +} + +@media (max-width: 1100px) { + .home-container { + padding: 16px; + } + .dashboard-map-panel app-route-overview-map { + height: auto; + } } .home-header { @@ -104,103 +181,8 @@ letter-spacing: 0.3px; } -/* Upload card */ -.upload-card { - padding: 24px; -} - .employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; } -.upload-grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - margin-bottom: 20px; -} - -@media (min-width: 768px) { - .upload-grid { - grid-template-columns: 1fr 1fr; - } -} - -.file-input-wrapper { - display: flex; - flex-direction: column; - gap: 8px; -} - -.file-label { - display: flex; - flex-direction: column; - gap: 4px; - padding: 16px; - border: 2px dashed var(--glm-gris-medio); - border-radius: 4px; - background: var(--glm-verde-bg); - cursor: pointer; - transition: border-color 0.2s, background 0.2s; -} - -.file-label:hover { - border-color: var(--glm-verde); - background: #e5f5dc; -} - -.file-label:has(.file-input:focus-visible) { - border-color: var(--glm-verde); - box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.2); -} - -.file-label i { - font-size: 2rem; - color: var(--glm-verde); -} - -.file-label span:first-of-type { - font-weight: 600; - color: var(--glm-azul); - font-size: 14px; -} - -.file-hint { - font-size: 0.8rem; - color: var(--glm-acero); -} - -.file-input { - position: absolute; - width: 0.1px; - height: 0.1px; - opacity: 0; - overflow: hidden; - z-index: -1; -} - -.file-name { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - background: var(--glm-verde-bg); - border: 1px solid var(--glm-verde-claro); - border-radius: 4px; - color: var(--glm-gris-oscuro); - font-size: 0.9rem; -} - -.file-name i { - color: var(--glm-verde); -} - -.upload-actions { - display: flex; - flex-direction: column; - gap: 12px; - padding-top: 16px; - border-top: 1px solid var(--glm-gris-medio); -} - .upload-error { display: flex; align-items: center; @@ -265,89 +247,6 @@ cursor: not-allowed; } -.file-label.has-error { - border-color: #F5A6A0; - background: var(--glm-rojo-ok); -} - -.file-label.has-success { - border-color: var(--glm-verde); - background: var(--glm-verde-bg); -} - -.preview-card { - padding: 12px; - border-radius: 4px; - border: 1px solid var(--glm-gris-medio); - background: var(--glm-surface); - font-size: 0.85rem; -} - -.preview-success { - border-color: var(--glm-verde-claro); - background: var(--glm-verde-bg); -} - -.preview-success .preview-header { - color: var(--glm-verde); - font-weight: 700; - display: flex; - align-items: center; - gap: 6px; -} - -.preview-error { - border-color: #F5A6A0; - background: var(--glm-rojo-ok); -} - -.preview-error .preview-header { - color: #D32F2F; - font-weight: 700; - display: flex; - align-items: center; - gap: 6px; -} - -.preview-stats { - margin-top: 6px; - color: var(--glm-acero); - font-size: 0.8rem; -} - -.preview-errors { - margin: 8px 0 0; - padding: 0 0 0 18px; - color: var(--glm-gris-oscuro); - font-size: 0.82rem; -} - -.preview-errors li { - margin-bottom: 4px; -} - -.error-value { - color: var(--glm-acero); - font-style: italic; -} - -.more-errors { - color: var(--glm-acero); - font-style: italic; -} - -.preview-loading { - color: var(--glm-acero); - font-size: 0.8rem; -} - -.preview-loading-row { - display: flex; - align-items: center; - gap: 8px; - color: var(--glm-acero); -} - .params-card { margin-top: 16px; padding: 14px; diff --git a/route-planner/src/app/private/home/home.component.html b/route-planner/src/app/private/home/home.component.html index 3cc0606..80e49c3 100644 --- a/route-planner/src/app/private/home/home.component.html +++ b/route-planner/src/app/private/home/home.component.html @@ -8,76 +8,23 @@ Mis proyectos
-

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

+

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

Combustible: {{ fuelRendimiento ?? '—' }} km/L · {{ fuelPrecio ?? '—' }}/L

-
- -
-

Cargando empleados activos...{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.

- -
-
- - -
- - {{ pdvFile.name }} - Validando... -
- -
-
Analizando archivo PDV...
-
- -
-
PDV válido — {{ pdvPreview!.totalRows }} filas
-
{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}
-
- -
-
Errores en PDV ({{ pdvPreview.errors.length }})
-
Faltan: {{ pdvPreview.missingHeaders.join(', ') }}
-
{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas
-
    -
  • - Fila {{ e.row }} — {{ e.field }}: {{ e.message }} - ({{ e.value }}) -
  • -
  • + {{ pdvPreview.errors.length - 8 }} errores más...
  • -
-
-
- -
- -
-
- - {{ uploadError }} -
-
+
+
+

Cargando empleados activos...{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.

+
- {{ submitDisabledReason }}
+
+ + {{ uploadError }}
+ {{ submitHint }}
- -
@@ -112,6 +60,16 @@
+
+
+

Mapa general y detalle de ruta

+

Selecciona una ruta en el mapa o en el balance para ver su detalle sin cambiar de pantalla.

+
+
+ +
+
+
diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index b5286a8..4b108d3 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -8,6 +8,7 @@ import { ApiResponse } from '../../core/models/route.model'; import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; import { Subscription } from 'rxjs'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; +import { RouteOverviewMapComponent } from './route-overview-map/route-overview-map.component'; import { PlanningInputService } from '../../core/services/planning-input.service'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { EmployeeService } from '../../core/services/employee.service'; @@ -19,17 +20,14 @@ import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../.. @Component({ standalone: true, selector: 'app-home', - imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent], + imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent, RouteOverviewMapComponent], templateUrl: './home.component.html', styleUrls: ['./home.component.css'] }) export class HomeComponent implements OnInit { pdvFile: File | null = null; - usuariosFile: File | null = null; pdvPreview: PreviewResult | null = null; - usuariosPreview: PreviewResult | null = null; pdvPreviewLoading = false; - usuariosPreviewLoading = false; activeEmployees = 0; employeesLoading = true; uploadError: string | null = null; @@ -40,12 +38,35 @@ export class HomeComponent implements OnInit { projectDescription = ''; projectCountry = ''; projectSaving = false; - inputsExpanded = true; + fuelRendimiento: number | null = null; + fuelPrecio: number | null = null; filtersExpanded = true; - unassignedExpanded = false; + unassignedExpanded = true; planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; + submitDisabled = true; + submitHint: string | null = 'Sube el archivo PDV'; private calcSub: Subscription | null = null; + private safeDetectChanges(): void { + setTimeout(() => { + try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ } + }); + } + + private updateSubmitState(): void { + this.submitDisabled = !this.canSubmit; + this.submitHint = this.computeSubmitHint(); + } + + private computeSubmitHint(): string | null { + if (!this.pdvFile) return 'Sube el archivo PDV'; + if (this.employeesLoading) return 'Cargando empleados...'; + if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.'; + if (this.pdvPreviewLoading) return 'Validando archivo PDV...'; + if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV'; + return null; + } + constructor( private rutasService: RutasService, private routeState: RouteState, @@ -82,13 +103,15 @@ export class HomeComponent implements OnInit { this.projectName = project.nombre; this.projectDescription = project.descripcion ?? ''; this.projectCountry = input.country; + this.fuelRendimiento = project.rendimiento_km_l ?? null; + this.fuelPrecio = project.precio_por_litro ?? null; this.planningFilter.setPdvs(input.rows); this.pdvFile = input.file; this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV'); } catch (error: any) { this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.'; } finally { - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); } } @@ -98,9 +121,22 @@ export class HomeComponent implements OnInit { async saveProject(): Promise { if (!this.projectId || !this.projectName.trim()) return; + if (this.fuelRendimiento != null && !(this.fuelRendimiento > 0)) { + this.uploadError = 'El rendimiento debe ser mayor a 0 km/L.'; + return; + } + if (this.fuelPrecio != null && !(this.fuelPrecio >= 0)) { + this.uploadError = 'El precio por litro no puede ser negativo.'; + return; + } this.projectSaving = true; try { - await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null }); + await this.projectsService.update(this.projectId, { + nombre: this.projectName.trim(), + descripcion: this.projectDescription.trim() || null, + rendimiento_km_l: this.fuelRendimiento, + precio_por_litro: this.fuelPrecio + }); this.editingProject = false; } catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; } finally { this.projectSaving = false; this.cdr.detectChanges(); } @@ -111,7 +147,6 @@ export class HomeComponent implements OnInit { filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); } applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); } clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); } - toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; } toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; } toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; } @@ -123,104 +158,10 @@ export class HomeComponent implements OnInit { this.activeEmployees = 0; } finally { this.employeesLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); } } - async onPdvFileChange(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - const basicError = this.validateFileBasic(file, 'PDV'); - if (basicError) { - this.pdvFile = null; - this.pdvPreview = null; - this.uploadError = basicError; - return; - } - this.pdvFile = file; - if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile); - this.uploadError = null; - this.pdvPreviewLoading = true; - this.pdvPreview = null; - this.cdr.detectChanges(); - try { - this.pdvPreview = await this.previewService.previewFile(file, 'PDV'); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV'; - this.pdvPreview = { - fileName: file.name, - type: 'PDV', - valid: false, - totalRows: 0, - validRows: 0, - headers: [], - normalizedHeaders: [], - missingHeaders: [], - errors: [{ row: 1, field: 'file', message: msg }], - duplicateIds: [], - sampleValidRows: [], - }; - } finally { - this.pdvPreviewLoading = false; - this.cdr.detectChanges(); - } - } - } - - async onUsuariosFileChange(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - const basicError = this.validateFileBasic(file, 'Usuarios'); - if (basicError) { - this.usuariosFile = null; - this.usuariosPreview = null; - this.uploadError = basicError; - return; - } - this.usuariosFile = file; - if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file); - this.uploadError = null; - this.usuariosPreviewLoading = true; - this.usuariosPreview = null; - this.cdr.detectChanges(); - try { - this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios'); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios'; - this.usuariosPreview = { - fileName: file.name, - type: 'Usuarios', - valid: false, - totalRows: 0, - validRows: 0, - headers: [], - normalizedHeaders: [], - missingHeaders: [], - errors: [{ row: 1, field: 'file', message: msg }], - duplicateIds: [], - sampleValidRows: [], - }; - } finally { - this.usuariosPreviewLoading = false; - this.cdr.detectChanges(); - } - } - } - - private validateFileBasic(file: File, type: string): string | null { - const validExtensions = ['.xlsx', '.xls']; - const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); - if (!validExtensions.includes(ext)) { - return `El archivo ${type} debe ser .xlsx o .xls`; - } - if (file.size > 10 * 1024 * 1024) { - return `El archivo ${type} supera 10MB`; - } - return null; - } - get canSubmit(): boolean { if (!this.pdvFile || !this.activeEmployees) return false; if (this.pdvPreviewLoading || this.employeesLoading) return false; @@ -254,7 +195,7 @@ export class HomeComponent implements OnInit { this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.'; this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } let employeesFile: File; @@ -282,7 +223,7 @@ export class HomeComponent implements OnInit { this.uploadError = String((anyRes as { error: unknown }).error); this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } const payload = Array.isArray(res) @@ -294,7 +235,7 @@ export class HomeComponent implements OnInit { this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.'; this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined; @@ -303,7 +244,7 @@ export class HomeComponent implements OnInit { this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.'; this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); return; } if (assigned) { @@ -314,13 +255,13 @@ export class HomeComponent implements OnInit { } this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); }, error: (err) => { this.uploadError = this.mapBackendError(err); this.routeState.setLoading(false); this.isLoading = false; - this.cdr.detectChanges(); + this.updateSubmitState(); this.safeDetectChanges(); } }); } @@ -347,17 +288,12 @@ export class HomeComponent implements OnInit { this.planningInput.clear(this.projectId); this.routeState.clearAll(); this.pdvFile = null; - this.usuariosFile = null; this.pdvPreview = null; - this.usuariosPreview = null; this.pdvPreviewLoading = false; - this.usuariosPreviewLoading = false; this.uploadError = null; this.isLoading = false; this.routeState.setLoading(false); - const pdvInput = document.getElementById('pdvFile') as HTMLInputElement; - const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement; - if (pdvInput) pdvInput.value = ''; - if (usuariosInput) usuariosInput.value = ''; + this.updateSubmitState(); + this.safeDetectChanges(); } } diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts index f16a89a..e0c3db8 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts @@ -27,6 +27,19 @@ export class NoAssignedRouteComponent implements OnDestroy { assignmentDays: Record = {}; assigningId: number | null = null; actionError: string | null = null; + userOptions: { id: string; name: string }[] = []; + dayOptions: string[] = []; + + private safeDetectChanges(): void { + setTimeout(() => { + try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ } + }); + } + + private refreshOptions(): void { + this.userOptions = this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); + this.dayOptions = this.routesState.getDiasUnicos(); + } constructor( private routesState: RouteState, @@ -36,22 +49,24 @@ export class NoAssignedRouteComponent implements OnDestroy { ) {} ngOnInit() { + this.refreshOptions(); this.subs.push( this.routesState.loading$.subscribe(value => { this.isLoading = value; - this.cdr.detectChanges(); + this.safeDetectChanges(); }) ); this.subs.push( this.routesState.noAssignedRoutes$.subscribe(data => { this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) })); this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes); - this.cdr.detectChanges(); + this.refreshOptions(); + this.safeDetectChanges(); }) ); this.subs.push(this.filters.filters$.subscribe(() => { this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes); - this.cdr.detectChanges(); + this.safeDetectChanges(); })); this.cols = [ { field: 'pdv_id', header: 'PDV ID' }, @@ -64,13 +79,7 @@ export class NoAssignedRouteComponent implements OnDestroy { ]; } - get userOptions(): { id: string; name: string }[] { - return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); - } - get dayOptions(): string[] { - return this.routesState.getDiasUnicos(); - } toggleDay(pdvId: number, day: string, checked: boolean): void { const key = String(pdvId); @@ -113,7 +122,8 @@ export class NoAssignedRouteComponent implements OnDestroy { this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; } finally { this.assigningId = null; - this.cdr.detectChanges(); + this.refreshOptions(); + this.safeDetectChanges(); } } diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css index 6492f8a..846cc10 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css @@ -88,6 +88,7 @@ .kpi-warning { border-left-color: var(--glm-naranja); } .kpi-blue { border-left-color: var(--glm-acero); } .kpi-orange { border-left-color: var(--glm-naranja); } +.kpi-fuel { border-left-color: var(--glm-verde); } .kpi-icon { display: grid; diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html index 12f7ba5..02cd8fd 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html @@ -23,9 +23,9 @@ class="export-button" *ngIf="hasData" (click)="downloadRoutes()" - [disabled]="pdfLoading" + [disabled]="exportLoading" > - {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }} {{ exportLoading ? "Generando Excel..." : "Descargar rutas" }}
-
- {{ reoptError || editError || pdfError }} +
+ {{ reoptError || editError || exportError }}
@@ -97,6 +97,17 @@ >
+
+
+
+ Combustible{{ summary.fuelLiters | number: "1.0-1" }} L—{{ fuelCurrency }} {{ summary.fuelCost | number: "1.2-2" }}Sin configurar en el proyecto +
+
@@ -119,6 +130,7 @@ Visitas Distancia Trabajo + Costo comb. Carga @@ -142,6 +154,8 @@ {{ load.visits }} {{ load.distance | number: "1.0-1" }} km {{ load.workHours | number: "1.0-1" }} h + {{ fuelCurrency }} {{ load.fuelCost | number: "1.2-2" }} + —
- + diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts index e7f3cd8..95b097c 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts @@ -4,17 +4,19 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { RouteState } from '../../../core/route-state/route'; -import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.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 { PlanningFilterService } from '../../../core/services/planning-filter.service'; import { RutasService } from '../../../core/services/router.service'; import { PlanningInputService } from '../../../core/services/planning-input.service'; +import { ProjectService } from '../../../core/services/project.service'; +import { currencyForCountry, fuelCost, isFuelConfigured, type FuelConfig } from '../../../shared/utils/fuel-estimation.utils'; -interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; } +interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; fuelLiters: number; fuelCost: number; } interface ReasonSummary { reason: string; count: number; } interface UserLoad { - id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; + id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; fuelLiters: number; fuelCost: number; capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string; } @@ -35,44 +37,64 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { reoptLoading = false; reoptError = ''; hasLocalChanges = false; - pdfLoading = false; - pdfError = ''; + exportLoading = false; + exportError = ''; readonly userLoadPageSize = 14; userLoadPage = 1; + fuelConfig: FuelConfig = { rendimientoKmL: null, precioPorLitro: null }; + fuelCurrency = '$'; + fuelConfigured = false; private readonly subs: Subscription[] = []; constructor( private routeState: RouteState, private filters: PlanningFilterService, - private pdfReport: RoutePdfReportService, + private excelExport: PlanningExportService, private rutasService: RutasService, private planningInput: PlanningInputService, + private projects: ProjectService, private router: Router, private activatedRoute: ActivatedRoute, private cdr: ChangeDetectorRef ) {} ngOnInit(): void { + void this.loadFuelConfig(); this.subs.push( this.routeState.assignedRoutes$.subscribe(() => this.refresh()), this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()), this.filters.filters$.subscribe(() => this.refresh()) ); } + + private async loadFuelConfig(): Promise { + try { + const project = await this.projects.get(this.projectId); + this.fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null }; + this.fuelCurrency = currencyForCountry(project.pais); + } catch { + this.fuelConfig = { rendimientoKmL: null, precioPorLitro: null }; + this.fuelCurrency = '$'; + } finally { + this.fuelConfigured = isFuelConfigured(this.fuelConfig); + this.refresh(); + this.cdr.detectChanges(); + } + } ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); } barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); } utilizationClass(load: UserLoad): string { return load.status; } async downloadRoutes(): Promise { - if (this.pdfLoading || !this.hasData) return; - this.pdfLoading = true; - this.pdfError = ''; + if (this.exportLoading || !this.hasData) return; + this.exportLoading = true; + this.exportError = ''; try { - await this.pdfReport.download(this.projectId); + await this.excelExport.export(this.projectId); } catch (error: any) { - this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.'; + this.exportError = error?.message ?? 'No se pudo generar el Excel de rutas.'; } finally { - this.pdfLoading = false; + this.exportLoading = false; this.cdr.detectChanges(); } } @@ -138,7 +160,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { const loads = new Map(); for (const route of assigned) { const id = String(route.json.usuario_id); - const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' }; + const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, fuelLiters: 0, fuelCost: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' }; load.routes.push(route); load.visits++; loads.set(id, load); } @@ -148,13 +170,18 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { 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); + const fuel = fuelCost(load.distance, this.fuelConfig); + load.fuelLiters = fuel.liters; + load.fuelCost = fuel.cost; 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.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`; return load; }).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours); this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages); - this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours }; + const fuelTotal = fuelCost(totalDistance, this.fuelConfig); + this.fuelConfigured = isFuelConfigured(this.fuelConfig); + this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours, fuelLiters: fuelTotal.liters, fuelCost: fuelTotal.cost }; const reasonByPdv = new Map(); for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado'); const counts = new Map(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1); @@ -162,5 +189,5 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { this.hasData = totalPdvs > 0; } private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; } - private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; } + private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0, fuelLiters: 0, fuelCost: 0 }; } } diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css index 97d0ae3..e4b1880 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css @@ -115,6 +115,41 @@ box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15); } +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0; + overflow: hidden; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + background: #fff; +} + +.segmented-control button { + padding: 8px 4px; + border: 0; + border-right: 1px solid var(--glm-gris-medio); + background: #fff; + color: var(--glm-acero); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 700; +} + +.segmented-control button:last-child { + border-right: 0; +} + +.segmented-control button:hover { + background: var(--glm-verde-bg); +} + +.segmented-control button.active { + background: var(--glm-verde); + color: #fff; +} + .filter-stats { display: flex; gap: 10px; @@ -310,6 +345,13 @@ text-transform: uppercase; } +.visit-group-title { + margin: 8px 0 4px; + color: var(--glm-azul); + font-size: 0.76rem; + font-weight: 700; +} + .reassign-form { display: grid; grid-template-columns: 1fr 1fr; @@ -506,3 +548,62 @@ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); font-family: Arial, sans-serif; } + +:host.embedded { + height: 620px; + min-height: 480px; + overflow: hidden; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + background: #fff; +} + +:host.embedded #map-overview { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 340px; + height: 100%; +} + +:host.embedded .overview-sidebar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 340px; + z-index: 500; +} + +:host.embedded .unassigned-bottom-drawer { + position: absolute; + left: 352px; + right: 12px; + bottom: 12px; + z-index: 600; +} + +@media (max-width: 1100px) { + :host.embedded { + height: auto; + min-height: 0; + } + :host.embedded #map-overview { + position: relative; + left: 0; + height: 420px; + } + :host.embedded .overview-sidebar { + position: relative; + width: 100%; + max-height: none; + } + :host.embedded .unassigned-bottom-drawer { + position: relative; + left: 0; + right: 0; + bottom: 0; + margin: 0 12px 12px; + } +} diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html index 129d7d1..546989a 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html @@ -6,12 +6,12 @@
-
IDNombreSegmentaciónCiudadEstadoZonaAcciones
{{ pdv.pdv_id }}{{ value(pdv, 'nombre_del_pdv') }}{{ value(pdv, 'segmentacion') }}{{ value(pdv, 'ciudad') }}{{ value(pdv, 'estado') }}{{ value(pdv, 'zona') }}
No hay PDVs registrados.
diff --git a/route-planner/src/app/private/pdvs/pdvs.component.ts b/route-planner/src/app/private/pdvs/pdvs.component.ts index 306557b..a1b6d43 100644 --- a/route-planner/src/app/private/pdvs/pdvs.component.ts +++ b/route-planner/src/app/private/pdvs/pdvs.component.ts @@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import * as XLSX from 'xlsx'; import { ProjectService } from '../../core/services/project.service'; import { RuteroProyectoPdv } from '../../core/models/project.model'; -import { ExcelPreviewService } from '../../shared/services/excel-preview.service'; +import { ExcelPreviewService, FieldError } from '../../shared/services/excel-preview.service'; import { normalizeRow } from '../../shared/utils/header-normalization.utils'; import { AuthService } from '../../core/services/auth.service'; import { UserProfileService } from '../../core/services/user-profile.service'; @@ -44,6 +44,8 @@ export class PdvsComponent implements OnInit { importing = false; error = ''; message = ''; + importErrors: FieldError[] = []; + importMissingHeaders: string[] = []; projectCountry = 'Republica Dominicana'; selectedIds = new Set(); bulkSaving = false; @@ -115,10 +117,14 @@ export class PdvsComponent implements OnInit { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return; - this.importing = true; this.error = ''; this.message = ''; + this.importing = true; this.error = ''; this.message = ''; this.importErrors = []; this.importMissingHeaders = []; try { const result = await this.preview.previewFile(file, 'PDV'); - if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.'); + if (!result.valid) { + this.importErrors = result.errors; + this.importMissingHeaders = result.missingHeaders; + throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.'); + } const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }); const rows = XLSX.utils.sheet_to_json>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true }); await this.projects.upsertPdvs(this.projectId, rows); diff --git a/route-planner/src/app/shared/services/planning-export.service.ts b/route-planner/src/app/shared/services/planning-export.service.ts index bc3e79f..37b9ee6 100644 --- a/route-planner/src/app/shared/services/planning-export.service.ts +++ b/route-planner/src/app/shared/services/planning-export.service.ts @@ -4,14 +4,22 @@ import { saveAs } from 'file-saver'; import { RouteState } from '../../core/route-state/route'; import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; import { PlanningFilterService } from '../../core/services/planning-filter.service'; +import { ProjectService } from '../../core/services/project.service'; +import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils'; +import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils'; @Injectable({ providedIn: 'root' }) export class PlanningExportService { - constructor(private routeState: RouteState, private filters: PlanningFilterService) {} + constructor(private routeState: RouteState, private filters: PlanningFilterService, private projects: ProjectService) {} - export(): void { + async export(projectId: string): Promise { + const project = await this.projects.get(projectId); + const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null }; + const fuelConfigured = isFuelConfigured(fuelConfig); + const fuelCurrency = currencyForCountry(project.pais); const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot()); - const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()); + const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()) + .map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) })); const users = new Map; distancia: number; trabajo: number; capacidad: number }>(); for (const route of assigned) { @@ -32,11 +40,13 @@ export class PlanningExportService { } const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id))); - const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id))); + const pending = unassigned.filter(route => !assignedIds.has(String(route.pdv_id))); + const unassignedIds = new Set(pending.map(route => String(route.pdv_id))); const total = assignedIds.size + unassignedIds.size; 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 fuelTotal = fuelCost(totalDistance, fuelConfig); const workbook = XLSX.utils.book_new(); const summary = [ @@ -47,7 +57,9 @@ export class PlanningExportService { { Indicador: 'Usuarios activos', Valor: users.size }, { Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size }, { Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) }, - { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) } + { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }, + { Indicador: 'Combustible total (L)', Valor: fuelConfigured ? Number(fuelTotal.liters.toFixed(2)) : 'Sin configurar' }, + { Indicador: `Costo combustible total (${fuelCurrency})`, Valor: fuelConfigured ? Number(fuelTotal.cost.toFixed(2)) : 'Sin configurar' } ]; const assignedRows = sequenceRoutes(assigned).map(segment => ({ @@ -61,15 +73,17 @@ export class PlanningExportService { '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}` + 'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`, + 'Costo combustible': fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : 'Sin configurar' })); - const unassignedRows = unassigned.map(route => ({ + const unassignedRows = pending.map(route => ({ 'PDV ID': route.pdv_id, PDV: route.pdv_nombre, 'Mínimo horas/semana': route.minimo_horas_semana, Latitud: route.latitud, Longitud: route.longitud, - Motivo: route.motivo + Motivo: route.motivo, + Recomendación: route.recomendacion ?? '' })); const userRows = Array.from(users, ([id, user]) => { const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0; @@ -82,6 +96,7 @@ export class PlanningExportService { 'Horas trabajo': Number(user.trabajo.toFixed(2)), 'Capacidad (h)': user.capacidad, 'Utilización (%)': Number(utilization.toFixed(1)), + [`Costo combustible (${fuelCurrency})`]: fuelConfigured ? Number(fuelCost(user.distancia, fuelConfig).cost.toFixed(2)) : 'Sin configurar', Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK' }; }); diff --git a/route-planner/src/app/shared/services/route-pdf-report.service.ts b/route-planner/src/app/shared/services/route-pdf-report.service.ts index b7d6063..eb20abb 100644 --- a/route-planner/src/app/shared/services/route-pdf-report.service.ts +++ b/route-planner/src/app/shared/services/route-pdf-report.service.ts @@ -8,6 +8,7 @@ import { ProjectService } from '../../core/services/project.service'; import { PlanningFilterService } from '../../core/services/planning-filter.service'; import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils'; +import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils'; @Injectable({ providedIn: 'root' }) export class RoutePdfReportService { @@ -30,17 +31,31 @@ export class RoutePdfReportService { .map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) })); const dailyRoutes = summarizeDailyRoutes(assigned); const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size; + const totalDistanceKm = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0); + const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null }; + const fuelConfigured = isFuelConfigured(fuelConfig); + const fuelTotal = fuelCost(totalDistanceKm, fuelConfig); + const fuelCurrency = currencyForCountry(project.pais); const payload = { project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' }, filters: this.filters.snapshot(), generatedAt: new Date().toISOString(), + fuel: { + configured: fuelConfigured, + rendimientoKmL: fuelConfig.rendimientoKmL, + precioPorLitro: fuelConfig.precioPorLitro, + currency: fuelCurrency + }, summary: { totalPdvs, assignedPdvs: assignedIds.size, unassignedPdvs: pending.length, coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0, - totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)), - totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2)) + totalDistanceKm: Number(totalDistanceKm.toFixed(2)), + totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2)), + fuelLiters: Number(fuelTotal.liters.toFixed(2)), + fuelCost: Number(fuelTotal.cost.toFixed(2)), + fuelCurrency }, assignedRoutes: sequenceRoutes(assigned).map(segment => ({ usuarioId: segment.route.json.usuario_id, @@ -53,6 +68,8 @@ export class RoutePdfReportService { distanciaKm: segment.route.json.distancia, desplazamientoHoras: segment.route.json.horas_desplazamiento, horasTrabajo: segment.route.json.horas_trabajo, + costoCombustible: fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : null, + monedaCombustible: fuelCurrency, cobertura: segment.route.json.cobertura ?? '', zona: segment.route.json.zona ?? '', region: segment.route.json.region ?? '', diff --git a/route-planner/src/app/shared/utils/country-center.utils.ts b/route-planner/src/app/shared/utils/country-center.utils.ts new file mode 100644 index 0000000..645ca07 --- /dev/null +++ b/route-planner/src/app/shared/utils/country-center.utils.ts @@ -0,0 +1,25 @@ +export interface CountryCenter { + lat: number; + lng: number; + zoom: number; +} + +function normalize(value: string | null | undefined): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/gi, '') + .toLowerCase(); +} + +export function centerForCountry(country: string | null | undefined): CountryCenter { + const normalized = normalize(country); + if (!normalized) return { lat: 19.4326, lng: -99.1332, zoom: 5 }; + if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) { + return { lat: 19.4326, lng: -99.1332, zoom: 10 }; + } + if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) { + return { lat: 18.4861, lng: -69.9312, zoom: 8 }; + } + return { lat: 19.4326, lng: -99.1332, zoom: 5 }; +} diff --git a/route-planner/src/app/shared/utils/fuel-estimation.utils.ts b/route-planner/src/app/shared/utils/fuel-estimation.utils.ts new file mode 100644 index 0000000..6463474 --- /dev/null +++ b/route-planner/src/app/shared/utils/fuel-estimation.utils.ts @@ -0,0 +1,43 @@ +export interface FuelConfig { + rendimientoKmL: number | null; + precioPorLitro: number | null; +} + +export function currencyForCountry(country: string | null | undefined): string { + const normalized = (country ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/gi, '') + .toLowerCase(); + if (!normalized) return '$'; + if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) return 'MX$'; + if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) return 'RD$'; + return '$'; +} + +export function isFuelConfigured(config: FuelConfig): boolean { + return ( + config.rendimientoKmL != null && + config.precioPorLitro != null && + Number.isFinite(config.rendimientoKmL) && + Number.isFinite(config.precioPorLitro) && + config.rendimientoKmL > 0 && + config.precioPorLitro >= 0 + ); +} + +export function fuelLiters(distanceKm: number, rendimientoKmL: number): number { + if (!Number.isFinite(distanceKm) || distanceKm <= 0) return 0; + if (!Number.isFinite(rendimientoKmL) || rendimientoKmL <= 0) return 0; + return distanceKm / rendimientoKmL; +} + +export function fuelCost(distanceKm: number, config: FuelConfig): { liters: number; cost: number } { + if (!isFuelConfigured(config)) return { liters: 0, cost: 0 }; + const liters = fuelLiters(distanceKm, config.rendimientoKmL as number); + return { liters, cost: liters * (config.precioPorLitro as number) }; +} + +export function formatFuelCost(cost: number, currency: string): string { + return `${currency} ${cost.toFixed(2)}`; +} diff --git a/supabase/proyecto_combustible.sql b/supabase/proyecto_combustible.sql new file mode 100644 index 0000000..48fa542 --- /dev/null +++ b/supabase/proyecto_combustible.sql @@ -0,0 +1,3 @@ +alter table public.rutero_proyectos + add column if not exists rendimiento_km_l numeric null, + add column if not exists precio_por_litro numeric null;