feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports

Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-15 06:12:12 -04:00
parent 19d31025a6
commit 0f65c02d83
76 changed files with 9276 additions and 321 deletions

4
.gitignore vendored
View File

@ -51,3 +51,7 @@ services/targo-hub/templates/*.bak-*.html
# Legacy refresh creds (prod-only, never commit)
.refresh.env
**/.refresh.env
# Python bytecode
__pycache__/
*.pyc

View File

@ -112,6 +112,10 @@ export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
export const removeAssistant = (job, techId) => jpost('/roster/job/team', { job, remove: techId })
// Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel
// Lien app PWA du tech (à copier / SMS) + son téléphone
export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech))
@ -123,7 +127,15 @@ export const startJob = (job) => jpost('/roster/job/start', { job })
export const finishJob = (job) => jpost('/roster/job/finish', { job })
export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {})
// Jobs Legacy (osTicket) ouverts assignés à UN tech (lecture seule) — vue « jobs courants du tech »
export const legacyTechTickets = (tech) => jget('/dispatch/legacy-sync/tech-tickets?tech=' + encodeURIComponent(tech))
// Charge Legacy DATÉE par tech×jour sur la fenêtre affichée → durées estimées appliquées aux timelines
export const legacyWindowLoad = (start, days) => jget('/dispatch/legacy-sync/window-load?start=' + encodeURIComponent(start) + '&days=' + encodeURIComponent(days))
// Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket
export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
// URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement.
export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon).
export const traccarTrack = (tech, from, to) => jget('/traccar/track?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))

View File

@ -104,7 +104,7 @@ import { createDoc } from 'src/api/erp'
import { Device } from '@twilio/voice-sdk'
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const SIP_WSS_URL = 'wss://voice.gigafibre.ca/ws'
const props = defineProps({

View File

@ -64,7 +64,7 @@ const props = defineProps({
const emit = defineEmits(['action', 'applied'])
const $q = useQuasar()
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const inputRef = ref(null)
const text = ref('')

View File

@ -88,6 +88,7 @@ export const PROJECT_KINDS = {
label: 'Ticket',
icon: 'confirmation_number',
color: '#f59e0b',
help: 'Crée un ticket (ERPNext Issue) quand le flow atteint cette étape — c\'est l\'action « créer un ticket ».',
fields: [
{ name: 'subject', type: 'text', label: 'Sujet', required: true },
{ name: 'description', type: 'textarea', label: 'Description' },

View File

@ -0,0 +1,150 @@
<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs">
<q-icon name="hub" size="20px" color="deep-purple-6" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Modèles IA routage & repli</span>
<q-space />
<q-spinner v-if="loading" size="16px" color="deep-purple-6" />
<q-btn flat round dense icon="refresh" size="sm" @click="load"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div>
<div class="text-caption text-grey-6 q-mb-md">
Choisis le modèle par tâche. <b>Gemini</b> reste le <b>repli automatique</b> si le modèle local échoue ou est indisponible. Le plan de contrôle (scripts, solveur) reste déterministe l'IA ne fait que proposer.
</div>
<!-- Profils -->
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-12 col-sm-6" v-for="p in profiles" :key="p.name">
<div class="prof-card" :class="{ avail: p.available }">
<div class="row items-center no-wrap">
<q-icon :name="p.name === 'gemini' ? 'cloud' : 'dns'" :color="p.available ? 'deep-purple-5' : 'grey-5'" class="q-mr-sm" />
<div>
<div class="text-weight-medium">{{ p.label }}</div>
<div class="text-caption text-grey-6">{{ p.model || '—' }}<span v-if="p.base"> · {{ p.base }}</span></div>
</div>
<q-space />
<q-badge :color="p.available ? 'green-2' : 'grey-3'" :text-color="p.available ? 'green-9' : 'grey-7'">{{ p.available ? 'disponible' : 'indisponible' }}</q-badge>
</div>
</div>
</div>
</div>
<!-- Config du profil local -->
<div class="local-cfg q-mb-md">
<div class="text-subtitle2 q-mb-xs">Profil local (vLLM / Ollama QWEN / Hermes)</div>
<div class="row q-col-gutter-sm">
<q-input class="col-12 col-sm-6" dense outlined v-model="local.baseUrl" label="URL base (OpenAI-compat)" placeholder="http://gpu-host:8000/v1/" hint="Slash final requis" />
<q-input class="col-12 col-sm-6" dense outlined v-model="local.model" label="Modèle" placeholder="Qwen/Qwen2.5-14B-Instruct" />
</div>
<div v-if="!localKeySet" class="text-caption text-orange-8 q-mt-xs">
<q-icon name="warning" size="14px" /> Clé absente : définis <code>AI_LOCAL_API_KEY</code> dans <code>/opt/targo-hub/.env</code> (les secrets restent côté serveur, jamais dans l'UI), puis recrée le conteneur.
</div>
<div class="row items-center q-gutter-sm q-mt-sm">
<q-btn dense outline color="deep-purple-6" icon="network_check" label="Tester la connexion" no-caps :loading="testing" :disable="!localAvailable" @click="testLocal" />
<span v-if="testResult" :class="testResult.ok ? 'text-green-8' : 'text-red-7'" class="text-caption">
<q-icon :name="testResult.ok ? 'check_circle' : 'error'" size="14px" />
{{ testResult.ok ? `OK · ${testResult.model} · ${testResult.latency_ms} ms` : ('Échec : ' + testResult.error) }}
</span>
<span v-else-if="!localAvailable" class="text-caption text-grey-6">Configure base + modèle + clé .env pour activer.</span>
</div>
</div>
<!-- Routage par tâche -->
<div class="text-subtitle2 q-mb-xs">Routage par tâche</div>
<div v-for="t in tasks" :key="t" class="row items-center q-py-xs q-px-sm route-row">
<q-icon :name="taskIcon(t)" size="18px" color="grey-7" class="q-mr-sm" />
<div class="col">
<div class="text-body2">{{ taskLabel(t) }}</div>
<div v-if="routing[t] && routing[t].wanted === 'local' && routing[t].effective !== 'local'" class="text-caption text-orange-8"> replié sur Gemini (local indisponible)</div>
</div>
<q-btn-toggle v-model="wanted[t]" :options="toggleOptions" dense unelevated no-caps toggle-color="deep-purple-6" color="grey-3" text-color="grey-8" size="sm" />
</div>
<div class="row justify-end q-mt-md">
<q-btn unelevated color="deep-purple-6" icon="save" label="Enregistrer le routage" no-caps :loading="saving" @click="save" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const loading = ref(false)
const saving = ref(false)
const profiles = ref([])
const tasks = ref([])
const routing = ref({})
const wanted = reactive({})
const local = reactive({ baseUrl: '', model: '' })
const localKeySet = ref(false)
const testing = ref(false)
const testResult = ref(null)
const TASK_LABELS = {
triage: 'Triage courriels', nl: 'Copilote (langage naturel)', split: 'Scinder une conversation',
sms: 'Répondeur SMS', roster: 'Copilote planification', dispatch: 'Dispatch (suggestions IA)',
diagnose: 'Diagnostic réseau', outage: 'Analyse de panne',
}
const TASK_ICONS = {
triage: 'mail', nl: 'auto_awesome', split: 'call_split', sms: 'sms',
roster: 'event', dispatch: 'local_shipping', diagnose: 'troubleshoot', outage: 'warning',
}
function taskLabel (t) { return TASK_LABELS[t] || t }
function taskIcon (t) { return TASK_ICONS[t] || 'smart_toy' }
const localAvailable = computed(() => (profiles.value.find(p => p.name === 'local') || {}).available)
const toggleOptions = computed(() => [
{ label: 'Gemini', value: 'gemini' },
{ label: 'Local', value: 'local', disable: !localAvailable.value },
])
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/ai/route-status`)
if (r.ok) {
const d = await r.json()
profiles.value = d.profiles || []
tasks.value = d.tasks || []
routing.value = d.routing || {}
for (const t of tasks.value) wanted[t] = (d.routing[t] && d.routing[t].wanted) || 'gemini'
const loc = profiles.value.find(p => p.name === 'local') || {}
local.baseUrl = loc.base ? (loc.base.endsWith('/') ? loc.base : loc.base + '/') : ''
local.model = loc.model || ''
localKeySet.value = !!loc.key_set
}
} catch (e) { /* */ } finally { loading.value = false }
}
async function save () {
saving.value = true
try {
const routes = {}
for (const t of tasks.value) routes[t] = wanted[t]
const r = await fetch(`${HUB_URL}/ai/routes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routes, local: { baseUrl: local.baseUrl, model: local.model } }) })
if (r.ok) { const d = await r.json(); profiles.value = d.profiles || profiles.value; routing.value = d.routing || routing.value; $q.notify({ type: 'positive', message: 'Routage IA enregistré' }) }
else $q.notify({ type: 'negative', message: 'Échec sauvegarde' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { saving.value = false }
}
async function testLocal () {
testing.value = true; testResult.value = null
try {
const r = await fetch(`${HUB_URL}/ai/route-test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile: 'local' }) })
testResult.value = await r.json()
} catch (e) { testResult.value = { ok: false, error: e.message } } finally { testing.value = false }
}
onMounted(load)
</script>
<style scoped>
.prof-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; background: #fafafd; }
.prof-card.avail { border-color: #c4b5fd; background: #f5f3ff; }
.local-cfg { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; background: #fafbff; }
.route-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
.route-row:hover { background: #f5f3ff; }
code { background: #eef2ff; padding: 1px 5px; border-radius: 4px; font-size: .82em; }
</style>

View File

@ -0,0 +1,105 @@
<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs">
<q-icon name="quickreply" size="20px" color="indigo-6" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Signatures & réponses pré-écrites</span>
<q-space />
<q-spinner v-if="loading" size="16px" color="indigo-6" />
<q-btn dense unelevated color="indigo-5" icon="add" label="Nouvelle" no-caps @click="addNew" />
</div>
<div class="text-caption text-grey-6 q-mb-md">
Réutilisables dans l'Inbox (menu « Réponses types » sous la réponse). Éditeur HTML : gras, listes, liens et <b>images / logo</b> — ou collez votre signature Gmail. Variables : <code v-pre>{{client}}</code>, <code v-pre>{{agent}}</code> (remplacées à l'insertion). Associez une <b>mailbox</b> (ex. factures@ « Comptes Payables »).
</div>
<div v-for="(c, i) in list" :key="c.id" class="canned-row q-mb-md">
<div class="row items-center q-gutter-sm q-mb-xs">
<q-input dense outlined v-model="c.title" label="Titre (ex. Signature Support)" class="col" />
<q-select dense outlined v-model="c.mailbox" :options="mailboxes" emit-value map-options label="Mailbox / file" style="min-width:185px" />
<q-btn flat round dense icon="delete" color="red-4" @click="remove(i)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</div>
<q-editor v-model="c.html" min-height="7rem" class="canned-editor"
:toolbar="[['bold','italic','underline'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]"
placeholder="Texte / signature… (image via le bouton ci-dessous, ou collez depuis Gmail)" />
<div class="row items-center q-mt-xs">
<q-btn flat dense size="sm" icon="image" color="indigo-6" no-caps label="Image / logo" :loading="upBusy === c.id" @click="pickImage(c)" />
<span class="text-caption text-grey-5 q-ml-sm">PNG/JPG/SVG · hébergée sur nos serveurs (visible chez le destinataire)</span>
</div>
</div>
<div v-if="!list.length" class="text-caption text-grey-5 q-py-sm">Aucune cliquez « Nouvelle » pour en créer une.</div>
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none" @change="onFile" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="save" label="Enregistrer" no-caps :loading="saving" @click="persist" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const list = ref([])
const loading = ref(false)
const saving = ref(false)
const upBusy = ref(null)
const fileInput = ref(null)
let _target = null
const mailboxes = [
{ label: 'Toutes', value: '' },
{ label: 'Facturations (Comptes Payables)', value: 'Facturations' },
{ label: 'Service à la clientèle', value: 'Service à la clientèle' },
{ label: 'Support', value: 'Supports' },
{ label: 'Technicien', value: 'Technicien' },
]
function uid () { try { return crypto.randomUUID() } catch (e) { return 'c' + Date.now() + Math.round(Math.random() * 1e6) } }
function textToHtml (t) { return String(t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>') }
function htmlToText (h) { return String(h || '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim() }
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/conversations/canned`)
if (r.ok) { const d = await r.json(); list.value = (d.responses || []).map(x => ({ id: x.id || uid(), title: x.title || '', mailbox: x.mailbox || '', category: x.category || 'Général', html: x.html || textToHtml(x.body) })) }
} catch (e) { /* */ } finally { loading.value = false }
}
function addNew () { list.value.push({ id: uid(), title: '', mailbox: '', category: 'Général', html: '' }) }
function remove (i) { list.value.splice(i, 1) }
function pickImage (c) { _target = c; if (fileInput.value) { fileInput.value.value = ''; fileInput.value.click() } }
async function onFile (e) {
const f = e.target.files && e.target.files[0]; const c = _target
if (!f || !c) return
upBusy.value = c.id
try {
const data = await new Promise((resolve, reject) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = reject; fr.readAsDataURL(f) })
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: f.name, data }) })
const d = await r.json().catch(() => ({}))
if (!r.ok || !d.url) throw new Error(d.error || 'Téléversement échoué')
c.html = (c.html || '') + `<img src="${d.url}" alt="" style="max-width:280px;height:auto" />`
$q.notify({ type: 'positive', message: 'Image ajoutée à la signature' })
} catch (err) { $q.notify({ type: 'negative', message: err.message }) } finally { upBusy.value = null; _target = null }
}
async function persist () {
saving.value = true
try {
const clean = list.value
.filter(c => (c.title || '').trim() || htmlToText(c.html).trim())
.map(c => ({ id: c.id, title: c.title, category: c.category, mailbox: c.mailbox || '', html: c.html || '', body: htmlToText(c.html) }))
const r = await fetch(`${HUB_URL}/conversations/canned`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ responses: clean }) })
if (r.ok) $q.notify({ type: 'positive', message: 'Réponses enregistrées' })
else $q.notify({ type: 'negative', message: 'Échec sauvegarde' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { saving.value = false }
}
onMounted(load)
</script>
<style scoped>
.canned-row { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; background: #fafbff; }
.canned-editor { background: #fff; }
code { background: #eef2ff; padding: 1px 5px; border-radius: 4px; font-size: .8em; }
</style>

View File

@ -0,0 +1,185 @@
<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs">
<q-icon name="inbox" size="20px" color="indigo-6" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Files (Inbox) & équipes</span>
<q-space />
<q-spinner v-if="loading" size="16px" color="indigo-6" />
</div>
<div class="text-caption text-grey-6 q-mb-md">
Qui reçoit et traite les demandes de chaque file. À l'arrivée d'une demande, ses membres sont notifiés (courriel + Inbox). Même logique que les groupes de droits on cherche des utilisateurs et on les ajoute.
</div>
<!-- Liste des files -->
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6 col-md-3" v-for="q in queues" :key="q">
<div class="queue-card" :class="{ sel: selected === q }" @click="select(q)">
<div class="row items-center no-wrap">
<q-icon name="groups" size="18px" :color="selected === q ? 'indigo-6' : 'grey-6'" class="q-mr-sm" />
<span class="text-weight-medium">{{ label(q) }}</span>
<q-space />
<q-badge :color="(members[q] || []).length ? 'indigo-1' : 'grey-3'" :text-color="(members[q] || []).length ? 'indigo-8' : 'grey-6'">
{{ (members[q] || []).length }}
</q-badge>
</div>
</div>
</div>
</div>
<!-- Détail file -->
<q-slide-transition>
<div v-if="selected" class="queue-detail q-mt-md">
<div class="row items-center q-mb-sm">
<q-icon name="groups" size="22px" color="indigo-6" class="q-mr-sm" />
<span class="text-h6">{{ label(selected) }}</span>
<q-space />
<q-btn flat round dense icon="close" @click="selected = null" />
</div>
<!-- Nom d'affichage (ex. singulier) le nom réel (clé, = F, souvent au pluriel) reste inchangé -->
<div class="row items-center q-gutter-sm q-mb-md">
<q-input dense outlined v-model="labelDraft" label="Nom affiché" style="min-width:220px" @keyup.enter="saveLabel" @blur="saveLabel">
<template #append><q-icon v-if="(labels[selected] || selected) !== labelDraft" name="edit" size="16px" color="indigo-5" /></template>
</q-input>
<span class="text-caption text-grey-6">Nom réel (F) : <b>{{ selected }}</b> inchangé</span>
</div>
<div class="text-subtitle2 q-mb-xs">Membres ({{ (members[selected] || []).length }})</div>
<div v-for="email in (members[selected] || [])" :key="email" class="row items-center q-py-xs q-px-sm qm-member-row">
<q-avatar size="26px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.7rem">{{ initial(email) }}</q-avatar>
<span class="text-body2">{{ email }}</span>
<q-space />
<q-btn flat round dense icon="person_remove" size="sm" color="red-5" class="qm-del" @click="removeMember(email)"><q-tooltip>Retirer de la file</q-tooltip></q-btn>
</div>
<div v-if="!(members[selected] || []).length" class="text-caption text-grey-5 q-py-sm">Aucun membre personne n'est notifié pour cette file.</div>
<!-- Ajouter un membre (recherche utilisateurs Authentik) -->
<div class="q-mt-sm" style="position:relative">
<q-input v-model="search" label="Ajouter un membre…" outlined dense autocomplete="off" name="qm-add-nope" @update:model-value="onSearch">
<template #append>
<q-spinner v-if="searching" size="16px" color="indigo-6" />
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; results = []" />
</template>
</q-input>
<div v-if="results.length" class="qm-dropdown">
<div v-for="u in results" :key="u.email || u.pk" class="qm-item" @click="addMember(u)">
<q-avatar size="24px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.65rem">{{ initial(u.email || u.name) }}</q-avatar>
<span class="text-body2">{{ u.name || u.username || u.email }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
<q-badge v-if="(members[selected] || []).includes((u.email || '').toLowerCase())" label="déjà membre" color="grey-3" text-color="grey-6" class="q-ml-auto" />
</div>
</div>
</div>
<!-- Importer tout un groupe Authentik (la table explicite reste la source) -->
<div class="row items-center q-gutter-sm q-mt-sm">
<q-select v-model="importGroupName" :options="groupsList" dense outlined options-dense style="min-width:200px" label="Importer un groupe Authentik" clearable emit-value map-options />
<q-btn dense unelevated color="indigo-5" icon="group_add" label="Importer les membres" :disable="!importGroupName" :loading="importing" no-caps @click="importGroup" />
</div>
</div>
</q-slide-transition>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const queues = ref(['Facturations', 'Service à la clientèle', 'Supports', 'Technicien'])
const members = ref({})
const selected = ref(null)
const loading = ref(false)
const search = ref('')
const searching = ref(false)
const results = ref([])
const groupsList = ref([])
const importGroupName = ref(null)
const importing = ref(false)
const labels = ref({}) // nom réel (clé) libellé d'affichage
const labelDraft = ref('')
let _t = null
function initial (s) { return String(s || '?').trim().charAt(0).toUpperCase() }
function label (q) { return labels.value[q] || q } // affichage (singulier possible) ; la clé reste le nom réel
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/conversations/queue-members`)
if (r.ok) { const d = await r.json(); members.value = d.members || {}; labels.value = d.labels || {}; if (d.queues && d.queues.length) queues.value = d.queues }
} catch (e) { /* */ } finally { loading.value = false }
try {
const g = await fetch(`${HUB_URL}/auth/groups`)
if (g.ok) { const d = await g.json(); groupsList.value = (d.groups || d || []).map(x => ({ label: `${x.name}${x.num_users != null ? ' (' + x.num_users + ')' : ''}`, value: x.name })) }
} catch (e) { /* */ }
}
async function importGroup () {
if (!importGroupName.value || !selected.value) return
importing.value = true
try {
const r = await fetch(`${HUB_URL}/auth/users?group=${encodeURIComponent(importGroupName.value)}`)
const d = r.ok ? await r.json() : {}
const emails = (d.users || d || []).map(u => String(u.email || '').toLowerCase()).filter(Boolean)
const list = members.value[selected.value] || (members.value[selected.value] = [])
let added = 0
emails.forEach(e => { if (!list.includes(e)) { list.push(e); added++ } })
if (added) { await save(); $q.notify({ type: 'positive', message: `${added} membre(s) de « ${importGroupName.value} » ajouté(s) à ${selected.value}` }) }
else $q.notify({ type: 'info', message: 'Tous ces membres sont déjà dans la file', timeout: 1500 })
importGroupName.value = null
} catch (e) { $q.notify({ type: 'negative', message: 'Échec import groupe' }) } finally { importing.value = false }
}
function select (q) { selected.value = selected.value === q ? null : q; search.value = ''; results.value = []; labelDraft.value = selected.value ? label(selected.value) : '' }
function onSearch (v) {
clearTimeout(_t)
const q = String(v || '').trim()
if (q.length < 2) { results.value = []; return }
_t = setTimeout(async () => {
searching.value = true
try { const r = await fetch(`${HUB_URL}/auth/users?search=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = (d.users || d || []).filter(u => u && u.email).slice(0, 8) } } catch (e) { results.value = [] } finally { searching.value = false }
}, 300)
}
async function save () {
try { await fetch(`${HUB_URL}/conversations/queue-members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ members: members.value, labels: labels.value }) }) } catch (e) { $q.notify({ type: 'negative', message: 'Échec sauvegarde' }) }
}
function saveLabel () {
if (!selected.value) return
const v = String(labelDraft.value || '').trim()
const cur = labels.value[selected.value] || ''
if (v && v !== selected.value) { if (cur === v) return; labels.value = { ...labels.value, [selected.value]: v } } // libellé personnalisé
else { if (!cur) return; const cp = { ...labels.value }; delete cp[selected.value]; labels.value = cp } // vide ou = nom réel retire le libellé
save(); $q.notify({ type: 'positive', message: 'Nom affiché mis à jour', timeout: 1200 })
}
function addMember (u) {
const email = String(u.email || '').toLowerCase(); if (!email || !selected.value) return
const list = members.value[selected.value] || (members.value[selected.value] = [])
if (!list.includes(email)) { list.push(email); save(); $q.notify({ type: 'positive', message: `${email} ajouté à ${selected.value}`, timeout: 1500 }) }
search.value = ''; results.value = []
}
function removeMember (email) {
if (!selected.value) return
members.value[selected.value] = (members.value[selected.value] || []).filter(e => e !== email)
save()
}
onMounted(load)
</script>
<style scoped>
.queue-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: all .12s; }
.queue-card:hover { border-color: #a5b4fc; }
.queue-card.sel { border-color: #6366f1; background: #eef2ff; }
.queue-detail { border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; background: #fafbff; }
.qm-dropdown { position: absolute; z-index: 20; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 240px; overflow-y: auto; }
.qm-item { display: flex; align-items: center; padding: 7px 10px; cursor: pointer; }
.qm-item:hover { background: #f1f5f9; }
/* Survol de la ligne membre → viser le bon « Retirer » dans une longue liste */
.qm-member-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
.qm-member-row:hover { background: #eff3ff; box-shadow: inset 3px 0 0 #6366f1; }
.qm-member-row .qm-del { opacity: .45; transition: opacity .1s; }
.qm-member-row:hover .qm-del { opacity: 1; }
</style>

View File

@ -0,0 +1,157 @@
<template>
<div class="dept-wrap">
<div class="dept-head">
<div class="text-caption text-grey-7">{{ totalActive }} active(s) · glisser une carte = classer dans une file · clic = ouvrir le fil</div>
<q-space />
<q-toggle v-model="showResolved" dense size="sm" label="Inclure résolues" color="grey-7" class="q-mr-sm" />
<q-btn flat dense round icon="refresh" :loading="loading" @click="fetchList" />
</div>
<div class="dept-board">
<div v-for="dep in depts" :key="dep" class="dept-col" :class="{ 'drop-hover': dropDept === dep }"
@dragover.prevent="dropDept = dep" @dragleave="dropDept === dep && (dropDept = null)" @drop="onDrop(dep)">
<div class="dept-col-hd" :class="dep === UNASSIGNED ? 'dh-unassigned' : 'dh-' + slug(dep)">
<q-icon :name="deptIcon(dep)" size="16px" class="q-mr-xs" />
<span class="ellipsis col">{{ dep }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[dep] || []).length }}</q-badge>
<q-btn flat dense round icon="add" size="xs" color="white" class="q-ml-xs">
<q-tooltip>Composer dans « {{ dep }} »</q-tooltip>
<q-menu auto-close>
<q-list dense style="min-width:160px">
<q-item clickable @click="compose('email', dep)"><q-item-section avatar><q-icon name="mail" color="red-5" /></q-item-section><q-item-section>Nouveau courriel</q-item-section></q-item>
<q-item clickable @click="compose('sms', dep)"><q-item-section avatar><q-icon name="sms" color="teal-6" /></q-item-section><q-item-section>Nouveau texto</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<div class="dept-col-body">
<div v-for="d in (columns[dep] || [])" :key="d.id" class="dept-card" :class="{ 'needs-reply': needsReply(d) }"
draggable="true" @dragstart="onDragStart(d)" @dragend="dragCard = null" @click="openCard(d)">
<div class="dept-card-t">
<q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" size="13px" class="q-mr-xs" :color="d.channel === 'email' ? 'red-5' : 'teal-6'" />
<span class="ellipsis col">{{ d.customerName || d.email || d.phone || 'Inconnu' }}</span>
<q-badge v-if="d.status === 'active'" color="green" class="q-ml-xs" style="font-size:.55rem;padding:1px 4px">actif</q-badge>
</div>
<div class="dept-card-s ellipsis">{{ preview(d) }}</div>
<div class="dept-card-m">
<span class="text-grey-6">{{ relTime(d.lastMessage && d.lastMessage.ts) }}</span>
<q-icon v-if="needsReply(d)" name="reply" size="12px" color="blue-7" class="q-ml-xs"><q-tooltip>En attente de réponse</q-tooltip></q-icon>
<q-space />
<span v-if="d.assignee" class="dept-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(d.assignee) }}</span>
</div>
</div>
<div v-if="!(columns[dep] || []).length" class="dept-empty"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent, relTime } from 'src/composables/useFormatters'
const $q = useQuasar()
const {
discussions, QUEUES, assignQueue, fetchList, loading,
openDiscussion, panelOpen, newDialogOpen, newDialogChannel,
} = useConversations()
const UNASSIGNED = 'Non assigné'
const depts = computed(() => [...QUEUES, UNASSIGNED])
const showResolved = ref(false)
const dragCard = ref(null)
const dropDept = ref(null)
const DEPT_ICONS = {
'Facturations': 'request_quote',
'Service à la clientèle': 'support_agent',
'Support': 'build',
'Supports': 'build',
'Technicien': 'engineering',
}
function deptIcon (dep) { return dep === UNASSIGNED ? 'inbox' : (DEPT_ICONS[dep] || 'groups') }
function slug (s) { return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '') }
function needsReply (d) { return d.status === 'active' && d.lastMessage && d.lastMessage.from === 'customer' }
function preview (d) { return d.subject || (d.lastMessage && d.lastMessage.text) || '—' }
function tsOf (d) { const t = d.lastMessage && d.lastMessage.ts; const n = t ? new Date(t).getTime() : 0; return Number.isFinite(n) ? n : 0 }
// shortAgent + relTime (ex-fmtTime, logique identique) viennent de useFormatters (consolidation)
// Regroupe les discussions unifiées (SMS + courriel) par file/département.
const columns = computed(() => {
const map = {}
for (const dep of depts.value) map[dep] = []
for (const d of (discussions.value || [])) {
if (!showResolved.value && d.status === 'closed') continue
const q = (d.queue && QUEUES.includes(d.queue)) ? d.queue : UNASSIGNED
map[q].push(d)
}
for (const dep of depts.value) map[dep].sort((a, b) => tsOf(b) - tsOf(a))
return map
})
const totalActive = computed(() => (discussions.value || []).filter(d => d.status === 'active').length)
// Jeton de conversation à classer (active sinon dernière sinon token de la discussion).
function discToken (d) {
const active = (d.conversations || []).find(c => c.status === 'active')
return (active && active.token) || d.token || (((d.conversations || []).slice(-1)[0]) || {}).token || null
}
function onDragStart (d) { dragCard.value = d }
async function onDrop (dep) {
dropDept.value = null
const d = dragCard.value; dragCard.value = null
if (!d) return
const tok = discToken(d)
if (!tok) { $q.notify({ type: 'warning', message: 'Conversation sans jeton — impossible de classer.' }); return }
const newQueue = dep === UNASSIGNED ? '' : dep
if ((d.queue || '') === newQueue) return
const prev = d.queue
d.queue = newQueue // déplacement optimiste (assignQueue rafraîchit ensuite la liste)
try {
await assignQueue(tok, newQueue)
$q.notify({ type: 'positive', message: `${d.customerName || d.phone || 'Conversation'}${dep}`, timeout: 1200 })
} catch (e) { d.queue = prev; $q.notify({ type: 'negative', message: 'Échec du classement' }) }
}
function openCard (d) { panelOpen.value = true; openDiscussion(d) }
// Envoi depuis une colonne : ouvre le dialogue partagé (rendu dans ConversationPanel, hors du tiroir).
// v1 : la nouvelle conversation atterrit en « Non assigné » l'agent la glisse dans la bonne file (auto-routage à venir).
function compose (channel /* , dep */) {
newDialogChannel.value = channel
newDialogOpen.value = true
}
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
</script>
<style scoped>
.dept-wrap { padding: 10px 14px; }
.dept-head { display: flex; align-items: center; margin-bottom: 10px; }
.dept-board { display: flex; gap: 12px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }
.dept-col { flex: 0 0 270px; background: #f1f5f9; border-radius: 10px; display: flex; flex-direction: column; max-height: calc(100vh - 230px); }
.dept-col.drop-hover { outline: 2px dashed #6366f1; outline-offset: -2px; }
.dept-col-hd { display: flex; align-items: center; font-weight: 700; font-size: .8rem; color: #fff; padding: 7px 10px; border-radius: 10px 10px 0 0; }
.dh-facturations { background: #d97706; }
.dh-service-a-la-clientele { background: #6366f1; }
.dh-support, .dh-supports { background: #0891b2; }
.dh-technicien { background: #16a34a; }
.dh-unassigned { background: #64748b; }
.dept-col-body { padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; }
.dept-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.dept-card:hover { border-color: #6366f1; }
.dept-card.needs-reply { border-left: 3px solid #2563eb; background: #eff6ff; }
.dept-card-t { font-weight: 600; font-size: .82rem; display: flex; align-items: center; }
.dept-card-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.dept-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.dept-assignee { color: #b45309; font-weight: 600; }
.dept-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
.col { min-width: 0; }
@media (max-width: 640px) {
.dept-board { scroll-snap-type: x mandatory; }
.dept-col { flex-basis: 86vw; scroll-snap-align: start; max-height: calc(100vh - 200px); }
}
</style>

View File

@ -11,7 +11,7 @@
<!-- Display mode -->
<template v-if="!editing">
<slot name="display" :value="value" :display-value="displayValue" :start-edit="startEdit">
<span class="inline-field__value" :class="{ 'inline-field__placeholder': !displayValue }">
<span class="inline-field__value" :class="{ 'inline-field__placeholder': !displayValue, 'inline-field__value--multiline': type === 'textarea' }">
{{ displayValue || placeholder }}
</span>
</slot>
@ -180,6 +180,8 @@ defineExpose({ startEdit })
</script>
<style lang="scss">
/* Champs multi-lignes (description, notes) : conserver les sauts de ligne en lecture (sinon tout est aplati en un paragraphe) */
.inline-field__value--multiline { white-space: pre-wrap; word-break: break-word; }
.inline-field {
display: inline-flex;
align-items: center;

View File

@ -0,0 +1,151 @@
<template>
<q-dialog v-model="open" @show="onShow">
<q-card style="min-width:480px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="forum" color="indigo-6" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">Intervenir{{ title ? ' — ' + title : '' }}</div>
<div class="text-caption text-grey-6">Collaboration d'équipe · <b>n'affecte pas l'assignation</b></div>
</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<!-- Écran 1 : options principales -->
<div v-if="step === 'menu'" class="row q-col-gutter-sm">
<div class="col-6" v-for="a in actions" :key="a.key">
<div class="iv-tile" :class="{ soon: a.soon }" @click="!a.soon && (step = a.key)">
<q-icon :name="a.icon" size="26px" :color="a.soon ? 'grey-5' : a.color" />
<div class="text-weight-medium q-mt-xs">{{ a.label }}</div>
<div class="text-caption text-grey-6">{{ a.hint }}</div>
<q-badge v-if="a.soon" label="bientôt" color="grey-3" text-color="grey-6" class="q-mt-xs" />
</div>
</div>
</div>
<!-- Écran 2a : note / indice -->
<div v-else-if="step === 'note'">
<q-btn flat dense size="sm" icon="arrow_back" label="Retour" no-caps class="q-mb-xs" @click="step = 'menu'" />
<q-input v-model="text" type="textarea" autogrow :rows="3" outlined autofocus
label="Indice, note, contexte… (visible par l'équipe sur le ticket)" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="send" label="Publier la note" no-caps :disable="!text.trim() || busy" :loading="busy" @click="submit([])" />
</div>
</div>
<!-- Écran 2b : mentionner un collègue -->
<div v-else-if="step === 'mention'">
<q-btn flat dense size="sm" icon="arrow_back" label="Retour" no-caps class="q-mb-xs" @click="step = 'menu'" />
<div class="row items-center q-gutter-xs q-mb-xs" v-if="picked.length">
<q-chip v-for="m in picked" :key="m" dense removable color="indigo-1" text-color="indigo-8" @remove="picked = picked.filter(x => x !== m)">{{ shortName(m) }}</q-chip>
</div>
<div style="position:relative">
<q-input v-model="search" dense outlined label="Mentionner un collègue…" autofocus autocomplete="off" @update:model-value="onSearch">
<template #append><q-spinner v-if="searching" size="16px" color="indigo-6" /></template>
</q-input>
<div v-if="results.length" class="iv-dd">
<div v-for="u in results" :key="u.email" class="iv-dd-item" @click="addPick(u.email)">
<q-avatar size="22px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.6rem">{{ (u.name || u.email).charAt(0).toUpperCase() }}</q-avatar>
{{ u.name || u.email }}<span class="text-caption text-grey-6 q-ml-xs">{{ u.email }}</span>
</div>
</div>
</div>
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="q-mt-sm" label="Message au(x) collègue(s)…" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="send" :label="`Notifier ${picked.length || ''}`" no-caps :disable="!picked.length || !text.trim() || busy" :loading="busy" @click="submit(picked)" />
</div>
</div>
<!-- Fil récent -->
<div v-if="activity.length" class="iv-feed q-mt-md">
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Activité récente</div>
<div v-for="(c, i) in activity" :key="i" class="iv-msg">
<b>{{ shortName(c.comment_by || c.comment_email) }}</b>
<span class="text-grey-5 q-ml-xs" style="font-size:.7rem">{{ fmt(c.creation) }}</span>
<div class="iv-msg-txt">{{ strip(c.content) }}</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { shortAgent as shortName, formatDateTimeShort as fmt } from 'src/composables/useFormatters'
const props = defineProps({ modelValue: Boolean, doctype: { type: String, default: 'Issue' }, name: String, title: String })
const emit = defineEmits(['update:modelValue', 'posted'])
const $q = useQuasar()
const open = ref(false)
const step = ref('menu')
const text = ref('')
const picked = ref([])
const search = ref('')
const results = ref([])
const searching = ref(false)
const busy = ref(false)
const activity = ref([])
let _t = null
const actions = [
{ key: 'note', icon: 'lightbulb', color: 'amber-7', label: 'Indice / note', hint: 'Partager une connaissance' },
{ key: 'mention', icon: 'alternate_email', color: 'indigo-6', label: 'Mentionner', hint: 'Demander à un collègue' },
{ key: 'link', icon: 'link', color: 'teal-6', label: 'Lier un élément', hint: 'Ticket, client…', soon: true },
{ key: 'follow', icon: 'visibility', color: 'blue-grey-6', label: 'Suivre', hint: 'Recevoir les suites', soon: true },
]
// sync v-model
import { watch } from 'vue'
watch(() => props.modelValue, v => { open.value = v })
watch(open, v => { emit('update:modelValue', v); if (!v) reset() })
function reset () { step.value = 'menu'; text.value = ''; picked.value = []; search.value = ''; results.value = [] }
// shortName = shortAgent, fmt = formatDateTimeShort (useFormatters, consolidation)
function strip (h) { return String(h || '').replace(/<[^>]+>/g, ' ').replace(/&lt;/g, '<').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim() }
async function onShow () { reset(); await loadActivity() }
async function loadActivity () {
if (!props.name) return
try { const r = await fetch(`${HUB_URL}/collab/activity?doctype=${encodeURIComponent(props.doctype)}&name=${encodeURIComponent(props.name)}`); if (r.ok) { const d = await r.json(); activity.value = d.comments || [] } } catch (e) { /* */ }
}
function onSearch (v) {
clearTimeout(_t); const q = String(v || '').trim()
if (q.length < 2) { results.value = []; return }
_t = setTimeout(async () => {
searching.value = true
try { const r = await fetch(`${HUB_URL}/auth/users?search=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = (d.users || d || []).filter(u => u && u.email && !picked.value.includes(u.email)).slice(0, 8) } } catch (e) { results.value = [] } finally { searching.value = false }
}, 250)
}
function addPick (email) { if (email && !picked.value.includes(email)) picked.value.push(email); search.value = ''; results.value = [] }
async function submit (mentions) {
if (!props.name || !text.value.trim()) return
busy.value = true
try {
const r = await fetch(`${HUB_URL}/collab/comment`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doctype: props.doctype, name: props.name, text: text.value.trim(), mentions }) })
const d = await r.json()
if (d.ok) {
$q.notify({ type: 'positive', message: mentions.length ? `Note publiée + ${mentions.length} collègue(s) notifié(s)` : 'Note publiée sur le ticket' })
text.value = ''; picked.value = []; step.value = 'menu'; await loadActivity(); emit('posted')
} else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { busy.value = false }
}
</script>
<style scoped>
.iv-tile { border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px; text-align: center; cursor: pointer; transition: all .12s; height: 100%; }
.iv-tile:hover { border-color: #6366f1; background: #eef2ff; }
.iv-tile.soon { cursor: default; opacity: .6; }
.iv-tile.soon:hover { border-color: #e2e8f0; background: transparent; }
.iv-dd { position: absolute; z-index: 20; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 220px; overflow-y: auto; }
.iv-dd-item { display: flex; align-items: center; padding: 7px 10px; cursor: pointer; font-size: .85rem; }
.iv-dd-item:hover { background: #f1f5f9; }
.iv-feed { border-top: 1px solid #eef2f7; padding-top: 8px; max-height: 200px; overflow-y: auto; }
.iv-msg { padding: 5px 0; border-bottom: 1px solid #f5f7fa; font-size: .8rem; }
.iv-msg-txt { color: #334155; white-space: pre-wrap; }
</style>

View File

@ -0,0 +1,145 @@
<template>
<q-dialog v-model="open">
<q-card style="min-width:460px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="confirmation_number" color="teal-7" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Nouveau ticket</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<!-- Sujet en langage naturel : tape le problème + nom / adresse / téléphone -->
<div style="position:relative">
<q-input dense outlined v-model="title" type="textarea" autogrow :rows="2" autofocus
label="Décris le problème (ex. « Problème wifi 2338 rue Ste-Clotilde » ou un nom / téléphone)"
@update:model-value="onSearch">
<template #append><q-spinner v-if="searching" size="16px" color="teal-6" /></template>
</q-input>
<div v-if="results.length && !customer" class="nt-dd">
<div v-for="m in results" :key="m.name" class="nt-item" @click="pick(m)">
<q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : 'person'" size="16px" color="teal-6" class="q-mr-sm" />
<div class="col ellipsis">
<div class="text-body2 ellipsis">{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></div>
<div class="text-caption text-grey-6 ellipsis">{{ m.address || m.territory || m.name }}</div>
</div>
<q-badge color="teal-1" text-color="teal-8" :label="m.matched" />
</div>
</div>
</div>
<!-- Client résolu -->
<div v-if="customer">
<div class="row items-center">
<q-chip dense color="blue-1" text-color="blue-9" icon="person" removable @remove="clearCustomer">{{ customerName }}</q-chip>
<span class="text-caption text-grey-6">compte lié</span>
</div>
<!-- Adresse de service (compte multi-adresses) on lie la BONNE, pas la facturation -->
<q-select v-if="serviceLocs.length > 1" dense outlined v-model="serviceLoc" :options="serviceLocs" option-label="address" option-value="name" emit-value map-options clearable
label="Adresse de service" class="q-mt-xs">
<template #prepend><q-icon name="place" color="orange-6" /></template>
</q-select>
<div v-else-if="serviceLoc && serviceLocs.length === 1" class="text-caption text-grey-7 q-mt-xs"><q-icon name="place" size="13px" color="orange-6" /> {{ serviceLocs[0].address }}</div>
</div>
<div v-else class="text-caption text-grey-5"><q-icon name="info" size="14px" /> Tape un nom, une adresse ou un numéro l'autosuggest lie le bon compte (optionnel).</div>
<!-- Départements probables par mots-clés (pas besoin de fouiller la liste) -->
<div v-if="suggestions.length" class="row items-center q-gutter-xs">
<span class="text-caption text-grey-6">Probable :</span>
<q-chip v-for="s in suggestions" :key="s.category" dense clickable :icon="s.icon"
:color="category === s.category ? s.color : 'grey-3'" :text-color="category === s.category ? 'white' : 'grey-8'"
@click="setCategory(s.category)">{{ s.category }}<q-tooltip>détecté : « {{ s.keyword }} »</q-tooltip></q-chip>
</div>
<div class="row q-col-gutter-sm">
<q-select class="col" dense outlined v-model="category" :options="categories" label="Catégorie" @update:model-value="touched = true" />
<q-select class="col" dense outlined v-model="priority" :options="['Low','Medium','High','Urgent']" label="Priorité" />
</div>
<div class="text-caption text-grey-6" v-if="queueFor(category)"> routé à l'équipe <b>{{ queueFor(category) }}</b></div>
<div class="row q-col-gutter-sm items-center">
<q-btn-toggle class="col-auto" v-model="status" dense unelevated size="sm" toggle-color="indigo-6" color="grey-3" text-color="grey-8" no-caps :options="[{ label: 'Ouvert', value: 'Open' }, { label: 'En attente', value: 'On Hold' }]" />
<q-input class="col" dense outlined v-model="dueDate" type="date" stack-label :label="status === 'On Hold' ? 'Rappel — réactiver à cette date' : 'Échéance (optionnel)'" />
</div>
<div v-if="status === 'On Hold'" class="text-caption text-amber-9"><q-icon name="pause_circle" size="13px" /> Suspendu (ex. en attente du paiement) réapparaît à la date de rappel.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="teal-7" icon="confirmation_number" label="Créer le ticket" :disable="!title.trim()" :loading="busy" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useConversations } from 'src/composables/useConversations'
import { suggestDepartments } from 'src/config/departments'
const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue', 'created'])
const $q = useQuasar()
const { createStandaloneTicket } = useConversations()
const open = ref(false)
const title = ref('')
const category = ref('Support')
const priority = ref('Medium')
const customer = ref('')
const customerName = ref('')
const results = ref([])
const searching = ref(false)
const busy = ref(false)
const status = ref('Open')
const dueDate = ref('')
const touched = ref(false) // l'agent a-t-il choisi la catégorie manuellement ?
let _t = null
const categories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre']
const QUEUE_OF = { Support: 'Supports', Facturation: 'Facturations', Commercial: 'Service à la clientèle', Installation: 'Technicien', Fibre: 'Technicien', Télévision: 'Technicien', Téléphonie: 'Technicien' }
function queueFor (c) { return QUEUE_OF[c] || '' }
// Départements probables (mots-clés, live) pré-sélection du plus probable tant que l'agent n'a pas choisi.
const suggestions = computed(() => suggestDepartments(title.value).slice(0, 3))
watch(suggestions, list => { if (!touched.value && list.length) category.value = list[0].category })
function setCategory (c) { category.value = c; touched.value = true }
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
watch(open, v => emit('update:modelValue', v))
const serviceLoc = ref('') // adresse de service liée (Service Location)
const serviceLocs = ref([]) // adresses du compte (multi-adresses)
function reset () { title.value = ''; category.value = 'Support'; priority.value = 'Medium'; customer.value = ''; customerName.value = ''; results.value = []; touched.value = false; status.value = 'Open'; dueDate.value = ''; serviceLoc.value = ''; serviceLocs.value = [] }
function clearCustomer () { customer.value = ''; customerName.value = ''; serviceLoc.value = ''; serviceLocs.value = [] }
function pick (m) {
customer.value = m.name; customerName.value = m.customer_name || m.name; results.value = []
serviceLoc.value = m.service_location || '' // adresse matchée présélection
fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(m.name)}`).then(r => r.ok ? r.json() : { locations: [] }).then(d => {
serviceLocs.value = d.locations || []
if (!serviceLoc.value && serviceLocs.value.length) serviceLoc.value = serviceLocs.value[0].name // pré-sélectionne l'adresse de service active la plus récente (triée en 1re par le hub)
}).catch(() => { serviceLocs.value = [] })
}
function onSearch (v) {
clearTimeout(_t)
if (customer.value) return // déjà lié
const q = String(v || '').trim()
if (q.length < 3) { results.value = []; return }
_t = setTimeout(async () => {
searching.value = true
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = d.matches || [] } } catch (e) { results.value = [] } finally { searching.value = false }
}, 350)
}
async function submit () {
if (!title.value.trim()) return
busy.value = true
try {
const d = await createStandaloneTicket({ title: title.value.trim(), category: category.value, priority: priority.value, customer: customer.value || undefined, customer_name: customerName.value || undefined, service_location: serviceLoc.value || undefined, queue: queueFor(category.value) || undefined, status: status.value, due_date: dueDate.value || undefined })
if (d.ok) { $q.notify({ type: 'positive', message: `Ticket ${d.name} créé${customerName.value ? ' · ' + customerName.value : ''}${queueFor(category.value) ? ' → ' + queueFor(category.value) : ''}` }); emit('created', d); open.value = false }
else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { busy.value = false }
}
</script>
<style scoped>
.nt-dd { position: absolute; z-index: 30; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 240px; overflow-y: auto; }
.nt-item { display: flex; align-items: center; padding: 8px 10px; cursor: pointer; border-bottom: 1px solid #f5f7fa; }
.nt-item:hover { background: #f0fdfa; }
</style>

View File

@ -0,0 +1,218 @@
<template>
<q-dialog v-model="open" @hide="reset">
<q-card style="min-width:540px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="bolt" color="deep-purple-6" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Commande dictée</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<!-- Dictée / texte -->
<div class="row items-end q-gutter-sm">
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="col" autofocus
label="Dicte ou écris (ex. « Crée un ticket de facture non payée pour Louis-Paul, texto au client qu'on attend le paiement »)"
@keyup.enter.exact.prevent="plan" />
<q-btn round :color="listening ? 'red-6' : 'deep-purple-5'" :icon="listening ? 'mic' : 'mic_none'" :loading="planning" @click="toggleMic">
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
</q-btn>
</div>
<div class="row justify-end q-mt-xs">
<q-btn unelevated color="deep-purple-6" icon="auto_awesome" label="Préparer le plan" no-caps :disable="!text.trim() || planning" :loading="planning" @click="plan" />
</div>
<!-- Vérifications (lecture immédiate pas de confirmation) -->
<div v-if="diagnoses.length" class="q-mt-md">
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
<div class="row items-center q-mb-xs">
<q-icon name="network_check" color="indigo-6" size="18px" class="q-mr-sm" />
<span class="text-weight-medium">Vérification</span>
<span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span>
</div>
<!-- 1 client diagnostic -->
<template v-if="a.diagnostic && a.diagnostic.found">
<div class="row items-center no-wrap">
<span class="diag-dot" :style="{ background: dotColor(a.diagnostic.summary) }"></span>
<div class="col">
<div class="text-weight-medium">{{ a.diagnostic.customer.customer_name }}</div>
<div class="text-caption text-grey-7">{{ a.diagnostic.location ? a.diagnostic.location.address : '—' }}</div>
</div>
<div class="text-right">
<div class="text-weight-medium" :style="{ color: dotColor(a.diagnostic.summary) }">{{ a.diagnostic.summary.label }}</div>
<div class="text-caption text-grey-6">{{ a.diagnostic.summary.detail }}<span v-if="a.diagnostic.summary.signal"> · {{ a.diagnostic.summary.signal }}</span></div>
</div>
</div>
<div v-if="a.diagnostic.locations && a.diagnostic.locations.length > 1" class="text-caption text-orange-8 q-mt-xs"><q-icon name="place" size="13px" /> {{ a.diagnostic.locations.length }} adresses affichage de la principale</div>
</template>
<!-- plusieurs clients préciser l'adresse -->
<template v-else-if="a.diagnostic && a.diagnostic.ambiguous">
<div class="text-caption text-orange-8 q-mb-xs">Plusieurs clients précise l'adresse :</div>
<q-list dense bordered class="rounded-borders">
<q-item clickable v-ripple v-for="(m, j) in a.diagnostic.matches" :key="j" @click="pickDiag(a, m)">
<q-item-section><q-item-label>{{ m.customer_name }}</q-item-label><q-item-label caption>{{ m.address || m.phone || m.email }}</q-item-label></q-item-section>
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
</q-item>
</q-list>
</template>
<div v-else-if="a.diagnostic && !a.diagnostic.found" class="text-caption text-red-7">Client introuvable pour « {{ a.diagnostic.query || a.customer_query }} »</div>
<div v-else class="text-caption text-grey-6"><q-spinner size="14px" /> diagnostic</div>
</div>
</div>
<!-- Plan d'actions à confirmer -->
<div v-if="writeActions.length" class="q-mt-md">
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plan ({{ writeActions.length }} action{{ writeActions.length > 1 ? 's' : '' }}) vérifie puis confirme :</div>
<div v-for="(a, i) in writeActions" :key="i" class="orch-act">
<div class="row items-center no-wrap q-mb-xs">
<q-icon :name="iconOf(a.type)" :color="colorOf(a.type)" size="18px" class="q-mr-sm" />
<span class="text-weight-medium">{{ labelOf(a.type) }}</span>
<q-badge v-if="a.type === 'create_ticket' && a.status === 'On Hold'" color="amber-3" text-color="amber-9" label="En attente" class="q-ml-sm" />
<q-badge v-if="a.category" :label="a.category" color="grey-3" text-color="grey-8" class="q-ml-xs" />
<q-space />
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
</div>
<!-- destinataire / client résolu -->
<div v-if="a.candidates && a.candidates.length" class="q-mb-xs">
<q-select dense outlined v-model="a.resolved" :options="a.candidates" option-label="customer_name" map-options emit-value
:display-value="a.resolved ? a.resolved.customer_name : '— aucun —'" label="Client / destinataire" @update:model-value="onResolved(a)">
<template #option="s">
<q-item v-bind="s.itemProps"><q-item-section>
<q-item-label>{{ s.opt.customer_name }}<q-badge v-if="s.opt.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ s.opt.kind === 'technicien' ? '🔧 technicien · ' : '' }}{{ s.opt.address || s.opt.phone || s.opt.email }}{{ s.opt.can_sms ? ' · SMS ✓' : '' }}</q-item-label>
</q-item-section></q-item>
</template>
</q-select>
<div v-if="a.type === 'send_sms' && a.resolved && !a.resolved.can_sms" class="text-caption text-orange-8"><q-icon name="warning" size="13px" /> pas de mobile SMS impossible</div>
</div>
<div v-else-if="a.phone" class="text-caption text-grey-7 q-mb-xs"><q-icon name="call" size="13px" /> {{ a.phone }}</div>
<div v-else-if="a.customer_query || a.to_query" class="text-caption text-orange-8 q-mb-xs"><q-icon name="info" size="13px" /> « {{ a.customer_query || a.to_query }} » non résolu</div>
<!-- Adresse de service liée (compte multi-adresses on choisit la bonne, pas la facturation) -->
<div v-if="a.type === 'create_ticket' && a.resolved" class="q-mb-xs">
<q-select v-if="a._locs && a._locs.length > 1" dense outlined v-model="a.service_location" :options="a._locs" option-label="address" option-value="name" emit-value map-options clearable label="Adresse de service">
<template #prepend><q-icon name="place" color="orange-6" /></template>
</q-select>
<div v-else-if="a.resolved.address || a.service_location" class="text-caption text-grey-7"><q-icon name="place" size="13px" color="orange-6" /> {{ a.resolved.address || locLabel(a) }}</div>
</div>
<!-- contenu éditable -->
<q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" />
<q-input v-if="a.type === 'send_sms'" v-model="a.message" type="textarea" autogrow :rows="2" dense outlined label="Texto" />
<template v-if="a.type === 'send_email'"><q-input v-model="a.subject" dense outlined label="Sujet" class="q-mb-xs" /><q-input v-model="a.body" type="textarea" autogrow :rows="2" dense outlined label="Courriel" /></template>
<q-input v-if="a.type === 'note'" v-model="a.text" type="textarea" autogrow :rows="2" dense outlined label="Note" />
</div>
</div>
<!-- Résultats -->
<div v-if="results.length" class="q-mt-md">
<div v-for="(r, i) in results" :key="i" class="text-body2" :class="r.ok ? 'text-green-8' : 'text-red-7'">
<q-icon :name="r.ok ? 'check_circle' : 'error'" size="15px" /> {{ summaryOf(r) }}
</div>
</div>
</q-card-section>
<q-card-actions v-if="writeActions.length && !results.length" align="right">
<q-btn flat label="Annuler" @click="actions = []" />
<q-btn unelevated color="green-7" icon="send" label="Confirmer et exécuter" no-caps :loading="running" @click="run" />
</q-card-actions>
<q-card-actions v-else-if="results.length" align="right">
<q-btn unelevated color="grey-7" label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue', 'done'])
const $q = useQuasar()
const open = ref(false)
const text = ref('')
const actions = ref([])
const results = ref([])
const planning = ref(false)
const running = ref(false)
const listening = ref(false)
const micSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition)
let recog = null
// Les vérifications (lecture) sont déjà exécutées dans le plan séparées des actions à confirmer.
const diagnoses = computed(() => actions.value.filter(a => a.type === 'diagnose'))
const writeActions = computed(() => actions.value.filter(a => a.type !== 'diagnose'))
function dotColor (s) { return (s && s.online === true) ? '#16a34a' : (s && s.online === false) ? '#dc2626' : '#94a3b8' }
async function pickDiag (a, m) {
a.diagnostic = null
try { const r = await fetch(`${HUB_URL}/collab/service-status?customer=${encodeURIComponent(m.name)}`); if (r.ok) a.diagnostic = await r.json() } catch (e) { a.diagnostic = { ok: false, error: e.message } }
}
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
watch(open, v => emit('update:modelValue', v))
function reset () { text.value = ''; actions.value = []; results.value = []; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false }
const iconOf = t => ({ create_ticket: 'confirmation_number', send_sms: 'sms', send_email: 'mail', note: 'sticky_note_2' }[t] || 'bolt')
const colorOf = t => ({ create_ticket: 'teal-7', send_sms: 'green-6', send_email: 'red-5', note: 'amber-7' }[t] || 'grey-7')
const labelOf = t => ({ create_ticket: 'Créer un ticket', send_sms: 'Envoyer un texto', send_email: 'Envoyer un courriel', note: 'Note interne' }[t] || t)
function summaryOf (r) {
if (r.type === 'create_ticket') return r.ok ? `Ticket ${r.name} créé (${r.label || ''})` : 'Ticket : ' + (r.error || 'échec')
if (r.type === 'send_sms') return r.ok ? `Texto envoyé à ${r.to} (${r.via})` : 'Texto : ' + (r.error || 'échec')
if (r.type === 'send_email') return r.ok ? `Courriel envoyé à ${r.to}` : 'Courriel : ' + (r.error || 'échec')
if (r.type === 'note') return 'Note : ' + (r.text || '')
return JSON.stringify(r)
}
function toggleMic () {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SR) { $q.notify({ type: 'warning', message: 'Dictée non supportée par ce navigateur' }); return }
if (listening.value) { try { recog && recog.stop() } catch (e) {} return }
recog = new SR(); recog.lang = 'fr-CA'; recog.interimResults = true; recog.continuous = false
recog.onresult = e => { let t = ''; for (const r of e.results) t += r[0].transcript; text.value = t }
recog.onend = () => { listening.value = false }
recog.onerror = () => { listening.value = false }
try { recog.start(); listening.value = true } catch (e) { listening.value = false }
}
async function plan () {
if (!text.value.trim()) return
planning.value = true; results.value = []
try {
const r = await fetch(`${HUB_URL}/collab/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.value.trim() }) })
const d = await r.json()
if (d.ok) {
actions.value = d.actions || []
for (const a of actions.value) if (a.type === 'create_ticket' && a.resolved) onResolved(a) // pré-charge les adresses de service
if (!actions.value.length) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' })
} else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false }
}
// Quand le client résolu change (ou au chargement) : récupère ses adresses de service et présélectionne celle qui matche.
async function onResolved (a) {
if (a.type !== 'create_ticket' || !a.resolved || !a.resolved.name) { a._locs = []; return }
a.service_location = a.resolved.service_location || ''
try {
const r = await fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(a.resolved.name)}`)
if (r.ok) { const d = await r.json(); a._locs = d.locations || []; if (!a.service_location && a._locs.length) a.service_location = a._locs[0].name } // adresse de service active la plus récente (triée 1re)
} catch (e) { a._locs = [] }
}
function locLabel (a) { const l = (a._locs || []).find(x => x.name === a.service_location); return l ? l.address : a.service_location }
async function run () {
running.value = true
try {
const r = await fetch(`${HUB_URL}/collab/orchestrate/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actions: writeActions.value }) })
const d = await r.json()
results.value = d.results || []
const ok = results.value.filter(x => x.ok).length
$q.notify({ type: ok ? 'positive' : 'negative', message: `${ok}/${results.value.length} action(s) exécutée(s)` })
emit('done')
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { running.value = false }
}
</script>
<style scoped>
.orch-act { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #faf9ff; }
.orch-diag { border: 1px solid #e0e7ff; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #f5f7ff; }
.diag-dot { width: 11px; height: 11px; border-radius: 50%; margin-right: 10px; flex: 0 0 auto; }
</style>

View File

@ -43,7 +43,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
defineEmits(['open-ticket'])
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const alerts = ref([])
const expanded = ref(true)

View File

@ -0,0 +1,88 @@
<template>
<!-- Tampon d'envoi : courriels multi-destinataires retenus N s avant l'envoi on voit + on peut annuler -->
<transition name="ob-slide">
<div v-if="pending.length" class="outbox-panel">
<div class="ob-head row items-center no-wrap">
<q-icon name="schedule_send" color="amber-9" size="20px" class="q-mr-xs" />
<div class="text-weight-bold">{{ pending.length }} courriel{{ pending.length > 1 ? 's' : '' }} en attente d'envoi</div>
<q-space />
<q-btn dense flat no-caps size="sm" color="red-7" icon="block" label="Tout annuler" @click="cancelAll" />
</div>
<div v-for="it in pending" :key="it.id" class="ob-item row items-center no-wrap"
:class="{ 'ob-failed': it.status === 'failed' }">
<q-circular-progress v-if="it.status !== 'failed'" :value="pct(it)" size="38px" :thickness="0.18"
color="amber-8" track-color="amber-2" show-value class="q-mr-sm">
<span class="text-caption text-weight-bold">{{ secLeft(it) }}</span>
</q-circular-progress>
<q-icon v-else name="error" color="red-7" size="30px" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-caption text-grey-7 ellipsis">
<q-badge :color="kindColor(it.kind)" text-color="white" :label="it.label || it.kind" class="q-mr-xs" />
{{ it.count }} destinataire{{ it.count > 1 ? 's' : '' }}
</div>
<div class="text-body2 ellipsis" :title="it.subject">{{ it.subject || '(sans objet)' }}</div>
<div class="text-caption text-grey-6 ellipsis" :title="it.recipients.join(', ')">{{ it.recipients.join(', ') }}</div>
<div v-if="it.status === 'failed'" class="text-caption text-red-7 ellipsis">Échec : {{ it.error }}</div>
</div>
<q-btn dense flat round icon="close" color="grey-7" @click="cancel(it.id)"><q-tooltip>Annuler (ne pas envoyer)</q-tooltip></q-btn>
<q-btn dense flat round :icon="it.status === 'failed' ? 'refresh' : 'send'" color="green-7" @click="sendNow(it.id)">
<q-tooltip>{{ it.status === 'failed' ? 'Réessayer' : 'Envoyer maintenant' }}</q-tooltip>
</q-btn>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useSSE } from 'src/composables/useSSE'
const $q = useQuasar()
const pending = ref([])
const holdSec = ref(20)
const now = ref(Date.now())
let ticker = null
let tick = 0
const sse = useSSE({ listeners: { outbox: (d) => { pending.value = d.pending || []; if (d.holdSec) holdSec.value = d.holdSec } } })
async function refresh () {
try { const r = await fetch(`${HUB_URL}/outbox`); if (r.ok) { const d = await r.json(); pending.value = d.pending || []; if (d.holdSec) holdSec.value = d.holdSec } } catch (e) { /* */ }
}
function secLeft (it) { return Math.max(0, Math.ceil((it.sendAt - now.value) / 1000)) }
function pct (it) { const total = holdSec.value * 1000; const left = Math.max(0, it.sendAt - now.value); return Math.max(0, Math.min(100, (left / total) * 100)) }
function kindColor (k) { return k === 'queue-notif' ? 'indigo-5' : k === 'ticket-notif' ? 'teal-6' : 'blue-grey-5' }
async function cancel (id) {
pending.value = pending.value.filter(x => x.id !== id) // optimiste
try { await fetch(`${HUB_URL}/outbox/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) } catch (e) { /* */ }
}
async function cancelAll () {
const n = pending.value.length; pending.value = []
try { await fetch(`${HUB_URL}/outbox/cancel-all`, { method: 'POST' }); $q.notify({ type: 'info', message: `${n} envoi(s) annulé(s)` }) } catch (e) { /* */ }
}
async function sendNow (id) {
try { const r = await fetch(`${HUB_URL}/outbox/send-now`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); const d = await r.json(); if (!d.ok) $q.notify({ type: 'negative', message: d.error || 'Échec envoi' }) } catch (e) { /* */ }
refresh()
}
onMounted(() => {
refresh()
sse.connect(['outbox'])
ticker = setInterval(() => { now.value = Date.now(); if (++tick % 6 === 0) refresh() }, 1000) // countdown + resync léger toutes les 6 s
})
onUnmounted(() => { if (ticker) clearInterval(ticker); sse.disconnect() })
</script>
<style scoped>
.outbox-panel { position: fixed; left: 18px; bottom: 18px; z-index: 4000; width: 390px; max-width: calc(100vw - 36px);
background: #fffdf5; border: 1px solid #fde68a; border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); overflow: hidden; }
.ob-head { padding: 8px 10px; background: #fef3c7; color: #92400e; border-bottom: 1px solid #fde68a; }
.ob-item { padding: 8px 10px; border-bottom: 1px solid #f5f0e0; gap: 2px; }
.ob-item:last-child { border-bottom: none; }
.ob-failed { background: #fef2f2; }
.ob-slide-enter-active, .ob-slide-leave-active { transition: all .25s ease; }
.ob-slide-enter-from, .ob-slide-leave-to { transform: translateY(20px); opacity: 0; }
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="pipe-wrap">
<div class="pipe-head">
<div v-if="!embedded" class="text-h6 text-weight-bold">Pipeline de ventes</div>
<div v-else class="text-subtitle2 text-weight-bold text-grey-8">Leads</div>
<q-space />
<div class="text-caption text-grey-7 q-mr-md">{{ totalLeads }} lead(s) · {{ openLeads }} en cours</div>
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
</div>
<div class="pipe-board">
<div v-for="st in stages" :key="st" class="pipe-col" :class="{ 'drop-hover': dropStage === st }"
@dragover.prevent="dropStage = st" @dragleave="dropStage === st && (dropStage = null)" @drop="onDrop(st)">
<div class="pipe-col-hd" :class="stageClass(st)">
<span>{{ st }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[st] || []).length }}</q-badge>
</div>
<div class="pipe-col-body">
<div v-for="c in (columns[st] || [])" :key="c.token" class="pipe-card" draggable="true"
@dragstart="onDragStart(c)" @dragend="dragCard = null" @click="openLead(c)">
<div class="pipe-card-t">
<q-icon :name="c.channel === 'email' ? 'mail' : 'sms'" size="13px" class="q-mr-xs" :color="c.channel === 'email' ? 'red-5' : 'teal-6'" />
{{ c.customerName || c.contact || 'Lead' }}
<q-badge v-if="c.is_customer" color="green-2" text-color="green-9" class="q-ml-xs" style="font-size:.6rem">client</q-badge>
</div>
<div class="pipe-card-s ellipsis">{{ c.subject || '—' }}</div>
<div class="pipe-card-m">
<span v-if="c.contact" class="text-grey-6 ellipsis" style="max-width:130px">{{ c.contact }}</span>
<q-space />
<span v-if="c.assignee" class="pipe-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(c.assignee) }}</span>
</div>
</div>
<div v-if="!(columns[st] || []).length" class="pipe-empty"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent } from 'src/composables/useFormatters'
defineProps({ embedded: Boolean })
const $q = useQuasar()
const { pipelineBoard, setPipeline, fetchList, discussions, openDiscussion, panelOpen } = useConversations()
const stages = ref(['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'])
const columns = ref({})
const loading = ref(false)
const dragCard = ref(null)
const dropStage = ref(null)
const totalLeads = computed(() => Object.values(columns.value).reduce((s, a) => s + a.length, 0))
const openLeads = computed(() => ['Nouveau', 'Qualifié', 'Devis'].reduce((s, st) => s + ((columns.value[st] || []).length), 0))
// shortAgent vient de useFormatters (consolidation)
function stageClass (st) { return 'st-' + st.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() }
async function load () {
loading.value = true
try { const b = await pipelineBoard(); if (b.stages && b.stages.length) stages.value = b.stages; columns.value = b.columns || {} } catch (e) { /* */ } finally { loading.value = false }
}
function onDragStart (c) { dragCard.value = c }
async function onDrop (stage) {
dropStage.value = null
const c = dragCard.value; dragCard.value = null
if (!c || !stage) return
// retire de l'ancienne colonne, ajoute à la nouvelle (optimiste)
for (const st in columns.value) columns.value[st] = columns.value[st].filter(x => x.token !== c.token)
columns.value[stage] = [{ ...c }, ...(columns.value[stage] || [])]
try { await setPipeline(c.token, stage); $q.notify({ type: 'positive', message: `${c.customerName || 'Lead'}${stage}`, timeout: 1500 }) } catch (e) { $q.notify({ type: 'negative', message: 'Échec déplacement' }); load() }
}
async function openLead (c) {
panelOpen.value = true
let disc = discussions.value.find(d => d.token === c.token || (d.conversations || []).some(x => x.token === c.token))
if (!disc) { await fetchList(); disc = discussions.value.find(d => d.token === c.token || (d.conversations || []).some(x => x.token === c.token)) }
if (disc) openDiscussion(disc)
}
onMounted(load)
</script>
<style scoped>
.pipe-wrap { padding: 12px 16px; }
.pipe-head { display: flex; align-items: center; margin-bottom: 12px; }
.pipe-board { display: flex; gap: 12px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }
.pipe-col { flex: 0 0 250px; background: #f1f5f9; border-radius: 10px; display: flex; flex-direction: column; max-height: calc(100vh - 220px); }
.pipe-col.drop-hover { outline: 2px dashed #6366f1; outline-offset: -2px; }
.pipe-col-hd { display: flex; align-items: center; font-weight: 700; font-size: .8rem; color: #fff; padding: 8px 10px; border-radius: 10px 10px 0 0; }
.st-nouveau { background: #64748b; } .st-qualifie { background: #6366f1; } .st-devis { background: #d97706; } .st-gagne { background: #16a34a; } .st-perdu { background: #dc2626; }
.pipe-col-body { padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; }
.pipe-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.pipe-card:hover { border-color: #6366f1; }
.pipe-card-t { font-weight: 600; font-size: .82rem; display: flex; align-items: center; }
.pipe-card-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.pipe-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.pipe-assignee { color: #b45309; font-weight: 600; }
.pipe-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
@media (max-width: 640px) {
.pipe-board { scroll-snap-type: x mandatory; }
.pipe-col { flex-basis: 86vw; scroll-snap-align: start; }
}
</style>

View File

@ -1722,6 +1722,10 @@ function addFromCatalog (p) {
}, 900)
}
// installRegular / installFee : déclarés AVANT useWizardPublish car son `state` les référence
// dès la création de l'objet sinon ReferenceError (TDZ) au setup le wizard ne monte plus.
const installRegular = ref(0) // prix de référence barré (marketing)
const installFee = ref(0) // montant réellement financé/dû (créance)
const { publish } = useWizardPublish({
props, emit,
state: {
@ -1938,8 +1942,7 @@ const referralChecking = ref(false)
const referralError = ref('')
// Installation financée (CRTC-compliant) alimente install_fee / install_regular du contrat.
const installRegular = ref(0) // prix de référence barré (marketing)
const installFee = ref(0) // montant réellement financé/dû (créance)
// NB: installRegular / installFee sont déclarés plus HAUT (avant useWizardPublish) voir TDZ.
const INSTALL_PRESETS = [
{ key: 'standard', label: 'Standard', regular: 360, fee: 240 },
{ key: 'simple', label: 'Simple', regular: 180, fee: 120 },

View File

@ -0,0 +1,33 @@
<template>
<div v-if="others.length" class="rstack">
<q-avatar v-for="r in head" :key="r.email" :size="size + 'px'" class="rstack-av"
:style="{ background: staffColor(r.email), color: '#fff', fontSize: Math.round(size * 0.42) + 'px' }">
{{ staffInitials(noteAuthorName(r.email)) }}
<q-tooltip>{{ shortAgent(r.email) }}<template v-if="r.readAt"> · {{ relTime(r.readAt) }}</template></q-tooltip>
</q-avatar>
<span v-if="extra > 0" class="rstack-more">+{{ extra }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { staffColor, staffInitials, noteAuthorName, shortAgent, relTime } from 'src/composables/useFormatters'
const props = defineProps({
readers: { type: Array, default: () => [] }, // [{ email, readAt }]
me: { type: String, default: '' },
max: { type: Number, default: 3 },
size: { type: Number, default: 20 },
})
// Exclut soi-même (façon Messenger : on voit les AUTRES qui ont lu)
const others = computed(() => (props.readers || []).filter(r => r && r.email && r.email !== props.me))
const head = computed(() => others.value.slice(0, props.max))
const extra = computed(() => Math.max(0, others.value.length - props.max))
</script>
<style scoped>
.rstack { display: flex; align-items: center; }
.rstack-av { margin-left: -6px; border: 1.5px solid #fff; font-weight: 600; }
.rstack-av:first-child { margin-left: 0; }
.rstack-more { font-size: 0.66rem; color: #64748b; margin-left: 5px; font-weight: 600; }
</style>

View File

@ -0,0 +1,207 @@
<template>
<q-dialog v-model="open" @hide="reset">
<q-card style="min-width:540px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="wifi_find" color="cyan-8" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Statut du service</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<!-- Recherche par n'importe quel identifiant -->
<q-input dense outlined v-model="query" autofocus clearable
label="Adresse, nom, téléphone ou courriel"
@update:model-value="onSearch" @keyup.enter.exact.prevent="onSearch(query, true)">
<template #prepend><q-icon name="search" /></template>
<template #append><q-spinner v-if="searching" size="16px" color="cyan-7" /></template>
</q-input>
<div class="text-caption text-grey-5 q-mt-xs"><q-icon name="info" size="13px" /> Ex. « 2338 Ste-Clotilde », « Jardins Lefort », un cellulaire ou un courriel comme pour créer un ticket.</div>
<!-- Plusieurs comptes on choisit -->
<div v-if="candidates.length" class="q-mt-md">
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plusieurs comptes choisis :</div>
<q-list bordered separator class="rounded-borders">
<q-item v-for="m in candidates" :key="m.name" clickable @click="pickCustomer(m.name, m)">
<q-item-section avatar><q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person'" color="cyan-7" /></q-item-section>
<q-item-section>
<q-item-label>{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ m.address || m.phone || m.email || m.name }}</q-item-label>
</q-item-section>
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
</q-item>
</q-list>
</div>
<!-- Aucun résultat -->
<div v-else-if="searched && !data" class="q-mt-md text-grey-6 text-center q-pa-md">
<q-icon name="search_off" size="28px" color="grey-4" /><div class="text-caption q-mt-xs">Aucun compte trouvé pour « {{ lastQuery }} ».</div>
</div>
<!-- Carte statut -->
<div v-if="data" class="q-mt-md">
<!-- En-tête compte -->
<div class="row items-center no-wrap q-mb-sm">
<q-icon name="person" color="blue-7" size="18px" class="q-mr-xs" />
<div class="col">
<div class="text-weight-bold ellipsis">{{ data.customer.customer_name }}</div>
<div class="text-caption text-grey-6 ellipsis">{{ [data.customer.phone, data.customer.email].filter(Boolean).join(' · ') || data.customer.name }}</div>
</div>
<!-- Bandeau global -->
<q-chip dense :color="statusColor(data.summary.online)" text-color="white" :icon="statusIcon(data.summary.online)" class="text-weight-medium">{{ data.summary.label }}</q-chip>
</div>
<!-- Adresse de service (compte multi-adresses re-requête) -->
<q-select v-if="data.locations && data.locations.length > 1" dense outlined v-model="selLoc" :options="data.locations" option-label="address" option-value="name" emit-value map-options
label="Adresse de service" class="q-mb-sm" @update:model-value="pickLocation">
<template #prepend><q-icon name="place" color="orange-6" /></template>
<template #option="s">
<q-item v-bind="s.itemProps"><q-item-section>
<q-item-label>{{ s.opt.address }}</q-item-label>
<q-item-label caption>{{ s.opt.active ? '● service actif' : s.opt.status || 'inactif' }}</q-item-label>
</q-item-section></q-item>
</template>
</q-select>
<div v-else-if="data.location" class="text-caption text-grey-7 q-mb-sm"><q-icon name="place" size="13px" color="orange-6" /> {{ data.location.address || data.location.name }}</div>
<!-- Abonnement(s) service -->
<div v-if="data.subscriptions && data.subscriptions.length" class="q-mb-sm">
<div v-for="s in data.subscriptions" :key="s.name" class="row items-center q-gutter-xs q-mb-xs">
<q-icon name="router" size="15px" color="grey-7" />
<span class="text-body2">{{ s.plan || s.name }}</span>
<q-badge :color="subColor(s.status)" text-color="white" :label="s.status || '—'" />
<span v-if="s.start" class="text-caption text-grey-5">depuis {{ s.start }}</span>
</div>
</div>
<!-- Équipement(s) : statut fibre + TR-069 -->
<div v-if="data.devices && data.devices.length">
<div v-for="d in data.devices" :key="d.serial || d.mac" class="ss-dev q-mb-xs">
<div class="row items-center no-wrap">
<q-icon :name="statusIcon(d.online)" :color="statusColor(d.online)" size="18px" class="q-mr-sm" />
<div class="col">
<div class="text-body2 text-weight-medium ellipsis">{{ d.model || d.brand || d.serial || 'Équipement' }}</div>
<div class="text-caption text-grey-6 ellipsis">{{ d.serial }}<span v-if="d.detail"> · {{ d.detail }}</span></div>
</div>
<div class="text-right">
<q-chip dense :color="statusColor(d.online)" text-color="white" class="q-ma-none">{{ d.label }}</q-chip>
<div v-if="d.minutesAgo != null" class="text-caption text-grey-5">{{ fmtAgo(d.minutesAgo) }}</div>
</div>
</div>
<!-- métriques -->
<div class="row q-gutter-xs q-mt-xs">
<q-chip v-if="d.signal" dense square size="sm" :color="signalColor(d.signal)" text-color="white" icon="network_check">{{ d.signal }}<span v-if="d.rxPower != null" class="q-ml-xs">{{ d.rxPower }} dBm</span></q-chip>
<q-chip v-if="d.wifiClients != null" dense square size="sm" color="grey-3" text-color="grey-8" icon="devices">{{ d.wifiClients }} Wi-Fi</q-chip>
<q-chip v-if="d.oltName" dense square size="sm" color="grey-3" text-color="grey-8" icon="dns">{{ d.oltName }}<span v-if="d.oltPort != null" class="q-ml-xs">· p{{ d.oltPort }}</span></q-chip>
<q-chip v-if="d.ip" dense square size="sm" color="grey-3" text-color="grey-8" icon="lan">{{ d.ip }}</q-chip>
<q-chip v-if="d.offlineCause" dense square size="sm" color="red-2" text-color="red-9" icon="warning">{{ d.offlineCause }}</q-chip>
</div>
<!-- DoStuff (n8n) : statut LIVE de l'OLT lent (~30 s), à la demande -->
<div v-if="d.serial && d.olt" class="q-mt-xs">
<q-btn v-if="!d._live && !d._liveLoading" dense flat size="sm" no-caps icon="bolt" color="deep-purple-6" label="Vérifier en direct (F)" @click="dostuff(d)">
<q-tooltip>Interroge l'OLT en direct via n8n (comme F) ~30 s</q-tooltip>
</q-btn>
<div v-if="d._liveLoading" class="text-caption text-deep-purple-6 q-py-xs"><q-spinner-dots size="18px" /> Interrogation de l'OLT (~30 s)</div>
<div v-if="d._live" class="ss-live">
<div class="row items-center no-wrap">
<q-icon :name="d._live.ok ? (d._live.online ? 'check_circle' : 'cancel') : 'info'" :color="d._live.ok ? statusColor(d._live.online) : 'grey-5'" size="15px" class="q-mr-xs" />
<b class="text-caption">Direct (F)</b>
<span class="text-caption q-ml-xs" :class="d._live.ok ? '' : 'text-grey-6'">{{ d._live.ok ? d._live.label : d._live.error }}</span>
<q-space />
<q-btn dense flat round size="xs" icon="refresh" color="grey-6" @click="dostuff(d)"><q-tooltip>Réinterroger</q-tooltip></q-btn>
</div>
<div v-if="d._live.ok" class="row q-gutter-xs q-mt-xs">
<q-chip v-if="d._live.rxPower != null" dense square size="sm" :color="signalColor(d._live.signal)" text-color="white" icon="network_check">{{ d._live.signal }} · Rx {{ d._live.rxPower }}<span v-if="d._live.txPower != null"> / Tx {{ d._live.txPower }}</span> dBm</q-chip>
<q-chip v-if="d._live.distance != null" dense square size="sm" color="grey-3" text-color="grey-8" icon="straighten">{{ d._live.distance }} m</q-chip>
<q-chip v-if="d._live.uptime" dense square size="sm" color="grey-3" text-color="grey-8" icon="schedule">{{ d._live.uptime }}</q-chip>
<q-chip v-if="d._live.lastDown" dense square size="sm" color="amber-2" text-color="amber-9" icon="history">{{ d._live.lastDown }}</q-chip>
<q-chip v-if="d._live.managementIp" dense square size="sm" clickable color="blue-2" text-color="blue-9" icon="router" @click="openMgmt(d._live.managementIp)">GUI {{ d._live.managementIp }}</q-chip>
<q-chip v-if="d._live.wanIp" dense square size="sm" color="grey-3" text-color="grey-8" icon="public">WAN {{ d._live.wanIp }}</q-chip>
<q-chip v-if="d._live.firmware" dense square size="sm" color="grey-3" text-color="grey-8" icon="memory">{{ d._live.firmware }}</q-chip>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-caption text-grey-6 q-pa-sm bg-grey-2 rounded-borders"><q-icon name="info" size="14px" /> {{ data.summary.detail }}</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue'])
const $q = useQuasar()
const open = ref(false)
const query = ref('')
const lastQuery = ref('')
const searching = ref(false)
const searched = ref(false)
const candidates = ref([])
const data = ref(null)
const selLoc = ref('')
let _t = null
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
watch(open, v => emit('update:modelValue', v))
function reset () { query.value = ''; lastQuery.value = ''; candidates.value = []; data.value = null; searched.value = false; selLoc.value = ''; clearTimeout(_t) }
function onSearch (v, now = false) {
clearTimeout(_t)
const q = String(v || '').trim()
candidates.value = []; data.value = null; searched.value = false
if (q.length < 3) return
_t = setTimeout(() => fetchStatus({ q }), now ? 0 : 400)
}
async function fetchStatus (params) {
searching.value = true
lastQuery.value = params.q || lastQuery.value
try {
const usp = new URLSearchParams()
for (const [k, val] of Object.entries(params)) if (val) usp.set(k, val)
const r = await fetch(`${HUB_URL}/collab/service-status?${usp}`)
const d = await r.json()
searched.value = true
if (!d.ok) { $q.notify({ type: 'negative', message: d.error || 'Échec' }); return }
if (d.ambiguous) { candidates.value = d.matches || []; data.value = null; return }
if (!d.found) { candidates.value = []; data.value = null; return }
candidates.value = []
data.value = d
selLoc.value = d.location ? d.location.name : ''
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { searching.value = false }
}
function pickCustomer (name, m) { lastQuery.value = (m && m.customer_name) || name; fetchStatus({ customer: name }) }
function pickLocation (loc) { if (data.value) fetchStatus({ customer: data.value.customer.name, location: loc }) }
function fmtAgo (min) { if (min == null) return ''; if (min < 1) return "à l'instant"; if (min < 60) return `il y a ${min} min`; const h = Math.floor(min / 60); return h < 24 ? `il y a ${h} h` : `il y a ${Math.floor(h / 24)} j` }
function statusColor (o) { return o === true ? 'green-6' : o === false ? 'red-6' : 'grey-5' }
function statusIcon (o) { return o === true ? 'check_circle' : o === false ? 'cancel' : 'help' }
function signalColor (s) { return s === 'excellent' || s === 'bon' ? 'green-6' : s === 'faible' ? 'orange-7' : s === 'critique' ? 'red-6' : 'grey-5' }
function subColor (s) { const x = String(s || '').toLowerCase(); return x.includes('actif') ? 'green-6' : x.includes('suspend') ? 'orange-7' : 'grey-5' }
// DoStuff (n8n) : statut LIVE de l'OLT ~30 s, à la demande (réservé OLT tech=3 ; sinon « OLT non reconnue » le statut ci-dessus suffit).
async function dostuff (d) {
if (!d.serial || !d.olt) return
d._live = null; d._liveLoading = true
try {
const r = await fetch(`${HUB_URL}/collab/dostuff?serial=${encodeURIComponent(d.serial)}&olt=${encodeURIComponent(d.olt)}`)
const res = await r.json()
if (!res.ok && res.recognized === false) res.error = 'OLT suivie par notre sonde (statut ci-dessus) — direct réservé aux OLT XGS-PON'
d._live = res
} catch (e) { d._live = { ok: false, error: e.message } } finally { d._liveLoading = false }
}
function openMgmt (ip) { if (ip) window.open('https://' + ip + '/', '_blank', 'noopener') }
</script>
<style scoped>
.ss-dev { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; background: #f7fafc; }
</style>

View File

@ -1,5 +1,73 @@
<template>
<div class="modal-field-grid">
<!-- Vue appareil moderne : carte réseau + voyants + tuiles + trafic + événements (le dense est replié plus bas) -->
<div v-if="device" class="topo-summary">
<div class="topo-row">
<div class="topo-node"><q-icon name="public" size="22px" :color="status.online ? 'green-6' : 'grey-5'" /><div class="topo-cap">Internet</div></div>
<div class="topo-link" :class="{ ok: status.online }"></div>
<div class="topo-node"><q-icon name="cell_tower" size="22px" color="blue-grey-6" /><div class="topo-cap">OLT</div><div class="topo-sub">{{ oltLabel }}</div></div>
<div class="topo-link" :class="{ ok: status.online }"></div>
<div class="topo-node"><div class="topo-modem"><q-icon name="router" size="22px" color="indigo-6" /><span class="topo-dot" :class="status.online ? 'on' : 'off'"></span></div><div class="topo-cap">Modem</div><div class="topo-sub" v-if="doc.olt_slot != null">{{ doc.olt_slot }}/{{ doc.olt_port }}/{{ doc.olt_ontid }}</div></div>
<div class="topo-link" :class="{ ok: clientCount > 0 }"></div>
<div class="topo-node topo-click" @click="openHosts"><div style="position:relative;display:inline-block;"><q-icon name="devices" size="22px" color="indigo-6" /><q-circular-progress v-if="hostsLoading" indeterminate size="15px" :thickness="0.22" color="indigo-5" style="position:absolute;right:-9px;top:-4px;" /></div><div class="topo-cap">{{ clientCount }} app.</div></div>
</div>
<div class="topo-badges">
<q-badge :color="statusBadgeColor">{{ status.label }}</q-badge>
<q-badge v-if="device.opticalStatus" outline :color="device.opticalStatus === 'Up' ? 'green' : 'red'">Fibre {{ device.opticalStatus }}</q-badge>
<q-badge v-if="signalLabel" outline :color="signalBadgeColor">Signal {{ signalLabel }}</q-badge>
<span v-if="device.lastInform" class="text-caption text-grey-5">{{ timeAgo(device.lastInform) }}</span>
<q-space />
<q-btn flat dense size="sm" icon="sync" :loading="refreshing" @click="doRefresh" title="Rafraîchir" />
</div>
<div class="dv-grid">
<div class="dv-tile" v-if="device.rxPower != null && device.rxPower !== 0"><div class="dv-tl"><q-icon name="network_check" size="14px" /> Signal Rx/Tx</div><div class="dv-tv" :style="{ color: rxColor }">{{ device.rxPower }}<span v-if="device.txPower != null"> / {{ device.txPower }}</span> dBm</div></div>
<div class="dv-tile" v-if="device.uptime"><div class="dv-tl"><q-icon name="schedule" size="14px" /> Uptime</div><div class="dv-tv">{{ formatUptime(device.uptime) }}</div></div>
<div class="dv-tile" v-if="managementIp"><div class="dv-tl"><q-icon name="settings" size="14px" /> Gestion</div><a :href="'https://' + managementIp + '/'" target="_blank" class="dv-tv dv-link">{{ managementIp }}</a></div>
<div class="dv-tile" v-if="lastOutage"><div class="dv-tl dv-warn"><q-icon name="power_off" size="14px" /> Dern. coupure</div><div class="dv-tv">{{ lastOutage }}</div></div>
</div>
<div class="dv-row2">
<div class="dv-wan">
<div class="dv-tl">
<q-icon name="show_chart" size="14px" /> {{ wanLive ? 'Débit WAN en direct' : 'Disponibilité & signal récents' }}
<q-btn dense flat round size="9px" class="dv-wan-btn" :icon="wanLive ? 'stop' : 'speed'" :color="wanLive ? 'red' : 'primary'" @click="toggleWanLive">
<q-tooltip>{{ wanLive ? 'Arrêter la mesure' : 'Débit WAN en direct — compteurs OLT (façon F)' }}</q-tooltip>
</q-btn>
</div>
<!-- Mode LIVE : débit WAN temps réel (compteurs Counter64 de l'OLT, échantillon 5 s, façon F) -->
<template v-if="wanLive">
<div v-if="wanLiveErr" class="dv-wan-ph">{{ wanErrMsg }}</div>
<template v-else>
<svg viewBox="0 0 220 40" class="wan-spark" preserveAspectRatio="none">
<polyline v-if="wanRates.length > 1" :points="wanLine('dl')" fill="none" stroke="#16a34a" stroke-width="1.5" />
<polyline v-if="wanRates.length > 1" :points="wanLine('ul')" fill="none" stroke="#2563eb" stroke-width="1.5" />
</svg>
<div class="text-caption q-mt-xs">
<span style="color:#16a34a;font-weight:600"> {{ fmtBps(wanNow && wanNow.dl) }}</span> &nbsp;
<span style="color:#2563eb;font-weight:600"> {{ fmtBps(wanNow && wanNow.ul) }}</span>
<q-spinner size="11px" class="q-ml-xs" v-if="!wanNow" /><span class="text-grey-5" v-if="!wanNow"> mesure</span>
</div>
</template>
</template>
<!-- Mode défaut : bande disponibilité + signal Rx (historique device_cache, passif) -->
<template v-else-if="wanHistory.length">
<div class="wan-strip">
<span v-for="(p, i) in wanHistory" :key="i" class="wan-tick" :style="{ background: p.online ? '#16a34a' : '#dc2626' }"><q-tooltip>{{ fmtTs(p.ts) }} {{ p.online ? 'en ligne' : 'hors ligne' }}{{ p.rxPower != null ? ' · ' + p.rxPower + ' dBm' : '' }}</q-tooltip></span>
</div>
<div class="text-caption text-grey-6 q-mt-xs">Dispo {{ uptimePct }}% · Rx {{ lastRx }} · {{ wanHistory.length }} pts</div>
</template>
<div v-else class="dv-wan-ph"><q-icon name="insights" size="18px" color="grey-4" /> historique en accumulation</div>
</div>
<div class="dv-events" v-if="events.length">
<div class="dv-tl"><q-icon name="history" size="14px" /> Événements récents</div>
<div v-for="e in events.slice(0, 4)" :key="e.id" class="dv-ev"><q-icon :name="evIcon(e)" size="13px" :color="evColor(e)" /> <span class="text-grey-6">{{ evTime(e) }}</span> · {{ evLabel(e) }}</div>
</div>
</div>
</div>
<!-- Identité de l'appareil (repliée par défaut) -->
<div class="collapse-head" @click="showIdentity = !showIdentity"><q-icon :name="showIdentity ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Identité de l'appareil</div>
<div v-show="showIdentity || !device" class="modal-field-grid">
<div v-for="f in equipFields" :key="f.field" class="mf">
<span class="mf-label">{{ f.label }}</span>
<InlineField :value="doc[f.field]" :field="f.field" doctype="Service Equipment" :docname="docName"
@ -9,24 +77,8 @@
</div>
<div v-if="device" class="q-mt-md">
<div class="info-block-title">
Diagnostic en direct
<q-badge :color="statusBadgeColor" class="q-ml-sm" :class="{ 'acs-badge-pulse': status.source === 'unknown' }">
{{ status.label }}
<q-tooltip>{{ status.detail }}</q-tooltip>
</q-badge>
<q-badge v-if="status.source === 'olt' && status.online" outline color="amber-8" class="q-ml-xs" style="font-size:0.65rem;">
<q-icon name="info" size="10px" class="q-mr-xs" />TR-069 inactif
<q-tooltip>Le modem est en ligne (confirmé par l'OLT) mais le canal de gestion TR-069 ne répond pas. Le client a internet.</q-tooltip>
</q-badge>
<q-badge v-if="status.source === 'tr069' && !status.online" outline color="orange-8" class="q-ml-xs" style="font-size:0.65rem;">
OLT non vérifié
<q-tooltip>Statut basé uniquement sur TR-069. Vérification OLT non disponible.</q-tooltip>
</q-badge>
<span v-if="device.lastInform" class="text-caption text-grey-5 q-ml-sm">{{ timeAgo(device.lastInform) }}</span>
<q-btn flat dense size="sm" icon="sync" class="q-ml-sm" :loading="refreshing" @click="doRefresh" title="Rafraichir" />
</div>
<div class="collapse-head" @click="showTech = !showTech"><q-icon :name="showTech ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Détails techniques<span class="text-caption text-grey-5 q-ml-xs">fibre · IP · WiFi · Ethernet · firmware</span></div>
<div v-show="showTech">
<div class="diag-section fibre-top">
<q-icon name="settings_input_hdmi" size="16px" class="q-mr-xs" />
<span class="text-weight-bold q-mr-sm">Fibre</span>
@ -118,14 +170,17 @@
<div class="diag-item" v-if="device.uptime"><span class="diag-label">Uptime</span>{{ formatUptime(device.uptime) }}</div>
</div>
</div>
</div>
<div class="diag-section">
<div class="diag-section-title">
<div class="diag-section-title collapse-head" @click="showHosts = !showHosts">
<q-icon :name="showHosts ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />
<q-icon name="devices" size="16px" class="q-mr-xs" />
Appareils connectes
Appareils connectés
<span v-if="hosts" class="text-caption text-grey-5 q-ml-xs">({{ hosts.total }})</span>
<q-btn flat dense size="sm" icon="refresh" class="q-ml-sm" :loading="hostsLoading" @click="loadHosts(true)" title="Rafraichir la liste" />
<q-btn flat dense size="sm" icon="refresh" class="q-ml-sm" :loading="hostsLoading" @click.stop="loadHosts(true)" title="Rafraichir la liste" />
</div>
<div v-show="showHosts">
<div v-if="hostsLoading" class="text-caption text-grey-5 q-py-xs">
<q-spinner size="14px" class="q-mr-xs" /> Interrogation de l'equipement...
</div>
@ -164,14 +219,16 @@
</div>
</div>
<div v-else-if="hosts" class="text-caption text-grey-5 q-py-xs">Aucun appareil</div>
</div>
</div>
</div>
<div v-else-if="doc.serial_number && !device" class="q-mt-md text-caption text-grey-5">
<q-spinner size="14px" class="q-mr-xs" v-if="deviceLoading" /> Chargement diagnostic ACS...
</div>
<!-- Advanced WiFi Diagnostic Panel -->
<div v-if="managementIp || props.doc?.serial_number" class="q-mt-md">
<!-- Diagnostic modem avancé (modem-bridge / Playwright) DÉSACTIVÉ « pour le moment » : trop lourd (~15 s).
Réactiver : remettre v-if="managementIp || props.doc?.serial_number". Le diagnostic GenieACS/OLT + liste d'appareils suffisent. -->
<div v-if="false" class="q-mt-md">
<div class="info-block-title">
<q-icon name="wifi_find" size="16px" class="q-mr-xs" />
Diagnostic modem
@ -447,12 +504,13 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useQuasar } from 'quasar'
import InlineField from 'src/components/shared/InlineField.vue'
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
import { deleteDoc } from 'src/api/erp'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({ doc: { type: Object, required: true }, docName: String })
const emit = defineEmits(['deleted'])
@ -690,6 +748,84 @@ function formatUptime (seconds) {
return d > 0 ? `${d}j ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
// Épuré : sections denses repliées par défaut (l'essentiel est dans le résumé en tête).
const showIdentity = ref(false)
const showTech = ref(false)
const showHosts = ref(false)
const clientCount = computed(() => (hosts.value && hosts.value.total != null) ? hosts.value.total : (device.value?.wifi?.totalClients ?? 0))
const SIG_LBL = { excellent: 'excellent', good: 'bon', fair: 'faible', bad: 'critique' }
const signalLabel = computed(() => SIG_LBL[signalQuality(props.doc.serial_number)] || '')
const signalBadgeColor = computed(() => { const s = signalQuality(props.doc.serial_number); return (s === 'excellent' || s === 'good') ? 'green' : s === 'fair' ? 'orange' : s === 'bad' ? 'red' : 'grey' })
const oltLabel = computed(() => props.doc.olt_name || props.doc.olt_ip || 'OLT')
// Événements récents (register / dying-gasp / online-offline) depuis nos device_events (le poller les journalise déjà).
const events = ref([])
async function loadEvents () {
if (!props.doc.serial_number) return
try { const r = await fetch(`${HUB_URL}/devices/events?serial=${encodeURIComponent(props.doc.serial_number)}&limit=8`); if (r.ok) events.value = await r.json() } catch (e) { /* */ }
}
const lastOutage = computed(() => { const o = events.value.find(e => e.event === 'offline'); return o ? (o.reason || 'coupure') : null })
function evIcon (e) { return e.event === 'offline' ? 'power_off' : e.event === 'online' ? 'check_circle' : 'fiber_manual_record' }
function evColor (e) { return e.event === 'offline' ? 'orange-7' : e.event === 'online' ? 'green-6' : 'grey-5' }
function evLabel (e) { return e.event === 'offline' ? ('Hors ligne' + (e.reason ? ' · ' + e.reason : '')) : e.event === 'online' ? 'En ligne' : (e.event || '') }
function evTime (e) { try { return new Date(e.created_at).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) } catch (x) { return '' } }
// Graphe : disponibilité + signal Rx récents, depuis l'historique device_cache (vraie data ; le débit Mbps façon F = source ES, plus tard).
const wanHistory = ref([])
async function loadWanHistory () {
if (!props.doc.serial_number) return
try { const r = await fetch(`${HUB_URL}/devices/cache?serial=${encodeURIComponent(props.doc.serial_number)}`); if (r.ok) { const d = await r.json(); wanHistory.value = (Array.isArray(d.history) ? d.history : []).slice(-48) } } catch (e) { /* */ }
}
const uptimePct = computed(() => { const h = wanHistory.value; if (!h.length) return null; return Math.round(h.filter(p => p.online).length / h.length * 100) })
const lastRx = computed(() => { for (let i = wanHistory.value.length - 1; i >= 0; i--) { if (wanHistory.value[i].rxPower != null) return wanHistory.value[i].rxPower + ' dBm' } return '—' })
function fmtTs (ts) { try { return new Date(ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) } catch (e) { return '' } }
// Débit WAN EN DIRECT : mirroir de raisecom_rcmg_bandwith.php de F. Le hub lit les compteurs
// d'octets Counter64 de l'OLT (Raisecom/tech-2) ; ici on échantillonne toutes les 5 s et on
// calcule le débit = (Δoctets / Δs) × 8 (façon F). À la demande seulement (charge OLT).
const wanLive = ref(false)
const wanLiveErr = ref('') // '' | 'unsupported' | 'error' | autre raison
const wanRates = ref([]) // [{ dl, ul, ts }] bits/s, ~40 derniers points
let wanTimer = null
let wanPrev = null // dernier échantillon brut pour le delta
const WAN_ERR_MSG = {
unsupported_olt_type: 'Débit en direct indisponible — OLT TP-Link (tech 3). Seules les OLT Raisecom exposent ce compteur.',
onu_not_resolved: 'ONU absent du sondage OLT en ce moment (OLT hors supervision ?). Débit en direct indisponible.',
no_counter: 'Aucun compteur de débit pour cet ONU sur lOLT.',
snmp_unavailable: 'Sonde SNMP indisponible.',
}
const wanErrMsg = computed(() => WAN_ERR_MSG[wanLiveErr.value] || (wanLiveErr.value ? 'Mesure indisponible pour le moment.' : ''))
const fmtBps = (bps) => (bps == null) ? '—' : (bps >= 1e6 ? (bps / 1e6).toFixed(1) + ' Mbps' : (bps / 1e3).toFixed(0) + ' kbps')
const wanNow = computed(() => wanRates.value.length ? wanRates.value[wanRates.value.length - 1] : null)
const wanMax = computed(() => { let m = 1; for (const r of wanRates.value) { if (r.dl > m) m = r.dl; if (r.ul > m) m = r.ul } return m })
function wanLine (key) {
const r = wanRates.value; if (r.length < 2) return ''
const max = wanMax.value, W = 220, H = 38, n = r.length
return r.map((p, i) => `${((i / (n - 1)) * W).toFixed(1)},${(H - ((p[key] || 0) / max) * H).toFixed(1)}`).join(' ')
}
async function wanPoll () {
if (!props.doc.serial_number) return
try {
const r = await fetch(`${HUB_URL}/collab/wan-rate?serial=${encodeURIComponent(props.doc.serial_number)}`)
if (!r.ok) { wanLiveErr.value = 'error'; stopWanTimer(); return }
const d = await r.json()
// Indisponible (OLT non Raisecom, ONU non résolu, pas de compteur) = état non transitoire on affiche le motif et on ARRÊTE de sonder (pas de spinner infini).
if (!d.available) { wanLiveErr.value = d.reason || 'error'; stopWanTimer(); return }
wanLiveErr.value = ''
const cur = { ts: d.ts, rx: d.rxOctets, tx: d.txOctets }
if (wanPrev) {
const sec = (cur.ts - wanPrev.ts) / 1000
const delta = (a, b) => { if (a == null || b == null) return null; let x = b - a; if (x < 0) x = 0; return sec > 0 ? (x / sec) * 8 : null }
wanRates.value = [...wanRates.value, { dl: delta(wanPrev.rx, cur.rx), ul: delta(wanPrev.tx, cur.tx), ts: cur.ts }].slice(-40)
}
wanPrev = cur
} catch (e) { wanLiveErr.value = 'error'; stopWanTimer() }
}
function startWanLive () { wanLive.value = true; wanLiveErr.value = ''; wanRates.value = []; wanPrev = null; wanPoll(); wanTimer = setInterval(wanPoll, 3000) }
function stopWanTimer () { if (wanTimer) { clearInterval(wanTimer); wanTimer = null } }
function stopWanLive () { stopWanTimer(); wanLive.value = false; wanLiveErr.value = '' }
function toggleWanLive () { wanLive.value ? stopWanLive() : startWanLive() }
// Liste d'appareils au clic sur l'icône : on déplie la version EN CACHE s'affiche vite (le hub sert le cache par défaut) + anneau de chargement pendant le rafraîchissement.
function openHosts () { showHosts.value = true; if (!hosts.value && device.value) loadHosts() }
async function doRefresh () {
if (!props.doc.serial_number) return
refreshing.value = true
@ -705,17 +841,23 @@ onMounted(async () => {
fetchStatus([{ serial_number: props.doc.serial_number }])
fetchOltStatus(props.doc.serial_number)
fetchPortContext(props.doc.serial_number).then(ctx => { portCtx.value = ctx })
loadEvents()
loadWanHistory()
}
})
watch(device, d => { if (d && !hosts.value) loadHosts() }, { immediate: true })
// Chargement PARESSEUX : on n'interroge le modem (modem-bridge) que si l'agent déplie « Appareils connectés ».
watch(showHosts, v => { if (v && !hosts.value && device.value) loadHosts() })
watch(() => props.doc.serial_number, sn => {
stopWanLive() // arrêter la mesure live de l'appareil précédent
if (sn) {
hosts.value = null
fetchStatus([{ serial_number: sn }])
fetchOltStatus(sn)
loadEvents(); loadWanHistory() // rafraîchir la bande dispo/événements pour le nouvel appareil
}
})
onUnmounted(() => stopWanLive())
</script>
<style scoped>
@ -724,6 +866,35 @@ watch(() => props.doc.serial_number, sn => {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.topo-summary { border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px; background: #f8fafc; margin-bottom: 10px; }
.topo-row { display: flex; align-items: flex-start; justify-content: center; gap: 0; }
.topo-node { display: flex; flex-direction: column; align-items: center; width: 90px; }
.topo-modem { position: relative; }
.topo-dot { position: absolute; right: -2px; bottom: 2px; width: 9px; height: 9px; border-radius: 999px; border: 2px solid #f8fafc; }
.topo-dot.on { background: #21BA45; } .topo-dot.off { background: #C10015; }
.topo-cap { font-size: 0.72rem; color: #64748b; margin-top: 3px; text-align: center; }
.topo-link { flex: 1; max-width: 56px; height: 2px; background: #cbd5e1; margin-top: 12px; }
.topo-link.ok { background: #21BA45; opacity: .55; }
.topo-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.topo-sub { font-size: 0.66rem; color: #94a3b8; }
.dv-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 10px; }
.dv-tile { border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; }
.dv-tl { font-size: 0.68rem; color: #64748b; display: flex; align-items: center; gap: 3px; }
.dv-tl.dv-warn { color: #b45309; }
.dv-tv { font-size: 0.84rem; font-weight: 500; color: #1e293b; margin-top: 2px; }
.dv-link { color: #4f46e5; text-decoration: none; }
.dv-row2 { display: flex; gap: 8px; margin-top: 8px; }
.dv-wan, .dv-events { flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; padding: 7px 9px; background: #fff; }
.dv-wan-ph { font-size: 0.78rem; color: #94a3b8; padding: 10px 0; text-align: center; }
.dv-ev { font-size: 0.74rem; color: #334155; padding: 2px 0; }
.topo-click { cursor: pointer; border-radius: 8px; transition: background .12s; }
.topo-click:hover { background: #eef2ff; }
.wan-strip { display: flex; gap: 2px; align-items: flex-end; height: 22px; margin-top: 4px; }
.wan-tick { flex: 1; min-width: 2px; height: 100%; border-radius: 1px; opacity: .85; }
.dv-wan-btn { float: right; margin-top: -2px; }
.wan-spark { width: 100%; height: 38px; display: block; margin-top: 4px; }
.collapse-head { display: flex; align-items: center; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #475569; padding: 6px 2px; border-top: 1px solid #f1f5f9; user-select: none; }
.collapse-head:hover { color: #1e293b; }
.diag-section { margin-top: 8px; }
.fibre-top { display: flex; align-items: center; font-size: 0.84rem; padding: 6px 8px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
.diag-section-title { font-size: 0.78rem; font-weight: 600; color: #4b5563; margin-bottom: 4px; display: flex; align-items: center; }

View File

@ -139,7 +139,7 @@ import { deleteDoc, getDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext'
import InlineField from 'src/components/shared/InlineField.vue'
const HUB_URL = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const HUB_URL = location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const props = defineProps({
doc: { type: Object, required: true },

View File

@ -70,7 +70,7 @@ export function useClientData (deps) {
fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
'contract_duration', 'product_sku', 'speed_down', 'speed_up',
'radius_user', 'device', 'display_order'],
'radius_user', 'device', 'display_order', 'legacy_service_id'],
limit: 100,
// display_order first (dispatcher-controlled), then by start date so
// newer subs float up when display_order hasn't been set yet (= 0).
@ -110,6 +110,7 @@ export function useClientData (deps) {
radius_pwd: '',
device: doc.device || '',
display_order: Number(doc.display_order || 0),
legacy_service_id: Number(doc.legacy_service_id || 0), // = service.id de F (0 = ajouté dans ERPNext, pas dans F)
qty: 1,
}))
}

View File

@ -135,3 +135,56 @@ export function noteTimeAgo (d) {
if (days < 7) return `Il y a ${days}j`
return formatDate(d)
}
// ═══ Canonical helpers (consolidation target — see docs/UI_AND_OPTIMIZATION.md §D) ═══
/**
* Email short agent handle (local part). Canonical replacement for the ~5 private
* `shortAgent` copies across conversation/board components.
* @param {string|null} email
* @returns {string}
*/
export function shortAgent (email) {
return String(email || '').split('@')[0]
}
/**
* Compact time of day "9h05" (fr). Used by tech list/cards.
* @param {string|number|Date|null} t
* @returns {string}
*/
export function fmtTimeHHhMM (t) {
if (!t) return ''
const d = new Date(t)
if (isNaN(d.getTime())) return ''
return `${d.getHours()}h${String(d.getMinutes()).padStart(2, '0')}`
}
/**
* Relative timestamp: today "14:32", otherwise "13/06" (fr-CA).
* Canonical target for the divergent inbox/board `formatTime` copies.
* @param {string|number|Date|null} ts
* @returns {string}
*/
export function relTime (ts) {
if (!ts) return ''
const dt = new Date(ts)
if (isNaN(dt.getTime())) return ''
const now = new Date()
return dt.toDateString() === now.toDateString()
? dt.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
: dt.toLocaleDateString('fr-CA', { day: '2-digit', month: '2-digit' })
}
/**
* Short date+time, e.g. "2026-06-13 14:32" (fr-CA). Accepts ISO or "YYYY-MM-DD HH:MM:SS".
* Canonical target for the ~6 `fmtTime`/`fmt`/`fmtTs` copies. Returns '' on empty/invalid.
* @param {string|number|Date|null} d
* @returns {string}
*/
export function formatDateTimeShort (d) {
if (!d) return ''
const dt = new Date(String(d).replace(' ', 'T'))
if (isNaN(dt.getTime())) return ''
return dt.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
}

View File

@ -0,0 +1,39 @@
/**
* departments.js Détection du département probable par MOTS-CLÉS (instantané, côté client).
*
* Sert au composer de ticket (et au SMS/courriel) : en tapant le sujet en langage naturel,
* on fait ressortir le(s) département(s) le(s) plus probable(s) chips cliquables, sans choisir dans une liste.
* `category` = la catégorie du ticket (préfixe [Cat]) ; `queue` = l'équipe notifiée (files de l'Inbox).
*
* Éditer ici pour ajuster les mots-clés une seule source.
*/
export const DEPARTMENTS = [
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'indigo-6',
re: /\b(wi-?fi|internet|lent[e]?|ralenti|panne|coup[ée]+|connexion|connecter?|signal|modem|routeur|red[ée]marr|d[ée]connect|d[ée]branch|intermittent|latence|ping|ne marche|ne fonctionne|pas d['e]internet|hors service|bug)\b/i },
{ category: 'Facturation', queue: 'Facturations', icon: 'receipt_long', color: 'green-7',
re: /\b(factur|paiement|payer|pay[ée]|solde|rembours|pr[ée]l[èe]v|carte de cr[ée]dit|montant|frais|cr[ée]dit|impay[ée]|retard de paiement|re[çc]u|virement|interac|trop per[çc]u)\b/i },
{ category: 'Installation', queue: 'Technicien', icon: 'construction', color: 'orange-7',
re: /\b(installation|installer|rendez-?vous|rdv|branchement|brancher|raccord|technicien|d[ée]placement|visite)\b/i },
{ category: 'Télévision', queue: 'Technicien', icon: 'live_tv', color: 'purple-6',
re: /\b(t[ée]l[ée]vision|t[ée]l[ée]\b|\btv\b|iptv|cha[îi]ne|d[ée]codeur|illico|ministra|enregistreur|t[ée]l[ée]command)\b/i },
{ category: 'Téléphonie', queue: 'Technicien', icon: 'call', color: 'teal-7',
re: /\b(t[ée]l[ée]phon|voip|tonalit[ée]|ligne t[ée]l|appel sortant|messagerie vocale|combin[ée]|sip)\b/i },
{ category: 'Commercial', queue: 'Service à la clientèle', icon: 'sell', color: 'blue-7',
re: /\b(forfait|abonnement|prix|soumission|nouveau client|d[ée]m[ée]nage|upgrade|am[ée]liorer|vitesse|offre|promotion|r[ée]siliation|r[ée]silier|annulation|annuler|fermer le compte)\b/i },
]
/**
* suggestDepartments(text) départements probables, classés par nombre de mots-clés trouvés.
* Renvoie [{ category, queue, icon, color, keyword, score }]. Le 1er = le plus probable.
*/
export function suggestDepartments (text) {
const t = String(text || '')
if (t.trim().length < 3) return []
const hits = []
for (const d of DEPARTMENTS) {
const g = new RegExp(d.re.source, 'gi')
const matches = t.match(g)
if (matches && matches.length) hits.push({ category: d.category, queue: d.queue, icon: d.icon, color: d.color, keyword: matches[0].toLowerCase(), score: matches.length })
}
return hits.sort((a, b) => b.score - a.score)
}

View File

@ -5,9 +5,14 @@
* import { HUB_URL } from 'src/config/hub'
*
* In dev (localhost), points to local targo-hub instance.
* In production, points to msg.gigafibre.ca.
* In production, uses the SAME-ORIGIN proxy: /ops/hub/* Traefik strips /ops
* ops-frontend nginx `location /hub/` injects the hub service token (Bearer)
* targo-hub. This keeps the token server-side (never in the JS bundle), lets
* EventSource (/sse, /conversations) work, and rides the existing Authentik
* forward-auth on /ops. Mirrors the /ops/api ERPNext pattern (config/erpnext.js).
*
* Can also be overridden via VITE_HUB_URL env variable.
*/
const _opsBase = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') // '' in dev, '/ops' in prod
export const HUB_URL = import.meta.env.VITE_HUB_URL
|| (window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca')
|| (window.location.hostname === 'localhost' ? 'http://localhost:3300' : _opsBase + '/hub')

View File

@ -0,0 +1,39 @@
/**
* API d'assignation UNIFIÉE — chemin d'écriture UNIQUE (via targo-hub).
* -----------------------------------------------------------------------------
* CONVERGENCE Dispatch Planification : l'assignation jobtech
* (`assigned_tech` / `scheduled_date` / `route_order` / `start_time` / `status`
* + l'ÉQUIPE) ne doit JAMAIS être écrite en direct dans ERPNext (PUT
* api/dispatch.js). Tout passe ici le hub (lib/roster.js) qui valide, pose le
* first-fit `start_time`, garde la durée, le booking_status, et après 1b
* préserve les assistants. Un seul écrivain plus de conflit « dernier qui
* écrit gagne » entre les deux UIs.
*
* NE PAS importer api/dispatch.js#updateJob pour ces champs. Utiliser ceci.
*/
import { HUB_URL as HUB } from 'src/config/hub'
async function jpost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
})
if (!r.ok) throw new Error('Assignment API ' + r.status + ' ' + path)
return r.json()
}
// Assigner un job à un tech sur une date (le hub pose first-fit start_time + garde durée + booking_status).
export const assign = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Réordonner / re-prioriser les jobs d'un tech×jour — updates = [{ job, route_order, priority? }]
export const reorder = (updates) => jpost('/roster/reorder-jobs', { updates })
// Retirer un job du tech (retour au pool non assigné).
export const unassign = (job) => jpost('/roster/unassign-job', { job })
// Équipe (lead + assistants). assistants = [{ tech_id, tech_name, duration_h, note, pinned }].
// Le LEAD reste `assigned_tech` (inchangé) ; on ne fait qu'éditer la table-enfant `assistants`.
// Route hub /roster/job/team ajoutée en 1b (qui persiste la table-enfant + recalcule l'occupation
// des assistants pinnés). Avant 1b, cet appel échoue proprement (404) — aucune vue ne l'appelle encore.
export const setTeam = (job, assistants) => jpost('/roster/job/team', { job, assistants })

View File

@ -0,0 +1,62 @@
/**
* useAssignment composable d'assignation UNIFIÉE (le SEUL point d'écriture
* jobtech, partagé par le board Dispatch ET la grille Planification).
* -----------------------------------------------------------------------------
* Les vues appellent assign/reorder/unassign/setTeam JAMAIS updateJob() direct.
* Le composable est agnostique du store : la vue fournit ses hooks optimistes.
*
* const A = useAssignment({
* onError: (e) => $q.notify({ type:'negative', message:String(e) }),
* onLocal: (fn) => fn(), // appliquer une MAJ locale optimiste (selon le store)
* })
* await A.assign(jobName, techId, '2026-06-10')
*
* `assigned_tech` = clé d'identité tech NORMALISÉE (voir useResources.techKey) :
* on passe l'id tech ; le hub résout le docname Dispatch Technician.
*/
import * as api from '../api/assignment'
// Sérialise une liste d'assistants (modèle front {techId,...} OU déjà {tech_id,...}) → format hub/ERPNext.
export function serializeAssistants (assistants) {
return (assistants || []).map(a => ({
tech_id: a.techId ?? a.tech_id,
tech_name: a.techName ?? a.tech_name ?? '',
duration_h: a.duration ?? a.duration_h ?? null,
note: a.note || '',
pinned: a.pinned ? 1 : 0,
}))
}
const aid = (a) => a && (a.techId ?? a.tech_id)
export function useAssignment (opts = {}) {
const onErr = opts.onError || ((e) => console.error('[assignment]', e))
const local = opts.onLocal || ((fn) => { try { fn && fn() } catch (e) { /* noop */ } })
async function run (fn, optimistic) {
if (optimistic) local(optimistic) // MAJ optimiste immédiate (UX réactive)
try { return await fn() } catch (e) { onErr(e); throw e }
}
// Assigner job→tech sur une date (option optimistic = closure de MAJ locale).
const assign = (job, tech, date, optimistic) => run(() => api.assign(job, tech, date), optimistic)
// Réordonner : updates = [{ job, route_order, priority? }].
const reorder = (updates, optimistic) => run(() => api.reorder(updates), optimistic)
// Désassigner (retour au pool).
const unassign = (job, optimistic) => run(() => api.unassign(job), optimistic)
// Remplacer l'équipe complète d'un job.
const setTeam = (job, assistants, optimistic) => run(() => api.setTeam(job, serializeAssistants(assistants)), optimistic)
// Ajouter un assistant (dédupe par tech) — `current` = liste actuelle d'assistants du job.
function addAssistant (job, current, assistant, optimistic) {
const next = [...(current || []).filter(a => aid(a) !== aid(assistant)), assistant]
return setTeam(job, next, optimistic)
}
// Retirer un assistant par id tech.
function removeAssistant (job, current, techId, optimistic) {
const next = (current || []).filter(a => aid(a) !== techId)
return setTeam(job, next, optimistic)
}
return { assign, reorder, unassign, setTeam, addAssistant, removeAssistant }
}

View File

@ -0,0 +1,93 @@
/**
* useResources « rail » de ressources UNIFIÉE (techs + sous-groupes + recherche
* + filtres + tri + masqués + scoring), partagée par TOUTES les vues.
* -----------------------------------------------------------------------------
* CONVERGENCE : aujourd'hui la liste de techs / sous-groupes / filtres est
* RECRÉÉE dans chaque page (Dispatch: useResourceFilter ; Planification:
* visibleTechs+groupFilter+search+hiddenTechs+priorityScores). On la définit ICI
* UNE fois ; chaque vue la consomme. Agnostique de la source : on lui passe un
* getter/ref du tableau de techs (modèle « union »).
*
* Modèle Tech union (champs lus ici, tous optionnels sauf id) :
* { id, name, fullName?, group?, status?, resourceType?, tags?[] }
*
* IDENTITÉ : `techKey(t)` normalise sur UNE clé (id) corrige l'ambiguïté
* docname Dispatch Technician vs technician_id repérée à la cartographie.
*/
import { ref, computed, watch, unref } from 'vue'
const lsGet = (k, d) => { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v) } catch { return d } }
const lsSet = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)) } catch { /* quota */ } }
export const techKey = (t) => (t && (t.id ?? t.technician_id ?? t.name)) || ''
const nameOf = (t) => t.fullName || t.full_name || t.name || ''
const lastName = (t) => nameOf(t).split(' ').pop().toLowerCase()
/**
* @param techsSource ref|getter renvoyant le tableau de techs
* @param opts.lsPrefix préfixe localStorage (persistance par contexte), défaut 'wf-'
* @param opts.defaultType '' | 'human' | 'material' filtre type de ressource initial
* @param opts.showHidden ref<boolean> si true, on affiche les masqués (grisés) au lieu de les cacher
* @param opts.score (t)=>number score optionnel (Planification : compétence+vitesse+coût) tri décroissant
* @param opts.loadH (t)=>number charge horaire (pour le tri 'load')
*/
export function useResources (techsSource, opts = {}) {
const P = opts.lsPrefix || 'wf-'
const techs = () => (unref(typeof techsSource === 'function' ? techsSource() : techsSource)) || []
const search = ref('')
const groupFilter = ref(lsGet(P + 'group', '') || '')
const resourceType = ref(opts.defaultType ?? '') // '' | 'human' | 'material'
const hidden = ref(lsGet(P + 'hidden', []) || []) // ids masqués
const sort = ref(lsGet(P + 'sort', 'default') || 'default') // 'default'|'alpha'|'load'
const showHidden = opts.showHidden || ref(false)
watch(groupFilter, v => lsSet(P + 'group', v))
watch(hidden, v => lsSet(P + 'hidden', v), { deep: true })
watch(sort, v => lsSet(P + 'sort', v))
// Sous-groupes (tech_group) présents, triés.
const groups = computed(() => { const s = new Set(); for (const t of techs()) if (t.group) s.add(t.group); return [...s].sort() })
const isHidden = (t) => hidden.value.includes(techKey(t))
// Liste filtrée + triée (humains avant matériel ; puis score|alpha|load|défaut).
const filtered = computed(() => {
let list = techs()
if (resourceType.value) list = list.filter(t => (t.resourceType || 'human') === resourceType.value)
if (search.value) { const q = search.value.toLowerCase(); list = list.filter(t => nameOf(t).toLowerCase().includes(q)) }
if (groupFilter.value) list = list.filter(t => t.group === groupFilter.value)
if (!showHidden.value) list = list.filter(t => !isHidden(t))
list = [...list].sort((a, b) => {
const at = a.resourceType === 'material' ? 1 : 0, bt = b.resourceType === 'material' ? 1 : 0
if (at !== bt) return at - bt
if (opts.score) return (opts.score(b) || 0) - (opts.score(a) || 0)
if (sort.value === 'alpha') return lastName(a).localeCompare(lastName(b))
if (sort.value === 'load' && opts.loadH) return (opts.loadH(a) || 0) - (opts.loadH(b) || 0)
return 0
})
return list
})
// Regroupé par sous-groupe (groupes nommés d'abord, « Non assigné » à la fin).
const grouped = computed(() => {
const m = new Map()
for (const t of filtered.value) { const g = t.group || ''; if (!m.has(g)) m.set(g, []); m.get(g).push(t) }
return [...m.entries()]
.sort((a, b) => (!a[0] && b[0]) ? 1 : (a[0] && !b[0]) ? -1 : a[0].localeCompare(b[0]))
.map(([name, list]) => ({ name, label: name || 'Non assigné', techs: list }))
})
const toggleHidden = (id) => { hidden.value = hidden.value.includes(id) ? hidden.value.filter(x => x !== id) : [...hidden.value, id] }
const hiddenCount = computed(() => hidden.value.length)
const clearFilters = () => { search.value = ''; groupFilter.value = ''; resourceType.value = opts.defaultType ?? '' }
return {
// état
search, groupFilter, resourceType, hidden, sort, showHidden,
// dérivés
groups, filtered, grouped, hiddenCount,
// actions
toggleHidden, clearFilters, isHidden, techKey,
}
}

View File

@ -81,7 +81,7 @@ import { Notify } from 'quasar'
import { publishJobs } from 'src/api/dispatch'
import { sendTestSms } from 'src/api/sms'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
async function getMagicLink (techId) {
try {

View File

@ -12,7 +12,7 @@ import { Notify } from 'quasar'
const HUB_URL = window.location.hostname === 'localhost'
? 'http://localhost:3300'
: 'https://msg.gigafibre.ca'
: (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const props = defineProps({
modelValue: Boolean,

View File

@ -219,6 +219,9 @@
<div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)">
<div class="row items-center no-wrap q-gutter-x-sm">
<code class="sub-sku">{{ sub.name }}</code>
<!-- Lien legacy F : legacy_service_id = service.id de F (0 = ajouté dans ERPNext, absent de F) -->
<q-badge v-if="sub.legacy_service_id > 0" color="blue-grey-1" text-color="blue-grey-8" :label="'F #' + sub.legacy_service_id"><q-tooltip>Service F #{{ sub.legacy_service_id }} (corrélé)</q-tooltip></q-badge>
<q-badge v-else color="red-2" text-color="red-9" label="OPS"><q-tooltip>Ajouté dans ERPNext absent de F (pas de legacy_service_id)</q-tooltip></q-badge>
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
<!-- Inline price edit: double-click to edit. Negative values kept as-is for rebates. -->
<span class="sub-price-wrap" @click.stop @dblclick.stop
@ -266,7 +269,12 @@
<div class="sub-total-line">
<span class="sub-total-label">Total mensuel</span>
<span class="sub-total-amount">{{ formatMoney(locSubsMonthlyTotal(loc.name)) }} /mois</span>
<span class="sub-total-amount" :class="{ 'sub-total-neg': locSubsMonthlyTotal(loc.name) < 0 }">{{ formatMoney(locSubsMonthlyTotal(loc.name)) }} /mois</span>
</div>
<!-- Garde-fou : un abonnement récurrent ne peut pas être négatif (rabais > frais). On NE corrige PAS (facturation = F autoritaire) : on signale pour correction dans F. -->
<div v-if="locSubsMonthlyTotal(loc.name) < 0" class="sub-total-warning">
<q-icon name="error" size="15px" color="red-6" class="q-mr-xs" />
<span>Total récurrent négatif les rabais dépassent les frais. Vérifier/corriger les rabais dans la facturation (F).</span>
</div>
</template>
@ -1630,6 +1638,8 @@ watch(() => route.query?.location, () => focusLocationFromQuery())
.sub-total-label { font-size: 0.85rem; font-weight: 700; text-transform: uppercase; color: #334155; }
.sub-total-amount { font-size: 1rem; font-weight: 800; color: var(--ops-accent); min-width: 100px; text-align: right; }
.sub-total-line.annual { border-top: 2px solid #92400e; .sub-total-label { color: #92400e; } .sub-total-amount { color: #92400e; } }
.sub-total-neg { color: #dc2626 !important; }
.sub-total-warning { display: flex; align-items: center; justify-content: flex-end; gap: 4px; font-size: 0.78rem; color: #b91c1c; padding: 2px 0 6px; text-align: right; }
.sub-annual-divider { display: flex; align-items: center; gap: 6px; margin-top: 16px; padding: 8px 0 4px; border-top: 1px dashed #d97706; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #92400e; }
.sub-section-header { display: flex; align-items: center; gap: 4px; padding: 6px 0 4px; cursor: pointer; user-select: none; &:hover { background: #f8fafc; } }
.sub-section-title { font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: #334155; }

View File

@ -0,0 +1,13 @@
<template>
<q-page class="comm-page">
<MessageList />
</q-page>
</template>
<script setup>
import MessageList from 'src/components/shared/MessageList.vue'
</script>
<style scoped>
.comm-page { padding: 0; }
</style>

View File

@ -0,0 +1,39 @@
<template>
<q-page class="cfp-page">
<div class="cfp-bar">
<q-btn flat dense icon="arrow_back" label="Boîte" no-caps color="indigo-7" @click="$router.push('/communications')" />
</div>
<ConversationPanel inline />
</q-page>
</template>
<script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import { useConversations } from 'src/composables/useConversations'
const route = useRoute()
const router = useRouter()
const { discussions, activeDiscussion, fetchList, openDiscussion, openConversation } = useConversations()
async function openByToken (token) {
if (!token) return
const find = () => (discussions.value || []).find(d => d.token === token || (d.conversations || []).some(c => c.token === token))
let disc = find()
if (!disc) { await fetchList(); disc = find() }
if (disc) { openDiscussion(disc); return } // ouvre le fil (active la vue thread plein écran)
await openConversation(token) // repli : ouverture directe par jeton (pose aussi activeDiscussion)
if (!activeDiscussion.value) router.push('/communications') // introuvable/supprimée retour à la boîte, pas de loader infini
}
onMounted(() => openByToken(route.params.token))
watch(() => route.params.token, t => openByToken(t))
</script>
<style scoped>
/* Plein écran : la page occupe la hauteur du viewport (moins l'en-tête) → le fil défile à l'intérieur, le bloc de réponse reste ancré en bas */
.cfp-page { padding: 0; height: calc(100vh - 50px); display: flex; flex-direction: column; overflow: hidden; }
.cfp-bar { padding: 4px 8px; border-bottom: 1px solid var(--ops-border, #e2e8f0); background: #fff; flex: 0 0 auto; }
.cfp-page :deep(.conv-fullpage) { flex: 1 1 auto; min-height: 0; }
</style>

View File

@ -0,0 +1,316 @@
<template>
<q-page class="q-pa-md" style="background:#f7f8fa">
<div class="row items-center q-mb-md">
<div>
<div class="text-h6 text-weight-bold">Sync F ERPNext <span class="text-grey-6 text-subtitle2"> tableau de bord</span></div>
<div class="text-caption text-grey-7">Moteurs modulaires (un par domaine), orchestration + vue unifiées. F autoritaire ; ERPNext reflète F.</div>
</div>
<q-space />
<q-btn outline color="grey-7" icon="confirmation_number" label="Rafraîchir tickets" :disable="syncing" no-caps class="q-mr-sm" @click="startSync('tickets')"><q-tooltip>Tirer les tickets legacy Dispatch (lourd ~1 min ; sinon auto aux 15 min + sync d'assignation live en Planification)</q-tooltip></q-btn>
<q-btn unelevated color="teal-7" icon="sync" label="Synchroniser maintenant" :loading="syncing" no-caps class="q-mr-sm" @click="startSync('core')"><q-tooltip>Comptes + paiements + soldes, tout de suite (~30 s). Factures/services : cron horaire.</q-tooltip></q-btn>
<q-btn flat color="primary" icon="refresh" label="Recalculer" :loading="loading || sLoading" @click="reload" no-caps />
</div>
<!-- Progression de la synchronisation manuelle -->
<q-card v-if="syncRun" flat bordered class="q-mb-md" :class="syncRun.running ? 'bg-blue-1' : 'bg-grey-2'">
<q-card-section class="q-py-sm">
<div class="row items-center q-mb-xs">
<q-icon :name="syncRun.running ? 'sync' : 'check_circle'" :color="syncRun.running ? 'blue-7' : 'green-7'" :class="{ spin: syncRun.running }" class="q-mr-sm" />
<span class="text-weight-medium">{{ syncRun.running ? 'Synchronisation en cours…' : 'Synchronisation terminée' }}</span>
<span class="text-caption text-grey-7 q-ml-sm">{{ syncRun.elapsed_s }} s · {{ syncRun.done }}/{{ syncRun.total }}</span>
<q-space />
<q-btn v-if="!syncRun.running" flat dense round size="sm" icon="close" @click="syncRun = null" />
</div>
<q-linear-progress :value="syncRun.total ? syncRun.done / syncRun.total : 0" :indeterminate="syncRun.running && !syncRun.done" color="teal" rounded size="7px" class="q-mb-sm" />
<div class="row q-col-gutter-md">
<div v-for="s in syncRun.steps" :key="s.key" class="col-auto row items-center">
<q-icon :name="stepIcon(s.status)" :color="stepColor(s.status)" size="16px" :class="{ spin: s.status === 'running' }" class="q-mr-xs" />
<span class="text-caption">{{ s.label }}<span v-if="s.detail" class="text-grey-7"> {{ s.detail }}</span></span>
</div>
</div>
<div v-if="syncRun.note" class="text-caption text-grey-6 q-mt-xs"><q-icon name="info" size="13px" /> {{ syncRun.note }}</div>
</q-card-section>
</q-card>
<!-- BANDE UNIFIÉE : tous les domaines en ordre de dépendance (DAG) -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="row items-center q-py-sm">
<div class="text-weight-bold">État par domaine</div>
<span class="text-caption text-grey-6 q-ml-sm"> ordre d'exécution = dépendances (un module ne tourne jamais avant ses prérequis)</span>
<q-space />
<template v-if="health.healthy != null">
<q-badge v-if="health.healthy" color="green-2" text-color="green-9"><q-icon name="check_circle" size="13px" class="q-mr-xs" />facturation à jour</q-badge>
<q-badge v-else color="orange-2" text-color="orange-9"><q-icon name="warning" size="13px" class="q-mr-xs" />facturation à vérifier</q-badge>
<span v-if="health.last_cycle_min_ago != null" class="text-caption text-grey-6 q-ml-sm">dernier cycle il y a {{ health.last_cycle_min_ago }} min</span>
</template>
</q-card-section>
<q-separator />
<div class="row q-pa-sm items-stretch">
<div class="col-6 col-md-2 q-pa-xs" v-for="d in domains" :key="d.key">
<q-card flat bordered class="dcard fit">
<div class="row items-center no-wrap q-mb-xs">
<q-badge rounded color="indigo-1" text-color="indigo-9" class="q-mr-xs">{{ d.order }}</q-badge>
<span class="text-weight-medium">{{ d.label }}</span>
<q-space />
<q-icon :name="d.icon" size="15px" :color="d.iconColor" />
</div>
<div class="num" :class="d.colorClass">{{ d.value }}</div>
<div class="lbl">{{ d.metric }}</div>
<div class="text-caption text-grey-6 ellipsis" :title="d.sub">{{ d.sub }}</div>
</q-card>
</div>
</div>
<q-card-section class="text-caption text-grey-7 q-pt-none">
<q-icon name="account_tree" size="14px" /> clients adresses · services · tickets factures paiements.
<code>disabled</code>/<code>is_suspended</code> = dérivé des services (jamais synchronisé) hors « à réviser ».
</q-card-section>
</q-card>
<q-banner dense rounded class="bg-green-1 text-green-9 q-mb-md">
<template #avatar><q-icon name="lock" color="green-7" /></template>
<b>Lecture seule.</b> Politique : <b>ADD</b> = remplir un champ vide (sûr) · <b>CHANGE</b> = valeur divergente (révision) ·
<b>NEVER-CLOBBER</b> = F vide jamais écrasé · statut client (<code>disabled</code>) = dérivé, jamais synchronisé.
</q-banner>
<div class="text-subtitle2 text-weight-bold text-grey-8 q-mb-sm"><q-icon name="group" size="18px" /> Détail clients <span class="text-caption text-grey-6"> aperçu legacy-sync</span></div>
<div v-if="loading" class="column items-center q-pa-xl text-grey-6">
<q-spinner-dots size="40px" color="primary" /><div class="q-mt-sm">Calcul de l'aperçu clients (~10 s)</div>
</div>
<div v-else-if="err" class="text-negative q-pa-md">{{ err }}</div>
<template v-else-if="rep">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-blue-8">{{ cs.would_create }}</div><div class="lbl">Comptes à créer</div></q-card></div>
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-green-8">{{ cs.safe_add }}</div><div class="lbl">À enrichir (sûr)</div></q-card></div>
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-orange-8">{{ cs.needs_review }}</div><div class="lbl">À réviser (actionnable)</div></q-card></div>
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-grey-7">{{ cs.unchanged }}</div><div class="lbl">Inchangés</div></q-card></div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-card flat bordered>
<q-card-section class="text-weight-bold bg-green-1 text-green-9">Enrichissements sûrs (ADD) {{ cs.safe_add }} clients</q-card-section>
<q-list dense separator>
<q-item v-for="(n,f) in rep.customers.add_by_field" :key="f"><q-item-section>{{ f }}</q-item-section><q-item-section side class="text-weight-bold">{{ n }}</q-item-section></q-item>
<q-item v-if="!Object.keys(rep.customers.add_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
<q-card-section class="text-caption text-grey-7">Remplit des champs ERPNext vides (email/tél). Réversible. <code>applySafeAdds(confirm:'SAFE-ADD')</code> non exécuté.</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4">
<q-card flat bordered>
<q-card-section class="text-weight-bold bg-orange-1 text-orange-9 row items-center q-py-sm">
<span>À réviser (CHANGE) {{ cs.needs_review }}</span>
<q-space />
<q-btn dense unelevated color="orange-8" size="sm" no-caps icon="done_all" label="Tout appliquer" :loading="batchApplying === 'tous'" :disable="!!batchApplying || !cs.needs_review" @click="confirmApplyAll" />
</q-card-section>
<q-list dense separator>
<q-item v-for="(n,f) in rep.customers.review_by_field" :key="f">
<q-item-section>{{ f }}</q-item-section>
<q-item-section side>
<div class="row items-center no-wrap q-gutter-xs">
<q-badge color="orange-2" text-color="orange-9">{{ n }}</q-badge>
<q-btn dense flat size="sm" icon="visibility" color="grey-7" @click="showDetail(f)"><q-tooltip>Voir le détail</q-tooltip></q-btn>
<q-btn dense flat color="teal-7" size="sm" no-caps label="Appliquer" :loading="batchApplying === f" :disable="!!batchApplying || f === 'customer_name'" @click="applyBatch(f)"><q-tooltip v-if="f === 'customer_name'">Nom protégé jamais écrasé</q-tooltip></q-btn>
</div>
</q-item-section>
</q-item>
<q-item v-if="!Object.keys(rep.customers.review_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
<q-card-section v-if="reviewDetail" class="bg-grey-1 q-py-sm">
<div class="row items-center q-mb-xs"><span class="text-caption text-weight-medium">{{ reviewDetail.field }} {{ reviewDetail.total }} changement(s)</span><q-space /><q-btn flat dense round size="xs" icon="close" @click="reviewDetail = null" /></div>
<div v-for="(it,i) in reviewDetail.items" :key="i" class="text-caption ellipsis">{{ it.customer_name }} <span class="text-grey-6">#{{ it.legacy_account_id }}</span> : <span v-for="d in it.change" :key="d.field"><s>{{ d.from }}</s> <b>{{ d.to }}</b></span></div>
<div v-if="reviewDetail.total > reviewDetail.items.length" class="text-caption text-grey-6"> +{{ reviewDetail.total - reviewDetail.items.length }} autres</div>
</q-card-section>
<q-card-section class="text-caption text-grey-7">F gagne, sur décision. Le nom n'est jamais écrasé. Applique par champ ou tout d'un coup.</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4">
<q-card flat bordered class="q-mb-md">
<q-card-section class="text-weight-bold bg-blue-grey-1 text-blue-grey-9">Statut dérivé (non-actionnable)</q-card-section>
<q-list dense separator>
<q-item><q-item-section>F actif / ERPNext désactivé</q-item-section><q-item-section side class="text-weight-bold">{{ sd.f_active_erp_disabled }}</q-item-section></q-item>
<q-item><q-item-section>F inactif / ERPNext actif</q-item-section><q-item-section side class="text-weight-bold">{{ sd.f_inactive_erp_enabled }}</q-item-section></q-item>
</q-list>
<q-card-section class="text-caption text-grey-7">Statut dérivé des abonnements <b>jamais synchronisé</b>. Un client hors zone reste vendable (cross-sell), pas « churn ».</q-card-section>
</q-card>
<q-card flat bordered>
<q-card-section class="text-weight-bold">Never-clobber (protégés)</q-card-section>
<q-list dense separator>
<q-item v-for="(n,f) in rep.customers.never_clobber_by_field" :key="f"><q-item-section>{{ f }}</q-item-section><q-item-section side class="text-weight-bold">{{ n }}</q-item-section></q-item>
<q-item v-if="!Object.keys(rep.customers.never_clobber_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="text-weight-bold">Nouveaux comptes (échantillon)</q-card-section>
<q-markup-table flat dense>
<thead><tr><th class="text-left">legacy_account_id</th><th class="text-left">Nom</th><th class="text-left">Statut F</th></tr></thead>
<tbody><tr v-for="c in rep.customers.creates_sample" :key="c.legacy_account_id"><td>{{ c.legacy_account_id }}</td><td>{{ c.customer_name }}</td><td>{{ c.status }}</td></tr></tbody>
</q-markup-table>
</q-card>
</div>
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="text-weight-bold">À réviser (échantillon)</q-card-section>
<q-list dense separator>
<q-item v-for="r in rep.customers.needs_review_sample" :key="r.legacy_account_id">
<q-item-section>
<q-item-label>{{ r.customer_name }} <span class="text-grey-6">#{{ r.legacy_account_id }}</span></q-item-label>
<q-item-label caption><span v-for="d in r.change" :key="d.field" class="chg">{{ d.field }}: <s>{{ d.from }}</s> <b>{{ d.to }}</b></span></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn dense flat color="teal-7" icon="check" label="Appliquer" size="sm" no-caps :loading="applyingId === r.legacy_account_id" @click="applyChange(r)">
<q-tooltip>Accepter la valeur de F (écrit dans ERPNext). Réversible. Le nom n'est jamais écrasé.</q-tooltip>
</q-btn>
</q-item-section>
</q-item>
<q-item v-if="!(rep.customers.needs_review_sample||[]).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
</q-card>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-md">Aperçu clients calculé {{ rep.started_at }} · F {{ cs.f_total }} comptes / ERPNext {{ cs.erp_total }}.</div>
</template>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const loading = ref(false)
const err = ref('')
const rep = ref(null)
const cs = computed(() => (rep.value && rep.value.customers.summary) || {})
const sd = computed(() => (rep.value && rep.value.customers.status_divergence) || {})
// Bande unifiée (rapide, /sync/status)
const sLoading = ref(false)
const status = ref(null)
const health = computed(() => (status.value && status.value.billing_health) || {})
const fmtN = (v) => v == null ? '—' : Number(v).toLocaleString('fr-CA', { maximumFractionDigits: 0 })
const fmtCur = (v) => v == null ? '—' : '$' + Number(v).toLocaleString('fr-CA', { maximumFractionDigits: 0 })
const lagCls = (l) => (l == null ? 'text-grey-6' : l > 0 ? 'text-orange-8' : 'text-green-8')
const createCls = (n) => (n == null ? 'text-grey-6' : n > 0 ? 'text-blue-8' : 'text-green-8')
const domains = computed(() => {
const m = (status.value && status.value.modules) || {}
const okIcon = (cond) => cond ? 'check_circle' : 'pending'
return [
{ key: 'clients', label: 'Clients', order: 1, value: fmtN(m.clients?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.clients?.erp)}`, colorClass: createCls(m.clients?.to_create), icon: okIcon(!m.clients?.to_create), iconColor: m.clients?.to_create ? 'blue-5' : 'green-5' },
{ key: 'adresses', label: 'Adresses', order: 2, value: fmtN(m.adresses?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.adresses?.erp)}`, colorClass: createCls(m.adresses?.to_create), icon: okIcon(!m.adresses?.to_create), iconColor: m.adresses?.to_create ? 'blue-5' : 'green-5' },
{ key: 'services', label: 'Services', order: 2, value: fmtN(m.services?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.services?.erp)}`, colorClass: createCls(m.services?.to_create), icon: okIcon(!m.services?.to_create), iconColor: m.services?.to_create ? 'blue-5' : 'green-5' },
{ key: 'factures', label: 'Factures', order: 3, value: fmtN(m.factures?.lag), metric: 'en retard', sub: `${fmtN(m.factures?.synced)} vivantes · ${fmtCur(m.factures?.ar_open)}`, colorClass: lagCls(m.factures?.lag), icon: okIcon(!m.factures?.lag), iconColor: m.factures?.lag ? 'orange-5' : 'green-5' },
{ key: 'paiements', label: 'Paiements', order: 4, value: fmtN(m.paiements?.lag), metric: 'en retard', sub: `${fmtN(m.paiements?.total)} au total`, colorClass: lagCls(m.paiements?.lag), icon: okIcon(!m.paiements?.lag), iconColor: m.paiements?.lag ? 'orange-5' : 'green-5' },
{ key: 'tickets', label: 'Tickets', order: 2, value: '·', metric: 'moteur séparé', sub: 'osTicket → Dispatch', colorClass: 'text-grey-6', icon: 'open_in_new', iconColor: 'grey-5' },
]
})
async function loadStatus () {
sLoading.value = true
try { const r = await fetch(`${HUB_URL}/sync/status`); if (r.ok) status.value = await r.json() } catch {}
sLoading.value = false
}
async function load () {
loading.value = true; err.value = ''
try {
const res = await fetch(`${HUB_URL}/legacy-sync/preview`)
if (!res.ok) throw new Error('Échec du calcul (' + res.status + ')')
rep.value = await res.json()
} catch (e) { err.value = e.message }
loading.value = false
}
function reload () { loadStatus(); load() }
// Synchronisation manuelle + progression
const syncing = ref(false)
const syncRun = ref(null)
let _pollTimer = null
const stepIcon = (s) => ({ pending: 'schedule', running: 'sync', done: 'check_circle', error: 'error' }[s] || 'schedule')
const stepColor = (s) => ({ pending: 'grey-5', running: 'blue-6', done: 'green-6', error: 'red-6' }[s] || 'grey-5')
async function startSync (scope) {
if (syncing.value) return
syncing.value = true; syncRun.value = null
try {
const r = await fetch(`${HUB_URL}/sync/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'F-WINS', scope }) })
const d = await r.json()
if (d.already_running) { $q.notify({ type: 'warning', message: 'Une synchronisation est déjà en cours' }); poll(); return }
if (!d.started) { $q.notify({ type: 'negative', message: d.error || 'Échec' }); syncing.value = false; return }
poll()
} catch (e) { $q.notify({ type: 'negative', message: e.message }); syncing.value = false }
}
function poll () {
clearTimeout(_pollTimer)
fetch(`${HUB_URL}/sync/run-status`).then(r => r.json()).then(d => {
syncRun.value = d
if (d.running) { _pollTimer = setTimeout(poll, 1500) }
else { syncing.value = false; const ok = (d.steps || []).filter(s => s.status === 'error').length === 0; $q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Synchronisation terminée' : 'Terminée avec des erreurs' }); reload() }
}).catch(() => { syncing.value = false })
}
// Appliquer un changement révisé (accepter F) par compte
const applyingId = ref(null)
async function applyChange (r) {
applyingId.value = r.legacy_account_id
try {
const res = await fetch(`${HUB_URL}/legacy-sync/apply-change`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ legacy_account_id: r.legacy_account_id, confirm: 'F-WINS' }) })
const d = await res.json()
if (d.applied) {
$q.notify({ type: 'positive', message: `Appliqué à ${r.customer_name} : ${Object.keys(d.patch).join(', ')}` })
rep.value.customers.needs_review_sample = rep.value.customers.needs_review_sample.filter(x => x.legacy_account_id !== r.legacy_account_id)
loadStatus()
} else $q.notify({ type: 'warning', message: d.error || d.note || 'Rien à appliquer' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { applyingId.value = null }
}
// Appliquer en LOT (par champ ou tout) + détail
const batchApplying = ref(null)
const reviewDetail = ref(null)
async function applyBatch (field) {
const label = field || 'tous'
if (batchApplying.value) return
batchApplying.value = label
try {
const r = await fetch(`${HUB_URL}/legacy-sync/apply-review-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field, confirm: 'F-WINS' }) })
const d = await r.json()
if (d.applied != null) { $q.notify({ type: 'positive', message: `${d.applied} changement(s) appliqué(s)${field ? ' · ' + field : ''}` }); reviewDetail.value = null; load() }
else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchApplying.value = null }
}
async function showDetail (field) {
if (reviewDetail.value && reviewDetail.value.field === field) { reviewDetail.value = null; return }
try {
const r = await fetch(`${HUB_URL}/legacy-sync/apply-review-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field }) }) // dry-run
const d = await r.json()
reviewDetail.value = { field, items: d.sample || [], total: d.would_apply || 0 }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
function confirmApplyAll () {
const bf = rep.value.customers.review_by_field || {}
const detail = Object.entries(bf).filter(([f]) => f !== 'customer_name').map(([f, n]) => `${f}: ${n}`).join(' · ')
$q.dialog({ title: 'Tout appliquer (F gagne)', message: `Accepter F sur ${cs.value.needs_review} client(s) ?<br><span class="text-caption">${detail}</span><br><br>Le nom est protégé. Réversible.`, html: true, cancel: true, ok: { label: 'Appliquer', color: 'orange-8' } }).onOk(() => applyBatch(null))
}
onBeforeUnmount(() => clearTimeout(_pollTimer))
onMounted(reload)
</script>
<style scoped>
.stat { padding: 12px; text-align: center; }
.stat .num { font-size: 1.7rem; font-weight: 700; line-height: 1; }
.stat .lbl { font-size: 0.72rem; color: #64748b; margin-top: 4px; }
.dcard { padding: 10px; }
.dcard .num { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
.dcard .lbl { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: .02em; }
.chg { display: inline-block; margin-right: 10px; }
code { background: #eef2ff; padding: 1px 4px; border-radius: 3px; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -0,0 +1,13 @@
<template>
<q-page class="pipe-page">
<PipelineBoard />
</q-page>
</template>
<script setup>
import PipelineBoard from 'src/components/shared/PipelineBoard.vue'
</script>
<style scoped>
.pipe-page { padding: 0; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,20 @@ const financeReports = [
color: 'deep-orange-6',
route: '/rapports/internet-cher',
},
{
title: 'Factures récurrentes négatives',
description: 'Comptes dont le total récurrent mensuel est négatif (rabais > frais) — anomalies de données à corriger. F reste autoritaire.',
icon: 'error',
color: 'red-7',
route: '/rapports/factures-negatives',
},
{
title: 'Services actifs sur comptes résiliés',
description: 'Comptes terminés dans F qui gardent des services récurrents actifs (charges + crédits). Liste de ménage — désactiver au niveau service (sauf employés à internet fourni).',
icon: 'cleaning_services',
color: 'blue-7',
route: '/rapports/resilies-actifs',
},
]
const opsReports = [

View File

@ -0,0 +1,171 @@
<template>
<q-page padding>
<div class="row items-center q-mb-sm">
<q-btn flat dense round icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Factures récurrentes négatives</div>
<q-space />
<q-btn outline color="primary" icon="refresh" label="Recalculer" no-caps :loading="loading" @click="load(true)" />
<q-btn flat color="grey-7" icon="download" label="CSV" no-caps class="q-ml-sm" :disable="!rows.length" @click="exportCsv" />
</div>
<q-banner dense class="bg-amber-1 text-grey-9 q-mb-md" rounded>
<template #avatar><q-icon name="info" color="amber-8" /></template>
Un abonnement récurrent ne peut pas être négatif. La facturation <b>F (gestionclient) est autoritaire et correcte</b> :
ces écarts sont des <b>anomalies de données côté ERPNext/OPS</b> (rabais ajoutés manuellement ou artefacts d'import) à corriger.
</q-banner>
<div v-if="loading && !rows.length" class="column items-center q-pa-xl text-grey-6">
<q-spinner size="32px" color="primary" />
<div class="q-mt-sm">Calcul en cours scan des abonnements actifs</div>
</div>
<div v-else-if="error" class="text-red-7 q-pa-md"><q-icon name="error" /> {{ error }}</div>
<template v-else>
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-auto">
<div style="background:var(--ops-surface);border:1px solid var(--ops-border);border-radius:10px;padding:10px 16px;min-width:140px">
<div style="font-size:12px;color:var(--ops-text-muted)">Comptes négatifs</div>
<div style="font-size:24px;font-weight:600;color:var(--ops-danger)">{{ rows.length }}</div>
</div>
</div>
<div class="col-6 col-sm-auto">
<div style="background:var(--ops-surface);border:1px solid var(--ops-border);border-radius:10px;padding:10px 16px;min-width:140px">
<div style="font-size:12px;color:var(--ops-text-muted)">Abonnements scannés</div>
<div style="font-size:24px;font-weight:600;color:var(--ops-text)">{{ scanned.toLocaleString('fr-CA') }}</div>
</div>
</div>
</div>
<div class="text-caption text-grey-7 q-mb-sm">
<b>{{ rows.length }}</b> compte{{ rows.length > 1 ? 's' : '' }} avec total récurrent négatif
<span v-if="generated"> · généré {{ fmtTime(generated) }}</span>
</div>
<q-table
:rows="rows" :columns="columns" row-key="key" flat bordered :grid="$q.screen.lt.md"
:pagination="{ rowsPerPage: 25, sortBy: 'total', descending: false }"
:rows-per-page-options="[25, 50, 100, 0]" dense>
<template #item="props">
<div class="col-12 q-pa-xs">
<q-card flat bordered class="q-pa-sm">
<div class="row items-center no-wrap q-mb-xs">
<router-link :to="'/clients/' + props.row.customer" class="text-primary text-weight-medium" style="text-decoration:none">{{ props.row.customer_name }}</router-link>
<q-space />
<q-badge :color="statusColor(props.row.account_status_label)" :label="props.row.account_status_label || '—'" />
</div>
<div class="text-caption text-grey-6 q-mb-xs">{{ props.row.service_location }}</div>
<div class="row justify-between text-caption"><span class="text-grey-7">Frais</span><span class="text-weight-medium">{{ money(props.row.charges) }}</span></div>
<div class="row justify-between text-caption"><span class="text-grey-7">Rabais</span><span class="text-red-7 text-weight-medium">{{ money(props.row.rebate) }}</span></div>
<div class="row justify-between text-caption"><span class="text-grey-7">Total adresse</span><span class="text-red-7 text-weight-bold">{{ money(props.row.total) }}</span></div>
</q-card>
</div>
</template>
<template #body-cell-customer="props">
<q-td :props="props">
<router-link :to="'/clients/' + props.row.customer" class="text-primary text-weight-medium" style="text-decoration:none">
{{ props.row.customer_name }}
</router-link>
<div class="text-caption text-grey-6">{{ props.row.customer }} · {{ props.row.service_location }}</div>
</q-td>
</template>
<template #body-cell-client="props">
<q-td :props="props">
<q-badge :color="statusColor(props.row.account_status_label)" :label="props.row.account_status_label || '—'">
<q-tooltip>Statut du compte GLOBAL dans F (legacy) l'autorité. Résilié = compte terminé (rabais résiduel = ménage).</q-tooltip>
</q-badge>
</q-td>
</template>
<template #body-cell-total="props">
<q-td :props="props" class="text-weight-bold text-red-7">{{ money(props.row.total) }}</q-td>
</template>
<template #body-cell-customer_total="props">
<q-td :props="props" :class="props.row.customer_total < 0 ? 'text-red-7 text-weight-bold' : 'text-green-7'">{{ money(props.row.customer_total) }}</q-td>
</template>
<template #body-cell-cause="props">
<q-td :props="props">
<q-badge v-if="props.row.cause === 'artifact'" color="blue-grey-5" label="Artefact d'adresse (compte OK)"><q-tooltip>Rabais et frais sur des adresses différentes le compte n'est pas négatif.</q-tooltip></q-badge>
<q-badge v-else-if="props.row.cause === 'manual'" color="red-7" :label="`Ajout OPS (hors F) ×${props.row.manualRebates}`" />
<template v-else-if="props.row.cause === 'suspended_charge'">
<q-badge color="deep-orange-8" label="Charge suspendue dans ERPNext"><q-tooltip>Une charge active dans F est Suspendue/Annulée dans ERPNext le rabais reste seul. Désync de statut.</q-tooltip></q-badge>
<div v-for="(c, i) in props.row.inactiveCharges" :key="i" class="text-caption text-orange-9">
{{ c.status }} : {{ c.plan }} {{ money(c.price) }}<span v-if="c.legacy"> · F #{{ c.legacy }}</span>
</div>
</template>
<q-badge v-else-if="props.row.cause === 'expired'" color="orange-8" label="Fin de service non respectée" />
<q-badge v-else color="deep-orange-7" label="Rabais F empilés (vérifier vs F)" />
</q-td>
</template>
<template #body-cell-rebates="props">
<q-td :props="props">
<div v-for="(r, i) in props.row.rebates" :key="i" class="text-caption">
<span class="text-red-7">{{ money(r.price) }}</span> · {{ r.plan || r.sku }}
<q-badge :color="r.legacy > 0 ? 'green-2' : 'red-3'" :text-color="r.legacy > 0 ? 'green-9' : 'red-9'" :label="r.source" class="q-ml-xs" />
<span v-if="r.end_date" class="text-orange-8"> · fin {{ r.end_date }}</span>
</div>
</q-td>
</template>
</q-table>
</template>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { formatMoney as money, formatDateTimeShort as fmtTime } from 'src/composables/useFormatters'
const $q = useQuasar()
const rows = ref([])
const scanned = ref(0)
const generated = ref(0)
const loading = ref(false)
const error = ref('')
let pollTimer = null
const columns = [
{ name: 'customer', label: 'Client / adresse', field: 'customer_name', align: 'left', sortable: true },
{ name: 'client', label: 'Client', field: 'customer_active', align: 'center', sortable: true },
{ name: 'charges', label: 'Frais', field: 'charges', align: 'right', sortable: true, format: v => money(v) },
{ name: 'rebate', label: 'Rabais', field: 'rebate', align: 'right', sortable: true, format: v => money(v) },
{ name: 'total', label: 'Total adresse', field: 'total', align: 'right', sortable: true },
{ name: 'customer_total', label: 'Total compte', field: 'customer_total', align: 'right', sortable: true },
{ name: 'cause', label: 'Cause probable', field: 'manualRebates', align: 'left' },
{ name: 'rebates', label: 'Détail rabais (actifs)', field: 'rebates', align: 'left' },
]
// money = formatMoney, fmtTime = formatDateTimeShort (useFormatters, consolidation)
function statusColor (label) { return label === 'Actif' ? 'green-6' : label === 'Suspendu' ? 'orange-7' : label === 'Résilié' ? 'red-5' : 'grey-5' }
async function load (refresh) {
loading.value = true; error.value = ''
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
try {
const r = await fetch(`${HUB_URL}/collab/negative-billing${refresh ? '?refresh=1' : ''}`)
const d = await r.json()
if (d.ready) {
rows.value = (d.rows || []).map(x => ({ ...x, key: x.customer + '|' + x.service_location }))
scanned.value = d.scanned || 0; generated.value = d.generated || 0; loading.value = false
} else if (d.error) {
error.value = d.error; loading.value = false
} else {
// calcul en arrière-plan on re-sonde
pollTimer = setTimeout(() => load(false), 3000)
}
} catch (e) { error.value = e.message; loading.value = false }
}
function exportCsv () {
const head = ['Client', 'ID', 'Statut compte F', 'Adresse', 'Frais', 'Rabais', 'Total', 'Total compte', 'Rabais détail']
const lines = rows.value.map(r => [
r.customer_name, r.customer, r.account_status_label || (r.customer_active ? 'Actif' : 'Inactif'), r.service_location, r.charges, r.rebate, r.total, r.customer_total,
r.rebates.map(x => `${x.plan || x.sku} (${x.price})`).join(' | '),
].map(c => `"${String(c == null ? '' : c).replace(/"/g, '""')}"`).join(','))
const csv = [head.join(','), ...lines].join('\n')
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'factures-negatives.csv'; a.click()
$q.notify({ type: 'positive', message: `${rows.value.length} ligne(s) exportée(s)` })
}
onMounted(() => load(false))
</script>

View File

@ -0,0 +1,130 @@
<template>
<q-page padding>
<div class="row items-center q-mb-sm">
<q-btn flat dense round icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Services actifs sur comptes résiliés</div>
<q-space />
<q-btn outline color="primary" icon="refresh" label="Recalculer" no-caps :loading="loading" @click="load(true)" />
<q-btn flat color="grey-7" icon="download" label="CSV" no-caps class="q-ml-sm" :disable="!rows.length" @click="exportCsv" />
</div>
<q-banner dense class="bg-blue-1 text-grey-9 q-mb-md" rounded>
<template #avatar><q-icon name="cleaning_services" color="blue-8" /></template>
Comptes <b>résiliés dans F</b> (status 3/4/5) qui gardent des <b>services récurrents actifs</b> (charges <i>et</i> crédits)
F ne les désactive pas à la résiliation. Liste de ménage : désactiver les services au besoin. Les <b>employés</b> (internet fourni) sont des exceptions à conserver.
</q-banner>
<div v-if="loading && !rows.length" class="column items-center q-pa-xl text-grey-6">
<q-spinner size="32px" color="primary" /><div class="q-mt-sm">Calcul en cours comptes résiliés × services actifs</div>
</div>
<div v-else-if="error" class="text-red-7 q-pa-md"><q-icon name="error" /> {{ error }}</div>
<template v-else>
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-auto">
<div style="background:var(--ops-surface);border:1px solid var(--ops-border);border-radius:10px;padding:10px 16px;min-width:140px">
<div style="font-size:12px;color:var(--ops-text-muted)">Comptes résiliés</div>
<div style="font-size:24px;font-weight:600;color:var(--ops-text)">{{ count }}</div>
</div>
</div>
<div class="col-6 col-sm-auto">
<div style="background:var(--ops-surface);border:1px solid var(--ops-border);border-radius:10px;padding:10px 16px;min-width:140px">
<div style="font-size:12px;color:var(--ops-text-muted)">Services actifs</div>
<div style="font-size:24px;font-weight:600;color:var(--ops-warning)">{{ services }}</div>
</div>
</div>
</div>
<div class="text-caption text-grey-7 q-mb-sm">
<b>{{ count }}</b> compte{{ count > 1 ? 's' : '' }} résilié{{ count > 1 ? 's' : '' }} avec services actifs
<span v-if="generated"> · généré {{ fmtTime(generated) }}</span>
</div>
<q-table :rows="rows" :columns="columns" row-key="customer" flat bordered :grid="$q.screen.lt.md"
:pagination="{ rowsPerPage: 25, sortBy: 'active_services', descending: true }"
:rows-per-page-options="[25, 50, 100, 0]" dense>
<template #item="props">
<div class="col-12 q-pa-xs">
<q-card flat bordered class="q-pa-sm">
<div class="row items-center no-wrap q-mb-xs">
<router-link :to="'/clients/' + props.row.customer" class="text-primary text-weight-medium" style="text-decoration:none">{{ props.row.customer_name }}</router-link>
<q-space />
<q-badge color="red-5" label="Résilié" />
</div>
<div class="text-caption text-grey-6 q-mb-xs">compte F #{{ props.row.account_id }} · {{ props.row.active_services }} service(s)</div>
<div class="row justify-between text-caption"><span class="text-grey-7">Frais</span><span class="text-weight-medium">{{ money(props.row.charges) }}</span></div>
<div class="row justify-between text-caption"><span class="text-grey-7">Crédits</span><span class="text-red-7 text-weight-medium">{{ money(props.row.credits) }}</span></div>
<div class="row justify-between text-caption"><span class="text-grey-7">Total mensuel</span><span :class="props.row.total < 0 ? 'text-red-7 text-weight-bold' : 'text-weight-bold'">{{ money(props.row.total) }}</span></div>
</q-card>
</div>
</template>
<template #body-cell-customer="props">
<q-td :props="props">
<router-link :to="'/clients/' + props.row.customer" class="text-primary text-weight-medium" style="text-decoration:none">{{ props.row.customer_name }}</router-link>
<q-badge color="red-5" label="Résilié" class="q-ml-xs" />
<div class="text-caption text-grey-6">{{ props.row.customer }} · compte F #{{ props.row.account_id }}</div>
</q-td>
</template>
<template #body-cell-total="props">
<q-td :props="props" class="text-weight-bold" :class="props.row.total < 0 ? 'text-red-7' : 'text-grey-9'">{{ money(props.row.total) }}</q-td>
</template>
<template #body-cell-services="props">
<q-td :props="props">
<div v-for="(s, i) in props.row.services" :key="i" class="text-caption">
<span :class="s.price < 0 ? 'text-red-7' : 'text-grey-8'">{{ money(s.price) }}</span> · {{ s.plan || s.sku }}
<span class="text-grey-5" v-if="s.legacy"> · F #{{ s.legacy }}</span>
</div>
</q-td>
</template>
</q-table>
</template>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { formatMoney as money, formatDateTimeShort as fmtTime } from 'src/composables/useFormatters'
const $q = useQuasar()
const rows = ref([]); const count = ref(0); const services = ref(0); const generated = ref(0)
const loading = ref(false); const error = ref('')
let pollTimer = null
const columns = [
{ name: 'customer', label: 'Client (résilié)', field: 'customer_name', align: 'left', sortable: true },
{ name: 'active_services', label: 'Services actifs', field: 'active_services', align: 'center', sortable: true },
{ name: 'charges', label: 'Frais', field: 'charges', align: 'right', sortable: true, format: v => money(v) },
{ name: 'credits', label: 'Crédits', field: 'credits', align: 'right', sortable: true, format: v => money(v) },
{ name: 'total', label: 'Total mensuel', field: 'total', align: 'right', sortable: true },
{ name: 'services', label: 'Détail des services actifs', field: 'services', align: 'left' },
]
// money = formatMoney, fmtTime = formatDateTimeShort (useFormatters, consolidation)
async function load (refresh) {
loading.value = true; error.value = ''
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
try {
const r = await fetch(`${HUB_URL}/collab/terminated-active${refresh ? '?refresh=1' : ''}`)
const d = await r.json()
if (d.ready) { rows.value = d.rows || []; count.value = d.count || 0; services.value = d.services || 0; generated.value = d.generated || 0; loading.value = false }
else if (d.error) { error.value = d.error; loading.value = false }
else { pollTimer = setTimeout(() => load(false), 3000) }
} catch (e) { error.value = e.message; loading.value = false }
}
function exportCsv () {
const head = ['Client', 'ID', 'Compte F', 'Services actifs', 'Frais', 'Crédits', 'Total', 'Détail']
const lines = rows.value.map(r => [
r.customer_name, r.customer, r.account_id, r.active_services, r.charges, r.credits, r.total,
r.services.map(s => `${s.plan || s.sku} (${s.price}${s.legacy ? ' F#' + s.legacy : ''})`).join(' | '),
].map(c => `"${String(c == null ? '' : c).replace(/"/g, '""')}"`).join(','))
const csv = [head.join(','), ...lines].join('\n')
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'comptes-resilies-services-actifs.csv'; a.click()
$q.notify({ type: 'positive', message: `${rows.value.length} ligne(s) exportée(s)` })
}
onMounted(() => load(false))
</script>

View File

@ -380,15 +380,14 @@
<q-spinner size="14px" class="q-mr-xs" /> Chargement...
</div>
<div v-else>
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs"
style="border-bottom:1px solid #f1f5f9">
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs q-px-sm member-row">
<q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem">
{{ initial(m) }}
</q-avatar>
<span class="text-body2">{{ m.name || m.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space />
<q-btn flat round dense icon="person_remove" size="sm" color="red-4"
<q-btn flat round dense icon="person_remove" size="sm" color="red-5" class="member-row-del"
@click="removeFromGroup(m, selectedGroup)" title="Retirer du groupe">
<q-tooltip>Retirer du groupe</q-tooltip>
</q-btn>
@ -730,6 +729,15 @@
</q-expansion-item>
</div>
</template>
<!-- Modèles IA routage par tâche + repli Gemini -->
<AiRoutingCard v-if="can('manage_settings')" class="q-mt-md" />
<!-- Files (Inbox) & équipes gestion des membres (même logique que les groupes de droits) -->
<QueueTeamsCard class="q-mt-md" />
<!-- Réponses pré-écrites (canned) réutilisables dans l'Inbox -->
<CannedResponsesCard class="q-mt-md" />
</q-page>
</template>
@ -743,6 +751,9 @@ import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/compos
import { usePermissions } from 'src/composables/usePermissions'
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
import { useUserGroups } from 'src/composables/useUserGroups'
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue'
import AiRoutingCard from 'src/components/settings/AiRoutingCard.vue'
import { useLegacySync } from 'src/composables/useLegacySync'
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue'))
@ -978,4 +989,9 @@ async function testSms () {
}
.member-search-item:last-child { border-bottom: none; }
.member-search-item:hover { background: #f1f5f9; }
/* Surlignage de la ligne membre au survol → on vise sans erreur le bon bouton « Retirer » dans une longue liste */
.member-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
.member-row:hover { background: #eff3ff; box-shadow: inset 3px 0 0 #6366f1; }
.member-row .member-row-del { opacity: .45; transition: opacity .1s; }
.member-row:hover .member-row-del { opacity: 1; }
</style>

View File

@ -0,0 +1,160 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon name="receipt_long" size="26px" color="indigo-7" class="q-mr-sm" />
<div>
<div class="text-h6">Factures fournisseurs</div>
<div class="text-caption text-grey-6">Captées par OCR depuis <b>{{ invoiceTo }}</b> vérifier créer le brouillon ERPNext (jamais soumis auto).</div>
</div>
<q-space />
<q-btn flat dense icon="refresh" :loading="loading" no-caps label="Rafraîchir" @click="load" />
<q-btn unelevated dense color="indigo-6" icon="cloud_download" :loading="polling" no-caps label="Relever maintenant" class="q-ml-sm" @click="poll" />
</div>
<q-banner v-if="!list.length && !loading" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
<template #avatar><q-icon name="info" color="amber-8" /></template>
Aucune facture captée. Vérifie que <b>cc@targointernet.com</b> est <b>membre du groupe {{ invoiceTo }}</b> (sinon rien n'arrive), puis « Relever maintenant ».
</q-banner>
<div class="row q-col-gutter-md">
<!-- Liste -->
<div :class="selected ? 'col-12 col-md-5' : 'col-12'">
<div class="row items-center q-gutter-sm q-mb-sm">
<q-btn-toggle v-model="filter" :options="[{label:'À traiter',value:'open'},{label:'Créées',value:'Created'},{label:'Ignorées',value:'Ignored'},{label:'Tout',value:'all'}]" dense unelevated toggle-color="indigo-6" color="grey-3" text-color="grey-8" size="sm" no-caps />
<q-space />
<q-badge color="indigo-1" text-color="indigo-8">{{ filtered.length }}</q-badge>
</div>
<div v-for="r in filtered" :key="r.name" class="si-row" :class="{ sel: selected && selected.name === r.name }" @click="selected = r">
<div class="row items-center no-wrap">
<q-icon :name="statusIcon(r.status)" :color="statusColor(r.status)" size="18px" class="q-mr-sm" />
<div class="col ellipsis">
<div class="text-weight-medium ellipsis">{{ r.vendor || '(fournisseur ?)' }}<span v-if="r.invoice_number" class="text-grey-6"> · {{ r.invoice_number }}</span></div>
<div class="text-caption text-grey-6">{{ r.invoice_date || r.email_date || '' }} · {{ r.email_from }}</div>
</div>
<div class="text-right">
<div class="text-weight-bold">{{ fmtMoney(r.total, r.currency) }}</div>
<q-badge :color="statusBadge(r.status)" :label="statusLabel(r.status)" />
</div>
</div>
</div>
<div v-if="!filtered.length && list.length" class="text-caption text-grey-5 q-pa-sm">Rien dans ce filtre.</div>
</div>
<!-- Détail -->
<div v-if="selected" class="col-12 col-md-7">
<div class="ops-card">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">{{ selected.vendor || '(fournisseur à confirmer)' }}</div>
<q-space />
<q-btn flat round dense icon="open_in_new" :href="erpBase + '/app/supplier-invoice-intake/' + encodeURIComponent(selected.name)" target="_blank"><q-tooltip>Ouvrir l'intake dans ERPNext</q-tooltip></q-btn>
<q-btn flat round dense icon="close" @click="selected = null" />
</div>
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-6 col-sm-3"><div class="si-lbl">Total</div><div class="text-weight-bold">{{ fmtMoney(selected.total, selected.currency) }}</div></div>
<div class="col-6 col-sm-3"><div class="si-lbl">Sous-total</div><div>{{ fmtMoney(selected.subtotal, selected.currency) }}</div></div>
<div class="col-6 col-sm-3"><div class="si-lbl">TPS</div><div>{{ fmtMoney(selected.tax_gst, selected.currency) }}</div></div>
<div class="col-6 col-sm-3"><div class="si-lbl">TVQ</div><div>{{ fmtMoney(selected.tax_qst, selected.currency) }}</div></div>
<div class="col-6 col-sm-4"><div class="si-lbl">N° facture</div><div>{{ selected.invoice_number || '—' }}</div></div>
<div class="col-6 col-sm-4"><div class="si-lbl">Date</div><div>{{ selected.invoice_date || '—' }}</div></div>
<div class="col-6 col-sm-4"><div class="si-lbl">Échéance</div><div>{{ selected.due_date || '—' }}</div></div>
</div>
<q-banner v-if="selected.ocr_error" class="bg-red-1 text-red-9 q-mb-sm" dense rounded><q-icon name="error" /> OCR : {{ selected.ocr_error }}</q-banner>
<!-- Aperçu pièce jointe -->
<div class="att-box q-mb-sm">
<iframe v-if="isPdf(selected)" :src="attUrl(selected)" class="att-frame" />
<img v-else :src="attUrl(selected)" class="att-img" @error="imgError = true" />
<div v-if="imgError" class="text-caption text-grey-6 q-pa-sm">Aperçu indisponible — <a :href="attUrl(selected)" target="_blank">ouvrir la pièce</a></div>
</div>
<!-- Lien PI déjà créé -->
<q-banner v-if="selected.purchase_invoice" class="bg-green-1 text-green-9 q-mb-sm" dense rounded>
<q-icon name="check_circle" /> Brouillon créé : <b>{{ selected.purchase_invoice }}</b>
<template #action><q-btn flat dense color="green-9" icon="open_in_new" label="Ouvrir dans ERPNext" no-caps :href="erpBase + '/app/purchase-invoice/' + encodeURIComponent(selected.purchase_invoice)" target="_blank" /></template>
</q-banner>
<div class="row items-center q-gutter-sm">
<q-btn v-if="selected.status !== 'Created'" unelevated color="indigo-6" icon="post_add" label="Créer le brouillon ERPNext" no-caps :loading="creating" @click="createDraft" />
<q-btn v-if="selected.status !== 'Ignored'" flat color="grey-7" icon="block" label="Ignorer" no-caps @click="ignore" />
<q-space />
<span class="text-caption text-grey-5">ERPNext = registre. Vérifie le fournisseur avant de soumettre.</span>
</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const list = ref([])
const loading = ref(false)
const polling = ref(false)
const creating = ref(false)
const selected = ref(null)
const filter = ref('open')
const invoiceTo = ref('factures@targointernet.com')
const erpBase = ref('https://erp.gigafibre.ca')
const imgError = ref(false)
watch(selected, () => { imgError.value = false })
const filtered = computed(() => {
if (filter.value === 'all') return list.value
if (filter.value === 'open') return list.value.filter(r => r.status === 'New' || r.status === 'Error')
return list.value.filter(r => r.status === filter.value)
})
function fmtMoney (v, cur) { if (v == null || v === '') return '—'; return Number(v).toLocaleString('fr-CA', { style: 'currency', currency: cur || 'CAD' }) }
function isPdf (r) { return /\.pdf$/i.test(r.attachment_filename || '') || /\.pdf$/i.test(r.attachment || '') }
function attUrl (r) { return `${HUB_URL}/supplier-invoices/${encodeURIComponent(r.name)}/attachment` }
function statusLabel (s) { return ({ New: 'À traiter', Created: 'Créée', Ignored: 'Ignorée', Error: 'Erreur' }[s] || s) }
function statusBadge (s) { return ({ New: 'blue-6', Created: 'green-6', Ignored: 'grey-5', Error: 'red-6' }[s] || 'grey-6') }
function statusColor (s) { return ({ New: 'blue-6', Created: 'green-7', Ignored: 'grey-5', Error: 'red-6' }[s] || 'grey-6') }
function statusIcon (s) { return ({ New: 'fiber_new', Created: 'check_circle', Ignored: 'block', Error: 'error' }[s] || 'receipt') }
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/supplier-invoices`)
if (r.ok) { const d = await r.json(); list.value = d.invoices || []; invoiceTo.value = d.invoice_to || invoiceTo.value; if (d.erp_base) erpBase.value = d.erp_base; if (selected.value) selected.value = list.value.find(x => x.name === selected.value.name) || null }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { loading.value = false }
}
async function poll () {
polling.value = true
try { const r = await fetch(`${HUB_URL}/supplier-invoices/poll`, { method: 'POST' }); const d = await r.json(); $q.notify({ type: d.ok ? 'positive' : 'negative', message: d.ok ? `Relève : ${d.added || 0} facture(s) captée(s)` : (d.error || 'échec') }); await load() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { polling.value = false }
}
async function createDraft () {
if (!selected.value) return
creating.value = true
try {
const r = await fetch(`${HUB_URL}/supplier-invoices/${encodeURIComponent(selected.value.name)}/create`, { method: 'POST' })
const d = await r.json()
if (d.ok) { $q.notify({ type: 'positive', message: `Brouillon créé : ${d.name}${d.supplier_matched ? '' : ' (fournisseur à confirmer)'}` }) }
else $q.notify({ type: 'negative', message: 'Échec : ' + (d.error || '') + '. Crée-la manuellement dans ERPNext.', timeout: 6000 })
await load()
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { creating.value = false }
}
async function ignore () {
if (!selected.value) return
try { await fetch(`${HUB_URL}/supplier-invoices/${encodeURIComponent(selected.value.name)}/ignore`, { method: 'POST' }); $q.notify({ type: 'info', message: 'Ignorée' }); await load() } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
onMounted(load)
</script>
<style scoped>
.si-row { border: 1px solid #e2e8f0; border-radius: 8px; padding: 9px 11px; margin-bottom: 6px; cursor: pointer; transition: all .1s; }
.si-row:hover { border-color: #a5b4fc; background: #f8faff; }
.si-row.sel { border-color: #6366f1; background: #eef2ff; }
.si-lbl { font-size: .7rem; color: #94a3b8; text-transform: uppercase; }
.att-box { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #f8fafc; }
.att-frame { width: 100%; height: 460px; border: 0; background: #fff; display: block; }
.att-img { width: 100%; max-height: 460px; object-fit: contain; display: block; background: #fff; }
</style>

View File

@ -140,12 +140,18 @@
<template v-if="modalTicket?.customer_name"> &middot; {{ modalTicket.customer_name }}</template>
</template>
<template #header-actions>
<q-btn flat dense no-caps icon="forum" color="indigo-6" label="Intervenir" class="q-mr-xs"
@click="openIntervene(modalTicket)">
<q-tooltip>Note / mention sans changer l'assignation</q-tooltip>
</q-btn>
<q-btn v-if="modalTicket?.customer" flat dense round icon="person" class="q-mr-xs"
@click="$router.push('/clients/' + modalTicket.customer); modalOpen = false">
<q-tooltip>Voir le client</q-tooltip>
</q-btn>
</template>
</DetailModal>
<InterveneDialog v-model="intervene.open" :name="intervene.name" :title="intervene.title" doctype="Issue" />
</q-page>
</template>
@ -156,6 +162,10 @@ import { formatDate } from 'src/composables/useFormatters'
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal'
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config'
import InterveneDialog from 'src/components/shared/InterveneDialog.vue'
const intervene = ref({ open: false, name: '', title: '' })
function openIntervene (t) { if (t) intervene.value = { open: true, name: t.name, title: t.subject || t.name } }
import DetailModal from 'src/components/shared/DetailModal.vue'
import InlineField from 'src/components/shared/InlineField.vue'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'

395
docs/PLATFORM_GUIDE.md Normal file
View File

@ -0,0 +1,395 @@
# TARGO / Gigafibre — OPS Platform Guide
> **What this is.** A single reference to the logic behind every major feature of the OPS platform,
> the *operating conditions* we deliberately built into each one to fit how a Québec fibre ISP actually
> runs, and the *gotchas* that cost us real time. If you were rebuilding this from scratch, this is the
> document that would save you months.
>
> **Audience.** Us (operators + future maintainers) and anyone evaluating "could we build our own ISP ops stack."
>
> **Last synthesized:** 2026-06-13. Treat file:line references as directional — verify against current code.
---
## 0. TL;DR — the five things that explain everything
1. **F is the source of truth for billing. ERPNext is an operational mirror.** Almost every design
decision flows from this. We never write billing to F; we sync F → ERPNext one-way; we never auto-act
on a divergence that is just "the mirror hasn't caught up."
2. **The legacy box (F) is fragile and must be treated like unexploded ordnance.** One SSH at a time,
read-only, query only indexed columns, never the `account_id` join. A bad query freezes live billing.
3. **Two apps, one hub.** A Quasar SPA (`apps/ops`, staff desktop) + a field-tech mobile view, both
talking to a single hand-rolled Node service (`services/targo-hub`) that fans out to F, ERPNext,
Gmail, Twilio, SNMP, n8n, Stripe, GenieACS, OR-Tools, etc. The hub is the integration brain.
4. **Status is derived, never imported.** A customer is never flipped "inactive" from F. Cancellation
happens at the *service* level. Active/inactive truth = F `account.status`, read on demand — not a synced flag.
5. **Everything real-time is SSE.** No websockets for app state (only Twilio media). The hub publishes
topics; the SPA subscribes. If a badge/panel is "dead," suspect the single mount point, not the data.
---
## 1. The core model: F → ERPNext one-way mirror
```
┌─────────────────────┐ one-way, preview-first ┌──────────────────────┐
│ F (gestionclient)│ ───────────────────────────────▶ │ ERPNext v16 (PG) │
│ PHP + MariaDB │ clients · adresses · services · │ operational mirror │
│ 10.100.80.100 │ factures · paiements · tickets │ + OPS-only doctypes │
│ AUTHORITATIVE │ │ scheduler PAUSED │
│ for billing │ writes back ONLY via PHP bridge │ mute_emails ON │
└─────────────────────┘ (X-Ops-Token), never direct SQL └──────────────────────┘
```
- **Why a mirror at all.** F bills correctly but is a closed legacy PHP app with no API, no modern UX,
no dispatch/CRM/automation surface. ERPNext gives us a queryable data model + a place to bolt on
dispatch, inbox, campaigns, portals, AI — without touching the billing engine.
- **Identifiers that glue the two worlds** (memorize these):
- `Customer.legacy_account_id` = F `account.id`
- `Service Subscription.legacy_service_id` = F `service.id`**and the SUB doc name embeds it**:
`SUB-0000056285` → F service `56285`. (We *almost* lost this correlation; keep it.)
- `Service Location.legacy_delivery_id` = F `delivery.id`
- **F `account.status`** is the authority for "is this client active": `1=Actif, 2=Suspendu, 3/4/5=Résilié`.
`mapAccount` derives `disabled = [1,2].includes(status) ? 0 : 1`.
- **Sync direction & safety.** `lib/legacy-sync.js` is **preview/read-only by default**; applying a
change requires an explicit confirm string (`SAFE-ADD`); payment mirroring requires `F-WINS`. The
heavy invoice/service creation runs in **host cron** `/opt/targo-sync/run.sh` (hourly), not in the hub.
The hub's DAG (`lib/sync-orchestrator.js`) documents/validates order
(clients→adresses→services; factures→paiements; tickets separate) and exposes `/sync/status`.
> **Operating condition (the big one):** *A divergence is not an action.* "F inactive / ERPNext active"
> for ~2,500 rows is **noise** (the mirror lag + derived flags), not churn to execute. Reconciliation
> dashboards must split *actionable* (email/phone/name) from *derived/non-actionable* (`disabled`,
> `is_suspended`). See §3.7 and §4.
---
## 2. System topology
| Layer | What | Where / how |
|---|---|---|
| **Staff SPA** | `apps/ops` — Vue 3 + Quasar, hash-router. Desktop ops console. | Built `quasar build``scp dist/spa/* → 96.125.196.67:/opt/ops-app/`. Behind **Authentik SSO**. |
| **Field-tech view** | `apps/ops` routes under `/j` (`TechLayout`) + a hub-served SPA `/t/<token>`. | Delivered to techs by **SMS magic-link** (JWT). PWA-ish, offline store + camera scan. |
| **Integration hub** | `services/targo-hub` — Node 20, **hand-rolled `http.createServer`** (no Express). ~70 `lib/*.js` modules, ~28k lines. | Container `targo-hub`, port **3300 not published** (test via `docker exec -i targo-hub node`). Lib bind-mounted `/opt/targo-hub/lib`; deploy = scp lib + `docker restart`. |
| **Billing truth** | F = `gestionclient` PHP/MariaDB | `10.100.80.100`, reached via a local `legacy-db` **mirror** + the PHP write bridge `ops_reassign.php`. |
| **Operational store** | ERPNext v16 on **PostgreSQL** | `erp.gigafibre.ca` (96.125.196.67). REST via `token` auth + `Host` header. Scheduler **paused**, `mute_emails` on. |
| **Address DB** | Québec `rqa_addresses` (5.24M) + `fiber_availability` | Local Postgres (the ERPNext PG), shared pool owned by `lib/address-db.js`. |
| **Realtime** | SSE pub/sub (`lib/sse.js`) | SPA subscribes `/sse?topics=...`. Only Twilio Media Streams use WebSocket. |
**Auth tiers (enforced by one gate at the top of `server.js`):**
1. **Public** allowlist (`/health`, `/g/`, `/c/`, `/book`, `/field`, `/diag/`, `/webhook/*`, checkout/portal/magic-link, `/conversations/email-ingest|channel-ingest`…).
2. **Customer self-service** — the **token in the URL is the secret** (signed JWT diag links, web-chat client routes, payment links).
3. **Staff/admin**`Authorization: Bearer HUB_SERVICE_TOKEN`, **injected server-side by the ops nginx `/hub/` proxy** (same-origin). Cross-origin apps with no token can only reach public routes.
- `HUB_GATE` modes: `enforce` (401) | `report` (log-only) | off. `ALWAYS_ENFORCE` list (`/devices`, `/email-queue`, `/campaigns`, `/giftbit`) is locked regardless.
- Per-handler extra secrets: `X-Mail-Token` (n8n ingest), `HUB_INTERNAL_TOKEN` (`/broadcast`), signed query token (iCal), `X-Ops-Token` (PHP write bridge).
> **Gotcha (auth):** use a **same-origin proxy that injects the Bearer token**, NOT Traefik forward-auth
> alone — cross-origin + SSE breaks under forward-auth. Several routes were historically left open
> (`/devices`, `/conversations`, `/olt`, `/admin/pollers`); the gate + `ALWAYS_ENFORCE` closed the worst.
---
## 3. Feature catalog
Each entry: **What** · **Logic** · **Operating conditions** (the invariants we built to fit ops) · **Gotchas**.
### 3.1 Clients — the 360° file (`/clients`, `/clients/:id`)
- **What.** Searchable customer list + a single page that aggregates subscriptions, services, equipment,
tickets, invoices, payments, notes, and **live modem status** for one customer.
- **Logic.** `ClientDetailPage.vue` (1,725 lines) orchestrates ~8 composables (`useClientData`,
`useSubscriptionActions`, `useEquipmentActions`, `usePaymentActions`, `useDeviceStatus`,
`useSubscriptionGroups`, …). Data is ERPNext doctypes (`Customer`, `Service Location`,
`Service Subscription`, `Subscription Plan`, `Item`) joined by the `legacy_*` keys.
- **Operating conditions.**
- *Effective service price* is **not** a column. F's `service` table has no price → price =
`hijack ? hijack_price : product.price` (+ `hijack_desc`). `status=1` = active; `date_suspended`
when `status=1` is just history — **ignore it**.
- *A customer is never set inactive.* Out-of-zone movers stay visible (cross-sell candidates).
`disabled`/`is_suspended` are **derived from subscriptions**, never synced from F.
- *Recurring monthly total can never be negative.* The page shows a red guard if it is, and each
subscription row carries an `F#id` / "added in OPS" badge so we can trace a bad rebate to its origin.
- **Gotchas.**
- `erp.list` **blocks some fields** on ERPNext v16 ("Field not permitted in query"); `lib/erp.js`
auto-retries by dropping them. The real per-service price field is `monthly_price`, *not* `actual_price`.
- This page is the #4 largest file — a refactor candidate, but it's load-bearing; split by section, don't rewrite.
### 3.2 Communications — unified inbox (`/communications`)
- **What.** One place for all customer conversations (SMS + email + web-chat) and open tickets,
organized two ways: a **department board** ("Par département") and a **sales pipeline** ("Pipeline ventes").
- **Logic.**
- `useConversations.js` is a **module-level singleton** holding `conversations`, `discussions`,
`panelOpen`, `activeCount`, queue/claim/assign, SSE wiring. Backend: `lib/conversation.js`
(+ `lib/ticket-collab.js` for the ERPNext-Issue side).
- Inbound mail/SMS/3CX/Facebook are **ingested** via `/conversations/email-ingest` & `/channel-ingest`
(n8n, `X-Mail-Token`). Replies go out via Gmail (`gmail.js`) or Twilio.
- `DepartmentBoard.vue` groups `discussions` by `queue` (= department) with drag-to-reassign
(`assignQueue`), click-to-open, and per-column compose. `PipelineBoard.vue` is the leads kanban.
- **AI triage** (`inbox-triage.js`): classifies inbound mail (known customer? type? suggest a ticket?).
- **Outbox / undo-send** (`outbox.js`): multi-recipient queue notifications are held N seconds and
cancellable from an ops panel; single-recipient agent replies bypass the buffer.
- **Category taxonomy is single-source** (`lib/categories.js`): the 4 queues, the request `TYPES`,
`type→queue` (`QUEUE_OF`) and `type→ticket-category` (`CAT`) live in ONE module, required by both
`conversation.js` and `inbox-triage.js`. They had drifted (`telephonie`/`television` existed in the
queue map but not the triage types). Contract: the AI prompt enumerates `TYPES` by hand — extend both.
- **Sender identity = the real `From`** (display name), then a customer matched **by email**, then the
raw address — *never* the AI's content-guessed name. And a thread is **never grouped by one of our own
domains** (`support@targo.ca`, `*@targointernet.com`): re-ingested outbound would otherwise collapse
unrelated threads into one. Per-agent reply stats come from stamping `msg.agent` on outbound replies.
- **Operating conditions.**
- *Departments = the four queues* `Facturations`, `Service à la clientèle`, `Supports`, `Technicien`
(+ "Non assigné"). The board reuses the existing queue labels — departments are queues.
- *Queue/notification lists must stay small.* A queue notification once emailed the **entire** team and
re-ingested our own notifications into the inbox (a feedback loop). Fix: empty `queue_members` +
an `ingestGuard` that drops own-notifications / internal mail / ticket-replies.
- *AI auto-reply only answers customer-initiated threads* — never our own "planned maintenance"/dunning
notifications, never a bare "ok/merci" ack (`isAck`), and yields if a human replied today.
- *Sales pipeline coexists with the department board* (2 tabs) — we did **not** replace it, because the
Sales-sync project still needs the leads stages.
- **Gotchas.**
- **`ConversationPanel.vue` is the single mount point for the whole comms subsystem.** Its `onMounted`
runs the global init (`fetchList`+`fetchTickets`+SSE); the drawer (`panelOpen`), the badge
(`activeCount`), **and** the "Nouveau texto/courriel" dialog all live inside it. If its `setup()`
throws, *everything* comms dies (no badge, panel won't open, compose won't open) while the rest of the
app keeps working. That exact symptom pattern = a ConversationPanel mount failure — **not the cache.**
- The throw we actually hit: a top-level `watch(coordToken, …)` placed **before** `const coordToken = computed()`
→ TDZ `ReferenceError`. Rule: in `<script setup>`, any top-level `watch()`/eager call must come
*after* the `const` it references. The Quasar build does **not** catch this (runtime error).
- `/email-queue` (`EmailQueuePage`) is the **ERPNext muted outbound queue** (a technical inspector),
*not* the Gmail conversation email. Don't confuse them.
### 3.3 Tickets & collaboration (`/tickets`, `/collab/*`)
- **What.** ERPNext `Issue` list + a "collab" layer: customer search (phone/civic/name fuzzy),
service+modem status, live modem checks, create-ticket-from-conversation, comments/@mentions.
- **Logic.** `ticket-collab.js` exposes `/collab/service-status` (service + modem in one call),
`/collab/dostuff` (live modem status by hitting **F's own n8n webhook** `n8napi.targo.ca/webhook/dostuff`),
the negative-billing & terminated-active reports, WAN-rate, and `/collab/diag-link`.
- **Operating conditions.**
- A ticket created from a conversation **keeps the conversation** (≠ archive).
- "Do Stuff" live check is `tech=3` only (`tech=2` is our own poller); ~27 s latency.
- **Gotchas.** `service-status` keys ONUs by the **same TPLG serial format** as ERPNext (MAC fallback);
`rxPowerOnu` is RAW (not dBm), `rxPowerOlt` is dBm×10. ERPNext TPLG serials ≠ GenieACS real serials.
### 3.4 Workforce — Dispatch + Planification + RDV + Copilot
This is the largest, most interconnected domain. The strategic move was **convergence**: Dispatch and
Planification share a **single write path** and a **single resource rail**.
- **Dispatch** (`/dispatch`, `DispatchPage.vue` 2,162 lines): drag-and-drop tech scheduling on a
Mapbox-backed board — routing, auto-distribute, **Uber-style job offers** (`useJobOffers`: broadcast/
accept/decline, rush pricing), GPS, undo. Backend `lib/dispatch.js` scores techs by proximity/cost/skills.
- **Planification** (`/planification`, 3,524 lines — the largest file): roster/shift grid driven by an
**OR-Tools solver** (`roster-solver:8090`) via `lib/roster.js`, over ERPNext doctypes (Dispatch
Technician, Shift Template/Requirement/Assignment, Tech Availability).
- **RDV** (`/rdv`): customer-appointment booking — roster-aware slots, 3 ranked offers + "fit", hold,
confirm, reschedule-notify. Public booking served at `/book`.
- **Copilot** (`/copilote`): conversational ops assistant (Gemini function-calling) with **write actions**
(reassign/SMS/escalate) + voice.
- **Historique** (`/historique`, `HistoriquePage.vue`): 3 tabs — *Tournées par technicien* (a day's jobs
per tech, **all statuses** incl. Completed/Cancelled), *Classement techniciens* (jobs completed), and
*Classement Inbox* (replies + threads handled per agent). It exists because the Dispatch board filters to
`open/assigned/in_progress`, so once a job is completed/cancelled (or its ticket closed/reassigned) it
vanished — "where did the tech go?". Read-only hub endpoints `/dispatch/history`, `/dispatch/leaderboard`,
`/conversations/leaderboard`; agent reply stats rely on `msg.agent` stamping (accrue from 2026-06-14).
- **Operating conditions.**
- *One write path.* Job→tech assignment goes through `features/workforce/useAssignment` (the "hub"),
shared by both the Dispatch board and the Planification grid — so the two views can't drift.
- *One resource rail.* `features/workforce/useResources` is the single techs+subgroups+search/score source.
- *Tags = skill levels; jobs carry a minimum required.* Dispatch logic cost-optimizes: cheapest tech that
still clears the job's minimum skill tags.
- *A tech on pause turns their dispatch tickets red* (roster ↔ dispatch coupling).
- *Booking offers obey a policy* (min lead time, hour window, max/day, buffer, blackouts).
- **Gotchas.**
- In `lib/roster.js`, **`erp.list` must be sequential, not `Promise.all`** (ERPNext chokes), use
`http.request` (not a global `fetch`), and watch Pydantic `None` on the solver side + anti-over-staffing.
- The legacy osTicket → Dispatch Job bridge is **opt-in** (`LEGACY_DISPATCH_SYNC=on`), idempotent via
`legacy_ticket_id`, and **recreating the container ≠ restarting it** for env to take effect.
### 3.5 Field-tech mobile app (`/j/*`, `TechLayout`; hub `/t/<token>`, `/field`)
- **What.** A phone-first app techs reach via SMS magic-link: today's jobs, job detail + status updates,
camera **serial scan**, device lookup, modem/Wi-Fi diagnostics, offline queue.
- **Logic.** Routes under `/j` with a magic-link catch-all `/j/:token`. `useScanner` (camera → Gemini
Vision OCR), `useOfflineStore` (Pinia, queue writes when offline), `useModemDiagnostic`/`useWifiDiagnostic`
(signal thresholds → verdicts). The hub also serves a server-rendered SPA at `/t/<token>`.
- **Operating conditions.** Auth = the **JWT in the link is the credential** (no SSO on a tech's phone).
Devices are linked to **addresses, not customers** — preserve manage-IP/credentials/parent hierarchy.
- **Gotchas.** This is the part most people get wrong on mobile elsewhere in the app — see §5: the tech
view *is* mobile-designed; the **staff console pages are not** (that's the redesign work).
### 3.6 Network, devices, OLT, diagnostics (`/network`, `/olt`, `/devices`)
- **What.** Topology/dependency map, health, OLT signal stats, poller admin, AI log RCA.
- **Logic.**
- `devices.js` caches GenieACS devices (poller sweeps ~6,000 ONTs / 5 min).
- `olt-snmp.js` polls TP-Link (ent. 11863) and Raisecom (ent. 8886) GPON OLTs over SNMP every 5 min —
ONU table walk, signal/power/serial. WAN throughput (Mbps) is **live SNMP**, not Elasticsearch.
- `network-intel.js` feeds InfluxDB + Grafana logs to Gemini for root-cause analysis.
- `outage-monitor.js` turns Uptime-Kuma webhooks into synthesized alerts.
- **Operating conditions.** WAN live-rate graph works on **Raisecom OLTs (tech-2)** only; tech-3 shows
"indisponible". The OLT keys ONUs by the same TPLG format as ERPNext (direct match).
- **Gotchas.** **SNMP Counter64 arrives as an 8-byte Buffer** — read it with `readBigUInt64BE`, or you get
~0/null. WAN octet OIDs (Raisecom): `rx=…2.4.1.5.{oltId}.40`, `tx=…3.{oltId}.40`,
`oltId = slot*1e7 + port*1e5 + ontid`, `.40` = the internet VLAN.
### 3.7 Billing sync & financial reports (`/sync-legacy`, `/rapports/*`)
- **What.** The F→ERPNext sync dashboard + reports: revenue by account, detailed sales, **TPS/TVQ taxes**,
AR aging, "Internet cher" (overpaying addresses + competition), **negative recurring bills**, and
**terminated accounts with active services**.
- **Logic.** Reports hit ERPNext (`lib/reports.js`) or the legacy MariaDB (`lib/legacy-reports.js`).
Payment reconciliation (`lib/legacy-payments.js`) mirrors Payment Entries into ERPNext idempotently
(`PE-{id}`), guarded by `confirm:'F-WINS'`, and computes a billing-coherence status.
- **Operating conditions.**
- *Recurring monthly bill is never negative.* When it was, the root cause was a **status-mapping bug**
(`status=1 + date_suspended>0` wrongly mapped to "Suspended"), corrected to `status=1 ⇒ Actif` in both
the Python cron and the JS. (177 subscriptions corrected.) Report: `/rapports/factures-negatives`.
- *Terminating a customer does NOT cascade to deactivate their services.* Some terminated accounts keep
legitimately-active services (**employees with comped internet**). Cancellation is per-service. The
account "Résilié" status is **informational** (a cleanup-report column), never an automatic action.
Report: `/rapports/resilies-actifs` (~2,724 terminated accounts / 3,966 active services).
- *Before ERPNext ever emits real billing, validate balance against F first.* (Scheduler stays paused
until cutover.)
- **Gotchas.**
- **Never join `delivery.account_id`** in F — it's unindexed → full scan → freezes live billing. Query by
`delivery_id` (indexed). Drive delivery_ids from ERPNext PG, then query F by those.
- ERPNext v16 on Postgres has real bugs (GROUP BY/HAVING/boolean/double-quote/ORDER BY NULL) — patches
are baked into the image; custom-field columns sometimes need an `ALTER` via the pool.
### 3.8 Address intelligence (`/conformite-adresses`, `/serviceability`)
- **What.** Address search/autocomplete, conformity/normalization review (incl. camping sites), and
per-address **competitor lookup**.
- **Logic.** `address-db.js` owns the shared PG pool over `rqa_addresses` (5.24M) + `fiber_availability`,
with a phased trigram query. `serviceability.js` hits **Québec IHV ArcGIS open data** (providers by
address, incl. Cogeco) with a `Referer: quebec.ca` header — this replaced a reCAPTCHA-blocked checker.
- **Operating conditions.** Future targeting is by **service availability at an address / a specific active
service**, at the service/address level — not the customer level.
- **Gotchas.** Trigram `word_similarity` with `<%` has a perf cliff — phase the query. The whole address DB
is **local** now (no Supabase cloud).
### 3.9 Campaigns — gift cards (`/campaigns/*`)
- **What.** Email gift-card campaigns: 2 CSVs → match to ERPNext `Customer` → Mailjet blast of personalized
FR/EN emails carrying a **Giftbit** shortlink → live SSE progress; reminder campaign for non-clickers;
Unlayer template editor + AI FR↔EN translate.
- **Logic.** `campaigns.js` (2,389 lines): CSV parse/match, MJML compile, Mailjet send + Event webhook,
the gift **redirect wrapper** `/g/` (own expiry + reuse). `giftbit.js` is the gift-card API client.
- **Operating conditions.** Email routing is FR/EN by `Customer.language`. The gift `/g/` wrapper gives us
our own expiry/reuse control on top of Giftbit.
- **Gotchas.** A real incident: a `/campaigns` endpoint was **public without auth** → "card already used" /
"$0.03 balance" (cards drained). Locked by token. Giftbit defaults to **TESTBED** (no real cards) until a
separate prod token is wired. Map-CSV customer matching only resolves ~25% — still a production blocker.
### 3.10 Customer-facing (portal, checkout, contracts, diag, store)
- **What.** Passwordless customer portal, public checkout/catalog/order, contract acceptance, a self-service
live diagnostic page, a hardware store (staging), self-signup (staging).
- **Logic.** `portal-auth.js` (email/phone → 24h JWT), `checkout.js`, `contracts.js` (residential JWT +
commercial DocuSeal), `client-diag.js` (`/diag/<jwt>` shows what works, no internal data), `payments.js`
(Stripe + webhook + **daily PPA cron** 06:00 EST).
- **Operating conditions.**
- Legacy passwords are **unsalted MD5** → force reset via email/SMS OTP, never trust them.
- The `/diag` page must **never leak internal data**.
- Contract acceptance is two-track: residential = JWT (promotion framing), commercial = DocuSeal.
- CRTC 2026-43 (in force 2026-06-12): install is billable (exception), termination fees forbidden without
a subsidized device; the residential "promotion spread/clawback" framing is now non-compliant.
- **Gotchas.** Invoice PDFs carry a **QR code** to the Stripe/portal payment link.
### 3.11 Telephony & voice agent (`/telephony`, `/voice/*`)
- **What.** 3CX telephony admin + a WebRTC softphone in the SPA (`usePhone`, SIP.js over WSS) + an **AI
voice agent** over Twilio Media Streams ↔ Gemini Live.
- **Logic.** `pbx.js` polls the 3CX call log + handles call-event webhooks; `voice-agent.js` does
mulaw↔PCM transcoding and bridges Twilio audio to Gemini Live.
- **Gotchas.** Voice agent is the only WebSocket consumer (`/voice/ws`). Inbound `/voice/*` is public.
### 3.12 Admin / RBAC / settings (`/settings`, `/equipe`, `/agent-flows`)
- **What.** Users, **permission matrix**, user groups, legacy-sync controls, AI agent flow/prompt editor.
- **Logic.** RBAC in `lib/auth.js` (capabilities like `view_clients`, `assign_jobs`, …) over Authentik;
the SPA gates nav items and features by capability (`usePermissions`). Nav is data-driven from
`src/config/nav.js` (each item has a `requires`).
- **Operating conditions.** SSO is **Authentik** (`auth.targo.ca`) — which is also why the staff app
**cannot be loaded headlessly** for verification (see §4).
---
## 4. Cross-cutting gotchas — "if you build another software, know these"
These are the lessons that generalize beyond TARGO.
**Legacy / data integrity**
- **Treat the authoritative legacy system as read-only and fragile.** One connection at a time, only
indexed queries, never the expensive join. A reporting query must never be able to freeze billing.
- **Keep the foreign keys.** Embedding `legacy_service_id` in the mirror's record *name* saved us; losing a
correlation key is expensive to rebuild.
- **A mirror lag is not a business event.** Separate "the mirror disagrees" from "the customer changed."
Auto-acting on the former manufactures fake churn.
- **Derive status, don't sync it.** `disabled`/`is_suspended` should be computed from services, never
flipped from the source — and cancellation belongs at the service level, not the account level
(employees with comped service prove the account-level cascade wrong).
- **An invariant deserves a guard *and* a report.** "Recurring bill ≥ 0" got both a fiche-level red warning
and a `/rapports/factures-negatives` sweep. The report found the systemic mapping bug the guard couldn't.
**ERPNext v16 on PostgreSQL specifically**
- It ships SQL that assumes MySQL (GROUP BY/HAVING/boolean/double-quotes/ORDER BY NULL). Patch in-image and
make patches idempotent. `get_list` silently rejects some fields → wrap with a field-drop-retry.
- Keep the scheduler paused and `mute_emails` on until you actually own billing.
**Realtime / frontend architecture**
- **Find your single points of failure.** One component (`ConversationPanel`) owning the init + the drawer +
the badge + the compose dialog means one `setup()` throw blacks out an entire subsystem while the rest of
the app looks fine. Either spread the init or know the symptom signature cold.
- **TDZ in `<script setup>`** — a top-level `watch(x)`/eager call before `const x = …` throws at runtime and
the build won't catch it. Order matters; computeds/functions are lazy, watches are not.
- **You can't verify an SSO-gated SPA headlessly.** Plan for it: deterministic fixes + asking for the browser
console error are faster than fighting the auth wall. Don't blame the cache before ruling out a mount throw.
- **Cache after deploy.** Hashed assets persist; serve `index.html` `no-cache` so redeploys take effect
without a manual hard-refresh.
**Integration plumbing**
- **SNMP Counter64 = 8-byte Buffer**`readBigUInt64BE`. Vendor enterprise OIDs differ (TP-Link vs Raisecom).
- **Auth via same-origin token-injecting proxy**, not forward-auth alone (cross-origin + SSE break).
- **Sequential, not parallel** for fragile upstreams (`erp.list` in the roster); `Promise.all` can DoS them.
- **Opt-in schedulers + idempotency keys** for anything that writes (the osTicket bridge); and remember
*recreate ≠ restart* for env changes.
- **Lock every public endpoint that can spend money** (the Giftbit incident). Default-deny + an
`ALWAYS_ENFORCE` list for the routes that should never see anonymous traffic.
**Ops/SSH hygiene**
- macOS has no `timeout`/`sudo` to the legacy box; use `su -s /bin/bash www-data -c …` and
`ssh -o BatchMode=yes -o ServerAliveInterval=…`. Back up before editing (`*.bak-YYYYMMDD`).
---
## 5. Mobile & code health (pointer)
The current state, the clean **mobile-first design system**, the per-page responsive strategy, and the
prioritized **code-optimization backlog** (helper consolidation, `<StatusBadge>`/`<StatCard>`, token
unification `--sb-*`→`--ops-*`, mega-component splits) live in a companion doc:
**`docs/UI_AND_OPTIMIZATION.md`**
One-line summary: the *shell* is responsive (drawer breakpoint 1024 + mobile header), but **no content page
is** (zero `q-table` grid mode, fixed-width dialogs/columns, `$q.screen` used in 2 files). The fix is a small
set of responsive primitives applied page-by-page, plus finishing the stalled helper consolidation
(`useFormatters` reaches 17 files, `useStatusClasses` only 4, while ~25 pages carry private copies).
---
## 6. Glossary
| Term | Meaning |
|---|---|
| **F** | `gestionclient` — the legacy PHP/MariaDB billing system. Authoritative for billing. |
| **hub** | `services/targo-hub` — the Node integration service (port 3300). |
| **OPS** | the Quasar staff SPA (`apps/ops`, `/opt/ops-app`). |
| **SUB-000…** | `Service Subscription` doc; the trailing number = F `service.id`. |
| **hijack** | per-service price override in F (`hijack_price`, `hijack_desc`). |
| **queue / file** | inbox label = department (Facturations, Service à la clientèle, Supports, Technicien). |
| **dostuff** | F's n8n webhook for live modem status (`tech=3`). |
| **RQA** | the local Québec address database (`rqa_addresses`). |
| **PPA** | pre-authorized payment (daily Stripe cron). |
| **TPLG serial** | ERPNext-side ONU serial format; ≠ GenieACS real serial (MAC fallback). |

136
docs/UI_AND_OPTIMIZATION.md Normal file
View File

@ -0,0 +1,136 @@
# OPS — Clean Mobile-First UI & Code Optimization Plan
> Companion to `PLATFORM_GUIDE.md`. This is the design + refactor playbook: what to change, in what order,
> and **at what risk** — written so it can be executed safely in small, verifiable batches.
>
> Constraint that shapes everything here: **the staff SPA is behind Authentik SSO, so UI changes can't be
> verified headlessly.** Therefore we prefer *additive* changes (new primitives, new media queries scoped to
> small viewports) and *behavior-neutral* refactors (pure-helper consolidation) that the build can validate,
> and we roll out page-by-page so any regression has a tiny blast radius.
---
## A. Current state (from the 2026-06-13 audit)
- **Shell is responsive, pages are not.** `MainLayout.vue` already does the right thing: `<q-drawer :breakpoint="1024">` (overlay below 1024px) + a mobile `<q-header v-if="$q.screen.lt.lg">` with hamburger + a desktop bar `v-if="$q.screen.gt.md"`. But **`$q.screen` appears in only 2 files total** (this layout + `ProjectWizard`). Every content page assumes desktop width.
- **Zero `q-table` grid mode** anywhere — the standard Quasar mobile-table fallback is unused, so every table horizontally overflows on a phone.
- **Dialogs hardcode widths** (`style="width:560px"`, `width:400px`, `min-width:420px`) — only `ProjectWizard` uses the correct `:maximized="$q.screen.lt.sm"` pattern; nothing else copies it.
- **Two parallel light token systems**: `--ops-*` (in `app.scss`) and `--sb-*` (in `dispatch-styles.scss`). Dispatch is **no longer dark** — it's a second light design language. 4 dispatch modals still carry orphaned Quasar `dark` attributes (visually inconsistent).
- **Helper duplication, adoption stalled**: `useFormatters` (canonical) reaches 17 files, `useStatusClasses` only 4, while ~25 pages carry private copies of `shortAgent` (×5), `statusColor` (×10, all different palettes), date/time formatters (×16), money (×6). ~337 ad-hoc Quasar color-name strings for status.
- **Hardcoded hex** instead of tokens: ProjectWizard 326, PlanificationPage 245, NetworkPage 100, EquipmentDetail 84, ClientDetailPage 80, ConversationPanel 61.
---
## B. The clean mobile-first design system
### B.1 Principles
1. **One token set.** Everything references `var(--ops-*)`. `--sb-*` becomes an alias layer, then is removed.
2. **Content reflows; dense tools degrade gracefully.** List/report/detail pages must be fully usable at
380px. The two genuinely desktop-only tools (Dispatch board, Planification grid) get an explicit
"best on desktop" affordance + a **read-only mobile summary**, rather than a fake reflow.
3. **A handful of primitives, applied everywhere.** We don't redesign 30 pages by hand — we build ~6 shared
components/utilities and route pages through them.
4. **Touch targets ≥ 44px, single-column forms on mobile, bottom-sheet dialogs.**
### B.2 Token system (unify `--sb-*``--ops-*`)
- Step 1 (safe): in `dispatch-styles.scss`, redefine each `--sb-*` as `var(--ops-*)` (alias). No visual change.
- Step 2 (later): delete `--sb-*` usages in favor of `--ops-*`, then remove the aliases.
- Step 3: remove `dark` attributes from the 4 dispatch modals (`CreateOfferModal`, `PublishScheduleModal`,
`WoCreateModal`, `JobEditModal`); delete the dead `$q.dark` branch in `TagEditor.vue`.
### B.3 The responsive primitives to build (additive — zero risk to existing pages)
| Primitive | What it does | Replaces |
|---|---|---|
| `<ResponsiveDialog>` | wraps `q-dialog` with `:maximized="$q.screen.lt.sm"` + sane `max-width:95vw` | every `style="width:NNNpx"` dialog |
| `<DataTable>` | wraps `q-table` with `:grid="$q.screen.lt.md"` + a default card slot + column-priority | every raw wide `q-table` |
| `<StatCard>` | the report stat tile (icon, value, label) using tokens | inline `style="min-width:130px"` cards on 6+ report pages |
| `<StatusBadge :doctype :value>` | one place that maps status→color/label | the ~10 `statusColor()` maps + 337 color-name strings |
| `<PageHeader>` | title + actions row that wraps on mobile (`col-12 col-sm-auto`) | fixed-width filter rows (`style="width:160px"`) |
| `<FilterBar>` | filter inputs that stack on mobile | same |
### B.4 Mobile layout rules (apply per page)
- Filter rows: `row q-col-gutter-sm` with each control `col-12 col-sm-auto` (stack on phone, inline on desktop).
- Two-column detail (`col-lg-8 / col-lg-4`): keep — it already stacks. But the right "sticky" column must
drop its `position:sticky`/`100vh` on mobile (it currently eats a full viewport once stacked — see
`app.scss .convos-sticky`). Add `@media (max-width: 1023px) { .convos-sticky { position: static; max-height: none } }`.
- Kanban boards (`DepartmentBoard`, `PipelineBoard`): already horizontal-scroll with fixed-width columns —
acceptable on mobile (swipe). Just shrink column width under `sm` (`flex-basis: 80vw`) so one column shows at a time.
- Dispatch / Planification: gate with a friendly "Best viewed on desktop" banner under `lt.md` + link to the
read-only day summary; don't attempt to reflow the timeline.
---
## C. Per-page responsive strategy
Risk legend: 🟢 trivial/additive · 🟡 moderate (touches layout) · 🔴 large (mega-component).
| Page | Problem | Fix | Risk |
|---|---|---|---|
| Report pages (×8) | fixed filter widths + wide tables, no grid mode | `<FilterBar>` + `<DataTable grid-on-mobile>` + `<StatCard>` | 🟢 |
| `ClientsPage`, `EquipePage`, `EmailQueuePage`, `TelephonyPage`, `SupplierInvoicesPage` | wide tables + fixed dialog widths | `<DataTable>` + `<ResponsiveDialog>` | 🟢 |
| `ClientDetailPage` | sticky right col eats mobile viewport; 560px dialogs | media-query unstick + `<ResponsiveDialog>` | 🟡 |
| `AddressConformityPage` | `min-width:420px` dialogs overflow | `<ResponsiveDialog>` | 🟢 |
| `CampaignNewPage` | dense multi-col form | single-column under `md`, step-by-step | 🟡 |
| `SettingsPage` permission matrix | wide horizontal-scroll grid | keep scroll, add sticky first column + min touch size | 🟡 |
| `DepartmentBoard` / `PipelineBoard` | fixed 270px columns | `flex-basis: 80vw` under `sm` | 🟢 |
| `NetworkPage` | map + topology, 100 hex | desktop-gate + token cleanup | 🟡 |
| `DispatchPage` / `PlanificationPage` | 100vh desktop workspaces, can't reflow | "best on desktop" banner + read-only mobile day view | 🔴 (new view) |
---
## D. Code optimization backlog (prioritized)
### P0 — behavior-neutral consolidation (safe, build-verifiable) ✅ *started this session*
1. **Finish the helper migration.** Make `useFormatters.js` the single source for: `formatMoney`, `formatDate`,
`formatDateShort`, `formatDateTime`, **`shortAgent`** (new), **`fmtTimeHHhMM`** (new, `9h05`),
**`relTime`** (new, "today→time, else date"), `timeAgo` (`noteTimeAgo`). Then delete the private copies:
- `shortAgent` ×5 → import (DepartmentBoard, PipelineBoard, ConversationPanel, InterveneDialog, +tech initials).
- money ×6 → `formatMoney` (verify each is byte-equivalent before swapping; the report `money`/`fmtMoney` are).
- date/time ×16 → the canonical set (swap only identical ones; unify the 4 divergent relative formatters into `relTime`).
- **Risk control:** swap only *identical* implementations; leave divergent ones flagged. Build after each batch.
2. **Add `useDebounce`** and replace the 3 hand-rolled `setTimeout` debouncers (MainLayout, useUserGroups×2).
### P1 — extract shared UI components (additive, then migrate page-by-page)
3. `<StatusBadge>` + a single `statusColor(doctype, value)` module → migrate the ~10 maps + the report pages first.
4. `<StatCard>`, `<ResponsiveDialog>`, `<DataTable>`, `<FilterBar>`, `<PageHeader>` (see §B.3) → migrate report
pages first (leaf, low blast radius), then list pages.
5. Token cleanup pass: replace top-7 hardcoded-hex offenders' brand colors with `var(--ops-*)`.
### P2 — split the mega-components (highest effort, do last, one at a time)
The top 5 are ~12k of 38k total `.vue` lines:
`PlanificationPage` (3,524) · `ProjectWizard` (2,891) · `DispatchPage` (2,162) · `NetworkPage` (1,829) ·
`ClientDetailPage` (1,725). Split **by section into child components + composables**, never a rewrite. These
are load-bearing; each split needs its own verify cycle.
### Enforcement (so it doesn't regress again)
- Add an ESLint `no-restricted-syntax` rule banning local `function formatMoney|statusColor|fmtDate|shortAgent`
definitions outside the canonical modules. (Adoption stalled once already — task #81 was "done" but ~25 pages
kept private copies. A lint rule is what makes consolidation stick.)
---
## E. What was actually changed vs. deliberately deferred (2026-06-13)
**Done & build-verified, NOT deployed** (held for spot-check, because the live app — `index.b5007bac.js`,
the conversations fix + Communications feature — is what's running while you're away and I can't runtime-verify):
- Extended `useFormatters.js` with `shortAgent`, `fmtTimeHHhMM`, `relTime`, `formatDateTimeShort` (all additive).
- **`shortAgent` consolidated in 4 files**: `DepartmentBoard`, `PipelineBoard`, `ConversationPanel`, `InterveneDialog`
(the 5th, `shortName`, was the same one-liner — aliased on import). 1 left = the tech `initials` *variant* (different fn).
- **`relTime`** swapped into `DepartmentBoard` (ex-`fmtTime`, identical logic).
- **`formatMoney`** swapped (aliased as `money`) into `ReportNegativeBillingPage` + `ReportTerminatedActivePage`
(both were `(v==null?0:v).toLocaleString(currency)` = equivalent for numeric/null). Left untouched on purpose:
`SupplierInvoicesPage.fmtMoney` (dash-for-empty + multi-currency) and `ReportInternetCher.formatMoney` (`Number()` coercion).
- **`formatDateTimeShort`** swapped (aliased as `fmtTime`/`fmt`) into `ReportNegativeBillingPage`,
`ReportTerminatedActivePage`, `InterveneDialog` (all were `toLocaleString({dateStyle:'short',timeStyle:'short'})`).
- Each swap aliases the import to the original local name → **zero call-site/template changes**. `npx quasar build` → clean.
**Next safe P0 (same pattern, ready to continue):** `formatDateTimeShort` into `EquipmentDetail` (`fmtTs` direct; `evTime`
needs an `e.created_at` wrapper) + `RightPanel.fmtThreadDate`; add a `formatDateTimeMedium` for the `dateStyle:'medium'`
copies (`EmailQueuePage`, `CampaignsListPage`, `GiftsInventoryPage`); add `useDebounce` (MainLayout + useUserGroups×2).
Then the `statusColor` ×10 + `<StatusBadge>` work moves to P1.
**Deliberately deferred** (needs your eyes / a verify cycle): everything that changes pixels (responsive
primitives, token unification, dialog/table wrappers) and the mega-component splits. The design + order above
is ready to execute in safe batches when you're back to glance at each one.
> Deploy of P0 refactors is a one-liner once you've glanced: `cd apps/ops && npx quasar build && scp -r dist/spa/* root@96.125.196.67:/opt/ops-app/`.

View File

@ -24,6 +24,7 @@ def create_all():
_create_job_checklist_item()
_create_job_photo()
_create_checklist_template()
_create_dispatch_job_assistant()
_extend_dispatch_job()
_create_contract_benefit()
_create_service_contract()
@ -397,6 +398,33 @@ def _create_checklist_template():
print(" ✓ Checklist Template created.")
# ─────────────────────────────────────────────────────────────────────────────
# Dispatch Job Assistant — child table : équipe (lead + assistants) sur une tâche
# Déjà présent sur le site live (créé hors repo) — versionné ici pour reproductibilité.
# ─────────────────────────────────────────────────────────────────────────────
def _create_dispatch_job_assistant():
"""Child table for crew/assistants on a Dispatch Job (le lead = assigned_tech)."""
if frappe.db.exists("DocType", "Dispatch Job Assistant"):
print(" Dispatch Job Assistant already exists — skipping.")
return
doc = frappe.get_doc({
"doctype": "DocType", "name": "Dispatch Job Assistant",
"module": "Dispatch", "custom": 1, "istable": 1,
"fields": [
{"fieldname": "tech_id", "fieldtype": "Data", "label": "Technicien (id)", "in_list_view": 1},
{"fieldname": "tech_name", "fieldtype": "Data", "label": "Nom", "in_list_view": 1},
{"fieldname": "duration_h", "fieldtype": "Float", "label": "Durée (h)", "in_list_view": 1,
"description": "Temps de l'assistant sur la tâche (peut être < la durée totale)"},
{"fieldname": "note", "fieldtype": "Data", "label": "Rôle / note"},
{"fieldname": "pinned", "fieldtype": "Check", "label": "Épinglé", "default": "0",
"description": "Si coché : bloque la lane de l'assistant + compte dans sa charge (anti double-booking)"},
],
})
doc.insert(ignore_permissions=True)
print(" ✓ Dispatch Job Assistant created.")
# ─────────────────────────────────────────────────────────────────────────────
# Extend Dispatch Job — Add FSM fields to existing doctype
# ─────────────────────────────────────────────────────────────────────────────
@ -429,6 +457,10 @@ def _extend_dispatch_job():
{"dt": "Dispatch Job", "fieldname": "step_order", "fieldtype": "Int",
"label": "Ordre d'étape", "insert_after": "on_close_webhook",
"description": "Position dans la séquence du projet (1, 2, 3...)"},
# Équipe / assistants (le LEAD reste assigned_tech ; assistants = renfort)
{"dt": "Dispatch Job", "fieldname": "assistants", "fieldtype": "Table",
"label": "Assistants (équipe)", "options": "Dispatch Job Assistant", "insert_after": "step_order",
"description": "Techniciens en renfort sur la tâche ; le responsable reste « assigned_tech »"},
# Equipment
{"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break",
"label": "Équipements", "insert_after": "tags"},

View File

@ -0,0 +1,69 @@
"""
setup_supplier_invoice.py Doctype « Supplier Invoice Intake » (factures fournisseurs captées par OCR).
Récolte : courriels to:factures@ OCR (hub) un intake par pièce jointe revue dans OPS
création d'un Purchase Invoice DRAFT dans ERPNext (jamais soumis auto). L'intake est le registre
auditable/permissionné de la capture ; le Purchase Invoice reste le document comptable de référence.
Exécuter dans le conteneur bench :
docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute setup_supplier_invoice.create
"""
import frappe
def create():
if frappe.db.exists("DocType", "Supplier Invoice Intake"):
print(" Supplier Invoice Intake existe déjà — skip.")
return
doc = frappe.get_doc({
"doctype": "DocType",
"name": "Supplier Invoice Intake",
"module": "Buying",
"custom": 1,
"autoname": "SII-.#####",
"track_changes": 1,
"sort_field": "modified",
"sort_order": "DESC",
"fields": [
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
"options": "New\nCreated\nIgnored\nError", "default": "New",
"in_list_view": 1, "in_standard_filter": 1, "reqd": 1},
{"fieldname": "vendor", "fieldtype": "Data", "label": "Fournisseur (OCR)", "in_list_view": 1},
{"fieldname": "supplier", "fieldtype": "Link", "label": "Fournisseur (lié)", "options": "Supplier"},
{"fieldname": "invoice_number", "fieldtype": "Data", "label": "N° facture", "in_list_view": 1},
{"fieldname": "invoice_date", "fieldtype": "Date", "label": "Date facture"},
{"fieldname": "due_date", "fieldtype": "Date", "label": "Échéance"},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "currency", "fieldtype": "Data", "label": "Devise", "default": "CAD"},
{"fieldname": "subtotal", "fieldtype": "Currency", "label": "Sous-total"},
{"fieldname": "tax_gst", "fieldtype": "Currency", "label": "TPS/GST"},
{"fieldname": "tax_qst", "fieldtype": "Currency", "label": "TVQ/QST"},
{"fieldname": "total", "fieldtype": "Currency", "label": "Total", "in_list_view": 1},
{"fieldname": "sb_src", "fieldtype": "Section Break", "label": "Source"},
{"fieldname": "email_from", "fieldtype": "Data", "label": "Expéditeur"},
{"fieldname": "email_subject", "fieldtype": "Data", "label": "Sujet"},
{"fieldname": "email_date", "fieldtype": "Data", "label": "Date courriel"},
{"fieldname": "gmail_msg_id", "fieldtype": "Data", "label": "Gmail message id", "read_only": 1},
{"fieldname": "cb2", "fieldtype": "Column Break"},
{"fieldname": "attachment", "fieldtype": "Attach", "label": "Pièce jointe"},
{"fieldname": "attachment_filename", "fieldtype": "Data", "label": "Nom du fichier"},
{"fieldname": "sb_link", "fieldtype": "Section Break", "label": "OCR / Comptabilité"},
{"fieldname": "purchase_invoice", "fieldtype": "Link", "label": "Purchase Invoice", "options": "Purchase Invoice"},
{"fieldname": "ocr_error", "fieldtype": "Small Text", "label": "Erreur OCR"},
{"fieldname": "raw_ocr", "fieldtype": "Long Text", "label": "OCR brut (JSON)"},
],
"permissions": [
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1,
"report": 1, "export": 1, "email": 1, "print": 1, "share": 1},
{"role": "Accounts Manager", "read": 1, "write": 1, "create": 1, "delete": 1,
"report": 1, "export": 1, "email": 1, "print": 1, "share": 1},
{"role": "Accounts User", "read": 1, "write": 1, "create": 1, "report": 1, "print": 1},
],
})
doc.insert(ignore_permissions=True)
frappe.db.commit()
print("✓ Supplier Invoice Intake créé.")

View File

@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
Sync INCRÉMENTAL des factures F (gestionclient) ERPNext MIROIR OPÉRATIONNEL.
Insère les factures F absentes d'ERPNext (id > watermark) en REFLÉTANT leur statut F :
Sales Invoice + Sales Invoice Item (mêmes maps que import_invoices.py cohérence
exacte avec les ~629k déjà importées : client, SKUcompte produit, service_location).
docstatus = 1 (submitted), status = Paid/Unpaid/Overdue/Return d'après F,
outstanding_amount = max(0, total_amt billed_amt) (F = vérité du solde).
SINV-{legacy_invoice_id zfill10} (idempotent : saute si déjà présent).
PAS de GL Entry / PLE (ERPNext non autoritaire miroir opérationnel, scheduler en pause).
LÉGER SUR F : ne lit que `WHERE id > watermark` (PK indexée) sur F LIVE. Aucun scan complet.
DRY-RUN par défaut : n'écrit RIEN sans la variable d'env APPLY=1.
Lancer (depuis l'hôte prod qui joint F live 10.100.80.100 + le PG ERPNext) :
docker cp sync_invoices_incremental.py erpnext-backend-1:/tmp/sync_inv.py
docker exec -e APPLY=0 erpnext-backend-1 /home/frappe/frappe-bench/env/bin/python /tmp/sync_inv.py # aperçu
docker exec -e APPLY=1 erpnext-backend-1 /home/frappe/frappe-bench/env/bin/python /tmp/sync_inv.py # écrit
Options env : SINCE (force le plancher d'id), LIMIT (taille du lot, 0=tout).
"""
import os
import uuid
import pymysql
import psycopg2
from datetime import datetime, timezone
from html import unescape
# F LIVE (pas le miroir périmé) — lecture bornée par id seulement.
LEGACY = {"host": os.environ.get("LEGACY_HOST", "10.100.80.100"), "user": "facturation",
"password": os.environ.get("LEGACY_PW", "***LEGACY-DB-PW-REDACTED***"), "database": "gestionclient",
"connect_timeout": 30, "read_timeout": 600}
PG = {"host": os.environ.get("PG_HOST", "db"), "port": 5432, "user": "postgres",
"password": os.environ.get("PG_PW", "123"), "dbname": os.environ.get("PG_DB", "_eb65bdc0c4b1b2d6")}
ADMIN = "Administrator"
COMPANY = "TARGO"
APPLY = os.environ.get("APPLY", "0") == "1"
SINCE = int(os.environ.get("SINCE", "0"))
LIMIT = int(os.environ.get("LIMIT", "0")) # 0 = pas de limite
def uid(p=""): return p + uuid.uuid4().hex[:10]
def now(): return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
def today(): return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def ts_to_date(t):
if not t or t <= 0: return None
try: return datetime.fromtimestamp(int(t), tz=timezone.utc).strftime("%Y-%m-%d")
except Exception: return None
def clean(v): return unescape(str(v)).strip() if v else ""
def r2(v):
try: return round(float(v or 0), 2)
except Exception: return 0.0
def sinv_name(i): return "SINV-{:010d}".format(int(i))
def log(m): print(m, flush=True)
# F invoice → statut ERPNext reflétant F (= mapInvoice côté hub).
def map_status(total, billed, due, td):
is_return = total < 0
owed = 0.0 if is_return else r2(max(0.0, total - billed))
if is_return: status = "Return"
elif owed <= 0.005: status = "Paid"
elif due and due < td: status = "Overdue"
else: status = "Unpaid"
return is_return, owed, status
def main():
td = today()
ts = now()
log("=== Sync incrémental factures F→ERPNext (MIROIR OPÉRATIONNEL) ===")
log(" mode = {}".format("APPLY (écriture)" if APPLY else "DRY-RUN (aucune écriture)"))
pg = psycopg2.connect(**PG)
pgc = pg.cursor()
# watermark = max legacy_invoice_id déjà dans ERPNext (ou SINCE)
pgc.execute('SELECT COALESCE(MAX(legacy_invoice_id),0) FROM "tabSales Invoice" WHERE legacy_invoice_id > 0')
erp_max = int(pgc.fetchone()[0] or 0)
watermark = SINCE if SINCE > 0 else erp_max
log(" watermark (id F) = {}".format(watermark))
# maps ERPNext (mêmes que import_invoices.py)
pgc.execute('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id > 0')
cust_map = {r[0]: (r[1], r[2]) for r in pgc.fetchall()}
pgc.execute('SELECT item_code FROM "tabItem"')
valid_items = set(r[0] for r in pgc.fetchall())
pgc.execute('SELECT legacy_delivery_id, name FROM "tabService Location" WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id <> 0')
loc_by_delivery = dict(pgc.fetchall())
pgc.execute("SELECT name FROM \"tabAccount\" WHERE account_type='Receivable' AND company=%s AND is_group=0 LIMIT 1", (COMPANY,))
receivable = pgc.fetchone()[0]
pgc.execute("SELECT account_number, name FROM \"tabAccount\" WHERE root_type IN ('Income','Expense') AND company=%s AND is_group=0 AND account_number <> ''", (COMPANY,))
gl_by_number = {r[0]: r[1] for r in pgc.fetchall()}
pgc.execute("SELECT name FROM \"tabAccount\" WHERE root_type='Income' AND company=%s AND is_group=0 LIMIT 1", (COMPANY,))
income_default = pgc.fetchone()[0]
log(" maps: {} clients, {} items, {} locations, {} comptes produit".format(len(cust_map), len(valid_items), len(loc_by_delivery), len(gl_by_number)))
# ── SYNC PAR NUMÉRO INCRÉMENTIEL, GAP-AWARE & LÉGÈRE ──
# On ne fait PAS un simple `id > max` (qui ignorerait les TROUS sous le max :
# factures sautées « sans client » qui se résolvent plus tard, inserts ratés).
# On diffe les NUMÉROS sur une fenêtre récente (id seulement = ultra-léger),
# et on ne charge/insère QUE les manquants (nouveaux au-dessus du max + trous comblés).
WINDOW = int(os.environ.get("WINDOW", "20000")) # re-vérifie les ~20k derniers numéros
floor = watermark if SINCE > 0 else max(0, watermark - WINDOW)
mc = pymysql.connect(**LEGACY)
cur = mc.cursor(pymysql.cursors.DictCursor)
# 1) numéros F dans la fenêtre (PK indexée, id seul)
cur.execute("SELECT id FROM invoice WHERE id > %s", (floor,))
f_ids = [r["id"] for r in cur.fetchall()]
# 2) numéros déjà dans ERPNext dans la fenêtre
pgc.execute('SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE legacy_invoice_id > %s', (floor,))
erp_ids = set(int(r[0]) for r in pgc.fetchall())
# 3) manquants = à traiter
todo = sorted(i for i in f_ids if i not in erp_ids)
if LIMIT > 0: todo = todo[:LIMIT]
log(" fenêtre id>{} : F={} · ERP={} · à insérer (nouveaux + trous) = {}".format(floor, len(f_ids), len(erp_ids), len(todo)))
invoices = []
items_by_inv = {}
for s in range(0, len(todo), 5000):
batch = todo[s:s+5000]
if not batch: continue
ph = ",".join(["%s"]*len(batch))
cur.execute("SELECT id, account_id, date_orig, due_date, total_amt, billed_amt, billing_status FROM invoice WHERE id IN ({}) ORDER BY id".format(ph), batch)
invoices.extend(cur.fetchall())
cur.execute("SELECT ii.*, s.delivery_id AS service_delivery_id FROM invoice_item ii LEFT JOIN service s ON s.id=ii.service_id WHERE ii.invoice_id IN ({}) ORDER BY ii.invoice_id, ii.id".format(ph), batch)
for r in cur.fetchall():
items_by_inv.setdefault(r["invoice_id"], []).append(r)
# mapping SKU→compte produit (product → product_cat.num_compte)
cur.execute("SELECT p.sku, pc.num_compte FROM product p JOIN product_cat pc ON p.category=pc.id WHERE p.sku IS NOT NULL AND pc.num_compte IS NOT NULL")
sku_to_gl = {}
for r in cur.fetchall():
an = str(int(r["num_compte"])) if r["num_compte"] else None
if an and an in gl_by_number: sku_to_gl[r["sku"]] = gl_by_number[an]
mc.close()
existing = set() # `todo` exclut déjà les présents ; ON CONFLICT couvre toute course
by_status = {"Paid": 0, "Unpaid": 0, "Overdue": 0, "Return": 0}
ok = skip = no_cust = item_ok = 0
samples = []
for inv in invoices:
if inv["id"] in existing: skip += 1; continue
cd = cust_map.get(inv["account_id"])
if not cd: no_cust += 1; continue
cust_name, cust_disp = cd
total = r2(inv["total_amt"]); billed = r2(inv["billed_amt"])
posting = ts_to_date(inv["date_orig"]) or td
due = ts_to_date(inv["due_date"]) or posting
is_return, owed, status = map_status(total, billed, due, td)
by_status[status] += 1
name = sinv_name(inv["id"])
if len(samples) < 8:
samples.append({"sinv": name, "status": status, "total": total, "outstanding": owed})
if not APPLY:
ok += 1
item_ok += len(items_by_inv.get(inv["id"], []))
continue
try:
pgc.execute("""
INSERT INTO "tabSales Invoice" (
name, creation, modified, modified_by, owner, docstatus, idx,
naming_series, customer, customer_name, company,
posting_date, due_date, currency, conversion_rate,
selling_price_list, price_list_currency,
base_grand_total, grand_total, base_net_total, net_total, base_total, total,
outstanding_amount, base_rounded_total, rounded_total,
is_return, is_debit_note, disable_rounded_total,
debit_to, party_account_currency, status, legacy_invoice_id
) VALUES (%s,%s,%s,%s,%s,1,0,
'ACC-SINV-.YYYY.-',%s,%s,%s,
%s,%s,'CAD',1,
'Standard Selling','CAD',
%s,%s,%s,%s,%s,%s,
%s,%s,%s,
%s,0,1,
%s,'CAD',%s,%s)
ON CONFLICT (name) DO NOTHING
""", (name, ts, ts, ADMIN, ADMIN,
cust_name, cust_disp, COMPANY,
posting, due,
total, total, total, total, total, total,
owed, total, total,
1 if is_return else 0,
receivable, status, inv["id"]))
for j, li in enumerate(items_by_inv.get(inv["id"], [])):
sku = clean(li.get("sku")) or "MISC"
qty = float(li.get("quantity") or 1)
rate = float(li.get("unitary_price") or 0)
amount = round(qty * rate, 2)
desc = (clean(li.get("product_name")) or sku)[:140]
item_code = sku if sku in valid_items else None
income = sku_to_gl.get(sku, income_default)
sloc = loc_by_delivery.get(li.get("service_delivery_id"))
pgc.execute("""
INSERT INTO "tabSales Invoice Item" (
name, creation, modified, modified_by, owner, docstatus, idx,
item_code, item_name, description, qty, rate, amount,
base_rate, base_amount, base_net_rate, base_net_amount, net_rate, net_amount,
stock_uom, uom, conversion_factor, income_account, cost_center, service_location,
parent, parentfield, parenttype
) VALUES (%s,%s,%s,%s,%s,1,%s,
%s,%s,%s,%s,%s,%s,
%s,%s,%s,%s,%s,%s,
'Nos','Nos',1,%s,'Main - T',%s,
%s,'items','Sales Invoice')
ON CONFLICT (name) DO NOTHING
""", (uid("SII-"), ts, ts, ADMIN, ADMIN, j+1,
item_code, desc, desc, qty, rate, amount,
rate, amount, rate, amount, rate, amount,
income, sloc, name))
item_ok += 1
ok += 1
except Exception as e:
pg.rollback()
log(" ERR inv#{} -> {}".format(inv["id"], str(e)[:160]))
continue
if ok % 2000 == 0:
pg.commit(); log(" ... {} insérées".format(ok))
if APPLY: pg.commit()
pg.close()
log("")
log("="*60)
log("Statut cible (reflète F) : {}".format(by_status))
log("{} : {} factures, {} lignes, {} déjà présentes, {} sans client".format(
"INSÉRÉES" if APPLY else "À INSÉRER (dry-run)", ok, item_ok, skip, no_cust))
log("Échantillon : {}".format(samples))
log("="*60)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
Sync INCRÉMENTAL F ERPNext couche OPÉRATIONNELLE adresses + services.
Crée les enregistrements MANQUANTS (diff complet par clé legacy, petites tables léger) :
delivery Service Location (LOC-{legacy_delivery_id zfill10}) mapping migrate_locations.py
service (status=1) Service Subscription (SUB-{legacy_service_id zfill10}) mapping import_services_and_enrich_customers.py
NE crée PAS le doctype standard `Subscription` (moteur de facturation récurrente) : F est
autoritaire pour la facturation (scheduler ERPNext en pause) couche billing gérée à la bascule,
comme GL/PLE pour les factures. Ici = miroir OPÉRATIONNEL seulement (ce que les équipes voient).
Ordre : Service Location d'ABORD (un service requiert sa location + le client). Idempotent (ON CONFLICT).
DRY-RUN par défaut ; n'écrit RIEN sans APPLY=1. Options env : LIMIT (lot), PG_HOST.
Lancer :
docker cp sync_services_incremental.py erpnext-backend-1:/tmp/sync_svc.py
docker exec -e APPLY=0 -e PG_HOST=db erpnext-backend-1 /home/frappe/frappe-bench/env/bin/python /tmp/sync_svc.py
docker exec -e APPLY=1 -e PG_HOST=db erpnext-backend-1 /home/frappe/frappe-bench/env/bin/python /tmp/sync_svc.py
"""
import os
import re
import uuid
import pymysql
import psycopg2
from datetime import datetime, timezone
from html import unescape
LEGACY = {"host": os.environ.get("LEGACY_HOST", "10.100.80.100"), "user": "facturation",
"password": os.environ.get("LEGACY_PW", "***LEGACY-DB-PW-REDACTED***"), "database": "gestionclient",
"connect_timeout": 30, "read_timeout": 600}
PG = {"host": os.environ.get("PG_HOST", "db"), "port": 5432, "user": "postgres",
"password": os.environ.get("PG_PW", "123"), "dbname": os.environ.get("PG_DB", "_eb65bdc0c4b1b2d6")}
ADMIN = "Administrator"
APPLY = os.environ.get("APPLY", "0") == "1"
LIMIT = int(os.environ.get("LIMIT", "0"))
# ── mappings (identiques à la migration) ──
PROD_CAT_MAP = {4: "Internet", 32: "Internet", 8: "Internet", 26: "Internet", 29: "Internet",
7: "Internet", 23: "Internet", 17: "Internet", 16: "Internet", 21: "Internet",
33: "IPTV", 34: "IPTV", 9: "VoIP", 15: "Hébergement", 11: "Hébergement",
30: "Hébergement", 10: "Autre", 13: "Autre"}
RECUR_MAP = {1: "Mensuel", 2: "Mensuel", 3: "Trimestriel", 4: "Annuel"}
def now(): return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
def uid(p=""): return p + uuid.uuid4().hex[:10]
def clean(v): return unescape(str(v)).strip() if v else ""
def loc_name(i): return "LOC-{:010d}".format(int(i))
def sub_name(i): return "SUB-{:010d}".format(int(i))
def ts_date(t, dflt=None):
if not t or int(t) <= 0: return dflt
try: return datetime.fromtimestamp(int(t), tz=timezone.utc).strftime("%Y-%m-%d")
except Exception: return dflt
def log(m): print(m, flush=True)
def normalize_postal(pc): # "j0s1b0" → "J0S 1B0"
s = re.sub(r"\s+", "", clean(pc)).upper()
return s[:3] + " " + s[3:6] if len(s) >= 6 else clean(pc)
def clean_addr(raw, postal=None): # strip postal qui a fui dans address1 + espaces
a = clean(raw)
if postal:
pc = re.sub(r"\s+", "", postal).upper()
a = re.sub(r"\s*" + re.escape(pc[:3]) + r"\s*" + re.escape(pc[3:6]) + r"\s*$", "", a, flags=re.I)
a = re.sub(r"\s*" + re.escape(pc) + r"\s*$", "", a, flags=re.I)
return re.sub(r"\s+", " ", a).strip()
def svc_status(s, d): # règle canonique (= legacy-sync.svcStatus) : F service → statut SS
if int(s or 0) == 1:
return "Suspendu" if (d and int(d) > 0) else "Actif"
return "Annulé"
def main():
ts = now()
log("=== Sync incrémental adresses + services (OPÉRATIONNEL, sans Subscription billing) ===")
log(" mode = {}".format("APPLY" if APPLY else "DRY-RUN"))
pg = psycopg2.connect(**PG); pgc = pg.cursor()
mc = pymysql.connect(**LEGACY); cur = mc.cursor(pymysql.cursors.DictCursor)
# maps communs
pgc.execute('SELECT legacy_account_id, name FROM "tabCustomer" WHERE legacy_account_id>0')
cust_map = {int(r[0]): r[1] for r in pgc.fetchall()}
cur.execute("SELECT id, account_id FROM delivery"); del_acct = {r["id"]: r["account_id"] for r in cur.fetchall()}
# ═══ PHASE A — Service Location (delivery) ═══
cur.execute("SELECT id FROM delivery")
f_del = [r["id"] for r in cur.fetchall()]
pgc.execute('SELECT legacy_delivery_id FROM "tabService Location" WHERE legacy_delivery_id>0')
erp_loc = set(int(r[0]) for r in pgc.fetchall())
miss_loc = sorted(i for i in f_del if i not in erp_loc)
if LIMIT: miss_loc = miss_loc[:LIMIT]
log(" Adresses : F={} ERP={}{} à créer".format(len(f_del), len(erp_loc), len(miss_loc)))
loc_ok = loc_skip = 0; loc_samples = []
if miss_loc:
for s in range(0, len(miss_loc), 2000):
batch = miss_loc[s:s+2000]
cur.execute("SELECT id, account_id, name, address1, city, state, zip FROM delivery WHERE id IN ({})".format(",".join(["%s"]*len(batch))), batch)
for d in cur.fetchall():
postal = normalize_postal(d.get("zip"))
addr = clean_addr(d.get("address1"), postal)
city = clean_addr(d.get("city"))
lname = (clean(d.get("name")) or ("{}, {}".format(addr, city) if addr else "Location-{}".format(d["id"])))[:140]
cust = cust_map.get(d["account_id"])
if len(loc_samples) < 6: loc_samples.append({"loc": loc_name(d["id"]), "addr": addr, "city": city, "customer": cust})
if not APPLY: loc_ok += 1; continue
try:
pgc.execute("""
INSERT INTO "tabService Location" (
name, creation, modified, modified_by, owner, docstatus, idx,
customer, location_name, status, address_line, city, postal_code, province, legacy_delivery_id
) VALUES (%s,%s,%s,%s,%s,0,0, %s,%s,'Active',%s,%s,%s,%s,%s)
ON CONFLICT (name) DO NOTHING
""", (loc_name(d["id"]), ts, ts, ADMIN, ADMIN, cust, lname, addr, city, postal, clean(d.get("state")), d["id"]))
loc_ok += 1
except Exception as e:
loc_skip += 1; pg.rollback()
if loc_skip <= 5: log(" ERR loc#{} -> {}".format(d["id"], str(e)[:120]))
continue
if APPLY: pg.commit()
# recharge le map location pour la phase B
pgc.execute('SELECT legacy_delivery_id, name FROM "tabService Location" WHERE legacy_delivery_id>0')
loc_map = {int(r[0]): r[1] for r in pgc.fetchall()}
# ═══ PHASE B — Service Subscription (service status=1) ═══
cur.execute("SELECT id FROM service WHERE status=1")
f_svc = [r["id"] for r in cur.fetchall()]
pgc.execute('SELECT legacy_service_id FROM "tabService Subscription" WHERE legacy_service_id>0')
erp_sub = set(int(r[0]) for r in pgc.fetchall())
miss_svc = sorted(i for i in f_svc if i not in erp_sub)
if LIMIT: miss_svc = miss_svc[:LIMIT]
log(" Services : F actifs={} ERP={}{} à créer".format(len(f_svc), len(erp_sub), len(miss_svc)))
# product names FR
cur.execute("SELECT product_id, name AS prod_name FROM product_translate WHERE language_id='francais'")
prod_names = {r["product_id"]: r["prod_name"] for r in cur.fetchall()}
sub_ok = sub_noloc = sub_skip = 0; sub_samples = []
if miss_svc:
for s in range(0, len(miss_svc), 2000):
batch = miss_svc[s:s+2000]
cur.execute("""SELECT s.id, s.delivery_id, s.product_id, s.payment_recurrence, s.hijack, s.hijack_price,
s.hijack_download_speed, s.hijack_upload_speed, s.date_orig, s.date_end_contract,
s.radius_user, s.radius_pwd,
p.sku, p.price, p.download_speed, p.upload_speed, p.category AS prod_cat
FROM service s LEFT JOIN product p ON s.product_id=p.id
WHERE s.id IN ({})""".format(",".join(["%s"]*len(batch))), batch)
for svc in cur.fetchall():
sl = loc_map.get(svc["delivery_id"]) if svc["delivery_id"] else None
cust = cust_map.get(del_acct.get(svc["delivery_id"])) if svc["delivery_id"] else None
if not sl or not cust: sub_noloc += 1; continue # location + client requis (idem migration)
cat = PROD_CAT_MAP.get(svc["prod_cat"] or 0, "Autre")
plan = unescape(prod_names.get(svc["product_id"], svc.get("sku") or "Unknown"))[:140]
if svc["hijack"] and svc["hijack_download_speed"]:
sd, su = int(svc["hijack_download_speed"]) // 1024, int(svc["hijack_upload_speed"] or 0) // 1024
elif svc["download_speed"]:
sd, su = int(svc["download_speed"]) // 1024, int(svc["upload_speed"] or 0) // 1024
else: sd = su = 0
price = float(svc["hijack_price"] or 0) if svc["hijack"] else float(svc["price"] or 0)
cycle = RECUR_MAP.get(svc["payment_recurrence"], "Mensuel")
start = ts_date(svc["date_orig"], "2020-01-01"); end = ts_date(svc["date_end_contract"])
if len(sub_samples) < 6: sub_samples.append({"sub": sub_name(svc["id"]), "cat": cat, "plan": plan, "price": price, "cycle": cycle, "loc": sl})
if not APPLY: sub_ok += 1; continue
try:
pgc.execute("""
INSERT INTO "tabService Subscription" (
name, creation, modified, modified_by, owner, docstatus, idx,
customer, service_location, status, service_category, plan_name,
speed_down, speed_up, monthly_price, billing_cycle, start_date, end_date,
legacy_service_id, radius_user, radius_password, product_sku
) VALUES (%s,%s,%s,%s,%s,0,0, %s,%s,'Actif',%s,%s, %s,%s,%s,%s,%s,%s, %s,%s,%s,%s)
ON CONFLICT (name) DO NOTHING
""", (sub_name(svc["id"]), ts, ts, ADMIN, ADMIN, cust, sl, cat, plan,
sd, su, price, cycle, start, end,
svc["id"], clean(svc.get("radius_user")), clean(svc.get("radius_pwd")), clean(svc.get("sku"))))
sub_ok += 1
except Exception as e:
sub_skip += 1; pg.rollback()
if sub_skip <= 5: log(" ERR svc#{} -> {}".format(svc["id"], str(e)[:120]))
continue
if APPLY: pg.commit()
# ═══ PHASE C — rafraîchir le PRIX des SS existantes (F autoritaire ; opérationnel) ═══
# Les prix F (rabais/crédits/forfaits) bougent après la migration → le monthly_price figé
# de la SS diverge. On le réaligne sur F. (Statut Actif/Suspendu/Annulé = legacy-sync.syncServices.)
cur.execute("SELECT s.id, IF(s.hijack=1, COALESCE(s.hijack_price,0), COALESCE(p.price,0)) AS fprice FROM service s LEFT JOIN product p ON p.id=s.product_id WHERE s.status=1")
fprice = {r["id"]: round(float(r["fprice"]), 2) for r in cur.fetchall()}
pgc.execute('SELECT legacy_service_id, monthly_price FROM "tabService Subscription" WHERE legacy_service_id>0')
to_upd = []
for lid, mp in pgc.fetchall():
f = fprice.get(int(lid))
if f is None: continue
if abs(round(float(mp or 0), 2) - f) > 0.011: to_upd.append((f, int(lid)))
price_fixed = 0
log(" Prix SS divergents vs F : {}".format(len(to_upd)))
if APPLY and to_upd:
for f, lid in to_upd:
pgc.execute('UPDATE "tabService Subscription" SET monthly_price=%s, modified=%s WHERE legacy_service_id=%s AND ROUND(monthly_price::numeric,2)<>%s', (f, ts, lid, f))
pg.commit(); price_fixed = len(to_upd)
# ═══ PHASE D — rafraîchir le STATUT des SS (Actif/Suspendu/Annulé) depuis F ═══
# Reflète résiliations (status 1→0 = Annulé) + suspensions (date_suspended>0 = Suspendu) + réactivations.
# C'est la SOURCE du statut client (disabled dérivé des services — cf. feedback_customer_status_policy).
cur.execute("SELECT id, status, date_suspended FROM service")
want = {r["id"]: svc_status(r["status"], r["date_suspended"]) for r in cur.fetchall()}
pgc.execute('SELECT legacy_service_id, status FROM "tabService Subscription" WHERE legacy_service_id>0')
st_upd = []; trans = {}
for lid, st in pgc.fetchall():
w = want.get(int(lid))
if w and w != st:
st_upd.append((w, int(lid))); k = (st or "") + "" + w; trans[k] = trans.get(k, 0) + 1
status_fixed = 0
log(" Statut SS divergents vs F : {} {}".format(len(st_upd), trans))
if APPLY and st_upd:
for w, lid in st_upd:
pgc.execute('UPDATE "tabService Subscription" SET status=%s, modified=%s WHERE legacy_service_id=%s AND status<>%s', (w, ts, lid, w))
pg.commit(); status_fixed = len(st_upd)
mc.close(); pg.close()
log("")
log("=" * 60)
log("Statut SS réalignés sur F : {} {}".format(status_fixed if APPLY else len(st_upd), "corrigés" if APPLY else "à corriger (dry-run)"))
log("Prix SS réalignés sur F : {} {}".format(price_fixed if APPLY else len(to_upd), "corrigés" if APPLY else "à corriger (dry-run)"))
log("Adresses : {} {} | {} err".format(loc_ok, "créées" if APPLY else "à créer (dry-run)", loc_skip))
log(" échantillon: {}".format(loc_samples))
log("Services : {} {} | {} sans location/client | {} err".format(sub_ok, "créés" if APPLY else "à créer (dry-run)", sub_noloc, sub_skip))
log(" échantillon: {}".format(sub_samples))
log("=" * 60)
if __name__ == "__main__":
main()

View File

@ -62,6 +62,78 @@ if ($action === 'batch_close') {
out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]);
}
// ─── 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)
// action=changes_since entity=account|delivery|service, since=<epoch date_last>, since_id=<id>, [limit=1000]
// → lignes modifiées/nouvelles + max_date_last/max_id (prochain watermark) + has_more (pagination)
if ($action === 'ping') {
$r = $db->query("SELECT
(SELECT MAX(date_last) FROM account) AS max_date_last,
(SELECT COUNT(*) FROM account) AS accounts,
(SELECT MAX(id) FROM account) AS max_account_id,
(SELECT MAX(id) FROM delivery) AS max_delivery_id,
(SELECT MAX(id) FROM service) AS max_service_id,
UNIX_TIMESTAMP() AS server_time");
out(['ok' => true, 'action' => 'ping', 'f' => ($r ? $r->fetch_assoc() : null)]);
}
if ($action === 'changes_since') {
$entity = $_POST['entity'] ?? 'account';
$since = (int)($_POST['since'] ?? 0); // watermark date_last (epoch)
$sinceId = (int)($_POST['since_id'] ?? 0); // watermark id (inserts sans date de modif)
$limit = min(5000, max(1, (int)($_POST['limit'] ?? 1000)));
if ($entity === 'account') {
// incrémental (since>0) : date_last couvre create+update, 1 page large ; full (since=0) : keyset pur sur id
$wsql = $since > 0 ? "date_last > $since" : "id > $sinceId";
$q = $db->query("SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa, date_orig, date_last
FROM account WHERE $wsql ORDER BY id ASC LIMIT $limit");
} elseif ($entity === 'delivery') {
$q = $db->query("SELECT id, account_id, name, address1, address2, city, state, zip, longitude, latitude, tel_home, cell, email, date_orig
FROM delivery WHERE id > $sinceId ORDER BY id ASC LIMIT $limit");
} elseif ($entity === 'service') {
$q = $db->query("SELECT id, delivery_id, product_id, status, payment_recurrence, date_orig, date_suspended, date_next_invoice, date_end_contract, hijack_price, forfait_internet
FROM service WHERE id > $sinceId ORDER BY id ASC LIMIT $limit");
} else {
out(['ok' => false, 'error' => 'entity inconnue (account|delivery|service)'], 400);
}
if (!$q) out(['ok' => false, 'error' => 'query: ' . $db->error], 500);
$rows = []; $maxTs = $since; $maxId = $sinceId;
while ($row = $q->fetch_assoc()) {
$rows[] = $row;
if (isset($row['date_last'])) $maxTs = max($maxTs, (int)$row['date_last']);
$maxId = max($maxId, (int)$row['id']);
}
out(['ok' => true, 'action' => 'changes_since', 'entity' => $entity, 'count' => count($rows),
'max_date_last' => $maxTs, 'max_id' => $maxId, 'has_more' => count($rows) >= $limit, 'rows' => $rows]);
}
// Tickets OUVERTS assignés (terrain) dans une fenêtre de dates → timeline OPS (lecture F LIVE, pas le miroir).
if ($action === 'tickets_window') {
$lo = (int)($_POST['lo'] ?? 0); $hi = (int)($_POST['hi'] ?? 0);
$staff = preg_replace('/[^0-9,]/', '', (string)($_POST['staff'] ?? '')); // CSV d'ids staff (sanitized = anti-injection)
if ($staff === '' || $lo <= 0 || $hi <= 0) out(['ok' => false, 'error' => 'lo/hi/staff requis'], 400);
$q = $db->query("SELECT t.id, t.subject, t.assign_to, t.due_date, dd.name AS dept
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
WHERE t.status='open' AND t.assign_to IN ($staff) AND t.due_date BETWEEN $lo AND $hi");
if (!$q) out(['ok' => false, 'error' => 'query: ' . $db->error], 500);
$rows = []; while ($r = $q->fetch_assoc()) $rows[] = $r;
out(['ok' => true, 'action' => 'tickets_window', 'count' => count($rows), 'rows' => $rows]);
}
// État RÉEL d'une LISTE de tickets dans F (réconciliation OPS : aligner les Dispatch Job sur la vérité F).
// SELECT-only, aucune limite de statut/dept : on veut savoir si chaque ticket est encore ouvert ET à qui il est assigné.
if ($action === 'ticket_status') {
$ids = preg_replace('/[^0-9,]/', '', (string)($_POST['ids'] ?? '')); // CSV d'ids ticket (sanitized = anti-injection)
if ($ids === '') out(['ok' => true, 'action' => 'ticket_status', 'count' => 0, 'rows' => []]);
$q = $db->query("SELECT t.id, t.status, t.assign_to, t.due_date, dd.name AS dept, t.subject
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
WHERE t.id IN ($ids)");
if (!$q) out(['ok' => false, 'error' => 'query: ' . $db->error], 500);
$rows = []; while ($r = $q->fetch_assoc()) $rows[] = $r;
out(['ok' => true, 'action' => 'ticket_status', 'count' => count($rows), 'rows' => $rows]);
}
// ─── actions sur 1 ticket ───
$ticket = (int)($_POST['ticket_id'] ?? 0);
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400);

View File

@ -0,0 +1,71 @@
# Sync F → ERPNext — état au 2026-06-11 (session nocturne)
> **TL;DR** — Moteur de sync delta **construit + testé en lecture seule** sur tes vraies données.
> **AUCUNE écriture** n'a été faite (ni F, ni ERPNext). Aucun cron ajouté. Les déploiements
> sont **inertes** (nouveau module `lib/legacy-sync.js` + routes gated, qui ne tournent que sur appel).
> Décision attendue de toi avant toute écriture (voir §6).
## 1. Méthode de sync retenue (rappel)
- **Pas de proxy live** vers F (prod fragile). **Pas de réplication binlog** (F n'a pas `log-bin` → exigerait un redémarrage du MySQL de facturation).
- **Snapshot/delta pull** : le script existant `refresh-report-tables.sh` fait déjà un `mysqldump --single-transaction` (non bloquant, 1 connexion) des tables `account/delivery/service/product` → miroir local `legacy-db`. On lit TOUJOURS le miroir, jamais F live.
- **Détection de changement** confirmée : `account.date_last` est une **vraie date de modification** (86 % des comptes, bouge en temps réel) → watermark possible sans rien changer dans F. `service`/`delivery` = INSERT par `id` (pas de date de modif).
- **Écritures** ERPNext→F = via le **pont PHP** (`ops_reassign.php`, déjà utilisé pour le dispatch), idempotent par `legacy_account_id`. F jamais écrit en double.
- **Propriété par domaine** : F = facturation (tarif/facture/statut billing), ERPNext = ops (dispatch/RDV/notes). Aucun champ disputé.
## 2. Ce qui a été construit
- **`services/targo-hub/lib/legacy-sync.js`** (déployé, inerte) :
- `previewCustomers()` — lit le miroir + ERPNext, classe chaque écart.
- `runPreview()` — orchestre customers + locations + services, écrit le rapport complet dans `data/legacy-sync-preview.json`.
- `applySafeAdds({confirm})`**dry-run par défaut**, n'écrit QUE si `confirm==='SAFE-ADD'` (non exécuté).
- **Routes** (gated par le token hub, donc OPS-only) :
- `GET /legacy-sync/preview` → recalcule + renvoie le rapport.
- `GET /legacy-sync/report` → relit le dernier rapport.
- `POST /legacy-sync/apply-safe-adds`**dry-run** sauf `{ "confirm": "SAFE-ADD" }`.
## 3. Politique de sécurité (le cœur de « preview d'abord »)
Chaque écart de champ est classé :
- **ADD** — ERPNext vide + F a une valeur → **sûr** (on remplit).
- **CHANGE** — les deux ont une valeur différente → **révision humaine** (jamais auto-appliqué).
- **NEVER-CLOBBER** — F vide + ERPNext a une valeur → **jamais écrasé** (protège ex. les `stripe_id`).
- **`disabled`** — ERPNext le DÉRIVE des abonnements actifs (≠ `account.status`) → **jamais synchronisé**, juste rapporté comme « divergence de statut ».
## 4. Résultats du preview (réels, 2026-06-11)
**Customers** — F 15 771 / ERPNext 15 303 :
| Bucket | Nombre | Action |
|---|---|---|
| À créer (nouveaux comptes F) | **471** | nouveaux clients depuis la migration (id 15 429+) |
| safe_add (enrichir champs vides) | **8 119** | **sûr** — email 7 073, tél 8 786, cell 2 310, stripe 138 |
| needs_review (valeurs divergentes) | **921** | is_commercial 640, is_bad_payer 161, ppa 47, email 34, nom 21, langue 9 |
| inchangés | 6 260 | — |
| never-clobber (protégés) | 2 | 1 stripe_id + 1 cell (F vide) |
**Divergence de statut** (NON auto-synchronisée) : 713 actifs-F/désactivés-ERP + **3 502 inactifs-F/actifs-ERP** (probables churns qu'ERPNext montre encore actifs → à réviser).
**Locations** delivery→Service Location : 537 à créer (sur 17 645).
**Services ACTIFS** (status=1) service→Service Subscription : **3 580 à créer** (F 41 685 / ERPNext 39 628). *(Avec l'historique inactif c'était 30 469 — désormais filtré sur `status=1`.)*
### Écran de revue OPS (déployé)
Page **`/sync-legacy`** (« Sync F↔ERPNext » dans le menu, `requires: view_settings`) — `src/pages/LegacySyncPage.vue`. **Lecture seule** : appelle `GET /legacy-sync/preview` et affiche les buckets (à créer / à enrichir / à réviser), les ventilations par champ (ADD/CHANGE/never-clobber), la divergence de statut, et des échantillons. Aucun bouton d'écriture (l'apply reste CLI/gated tant que tu n'as pas donné le feu vert).
## 5. Comment l'utiliser
```bash
# Preview (lecture seule) :
docker exec targo-hub node -e "require('/app/lib/legacy-sync').runPreview().then(r=>console.log(JSON.stringify(r.customers.summary)))"
# Rapport complet :
cat /opt/targo-hub/data/legacy-sync-preview.json # (dans le conteneur : /app/data/…)
# Dry-run de l'apply (n'écrit RIEN) :
docker exec targo-hub node -e "require('/app/lib/legacy-sync').applySafeAdds({}).then(r=>console.log(JSON.stringify(r)))"
```
## 6. Décisions / prochaines étapes (À TON RÉVEIL)
1. **Feu vert pour `safe_add` ?** Remplir les 8 119 champs vides (email/tél) est sûr et réversible (PUT partiel). → lancer `applySafeAdds({confirm:'SAFE-ADD'})`. (Nuance : email/tél canoniques vivent sur le doctype **Contact** ; on remplit ici la copie `Customer.email_id`/`tel_home` pour recherche/affichage — le sync Contact complet est une étape ultérieure.)
2. **needs_review (921)** : surtout `is_commercial` (640) — vérifier le mapping (F.`commercial` vs ERPNext `is_commercial`) ; petit volume, révisable.
3. **Divergence de statut (3 502 inactifs-F/actifs-ERP)** : décider la règle (réconcilier ERPNext.disabled depuis F.status pour les terminés ?). C'est un signal de churn, pas un bug.
4. **Services** : filtrer `status=1` + mapper service→Service Subscription (+Subscription) proprement.
5. **Fraîcheur F→miroir** : planifier le delta (watermark `date_last`/`id`) — toujours pas de cron ajouté (en attente de ta décision de cadence).
6. **Suite du funnel** : Lead (doctype ERPNext) + wizard rep, puis push-to-F (#65), puis onboarding (flow), puis RDV (booking déjà construit).
## 7. Garanties de cette session
- ✅ Zéro écriture dans F. ✅ Zéro écriture dans ERPNext. ✅ Aucun cron/scheduler ajouté.
- ✅ Scheduler ERPNext reste en pause ; facturation F reste autoritaire.
- ✅ Déploiements = nouveau module + routes gated (n'altèrent aucun comportement existant).

View File

@ -425,27 +425,10 @@ function buildSystemPrompt () {
// Initialize on load
loadFlows()
// Répondeur SMS → routé par tâche 'sms' (PII + volume) via lib/ai.js (repli Gemini auto). Boucle tool-calls inchangée : on ré-emballe en {choices:[{message}]}.
const geminiChat = async (messages, tools) => {
const url = `${cfg.AI_BASE_URL}chat/completions`
const body = { model: cfg.AI_MODEL, max_tokens: 500, messages, tools }
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
body: JSON.stringify(body),
})
if (res.status === 429 && attempt < 2) {
const delay = (attempt + 1) * 2000
log(`Rate limited (429), retrying in ${delay}ms`)
await new Promise(r => setTimeout(r, delay))
continue
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Gemini API ${res.status}: ${text.substring(0, 200)}`)
}
return await res.json()
}
const msg = await require('./ai').chat({ task: 'sms', messages, tools, maxTokens: 500, wantMessage: true })
return { choices: [{ message: msg }] }
}
const runAgent = async (customerId, customerName, messageHistory, { token } = {}) => {

View File

@ -26,78 +26,97 @@
*/
const cfg = require('./config')
const { log, json, parseBody, erpFetch } = require('./helpers')
const { log, json, parseBody, erpFetch, readJsonFile, writeJsonFile } = require('./helpers')
// ── Core Gemini call (reuses same config as agent.js) ───────────────────────
async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3 } = {}) {
if (!cfg.AI_API_KEY) throw new Error('AI_API_KEY not configured')
const url = `${cfg.AI_BASE_URL}chat/completions`
// Model chain: primary → fallback (handles 503 overload gracefully)
const models = [cfg.AI_MODEL, cfg.AI_FALLBACK_MODEL].filter(Boolean)
for (const model of models) {
const body = {
model,
max_completion_tokens: maxTokens,
temperature,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
}
if (jsonMode) body.response_format = { type: 'json_object' }
let modelFailed = false
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
body: JSON.stringify(body),
})
if (res.status === 429 && attempt < 2) {
await new Promise(r => setTimeout(r, (attempt + 1) * 2000))
continue
}
if (res.status === 503) {
log(`AI: ${model} returned 503 (overloaded), attempt ${attempt + 1}/3`)
if (attempt < 2) {
await new Promise(r => setTimeout(r, (attempt + 1) * 3000))
continue
}
modelFailed = true
break
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`AI API ${res.status}: ${text.substring(0, 200)}`)
}
const data = await res.json()
const finish = data.choices?.[0]?.finish_reason
const content = data.choices?.[0]?.message?.content
if (!content) throw new Error('Empty AI response')
log(`AI: model=${data.model || model}, finish=${finish}, ${content.length} chars, tokens=${data.usage?.completion_tokens || '?'}/${maxTokens}`)
if (jsonMode) {
try { return JSON.parse(content) }
catch {
const match = content.match(/```(?:json)?\s*([\s\S]*?)```/)
if (match) {
try { return JSON.parse(match[1].trim()) }
catch { /* fall through */ }
}
return { raw: content.substring(0, 300), error: 'Failed to parse JSON' }
}
}
return content
}
if (modelFailed && models.indexOf(model) < models.length - 1) {
log(`AI: ${model} unavailable, falling back to ${models[models.indexOf(model) + 1]}`)
continue
}
async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3, task = 'dispatch' } = {}) {
// Délègue au routeur par tâche (profils + repli Gemini auto). Conserve le parsing JSON tolérant des appelants existants.
const content = await chat({ task, jsonMode, maxTokens, temperature, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }] })
if (!content) throw new Error('Empty AI response')
if (!jsonMode) return content
try { return JSON.parse(content) } catch {
const match = content.match(/```(?:json)?\s*([\s\S]*?)```/)
if (match) { try { return JSON.parse(match[1].trim()) } catch { /* fall through */ } }
return { raw: content.substring(0, 300), error: 'Failed to parse JSON' }
}
throw new Error('AI API: all models unavailable (503 overload). Try again shortly.')
}
// ── Routeur de modèle PAR TÂCHE ─────────────────────────────────────────────
// Le plan de CONTRÔLE reste déterministe (scripts) ; ici on ne route que le plan de JUGEMENT (classer/rédiger/suggérer).
// gemini = défaut cloud. local = vLLM/Ollama (QWEN/Hermes) — actif seulement si AI_LOCAL_BASE_URL+MODEL définis, sinon repli Gemini.
// Routage/local persistés (éditables depuis le panneau Ops) — le FICHIER prime sur l'env. La CLÉ reste en .env (jamais en UI).
const ROUTES_FILE = '/app/data/ai_routes.json'
function loadRoutesFile () { return readJsonFile(ROUTES_FILE, {}) }
function saveRoutesFile (obj) { return writeJsonFile(ROUTES_FILE, obj) }
function buildProfiles () {
const loc = (loadRoutesFile().local) || {}
return {
gemini: { name: 'gemini', baseUrl: cfg.AI_BASE_URL, model: cfg.AI_MODEL, fallbackModel: cfg.AI_FALLBACK_MODEL, key: cfg.AI_API_KEY, label: 'Gemini (cloud)' },
local: { name: 'local', baseUrl: loc.baseUrl || cfg.AI_LOCAL_BASE_URL, model: loc.model || cfg.AI_LOCAL_MODEL, fallbackModel: '', key: cfg.AI_LOCAL_API_KEY, label: 'Local (vLLM/QWEN)' },
}
}
const profileAvailable = (p) => !!(p && p.baseUrl && p.model && p.key)
// Intention de routage par tâche (surchargée par AI_ROUTES env PUIS par le fichier). Défaut: tout gemini.
const DEFAULT_ROUTES = { triage: 'gemini', nl: 'gemini', split: 'gemini', sms: 'gemini', roster: 'gemini', dispatch: 'gemini', diagnose: 'gemini', outage: 'gemini', default: 'gemini' }
function effectiveRoutes () { return { ...DEFAULT_ROUTES, ...(cfg.AI_ROUTES || {}), ...((loadRoutesFile().routes) || {}) } }
function routeFor (task) {
const profiles = buildProfiles()
let want = effectiveRoutes()[task] || 'gemini'
if (!profileAvailable(profiles[want])) want = 'gemini' // profil débranché → repli déterministe sur Gemini
return profiles[want] || profiles.gemini
}
// État du routage (diagnostic + panneau)
function routeStatus () {
const profiles = buildProfiles()
const tasks = Object.keys(DEFAULT_ROUTES).filter(t => t !== 'default')
const routes = effectiveRoutes()
const routing = {}
for (const t of tasks) routing[t] = { wanted: routes[t] || 'gemini', effective: routeFor(t).name } // wanted = choix ; effective = réel (repli si indispo)
return {
profiles: Object.values(profiles).map(p => ({ name: p.name, label: p.label, model: p.model || null, available: profileAvailable(p), key_set: !!p.key, base: p.baseUrl ? p.baseUrl.replace(/\/+$/, '') : null })),
tasks, routing, fallback: 'gemini', local_key_env: 'AI_LOCAL_API_KEY',
}
}
// Un appel chat OpenAI-compatible sur un PROFIL donné (un seul modèle), avec retries 429/503. Renvoie le message brut.
async function callProfileOnce (profile, model, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) {
const body = { model, max_tokens: maxTokens, temperature, messages } // max_tokens = le plus compatible (Gemini-compat + vLLM + Ollama)
if (jsonMode) body.response_format = { type: 'json_object' }
if (tools) body.tools = tools
if (reasoningEffort) body.reasoning_effort = reasoningEffort // gemini-2.5 : 'none' désactive le « thinking » (sinon ses jetons de réflexion mangent max_tokens → réponse tronquée sur un long contexte). Champ ignoré par les profils qui ne le connaissent pas.
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${profile.baseUrl}chat/completions`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${profile.key}` }, body: JSON.stringify(body),
})
if ((res.status === 429 || res.status === 503) && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 2000)); continue }
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`${profile.name} ${res.status}: ${t.slice(0, 160)}`) }
const data = await res.json()
const msg = data.choices?.[0]?.message
if (!msg) throw new Error('réponse IA vide')
msg._model = data.model || model
return msg
}
throw new Error(`${profile.name}: indisponible (429/503)`)
}
async function callProfile (profile, opts) {
const models = [profile.model, profile.fallbackModel].filter(Boolean)
let lastErr
for (const m of models) { try { return await callProfileOnce(profile, m, opts) } catch (e) { lastErr = e } }
throw lastErr || new Error(`${profile.name}: échec`)
}
// Point d'entrée message-based avec ROUTAGE + repli Gemini. Renvoie la string (ou le message complet si wantMessage, pour les tool-calls).
async function chat ({ task = 'default', messages, jsonMode = false, maxTokens = 700, temperature = 0.2, tools, wantMessage = false, reasoningEffort } = {}) {
const primary = routeFor(task)
if (!profileAvailable(primary)) throw new Error('AI non configurée')
let msg
try { msg = await callProfile(primary, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) }
catch (e) {
const gem = buildProfiles().gemini
if (primary.name !== 'gemini' && profileAvailable(gem)) { log(`AI[${task}] ${primary.name} échec (${e.message}) → repli gemini`); msg = await callProfile(gem, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) }
else throw e
}
return wantMessage ? msg : (msg.content || '')
}
// ── PII minimizer — strip sensitive fields before sending to AI ─────────────
@ -284,7 +303,7 @@ async function diagnose (symptoms, deviceData, customer) {
} : null,
customer: minimizeCustomer(customer),
})
return aiCall(DIAGNOSE_PROMPT, userPrompt)
return aiCall(DIAGNOSE_PROMPT, userPrompt, { task: 'diagnose' })
}
// ── Daily summary for tech ──────────────────────────────────────────────────
@ -427,7 +446,7 @@ async function analyzeOutage (serial) {
}
// Complex case — multiple customers affected or unclear cause → use AI
const result = await aiCall(OUTAGE_PROMPT, JSON.stringify(portSummary))
const result = await aiCall(OUTAGE_PROMPT, JSON.stringify(portSummary), { task: 'outage' })
result.port_summary = portSummary
result.ai_used = true
return result
@ -677,6 +696,40 @@ async function handle (req, res, method, urlPath) {
return json(res, 200, PRIVACY_INFO)
}
// GET /ai/route-status — profils de modèle + routage par tâche (diagnostic + panneau)
if (sub === 'route-status' && method === 'GET') {
return json(res, 200, routeStatus())
}
// POST /ai/route-test — ping un profil (petit appel réel) pour valider la connexion avant de router dessus.
if (sub === 'route-test' && method === 'POST') {
const b = await parseBody(req)
const profiles = buildProfiles()
const p = profiles[(b && b.profile) || 'local']
if (!p) return json(res, 400, { error: 'profil inconnu' })
if (!profileAvailable(p)) return json(res, 200, { ok: false, error: 'profil non configuré (base/modèle/clé manquants)' })
const t0 = Date.now()
try {
const msg = await callProfile(p, { messages: [{ role: 'user', content: 'ping' }], maxTokens: 5, temperature: 0 })
return json(res, 200, { ok: true, model: msg._model || p.model, latency_ms: Date.now() - t0, sample: (msg.content || '').slice(0, 40) })
} catch (e) { return json(res, 200, { ok: false, error: e.message, latency_ms: Date.now() - t0 }) }
}
// POST /ai/routes — enregistre le routage par tâche + base/modèle du profil local. La CLÉ reste en .env (AI_LOCAL_API_KEY).
if (sub === 'routes' && method === 'POST') {
try {
const b = await parseBody(req)
const cur = loadRoutesFile()
const routes = {}
if (b.routes && typeof b.routes === 'object') { for (const [t, p] of Object.entries(b.routes)) if (p === 'gemini' || p === 'local') routes[t] = p } // valide les valeurs
const next = {
routes: Object.keys(routes).length ? routes : (cur.routes || {}),
local: { baseUrl: (b.local && b.local.baseUrl != null) ? String(b.local.baseUrl).trim() : (cur.local && cur.local.baseUrl) || '', model: (b.local && b.local.model != null) ? String(b.local.model).trim() : (cur.local && cur.local.model) || '' },
}
if (!saveRoutesFile(next)) return json(res, 500, { error: 'écriture échouée' })
return json(res, 200, { ok: true, ...routeStatus() })
} catch (e) { return json(res, 500, { error: e.message }) }
}
return json(res, 404, { error: 'AI endpoint not found' })
}
@ -706,6 +759,9 @@ const PRIVACY_INFO = {
module.exports = {
handle,
aiCall,
chat,
routeFor,
routeStatus,
dispatchSuggest,
dispatchRedistribute,
dispatchNlp,

View File

@ -949,6 +949,7 @@ function giftExpiredPage (reason) {
expired: { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' },
revoked: { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' },
notfound: { fr: 'Lien introuvable.', en: 'Link not found.' },
blocked: { fr: 'Ce lien doit être ouvert depuis votre appareil personnel (téléphone ou connexion résidentielle).', en: 'Please open this link from your personal device (phone or home connection).' },
}
const m = messages[reason] || messages.notfound
return `<!doctype html><html lang="fr"><head><meta charset="utf-8">
@ -967,7 +968,7 @@ a{color:#00C853;text-decoration:none;font-weight:600}
</style></head><body>
<div class="card">
<div class="logo">TARGO</div>
<div class="icon">🎁</div>
<div class="icon">${reason === 'blocked' ? '🔒' : '🎁'}</div>
<h1>${m.fr}</h1>
<p>Si tu penses qu'il s'agit d'une erreur, écris-nous à <a href="mailto:support@targo.ca">support@targo.ca</a>.</p>
<p class="en">${m.en}</p>
@ -1372,6 +1373,9 @@ function applyWebhookEvent (ev) {
// ── HTTP routing ─────────────────────────────────────────────────────────────
async function handle (req, res, method, path) {
// NOTE: auth is enforced centrally in server.js (hub auth gate): /campaigns
// is ALWAYS_ENFORCE (Bearer HUB_SERVICE_TOKEN) except the public
// POST /campaigns/webhook. No per-module gate needed here.
// POST /campaigns/parse — preview matched send list (no save)
// Returns:
// - recipients[]: contacts paired with a gift_url (ready to send)
@ -2325,6 +2329,25 @@ async function handleGiftRedirect (req, res, urlPath) {
if (r.gift_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired')
if (!r.gift_url) return respondExpired('notfound')
// ── Anti-bot : journalise l'ouverture (IP + heure) et BLOQUE les IP cloud/datacenter (AWS, GCP…). ──
// Les vrais destinataires ouvrent depuis du résidentiel/mobile ; les bots/proxies viennent de datacenters
// (cf. redemptions observées depuis 35.94.x / 35.91.x = AWS). Tunable via GIFT_BLOCK_CLOUD (défaut on).
const cloudips = require('./cloud-ips'); cloudips.init()
const _ips = cloudips.clientIps(req)
let _cloud = null
for (const ip of _ips) { const c = cloudips.isCloudIp(ip); if (c.cloud) { _cloud = { ip, provider: c.provider }; break } }
r.gift_opens = Array.isArray(r.gift_opens) ? r.gift_opens : []
r.gift_opens.push({ at: new Date().toISOString(), ips: _ips, blocked: _cloud ? _cloud.provider : '' })
if (r.gift_opens.length > 40) r.gift_opens = r.gift_opens.slice(-40)
if (_cloud && /^(on|1|true)$/i.test(process.env.GIFT_BLOCK_CLOUD || 'on')) {
r.gift_blocked_count = (r.gift_blocked_count || 0) + 1
try { saveCampaign(hit.campaign) } catch (e) {}
log(`[gift] BLOQUÉ ${_cloud.provider} ${_cloud.ip} — token ${token} (campagne ${hit.campaign.id})`)
try { sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r }) } catch (e) {}
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' })
return res.end(giftExpiredPage('blocked'))
}
// Successful redirect — record analytics, persist async (no await; the
// recipient is already on their way to Giftbit's page).
r.gift_redirected_count = (r.gift_redirected_count || 0) + 1

View File

@ -0,0 +1,138 @@
'use strict'
// ── Diagnostic CLIENT en libre-service via lien tokenisé ──────────────────────────────────────────
// On envoie au client un lien signé /diag/<jwt> (scope 'customer'). Il ouvre une page mobile qui montre,
// EN DIRECT, ce qui fonctionne (Internet, fibre, Wi-Fi…) — sans aucune donnée interne (série, IP, OLT, dBm).
// Réutilise verifyJwt (magic-link) + serviceStatus (ticket-collab). Route PUBLIQUE : le token EST le secret.
const cfg = require('./config')
const { log } = require('./helpers')
const { verifyJwt } = require('./magic-link')
function sendHtml (res, code, html) { res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(html) }
function sendJson (res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)) }
// Transforme le statut interne en vue CLIENT (aucune fuite : pas de série, MAC, IP, OLT, dBm).
function clientView (s, payload) {
const sum = (s && s.summary) || {}
// Vue CLIENT : pilotée par « en ligne » (sur FTTH, si on est en ligne, la fibre est up). Le détail fibre/OLT
// reste côté agent — un poller OLT en panne chez NOUS ne doit jamais afficher « problème » à un client servi.
const online = sum.online === true
const tel = (s && s.subscriptions ? s.subscriptions : []).some(x => /t[ée]l[ée]phon|voip|sip/i.test(String(x.plan || '')))
const checks = [
{ key: 'internet', label: 'Internet', ok: online },
{ key: 'wifi', label: 'Wi-Fi', ok: online },
]
if (tel) checks.push({ key: 'tel', label: 'Téléphonie', ok: online })
return {
name: (payload && payload.name) || (s && s.customer && s.customer.customer_name) || '',
allOk: checks.every(c => c.ok),
online,
checks,
plans: (s && s.subscriptions ? s.subscriptions : []).map(x => x.plan).filter(Boolean).slice(0, 4),
address: s && s.location ? (s.location.address || '') : '',
ts: new Date().toISOString(),
}
}
const ESC = (t) => String(t == null ? '' : t).replace(/[<&>"]/g, c => ({ '<': '&lt;', '&': '&amp;', '>': '&gt;', '"': '&quot;' }[c]))
function expiredPage () {
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lien expiré</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#f1f5f9;color:#1e293b;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}.c{background:#fff;border-radius:16px;padding:28px;max-width:380px;text-align:center;box-shadow:0 8px 30px rgba(0,0,0,.08)}</style>
</head><body><div class="c"><h2 style="color:#0e7490">Gigafibre</h2><p>Ce lien de diagnostic a expiré ou n'est pas valide.</p><p style="color:#64748b;font-size:14px">Répondez à notre message et nous vous en enverrons un nouveau.</p></div></body></html>`
}
function page (token) {
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Diagnostic Gigafibre</title>
<style>
:root{--ok:#16a34a;--bad:#dc2626;--ink:#0f172a;--mut:#64748b;--bg:#f1f5f9;--teal:#0e7490}
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:var(--bg);color:var(--ink);margin:0;padding:18px}
.wrap{max-width:440px;margin:0 auto}
.brand{color:var(--teal);font-weight:700;font-size:20px;text-align:center;margin:6px 0 2px}
.sub{color:var(--mut);font-size:13px;text-align:center;margin-bottom:16px}
.hero{border-radius:16px;padding:22px;text-align:center;color:#fff;margin-bottom:14px}
.hero.ok{background:#16a34a}.hero.bad{background:#dc2626}.hero.wait{background:#64748b}
.hero .big{font-size:20px;font-weight:600}.hero .em{font-size:40px;line-height:1}
.card{background:#fff;border-radius:14px;padding:6px 14px;box-shadow:0 4px 16px rgba(0,0,0,.06)}
.row{display:flex;align-items:center;padding:13px 2px;border-bottom:1px solid #f1f5f9}.row:last-child{border-bottom:none}
.row .lbl{flex:1;font-size:16px}.dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;font-weight:700}
.dot.ok{background:var(--ok)}.dot.bad{background:var(--bad)}
.plans{color:var(--mut);font-size:13px;text-align:center;margin-top:12px}
.btn{display:block;width:100%;margin-top:16px;padding:13px;border:0;border-radius:12px;background:#fff;color:var(--teal);font-size:15px;font-weight:600;box-shadow:0 2px 10px rgba(0,0,0,.06)}
.foot{color:var(--mut);font-size:12px;text-align:center;margin-top:16px;line-height:1.6}
.skel{height:14px;background:#e2e8f0;border-radius:6px;margin:8px 0;animation:p 1.2s infinite}@keyframes p{50%{opacity:.5}}
.chatbox{background:#fff;border-radius:14px;padding:10px;max-height:280px;overflow-y:auto;box-shadow:0 4px 16px rgba(0,0,0,.06);margin-top:8px}
.msg{padding:9px 12px;border-radius:14px;margin:6px 0;font-size:15px;max-width:85%;line-height:1.4;white-space:pre-wrap}
.msg.me{background:#0e7490;color:#fff;margin-left:auto;border-bottom-right-radius:4px}
.msg.ai{background:#f1f5f9;color:#0f172a;border-bottom-left-radius:4px}
.chatrow{display:flex;gap:8px;margin-top:8px}
.chatrow input{flex:1;padding:12px;border:1px solid #cbd5e1;border-radius:12px;font-size:15px}
.chatrow button{padding:12px 16px;border:0;border-radius:12px;background:#0e7490;color:#fff;font-weight:600}
.typing{font-size:13px;color:#64748b;padding:4px 6px}
</style></head><body><div class="wrap">
<div class="brand">Gigafibre</div><div class="sub" id="sub">Diagnostic de votre service</div>
<div id="hero" class="hero wait"><div class="em"></div><div class="big">Vérification en cours</div></div>
<div class="card" id="checks"><div class="skel" style="width:80%"></div><div class="skel" style="width:60%"></div><div class="skel" style="width:70%"></div></div>
<div class="plans" id="plans"></div>
<button class="btn" id="refresh" onclick="run()">Revérifier</button>
<button class="btn" id="chatToggle" onclick="openChat()">Une question ? Discuter avec l'assistant</button>
<div id="chat" style="display:none">
<div class="chatbox" id="chatbox"></div>
<div class="chatrow"><input id="chatinp" placeholder="Décrivez votre problème…" autocomplete="off" onkeydown="if(event.key==='Enter'){event.preventDefault();sendChat()}"><button onclick="sendChat()">Envoyer</button></div>
</div>
<div class="foot">Toujours un souci ? Discutez avec l'assistant ci-dessus un agent prend le relais au besoin.<br>© Gigafibre · TARGO</div>
</div><script>
var TOKEN=${JSON.stringify(token)};
function run(){
var hero=document.getElementById('hero');hero.className='hero wait';hero.innerHTML='<div class="em">⏳</div><div class="big">Vérification en cours…</div>';
fetch('/diag/'+TOKEN+'/data',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(d.error){document.getElementById('sub').textContent='Lien expiré';hero.className='hero wait';hero.innerHTML='<div class="big">Lien expiré</div>';return}
if(d.name)document.getElementById('sub').textContent='Bonjour '+d.name+(d.address?' · '+d.address:'');
hero.className='hero '+(d.allOk?'ok':'bad');
hero.innerHTML='<div class="em">'+(d.allOk?'✓':'!')+'</div><div class="big">'+(d.allOk?'Tout fonctionne':'Un problème est détecté')+'</div>';
var html='';(d.checks||[]).forEach(function(c){html+='<div class="row"><span class="lbl">'+c.label+'</span><span class="dot '+(c.ok?'ok':'bad')+'">'+(c.ok?'✓':'×')+'</span></div>'});
document.getElementById('checks').innerHTML=html;
document.getElementById('plans').textContent=(d.plans&&d.plans.length)?('Forfait : '+d.plans.join(' · ')):'';
}).catch(function(){hero.className='hero wait';hero.innerHTML='<div class="big">Réessayez dans un instant</div>'});
}
run();
var CONV=null,ES=null;
function openChat(){document.getElementById('chat').style.display='block';document.getElementById('chatToggle').style.display='none';document.getElementById('chatinp').focus()}
function addMsg(who,text){var b=document.getElementById('chatbox');var d=document.createElement('div');d.className='msg '+who;d.textContent=text;b.appendChild(d);b.scrollTop=b.scrollHeight}
function connect(conv){CONV=conv;try{ES=new EventSource('/conversations/'+conv+'/sse');ES.addEventListener('conv-message',function(e){try{var m=JSON.parse(e.data).message;if(m&&m.from!=='customer'&&m.text)addMsg('ai',m.text)}catch(x){}})}catch(x){}}
function sendChat(){var inp=document.getElementById('chatinp');var t=(inp.value||'').trim();if(!t)return;inp.value='';addMsg('me',t);
if(!CONV){fetch('/diag/'+TOKEN+'/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:t})}).then(function(r){return r.json()}).then(function(d){if(d.convToken)connect(d.convToken);else addMsg('ai','Désolé, réessayez dans un instant.')}).catch(function(){addMsg('ai','Connexion interrompue, réessayez.')})}
else{fetch('/conversations/'+CONV+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:t})})}
}
</script></body></html>`
}
async function route (req, res, method, path) {
const m = path.match(/^\/diag\/([A-Za-z0-9._-]+?)(\/data|\/chat)?$/)
if (!m) return sendHtml(res, 404, '<h1>404</h1>')
const token = m[1]; const sub = m[2] || ''
const payload = verifyJwt(token)
if (!payload || payload.scope !== 'customer' || !payload.sub) {
return sub ? sendJson(res, 401, { error: 'expired' }) : sendHtml(res, 200, expiredPage())
}
// CHAT : 1er message du client → crée une conversation (liée au compte) + déclenche l'IA (même moteur que le SMS/web-chat).
// L'IA a l'outil check_device_status → réponses diagnostiques ; la conversation remonte dans l'Inbox agent (escalade).
if (sub === '/chat' && method === 'POST') {
let body = ''; await new Promise(r => { req.on('data', c => { body += c }); req.on('end', r); req.on('error', r) })
let text = ''; try { text = String((JSON.parse(body || '{}')).text || '').trim() } catch (e) { /* */ }
if (!text) return sendJson(res, 400, { error: 'text requis' })
const conv = require('./conversation')
const c = conv.createConversation({ phone: null, customer: payload.sub, customerName: payload.name || payload.sub, subject: 'Diagnostic en ligne — ' + (payload.name || payload.sub) })
c.channel = 'webchat'; c.channelKey = 'diag:' + payload.sub; c.queue = 'Supports'; if (payload.email) c.email = payload.email // escalade fiable vers l'Inbox agent
conv.addMessage(c, { from: 'customer', text, via: 'webchat' })
try { conv.triggerAgent(c) } catch (e) { /* */ }
log('client-diag chat: conv ' + c.token + ' (' + payload.sub + ')')
return sendJson(res, 200, { ok: true, convToken: c.token })
}
if (method !== 'GET') return sendHtml(res, 404, '<h1>404</h1>')
if (sub === '/data') {
try { const s = await require('./ticket-collab').serviceStatus({ customer: payload.sub }); return sendJson(res, 200, clientView(s, payload)) } catch (e) { log('client-diag data ' + payload.sub + ': ' + e.message); return sendJson(res, 500, { error: 'indisponible' }) }
}
return sendHtml(res, 200, page(token))
}
module.exports = { route, clientView }

View File

@ -0,0 +1,83 @@
// lib/cloud-ips.js — détecte si une IP appartient à un fournisseur cloud/datacenter (AWS, GCP…).
// Sert à BLOQUER les ouvertures de liens-cadeaux depuis des IP de datacenter (bots/proxies) au wrapper /g/.
// Charge les plages PUBLIÉES (AWS ip-ranges.json, GCP cloud.json) en mémoire + rafraîchit 1×/jour.
// Fail-open : tant que les plages ne sont pas chargées (ou en cas d'erreur), isCloudIp renvoie {cloud:false} → on NE bloque pas par défaut.
const https = require('https')
let V4 = [] // [{base:int, bits, provider}]
let V6 = [] // [{base:BigInt, bits, provider}]
let _loadedAt = 0
// ── Parsing IP ──
function ip4ToInt (ip) { const p = String(ip).split('.'); if (p.length !== 4) return null; let n = 0; for (const o of p) { const x = Number(o); if (!Number.isInteger(x) || x < 0 || x > 255) return null; n = (n * 256) + x } return n >>> 0 }
function ip6ToBig (ip) {
ip = String(ip).split('%')[0].toLowerCase()
if (ip.indexOf(':') < 0) return null
let head, tail
if (ip.indexOf('::') >= 0) { const parts = ip.split('::'); head = parts[0] ? parts[0].split(':') : []; tail = parts[1] ? parts[1].split(':') : [] } else { head = ip.split(':'); tail = [] }
const expand = (arr) => { const out = []; for (const g of arr) { if (g.indexOf('.') >= 0) { const q = g.split('.').map(Number); if (q.length === 4 && q.every(x => x >= 0 && x <= 255)) { out.push((((q[0] << 8) | q[1]) >>> 0).toString(16)); out.push((((q[2] << 8) | q[3]) >>> 0).toString(16)) } } else if (g !== '') out.push(g) } return out }
head = expand(head); tail = expand(tail)
const mid = 8 - (head.length + tail.length); if (mid < 0) return null
const groups = head.concat(Array(mid).fill('0'), tail)
let big = 0n
for (const g of groups) { const v = parseInt(g || '0', 16); if (isNaN(v)) return null; big = (big << 16n) | BigInt(v) }
return big
}
function parseCidr (cidr, provider) {
const [addr, bitsRaw] = String(cidr).split('/'); const bits = parseInt(bitsRaw, 10)
if (addr.indexOf(':') >= 0) { const base = ip6ToBig(addr); if (base == null || isNaN(bits)) return; V6.push({ base, bits, provider }) } else { const base = ip4ToInt(addr); if (base == null || isNaN(bits)) return; V4.push({ base, bits, provider }) }
}
// ── Détection ──
function isCloudIp (ip) {
if (!ip) return { cloud: false }
ip = String(ip).trim().replace(/^::ffff:/i, '') // IPv4-mapped IPv6 → IPv4
if (ip.indexOf(':') < 0) {
const n = ip4ToInt(ip); if (n == null) return { cloud: false }
for (const r of V4) { const mask = r.bits === 0 ? 0 : (0xFFFFFFFF << (32 - r.bits)) >>> 0; if ((n & mask) >>> 0 === (r.base & mask) >>> 0) return { cloud: true, provider: r.provider } }
return { cloud: false }
}
const big = ip6ToBig(ip); if (big == null) return { cloud: false }
for (const r of V6) { const sh = 128n - BigInt(r.bits); if ((big >> sh) === (r.base >> sh)) return { cloud: true, provider: r.provider } }
return { cloud: false }
}
// IP(s) candidates d'une requête derrière Traefik : toute la chaîne X-Forwarded-For + l'adresse socket.
function clientIps (req) {
const out = []
const xff = req.headers['x-forwarded-for']
if (xff) for (const p of String(xff).split(',')) { const ip = p.trim(); if (ip) out.push(ip) }
const xr = req.headers['x-real-ip']; if (xr) out.push(String(xr).trim())
const ra = req.socket && req.socket.remoteAddress; if (ra) out.push(String(ra).trim())
return out.filter((v, i, a) => v && a.indexOf(v) === i)
}
// ── Chargement des plages publiées ──
function fetchJson (url) {
return new Promise((resolve) => {
https.get(url, { headers: { 'User-Agent': 'targo-hub/cloud-ips' }, timeout: 20000 }, (r) => {
if (r.statusCode !== 200) { r.resume(); return resolve(null) }
let b = ''; r.on('data', c => { b += c }); r.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { resolve(null) } })
}).on('error', () => resolve(null)).on('timeout', function () { this.destroy(); resolve(null) })
})
}
async function load () {
const v4 = [], v6 = []
const push = (cidr, prov) => { const [a, bRaw] = String(cidr).split('/'); const bits = parseInt(bRaw, 10); if (isNaN(bits)) return; if (a.indexOf(':') >= 0) { const base = ip6ToBig(a); if (base != null) v6.push({ base, bits, provider: prov }) } else { const base = ip4ToInt(a); if (base != null) v4.push({ base, bits, provider: prov }) } }
const aws = await fetchJson('https://ip-ranges.amazonaws.com/ip-ranges.json')
if (aws) { for (const p of (aws.prefixes || [])) push(p.ip_prefix, 'AWS'); for (const p of (aws.ipv6_prefixes || [])) push(p.ipv6_prefix, 'AWS') }
const gcp = await fetchJson('https://www.gstatic.com/ipranges/cloud.json')
if (gcp) { for (const p of (gcp.prefixes || [])) { if (p.ipv4Prefix) push(p.ipv4Prefix, 'GCP'); if (p.ipv6Prefix) push(p.ipv6Prefix, 'GCP') } }
// Plages supplémentaires (datacenters fréquents) — ajout manuel léger.
for (const c of EXTRA_V4) push(c, 'DC')
if (v4.length + v6.length > 100) { V4 = v4; V6 = v6; _loadedAt = Date.now() } // ne remplace que si le chargement a vraiment ramené des plages
return { v4: v4.length, v6: v6.length, loaded: !!(v4.length + v6.length) }
}
// DigitalOcean / OVH / Hetzner / Oracle (échantillon ; étendre au besoin).
const EXTRA_V4 = ['159.203.0.0/16', '167.71.0.0/16', '134.209.0.0/16', '157.245.0.0/16', '144.217.0.0/16', '51.222.0.0/16', '5.9.0.0/16', '88.198.0.0/16', '129.213.0.0/16']
let _initPromise = null
function init () { if (!_initPromise) { _initPromise = load().catch(() => ({ loaded: false })); const t = setInterval(() => { load().catch(() => {}) }, 24 * 3600 * 1000); if (t && t.unref) t.unref() } return _initPromise }
function stats () { return { v4: V4.length, v6: V6.length, loadedAt: _loadedAt } }
module.exports = { init, isCloudIp, clientIps, stats, ip4ToInt, ip6ToBig }

View File

@ -39,6 +39,12 @@ module.exports = {
AI_MODEL: env('AI_MODEL', 'gemini-2.5-flash'),
AI_FALLBACK_MODEL: env('AI_FALLBACK_MODEL', 'gemini-2.5-flash-lite-preview'),
AI_BASE_URL: env('AI_BASE_URL', 'https://generativelanguage.googleapis.com/v1beta/openai/'),
// Modèle LOCAL (vLLM/Ollama → QWEN/Hermes), OpenAI-compatible. Vide = profil « débranché » (repli auto sur Gemini).
AI_LOCAL_BASE_URL: env('AI_LOCAL_BASE_URL', ''), // ex. http://gpu-host:8000/v1/ (slash final requis)
AI_LOCAL_MODEL: env('AI_LOCAL_MODEL', ''), // ex. Qwen/Qwen2.5-14B-Instruct
AI_LOCAL_API_KEY: env('AI_LOCAL_API_KEY', ''), // VIDE par défaut = profil local débranché. Définir (même factice pour vLLM sans auth) = activation explicite.
// Routage tâche→profil (JSON). Ex. {"triage":"local","sms":"local"} pour basculer ces tâches vers le local. Vide = défauts (tout Gemini).
AI_ROUTES: (() => { try { return JSON.parse(env('AI_ROUTES', '') || '{}') } catch (e) { return {} } })(),
VOICE_MODEL: env('VOICE_MODEL', 'models/gemini-2.5-flash-live-preview'),
OLT_HOST: env('OLT_HOST'),
OLT_COMMUNITY: env('OLT_COMMUNITY', 'public'),

View File

@ -0,0 +1,92 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// coupon-triage.js — Levier 1 : qu'une demande de coupons hotspot ne « dorme » plus.
// Courriel reçu → détecte l'intention → RÉSOUT la ferme par le COURRIEL de l'expéditeur
// (pas le nom : ex. Chantaltrottier67@gmail.com → Jardins Lefort Inc) → crée un ticket
// suivi (file support) → ALERTE (n8n → Google Chat). Zéro dépendance à F.
// L'ÉMISSION des coupons (camping_customer_batch) reste manuelle/F pour l'instant (Levier 2).
// ─────────────────────────────────────────────────────────────────────────────
const https = require('https')
const { json, log, parseBody, lookupCustomersByEmail } = require('./helpers')
const COUPON_RX = /coupon|hotspot|code\s*d['e]?\s*acc[èe]s|acc[èe]s\s*wifi|wifi\s*(temporaire|invit)|forfait\s*camping|travailleur|prépay|prepaid|carte\s*temps/i
const QTY_RX = /(\d{1,3})\s*(coupons?|codes?|acc[èe]s|travailleurs?|cartes?|forfaits?)/i
// Détecte l'intention « coupons » + une quantité éventuelle dans le sujet/corps.
function detectCoupon (subject, body) {
const txt = `${subject || ''}\n${body || ''}`
const m = txt.match(QTY_RX)
return { isCoupon: COUPON_RX.test(txt), qty: m ? parseInt(m[1], 10) : null }
}
// Alerte vers n8n (qui poste dans Google Chat) ou un webhook Google Chat direct.
// Configurable : N8N_COUPON_WEBHOOK ou GCHAT_WEBHOOK. Sans config → loggé + sauté (pas d'échec).
async function alertWebhook (payload) {
const url = process.env.N8N_COUPON_WEBHOOK || process.env.GCHAT_WEBHOOK || ''
if (!url) { log('coupon-triage: aucun webhook (N8N_COUPON_WEBHOOK/GCHAT_WEBHOOK) — alerte sautée'); return { alerted: false, reason: 'no_webhook' } }
const body = JSON.stringify({ text: payload.text, ...payload }) // Google Chat lit {text} ; n8n lit tout
return new Promise((resolve) => {
try {
const u = new URL(url)
const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', timeout: 8000, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ alerted: res.statusCode < 400, status: res.statusCode })) })
req.on('error', e => { log('coupon alert err: ' + e.message); resolve({ alerted: false, error: e.message }) })
req.on('timeout', () => req.destroy(new Error('timeout')))
req.write(body); req.end()
} catch (e) { resolve({ alerted: false, error: e.message }) }
})
}
// Triage d'une demande. dryRun=true → détecte + résout, sans rien créer.
async function triage ({ from = '', subject = '', body = '', agent = '', dryRun = false } = {}) {
const det = detectCoupon(subject, body)
const matches = from ? (await lookupCustomersByEmail(from).catch(() => [])) : []
const farm = (matches && matches.length) ? matches[0] : null
const out = {
detected: det.isCoupon, qty: det.qty, from,
farm: farm ? { name: farm.name, customer_name: farm.customer_name } : null,
matches: (matches || []).length,
}
if (!det.isCoupon) { out.note = 'non détecté comme demande de coupons'; return out }
if (dryRun) { out.dry_run = true; return out }
// 1) ticket suivi (file support ; signale si la ferme n'est pas résolue)
const title = `Coupons hotspot${det.qty ? ` ×${det.qty}` : ''}${farm ? `${farm.customer_name}` : ''}`.slice(0, 120)
const desc = `Demande de coupons hotspot${det.qty ? ` (×${det.qty})` : ''} reçue de ${from}.\n` +
(farm ? `Ferme facturable résolue par courriel : ${farm.customer_name} (${farm.name}).` : '⚠ Ferme NON résolue par le courriel expéditeur — à identifier (mentionnée dans le message ?).') +
`\n\n--- message ---\n${String(body || '').slice(0, 800)}`
let ticket = {}
try { ticket = await require('./ticket-collab').createTicket({ title, category: 'Support', priority: 'Medium', status: 'Open', customer: farm ? farm.name : undefined, customer_name: farm ? farm.customer_name : undefined, queue: 'Supports', description: desc, agent }) }
catch (e) { log('coupon-triage createTicket: ' + e.message); ticket = { ok: false, error: e.message } }
// 2) alerte (n8n → Google Chat)
const text = `🎟️ Demande de coupons${det.qty ? ` ×${det.qty}` : ''}${farm ? farm.customer_name : 'ferme à IDENTIFIER (' + from + ')'}` +
`${ticket.name ? ` · ticket ${ticket.name}` : ''}${farm ? '' : ' · ⚠ courriel non résolu'}`
const alert = await alertWebhook({ text, from, qty: det.qty, farm: out.farm, ticket: ticket.name || null })
return { ...out, ticket: ticket.name || null, ticket_ok: ticket.ok !== false, alert }
}
// Hook AUTO depuis l'inbox (ingestEmail) : la conversation est déjà le suivi (client résolu + file) →
// on NE crée PAS de ticket séparé, on MARQUE la conv « coupons » + on ALERTE (Google Chat via n8n) pour ne pas la laisser dormir.
async function onEmail ({ from = '', subject = '', body = '', conv = null } = {}) {
const det = detectCoupon(subject, body)
if (!det.isCoupon) return { detected: false }
if (conv) { conv.couponRequest = { qty: det.qty, ts: new Date().toISOString() }; if (!conv.queue) conv.queue = 'Supports' }
const farmName = (conv && (conv.customerName || conv.customer)) || from
const link = (conv && conv.token) ? `\n${process.env.OPS_BASE_URL || 'https://ops.gigafibre.ca'}/#/conversations?c=${conv.token}` : ''
const text = `🎟️ Demande de coupons${det.qty ? ` ×${det.qty}` : ''}${farmName}${(conv && conv.customer) ? '' : ' · ⚠ ferme à identifier'}${link}`
const alert = await alertWebhook({ text, from, qty: det.qty, customer: (conv && conv.customer) || null, conv: (conv && conv.token) || null })
log(`coupon-triage onEmail: ${from} qty=${det.qty} alerted=${alert.alerted}`)
return { detected: true, qty: det.qty, alerted: alert.alerted }
}
async function handle (req, res, method, p) {
if (p === '/coupons/triage' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await triage({ from: b.from, subject: b.subject, body: b.body, agent: req.headers['x-authentik-email'] || b.agent || '', dryRun: !!b.dryRun })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
return json(res, 404, { error: 'route coupons inconnue' })
}
module.exports = { handle, triage, onEmail, detectCoupon, alertWebhook }

View File

@ -0,0 +1,300 @@
// lib/giftbit.js — Client API Giftbit (cartes-cadeaux). VALIDÉ testbed 2026-06-10 (voir memory/reference_giftbit.md).
// Capacités : solde (funds), marketplace (produits), création de campagne (SHORTLINK), statut des cartes,
// révocation (DELETE → GIVER_CANCELLED) pour remplacer un lien compromis.
// ⚠️ Cloudflare bannit les User-Agent non-navigateur (erreur 1010) → on envoie un UA Chrome.
// Secrets : GIFTBIT_TOKEN + GIFTBIT_BASE via ENV (jamais dans le repo). Défaut = TESTBED (aucune vraie carte).
const https = require('https')
const { json, parseBody } = require('./helpers')
const BASE = (process.env.GIFTBIT_BASE || 'https://testbedapp.giftbit.com/papi/v1').replace(/\/+$/, '')
const TOKEN = process.env.GIFTBIT_TOKEN || ''
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
const isTestbed = /testbed/i.test(BASE)
const enc = encodeURIComponent
function api (method, path, body) {
return new Promise((resolve) => {
let u; try { u = new URL(BASE + path) } catch (e) { return resolve({ ok: false, status: 0, error: 'bad url' }) }
const data = body != null ? Buffer.from(JSON.stringify(body)) : null
const headers = { Authorization: 'Bearer ' + TOKEN, Accept: 'application/json', 'User-Agent': UA }
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length }
const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers }, (r) => {
let b = ''; r.on('data', c => { b += c }); r.on('end', () => {
let j = null; try { j = JSON.parse(b) } catch (e) {}
resolve({ ok: r.statusCode >= 200 && r.statusCode < 300, status: r.statusCode, body: j, raw: j ? undefined : String(b).slice(0, 300) })
})
})
req.on('error', e => resolve({ ok: false, status: 0, error: String((e && e.message) || e) }))
req.setTimeout(30000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }) })
if (data) req.write(data); req.end()
})
}
const ping = () => api('GET', '/ping')
const funds = () => api('GET', '/funds')
const marketplace = (q = {}) => api('GET', '/marketplace?' + new URLSearchParams({ region: String(q.region || '2'), limit: String(q.limit || '50'), offset: String(q.offset || '0') }).toString())
const brands = (q = {}) => api('GET', '/brands?' + new URLSearchParams({ region: String(q.region || '2'), limit: String(q.limit || '50'), offset: String(q.offset || '0') }).toString())
const listGifts = (q = {}) => { const p = new URLSearchParams(); for (const k of ['campaign_uuid', 'campaign_id', 'limit', 'offset', 'status']) if (q[k] != null && q[k] !== '') p.set(k, String(q[k])); return api('GET', '/gifts?' + p.toString()) }
const getGift = (uuid) => api('GET', '/gifts/' + enc(uuid))
const cancelGift = (uuid) => api('DELETE', '/gifts/' + enc(uuid)) // → GIVER_CANCELLED : révoque le shortlink gtbt.co + rend les fonds
// Construit la liste marketplace_gifts. 1 entrée = reward DIRECT (marque fixée) ; ≥2 = TEMPLATE (le destinataire choisit à la rédemption → brand_code null jusqu'au choix).
function buildMarketplaceGifts (o = {}) {
if (Array.isArray(o.marketplace_gifts) && o.marketplace_gifts.length) return o.marketplace_gifts.map(g => ({ id: Number(g.id), price_in_cents: Number(g.price_in_cents != null ? g.price_in_cents : o.price_in_cents) }))
if (Array.isArray(o.brand_ids) && o.brand_ids.length) return o.brand_ids.map(id => ({ id: Number(id), price_in_cents: Number(o.price_in_cents) }))
return [{ id: Number(o.marketplace_gift_id), price_in_cents: Number(o.price_in_cents) }]
}
// Création de campagne (schéma VALIDÉ) : marketplace_gifts:[{id, price_in_cents}] (PAS "price"/brand_codes) ; message/subject à la racine.
function createCampaign (o = {}) {
const body = {
id: String(o.id),
delivery_type: o.delivery_type || 'SHORTLINK',
expiry: o.expiry || defaultExpiry(),
message: o.message || '',
subject: o.subject || '',
suppress_default_greeting: !!o.suppress_default_greeting,
marketplace_gifts: buildMarketplaceGifts(o),
contacts: (o.contacts || []).map(c => ({ email: c.email, firstname: c.firstname || '', lastname: c.lastname || '' }))
}
return api('POST', '/campaign', body)
}
function defaultExpiry () { const d = new Date(); d.setFullYear(d.getFullYear() + 1); return d.toISOString().slice(0, 10) }
// Remplacer un lien (soupçon de piratage) : révoque l'ancien gift PUIS recrée pour le même contact.
// Renvoie { old:{uuid,shortlink,status}, created } → l'appelant conserve l'ancien en HISTORIQUE.
async function replaceGift (o = {}) {
const cur = await getGift(o.uuid)
const g = cur.body && cur.body.gift
if (!g) return { ok: false, error: 'gift introuvable', detail: cur.body || cur.error }
const cancel = await cancelGift(o.uuid)
if (!cancel.ok) return { ok: false, error: 'révocation échouée', cancel: cancel.body || cancel.error }
const created = await createCampaign({
id: o.new_campaign_id || ('replace-' + String(o.uuid).slice(0, 10) + '-' + Date.now()),
expiry: o.expiry || defaultExpiry(),
message: o.message || 'Nouveau lien de remplacement.',
subject: o.subject || 'Votre carte-cadeau (nouveau lien)',
marketplace_gift_id: o.marketplace_gift_id || g.marketplacegift_id,
price_in_cents: o.price_in_cents || g.price_in_cents,
contacts: [{ email: o.email || g.recipient_email, firstname: (o.firstname || (g.recipient_name || '').split(' ')[0] || ''), lastname: o.lastname || '' }]
})
return {
ok: !!created.ok,
old: { uuid: g.uuid, shortlink: g.shortlink, status: (cancel.body && cancel.body.gift && cancel.body.gift.status) || 'GIVER_CANCELLED', price_in_cents: g.price_in_cents, brand_code: g.brand_code, recipient_email: g.recipient_email, recipient_name: g.recipient_name },
created: created.body || null,
created_status: created.status
}
}
const DEMO_HTML = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>TARGO &times; Giftbit &mdash; Integration Demo (Sandbox)</title>
<style>
*{box-sizing:border-box} body{margin:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f4f6fa;color:#1c2533}
header{background:#0f1b2d;color:#fff;padding:18px 22px} header h1{margin:0;font-size:18px} header .sub{opacity:.7;font-size:12.5px;margin-top:3px}
.sandbox{background:#fff7e6;border-bottom:1px solid #ffe0a3;color:#7a5200;padding:9px 22px;font-size:12.5px}
main{max-width:860px;margin:18px auto;padding:0 16px}
.card{background:#fff;border:1px solid #e3e8ef;border-radius:12px;padding:16px 18px;margin-bottom:16px}
.card h2{font-size:14px;margin:0 0 10px;color:#0f1b2d}
.step{display:inline-block;width:22px;height:22px;border-radius:50%;background:#5b6ef5;color:#fff;text-align:center;line-height:22px;font-size:12px;margin-right:7px}
.modes label{display:inline-flex;align-items:center;gap:6px;margin-right:18px;font-size:13.5px;cursor:pointer}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:6px}
.brand{border:2px solid #e3e8ef;border-radius:10px;padding:10px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12.5px;transition:.12s}
.brand:hover{border-color:#b9c2d6} .brand.sel{border-color:#5b6ef5;background:#eef0ff}
.brand img{width:34px;height:34px;object-fit:contain;border-radius:5px;background:#fff}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px}
input[type=email]{padding:8px 10px;border:1px solid #cfd6e0;border-radius:8px;font-size:13px;min-width:240px}
button{background:#5b6ef5;color:#fff;border:0;border-radius:9px;padding:10px 16px;font-size:13.5px;font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:not-allowed}
.result{background:#f0fff4;border:1px solid #b7e4c7;border-radius:10px;padding:14px;margin-top:10px;display:none}
.result.show{display:block} .result.err{background:#fff0f0;border-color:#f5c2c2}
.link{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#fff;border:1px dashed #b9c2d6;border-radius:7px;padding:8px 10px;word-break:break-all;margin:8px 0}
.muted{color:#6a7686;font-size:12px} .ok{color:#1b873f;font-weight:600}
.sec{font-size:12.5px;color:#44506a;line-height:1.6} .sec b{color:#0f1b2d}
.tag{display:inline-block;font-size:10.5px;background:#eef0ff;color:#3b46b5;border-radius:5px;padding:1px 6px;margin-left:6px}
</style></head><body>
<header><h1>TARGO Communications &times; Giftbit &mdash; Integration Demo</h1><div class="sub">Reward creation via Giftbit API &mdash; direct brand &amp; multi-brand link (recipient choice)</div></header>
<div class="sandbox">🧪 <b>SANDBOX / TESTBED</b> &mdash; Runs only against <b>testbedapp.giftbit.com</b>. No real cards are issued. The API token is held <b>server-side</b> and is <b>never</b> sent to this page. Amount fixed at <b>$5 CAD</b>, single recipient.</div>
<main>
<div class="card" style="border-left:4px solid #5b6ef5">
<h2>For Giftbit reviewers</h2>
<div class="sec">This page demonstrates TARGO Communications' integration with the <b>Giftbit API (papi/v1)</b>. It is connected to the <b>testbed</b> account only. You can create a reward two ways and inspect the resulting shortlink redemption flow:
<br>&bull; <b>Direct reward</b> &mdash; one specific brand, fixed at issuance.
<br>&bull; <b>Reward link (multi-brand)</b> &mdash; several brands offered; the recipient selects their brand on the Giftbit redemption page (the gift's <code>brand_code</code> stays null until then).
<br>Brands are loaded live from <code>GET /marketplace</code>. Rewards are created via <code>POST /campaign</code> with <code>marketplace_gifts</code>. Links can be cancelled and re-issued with <code>DELETE /gifts/{uuid}</code>. Contact: <b>louis@targo.ca</b>.</div>
</div>
<div class="card"><h2><span class="step">1</span>Reward type</h2>
<div class="modes">
<label><input type="radio" name="mode" value="direct" checked> <b>Direct reward</b> &mdash; one brand (fixed)</label>
<label><input type="radio" name="mode" value="template"> <b>Reward link</b> &mdash; several brands, recipient chooses at redemption</label>
</div>
<div class="muted" id="modehint" style="margin-top:6px">Pick exactly one brand below.</div>
</div>
<div class="card"><h2><span class="step">2</span>Choose brand(s) <span class="tag">GET /marketplace</span></h2>
<div id="brands" class="grid"><div class="muted">Loading options&hellip;</div></div>
</div>
<div class="card"><h2><span class="step">3</span>Recipient &amp; create</h2>
<div class="row"><input type="email" id="email" placeholder="recipient@example.com (optional, sandbox)" value="demo@targo.ca">
<button id="go" disabled>Create $5 reward (testbed)</button></div>
<div class="result" id="result"></div>
</div>
<div class="card"><h2>Generated reward links <span class="tag">GET /gifts</span><button id="refresh" style="float:right;padding:5px 11px;font-size:12px;background:#eef0ff;color:#3b46b5"> Refresh</button></h2>
<div id="history"><div class="muted">Loading&hellip;</div></div>
<div class="muted" style="margin-top:6px">Live statuses from the testbed: <b>SENT_AND_REDEEMABLE</b> &rarr; <b>REDEEMED</b> (brand fills in once the recipient chooses) &rarr; <b>GIVER_CANCELLED</b> (revoked). &ldquo;Revoke&rdquo; cancels a reward link via <code>DELETE /gifts/{uuid}</code>.</div>
</div>
<div class="card"><h2>Activity by day <span class="tag">redemption trend</span></h2>
<div id="stats"><div class="muted">Loading&hellip;</div></div>
<div class="muted" style="margin-top:6px">Organic redemptions cluster right after delivery, then taper off. A steady daily trickle &mdash; or many <b>instant</b> redemptions (&lt; 15 min after issue) &mdash; can indicate automated / low-profile abuse.</div>
</div>
<div class="card"><h2>Security &amp; correct use</h2>
<div class="sec">
&bull; <b>Token server-side only</b> &mdash; the Giftbit Bearer token lives in the hub environment; this browser page never receives it.<br>
&bull; <b>Sandboxed</b> &mdash; this endpoint refuses to run against production; amount, brand list and recipient count are capped.<br>
&bull; <b>Direct</b> create sends one <code>marketplace_gifts</code> entry; <b>multi-brand</b> sends several &mdash; Giftbit returns a shortlink where the recipient picks their brand (<code>brand_code</code> is null until chosen).<br>
&bull; <b>HTTPS</b> end-to-end; links are delivered via our own wrapper with independent expiry; links can be cancelled (<code>DELETE /gifts/{uuid}</code>) and re-issued at any time.
</div>
</div>
</main>
<script>
var BRANDS=[],SEL=[];
function mode(){return document.querySelector('input[name=mode]:checked').value}
function refreshHint(){document.getElementById('modehint').textContent = mode()==='direct' ? 'Pick exactly one brand below.' : 'Pick 2 or more brands; the recipient chooses at redemption.';syncGo()}
function syncGo(){var m=mode();var ok = m==='direct' ? SEL.length===1 : SEL.length>=2;document.getElementById('go').disabled=!ok}
function toggle(id,el){var i=SEL.indexOf(id);if(i>=0){SEL.splice(i,1);el.classList.remove('sel')}else{if(mode()==='direct'){SEL=[id];document.querySelectorAll('.brand').forEach(function(b){b.classList.remove('sel')})}else{SEL.push(id)}el.classList.add('sel')}syncGo()}
fetch('/giftbit-demo/marketplace').then(function(r){return r.json()}).then(function(d){
BRANDS=d.brands||[];var c=document.getElementById('brands');c.innerHTML='';
if(!BRANDS.length){c.innerHTML='<div class="muted">No options.</div>';return}
BRANDS.forEach(function(b){var el=document.createElement('div');el.className='brand';
el.innerHTML=(b.image_url?'<img src="'+b.image_url+'" alt="">':'')+'<span>'+b.name.replace('CAD ','').replace(/ \\|.*$/,'')+'</span>';
el.onclick=function(){toggle(b.id,el)};c.appendChild(el)})
}).catch(function(){document.getElementById('brands').innerHTML='<div class="muted">Failed to load options.</div>'});
document.querySelectorAll('input[name=mode]').forEach(function(r){r.onchange=function(){SEL=[];document.querySelectorAll('.brand').forEach(function(b){b.classList.remove('sel')});refreshHint()}});
document.getElementById('go').onclick=function(){
var btn=this;btn.disabled=true;btn.textContent='Creating…';
var res=document.getElementById('result');res.className='result';
fetch('/giftbit-demo/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand_ids:SEL,email:document.getElementById('email').value})})
.then(function(r){return r.json()}).then(function(d){
btn.textContent='Create $5 reward (testbed)';syncGo();
if(!d.ok){res.className='result show err';res.innerHTML='<b>Error:</b> '+(d.error||'failed');return}
var g=(d.gifts&&d.gifts[0])||{};
if(g.shortlink){renderDemo(d,g);}else{res.className='result show';res.innerHTML='<div class="ok">✓ Reward created &mdash; generating secure link…</div><div class="muted">Campaign '+d.campaign_id+'</div>';pollDemo(d,0);}
}).catch(function(){btn.textContent='Create $5 reward (testbed)';res.className='result show err';res.innerHTML='Request failed.'})
};
function renderDemo(d,g){var res=document.getElementById('result');
var html='<div class="ok">✓ '+(d.mode==='template'?'Multi-brand reward link created':'Direct reward created')+' &mdash; $5 CAD (testbed)</div>';
html+='<div class="link">'+g.shortlink+'</div><a href="'+g.shortlink+'" target="_blank"><button>Open redemption page ↗</button></a>';
html+='<div class="muted" style="margin-top:8px">Campaign: '+d.campaign_id+' &middot; status: '+(g.status||'')+' &middot; brand: '+(g.brand_code||(d.mode==='template'?'(recipient chooses at redemption)':'set'))+'</div>';
res.className='result show';res.innerHTML=html;loadHistory();loadStats();}
function pollDemo(d,n){if(n>14){document.getElementById('result').innerHTML+='<div class="muted">Link still generating (testbed latency) — refresh status shortly.</div>';return}
setTimeout(function(){fetch('/giftbit-demo/status?campaign_id='+encodeURIComponent(d.campaign_id)).then(function(r){return r.json()}).then(function(s){var g=(s.gifts&&s.gifts[0])||{};if(g.shortlink){d.gifts=[g];renderDemo(d,g);}else{pollDemo(d,n+1);}}).catch(function(){pollDemo(d,n+1)})},1800);}
function statusBadge(s){var c=({REDEEMED:'#1b873f',SENT_AND_REDEEMABLE:'#3b46b5',GIVER_CANCELLED:'#b54343'})[s]||'#6a7686';return '<span style="font-size:10px;font-weight:700;color:#fff;background:'+c+';border-radius:5px;padding:1px 7px">'+(s||'—')+'</span>';}
var HIST=[],HPAGE=0,HSIZE=20,HALL=false;
function loadHistory(){fetch('/giftbit-demo/history').then(function(r){return r.json()}).then(function(d){HIST=d.gifts||[];renderHist()}).catch(function(){document.getElementById('history').innerHTML='<div class="muted">Failed to load history.</div>'})}
function renderHist(){var h=document.getElementById('history');if(!HIST.length){h.innerHTML='<div class="muted">No reward links generated yet.</div>';return}var total=HIST.length,pages=HALL?1:Math.max(1,Math.ceil(total/HSIZE));if(HPAGE>=pages)HPAGE=0;var slice=HALL?HIST:HIST.slice(HPAGE*HSIZE,HPAGE*HSIZE+HSIZE);var t='<table style="width:100%;border-collapse:collapse;font-size:12.5px"><tr style="text-align:left;color:#6a7686;border-bottom:1px solid #e3e8ef"><th style="padding:5px 4px">Created</th><th>Brand</th><th>Amount</th><th>Status</th><th>Redeemed</th><th>Link</th><th></th></tr>';slice.forEach(function(g){t+='<tr style="border-bottom:1px solid #f0f2f6"><td style="padding:6px 4px">'+(g.created_date||'')+'</td><td>'+(g.brand_code?g.brand_code:'<span class=muted>recipient chooses</span>')+'</td><td>$'+(((g.price_in_cents||0)/100).toFixed(2))+'</td><td>'+statusBadge(g.status)+'</td><td>'+(g.redeemed_date?g.redeemed_date:'<span class=muted>—</span>')+'</td><td>'+(g.shortlink?'<a href="'+g.shortlink+'" target="_blank">open ↗</a>':'<span class=muted>…</span>')+'</td><td style="text-align:right">'+(g.status==='SENT_AND_REDEEMABLE'?'<button class="rev" data-u="'+g.uuid+'" style="padding:3px 9px;font-size:11px;background:#fff0f0;color:#b54343;border:1px solid #f5c2c2">Revoke</button>':'<span class=muted>'+(g.status==='REDEEMED'?'redeemed':'revoked')+'</span>')+'</td></tr>';});t+='</table>';var pg='<div style="margin-top:9px;display:flex;gap:5px;align-items:center;flex-wrap:wrap"><span class="muted" style="margin-right:4px">'+total+' link(s)</span>';if(!HALL&&pages>1){for(var i=0;i<pages;i++){pg+='<button class="pg" data-p="'+i+'" style="padding:3px 9px;font-size:12px;border:1px solid #cfd6e0;border-radius:6px;cursor:pointer;background:'+(i===HPAGE?'#5b6ef5':'#fff')+';color:'+(i===HPAGE?'#fff':'#3b46b5')+'">'+(i+1)+'</button>';}}pg+='<button id="hall" style="padding:3px 10px;font-size:12px;border:1px solid #cfd6e0;border-radius:6px;cursor:pointer;background:#eef0ff;color:#3b46b5;margin-left:6px">'+(HALL?'Paginate (20/page)':'Show all')+'</button></div>';h.innerHTML=t+pg;bindHist()}
function bindHist(){Array.prototype.forEach.call(document.querySelectorAll('.rev'),function(b){b.onclick=function(){var u=b.getAttribute('data-u');b.disabled=true;b.textContent='…';fetch('/giftbit-demo/revoke',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uuid:u})}).then(function(r){return r.json()}).then(function(){loadHistory()}).catch(function(){loadHistory()})}});Array.prototype.forEach.call(document.querySelectorAll('.pg'),function(b){b.onclick=function(){HPAGE=parseInt(b.getAttribute('data-p'),10);renderHist()}});var a=document.getElementById('hall');if(a)a.onclick=function(){HALL=!HALL;HPAGE=0;renderHist()}}
document.getElementById('refresh').onclick=loadHistory;
function loadStats(){fetch('/giftbit-demo/stats').then(function(r){return r.json()}).then(renderStats).catch(function(){document.getElementById('stats').innerHTML='<div class="muted">Failed to load.</div>'})}
function renderStats(d){var el=document.getElementById('stats');var days=(d&&d.days)||[];var t=(d&&d.totals)||{};if(!days.length){el.innerHTML='<div class="muted">No data yet.</div>';return}var max=1;days.forEach(function(x){max=Math.max(max,x.created,x.redeemed)});var h='<div style="display:flex;align-items:flex-end;gap:10px;height:118px;padding:4px 2px;overflow-x:auto">';days.forEach(function(x){var ch=Math.round(x.created/max*96),rh=Math.round(x.redeemed/max*96);h+='<div style="text-align:center;min-width:40px"><div style="display:flex;gap:2px;align-items:flex-end;height:98px;justify-content:center"><div title="issued '+x.created+'" style="width:13px;height:'+ch+'px;background:#cfd6e0;border-radius:3px 3px 0 0"></div><div title="redeemed '+x.redeemed+'" style="width:13px;height:'+rh+'px;background:#1b873f;border-radius:3px 3px 0 0"></div></div><div class="muted" style="font-size:10px;margin-top:3px">'+String(x.date).slice(5)+'</div></div>';});h+='</div>';h+='<div style="font-size:12px;margin-top:6px"><span style="display:inline-block;width:10px;height:10px;background:#cfd6e0;border-radius:2px"></span> issued&nbsp;&nbsp;<span style="display:inline-block;width:10px;height:10px;background:#1b873f;border-radius:2px"></span> redeemed&nbsp;·&nbsp;total '+(t.gifts||0)+' issued / '+(t.redeemed||0)+' redeemed';if(t.fast_redemptions)h+='&nbsp;·&nbsp;<b style="color:#b54343">⚠ '+t.fast_redemptions+' instant (&lt;15 min)</b>';if(t.late_redemptions)h+='&nbsp;·&nbsp;<b style="color:#b07a00">'+t.late_redemptions+' late (&gt;72 h)</b>';h+='</div>';el.innerHTML=h}
loadHistory();
loadStats();
refreshHint();
</script></body></html>`
// ─────────────────────────────────────────────────────────────────────────────
// DÉMO « JAIL » (sandbox testbed, publique) — pour la validation par Giftbit.
// Garanties : (1) testbed UNIQUEMENT (refus si prod), (2) montant FIXE 5 $, (3) marques d'une
// liste blanche, (4) 1 destinataire, (5) le token reste SERVEUR (jamais envoyé au navigateur).
// ─────────────────────────────────────────────────────────────────────────────
const DEMO_BRAND_IDS = [386, 574, 572, 617, 619, 620, 621, 622, 568] // CAD, montant variable (≥5 $) — region=1
const DEMO_PRICE = 500
const DEMO_GUARD = () => isTestbed // jamais en prod
async function demoBrands () {
const r = await marketplace({ region: '1', limit: '50' })
const list = (r.body && r.body.marketplace_gifts) || []
return list.filter(g => DEMO_BRAND_IDS.includes(Number(g.id)))
.map(g => ({ id: g.id, name: g.name, image_url: g.image_url, currency: g.currencyisocode }))
}
async function demoCreate (o = {}) {
if (!DEMO_GUARD()) return { ok: false, error: 'Démo désactivée hors environnement testbed.' }
const ids = (Array.isArray(o.brand_ids) ? o.brand_ids : []).map(Number).filter(x => DEMO_BRAND_IDS.includes(x))
if (!ids.length) return { ok: false, error: 'Choisir au moins une marque de la liste.' }
const email = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(o.email || '')) ? o.email : 'demo@targo.ca'
const mode = ids.length > 1 ? 'template' : 'direct'
const cid = 'targo-demo-' + Date.now()
const created = await createCampaign({
id: cid, delivery_type: 'SHORTLINK', expiry: defaultExpiry(),
subject: 'TARGO × Giftbit — Integration demo', message: mode === 'template' ? 'Choose your gift card.' : 'Your gift card.',
marketplace_gifts: ids.map(id => ({ id, price_in_cents: DEMO_PRICE })),
contacts: [{ email, firstname: 'Demo', lastname: 'Recipient' }]
})
if (!created.ok) return { ok: false, error: 'Création refusée', detail: created.body }
// Le gift est asynchrone ET le shortlink se génère APRÈS la carte (~8 s) → on sonde jusqu'au shortlink (max ~12 s ; la page poll aussi en repli).
let gifts = []
for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 800)); const g = await listGifts({ campaign_id: cid }); gifts = (g.body && g.body.gifts) || []; if (gifts.length && gifts[0].shortlink) break } // réponse rapide ; la page poll /status pour le shortlink si pas encore prêt
return { ok: true, mode, campaign_id: cid, campaign_uuid: (created.body && created.body.campaign && created.body.campaign.uuid) || '', price_in_cents: DEMO_PRICE, brands: ids, gifts: gifts.map(g => ({ uuid: g.uuid, shortlink: g.shortlink, status: g.status, brand_code: g.brand_code })) }
}
// Historique des liens générés par la démo (campaign_id préfixé targo-demo-), trié du + récent au + ancien.
async function demoHistory () {
const r = await listGifts({ limit: 500 })
const gs = (r.body && r.body.gifts) || []
return gs.filter(g => String(g.campaign_id || '').startsWith('targo-demo-'))
.sort((a, b) => String(b.created_date || '').localeCompare(String(a.created_date || '')))
.map(g => ({ uuid: g.uuid, shortlink: g.shortlink, status: g.status, brand_code: g.brand_code, price_in_cents: g.price_in_cents, created_date: g.created_date, redeemed_date: g.redeemed_date || '', recipient_email: g.recipient_email }))
}
// Stat « activité par jour » : créés vs rédemés/jour + signaux d'abus (rédemptions « instantanées » < 15 min = bot-like ;
// rédemptions tardives = filet régulier après la courbe organique). Révèle un pirate low-profile.
function dayOf (s) { return String(s || '').slice(0, 10) }
function _ts (s) { const t = Date.parse(String(s || '').replace(' ', 'T') + 'Z'); return isFinite(t) ? t : null }
function dailyAgg (gifts) {
const map = {}; let redeemed = 0; let fast = 0; let lateDrip = 0
for (const g of gifts) {
const cd = dayOf(g.created_date); if (cd) { (map[cd] = map[cd] || { created: 0, redeemed: 0 }).created++ }
if (g.status === 'REDEEMED' && g.redeemed_date) {
const rd = dayOf(g.redeemed_date); (map[rd] = map[rd] || { created: 0, redeemed: 0 }).redeemed++; redeemed++
const t1 = _ts(g.created_date); const t2 = _ts(g.redeemed_date)
if (t1 != null && t2 != null) { const dh = (t2 - t1) / 3600000; if (dh < 0.25) fast++; if (dh > 72) lateDrip++ } // <15 min = instantané ; >72 h = tardif
}
}
const days = Object.keys(map).sort().map(d => ({ date: d, created: map[d].created, redeemed: map[d].redeemed }))
return { days, totals: { gifts: gifts.length, redeemed, fast_redemptions: fast, late_redemptions: lateDrip } }
}
// Révocation depuis la démo : SEULEMENT un gift démo (garde-fou) → DELETE → GIVER_CANCELLED (montre le traitement d'un lien compromis).
async function demoRevoke (uuid) {
if (!DEMO_GUARD()) return { ok: false, error: 'testbed only' }
const cur = await getGift(uuid); const g = cur.body && cur.body.gift
if (!g || !String(g.campaign_id || '').startsWith('targo-demo-')) return { ok: false, error: 'Only demo links can be revoked here.' }
if (g.status !== 'SENT_AND_REDEEMABLE') return { ok: false, error: 'Only unredeemed links can be revoked (current status: ' + g.status + ').' }
const r = await cancelGift(uuid)
return { ok: r.ok, status: (r.body && r.body.gift && r.body.gift.status) || 'GIVER_CANCELLED' }
}
async function handle (req, res, method, path, url) {
const qs = (url && url.searchParams) || new URLSearchParams()
// ── Démo publique (sandbox) : page HTML + données testbed. Sert AVANT le contrôle TOKEN pour que la page charge toujours. ──
if (path === '/giftbit-demo' || path === '/giftbit-demo/' || path === '/giftbit-demo.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(DEMO_HTML) }
if (path.startsWith('/giftbit-demo/')) {
if (!TOKEN) return json(res, 503, { ok: false, error: 'token absent' })
if (!isTestbed) return json(res, 403, { ok: false, error: 'Démo disponible en testbed seulement.' })
if (path === '/giftbit-demo/marketplace') return json(res, 200, { ok: true, testbed: true, brands: await demoBrands() })
if (path === '/giftbit-demo/create' && method === 'POST') { const b = await parseBody(req); return json(res, 200, await demoCreate(b)) }
if (path === '/giftbit-demo/status') { const r = await listGifts({ campaign_id: qs.get('campaign_id') || '' }); return json(res, 200, r.body || { ok: false }) }
if (path === '/giftbit-demo/history') return json(res, 200, { ok: true, gifts: await demoHistory() })
if (path === '/giftbit-demo/stats') return json(res, 200, { ok: true, ...dailyAgg(await demoHistory()) })
if (path === '/giftbit-demo/revoke' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); return json(res, 200, await demoRevoke(b.uuid)) }
return json(res, 404, { ok: false, error: 'route démo inconnue' })
}
if (!TOKEN) return json(res, 503, { ok: false, error: 'GIFTBIT_TOKEN absent de l\'env du hub' })
try {
if (path === '/giftbit/ping') { const r = await ping(); return json(res, 200, { ok: r.ok, testbed: isTestbed, ...(r.body || {}), _status: r.status }) }
if (path === '/giftbit/funds') { const r = await funds(); return json(res, 200, { ok: r.ok, testbed: isTestbed, ...(r.body || {}) }) }
if (path === '/giftbit/marketplace') { const r = await marketplace(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/brands') { const r = await brands(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/gifts' && method === 'GET') { const r = await listGifts(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/stats' && method === 'GET') { const q = Object.fromEntries(qs); const r = await listGifts({ campaign_id: q.campaign_id || '', campaign_uuid: q.campaign_uuid || '', limit: q.limit || 1000 }); const gifts = (r.body && r.body.gifts) || []; return json(res, 200, { ok: true, count: gifts.length, ...dailyAgg(gifts) }) }
if (path.startsWith('/giftbit/gift/') && method === 'GET') { const r = await getGift(path.split('/').pop()); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/cancel' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); const r = await cancelGift(b.uuid); return json(res, 200, { ok: r.ok, ...(r.body || {}) }) }
if (path === '/giftbit/campaign' && method === 'POST') { const b = await parseBody(req); if (!b.id || !b.marketplace_gift_id || !b.price_in_cents || !Array.isArray(b.contacts) || !b.contacts.length) return json(res, 400, { ok: false, error: 'id, marketplace_gift_id, price_in_cents, contacts[] requis' }); const r = await createCampaign(b); return json(res, 200, { ok: r.ok, ...(r.body || {}), _status: r.status }) }
if (path === '/giftbit/replace' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); const r = await replaceGift(b); return json(res, r.ok ? 200 : 500, r) }
return json(res, 404, { ok: false, error: 'route giftbit inconnue' })
} catch (e) { return json(res, 500, { ok: false, error: String((e && e.message) || e) }) }
}
module.exports = { handle, api, ping, funds, marketplace, brands, listGifts, getGift, cancelGift, createCampaign, replaceGift, isTestbed, BASE }

View File

@ -0,0 +1,185 @@
// ── Lecteur Gmail (compte de service + délégation domaine) — navigue/relève cc@ sans MFA ni mot de passe ──
// Auth = JWT RS256 signé avec la clé du compte de service (crypto natif) → access_token IMPERSONNÉ sur la boîte.
// AUCUNE dépendance npm (pas de googleapis) → pas de rebuild d'image. Clé dans /app/data/gmail_sa.json (ou env GMAIL_SA_JSON).
const fs = require('fs')
const crypto = require('crypto')
const { log, loadSeenSet, saveSeenSet } = require('./helpers')
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const SCOPE = 'https://mail.google.com/' // accès COMPLET Gmail : lire + envoyer/répondre + supprimer/corbeille + libellés
function mailbox () { return (process.env.GMAIL_MAILBOX || 'cc@targointernet.com').trim() }
// From de TOUT envoi sortant (réponses, files, group-send). support@targo.ca est un alias « send-as » VÉRIFIÉ sur cc@ → Gmail l'autorise. Surchargeable par GMAIL_SEND_FROM.
function sendFrom () { return (process.env.GMAIL_SEND_FROM || 'support@targo.ca').trim() }
function loadSA () {
if (process.env.GMAIL_SA_JSON) { try { return JSON.parse(process.env.GMAIL_SA_JSON) } catch (e) {} }
try { return JSON.parse(fs.readFileSync('/app/data/gmail_sa.json', 'utf8')) } catch (e) { return null }
}
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const _toks = {} // cache token PAR boîte impersonée (cc@, factures@…)
async function getToken (mb) {
mb = mb || mailbox()
const c = _toks[mb]
if (c && Date.now() < c.exp - 60000) return c.tok
const sa = loadSA()
if (!sa || !sa.client_email || !sa.private_key) throw new Error('Clé compte de service Gmail absente (/app/data/gmail_sa.json)')
const iat = Math.floor(Date.now() / 1000); const exp = iat + 3600
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
const claims = b64url(JSON.stringify({ iss: sa.client_email, sub: mb, scope: SCOPE, aud: TOKEN_URL, iat, exp }))
const signer = crypto.createSign('RSA-SHA256'); signer.update(header + '.' + claims)
const assertion = header + '.' + claims + '.' + b64url(signer.sign(sa.private_key))
const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + assertion })
const j = await res.json().catch(() => ({}))
if (!res.ok || !j.access_token) throw new Error('Token Gmail (' + mb + '): ' + (j.error_description || j.error || res.status))
_toks[mb] = { tok: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 }
return _toks[mb].tok
}
async function gapi (path, qs, mb) {
mb = mb || mailbox()
const tok = await getToken(mb)
const u = new URL('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mb) + path)
if (qs) for (const k in qs) if (qs[k] != null) u.searchParams.set(k, qs[k])
const res = await fetch(u, { headers: { Authorization: 'Bearer ' + tok } })
const j = await res.json().catch(() => ({}))
if (!res.ok) throw new Error('gmail ' + path + ': ' + ((j.error && j.error.message) || res.status))
return j
}
const hdr = (m, name) => { const h = ((m.payload && m.payload.headers) || []).find(x => x.name.toLowerCase() === name.toLowerCase()); return h ? h.value : '' }
const walkMime = (p, mime) => { if (!p) return ''; if (p.mimeType === mime && p.body && p.body.data) return Buffer.from(p.body.data, 'base64').toString('utf8'); if (p.parts) { for (const c of p.parts) { const r = walkMime(c, mime); if (r) return r } } return '' }
function decodeBody (payload) {
let txt = walkMime(payload, 'text/plain')
if (!txt) { const html = walkMime(payload, 'text/html'); if (html) txt = html.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim() }
return txt
}
// HTML brut du courriel (pour affichage WYSIWYG dans un iframe sandbox côté Ops). '' si le message est texte seul.
function decodeHtml (payload) { return walkMime(payload, 'text/html') || '' }
async function listMessages ({ q = 'newer_than:2d', max = 25, mb } = {}) { const j = await gapi('/messages', { q, maxResults: max }, mb); return j.messages || [] }
// Content-Id (sans <>) d'une part MIME — identifie les images inline référencées par « cid: » dans le HTML.
const partCid = (p) => { const h = ((p.headers || []).find(x => x.name && x.name.toLowerCase() === 'content-id')); return h ? String(h.value).replace(/^<|>$/g, '').trim() : '' }
// Liste les pièces jointes (filename + attachmentId + mimeType). EXCLUT les images inline (cid) : elles sont rendues dans le HTML, pas en pièce jointe.
function listAttachments (payload) {
const out = []
const walk = (p) => { if (!p) return; const inlineImg = partCid(p) && /^image\//i.test(p.mimeType || ''); if (!inlineImg && p.filename && p.body && p.body.attachmentId) out.push({ filename: p.filename, mimeType: p.mimeType || 'application/octet-stream', attachmentId: p.body.attachmentId, size: p.body.size || 0 }); if (p.parts) p.parts.forEach(walk) }
walk(payload)
return out
}
// Collecte les images inline (cid) : { cid, mimeType, attachmentId, data(base64url si inline), size }.
function collectInlineImages (payload) {
const out = []
const walk = (p) => { if (!p) return; const cid = partCid(p); if (cid && /^image\//i.test(p.mimeType || '') && p.body) out.push({ cid, mimeType: p.mimeType, attachmentId: p.body.attachmentId || null, data: p.body.data || null, size: p.body.size || 0 }); if (p.parts) p.parts.forEach(walk) }
walk(payload)
return out
}
// Remplace les <img src="cid:…"> par des data: URLs → l'image s'affiche dans l'inbox (sinon : image cassée). Cap 500 Ko/image (pas de bloat du stockage).
async function inlineCidImages (html, payload, msgId, mb) {
if (!html || !/cid:/i.test(html)) return html
let out = html
for (const im of collectInlineImages(payload)) {
if (im.size && im.size > 500000) continue
let b64 = im.data ? im.data.replace(/-/g, '+').replace(/_/g, '/') : null
if (!b64 && im.attachmentId) { try { b64 = await getAttachment(msgId, im.attachmentId, mb) } catch (e) { b64 = null } }
if (!b64) continue
out = out.replace(new RegExp('cid:' + im.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), 'data:' + (im.mimeType || 'image/png') + ';base64,' + b64)
}
return out
}
async function getMessage (id, mb) {
const m = await gapi('/messages/' + encodeURIComponent(id), { format: 'full' }, mb)
const html = await inlineCidImages(decodeHtml(m.payload), m.payload, m.id, mb)
return { id: m.id, threadId: m.threadId, from: hdr(m, 'From'), to: hdr(m, 'To'), subject: hdr(m, 'Subject'), date: hdr(m, 'Date'), messageId: hdr(m, 'Message-Id') || m.id, snippet: m.snippet || '', body: decodeBody(m.payload), html, attachments: listAttachments(m.payload) }
}
// Télécharge une pièce jointe → base64 standard (pour OCR vision). mb = boîte impersonée.
async function getAttachment (msgId, attId, mb) {
const j = await gapi('/messages/' + encodeURIComponent(msgId) + '/attachments/' + encodeURIComponent(attId), null, mb)
return (j.data || '').replace(/-/g, '+').replace(/_/g, '/') // base64url → base64
}
// ── Écriture : envoyer / répondre / supprimer ──
function encodeHeader (s) { return /[^\x00-\x7F]/.test(String(s)) ? '=?UTF-8?B?' + Buffer.from(String(s)).toString('base64') + '?=' : String(s) } // RFC 2047 pour sujets accentués
const htmlToText = (html) => String(html || '').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
function buildRfc822 ({ to, subject, body, html, inReplyTo, references, from }) {
const h = ['From: ' + (from || sendFrom()), 'To: ' + to, 'Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0']
if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo)
if (references || inReplyTo) h.push('References: ' + (references || inReplyTo))
if (html) { // multipart/alternative : repli texte + HTML (déliverabilité + clients sans HTML)
const bnd = 'alt_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
h.push('Content-Type: multipart/alternative; boundary="' + bnd + '"')
const plain = body || htmlToText(html)
const parts = [
'--' + bnd, 'Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', plain, '',
'--' + bnd, 'Content-Type: text/html; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', html, '',
'--' + bnd + '--', '',
]
return h.join('\r\n') + '\r\n\r\n' + parts.join('\r\n')
}
h.push('Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit')
return h.join('\r\n') + '\r\n\r\n' + (body || '')
}
// Envoie (ou répond, si threadId/inReplyTo) un courriel DEPUIS la boîte impersonnée. `html` => multipart/alternative.
async function sendMessage ({ to, subject, body, html, threadId, inReplyTo, references, from } = {}) {
if (!to) throw new Error('destinataire requis')
const tok = await getToken()
const payload = { raw: b64url(buildRfc822({ to, subject, body, html, inReplyTo, references, from })) }
if (threadId) payload.threadId = threadId
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/send', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
const j = await res.json().catch(() => ({}))
if (!res.ok) throw new Error('gmail send: ' + ((j.error && j.error.message) || res.status))
return { ok: true, id: j.id, threadId: j.threadId }
}
async function trashMessage (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id) + '/trash', { method: 'POST', headers: { Authorization: 'Bearer ' + tok } }); if (!res.ok) throw new Error('gmail trash: ' + res.status); return { ok: true, trashed: id } }
async function deleteMessage (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id), { method: 'DELETE', headers: { Authorization: 'Bearer ' + tok } }); if (!(res.status === 204 || res.ok)) throw new Error('gmail delete: ' + res.status); return { ok: true, deleted: id } }
// Marque SPAM (label SPAM + retire INBOX/UNREAD) → Gmail apprend + sort de la boîte. Réversible (le message reste dans Spam).
async function markSpam (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id) + '/modify', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify({ addLabelIds: ['SPAM'], removeLabelIds: ['INBOX', 'UNREAD'] }) }); if (!res.ok) throw new Error('gmail spam: ' + res.status); return { ok: true, spam: id } }
// Corbeille TOUT le fil (réversible 30 j) — pour « supprimer la conversation = sortir les courriels de la boîte ».
async function trashThread (threadId) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/threads/' + encodeURIComponent(threadId) + '/trash', { method: 'POST', headers: { Authorization: 'Bearer ' + tok } }); if (!res.ok) throw new Error('gmail trashThread: ' + res.status); return { ok: true, trashedThread: threadId } }
// Poll : nouveaux courriels → ingestEmail (dédup via seen). Opt-in GMAIL_INGEST=on.
const SEEN_FILE = '/app/data/gmail_seen.json'
const loadSeen = () => loadSeenSet(SEEN_FILE)
const saveSeen = (s) => saveSeenSet(SEEN_FILE, s)
async function poll () {
// Exclut les courriels adressés à factures@ : ils sont traités par le pipeline FACTURE FOURNISSEUR (pas des conversations).
const invTo = (process.env.GMAIL_INVOICE_TO || 'factures@targointernet.com').trim()
const ids = await listMessages({ q: 'newer_than:3d' + (invTo ? ' -to:' + invTo : ''), max: 30 })
const seen = loadSeen(); let ingested = 0
const { ingestEmail } = require('./conversation')
for (const { id } of ids.slice().reverse()) {
if (seen.has(id)) continue
try { const m = await getMessage(id); await ingestEmail({ from: m.from, subject: m.subject, body: m.body || m.snippet, html: m.html, message_id: m.messageId, thread_id: m.threadId, gmail_id: m.id }); seen.add(id); ingested++ } catch (e) { log('gmail poll msg ' + id + ': ' + e.message) }
}
saveSeen(seen)
return { ok: true, scanned: ids.length, ingested }
}
let _timer = null
function start () {
if (_timer) return
if (String(process.env.GMAIL_INGEST || '').toLowerCase() === 'off') { log('Gmail ingest: OFF (GMAIL_INGEST=off)'); return }
if (!loadSA()) { log('Gmail ingest: clé absente → OFF (déposer /app/data/gmail_sa.json puis docker restart)'); return }
const ms = (Number(process.env.GMAIL_POLL_MIN) || 3) * 60000
_timer = setInterval(() => poll().catch(e => log('gmail poll error: ' + e.message)), ms)
log('Gmail ingest poller ON (' + (ms / 60000) + ' min) sur ' + mailbox())
poll().catch(e => log('gmail first poll: ' + e.message))
}
function stop () { if (_timer) { clearInterval(_timer); _timer = null } }
async function handle (req, res, method, p, url) {
const json = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)) }
try {
if (p === '/gmail/status' && method === 'GET') { const sa = loadSA(); return json(200, { configured: !!sa, sa_email: sa ? sa.client_email : null, mailbox: mailbox(), poller_on: !!_timer }) }
if (p === '/gmail/ping' && method === 'GET') { const mb = (url && url.searchParams.get('mailbox')) || mailbox(); await getToken(mb); const ids = await listMessages({ q: 'newer_than:30d', max: 1, mb }); return json(200, { ok: true, mailbox: mb, sample_found: ids.length }) }
if (p === '/gmail/poll' && method === 'POST') return json(200, await poll())
if (p === '/gmail/list' && method === 'GET') { const mb = (url && url.searchParams.get('mailbox')) || mailbox(); const q = (url && url.searchParams.get('q')) || 'newer_than:7d'; const ids = await listMessages({ q, max: 25, mb }); const out = []; for (const { id } of ids.slice(0, 20)) { try { out.push(await getMessage(id, mb)) } catch (e) {} } return json(200, { mailbox: mb, count: out.length, messages: out.map(m => ({ id: m.id, from: m.from, subject: m.subject, date: m.date, snippet: m.snippet, attachments: (m.attachments || []).map(a => a.filename) })) }) }
if (p === '/gmail/message' && method === 'GET') { const id = url && url.searchParams.get('id'); if (!id) return json(400, { error: 'id requis' }); return json(200, await getMessage(id)) }
if (p === '/gmail/send' && method === 'POST') { const b = await readBody(req); return json(200, await sendMessage(b || {})) }
if (p === '/gmail/trash' && method === 'POST') { const b = await readBody(req); if (!b || !b.id) return json(400, { error: 'id requis' }); return json(200, await trashMessage(b.id)) }
if (p === '/gmail/delete' && method === 'POST') { const b = await readBody(req); if (!b || !b.id) return json(400, { error: 'id requis' }); return json(200, await deleteMessage(b.id)) }
} catch (e) { return json(500, { error: e.message }) }
return json(404, { error: 'not found' })
}
function readBody (req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 5e6) req.destroy() }); req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { resolve({}) } }); req.on('error', () => resolve({})) }) }
module.exports = { getToken, listMessages, getMessage, getAttachment, sendMessage, trashMessage, deleteMessage, markSpam, trashThread, poll, start, stop, handle, mailbox }

View File

@ -1,6 +1,7 @@
'use strict'
const http = require('http')
const https = require('https')
const fs = require('fs')
const { URL } = require('url')
const cfg = require('./config')
@ -78,18 +79,48 @@ function erpRequest (method, path, body) {
return erpFetch(path, { method, ...(body && { body }) })
}
async function lookupCustomerByPhone (phone) {
const digits = phone.replace(/\D/g, '').slice(-10)
const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'tel_home', 'tel_office'])
for (const field of ['cell_phone', 'tel_home', 'tel_office']) {
// Renvoie TOUTES les fiches Customer dont un champ téléphone se termine par les 10 chiffres (multi-champs
// incl. mobile_no), dédupliquées. Permet de choisir parmi plusieurs fiches quand le numéro est partagé.
async function lookupCustomersByPhone (phone, limit = 6) {
const digits = String(phone || '').replace(/\D/g, '').slice(-10)
if (digits.length < 7) return []
const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'mobile_no', 'tel_home', 'tel_office', 'email_id', 'territory'])
const byName = new Map()
for (const field of ['cell_phone', 'mobile_no', 'tel_home', 'tel_office']) {
const filters = JSON.stringify([[field, 'like', '%' + digits]])
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${limit}`
try {
const res = await erpFetch(path)
if (res.status === 200 && res.data?.data?.length > 0) return res.data.data[0]
} catch (e) { log('lookupCustomerByPhone error on ' + field + ':', e.message) }
if (res.status === 200 && res.data?.data?.length) {
for (const c of res.data.data) if (!byName.has(c.name)) byName.set(c.name, { name: c.name, customer_name: c.customer_name, matched_field: field, email: c.email_id || '', territory: c.territory || '' })
}
} catch (e) { log('lookupCustomersByPhone ' + field + ':', e.message) }
if (byName.size >= limit) break
}
return null
return [...byName.values()].slice(0, limit)
}
// Compat : 1re fiche (ou null). Les flux existants continuent de marcher.
async function lookupCustomerByPhone (phone) { const all = await lookupCustomersByPhone(phone, 1); return all[0] || null }
// Fiches clients par COURRIEL (email_id exact prioritaire, puis email_billing qui peut contenir plusieurs adresses).
// Même forme de retour que lookupCustomersByPhone → réutilise le sélecteur de fiche (multi-match) côté Ops.
async function lookupCustomersByEmail (email, limit = 6) {
const e = String(email || '').trim().toLowerCase()
if (!/.+@.+\..+/.test(e)) return []
const fields = JSON.stringify(['name', 'customer_name', 'email_id', 'email_billing', 'cell_phone', 'mobile_no', 'territory'])
const byName = new Map()
for (const [field, op, val] of [['email_id', '=', e], ['email_billing', 'like', '%' + e + '%']]) {
const filters = JSON.stringify([[field, op, val]])
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${limit}`
try {
const res = await erpFetch(path)
if (res.status === 200 && res.data?.data?.length) {
for (const c of res.data.data) if (!byName.has(c.name)) byName.set(c.name, { name: c.name, customer_name: c.customer_name, matched_field: field, email: c.email_id || e, phone: c.cell_phone || c.mobile_no || '', territory: c.territory || '' })
}
} catch (err) { log('lookupCustomersByEmail ' + field + ':', err.message) }
if (byName.size >= limit) break
}
return [...byName.values()].slice(0, limit)
}
function createCommunication (fields) {
@ -141,8 +172,25 @@ function cors (res, methods = 'GET, POST, OPTIONS') {
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
}
// ── Utilitaires partagés (stores JSON, sets de dédup, data-URL) — regroupés ici pour éviter la duplication ──
// Lit un fichier JSON ; renvoie `fallback` (copie) si absent/illisible. Comportement identique aux load*() qu'il remplace.
function readJsonFile (path, fallback) {
try { return JSON.parse(fs.readFileSync(path, 'utf8')) } catch (e) { return (fallback !== undefined ? fallback : null) }
}
// Écrit un objet en JSON indenté ; renvoie true/false. Best-effort (log en cas d'échec).
function writeJsonFile (path, obj) {
try { fs.writeFileSync(path, JSON.stringify(obj, null, 2)); return true } catch (e) { log('writeJsonFile ' + path + ': ' + e.message); return false }
}
// Set de dédup persistée (ex. ids de messages déjà traités). Lit un tableau JSON → Set.
function loadSeenSet (path) { try { return new Set(JSON.parse(fs.readFileSync(path, 'utf8'))) } catch (e) { return new Set() } }
// Sauve un Set en tableau JSON, borné aux `cap` derniers (évite la croissance infinie).
function saveSeenSet (path, set, cap = 3000) { try { fs.writeFileSync(path, JSON.stringify([...set].slice(-cap))) } catch (e) { /* */ } }
// Retire le préfixe data:image/...;base64, → base64 pur (pour l'OCR vision).
function stripDataUrl (s) { return String(s || '').replace(/^data:[^;]+;base64,/, '') }
module.exports = {
log, json, parseBody, httpRequest, cors,
erpFetch, erpRequest, lookupCustomerByPhone, createCommunication,
erpFetch, erpRequest, lookupCustomerByPhone, lookupCustomersByPhone, lookupCustomersByEmail, createCommunication,
nbiRequest, deepGetValue,
readJsonFile, writeJsonFile, loadSeenSet, saveSeenSet, stripDataUrl,
}

View File

@ -366,6 +366,66 @@ function sync (opts = {}) {
return run
}
// INGESTION des tickets ASSIGNÉS À UN TECH (≠ pool 3301) → Dispatch Job assignés + datés, ÉDITABLES/réordonnables dans Ops.
// Idempotent par legacy_ticket_id (réutilise buildJob → coords/client/adresse/sujet+#/scheduled_date/legacy_dept-couleur).
// Terrain seulement + fenêtre due_date [loDays, hiDays] (défaut -7j→+30j) pour borner le volume. dryRun = APERÇU (0 écriture).
async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30 } = {}) {
resetCaches()
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' }
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const staffToTech = {}; const ids = []
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); if (s >= 2 && s !== TARGO_TECH_STAFF_ID) { staffToTech[s] = t.technician_id || t.name; ids.push(s) } }
if (!ids.length) return { ok: true, dryRun, created: 0, note: 'aucun tech mappé' }
const now = Math.floor(Date.now() / 1000), D = 86400
const lo = now + loDays * D, hi = now + hiDays * D
const [rows] = await p.query(
`SELECT t.id, t.assign_to, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
(SELECT mm.msg FROM ticket_msg mm WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%' ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
(SELECT mm3.msg FROM ticket_msg mm3 WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
FROM ticket t
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1))
WHERE t.status = 'open' AND t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?
ORDER BY t.due_date ASC`, [ids, lo, hi])
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
let created = 0, updated = 0, skipped = 0, errors = 0
const details = [], errSamples = []
for (const t of (rows || [])) {
try {
const subj = String(t.subject || '')
if (!(/est\.\s?time/i.test(subj) || FIELD_RE.test(t.dept || ''))) continue // terrain seulement
const tech = staffToTech[t.assign_to]; if (!tech) continue
const b = await buildJob(t)
b.payload.assigned_tech = tech; b.payload.status = 'assigned'
const ex = await findExisting(b.legacy_id)
if (ex) {
const patch = {}
if (!ex.assigned_tech) { patch.assigned_tech = tech; patch.status = 'assigned' } // ne CLOBBE jamais un tech déjà posé par le répartiteur
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
if (!ex.scheduled_date && b.payload.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
const hasC = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 1e-4
if (b.payload.latitude != null && !(hasC(ex.latitude) && hasC(ex.longitude))) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude }
if (dryRun) { skipped++; details.push({ legacy_id: b.legacy_id, action: 'exists', job: ex.name, tech, would_patch: Object.keys(patch) }) }
else if (Object.keys(patch).length) { const r = await erp.update('Dispatch Job', ex.name, patch); if (r && r.ok) updated++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'update' }) } }
else skipped++
} else if (dryRun) {
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 })
} 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' }) }
}
} 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, 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)
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
async function syncImpl ({ dryRun = false } = {}) {
resetCaches()
@ -863,9 +923,138 @@ function startSync () {
}
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = 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 »).
// LECTURE SEULE : 1 requête par appel (au clic dans Ops), zéro écriture. Pool plafonné à 2 conns (prod legacy protégée).
async function legacyTechTickets (staffId) {
const sid = Number(staffId); if (!sid) return { ok: false, error: 'staff_id invalide' }
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query(
`SELECT t.id, t.subject, dd.name AS dept, t.due_date, t.priority, 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 t.status = 'open' AND t.assign_to = ?
ORDER BY (t.due_date IS NULL OR t.due_date = 0), t.due_date ASC LIMIT 400`, [sid])
const decode = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return '' } })
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
const all = (rows || []).map(r => {
const subject = decode(r.subject)
const est = (subject.match(/est\.\s?time\s*([0-9hm: ]+)/i) || [])[1] || ''
return {
id: String(r.id), subject, dept: r.dept || '',
customer: r.company || [r.first_name, r.last_name].filter(Boolean).join(' ') || '',
city: r.city || '',
due: (r.due_date && Number(r.due_date) > 0) ? new Date(Number(r.due_date) * 1000).toISOString().slice(0, 10) : '',
est: est.trim(),
field: /est\.\s?time/i.test(subject) || FIELD_RE.test(r.dept || ''),
reply: `https://store.targo.ca/targo/reply_ticket.php?ticket=${r.id}`,
}
})
const jobs = all.filter(t => t.field)
const ids = jobs.map(t => t.id)
let inDispatch = new Set()
if (ids.length) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', ids]], fields: ['legacy_ticket_id'], limit: 1000 }); inDispatch = new Set((djs || []).map(d => String(d.legacy_ticket_id))) } catch (e) {} }
for (const t of jobs) t.in_dispatch = inDispatch.has(t.id)
return { ok: true, staff_id: sid, total_open: all.length, hidden_non_field: all.length - jobs.length, jobs }
}
// Dépt/sujet legacy → COMPÉTENCE (réplique roster.js deptToSkill) → couleur du bloc identique aux jobs importés.
function deptSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// Durée estimée d'un job legacy : « est. time XhYm » dans le sujet, sinon défaut par dépt.
function estLegacyHours (subject, dept) {
const s = String(subject || '')
let m = s.match(/est\.\s?time\s*(\d+)\s*h(?:\s*(\d+)\s*m)?/i)
if (m) return Math.round(((+m[1]) + (m[2] ? (+m[2]) / 60 : 0)) * 10) / 10
m = s.match(/est\.\s?time\s*(\d+)\s*m/i)
if (m) return Math.round((+m[1]) / 60 * 10) / 10
const d = String(dept || '').toLowerCase()
if (/install/.test(d)) return 2
if (/r[eé]paration|fusion/.test(d)) return 1.5
return 1
}
// Charge LEGACY (osTicket) DATÉE par tech×jour sur la fenêtre [start, start+days[ : pour « appliquer » les durées
// estimées des jobs assignés directement dans le legacy sur les timelines, MAIS seulement pour les journées affichées.
// 1 requête bornée par due_date (petit résultat). Clé = technician_id|iso (= la clé d'occupancy côté Ops).
async function legacyWindowLoad (start, days) {
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
const n = Math.max(1, Math.min(31, Number(days) || 7))
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const staffToTech = {}; const ids = []
for (const t of (techs || [])) { const mm = String(t.technician_id || '').match(/(\d+)$/); if (!mm) continue; const sx = Number(mm[1]); if (sx >= 2 && sx !== TARGO_TECH_STAFF_ID) { staffToTech[sx] = t.technician_id || t.name; ids.push(sx) } } // exclut le pool « Tech Targo » (3301) — backlog, pas une personne
if (!ids.length) return { ok: true, load: {} }
const isos = []; for (let i = 0; i < n; i++) { const dd = new Date(start + 'T12:00:00Z'); dd.setUTCDate(dd.getUTCDate() + i); isos.push(dd.toISOString().slice(0, 10)) }
const isoSet = new Set(isos)
const startUnix = Math.floor(Date.parse(start + 'T00:00:00Z') / 1000)
const lo = startUnix - 2 * 86400, hi = startUnix + (n + 2) * 86400
const decode = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
// Tickets lus sur F LIVE via le pont PHP (le miroir n'est PAS rafraîchi pour `ticket` → périmé). Repli miroir si pont indispo.
let rows = []
try {
const w = await legacyWrite({ action: 'tickets_window', lo, hi, staff: ids.join(',') })
rows = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []
} catch (e) {
try { const [mr] = await p.query(`SELECT t.id, t.subject, t.assign_to, t.due_date, dd.name AS dept FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id WHERE t.status = 'open' AND t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?`, [ids, lo, hi]); rows = mr } catch (e2) {}
}
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i // jobs TERRAIN (cohérent avec le 📋) — exclut facturation/vente/admin
// Exclut les tickets DÉJÀ ingérés comme vrais Dispatch Job (legacy_ticket_id) → ils s'affichent via l'occupancy ; évite le DOUBLON sur la timeline.
let ingested = new Set()
// Le Set « ingéré » = EXACTEMENT les tickets que l'occupation DESSINE (occupancyByTechDay filtre status in [open,assigned,in_progress]).
// Sinon un DJ Completed/Cancelled/closed supprime le synthétique SANS rien dessiner → ticket F encore ouvert = caché des DEUX côtés
// (cas Nathan : 6 DJ Cancelled ; + 116 DJ Completed au total). F reste autoritaire (ticket ouvert+assigné+dû) → on redessine le bloc.
try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['legacy_ticket_id'], limit: 5000 }); ingested = new Set((djs || []).map(d => String(d.legacy_ticket_id))) } catch (e) {}
const load = {}
for (const r of (rows || [])) {
const tech = staffToTech[r.assign_to]; if (!tech || !r.due_date) continue
if (ingested.has(String(r.id))) continue // déjà un vrai Dispatch Job → pas de bloc synthétique
const subj = decode(r.subject)
// TERRAIN = exige un DÉPLACEMENT (install / réparation / TV / téléphonie / fusion) → bloc horaire.
// « NP - » = note/suivi rapide (≤2 min, souvent au support) → JAMAIS terrain même si le dept matche → fine ligne verticale.
// Le reste (Facturation, Vente, ToDo, Support…) → non-terrain → fine ligne verticale aussi. On liste tout, mais seul le terrain occupe la charge.
const npQuick = /\bNP\b\s*-/i.test(subj)
const isField = !npQuick && (/est\.\s?time/i.test(subj) || FIELD_RE.test(r.dept || ''))
const iso = new Date(Number(r.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
if (!isoSet.has(iso)) continue
const est = estLegacyHours(r.subject, r.dept)
const k = tech + '|' + iso
const e = load[k] || (load[k] = { h: 0, n: 0, officeN: 0, jobs: [] })
// Seul le TERRAIN compte dans la charge horaire (occupation/surcharge). L'admin/facturation est listée (jobs) mais ne gonfle pas l'occupation.
if (isField) { e.h += est; e.n++ } else { e.officeN++ }
e.jobs.push({ id: String(r.id), subject: subj, est_h: est, dept: r.dept || '', skill: deptSkill(r.dept || subj), field: isField })
}
for (const k in load) load[k].h = Math.round(load[k].h * 10) / 10
return { ok: true, load }
}
async function handle (req, res, method, path) {
try {
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
if (path === '/dispatch/legacy-sync/tech-tickets' && method === 'GET') { // jobs legacy ouverts assignés à UN tech (lecture seule)
const q = new URL(req.url, 'http://localhost').searchParams
let sid = q.get('staff'); const tech = q.get('tech')
if (!sid && tech) { const m = String(tech).match(/(\d+)$/); sid = m ? m[1] : '' }
if (!sid) return json(res, 400, { ok: false, error: 'staff ou tech requis' })
return json(res, 200, await legacyTechTickets(sid))
}
if (path === '/dispatch/legacy-sync/window-load' && method === 'GET') { // charge legacy datée par tech×jour (fenêtre affichée) — lecture seule
const q = new URL(req.url, 'http://localhost').searchParams
const start = q.get('start'); const days = q.get('days') || '7'
if (!start) return json(res, 400, { ok: false, error: 'start requis' })
return json(res, 200, await legacyWindowLoad(start, days))
}
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'GET') return json(res, 200, await ingestAssigned({ dryRun: true })) // APERÇU (0 écriture)
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'POST') { // INGÈRE : crée les Dispatch Job assignés (terrain, fenêtre)
const q = new URL(req.url, 'http://localhost').searchParams
return json(res, 200, await ingestAssigned({ dryRun: false, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30 }))
}
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
@ -905,6 +1094,11 @@ async function handle (req, res, method, path) {
return json(res, 200, await techSyncApply(ids))
}
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
if (path === '/dispatch/legacy-sync/reconcile-jobs' && method === 'GET') return json(res, 200, await reconcileLegacyJobs({})) // APERÇU : aligne DJ legacy actifs sur F (0 écriture)
if (path === '/dispatch/legacy-sync/reconcile-jobs' && method === 'POST') { // APPLIQUE (confirm=RECONCILE) : corrige tech / annule selon F. N'écrit QUE ERPNext. purgePool=1 = aussi annuler les F=pool (opt-in risqué).
const q = new URL(req.url, 'http://localhost').searchParams
return json(res, 200, await reconcileLegacyJobs({ confirm: q.get('confirm') || '', purgePool: q.get('purgePool') === '1' }))
}
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
@ -918,4 +1112,62 @@ async function handle (req, res, method, path) {
}
}
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
// ─── Réconciliation F→OPS : aligner les Dispatch Job legacy ACTIFS sur la vérité de F (autoritaire) ───
// Pour chaque DJ actif (open/assigned/in_progress) portant un legacy_ticket_id, on lit l'état RÉEL dans F (action pont
// `ticket_status`) puis : F fermé/résolu → ANNULE le DJ ; F renvoyé au pool (Tech Targo) ou à personne → ANNULE ;
// F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech` (+ scheduled_date = due_date F) ; F donné à un non-tech
// (bureau) → ANNULE (pas de bloc terrain pour du bureau) ; F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut.
async function reconcileLegacyJobs (opts = {}) {
const apply = opts.confirm === 'RECONCILE'
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const decode = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const techToStaff = {}; const staffToTech = {}
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id }
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'scheduled_date', 'status', 'subject'], limit: 5000 })
const plan = { reassign: [], cancel_closed: [], cancel_pool: [], cancel_office: [], agree: [], not_in_f: [] }
const ids = [...new Set((djs || []).map(d => String(d.legacy_ticket_id)).filter(Boolean))]
const fState = {}
for (let i = 0; i < ids.length; i += 400) {
const chunk = ids.slice(i, i + 400)
try { const w = await legacyWrite({ action: 'ticket_status', ids: chunk.join(',') }); const rows = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []; for (const r of rows) fState[String(r.id)] = r } catch (e) { /* lot pont en échec → ces tickets resteront en not_in_f (prudence : on ne touche pas) */ }
}
for (const j of (djs || [])) {
const tid = String(j.legacy_ticket_id)
const f = fState[tid]
const curStaff = techToStaff[j.assigned_tech]
const subj = decode(j.subject || '').slice(0, 50)
if (!f) { plan.not_in_f.push({ job: j.name, tid, subj }); continue }
const fStaff = Number(f.assign_to) || 0
const open = String(f.status || '').toLowerCase() === 'open'
if (!open) { plan.cancel_closed.push({ job: j.name, tid, subj, f_status: f.status }); continue }
if (fStaff === TARGO_TECH_STAFF_ID || fStaff < 2) {
if (!j.assigned_tech) { plan.agree.push({ job: j.name, tid }); continue } // DJ NON assigné + F au pool = backlog COHÉRENT (les deux au pool) → on laisse
plan.cancel_pool.push({ job: j.name, tid, subj }); continue // DJ assigné à un tech mais F au pool → vraie divergence (stagé non publié OU périmé)
}
if (fStaff === curStaff) { plan.agree.push({ job: j.name, tid }); continue }
const fTech = staffToTech[fStaff]
if (!fTech) { plan.cancel_office.push({ job: j.name, tid, subj, f_staff: fStaff }); continue }
const iso = f.due_date ? new Date(Number(f.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : (j.scheduled_date || null)
plan.reassign.push({ job: j.name, tid, subj, from: j.assigned_tech || '(aucun)', to: fTech, sched: iso })
}
const counts = { active_total: (djs || []).length, reassign: plan.reassign.length, cancel_closed: plan.cancel_closed.length, cancel_pool: plan.cancel_pool.length, cancel_office: plan.cancel_office.length, agree: plan.agree.length, not_in_f: plan.not_in_f.length }
const samples = { reassign: plan.reassign.slice(0, 10), cancel_closed: plan.cancel_closed.slice(0, 10), cancel_pool: plan.cancel_pool.slice(0, 10), cancel_office: plan.cancel_office.slice(0, 10), not_in_f: plan.not_in_f.slice(0, 10) }
if (!apply) return { ok: true, apply: false, counts, samples, note: 'cancel_pool n\'est PAS appliqué par défaut (peut être une assignation OPS stagée non publiée) — opt-in purgePool=1 requis.' }
let done = 0; const errs = []
// SÛRS : F dit explicitement fermé / bureau → on annule le DJ. cancel_pool EXCLU par défaut (ambigu staging vs périmé).
const toCancel = [...plan.cancel_closed, ...plan.cancel_office]
if (opts.purgePool) toCancel.push(...plan.cancel_pool) // OPT-IN EXPLICITE : F=pool → annule (RISQUE : efface une assignation OPS non encore poussée dans F)
for (const c of toCancel) {
try { await erp.update('Dispatch Job', c.job, { status: 'Cancelled' }) } catch (e) { errs.push({ job: c.job, action: 'cancel', err: String(e.message || e) }) }
if (++done % 25 === 0) await sleep(200)
}
for (const r of plan.reassign) {
const patch = { assigned_tech: r.to }; if (r.sched) patch.scheduled_date = r.sched
try { await erp.update('Dispatch Job', r.job, patch) } catch (e) { errs.push({ job: r.job, action: 'reassign', err: String(e.message || e) }) }
if (++done % 25 === 0) await sleep(200)
}
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
}
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests

View File

@ -0,0 +1,582 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// F (gestionclient) → ERPNext — RÉCONCILIATION DES PAIEMENTS, mode PREVIEW.
//
// LECTURE SEULE par défaut. F reste AUTORITAIRE sur la facturation ; ERPNext en
// est une COPIE. On ne touche JAMAIS F. L'« apply » (miroir des paiements) est
// gardé derrière confirm==='F-WINS' et n'écrit que la couche LOG (Payment Entry
// + références), exactement comme la migration l'a fait pour les 542k paiements
// existants.
//
// ÉTAT VÉRIFIÉ EN PROD (2026-06-12) — à garder en tête pour la cohérence :
// • Sales Invoice : ~629 927 en docstatus=2 (ANNULÉES) → l'AR ERPNext est GELÉ
// (les factures annulées ont outstanding=0 et n'impactent pas l'AR vivant).
// 3 factures « vivantes » récentes en série SINV-2026-NNNNNN.
// • Payment Entry : 542 102, nommées PE-{id zfill10}, mode_of_payment peuplé,
// paid_from='Comptes clients - T', paid_to='Banque - T', docstatus=1.
// → importées par reimport_payments.py (zfill10 + MODE_MAP). max id = 542325.
// • GL (3,1M) + PLE (1,06M) construits par clean_reimport.py (contre les
// factures désormais annulées).
// • F : 553 372 paiements max → delta ≈ 11k paiements nouveaux depuis le miroir.
//
// CONSÉQUENCE : « appliquer un paiement » ne peut PAS réduire un solde de facture
// ERPNext (factures annulées). La cohérence des SOLDES vit dans F (invoice.billed_amt).
// Ce module : (1) PREVIEW/réconciliation F↔ERPNext, (2) MIROIR du log de paiement
// (PE+PER) pour le delta, idempotent par PE-{id}, gardé. La couche GL/PLE/outstanding
// reste figée tant que la BASCULE de facturation vers ERPNext n'est pas décidée.
//
// Modèle de nommage (= clés d'idempotence, identiques à la migration) :
// Payment Entry PE-{legacy_payment_id zfill10}
// Payment Entry Reference PER-{legacy_payment_id}-{idx} → SINV-{invoice_id zfill10}
// Sales Invoice SINV-{legacy_invoice_id zfill10}
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { json, log } = require('./helpers')
let mysql; try { mysql = require('mysql2/promise') } catch (e) { mysql = null }
const REPORT_DIR = path.join(__dirname, '..', 'data')
const REPORT_FILE = path.join(REPORT_DIR, 'legacy-payments-preview.json')
const RECON_FILE = path.join(REPORT_DIR, 'legacy-payments-recon.json') // cache réconciliation (évite de rescanner F live)
const STATE_FILE = path.join(REPORT_DIR, 'legacy-payments-state.json')
const RUN_FILE = path.join(REPORT_DIR, 'legacy-payments-run.json') // dernier cycle (santé/observabilité)
// ── Legacy type → ERPNext mode_of_payment (IDENTIQUE à reimport_payments.py) ──
const MODE_MAP = {
ppa: 'Bank Draft',
'paiement direct': 'Bank Transfer',
'carte credit': 'Credit Card',
cheque: 'Cheque',
comptant: 'Cash',
reversement: 'Bank Transfer',
credit: 'Credit Note',
'credit targo': 'Credit Note',
'credit facture': 'Credit Note',
}
const CREDIT_TYPES = new Set(['credit', 'credit targo', 'credit facture'])
const PAID_FROM = 'Comptes clients - T' // Receivable (idem migration)
const PAID_TO = 'Banque - T' // Bank
const pad10 = (n) => String(n).padStart(10, '0')
const peName = (id) => 'PE-' + pad10(id)
const sinvName = (id) => 'SINV-' + pad10(id)
function modeFor (type) { return MODE_MAP[String(type || '').trim().toLowerCase()] || 'Bank Transfer' }
function tsToDate (t) { if (!t || t <= 0) return null; try { return new Date(Number(t) * 1000).toISOString().slice(0, 10) } catch { return null } }
function r2 (v) { return Math.round((Number(v) || 0) * 100) / 100 }
// ── pools ─────────────────────────────────────────────────────────────────────
function pgPool () { return require('./address-db').pool() } // PG ERPNext (_eb65bdc0c4b1b2d6)
let _my
function myPool () {
if (!mysql) return null
if (!_my) _my = mysql.createPool({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true, connectTimeout: 8000 })
return _my
}
const pgq = (s, a) => pgPool().query(s, a).then(r => r.rows)
function loadState () { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) } catch { return {} } }
function saveState (s) { try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)) } catch (e) { log('legacy-payments: state write fail', e.message) } }
// ── état de l'AR ERPNext (factures par docstatus) ────────────────────────────
async function arState () {
const rows = await pgq('SELECT docstatus, COUNT(*) c FROM "tabSales Invoice" GROUP BY docstatus')
const by = { draft: 0, submitted: 0, cancelled: 0 }
for (const r of rows) { if (r.docstatus === 0) by.draft = Number(r.c); else if (r.docstatus === 1) by.submitted = Number(r.c); else if (r.docstatus === 2) by.cancelled = Number(r.c) }
const frozen = by.submitted <= 5 && by.cancelled > 1000 // l'immense majorité annulée → AR gelé
return { ...by, ar_frozen: frozen, note: frozen ? 'AR ERPNext GELÉ (factures migrées annulées) — F autoritaire sur les soldes' : 'AR ERPNext actif' }
}
// ── PREVIEW : delta de paiements F non encore reflétés dans ERPNext ───────────
// since = id de paiement plancher (défaut : MAX(legacy_payment_id) dans ERPNext).
async function previewPayments ({ since = null, limit = 20000, window = 0 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
const erpMax = Number((await pgq('SELECT COALESCE(MAX(legacy_payment_id),0) mx FROM "tabPayment Entry"'))[0].mx) || 0
// GAP-AWARE : on recule le plancher d'une fenêtre → on repère aussi les TROUS sous le max
// (paiements sautés « sans client » résolus plus tard). Idempotent : les présents sont sautés.
const sinceId = (since != null) ? Number(since) : Math.max(0, erpMax - Number(window || 0))
const ar = await arState()
const [prows] = await mp.query(
'SELECT id, account_id, date_orig, amount, applied_amt, type, reference, memo FROM payment WHERE id > ? ORDER BY id LIMIT ?',
[sinceId, limit + 1])
const hasMore = prows.length > limit
const payments = hasMore ? prows.slice(0, limit) : prows
const ids = payments.map(p => p.id)
// payment_item (allocations) du delta
const itemsByPay = {}
if (ids.length) {
const [ir] = await mp.query(`SELECT payment_id, invoice_id, amount FROM payment_item WHERE payment_id IN (${ids.map(() => '?').join(',')})`, ids)
for (const it of ir) (itemsByPay[it.payment_id] || (itemsByPay[it.payment_id] = [])).push(it)
}
// index Customer (par legacy_account_id) — seulement les comptes concernés
const acctIds = [...new Set(payments.map(p => Number(p.account_id)))]
const custMap = {}
for (let i = 0; i < acctIds.length; i += 1000) {
const b = acctIds.slice(i, i + 1000); if (!b.length) continue
for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x
}
// existence des factures cibles (par legacy_invoice_id) + docstatus
const invIds = [...new Set(Object.values(itemsByPay).flat().map(it => Number(it.invoice_id)).filter(Boolean))]
const invMap = {}
for (let i = 0; i < invIds.length; i += 2000) {
const b = invIds.slice(i, i + 2000); if (!b.length) continue
for (const x of await pgq('SELECT legacy_invoice_id, name, docstatus FROM "tabSales Invoice" WHERE legacy_invoice_id = ANY($1)', [b])) invMap[Number(x.legacy_invoice_id)] = x
}
// PE déjà miroir (idempotence) pour le delta
const existingPe = new Set()
if (ids.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [ids])) existingPe.add(Number(x.legacy_payment_id))
// ── classification ──
const byType = {}; const issues = { no_customer: 0, unallocated: 0, alloc_missing_invoice: 0, alloc_to_cancelled: 0, credits: 0 }
const custRoll = {}; let totalAmt = 0, wouldCreate = 0, alreadyMirror = 0
const samples = { no_customer: [], missing_invoice: [], credits: [], to_create: [] }
let minDate = null, maxDate = null
for (const p of payments) {
const type = String(p.type || '').trim().toLowerCase()
const amt = r2(Math.abs(p.amount))
const d = tsToDate(p.date_orig)
if (d) { if (!minDate || d < minDate) minDate = d; if (!maxDate || d > maxDate) maxDate = d }
byType[type] = byType[type] || { count: 0, amount: 0 }
byType[type].count++; byType[type].amount = r2(byType[type].amount + amt)
totalAmt = r2(totalAmt + amt)
if (CREDIT_TYPES.has(type)) issues.credits++
const cust = custMap[Number(p.account_id)]
if (!cust) { issues.no_customer++; if (samples.no_customer.length < 15) samples.no_customer.push({ legacy_payment_id: p.id, account_id: p.account_id, amount: amt, type }); continue }
const alloc = itemsByPay[p.id] || []
let resolvedAlloc = 0, missingAlloc = 0, cancelledAlloc = 0
const refs = []
for (const it of alloc) {
const inv = invMap[Number(it.invoice_id)]
if (!inv) { missingAlloc++; if (samples.missing_invoice.length < 15) samples.missing_invoice.push({ legacy_payment_id: p.id, invoice_id: it.invoice_id }); continue }
if (inv.docstatus === 2) cancelledAlloc++
resolvedAlloc++
refs.push({ reference_name: inv.name, allocated: r2(Math.abs(it.amount)), cancelled: inv.docstatus === 2 })
}
if (missingAlloc) issues.alloc_missing_invoice += missingAlloc
if (cancelledAlloc) issues.alloc_to_cancelled += cancelledAlloc
if (!alloc.length) issues.unallocated++
if (existingPe.has(Number(p.id))) { alreadyMirror++; continue }
wouldCreate++
const cr = custRoll[cust.name] || (custRoll[cust.name] = { customer: cust.name, customer_name: cust.customer_name, count: 0, amount: 0 })
cr.count++; cr.amount = r2(cr.amount + amt)
if (samples.to_create.length < 25) samples.to_create.push({ pe: peName(p.id), date: d, amount: amt, type, mode: modeFor(type), customer_name: cust.customer_name, refs: refs.slice(0, 4), unallocated: !alloc.length })
}
const topCustomers = Object.values(custRoll).sort((a, b) => b.amount - a.amount).slice(0, 20)
const out = {
mode: 'PREVIEW (lecture seule — aucune écriture ni dans F ni dans ERPNext)',
generated_at: new Date().toISOString(),
ar_state: ar,
watermark: { since_payment_id: sinceId, erp_max_payment_id: erpMax, source: (since != null ? 'override' : 'erp_max') },
delta: {
scanned: payments.length, has_more: hasMore, would_create: wouldCreate, already_mirrored: alreadyMirror,
total_amount: totalAmt, date_range: { from: minDate, to: maxDate },
by_type: Object.fromEntries(Object.entries(byType).map(([k, v]) => [k, { count: v.count, amount: v.amount, mode_of_payment: modeFor(k) }])),
},
issues,
top_customers: topCustomers,
samples,
consistency_note: ar.ar_frozen
? "ERPNext AR gelé : le miroir ajoute le LOG de paiement (PE+PER) sans réduire de solde (factures annulées). Les SOLDES restent dans F (invoice.billed_amt). La bascule de facturation vers ERPNext est une décision séparée."
: 'ERPNext AR actif : le miroir des paiements impacte les soldes.',
}
try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(REPORT_FILE, JSON.stringify(out, null, 2)); out.saved_to = REPORT_FILE } catch (e) { log('legacy-payments: report write fail', e.message) }
return out
}
// F invoice → statut ERPNext REFLÉTANT F (mirroir clean_reimport + fix_invoice_outstanding).
// docstatus=1 (submitted) car « on insère selon le statut qu'elles ont dans F ».
const TODAY = () => new Date().toISOString().slice(0, 10)
function mapInvoice (f, today) {
const total = r2(f.total_amt); const billed = r2(f.billed_amt)
const isReturn = total < 0
const due = tsToDate(f.due_date) || tsToDate(f.date_orig)
const owed = isReturn ? 0 : r2(Math.max(0, total - billed))
let status
if (isReturn) status = 'Return'
else if (owed <= 0.005) status = 'Paid'
else if (due && due < (today || TODAY())) status = 'Overdue'
else status = 'Unpaid'
return { sinv: sinvName(f.id), legacy_invoice_id: Number(f.id), is_return: isReturn ? 1 : 0, total, billed, outstanding: owed, status, docstatus: 1 }
}
// ── PREVIEW : delta de FACTURES F absentes d'ERPNext (le volet manquant) ──────
// Lecture seule. Quantifie les factures créées dans F depuis le gel de migration.
async function previewInvoices ({ since = null, limit = 30000 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir legacy-db indisponible')
const erpMax = Number((await pgq('SELECT COALESCE(MAX(legacy_invoice_id),0) mx FROM "tabSales Invoice"'))[0].mx) || 0
const sinceId = (since != null) ? Number(since) : erpMax
const [rows] = await mp.query('SELECT id, account_id, date_orig, due_date, total_amt, billed_amt, billing_status FROM invoice WHERE id > ? ORDER BY id LIMIT ?', [sinceId, limit + 1])
const hasMore = rows.length > limit
const invs = hasMore ? rows.slice(0, limit) : rows
const today = TODAY()
const byStatus = { Paid: 0, Unpaid: 0, Overdue: 0, Return: 0 }
const acctIds = [...new Set(invs.map(i => Number(i.account_id)))]
const custMap = {}
for (let i = 0; i < acctIds.length; i += 1000) { const b = acctIds.slice(i, i + 1000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x }
const byMonth = {}; const custRoll = {}
let invoiced = 0, owed = 0, billed = 0, returns = 0, noCust = 0, unbilledCnt = 0
let minD = null, maxD = null
const sampleMapped = []
for (const i of invs) {
const tgt = mapInvoice(i, today); byStatus[tgt.status]++
const tot = tgt.total; const ow = tgt.outstanding
const d = tsToDate(i.date_orig); if (d) { if (!minD || d < minD) minD = d; if (!maxD || d > maxD) maxD = d }
invoiced = r2(invoiced + tot); owed = r2(owed + ow); if (Number(i.billing_status) === 1) billed = r2(billed + tot)
if (tot < 0) returns++
if (Number(i.billing_status) !== 1) unbilledCnt++
if (sampleMapped.length < 12) sampleMapped.push({ sinv: tgt.sinv, status: tgt.status, total: tgt.total, outstanding: tgt.outstanding, is_return: tgt.is_return })
const mo = d ? d.slice(0, 7) : '?'; byMonth[mo] = byMonth[mo] || { count: 0, invoiced: 0, owed: 0 }
byMonth[mo].count++; byMonth[mo].invoiced = r2(byMonth[mo].invoiced + tot); byMonth[mo].owed = r2(byMonth[mo].owed + ow)
const cust = custMap[Number(i.account_id)]
if (!cust) { noCust++; continue }
const cr = custRoll[cust.name] || (custRoll[cust.name] = { customer: cust.name, customer_name: cust.customer_name, count: 0, owed: 0 })
cr.count++; cr.owed = r2(cr.owed + ow)
}
return {
mode: 'PREVIEW (lecture seule)', generated_at: new Date().toISOString(),
watermark: { since_invoice_id: sinceId, erp_max_invoice_id: erpMax },
delta: { scanned: invs.length, has_more: hasMore, total_invoiced: invoiced, total_billed: billed, total_owed: owed, returns, unbilled: unbilledCnt, no_customer: noCust, date_range: { from: minD, to: maxD } },
target_status: byStatus, // statut ERPNext qui serait appliqué (reflète F)
sample_mapped: sampleMapped,
by_month: byMonth,
top_customers_by_owed: Object.values(custRoll).sort((a, b) => b.owed - a.owed).slice(0, 20),
note: "Factures F créées depuis le gel (id > max importé). Volet PRÉREQUIS au miroir des paiements : sans elles, les allocations de paiement restent orphelines.",
}
}
// ── VUE D'ENSEMBLE : l'écart de facturation complet (factures + paiements) ────
async function billingGap () {
const [inv, pay, ar] = await Promise.all([previewInvoices(), previewPayments(), arState()])
return {
generated_at: new Date().toISOString(),
ar_state: ar,
frozen_since: inv.delta.date_range.from,
invoices_behind: { count: inv.delta.scanned, has_more: inv.delta.has_more, total_invoiced: inv.delta.total_invoiced, total_owed: inv.delta.total_owed, returns: inv.delta.returns, by_month: inv.by_month },
payments_behind: { count: pay.delta.scanned, has_more: pay.delta.has_more, total_amount: pay.delta.total_amount, by_type: pay.delta.by_type, would_create: pay.delta.would_create, orphan_allocations: pay.issues.alloc_missing_invoice },
note: 'F autoritaire. ERPNext = copie gelée depuis frozen_since. Rétablir les factures d\'abord rend les allocations de paiement résolvables.',
}
}
// ── RÉCONCILIATION GLOBALE F ↔ ERPNext (« vérifier ce qu'on a déjà ») ─────────
// Agrégats BORNÉS sur F live (one-shot) + ERPNext, mis EN CACHE (RECON_FILE) pour
// ne JAMAIS rescanner F live à chaque appel. ?fresh=1 force un recalcul.
async function reconcileSummary ({ fresh = false } = {}) {
if (!fresh) { try { const c = JSON.parse(fs.readFileSync(RECON_FILE, 'utf8')); c.from_cache = true; return c } catch {} }
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
// ── F live : agrégats indexés/one-shot (MVCC, non bloquant) ──
const [[fi]] = await mp.query(`SELECT COUNT(*) c, MAX(id) mx,
SUM(CASE WHEN total_amt < 0 THEN 1 ELSE 0 END) returns,
SUM(CASE WHEN total_amt > billed_amt + 0.005 THEN 1 ELSE 0 END) open_cnt,
ROUND(SUM(CASE WHEN total_amt > billed_amt THEN total_amt - billed_amt ELSE 0 END), 2) owed
FROM invoice`)
const [fbs] = await mp.query('SELECT billing_status, COUNT(*) c FROM invoice GROUP BY billing_status')
const [[fp]] = await mp.query('SELECT COUNT(*) c, MAX(id) mx FROM payment')
// ── ERPNext : ce qu'on a déjà ──
const erpInv = (await pgq('SELECT docstatus, COUNT(*) c FROM "tabSales Invoice" GROUP BY docstatus')).reduce((o, r) => (o[r.docstatus] = Number(r.c), o), {})
const erpInvLeg = (await pgq('SELECT COUNT(*) c, MAX(legacy_invoice_id) mx FROM "tabSales Invoice" WHERE legacy_invoice_id > 0'))[0]
const erpPe = (await pgq('SELECT COUNT(*) c, MAX(legacy_payment_id) mx FROM "tabPayment Entry"'))[0]
const ar = await arState()
// ── divergence d'un échantillon : factures ERPNext annulées vs statut réel F ──
const samp = await pgq(`SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE docstatus=2 AND legacy_invoice_id>0 ORDER BY legacy_invoice_id DESC LIMIT 500`)
let paidInF = 0, owedInF = 0, absentInF = 0; const owedSamples = []
if (samp.length) {
const ids = samp.map(r => Number(r.legacy_invoice_id))
const [frows] = await mp.query(`SELECT id, total_amt, billed_amt, billing_status FROM invoice WHERE id IN (${ids.map(() => '?').join(',')})`, ids)
const fmap = {}; for (const f of frows) fmap[f.id] = f
for (const id of ids) { const f = fmap[id]; if (!f) { absentInF++; continue }
const du = r2(Math.max(0, Number(f.total_amt) - Number(f.billed_amt)))
if (du > 0.005) { owedInF++; if (owedSamples.length < 8) owedSamples.push({ sinv: sinvName(id), erp_status: 'Cancelled', f_owed: du, f_billing_status: f.billing_status }) } else paidInF++
}
}
const fInvTotal = Number(fi.c); const erpCovered = Number(erpInvLeg.c)
const out = {
generated_at: new Date().toISOString(), from_cache: false,
F: {
invoices_total: fInvTotal, max_invoice_id: Number(fi.mx),
by_billing_status: fbs.reduce((o, r) => (o[r.billing_status] = Number(r.c), o), {}),
reversals: Number(fi.returns), open_invoices: Number(fi.open_cnt), real_AR_owed: Number(fi.owed),
payments_total: Number(fp.c), max_payment_id: Number(fp.mx),
},
ERPNext: {
invoices_by_docstatus: { draft: erpInv[0] || 0, submitted: erpInv[1] || 0, cancelled: erpInv[2] || 0 },
invoices_with_legacy_id: erpCovered, max_legacy_invoice_id: Number(erpInvLeg.mx),
payments: Number(erpPe.c), max_legacy_payment_id: Number(erpPe.mx),
ar_frozen: ar.ar_frozen,
},
coverage: {
invoices_in_F_not_in_ERPNext: fInvTotal - erpCovered,
invoices_forward_gap: Math.max(0, Number(fi.mx) - Number(erpInvLeg.mx)), // id > max importé
payments_forward_gap: Math.max(0, Number(fp.mx) - Number(erpPe.mx)),
},
divergence_sample: { of: samp.length, erp_cancelled_but_F_paid: paidInF, erp_cancelled_but_F_owed: owedInF, absent_in_F: absentInF, owed_examples: owedSamples },
verdict: `F = vérité : ${Number(fi.open_cnt)} factures ouvertes, ${Number(fi.owed)}$ dus réellement. ERPNext reflète actuellement ~0 (toutes annulées). Pour refléter F : insérer/réactiver avec le statut F + outstanding=montant_du.`,
}
try { fs.writeFileSync(RECON_FILE, JSON.stringify(out, null, 2)); out.saved_to = RECON_FILE } catch (e) { log('recon write fail', e.message) }
return out
}
// ── RÉCONCILIATION D'UN COMPTE : timeline factures vs paiements (F autoritaire) ──
// Lecture seule. Sert à VOIR la cohérence pour un client (ERPNext copie vs F vérité).
async function reconcileAccount ({ accountId = null, customer = null } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
let acct = accountId
if (!acct && customer) {
const r = await pgq('SELECT legacy_account_id FROM "tabCustomer" WHERE name=$1', [customer])
acct = r[0] && Number(r[0].legacy_account_id)
}
if (!acct) throw new Error('accountId ou customer requis')
const [invs] = await mp.query('SELECT id, date_orig, total_amt, billed_amt, billing_status, due_date FROM invoice WHERE account_id=? ORDER BY date_orig', [acct])
const [pays] = await mp.query('SELECT id, date_orig, amount, type, reference FROM payment WHERE account_id=? ORDER BY date_orig', [acct])
// état ERPNext de ces PE/SINV
const peIds = pays.map(p => Number(p.id))
const erpPe = new Set(); if (peIds.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [peIds])) erpPe.add(Number(x.legacy_payment_id))
const f_balance = r2(invs.reduce((s, i) => s + Math.max(0, Number(i.total_amt) - Number(i.billed_amt)), 0))
const f_invoiced = r2(invs.reduce((s, i) => s + Number(i.total_amt), 0))
const f_paid = r2(pays.reduce((s, p) => s + Number(p.amount), 0))
return {
account_id: acct, customer,
f_summary: { invoices: invs.length, payments: pays.length, total_invoiced: f_invoiced, total_paid: f_paid, balance_owed: f_balance },
erp_mirror: { payments_in_erp: erpPe.size, payments_missing: pays.length - erpPe.size },
invoices: invs.slice(0, 60).map(i => ({ id: i.id, sinv: sinvName(i.id), date: tsToDate(i.date_orig), total: r2(i.total_amt), billed: r2(i.billed_amt), owed: r2(Math.max(0, Number(i.total_amt) - Number(i.billed_amt))), paid_in_F: Number(i.billing_status) === 1 })),
payments: pays.slice(0, 60).map(p => ({ id: p.id, pe: peName(p.id), date: tsToDate(p.date_orig), amount: r2(p.amount), type: p.type, mirrored_in_erp: erpPe.has(Number(p.id)) })),
note: 'F est la source de vérité du solde (total_amt billed_amt). ERPNext est une copie du LOG de paiement.',
}
}
// ── APPLY (gardé) — MIROIR du log de paiement uniquement (PE + PER), idempotent ──
// N'écrit QUE si confirm==='F-WINS'. Reproduit EXACTEMENT le schéma des 542k PE
// existants (PE-{zfill10}, mode_of_payment, paid_from/paid_to, docstatus=1, refs
// vers SINV existantes). N'écrit PAS GL/PLE/outstanding (couche figée — bascule à décider).
// Sans confirm → dry-run : renvoie le plan (échantillon des lignes qui seraient créées).
async function syncPayments ({ confirm = false, since = null, limit = 5000, window = 0 } = {}) {
const pre = await previewPayments({ since, limit, window })
const plan = {
mode: 'miroir LOG paiement (PE+PER) — GL/PLE/outstanding NON touchés',
ar_frozen: pre.ar_state.ar_frozen,
since_payment_id: pre.watermark.since_payment_id,
scanned: pre.delta.scanned, would_create: pre.delta.would_create, already_mirrored: pre.delta.already_mirrored,
total_amount: pre.delta.total_amount, by_type: pre.delta.by_type, issues: pre.issues,
sample: pre.samples.to_create.slice(0, 10),
}
if (confirm !== 'F-WINS') {
return { dry_run: true, ...plan, note: "POST { confirm:'F-WINS' } pour écrire le miroir (PE+PER). limit limite le lot. since force le plancher d'id." }
}
// ── écriture idempotente, transactionnelle ──
const mp = myPool()
const [prows] = await mp.query('SELECT id, account_id, date_orig, amount, type, reference, memo FROM payment WHERE id > ? ORDER BY id LIMIT ?', [pre.watermark.since_payment_id, limit])
const ids = prows.map(p => p.id)
const itemsByPay = {}
if (ids.length) { const [ir] = await mp.query(`SELECT payment_id, invoice_id, amount FROM payment_item WHERE payment_id IN (${ids.map(() => '?').join(',')})`, ids); for (const it of ir) (itemsByPay[it.payment_id] || (itemsByPay[it.payment_id] = [])).push(it) }
// index customer + factures existantes
const acctIds = [...new Set(prows.map(p => Number(p.account_id)))]
const custMap = {}
for (let i = 0; i < acctIds.length; i += 1000) { const b = acctIds.slice(i, i + 1000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x }
const invIds = [...new Set(Object.values(itemsByPay).flat().map(it => Number(it.invoice_id)).filter(Boolean))]
const invSet = new Set()
for (let i = 0; i < invIds.length; i += 2000) { const b = invIds.slice(i, i + 2000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE legacy_invoice_id = ANY($1)', [b])) invSet.add(Number(x.legacy_invoice_id)) }
const existingPe = new Set()
if (ids.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [ids])) existingPe.add(Number(x.legacy_payment_id))
const client = await pgPool().connect()
let ok = 0, skip = 0, refOk = 0, err = 0; const errors = []; let maxId = pre.watermark.since_payment_id
try {
for (const p of prows) {
maxId = Math.max(maxId, Number(p.id))
if (existingPe.has(Number(p.id))) { skip++; continue }
const cust = custMap[Number(p.account_id)]
if (!cust) { skip++; continue }
const amt = r2(Math.abs(p.amount)); if (amt <= 0) { skip++; continue }
const posting = tsToDate(p.date_orig) || new Date().toISOString().slice(0, 10)
const mode = modeFor(p.type)
const ref = String(p.reference || '').slice(0, 140)
const memo = String(p.memo || '').slice(0, 140)
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO "tabPayment Entry" (name, owner, creation, modified, modified_by, docstatus, posting_date, company,
payment_type, party_type, party, party_name, paid_from, paid_from_account_currency, paid_to, paid_to_account_currency,
paid_amount, base_paid_amount, received_amount, base_received_amount, source_exchange_rate, target_exchange_rate,
mode_of_payment, reference_no, reference_date, status, remarks, legacy_payment_id)
VALUES ($1,'Administrator',NOW(),NOW(),'Administrator',1,$2,'TARGO',
'Receive','Customer',$3,$4,$5,'CAD',$6,'CAD',
$7,$7,$7,$7,1,1,
$8,$9,$2,'Submitted',$10,$11)
ON CONFLICT (name) DO NOTHING`,
[peName(p.id), posting, cust.name, cust.customer_name, PAID_FROM, PAID_TO, amt, mode, ref || null, memo || null, Number(p.id)])
let j = 0
for (const it of (itemsByPay[p.id] || [])) {
if (!invSet.has(Number(it.invoice_id))) continue
await client.query(
`INSERT INTO "tabPayment Entry Reference" (name, owner, creation, modified, modified_by, docstatus, idx,
parent, parentfield, parenttype, reference_doctype, reference_name, allocated_amount, exchange_rate)
VALUES ($1,'Administrator',NOW(),NOW(),'Administrator',1,$2,$3,'references','Payment Entry','Sales Invoice',$4,$5,1)
ON CONFLICT (name) DO NOTHING`,
[`PER-${p.id}-${j}`, j + 1, peName(p.id), sinvName(it.invoice_id), r2(Math.abs(it.amount))])
j++; refOk++
}
await client.query('COMMIT')
ok++
} catch (e) { await client.query('ROLLBACK'); err++; if (errors.length < 20) errors.push({ payment_id: p.id, err: String(e.message).slice(0, 200) }) }
}
} finally { client.release() }
const st = loadState(); st.payment_max_id = Math.max(Number(st.payment_max_id) || 0, maxId); st.last_run = new Date().toISOString(); saveState(st)
log(`legacy-payments syncPayments: ${ok} PE créés, ${refOk} refs, ${skip} ignorés, ${err} err, watermark→${maxId}`)
return { applied: ok, references: refOk, skipped: skip, errors: err, error_samples: errors, watermark: st.payment_max_id, by_type: pre.delta.by_type }
}
// ── RAFRAÎCHIR LE SOLDE DES FACTURES VIVANTES OUVERTES (F autoritaire) ────────
// Borné + léger : ne recheck que les factures docstatus=1 Unpaid/Overdue (seules
// dont le statut peut basculer vers Paid quand F encaisse). Re-fetch F billed_amt
// par id (WHERE id IN ...). Met à jour outstanding + status d'après F. Sans confirm → dry-run.
async function refreshOpenInvoices ({ confirm = false, limit = 20000 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
const open = await pgq(`SELECT name, legacy_invoice_id, grand_total, due_date FROM "tabSales Invoice"
WHERE docstatus=1 AND legacy_invoice_id>0 AND status IN ('Unpaid','Overdue') LIMIT ${Number(limit)}`)
if (!open.length) return { open: 0, would_update: 0, note: 'aucune facture vivante ouverte' }
const ids = open.map(r => Number(r.legacy_invoice_id))
const fmap = {}
for (let i = 0; i < ids.length; i += 2000) {
const b = ids.slice(i, i + 2000); if (!b.length) continue
const [fr] = await mp.query(`SELECT id, total_amt, billed_amt FROM invoice WHERE id IN (${b.map(() => '?').join(',')})`, b)
for (const f of fr) fmap[f.id] = f
}
const td = TODAY(); const changes = []
for (const inv of open) {
const f = fmap[Number(inv.legacy_invoice_id)]; if (!f) continue
const owed = r2(Math.max(0, Number(f.total_amt) - Number(f.billed_amt)))
const due = (inv.due_date && String(inv.due_date).slice(0, 10)) || td
let status = owed <= 0.005 ? 'Paid' : (due < td ? 'Overdue' : 'Unpaid')
if (Math.abs(owed - r2(inv.grand_total)) < 0.005 && owed > 0) { /* still fully open */ }
const curOwed = null // we don't have current outstanding in this query; update unconditionally if status/owed differ
changes.push({ name: inv.name, owed, status })
}
if (confirm !== 'F-WINS') return { dry_run: true, open: open.length, candidates: changes.length, sample: changes.slice(0, 8) }
const client = await pgPool().connect(); let upd = 0
try {
for (const c of changes) {
const r = await client.query('UPDATE "tabSales Invoice" SET outstanding_amount=$1, status=$2 WHERE name=$3 AND docstatus=1 AND (ROUND(outstanding_amount::numeric,2)<>$1 OR status<>$2)', [c.owed, c.status, c.name])
upd += r.rowCount
}
} finally { client.release() }
log(`legacy-payments refreshOpenInvoices: ${upd} factures mises à jour (sur ${open.length} ouvertes)`)
return { open: open.length, updated: upd }
}
// ── FERMER LA CHAÎNE : créer les Customers manquants derrière la facturation ──
// Racine des « sans client » : des comptes F (recent billing) absents d'ERPNext.
// Création CIBLÉE (seulement les comptes ayant des factures/paiements non synchronisés
// dans la fenêtre) via erp.create (REST → naming/validations/defaults gérés), idempotent
// par legacy_account_id, en réutilisant mapAccount de legacy-sync (même mapping que #62).
// Délègue au créateur CANONIQUE (legacy-sync.createCustomersByIds) — un seul chemin de création.
// Ici on ne fait que CIBLER : les comptes référencés par la facturation récente (fenêtre) absents d'ERPNext.
async function ensureBillingCustomers ({ confirm = false, window = 20000, limit = 1000 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
const invMax = Number((await pgq('SELECT COALESCE(MAX(legacy_invoice_id),0) mx FROM "tabSales Invoice"'))[0].mx) || 0
const payMax = Number((await pgq('SELECT COALESCE(MAX(legacy_payment_id),0) mx FROM "tabPayment Entry"'))[0].mx) || 0
const [ia] = await mp.query('SELECT DISTINCT account_id FROM invoice WHERE id > ?', [Math.max(0, invMax - Number(window))])
const [pa] = await mp.query('SELECT DISTINCT account_id FROM payment WHERE id > ?', [Math.max(0, payMax - Number(window))])
const acctIds = [...new Set([...ia, ...pa].map(r => Number(r.account_id)).filter(Boolean))]
const res = await require('./legacy-sync').createCustomersByIds({ ids: acctIds, confirm, limit }) // créateur canonique
return { referenced: acctIds.length, ...res }
}
// ── CYCLE DE SYNC (paiements + rafraîchissement soldes) — pour le cron ────────
// Note : l'insertion des FACTURES (Python sync_invoices_incremental.py) tourne en amont
// dans le même cycle cron (cf. /opt/targo-sync/run.sh). Ici : volet hub (paiements + soldes).
async function syncCycle ({ confirm = false, limit = 20000, window = 5000 } = {}) {
const pay = await syncPayments({ confirm, limit, window }) // window → comble les trous récents (numéros sautés)
const refresh = await refreshOpenInvoices({ confirm, limit })
const out = { ran_at: new Date().toISOString(), payments: pay, open_refresh: refresh }
if (confirm === 'F-WINS') { try { fs.writeFileSync(RUN_FILE, JSON.stringify({ ran_at: out.ran_at, payments_applied: pay.applied || 0, references: pay.references || 0, refreshed: refresh.updated || 0, errors: pay.errors || 0 }, null, 2)) } catch (e) {} }
return out
}
// ── ÉTAT DE COHÉRENCE (santé) — instantané, léger (MAX(id) indexé + agrégats PG) ──
// Pour un tableau de bord : où en est ERPNext vs F + dernier cycle. Ne scanne PAS F.
async function coherenceStatus () {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
const erp = (await pgq(`SELECT
(SELECT COALESCE(MAX(legacy_invoice_id),0) FROM "tabSales Invoice") inv_max,
(SELECT COUNT(*) FROM "tabSales Invoice" WHERE docstatus=1 AND legacy_invoice_id>638609) inv_live,
(SELECT COALESCE(SUM(outstanding_amount),0) FROM "tabSales Invoice" WHERE docstatus=1 AND status IN ('Unpaid','Overdue')) ar_open,
(SELECT COALESCE(MAX(legacy_payment_id),0) FROM "tabPayment Entry") pay_max,
(SELECT COUNT(*) FROM "tabPayment Entry") pay_count`))[0]
const [[fi]] = await mp.query('SELECT MAX(id) mx FROM invoice') // instantané (PK)
const [[fp]] = await mp.query('SELECT MAX(id) mx FROM payment')
const lagInv = Number(fi.mx) - Number(erp.inv_max)
const lagPay = Number(fp.mx) - Number(erp.pay_max)
let lastRun = null; try { lastRun = JSON.parse(fs.readFileSync(RUN_FILE, 'utf8')) } catch {}
const ranAgoMin = lastRun && lastRun.ran_at ? Math.round((Date.now() - Date.parse(lastRun.ran_at)) / 60000) : null
return {
checked_at: new Date().toISOString(),
invoices: { f_max_id: Number(fi.mx), erp_max_synced: Number(erp.inv_max), lag: lagInv, live_synced: Number(erp.inv_live), ar_open: r2(erp.ar_open) },
payments: { f_max_id: Number(fp.mx), erp_max_synced: Number(erp.pay_max), lag: lagPay, total: Number(erp.pay_count) },
last_cycle: lastRun, last_cycle_min_ago: ranAgoMin,
healthy: ranAgoMin != null && ranAgoMin < 90, // cron horaire → < 90 min = sain
note: 'lag = numéros F créés depuis le dernier cycle (se résorbe à l\'heure suivante). Cron horaire :15.',
}
}
// ── routes HTTP (gated ; preview = sûr) ───────────────────────────────────────
const { parseBody } = require('./helpers')
async function handle (req, res, method, p, url) {
if (p === '/legacy-payments/preview' && method === 'GET') {
try { const since = url && url.searchParams.get('since'); const limit = url && url.searchParams.get('limit'); return json(res, 200, await previewPayments({ since: since != null ? Number(since) : null, limit: limit ? Number(limit) : 20000 })) }
catch (e) { log('legacy-payments preview error:', e.message); return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/report' && method === 'GET') {
try { return json(res, 200, JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))) } catch { return json(res, 404, { error: 'aucun rapport — lancer /legacy-payments/preview' }) }
}
if (p === '/legacy-payments/invoices-preview' && method === 'GET') {
try { const since = url && url.searchParams.get('since'); const limit = url && url.searchParams.get('limit'); return json(res, 200, await previewInvoices({ since: since != null ? Number(since) : null, limit: limit ? Number(limit) : 30000 })) }
catch (e) { log('legacy-payments invoices-preview error:', e.message); return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/billing-gap' && method === 'GET') {
try { return json(res, 200, await billingGap()) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/reconcile-summary' && method === 'GET') {
try { const fresh = url && (url.searchParams.get('fresh') === '1'); return json(res, 200, await reconcileSummary({ fresh })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/reconcile' && method === 'GET') {
try { const acct = url && url.searchParams.get('account'); const cust = url && url.searchParams.get('customer'); return json(res, 200, await reconcileAccount({ accountId: acct ? Number(acct) : null, customer: cust || null })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/run' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await syncPayments({ confirm: b.confirm, since: b.since != null ? Number(b.since) : null, limit: b.limit ? Number(b.limit) : 5000 })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/ensure-customers' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await ensureBillingCustomers({ confirm: b.confirm, window: b.window ? Number(b.window) : 20000, limit: b.limit ? Number(b.limit) : 1000 })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/refresh-open' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await refreshOpenInvoices({ confirm: b.confirm, limit: b.limit ? Number(b.limit) : 20000 })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/sync-cycle' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await syncCycle({ confirm: b.confirm, limit: b.limit ? Number(b.limit) : 20000 })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/coherence' && method === 'GET') {
try { return json(res, 200, await coherenceStatus()) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/state' && method === 'GET') return json(res, 200, loadState())
return json(res, 404, { error: 'route legacy-payments inconnue' })
}
module.exports = { handle, previewPayments, previewInvoices, billingGap, reconcileSummary, reconcileAccount, syncPayments, refreshOpenInvoices, ensureBillingCustomers, syncCycle, coherenceStatus, arState, MODE_MAP, modeFor, peName, sinvName }

View File

@ -0,0 +1,749 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// F (gestionclient) → ERPNext — moteur de SYNC DELTA, mode PREVIEW.
//
// LECTURE SEULE : ne fait AUCUN erp.create / erp.update / écriture MySQL.
// Lit le miroir local `legacy-db` (jamais le F live) + ERPNext, et calcule ce
// qui SERAIT créé / mis à jour côté ERPNext, par clé legacy_* :
// account → Customer (clé legacy_account_id)
// delivery → Service Location (clé legacy_delivery_id)
// service → Service Subscription(clé legacy_service_id)
//
// Détection de changement (cf. analyse 2026-06-10) :
// • INSERT = id F absent côté ERPNext (par clé legacy_*).
// • UPDATE = `account.date_last` (vraie date de modif, 86% des comptes) au-delà
// du watermark ; en preview on compare directement les champs.
//
// F reste autoritaire pour la facturation : on NE synchronise PAS ici les
// invoice/payment/GL/PLE (ça vit dans F). On synchronise la couche CLIENT
// (comptes / adresses / services) nécessaire au funnel lead→abo→onboarding→RDV.
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { json, log } = require('./helpers')
const erp = require('./erp')
let mysql; try { mysql = require('mysql2/promise') } catch (e) { mysql = null }
const REPORT_DIR = path.join(__dirname, '..', 'data')
const REPORT_FILE = path.join(REPORT_DIR, 'legacy-sync-preview.json')
const STATE_FILE = path.join(REPORT_DIR, 'legacy-sync-state.json') // watermark incrémental
const RUN_LOG = path.join(REPORT_DIR, 'legacy-sync-run.json') // progression de l'apply (pollable)
function loadState () { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) } catch { return {} } }
function saveState (s) {
try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)) }
catch (e) { log('legacy-sync: échec écriture state:', e.message) }
}
function writeRun (o) { try { fs.writeFileSync(RUN_LOG, JSON.stringify(o)) } catch (e) {} }
const ERRORS_FILE = path.join(REPORT_DIR, 'legacy-sync-errors.json') // liste COMPLÈTE des erreurs d'apply + raisons
function writeErrors (o) { try { fs.writeFileSync(ERRORS_FILE, JSON.stringify(o, null, 2)) } catch (e) {} }
// Classe le message d'erreur ERPNext en raison courte (pour grouper/lister).
function errReason (msg) {
const m = String(msg || '')
if (/InvalidEmailAddressError/i.test(m)) return 'email_invalide' // ex. emails multiples « a@x;b@y »
if (/InvalidPhoneNumber|phone/i.test(m)) return 'téléphone_invalide'
if (/LinkValidationError|Could not find|does not exist/i.test(m)) return 'lien_introuvable'
if (/MandatoryError|is required|Mandatory/i.test(m)) return 'champ_obligatoire'
if (/Duplicate|already exists/i.test(m)) return 'doublon'
if (/socket hang up|ECONNRESET|ETIMEDOUT|timeout|EAI_AGAIN|50[234]/i.test(m)) return 'réseau'
if (/Permission|not permitted|Forbidden|403/i.test(m)) return 'permission'
if (/CharacterLengthExceeded|too long/i.test(m)) return 'trop_long'
return 'autre'
}
let _pool
function pool () {
if (!mysql) return null
if (!_pool) {
_pool = mysql.createPool({
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS,
database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
})
}
return _pool
}
// ── client du pont PHP (lecture FRAÎCHE depuis F live ; le miroir peut être en retard) ──
const https = require('https')
const querystring = require('querystring')
function bridgeUrl () { return process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php' }
function bridgeToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
function bridgeCall (params) {
return new Promise((resolve, reject) => {
const body = querystring.stringify(params)
const u = new URL(bridgeUrl())
const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', timeout: 25000,
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': bridgeToken(), 'Content-Length': Buffer.byteLength(body) } },
res => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)) } catch { reject(new Error('bridge parse: ' + String(b).slice(0, 160))) } }) })
req.on('error', reject); req.on('timeout', () => req.destroy(new Error('bridge timeout'))); req.write(body); req.end()
})
}
// Tire les comptes changés/nouveaux depuis F live, paginé par (date_last, id).
async function fetchAccountsViaBridge (since, sinceId) {
const sinceFixed = Number(since) || 0
if (sinceFixed > 0) {
// INCRÉMENTAL : `date_last > since` couvre create+update ; le delta est petit → 1 page large.
const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'account', since: String(sinceFixed), since_id: '0', limit: '5000' }), 'bridge delta')
if (!d || !d.ok) throw new Error('bridge changes_since: ' + ((d && d.error) || 'réponse invalide'))
if (d.has_more) log('legacy-sync: ⚠ delta > 5000 lignes — pagination incrémentale à étendre')
return d.rows || []
}
// FULL : keyset PUR sur id (since=0 → le PHP utilise `id > sinceId`)
const rows = []; let sid = Number(sinceId) || 0
for (let page = 0; page < 100; page++) {
const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'account', since: '0', since_id: String(sid), limit: '1000' }), 'bridge full')
if (!d || !d.ok) throw new Error('bridge full: ' + ((d && d.error) || 'réponse invalide'))
rows.push(...(d.rows || []))
if (!d.has_more) break
const next = Number(d.max_id) || 0
if (next <= sid) break // garde-fou anti-boucle
sid = next
}
return rows
}
// Services F (statut frais) — keyset pur sur id (service n'a pas de date de modif → scan complet périodique).
async function fetchServicesViaBridge () {
const rows = []; let sid = 0
for (let page = 0; page < 200; page++) {
const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'service', since: '0', since_id: String(sid), limit: '2000' }), 'bridge services')
if (!d || !d.ok) throw new Error('bridge service: ' + ((d && d.error) || 'réponse invalide'))
rows.push(...(d.rows || []))
if (!d.has_more) break
const next = Number(d.max_id) || 0; if (next <= sid) break; sid = next
}
return rows
}
// F service.status → statut Service Subscription. CORRIGÉ 2026-06-13 : dans F, **status=1 = ACTIF**, point.
// `date_suspended` n'est que l'HISTORIQUE d'une ancienne suspension (service réactivé) → l'IGNORER quand status=1.
// (L'ancien code `status==1 && date_suspended>0 → Suspendu` excluait à tort les services réactivés du total :
// prouvé sur Schink #38243 et Marianne #31841 → totaux F restaurés. Cf. feedback_billing_invariants.)
function svcStatus (s, dateSusp) {
if (Number(s) === 1) return 'Actif' // F status=1 = ACTIF ; date_suspended ignoré (historique d'une suspension passée)
return 'Annulé' // status=0 = exclu du total (suspendu/annulé) — on garde 'Annulé' comme avant pour limiter le blast-radius aux 177 corrections de facturation
}
// ── helpers de normalisation ────────────────────────────────────────────────
function clean (v) {
if (v == null) return ''
let s = String(v)
s = s.replace(/&amp;/g, '&').replace(/&#0?39;|&apos;/g, "'").replace(/&quot;/g, '"')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&#0?(\d+);/g, (_, n) => { try { return String.fromCharCode(+n) } catch { return _ } })
return s.trim()
}
function digits (v) { return String(v == null ? '' : v).replace(/\D/g, '') }
// F met parfois PLUSIEURS emails dans un champ (couple/entreprise/partenaires qui suivent la facturation),
// séparés par ; ou ,. ERPNext.email_id n'accepte qu'UN email → on garde le 1er valide comme principal
// et la LISTE COMPLÈTE va dans email_billing (CC facturation, emails secondaires importés).
function emailList (v) {
return clean(v).toLowerCase().split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s))
}
function firstEmail (v) { return emailList(v)[0] || '' }
const GROUP_MAP = { 1: 'Individual', 4: 'Commercial', 5: 'Individual', 6: 'Individual', 7: 'Individual', 8: 'Commercial', 9: 'Government', 10: 'Non Profit' }
// account (F) → sous-ensemble HAUTE-CONFIANCE des champs Customer (ceux que la
// migration peuple de façon déterministe). L'enrichissement plus fin viendra plus tard.
function mapAccount (a) {
const first = clean(a.first_name), last = clean(a.last_name), company = clean(a.company)
const isCompany = !!company
return {
legacy_account_id: Number(a.id),
customer_name: isCompany ? company : (`${first} ${last}`.trim() || `Client-${a.id}`),
customer_type: isCompany ? 'Company' : 'Individual',
customer_group: GROUP_MAP[a.group_id] || 'Individual',
language: a.language_id === 'francais' ? 'fr' : 'en',
disabled: [1, 2].includes(Number(a.status)) ? 0 : 1, // actif/suspendu = visible ; résilié (3,4,5) = désactivé
is_suspended: Number(a.status) === 2 ? 1 : 0,
email_id: firstEmail(a.email) || null,
email_billing: (() => { const e = emailList(a.email); return e.length > 1 ? e.join(';') : null })(), // emails secondaires (couple/entreprise/partenaires) → CC facturation
tel_home: digits(a.tel_home) || null,
cell_phone: digits(a.cell) || null,
stripe_id: clean(a.stripe_id) || null,
ppa_enabled: Number(a.ppa) ? 1 : 0, // pont PHP renvoie "0"/"1" (string) → Number() requis ("0" est truthy en JS)
is_commercial: Number(a.commercial) ? 1 : 0,
is_bad_payer: Number(a.mauvais_payeur) ? 1 : 0,
_status: Number(a.status),
}
}
// Statut de compte GLOBAL F par id (account.status : 1=actif, 2=suspendu, 3/4/5=résilié). PK indexée → batch rapide/sûr.
const F_ACCT_STATUS_LABEL = { 1: 'Actif', 2: 'Suspendu', 3: 'Résilié', 4: 'Résilié', 5: 'Résilié' }
async function fAccountStatuses (ids) {
ids = [...new Set((ids || []).map(Number).filter(Boolean))]
if (!ids.length) return {}
const p = pool(); if (!p) return {}
const out = {}
for (let i = 0; i < ids.length; i += 500) {
const b = ids.slice(i, i + 500)
const [rows] = await p.query({ sql: 'SELECT id, status FROM account WHERE id IN (' + b.map(() => '?').join(',') + ')', timeout: 6000 }, b)
for (const r of rows) out[Number(r.id)] = Number(r.status)
}
return out
}
// IDs des comptes F RÉSILIÉS (status 3/4/5). Scan léger d'account (~17k lignes).
async function fResiliatedAccountIds () {
const p = pool(); if (!p) return []
const [rows] = await p.query({ sql: 'SELECT id FROM account WHERE status IN (3,4,5)', timeout: 10000 })
return rows.map(r => Number(r.id))
}
// Champs texte : politique ADD / CHANGE / NEVER-CLOBBER (cf. preview 2026-06-11).
const TEXT_FIELDS = ['customer_name', 'customer_type', 'customer_group', 'language', 'email_id', 'email_billing', 'tel_home', 'cell_phone', 'stripe_id']
// Drapeaux booléens SYNCHRONISÉS (F autoritaire, vont dans « à réviser »).
const BOOL_FIELDS = ['ppa_enabled', 'is_commercial', 'is_bad_payer']
// Statut client = DÉRIVÉ des abonnements (Service Subscription) — affiché en divergence
// mais JAMAIS synchronisé/flippé depuis F (cf. feedback_customer_status_policy : pas de faux « churn »,
// le client reste vendable/cross-sell). Fetché pour l'info, hors review/sync.
const STATUS_FIELDS = ['disabled', 'is_suspended']
const CMP = [...TEXT_FIELDS, ...BOOL_FIELDS, ...STATUS_FIELDS]
const PHONE_FIELDS = new Set(['tel_home', 'cell_phone'])
function normField (field, v) {
if (v == null) return ''
if (PHONE_FIELDS.has(field)) return digits(v)
if (field === 'email_id') return String(v).trim().toLowerCase()
if (typeof v === 'number') return String(v)
return String(v).trim()
}
function isEmpty (v) { return v == null || String(v).trim() === '' }
// Retry sur erreurs réseau transitoires (socket hang up, ECONNRESET, timeouts, 5xx) — « non bloquant si surcharge ».
async function withRetry (fn, label, tries = 5) {
let last
for (let i = 0; i < tries; i++) {
try { return await fn() } catch (e) {
last = e
const transient = /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|network|timeout|50[234]/i.test(e.message || '')
if (!transient || i === tries - 1) throw e
await new Promise(r => setTimeout(r, 500 * (i + 1)))
log(`legacy-sync retry ${label} #${i + 1}: ${e.message}`)
}
}
throw last
}
// ── chargement de l'index ERPNext (paginé) ──────────────────────────────────
async function loadErpIndex (doctype, key, fields) {
const idx = new Map()
let start = 0; const lim = 1000; let available = null
for (;;) {
const rows = await withRetry(() => erp.list(doctype, { filters: [[key, '>', 0]], fields: ['name', key, ...fields], limit: lim, start }), `list ${doctype} @${start}`)
if (!rows || !rows.length) break
if (available === null) available = fields.filter(f => f in rows[0]) // v16 PG peut bloquer des champs
for (const r of rows) idx.set(Number(r[key]), r)
if (rows.length < lim) break
start += lim
}
return { idx, available: available || fields }
}
// Fetch ciblé (mode incrémental) : ne charge QUE les enregistrements ERPNext dont la clé change.
async function loadErpByIds (doctype, key, fields, ids) {
const idx = new Map(); let available = null
for (let i = 0; i < ids.length; i += 100) {
const batch = ids.slice(i, i + 100)
if (!batch.length) continue
const rows = await withRetry(() => erp.list(doctype, { filters: [[key, 'in', batch]], fields: ['name', key, ...fields], limit: 500 }), `list ${doctype} in-batch`)
if (available === null && rows && rows[0]) available = fields.filter(f => f in rows[0])
for (const r of (rows || [])) idx.set(Number(r[key]), r)
}
return { idx, available: available || fields }
}
// ── PREVIEW : comptes → Customer ─────────────────────────────────────────────
function bump (obj, k) { obj[k] = (obj[k] || 0) + 1 }
async function previewCustomers ({ sinceDateLast = 0, sinceId = 0, source = 'mirror' } = {}) {
let rows
if (source === 'bridge') {
rows = await fetchAccountsViaBridge(sinceDateLast, sinceId) // lecture FRAÎCHE de F live (miroir périmé contourné)
} else {
const p = pool(); if (!p) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
const where = Number(sinceDateLast) > 0 ? ` WHERE date_last > ${Number(sinceDateLast)}` : ''
;[rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa, date_last FROM account' + where)
}
const maxDateLast = rows.reduce((m, r) => Math.max(m, Number(r.date_last) || 0), Number(sinceDateLast) || 0)
const maxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), Number(sinceId) || 0)
// Index ERPNext : ciblé si jeu borné (bridge/incrémental & ≤3000), sinon index complet.
const targeted = (source === 'bridge' || Number(sinceDateLast) > 0) && rows.length <= 3000
const { idx: erpIdx, available } = targeted
? await loadErpByIds('Customer', 'legacy_account_id', CMP, rows.map(r => Number(r.id)))
: await loadErpIndex('Customer', 'legacy_account_id', CMP)
const fullIndexLoaded = !targeted
const textF = TEXT_FIELDS.filter(f => available.includes(f))
const boolF = BOOL_FIELDS.filter(f => available.includes(f))
const hasDisabled = available.includes('disabled')
const out = {
f_total: rows.length, erp_total: erpIdx.size,
compared_text: textF, compared_bool: boolF, dropped: CMP.filter(f => !available.includes(f)),
creates: [], safe_add: [], needs_review: [], unchanged: 0,
add_by_field: {}, review_by_field: {}, never_clobber_by_field: {},
status_divergence: { f_active_erp_disabled: 0, f_inactive_erp_enabled: 0, samples: [] },
erp_only: [],
}
for (const a of rows) {
const m = mapAccount(a)
const ex = erpIdx.get(Number(a.id))
if (!ex) { out.creates.push({ legacy_account_id: m.legacy_account_id, customer_name: m.customer_name, status: m._status }); continue }
const add = [], change = [], skipNull = []
// champs texte → ADD (ERPNext vide) / CHANGE (les deux diffèrent) / NEVER-CLOBBER (F vide)
for (const f of textF) {
if (normField(f, m[f]) === normField(f, ex[f])) continue
const fEmpty = isEmpty(m[f]), eEmpty = isEmpty(ex[f])
if (fEmpty && !eEmpty) { skipNull.push({ field: f, erp: ex[f] }); bump(out.never_clobber_by_field, f) }
else if (eEmpty && !fEmpty) { add.push({ field: f, to: m[f] }); bump(out.add_by_field, f) }
else { change.push({ field: f, from: ex[f], to: m[f] }); bump(out.review_by_field, f) }
}
// drapeaux booléens → toujours review (jamais d'auto-bascule)
for (const f of boolF) {
if (normField(f, m[f]) !== normField(f, ex[f])) { change.push({ field: f, from: ex[f], to: m[f] }); bump(out.review_by_field, f) }
}
// disabled : divergence de statut (NON synchronisée — ERPNext la dérive des abonnements)
if (hasDisabled && normField('disabled', m.disabled) !== normField('disabled', ex.disabled)) {
if (m.disabled === 0 && ex.disabled === 1) out.status_divergence.f_active_erp_disabled++
else if (m.disabled === 1 && ex.disabled === 0) out.status_divergence.f_inactive_erp_enabled++
if (out.status_divergence.samples.length < 30) out.status_divergence.samples.push({ legacy_account_id: m.legacy_account_id, customer_name: m.customer_name, f_status: m._status, erp_disabled: ex.disabled })
}
const rec = { legacy_account_id: m.legacy_account_id, name: ex.name, customer_name: m.customer_name }
if (change.length) { rec.change = change; if (add.length) rec.add = add; if (skipNull.length) rec.skip_null = skipNull; out.needs_review.push(rec) }
else if (add.length) { rec.add = add; if (skipNull.length) rec.skip_null = skipNull; out.safe_add.push(rec) }
else out.unchanged++
}
// erp_only n'a de sens qu'en scan complet (sinon on n'a chargé qu'un sous-ensemble)
if (fullIndexLoaded) { const fIds = new Set(rows.map(r => Number(r.id))); out.erp_only = [...erpIdx.keys()].filter(id => !fIds.has(id)) }
out.incremental = Number(sinceDateLast) > 0 || source === 'bridge'
out.source = source
out.since_date_last = Number(sinceDateLast) || 0
out.max_date_last = maxDateLast
out.max_id = maxId
return out
}
// ── PREVIEW générique INSERT-only (delivery / service) ───────────────────────
async function previewInsertsOnly ({ table, idCol = 'id', doctype, key, label, where = '' }) {
const p = pool(); if (!p) throw new Error('miroir indisponible')
const [rows] = await p.query(`SELECT ${idCol} AS id FROM \`${table}\`${where ? ` WHERE ${where}` : ''}`)
const { idx } = await loadErpIndex(doctype, key, [])
const sample = []; let would = 0
for (const r of rows) { if (!idx.has(Number(r.id))) { would++; if (sample.length < 30) sample.push(Number(r.id)) } }
return { label, where: where || '(toutes)', f_total: rows.length, erp_total: idx.size, would_create: would, sample }
}
// ── orchestrateur preview ────────────────────────────────────────────────────
async function runPreview ({ persist = true } = {}) {
const startedAt = new Date().toISOString()
const customers = await previewCustomers()
const locations = await previewInsertsOnly({ table: 'delivery', doctype: 'Service Location', key: 'legacy_delivery_id', label: 'delivery→Service Location' })
const services = await previewInsertsOnly({ table: 'service', doctype: 'Service Subscription', key: 'legacy_service_id', label: 'service ACTIF→Service Subscription', where: 'status=1' })
const c = customers
const report = {
mode: 'PREVIEW (lecture seule — aucune écriture ni dans F ni dans ERPNext)',
started_at: startedAt,
customers: {
summary: {
f_total: c.f_total, erp_total: c.erp_total,
would_create: c.creates.length,
safe_add: c.safe_add.length, // ⇐ enrichissement SÛR : remplir des champs vides (email/tél…)
needs_review: c.needs_review.length, // ⇐ valeur divergente → révision humaine avant écriture
unchanged: c.unchanged,
erp_only_not_in_F: c.erp_only.length,
},
add_by_field: c.add_by_field, // ce qui serait REMPLI (sûr)
review_by_field: c.review_by_field, // ce qui DIVERGE (à réviser)
never_clobber_by_field: c.never_clobber_by_field, // F vide → JAMAIS écrasé (ex. stripe_id)
status_divergence: { f_active_erp_disabled: c.status_divergence.f_active_erp_disabled, f_inactive_erp_enabled: c.status_divergence.f_inactive_erp_enabled, samples: c.status_divergence.samples.slice(0, 15) },
compared_text: c.compared_text, compared_bool: c.compared_bool, dropped: c.dropped,
creates_sample: c.creates.slice(0, 30),
safe_add_sample: c.safe_add.slice(0, 15),
needs_review_sample: c.needs_review.slice(0, 20),
erp_only_sample: c.erp_only.slice(0, 30),
},
locations,
services,
finished_at: new Date().toISOString(),
}
// rapport COMPLET (toutes les listes) à part, pour révision/itération
const full = { started_at: startedAt, customers_creates: c.creates, customers_safe_add: c.safe_add, customers_needs_review: c.needs_review, customers_erp_only: c.erp_only, status_divergence_samples: c.status_divergence.samples }
if (persist) {
try {
if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true })
fs.writeFileSync(REPORT_FILE, JSON.stringify({ report, full }, null, 2))
report.saved_to = REPORT_FILE
} catch (e) { log('legacy-sync: échec écriture rapport:', e.message) }
}
return report
}
// Écrit un patch de champs Customer via le POOL PG (PAS erp.update : ERPNext re-dérive
// email_id/tél du Contact lié → renvoie ok mais NO-OP silencieux ; vérifié 2026-06-12).
// fieldnames = allowlist fixe TEXT_FIELDS+BOOL_FIELDS (pas d'injection). Never-clobber par du vide.
async function writeCustomerPatch (name, patch) {
const SYNCABLE = new Set([...TEXT_FIELDS, ...BOOL_FIELDS])
const keys = Object.keys(patch).filter(f => SYNCABLE.has(f) && patch[f] != null && String(patch[f]) !== '')
if (!keys.length) return { ok: true, fields: 0 }
const pgp = require('./address-db').pool()
const sets = keys.map((f, i) => `"${f}"=$${i + 1}`).join(', ')
await pgp.query(`UPDATE "tabCustomer" SET ${sets}, modified=NOW() WHERE name=$${keys.length + 1}`, [...keys.map(f => patch[f]), name])
return { ok: true, fields: keys.length }
}
// ── APPLY (mode SAFE-ADD uniquement) — dry-run par défaut, écriture gardée ────
// N'applique QUE le bucket safe_add : remplir des champs ERPNext VIDES avec la
// valeur de F (email/tél/stripe). Écrit via le pool PG. Ne touche aucun autre champ.
// N'écrit JAMAIS sans confirm==='SAFE-ADD'. Ne touche pas needs_review ni disabled.
const { parseBody } = require('./helpers')
async function applySafeAdds ({ confirm = false, limit = 0 } = {}) {
const c = await previewCustomers()
const targets = limit ? c.safe_add.slice(0, limit) : c.safe_add
if (confirm !== 'SAFE-ADD') {
return { dry_run: true, eligible: c.safe_add.length, would_update: targets.length, sample: targets.slice(0, 10).map(t => ({ name: t.name, add: t.add })) }
}
let ok = 0, err = 0; const errors = []
for (const t of targets) {
const patch = {}; for (const a of t.add) patch[a.field] = a.to
try {
const r = await writeCustomerPatch(t.name, patch)
if (r && r.ok !== false) ok++; else { err++; if (errors.length < 20) errors.push({ name: t.name, err: (r && r.error) || 'update failed' }) }
} catch (e) { err++; if (errors.length < 20) errors.push({ name: t.name, err: e.message }) }
}
log(`legacy-sync apply SAFE-ADD: ${ok} ok, ${err} err`)
return { applied: ok, errors: err, error_samples: errors }
}
// ── SYNC INCRÉMENTAL F→OPS (watermark date_last) — ERPNext = test, F autoritaire ──
// Applique ADD + CHANGE (F gagne) sur les Customers EXISTANTS. PUT partiel → ne touche
// QUE les champs mappés (l'enrichissement ERPNext-only : geocoding/fibre/AQ reste intact).
// JAMAIS `disabled` (dérivé des abonnements), JAMAIS écraser une valeur par du vide.
// Les CRÉATIONS (nouveaux comptes F) sont comptées mais gérées par un step dédié.
// dry-run sauf confirm==='F-WINS'. Avance le watermark seulement après application.
async function syncCustomers ({ confirm = false, full = false, source = 'bridge' } = {}) {
const st = loadState()
const since = full ? 0 : (Number(st.account_date_last) || 0)
const sinceId = full ? 0 : (Number(st.account_max_id) || 0)
const c = await previewCustomers({ sinceDateLast: since, sinceId, source })
const plan = { mode: (since || sinceId) ? 'incrémental' : 'complet', source, since_date_last: since, since_id: sinceId, scanned: c.f_total, would_create: c.creates.length, would_update_safe: c.safe_add.length, would_update_review: c.needs_review.length, max_date_last: c.max_date_last, max_id: c.max_id }
if (confirm !== 'F-WINS') return { dry_run: true, ...plan, note: "POST { confirm:'F-WINS' } pour appliquer les MAJ. full:true = ré-scan complet. source:'mirror' = lire le miroir au lieu de F live." }
// « sans briser rien » : on ne RÉGRESSE pas des champs enrichis côté ERPNext.
// customer_name = nettoyé à la migration (accents/composés, tâche #12) → on ne l'écrase
// jamais par le brut de F. (Il n'apparaît que dans CHANGE, jamais dans ADD.)
const PROTECT = new Set(['customer_name'])
let ok = 0, err = 0, protectedSkips = 0; const errors = []
const apply = async (rec) => {
const patch = {}
for (const a of (rec.add || [])) patch[a.field] = a.to
for (const d of (rec.change || [])) { if (PROTECT.has(d.field)) { protectedSkips++; continue } patch[d.field] = d.to }
if (!Object.keys(patch).length) return
const fields = Object.keys(patch)
const fail = (msg) => { err++; errors.push({ name: rec.name, legacy_account_id: rec.legacy_account_id, customer_name: rec.customer_name, fields, reason: errReason(msg), err: String(msg || 'fail').slice(0, 300) }) }
try { const r = await writeCustomerPatch(rec.name, patch); if (r && r.ok !== false) ok++; else fail(r && r.error) }
catch (e) { fail(e.message) }
}
const all = [...c.safe_add, ...c.needs_review]
const total = all.length; let done = 0
writeRun({ status: 'running', mode: plan.mode, total, done: 0, ok: 0, err: 0, started_at: new Date().toISOString() })
for (const rec of all) {
await apply(rec); done++
if (done % 50 === 0) writeRun({ status: 'running', mode: plan.mode, total, done, ok, err })
if (done % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle : laisse ERPNext respirer (ne pas saturer l'UI dispatch)
}
const byReason = {}; for (const e of errors) byReason[e.reason] = (byReason[e.reason] || 0) + 1
writeErrors({ ts: new Date().toISOString(), mode: plan.mode, applied: ok, total_errors: errors.length, by_reason: byReason, errors })
st.account_date_last = Math.max(Number(st.account_date_last) || 0, c.max_date_last || 0)
st.account_max_id = Math.max(Number(st.account_max_id) || 0, c.max_id || 0)
st.last_run = new Date().toISOString(); saveState(st)
writeRun({ status: 'done', mode: plan.mode, total, done, ok, err, errors_by_reason: byReason, protected_name_skips: protectedSkips, watermark: st.account_date_last, creates_deferred: c.creates.length, finished_at: new Date().toISOString() })
log(`legacy-sync syncCustomers (${plan.mode}): ${ok} maj, ${err} err ${JSON.stringify(byReason)}, watermark→${c.max_date_last}`)
return { ...plan, applied: ok, errors: err, errors_by_reason: byReason, protected_name_skips: protectedSkips, error_samples: errors.slice(0, 20), watermark: st.account_date_last, creates_deferred: c.creates.length }
}
// ── SYNC STATUT DE SERVICE (subscription) F→ERPNext — F autoritaire ──────────
// service n'a pas de date de modif → SCAN COMPLET (statut peut changer sans changer l'id).
// Met à jour le statut des Service Subscription EXISTANTes (par legacy_service_id) : Actif/Suspendu/Annulé.
// dry-run sauf confirm==='F-WINS'. (Créations des services F manquants = step séparé.)
async function syncServices ({ confirm = false } = {}) {
const fsvc = await fetchServicesViaBridge()
const want = new Map()
for (const s of fsvc) want.set(Number(s.id), svcStatus(s.status, s.date_suspended))
// index ERPNext Service Subscription (legacy_service_id → {name,status})
const idx = new Map(); let start = 0
for (;;) {
const r = await withRetry(() => erp.list('Service Subscription', { filters: [['legacy_service_id', '>', 0]], fields: ['name', 'legacy_service_id', 'status'], limit: 1000, start }), 'list SS')
if (!r || !r.length) break
for (const x of r) idx.set(Number(x.legacy_service_id), x)
if (r.length < 1000) break
start += 1000
}
const changes = []
for (const [lid, sub] of idx) { const w = want.get(lid); if (w && w !== sub.status) changes.push({ name: sub.name, from: sub.status, to: w, lid }) }
const byTrans = {}; for (const c of changes) { const k = (c.from || '∅') + '→' + c.to; byTrans[k] = (byTrans[k] || 0) + 1 }
const plan = { f_services: fsvc.length, erp_subs: idx.size, would_update: changes.length, by_transition: byTrans }
if (confirm !== 'F-WINS') return { dry_run: true, ...plan, sample: changes.slice(0, 20) }
let ok = 0, err = 0; const errs = []
let i = 0
for (const c of changes) {
try { const r = await withRetry(() => erp.update('Service Subscription', c.name, { status: c.to }), 'svc ' + c.name); if (r && r.ok !== false) ok++; else { err++; if (errs.length < 20) errs.push({ name: c.name, err: (r && r.error) || 'fail' }) } }
catch (e) { err++; if (errs.length < 20) errs.push({ name: c.name, err: e.message }) }
if (++i % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle
}
log(`legacy-sync syncServices: ${ok} maj statut, ${err} err ${JSON.stringify(byTrans)}`)
return { ...plan, applied: ok, errors: err, error_samples: errs }
}
// ── CRÉATEUR DE CLIENTS CANONIQUE (utilisé par billing + orchestrateur) ──────
// Crée les Customers ERPNext manquants depuis F (par legacy_account_id) via erp.create
// (REST → naming/validations/defaults gérés). Idempotent. POLITIQUE : créé VISIBLE
// (disabled=0) — le statut actif/inactif est DÉRIVÉ des abonnements, jamais imposé par F
// (cf. feedback_customer_status_policy : pas de faux « churn », client reste vendable/cross-sell).
async function createCustomersByIds ({ ids = [], confirm = false, limit = 2000 } = {}) {
ids = [...new Set((ids || []).map(Number).filter(Boolean))]
if (!ids.length) return { requested: 0, missing: 0, created: 0 }
// existence via le pool PG (ANY paramétré) — pas d'erp.list 'in' (URL trop longue pour de gros lots)
const pgp = require('./address-db').pool()
const have = new Set()
for (let i = 0; i < ids.length; i += 2000) {
const b = ids.slice(i, i + 2000)
const r = await pgp.query('SELECT legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])
for (const x of r.rows) have.add(Number(x.legacy_account_id))
}
const missing = ids.filter(id => !have.has(id)).slice(0, limit)
if (!missing.length) return { requested: ids.length, missing: 0, created: 0 }
const p = pool(); if (!p) throw new Error('F indisponible (mysql2 absent)')
const accts = []
for (let i = 0; i < missing.length; i += 1000) {
const b = missing.slice(i, i + 1000)
const [rows] = await p.query(`SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa FROM account WHERE id IN (${b.map(() => '?').join(',')})`, b)
accts.push(...rows)
}
if (confirm !== 'F-WINS') return { dry_run: true, requested: ids.length, missing: missing.length, would_create: accts.length, sample: accts.slice(0, 8).map(a => ({ legacy_account_id: Number(a.id), customer_name: mapAccount(a).customer_name })) }
let ok = 0, err = 0; const errors = []
for (const a of accts) {
const m = mapAccount(a)
const doc = { customer_name: m.customer_name, customer_type: m.customer_type, customer_group: m.customer_group, legacy_account_id: m.legacy_account_id, disabled: 0 } // créé visible (politique de statut)
if (m.email_id) doc.email_id = m.email_id
if (m.cell_phone) doc.mobile_no = m.cell_phone
try { const r = await withRetry(() => erp.create('Customer', doc), 'create cust'); if (r && r.ok !== false) ok++; else { err++; if (errors.length < 15) errors.push({ legacy_account_id: m.legacy_account_id, err: (r && r.error) || 'fail' }) } }
catch (e) { err++; if (errors.length < 15) errors.push({ legacy_account_id: m.legacy_account_id, err: String(e.message).slice(0, 160) }) }
if ((ok + err) % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle
}
log(`legacy-sync createCustomersByIds: ${ok} créés, ${err} err (sur ${missing.length} manquants)`)
return { requested: ids.length, missing: missing.length, created: ok, errors: err, error_samples: errors }
}
// Applique les changements RÉVISÉS EN LOT depuis le dernier rapport (REPORT_FILE.full.customers_needs_review).
// field précisé → n'applique QUE ce champ (ex. tous les email_id) ; sinon TOUS les champs divergents (sauf PROTECT).
// Écrit via writeCustomerPatch (pool PG). Idempotent. dry-run sauf confirm==='F-WINS'.
async function applyReviewBatch ({ field = null, confirm = false, limit = 0 } = {}) {
let saved
try { saved = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8')).full } catch { throw new Error('aucun rapport — lancer /legacy-sync/preview d\'abord') }
const PROTECT = new Set(['customer_name'])
let items = (saved.customers_needs_review || []).filter(r => Array.isArray(r.change) && r.change.length)
if (field) items = items.filter(r => r.change.some(d => d.field === field && !PROTECT.has(d.field)))
if (limit) items = items.slice(0, limit)
// décompte par champ (pour le détail)
const byField = {}
for (const r of items) for (const d of r.change) { if (PROTECT.has(d.field)) continue; if (field && d.field !== field) continue; byField[d.field] = (byField[d.field] || 0) + 1 }
if (confirm !== 'F-WINS') return { dry_run: true, field: field || 'tous', would_apply: items.length, by_field: byField, sample: items.slice(0, 10).map(r => ({ customer_name: r.customer_name, legacy_account_id: r.legacy_account_id, change: r.change.filter(d => !PROTECT.has(d.field) && (!field || d.field === field)) })) }
let ok = 0, err = 0, protectedSkips = 0; const errors = []
for (const r of items) {
const patch = {}
for (const d of r.change) { if (PROTECT.has(d.field)) { protectedSkips++; continue } if (field && d.field !== field) continue; patch[d.field] = d.to }
if (!Object.keys(patch).length) continue
try { await writeCustomerPatch(r.name, patch); ok++ } catch (e) { err++; if (errors.length < 20) errors.push({ name: r.name, err: String(e.message).slice(0, 160) }) }
}
log(`legacy-sync applyReviewBatch (${field || 'tous'}): ${ok} appliqués, ${err} err`)
return { applied: ok, errors: err, field: field || 'tous', by_field: byField, protected_name_skips: protectedSkips, error_samples: errors }
}
// Crée TOUS les nouveaux comptes F (bucket `creates` de la preview) — pour l'orchestrateur « clients ».
async function createCustomers ({ confirm = false, limit = 1000 } = {}) {
const c = await previewCustomers()
return await createCustomersByIds({ ids: c.creates.map(x => x.legacy_account_id), confirm, limit })
}
// Appliquer un changement RÉVISÉ d'UN compte (F gagne, ciblé). Ex. email hotmail→gmail.
// Re-dérive les valeurs F (jamais la valeur stale de l'UI), n'applique QUE les champs divergents,
// JAMAIS customer_name (protégé) ni disabled/is_suspended (dérivés), JAMAIS écraser par du vide.
async function applyAccountChange ({ legacy_account_id, fields = null, confirm = false } = {}) {
legacy_account_id = Number(legacy_account_id)
if (!legacy_account_id) throw new Error('legacy_account_id requis')
const p = pool(); if (!p) throw new Error('F indisponible')
const [rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa FROM account WHERE id=?', [legacy_account_id])
if (!rows.length) throw new Error('compte F introuvable')
const m = mapAccount(rows[0])
const PROTECT = new Set(['customer_name']) // nettoyé à la migration, jamais réécrit par le brut F
const SYNCABLE = [...TEXT_FIELDS, ...BOOL_FIELDS] // exclut STATUS_FIELDS (disabled/is_suspended = dérivés)
// Écriture via le pool PG direct (comme tout le reste de la sync) — erp.update (REST) ne persiste pas
// email_id/tel (ERPNext re-dérive du Contact lié). Les fieldnames sont une allowlist fixe (pas d'injection).
const pgp = require('./address-db').pool()
const cols = SYNCABLE.map(f => `"${f}"`).join(', ')
const exRows = (await pgp.query(`SELECT name, ${cols} FROM "tabCustomer" WHERE legacy_account_id=$1 LIMIT 1`, [legacy_account_id])).rows
if (!exRows.length) throw new Error('Customer ERPNext introuvable')
const ex = exRows[0]
const cand = (fields && fields.length) ? fields : SYNCABLE
const patch = {}
for (const f of cand) {
if (PROTECT.has(f) || !SYNCABLE.includes(f)) continue
if (normField(f, m[f]) !== normField(f, ex[f]) && !isEmpty(m[f])) patch[f] = m[f] // F gagne, jamais par du vide
}
const keys = Object.keys(patch)
if (!keys.length) return { applied: 0, name: ex.name, note: 'aucun changement applicable (ou déjà à jour)' }
if (confirm !== 'F-WINS') return { dry_run: true, name: ex.name, patch }
await writeCustomerPatch(ex.name, patch) // helper partagé (pool PG)
log(`legacy-sync applyAccountChange #${legacy_account_id}: ${keys.join(',')}`)
return { applied: keys.length, name: ex.name, patch }
}
// Compteurs F LÉGERS (petites tables : account/delivery/service) — pour le tableau de bord. À mettre en cache côté appelant.
async function fCounts () {
const p = pool(); if (!p) return {}
const [[a]] = await p.query('SELECT COUNT(*) c FROM account')
const [[d]] = await p.query('SELECT COUNT(*) c FROM delivery')
const [[s]] = await p.query('SELECT COUNT(*) c FROM service WHERE status=1')
return { accounts: Number(a.c), deliveries: Number(d.c), services_active: Number(s.c) }
}
// Écart EXACT par domaine (set-diff d'ids, pas une soustraction de compte) → « à créer » juste + orphelins
// (ids ERPNext sans source F = comptes supprimés/fusionnés dans F). Léger : ids seuls, petites tables ; à cacher.
async function domainCounts () {
const p = pool(); if (!p) return null
const pgp = require('./address-db').pool()
// missing (à créer) = F-actif absent d'ERPNext ; orphans = ERPNext absent de F-TOTAL (supprimé de F,
// PAS juste annulé — un service annulé garde sa SS et n'est pas orphelin).
const d = async (fActiveSql, pgSql, fAllSql = null) => {
const [fa] = await p.query(fActiveSql); const fActive = new Set(fa.map(r => Number(r.id)))
const er = (await pgp.query(pgSql)).rows.map(r => Number(r.k)); const eset = new Set(er)
let missing = 0; for (const id of fActive) if (!eset.has(id)) missing++
let fAll = fActive
if (fAllSql) { const [fr] = await p.query(fAllSql); fAll = new Set(fr.map(r => Number(r.id))) }
let orphans = 0; for (const id of eset) if (!fAll.has(id)) orphans++
return { f: fActive.size, erp: eset.size, missing, orphans }
}
return {
clients: await d('SELECT id FROM account', 'SELECT legacy_account_id k FROM "tabCustomer" WHERE legacy_account_id>0'),
adresses: await d('SELECT id FROM delivery', 'SELECT legacy_delivery_id k FROM "tabService Location" WHERE legacy_delivery_id>0'),
services: await d('SELECT id FROM service WHERE status=1', 'SELECT legacy_service_id k FROM "tabService Subscription" WHERE legacy_service_id>0', 'SELECT id FROM service'),
}
}
// ── RÉCONCILIATION SERVICES PAR COMPTE (F→ERPNext) — APERÇU lecture seule ─────
// Cause vue (Schink/Cousineau/Marianne) : ERPNext a gardé l'ANCIEN forfait (suspendu) et n'a jamais
// reçu le NOUVEAU forfait ACTIF de F → total faux. On lit F par delivery_id (INDEXÉ ; dérivé d'ERPNext
// pour ÉVITER la jointure account non indexée qui figeait F), on diffe vs ERPNext. AUCUNE écriture.
// 1 compte à la fois = requête F légère, pas de scan complet, pas de rafale.
async function previewAccountServiceReconcile (customer) {
if (!customer) throw new Error('customer requis')
const pgp = require('./address-db').pool(); if (!pgp) throw new Error('ERPNext PG indisponible')
const locRows = (await pgp.query('SELECT name, legacy_delivery_id FROM "tabService Location" WHERE customer=$1', [customer])).rows
const dids = [...new Set(locRows.map(l => Number(l.legacy_delivery_id)).filter(Boolean))]
const p = pool(); if (!p) throw new Error('F indisponible (mysql2 absent)')
let fsvc = []
// PAS de jointure (les lectures de lignes service + jointure figent F) → 2 requêtes simples.
if (dids.length) {
const [rows] = await p.query({ sql: `SELECT id, status, date_suspended, delivery_id, product_id, hijack, hijack_price, hijack_desc FROM service WHERE delivery_id IN (${dids.map(() => '?').join(',')})`, timeout: 6000 }, dids)
fsvc = rows
}
// prix/sku produit par PK (rapide). F : prix effectif = hijack ? hijack_price : product.price (le « hijack » = override par service).
const prodIds = [...new Set(fsvc.map(s => Number(s.product_id)).filter(Boolean))]
const prod = {}
if (prodIds.length) { try { const [pr] = await p.query({ sql: `SELECT id, sku, price FROM product WHERE id IN (${prodIds.map(() => '?').join(',')})`, timeout: 6000 }, prodIds); for (const x of pr) prod[Number(x.id)] = x } catch (e) { /* produit optionnel */ } }
const effPrice = s => (Number(s.hijack) === 1) ? Number(s.hijack_price || 0) : Number((prod[Number(s.product_id)] || {}).price || 0)
const effDesc = s => (Number(s.hijack) === 1 && s.hijack_desc) ? s.hijack_desc : ((prod[Number(s.product_id)] || {}).sku || ('prod ' + s.product_id))
const subs = await erp.list('Service Subscription', { filters: [['customer', '=', customer]], fields: ['name', 'legacy_service_id', 'status', 'monthly_price', 'plan_name', 'service_location'], limit: 300 }) || []
const erpByLid = new Map(); for (const s of subs) erpByLid.set(Number(s.legacy_service_id || 0), s)
const isFActive = s => Number(s.status) === 1 // F: status=1 = ACTIF (date_suspended = historique, ignoré — cf. svcStatus corrigé)
const fActive = fsvc.filter(isFActive)
const missingActive = [] // F actif mais ABSENT/INACTIF dans ERPNext → à créer/réactiver
for (const s of fActive) {
const e = erpByLid.get(Number(s.id))
if (!e) missingActive.push({ legacy: Number(s.id), product: effDesc(s), price: Math.round(effPrice(s) * 100) / 100, erp: 'absent' })
else if (e.status !== 'Actif') missingActive.push({ legacy: Number(s.id), product: effDesc(s), price: Math.round(effPrice(s) * 100) / 100, erp: e.status, erp_name: e.name })
}
const erpActiveButFInactive = [] // ERPNext actif mais F inactif → à suspendre/annuler
for (const e of subs) {
if (e.status !== 'Actif' || !e.legacy_service_id) continue
const f = fsvc.find(x => Number(x.id) === Number(e.legacy_service_id))
if (f && !isFActive(f)) erpActiveButFInactive.push({ legacy: Number(e.legacy_service_id), name: e.name, plan: e.plan_name, price: Number(e.monthly_price || 0), f_status: Number(f.status), f_susp: Number(f.date_suspended || 0) })
}
const round = n => Math.round(n * 100) / 100
return {
customer, deliveries: dids,
f_active_total: round(fActive.reduce((a, s) => a + effPrice(s), 0)),
erp_active_total: round(subs.filter(s => s.status === 'Actif').reduce((a, s) => a + Number(s.monthly_price || 0), 0)),
missing_active: missingActive,
erp_active_f_inactive: erpActiveButFInactive,
aligned: missingActive.length === 0 && erpActiveButFInactive.length === 0,
}
}
// ── routes HTTP (gated ; preview = sûr) ──────────────────────────────────────
async function handle (req, res, method, p, url) {
if (p === '/legacy-sync/reconcile-account' && method === 'GET') {
try { return json(res, 200, await previewAccountServiceReconcile(url.searchParams.get('customer') || '')) }
catch (e) { log('reconcile-account error:', e.message); return json(res, 200, { error: e.message }) }
}
if (p === '/legacy-sync/preview' && method === 'GET') {
try { return json(res, 200, await runPreview({ persist: true })) }
catch (e) { log('legacy-sync preview error:', e.message); return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-sync/report' && method === 'GET') {
try { return json(res, 200, JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))) }
catch (e) { return json(res, 404, { error: 'aucun rapport — lancer /legacy-sync/preview' }) }
}
// Écriture gardée : sans body.confirm==='SAFE-ADD' → dry-run (n'écrit rien).
if (p === '/legacy-sync/apply-safe-adds' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await applySafeAdds({ confirm: b.confirm, limit: b.limit || 0 })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
// Sync incrémental F→OPS (watermark). dry-run sauf { confirm:'F-WINS' }. { full:true } = ignorer le watermark.
if (p === '/legacy-sync/run' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await syncCustomers({ confirm: b.confirm, full: !!b.full, source: b.source || 'bridge' })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
// Sync statut de service (subscription). dry-run sauf { confirm:'F-WINS' }.
if (p === '/legacy-sync/run-services' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await syncServices({ confirm: b.confirm })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
// Appliquer les changements révisés EN LOT (par champ ou tous). dry-run sauf { confirm:'F-WINS' }.
if (p === '/legacy-sync/apply-review-batch' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await applyReviewBatch({ field: b.field || null, confirm: b.confirm, limit: b.limit || 0 })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
// Appliquer un changement révisé d'un compte (F gagne, ciblé). dry-run sauf { confirm:'F-WINS' }.
if (p === '/legacy-sync/apply-change' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await applyAccountChange({ legacy_account_id: b.legacy_account_id, fields: b.fields, confirm: b.confirm })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
// Création des comptes F manquants (créateur canonique). dry-run sauf { confirm:'F-WINS' }.
if (p === '/legacy-sync/create-customers' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, b.ids ? await createCustomersByIds({ ids: b.ids, confirm: b.confirm, limit: b.limit || 2000 }) : await createCustomers({ confirm: b.confirm, limit: b.limit || 1000 })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
// Monitoring de F via le pont (fraîcheur + compteurs) — lecture seule.
if (p === '/legacy-sync/f-ping' && method === 'GET') {
try { return json(res, 200, await bridgeCall({ action: 'ping' })) }
catch (e) { return json(res, 502, { error: e.message }) }
}
if (p === '/legacy-sync/state' && method === 'GET') return json(res, 200, loadState())
if (p === '/legacy-sync/run-status' && method === 'GET') { try { return json(res, 200, JSON.parse(fs.readFileSync(RUN_LOG, 'utf8'))) } catch { return json(res, 200, { status: 'idle' }) } }
// Liste COMPLÈTE des erreurs du dernier apply, groupées par raison. ?reason=email_invalide pour filtrer.
if (p === '/legacy-sync/errors' && method === 'GET') {
try {
const d = JSON.parse(fs.readFileSync(ERRORS_FILE, 'utf8'))
const filt = url && url.searchParams.get('reason')
if (filt) d.errors = (d.errors || []).filter(e => e.reason === filt)
return json(res, 200, d)
} catch { return json(res, 200, { total_errors: 0, by_reason: {}, errors: [], note: 'aucun apply exécuté' }) }
}
return json(res, 404, { error: 'route legacy-sync inconnue' })
}
module.exports = { handle, runPreview, previewCustomers, applySafeAdds, syncCustomers, syncServices, createCustomers, createCustomersByIds, applyAccountChange, applyReviewBatch, fCounts, domainCounts, loadState, mapAccount, svcStatus, fAccountStatuses, fResiliatedAccountIds, F_ACCT_STATUS_LABEL, GROUP_MAP, CMP }

View File

@ -0,0 +1,121 @@
// lib/municipality.js — Résolveur de municipalités : mappe un nom de ville TEXTE LIBRE (delivery.city, tickets)
// vers la table canonique `municipalite` de F (1 185 municipalités QC : id, nom, code, administration/MRC).
// But : regrouper proprement par municipalité (les 21 orthographes de « Sainte-Clotilde » → 1 seul groupe).
// Lecture seule. Cache en mémoire. Détection campings (lieu-dit) via table d'alias extensible.
let mysql; try { mysql = require('mysql2/promise') } catch { /* optionnel */ }
const cfg = require('./config')
const { json, parseBody } = require('./helpers')
let _pool = null
function pool () { if (!mysql) return null; if (!_pool) _pool = mysql.createPool({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true }); return _pool }
// ── Normalisation ──
function decodeEntities (s) { return String(s == null ? '' : s).replace(/&#0*39;|&apos;/gi, "'").replace(/&amp;/gi, '&').replace(/&eacute;|&egrave;|&ecirc;/gi, 'e').replace(/&agrave;|&acirc;/gi, 'a').replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return ' ' } }) }
function deburr (s) { return decodeEntities(s).normalize('NFD').replace(/[̀-ͯ]/g, '') }
const STOP = new Set(['de', 'des', 'du', 'la', 'le', 'les', 'd', 'l', 'aux', 'au', 'sur', 'a'])
const NOISE = new Set(['camping', 'domaine', 'terrain', 'parc', 'roulotte', 'site', 'lot']) // mots-bruit (campings/lieux-dits) retirés pour le matching
function tokens (s) {
let x = ' ' + deburr(s).toLowerCase().replace(/\(.*?\)/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim() + ' '
x = x.replace(/ qc /g, ' ').replace(/ quebec /g, ' ')
// saint/sainte/st/ste → un seul token 'st' (corrige les erreurs Saint↔Sainte fréquentes dans les données)
return x.split(/\s+/).filter(t => t && !STOP.has(t) && !NOISE.has(t)).map(t => (t === 'ste' || t === 'sainte' || t === 'saint' || t === 'st') ? 'st' : t)
}
function lev (a, b) { if (a === b) return 0; const m = a.length, n = b.length; if (!m || !n) return m || n; let prev = Array.from({ length: n + 1 }, (_, i) => i); for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur } return prev[n] }
// un token d'entrée "matche" un token candidat : exact, préfixe (≥4), ou faute de frappe (lev≤1 ; ≤2 si long)
function tokMatch (t, toks) { for (const c of toks) { if (c === t) return true; if (t.length >= 5 && c.length >= 5 && (c.startsWith(t) || t.startsWith(c))) return true; if (lev(t, c) <= 1 && Math.min(t.length, c.length) >= 4) return true } return false } // lev≤1 SEULEMENT (≤2 conflateait Sherrington↔Harrington) ; préfixe ≥5
// ── Alias campings / hameaux → municipalité. toks (normalisés, sans bruit) ⊆ tokens d'entrée = match. muni = nom résolu via la table. EXTENSIBLE. ──
const CAMPING_ALIAS = [
{ toks: ['lac', 'pins'], muni: 'Franklin' }, // Camping Lac des Pins (secteur St-Antoine-Abbé → code FRK = Franklin)
{ toks: ['dauphinais'], muni: 'Hemmingford' }, // Camping / Domaine Dauphinais
{ toks: ['sandysun'], muni: 'Franklin' }, // Camping SandySun
{ toks: ['sandy', 'sun'], muni: 'Franklin' },
{ toks: ['rockburn'], muni: 'Hinchinbrooke' }, // hameau
{ toks: ['herdman'], muni: 'Hinchinbrooke' }, // hameau
{ toks: ['acadie'], muni: 'Saint-Jean-sur-Richelieu' }, // L'Acadie (secteur)
{ toks: ['melocheville'], muni: 'Beauharnois' }, // secteur
]
let MUNIS = null; let _byKey = null; let _loadedAt = 0
async function load (force) {
if (MUNIS && !force && (Date.now() - _loadedAt) < 6 * 3600 * 1000) return MUNIS
const p = pool(); if (!p) throw new Error('mysql2 indisponible')
const [rows] = await p.query('SELECT id, nom, code, administration, often_used FROM municipalite')
MUNIS = rows.map(r => ({ id: r.id, nom: r.nom, code: r.code, mrc: r.administration, often: r.often_used ? 1 : 0, toks: tokens(r.nom) }))
_byKey = {}; for (const m of MUNIS) { const k = m.toks.join(' '); if (!_byKey[k] || m.often > (_byKey[k].often || 0)) _byKey[k] = m }
_loadedAt = Date.now()
return MUNIS
}
// Coeur : matche une liste de tokens → meilleure municipalité {m, via, score, ambiguous, candidates} ou null.
function matchMunicipality (it) {
if (!it.length) return null
if (_byKey[it.join(' ')]) return { m: _byKey[it.join(' ')], via: 'exact', score: 1 }
let best = null; const cands = []
for (const m of MUNIS) {
let inter = 0; for (const t of it) if (tokMatch(t, m.toks)) inter++
if (!inter) continue
const contain = inter / it.length; const coverage = inter / m.toks.length; const extra = m.toks.length - inter
const score = contain * 2 + coverage; const c = { m, contain, coverage, extra, score }; cands.push(c)
if (!best || score > best.score || (score === best.score && (m.often > best.m.often || (m.often === best.m.often && (extra < best.extra || (extra === best.extra && m.id < best.m.id)))))) best = c
}
if (!best || best.contain < 0.6) {
for (const t of it) { if (t.length < 4) continue; const par = MUNIS.find(mm => mm.toks.length === 1 && mm.toks[0] === t); if (par) return { m: par, via: 'parent', score: 0.9 } }
return null
}
const top = cands.filter(c => c.contain >= 0.999)
const ambiguous = top.length > 1 && top.filter(c => c.m.often === best.m.often).length > 1
return { m: best.m, via: best.contain >= 0.999 ? 'superset' : 'fuzzy', score: Math.round(best.score * 100) / 100, ambiguous, candidates: ambiguous ? top.map(c => c.m.nom).slice(0, 5) : undefined }
}
// Résout une ville texte libre → municipalité canonique (alias campings/hameaux d'abord, puis matching).
function resolveSync (city) {
const raw = String(city || '').trim()
const isCamping = /\b(camping|domaine|terrain|roulotte)\b/i.test(deburr(raw))
const it = tokens(raw)
if (!it.length) return { input: raw, matched: false, reason: 'empty', is_camping: isCamping }
let aliasHit = null
for (const a of CAMPING_ALIAS) { if (a.toks.every(t => it.includes(t) || it.some(x => tokMatch(t, [x]))) && (!aliasHit || a.toks.length > aliasHit.toks.length)) aliasHit = a }
if (aliasHit) { const r = matchMunicipality(tokens(aliasHit.muni)); if (r) return { input: raw, matched: true, via: 'alias', is_camping: isCamping, canonical: r.m.nom, code: r.m.code, id: r.m.id, mrc: r.m.mrc, score: 1, alias_to: aliasHit.muni } }
const r = matchMunicipality(it)
if (r) return { input: raw, matched: true, via: r.via, is_camping: isCamping, canonical: r.m.nom, code: r.m.code, id: r.m.id, mrc: r.m.mrc, score: r.score, ambiguous: r.ambiguous, candidates: r.candidates }
return { input: raw, matched: false, is_camping: isCamping, reason: 'aucun_match' }
}
async function resolve (city) { await load(); return resolveSync(city) }
// Rapport : distinct delivery.city de F → résolus → effondrement des groupes.
async function previewDelivery (limit) {
await load()
const p = pool(); if (!p) throw new Error('mysql2 indisponible')
const [rows] = await p.query('SELECT city, COUNT(*) n FROM delivery WHERE city IS NOT NULL AND city <> "" GROUP BY city')
const groups = {}; const unmatched = []; const campings = []; const ambiguous = []
for (const r of rows) {
const res = resolveSync(r.city)
if (res.matched) { const k = res.code || res.canonical; (groups[k] = groups[k] || { canonical: res.canonical, code: res.code, mrc: res.mrc, variants: [], total: 0 }); groups[k].variants.push({ raw: r.city, n: r.n, via: res.via }); groups[k].total += r.n; if (res.ambiguous) ambiguous.push({ raw: r.city, candidates: res.candidates }) }
else if (res.is_camping) campings.push({ raw: r.city, n: r.n })
else unmatched.push({ raw: r.city, n: r.n, near: res.near })
}
const collapsed = Object.values(groups).filter(g => g.variants.length > 1).sort((a, b) => b.variants.length - a.variants.length)
return {
raw_distinct: rows.length,
canonical_groups: Object.keys(groups).length,
multi_variant_groups: collapsed.length,
unmatched: unmatched.length, campings: campings.length, ambiguous: ambiguous.length,
top_collapses: collapsed.slice(0, 12).map(g => ({ municipalite: g.canonical, code: g.code, variants: g.variants.length, rows: g.total, examples: g.variants.sort((a, b) => b.n - a.n).slice(0, 6).map(v => v.raw + ' (' + v.n + ')') })),
unmatched_top: unmatched.sort((a, b) => b.n - a.n).slice(0, 15),
campings_list: campings.sort((a, b) => b.n - a.n).slice(0, 15),
ambiguous_top: ambiguous.slice(0, 10)
}
}
async function handle (req, res, method, path, url) {
const qs = (url && url.searchParams) || new URLSearchParams()
try {
if (path === '/municipality/resolve') return json(res, 200, await resolve(qs.get('city') || ''))
if (path === '/municipality/preview-delivery') return json(res, 200, await previewDelivery())
if (path === '/municipality/reload') { await load(true); return json(res, 200, { ok: true, count: MUNIS.length }) }
return json(res, 404, { ok: false, error: 'route municipality inconnue' })
} catch (e) { return json(res, 500, { ok: false, error: String((e && e.message) || e) }) }
}
module.exports = { handle, resolve, resolveSync, previewDelivery, load, tokens }

View File

@ -168,6 +168,78 @@ async function getManageIp (serial, opts = {}) {
return { serial, oltName, oltHost, slot, port, ontId, ips, manageIp: mgmt?.ip || ips[0].ip }
}
// Raisecom per-ONU WAN octet counters (VLAN 40 = service internet) — mirrors F's
// device_ajax/raisecom_rcmg_bandwith.php. Cumulative octets (32-bit, may wrap):
// rx (download) = 1.3.6.1.4.1.8886.18.3.7.2.4.1.5.{oltId}.40
// tx (upload) = 1.3.6.1.4.1.8886.18.3.7.2.4.1.3.{oltId}.40
// oltId = slot*10000000 + port*100000 + ontid (same encoding as the WAN-IP table)
// Raisecom (tech-2) ONLY — TP-Link/tech-3 has no live WAN-rate counter in F.
// The CALLER samples twice and computes rate = ((val - last) / sec) * 8 bits/s
// (with counter-wrap handling), exactly like F's chart.
const RAISECOM_WAN_OCTETS_BASE = '1.3.6.1.4.1.8886.18.3.7.2.4.1'
const RC_WAN_VLAN = 40
async function getOnuBandwidth (serial, opts = {}) {
let oltHost, community, oltName, slot, port, ontId, type
if (serial) {
const onu = getOnuBySerial(serial)
if (onu) {
const olt = olts.get(onu.oltHost)
if (olt) {
type = olt.type; oltHost = olt.host; community = olt.community; oltName = olt.name
const parts = (onu.port || '').split('/') // Raisecom cache stores port as "0/slot/port"
if (parts.length === 3) { slot = parseInt(parts[1]); port = parseInt(parts[2]) }
ontId = onu.onuIdx
}
}
}
// Explicit params (from ERPNext Service Equipment when ONU not in cache)
if (opts.oltIp) oltHost = opts.oltIp
if (opts.community) community = opts.community
if (opts.slot != null) slot = parseInt(opts.slot)
if (opts.port != null) port = parseInt(opts.port)
if (opts.ontId != null) ontId = parseInt(opts.ontId)
if (opts.type) type = (opts.type + '').toLowerCase()
if (!community) community = 'targosnmp'
if (!oltName) oltName = oltHost
// tech-3 (TP-Link) has no live WAN octet counter in F → report unavailable, don't poll
if (type && type !== 'raisecom') return { available: false, reason: 'unsupported_olt_type', type }
if (!oltHost || slot == null || port == null || ontId == null || isNaN(slot) || isNaN(port) || isNaN(ontId)) {
return { available: false, reason: 'onu_not_resolved' }
}
const oltId = slot * 10000000 + port * 100000 + ontId
const session = createSession(oltHost, community)
let rxOctets = null, txOctets = null
try {
// Les 2 gets en parallèle (même session) → ~2× plus rapide à répondre
const [rxVb, txVb] = await Promise.all([
snmpGet(session, `${RAISECOM_WAN_OCTETS_BASE}.5.${oltId}.${RC_WAN_VLAN}`),
snmpGet(session, `${RAISECOM_WAN_OCTETS_BASE}.3.${oltId}.${RC_WAN_VLAN}`),
])
// These counters are SNMP Counter64 → net-snmp hands them back as an 8-byte
// big-endian Buffer (NOT an Integer). Parse to a JS number (octet totals stay
// well under 2^53 between resets, so precision is fine).
const toNum = (vb) => {
if (!vb) return null
const v = vb.value
if (typeof v === 'number') return v // Counter32/Gauge/Integer
if (typeof v === 'bigint') return Number(v)
if (Buffer.isBuffer(v)) {
try { return Number(v.readBigUInt64BE(0)) }
catch (e) { const n = Number('0x' + v.toString('hex')); return isNaN(n) ? null : n }
}
const n = parseInt(v, 10); return isNaN(n) ? null : n
}
rxOctets = toNum(rxVb); txOctets = toNum(txVb)
} finally { try { session.close() } catch {} }
if (rxOctets == null && txOctets == null) return { available: false, reason: 'no_counter', oltName, oltId }
return { available: true, serial: serial || null, oltName, slot, port, ontId, oltId, rxOctets, txOctets, ts: Date.now() }
}
function extractVal (vb) {
if (vb.type === snmp.ObjectType.OctetString) return vb.value.toString()
if (vb.type === snmp.ObjectType.Integer) return vb.value
@ -484,5 +556,5 @@ function stopOltPoller () {
module.exports = {
registerOlt, startOltPoller, stopOltPoller,
getOnuBySerial, getPortNeighbors, getPortHealth, classifyOfflineCause,
getOltStats, getAllOnus, pollAllOlts, getManageIp,
getOltStats, getAllOnus, pollAllOlts, getManageIp, getOnuBandwidth,
}

View File

@ -0,0 +1,74 @@
'use strict'
// ── Tampon d'envoi sortant (« undo send ») pour les courriels MULTI-DESTINATAIRES (notifs de file) ──────────
// But : avant qu'un fan-out parte, le retenir N secondes, l'AFFICHER (destinataires + sujet + compte à rebours)
// dans un panneau OPS, et permettre d'ANNULER. Filet anti-spam (voir [[feedback_inbox_queue_notify]]).
// Les réponses agent à 1 destinataire ne passent PAS ici (envoi immédiat, aucun risque). HOLD=0 → envoi direct.
const fs = require('fs')
const { log } = require('./helpers')
const sse = require('./sse')
const STORE = '/app/data/outbox.json'
const HOLD_MS = Math.max(0, (Number(process.env.OUTBOX_HOLD_SEC) || 20) * 1000)
const pending = new Map() // id -> { id, to, subject, sendAt, opts, meta, status, error? }
const timers = new Map()
let seq = 0
function persist () { try { fs.writeFileSync(STORE + '.tmp', JSON.stringify([...pending.values()])); fs.renameSync(STORE + '.tmp', STORE) } catch (e) { log('outbox persist: ' + e.message) } }
function recipientsOf (to) { return String(to || '').split(',').map(s => s.trim()).filter(Boolean) }
function view (e) { return { id: e.id, subject: e.subject, recipients: recipientsOf(e.to), count: recipientsOf(e.to).length, sendAt: e.sendAt, status: e.status || 'pending', error: e.error || null, kind: (e.meta && e.meta.kind) || 'email', label: (e.meta && e.meta.label) || '' } }
function list () { return [...pending.values()].sort((a, b) => a.sendAt - b.sendAt).map(view) }
function broadcast () { try { sse.broadcast('outbox', 'outbox', { pending: list(), holdSec: HOLD_MS / 1000 }) } catch (e) { /* */ } }
async function doSend (e) {
const gmail = require('./gmail')
const r = await gmail.sendMessage(e.opts)
if (e.meta && e.meta.convToken) { try { require('./conversation').onOutboxSent(e.meta.convToken, r, e.meta) } catch (x) { /* */ } }
return r
}
async function flush (id) {
const e = pending.get(id); if (!e || e.status === 'sending') return
e.status = 'sending'; clearTimeout(timers.get(id)); timers.delete(id)
try {
await doSend(e)
pending.delete(id); persist(); broadcast()
log('outbox SENT ' + id + ' → ' + e.to + ' [' + ((e.meta && e.meta.kind) || 'email') + ']')
} catch (err) {
e.status = 'failed'; e.error = err.message; persist(); broadcast() // reste visible (rouge) → Réessayer / Rejeter
log('outbox FAILED ' + id + ': ' + err.message)
}
}
function arm (e) { const t = setTimeout(() => flush(e.id).catch(() => {}), Math.max(0, e.sendAt - Date.now())); timers.set(e.id, t) }
// Met en file un envoi retenu. Renvoie une promesse qui résout TÔT (l'envoi réel se fait au flush).
function enqueue (opts, meta = {}) {
if (!opts || !opts.to) return Promise.resolve({ ok: false, error: 'destinataire requis' })
if (HOLD_MS === 0) return doSend({ opts, meta }).then(r => ({ ok: true, id: r && r.id, sent: true })).catch(e => ({ ok: false, error: e.message }))
const id = 'ob_' + Date.now().toString(36) + '_' + (++seq)
const e = { id, to: opts.to, subject: opts.subject || '', sendAt: Date.now() + HOLD_MS, opts, meta, status: 'pending' }
pending.set(id, e); persist(); arm(e); broadcast()
log('outbox HELD ' + id + ' → ' + recipientsOf(opts.to).length + ' dest. [' + (meta.kind || 'email') + '] « ' + e.subject.slice(0, 50) + ' »')
return Promise.resolve({ ok: true, queued: true, id, sendAt: e.sendAt })
}
function cancel (id) { const e = pending.get(id); if (!e) return { ok: false, error: 'introuvable' }; clearTimeout(timers.get(id)); timers.delete(id); pending.delete(id); persist(); broadcast(); log('outbox CANCEL ' + id); return { ok: true, cancelled: id } }
function cancelAll () { const ids = [...pending.keys()]; for (const id of ids) { clearTimeout(timers.get(id)); timers.delete(id) } pending.clear(); persist(); broadcast(); log('outbox CANCEL-ALL (' + ids.length + ')'); return { ok: true, cancelled: ids.length } }
async function sendNow (id) { const e = pending.get(id); if (!e) return { ok: false, error: 'introuvable' }; if (e.status === 'failed') e.status = 'pending'; e.sendAt = Date.now(); await flush(id); const still = pending.get(id); return { ok: !still || still.status !== 'failed', id, error: still && still.error } }
function startup () {
try { for (const e of JSON.parse(fs.readFileSync(STORE, 'utf8'))) pending.set(e.id, e) } catch (e) { return }
let armed = 0
for (const e of pending.values()) { if (e.status === 'failed') continue; arm(e); armed++ } // les en attente repartent (sendAt passé → flush quasi immédiat)
if (pending.size) log('outbox: ' + pending.size + ' en file rechargés (' + armed + ' réarmés)')
broadcast()
}
async function handle (req, res, method, p) {
const json = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)) }
const body = () => new Promise(r => { let d = ''; req.on('data', c => { d += c }); req.on('end', () => { try { r(JSON.parse(d || '{}')) } catch (e) { r({}) } }); req.on('error', () => r({})) })
if (p === '/outbox' && method === 'GET') return json(200, { pending: list(), holdSec: HOLD_MS / 1000 })
if (p === '/outbox/cancel' && method === 'POST') { const b = await body(); return json(200, cancel(b.id)) }
if (p === '/outbox/cancel-all' && method === 'POST') return json(200, cancelAll())
if (p === '/outbox/send-now' && method === 'POST') { const b = await body(); return json(200, await sendNow(b.id)) }
return json(404, { error: 'not found' })
}
module.exports = { enqueue, cancel, cancelAll, sendNow, list, startup, handle }

View File

@ -35,6 +35,10 @@ const DEFAULT_POLICY = {
// skill_by_type : table type de job (service_type) → compétence requise. Le client ne voit que les
// créneaux des techs qui ont ce tag. '' = aucun tag requis (ex. ajout TV/borne WiFi → n'importe qui).
booking: { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} },
// Routage de tournée (lu CÔTÉ CLIENT par la page Planification) : origine de tournée = dépôt par défaut,
// OU domicile du tech (tech_homes[id]) s'il est plus proche du travail. La 1re job = la plus proche de l'origine.
depot: { label: 'Dépôt', address: '1867 Chemin de la Rivière, Sainte-Clotilde, QC J0L 1W0', lat: 45.158661, lon: -73.67543 },
tech_homes: {}, // { 'TECH-xxxx': { address, lat, lon } } — édité via le sélecteur d'emplacement (page Planification)
}
const POLICY_OPTIONS = {
reschedule: [
@ -249,19 +253,10 @@ async function execTool (name, args) {
}
// ── Appel Gemini (OpenAI-compat) — même config que agent.js ─────────────────
// Copilote roster → routé par tâche 'roster' via lib/ai.js (repli Gemini auto). Boucle tool-calls inchangée (ré-emballage {choices:[{message}]}).
async function geminiChat (messages) {
const url = `${cfg.AI_BASE_URL}chat/completions`
const body = { model: cfg.AI_MODEL, max_tokens: 700, messages, tools: TOOLS }
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
body: JSON.stringify(body),
})
if (res.status === 429 && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 1500)); continue }
if (!res.ok) throw new Error(`Gemini ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`)
return await res.json()
}
const msg = await require('./ai').chat({ task: 'roster', messages, tools: TOOLS, maxTokens: 700, wantMessage: true })
return { choices: [{ message: msg }] }
}
function systemPrompt () {

View File

@ -353,11 +353,19 @@ function deptToSkill (txt) {
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) {
const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))]
if (!codes.length) return jobs
let locs = []
try { locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }) } catch (e) { return jobs }
const m = {}; for (const l of locs) m[l.name] = l
for (const j of jobs) { const l = m[j.service_location]; if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || '' }
const m = {}
if (codes.length) { try { const locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }); for (const l of locs) m[l.name] = l } catch (e) {} }
// Résolveur municipalité (best-effort : si F injoignable, on n'enrichit pas → OPS retombe sur la ville brute).
let muni = null; try { muni = require('./municipality'); await muni.load() } catch (e) { muni = null }
const cityFromAddr = (s) => { const p = String(s || '').split(',').map(x => x.trim()).filter(Boolean); return p.length >= 2 ? p[p.length - 1] : '' }
for (const j of jobs) {
const l = m[j.service_location]
if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || ''
if (muni) {
const rawCity = (l && l.city) || cityFromAddr(j.location_label || j.address) || ''
if (rawCity) { try { const r = muni.resolveSync(rawCity); if (r && r.matched) { j.municipalite = r.canonical; j.municipalite_code = r.code } } catch (e) {} }
}
}
return jobs
}
function nowMs () { return new Date().getTime() }
@ -702,7 +710,33 @@ async function occupancyByTechDay (start, days) {
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
}
// ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ──
// 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ;
// l'assistant épinglé voit un bloc miroir à l'heure du lead, dimensionné à SA durée → anti double-booking.
const jobNames = jobs.map(j => j.name)
if (jobNames.length) {
const jByName = {}; for (const j of jobs) jByName[j.name] = j
let assists = []
try {
assists = await erp.list('Dispatch Job Assistant', {
filters: [['parent', 'in', jobNames], ['pinned', '=', 1]],
fields: ['parent', 'tech_id', 'tech_name', 'duration_h'], limit: 5000,
})
} catch (e) { assists = [] } // table absente / aucun assistant → silencieux (0 impact)
for (const a of assists) {
const j = jByName[a.parent]; if (!j || !a.tech_id || !j.scheduled_date) continue
const k = a.tech_id + '|' + j.scheduled_date
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject)
const jDur = (estimateForJob(j, occChars).minutes / 60) || Number(j.duration_h) || 0
const dur = Number(a.duration_h) || jDur || 1 // durée propre de l'assistant (≤ job)
o.h += dur
const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name, assist: true })
o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' })
}
}
// ordre = route_order manuel s'il existe, sinon priorité puis heure
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
@ -1032,6 +1066,27 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
}
// Lire / éditer l'ÉQUIPE d'un job (assistants en renfort) — le LEAD (assigned_tech) reste INCHANGÉ.
// GET ?job=X → liste actuelle des assistants
// POST {job, add:{tech_id,...}} → AJOUTE (dédup sur tech_id), sans clobber des autres
// POST {job, remove:tech_id} → RETIRE
// POST {job, assistants:[...]} → REMPLACE toute la table-enfant
// PUT partiel ⇒ assigned_tech / scheduled_date / etc. préservés. Chemin d'écriture UNIQUE (features/workforce).
if (path === '/roster/job/team' && (method === 'GET' || method === 'POST')) {
const b = method === 'POST' ? await parseBody(req) : {}
const job = b.job || qs.get('job')
if (!job) return json(res, 400, { error: 'job requis' })
const norm = (a) => ({ tech_id: String(a.tech_id), tech_name: String(a.tech_name || ''), duration_h: Number(a.duration_h) || 0, note: String(a.note || '').slice(0, 140), pinned: a.pinned ? 1 : 0 })
let cur = []
try { cur = await erp.list('Dispatch Job Assistant', { filters: [['parent', '=', job]], fields: ['tech_id', 'tech_name', 'duration_h', 'note', 'pinned'], limit: 50 }) } catch (e) { cur = [] }
if (method === 'GET') return json(res, 200, { job, assistants: cur.map(norm) })
let rows
if (b.add && b.add.tech_id) { rows = cur.filter(a => String(a.tech_id) !== String(b.add.tech_id)).map(norm); rows.push(norm(b.add)) }
else if (b.remove) { rows = cur.filter(a => String(a.tech_id) !== String(b.remove)).map(norm) }
else { rows = (b.assistants || []).filter(a => a && a.tech_id).map(norm) }
const r = await retryWrite(() => erp.update('Dispatch Job', job, { assistants: rows }))
return json(res, r.ok ? 200 : 500, { ...r, job, assistants: rows.length, team: rows })
}
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
if (path === '/roster/job/set-location' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })

View File

@ -0,0 +1,137 @@
'use strict'
// ── Factures fournisseurs ──────────────────────────────────────────────────
// Courriels to:factures@ (reçus dans cc@, membre du groupe) → OCR des pièces (image/PDF) → un INTAKE par pièce
// stocké dans ERPNext (doctype « Supplier Invoice Intake », auditable/permissionné) + pièce jointe versée dans ERPNext.
// Revue dans OPS → création d'un Purchase Invoice DRAFT (jamais soumis auto). ERPNext = registre ; OPS = capture/triage.
const http = require('http')
const cfg = require('./config')
const { log, json, parseBody, loadSeenSet, saveSeenSet } = require('./helpers')
const erp = require('./erp')
const SEEN = '/app/data/supplier_invoices_seen.json'
const DOCTYPE = 'Supplier Invoice Intake'
const INVOICE_TO = () => (process.env.GMAIL_INVOICE_TO || 'factures@targointernet.com').trim()
const isOcrable = (a) => /pdf$/i.test(a.mimeType) || /^image\//i.test(a.mimeType) || /\.(pdf|jpe?g|png|webp|heic)$/i.test(a.filename || '')
// Verse la pièce dans ERPNext (File privé attaché à l'intake) → le champ `attachment` reçoit l'URL. Best-effort.
async function attachToErp (name, filename, mime, base64) {
const r = await erp.create('File', {
file_name: filename || (name + (/pdf/i.test(mime) ? '.pdf' : '.jpg')),
is_private: 1, content: base64, decode: true,
attached_to_doctype: DOCTYPE, attached_to_name: name, attached_to_field: 'attachment',
})
if (!r.ok) log('attachToErp ' + name + ': ' + r.error)
return r.ok
}
// OCR une pièce → crée un intake ERPNext (+ pièce versée). Dédup gérée en amont (seen-set).
async function ingestAttachment ({ mb, msgId, from, subject, date, att }) {
const vision = require('./vision'); const gmail = require('./gmail')
const isPdf = /pdf/i.test(att.mimeType) || /\.pdf$/i.test(att.filename || '')
const mime = isPdf ? 'application/pdf' : (att.mimeType && att.mimeType.startsWith('image/') ? att.mimeType : 'image/jpeg')
const base64 = await gmail.getAttachment(msgId, att.attachmentId, mb)
let p = {}
try { p = await vision.extractInvoice(base64, mime) } catch (e) { log('supplier OCR ' + att.filename + ': ' + e.message); p = { ocr_error: e.message } }
const doc = {
status: p.ocr_error ? 'Error' : 'New',
vendor: p.vendor || null, invoice_number: p.invoice_number || null,
invoice_date: p.date || null, due_date: p.due_date || null,
currency: p.currency || 'CAD', subtotal: p.subtotal || 0, tax_gst: p.tax_gst || 0, tax_qst: p.tax_qst || 0, total: p.total || 0,
email_from: from, email_subject: subject, email_date: date, gmail_msg_id: msgId,
attachment_filename: att.filename || '', ocr_error: p.ocr_error || null,
raw_ocr: JSON.stringify(p).slice(0, 100000),
}
const r = await erp.create(DOCTYPE, doc)
if (!r.ok) { log('intake create échec: ' + r.error); return false }
await attachToErp(r.name, att.filename, mime, base64).catch(() => {})
return true
}
// Poll : courriels to:factures@ (dans cc@). Idempotent (seen par message).
async function pollInvoices () {
const gmail = require('./gmail'); const mb = gmail.mailbox()
const seen = loadSeenSet(SEEN); let added = 0
let ids = []
try { ids = await gmail.listMessages({ q: `newer_than:14d to:${INVOICE_TO()}`, max: 30, mb }) } catch (e) { log('pollInvoices list: ' + e.message); return { ok: false, error: e.message } }
for (const { id } of ids.slice().reverse()) {
if (seen.has(id)) continue
try {
const m = await gmail.getMessage(id, mb)
for (const att of (m.attachments || []).filter(isOcrable)) { if (await ingestAttachment({ mb, msgId: id, from: m.from, subject: m.subject, date: m.date, att })) added++ }
seen.add(id)
} catch (e) { log('pollInvoices msg ' + id + ': ' + e.message) }
}
saveSeenSet(SEEN, seen)
return { ok: true, scanned: ids.length, added }
}
// Crée un Purchase Invoice DRAFT (best-effort, jamais soumis). Échec → intake passe 'Error' + message ; saisie manuelle possible.
async function createDraft (name) {
const c = await erp.get(DOCTYPE, name)
if (!c || !c.name) return { ok: false, error: 'intake introuvable' }
let supplier = c.supplier || null
if (!supplier && c.vendor) {
try { const sups = await erp.list('Supplier', { filters: [['supplier_name', 'like', '%' + c.vendor + '%']], fields: ['name'], limit: 1 }); if (sups && sups[0]) supplier = sups[0].name } catch (e) { /* */ }
}
const doc = {
bill_no: c.invoice_number || undefined, bill_date: c.invoice_date || undefined, currency: c.currency || 'CAD',
items: [{ item_name: ((c.vendor || 'Facture') + (c.invoice_number ? ' ' + c.invoice_number : '')).slice(0, 140), description: 'Facture fournisseur (OCR) — ' + (c.attachment_filename || name), qty: 1, rate: Number(c.total || c.subtotal || 0) || 0 }],
}
if (supplier) doc.supplier = supplier
const pi = await erp.create('Purchase Invoice', doc)
if (pi && pi.ok) { await erp.update(DOCTYPE, name, { status: 'Created', supplier: supplier || undefined, purchase_invoice: pi.name, ocr_error: null }); return { ok: true, name: pi.name, supplier_matched: !!supplier } }
await erp.update(DOCTYPE, name, { status: 'Error', ocr_error: (pi && pi.error) || 'échec création' })
return { ok: false, error: (pi && pi.error) || 'échec création', supplier_matched: !!supplier }
}
// Récupère le binaire d'un fichier privé ERPNext (pour l'affichage dans OPS), authentifié comme erpFetch.
function erpFileBinary (fileUrl) {
return new Promise((resolve, reject) => {
const u = new URL(cfg.ERP_URL + fileUrl)
const req = http.request({ hostname: u.hostname, port: u.port || 8000, path: u.pathname + u.search, method: 'GET', headers: { Host: cfg.ERP_SITE, Authorization: 'token ' + cfg.ERP_TOKEN } }, (res) => {
if (res.statusCode >= 400) { res.resume(); return reject(new Error('ERP file ' + res.statusCode)) }
const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: res.headers['content-type'] || 'application/octet-stream' }))
})
req.on('error', reject); req.end()
})
}
async function handle (req, res, method, p, url) {
if (p === '/supplier-invoices' && method === 'GET') {
const showAll = url && url.searchParams.get('all') === '1'
const filters = showAll ? [] : [['status', '!=', 'Ignored']]
const fields = ['name', 'status', 'vendor', 'supplier', 'invoice_number', 'invoice_date', 'due_date', 'currency', 'subtotal', 'tax_gst', 'tax_qst', 'total', 'attachment', 'attachment_filename', 'email_from', 'email_subject', 'email_date', 'ocr_error', 'purchase_invoice', 'creation']
let invoices = []
try { invoices = await erp.list(DOCTYPE, { filters, fields, limit: 200, orderBy: 'creation desc' }) } catch (e) { return json(res, 200, { invoices: [], error: e.message, invoice_to: INVOICE_TO() }) }
return json(res, 200, { invoices, invoice_to: INVOICE_TO(), erp_base: (cfg.ERP_PUBLIC_URL || 'https://erp.gigafibre.ca') })
}
if (p === '/supplier-invoices/poll' && method === 'POST') return json(res, 200, await pollInvoices())
const m = p.match(/^\/supplier-invoices\/([^/]+)\/(attachment|create|ignore)$/)
if (m) {
const name = decodeURIComponent(m[1]); const action = m[2]
if (action === 'attachment' && method === 'GET') {
try {
const c = await erp.get(DOCTYPE, name)
if (!c || !c.attachment) return json(res, 404, { error: 'pas de pièce' })
const f = await erpFileBinary(c.attachment)
res.writeHead(200, { 'Content-Type': f.contentType, 'Content-Disposition': 'inline; filename="' + (c.attachment_filename || 'piece') + '"' })
return res.end(f.buffer)
} catch (e) { return json(res, 404, { error: 'pièce introuvable: ' + e.message }) }
}
if (action === 'create' && method === 'POST') return json(res, 200, await createDraft(name))
if (action === 'ignore' && method === 'POST') { const r = await erp.update(DOCTYPE, name, { status: 'Ignored' }); return json(res, r.ok ? 200 : 500, r.ok ? { ok: true } : { error: r.error }) }
}
return json(res, 404, { error: 'not found' })
}
let _timer = null
function start () {
if (String(process.env.GMAIL_INGEST || '').toLowerCase() === 'off') return
const ms = (Number(process.env.INVOICE_POLL_MIN) || 10) * 60000
if (_timer) clearInterval(_timer)
_timer = setInterval(() => pollInvoices().catch(e => log('pollInvoices: ' + e.message)), ms)
log('Factures fournisseurs : poll ' + (ms / 60000) + ' min sur ' + require('./gmail').mailbox() + ' (to:' + INVOICE_TO() + ') → ERPNext ' + DOCTYPE)
pollInvoices().catch(() => {})
}
module.exports = { handle, pollInvoices, createDraft, start }

View File

@ -0,0 +1,121 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// ORCHESTRATEUR DE SYNCHRONISATION F → ERPNext — colonne vertébrale unifiée.
//
// Pattern : moteurs MODULAIRES (un par domaine) + orchestration + vue UNIFIÉES.
// (1) MANIFEST du DAG de dépendances → garantit l'ordre (un module ne tourne jamais
// avant ses prérequis : c'est la leçon « comptes avant factures » généralisée).
// (2) status() → agrège l'état LÉGER de chaque module (compteurs PG + MAX(id) F +
// coherenceStatus billing), mis en cache 60s. Base du tableau de bord unifié.
//
// L'EXÉCUTION ordonnée vit dans /opt/targo-sync/run.sh (l'hôte a docker pour le step
// Python des factures). Le DAG ci-dessous DOCUMENTE et VALIDE cet ordre.
// Règle de statut client : disabled/is_suspended = DÉRIVÉ des services, jamais sync
// depuis F (cf. feedback_customer_status_policy) → classés « dérivé » côté dashboard.
// ─────────────────────────────────────────────────────────────────────────────
const { json, log, parseBody } = require('./helpers')
// DAG : chaque module déclare ses prérequis. Ordre topologique garanti par l'orchestrateur.
// Dépendances VÉRIFIÉES contre les colonnes *_id de F (aucune FK déclarée) :
// delivery.account_id→account · service.{delivery_id,device_id,product_id} · invoice.account_id
// invoice_item.{invoice_id,service_id} · payment.account_id · payment_item.{payment_id,invoice_id}
// ticket.{account_id,delivery_id} · account_memo.account_id
const MODULES = [
{ key: 'clients', label: 'Clients', deps: [], engine: 'legacy-sync', creator: 'createCustomers' },
{ key: 'adresses', label: 'Adresses', deps: ['clients'], engine: 'legacy-sync', note: 'Service Location (delivery.account_id)' },
{ key: 'services', label: 'Services', deps: ['clients', 'adresses'], engine: 'legacy-sync', note: 'Service Subscription (service.delivery_id)' },
{ key: 'tickets', label: 'Tickets', deps: ['clients', 'adresses'], engine: 'legacy-dispatch-sync', note: 'ticket.{account_id,delivery_id} — cadence propre' },
{ key: 'factures', label: 'Factures', deps: ['clients', 'adresses'], engine: 'sync_invoices_incremental.py', note: 'invoice.account_id ; items→service_location' },
{ key: 'paiements', label: 'Paiements', deps: ['factures'], engine: 'legacy-payments', note: 'payment_item.invoice_id' },
]
// Ordre topologique (Kahn) — sert à la fois de doc et de garde-fou (détecte un cycle).
function topoOrder () {
const done = new Set(); const order = []
let guard = 0
while (order.length < MODULES.length && guard++ < 50) {
for (const m of MODULES) { if (!done.has(m.key) && m.deps.every(d => done.has(d))) { order.push(m.key); done.add(m.key) } }
}
return order
}
let _cache = null; let _cacheAt = 0
async function status ({ fresh = false } = {}) {
if (!fresh && _cache && (Date.now() - _cacheAt) < 60000) return { ..._cache, from_cache: true }
const lp = require('./legacy-payments')
const ls = require('./legacy-sync')
let dc = null; try { dc = await ls.domainCounts() } catch (e) { dc = null } // set-diff EXACT (à créer + orphelins)
let coh = {}; try { coh = await lp.coherenceStatus() } catch (e) { coh = { error: e.message } }
const mods = {}
const dom = (k, extra) => (dc && dc[k]) ? { erp: dc[k].erp, f: dc[k].f, to_create: dc[k].missing, orphans: dc[k].orphans, ...(extra || {}) } : { error: 'F indisponible' }
mods.clients = dom('clients', { status_note: 'disabled/is_suspended = dérivé (non synchronisé)' })
mods.adresses = dom('adresses')
mods.services = dom('services', { note: 'orphans = SS dont le service F est supprimé (≠ annulé)' })
mods.factures = coh.invoices ? { lag: coh.invoices.lag, synced: coh.invoices.live_synced, ar_open: coh.invoices.ar_open } : { error: coh.error }
mods.paiements = coh.payments ? { lag: coh.payments.lag, total: coh.payments.total } : { error: coh.error }
mods.tickets = { engine: 'legacy-dispatch-sync', note: 'moteur séparé (osTicket → Dispatch Job)' }
const out = {
checked_at: new Date().toISOString(),
order: topoOrder(),
dag: MODULES.map(m => ({ key: m.key, label: m.label, deps: m.deps, engine: m.engine, note: m.note })),
modules: mods,
billing_health: { healthy: coh.healthy, last_cycle: coh.last_cycle, last_cycle_min_ago: coh.last_cycle_min_ago },
note: 'Moteurs modulaires, orchestration + vue unifiées. Exécution ordonnée : /opt/targo-sync/run.sh (horaire).',
}
_cache = out; _cacheAt = Date.now()
return out
}
// ── SYNC MANUEL (« Synchroniser maintenant ») — étapes HUB-SIDE, progression pollable ──
// Couvre les couches qui changent en continu (clients/paiements/soldes/tickets), in-process.
// Les FACTURES + SERVICES sont créés par le cron horaire (scripts Python sur l'hôte) → hors de ce bouton ;
// l'UI affiche leur lag + le prochain passage auto.
// Cœur (rapide, ~30s) : couches qui changent en continu.
const CORE_STEPS = [
{ key: 'clients', label: 'Comptes manquants', fn: async () => { const r = await require('./legacy-payments').ensureBillingCustomers({ confirm: 'F-WINS' }); return `${r.created || 0} créé(s)` } },
{ key: 'paiements', label: 'Paiements', fn: async () => { const r = await require('./legacy-payments').syncPayments({ confirm: 'F-WINS', window: 5000, limit: 20000 }); return `${r.applied || 0} paiement(s), ${r.references || 0} alloc.` } },
{ key: 'soldes', label: 'Soldes des factures', fn: async () => { const r = await require('./legacy-payments').refreshOpenInvoices({ confirm: 'F-WINS' }); return `${r.updated || 0} soldé(s)` } },
]
// Tickets : SÉPARÉ (lourd ~70s : pull legacy + géocodage ; déjà sur scheduler 15 min + sync d'assignation live en Planification).
const TICKET_STEP = { key: 'tickets', label: 'Tickets (dispatch)', fn: async () => { const r = await require('./legacy-dispatch-sync').sync({ dryRun: false }); const n = r && (r.created ?? r.ingested ?? r.jobs_created); return n != null ? `${n} ticket(s)` : 'à jour' } }
function stepsFor (scope) { return scope === 'tickets' ? [TICKET_STEP] : scope === 'all' ? [...CORE_STEPS, TICKET_STEP] : CORE_STEPS }
let _run = { running: false, scope: null, steps: [], started_at: null, finished_at: null }
async function runManual ({ confirm, scope = 'core' }) {
if (confirm !== 'F-WINS') return { error: "confirm:'F-WINS' requis" }
if (_run.running) return { already_running: true }
const defs = stepsFor(scope)
_run = { running: true, scope, started_at: new Date().toISOString(), finished_at: null, steps: defs.map(s => ({ key: s.key, label: s.label, status: 'pending', detail: null })) }
;(async () => {
for (let i = 0; i < defs.length; i++) {
_run.steps[i].status = 'running'
try { _run.steps[i].detail = await defs[i].fn(); _run.steps[i].status = 'done' }
catch (e) { _run.steps[i].status = 'error'; _run.steps[i].detail = String(e.message).slice(0, 160); log('sync run step ' + defs[i].key + ' err: ' + e.message) }
}
_run.running = false; _run.finished_at = new Date().toISOString(); _cacheAt = 0 // force /sync/status frais
})()
return { started: true, scope, steps: _run.steps.length }
}
function runStatus () {
const done = _run.steps.filter(s => s.status === 'done' || s.status === 'error').length
const ms = _run.started_at ? ((_run.finished_at ? Date.parse(_run.finished_at) : Date.now()) - Date.parse(_run.started_at)) : 0
return { ..._run, total: _run.steps.length, done, elapsed_s: Math.round(ms / 1000), note: 'Factures & services : créés au cycle horaire (cron :15).' }
}
async function handle (req, res, method, p, url) {
if (p === '/sync/run' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await runManual({ confirm: b.confirm, scope: b.scope })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/sync/run-status' && method === 'GET') return json(res, 200, runStatus())
if (p === '/sync/status' && method === 'GET') {
try { const fresh = url && url.searchParams.get('fresh') === '1'; return json(res, 200, await status({ fresh })) }
catch (e) { log('sync-orchestrator status error:', e.message); return json(res, 500, { error: e.message }) }
}
if (p === '/sync/dag' && method === 'GET') return json(res, 200, { order: topoOrder(), dag: MODULES })
return json(res, 404, { error: 'route sync inconnue' })
}
module.exports = { handle, status, topoOrder, MODULES }

View File

@ -126,39 +126,9 @@ Règles dates:
- Heures au format 24h (HH:MM)`
}
// Analyse SMS d'absence tech → routé par tâche 'roster' via lib/ai.js (repli Gemini auto).
function llmRequest (messages) {
return new Promise((resolve, reject) => {
const baseUrl = cfg.AI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai/'
const url = new URL(baseUrl + 'chat/completions')
const isHttps = url.protocol === 'https:'
const client = isHttps ? require('https') : require('http')
const payload = JSON.stringify({
model: cfg.AI_MODEL || 'gemini-2.5-flash',
messages,
temperature: 0.1,
max_tokens: 1024,
})
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
if (cfg.AI_API_KEY) headers['Authorization'] = 'Bearer ' + cfg.AI_API_KEY
const req = client.request({
hostname: url.hostname, port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search, method: 'POST', headers, timeout: 15000,
}, (res) => {
let body = ''
res.on('data', c => body += c)
res.on('end', () => {
try {
const data = JSON.parse(body)
const content = data.choices?.[0]?.message?.content || ''
resolve(content)
} catch { reject(new Error('LLM parse error: ' + body.substring(0, 200))) }
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('LLM timeout')) })
req.write(payload)
req.end()
})
return require('./ai').chat({ task: 'roster', messages, maxTokens: 1024, temperature: 0.1 })
}
async function parseWithLlm (text) {

View File

@ -0,0 +1,627 @@
'use strict'
// ── Collaboration légère sur un document ERPNext (ticket Issue, etc.) ───────
// Objectif : intervenir VITE (indice, note, mention d'un collègue) SANS toucher l'assignation (_assign intact).
// Stocké comme Comment ERPNext (visible aussi dans Desk) ; mention = notification courriel au collègue. UI = wizard épuré côté OPS.
const cfg = require('./config')
const { log, json, parseBody, lookupCustomersByPhone, lookupCustomersByEmail, readJsonFile } = require('./helpers')
const erp = require('./erp')
// Mots « problème » (jamais un nom → toujours retirés) vs mots « rue » (retirés SEULEMENT en contexte adresse,
// pour ne pas amputer un nom comme « Saint-Pierre »).
const PROBLEM = /\b(probl[eè]me|panne|wi-?fi|internet|t[ée]l[ée]vision|t[ée]l[ée]phone|t[ée]l[ée]|lent[e]?|ralenti|coup[ée]+|d[ée]connect|bug|app|appartement)\b/gi
const STREET = /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|st|ste|saint|sainte|qc|no)\b/gi
// Normalise pour le fuzzy : minuscules + sans accents + sans séparateurs (« Louis-Paul » = « Louis-paul » = « LouisPaul »).
const normName = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')
// Recherche client UNIFIÉE depuis un texte libre : téléphone (10 ch.), no civique (→ adresse via Service Location),
// et/ou nom (→ Customer). Insensible à la casse/accents (unaccent). Renvoie des candidats classés pour l'autosuggest.
async function searchCustomers (q) {
const s = String(q || '').trim()
if (s.length < 2) return []
const nums = s.match(/\d+/g) || []
const phone = nums.find(n => n.length >= 10)
const civic = nums.find(n => n.length >= 1 && n.length <= 6 && n !== phone)
let text = s.replace(/\d+/g, ' ').replace(PROBLEM, ' ')
if (civic) text = text.replace(STREET, ' ') // contexte adresse → on retire « rue/st/… » pour isoler le nom ; sinon on garde (« Saint-Pierre »)
text = text.replace(/[^\p{L}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim()
const found = new Map() // name -> { matched, address? }
// 1) Téléphone (REST multi-champs)
if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } }
let p = null
try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
// 2) No civique → adresse (Service Location → client)
if (p && civic && found.size < 8) {
try {
const street = text.split(' ').filter(w => w.length >= 3).slice(0, 2)
const cond = street.length ? ' AND unaccent(lower(coalesce(sl.address_line,\'\'))) LIKE unaccent(lower($2))' : ''
const r = await p.query(
`SELECT DISTINCT sl.name, sl.customer, sl.address_line, sl.city FROM "tabService Location" sl
WHERE sl.customer IS NOT NULL AND sl.address_line ILIKE $1 ${cond} ORDER BY sl.address_line LIMIT 8`,
street.length ? ['%' + civic + '%', '%' + street.join('%') + '%'] : ['%' + civic + '%'])
for (const row of r.rows) if (row.customer && !found.has(row.customer)) found.set(row.customer, { matched: 'adresse', address: [row.address_line, row.city].filter(Boolean).join(', '), service_location: row.name }) // lie la BONNE adresse de service (pas la facturation)
} catch (e) { log('searchCustomers addr: ' + e.message) }
}
// 3) Nom (Customer) — FUZZY : normalisé (sans séparateurs/accents/casse) + similarité trigramme (score façon Google, tolère typos).
if (p && found.size < 8) {
const qn = normName(text)
if (qn.length >= 3) {
const NORM = "regexp_replace(unaccent(lower(customer_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
// word_similarity($q, nom) = meilleure sous-chaîne → « bourdn » matche « andrebourdon ». Seuil explicite (pas de GUC).
// On INCLUT les clients désactivés (anciens / réactivation) mais on classe les ACTIFS d'abord (+ badge « inactif » côté UI).
rows = (await p.query(
`SELECT name, customer_name, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 8`, [qn])).rows
} catch (e) {
// Repli si pg_trgm indisponible : sous-chaîne normalisée seule.
try { rows = (await p.query(`SELECT name, customer_name FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY coalesce(disabled,0) ASC, customer_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchCustomers name: ' + e2.message) }
}
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
}
}
if (!found.size) return []
// Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent).
const names = [...found.keys()].slice(0, 8)
let rows = []
if (p) { try { rows = (await p.query('SELECT name, customer_name, email_id, mobile_no, cell_phone, tel_home, tel_office, territory, disabled FROM "tabCustomer" WHERE name = ANY($1)', [names])).rows } catch (e) { log('searchCustomers enrich: ' + e.message) } }
return names.map(n => {
const r = rows.find(x => x.name === n) || {}; const f = found.get(n)
const mobile = r.mobile_no || r.cell_phone || ''
return {
name: n, customer_name: r.customer_name || n, email: r.email_id || '',
mobile, phone: mobile || r.tel_home || r.tel_office || '', can_sms: !!mobile,
territory: r.territory || '', address: f.address || '', matched: f.matched, inactive: !!r.disabled,
service_location: f.service_location || null,
}
})
}
// Recherche TECHNICIEN par nom (fuzzy, comme les clients) → pour texter un tech par la voix (« texto au technicien Marc »).
async function searchTechnicians (q) {
let text = String(q || '').replace(/\b(technicien|technicienne|tech)\b/gi, ' ').replace(/\s+/g, ' ').trim()
const qn = normName(text); if (qn.length < 2) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
const NORM = "regexp_replace(unaccent(lower(full_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
rows = (await p.query(`SELECT name, full_name, phone, word_similarity($1, ${NORM}) AS score FROM "tabDispatch Technician"
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
} catch (e) { try { rows = (await p.query(`SELECT name, full_name, phone FROM "tabDispatch Technician" WHERE ${NORM} LIKE '%'||$1||'%' LIMIT 6`, [qn])).rows } catch (e2) { log('searchTechnicians: ' + e2.message) } }
return rows.map(r => { const ph = String(r.phone || '').replace(/\D/g, ''); return { name: r.name, customer_name: r.full_name, phone: ph, can_sms: ph.length >= 10, kind: 'technicien' } })
}
// Destinataire = clients techniciens (pour les SMS/courriels orchestrés). Techs d'abord (contexte « texto au tech »).
async function searchRecipients (q) {
const [techs, custs] = await Promise.all([searchTechnicians(q), searchCustomers(q)])
const out = []
for (const t of techs) out.push(t)
for (const c of custs) out.push({ ...c, kind: c.kind || 'client' })
return out.slice(0, 6)
}
// Liste les adresses de service d'un compte (un compte entreprise en a plusieurs) → pour choisir la bonne.
// Adresses de service d'un compte, TRIÉES : celles avec un service ACTIF d'abord, puis par
// service actif le plus RÉCENT (start_date), puis création. → la 1re ligne = l'adresse à pré-sélectionner.
async function serviceLocations (customer) {
if (!customer) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
try {
const r = await p.query(`
SELECT sl.name, sl.address_line, sl.city, sl.status,
MAX(CASE WHEN ss.status='Actif' THEN 1 ELSE 0 END) AS has_active,
MAX(ss.start_date) FILTER (WHERE ss.status='Actif') AS last_active
FROM "tabService Location" sl
LEFT JOIN "tabService Subscription" ss ON ss.service_location = sl.name AND ss.customer = sl.customer
WHERE sl.customer = $1
GROUP BY sl.name, sl.address_line, sl.city, sl.status, sl.creation
ORDER BY has_active DESC, last_active DESC NULLS LAST, sl.creation DESC
LIMIT 50`, [customer])
return r.rows.map(x => ({ name: x.name, address: [x.address_line, x.city].filter(Boolean).join(', '), status: x.status || '', active: !!Number(x.has_active) }))
} catch (e) { log('serviceLocations: ' + e.message); return [] }
}
// Adresse de service par DÉFAUT = la plus récente ACTIVE (1re ligne de serviceLocations).
async function defaultServiceLocation (customer) {
const locs = await serviceLocations(customer)
return locs.length ? locs[0].name : null
}
// ── STATUT DU SERVICE (lecture seule, pendant « lecture » du Do Stuff de F) ─────────────
// Qualité du signal optique GPON (Rx power, dBm) — mêmes seuils que useDeviceStatus.signalQuality.
function signalFrom (rx) {
if (rx == null || isNaN(rx)) return null
if (rx > -8) return 'excellent'; if (rx > -20) return 'bon'; if (rx > -25) return 'faible'; return 'critique'
}
// Combine TR-069 (canal de gestion) + OLT SNMP (fibre, AUTORITAIRE) → statut définitif.
// Miroir serveur de useDeviceStatus.combinedStatus : l'OLT prime, TR-069 = repli si pas de donnée OLT.
function combineDeviceStatus (summary, onu) {
const li = summary && summary.lastInform ? new Date(summary.lastInform).getTime() : null
const tr069 = li ? (Date.now() - li) < 15 * 60 * 1000 : null
const oltOnline = onu ? (onu.status === 'online') : null
let online, source, detail
if (oltOnline === true) { online = true; source = tr069 === true ? 'both' : 'olt'; detail = tr069 === true ? 'TR-069 + Fibre OK' : 'Fibre OK · TR-069 inactif' }
else if (oltOnline === false) { online = false; source = 'olt'; detail = tr069 === true ? 'Fibre coupée · TR-069 résiduel' : 'Fibre hors ligne' }
else if (tr069 === true) { online = true; source = 'tr069'; detail = 'TR-069 actif · fibre non vérifiée' }
else if (tr069 === false) { online = false; source = 'tr069'; detail = 'TR-069 inactif · fibre non vérifiée' }
else { online = null; source = 'unknown'; detail = 'Aucune donnée' }
const rx = onu && onu.rxPower != null ? Number(onu.rxPower) : (summary && summary.rxPower != null ? Number(summary.rxPower) : null)
return {
online, source, detail,
label: online === true ? 'En ligne' : online === false ? 'Hors ligne' : 'Inconnu',
rxPower: rx != null && !isNaN(rx) ? rx : null, signal: signalFrom(rx),
lastInform: (summary && summary.lastInform) || null,
minutesAgo: li ? Math.floor((Date.now() - li) / 60000) : null,
wifiClients: summary && summary.wifi ? (summary.wifi.totalClients || 0) : null,
hostsCount: summary && summary.hostsCount != null ? summary.hostsCount : null,
uptime: (summary && summary.uptime) || null,
ip: (summary && summary.ip) || null,
model: summary ? [summary.manufacturer, summary.model].filter(Boolean).join(' ').trim() : '',
offlineCause: (onu && onu.lastOfflineCause) || null,
oltName: (onu && onu.oltName) || null, oltPort: onu && onu.port != null ? onu.port : null,
}
}
// Statut « service + modem » par IDENTIFIANT LIBRE (adresse | nom | téléphone | courriel).
// Même paradigme que la création de ticket en langage naturel, mais en LECTURE : résout le compte →
// adresse de service active la plus récente → équipement(s) → statut TR-069 + OLT (fibre). Aucune dépendance à F.
async function serviceStatus ({ q, customer, location } = {}) {
const devices = require('./devices')
let olt = null; try { olt = require('./olt-snmp') } catch (e) { /* SNMP indispo */ }
// 1) Résolution du compte (courriel → lookupCustomersByEmail ; sinon nom/téléphone/adresse → searchCustomers)
let cust = customer || null, matches = []
if (!cust) {
const s = String(q || '').trim()
if (!s) return { ok: false, error: 'identifiant requis (adresse, nom, téléphone ou courriel)' }
if (/.+@.+\..+/.test(s)) {
try { matches = (await lookupCustomersByEmail(s, 6)).map(m => ({ name: m.name, customer_name: m.customer_name || m.name, email: m.email || s, phone: m.phone || '', matched: 'courriel' })) } catch (e) { matches = [] }
} else {
try { matches = await searchCustomers(s) } catch (e) { matches = [] }
}
matches = (matches || []).filter(m => m && m.name)
if (!matches.length) return { ok: true, found: false, matches: [], query: s }
if (matches.length > 1) return { ok: true, found: false, ambiguous: true, matches: matches.slice(0, 6), query: s }
cust = matches[0].name
}
// En-tête client (depuis le match, sinon fiche ERPNext si le compte est passé explicitement)
let head = matches[0] || null
if (!head && cust) { try { const c = await erp.get('Customer', cust); if (c) head = { name: cust, customer_name: c.customer_name || cust, email: c.email_id || '', phone: c.mobile_no || c.cell_phone || '' } } catch (e) { /* */ } }
// 2) Adresse de service (fournie, sinon active la plus récente)
const locs = await serviceLocations(cust)
const loc = location || (locs.length ? locs[0].name : null)
const locInfo = locs.find(l => l.name === loc) || (loc ? { name: loc, address: loc } : null)
// 3) Équipement(s) à cette adresse (repli : tout l'équipement du compte si rien à l'adresse)
const EQF = ['name', 'serial_number', 'mac_address', 'ip_address', 'brand', 'model', 'status', 'service_location', 'olt_ip']
let equip = []
try {
const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
equip = await erp.list('Service Equipment', { filters: f, fields: EQF, limit: 6 }) || []
if (!equip.length && loc) equip = await erp.list('Service Equipment', { filters: [['customer', '=', cust]], fields: EQF, limit: 6 }) || []
} catch (e) { log('serviceStatus equip ' + cust + ': ' + e.message) }
// 4) Statut par équipement (TR-069 via cache/ACS + OLT SNMP autoritaire)
const devs = []
for (const eq of equip.slice(0, 4)) {
const serial = eq.serial_number || null
let summary = null
if (serial) {
try { const c = devices.getCached(serial); summary = (c && c.summary && c.summary.lastInform) ? c.summary : await devices.fetchDeviceDetails(serial) } catch (e) { /* */ }
}
let onu = null; if (serial && olt) { try { onu = olt.getOnuBySerial(serial) } catch (e) { /* */ } }
devs.push({ serial, mac: eq.mac_address || null, olt: eq.olt_ip || null, brand: eq.brand || '', equipmentStatus: eq.status || '', service_location: eq.service_location || null, ...combineDeviceStatus(summary, onu), resolved: !!(summary || onu) })
}
// 5) Abonnement(s) service à l'adresse (moitié « service » : actif/suspendu + forfait)
let subs = []
try {
const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
subs = await erp.list('Service Subscription', { filters: f, fields: ['name', 'plan_name', 'status', 'start_date'], orderBy: 'start_date desc', limit: 8 }) || []
} catch (e) { /* doctype/champ absent → on ignore */ }
// Résumé global = le « meilleur » équipement (en ligne d'abord)
const best = devs.slice().sort((a, b) => (b.online === true ? 1 : 0) - (a.online === true ? 1 : 0))[0] || null
return {
ok: true, found: true,
customer: { name: cust, customer_name: (head && head.customer_name) || cust, email: (head && head.email) || '', phone: (head && head.phone) || '' },
location: locInfo, locations: locs,
subscriptions: subs.map(s => ({ name: s.name, plan: s.plan_name || '', status: s.status || '', start: s.start_date || null })),
devices: devs,
summary: best
? { online: best.online, label: best.label, detail: best.detail, source: best.source, signal: best.signal, rxPower: best.rxPower }
: { online: null, label: 'Aucun équipement', detail: equip.length ? 'équipement sans numéro de série' : 'aucun équipement lié à ce compte', source: 'none' },
}
}
const OPS_URL = () => (cfg.OPS_PUBLIC_URL || 'https://erp.gigafibre.ca/ops').replace(/\/$/, '')
const ERP_URL = () => (cfg.ERP_PUBLIC_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '')
const shortName = (e) => String(e || '').split('@')[0]
// Activité récente (commentaires/notes) d'un document — pour afficher le fil dans le wizard.
async function activity (doctype, name) {
const doc = await erp.get(doctype, name)
let comments = []
try {
comments = await erp.list('Comment', {
filters: [['reference_doctype', '=', doctype], ['reference_name', '=', name], ['comment_type', 'in', ['Comment', 'Info']]],
fields: ['content', 'comment_email', 'comment_by', 'creation', 'comment_type'], orderBy: 'creation desc', limit: 25,
})
} catch (e) { log('collab activity ' + doctype + '/' + name + ': ' + e.message) }
return { doc: doc || null, comments }
}
// Ajoute un commentaire (note/indice) + notifie les collègues mentionnés. NE TOUCHE PAS _assign / l'assignation.
async function addComment (doctype, name, { text, mentions = [], agent } = {}) {
const content = String(text || '').trim()
if (!content) return { ok: false, error: 'texte requis' }
const ment = (Array.isArray(mentions) ? mentions : []).filter(e => /.+@.+\..+/.test(e))
const tag = ment.length ? ('<div data-mentions="' + ment.join(',') + '">↪ ' + ment.map(shortName).join(', ') + '</div>') : ''
const r = await erp.create('Comment', {
comment_type: 'Comment', reference_doctype: doctype, reference_name: name,
content: tag + '<div>' + content.replace(/</g, '&lt;').replace(/\n/g, '<br>') + '</div>' + (agent ? '<div style="color:#94a3b8">— ' + shortName(agent) + ' (OPS)</div>' : ''),
comment_email: agent || 'ops@targo.ca', comment_by: agent || 'OPS',
})
if (!r.ok) return { ok: false, error: r.error }
// Notifier les mentionnés (courriel) — sans rien changer à l'assignation.
if (ment.length) {
const gmail = require('./gmail')
const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name
const link = ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)`
for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } }
}
return { ok: true, mentioned: ment }
}
// Crée un ticket (ERPNext Issue) autonome — depuis le FAB / composer NL. customer optionnel ; queue = notifie l'équipe.
async function createTicket ({ title, category, priority, description, customer, customer_name, queue, status, due_date, service_location, agent } = {}) {
const t = String(title || '').trim()
if (!t) return { ok: false, error: 'titre requis' }
const cat = String(category || '').trim()
const st = (status === 'On Hold' || status === 'pending' || status === 'En attente') ? 'On Hold' : 'Open' // « pending » = suspendu jusqu'à la date de rappel
const data = {
subject: (cat ? '[' + cat + '] ' : '') + t.slice(0, 130), status: st, priority: priority || 'Medium',
description: (description ? String(description) + '\n\n' : '') + (agent ? '— créé par ' + shortName(agent) + ' (OPS)' : ''),
}
if (customer) data.customer = customer
// Adresse de service : celle fournie, SINON l'adresse active la plus récente du compte (auto).
if (!service_location && customer) { try { service_location = await defaultServiceLocation(customer) } catch (e) {} }
if (service_location) data.custom_service_location = service_location // lie le ticket à la BONNE adresse de service
if (due_date && /^\d{4}-\d{2}-\d{2}/.test(due_date)) data.custom_reminder_date = due_date.slice(0, 10) // rappel / échéance
const r = await erp.create('Issue', data)
if (!r.ok) return { ok: false, error: r.error }
// Router vers l'équipe : notifier les membres de la file (ex. Supports) — comme un courriel entrant.
if (queue) {
const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e))
if (members.length) {
const link = ERP_URL() + '/app/issue/' + encodeURIComponent(r.name)
const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS`
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) }
}
}
return { ok: true, name: r.name, subject: data.subject }
}
// ── ORCHESTRATEUR EN LANGAGE NATUREL / DICTÉE ──────────────────────────────
// « Crée un ticket de facture non payée pour Louispaul, envoie un texto à ce tech concernant telle adresse. »
// L'IA décompose en ACTIONS (sans inventer d'entités) ; le code RÉSOUT les clients (searchCustomers) ; on renvoie un
// PLAN à confirmer (les actions sortantes = SMS/ticket ne s'exécutent qu'après validation humaine). Puis /run exécute.
const ORCH_SYS = `Tu transformes la commande d'un agent (fournisseur Internet TARGO) en JSON d'ACTIONS. Réponds UNIQUEMENT par {"actions":[...]} (aucun texte autour).
Types autorisés :
- {"type":"create_ticket","customer_query":"<nom/adresse/téléphone du client tel que dicté, ou null>","subject":"<sujet court>","category":"Support|Facturation|Installation|Télévision|Téléphonie|Commercial|Autre","priority":"Low|Medium|High|Urgent","status":"Open|On Hold","due_date":"YYYY-MM-DD ou null"}
- {"type":"send_sms","to_query":"<nom/adresse/téléphone du destinataire tel que dicté>","message":"<le texto>"}
- {"type":"send_email","to_query":"<...>","subject":"<...>","body":"<...>"}
- {"type":"note","text":"<note interne>"}
- {"type":"diagnose","customer_query":"<nom/adresse/téléphone du client tel que dicté>"} // QUESTION ou VÉRIFICATION d'état (lecture) : connexion, service, internet, modem, signal, « est-il en ligne ? », « a-t-il une panne ? »
Règles : status="On Hold" si on ATTEND quelque chose (paiement, pièce). Ne résous PAS les entités, garde le terme dicté dans *_query. N'invente jamais de numéro, montant ni adresse. Déduis la catégorie par le sens (« facture »Facturation, « wifi/panne »Support, « installation »Installation).
IMPORTANT si la commande est une QUESTION ou une demande de VÉRIFICATION d'état (pas une action à exécuter), p.ex. « vérifie/vérifier la connexion|le service|internet de X », « est-ce que X est en ligne », « X a-t-il une panne », « statut de X » utilise UNIQUEMENT {"type":"diagnose"} (JAMAIS create_ticket). Une vérification ne crée rien.`
async function orchestratePlan (text) {
if (!String(text || '').trim()) return { ok: false, error: 'commande vide' }
let out
try { out = await require('./ai').chat({ task: 'nl', maxTokens: 800, temperature: 0.1, messages: [{ role: 'system', content: ORCH_SYS }, { role: 'user', content: String(text) }] }) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
let parsed; try { parsed = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
const actions = Array.isArray(parsed.actions) ? parsed.actions.slice(0, 8) : []
for (const a of actions) {
// Intent LECTURE : on exécute le diagnostic tout de suite (sûr) — serviceStatus gère seul
// 1 client → diagnostic ; plusieurs → {ambiguous, matches} (l'agent précise l'adresse) ; aucun → introuvable.
if (a.type === 'diagnose') {
try { a.diagnostic = await serviceStatus({ q: a.customer_query || '' }) }
catch (e) { a.diagnostic = { ok: false, error: e.message } }
continue
}
const q = a.customer_query || a.to_query
if (!q) continue
if (/^\+?\d[\d\s().-]{6,}$/.test(String(q))) { a.phone = String(q).replace(/\D/g, ''); continue } // numéro brut dicté
try {
// ticket = pour un CLIENT ; sms/courriel = destinataire CLIENT TECHNICIEN.
const m = (a.type === 'create_ticket') ? await searchCustomers(q) : await searchRecipients(q)
a.candidates = m.slice(0, 4); a.resolved = m[0] || null
} catch (e) { a.candidates = []; a.resolved = null }
}
return { ok: true, actions, transcript: String(text) }
}
async function orchestrateRun (actions, agent) {
const conv = require('./conversation')
const results = []
for (const a of (Array.isArray(actions) ? actions : [])) {
try {
if (a.type === 'create_ticket') {
const r = await createTicket({ title: a.subject || 'Demande', category: a.category, priority: a.priority, status: a.status, due_date: a.due_date, customer: a.resolved && a.resolved.name, customer_name: a.resolved && a.resolved.customer_name, service_location: a.service_location || (a.resolved && a.resolved.service_location), queue: a.queue, agent })
results.push({ type: 'create_ticket', ok: r.ok, name: r.name, label: r.subject, error: r.error })
} else if (a.type === 'send_sms') {
const phone = a.phone || (a.resolved && a.resolved.phone)
if (!phone) { results.push({ type: 'send_sms', ok: false, error: 'destinataire sans numéro' }); continue }
const r = await conv.sendSms({ phone, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name, message: a.message })
results.push({ type: 'send_sms', ok: r.ok, to: (a.resolved && a.resolved.customer_name) || phone, via: r.via, error: r.error })
} else if (a.type === 'send_email') {
const to = (a.resolved && a.resolved.email) || a.to
if (!to) { results.push({ type: 'send_email', ok: false, error: 'destinataire sans courriel' }); continue }
const r = await conv.sendNewEmail({ to, subject: a.subject, body: a.body, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name })
results.push({ type: 'send_email', ok: r.ok, to, error: r.error })
} else if (a.type === 'note') {
results.push({ type: 'note', ok: true, text: a.text })
}
} catch (e) { results.push({ type: a.type, ok: false, error: e.message }) }
}
return { ok: true, results }
}
// ── Rapport : comptes avec TOTAL RÉCURRENT MENSUEL NÉGATIF ──────────────────
// Un abonnement récurrent ne peut pas être négatif (rabais > frais). F est autoritaire et
// correct ; ce rapport débusque la DÉRIVE côté ERPNext/OPS (rabais ajoutés à la main / artefacts
// d'import). Scan des Service Subscription ACTIF mensuelles, agrégées par (client, adresse).
// Calcul en arrière-plan + cache 1 h (la requête HTTP ne bloque pas ; la page sonde jusqu'à prêt).
let _negBill = { ts: 0, data: null, computing: false, error: null }
async function computeNegativeBilling () {
const groups = new Map() // clé client|adresse → { customer, service_location, total, charges, rebate, rebates[] }
const custTotals = new Map() // client → total mensuel ACTIF toutes adresses (pour distinguer un vrai négatif d'un artefact de regroupement par adresse)
// « Actif/Inactif » du client = statut de compte GLOBAL F (account.status), récupéré après le scan (cf. fAccountStatuses) — autorité.
const TODAY = new Date().toISOString().slice(0, 10)
let start = 0, scanned = 0; const PAGE = 2000
for (let i = 0; i < 60; i++) { // borne dure 120k
const rows = await erp.list('Service Subscription', {
filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']],
fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id', 'end_date'],
limit: PAGE, start,
}) || []
if (!rows.length) break
for (const r of rows) {
const price = Number(r.monthly_price || 0)
const cust = r.customer || '?'
const key = cust + '|' + (r.service_location || '')
const legacy = Number(r.legacy_service_id || 0)
let g = groups.get(key)
if (!g) { g = { customer: cust, service_location: r.service_location || '', total: 0, charges: 0, rebate: 0, rebates: [], manualRebates: 0, expiredActive: 0 }; groups.set(key, g) }
g.total += price
custTotals.set(cust, (custTotals.get(cust) || 0) + price)
// signal de fin de service non respectée (date de fin passée mais encore Actif)
if (r.end_date && r.end_date < TODAY) g.expiredActive++
if (price < 0) {
g.rebate += price
if (legacy <= 0) g.manualRebates++ // rabais SANS lien F = ajouté dans ERPNext
g.rebates.push({ name: r.name, sku: r.product_sku || '', plan: r.plan_name || '', price, legacy, source: legacy > 0 ? ('F #' + legacy) : 'ajouté OPS', end_date: r.end_date || null })
} else g.charges += price
}
scanned += rows.length
if (rows.length < PAGE) break
start += PAGE
}
const neg = [...groups.values()].filter(g => g.total < -0.005).sort((a, b) => a.total - b.total)
// Pour chaque compte négatif : examiner TOUS les abos (actif+inactif) → une CHARGE suspendue/annulée
// qui rendrait le total positif = désync de statut F→ERPNext (cause réelle vue sur Schink : FTTH500 Suspendu
// dans ERPNext mais actif dans F). Peu de comptes (~quelques dizaines) → fetch par compte économique.
const allByCust = new Map()
for (const g of neg) {
if (!allByCust.has(g.customer)) {
try { allByCust.set(g.customer, await erp.list('Service Subscription', { filters: [['customer', '=', g.customer]], fields: ['name', 'status', 'monthly_price', 'plan_name', 'service_location', 'legacy_service_id', 'billing_cycle'], limit: 100 }) || []) } catch (e) { allByCust.set(g.customer, []) }
}
const inact = (allByCust.get(g.customer) || []).filter(s => (s.service_location || '') === g.service_location && s.billing_cycle !== 'Annuel' && Number(s.monthly_price || 0) > 0 && s.status !== 'Actif')
g.inactiveChargeSum = inact.reduce((s, x) => s + Number(x.monthly_price || 0), 0)
g.inactiveCharges = inact.map(s => ({ plan: s.plan_name || '', price: Number(s.monthly_price || 0), status: s.status || '', legacy: Number(s.legacy_service_id || 0) }))
}
const causeOf = (g, ct) => {
if (ct >= -0.005) return 'artifact' // rabais/frais sur adresses ≠ → compte OK
if (g.manualRebates > 0) return 'manual' // rabais ajouté hors F
if (g.total + (g.inactiveChargeSum || 0) >= -0.005) return 'suspended_charge' // une charge suspendue/annulée explique le négatif
if (g.expiredActive > 0) return 'expired'
return 'stacked_rebates' // vrais rabais F qui dépassent (à vérifier vs F)
}
// Noms clients + ID de compte F (batch 'in' par 100)
const ids = [...new Set(neg.map(g => g.customer).filter(Boolean))]
const names = {}, acctIdOf = {}
for (let i = 0; i < ids.length; i += 100) {
try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name', 'legacy_account_id'], limit: 100 }) || []; for (const c of cs) { names[c.name] = c.customer_name; acctIdOf[c.name] = Number(c.legacy_account_id || 0) } } catch (e) { /* nom optionnel */ }
}
// Statut de compte GLOBAL F (account.status : 1=Actif, 2=Suspendu, 3/4/5=Résilié) — l'autorité (≠ heuristique forfait, ≠ flag disabled).
let acctStatus = {}; const ls = require('./legacy-sync')
try { acctStatus = await ls.fAccountStatuses(Object.values(acctIdOf)) } catch (e) { log('negative-billing fAccountStatuses: ' + e.message) }
const LABEL = ls.F_ACCT_STATUS_LABEL || {}
const round = n => Math.round(n * 100) / 100
return {
count: neg.length, scanned, generated: Date.now(),
rows: neg.slice(0, 2000).map(g => { const ct = round(custTotals.get(g.customer) || 0); const fst = acctStatus[acctIdOf[g.customer]]; return ({ customer: g.customer, customer_name: names[g.customer] || g.customer, service_location: g.service_location, total: round(g.total), charges: round(g.charges), rebate: round(g.rebate), customer_total: ct, account_status: fst != null ? fst : null, account_status_label: LABEL[fst] || (fst != null ? 'Inconnu' : '—'), customer_active: fst === 1, manualRebates: g.manualRebates, expiredActive: g.expiredActive, inactiveChargeSum: round(g.inactiveChargeSum || 0), inactiveCharges: g.inactiveCharges || [], cause: causeOf(g, ct), rebates: g.rebates }) }),
}
}
async function negativeBilling (refresh) {
const fresh = (Date.now() - _negBill.ts) < 3600000
if (!refresh && _negBill.data && fresh) return { ready: true, cached: true, ..._negBill.data }
if (_negBill.computing) return { ready: false, computing: true }
if (!refresh && _negBill.error && fresh) return { ready: false, error: _negBill.error }
_negBill.computing = true
computeNegativeBilling()
.then(d => { _negBill = { ts: Date.now(), data: d, computing: false, error: null }; log(`negative-billing: ${d.count} comptes négatifs / ${d.scanned} abos scannés`) })
.catch(e => { _negBill = { ts: Date.now(), data: null, computing: false, error: e.message }; log('negative-billing ERROR: ' + e.message) })
return { ready: false, computing: true, started: true }
}
// ── Rapport : COMPTES RÉSILIÉS qui gardent des SERVICES ACTIFS (à nettoyer au niveau service) ──
// F ne désactive pas les services récurrents à la résiliation → charges + crédits restent status=1.
// Liste de ménage (les employés à internet fourni sont des exceptions à garder). Calcul en arrière-plan + cache 1 h.
let _termActive = { ts: 0, data: null, computing: false, error: null }
async function computeTerminatedActive () {
const ls = require('./legacy-sync')
const resilieIds = await ls.fResiliatedAccountIds()
if (!resilieIds.length) return { count: 0, services: 0, generated: Date.now(), rows: [] }
const pgp = require('./address-db').pool()
const custAcct = new Map() // customer ERPNext → legacy_account_id (résilié)
if (pgp) {
for (let i = 0; i < resilieIds.length; i += 5000) {
try { const r = await pgp.query('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [resilieIds.slice(i, i + 5000)]); for (const x of r.rows) custAcct.set(x.name, Number(x.legacy_account_id)) } catch (e) { log('terminated-active PG: ' + e.message) }
}
}
if (!custAcct.size) return { count: 0, services: 0, generated: Date.now(), rows: [] }
const byCust = new Map()
let start = 0, scanned = 0; const PAGE = 2000
for (let i = 0; i < 60; i++) {
const rows = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']], fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id'], limit: PAGE, start }) || []
if (!rows.length) break
for (const r of rows) {
if (!custAcct.has(r.customer)) continue
let g = byCust.get(r.customer); if (!g) { g = { customer: r.customer, total: 0, services: [] }; byCust.set(r.customer, g) }
const price = Number(r.monthly_price || 0)
g.total += price
g.services.push({ legacy: Number(r.legacy_service_id || 0), sku: r.product_sku || '', plan: r.plan_name || '', price, location: r.service_location || '' })
}
scanned += rows.length
if (rows.length < PAGE) break
start += PAGE
}
const ids = [...byCust.keys()]; const names = {}
for (let i = 0; i < ids.length; i += 100) { try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name'], limit: 100 }) || []; for (const c of cs) names[c.name] = c.customer_name } catch (e) { /* */ } }
const round = n => Math.round(n * 100) / 100
const rows = [...byCust.values()].map(g => ({
customer: g.customer, customer_name: names[g.customer] || g.customer, account_id: custAcct.get(g.customer),
active_services: g.services.length,
charges: round(g.services.filter(s => s.price > 0).reduce((a, s) => a + s.price, 0)),
credits: round(g.services.filter(s => s.price < 0).reduce((a, s) => a + s.price, 0)),
total: round(g.total),
services: g.services.map(s => ({ ...s, price: round(s.price) })),
})).sort((a, b) => b.active_services - a.active_services || b.total - a.total)
return { count: rows.length, services: rows.reduce((a, r) => a + r.active_services, 0), scanned, generated: Date.now(), rows: rows.slice(0, 3000) }
}
async function terminatedActive (refresh) {
const fresh = (Date.now() - _termActive.ts) < 3600000
if (!refresh && _termActive.data && fresh) return { ready: true, cached: true, ..._termActive.data }
if (_termActive.computing) return { ready: false, computing: true }
if (!refresh && _termActive.error && fresh) return { ready: false, error: _termActive.error }
_termActive.computing = true
computeTerminatedActive()
.then(d => { _termActive = { ts: Date.now(), data: d, computing: false, error: null }; log(`terminated-active: ${d.count} comptes résiliés / ${d.services} services actifs`) })
.catch(e => { _termActive = { ts: Date.now(), data: null, computing: false, error: e.message }; log('terminated-active ERROR: ' + e.message) })
return { ready: false, computing: true, started: true }
}
async function handle (req, res, method, p, url) {
// POST /collab/orchestrate {text} → PLAN d'actions (ne s'exécute pas). POST /collab/orchestrate/run {actions} → exécute (confirmé).
if (p === '/collab/orchestrate' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestratePlan(b.text || ''))
}
if (p === '/collab/orchestrate/run' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || ''))
}
// GET /collab/customer-search?q= — autosuggest client par texte libre (nom / téléphone / adresse)
if (p === '/collab/customer-search' && method === 'GET') {
return json(res, 200, { matches: await searchCustomers(url.searchParams.get('q') || '') })
}
// GET /collab/service-locations?customer= — adresses de service d'un compte (multi-adresses)
if (p === '/collab/service-locations' && method === 'GET') {
return json(res, 200, { locations: await serviceLocations(url.searchParams.get('customer') || '') })
}
// GET /collab/service-status?q=<adresse|nom|téléphone|courriel>[&customer=&location=] — statut service+modem (lecture)
if (p === '/collab/service-status' && method === 'GET') {
return json(res, 200, await serviceStatus({ q: url.searchParams.get('q') || '', customer: url.searchParams.get('customer') || '', location: url.searchParams.get('location') || '' }))
}
// GET /collab/dostuff?serial=<sn>&olt=<olt_ip> — statut LIVE du modem via le webhook n8n que F utilise (LENT ~25-30 s ; tech=3)
if (p === '/collab/dostuff' && method === 'GET') {
return json(res, 200, await dostuffStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', olt: url.searchParams.get('olt') || '' }))
}
// GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
if (p === '/collab/negative-billing' && method === 'GET') {
return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
}
// GET /collab/terminated-active[?refresh=1] — comptes RÉSILIÉS (F status 3/4/5) gardant des services ACTIFS (ménage)
if (p === '/collab/terminated-active' && method === 'GET') {
return json(res, 200, await terminatedActive(url.searchParams.get('refresh') === '1'))
}
// GET /collab/wan-rate?serial=<sn>[&olt=&slot=&port=&ontid=] — compteurs d'octets WAN de l'ONU (Raisecom/tech-2 seulement),
// tels que F les lit dans raisecom_rcmg_bandwith.php. Le CLIENT échantillonne 2× et calcule le débit (delta/sec*8).
if (p === '/collab/wan-rate' && method === 'GET') {
let olt = null; try { olt = require('./olt-snmp') } catch (e) { return json(res, 200, { available: false, reason: 'snmp_unavailable' }) }
const serial = url.searchParams.get('serial') || url.searchParams.get('sn') || ''
const opts = {}
if (url.searchParams.get('olt')) opts.oltIp = url.searchParams.get('olt')
if (url.searchParams.get('slot') != null && url.searchParams.get('slot') !== '') opts.slot = url.searchParams.get('slot')
if (url.searchParams.get('port') != null && url.searchParams.get('port') !== '') opts.port = url.searchParams.get('port')
if (url.searchParams.get('ontid') != null && url.searchParams.get('ontid') !== '') opts.ontId = url.searchParams.get('ontid')
if (url.searchParams.get('type')) opts.type = url.searchParams.get('type')
if (!serial && !opts.oltIp) return json(res, 400, { error: 'serial ou olt requis' })
try { return json(res, 200, await olt.getOnuBandwidth(serial, opts)) }
catch (e) { return json(res, 200, { available: false, reason: 'error', error: e.message }) }
}
// GET /collab/diag-link?customer=C-XXX[&name=] — lien de diagnostic libre-service (token signé 7 j) à envoyer au client
if (p === '/collab/diag-link' && method === 'GET') {
const customer = url.searchParams.get('customer') || ''
if (!customer) return json(res, 400, { error: 'customer requis' })
let name = url.searchParams.get('name') || ''
if (!name) { try { const c = await erp.get('Customer', customer); name = (c && c.customer_name) || '' } catch (e) { /* */ } }
const token = require('./magic-link').generateCustomerToken(customer, name, '', 24 * 7)
const base = (cfg.CLIENT_PUBLIC_URL || 'https://app.gigafibre.ca').replace(/\/$/, '')
return json(res, 200, { ok: true, link: base + '/diag/' + token, customer, name })
}
// POST /collab/ticket {title, category, priority, description, customer, customer_name, queue} — créer un ticket autonome
if (p === '/collab/ticket' && method === 'POST') {
const b = await parseBody(req)
const r = await createTicket({ ...b, agent: req.headers['x-authentik-email'] || b.agent || '' })
return json(res, r.ok ? 200 : 400, r)
}
// GET /collab/activity?doctype=Issue&name=ISS-0001
if (p === '/collab/activity' && method === 'GET') {
const doctype = url.searchParams.get('doctype'); const name = url.searchParams.get('name')
if (!doctype || !name) return json(res, 400, { error: 'doctype + name requis' })
return json(res, 200, await activity(doctype, name))
}
// POST /collab/comment {doctype, name, text, mentions}
if (p === '/collab/comment' && method === 'POST') {
const b = await parseBody(req)
if (!b.doctype || !b.name) return json(res, 400, { error: 'doctype + name requis' })
const agent = req.headers['x-authentik-email'] || b.agent || ''
const r = await addComment(b.doctype, b.name, { text: b.text, mentions: b.mentions, agent })
return json(res, r.ok ? 200 : 400, r)
}
return json(res, 404, { error: 'not found' })
}
// ── DoStuff (n8n) : statut LIVE d'un modem via le MÊME webhook que F (GET {sn, olt}) ──────────────────
// F appelle n8napi.targo.ca/webhook/dostuff en GET avec un body JSON → on réplique via le module https (fetch
// refuse un body en GET). LENT (~25-30 s : n8n interroge l'OLT en direct) → action À LA DEMANDE seulement.
// Réservé tech=3 (OLT reconnus par n8n) ; tech=2 (poller OLT standard) → n8n renvoie « OLT non reconnue » → repli sur notre statut.
function dostuffStatus ({ sn, olt } = {}) {
return new Promise((resolve) => {
if (!sn || !olt) return resolve({ ok: false, error: 'sn + olt requis' })
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn), olt: String(olt) })
const req = https.request('https://n8napi.targo.ca/webhook/dostuff', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 40000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
const r = Array.isArray(arr) ? arr[0] : arr
if (!r || r.ErrorCode) return resolve({ ok: false, recognized: false, error: (r && r.ErrorCode) || 'réponse vide' }) // OLT non reconnue (tech=2) → repli
const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim()
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
const online = clean(r.Status) === '1' || clean(r.InternetOn) === '1'
resolve({
ok: true, source: 'dostuff', online, label: online ? 'En ligne' : 'Hors ligne',
rxPower: num(r.RxSignal), txPower: num(r.TxSignal), signal: signalFrom(num(r.RxSignal)), distance: num(r.Distance),
uptime: clean(r.Uptime) || null, lastDown: clean(r.LastDown) || null, firmware: clean(r.Firmware) || null,
managementIp: clean(r.Management) || null, wanIp: clean(r.Internet) || null, voip: clean(r.VoIP) || null, iptv: clean(r.IPTV) || null,
profileId: clean(r.ProfileID) || null, slot: clean(r.Slot) || null, port: clean(r.Port) || null, onuId: clean(r.OnuID) || null,
})
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~40 s) — OLT lent ou modem hors ligne' }) })
req.write(body); req.end()
})
}
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus }

View File

@ -1,6 +1,7 @@
'use strict'
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
// Prefer bearer token (revocable, no password in env); fall back to Basic auth for legacy.
const authHeader = cfg.TRACCAR_TOKEN
@ -15,6 +16,24 @@ async function traccarFetch (path) {
return res.json()
}
// Variante RAW (texte) — pour GPX/KML (Accept différent, pas de parse JSON).
async function traccarRaw (path, accept) {
const res = await fetch(cfg.TRACCAR_URL + path, { headers: { Authorization: authHeader, Accept: accept } })
if (!res.ok) throw new Error(`Traccar ${res.status}: ${path}`)
return res.text()
}
// Résout l'appareil Traccar depuis ?device= OU ?tech= (technician_id puis docname Dispatch Technician).
async function deviceFor (url) {
let device = url.searchParams.get('device'); const tech = url.searchParams.get('tech')
if (!device && tech) {
let rows = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tech]], fields: ['traccar_device_id'], limit: 1 })
if (!rows.length) rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['traccar_device_id'], limit: 1 })
device = rows.length ? rows[0].traccar_device_id : null
}
return device
}
// Cache devices for 60s (they rarely change)
let _devCache = null, _devCacheTs = 0
async function getDevices () {
@ -55,6 +74,35 @@ async function handle (req, res, method, path) {
}
}
// Export GPX du parcours d'un tech sur une fenêtre (jour courant) — résout le device depuis Dispatch Technician.
if (sub === 'gpx' && method === 'GET') {
try {
const url = new URL(req.url, 'http://localhost')
const device = await deviceFor(url)
if (!device) return json(res, 404, { error: 'Aucun appareil GPS associé à ce technicien' })
const from = url.searchParams.get('from'); const to = url.searchParams.get('to')
if (!from || !to) return json(res, 400, { error: 'from + to (ISO 8601) requis' })
const gpx = await traccarRaw(`/api/positions?deviceId=${encodeURIComponent(device)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, 'application/gpx+xml')
res.writeHead(200, { 'Content-Type': 'application/gpx+xml; charset=utf-8', 'Content-Disposition': `attachment; filename="parcours-${device}-${String(from).slice(0, 10)}.gpx"` })
return res.end(gpx)
} catch (e) { log('Traccar gpx error:', e.message); return json(res, 502, { error: 'Traccar GPX indisponible' }) }
}
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → coords [lon,lat] (JSON léger) pour superposer sur la carte.
// IMPORTANT : exiger une plage EXACTE (1 jour) — sinon Traccar renvoie un volume énorme et lent.
if (sub === 'track' && method === 'GET') {
try {
const url = new URL(req.url, 'http://localhost')
const device = await deviceFor(url)
if (!device) return json(res, 404, { error: 'Aucun appareil GPS associé à ce technicien' })
const from = url.searchParams.get('from'); const to = url.searchParams.get('to')
if (!from || !to) return json(res, 400, { error: 'from + to (ISO 8601) requis' })
const pts = await traccarFetch(`/api/positions?deviceId=${encodeURIComponent(device)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
const coords = (Array.isArray(pts) ? pts : []).filter(p => p.latitude != null && p.longitude != null).map(p => [p.longitude, p.latitude])
return json(res, 200, { device, count: coords.length, coords })
} catch (e) { log('Traccar track error:', e.message); return json(res, 502, { error: 'Traccar track indisponible' }) }
}
// Proxy any other Traccar API path (fallback)
if (method === 'GET') {
try {

View File

@ -70,7 +70,7 @@ async function smsStatus (req, res) {
setImmediate(() => broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() }))
}
async function sendSmsInternal (phone, message, customer) {
async function sendSmsInternal (phone, message, customer, mediaUrl) {
// Normalize to E.164: strip non-digits, add +1 for 10-digit North American numbers
const digits = phone.replace(/\D/g, '')
const to = phone.startsWith('+') ? phone
@ -78,9 +78,10 @@ async function sendSmsInternal (phone, message, customer) {
: digits.length === 11 && digits.startsWith('1') ? '+' + digits
: '+' + digits
const twilioData = new URLSearchParams({
To: to, From: cfg.TWILIO_FROM, Body: message,
To: to, From: cfg.TWILIO_FROM, Body: message || '',
StatusCallback: cfg.HUB_PUBLIC_URL + '/webhook/twilio/sms-status',
})
if (mediaUrl) twilioData.append('MediaUrl', mediaUrl) // MMS : pièce jointe image (URL publique)
const authStr = Buffer.from(cfg.TWILIO_ACCOUNT_SID + ':' + cfg.TWILIO_AUTH_TOKEN).toString('base64')
const https = require('https')
const twilioRes = await new Promise((resolve, reject) => {

View File

@ -1,15 +1,15 @@
'use strict'
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const { log, json, parseBody, stripDataUrl } = require('./helpers')
const GEMINI_URL = () => `https://generativelanguage.googleapis.com/v1beta/models/${cfg.AI_MODEL}:generateContent?key=${cfg.AI_API_KEY}`
async function geminiVision (base64Image, prompt, schema) {
async function geminiVision (base64Image, prompt, schema, mime = 'image/jpeg') {
const resp = await fetch(GEMINI_URL(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: 'image/jpeg', data: base64Image } }] }],
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: mime, data: base64Image } }] }],
generationConfig: { temperature: 0.1, maxOutputTokens: 1024, responseMimeType: 'application/json', responseSchema: schema },
}),
})
@ -25,7 +25,7 @@ async function geminiVision (base64Image, prompt, schema) {
function extractBase64 (req, body, label) {
if (!cfg.AI_API_KEY) return { error: 'AI_API_KEY not configured', status: 500 }
if (!body.image) return { error: 'Missing image field (base64)', status: 400 }
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
const base64 = stripDataUrl(body.image)
log(`Vision ${label}: received image ${Math.round(base64.length * 3 / 4 / 1024)}KB`)
return { base64 }
}
@ -141,30 +141,68 @@ const INVOICE_SCHEMA = {
required: ['vendor', 'total'],
}
// Extraction facture réutilisable (image OU PDF). base64 SANS préfixe data:. mime = 'application/pdf' pour les PDF.
async function extractInvoice (base64, mime = 'image/jpeg') {
const parsed = await geminiVision(base64, INVOICE_PROMPT, INVOICE_SCHEMA, mime)
if (!parsed) return { vendor: null, total: null, items: [] }
for (const k of ['subtotal', 'tax_gst', 'tax_qst', 'total']) {
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0
}
if (Array.isArray(parsed.items)) {
for (const it of parsed.items) {
for (const k of ['qty', 'rate', 'amount']) {
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0
}
}
}
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`)
return parsed
}
async function handleInvoice (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'invoice')
if (check.error) return json(res, check.status, { error: check.error })
try {
const parsed = await geminiVision(check.base64, INVOICE_PROMPT, INVOICE_SCHEMA)
if (!parsed) return json(res, 200, { vendor: null, total: null, items: [] })
// Normalize: trim + coerce numbers (model sometimes returns "1,234.56" as string)
for (const k of ['subtotal', 'tax_gst', 'tax_qst', 'total']) {
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0
}
if (Array.isArray(parsed.items)) {
for (const it of parsed.items) {
for (const k of ['qty', 'rate', 'amount']) {
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0
}
}
}
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`)
return json(res, 200, parsed)
} catch (e) {
log('Vision invoice error:', e.message)
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
}
try { return json(res, 200, await extractInvoice(check.base64)) }
catch (e) { log('Vision invoice error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
}
// ─── Preuve de paiement (capture d'écran client) ───────────────────────
// Synergie Inbox : un client envoie une capture de son paiement (virement Interac, app bancaire, reçu).
// On EXTRAIT (montant/date/référence/méthode) pour AFFICHER et décider. AUCUNE écriture auto en facturation (F autoritaire).
const PAYMENT_PROMPT = `Tu analyses une CAPTURE D'ÉCRAN de preuve de paiement envoyée par un client d'un fournisseur Internet au Québec
(virement Interac, application bancaire, reçu de carte, confirmation de paiement). Renvoie UNIQUEMENT du JSON selon le schéma.
Règles : "is_payment"=true seulement si l'image montre vraiment un paiement/transfert/reçu. "amount" = nombre (sans symbole). "currency" = CAD par défaut.
"date" = ISO YYYY-MM-DD si visible sinon null. "method" = Interac|Carte|Virement|Comptant|Autre. "reference" = numéro de confirmation/référence si visible.
"payer_name"/"recipient" = noms si visibles. Ne devine pas : champ absent null. "confidence" = 0.01.0.`
const PAYMENT_SCHEMA = {
type: 'object',
properties: {
is_payment: { type: 'boolean' },
amount: { type: 'number', nullable: true },
currency: { type: 'string', nullable: true },
date: { type: 'string', nullable: true },
method: { type: 'string', nullable: true },
reference: { type: 'string', nullable: true },
payer_name: { type: 'string', nullable: true },
recipient: { type: 'string', nullable: true },
confidence: { type: 'number' },
},
required: ['is_payment', 'confidence'],
}
async function extractPayment (base64) {
const parsed = await geminiVision(base64, PAYMENT_PROMPT, PAYMENT_SCHEMA)
if (!parsed) return { is_payment: false, confidence: 0 }
if (typeof parsed.amount === 'string') parsed.amount = Number(parsed.amount.replace(/[^0-9.\-]/g, '')) || null
parsed.confidence = Math.max(0, Math.min(1, Number(parsed.confidence) || 0))
log(`Vision payment: is_payment=${parsed.is_payment} amount=${parsed.amount} ${parsed.currency || ''} ref=${parsed.reference || '-'} conf=${parsed.confidence}`)
return parsed
}
async function handlePayment (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'payment')
if (check.error) return json(res, check.status, { error: check.error })
try { return json(res, 200, { ok: true, ...(await extractPayment(check.base64)) }) }
catch (e) { log('Vision payment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
}
// ─── Field-targeted extraction (for tech mobile form auto-fill) ─────────
@ -249,4 +287,4 @@ async function handleFieldScan (req, res) {
}
}
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractField, handleFieldScan }
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractInvoice, extractField, handleFieldScan, extractPayment, handlePayment }

View File

@ -15,6 +15,7 @@ const traccar = require('./lib/traccar')
const dispatch = require('./lib/dispatch')
const ical = require('./lib/ical')
const vision = require('./lib/vision')
const giftbit = require('./lib/giftbit')
let voiceAgent
try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) }
// Oktopus stack is decommissioned. Set OKTOPUS_DISABLED=1 (default) to skip
@ -44,6 +45,54 @@ const server = http.createServer(async (req, res) => {
if (method === 'OPTIONS') { res.writeHead(204); return res.end() }
try {
// ── Hub auth gate — staged rollout ───────────────────────────────────
// Tiers: PUBLIC (no auth) · customer self-service (own internal auth, also
// listed PUBLIC here, e.g. /payments /portal) · staff-ADMIN (Bearer
// HUB_SERVICE_TOKEN, injected server-side by the ops nginx /hub/ proxy).
// Cross-origin apps with no token (client portal, field-tech) must only
// use PUBLIC routes below.
// HUB_GATE = 'enforce' (401 on deny) | 'report' (log + allow) | unset(off)
// ALWAYS_ENFORCE = already locked & verified (no legit anon traffic) → 401
// regardless of mode. /campaigns/webhook stays public (Mailjet → us).
{
const PUBLIC = [
'/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
'/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/',
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/twiml', '/voice/status',
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
'/api/referral', '/api/accept-for-client',
'/payments', // customer self-service (own auth) + ops (proxied)
'/webhook/', '/campaigns/webhook',
'/dispatch/calendar/', // token-authed .ics feed
'/giftbit-demo', // démo sandbox testbed (validation Giftbit) — PUBLIC ; l'API /giftbit/* reste gatée
'/conversations/email-ingest', // ingestion courriel n8n — PUBLIC mais protégé par X-Mail-Token dans le handler
'/conversations/channel-ingest', // ingestion canal (web chat / 3CX / Facebook) — PUBLIC mais protégé par X-Mail-Token
]
const ALWAYS_ENFORCE = ['/devices', '/email-queue', '/campaigns', '/giftbit']
// Web-chat client (lien magique /c/<token>) : le token EST le secret → routes CLIENT publiques.
// GET /conversations/<token>, GET .../sse, POST .../messages, POST .../push UNIQUEMENT.
// Exclut resolve/archive/discussion (réservées agent) ; le reste de /conversations reste gaté.
const convChat = (() => {
const mm = path.match(/^\/conversations\/([A-Za-z0-9_-]{5,10})(?:\/(messages|sse|push))?$/)
if (!mm || ['resolve', 'archive', 'bulk', 'discussion'].includes(mm[1])) return false
const sub = mm[2]
if (!sub) return method === 'GET' // charger le fil
if (sub === 'sse') return method === 'GET' // flux SSE client
return method === 'POST' // messages | push
})()
const isPublic = convChat || PUBLIC.some(p => path.startsWith(p))
if (!isPublic) {
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
const hasToken = !!process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN
if (!hasToken) {
const mustEnforce = ALWAYS_ENFORCE.some(p => path.startsWith(p))
if (mustEnforce || process.env.HUB_GATE === 'enforce') {
return json(res, 401, { error: 'Unauthorized' })
}
if (process.env.HUB_GATE === 'report') log(`[hub-gate] would-deny ${method} ${path}`)
}
}
}
if (path === '/health') {
return json(res, 200, { ok: true, clients: sse.clientCount(), topics: sse.topicCount(), uptime: Math.floor(process.uptime()) })
}
@ -98,12 +147,20 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
if (path.startsWith('/giftbit')) return giftbit.handle(req, res, method, path, url)
if (path.startsWith('/municipality')) return require('./lib/municipality').handle(req, res, method, path, url)
if (path.startsWith('/legacy-sync')) return require('./lib/legacy-sync').handle(req, res, method, path, url)
if (path.startsWith('/legacy-payments')) return require('./lib/legacy-payments').handle(req, res, method, path, url)
if (path.startsWith('/sync/')) return require('./lib/sync-orchestrator').handle(req, res, method, path, url)
if (path.startsWith('/coupons/')) return require('./lib/coupon-triage').handle(req, res, method, path)
if (path.startsWith('/outbox')) return require('./lib/outbox').handle(req, res, method, path)
if (path.startsWith('/address/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
// Lightweight tech mobile page: /t/{token}[/action]
if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path)
if (path.startsWith('/diag/')) return require('./lib/client-diag').route(req, res, method, path) // diagnostic client libre-service (token signé)
if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)
if (path.startsWith('/api/catalog') || path.startsWith('/api/checkout') || path.startsWith('/api/accept-for-client') || path.startsWith('/api/order') || path.startsWith('/api/address') || path.startsWith('/api/otp')) return require('./lib/checkout').handle(req, res, method, path)
if (path.startsWith('/api/referral/')) return require('./lib/referral').handle(req, res, method, path)
@ -118,6 +175,9 @@ const server = http.createServer(async (req, res) => {
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path)
if (path.startsWith('/gmail')) return require('./lib/gmail').handle(req, res, method, path, url)
if (path.startsWith('/supplier-invoices')) return require('./lib/supplier-invoices').handle(req, res, method, path, url)
if (path.startsWith('/collab')) return require('./lib/ticket-collab').handle(req, res, method, path, url)
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
// Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
@ -149,6 +209,7 @@ const server = http.createServer(async (req, res) => {
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res)
if (path === '/vision/invoice' && method === 'POST') return vision.handleInvoice(req, res)
if (path === '/vision/payment' && method === 'POST') return vision.handlePayment(req, res)
if (path.startsWith('/ai/')) return require('./lib/ai').handle(req, res, method, path)
if (path.startsWith('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)
@ -223,6 +284,9 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
devices.startPoller()
try { require('./lib/olt-snmp').startOltPoller() }
catch (e) { log('OLT SNMP poller failed to start:', e.message) }
// Tampon d'envoi sortant (undo-send des notifs de file) : réarme les envois retenus après redémarrage
try { require('./lib/outbox').startup() }
catch (e) { log('outbox startup failed:', e.message) }
if (oktopusMqtt) oktopusMqtt.start()
else log('Oktopus MQTT monitor: skipped (disabled)')
// Start PPA (pre-authorized payment) cron scheduler
@ -231,4 +295,10 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
try { require('./lib/legacy-dispatch-sync').startSync() }
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
// Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
try { require('./lib/gmail').start() }
catch (e) { log('gmail ingest failed to start:', e.message) }
// Factures fournisseurs : courriels to:factures@ (via cc@) → OCR → candidats à revoir (Purchase Invoice draft)
try { require('./lib/supplier-invoices').start() }
catch (e) { log('supplier-invoices failed to start:', e.message) }
})