Compare commits

...

5 Commits

Author SHA1 Message Date
louispaulb
e43f04d3e5 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>
2026-07-16 10:49:28 -04:00
louispaulb
956e96b8bc chore(hub): sync repo with deployed hub state (uncommitted prod work)
Le hub deploye (/opt/targo-hub) avait derive du depot (deploiements scp
directs non commites). Rapproche la branche de la realite deployee AVANT
d empiler le nouveau travail:
- legacy-dispatch-sync.js: liens natifs source_issue/depends_on/parent_incident
  + backfills (deployes, jamais commites)
- ops_reassign.php: action post (note/reponse au fil, deployee)

Aucun secret (creds via ops_secret.php). Fichiers = copie exacte du deploye.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:49:17 -04:00
louispaulb
e0e1da1c01 fix(dispatch): lever le cap 50 sur les techniciens (occupation + charge)
techOccupancy et getTechsWithLoad récupéraient les Dispatch Technician avec
limit_page_length=50 alors que l'effectif est de 56. Les techs au-delà du 50e
(ordre ERPNext) étaient donc ABSENTS de l'occupation → dans le calendrier mois
de leur horaire (TechScheduleDialog), c.hasShift est piloté UNIQUEMENT par
l'occupation (shiftMap), donc leurs quarts créés en Semaine/Jour n'y
apparaissaient jamais (tout gris) — ex. Philippe Bourdon & co., alors que Simon
Clot-Gagnon (dans les 50) s'affichait bien. Même cap = mêmes techs invisibles
au dispatch/ranking (getTechsWithLoad). Cap porté à 500 (comme les requêtes
jobs/disponibilités voisines).

Confirmé live : roster/technicians = 56 (Philippe présent) vs dispatch/occupancy
= 45 sans Philippe. Les Shift Assignment existaient (fetchés limit 2000) mais ne
nourrissent que le menu de cellule, pas la barre d'occupation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:19:15 -04:00
louispaulb
c7c9f81364 fix(planif): Suggérer = jobs DUS le jour sélectionné · jour partagé entre vues (session) · quarts brouillon reflétés au calendrier mois
1) Suggérer (retour terrain, ex. ticket 254403 en retard auto-dispatché) :
   - fenêtre = EXACTEMENT le jour sélectionné ; les chips de date du pool
     n'élargissent plus jamais le dispatch (elles filtrent l'affichage)
   - suggestJobs filtré à la SOURCE (couvre solveur VRP + heuristique) :
     scheduled_date === jour sélectionné ; retards/sans-date exclus —
     seule exception : sélection manuelle (lasso)
   - libellés du dialogue mis à jour (« N job(s) DUS ce jour · retards exclus »)
2) Jour sélectionné PARTAGÉ (selDay) persisté en sessionStorage (planif_sel_day) :
   écrit par bande Tournées / Jour kanban / sélecteur de date / bande mobile /
   dialogue Suggérer ; repris au changement de vue (Jour ne retombe plus sur
   « aujourd'hui », Tournées suit, Mois ancre son mois dessus)
3) Quarts créés en Semaine/Jour → calendrier mois par tech + vue Mois :
   flush du brouillon (debounce 800 ms) avant l'ouverture du dialogue horaire,
   flush au passage en vue Mois, et refresh auto des calendriers ouverts quand
   une auto-sauvegarde se termine (techSchedRefresh / monthRefresh ;
   MonthOverview.refreshKey enfin câblé du parent)

Vérifié (aperçu 9001, lecture seule) : pool 44 jobs dont 8 en retard → dialogue
n'offre que les 2 DUS le jour choisi ; 22/07 suivi Semaine→Jour→Tournées ;
Mois affiche « Août 2026 » pour selDay=2026-08-05 ; build SPA OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:17:12 -04:00
louispaulb
48edede793 refactor(planif): désencombrer l'en-tête selon UX_REFACTORING_PROMPT (PL1/PL2/PL4/PL5/PL6)
- PL5 : sous-menu « Modèles de semaine » (menu-dans-menu) → dialogue dédié
  components/planif/WeekTemplatesDialog.vue (props données / emits save·apply·
  set-default·delete ; état + mutations restent dans la page, EmptyState réutilisé)
- PL2 : menu Outils 13 items + sous-menu → 11 items à plat ; retrait des 2 entrées
  redondantes avec la barre (« À assigner », vue Mois)
- PL1/PL4 : barre allégée — bouton ★ modèle par défaut déplacé dans le ⋮ (groupé)
  + ⌘K (planif-templates, planif-apply-default) ; HelpHint « Suggérer » retiré
  (l'explication vit déjà dans le bandeau du dialogue)
- PL6 : champ orphelin « Max h/sem » supprimé (ref jamais consommée = code mort)
- docs : statuts PL1/PL2/PL4/PL5/PL6 mis à jour dans UX_REFACTORING_PROMPT.md

Vérifié : build SPA OK · aperçu 9001 — Outils/⋮/dialogue exercés, chaîne emit
save → prompt parent OK, grille Semaine + Tournées intactes, 0 nouvelle erreur
console (les « Anchor: target null » préexistent au baseline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:56:34 -04:00
6 changed files with 568 additions and 129 deletions

View File

@ -0,0 +1,49 @@
<template>
<q-dialog v-model="open">
<q-card style="min-width:440px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="bookmark" color="brown" size="20px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Modèles de semaine</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-sm">
<div class="text-caption text-grey-7 q-mb-sm">Un modèle mémorise le patron de quarts de la semaine (qui travaille quel quart, par jour). L'application est <b>consciente des absences</b> : les jours un tech est absent sont sautés.</div>
<EmptyState v-if="!templates.length" icon="bookmark_border" label="Aucun modèle enregistré" hint="Enregistre d'abord la semaine affichée comme modèle." />
<q-list v-else separator dense>
<q-item v-for="(tm, i) in templates" :key="i">
<q-item-section avatar>
<q-btn flat dense round size="sm" :icon="tm.default ? 'star' : 'star_border'" :color="tm.default ? 'amber-8' : 'grey-5'" @click="emit('set-default', i)"><q-tooltip>Modèle par défaut () appliqué en 1 clic depuis la barre ou K</q-tooltip></q-btn>
</q-item-section>
<q-item-section>
<q-item-label>{{ tm.name }}<q-badge v-if="tm.default" color="amber-8" class="q-ml-sm" label="défaut" /></q-item-label>
</q-item-section>
<q-item-section side class="row items-center no-wrap q-gutter-xs">
<q-btn dense no-caps outline size="sm" color="primary" icon="play_arrow" label="Appliquer" @click="applyAndClose(tm)"><q-tooltip>Applique le patron à la semaine affichée (absences respectées)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="emit('delete', i)"><q-tooltip>Supprimer ce modèle</q-tooltip></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right" class="q-pt-none">
<q-btn dense no-caps unelevated color="primary" icon="save" label="Enregistrer la semaine affichée…" @click="emit('save')" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
// Dialogue « Modèles de semaine » extrait de PlanificationPage (sous-menu imbriqué du menu Outils dialogue dédié,
// règle « pas de menu-dans-menu »). L'état (weekTemplates) et les mutations restent dans la PAGE (localStorage LS_TPL) :
// le dialogue est purement présentation emits save / apply / set-default / delete.
import { computed } from 'vue'
import EmptyState from 'src/components/shared/EmptyState.vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
templates: { type: Array, default: () => [] }, // [{ name, byDow, default? }] LECTURE seule ici
})
const emit = defineEmits(['update:modelValue', 'save', 'apply', 'set-default', 'delete'])
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
function applyAndClose (tm) { emit('apply', tm); open.value = false }
</script>

View File

