gigafibre-fsm/services/targo-hub/lib/helpers.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

141 lines
4.9 KiB
JavaScript

'use strict'
const http = require('http')
const https = require('https')
const { URL } = require('url')
const cfg = require('./config')
function log (...args) { console.log(`[${new Date().toISOString().slice(11, 19)}]`, ...args) }
function json (res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
}
function parseBody (req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', c => chunks.push(c))
req.on('end', () => {
const raw = Buffer.concat(chunks).toString()
const ct = (req.headers['content-type'] || '').toLowerCase()
if (ct.includes('application/json')) {
try { resolve(JSON.parse(raw)) } catch { resolve({}) }
} else if (ct.includes('urlencoded')) {
resolve(Object.fromEntries(new URLSearchParams(raw)))
} else {
resolve(raw)
}
})
req.on('error', reject)
})
}
function httpRequest (baseUrl, path, { method = 'GET', body, headers = {}, timeout = 15000 } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(path, baseUrl)
const proto = u.protocol === 'https:' ? https : http
const req = proto.request({
hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search, method,
headers: { 'Content-Type': 'application/json', ...headers }, timeout,
}, (resp) => {
let data = ''
resp.on('data', c => { data += c })
resp.on('end', () => {
try { resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null }) }
catch { resolve({ status: resp.statusCode, data }) }
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout: ' + path)) })
if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body))
req.end()
})
}
function erpFetch (path, opts = {}) {
const parsed = new URL(cfg.ERP_URL + path)
return new Promise((resolve, reject) => {
const req = http.request({
hostname: parsed.hostname, port: parsed.port || 8000,
path: parsed.pathname + parsed.search, method: opts.method || 'GET',
headers: { Host: cfg.ERP_SITE, Authorization: 'token ' + cfg.ERP_TOKEN, 'Content-Type': 'application/json', ...opts.headers },
}, (res) => {
let body = ''
res.on('data', c => body += c)
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
catch { resolve({ status: res.statusCode, data: body }) }
})
})
req.on('error', reject)
if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body))
req.end()
})
}
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']) {
const filters = JSON.stringify([[field, 'like', '%' + digits]])
const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`
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) }
}
return null
}
function createCommunication (fields) {
return erpFetch('/api/resource/Communication', { method: 'POST', body: JSON.stringify(fields) })
}
// --- GenieACS NBI rate limiter ---
// Prevents overwhelming GenieACS with concurrent requests
const NBI_MAX_CONCURRENT = parseInt(process.env.NBI_MAX_CONCURRENT || '3')
const NBI_MIN_INTERVAL_MS = parseInt(process.env.NBI_MIN_INTERVAL_MS || '200')
let nbiActive = 0
let nbiLastRequest = 0
const nbiQueue = []
function nbiRequest (path, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
function execute () {
nbiActive++
const now = Date.now()
const wait = Math.max(0, NBI_MIN_INTERVAL_MS - (now - nbiLastRequest))
setTimeout(() => {
nbiLastRequest = Date.now()
httpRequest(cfg.GENIEACS_NBI_URL, path, { method, body, timeout: 30000 })
.then(resolve).catch(reject)
.finally(() => {
nbiActive--
if (nbiQueue.length > 0) nbiQueue.shift()()
})
}, wait)
}
if (nbiActive >= NBI_MAX_CONCURRENT) {
nbiQueue.push(execute)
} else {
execute()
}
})
}
function deepGetValue (obj, path) {
const node = path.split('.').reduce((o, k) => o?.[k], obj)
return node?._value !== undefined ? node._value : null
}
module.exports = {
log, json, parseBody, httpRequest,
erpFetch, erpRequest, lookupCustomerByPhone, createCommunication,
nbiRequest, deepGetValue,
}