From 7f3ad5618882ee31de92beabf0e01d7e5ad0ce39 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 4 Jun 2026 14:19:42 -0400 Subject: [PATCH] =?UTF-8?q?Booking=20#56:=20politique=20de=20cr=C3=A9neaux?= =?UTF-8?q?=20offerts=20+=20holds=20temporaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getBookingPolicy() (sous-objet 'booking' du fichier policy): lead_hours, day_start/end, days_offered, horizon_days, max_per_day, hold_minutes. Appliquée dans bookingSlots → cohérent pour /book + vue agent + fit. ignorePolicy au moment de confirmer (slot encore libre). - Holds en mémoire (Map TTL): /roster/book/hold {date,start,minutes|release} → fenêtre retirée des dispos des autres pendant le hold; libérée à la confirmation. Évite le double-booking pendant qu'un agent/client choisit. - roster-assistant: DEFAULT_POLICY.booking + booking_fields/weekdays (descripteurs UI), fusion fine. - Testé: plage 10-14h filtre bien; hold 10:00 dispo 12→11. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/targo-hub/lib/roster-assistant.js | 18 +++++- services/targo-hub/lib/roster.js | 70 ++++++++++++++++++++-- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/services/targo-hub/lib/roster-assistant.js b/services/targo-hub/lib/roster-assistant.js index caae86d..4a6ccfc 100644 --- a/services/targo-hub/lib/roster-assistant.js +++ b/services/targo-hub/lib/roster-assistant.js @@ -31,6 +31,8 @@ const DEFAULT_POLICY = { sms_quiet_hours: true, // pas d'envoi 21h–8h sms_max: 1, escalation: 'queue_sms', // file Ops + SMS superviseur + // #56 — créneaux offerts au booking (page /book + vue agent). Lu par lib/roster.js getBookingPolicy(). + booking: { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 }, } const POLICY_OPTIONS = { reschedule: [ @@ -42,13 +44,25 @@ const POLICY_OPTIONS = { { value: 'queue', label: 'File « À recontacter » dans Ops seulement' }, { value: 'queue_sms', label: 'File Ops + SMS au superviseur' }, ], + // #56 — réglages des créneaux offerts (rendus en champs dans l'UI) + booking_fields: [ + { key: 'lead_hours', label: 'Délai minimum avant un RDV', unit: 'heures', min: 0, max: 168, hint: 'Aucun créneau offert avant ce délai (ex. 24 h pour préparer la tournée).' }, + { key: 'day_start', label: 'Première heure offerte', unit: 'h', min: 0, max: 23 }, + { key: 'day_end', label: 'Dernière heure offerte', unit: 'h', min: 1, max: 24 }, + { key: 'horizon_days', label: 'Fenêtre de réservation', unit: 'jours', min: 1, max: 90, hint: 'Jusqu’à combien de jours à l’avance on propose.' }, + { key: 'max_per_day', label: 'Max de créneaux affichés / jour', unit: '0 = illimité', min: 0, max: 50 }, + { key: 'hold_minutes', label: 'Réservation temporaire (hold)', unit: 'minutes', min: 1, max: 60, hint: 'Durée pendant laquelle un créneau sélectionné est bloqué pour les autres.' }, + ], + weekdays: [{ v: 1, l: 'Lun' }, { v: 2, l: 'Mar' }, { v: 3, l: 'Mer' }, { v: 4, l: 'Jeu' }, { v: 5, l: 'Ven' }, { v: 6, l: 'Sam' }, { v: 0, l: 'Dim' }], } function getPolicy () { - try { return { ...DEFAULT_POLICY, ...JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')) } } + try { const f = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...DEFAULT_POLICY, ...f, booking: { ...DEFAULT_POLICY.booking, ...(f.booking || {}) } } } catch { return { ...DEFAULT_POLICY } } } function setPolicy (p) { - const next = { ...getPolicy(), ...p } + const cur = getPolicy() + const next = { ...cur, ...p } + if (p && p.booking) next.booking = { ...cur.booking, ...p.booking } // fusion fine des réglages booking try { fs.mkdirSync(path.dirname(POLICY_FILE), { recursive: true }) } catch {} fs.writeFileSync(POLICY_FILE, JSON.stringify(next, null, 2)) return next diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 9344f77..04072ba 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -33,6 +33,9 @@ const crypto = require('crypto') const { json, parseBody } = require('./helpers') const erp = require('./erp') const cfg = require('./config') +const fs = require('fs') +const path = require('path') +const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json') const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const PAUSE_STATUS = 'En pause' @@ -255,6 +258,31 @@ async function coverage (start, days) { function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 } function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } +// ── #56 Politique de créneaux offerts + holds temporaires ──────────────────── +// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`), +// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source +// de créneaux (page /book, vue agent, fit) → comportement cohérent partout. +const BOOKING_DEFAULTS = { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 } +function getBookingPolicy () { + try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } } +} +function nowMs () { return new Date().getTime() } +// Holds en mémoire : quand un client/agent sélectionne une fenêtre, on la réserve +// quelques minutes pour éviter qu'un autre la prenne pendant la confirmation. +// Transitoire (perdu au redémarrage du hub = acceptable pour un blocage de ~10 min). +const bookingHolds = new Map() // clé 'YYYY-MM-DD|HH:MM' → [expiryMs, …] +function holdCount (key) { + const now = nowMs(); const arr = (bookingHolds.get(key) || []).filter(t => t > now) + if (arr.length) bookingHolds.set(key, arr); else bookingHolds.delete(key) + return arr.length +} +function addHold (key, minutes) { + const arr = (bookingHolds.get(key) || []).filter(t => t > nowMs()) + arr.push(nowMs() + Math.max(1, minutes || 10) * 60000); bookingHolds.set(key, arr) + return arr.length +} +function releaseHold (key) { bookingHolds.delete(key) } + async function loadBookingData (start, days) { const dates = rangeDates(start, days) const asgs = await fetchAssignments(start, days) @@ -289,19 +317,35 @@ function techGaps (a, d, skill, zone) { return { tech: t, gaps } } -async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false } = {}) { +async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false, ignorePolicy = false } = {}) { const dur = Number(duration) || 1 + const pol = getBookingPolicy() const d = await loadBookingData(start, days) + const leadCut = ignorePolicy ? 0 : nowMs() + (pol.lead_hours || 0) * 3600000 + const dayStart = ignorePolicy ? 0 : (Number.isFinite(pol.day_start) ? pol.day_start : 0) + const dayEnd = ignorePolicy ? 24 : (Number.isFinite(pol.day_end) ? pol.day_end : 24) + const offered = !ignorePolicy && Array.isArray(pol.days_offered) && pol.days_offered.length ? pol.days_offered : null const out = [] for (const a of d.asgs) { if (a.status === 'Annulé') continue + if (offered && !offered.includes(parseISO(a.date).getUTCDay())) continue // jour non offert par la politique const g = techGaps(a, d, skill, zone); if (!g) continue - for (const [gs, ge] of g.gaps) { let s = gs; while (s + dur <= ge) { out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }); s += dur } } + for (const [gs, ge] of g.gaps) { + let s = Math.max(gs, dayStart); const end = Math.min(ge, dayEnd) // borné à la plage horaire offerte + while (s + dur <= end) { + const slotMs = parseISO(a.date).getTime() + s * 3600000 // approx UTC — suffisant pour un délai exprimé en heures + if (slotMs >= leadCut) out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }) + s += dur + } + } } - if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, sans exposer qui + if (aggregate) { // client : 1 fenêtre par (date,heure) + nb de techs dispo, moins les holds actifs const byWin = {} for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ } - return Object.values(byWin).sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h).slice(0, limit) + let arr = Object.values(byWin).map(w => ({ ...w, available: w.available - holdCount(w.date + '|' + w.start) })).filter(w => w.available > 0) + arr.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h) + if (!ignorePolicy && pol.max_per_day > 0) { const c = {}; arr = arr.filter(w => { c[w.date] = (c[w.date] || 0) + 1; return c[w.date] <= pol.max_per_day }) } // plafond/jour + return arr.slice(0, limit) } out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h) return out.slice(0, limit) @@ -338,11 +382,14 @@ async function jobByToken (token) { return rows[0] || null } async function confirmWindow (jobName, date, start, duration) { - const day = await bookingSlots({ duration, start: date, days: 1, limit: 300 }) + // À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre + // (pas re-filtrer par la politique d'offre) → ignorePolicy. + const day = await bookingSlots({ duration, start: date, days: 1, limit: 300, ignorePolicy: true }) const slot = day.find(s => s.start === start) if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' } const st = start.length === 5 ? start + ':00' : start const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' })) + if (r.ok) releaseHold(date + '|' + start) return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' } } @@ -382,7 +429,7 @@ async function handlePublicBooking (req, res, method, path, url) { if (path === '/book/api/options' && method === 'GET') { const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' }) const dur = Number(job.duration_h) || 1 - const windows = await bookingSlots({ duration: dur, start: todayET(), days: 21, aggregate: true, limit: 60 }) + const windows = await bookingSlots({ duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 }) return json(res, 200, { ok: true, job: { location: job.service_location || '', duration: dur, scheduled: job.scheduled_date || '' }, windows }) } if (path === '/book/api/submit' && method === 'POST') { @@ -512,6 +559,16 @@ async function handle (req, res, method, path, url) { const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 }) return json(res, 200, { jobs: rows || [] }) } + // Hold : réserver temporairement une fenêtre (agent au tél. ou client qui sélectionne) + // → la fenêtre est retirée des dispos des autres pendant `minutes` (défaut politique). + if (path === '/roster/book/hold' && method === 'POST') { + const b = await parseBody(req) + if (!b.date || !b.start) return json(res, 400, { error: 'date + start requis' }) + const key = b.date + '|' + (String(b.start).length >= 5 ? String(b.start).slice(0, 5) : b.start) + if (b.release) { releaseHold(key); return json(res, 200, { ok: true, released: true, key }) } + const minutes = b.minutes || getBookingPolicy().hold_minutes + return json(res, 200, { ok: true, key, holds: addHold(key, minutes), minutes }) + } // Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer if (path === '/roster/book/fit' && method === 'POST') { const b = await parseBody(req) @@ -526,6 +583,7 @@ async function handle (req, res, method, path, url) { if (b.tech) patch.assigned_tech = b.tech if (b.duration) patch.duration_h = b.duration const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch)) + if (r.ok && b.date && b.start) releaseHold(b.date + '|' + (String(b.start).slice(0, 5))) return json(res, r.ok ? 200 : 500, r) } // Créer plusieurs besoins d'un coup (depuis l'éditeur de demande)