diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
index d35effb..b32e0fb 100644
--- a/apps/ops/src/api/roster.js
+++ b/apps/ops/src/api/roster.js
@@ -98,6 +98,8 @@ export const pushLegacyApply = () => jpost('/dispatch/legacy-sync/push-assignmen
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
+// Fermeture EN LOT (1 appel pour N tickets) — efficace
+export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {})
// Assigner un job à un tech (date = case déposée)
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index f6fc74c..54c4b63 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -544,6 +544,7 @@
+
CHANGE LOG (Ops):
' . $msg . '
'); $db->query("INSERT INTO ticket_msg (ticket_id, staff_id, msg, date_orig, public) VALUES ($ticket, $staff, '$m', " . time() . ", 0)"); } -$now = time(); +// ─── FERMETURE EN LOT : 1 connexion, N tickets (efficace — évite N allers-retours HTTPS + mysqli) ─── +if ($action === 'batch_close') { + $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)($_POST['ticket_ids'] ?? '')))))); + $closed = 0; $skipped = 0; $lockedN = 0; $results = []; + foreach ($ids as $tid) { + $r = $db->query("SELECT status, lock_name FROM ticket WHERE id = $tid LIMIT 1"); $row = $r ? $r->fetch_assoc() : null; + if (!$row || $row['status'] === 'closed') { $skipped++; $results[] = ['t' => $tid, 'skip' => true]; continue; } + if (trim((string)$row['lock_name']) !== '') { $lockedN++; $results[] = ['t' => $tid, 'locked' => $row['lock_name']]; continue; } + $db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$tid AND status<>'closed'"); + $db->query("UPDATE ticket SET status='open', last_update=$now WHERE waiting_for=$tid AND status<>'closed'"); + ops_log($db, $tid, $logStaff, 'Ticket fermé via Ops (lot).'); + $closed++; $results[] = ['t' => $tid, 'ok' => true]; + } + out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]); +} + +// ─── actions sur 1 ticket ─── +$ticket = (int)($_POST['ticket_id'] ?? 0); +if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400); +$res = $db->query("SELECT id, status, assign_to, lock_name, followed_by FROM ticket WHERE id = $ticket LIMIT 1"); +$t = $res ? $res->fetch_assoc() : null; +if (!$t) out(['ok' => false, 'error' => 'ticket introuvable'], 404); +if ($t['status'] === 'closed') out(['ok' => false, 'error' => 'ticket déjà fermé', 'status' => 'closed']); +// LOCK : un staff édite le ticket dans le legacy → on NE clobbe PAS (le répartiteur réessaie) +if (trim((string)$t['lock_name']) !== '') out(['ok' => false, 'error' => 'verrouillé', 'locked_by' => $t['lock_name']]); if ($action === 'close') { $db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$ticket AND status<>'closed'"); diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 7b975ee..c953919 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -42,6 +42,13 @@ async function closeTicketLegacy (ticketId, actorEmail) { const w = await legacyWrite({ action: 'close', ticket_id: ticketId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } })) return (w && w.data) || { ok: false, error: 'no response' } } +// Fermeture EN LOT : 1 seul appel PHP (1 connexion) pour N tickets → bien plus rapide que N appels. +async function batchCloseLegacy (ticketIds, actorEmail) { + let actorStaff = 0 + try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } } catch (e) {} + const w = await legacyWrite({ action: 'batch_close', ticket_ids: ticketIds.join(','), actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } })) + return (w && w.data) || { ok: false, error: 'no response' } +} // Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc // FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU @@ -714,6 +721,13 @@ async function handle (req, res, method, path) { if (r && r.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(id)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } return json(res, 200, r) } + if (path === '/dispatch/legacy-sync/batch-close' && method === 'POST') { // ferme N tickets legacy en 1 appel + marque les Dispatch Jobs Completed + const ids = (new URL(req.url, 'http://localhost').searchParams.get('tickets') || '').split(',').map(s => s.trim()).filter(Boolean) + if (!ids.length) return json(res, 400, { ok: false, error: 'tickets requis' }) + const r = await batchCloseLegacy(ids, req.headers['x-authentik-email'] || '') + if (r && r.ok && Array.isArray(r.results)) { for (const it of r.results) { if (it.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(it.t)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } } } + return json(res, 200, r) + } 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/reconcile' && method === 'GET') return json(res, 200, await reconcile())