diff --git a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue index 2d500f6..b33cbf1 100644 --- a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue +++ b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue @@ -38,7 +38,7 @@ async function search () { if (target.latitude == null) return loading.value = true; searched.value = true try { - slots.value = await suggestSlots({ duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude, after_date: afterDate.value, limit: 8 }) + slots.value = await suggestSlots({ duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude, after_date: afterDate.value, limit: 8, skill: skill.value }) } catch (e) { Notify.create({ type: 'negative', message: 'Recherche impossible : ' + (e.message || e), timeout: 4000 }); slots.value = [] } finally { loading.value = false } } @@ -98,7 +98,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
-
Aucun créneau — élargis la date.
+
Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} — vérifie qu'un tech qualifié a un quart sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.
{{ dayLabel(s.date).slice(0, 3) }} diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js index 54a6e96..01e450c 100644 --- a/services/targo-hub/lib/dispatch.js +++ b/services/targo-hub/lib/dispatch.js @@ -129,21 +129,22 @@ function rankTechs (techs, jobCoords, jobDuration = 1) { // window, minus a travel buffer before the new job, minus a "not in the // past" cutoff. Scores surface the most natural inserts (earliest first, // then shortest travel), with a 2-slot-per-tech cap to diversify results. -const SLOT_DEFAULT_SHIFT = { start_h: 8, end_h: 17 } const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed start const SLOT_HORIZON_DAYS = 7 const SLOT_MAX_PER_TECH = 2 -async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5 } = {}) { +async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '' } = {}) { const baseDate = after_date || todayET() const duration = parseFloat(duration_h) || 1 const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i)) const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null + const wantSkill = String(skill || '').trim().toLowerCase() + const isRepair = /r[ée]par|d[ée]pann|panne|bris/.test(wantSkill) // les WEEK-ENDS sont RÉSERVÉS aux réparations → sautés pour les autres compétences - const [techRes, jobRes] = await Promise.all([ + const [techRes, jobRes, tplRes, shiftRes] = await Promise.all([ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([ 'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', - 'absence_from', 'absence_until', + 'absence_from', 'absence_until', 'skills', '_user_tags', ]))}&limit_page_length=50`), erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([ ['status', 'in', ['open', 'assigned']], @@ -153,10 +154,23 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, 'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'longitude', 'latitude', ]))}&limit_page_length=500`), + erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`), + erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`), ]) if (techRes.status !== 200) throw new Error('Failed to fetch technicians') - const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable') + // FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe). + const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) } + const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && hasSkill(t)) const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : [] + // Fenêtres de quart RÉELLES par tech×jour (Shift Assignment × Shift Template) — plus de fenêtre 8-17 par défaut : un tech SANS quart ce jour = pas de créneau. + const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t + const winBy = {} // 'technician|date' → { start_h, end_h } (quart réel, garde exclue) + for (const a of ((shiftRes.data && shiftRes.data.data) || [])) { + const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue + const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue + const k = a.technician + '|' + a.assignment_date + const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e } + } const today = todayET() const nowH = nowHoursET() @@ -167,9 +181,15 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, ? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null for (const dateStr of dates) { + // WEEK-END réservé aux réparations : on saute sam/dim pour toute autre compétence. + const dow = new Date(dateStr + 'T12:00:00').getUTCDay() + if ((dow === 0 || dow === 6) && !isRepair) continue // Absence window skip. if (tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue + // QUART RÉEL du tech ce jour (Shift Assignment) — pas de quart = pas de créneau (créer un quart pour en ouvrir). + const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr] + if (!shift) continue // Day's pinned jobs (only those with a real start_time — floating jobs // without a time are ignored since we don't know when they'll land). @@ -186,8 +206,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, }) .sort((a, b) => a.start_h - b.start_h) - // Build gaps bounded by shift_start/end. - const shift = SLOT_DEFAULT_SHIFT + // Build gaps bounded by shift_start/end (shift = quart réel du tech ce jour, résolu ci-dessus). const gaps = [] let cursor = shift.start_h, prevCoords = homeCoords for (const j of dayJobs) {