Phase 1 (hygiène) : utils partagés + logique pure testable + observabilité erp + 1ers tests
Modularisation / dé-duplication : - lib/util/text.js : `norm` canonique partagé (remplace 2 ré-implémentations : address-db, legacy-dispatch-sync). - lib/util/legacy-parse.js : parseurs/mapping PURS du pont (DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord) extraits hors I/O → testables en isolation, sans pg/mysql/erp. - legacy-dispatch-sync + address-db importent ces utils (pont vérifié en prod : preview OK, 0 erreur). Observabilité (sûr, additif, 1 seul point) : - erp.js create/update/remove : log de l'échec à la SOURCE quand HTTP≥400 → toutes les écritures ERPNext silencieuses des 50+ appelants sont désormais tracées, SANS changer aucun flux de contrôle. Tests (fondation) : - vitest + npm test ; test/util.test.js : 19 tests verts sur norm + coord(bornes QC)/prio/startTime/jobType/tzDate. Tournent sans installer les deps lourdes du hub (modules purs). Aligné docs/architecture/VISION.md (P0 hygiène). Suite : audit r.ok des appelants financiers (payments/contracts) en revue supervisée ; CI/CD minimal (Gitea Actions lint+test) ; décomposition des god-files (Phase 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f33f7a6309
commit
48c2f53d18
|
|
@ -18,7 +18,7 @@
|
||||||
const { Pool } = require('pg')
|
const { Pool } = require('pg')
|
||||||
const { log } = require('./helpers')
|
const { log } = require('./helpers')
|
||||||
|
|
||||||
const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim()
|
const { norm } = require('./util/text') // helper texte partagé (Phase 1 : dé-duplication)
|
||||||
|
|
||||||
let _pool
|
let _pool
|
||||||
function pool () {
|
function pool () {
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ async function create (doctype, body) {
|
||||||
const r = await erpFetch(`/api/resource/${encDocType(doctype)}`, {
|
const r = await erpFetch(`/api/resource/${encDocType(doctype)}`, {
|
||||||
method: 'POST', body: JSON.stringify(body),
|
method: 'POST', body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
|
if (r.status >= 400) { const error = errorMessage(r); log(`erp.create ${doctype} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
|
||||||
return { ok: true, data: r.data?.data, name: r.data?.data?.name }
|
return { ok: true, data: r.data?.data, name: r.data?.data?.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,13 +118,13 @@ async function update (doctype, name, body) {
|
||||||
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, {
|
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, {
|
||||||
method: 'PUT', body: JSON.stringify(body),
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
|
if (r.status >= 400) { const error = errorMessage(r); log(`erp.update ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
|
||||||
return { ok: true, data: r.data?.data }
|
return { ok: true, data: r.data?.data }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove (doctype, name) {
|
async function remove (doctype, name) {
|
||||||
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, { method: 'DELETE' })
|
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, { method: 'DELETE' })
|
||||||
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
|
if (r.status >= 400) { const error = errorMessage(r); log(`erp.remove ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,36 +30,10 @@ try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */
|
||||||
|
|
||||||
const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch)
|
const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch)
|
||||||
|
|
||||||
// dept_id legacy → job_type Dispatch Job (valeurs valides : Installation/Réparation/Retrait/Dépannage/Autre)
|
// Parseurs/mapping PURS extraits dans util/legacy-parse (testables en isolation) :
|
||||||
const DEPT_JOBTYPE = {
|
// DEPT_JOBTYPE/DUR + jobType/prio/tzDate/startTime/coord. + norm (util/text). (Phase 1 : logique pure séparée des I/O.)
|
||||||
27: 'Installation', 12: 'Installation', 7: 'Installation', // Installation Fibre / Installation / Monteur
|
const { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord } = require('./util/legacy-parse')
|
||||||
26: 'Réparation', 10: 'Réparation', 33: 'Réparation', // Réparation Fibre / Réparation / Fusionneur
|
const { norm } = require('./util/text')
|
||||||
15: 'Retrait', // Désinstallation
|
|
||||||
}
|
|
||||||
const DUR = { Installation: 2, 'Réparation': 1.5, Retrait: 1, 'Dépannage': 1, Autre: 1 } // durée par défaut (le legacy n'en a pas)
|
|
||||||
const jobType = (deptId) => DEPT_JOBTYPE[deptId] || 'Autre'
|
|
||||||
const prio = (p) => { p = Number(p) || 0; return p >= 3 ? 'high' : p === 2 ? 'medium' : 'low' }
|
|
||||||
// due_date legacy = epoch à minuit LOCAL → date America/Toronto (évite le décalage UTC)
|
|
||||||
const tzDate = (unix) => (unix ? new Date(Number(unix) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : null)
|
|
||||||
function startTime (dueTime) {
|
|
||||||
if (!dueTime) return null
|
|
||||||
const m = String(dueTime).match(/^(\d{1,2}):(\d{2})/)
|
|
||||||
if (m) return m[1].padStart(2, '0') + ':' + m[2] + ':00'
|
|
||||||
const t = String(dueTime).toLowerCase()
|
|
||||||
if (t === 'am') return '08:00:00'
|
|
||||||
if (t === 'pm') return '13:00:00'
|
|
||||||
return null // 'day' / inconnu → pas d'heure précise
|
|
||||||
}
|
|
||||||
const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim()
|
|
||||||
|
|
||||||
// Coords legacy = chaînes ("-73.5599440" / "45.2528570"). On parse + valide les bornes Québec
|
|
||||||
// (lat 44→63, lon -80→-57) pour rejeter 0/0, placeholders et valeurs aberrantes → routage fiable.
|
|
||||||
function coord (lat, lon) {
|
|
||||||
const la = parseFloat(lat), lo = parseFloat(lon)
|
|
||||||
if (!isFinite(la) || !isFinite(lo)) return null
|
|
||||||
if (la < 44 || la > 63 || lo < -80 || lo > -57) return null
|
|
||||||
return { lat: la, lon: lo }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
|
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
|
||||||
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
|
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
|
||||||
|
|
@ -420,4 +394,4 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets }
|
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType } // parseurs purs exposés pour les tests
|
||||||
|
|
|
||||||
36
services/targo-hub/lib/util/legacy-parse.js
Normal file
36
services/targo-hub/lib/util/legacy-parse.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* util/legacy-parse.js — parseurs/mapping PURS du pont legacy (osTicket → Dispatch Job).
|
||||||
|
* Aucune dépendance I/O (pg/mysql/erp) → testable en isolation (Phase 1 : logique pure séparée des I/O).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// dept_id legacy → job_type Dispatch Job (valeurs valides : Installation/Réparation/Retrait/Dépannage/Autre)
|
||||||
|
const DEPT_JOBTYPE = {
|
||||||
|
27: 'Installation', 12: 'Installation', 7: 'Installation', // Installation Fibre / Installation / Monteur
|
||||||
|
26: 'Réparation', 10: 'Réparation', 33: 'Réparation', // Réparation Fibre / Réparation / Fusionneur
|
||||||
|
15: 'Retrait', // Désinstallation
|
||||||
|
}
|
||||||
|
const DUR = { Installation: 2, 'Réparation': 1.5, Retrait: 1, 'Dépannage': 1, Autre: 1 } // durée par défaut (le legacy n'en a pas)
|
||||||
|
const jobType = (deptId) => DEPT_JOBTYPE[deptId] || 'Autre'
|
||||||
|
const prio = (p) => { p = Number(p) || 0; return p >= 3 ? 'high' : p === 2 ? 'medium' : 'low' }
|
||||||
|
// due_date legacy = epoch à minuit LOCAL → date America/Toronto (évite le décalage UTC)
|
||||||
|
const tzDate = (unix) => (unix ? new Date(Number(unix) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : null)
|
||||||
|
function startTime (dueTime) {
|
||||||
|
if (!dueTime) return null
|
||||||
|
const m = String(dueTime).match(/^(\d{1,2}):(\d{2})/)
|
||||||
|
if (m) return m[1].padStart(2, '0') + ':' + m[2] + ':00'
|
||||||
|
const t = String(dueTime).toLowerCase()
|
||||||
|
if (t === 'am') return '08:00:00'
|
||||||
|
if (t === 'pm') return '13:00:00'
|
||||||
|
return null // 'day' / inconnu → pas d'heure précise
|
||||||
|
}
|
||||||
|
// Coords legacy = chaînes ("-73.5599440" / "45.2528570"). Parse + valide les bornes Québec
|
||||||
|
// (lat 44→63, lon -80→-57) pour rejeter 0/0, placeholders et valeurs aberrantes → routage fiable.
|
||||||
|
function coord (lat, lon) {
|
||||||
|
const la = parseFloat(lat), lo = parseFloat(lon)
|
||||||
|
if (!isFinite(la) || !isFinite(lo)) return null
|
||||||
|
if (la < 44 || la > 63 || lo < -80 || lo > -57) return null
|
||||||
|
return { lat: la, lon: lo }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord }
|
||||||
17
services/targo-hub/lib/util/text.js
Normal file
17
services/targo-hub/lib/util/text.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* util/text.js — helpers texte PARTAGÉS (Phase 1 modularisation : dé-duplication).
|
||||||
|
* Remplace les ré-implémentations locales de `norm` (address-db, legacy-dispatch-sync, …).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Normalisation pour comparaison/recherche : minuscules + sans accents (NFD) + espaces compactés + trim.
|
||||||
|
// (Surensemble des variantes locales — le compactage d'espaces est inoffensif et plus robuste.)
|
||||||
|
const norm = (s) => (s || '')
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
module.exports = { norm }
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
"description": "SSE relay + unified message hub for Targo/Gigafibre",
|
"description": "SSE relay + unified message hub for Targo/Gigafibre",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js"
|
"start": "node server.js",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mjml": "^5.2.2",
|
"mjml": "^5.2.2",
|
||||||
|
|
|
||||||
42
services/targo-hub/test/util.test.js
Normal file
42
services/targo-hub/test/util.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Tests des helpers PURS (Phase 1 : fondation de tests, zéro dépendance I/O).
|
||||||
|
// Couvre la logique critique du pipeline d'adresses/pont legacy que je ne veux plus voir régresser.
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { norm } from '../lib/util/text.js'
|
||||||
|
import { coord, prio, startTime, jobType, tzDate } from '../lib/util/legacy-parse.js'
|
||||||
|
|
||||||
|
describe('norm', () => {
|
||||||
|
it('minuscule + sans accents', () => expect(norm('RUE René-Vinet')).toBe('rue rene-vinet'))
|
||||||
|
it('compacte les espaces + trim', () => expect(norm(' Sainte Clotilde ')).toBe('sainte clotilde'))
|
||||||
|
it('ville accentuée', () => expect(norm('Sainte-Clotilde-de-Châteauguay')).toBe('sainte-clotilde-de-chateauguay'))
|
||||||
|
it('null/undefined → chaîne vide', () => { expect(norm(null)).toBe(''); expect(norm(undefined)).toBe('') })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('coord (bornes Québec)', () => {
|
||||||
|
it('coord QC valide (chaînes legacy)', () => expect(coord('45.2528570', '-73.5599440')).toEqual({ lat: 45.252857, lon: -73.559944 }))
|
||||||
|
it('0/0 rejeté', () => expect(coord(0, 0)).toBeNull())
|
||||||
|
it('hors bornes (Toronto lat 43.65) rejeté', () => expect(coord(43.65, -79.38)).toBeNull())
|
||||||
|
it('longitude hors bornes rejetée', () => expect(coord(45.5, -50)).toBeNull())
|
||||||
|
it('non-numérique rejeté', () => { expect(coord('abc', 'x')).toBeNull(); expect(coord(null, null)).toBeNull() })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prio (priorité legacy → Dispatch)', () => {
|
||||||
|
it('≥3 → high', () => { expect(prio(3)).toBe('high'); expect(prio('5')).toBe('high') })
|
||||||
|
it('2 → medium', () => expect(prio(2)).toBe('medium'))
|
||||||
|
it('1/0/null → low', () => { expect(prio(1)).toBe('low'); expect(prio(0)).toBe('low'); expect(prio(null)).toBe('low') })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('startTime (due_time legacy → HH:MM:SS)', () => {
|
||||||
|
it('heure explicite', () => { expect(startTime('14:30')).toBe('14:30:00'); expect(startTime('9:05')).toBe('09:05:00') })
|
||||||
|
it('am/pm', () => { expect(startTime('am')).toBe('08:00:00'); expect(startTime('pm')).toBe('13:00:00') })
|
||||||
|
it('day / vide → null', () => { expect(startTime('day')).toBeNull(); expect(startTime('')).toBeNull() })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jobType (dept_id → job_type)', () => {
|
||||||
|
it('mappings connus', () => { expect(jobType(27)).toBe('Installation'); expect(jobType(26)).toBe('Réparation'); expect(jobType(15)).toBe('Retrait') })
|
||||||
|
it('inconnu → Autre', () => expect(jobType(999)).toBe('Autre'))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tzDate (epoch → date America/Toronto)', () => {
|
||||||
|
it('null → null', () => expect(tzDate(null)).toBeNull())
|
||||||
|
it('epoch → YYYY-MM-DD', () => expect(tzDate(1749182400)).toMatch(/^\d{4}-\d{2}-\d{2}$/))
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user