diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index f21531c..87b4137 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -100,6 +100,8 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu export const unassignedJobs = () => jget('/roster/unassigned-jobs') // Optimisation de tournées (VRPTW + compétences + temps sur place) via le solveur OR-Tools. payload = { jobs[], vehicles[], ... } export const optimizeRoutes = (payload) => jpost('/roster/optimize-routes', payload) +// P4 — orchestration COMPLÈTE côté hub (pool+quarts+occupation+règles d'adresse) : { dates?|days?, tech_ids?, opts, dur_overrides, job_windows } → { plan[], unassigned[], assumed_shift } +export const optimizePlanHub = (payload) => jpost('/roster/optimize-plan', payload) // Proxys OSRM auto-hébergé (points [[lon,lat],…]) — tracés + matrices SANS Mapbox payant. export const osrmRoute = (points) => jpost('/roster/osrm-route', { points }) // → { km, mins, geometry, legs[] (temps entre arrêts) } export const osrmTable = (points) => jpost('/roster/osrm-table', { points }) // → { durations (s), distances (m) } même forme que Mapbox Matrix diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 3ba0cb8..512692c 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1107,6 +1107,9 @@ {{ e.skill }} ★{{ e.lvl }}/{{ e.reqLevel }} Tech rapide sur ce type ({{ e.eff }}) + + Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt + {{ e.sameAddrN }} jobs à la MÊME adresse — regroupés en 1 seul arrêt {{ e.subject }} · {{ e.customer }} {{ Math.round(e.dur * 10) / 10 }}h @@ -4174,9 +4177,40 @@ function shiftWindowMin (techId, iso) { if (!isFinite(s) || !isFinite(e) || e <= s) return null return { start: Math.round(s * 60), end: Math.round(e * 60) } } -// « Optimiser » = le greedy décide le JOUR (buckets + placeholders), puis le solveur VRP OR-Tools ré-optimise CHAQUE jour -// (assignation + routes) parmi les techs de quart → regroupe les secteurs, respecte compétences + temps sur place. +// « Optimiser » = P4 : l'ORCHESTRATION COMPLÈTE vit au hub (/roster/optimize-plan — pool, quarts, occupation, mêmes règles pour le cron +// auto-plan) avec règles d'adresse : même adresse = 1 arrêt ; job à l'adresse d'un job DÉJÀ assigné → épinglé sur CE tech (coordination). +// Repli : l'ancienne orchestration locale si le hub est indisponible. async function optimizeSuggestion () { + suggestDlg.makeShifts = true // quarts 8-16 assumés (hub assumed_shift) → créés au Publier + const selTechs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) + const job_windows = {} + for (const n in suggestDlg.jobTime) { const w = AMPM_WIN[(suggestDlg.jobTime[n] || {}).ampm]; if (w) job_windows[n] = { tw_start_min: w[0], tw_end_min: w[1] } } + try { + const r = await roster.optimizePlanHub({ + dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id), + opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef }, + dur_overrides: { ...durOverride }, job_windows, + }) + if (!r || !r.ok || !Array.isArray(r.plan)) throw new Error((r && r.error) || 'réponse invalide') + const out = [] + for (const e of r.plan) { + const t = techById.value[e.techId] || {} + out.push({ + ...e, techName: e.techName || t.name || e.techId, placeholder: false, over: false, + capable: e.capable !== false, noShift: !!(r.assumed_shift && r.assumed_shift[e.techId + '|' + e.iso]), + lvl: e.skill ? (skillLevelOf(t, e.skill) || 0) : 0, eff: e.skill ? skillEffOf(t, e.skill) : (Number(t.efficiency) || 1), + }) + } + for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 }) + suggestDlg.plan = out + return + } catch (e) { + $q.notify({ type: 'warning', message: 'Optimisation serveur indisponible — repli local', timeout: 2500 }) + } + await optimizeSuggestionLocal() +} +// Ancienne orchestration CLIENT (repli d'urgence si le hub est injoignable) — même solveur, sans les règles d'adresse. +async function optimizeSuggestionLocal () { buildSuggestion() // pose les jours + skill/dur/coords/reqLevel sur chaque entrée + les placeholders suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné → à créer au moment du Publier const plan = suggestDlg.plan @@ -4305,7 +4339,14 @@ const suggestRoutes = computed(() => { for (const g of suggestGroups.value) { if (g.placeholder) continue const d = (g.dayList || []).find(x => x.iso === day); if (!d || !d.entries.length) continue - const stops = d.entries.filter(e => e.lat != null && e.lon != null).map((e, i) => ({ lat: +e.lat, lon: +e.lon, seq: i + 1, subject: e.subject })) + // MÊME ADRESSE = 1 ARRÊT sur la carte : les entrées aux mêmes coordonnées (~10 m) partagent un seul pin numéroté. + const stops = []; const seen = new Map() + for (const e of d.entries) { + if (e.lat == null || e.lon == null) continue + const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4) + const ex = seen.get(k) + if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1 }; seen.set(k, s); stops.push(s) } + } if (!stops.length) continue const home = techOrigin(g.techId) // domicile, sinon bureau TARGO out.push({ techId: g.techId, techName: g.techName, color: ROUTE_COLORS[ci % ROUTE_COLORS.length], km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops }) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 4a382ef..31c320b 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -124,6 +124,152 @@ function osrmGet (path) { }) } const osrmPts = (b, max) => { const pts = Array.isArray(b.points) ? b.points.slice(0, max) : []; return (pts.length >= 2 && pts.every(p => Array.isArray(p) && isFinite(+p[0]) && isFinite(+p[1]))) ? pts.map(p => (+p[0]).toFixed(6) + ',' + (+p[1]).toFixed(6)).join(';') : null } +// Matrice OSRM (si dispo) + solveur VRP — brique partagée (endpoint /optimize-routes ET optimizePlan). +async function solveVrp (body) { + if (!body.matrix) { + try { const m = await osrmMatrix(body.jobs, body.vehicles); if (m) { body.matrix = m; body.matrix_source = 'osrm' } } + catch (e) { log('osrm matrix skip: ' + (e && e.message)) } + } + return postSolver('/route', body) +} + +// ── P4 — Orchestration d'optimisation COMPLÈTE côté hub (réutilisable : SPA, cron auto-plan matinal, app terrain). ── +// Règles métier incluses : +// • MÊME ADRESSE = 1 ARRÊT : les jobs du pool à la même adresse sont fusionnés en 1 nœud solveur (temps sur place additionnés) +// → même tech, arrêt unique (ex. installation + config boîtier tel au même domicile). +// • COORDINATION : un job du pool à l'adresse d'un job DÉJÀ ASSIGNÉ ce jour-là est ÉPINGLÉ sur CE tech (il y va déjà) — +// ex. « Configuration de boîtier tel » placé sur l'arrêt de l'installateur. +const hmToMin = (t) => { const p = String(t || '').split(':'); const h = Number(p[0]); const m = Number(p[1]) || 0; return isFinite(h) ? h * 60 + m : null } +function addrKeyOf (j) { // clé d'adresse : coords arrondies (~10 m) si dispo, sinon adresse normalisée + const la = Number(j.latitude != null ? j.latitude : j.lat); const lo = Number(j.longitude != null ? j.longitude : j.lon) + if (isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01) return la.toFixed(4) + ',' + lo.toFixed(4) + const a = String(j.address || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, '') + return a.length >= 6 ? 'a:' + a : null +} +async function optimizePlan (body) { + const start = body.start || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) + const dates = (Array.isArray(body.dates) && body.dates.length ? body.dates.slice(0, 7) : rangeDates(start, Math.max(1, Math.min(7, Number(body.days) || 2)))).sort() + const lo = dates[0]; const hi = dates[dates.length - 1] + const span = Math.round((Date.parse(hi) - Date.parse(lo)) / 86400000) + 1 + const opts = body.opts || {} + const durOv = body.dur_overrides || {} + const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM) + const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null + + const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([ + buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span), + ]) + const tplBy = Object.fromEntries(templates.map(t => [t.name, t])) + const winBy = {} // techId|iso → fenêtre de quart RÉELLE (min) + for (const a of assignments) { + const tp = tplBy[a.shift]; if (!tp || tp.on_call) continue + const s = hmToMin(tp.start_time); let e = hmToMin(tp.end_time); if (s == null || e == null) continue + if (e <= s) e = 24 * 60 + const k = a.tech + '|' + a.date + const w = winBy[k]; winBy[k] = w ? { start: Math.min(w.start, s), end: Math.max(w.end, e) } : { start: s, end: e } + } + let policy = {}; try { policy = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')) } catch (e) {} + const homes = policy.tech_homes || {}; const depot = policy.depot || null + const originOf = (tid) => { const h = homes[tid]; if (h && isFinite(+h.lat) && isFinite(+h.lon)) return { lat: +h.lat, lon: +h.lon }; if (depot && isFinite(+depot.lat) && isFinite(+depot.lon)) return { lat: +depot.lat, lon: +depot.lon }; return null } + const isWE = iso => { const d = new Date(iso + 'T12:00:00Z').getUTCDay(); return d === 0 || d === 6 } + const durOf = j => { const o = Number(durOv[j.name]); if (o > 0) return o; return j.est_min ? j.est_min / 60 : (Number(j.duration_h) || 1) } + const prBoost = p => (p === 'urgent' || p === 'high') ? 400000 : (p === 'low' ? -60000 : 0) + const prEarly = p => (p === 'urgent' || p === 'high') ? 10 : 0 + const techs = techsAll.filter(t => !wantTech || wantTech.has(t.id)) + const entryOf = (j, iso, dur) => ({ + jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', + iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', reqLevel: Number(j.required_level) || 1, + lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null, + priority: String(j.priority || '').toLowerCase(), + }) + + // Bucket par jour : daté dans la fenêtre → son jour ; daté APRÈS → laissé pour plus tard ; en retard/sans date → 1er jour (déborde vers le suivant si non casé). + const dset = new Set(dates); const byDay = {}; const skipped = [] + for (const j of pool) { + const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null + if (sched && sched > hi) { skipped.push(j.name); continue } + const iso = (sched && dset.has(sched)) ? sched : dates[0] + ;(byDay[iso] = byDay[iso] || []).push(j) + } + + const plan = []; const unassigned = []; const assumedShift = {} + let spill = [] + for (const iso of dates) { + const dayJobs = [...(byDay[iso] || []), ...spill]; spill = [] + if (!dayJobs.length) continue + // Occupation existante : minutes déjà prises par tech + index adresse→tech (coordination) + const usedMin = {}; const addrTech = {} + for (const t of techs) { + const o = occ[t.id + '|' + iso]; if (!o) continue + let mins = 0 + for (const ej of (o.jobs || [])) { + if (ej.cancelled) continue + mins += Math.round((Number(ej.dur) || (ej.est_min ? ej.est_min / 60 : 1)) * 60) + const k = addrKeyOf(ej); if (k && !addrTech[k]) addrTech[k] = { techId: t.id, techName: t.name, subject: ej.subject } + } + if (mins) usedMin[t.id] = mins + } + // ÉPINGLAGE coordination : même adresse qu'un job DÉJÀ assigné ce jour → ce tech (capable ou pas : on SIGNALE, on ne bloque pas — même camion) + const rest = [] + for (const j of dayJobs) { + const k = addrKeyOf(j); const hit = k && addrTech[k] + if (hit) { + const t = techs.find(x => x.id === hit.techId) + const dur = durOf(j) + plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: !j.required_skill || !t || (t.skills || []).includes(j.required_skill) }) + usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60) + } else rest.push(j) + } + // Véhicules : quart réel, sinon 8-16 assumé en SEMAINE (créé au Publier côté UI) ; jamais le week-end ; absents exclus. + const vehicles = [] + for (const t of techs) { + if (absences[t.id + '|' + iso]) continue + let w = winBy[t.id + '|' + iso] || null + if (!w) { if (isWE(iso)) continue; w = { start: 480, end: 960 }; assumedShift[t.id + '|' + iso] = true } + const used = usedMin[t.id] || 0 + const o = originOf(t.id) + vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: o ? o.lat : null, home_lon: o ? o.lon : null, shift_start_min: Math.min(w.end - 15, w.start + used), shift_end_min: w.end, overtime_min: Math.round(0.2 * (w.end - w.start)) }) + } + if (!vehicles.length) { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue } + // MÊME ADRESSE = 1 ARRÊT : fusion des jobs du pool par clé d'adresse → 1 nœud (temps additionnés, skill = 1re non vide du groupe) + const gmap = new Map(); const units = [] + for (const j of rest) { const k = addrKeyOf(j); if (!k) { units.push([j]); continue } const g = gmap.get(k); if (g) g.push(j); else { const ng = [j]; gmap.set(k, ng); units.push(ng) } } + const sJobs = units.map((g, i) => { + const j0 = g[0] + const w = winOv[(g.find(x => winOv[x.name]) || {}).name] + const prios = g.map(j => String(j.priority || '').toLowerCase()) + return { + id: 'u' + i, + lat: (j0.latitude != null && isFinite(+j0.latitude)) ? +j0.latitude : null, + lon: (j0.longitude != null && isFinite(+j0.longitude)) ? +j0.longitude : null, + service_min: Math.max(5, Math.round(g.reduce((s, j) => s + durOf(j), 0) * 60)), + skill: (g.find(j => j.required_skill) || {}).required_skill || '', + priority_boost: Math.max(...prios.map(prBoost)), + urgent_weight: Math.max(...prios.map(prEarly)), + ...(w ? { tw_start_min: w.tw_start_min, tw_end_min: w.tw_end_min } : {}), + } + }) + const res = await solveVrp({ jobs: sJobs, vehicles, max_seconds: Number(opts.max_seconds) || 8, rank_weight: opts.rank_weight != null ? Number(opts.rank_weight) : 2, speed_kmh: Number(opts.speed_kmh) || 45, overtime_coef: opts.overtime_coef != null ? Number(opts.overtime_coef) : 20 }) + if (!res || res.status !== 'OK') { for (const j of rest) unassigned.push(entryOf(j, iso, durOf(j))); continue } + const placed = new Set() + for (const rt of (res.routes || [])) { + const t = techs.find(x => x.id === rt.vehicle) || { id: rt.vehicle, name: rt.vehicle_name, skills: [] } + for (const st of (rt.stops || [])) { + const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue + placed.add(st.job_id) + for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: !j.required_skill || (t.skills || []).includes(j.required_skill), sameAddrN: g.length }) + } + } + for (const uid of (res.unassigned || [])) { + if (placed.has(uid)) continue + const g = units[Number(String(uid).slice(1))]; if (!g) continue + if (iso !== hi) spill.push(...g) // retenté le jour suivant + else for (const j of g) unassigned.push(entryOf(j, iso, durOf(j))) + } + } + for (const j of spill) unassigned.push(entryOf(j, dates[dates.length - 1], durOf(j))) // sécurité (spill après le dernier jour) + return { ok: true, dates, plan, unassigned, assumed_shift: assumedShift, skipped_later: skipped.length } +} const sleep = (ms) => new Promise(r => setTimeout(r, ms)) // Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur @@ -987,14 +1133,15 @@ async function handle (req, res, method, path, url) { } // Optimisation de TOURNÉES (VRPTW + compétences + temps sur place) — proxy vers le solveur OR-Tools /route. // Le SPA construit le payload {jobs, vehicles, ...} (il a coords/skills/durées/domiciles/quarts) ; on relaie au solveur interne. + if (path === '/roster/optimize-plan' && method === 'POST') { // P4 — orchestration COMPLÈTE hub (pool+quarts+occupation+règles d'adresse) → plan prêt pour la revue / cron auto-plan + const b = await parseBody(req) + try { return json(res, 200, await optimizePlan(b)) } + catch (e) { log('optimize-plan err: ' + ((e && e.stack) || e)); return json(res, 500, { ok: false, error: String((e && e.message) || e) }) } + } if (path === '/roster/optimize-routes' && method === 'POST') { const body = await parseBody(req) if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' }) - if (!body.matrix) { // temps de ROUTE réels via OSRM si dispo ; sinon le solveur retombe sur le haversine - try { const m = await osrmMatrix(body.jobs, body.vehicles); if (m) { body.matrix = m; body.matrix_source = 'osrm' } } - catch (e) { log('osrm matrix skip: ' + (e && e.message)) } - } - try { return json(res, 200, await postSolver('/route', body)) } + try { return json(res, 200, await solveVrp(body)) } // matrice OSRM auto si absente ; repli haversine dans le solveur catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) } } // Jours fériés QC (déterministe) — sert le badge « férié » du day-strip + la réduction de capacité par défaut.