hub+solveur : compétences multi par job + réservation + matérialisation quarts + app terrain natif

- roster.js : store /roster/job-skills (LISTE = source unique des compétences requises),
  enrichissement required_skills[] + required_skill (1re, rétro-compat), solveur techCovers
  (le tech doit les avoir TOUTES) ; /roster/reserve (bloc placeholder priorité moyenne, soft) ;
  publishWeek modes draft/submit/approve/publish ; matérialiseur de quarts récurrents
  (source:pattern) ; décodage des entités HTML aux points de service
- route_solver.py : skill_ok list-aware (toutes les compétences requises)
- dispatch.js : suggestSlots ignoreReserved (une réservation ne bloque pas la recherche de créneau)
- conversation.js : agentName sur le fil (commentaire au nom du tech, pas « Targo Ops »)
- legacy-dispatch-sync.js : postTicketLegacy(actorEmail, actorName) — commentaire attribué
- field-app.html + server.js : commentaire + fil de ticket NATIFS (restent sur OPS)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 20:46:42 -04:00
parent eb8376c9c3
commit 4870cec09b
7 changed files with 199 additions and 53 deletions

View File

@ -71,13 +71,19 @@ def solve_route(req: dict) -> dict:
except Exception:
return 1.0
def skill_ok(veh, sk):
return (not sk) or (sk in (veh.get("skills") or []))
def skill_ok(veh, job):
# job = dict (préféré : liste `skills`, sinon `skill` unique) OU chaîne (rétro-compat). Le tech doit avoir TOUTES les compétences requises.
if isinstance(job, str):
req = [job] if job else []
else:
req = (job.get("skills") or ([job["skill"]] if job.get("skill") else []))
vs = veh.get("skills") or []
return all(s in vs for s in req)
# Pré-filtre : un job sans AUCUN tech compétent est non planifiable → hors modèle (évite les "allowed" vides).
jobs, unservable = [], []
for j in all_jobs:
if any(skill_ok(v, j.get("skill")) for v in vehicles):
if any(skill_ok(v, j) for v in vehicles):
jobs.append(j)
else:
unservable.append(j["id"])
@ -186,9 +192,8 @@ def solve_route(req: dict) -> dict:
# URGENCE → servir TÔT : coût = uw × heure d'arrivée (borne souple à 0) → tire le job en début de tournée
# (conditionne le début de journée du tech). Reste souple : ne casse pas la faisabilité, se combine aux fenêtres AM/PM.
time_dim.SetCumulVarSoftUpperBound(idx, 0, uw)
sk = j.get("skill")
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, sk)]
if len(allowed) < n_veh: # compétence restreint réellement → filtre DUR
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, j)]
if len(allowed) < n_veh: # compétence(s) restreignent réellement → filtre DUR
sv.Add(sv.MemberCt(routing.VehicleVar(idx), allowed + [-1]))
routing.AddDisjunction([idx], base_pen + int(j.get("priority_boost") or 0)) # laisser tomber coûte cher → préfère assigner

View File