@ -78,22 +78,9 @@
</q-item>
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="groups" color="teal" /></q-item-section><q-item-section>Équipe compétences · cadence · coût<q-item-label caption>Compétences (score + cadence/skill) · coût · catalogue</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
<q-item clickable>
<q-item clickable v-close-popup @click="showWeekTemplates = true">
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
<q-item-section>Modèles de semaine</q-item-section>
<q-item-section side><q-icon name="chevron_right" color="grey-6" /></q-item-section>
<q-menu anchor="top end" self="top start">
<q-list dense style="min-width:250px">
<q-item clickable v-close-popup @click="saveTemplate"><q-item-section avatar><q-icon name="save" /></q-item-section><q-item-section>Enregistrer la semaine</q-item-section></q-item>
<q-separator v-if="weekTemplates.length" />
<q-item-label v-if="weekTemplates.length" header>Appliquer</q-item-label>
<q-item v-for="(tm, i) in weekTemplates" :key="i" clickable @click="applyTemplate(tm)">
<q-item-section avatar><q-btn flat dense round size="sm" :icon="tm.default ? 'star' : 'star_border'" :color="tm.default ? 'amber-8' : 'grey-5'" @click.stop="setDefaultTemplate(i)"><q-tooltip>Modèle par défaut ()</q-tooltip></q-btn></q-item-section>
<q-item-section>{{ tm.name }}</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click.stop="deleteTemplate(i)" /></q-item-section>
</q-item>
</q-list>
</q-menu>
<q-item-section>Modèles de semaine<q-item-label caption>{{ weekTemplates.length ? weekTemplates.length + ' modèle(s)' + (defaultTemplate ? ' · défaut : ' + defaultTemplate.name : '') : 'enregistrer / appliquer un patron' }}</q-item-label></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
<q-separator class="q-my-xs" />
@ -104,9 +91,8 @@
<q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-item clickable v-close-popup @click="showTechSync = true"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
<q-separator class="q-my-xs" />
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board -->
<!-- 👁 AFFICHAGE : bascules de ce qui s'affiche sur le board (les raccourcis redondants avec la barre « À assigner », vue Mois ont été retirés) -->
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">👁&nbsp; Affichage</q-item-label>
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
<q-item clickable v-close-popup @click="showDayJobs = !showDayJobs">
<q-item-section avatar><q-icon name="event_note" :color="showDayJobs ? 'indigo' : 'grey-7'" /></q-item-section>
<q-item-section>Jobs prévus en en-tête<q-item-label caption>liste par jour glisser sur un tech</q-item-label></q-item-section>
@ -117,16 +103,11 @@
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="boardView = 'month'">
<q-item-section avatar><q-icon name="calendar_month" color="indigo" /></q-item-section>
<q-item-section>Besoins &amp; couverture (vue Mois)<q-item-label caption>heures requises par compétence · alertes de couverture</q-item-label></q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" />
<!-- (l'explication détaillée de « Suggérer » vit DANS le dialogue — bandeau ambre à l'ouverture ; le modèle par défaut vit dans + K) -->
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
<HelpHint class="q-ml-xs" title="Suggérer — répartition automatique">Propose une assignation optimisée des jobs <b>du jour sélectionné</b> aux techniciens, en combinant compétence requise, disponibilité, distance (routes réelles) et taux d'occupation. Les cas simples vont d'abord aux techs les <b>moins polyvalents</b> (réserve les experts). Rien n'est appliqué avant votre validation.</HelpHint>
<!-- PUBLIER unifié : action principale + sous-options (SMS · publier au legacy) dans le même bouton -->
<q-btn-dropdown split :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" no-caps @click="doPublish">
<q-list dense style="min-width:250px">
@ -145,18 +126,24 @@
</q-item>
</q-list>
</q-btn-dropdown>
<!-- Utilitaires secondaires regroupés dans un (annuler/rétablir gardent leurs raccourcis Ctrl+Z / Ctrl+Shift+Z) -->
<OverflowMenu :items="[
{ icon: 'undo', label: 'Annuler', caption: 'Ctrl+Z', disabled: !history.length, action: undo },
{ icon: 'redo', label: 'Rétablir', caption: 'Ctrl+Shift+Z', disabled: !future.length, action: redo },
{ icon: 'refresh', label: 'Rafraîchir', action: () => guard(loadWeek) },
<!-- Utilitaires secondaires regroupés dans un (annuler/rétablir gardent leurs raccourcis Ctrl+Z / Ctrl+Shift+Z ;
le modèle par défaut , retiré de la barre, reste joignable ici ET via K) -->
<OverflowMenu :groups="[
{ items: [
{ icon: 'undo', label: 'Annuler', caption: 'Ctrl+Z', disabled: !history.length, action: undo },
{ icon: 'redo', label: 'Rétablir', caption: 'Ctrl+Shift+Z', disabled: !future.length, action: redo },
{ icon: 'refresh', label: 'Rafraîchir', action: () => guard(loadWeek) },
] },
{ label: 'Modèles de semaine', items: [
{ icon: 'star', label: 'Appliquer le modèle par défaut', caption: defaultTemplate ? defaultTemplate.name : 'aucun — marquer ★ dans Modèles', color: 'amber-8', disabled: !defaultTemplate, action: applyDefault },
{ icon: 'bookmark', label: 'Gérer les modèles…', action: () => { showWeekTemplates = true } },
] },
]" />
</div>
<!-- Rangée 2 : filtres + légende en popover DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-gutter-sm q-mb-sm gt-sm">
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
<q-space />
@ -461,7 +448,8 @@
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). -->
<!-- P3 Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
<!-- Vue MOIS : résumé mensuel (en quart / absents) + couverture par compétence (remplace « Demande ») -->
<MonthOverview v-if="boardView === 'month'" :techs="techs" :templates="templates" :anchor="start" @generated="() => guard(loadWeek)" />
<!-- anchor = jour sélectionné partagé (même mois que le jour choisi en Semaine/Jour/Tournées) ; refresh-key = recharge quand un brouillon de quart vient d'être auto-sauvegardé -->
<MonthOverview v-if="boardView === 'month'" :techs="techs" :templates="templates" :anchor="selDay || start" :refresh-key="monthRefresh" @generated="() => guard(loadWeek)" />
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
@ -568,6 +556,9 @@
<!-- Types de shift extrait dans components/planif/ShiftTypesDialog.vue (décomposition #4) ; @changed recharge les templates partagés -->
<ShiftTypesDialog v-model="showShiftEditor" :templates="templates" @changed="refreshTemplates" />
<!-- Modèles de semaine (patrons d'horaire) dialogue dédié ; état + mutations dans la page (LS_TPL) -->
<WeekTemplatesDialog v-model="showWeekTemplates" :templates="weekTemplates" @save="saveTemplate" @apply="applyTemplate" @set-default="setDefaultTemplate" @delete="deleteTemplate" />
<!-- Congés & disponibilités extrait dans components/planif/LeaveDialog.vue (décomposition #4) -->
<LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
@ -921,20 +912,20 @@
<q-toggle v-model="suggestDlg.includeAssigned" dense size="sm" color="deep-purple" label="Ré-optimiser aussi les jobs déjà assignés (simulation)" />
<q-icon name="info" size="15px" color="grey-5"><q-tooltip class="bg-grey-9" style="font-size:11px">Inclut les {{ assignedNamesForWindow().length }} job(s) déjà assigné(s) du/des jour(s) le solveur repropose une répartition de TOUT (existant + nouveaux). Force le solveur VRP. Aperçu seulement « Appliquer » réassigne.</q-tooltip></q-icon>
</div>
<!-- FENÊTRE de répartition = les CHIPS de date cochés dans le pool (ou la sélection lasso). Sinon un jour choisi ici. -->
<!-- FENÊTRE de répartition = LE jour sélectionné (selDay partagé entre les vues ; modifiable ici). Lasso = seule exception (jobs choisis à la main). -->
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span>
<template v-if="suggestFiltered">
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ suggestScope }}</span>
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span>
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</span>
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso (peut inclure des retards)' : 'chips de compétence du pool' }}</span>
</template>
<template v-else>
<span class="text-caption">pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">{{ suggestJobCount }} job(s) de ce jour · retards exclus (chip « en retard » pour les inclure)</span>
<q-space /><span class="text-caption text-grey-6">{{ suggestJobCount }} job(s) DUS ce jour · retards/sans-date exclus pour en dispatcher un : le sélectionner à la main dans le pool</span>
</template>
</div>
<div class="row items-center q-mb-xs" style="gap:4px">
@ -1247,7 +1238,7 @@
<q-list v-else separator>
<q-expansion-item v-for="m in notifyDlg.techs" :key="m.techId" dense>
<template #header>
<q-item-section avatar><q-avatar size="30px" color="deep-purple-1" text-color="deep-purple-9" style="font-weight:700;font-size:11px">{{ initials(m.name) }}</q-avatar></q-item-section>
<q-item-section avatar><UserAvatar :email="m.email" :tech-id="m.techId" :name="m.name" :size="30" card /></q-item-section>
<q-item-section><q-item-label>{{ m.name }}</q-item-label><q-item-label caption>{{ m.jobCount }} intervention(s)<span v-if="!m.phone && !m.email" class="text-negative"> · aucune coordonnée</span></q-item-label></q-item-section>
<q-item-section side @click.stop>
<div class="row items-center q-gutter-xs no-wrap">
@ -1815,6 +1806,7 @@ import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyB
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait décomposition #4)
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4)
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait décomposition #4)
import WeekTemplatesDialog from 'src/components/planif/WeekTemplatesDialog.vue' // Modèles de semaine (ex-sous-menu Outils dialogue, règle « pas de menu-dans-menu »)
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import HelpHint from 'src/components/shared/HelpHint.vue'
import { skillIcon, skillSym, markerIcon } from 'src/composables/useSkillIcons' // SOURCE UNIQUE des icônes de compétences (réutilisée fiche client)
@ -1822,6 +1814,7 @@ import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/co
import SkillCadenceTable from 'src/components/planif/SkillCadenceTable.vue' // éditeur compétences+cadence PARTAGÉ (volet tech + outil Équipe)
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import UserAvatar from 'src/components/shared/UserAvatar.vue' // avatar CANONIQUE identité-aware (photo + nom résolu + carte + renommage) remplace les monogrammes bruts du board
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // « » standardisé pour les actions secondaires (désencombrer la barre)
import JobPool from 'src/components/planif/JobPool.vue' // pool « Jobs à assigner » UNIFIÉ (flottant · kanban · mobile) remplace les 3 réimplémentations
import { useJobPool } from 'src/composables/useJobPool' // cerveau filtre/tri/groupe/badges partagé par les 3 surfaces
@ -1946,13 +1939,14 @@ const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on
const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', weekdays: [], anchor: '', steps: [] })
const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }]
const history = ref([]); const future = ref([])
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40); const skillFilter = ref([])
const search = ref(''); const groupFilter = ref(null); const skillFilter = ref([]) // (maxHours retiré champ orphelin jamais consommé)
// Ressources masquées (hors front-line : compta, etc.) masquées ET ignorées du calcul ; localStorage.
const hiddenTechs = ref([]); const showHidden = ref(false)
function isHidden (id) { return hiddenTechs.value.includes(id) }
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
const showShiftEditor = ref(false) // v-model de <ShiftTypesDialog> (editTpls/newTpl/fonctions déplacés dans le composant)
const showWeekTemplates = ref(false) // v-model de <WeekTemplatesDialog> l'état weekTemplates + mutations restent ICI (localStorage)
const showTeamEditor = ref(false); const editTechs = ref([]); const teamCost = reactive({})
const notifySms = ref(false)
// Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi.
@ -2026,9 +2020,16 @@ watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, af
// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub).
const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils)
const boardView = ref(['kanban', 'routes', 'month'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'month' | 'routes'
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = selDay.value || nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = mondayISO(kbSelIso.value); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } else if (v === 'month' && _draftT) { autosaveDraft() } }) // Jour reprend le jour SÉLECTIONNÉ (selDay), plus « aujourd'hui » ; Mois flush du brouillon (le calendrier lit le serveur)
watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé
const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto aujourd'hui)
// Jour SÉLECTIONNÉ partagé entre les vues (Semaine · Jour · Tournées · mobile) VARIABLE DE SESSION :
// choisi dans une vue mémorisé (sessionStorage, survit au refresh de l'onglet) repris par les autres vues
// au lieu de retomber sur « aujourd'hui ». Source unique aussi pour le jour cible de « Suggérer ».
const SEL_DAY_KEY = 'planif_sel_day'
const selDay = ref((() => { try { const v = sessionStorage.getItem(SEL_DAY_KEY); return (v && /^\d{4}-\d{2}-\d{2}$/.test(v)) ? v : todayISO() } catch (e) { return todayISO() } })())
function setSelDay (iso) { if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(String(iso))) return; selDay.value = iso; try { sessionStorage.setItem(SEL_DAY_KEY, iso) } catch (e) {} }
watch(kbSelIso, v => setSelDay(v))
const kanbanDay = computed(() => { // Jour : jour choisi, sinon AUJOURD'HUI (ET) si dans la fenêtre, sinon le 1er jour
for (const c of [kbSelIso.value, nowET.value.iso]) { if (!c) continue; const d = dayList.value.find(x => x.iso === c); if (d) return d }
return dayList.value[0] || null
@ -2038,6 +2039,7 @@ const boardDate = computed({
get: () => (boardView.value === 'kanban' ? ((kanbanDay.value && kanbanDay.value.iso) || start.value) : start.value),
set: (v) => {
if (!v) return
setSelDay(v) // le sélecteur de date de la barre choisit AUSSI le jour partagé (repris par Jour/Tournées/Suggérer)
if (boardView.value === 'kanban') { kbSelIso.value = v; if (!dayList.value.find(d => d.iso === v)) { start.value = mondayISO(v); loadWeek() } }
else if (v !== start.value) { start.value = v; onWeekChange() }
},
@ -2642,7 +2644,11 @@ async function applyWeekPreset (t, dows, min, max) {
// Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage.
const techSchedOpen = ref(false); const techSchedTech = ref(null)
const techSchedRefresh = ref(0) // après une écriture de quart le calendrier par tech recharge son mois
function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true }
const monthRefresh = ref(0) // quand un brouillon vient d'être auto-sauvegardé la vue Mois recharge (les quarts créés en Semaine/Jour s'y reflètent)
// Ouvre le calendrier par tech en FLUSHANT d'abord le brouillon en attente (debounce 800 ms) : le dialogue lit le
// SERVEUR (occupation + assignations Proposé/Publié) sans flush, un quart tout juste peint n'apparaîtrait pas.
async function openTechSchedule (t) { techSchedTech.value = t; if (_draftT) { try { await autosaveDraft() } catch (e) { /* best-effort */ } } techSchedOpen.value = true }
// (le watcher « autosave terminé rafraîchir les calendriers » vit à côté de `autosaving` plus bas TDZ)
// Calendrier par tech : le menu de cellule PARTAGÉ y émet des intentions ; on les exécute avec les mêmes fonctions que la grille.
// Quarts = persistés PAR DATE (createShift 'Publié' / deleteAssignment) car le calendrier est mensuel (hors fenêtre de la grille).
async function onTechSchedSetShift ({ techId, iso, min, max } = {}) {
@ -2745,7 +2751,10 @@ function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; retur
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type par mots-clés, repli 'bolt'.
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N »)
// Compétences du tech pour l'affichage (icône principale + « +N ») : on RESPECTE l'ordre de priorité
// défini par glisser-déposer dans l'éditeur de compétences (t.skills) la 1re = fonction principale.
// (ex. une personne au support voit son casque en 1er ; réparation support, chacun garde son rang.)
function tSkills (t) { return [...(t.skills || [])] }
// Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort group ; sinon icône de COMPÉTENCE
// (skill/required_skill, ou dept pour les tickets legacy ex. « Install/Reparation Télé » = TV).
// Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept).
@ -4243,6 +4252,7 @@ const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().t
// heatColor composables/useHelpers.js (source unique, partagé avec <OccupancyStrip> + tableau de bord)
const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres
const mobileSelIso = ref(null)
watch(mobileSelIso, v => setSelDay(v)) // le jour touché sur mobile devient le jour partagé (repris par les autres vues)
// Bande de jours mobile = fenêtre LONGUE (4 semaines) carrousel horizontal CONTINU (sans flèches).
// L'occupation est chargée sur la même fenêtre (voir getOccupancy mobile) pour que tous ces jours aient des données.
const MOBILE_STRIP_DAYS = 28
@ -4623,11 +4633,9 @@ function buildSuggestion () {
else if (sched && winSet.has(sched)) cand = [sched] // daté DANS la fenêtre ce jour
else if (sched && sched > winMax) continue // daté APRÈS la fenêtre PAS dispatché maintenant (on ne planifie pas loin)
else {
// En retard (date < fenêtre) ou sans date : NE PLUS balayer automatiquement sur le(s) jour(s) choisi(s).
// Seulement si EXPLICITEMENT demandé : sélection lasso, ou chip « en retard » / « Sans date » coché.
// sélectionner un seul jour ne dispatche QUE les jobs dus ce jour-là (retour terrain : les retards ne doivent pas s'inviter).
const wantBacklog = useSel || (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date')
if (!wantBacklog) continue
// En retard (date < fenêtre) ou sans date : JAMAIS auto-dispatché. UNE seule exception : la sélection
// manuelle explicite (lasso/cases) les chips du pool n'ouvrent plus cette porte (ticket ex. 254403).
if (!useSel) continue
cand = win
}
if (!cand.length) continue
@ -4710,8 +4718,9 @@ function buildSuggestion () {
suggestDlg.plan = plan
}
function openSuggest () {
// Jour de base = la sélection de la vue active (strip Tournées, Jour kanban, ou bande mobile) « Suggérer se base sur la journée sélectionnée »
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO()
// Jour de base = le jour SÉLECTIONNÉ PARTAGÉ (selDay synchronisé depuis la bande Tournées, le Jour kanban,
// le sélecteur de date et la bande mobile) « Suggérer se base toujours sur la journée sélectionnée ».
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kanbanDay.value && kanbanDay.value.iso) || selDay.value || todayISO()
suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles.
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
@ -4731,48 +4740,41 @@ function openSuggest () {
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
}
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) le jour choisi dans le sélecteur.
// Les chips « en retard » / « Sans date » ne sont pas des jours calendaires les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer).
// Dispatch auto : la FENÊTRE = LE jour sélectionné (selDay partagé entre vues ; modifiable dans le dialogue).
// Changer le jour dans le dialogue met aussi à jour le jour partagé (mémoire de session). Non destructif (revue avant d'appliquer).
const suggestDay = ref('')
watch(suggestDay, v => setSelDay(v))
const suggestWindow = computed(() => {
// Vue Tournées : la fenêtre = EXACTEMENT le jour sélectionné (autorité = la bande de dates), jamais les chips de date du pool.
// « Suggérer » ne balaie PAS les jobs en retard (date < jour choisi) sur ce jour ; seuls les jobs DUS ce jour-là sont répartis.
// RÈGLE DÉFINITIVE (retour terrain, ticket ex. 254403 en retard auto-dispatché) : la fenêtre = EXACTEMENT
// LE JOUR SÉLECTIONNÉ (bande Tournées / Jour kanban / sélecteur du dialogue tous synchronisés via selDay).
// Les chips de date du pool FILTRENT l'affichage mais n'élargissent JAMAIS le dispatch auto ; les jobs en
// retard ou sans date ne sont dispatchés QUE par sélection manuelle explicite (lasso/cases cochées).
if (boardView.value === 'routes' && routesDay.value) return [routesDay.value]
const f = floatingPool.dateFilter.value || []
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché sélecteur de jour du dialogue
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date placés aujourd'hui
return [...days].sort()
return [suggestDay.value || selDay.value || todayISO()]
})
// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon « ce que je vois » = le pool filtré
// par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » non réparti aux techs terrain.
const suggestJobs = () => {
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite = seule façon de dispatcher un retard/sans-date
// Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) « ce que je filtre = ce que Suggérer répartit ».
if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold')
let jobs = (floatingPool.filteredJobs.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre jamais les
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
// (Pour dispatcher les retards, cocher explicitement le chip « en retard » ou « Sans date ».)
// Filtre appliqué à la SOURCE (couvre aussi le solveur Optimiser qui prend suggestJobs()) : seuls les jobs
// datés DANS la fenêtre passent, SAUF si l'utilisateur demande explicitement les retards/sans-date.
const wantBacklog = (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date')
if (!wantBacklog) {
const win = new Set(suggestWindow.value)
jobs = jobs.filter(j => j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
}
// RÈGLE : seuls les jobs dont la DATE DUE = le jour sélectionné sont auto-dispatchés (jamais les retards,
// jamais les sans-date, jamais les autres jours ticket ex. 254403). Appliqué à la SOURCE : couvre le
// solveur VRP (job_names) ET l'heuristique locale. Les chips de COMPÉTENCE du pool restreignent encore
// (ex. décocher « téléphonie ») ; les chips de DATE n'influencent plus le dispatch (affichage seulement).
const win = new Set(suggestWindow.value)
let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
const sk = floatingPool.skillFilter.value || []
if (sk.length) jobs = jobs.filter(j => sk.includes(j.required_skill))
return jobs
}
const suggestJobCount = computed(() => suggestJobs().length)
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.dateFilter.value.length || floatingPool.skillFilter.value.length))
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) décrit ce qui limite le jeu de jobs.
// Y a-t-il un filtre restreignant le dispatch ? (lasso ou chips de TYPE les chips de date ne comptent plus : le jour vient de selDay)
const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.skillFilter.value.length))
// Libellé du périmètre : « sélectionnés » (lasso) · « des types cochés » (chips de compétence).
const suggestScope = computed(() => {
if (selectedNames.value.length) return 'sélectionnés'
const parts = []
if (floatingPool.dateFilter.value.length) parts.push('jours')
if (floatingPool.skillFilter.value.length) parts.push('types')
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
return floatingPool.skillFilter.value.length ? 'des types cochés' : 'du jour'
})
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => {
@ -5212,7 +5214,16 @@ async function loadRouteTrack () {
} catch (e) { routeTrack.value = null; routeTrackMsg.value = 'indispo' }
}
watch([soloRouteTech, routesDay, showRouteTrack, boardView], loadRouteTrack)
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
watch(boardView, (v) => {
if (v !== 'routes') return
// Reprend le jour SÉLECTIONNÉ partagé (selDay) même jour que les vues Semaine/Jour. Si le jour est hors de la
// fenêtre chargée, on déplace la fenêtre (comme le fait le mode Jour) plutôt que de retomber sur aujourd'hui.
const sel = selDay.value
if (sel && (dayList.value || []).some(d => d.iso === sel)) { routesDay.value = sel; return }
if (sel && /^\d{4}-\d{2}-\d{2}$/.test(sel)) { routesDay.value = sel; start.value = mondayISO(sel); loadWeek(); return }
if (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value)) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t }
}, { immediate: true })
watch(routesDay, v => setSelDay(v)) // le jour cliqué dans la bande Tournées devient le jour partagé
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).
@ -5833,6 +5844,9 @@ function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.v
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
// #2 AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
const autosaving = ref(false)
// Auto-save TERMINÉ rafraîchit les calendriers qui lisent le SERVEUR (dialogue horaire par tech · vue Mois) :
// les quarts créés en Semaine/Jour s'y répliquent sans fermer/rouvrir. (Watcher ICI, après la déclaration TDZ.)
watch(autosaving, (v, ov) => { if (ov && !v) { if (techSchedOpen.value) techSchedRefresh.value++; if (boardView.value === 'month') monthRefresh.value++ } })
const changeLog = ref([]) // journal de session : { id, at, text, revert?:{tech,date,before}, reverted? }
let _draftT = null
let _chgSeq = 0
@ -6022,6 +6036,8 @@ registerActions('planif', [
{ id: 'planif-create', label: 'Créer une tâche / intervention', icon: 'add', group: 'Planification', keywords: 'creer job tache intervention soumission', run: () => { createChooser.value = true } },
{ id: 'planif-assign', label: 'Jobs à assigner', icon: 'assignment_ind', group: 'Planification', keywords: 'assigner pool repartir', run: openAssignPanel },
{ id: 'planif-leave', label: 'Congés / absences', icon: 'beach_access', group: 'Planification', keywords: 'conges absences vacances', run: () => { showLeave.value = true } },
{ id: 'planif-templates', label: 'Modèles de semaine', icon: 'bookmark', group: 'Planification', keywords: 'modeles semaine patron template horaire', run: () => { showWeekTemplates.value = true } },
{ id: 'planif-apply-default', label: 'Appliquer le modèle par défaut (★)', icon: 'star', group: 'Planification', keywords: 'modele defaut appliquer patron star', run: applyDefault },
])
onUnmounted(() => unregisterActions('planif'))
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
@ -6471,6 +6487,7 @@ tfoot .sum .tech-col { background: #fafafa; }
.pm-more { width: 100%; border: none; background: none; color: #6366f1; font-size: 12px; font-weight: 600; padding: 9px; cursor: pointer; }
.pm-tech-idle { opacity: .55; }
.pm-ava { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.pm-ava-uav { flex: 0 0 auto; } /* avatar canonique dans la liste techs mobile (garde l'espacement via le gap du parent) */
.pm-tech.assignable { background: #f5f3ff; outline: 1px dashed #c4b5fd; }
.pm-tech.assignable:active { background: #ede9fe; }
.pm-assign-banner { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; background: #6366f1; color: #fff; font-size: 12px; font-weight: 600; padding: 8px 10px; border-radius: 8px; margin-bottom: 8px; }

View File

@ -78,12 +78,12 @@ File layout: template L11768 · script L17706033 (~4,264 lines — the rea
| ID | Anti-pattern | Anchor | Status | Problem |
|---|---|---|---|---|
| PL1 | WALL_OF_OPTIONS | desktop header rows (comment « Rangée 1 : actions principales ») ~L32-154 | open | 20+ interactive controls in one toolbar |
| PL2 | WALL_OF_OPTIONS | `label="Outils"` dropdown ~L71-125 | open | 13 items in 3 sections + nested submenu |
| PL1 | WALL_OF_OPTIONS | desktop header rows (comment « Rangée 1 : actions principales ») ~L32-154 | **largely fixed 2026-07-14** | Was 20+ controls. Now: view switch · nav · date · journal + 5 primary actions (À assigner, Créer, Outils, Suggérer, Publier) + grouped ⋮ (★ défaut relocated there + ⌘K). HelpHint removed (explainer lives in the Suggérer dialog banner) |
| PL2 | WALL_OF_OPTIONS | `label="Outils"` dropdown ~L71-125 | **fixed 2026-07-14** | Was 13 items + nested submenu. Now 11 flat items; the 2 entries duplicating always-visible controls (À assigner, vue Mois) removed |
| PL3 | WALL_OF_OPTIONS | q-dialog `jobSheet.open` ~L281-356 | open | ~15 interactive zones on a phone screen. Reuse `detail-sections/modules/JobThread.vue` + `JobReplyBox.vue` instead of the hand-rolled pm-postbox composer |
| PL4 | HIDDEN_PRIMARY | `label` contains « Publier », split q-btn-dropdown ~L131-147 | **partially fixed** | Publier is now a unified split q-btn-dropdown (color=positive, dirty-count, outline-when-clean). Remaining: the row still has ~10 sibling controls (see PL1) |
| PL5 | MENU_IN_MENU | « Modèles de semaine » ~L85-97 | open | Submenu inside a dropdown |
| PL6 | ORPHAN_FIELD | `label="Max h/sem"` ~L159 | open | Floating numeric input with no context |
| PL4 | HIDDEN_PRIMARY | `label` contains « Publier », split q-btn-dropdown ~L131-147 | **fixed** | Publier is a unified split q-btn-dropdown (color=positive, dirty-count, outline-when-clean); sibling-control count resolved with PL1 |
| PL5 | MENU_IN_MENU | « Modèles de semaine » ~L85-97 | **fixed 2026-07-14** | Extracted to `components/planif/WeekTemplatesDialog.vue` (props data / emits save·apply·set-default·delete; state stays in the page). Reachable via Outils, ⋮ and ⌘K |
| PL6 | ORPHAN_FIELD | `label="Max h/sem"` ~L159 | **fixed 2026-07-14** | Deleted — the input was dead code (ref never consumed by any filter/solver) |
| PL7 | STATUS_MYSTERY | autosave journal ~L48-61 | **largely fixed** | Icon now swaps to cloud_sync + primary color while saving (persistent feedback); popover holds the change journal + per-change revert by design. Verify before changing |
| PL8 | MONOLITH | entire file | open | 6,559 lines = unmaintainable (see decomposition plan) |

View File

@ -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.
@ -62,6 +63,23 @@ if ($action === 'batch_close') {
out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]);
}
// ─── POST : ajouter une note/réponse au fil d'un ticket depuis Ops ───
// public=0 (DÉFAUT) = note INTERNE (jamais envoyée au client) · public=1 = message public (visible dans le ticket).
// Même mécanisme que ops_log (INSERT ticket_msg) → apparaît dans le fil lu par le hub. (int)-cast + real_escape = sûr.
if ($action === 'post') {
$ticket = (int)($_POST['ticket_id'] ?? 0);
$msg = trim((string)($_POST['msg'] ?? ''));
$public = ((($_POST['public'] ?? '0')) === '1') ? 1 : 0;
if ($ticket <= 0 || $msg === '') out(['ok' => false, 'error' => 'ticket_id + msg requis'], 400);
$r = $db->query("SELECT id FROM ticket WHERE id = $ticket LIMIT 1");
if (!$r || !$r->fetch_assoc()) out(['ok' => false, 'error' => 'ticket introuvable'], 404);
$m = $db->real_escape_string(mb_substr($msg, 0, 6000));
$db->query("INSERT INTO ticket_msg (ticket_id, staff_id, msg, date_orig, public) VALUES ($ticket, $logStaff, '$m', $now, $public)");
$newId = $db->insert_id;
$db->query("UPDATE ticket SET last_update=$now WHERE id=$ticket");
out(['ok' => true, 'action' => 'post', 'public' => $public, 'msg_id' => $newId]);
}
// ─── LECTURE / MONITORING (sync F→OPS frais — le miroir local peut être en retard) ───
// Toutes les valeurs numériques sont (int)-castées → interpolation sûre (pas d'injection).
// action=ping → fraîcheur + compteurs (max date_last, max ids, heure serveur)
@ -153,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);

View File

@ -52,7 +52,7 @@ function distKm (a, b) {
async function getTechsWithLoad (dateStr) {
const [techRes, jobRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'traccar_device_id', 'phone']))}&limit_page_length=50`),
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'traccar_device_id', 'phone']))}&limit_page_length=500`), // 500 : couvre tout l'effectif (>50) — sinon les techs au-delà du 50e sont invisibles au dispatch/ranking
erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify({ status: ['in', ['open', 'assigned']], scheduled_date: dateStr }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'assigned_tech', 'duration_h', 'longitude', 'latitude', 'status', 'route_order']))}&limit_page_length=200`),
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
@ -323,7 +323,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
'name', 'technician_id', 'full_name', 'status', 'absence_from', 'absence_until', 'skills', '_user_tags',
]))}&limit_page_length=50`),
]))}&limit_page_length=500`), // 500 : couvre tout l'effectif (56+ techs) — le cap 50 laissait les techs au-delà du 50e SANS occupation → calendrier mois de leur horaire tout gris (bug Philippe Bourdon & co.)
erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
['status', 'in', ['open', 'assigned']],
['scheduled_date', '>=', dates[0]],

View File

@ -384,8 +384,8 @@ async function fetchTargoTickets () {
// caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
let _custCache = new Map()
let _slCache = new Map()
let _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } // observabilité : comptes/SL créés + Issue (re)liées au client pendant le run (récits dans le résumé)
function resetCaches () { _custCache = new Map(); _slCache = new Map(); _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } }
let _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0, source_issue_linked: 0, parent_incident_linked: 0, depends_on_linked: 0 } // observabilité : comptes/SL créés + Issue (re)liées au client pendant le run (récits dans le résumé)
function resetCaches () { _custCache = new Map(); _slCache = new Map(); _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0, source_issue_linked: 0, parent_incident_linked: 0, depends_on_linked: 0 } }
// Auto-création des comptes manquants pendant l'import (par défaut ON — désactivable via LEGACY_DISPATCH_CREATE_CUSTOMERS=off).
// Un ticket legacy dont le compte F n'existe pas encore côté ERPNext (ex. « Robert Usereau » dispatché mais introuvable dans OPS)
// crée le Customer (chemin canonique legacy-sync) + une Service Location depuis l'adresse de service → la fiche devient trouvable et liable.
@ -594,44 +594,73 @@ async function buildJob (t, { dryRun = false } = {}) {
}
async function findExisting (legacyId) {
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address'], limit: 1 })
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address', 'source_issue', 'depends_on'], limit: 1 })
return (r && r[0]) || null
}
// RELATIONS DURABLES-NATIVES (survivent à une coupure de F) : lie le Dispatch Job à SON Issue via le champ natif
// `source_issue` (au lieu de ne partager que `legacy_ticket_id`, une clé morte sans F). Fill-only + idempotent.
async function linkJobSourceIssue (jobName, curSourceIssue, issueName, dryRun) {
if (!jobName || !issueName || curSourceIssue) return false
if (dryRun) { _createStats.source_issue_linked++; return true }
const r = await erp.update('Dispatch Job', jobName, { source_issue: issueName })
if (r && r.ok) { _createStats.source_issue_linked++; return true }
return false
}
// DÉPENDANCE NATIVE Job→Job depuis `ticket.waiting_for` (le ticket attend un AUTRE ticket) → champ natif `depends_on`.
// LIEN SEUL — PAS de passage auto en On Hold (la visibilité au pool reste gérée par le dispatch existant). Fill-only + idempotent.
async function linkJobDependsOn (jobName, curDependsOn, waitsOnJobName, dryRun) {
if (!jobName || !waitsOnJobName || curDependsOn || jobName === waitsOnJobName) return false
if (dryRun) { _createStats.depends_on_linked++; return true }
const r = await erp.update('Dispatch Job', jobName, { depends_on: waitsOnJobName })
if (r && r.ok) { _createStats.depends_on_linked++; return true }
return false
}
// Assure que l'Issue ERPNext (matchée par legacy_ticket_id) porte le `customer` → le ticket legacy apparaît dans la fiche
// client (useClientData.loadTickets filtre les Issue par `customer`). Met à jour l'Issue si son customer est vide/différent ;
// si l'Issue manque (ticket plus récent que la dernière passe `migrate_tickets.py`), la CRÉE (minimale). Idempotent
// (re-run = 0 écriture sur les déjà-liées ; migrate_tickets.py skippe le legacy_ticket_id déjà présent → pas de doublon).
// Best-effort : une erreur ici n'empêche PAS le Dispatch Job (on log et on continue — cf. createServiceLocation).
async function ensureIssueCustomer (t, custName, { dryRun = false } = {}) {
if (!LINK_ISSUES || !custName || !t || !t.id || dryRun) return null
if (!LINK_ISSUES || !custName || !t || !t.id) return null
const legacyId = Number(t.id)
if (!Number.isFinite(legacyId)) return null
try {
const rows = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'customer'], limit: 1 })
const rows = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'customer', 'parent_incident'], limit: 1 })
const iss = rows && rows[0]
let issName = iss ? iss.name : ''
let curParentIncident = iss ? iss.parent_incident : ''
if (iss) {
if (iss.customer === custName) return { action: 'ok', issue: iss.name }
const r = await erp.update('Issue', iss.name, { customer: custName }) // (re)lie — n'écrit que si vide/différent
if (r && r.ok) { _createStats.issues_linked++; return { action: 'linked', issue: iss.name } }
log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ') — ' + ((r && r.error) || 'update'))
return null
if (iss.customer !== custName) { // (re)lie — n'écrit que si vide/différent
if (!dryRun) { const r = await erp.update('Issue', iss.name, { customer: custName }); if (!(r && r.ok)) log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ')') }
_createStats.issues_linked++
}
} else {
// Issue absente → create minimale. issue_type/priority OMIS (liens seedés → risque LinkValidationError).
const tkStatus = String(t.tk_status || 'open').toLowerCase()
const doc = {
subject: (decodeEntities(t.subject || '').trim() || ('Ticket #' + legacyId)).slice(0, 255),
status: tkStatus === 'closed' ? 'Closed' : tkStatus === 'pending' ? 'On Hold' : 'Open',
customer: custName, company: ISSUE_COMPANY, legacy_ticket_id: legacyId,
}
const od = tzDate(t.date_create); if (od) doc.opening_date = od
if (dryRun) { _createStats.issues_created++ } else {
const r = await erp.create('Issue', doc)
if (r && r.ok && r.name) { issName = r.name; _createStats.issues_created++ } else { log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create')); return null }
}
}
// Issue absente → create minimale. issue_type/priority OMIS volontairement (liens vers doctypes seedés → risque
// LinkValidationError) : la fiche n'en a pas besoin pour lister le ticket. Le `subject`/`status`/`opening_date`
// suffisent à un rendu correct (loadTickets trie par is_important desc, opening_date desc).
const tkStatus = String(t.tk_status || 'open').toLowerCase()
const doc = {
subject: (decodeEntities(t.subject || '').trim() || ('Ticket #' + legacyId)).slice(0, 255),
status: tkStatus === 'closed' ? 'Closed' : tkStatus === 'pending' ? 'On Hold' : 'Open',
customer: custName,
company: ISSUE_COMPANY,
legacy_ticket_id: legacyId,
// HIÉRARCHIE NATIVE (durable, survit à F) : ticket.parent → Issue.parent_incident (fill-only) + parent.is_incident.
// Maintenu LIVE ici (avant : recalculé seulement par la migration batch). Résout l'Issue du parent par legacy_ticket_id.
if (issName && Number(t.parent) > 0 && !curParentIncident) {
const pr = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', Number(t.parent)]], fields: ['name', 'is_incident'], limit: 1 })
const parent = pr && pr[0]
if (parent && parent.name) {
if (!dryRun) { await erp.update('Issue', issName, { parent_incident: parent.name }); if (!parent.is_incident) await erp.update('Issue', parent.name, { is_incident: 1 }) }
_createStats.parent_incident_linked++
}
}
const od = tzDate(t.date_create); if (od) doc.opening_date = od
const r = await erp.create('Issue', doc)
if (r && r.ok && r.name) { _createStats.issues_created++; return { action: 'created', issue: r.name } }
log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create'))
return { action: iss ? 'ok' : 'created', issue: issName }
} catch (e) { log('ensureIssueCustomer: exception ticket#' + legacyId + ' — ' + (e && e.message)) }
return null
}
@ -699,6 +728,7 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i
const ex = await findExisting(String(t.id))
if (ex && ex.assigned_tech && hasCo(ex.latitude) && hasCo(ex.longitude)) { skipped++; if (dryRun) details.push({ legacy_id: String(t.id), action: 'exists-complete' }); continue }
const b = await buildJob(t, { dryRun })
let jobName = ex ? ex.name : ''; const jobSrcIssue = ex ? (ex.source_issue || '') : ''; const jobDependsOn = ex ? (ex.depends_on || '') : '' // relations natives (source_issue + depends_on)
const isClosed = String(t.tk_status) === 'closed'
b.payload.assigned_tech = tech; b.payload.status = isClosed ? 'Completed' : 'assigned' // ticket F fermé → job déjà fait
if (ex) {
@ -715,13 +745,15 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', tech, subject: b.payload.subject, dept: t.dept, scheduled_date: b.payload.scheduled_date || null, skill: deptSkill(t.dept || subj), customer: b.matched.customer_name, customer_matched: b.matched.customer, coords: b.matched.coords, coord_src: b.matched.coord_src })
} else {
const r = await erp.create('Dispatch Job', b.payload)
if (r && r.ok) created++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
if (r && r.ok) { created++; jobName = r.name } else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
}
// Relie l'Issue sous-jacente au client (les DJ « complets » court-circuités plus haut sont couverts par backfillIssueCustomers).
if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun })
// Relie l'Issue au client (+ hiérarchie parent_incident) ; puis lie le DJ à SON Issue (source_issue = relation NATIVE durable).
if (b.payload.customer) { const ir = await ensureIssueCustomer(t, b.payload.customer, { dryRun }); if (ir && ir.issue) await linkJobSourceIssue(jobName, jobSrcIssue, ir.issue, dryRun) }
// Dépendance native : ticket.waiting_for → depends_on (Job du ticket attendu). Lien seul.
if (Number(t.waiting_for) > 0 && jobName) { const wj = await findExisting(String(t.waiting_for)); if (wj && wj.name) await linkJobDependsOn(jobName, jobDependsOn, wj.name, dryRun) }
} catch (e) { errors++; errSamples.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) }
}
return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) }
return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, source_issue_linked: _createStats.source_issue_linked, parent_incident_linked: _createStats.parent_incident_linked, depends_on_linked: _createStats.depends_on_linked, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) }
}
function ingestAssigned (opts = {}) { const run = _syncLock.then(() => ingestAssignedImpl(opts), () => ingestAssignedImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel que sync (frappe_pg)
@ -740,6 +772,7 @@ async function syncImpl ({ dryRun = false } = {}) {
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
const ex = await findExisting(b.legacy_id)
let jobName = ex ? ex.name : ''; const jobSrcIssue = ex ? (ex.source_issue || '') : ''; const jobDependsOn = ex ? (ex.depends_on || '') : '' // relations natives (source_issue + depends_on)
if (ex) {
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
// s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur.
@ -772,18 +805,20 @@ async function syncImpl ({ dryRun = false } = {}) {
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, parent: b.matched.parent, addr: b.addr })
} else {
const r = await erp.create('Dispatch Job', b.payload)
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
if (r && r.ok) { created++; jobName = r.name; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
}
// Relie l'Issue sous-jacente au client (indépendant du DJ : couvre aussi le cas « DJ déjà là mais Issue.customer vide »).
if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun })
// Relie l'Issue sous-jacente au client (+ hiérarchie parent_incident) ; puis lie le DJ à SON Issue (source_issue = relation NATIVE durable).
if (b.payload.customer) { const ir = await ensureIssueCustomer(t, b.payload.customer, { dryRun }); if (ir && ir.issue) await linkJobSourceIssue(jobName, jobSrcIssue, ir.issue, dryRun) }
// Dépendance native : ticket.waiting_for → depends_on (Job du ticket attendu). Lien seul.
if (Number(t.waiting_for) > 0 && jobName) { const wj = await findExisting(String(t.waiting_for)); if (wj && wj.name) await linkJobDependsOn(jobName, jobDependsOn, wj.name, dryRun) }
} catch (e) {
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
}
}
let closedResolved = 0
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, source_issue_linked: _createStats.source_issue_linked, parent_incident_linked: _createStats.parent_incident_linked, depends_on_linked: _createStats.depends_on_linked, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
return { ...summary, details }
}
@ -1118,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 () {
@ -1294,13 +1330,22 @@ function startSync () {
// ingestAssigned re-scanne ~1000 tickets → on ne le lance qu'1 tick sur N (défaut 4 ≈ horaire à 15 min), pas à chaque tick.
// (Le court-circuit « déjà importé » rend les passages en régime permanent rapides, mais on borne quand même la cadence.)
const assignedEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_ASSIGNED_EVERY) || 4)
// AUTO-CORRECTION des coords PÉRIMÉES : les coords erronées (posées par un ancien géocodage, avant que la fibre indexe
// l'adresse) NE se corrigeaient jamais toutes seules — la sync ne remplit que les coords MANQUANTES (0,0/NULL). Ici on
// relance geolocateJobs({refreshFibre}) qui RE-vérifie les jobs DÉJÀ coordonnés et n'écrase QUE si la fibre CONFIRME la
// rue ET que le déplacement > 100 m (cf. geolocateJobs) → les vieux pins décalés se soignent seuls. 1 tick sur N (~horaire).
// Désactivable via LEGACY_DISPATCH_REFRESH_FIBRE_AUTO=off.
const refreshFibreAuto = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_REFRESH_FIBRE_AUTO || ''))
const refreshFibreEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_REFRESH_FIBRE_EVERY) || 4)
let _tickN = 0
const tick = () => {
if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return }
_syncInFlight = true
const runAssigned = assignedAuto && (_tickN % assignedEvery === 0); _tickN++
const runAssigned = assignedAuto && (_tickN % assignedEvery === 0)
const runRefresh = refreshFibreAuto && (_tickN % refreshFibreEvery === 0); _tickN++
sync({ dryRun: false })
.then(() => runAssigned ? ingestAssigned({ dryRun: false, allDates: true }) : null)
.then(() => runRefresh ? geolocateJobs({ dryRun: false, refreshFibre: true }).then(r => { if (r && r.written) log(`legacy-dispatch-sync: auto-refresh fibre → ${r.written} coord(s) corrigée(s)`) }) : null)
.catch(e => log('legacy-dispatch-sync tick error:', e.message))
.finally(() => { _syncInFlight = false })
}
@ -1315,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 »).
@ -1601,15 +1655,21 @@ async function geolocateFromInfra ({ dryRun = true, limit = 1000, jobs = true }
else { none++; continue }
updates.push({ name: s.name, did: +s.legacy_delivery_id, lat: +(+lat).toFixed(6), lon: +(+lon).toFixed(6), src })
}
let writtenSL = 0, writtenJobs = 0, blockedNoCustomer = 0, failedSL = 0
let writtenSL = 0, writtenSLsql = 0, writtenJobs = 0, blockedNoCustomer = 0, failedSL = 0
if (!dryRun) {
// ⚠️ erp.update NE LÈVE PAS : il renvoie {ok:false,error} → toujours vérifier .ok. Une SL sans `customer` → MandatoryError au PUT.
for (const u of updates) {
const r = await erp.update('Service Location', u.name, { latitude: u.lat, longitude: u.lon }).catch(e => ({ ok: false, error: e.message }))
if (r && r.ok) writtenSL++
else if (/customer/i.test(String((r && r.error) || ''))) blockedNoCustomer++
else { failedSL++; log('geoloc SL ' + u.name + ': ' + ((r && r.error) || 'échec')) }
else if (/customer/i.test(String((r && r.error) || ''))) {
// SL SANS `customer` (fibre DISPONIBLE, pas encore de client) = cas NORMAL, PAS orphelin. Le PUT ERPNext re-valide
// tout le doc → MandatoryError. On écrit alors les coords en DIRECT via le pool PG (même DB ERPNext que address-db) :
// les coords n'ont AUCUNE dénormalisation dépendante, donc bypass validation = sûr (idem backfill batch 2026-07-14).
try { const rr = await addrdb.pool().query('UPDATE "tabService Location" SET latitude=$1, longitude=$2, modified=now() WHERE name=$3', [u.lat, u.lon, u.name]); if (rr && rr.rowCount) writtenSLsql++; else blockedNoCustomer++ } catch (e) { blockedNoCustomer++; log('geoloc SL SQL ' + u.name + ': ' + e.message) }
} else { failedSL++; log('geoloc SL ' + u.name + ': ' + ((r && r.error) || 'échec')) }
}
// Propager les coords écrites (REST ou SQL) vers gf_latitude/gf_longitude des Address liées encore à 0 (source géo canonique du collapse).
try { await addrdb.pool().query('UPDATE "tabAddress" a SET gf_latitude=sl.latitude, gf_longitude=sl.longitude, modified=now() FROM "tabService Location" sl WHERE sl.linked_address=a.name AND sl.latitude IS NOT NULL AND abs(sl.latitude)>0.01 AND (a.gf_latitude IS NULL OR abs(coalesce(a.gf_latitude,0))<0.01) AND sl.name = ANY($1)', [updates.map(u => u.name)]) } catch (e) { log('geoloc gf_ propagate: ' + e.message) }
if (jobs && updates.length) {
const coordByLoc = {}; for (const u of updates) coordByLoc[u.name] = u
const locNames = updates.map(u => u.name)
@ -1620,7 +1680,7 @@ async function geolocateFromInfra ({ dryRun = true, limit = 1000, jobs = true }
}
}
}
return { ok: true, dryRun, missing: missing.length, resolvable: updates.length, via_placemarks: viaPm, via_delivery: viaDeliv, via_fibre_addr: viaFibre, via_nom: viaNom, unresolved: none, written_service_locations: writtenSL, blocked_no_customer: blockedNoCustomer, failed: failedSL, written_jobs: writtenJobs, sample: updates.slice(0, 8) }
return { ok: true, dryRun, missing: missing.length, resolvable: updates.length, via_placemarks: viaPm, via_delivery: viaDeliv, via_fibre_addr: viaFibre, via_nom: viaNom, unresolved: none, written_service_locations: writtenSL, written_sql_no_customer: writtenSLsql, blocked_no_customer: blockedNoCustomer, failed: failedSL, written_jobs: writtenJobs, sample: updates.slice(0, 8) }
}
// ── Géoloc par ADRESSE (réutilisable, tolérant aux variantes + COMPARAISON DE RUE pour désambiguïser) ──
@ -1635,20 +1695,48 @@ function _parseAddr (address) {
// Civique AVEC le suffixe d'unité « -4 » (format F « bâtiment-unité », ex. « 102-4 ») : la fibre indexe le terrain « 102-4 »,
// donc « terrain LIKE '102-4%' » cible le BON immeuble ; « 102 » seul ratissait toutes les rues avec un civique 102.
const cm = first.match(/^\s*(\d+(?:-\d+)?)\s*[A-Za-z]?/); const civic = cm ? cm[1] : ''
const street = first.replace(/^\s*\d+(?:-\d+)?\s*[A-Za-z]?\s*/, '').trim()
// Le suffixe d'unité (« 3A ») doit être COLLÉ au numéro puis suivi d'un espace ; sinon le `\s*[A-Za-z]?`
// mangeait le « R » de « Rue » (ex. « 4-3 Rue Des Cèdres » → street « ue Des Cèdres »).
const street = first.replace(/^\s*\d+(?:-\d+)?[A-Za-z]?\s+/, '').trim()
return { zip, civic, street }
}
// Un civique « A-B » est AMBIGU : bâtiment-unité (F indexe le terrain « A-B », ex. « 102-4 Rue Elsie ») OU unité-civique
// (le VRAI civique est le 2ᵉ nombre, ex. « 4-3 Rue Des Cèdres » = app. 4 au civique 3, F indexe « 3 »). On essaie les deux
// ordres, du plus précis au plus large, et la RUE tranche (cf. fibreMatch). Un civique simple garde le comportement historique.
function _civicCandidates (civic) {
const c = String(civic || '').trim(); if (!c) return []
const m = c.match(/^(\d+)-(\d+)$/); if (!m) return [{ pat: c + '%', kind: 'like' }]
const [, a, b] = m
return [
{ pat: a + '-' + b + '%', kind: 'like' }, // A-B : bâtiment-unité (comportement Elsie, historique)
{ pat: b + '-' + a + '%', kind: 'like' }, // B-A : ordre inverse stocké
{ pat: b, kind: 'exact' }, // B : unité-civique → civique = 2ᵉ nombre
{ pat: a, kind: 'exact' } // A : repli (civique = 1ᵉ nombre, unité au 2ᵉ)
]
}
// Match fibre par zip+civique, DÉSAMBIGUÏSÉ par la rue (compare quand plusieurs civiques identiques). → ligne fibre ou null.
// Tolère l'ordre AMBIGU du civique « A-B » (bâtiment-unité vs unité-civique) via _civicCandidates : le candidat as-parsed
// garde le comportement historique ; chaque ordre ALTERNATIF n'est accepté que si la RUE concorde (jamais deviné).
async function fibreMatch (p, zip, civic, street) {
const z = String(zip || '').replace(/\s/g, '').toUpperCase(); const cv = String(civic || '').trim()
if (!z || !cv) return null
let rows = []
try { [rows] = await p.query("SELECT placemarks_id pmid, latitude flat, longitude flon, rue FROM fibre WHERE REPLACE(UPPER(zip),' ','')=? AND terrain LIKE ? LIMIT 12", [z, cv + '%']) } catch (e) { return null }
if (!rows.length) return null
if (rows.length === 1) return rows[0]
const z = String(zip || '').replace(/\s/g, '').toUpperCase()
const cands = _civicCandidates(civic)
if (!z || !cands.length) return null
const ns = _normStreet(street)
if (ns) { const m = rows.find(r => { const rr = _normStreet(r.rue); return rr && (rr === ns || (rr.length >= 4 && (rr.includes(ns) || ns.includes(rr)))) }); if (m) return m }
return null // plusieurs civiques + aucune rue concordante → on NE devine pas (évite le mauvais « 95 Rue Principale » vs « Rang du Cinq »)
const streetHit = (rows) => ns ? rows.find(r => { const rr = _normStreet(r.rue); return rr && (rr === ns || (rr.length >= 4 && (rr.includes(ns) || ns.includes(rr)))) }) : null
for (let i = 0; i < cands.length; i++) {
const cand = cands[i]
const sql = "SELECT placemarks_id pmid, latitude flat, longitude flon, rue FROM fibre WHERE REPLACE(UPPER(zip),' ','')=? AND " +
(cand.kind === 'exact' ? 'terrain=?' : 'terrain LIKE ?') + ' LIMIT 12'
let rows = []
try { [rows] = await p.query(sql, [z, cand.pat]) } catch (e) { return null }
if (!rows.length) continue
// Ordre ALTERNATIF (i>0) : n'accepter que si la RUE concorde — un civique inversé/partiel ne doit JAMAIS être deviné
// sans confirmation de rue. Le candidat as-parsed (i===0) garde le comportement historique (row unique accepté).
if (rows.length === 1) { if (i === 0 || !ns) return rows[0]; if (_streetConfirmed(rows[0].rue, street)) return rows[0]; continue }
const m = streetHit(rows); if (m) return m
// plusieurs civiques + aucune rue concordante → on NE devine pas (évite le mauvais « 95 Rue Principale » vs « Rang du Cinq »)
}
return null
}
function _haversineM (la1, lo1, la2, lo2) { const R = 6371000, r = Math.PI / 180; const dLa = (la2 - la1) * r, dLo = (lo2 - lo1) * r; const a = Math.sin(dLa / 2) ** 2 + Math.cos(la1 * r) * Math.cos(la2 * r) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)) }
@ -1876,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)
@ -2147,4 +2246,245 @@ async function backfillIssueCustomersImpl ({ dryRun = true, limit = 0, throttleM
return { ok: true, dryRun, jobs: totalJobs, already_linked: alreadyLinked, linked, created_issues: createdIssues, missing_issue: missingIssue, errors, sample: samples, 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, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
// Backfill du lien NATIF durable Dispatch Job → Issue (`source_issue`) pour TOUS les jobs legacy (y compris ceux
// court-circuités par le sync). Résout l'Issue par legacy_ticket_id. Idempotent (ne touche que source_issue vide).
// C'est ce qui fait SURVIVRE la relation ticket↔job à une coupure de F (legacy_ticket_id devient une clé morte).
function backfillSourceIssue (opts = {}) { const run = _syncLock.then(() => backfillSourceIssueImpl(opts), () => backfillSourceIssueImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run }
async function backfillSourceIssueImpl ({ dryRun = true, limit = 0, throttleMs = 40 } = {}) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const jobs = []
for (let start = 0; ; start += 500) {
const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['source_issue', 'is', 'not set']], fields: ['name', 'legacy_ticket_id'], limit: 500, start })
if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start }
jobs.push(...r.rows)
if (r.rows.length < 500) break
if (limit > 0 && jobs.length >= limit) break
}
const rows = limit > 0 ? jobs.slice(0, limit) : jobs
const ids = [...new Set(rows.map(j => Number(j.legacy_ticket_id)).filter(Number.isFinite))]
const issByTk = new Map()
for (let i = 0; i < ids.length; i += 200) {
const chunk = ids.slice(i, i + 200)
const iss = await erp.list('Issue', { filters: [['legacy_ticket_id', 'in', chunk]], fields: ['name', 'legacy_ticket_id'], limit: chunk.length + 10 })
for (const r of (iss || [])) issByTk.set(Number(r.legacy_ticket_id), r)
}
let linked = 0, missingIssue = 0, errors = 0; const errSamples = []
for (const j of rows) {
const iss = issByTk.get(Number(j.legacy_ticket_id))
if (!iss) { missingIssue++; continue }
if (dryRun) { linked++; continue }
try { const r = await erp.update('Dispatch Job', j.name, { source_issue: iss.name }); if (r && r.ok) linked++; else { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: (r && r.error) || 'update' }) } }
catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: String((e && e.message) || e) }) }
if (throttleMs) await sleep(throttleMs)
}
return { ok: true, dryRun, jobs: rows.length, linked, missing_issue: missingIssue, errors, error_samples: errSamples }
}
// Backfill de la DÉPENDANCE NATIVE Job→Job (`depends_on`) depuis legacy `ticket.waiting_for` (le ticket attend un
// AUTRE ticket). LIEN SEUL (pas d'On Hold auto — visibilité pool inchangée). Idempotent (ne touche que depends_on vide).
function backfillDependsOn (opts = {}) { const run = _syncLock.then(() => backfillDependsOnImpl(opts), () => backfillDependsOnImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run }
async function backfillDependsOnImpl ({ dryRun = true, throttleMs = 40 } = {}) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' }
const [wrows] = await p.query('SELECT id, waiting_for FROM ticket WHERE waiting_for > 0')
const waitMap = new Map(); for (const r of (wrows || [])) waitMap.set(Number(r.id), Number(r.waiting_for))
if (!waitMap.size) return { ok: true, dryRun, legacy_pairs: 0, candidates: 0, linked: 0, missing_job: 0, errors: 0 }
// Jobs candidats : depends_on vide + legacy_ticket_id présent, dont le ticket a un waiting_for.
const cand = []
for (let start = 0; ; start += 500) {
const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['depends_on', 'is', 'not set']], fields: ['name', 'legacy_ticket_id'], limit: 500, start })
if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start }
for (const j of r.rows) if (waitMap.has(Number(j.legacy_ticket_id))) cand.push(j)
if (r.rows.length < 500) break
}
// Résout le Job du ticket ATTENDU (par legacy_ticket_id).
const waitingIds = [...new Set(cand.map(j => waitMap.get(Number(j.legacy_ticket_id))).filter(Number.isFinite))]
const jobByTk = new Map()
for (let i = 0; i < waitingIds.length; i += 200) {
const chunk = waitingIds.slice(i, i + 200)
const js = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', chunk.map(String)]], fields: ['name', 'legacy_ticket_id'], limit: chunk.length + 10 })
for (const r of (js || [])) jobByTk.set(Number(r.legacy_ticket_id), r.name)
}
let linked = 0, missingJob = 0, errors = 0; const errSamples = []
for (const j of cand) {
const wjob = jobByTk.get(waitMap.get(Number(j.legacy_ticket_id)))
if (!wjob || wjob === j.name) { missingJob++; continue }
if (dryRun) { linked++; continue }
try { const r = await erp.update('Dispatch Job', j.name, { depends_on: wjob }); if (r && r.ok) linked++; else { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: (r && r.error) || 'update' }) } }
catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: String((e && e.message) || e) }) }
if (throttleMs) await sleep(throttleMs)
}
return { ok: true, dryRun, legacy_pairs: waitMap.size, candidates: cand.length, linked, missing_job: missingJob, errors, error_samples: errSamples }
}
// ─── 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