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:
parent
bd92558fa6
commit
5ab14cac44
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -51,3 +51,7 @@ services/targo-hub/templates/*.bak-*.html
|
|||
# Legacy refresh creds (prod-only, never commit)
|
||||
.refresh.env
|
||||
**/.refresh.env
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -112,6 +112,10 @@ export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates
|
|||
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
|
||||
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
|
||||
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
|
||||
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
|
||||
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
|
||||
export const removeAssistant = (job, techId) => jpost('/roster/job/team', { job, remove: techId })
|
||||
// Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel
|
||||
// Lien app PWA du tech (à copier / SMS) + son téléphone
|
||||
export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech))
|
||||
|
|
@ -123,7 +127,15 @@ export const startJob = (job) => jpost('/roster/job/start', { job })
|
|||
export const finishJob = (job) => jpost('/roster/job/finish', { job })
|
||||
export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
|
||||
export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {})
|
||||
// Jobs Legacy (osTicket) ouverts assignés à UN tech (lecture seule) — vue « jobs courants du tech »
|
||||
export const legacyTechTickets = (tech) => jget('/dispatch/legacy-sync/tech-tickets?tech=' + encodeURIComponent(tech))
|
||||
// Charge Legacy DATÉE par tech×jour sur la fenêtre affichée → durées estimées appliquées aux timelines
|
||||
export const legacyWindowLoad = (start, days) => jget('/dispatch/legacy-sync/window-load?start=' + encodeURIComponent(start) + '&days=' + encodeURIComponent(days))
|
||||
// Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket
|
||||
export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
|
||||
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
|
||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
||||
// URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement.
|
||||
export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
|
||||
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon).
|
||||
export const traccarTrack = (tech, from, to) => jget('/traccar/track?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ import { createDoc } from 'src/api/erp'
|
|||
import { Device } from '@twilio/voice-sdk'
|
||||
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
|
||||
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
||||
const SIP_WSS_URL = 'wss://voice.gigafibre.ca/ws'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const props = defineProps({
|
|||
const emit = defineEmits(['action', 'applied'])
|
||||
const $q = useQuasar()
|
||||
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
||||
|
||||
const inputRef = ref(null)
|
||||
const text = ref('')
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export const PROJECT_KINDS = {
|
|||
label: 'Ticket',
|
||||
icon: 'confirmation_number',
|
||||
color: '#f59e0b',
|
||||
help: 'Crée un ticket (ERPNext Issue) quand le flow atteint cette étape — c\'est l\'action « créer un ticket ».',
|
||||
fields: [
|
||||
{ name: 'subject', type: 'text', label: 'Sujet', required: true },
|
||||
{ name: 'description', type: 'textarea', label: 'Description' },
|
||||
|
|
|
|||
150
apps/ops/src/components/settings/AiRoutingCard.vue
Normal file
150
apps/ops/src/components/settings/AiRoutingCard.vue
Normal 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>
|
||||
105
apps/ops/src/components/settings/CannedResponsesCard.vue
Normal file
105
apps/ops/src/components/settings/CannedResponsesCard.vue
Normal 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, '&').replace(/</g, '<').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(/ /g, ' ').replace(/&/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>
|
||||
185
apps/ops/src/components/settings/QueueTeamsCard.vue
Normal file
185
apps/ops/src/components/settings/QueueTeamsCard.vue
Normal 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>
|
||||
157
apps/ops/src/components/shared/DepartmentBoard.vue
Normal file
157
apps/ops/src/components/shared/DepartmentBoard.vue
Normal 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>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<!-- Display mode -->
|
||||
<template v-if="!editing">
|
||||
<slot name="display" :value="value" :display-value="displayValue" :start-edit="startEdit">
|
||||
<span class="inline-field__value" :class="{ 'inline-field__placeholder': !displayValue }">
|
||||
<span class="inline-field__value" :class="{ 'inline-field__placeholder': !displayValue, 'inline-field__value--multiline': type === 'textarea' }">
|
||||
{{ displayValue || placeholder }}
|
||||
</span>
|
||||
</slot>
|
||||
|
|
@ -180,6 +180,8 @@ defineExpose({ startEdit })
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* Champs multi-lignes (description, notes) : conserver les sauts de ligne en lecture (sinon tout est aplati en un paragraphe) */
|
||||
.inline-field__value--multiline { white-space: pre-wrap; word-break: break-word; }
|
||||
.inline-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
151
apps/ops/src/components/shared/InterveneDialog.vue
Normal file
151
apps/ops/src/components/shared/InterveneDialog.vue
Normal 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(/</g, '<').replace(/&/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>
|
||||
145
apps/ops/src/components/shared/NewTicketDialog.vue
Normal file
145
apps/ops/src/components/shared/NewTicketDialog.vue
Normal 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>
|
||||
218
apps/ops/src/components/shared/OrchestratorDialog.vue
Normal file
218
apps/ops/src/components/shared/OrchestratorDialog.vue
Normal 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>
|
||||
|
|
@ -43,7 +43,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
|||
|
||||
defineEmits(['open-ticket'])
|
||||
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
||||
|
||||
const alerts = ref([])
|
||||
const expanded = ref(true)
|
||||
|
|
|
|||
88
apps/ops/src/components/shared/OutboxPanel.vue
Normal file
88
apps/ops/src/components/shared/OutboxPanel.vue
Normal 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>
|
||||
109
apps/ops/src/components/shared/PipelineBoard.vue
Normal file
109
apps/ops/src/components/shared/PipelineBoard.vue
Normal 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>
|
||||
|
|
@ -1722,6 +1722,10 @@ function addFromCatalog (p) {
|
|||
}, 900)
|
||||
}
|
||||
|
||||
// installRegular / installFee : déclarés AVANT useWizardPublish car son `state` les référence
|
||||
// dès la création de l'objet — sinon ReferenceError (TDZ) au setup → le wizard ne monte plus.
|
||||
const installRegular = ref(0) // prix de référence barré (marketing)
|
||||
const installFee = ref(0) // montant réellement financé/dû (créance)
|
||||
const { publish } = useWizardPublish({
|
||||
props, emit,
|
||||
state: {
|
||||
|
|
@ -1938,8 +1942,7 @@ const referralChecking = ref(false)
|
|||
const referralError = ref('')
|
||||
|
||||
// Installation financée (CRTC-compliant) — alimente install_fee / install_regular du contrat.
|
||||
const installRegular = ref(0) // prix de référence barré (marketing)
|
||||
const installFee = ref(0) // montant réellement financé/dû (créance)
|
||||
// NB: installRegular / installFee sont déclarés plus HAUT (avant useWizardPublish) — voir TDZ.
|
||||
const INSTALL_PRESETS = [
|
||||
{ key: 'standard', label: 'Standard', regular: 360, fee: 240 },
|
||||
{ key: 'simple', label: 'Simple', regular: 180, fee: 120 },
|
||||
|
|
|
|||
33
apps/ops/src/components/shared/ReaderStack.vue
Normal file
33
apps/ops/src/components/shared/ReaderStack.vue
Normal 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>
|
||||
207
apps/ops/src/components/shared/ServiceStatusDialog.vue
Normal file
207
apps/ops/src/components/shared/ServiceStatusDialog.vue
Normal 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>
|
||||
|
|
@ -1,5 +1,73 @@
|
|||
<template>
|
||||
<div class="modal-field-grid">
|
||||
<!-- Vue appareil moderne : carte réseau + voyants + tuiles + trafic + événements (le dense est replié plus bas) -->
|
||||
<div v-if="device" class="topo-summary">
|
||||
<div class="topo-row">
|
||||
<div class="topo-node"><q-icon name="public" size="22px" :color="status.online ? 'green-6' : 'grey-5'" /><div class="topo-cap">Internet</div></div>
|
||||
<div class="topo-link" :class="{ ok: status.online }"></div>
|
||||
<div class="topo-node"><q-icon name="cell_tower" size="22px" color="blue-grey-6" /><div class="topo-cap">OLT</div><div class="topo-sub">{{ oltLabel }}</div></div>
|
||||
<div class="topo-link" :class="{ ok: status.online }"></div>
|
||||
<div class="topo-node"><div class="topo-modem"><q-icon name="router" size="22px" color="indigo-6" /><span class="topo-dot" :class="status.online ? 'on' : 'off'"></span></div><div class="topo-cap">Modem</div><div class="topo-sub" v-if="doc.olt_slot != null">{{ doc.olt_slot }}/{{ doc.olt_port }}/{{ doc.olt_ontid }}</div></div>
|
||||
<div class="topo-link" :class="{ ok: clientCount > 0 }"></div>
|
||||
<div class="topo-node topo-click" @click="openHosts"><div style="position:relative;display:inline-block;"><q-icon name="devices" size="22px" color="indigo-6" /><q-circular-progress v-if="hostsLoading" indeterminate size="15px" :thickness="0.22" color="indigo-5" style="position:absolute;right:-9px;top:-4px;" /></div><div class="topo-cap">{{ clientCount }} app.</div></div>
|
||||
</div>
|
||||
<div class="topo-badges">
|
||||
<q-badge :color="statusBadgeColor">{{ status.label }}</q-badge>
|
||||
<q-badge v-if="device.opticalStatus" outline :color="device.opticalStatus === 'Up' ? 'green' : 'red'">Fibre {{ device.opticalStatus }}</q-badge>
|
||||
<q-badge v-if="signalLabel" outline :color="signalBadgeColor">Signal {{ signalLabel }}</q-badge>
|
||||
<span v-if="device.lastInform" class="text-caption text-grey-5">{{ timeAgo(device.lastInform) }}</span>
|
||||
<q-space />
|
||||
<q-btn flat dense size="sm" icon="sync" :loading="refreshing" @click="doRefresh" title="Rafraîchir" />
|
||||
</div>
|
||||
|
||||
<div class="dv-grid">
|
||||
<div class="dv-tile" v-if="device.rxPower != null && device.rxPower !== 0"><div class="dv-tl"><q-icon name="network_check" size="14px" /> Signal Rx/Tx</div><div class="dv-tv" :style="{ color: rxColor }">{{ device.rxPower }}<span v-if="device.txPower != null"> / {{ device.txPower }}</span> dBm</div></div>
|
||||
<div class="dv-tile" v-if="device.uptime"><div class="dv-tl"><q-icon name="schedule" size="14px" /> Uptime</div><div class="dv-tv">{{ formatUptime(device.uptime) }}</div></div>
|
||||
<div class="dv-tile" v-if="managementIp"><div class="dv-tl"><q-icon name="settings" size="14px" /> Gestion</div><a :href="'https://' + managementIp + '/'" target="_blank" class="dv-tv dv-link">{{ managementIp }}</a></div>
|
||||
<div class="dv-tile" v-if="lastOutage"><div class="dv-tl dv-warn"><q-icon name="power_off" size="14px" /> Dern. coupure</div><div class="dv-tv">{{ lastOutage }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="dv-row2">
|
||||
<div class="dv-wan">
|
||||
<div class="dv-tl">
|
||||
<q-icon name="show_chart" size="14px" /> {{ wanLive ? 'Débit WAN en direct' : 'Disponibilité & signal récents' }}
|
||||
<q-btn dense flat round size="9px" class="dv-wan-btn" :icon="wanLive ? 'stop' : 'speed'" :color="wanLive ? 'red' : 'primary'" @click="toggleWanLive">
|
||||
<q-tooltip>{{ wanLive ? 'Arrêter la mesure' : 'Débit WAN en direct — compteurs OLT (façon F)' }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<!-- Mode LIVE : débit WAN temps réel (compteurs Counter64 de l'OLT, échantillon 5 s, façon F) -->
|
||||
<template v-if="wanLive">
|
||||
<div v-if="wanLiveErr" class="dv-wan-ph">{{ wanErrMsg }}</div>
|
||||
<template v-else>
|
||||
<svg viewBox="0 0 220 40" class="wan-spark" preserveAspectRatio="none">
|
||||
<polyline v-if="wanRates.length > 1" :points="wanLine('dl')" fill="none" stroke="#16a34a" stroke-width="1.5" />
|
||||
<polyline v-if="wanRates.length > 1" :points="wanLine('ul')" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
||||
</svg>
|
||||
<div class="text-caption q-mt-xs">
|
||||
<span style="color:#16a34a;font-weight:600">▼ {{ fmtBps(wanNow && wanNow.dl) }}</span>
|
||||
<span style="color:#2563eb;font-weight:600">▲ {{ fmtBps(wanNow && wanNow.ul) }}</span>
|
||||
<q-spinner size="11px" class="q-ml-xs" v-if="!wanNow" /><span class="text-grey-5" v-if="!wanNow"> mesure…</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Mode défaut : bande disponibilité + signal Rx (historique device_cache, passif) -->
|
||||
<template v-else-if="wanHistory.length">
|
||||
<div class="wan-strip">
|
||||
<span v-for="(p, i) in wanHistory" :key="i" class="wan-tick" :style="{ background: p.online ? '#16a34a' : '#dc2626' }"><q-tooltip>{{ fmtTs(p.ts) }} — {{ p.online ? 'en ligne' : 'hors ligne' }}{{ p.rxPower != null ? ' · ' + p.rxPower + ' dBm' : '' }}</q-tooltip></span>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs">Dispo {{ uptimePct }}% · Rx {{ lastRx }} · {{ wanHistory.length }} pts</div>
|
||||
</template>
|
||||
<div v-else class="dv-wan-ph"><q-icon name="insights" size="18px" color="grey-4" /> historique en accumulation</div>
|
||||
</div>
|
||||
<div class="dv-events" v-if="events.length">
|
||||
<div class="dv-tl"><q-icon name="history" size="14px" /> Événements récents</div>
|
||||
<div v-for="e in events.slice(0, 4)" :key="e.id" class="dv-ev"><q-icon :name="evIcon(e)" size="13px" :color="evColor(e)" /> <span class="text-grey-6">{{ evTime(e) }}</span> · {{ evLabel(e) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identité de l'appareil (repliée par défaut) -->
|
||||
<div class="collapse-head" @click="showIdentity = !showIdentity"><q-icon :name="showIdentity ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Identité de l'appareil</div>
|
||||
<div v-show="showIdentity || !device" class="modal-field-grid">
|
||||
<div v-for="f in equipFields" :key="f.field" class="mf">
|
||||
<span class="mf-label">{{ f.label }}</span>
|
||||
<InlineField :value="doc[f.field]" :field="f.field" doctype="Service Equipment" :docname="docName"
|
||||
|
|
@ -9,24 +77,8 @@
|
|||
</div>
|
||||
|
||||
<div v-if="device" class="q-mt-md">
|
||||
<div class="info-block-title">
|
||||
Diagnostic en direct
|
||||
<q-badge :color="statusBadgeColor" class="q-ml-sm" :class="{ 'acs-badge-pulse': status.source === 'unknown' }">
|
||||
{{ status.label }}
|
||||
<q-tooltip>{{ status.detail }}</q-tooltip>
|
||||
</q-badge>
|
||||
<q-badge v-if="status.source === 'olt' && status.online" outline color="amber-8" class="q-ml-xs" style="font-size:0.65rem;">
|
||||
<q-icon name="info" size="10px" class="q-mr-xs" />TR-069 inactif
|
||||
<q-tooltip>Le modem est en ligne (confirmé par l'OLT) mais le canal de gestion TR-069 ne répond pas. Le client a internet.</q-tooltip>
|
||||
</q-badge>
|
||||
<q-badge v-if="status.source === 'tr069' && !status.online" outline color="orange-8" class="q-ml-xs" style="font-size:0.65rem;">
|
||||
OLT non vérifié
|
||||
<q-tooltip>Statut basé uniquement sur TR-069. Vérification OLT non disponible.</q-tooltip>
|
||||
</q-badge>
|
||||
<span v-if="device.lastInform" class="text-caption text-grey-5 q-ml-sm">{{ timeAgo(device.lastInform) }}</span>
|
||||
<q-btn flat dense size="sm" icon="sync" class="q-ml-sm" :loading="refreshing" @click="doRefresh" title="Rafraichir" />
|
||||
</div>
|
||||
|
||||
<div class="collapse-head" @click="showTech = !showTech"><q-icon :name="showTech ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />Détails techniques<span class="text-caption text-grey-5 q-ml-xs">fibre · IP · WiFi · Ethernet · firmware</span></div>
|
||||
<div v-show="showTech">
|
||||
<div class="diag-section fibre-top">
|
||||
<q-icon name="settings_input_hdmi" size="16px" class="q-mr-xs" />
|
||||
<span class="text-weight-bold q-mr-sm">Fibre</span>
|
||||
|
|
@ -118,14 +170,17 @@
|
|||
<div class="diag-item" v-if="device.uptime"><span class="diag-label">Uptime</span>{{ formatUptime(device.uptime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diag-section">
|
||||
<div class="diag-section-title">
|
||||
<div class="diag-section-title collapse-head" @click="showHosts = !showHosts">
|
||||
<q-icon :name="showHosts ? 'expand_more' : 'chevron_right'" size="18px" class="q-mr-xs" />
|
||||
<q-icon name="devices" size="16px" class="q-mr-xs" />
|
||||
Appareils connectes
|
||||
Appareils connectés
|
||||
<span v-if="hosts" class="text-caption text-grey-5 q-ml-xs">({{ hosts.total }})</span>
|
||||
<q-btn flat dense size="sm" icon="refresh" class="q-ml-sm" :loading="hostsLoading" @click="loadHosts(true)" title="Rafraichir la liste" />
|
||||
<q-btn flat dense size="sm" icon="refresh" class="q-ml-sm" :loading="hostsLoading" @click.stop="loadHosts(true)" title="Rafraichir la liste" />
|
||||
</div>
|
||||
<div v-show="showHosts">
|
||||
<div v-if="hostsLoading" class="text-caption text-grey-5 q-py-xs">
|
||||
<q-spinner size="14px" class="q-mr-xs" /> Interrogation de l'equipement...
|
||||
</div>
|
||||
|
|
@ -164,14 +219,16 @@
|
|||
</div>
|
||||
</div>
|
||||
<div v-else-if="hosts" class="text-caption text-grey-5 q-py-xs">Aucun appareil</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="doc.serial_number && !device" class="q-mt-md text-caption text-grey-5">
|
||||
<q-spinner size="14px" class="q-mr-xs" v-if="deviceLoading" /> Chargement diagnostic ACS...
|
||||
</div>
|
||||
|
||||
<!-- ── Advanced WiFi Diagnostic Panel ── -->
|
||||
<div v-if="managementIp || props.doc?.serial_number" class="q-mt-md">
|
||||
<!-- ── Diagnostic modem avancé (modem-bridge / Playwright) — DÉSACTIVÉ « pour le moment » : trop lourd (~15 s).
|
||||
Réactiver : remettre v-if="managementIp || props.doc?.serial_number". Le diagnostic GenieACS/OLT + liste d'appareils suffisent. ── -->
|
||||
<div v-if="false" class="q-mt-md">
|
||||
<div class="info-block-title">
|
||||
<q-icon name="wifi_find" size="16px" class="q-mr-xs" />
|
||||
Diagnostic modem
|
||||
|
|
@ -447,12 +504,13 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
|
||||
import { deleteDoc } from 'src/api/erp'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
const props = defineProps({ doc: { type: Object, required: true }, docName: String })
|
||||
const emit = defineEmits(['deleted'])
|
||||
|
|
@ -690,6 +748,84 @@ function formatUptime (seconds) {
|
|||
return d > 0 ? `${d}j ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
// Épuré : sections denses repliées par défaut (l'essentiel est dans le résumé en tête).
|
||||
const showIdentity = ref(false)
|
||||
const showTech = ref(false)
|
||||
const showHosts = ref(false)
|
||||
const clientCount = computed(() => (hosts.value && hosts.value.total != null) ? hosts.value.total : (device.value?.wifi?.totalClients ?? 0))
|
||||
const SIG_LBL = { excellent: 'excellent', good: 'bon', fair: 'faible', bad: 'critique' }
|
||||
const signalLabel = computed(() => SIG_LBL[signalQuality(props.doc.serial_number)] || '')
|
||||
const signalBadgeColor = computed(() => { const s = signalQuality(props.doc.serial_number); return (s === 'excellent' || s === 'good') ? 'green' : s === 'fair' ? 'orange' : s === 'bad' ? 'red' : 'grey' })
|
||||
const oltLabel = computed(() => props.doc.olt_name || props.doc.olt_ip || 'OLT')
|
||||
// Événements récents (register / dying-gasp / online-offline) — depuis nos device_events (le poller les journalise déjà).
|
||||
const events = ref([])
|
||||
async function loadEvents () {
|
||||
if (!props.doc.serial_number) return
|
||||
try { const r = await fetch(`${HUB_URL}/devices/events?serial=${encodeURIComponent(props.doc.serial_number)}&limit=8`); if (r.ok) events.value = await r.json() } catch (e) { /* */ }
|
||||
}
|
||||
const lastOutage = computed(() => { const o = events.value.find(e => e.event === 'offline'); return o ? (o.reason || 'coupure') : null })
|
||||
function evIcon (e) { return e.event === 'offline' ? 'power_off' : e.event === 'online' ? 'check_circle' : 'fiber_manual_record' }
|
||||
function evColor (e) { return e.event === 'offline' ? 'orange-7' : e.event === 'online' ? 'green-6' : 'grey-5' }
|
||||
function evLabel (e) { return e.event === 'offline' ? ('Hors ligne' + (e.reason ? ' · ' + e.reason : '')) : e.event === 'online' ? 'En ligne' : (e.event || '') }
|
||||
function evTime (e) { try { return new Date(e.created_at).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) } catch (x) { return '' } }
|
||||
// Graphe : disponibilité + signal Rx récents, depuis l'historique device_cache (vraie data ; le débit Mbps façon F = source ES, plus tard).
|
||||
const wanHistory = ref([])
|
||||
async function loadWanHistory () {
|
||||
if (!props.doc.serial_number) return
|
||||
try { const r = await fetch(`${HUB_URL}/devices/cache?serial=${encodeURIComponent(props.doc.serial_number)}`); if (r.ok) { const d = await r.json(); wanHistory.value = (Array.isArray(d.history) ? d.history : []).slice(-48) } } catch (e) { /* */ }
|
||||
}
|
||||
const uptimePct = computed(() => { const h = wanHistory.value; if (!h.length) return null; return Math.round(h.filter(p => p.online).length / h.length * 100) })
|
||||
const lastRx = computed(() => { for (let i = wanHistory.value.length - 1; i >= 0; i--) { if (wanHistory.value[i].rxPower != null) return wanHistory.value[i].rxPower + ' dBm' } return '—' })
|
||||
function fmtTs (ts) { try { return new Date(ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }) } catch (e) { return '' } }
|
||||
|
||||
// Débit WAN EN DIRECT : mirroir de raisecom_rcmg_bandwith.php de F. Le hub lit les compteurs
|
||||
// d'octets Counter64 de l'OLT (Raisecom/tech-2) ; ici on échantillonne toutes les 5 s et on
|
||||
// calcule le débit = (Δoctets / Δs) × 8 (façon F). À la demande seulement (charge OLT).
|
||||
const wanLive = ref(false)
|
||||
const wanLiveErr = ref('') // '' | 'unsupported' | 'error' | autre raison
|
||||
const wanRates = ref([]) // [{ dl, ul, ts }] bits/s, ~40 derniers points
|
||||
let wanTimer = null
|
||||
let wanPrev = null // dernier échantillon brut pour le delta
|
||||
const WAN_ERR_MSG = {
|
||||
unsupported_olt_type: 'Débit en direct indisponible — OLT TP-Link (tech 3). Seules les OLT Raisecom exposent ce compteur.',
|
||||
onu_not_resolved: 'ONU absent du sondage OLT en ce moment (OLT hors supervision ?). Débit en direct indisponible.',
|
||||
no_counter: 'Aucun compteur de débit pour cet ONU sur l’OLT.',
|
||||
snmp_unavailable: 'Sonde SNMP indisponible.',
|
||||
}
|
||||
const wanErrMsg = computed(() => WAN_ERR_MSG[wanLiveErr.value] || (wanLiveErr.value ? 'Mesure indisponible pour le moment.' : ''))
|
||||
const fmtBps = (bps) => (bps == null) ? '—' : (bps >= 1e6 ? (bps / 1e6).toFixed(1) + ' Mbps' : (bps / 1e3).toFixed(0) + ' kbps')
|
||||
const wanNow = computed(() => wanRates.value.length ? wanRates.value[wanRates.value.length - 1] : null)
|
||||
const wanMax = computed(() => { let m = 1; for (const r of wanRates.value) { if (r.dl > m) m = r.dl; if (r.ul > m) m = r.ul } return m })
|
||||
function wanLine (key) {
|
||||
const r = wanRates.value; if (r.length < 2) return ''
|
||||
const max = wanMax.value, W = 220, H = 38, n = r.length
|
||||
return r.map((p, i) => `${((i / (n - 1)) * W).toFixed(1)},${(H - ((p[key] || 0) / max) * H).toFixed(1)}`).join(' ')
|
||||
}
|
||||
async function wanPoll () {
|
||||
if (!props.doc.serial_number) return
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/collab/wan-rate?serial=${encodeURIComponent(props.doc.serial_number)}`)
|
||||
if (!r.ok) { wanLiveErr.value = 'error'; stopWanTimer(); return }
|
||||
const d = await r.json()
|
||||
// Indisponible (OLT non Raisecom, ONU non résolu, pas de compteur) = état non transitoire → on affiche le motif et on ARRÊTE de sonder (pas de spinner infini).
|
||||
if (!d.available) { wanLiveErr.value = d.reason || 'error'; stopWanTimer(); return }
|
||||
wanLiveErr.value = ''
|
||||
const cur = { ts: d.ts, rx: d.rxOctets, tx: d.txOctets }
|
||||
if (wanPrev) {
|
||||
const sec = (cur.ts - wanPrev.ts) / 1000
|
||||
const delta = (a, b) => { if (a == null || b == null) return null; let x = b - a; if (x < 0) x = 0; return sec > 0 ? (x / sec) * 8 : null }
|
||||
wanRates.value = [...wanRates.value, { dl: delta(wanPrev.rx, cur.rx), ul: delta(wanPrev.tx, cur.tx), ts: cur.ts }].slice(-40)
|
||||
}
|
||||
wanPrev = cur
|
||||
} catch (e) { wanLiveErr.value = 'error'; stopWanTimer() }
|
||||
}
|
||||
function startWanLive () { wanLive.value = true; wanLiveErr.value = ''; wanRates.value = []; wanPrev = null; wanPoll(); wanTimer = setInterval(wanPoll, 3000) }
|
||||
function stopWanTimer () { if (wanTimer) { clearInterval(wanTimer); wanTimer = null } }
|
||||
function stopWanLive () { stopWanTimer(); wanLive.value = false; wanLiveErr.value = '' }
|
||||
function toggleWanLive () { wanLive.value ? stopWanLive() : startWanLive() }
|
||||
// Liste d'appareils au clic sur l'icône : on déplie → la version EN CACHE s'affiche vite (le hub sert le cache par défaut) + anneau de chargement pendant le rafraîchissement.
|
||||
function openHosts () { showHosts.value = true; if (!hosts.value && device.value) loadHosts() }
|
||||
|
||||
async function doRefresh () {
|
||||
if (!props.doc.serial_number) return
|
||||
refreshing.value = true
|
||||
|
|
@ -705,17 +841,23 @@ onMounted(async () => {
|
|||
fetchStatus([{ serial_number: props.doc.serial_number }])
|
||||
fetchOltStatus(props.doc.serial_number)
|
||||
fetchPortContext(props.doc.serial_number).then(ctx => { portCtx.value = ctx })
|
||||
loadEvents()
|
||||
loadWanHistory()
|
||||
}
|
||||
})
|
||||
|
||||
watch(device, d => { if (d && !hosts.value) loadHosts() }, { immediate: true })
|
||||
// Chargement PARESSEUX : on n'interroge le modem (modem-bridge) que si l'agent déplie « Appareils connectés ».
|
||||
watch(showHosts, v => { if (v && !hosts.value && device.value) loadHosts() })
|
||||
watch(() => props.doc.serial_number, sn => {
|
||||
stopWanLive() // arrêter la mesure live de l'appareil précédent
|
||||
if (sn) {
|
||||
hosts.value = null
|
||||
fetchStatus([{ serial_number: sn }])
|
||||
fetchOltStatus(sn)
|
||||
loadEvents(); loadWanHistory() // rafraîchir la bande dispo/événements pour le nouvel appareil
|
||||
}
|
||||
})
|
||||
onUnmounted(() => stopWanLive())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -724,6 +866,35 @@ watch(() => props.doc.serial_number, sn => {
|
|||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.topo-summary { border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px; background: #f8fafc; margin-bottom: 10px; }
|
||||
.topo-row { display: flex; align-items: flex-start; justify-content: center; gap: 0; }
|
||||
.topo-node { display: flex; flex-direction: column; align-items: center; width: 90px; }
|
||||
.topo-modem { position: relative; }
|
||||
.topo-dot { position: absolute; right: -2px; bottom: 2px; width: 9px; height: 9px; border-radius: 999px; border: 2px solid #f8fafc; }
|
||||
.topo-dot.on { background: #21BA45; } .topo-dot.off { background: #C10015; }
|
||||
.topo-cap { font-size: 0.72rem; color: #64748b; margin-top: 3px; text-align: center; }
|
||||
.topo-link { flex: 1; max-width: 56px; height: 2px; background: #cbd5e1; margin-top: 12px; }
|
||||
.topo-link.ok { background: #21BA45; opacity: .55; }
|
||||
.topo-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.topo-sub { font-size: 0.66rem; color: #94a3b8; }
|
||||
.dv-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 10px; }
|
||||
.dv-tile { border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; }
|
||||
.dv-tl { font-size: 0.68rem; color: #64748b; display: flex; align-items: center; gap: 3px; }
|
||||
.dv-tl.dv-warn { color: #b45309; }
|
||||
.dv-tv { font-size: 0.84rem; font-weight: 500; color: #1e293b; margin-top: 2px; }
|
||||
.dv-link { color: #4f46e5; text-decoration: none; }
|
||||
.dv-row2 { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.dv-wan, .dv-events { flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; padding: 7px 9px; background: #fff; }
|
||||
.dv-wan-ph { font-size: 0.78rem; color: #94a3b8; padding: 10px 0; text-align: center; }
|
||||
.dv-ev { font-size: 0.74rem; color: #334155; padding: 2px 0; }
|
||||
.topo-click { cursor: pointer; border-radius: 8px; transition: background .12s; }
|
||||
.topo-click:hover { background: #eef2ff; }
|
||||
.wan-strip { display: flex; gap: 2px; align-items: flex-end; height: 22px; margin-top: 4px; }
|
||||
.wan-tick { flex: 1; min-width: 2px; height: 100%; border-radius: 1px; opacity: .85; }
|
||||
.dv-wan-btn { float: right; margin-top: -2px; }
|
||||
.wan-spark { width: 100%; height: 38px; display: block; margin-top: 4px; }
|
||||
.collapse-head { display: flex; align-items: center; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #475569; padding: 6px 2px; border-top: 1px solid #f1f5f9; user-select: none; }
|
||||
.collapse-head:hover { color: #1e293b; }
|
||||
.diag-section { margin-top: 8px; }
|
||||
.fibre-top { display: flex; align-items: center; font-size: 0.84rem; padding: 6px 8px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||||
.diag-section-title { font-size: 0.78rem; font-weight: 600; color: #4b5563; margin-bottom: 4px; display: flex; align-items: center; }
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ import { deleteDoc, getDoc, updateDoc } from 'src/api/erp'
|
|||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
|
||||
const HUB_URL = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
||||
const HUB_URL = location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
||||
|
||||
const props = defineProps({
|
||||
doc: { type: Object, required: true },
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function useClientData (deps) {
|
|||
fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
|
||||
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
|
||||
'contract_duration', 'product_sku', 'speed_down', 'speed_up',
|
||||
'radius_user', 'device', 'display_order'],
|
||||
'radius_user', 'device', 'display_order', 'legacy_service_id'],
|
||||
limit: 100,
|
||||
// display_order first (dispatcher-controlled), then by start date so
|
||||
// newer subs float up when display_order hasn't been set yet (= 0).
|
||||
|
|
@ -110,6 +110,7 @@ export function useClientData (deps) {
|
|||
radius_pwd: '',
|
||||
device: doc.device || '',
|
||||
display_order: Number(doc.display_order || 0),
|
||||
legacy_service_id: Number(doc.legacy_service_id || 0), // = service.id de F (0 = ajouté dans ERPNext, pas dans F)
|
||||
qty: 1,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,3 +135,56 @@ export function noteTimeAgo (d) {
|
|||
if (days < 7) return `Il y a ${days}j`
|
||||
return formatDate(d)
|
||||
}
|
||||
|
||||
// ═══ Canonical helpers (consolidation target — see docs/UI_AND_OPTIMIZATION.md §D) ═══
|
||||
|
||||
/**
|
||||
* Email → short agent handle (local part). Canonical replacement for the ~5 private
|
||||
* `shortAgent` copies across conversation/board components.
|
||||
* @param {string|null} email
|
||||
* @returns {string}
|
||||
*/
|
||||
export function shortAgent (email) {
|
||||
return String(email || '').split('@')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact time of day "9h05" (fr). Used by tech list/cards.
|
||||
* @param {string|number|Date|null} t
|
||||
* @returns {string}
|
||||
*/
|
||||
export function fmtTimeHHhMM (t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return `${d.getHours()}h${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative timestamp: today → "14:32", otherwise → "13/06" (fr-CA).
|
||||
* Canonical target for the divergent inbox/board `formatTime` copies.
|
||||
* @param {string|number|Date|null} ts
|
||||
* @returns {string}
|
||||
*/
|
||||
export function relTime (ts) {
|
||||
if (!ts) return ''
|
||||
const dt = new Date(ts)
|
||||
if (isNaN(dt.getTime())) return ''
|
||||
const now = new Date()
|
||||
return dt.toDateString() === now.toDateString()
|
||||
? dt.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
||||
: dt.toLocaleDateString('fr-CA', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Short date+time, e.g. "2026-06-13 14:32" (fr-CA). Accepts ISO or "YYYY-MM-DD HH:MM:SS".
|
||||
* Canonical target for the ~6 `fmtTime`/`fmt`/`fmtTs` copies. Returns '' on empty/invalid.
|
||||
* @param {string|number|Date|null} d
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTimeShort (d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(String(d).replace(' ', 'T'))
|
||||
if (isNaN(dt.getTime())) return ''
|
||||
return dt.toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
|
|
|||
39
apps/ops/src/config/departments.js
Normal file
39
apps/ops/src/config/departments.js
Normal 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)
|
||||
}
|
||||
|
|
@ -5,9 +5,14 @@
|
|||
* import { HUB_URL } from 'src/config/hub'
|
||||
*
|
||||
* In dev (localhost), points to local targo-hub instance.
|
||||
* In production, points to msg.gigafibre.ca.
|
||||
* In production, uses the SAME-ORIGIN proxy: /ops/hub/* → Traefik strips /ops
|
||||
* → ops-frontend nginx `location /hub/` injects the hub service token (Bearer)
|
||||
* → targo-hub. This keeps the token server-side (never in the JS bundle), lets
|
||||
* EventSource (/sse, /conversations) work, and rides the existing Authentik
|
||||
* forward-auth on /ops. Mirrors the /ops/api → ERPNext pattern (config/erpnext.js).
|
||||
*
|
||||
* Can also be overridden via VITE_HUB_URL env variable.
|
||||
*/
|
||||
const _opsBase = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') // '' in dev, '/ops' in prod
|
||||
export const HUB_URL = import.meta.env.VITE_HUB_URL
|
||||
|| (window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca')
|
||||
|| (window.location.hostname === 'localhost' ? 'http://localhost:3300' : _opsBase + '/hub')
|
||||
|
|
|
|||
39
apps/ops/src/features/workforce/api/assignment.js
Normal file
39
apps/ops/src/features/workforce/api/assignment.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* API d'assignation UNIFIÉE — chemin d'écriture UNIQUE (via targo-hub).
|
||||
* -----------------------------------------------------------------------------
|
||||
* CONVERGENCE Dispatch ⇄ Planification : l'assignation job→tech
|
||||
* (`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 })
|
||||
62
apps/ops/src/features/workforce/composables/useAssignment.js
Normal file
62
apps/ops/src/features/workforce/composables/useAssignment.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* useAssignment — composable d'assignation UNIFIÉE (le SEUL point d'écriture
|
||||
* job→tech, 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 }
|
||||
}
|
||||
93
apps/ops/src/features/workforce/composables/useResources.js
Normal file
93
apps/ops/src/features/workforce/composables/useResources.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ import { Notify } from 'quasar'
|
|||
import { publishJobs } from 'src/api/dispatch'
|
||||
import { sendTestSms } from 'src/api/sms'
|
||||
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
||||
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
||||
|
||||
async function getMagicLink (techId) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Notify } from 'quasar'
|
|||
|
||||
const HUB_URL = window.location.hostname === 'localhost'
|
||||
? 'http://localhost:3300'
|
||||
: 'https://msg.gigafibre.ca'
|
||||
: (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@
|
|||
<div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)">
|
||||
<div class="row items-center no-wrap q-gutter-x-sm">
|
||||
<code class="sub-sku">{{ sub.name }}</code>
|
||||
<!-- Lien legacy F : legacy_service_id = service.id de F (0 = ajouté dans ERPNext, absent de F) -->
|
||||
<q-badge v-if="sub.legacy_service_id > 0" color="blue-grey-1" text-color="blue-grey-8" :label="'F #' + sub.legacy_service_id"><q-tooltip>Service F #{{ sub.legacy_service_id }} (corrélé)</q-tooltip></q-badge>
|
||||
<q-badge v-else color="red-2" text-color="red-9" label="OPS"><q-tooltip>Ajouté dans ERPNext — absent de F (pas de legacy_service_id)</q-tooltip></q-badge>
|
||||
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
|
||||
<!-- Inline price edit: double-click to edit. Negative values kept as-is for rebates. -->
|
||||
<span class="sub-price-wrap" @click.stop @dblclick.stop
|
||||
|
|
@ -266,7 +269,12 @@
|
|||
|
||||
<div class="sub-total-line">
|
||||
<span class="sub-total-label">Total mensuel</span>
|
||||
<span class="sub-total-amount">{{ formatMoney(locSubsMonthlyTotal(loc.name)) }} /mois</span>
|
||||
<span class="sub-total-amount" :class="{ 'sub-total-neg': locSubsMonthlyTotal(loc.name) < 0 }">{{ formatMoney(locSubsMonthlyTotal(loc.name)) }} /mois</span>
|
||||
</div>
|
||||
<!-- Garde-fou : un abonnement récurrent ne peut pas être négatif (rabais > frais). On NE corrige PAS (facturation = F autoritaire) : on signale pour correction dans F. -->
|
||||
<div v-if="locSubsMonthlyTotal(loc.name) < 0" class="sub-total-warning">
|
||||
<q-icon name="error" size="15px" color="red-6" class="q-mr-xs" />
|
||||
<span>Total récurrent négatif — les rabais dépassent les frais. Vérifier/corriger les rabais dans la facturation (F).</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -1630,6 +1638,8 @@ watch(() => route.query?.location, () => focusLocationFromQuery())
|
|||
.sub-total-label { font-size: 0.85rem; font-weight: 700; text-transform: uppercase; color: #334155; }
|
||||
.sub-total-amount { font-size: 1rem; font-weight: 800; color: var(--ops-accent); min-width: 100px; text-align: right; }
|
||||
.sub-total-line.annual { border-top: 2px solid #92400e; .sub-total-label { color: #92400e; } .sub-total-amount { color: #92400e; } }
|
||||
.sub-total-neg { color: #dc2626 !important; }
|
||||
.sub-total-warning { display: flex; align-items: center; justify-content: flex-end; gap: 4px; font-size: 0.78rem; color: #b91c1c; padding: 2px 0 6px; text-align: right; }
|
||||
.sub-annual-divider { display: flex; align-items: center; gap: 6px; margin-top: 16px; padding: 8px 0 4px; border-top: 1px dashed #d97706; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #92400e; }
|
||||
.sub-section-header { display: flex; align-items: center; gap: 4px; padding: 6px 0 4px; cursor: pointer; user-select: none; &:hover { background: #f8fafc; } }
|
||||
.sub-section-title { font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: #334155; }
|
||||
|
|
|
|||
13
apps/ops/src/pages/CommunicationsPage.vue
Normal file
13
apps/ops/src/pages/CommunicationsPage.vue
Normal 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>
|
||||
39
apps/ops/src/pages/ConversationFullPage.vue
Normal file
39
apps/ops/src/pages/ConversationFullPage.vue
Normal 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>
|
||||
316
apps/ops/src/pages/LegacySyncPage.vue
Normal file
316
apps/ops/src/pages/LegacySyncPage.vue
Normal 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)} dû`, 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>
|
||||
13
apps/ops/src/pages/PipelinePage.vue
Normal file
13
apps/ops/src/pages/PipelinePage.vue
Normal 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
|
|
@ -70,6 +70,20 @@ const financeReports = [
|
|||
color: 'deep-orange-6',
|
||||
route: '/rapports/internet-cher',
|
||||
},
|
||||
{
|
||||
title: 'Factures récurrentes négatives',
|
||||
description: 'Comptes dont le total récurrent mensuel est négatif (rabais > frais) — anomalies de données à corriger. F reste autoritaire.',
|
||||
icon: 'error',
|
||||
color: 'red-7',
|
||||
route: '/rapports/factures-negatives',
|
||||
},
|
||||
{
|
||||
title: 'Services actifs sur comptes résiliés',
|
||||
description: 'Comptes terminés dans F qui gardent des services récurrents actifs (charges + crédits). Liste de ménage — désactiver au niveau service (sauf employés à internet fourni).',
|
||||
icon: 'cleaning_services',
|
||||
color: 'blue-7',
|
||||
route: '/rapports/resilies-actifs',
|
||||
},
|
||||
]
|
||||
|
||||
const opsReports = [
|
||||
|
|
|
|||
171
apps/ops/src/pages/ReportNegativeBillingPage.vue
Normal file
171
apps/ops/src/pages/ReportNegativeBillingPage.vue
Normal 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>
|
||||
130
apps/ops/src/pages/ReportTerminatedActivePage.vue
Normal file
130
apps/ops/src/pages/ReportTerminatedActivePage.vue
Normal 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>
|
||||
|
|
@ -380,15 +380,14 @@
|
|||
<q-spinner size="14px" class="q-mr-xs" /> Chargement...
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs"
|
||||
style="border-bottom:1px solid #f1f5f9">
|
||||
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs q-px-sm member-row">
|
||||
<q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem">
|
||||
{{ initial(m) }}
|
||||
</q-avatar>
|
||||
<span class="text-body2">{{ m.name || m.username }}</span>
|
||||
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="person_remove" size="sm" color="red-4"
|
||||
<q-btn flat round dense icon="person_remove" size="sm" color="red-5" class="member-row-del"
|
||||
@click="removeFromGroup(m, selectedGroup)" title="Retirer du groupe">
|
||||
<q-tooltip>Retirer du groupe</q-tooltip>
|
||||
</q-btn>
|
||||
|
|
@ -730,6 +729,15 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modèles IA — routage par tâche + repli Gemini -->
|
||||
<AiRoutingCard v-if="can('manage_settings')" class="q-mt-md" />
|
||||
|
||||
<!-- Files (Inbox) & équipes — gestion des membres (même logique que les groupes de droits) -->
|
||||
<QueueTeamsCard class="q-mt-md" />
|
||||
|
||||
<!-- Réponses pré-écrites (canned) — réutilisables dans l'Inbox -->
|
||||
<CannedResponsesCard class="q-mt-md" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -743,6 +751,9 @@ import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/compos
|
|||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
|
||||
import { useUserGroups } from 'src/composables/useUserGroups'
|
||||
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
||||
import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue'
|
||||
import AiRoutingCard from 'src/components/settings/AiRoutingCard.vue'
|
||||
import { useLegacySync } from 'src/composables/useLegacySync'
|
||||
|
||||
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue'))
|
||||
|
|
@ -978,4 +989,9 @@ async function testSms () {
|
|||
}
|
||||
.member-search-item:last-child { border-bottom: none; }
|
||||
.member-search-item:hover { background: #f1f5f9; }
|
||||
/* Surlignage de la ligne membre au survol → on vise sans erreur le bon bouton « Retirer » dans une longue liste */
|
||||
.member-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
|
||||
.member-row:hover { background: #eff3ff; box-shadow: inset 3px 0 0 #6366f1; }
|
||||
.member-row .member-row-del { opacity: .45; transition: opacity .1s; }
|
||||
.member-row:hover .member-row-del { opacity: 1; }
|
||||
</style>
|
||||
|
|
|
|||
160
apps/ops/src/pages/SupplierInvoicesPage.vue
Normal file
160
apps/ops/src/pages/SupplierInvoicesPage.vue
Normal 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>
|
||||
|
|
@ -140,12 +140,18 @@
|
|||
<template v-if="modalTicket?.customer_name"> · {{ modalTicket.customer_name }}</template>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<q-btn flat dense no-caps icon="forum" color="indigo-6" label="Intervenir" class="q-mr-xs"
|
||||
@click="openIntervene(modalTicket)">
|
||||
<q-tooltip>Note / mention sans changer l'assignation</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="modalTicket?.customer" flat dense round icon="person" class="q-mr-xs"
|
||||
@click="$router.push('/clients/' + modalTicket.customer); modalOpen = false">
|
||||
<q-tooltip>Voir le client</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</DetailModal>
|
||||
|
||||
<InterveneDialog v-model="intervene.open" :name="intervene.name" :title="intervene.title" doctype="Issue" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -156,6 +162,10 @@ import { formatDate } from 'src/composables/useFormatters'
|
|||
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config'
|
||||
import InterveneDialog from 'src/components/shared/InterveneDialog.vue'
|
||||
|
||||
const intervene = ref({ open: false, name: '', title: '' })
|
||||
function openIntervene (t) { if (t) intervene.value = { open: true, name: t.name, title: t.subject || t.name } }
|
||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
|
|
|
|||
395
docs/PLATFORM_GUIDE.md
Normal file
395
docs/PLATFORM_GUIDE.md
Normal 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
136
docs/UI_AND_OPTIMIZATION.md
Normal 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/`.
|
||||
|
|
@ -24,6 +24,7 @@ def create_all():
|
|||
_create_job_checklist_item()
|
||||
_create_job_photo()
|
||||
_create_checklist_template()
|
||||
_create_dispatch_job_assistant()
|
||||
_extend_dispatch_job()
|
||||
_create_contract_benefit()
|
||||
_create_service_contract()
|
||||
|
|
@ -397,6 +398,33 @@ def _create_checklist_template():
|
|||
print(" ✓ Checklist Template created.")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Dispatch Job Assistant — child table : équipe (lead + assistants) sur une tâche
|
||||
# Déjà présent sur le site live (créé hors repo) — versionné ici pour reproductibilité.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_dispatch_job_assistant():
|
||||
"""Child table for crew/assistants on a Dispatch Job (le lead = assigned_tech)."""
|
||||
if frappe.db.exists("DocType", "Dispatch Job Assistant"):
|
||||
print(" Dispatch Job Assistant already exists — skipping.")
|
||||
return
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "DocType", "name": "Dispatch Job Assistant",
|
||||
"module": "Dispatch", "custom": 1, "istable": 1,
|
||||
"fields": [
|
||||
{"fieldname": "tech_id", "fieldtype": "Data", "label": "Technicien (id)", "in_list_view": 1},
|
||||
{"fieldname": "tech_name", "fieldtype": "Data", "label": "Nom", "in_list_view": 1},
|
||||
{"fieldname": "duration_h", "fieldtype": "Float", "label": "Durée (h)", "in_list_view": 1,
|
||||
"description": "Temps de l'assistant sur la tâche (peut être < la durée totale)"},
|
||||
{"fieldname": "note", "fieldtype": "Data", "label": "Rôle / note"},
|
||||
{"fieldname": "pinned", "fieldtype": "Check", "label": "Épinglé", "default": "0",
|
||||
"description": "Si coché : bloque la lane de l'assistant + compte dans sa charge (anti double-booking)"},
|
||||
],
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(" ✓ Dispatch Job Assistant created.")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Extend Dispatch Job — Add FSM fields to existing doctype
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -429,6 +457,10 @@ def _extend_dispatch_job():
|
|||
{"dt": "Dispatch Job", "fieldname": "step_order", "fieldtype": "Int",
|
||||
"label": "Ordre d'étape", "insert_after": "on_close_webhook",
|
||||
"description": "Position dans la séquence du projet (1, 2, 3...)"},
|
||||
# Équipe / assistants (le LEAD reste assigned_tech ; assistants = renfort)
|
||||
{"dt": "Dispatch Job", "fieldname": "assistants", "fieldtype": "Table",
|
||||
"label": "Assistants (équipe)", "options": "Dispatch Job Assistant", "insert_after": "step_order",
|
||||
"description": "Techniciens en renfort sur la tâche ; le responsable reste « assigned_tech »"},
|
||||
# Equipment
|
||||
{"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break",
|
||||
"label": "Équipements", "insert_after": "tags"},
|
||||
|
|
|
|||
69
erpnext/setup_supplier_invoice.py
Normal file
69
erpnext/setup_supplier_invoice.py
Normal 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éé.")
|
||||
233
scripts/migration/sync_invoices_incremental.py
Normal file
233
scripts/migration/sync_invoices_incremental.py
Normal 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, SKU→compte 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()
|
||||
228
scripts/migration/sync_services_incremental.py
Normal file
228
scripts/migration/sync_services_incremental.py
Normal 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()
|
||||
|
|
@ -62,6 +62,78 @@ if ($action === 'batch_close') {
|
|||
out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]);
|
||||
}
|
||||
|
||||
// ─── LECTURE / MONITORING (sync F→OPS frais — le miroir local peut être en retard) ───
|
||||
// Toutes les valeurs numériques sont (int)-castées → interpolation sûre (pas d'injection).
|
||||
// action=ping → fraîcheur + compteurs (max date_last, max ids, heure serveur)
|
||||
// action=changes_since entity=account|delivery|service, since=<epoch date_last>, since_id=<id>, [limit=1000]
|
||||
// → lignes modifiées/nouvelles + max_date_last/max_id (prochain watermark) + has_more (pagination)
|
||||
if ($action === 'ping') {
|
||||
$r = $db->query("SELECT
|
||||
(SELECT MAX(date_last) FROM account) AS max_date_last,
|
||||
(SELECT COUNT(*) FROM account) AS accounts,
|
||||
(SELECT MAX(id) FROM account) AS max_account_id,
|
||||
(SELECT MAX(id) FROM delivery) AS max_delivery_id,
|
||||
(SELECT MAX(id) FROM service) AS max_service_id,
|
||||
UNIX_TIMESTAMP() AS server_time");
|
||||
out(['ok' => true, 'action' => 'ping', 'f' => ($r ? $r->fetch_assoc() : null)]);
|
||||
}
|
||||
|
||||
if ($action === 'changes_since') {
|
||||
$entity = $_POST['entity'] ?? 'account';
|
||||
$since = (int)($_POST['since'] ?? 0); // watermark date_last (epoch)
|
||||
$sinceId = (int)($_POST['since_id'] ?? 0); // watermark id (inserts sans date de modif)
|
||||
$limit = min(5000, max(1, (int)($_POST['limit'] ?? 1000)));
|
||||
if ($entity === 'account') {
|
||||
// incrémental (since>0) : date_last couvre create+update, 1 page large ; full (since=0) : keyset pur sur id
|
||||
$wsql = $since > 0 ? "date_last > $since" : "id > $sinceId";
|
||||
$q = $db->query("SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa, date_orig, date_last
|
||||
FROM account WHERE $wsql ORDER BY id ASC LIMIT $limit");
|
||||
} elseif ($entity === 'delivery') {
|
||||
$q = $db->query("SELECT id, account_id, name, address1, address2, city, state, zip, longitude, latitude, tel_home, cell, email, date_orig
|
||||
FROM delivery WHERE id > $sinceId ORDER BY id ASC LIMIT $limit");
|
||||
} elseif ($entity === 'service') {
|
||||
$q = $db->query("SELECT id, delivery_id, product_id, status, payment_recurrence, date_orig, date_suspended, date_next_invoice, date_end_contract, hijack_price, forfait_internet
|
||||
FROM service WHERE id > $sinceId ORDER BY id ASC LIMIT $limit");
|
||||
} else {
|
||||
out(['ok' => false, 'error' => 'entity inconnue (account|delivery|service)'], 400);
|
||||
}
|
||||
if (!$q) out(['ok' => false, 'error' => 'query: ' . $db->error], 500);
|
||||
$rows = []; $maxTs = $since; $maxId = $sinceId;
|
||||
while ($row = $q->fetch_assoc()) {
|
||||
$rows[] = $row;
|
||||
if (isset($row['date_last'])) $maxTs = max($maxTs, (int)$row['date_last']);
|
||||
$maxId = max($maxId, (int)$row['id']);
|
||||
}
|
||||
out(['ok' => true, 'action' => 'changes_since', 'entity' => $entity, 'count' => count($rows),
|
||||
'max_date_last' => $maxTs, 'max_id' => $maxId, 'has_more' => count($rows) >= $limit, 'rows' => $rows]);
|
||||
}
|
||||
|
||||
// Tickets OUVERTS assignés (terrain) dans une fenêtre de dates → timeline OPS (lecture F LIVE, pas le miroir).
|
||||
if ($action === 'tickets_window') {
|
||||
$lo = (int)($_POST['lo'] ?? 0); $hi = (int)($_POST['hi'] ?? 0);
|
||||
$staff = preg_replace('/[^0-9,]/', '', (string)($_POST['staff'] ?? '')); // CSV d'ids staff (sanitized = anti-injection)
|
||||
if ($staff === '' || $lo <= 0 || $hi <= 0) out(['ok' => false, 'error' => 'lo/hi/staff requis'], 400);
|
||||
$q = $db->query("SELECT t.id, t.subject, t.assign_to, t.due_date, dd.name AS dept
|
||||
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||||
WHERE t.status='open' AND t.assign_to IN ($staff) AND t.due_date BETWEEN $lo AND $hi");
|
||||
if (!$q) out(['ok' => false, 'error' => 'query: ' . $db->error], 500);
|
||||
$rows = []; while ($r = $q->fetch_assoc()) $rows[] = $r;
|
||||
out(['ok' => true, 'action' => 'tickets_window', 'count' => count($rows), 'rows' => $rows]);
|
||||
}
|
||||
|
||||
// État RÉEL d'une LISTE de tickets dans F (réconciliation OPS : aligner les Dispatch Job sur la vérité F).
|
||||
// SELECT-only, aucune limite de statut/dept : on veut savoir si chaque ticket est encore ouvert ET à qui il est assigné.
|
||||
if ($action === 'ticket_status') {
|
||||
$ids = preg_replace('/[^0-9,]/', '', (string)($_POST['ids'] ?? '')); // CSV d'ids ticket (sanitized = anti-injection)
|
||||
if ($ids === '') out(['ok' => true, 'action' => 'ticket_status', 'count' => 0, 'rows' => []]);
|
||||
$q = $db->query("SELECT t.id, t.status, t.assign_to, t.due_date, dd.name AS dept, t.subject
|
||||
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||||
WHERE t.id IN ($ids)");
|
||||
if (!$q) out(['ok' => false, 'error' => 'query: ' . $db->error], 500);
|
||||
$rows = []; while ($r = $q->fetch_assoc()) $rows[] = $r;
|
||||
out(['ok' => true, 'action' => 'ticket_status', 'count' => count($rows), 'rows' => $rows]);
|
||||
}
|
||||
|
||||
// ─── actions sur 1 ticket ───
|
||||
$ticket = (int)($_POST['ticket_id'] ?? 0);
|
||||
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400);
|
||||
|
|
|
|||
71
services/targo-hub/docs/legacy-sync.md
Normal file
71
services/targo-hub/docs/legacy-sync.md
Normal 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).
|
||||
|
|
@ -425,27 +425,10 @@ function buildSystemPrompt () {
|
|||
// Initialize on load
|
||||
loadFlows()
|
||||
|
||||
// Répondeur SMS → routé par tâche 'sms' (PII + volume) via lib/ai.js (repli Gemini auto). Boucle tool-calls inchangée : on ré-emballe en {choices:[{message}]}.
|
||||
const geminiChat = async (messages, tools) => {
|
||||
const url = `${cfg.AI_BASE_URL}chat/completions`
|
||||
const body = { model: cfg.AI_MODEL, max_tokens: 500, messages, tools }
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.status === 429 && attempt < 2) {
|
||||
const delay = (attempt + 1) * 2000
|
||||
log(`Rate limited (429), retrying in ${delay}ms`)
|
||||
await new Promise(r => setTimeout(r, delay))
|
||||
continue
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Gemini API ${res.status}: ${text.substring(0, 200)}`)
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
const msg = await require('./ai').chat({ task: 'sms', messages, tools, maxTokens: 500, wantMessage: true })
|
||||
return { choices: [{ message: msg }] }
|
||||
}
|
||||
|
||||
const runAgent = async (customerId, customerName, messageHistory, { token } = {}) => {
|
||||
|
|
|
|||
|
|
@ -26,78 +26,97 @@
|
|||
*/
|
||||
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||
const { log, json, parseBody, erpFetch, readJsonFile, writeJsonFile } = require('./helpers')
|
||||
|
||||
// ── Core Gemini call (reuses same config as agent.js) ───────────────────────
|
||||
|
||||
async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3 } = {}) {
|
||||
if (!cfg.AI_API_KEY) throw new Error('AI_API_KEY not configured')
|
||||
|
||||
const url = `${cfg.AI_BASE_URL}chat/completions`
|
||||
// Model chain: primary → fallback (handles 503 overload gracefully)
|
||||
const models = [cfg.AI_MODEL, cfg.AI_FALLBACK_MODEL].filter(Boolean)
|
||||
|
||||
for (const model of models) {
|
||||
const body = {
|
||||
model,
|
||||
max_completion_tokens: maxTokens,
|
||||
temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
}
|
||||
if (jsonMode) body.response_format = { type: 'json_object' }
|
||||
|
||||
let modelFailed = false
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.status === 429 && attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, (attempt + 1) * 2000))
|
||||
continue
|
||||
}
|
||||
if (res.status === 503) {
|
||||
log(`AI: ${model} returned 503 (overloaded), attempt ${attempt + 1}/3`)
|
||||
if (attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, (attempt + 1) * 3000))
|
||||
continue
|
||||
}
|
||||
modelFailed = true
|
||||
break
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`AI API ${res.status}: ${text.substring(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const finish = data.choices?.[0]?.finish_reason
|
||||
const content = data.choices?.[0]?.message?.content
|
||||
if (!content) throw new Error('Empty AI response')
|
||||
log(`AI: model=${data.model || model}, finish=${finish}, ${content.length} chars, tokens=${data.usage?.completion_tokens || '?'}/${maxTokens}`)
|
||||
|
||||
if (jsonMode) {
|
||||
try { return JSON.parse(content) }
|
||||
catch {
|
||||
const match = content.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||
if (match) {
|
||||
try { return JSON.parse(match[1].trim()) }
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
return { raw: content.substring(0, 300), error: 'Failed to parse JSON' }
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
if (modelFailed && models.indexOf(model) < models.length - 1) {
|
||||
log(`AI: ${model} unavailable, falling back to ${models[models.indexOf(model) + 1]}`)
|
||||
continue
|
||||
}
|
||||
async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3, task = 'dispatch' } = {}) {
|
||||
// Délègue au routeur par tâche (profils + repli Gemini auto). Conserve le parsing JSON tolérant des appelants existants.
|
||||
const content = await chat({ task, jsonMode, maxTokens, temperature, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }] })
|
||||
if (!content) throw new Error('Empty AI response')
|
||||
if (!jsonMode) return content
|
||||
try { return JSON.parse(content) } catch {
|
||||
const match = content.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||
if (match) { try { return JSON.parse(match[1].trim()) } catch { /* fall through */ } }
|
||||
return { raw: content.substring(0, 300), error: 'Failed to parse JSON' }
|
||||
}
|
||||
throw new Error('AI API: all models unavailable (503 overload). Try again shortly.')
|
||||
}
|
||||
|
||||
// ── Routeur de modèle PAR TÂCHE ─────────────────────────────────────────────
|
||||
// Le plan de CONTRÔLE reste déterministe (scripts) ; ici on ne route que le plan de JUGEMENT (classer/rédiger/suggérer).
|
||||
// gemini = défaut cloud. local = vLLM/Ollama (QWEN/Hermes) — actif seulement si AI_LOCAL_BASE_URL+MODEL définis, sinon repli Gemini.
|
||||
// Routage/local persistés (éditables depuis le panneau Ops) — le FICHIER prime sur l'env. La CLÉ reste en .env (jamais en UI).
|
||||
const ROUTES_FILE = '/app/data/ai_routes.json'
|
||||
function loadRoutesFile () { return readJsonFile(ROUTES_FILE, {}) }
|
||||
function saveRoutesFile (obj) { return writeJsonFile(ROUTES_FILE, obj) }
|
||||
function buildProfiles () {
|
||||
const loc = (loadRoutesFile().local) || {}
|
||||
return {
|
||||
gemini: { name: 'gemini', baseUrl: cfg.AI_BASE_URL, model: cfg.AI_MODEL, fallbackModel: cfg.AI_FALLBACK_MODEL, key: cfg.AI_API_KEY, label: 'Gemini (cloud)' },
|
||||
local: { name: 'local', baseUrl: loc.baseUrl || cfg.AI_LOCAL_BASE_URL, model: loc.model || cfg.AI_LOCAL_MODEL, fallbackModel: '', key: cfg.AI_LOCAL_API_KEY, label: 'Local (vLLM/QWEN)' },
|
||||
}
|
||||
}
|
||||
const profileAvailable = (p) => !!(p && p.baseUrl && p.model && p.key)
|
||||
// Intention de routage par tâche (surchargée par AI_ROUTES env PUIS par le fichier). Défaut: tout gemini.
|
||||
const DEFAULT_ROUTES = { triage: 'gemini', nl: 'gemini', split: 'gemini', sms: 'gemini', roster: 'gemini', dispatch: 'gemini', diagnose: 'gemini', outage: 'gemini', default: 'gemini' }
|
||||
function effectiveRoutes () { return { ...DEFAULT_ROUTES, ...(cfg.AI_ROUTES || {}), ...((loadRoutesFile().routes) || {}) } }
|
||||
function routeFor (task) {
|
||||
const profiles = buildProfiles()
|
||||
let want = effectiveRoutes()[task] || 'gemini'
|
||||
if (!profileAvailable(profiles[want])) want = 'gemini' // profil débranché → repli déterministe sur Gemini
|
||||
return profiles[want] || profiles.gemini
|
||||
}
|
||||
// État du routage (diagnostic + panneau)
|
||||
function routeStatus () {
|
||||
const profiles = buildProfiles()
|
||||
const tasks = Object.keys(DEFAULT_ROUTES).filter(t => t !== 'default')
|
||||
const routes = effectiveRoutes()
|
||||
const routing = {}
|
||||
for (const t of tasks) routing[t] = { wanted: routes[t] || 'gemini', effective: routeFor(t).name } // wanted = choix ; effective = réel (repli si indispo)
|
||||
return {
|
||||
profiles: Object.values(profiles).map(p => ({ name: p.name, label: p.label, model: p.model || null, available: profileAvailable(p), key_set: !!p.key, base: p.baseUrl ? p.baseUrl.replace(/\/+$/, '') : null })),
|
||||
tasks, routing, fallback: 'gemini', local_key_env: 'AI_LOCAL_API_KEY',
|
||||
}
|
||||
}
|
||||
|
||||
// Un appel chat OpenAI-compatible sur un PROFIL donné (un seul modèle), avec retries 429/503. Renvoie le message brut.
|
||||
async function callProfileOnce (profile, model, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) {
|
||||
const body = { model, max_tokens: maxTokens, temperature, messages } // max_tokens = le plus compatible (Gemini-compat + vLLM + Ollama)
|
||||
if (jsonMode) body.response_format = { type: 'json_object' }
|
||||
if (tools) body.tools = tools
|
||||
if (reasoningEffort) body.reasoning_effort = reasoningEffort // gemini-2.5 : 'none' désactive le « thinking » (sinon ses jetons de réflexion mangent max_tokens → réponse tronquée sur un long contexte). Champ ignoré par les profils qui ne le connaissent pas.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const res = await fetch(`${profile.baseUrl}chat/completions`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${profile.key}` }, body: JSON.stringify(body),
|
||||
})
|
||||
if ((res.status === 429 || res.status === 503) && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 2000)); continue }
|
||||
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`${profile.name} ${res.status}: ${t.slice(0, 160)}`) }
|
||||
const data = await res.json()
|
||||
const msg = data.choices?.[0]?.message
|
||||
if (!msg) throw new Error('réponse IA vide')
|
||||
msg._model = data.model || model
|
||||
return msg
|
||||
}
|
||||
throw new Error(`${profile.name}: indisponible (429/503)`)
|
||||
}
|
||||
async function callProfile (profile, opts) {
|
||||
const models = [profile.model, profile.fallbackModel].filter(Boolean)
|
||||
let lastErr
|
||||
for (const m of models) { try { return await callProfileOnce(profile, m, opts) } catch (e) { lastErr = e } }
|
||||
throw lastErr || new Error(`${profile.name}: échec`)
|
||||
}
|
||||
// Point d'entrée message-based avec ROUTAGE + repli Gemini. Renvoie la string (ou le message complet si wantMessage, pour les tool-calls).
|
||||
async function chat ({ task = 'default', messages, jsonMode = false, maxTokens = 700, temperature = 0.2, tools, wantMessage = false, reasoningEffort } = {}) {
|
||||
const primary = routeFor(task)
|
||||
if (!profileAvailable(primary)) throw new Error('AI non configurée')
|
||||
let msg
|
||||
try { msg = await callProfile(primary, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) }
|
||||
catch (e) {
|
||||
const gem = buildProfiles().gemini
|
||||
if (primary.name !== 'gemini' && profileAvailable(gem)) { log(`AI[${task}] ${primary.name} échec (${e.message}) → repli gemini`); msg = await callProfile(gem, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) }
|
||||
else throw e
|
||||
}
|
||||
return wantMessage ? msg : (msg.content || '')
|
||||
}
|
||||
|
||||
// ── PII minimizer — strip sensitive fields before sending to AI ─────────────
|
||||
|
|
@ -284,7 +303,7 @@ async function diagnose (symptoms, deviceData, customer) {
|
|||
} : null,
|
||||
customer: minimizeCustomer(customer),
|
||||
})
|
||||
return aiCall(DIAGNOSE_PROMPT, userPrompt)
|
||||
return aiCall(DIAGNOSE_PROMPT, userPrompt, { task: 'diagnose' })
|
||||
}
|
||||
|
||||
// ── Daily summary for tech ──────────────────────────────────────────────────
|
||||
|
|
@ -427,7 +446,7 @@ async function analyzeOutage (serial) {
|
|||
}
|
||||
|
||||
// Complex case — multiple customers affected or unclear cause → use AI
|
||||
const result = await aiCall(OUTAGE_PROMPT, JSON.stringify(portSummary))
|
||||
const result = await aiCall(OUTAGE_PROMPT, JSON.stringify(portSummary), { task: 'outage' })
|
||||
result.port_summary = portSummary
|
||||
result.ai_used = true
|
||||
return result
|
||||
|
|
@ -677,6 +696,40 @@ async function handle (req, res, method, urlPath) {
|
|||
return json(res, 200, PRIVACY_INFO)
|
||||
}
|
||||
|
||||
// GET /ai/route-status — profils de modèle + routage par tâche (diagnostic + panneau)
|
||||
if (sub === 'route-status' && method === 'GET') {
|
||||
return json(res, 200, routeStatus())
|
||||
}
|
||||
// POST /ai/route-test — ping un profil (petit appel réel) pour valider la connexion avant de router dessus.
|
||||
if (sub === 'route-test' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const profiles = buildProfiles()
|
||||
const p = profiles[(b && b.profile) || 'local']
|
||||
if (!p) return json(res, 400, { error: 'profil inconnu' })
|
||||
if (!profileAvailable(p)) return json(res, 200, { ok: false, error: 'profil non configuré (base/modèle/clé manquants)' })
|
||||
const t0 = Date.now()
|
||||
try {
|
||||
const msg = await callProfile(p, { messages: [{ role: 'user', content: 'ping' }], maxTokens: 5, temperature: 0 })
|
||||
return json(res, 200, { ok: true, model: msg._model || p.model, latency_ms: Date.now() - t0, sample: (msg.content || '').slice(0, 40) })
|
||||
} catch (e) { return json(res, 200, { ok: false, error: e.message, latency_ms: Date.now() - t0 }) }
|
||||
}
|
||||
|
||||
// POST /ai/routes — enregistre le routage par tâche + base/modèle du profil local. La CLÉ reste en .env (AI_LOCAL_API_KEY).
|
||||
if (sub === 'routes' && method === 'POST') {
|
||||
try {
|
||||
const b = await parseBody(req)
|
||||
const cur = loadRoutesFile()
|
||||
const routes = {}
|
||||
if (b.routes && typeof b.routes === 'object') { for (const [t, p] of Object.entries(b.routes)) if (p === 'gemini' || p === 'local') routes[t] = p } // valide les valeurs
|
||||
const next = {
|
||||
routes: Object.keys(routes).length ? routes : (cur.routes || {}),
|
||||
local: { baseUrl: (b.local && b.local.baseUrl != null) ? String(b.local.baseUrl).trim() : (cur.local && cur.local.baseUrl) || '', model: (b.local && b.local.model != null) ? String(b.local.model).trim() : (cur.local && cur.local.model) || '' },
|
||||
}
|
||||
if (!saveRoutesFile(next)) return json(res, 500, { error: 'écriture échouée' })
|
||||
return json(res, 200, { ok: true, ...routeStatus() })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
return json(res, 404, { error: 'AI endpoint not found' })
|
||||
}
|
||||
|
||||
|
|
@ -706,6 +759,9 @@ const PRIVACY_INFO = {
|
|||
module.exports = {
|
||||
handle,
|
||||
aiCall,
|
||||
chat,
|
||||
routeFor,
|
||||
routeStatus,
|
||||
dispatchSuggest,
|
||||
dispatchRedistribute,
|
||||
dispatchNlp,
|
||||
|
|
|
|||
|
|
@ -949,6 +949,7 @@ function giftExpiredPage (reason) {
|
|||
expired: { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' },
|
||||
revoked: { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' },
|
||||
notfound: { fr: 'Lien introuvable.', en: 'Link not found.' },
|
||||
blocked: { fr: 'Ce lien doit être ouvert depuis votre appareil personnel (téléphone ou connexion résidentielle).', en: 'Please open this link from your personal device (phone or home connection).' },
|
||||
}
|
||||
const m = messages[reason] || messages.notfound
|
||||
return `<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
||||
|
|
@ -967,7 +968,7 @@ a{color:#00C853;text-decoration:none;font-weight:600}
|
|||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="logo">TARGO</div>
|
||||
<div class="icon">🎁</div>
|
||||
<div class="icon">${reason === 'blocked' ? '🔒' : '🎁'}</div>
|
||||
<h1>${m.fr}</h1>
|
||||
<p>Si tu penses qu'il s'agit d'une erreur, écris-nous à <a href="mailto:support@targo.ca">support@targo.ca</a>.</p>
|
||||
<p class="en">${m.en}</p>
|
||||
|
|
@ -1372,6 +1373,9 @@ function applyWebhookEvent (ev) {
|
|||
|
||||
// ── HTTP routing ─────────────────────────────────────────────────────────────
|
||||
async function handle (req, res, method, path) {
|
||||
// NOTE: auth is enforced centrally in server.js (hub auth gate): /campaigns
|
||||
// is ALWAYS_ENFORCE (Bearer HUB_SERVICE_TOKEN) except the public
|
||||
// POST /campaigns/webhook. No per-module gate needed here.
|
||||
// POST /campaigns/parse — preview matched send list (no save)
|
||||
// Returns:
|
||||
// - recipients[]: contacts paired with a gift_url (ready to send)
|
||||
|
|
@ -2325,6 +2329,25 @@ async function handleGiftRedirect (req, res, urlPath) {
|
|||
if (r.gift_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired')
|
||||
if (!r.gift_url) return respondExpired('notfound')
|
||||
|
||||
// ── Anti-bot : journalise l'ouverture (IP + heure) et BLOQUE les IP cloud/datacenter (AWS, GCP…). ──
|
||||
// Les vrais destinataires ouvrent depuis du résidentiel/mobile ; les bots/proxies viennent de datacenters
|
||||
// (cf. redemptions observées depuis 35.94.x / 35.91.x = AWS). Tunable via GIFT_BLOCK_CLOUD (défaut on).
|
||||
const cloudips = require('./cloud-ips'); cloudips.init()
|
||||
const _ips = cloudips.clientIps(req)
|
||||
let _cloud = null
|
||||
for (const ip of _ips) { const c = cloudips.isCloudIp(ip); if (c.cloud) { _cloud = { ip, provider: c.provider }; break } }
|
||||
r.gift_opens = Array.isArray(r.gift_opens) ? r.gift_opens : []
|
||||
r.gift_opens.push({ at: new Date().toISOString(), ips: _ips, blocked: _cloud ? _cloud.provider : '' })
|
||||
if (r.gift_opens.length > 40) r.gift_opens = r.gift_opens.slice(-40)
|
||||
if (_cloud && /^(on|1|true)$/i.test(process.env.GIFT_BLOCK_CLOUD || 'on')) {
|
||||
r.gift_blocked_count = (r.gift_blocked_count || 0) + 1
|
||||
try { saveCampaign(hit.campaign) } catch (e) {}
|
||||
log(`[gift] BLOQUÉ ${_cloud.provider} ${_cloud.ip} — token ${token} (campagne ${hit.campaign.id})`)
|
||||
try { sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r }) } catch (e) {}
|
||||
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' })
|
||||
return res.end(giftExpiredPage('blocked'))
|
||||
}
|
||||
|
||||
// Successful redirect — record analytics, persist async (no await; the
|
||||
// recipient is already on their way to Giftbit's page).
|
||||
r.gift_redirected_count = (r.gift_redirected_count || 0) + 1
|
||||
|
|
|
|||
138
services/targo-hub/lib/client-diag.js
Normal file
138
services/targo-hub/lib/client-diag.js
Normal 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 => ({ '<': '<', '&': '&', '>': '>', '"': '"' }[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 }
|
||||
83
services/targo-hub/lib/cloud-ips.js
Normal file
83
services/targo-hub/lib/cloud-ips.js
Normal 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 }
|
||||
|
|
@ -39,6 +39,12 @@ module.exports = {
|
|||
AI_MODEL: env('AI_MODEL', 'gemini-2.5-flash'),
|
||||
AI_FALLBACK_MODEL: env('AI_FALLBACK_MODEL', 'gemini-2.5-flash-lite-preview'),
|
||||
AI_BASE_URL: env('AI_BASE_URL', 'https://generativelanguage.googleapis.com/v1beta/openai/'),
|
||||
// Modèle LOCAL (vLLM/Ollama → QWEN/Hermes), OpenAI-compatible. Vide = profil « débranché » (repli auto sur Gemini).
|
||||
AI_LOCAL_BASE_URL: env('AI_LOCAL_BASE_URL', ''), // ex. http://gpu-host:8000/v1/ (slash final requis)
|
||||
AI_LOCAL_MODEL: env('AI_LOCAL_MODEL', ''), // ex. Qwen/Qwen2.5-14B-Instruct
|
||||
AI_LOCAL_API_KEY: env('AI_LOCAL_API_KEY', ''), // VIDE par défaut = profil local débranché. Définir (même factice pour vLLM sans auth) = activation explicite.
|
||||
// Routage tâche→profil (JSON). Ex. {"triage":"local","sms":"local"} pour basculer ces tâches vers le local. Vide = défauts (tout Gemini).
|
||||
AI_ROUTES: (() => { try { return JSON.parse(env('AI_ROUTES', '') || '{}') } catch (e) { return {} } })(),
|
||||
VOICE_MODEL: env('VOICE_MODEL', 'models/gemini-2.5-flash-live-preview'),
|
||||
OLT_HOST: env('OLT_HOST'),
|
||||
OLT_COMMUNITY: env('OLT_COMMUNITY', 'public'),
|
||||
|
|
|
|||
92
services/targo-hub/lib/coupon-triage.js
Normal file
92
services/targo-hub/lib/coupon-triage.js
Normal 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 }
|
||||
300
services/targo-hub/lib/giftbit.js
Normal file
300
services/targo-hub/lib/giftbit.js
Normal 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 × Giftbit — 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 × Giftbit — Integration Demo</h1><div class="sub">Reward creation via Giftbit API — direct brand & multi-brand link (recipient choice)</div></header>
|
||||
<div class="sandbox">🧪 <b>SANDBOX / TESTBED</b> — 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>• <b>Direct reward</b> — one specific brand, fixed at issuance.
|
||||
<br>• <b>Reward link (multi-brand)</b> — 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> — one brand (fixed)</label>
|
||||
<label><input type="radio" name="mode" value="template"> <b>Reward link</b> — 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…</div></div>
|
||||
</div>
|
||||
<div class="card"><h2><span class="step">3</span>Recipient & 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…</div></div>
|
||||
<div class="muted" style="margin-top:6px">Live statuses from the testbed: <b>SENT_AND_REDEEMABLE</b> → <b>REDEEMED</b> (brand fills in once the recipient chooses) → <b>GIVER_CANCELLED</b> (revoked). “Revoke” 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…</div></div>
|
||||
<div class="muted" style="margin-top:6px">Organic redemptions cluster right after delivery, then taper off. A steady daily trickle — or many <b>instant</b> redemptions (< 15 min after issue) — can indicate automated / low-profile abuse.</div>
|
||||
</div>
|
||||
<div class="card"><h2>Security & correct use</h2>
|
||||
<div class="sec">
|
||||
• <b>Token server-side only</b> — the Giftbit Bearer token lives in the hub environment; this browser page never receives it.<br>
|
||||
• <b>Sandboxed</b> — this endpoint refuses to run against production; amount, brand list and recipient count are capped.<br>
|
||||
• <b>Direct</b> create sends one <code>marketplace_gifts</code> entry; <b>multi-brand</b> sends several — Giftbit returns a shortlink where the recipient picks their brand (<code>brand_code</code> is null until chosen).<br>
|
||||
• <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 — 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')+' — $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+' · status: '+(g.status||'')+' · 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 <span style="display:inline-block;width:10px;height:10px;background:#1b873f;border-radius:2px"></span> redeemed · total '+(t.gifts||0)+' issued / '+(t.redeemed||0)+' redeemed';if(t.fast_redemptions)h+=' · <b style="color:#b54343">⚠ '+t.fast_redemptions+' instant (<15 min)</b>';if(t.late_redemptions)h+=' · <b style="color:#b07a00">'+t.late_redemptions+' late (>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 }
|
||||
185
services/targo-hub/lib/gmail.js
Normal file
185
services/targo-hub/lib/gmail.js
Normal 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(/ /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(/ /g, ' ').replace(/&/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 }
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use strict'
|
||||
const http = require('http')
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const { URL } = require('url')
|
||||
const cfg = require('./config')
|
||||
|
||||
|
|
@ -78,18 +79,48 @@ function erpRequest (method, path, body) {
|
|||
return erpFetch(path, { method, ...(body && { body }) })
|
||||
}
|
||||
|
||||
async function lookupCustomerByPhone (phone) {
|
||||
const digits = phone.replace(/\D/g, '').slice(-10)
|
||||
const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'tel_home', 'tel_office'])
|
||||
for (const field of ['cell_phone', 'tel_home', 'tel_office']) {
|
||||
// Renvoie TOUTES les fiches Customer dont un champ téléphone se termine par les 10 chiffres (multi-champs
|
||||
// incl. mobile_no), dédupliquées. Permet de choisir parmi plusieurs fiches quand le numéro est partagé.
|
||||
async function lookupCustomersByPhone (phone, limit = 6) {
|
||||
const digits = String(phone || '').replace(/\D/g, '').slice(-10)
|
||||
if (digits.length < 7) return []
|
||||
const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'mobile_no', 'tel_home', 'tel_office', 'email_id', 'territory'])
|
||||
const byName = new Map()
|
||||
for (const field of ['cell_phone', 'mobile_no', 'tel_home', 'tel_office']) {
|
||||
const filters = JSON.stringify([[field, 'like', '%' + digits]])
|
||||
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`
|
||||
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${limit}`
|
||||
try {
|
||||
const res = await erpFetch(path)
|
||||
if (res.status === 200 && res.data?.data?.length > 0) return res.data.data[0]
|
||||
} catch (e) { log('lookupCustomerByPhone error on ' + field + ':', e.message) }
|
||||
if (res.status === 200 && res.data?.data?.length) {
|
||||
for (const c of res.data.data) if (!byName.has(c.name)) byName.set(c.name, { name: c.name, customer_name: c.customer_name, matched_field: field, email: c.email_id || '', territory: c.territory || '' })
|
||||
}
|
||||
} catch (e) { log('lookupCustomersByPhone ' + field + ':', e.message) }
|
||||
if (byName.size >= limit) break
|
||||
}
|
||||
return null
|
||||
return [...byName.values()].slice(0, limit)
|
||||
}
|
||||
// Compat : 1re fiche (ou null). Les flux existants continuent de marcher.
|
||||
async function lookupCustomerByPhone (phone) { const all = await lookupCustomersByPhone(phone, 1); return all[0] || null }
|
||||
|
||||
// Fiches clients par COURRIEL (email_id exact prioritaire, puis email_billing qui peut contenir plusieurs adresses).
|
||||
// Même forme de retour que lookupCustomersByPhone → réutilise le sélecteur de fiche (multi-match) côté Ops.
|
||||
async function lookupCustomersByEmail (email, limit = 6) {
|
||||
const e = String(email || '').trim().toLowerCase()
|
||||
if (!/.+@.+\..+/.test(e)) return []
|
||||
const fields = JSON.stringify(['name', 'customer_name', 'email_id', 'email_billing', 'cell_phone', 'mobile_no', 'territory'])
|
||||
const byName = new Map()
|
||||
for (const [field, op, val] of [['email_id', '=', e], ['email_billing', 'like', '%' + e + '%']]) {
|
||||
const filters = JSON.stringify([[field, op, val]])
|
||||
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${limit}`
|
||||
try {
|
||||
const res = await erpFetch(path)
|
||||
if (res.status === 200 && res.data?.data?.length) {
|
||||
for (const c of res.data.data) if (!byName.has(c.name)) byName.set(c.name, { name: c.name, customer_name: c.customer_name, matched_field: field, email: c.email_id || e, phone: c.cell_phone || c.mobile_no || '', territory: c.territory || '' })
|
||||
}
|
||||
} catch (err) { log('lookupCustomersByEmail ' + field + ':', err.message) }
|
||||
if (byName.size >= limit) break
|
||||
}
|
||||
return [...byName.values()].slice(0, limit)
|
||||
}
|
||||
|
||||
function createCommunication (fields) {
|
||||
|
|
@ -141,8 +172,25 @@ function cors (res, methods = 'GET, POST, OPTIONS') {
|
|||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
}
|
||||
|
||||
// ── Utilitaires partagés (stores JSON, sets de dédup, data-URL) — regroupés ici pour éviter la duplication ──
|
||||
// Lit un fichier JSON ; renvoie `fallback` (copie) si absent/illisible. Comportement identique aux load*() qu'il remplace.
|
||||
function readJsonFile (path, fallback) {
|
||||
try { return JSON.parse(fs.readFileSync(path, 'utf8')) } catch (e) { return (fallback !== undefined ? fallback : null) }
|
||||
}
|
||||
// Écrit un objet en JSON indenté ; renvoie true/false. Best-effort (log en cas d'échec).
|
||||
function writeJsonFile (path, obj) {
|
||||
try { fs.writeFileSync(path, JSON.stringify(obj, null, 2)); return true } catch (e) { log('writeJsonFile ' + path + ': ' + e.message); return false }
|
||||
}
|
||||
// Set de dédup persistée (ex. ids de messages déjà traités). Lit un tableau JSON → Set.
|
||||
function loadSeenSet (path) { try { return new Set(JSON.parse(fs.readFileSync(path, 'utf8'))) } catch (e) { return new Set() } }
|
||||
// Sauve un Set en tableau JSON, borné aux `cap` derniers (évite la croissance infinie).
|
||||
function saveSeenSet (path, set, cap = 3000) { try { fs.writeFileSync(path, JSON.stringify([...set].slice(-cap))) } catch (e) { /* */ } }
|
||||
// Retire le préfixe data:image/...;base64, → base64 pur (pour l'OCR vision).
|
||||
function stripDataUrl (s) { return String(s || '').replace(/^data:[^;]+;base64,/, '') }
|
||||
|
||||
module.exports = {
|
||||
log, json, parseBody, httpRequest, cors,
|
||||
erpFetch, erpRequest, lookupCustomerByPhone, createCommunication,
|
||||
erpFetch, erpRequest, lookupCustomerByPhone, lookupCustomersByPhone, lookupCustomersByEmail, createCommunication,
|
||||
nbiRequest, deepGetValue,
|
||||
readJsonFile, writeJsonFile, loadSeenSet, saveSeenSet, stripDataUrl,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -366,6 +366,66 @@ function sync (opts = {}) {
|
|||
return run
|
||||
}
|
||||
|
||||
// INGESTION des tickets ASSIGNÉS À UN TECH (≠ pool 3301) → Dispatch Job assignés + datés, ÉDITABLES/réordonnables dans Ops.
|
||||
// Idempotent par legacy_ticket_id (réutilise buildJob → coords/client/adresse/sujet+#/scheduled_date/legacy_dept-couleur).
|
||||
// Terrain seulement + fenêtre due_date [loDays, hiDays] (défaut -7j→+30j) pour borner le volume. dryRun = APERÇU (0 écriture).
|
||||
async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30 } = {}) {
|
||||
resetCaches()
|
||||
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' }
|
||||
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
|
||||
const staffToTech = {}; const ids = []
|
||||
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); if (s >= 2 && s !== TARGO_TECH_STAFF_ID) { staffToTech[s] = t.technician_id || t.name; ids.push(s) } }
|
||||
if (!ids.length) return { ok: true, dryRun, created: 0, note: 'aucun tech mappé' }
|
||||
const now = Math.floor(Date.now() / 1000), D = 86400
|
||||
const lo = now + loDays * D, hi = now + hiDays * D
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id, t.assign_to, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
|
||||
t.date_create, t.last_update,
|
||||
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
|
||||
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
|
||||
(SELECT mm.msg FROM ticket_msg mm WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%' ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
|
||||
(SELECT mm3.msg FROM ticket_msg mm3 WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
|
||||
FROM ticket t
|
||||
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||||
LEFT JOIN account a ON a.id = t.account_id
|
||||
LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1))
|
||||
WHERE t.status = 'open' AND t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?
|
||||
ORDER BY t.due_date ASC`, [ids, lo, hi])
|
||||
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
|
||||
let created = 0, updated = 0, skipped = 0, errors = 0
|
||||
const details = [], errSamples = []
|
||||
for (const t of (rows || [])) {
|
||||
try {
|
||||
const subj = String(t.subject || '')
|
||||
if (!(/est\.\s?time/i.test(subj) || FIELD_RE.test(t.dept || ''))) continue // terrain seulement
|
||||
const tech = staffToTech[t.assign_to]; if (!tech) continue
|
||||
const b = await buildJob(t)
|
||||
b.payload.assigned_tech = tech; b.payload.status = 'assigned'
|
||||
const ex = await findExisting(b.legacy_id)
|
||||
if (ex) {
|
||||
const patch = {}
|
||||
if (!ex.assigned_tech) { patch.assigned_tech = tech; patch.status = 'assigned' } // ne CLOBBE jamais un tech déjà posé par le répartiteur
|
||||
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
|
||||
if (!ex.scheduled_date && b.payload.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
|
||||
const hasC = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 1e-4
|
||||
if (b.payload.latitude != null && !(hasC(ex.latitude) && hasC(ex.longitude))) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude }
|
||||
if (dryRun) { skipped++; details.push({ legacy_id: b.legacy_id, action: 'exists', job: ex.name, tech, would_patch: Object.keys(patch) }) }
|
||||
else if (Object.keys(patch).length) { const r = await erp.update('Dispatch Job', ex.name, patch); if (r && r.ok) updated++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'update' }) } }
|
||||
else skipped++
|
||||
} else if (dryRun) {
|
||||
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', tech, subject: b.payload.subject, dept: t.dept, scheduled_date: b.payload.scheduled_date || null, skill: deptSkill(t.dept || subj), customer: b.matched.customer_name, customer_matched: b.matched.customer, coords: b.matched.coords })
|
||||
} else {
|
||||
const r = await erp.create('Dispatch Job', b.payload)
|
||||
if (r && r.ok) created++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
|
||||
}
|
||||
} catch (e) { errors++; errSamples.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) }
|
||||
}
|
||||
return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) }
|
||||
}
|
||||
function ingestAssigned (opts = {}) { const run = _syncLock.then(() => ingestAssignedImpl(opts), () => ingestAssignedImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel que sync (frappe_pg)
|
||||
|
||||
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
|
||||
async function syncImpl ({ dryRun = false } = {}) {
|
||||
resetCaches()
|
||||
|
|
@ -863,9 +923,138 @@ function startSync () {
|
|||
}
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
|
||||
|
||||
// Jobs Legacy (osTicket) OUVERTS assignés à UN tech précis (staff_id legacy), filtrés « terrain »
|
||||
// (dépts dispatch : install/réparation/télé/téléphonie/monteur/fusion/désinstall OU subject « est. time »).
|
||||
// LECTURE SEULE : 1 requête par appel (au clic dans Ops), zéro écriture. Pool plafonné à 2 conns (prod legacy protégée).
|
||||
async function legacyTechTickets (staffId) {
|
||||
const sid = Number(staffId); if (!sid) return { ok: false, error: 'staff_id invalide' }
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id, t.subject, dd.name AS dept, t.due_date, t.priority, a.first_name, a.last_name, a.company, a.city
|
||||
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id
|
||||
WHERE t.status = 'open' AND t.assign_to = ?
|
||||
ORDER BY (t.due_date IS NULL OR t.due_date = 0), t.due_date ASC LIMIT 400`, [sid])
|
||||
const decode = (s) => String(s == null ? '' : s).replace(/�?39;/g, "'").replace(/&/g, '&').replace(/"/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(/�?39;/g, "'").replace(/&/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
|
||||
// Tickets lus sur F LIVE via le pont PHP (le miroir n'est PAS rafraîchi pour `ticket` → périmé). Repli miroir si pont indispo.
|
||||
let rows = []
|
||||
try {
|
||||
const w = await legacyWrite({ action: 'tickets_window', lo, hi, staff: ids.join(',') })
|
||||
rows = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []
|
||||
} catch (e) {
|
||||
try { const [mr] = await p.query(`SELECT t.id, t.subject, t.assign_to, t.due_date, dd.name AS dept FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id WHERE t.status = 'open' AND t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?`, [ids, lo, hi]); rows = mr } catch (e2) {}
|
||||
}
|
||||
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i // jobs TERRAIN (cohérent avec le 📋) — exclut facturation/vente/admin
|
||||
// Exclut les tickets DÉJÀ ingérés comme vrais Dispatch Job (legacy_ticket_id) → ils s'affichent via l'occupancy ; évite le DOUBLON sur la timeline.
|
||||
let ingested = new Set()
|
||||
// Le Set « ingéré » = EXACTEMENT les tickets que l'occupation DESSINE (occupancyByTechDay filtre status in [open,assigned,in_progress]).
|
||||
// Sinon un DJ Completed/Cancelled/closed supprime le synthétique SANS rien dessiner → ticket F encore ouvert = caché des DEUX côtés
|
||||
// (cas Nathan : 6 DJ Cancelled ; + 116 DJ Completed au total). F reste autoritaire (ticket ouvert+assigné+dû) → on redessine le bloc.
|
||||
try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['legacy_ticket_id'], limit: 5000 }); ingested = new Set((djs || []).map(d => String(d.legacy_ticket_id))) } catch (e) {}
|
||||
const load = {}
|
||||
for (const r of (rows || [])) {
|
||||
const tech = staffToTech[r.assign_to]; if (!tech || !r.due_date) continue
|
||||
if (ingested.has(String(r.id))) continue // déjà un vrai Dispatch Job → pas de bloc synthétique
|
||||
const subj = decode(r.subject)
|
||||
// TERRAIN = exige un DÉPLACEMENT (install / réparation / TV / téléphonie / fusion) → bloc horaire.
|
||||
// « NP - » = note/suivi rapide (≤2 min, souvent au support) → JAMAIS terrain même si le dept matche → fine ligne verticale.
|
||||
// Le reste (Facturation, Vente, ToDo, Support…) → non-terrain → fine ligne verticale aussi. On liste tout, mais seul le terrain occupe la charge.
|
||||
const npQuick = /\bNP\b\s*-/i.test(subj)
|
||||
const isField = !npQuick && (/est\.\s?time/i.test(subj) || FIELD_RE.test(r.dept || ''))
|
||||
const iso = new Date(Number(r.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||
if (!isoSet.has(iso)) continue
|
||||
const est = estLegacyHours(r.subject, r.dept)
|
||||
const k = tech + '|' + iso
|
||||
const e = load[k] || (load[k] = { h: 0, n: 0, officeN: 0, jobs: [] })
|
||||
// Seul le TERRAIN compte dans la charge horaire (occupation/surcharge). L'admin/facturation est listée (jobs) mais ne gonfle pas l'occupation.
|
||||
if (isField) { e.h += est; e.n++ } else { e.officeN++ }
|
||||
e.jobs.push({ id: String(r.id), subject: subj, est_h: est, dept: r.dept || '', skill: deptSkill(r.dept || subj), field: isField })
|
||||
}
|
||||
for (const k in load) load[k].h = Math.round(load[k].h * 10) / 10
|
||||
return { ok: true, load }
|
||||
}
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
try {
|
||||
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
|
||||
if (path === '/dispatch/legacy-sync/tech-tickets' && method === 'GET') { // jobs legacy ouverts assignés à UN tech (lecture seule)
|
||||
const q = new URL(req.url, 'http://localhost').searchParams
|
||||
let sid = q.get('staff'); const tech = q.get('tech')
|
||||
if (!sid && tech) { const m = String(tech).match(/(\d+)$/); sid = m ? m[1] : '' }
|
||||
if (!sid) return json(res, 400, { ok: false, error: 'staff ou tech requis' })
|
||||
return json(res, 200, await legacyTechTickets(sid))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/window-load' && method === 'GET') { // charge legacy datée par tech×jour (fenêtre affichée) — lecture seule
|
||||
const q = new URL(req.url, 'http://localhost').searchParams
|
||||
const start = q.get('start'); const days = q.get('days') || '7'
|
||||
if (!start) return json(res, 400, { ok: false, error: 'start requis' })
|
||||
return json(res, 200, await legacyWindowLoad(start, days))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'GET') return json(res, 200, await ingestAssigned({ dryRun: true })) // APERÇU (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'POST') { // INGÈRE : crée les Dispatch Job assignés (terrain, fenêtre)
|
||||
const q = new URL(req.url, 'http://localhost').searchParams
|
||||
return json(res, 200, await ingestAssigned({ dryRun: false, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30 }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
|
||||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
|
||||
|
|
@ -905,6 +1094,11 @@ async function handle (req, res, method, path) {
|
|||
return json(res, 200, await techSyncApply(ids))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
|
||||
if (path === '/dispatch/legacy-sync/reconcile-jobs' && method === 'GET') return json(res, 200, await reconcileLegacyJobs({})) // APERÇU : aligne DJ legacy actifs sur F (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/reconcile-jobs' && method === 'POST') { // APPLIQUE (confirm=RECONCILE) : corrige tech / annule selon F. N'écrit QUE ERPNext. purgePool=1 = aussi annuler les F=pool (opt-in risqué).
|
||||
const q = new URL(req.url, 'http://localhost').searchParams
|
||||
return json(res, 200, await reconcileLegacyJobs({ confirm: q.get('confirm') || '', purgePool: q.get('purgePool') === '1' }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
|
||||
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
|
||||
if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
|
||||
|
|
@ -918,4 +1112,62 @@ async function handle (req, res, method, path) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
|
||||
// ─── Réconciliation F→OPS : aligner les Dispatch Job legacy ACTIFS sur la vérité de F (autoritaire) ───
|
||||
// Pour chaque DJ actif (open/assigned/in_progress) portant un legacy_ticket_id, on lit l'état RÉEL dans F (action pont
|
||||
// `ticket_status`) puis : F fermé/résolu → ANNULE le DJ ; F renvoyé au pool (Tech Targo) ou à personne → ANNULE ;
|
||||
// F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech` (+ scheduled_date = due_date F) ; F donné à un non-tech
|
||||
// (bureau) → ANNULE (pas de bloc terrain pour du bureau) ; F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut.
|
||||
async function reconcileLegacyJobs (opts = {}) {
|
||||
const apply = opts.confirm === 'RECONCILE'
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
const decode = (s) => String(s == null ? '' : s).replace(/�?39;/g, "'").replace(/&/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
|
||||
|
|
|
|||
582
services/targo-hub/lib/legacy-payments.js
Normal file
582
services/targo-hub/lib/legacy-payments.js
Normal 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 }
|
||||
749
services/targo-hub/lib/legacy-sync.js
Normal file
749
services/targo-hub/lib/legacy-sync.js
Normal 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(/&/g, '&').replace(/�?39;|'/g, "'").replace(/"/g, '"')
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/�?(\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 }
|
||||
121
services/targo-hub/lib/municipality.js
Normal file
121
services/targo-hub/lib/municipality.js
Normal 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(/�*39;|'/gi, "'").replace(/&/gi, '&').replace(/é|è|ê/gi, 'e').replace(/à|â/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 }
|
||||
|
|
@ -168,6 +168,78 @@ async function getManageIp (serial, opts = {}) {
|
|||
return { serial, oltName, oltHost, slot, port, ontId, ips, manageIp: mgmt?.ip || ips[0].ip }
|
||||
}
|
||||
|
||||
// Raisecom per-ONU WAN octet counters (VLAN 40 = service internet) — mirrors F's
|
||||
// device_ajax/raisecom_rcmg_bandwith.php. Cumulative octets (32-bit, may wrap):
|
||||
// rx (download) = 1.3.6.1.4.1.8886.18.3.7.2.4.1.5.{oltId}.40
|
||||
// tx (upload) = 1.3.6.1.4.1.8886.18.3.7.2.4.1.3.{oltId}.40
|
||||
// oltId = slot*10000000 + port*100000 + ontid (same encoding as the WAN-IP table)
|
||||
// Raisecom (tech-2) ONLY — TP-Link/tech-3 has no live WAN-rate counter in F.
|
||||
// The CALLER samples twice and computes rate = ((val - last) / sec) * 8 bits/s
|
||||
// (with counter-wrap handling), exactly like F's chart.
|
||||
const RAISECOM_WAN_OCTETS_BASE = '1.3.6.1.4.1.8886.18.3.7.2.4.1'
|
||||
const RC_WAN_VLAN = 40
|
||||
|
||||
async function getOnuBandwidth (serial, opts = {}) {
|
||||
let oltHost, community, oltName, slot, port, ontId, type
|
||||
|
||||
if (serial) {
|
||||
const onu = getOnuBySerial(serial)
|
||||
if (onu) {
|
||||
const olt = olts.get(onu.oltHost)
|
||||
if (olt) {
|
||||
type = olt.type; oltHost = olt.host; community = olt.community; oltName = olt.name
|
||||
const parts = (onu.port || '').split('/') // Raisecom cache stores port as "0/slot/port"
|
||||
if (parts.length === 3) { slot = parseInt(parts[1]); port = parseInt(parts[2]) }
|
||||
ontId = onu.onuIdx
|
||||
}
|
||||
}
|
||||
}
|
||||
// Explicit params (from ERPNext Service Equipment when ONU not in cache)
|
||||
if (opts.oltIp) oltHost = opts.oltIp
|
||||
if (opts.community) community = opts.community
|
||||
if (opts.slot != null) slot = parseInt(opts.slot)
|
||||
if (opts.port != null) port = parseInt(opts.port)
|
||||
if (opts.ontId != null) ontId = parseInt(opts.ontId)
|
||||
if (opts.type) type = (opts.type + '').toLowerCase()
|
||||
if (!community) community = 'targosnmp'
|
||||
if (!oltName) oltName = oltHost
|
||||
|
||||
// tech-3 (TP-Link) has no live WAN octet counter in F → report unavailable, don't poll
|
||||
if (type && type !== 'raisecom') return { available: false, reason: 'unsupported_olt_type', type }
|
||||
if (!oltHost || slot == null || port == null || ontId == null || isNaN(slot) || isNaN(port) || isNaN(ontId)) {
|
||||
return { available: false, reason: 'onu_not_resolved' }
|
||||
}
|
||||
|
||||
const oltId = slot * 10000000 + port * 100000 + ontId
|
||||
const session = createSession(oltHost, community)
|
||||
let rxOctets = null, txOctets = null
|
||||
try {
|
||||
// Les 2 gets en parallèle (même session) → ~2× plus rapide à répondre
|
||||
const [rxVb, txVb] = await Promise.all([
|
||||
snmpGet(session, `${RAISECOM_WAN_OCTETS_BASE}.5.${oltId}.${RC_WAN_VLAN}`),
|
||||
snmpGet(session, `${RAISECOM_WAN_OCTETS_BASE}.3.${oltId}.${RC_WAN_VLAN}`),
|
||||
])
|
||||
// These counters are SNMP Counter64 → net-snmp hands them back as an 8-byte
|
||||
// big-endian Buffer (NOT an Integer). Parse to a JS number (octet totals stay
|
||||
// well under 2^53 between resets, so precision is fine).
|
||||
const toNum = (vb) => {
|
||||
if (!vb) return null
|
||||
const v = vb.value
|
||||
if (typeof v === 'number') return v // Counter32/Gauge/Integer
|
||||
if (typeof v === 'bigint') return Number(v)
|
||||
if (Buffer.isBuffer(v)) {
|
||||
try { return Number(v.readBigUInt64BE(0)) }
|
||||
catch (e) { const n = Number('0x' + v.toString('hex')); return isNaN(n) ? null : n }
|
||||
}
|
||||
const n = parseInt(v, 10); return isNaN(n) ? null : n
|
||||
}
|
||||
rxOctets = toNum(rxVb); txOctets = toNum(txVb)
|
||||
} finally { try { session.close() } catch {} }
|
||||
|
||||
if (rxOctets == null && txOctets == null) return { available: false, reason: 'no_counter', oltName, oltId }
|
||||
return { available: true, serial: serial || null, oltName, slot, port, ontId, oltId, rxOctets, txOctets, ts: Date.now() }
|
||||
}
|
||||
|
||||
function extractVal (vb) {
|
||||
if (vb.type === snmp.ObjectType.OctetString) return vb.value.toString()
|
||||
if (vb.type === snmp.ObjectType.Integer) return vb.value
|
||||
|
|
@ -484,5 +556,5 @@ function stopOltPoller () {
|
|||
module.exports = {
|
||||
registerOlt, startOltPoller, stopOltPoller,
|
||||
getOnuBySerial, getPortNeighbors, getPortHealth, classifyOfflineCause,
|
||||
getOltStats, getAllOnus, pollAllOlts, getManageIp,
|
||||
getOltStats, getAllOnus, pollAllOlts, getManageIp, getOnuBandwidth,
|
||||
}
|
||||
|
|
|
|||
74
services/targo-hub/lib/outbox.js
Normal file
74
services/targo-hub/lib/outbox.js
Normal 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 }
|
||||
|
|
@ -35,6 +35,10 @@ const DEFAULT_POLICY = {
|
|||
// skill_by_type : table type de job (service_type) → compétence requise. Le client ne voit que les
|
||||
// créneaux des techs qui ont ce tag. '' = aucun tag requis (ex. ajout TV/borne WiFi → n'importe qui).
|
||||
booking: { lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10, skill_by_type: {} },
|
||||
// Routage de tournée (lu CÔTÉ CLIENT par la page Planification) : origine de tournée = dépôt par défaut,
|
||||
// OU domicile du tech (tech_homes[id]) s'il est plus proche du travail. La 1re job = la plus proche de l'origine.
|
||||
depot: { label: 'Dépôt', address: '1867 Chemin de la Rivière, Sainte-Clotilde, QC J0L 1W0', lat: 45.158661, lon: -73.67543 },
|
||||
tech_homes: {}, // { 'TECH-xxxx': { address, lat, lon } } — édité via le sélecteur d'emplacement (page Planification)
|
||||
}
|
||||
const POLICY_OPTIONS = {
|
||||
reschedule: [
|
||||
|
|
@ -249,19 +253,10 @@ async function execTool (name, args) {
|
|||
}
|
||||
|
||||
// ── Appel Gemini (OpenAI-compat) — même config que agent.js ─────────────────
|
||||
// Copilote roster → routé par tâche 'roster' via lib/ai.js (repli Gemini auto). Boucle tool-calls inchangée (ré-emballage {choices:[{message}]}).
|
||||
async function geminiChat (messages) {
|
||||
const url = `${cfg.AI_BASE_URL}chat/completions`
|
||||
const body = { model: cfg.AI_MODEL, max_tokens: 700, messages, tools: TOOLS }
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.AI_API_KEY}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.status === 429 && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 1500)); continue }
|
||||
if (!res.ok) throw new Error(`Gemini ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`)
|
||||
return await res.json()
|
||||
}
|
||||
const msg = await require('./ai').chat({ task: 'roster', messages, tools: TOOLS, maxTokens: 700, wantMessage: true })
|
||||
return { choices: [{ message: msg }] }
|
||||
}
|
||||
|
||||
function systemPrompt () {
|
||||
|
|
|
|||
|
|
@ -353,11 +353,19 @@ function deptToSkill (txt) {
|
|||
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
|
||||
async function attachLocations (jobs) {
|
||||
const codes = [...new Set((jobs || []).map(j => j.service_location).filter(Boolean))]
|
||||
if (!codes.length) return jobs
|
||||
let locs = []
|
||||
try { locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }) } catch (e) { return jobs }
|
||||
const m = {}; for (const l of locs) m[l.name] = l
|
||||
for (const j of jobs) { const l = m[j.service_location]; if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || '' }
|
||||
const m = {}
|
||||
if (codes.length) { try { const locs = await erp.list('Service Location', { filters: [['name', 'in', codes]], fields: ['name', 'address_line', 'city', 'location_name'], limit: codes.length + 10 }); for (const l of locs) m[l.name] = l } catch (e) {} }
|
||||
// Résolveur municipalité (best-effort : si F injoignable, on n'enrichit pas → OPS retombe sur la ville brute).
|
||||
let muni = null; try { muni = require('./municipality'); await muni.load() } catch (e) { muni = null }
|
||||
const cityFromAddr = (s) => { const p = String(s || '').split(',').map(x => x.trim()).filter(Boolean); return p.length >= 2 ? p[p.length - 1] : '' }
|
||||
for (const j of jobs) {
|
||||
const l = m[j.service_location]
|
||||
if (l) j.location_label = [l.address_line, l.city].filter(Boolean).join(', ') || l.location_name || ''
|
||||
if (muni) {
|
||||
const rawCity = (l && l.city) || cityFromAddr(j.location_label || j.address) || ''
|
||||
if (rawCity) { try { const r = muni.resolveSync(rawCity); if (r && r.matched) { j.municipalite = r.canonical; j.municipalite_code = r.code } } catch (e) {} }
|
||||
}
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
function nowMs () { return new Date().getTime() }
|
||||
|
|
@ -702,7 +710,33 @@ async function occupancyByTechDay (start, days) {
|
|||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
|
||||
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
|
||||
}
|
||||
// ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ──
|
||||
// 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ;
|
||||
// l'assistant épinglé voit un bloc miroir à l'heure du lead, dimensionné à SA durée → anti double-booking.
|
||||
const jobNames = jobs.map(j => j.name)
|
||||
if (jobNames.length) {
|
||||
const jByName = {}; for (const j of jobs) jByName[j.name] = j
|
||||
let assists = []
|
||||
try {
|
||||
assists = await erp.list('Dispatch Job Assistant', {
|
||||
filters: [['parent', 'in', jobNames], ['pinned', '=', 1]],
|
||||
fields: ['parent', 'tech_id', 'tech_name', 'duration_h'], limit: 5000,
|
||||
})
|
||||
} catch (e) { assists = [] } // table absente / aucun assistant → silencieux (0 impact)
|
||||
for (const a of assists) {
|
||||
const j = jByName[a.parent]; if (!j || !a.tech_id || !j.scheduled_date) continue
|
||||
const k = a.tech_id + '|' + j.scheduled_date
|
||||
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
|
||||
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject)
|
||||
const jDur = (estimateForJob(j, occChars).minutes / 60) || Number(j.duration_h) || 0
|
||||
const dur = Number(a.duration_h) || jDur || 1 // durée propre de l'assistant (≤ job)
|
||||
o.h += dur
|
||||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name, assist: true })
|
||||
o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' })
|
||||
}
|
||||
}
|
||||
// ordre = route_order manuel s'il existe, sinon priorité puis heure
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||||
|
|
@ -1032,6 +1066,27 @@ async function handle (req, res, method, path, url) {
|
|||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
|
||||
}
|
||||
// Lire / éditer l'ÉQUIPE d'un job (assistants en renfort) — le LEAD (assigned_tech) reste INCHANGÉ.
|
||||
// GET ?job=X → liste actuelle des assistants
|
||||
// POST {job, add:{tech_id,...}} → AJOUTE (dédup sur tech_id), sans clobber des autres
|
||||
// POST {job, remove:tech_id} → RETIRE
|
||||
// POST {job, assistants:[...]} → REMPLACE toute la table-enfant
|
||||
// PUT partiel ⇒ assigned_tech / scheduled_date / etc. préservés. Chemin d'écriture UNIQUE (features/workforce).
|
||||
if (path === '/roster/job/team' && (method === 'GET' || method === 'POST')) {
|
||||
const b = method === 'POST' ? await parseBody(req) : {}
|
||||
const job = b.job || qs.get('job')
|
||||
if (!job) return json(res, 400, { error: 'job requis' })
|
||||
const norm = (a) => ({ tech_id: String(a.tech_id), tech_name: String(a.tech_name || ''), duration_h: Number(a.duration_h) || 0, note: String(a.note || '').slice(0, 140), pinned: a.pinned ? 1 : 0 })
|
||||
let cur = []
|
||||
try { cur = await erp.list('Dispatch Job Assistant', { filters: [['parent', '=', job]], fields: ['tech_id', 'tech_name', 'duration_h', 'note', 'pinned'], limit: 50 }) } catch (e) { cur = [] }
|
||||
if (method === 'GET') return json(res, 200, { job, assistants: cur.map(norm) })
|
||||
let rows
|
||||
if (b.add && b.add.tech_id) { rows = cur.filter(a => String(a.tech_id) !== String(b.add.tech_id)).map(norm); rows.push(norm(b.add)) }
|
||||
else if (b.remove) { rows = cur.filter(a => String(a.tech_id) !== String(b.remove)).map(norm) }
|
||||
else { rows = (b.assistants || []).filter(a => a && a.tech_id).map(norm) }
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', job, { assistants: rows }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job, assistants: rows.length, team: rows })
|
||||
}
|
||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
|
||||
if (path === '/roster/job/set-location' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
|
|
|
|||
137
services/targo-hub/lib/supplier-invoices.js
Normal file
137
services/targo-hub/lib/supplier-invoices.js
Normal 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 }
|
||||
121
services/targo-hub/lib/sync-orchestrator.js
Normal file
121
services/targo-hub/lib/sync-orchestrator.js
Normal 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 }
|
||||
|
|
@ -126,39 +126,9 @@ Règles dates:
|
|||
- Heures au format 24h (HH:MM)`
|
||||
}
|
||||
|
||||
// Analyse SMS d'absence tech → routé par tâche 'roster' via lib/ai.js (repli Gemini auto).
|
||||
function llmRequest (messages) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const baseUrl = cfg.AI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai/'
|
||||
const url = new URL(baseUrl + 'chat/completions')
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const client = isHttps ? require('https') : require('http')
|
||||
const payload = JSON.stringify({
|
||||
model: cfg.AI_MODEL || 'gemini-2.5-flash',
|
||||
messages,
|
||||
temperature: 0.1,
|
||||
max_tokens: 1024,
|
||||
})
|
||||
const headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
||||
if (cfg.AI_API_KEY) headers['Authorization'] = 'Bearer ' + cfg.AI_API_KEY
|
||||
const req = client.request({
|
||||
hostname: url.hostname, port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname + url.search, method: 'POST', headers, timeout: 15000,
|
||||
}, (res) => {
|
||||
let body = ''
|
||||
res.on('data', c => body += c)
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body)
|
||||
const content = data.choices?.[0]?.message?.content || ''
|
||||
resolve(content)
|
||||
} catch { reject(new Error('LLM parse error: ' + body.substring(0, 200))) }
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('LLM timeout')) })
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
return require('./ai').chat({ task: 'roster', messages, maxTokens: 1024, temperature: 0.1 })
|
||||
}
|
||||
|
||||
async function parseWithLlm (text) {
|
||||
|
|
|
|||
627
services/targo-hub/lib/ticket-collab.js
Normal file
627
services/targo-hub/lib/ticket-collab.js
Normal 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, '<').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 }
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
|
||||
// Prefer bearer token (revocable, no password in env); fall back to Basic auth for legacy.
|
||||
const authHeader = cfg.TRACCAR_TOKEN
|
||||
|
|
@ -15,6 +16,24 @@ async function traccarFetch (path) {
|
|||
return res.json()
|
||||
}
|
||||
|
||||
// Variante RAW (texte) — pour GPX/KML (Accept différent, pas de parse JSON).
|
||||
async function traccarRaw (path, accept) {
|
||||
const res = await fetch(cfg.TRACCAR_URL + path, { headers: { Authorization: authHeader, Accept: accept } })
|
||||
if (!res.ok) throw new Error(`Traccar ${res.status}: ${path}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
// Résout l'appareil Traccar depuis ?device= OU ?tech= (technician_id puis docname Dispatch Technician).
|
||||
async function deviceFor (url) {
|
||||
let device = url.searchParams.get('device'); const tech = url.searchParams.get('tech')
|
||||
if (!device && tech) {
|
||||
let rows = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tech]], fields: ['traccar_device_id'], limit: 1 })
|
||||
if (!rows.length) rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['traccar_device_id'], limit: 1 })
|
||||
device = rows.length ? rows[0].traccar_device_id : null
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
// Cache devices for 60s (they rarely change)
|
||||
let _devCache = null, _devCacheTs = 0
|
||||
async function getDevices () {
|
||||
|
|
@ -55,6 +74,35 @@ async function handle (req, res, method, path) {
|
|||
}
|
||||
}
|
||||
|
||||
// Export GPX du parcours d'un tech sur une fenêtre (jour courant) — résout le device depuis Dispatch Technician.
|
||||
if (sub === 'gpx' && method === 'GET') {
|
||||
try {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const device = await deviceFor(url)
|
||||
if (!device) return json(res, 404, { error: 'Aucun appareil GPS associé à ce technicien' })
|
||||
const from = url.searchParams.get('from'); const to = url.searchParams.get('to')
|
||||
if (!from || !to) return json(res, 400, { error: 'from + to (ISO 8601) requis' })
|
||||
const gpx = await traccarRaw(`/api/positions?deviceId=${encodeURIComponent(device)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, 'application/gpx+xml')
|
||||
res.writeHead(200, { 'Content-Type': 'application/gpx+xml; charset=utf-8', 'Content-Disposition': `attachment; filename="parcours-${device}-${String(from).slice(0, 10)}.gpx"` })
|
||||
return res.end(gpx)
|
||||
} catch (e) { log('Traccar gpx error:', e.message); return json(res, 502, { error: 'Traccar GPX indisponible' }) }
|
||||
}
|
||||
|
||||
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → coords [lon,lat] (JSON léger) pour superposer sur la carte.
|
||||
// IMPORTANT : exiger une plage EXACTE (1 jour) — sinon Traccar renvoie un volume énorme et lent.
|
||||
if (sub === 'track' && method === 'GET') {
|
||||
try {
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const device = await deviceFor(url)
|
||||
if (!device) return json(res, 404, { error: 'Aucun appareil GPS associé à ce technicien' })
|
||||
const from = url.searchParams.get('from'); const to = url.searchParams.get('to')
|
||||
if (!from || !to) return json(res, 400, { error: 'from + to (ISO 8601) requis' })
|
||||
const pts = await traccarFetch(`/api/positions?deviceId=${encodeURIComponent(device)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||
const coords = (Array.isArray(pts) ? pts : []).filter(p => p.latitude != null && p.longitude != null).map(p => [p.longitude, p.latitude])
|
||||
return json(res, 200, { device, count: coords.length, coords })
|
||||
} catch (e) { log('Traccar track error:', e.message); return json(res, 502, { error: 'Traccar track indisponible' }) }
|
||||
}
|
||||
|
||||
// Proxy any other Traccar API path (fallback)
|
||||
if (method === 'GET') {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ async function smsStatus (req, res) {
|
|||
setImmediate(() => broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() }))
|
||||
}
|
||||
|
||||
async function sendSmsInternal (phone, message, customer) {
|
||||
async function sendSmsInternal (phone, message, customer, mediaUrl) {
|
||||
// Normalize to E.164: strip non-digits, add +1 for 10-digit North American numbers
|
||||
const digits = phone.replace(/\D/g, '')
|
||||
const to = phone.startsWith('+') ? phone
|
||||
|
|
@ -78,9 +78,10 @@ async function sendSmsInternal (phone, message, customer) {
|
|||
: digits.length === 11 && digits.startsWith('1') ? '+' + digits
|
||||
: '+' + digits
|
||||
const twilioData = new URLSearchParams({
|
||||
To: to, From: cfg.TWILIO_FROM, Body: message,
|
||||
To: to, From: cfg.TWILIO_FROM, Body: message || '',
|
||||
StatusCallback: cfg.HUB_PUBLIC_URL + '/webhook/twilio/sms-status',
|
||||
})
|
||||
if (mediaUrl) twilioData.append('MediaUrl', mediaUrl) // MMS : pièce jointe image (URL publique)
|
||||
const authStr = Buffer.from(cfg.TWILIO_ACCOUNT_SID + ':' + cfg.TWILIO_AUTH_TOKEN).toString('base64')
|
||||
const https = require('https')
|
||||
const twilioRes = await new Promise((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody } = require('./helpers')
|
||||
const { log, json, parseBody, stripDataUrl } = require('./helpers')
|
||||
|
||||
const GEMINI_URL = () => `https://generativelanguage.googleapis.com/v1beta/models/${cfg.AI_MODEL}:generateContent?key=${cfg.AI_API_KEY}`
|
||||
|
||||
async function geminiVision (base64Image, prompt, schema) {
|
||||
async function geminiVision (base64Image, prompt, schema, mime = 'image/jpeg') {
|
||||
const resp = await fetch(GEMINI_URL(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: 'image/jpeg', data: base64Image } }] }],
|
||||
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: mime, data: base64Image } }] }],
|
||||
generationConfig: { temperature: 0.1, maxOutputTokens: 1024, responseMimeType: 'application/json', responseSchema: schema },
|
||||
}),
|
||||
})
|
||||
|
|
@ -25,7 +25,7 @@ async function geminiVision (base64Image, prompt, schema) {
|
|||
function extractBase64 (req, body, label) {
|
||||
if (!cfg.AI_API_KEY) return { error: 'AI_API_KEY not configured', status: 500 }
|
||||
if (!body.image) return { error: 'Missing image field (base64)', status: 400 }
|
||||
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
||||
const base64 = stripDataUrl(body.image)
|
||||
log(`Vision ${label}: received image ${Math.round(base64.length * 3 / 4 / 1024)}KB`)
|
||||
return { base64 }
|
||||
}
|
||||
|
|
@ -141,30 +141,68 @@ const INVOICE_SCHEMA = {
|
|||
required: ['vendor', 'total'],
|
||||
}
|
||||
|
||||
// Extraction facture réutilisable (image OU PDF). base64 SANS préfixe data:. mime = 'application/pdf' pour les PDF.
|
||||
async function extractInvoice (base64, mime = 'image/jpeg') {
|
||||
const parsed = await geminiVision(base64, INVOICE_PROMPT, INVOICE_SCHEMA, mime)
|
||||
if (!parsed) return { vendor: null, total: null, items: [] }
|
||||
for (const k of ['subtotal', 'tax_gst', 'tax_qst', 'total']) {
|
||||
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0
|
||||
}
|
||||
if (Array.isArray(parsed.items)) {
|
||||
for (const it of parsed.items) {
|
||||
for (const k of ['qty', 'rate', 'amount']) {
|
||||
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`)
|
||||
return parsed
|
||||
}
|
||||
async function handleInvoice (req, res) {
|
||||
const body = await parseBody(req)
|
||||
const check = extractBase64(req, body, 'invoice')
|
||||
if (check.error) return json(res, check.status, { error: check.error })
|
||||
try {
|
||||
const parsed = await geminiVision(check.base64, INVOICE_PROMPT, INVOICE_SCHEMA)
|
||||
if (!parsed) return json(res, 200, { vendor: null, total: null, items: [] })
|
||||
// Normalize: trim + coerce numbers (model sometimes returns "1,234.56" as string)
|
||||
for (const k of ['subtotal', 'tax_gst', 'tax_qst', 'total']) {
|
||||
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0
|
||||
}
|
||||
if (Array.isArray(parsed.items)) {
|
||||
for (const it of parsed.items) {
|
||||
for (const k of ['qty', 'rate', 'amount']) {
|
||||
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`)
|
||||
return json(res, 200, parsed)
|
||||
} catch (e) {
|
||||
log('Vision invoice error:', e.message)
|
||||
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
|
||||
}
|
||||
try { return json(res, 200, await extractInvoice(check.base64)) }
|
||||
catch (e) { log('Vision invoice error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
|
||||
}
|
||||
|
||||
// ─── Preuve de paiement (capture d'écran client) ───────────────────────
|
||||
// Synergie Inbox : un client envoie une capture de son paiement (virement Interac, app bancaire, reçu).
|
||||
// On EXTRAIT (montant/date/référence/méthode) pour AFFICHER et décider. AUCUNE écriture auto en facturation (F autoritaire).
|
||||
const PAYMENT_PROMPT = `Tu analyses une CAPTURE D'ÉCRAN de preuve de paiement envoyée par un client d'un fournisseur Internet au Québec
|
||||
(virement Interac, application bancaire, reçu de carte, confirmation de paiement). Renvoie UNIQUEMENT du JSON selon le schéma.
|
||||
Règles : "is_payment"=true seulement si l'image montre vraiment un paiement/transfert/reçu. "amount" = nombre (sans symbole). "currency" = CAD par défaut.
|
||||
"date" = ISO YYYY-MM-DD si visible sinon null. "method" = Interac|Carte|Virement|Comptant|Autre. "reference" = numéro de confirmation/référence si visible.
|
||||
"payer_name"/"recipient" = noms si visibles. Ne devine pas : champ absent → null. "confidence" = 0.0–1.0.`
|
||||
const PAYMENT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
is_payment: { type: 'boolean' },
|
||||
amount: { type: 'number', nullable: true },
|
||||
currency: { type: 'string', nullable: true },
|
||||
date: { type: 'string', nullable: true },
|
||||
method: { type: 'string', nullable: true },
|
||||
reference: { type: 'string', nullable: true },
|
||||
payer_name: { type: 'string', nullable: true },
|
||||
recipient: { type: 'string', nullable: true },
|
||||
confidence: { type: 'number' },
|
||||
},
|
||||
required: ['is_payment', 'confidence'],
|
||||
}
|
||||
async function extractPayment (base64) {
|
||||
const parsed = await geminiVision(base64, PAYMENT_PROMPT, PAYMENT_SCHEMA)
|
||||
if (!parsed) return { is_payment: false, confidence: 0 }
|
||||
if (typeof parsed.amount === 'string') parsed.amount = Number(parsed.amount.replace(/[^0-9.\-]/g, '')) || null
|
||||
parsed.confidence = Math.max(0, Math.min(1, Number(parsed.confidence) || 0))
|
||||
log(`Vision payment: is_payment=${parsed.is_payment} amount=${parsed.amount} ${parsed.currency || ''} ref=${parsed.reference || '-'} conf=${parsed.confidence}`)
|
||||
return parsed
|
||||
}
|
||||
async function handlePayment (req, res) {
|
||||
const body = await parseBody(req)
|
||||
const check = extractBase64(req, body, 'payment')
|
||||
if (check.error) return json(res, check.status, { error: check.error })
|
||||
try { return json(res, 200, { ok: true, ...(await extractPayment(check.base64)) }) }
|
||||
catch (e) { log('Vision payment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
|
||||
}
|
||||
|
||||
// ─── Field-targeted extraction (for tech mobile form auto-fill) ─────────
|
||||
|
|
@ -249,4 +287,4 @@ async function handleFieldScan (req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractField, handleFieldScan }
|
||||
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractInvoice, extractField, handleFieldScan, extractPayment, handlePayment }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const traccar = require('./lib/traccar')
|
|||
const dispatch = require('./lib/dispatch')
|
||||
const ical = require('./lib/ical')
|
||||
const vision = require('./lib/vision')
|
||||
const giftbit = require('./lib/giftbit')
|
||||
let voiceAgent
|
||||
try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) }
|
||||
// Oktopus stack is decommissioned. Set OKTOPUS_DISABLED=1 (default) to skip
|
||||
|
|
@ -44,6 +45,54 @@ const server = http.createServer(async (req, res) => {
|
|||
if (method === 'OPTIONS') { res.writeHead(204); return res.end() }
|
||||
|
||||
try {
|
||||
// ── Hub auth gate — staged rollout ───────────────────────────────────
|
||||
// Tiers: PUBLIC (no auth) · customer self-service (own internal auth, also
|
||||
// listed PUBLIC here, e.g. /payments /portal) · staff-ADMIN (Bearer
|
||||
// HUB_SERVICE_TOKEN, injected server-side by the ops nginx /hub/ proxy).
|
||||
// Cross-origin apps with no token (client portal, field-tech) must only
|
||||
// use PUBLIC routes below.
|
||||
// HUB_GATE = 'enforce' (401 on deny) | 'report' (log + allow) | unset(off)
|
||||
// ALWAYS_ENFORCE = already locked & verified (no legit anon traffic) → 401
|
||||
// regardless of mode. /campaigns/webhook stays public (Mailjet → us).
|
||||
{
|
||||
const PUBLIC = [
|
||||
'/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
|
||||
'/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/',
|
||||
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/twiml', '/voice/status',
|
||||
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
|
||||
'/api/referral', '/api/accept-for-client',
|
||||
'/payments', // customer self-service (own auth) + ops (proxied)
|
||||
'/webhook/', '/campaigns/webhook',
|
||||
'/dispatch/calendar/', // token-authed .ics feed
|
||||
'/giftbit-demo', // démo sandbox testbed (validation Giftbit) — PUBLIC ; l'API /giftbit/* reste gatée
|
||||
'/conversations/email-ingest', // ingestion courriel n8n — PUBLIC mais protégé par X-Mail-Token dans le handler
|
||||
'/conversations/channel-ingest', // ingestion canal (web chat / 3CX / Facebook) — PUBLIC mais protégé par X-Mail-Token
|
||||
]
|
||||
const ALWAYS_ENFORCE = ['/devices', '/email-queue', '/campaigns', '/giftbit']
|
||||
// Web-chat client (lien magique /c/<token>) : le token EST le secret → routes CLIENT publiques.
|
||||
// GET /conversations/<token>, GET .../sse, POST .../messages, POST .../push UNIQUEMENT.
|
||||
// Exclut resolve/archive/discussion (réservées agent) ; le reste de /conversations reste gaté.
|
||||
const convChat = (() => {
|
||||
const mm = path.match(/^\/conversations\/([A-Za-z0-9_-]{5,10})(?:\/(messages|sse|push))?$/)
|
||||
if (!mm || ['resolve', 'archive', 'bulk', 'discussion'].includes(mm[1])) return false
|
||||
const sub = mm[2]
|
||||
if (!sub) return method === 'GET' // charger le fil
|
||||
if (sub === 'sse') return method === 'GET' // flux SSE client
|
||||
return method === 'POST' // messages | push
|
||||
})()
|
||||
const isPublic = convChat || PUBLIC.some(p => path.startsWith(p))
|
||||
if (!isPublic) {
|
||||
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
|
||||
const hasToken = !!process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN
|
||||
if (!hasToken) {
|
||||
const mustEnforce = ALWAYS_ENFORCE.some(p => path.startsWith(p))
|
||||
if (mustEnforce || process.env.HUB_GATE === 'enforce') {
|
||||
return json(res, 401, { error: 'Unauthorized' })
|
||||
}
|
||||
if (process.env.HUB_GATE === 'report') log(`[hub-gate] would-deny ${method} ${path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path === '/health') {
|
||||
return json(res, 200, { ok: true, clients: sse.clientCount(), topics: sse.topicCount(), uptime: Math.floor(process.uptime()) })
|
||||
}
|
||||
|
|
@ -98,12 +147,20 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
|
||||
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
|
||||
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
|
||||
if (path.startsWith('/giftbit')) return giftbit.handle(req, res, method, path, url)
|
||||
if (path.startsWith('/municipality')) return require('./lib/municipality').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/legacy-sync')) return require('./lib/legacy-sync').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/legacy-payments')) return require('./lib/legacy-payments').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/sync/')) return require('./lib/sync-orchestrator').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/coupons/')) return require('./lib/coupon-triage').handle(req, res, method, path)
|
||||
if (path.startsWith('/outbox')) return require('./lib/outbox').handle(req, res, method, path)
|
||||
if (path.startsWith('/address/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
|
||||
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
|
||||
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
|
||||
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
|
||||
// Lightweight tech mobile page: /t/{token}[/action]
|
||||
if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path)
|
||||
if (path.startsWith('/diag/')) return require('./lib/client-diag').route(req, res, method, path) // diagnostic client libre-service (token signé)
|
||||
if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)
|
||||
if (path.startsWith('/api/catalog') || path.startsWith('/api/checkout') || path.startsWith('/api/accept-for-client') || path.startsWith('/api/order') || path.startsWith('/api/address') || path.startsWith('/api/otp')) return require('./lib/checkout').handle(req, res, method, path)
|
||||
if (path.startsWith('/api/referral/')) return require('./lib/referral').handle(req, res, method, path)
|
||||
|
|
@ -118,6 +175,9 @@ const server = http.createServer(async (req, res) => {
|
|||
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
|
||||
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
|
||||
if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path)
|
||||
if (path.startsWith('/gmail')) return require('./lib/gmail').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/supplier-invoices')) return require('./lib/supplier-invoices').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/collab')) return require('./lib/ticket-collab').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
|
||||
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
|
||||
// Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
|
||||
|
|
@ -149,6 +209,7 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
||||
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res)
|
||||
if (path === '/vision/invoice' && method === 'POST') return vision.handleInvoice(req, res)
|
||||
if (path === '/vision/payment' && method === 'POST') return vision.handlePayment(req, res)
|
||||
if (path.startsWith('/ai/')) return require('./lib/ai').handle(req, res, method, path)
|
||||
if (path.startsWith('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
|
||||
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)
|
||||
|
|
@ -223,6 +284,9 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
|
|||
devices.startPoller()
|
||||
try { require('./lib/olt-snmp').startOltPoller() }
|
||||
catch (e) { log('OLT SNMP poller failed to start:', e.message) }
|
||||
// Tampon d'envoi sortant (undo-send des notifs de file) : réarme les envois retenus après redémarrage
|
||||
try { require('./lib/outbox').startup() }
|
||||
catch (e) { log('outbox startup failed:', e.message) }
|
||||
if (oktopusMqtt) oktopusMqtt.start()
|
||||
else log('Oktopus MQTT monitor: skipped (disabled)')
|
||||
// Start PPA (pre-authorized payment) cron scheduler
|
||||
|
|
@ -231,4 +295,10 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
|
|||
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
|
||||
try { require('./lib/legacy-dispatch-sync').startSync() }
|
||||
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
|
||||
// Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
|
||||
try { require('./lib/gmail').start() }
|
||||
catch (e) { log('gmail ingest failed to start:', e.message) }
|
||||
// Factures fournisseurs : courriels to:factures@ (via cc@) → OCR → candidats à revoir (Purchase Invoice draft)
|
||||
try { require('./lib/supplier-invoices').start() }
|
||||
catch (e) { log('supplier-invoices failed to start:', e.message) }
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user