@ -775,6 +775,7 @@ async function logToLinkedTicket (conv, msg, subject) {
communication_type: 'Communication', communication_medium: 'Email',
sent_or_received: sent ? 'Sent' : 'Received',
sender: sent ? (cfg.MAIL_FROM || 'cc@targointernet.com') : (conv.email || ''),
sender_full_name: sent ? (msg.agentName || 'Targo Ops') : (conv.customerName || ''), // sortie → prénom+nom de l'agent (jamais le login « louis »)
recipients: sent ? (conv.email || '') : (cfg.MAIL_FROM || 'cc@targointernet.com'),
subject: 'Re: ' + (subject || conv.lastSubject || conv.subject || ''),
content: msg.html || (msg.text || '').replace(/\n/g, '<br>'),
@ -786,7 +787,7 @@ async function logToLinkedTicket (conv, msg, subject) {
// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
async function emailTicket ({ issue, to, cc, message, agent, link } = {}) {
async function emailTicket ({ issue, to, cc, message, agent, agentName, link } = {}) {
const iss = String(issue || '').trim()
const dest = String(to || '').trim()
if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
@ -802,7 +803,7 @@ async function emailTicket ({ issue, to, cc, message, agent, link } = {}) {
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
let html = esc(text).replace(/\n/g, '<br>')
if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', toEmail: dest, ccRaw: cc || '' })
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', agentName: agentName || '', toEmail: dest, ccRaw: cc || '' })
const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
await logToLinkedTicket(conv, msg, subject).catch(() => {})
saveToDisk()
@ -1311,7 +1312,7 @@ async function handle (req, res, method, p, url) {
// ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
if (p === '/conversations/ticket-email' && method === 'POST') {
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '', agentName: (b && b.agentName) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).

View File

@ -133,7 +133,7 @@ const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed star
const SLOT_HORIZON_DAYS = 7
const SLOT_MAX_PER_TECH = 2
async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '' } = {}) {
async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '', ignoreReserved = false } = {}) {
const baseDate = after_date || todayET()
const duration = parseFloat(duration_h) || 1
const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i))
@ -152,7 +152,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
['scheduled_date', '<=', dates[dates.length - 1]],
]))}&fields=${encodeURIComponent(JSON.stringify([
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h',
'longitude', 'latitude',
'longitude', 'latitude', 'job_type',
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
@ -195,7 +195,8 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
// without a time are ignored since we don't know when they'll land).
const dayJobs = allJobs
.filter(j => j.assigned_tech === tech.technician_id &&
j.scheduled_date === dateStr && j.start_time)
j.scheduled_date === dateStr && j.start_time &&
!(ignoreReserved && j.job_type === 'Réservation')) // mode urgence/réparation → les blocs RÉSERVÉS (soft) ne bloquent pas
.map(j => {
const s = timeToHours(j.start_time)
return {

View File

@ -30,6 +30,24 @@ const muni = require('./municipality') // résolveur de municipalités QC (centr
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
const audit = require('./dispatch-audit') // journal d'audit d'assignation (append-only JSONL)
const fs = require('fs')
// Décodeur d'entités HTML UNIQUE (osTicket stocke les sujets encodés : &#039; &eacute; &amp; …). Appliqué à l'ÉCRITURE
// du sujet des jobs → la donnée stockée est propre partout (Dispatch, Planification, app terrain, rapports), pas de
// rustine d'affichage par surface. 2 passes = gère le double-encodage (&amp;#039;). Remplace 3 closures `decode` qui
// divergeaient (l'une gérait &quot;, pas les autres) — source exacte des « codes HTML par endroits ».
const NAMED_ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë', agrave: 'à', acirc: 'â', agrave2: 'à', ccedil: 'ç', ugrave: 'ù', ucirc: 'û', uuml: 'ü', icirc: 'î', iuml: 'ï', ocirc: 'ô', ouml: 'ö', auml: 'ä', laquo: '«', raquo: '»', hellip: '…', rsquo: '', lsquo: '', ldquo: '“', rdquo: '”', ndash: '', mdash: '—', deg: '°', euro: '€', copy: '©', reg: '®', trade: '™' }
function decodeEntities (s) {
let v = String(s == null ? '' : s)
if (v.indexOf('&') < 0) return v
for (let i = 0; i < 2 && v.indexOf('&') >= 0; i++) {
const nv = v
.replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)) } catch (e) { return '' } })
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return '' } })
.replace(/&([a-z]+\d*);/gi, (m, name) => (Object.prototype.hasOwnProperty.call(NAMED_ENT, name.toLowerCase()) ? NAMED_ENT[name.toLowerCase()] : m))
if (nv === v) break
v = nv
}
return v
}
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
@ -58,10 +76,14 @@ async function resolveActorStaff (actorEmail) {
return 0
}
// Poster une note/réponse au fil d'un ticket legacy via ops_reassign.php action=post. public=false (défaut) = note INTERNE (jamais au client) · true = visible.
async function postTicketLegacy (ticketId, msg, isPublic, actorEmail) {
async function postTicketLegacy (ticketId, msg, isPublic, actorEmail, actorName) {
if (!ticketId || !String(msg || '').trim()) return { ok: false, error: 'ticket + msg requis' }
const actorStaff = await resolveActorStaff(actorEmail)
const w = await legacyWrite({ action: 'post', ticket_id: ticketId, msg: String(msg), public: isPublic ? 1 : '', actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
// Attribution : si l'acteur a un compte staff osTicket (courriel reconnu) → la note porte SON nom automatiquement.
// Sinon (ex. tech terrain sans compte legacy) on identifie l'auteur dans le corps pour ne PAS afficher « Tech Targo ».
let body = String(msg)
if (!actorStaff && actorName) body = actorName + ' :\n' + body
const w = await legacyWrite({ action: 'post', ticket_id: ticketId, msg: body, public: isPublic ? 1 : '', actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
return (w && w.data) || { ok: false, error: 'no response' }
}
// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
@ -431,7 +453,7 @@ async function buildJob (t) {
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
const addr = svcAddr || billAddr
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
let subject = decodeEntities(t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
@ -942,7 +964,7 @@ async function ticketThread (legacyId) {
fromClient: !r.staff_id, // staff_id 0/null = message du client (utile pour le fil fusionné P3/P4)
text: stripHtml(r.msg, 6000),
})).filter(m => m.text)
return { ok: true, ticket: id, subject: t.subject || '', status: t.status || '', contact, count: messages.length, messages }
return { ok: true, ticket: id, subject: decodeEntities(t.subject || ''), status: t.status || '', contact, count: messages.length, messages }
}
// Fil + CONTACT d'un Dispatch Job, source unifiée : osTicket si legacy_ticket_id, SINON fiche client ERPNext (jobs natifs ex. FR-…).
@ -967,7 +989,7 @@ async function jobThread (jobName) {
text: String(m.text || (m.html ? String(m.html).replace(/<[^>]+>/g, ' ') : '')).replace(/\s+/g, ' ').trim(),
})).filter(m => m.text)
} catch (e) {}
return { ok: true, ticket: null, subject: job.subject || '', status: '', contact, count: messages.length, messages, source: 'erpnext' }
return { ok: true, ticket: null, subject: decodeEntities(job.subject || ''), status: '', contact, count: messages.length, messages, source: 'erpnext' }
}
// ── VEILLE Legacy → Ops (poll léger → SSE) ──
@ -1172,7 +1194,7 @@ async function legacyTechTickets (staffId) {
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id
WHERE t.status = 'open' AND t.assign_to = ?
ORDER BY (t.due_date IS NULL OR t.due_date = 0), t.due_date ASC LIMIT 400`, [sid])
const decode = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return '' } })
const decode = decodeEntities
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
const all = (rows || []).map(r => {
const subject = decode(r.subject)
@ -1233,7 +1255,7 @@ async function legacyWindowLoad (start, days) {
const isoSet = new Set(isos)
const startUnix = Math.floor(Date.parse(start + 'T00:00:00Z') / 1000)
const lo = startUnix - 2 * 86400, hi = startUnix + (n + 2) * 86400
const decode = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
const decode = decodeEntities
// Tickets lus sur F LIVE via le pont PHP (le miroir n'est PAS rafraîchi pour `ticket` → périmé). Repli miroir si pont indispo.
let rows = []
try {
@ -1593,7 +1615,7 @@ async function ticketLookup (id) {
let acc = null; if (t.account_id) { try { const [a] = await p.query('SELECT id, first_name, last_name, company FROM account WHERE id=?', [t.account_id]); acc = a[0] || null } catch (e) {} }
let phone = null; if (!t.account_id || !dv) { try { const pm = await accountByPhone(p, t.subject); if (pm) phone = { account_id: pm.account_id, customer: pm.customer_name, address: [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ') } } catch (e) {} }
let dj = null; try { const x = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(tid)]], fields: ['name', 'status', 'latitude', 'longitude', 'address', 'assigned_tech', 'customer', 'scheduled_date'], limit: 1 }); dj = x[0] || null } catch (e) {}
return { ok: true, found: true, ticket: { id: t.id, subject: t.subject, status: t.status, account_id: t.account_id || 0, delivery_id: t.delivery_id || 0, assign_to: t.assign_to || 0 }, account: acc, service_address: dv, phone_match: phone, dispatch_job: dj }
return { ok: true, found: true, ticket: { id: t.id, subject: decodeEntities(t.subject), status: t.status, account_id: t.account_id || 0, delivery_id: t.delivery_id || 0, assign_to: t.assign_to || 0 }, account: acc, service_address: dv, phone_match: phone, dispatch_job: dj }
}
// BACKFILL de la trace tech perdue : les Dispatch Jobs issus du pont SANS assigned_tech (annulés/terminés « Non
@ -1787,7 +1809,7 @@ async function handle (req, res, method, path) {
async function reconcileLegacyJobs (opts = {}) {
const apply = opts.confirm === 'RECONCILE'
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const decode = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
const decode = decodeEntities
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const techToStaff = {}; const staffToTech = {}
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id }
@ -1837,4 +1859,4 @@ async function reconcileLegacyJobs (opts = {}) {
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
}
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests

View File

@ -46,6 +46,18 @@ function setJobLevel (name, level) {
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
}
// Compétences REQUISES par job = LISTE (store hub durable, comme les niveaux). `required_skill` n'est PAS un champ ERPNext (enrichi).
// Le solveur exige que le tech ait TOUTES les compétences de la liste ; la 1re = principale (affichage/couleur/icône). Vide → repli dépt.
const JOB_SKILLS_FILE = path.join(__dirname, '..', 'data', 'job-skills.json')
function getJobSkillsMap () { try { return JSON.parse(fs.readFileSync(JOB_SKILLS_FILE, 'utf8')) || {} } catch { return {} } }
function getJobSkills (name) { const v = getJobSkillsMap()[String(name)]; return Array.isArray(v) ? v.filter(Boolean) : [] }
function setJobSkills (name, list) {
const m = getJobSkillsMap()
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))]
if (arr.length) m[String(name)] = arr; else delete m[String(name)]
try { fs.mkdirSync(path.dirname(JOB_SKILLS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_SKILLS_FILE, JSON.stringify(m)); return m
}
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau.
const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json')
@ -187,9 +199,12 @@ async function optimizePlan (body) {
const prBoost = p => (p === 'urgent' || p === 'high') ? 400000 : (p === 'low' ? -60000 : 0)
const prEarly = p => (p === 'urgent' || p === 'high') ? 10 : 0
const techs = techsAll.filter(t => !wantTech || wantTech.has(t.id))
// Compétences requises d'un job (LISTE) ; `required_skill` = principale (1re). techCovers = le tech les a TOUTES.
const skillsOf = j => (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills.filter(Boolean) : (j.required_skill ? [j.required_skill] : [])
const techCovers = (t, j) => { const req = skillsOf(j); return !req.length || (t && req.every(s => (t.skills || []).includes(s))) }
const entryOf = (j, iso, dur) => ({
jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '',
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', reqLevel: Number(j.required_level) || 1,
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', skills: skillsOf(j), reqLevel: Number(j.required_level) || 1,
lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null,
priority: String(j.priority || '').toLowerCase(), remote: !!j.remote, parent: j.parent_job || null,
})
@ -230,7 +245,7 @@ async function optimizePlan (body) {
if (hit) {
const t = techs.find(x => x.id === hit.techId)
const dur = durOf(j)
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: !j.required_skill || !t || (t.skills || []).includes(j.required_skill) })
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(t, j) })
usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60)
} else rest.push(j)
}
@ -257,7 +272,8 @@ async function optimizePlan (body) {
lat: (j0.latitude != null && isFinite(+j0.latitude)) ? +j0.latitude : null,
lon: (j0.longitude != null && isFinite(+j0.longitude)) ? +j0.longitude : null,
service_min: Math.max(5, Math.round(g.reduce((s, j) => s + durOf(j), 0) * 60)),
skill: (g.find(j => j.required_skill) || {}).required_skill || '',
skill: (g.find(j => j.required_skill) || {}).required_skill || '', // principale (rang spécialiste)
skills: [...new Set(g.flatMap(j => skillsOf(j)))], // UNION des compétences requises du groupe → le tech doit TOUTES les avoir
priority_boost: Math.max(...prios.map(prBoost)),
urgent_weight: Math.max(...prios.map(prEarly)),
...(w ? { tw_start_min: w.tw_start_min, tw_end_min: w.tw_end_min } : {}),
@ -271,7 +287,7 @@ async function optimizePlan (body) {
for (const st of (rt.stops || [])) {
const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue
placed.add(st.job_id)
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: !j.required_skill || (t.skills || []).includes(j.required_skill), sameAddrN: g.length })
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(t, j), sameAddrN: g.length })
}
}
for (const uid of (res.unassigned || [])) {
@ -419,6 +435,7 @@ async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM'
const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' }))
const name = (r && (r.name || (r.data && r.data.name))) || null
if (name) cache[key] = name
else log('[materialize] ensureShiftTemplate FAIL ' + key + ' → ' + JSON.stringify(r).slice(0, 220))
return name
}
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
@ -430,7 +447,8 @@ async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } =
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
const tplCache = {}
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0, tplNull = 0, createFail = 0
log('[materialize] start=' + start + ' days=' + days + ' techs=' + techs.length + '/' + techsAll.length + ' avec_patron dryRun=' + dryRun)
for (const t of techs) {
const sched = t.weekly_schedule || {}
for (const iso of dates) {
@ -445,17 +463,33 @@ async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } =
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
continue
}
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) continue
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) { tplNull++; continue }
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
} else if (dryRun) created++
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++ }
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) } }
}
}
return { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation }
const result = { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation, tpl_null: tplNull, create_fail: createFail }
log('[materialize] done ' + JSON.stringify(result))
return result
}
// CRON : matérialise les quarts récurrents (weekly_schedule → Shift Assignment) sur un HORIZON GLISSANT.
// Sans ça, l'horaire récurrent n'existe que pour la fenêtre capturée au clic (= le bug « n'apparaît pas dans le roster »).
// Idempotent (saute les lignes pattern déjà bonnes, respecte les overrides manuels). Désactivable : SHIFT_MATERIALIZE_CRON=off.
let _matTimer = null
function startShiftMaterializer () {
if (String(process.env.SHIFT_MATERIALIZE_CRON || 'on').toLowerCase() === 'off') { log('[materialize] cron désactivé (SHIFT_MATERIALIZE_CRON=off)'); return }
const run = () => materializeShifts({ dryRun: false, weeks: 6 })
.then(r => log('[materialize] cron ' + JSON.stringify({ techs: r.techs, created: r.created, updated: r.updated, deleted: r.deleted, fail: r.create_fail })))
.catch(e => log('[materialize] cron error: ' + e.message))
setTimeout(run, 90 * 1000) // 1er passage ~90 s après le boot (ERPNext prêt)
_matTimer = setInterval(run, 24 * 60 * 60 * 1000) // quotidien → horizon 6 semaines qui avance
log('[materialize] cron armé (quotidien, horizon 6 semaines)')
}
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
@ -683,8 +717,13 @@ async function buildUnassigned () {
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech)
const chars = readJobChar().items
const jlv = getJobLevels(); const jrm = getJobRemote()
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
const jlv = getJobLevels(); const jrm = getJobRemote(); const jsk = getJobSkillsMap()
for (const j of jobs) {
const ov = jsk[j.name]; const derived = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject)
j.required_skills = (Array.isArray(ov) && ov.length) ? ov.filter(Boolean) : (derived ? [derived] : []) // LISTE (override store, sinon dérivé)
j.required_skill = j.required_skills[0] || '' // principale = 1re (affichage/couleur/icône + rétro-compat)
j.required_level = jlv[j.name] || 0; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels
}
await attachLocations(jobs)
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
return jobs
@ -941,21 +980,36 @@ async function handleFieldTech (req, res, method, path, url) {
const isPublic = !!b.public
let posted = false, sentToCustomer = false
try {
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id'] })
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id', 'assigned_tech'] })
const lid = j && j.legacy_ticket_id
// Note au nom du TECHNICIEN (pas « Tech Targo »). Courriel → compte staff osTicket si présent (auteur = son nom) ;
// nom → repli dans le corps si aucun compte legacy. Le technicien = celui assigné au job.
let techEmail = '', techName = ''
if (j && j.assigned_tech) { try { const tr = await erp.list('Dispatch Technician', { filters: [['name', '=', j.assigned_tech]], fields: ['full_name', 'email'], limit: 1 }); if (tr && tr[0]) { techEmail = tr[0].email || ''; techName = tr[0].full_name || '' } } catch (e) {} }
if (lid) {
// Fil legacy (osTicket) = ce que le volet job affiche. public=1 → réponse publique (courriel client) ; sinon note interne.
const msg = isPublic ? text : ('🔧 [Note tech — interne]\n' + text)
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, msg, isPublic, '')
// Fil legacy (osTicket) = ce que le volet job affiche. Corps = le texte brut du tech (aucune étiquette « [Note tech] » — bruit inutile) :
// note interne par défaut, publique (courriel client) si coché. L'attribution se fait par l'auteur, pas par un préfixe.
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, text, isPublic, techEmail, techName)
posted = !!(r && r.ok !== false); sentToCustomer = isPublic && posted
} else {
// Job natif (pas de ticket legacy) → Comment ERPNext sur le Dispatch Job.
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: name, content: (isPublic ? '📣 [Public] ' : '🔧 [Note tech] ') + text })
// Job natif (pas de ticket legacy) → Comment ERPNext sur le Dispatch Job, préfixé du nom du tech (l'auteur ERPNext = compte service).
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: name, content: (techName ? techName + ' : ' : '') + text })
posted = true
}
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
return json(res, 200, { ok: posted, public: isPublic, sentToCustomer })
}
// Lire le FIL du ticket depuis l'app terrain (near-realtime : le client poll pendant que le panneau est ouvert).
if (path === '/field/thread' && method === 'GET') {
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
try {
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id'] })
const lid = j && j.legacy_ticket_id
if (!lid) return json(res, 200, { ok: true, messages: [] })
const th = await require('./legacy-dispatch-sync').ticketThread(lid)
return json(res, 200, (th && th.ok) ? { ok: true, subject: th.subject, status: th.status, messages: th.messages || [] } : { ok: false, error: (th && th.error) || 'fil indisponible' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
@ -1542,6 +1596,52 @@ async function handle (req, res, method, path, url) {
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
}
if (path === '/roster/job-skills' && method === 'POST') { // compétences requises PERSISTANTES par job (LISTE, store hub) → dispatch auto
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir
return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] })
}
// Répondre au fil du billet DEPUIS LE VOLET JOB (dispatcher authentifié) — même mécanique que l'app terrain, mais l'acteur = l'agent connecté.
// Note INTERNE par défaut (jamais au client) ; publique (courriel client) SEULEMENT si public=true.
// #5 — Bloc RÉSERVÉ : réserve le temps d'un tech pour une tâche/projet = Dispatch Job job_type='Réservation', priorité MOYENNE,
// assigné, avec heure+durée → occupe l'occupation ⇒ dé-priorise le tech au dispatch auto ET soustrait des créneaux. « Soft » :
// la recherche de créneau en mode urgence/réparation IGNORE ces blocs (dispatch.suggestSlots ignoreReserved) → réparations/urgences passent.
if (path === '/roster/reserve' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { ok: false, error: 'tech + date requis' })
try {
const ref = await require('./dispatch').nextJobRef()
const payload = {
ticket_id: ref, subject: b.reason ? ('Réservé — ' + String(b.reason).slice(0, 80)) : 'Réservé (indisponible au dispatch)',
job_type: 'Réservation', priority: 'medium', status: 'assigned',
assigned_tech: b.tech, scheduled_date: b.date,
start_time: b.start_time || '08:00', duration_h: Number(b.duration_h) || 2,
}
const r = await retryWrite(() => erp.create('Dispatch Job', payload))
if (r && r.ok !== false) { invalidatePool(); return json(res, 200, { ok: true, name: r.name || (r.data && r.data.name), ref }) }
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if (path === '/roster/job-comment' && method === 'POST') {
const b = await parseBody(req)
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })
const isPublic = !!b.public
const actorEmail = req.headers['x-authentik-email'] || b.agent || '' // attribution : l'agent connecté (→ compte staff osTicket = son nom)
const actorName = b.agentName || ''
try {
let lid = b.lid || null
if (!lid && b.job) { const j = await erp.get('Dispatch Job', b.job, { fields: ['legacy_ticket_id'] }); lid = j && j.legacy_ticket_id }
if (lid) {
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, text, isPublic, actorEmail, actorName)
return json(res, 200, { ok: !!(r && r.ok !== false), public: isPublic, sentToCustomer: isPublic })
}
if (b.job) { // job natif sans billet legacy → Comment ERPNext (préfixé du nom de l'agent, l'auteur ERPNext = compte service)
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: b.job, content: (actorName ? actorName + ' : ' : '') + text })
return json(res, 200, { ok: true, public: isPublic })
}
return json(res, 400, { ok: false, error: 'job ou lid requis' })
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
}
if (path === '/roster/job-remote' && method === 'POST') { // 🖥 « sans déplacement » (configurable à distance, ex. boîtier tel via ACS) — exclu des tournées
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
setJobRemote(b.name, !!b.remote); invalidatePool() // le pool porte j.remote → rafraîchir
@ -1782,22 +1882,30 @@ async function handle (req, res, method, path, url) {
const keyOf = (a) => a.tech + '|' + a.date + '|' + a.shift
const existByKey = {}; for (const a of existing) existByKey[keyOf(a)] = a
const desiredKeys = new Set(desired.map(keyOf))
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0
// #2/#3 — statut par mode : draft(Proposé, auto-save, 0 SMS · ne touche pas l'existant) · submit(Soumis) · approve(Approuvé) · publish(Publié + SMS).
const STATUS_BY_MODE = { draft: 'Proposé', submit: 'Soumis', approve: 'Approuvé', publish: 'Publié' }
const mode = STATUS_BY_MODE[b.mode] ? b.mode : 'publish'
const targetStatus = STATUS_BY_MODE[mode]
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0; let promoted = 0
for (const a of existing) { // supprimer ceux qui ne sont plus voulus
if (desiredKeys.has(keyOf(a))) continue
const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++
}
for (const a of desired) { // créer seulement les nouveaux ; ignorer les inchangés
if (existByKey[keyOf(a)]) { unchanged++; continue }
for (const a of desired) {
const ex = existByKey[keyOf(a)]
if (ex) { // draft = laisser tel quel ; submit/approve/publish = faire MONTER le statut (jamais rétrograder un Publié)
if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, { status: targetStatus })); if (r.ok) promoted++; else errors++ } else unchanged++
continue
}
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: 'Publié', source: a.source || 'solveur',
status: (mode === 'draft' ? 'Proposé' : targetStatus), source: a.source || 'manuel',
}))
if (r.ok) created++; else errors++
}
let notified = 0
if (b.notify && created) { // SMS opt-in aux techs (Twilio) — non bloquant
if (mode === 'publish' && b.notify && (created || promoted)) { // SMS opt-in aux techs (Twilio) — SEULEMENT à la publication, jamais en auto-save brouillon
try {
const techs = await fetchTechnicians()
const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
@ -1812,7 +1920,7 @@ async function handle (req, res, method, path, url) {
}
} catch (e) { /* notif non bloquante */ }
}
return json(res, 200, { ok: errors === 0, created, deleted, errors, notified, unchanged })
return json(res, 200, { ok: errors === 0, mode, created, deleted, promoted, errors, notified, unchanged })
}
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
@ -1982,4 +2090,4 @@ async function handle (req, res, method, path, url) {
return json(res, 404, { error: 'roster: route inconnue ' + path })
}
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots, startShiftMaterializer }

View File

@ -53,11 +53,13 @@
<button onclick=openScan()>📦 Appareil</button>
</div>
<div id=cmtbox style="display:none;margin-top:8px;background:#0f172a;border:1px solid #334155;border-radius:10px;padding:10px;text-align:left">
<textarea id=cmtxt placeholder="Note / commentaire pour ce ticket…" style="width:100%;box-sizing:border-box;min-height:70px;border-radius:8px;border:1px solid #475569;background:#1e293b;color:#e2e8f0;padding:8px;font-size:15px"></textarea>
<div style="font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:4px">💬 Fil du ticket</div>
<div id=cmthread style="max-height:210px;overflow:auto;margin-bottom:8px;font-size:13px;line-height:1.4;color:#e2e8f0">Chargement…</div>
<textarea id=cmtxt placeholder="Écrire une note / un commentaire…" style="width:100%;box-sizing:border-box;min-height:64px;border-radius:8px;border:1px solid #475569;background:#1e293b;color:#e2e8f0;padding:8px;font-size:15px"></textarea>
<label style="display:flex;align-items:center;gap:6px;margin:8px 0;font-size:13px;color:#cbd5e1"><input type=checkbox id=cmpub> 📣 Envoyer aussi au client (réponse publique)</label>
<div style="display:flex;gap:8px">
<button onclick=sendComment() style="flex:1;background:#6366f1">Ajouter au ticket</button>
<button onclick="document.getElementById('cmtbox').style.display='none'" style="flex:0 0 90px;background:#475569">Annuler</button>
<button onclick=sendComment() style="flex:1;background:#6366f1;color:#fff;font-weight:700;font-size:15px;padding:13px;border:0;border-radius:10px">Ajouter au ticket</button>
<button onclick=closeComment() style="flex:0 0 96px;background:#475569;color:#fff;font-weight:600;font-size:15px;padding:13px;border:0;border-radius:10px">Fermer</button>
</div>
</div>
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
@ -83,8 +85,11 @@ const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=nul
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
function badge(j){return j.ended?'<span class="badge b-done">terminé'+(j.minutes!=null?' · '+j.minutes+'m':'')+'</span>':j.started?'<span class="badge b-run">en cours</span>':'<span class="badge b-todo">à faire</span>'}
async function api(p,o){const r=await fetch(HUB+p,o);return r.json()}
function openComment(){var b=$('cmtbox');b.style.display=(b.style.display==='none'||!b.style.display)?'block':'none';if(b.style.display==='block')$('cmtxt').focus()}
async function sendComment(){var t=($('cmtxt').value||'').trim();if(!t){toast('Écris un commentaire');return}var pub=$('cmpub').checked;var r=await api('/field/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,text:t,public:pub})}).catch(function(){return{ok:false}});if(r&&r.ok){$('cmtxt').value='';$('cmpub').checked=false;$('cmtbox').style.display='none';toast(pub?(r.sentToCustomer?'✓ Ajouté au ticket + envoyé au client':'✓ Ajouté (client non joignable)'):'✓ Note interne ajoutée au ticket')}else{toast('Échec — réessaie')}}
var _cmPoll=null;
function openComment(){var b=$('cmtbox');var show=(b.style.display==='none'||!b.style.display);if(show){b.style.display='block';$('cmtxt').focus();loadThread();if(_cmPoll)clearInterval(_cmPoll);_cmPoll=setInterval(loadThread,15000)}else{closeComment()}}
function closeComment(){$('cmtbox').style.display='none';if(_cmPoll){clearInterval(_cmPoll);_cmPoll=null}}
async function loadThread(){if(!CUR||!CUR.token)return;var r=await api('/field/thread?token='+encodeURIComponent(CUR.token)).catch(function(){return{ok:false}});var el=$('cmthread');if(!el)return;if(!r||!r.ok){el.innerHTML='<div style="color:#94a3b8">Fil indisponible</div>';return}var ms=r.messages||[];if(!ms.length){el.innerHTML='<div style="color:#94a3b8">Aucun message pour l\'instant.</div>';return}el.innerHTML=ms.slice(-40).map(function(m){var d=m.at?new Date(m.at).toLocaleString('fr-CA',{dateStyle:'short',timeStyle:'short'}):'';var txt=esc(String(m.text||m.msg||'').replace(/<[^>]+>/g,' ').replace(/&nbsp;/g,' ').trim()).slice(0,700);var who=m.fromClient?'<span style="color:#fbbf24;font-size:10px;font-weight:700">CLIENT</span> ':'';return '<div style="border-bottom:1px solid #1e293b;padding:5px 0">'+who+'<b style="color:#a5b4fc">'+esc(m.author||'—')+'</b> <span style="color:#64748b;font-size:11px">'+d+'</span><br><span style="white-space:pre-wrap">'+txt+'</span></div>'}).join('');el.scrollTop=el.scrollHeight}
async function sendComment(){var t=($('cmtxt').value||'').trim();if(!t){toast('Écris un commentaire');return}var pub=$('cmpub').checked;var r=await api('/field/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,text:t,public:pub})}).catch(function(){return{ok:false}});if(r&&r.ok){$('cmtxt').value='';$('cmpub').checked=false;toast(pub?(r.sentToCustomer?'✓ Ajouté + envoyé au client':'✓ Ajouté (client non joignable)'):'✓ Note interne ajoutée');loadThread()}else{toast('Échec — réessaie')}}
function dist(a1,o1,a2,o2){const R=6371000,t=Math.PI/180,dA=(a2-a1)*t,dO=(o2-o1)*t;const x=Math.sin(dA/2)**2+Math.cos(a1*t)*Math.cos(a2*t)*Math.sin(dO/2)**2;return Math.round(R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)))}
async function load(){
const r=await api('/field/tech?t='+encodeURIComponent(T)).catch(()=>({}));
@ -98,15 +103,16 @@ function renderList(){
if(!JOBS.length){$('jobs').innerHTML='Aucune intervention.';return}
$('jobs').innerHTML='';
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(j.subject||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(j.address||'')+'</div></div>'+badge(j);
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(dec(j.subject)||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(dec(j.address))+'</div></div>'+badge(j);
$('jobs').appendChild(d)});
}
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}
function dec(s){var v=String(s||'');if(v.indexOf('&')<0)return v;for(var i=0;i<2;i++){var t=document.createElement('textarea');t.innerHTML=v;if(t.value===v)break;v=t.value}return v} // décode les entités HTML (&#039; &eacute; ) venues d'osTicket AVANT affichage
function showList(){if(watch){navigator.geolocation.clearWatch(watch);watch=null}if(map){map.remove();map=null}$('detail').style.display='none';$('list').style.display='block';load()}
function openDetail(i){
CUR=JOBS[i];enteredZone=false;$('list').style.display='none';$('detail').style.display='block';
$('dsub').textContent=CUR.subject||'Job';$('dcust').textContent=CUR.customer||'';$('daddr').textContent='📍 '+(CUR.address||'(adresse manquante)');
var dn=$('dnote'),nt=(CUR.notes||'').trim(),sk=CUR.skill||'';dn.innerHTML=(nt?('<b>📌 Note importante</b><br>'+esc(nt)):'')+(sk?('<div class=skl>🔧 '+esc(sk)+'</div>'):'');dn.style.display=(nt||sk)?'block':'none';
$('dsub').textContent=dec(CUR.subject)||'Job';$('dcust').textContent=dec(CUR.customer);$('daddr').textContent='📍 '+(dec(CUR.address)||'(adresse manquante)');
var dn=$('dnote'),nt=dec(CUR.notes).trim(),sk=dec(CUR.skill);dn.innerHTML=(nt?('<b>📌 Note importante</b><br>'+esc(nt)):'')+(sk?('<div class=skl>🔧 '+esc(sk)+'</div>'):'');dn.style.display=(nt||sk)?'block':'none';
$('dbadge').innerHTML=badge(CUR);
const hasLL=CUR.lat&&Math.abs(+CUR.lat)>0.01;
$('ditin').href=hasLL?('https://www.google.com/maps/dir/?api=1&destination='+CUR.lat+','+CUR.lon):'#';

View File

@ -382,6 +382,9 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
try { require('./lib/legacy-dispatch-sync').startSync() }
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
// Matérialisation des quarts récurrents (weekly_schedule → Shift Assignment) sur horizon glissant 6 sem. Désactivable SHIFT_MATERIALIZE_CRON=off.
try { require('./lib/roster').startShiftMaterializer() }
catch (e) { log('shift materializer failed to start:', e.message) }
// Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
try { require('./lib/gmail').start() }
catch (e) { log('gmail ingest failed to start:', e.message) }