/* backfill-wireless-equip.js — crée les Service Equipment OPS manquants pour les CPE SANS-FIL de F * (airos_ac / airosm / cambium). Résout customer+service_location via device→service→delivery_id→ * Service Location(legacy_delivery_id). NON DESTRUCTIF (crée seulement), dédup par MAC/IP, manifeste d'annulation. * Usage: node backfill-wireless-equip.js (DRY-RUN — compte + échantillon, 0 écriture) * node backfill-wireless-equip.js --apply (crée + écrit le manifeste) */ const erp = require('/app/lib/erp') const mysql = require('mysql2/promise') const fs = require('fs') const APPLY = process.argv.includes('--apply') const MANIFEST = '/app/data/backfill_wireless_equip.json' const normMac = m => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase() const brandOf = (cat, model) => /cambium/i.test(cat) ? 'Cambium' : (/ubiqu|nano|rocket|litebeam|powerbeam|bullet|airmax|airos|loco/i.test((model || '') + cat) ? 'Ubiquiti' : '') ;(async () => { let cfg = {}; try { cfg = require('/app/lib/config') } catch (e) { /* fallback to env below */ } const pool = mysql.createPool({ host: cfg.LEGACY_DB_HOST || process.env.LEGACY_DB_HOST || '10.100.80.100', user: cfg.LEGACY_DB_USER || process.env.LEGACY_DB_USER || 'facturation', password: cfg.LEGACY_DB_PASS || process.env.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME || process.env.LEGACY_DB_NAME || 'gestionclient', connectionLimit: 2, connectTimeout: 8000, }) // 1) CPE sans-fil F + delivery_id du service (préfère un service actif). const [rows] = await pool.query( "SELECT d.id device_id, d.category, d.manage, d.port, d.mac, d.sn, d.model, " + " (SELECT s.delivery_id FROM service s WHERE s.device_id=d.id AND s.delivery_id>0 ORDER BY (s.status=1) DESC, s.id DESC LIMIT 1) delivery_id " + " FROM device d WHERE d.category IN ('airos_ac','airosm','cambium') AND d.manage<>''") await pool.end() const wired = rows.filter(r => Number(r.delivery_id) > 0) // 2) Index Service Location OPS par legacy_delivery_id → {name, customer} const delIds = [...new Set(wired.map(r => Number(r.delivery_id)))] const slIndex = {} for (let i = 0; i < delIds.length; i += 100) { const batch = delIds.slice(i, i + 100) const sls = await erp.list('Service Location', { filters: [['legacy_delivery_id', 'in', batch]], fields: ['name', 'customer', 'legacy_delivery_id'], limit: 500 }).catch(() => []) for (const s of sls) if (s.customer) slIndex[Number(s.legacy_delivery_id)] = { name: s.name, customer: s.customer } } // 3) Service Equipment existants (ceux avec ip_address = univers sans-fil) → dédup par MAC + IP const existMac = new Set(), existIp = new Set() const existing = await erp.list('Service Equipment', { filters: [['ip_address', '!=', '']], fields: ['name', 'mac_address', 'ip_address'], limit: 8000 }).catch(() => []) for (const e of existing) { if (e.mac_address) existMac.add(normMac(e.mac_address)); if (e.ip_address) existIp.add(String(e.ip_address).trim()) } // 4) calcule les créations const toCreate = [], skips = { no_sl: 0, dup: 0, no_key: 0 } for (const r of wired) { const sl = slIndex[Number(r.delivery_id)] if (!sl) { skips.no_sl++; continue } const nm = normMac(r.mac), ip = String(r.manage).trim() if (!nm && !ip) { skips.no_key++; continue } // ni MAC ni IP → indédupable, on saute if ((nm && existMac.has(nm)) || (ip && existIp.has(ip))) { skips.dup++; continue } // serial_number OBLIGATOIRE. F sn souvent NULL pour les CPE sans-fil → repli sur la MAC (identifiant réel, unique), // sinon clé synthétique stable par device_id. mac_address garde le format d'origine (avec séparateurs). const serial = (r.sn && String(r.sn).trim()) ? String(r.sn).trim() : (nm || ('AIROS-' + r.device_id)) const doc = { equipment_type: 'Autre', brand: brandOf(r.category, r.model), model: r.model || '', serial_number: serial, mac_address: r.mac || '', ip_address: r.manage, login_user: 'admin', customer: sl.customer, service_location: sl.name, status: 'Actif', ownership: 'Gigafibre', } toCreate.push({ device_id: r.device_id, mac: r.mac, ip: r.manage, doc }) } console.log(JSON.stringify({ f_wireless: rows.length, with_delivery: wired.length, existing_wireless_se: existing.length, to_create: toCreate.length, skipped: skips }, null, 1)) console.log('SAMPLE:', JSON.stringify(toCreate.slice(0, 4).map(x => x.doc), null, 1)) if (!APPLY) { console.log('DRY-RUN — passe --apply pour créer.'); return } // 5) crée + manifeste const created = [] let n = 0 for (const c of toCreate) { const res = await erp.create('Service Equipment', c.doc).catch(e => ({ error: String(e.message || e) })) const okName = (res && typeof res.name === 'string') ? res.name : null if (okName) created.push({ name: okName, device_id: c.device_id, mac: c.mac, ip: c.ip }) else created.push({ error: String((res && res.error) || 'échec').slice(0, 160), status: res && res.status, device_id: c.device_id, mac: c.mac }) if (++n % 100 === 0) console.log('… ' + n + '/' + toCreate.length) } fs.writeFileSync(MANIFEST, JSON.stringify({ at: Date.now(), created }, null, 1)) console.log('CREATED ' + created.filter(c => c.name).length + ' / ' + toCreate.length + ' · errors ' + created.filter(c => c.error).length + ' · manifest ' + MANIFEST) })().catch(e => { console.log('FATAL', e.message) })