diff --git a/services/roster-solver/route_solver.py b/services/roster-solver/route_solver.py
index b07a582..a0c05b3 100644
--- a/services/roster-solver/route_solver.py
+++ b/services/roster-solver/route_solver.py
@@ -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
diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js
index 2022b9e..1bea27c 100644
--- a/services/targo-hub/lib/conversation.js
+++ b/services/targo-hub/lib/conversation.js
@@ -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, '
'),
@@ -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, '&').replace(//g, '>')
let html = esc(text).replace(/\n/g, '
')
if (link) html += `
— Ouvrir le ticket ${esc(iss)} dans OPS`
- 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).
diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js
index 01e450c..baea30e 100644
--- a/services/targo-hub/lib/dispatch.js
+++ b/services/targo-hub/lib/dispatch.js
@@ -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 {
diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js
index 56461e9..6ca9112 100644
--- a/services/targo-hub/lib/legacy-dispatch-sync.js
+++ b/services/targo-hub/lib/legacy-dispatch-sync.js
@@ -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 : ' é & …). 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 ('). Remplace 3 closures `decode` qui
+// divergeaient (l'une gérait ", 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(/([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(/?39;/g, "'").replace(/&/g, '&').replace(/"/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(/?39;/g, "'").replace(/&/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(/?39;/g, "'").replace(/&/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
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 2aca37f..94be724 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -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 }
diff --git a/services/targo-hub/public/field-app.html b/services/targo-hub/public/field-app.html
index 609eda8..2b8ac9a 100644
--- a/services/targo-hub/public/field-app.html
+++ b/services/targo-hub/public/field-app.html
@@ -53,11 +53,13 @@