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
setEntryDur(e, v)">
@@ -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.