feat(dispatch): TARGO office as default departure + per-tech origin button
techOrigin(techId): a tech's departure point = their home if set, else the TARGO office (depot from roster policy) — the stated default. Wired into ALL 9 origin sites: greedy proximity, VRP vehicles (solver now optimizes from the office), techsForJob/Selection ranking, review nnOrder + suggestRoutes + loadLegTimes, and the Tournées tab. UI: origin button on each tech — 🏠 (teal) when home set, 🏢 (grey) for office-default — in the review header AND the Tournées legend; click opens the existing home map picker (GPS position / search / map click). The first-leg line now reads "🏢 bureau TARGO → 1er arrêt : X min" or "🏠 domicile → …" (legsByKey carries the origin kind). Verified: prod policy has the depot (Sainte-Clotilde) and 1 home → all legends show 🏢; review shows real OSRM office→first-stop times (21/43/29/26 min); pin opens "Domicile — Anthony Dion" picker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ff43b54a76
commit
ebba1025ac
|
|
@ -483,7 +483,9 @@
|
||||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
|
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
|
||||||
<div class="suggest-legend q-mt-xs">
|
<div class="suggest-legend q-mt-xs">
|
||||||
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)">
|
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)">
|
||||||
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }} · {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span>
|
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
|
||||||
|
<q-icon :name="techHomes[r.id] ? 'home' : 'business'" size="13px" :color="techHomes[r.id] ? 'teal-7' : 'blue-grey-4'" style="cursor:pointer" @click.stop="openHomePicker({ id: r.id, name: r.name })"><q-tooltip>Départ : {{ techHomes[r.id] ? 'domicile' : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier</q-tooltip></q-icon>
|
||||||
|
· {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!dayRoutes.length" class="text-grey-6" style="font-size:12px">Aucune tournée géolocalisée ce jour — les jobs assignés n'ont pas de coordonnées, ou la journée est vide.</span>
|
<span v-if="!dayRoutes.length" class="text-grey-6" style="font-size:12px">Aucune tournée géolocalisée ce jour — les jobs assignés n'ont pas de coordonnées, ou la journée est vide.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1065,6 +1067,8 @@
|
||||||
<q-space />
|
<q-space />
|
||||||
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
|
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
|
||||||
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16</q-tooltip></q-btn>
|
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16</q-tooltip></q-btn>
|
||||||
|
<!-- Point de DÉPART : 🏠 domicile défini ou 🏢 bureau TARGO (défaut) — clic = définir/modifier sur la carte -->
|
||||||
|
<q-btn v-if="!g.placeholder" flat dense round size="sm" :icon="techHomes[g.techId] ? 'home' : 'business'" :color="techHomes[g.techId] ? 'teal-7' : 'blue-grey-4'" @click="openHomePicker(techById[g.techId] || { id: g.techId, name: g.techName })"><q-tooltip>Départ : {{ techHomes[g.techId] ? ('domicile — ' + (techHomes[g.techId].address || 'défini')) : 'bureau TARGO (défaut)' }} — cliquer pour définir/modifier le domicile</q-tooltip></q-btn>
|
||||||
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips ★ + glisser priorité) -->
|
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips ★ + glisser priorité) -->
|
||||||
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité</q-tooltip></q-btn>
|
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} — glisser pour l'ordre de priorité</q-tooltip></q-btn>
|
||||||
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
|
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
|
||||||
|
|
@ -1097,7 +1101,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="(e, ei) in d.entries" :key="e.jobName">
|
<template v-for="(e, ei) in d.entries" :key="e.jobName">
|
||||||
<div v-if="ei === 0 && legHome(g, d) != null" class="suggest-leg-time">🚗 domicile → 1er arrêt : {{ legHome(g, d) }} min</div>
|
<div v-if="ei === 0 && legHome(g, d) != null" class="suggest-leg-time">🚗 {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} → 1er arrêt : {{ legHome(g, d) }} min</div>
|
||||||
<div class="suggest-entry">
|
<div class="suggest-entry">
|
||||||
<span class="suggest-step">{{ ei + 1 }}</span>
|
<span class="suggest-step">{{ ei + 1 }}</span>
|
||||||
<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>
|
||||||
|
|
@ -3157,6 +3161,14 @@ const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
|
||||||
// (politique dispatch, lue côté client). La 1re job devient la plus proche de cette origine via « Optimiser depuis le départ ».
|
// (politique dispatch, lue côté client). La 1re job devient la plus proche de cette origine via « Optimiser depuis le départ ».
|
||||||
const depot = ref(null) // { label, address, lat, lon }
|
const depot = ref(null) // { label, address, lat, lon }
|
||||||
const techHomes = ref({}) // { techId: { address, lat, lon } }
|
const techHomes = ref({}) // { techId: { address, lat, lon } }
|
||||||
|
// Point de DÉPART d'un tech : son DOMICILE s'il est défini, SINON le bureau TARGO (dépôt) — politique par défaut.
|
||||||
|
function techOrigin (techId) {
|
||||||
|
const h = techHomes.value[techId]
|
||||||
|
if (h && isFinite(+h.lat) && isFinite(+h.lon) && (Math.abs(+h.lat) > 0.01)) return { lat: +h.lat, lon: +h.lon, kind: 'home', address: h.address || '' }
|
||||||
|
const d = depot.value
|
||||||
|
if (d && isFinite(+d.lat) && isFinite(+d.lon)) return { lat: +d.lat, lon: +d.lon, kind: 'depot', address: d.address || '' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
async function loadDispatchPolicy () {
|
async function loadDispatchPolicy () {
|
||||||
try { const d = await roster.getPolicy(); depot.value = (d.policy && d.policy.depot) || null; techHomes.value = (d.policy && d.policy.tech_homes) || {} } catch (e) { /* non bloquant */ }
|
try { const d = await roster.getPolicy(); depot.value = (d.policy && d.policy.depot) || null; techHomes.value = (d.policy && d.policy.tech_homes) || {} } catch (e) { /* non bloquant */ }
|
||||||
}
|
}
|
||||||
|
|
@ -3844,8 +3856,8 @@ function techsForJob (job) {
|
||||||
return (visibleTechs.value || []).map(t => {
|
return (visibleTechs.value || []).map(t => {
|
||||||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
||||||
const capable = !reqSkill || (t.skills || []).includes(reqSkill)
|
const capable = !reqSkill || (t.skills || []).includes(reqSkill)
|
||||||
const home = techHomes.value[t.id]
|
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||||||
const distKm = (hasJC && home && isFinite(+home.lat) && isFinite(+home.lon)) ? haversineKm(+home.lat, +home.lon, jlat, jlon) : null
|
const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
|
||||||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||||||
}).sort((a, b) =>
|
}).sort((a, b) =>
|
||||||
(Number(b.capable) - Number(a.capable)) || // capables d'abord
|
(Number(b.capable) - Number(a.capable)) || // capables d'abord
|
||||||
|
|
@ -3873,8 +3885,8 @@ function techsForSelection () {
|
||||||
return (visibleTechs.value || []).map(t => {
|
return (visibleTechs.value || []).map(t => {
|
||||||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
||||||
const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection
|
const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection
|
||||||
const home = techHomes.value[t.id]
|
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||||||
const distKm = (cLat != null && home && isFinite(+home.lat) && isFinite(+home.lon)) ? haversineKm(+home.lat, +home.lon, cLat, cLon) : null
|
const distKm = (cLat != null && home) ? haversineKm(home.lat, home.lon, cLat, cLon) : null
|
||||||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||||||
}).sort((a, b) =>
|
}).sort((a, b) =>
|
||||||
(Number(b.capable) - Number(a.capable)) ||
|
(Number(b.capable) - Number(a.capable)) ||
|
||||||
|
|
@ -3929,14 +3941,13 @@ function setEntryDur (e, v) { // temps révisé : plan (réactif) + mémoire de
|
||||||
async function loadLegTimes () {
|
async function loadLegTimes () {
|
||||||
for (const g of suggestGroups.value) {
|
for (const g of suggestGroups.value) {
|
||||||
if (g.placeholder) continue
|
if (g.placeholder) continue
|
||||||
const home = techHomes.value[g.techId]
|
const h = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||||||
const h = (home && isFinite(+home.lat) && isFinite(+home.lon)) ? home : null
|
|
||||||
for (const d of g.dayList) {
|
for (const d of g.dayList) {
|
||||||
const geo = d.entries.filter(e => e.lat != null && e.lon != null)
|
const geo = d.entries.filter(e => e.lat != null && e.lon != null)
|
||||||
if (geo.length < (h ? 1 : 2)) continue
|
if (geo.length < (h ? 1 : 2)) continue
|
||||||
const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon })))
|
const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon })))
|
||||||
if (!info || !info.legs || !info.legs.length) continue
|
if (!info || !info.legs || !info.legs.length) continue
|
||||||
const legs = info.legs; const out = { homeTo: null, after: {} }
|
const legs = info.legs; const out = { homeTo: null, after: {}, kind: h ? h.kind : null }
|
||||||
let li = 0
|
let li = 0
|
||||||
if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 }
|
if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 }
|
||||||
for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins }
|
for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins }
|
||||||
|
|
@ -4019,7 +4030,7 @@ function buildSuggestion () {
|
||||||
const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence
|
const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence
|
||||||
const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job
|
const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job
|
||||||
const costN = ((Number(t.cost_h) || 0) - cMin) / cRange
|
const costN = ((Number(t.cost_h) || 0) - cMin) / cRange
|
||||||
const home = techHomes.value[t.id]
|
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut) — départ de tournée
|
||||||
for (const iso of cand) {
|
for (const iso of cand) {
|
||||||
const c = cell(t.id, iso)
|
const c = cell(t.id, iso)
|
||||||
const noShift = !hasShiftDay(t.id, iso)
|
const noShift = !hasShiftDay(t.id, iso)
|
||||||
|
|
@ -4198,8 +4209,8 @@ async function optimizeSuggestion () {
|
||||||
const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0
|
const occ = techDayOcc(t.id, iso); const usedMin = occ ? Math.round((occ.usedH || 0) * 60) : 0
|
||||||
const startMin = Math.min(w.end - 15, w.start + usedMin)
|
const startMin = Math.min(w.end - 15, w.start + usedMin)
|
||||||
const overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage)
|
const overtimeMin = Math.round(0.2 * (w.end - w.start)) // plafond ~120 % du quart nominal ; au-delà de 100 % = pénalisé (équilibrage)
|
||||||
const h = techHomes.value[t.id]
|
const o = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||||||
vehicles.push({ id: t.id, name: t.name, skills: t.skills || [], home_lat: (h && isFinite(+h.lat)) ? +h.lat : null, home_lon: (h && isFinite(+h.lon)) ? +h.lon : null, shift_start_min: startMin, shift_end_min: w.end, overtime_min: overtimeMin })
|
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: startMin, shift_end_min: w.end, overtime_min: overtimeMin })
|
||||||
}
|
}
|
||||||
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés
|
if (!vehicles.length) { unassigned.push(...entries); continue } // aucun tech éligible ce jour → non assignés
|
||||||
let res
|
let res
|
||||||
|
|
@ -4251,7 +4262,7 @@ const suggestGroups = computed(() => {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return Object.values(byTech).map(g => {
|
return Object.values(byTech).map(g => {
|
||||||
const home = techHomes.value[g.techId]; const isPh = isHoldId(g.techId); let tkm = 0
|
const home = isHoldId(g.techId) ? null : techOrigin(g.techId); const isPh = isHoldId(g.techId); let tkm = 0 // départ = domicile, sinon bureau TARGO
|
||||||
const days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
|
const days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
|
||||||
const r = nnOrder(d.entries, home); tkm += r.km
|
const r = nnOrder(d.entries, home); tkm += r.km
|
||||||
// Jobs DÉJÀ assignés à ce tech ce jour (occupation OPS + tickets legacy F terrain) → visibles + secteur où il est déjà
|
// Jobs DÉJÀ assignés à ce tech ce jour (occupation OPS + tickets legacy F terrain) → visibles + secteur où il est déjà
|
||||||
|
|
@ -4296,8 +4307,8 @@ const suggestRoutes = computed(() => {
|
||||||
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 }))
|
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 }))
|
||||||
if (!stops.length) continue
|
if (!stops.length) continue
|
||||||
const home = techHomes.value[g.techId]
|
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 && isFinite(+home.lat) && isFinite(+home.lon)) ? { 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 })
|
||||||
ci++
|
ci++
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
@ -4316,8 +4327,8 @@ const dayRoutes = computed(() => {
|
||||||
const jobs = ((occ && occ.jobs) || []).filter(j => !j.cancelled && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && Math.abs(+j.lat) > 0.01)
|
const jobs = ((occ && occ.jobs) || []).filter(j => !j.cancelled && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && Math.abs(+j.lat) > 0.01)
|
||||||
if (!jobs.length) continue
|
if (!jobs.length) continue
|
||||||
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
|
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
|
||||||
const h = techHomes.value[t.id]
|
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
|
||||||
out.push({ id: t.id, name: t.name, color: ROUTE_COLORS[ci++ % ROUTE_COLORS.length], home: (h && isFinite(+h.lat) && isFinite(+h.lon)) ? { lat: +h.lat, lon: +h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) })
|
out.push({ id: t.id, name: t.name, color: ROUTE_COLORS[ci++ % ROUTE_COLORS.length], home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user