fix(booking): garde anti-clobber F sur les confirmations de RDV + hold exclusif 5 min

Découverte du mapping : la confirmation de RDV (confirmWindow + POST /roster/book/confirm) écrivait assigned_tech
SANS la garde « déjà assigné dans F » que /roster/assign-job applique → une confirmation client (ou staff) pouvait
écraser une assignation faite manuellement dans F. Même classe que le bug double-assignation.

- fConflict(legacyId, techId) : garde PARTAGÉE extraite (ticketAssignState), réutilisée par les 3 chemins d'écriture
  (assign-job refactorisé pour l'utiliser → plus de dérive).
- confirmWindow (chemin CLIENT, pas de force) : si conflit F, N'ÉCRASE PAS — enregistre le créneau choisi
  (booking_status Proposé) et laisse le répartiteur confirmer le bon tech. Message client « nous confirmerons sous peu ».
- POST /roster/book/confirm (chemin STAFF) : 409 conflict sauf force=true → l'OPS réaffiche « déjà assigné dans F à X ».
- hold_minutes défaut 10 → 5 (décision Louis) : réservation exclusive 5 min → retour au pool → indicatif (revalidé
  à la confirmation via la garde ci-dessus).

Déployé (hub redémarré, sain, SSE clients se reconnectent via 187115c/89f7efc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 14:30:40 -04:00
parent 89f7efc4ba
commit 777794cd28

View File

@ -664,7 +664,9 @@ function estimateForJob (job, items) {
// 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, skill_by_type: {} }
// hold_minutes : réservation EXCLUSIVE d'un créneau offert. 5 min (décision Louis 2026-07-19) → passé ce délai
// il retourne au pool et redevient indicatif (revalidé à la confirmation via la garde F). Éditable via /roster/policy.
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: 5, skill_by_type: {} }
function getBookingPolicy () {
try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } }
}
@ -871,6 +873,21 @@ async function jobByToken (token) {
const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
return rows[0] || null
}
// Garde anti-clobber « déjà assigné dans F » — PARTAGÉE par /roster/assign-job, confirmWindow et /roster/book/confirm.
// Retourne un objet conflit (→ 409 côté staff) si le ticket legacy est assigné à un AUTRE staff réel dans F, sinon null.
// Best-effort : F injoignable ⇒ null (on ne bloque pas). Empêche qu'une confirmation client écrase une assignation F.
async function fConflict (legacyId, techId) {
if (!legacyId) return null
try {
const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId)
const sidTarget = (String(techId || '').match(/(\d{2,})$/) || [])[1] || ''
if (st && st.assigned && String(st.staff_id) !== sidTarget) {
return { conflict: true, ticket: legacyId, f_staff_id: st.staff_id, f_staff_name: st.staff_name || '', f_status: st.status || '', error: 'Déjà assigné dans F à ' + (st.staff_name || ('staff #' + st.staff_id)) }
}
} catch (e) { /* F injoignable → best-effort */ }
return null
}
async function confirmWindow (jobName, date, start, duration, skill) {
// À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre
// (pas re-filtrer par la politique d'offre) → ignorePolicy. MAIS on garde le filtre COMPÉTENCE
@ -879,6 +896,16 @@ async function confirmWindow (jobName, date, start, duration, skill) {
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
// Garde F : si le ticket legacy est déjà assigné à un AUTRE tech dans F, on N'ÉCRASE PAS (côté client, pas de force).
// On enregistre le créneau choisi (booking_status Proposé) et on laisse le répartiteur confirmer le bon tech.
let legacyId = ''
try { const jb = await erp.get('Dispatch Job', jobName, { fields: ['legacy_ticket_id'] }); legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {}
const cf = await fConflict(legacyId, slot.tech)
if (cf) {
const rp = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, booking_status: 'Proposé', booking_prefs: JSON.stringify([{ date, start }]) }))
if (rp.ok) releaseHold(date + '|' + start)
return { ok: true, confirmed: false, message: 'Créneau enregistré — nous confirmerons le technicien sous peu.' }
}
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' }
@ -1678,13 +1705,8 @@ async function handle (req, res, method, path, url) {
// tech, on refuse (409 conflict) sauf force=true — le SPA affiche « Déjà assigné dans F à X — réassigner ? ».
// Best-effort : F injoignable ⇒ on n'empêche PAS l'assignation.
if (legacyId && !b.force) {
try {
const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId)
const sidTarget = (String(b.tech).match(/(\d{2,})$/) || [])[1] || ''
if (st && st.assigned && String(st.staff_id) !== sidTarget) {
return json(res, 409, { ok: false, conflict: true, ticket: legacyId, f_staff_id: st.staff_id, f_staff_name: st.staff_name || '', f_status: st.status || '', error: 'Déjà assigné dans F à ' + (st.staff_name || ('staff #' + st.staff_id)) })
}
} catch (e) {}
const cf = await fConflict(legacyId, b.tech)
if (cf) return json(res, 409, { ok: false, ...cf })
}
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
if (b.date) patch.scheduled_date = b.date
@ -1982,6 +2004,14 @@ async function handle (req, res, method, path, url) {
if (path === '/roster/book/confirm' && method === 'POST') {
const b = await parseBody(req)
if (!b.job) return json(res, 400, { error: 'job requis' })
// Garde anti-clobber F (comme /roster/assign-job) : si on (ré)assigne un tech et que le ticket legacy est
// déjà assigné à un AUTRE tech dans F, refuser en 409 sauf force=true → l'OPS propose « réassigner ? ».
if (b.tech && !b.force) {
let legacyId = ''
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['legacy_ticket_id'] }); legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {}
const cf = await fConflict(legacyId, b.tech)
if (cf) return json(res, 409, { ok: false, ...cf })
}
const st = (b.start || '').length === 5 ? b.start + ':00' : b.start
const patch = { scheduled_date: b.date, start_time: st, status: 'assigned', booking_status: 'Confirmé', booking_prefs: JSON.stringify(b.prefs || []) }
if (b.tech) patch.assigned_tech = b.tech