feat(dispatch): P4 hub-side optimize orchestration + same-address rules

P4 — the FULL optimize orchestration now lives in the hub
(POST /roster/optimize-plan): pool (buildUnassigned), day bucketing
(dated-in-window → its day; overdue/undated → day 1 with spillover to
next day if unplaced), vehicles from real shift windows (assignments ×
templates) or assumed 8-16 weekdays (assumed_shift map returned; never
weekends; absences excluded), existing load (occupancyByTechDay) reduces
capacity, origins = home else TARGO depot, priorities from j.priority,
per-job AM/PM windows + duration overrides accepted, OSRM matrix +
OR-Tools solve per day (shared solveVrp, also used by /optimize-routes).
Reusable by the morning auto-plan cron and the field app.

Address rules (user cases baked in, hub-side):
- SAME ADDRESS = ONE STOP: pool jobs sharing an address key (~10m coords
  or normalized address) are merged into one solver node (service times
  summed) → same tech, single stop. Verified: install + TV boîtier of
  the same customer (Cazaville) grouped on Stéphane, 🔗 badges.
- COORDINATION: a pool job at the address of an ALREADY-ASSIGNED job
  that day is pinned onto THAT tech (they're going anyway) — verified:
  "Réinstallation #253028" pinned to Frédérique who already has the
  install there; capable flag kept honest (⚠ if skill missing).

SPA: optimizeSuggestion → hub-first (dates/techs/opts/dur_overrides/
job_windows), entries enriched client-side (lvl/eff/noShift from
assumed_shift); local orchestration kept as emergency fallback. Review
shows 🔗 teal (coordonné avec déjà-assigné) / 🔗 indigo (N jobs même
adresse = 1 arrêt); map merges same-coord stops into one numbered pin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 19:14:38 -04:00
parent ebba1025ac
commit cd12dbe601
3 changed files with 198 additions and 8 deletions

View File

@ -100,6 +100,8 @@ export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribu
export const unassignedJobs = () => jget('/roster/unassigned-jobs') 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[], ... } // 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) 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. // 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 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 export const osrmTable = (points) => jpost('/roster/osrm-table', { points }) // → { durations (s), distances (m) } même forme que Mapbox Matrix

View File

@ -1107,6 +1107,9 @@
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span> <span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
<span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span> <span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span>
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon> <q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} placé sur SON arrêt</q-tooltip></q-icon>
<q-icon v-else-if="e.sameAddrN > 1" name="link" size="14px" color="indigo-4" class="q-mr-xs"><q-tooltip>{{ e.sameAddrN }} jobs à la MÊME adresse regroupés en 1 seul arrêt</q-tooltip></q-icon>
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span> <span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h <span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)"> <q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
@ -4174,9 +4177,40 @@ function shiftWindowMin (techId, iso) {
if (!isFinite(s) || !isFinite(e) || e <= s) return null if (!isFinite(s) || !isFinite(e) || e <= s) return null
return { start: Math.round(s * 60), end: Math.round(e * 60) } 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 // « Optimiser » = P4 : l'ORCHESTRATION COMPLÈTE vit au hub (/roster/optimize-plan pool, quarts, occupation, mêmes règles pour le cron
// (assignation + routes) parmi les techs de quart regroupe les secteurs, respecte compétences + temps sur place. // 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 () { 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 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 suggestDlg.makeShifts = true // on SUPPOSE un quart 8-16 pour chaque tech sélectionné à créer au moment du Publier
const plan = suggestDlg.plan const plan = suggestDlg.plan
@ -4305,7 +4339,14 @@ const suggestRoutes = computed(() => {
for (const g of suggestGroups.value) { for (const g of suggestGroups.value) {
if (g.placeholder) continue if (g.placeholder) continue
const d = (g.dayList || []).find(x => x.iso === day); if (!d || !d.entries.length) 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 if (!stops.length) continue
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO 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 }) 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 })

View File

@ -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 } 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)) const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur // 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. // 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. // 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') { if (path === '/roster/optimize-routes' && method === 'POST') {
const body = await parseBody(req) const body = await parseBody(req)
if (!Array.isArray(body.jobs) || !Array.isArray(body.vehicles)) return json(res, 400, { error: 'jobs[] et vehicles[] requis' }) 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 { return json(res, 200, await solveVrp(body)) } // matrice OSRM auto si absente ; repli haversine dans le solveur
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)) }
catch (e) { return json(res, 502, { status: 'ERROR', message: 'solveur injoignable: ' + (e && e.message) }) } 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. // Jours fériés QC (déterministe) — sert le badge « férié » du day-strip + la réduction de capacité par défaut.