- 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) {