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:
parent
89f7efc4ba
commit
777794cd28
|
|
@ -664,7 +664,9 @@ function estimateForJob (job, items) {
|
||||||
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
|
// 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
|
// é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.
|
// 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 () {
|
function getBookingPolicy () {
|
||||||
try { const p = JSON.parse(fs.readFileSync(POLICY_FILE, 'utf8')); return { ...BOOKING_DEFAULTS, ...(p.booking || {}) } } catch { return { ...BOOKING_DEFAULTS } }
|
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 })
|
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
|
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) {
|
async function confirmWindow (jobName, date, start, duration, skill) {
|
||||||
// À la confirmation on veut juste vérifier que le tech est ENCORE physiquement libre
|
// À 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
|
// (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)
|
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.' }
|
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 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é' }))
|
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)
|
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' }
|
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 ? ».
|
// 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.
|
// Best-effort : F injoignable ⇒ on n'empêche PAS l'assignation.
|
||||||
if (legacyId && !b.force) {
|
if (legacyId && !b.force) {
|
||||||
try {
|
const cf = await fConflict(legacyId, b.tech)
|
||||||
const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId)
|
if (cf) return json(res, 409, { ok: false, ...cf })
|
||||||
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 patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
|
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
|
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') {
|
if (path === '/roster/book/confirm' && method === 'POST') {
|
||||||
const b = await parseBody(req)
|
const b = await parseBody(req)
|
||||||
if (!b.job) return json(res, 400, { error: 'job requis' })
|
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 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 || []) }
|
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
|
if (b.tech) patch.assigned_tech = b.tech
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user