gigafibre-fsm/services/targo-hub/lib/poller-control.js
louispaulb aa5921481b feat: contract → chain → subscription → prorated invoice lifecycle + tech group claim
- contracts.js: built-in install chain fallback when no Flow Template matches
  on_contract_signed — every accepted contract now creates a master Issue +
  chained Dispatch Jobs (fiber_install template) so we never lose a signed
  contract to a missing flow config.
- acceptance.js: export createDeferredJobs + propagate assigned_group into
  Dispatch Job payload (was only in notes, not queryable).
- dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal,
  setJobStatusWithChain) + terminal-node detection that activates pending
  Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits
  a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing
  convention: activation day is free, first period starts next day.
- dispatch.js: fix Sales Invoice 417 by resolving company default income
  account (Ventes - T) and passing company + income_account on each item.
- dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech
  self-assignment from the group queue; enriches with customer_name /
  service_location via per-job fetches since those fetch_from fields aren't
  queryable in list API.
- TechTasksPage.vue: redesigned mobile-first UI with progress arc, status
  chips, and new "Tâches du groupe" section showing claimable unassigned
  jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked.
- NetworkPage.vue + poller-control.js: poller toggle semantics flipped —
  green when enabled, red/gray when paused; explicit status chips for clarity.

E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress →
Completed walks chain → SUB-0000100002 activated (start=2026-04-24) →
SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:40:54 -04:00

109 lines
3.8 KiB
JavaScript

'use strict'
// Poller pause control — test-phase friendly on/off switches for the two
// network pollers that run inside targo-hub:
//
// 1. device — GenieACS device-cache poll (~6000 ONTs every 5 min)
// 2. olt — SNMP sweep of the 4 OLTs (every 5 min)
//
// State is persisted to data/poller-control.json so pauses survive container
// restarts. Each poller's tick function first calls isPaused('x') and bails
// early (logging a one-liner so you can see pauses in the container log).
//
// HTTP surface:
// GET /admin/pollers → { device: {paused, ...}, olt: {...} }
// POST /admin/pollers { device, olt } → updates the flags (partial ok)
//
// Auth alignment: same tier as /olt/config + /olt/poll (unauthenticated at
// hub layer — the ops SPA that calls these is already behind Authentik SSO
// at the edge). Don't expose this without that front door in place.
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const STATE_PATH = path.join(__dirname, '..', 'data', 'poller-control.json')
const DEFAULT_STATE = {
device: { paused: false, lastChange: null, reason: '' },
olt: { paused: false, lastChange: null, reason: '' },
}
let state = { ...DEFAULT_STATE }
function load () {
try {
if (fs.existsSync(STATE_PATH)) {
const raw = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'))
state = {
device: { ...DEFAULT_STATE.device, ...(raw.device || {}) },
olt: { ...DEFAULT_STATE.olt, ...(raw.olt || {}) },
}
if (state.device.paused || state.olt.paused) {
log(`[poller-control] loaded — device.paused=${state.device.paused} olt.paused=${state.olt.paused}`)
}
}
} catch (e) { log(`[poller-control] load failed: ${e.message} — using defaults`) }
}
function persist () {
try {
const dir = path.dirname(STATE_PATH)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2))
} catch (e) { log(`[poller-control] persist failed: ${e.message}`) }
}
function isPaused (kind) {
return !!(state[kind] && state[kind].paused)
}
function setPaused (kind, paused, reason = '') {
if (!state[kind]) return false
const prev = state[kind].paused
state[kind].paused = !!paused
state[kind].lastChange = new Date().toISOString()
state[kind].reason = reason || ''
if (prev !== state[kind].paused) {
log(`[poller-control] ${kind} poller ${paused ? 'PAUSED' : 'RESUMED'}${reason ? `${reason}` : ''}`)
}
persist()
return true
}
function getState () {
return JSON.parse(JSON.stringify(state))
}
// ── HTTP handler ────────────────────────────────────────────────────────────
async function handle (req, res, method, reqPath) {
if (reqPath === '/admin/pollers' && method === 'GET') {
return json(res, 200, getState())
}
if (reqPath === '/admin/pollers' && method === 'POST') {
const body = await parseBody(req)
const updates = {}
for (const kind of ['device', 'olt']) {
if (body && typeof body[kind] === 'object' && body[kind] !== null) {
if (typeof body[kind].paused === 'boolean') {
setPaused(kind, body[kind].paused, body[kind].reason || '')
updates[kind] = true
}
} else if (body && typeof body[kind] === 'boolean') {
// shorthand: { device: true, olt: false }
setPaused(kind, body[kind])
updates[kind] = true
}
}
return json(res, 200, { ok: true, updates, state: getState() })
}
return json(res, 404, { error: 'not found' })
}
// initial load on require
load()
module.exports = { isPaused, setPaused, getState, handle, load }