Booking #56: politique de créneaux offerts + holds temporaires
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
69ad35b9bc
commit
7f3ad56188
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user