feat(booking): slot search respects skill + real shifts + weekend-for-repairs

suggestSlots was skill-blind and assumed a default 8-17 shift for every
tech. Now:
- SKILL FILTER: only techs whose skills (or _user_tags) include the
  requested skill are considered. Dialog passes the picked chip's skill.
- REAL SHIFTS: slot windows come from published Shift Assignment ×
  Shift Template (garde/on_call excluded). A tech with no regular shift
  that day yields no slot — so it reflects reality (needs shifts created).
- WEEKENDS reserved for repairs: Sat/Sun skipped unless the skill is a
  repair/dépannage. Removed SLOT_DEFAULT_SHIFT.
- Empty-state message hints to publish a shift / weekend rule.

Verified: with only garde shifts in the horizon, correctly returns 0
(regular_count=0). Skill/weekend/garde-exclusion logic confirmed via
data diagnostic. Produces slots once a regular day-shift is published for
a qualified tech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 10:21:01 -04:00
parent 374e0da229
commit 8b9d5376a7
2 changed files with 28 additions and 9 deletions

View File

@ -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
<q-separator v-if="searched" />
<q-card-section v-if="searched" style="max-height:44vh;overflow:auto" class="q-pt-sm">
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div>
<div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau élargis la date.</div>
<div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<q-list v-else separator>
<q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders">
<q-item-section avatar><q-avatar size="34px" color="blue-grey-1" text-color="blue-grey-8" class="text-weight-bold" style="font-size:12px">{{ dayLabel(s.date).slice(0, 3) }}</q-avatar></q-item-section>

View File

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