/** * Roster (Planification) API — appelle targo-hub /roster/*. * Le backend lit les techs/modèles/besoins dans ERPNext facturation et appelle * le solveur OR-Tools. Voir services/targo-hub/lib/roster.js. */ import { HUB_URL as HUB } from 'src/config/hub' // Toute requête a un TIMEOUT (AbortController) : un solveur/hub lent ou injoignable ne doit JAMAIS // laisser l'interface figée (ex. « Suggérer » qui tourne sans fin). Au-delà, on lève → repli local. async function _fetch (path, init = {}, ms = 45000) { const ctl = new AbortController() const timer = setTimeout(() => ctl.abort(), ms) try { const r = await fetch(HUB + path, { ...init, signal: ctl.signal }) if (!r.ok) { // Le corps d'erreur porte parfois un contexte actionnable (ex. 409 conflict de /roster/assign-job) → l'attacher. let body = null; try { body = await r.json() } catch (e2) { /* corps non-JSON */ } const err = new Error((body && body.error) || ('Roster API ' + r.status)); err.status = r.status; if (body) err.body = body throw err } return await r.json() } catch (e) { if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable') throw e } finally { clearTimeout(timer) } } async function jget (path, ms) { return _fetch(path, {}, ms) } async function jpost (path, body, ms) { return _fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }, ms) } async function jput (path, body, ms) { return _fetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }, ms) } export const listTechnicians = () => jget('/roster/technicians') export const listTemplates = () => jget('/roster/templates') export const createTemplate = (t) => jpost('/roster/templates', t) // Fériés QC déterministes (calendrier) + préavis de disponibilité avant un férié. export const holidays = (from, to) => jget(`/roster/holidays?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`) export const holidayNotice = (days = 35) => jget('/roster/holiday-notice?days=' + days) export const sendHolidayNotice = (date) => jpost('/roster/holiday-notice', { date }) export const listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`) export const createRequirement = (r) => jpost('/roster/requirements', r) export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`) export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`) // Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due. // skill (chaîne ou tableau) → dénominateur FILTRÉ : ne compte que les techs qui ont cette/ces compétence(s). export const getCapacity = (start, days = 7, skill = '') => { const s = (Array.isArray(skill) ? skill : (skill ? [skill] : [])).filter(Boolean) return jget(`/roster/capacity?start=${start}&days=${days}${s.length ? '&skill=' + encodeURIComponent(s.join(',')) : ''}`) } // Résolveur « raison → compétence(s) » : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }. export const resolveSkills = (body) => jpost('/roster/resolve-skills', body || {}) export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`) export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`) export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`) export const applyGardeHorizon = (start, weeks, assignments, shifts) => jpost('/roster/garde/apply', { start, weeks, assignments, shifts }) export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/set', { tech, date, type, remove }) export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights }) export const publish = (assignments) => jpost('/roster/publish', { assignments }) export const publishWeek = (start, days, assignments, notify, mode) => jpost('/roster/publish-week', { start, days, assignments, notify, mode }) // Notifier les techs de leur tournée (SMS/Email, lien token + infos) : preview=true → compose sans envoyer. export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload) export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch) // ── Copilote (Gemini Flash) + politique de reprise ── export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history }) export const getPolicy = () => jget('/roster/policy') export const savePolicy = (policy) => jpost('/roster/policy', policy) export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job export const setJobSkills = (name, skills) => jpost('/roster/job-skills', { name, skills }) // compétences requises (LISTE) persistantes par job → dispatch auto export const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_job) // Répondre au fil du billet depuis le volet job (note interne par défaut, au client si public). agentName = prénom+nom de l'agent (attribution). export const postJobComment = ({ job, lid, text, isPublic, agentName }) => jpost('/roster/job-comment', { job, lid, text, public: !!isPublic, agentName: agentName || '' }) // #5 — Réserver le temps d'un tech (bloc « Réservation » priorité moyenne) → dé-priorise au dispatch auto + soustrait des créneaux. export const reserveTech = ({ tech, date, start_time, duration_h, reason }) => jpost('/roster/reserve', { tech, date, start_time, duration_h, reason }) // Job « bouche-trou » : job générique standard assigné → retire le tech du dispatch (option : génère un ticket). export const createFillerJob = (body) => jpost('/roster/filler-job', body || {}) export async function deleteShiftTemplate (name) { const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' }) if (!r.ok) throw new Error('Suppression modèle: ' + r.status) return r.json() } export const bulkRequirements = (requirements) => jpost('/roster/requirements/bulk', { requirements }) export const clearRequirements = (start, days = 7) => jpost('/roster/requirements/clear', { start, days }) export async function deleteAssignment (name) { const r = await fetch(HUB + '/roster/assignment/' + encodeURIComponent(name), { method: 'DELETE' }) if (!r.ok) throw new Error('Suppression échouée: ' + r.status) return r.json() } export const listAvailability = (status) => jget('/roster/availability' + (status ? '?status=' + encodeURIComponent(status) : '')) export const requestAvailability = (a) => jpost('/roster/availability', a) export const approveAvailability = (name, body) => jpost('/roster/availability/' + encodeURIComponent(name) + '/approve', body || {}) export const pauseTechnician = (id, paused, reason) => jpost(`/roster/technician/${encodeURIComponent(id)}/pause`, { paused, reason }) export const archiveTechnician = (id, archived) => jpost(`/roster/technician/${encodeURIComponent(id)}/archive`, { archived }) // archive réversible (masqué du dispatch/solveur/créneaux, historique conservé) export const setTechEfficiency = (id, efficiency) => jpost(`/roster/technician/${encodeURIComponent(id)}/efficiency`, { efficiency }) export const setTechCost = (id, body) => jpost(`/roster/technician/${encodeURIComponent(id)}/cost`, body) export const setTechSkills = (id, skills, skillLevels, skillEff) => { const body = { skills }; if (skillLevels !== undefined) body.skill_levels = skillLevels; if (skillEff !== undefined) body.skill_eff = skillEff; return jpost(`/roster/technician/${encodeURIComponent(id)}/skills`, body) } // ── Prise de RDV ── export const bookJobs = () => jget('/roster/book/jobs') // Méta de l'éditeur type→compétence : { service_types, skills, skill_by_type } export const bookMeta = () => jget('/roster/book/meta') export const bookSlots = (p) => jget('/roster/book/slots?' + new URLSearchParams(p).toString()) export const bookFit = (body) => jpost('/roster/book/fit', body) export const bookConfirm = (body) => jpost('/roster/book/confirm', body) // Hold temporaire d'une fenêtre (agent qui sélectionne) — { date, start, minutes } ou { date, start, release:true } export const bookHold = (body) => jpost('/roster/book/hold', body) // Lien client (token) pour un job → { token, url } export const bookLink = (job) => jpost('/roster/book/link', { job }) // File « À recontacter » (jobs À reporter) export const jobsToReschedule = () => jget('/roster/jobs-to-reschedule') // Impact d'un retrait de compétence : jobs assignés exigeant cette compétence export const skillImpact = (tech, skill) => jget('/roster/skill-impact?tech=' + encodeURIComponent(tech) + '&skill=' + encodeURIComponent(skill)) // Impact d'une absence : jobs assignés au tech sur ces dates (CSV) export const absenceImpact = (tech, dates) => jget('/roster/absence-impact?tech=' + encodeURIComponent(tech) + '&dates=' + encodeURIComponent((dates || []).join(','))) // Redistribuer ces jobs : mode 'auto' (re-match) ou 'requeue' (À recontacter) export const redistributeSkillJobs = (jobs, skill, mode) => jpost('/roster/skill-impact/redistribute', { jobs, skill, mode }) // Candidats classés pour reprendre un job (techs qualifiés + libres au créneau) export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=' + encodeURIComponent(job) + '&exclude=' + encodeURIComponent(exclude || '')) // Plan explicite : [{ job, tech } | { job, requeue:true }] export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan }) // Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer export const unassignedJobs = () => jget('/roster/unassigned-jobs') // Optimisation de tournées (VRPTW + compétences + temps sur place) via le solveur OR-Tools. payload = { jobs[], vehicles[], ... } export const optimizeRoutes = (payload) => jpost('/roster/optimize-routes', payload) // P4 — orchestration COMPLÈTE côté hub (pool+quarts+occupation+règles d'adresse) : { dates?|days?, tech_ids?, opts, dur_overrides, job_windows } → { plan[], unassigned[], assumed_shift } export const optimizePlanHub = (payload) => jpost('/roster/optimize-plan', payload, 90000) // solveur VRP : timeout 90s → sinon repli local (jamais figé) // Proxys OSRM auto-hébergé (points [[lon,lat],…]) — tracés + matrices SANS Mapbox payant. export const osrmRoute = (points) => jpost('/roster/osrm-route', { points }) // → { km, mins, geometry, legs[] (temps entre arrêts) } export const osrmTable = (points) => jpost('/roster/osrm-table', { points }) // → { durations (s), distances (m) } même forme que Mapbox Matrix export const osrmTrip = (points) => jpost('/roster/osrm-trip', { points }) // TSP → { code, waypoints:[{waypoint_index}] } (remplace Mapbox optimized-trips) // Write-back legacy : aperçu (0 écriture) puis application (réassigne ticket.assign_to au tech dans osTicket) export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments') export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {}) // Fil complet d'un ticket legacy (osTicket) : messages + réponses, pour l'expand au clic export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id)) // Poster au fil d'un ticket : isPublic=false (défaut) = note INTERNE (jamais au client) · true = message public export const postTicket = (ticket, msg, isPublic) => jpost('/dispatch/legacy-sync/ticket-post', { ticket, msg, public: !!isPublic }) // GÉNÉRALISÉ par JOB (osTicket OU ERPNext-natif) : fil+contact (lecture seule) et résolution de conversation pour répondre. export const jobThread = (job) => jget('/dispatch/legacy-sync/job-thread?job=' + encodeURIComponent(job)) export const jobConversation = (job) => jget('/dispatch/legacy-sync/job-conversation?job=' + encodeURIComponent(job)) // P2 — réponse au CLIENT via le MÊME chemin que la Boîte (/conversations/{token}/messages → Outbox courriel/SMS + miroir osTicket). // X-Authentik-Email REQUIS : sans lui le hub enregistre l'envoi comme message CLIENT (from='customer') + déclenche l'IA. À passer (useAuthStore().user). export async function sendConvMessage (token, text, agentEmail) { const headers = { 'Content-Type': 'application/json' } if (agentEmail) headers['X-Authentik-Email'] = agentEmail const r = await fetch(HUB + '/conversations/' + encodeURIComponent(token) + '/messages', { method: 'POST', headers, body: JSON.stringify({ text }) }) if (!r.ok) throw new Error('Envoi conv ' + r.status) return r.json() } // Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants) export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {}) // Fermeture EN LOT (1 appel pour N tickets) — efficace export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {}) // Assigner un job à un tech (date = case déposée) // Assignation avec GARDE-FOU F : si le ticket legacy est déjà assigné dans F à un autre vrai tech (fenêtre aveugle // du miroir ~3 min), le hub renvoie 409 conflict → on demande confirmation AVANT d'écraser (jamais silencieux). export const assignJob = async (job, tech, date, { force = false } = {}) => { try { return await jpost('/roster/assign-job', { job, tech, date, force }) } catch (e) { const b = e && e.body if (b && b.conflict) { const { Dialog } = await import('quasar') return new Promise((resolve, reject) => { Dialog.create({ title: 'Déjà assigné dans F', message: `Le ticket ${b.ticket || ''} est déjà assigné dans F à ${b.f_staff_name || ('staff #' + b.f_staff_id)}${b.f_status ? ' (ticket ' + b.f_status + ')' : ''}. Réassigner quand même ?`, cancel: { label: 'Annuler', flat: true, color: 'grey-8' }, ok: { label: 'Réassigner quand même', color: 'negative', unelevated: true }, persistent: true, }).onOk(() => { assignJob(job, tech, date, { force: true }).then(resolve, reject) }) .onCancel(() => reject(new Error('Assignation annulée — déjà assigné dans F à ' + (b.f_staff_name || ('staff #' + b.f_staff_id))))) }) } throw e } } // Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id)) // Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }] export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates }) // Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto) export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job }) // Médias terrain d'un job : photos du tech (géo-vérifiées) + journal completion_notes (arrivées, scans, notes). export const jobMedia = (job) => jget('/roster/job-media?job=' + encodeURIComponent(job)) // Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address }) // Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub). export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch }) // Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub. export const createShift = (s) => jpost('/roster/shift', s) // Hybride : patron récurrent (source) + matérialisation (fériés/vacances sautés, manuels préservés) export const setWeeklySchedule = (techId, schedule) => jpost('/roster/technician/' + encodeURIComponent(techId) + '/weekly-schedule', { schedule }) export const materializeShifts = (payload) => jpost('/roster/materialize-shifts', payload || {}) // ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire. export const setTechContact = (b) => jpost('/roster/technician/contact', b) // édition inline téléphone/courriel d'un tech export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job)) export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a }) export const removeAssistant = (job, techId) => jpost('/roster/job/team', { job, remove: techId }) // Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel // Lien app PWA du tech (à copier / SMS) + son téléphone export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech)) // Table additive « durées par caractéristique » (éditable inline) — seed + apprentissage futur (learned_min) export const getJobChars = () => jget('/roster/job-characteristics') export const saveJobChars = (items) => jpost('/roster/job-characteristics', { items }) // Chrono (boucle de capture) : début/fin réels d'un job → durée réelle (apprentissage) export const startJob = (job) => jpost('/roster/job/start', { job }) export const finishJob = (job) => jpost('/roster/job/finish', { job }) export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync') export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {}) // Jobs Legacy (osTicket) ouverts assignés à UN tech (lecture seule) — vue « jobs courants du tech » export const legacyTechTickets = (tech) => jget('/dispatch/legacy-sync/tech-tickets?tech=' + encodeURIComponent(tech)) // Charge Legacy DATÉE par tech×jour sur la fenêtre affichée → durées estimées appliquées aux timelines export const legacyWindowLoad = (start, days) => jget('/dispatch/legacy-sync/window-load?start=' + encodeURIComponent(start) + '&days=' + encodeURIComponent(days)) // Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {}) // Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? } export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body) // Proposer un RDV au client : envoie le lien /book (choix d'un créneau OU proposition de 3 dispos). Résout OU crée le // job ouvert depuis { job | source_issue | customer } (+ subject/skill/duration/service_location/phone). Voir /roster/book/propose. export const proposeAppointment = (body) => jpost('/roster/book/propose', body) // URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement. export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to) // Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon). export const traccarTrack = (tech, from, to) => jget('/traccar/track?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)) // Validation « service livré » d'un job : statut service+modem (en ligne + signal) pour le client/adresse du job. // Croise tech-présent (geofence) + service-en-ligne → preuve de complétion d'install. export const jobServiceStatus = (job) => jget('/roster/job-service-status?job=' + encodeURIComponent(job)) // Aperçu croisement temps-sur-site (Traccar × emplacement job) + ancre d'activation GenieACS (clamped_minutes / service_anchor). export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days) // Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile). export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech)) // Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }. export const techPositions = () => jget('/roster/tech-positions') // Géofencing : timeline d'un job → { state, events:[{status,at,dist}] } (status: en_route|on_site|departed). export const jobGeofence = (name) => jget('/roster/job/' + encodeURIComponent(name) + '/geofence') // Géofencing : états live pour une liste de jobs → { states: { jobName: state } }. export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' + encodeURIComponent((names || []).join(','))) // Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }. export const listTraccarDevices = () => jget('/traccar/devices') // Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif. export const setTechTraccarDevice = (id, deviceId) => jpost('/roster/technician/' + encodeURIComponent(id) + '/traccar-device', { device_id: deviceId }) // Roster appareils GPS enrichi (device + tech(s) assigné(s) + non-associé + dernier signal) — vue « appareil → tech ». export const listTraccarDevicesRoster = () => jget('/roster/traccar-devices') // Assigner un appareil → un tech (SENS INVERSE) ; libère l'ancien détenteur. tech_id vide = dissocier de tous. export const assignTraccarDevice = (deviceId, techId) => jpost('/roster/traccar-device-assign', { device_id: deviceId, tech_id: techId || '' })