feat(dispatch): write-back unifie Publier vers F + tickets perimes natifs
Hub (legacy-dispatch-sync.js): - publishPreview() + GET /dispatch/legacy-sync/publish-preview: apercu unifie OPS->F (assignation + fermeture + horaire) + avertissements tech non mappe = la liste des changements avant du bouton. Lecture seule. - publishJob() + GET|POST /dispatch/legacy-sync/publish-job: apply par job (GET=plan dry-run, POST=applique) via ops_reassign.php. - staleTickets() + GET /dispatch/legacy-sync/stale: tickets ouverts sans activite >=N j (natif, remplace le courriel stale de F); scope buckets/maxAge en SQL. - staleNudge() + GET|POST /stale-nudge + digest planifie opt-in (LEGACY_STALE_NUDGE), anti-spam persiste, nudge via alertWebhook. Bridge F (ops_reassign.php): - action schedule: write-back due_date/due_time (horaire OPS->F). Verifie en prod (lecture seule + dry-run; aucune ecriture F reelle tiree). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
956e96b8bc
commit
e43f04d3e5
|
|
@ -8,6 +8,7 @@
|
|||
* action=reassign ticket_id, staff_id, [assist_ids=CSV] [notify=1] [address] → assign_to + participant(assistants) + followed_by + ticket_msg
|
||||
* notify=1 → courriel d'assignation au tech (résumé + lien Répondre + Adresse), via PHPMailer6 OAuth2 (ops_secret.php)
|
||||
* action=close ticket_id → status=closed + date_closed + closed_by + réouverture enfants + ticket_msg
|
||||
* action=schedule ticket_id, due_date=<epoch>, [due_time] → due_date + due_time + ticket_msg (write-back horaire Ops→F)
|
||||
*
|
||||
* FIDÈLE à ticket_view.php : format participant `-id-;-id-`, followers JSON, log CHANGE LOG, respect du lock_name,
|
||||
* dates en epoch (time()), réouverture des tickets enfants `waiting_for` à la fermeture.
|
||||
|
|
@ -170,6 +171,21 @@ if ($action === 'close') {
|
|||
out(['ok' => true, 'action' => 'close', 'affected' => $aff]);
|
||||
}
|
||||
|
||||
// ─── HORAIRE : write-back de la date/heure planifiées dans Ops → ticket.due_date/due_time ───
|
||||
// due_date = epoch (int) OBLIGATOIRE ; due_time = 'HH:MM' | 'am' | 'pm' | 'day' (varchar, validé, sinon vidé).
|
||||
// Respecte lock/closed (préambule ci-dessus). N'écrase PAS d'autres champs. Idempotent (affected=0 si déjà à cette valeur).
|
||||
if ($action === 'schedule') {
|
||||
$due = (int)($_POST['due_date'] ?? 0);
|
||||
if ($due <= 0) out(['ok' => false, 'error' => 'due_date (epoch) requis'], 400);
|
||||
$dt = trim((string)($_POST['due_time'] ?? ''));
|
||||
if ($dt !== '' && !preg_match('/^([01]?\d|2[0-3]):[0-5]\d$/', $dt) && !in_array(strtolower($dt), ['am', 'pm', 'day'], true)) $dt = '';
|
||||
$dtEsc = $db->real_escape_string($dt);
|
||||
$db->query("UPDATE ticket SET due_date=$due, due_time='$dtEsc', last_update=$now WHERE id=$ticket AND status<>'closed'");
|
||||
$aff = $db->affected_rows;
|
||||
ops_log($db, $ticket, $logStaff, 'Horaire mis à jour via Ops : ' . date('Y-m-d', $due) . ($dt !== '' ? ' ' . $dt : '') . '.');
|
||||
out(['ok' => true, 'action' => 'schedule', 'affected' => $aff, 'due_date' => $due, 'due_time' => $dt]);
|
||||
}
|
||||
|
||||
// ─── reassign (+ assistants) ───
|
||||
$staff = (int)($_POST['staff_id'] ?? 0);
|
||||
if ($staff <= 0) out(['ok' => false, 'error' => 'staff_id requis'], 400);
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,7 @@ async function jobThread (jobName) {
|
|||
// nouvelle activité (last_update). Cadence courte (≈3 min) ≠ la sync d'import (15 min). Pas de verrou (read-only legacy+ERP).
|
||||
const _watchSnap = new Map() // ticket_id → { status, assign_to, last_update }
|
||||
let _watchTimer = null
|
||||
let _staleTimer = null
|
||||
let _lastWatch = null
|
||||
// Map staff legacy <id> → Dispatch Technician TECH-<id> (même logique que l'ingestion des tickets assignés).
|
||||
async function loadStaffToTech () {
|
||||
|
|
@ -1359,8 +1360,17 @@ function startSync () {
|
|||
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
|
||||
_watchTimer = setInterval(wtick, watchMin * 60 * 1000)
|
||||
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)
|
||||
// DIGEST « tickets périmés » (sans activité ≥N j) → nudge best-effort. OPT-IN (défaut OFF) : LEGACY_STALE_NUDGE ∈ {on,1,true}.
|
||||
// Cadence LEGACY_STALE_NUDGE_HOURS (défaut 24). Inerte si aucun webhook configuré (alertWebhook log+skip).
|
||||
if (['on', '1', 'true'].includes(String(process.env.LEGACY_STALE_NUDGE || '').toLowerCase())) {
|
||||
const staleHours = Math.max(1, Number(process.env.LEGACY_STALE_NUDGE_HOURS) || 24)
|
||||
const stick = () => { staleNudge().then(r => { if (r && (r.fresh || !r.ok)) log('stale-nudge:', JSON.stringify(r)) }).catch(e => log('stale-nudge error:', e.message)) }
|
||||
setTimeout(stick, 120 * 1000)
|
||||
_staleTimer = setInterval(stick, staleHours * 3600 * 1000)
|
||||
log(`stale-nudge: digest actif (toutes les ${staleHours}h)`)
|
||||
}
|
||||
}
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } if (_staleTimer) { clearInterval(_staleTimer); _staleTimer = null } }
|
||||
|
||||
// Jobs Legacy (osTicket) OUVERTS assignés à UN tech précis (staff_id legacy), filtrés « terrain »
|
||||
// (dépts dispatch : install/réparation/télé/téléphonie/monteur/fusion/désinstall OU subject « est. time »).
|
||||
|
|
@ -1954,6 +1964,17 @@ async function handle (req, res, method, path) {
|
|||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'GET') return json(res, 200, await purgeStaleOrphans({ dryRun: true })) // aperçu purge orphelins TT- périmés
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'POST') return json(res, 200, await purgeStaleOrphans({ dryRun: false })) // supprime
|
||||
if (path === '/dispatch/legacy-sync/watch' && method === 'GET') return json(res, 200, await watchLegacy()) // poll manuel + deltas (diffuse aussi sur SSE 'dispatch')
|
||||
if (path === '/dispatch/legacy-sync/stale' && method === 'GET') { // tickets ouverts sans activité ≥N j (défaut 3) — natif, remplace le courriel « stale » de F. LECTURE SEULE. ?days= ?limit=
|
||||
const u = new URL(req.url, 'http://localhost'); return json(res, 200, await staleTickets({ days: u.searchParams.get('days'), limit: u.searchParams.get('limit') }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/stale-nudge' && (method === 'GET' || method === 'POST')) return json(res, 200, await staleNudge()) // déclenche le digest maintenant (best-effort webhook ; anti-spam persisté)
|
||||
if (path === '/dispatch/legacy-sync/publish-preview' && method === 'GET') { // APERÇU UNIFIÉ OPS→F (assignation+fermeture+horaire) — 0 écriture. ?job=<name> pour 1 seul job.
|
||||
const jb = new URL(req.url, 'http://localhost').searchParams.get('job') || ''; return json(res, 200, await publishPreview({ job: jb }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/publish-job' && (method === 'GET' || method === 'POST')) { // GET = PLAN (dry-run, 0 écriture) · POST = APPLIQUE vers F (assign/close/schedule). ?job= ?only=assign,close,schedule ?notify=0
|
||||
const u = new URL(req.url, 'http://localhost'); const jb = u.searchParams.get('job'); if (!jb) return json(res, 400, { ok: false, error: 'job requis' })
|
||||
return json(res, 200, await publishJob({ job: jb, dryRun: method === 'GET', actorEmail: req.headers['x-authentik-email'] || '', notify: u.searchParams.get('notify') !== '0', only: u.searchParams.get('only') || null }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/tech-sync' && method === 'GET') return json(res, 200, await techSyncReport()) // rapport réconciliation techs (3 systèmes)
|
||||
if (path === '/dispatch/legacy-sync/mine-durations' && method === 'GET') return json(res, 200, await mineDurations()) // baseline durées apprises (proxy réponses staff)
|
||||
if (path === '/dispatch/legacy-sync/tech-sync/apply' && method === 'POST') { // crée les fiches Dispatch Technician choisies (ERPNext only)
|
||||
|
|
@ -2296,4 +2317,174 @@ async function backfillDependsOnImpl ({ dryRun = true, throttleMs = 40 } = {}) {
|
|||
return { ok: true, dryRun, legacy_pairs: waitMap.size, candidates: cand.length, linked, missing_job: missingJob, errors, error_samples: errSamples }
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||
// ─── TICKETS PÉRIMÉS (natif) : remplace le courriel « stale » de F ────────────────────────────────
|
||||
// Tickets legacy OUVERTS sans activité depuis N jours (défaut 3). « activité » = ticket.last_update (epoch),
|
||||
// qui ne bouge QUE sur un vrai événement F (message/réponse/statut/assignation) — nos lectures sont SELECT-only
|
||||
// → signal PROPRE (pas faussé par la sync). LECTURE SEULE (0 écriture). Borné (ORDER BY last_update ASC LIMIT).
|
||||
// buckets : sous-ensemble de {pool,tech,unassigned,other} à INCLURE (poussé en SQL → le LIMIT reste pertinent).
|
||||
// pool=Tech Targo (3301) · tech=staff mappé à un Dispatch Technician · unassigned=assign_to 0 · other=autre staff (bureau/asset).
|
||||
// maxAgeDays : borne SUPÉRIEURE d'âge (exclut les tickets abandonnés depuis des années — bruit ≠ « vient de périmer »). 0 = pas de borne.
|
||||
async function staleTickets ({ days = 3, limit = 500, buckets = null, maxAgeDays = 0 } = {}) {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const d = Math.max(1, Math.min(90, Number(days) || 3))
|
||||
const lim = Math.max(1, Math.min(2000, Number(limit) || 500))
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const cutoff = now - d * 86400
|
||||
const maxAge = Math.max(0, Number(maxAgeDays) || 0)
|
||||
const staffToTech = await loadStaffToTech()
|
||||
const techStaffIds = Object.keys(staffToTech).map(Number).filter(Boolean)
|
||||
const want = Array.isArray(buckets) ? buckets : (typeof buckets === 'string' && buckets.trim() ? buckets.split(',') : null)
|
||||
const wantSet = (want && want.length) ? new Set(want.map(s => String(s).trim()).filter(Boolean)) : null
|
||||
const where = ["t.status = 'open'", 't.last_update > 0', 't.last_update < ?']; const args = [cutoff]
|
||||
if (maxAge) { where.push('t.last_update > ?'); args.push(now - maxAge * 86400) }
|
||||
if (wantSet) {
|
||||
const ors = []
|
||||
if (wantSet.has('pool')) { ors.push('t.assign_to = ?'); args.push(TARGO_TECH_STAFF_ID) }
|
||||
if (wantSet.has('unassigned')) ors.push('t.assign_to = 0')
|
||||
if (wantSet.has('tech') && techStaffIds.length) { ors.push('t.assign_to IN (?)'); args.push(techStaffIds) }
|
||||
if (wantSet.has('other')) { ors.push('(t.assign_to > 0 AND t.assign_to NOT IN (?))'); args.push([TARGO_TECH_STAFF_ID, ...techStaffIds]) }
|
||||
if (!ors.length) return { ok: true, days: d, cutoff_iso: new Date(cutoff * 1000).toISOString(), total: 0, capped: false, buckets: { pool: 0, tech: 0, unassigned: 0, other: 0 }, tickets: [] }
|
||||
where.push('(' + ors.join(' OR ') + ')')
|
||||
}
|
||||
args.push(lim)
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id, t.subject, t.assign_to, t.last_update, t.due_date, t.priority, dd.name AS dept,
|
||||
a.first_name, a.last_name, a.company, a.city
|
||||
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 ${where.join(' AND ')}
|
||||
ORDER BY t.last_update ASC LIMIT ?`, args)
|
||||
const ids = (rows || []).map(r => String(r.id))
|
||||
let djByTk = new Map()
|
||||
if (ids.length) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', ids]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status'], limit: ids.length + 10 }); djByTk = new Map((djs || []).map(j => [String(j.legacy_ticket_id), j])) } catch (e) {} }
|
||||
const bucketCounts = { pool: 0, tech: 0, unassigned: 0, other: 0 }
|
||||
const tickets = (rows || []).map(r => {
|
||||
const at = parseInt(r.assign_to, 10) || 0
|
||||
let bucket, who
|
||||
if (at === TARGO_TECH_STAFF_ID) { bucket = 'pool'; who = 'Tech Targo (pool)' } else if (at === 0) { bucket = 'unassigned'; who = '(non assigné)' } else if (staffToTech[at]) { bucket = 'tech'; who = staffToTech[at] } else { bucket = 'other'; who = 'staff #' + at }
|
||||
bucketCounts[bucket]++
|
||||
const dj = djByTk.get(String(r.id)) || null
|
||||
return {
|
||||
ticket: String(r.id), subject: decodeEntities(r.subject), dept: r.dept || '', bucket, assignee: who,
|
||||
customer: r.company || [r.first_name, r.last_name].filter(Boolean).join(' ') || '', city: r.city || '',
|
||||
age_days: Math.floor((now - Number(r.last_update)) / 86400), last_activity: new Date(Number(r.last_update) * 1000).toISOString(),
|
||||
due: (r.due_date && Number(r.due_date) > 0) ? new Date(Number(r.due_date) * 1000).toISOString().slice(0, 10) : '', priority: Number(r.priority) || 0,
|
||||
job: dj ? dj.name : null, job_status: dj ? dj.status : null,
|
||||
reply: `https://store.targo.ca/targo/reply_ticket.php?ticket=${r.id}`,
|
||||
}
|
||||
})
|
||||
return { ok: true, days: d, cutoff_iso: new Date(cutoff * 1000).toISOString(), total: tickets.length, capped: tickets.length >= lim, buckets: bucketCounts, tickets }
|
||||
}
|
||||
// Digest → nudge best-effort (Google Chat/n8n via coupon-triage.alertWebhook). Anti-spam : n'alerte que sur les
|
||||
// tickets NOUVELLEMENT périmés depuis le dernier digest (persistés dans /app/data/stale_nudged.json). Inerte sans webhook.
|
||||
async function staleNudge () {
|
||||
// Par défaut le nudge se limite aux tickets dispatch-pertinents (pool/tech/non-assigné) et exclut le bruit
|
||||
// « bureau/asset » (staff hors Dispatch Technician, tickets abandonnés depuis des années). Ajustable par env.
|
||||
const nbuckets = (process.env.LEGACY_STALE_BUCKETS || 'pool,tech,unassigned')
|
||||
const nmaxAge = Number(process.env.LEGACY_STALE_MAX_AGE_DAYS) || 0
|
||||
const r = await staleTickets({ days: Number(process.env.LEGACY_STALE_DAYS) || 3, limit: 1000, buckets: nbuckets, maxAgeDays: nmaxAge }).catch(e => ({ ok: false, error: e.message }))
|
||||
if (!r.ok) return r
|
||||
const FILE = '/app/data/stale_nudged.json'
|
||||
let seen = []; try { seen = JSON.parse(fs.readFileSync(FILE, 'utf8')) } catch (e) {}
|
||||
const seenSet = new Set(seen)
|
||||
const fresh = r.tickets.filter(t => !seenSet.has(t.ticket))
|
||||
try { fs.writeFileSync(FILE, JSON.stringify(r.tickets.map(t => t.ticket))) } catch (e) {} // set courant → un ticket réglé retombe hors liste et pourra ré-alerter s'il redevient périmé
|
||||
if (!fresh.length) return { ok: true, total: r.total, fresh: 0, alerted: false }
|
||||
const top = fresh.slice(0, 15)
|
||||
const lines = top.map(t => (`• #${t.ticket} · ${t.age_days}j · ${t.assignee} · ${t.customer || t.subject}`).slice(0, 120))
|
||||
const text = `⏰ ${fresh.length} nouveau(x) ticket(s) sans activité ≥${r.days}j (total ouvert périmé : ${r.total})\n` + lines.join('\n') + (fresh.length > top.length ? `\n… +${fresh.length - top.length} autre(s)` : '')
|
||||
let a; try { const ct = require('./coupon-triage'); a = await ct.alertWebhook({ text, kind: 'stale-tickets', total: r.total, fresh: fresh.length }) } catch (e) { a = { alerted: false, reason: e.message } }
|
||||
return { ok: true, total: r.total, fresh: fresh.length, alerted: !!(a && a.alerted), webhook: a && a.reason }
|
||||
}
|
||||
|
||||
// ─── APERÇU UNIFIÉ « Publier vers F » (OPS→F) ─────────────────────────────────────────────────────
|
||||
// Pour chaque Dispatch Job actif portant un legacy_ticket_id (ou un seul si {job}), calcule les changements
|
||||
// EN ATTENTE à pousser vers F sur 3 axes : (1) assignation tech, (2) fermeture, (3) horaire. LECTURE SEULE
|
||||
// (0 écriture) — c'est la « liste sommaire des changements » que le bouton « Publier vers F » affiche AVANT.
|
||||
// L'APPLY réutilise les routes existantes : assignation → push-assignments ; fermeture → batch-close ;
|
||||
// horaire → requiert une action « schedule » côté ops_reassign.php (F) pas encore présente (signalé par change).
|
||||
async function publishPreview ({ job = '' } = {}) {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const filters = job ? [['name', '=', job]] : [['legacy_ticket_id', '!=', '']]
|
||||
const jobs = await erp.list('Dispatch Job', { filters, fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status', 'scheduled_date', 'start_time', 'subject'], limit: job ? 2 : 3000 })
|
||||
if (!jobs.length) return { ok: true, jobs: 0, with_changes: 0, summary: { assign: 0, close: 0, schedule: 0, warnings: 0 }, changes: [] }
|
||||
const techIds = [...new Set(jobs.map(j => j.assigned_tech).filter(Boolean))]
|
||||
const techs = techIds.length ? await erp.list('Dispatch Technician', { filters: [['name', 'in', techIds]], fields: ['name', 'full_name', 'email'], limit: techIds.length + 5 }) : []
|
||||
const techByName = {}; for (const t of (techs || [])) techByName[t.name] = t
|
||||
const emails = [...new Set((techs || []).map(t => String(t.email || '').toLowerCase()).filter(Boolean))]
|
||||
const [emRows] = emails.length ? await p.query('SELECT id, first_name, last_name, status, lower(email) AS email FROM staff WHERE status=1 AND lower(email) IN (?)', [emails]) : [[]]
|
||||
const staffByEmail = new Map((emRows || []).map(s => [s.email, s]))
|
||||
const sufIds = [...new Set(jobs.map(j => (String(j.assigned_tech).match(/(\d{2,})$/) || [])[1]).filter(Boolean))]
|
||||
const [sufRows] = sufIds.length ? await p.query('SELECT id, first_name, last_name, status FROM staff WHERE id IN (?)', [sufIds]) : [[]]
|
||||
const staffById = new Map((sufRows || []).map(s => [String(s.id), s]))
|
||||
const resolveStaff = (techRow, assignedTech) => {
|
||||
const email = String((techRow && techRow.email) || '').toLowerCase()
|
||||
if (email && staffByEmail.has(email)) return { staff: staffByEmail.get(email), via: 'email' }
|
||||
const sid = (String(assignedTech).match(/(\d{2,})$/) || [])[1]
|
||||
const cand = sid ? staffById.get(sid) : null
|
||||
if (cand) { const a = norm((techRow && techRow.full_name) || ''); const b = norm((cand.first_name || '') + ' ' + (cand.last_name || '')); if (a && b && (a === b || a.includes(b) || b.includes(a) || (a.split(' ')[0] === b.split(' ')[0] && a.split(' ').slice(-1)[0] === b.split(' ').slice(-1)[0]))) return { staff: cand, via: 'nom' } }
|
||||
return { staff: null, via: null }
|
||||
}
|
||||
const ids = [...new Set(jobs.map(j => String(j.legacy_ticket_id)).filter(Boolean))]
|
||||
const fState = {}
|
||||
for (let i = 0; i < ids.length; i += 400) {
|
||||
try { const w = await legacyWrite({ action: 'ticket_status', ids: ids.slice(i, i + 400).join(',') }); const rws = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []; for (const rr of rws) fState[String(rr.id)] = rr } catch (e) {}
|
||||
}
|
||||
const DONE = new Set(['Completed', 'Resolved', 'Closed'])
|
||||
const changes = []; const summary = { assign: 0, close: 0, schedule: 0, warnings: 0 }
|
||||
for (const j of jobs) {
|
||||
if (j.status === 'Cancelled') continue
|
||||
const tid = String(j.legacy_ticket_id); const f = fState[tid]
|
||||
if (!f) { if (job) changes.push({ job: j.name, ticket: tid, subject: decodeEntities(j.subject || '').slice(0, 60), changes: [], note: 'ticket introuvable dans F (lot pont en échec ?)' }); continue }
|
||||
const fOpen = String(f.status || '').toLowerCase() === 'open'
|
||||
const ch = []
|
||||
if (j.assigned_tech && fOpen) {
|
||||
const { staff: st, via } = resolveStaff(techByName[j.assigned_tech], j.assigned_tech)
|
||||
if (!st || Number(st.status) !== 1) { ch.push({ field: 'assign_to', kind: 'assign', warn: true, from: String(f.assign_to || ''), to: null, tech: j.assigned_tech, message: '⚠ tech non mappé à un staff F actif (email/nom) — assignation non poussable' }); summary.warnings++ } else if (String(f.assign_to) !== String(st.id)) { ch.push({ field: 'assign_to', kind: 'assign', from: String(f.assign_to || ''), to: String(st.id), tech: j.assigned_tech, staff_name: [st.first_name, st.last_name].filter(Boolean).join(' '), via }); summary.assign++ }
|
||||
}
|
||||
if (DONE.has(j.status) && fOpen) { ch.push({ field: 'status', kind: 'close', from: 'open', to: 'closed', job_status: j.status }); summary.close++ }
|
||||
if (j.scheduled_date && fOpen) {
|
||||
const fDue = (f.due_date && Number(f.due_date) > 0) ? new Date(Number(f.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : ''
|
||||
if (fDue !== j.scheduled_date) { ch.push({ field: 'due_date', kind: 'schedule', from: fDue || '(aucune)', to: j.scheduled_date, start_time: j.start_time || null, f_side: 'requiert action « schedule » dans ops_reassign.php (non présente)' }); summary.schedule++ }
|
||||
}
|
||||
if (ch.length) changes.push({ job: j.name, ticket: tid, subject: decodeEntities(j.subject || '').slice(0, 60), assignee: j.assigned_tech || '', changes: ch })
|
||||
}
|
||||
return { ok: true, jobs: jobs.length, with_changes: changes.length, summary, changes }
|
||||
}
|
||||
|
||||
// APPLY UNIFIÉ « Publier vers F » POUR UN JOB : applique les changements en attente (assign/close/schedule) calculés par
|
||||
// publishPreview, via ops_reassign.php. = l'action du bouton après confirmation de l'aperçu. dryRun => plan seul (0 écriture).
|
||||
// only='assign,close,schedule' restreint ; notify=false coupe l'avis courriel au tech. Résultat par changement.
|
||||
async function publishJob ({ job, dryRun = false, actorEmail = '', notify = true, only = null } = {}) {
|
||||
if (!job) return { ok: false, error: 'job requis' }
|
||||
const pv = await publishPreview({ job })
|
||||
const entry = (pv.changes || []).find(c => c.job === job)
|
||||
if (!entry || !entry.changes || !entry.changes.length) return { ok: true, job, ticket: entry ? entry.ticket : null, applied: [], nothing_to_publish: true }
|
||||
const ticket = entry.ticket
|
||||
const onlySet = only ? new Set(String(only).split(',').map(s => s.trim()).filter(Boolean)) : null
|
||||
const actorStaff = dryRun ? 0 : await resolveActorStaff(actorEmail)
|
||||
let addr = ''
|
||||
try { const dj = await erp.list('Dispatch Job', { filters: [['name', '=', job]], fields: ['address'], limit: 1 }); addr = (dj && dj[0] && dj[0].address) || '' } catch (e) {}
|
||||
const applied = []
|
||||
for (const c of entry.changes) {
|
||||
if (onlySet && !onlySet.has(c.kind)) continue
|
||||
if (c.warn) { applied.push({ kind: c.kind, ok: false, skipped: 'warning', message: c.message }); continue }
|
||||
let payload = null
|
||||
if (c.kind === 'assign') payload = { action: 'reassign', ticket_id: ticket, staff_id: c.to, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: addr }
|
||||
else if (c.kind === 'close') payload = { action: 'close', ticket_id: ticket, actor_staff_id: actorStaff || '' }
|
||||
else if (c.kind === 'schedule') { const due = Math.floor(Date.parse(c.to + 'T12:00:00-04:00') / 1000); payload = { action: 'schedule', ticket_id: ticket, due_date: due, due_time: c.start_time ? String(c.start_time).slice(0, 5) : '' } }
|
||||
if (!payload) continue
|
||||
if (dryRun) { applied.push({ kind: c.kind, dryRun: true, payload }); continue }
|
||||
const w = await legacyWrite(payload).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
const d = (w && w.data) || {}
|
||||
if (d.ok) {
|
||||
if (c.kind === 'close') { try { await erp.update('Dispatch Job', job, { status: 'Completed' }) } catch (e) {} }
|
||||
audit.record({ job, ticket: String(ticket), event: c.kind === 'assign' ? 'assigned' : (c.kind === 'close' ? 'completed' : 'rescheduled'), to: c.kind === 'assign' ? (c.tech || '') : String(c.to || ''), actor: actorEmail || 'ops', source: 'ops-publish' })
|
||||
}
|
||||
applied.push({ kind: c.kind, ok: !!d.ok, to: c.to, tech: c.tech, notified: d.notified, affected: d.affected, error: d.error })
|
||||
}
|
||||
return { ok: applied.every(a => a.ok || a.skipped || a.dryRun), job, ticket, applied }
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user