Compare commits
5 Commits
8e7838d1fb
...
e43f04d3e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e43f04d3e5 | ||
|
|
956e96b8bc | ||
|
|
e0e1da1c01 | ||
|
|
c7c9f81364 | ||
|
|
48edede793 |
49
apps/ops/src/components/planif/WeekTemplatesDialog.vue
Normal file
49
apps/ops/src/components/planif/WeekTemplatesDialog.vue
Normal 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 où 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>
|
||||
|
|
@ -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">👁 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 & 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="[
|
||||
<!-- 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) {
|
||||
// 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)
|
||||
jobs = jobs.filter(j => j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
|
||||
}
|
||||
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; }
|
||||
|
|
|
|||
|
|
@ -78,12 +78,12 @@ File layout: template L1–1768 · script L1770–6033 (~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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
// 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).
|
||||
} 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,
|
||||
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) { _createStats.issues_created++; return { action: 'created', issue: r.name } }
|
||||
log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create'))
|
||||
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 }
|
||||
}
|
||||
}
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user