diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 34fcaa7..5662894 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -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