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 bd92558fa6
commit 5ab14cac44
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) # Legacy refresh creds (prod-only, never commit)
.refresh.env .refresh.env
**/.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 }) export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte // 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 }) 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 // 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 // Lien app PWA du tech (à copier / SMS) + son téléphone
export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech)) 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 finishJob = (job) => jpost('/roster/job/finish', { job })
export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync') export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {}) 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 // 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), {}) 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? } // Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body) 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 { Device } from '@twilio/voice-sdk'
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js' 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 SIP_WSS_URL = 'wss://voice.gigafibre.ca/ws'
const props = defineProps({ const props = defineProps({

View File

@ -64,7 +64,7 @@ const props = defineProps({
const emit = defineEmits(['action', 'applied']) const emit = defineEmits(['action', 'applied'])
const $q = useQuasar() 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 inputRef = ref(null)
const text = ref('') const text = ref('')

View File

@ -88,6 +88,7 @@ export const PROJECT_KINDS = {
label: 'Ticket', label: 'Ticket',
icon: 'confirmation_number', icon: 'confirmation_number',
color: '#f59e0b', color: '#f59e0b',
help: 'Crée un ticket (ERPNext Issue) quand le flow atteint cette étape — c\'est l\'action « créer un ticket ».',
fields: [ fields: [
{ name: 'subject', type: 'text', label: 'Sujet', required: true }, { name: 'subject', type: 'text', label: 'Sujet', required: true },
{ name: 'description', type: 'textarea', label: 'Description' }, { 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 --> <!-- Display mode -->
<template v-if="!editing"> <template v-if="!editing">
<slot name="display" :value="value" :display-value="displayValue" :start-edit="startEdit"> <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 }} {{ displayValue || placeholder }}
</span> </span>
</slot> </slot>
@ -180,6 +180,8 @@ defineExpose({ startEdit })
</script> </script>
<style lang="scss"> <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 { .inline-field {
display: inline-flex; display: inline-flex;
align-items: center; 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']) 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 alerts = ref([])
const expanded = ref(true) 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) }, 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({ const { publish } = useWizardPublish({
props, emit, props, emit,
state: { state: {
@ -1938,8 +1942,7 @@ const referralChecking = ref(false)
const referralError = ref('') const referralError = ref('')
// Installation financée (CRTC-compliant) alimente install_fee / install_regular du contrat. // Installation financée (CRTC-compliant) alimente install_fee / install_regular du contrat.
const installRegular = ref(0) // prix de référence barré (marketing) // NB: installRegular / installFee sont déclarés plus HAUT (avant useWizardPublish) voir TDZ.
const installFee = ref(0) // montant réellement financé/dû (créance)
const INSTALL_PRESETS = [ const INSTALL_PRESETS = [
{ key: 'standard', label: 'Standard', regular: 360, fee: 240 }, { key: 'standard', label: 'Standard', regular: 360, fee: 240 },
{ key: 'simple', label: 'Simple', regular: 180, fee: 120 }, { 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> <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"> <div v-for="f in equipFields" :key="f.field" class="mf">
<span class="mf-label">{{ f.label }}</span> <span class="mf-label">{{ f.label }}</span>
<InlineField :value="doc[f.field]" :field="f.field" doctype="Service Equipment" :docname="docName" <InlineField :value="doc[f.field]" :field="f.field" doctype="Service Equipment" :docname="docName"
@ -9,24 +77,8 @@
</div> </div>
<div v-if="device" class="q-mt-md"> <div v-if="device" class="q-mt-md">
<div class="info-block-title"> <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>
Diagnostic en direct <div v-show="showTech">
<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="diag-section fibre-top"> <div class="diag-section fibre-top">
<q-icon name="settings_input_hdmi" size="16px" class="q-mr-xs" /> <q-icon name="settings_input_hdmi" size="16px" class="q-mr-xs" />
<span class="text-weight-bold q-mr-sm">Fibre</span> <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 class="diag-item" v-if="device.uptime"><span class="diag-label">Uptime</span>{{ formatUptime(device.uptime) }}</div>
</div> </div>
</div> </div>
</div>
<div class="diag-section"> <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" /> <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> <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>
<div v-show="showHosts">
<div v-if="hostsLoading" class="text-caption text-grey-5 q-py-xs"> <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... <q-spinner size="14px" class="q-mr-xs" /> Interrogation de l'equipement...
</div> </div>
@ -164,14 +219,16 @@
</div> </div>
</div> </div>
<div v-else-if="hosts" class="text-caption text-grey-5 q-py-xs">Aucun appareil</div> <div v-else-if="hosts" class="text-caption text-grey-5 q-py-xs">Aucun appareil</div>
</div>
</div> </div>
</div> </div>
<div v-else-if="doc.serial_number && !device" class="q-mt-md text-caption text-grey-5"> <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... <q-spinner size="14px" class="q-mr-xs" v-if="deviceLoading" /> Chargement diagnostic ACS...
</div> </div>
<!-- Advanced WiFi Diagnostic Panel --> <!-- Diagnostic modem avancé (modem-bridge / Playwright) DÉSACTIVÉ « pour le moment » : trop lourd (~15 s).
<div v-if="managementIp || props.doc?.serial_number" class="q-mt-md"> 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"> <div class="info-block-title">
<q-icon name="wifi_find" size="16px" class="q-mr-xs" /> <q-icon name="wifi_find" size="16px" class="q-mr-xs" />
Diagnostic modem Diagnostic modem
@ -447,12 +504,13 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import { useDeviceStatus } from 'src/composables/useDeviceStatus' import { useDeviceStatus } from 'src/composables/useDeviceStatus'
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic' import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
import { deleteDoc } from 'src/api/erp' import { deleteDoc } from 'src/api/erp'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({ doc: { type: Object, required: true }, docName: String }) const props = defineProps({ doc: { type: Object, required: true }, docName: String })
const emit = defineEmits(['deleted']) 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` 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 () { async function doRefresh () {
if (!props.doc.serial_number) return if (!props.doc.serial_number) return
refreshing.value = true refreshing.value = true
@ -705,17 +841,23 @@ onMounted(async () => {
fetchStatus([{ serial_number: props.doc.serial_number }]) fetchStatus([{ serial_number: props.doc.serial_number }])
fetchOltStatus(props.doc.serial_number) fetchOltStatus(props.doc.serial_number)
fetchPortContext(props.doc.serial_number).then(ctx => { portCtx.value = ctx }) 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 => { watch(() => props.doc.serial_number, sn => {
stopWanLive() // arrêter la mesure live de l'appareil précédent
if (sn) { if (sn) {
hosts.value = null hosts.value = null
fetchStatus([{ serial_number: sn }]) fetchStatus([{ serial_number: sn }])
fetchOltStatus(sn) fetchOltStatus(sn)
loadEvents(); loadWanHistory() // rafraîchir la bande dispo/événements pour le nouvel appareil
} }
}) })
onUnmounted(() => stopWanLive())
</script> </script>
<style scoped> <style scoped>
@ -724,6 +866,35 @@ watch(() => props.doc.serial_number, sn => {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.4; } 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; } .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; } .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; } .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 { BASE_URL } from 'src/config/erpnext'
import InlineField from 'src/components/shared/InlineField.vue' 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({ const props = defineProps({
doc: { type: Object, required: true }, 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', fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date', 'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
'contract_duration', 'product_sku', 'speed_down', 'speed_up', 'contract_duration', 'product_sku', 'speed_down', 'speed_up',
'radius_user', 'device', 'display_order'], 'radius_user', 'device', 'display_order', 'legacy_service_id'],
limit: 100, limit: 100,
// display_order first (dispatcher-controlled), then by start date so // display_order first (dispatcher-controlled), then by start date so
// newer subs float up when display_order hasn't been set yet (= 0). // newer subs float up when display_order hasn't been set yet (= 0).
@ -110,6 +110,7 @@ export function useClientData (deps) {
radius_pwd: '', radius_pwd: '',
device: doc.device || '', device: doc.device || '',
display_order: Number(doc.display_order || 0), 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, qty: 1,
})) }))
} }

View File

@ -135,3 +135,56 @@ export function noteTimeAgo (d) {
if (days < 7) return `Il y a ${days}j` if (days < 7) return `Il y a ${days}j`
return formatDate(d) 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' * import { HUB_URL } from 'src/config/hub'
* *
* In dev (localhost), points to local targo-hub instance. * 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. * 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 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 { publishJobs } from 'src/api/dispatch'
import { sendTestSms } from 'src/api/sms' 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) { async function getMagicLink (techId) {
try { try {

View File

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

View File

@ -219,6 +219,9 @@
<div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)"> <div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)">
<div class="row items-center no-wrap q-gutter-x-sm"> <div class="row items-center no-wrap q-gutter-x-sm">
<code class="sub-sku">{{ sub.name }}</code> <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> <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. --> <!-- Inline price edit: double-click to edit. Negative values kept as-is for rebates. -->
<span class="sub-price-wrap" @click.stop @dblclick.stop <span class="sub-price-wrap" @click.stop @dblclick.stop
@ -266,7 +269,12 @@
<div class="sub-total-line"> <div class="sub-total-line">
<span class="sub-total-label">Total mensuel</span> <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> </div>
</template> </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-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-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-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-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-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; } .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', color: 'deep-orange-6',
route: '/rapports/internet-cher', 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 = [ 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... <q-spinner size="14px" class="q-mr-xs" /> Chargement...
</div> </div>
<div v-else> <div v-else>
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs" <div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs q-px-sm member-row">
style="border-bottom:1px solid #f1f5f9">
<q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem"> <q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem">
{{ initial(m) }} {{ initial(m) }}
</q-avatar> </q-avatar>
<span class="text-body2">{{ m.name || m.username }}</span> <span class="text-body2">{{ m.name || m.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span> <span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space /> <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"> @click="removeFromGroup(m, selectedGroup)" title="Retirer du groupe">
<q-tooltip>Retirer du groupe</q-tooltip> <q-tooltip>Retirer du groupe</q-tooltip>
</q-btn> </q-btn>
@ -730,6 +729,15 @@
</q-expansion-item> </q-expansion-item>
</div> </div>
</template> </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> </q-page>
</template> </template>
@ -743,6 +751,9 @@ import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/compos
import { usePermissions } from 'src/composables/usePermissions' import { usePermissions } from 'src/composables/usePermissions'
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix' import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
import { useUserGroups } from 'src/composables/useUserGroups' 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' import { useLegacySync } from 'src/composables/useLegacySync'
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue')) 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:last-child { border-bottom: none; }
.member-search-item:hover { background: #f1f5f9; } .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> </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 v-if="modalTicket?.customer_name"> &middot; {{ modalTicket.customer_name }}</template>
</template> </template>
<template #header-actions> <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" <q-btn v-if="modalTicket?.customer" flat dense round icon="person" class="q-mr-xs"
@click="$router.push('/clients/' + modalTicket.customer); modalOpen = false"> @click="$router.push('/clients/' + modalTicket.customer); modalOpen = false">
<q-tooltip>Voir le client</q-tooltip> <q-tooltip>Voir le client</q-tooltip>
</q-btn> </q-btn>
</template> </template>
</DetailModal> </DetailModal>
<InterveneDialog v-model="intervene.open" :name="intervene.name" :title="intervene.title" doctype="Issue" />
</q-page> </q-page>
</template> </template>
@ -156,6 +162,10 @@ import { formatDate } from 'src/composables/useFormatters'
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses' import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal' import { useDetailModal } from 'src/composables/useDetailModal'
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config' 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 DetailModal from 'src/components/shared/DetailModal.vue'
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.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_checklist_item()
_create_job_photo() _create_job_photo()
_create_checklist_template() _create_checklist_template()
_create_dispatch_job_assistant()
_extend_dispatch_job() _extend_dispatch_job()
_create_contract_benefit() _create_contract_benefit()
_create_service_contract() _create_service_contract()
@ -397,6 +398,33 @@ def _create_checklist_template():
print(" ✓ Checklist Template created.") 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 # 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", {"dt": "Dispatch Job", "fieldname": "step_order", "fieldtype": "Int",
"label": "Ordre d'étape", "insert_after": "on_close_webhook", "label": "Ordre d'étape", "insert_after": "on_close_webhook",
"description": "Position dans la séquence du projet (1, 2, 3...)"}, "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 # Equipment
{"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break", {"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break",
"label": "Équipements", "insert_after": "tags"}, "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", "VD67owoj"), "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", "VD67owoj"), "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]); 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 ─── // ─── actions sur 1 ticket ───
$ticket = (int)($_POST['ticket_id'] ?? 0); $ticket = (int)($_POST['ticket_id'] ?? 0);
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400); 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 // Initialize on load
loadFlows() 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 geminiChat = async (messages, tools) => {
const url = `${cfg.AI_BASE_URL}chat/completions` const msg = await require('./ai').chat({ task: 'sms', messages, tools, maxTokens: 500, wantMessage: true })
const body = { model: cfg.AI_MODEL, max_tokens: 500, messages, tools } return { choices: [{ message: msg }] }
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 runAgent = async (customerId, customerName, messageHistory, { token } = {}) => { const runAgent = async (customerId, customerName, messageHistory, { token } = {}) => {

View File

@ -26,78 +26,97 @@
*/ */
const cfg = require('./config') 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) ─────────────────────── // ── Core Gemini call (reuses same config as agent.js) ───────────────────────
async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3 } = {}) { async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3, task = 'dispatch' } = {}) {
if (!cfg.AI_API_KEY) throw new Error('AI_API_KEY not configured') // 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 }] })
const url = `${cfg.AI_BASE_URL}chat/completions` if (!content) throw new Error('Empty AI response')
// Model chain: primary → fallback (handles 503 overload gracefully) if (!jsonMode) return content
const models = [cfg.AI_MODEL, cfg.AI_FALLBACK_MODEL].filter(Boolean) try { return JSON.parse(content) } catch {
const match = content.match(/```(?:json)?\s*([\s\S]*?)```/)
for (const model of models) { if (match) { try { return JSON.parse(match[1].trim()) } catch { /* fall through */ } }
const body = { return { raw: content.substring(0, 300), error: 'Failed to parse JSON' }
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
}
} }
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 ───────────── // ── PII minimizer — strip sensitive fields before sending to AI ─────────────
@ -284,7 +303,7 @@ async function diagnose (symptoms, deviceData, customer) {
} : null, } : null,
customer: minimizeCustomer(customer), customer: minimizeCustomer(customer),
}) })
return aiCall(DIAGNOSE_PROMPT, userPrompt) return aiCall(DIAGNOSE_PROMPT, userPrompt, { task: 'diagnose' })
} }
// ── Daily summary for tech ────────────────────────────────────────────────── // ── Daily summary for tech ──────────────────────────────────────────────────
@ -427,7 +446,7 @@ async function analyzeOutage (serial) {
} }
// Complex case — multiple customers affected or unclear cause → use AI // 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.port_summary = portSummary
result.ai_used = true result.ai_used = true
return result return result
@ -677,6 +696,40 @@ async function handle (req, res, method, urlPath) {
return json(res, 200, PRIVACY_INFO) 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' }) return json(res, 404, { error: 'AI endpoint not found' })
} }
@ -706,6 +759,9 @@ const PRIVACY_INFO = {
module.exports = { module.exports = {
handle, handle,
aiCall, aiCall,
chat,
routeFor,
routeStatus,
dispatchSuggest, dispatchSuggest,
dispatchRedistribute, dispatchRedistribute,
dispatchNlp, dispatchNlp,

View File

@ -949,6 +949,7 @@ function giftExpiredPage (reason) {
expired: { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' }, 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.' }, revoked: { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' },
notfound: { fr: 'Lien introuvable.', en: 'Link not found.' }, 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 const m = messages[reason] || messages.notfound
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"> 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> </style></head><body>
<div class="card"> <div class="card">
<div class="logo">TARGO</div> <div class="logo">TARGO</div>
<div class="icon">🎁</div> <div class="icon">${reason === 'blocked' ? '🔒' : '🎁'}</div>
<h1>${m.fr}</h1> <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>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> <p class="en">${m.en}</p>
@ -1372,6 +1373,9 @@ function applyWebhookEvent (ev) {
// ── HTTP routing ───────────────────────────────────────────────────────────── // ── HTTP routing ─────────────────────────────────────────────────────────────
async function handle (req, res, method, path) { 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) // POST /campaigns/parse — preview matched send list (no save)
// Returns: // Returns:
// - recipients[]: contacts paired with a gift_url (ready to send) // - 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_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired')
if (!r.gift_url) return respondExpired('notfound') 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 // Successful redirect — record analytics, persist async (no await; the
// recipient is already on their way to Giftbit's page). // recipient is already on their way to Giftbit's page).
r.gift_redirected_count = (r.gift_redirected_count || 0) + 1 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_MODEL: env('AI_MODEL', 'gemini-2.5-flash'),
AI_FALLBACK_MODEL: env('AI_FALLBACK_MODEL', 'gemini-2.5-flash-lite-preview'), 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/'), 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'), VOICE_MODEL: env('VOICE_MODEL', 'models/gemini-2.5-flash-live-preview'),
OLT_HOST: env('OLT_HOST'), OLT_HOST: env('OLT_HOST'),
OLT_COMMUNITY: env('OLT_COMMUNITY', 'public'), 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' 'use strict'
const http = require('http') const http = require('http')
const https = require('https') const https = require('https')
const fs = require('fs')
const { URL } = require('url') const { URL } = require('url')
const cfg = require('./config') const cfg = require('./config')
@ -78,18 +79,48 @@ function erpRequest (method, path, body) {
return erpFetch(path, { method, ...(body && { body }) }) return erpFetch(path, { method, ...(body && { body }) })
} }
async function lookupCustomerByPhone (phone) { // Renvoie TOUTES les fiches Customer dont un champ téléphone se termine par les 10 chiffres (multi-champs
const digits = phone.replace(/\D/g, '').slice(-10) // incl. mobile_no), dédupliquées. Permet de choisir parmi plusieurs fiches quand le numéro est partagé.
const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'tel_home', 'tel_office']) async function lookupCustomersByPhone (phone, limit = 6) {
for (const field of ['cell_phone', 'tel_home', 'tel_office']) { 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 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 { try {
const res = await erpFetch(path) const res = await erpFetch(path)
if (res.status === 200 && res.data?.data?.length > 0) return res.data.data[0] if (res.status === 200 && res.data?.data?.length) {
} catch (e) { log('lookupCustomerByPhone error on ' + field + ':', e.message) } 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) { function createCommunication (fields) {
@ -141,8 +172,25 @@ function cors (res, methods = 'GET, POST, OPTIONS') {
res.setHeader('Access-Control-Allow-Headers', 'Content-Type') 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 = { module.exports = {
log, json, parseBody, httpRequest, cors, log, json, parseBody, httpRequest, cors,
erpFetch, erpRequest, lookupCustomerByPhone, createCommunication, erpFetch, erpRequest, lookupCustomerByPhone, lookupCustomersByPhone, lookupCustomersByEmail, createCommunication,
nbiRequest, deepGetValue, nbiRequest, deepGetValue,
readJsonFile, writeJsonFile, loadSeenSet, saveSeenSet, stripDataUrl,
} }

View File

@ -366,6 +366,66 @@ function sync (opts = {}) {
return run 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). // 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 } = {}) { async function syncImpl ({ dryRun = false } = {}) {
resetCaches() resetCaches()
@ -863,9 +923,138 @@ function startSync () {
} }
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } } function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
// 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) { async function handle (req, res, method, path) {
try { try {
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true })) 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/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 === '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 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)) 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' && 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/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/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) 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 } 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) { function extractVal (vb) {
if (vb.type === snmp.ObjectType.OctetString) return vb.value.toString() if (vb.type === snmp.ObjectType.OctetString) return vb.value.toString()
if (vb.type === snmp.ObjectType.Integer) return vb.value if (vb.type === snmp.ObjectType.Integer) return vb.value
@ -484,5 +556,5 @@ function stopOltPoller () {
module.exports = { module.exports = {
registerOlt, startOltPoller, stopOltPoller, registerOlt, startOltPoller, stopOltPoller,
getOnuBySerial, getPortNeighbors, getPortHealth, classifyOfflineCause, 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 // 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). // 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: {} }, 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 = { const POLICY_OPTIONS = {
reschedule: [ reschedule: [
@ -249,19 +253,10 @@ async function execTool (name, args) {
} }
// ── Appel Gemini (OpenAI-compat) — même config que agent.js ───────────────── // ── 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) { async function geminiChat (messages) {
const url = `${cfg.AI_BASE_URL}chat/completions` const msg = await require('./ai').chat({ task: 'roster', messages, tools: TOOLS, maxTokens: 700, wantMessage: true })
const body = { model: cfg.AI_MODEL, max_tokens: 700, messages, tools: TOOLS } return { choices: [{ message: msg }] }
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()
}
} }
function systemPrompt () { function systemPrompt () {

View File

@ -353,11 +353,19 @@ function deptToSkill (txt) {
// Batch : 1 seule requête sur Service Location pour tous les codes distincts. // Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) { async function attachLocations (jobs) {
const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))] const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))]
if (!codes.length) return jobs const m = {}
let locs = [] 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) {} }
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 } // Résolveur municipalité (best-effort : si F injoignable, on n'enrichit pas → OPS retombe sur la ville brute).
const m = {}; for (const l of locs) m[l.name] = l let muni = null; try { muni = require('./municipality'); await muni.load() } catch (e) { muni = null }
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 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 return jobs
} }
function nowMs () { return new Date().getTime() } 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 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 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 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 // 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))) 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 })) 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 }) 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. // 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') { if (path === '/roster/job/set-location' && method === 'POST') {
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' }) 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)` - 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) { function llmRequest (messages) {
return new Promise((resolve, reject) => { return require('./ai').chat({ task: 'roster', messages, maxTokens: 1024, temperature: 0.1 })
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()
})
} }
async function parseWithLlm (text) { 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' 'use strict'
const cfg = require('./config') const cfg = require('./config')
const { log, json, parseBody } = require('./helpers') 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. // Prefer bearer token (revocable, no password in env); fall back to Basic auth for legacy.
const authHeader = cfg.TRACCAR_TOKEN const authHeader = cfg.TRACCAR_TOKEN
@ -15,6 +16,24 @@ async function traccarFetch (path) {
return res.json() 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) // Cache devices for 60s (they rarely change)
let _devCache = null, _devCacheTs = 0 let _devCache = null, _devCacheTs = 0
async function getDevices () { 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) // Proxy any other Traccar API path (fallback)
if (method === 'GET') { if (method === 'GET') {
try { try {

View File

@ -70,7 +70,7 @@ async function smsStatus (req, res) {
setImmediate(() => broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() })) 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 // Normalize to E.164: strip non-digits, add +1 for 10-digit North American numbers
const digits = phone.replace(/\D/g, '') const digits = phone.replace(/\D/g, '')
const to = phone.startsWith('+') ? phone const to = phone.startsWith('+') ? phone
@ -78,9 +78,10 @@ async function sendSmsInternal (phone, message, customer) {
: digits.length === 11 && digits.startsWith('1') ? '+' + digits : digits.length === 11 && digits.startsWith('1') ? '+' + digits
: '+' + digits : '+' + digits
const twilioData = new URLSearchParams({ 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', 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 authStr = Buffer.from(cfg.TWILIO_ACCOUNT_SID + ':' + cfg.TWILIO_AUTH_TOKEN).toString('base64')
const https = require('https') const https = require('https')
const twilioRes = await new Promise((resolve, reject) => { const twilioRes = await new Promise((resolve, reject) => {

View File

@ -1,15 +1,15 @@
'use strict' 'use strict'
const cfg = require('./config') 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}` 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(), { const resp = await fetch(GEMINI_URL(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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 }, 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) { function extractBase64 (req, body, label) {
if (!cfg.AI_API_KEY) return { error: 'AI_API_KEY not configured', status: 500 } 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 } 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`) log(`Vision ${label}: received image ${Math.round(base64.length * 3 / 4 / 1024)}KB`)
return { base64 } return { base64 }
} }
@ -141,30 +141,68 @@ const INVOICE_SCHEMA = {
required: ['vendor', 'total'], 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) { async function handleInvoice (req, res) {
const body = await parseBody(req) const body = await parseBody(req)
const check = extractBase64(req, body, 'invoice') const check = extractBase64(req, body, 'invoice')
if (check.error) return json(res, check.status, { error: check.error }) if (check.error) return json(res, check.status, { error: check.error })
try { try { return json(res, 200, await extractInvoice(check.base64)) }
const parsed = await geminiVision(check.base64, INVOICE_PROMPT, INVOICE_SCHEMA) catch (e) { log('Vision invoice error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
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']) { // ─── Preuve de paiement (capture d'écran client) ───────────────────────
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0 // 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).
if (Array.isArray(parsed.items)) { 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
for (const it of parsed.items) { (virement Interac, application bancaire, reçu de carte, confirmation de paiement). Renvoie UNIQUEMENT du JSON selon le schéma.
for (const k of ['qty', 'rate', 'amount']) { 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.
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0 "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',
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`) properties: {
return json(res, 200, parsed) is_payment: { type: 'boolean' },
} catch (e) { amount: { type: 'number', nullable: true },
log('Vision invoice error:', e.message) currency: { type: 'string', nullable: true },
return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) 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) ───────── // ─── 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 dispatch = require('./lib/dispatch')
const ical = require('./lib/ical') const ical = require('./lib/ical')
const vision = require('./lib/vision') const vision = require('./lib/vision')
const giftbit = require('./lib/giftbit')
let voiceAgent let voiceAgent
try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) } 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 // 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() } if (method === 'OPTIONS') { res.writeHead(204); return res.end() }
try { 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') { if (path === '/health') {
return json(res, 200, { ok: true, clients: sse.clientCount(), topics: sse.topicCount(), uptime: Math.floor(process.uptime()) }) 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('/auth/')) return auth.handle(req, res, method, path, url)
if (path.startsWith('/conversations')) return conversation.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('/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/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('/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('/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) if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
// Lightweight tech mobile page: /t/{token}[/action] // Lightweight tech mobile page: /t/{token}[/action]
if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path) 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('/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/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) 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$/) const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams) 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('/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('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').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 // 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/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(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/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('/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('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, 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() devices.startPoller()
try { require('./lib/olt-snmp').startOltPoller() } try { require('./lib/olt-snmp').startOltPoller() }
catch (e) { log('OLT SNMP poller failed to start:', e.message) } 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() if (oktopusMqtt) oktopusMqtt.start()
else log('Oktopus MQTT monitor: skipped (disabled)') else log('Oktopus MQTT monitor: skipped (disabled)')
// Start PPA (pre-authorized payment) cron scheduler // 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 // Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
try { require('./lib/legacy-dispatch-sync').startSync() } try { require('./lib/legacy-dispatch-sync').startSync() }
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) } 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) }
}) })