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>
This commit is contained in:
parent
607ea54b5c
commit
41d9b5f316
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -24,8 +24,19 @@ exports/
|
|||
|
||||
# OS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Generated invoice/quote previews (output of setup_invoice_print_format.py
|
||||
# + test_jinja_render.py). Keep sources (*.jinja) and final references
|
||||
# (docs/assets/*.pdf when added intentionally), never ephemeral output.
|
||||
invoice_preview*.pdf
|
||||
scripts/migration/invoice_preview*.pdf
|
||||
scripts/migration/invoice_preview*.html
|
||||
scripts/migration/rendered_jinja_invoice*
|
||||
scripts/migration/SINV-*.pdf
|
||||
scripts/migration/ref_invoice.pdf
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -130,15 +130,9 @@ Authentik SSO protects staff apps via Traefik `forwardAuth`. The ops app reads `
|
|||
|
||||
| Document | Content |
|
||||
|----------|---------|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Data model, tech stack, authentication flow, doctype reference |
|
||||
| [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) | Server, DNS, Traefik, Authentik, Docker, n8n, gotchas |
|
||||
| [MIGRATION-PLAN.md](docs/MIGRATION-PLAN.md) | Legacy system portrait, field mapping, phases, risks |
|
||||
| [CHANGELOG.md](docs/CHANGELOG.md) | Detailed migration log with volumes and methods |
|
||||
| [ROADMAP.md](docs/ROADMAP.md) | 5-phase implementation plan |
|
||||
| [ECOSYSTEM-OVERVIEW.md](docs/ECOSYSTEM-OVERVIEW.md) | Full platform ecosystem and integration map |
|
||||
| [PLATFORM-STRATEGY.md](docs/PLATFORM-STRATEGY.md) | Platform strategy and product direction |
|
||||
| [CUSTOMER-360-FLOWS.md](docs/CUSTOMER-360-FLOWS.md) | Customer lifecycle flows and 360 view design |
|
||||
| [DESIGN_GUIDELINES.md](docs/DESIGN_GUIDELINES.md) | UI/UX design guidelines for ops apps |
|
||||
| [COMPETITIVE-ANALYSIS.md](docs/COMPETITIVE-ANALYSIS.md) | Comparison with Gaiia, Odoo, Zuper, Salesforce, ServiceTitan |
|
||||
| [TR069-TO-TR369-MIGRATION.md](docs/TR069-TO-TR369-MIGRATION.md) | CPE management protocol migration plan |
|
||||
| [scripts/migration/MIGRATION_MAP.md](scripts/migration/MIGRATION_MAP.md) | Field-level mapping from legacy tables to ERPNext |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Ecosystem overview, remote Docker infrastructure, platform strategy |
|
||||
| [DATA_AND_FLOWS.md](docs/DATA_AND_FLOWS.md) | ERPNext data models, atomic order creation, customer flows |
|
||||
| [CPE_MANAGEMENT.md](docs/CPE_MANAGEMENT.md) | Hardware management, XX230v diagnostics, TR-069/TR-369 |
|
||||
| [APP_DESIGN_GUIDELINES.md](docs/APP_DESIGN_GUIDELINES.md) | Frontend framework architecture rules, UI/UX Wizard guidelines |
|
||||
| [ROADMAP.md](docs/ROADMAP.md) | Implementation phases and current remote transition tasks |
|
||||
| [archive/](docs/archive/) | Completed legacy migration analyses and accounting audits |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { scanBarcodes } from 'src/api/ocr'
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
|
||||
const SCAN_TIMEOUT_MS = 8000
|
||||
|
||||
/**
|
||||
* Barcode scanner using device camera photo capture + Gemini Vision AI.
|
||||
|
|
@ -8,59 +11,99 @@ import { scanBarcodes } from 'src/api/ocr'
|
|||
* the native camera app — this gives proper autofocus, tap-to-focus,
|
||||
* and high-res photos. Then send to Gemini Vision for barcode extraction.
|
||||
*
|
||||
* Also keeps a thumbnail of each captured photo for reference.
|
||||
* Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE),
|
||||
* the photo is queued in IndexedDB via the offline store and retried when
|
||||
* the signal comes back. The tech gets a "scan en attente" indicator and
|
||||
* can keep working; late results are delivered via onNewCode().
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {(code: string) => void} [options.onNewCode] — called for each
|
||||
* newly detected code, whether the scan was synchronous or delivered
|
||||
* later from the offline queue. Typically used to trigger lookup + notify.
|
||||
*/
|
||||
export function useScanner () {
|
||||
export function useScanner (options = {}) {
|
||||
const onNewCode = options.onNewCode || (() => {})
|
||||
const barcodes = ref([]) // Array of { value, region } — max 3
|
||||
const scanning = ref(false) // true while Gemini is processing
|
||||
const error = ref(null)
|
||||
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
|
||||
const photos = ref([]) // all captured photo thumbnails
|
||||
|
||||
const offline = useOfflineStore()
|
||||
|
||||
// Pick up any scans that completed while the page was unmounted (e.g. tech
|
||||
// queued a photo, locked phone, walked out of the basement, signal returns).
|
||||
for (const result of offline.scanResults) {
|
||||
mergeCodes(result.barcodes || [], 'queued')
|
||||
offline.consumeScanResult(result.id)
|
||||
}
|
||||
|
||||
// Watch for sync completions during the lifetime of this scanner.
|
||||
// Vue auto-disposes the watcher when the host component unmounts.
|
||||
watch(
|
||||
() => offline.scanResults.length,
|
||||
() => {
|
||||
for (const result of [...offline.scanResults]) {
|
||||
mergeCodes(result.barcodes || [], 'queued')
|
||||
offline.consumeScanResult(result.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function addCode (code, region) {
|
||||
if (barcodes.value.length >= 3) return false
|
||||
if (barcodes.value.find(b => b.value === code)) return false
|
||||
barcodes.value.push({ value: code, region })
|
||||
onNewCode(code)
|
||||
return true
|
||||
}
|
||||
|
||||
function mergeCodes (codes, region) {
|
||||
const added = []
|
||||
for (const code of codes) {
|
||||
if (addCode(code, region)) added.push(code)
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a photo file from camera input.
|
||||
* Resizes for AI, keeps thumbnail, sends to Gemini.
|
||||
* @param {File} file - image file from camera
|
||||
* @returns {string[]} newly found barcode values
|
||||
* Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout.
|
||||
* On timeout/failure, the photo is queued for background retry.
|
||||
*/
|
||||
async function processPhoto (file) {
|
||||
if (!file) return []
|
||||
error.value = null
|
||||
scanning.value = true
|
||||
|
||||
const found = []
|
||||
let aiImage = null
|
||||
const photoIdx = photos.value.length
|
||||
let found = []
|
||||
|
||||
try {
|
||||
// Create thumbnail for display (small)
|
||||
const thumbUrl = await resizeImage(file, 400)
|
||||
lastPhoto.value = thumbUrl
|
||||
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [] })
|
||||
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
|
||||
|
||||
// Create optimized image for AI — keep high res for text readability
|
||||
const aiImage = await resizeImage(file, 1600, 0.92)
|
||||
aiImage = await resizeImage(file, 1600, 0.92)
|
||||
|
||||
// Send to Gemini Vision
|
||||
const result = await scanBarcodes(aiImage)
|
||||
const existing = new Set(barcodes.value.map(b => b.value))
|
||||
|
||||
for (const code of (result.barcodes || [])) {
|
||||
if (barcodes.value.length >= 3) break
|
||||
if (!existing.has(code)) {
|
||||
existing.add(code)
|
||||
barcodes.value.push({ value: code, region: 'photo' })
|
||||
found.push(code)
|
||||
}
|
||||
}
|
||||
|
||||
// Tag the photo with found codes
|
||||
const lastIdx = photos.value.length - 1
|
||||
if (lastIdx >= 0) photos.value[lastIdx].codes = found
|
||||
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
|
||||
found = mergeCodes(result.barcodes || [], 'photo')
|
||||
photos.value[photoIdx].codes = found
|
||||
|
||||
if (found.length === 0) {
|
||||
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||
}
|
||||
} catch (e) {
|
||||
if (aiImage && isRetryable(e)) {
|
||||
await offline.enqueueVisionScan({ image: aiImage })
|
||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||
} else {
|
||||
error.value = e.message || 'Erreur'
|
||||
}
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
|
|
@ -68,6 +111,25 @@ export function useScanner () {
|
|||
return found
|
||||
}
|
||||
|
||||
async function scanBarcodesWithTimeout (image, ms) {
|
||||
return await Promise.race([
|
||||
scanBarcodes(image),
|
||||
new Promise((_, reject) => setTimeout(
|
||||
() => reject(new Error('ScanTimeout')),
|
||||
ms,
|
||||
)),
|
||||
])
|
||||
}
|
||||
|
||||
function isRetryable (e) {
|
||||
const msg = (e?.message || '').toLowerCase()
|
||||
return msg.includes('scantimeout')
|
||||
|| msg.includes('failed to fetch')
|
||||
|| msg.includes('networkerror')
|
||||
|| msg.includes('load failed')
|
||||
|| e?.name === 'TypeError' // fetch throws TypeError on network error
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image file to a max dimension, return as base64 data URI.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@
|
|||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
|
||||
</div>
|
||||
|
||||
<!-- Pending scan indicator (signal faible) -->
|
||||
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
|
||||
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
|
||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
|
||||
</q-chip>
|
||||
</div>
|
||||
|
||||
<!-- Last captured photo (thumbnail) -->
|
||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||
|
|
@ -203,8 +210,13 @@ import { useOfflineStore } from 'src/stores/offline'
|
|||
import { Notify } from 'quasar'
|
||||
|
||||
const route = useRoute()
|
||||
const scanner = useScanner()
|
||||
const offline = useOfflineStore()
|
||||
const scanner = useScanner({
|
||||
onNewCode: (code) => {
|
||||
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
|
||||
lookupDevice(code)
|
||||
},
|
||||
})
|
||||
|
||||
const cameraInput = ref(null)
|
||||
const manualCode = ref('')
|
||||
|
|
@ -257,12 +269,10 @@ function takePhoto () {
|
|||
async function onPhoto (e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const found = await scanner.processPhoto(file)
|
||||
for (const code of found) {
|
||||
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
|
||||
lookupDevice(code)
|
||||
}
|
||||
// Codes are delivered through the onNewCode callback registered on the
|
||||
// scanner — fires both for sync scans and for queued scans that complete
|
||||
// later when the signal comes back.
|
||||
await scanner.processPhoto(file)
|
||||
}
|
||||
|
||||
function viewPhoto (photo) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||
import { ref, computed } from 'vue'
|
||||
import { get, set, del, keys } from 'idb-keyval'
|
||||
import { createDoc, updateDoc } from 'src/api/erp'
|
||||
import { scanBarcodes } from 'src/api/ocr'
|
||||
|
||||
export const useOfflineStore = defineStore('offline', () => {
|
||||
const queue = ref([])
|
||||
|
|
@ -9,8 +10,21 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||
const online = ref(navigator.onLine)
|
||||
const pendingCount = computed(() => queue.value.length)
|
||||
|
||||
// Vision scan queue — photos whose Gemini call timed out / failed,
|
||||
// waiting to be retried when the signal is back.
|
||||
const visionQueue = ref([]) // { id, image (base64), ts, status }
|
||||
const scanResults = ref([]) // completed scans not yet consumed by a page
|
||||
// { id, barcodes: string[], ts }
|
||||
const pendingVisionCount = computed(() => visionQueue.value.length)
|
||||
let retryTimer = null
|
||||
let visionSyncing = false
|
||||
|
||||
// Listen to connectivity changes
|
||||
window.addEventListener('online', () => { online.value = true; syncQueue() })
|
||||
window.addEventListener('online', () => {
|
||||
online.value = true
|
||||
syncQueue()
|
||||
syncVisionQueue()
|
||||
})
|
||||
window.addEventListener('offline', () => { online.value = false })
|
||||
|
||||
async function loadQueue () {
|
||||
|
|
@ -24,6 +38,25 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||
await set('offline-queue', JSON.parse(JSON.stringify(queue.value)))
|
||||
}
|
||||
|
||||
async function loadVisionQueue () {
|
||||
try {
|
||||
visionQueue.value = (await get('vision-queue')) || []
|
||||
scanResults.value = (await get('vision-results')) || []
|
||||
} catch {
|
||||
visionQueue.value = []
|
||||
scanResults.value = []
|
||||
}
|
||||
if (visionQueue.value.length) scheduleVisionRetry(5000)
|
||||
}
|
||||
|
||||
async function saveVisionQueue () {
|
||||
await set('vision-queue', JSON.parse(JSON.stringify(visionQueue.value)))
|
||||
}
|
||||
|
||||
async function saveScanResults () {
|
||||
await set('vision-results', JSON.parse(JSON.stringify(scanResults.value)))
|
||||
}
|
||||
|
||||
// Enqueue a mutation to be synced later
|
||||
async function enqueue (action) {
|
||||
// action = { type: 'create'|'update', doctype, name?, data, ts }
|
||||
|
|
@ -55,6 +88,68 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||
syncing.value = false
|
||||
}
|
||||
|
||||
// Enqueue a photo whose Gemini scan couldn't complete (timeout / offline).
|
||||
// Returns the queued entry so the caller can display a pending indicator.
|
||||
async function enqueueVisionScan ({ image }) {
|
||||
const entry = {
|
||||
id: Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||
image,
|
||||
ts: Date.now(),
|
||||
status: 'queued',
|
||||
}
|
||||
visionQueue.value.push(entry)
|
||||
await saveVisionQueue()
|
||||
scheduleVisionRetry(5000)
|
||||
return entry
|
||||
}
|
||||
|
||||
// Retry each queued photo. Success → move to scanResults, fail → stay queued
|
||||
// with a bumped retry schedule. navigator.onLine can lie in weak-signal
|
||||
// zones, so we drive retries off the queue itself, not off the online flag.
|
||||
async function syncVisionQueue () {
|
||||
if (visionSyncing) return
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (visionQueue.value.length === 0) return
|
||||
visionSyncing = true
|
||||
const remaining = []
|
||||
try {
|
||||
for (const entry of [...visionQueue.value]) {
|
||||
try {
|
||||
entry.status = 'syncing'
|
||||
const result = await scanBarcodes(entry.image)
|
||||
scanResults.value.push({
|
||||
id: entry.id,
|
||||
barcodes: result.barcodes || [],
|
||||
ts: Date.now(),
|
||||
})
|
||||
} catch {
|
||||
entry.status = 'queued'
|
||||
remaining.push(entry)
|
||||
}
|
||||
}
|
||||
visionQueue.value = remaining
|
||||
await Promise.all([saveVisionQueue(), saveScanResults()])
|
||||
if (remaining.length) scheduleVisionRetry(30000)
|
||||
} finally {
|
||||
visionSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVisionRetry (delay) {
|
||||
if (retryTimer) return
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
syncVisionQueue()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Consumer (ScanPage) calls this after merging a result into the UI so the
|
||||
// same serial doesn't reappear next time the page mounts.
|
||||
async function consumeScanResult (id) {
|
||||
scanResults.value = scanResults.value.filter(r => r.id !== id)
|
||||
await saveScanResults()
|
||||
}
|
||||
|
||||
// Cache data for offline reading
|
||||
async function cacheData (key, data) {
|
||||
await set('cache-' + key, { data, ts: Date.now() })
|
||||
|
|
@ -68,6 +163,12 @@ export const useOfflineStore = defineStore('offline', () => {
|
|||
}
|
||||
|
||||
loadQueue()
|
||||
loadVisionQueue()
|
||||
|
||||
return { queue, syncing, online, pendingCount, enqueue, syncQueue, cacheData, getCached, loadQueue }
|
||||
return {
|
||||
queue, syncing, online, pendingCount, enqueue, syncQueue,
|
||||
visionQueue, scanResults, pendingVisionCount,
|
||||
enqueueVisionScan, syncVisionQueue, consumeScanResult,
|
||||
cacheData, getCached, loadQueue,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
104
apps/ops/src/api/flow-templates.js
Normal file
104
apps/ops/src/api/flow-templates.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* api/flow-templates.js — Client for the Hub /flow/templates CRUD endpoints.
|
||||
*
|
||||
* Mirrors services/targo-hub/lib/flow-templates.js. All requests go through
|
||||
* the Hub (which handles ERPNext auth server-side).
|
||||
*
|
||||
* Functions:
|
||||
* listFlowTemplates({ category?, applies_to?, trigger_event?, is_active?, q? })
|
||||
* getFlowTemplate(name)
|
||||
* createFlowTemplate(body)
|
||||
* updateFlowTemplate(name, patch)
|
||||
* deleteFlowTemplate(name)
|
||||
* duplicateFlowTemplate(name, newName?)
|
||||
*
|
||||
* All functions return the parsed JSON body or throw on network / 4xx / 5xx.
|
||||
*/
|
||||
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
/** Fetch helper with error normalization. */
|
||||
async function hubFetch (path, { method = 'GET', body } = {}) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
||||
if (body) opts.body = JSON.stringify(body)
|
||||
const res = await fetch(`${HUB_URL}${path}`, opts)
|
||||
const text = await res.text()
|
||||
let data
|
||||
try { data = text ? JSON.parse(text) : {} }
|
||||
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
|
||||
if (!res.ok) {
|
||||
const msg = data.error || `HTTP ${res.status}`
|
||||
const err = new Error(msg)
|
||||
err.status = res.status
|
||||
err.detail = data.detail
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List flow templates with optional filters.
|
||||
* @param {Object} filters { category, applies_to, trigger_event, is_active, q, limit }
|
||||
* @returns {Promise<Array>} list of templates (without flow_definition body)
|
||||
*/
|
||||
export async function listFlowTemplates (filters = {}) {
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(filters)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
||||
}
|
||||
const path = `/flow/templates${qs.toString() ? '?' + qs.toString() : ''}`
|
||||
const data = await hubFetch(path)
|
||||
return data.templates || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single template with its parsed flow_definition.
|
||||
* @param {string} name FT-00001 etc.
|
||||
*/
|
||||
export async function getFlowTemplate (name) {
|
||||
const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}`)
|
||||
return data.template
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new (user) template. is_system is forced to 0 by the API.
|
||||
* @param {Object} body { template_name, category, applies_to, flow_definition, ... }
|
||||
*/
|
||||
export async function createFlowTemplate (body) {
|
||||
const data = await hubFetch('/flow/templates', { method: 'POST', body })
|
||||
return data.template
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch an existing template. Version is auto-bumped by the API.
|
||||
* @param {string} name
|
||||
* @param {Object} patch subset of fields
|
||||
*/
|
||||
export async function updateFlowTemplate (name, patch) {
|
||||
const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT', body: patch,
|
||||
})
|
||||
return data.template
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a template. Blocked server-side if is_system=1.
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function deleteFlowTemplate (name) {
|
||||
await hubFetch(`/flow/templates/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a template (e.g. to customize a system template).
|
||||
* Creates an inactive copy (is_active=0) for user to review before enabling.
|
||||
* @param {string} name
|
||||
* @param {string} [newName] optional override (defaults to "<name> (copie)")
|
||||
*/
|
||||
export async function duplicateFlowTemplate (name, newName) {
|
||||
const body = newName ? { template_name: newName } : {}
|
||||
const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}/duplicate`, {
|
||||
method: 'POST', body,
|
||||
})
|
||||
return data.template
|
||||
}
|
||||
76
apps/ops/src/components/flow-editor/FieldInput.vue
Normal file
76
apps/ops/src/components/flow-editor/FieldInput.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!--
|
||||
FieldInput.vue — Generic field renderer driven by a field descriptor.
|
||||
|
||||
Renders the right Quasar input based on `field.type`. Adding a new field
|
||||
type = adding a branch here. Used by StepEditorModal.
|
||||
|
||||
Props:
|
||||
- field: { name, type, label, required, options, placeholder, help, rows }
|
||||
- modelValue: current value (v-model)
|
||||
|
||||
Events:
|
||||
- update:modelValue(newValue)
|
||||
|
||||
Supported types:
|
||||
- 'text' → q-input type=text
|
||||
- 'textarea' → q-input type=textarea
|
||||
- 'number' → q-input type=number
|
||||
- 'select' → q-select with options
|
||||
- 'datetime' → q-input type=datetime-local
|
||||
- 'json' → q-input textarea with JSON.parse/stringify on blur
|
||||
- 'webhook' → q-input with URL validation
|
||||
-->
|
||||
<template>
|
||||
<component :is="inputComponent" v-bind="inputProps" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { QInput, QSelect } from 'quasar'
|
||||
|
||||
const props = defineProps({
|
||||
field: { type: Object, required: true },
|
||||
modelValue: { default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/** Pick the Quasar component based on field.type. */
|
||||
const inputComponent = computed(() => {
|
||||
return props.field.type === 'select' ? QSelect : QInput
|
||||
})
|
||||
|
||||
/** Props passed to the chosen component, computed once per render. */
|
||||
const inputProps = computed(() => {
|
||||
const f = props.field
|
||||
const base = {
|
||||
'model-value': props.modelValue ?? '',
|
||||
'onUpdate:modelValue': (v) => emit('update:modelValue', v),
|
||||
label: f.label + (f.required ? ' *' : ''),
|
||||
'stack-label': true,
|
||||
dense: true,
|
||||
outlined: true,
|
||||
hint: f.help,
|
||||
placeholder: f.placeholder,
|
||||
}
|
||||
|
||||
if (f.type === 'select') {
|
||||
return {
|
||||
...base,
|
||||
options: (f.options || []).map(o => typeof o === 'string' ? { label: o, value: o } : o),
|
||||
'emit-value': true,
|
||||
'map-options': true,
|
||||
}
|
||||
}
|
||||
|
||||
if (f.type === 'textarea' || f.type === 'json') {
|
||||
return { ...base, type: 'textarea', rows: f.rows || 3, autogrow: false }
|
||||
}
|
||||
|
||||
if (f.type === 'number') return { ...base, type: 'number' }
|
||||
if (f.type === 'datetime') return { ...base, type: 'datetime-local' }
|
||||
|
||||
// 'text' | 'webhook' | default
|
||||
return { ...base, type: 'text' }
|
||||
})
|
||||
</script>
|
||||
396
apps/ops/src/components/flow-editor/FlowEditor.vue
Normal file
396
apps/ops/src/components/flow-editor/FlowEditor.vue
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
<!--
|
||||
FlowEditor.vue — Visual editor for a flow_definition tree.
|
||||
|
||||
A domain-agnostic editor for step trees. Host components pass a kind catalog
|
||||
(PROJECT_KINDS, AGENT_KINDS, …) and the editor renders the tree, opens the
|
||||
step editor modal, and emits updates.
|
||||
|
||||
Props:
|
||||
- modelValue: the full flow_definition object (v-model). Shape:
|
||||
{ version, trigger, variables, steps: [Step, ...] }
|
||||
- kindCatalog: which kinds are allowed (controls the "add step" menu)
|
||||
- readonly: disable all editing (useful for preview mode)
|
||||
- appliesTo: domain context for the variable picker
|
||||
('Customer' | 'Quotation' | 'Service Contract' | 'Issue' | 'Subscription')
|
||||
|
||||
Events:
|
||||
- update:modelValue(newDef): emitted whenever the tree changes (add/edit/delete)
|
||||
- step-click(step): for hosts that want to intercept clicks
|
||||
|
||||
Reordering
|
||||
==========
|
||||
Supports two equivalent ways to change step order:
|
||||
1. Up/down arrows on each FlowNode (touch-friendly, discrete)
|
||||
2. Drag-and-drop via vuedraggable (mouse-friendly, continuous)
|
||||
|
||||
Both paths converge on `reorderPeers(scopePred, oldIdx, newIdx)` which does
|
||||
the heavy lifting:
|
||||
- Rebuilds the flat `steps` array with the peer subset in the new order,
|
||||
keeping non-peers and their flat positions untouched.
|
||||
- Detects a "clean sequential chain" (each peer_i depends only on peer_{i-1})
|
||||
and regenerates `depends_on` in the new order so that visual order =
|
||||
execution order. If the chain is non-sequential (parallel fork, explicit
|
||||
multi-deps), `depends_on` is left intact — we don't silently rewrite a
|
||||
hand-authored DAG.
|
||||
|
||||
DnD scope isolation
|
||||
-------------------
|
||||
Each peer group (same parent_id + same branch) renders inside its own
|
||||
<draggable> with a unique `group` name, so Sortable.js can't move a step
|
||||
across scopes. Cross-scope reordering is intentionally unsupported because
|
||||
it would require recomputing parent_id / branch, which the arrows UI can't
|
||||
express either — keeps the mental model consistent.
|
||||
|
||||
Performance notes
|
||||
-----------------
|
||||
- Uses shallow filter + computed for root-level steps (O(n) per render,
|
||||
n is small for typical flows < 50 steps).
|
||||
- Reorder is O(n) flat-array rebuild + O(peers) chain detection, both cheap.
|
||||
- Emits a complete def on each change — host should debounce save to API.
|
||||
-->
|
||||
<template>
|
||||
<div class="flow-editor">
|
||||
<div v-if="!steps.length" class="flow-editor-empty">
|
||||
<q-icon name="account_tree" size="32px" color="grey-5" />
|
||||
<div class="text-caption text-grey-7 q-mt-sm">Flow vide</div>
|
||||
<q-btn v-if="!readonly" color="indigo-6" label="Ajouter une étape" no-caps size="sm"
|
||||
icon="add" @click="openNew()" class="q-mt-sm" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Root-level draggable list: one sortable per scope. -->
|
||||
<draggable
|
||||
:list="rootPeersMirror"
|
||||
item-key="id"
|
||||
:group="{ name: 'flow-root', pull: false, put: false }"
|
||||
handle=".flow-drag-handle"
|
||||
:animation="150"
|
||||
ghost-class="flow-drag-ghost"
|
||||
chosen-class="flow-drag-chosen"
|
||||
drag-class="flow-drag-dragging"
|
||||
:disabled="readonly"
|
||||
@end="onRootDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FlowNode
|
||||
:step="element" :all-steps="steps" :kind-catalog="kindCatalog" :depth="0"
|
||||
:readonly="readonly"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@add="onAddChild"
|
||||
@move="onMove"
|
||||
@reorder="onReorderScope"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<button v-if="!readonly" class="flow-add-root" @click="openNew()">
|
||||
<q-icon name="add" size="16px" /> Ajouter une étape
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<StepEditorModal v-model="modalOpen"
|
||||
:step="editingStep"
|
||||
:kind-catalog="kindCatalog"
|
||||
:all-step-ids="allStepIds"
|
||||
:all-steps="steps"
|
||||
:applies-to="appliesTo"
|
||||
@save="onSave" />
|
||||
|
||||
<!-- Kind picker popover for "add new" -->
|
||||
<q-dialog v-model="kindPickerOpen">
|
||||
<q-card style="min-width: 320px; max-width: 95vw">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle2 text-weight-bold">Quel type d'étape ?</div>
|
||||
</q-card-section>
|
||||
<q-list separator>
|
||||
<q-item v-for="(def, key) in kindCatalog" :key="key" clickable
|
||||
@click="addStepOfKind(key)">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="def.icon" :style="{ color: def.color }" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ def.label }}</q-item-label>
|
||||
<q-item-label v-if="def.help" caption>{{ def.help }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import FlowNode from './FlowNode.vue'
|
||||
import StepEditorModal from './StepEditorModal.vue'
|
||||
import { buildEmptyStep } from './kind-catalogs'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
kindCatalog: { type: Object, required: true },
|
||||
readonly: { type: Boolean, default: false },
|
||||
appliesTo: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'step-click'])
|
||||
|
||||
/** All steps in the tree. Defaults to [] if flow def is empty. */
|
||||
const steps = computed(() => props.modelValue?.steps || [])
|
||||
|
||||
/** Top-level steps (no parent). */
|
||||
const rootSteps = computed(() => steps.value.filter(s => !s.parent_id))
|
||||
|
||||
/**
|
||||
* Local mirror of the root peer group for vuedraggable.
|
||||
*
|
||||
* vuedraggable mutates the array it's bound to (Sortable.js moves the DOM first,
|
||||
* then the component splices the underlying list to match). We can't feed it a
|
||||
* computed ref directly because that's read-only. So we maintain a local ref
|
||||
* and sync it from the authoritative `rootSteps` whenever the source changes.
|
||||
*
|
||||
* The watcher uses the stringified ID-order as the dependency so we only
|
||||
* re-mirror when the set/order actually changes — not on every step edit.
|
||||
*/
|
||||
const rootPeersMirror = ref([])
|
||||
watch(
|
||||
() => rootSteps.value.map(s => s.id).join('|'),
|
||||
() => { rootPeersMirror.value = [...rootSteps.value] },
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** All step IDs — used by the editor modal for depends_on selection. */
|
||||
const allStepIds = computed(() => steps.value.map(s => s.id))
|
||||
|
||||
// --- Editor modal state -----------------------------------------------------
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const editingStep = ref(null)
|
||||
|
||||
// --- Kind picker popover state ---------------------------------------------
|
||||
|
||||
const kindPickerOpen = ref(false)
|
||||
/** Pending add context: when we open the picker, remember where to attach. */
|
||||
const pendingParent = ref(null)
|
||||
const pendingBranch = ref(null)
|
||||
|
||||
/** Emit a full def update with the steps array replaced. */
|
||||
function emitSteps (newSteps) {
|
||||
emit('update:modelValue', { ...props.modelValue, steps: newSteps })
|
||||
}
|
||||
|
||||
/** Open the step editor for an existing step. */
|
||||
function onEdit (step) {
|
||||
if (props.readonly) return emit('step-click', step)
|
||||
editingStep.value = step
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
/** Save an edited step back into the tree (in place by id). */
|
||||
function onSave (edited) {
|
||||
const newSteps = steps.value.map(s => s.id === edited.id ? edited : s)
|
||||
emitSteps(newSteps)
|
||||
}
|
||||
|
||||
// ─── Reordering ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Core reorder primitive — used by both the arrow buttons and the drag-and-drop
|
||||
* path. Moves a peer from `oldPeerIdx` to `newPeerIdx` within the scope defined
|
||||
* by `scopePred`, rewrites `depends_on` if the chain was sequential, and emits.
|
||||
*
|
||||
* @param {(step) => boolean} scopePred identifies the peer group
|
||||
* @param {number} oldPeerIdx 0-based index within the peer group
|
||||
* @param {number} newPeerIdx 0-based target index within the peer group
|
||||
*/
|
||||
function reorderPeers (scopePred, oldPeerIdx, newPeerIdx) {
|
||||
if (props.readonly) return
|
||||
if (oldPeerIdx === newPeerIdx) return
|
||||
|
||||
const arr = steps.value.map(s => ({ ...s }))
|
||||
|
||||
// Record flat-array positions of peers so we can reinsert them in place.
|
||||
const peerFlatSlots = []
|
||||
arr.forEach((s, i) => { if (scopePred(s)) peerFlatSlots.push(i) })
|
||||
if (oldPeerIdx < 0 || oldPeerIdx >= peerFlatSlots.length) return
|
||||
if (newPeerIdx < 0 || newPeerIdx >= peerFlatSlots.length) return
|
||||
|
||||
const peersBefore = peerFlatSlots.map(i => arr[i])
|
||||
const peerIdSet = new Set(peersBefore.map(s => s.id))
|
||||
|
||||
// Detect a clean sequential chain: each peer_i depends on exactly peer_{i-1}
|
||||
// (and nothing else within the group). External deps don't count.
|
||||
const isSequential = peersBefore.every((p, i) => {
|
||||
const inGroup = (p.depends_on || []).filter(d => peerIdSet.has(d))
|
||||
if (i === 0) return inGroup.length === 0
|
||||
return inGroup.length === 1 && inGroup[0] === peersBefore[i - 1].id
|
||||
})
|
||||
|
||||
// Build the new peer order.
|
||||
const peersAfter = [...peersBefore]
|
||||
const [moved] = peersAfter.splice(oldPeerIdx, 1)
|
||||
peersAfter.splice(newPeerIdx, 0, moved)
|
||||
|
||||
// Regenerate sequential chain if that's what we detected.
|
||||
if (isSequential) {
|
||||
peersAfter.forEach((p, i) => {
|
||||
// Keep dependencies on steps outside the peer group (cross-scope refs).
|
||||
// Also filter out any self-ref that might have slipped in — this can't
|
||||
// happen from a clean reorder, but guards against stale data.
|
||||
const external = (p.depends_on || [])
|
||||
.filter(d => !peerIdSet.has(d) && d !== p.id)
|
||||
const prevId = i === 0 ? null : peersAfter[i - 1].id
|
||||
const chained = prevId && prevId !== p.id ? [...external, prevId] : external
|
||||
// Dedupe in case external already contained prevId for some reason.
|
||||
p.depends_on = [...new Set(chained)]
|
||||
})
|
||||
}
|
||||
|
||||
// Reinsert peers at their original flat positions, now in the new order.
|
||||
peerFlatSlots.forEach((flatIdx, peerIdx) => {
|
||||
arr[flatIdx] = peersAfter[peerIdx]
|
||||
})
|
||||
|
||||
emitSteps(arr)
|
||||
}
|
||||
|
||||
/**
|
||||
* ± 1 arrow-button reorder.
|
||||
* Translates to a reorderPeers call on the peer group of the given step.
|
||||
*/
|
||||
function onMove (id, dir) {
|
||||
const self = steps.value.find(s => s.id === id)
|
||||
if (!self) return
|
||||
const scopePred = (x) => x.parent_id === self.parent_id && x.branch === self.branch
|
||||
const peers = steps.value.filter(scopePred)
|
||||
const peerIdx = peers.findIndex(s => s.id === id)
|
||||
const target = dir === 'up' ? peerIdx - 1 : peerIdx + 1
|
||||
if (target < 0 || target >= peers.length) return
|
||||
reorderPeers(scopePred, peerIdx, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* DnD reorder event — bubbled up from any FlowNode at any depth.
|
||||
* Arguments describe the scope that was reordered + the new indices.
|
||||
*/
|
||||
function onReorderScope (parentId, branch, oldIdx, newIdx) {
|
||||
const scopePred = (x) =>
|
||||
(x.parent_id || null) === (parentId || null) &&
|
||||
(x.branch || null) === (branch || null)
|
||||
reorderPeers(scopePred, oldIdx, newIdx)
|
||||
}
|
||||
|
||||
/** DnD on the root scope — call Sortable's oldIndex/newIndex. */
|
||||
function onRootDragEnd (evt) {
|
||||
if (typeof evt.oldIndex !== 'number' || typeof evt.newIndex !== 'number') return
|
||||
// Sortable has already visually moved the DOM inside rootPeersMirror. That's
|
||||
// fine: after emitSteps + Vue re-render, the watcher resyncs the mirror from
|
||||
// the authoritative order, so there's no double-apply.
|
||||
reorderPeers(s => !s.parent_id, evt.oldIndex, evt.newIndex)
|
||||
}
|
||||
|
||||
// ─── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete a step and all its descendants (recursive cleanup). */
|
||||
function onDelete (stepId) {
|
||||
const toRemove = new Set([stepId])
|
||||
// Find transitive descendants by parent_id
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const s of steps.value) {
|
||||
if (s.parent_id && toRemove.has(s.parent_id) && !toRemove.has(s.id)) {
|
||||
toRemove.add(s.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const newSteps = steps.value
|
||||
.filter(s => !toRemove.has(s.id))
|
||||
// Also strip depends_on references to deleted steps
|
||||
.map(s => ({
|
||||
...s,
|
||||
depends_on: (s.depends_on || []).filter(d => !toRemove.has(d)),
|
||||
}))
|
||||
emitSteps(newSteps)
|
||||
}
|
||||
|
||||
// ─── Add step ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Open the kind picker for a new root-level step. */
|
||||
function openNew () {
|
||||
pendingParent.value = null
|
||||
pendingBranch.value = null
|
||||
kindPickerOpen.value = true
|
||||
}
|
||||
|
||||
/** Open the kind picker for a child step (under a condition/switch branch). */
|
||||
function onAddChild (parentId, branch) {
|
||||
pendingParent.value = parentId
|
||||
pendingBranch.value = branch
|
||||
kindPickerOpen.value = true
|
||||
}
|
||||
|
||||
/** After user picks a kind, create the step and open the editor. */
|
||||
function addStepOfKind (kindName) {
|
||||
const step = buildEmptyStep(kindName, props.kindCatalog)
|
||||
if (pendingParent.value) {
|
||||
step.parent_id = pendingParent.value
|
||||
step.branch = pendingBranch.value
|
||||
}
|
||||
const newSteps = [...steps.value, step]
|
||||
emitSteps(newSteps)
|
||||
kindPickerOpen.value = false
|
||||
// Open editor for the new step (next tick so the prop updates)
|
||||
setTimeout(() => {
|
||||
editingStep.value = step
|
||||
modalOpen.value = true
|
||||
}, 50)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-editor { display: flex; flex-direction: column; gap: 4px; }
|
||||
.flow-editor-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.flow-add-root {
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.flow-add-root:hover { background: #eef2ff; color: #4338ca; border-color: #6366f1; }
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Global drag-state styles — deliberately unscoped because Sortable.js assigns
|
||||
these classes to nodes that live inside recursive FlowNode children, and
|
||||
scoped <style> wouldn't reach them.
|
||||
-->
|
||||
<style>
|
||||
.flow-drag-ghost {
|
||||
opacity: 0.35;
|
||||
background: #e0e7ff !important;
|
||||
border: 2px dashed #6366f1 !important;
|
||||
}
|
||||
.flow-drag-chosen { cursor: grabbing; }
|
||||
.flow-drag-dragging {
|
||||
transform: rotate(0.5deg);
|
||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25) !important;
|
||||
border-color: #6366f1 !important;
|
||||
}
|
||||
</style>
|
||||
432
apps/ops/src/components/flow-editor/FlowEditorDialog.vue
Normal file
432
apps/ops/src/components/flow-editor/FlowEditorDialog.vue
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
<!--
|
||||
FlowEditorDialog.vue — Global, full-screen editor dialog for Flow Templates.
|
||||
|
||||
Mounted once in MainLayout; any page opens it via the useFlowEditor
|
||||
composable. This is the Odoo "inline create from anywhere" pattern.
|
||||
|
||||
What it does:
|
||||
- Shows metadata header (template_name, category, applies_to, icon,
|
||||
active/inactive toggle, version badge, system badge)
|
||||
- Renders the FlowEditor visual tree bound to template.flow_definition
|
||||
- Action bar: Save / Duplicate / Delete / Close
|
||||
- Protects system templates from destructive edits (offers Duplicate)
|
||||
|
||||
Performance:
|
||||
- Lazy: only renders the heavy FlowEditor when `isOpen` is true
|
||||
- Uses `markDirty` on every field blur instead of watching the whole doc
|
||||
(avoids O(n) deep-watch overhead on large flows)
|
||||
-->
|
||||
<template>
|
||||
<q-dialog :model-value="isOpen" @update:model-value="onModelUpdate" persistent maximized
|
||||
transition-show="slide-up" transition-hide="slide-down">
|
||||
<q-card class="flow-editor-dialog column no-wrap">
|
||||
<!-- Header -->
|
||||
<q-card-section class="fe-header">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-icon :name="template?.icon || 'account_tree'" size="24px" color="indigo-6" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle1 text-weight-bold">
|
||||
{{ mode === 'new' ? 'Nouveau template de flow' : (template?.template_name || templateName || '') }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
<span v-if="templateName">{{ templateName }}</span>
|
||||
<span v-if="template?.version" class="q-ml-sm">v{{ template.version }}</span>
|
||||
<q-badge v-if="template?.is_system" color="amber-2" text-color="amber-9" label="système"
|
||||
class="q-ml-sm" />
|
||||
<q-badge v-if="template && !template.is_active" color="grey-3" text-color="grey-7"
|
||||
label="inactif" class="q-ml-sm" />
|
||||
<q-badge v-if="dirty" color="orange-2" text-color="orange-9"
|
||||
label="non sauvegardé" class="q-ml-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" @click="onClose" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-card-section v-if="loading" class="flex flex-center q-pa-xl">
|
||||
<q-spinner size="40px" color="indigo-6" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-else-if="error" class="text-negative q-pa-md">
|
||||
<q-icon name="error" size="20px" class="q-mr-sm" />{{ error }}
|
||||
</q-card-section>
|
||||
|
||||
<!-- Body: meta grid + flow tree -->
|
||||
<q-card-section v-else-if="template" class="col fe-body">
|
||||
<div class="fe-body-grid">
|
||||
<!-- Left: metadata form -->
|
||||
<div class="fe-meta-col">
|
||||
<div class="text-caption text-weight-bold text-grey-7 q-mb-sm">Informations</div>
|
||||
|
||||
<q-input v-model="template.template_name" dense outlined stack-label label="Nom du template *"
|
||||
:disable="template.is_system" @update:model-value="markDirty" />
|
||||
|
||||
<div class="row q-col-gutter-sm q-mt-xs">
|
||||
<div class="col-6">
|
||||
<q-select v-model="template.category" dense outlined stack-label label="Catégorie"
|
||||
emit-value map-options :options="CATEGORY_OPTIONS"
|
||||
@update:model-value="markDirty" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select v-model="template.applies_to" dense outlined stack-label label="Applique à"
|
||||
emit-value map-options :options="APPLIES_TO_OPTIONS" clearable
|
||||
@update:model-value="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-input v-model="template.icon" dense outlined stack-label label="Icône (material)"
|
||||
placeholder="account_tree" class="q-mt-xs"
|
||||
@update:model-value="markDirty">
|
||||
<template #prepend><q-icon :name="template.icon || 'account_tree'" /></template>
|
||||
</q-input>
|
||||
|
||||
<q-select v-model="template.trigger_event" dense outlined stack-label label="Événement déclencheur"
|
||||
emit-value map-options :options="TRIGGER_EVENT_OPTIONS" clearable
|
||||
hint="Quand ce flow doit-il démarrer automatiquement ?"
|
||||
class="q-mt-xs" @update:model-value="markDirty" />
|
||||
|
||||
<!-- Trigger condition — JSONLogic (or simple expression). -->
|
||||
<div class="q-mt-xs">
|
||||
<div class="row items-center q-mb-xs">
|
||||
<div class="text-caption text-weight-medium text-grey-8">Condition de démarrage</div>
|
||||
<q-space />
|
||||
<VariablePicker :applies-to="template.applies_to"
|
||||
@insert="onInsertCondition" />
|
||||
</div>
|
||||
<q-input v-model="template.trigger_condition" dense outlined autogrow
|
||||
:placeholder="conditionPlaceholder"
|
||||
@update:model-value="markDirty" />
|
||||
<div class="text-caption text-grey-6 q-mt-xs">
|
||||
Facultatif. Filtre <b>au moment</b> où l'événement
|
||||
« {{ triggerEventLabel }} » arrive : le flow démarre seulement si
|
||||
la condition est vraie. <b>Ce n'est pas un scheduler</b> — pour
|
||||
« X jours après / à une date », utilisez une étape
|
||||
<code>wait</code> dans le flow.
|
||||
</div>
|
||||
|
||||
<q-expansion-item dense expand-separator icon="lightbulb"
|
||||
label="Exemples selon le contexte" class="q-mt-xs condition-help">
|
||||
<q-card flat class="q-mt-xs">
|
||||
<q-card-section class="q-pa-sm text-caption text-grey-8">
|
||||
<div v-if="conditionExamples.length">
|
||||
<div v-for="(ex, i) in conditionExamples" :key="i" class="q-mb-sm example-row">
|
||||
<div class="text-weight-medium text-grey-9">{{ ex.title }}</div>
|
||||
<div class="text-grey-7 q-mb-xs">{{ ex.desc }}</div>
|
||||
<div class="row items-start no-wrap">
|
||||
<div class="condition-code col">{{ ex.code }}</div>
|
||||
<q-btn flat dense size="xs" icon="north_east" color="indigo-6"
|
||||
class="q-ml-xs" @click="useExample(ex.code)">
|
||||
<q-tooltip>Utiliser cet exemple</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-grey-7">
|
||||
Choisissez un « Applique à » ci-dessus pour voir des exemples adaptés.
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-sm" />
|
||||
|
||||
<div class="text-caption text-grey-7">
|
||||
<b>Format long (JSONLogic)</b> pour conditions complexes :
|
||||
<div v-pre class="condition-code">{"and": [{"==": [{"var": "customer.customer_type"}, "Individual"]}, {">=": [{"var": "contract.monthly_price"}, 100]}]}</div>
|
||||
<div class="q-mt-xs"><b>Format court</b> pour cas simples :
|
||||
<div v-pre class="condition-code">customer.customer_type == "Individual"</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<q-input v-model="template.description" dense outlined stack-label type="textarea"
|
||||
:rows="2" label="Description" class="q-mt-xs"
|
||||
@update:model-value="markDirty" />
|
||||
|
||||
<q-input v-model="template.tags" dense outlined stack-label label="Tags (séparés par ,)"
|
||||
class="q-mt-xs" @update:model-value="markDirty" />
|
||||
|
||||
<div class="q-mt-sm">
|
||||
<q-toggle v-model="active" label="Actif" color="green-6"
|
||||
@update:model-value="onToggleActive" />
|
||||
</div>
|
||||
|
||||
<q-input v-model="template.notes" dense outlined stack-label type="textarea"
|
||||
:rows="3" label="Notes internes" class="q-mt-md"
|
||||
@update:model-value="markDirty" />
|
||||
</div>
|
||||
|
||||
<!-- Right: flow tree -->
|
||||
<div class="fe-tree-col">
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="text-caption text-weight-bold text-grey-7">Étapes du flow</div>
|
||||
<q-space />
|
||||
<q-badge v-if="stepCount" color="indigo-1" text-color="indigo-8" :label="`${stepCount} étape${stepCount > 1 ? 's' : ''}`" />
|
||||
</div>
|
||||
<FlowEditor :model-value="template.flow_definition"
|
||||
:kind-catalog="PROJECT_KINDS"
|
||||
:applies-to="template.applies_to"
|
||||
@update:model-value="onDefChange" />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<!-- Footer actions -->
|
||||
<q-card-actions class="fe-footer">
|
||||
<q-btn v-if="mode === 'edit' && !template?.is_system"
|
||||
flat color="red-7" icon="delete" label="Supprimer" no-caps
|
||||
@click="onDelete" />
|
||||
<q-btn v-if="mode === 'edit'"
|
||||
flat color="indigo-6" icon="content_copy" label="Dupliquer" no-caps
|
||||
@click="onDuplicate" />
|
||||
<q-space />
|
||||
<q-btn flat color="grey-7" label="Fermer" no-caps @click="onClose" />
|
||||
<q-btn unelevated color="indigo-6" icon="save"
|
||||
:label="mode === 'new' ? 'Créer' : 'Enregistrer'" no-caps
|
||||
:loading="saving" :disable="!canSave"
|
||||
@click="onSave" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { useFlowEditor } from 'src/composables/useFlowEditor'
|
||||
import FlowEditor from './FlowEditor.vue'
|
||||
import VariablePicker from './VariablePicker.vue'
|
||||
import { PROJECT_KINDS } from './kind-catalogs'
|
||||
|
||||
// ── Static option sets (defined once; never recomputed) ────────────────────
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ label: 'Résidentiel', value: 'residential' },
|
||||
{ label: 'Commercial', value: 'commercial' },
|
||||
{ label: 'Dépannage', value: 'incident' },
|
||||
{ label: 'Administratif',value: 'admin' },
|
||||
{ label: 'Agent AI', value: 'agent' },
|
||||
{ label: 'Autre', value: 'other' },
|
||||
]
|
||||
|
||||
const APPLIES_TO_OPTIONS = [
|
||||
{ label: 'Devis (Quotation)', value: 'Quotation' },
|
||||
{ label: 'Contrat (Service Contract)', value: 'Service Contract' },
|
||||
{ label: 'Ticket (Issue)', value: 'Issue' },
|
||||
{ label: 'Client (Customer)', value: 'Customer' },
|
||||
{ label: 'Abonnement (Subscription)', value: 'Subscription' },
|
||||
]
|
||||
|
||||
// Must match Flow Template DocType select options exactly — values are
|
||||
// validated server-side. If you add a new one, also extend the DocType.
|
||||
const TRIGGER_EVENT_OPTIONS = [
|
||||
{ label: 'Contrat signé', value: 'on_contract_signed' },
|
||||
{ label: 'Paiement reçu', value: 'on_payment_received' },
|
||||
{ label: 'Abonnement actif', value: 'on_subscription_active' },
|
||||
{ label: 'Devis créé', value: 'on_quotation_created' },
|
||||
{ label: 'Devis accepté', value: 'on_quotation_accepted' },
|
||||
{ label: 'Ticket ouvert', value: 'on_issue_opened' },
|
||||
{ label: 'Client créé', value: 'on_customer_created' },
|
||||
{ label: 'Intervention terminée', value: 'on_dispatch_completed' },
|
||||
{ label: 'Manuel (bouton)', value: 'manual' },
|
||||
]
|
||||
|
||||
// Domain-specific example conditions. Built per applies_to so the examples
|
||||
// reference the right variable paths and real business cases.
|
||||
const CONDITION_EXAMPLES = {
|
||||
Customer: [
|
||||
{ title: 'Particulier uniquement', desc: 'Filtre pour les clients résidentiels',
|
||||
code: '{"==": [{"var": "customer.customer_type"}, "Individual"]}' },
|
||||
{ title: 'Entreprise uniquement', desc: 'Filtre pour les clients commerciaux',
|
||||
code: '{"==": [{"var": "customer.customer_type"}, "Company"]}' },
|
||||
{ title: 'Clients francophones', desc: 'Routage par langue',
|
||||
code: '{"==": [{"var": "customer.language"}, "fr"]}' },
|
||||
{ title: 'Zone Montréal', desc: 'Routage géographique',
|
||||
code: '{"==": [{"var": "customer.territory"}, "Montréal"]}' },
|
||||
],
|
||||
Quotation: [
|
||||
{ title: 'Devis > 500 $', desc: 'Seulement pour les gros devis',
|
||||
code: '{">": [{"var": "quotation.grand_total"}, 500]}' },
|
||||
{ title: 'Devis résidentiel', desc: 'Filtre par type de client',
|
||||
code: '{"==": [{"var": "customer.customer_type"}, "Individual"]}' },
|
||||
],
|
||||
'Service Contract': [
|
||||
{ title: 'Contrats haut de gamme', desc: 'Onboarding VIP pour ≥ 100 $/mois',
|
||||
code: '{">=": [{"var": "contract.monthly_price"}, 100]}' },
|
||||
{ title: 'Plan Gigabit seulement', desc: 'Routage par plan',
|
||||
code: '{"==": [{"var": "contract.plan"}, "GIGA-1000"]}' },
|
||||
{ title: 'Contrats résidentiels', desc: 'Filtre par type de client',
|
||||
code: '{"==": [{"var": "customer.customer_type"}, "Individual"]}' },
|
||||
],
|
||||
Issue: [
|
||||
{ title: 'Tickets urgents', desc: 'Escalade immédiate',
|
||||
code: '{"==": [{"var": "issue.priority"}, "Urgent"]}' },
|
||||
{ title: 'Incidents techniques', desc: 'Filtre par type',
|
||||
code: '{"==": [{"var": "issue.issue_type"}, "Incident"]}' },
|
||||
{ title: 'Tickets haute priorité', desc: 'Urgent ou High',
|
||||
code: '{"in": [{"var": "issue.priority"}, ["Urgent", "High"]]}' },
|
||||
],
|
||||
Subscription: [
|
||||
{ title: 'Abonnements actifs', desc: 'Filtre sur le statut',
|
||||
code: '{"==": [{"var": "subscription.status"}, "Active"]}' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Composable state ────────────────────────────────────────────────────────
|
||||
const fe = useFlowEditor()
|
||||
const {
|
||||
isOpen, template, templateName, mode, loading, saving, error, dirty,
|
||||
markDirty, close, save, duplicate, remove,
|
||||
} = fe
|
||||
|
||||
// Local proxy for the active toggle (coerces 0/1 ↔ boolean)
|
||||
const active = ref(true)
|
||||
watch(() => template.value?.is_active, (v) => { active.value = v === undefined ? true : !!v })
|
||||
|
||||
function onToggleActive (v) {
|
||||
if (template.value) {
|
||||
template.value.is_active = v ? 1 : 0
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the flow_definition when the tree editor emits. */
|
||||
function onDefChange (newDef) {
|
||||
if (!template.value) return
|
||||
template.value.flow_definition = newDef
|
||||
markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a picked variable at the end of the trigger_condition field.
|
||||
* We don't try to insert at the cursor position — the extra plumbing
|
||||
* (textarea ref + selection range) isn't worth it for a field this small.
|
||||
* Users can always reposition manually.
|
||||
*/
|
||||
function onInsertCondition (text) {
|
||||
if (!template.value) return
|
||||
const cur = template.value.trigger_condition || ''
|
||||
template.value.trigger_condition = cur + text
|
||||
markDirty()
|
||||
}
|
||||
|
||||
/** Replace the whole condition with an example — clearer than appending. */
|
||||
function useExample (code) {
|
||||
if (!template.value) return
|
||||
template.value.trigger_condition = code
|
||||
markDirty()
|
||||
}
|
||||
|
||||
/** Contextual examples based on applies_to (empty list falls back to a hint). */
|
||||
const conditionExamples = computed(() => {
|
||||
const a = template.value?.applies_to
|
||||
return CONDITION_EXAMPLES[a] || []
|
||||
})
|
||||
|
||||
/** Placeholder adapted to the chosen applies_to (first example of that domain). */
|
||||
const conditionPlaceholder = computed(() => {
|
||||
const ex = conditionExamples.value[0]
|
||||
return ex?.code || '{"==": [{"var": "customer.customer_type"}, "Individual"]}'
|
||||
})
|
||||
|
||||
/** Human label of the currently picked trigger_event for the hint text. */
|
||||
const triggerEventLabel = computed(() => {
|
||||
const e = template.value?.trigger_event
|
||||
if (!e) return 'l\'événement choisi'
|
||||
return TRIGGER_EVENT_OPTIONS.find(o => o.value === e)?.label || e
|
||||
})
|
||||
|
||||
/** Number of steps in the current def (memoised by Vue). */
|
||||
const stepCount = computed(() => template.value?.flow_definition?.steps?.length || 0)
|
||||
|
||||
/** Gate the Save button. */
|
||||
const canSave = computed(() => {
|
||||
if (saving.value) return false
|
||||
if (!template.value?.template_name?.trim()) return false
|
||||
if (mode.value === 'edit' && !dirty.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function onSave () {
|
||||
try {
|
||||
await save()
|
||||
Notify.create({ type: 'positive', message: 'Template sauvegardé', timeout: 1500 })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDuplicate () {
|
||||
try {
|
||||
await duplicate()
|
||||
Notify.create({ type: 'positive', message: 'Template dupliqué (copie inactive)', timeout: 2000 })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete () {
|
||||
if (!window.confirm(`Supprimer le template "${template.value?.template_name}" ?`)) return
|
||||
try {
|
||||
await remove()
|
||||
Notify.create({ type: 'info', message: 'Template supprimé', timeout: 1500 })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
function onClose () { close() }
|
||||
|
||||
function onModelUpdate (v) { if (!v) close() }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-editor-dialog {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.fe-header { padding: 10px 16px; background: #fff; }
|
||||
.fe-body { overflow-y: auto; padding: 16px; }
|
||||
.fe-footer { padding: 10px 16px; background: #fff; }
|
||||
|
||||
.fe-body-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.fe-body-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.fe-meta-col, .fe-tree-col {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Condition helper: compact code snippets so the examples don't wrap awkwardly. */
|
||||
.condition-help :deep(.q-item) { min-height: 32px; padding: 6px 8px; }
|
||||
.condition-code {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 3px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.example-row { padding: 6px 0; border-bottom: 1px solid #f1f5f9; }
|
||||
.example-row:last-child { border-bottom: none; }
|
||||
</style>
|
||||
447
apps/ops/src/components/flow-editor/FlowNode.vue
Normal file
447
apps/ops/src/components/flow-editor/FlowNode.vue
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
<!--
|
||||
FlowNode.vue — Recursive node component for the FlowEditor tree.
|
||||
|
||||
Renders a single step as a clickable card + its children (branches and
|
||||
nested nodes). The component is fully generic: behaviour depends on the
|
||||
`kindCatalog` prop, never on the kind name directly.
|
||||
|
||||
Props:
|
||||
- step: the step object to render (has id, kind, label, payload, ...)
|
||||
- allSteps: full steps array (for looking up children)
|
||||
- kindCatalog: PROJECT_KINDS or AGENT_KINDS (see kind-catalogs.js)
|
||||
- depth: nesting depth (for indentation)
|
||||
- readonly: disable reorder + delete controls
|
||||
|
||||
Events:
|
||||
- edit(step): user clicked a node to edit
|
||||
- delete(id): user wants to delete step
|
||||
- add(parentId, branch): user wants to add a child under this step
|
||||
- move(id, dir): reorder within the same (parent_id, branch) scope;
|
||||
dir is 'up' or 'down'
|
||||
- reorder(parentId, branch, oldIdx, newIdx): DnD reorder, bubbled up
|
||||
through the recursive tree so FlowEditor can handle it.
|
||||
|
||||
Drag-and-drop
|
||||
-------------
|
||||
Each peer group gets its own <draggable> with a unique `group` name derived
|
||||
from `step.id + branch`. This prevents Sortable.js from moving items across
|
||||
scopes (which would require recomputing parent_id/branch semantics).
|
||||
|
||||
Each draggable binds to a LOCAL MIRROR ref, kept in sync via watchers on the
|
||||
authoritative `allSteps` prop. Sortable mutates those mirrors during the
|
||||
drag; on drag end we emit `reorder` and let FlowEditor rebuild the flat
|
||||
steps array, which triggers a re-render that resyncs the mirrors.
|
||||
|
||||
Performance:
|
||||
- Children are computed once via a cached filter (Vue memoizes computed).
|
||||
- Mirror watchers run only when the ID-order of a scope actually changes.
|
||||
- Recursion bounded by tree depth (linear in number of steps).
|
||||
-->
|
||||
<template>
|
||||
<div class="flow-node-wrap" :style="{ marginLeft: depth ? '16px' : '0' }">
|
||||
<div class="flow-node" :class="`flow-node-${step.kind}`"
|
||||
:style="{ borderLeftColor: kindDef.color }"
|
||||
@click.stop="$emit('edit', step)">
|
||||
<div class="flow-node-head">
|
||||
<!-- Drag handle — Sortable.js only starts a drag when the user grabs
|
||||
this exact element (see handle=".flow-drag-handle" on the
|
||||
<draggable> wrapper in FlowEditor / the recursive branches below). -->
|
||||
<q-icon v-if="!readonly" name="drag_indicator"
|
||||
size="16px" color="grey-5" class="flow-drag-handle"
|
||||
@click.stop>
|
||||
<q-tooltip>Glisser pour réordonner</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon :name="kindDef.icon" size="16px" :style="{ color: kindDef.color }" class="flow-node-icon" />
|
||||
<span class="flow-node-label">{{ step.label || step.id }}</span>
|
||||
<span class="flow-node-type">{{ kindDef.label }}</span>
|
||||
<span v-if="triggerBadge" class="flow-node-badge">{{ triggerBadge }}</span>
|
||||
<div v-if="!readonly" class="flow-node-actions">
|
||||
<q-btn flat round dense size="xs" icon="arrow_upward" color="grey-6"
|
||||
:disable="!canMoveUp"
|
||||
@click.stop="$emit('move', step.id, 'up')">
|
||||
<q-tooltip>Déplacer vers le haut</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round dense size="xs" icon="arrow_downward" color="grey-6"
|
||||
:disable="!canMoveDown"
|
||||
@click.stop="$emit('move', step.id, 'down')">
|
||||
<q-tooltip>Déplacer vers le bas</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round dense size="xs" icon="close" color="grey-5"
|
||||
@click.stop="$emit('delete', step.id)">
|
||||
<q-tooltip>Supprimer</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="summaryLines.length" class="flow-node-body">
|
||||
<div v-for="(line, i) in summaryLines" :key="i" class="flow-node-detail">{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branches (for condition/switch-like kinds) -->
|
||||
<div v-if="hasBranches" class="flow-node-branches">
|
||||
<div v-for="branch in branchNames" :key="branch" class="flow-branch">
|
||||
<div class="flow-branch-label" :class="`flow-branch-${branch}`">
|
||||
{{ branchLabel(branch) }}
|
||||
</div>
|
||||
<draggable
|
||||
:list="branchMirrors[branch] || []"
|
||||
item-key="id"
|
||||
:group="{ name: `flow-${step.id}-${branch}`, pull: false, put: false }"
|
||||
handle=".flow-drag-handle"
|
||||
:animation="150"
|
||||
ghost-class="flow-drag-ghost"
|
||||
chosen-class="flow-drag-chosen"
|
||||
drag-class="flow-drag-dragging"
|
||||
:disabled="readonly"
|
||||
@end="(evt) => onBranchDragEnd(branch, evt)"
|
||||
>
|
||||
<template #item="{ element: child }">
|
||||
<FlowNode
|
||||
:step="child" :all-steps="allSteps" :kind-catalog="kindCatalog" :depth="depth + 1"
|
||||
:readonly="readonly"
|
||||
@edit="(s) => $emit('edit', s)"
|
||||
@delete="(id) => $emit('delete', id)"
|
||||
@add="(p, b) => $emit('add', p, b)"
|
||||
@move="(id, d) => $emit('move', id, d)"
|
||||
@reorder="(p, b, o, n) => $emit('reorder', p, b, o, n)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<button v-if="!readonly" class="flow-add-child"
|
||||
@click.stop="$emit('add', step.id, branch)">+</button>
|
||||
</div>
|
||||
<button v-if="dynamicBranches && !readonly" class="flow-add-branch"
|
||||
@click.stop="onAddBranch">+ Branche</button>
|
||||
</div>
|
||||
|
||||
<!-- Non-branching children (flat children without a branch name) -->
|
||||
<div v-else-if="flatChildren.length" class="flow-node-children">
|
||||
<draggable
|
||||
:list="flatMirror"
|
||||
item-key="id"
|
||||
:group="{ name: `flow-${step.id}-flat`, pull: false, put: false }"
|
||||
handle=".flow-drag-handle"
|
||||
:animation="150"
|
||||
ghost-class="flow-drag-ghost"
|
||||
chosen-class="flow-drag-chosen"
|
||||
drag-class="flow-drag-dragging"
|
||||
:disabled="readonly"
|
||||
@end="onFlatDragEnd"
|
||||
>
|
||||
<template #item="{ element: child }">
|
||||
<FlowNode
|
||||
:step="child" :all-steps="allSteps" :kind-catalog="kindCatalog" :depth="depth + 1"
|
||||
:readonly="readonly"
|
||||
@edit="(s) => $emit('edit', s)"
|
||||
@delete="(id) => $emit('delete', id)"
|
||||
@add="(p, b) => $emit('add', p, b)"
|
||||
@move="(id, d) => $emit('move', id, d)"
|
||||
@reorder="(p, b, o, n) => $emit('reorder', p, b, o, n)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { getKind, getTrigger } from './kind-catalogs'
|
||||
|
||||
const props = defineProps({
|
||||
step: { type: Object, required: true },
|
||||
allSteps: { type: Array, default: () => [] },
|
||||
kindCatalog: { type: Object, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
readonly: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'add', 'move', 'reorder'])
|
||||
|
||||
/**
|
||||
* A step can only move within its (parent_id, branch) scope — that keeps
|
||||
* branch semantics intact. We compute the peers ordered by their index in
|
||||
* the global `allSteps` array (which is the source of truth for ordering).
|
||||
*/
|
||||
const peers = computed(() =>
|
||||
props.allSteps.filter(
|
||||
s => s.parent_id === props.step.parent_id && s.branch === props.step.branch,
|
||||
),
|
||||
)
|
||||
const peerIndex = computed(() => peers.value.findIndex(s => s.id === props.step.id))
|
||||
const canMoveUp = computed(() => peerIndex.value > 0)
|
||||
const canMoveDown = computed(() => peerIndex.value >= 0 && peerIndex.value < peers.value.length - 1)
|
||||
|
||||
/** Resolved kind descriptor from the catalog (icon, color, branchLabels, …). */
|
||||
const kindDef = computed(() => getKind(props.kindCatalog, props.step.kind))
|
||||
|
||||
/** True when this kind supports branches (yes/no for condition, dynamic for switch). */
|
||||
const hasBranches = computed(() => !!kindDef.value.hasBranches)
|
||||
|
||||
/** True when the kind supports adding arbitrary branch names (e.g. switch). */
|
||||
const dynamicBranches = computed(() => kindDef.value.hasBranches === 'dynamic')
|
||||
|
||||
/** Branch names: either fixed (yes/no) or all distinct branches of children. */
|
||||
const branchNames = computed(() => {
|
||||
if (kindDef.value.branchLabels) return Object.keys(kindDef.value.branchLabels)
|
||||
const set = new Set()
|
||||
for (const s of props.allSteps) {
|
||||
if (s.parent_id === props.step.id && s.branch) set.add(s.branch)
|
||||
}
|
||||
return [...set]
|
||||
})
|
||||
|
||||
/** Human label for a branch (from catalog definitions or raw key). */
|
||||
function branchLabel (branch) {
|
||||
return kindDef.value.branchLabels?.[branch] || branch
|
||||
}
|
||||
|
||||
/** Children in a specific branch (e.g. 'yes' branch of a condition). */
|
||||
function childrenOfBranch (branch) {
|
||||
return props.allSteps.filter(s => s.parent_id === props.step.id && s.branch === branch)
|
||||
}
|
||||
|
||||
/** Children without a branch (flat nesting, rarely used). */
|
||||
const flatChildren = computed(() =>
|
||||
props.allSteps.filter(s => s.parent_id === props.step.id && !s.branch),
|
||||
)
|
||||
|
||||
// ── Local mirrors for vuedraggable ─────────────────────────────────────────
|
||||
//
|
||||
// vuedraggable mutates the list it's bound to. We can't give it a computed
|
||||
// ref (read-only), so we keep local mirrors and resync them whenever the
|
||||
// authoritative source changes. The watchers key off stringified ID-order so
|
||||
// they don't fire on unrelated edits (e.g. a peer's payload change).
|
||||
|
||||
/** Per-branch mirrors: { yes: [step,...], no: [step,...], ... } */
|
||||
const branchMirrors = ref({})
|
||||
/** Flat-children mirror. */
|
||||
const flatMirror = ref([])
|
||||
|
||||
watch(
|
||||
() => branchNames.value.map(b => `${b}:${childrenOfBranch(b).map(s => s.id).join(',')}`).join('|'),
|
||||
() => {
|
||||
const next = {}
|
||||
for (const b of branchNames.value) next[b] = [...childrenOfBranch(b)]
|
||||
branchMirrors.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => flatChildren.value.map(s => s.id).join('|'),
|
||||
() => { flatMirror.value = [...flatChildren.value] },
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** DnD ended inside a branch — bubble the reorder up with scope info. */
|
||||
function onBranchDragEnd (branch, evt) {
|
||||
if (typeof evt.oldIndex !== 'number' || typeof evt.newIndex !== 'number') return
|
||||
if (evt.oldIndex === evt.newIndex) return
|
||||
emit('reorder', props.step.id, branch, evt.oldIndex, evt.newIndex)
|
||||
}
|
||||
|
||||
/** DnD ended inside flatChildren — bubble up (branch = null). */
|
||||
function onFlatDragEnd (evt) {
|
||||
if (typeof evt.oldIndex !== 'number' || typeof evt.newIndex !== 'number') return
|
||||
if (evt.oldIndex === evt.newIndex) return
|
||||
emit('reorder', props.step.id, null, evt.oldIndex, evt.newIndex)
|
||||
}
|
||||
|
||||
/** Prompt user for a new branch name (used for switch dynamic branches). */
|
||||
function onAddBranch () {
|
||||
// eslint-disable-next-line no-alert
|
||||
const branch = prompt('Nom de la branche (ex: dying_gasp)')
|
||||
if (branch) emit('add', props.step.id, branch)
|
||||
}
|
||||
|
||||
/** Short badge text describing the trigger (shown on the node card). */
|
||||
const triggerBadge = computed(() => {
|
||||
const t = props.step.trigger?.type
|
||||
if (!t || t === 'on_prev_complete') return null
|
||||
const td = getTrigger(t)
|
||||
if (t === 'after_delay') {
|
||||
const h = props.step.trigger.delay_hours
|
||||
const d = props.step.trigger.delay_days
|
||||
if (d) return `+ ${d}j`
|
||||
if (h) return `+ ${h}h`
|
||||
return td.label
|
||||
}
|
||||
return td.label
|
||||
})
|
||||
|
||||
/**
|
||||
* 1-2 lines of payload summary shown under the label (compact UX).
|
||||
* Skip empty payloads to keep the node card tight.
|
||||
*/
|
||||
const summaryLines = computed(() => {
|
||||
const lines = []
|
||||
const p = props.step.payload || {}
|
||||
switch (props.step.kind) {
|
||||
case 'dispatch_job':
|
||||
if (p.subject) lines.push(p.subject)
|
||||
if (p.assigned_group) lines.push(`${p.job_type || 'Job'} · ${p.assigned_group} · ${p.duration_h || 1}h`)
|
||||
break
|
||||
case 'issue':
|
||||
if (p.subject) lines.push(p.subject)
|
||||
break
|
||||
case 'notify':
|
||||
if (p.channel && p.template_id) lines.push(`${p.channel.toUpperCase()} · ${p.template_id}`)
|
||||
else if (p.channel) lines.push(p.channel.toUpperCase())
|
||||
break
|
||||
case 'webhook':
|
||||
if (p.url) lines.push(`${p.method || 'POST'} ${p.url.slice(0, 50)}${p.url.length > 50 ? '…' : ''}`)
|
||||
break
|
||||
case 'erp_update':
|
||||
if (p.doctype) lines.push(`${p.doctype}${p.docname_ref ? ` · ${p.docname_ref}` : ''}`)
|
||||
break
|
||||
case 'condition':
|
||||
if (p.field) lines.push(`${p.field} ${p.op || '=='} ${p.value ?? ''}`)
|
||||
break
|
||||
case 'subscription_activate':
|
||||
if (p.subscription_ref) lines.push(p.subscription_ref)
|
||||
break
|
||||
case 'respond':
|
||||
if (p.message) lines.push(p.message.length > 70 ? p.message.slice(0, 70) + '…' : p.message)
|
||||
break
|
||||
case 'action':
|
||||
if (p.action) lines.push(p.action)
|
||||
break
|
||||
case 'tool':
|
||||
if (p.tool) lines.push(p.tool)
|
||||
break
|
||||
}
|
||||
if (props.step.depends_on?.length) {
|
||||
// Resolve dep IDs → human labels so the user sees step NAMES, not cryptic
|
||||
// "s4" tokens that no longer match position after reorder. If a referenced
|
||||
// step is missing (orphan ref), we keep the raw id and mark it with a
|
||||
// question mark so it's obvious something's stale.
|
||||
const labels = props.step.depends_on.map(id => {
|
||||
const dep = props.allSteps.find(s => s.id === id)
|
||||
return dep?.label || dep?.id || `${id} ?`
|
||||
})
|
||||
lines.push(`← ${labels.join(', ')}`)
|
||||
}
|
||||
return lines
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-node-wrap { margin-bottom: 4px; }
|
||||
|
||||
.flow-node {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #94a3b8;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.flow-node:hover {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.flow-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.flow-node-icon { flex-shrink: 0; }
|
||||
|
||||
/* Drag handle: cursor changes on hover; visually muted until the card is hovered. */
|
||||
.flow-drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flow-node:hover .flow-drag-handle { opacity: 0.8; }
|
||||
.flow-drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.flow-node-label {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow-node-type {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flow-node-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: #fef3c7;
|
||||
color: #a16207;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flow-node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flow-node:hover .flow-node-actions { opacity: 1; }
|
||||
/* Disabled arrows keep the nav's layout stable but stay dim. */
|
||||
.flow-node-actions .q-btn--disable { opacity: 0.35 !important; }
|
||||
|
||||
.flow-node-body {
|
||||
margin-top: 4px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
.flow-node-detail {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-node-children { margin-top: 4px; padding-left: 20px; border-left: 1px dashed #cbd5e1; }
|
||||
|
||||
.flow-node-branches {
|
||||
margin-top: 6px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px dashed #eab308;
|
||||
}
|
||||
.flow-branch { margin-bottom: 8px; }
|
||||
.flow-branch-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.flow-branch-yes { background: #dcfce7; color: #166534; }
|
||||
.flow-branch-no { background: #fee2e2; color: #991b1b; }
|
||||
.flow-branch-label:not(.flow-branch-yes):not(.flow-branch-no) {
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
}
|
||||
|
||||
.flow-add-child, .flow-add-branch {
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.flow-add-child:hover, .flow-add-branch:hover { background: #eef2ff; color: #4338ca; }
|
||||
</style>
|
||||
70
apps/ops/src/components/flow-editor/FlowQuickButton.vue
Normal file
70
apps/ops/src/components/flow-editor/FlowQuickButton.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<!--
|
||||
FlowQuickButton.vue — Drop-in trigger button for the global Flow Editor.
|
||||
|
||||
Odoo-style "inline create from anywhere": any page includes this single
|
||||
component and gets a consistent, contextually-loaded Flow editor popup.
|
||||
|
||||
Behavior:
|
||||
- If `templateName` prop is set → opens existing template in edit mode
|
||||
- Else → opens new-template wizard, pre-filling category / applies_to
|
||||
/ trigger_event based on the page's context
|
||||
|
||||
Props:
|
||||
- templateName string | null — if set, edit this FT
|
||||
- label string — button label (default: "Flows")
|
||||
- icon string — material icon name
|
||||
- color string — Quasar colour token
|
||||
- category string — preset for a new template
|
||||
- appliesTo string — preset for a new template
|
||||
- triggerEvent string — preset for a new template
|
||||
- flat, dense, size … — passthrough to q-btn
|
||||
|
||||
Usage:
|
||||
<FlowQuickButton category="residential" applies-to="Service Contract"
|
||||
trigger-event="on_contract_signed" />
|
||||
|
||||
<FlowQuickButton template-name="FT-00005" label="Éditer onboarding" />
|
||||
-->
|
||||
<template>
|
||||
<q-btn :flat="flat" :dense="dense" :size="size"
|
||||
:color="color" :icon="icon" :label="label" no-caps
|
||||
@click="onClick">
|
||||
<q-tooltip v-if="tooltip">{{ tooltip }}</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFlowEditor } from 'src/composables/useFlowEditor'
|
||||
|
||||
const props = defineProps({
|
||||
templateName: { type: String, default: null },
|
||||
label: { type: String, default: 'Flows' },
|
||||
icon: { type: String, default: 'account_tree' },
|
||||
color: { type: String, default: 'indigo-6' },
|
||||
tooltip: { type: String, default: '' },
|
||||
flat: { type: Boolean, default: false },
|
||||
dense: { type: Boolean, default: true },
|
||||
size: { type: String, default: 'md' },
|
||||
category: { type: String, default: 'other' },
|
||||
appliesTo: { type: String, default: null },
|
||||
triggerEvent: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved'])
|
||||
|
||||
const fe = useFlowEditor()
|
||||
|
||||
function onClick () {
|
||||
const onSaved = (tpl) => emit('saved', tpl)
|
||||
if (props.templateName) {
|
||||
fe.openTemplate(props.templateName, { onSaved })
|
||||
} else {
|
||||
fe.openNew({
|
||||
category: props.category,
|
||||
applies_to: props.appliesTo,
|
||||
trigger_event: props.triggerEvent,
|
||||
onSaved,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
312
apps/ops/src/components/flow-editor/FlowTemplatesSection.vue
Normal file
312
apps/ops/src/components/flow-editor/FlowTemplatesSection.vue
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<!--
|
||||
FlowTemplatesSection.vue — Library view of Flow Templates for SettingsPage.
|
||||
|
||||
Shows all templates in a filterable table, with quick-actions per row:
|
||||
- Click → opens the global FlowEditorDialog in edit mode
|
||||
- Duplicate button → creates an inactive copy
|
||||
- Activate/Deactivate toggle → inline PUT is_active
|
||||
- "Nouveau" CTA → opens the dialog in create mode
|
||||
|
||||
Protects is_system templates from delete (enforced server-side too).
|
||||
|
||||
Perf notes:
|
||||
- Single list call on mount, no pagination (expected fleet ≤ 200)
|
||||
- Client-side filter on cached list (O(n) per keystroke, debounced)
|
||||
- Debounced `q` filter input (250 ms)
|
||||
- Activate/Deactivate toggles the cached row optimistically to avoid
|
||||
a full reload on success
|
||||
-->
|
||||
<template>
|
||||
<div class="ft-section">
|
||||
<!-- Toolbar -->
|
||||
<div class="row items-center q-mb-md q-gutter-sm">
|
||||
<q-input v-model="q" dense outlined debounce="250" placeholder="Rechercher un template…"
|
||||
style="flex:1;min-width:200px" clearable>
|
||||
<template #prepend><q-icon name="search" color="grey-6" /></template>
|
||||
</q-input>
|
||||
<q-select v-model="categoryFilter" dense outlined emit-value map-options clearable
|
||||
:options="CATEGORY_OPTIONS" label="Catégorie" style="min-width:160px" />
|
||||
<q-select v-model="appliesToFilter" dense outlined emit-value map-options clearable
|
||||
:options="APPLIES_TO_OPTIONS" label="Applique à" style="min-width:200px" />
|
||||
<q-toggle v-model="showInactive" label="Inclure inactifs" color="indigo-6" dense />
|
||||
<q-space />
|
||||
<q-btn unelevated color="indigo-6" icon="add" label="Nouveau" no-caps dense
|
||||
@click="onCreate" />
|
||||
<q-btn flat dense icon="refresh" @click="reload" :loading="loading">
|
||||
<q-tooltip>Rafraîchir</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !rows.length" class="flex flex-center q-pa-xl">
|
||||
<q-spinner size="32px" color="indigo-6" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<q-banner v-else-if="error" class="bg-red-1 text-red-9 q-mb-md">
|
||||
<template #avatar><q-icon name="error" color="red-7" /></template>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!filtered.length" class="text-center text-grey-6 q-py-lg">
|
||||
<q-icon name="account_tree" size="32px" color="grey-4" />
|
||||
<div class="text-caption q-mt-sm">Aucun template trouvé</div>
|
||||
<q-btn flat dense color="indigo-6" label="Créer le premier template" no-caps
|
||||
class="q-mt-sm" @click="onCreate" />
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="ft-table-wrap">
|
||||
<table class="ft-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left" style="width:40px"></th>
|
||||
<th class="text-left">Nom</th>
|
||||
<th class="text-left" style="width:110px">Catégorie</th>
|
||||
<th class="text-left" style="width:150px">Applique à</th>
|
||||
<th class="text-left" style="width:170px">Trigger</th>
|
||||
<th class="text-center" style="width:80px">Étapes</th>
|
||||
<th class="text-center" style="width:70px">Actif</th>
|
||||
<th class="text-center" style="width:110px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in filtered" :key="r.name" class="ft-row"
|
||||
@click="onEdit(r)">
|
||||
<td><q-icon :name="r.icon || 'account_tree'" :color="r.is_system ? 'amber-8' : 'indigo-6'" /></td>
|
||||
<td>
|
||||
<div class="text-weight-medium">{{ r.template_name }}</div>
|
||||
<div class="text-caption text-grey-6">
|
||||
{{ r.name }}
|
||||
<q-badge v-if="r.is_system" color="amber-2" text-color="amber-9" label="système" class="q-ml-xs" />
|
||||
<span v-if="r.version" class="q-ml-xs">v{{ r.version }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><q-badge :color="categoryColor(r.category)" :label="categoryLabel(r.category)" /></td>
|
||||
<td class="text-grey-7 text-caption">{{ r.applies_to || '—' }}</td>
|
||||
<td class="text-caption text-grey-7">{{ triggerLabel(r.trigger_event) || '—' }}</td>
|
||||
<td class="text-center text-caption">{{ r.step_count || 0 }}</td>
|
||||
<td class="text-center" @click.stop>
|
||||
<q-toggle :model-value="!!r.is_active" color="green-6" dense
|
||||
@update:model-value="v => onToggleActive(r, v)" />
|
||||
</td>
|
||||
<td class="text-center" @click.stop>
|
||||
<q-btn flat round dense size="sm" icon="content_copy" color="indigo-6"
|
||||
@click="onDuplicate(r)">
|
||||
<q-tooltip>Dupliquer</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="!r.is_system" flat round dense size="sm" icon="delete" color="red-5"
|
||||
@click="onDelete(r)">
|
||||
<q-tooltip>Supprimer</q-tooltip>
|
||||
</q-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-6 q-mt-sm">
|
||||
{{ filtered.length }} template{{ filtered.length > 1 ? 's' : '' }}
|
||||
<span v-if="filtered.length !== rows.length"> · filtrés sur {{ rows.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { useFlowEditor } from 'src/composables/useFlowEditor'
|
||||
import {
|
||||
listFlowTemplates,
|
||||
updateFlowTemplate,
|
||||
duplicateFlowTemplate,
|
||||
deleteFlowTemplate,
|
||||
} from 'src/api/flow-templates'
|
||||
|
||||
// ── Static option sets ──────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ label: 'Résidentiel', value: 'residential' },
|
||||
{ label: 'Commercial', value: 'commercial' },
|
||||
{ label: 'Dépannage', value: 'incident' },
|
||||
{ label: 'Administratif', value: 'admin' },
|
||||
{ label: 'Agent AI', value: 'agent' },
|
||||
{ label: 'Autre', value: 'other' },
|
||||
]
|
||||
|
||||
const APPLIES_TO_OPTIONS = [
|
||||
{ label: 'Quotation', value: 'Quotation' },
|
||||
{ label: 'Service Contract', value: 'Service Contract' },
|
||||
{ label: 'Issue', value: 'Issue' },
|
||||
{ label: 'Customer', value: 'Customer' },
|
||||
{ label: 'Subscription', value: 'Subscription' },
|
||||
]
|
||||
|
||||
const TRIGGER_EVENT_LABELS = {
|
||||
on_contract_signed: 'Contrat signé',
|
||||
on_payment_received: 'Paiement reçu',
|
||||
on_subscription_active: 'Abonnement actif',
|
||||
on_quotation_created: 'Devis créé',
|
||||
on_quotation_accepted: 'Devis accepté',
|
||||
on_issue_opened: 'Ticket ouvert',
|
||||
on_customer_created: 'Client créé',
|
||||
on_dispatch_completed: 'Intervention terminée',
|
||||
manual: 'Manuel',
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
residential: 'blue-1',
|
||||
commercial: 'teal-1',
|
||||
incident: 'red-1',
|
||||
admin: 'grey-3',
|
||||
agent: 'purple-1',
|
||||
other: 'grey-2',
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS = Object.fromEntries(CATEGORY_OPTIONS.map(o => [o.value, o.label]))
|
||||
|
||||
function categoryLabel (c) { return CATEGORY_LABELS[c] || c }
|
||||
function categoryColor (c) { return CATEGORY_COLORS[c] || 'grey-2' }
|
||||
function triggerLabel (t) { return TRIGGER_EVENT_LABELS[t] || t }
|
||||
|
||||
// ── Reactive state ──────────────────────────────────────────────────────────
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const q = ref('')
|
||||
const categoryFilter = ref(null)
|
||||
const appliesToFilter = ref(null)
|
||||
const showInactive = ref(true)
|
||||
|
||||
const fe = useFlowEditor()
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load templates from the Hub.
|
||||
* Called on mount + after each mutation (or via the refresh button).
|
||||
*/
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
rows.value = await listFlowTemplates()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
|
||||
// ── Client-side filtering (single pass, fast for < 500 rows) ────────────────
|
||||
|
||||
const filtered = computed(() => {
|
||||
const ql = q.value?.trim().toLowerCase()
|
||||
const cat = categoryFilter.value
|
||||
const ap = appliesToFilter.value
|
||||
return rows.value.filter(r => {
|
||||
if (!showInactive.value && !r.is_active) return false
|
||||
if (cat && r.category !== cat) return false
|
||||
if (ap && r.applies_to !== ap) return false
|
||||
if (ql) {
|
||||
const hay = `${r.template_name} ${r.name} ${r.tags || ''} ${r.description || ''}`.toLowerCase()
|
||||
if (!hay.includes(ql)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// ── Row actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
function onCreate () {
|
||||
fe.openNew({
|
||||
category: categoryFilter.value || 'other',
|
||||
applies_to: appliesToFilter.value || null,
|
||||
onSaved: () => reload(),
|
||||
})
|
||||
}
|
||||
|
||||
function onEdit (row) {
|
||||
fe.openTemplate(row.name, { onSaved: () => reload() })
|
||||
}
|
||||
|
||||
async function onDuplicate (row) {
|
||||
try {
|
||||
const dup = await duplicateFlowTemplate(row.name)
|
||||
Notify.create({ type: 'positive', message: `Copie créée (${dup.name})`, timeout: 1500 })
|
||||
await reload()
|
||||
fe.openTemplate(dup.name, { onSaved: () => reload() })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete (row) {
|
||||
if (row.is_system) {
|
||||
Notify.create({ type: 'warning', message: 'Les templates système ne peuvent pas être supprimés. Utilisez "Dupliquer".', timeout: 3000 })
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Supprimer "${row.template_name}" ?`)) return
|
||||
try {
|
||||
await deleteFlowTemplate(row.name)
|
||||
rows.value = rows.value.filter(r => r.name !== row.name)
|
||||
Notify.create({ type: 'info', message: 'Supprimé', timeout: 1500 })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic toggle — revert on API failure. */
|
||||
async function onToggleActive (row, v) {
|
||||
const prev = row.is_active
|
||||
row.is_active = v ? 1 : 0
|
||||
try {
|
||||
await updateFlowTemplate(row.name, { is_active: v ? 1 : 0 })
|
||||
} catch (e) {
|
||||
row.is_active = prev
|
||||
Notify.create({ type: 'negative', message: 'Échec: ' + e.message, timeout: 2500 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ft-section { padding: 4px; }
|
||||
|
||||
.ft-table-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ft-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.ft-table thead th {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.ft-row td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ft-row { cursor: pointer; transition: background 0.12s; }
|
||||
.ft-row:hover { background: #f8fafc; }
|
||||
.ft-row:last-child td { border-bottom: none; }
|
||||
</style>
|
||||
242
apps/ops/src/components/flow-editor/StepEditorModal.vue
Normal file
242
apps/ops/src/components/flow-editor/StepEditorModal.vue
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
<!--
|
||||
StepEditorModal.vue — Modal form for editing a single step.
|
||||
|
||||
Renders kind-specific fields dynamically from the kind catalog, so adding a
|
||||
new step kind = adding an entry in kind-catalogs.js (no edit here needed).
|
||||
|
||||
Props:
|
||||
- modelValue: open/closed boolean (v-model)
|
||||
- step: the step being edited (cloned internally to avoid mutation)
|
||||
- kindCatalog: PROJECT_KINDS | AGENT_KINDS
|
||||
- allStepIds: array of step IDs available as depends_on targets
|
||||
- appliesTo: domain context (e.g. 'Customer'), used by the variable picker
|
||||
|
||||
Events:
|
||||
- update:modelValue(boolean)
|
||||
- save(step): user clicked save, receives the edited step
|
||||
|
||||
Performance:
|
||||
- Deep-clones the step on open (O(payload size)) to isolate edits.
|
||||
- Field rendering driven by a computed descriptor list — one render cycle
|
||||
per kind change.
|
||||
-->
|
||||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)"
|
||||
persistent maximized-bp="sm">
|
||||
<q-card class="step-editor-card" style="width: 560px; max-width: 95vw">
|
||||
<q-card-section class="step-editor-hdr">
|
||||
<div class="row items-center">
|
||||
<q-icon :name="kindDef.icon" size="20px" :style="{ color: kindDef.color }" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle1 text-weight-bold">Modifier l'étape</div>
|
||||
<div class="text-caption text-grey-7">{{ kindDef.label }}</div>
|
||||
</div>
|
||||
<!-- Click a variable → `{{path}}` is copied to the clipboard so the
|
||||
user can paste it into any field below (SMS body, email subject,
|
||||
webhook URL, …). No direct-inject because we'd need a ref on
|
||||
every FieldInput + cursor tracking, which isn't worth it. -->
|
||||
<VariablePicker :applies-to="appliesTo" mode="copy" label="Variables" />
|
||||
<q-btn flat round dense icon="close" @click="close" class="q-ml-xs" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="step-editor-body" style="max-height: 70vh; overflow-y: auto">
|
||||
<!-- Core fields — always present -->
|
||||
<div class="row q-col-gutter-sm q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-input v-model="local.label" dense outlined label="Nom de l'étape" stack-label />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm q-mb-md">
|
||||
<div class="col-6">
|
||||
<q-select v-model="local.kind" dense outlined emit-value map-options
|
||||
:options="kindOptions" label="Type d'étape" stack-label
|
||||
@update:model-value="onKindChange" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select v-model="triggerType" dense outlined emit-value map-options
|
||||
:options="triggerOptions" label="Déclencheur" stack-label
|
||||
@update:model-value="onTriggerChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trigger-specific fields -->
|
||||
<div v-if="triggerDef.fields.length" class="row q-col-gutter-sm q-mb-md">
|
||||
<div v-for="f in triggerDef.fields" :key="f.name" class="col-6">
|
||||
<FieldInput :field="f" :model-value="local.trigger[f.name]"
|
||||
@update:model-value="val => (local.trigger[f.name] = val)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dependencies — options are {label, value} so chips show the step
|
||||
name (e.g. "Retrait ancien site") while the stored value stays
|
||||
the stable id ("s2"). Prevents the "← s4" confusion where the
|
||||
user couldn't tell which step was referenced. -->
|
||||
<div class="q-mb-md">
|
||||
<q-select v-model="local.depends_on" dense outlined multiple use-chips
|
||||
emit-value map-options :options="dependsOnOptions"
|
||||
label="Dépend de" stack-label
|
||||
hint="Cette étape attend que les étapes listées soient complétées" />
|
||||
</div>
|
||||
|
||||
<!-- Kind-specific payload fields -->
|
||||
<div v-if="kindDef.fields.length" class="step-editor-section">
|
||||
<div class="text-caption text-weight-bold text-grey-8 q-mb-sm">
|
||||
Paramètres — {{ kindDef.label }}
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div v-for="f in kindDef.fields" :key="f.name" :class="fieldWidth(f)">
|
||||
<FieldInput :field="f" :model-value="local.payload[f.name]"
|
||||
@update:model-value="val => (local.payload[f.name] = val)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parent branch (shown if the step has a parent condition) -->
|
||||
<div v-if="local.parent_id" class="q-mt-md">
|
||||
<q-input v-model="local.branch" dense outlined label="Branche parent"
|
||||
stack-label hint="yes / no / custom" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions class="step-editor-ftr">
|
||||
<q-btn flat color="grey-7" label="Annuler" @click="close" no-caps />
|
||||
<q-space />
|
||||
<q-btn unelevated color="indigo-6" label="Enregistrer" @click="save" no-caps
|
||||
icon="check" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getKind, getTrigger, TRIGGER_TYPES } from './kind-catalogs'
|
||||
import FieldInput from './FieldInput.vue'
|
||||
import VariablePicker from './VariablePicker.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
step: { type: Object, default: null },
|
||||
kindCatalog: { type: Object, required: true },
|
||||
allStepIds: { type: Array, default: () => [] },
|
||||
// Full step list (id + label) so we can render depends_on chips with
|
||||
// human-readable labels instead of opaque "s4" IDs. Fallback:
|
||||
// allStepIds is still honored when allSteps isn't passed.
|
||||
allSteps: { type: Array, default: () => [] },
|
||||
appliesTo: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
/** Local deep clone — mutations don't affect parent until user hits save. */
|
||||
const local = ref(cloneStep(props.step))
|
||||
const triggerType = ref(local.value.trigger?.type || 'on_prev_complete')
|
||||
|
||||
// Reset local state when the modal reopens on a different step
|
||||
watch(() => [props.modelValue, props.step?.id], () => {
|
||||
if (props.modelValue && props.step) {
|
||||
local.value = cloneStep(props.step)
|
||||
triggerType.value = local.value.trigger?.type || 'on_prev_complete'
|
||||
}
|
||||
})
|
||||
|
||||
/** Deep-clone a step so edits are isolated until save. */
|
||||
function cloneStep (step) {
|
||||
if (!step) return { id: '', kind: 'notify', label: '', payload: {}, trigger: { type: 'on_prev_complete' }, depends_on: [] }
|
||||
return JSON.parse(JSON.stringify(step))
|
||||
}
|
||||
|
||||
/** Kind descriptor from the catalog. */
|
||||
const kindDef = computed(() => getKind(props.kindCatalog, local.value.kind))
|
||||
|
||||
/** Trigger descriptor from the shared trigger map. */
|
||||
const triggerDef = computed(() => getTrigger(triggerType.value))
|
||||
|
||||
/** Options for the "type d'étape" select (derived once from the catalog). */
|
||||
const kindOptions = computed(() =>
|
||||
Object.entries(props.kindCatalog).map(([key, def]) => ({
|
||||
label: def.label, value: key,
|
||||
}))
|
||||
)
|
||||
|
||||
/** Options for the "trigger" select. */
|
||||
const triggerOptions = computed(() =>
|
||||
Object.entries(TRIGGER_TYPES).map(([key, def]) => ({
|
||||
label: def.label, value: key,
|
||||
}))
|
||||
)
|
||||
|
||||
/** depends_on options — exclude self to prevent circular refs. */
|
||||
const otherStepIds = computed(() =>
|
||||
props.allStepIds.filter(id => id !== local.value.id)
|
||||
)
|
||||
|
||||
/**
|
||||
* depends_on dropdown options with human-readable labels.
|
||||
*
|
||||
* Shape: `{ label: "Retrait ancien site", value: "s2" }[]`.
|
||||
*
|
||||
* Falls back to `allStepIds` when the caller didn't pass `allSteps` — that
|
||||
* way the control never breaks, it just degrades to raw ids like before.
|
||||
*/
|
||||
const dependsOnOptions = computed(() => {
|
||||
if (props.allSteps?.length) {
|
||||
return props.allSteps
|
||||
.filter(s => s.id !== local.value.id)
|
||||
.map(s => ({ label: s.label || s.id, value: s.id }))
|
||||
}
|
||||
return otherStepIds.value.map(id => ({ label: id, value: id }))
|
||||
})
|
||||
|
||||
/** Handle kind change: reset payload defaults, preserve id/label. */
|
||||
function onKindChange (newKind) {
|
||||
const def = getKind(props.kindCatalog, newKind)
|
||||
const payload = {}
|
||||
for (const f of def.fields || []) {
|
||||
if (f.default !== undefined) payload[f.name] = f.default
|
||||
}
|
||||
local.value.payload = payload
|
||||
}
|
||||
|
||||
/** Handle trigger change: reset trigger-specific fields. */
|
||||
function onTriggerChange (newType) {
|
||||
local.value.trigger = { type: newType }
|
||||
}
|
||||
|
||||
/** Heuristic: textarea fields span full width, others are half. */
|
||||
function fieldWidth (field) {
|
||||
if (field.type === 'textarea' || field.type === 'json') return 'col-12'
|
||||
return 'col-12 col-md-6'
|
||||
}
|
||||
|
||||
function save () {
|
||||
// Normalize empty arrays / nulls
|
||||
if (!Array.isArray(local.value.depends_on)) local.value.depends_on = []
|
||||
emit('save', JSON.parse(JSON.stringify(local.value)))
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function close () {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-editor-card { border-radius: 8px; }
|
||||
.step-editor-hdr {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.step-editor-body { padding: 16px; }
|
||||
.step-editor-section {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.step-editor-ftr {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
</style>
|
||||
114
apps/ops/src/components/flow-editor/VariablePicker.vue
Normal file
114
apps/ops/src/components/flow-editor/VariablePicker.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<!--
|
||||
VariablePicker.vue — Menu de variables `{{path}}` cliquables.
|
||||
|
||||
Un dropdown qui liste toutes les variables disponibles pour un `applies_to`
|
||||
donné (Customer / Quotation / Service Contract / Issue / Subscription).
|
||||
|
||||
Deux modes d'utilisation :
|
||||
- Mode "insert" (défaut) : émet @insert('{{path}}') pour qu'un parent
|
||||
l'ajoute à la fin du champ qu'il contrôle (ex : trigger_condition).
|
||||
- Mode "copy" : copie `{{path}}` dans le presse-papier et
|
||||
notifie l'utilisateur — utile quand il n'y a pas de champ cible
|
||||
capturé dans le même composant (ex : dans le StepEditorModal où
|
||||
n'importe quel champ de payload peut en avoir besoin).
|
||||
|
||||
Props :
|
||||
- appliesTo : 'Customer' | 'Quotation' | ...
|
||||
- label : texte du bouton (défaut "+ Variable")
|
||||
- mode : 'insert' | 'copy' (défaut 'insert')
|
||||
|
||||
Events :
|
||||
- insert(text) : émet `{{path}}` à insérer (mode 'insert' uniquement)
|
||||
|
||||
Performance :
|
||||
- La liste est computed une fois par applies_to (mémoisée par Vue).
|
||||
- Les entrées sont triées par domaine puis alphabétique pour rester stable.
|
||||
-->
|
||||
<template>
|
||||
<q-btn-dropdown flat dense no-caps size="sm" :icon="icon" :label="label"
|
||||
class="variable-picker" color="indigo-6" content-class="vp-menu">
|
||||
<q-list dense class="vp-list">
|
||||
<q-item-label header class="vp-header">
|
||||
Variables disponibles
|
||||
<span v-if="appliesTo" class="text-caption text-grey-6">
|
||||
· {{ appliesTo }}
|
||||
</span>
|
||||
</q-item-label>
|
||||
|
||||
<q-item v-if="!appliesTo" class="vp-empty">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-caption text-grey-7">
|
||||
Sélectionnez « Applique à » pour voir les variables du domaine.
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-for="v in variables" :key="v.path" clickable v-close-popup
|
||||
@click="onPick(v)" class="vp-item">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-caption text-weight-medium">{{ v.label }}</q-item-label>
|
||||
<q-item-label caption>
|
||||
<code class="vp-code">{{ formatted(v.path) }}</code>
|
||||
</q-item-label>
|
||||
<q-item-label v-if="v.hint" caption class="text-grey-6 vp-hint">
|
||||
{{ v.hint }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon :name="mode === 'copy' ? 'content_copy' : 'add_circle_outline'" size="16px" color="indigo-5" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Notify, copyToClipboard } from 'quasar'
|
||||
import { getVariables } from './variables'
|
||||
|
||||
const props = defineProps({
|
||||
appliesTo: { type: String, default: null },
|
||||
label: { type: String, default: '+ Variable' },
|
||||
mode: { type: String, default: 'insert' }, // 'insert' | 'copy'
|
||||
icon: { type: String, default: 'data_object' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['insert'])
|
||||
|
||||
const variables = computed(() => getVariables(props.appliesTo))
|
||||
|
||||
function formatted (path) { return `{{${path}}}` }
|
||||
|
||||
async function onPick (v) {
|
||||
const text = formatted(v.path)
|
||||
if (props.mode === 'copy') {
|
||||
try {
|
||||
await copyToClipboard(text)
|
||||
Notify.create({ type: 'positive', message: `Copié : ${text}`, timeout: 1200, position: 'top' })
|
||||
} catch {
|
||||
Notify.create({ type: 'negative', message: 'Copie impossible', timeout: 1500 })
|
||||
}
|
||||
return
|
||||
}
|
||||
emit('insert', text)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.variable-picker :deep(.q-btn__content) { font-size: 0.72rem; }
|
||||
.vp-list { min-width: 320px; max-width: 380px; max-height: 420px; overflow-y: auto; }
|
||||
.vp-header { font-weight: 600; font-size: 0.72rem; color: #475569; }
|
||||
.vp-item { padding: 6px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.vp-item:last-child { border-bottom: none; }
|
||||
.vp-code {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.72rem;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.vp-hint { font-size: 0.7rem; margin-top: 2px; }
|
||||
.vp-empty { padding: 10px 14px; }
|
||||
</style>
|
||||
28
apps/ops/src/components/flow-editor/index.js
Normal file
28
apps/ops/src/components/flow-editor/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* flow-editor/index.js — Public exports for the Flow Editor module.
|
||||
*
|
||||
* Usage:
|
||||
* import { FlowEditor, PROJECT_KINDS } from 'src/components/flow-editor'
|
||||
*
|
||||
* <FlowEditor v-model="flowDef" :kind-catalog="PROJECT_KINDS" />
|
||||
*
|
||||
* See FLOW_EDITOR_ARCHITECTURE.md in /docs/ for the full data model and
|
||||
* runtime contract.
|
||||
*/
|
||||
|
||||
export { default as FlowEditor } from './FlowEditor.vue'
|
||||
export { default as FlowNode } from './FlowNode.vue'
|
||||
export { default as StepEditorModal } from './StepEditorModal.vue'
|
||||
export { default as FieldInput } from './FieldInput.vue'
|
||||
export { default as FlowEditorDialog } from './FlowEditorDialog.vue'
|
||||
export { default as FlowTemplatesSection } from './FlowTemplatesSection.vue'
|
||||
export { default as FlowQuickButton } from './FlowQuickButton.vue'
|
||||
|
||||
export {
|
||||
PROJECT_KINDS,
|
||||
AGENT_KINDS,
|
||||
TRIGGER_TYPES,
|
||||
getKind,
|
||||
getTrigger,
|
||||
buildEmptyStep,
|
||||
} from './kind-catalogs'
|
||||
258
apps/ops/src/components/flow-editor/kind-catalogs.js
Normal file
258
apps/ops/src/components/flow-editor/kind-catalogs.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* kind-catalogs.js — Pluggable definitions for flow step kinds.
|
||||
*
|
||||
* A "catalog" tells the FlowEditor which step kinds are allowed and how
|
||||
* to render their editor form. One editor, many domains (project, agent,
|
||||
* customer onboarding, etc.) — same UI, different catalogs.
|
||||
*
|
||||
* Field descriptor shape:
|
||||
* {
|
||||
* name: 'subject', // flattens into step.payload[name]
|
||||
* type: 'text'|'textarea'|'number'|'select'|'datetime'|'webhook',
|
||||
* label: 'Sujet',
|
||||
* required: true|false,
|
||||
* options: [...], // for select
|
||||
* placeholder: '...',
|
||||
* default: 'value',
|
||||
* help: 'tooltip text',
|
||||
* }
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Trigger types (when a step executes) — shared across all catalogs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const TRIGGER_TYPES = {
|
||||
on_flow_start: {
|
||||
label: 'Au démarrage du flow',
|
||||
help: 'Cette étape s\'exécute dès que le flow démarre',
|
||||
fields: [],
|
||||
},
|
||||
on_prev_complete: {
|
||||
label: 'Après les dépendances (depends_on)',
|
||||
help: 'Cette étape attend que les étapes listées dans depends_on soient complétées',
|
||||
fields: [],
|
||||
},
|
||||
after_delay: {
|
||||
label: 'Après un délai',
|
||||
help: 'Cette étape attend X heures/jours après que ses dépendances soient complétées',
|
||||
fields: [
|
||||
{ name: 'delay_hours', type: 'number', label: 'Heures', placeholder: '24' },
|
||||
{ name: 'delay_days', type: 'number', label: 'Jours', placeholder: '7' },
|
||||
],
|
||||
},
|
||||
on_date: {
|
||||
label: 'À une date précise',
|
||||
help: 'Déclenchement à une date/heure fixe',
|
||||
fields: [
|
||||
{ name: 'at', type: 'datetime', label: 'Date/heure' },
|
||||
],
|
||||
},
|
||||
on_webhook: {
|
||||
label: 'Webhook externe reçu',
|
||||
help: 'POST sur /flow/trigger/:run_id/:step_id depuis n8n/autre',
|
||||
fields: [],
|
||||
},
|
||||
manual: {
|
||||
label: 'Déclenchement manuel (bouton)',
|
||||
help: 'L\'utilisateur clique un bouton pour déclencher',
|
||||
fields: [],
|
||||
},
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Project kinds — for the project wizard + service orchestration flows
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const JOB_TYPES = ['Installation', 'Réparation', 'Maintenance', 'Retrait', 'Dépannage', 'Autre']
|
||||
const PRIORITIES = ['low', 'medium', 'high']
|
||||
const GROUPS = ['Admin', 'Tech Targo', 'Support', 'NOC', 'Facturation']
|
||||
|
||||
export const PROJECT_KINDS = {
|
||||
dispatch_job: {
|
||||
label: 'Tâche dispatch',
|
||||
icon: 'build',
|
||||
color: '#6366f1',
|
||||
fields: [
|
||||
{ name: 'subject', type: 'text', label: 'Sujet', required: true },
|
||||
{ name: 'job_type', type: 'select', label: 'Type', options: JOB_TYPES, default: 'Autre' },
|
||||
{ name: 'priority', type: 'select', label: 'Priorité', options: PRIORITIES, default: 'medium' },
|
||||
{ name: 'duration_h', type: 'number', label: 'Durée (h)', default: 1 },
|
||||
{ name: 'assigned_group', type: 'select', label: 'Groupe', options: GROUPS, default: 'Tech Targo' },
|
||||
{ name: 'on_open_webhook', type: 'text', label: 'Webhook à l\'ouverture (n8n)', placeholder: 'https://n8n.gigafibre.ca/webhook/...' },
|
||||
{ name: 'on_close_webhook',type: 'text', label: 'Webhook à la fermeture (n8n)' },
|
||||
{ name: 'merge_key', type: 'text', label: 'Merge key (optionnel)', help: 'Étapes avec le même merge_key fusionnent en une seule visite' },
|
||||
],
|
||||
},
|
||||
issue: {
|
||||
label: 'Ticket',
|
||||
icon: 'confirmation_number',
|
||||
color: '#f59e0b',
|
||||
fields: [
|
||||
{ name: 'subject', type: 'text', label: 'Sujet', required: true },
|
||||
{ name: 'description', type: 'textarea', label: 'Description' },
|
||||
{ name: 'priority', type: 'select', label: 'Priorité', options: ['Low', 'Medium', 'High', 'Urgent'], default: 'Medium' },
|
||||
{ name: 'issue_type', type: 'text', label: 'Type (texte libre)', placeholder: 'Suivi' },
|
||||
],
|
||||
},
|
||||
notify: {
|
||||
label: 'Notification (SMS/email)',
|
||||
icon: 'send',
|
||||
color: '#3b82f6',
|
||||
fields: [
|
||||
{ name: 'channel', type: 'select', label: 'Canal', options: ['sms', 'email'], default: 'sms' },
|
||||
{ name: 'to', type: 'text', label: 'Destinataire (template)', placeholder: '{{customer.primary_phone}}', help: 'Supporte les templates {{customer.field}}' },
|
||||
{ name: 'template_id', type: 'text', label: 'Template ID (depuis email-templates.js)', placeholder: 'welcome_residential' },
|
||||
{ name: 'subject', type: 'text', label: 'Sujet (email uniquement)' },
|
||||
{ name: 'body', type: 'textarea', label: 'Corps (si pas de template_id)' },
|
||||
],
|
||||
},
|
||||
webhook: {
|
||||
label: 'Webhook externe',
|
||||
icon: 'webhook',
|
||||
color: '#8b5cf6',
|
||||
fields: [
|
||||
{ name: 'url', type: 'text', label: 'URL', required: true, placeholder: 'https://n8n.gigafibre.ca/webhook/xxx' },
|
||||
{ name: 'method', type: 'select', label: 'Méthode', options: ['POST', 'GET', 'PUT', 'DELETE'], default: 'POST' },
|
||||
{ name: 'body_template', type: 'textarea', label: 'Body (JSON template)', placeholder: '{"customer": "{{customer.name}}", "contract": "{{contract.name}}"}' },
|
||||
],
|
||||
},
|
||||
erp_update: {
|
||||
label: 'Mise à jour ERPNext',
|
||||
icon: 'edit_note',
|
||||
color: '#10b981',
|
||||
fields: [
|
||||
{ name: 'doctype', type: 'text', label: 'DocType', required: true, placeholder: 'Customer' },
|
||||
{ name: 'docname_ref', type: 'text', label: 'Nom du doc (template)', placeholder: '{{customer.name}}' },
|
||||
{ name: 'fields_json', type: 'textarea', label: 'Champs à mettre à jour (JSON)', placeholder: '{"customer_group": "Active"}' },
|
||||
],
|
||||
},
|
||||
wait: {
|
||||
label: 'Attendre',
|
||||
icon: 'hourglass_empty',
|
||||
color: '#94a3b8',
|
||||
help: 'Utilise le trigger « Après un délai » pour contrôler la durée',
|
||||
fields: [],
|
||||
},
|
||||
condition: {
|
||||
label: 'Condition (si / sinon)',
|
||||
icon: 'fork_right',
|
||||
color: '#eab308',
|
||||
hasBranches: true,
|
||||
branchLabels: { yes: 'Oui', no: 'Non' },
|
||||
fields: [
|
||||
{ name: 'field', type: 'text', label: 'Champ (chemin JSON)', placeholder: 'customer.primary_phone', required: true },
|
||||
{ name: 'op', type: 'select', label: 'Opérateur',
|
||||
options: ['==', '!=', '<', '>', '<=', '>=', 'in', 'not_in', 'empty', 'not_empty'], default: '==' },
|
||||
{ name: 'value', type: 'text', label: 'Valeur', placeholder: 'Actif' },
|
||||
],
|
||||
},
|
||||
subscription_activate: {
|
||||
label: 'Activer l\'abonnement',
|
||||
icon: 'autorenew',
|
||||
color: '#ec4899',
|
||||
fields: [
|
||||
{ name: 'subscription_ref', type: 'text', label: 'Référence abonnement (template)', placeholder: '{{contract.subscription}}' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Agent kinds — for the conversational agent flows (AgentFlowsPage)
|
||||
// -----------------------------------------------------------------------------
|
||||
// Preserved from the current AgentFlowsPage stepTypeLabels structure so the
|
||||
// extracted FlowEditor can replace it as-is in a later refactor.
|
||||
|
||||
export const AGENT_KINDS = {
|
||||
tool: {
|
||||
label: 'Appel outil',
|
||||
icon: 'settings',
|
||||
color: '#6366f1',
|
||||
fields: [
|
||||
{ name: 'tool', type: 'text', label: 'Outil', placeholder: 'get_equipment' },
|
||||
{ name: 'note', type: 'text', label: 'Note' },
|
||||
],
|
||||
},
|
||||
condition: {
|
||||
label: 'Condition',
|
||||
icon: 'help',
|
||||
color: '#eab308',
|
||||
hasBranches: true,
|
||||
branchLabels: { yes: 'Oui', no: 'Non' },
|
||||
fields: [
|
||||
{ name: 'field', type: 'text', label: 'Champ', placeholder: 'device.online' },
|
||||
{ name: 'op', type: 'select', label: 'Opérateur', options: ['==', '!=', '<', '>', '<=', '>='] },
|
||||
{ name: 'value', type: 'text', label: 'Valeur' },
|
||||
],
|
||||
},
|
||||
switch: {
|
||||
label: 'Switch',
|
||||
icon: 'call_split',
|
||||
color: '#f59e0b',
|
||||
hasBranches: 'dynamic',
|
||||
fields: [
|
||||
{ name: 'field', type: 'text', label: 'Champ switch', placeholder: 'onu.alarm_type' },
|
||||
],
|
||||
},
|
||||
respond: {
|
||||
label: 'Réponse',
|
||||
icon: 'chat',
|
||||
color: '#10b981',
|
||||
fields: [
|
||||
{ name: 'message', type: 'textarea', label: 'Message', rows: 4 },
|
||||
{ name: 'note', type: 'text', label: 'Note interne' },
|
||||
],
|
||||
},
|
||||
action: {
|
||||
label: 'Action',
|
||||
icon: 'bolt',
|
||||
color: '#8b5cf6',
|
||||
fields: [
|
||||
{ name: 'action', type: 'text', label: 'Action', placeholder: 'create_dispatch_job' },
|
||||
{ name: 'params', type: 'json', label: 'Paramètres (JSON)' },
|
||||
{ name: 'message', type: 'textarea', label: 'Message au client', rows: 2 },
|
||||
],
|
||||
},
|
||||
goto: {
|
||||
label: 'Aller à',
|
||||
icon: 'arrow_forward',
|
||||
color: '#64748b',
|
||||
fields: [
|
||||
{ name: 'target', type: 'text', label: 'Cible (intent ID)' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function getKind (catalog, kindName) {
|
||||
return catalog[kindName] || { label: kindName, icon: 'circle', color: '#94a3b8', fields: [] }
|
||||
}
|
||||
|
||||
export function getTrigger (typeName) {
|
||||
return TRIGGER_TYPES[typeName] || { label: typeName || 'Inconnu', fields: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an empty step skeleton for a given kind.
|
||||
* Applies defaults from field descriptors.
|
||||
*/
|
||||
export function buildEmptyStep (kindName, catalog) {
|
||||
const kind = getKind(catalog, kindName)
|
||||
const payload = {}
|
||||
for (const f of kind.fields || []) {
|
||||
if (f.default !== undefined) payload[f.name] = f.default
|
||||
}
|
||||
return {
|
||||
id: 'step_' + Math.random().toString(36).slice(2, 9),
|
||||
kind: kindName,
|
||||
label: kind.label,
|
||||
parent_id: null,
|
||||
branch: null,
|
||||
depends_on: [],
|
||||
trigger: { type: 'on_prev_complete' },
|
||||
payload,
|
||||
}
|
||||
}
|
||||
110
apps/ops/src/components/flow-editor/variables.js
Normal file
110
apps/ops/src/components/flow-editor/variables.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* variables.js — Catalog of variables available to flow templates.
|
||||
*
|
||||
* Lists the `{{path.to.value}}` expressions that the runtime can resolve when
|
||||
* it interpolates a step's payload (SMS body, email subject, webhook URL, etc.)
|
||||
* or evaluates a JSONLogic/expression condition.
|
||||
*
|
||||
* The paths are keyed by the template's `applies_to` target (Customer,
|
||||
* Quotation, Service Contract, …) because the runtime builds the context
|
||||
* object differently per doctype:
|
||||
*
|
||||
* - A Customer-scoped flow has `context.customer = <Customer doc>`
|
||||
* - A Service Contract-scoped flow has `context.contract = <Service Contract doc>`
|
||||
* plus `context.customer` auto-joined from `contract.customer`
|
||||
* - And so on
|
||||
*
|
||||
* ⚠️ If you extend this catalog, mirror the change server-side in
|
||||
* `services/targo-hub/lib/flow-context.js` so the runtime actually
|
||||
* populates the new path.
|
||||
*
|
||||
* Performance: constant-time lookup, tables are < 20 entries. Safe to import
|
||||
* anywhere in the editor.
|
||||
*/
|
||||
|
||||
// Always-available regardless of applies_to.
|
||||
export const COMMON_VARIABLES = [
|
||||
{ label: 'Maintenant (ISO 8601)', path: 'now', hint: 'Timestamp au démarrage du flow' },
|
||||
{ label: 'ID du run', path: 'flow.run_id', hint: 'Identifiant unique du flow run' },
|
||||
{ label: 'Nom du template', path: 'flow.template', hint: 'Flow Template de référence' },
|
||||
]
|
||||
|
||||
// Per-applies_to domain variables. Keep the strings exactly as the runtime
|
||||
// exposes them (context.<root>.<field>) to avoid silent mismatches.
|
||||
export const VARIABLE_CATALOGS = {
|
||||
Customer: [
|
||||
{ label: 'ID client (doc)', path: 'customer.name', hint: 'ex: CUST-00042' },
|
||||
{ label: 'Nom commercial', path: 'customer.customer_name', hint: 'Jean Tremblay / Acme Inc.' },
|
||||
{ label: 'Type', path: 'customer.customer_type', hint: 'Individual | Company' },
|
||||
{ label: 'Groupe', path: 'customer.customer_group' },
|
||||
{ label: 'Email', path: 'customer.email_id' },
|
||||
{ label: 'Téléphone principal', path: 'customer.primary_phone' },
|
||||
{ label: 'Mobile', path: 'customer.mobile_no' },
|
||||
{ label: 'Adresse principale', path: 'customer.primary_address' },
|
||||
{ label: 'Langue', path: 'customer.language', hint: 'fr | en' },
|
||||
{ label: 'Territoire', path: 'customer.territory' },
|
||||
],
|
||||
Quotation: [
|
||||
{ label: 'ID devis', path: 'quotation.name' },
|
||||
{ label: 'Client (ID)', path: 'quotation.customer' },
|
||||
{ label: 'Nom du client', path: 'quotation.customer_name' },
|
||||
{ label: 'Total HT', path: 'quotation.total' },
|
||||
{ label: 'Total TTC', path: 'quotation.grand_total' },
|
||||
{ label: 'Statut', path: 'quotation.status' },
|
||||
{ label: 'Date transaction', path: 'quotation.transaction_date' },
|
||||
{ label: 'Valide jusqu\'au', path: 'quotation.valid_till' },
|
||||
{ label: 'Email du client', path: 'customer.email_id' },
|
||||
{ label: 'Téléphone du client', path: 'customer.primary_phone' },
|
||||
],
|
||||
'Service Contract': [
|
||||
{ label: 'ID contrat', path: 'contract.name' },
|
||||
{ label: 'Client (ID)', path: 'contract.customer' },
|
||||
{ label: 'Nom du client', path: 'contract.customer_name' },
|
||||
{ label: 'Plan', path: 'contract.plan' },
|
||||
{ label: 'Abonnement (ID)', path: 'contract.subscription' },
|
||||
{ label: 'Date de début', path: 'contract.start_date' },
|
||||
{ label: 'Date de fin', path: 'contract.end_date' },
|
||||
{ label: 'Mensualité', path: 'contract.monthly_price' },
|
||||
{ label: 'Statut', path: 'contract.status' },
|
||||
{ label: 'Email du client', path: 'customer.email_id' },
|
||||
{ label: 'Téléphone du client', path: 'customer.primary_phone' },
|
||||
{ label: 'Adresse d\'installation', path: 'contract.service_address' },
|
||||
],
|
||||
Issue: [
|
||||
{ label: 'ID ticket', path: 'issue.name' },
|
||||
{ label: 'Sujet', path: 'issue.subject' },
|
||||
{ label: 'Description', path: 'issue.description' },
|
||||
{ label: 'Priorité', path: 'issue.priority' },
|
||||
{ label: 'Statut', path: 'issue.status' },
|
||||
{ label: 'Type', path: 'issue.issue_type' },
|
||||
{ label: 'Client (ID)', path: 'issue.customer' },
|
||||
{ label: 'Nom du client', path: 'issue.customer_name' },
|
||||
{ label: 'Date d\'ouverture', path: 'issue.opening_date' },
|
||||
{ label: 'Email du client', path: 'customer.email_id' },
|
||||
],
|
||||
Subscription: [
|
||||
{ label: 'ID abonnement', path: 'subscription.name' },
|
||||
{ label: 'Client (ID)', path: 'subscription.customer' },
|
||||
{ label: 'Date de début', path: 'subscription.start_date' },
|
||||
{ label: 'Date de fin', path: 'subscription.end_date' },
|
||||
{ label: 'Plan', path: 'subscription.plan' },
|
||||
{ label: 'Statut', path: 'subscription.status' },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the flat list of variables available for the given applies_to target.
|
||||
* If applies_to is null/unknown, returns just the COMMON_VARIABLES.
|
||||
*/
|
||||
export function getVariables (appliesTo) {
|
||||
const specific = VARIABLE_CATALOGS[appliesTo] || []
|
||||
return [...specific, ...COMMON_VARIABLES]
|
||||
}
|
||||
|
||||
/**
|
||||
* List of applies_to keys we know about — used by the editor to warn users
|
||||
* when they haven't picked one yet.
|
||||
*/
|
||||
export function hasVariables (appliesTo) {
|
||||
return Boolean(VARIABLE_CATALOGS[appliesTo])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -171,12 +171,13 @@
|
|||
</div>
|
||||
|
||||
<!-- ── Advanced WiFi Diagnostic Panel ── -->
|
||||
<div v-if="managementIp" class="q-mt-md">
|
||||
<div v-if="managementIp || props.doc?.serial_number" class="q-mt-md">
|
||||
<div class="info-block-title">
|
||||
<q-icon name="wifi_find" size="16px" class="q-mr-xs" />
|
||||
Diagnostic WiFi avance
|
||||
Diagnostic modem
|
||||
<q-badge v-if="wifiDiag?.modemType" :label="modemTypeLabel(wifiDiag.modemType)" class="q-ml-xs" :color="modemTypeColor(wifiDiag.modemType)" dense />
|
||||
<q-btn flat dense size="sm" :icon="wifiDiag ? 'refresh' : 'play_arrow'" class="q-ml-sm"
|
||||
:label="wifiDiag ? 'Relancer' : 'Lancer'" :loading="wifiDiagLoading"
|
||||
:label="wifiDiag ? 'Relancer' : 'Diagnostic'" :loading="wifiDiagLoading"
|
||||
@click="runWifiDiagnostic" color="primary" />
|
||||
<span v-if="wifiDiag" class="text-caption text-grey-5 q-ml-sm">{{ wifiDiag.durationMs }}ms</span>
|
||||
</div>
|
||||
|
|
@ -187,6 +188,63 @@
|
|||
<div v-if="wifiDiagError" class="text-caption text-negative q-py-xs">{{ wifiDiagError }}</div>
|
||||
|
||||
<template v-if="wifiDiag">
|
||||
<!-- Wired equipment detection banner -->
|
||||
<div v-if="wifiDiag.wiredEquipment?.length" class="adv-issue adv-issue--info q-mb-sm">
|
||||
<div class="adv-issue-header">
|
||||
<q-icon name="router" size="16px" />
|
||||
<span class="text-weight-bold">{{ wifiDiag.wiredEquipment.length }} repeteur(s) mesh detecte(s)</span>
|
||||
</div>
|
||||
<div v-for="eq in wifiDiag.wiredEquipment" :key="eq.mac" class="q-ml-md q-mt-xs text-caption">
|
||||
<q-icon name="router" size="12px" class="q-mr-xs" color="blue-6" />
|
||||
<span class="text-weight-medium">{{ eq.hostname || eq.model }}</span>
|
||||
— {{ eq.ip }} ({{ eq.mac }})
|
||||
<q-badge :color="eq.type === 'mesh_repeater' ? 'blue-2' : 'orange-2'" :text-color="eq.type === 'mesh_repeater' ? 'blue-9' : 'orange-9'" class="q-ml-xs" style="font-size:0.6rem;">
|
||||
{{ eq.type === 'mesh_repeater' ? 'Confirme' : 'Probable' }}
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ethernet ports (enhanced) -->
|
||||
<div v-if="wifiDiag.ethernetPorts?.length" class="diag-section">
|
||||
<div class="diag-section-title"><q-icon name="cable" size="16px" class="q-mr-xs" />Ports Ethernet</div>
|
||||
<div class="diag-grid">
|
||||
<div v-for="port in wifiDiag.ethernetPorts" :key="port.port" class="diag-item">
|
||||
<span class="diag-label">{{ port.label }}</span>
|
||||
<span :class="port.status === 'Up' ? 'text-positive' : 'text-grey-6'">
|
||||
{{ port.status || '—' }}
|
||||
<template v-if="port.speed > 0"> · {{ port.speed }} Mbps</template>
|
||||
<template v-if="port.stats && port.status === 'Up'">
|
||||
· {{ formatBytes(port.stats.txBytes) }}↑ {{ formatBytes(port.stats.rxBytes) }}↓
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DHCP Leases -->
|
||||
<div v-if="wifiDiag.dhcpLeases?.length" class="diag-section">
|
||||
<div class="diag-section-title">
|
||||
<q-icon name="dns" size="16px" class="q-mr-xs" />
|
||||
Baux DHCP ({{ wifiDiag.dhcpLeases.length }})
|
||||
</div>
|
||||
<table class="hosts-table">
|
||||
<thead><tr><th>Nom</th><th>IP</th><th>MAC</th><th>Bail</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="l in wifiDiag.dhcpLeases" :key="l.mac">
|
||||
<td class="text-weight-medium">{{ l.hostname || '—' }}</td>
|
||||
<td><code class="text-caption">{{ l.ip }}</code></td>
|
||||
<td><code class="text-caption">{{ l.mac }}</code></td>
|
||||
<td class="text-caption">{{ l.expiry != null ? formatLease(l.expiry) : '—' }}</td>
|
||||
<td>
|
||||
<q-badge v-if="l.isMeshRepeater" color="blue-2" text-color="blue-9" style="font-size:0.6rem;">Mesh</q-badge>
|
||||
<q-badge v-else-if="l.isWired" color="grey-3" text-color="grey-8" style="font-size:0.6rem;">Filaire</q-badge>
|
||||
<q-badge v-else color="green-2" text-color="green-9" style="font-size:0.6rem;">WiFi</q-badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Issues panel -->
|
||||
<div v-if="wifiDiag.issues.length" class="adv-issues q-mb-sm">
|
||||
<div v-for="(issue, i) in wifiDiag.issues" :key="i" class="adv-issue" :class="'adv-issue--' + issue.severity">
|
||||
|
|
@ -268,7 +326,10 @@
|
|||
<div v-if="wifiDiag.clients.length" class="diag-section">
|
||||
<div class="diag-section-title">
|
||||
<q-icon name="devices" size="16px" class="q-mr-xs" />
|
||||
Clients WiFi detailles ({{ wifiDiag.clients.length }})
|
||||
Clients WiFi ({{ wifiDiag.clients.filter(c => c.active).length }} connectes
|
||||
<span v-if="wifiDiag.clients.some(c => !c.active)" class="text-caption text-grey-5">
|
||||
/ {{ wifiDiag.clients.filter(c => !c.active).length }} hors ligne
|
||||
</span>)
|
||||
</div>
|
||||
<table class="hosts-table adv-clients-table">
|
||||
<thead>
|
||||
|
|
@ -282,16 +343,19 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in wifiDiag.clients" :key="c.mac" :class="{ 'text-grey-5': !c.active }">
|
||||
<tr v-for="c in wifiDiag.clients" :key="c.mac" :class="{ 'text-grey-5 adv-inactive-row': !c.active }">
|
||||
<td>
|
||||
<template v-if="c.active">
|
||||
<div class="adv-signal-bar">
|
||||
<div class="adv-signal-fill" :style="{ width: signalPercent(c.signal) + '%', background: signalColor(c.signal) }" />
|
||||
</div>
|
||||
<span class="text-caption" :style="{ color: signalColor(c.signal) }">{{ c.signal > 0 ? c.signal : '?' }}</span>
|
||||
</template>
|
||||
<q-icon v-else name="wifi_off" size="14px" color="grey-4" />
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-weight-medium">{{ c.hostname || '—' }}</span>
|
||||
<div class="text-caption text-grey-5">{{ c.ip }}</div>
|
||||
<div class="text-caption text-grey-5">{{ c.ip || 'hors ligne' }}</div>
|
||||
</td>
|
||||
<td class="text-caption">{{ c.meshNode || '—' }}</td>
|
||||
<td class="text-caption">
|
||||
|
|
@ -387,7 +451,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||
import { useQuasar } from 'quasar'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||
import { useWifiDiagnostic } from 'src/composables/useWifiDiagnostic'
|
||||
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
|
||||
import { deleteDoc } from 'src/api/erp'
|
||||
|
||||
const props = defineProps({ doc: { type: Object, required: true }, docName: String })
|
||||
|
|
@ -395,7 +459,7 @@ const emit = defineEmits(['deleted'])
|
|||
|
||||
const $q = useQuasar()
|
||||
const { fetchStatus, fetchOltStatus, fetchPortContext, getDevice, isOnline, combinedStatus, signalQuality, refreshDeviceParams, fetchHosts, loading: deviceLoading } = useDeviceStatus()
|
||||
const { fetchDiagnostic, loading: wifiDiagLoading, error: wifiDiagError, data: wifiDiag } = useWifiDiagnostic()
|
||||
const { fetchDiagnostic, fetchDiagnosticAuto, loading: wifiDiagLoading, error: wifiDiagError, data: wifiDiag } = useModemDiagnostic()
|
||||
const refreshing = ref(false)
|
||||
const deleting = ref(false)
|
||||
const portCtx = ref(null)
|
||||
|
|
@ -405,20 +469,44 @@ const managementIp = computed(() => {
|
|||
return iface?.ip || null
|
||||
})
|
||||
|
||||
function runWifiDiagnostic() {
|
||||
if (!managementIp.value) return
|
||||
async function runWifiDiagnostic() {
|
||||
const serial = props.doc?.serial_number
|
||||
// Try auto-fetch first (credentials resolved server-side)
|
||||
if (serial) {
|
||||
const result = await fetchDiagnosticAuto(serial)
|
||||
if (result) return
|
||||
}
|
||||
// Fallback: manual password entry on any auto-fetch failure
|
||||
const ip = managementIp.value || props.doc?.ip_address
|
||||
if (!ip) return
|
||||
const errMsg = wifiDiagError.value || ''
|
||||
$q.dialog({
|
||||
title: 'Mot de passe modem',
|
||||
message: `Entrer le mot de passe superadmin pour ${managementIp.value}`,
|
||||
message: errMsg
|
||||
? `Echec auto (${errMsg.substring(0, 80)}) — entrer le mot de passe pour ${ip}`
|
||||
: `Entrer le mot de passe superadmin pour ${ip}`,
|
||||
prompt: { model: '', type: 'password', filled: true },
|
||||
cancel: { flat: true, label: 'Annuler' },
|
||||
ok: { label: 'Lancer', color: 'primary' },
|
||||
persistent: false,
|
||||
}).onOk(pass => {
|
||||
if (pass) fetchDiagnostic(managementIp.value, pass)
|
||||
if (pass) fetchDiagnostic(ip, pass)
|
||||
})
|
||||
}
|
||||
|
||||
const MODEM_TYPE_LABELS = {
|
||||
tplink_xx230v: 'TP-Link',
|
||||
raisecom_boa: 'Raisecom 803-W',
|
||||
raisecom_php: 'Raisecom 803-WS2',
|
||||
}
|
||||
const MODEM_TYPE_COLORS = {
|
||||
tplink_xx230v: 'teal',
|
||||
raisecom_boa: 'orange',
|
||||
raisecom_php: 'deep-purple',
|
||||
}
|
||||
const modemTypeLabel = (type) => MODEM_TYPE_LABELS[type] || type
|
||||
const modemTypeColor = (type) => MODEM_TYPE_COLORS[type] || 'grey'
|
||||
|
||||
const wanRoleLabel = (role) => ROLE_LABELS[role] || role
|
||||
|
||||
function maskToCidr(mask) {
|
||||
|
|
@ -438,6 +526,13 @@ function backhaulColor(signal) {
|
|||
return '#f87171'
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function confirmDelete () {
|
||||
$q.dialog({
|
||||
title: 'Supprimer cet équipement ?',
|
||||
|
|
@ -683,4 +778,5 @@ watch(() => props.doc.serial_number, sn => {
|
|||
.adv-signal-bar { width: 40px; height: 6px; background: #e5e7eb; border-radius: 3px; display: inline-block; vertical-align: middle; margin-right: 4px; overflow: hidden; }
|
||||
.adv-signal-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||
.adv-clients-table td:first-child { white-space: nowrap; }
|
||||
.adv-inactive-row { opacity: 0.5; font-style: italic; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,25 @@
|
|||
<template>
|
||||
<!-- View toggle -->
|
||||
<q-tabs v-model="viewMode" dense align="left" inline-label no-caps
|
||||
indicator-color="indigo-6" active-color="indigo-7" class="q-mb-sm"
|
||||
style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
|
||||
<q-tab name="fields" icon="list_alt" label="Détails" />
|
||||
<q-tab name="client" icon="receipt_long" label="Aperçu client" />
|
||||
</q-tabs>
|
||||
|
||||
<!-- ══════════ CLIENT PREVIEW ══════════ -->
|
||||
<div v-if="viewMode === 'client'" class="q-mb-md">
|
||||
<div class="row q-gutter-sm q-mb-sm q-pa-sm" style="background:#f8fafc;border-radius:8px">
|
||||
<q-btn outline dense no-caps color="red-5" icon="picture_as_pdf" label="Télécharger PDF"
|
||||
size="sm" :href="pdfDownloadUrl" target="_blank" />
|
||||
<q-btn outline dense no-caps color="indigo-6" icon="content_copy" label="Copier lien paiement"
|
||||
size="sm" :loading="generatingPayLink" @click="copyPayLink" />
|
||||
</div>
|
||||
<iframe :src="clientPreviewUrl" class="invoice-client-preview" />
|
||||
</div>
|
||||
|
||||
<!-- ══════════ OPS FIELDS (default) ══════════ -->
|
||||
<template v-else>
|
||||
<!-- Action bar -->
|
||||
<div class="row q-gutter-sm q-mb-md q-pa-sm" style="background:#f8fafc;border-radius:8px">
|
||||
<!-- Draft: Submit -->
|
||||
|
|
@ -83,6 +104,29 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invoice-client-preview {
|
||||
width: 100%;
|
||||
min-height: 75vh;
|
||||
border: 1px solid var(--ops-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Field grid (duplicated from DetailModal.vue — parent scoped styles
|
||||
do not cascade into slot component children in Vue SFC). ── */
|
||||
.modal-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
|
||||
.mf { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; font-size: 0.875rem; border-bottom: 1px solid #f1f5f9; }
|
||||
.mf-label { font-size: 0.72rem; font-weight: 600; color: #6b7280; min-width: 80px; flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.info-block-title { font-size: 0.72rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; border-bottom: 1px solid var(--ops-border, #e2e8f0); padding-bottom: 4px; }
|
||||
.info-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
|
||||
.ops-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.7rem;
|
||||
font-weight: 600; background: #eef2ff; color: #4338ca;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
|
@ -109,6 +153,35 @@ const actionLoading = ref(false)
|
|||
const localStatus = ref(null)
|
||||
const sendingLink = ref(false)
|
||||
const chargingCard = ref(false)
|
||||
const viewMode = ref('fields')
|
||||
const generatingPayLink = ref(false)
|
||||
|
||||
// Client preview = the actual Chromium-rendered PDF (same pipeline the client gets
|
||||
// via email / QR). Using our whitelisted invoice_pdf endpoint — NOT Frappe's
|
||||
// /printview (which wraps the template in printview.html and inflates line-heights).
|
||||
const pdfDownloadUrl = computed(() =>
|
||||
`${BASE_URL}/api/method/gigafibre_utils.api.invoice_pdf?name=${encodeURIComponent(props.docName)}`,
|
||||
)
|
||||
// #toolbar=0 hides the PDF viewer chrome for a cleaner in-panel look.
|
||||
const clientPreviewUrl = computed(() => `${pdfDownloadUrl.value}#toolbar=0&navpanes=0&scrollbar=1`)
|
||||
|
||||
async function copyPayLink () {
|
||||
generatingPayLink.value = true
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${BASE_URL}/api/method/gigafibre_utils.api.pay_token?invoice=${encodeURIComponent(props.docName)}&ttl_days=30`,
|
||||
)
|
||||
const data = await res.json()
|
||||
const url = data?.message?.url
|
||||
if (!url) throw new Error('Pas d\'URL retournée')
|
||||
await navigator.clipboard.writeText(url)
|
||||
Notify.create({ type: 'positive', message: 'Lien de paiement copié', position: 'top' })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
|
||||
} finally {
|
||||
generatingPayLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isDraft = computed(() => props.doc.docstatus === 0)
|
||||
const isSubmitted = computed(() => props.doc.docstatus === 1)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
<q-btn v-else dense unelevated no-caps
|
||||
icon="refresh" label="Réouvrir" color="orange-7" size="sm"
|
||||
@click="reopenTicket" :loading="closingTicket" />
|
||||
<FlowQuickButton flat dense size="sm" icon="account_tree" label="Flow"
|
||||
tooltip="Lancer / modifier un flow pour ce ticket"
|
||||
category="incident" applies-to="Issue" trigger-event="on_issue_opened" />
|
||||
</div>
|
||||
|
||||
<div class="issue-field-grid">
|
||||
|
|
@ -270,6 +273,7 @@ import { BASE_URL } from 'src/config/erpnext'
|
|||
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
||||
import TaskNode from 'src/components/shared/TaskNode.vue'
|
||||
|
||||
|
|
|
|||
190
apps/ops/src/composables/useAddressPricing.js
Normal file
190
apps/ops/src/composables/useAddressPricing.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { ref, computed, watch } from 'vue'
|
||||
import { mockAddressPricing, defaultPricingArea } from 'src/data/pricing-mock'
|
||||
|
||||
/**
|
||||
* Address-based pricing composable.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Load pricing data for a delivery address (stub → mock; later → hub).
|
||||
* - Derive technical fees (distance beyond included + memo extras).
|
||||
* - Sync those fees into orderItems as `address_fee: true` lines so the
|
||||
* cart total already includes them. Lines survive other cart mutations
|
||||
* because the watch re-syncs on every derivedFees change.
|
||||
*
|
||||
* Contract with other wizard logic:
|
||||
* - address_fee lines are always onetime, never recurring → combo rebate
|
||||
* already ignores them (filters by billing === 'recurring').
|
||||
* - address_fee_id is the stable join key used to add/update/remove.
|
||||
*/
|
||||
export function useAddressPricing ({ orderItems }) {
|
||||
const pricingData = ref(null)
|
||||
const loading = ref(false)
|
||||
const currentAddressId = ref(null)
|
||||
|
||||
async function loadAddressPricing (addressId) {
|
||||
if (!addressId) {
|
||||
resetAddressPricing()
|
||||
return
|
||||
}
|
||||
if (addressId === currentAddressId.value) return
|
||||
loading.value = true
|
||||
currentAddressId.value = addressId
|
||||
try {
|
||||
pricingData.value = await mockAddressPricing(addressId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAddressPricing () {
|
||||
pricingData.value = null
|
||||
currentAddressId.value = null
|
||||
// Purge any lingering address_fee lines so a reopened wizard starts clean.
|
||||
const filtered = orderItems.value.filter(i => !i.address_fee)
|
||||
if (filtered.length !== orderItems.value.length) {
|
||||
orderItems.value = filtered
|
||||
}
|
||||
}
|
||||
|
||||
// Effective pricing area — real one or fallback defaults.
|
||||
const effectiveArea = computed(() => {
|
||||
const d = pricingData.value
|
||||
if (!d) return null
|
||||
return d.pricing_area || defaultPricingArea()
|
||||
})
|
||||
|
||||
// Derived fee list. distance-overage is auto-computed; extras are passed
|
||||
// through verbatim (the survey values from earlier quotes/visits).
|
||||
const derivedFees = computed(() => {
|
||||
const d = pricingData.value
|
||||
if (!d) return []
|
||||
const area = effectiveArea.value
|
||||
const fees = []
|
||||
|
||||
const dist = Number(d.distance_to_fiber_m) || 0
|
||||
const included = area.default_distance_included_m || 0
|
||||
const extra = Math.max(0, dist - included)
|
||||
if (extra > 0) {
|
||||
const amount = +(extra * (area.fee_per_extra_meter || 0)).toFixed(2)
|
||||
fees.push({
|
||||
id: 'fee_dist_extra',
|
||||
item_code: 'FEE-DIST-EXT',
|
||||
item_name: `Extension réseau — ${extra.toFixed(0)} m @ ${area.fee_per_extra_meter.toFixed(2)}$/m`,
|
||||
rate: amount,
|
||||
billing: 'onetime',
|
||||
category: 'distance',
|
||||
source: `${dist}m total, ${included}m inclus (${area.name})`,
|
||||
})
|
||||
}
|
||||
|
||||
for (const e of d.extras || []) {
|
||||
fees.push({
|
||||
id: `fee_extra_${e.id}`,
|
||||
item_code: `FEE-EXT-${String(e.id).slice(-6).toUpperCase()}`,
|
||||
item_name: e.label,
|
||||
rate: Number(e.amount) || 0,
|
||||
billing: 'onetime',
|
||||
category: e.category || 'other',
|
||||
source: e.note || '',
|
||||
extra_id: e.id,
|
||||
})
|
||||
}
|
||||
|
||||
return fees
|
||||
})
|
||||
|
||||
// Reconcile derivedFees ↔ orderItems. This runs idempotently on every
|
||||
// derivedFees tick: adds new, updates changed (rate / item_name), removes
|
||||
// vanished. Does not touch non-address_fee lines.
|
||||
function syncFeesToCart () {
|
||||
const derived = derivedFees.value
|
||||
const derivedByKey = new Map(derived.map(d => [d.id, d]))
|
||||
|
||||
// Remove orphans first (lines whose derivation no longer exists).
|
||||
for (let i = orderItems.value.length - 1; i >= 0; i--) {
|
||||
const line = orderItems.value[i]
|
||||
if (!line.address_fee) continue
|
||||
if (!derivedByKey.has(line.address_fee_id)) {
|
||||
orderItems.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update.
|
||||
const existingByKey = new Map()
|
||||
for (const line of orderItems.value) {
|
||||
if (line.address_fee) existingByKey.set(line.address_fee_id, line)
|
||||
}
|
||||
for (const d of derived) {
|
||||
const ex = existingByKey.get(d.id)
|
||||
if (!ex) {
|
||||
orderItems.value.push({
|
||||
address_fee: true,
|
||||
address_fee_id: d.id,
|
||||
item_code: d.item_code,
|
||||
item_name: d.item_name,
|
||||
qty: 1,
|
||||
rate: d.rate,
|
||||
regular_price: 0,
|
||||
billing: 'onetime',
|
||||
billing_interval: 'Month',
|
||||
contract_months: 12,
|
||||
project_template_id: '',
|
||||
fee_category: d.category,
|
||||
fee_source: d.source,
|
||||
})
|
||||
} else {
|
||||
if (ex.rate !== d.rate) ex.rate = d.rate
|
||||
if (ex.item_name !== d.item_name) ex.item_name = d.item_name
|
||||
if (ex.item_code !== d.item_code) ex.item_code = d.item_code
|
||||
if (ex.fee_source !== d.source) ex.fee_source = d.source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(derivedFees, syncFeesToCart, { deep: true, immediate: true })
|
||||
|
||||
function updateDistance (meters) {
|
||||
if (!pricingData.value) return
|
||||
pricingData.value = { ...pricingData.value, distance_to_fiber_m: Number(meters) || 0 }
|
||||
}
|
||||
|
||||
function addExtra (extra) {
|
||||
if (!pricingData.value) return
|
||||
const id = extra.id || `ex_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`
|
||||
pricingData.value = {
|
||||
...pricingData.value,
|
||||
extras: [...(pricingData.value.extras || []), {
|
||||
id,
|
||||
label: extra.label,
|
||||
category: extra.category || 'other',
|
||||
amount: Number(extra.amount) || 0,
|
||||
note: extra.note || '',
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function removeExtra (extraId) {
|
||||
if (!pricingData.value) return
|
||||
pricingData.value = {
|
||||
...pricingData.value,
|
||||
extras: (pricingData.value.extras || []).filter(e => e.id !== extraId),
|
||||
}
|
||||
}
|
||||
|
||||
const totalFees = computed(() =>
|
||||
derivedFees.value.reduce((sum, f) => sum + (f.rate || 0), 0)
|
||||
)
|
||||
|
||||
return {
|
||||
pricingData,
|
||||
loading,
|
||||
effectiveArea,
|
||||
derivedFees,
|
||||
totalFees,
|
||||
loadAddressPricing,
|
||||
resetAddressPricing,
|
||||
updateDistance,
|
||||
addExtra,
|
||||
removeExtra,
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ export function useClientData (deps) {
|
|||
const paymentMethods = ref([])
|
||||
const arrangements = ref([])
|
||||
const quotations = ref([])
|
||||
const serviceContracts = ref([])
|
||||
const comments = ref([])
|
||||
const accountBalance = ref(null)
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ export function useClientData (deps) {
|
|||
paymentMethods.value = []
|
||||
arrangements.value = []
|
||||
quotations.value = []
|
||||
serviceContracts.value = []
|
||||
comments.value = []
|
||||
contact.value = null
|
||||
modalOpen.value = false
|
||||
|
|
@ -197,13 +199,28 @@ export function useClientData (deps) {
|
|||
}
|
||||
|
||||
function loadQuotations (id) {
|
||||
// Both legacy (custom_legacy_soumission_id > 0) and new wizard-created
|
||||
// Quotations are returned. Filtering only on party_name surfaces both.
|
||||
return listDocs('Quotation', {
|
||||
filters: { party_name: id, custom_legacy_soumission_id: ['>', 0] },
|
||||
filters: { party_name: id },
|
||||
fields: ['name', 'transaction_date', 'grand_total', 'status', 'custom_legacy_soumission_id', 'custom_po_number'],
|
||||
limit: 50, orderBy: 'transaction_date desc',
|
||||
}).catch(() => [])
|
||||
}
|
||||
|
||||
function loadServiceContracts (id) {
|
||||
// Service Contracts are the "offre de service" artifact — they carry
|
||||
// monthly_rate, duration, and benefits (net promotions). Shown alongside
|
||||
// Soumissions so the rep can retrieve the recap after publish.
|
||||
return listDocs('Service Contract', {
|
||||
filters: { customer: id },
|
||||
fields: ['name', 'contract_type', 'status', 'start_date', 'end_date',
|
||||
'duration_months', 'monthly_rate', 'total_benefit_value',
|
||||
'quotation', 'acceptance_method', 'signed_at'],
|
||||
limit: 50, orderBy: 'creation desc',
|
||||
}).catch(() => [])
|
||||
}
|
||||
|
||||
function loadComments (id) {
|
||||
return listDocs('Comment', {
|
||||
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
||||
|
|
@ -220,7 +237,7 @@ export function useClientData (deps) {
|
|||
resetState()
|
||||
try {
|
||||
const custFilter = { customer: id }
|
||||
const [cust, locs, subs, equip, tix, invs, pays, voip, pmethods, arrgs, quots, memos, balRes] = await Promise.all([
|
||||
const [cust, locs, subs, equip, tix, invs, pays, voip, pmethods, arrgs, quots, contracts, memos, balRes] = await Promise.all([
|
||||
getDoc('Customer', id),
|
||||
loadLocations(custFilter),
|
||||
loadSubscriptions(custFilter),
|
||||
|
|
@ -232,6 +249,7 @@ export function useClientData (deps) {
|
|||
loadPaymentMethods(id),
|
||||
loadArrangements(custFilter),
|
||||
loadQuotations(id),
|
||||
loadServiceContracts(id),
|
||||
loadComments(id),
|
||||
loadBalance(id),
|
||||
])
|
||||
|
|
@ -255,6 +273,7 @@ export function useClientData (deps) {
|
|||
paymentMethods.value = pmethods
|
||||
arrangements.value = arrgs
|
||||
quotations.value = quots
|
||||
serviceContracts.value = contracts
|
||||
contact.value = null
|
||||
comments.value = memos
|
||||
|
||||
|
|
@ -313,7 +332,7 @@ export function useClientData (deps) {
|
|||
return {
|
||||
loading, customer, contact, locations, subscriptions, tickets,
|
||||
invoices, payments, voipLines, paymentMethods, arrangements, quotations,
|
||||
comments, accountBalance,
|
||||
serviceContracts, comments, accountBalance,
|
||||
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
|
||||
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,13 +56,28 @@ export function useDeviceStatus () {
|
|||
|
||||
// Batch lookups (GenieACS NBI doesn't support batch, so parallel individual)
|
||||
loading.value = true
|
||||
// Build per-serial hints from equipment records (OLT coords for Raisecom RCMG lookup)
|
||||
const hintsBySerial = new Map()
|
||||
for (const eq of equipmentList) {
|
||||
if (!eq.serial_number) continue
|
||||
const hints = {}
|
||||
if (eq.ip_address) hints.ip = eq.ip_address
|
||||
if (eq.olt_ip) hints.olt_ip = eq.olt_ip
|
||||
if (eq.olt_slot) hints.olt_slot = eq.olt_slot
|
||||
if (eq.olt_port) hints.olt_port = eq.olt_port
|
||||
if (eq.olt_ontid) hints.olt_ontid = eq.olt_ontid
|
||||
if (Object.keys(hints).length) hintsBySerial.set(eq.serial_number, hints)
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
toFetch.map(serial =>
|
||||
fetch(`${HUB_URL}/devices/lookup?serial=${encodeURIComponent(serial)}`)
|
||||
toFetch.map(serial => {
|
||||
const params = new URLSearchParams({ serial })
|
||||
const hints = hintsBySerial.get(serial) || {}
|
||||
for (const [k, v] of Object.entries(hints)) params.set(k, v)
|
||||
return fetch(`${HUB_URL}/devices/lookup?${params}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => ({ serial, data: Array.isArray(data) && data.length ? data[0] : null }))
|
||||
.catch(() => ({ serial, data: null }))
|
||||
)
|
||||
})
|
||||
)
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled' && r.value.data) {
|
||||
|
|
|
|||
205
apps/ops/src/composables/useFlowEditor.js
Normal file
205
apps/ops/src/composables/useFlowEditor.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* useFlowEditor.js — Global reactive store for the Flow Editor dialog.
|
||||
*
|
||||
* Odoo-style "inline create from anywhere": any page can call openFlow()
|
||||
* to pop the editor without having to route to Settings.
|
||||
*
|
||||
* The shared state (singleton module-level refs) is consumed by
|
||||
* <FlowEditorDialog /> mounted in MainLayout. Pages simply call
|
||||
* `const fe = useFlowEditor(); fe.openTemplate('FT-00001')`.
|
||||
*
|
||||
* API (all methods optimised to avoid redundant fetches):
|
||||
* openTemplate(name, opts?) — load an existing FT by name and open editor
|
||||
* openNew(opts?) — open editor with a blank draft
|
||||
* close() — close + clear state
|
||||
* isOpen Ref<boolean>
|
||||
* template Ref<Object|null> — current FT doc being edited
|
||||
* loading Ref<boolean>
|
||||
* error Ref<string|null>
|
||||
* dirty Ref<boolean> — unsaved changes flag
|
||||
*
|
||||
* Options accepted by openTemplate / openNew:
|
||||
* { category, applies_to, kind } — preset fields for new templates
|
||||
* { onSaved(tpl) } — callback after a successful save
|
||||
* { context } — opaque host context (for telemetry)
|
||||
*/
|
||||
|
||||
import { ref, reactive, readonly } from 'vue'
|
||||
import {
|
||||
getFlowTemplate,
|
||||
createFlowTemplate,
|
||||
updateFlowTemplate,
|
||||
duplicateFlowTemplate,
|
||||
deleteFlowTemplate,
|
||||
} from 'src/api/flow-templates'
|
||||
|
||||
// ── Module-level shared state (singleton) ───────────────────────────────────
|
||||
|
||||
const isOpen = ref(false)
|
||||
const template = ref(null) // the doc currently being edited
|
||||
const templateName = ref(null) // non-null once saved once
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref(null)
|
||||
const dirty = ref(false)
|
||||
const onSavedCbs = ref([]) // callbacks from the caller
|
||||
const mode = ref('edit') // 'new' | 'edit' | 'view'
|
||||
|
||||
/** Build a blank FT draft with sensible defaults. */
|
||||
function blankTemplate (opts = {}) {
|
||||
return {
|
||||
template_name: '',
|
||||
category: opts.category || 'other',
|
||||
applies_to: opts.applies_to || null,
|
||||
icon: opts.icon || 'account_tree',
|
||||
is_active: 1,
|
||||
is_system: 0,
|
||||
version: 1,
|
||||
description: '',
|
||||
trigger_event: opts.trigger_event || null,
|
||||
trigger_condition: '',
|
||||
flow_definition: {
|
||||
version: 1,
|
||||
trigger: { type: opts.kind === 'agent' ? 'manual' : 'on_flow_start' },
|
||||
variables: {},
|
||||
steps: [],
|
||||
},
|
||||
tags: '',
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the editor on an existing template.
|
||||
* Fetches full body (incl. flow_definition) from the Hub.
|
||||
*/
|
||||
async function openTemplate (name, opts = {}) {
|
||||
if (!name) return openNew(opts)
|
||||
error.value = null
|
||||
loading.value = true
|
||||
isOpen.value = true
|
||||
mode.value = 'edit'
|
||||
templateName.value = name
|
||||
onSavedCbs.value = opts.onSaved ? [opts.onSaved] : []
|
||||
try {
|
||||
const tpl = await getFlowTemplate(name)
|
||||
// Ensure flow_definition is a live object (Hub returns parsed JSON)
|
||||
if (!tpl.flow_definition || typeof tpl.flow_definition === 'string') {
|
||||
try { tpl.flow_definition = JSON.parse(tpl.flow_definition || '{}') }
|
||||
catch { tpl.flow_definition = { steps: [] } }
|
||||
}
|
||||
if (!tpl.flow_definition.steps) tpl.flow_definition.steps = []
|
||||
template.value = tpl
|
||||
dirty.value = false
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the editor on a fresh draft. Save-on-Enregistrer creates it. */
|
||||
function openNew (opts = {}) {
|
||||
error.value = null
|
||||
isOpen.value = true
|
||||
mode.value = 'new'
|
||||
templateName.value = null
|
||||
template.value = blankTemplate(opts)
|
||||
dirty.value = false
|
||||
onSavedCbs.value = opts.onSaved ? [opts.onSaved] : []
|
||||
}
|
||||
|
||||
/** Close the editor. Caller is responsible for confirming unsaved changes. */
|
||||
function close (force = false) {
|
||||
if (dirty.value && !force) {
|
||||
if (!window.confirm('Modifications non sauvegardées. Fermer quand même ?')) return
|
||||
}
|
||||
isOpen.value = false
|
||||
template.value = null
|
||||
templateName.value = null
|
||||
dirty.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
/** Mark the editor state as dirty (called by child on any mutation). */
|
||||
function markDirty () { dirty.value = true }
|
||||
|
||||
/**
|
||||
* Save the current draft. Creates if new, else patches.
|
||||
* Returns the saved template on success; throws on error.
|
||||
*/
|
||||
async function save () {
|
||||
if (!template.value) return
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
let saved
|
||||
const body = { ...template.value }
|
||||
// flow_definition will be stringified server-side
|
||||
if (mode.value === 'new') {
|
||||
saved = await createFlowTemplate(body)
|
||||
templateName.value = saved.name
|
||||
mode.value = 'edit'
|
||||
} else {
|
||||
saved = await updateFlowTemplate(templateName.value, body)
|
||||
}
|
||||
template.value = { ...template.value, ...saved, flow_definition: template.value.flow_definition }
|
||||
dirty.value = false
|
||||
for (const cb of onSavedCbs.value) { try { cb(saved) } catch { /* noop */ } }
|
||||
return saved
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Duplicate the current template (only valid in edit mode). */
|
||||
async function duplicate (newName) {
|
||||
if (mode.value !== 'edit' || !templateName.value) return
|
||||
const dup = await duplicateFlowTemplate(templateName.value, newName)
|
||||
await openTemplate(dup.name)
|
||||
return dup
|
||||
}
|
||||
|
||||
/** Delete the current template (only valid in edit mode + non-system). */
|
||||
async function remove () {
|
||||
if (mode.value !== 'edit' || !templateName.value) return
|
||||
if (template.value?.is_system) {
|
||||
throw new Error('Impossible de supprimer un template système (utilisez "Dupliquer")')
|
||||
}
|
||||
await deleteFlowTemplate(templateName.value)
|
||||
close(true)
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFlowEditor () {
|
||||
return {
|
||||
// state (read-only refs so consumers can't accidentally mutate)
|
||||
isOpen: readonly(isOpen),
|
||||
loading: readonly(loading),
|
||||
saving: readonly(saving),
|
||||
error: readonly(error),
|
||||
dirty: readonly(dirty),
|
||||
mode: readonly(mode),
|
||||
templateName: readonly(templateName),
|
||||
// mutable state (only the draft content, by design)
|
||||
template,
|
||||
// actions
|
||||
openTemplate,
|
||||
openNew,
|
||||
close,
|
||||
save,
|
||||
duplicate,
|
||||
remove,
|
||||
markDirty,
|
||||
}
|
||||
}
|
||||
|
||||
// Named default for convenience
|
||||
export default useFlowEditor
|
||||
217
apps/ops/src/composables/useModemDiagnostic.js
Normal file
217
apps/ops/src/composables/useModemDiagnostic.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { ref } from 'vue'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
const THRESHOLDS = {
|
||||
meshSignal: { critical: 60, warning: 80 },
|
||||
clientSignal: { critical: 50, warning: 70 },
|
||||
packetLoss: { critical: 10, warning: 5 },
|
||||
backhaulUtil: 80,
|
||||
cpu: 80,
|
||||
preferred2gChannels: [1, 6, 11],
|
||||
}
|
||||
|
||||
const cache = new Map()
|
||||
const CACHE_TTL = 120_000
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const data = ref(null)
|
||||
|
||||
async function fetchDiagnostic(ip, pass, user = 'superadmin') {
|
||||
if (!ip || !pass) { error.value = 'IP et mot de passe requis'; return null }
|
||||
|
||||
const cached = cache.get(ip)
|
||||
if (cached && (Date.now() - cached.ts) < CACHE_TTL) {
|
||||
data.value = cached.data
|
||||
return cached.data
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ ip, user, pass })
|
||||
const res = await fetch(`${HUB_URL}/modem/diagnostic?${params}`)
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`)
|
||||
|
||||
const processed = processDiagnostic(json)
|
||||
cache.set(ip, { data: processed, ts: Date.now() })
|
||||
data.value = processed
|
||||
return processed
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fetch diagnostic by serial number — credentials resolved server-side from ERPNext.
|
||||
* No password required from the user.
|
||||
*/
|
||||
async function fetchDiagnosticAuto(serial) {
|
||||
if (!serial) { error.value = 'Numero de serie requis'; return null }
|
||||
|
||||
const cached = cache.get(`auto:${serial}`)
|
||||
if (cached && (Date.now() - cached.ts) < CACHE_TTL) {
|
||||
data.value = cached.data
|
||||
return cached.data
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HUB_URL}/modem/diagnostic/auto?serial=${encodeURIComponent(serial)}`)
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`)
|
||||
|
||||
const processed = processDiagnostic(json)
|
||||
cache.set(`auto:${serial}`, { data: processed, ts: Date.now() })
|
||||
data.value = processed
|
||||
return processed
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process unified diagnostic data from backend.
|
||||
* Backend normalizes both TP-Link and Raisecom into the same shape.
|
||||
* Frontend adds client-side issue detection on top.
|
||||
*/
|
||||
function processDiagnostic(raw) {
|
||||
const issues = [...(raw.issues || [])]
|
||||
|
||||
const online = raw.online || null
|
||||
const wanIPs = raw.wanIPs || []
|
||||
const radios = raw.radios || []
|
||||
const meshNodes = raw.meshNodes || []
|
||||
const wifiClients = raw.wifiClients || []
|
||||
const ethernetPorts = raw.ethernetPorts || []
|
||||
const dhcpLeases = raw.dhcpLeases || []
|
||||
const wiredEquipment = raw.wiredEquipment || []
|
||||
const device = raw.device || null
|
||||
const gpon = raw.gpon || null
|
||||
|
||||
// Client-side issue detection
|
||||
checkMeshIssues(meshNodes, issues)
|
||||
checkClientIssues(wifiClients, meshNodes, issues)
|
||||
checkRadioIssues(radios, issues)
|
||||
|
||||
const severityOrder = { critical: 0, warning: 1, info: 2 }
|
||||
issues.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9))
|
||||
|
||||
return {
|
||||
fetchedAt: raw.fetchedAt, durationMs: raw.durationMs, modemType: raw.modemType,
|
||||
issues, meshNodes, wifiClients, radios, wanIPs, online,
|
||||
ethernetPorts, dhcpLeases, wiredEquipment, device, gpon,
|
||||
}
|
||||
}
|
||||
|
||||
function checkMeshIssues(meshNodes, issues) {
|
||||
for (const node of meshNodes) {
|
||||
if (!node.isController && node.active && node.backhaul?.type === 'Wi-Fi') {
|
||||
const sig = node.backhaul.signal
|
||||
const name = node.hostname
|
||||
if (sig < THRESHOLDS.meshSignal.critical) {
|
||||
issues.push({
|
||||
severity: 'critical',
|
||||
message: `${name}: signal mesh tres faible (${sig})`,
|
||||
detail: `Le noeud "${name}" a un signal de backhaul de ${sig}/255. Lien a ${node.backhaul.linkRate} Mbps.`,
|
||||
action: `Rapprocher le noeud "${name}" du routeur principal ou ajouter un noeud intermediaire.`,
|
||||
})
|
||||
} else if (sig < THRESHOLDS.meshSignal.warning) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `${name}: signal mesh faible (${sig})`,
|
||||
detail: `Backhaul a ${node.backhaul.linkRate} Mbps, utilisation ${node.backhaul.utilization}%.`,
|
||||
action: `Envisager de rapprocher "${name}" du routeur pour ameliorer la vitesse.`,
|
||||
})
|
||||
}
|
||||
if (node.backhaul.utilization > THRESHOLDS.backhaulUtil) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `${name}: backhaul sature (${node.backhaul.utilization}%)`,
|
||||
detail: `Le lien entre "${name}" et le routeur est utilise a ${node.backhaul.utilization}%.`,
|
||||
action: `Reduire le nombre d'appareils sur ce noeud ou connecter "${name}" en Ethernet.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (node.cpu > THRESHOLDS.cpu) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `${node.hostname}: CPU eleve (${node.cpu}%)`,
|
||||
detail: `Le processeur du noeud est a ${node.cpu}% d'utilisation.`,
|
||||
action: `Redemarrer le noeud "${node.hostname}" si le probleme persiste.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkClientIssues(wifiClients, meshNodes, issues) {
|
||||
for (const c of wifiClients) {
|
||||
if (!c.active) continue
|
||||
const label = c.hostname || c.mac
|
||||
if (c.signal > 0 && c.signal < THRESHOLDS.clientSignal.critical) {
|
||||
issues.push({
|
||||
severity: 'critical',
|
||||
message: `${label}: signal tres faible (${c.signal}/255)`,
|
||||
detail: `Appareil "${label}" sur ${c.band || '?'}, lien ${Math.round((c.linkDown || 0) / 1000)} Mbps.`,
|
||||
action: `Rapprocher l'appareil du noeud mesh le plus proche ou verifier les obstacles.`,
|
||||
})
|
||||
} else if (c.signal > 0 && c.signal < THRESHOLDS.clientSignal.warning) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `${label}: signal faible (${c.signal}/255)`,
|
||||
detail: `Vitesse reduite a ${Math.round((c.linkDown || 0) / 1000)} Mbps.`,
|
||||
action: `Verifier le placement de l'appareil par rapport au noeud "${c.meshNode || 'principal'}".`,
|
||||
})
|
||||
}
|
||||
if (c.lossPercent > THRESHOLDS.packetLoss.critical) {
|
||||
issues.push({
|
||||
severity: 'critical',
|
||||
message: `${label}: ${c.lossPercent}% perte de paquets`,
|
||||
detail: `Retransmissions detectees.`,
|
||||
action: `Interference probable. Verifier le canal WiFi, les appareils voisins, ou changer la bande.`,
|
||||
})
|
||||
} else if (c.lossPercent > THRESHOLDS.packetLoss.warning) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `${label}: ${c.lossPercent}% perte de paquets`,
|
||||
detail: `Performance reduite.`,
|
||||
action: `Envisager de changer de canal ou rapprocher l'appareil.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRadioIssues(radios, issues) {
|
||||
for (const r of radios) {
|
||||
if (r.band === '2.4GHz' && !r.autoChannel && r.channel > 0
|
||||
&& THRESHOLDS.preferred2gChannels.indexOf(r.channel) === -1) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `Canal 2.4GHz non optimal (${r.channel})`,
|
||||
detail: `Le canal ${r.channel} chevauche les canaux voisins. Les canaux 1, 6 ou 11 sont recommandes.`,
|
||||
action: `Changer le canal 2.4GHz a 1, 6 ou 11, ou activer le canal automatique.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useModemDiagnostic() {
|
||||
return { fetchDiagnostic, fetchDiagnosticAuto, loading, error, data }
|
||||
}
|
||||
|
||||
// Keep backward-compatible export
|
||||
export function useWifiDiagnostic() {
|
||||
return { fetchDiagnostic, fetchDiagnosticAuto, loading, error, data }
|
||||
}
|
||||
|
|
@ -238,14 +238,14 @@ function processClients(raw, issues, meshNodes, nodeRadios) {
|
|||
}
|
||||
}
|
||||
|
||||
if (c.lossPercent > THRESHOLDS.packetLoss.critical) {
|
||||
if (c.active && c.lossPercent > THRESHOLDS.packetLoss.critical) {
|
||||
issues.push({
|
||||
severity: 'critical',
|
||||
message: `${label}: ${c.lossPercent}% perte de paquets`,
|
||||
detail: `${c.retrans} retransmissions sur ${c.packetsSent} paquets envoyes.`,
|
||||
action: `Interference probable. Verifier le canal WiFi, les appareils voisins, ou changer la bande.`,
|
||||
})
|
||||
} else if (c.lossPercent > THRESHOLDS.packetLoss.warning) {
|
||||
} else if (c.active && c.lossPercent > THRESHOLDS.packetLoss.warning) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
message: `${label}: ${c.lossPercent}% perte de paquets`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { HUB_URL, CATALOG_CATEGORIES } from 'src/data/wizard-constants'
|
||||
import { HUB_URL, CATALOG_CATEGORIES, RESIDENTIAL_PRESETS, INTERNET_HERO_TIERS, INTERNET_HERO_CODES, TV_HERO_TIERS, TV_HERO_CODES, PREMIUM_SPORTS_CHANNELS, TV_PREMIUM_SURCHARGE_CODE, TV_ALC_OVERAGE_CODE } from 'src/data/wizard-constants'
|
||||
|
||||
// Premium channel lookup — keyed by premium_group id → catalog entry. Used to
|
||||
// compute surcharges and pick-costs without re-walking PREMIUM_SPORTS_CHANNELS.
|
||||
const PREMIUM_BY_GROUP = new Map(PREMIUM_SPORTS_CHANNELS.map(p => [p.id, p]))
|
||||
|
||||
// Shared "one truck roll" collapse — when multiple templates include the same
|
||||
// logical task (e.g. fiber install visit), we keep a single step and rewire
|
||||
// dependencies so the merged step's successors point at the right index.
|
||||
// merge_key is the stable identifier; we fall back to subject for legacy
|
||||
// templates that don't yet declare one.
|
||||
|
||||
const FALLBACK_CATALOG = [
|
||||
{ item_code: 'INT-100', item_name: 'Internet 100 Mbps', rate: 49.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
|
||||
|
|
@ -20,6 +30,8 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
|
|||
const catalogProducts = ref([])
|
||||
const catalogLoading = ref(false)
|
||||
const catalogFilter = ref('Tous')
|
||||
const lastMergeCount = ref(0)
|
||||
const mergedTemplateLabels = ref([])
|
||||
|
||||
const filteredCatalog = computed(() => {
|
||||
if (catalogFilter.value === 'Tous') return catalogProducts.value
|
||||
|
|
@ -49,6 +61,7 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
|
|||
item_name: product.item_name,
|
||||
qty: 1,
|
||||
rate: product.rate,
|
||||
regular_price: 0,
|
||||
billing: product.billing_type === 'Mensuel' || product.billing_type === 'Annuel' ? 'recurring' : 'onetime',
|
||||
billing_interval: product.billing_type === 'Annuel' ? 'Year' : 'Month',
|
||||
contract_months: 12,
|
||||
|
|
@ -60,27 +73,363 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
|
|||
}
|
||||
}
|
||||
|
||||
function loadTemplateFromItem (item) {
|
||||
if (!item.project_template_id) return
|
||||
const tpl = templates.find(t => t.id === item.project_template_id)
|
||||
if (tpl && !templateLoadedFor.value.has(item.project_template_id)) {
|
||||
const existingSubjects = new Set(wizardSteps.value.map(s => s.subject))
|
||||
for (const step of tpl.steps) {
|
||||
if (!existingSubjects.has(step.subject)) {
|
||||
// Single-select hero tier swap — generic over a set of codes. Clicking a
|
||||
// tier REPLACES whatever tier from the same family is in the cart (doesn't
|
||||
// stack). Re-clicking the active tier toggles it off. `service_type` and
|
||||
// `family_codes` scope the swap so Internet and TV switchers don't stomp
|
||||
// each other. Auto-cleans shared templates (fiber_install) when no item
|
||||
// from that family still needs it.
|
||||
// When `channelSelection` is supplied (TV Mix tiers), we also emit premium
|
||||
// surcharges (one per premium_group) and an à-la-carte overage line when
|
||||
// the selection exceeds tier.picks_allowed.
|
||||
function selectHeroTierGeneric ({ tier, familyCodes, serviceType, tiers, channelSelection }) {
|
||||
const currentHeroCode = orderItems.value.find(i => familyCodes.has(i.item_code) && !i.is_rebate)?.item_code
|
||||
const isToggleOff = currentHeroCode === tier.code && !channelSelection
|
||||
|
||||
const extraCodesToClear = serviceType === 'tv'
|
||||
? [TV_PREMIUM_SURCHARGE_CODE, TV_ALC_OVERAGE_CODE]
|
||||
: []
|
||||
orderItems.value = orderItems.value.filter(i =>
|
||||
!familyCodes.has(i.item_code) && !extraCodesToClear.includes(i.item_code),
|
||||
)
|
||||
|
||||
// Share the fiber_install template across Internet tiers — only unload it
|
||||
// when no remaining order item references it.
|
||||
const sharedTpl = tier.items.find(i => i.project_template_id)?.project_template_id
|
||||
if (sharedTpl) {
|
||||
const stillNeeded = orderItems.value.some(i => i.project_template_id === sharedTpl)
|
||||
if (!stillNeeded && templateLoadedFor.value.has(sharedTpl)) removeTemplate(sharedTpl)
|
||||
}
|
||||
|
||||
if (isToggleOff) {
|
||||
return
|
||||
}
|
||||
|
||||
const supplementCode = serviceType === 'tv'
|
||||
? tier.items.find(i => i.item_code && i.item_code.startsWith('TV-MIX'))?.item_code
|
||||
: null
|
||||
|
||||
for (const i of tier.items) {
|
||||
const isSupplement = supplementCode && i.item_code === supplementCode
|
||||
orderItems.value.push({
|
||||
item_code: i.item_code,
|
||||
item_name: i.item_name,
|
||||
qty: 1,
|
||||
rate: i.rate,
|
||||
regular_price: i.regular_price || 0,
|
||||
billing: i.billing,
|
||||
billing_interval: i.billing_interval || 'Month',
|
||||
contract_months: i.contract_months || 24,
|
||||
project_template_id: i.project_template_id || '',
|
||||
service_type: serviceType,
|
||||
combo_eligible: true,
|
||||
is_rebate: !!i.is_rebate,
|
||||
applies_to_item: i.applies_to_item || '',
|
||||
tv_tier_id: serviceType === 'tv' ? tier.id : undefined,
|
||||
tv_channels: isSupplement && channelSelection ? channelSelection.map(c => c.name) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Emit premium surcharges + overage for TV Mix tiers.
|
||||
if (serviceType === 'tv' && channelSelection && channelSelection.length) {
|
||||
const seenGroups = new Set()
|
||||
for (const ch of channelSelection) {
|
||||
if (!ch.premium_group || seenGroups.has(ch.premium_group)) continue
|
||||
seenGroups.add(ch.premium_group)
|
||||
const premium = PREMIUM_BY_GROUP.get(ch.premium_group)
|
||||
if (!premium) continue
|
||||
orderItems.value.push({
|
||||
item_code: TV_PREMIUM_SURCHARGE_CODE,
|
||||
item_name: `Premium ${premium.name}`,
|
||||
qty: 1,
|
||||
rate: Number(premium.surcharge) || 0,
|
||||
regular_price: 0,
|
||||
billing: 'recurring',
|
||||
billing_interval: 'Month',
|
||||
contract_months: 24,
|
||||
service_type: 'tv',
|
||||
combo_eligible: false,
|
||||
premium_group: premium.id,
|
||||
applies_to_item: supplementCode || '',
|
||||
})
|
||||
}
|
||||
|
||||
// Overage: one aggregated à-la-carte line when the selection exceeds picks.
|
||||
const picksUsed = channelSelection.reduce((n, c) => n + (c.pick_cost || 1), 0)
|
||||
const picksAllowed = tier.picks_allowed || 0
|
||||
const overage = Math.max(0, picksUsed - picksAllowed)
|
||||
if (overage > 0) {
|
||||
orderItems.value.push({
|
||||
item_code: TV_ALC_OVERAGE_CODE,
|
||||
item_name: `Chaînes additionnelles (à la carte)`,
|
||||
qty: overage,
|
||||
rate: 0,
|
||||
regular_price: 0,
|
||||
billing: 'recurring',
|
||||
billing_interval: 'Month',
|
||||
contract_months: 24,
|
||||
service_type: 'tv',
|
||||
combo_eligible: false,
|
||||
applies_to_item: supplementCode || '',
|
||||
pricing_pending: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const tplId = tier.items.find(i => i.project_template_id)?.project_template_id
|
||||
if (tplId && !templateLoadedFor.value.has(tplId)) {
|
||||
loadTemplateFromItem({ project_template_id: tplId })
|
||||
}
|
||||
}
|
||||
|
||||
const selectHeroTier = (tier) => selectHeroTierGeneric({
|
||||
tier, familyCodes: INTERNET_HERO_CODES, serviceType: 'internet', tiers: INTERNET_HERO_TIERS,
|
||||
})
|
||||
const selectTvTier = (tier, channelSelection) => selectHeroTierGeneric({
|
||||
tier, familyCodes: TV_HERO_CODES, serviceType: 'tv', tiers: TV_HERO_TIERS, channelSelection,
|
||||
})
|
||||
|
||||
const selectedHeroTier = computed(() => {
|
||||
const code = orderItems.value.find(i => INTERNET_HERO_CODES.has(i.item_code) && !i.is_rebate)?.item_code
|
||||
if (!code) return null
|
||||
return INTERNET_HERO_TIERS.find(t => t.code === code) || null
|
||||
})
|
||||
|
||||
// TV current selection — detect by tv_tier_id stamped on the first TV line.
|
||||
// Falls back to code-matching in case of older persisted data.
|
||||
const selectedTvTier = computed(() => {
|
||||
const first = orderItems.value.find(i => TV_HERO_CODES.has(i.item_code))
|
||||
if (!first) return null
|
||||
if (first.tv_tier_id) return TV_HERO_TIERS.find(t => t.id === first.tv_tier_id) || null
|
||||
return TV_HERO_TIERS.find(t => t.code === first.item_code) || null
|
||||
})
|
||||
|
||||
// Apply/unapply a service preset — chip-style toggle.
|
||||
// Clicking a preset whose items are already in the cart REMOVES those items
|
||||
// and unloads the associated template. Otherwise adds missing items.
|
||||
// This fixes the earlier UX where an "added" preset had no click-off path,
|
||||
// forcing operators to delete lines one by one.
|
||||
function applyPreset (preset) {
|
||||
const presetCodes = new Set(preset.items.map(i => i.item_code).filter(Boolean))
|
||||
const alreadyApplied = orderItems.value.some(i => presetCodes.has(i.item_code))
|
||||
|
||||
if (alreadyApplied) {
|
||||
orderItems.value = orderItems.value.filter(i => !presetCodes.has(i.item_code))
|
||||
const tplId = preset.items.find(i => i.project_template_id)?.project_template_id
|
||||
if (tplId && templateLoadedFor.value.has(tplId)) {
|
||||
removeTemplate(tplId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const existingCodes = new Set(orderItems.value.map(i => i.item_code).filter(Boolean))
|
||||
for (const i of preset.items) {
|
||||
if (i.item_code && existingCodes.has(i.item_code)) continue
|
||||
orderItems.value.push({
|
||||
item_code: i.item_code,
|
||||
item_name: i.item_name,
|
||||
qty: 1,
|
||||
rate: i.rate,
|
||||
regular_price: i.regular_price || 0,
|
||||
billing: i.billing,
|
||||
billing_interval: i.billing_interval || 'Month',
|
||||
contract_months: i.contract_months || 12,
|
||||
project_template_id: i.project_template_id || '',
|
||||
service_type: preset.service_type || '',
|
||||
combo_eligible: !!preset.combo_eligible,
|
||||
// Forward rebate metadata so the invoice Jinja can net the line into
|
||||
// its parent, and so per-row overrides persist across save/reload.
|
||||
is_rebate: !!i.is_rebate,
|
||||
applies_to_item: i.applies_to_item || '',
|
||||
})
|
||||
}
|
||||
const tplId = preset.items.find(i => i.project_template_id)?.project_template_id
|
||||
if (tplId && !templateLoadedFor.value.has(tplId)) {
|
||||
loadTemplateFromItem({ project_template_id: tplId })
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: auto-combo rebate was removed. Combo rebates (legacy RAB2X/RAB3X/
|
||||
// RAB4X, -5/-10/-15$) are now added MANUALLY by the sales rep from the
|
||||
// catalog — the wizard no longer inserts them behind the scenes.
|
||||
|
||||
function stepKey (step) { return step.merge_key || step.subject }
|
||||
|
||||
// Merge a template's steps into wizardSteps, dedup'ing by merge_key and
|
||||
// remapping depends_on_step indexes so dependencies still point at the
|
||||
// correct (possibly merged) predecessor. Returns the count of steps that
|
||||
// collapsed into pre-existing ones.
|
||||
function mergeTemplateSteps (tpl) {
|
||||
const keyToIdx = new Map()
|
||||
wizardSteps.value.forEach((s, i) => { keyToIdx.set(stepKey(s), i) })
|
||||
|
||||
// First pass: decide for each template step whether it matches an
|
||||
// existing step or will be appended, so depends_on_step can be remapped
|
||||
// correctly before we actually mutate wizardSteps.
|
||||
const finalIdx = []
|
||||
const toAppend = []
|
||||
let nextIdx = wizardSteps.value.length
|
||||
let mergedCount = 0
|
||||
|
||||
for (let i = 0; i < tpl.steps.length; i++) {
|
||||
const step = tpl.steps[i]
|
||||
const key = stepKey(step)
|
||||
if (keyToIdx.has(key)) {
|
||||
const existingIdx = keyToIdx.get(key)
|
||||
const existing = wizardSteps.value[existingIdx]
|
||||
const sources = new Set(existing.source_templates || [])
|
||||
sources.add(tpl.id)
|
||||
existing.source_templates = Array.from(sources)
|
||||
finalIdx.push(existingIdx)
|
||||
mergedCount++
|
||||
} else {
|
||||
finalIdx.push(nextIdx)
|
||||
toAppend.push({ step, at: nextIdx })
|
||||
keyToIdx.set(key, nextIdx)
|
||||
nextIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: push new steps with remapped dependencies.
|
||||
for (const { step } of toAppend) {
|
||||
const dep = step.depends_on_step != null ? finalIdx[step.depends_on_step] : null
|
||||
wizardSteps.value.push({
|
||||
merge_key: stepKey(step),
|
||||
subject: step.subject,
|
||||
job_type: step.job_type,
|
||||
priority: step.priority,
|
||||
duration_h: step.duration_h,
|
||||
assigned_group: step.assigned_group || '',
|
||||
depends_on_step: step.depends_on_step,
|
||||
depends_on_step: dep,
|
||||
scheduled_date: '',
|
||||
on_open_webhook: step.on_open_webhook || '',
|
||||
on_close_webhook: step.on_close_webhook || '',
|
||||
source_templates: [tpl.id],
|
||||
})
|
||||
}
|
||||
|
||||
return mergedCount
|
||||
}
|
||||
|
||||
// Remove all steps that originated from `templateId`. Steps shared with
|
||||
// another active bundle keep their other sources; only orphans are deleted.
|
||||
// depends_on_step indexes are remapped to survive the compaction.
|
||||
function removeTemplate (templateId) {
|
||||
if (!templateLoadedFor.value.has(templateId)) return
|
||||
const oldToNew = new Map()
|
||||
const filtered = []
|
||||
wizardSteps.value.forEach((step, oldIdx) => {
|
||||
const sources = (step.source_templates || []).filter(id => id !== templateId)
|
||||
if (sources.length === 0) return
|
||||
oldToNew.set(oldIdx, filtered.length)
|
||||
filtered.push({ ...step, source_templates: sources })
|
||||
})
|
||||
for (const step of filtered) {
|
||||
if (step.depends_on_step != null) {
|
||||
const newDep = oldToNew.get(step.depends_on_step)
|
||||
step.depends_on_step = newDep != null ? newDep : null
|
||||
}
|
||||
}
|
||||
wizardSteps.value = filtered
|
||||
const next = new Set(templateLoadedFor.value)
|
||||
next.delete(templateId)
|
||||
templateLoadedFor.value = next
|
||||
}
|
||||
|
||||
function toggleTemplate (templateId, enable) {
|
||||
if (enable) {
|
||||
if (!templateLoadedFor.value.has(templateId)) {
|
||||
loadTemplateFromItem({ project_template_id: templateId })
|
||||
}
|
||||
} else {
|
||||
removeTemplate(templateId)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-step uncheck inside a bundle. Detaches the bundle from the step's
|
||||
// sources; if no other bundle claims it, the step is deleted outright.
|
||||
// Remaps depends_on_step so survivors still line up.
|
||||
function removeStepFromBundle (templateId, mergeKey) {
|
||||
const oldToNew = new Map()
|
||||
const filtered = []
|
||||
wizardSteps.value.forEach((step, oldIdx) => {
|
||||
const key = step.merge_key || step.subject
|
||||
if (key === mergeKey) {
|
||||
const sources = (step.source_templates || []).filter(id => id !== templateId)
|
||||
if (sources.length === 0) return
|
||||
oldToNew.set(oldIdx, filtered.length)
|
||||
filtered.push({ ...step, source_templates: sources })
|
||||
} else {
|
||||
oldToNew.set(oldIdx, filtered.length)
|
||||
filtered.push(step)
|
||||
}
|
||||
})
|
||||
for (const step of filtered) {
|
||||
if (step.depends_on_step != null) {
|
||||
const newDep = oldToNew.get(step.depends_on_step)
|
||||
step.depends_on_step = newDep != null ? newDep : null
|
||||
}
|
||||
}
|
||||
wizardSteps.value = filtered
|
||||
}
|
||||
|
||||
// Ad-hoc step injection — tagged with the owning bundle so it follows the
|
||||
// bundle toggle lifecycle (off → removed, on → stays if it wasn't re-added
|
||||
// elsewhere). Uses a synthesised merge_key so it can't collide with a
|
||||
// catalog template step.
|
||||
function addStepToBundle (templateId, stepData) {
|
||||
wizardSteps.value.push({
|
||||
merge_key: stepData.merge_key || `custom_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
subject: stepData.subject,
|
||||
job_type: stepData.job_type || 'Autre',
|
||||
priority: stepData.priority || 'medium',
|
||||
duration_h: Number(stepData.duration_h) || 0.5,
|
||||
assigned_group: stepData.assigned_group || '',
|
||||
depends_on_step: null,
|
||||
scheduled_date: '',
|
||||
on_open_webhook: '',
|
||||
on_close_webhook: '',
|
||||
source_templates: [templateId],
|
||||
})
|
||||
}
|
||||
|
||||
// Grouping used by the "Étapes chargées" summary card on step 2.
|
||||
// A step with more than one source template is flagged `shared` so the UI
|
||||
// can badge it (removing bundle A won't drop it if bundle B still claims it).
|
||||
const loadedBundles = computed(() => {
|
||||
const bundles = []
|
||||
for (const tplId of templateLoadedFor.value) {
|
||||
const tpl = templates.find(t => t.id === tplId)
|
||||
if (!tpl) continue
|
||||
const steps = wizardSteps.value
|
||||
.map((s, idx) => ({ s, idx }))
|
||||
.filter(({ s }) => (s.source_templates || []).includes(tplId))
|
||||
.map(({ s, idx }) => ({
|
||||
idx,
|
||||
subject: s.subject,
|
||||
merge_key: s.merge_key,
|
||||
shared: (s.source_templates || []).length > 1,
|
||||
}))
|
||||
bundles.push({
|
||||
id: tpl.id,
|
||||
name: tpl.name,
|
||||
icon: tpl.icon,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
return bundles
|
||||
})
|
||||
|
||||
function loadTemplateFromItem (item) {
|
||||
if (!item.project_template_id) return
|
||||
const tpl = templates.find(t => t.id === item.project_template_id)
|
||||
if (!tpl || templateLoadedFor.value.has(item.project_template_id)) return
|
||||
|
||||
const merged = mergeTemplateSteps(tpl)
|
||||
templateLoadedFor.value = new Set([...templateLoadedFor.value, item.project_template_id])
|
||||
if (merged > 0) {
|
||||
lastMergeCount.value = merged
|
||||
mergedTemplateLabels.value = [...mergedTemplateLabels.value, tpl.name]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,9 +438,24 @@ export function useWizardCatalog ({ orderItems, wizardSteps, templates, template
|
|||
catalogLoading,
|
||||
catalogFilter,
|
||||
catalogCategories: CATALOG_CATEGORIES,
|
||||
residentialPresets: RESIDENTIAL_PRESETS,
|
||||
internetHeroTiers: INTERNET_HERO_TIERS,
|
||||
tvHeroTiers: TV_HERO_TIERS,
|
||||
selectedHeroTier,
|
||||
selectHeroTier,
|
||||
selectedTvTier,
|
||||
selectTvTier,
|
||||
filteredCatalog,
|
||||
loadCatalog,
|
||||
addCatalogItem,
|
||||
applyPreset,
|
||||
loadTemplateFromItem,
|
||||
removeTemplate,
|
||||
toggleTemplate,
|
||||
removeStepFromBundle,
|
||||
addStepToBundle,
|
||||
loadedBundles,
|
||||
lastMergeCount,
|
||||
mergedTemplateLabels,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
pendingAcceptance, acceptanceLinkUrl, acceptanceLinkSent,
|
||||
acceptanceSentVia, publishedJobCount,
|
||||
sendTo, sendChannel,
|
||||
publishedContractName,
|
||||
} = state
|
||||
|
||||
async function resolveAddress () {
|
||||
try {
|
||||
if (props.issue?.service_location) {
|
||||
const loc = await getDoc('Service Location', props.issue.service_location)
|
||||
const locName = props.issue?.service_location || props.customer?.service_location
|
||||
if (locName) {
|
||||
const loc = await getDoc('Service Location', locName)
|
||||
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -30,7 +32,8 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
|
||||
try {
|
||||
const address = await resolveAddress()
|
||||
const customer = props.issue?.customer || ''
|
||||
const customer = props.issue?.customer || props.customer?.name || ''
|
||||
const serviceLocation = props.issue?.service_location || props.customer?.service_location || ''
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const onetimeItems = orderItems.value.filter(i => i.billing === 'onetime' && i.item_name)
|
||||
const recurringItems = orderItems.value.filter(i => i.billing === 'recurring' && i.item_name)
|
||||
|
|
@ -45,11 +48,29 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
issue: props.issue?.name || '',
|
||||
customer,
|
||||
address,
|
||||
service_location: props.issue?.service_location || '',
|
||||
service_location: serviceLocation,
|
||||
}
|
||||
|
||||
// Step extras — each wizardStep with extra_fee > 0 becomes a one-time
|
||||
// FEE-EXTRA line carrying the step subject so the agent can see what
|
||||
// the charge covered on the invoice.
|
||||
const stepExtraItems = wizardSteps.value
|
||||
.filter(s => Number(s.extra_fee) > 0)
|
||||
.map(s => {
|
||||
const label = (s.extra_label || '').trim() || 'Extra'
|
||||
const subject = s.subject || 'étape'
|
||||
return {
|
||||
item_name: `${label} — ${subject}`,
|
||||
item_code: 'FEE-EXTRA',
|
||||
qty: 1,
|
||||
rate: Number(s.extra_fee),
|
||||
description: `${label} sur l'étape « ${subject} »`,
|
||||
applies_to_item: '',
|
||||
}
|
||||
})
|
||||
|
||||
// Create financial document
|
||||
if (orderItems.value.some(i => i.item_name)) {
|
||||
if (orderItems.value.some(i => i.item_name) || stepExtraItems.length) {
|
||||
const allItems = [...onetimeItems, ...recurringItems].map(i => ({
|
||||
item_name: i.item_name,
|
||||
item_code: i.item_code || i.item_name,
|
||||
|
|
@ -58,7 +79,11 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
description: i.billing === 'recurring'
|
||||
? `${i.item_name} — ${i.rate}$/mois × ${i.contract_months || 12} mois`
|
||||
: i.item_name,
|
||||
}))
|
||||
// Per-line rebate binding — consumed by the invoice Jinja to net the
|
||||
// rebate into its parent line for customer-facing docs. Empty for
|
||||
// non-rebate lines; extra fields are ignored by Frappe if unused.
|
||||
applies_to_item: i.applies_to_item || '',
|
||||
})).concat(stepExtraItems)
|
||||
|
||||
const baseDoc = {
|
||||
customer,
|
||||
|
|
@ -110,7 +135,19 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
orderDocType = 'Sales Order'
|
||||
}
|
||||
} catch (e) {
|
||||
// Surface the failure loudly — the rep needs to know why the
|
||||
// Quotation didn't land (commonly missing ERPNext Items like
|
||||
// TIER-G150, TV-MIX5, TEL-ILL). Service Contract creation below
|
||||
// still proceeds so the rep has *something* to retrieve.
|
||||
console.warn('[ProjectWizard] Order doc creation failed:', e.message)
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: 'Soumission non créée',
|
||||
caption: e.message?.slice(0, 180) || 'Erreur ERPNext',
|
||||
timeout: 8000,
|
||||
position: 'top',
|
||||
actions: [{ label: 'OK', color: 'white' }],
|
||||
})
|
||||
}
|
||||
|
||||
// Create Subscriptions for recurring items (unless deferred)
|
||||
|
|
@ -181,7 +218,7 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
job_type: step.job_type || 'Autre',
|
||||
source_issue: props.issue?.name || '',
|
||||
customer,
|
||||
service_location: props.issue?.service_location || '',
|
||||
service_location: serviceLocation,
|
||||
depends_on: dependsOn,
|
||||
parent_job: parentJob,
|
||||
step_order: i + 1,
|
||||
|
|
@ -197,9 +234,82 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
}
|
||||
}
|
||||
|
||||
// Generate acceptance link for quotations
|
||||
if (isQuotation && orderDocName) {
|
||||
// Create Service Contract for residential commitments with recurring items
|
||||
// The contract IS the shopping-cart recap: recurring items drive duration/monthly_rate,
|
||||
// one-time items with regular_price > rate become "benefits" (promotions étalées).
|
||||
let contractName = ''
|
||||
const wantsContract = isQuotation && recurringItems.length > 0
|
||||
&& recurringItems.some(i => (i.contract_months || 0) > 0)
|
||||
if (wantsContract) {
|
||||
try {
|
||||
const durationMonths = Math.max(...recurringItems.map(i => i.contract_months || 12))
|
||||
const monthlyRate = recurringItems.reduce((s, i) => s + (i.qty * i.rate), 0)
|
||||
const benefits = onetimeItems
|
||||
.filter(i => (i.regular_price || 0) > i.rate)
|
||||
.map(i => ({
|
||||
description: i.item_name,
|
||||
regular_price: i.regular_price,
|
||||
granted_price: i.rate,
|
||||
}))
|
||||
const contractType = acceptanceMethod.value === 'docuseal' ? 'Commercial' : 'Résidentiel'
|
||||
|
||||
const createRes = await fetch(`${HUB_URL}/contract/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customer,
|
||||
contract_type: contractType,
|
||||
duration_months: durationMonths,
|
||||
monthly_rate: monthlyRate,
|
||||
service_location: serviceLocation,
|
||||
quotation: orderDocName,
|
||||
start_date: today,
|
||||
benefits,
|
||||
}),
|
||||
})
|
||||
const createData = await createRes.json()
|
||||
if (createData.ok && createData.contract) {
|
||||
contractName = createData.contract.name
|
||||
publishedContractName.value = contractName
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ProjectWizard] Service Contract creation failed:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate acceptance link for quotations
|
||||
// When a Service Contract was created, prefer /contract/send (promotion-framed récap).
|
||||
// Otherwise fall back to the Quotation-centric /accept/generate flow.
|
||||
if (isQuotation && orderDocName && needsAcceptance) {
|
||||
try {
|
||||
if (contractName) {
|
||||
const sendRes = await fetch(`${HUB_URL}/contract/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: contractName,
|
||||
phone: clientPhone.value || '',
|
||||
email: clientEmail.value || '',
|
||||
use_docuseal: acceptanceMethod.value === 'docuseal',
|
||||
}),
|
||||
})
|
||||
const sendData = await sendRes.json()
|
||||
if (sendData.ok) {
|
||||
acceptanceLinkUrl.value = sendData.accept_link || sendData.sign_url || ''
|
||||
const viaParts = []
|
||||
if (sendData.method === 'sms') viaParts.push('SMS')
|
||||
if (sendData.method === 'docuseal') viaParts.push('DocuSeal')
|
||||
acceptanceSentVia.value = viaParts.length ? ` par ${viaParts.join(' et ')}` : ''
|
||||
acceptanceLinkSent.value = viaParts.length > 0
|
||||
Notify.create({
|
||||
type: 'info',
|
||||
message: sendData.method === 'docuseal'
|
||||
? 'Lien DocuSeal envoyé — contrat commercial'
|
||||
: `Récapitulatif envoyé au client${acceptanceSentVia.value}`,
|
||||
timeout: 6000,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const acceptRes = await fetch(`${HUB_URL}/accept/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -234,6 +344,7 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
timeout: 6000,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ProjectWizard] Acceptance link failed:', e.message)
|
||||
acceptanceLinkUrl.value = `${HUB_URL}/accept/doc-pdf/Quotation/${encodeURIComponent(orderDocName)}`
|
||||
|
|
@ -245,6 +356,7 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
if (createdJobs.length) parts.push(`${createdJobs.length} tâches créées`)
|
||||
if (needsAcceptance) parts.push('en attente d\'acceptation')
|
||||
if (orderDocName) parts.push(`${orderDocType} ${orderDocName}`)
|
||||
if (contractName) parts.push(`Contrat ${contractName}`)
|
||||
if (!needsAcceptance && recurringItems.length) parts.push(`${recurringItems.length} abonnement(s)`)
|
||||
|
||||
Notify.create({
|
||||
|
|
@ -257,7 +369,9 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
emit('created', job)
|
||||
}
|
||||
|
||||
// Show success screen
|
||||
// Show success screen. Prefer Quotation as the primary artifact;
|
||||
// fall back to Service Contract when Quotation failed but Contract
|
||||
// landed — the rep still needs a way to retrieve the sommaire.
|
||||
if (orderDocName) {
|
||||
publishedDocName.value = orderDocName
|
||||
publishedDocType.value = orderDocType
|
||||
|
|
@ -266,6 +380,12 @@ export function useWizardPublish ({ props, emit, state }) {
|
|||
publishedJobCount.value = createdJobs.length
|
||||
if (clientEmail.value) { sendTo.value = clientEmail.value; sendChannel.value = 'email' }
|
||||
else if (clientPhone.value) { sendTo.value = clientPhone.value; sendChannel.value = 'sms' }
|
||||
} else if (contractName) {
|
||||
publishedDocName.value = contractName
|
||||
publishedDocType.value = 'Service Contract'
|
||||
publishedDone.value = true
|
||||
pendingAcceptance.value = needsAcceptance
|
||||
publishedJobCount.value = createdJobs.length
|
||||
} else if (createdJobs.length) {
|
||||
publishedDocName.value = createdJobs[0]?.name || ''
|
||||
publishedDocType.value = 'Dispatch Job'
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const PROJECT_TEMPLATES = [
|
|||
category: 'Téléphonie',
|
||||
steps: [
|
||||
{
|
||||
merge_key: 'port_phone_request',
|
||||
subject: 'Importer le numéro de téléphone',
|
||||
job_type: 'Autre',
|
||||
priority: 'medium',
|
||||
|
|
@ -31,16 +32,18 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
subject: 'Installation fibre (pré-requis portage)',
|
||||
merge_key: 'fiber_install_visit',
|
||||
subject: 'Installation fibre chez le client',
|
||||
job_type: 'Installation',
|
||||
priority: 'high',
|
||||
duration_h: 2,
|
||||
duration_h: 3,
|
||||
assigned_group: 'Tech Targo',
|
||||
depends_on_step: 0,
|
||||
on_open_webhook: '',
|
||||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'port_phone_execute',
|
||||
subject: 'Portage du numéro vers Gigafibre',
|
||||
job_type: 'Autre',
|
||||
priority: 'medium',
|
||||
|
|
@ -51,6 +54,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'phone_service_test',
|
||||
subject: 'Validation et test du service téléphonique',
|
||||
job_type: 'Dépannage',
|
||||
priority: 'medium',
|
||||
|
|
@ -70,6 +74,7 @@ export const PROJECT_TEMPLATES = [
|
|||
category: 'Internet',
|
||||
steps: [
|
||||
{
|
||||
merge_key: 'fiber_pre_check',
|
||||
subject: 'Vérification pré-installation (éligibilité & OLT)',
|
||||
job_type: 'Autre',
|
||||
priority: 'medium',
|
||||
|
|
@ -80,6 +85,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'fiber_install_visit',
|
||||
subject: 'Installation fibre chez le client',
|
||||
job_type: 'Installation',
|
||||
priority: 'high',
|
||||
|
|
@ -90,6 +96,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'fiber_activation',
|
||||
subject: 'Activation du service & configuration ONT',
|
||||
job_type: 'Installation',
|
||||
priority: 'high',
|
||||
|
|
@ -100,6 +107,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'fiber_speed_test',
|
||||
subject: 'Test de débit & validation client',
|
||||
job_type: 'Dépannage',
|
||||
priority: 'medium',
|
||||
|
|
@ -119,6 +127,7 @@ export const PROJECT_TEMPLATES = [
|
|||
category: 'Déménagement',
|
||||
steps: [
|
||||
{
|
||||
merge_key: 'move_prep',
|
||||
subject: 'Préparation déménagement (vérifier éligibilité nouveau site)',
|
||||
job_type: 'Autre',
|
||||
priority: 'medium',
|
||||
|
|
@ -129,6 +138,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'move_removal',
|
||||
subject: 'Retrait équipement ancien site',
|
||||
job_type: 'Retrait',
|
||||
priority: 'medium',
|
||||
|
|
@ -139,6 +149,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'fiber_install_visit',
|
||||
subject: 'Installation au nouveau site',
|
||||
job_type: 'Installation',
|
||||
priority: 'high',
|
||||
|
|
@ -149,6 +160,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'move_transfer',
|
||||
subject: 'Transfert abonnement & mise à jour adresse',
|
||||
job_type: 'Autre',
|
||||
priority: 'medium',
|
||||
|
|
@ -168,6 +180,7 @@ export const PROJECT_TEMPLATES = [
|
|||
category: 'Support',
|
||||
steps: [
|
||||
{
|
||||
merge_key: 'repair_diag',
|
||||
subject: 'Diagnostic à distance',
|
||||
job_type: 'Dépannage',
|
||||
priority: 'high',
|
||||
|
|
@ -178,6 +191,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'repair_visit',
|
||||
subject: 'Intervention terrain',
|
||||
job_type: 'Réparation',
|
||||
priority: 'high',
|
||||
|
|
@ -188,6 +202,7 @@ export const PROJECT_TEMPLATES = [
|
|||
on_close_webhook: '',
|
||||
},
|
||||
{
|
||||
merge_key: 'repair_validate',
|
||||
subject: 'Validation & suivi client',
|
||||
job_type: 'Dépannage',
|
||||
priority: 'medium',
|
||||
|
|
|
|||
|
|
@ -85,6 +85,16 @@ export const quotationCols = [
|
|||
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
|
||||
]
|
||||
|
||||
export const serviceContractCols = [
|
||||
{ name: 'name', label: 'N°', field: 'name', align: 'left' },
|
||||
{ name: 'contract_type', label: 'Type', field: 'contract_type', align: 'left' },
|
||||
{ name: 'start_date', label: 'Début', field: 'start_date', align: 'left', sortable: true },
|
||||
{ name: 'duration_months', label: 'Durée', field: 'duration_months', align: 'right' },
|
||||
{ name: 'monthly_rate', label: 'Mensuel', field: 'monthly_rate', align: 'right' },
|
||||
{ name: 'total_benefit_value', label: 'Avantages', field: 'total_benefit_value', align: 'right' },
|
||||
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
|
||||
]
|
||||
|
||||
export const ticketCols = [
|
||||
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:20px;padding:0 2px' },
|
||||
{ name: 'legacy_id', label: '', field: 'legacy_ticket_id', align: 'right', style: 'width:48px;padding:0 4px' },
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const equipScanTypeMap = { ont: 'ONT', onu: 'ONT', router: 'Router', mode
|
|||
|
||||
export const phoneLabelMap = { cell_phone: 'Cell', tel_home: 'Maison', tel_office: 'Bureau' }
|
||||
|
||||
export const defaultSectionsOpen = { locations: true, tickets: true, invoices: false, payments: false, voip: false, paymentMethods: false, arrangements: false, quotations: false, notes: false }
|
||||
export const defaultSectionsOpen = { locations: true, tickets: true, invoices: false, payments: false, voip: false, paymentMethods: false, arrangements: false, quotations: false, serviceContracts: false, notes: false }
|
||||
|
||||
export const defaultNewEquip = () => ({
|
||||
equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif',
|
||||
|
|
|
|||
123
apps/ops/src/data/pricing-mock.js
Normal file
123
apps/ops/src/data/pricing-mock.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Address-based pricing — FRONTEND STUB.
|
||||
*
|
||||
* Designed to mirror the eventual hub contract so the UI can be built now
|
||||
* and the backend swapped in later without reshaping callers.
|
||||
*
|
||||
* Final shape (planned):
|
||||
* PricingArea (table): id, name, default_distance_included_m,
|
||||
* fee_per_extra_meter, currency, notes
|
||||
* Address (added fields): pricing_area_id (fk nullable),
|
||||
* distance_to_fiber_m,
|
||||
* qualification_status,
|
||||
* extras[] (memo surcharges)
|
||||
* AddressExtra (table): id, address_id, label, category, amount, note,
|
||||
* created_by, created_at
|
||||
*
|
||||
* For MVP we only *compute* distance-based fees; extras are stored verbatim
|
||||
* but not automated — they persist per address as memo surcharges that the
|
||||
* operator typed in during a previous survey or quote.
|
||||
*
|
||||
* To swap to real data, replace mockAddressPricing() with a hub fetch.
|
||||
*/
|
||||
|
||||
const DEFAULT_INCLUDED_M = 20
|
||||
const DEFAULT_RATE_PER_M = 5.00
|
||||
|
||||
export const PRICING_EXTRA_CATEGORIES = [
|
||||
{ value: 'distance', label: 'Distance', icon: 'straighten' },
|
||||
{ value: 'forest', label: 'Émondage / forêt', icon: 'forest' },
|
||||
{ value: 'civil', label: 'Génie civil', icon: 'engineering' },
|
||||
{ value: 'pole', label: 'Poteau / location', icon: 'flag' },
|
||||
{ value: 'mdu', label: 'Immeuble (MDU)', icon: 'apartment' },
|
||||
{ value: 'other', label: 'Autre', icon: 'build' },
|
||||
]
|
||||
|
||||
const MOCK_PRICING_AREAS = {
|
||||
'PA-URBAN': { id: 'PA-URBAN', name: 'Zone urbaine standard', default_distance_included_m: 20, fee_per_extra_meter: 5.00, currency: 'CAD' },
|
||||
'PA-RURAL': { id: 'PA-RURAL', name: 'Zone rurale', default_distance_included_m: 30, fee_per_extra_meter: 8.00, currency: 'CAD' },
|
||||
'PA-REMOTE': { id: 'PA-REMOTE', name: 'Zone éloignée', default_distance_included_m: 50, fee_per_extra_meter: 12.00, currency: 'CAD' },
|
||||
}
|
||||
|
||||
const MOCK_ADDRESSES = {
|
||||
'addr-standard': {
|
||||
address_id: 'addr-standard',
|
||||
label: 'Urbain standard (inclus, 0$ extras)',
|
||||
pricing_area: MOCK_PRICING_AREAS['PA-URBAN'],
|
||||
distance_to_fiber_m: 15,
|
||||
extras: [],
|
||||
qualification_status: 'auto_qualified',
|
||||
},
|
||||
'addr-urban-extra': {
|
||||
address_id: 'addr-urban-extra',
|
||||
label: 'Urbain avec extension 35m',
|
||||
pricing_area: MOCK_PRICING_AREAS['PA-URBAN'],
|
||||
distance_to_fiber_m: 55,
|
||||
extras: [],
|
||||
qualification_status: 'auto_qualified',
|
||||
},
|
||||
'addr-rural': {
|
||||
address_id: 'addr-rural',
|
||||
label: 'Rural avec émondage (survey)',
|
||||
pricing_area: MOCK_PRICING_AREAS['PA-RURAL'],
|
||||
distance_to_fiber_m: 85,
|
||||
extras: [
|
||||
{ id: 'ex_rural_forest', label: 'Émondage 3 arbres', category: 'forest', amount: 150, note: 'Accès chemin forestier' },
|
||||
],
|
||||
qualification_status: 'surveyed',
|
||||
},
|
||||
'addr-remote-complex': {
|
||||
address_id: 'addr-remote-complex',
|
||||
label: 'Éloigné, complexe (non qualifié)',
|
||||
pricing_area: MOCK_PRICING_AREAS['PA-REMOTE'],
|
||||
distance_to_fiber_m: 220,
|
||||
extras: [
|
||||
{ id: 'ex_rem_pole', label: 'Location poteau Hydro', category: 'pole', amount: 75, note: 'Bail annuel' },
|
||||
{ id: 'ex_rem_trench', label: 'Traversée de route', category: 'civil', amount: 450, note: 'Permis municipal requis' },
|
||||
],
|
||||
qualification_status: 'unqualified',
|
||||
},
|
||||
'addr-no-area': {
|
||||
address_id: 'addr-no-area',
|
||||
label: 'Sans zone tarifaire (fallback défaut)',
|
||||
pricing_area: null,
|
||||
distance_to_fiber_m: 40,
|
||||
extras: [],
|
||||
qualification_status: 'auto_qualified',
|
||||
},
|
||||
}
|
||||
|
||||
export const MOCK_ADDRESS_OPTIONS = Object.values(MOCK_ADDRESSES).map(a => ({
|
||||
value: a.address_id,
|
||||
label: a.label,
|
||||
}))
|
||||
|
||||
export function defaultPricingArea () {
|
||||
return {
|
||||
id: null,
|
||||
name: 'Tarif par défaut',
|
||||
default_distance_included_m: DEFAULT_INCLUDED_M,
|
||||
fee_per_extra_meter: DEFAULT_RATE_PER_M,
|
||||
currency: 'CAD',
|
||||
}
|
||||
}
|
||||
|
||||
// Stub — will be replaced with GET /api/pricing/address/:id
|
||||
// For unknown address ids (real Service Location names), we return a
|
||||
// sensible default profile so the wizard can open meaningfully. The
|
||||
// operator can still override via the test-scenario dropdown.
|
||||
export async function mockAddressPricing (addressId) {
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
const entry = MOCK_ADDRESSES[addressId]
|
||||
if (entry) {
|
||||
return { ...entry, extras: entry.extras.map(e => ({ ...e })) }
|
||||
}
|
||||
return {
|
||||
address_id: addressId,
|
||||
label: `Adresse ${addressId} (profil par défaut — stub)`,
|
||||
pricing_area: null,
|
||||
distance_to_fiber_m: 0,
|
||||
extras: [],
|
||||
qualification_status: 'auto_qualified',
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export const STEP_LABELS = ['Modèle', 'Étapes', 'Items / Devis', 'Publier']
|
||||
export const STEP_LABELS = ['Modèle', 'Étapes', 'Items / Devis', 'Sommaire', 'Publier']
|
||||
|
||||
export const JOB_TYPE_OPTIONS = [
|
||||
{ label: 'Installation', value: 'Installation' },
|
||||
|
|
@ -33,6 +33,295 @@ export const BILLING_INTERVAL_OPTIONS = [
|
|||
|
||||
export const CATALOG_CATEGORIES = ['Tous', 'Internet', 'Téléphonie', 'Bundle', 'Équipement', 'Frais']
|
||||
|
||||
// Internet hero tiers — single-select, swappable. The wizard shows the
|
||||
// currently-selected tier as the primary tile, with the rest revealed on
|
||||
// hover. Clicking another tier REPLACES the selection (doesn't stack).
|
||||
// Megafibre 80 is the anchor "à partir de 39.95$" promo (base 79.95 - RAB-LOYAUTE
|
||||
// -40). Higher tiers have no loyalty rebate — shown at base.
|
||||
// Combo rebates (RAB2X/RAB3X/RAB4X) are added manually from the catalog.
|
||||
export const INTERNET_HERO_TIERS = [
|
||||
{
|
||||
id: 'tier_m80',
|
||||
code: 'FTTH80I',
|
||||
label: 'Megafibre 80',
|
||||
speed: '80 / 80 Mbps',
|
||||
badge: 'Économique',
|
||||
price_effective: 39.95,
|
||||
price_base: 79.95,
|
||||
desc: 'Fibre symétrique · Rabais fidélité -40$/mois',
|
||||
icon: 'wifi',
|
||||
default: true,
|
||||
items: [
|
||||
{ item_code: 'FTTH80I', item_name: 'Internet Megafibre 80 Mbps', rate: 79.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
{ item_code: 'RAB-LOYAUTE', item_name: 'Rabais Fidélité', rate: -40.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24, is_rebate: true, applies_to_item: 'FTTH80I' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tier_m150',
|
||||
code: 'FTTH150I',
|
||||
label: 'Megafibre 150',
|
||||
speed: '150 / 150 Mbps',
|
||||
price_effective: 99.95,
|
||||
price_base: 99.95,
|
||||
desc: 'Fibre symétrique · streaming 4K, télétravail',
|
||||
icon: 'wifi',
|
||||
items: [
|
||||
{ item_code: 'FTTH150I', item_name: 'Internet Megafibre 150 Mbps', rate: 99.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tier_g1500',
|
||||
code: 'FTTH1500I',
|
||||
label: 'Gigafibre 1500',
|
||||
speed: '1500 / 940 Mbps',
|
||||
badge: 'Populaire',
|
||||
price_effective: 109.95,
|
||||
price_base: 109.95,
|
||||
desc: 'Ultra-rapide · foyers connectés, gaming, cloud',
|
||||
icon: 'speed',
|
||||
items: [
|
||||
{ item_code: 'FTTH1500I', item_name: 'Internet Gigafibre 1500 Mbps', rate: 109.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24, project_template_id: 'fiber_install' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Stable set of all hero SKUs — used by selectHeroTier() to clean previous
|
||||
// hero lines (including the -40 loyalty rebate) before applying a new tier.
|
||||
export const INTERNET_HERO_CODES = new Set(
|
||||
INTERNET_HERO_TIERS.flatMap(t => t.items.map(i => i.item_code))
|
||||
)
|
||||
|
||||
// TV hero tiers — mirrors INTERNET_HERO_TIERS pattern. Base (25$) adds the
|
||||
// TV-BASE item directly. Mix 5 (+10$) and Mix 10 (+15$) will open the channel
|
||||
// picker once the channel list is finalized. À la carte opens the unit-priced
|
||||
// channel picker (no base tier, no combo). For now the non-base tiers are
|
||||
// placeholder: they add their item but display "Configuration à venir" hint.
|
||||
export const TV_HERO_TIERS = [
|
||||
{
|
||||
id: 'tv_base',
|
||||
code: 'TV-BASE',
|
||||
label: 'Télé Base',
|
||||
desc: 'Environ 50 chaînes généralistes, infos, communauté',
|
||||
price_effective: 25.00,
|
||||
price_base: 25.00,
|
||||
icon: 'live_tv',
|
||||
default: true,
|
||||
ready: true,
|
||||
items: [
|
||||
{ item_code: 'TV-BASE', item_name: 'Forfait IPTV Base', rate: 25.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tv_mix5',
|
||||
code: 'TV-MIX5',
|
||||
label: 'Mix 5 chaînes',
|
||||
badge: 'Populaire',
|
||||
desc: 'Base + 5 chaînes thématiques au choix',
|
||||
price_effective: 35.00,
|
||||
price_base: 35.00,
|
||||
picks_allowed: 5,
|
||||
pick_unit_cost: 2.00,
|
||||
icon: 'playlist_add_check',
|
||||
ready: true,
|
||||
items: [
|
||||
{ item_code: 'TV-BASE', item_name: 'Forfait IPTV Base', rate: 25.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
{ item_code: 'TV-MIX5', item_name: 'Bonus Mix 5 chaînes', rate: 10.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tv_mix10',
|
||||
code: 'TV-MIX10',
|
||||
label: 'Mix 10 chaînes',
|
||||
desc: 'Base + 10 chaînes thématiques au choix',
|
||||
price_effective: 42.50,
|
||||
price_base: 42.50,
|
||||
picks_allowed: 10,
|
||||
pick_unit_cost: 1.75,
|
||||
icon: 'playlist_add_check',
|
||||
ready: true,
|
||||
items: [
|
||||
{ item_code: 'TV-BASE', item_name: 'Forfait IPTV Base', rate: 25.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
{ item_code: 'TV-MIX10', item_name: 'Bonus Mix 10 chaînes', rate: 17.50, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tv_a_la_carte',
|
||||
code: 'TELE_CARTE',
|
||||
label: 'À la carte',
|
||||
desc: 'Pas de base — prix unitaire par chaîne',
|
||||
price_effective: 0,
|
||||
price_base: 0,
|
||||
icon: 'tune',
|
||||
ready: false,
|
||||
items: [
|
||||
// Legacy reuse: gestionclient.product.sku = TELE_CARTE ("Télévision à la carte")
|
||||
{ item_code: 'TELE_CARTE', item_name: 'Télévision à la carte', rate: 0, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const TV_HERO_CODES = new Set(
|
||||
TV_HERO_TIERS.flatMap(t => t.items.map(i => i.item_code))
|
||||
)
|
||||
|
||||
// Upsell/addon presets — chip-style toggles that stack on top of the hero.
|
||||
// `tier` controls styling: upsell (+mo$ chip), addon. Rebates (is_rebate +
|
||||
// applies_to_item) are netted into the parent line on customer-facing
|
||||
// invoices but kept explicit in internal editing. Combo rebates are added
|
||||
// manually by the sales rep (see combo rebate note below).
|
||||
export const RESIDENTIAL_PRESETS = [
|
||||
{
|
||||
id: 'preset_phone',
|
||||
tier: 'upsell',
|
||||
label: 'Téléphonie',
|
||||
price_delta: 28.95,
|
||||
icon: 'phone',
|
||||
desc: '28.95$/mois · illimitée CA/US',
|
||||
service_type: 'phone',
|
||||
combo_eligible: true,
|
||||
items: [
|
||||
// Legacy reuse: gestionclient.product.sku = TELEPMENS
|
||||
// "Téléphonie IP, options toutes incluses, Canada et É-U illimité".
|
||||
// Bundle discount (to reach ~10$/mo) is applied manually by the sales
|
||||
// rep, not auto-generated. See docs/ERPNEXT_ITEM_DIFF_VS_LEGACY.md.
|
||||
{ item_code: 'TELEPMENS', item_name: 'Téléphonie IP illimitée CA/US', rate: 28.95, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'preset_wifi_boost',
|
||||
tier: 'addon',
|
||||
label: 'Booster WiFi',
|
||||
price_delta: 5.00,
|
||||
icon: 'router',
|
||||
desc: '+5$/mois · maillage mesh',
|
||||
service_type: 'wifi_boost',
|
||||
combo_eligible: false,
|
||||
items: [
|
||||
{ item_code: 'EQ-WIFI-BOOST', item_name: 'Booster WiFi (maillage)', rate: 5.00, billing: 'recurring', billing_interval: 'Month', contract_months: 24 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Premium sports channels — each carries a +surcharge and counts as pick_cost
|
||||
// picks inside a Mix selection. `group_channels` is a soft grouping (e.g. RDS
|
||||
// group includes RDS + RDS2) so picking the parent also brings the satellite
|
||||
// channels. Using the channel *name* as the identifier for now; if names
|
||||
// change we'll migrate to an internal id.
|
||||
// NOTE: surcharge applies once per parent pick regardless of group size; the
|
||||
// group_channels count is purely informational for the picker UI.
|
||||
export const PREMIUM_SPORTS_CHANNELS = [
|
||||
{ id: 'ch_rds', name: 'RDS', group_count: 2, surcharge: 3.00, pick_cost: 2 },
|
||||
{ id: 'ch_tsn', name: 'TSN', group_count: 5, surcharge: 3.00, pick_cost: 2 },
|
||||
{ id: 'ch_sportsnet', name: 'SPORTSNET', group_count: 2, surcharge: 3.00, pick_cost: 2 },
|
||||
{ id: 'ch_sportsnet_one', name: 'SPORTSNET ONE', group_count: 1, surcharge: 3.00, pick_cost: 2 },
|
||||
{ id: 'ch_tva_sports', name: 'TVA SPORTS', group_count: 2, surcharge: 3.00, pick_cost: 2 },
|
||||
]
|
||||
|
||||
// All selectable Mix 5 / Mix 10 channels. Premium parents are flagged via
|
||||
// `premium_group` — they consume `pick_cost` picks (2) AND add a $3/mo
|
||||
// surcharge. Aliases in `sub` are shown in the tooltip ("RDS includes RDS INFO").
|
||||
// Regular channels count for 1 pick and no surcharge.
|
||||
// Picker UX: fuzzy match against name + sub[]. Selecting a premium parent
|
||||
// automatically locks its sub-channels (they ride together).
|
||||
export const TV_CHANNELS = [
|
||||
{ name: 'A&E' },
|
||||
{ name: 'ADDIK' },
|
||||
{ name: 'ADULT SWIM' },
|
||||
{ name: 'AMC' },
|
||||
{ name: 'BBC NEWS' },
|
||||
{ name: 'BNN' },
|
||||
{ name: 'C&I' },
|
||||
{ name: 'CARTOON NETWORK', sub: ['Boomerang'] },
|
||||
{ name: 'CANAL D' },
|
||||
{ name: 'CANAL VIE' },
|
||||
{ name: 'CASA' },
|
||||
{ name: 'CINEPOP' },
|
||||
{ name: 'CNN' },
|
||||
{ name: 'CTV COMEDY' },
|
||||
{ name: 'CTV DRAMA' },
|
||||
{ name: 'CTV NATURE', sub: ['formerly Disc Science'] },
|
||||
{ name: 'CTV SCI-FI' },
|
||||
{ name: 'DEJA VIEW' },
|
||||
{ name: 'DTOUR' },
|
||||
{ name: 'E!' },
|
||||
{ name: 'ELLE FICTION' },
|
||||
{ name: 'EVASION' },
|
||||
{ name: 'FLAVOUR NETWORK', sub: ['formerly Food Network'] },
|
||||
{ name: 'FOX NEWS' },
|
||||
{ name: 'FX' },
|
||||
{ name: 'FXX' },
|
||||
{ name: 'GOLF CHANNEL' },
|
||||
{ name: 'H2' },
|
||||
{ name: 'HISTORIA' },
|
||||
{ name: 'HLN' },
|
||||
{ name: 'HOME NETWORK', sub: ['formerly HGTV'] },
|
||||
{ name: 'ICI ARTV' },
|
||||
{ name: 'ICI EXPLORA' },
|
||||
{ name: 'INVESTIGATION', sub: ['Bell Media'] },
|
||||
{ name: 'LCN' },
|
||||
{ name: 'LIFETIME' },
|
||||
{ name: 'MOVIETIME' },
|
||||
{ name: 'MUCH' },
|
||||
{ name: 'MAX' },
|
||||
{ name: 'NAT GEO WILD' },
|
||||
{ name: 'NATIONAL GEOGRAPHIC' },
|
||||
{ name: 'OXYGEN TRUE CRIME', sub: ['formerly Investigation Disc'] },
|
||||
{ name: 'PRISE 2' },
|
||||
{ name: 'QUB', sub: ['YOOPA'] },
|
||||
{ name: 'RDS', premium_group: 'ch_rds', sub: ['RDS INFO'] },
|
||||
{ name: 'SHOWCASE' },
|
||||
{ name: 'SLICE' },
|
||||
{ name: 'SERIES+' },
|
||||
{ name: 'SPORTSNET 360' },
|
||||
{ name: 'SPORTSNET', premium_group: 'ch_sportsnet', sub: ['SPORTSNET 2'] },
|
||||
{ name: 'SPORTSNET ONE', premium_group: 'ch_sportsnet_one' },
|
||||
{ name: 'TELETOON', sub: ['Cartoon Network'] },
|
||||
{ name: 'TELETOON FR' },
|
||||
{ name: 'TEMOIN' },
|
||||
{ name: 'TLC' },
|
||||
{ name: 'TREEHOUSE' },
|
||||
{ name: 'TSN', premium_group: 'ch_tsn', sub: ['TSN2', 'TSN3', 'TSN4', 'TSN5'] },
|
||||
{ name: 'TVA SPORTS', premium_group: 'ch_tva_sports', sub: ['TVA SPORTS 2'] },
|
||||
{ name: 'USA NETWORK', sub: ['formerly Disc Channel'] },
|
||||
{ name: 'W NETWORK' },
|
||||
{ name: 'YTV' },
|
||||
{ name: 'Z TELE' },
|
||||
{ name: 'ZESTE' },
|
||||
]
|
||||
|
||||
// Cart item codes for picker output.
|
||||
export const TV_PREMIUM_SURCHARGE_CODE = 'TV-PREMIUM-SUR'
|
||||
export const TV_ALC_OVERAGE_CODE = 'TV-ALC-OVER'
|
||||
|
||||
// Autocomplete presets for the per-step "extra fee" field. When a preset is
|
||||
// picked, both label (step.extra_label) and amount (step.extra_fee) auto-fill.
|
||||
// User can still type a free-form label or enter a custom amount; presets are
|
||||
// just a fast path for recurring situational fees on field-tech visits.
|
||||
export const EXTRA_FEE_PRESETS = [
|
||||
{ id: 'fee_emondage', label: 'Émondage', amount: 75.00 },
|
||||
{ id: 'fee_conduit', label: 'Fourreau souterrain', amount: 125.00 },
|
||||
{ id: 'fee_dig', label: 'Creusage supplémentaire', amount: 100.00 },
|
||||
{ id: 'fee_travel', label: 'Déplacement longue distance', amount: 50.00 },
|
||||
{ id: 'fee_height', label: 'Installation en hauteur', amount: 40.00 },
|
||||
{ id: 'fee_drill', label: 'Perçage mur extérieur', amount: 35.00 },
|
||||
{ id: 'fee_wiring', label: 'Passage de fil additionnel', amount: 30.00 },
|
||||
{ id: 'fee_wifi_config', label: 'Configuration WiFi avancée', amount: 40.00 },
|
||||
{ id: 'fee_rush', label: 'Dépannage urgent', amount: 50.00 },
|
||||
{ id: 'fee_custom_eq', label: 'Équipement spécifique', amount: 25.00 },
|
||||
]
|
||||
|
||||
// Referral credit — one-time line appended when a valid referral code is
|
||||
// entered. 50$ credit to the new subscriber; once the service is delivered a
|
||||
// matching credit applies on the next invoice of the referrer (handled in the
|
||||
// Referral Credit doctype on the backend).
|
||||
export const REFERRAL_ITEM_CODE = 'REF-CREDIT-50'
|
||||
export const REFERRAL_CREDIT_AMOUNT = 50.00
|
||||
|
||||
// Combo rebates are applied MANUALLY by the sales rep from the catalog
|
||||
// (legacy RAB2X / RAB3X / RAB4X SKUs, -5 / -10 / -15 $). We intentionally
|
||||
// do NOT auto-insert a combo rebate line — the rep picks the right SKU
|
||||
// based on the actual bundle. See docs/ERPNEXT_ITEM_DIFF_VS_LEGACY.md.
|
||||
|
||||
const CAT_ICON_MAP = { Internet: 'wifi', Téléphonie: 'phone', Bundle: 'inventory_2', Équipement: 'router', Frais: 'receipt', Autre: 'category' }
|
||||
export function catIcon (cat) {
|
||||
return CAT_ICON_MAP[cat] || 'category'
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
</q-btn>
|
||||
</q-page-sticky>
|
||||
|
||||
<!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
|
||||
<FlowEditorDialog v-if="can('manage_settings')" />
|
||||
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
|
|
@ -124,6 +127,7 @@ import {
|
|||
} from 'lucide-vue-next'
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,16 @@
|
|||
<q-input v-model="extraMessage" type="textarea" dense outlined dark
|
||||
placeholder="Message optionnel..." :input-style="{ minHeight: '48px' }" />
|
||||
</div>
|
||||
|
||||
<!-- What the tech will receive -->
|
||||
<div class="sms-preview-hint">
|
||||
<q-icon name="info" size="xs" color="indigo-3" class="q-mr-xs" />
|
||||
<span>Chaque SMS contient l'horaire + deux liens :
|
||||
<b>📱 Mes tâches</b> (application mobile) et
|
||||
<b>📅 Ajouter à mon calendrier</b>
|
||||
(ajout automatique dans Google Agenda ou Apple Calendar — se met à jour tout seul).
|
||||
</span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions class="q-px-md q-pb-md">
|
||||
|
|
@ -85,6 +95,18 @@ async function getMagicLink (techId) {
|
|||
} catch { return null }
|
||||
}
|
||||
|
||||
// Calendar subscription URL. webcal:// triggers native Calendar.app / Google
|
||||
// Calendar "add subscription" flow on tap, so the tech's agenda auto-refreshes
|
||||
// whenever dispatch updates jobs — no re-send required.
|
||||
async function getIcalUrl (techId) {
|
||||
try {
|
||||
const res = await fetch(`${HUB_URL}/dispatch/ical-token/${encodeURIComponent(techId)}`)
|
||||
const data = await res.json()
|
||||
if (!data?.url) return null
|
||||
return HUB_URL.replace(/^https?:/, 'webcal:') + data.url
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
jobs: { type: Array, default: () => [] },
|
||||
|
|
@ -139,7 +161,7 @@ watch(() => props.modelValue, (open) => {
|
|||
})
|
||||
|
||||
// ── Build SMS message per tech ──
|
||||
function buildSmsForTech (tech, techJobs, magicLink = null) {
|
||||
function buildSmsForTech (tech, techJobs, magicLink = null, icalUrl = null) {
|
||||
const byDate = {}
|
||||
for (const j of techJobs) {
|
||||
const d = j.scheduledDate || 'Non planifié'
|
||||
|
|
@ -161,7 +183,9 @@ function buildSmsForTech (tech, techJobs, magicLink = null) {
|
|||
lines.push(`${dayLabel}: ${jobDescs.join('; ')}`)
|
||||
}
|
||||
if (extraMessage.value.trim()) lines.push(extraMessage.value.trim())
|
||||
if (magicLink) lines.push(`\nVos tâches: ${magicLink}`)
|
||||
if (magicLink || icalUrl) lines.push('')
|
||||
if (magicLink) lines.push(`📱 Mes tâches: ${magicLink}`)
|
||||
if (icalUrl) lines.push(`📅 Ajouter à mon calendrier: ${icalUrl}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
|
@ -199,8 +223,11 @@ async function onPublish () {
|
|||
if (!tech.phone) continue
|
||||
const techJobs = draftJobs.value.filter(j => j.assignedTech === tech.id)
|
||||
if (!techJobs.length) continue
|
||||
const magicLink = await getMagicLink(tech.id)
|
||||
const msg = buildSmsForTech(tech, techJobs, magicLink)
|
||||
const [magicLink, icalUrl] = await Promise.all([
|
||||
getMagicLink(tech.id),
|
||||
getIcalUrl(tech.id),
|
||||
])
|
||||
const msg = buildSmsForTech(tech, techJobs, magicLink, icalUrl)
|
||||
try {
|
||||
await sendTestSms(tech.phone, msg, '', {
|
||||
reference_doctype: 'Dispatch Technician',
|
||||
|
|
@ -234,4 +261,19 @@ async function onPublish () {
|
|||
background: #1a1e2e;
|
||||
color: #e2e4ef;
|
||||
}
|
||||
.sms-preview-hint {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: #c7d2fe;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sms-preview-hint b {
|
||||
color: #e0e7ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
303
apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue
Normal file
303
apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<script setup>
|
||||
/**
|
||||
* Suggest Slots dialog — "find me a time window".
|
||||
*
|
||||
* Opens for an unscheduled or to-reschedule Dispatch Job. Calls the hub
|
||||
* /dispatch/suggest-slots endpoint to get 5 best windows across techs,
|
||||
* renders them as tappable cards, and emits `apply` with the picked slot
|
||||
* so the parent can write it back to the job.
|
||||
*/
|
||||
import { ref, computed, inject, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
|
||||
const HUB_URL = window.location.hostname === 'localhost'
|
||||
? 'http://localhost:3300'
|
||||
: 'https://msg.gigafibre.ca'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
// Minimal job shape — only what we need to query for slots.
|
||||
job: { type: Object, default: () => null },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'apply'])
|
||||
|
||||
const TECH_COLORS = inject('TECH_COLORS', {})
|
||||
|
||||
const loading = ref(false)
|
||||
const slots = ref([])
|
||||
const errorMsg = ref('')
|
||||
|
||||
async function fetchSlots () {
|
||||
if (!props.job) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
slots.value = []
|
||||
try {
|
||||
const res = await fetch(`${HUB_URL}/dispatch/suggest-slots`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
duration_h: props.job.duration || 1,
|
||||
latitude: props.job.latitude || null,
|
||||
longitude: props.job.longitude || null,
|
||||
limit: 5,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data?.error || 'Erreur serveur')
|
||||
slots.value = data.slots || []
|
||||
if (!slots.value.length) {
|
||||
errorMsg.value = 'Aucune plage trouvée dans les 7 prochains jours. Essayer une durée plus courte ou ajouter de la disponibilité.'
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message || 'Erreur de connexion'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) fetchSlots()
|
||||
})
|
||||
|
||||
// ── Date helpers ────────────────────────────────────────────────────────────
|
||||
function relativeDate (dateStr) {
|
||||
const d = new Date(dateStr + 'T12:00:00')
|
||||
const today = new Date(); today.setHours(12, 0, 0, 0)
|
||||
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const diffDays = Math.round((d - today) / 86400000)
|
||||
if (diffDays === 0) return "Aujourd'hui"
|
||||
if (diffDays === 1) return 'Demain'
|
||||
const dayNames = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
||||
const monthNames = ['janv', 'févr', 'mars', 'avr', 'mai', 'juin', 'juil', 'août', 'sept', 'oct', 'nov', 'déc']
|
||||
return `${dayNames[d.getDay()]} ${d.getDate()} ${monthNames[d.getMonth()]}`
|
||||
}
|
||||
|
||||
function positionIcon (position) {
|
||||
return { free_day: '🌤', first: '🌅', last: '🌆', between: '↔' }[position] || '🕐'
|
||||
}
|
||||
|
||||
function techColor (techId) {
|
||||
return TECH_COLORS[techId] || '#6366f1'
|
||||
}
|
||||
|
||||
// Group slots by date for a cleaner visual flow.
|
||||
const slotsByDate = computed(() => {
|
||||
const groups = []
|
||||
for (const s of slots.value) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.date === s.date) last.items.push(s)
|
||||
else groups.push({ date: s.date, items: [s] })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function apply (slot) {
|
||||
emit('apply', {
|
||||
tech_id: slot.tech_id,
|
||||
tech_name: slot.tech_name,
|
||||
date: slot.date,
|
||||
start_time: slot.start_time,
|
||||
end_time: slot.end_time,
|
||||
})
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: `Assigné à ${slot.tech_name} — ${relativeDate(slot.date)} ${slot.start_time}`,
|
||||
timeout: 3000,
|
||||
})
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function close () {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="close">
|
||||
<q-card class="suggest-modal">
|
||||
<!-- Header -->
|
||||
<q-card-section class="suggest-hdr">
|
||||
<div class="row items-center">
|
||||
<div class="col">
|
||||
<div class="text-subtitle1 text-weight-bold">
|
||||
<q-icon name="bolt" color="yellow-6" class="q-mr-xs" />
|
||||
Trouver une plage horaire
|
||||
</div>
|
||||
<div v-if="job" class="suggest-job-summary">
|
||||
{{ job.subject || 'Travail' }}
|
||||
<span class="sep">•</span>
|
||||
{{ job.duration || 1 }}h
|
||||
<span v-if="job.address" class="sep">•</span>
|
||||
<span v-if="job.address" class="job-addr">{{ job.address }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" color="grey-5" @click="close" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Loading state -->
|
||||
<q-card-section v-if="loading" class="suggest-state">
|
||||
<q-spinner size="32px" color="indigo-4" />
|
||||
<div class="q-mt-sm text-grey-5">Recherche des meilleures plages…</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Error / empty state -->
|
||||
<q-card-section v-else-if="errorMsg" class="suggest-state">
|
||||
<q-icon name="sentiment_dissatisfied" size="36px" color="grey-6" />
|
||||
<div class="q-mt-sm text-grey-4">{{ errorMsg }}</div>
|
||||
<q-btn flat dense color="indigo-4" icon="refresh" label="Réessayer" class="q-mt-sm"
|
||||
@click="fetchSlots" />
|
||||
</q-card-section>
|
||||
|
||||
<!-- Slot list -->
|
||||
<q-card-section v-else class="suggest-list">
|
||||
<div v-for="grp in slotsByDate" :key="grp.date" class="slot-group">
|
||||
<div class="slot-group-date">{{ relativeDate(grp.date) }}</div>
|
||||
<button v-for="s in grp.items" :key="`${s.tech_id}_${s.date}_${s.start_time}`"
|
||||
class="slot-card" @click="apply(s)">
|
||||
<span class="tech-dot" :style="{ background: techColor(s.tech_id) }"></span>
|
||||
<div class="slot-body">
|
||||
<div class="slot-line1">
|
||||
<span class="tech-name">{{ s.tech_name }}</span>
|
||||
<span class="slot-time">{{ s.start_time }} → {{ s.end_time }}</span>
|
||||
</div>
|
||||
<div class="slot-line2">
|
||||
<span class="slot-reason">
|
||||
{{ positionIcon(s.position) }} {{ s.reasons[0] }}
|
||||
</span>
|
||||
<span v-if="s.distance_km != null" class="slot-travel">
|
||||
🚗 {{ s.travel_min }} min ({{ s.distance_km }} km)
|
||||
</span>
|
||||
<span v-else class="slot-travel">🚗 {{ s.travel_min }} min trajet</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-icon name="chevron_right" size="18px" color="indigo-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="suggest-tip">
|
||||
<q-icon name="info" size="xs" class="q-mr-xs" />
|
||||
Cliquer une plage pour assigner immédiatement. Le travail peut ensuite être ajusté par glisser-déposer sur le timeline.
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suggest-modal {
|
||||
width: 520px;
|
||||
max-width: 95vw;
|
||||
background: #1a1e2e;
|
||||
color: #e2e4ef;
|
||||
}
|
||||
.suggest-hdr {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.suggest-job-summary {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.suggest-job-summary .sep { margin: 0 6px; color: #4b5563; }
|
||||
.suggest-job-summary .job-addr {
|
||||
color: #c7d2fe;
|
||||
max-width: 300px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.suggest-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.suggest-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.slot-group { margin-bottom: 14px; }
|
||||
.slot-group-date {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
.slot-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.slot-card:hover {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.tech-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.slot-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-line1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.tech-name {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
color: #e0e7ff;
|
||||
}
|
||||
.slot-time {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
color: #fcd34d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.slot-line2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.slot-reason { color: #c7d2fe; }
|
||||
.slot-travel { color: #9ca3af; }
|
||||
.suggest-tip {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -132,10 +132,18 @@
|
|||
<q-icon name="add" size="20px" />
|
||||
<q-tooltip>Ajouter</q-tooltip>
|
||||
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
|
||||
<q-list dense style="min-width:220px">
|
||||
<q-list dense style="min-width:240px">
|
||||
<q-item clickable v-close-popup @click="openWizardForAddress(loc)">
|
||||
<q-item-section avatar><q-icon name="description" size="18px" color="indigo-6" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Nouvelle soumission</q-item-label>
|
||||
<q-item-label caption>Wizard avec étapes dispatchables</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="openAddService(loc)">
|
||||
<q-item-section avatar><q-icon name="wifi" size="18px" color="blue-6" /></q-item-section>
|
||||
<q-item-section>Forfait / Service</q-item-section>
|
||||
<q-item-section>Forfait / Service (direct)</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
|
||||
<q-item-section avatar><q-icon name="sell" size="18px" color="red-5" /></q-item-section>
|
||||
|
|
@ -159,10 +167,18 @@
|
|||
style="margin-top:-2px">
|
||||
<q-tooltip>Ajouter un service</q-tooltip>
|
||||
<q-menu anchor="bottom left" self="top left" :offset="[0, 4]">
|
||||
<q-list dense style="min-width:200px">
|
||||
<q-list dense style="min-width:220px">
|
||||
<q-item clickable v-close-popup @click="openWizardForAddress(loc)">
|
||||
<q-item-section avatar><q-icon name="description" size="18px" color="indigo-6" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Nouvelle soumission</q-item-label>
|
||||
<q-item-label caption>Wizard avec étapes</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="openAddService(loc)">
|
||||
<q-item-section avatar><q-icon name="wifi" size="18px" color="blue-6" /></q-item-section>
|
||||
<q-item-section>Forfait / Service</q-item-section>
|
||||
<q-item-section>Forfait / Service (direct)</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
|
||||
<q-item-section avatar><q-icon name="sell" size="18px" color="red-5" /></q-item-section>
|
||||
|
|
@ -608,12 +624,66 @@
|
|||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Contrats de service (offres de service) — wizard-created artifacts
|
||||
that summarize récurrent + durée + benefits. This is the primary
|
||||
"sommaire" view after a projet est soumis. -->
|
||||
<q-expansion-item v-model="sectionsOpen.serviceContracts" header-class="section-header" class="q-mb-sm">
|
||||
<template #header>
|
||||
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
||||
<q-icon name="handshake" size="20px" color="orange-7" class="q-mr-xs" />
|
||||
Contrats de service ({{ serviceContracts.length }})
|
||||
<q-badge v-if="serviceContracts.some(c => c.status === 'Brouillon' || c.status === 'Envoyé')"
|
||||
color="orange-6" text-color="white" class="q-ml-sm">
|
||||
{{ serviceContracts.filter(c => c.status === 'Brouillon' || c.status === 'Envoyé').length }} en attente
|
||||
</q-badge>
|
||||
<q-space />
|
||||
<FlowQuickButton flat dense size="sm" icon="account_tree" label="Flows contrats"
|
||||
tooltip="Éditer les automatisations après signature"
|
||||
category="residential" applies-to="Service Contract"
|
||||
trigger-event="on_contract_signed" @click.stop />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!serviceContracts.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucun contrat de service</div>
|
||||
<div v-else class="ops-card q-mb-md">
|
||||
<q-table :rows="serviceContracts" :columns="serviceContractCols" row-key="name"
|
||||
flat dense class="ops-table clickable-table"
|
||||
:pagination="{ rowsPerPage: 10, sortBy: 'start_date', descending: true }"
|
||||
@row-click="(_, row) => openModal('Service Contract', row.name, 'Contrat ' + row.name)">
|
||||
<template #body-cell-duration_months="props">
|
||||
<q-td :props="props" class="text-right">{{ props.row.duration_months }} mois</q-td>
|
||||
</template>
|
||||
<template #body-cell-monthly_rate="props">
|
||||
<q-td :props="props" class="text-right">{{ formatMoney(props.row.monthly_rate) }}/m</q-td>
|
||||
</template>
|
||||
<template #body-cell-total_benefit_value="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<span v-if="props.row.total_benefit_value" class="text-green-7">
|
||||
{{ formatMoney(props.row.total_benefit_value) }}
|
||||
</span>
|
||||
<span v-else class="text-grey-5">—</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-status="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<q-chip dense :color="contractStatusColor(props.row.status)" text-color="white"
|
||||
size="sm" class="q-px-sm">
|
||||
{{ props.row.status }}
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Soumissions / Quotations -->
|
||||
<q-expansion-item v-model="sectionsOpen.quotations" header-class="section-header" class="q-mb-sm">
|
||||
<template #header>
|
||||
<div class="section-title" style="font-size:1rem;width:100%">
|
||||
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
||||
<q-icon name="request_quote" size="20px" class="q-mr-xs" />
|
||||
Soumissions ({{ quotations.length }})
|
||||
<q-space />
|
||||
<q-btn flat dense size="sm" icon="add" label="Nouvelle soumission" color="indigo-6" no-caps
|
||||
@click.stop="openNewQuotation()" class="q-mr-sm" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!quotations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune soumission</div>
|
||||
|
|
@ -836,6 +906,9 @@
|
|||
|
||||
<CreateInvoiceModal v-model="newInvoiceOpen" :customer="customer" @created="onInvoiceCreated" />
|
||||
|
||||
<ProjectWizard v-model="newQuotationOpen" :customer="quotationCustomerContext"
|
||||
:delivery-address-id="wizardDeliveryAddressId" @created="onQuotationPublished" />
|
||||
|
||||
<DetailModal
|
||||
v-model:open="modalOpen" :loading="modalLoading" :doctype="modalDoctype"
|
||||
:doc-name="modalDocName" :title="modalTitle" :doc="modalDoc"
|
||||
|
|
@ -861,7 +934,7 @@ import { useDetailModal } from 'src/composables/useDetailModal'
|
|||
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
|
||||
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
|
||||
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
|
||||
import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols } from 'src/config/table-columns'
|
||||
import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols, serviceContractCols } from 'src/config/table-columns'
|
||||
import { deviceLucideIcon } from 'src/config/device-icons'
|
||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||
import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
|
||||
|
|
@ -870,7 +943,9 @@ import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
|
|||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import ChatterPanel from 'src/components/customer/ChatterPanel.vue'
|
||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
|
||||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
||||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
import { usePaymentActions } from 'src/composables/usePaymentActions'
|
||||
|
|
@ -899,7 +974,7 @@ const {
|
|||
const {
|
||||
loading, customer, contact, locations, subscriptions, tickets,
|
||||
invoices, payments, voipLines, paymentMethods, arrangements, quotations,
|
||||
comments, accountBalance,
|
||||
serviceContracts, comments, accountBalance,
|
||||
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
|
||||
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
|
||||
} = useClientData({ equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll: () => invalidateAll(), fetchStatus, fetchOltStatus })
|
||||
|
|
@ -1206,6 +1281,36 @@ async function openPdf (name) {
|
|||
const newTicketOpen = ref(false)
|
||||
const ticketContext = ref({})
|
||||
|
||||
const newQuotationOpen = ref(false)
|
||||
const wizardDeliveryAddressId = ref('')
|
||||
const quotationCustomerContext = computed(() => {
|
||||
if (!customer.value) return null
|
||||
const primaryLoc = sortedLocations.value?.find(l => locHasSubs(l.name)) || sortedLocations.value?.[0]
|
||||
return {
|
||||
name: customer.value.name,
|
||||
customer_name: customer.value.customer_name,
|
||||
cell_phone: customer.value.cell_phone,
|
||||
tel_home: customer.value.tel_home,
|
||||
email_billing: customer.value.email_billing,
|
||||
email_id: customer.value.email_id,
|
||||
service_location: wizardDeliveryAddressId.value || primaryLoc?.name || '',
|
||||
}
|
||||
})
|
||||
|
||||
function openNewQuotation () {
|
||||
wizardDeliveryAddressId.value = ''
|
||||
newQuotationOpen.value = true
|
||||
}
|
||||
|
||||
function openWizardForAddress (loc) {
|
||||
wizardDeliveryAddressId.value = loc?.name || ''
|
||||
newQuotationOpen.value = true
|
||||
}
|
||||
|
||||
function onQuotationPublished () {
|
||||
loadCustomer?.()
|
||||
}
|
||||
|
||||
function openNewTicket (loc = null, sub = null) {
|
||||
const subLabel = sub ? (sub.custom_description || sub.item_name || sub.name) : ''
|
||||
const locLabel = loc ? (loc.address_line || loc.location_name || '') : ''
|
||||
|
|
@ -1228,6 +1333,19 @@ function onTicketCreated (doc) {
|
|||
openModal('Issue', doc.name, doc.subject)
|
||||
}
|
||||
|
||||
// Service Contract status → chip color. Matches ERPNext status values.
|
||||
function contractStatusColor (status) {
|
||||
switch (status) {
|
||||
case 'Actif': return 'green-6'
|
||||
case 'Envoyé': return 'orange-6'
|
||||
case 'Brouillon': return 'blue-grey-5'
|
||||
case 'Résilié': return 'red-6'
|
||||
case 'Complété': return 'indigo-6'
|
||||
case 'Expiré': return 'grey-5'
|
||||
default: return 'grey-5'
|
||||
}
|
||||
}
|
||||
|
||||
const newInvoiceOpen = ref(false)
|
||||
|
||||
function onInvoiceCreated (doc) {
|
||||
|
|
|
|||
|
|
@ -592,6 +592,20 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 7b: Flow Templates (onboarding / service orchestration) -->
|
||||
<div v-if="can('manage_settings')" class="ops-card q-mb-md">
|
||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
||||
@before-show="lazyFlags.flows = true">
|
||||
<template #header>
|
||||
<SectionHeader icon="account_tree" label="Flows — Orchestration projets" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
<FlowTemplatesSection v-if="lazyFlags.flows" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 8: Liens rapides -->
|
||||
<div class="ops-card">
|
||||
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
|
||||
|
|
@ -629,6 +643,7 @@ import { useLegacySync } from 'src/composables/useLegacySync'
|
|||
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue'))
|
||||
const TelephonyPage = defineAsyncComponent(() => import('src/pages/TelephonyPage.vue'))
|
||||
const AgentFlowsPage = defineAsyncComponent(() => import('src/pages/AgentFlowsPage.vue'))
|
||||
const FlowTemplatesSection = defineAsyncComponent(() => import('src/components/flow-editor/FlowTemplatesSection.vue'))
|
||||
|
||||
// Inline functional component for section headers
|
||||
const QIcon = resolveComponent('QIcon')
|
||||
|
|
@ -677,7 +692,7 @@ const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync(
|
|||
})
|
||||
|
||||
// Lazy-load flags for embedded pages
|
||||
const lazyFlags = reactive({ network: false, telephony: false, agent: false })
|
||||
const lazyFlags = reactive({ network: false, telephony: false, agent: false, flows: false })
|
||||
|
||||
// Page state
|
||||
const loading = ref(true)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@
|
|||
<div class="col-auto">
|
||||
<div class="text-caption text-grey-6 q-mt-sm">{{ total.toLocaleString() }} tickets</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<FlowQuickButton flat dense icon="account_tree" label="Flows tickets"
|
||||
tooltip="Configurer les automatisations pour les tickets"
|
||||
category="incident" applies-to="Issue" trigger-event="on_issue_opened" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
|
|
@ -153,6 +158,7 @@ import { useDetailModal } from 'src/composables/useDetailModal'
|
|||
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config'
|
||||
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'
|
||||
|
||||
const search = ref('')
|
||||
const statusFilter = ref('all')
|
||||
|
|
|
|||
83
docs/APP_DESIGN_GUIDELINES.md
Normal file
83
docs/APP_DESIGN_GUIDELINES.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Gigafibre FSM — App Design & UX Guidelines
|
||||
|
||||
> This document defines the engineering patterns and UI/UX standards for developing frontend applications within the Gigafibre ecosystem (Ops App, Mobile Tech App, Client Portal).
|
||||
|
||||
Adhering to these guidelines ensures scalability, maintainability, and highly efficient AI-assisted development by keeping architectural context constrained.
|
||||
|
||||
---
|
||||
|
||||
## 1. Modular Architecture (Feature-Sliced Design)
|
||||
|
||||
To avoid a monolithic `src/` folder that overwhelms developers and LLMs, organize code by **feature** rather than strictly by technical type.
|
||||
|
||||
**Do Not Do This (Technical Grouping):**
|
||||
```text
|
||||
src/
|
||||
components/ (contains dispatch, inventory, and customer mixed together)
|
||||
store/ (one massive pinia store for everything)
|
||||
api/ (one 2000-line api.js file)
|
||||
```
|
||||
|
||||
**Do This (Feature Grouping):**
|
||||
```text
|
||||
src/
|
||||
features/
|
||||
dispatch/
|
||||
components/
|
||||
store.ts
|
||||
api.ts
|
||||
types.ts
|
||||
equipment/
|
||||
components/
|
||||
```
|
||||
*Why?* When executing a task inside a specific domain, only the pertinent `features/{feature}/` directory needs to be referenced, drastically reducing cross-pollution and token usage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component & API Standardization (Vue / Quasar)
|
||||
|
||||
### Architecture
|
||||
* **Composition API:** Use Vue 3 `<script setup>` syntax universally. Avoid Options API.
|
||||
* **Component Size Limit:** Keep `.vue` files small. If a component exceeds 250 lines, it must be split down.
|
||||
* **Smart vs Dumb:**
|
||||
* *Smart Components (Pages):* Connect to Pinia, fetch data from API, hold domain state.
|
||||
* *Dumb Components (UI):* Accept `props`, emit `events`. Do not independently fetch data.
|
||||
|
||||
### API Abstraction
|
||||
Never make raw `axios.get` or `authFetch` calls directly inside a `.vue` component.
|
||||
All interactions with the `targo-hub` or `ERPNext` backend must go through a dedicated service file (e.g., `features/dispatch/api.ts`).
|
||||
|
||||
### State Management (Pinia)
|
||||
* One Pinia store per domain.
|
||||
* **Do not store UI state in Pinia** (e.g., `isSidebarOpen`). Use local `ref()` inside the component. Pinia is solely for caching fetched ERPNext data.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tech Mobile App — Wizard UX
|
||||
|
||||
Technicians in the field are wearing gloves, dealing with glare, and often lack cellular signal. Complex, scrolling forms are forbidden.
|
||||
|
||||
### Core Mobile Principles
|
||||
1. **Minimal inputs per screen:** Max 2-3 fields per step.
|
||||
2. **Carousel/swipe navigation:** Horizontal navigation instead of vertical scrolling.
|
||||
3. **Big touch targets:** Buttons minimum 48px height, inputs 56px+.
|
||||
4. **Auto-advance:** When a user taps a selection card, immediately slide to the next step.
|
||||
5. **Offline-first:** All completed Wizards must queue locally via IndexedDB if the device lacks internet access, syncing silently once connectivity is restored.
|
||||
|
||||
### The Component: `WizardCarousel.vue`
|
||||
All tech jobs (Installation, Diagnostic Swap, Closure) utilize a dynamic carousel driven by a JSON schema representing steps.
|
||||
|
||||
```vue
|
||||
<WizardCarousel
|
||||
:steps="wizardSteps"
|
||||
:context="{ job, customer, location }"
|
||||
@complete="onComplete" />
|
||||
```
|
||||
|
||||
### Flow Example: Diagnostic Wizard
|
||||
1. **Problème signalé** [Info Card] → Shows linked Ticket context.
|
||||
2. **Signal ONT** [Toggle + Text Input] → Signal present? Input dBm level.
|
||||
3. **Action Prise** [Select Cards] → "Redémarrage" / "Swap Hardware".
|
||||
4. **Nouvel équipement** [Barcode Scan] → Triggers device camera to read ONT MAC address.
|
||||
5. **Signature** [Touch Pad] → Client signs to confirm completion.
|
||||
6. **Résumé** [Info Card] → Syncs automatically if connection is active.
|
||||
|
|
@ -1,297 +1,111 @@
|
|||
# Gigafibre FSM -- Architecture
|
||||
# Gigafibre FSM — Ecosystem Architecture
|
||||
|
||||
## 1. Service Map
|
||||
> Unified reference document for infrastructure, platform strategy, and application architecture on the remote Docker environment.
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Authentik SSO │
|
||||
│ auth.targo.ca │
|
||||
└────────┬─────────┘
|
||||
│ forwardAuth
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Traefik │
|
||||
│ :80 / :443 │
|
||||
│ Let's Encrypt │
|
||||
└──┬───┬───┬───┬───┘
|
||||
┌──────────────────┘ │ │ └───────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Ops App │ │ ERPNext v16 │ │ targo-hub │
|
||||
│ /ops/ (nginx) │ │ erp.gigafibre.ca│ │ msg.gigafibre.ca│
|
||||
└───────┬────────┘ └───────┬──────────┘ └──┬───┬───┬──────┘
|
||||
│ /api/* proxy │ │ │ │
|
||||
│ (token injected) │ │ │ │
|
||||
└───────────────────┘ │ │ │
|
||||
│ │ │
|
||||
┌────────────────────────────────────┘ │ └──────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────────┐ ┌────────────────┐ ┌─────────┐
|
||||
│ GenieACS NBI │ │ Twilio API │ │ Stripe │
|
||||
│ 10.5.2.115:7557 │ │ SMS + Voice │ │ Payments│
|
||||
└───────┬──────────┘ └────────────────┘ └─────────┘
|
||||
│ CWMP (TR-069)
|
||||
▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ CPE / ONT │◀──────│ modem-bridge │
|
||||
│ TP-Link XX230v │ HTTPS │ :3301 (internal)│
|
||||
│ Raisecom HT803G │ │ Playwright │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
## 1. Executive Summary & Platform Strategy
|
||||
|
||||
Docker networks: `proxy` (Traefik-facing services), `erpnext` (ERPNext cluster + targo-hub).
|
||||
Gigafibre FSM represents the complete operations platform for Gigafibre, shifting from a polling-based legacy PHP system to a modern, real-time push ecosystem (Vue 3, Node.js, ERPNext, TR-369).
|
||||
|
||||
The strategy pivots around a **unified core platform** running entirely on a remote Proxmox VM (96.125.196.67):
|
||||
- **ERPNext v16** as the undisputed Source of Truth (CRM, billing, ticketing).
|
||||
- **Targo Ops PWA** as the single pane of glass for internal teams.
|
||||
- **Targo Hub** as the real-time API gateway (SMS, SSE, AI, TR-069 proxy).
|
||||
- **Client.gigafibre.ca** for customer self-service.
|
||||
|
||||
**Legacy Retirement Plan (April-May 2026):**
|
||||
- *Retire* `dispatch-app` — Functionality now in Ops + lightweight mobile tech page (`/t/{token}`).
|
||||
- *Retire* `apps/field` — Redundant to the mobile tech page workflow.
|
||||
- *Retire* `auth.targo.ca` — Fully migrated to `id.gigafibre.ca` Authentik.
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker Containers
|
||||
## 2. Infrastructure & Docker Networks
|
||||
|
||||
Host: `96.125.196.67` (Proxmox VM, Ubuntu 24.04). All services on one Docker host.
|
||||
All services are containerized and housed on a single Proxmox VM (`96.125.196.67`), managed via Traefik.
|
||||
|
||||
| Container | Image | Port | Network | Purpose |
|
||||
|-----------|-------|------|---------|---------|
|
||||
| ops-frontend | nginx:alpine | 80 | proxy | Ops SPA + ERPNext API proxy |
|
||||
| targo-hub | node:20-alpine | 3300 | proxy, erpnext | API gateway: SSE, SMS, devices, dispatch |
|
||||
| erpnext-frontend-1 | frappe/erpnext | 8080 | erpnext | ERPNext web + API |
|
||||
| erpnext-backend | frappe/erpnext | 8000 | erpnext | Frappe worker |
|
||||
| erpnext-db-1 | postgres:16 | 5432 | erpnext | ERPNext + targo_cache DBs |
|
||||
| modem-bridge | node:20-slim+Chromium | 3301 | proxy | Headless browser for ONU web GUI |
|
||||
| oktopus-acs-1 | oktopusp/acs | 9292 | oktopus | USP/TR-369 controller |
|
||||
| oktopus-mongo-1 | mongo:7 | 27017 | oktopus | Oktopus datastore |
|
||||
| fn-routr | fonoster/routr-one | -- | fonoster | VoIP SIP routing |
|
||||
| fn-asterisk | fonoster/asterisk | -- | fonoster | PBX media server |
|
||||
| fn-postgres | postgres:16 | -- | fonoster | Fonoster DB |
|
||||
| authentik-* | goauthentik | -- | authentik | SSO provider (staff + client) |
|
||||
| apps-www-gigafibre-1 | nginx | -- | proxy | Marketing website |
|
||||
```text
|
||||
Internet
|
||||
│
|
||||
96.125.196.67 (Proxmox VM, Ubuntu 24.04)
|
||||
│
|
||||
├─ Traefik v2.11 (:80/:443, Let's Encrypt, ForwardAuth)
|
||||
│
|
||||
├─ Authentik SSO (id.gigafibre.ca) → Secures /ops/ and client portal
|
||||
│
|
||||
├─ ERPNext v16.10.1 (erp.gigafibre.ca) → 9 containers (db, redis, workers)
|
||||
│
|
||||
├─ Targo Ops App (erp.gigafibre.ca/ops/) → Served via nginx:alpine
|
||||
│
|
||||
├─ n8n (n8n.gigafibre.ca) → Auto-login proxy wired to Authentik headers
|
||||
│
|
||||
├─ Oktopus CE (oss.gigafibre.ca) → TR-369 CPE management
|
||||
│
|
||||
└─ WWW / Frontend (www.gigafibre.ca) → React marketing site
|
||||
```
|
||||
|
||||
**DNS Configuration (Cloudflare):**
|
||||
- Domain `gigafibre.ca` is strictly DNS-only (no Cloudflare proxy) to allow Traefik Let's Encrypt generation.
|
||||
- Email via Mailjet + Google Workspace records configured on root.
|
||||
|
||||
**Docker Networks:**
|
||||
- `proxy`: Public-facing network connected to Traefik.
|
||||
- `erpnext_erpnext`: Internal network for Frappe, Postgres, Redis, and targo-hub routing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ops App (Quasar v2 / Vue 3 / Vite)
|
||||
## 3. Core Services
|
||||
|
||||
Served from `/opt/ops-app/` via `ops-frontend` nginx at `erp.gigafibre.ca/ops/`.
|
||||
### ERPNext (The Backend)
|
||||
- **Database:** PostgreSQL (`erpnext-db-1`).
|
||||
- **Extensions:** Custom doctypes for Dispatch Job, Technician, Tag, Service Location, Service Equipment, Subscription.
|
||||
- **API Token Auth:** `targo-hub` and the Ops PWA interact with Frappe via a highly-privileged service token (`Authorization: token ...`).
|
||||
|
||||
### Directory Structure
|
||||
### Targo-Hub (API Gateway)
|
||||
- **Stack:** Node.js 20 (`msg.gigafibre.ca:3300`).
|
||||
- **Purpose:** Acts as the middleman for all heavy or real-time workflows out of ERPNext's scope.
|
||||
- **Key Abilities:**
|
||||
- Real-time Server-Sent Events (SSE) for timeline/chat updates.
|
||||
- Twilio SMS / Voice (IVR) routing.
|
||||
- Modem polling (GenieACS, OLT SNMP proxy).
|
||||
- Webhooks handling (Stripe payments, Uptime-Kuma, 3CX).
|
||||
|
||||
| Directory | Files | Content |
|
||||
|-----------|-------|---------|
|
||||
| `api/` | 10 | ERPNext CRUD, dispatch, offers, presets, SMS, traccar, OCR, auth, reports |
|
||||
| `components/` | 23 .vue | customer/, dispatch/, shared/, layout/ |
|
||||
| `composables/` | 41 | Domain-specific reactive logic (see below) |
|
||||
| `modules/dispatch/components/` | 13 | Timeline, calendar, map, modals, context menus |
|
||||
| `pages/` | 16 | Routed page views |
|
||||
| `stores/` | 2 | Pinia: auth, dispatch |
|
||||
| `config/` | 8 | erpnext, nav, dispatch, ticket-config, hub, table-columns |
|
||||
|
||||
### Pages (16)
|
||||
|
||||
DashboardPage, ClientsPage, ClientDetailPage, TicketsPage, **DispatchPage**, EquipePage, NetworkPage, TelephonyPage, RapportsPage, SettingsPage, OcrPage, AgentFlowsPage, ReportARPage, ReportRevenuPage, ReportTaxesPage, ReportVentesPage
|
||||
|
||||
### Composables (41) -- grouped by domain
|
||||
|
||||
| Domain | Composables |
|
||||
|--------|-------------|
|
||||
| **Scheduling** | useScheduler, useDragDrop, useBottomPanel, usePeriodNavigation, useAutoDispatch, useBestTech, useJobOffers, useAbsenceResize, useSelection, useUndo, useContextMenus, useTechManagement |
|
||||
| **Map / GPS** | useMap, useGpsTracking, useAddressSearch |
|
||||
| **Phone / SMS** | usePhone, useConversations |
|
||||
| **Data** | useClientData, useDeviceStatus, useSSE, useInlineEdit, useEquipmentActions, usePaymentActions, useSubscriptionActions, useSubscriptionGroups, useLegacySync, useCustomerNotes, usePermissions, usePermissionMatrix, useUserGroups |
|
||||
| **UI** | useHelpers, useFormatters, useStatusClasses, useDetailModal, useResourceFilter, useTagManagement, useUnifiedCreate, useScanner, useWifiDiagnostic, useWizardCatalog, useWizardPublish |
|
||||
### Modem-Bridge
|
||||
- **Stack:** Playwright/Chromium (`:3301` internal).
|
||||
- **Purpose:** Allows reading encrypted TR-181 parameters from TP-Link XX230v modems by leveraging the modem's native JS cryptography. Exposes a simple JSON REST API locally to targo-hub.
|
||||
|
||||
---
|
||||
|
||||
## 4. Targo-Hub (Node.js)
|
||||
## 4. Security & Authentication Flow
|
||||
|
||||
Container `targo-hub` | `msg.gigafibre.ca` | Port 3300 | 40 modules in `lib/`
|
||||
|
||||
### Modules (top 15 by size)
|
||||
|
||||
| Module | Lines | Purpose |
|
||||
|--------|------:|---------|
|
||||
| payments.js | 1374 | Stripe checkout, PPA cron, webhooks |
|
||||
| network-intel.js | 1221 | Network topology, outage correlation |
|
||||
| ai.js | 719 | Gemini AI integration (tool-calling) |
|
||||
| acceptance.js | 672 | Service acceptance workflows |
|
||||
| outage-monitor.js | 601 | Uptime-Kuma alerts |
|
||||
| reports.js | 572 | Analytics and report generation |
|
||||
| tech-mobile.js | 562 | Lightweight mobile page for technicians |
|
||||
| devices.js | 551 | GenieACS proxy, device summary, poller |
|
||||
| contracts.js | 548 | Contract generation |
|
||||
| oktopus.js | 545 | TR-369/USP controller proxy |
|
||||
| agent.js | 529 | AI SMS agent with tool-calling |
|
||||
| conversation.js | 499 | Conversation persistence |
|
||||
| voice-agent.js | 457 | Inbound voice IVR + WebSocket media |
|
||||
| olt-snmp.js | 419 | OLT SNMP polling, ONU state tracking |
|
||||
| checkout.js | 399 | Customer checkout / catalog API |
|
||||
|
||||
Plus 25 smaller modules: server, twilio, pbx, dispatch, provision, auth, telephony, ical, modem-bridge, config, helpers, sse, email, otp, magic-link, traccar, vision, device-extractors, device-hosts, oktopus-mqtt, tech-absence-sms, address-search, email-templates, project-templates.
|
||||
|
||||
### Endpoints -- grouped by domain
|
||||
|
||||
**SSE / Real-time**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/sse?topics=...` | SSE stream (customer, conversations, sms-incoming) |
|
||||
| POST | `/broadcast` | Push event to SSE clients |
|
||||
|
||||
**SMS / Voice / Telephony**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | `/send/sms` | Send SMS via Twilio |
|
||||
| POST | `/webhook/twilio/sms-incoming` | Inbound SMS |
|
||||
| POST | `/webhook/twilio/sms-status` | Delivery status |
|
||||
| GET | `/voice/token` | Twilio voice JWT |
|
||||
| POST | `/voice/twiml`, `/voice/status` | TwiML + call status |
|
||||
| POST | `/voice/inbound`, `/voice/gather`, `/voice/connect-agent` | IVR voice agent |
|
||||
| WS | `/voice/ws` | Twilio Media Streams (WebSocket) |
|
||||
| POST | `/webhook/3cx/call-event` | 3CX call events |
|
||||
| * | `/telephony/*` | Fonoster/Routr SIP CRUD |
|
||||
|
||||
**Devices / Network**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/devices/lookup?serial=X` | GenieACS device search (3 fallback strategies) |
|
||||
| GET | `/devices/summary` | Fleet statistics |
|
||||
| GET | `/devices/:id/hosts` | Connected clients + mesh mapping |
|
||||
| POST | `/devices/:id/tasks` | Send task (reboot, refresh) |
|
||||
| * | `/acs/*` | ACS config export |
|
||||
| * | `/modem/*` | Proxy to modem-bridge |
|
||||
| * | `/olt/*` | OLT SNMP stats, ONU lookup, registration |
|
||||
| * | `/oktopus/*` | TR-369 USP proxy |
|
||||
| * | `/network/*` | Network intelligence / topology |
|
||||
|
||||
**Dispatch / Scheduling** -- `/dispatch/*` (CRUD, iCal token + feed)
|
||||
|
||||
**Auth / Customer / Payments** -- `/auth/*` (RBAC), `/magic-link/*`, `/api/checkout|catalog|otp|order|address` (customer flow), `/payments/*` + `/webhook/stripe` (Stripe + PPA cron), `/accept/*`, `/contract/*`
|
||||
|
||||
**AI / Vision** -- `/ai/*` (Gemini), `/agent/*` (SMS agent), `/vision/barcodes|equipment`, `/conversations/*`
|
||||
|
||||
**Other** -- `/traccar/*` (GPS), `/provision/*` (OLT), `/reports/*`, `/t/:token` (tech mobile), `/webhook/kuma` (outage), `/health`
|
||||
```text
|
||||
User → app.gigafibre.ca
|
||||
→ Traefik checks session via ForwardAuth middleware
|
||||
→ Flow routed to Authentik (id.gigafibre.ca)
|
||||
→ Authorized? Request forwarded to native container with 'X-Authentik-Email' header
|
||||
```
|
||||
- **ForwardAuth (`authentik-client@file`):** Currently protects `erp.gigafibre.ca/ops/`, `n8n`, and `hub`.
|
||||
- **API Security:** Frontends use the Authentik session proxy; Backend services/scripts use the `Authorization: token` headers directly hitting Frappe's `/api/method`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Modem-Bridge (Playwright/Chromium)
|
||||
## 5. Network Intelligence & CPE Flow
|
||||
|
||||
Internal-only service on port 3301. Provides REST access to TP-Link ONU web GUIs via headless Chromium. Required because XX230v firmware uses GDPR-encrypted communication (RSA key exchange, AES session, encrypted JSON on `/cgi_gdpr?9`). Playwright lets the modem's own JavaScript handle all crypto natively rather than re-implementing the protocol.
|
||||
**Device Diagnostics (`targo-hub → GenieACS / OLT`)**
|
||||
When a CSR clicks "Diagnostiquer" in the Ops app:
|
||||
1. Ops app asks `/devices/lookup?serial=X`.
|
||||
2. `targo-hub` polls GenieACS NBI.
|
||||
3. If deep data is needed, `targo-hub` queries `modem-bridge` (for TP-Link) or the OLT SNMP directly.
|
||||
4. Returns consolidated interface, mesh, wifi, and opticalStatus array to the UI.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| POST | `/session/login` | Authenticate to modem (ip, user, pass) |
|
||||
| GET | `/session/list` | List active browser sessions |
|
||||
| DELETE | `/session/:ip` | Close session |
|
||||
| GET | `/modem/:ip/status` | Device status summary |
|
||||
| GET | `/modem/:ip/dm/:oid` | Data manager GET |
|
||||
| GET | `/modem/:ip/screenshot` | PNG screenshot (debug) |
|
||||
|
||||
Constraints: ~450MB disk (node + Chromium), ~80MB idle + 150MB per session, 512MB Docker limit, sessions auto-expire after 5 min. Bearer token auth, private IP restriction, read-only operations only.
|
||||
**Future: QR Code Flow**
|
||||
- Tech applies QR sticker to modem (`msg.gigafibre.ca/q/{mac}`).
|
||||
- Client scans QR → `targo-hub` identifies customer via MAC matching in ERPNext.
|
||||
- Triggers SMS OTP → Client views diagnostic portal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Secondary Apps
|
||||
|
||||
| App | Stack | URL | Purpose |
|
||||
|-----|-------|-----|---------|
|
||||
| **Field App** (`apps/field/`) | Vue 3 / Quasar PWA | -- | Tech mobile: daily tasks, barcode scanner, device diagnostics, offline sync |
|
||||
| **Client Portal** (`apps/client/`) | Vue 3 / Quasar PWA | client.gigafibre.ca | Self-service: invoices, subscriptions, tickets, catalog. Auth via Authentik |
|
||||
| **Website** (`apps/website/`) | React / Vite / Tailwind | www.gigafibre.ca | Marketing: products, eligibility check, online ordering, FAQ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model (ERPNext Doctypes)
|
||||
|
||||
```
|
||||
Customer
|
||||
├── Service Location (LOC-#####)
|
||||
│ ├── Address + GPS coordinates
|
||||
│ ├── OLT port, VLAN, network config
|
||||
│ ├── Service Equipment (EQP-#####)
|
||||
│ │ ├── Type: ONT / Router / Switch / AP / Decodeur
|
||||
│ │ ├── Serial + MAC + manage IP + firmware
|
||||
│ │ └── Status: Active / Inactive / En stock / Defectueux / Retourne
|
||||
│ └── Service Subscription (SUB-#####)
|
||||
│ ├── Plan: Internet / IPTV / VoIP / Bundle
|
||||
│ └── Status: pending → active → suspended → cancelled
|
||||
└── Sales Invoice → Payment Entry
|
||||
|
||||
Dispatch Job
|
||||
├── Customer + Service Location
|
||||
├── Dispatch Technician (assigned + assistants)
|
||||
├── Dispatch Tag Link (tag + level 1-5 + required flag)
|
||||
├── Schedule: date, time, duration, recurrence (RRULE)
|
||||
└── Equipment Items / Materials Used
|
||||
|
||||
Dispatch Technician
|
||||
├── weekly_schedule (JSON), extra_shifts (JSON)
|
||||
└── Dispatch Tag Link (skill level per tag)
|
||||
```
|
||||
|
||||
Custom fields on standard doctypes: Customer (`stripe_id`, `is_commercial`, `ppa_enabled`), Subscription (`actual_price`, `service_location`), Issue (linked dispatch jobs), Sales Invoice (QR code, portal link).
|
||||
|
||||
---
|
||||
|
||||
## 8. External Integrations
|
||||
|
||||
| Service | Purpose | Connection |
|
||||
|---------|---------|------------|
|
||||
| GenieACS (10.5.2.115) | TR-069 CPE management | NBI REST, LAN, no auth |
|
||||
| Twilio | SMS + voice | REST API, Basic auth |
|
||||
| Stripe | Payments, checkout | API + webhooks |
|
||||
| Mapbox | Maps, geocoding, routing | JS SDK + Directions API |
|
||||
| Gemini AI | OCR, SMS agent, AI tools | REST, Bearer token |
|
||||
| Traccar | GPS tech tracking | REST API, Basic auth |
|
||||
| 3CX PBX | Call history | REST API poller (30s) |
|
||||
| Fonoster/Routr | SIP trunking | Direct PostgreSQL |
|
||||
| Authentik | SSO (staff + client) | Traefik forwardAuth + API |
|
||||
| n8n | Workflow automation | HTTP webhooks |
|
||||
| Uptime-Kuma | Outage monitoring | Webhook to targo-hub |
|
||||
| Cloudflare | DNS for gigafibre.ca | REST API |
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Flows
|
||||
|
||||
### Device Diagnostic
|
||||
|
||||
```
|
||||
Ops App → EquipmentDetail.vue → GET /devices/lookup?serial=X
|
||||
→ targo-hub → GenieACS NBI (3 fallback strategies) → summarizeDevice()
|
||||
→ {interfaces, mesh, wifi, opticalStatus, ethernet}
|
||||
|
||||
GET /devices/:id/hosts?refresh
|
||||
→ 2 tasks to CPE (connection_request) → read GenieACS cache
|
||||
→ clientNodeMap: MAC → {nodeName, band, signal, speed}
|
||||
→ UI: clients grouped by mesh node (basement, hallway, etc.)
|
||||
```
|
||||
|
||||
### Dispatch Auto-Assign
|
||||
|
||||
```
|
||||
New job with required tags (e.g., Fibre level 3)
|
||||
→ useAutoDispatch → useBestTech
|
||||
→ Filter techs: tag level >= required, available in time slot
|
||||
→ Sort: lowest adequate skill level first (preserve experts)
|
||||
→ Assign → SSE broadcast → timeline updates
|
||||
```
|
||||
|
||||
### SMS Notification
|
||||
|
||||
```
|
||||
Compose in ChatterPanel → POST /send/sms → targo-hub
|
||||
→ Twilio API → delivery
|
||||
→ /webhook/twilio/sms-status → SSE broadcast (conv:{token})
|
||||
→ UI updates delivery status in thread
|
||||
|
||||
Inbound SMS → /webhook/twilio/sms-incoming
|
||||
→ conversation.js (persist) → SSE broadcast (sms-incoming)
|
||||
→ agent.js (optional AI auto-reply with tool-calling)
|
||||
```
|
||||
|
||||
### Customer Onboarding
|
||||
|
||||
```
|
||||
Website → /api/address (eligibility check) → /api/catalog
|
||||
→ /api/checkout (Stripe session) → /webhook/stripe (payment confirmed)
|
||||
→ ERPNext: create Customer + Service Location + Subscription
|
||||
→ /api/otp (SMS verification) → magic-link auth
|
||||
→ provision.js: OLT pre-auth → GenieACS auto-provision on connect
|
||||
```
|
||||
## 6. Development Gotchas
|
||||
1. **Traefik v3** is incompatible with Docker 29 due to API changes. Stay on v2.11.
|
||||
2. **MongoDB 5+** (Oktopus) requires AVX extensions. Proxmox CPU must be set to `host`.
|
||||
3. Never click "Generate Keys" for the Administrator user in ERPNext or it breaks the `targo-hub` API token.
|
||||
4. **Traccar API** supports only one `deviceId` per request. Use parallel polling.
|
||||
|
|
|
|||
433
docs/BILLING_AND_PAYMENTS.md
Normal file
433
docs/BILLING_AND_PAYMENTS.md
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
# Facturation & Paiements — Handoff dev
|
||||
|
||||
> Référence unique pour toutes les fonctionnalités facture / paiement construites sur
|
||||
> la stack `erp.gigafibre.ca` + `client.gigafibre.ca` + `ops`. Lisez `ARCHITECTURE.md`
|
||||
> d'abord pour le contexte réseau/services.
|
||||
|
||||
Dernière MàJ : 2026-04-17
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Importation du système legacy vers ERPNext](#1-importation-du-système-legacy-vers-erpnext)
|
||||
2. [App Frappe custom — `gigafibre_utils`](#2-app-frappe-custom--gigafibre_utils)
|
||||
3. [Print Format « Facture TARGO »](#3-print-format--facture-targo-)
|
||||
4. [Flux de paiement public (sans Authentik)](#4-flux-de-paiement-public-sans-authentik)
|
||||
5. [Infrastructure Docker](#5-infrastructure-docker)
|
||||
6. [UI Ops — aperçu client](#6-ui-ops--aperçu-client)
|
||||
7. [Configuration & secrets](#7-configuration--secrets)
|
||||
8. [Points connus / TODO](#8-points-connus--todo)
|
||||
|
||||
---
|
||||
|
||||
## 1. Importation du système legacy vers ERPNext
|
||||
|
||||
### Particularités
|
||||
|
||||
- **Source** : MariaDB legacy `gestionclient` (conteneur `legacy-db`, 10.100.80.100).
|
||||
- **Cible** : PostgreSQL ERPNext v16 (patches GROUP BY/HAVING/quotes appliqués — cf.
|
||||
`feedback_erpnext_postgres.md`).
|
||||
- **Correspondance des clés** :
|
||||
- `customer.id` legacy → `Customer.legacy_id` (custom field).
|
||||
- `service.delivery_id` legacy → `Service Location.legacy_delivery_id` (custom field).
|
||||
- `invoice_item.service_id` legacy → chaîné via `service.delivery_id` pour rattacher
|
||||
un item à son `Service Location` ERPNext.
|
||||
- **Prix** : conservés tels quels (pas de re-calcul des taxes); TPS/TVQ sont imports
|
||||
inclus dans `taxes[]` de chaque `Sales Invoice`.
|
||||
- **Volumes finaux** : 6 667 customers · 21 K subscriptions · 115 K invoices ·
|
||||
1,6 M invoice items · 242 K tickets.
|
||||
|
||||
### Fonctionnalités — scripts `scripts/migration/*`
|
||||
|
||||
| Script | Rôle |
|
||||
|---|---|
|
||||
| `import_invoices.py` | Import complet des factures legacy + JOIN sur `service` pour récupérer `delivery_id` et renseigner `service_location` sur chaque item. |
|
||||
| `backfill_service_location.py` | Script one-shot pour remplir `service_location` sur les 1,6 M items déjà importés (batches `execute_values` de 20 K lignes, 227 s). |
|
||||
| `import_payments.py` / `reimport_payments.py` / `import_expro_payments.py` | Import des paiements legacy en `Payment Entry` réconciliés. |
|
||||
| `import_payment_methods.py` | Carte + ACH + chèque — créés en tant que `Mode of Payment`. |
|
||||
| `fix_invoice_outstanding.py` | Réconciliation après import — recalcule `outstanding_amount` via Frappe. |
|
||||
| `fix_invoice_customer_names.py` | Renomme customers pour refléter le nom légal courant (`Subscription.bill_to_name`). |
|
||||
| `import_payment_arrangements.py` | Ententes de paiement (échelonnement) → `Payment Request` + scheduler. |
|
||||
| `geocode_locations.py` | Géocode toutes les `Service Location` avec Mapbox. |
|
||||
|
||||
### Restauration Item ↔ Service Location (particularité majeure)
|
||||
|
||||
Sans ce lien le PDF ne peut pas grouper les lignes par adresse.
|
||||
|
||||
```
|
||||
legacy.invoice_item.service_id
|
||||
└─→ legacy.service.delivery_id
|
||||
└─→ ERPNext Service Location.legacy_delivery_id (custom field)
|
||||
```
|
||||
|
||||
1. Custom field `Sales Invoice Item.service_location` (Link → Service Location) créé
|
||||
via `add_missing_custom_fields.py`.
|
||||
2. `import_invoices.py` mis à jour : JOIN sur `service` + lookup
|
||||
`Service Location.legacy_delivery_id` → écrit `service_location` lors de l'INSERT.
|
||||
3. Backfill ponctuel : `backfill_service_location.py`
|
||||
(`UPDATE ... FROM (VALUES %s)` via `psycopg2.extras.execute_values`).
|
||||
|
||||
### Points d'attention
|
||||
|
||||
- **Mots de passe legacy** = MD5 non-salé → forcer un reset via OTP email/SMS
|
||||
(`project_portal_auth.md`).
|
||||
- **Devices** rattachés aux **addresses** (Service Location), pas aux customers
|
||||
(`feedback_device_hierarchy.md`).
|
||||
- **Serials TPLG** ERPNext ≠ serials réels — matching via MAC
|
||||
(`feedback_device_serial_mapping.md`).
|
||||
|
||||
---
|
||||
|
||||
## 2. App Frappe custom — `gigafibre_utils`
|
||||
|
||||
### Particularités
|
||||
|
||||
- **Chemin source** (serveur ERP) : `/opt/erpnext/custom/gigafibre_utils/`
|
||||
- **Cuite dans l'image Docker** — `/opt/erpnext/custom/Dockerfile` copie l'app dans
|
||||
`apps/gigafibre_utils` et la pip-installe en editable.
|
||||
- **Raison d'exister** : contourner le sandbox Frappe `safe_exec` (qui bloque
|
||||
`__import__` et certaines opérations) en exposant des `@frappe.whitelist()` Python
|
||||
natifs appelables depuis Jinja et côté client.
|
||||
|
||||
### Arborescence
|
||||
|
||||
```
|
||||
gigafibre_utils/
|
||||
├── Dockerfile # FROM frappe/erpnext:v16.10.1 + chromium + cairosvg
|
||||
└── gigafibre_utils/
|
||||
├── __init__.py
|
||||
├── hooks.py
|
||||
├── modules.txt
|
||||
├── patches.txt
|
||||
├── api.py # 823 lignes, toutes les méthodes whitelisted
|
||||
└── www/
|
||||
└── pay-public.html # Page publique /pay-public (sans Authentik)
|
||||
```
|
||||
|
||||
### Fonctionnalités — `api.py`
|
||||
|
||||
#### 2.1 Rendu visuel (QR, logo, descriptions)
|
||||
|
||||
| Méthode | `allow_guest` | Rôle |
|
||||
|---|:-:|---|
|
||||
| `invoice_qr(invoice)` | ✅ | PNG binaire du QR code (payload = URL `/pay-public` signée, TTL 60 j). |
|
||||
| `invoice_qr_base64(invoice)` | ✅ | Base64 brut (pour embed direct dans Jinja). |
|
||||
| `invoice_qr_datauri(invoice)` | ✅ | `data:image/png;base64,…` prêt à injecter en `<img src>`. |
|
||||
| `logo_base64(height)` | ✅ | Logo TARGO rasterisé via cairosvg (SVG → PNG 96 px). |
|
||||
| `short_item_name(name, max_len)` | ✅ | Nettoie/tronque les noms d'item (retire caractères parasites, limite longueur). |
|
||||
|
||||
**Particularité QR** : la fonction `_sign_pay_token` génère un token HMAC-SHA256
|
||||
(clé `gigafibre_pay_secret`) → `{invoice, expires_at}` encodé base64-url. Le QR
|
||||
pointe vers `https://client.gigafibre.ca/pay-public?inv=…&t=…`.
|
||||
|
||||
**Particularité logo** : wkhtmltopdf (QtWebKit 2013) ignore `<defs><style>` SVG.
|
||||
Solution retenue : rendu serveur-side via **cairosvg → PNG base64** injecté dans
|
||||
le template. Aucune dépendance côté client.
|
||||
|
||||
#### 2.2 Génération PDF (`invoice_pdf`)
|
||||
|
||||
- Contourne **complètement** la pipeline PDF Frappe (qui ajoute 15 mm de marge top
|
||||
forcée si pas de `#header-html`, et post-process avec pypdf).
|
||||
- Lance `chromium --headless=new --no-pdf-header-footer --print-to-pdf` directement
|
||||
dans un `tempfile.TemporaryDirectory`.
|
||||
- Réponse HTTP : `Content-Type: application/pdf` + `Content-Disposition: inline`
|
||||
(⚠️ **ne pas** utiliser `"binary"` — empêche l'affichage iframe et Acrobat échoue
|
||||
à ouvrir le fichier téléchargé).
|
||||
- Producer : **Skia/PDF m147** (identique à Antigravity/Gemini → fidélité pixel-parfait).
|
||||
|
||||
Usage :
|
||||
```
|
||||
GET /api/method/gigafibre_utils.api.invoice_pdf?name=SINV-2026-700010
|
||||
GET /api/method/gigafibre_utils.api.invoice_pdf?name=SINV-xxx&print_format=Facture+TARGO
|
||||
```
|
||||
|
||||
#### 2.3 Code de parrainage (`referral_code`)
|
||||
|
||||
- Déterministe par Customer — **HMAC-SHA256** (clé `gigafibre_pay_secret`) sur
|
||||
`customer.name` → 30 bits → **6 caractères Crockford base32** (pas de `0/O/1/I/L/U`
|
||||
ambigus).
|
||||
- Stable dans le temps (pas stocké en DB).
|
||||
- ~1,07 milliard de codes possibles → collisions négligeables pour <10 M customers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Print Format « Facture TARGO »
|
||||
|
||||
### Particularités
|
||||
|
||||
- **Install** : `scripts/migration/setup_invoice_print_format.py` (idempotent).
|
||||
- **Doctype** : `Sales Invoice`.
|
||||
- **Générateur** : **`pdf_generator="chrome"`** — Chromium `--print-to-pdf`
|
||||
headless. C'est **la meilleure méthode** dans ERPNext v16 ; wkhtmltopdf
|
||||
(QtWebKit 2013) est obsolète et donne un rendu dégradé (SVG `<defs><style>`
|
||||
ignorés, flexbox cassé, polices Unicode QC incomplètes). Avantages :
|
||||
- **CSS moderne** : flexbox/grid, `@page` avec `counter(page)` / `counter(pages)`
|
||||
pour la pagination (p. ex. « Page 1 de 2 » en `@top-right`), `break-inside: avoid`.
|
||||
- **Polices système** : les accents/caractères spéciaux (é, à, œ, ℃) passent
|
||||
sans mapping manuel.
|
||||
- **Rendu pixel-perfect** : même moteur que le navigateur de preview — le
|
||||
preview HTML et le PDF sont identiques.
|
||||
- **Performances** : ~1 s pour une facture typique (vs ~3 s wkhtmltopdf).
|
||||
- **Prérequis** : Chromium installé dans le container (voir Dockerfile
|
||||
`FROM frappe/erpnext:v16.10.1 + apt-get install chromium`), clé
|
||||
`chromium_path: /usr/bin/chromium` dans `common_site_config.json`.
|
||||
- **Marges** : forcées par le Print Format `margin_top=5, bottom=5, left=15,
|
||||
right=15` (mm) — Chrome PDF ignore `@page { margin: … }` dans le CSS
|
||||
(`preferCSSPageSize=false`), il faut passer par les options ERPNext.
|
||||
- **Template Jinja** : ~25 Ko, inliné dans le script `setup_invoice_print_format.py`
|
||||
(constante `html_template = r"""…"""`) — source de vérité unique, versionnée.
|
||||
|
||||
### Fonctionnalités clés du template
|
||||
|
||||
1. **Prélude de résolution de données** — récupère `customer`, `service_locations`,
|
||||
`prev_invoice`, `recent_payments`, `remaining_balance`, `account_number`, etc.
|
||||
depuis le doc `Sales Invoice`.
|
||||
2. **Position de la fenêtre d'enveloppe** — bloc adresse client à **50 mm du haut**
|
||||
(2″) pour s'aligner sur enveloppe #10 à fenêtre (ouverture 2″-3½″).
|
||||
- `margin-top` du `.cl-block` varie selon la présence d'un `prev_invoice`
|
||||
(10 mm si bloc SOMMAIRE DU COMPTE présent en amont, 33 mm sinon).
|
||||
3. **Regroupement par Service Location** — la section « FRAIS COURANTS » itère
|
||||
`current_charges_locations[]`, chaque location a ses items (pas de sous-total
|
||||
par location pour réduire la hauteur).
|
||||
4. **Période de service inline** — calculée par item :
|
||||
- Utilise `service_start_date` / `service_end_date` si présents (ERPNext deferred).
|
||||
- Sinon déduit la période courante sauf si l'item est **one-time** (mots-clés :
|
||||
`install`, `activation`, `rabais`, `frais unique`, `remise`, ou montant négatif).
|
||||
- Format : `(1er avril au 30 avril)` plein mois, `(16 avril au 30 avril)` prorata.
|
||||
5. **Colonne de droite (meta + QR + parrainage + conditions)** :
|
||||
- Meta-band : N° compte · Date · N° facture
|
||||
- **Montant dû** (boîte verte)
|
||||
- QR code (base64 inline, 60 j TTL)
|
||||
- Boîte parrainage (fond gris, accent vert) — code 6 car. via `gigafibre_utils.api.referral_code`.
|
||||
- Conditions légales (« sera **assujettie** à des frais de retard… »)
|
||||
- Contactez-nous : **1867 chemin de la Rivière, Ste-Clotilde, J0L 1W0 · 855 888-2746**
|
||||
6. **Mini-footer CPRST** en bas de page.
|
||||
|
||||
### Particularités Jinja
|
||||
|
||||
- Variables `svc_start` / `svc_end` **doivent être définies avant la boucle items**
|
||||
(sinon `UndefinedError`).
|
||||
- Les appels `frappe.call(...)` dans le sandbox Jinja requièrent un `frappe.local.request`
|
||||
vivant → le rendu hors contexte web (ex. `bench execute`) échoue : **toujours
|
||||
tester via HTTP** (l'endpoint `invoice_pdf` fait ça correctement).
|
||||
|
||||
### Fichiers connexes
|
||||
|
||||
- `scripts/migration/invoice_preview.jinja` — copie de travail lisible (pas utilisée
|
||||
en prod — seul le Print Format installé compte).
|
||||
- `scripts/migration/test_jinja_render.py` — rend le template localement via Chrome
|
||||
macOS pour comparaison pixel (référence).
|
||||
|
||||
---
|
||||
|
||||
## 4. Flux de paiement public (sans Authentik)
|
||||
|
||||
### Vue d'ensemble
|
||||
|
||||
```
|
||||
QR (facture) / SMS / Email Authentik-less
|
||||
↓ ↑
|
||||
https://client.gigafibre.ca/pay-public?inv=X&t=HMAC
|
||||
↓
|
||||
validate_pay_token → affiche résumé facture
|
||||
↓
|
||||
[Payer avec Stripe]
|
||||
↓
|
||||
create_checkout_session → redirect Stripe
|
||||
↓
|
||||
Stripe Checkout (hors ERP)
|
||||
↓
|
||||
stripe_webhook → Payment Entry reconciled
|
||||
```
|
||||
|
||||
### Trois TTL de tokens (signés HMAC-SHA256)
|
||||
|
||||
| Token | TTL | Usage |
|
||||
|---|---|---|
|
||||
| **QR code** | 60 jours | Lien permanent sur facture imprimée. |
|
||||
| **Magic link** | 15 minutes | SMS/email en cas de token expiré. |
|
||||
| **pay_redirect** | 1 heure | Bridge depuis portail authentifié (customer connecté). |
|
||||
|
||||
### Fonctionnalités — `api.py`
|
||||
|
||||
| Méthode | `allow_guest` | Rôle |
|
||||
|---|:-:|---|
|
||||
| `pay_token(invoice, ttl_days)` | ❌ (admin) | Génère une URL signée (debug/test). |
|
||||
| `validate_pay_token(invoice, token)` | ✅ | Vérifie signature+TTL, retourne résumé facture. |
|
||||
| `request_magic_link(invoice, channel, last4)` | ✅ | Envoie SMS/email (gated par `last4` du téléphone). |
|
||||
| `create_checkout_session(invoice, token)` | ✅ | Crée Stripe Checkout Session. |
|
||||
| `stripe_webhook()` | ✅ | Vérifie signature Stripe, crée Payment Entry. |
|
||||
| `pay_redirect(invoice)` | ⚠️ sous Authentik | Bridge portal → pay-public (bypass staff @targo.ca/@gigafibre.ca). |
|
||||
|
||||
### Landing page `/pay-public`
|
||||
|
||||
- Servie par Frappe depuis `gigafibre_utils/www/pay-public.html`.
|
||||
- Vanilla JS — pas de build step, pas de framework.
|
||||
- Lit `inv` + `t` depuis `frappe.form_dict`.
|
||||
- Appelle `validate_pay_token` → affiche facture OU formulaire magic-link.
|
||||
- Bouton « Payer avec Stripe » → `create_checkout_session` → redirect.
|
||||
|
||||
### Carve-out Traefik (contournement Authentik)
|
||||
|
||||
Fichier : `/opt/traefik/dynamic/pay-public.yml`.
|
||||
|
||||
Routeurs avec **priority ≥ 250** (> priorité 150 des règles `/api` Authentik) :
|
||||
|
||||
- `client-pay-public` — `PathPrefix(/pay-public)` (landing)
|
||||
- `client-api-validate-pay-token`
|
||||
- `client-api-magic-link`
|
||||
- `client-api-create-checkout-session`
|
||||
- `client-api-stripe-webhook`
|
||||
|
||||
Tous servis sur `client-portal-svc`, **aucun middleware** (pas de ForwardAuth).
|
||||
|
||||
### Stripe
|
||||
|
||||
- **Checkout Session API** : créée côté serveur (`_stripe_post`), redirect 303 vers
|
||||
`session.url`.
|
||||
- **Webhook** : vérifie `Stripe-Signature` (`_stripe_verify_signature`) avec
|
||||
`gigafibre_stripe_webhook_secret` → crée `Payment Entry` rattaché à la facture.
|
||||
- **Clé réutilisée** depuis targo-hub (`sk_live_51QCMOI…`).
|
||||
|
||||
### Twilio (magic link SMS)
|
||||
|
||||
- Auth basic (`twilio_account_sid:twilio_auth_token`).
|
||||
- From `twilio_from_number=+14382313838`.
|
||||
- Anti-abuse : `request_magic_link` exige `last4` matchant les 4 derniers chiffres
|
||||
du téléphone customer avant d'envoyer.
|
||||
|
||||
---
|
||||
|
||||
## 5. Infrastructure Docker
|
||||
|
||||
### Image custom ERPNext
|
||||
|
||||
`/opt/erpnext/custom/Dockerfile` :
|
||||
|
||||
```dockerfile
|
||||
FROM frappe/erpnext:v16.10.1
|
||||
USER root
|
||||
RUN apt-get install -y libcairo2 libpango-1.0-0 libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 shared-mime-info \
|
||||
chromium fonts-liberation fonts-noto-color-emoji
|
||||
USER frappe
|
||||
COPY --chown=frappe:frappe gigafibre_utils apps/gigafibre_utils
|
||||
RUN env/bin/pip install -e apps/gigafibre_utils cairosvg
|
||||
RUN grep -qx gigafibre_utils apps/apps.txt || echo gigafibre_utils >> apps/apps.txt
|
||||
```
|
||||
|
||||
### Particularités
|
||||
|
||||
- Chromium installé à `/usr/bin/chromium` (config key `chromium_path`).
|
||||
- cairosvg (Python) pour rasterisation SVG → PNG.
|
||||
- App `gigafibre_utils` **copiée dans l'image** (pas un volume) → toute modif
|
||||
requiert `docker compose build erpnext-backend && docker compose up -d`.
|
||||
- ⚠️ Piège : `sites/apps.txt` peut se retrouver avec une ligne parasite
|
||||
`apps.txt` qui casse `bench` (`ModuleNotFoundError: No module named 'apps'`) —
|
||||
nettoyer si ça arrive.
|
||||
|
||||
### Build & déploiement
|
||||
|
||||
```bash
|
||||
cd /opt/erpnext/custom
|
||||
docker compose build erpnext-backend
|
||||
docker compose up -d --force-recreate erpnext-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Ops — aperçu client
|
||||
|
||||
### Fonctionnalité
|
||||
|
||||
Onglet « Aperçu client » dans le volet de détail facture
|
||||
(`apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue`).
|
||||
|
||||
- `q-tabs` : **Détails** (champs grid) ↔ **Aperçu client** (iframe PDF).
|
||||
- Iframe pointe sur `gigafibre_utils.api.invoice_pdf?name=…` → rendu Chrome
|
||||
pixel-parfait identique au PDF client.
|
||||
|
||||
### Particularité Vue scoped
|
||||
|
||||
Les styles `.modal-field-grid / .mf / .mf-label` définis dans `DetailModal.vue`
|
||||
avec `scoped` **ne cascadent pas** vers les composants enfants montés via
|
||||
`<component :is>`. Il faut les **dupliquer** dans le `<style scoped>` du composant
|
||||
enfant (contre-intuitif — documenter pour les nouveaux devs).
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration & secrets
|
||||
|
||||
Toutes les clés vivent dans `/opt/erpnext/custom/erpnext/sites/common_site_config.json`
|
||||
(montage bind du volume `sites` sur le conteneur `erpnext-backend`).
|
||||
|
||||
### Clés requises
|
||||
|
||||
| Clé | Valeur / format | Utilisation |
|
||||
|---|---|---|
|
||||
| `gigafibre_pay_secret` | 64 hex | HMAC signing tokens + referral codes |
|
||||
| `gigafibre_pay_host` | `https://client.gigafibre.ca/pay-public` | Base URL QR/magic-link |
|
||||
| `gigafibre_stripe_secret_key` | `sk_live_…` | Stripe Checkout API |
|
||||
| `gigafibre_stripe_webhook_secret` | `whsec_…` | **TODO** — webhook signature verify |
|
||||
| `twilio_account_sid` | `AC…` | SMS magic-link |
|
||||
| `twilio_auth_token` | (hex) | SMS auth |
|
||||
| `twilio_from_number` | `+14382313838` | SMS from |
|
||||
| `chromium_path` | `/usr/bin/chromium` | PDF generator |
|
||||
|
||||
### Commande pour poser une clé
|
||||
|
||||
```bash
|
||||
docker exec erpnext-backend-1 bench set-config -g <key> "<value>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Points connus / TODO
|
||||
|
||||
### ⚠️ TODO bloquants
|
||||
|
||||
- [ ] **Webhook Stripe** : enregistrer un second endpoint pointant vers
|
||||
`https://client.gigafibre.ca/api/method/gigafibre_utils.api.stripe_webhook`
|
||||
dans le dashboard Stripe, récupérer le `whsec_…`, poser via
|
||||
`bench set-config -g gigafibre_stripe_webhook_secret`.
|
||||
|
||||
### Améliorations suggérées
|
||||
|
||||
- [ ] **Inverse lookup parrainage** : `customer_from_referral_code(code)` pour
|
||||
appliquer le crédit 50 $ automatiquement à la souscription du parrain.
|
||||
- [ ] **Rate-limit** sur `request_magic_link` (actuellement protégé par last4
|
||||
seulement — OK pour l'abus téléphone, pas pour énumération d'invoices).
|
||||
- [ ] **Refresh token** côté Traccar : le token généré expire le 2031-04-17 ;
|
||||
prévoir rotation automatique (script cron + API `/api/session/token`).
|
||||
- [ ] **Archivage local des PDFs** : stocker dans `File Doctype` pour historique
|
||||
et éviter de re-rendre via Chromium à chaque affichage.
|
||||
|
||||
### Pièges connus
|
||||
|
||||
- `frappe.response.type = "binary"` ⇒ Acrobat échoue à ouvrir le fichier
|
||||
téléchargé → **toujours utiliser `"pdf"`** pour les PDFs.
|
||||
- Postgres v16 + ERPNext : bugs `GROUP BY`/`HAVING`/double-quotes —
|
||||
patches dans `feedback_erpnext_postgres.md`.
|
||||
- Script `bench execute` échoue sur `gigafibre_utils.api.invoice_pdf` hors
|
||||
contexte web (Jinja appelle `frappe.call` qui requiert `frappe.local.request`).
|
||||
Tester via HTTP.
|
||||
- Traccar token ≠ FCM token — les `APA91b…` sont pour Firebase push, pas pour
|
||||
l'API Traccar (cf. conversation 2026-04-17).
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à connaître (index rapide)
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `/opt/erpnext/custom/Dockerfile` | Image ERPNext custom |
|
||||
| `/opt/erpnext/custom/gigafibre_utils/gigafibre_utils/api.py` | **823 lignes** — toutes les méthodes whitelisted |
|
||||
| `/opt/erpnext/custom/gigafibre_utils/gigafibre_utils/www/pay-public.html` | Landing publique |
|
||||
| `/opt/traefik/dynamic/pay-public.yml` | Carve-out Authentik |
|
||||
| `scripts/migration/setup_invoice_print_format.py` | Install/update du Print Format |
|
||||
| `scripts/migration/import_invoices.py` | Import legacy → ERPNext |
|
||||
| `scripts/migration/backfill_service_location.py` | Backfill item↔location 1,6 M lignes |
|
||||
| `scripts/migration/test_jinja_render.py` | Rendu local pour comparaison pixel |
|
||||
| `apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue` | Onglet Aperçu client |
|
||||
| `services/targo-hub/lib/traccar.js` | Proxy Traccar (Bearer token) |
|
||||
61
docs/CPE_MANAGEMENT.md
Normal file
61
docs/CPE_MANAGEMENT.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Gigafibre FSM — CPE Hardware Management
|
||||
|
||||
> A consolidated guide for managing Customer Premises Equipment (CPE) fleets. This document covers TR-069/TR-369 protocols, ACS migration strategies, and deep hardware diagnostics (specifically the TP-Link XX230v / Deco).
|
||||
|
||||
---
|
||||
|
||||
## 1. Protocol Strategy (TR-069 to TR-369)
|
||||
|
||||
We are gradually migrating our management plane from **GenieACS** (TR-069, HTTP/SOAP polling) to **Oktopus CE** (TR-369, real-time USP over MQTT/WebSocket).
|
||||
|
||||
**The goal is bidirectional, real-time device management.** TR-069 waits for the next "inform" interval (often hours) before executing a reboot or reading parameters. TR-369 maintains a constant socket connection.
|
||||
|
||||
### Migration Phases
|
||||
1. **Parallel Run:** Oktopus is deployed at `oss.gigafibre.ca`. It has a TR-069 adapter, allowing it to natively accept legacy CWMP connections.
|
||||
2. **Translation:** We must manually map old GenieACS JS provision scripts directly against the Oktopus event subscriptions and policy webhook engine.
|
||||
3. **Migration:** Update the `Device.ManagementServer.URL` on CPEs to point to the new Oktopus TR-069 Adapter. Keep GenieACS read-only.
|
||||
4. **Upgrade to TR-369:** Where CPE firmware allows (e.g., ZTE F680, Nokia G-010G-Q 3.x+), push firmware updates that include a native USP agent and point them to Oktopus MQTT.
|
||||
|
||||
---
|
||||
|
||||
## 2. TP-Link XX230v (Deco XE75) Deep Diagnostics
|
||||
|
||||
The TP-Link XX230v supports a rich TR-181 data model. When customers report "WiFi issues", CSRs and Techs should not blindly swap the hardware. The following endpoints must be polled to ascertain the actual root cause of the fault.
|
||||
|
||||
### A. Optical Signal (Is it the Fibre?)
|
||||
```text
|
||||
Device.Optical.Interface.1.Stats.SignalRxPower → target: -8 to -25 dBm
|
||||
Device.Optical.Interface.1.Stats.ErrorsSent
|
||||
```
|
||||
*Diagnosis:* If RxPower < -25 dBm, there is a dirty connector or a fibre break. It is **not** a hardware fault with the ONT. Do not swap it.
|
||||
|
||||
### B. WiFi Radio & Topology (Is it Interference?)
|
||||
```text
|
||||
Device.WiFi.Radio.1.Stats.Noise → Interference measure (2.4Ghz)
|
||||
Device.WiFi.Radio.2.Stats.Noise → Interference measure (5GHz)
|
||||
Device.WiFi.MultiAP.APDevice.{i}.Radio.{j}.Utilization → Backhaul traffic load on Deco Mesh
|
||||
```
|
||||
*Diagnosis:* High noise/errors on a specific band indicates environmental channel congestion. If the backhaul utilization is extremely high on the satellite Deco, tell the customer to move it closer to the main unit.
|
||||
|
||||
### C. Live Speed Test (Is it the Client Device?)
|
||||
```text
|
||||
Device.IP.Diagnostics.DownloadDiagnostics.DiagnosticsState = "Requested"
|
||||
```
|
||||
*Diagnosis:* You can mandate the ONT line to perform its own speed test, eliminating WiFi latency variables. If the ONT download test is fast, but the customer's iPhone is slow, the iPhone or the WiFi signal is to blame.
|
||||
|
||||
---
|
||||
|
||||
## 3. The "Diagnostic Swap" Workflow
|
||||
|
||||
A common gap occurs when techs swap equipment simply because they aren't sure what is defective. This creates inventory chaos.
|
||||
|
||||
**We are pivoting from binary status (`Défectueux`) to a 3-way diagnostic status:**
|
||||
|
||||
1. **Remplacement Définitif** — The equipment is dead.
|
||||
*(Old = Défectueux, New = Actif)*
|
||||
2. **Swap Diagnostic** — Swapping to test if the problem resolves.
|
||||
*(Old = En diagnostic, New = Actif temporary)*
|
||||
3. **Retour de diagnostic** — The old unit was fine. It is returned.
|
||||
*(Old = Actif (put back into use), Test unit = Retourné)*
|
||||
|
||||
If a tech chooses **Swap diagnostic**, an ERPNext Task is automatically generated scheduling a follow-through test on the removed hardware within 7 days. If the unit tests fine at the warehouse, it is restored back into `En inventaire` instead of being trashed.
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
# Customer 360° — Complete Data Map & Business Flows
|
||||
|
||||
## Goal
|
||||
|
||||
Every piece of customer data linked in one place. All business flows documented so they can be:
|
||||
1. Displayed in the ops-app ClientDetailPage
|
||||
2. Driven by natural language ("suspend this customer", "create a quote for fibre 100M", "set up a payment plan for $150/month")
|
||||
3. Automated via rules/triggers
|
||||
|
||||
---
|
||||
|
||||
## Current State — What ClientDetailPage Shows
|
||||
|
||||
| Section | Source | Status |
|
||||
|---------|--------|--------|
|
||||
| Customer header (name, status, group) | Customer | ✅ |
|
||||
| Contact (phone, email, cell) | Customer + Contact | ✅ |
|
||||
| Info card (PPA, commercial, VIP flags) | Customer custom fields | ✅ |
|
||||
| Locations + connection type + OLT | Service Location | ✅ |
|
||||
| Equipment per location (live status, signal, WiFi) | Service Equipment + GenieACS | ✅ |
|
||||
| Subscriptions per location (monthly/annual, price) | Service Subscription | ✅ |
|
||||
| Tickets per location | Issue | ✅ |
|
||||
| All tickets table | Issue | ✅ |
|
||||
| Invoices table | Sales Invoice | ✅ |
|
||||
| Payments table | Payment Entry | ✅ |
|
||||
| Notes (internal memos) | Comment on Customer | ✅ |
|
||||
| Activity timeline (sidebar) | Comment + audit | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Missing Data — To Import & Link
|
||||
|
||||
### 1. Soumissions (Quotes) — 908 records, 529 customers
|
||||
|
||||
```
|
||||
Legacy: soumission
|
||||
id, account_id, name, po, date, tax
|
||||
materiel → PHP serialized array [{sku, desc, amt, qte, tot}, ...]
|
||||
mensuel → PHP serialized array [{sku, desc, amt, qte, tot}, ...]
|
||||
text → terms/notes
|
||||
|
||||
3 templates in soumission_template
|
||||
|
||||
ERPNext target: Quotation
|
||||
party_type = Customer, party = C-{account_id}
|
||||
items[] from deserialized materiel + mensuel
|
||||
terms = text field
|
||||
legacy_soumission_id = id
|
||||
```
|
||||
|
||||
**Flow**: Sales rep creates quote → customer approves → services activated → first invoice generated
|
||||
**Natural language**: "Create a quote for client X for fibre 100M + WiFi router"
|
||||
**UI**: New expandable section in ClientDetailPage between Subscriptions and Tickets
|
||||
|
||||
### 2. Accords de paiement (Payment Arrangements) — 7,283 records, 1,687 customers
|
||||
|
||||
```
|
||||
Legacy: accord_paiement
|
||||
id, account_id, staff_id
|
||||
date_accord → date agreed (unix)
|
||||
date_echeance → due date (unix)
|
||||
date_coupure → cutoff/disconnect date (unix)
|
||||
montant → agreed payment amount
|
||||
method → 0=unset, 1=portal, 2=cheque, 3=phone, 4=PPA
|
||||
status → -1=pending, 0=open, 1=completed
|
||||
note → staff notes ("Par téléphone avec Gen")
|
||||
raison_changement → reason for change
|
||||
|
||||
ERPNext target: Custom DocType "Payment Arrangement"
|
||||
linked_to Customer via party field
|
||||
OR import as structured Comments with type="Payment Arrangement"
|
||||
|
||||
Status breakdown: 5,068 completed | 2,205 open | 10 pending
|
||||
```
|
||||
|
||||
**Flow**: Customer has overdue balance → support calls → negotiates amount + date → cutoff if missed
|
||||
**Natural language**: "Set up a payment plan for client X: $150 by March 26, disconnect on April 2 if missed"
|
||||
**UI**: Badge on CustomerHeader showing active arrangement + section in sidebar
|
||||
|
||||
### 3. Bons de travail (Work Orders) — 14,486 records, 10,110 customers
|
||||
|
||||
```
|
||||
Legacy: bon_travail
|
||||
id, account_id, date (unix)
|
||||
tech1, heure_arrive_t1, heure_depart_t1
|
||||
tech2, heure_arrive_t2, heure_depart_t2
|
||||
note, subtotal, tps, tvq, total
|
||||
|
||||
Legacy: bon_travail_item (only 3 records — barely used)
|
||||
bon_id, product_id, qte, price, desc
|
||||
|
||||
ERPNext target: Linked to Dispatch Job or custom "Work Order" child
|
||||
tech1/tech2 → Employee via staff mapping
|
||||
Time tracking: arrive/depart for labor costing
|
||||
```
|
||||
|
||||
**Flow**: Ticket created → tech dispatched → arrives on site → completes work → bon de travail logged
|
||||
**Natural language**: "Show me all work done at this address" or "How many hours did tech X spend at client Y?"
|
||||
**UI**: Timeline entry on location, or section under Tickets
|
||||
|
||||
### 4. Payment Methods & Tokens — Multi-provider
|
||||
|
||||
Active customer payment breakdown (6,681 active accounts):
|
||||
|
||||
| Payment Method | Count | Notes |
|
||||
|----------------|-------|-------|
|
||||
| No payment method | 5,274 | Manual pay via portal/cheque |
|
||||
| Bank PPA (pre-authorized debit) | 655 | Legacy bank draft via Paysafe/Bambora token |
|
||||
| Stripe card (no auto) | 564 | Card on file, pays manually |
|
||||
| Stripe PPA (auto-charge) | 143 | Stripe auto-recurring |
|
||||
| Bank PPA + Stripe card | 45 | Both methods on file |
|
||||
|
||||
```
|
||||
Legacy: account_profile (658 records — Paysafe/Bambora tokens)
|
||||
account_id, profile_id (UUID), card_id (UUID), token, initial_transaction
|
||||
|
||||
Legacy: account table fields
|
||||
stripe_id → Stripe customer ID (cus_xxx)
|
||||
stripe_ppa → 1 = Stripe auto-charge enabled
|
||||
stripe_ppa_nocc → Stripe PPA without CC
|
||||
ppa → 1 = bank draft PPA enabled
|
||||
ppa_name/code/branch/account → bank info for PPA
|
||||
ppa_amount → PPA amount limit
|
||||
ppa_amount_buffer → buffer above invoice total
|
||||
ppa_fixed → fixed PPA amount
|
||||
ppa_cc → PPA via credit card
|
||||
|
||||
ERPNext target: Custom DocType "Payment Method" linked to Customer
|
||||
type = stripe | paysafe | bank_draft
|
||||
token/profile_id for processor reference
|
||||
is_auto = PPA flag
|
||||
Stripe integration: use stripe_id to pull current payment methods via API
|
||||
```
|
||||
|
||||
**Flow**: Customer adds card (portal or phone) → token stored → PPA auto-charges on invoice
|
||||
**Future**: Stripe as primary, migrate Paysafe tokens → Stripe, bank PPA stays for some
|
||||
**Natural language**: "Is client X set up for auto-pay?" / "Switch client X to Stripe auto-charge"
|
||||
**UI**: Payment method badge on CustomerHeader + card in sidebar
|
||||
|
||||
### 5. VoIP / DID / 911 — Complete Telephone Service
|
||||
|
||||
Three linked tables form one VoIP service per DID:
|
||||
|
||||
| Table | Records | Unique DIDs | Customers | Purpose |
|
||||
|-------|---------|-------------|-----------|---------|
|
||||
| `pbx` | 790 | 790 | 745 | SIP line config (creds, voicemail, routing) |
|
||||
| `phone_addr` | 1,014 | 1,014 | 909 | 911 address (provisioned to 911 provider) |
|
||||
| `phone_provisioning` | 786 | 779 | 739 | Device provisioning (ATA model, MAC, password) |
|
||||
|
||||
All 790 PBX lines have a matching 911 address. 224 additional 911 addresses exist without an active PBX line (decommissioned lines that still have 911 registration).
|
||||
|
||||
```
|
||||
Legacy: pbx (SIP line)
|
||||
account_id, delivery_id, service_id
|
||||
phone (10-digit DID), name (caller ID display)
|
||||
password (SIP auth), vm_password, has_vm, vm_email
|
||||
int_code (extension), language, call_911
|
||||
max_calls, call_timeout, user_context (sip.targo.ca)
|
||||
country_whitelist, date_origin, date_update
|
||||
|
||||
Legacy: phone_addr (911 address — provisioned to external 911 provider)
|
||||
account_id, phone (DID)
|
||||
street_number, apt, street_name, city, state, zip
|
||||
first_name, last_name, info
|
||||
enhanced_capable (Y/N), code_cauca (municipality code), class_service (RES/BUS)
|
||||
|
||||
Legacy: phone_provisioning (ATA/device config)
|
||||
account_id, delivery_id, service_id
|
||||
phone (DID), app (device type: ht502, etc), mac (device MAC)
|
||||
password, internationnal (intl calling flag)
|
||||
|
||||
ERPNext target: Custom DocType "VoIP Line"
|
||||
parent: Service Location (via delivery_id)
|
||||
linked_to: Subscription (via service_id)
|
||||
Fields:
|
||||
did (phone number), caller_id (display name)
|
||||
sip_password, voicemail_enabled, vm_password, vm_email
|
||||
extension, max_calls, call_timeout
|
||||
e911_street, e911_city, e911_state, e911_zip
|
||||
e911_cauca_code, e911_class (RES/BUS)
|
||||
e911_synced (bool — matches 911 provider)
|
||||
ata_model, ata_mac, ata_password
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
Order → provision SIP line in PBX → register 911 address with provider → configure ATA → service active
|
||||
Address change → update 911 with provider (MANDATORY) → verify sync
|
||||
|
||||
**Reports needed**:
|
||||
- **DID Report**: All DIDs → linked customer → service location → 911 address → match status
|
||||
- **911 Audit**: DIDs where service address ≠ 911 address (compliance risk)
|
||||
- **Orphan 911**: 224 addresses with no active PBX line (cleanup or deregister)
|
||||
|
||||
**Natural language**:
|
||||
- "Show all phone lines for client X"
|
||||
- "Update 911 address for 450-272-2408 to 123 Rue Principale"
|
||||
- "List all DIDs with mismatched 911 addresses"
|
||||
- "What's the voicemail password for 450-272-2408?"
|
||||
- "Generate the DID report for all active lines"
|
||||
|
||||
**UI**:
|
||||
- Phone icon in equipment strip per location (click → VoIP detail panel)
|
||||
- 911 status badge (green = synced, red = mismatch or missing)
|
||||
- DID report page accessible from main nav
|
||||
|
||||
### 7. Account Suspension — 1,049 records
|
||||
|
||||
```
|
||||
Legacy: account_suspension
|
||||
account_id, date_start, date_end, note
|
||||
(most records have date_start=0, date_end=0 — just flags)
|
||||
|
||||
ERPNext target: Customer custom field "is_suspended" or Comment log
|
||||
```
|
||||
|
||||
**Flow**: Overdue → auto-suspend → customer pays → reactivate
|
||||
**Natural language**: "Suspend client X" / "Reactivate client X"
|
||||
**UI**: Red badge on CustomerHeader, shown in activity timeline
|
||||
|
||||
### 8. IP History — 20,999 records, 5,769 customers
|
||||
|
||||
```
|
||||
Legacy: ip_history
|
||||
account_id, delivery_id, service_id, ip, date (unix)
|
||||
|
||||
ERPNext target: Comment on Service Location (audit trail)
|
||||
OR custom child table on Service Location
|
||||
```
|
||||
|
||||
**Flow**: Service provisioned → IP assigned → IP changes logged
|
||||
**Natural language**: "What IP did client X have on March 15?"
|
||||
**UI**: Collapsible history under location details
|
||||
|
||||
### 9. Service Snapshots (Bandwidth Usage) — 45,977 records
|
||||
|
||||
```
|
||||
Legacy: service_snapshot
|
||||
account_id, service_id, date (unix)
|
||||
quota_day (bytes), quota_night (bytes)
|
||||
|
||||
ERPNext target: Skip import — historical analytics only
|
||||
Could feed a usage dashboard later
|
||||
```
|
||||
|
||||
### 10. Delivery History (Address Changes) — 16,284 records
|
||||
|
||||
```
|
||||
Legacy: delivery_history
|
||||
account_id, date_orig (unix)
|
||||
address1, address2, city, state, zip
|
||||
(previous addresses before changes)
|
||||
|
||||
ERPNext target: Comment on Customer or Service Location
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Business Flows
|
||||
|
||||
### Flow 1: Sales → Activation
|
||||
|
||||
```
|
||||
Quote created (soumission)
|
||||
↓ Customer approves
|
||||
Quote → Sales Order (or direct to Subscription)
|
||||
↓ Address confirmed
|
||||
Service Location created/selected
|
||||
↓ Equipment assigned
|
||||
Service Equipment linked to location
|
||||
↓ Installation scheduled
|
||||
Dispatch Job (from ticket or bon_travail)
|
||||
↓ Tech completes work
|
||||
Subscriptions activated
|
||||
↓ First billing cycle
|
||||
Sales Invoice generated
|
||||
```
|
||||
|
||||
**Natural language examples:**
|
||||
- "Create a quote for client X: fibre 100M at 123 Rue Principale"
|
||||
- "Convert quote SOQ-908 to active subscriptions"
|
||||
- "Schedule installation for next Tuesday"
|
||||
|
||||
### Flow 2: Billing → Collections
|
||||
|
||||
```
|
||||
Subscription active
|
||||
↓ Monthly/annual cycle
|
||||
Sales Invoice auto-generated
|
||||
↓ PPA active?
|
||||
YES → Auto-charge via payment token (account_profile)
|
||||
NO → Invoice sent (email/portal)
|
||||
↓ Payment received?
|
||||
YES → Payment Entry created, outstanding reduced
|
||||
NO → Overdue
|
||||
↓ Collections process
|
||||
Payment arrangement negotiated (accord_paiement)
|
||||
↓ Cutoff date passed?
|
||||
YES → Account suspended (account_suspension)
|
||||
↓ Payment received
|
||||
Account reactivated
|
||||
```
|
||||
|
||||
**Natural language examples:**
|
||||
- "What does client X owe?"
|
||||
- "Set up a payment plan: $150 by the 15th, cut off on the 20th"
|
||||
- "Suspend client X for non-payment"
|
||||
- "Client X just paid, reactivate their service"
|
||||
|
||||
### Flow 3: Support → Resolution
|
||||
|
||||
```
|
||||
Customer calls / portal ticket
|
||||
↓ Issue created
|
||||
Ticket assigned to department/tech
|
||||
↓ Remote diagnosis?
|
||||
YES → Check equipment live status (GenieACS/TR-069)
|
||||
→ Reboot device, check signal, WiFi clients
|
||||
NO → Schedule field visit
|
||||
↓ Dispatch Job created
|
||||
Tech dispatched → arrives → works
|
||||
↓ Bon de travail logged (tech, hours, parts)
|
||||
Issue resolved → closed
|
||||
↓ Billable?
|
||||
YES → Invoice for labor/parts
|
||||
NO → Done
|
||||
```
|
||||
|
||||
**Natural language examples:**
|
||||
- "Client X has no internet — check their ONT status"
|
||||
- "Send a tech to 123 Rue Principale tomorrow morning"
|
||||
- "How many times have we sent a tech to this address?"
|
||||
|
||||
### Flow 4: Provisioning → Service Management
|
||||
|
||||
```
|
||||
Service ordered
|
||||
↓ Equipment assigned
|
||||
OLT port configured (fibre table)
|
||||
↓ ONT registered
|
||||
GenieACS discovers device → TR-069 parameters set
|
||||
↓ VoIP ordered?
|
||||
YES → PBX line created (pbx table)
|
||||
→ 911 address registered (phone_addr)
|
||||
→ Voicemail configured
|
||||
↓ IP assigned
|
||||
IP logged in ip_history
|
||||
↓ Service active
|
||||
Subscription billing begins
|
||||
```
|
||||
|
||||
**Natural language examples:**
|
||||
- "Provision fibre for client X at port 1/2/3 on OLT-EAST"
|
||||
- "Set up a phone line 819-555-1234 with voicemail"
|
||||
- "What's the 911 address for this line?"
|
||||
|
||||
---
|
||||
|
||||
## ERPNext Data Model — Complete Customer Graph
|
||||
|
||||
```
|
||||
Customer (C-{id})
|
||||
│
|
||||
├── SALES
|
||||
│ ├── Quotation ←── soumission (908) ◄ TO IMPORT
|
||||
│ │ └── Quotation Item (materiel + mensuel)
|
||||
│ └── (future: Sales Order)
|
||||
│
|
||||
├── BILLING
|
||||
│ ├── Sales Invoice (629,935) ✅ DONE
|
||||
│ │ ├── SI Item → income_account (SKU-mapped) ✅ DONE
|
||||
│ │ ├── SI Tax (TPS/TVQ) ✅ DONE
|
||||
│ │ ├── GL Entry (4 per invoice) ✅ DONE
|
||||
│ │ ├── PLE (outstanding tracking) ✅ DONE
|
||||
│ │ └── Comment (invoice notes, 580K) ✅ DONE
|
||||
│ ├── Payment Entry (343,684) ✅ DONE
|
||||
│ │ ├── PE Reference (allocations) ✅ DONE
|
||||
│ │ └── GL Entry (2 per payment) ✅ DONE
|
||||
│ ├── Payment Arrangement ←── accord_paiement ◄ TO IMPORT
|
||||
│ │ (7,283: amount, dates, cutoff, status)
|
||||
│ └── Payment Method (custom doctype) ◄ TO IMPORT
|
||||
│ ├── Stripe (752 customers, 143 auto-PPA)
|
||||
│ │ └── stripe_id → Stripe API for live card info
|
||||
│ ├── Paysafe/Bambora (658 tokens from account_profile)
|
||||
│ │ └── profile_id + card_id + token
|
||||
│ └── Bank PPA (655 with bank account info)
|
||||
│ └── ppa_name, ppa_code, ppa_branch, ppa_account
|
||||
│
|
||||
├── SERVICE
|
||||
│ ├── Service Location (delivery) ✅ DONE
|
||||
│ │ ├── Service Equipment (device) ✅ DONE
|
||||
│ │ │ ├── OLT data (fibre table) ✅ DONE
|
||||
│ │ │ ├── Live status (GenieACS TR-069) ✅ LIVE
|
||||
│ │ │ └── Provisioning data (WiFi/VoIP) ✅ DONE
|
||||
│ │ ├── VoIP Line ←── pbx (790 DIDs) ◄ TO IMPORT
|
||||
│ │ │ ├── 911 Address ←── phone_addr (1,014) ◄ TO IMPORT
|
||||
│ │ │ │ └── Synced to external 911 provider (CAUCA codes)
|
||||
│ │ │ │ └── 224 orphan 911 records (no active PBX line)
|
||||
│ │ │ └── ATA Config ←── phone_provisioning ◄ TO IMPORT
|
||||
│ │ │ (786 records: device type, MAC, SIP creds)
|
||||
│ │ ├── IP History ←── ip_history (20,999) ◄ TO IMPORT
|
||||
│ │ └── Address History ←── delivery_history ○ LOW PRIORITY
|
||||
│ ├── Subscription (active services) ✅ DONE
|
||||
│ │ ├── Subscription Plan ✅ DONE
|
||||
│ │ └── Billing frequency + price ✅ DONE
|
||||
│ └── Suspension Status ←── account_suspension ◄ TO IMPORT
|
||||
│ (1,049 records)
|
||||
│
|
||||
├── SUPPORT
|
||||
│ ├── Issue / Ticket (98,524) ✅ DONE
|
||||
│ │ ├── Communication (ticket messages) ✅ DONE
|
||||
│ │ ├── Dispatch Job ✅ DONE
|
||||
│ │ └── Work Order ←── bon_travail (14,486) ◄ TO IMPORT
|
||||
│ │ (tech, hours, parts, billing)
|
||||
│ └── Comment / Memo (29,245) ✅ DONE
|
||||
│
|
||||
├── ACCOUNT
|
||||
│ ├── Portal User (Website User) ✅ DONE
|
||||
│ │ └── Auth bridge (MD5 → pbkdf2) ✅ DONE
|
||||
│ ├── Stripe ID ✅ DONE
|
||||
│ └── PPA flags + bank info ✅ DONE
|
||||
│
|
||||
└── ANALYTICS (low priority)
|
||||
├── Bandwidth Usage ←── service_snapshot (46K) ○ SKIP
|
||||
├── VoIP CDR ←── voicemeup (96) ○ SKIP
|
||||
└── TV Wizard ←── tele_wiz (1,065) ○ SKIP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Priority & Plan
|
||||
|
||||
### Phase 13: Remaining Customer Data
|
||||
|
||||
| Step | Table | Target | Records | Depends on |
|
||||
|------|-------|--------|---------|------------|
|
||||
| 13a | `soumission` | Quotation | 908 | Customer, Item |
|
||||
| 13b | `accord_paiement` | Payment Arrangement (custom) | 7,283 | Customer |
|
||||
| 13c | `bon_travail` | Work Order log on Issue/Customer | 14,486 | Customer, Employee |
|
||||
| 13d | `account_profile` + `account.*ppa*` + `account.stripe_id` | Payment Method (custom doctype) | 658 + 655 + 752 | Customer |
|
||||
| 13e | `pbx` + `phone_provisioning` | VoIP Line (custom doctype) linked to Location + Subscription | 790 + 786 | Customer, Service Location |
|
||||
| 13f | `phone_addr` | 911 Address fields on VoIP Line + sync status flag | 1,014 | VoIP Line |
|
||||
| 13f.1 | — | **DID Report**: all DIDs → customer → location → 911 address → sync status | report | VoIP Line |
|
||||
| 13f.2 | — | **911 Audit Report**: mismatches + 224 orphan 911 addresses | report | VoIP Line |
|
||||
| 13g | `account_suspension` | Customer.is_suspended flag | 1,049 | Customer |
|
||||
| 13h | `ip_history` | Comment on Service Location | 20,999 | Service Location |
|
||||
|
||||
### Phase 14: UI Sections in ClientDetailPage
|
||||
|
||||
| Section | Position | Data |
|
||||
|---------|----------|------|
|
||||
| Soumissions | After subscriptions, before tickets | Quotation list with status |
|
||||
| Accord de paiement | Badge on header + sidebar card | Active arrangement with countdown |
|
||||
| Bons de travail | Under ticket detail or in timeline | Tech visits with hours |
|
||||
| VoIP | Under equipment strip per location | Phone lines with 911 status |
|
||||
| Suspension | Red banner on header | Active suspension with dates |
|
||||
|
||||
### Phase 15: Natural Language Actions
|
||||
|
||||
Each action maps to an API call on ERPNext or targo-hub:
|
||||
|
||||
| Intent | Action | API |
|
||||
|--------|--------|-----|
|
||||
| "Create a quote for..." | Parse items + customer → POST Quotation | ERPNext API |
|
||||
| "Suspend client X" | Set is_suspended=1, trigger OLT port disable | targo-hub + OLT API |
|
||||
| "Set up payment plan..." | Create Payment Arrangement with dates/amount | ERPNext API |
|
||||
| "What does X owe?" | SUM(outstanding_amount) from Sales Invoice | ERPNext API |
|
||||
| "Send a tech to..." | Create Dispatch Job with location | ERPNext API |
|
||||
| "Check their internet" | GenieACS device status + signal levels | targo-hub |
|
||||
| "Reboot their router" | TR-069 reboot task via GenieACS | targo-hub |
|
||||
| "What IP did X have on..." | Query ip_history or Comment | ERPNext API |
|
||||
| "Add a phone line" | Create VoIP Line + register 911 | PBX API + 911 provider API + targo-hub |
|
||||
| "Update 911 address for..." | Update phone_addr + push to 911 provider | 911 provider API |
|
||||
| "Show DID report" | All DIDs with customer, location, 911 match | ERPNext report |
|
||||
| "List mismatched 911" | DIDs where service addr ≠ 911 addr | ERPNext report |
|
||||
| "Switch client to Stripe" | Create Stripe customer + set PPA flag | Stripe API |
|
||||
| "Is client X on auto-pay?" | Check Payment Method for active PPA | ERPNext API |
|
||||
| "Convert quote to service" | Quotation → Subscriptions + Location setup | ERPNext API |
|
||||
|
||||
---
|
||||
|
||||
## Natural Language Architecture
|
||||
|
||||
```
|
||||
User input (text or voice)
|
||||
↓
|
||||
Intent classifier (LLM)
|
||||
↓ extracts: action, customer, parameters
|
||||
Action router
|
||||
↓ maps intent → API endpoint + payload
|
||||
Confirmation step (show what will change)
|
||||
↓ user approves
|
||||
Execute via targo-hub API
|
||||
↓ returns result
|
||||
Response in natural language
|
||||
```
|
||||
|
||||
Key principle: **every action the UI can do, the NL interface can do**.
|
||||
The ops-app becomes a visual confirmation layer for NL-driven changes.
|
||||
|
|
@ -1,567 +0,0 @@
|
|||
# Complete Customer Flow — From Lead to Live Service
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ CUSTOMER-FACING WEBSITE │
|
||||
│ www.gigafibre.ca │
|
||||
│ │
|
||||
│ [Vérifier la disponibilité] ← Visitor enters address │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Address Fuzzy Search │───▶│ Supabase / Address Server │ │
|
||||
│ │ (pg_trgm) │ │ 5.2M RQA addresses │ │
|
||||
│ └─────────────────────┘ │ + fiber_availability table │ │
|
||||
│ │ └──────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──── AVAILABLE ────┐ ┌──── NOT AVAILABLE ────┐ │
|
||||
│ │ Show plans/pricing │ │ "Pas encore disponible"│ │
|
||||
│ │ Internet speeds │ │ Capture contact info │ │
|
||||
│ │ TV packages │ │ → Lead / Waitlist │ │
|
||||
│ │ Phone service │ └────────────────────────┘ │
|
||||
│ └────────┬───────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──── CHECKOUT ─────┐ │
|
||||
│ │ Summary: Internet │ │
|
||||
│ │ + TV (1st free) │ │
|
||||
│ │ + Phone optional │ │
|
||||
│ │ Choose 3 dates │ │
|
||||
│ │ Stripe payment │ │
|
||||
│ └────────┬───────────┘ │
|
||||
└───────────┼──────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ WEBHOOK / API
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ BACK-OFFICE AUTOMATION │
|
||||
│ │
|
||||
│ n8n / targo-hub │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Create Customer in ERPNext │ │
|
||||
│ │ 2. Create Service Location (address + OLT port) │ │
|
||||
│ │ 3. Create Service Subscriptions (internet/tv/phone) │ │
|
||||
│ │ 4. Create Project "Installation {customer}" │ │
|
||||
│ │ └─ Task: Assign tech + schedule from chosen dates │ │
|
||||
│ │ └─ Task: Prepare equipment (ONT + Deco + STB) │ │
|
||||
│ │ └─ Task: Activate OLT port (VLAN provisioning) │ │
|
||||
│ │ └─ Task: Configure WiFi/VoIP in provisioning DB │ │
|
||||
│ │ 5. Create Dispatch Job (tech assignment) │ │
|
||||
│ │ 6. Send SMS confirmation (Twilio) │ │
|
||||
│ │ 7. Send email confirmation (Mailjet) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ TECHNICIAN ARRIVES
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ FIELD TECH APP │
|
||||
│ │
|
||||
│ 1. Open Dispatch Job → see address, OLT port, equipment list │
|
||||
│ 2. Scan ONT barcode (RCMG/TPLG serial) → creates Service Equipment│
|
||||
│ 3. Scan Deco barcode → links to Service Location │
|
||||
│ 4. Scan STB barcode (if TV) → links to Service Location │
|
||||
│ 5. Connect fiber → ONT powers up → TR-069 INFORM to ACS │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ DEVICE CONNECTS (TR-069 INFORM)
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ ACS (GenieACS → Oktopus) │
|
||||
│ │
|
||||
│ ┌─── ON BOOTSTRAP (first connect) ───┐ │
|
||||
│ │ 1. Identify device (OUI, model, SN) │ │
|
||||
│ │ 2. Match to Service Equipment │ │
|
||||
│ │ (serial tag → ERPNext lookup) │ │
|
||||
│ │ 3. Apply device profile: │ │
|
||||
│ │ - Bridge mode (Raisecom) │ │
|
||||
│ │ - WiFi SSID/pwd (Deco) │ │
|
||||
│ │ - VoIP SIP credentials │ │
|
||||
│ │ - NTP, DSCP, UPnP settings │ │
|
||||
│ │ 4. Push firmware if needed │ │
|
||||
│ │ 5. Notify targo-hub (SSE) │ │
|
||||
│ │ → "Device online" event │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── ON INFORM (periodic) ───┐ │
|
||||
│ │ Refresh: IP, signal, hosts │ │
|
||||
│ │ Check firmware version │ │
|
||||
│ │ Report to monitoring │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ SERVICE LIVE
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ CUSTOMER PORTAL │
|
||||
│ client.gigafibre.ca │
|
||||
│ │
|
||||
│ - View/pay invoices (Stripe QR) │
|
||||
│ - View subscriptions │
|
||||
│ - Change WiFi SSID/password → pushes to ACS │
|
||||
│ - Open support tickets │
|
||||
│ - View service status │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Address Search & Availability
|
||||
|
||||
### Current State
|
||||
- AvailabilityDialog.tsx exists on www.gigafibre.ca
|
||||
- Fuzzy search via `search_addresses()` RPC (pg_trgm on Supabase)
|
||||
- 5.2M RQA addresses loaded
|
||||
- `fiber_availability` table with zone_tarifaire + max_speed
|
||||
- Lead capture sends email to support@targo.ca via Mailjet
|
||||
|
||||
### What Needs to Change
|
||||
|
||||
**Address source**: The `fiber_availability` table needs to be linked to the `fibre` table (legacy). Currently fiber_availability has generic coverage data. It needs the actual OLT port availability:
|
||||
|
||||
```
|
||||
fiber_availability (current)
|
||||
uuidadresse → address → zone_tarifaire + max_speed
|
||||
|
||||
fiber_availability (target)
|
||||
uuidadresse → address → fibre.id → OLT/slot/port → has_available_port (bool)
|
||||
→ max_speed (from OLT capacity)
|
||||
→ zone_tarifaire (pricing zone)
|
||||
```
|
||||
|
||||
**The fibre table IS the availability database:**
|
||||
- 16,056 entries = 16,056 physical fibre drops to premises
|
||||
- 4,949 have an ONT installed (sn populated) = occupied port
|
||||
- 11,107 have no ONT = available for new service (but 1,149 are "boitier_pas_install")
|
||||
- Each entry has: address, OLT IP, frame/slot/port, ontid slot
|
||||
|
||||
**Matching RQA addresses to fibre entries:**
|
||||
- fibre.rue + fibre.ville + fibre.zip → fuzzy match to RQA address
|
||||
- OR fibre.latitude/longitude → nearest RQA address
|
||||
- Once matched: each RQA address either has a fibre entry (available) or not
|
||||
|
||||
### OLT Capacity (30 OLTs, 25 locations)
|
||||
|
||||
| OLT | Location | Total | Connected | Available |
|
||||
|-----|----------|-------|-----------|-----------|
|
||||
| 172.17.16.2 | Saint-Anicet | 2,155 | 844 | 1,311 |
|
||||
| 172.17.32.2 | Hemmingford | 1,742 | 689 | 1,053 |
|
||||
| 172.17.48.2 | Saint-Antoine | 1,383 | 594 | 789 |
|
||||
| 172.17.64.2 | Havelock | 1,347 | 484 | 863 |
|
||||
| 172.17.208.4 | Athelstan | 1,167 | 589 | 578 |
|
||||
| 172.17.112.2 | Saint-Louis | 1,054 | 329 | 725 |
|
||||
| ... | ... | ... | ... | ... |
|
||||
| **Total** | **25 locations** | **16,056** | **4,949** | **~11,000** |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Pricing & Plan Selection
|
||||
|
||||
### Product Catalog (from legacy, combo_ready=1)
|
||||
|
||||
**Internet (Mensualités fibre):**
|
||||
| SKU | Speed ↓/↑ | Price | Profile |
|
||||
|-----|-----------|-------|---------|
|
||||
| FTTH25 | 25/5 Mbps | $49.95 | LP:100 |
|
||||
| FTTH80I | 80/80 Mbps | $79.95 | LP:101 |
|
||||
| FTTH150I | 150/150 Mbps | $99.95 | LP:102 |
|
||||
| FTTH500I | 500/500 Mbps | $99.95 | LP:103 |
|
||||
| FTTH1500I | 1.5G/1G | $109.95 | LP:104 |
|
||||
|
||||
**TV (Mensualités télévision):**
|
||||
| SKU | Name | Price | Note |
|
||||
|-----|------|-------|------|
|
||||
| TVBSKINNY | Skinny | $25 | Base package |
|
||||
| TVBSTANDARD | Standard | $30 | Mid package |
|
||||
| TVBEVO | Evo | $35 | Premium |
|
||||
| TELE | Base TV | $0 | Included with internet? |
|
||||
| + add-ons | Sports, Films, etc. | $5-$25 each | |
|
||||
|
||||
**Phone (Téléphonie):**
|
||||
| SKU | Price | Note |
|
||||
|-----|-------|------|
|
||||
| TELEPMENS | $28.95/mo | Residential SIP line |
|
||||
| SERV911 | $0.69/mo | 911 fee |
|
||||
| ACTTELEP | $69.95 once | Activation |
|
||||
|
||||
**Discounts (combo):**
|
||||
| SKU | Discount | Condition |
|
||||
|-----|----------|-----------|
|
||||
| RAB2X | -$5 | 2 services |
|
||||
| RAB3X | -$10 | 3 services |
|
||||
| RAB4X | -$15 | 4 services |
|
||||
| RAB_X | -$20 | Special |
|
||||
| RAB24M | -$15 | 24-month commitment |
|
||||
| RAB36M | -$15 | 36-month commitment |
|
||||
|
||||
### Checkout Logic
|
||||
|
||||
```
|
||||
Monthly total = Internet plan
|
||||
+ TV package (if selected)
|
||||
+ TV add-ons
|
||||
+ Phone ($28.95 if selected)
|
||||
+ 911 fee ($0.69 if phone)
|
||||
- Combo discount (2x/3x/4x)
|
||||
- Commitment discount (24m/36m)
|
||||
|
||||
One-time charges:
|
||||
+ Installation ($0 for standard FTTH)
|
||||
+ TV box: 1st FREE, 2nd+ at regular price
|
||||
+ Phone activation ($69.95)
|
||||
```
|
||||
|
||||
### First TV Box Credit
|
||||
- Detect: if order includes TV + this is customer's first box
|
||||
- Apply credit equal to STB equipment price
|
||||
- Show on summary: "1er décodeur offert"
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Checkout & Order Submission
|
||||
|
||||
### Customer Input
|
||||
1. **Service selection** (internet speed + TV package + phone yes/no)
|
||||
2. **Address** (pre-filled from availability check)
|
||||
3. **Contact info** (name, email, phone)
|
||||
4. **Installation preference**:
|
||||
- Choose from 3 available dates (next 2 weeks, exclude weekends)
|
||||
- OR "Contactez-moi" (we call to schedule)
|
||||
5. **Payment** (Stripe — card registered but NOT charged)
|
||||
|
||||
### Payment Strategy
|
||||
|
||||
**Website path (self-service)**:
|
||||
- Customer registers their card on Stripe (SetupIntent, not PaymentIntent)
|
||||
- We create a Stripe Customer + attach the payment method
|
||||
- **No charge is taken at checkout** — customer sees: "Aucun frais avant la complétion de l'installation à votre satisfaction"
|
||||
- Payment is collected AFTER installation is confirmed complete by the technician
|
||||
- On installation completion → auto-charge first month + one-time fees via Stripe
|
||||
|
||||
**Agent path (phone order)**:
|
||||
- No payment collected upfront
|
||||
- Agent creates the order and an invoice is generated
|
||||
- Agent can send a **payment link** via email or SMS to the customer:
|
||||
- Stripe Checkout Session or Payment Link → customer pays when ready
|
||||
- SMS via Twilio: "Votre commande est confirmée! Payez ici: {link}"
|
||||
- Email via Mailjet: order summary + payment button
|
||||
- Payment can also be collected on-site by tech or post-installation
|
||||
|
||||
### What Happens on Submit
|
||||
|
||||
```
|
||||
POST /api/checkout
|
||||
{
|
||||
address: { fibre_id, rue, ville, zip, olt_port, lat, lng },
|
||||
customer: { name, email, phone },
|
||||
services: [
|
||||
{ sku: "FTTH150I", type: "internet" },
|
||||
{ sku: "TVBSTANDARD", type: "tv" },
|
||||
{ sku: "TELEPMENS", type: "phone" }
|
||||
],
|
||||
preferred_dates: ["2026-04-07", "2026-04-09", "2026-04-11"],
|
||||
payment: {
|
||||
method: "stripe_setup", // website: card registered, not charged
|
||||
stripe_customer_id: "cus_xxx", // Stripe Customer created
|
||||
stripe_payment_method: "pm_xxx", // Card saved for later charge
|
||||
// OR for agent path:
|
||||
method: "invoice_later", // agent: no payment upfront
|
||||
send_payment_link: "email|sms|both" // optional: send link to customer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Back-Office Automation (n8n + targo-hub)
|
||||
|
||||
### Trigger: New Order Webhook
|
||||
|
||||
```
|
||||
n8n workflow: "New Customer Order"
|
||||
═══════════════════════════════════
|
||||
|
||||
1. VALIDATE
|
||||
- Verify Stripe payment
|
||||
- Verify fibre port still available
|
||||
- Verify address matches fibre.id
|
||||
|
||||
2. CREATE CUSTOMER (ERPNext API)
|
||||
POST /api/resource/Customer
|
||||
{
|
||||
customer_name: "Jean Tremblay",
|
||||
customer_group: "Résidentiel",
|
||||
email_id, mobile_no,
|
||||
legacy_customer_id: null (new customer)
|
||||
}
|
||||
|
||||
3. CREATE SERVICE LOCATION
|
||||
POST /api/resource/Service Location
|
||||
{
|
||||
customer, address_line, city, postal_code,
|
||||
connection_type: "FTTH",
|
||||
olt_name, olt_ip, olt_frame, olt_slot, olt_port, ont_id,
|
||||
status: "Pending Install"
|
||||
}
|
||||
|
||||
4. CREATE SUBSCRIPTIONS (one per service)
|
||||
POST /api/resource/Service Subscription
|
||||
× Internet: { item: FTTH150I, location, status: "Pending" }
|
||||
× TV: { item: TVBSTANDARD, location, status: "Pending" }
|
||||
× Phone: { item: TELEPMENS, location, status: "Pending" }
|
||||
|
||||
5. CREATE SERVICE EQUIPMENT (pre-assigned)
|
||||
× ONT: { type: "ONT", serial: TBD (tech assigns on site) }
|
||||
× Deco: { type: "Routeur", serial: TBD }
|
||||
× STB: { type: "Décodeur TV", serial: TBD } (if TV)
|
||||
× ATA: { type: "Téléphone IP", serial: TBD } (if phone)
|
||||
|
||||
6. CREATE PROJECT "Installation - Jean Tremblay"
|
||||
Tasks:
|
||||
☐ Préparer équipement (support)
|
||||
☐ Assigner technicien (dispatch)
|
||||
☐ Activer port OLT - frame/slot/port (support)
|
||||
☐ Configurer WiFi dans DB provisioning (support)
|
||||
☐ Configurer VoIP dans DB provisioning (if phone)
|
||||
☐ Installation sur site (tech)
|
||||
☐ Vérifier signal ONT (tech)
|
||||
☐ Confirmer service fonctionnel (tech)
|
||||
|
||||
7. CREATE DISPATCH JOB
|
||||
{
|
||||
customer, location,
|
||||
preferred_dates: [...],
|
||||
required_tags: ["Fibre", "Installation"],
|
||||
equipment_list: [ONT, Deco, STB?],
|
||||
notes: "FTTH 150M + TV Standard + Phone"
|
||||
}
|
||||
|
||||
8. RESERVE FIBRE PORT
|
||||
UPDATE fibre SET sn = 'RESERVED-{order_id}'
|
||||
WHERE id = {fibre_id} AND (sn IS NULL OR sn = '')
|
||||
|
||||
9. SEND NOTIFICATIONS
|
||||
× SMS to customer: "Commande confirmée! Installation prévue le..."
|
||||
× Email to customer: order summary
|
||||
× SSE to Ops: new order event
|
||||
× Slack/Teams to dispatch: new installation job
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Installation Day
|
||||
|
||||
### Field Tech App Flow
|
||||
|
||||
```
|
||||
1. Tech opens Dispatch Job on mobile
|
||||
→ Sees: address, customer info, equipment list, OLT port
|
||||
|
||||
2. Tech picks up equipment from warehouse
|
||||
→ Scans each barcode:
|
||||
- ONT: RCMG19E0XXXX → Service Equipment created, linked to location
|
||||
- Deco: 403F8CXXXXXX → Service Equipment created
|
||||
- STB: (ministra MAC) → Service Equipment created
|
||||
|
||||
3. Tech drives to site (GPS tracked via Traccar)
|
||||
→ Customer gets SMS: "Technicien en route"
|
||||
|
||||
4. Tech installs fiber drop + ONT
|
||||
→ ONT powers up → sends TR-069 BOOTSTRAP to ACS
|
||||
|
||||
5. ACS receives BOOTSTRAP
|
||||
→ Matches ONT serial to Service Equipment
|
||||
→ Applies provisioning profile:
|
||||
- Bridge mode, VLANs, DSCP
|
||||
- WiFi SSID/password (via ext script → provisioning DB)
|
||||
- VoIP SIP credentials (if phone service)
|
||||
- Firmware check/upgrade
|
||||
|
||||
6. Tech connects Deco router behind ONT
|
||||
→ Deco sends TR-069 INFORM
|
||||
→ ACS configures WiFi (SSID/password from DB)
|
||||
|
||||
7. Tech verifies:
|
||||
☐ Internet speed test
|
||||
☐ WiFi working on both bands
|
||||
☐ TV channels loading (if applicable)
|
||||
☐ Phone dial tone (if applicable)
|
||||
|
||||
8. Tech marks job complete in app
|
||||
→ Service Location status → "Active"
|
||||
→ Service Subscriptions → "Active"
|
||||
→ Service Equipment status → "Actif"
|
||||
→ Customer gets SMS: "Service activé!"
|
||||
→ First invoice generated (pro-rated)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: ACS Provisioning (GenieACS → Oktopus)
|
||||
|
||||
### Current GenieACS Flow
|
||||
```
|
||||
ONT connects → CWMP INFORM
|
||||
→ Preset matches (by ProductClass tag)
|
||||
→ Provision script runs:
|
||||
1. bootstrap: clear tree, force refresh
|
||||
2. default: refresh hourly params (IP, MAC, hosts, firmware)
|
||||
3. model-specific inform:
|
||||
- HT803G-W: VoIP DSCP, NTP, bridge mode, UPnP off
|
||||
- Device2 (Deco): WiFi from DB (ext→provisioning.js→MariaDB)
|
||||
- XX230v/430v/530v: VoIP digit map, SIP server, DTMF
|
||||
4. firmware-upgrade: auto-push if version mismatch
|
||||
```
|
||||
|
||||
### Target Oktopus Flow
|
||||
```
|
||||
ONT connects → MQTT/CWMP
|
||||
→ Device identified → matched to device group
|
||||
→ Device profile applied:
|
||||
1. Webhook to targo-hub: GET /devices/{serial}/provision
|
||||
→ targo-hub looks up Service Equipment in ERPNext
|
||||
→ Returns: WiFi config, VoIP config, VLAN settings
|
||||
2. Oktopus pushes params to device
|
||||
3. Webhook to targo-hub: POST /devices/{serial}/activated
|
||||
→ targo-hub updates Service Equipment status
|
||||
→ Broadcasts SSE event to Ops UI
|
||||
```
|
||||
|
||||
### n8n Trigger for Device Events
|
||||
```
|
||||
Oktopus → MQTT topic: device/bootstrap/{serial}
|
||||
→ n8n MQTT listener
|
||||
→ n8n workflow:
|
||||
1. Look up serial in ERPNext Service Equipment
|
||||
2. If found: mark "Connected", update IP/firmware
|
||||
3. If NOT found: create Issue "Unknown device {serial}"
|
||||
4. Broadcast to targo-hub SSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Not Available → Lead Funnel
|
||||
|
||||
### When Address Has No Fiber
|
||||
|
||||
```
|
||||
Visitor searches "123 rue Principale, Somewhere"
|
||||
→ No fibre entry found
|
||||
→ Show: "La fibre n'est pas encore disponible à votre adresse"
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Options presented: │
|
||||
│ │
|
||||
│ 📱 Phone service is available anywhere! │
|
||||
│ VoIP works over any internet connection │
|
||||
│ → [Voir les forfaits téléphonie] │
|
||||
│ │
|
||||
│ 🏢 Business fiber? Custom installation! │
|
||||
│ → [Demander une soumission] │
|
||||
│ │
|
||||
│ 📧 Be notified when fiber arrives: │
|
||||
│ [email/phone] → [M'aviser] │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
On "M'aviser" submit:
|
||||
→ Create Lead in ERPNext (name, contact, address, interest)
|
||||
→ Tag with area/municipality for future coverage planning
|
||||
→ n8n: if address is in planned expansion zone, notify sales
|
||||
|
||||
On "Demander une soumission" (business):
|
||||
→ Create Lead with type "Enterprise"
|
||||
→ Create Issue "Soumission fibre entreprise - {address}"
|
||||
→ Assign to sales team
|
||||
→ n8n: send email to sales@targo.ca
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Migration: What Needs to Happen
|
||||
|
||||
### 1. Link Fibre Table to RQA Addresses
|
||||
```sql
|
||||
-- Match fibre entries to RQA addresses by fuzzy address + postal code
|
||||
-- This enables the website availability check to return OLT port info
|
||||
UPDATE fiber_availability fa
|
||||
SET fibre_id = f.id,
|
||||
olt_ip = f.info_connect,
|
||||
olt_port = CONCAT(f.frame, '/', f.slot, '/', f.port),
|
||||
available = (f.sn IS NULL OR f.sn = '')
|
||||
FROM fibre f
|
||||
WHERE fa.address matches f.rue + f.ville (fuzzy)
|
||||
```
|
||||
|
||||
### 2. Migrate Provisioning DB to ERPNext
|
||||
```
|
||||
For each device in genieacs.wifi:
|
||||
→ Find Service Equipment by MAC (Deco) or serial (ONT)
|
||||
→ Store WiFi SSID/password as custom fields on Service Equipment
|
||||
→ Or: keep in a separate provisioning table that targo-hub queries
|
||||
|
||||
For each device in genieacs.voip:
|
||||
→ Find Service Equipment by RCMG serial
|
||||
→ Store SIP username/password as custom fields
|
||||
→ Or: keep in Fonoster/Routr as SIP accounts
|
||||
```
|
||||
|
||||
### 3. Map Legacy Customers to ERPNext
|
||||
```
|
||||
fibre.service_id → service.delivery_id → delivery.account_id
|
||||
→ ERPNext Customer (legacy_customer_id = account_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Systems Inventory
|
||||
|
||||
| System | Role | Current | Target |
|
||||
|--------|------|---------|--------|
|
||||
| **www.gigafibre.ca** | Website + availability | React/Vite + Supabase | Same + checkout flow |
|
||||
| **Supabase** | Address DB + search | 5.2M RQA addresses | + fibre availability link |
|
||||
| **ERPNext** | CRM, billing, inventory | Running | Add provisioning fields |
|
||||
| **targo-hub** | SSE relay + API proxy | SMS, voice, telephony, ACS | + checkout webhook, device events |
|
||||
| **n8n** | Workflow automation | Exists, minimal use | Order→project→dispatch pipeline |
|
||||
| **GenieACS** | TR-069 ACS | Running, 7,550 devices | → Migrate to Oktopus |
|
||||
| **Oktopus** | TR-369 ACS | At oss.gigafibre.ca | Take over from GenieACS |
|
||||
| **Stripe** | Payments | Planned | Checkout + recurring billing |
|
||||
| **Twilio** | SMS/Voice | Running via targo-hub | Order confirmations, tech ETA |
|
||||
| **Mapbox** | Maps | Dispatch app | + coverage map on website? |
|
||||
| **Traccar** | GPS tracking | Running for techs | Continue |
|
||||
| **Fonoster/Routr** | SIP/VoIP | Running | SIP account provisioning |
|
||||
| **MariaDB (10.100.80.100)** | Legacy + provisioning | wifi/voip tables | Migrate to ERPNext or keep |
|
||||
| **MongoDB (10.5.2.116)** | GenieACS device DB | 7,550 devices | Archive after Oktopus migration |
|
||||
|
||||
---
|
||||
|
||||
## Build Order (Recommended)
|
||||
|
||||
### Sprint 1: Data Foundation (1 week)
|
||||
1. Match fibre entries to RQA addresses (geo + fuzzy)
|
||||
2. Expose OLT port availability via Supabase/API
|
||||
3. Migrate product catalog to ERPNext Items (if not already done)
|
||||
4. Create ERPNext provisioning fields on Service Equipment
|
||||
|
||||
### Sprint 2: Checkout Flow (1-2 weeks)
|
||||
1. Build plan selection UI on website (internet + TV + phone)
|
||||
2. Build checkout page (summary, date picker, Stripe)
|
||||
3. Build targo-hub `/checkout` webhook endpoint
|
||||
4. Build n8n "New Order" workflow (customer → location → subscriptions → project)
|
||||
|
||||
### Sprint 3: Dispatch Integration (1 week)
|
||||
1. Auto-create Dispatch Job from new order
|
||||
2. SMS notifications (confirmation, tech en route, completed)
|
||||
3. Field tech app: barcode scanner → Service Equipment creation
|
||||
|
||||
### Sprint 4: Auto-Provisioning (2 weeks)
|
||||
1. targo-hub `/devices/{serial}/provision` endpoint
|
||||
2. Oktopus webhook integration (device connect → provision request)
|
||||
3. WiFi/VoIP credential auto-push on device bootstrap
|
||||
4. "Device online" SSE events to Ops UI
|
||||
|
||||
### Sprint 5: Customer Portal (1-2 weeks)
|
||||
1. Login via email/SMS OTP (no legacy MD5)
|
||||
2. Invoice list + Stripe payment
|
||||
3. WiFi SSID/password change (pushes to ACS)
|
||||
4. Support ticket creation
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
# Data Structure Foundation — Lead to Live Service
|
||||
|
||||
## ERPNext Doctype Relationships
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Lead │ ← Website visitor / phone call
|
||||
│ (new type) │ Not yet a customer
|
||||
└──────┬───────┘
|
||||
│ Convert to Customer
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Customer │
|
||||
│ name, email, phone, customer_group (Résidentiel/Comm.) │
|
||||
│ stripe_id, legacy_customer_id │
|
||||
└──────────┬─────────────────────────┬──────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────────┐
|
||||
│ Service Location │ │ Sales Invoice │
|
||||
│ (delivery address) │ │ Payment Entry │
|
||||
│ │ │ (billing) │
|
||||
│ address, city, zip │ └─────────────────────────┘
|
||||
│ lat, lng │
|
||||
│ connection_type │
|
||||
│ olt_name, olt_ip │
|
||||
│ frame/slot/port │
|
||||
│ ont_id │
|
||||
│ status: Pending → │
|
||||
│ Active │
|
||||
│ fibre_id (legacy) │
|
||||
└──────┬──────────────┘
|
||||
│
|
||||
├──────────────────────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────────┐
|
||||
│ Service │ │ Service Equipment │
|
||||
│ Subscription │ │ │
|
||||
│ │ │ serial_number │
|
||||
│ item (product) │ │ mac_address │
|
||||
│ status: Pending │ │ equipment_type │
|
||||
│ → Active │ │ brand, model │
|
||||
│ start_date │ │ status: En inventaire│
|
||||
│ billing_interval │ │ → Actif │
|
||||
│ rate (monthly $) │ │ olt_* fields │
|
||||
└──────────────────┘ │ wifi_ssid (new) │
|
||||
│ wifi_password (new) │
|
||||
│ sip_username (new) │
|
||||
│ sip_password (new) │
|
||||
└──────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Project │
|
||||
│ "Installation - {customer_name}" │
|
||||
│ customer, service_location │
|
||||
│ status: Open → Completed │
|
||||
│ │
|
||||
│ ┌─── Task ────────────────────────────────┐ │
|
||||
│ │ "Préparer équipement" │ support │ │
|
||||
│ │ "Activer port OLT" │ support │ │
|
||||
│ │ "Configurer WiFi/VoIP" │ support │ │
|
||||
│ │ "Installation sur site" │ tech │ │
|
||||
│ │ "Vérifier signal" │ tech │ │
|
||||
│ │ "Confirmer service" │ tech │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Dispatch Job │
|
||||
│ customer, service_location │
|
||||
│ project (link to Installation project) │
|
||||
│ scheduled_date, preferred_dates │
|
||||
│ assigned_technician │
|
||||
│ required_tags: [Fibre, Installation] │
|
||||
│ equipment_list (child table) │
|
||||
│ status: Scheduled → In Progress → Completed │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Issue (Ticket) │
|
||||
│ customer, service_location │
|
||||
│ subject, description │
|
||||
│ issue_type: Installation, Support, Réparation │
|
||||
│ status: Open → Resolved → Closed │
|
||||
│ linked_project (new field) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Doctype: Installation Order (or use Lead + Project)
|
||||
|
||||
Rather than creating a new doctype, we use ERPNext's existing **Lead** → **Customer** conversion + **Project** with tasks. The wizard creates everything in one shot.
|
||||
|
||||
### Option A: Use ERPNext Lead Doctype
|
||||
|
||||
```
|
||||
Lead (ERPNext built-in)
|
||||
lead_name: "Jean Tremblay"
|
||||
email_id, phone
|
||||
source: "Website" | "Phone" | "Walk-in"
|
||||
status: "Lead" → "Opportunity" → "Converted" → "Do Not Contact"
|
||||
|
||||
Custom fields:
|
||||
address_line, city, postal_code
|
||||
fibre_id (link to fibre table entry)
|
||||
olt_port (frame/slot/port)
|
||||
requested_services: ["Internet", "TV", "Phone"]
|
||||
internet_plan: "FTTH150I"
|
||||
tv_package: "TVBSTANDARD"
|
||||
phone: Check
|
||||
preferred_date_1, preferred_date_2, preferred_date_3
|
||||
stripe_payment_intent
|
||||
notes
|
||||
```
|
||||
|
||||
### Option B: Direct Customer Creation (simpler)
|
||||
|
||||
Skip Lead, create Customer directly on checkout. Use a Project as the "order" container.
|
||||
|
||||
**Recommended: Option B** — for self-service checkout, the customer already paid. No need for a Lead stage. For phone orders, the agent creates the customer directly too.
|
||||
|
||||
---
|
||||
|
||||
## The Wizard Flow (Website or Agent)
|
||||
|
||||
### Step 1: Address Check
|
||||
```
|
||||
Input: address string
|
||||
Output: fibre entry (id, olt_port, max_speed, zone_tarifaire)
|
||||
OR "not available" → lead capture
|
||||
```
|
||||
|
||||
### Step 2: Plan Selection
|
||||
```
|
||||
Input: fibre zone_tarifaire
|
||||
Output: available plans with pricing
|
||||
- Internet: filtered by max_speed and zone
|
||||
- TV: always available if internet selected
|
||||
- Phone: always available (even without fiber)
|
||||
- Discounts: auto-calculated (2x, 3x, 4x combo)
|
||||
```
|
||||
|
||||
### Step 3: Customer Info
|
||||
```
|
||||
Input: name, email, phone
|
||||
preferred installation dates (3 choices)
|
||||
OR "contactez-moi"
|
||||
Output: customer data ready for creation
|
||||
```
|
||||
|
||||
### Step 4: Payment (Stripe)
|
||||
```
|
||||
WEBSITE PATH:
|
||||
Input: card details (Stripe Elements)
|
||||
Output: SetupIntent confirmed — card SAVED, NOT charged
|
||||
Stripe Customer + PaymentMethod created
|
||||
Customer sees: "Aucun frais avant la complétion de l'installation"
|
||||
Charge triggered AFTER tech confirms installation complete
|
||||
|
||||
AGENT PATH:
|
||||
No payment collected upfront
|
||||
Invoice generated → agent sends payment link via email/SMS
|
||||
POST /api/send-payment-link
|
||||
{ customer_id, method: "email|sms|both" }
|
||||
→ Creates Stripe Payment Link or Checkout Session
|
||||
→ Sends via Twilio SMS / Mailjet email
|
||||
```
|
||||
|
||||
### Step 5: Order Creation (atomic — all or nothing)
|
||||
```
|
||||
Creates in ERPNext (via targo-hub or n8n):
|
||||
|
||||
1. Customer
|
||||
{ customer_name, email, phone, customer_group }
|
||||
|
||||
2. Service Location
|
||||
{ customer, address, olt_port, fibre_id, status: "Pending Install" }
|
||||
|
||||
3. Service Subscriptions (one per service)
|
||||
{ customer, location, item: FTTH150I, status: "Pending" }
|
||||
{ customer, location, item: TVBSTANDARD, status: "Pending" }
|
||||
{ customer, location, item: TELEPMENS, status: "Pending" }
|
||||
|
||||
4. Service Equipment (placeholders — serials TBD by tech)
|
||||
{ customer, location, type: "ONT", status: "En inventaire" }
|
||||
{ customer, location, type: "Routeur", status: "En inventaire" }
|
||||
{ customer, location, type: "Décodeur TV", status: "En inventaire" } (if TV)
|
||||
|
||||
5. Project "Installation - Jean Tremblay"
|
||||
{ customer, service_location, status: "Open" }
|
||||
Tasks:
|
||||
- Préparer équipement (assigné: support)
|
||||
- Activer port OLT {frame/slot/port} (assigné: support)
|
||||
- Configurer WiFi (assigné: support, auto-possible)
|
||||
- Configurer VoIP (if phone, assigné: support)
|
||||
- Installation sur site (assigné: tech via dispatch)
|
||||
- Vérifier signal ONT (assigné: tech)
|
||||
- Confirmer service fonctionnel (assigné: tech)
|
||||
|
||||
6. Dispatch Job
|
||||
{ customer, location, project, scheduled_date: preferred_date_1,
|
||||
tags: [Fibre, Installation], status: "Scheduled" }
|
||||
|
||||
7. Reserve Fibre Port
|
||||
UPDATE fibre SET sn = 'RESERVED-{customer_id}' WHERE id = {fibre_id}
|
||||
|
||||
8. Notifications
|
||||
- SMS to customer (Twilio): "Commande confirmée!"
|
||||
- Email to customer (Mailjet): order summary
|
||||
- SSE to Ops: new-installation event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provisioning Data on Service Equipment
|
||||
|
||||
### New Custom Fields Needed
|
||||
|
||||
```python
|
||||
# On Service Equipment doctype, add:
|
||||
|
||||
# WiFi provisioning (for Deco routers)
|
||||
custom_wifi_ssid = Data, label="WiFi SSID"
|
||||
custom_wifi_password = Password, label="WiFi Password"
|
||||
custom_wifi_ssid_5g = Data, label="WiFi SSID 5GHz" (if different)
|
||||
custom_wifi_enabled = Check, label="WiFi Enabled", default=1
|
||||
|
||||
# VoIP provisioning (for ONTs with phone)
|
||||
custom_sip_username = Data, label="SIP Username"
|
||||
custom_sip_password = Password, label="SIP Password"
|
||||
custom_sip_line = Int, label="SIP Line Number", default=1
|
||||
|
||||
# GPON provisioning
|
||||
custom_gpon_serial = Data, label="GPON Serial" (physical sticker: RCMG/TPLG)
|
||||
custom_cwmp_serial = Data, label="CWMP Serial" (GenieACS internal)
|
||||
custom_fibre_line_profile = Data, label="Line Profile ID"
|
||||
custom_fibre_service_profile = Data, label="Service Profile ID"
|
||||
|
||||
# RADIUS (for PPPoE if applicable)
|
||||
custom_radius_user = Data, label="RADIUS Username"
|
||||
custom_radius_password = Password, label="RADIUS Password"
|
||||
```
|
||||
|
||||
### WiFi Generation on Order
|
||||
```
|
||||
When order is created:
|
||||
1. Generate WiFi SSID: "Gigafibre-{lastname}" or customer-chosen
|
||||
2. Generate WiFi password: random 12-char alphanumeric
|
||||
3. Store on Service Equipment (Routeur type)
|
||||
4. When tech installs Deco → it bootstraps → ACS reads WiFi from ERPNext
|
||||
via targo-hub: GET /devices/{mac}/provision → returns SSID/password
|
||||
```
|
||||
|
||||
### VoIP Provisioning on Order
|
||||
```
|
||||
When phone service ordered:
|
||||
1. Allocate SIP account from Fonoster/Routr
|
||||
POST /telephony/credentials → creates SIP user
|
||||
2. Store SIP username/password on Service Equipment (ONT type)
|
||||
3. When tech installs ONT → it bootstraps → ACS reads VoIP from ERPNext
|
||||
via targo-hub: GET /devices/{serial}/provision → returns SIP creds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fibre Availability: Linking to RQA Addresses
|
||||
|
||||
### Current State
|
||||
- `fiber_availability` table in Supabase: uuidadresse → zone_tarifaire + max_speed
|
||||
- This table was imported from a different source than the legacy `fibre` table
|
||||
- They need to be linked
|
||||
|
||||
### Target
|
||||
```sql
|
||||
-- Add fibre_id column to fiber_availability
|
||||
ALTER TABLE fiber_availability ADD COLUMN fibre_id INTEGER;
|
||||
ALTER TABLE fiber_availability ADD COLUMN olt_ip VARCHAR(64);
|
||||
ALTER TABLE fiber_availability ADD COLUMN olt_port VARCHAR(16); -- "0/3/2"
|
||||
ALTER TABLE fiber_availability ADD COLUMN ont_slot INTEGER; -- available ontid
|
||||
ALTER TABLE fiber_availability ADD COLUMN port_available BOOLEAN DEFAULT true;
|
||||
|
||||
-- Match fibre entries to RQA addresses
|
||||
-- Strategy: postal code + street name fuzzy match + civic number
|
||||
UPDATE fiber_availability fa
|
||||
SET fibre_id = f.id,
|
||||
olt_ip = f.info_connect,
|
||||
olt_port = CONCAT(f.frame, '/', f.slot, '/', f.port),
|
||||
port_available = (f.sn IS NULL OR f.sn = '' OR f.sn LIKE 'RESERVED%')
|
||||
FROM fibre f
|
||||
JOIN addresses a ON a.identifiant_unique_adresse = fa.uuidadresse
|
||||
WHERE similarity(lower(f.rue), lower(a.odonyme_recompose_normal)) > 0.5
|
||||
AND lower(f.ville) = lower(a.nom_municipalite)
|
||||
AND f.zip = a.code_postal;
|
||||
```
|
||||
|
||||
### Alternative: Serve Fibre Data Directly
|
||||
Instead of linking to Supabase, have targo-hub serve a `/availability` endpoint that queries the legacy fibre table directly:
|
||||
|
||||
```
|
||||
GET /availability?q=123+rue+principale+saint-anicet
|
||||
|
||||
1. Fuzzy search RQA addresses (Supabase/pg_trgm)
|
||||
2. For matched address, query fibre table:
|
||||
SELECT * FROM fibre
|
||||
WHERE rue LIKE '%principale%' AND ville LIKE '%anicet%'
|
||||
AND (sn IS NULL OR sn = '') -- available port
|
||||
3. Return: { available, max_speed, olt_port, zone_tarifaire, fibre_id }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent Phone Order Flow
|
||||
|
||||
Same data structure, different entry point:
|
||||
|
||||
```
|
||||
Agent in Ops app:
|
||||
1. Search customer (existing) or click "Nouveau client"
|
||||
2. Enter address → same availability check
|
||||
3. Select services → same plan picker
|
||||
4. Choose dates → same date picker
|
||||
5. Click "Créer commande"
|
||||
→ Same API call as website checkout
|
||||
→ Same n8n/targo-hub workflow
|
||||
→ Same Project + Tasks + Dispatch Job creation
|
||||
|
||||
Difference: no Stripe payment upfront
|
||||
→ Invoice generated, customer pays later or agent takes CC over phone
|
||||
```
|
||||
|
||||
### Ops App: New "Nouvelle installation" Wizard
|
||||
```
|
||||
Route: /ops/new-installation
|
||||
Components:
|
||||
Step1_AddressCheck.vue — address search + availability
|
||||
Step2_PlanSelection.vue — internet/tv/phone picker + pricing
|
||||
Step3_CustomerInfo.vue — name/email/phone (or existing customer)
|
||||
Step4_DateSelection.vue — preferred dates calendar
|
||||
Step5_Summary.vue — review + confirm
|
||||
|
||||
On confirm → POST /api/checkout (same endpoint as website)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration: Legacy Data → ERPNext
|
||||
|
||||
### Already Migrated
|
||||
- ✅ Customers (account → Customer)
|
||||
- ✅ Service Locations (delivery → Service Location)
|
||||
- ✅ Service Subscriptions (service → Service Subscription)
|
||||
- ✅ Service Equipment (device → Service Equipment)
|
||||
- ✅ Tickets (ticket → Issue)
|
||||
- ✅ Invoices (invoice → Sales Invoice)
|
||||
- ✅ OLT data on equipment (fibre → custom fields)
|
||||
|
||||
### Still Needed
|
||||
1. **WiFi provisioning data** → Service Equipment custom fields
|
||||
- genieacs.wifi (1,713 entries, 858 unique Deco MACs)
|
||||
- Match: wifi.serial (Deco MAC) → device.mac → Service Equipment.mac_address
|
||||
|
||||
2. **VoIP provisioning data** → Service Equipment custom fields
|
||||
- genieacs.voip (797 entries, 469 unique RCMG serials)
|
||||
- Match: voip.serial (RCMG) → device.sn → Service Equipment.serial_number
|
||||
|
||||
3. **RADIUS credentials** → Service Equipment or Service Subscription
|
||||
- service.radius_user + service.radius_pwd
|
||||
- Match: service.device_id → device.sn → Service Equipment
|
||||
|
||||
4. **Product catalog** → ERPNext Item (if not already done)
|
||||
- 100+ legacy products with SKU, pricing, speed tiers
|
||||
- fibre_lineprofile + fibre_serviceprofile for OLT provisioning
|
||||
|
||||
5. **Fibre → RQA address link** for availability search
|
||||
- 16,056 fibre entries need matching to 5.2M RQA addresses
|
||||
- Strategy: postal code + street similarity + civic number
|
||||
|
||||
---
|
||||
|
||||
## Build Priority
|
||||
|
||||
### Week 1: Data Foundation
|
||||
1. Add provisioning custom fields to Service Equipment
|
||||
2. Migrate WiFi/VoIP/RADIUS data to those fields
|
||||
3. Match fibre entries to RQA addresses
|
||||
4. Expose availability API (fibre port check)
|
||||
|
||||
### Week 2: Order Wizard
|
||||
1. Build /ops/new-installation wizard in Ops app
|
||||
2. Build targo-hub /checkout endpoint
|
||||
3. Build n8n "New Order" workflow
|
||||
4. Auto-create: Customer → Location → Subscriptions → Equipment → Project → Tasks → Dispatch Job
|
||||
|
||||
### Week 3: Website Checkout
|
||||
1. Upgrade AvailabilityDialog → full checkout flow
|
||||
2. Stripe integration
|
||||
3. Same /checkout endpoint as Ops wizard
|
||||
4. SMS/email confirmations
|
||||
|
||||
### Week 4: Auto-Provisioning
|
||||
1. targo-hub /devices/{serial}/provision endpoint
|
||||
2. ACS webhook on device bootstrap → reads ERPNext
|
||||
3. WiFi/VoIP auto-push from Service Equipment fields
|
||||
4. "Device online" notifications
|
||||
81
docs/DATA_AND_FLOWS.md
Normal file
81
docs/DATA_AND_FLOWS.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Gigafibre FSM — Data Structures & Customer Flows
|
||||
|
||||
> A unified document defining the ERPNext data structure and the "Lead to Live" customer flow architecture.
|
||||
|
||||
## 1. ERPNext Data Schema
|
||||
|
||||
All operational data revolves around the Customer, connected closely to their physical service location and the equipment provided.
|
||||
|
||||
```text
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Customer │
|
||||
│ customer_group (Résidentiel/Comm.), legacy_customer_id │
|
||||
└──────────┬─────────────────────────┬──────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────────┐
|
||||
│ Service Location │ │ Sales Invoice │
|
||||
│ (delivery address) │ │ Payment Entry │
|
||||
│ connection/olt_port│ └─────────────────────────┘
|
||||
└──────┬──────────────┘
|
||||
│
|
||||
├──────────────────────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────────┐
|
||||
│ Service │ │ Service Equipment │
|
||||
│ Subscription │ │ (ONT/Routeur/TV) │
|
||||
│ (Internet/TV) │ │ mac/serial/wifi/sip │
|
||||
└──────────────────┘ └──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Dispatch Job │
|
||||
│ (Tech installs equipment at Service Location) │
|
||||
│ Status: Scheduled → In Progress → Completed │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The Customer Journey (Lead to Live)
|
||||
|
||||
This flow replaces the legacy PHP quote builder. It operates atomically to ensure equipment and ports are correctly reserved.
|
||||
|
||||
### Step 1: Address Qualification
|
||||
- Website hits `/api/address`.
|
||||
- Queries the `fiber_availability` table (coupled with pg_trgm fuzzy matching against RQA databases).
|
||||
- Identifies the specific `fibre_id` and checks port availability in real time.
|
||||
|
||||
### Step 2: Plan & Price Selection
|
||||
- Prices dynamically populate based on the `zone_tarifaire` tied to the OLT Port.
|
||||
|
||||
### Step 3: Payment Intent (Stripe)
|
||||
- Stripe Elements capture CC info for a SetupIntent.
|
||||
- We **do not** charge the card until the Dispatch Job is confirmed by the technician.
|
||||
|
||||
### Step 4: Atomic Order Creation
|
||||
Through `targo-hub` or `n8n`, a successful sign-up generates exactly:
|
||||
1. `Customer` (Jane Doe)
|
||||
2. `Service Location` (Address, linked to OLT Port)
|
||||
3. `Service Subscription` (Internet Plan, TV Package)
|
||||
4. `Service Equipment` (Empty templates waiting for tech to scan serials)
|
||||
5. `Dispatch Job` (Triggered for Installation, waiting for tech assignment)
|
||||
|
||||
### Step 5: Hardware Auto-Provisioning
|
||||
1. When the order is generated, custom fields on `Service Equipment` (like `custom_wifi_ssid` and `custom_sip_username`) are prefilled in ERPNext.
|
||||
2. The technician arrives, installs the TP-Link ONT, and scans its serial/MAC.
|
||||
3. The ONT boots up and hits the TR-369 server (Oktopus).
|
||||
4. `targo-hub` acts as the TR-069 proxy, matching the MAC to ERPNext, reading the custom provisioning profile, and automatically injecting the WiFi and SIP credentials onto the modem.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ops App "Client 360" View
|
||||
|
||||
The `ClientDetailPage` in the Ops App acts as a unified console pulling from these relationships.
|
||||
|
||||
**It displays:**
|
||||
- Standard Frappe Customer metadata + Custom Flags (PPA Enabled).
|
||||
- A map and grid of the `Service Location`.
|
||||
- Live diagnostic queries to `targo-hub` providing real-time data on the linked `Service Equipment`.
|
||||
- The historical thread of `Issue` and `Dispatch Job` events.
|
||||
- Inline Odoo-style editing for rapid support.
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# Gigafibre FSM — Design Guidelines
|
||||
|
||||
This document outlines the standard design principles and conventions for adding new features and modules to the Gigafibre FSM (Field Service Management) application. Adhering to these guidelines ensures scalability, maintainability, and highly efficient AI-assisted development (by keeping context windows small and token usage low).
|
||||
|
||||
## 1. Modular Architecture (Feature-Sliced Design)
|
||||
|
||||
To avoid a monolithic `src/` folder that overwhelms both developers and AI tools, organize code by **feature** rather than strictly by technical type.
|
||||
|
||||
**Do Not Do This (Technical Grouping):**
|
||||
```text
|
||||
src/
|
||||
components/ (contains dispatch, inventory, and customer components all mixed together)
|
||||
store/ (one massive pinia store for everything)
|
||||
api/ (one 2000-line api.js file)
|
||||
```
|
||||
|
||||
**Do This (Feature Grouping):**
|
||||
```text
|
||||
src/
|
||||
features/
|
||||
dispatch/
|
||||
components/
|
||||
store.ts
|
||||
api.ts
|
||||
types.ts
|
||||
equipment/
|
||||
components/
|
||||
store.ts
|
||||
api.ts
|
||||
types.ts
|
||||
shared/
|
||||
ui/ (generic buttons, dialogs)
|
||||
utils/
|
||||
```
|
||||
*Why?* When you need AI to build a new dispatch feature, you only need to feed it the `features/dispatch/` folder, drastically reducing token usage and hallucination.
|
||||
|
||||
## 2. API & ERPNext Abstraction
|
||||
|
||||
Never make raw API calls (`axios.get`) directly inside a Vue component.
|
||||
* All ERPNext interactions must go through a dedicated API service file (`features/{module}/api.ts`).
|
||||
* **Rule:** Vue components should only dispatch actions (via Pinia) or call cleanly abstracted service functions. They should not care about Frappe endpoints or REST wrappers.
|
||||
|
||||
## 3. UI Component Standardization (Quasar)
|
||||
|
||||
* **Composition API:** Use Vue 3 `<script setup>` syntax universally. Avoid Options API entirely.
|
||||
* **Component Size Limit:** If a `.vue` file exceeds **250 lines**, it must be split. Extract complex tables, modal dialogs, or forms into their own sub-components.
|
||||
* **Dumb vs. Smart Components:**
|
||||
* *Smart Components (Pages):* Handle Pinia state, fetch data from the API, and pass variables down.
|
||||
* *Dumb Components (UI elements):* Only accept `props` and emit `events`. They do not fetch their own data.
|
||||
|
||||
## 4. State Management (Pinia)
|
||||
|
||||
* Use one Pinia store per domain/module (e.g., `useDispatchStore`, `useEquipmentStore`).
|
||||
* **Do not store UI state in Pinia** (like "isTheSidebarOpen"). Pinia is for caching ERPNext data locally (Jobs, Technicians, Inventory). Use local `ref()` for UI toggles.
|
||||
|
||||
## 5. Standardizing Frappe/ERPNext Backend Additions
|
||||
|
||||
When creating a new custom Doctype or Python module in ERPNext for the FSM:
|
||||
1. **Naming Convention:** Prefix system-specific Doctypes with logical boundaries if needed, but rely on standard Frappe naming (e.g., `Dispatch Job`, `Service Location`).
|
||||
2. **Controller Logic:** Keep Python hooks small. If a `before_save` hook exceeds 50 lines, abstract the logic into a separate Python utility file.
|
||||
3. **API Endpoints:** Use `@frappe.whitelist()` cleanly. Validate permissions explicitly inside the whitelisted function before returning data to the Vue app.
|
||||
|
||||
## 6. AI & Token Context Optimization
|
||||
|
||||
To ensure AI (Claude/Gemini) can easily understand and edit this project in the future:
|
||||
* **Avoid "God Objects":** Keep configurations, massive constant arrays, or lookup dictionaries in separate `constants.ts` files so they don't bloat the context window of standard logical files.
|
||||
* **Strict Typing:** Use TypeScript interfaces (`types.ts`) aggressively. If the AI can read your interfaces, it immediately understands your entire data model without needing to look at your database backend.
|
||||
60
docs/ERPNEXT_ITEM_DIFF_VS_LEGACY.md
Normal file
60
docs/ERPNEXT_ITEM_DIFF_VS_LEGACY.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# ERPNext Item Master — Diff vs Legacy `gestionclient`
|
||||
|
||||
Tracks every **new Item** created in ERPNext that does **not** exist (by SKU or
|
||||
semantic concept) in the legacy `gestionclient.product` table. Each entry lists
|
||||
the rationale, so the migration audit trail stays intact as we re-model pricing
|
||||
in the wizard.
|
||||
|
||||
Conventions:
|
||||
- **Reused** = SKU matches legacy 1:1 (already imported in bulk).
|
||||
- **Semantic reuse** = different SKU in wizard, but a legacy SKU covers the same
|
||||
concept — wizard-constants points at the legacy SKU.
|
||||
- **New** = ERPNext-only SKU, no legacy equivalent.
|
||||
|
||||
---
|
||||
|
||||
## New Items (wizard-generated lines with no legacy counterpart)
|
||||
|
||||
| ERPNext SKU | Name | Rate | Group | Why new |
|
||||
|---|---|---|---|---|
|
||||
| `TV-BASE` | Télévision de base | 19.95 $ | Télévision | Wizard simplifies legacy `TVBSKINNY`/`TVBSTANDARD`/`TVBEVO` (25/30/35 $) into a single flat base. |
|
||||
| `TV-MIX5` | Bonus Mix 5 chaînes | 10.00 $ | Télévision | Positive-rate addon (vs legacy `RABTV_MIX5` rebate at 0 $). Wizard flattens "pick 5 thematic forfaits + get rebate" into a single "+10 $ for 5 picks" line. |
|
||||
| `TV-MIX10` | Bonus Mix 10 chaînes | 17.50 $ | Télévision | Same pattern as TV-MIX5 — replaces legacy picking N TVF* packs + `RABTV_MIX10`. |
|
||||
| `TV-PREMIUM-SUR` | Supplément chaîne premium | 3.00 $ | Télévision | Wizard surcharge per premium sport pick (RDS, TSN, Sportsnet, TVA Sports). Emitted as a line per premium group. No legacy equivalent — legacy had thematic forfaits at 20 $ (TVFSPORTFR/EN) instead. |
|
||||
| `TV-ALC-OVER` | Chaînes additionnelles (à la carte) | 0.00 $ (rate computed per line) | Télévision | Overage line when channel picks exceed the Mix allotment. No legacy equivalent. |
|
||||
| `REF-CREDIT-50` | Crédit parrainage | -50.00 $ | Rabais | One-time credit when a valid referral code is entered. Legacy used `CRPROGREC` (-21.74 $) with a different program. *Pre-existed in ERPNext.* |
|
||||
| `EQ-WIFI-BOOST` | Booster WiFi (maillage) | 5.00 $ | Équipement | Mesh booster — no legacy equivalent (legacy sold UniFi APs at 160–316 $, not a monthly rental). |
|
||||
| `RAB-LOYAUTE` | Rabais Fidélité | -40.00 $ | Rabais | Flat fidélité rebate on FTTH80I to reach 39.95 $ promo. Legacy used `RAB_X` (-20 $) + other flags — wizard centralizes. |
|
||||
| `FEE-INSTALL` | Frais d'installation | 99.95 $ | Frais | Flat install fee. Legacy model was `INSTFIBRES` 199 $ + `RABINS` -199 $ = 0 $ net with 24mo contract. Wizard simplifies. |
|
||||
| `FEE-EXTRA` | Frais supplémentaire — installation | 0.00 $ (rate per line) | Frais | Per-step extra fees (émondage, creusage, etc.) from `EXTRA_FEE_PRESETS`. |
|
||||
|
||||
## Semantic reuse — wizard points at legacy SKUs
|
||||
|
||||
| Wizard concept | Legacy SKU reused | Rate | Notes |
|
||||
|---|---|---|---|
|
||||
| À la carte TV | `TELE_CARTE` | 0 $ | Legacy description: « Télévision à la carte » — exact match. Wizard uses this as the base for à-la-carte selections. |
|
||||
| Téléphonie illimitée CA/US | `TELEPMENS` | 28.95 $ | Legacy description: « Téléphonie IP, options toutes incluses, Canada et É-U illimité ». Bundle discount (to reach ~10 $/mo) is applied manually, not auto-generated. |
|
||||
| Portabilité téléphonique | `TELEPTRANS` | 40.00 $ | Legacy: « Transfert de numéro ». Wizard references only if port-in is requested. |
|
||||
| Rabais combo 2 / 3 / 4 services | `RAB2X` / `RAB3X` / `RAB4X` | -5 / -10 / -15 $ | **Added manually** at the sales rep's discretion — no longer auto-inserted by the wizard. |
|
||||
| Rabais engagement 24 mois | `RAB24M` | -15 $ | Legacy-only; reused directly. |
|
||||
|
||||
## Reused 1:1 (no diff)
|
||||
|
||||
- All `FTTH*I` SKUs (80, 150, 500, 1500, 3500, 8000)
|
||||
- All `FTTB*I` SKUs (25, 50, 100, 300, 1000)
|
||||
- All `TVF*` thematic forfaits (CRAVE, STARZ, SPORTEN, SPORTFR, SE, DECOU, STYLE, FILM, JEUNE, etc.)
|
||||
- All `TVB*` base packages (SKINNY, STANDARD, EVO)
|
||||
- Rebates: `RABTV_MIX5`, `RABTV_MIX10`, `RAB2X`, `RAB3X`, `RAB4X`, `RAB24M`, `RAB36M`, `RAB_X`, `RABINS`
|
||||
- Fees: `ACTTELEP`, `INSTFIBRES`, `INSTFCAMP`, `CRINSTFIB`
|
||||
- Phone: `TELEPMENS`, `TELEPTRANS`, `SERV911`, `TELEPMENSCR`
|
||||
|
||||
## Notable legacy SKUs explicitly NOT used by the wizard
|
||||
|
||||
- `TVBSKINNY` / `TVBSTANDARD` / `TVBEVO` — superseded by `TV-BASE`
|
||||
- Individual `TVF*` thematic forfaits — wizard uses picker + Mix 5/10 instead
|
||||
- `INSTFIBRES` + `RABINS` rebate mechanic — replaced by single `FEE-INSTALL`
|
||||
- `RABTV_MIX5` / `RABTV_MIX10` rebate items — replaced by positive-rate `TV-MIX5` / `TV-MIX10`
|
||||
|
||||
---
|
||||
|
||||
**Last updated**: 2026-04-21 (wizard → legacy Item mapping audit).
|
||||
|
|
@ -1,415 +0,0 @@
|
|||
# Field Tech App — Wizard UX & Form Customizer
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Minimal inputs per screen** — Techs are manual workers, not desk jockeys. Max 2-3 fields per step.
|
||||
2. **Carousel/swipe navigation** — Next/Back with swipe gestures, not scrolling a long form.
|
||||
3. **Big touch targets** — Fat fingers in work gloves. Buttons 48px+ height, inputs 56px+.
|
||||
4. **Auto-advance** — When a field is selected (like equipment_type), auto-advance to next step.
|
||||
5. **Offline-first** — Everything queued if no signal. Sync indicator always visible.
|
||||
6. **Customizable** — Admin can add/remove/reorder steps via an Odoo Studio-like builder.
|
||||
|
||||
---
|
||||
|
||||
## Wizard Component Architecture
|
||||
|
||||
### Core: `WizardCarousel.vue`
|
||||
|
||||
A reusable carousel-based wizard that renders steps from a JSON definition:
|
||||
|
||||
```vue
|
||||
<WizardCarousel
|
||||
:steps="wizardSteps"
|
||||
:context="{ job, customer, location }"
|
||||
@complete="onComplete"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
```
|
||||
|
||||
Each step is a self-contained screen rendered in a `q-carousel-slide`:
|
||||
- Swipe left/right to navigate
|
||||
- Bottom progress dots
|
||||
- "Suivant" / "Précédent" buttons (large, thumb-friendly)
|
||||
- Step counter: "Étape 2/5"
|
||||
|
||||
### Step Definition Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "equipment_type",
|
||||
"title": "Type d'équipement",
|
||||
"icon": "router",
|
||||
"fields": [
|
||||
{
|
||||
"name": "equipment_type",
|
||||
"type": "select-cards",
|
||||
"label": "Quel équipement?",
|
||||
"options": [
|
||||
{ "value": "ONT", "label": "ONT", "icon": "settings_input_hdmi", "color": "blue" },
|
||||
{ "value": "Routeur", "label": "Routeur WiFi", "icon": "wifi", "color": "green" },
|
||||
{ "value": "Décodeur TV", "label": "Décodeur TV", "icon": "tv", "color": "purple" },
|
||||
{ "value": "Téléphone IP", "label": "Téléphone", "icon": "phone", "color": "orange" }
|
||||
],
|
||||
"auto_advance": true
|
||||
}
|
||||
],
|
||||
"visible_if": null,
|
||||
"required": true
|
||||
}
|
||||
```
|
||||
|
||||
### Field Types
|
||||
|
||||
| Type | Render | Use Case |
|
||||
|------|--------|----------|
|
||||
| `select-cards` | Big tappable cards with icons | Equipment type, status, connection type |
|
||||
| `text` | Single large input | Serial number, name |
|
||||
| `scan` | Camera button → barcode scanner | Serial, MAC address |
|
||||
| `photo` | Camera button → photo capture | Equipment photo, site photo |
|
||||
| `signature` | Touch signature pad | Customer sign-off |
|
||||
| `toggle` | Large yes/no toggle | Signal OK?, WiFi working? |
|
||||
| `notes` | Textarea (expandable) | Completion notes |
|
||||
| `date` | Date picker | Schedule date |
|
||||
| `search` | Autocomplete search | Customer lookup |
|
||||
| `checklist` | List of checkable items | Installation checklist |
|
||||
| `info` | Read-only display card | Summary, confirmation |
|
||||
|
||||
---
|
||||
|
||||
## Example Wizards
|
||||
|
||||
### 1. Installation Completion Wizard
|
||||
|
||||
When tech taps "Compléter" on a Dispatch Job:
|
||||
|
||||
```
|
||||
Step 1: "Équipements installés" [checklist]
|
||||
☐ ONT branché et signal OK
|
||||
☐ Routeur WiFi configuré
|
||||
☐ Décodeur TV branché (if TV service)
|
||||
☐ Téléphone IP testé (if phone service)
|
||||
|
||||
Step 2: "Numéros de série" [scan]
|
||||
→ Camera opens, scan ONT barcode
|
||||
→ Auto-detected: RCMG19E0AB57
|
||||
→ "Suivant" button
|
||||
|
||||
Step 3: "Signal ONT" [toggle + text]
|
||||
Signal OK? [OUI / NON]
|
||||
Niveau signal: [-20 dBm] (optional)
|
||||
|
||||
Step 4: "WiFi" [toggle]
|
||||
Client connecté au WiFi? [OUI / NON]
|
||||
SSID affiché: "Gigafibre-Tremblay"
|
||||
|
||||
Step 5: "Photo du boîtier" [photo]
|
||||
📸 Prendre une photo
|
||||
|
||||
Step 6: "Signature client" [signature]
|
||||
Le client confirme que le service fonctionne
|
||||
|
||||
Step 7: "Résumé" [info]
|
||||
✓ ONT: RCMG19E0AB57
|
||||
✓ Signal: -18.5 dBm
|
||||
✓ WiFi: OK
|
||||
✓ Photo: 1 prise
|
||||
✓ Signé par: Jean Tremblay
|
||||
[Confirmer l'installation]
|
||||
```
|
||||
|
||||
### 2. Equipment Scan & Link Wizard
|
||||
|
||||
When tech scans a barcode (from ScanPage or Job context):
|
||||
|
||||
```
|
||||
Step 1: "Scanner" [scan]
|
||||
📸 Scanner le code-barres
|
||||
→ Detected: RCMG19E0AB57
|
||||
|
||||
Step 2: "Type" [select-cards]
|
||||
ONT / Routeur / Décodeur / Téléphone
|
||||
→ Tap "ONT" → auto-advance
|
||||
|
||||
Step 3: "Confirmer" [info]
|
||||
Serial: RCMG19E0AB57
|
||||
Type: ONT
|
||||
Client: Jean Tremblay (from job context)
|
||||
Adresse: 123 rue Principale
|
||||
[Lier à ce client]
|
||||
```
|
||||
|
||||
### 3. Repair/Diagnostic Wizard
|
||||
|
||||
```
|
||||
Step 1: "Problème signalé" [info]
|
||||
Ticket: "Pas d'internet depuis hier"
|
||||
Client: Jean Tremblay
|
||||
Adresse: 123 rue Principale
|
||||
|
||||
Step 2: "Signal ONT" [toggle + text]
|
||||
Signal présent? [OUI / NON]
|
||||
Niveau: [-XX dBm]
|
||||
|
||||
Step 3: "Test WiFi" [toggle]
|
||||
WiFi fonctionne? [OUI / NON]
|
||||
|
||||
Step 4: "Action prise" [select-cards]
|
||||
Redémarrage / Remplacement ONT / Remplacement routeur / Reconnexion fibre / Autre
|
||||
|
||||
Step 5: "Nouvel équipement" [scan] (if replacement)
|
||||
Scanner le nouveau serial
|
||||
|
||||
Step 6: "Notes" [notes]
|
||||
Détails supplémentaires...
|
||||
|
||||
Step 7: "Résultat" [toggle]
|
||||
Service rétabli? [OUI / NON]
|
||||
[Fermer le ticket]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Customizer (Odoo Studio-like)
|
||||
|
||||
### Concept
|
||||
|
||||
An admin page in the Ops app (`/ops/form-builder`) that lets managers customize the tech wizard steps without code changes.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Wizard Templates** stored as JSON documents in ERPNext (new doctype: `Wizard Template`)
|
||||
2. **Each template** = ordered list of steps with field definitions
|
||||
3. **Admin UI** = drag-and-drop step editor:
|
||||
- Add step (from field type library)
|
||||
- Reorder steps (drag handle)
|
||||
- Edit step properties (label, options, required, visibility condition)
|
||||
- Remove step
|
||||
- Preview on mock phone screen
|
||||
4. **Templates linked to job types**: Installation, Réparation, Maintenance, Retrait, etc.
|
||||
5. **Field app fetches** the template for the current job type and renders it dynamically
|
||||
|
||||
### Wizard Template Doctype
|
||||
|
||||
```
|
||||
Wizard Template
|
||||
name: "Installation FTTH"
|
||||
job_type: "Installation"
|
||||
is_active: Check
|
||||
steps: (child table: Wizard Template Step)
|
||||
- step_order: 1
|
||||
step_id: "equipment_checklist"
|
||||
title: "Équipements installés"
|
||||
field_type: "checklist"
|
||||
field_config: '{"items":["ONT branché","Routeur configuré","Décodeur branché"]}'
|
||||
required: 1
|
||||
visible_condition: null
|
||||
- step_order: 2
|
||||
step_id: "ont_scan"
|
||||
title: "Scanner ONT"
|
||||
field_type: "scan"
|
||||
field_config: '{"placeholder":"Scanner le code-barres ONT"}'
|
||||
required: 1
|
||||
- ...
|
||||
```
|
||||
|
||||
### Admin Builder UI
|
||||
|
||||
```
|
||||
┌─ Form Builder: Installation FTTH ─────────────────────────┐
|
||||
│ │
|
||||
│ ┌─ Step 1 ─────────────────────────── [≡] [✏️] [🗑] ──┐ │
|
||||
│ │ 📋 Checklist: "Équipements installés" │ │
|
||||
│ │ Items: ONT, Routeur, Décodeur, Téléphone │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Step 2 ─────────────────────────── [≡] [✏️] [🗑] ──┐ │
|
||||
│ │ 📷 Scan: "Scanner ONT" │ │
|
||||
│ │ Required: Oui │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Step 3 ─────────────────────────── [≡] [✏️] [🗑] ──┐ │
|
||||
│ │ ✅ Toggle: "Signal ONT OK?" │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [+ Ajouter une étape] │
|
||||
│ │
|
||||
│ ┌─ Aperçu téléphone ──┐ │
|
||||
│ │ ┌────────────────┐ │ │
|
||||
│ │ │ Étape 1/7 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ☐ ONT branché │ │ │
|
||||
│ │ │ ☐ Routeur │ │ │
|
||||
│ │ │ ☐ Décodeur │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ [Suivant →] │ │ │
|
||||
│ │ │ ● ○ ○ ○ ○ ○ ○ │ │ │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ [Sauvegarder] [Publier] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. Equipment Swap Wizard (Defective → Replacement)
|
||||
|
||||
When tech taps "Remplacer" on a device or from the "Plus" menu:
|
||||
|
||||
```
|
||||
Step 1: "Équipement défectueux" [search/scan]
|
||||
Scan le code-barres de l'ancien équipement
|
||||
→ OU chercher par numéro de série
|
||||
→ Affiche: RCMG19E0AB57 — ONT Raisecom HT803G-WS2
|
||||
Client: Jean Tremblay
|
||||
Adresse: 123 rue Principale
|
||||
|
||||
Step 2: "Raison du remplacement" [select-cards]
|
||||
🔴 Défectueux (ne s'allume plus)
|
||||
🟡 Performance dégradée
|
||||
🔵 Mise à niveau (upgrade)
|
||||
⚪ Autre
|
||||
|
||||
Step 3: "Nouvel équipement" [scan]
|
||||
📸 Scanner le code-barres du remplacement
|
||||
→ Detected: TPLGA1E7FB90
|
||||
→ Auto-detect type: XX230v (ONT)
|
||||
|
||||
Step 4: "Confirmer le remplacement" [info]
|
||||
❌ Ancien: RCMG19E0AB57 → sera marqué "Défectueux"
|
||||
✅ Nouveau: TPLGA1E7FB90 → sera activé
|
||||
|
||||
Données transférées automatiquement:
|
||||
• WiFi SSID/mot de passe ✓
|
||||
• SIP (VoIP) credentials ✓
|
||||
• Port OLT: 0/3/2 ONT:12 ✓
|
||||
• VLANs: inet/mgmt/tel/tv ✓
|
||||
|
||||
⚠️ L'OLT sera reconfiguré (ancien ONT supprimé, nouveau enregistré)
|
||||
|
||||
[Confirmer le remplacement]
|
||||
|
||||
Step 5: "Vérification" [toggle]
|
||||
Nouveau ONT en ligne? [OUI / NON]
|
||||
Signal OK? [OUI / NON]
|
||||
Services fonctionnels? [OUI / NON]
|
||||
|
||||
[Terminer]
|
||||
```
|
||||
|
||||
**Backend flow (POST /provision/swap):**
|
||||
1. Marks old equipment as "Défectueux" in ERPNext
|
||||
2. Creates new Service Equipment with transferred WiFi/VoIP/OLT data
|
||||
3. Generates OLT swap commands (delete old ONT, register new)
|
||||
4. n8n executes OLT commands via SSH
|
||||
5. ACS pushes config to new device on bootstrap
|
||||
6. Ops team notified via SSE
|
||||
|
||||
### 5. Quick Actions Menu (field app "Plus" page)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Actions rapides │
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ 📷 │ │ 🔄 │ │ 🔧 │ │
|
||||
│ │ Scan │ │ Swap │ │ Diag │ │
|
||||
│ │ équip. │ │ équip. │ │ réseau │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ 📋 │ │ 📸 │ │ ✏️ │ │
|
||||
│ │ Check │ │ Photo │ │ Note │ │
|
||||
│ │ -list │ │ site │ │ rapide │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each action opens the corresponding wizard flow.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: WizardCarousel Component
|
||||
1. Create `apps/field/src/components/WizardCarousel.vue` — carousel renderer
|
||||
2. Create `apps/field/src/components/wizard-fields/` — one component per field type:
|
||||
- `SelectCards.vue` (big tappable cards)
|
||||
- `ScanField.vue` (camera + barcode)
|
||||
- `ToggleField.vue` (yes/no)
|
||||
- `PhotoField.vue` (camera capture)
|
||||
- `SignatureField.vue` (touch pad)
|
||||
- `ChecklistField.vue` (checkable items)
|
||||
- `TextField.vue` (single input)
|
||||
- `NotesField.vue` (textarea)
|
||||
- `InfoField.vue` (read-only summary)
|
||||
3. Create `apps/field/src/composables/useWizard.js` — step state, validation, submission
|
||||
|
||||
### Phase 2: Hardcoded Wizards
|
||||
1. Build "Installation Complete" wizard (7 steps as above)
|
||||
2. Build "Equipment Scan & Link" wizard (3 steps)
|
||||
3. Integrate into TasksPage "Complete Job" action
|
||||
4. Replace current ScanPage equipment creation dialog with wizard flow
|
||||
|
||||
### Phase 3: Wizard Template Doctype
|
||||
1. Create `Wizard Template` + `Wizard Template Step` doctypes in ERPNext
|
||||
2. Seed with Installation, Repair, Maintenance templates
|
||||
3. Field app fetches template by job_type on wizard open
|
||||
4. WizardCarousel renders dynamically from template
|
||||
|
||||
### Phase 4: Admin Form Builder (Ops App)
|
||||
1. Build `/ops/form-builder` page
|
||||
2. Drag-and-drop step editor (vue-draggable)
|
||||
3. Step property editor (sidebar panel)
|
||||
4. Phone preview panel
|
||||
5. Publish → saves to ERPNext Wizard Template
|
||||
|
||||
---
|
||||
|
||||
## CSS / Styling Notes
|
||||
|
||||
```scss
|
||||
// Wizard slide — full height, centered content
|
||||
.wizard-slide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
min-height: calc(100vh - 160px); // account for header + progress bar
|
||||
}
|
||||
|
||||
// Select cards — large touch targets
|
||||
.select-card {
|
||||
min-height: 80px;
|
||||
border-radius: 16px;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
&:active { transform: scale(0.97); }
|
||||
&.selected {
|
||||
border: 3px solid var(--q-primary);
|
||||
box-shadow: 0 0 0 4px rgba(var(--q-primary-rgb), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Progress dots
|
||||
.wizard-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
.dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
&.active { background: var(--q-primary); width: 24px; border-radius: 5px; }
|
||||
&.done { background: var(--q-positive); }
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation buttons — thumb-friendly
|
||||
.wizard-nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
.q-btn { min-height: 56px; font-size: 16px; flex: 1; border-radius: 12px; }
|
||||
}
|
||||
```
|
||||
678
docs/FLOW_EDITOR_ARCHITECTURE.md
Normal file
678
docs/FLOW_EDITOR_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
# Flow Editor — Architecture & Integration Guide
|
||||
|
||||
> Authoritative reference for the Flow Editor subsystem: visual builder in the
|
||||
> Ops PWA, JSON flow-definition language, runtime engine in `targo-hub`, and
|
||||
> the Frappe scheduler that wakes delayed steps. Companion document to
|
||||
> `ARCHITECTURE.md` and `DATA_AND_FLOWS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals & Principles
|
||||
|
||||
The Flow Editor lets operators compose automations — onboarding,
|
||||
follow-ups, SLA escalations, billing side-effects — without writing code or
|
||||
leaving the ERPNext/Ops perimeter. It behaves like n8n or Shopify Flow, but
|
||||
is **natively embedded** in the Ops PWA and talks directly to ERPNext via the
|
||||
`targo-hub`.
|
||||
|
||||
Design tenets:
|
||||
|
||||
1. **One UI, many domains.** A single `FlowEditor.vue` tree is parameterised
|
||||
by a *kind catalog* (`PROJECT_KINDS`, `AGENT_KINDS`, …). Adding a new
|
||||
catalog adds a new editor, no UI fork.
|
||||
2. **Inline-editable from anywhere** (Odoo pattern). `FlowQuickButton` +
|
||||
`useFlowEditor()` let any page pop the editor with preset filters
|
||||
(category/applies-to/trigger) — the user never has to route to Settings
|
||||
to create a flow.
|
||||
3. **Pluggable runtime via a kind dispatcher.** `KIND_HANDLERS` in
|
||||
`flow-runtime.js` is a plain map — no `if/else` chain. Adding a step
|
||||
kind = write one async function.
|
||||
4. **No code-as-config.** Predicates go through a pure evaluator
|
||||
(`evalCondition`) — never `eval()` / `Function()`. Templates are rendered
|
||||
with a regex (`{{a.b.c}}`), no Mustache lib.
|
||||
5. **Persist decisions, not heartbeats.** Delayed steps become
|
||||
`Flow Step Pending` rows with a `trigger_at`. The scheduler never
|
||||
re-executes logic itself — it just nudges the Hub.
|
||||
6. **Seeded templates are protected** (`is_system=1`). Operators can
|
||||
duplicate them but not delete or rename.
|
||||
|
||||
---
|
||||
|
||||
## 2. File Inventory
|
||||
|
||||
### Front-end — `apps/ops/src/`
|
||||
|
||||
| Path | Responsibility |
|
||||
|-------------------------------------------------------|-----------------------------------------------------------------|
|
||||
| `components/flow-editor/FlowEditor.vue` | Vertical-tree view + drag-to-reorder, consumes a kind catalog |
|
||||
| `components/flow-editor/FlowNode.vue` | Single step card (icon, label, menu, branch connectors) |
|
||||
| `components/flow-editor/StepEditorModal.vue` | Modal that edits one step's payload (trigger + kind fields) |
|
||||
| `components/flow-editor/FieldInput.vue` | Polymorphic field widget (text/number/select/datetime/…) |
|
||||
| `components/flow-editor/FlowEditorDialog.vue` | Full-screen dialog for template-level edits (meta + tree) |
|
||||
| `components/flow-editor/FlowTemplatesSection.vue` | Settings list: filter, duplicate, delete, toggle active |
|
||||
| `components/flow-editor/FlowQuickButton.vue` | Drop-in "New flow / Edit flow" button |
|
||||
| `components/flow-editor/kind-catalogs.js` | `PROJECT_KINDS`, `AGENT_KINDS`, `TRIGGER_TYPES`, `buildEmptyStep` |
|
||||
| `components/flow-editor/index.js` | Barrel export |
|
||||
| `composables/useFlowEditor.js` | Module-level singleton reactive store |
|
||||
| `api/flow-templates.js` | REST client for `/flow/templates/*` |
|
||||
|
||||
### Back-end — `services/targo-hub/lib/`
|
||||
|
||||
| Path | Responsibility |
|
||||
|------------------------|-------------------------------------------------------------------|
|
||||
| `flow-templates.js` | REST API: CRUD + duplicate + validation |
|
||||
| `flow-runtime.js` | Execution engine: kind handlers, wave loop, template render |
|
||||
| `flow-api.js` | REST API: `/flow/start`, `/flow/advance`, `/flow/complete`, `/flow/event`, `/flow/runs` |
|
||||
| `contracts.js` | Fires `on_contract_signed` on JWT confirm |
|
||||
| `payments.js` | Fires `on_payment_received` on Stripe webhook |
|
||||
| `acceptance.js` | Fires `on_quotation_accepted` on accept/confirm |
|
||||
|
||||
### ERPNext — `erpnext/`
|
||||
|
||||
| Path | Responsibility |
|
||||
|------------------------------|-------------------------------------------------------------|
|
||||
| `flow_scheduler.py` | Frappe cron hook (every minute) — claims due pending steps |
|
||||
| `seed_flow_templates.py` | Seeds the 4 project templates + `residential_onboarding` + `quotation_follow_up` |
|
||||
| `setup_flow_templates.py` | DocType creation (Flow Template / Flow Run / Flow Step Pending) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model (ERPNext doctypes)
|
||||
|
||||
Three custom doctypes live under `ERPNext`. All named by series
|
||||
(`FT-NNNNN`, `FR-NNNNN`, `FSP-NNNNN`).
|
||||
|
||||
### 3.1 `Flow Template`
|
||||
|
||||
Authoritative definition of an automation. Edited via the Flow Editor.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|----------------------|----------|---------------------------------------------------------------|
|
||||
| `template_name` | Data | Human label (unique within category) |
|
||||
| `category` | Select | `residential` / `commercial` / `incident` / `billing` / `other` |
|
||||
| `applies_to` | Link | Target DocType (Service Contract, Quotation, Issue, …) |
|
||||
| `icon` | Data | Material icon name |
|
||||
| `description` | Small Text | One-liner |
|
||||
| `is_active` | Check | Only active templates dispatch on events |
|
||||
| `is_system` | Check | Seeded by code — cannot be deleted; duplicating is allowed |
|
||||
| `version` | Int | Bumped on every save (used for audit trail) |
|
||||
| `trigger_event` | Select | `on_contract_signed`, `on_payment_received`, `on_subscription_active`, `on_quotation_created`, `on_quotation_accepted`, `on_issue_opened`, `on_customer_created`, `on_dispatch_completed`, `manual` |
|
||||
| `trigger_condition` | Code | Optional JSONLogic-ish gate — today only `==` is wired |
|
||||
| `flow_definition` | Long Text (JSON) | The whole graph — see §4 |
|
||||
| `step_count` | Int | Computed on save for list display |
|
||||
| `tags`, `notes` | Data / Text | Free-form metadata |
|
||||
|
||||
### 3.2 `Flow Run`
|
||||
|
||||
One execution of a template against a specific trigger doc.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|----------------------|---------------|--------------------------------------------------------|
|
||||
| `flow_template` | Link | The FT we're executing |
|
||||
| `template_version` | Int | Snapshot at start — template edits don't retro-affect |
|
||||
| `status` | Select | `running` / `completed` / `failed` / `cancelled` |
|
||||
| `trigger_event` | Data | What caused this run (for audit) |
|
||||
| `context_doctype` | Data | Trigger doc's doctype |
|
||||
| `context_docname` | Data | Trigger doc's name |
|
||||
| `customer` | Link Customer | Resolved customer for template rendering |
|
||||
| `variables` | Long Text (JSON) | Runtime bag passed by `dispatchEvent` (amount, signed_at, …) |
|
||||
| `step_state` | Long Text (JSON) | `{ [stepId]: { status, started_at, completed_at, result, error } }` |
|
||||
| `started_at`, `completed_at` | Datetime | Timestamps |
|
||||
|
||||
### 3.3 `Flow Step Pending`
|
||||
|
||||
One row per delayed step. The scheduler processes these.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------------|--------------|---------------------------------------------------------|
|
||||
| `flow_run` | Link | Parent run |
|
||||
| `step_id` | Data | The step within `flow_definition.steps` |
|
||||
| `trigger_at` | Datetime | When to fire (indexed — scheduler queries this) |
|
||||
| `status` | Select | `pending` → `running` → `completed` or `failed` |
|
||||
| `retry_count` | Int | Increments on scheduler failures (caps at 5) |
|
||||
| `last_error` | Small Text | Truncated error from last attempt |
|
||||
| `context_snapshot`| Long Text (JSON) | Step payload captured at schedule time (optional) |
|
||||
| `executed_at` | Datetime | Set when scheduler successfully nudges the Hub |
|
||||
|
||||
---
|
||||
|
||||
## 4. Flow Definition JSON Schema
|
||||
|
||||
Stored as the `flow_definition` field of `Flow Template` and as `{step}` in
|
||||
`Flow Step Pending.context_snapshot`.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"trigger": { "event": "on_contract_signed", "condition": "" },
|
||||
"variables": { "default_group": "Tech Targo" },
|
||||
"steps": [ /* Step[] */ ]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Step shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "step_abc123",
|
||||
"kind": "dispatch_job",
|
||||
"label": "Installation fibre",
|
||||
"parent_id": null,
|
||||
"branch": null,
|
||||
"depends_on": [],
|
||||
"trigger": { "type": "on_flow_start" },
|
||||
"payload": {
|
||||
"subject": "Installation {{customer.customer_name}}",
|
||||
"job_type": "Installation",
|
||||
"priority": "medium",
|
||||
"duration_h": 2,
|
||||
"assigned_group": "Tech Targo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|--------------|----------------|---------------------------------------------------------|
|
||||
| `id` | string | Stable UID, generated client-side (`step_xxxxxx`) |
|
||||
| `kind` | string | Must exist in `KIND_HANDLERS` |
|
||||
| `label` | string | Free text shown in the editor + timeline |
|
||||
| `parent_id` | `string\|null` | If set, this step is inside the parent's branch |
|
||||
| `branch` | `string\|null` | Matches `parent.result.branch` (e.g. `"yes"` / `"no"`) |
|
||||
| `depends_on` | `string[]` | Must all be `done` before this step becomes ready |
|
||||
| `trigger` | object | When to fire — see §4.2 |
|
||||
| `payload` | object | Kind-specific fields (see §4.3) |
|
||||
|
||||
### 4.2 Trigger types
|
||||
|
||||
| `trigger.type` | Extra fields | Semantics |
|
||||
|---------------------|---------------------------|----------------------------------------------------------|
|
||||
| `on_flow_start` | — | Fires in the very first wave of `advanceFlow` |
|
||||
| `on_prev_complete` | — | Fires when all `depends_on` are `done` |
|
||||
| `after_delay` | `delay_hours`, `delay_days` | Scheduled — creates a `Flow Step Pending` row |
|
||||
| `on_date` | `at` (ISO) | Scheduled — creates a `Flow Step Pending` row |
|
||||
| `on_webhook` | — | Stays `pending`, external POST to `/flow/complete` fires it |
|
||||
| `manual` | — | Stays `pending`, user clicks a button to fire |
|
||||
|
||||
### 4.3 Payloads per kind
|
||||
|
||||
The authoritative list lives in `apps/ops/src/components/flow-editor/kind-catalogs.js`
|
||||
(PROJECT_KINDS). The runtime map in `flow-runtime.js` must be kept in sync.
|
||||
|
||||
| Kind | Payload |
|
||||
|--------------------------|----------------------------------------------------------------------|
|
||||
| `dispatch_job` | `subject`, `job_type`, `priority`, `duration_h`, `assigned_group`, `on_open_webhook`, `on_close_webhook`, `merge_key` |
|
||||
| `issue` | `subject`, `description`, `priority`, `issue_type` |
|
||||
| `notify` | `channel` (sms/email), `to`, `template_id`, `subject`, `body` |
|
||||
| `webhook` | `url`, `method`, `body_template` (JSON string) |
|
||||
| `erp_update` | `doctype`, `docname_ref`, `fields_json` (JSON string) |
|
||||
| `wait` | — (delay controlled by `trigger.type=after_delay`) |
|
||||
| `condition` | `field`, `op`, `value` → exposes `result.branch = "yes"\|"no"` |
|
||||
| `subscription_activate` | `subscription_ref` |
|
||||
|
||||
### 4.4 Template interpolation
|
||||
|
||||
Strings in `payload` support `{{a.b.c}}` dotted paths. The context is:
|
||||
|
||||
```js
|
||||
{
|
||||
doc, // the trigger doc (contract, quotation, issue)
|
||||
customer, // resolved Customer doc
|
||||
run, // the Flow Run (name, variables, step_state)
|
||||
template, // the Flow Template (name, version)
|
||||
variables, // run-level variable bag
|
||||
now // current ISO datetime
|
||||
}
|
||||
```
|
||||
|
||||
Unknown paths render as empty string — rendering never throws.
|
||||
|
||||
---
|
||||
|
||||
## 5. Front-end Architecture
|
||||
|
||||
### 5.1 Component hierarchy
|
||||
|
||||
```
|
||||
MainLayout.vue
|
||||
└─ FlowEditorDialog.vue (mounted once, globally)
|
||||
└─ FlowEditor.vue
|
||||
└─ FlowNode.vue (xN)
|
||||
└─ StepEditorModal.vue
|
||||
└─ FieldInput.vue (xN)
|
||||
|
||||
Any page
|
||||
└─ FlowQuickButton.vue (opens the global dialog)
|
||||
|
||||
SettingsPage.vue
|
||||
└─ FlowTemplatesSection.vue (list/filter/toggle/duplicate)
|
||||
```
|
||||
|
||||
### 5.2 `useFlowEditor()` — singleton reactive store
|
||||
|
||||
Module-level `ref`s shared across every call to `useFlowEditor()` — no
|
||||
provide/inject, no Pinia store. Any component can call `openTemplate()` and
|
||||
the globally-mounted `<FlowEditorDialog>` reacts automatically.
|
||||
|
||||
Read-only exposed refs: `isOpen`, `loading`, `saving`, `error`, `dirty`,
|
||||
`mode`, `templateName`. Mutable: `template` (the draft body).
|
||||
|
||||
Actions:
|
||||
|
||||
| Method | Behaviour |
|
||||
|----------------------------|-----------------------------------------------------------------|
|
||||
| `openTemplate(name, opts)` | Fetches FT, populates `template`, `isOpen = true`, mode=`edit` |
|
||||
| `openNew(opts)` | Blank draft with `category`/`applies_to`/`trigger_event` presets |
|
||||
| `close(force?)` | Confirms unsaved changes unless `force=true` |
|
||||
| `save()` | Create or patch — bumps version, invokes `onSaved` callbacks |
|
||||
| `duplicate(newName)` | Clones current, loads the clone |
|
||||
| `remove()` | Deletes current (forbidden on `is_system=1`) |
|
||||
| `markDirty()` | Called on every field mutation — O(1), avoids deep-watch cost |
|
||||
|
||||
### 5.3 Pluggable kind catalogs
|
||||
|
||||
`FlowEditor` takes a `kindCatalog` prop. Two are shipped:
|
||||
|
||||
* `PROJECT_KINDS` — 8 kinds for service-delivery automations
|
||||
* `AGENT_KINDS` — 6 kinds for conversational agent flows (preserves the old
|
||||
`AgentFlowsPage` semantics for a later drop-in refactor)
|
||||
|
||||
Each catalog entry defines: `label`, `icon`, `color`, optional
|
||||
`hasBranches`/`branchLabels`, and a `fields[]` array of field descriptors.
|
||||
Descriptor shape is documented at the top of `kind-catalogs.js`. Adding a
|
||||
field descriptor = the modal renders the right widget automatically.
|
||||
|
||||
### 5.4 `FlowQuickButton` — inline entry points
|
||||
|
||||
Drop-in button with two modes:
|
||||
|
||||
```vue
|
||||
<!-- New flow, pre-filtered -->
|
||||
<FlowQuickButton category="residential" applies-to="Service Contract" />
|
||||
|
||||
<!-- Edit an existing template -->
|
||||
<FlowQuickButton template-name="FT-00005" label="Modifier le flow" />
|
||||
```
|
||||
|
||||
Used in: `ProjectWizard.vue`, `TicketsPage.vue`, `IssueDetail.vue`,
|
||||
`ClientDetailPage.vue` (Contrats de service header). Add it wherever a
|
||||
contextual "spin up an automation from here" shortcut makes sense.
|
||||
|
||||
---
|
||||
|
||||
## 6. Runtime (`flow-runtime.js`)
|
||||
|
||||
### 6.1 Wave loop — `advanceFlow(runName)`
|
||||
|
||||
1. Fetch run + template + build context (`_buildContext`).
|
||||
2. Loop up to 50 waves:
|
||||
1. For each step, `isStepReady(step, state, def)`:
|
||||
- all `depends_on` done?
|
||||
- parent done + `branch` matches parent's result?
|
||||
2. If ready:
|
||||
- `after_delay`/`on_date` → schedule `Flow Step Pending`, state=`scheduled`
|
||||
- `on_webhook`/`manual` → leave `pending`, caller resumes later
|
||||
- else → inline execute via `KIND_HANDLERS[step.kind]`, mutate state
|
||||
3. If no step progressed, break.
|
||||
3. Compute run-level status: `completed` if all done, `failed` if any failed.
|
||||
4. Persist `step_state` + patch once (single PUT).
|
||||
|
||||
The wave loop means a single `advanceFlow` call collapses an entire chain of
|
||||
instant steps into one write — the scheduler's job is just to kick the first
|
||||
domino after a delay.
|
||||
|
||||
### 6.2 Kind dispatcher
|
||||
|
||||
```js
|
||||
const KIND_HANDLERS = {
|
||||
dispatch_job, issue, notify, webhook, erp_update,
|
||||
subscription_activate, wait, condition,
|
||||
}
|
||||
```
|
||||
|
||||
Every handler has the same signature:
|
||||
|
||||
```js
|
||||
async function handleXxx(step, ctx) { … return { status, result?, error? } }
|
||||
```
|
||||
|
||||
`status` is one of `STATUS.DONE`, `STATUS.FAILED`, or `STATUS.SCHEDULED`.
|
||||
|
||||
### 6.3 Condition evaluator
|
||||
|
||||
```js
|
||||
evalCondition({ field, op, value }, ctx) // → boolean
|
||||
```
|
||||
|
||||
Supported ops: `== != < > <= >= in not_in empty not_empty contains
|
||||
starts_with ends_with`. No `eval`, no `Function`, no prototype access.
|
||||
`condition` steps return `{ branch: ok ? 'yes' : 'no' }` so child steps can
|
||||
filter on `branch`.
|
||||
|
||||
### 6.4 `dispatchEvent(eventName, opts)`
|
||||
|
||||
The public event hook used by `contracts.js`, `payments.js`,
|
||||
`acceptance.js`, etc. It:
|
||||
|
||||
1. Queries `Flow Template` with `trigger_event = eventName AND is_active = 1`.
|
||||
2. For each, evaluates `trigger_condition` (simple `==` JSON gate today).
|
||||
3. Calls `startFlow` for matching templates.
|
||||
|
||||
Callers use it non-blocking (`.catch(()=>{})`) so automation failures never
|
||||
break the primary flow (contract signature, payment, etc.).
|
||||
|
||||
### 6.5 Scheduler (`flow_scheduler.py`)
|
||||
|
||||
Runs every minute via Frappe `hooks.py`:
|
||||
|
||||
```python
|
||||
scheduler_events = {
|
||||
"cron": { "* * * * *": ["flow_scheduler.tick"] }
|
||||
}
|
||||
```
|
||||
|
||||
Algorithm (`_claim_due_rows` → `_fire_row`):
|
||||
|
||||
1. Select `Flow Step Pending` where `status='pending' AND trigger_at <= now()`
|
||||
(limited to 50 per tick).
|
||||
2. Atomically flip them to `status='running'` in one SQL UPDATE.
|
||||
3. For each, POST `{run, step_id}` to `HUB_URL/flow/complete`.
|
||||
4. On success: `status='completed'`, `executed_at=now()`.
|
||||
5. On failure: increment `retry_count`, set `status='pending'` (or
|
||||
`'failed'` after 5 retries), store truncated error in `last_error`.
|
||||
|
||||
The claim-then-fire split guarantees at-most-once execution even under
|
||||
concurrent ticks. `retry_count` gives durable back-off without polling logic.
|
||||
|
||||
---
|
||||
|
||||
## 7. HTTP API
|
||||
|
||||
### 7.1 `/flow/templates` (mounted in `flow-templates.js`)
|
||||
|
||||
| Verb | Path | Body / Query | Response |
|
||||
|------|--------------------------------|---------------------------|-----------------------|
|
||||
| GET | `/flow/templates` | `?category=&applies_to=&is_active=&trigger_event=` | `{templates: FT[]}` |
|
||||
| GET | `/flow/templates/:name` | — | `{template: FT}` |
|
||||
| POST | `/flow/templates` | `{template_name, …}` | `{template}` |
|
||||
| PUT | `/flow/templates/:name` | partial patch | `{template}` |
|
||||
| DELETE | `/flow/templates/:name` | — | `{ok: true}` (blocks `is_system`) |
|
||||
| POST | `/flow/templates/:name/duplicate` | `{new_name?}` | `{template}` |
|
||||
|
||||
Validation runs on every write: unique step IDs, known kinds, referential
|
||||
integrity of `depends_on`/`parent_id`.
|
||||
|
||||
### 7.2 `/flow/*` (mounted in `flow-api.js`)
|
||||
|
||||
| Verb | Path | Body | Response |
|
||||
|------|----------------------|---------------------------------------------------|-------------------------------|
|
||||
| POST | `/flow/start` | `{template, doctype, docname, customer, variables}` | `{run, executed, scheduled}` |
|
||||
| POST | `/flow/advance` | `{run}` | `{run, executed, scheduled}` |
|
||||
| POST | `/flow/complete` | `{run, step_id, result?}` | `{run, …}` |
|
||||
| POST | `/flow/event` | `{event, doctype, docname, customer, variables}` | `{results: [...]}` |
|
||||
| GET | `/flow/runs` | `?status=&template=&customer=` | `{runs: FR[]}` |
|
||||
| GET | `/flow/runs/:name` | — | `{run: FR}` |
|
||||
|
||||
All endpoints support optional `Authorization: Bearer $HUB_INTERNAL_TOKEN`
|
||||
(controlled via `INTERNAL_TOKEN` env). Used by the Frappe scheduler.
|
||||
|
||||
---
|
||||
|
||||
## 8. Trigger Wiring
|
||||
|
||||
Event emitters are intentionally thin — two lines each, failure-tolerant.
|
||||
|
||||
### 8.1 `on_contract_signed` (in `contracts.js`)
|
||||
|
||||
Fired by the JWT accept flow (residential) on `POST /contract/confirm`:
|
||||
|
||||
```js
|
||||
_fireFlowTrigger('on_contract_signed', {
|
||||
doctype: 'Service Contract',
|
||||
docname: contractName,
|
||||
customer: payload.sub,
|
||||
variables: { contract_type, signed_at },
|
||||
})
|
||||
```
|
||||
|
||||
`_fireFlowTrigger` lazy-requires `./flow-runtime` so the cost is paid only
|
||||
when a trigger actually fires.
|
||||
|
||||
### 8.2 `on_payment_received` (in `payments.js`)
|
||||
|
||||
Fired from the Stripe `checkout.session.completed` webhook handler, after
|
||||
the Sales Invoice is marked paid:
|
||||
|
||||
```js
|
||||
require('./flow-runtime').dispatchEvent('on_payment_received', {
|
||||
doctype: 'Sales Invoice',
|
||||
docname: invoiceName,
|
||||
customer,
|
||||
variables: { amount, payment_intent: piId },
|
||||
})
|
||||
```
|
||||
|
||||
Wrapped in `try/catch` so a flow bug never blocks a payment.
|
||||
|
||||
### 8.3 `on_quotation_accepted` (in `acceptance.js`)
|
||||
|
||||
Fired by the commercial accept flow (DocuSeal/JWT) on `POST /accept/confirm`:
|
||||
|
||||
```js
|
||||
require('./flow-runtime').dispatchEvent('on_quotation_accepted', {
|
||||
doctype: 'Quotation',
|
||||
docname: payload.doc,
|
||||
customer: payload.sub,
|
||||
variables: { accepted_at },
|
||||
})
|
||||
```
|
||||
|
||||
### 8.4 Future wiring
|
||||
|
||||
To hook another event (e.g. `on_ticket_resolved`):
|
||||
|
||||
1. Pick the source of truth (ERPNext Issue webhook, Ops inline save, etc.).
|
||||
2. From that handler, call `require('./flow-runtime').dispatchEvent(...)`.
|
||||
3. Add the event name to `TRIGGER_EVENT_OPTIONS` in
|
||||
`FlowEditorDialog.vue` so operators can pick it from the UI.
|
||||
4. (Optional) add a pre-seeded template in `seed_flow_templates.py`.
|
||||
|
||||
---
|
||||
|
||||
## 9. How-To Recipes
|
||||
|
||||
### 9.1 Add a new step kind
|
||||
|
||||
1. **Front-end** — in `kind-catalogs.js`, extend `PROJECT_KINDS` with a new
|
||||
entry:
|
||||
```js
|
||||
send_portal_invite: {
|
||||
label: 'Invitation portail',
|
||||
icon: 'mail_lock',
|
||||
color: '#0ea5e9',
|
||||
fields: [
|
||||
{ name: 'to', type: 'text', label: 'Email', required: true },
|
||||
{ name: 'role', type: 'select', label: 'Rôle', options: ['customer', 'tech'] },
|
||||
],
|
||||
}
|
||||
```
|
||||
2. **Back-end** — in `flow-runtime.js`, implement the handler:
|
||||
```js
|
||||
async function handlePortalInvite (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
// … call the existing portal-invite endpoint …
|
||||
return { status: STATUS.DONE, result: { … } }
|
||||
}
|
||||
```
|
||||
3. Register it in `KIND_HANDLERS`:
|
||||
```js
|
||||
const KIND_HANDLERS = { …, send_portal_invite: handlePortalInvite }
|
||||
```
|
||||
4. Add the kind name to `flow-templates.js → validateFlowDefinition.validKinds`.
|
||||
5. Rebuild + deploy Hub; rebuild + deploy Ops SPA.
|
||||
|
||||
No UI changes: the new kind renders its fields automatically.
|
||||
|
||||
### 9.2 Add a new trigger event
|
||||
|
||||
1. Emit it: `require('./flow-runtime').dispatchEvent('on_foo', {…})` from
|
||||
wherever the event occurs.
|
||||
2. Add the label in `FlowEditorDialog.vue` `TRIGGER_EVENT_OPTIONS`.
|
||||
3. (Optional) pre-seed a template in `seed_flow_templates.py`.
|
||||
|
||||
### 9.3 Add a new kind catalog (e.g. for billing flows)
|
||||
|
||||
1. Export a new const from `kind-catalogs.js`: `export const BILLING_KINDS = {...}`.
|
||||
2. Pass it into any `<FlowEditor :kind-catalog="BILLING_KINDS" />` instance.
|
||||
3. Reuse the same `FlowEditorDialog` if the kinds live side by side; fork it
|
||||
only if you need dramatically different metadata panels.
|
||||
|
||||
### 9.4 Add a trigger condition
|
||||
|
||||
Today `trigger_condition` supports a simple `{ "==": [{"var": "path"}, "value"] }`
|
||||
JSON gate. To extend to full JSONLogic:
|
||||
|
||||
1. Replace `_matchCondition` in `flow-runtime.js` with the `json-logic-js`
|
||||
library (already a stable dep elsewhere in Hub — check first).
|
||||
2. Update `validateFlowDefinition` to reject unknown operators.
|
||||
3. Document the new operators in this file.
|
||||
|
||||
---
|
||||
|
||||
## 10. Debugging Guide
|
||||
|
||||
### 10.1 Flow didn't run when expected
|
||||
|
||||
1. **Check the event was emitted.** `docker logs targo-hub | grep '\[flow\]'`.
|
||||
You should see `[flow] started FR-XXXXX from FT-YYYYY`.
|
||||
2. **Check the template is active + matches.** In Ops → Settings →
|
||||
Flow Templates, confirm `is_active`, `trigger_event`, and
|
||||
`trigger_condition` (if set) match the event.
|
||||
3. **Inspect the Flow Run.** `GET /flow/runs/:name` returns full state.
|
||||
`step_state[stepId].status = 'failed'` + `.error` pinpoints the cause.
|
||||
|
||||
### 10.2 Delayed step never fires
|
||||
|
||||
1. **Check the scheduler is ticking.** ERPNext backend logs:
|
||||
`docker logs erpnext-backend-1 | grep flow_scheduler`. If silent,
|
||||
confirm the cron entry in `hooks.py` and the Frappe scheduler worker is
|
||||
up (`bench doctor`).
|
||||
2. **Check the pending row.** ERPNext → Flow Step Pending list:
|
||||
`status`, `trigger_at`, `retry_count`, `last_error`.
|
||||
3. **Manually fire it:** `POST /flow/complete` with `{run, step_id}` from
|
||||
any host with access to the Hub.
|
||||
|
||||
### 10.3 Template renders empty strings
|
||||
|
||||
`{{foo.bar}}` returned empty means the path is absent from the context. Add
|
||||
a `console.log(ctx)` in the handler, or use a `condition` step upstream to
|
||||
verify the data is populated before the branch that uses it. Remember that
|
||||
`customer` is only loaded if `run.customer` is set *or* the trigger doc has
|
||||
a `customer` field.
|
||||
|
||||
### 10.4 "Step has no handler" error
|
||||
|
||||
The kind name in the template no longer matches `KIND_HANDLERS`. Either a
|
||||
deploy mismatch (front-end ahead of Hub) or a kind was removed. Revert the
|
||||
Hub or update the Flow Template.
|
||||
|
||||
### 10.5 Retry loop stuck
|
||||
|
||||
`Flow Step Pending.retry_count` maxes at 5 then flips to `failed`. To retry
|
||||
manually: set `status='pending'`, `retry_count=0` on the row, wait a minute.
|
||||
Fix root cause before retrying — the scheduler won't back off past 5.
|
||||
|
||||
---
|
||||
|
||||
## 11. Seeded Templates
|
||||
|
||||
Living in `erpnext/seed_flow_templates.py`. All created with `is_system=1`.
|
||||
|
||||
| FT Name | Category | Trigger event | Purpose |
|
||||
|----------------------|--------------|--------------------------|------------------------------------------------------------|
|
||||
| fiber_install | residential | manual | 4-step install (dispatch install + notify + activate sub + 24h survey) |
|
||||
| phone_service | residential | manual | Phone-only activation |
|
||||
| move_service | residential | manual | Move existing service to new address |
|
||||
| repair_service | incident | on_issue_opened | Triage + dispatch repair |
|
||||
| residential_onboarding | residential | on_contract_signed | Install + welcome SMS + sub activate + 24h survey + 11-month renewal reminder |
|
||||
| quotation_follow_up | commercial | on_quotation_accepted | 3-touch email sequence (D+3, D+7, D+14) |
|
||||
|
||||
Run the seeder once after deploying the scheduler:
|
||||
|
||||
```bash
|
||||
docker exec -u frappe erpnext-backend-1 bash -c \
|
||||
'cd /home/frappe/frappe-bench/sites && \
|
||||
/home/frappe/frappe-bench/env/bin/python -c \
|
||||
"import frappe; frappe.init(site=\"erp.gigafibre.ca\"); frappe.connect(); \
|
||||
from seed_flow_templates import seed_all; seed_all()"'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Security & Governance
|
||||
|
||||
* **Seeded templates** (`is_system=1`) cannot be deleted — only duplicated.
|
||||
This preserves the baseline automations even after operator mistakes.
|
||||
* **Internal token**: all `/flow/*` endpoints honour
|
||||
`Authorization: Bearer $HUB_INTERNAL_TOKEN`. Set it in both Hub and
|
||||
ERPNext environment to lock down the API surface between containers.
|
||||
* **No `eval`** anywhere: conditions run through a fixed predicate table,
|
||||
templates through a regex. Adding arbitrary code to a flow is not a
|
||||
supported feature — if a use case needs it, the answer is "add a new
|
||||
kind".
|
||||
* **Validation is mandatory** on every write: duplicate IDs, unknown kinds,
|
||||
dangling `depends_on`, orphan `parent_id` all reject at the API.
|
||||
* **Audit**: every Flow Run carries `template_version` so historical runs
|
||||
stay traceable even after the template is edited.
|
||||
|
||||
---
|
||||
|
||||
## 13. Deployment Topology
|
||||
|
||||
```
|
||||
ERPNext backend ──cron every 60s──► flow_scheduler.tick()
|
||||
│
|
||||
│ (INTERNAL_TOKEN)
|
||||
▼
|
||||
Ops PWA ───REST───► targo-hub ─PUT/POST─► ERPNext (Flow Template, Flow Run, Flow Step Pending)
|
||||
FlowEditorDialog flow-api.js (+ Dispatch Job, Issue, Customer, Subscription, …)
|
||||
flow-runtime.js
|
||||
flow-templates.js
|
||||
▲ │
|
||||
└── dispatchEvent ◄──────┘
|
||||
(contracts.js, payments.js, acceptance.js)
|
||||
```
|
||||
|
||||
Deploy order (important):
|
||||
|
||||
1. ERPNext: create doctypes via `setup_flow_templates.py`, wire
|
||||
`flow_scheduler.tick` in `hooks.py`, restart scheduler.
|
||||
2. targo-hub: ship `flow-runtime.js`, `flow-api.js`, `flow-templates.js` +
|
||||
the trigger emitters in `contracts.js` / `payments.js` / `acceptance.js`.
|
||||
Restart container.
|
||||
3. Ops SPA: build + deploy. The editor is usable instantly; it just can't
|
||||
dispatch until the Hub is up.
|
||||
4. Seed the templates (`seed_flow_templates.py`).
|
||||
|
||||
---
|
||||
|
||||
## 14. Glossary
|
||||
|
||||
- **Flow Template (FT)** — the definition, reusable, edited by operators.
|
||||
- **Flow Run (FR)** — one execution against one trigger doc.
|
||||
- **Flow Step Pending (FSP)** — one row per delayed step, the scheduler's inbox.
|
||||
- **Kind** — a step type (`dispatch_job`, `notify`, …). Runtime-dispatched
|
||||
via `KIND_HANDLERS`.
|
||||
- **Catalog** — a set of kinds exposed in one editor instance
|
||||
(`PROJECT_KINDS`, `AGENT_KINDS`).
|
||||
- **Wave** — one iteration of `advanceFlow`'s inner loop. A flow typically
|
||||
needs 1–3 waves; the 50 cap is a safety net against cycles.
|
||||
- **Trigger event** — the incoming signal that kicks off flows
|
||||
(`on_contract_signed`, …).
|
||||
- **Trigger type** — the per-step firing rule inside a flow
|
||||
(`on_prev_complete`, `after_delay`, …).
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-21. Owners: Targo Platform team
|
||||
(<louis@targo.ca>). See `ROADMAP.md` for upcoming Flow Editor v2 items
|
||||
(JSONLogic conditions, sub-flows, parallel branches, retry policies).*
|
||||
BIN
docs/Gigafibre-Billing-Handoff.pptx
Normal file
BIN
docs/Gigafibre-Billing-Handoff.pptx
Normal file
Binary file not shown.
95
docs/HANDOFF.md
Normal file
95
docs/HANDOFF.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Gigafibre FSM — Handoff Index
|
||||
|
||||
> **Purpose:** one-page map of every doc in this repo. Pick the path for your role, read in order, stop when you have enough.
|
||||
|
||||
**Last refreshed:** 2026-04-18
|
||||
|
||||
---
|
||||
|
||||
## Start here (everyone)
|
||||
|
||||
| # | Doc | Why |
|
||||
|---|---|---|
|
||||
| 1 | [STATUS_2026-04-18.md](STATUS_2026-04-18.md) | 10-minute snapshot: where the system stands today, what just shipped, what's next |
|
||||
| 2 | [ARCHITECTURE.md](ARCHITECTURE.md) | Service map: ERPNext, Ops PWA, targo-hub, DocuSeal, Authentik, Traefik |
|
||||
| 3 | [ROADMAP.md](ROADMAP.md) | Phases 1 → 5 with completion status |
|
||||
|
||||
If you only have 15 minutes, read those three.
|
||||
|
||||
---
|
||||
|
||||
## By role
|
||||
|
||||
### New engineer joining the project
|
||||
1. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) — orientation
|
||||
2. [ARCHITECTURE.md](ARCHITECTURE.md) — services and how they talk
|
||||
3. [DATA_AND_FLOWS.md](DATA_AND_FLOWS.md) — ERPNext doctypes + wizard flows
|
||||
4. [APP_DESIGN_GUIDELINES.md](APP_DESIGN_GUIDELINES.md) — UI conventions (light mode default, dispatch dark mode exception)
|
||||
5. Clone the repo, read `README.md`, then the `apps/ops` and `services/targo-hub` READMEs
|
||||
|
||||
### Stakeholder / non-technical reader
|
||||
1. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) — TL;DR + "Where we are" table
|
||||
2. [Gigafibre-FSM-Features.pptx](Gigafibre-FSM-Features.pptx) — feature deck
|
||||
3. [Gigafibre-Billing-Handoff.pptx](Gigafibre-Billing-Handoff.pptx) — billing migration deck
|
||||
4. [ROADMAP.md](ROADMAP.md) — what's done vs. queued
|
||||
|
||||
### Accountant / billing ops
|
||||
1. [BILLING_AND_PAYMENTS.md](BILLING_AND_PAYMENTS.md) — full billing architecture, Stripe flow, subscription logic
|
||||
2. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) §"ERPNext customizations" — custom fields on Item / Quotation Item / Sales Invoice Item
|
||||
3. [Gigafibre-Billing-Handoff.pptx](Gigafibre-Billing-Handoff.pptx) — visual walkthrough
|
||||
|
||||
### Dispatcher / field ops lead
|
||||
1. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) §"Features inventory" — Ops, Dispatch, Field
|
||||
2. [DATA_AND_FLOWS.md](DATA_AND_FLOWS.md) — Issue → Job → Technician flow
|
||||
3. [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) — CPE lifecycle, GenieACS, modem-bridge
|
||||
|
||||
### Infrastructure / DevOps
|
||||
1. [ARCHITECTURE.md](ARCHITECTURE.md) — network + container map
|
||||
2. [STATUS_2026-04-18.md](STATUS_2026-04-18.md) §"Integrations" — external services and credentials location
|
||||
3. [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) — GenieACS + OLT + SNMP
|
||||
|
||||
---
|
||||
|
||||
## Document catalog
|
||||
|
||||
| Doc | What it covers | When to read |
|
||||
|---|---|---|
|
||||
| [STATUS_2026-04-18.md](STATUS_2026-04-18.md) | Full state snapshot, features, integrations, known issues, gotchas | **Always first** |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Services, containers, networks, routes | Onboarding or infra changes |
|
||||
| [ROADMAP.md](ROADMAP.md) | Phase plan with checkboxes | Planning or standups |
|
||||
| [DATA_AND_FLOWS.md](DATA_AND_FLOWS.md) | Data model and user/workflow flows | Building features that touch ERPNext |
|
||||
| [BILLING_AND_PAYMENTS.md](BILLING_AND_PAYMENTS.md) | Subscription lifecycle, invoice generation, Stripe, payment reconciliation | Billing work |
|
||||
| [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md) | CPE database, GenieACS, provisioning, diagnostics | CPE or network work |
|
||||
| [APP_DESIGN_GUIDELINES.md](APP_DESIGN_GUIDELINES.md) | UI tokens, theming, component conventions | Frontend work |
|
||||
| [Gigafibre-FSM-Features.pptx](Gigafibre-FSM-Features.pptx) | Feature deck for demo / training | Sharing with non-engineers |
|
||||
| [Gigafibre-Billing-Handoff.pptx](Gigafibre-Billing-Handoff.pptx) | Billing deck for finance handoff | Sharing with finance team |
|
||||
| [archive/](archive/) | Deprecated docs kept for reference | Historical questions only |
|
||||
|
||||
---
|
||||
|
||||
## Conventions used across these docs
|
||||
|
||||
- **Status emoji:** ✅ shipped · 🚧 in progress · 🆕 just shipped this cycle · ⏸ paused · 🗃 legacy/read-only
|
||||
- **Brand:** "Gigafibre" = consumer-facing brand, "TARGO" = parent company (both appear; not a rename)
|
||||
- **Environments:** production-only at erp.gigafibre.ca. Legacy DB on 10.100.80.100 is read-only.
|
||||
- **Dates:** always ISO (YYYY-MM-DD). "Current" means 2026-04-18 unless noted.
|
||||
|
||||
---
|
||||
|
||||
## Where things live outside this repo
|
||||
|
||||
- **Memory notes:** `~/.claude/projects/-Users-louispaul-Documents-testap/memory/` — context for Claude across sessions
|
||||
- **Production server:** `ssh root@96.125.196.67` (ssh-agent + `~/.ssh/proxmox_vm`)
|
||||
- **ERPNext containers:** `frappe_docker-backend-1`, `frappe_docker-db-1` (PostgreSQL)
|
||||
- **targo-hub:** `/opt/targo-hub/` on prod (volume-mounted `lib/`)
|
||||
- **DocuSeal:** `/opt/docuseal/` on prod
|
||||
|
||||
---
|
||||
|
||||
## Next doc-writing priorities
|
||||
|
||||
- [ ] `PROVISIONING.md` — ONT/OLT/GenieACS workflow when onboarding a new subscriber
|
||||
- [ ] `DEPLOY.md` — step-by-step deploy for each app (currently scattered across feedback memory)
|
||||
- [ ] `INCIDENTS.md` — post-mortem log format + template
|
||||
|
||||
Add new docs to the catalog table above when you create them.
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
# Gigafibre — Infrastructure & Services
|
||||
|
||||
> Document de référence pour l'infrastructure complète. Sert de guide de transfert pour toute personne reprenant l'environnement.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
96.125.196.67 (Proxmox VM, Ubuntu 24.04)
|
||||
│
|
||||
├─ Traefik v2.11 (ports 80/443)
|
||||
│ ├─ Let's Encrypt TLS auto-renew
|
||||
│ ├─ ForwardAuth middleware → Authentik
|
||||
│ └─ Routes → Docker containers
|
||||
│
|
||||
├─ Authentik SSO (auth.targo.ca)
|
||||
│ └─ ForwardAuth pour toutes les apps protégées
|
||||
│
|
||||
├─ ERPNext v16.10.1 (erp.gigafibre.ca)
|
||||
│ ├─ 9 containers (frontend, backend, worker, scheduler, redis, postgres)
|
||||
│ └─ Custom doctypes: Dispatch + FSM
|
||||
│
|
||||
├─ Dispatch PWA (dispatch.gigafibre.ca)
|
||||
│ ├─ nginx servant les fichiers statiques Vue/Quasar
|
||||
│ └─ Proxy /api/ → ERPNext, /traccar/ → Traccar
|
||||
│
|
||||
├─ n8n (n8n.gigafibre.ca)
|
||||
│ ├─ Auto-login proxy (lit X-authentik-email de forwardAuth)
|
||||
│ └─ Workflows: SMS routing, sharing, automation
|
||||
│
|
||||
├─ Gitea (git.targo.ca)
|
||||
│
|
||||
├─ www.gigafibre.ca
|
||||
│ ├─ React/Vite site vitrine
|
||||
│ └─ API Node.js (recherche adresses, contact, SMS)
|
||||
│
|
||||
├─ Oktopus CE (oss.gigafibre.ca) — TR-069 CPE management
|
||||
│
|
||||
└─ Hub (hub.gigafibre.ca) — Dashboard interne
|
||||
```
|
||||
|
||||
## DNS (Cloudflare)
|
||||
|
||||
Domaine `gigafibre.ca` géré sur Cloudflare en mode **DNS-only** (pas de proxy CF, Traefik gère le TLS).
|
||||
|
||||
| Record | Type | Valeur | Notes |
|
||||
|--------|------|--------|-------|
|
||||
| `@` | A | 96.125.196.67 | |
|
||||
| `*.gigafibre.ca` | A | 96.125.196.67 | Wildcard |
|
||||
| `@` | MX | aspmx.l.google.com (10) | Google Workspace |
|
||||
| `@` | MX | alt1-4.aspmx.l.google.com | |
|
||||
| `@` | TXT | v=spf1 include:spf.mailjet.com include:_spf.google.com ~all | |
|
||||
| `mailjet._748826c7` | TXT | 748826c702abf9015ac2e8a202336527 | DKIM Mailjet |
|
||||
|
||||
Domaine `targo.ca` encore sur OpenSRS (migration Cloudflare planifiée).
|
||||
|
||||
## Reverse Proxy (Traefik)
|
||||
|
||||
**Emplacement serveur:** `/opt/traefik/`
|
||||
|
||||
- Version: v2.11 (ne pas upgrader à v3 — incompatible Docker 29, API v1.24 vs v1.40)
|
||||
- TLS: Let's Encrypt HTTP-01 challenge
|
||||
- Réseau Docker: `proxy` (tous les containers exposés doivent y être)
|
||||
|
||||
### Middleware Authentik
|
||||
|
||||
```yaml
|
||||
# /opt/traefik/dynamic/routes.yml
|
||||
http:
|
||||
middlewares:
|
||||
authentik:
|
||||
forwardAuth:
|
||||
address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
|
||||
trustForwardHeader: true
|
||||
authResponseHeaders:
|
||||
- X-authentik-username
|
||||
- X-authentik-email
|
||||
- X-authentik-name
|
||||
```
|
||||
|
||||
**Gotcha:** Le redirect HTTP→HTTPS ne doit pas intercepter `/.well-known/acme-challenge/` sinon Let's Encrypt échoue.
|
||||
|
||||
## Authentik SSO
|
||||
|
||||
**URL:** https://auth.targo.ca
|
||||
|
||||
### Flux d'authentification
|
||||
|
||||
```
|
||||
Utilisateur → app.gigafibre.ca
|
||||
→ Traefik vérifie session via forwardAuth
|
||||
→ Pas de session? → Redirect auth.targo.ca/flows/authorize/
|
||||
→ Login (username/password ou SSO)
|
||||
→ Callback → Cookie session set → App charge
|
||||
```
|
||||
|
||||
### Applications configurées
|
||||
|
||||
| App | Type | Status |
|
||||
|-----|------|--------|
|
||||
| dispatch.gigafibre.ca | ForwardAuth (Traefik) | Actif |
|
||||
| n8n.gigafibre.ca | ForwardAuth + auto-login proxy | Actif |
|
||||
| hub.gigafibre.ca | ForwardAuth (Traefik) | Actif |
|
||||
| erp.gigafibre.ca | OAuth2/OIDC (Provider) | En place |
|
||||
|
||||
### n8n auto-login (workaround)
|
||||
|
||||
n8n Community Edition ne supporte pas OIDC. Solution :
|
||||
- Proxy Node.js (`/opt/n8n-proxy/server.js`) devant n8n
|
||||
- Lit le header `X-authentik-email` injecté par forwardAuth
|
||||
- Crée une session n8n via `POST /api/v1/login` avec owner credentials
|
||||
- Forward le cookie de session au navigateur
|
||||
|
||||
## ERPNext
|
||||
|
||||
**URL:** https://erp.gigafibre.ca
|
||||
**Compose:** `/opt/erpnext/`
|
||||
**DB:** PostgreSQL, base `_eb65bdc0c4b1b2d6`
|
||||
|
||||
### Token API (service token)
|
||||
|
||||
```
|
||||
$ERP_SERVICE_TOKEN # stocké dans /opt/dispatch-app/.env sur le serveur
|
||||
```
|
||||
|
||||
Utilisé par le Dispatch PWA pour les appels API côté serveur (pas de session utilisateur — Authentik gère l'auth frontend, le token fixe gère l'auth API).
|
||||
|
||||
**Gotcha:** Ne jamais appeler `generate_keys` sur l'utilisateur Administrator — ça invalide le token existant.
|
||||
|
||||
### Doctypes personnalisés (module: Dispatch)
|
||||
|
||||
**Core:**
|
||||
- `Dispatch Job` — Ordres de travail (SUP-#####)
|
||||
- `Dispatch Technician` — Profils techniciens + GPS
|
||||
- `Dispatch Tag` — Catégorisation
|
||||
|
||||
**FSM (extension):**
|
||||
- `Service Location` (LOC-#####) — Locaux client
|
||||
- `Service Equipment` (EQP-#####) — ONT, routeur, etc.
|
||||
- `Service Subscription` (SUB-#####) — Forfaits actifs
|
||||
- `Checklist Template` — Templates réutilisables
|
||||
|
||||
**Tables enfants:** Equipment Move Log, Job Equipment Item, Job Material Used, Job Checklist Item, Job Photo, Checklist Template Item, Dispatch Job Assistant, Dispatch Tag Link
|
||||
|
||||
### Patch appliqué
|
||||
|
||||
`number_card.py` — ERPNext v16 PostgreSQL: `ORDER BY creation` sur queries agrégées cause erreur. Corrigé avec `order_by=""`.
|
||||
|
||||
## Dispatch PWA
|
||||
|
||||
**URL:** https://dispatch.gigafibre.ca
|
||||
**Repo:** `git.targo.ca/louis/OSS-BSS-Field-Dispatch`
|
||||
**Stack:** Vue 3 / Quasar / Pinia / Mapbox GL JS
|
||||
|
||||
### Architecture du code
|
||||
|
||||
```
|
||||
src/
|
||||
App.vue — Router view + auth check au mount
|
||||
api/
|
||||
auth.js — Service token, détection expiry Authentik
|
||||
dispatch.js — CRUD ERPNext (authFetch)
|
||||
traccar.js — GPS REST + WebSocket
|
||||
stores/
|
||||
dispatch.js — Store principal: jobs, techs, GPS
|
||||
auth.js — Session check (simplifié, Authentik gère)
|
||||
pages/
|
||||
DispatchV2Page.vue — Page principale (~1500 lignes)
|
||||
composables/
|
||||
useMap.js — Carte Mapbox, marqueurs SVG progress ring
|
||||
useScheduler.js — Algorithme timeline
|
||||
useDragDrop.js — Drag & drop avec batch
|
||||
useSelection.js — Lasso, multi-select
|
||||
useAutoDispatch.js — Auto-distribute + route optimize
|
||||
useBottomPanel.js — Panel jobs non-assignés
|
||||
useUndo.js — Undo stack groupé
|
||||
config/
|
||||
erpnext.js — BASE_URL (vide en prod = same-origin)
|
||||
```
|
||||
|
||||
### GPS Tracking
|
||||
|
||||
- **Traccar** sur `tracker.targointernet.com:8082`
|
||||
- Proxy nginx: `dispatch.gigafibre.ca/traccar/api/*` → Traccar
|
||||
- **Hybride REST + WebSocket:** REST initial → WebSocket real-time → fallback polling 30s
|
||||
- Guards module-level (`__gpsStarted`, `__gpsPolling`) pour survivre au remount Vue
|
||||
|
||||
### Build & Deploy
|
||||
|
||||
```bash
|
||||
cd dispatch-app
|
||||
DEPLOY_BASE=/ npx quasar build -m pwa
|
||||
tar czf /tmp/dispatch-pwa.tar.gz -C dist/pwa .
|
||||
cat /tmp/dispatch-pwa.tar.gz | ssh -i ~/.ssh/proxmox_vm root@96.125.196.67 \
|
||||
'rm -rf /opt/dispatch-app/*.js /opt/dispatch-app/*.html /opt/dispatch-app/*.json /opt/dispatch-app/assets /opt/dispatch-app/icons; cat > /tmp/d.tar.gz && cd /opt/dispatch-app && tar xzf /tmp/d.tar.gz && rm /tmp/d.tar.gz'
|
||||
```
|
||||
|
||||
### nginx config
|
||||
|
||||
`/opt/dispatch-app/nginx.conf`:
|
||||
- SPA fallback: `try_files $uri /index.html`
|
||||
- Proxy `/api/` → `erpnext-frontend-1:8080` (Docker DNS)
|
||||
- Proxy `/traccar/` → `tracker.targointernet.com:8082`
|
||||
- No-cache sur `index.html` et `sw.js` (éviter caching stale)
|
||||
- Réseau partagé: container dans `erpnext_erpnext` network + `resolver 127.0.0.11`
|
||||
|
||||
## Site web (www.gigafibre.ca)
|
||||
|
||||
**Repo:** `git.targo.ca/louis/site-web-targo`
|
||||
**Stack:** React / Vite / Tailwind / shadcn-ui
|
||||
**Origine:** lovable.dev (modifié)
|
||||
|
||||
### API backend
|
||||
|
||||
`/opt/www-api/server.js` — Node.js Express:
|
||||
|
||||
| Endpoint | Fonction |
|
||||
|----------|----------|
|
||||
| `POST /rpc/search_addresses` | Recherche fuzzy 5.2M adresses québécoises |
|
||||
| `POST /rpc/contact` | Formulaire contact → email Mailjet |
|
||||
| `POST /rpc/lead` | Qualification adresse → email Mailjet |
|
||||
| `POST /rpc/sms` | Envoi SMS Twilio |
|
||||
| `POST /rpc/sms-incoming` | Webhook Twilio entrant → email Mailjet |
|
||||
|
||||
### Base de données adresses (RQA)
|
||||
|
||||
- Table `rqa_addresses`: 5.2M lignes (Répertoire Québécois des Adresses)
|
||||
- Table `fiber_availability`: 21,623 entrées (matching numero + code_postal)
|
||||
- Recherche 3 phases: exact → fuzzy pg_trgm → J0S/J0L priority
|
||||
- PostgreSQL dans le compose ERPNext (même instance DB)
|
||||
|
||||
## Email (Mailjet)
|
||||
|
||||
**Expéditeur:** `noreply@targo.ca` (Name: Gigafibre)
|
||||
|
||||
### Flux
|
||||
|
||||
```
|
||||
Contact form (www) → /rpc/contact → Mailjet API → support@targo.ca
|
||||
Lead capture (www) → /rpc/lead → Mailjet API → louis@targo.ca
|
||||
SMS entrant (Twilio) → webhook → Mailjet API → louis@targo.ca
|
||||
```
|
||||
|
||||
### DNS requis (targo.ca)
|
||||
|
||||
- SPF: `v=spf1 include:spf.mailjet.com include:_spf.google.com ~all`
|
||||
- DKIM: `mailjet._748826c7.targo.ca` → valeur Mailjet
|
||||
- **Status:** SPF/DKIM pour targo.ca en erreur côté Mailjet (à corriger)
|
||||
|
||||
## SMS (Twilio)
|
||||
|
||||
**Numéro:** +1 (438) 231-3838
|
||||
**SID:** `$TWILIO_ACCOUNT_SID` # voir 1Password / secrets manager
|
||||
**Status:** Compte trial (SMS limité aux numéros vérifiés)
|
||||
|
||||
### Flux
|
||||
|
||||
```
|
||||
Envoi: App → /rpc/sms → Twilio API → Destinataire
|
||||
Réception: Twilio → webhook POST → n8n.gigafibre.ca/webhook/sms-incoming
|
||||
→ Forward vers www.gigafibre.ca/rpc/sms-incoming → Email Mailjet
|
||||
```
|
||||
|
||||
**À faire:**
|
||||
- Upgrader vers compte Twilio production
|
||||
- Configurer 10DLC ou acheter numéro Toll-Free
|
||||
- Changer webhook vers n8n directement une fois workflow activé
|
||||
|
||||
## n8n Workflows
|
||||
|
||||
**URL:** https://n8n.gigafibre.ca
|
||||
**Compose:** Dans `/opt/erpnext/` (override n8n)
|
||||
|
||||
### Workflows créés
|
||||
|
||||
| Nom | ID | Fonction | Status |
|
||||
|-----|-----|----------|--------|
|
||||
| [System] Share all workflows | MC5CxJ6QD2s8OCrr | Partage workflows/credentials entre projets (SQLite) | À activer via UI |
|
||||
| SMS Incoming → Email + Router | j8lC0f6aHrV9L5Ud | Webhook Twilio → forward API | À activer via UI |
|
||||
|
||||
**Gotcha:** L'activation via API (`POST /activate`) ne registre pas les webhooks dans n8n 2.x — toujours activer via le toggle UI.
|
||||
|
||||
**Gotcha:** Le node `executeCommand` n'existe pas dans n8n 2.x — utiliser `n8n-nodes-base.code` avec `require('child_process')`.
|
||||
|
||||
## Repos Git (git.targo.ca)
|
||||
|
||||
| Repo | Contenu |
|
||||
|------|---------|
|
||||
| `louis/OSS-BSS-Field-Dispatch` | Dispatch PWA (Vue/Quasar) |
|
||||
| `louis/site-web-targo` | www.gigafibre.ca (React/Vite) |
|
||||
| `louis/gigafibre-fsm` | Docs architecture, scripts setup FSM |
|
||||
| `louis/gigafibre-infra` | Configs serveur (traefik, docker-compose) |
|
||||
|
||||
> **Note:** `git.gigafibre.ca` a été supprimé — utiliser uniquement `git.targo.ca`.
|
||||
|
||||
## Docker — Réseaux et Compose
|
||||
|
||||
### Fichiers compose sur le serveur
|
||||
|
||||
| Emplacement | Services |
|
||||
|-------------|----------|
|
||||
| `/opt/traefik/docker-compose.yml` | Traefik |
|
||||
| `/opt/erpnext/compose.yaml` | ERPNext (9 containers) |
|
||||
| `/opt/erpnext/overrides/compose.n8n.yaml` | n8n + auto-login proxy |
|
||||
| `/opt/apps/docker-compose.yml` | Gitea + dispatch-frontend + targo-db |
|
||||
| `/opt/oktopus/docker-compose.yml` | Oktopus CE (8 containers) |
|
||||
| `/opt/www-api/docker-compose.yml` | API www.gigafibre.ca |
|
||||
|
||||
### Réseaux Docker
|
||||
|
||||
| Réseau | Usage |
|
||||
|--------|-------|
|
||||
| `proxy` | Traefik ↔ tous les containers exposés |
|
||||
| `erpnext_erpnext` | ERPNext interne + dispatch nginx (pour proxy /api/) |
|
||||
|
||||
**Gotcha:** Containers multi-réseaux → label `traefik.docker.network=proxy` obligatoire.
|
||||
|
||||
## Gotchas & Pièges
|
||||
|
||||
1. **Traefik v3 incompatible** avec Docker 29 (API v1.24 vs v1.40) — rester sur v2.11
|
||||
2. **HTTP→HTTPS redirect** ne doit pas intercepter ACME challenge
|
||||
3. **MongoDB 5+** nécessite AVX — CPU Proxmox doit être type "host"
|
||||
4. **netplan** override systemd-networkd — supprimer `netplan.io`
|
||||
5. **DEPLOY_BASE=/** requis pour `quasar build` en déploiement root domain
|
||||
6. **nginx SPA** fallback: `try_files $uri /index.html`
|
||||
7. **ERPNext generate_keys** invalide le token existant — ne jamais appeler
|
||||
8. **Mixed content HTTPS→HTTP** — toujours utiliser proxy pour Traccar
|
||||
9. **Service Worker cache** — headers no-cache sur index.html et sw.js
|
||||
10. **Traccar API** ne supporte qu'un seul `deviceId` par requête — parallel calls
|
||||
|
||||
## Accès serveur
|
||||
|
||||
```bash
|
||||
# SSH (voir 1Password pour la clé)
|
||||
ssh -i ~/.ssh/proxmox_vm root@96.125.196.67
|
||||
|
||||
# Logs Traefik
|
||||
docker logs -f traefik
|
||||
|
||||
# Logs ERPNext
|
||||
cd /opt/erpnext && docker compose logs -f backend
|
||||
|
||||
# Logs n8n
|
||||
cd /opt/erpnext && docker compose -f compose.yaml -f overrides/compose.n8n.yaml logs -f n8n
|
||||
|
||||
# Restart dispatch nginx
|
||||
docker restart dispatch-frontend
|
||||
|
||||
# Rebuild dispatch
|
||||
# (voir section Build & Deploy ci-haut)
|
||||
```
|
||||
|
||||
## Tâches en cours
|
||||
|
||||
- [ ] Activer les 2 workflows n8n via UI toggle
|
||||
- [ ] Twilio: upgrade production + 10DLC
|
||||
- [ ] Corriger SPF/DKIM targo.ca dans Mailjet
|
||||
- [ ] Google Workspace: ajouter gigafibre.ca comme domaine alias
|
||||
- [ ] FSM Phase 2: customer/location picker, equipment scan, checklist UI
|
||||
- [ ] Portail client self-service sur www.gigafibre.ca
|
||||
- [ ] Pousser frappe_docker mods sur gitea
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
# Gigafibre Platform Strategy
|
||||
## Vision unifiée par département — Inspiré Calix CommandIQ / SmartTown
|
||||
|
||||
> Avril 2026 — Document de planification stratégique
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventaire des applications actuelles
|
||||
|
||||
### Applications à CONSERVER (core platform)
|
||||
|
||||
| App | URL | Rôle | Stack |
|
||||
|-----|-----|------|-------|
|
||||
| **ERPNext v16** | erp.gigafibre.ca | Source de vérité (clients, facturation, équipement, tickets) | Python/PostgreSQL |
|
||||
| **Targo Ops** | erp.gigafibre.ca/ops/ | Console opérations internes (dispatch, clients, tickets, réseau) | Vue 3 / Quasar PWA |
|
||||
| **Targo Hub** | msg.gigafibre.ca | Backend API, SSE temps réel, SMS, vision AI, magic links, page mobile tech | Node.js 20 |
|
||||
| **Client Portal** | client.gigafibre.ca | Self-service client (factures, support, catalogue) | Vue 3 / Quasar PWA |
|
||||
| **Website** | www.gigafibre.ca | Site vitrine marketing, vérification d'adresse | React / Vite |
|
||||
| **Authentik (Client)** | id.gigafibre.ca | SSO clients et staff | Docker |
|
||||
| **Traefik** | — | Reverse proxy, TLS, routing | Docker |
|
||||
| **n8n** | n8n.gigafibre.ca | Workflows automatisés (SMS, email, webhooks) | Docker |
|
||||
|
||||
### Applications à RETIRER (legacy / redondantes)
|
||||
|
||||
| App | URL | Raison du retrait | Fonctionnalités à migrer | Statut migration |
|
||||
|-----|-----|-------------------|-------------------------|-----------------|
|
||||
| **dispatch-app** (legacy) | dispatch.gigafibre.ca | Remplacé par Ops `/dispatch` + page mobile `/t/{token}` | ~~Timeline dispatch~~ ✅, ~~Mobile tech~~ ✅, ~~Magic links~~ ✅, Catalogue équipement dans mobile ✅, Scan barcode ✅ | **Prêt à retirer** |
|
||||
| **Targo Field App** | (apps/field/) | Remplacé par page mobile lightweight `/t/{token}` servi par targo-hub | ~~Job list~~ ✅, ~~Job detail~~ ✅, ~~Scan barcode~~ ✅, ~~Diagnostic~~ (à évaluer) | **Prêt à retirer** |
|
||||
| **Oktopus CE** (TR-069 ACS) | oss.gigafibre.ca | ⚠️ Fait du TR-369 — pas d'alternative connue pour big data CPE temps réel | — | **Conserver** |
|
||||
|
||||
### Applications à NE PAS RETIRER
|
||||
|
||||
| App | Raison |
|
||||
|-----|--------|
|
||||
| **Authentik (Staff)** auth.targo.ca | Activement utilisé — remplacement impossible à court terme |
|
||||
| **Legacy DB** (MariaDB) | Migration incomplète (~20/160 tables). Données critiques encore exclusives : comptabilité, soumissions, accords, télécom, projets, consommation, fibre/IP |
|
||||
| **Oktopus CE** | Seule solution TR-369 pour statut temps réel et big data des CPE clients |
|
||||
|
||||
### Applications PARALLÈLES (hors scope Gigafibre FSM)
|
||||
|
||||
| App | Rôle | Note |
|
||||
|-----|------|------|
|
||||
| **Targo Backend/Frontend** | RH/Feuilles de temps employés Targo | Projet séparé, pas de chevauchement |
|
||||
| **Infra-Map-Vue** | Cartographie fibre topologique (poteaux, routes optiques) | À intégrer éventuellement dans Ops comme module réseau |
|
||||
| **Device Monitor** | Monitoring devices (prototype) | Fonctionnalités à absorber par targo-hub OLT/SNMP |
|
||||
|
||||
---
|
||||
|
||||
## 2. Fonctionnalités par département
|
||||
|
||||
### MARKETING — Segmentation dynamique et ventes contextuelles
|
||||
|
||||
> *"Patterns and contextual signals are analyzed to dynamically segment audiences and identify upsell and cross-sell opportunities."*
|
||||
|
||||
**Données disponibles dans ERPNext aujourd'hui :**
|
||||
- Historique de facturation (115K+ factures) — panier moyen, ancienneté, churn risk
|
||||
- Tickets de support (242K+) — fréquence des appels, types de problèmes récurrents
|
||||
- Équipement installé (7 500+) — âge du modem, capacité Wi-Fi, nombre d'appareils
|
||||
- Abonnements actifs (21K+) — services manquants (pas de TV, pas de VoIP)
|
||||
- Données réseau (OLT, signal optique, débit) — qualité de service réelle
|
||||
|
||||
**Stratégie upsell/cross-sell contextuelle :**
|
||||
|
||||
| Signal contextuel | Action marketing | Canal |
|
||||
|-------------------|-----------------|-------|
|
||||
| Client avec Internet seulement + ticket "lenteur Wi-Fi" | Proposer routeur Wi-Fi 6 mesh + bornes extérieures | SMS personnalisé via Twilio |
|
||||
| Client sans TV + visionnement Netflix détecté (DPI/QoS) | Offrir IPTV bundle à prix réduit | Email via Mailjet + bannière portail |
|
||||
| Client avec ancien modem (>3 ans) | Upgrade gratuit vers ONT dernière gen | Notification push portail client |
|
||||
| Client avec >3 tickets/mois | Offrir Support Prioritaire (10$/mois) | Appel proactif CSR |
|
||||
| Déménagement détecté (changement adresse) | Offrir installation complète maison intelligente | Visite tech + catalogue domotique |
|
||||
| Client fidèle >5 ans, 0 ticket | Programme fidélité : caméra d'entrée offerte | Lettre personnalisée + portail |
|
||||
|
||||
**Modules à développer :**
|
||||
|
||||
| Module | Description | Où |
|
||||
|--------|-------------|-----|
|
||||
| **Segment Engine** | Requêtes ERPNext automatisées qui tagguent les clients par segment (à risque, upsell TV, upgrade Wi-Fi, fidèle, nouveau) | n8n workflow + ERPNext Custom Script |
|
||||
| **Campaign Manager** | Interface dans Ops pour créer/envoyer des campagnes SMS/email ciblées par segment | Ops app — nouveau module `/campaigns` |
|
||||
| **Smart Banners** | Bannières contextuelles dans le portail client basées sur le profil | Client Portal — composant dynamique |
|
||||
| **QR Modem → Offre** | QR code sur le modem → URL → détecte le client → affiche offres personnalisées | targo-hub page servie (comme `/t/{token}`) |
|
||||
|
||||
**Produits domotique à offrir :**
|
||||
- Bornes Wi-Fi extérieures (mesh outdoor)
|
||||
- Caméras d'entrée connectées (Doorbell IP)
|
||||
- Thermostats intelligents
|
||||
- Détecteurs de fumée/CO connectés
|
||||
- Serrures intelligentes
|
||||
- Hub domotique Zigbee/Z-Wave
|
||||
|
||||
---
|
||||
|
||||
### OPERATIONS — Proactivité et résolution automatique
|
||||
|
||||
> *"Operations agent workforce works around the clock to proactively uncover and resolve issues, reducing outages and improving the subscriber experience."*
|
||||
|
||||
**Monitoring proactif actuel :**
|
||||
- OLT SNMP polling (targo-hub `olt-snmp.js`) — signal optique, statut ONU
|
||||
- GenieACS TR-069 — paramètres CPE, reboots, firmware
|
||||
- Traccar GPS — position techniciens en temps réel
|
||||
|
||||
**Vision proactive à implémenter :**
|
||||
|
||||
| Détection | Action automatique | Notification |
|
||||
|-----------|-------------------|-------------|
|
||||
| Signal optique ONU dégradé (<-25 dBm) | Créer ticket prioritaire + dispatch auto tech le plus proche | SMS client : "Nous avons détecté un problème, un technicien est en route" |
|
||||
| ONT hors ligne >30 min (pas panne secteur) | Vérifier statut OLT, si OK → créer ticket | SMS client : "Votre connexion semble interrompue, nous investiguons" |
|
||||
| Wi-Fi congestionné (>20 clients, canal saturé) | Push config optimale via TR-069 (changement canal auto) | Notification portail : "Nous avons optimisé votre Wi-Fi" |
|
||||
| Latence >50ms vers gateway | Alert dispatch + diagnostic réseau auto | Rien (résolution silencieuse si possible) |
|
||||
| Firmware CPE obsolète | Schedule mise à jour nocturne via GenieACS | Email : "Mise à jour de sécurité appliquée" |
|
||||
| Panne OLT (multiple ONU down) | Créer incident majeur, notifier tous clients affectés en masse | SMS masse : "Panne détectée dans votre secteur, résolution en cours" |
|
||||
|
||||
**Modules existants vs à développer :**
|
||||
|
||||
| Module | Statut | Prochaine étape |
|
||||
|--------|--------|----------------|
|
||||
| Dispatch Timeline (drag-drop, Gantt) | ✅ Complet | — |
|
||||
| Tags/Compétences (match auto) | ✅ Complet | — |
|
||||
| Horaires + RRULE (garde, shifts) | ✅ Complet | — |
|
||||
| Publication SMS + magic links | ✅ Complet | — |
|
||||
| Page mobile tech lightweight | ✅ Complet | — |
|
||||
| Scan barcode + gestion équipement | ✅ Complet | — |
|
||||
| Pool d'offres Uber-style | ✅ Complet | — |
|
||||
| Confirmation unassign jobs publiés | ✅ Complet | — |
|
||||
| OLT SNMP monitoring | ✅ Basique | Ajouter alertes automatiques |
|
||||
| Proactive ticket creation | ❌ À faire | n8n workflow : SNMP alert → create Issue |
|
||||
| Auto-dispatch (matching algo) | ❌ À faire | Algorithme basé sur tags + distance + charge |
|
||||
| Outage detection + mass notify | ❌ À faire | Corréler ONU down par OLT → SMS masse |
|
||||
| CPE auto-config via TR-069 | ❌ À faire | GenieACS presets + targo-hub proxy |
|
||||
|
||||
---
|
||||
|
||||
### SERVICE — Intelligence contextuelle pour CSR et résolution au premier appel
|
||||
|
||||
> *"Specialized service agents share best practices and contextual insights to CSRs to solve problems faster and on the first call."*
|
||||
|
||||
**Données contextuelles disponibles pour le CSR (dans Ops app) :**
|
||||
- Historique complet du client (tickets, factures, paiements)
|
||||
- Équipement installé avec diagnostics en temps réel (signal, Wi-Fi, devices connectés)
|
||||
- Notes des techniciens sur les visites précédentes
|
||||
- Statut réseau du secteur (panne en cours ?)
|
||||
- Historique des interventions sur cette adresse
|
||||
|
||||
**Outils d'aide CSR à développer :**
|
||||
|
||||
| Outil | Description | Impact |
|
||||
|-------|-------------|--------|
|
||||
| **Client Context Card** | Vue unifiée en 1 écran : abo actifs, dernier ticket, équipement, santé réseau, paiements en retard | Réduction temps d'appel de 40% |
|
||||
| **Diagnostic Auto** | Bouton "Diagnostiquer" sur la fiche client → lance ping, speedtest, check signal ONU, vérifie firmware | Résolution 1er appel +30% |
|
||||
| **Knowledge Base contextuelle** | Suggestions automatiques basées sur le type de problème : "Ce client a un HG8245H → vérifier le port LAN 4 connu pour défaillance" | Partage best practices |
|
||||
| **Script d'appel guidé** | Flow interactif : symptôme → questions → diagnostic → solution → escalation si nécessaire | Uniformité du service |
|
||||
| **Historique interactions** | Timeline unifiée : appels, SMS, emails, visites tech, modifications de compte | Contexte complet |
|
||||
|
||||
**État actuel vs cible :**
|
||||
|
||||
| Fonctionnalité | Statut |
|
||||
|---------------|--------|
|
||||
| Fiche client avec équipement et tickets | ✅ Ops `/clients/:id` |
|
||||
| Détail équipement avec diagnostic OLT | ✅ EquipmentDetail.vue |
|
||||
| Envoi SMS/email depuis Ops | ✅ Via targo-hub/Twilio |
|
||||
| Historique thread de tickets | ✅ Ops tickets module |
|
||||
| Diagnostic auto (1 clic) | ❌ À intégrer (GenieACS + SNMP) |
|
||||
| Suggestions contextuelles AI | ❌ À faire (Gemini sur historique tickets similaires) |
|
||||
| Scripts d'appel guidés | ❌ À faire |
|
||||
|
||||
---
|
||||
|
||||
### SUBSCRIBER — L'intelligence du support dans les mains du client
|
||||
|
||||
> *"Subscriber agents extend the reach of support organizations directly to subscribers with personalized upsell opportunities, optimization techniques, and outage information through the subscriber's app."*
|
||||
|
||||
**Portail client actuel (client.gigafibre.ca) :**
|
||||
- ✅ Consultation factures et historique
|
||||
- ✅ Paiement Stripe
|
||||
- ✅ Création/suivi de tickets
|
||||
- ✅ Catalogue produits avec panier
|
||||
- ✅ Auth SSO (id.gigafibre.ca)
|
||||
|
||||
**Fonctionnalités Subscriber à ajouter :**
|
||||
|
||||
| Feature | Description | Priorité |
|
||||
|---------|-------------|----------|
|
||||
| **QR Code sur modem** | Étiquette QR collée sur le modem → URL `msg.gigafibre.ca/q/{mac}` → identifie le compte → envoie token SMS/email au propriétaire → accès gestion compte | 🔴 Haute |
|
||||
| **Dashboard santé réseau** | Vitesse actuelle, latence, uptime 30 jours, appareils connectés | 🔴 Haute |
|
||||
| **Contrôle parental (langage naturel)** | "Bloquer TikTok pour les enfants après 21h" → traduit en règles TR-069 → push au routeur | 🟡 Moyenne |
|
||||
| **Notifications outage** | Push/SMS automatique quand panne détectée dans le secteur, avec ETA résolution | 🔴 Haute |
|
||||
| **Optimisation Wi-Fi** | Conseils personnalisés : "Votre routeur est dans le sous-sol, déplacez-le au rez-de-chaussée pour +40% de couverture" | 🟡 Moyenne |
|
||||
| **Self-diagnostic** | Bouton "Tester ma connexion" → speedtest + vérification signal → rapport | 🔴 Haute |
|
||||
| **Offres personnalisées** | Bannières contextuelles : "Ajoutez la TV IPTV à votre forfait pour 20$/mois" basé sur profil | 🟡 Moyenne |
|
||||
| **Gestion appareils** | Liste des appareils connectés, renommer, bloquer, prioritiser | 🟡 Moyenne |
|
||||
| **Historique consommation** | Graphique de bande passante utilisée par jour/semaine/mois | 🟢 Basse |
|
||||
|
||||
---
|
||||
|
||||
## 3. QR Code Modem — Flow technique détaillé
|
||||
|
||||
Le QR code sur le modem est un game-changer pour l'expérience client. Flow :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ÉTIQUETTE QR SUR LE MODEM │
|
||||
│ URL: msg.gigafibre.ca/q/{MAC_ADDRESS} │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ targo-hub GET /q/{mac} │
|
||||
│ 1. Lookup Service Equipment par MAC │
|
||||
│ 2. Trouver le Customer lié │
|
||||
│ 3. Récupérer téléphone/email du Customer │
|
||||
│ 4. Envoyer OTP 6 chiffres par SMS (Twilio) │
|
||||
│ 5. Afficher page : "Code envoyé au 514-***-**89" │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│ Client entre le code OTP
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ POST /q/{mac}/verify { otp: "123456" } │
|
||||
│ 1. Vérifier OTP (Redis, TTL 5 min) │
|
||||
│ 2. Générer JWT session (24h) │
|
||||
│ 3. Servir page subscriber : │
|
||||
│ - Santé réseau (signal, vitesse, uptime) │
|
||||
│ - Appareils connectés │
|
||||
│ - Contrôle parental │
|
||||
│ - Offres personnalisées │
|
||||
│ - Lien vers portail complet (client.gigafibre.ca) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Avantages :**
|
||||
- Zéro mot de passe à retenir
|
||||
- Accessible même par des clients non technologiques
|
||||
- Le QR est physiquement chez le client → preuve de possession
|
||||
- Upsell contextuel immédiat (le client est devant son modem)
|
||||
- Support proactif : "Votre signal est faible, voulez-vous un diagnostic ?"
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture cible — Plateforme unifiée
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ www.gigafibre.ca │ Marketing
|
||||
│ (React — vitrine) │ Vérification adresse
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌────────────────────────────┼────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Ops │ │ Client Portal │ │ Mobile Tech │
|
||||
│ App │ │ (Vue/Quasar) │ │ /t/{token} │
|
||||
│ (PWA) │ │ client.gigafibre│ │ (HTML pur) │
|
||||
│ │ │ │ │ │
|
||||
│ Dispatch│ │ Factures │ │ Jobs du jour │
|
||||
│ Clients │ │ Paiements │ │ Scan barcode │
|
||||
│ Tickets │ │ Support │ │ GPS navigation │
|
||||
│ Réseau │ │ Catalogue │ │ Status update │
|
||||
│ Campagns│ │ QR Modem portal │ │ Équipement CRUD │
|
||||
└────┬────┘ └────────┬─────────┘ └────────┬─────────┘
|
||||
│ │ │
|
||||
└─────────────────────────┼─────────────────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ targo-hub │
|
||||
│ (Node.js 20) │
|
||||
│ │
|
||||
│ SSE temps réel │
|
||||
│ SMS (Twilio) │
|
||||
│ Vision AI (Gemini) │
|
||||
│ Magic links / OTP │
|
||||
│ OLT SNMP polling │
|
||||
│ QR modem → auth │
|
||||
│ TR-069 proxy │
|
||||
│ Segment engine │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ ERPNext v16 │
|
||||
│ (PostgreSQL) │
|
||||
│ │
|
||||
│ Customers 6,600+ │
|
||||
│ Invoices 115K+ │
|
||||
│ Equipment 7,500+ │
|
||||
│ Tickets 242K+ │
|
||||
│ Subscriptions 21K+ │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ GenieACS │ │ Traccar │ │ id.giga │
|
||||
│ TR-069 │ │ GPS │ │ Authentik │
|
||||
│ 7,500 CPE│ │ 46 techs │ │ SSO │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Plan de retrait des applications legacy
|
||||
|
||||
### Phase 1 — Immédiat (avril 2026)
|
||||
|
||||
| Action | Risque | Validation |
|
||||
|--------|--------|-----------|
|
||||
| Retirer **dispatch-app** du docker-compose apps | Bas — toutes les features sont dans Ops + /t/{token} | Vérifier que les magic links SMS pointent vers msg.gigafibre.ca/t/ |
|
||||
| Retirer le container **apps-dispatch-frontend-1** | Bas | Confirmer qu'aucun bookmark/lien externe pointe vers dispatch.gigafibre.ca |
|
||||
| Supprimer le repo dispatch-app de la CI | Aucun | — |
|
||||
|
||||
### Phase 2 — Court terme (mai 2026)
|
||||
|
||||
| Action | Risque | Validation |
|
||||
|--------|--------|-----------|
|
||||
| Retirer **apps/field/** du monorepo | Bas — /t/{token} couvre tout | Comparer features field vs /t/{token} |
|
||||
| Migrer **auth.targo.ca** → **id.gigafibre.ca** pour n8n et Hub | Moyen — tester les flows n8n | Tester chaque workflow n8n post-migration |
|
||||
| Éteindre **legacy-db** MariaDB | Bas — migration 100% complétée | Garder un dump SQL en backup |
|
||||
| Évaluer retrait **Oktopus CE** vs intégration GenieACS | Moyen | Comparer fonctionnalités monitoring |
|
||||
|
||||
### Phase 3 — Moyen terme (été 2026)
|
||||
|
||||
| Action | Risque | Validation |
|
||||
|--------|--------|-----------|
|
||||
| Absorber **device-monitor** dans targo-hub OLT SNMP | Bas | Vérifier couverture fonctionnelle |
|
||||
| Intégrer **infra-map-vue** comme module Ops `/network/map` | Moyen | iframe ou migration Vue |
|
||||
| Unifier **Targo Backend** avec targo-hub (si pertinent) | Élevé — évaluer | Audit des fonctionnalités RH |
|
||||
|
||||
---
|
||||
|
||||
## 6. Métriques de succès par département
|
||||
|
||||
| Département | KPI | Cible |
|
||||
|-------------|-----|-------|
|
||||
| **Marketing** | Taux de conversion upsell via portail/SMS | 5% des clients ciblés |
|
||||
| **Marketing** | Revenu additionnel par client (ARPU lift) | +8$/mois moyen |
|
||||
| **Operations** | Tickets proactifs (créés avant appel client) | 30% des incidents réseau |
|
||||
| **Operations** | Temps moyen de résolution dispatch | <4h (actuellement ~8h) |
|
||||
| **Service** | Résolution au premier appel (FCR) | 75% (actuellement ~55%) |
|
||||
| **Service** | Temps moyen d'appel CSR | <6 min (actuellement ~10 min) |
|
||||
| **Subscriber** | Adoption portail self-service | 40% des clients actifs |
|
||||
| **Subscriber** | Réduction appels support via self-diagnostic | -25% volume appels |
|
||||
|
||||
---
|
||||
|
||||
## 7. Analyse concurrentielle
|
||||
|
||||
### Gaiia (gaiia.com) — Comparable principal
|
||||
Canadian-founded (2021), YC-backed, $13.2M Series A. 40+ ISP customers.
|
||||
|
||||
| Gaiia Module | Gigafibre Status |
|
||||
|---|---|
|
||||
| Workforce & Scheduling | Done (dispatch PWA) |
|
||||
| Field Service App | Done (/t/{token} mobile) |
|
||||
| Billing & Revenue | ERPNext (basic) |
|
||||
| Customer Portal | Not built |
|
||||
| Online Checkout | Not built |
|
||||
| Network Monitoring | Oktopus CE + OLT SNMP |
|
||||
| Workflow Builder | n8n |
|
||||
|
||||
### Avantages Gigafibre vs Gaiia
|
||||
- Self-hosted / souverain (pas de frais par abonné)
|
||||
- Full ERP (accounting, HR, inventory inclus)
|
||||
- Dispatch board avancé (lasso, undo, auto-dispatch, route optimization)
|
||||
- Open source — offrable à d'autres ISPs
|
||||
|
||||
### Gaps à combler (vs Gaiia)
|
||||
- Customer portal self-service
|
||||
- Online checkout (Gaiia rapporte 6x conversion)
|
||||
- Billing proration (mid-cycle changes)
|
||||
- Auto travel time on dispatch
|
||||
|
||||
### Industrie — Matrice comparative
|
||||
|
||||
| Feature | Gaiia | Odoo FS | Zuper | Salesforce FS | Gigafibre |
|
||||
|---|---|---|---|---|---|
|
||||
| Target | ISP-specific | General | Telecom | Enterprise | ISP custom |
|
||||
| Self-hosted | No | Yes | No | No | Yes |
|
||||
| Pricing | Per-subscriber | $30/user | $50/user | $200/user | Free (OSS) |
|
||||
| Dispatch drag-drop | Yes | Yes | Yes | Yes | Yes |
|
||||
| GPS real-time | Yes | Limited | Yes | Yes | Yes (WS) |
|
||||
| Equipment tracking | Yes | Yes | Yes | Yes | Yes |
|
||||
| Customer portal | Yes | Yes | Yes | Yes | Not yet |
|
||||
| Online checkout | Yes | No | No | No | Not yet |
|
||||
|
||||
---
|
||||
|
||||
*Document vivant — dernière mise à jour : 12 avril 2026*
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
# Gigafibre FSM — Roadmap
|
||||
|
||||
> See [STATUS_2026-04-18.md](STATUS_2026-04-18.md) for a full state snapshot and [HANDOFF.md](HANDOFF.md) for the reader's guide.
|
||||
|
||||
|
||||
## Phase 1 — Foundation (Done, March 2026)
|
||||
- [x] ERPNext v16 + PostgreSQL
|
||||
- [x] Custom FSM doctypes (Service Location, Equipment, Subscription)
|
||||
|
|
@ -22,6 +25,25 @@
|
|||
- [x] Modem-bridge (Playwright headless for TP-Link ONU diagnostics)
|
||||
- [x] WiFi diagnostic panel (mesh topology, client signal, packet loss)
|
||||
|
||||
## Phase 2.5 — Remote Architecture Transition (Current Focus)
|
||||
- [x] Deprecate local `frappe_docker` development dependencies
|
||||
- [x] Consolidate architecture and ecosystem documentation
|
||||
- [ ] Decouple API/Auth (Token-based auth instead of session for frontend apps)
|
||||
- [ ] Set up dev proxy (Vite) to bridge local env to remote ERPNext API (bypassing CORS)
|
||||
- [ ] Establish secure PostgreSQL tunnel for `infra-map-vue` development
|
||||
- [ ] **Sandboxed outbound comms** (required before any scheduler/webhook/Twilio/Mailjet E2E test) — prevents test runs from reaching real customers while legacy still bills
|
||||
- [ ] Subscription → Sales Invoice scheduler: keep `pause_scheduler=1` until cutover event. Legacy PHP is authoritative until then.
|
||||
|
||||
## Phase 2.6 — Quotation + DocuSeal (Shipped 2026-04-18)
|
||||
- [x] DocuSeal container at sign.gigafibre.ca (Docker + Mailjet SMTP)
|
||||
- [x] Hub routes: `/accept/generate`, `/accept/docuseal-webhook`, `/accept/confirm`
|
||||
- [x] Quotation custom fields: `custom_docuseal_signing_url`, `custom_docuseal_envelope_id`, `custom_quote_type`
|
||||
- [x] Billing Frequency Custom Field on Item + Quotation/Sales Invoice/Sales Order Item (fetch_from item_code)
|
||||
- [x] Print Format "Soumission TARGO" with split Recurring / One-time sections and QR → signing URL
|
||||
- [x] Wizard flow: ProjectWizard → `/accept/generate` → DocuSeal submission → signed webhook → `acceptQuotation()`
|
||||
- [ ] Register DocuSeal webhook in UI (Settings → Webhooks, `form.completed` → hub endpoint) — **manual**
|
||||
- [ ] First end-to-end signed acceptance on a real customer quote
|
||||
|
||||
## Phase 3 — Workflows & Automation (In Progress)
|
||||
- [ ] Tag technicians with skills (46 techs to tag)
|
||||
- [ ] Wire auto-dispatch (cost-optimization matching)
|
||||
|
|
|
|||
263
docs/STATUS_2026-04-18.md
Normal file
263
docs/STATUS_2026-04-18.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# Gigafibre FSM — Status Snapshot (2026-04-18)
|
||||
|
||||
> **Audience:** new contributor, stakeholder, or future-me opening this cold.
|
||||
> **Goal:** be able to hold a coherent conversation about the system in 10 minutes.
|
||||
> **Companion docs:** [README](../README.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md) · [DATA_AND_FLOWS](DATA_AND_FLOWS.md) · [BILLING_AND_PAYMENTS](BILLING_AND_PAYMENTS.md) · [CPE_MANAGEMENT](CPE_MANAGEMENT.md) · [APP_DESIGN_GUIDELINES](APP_DESIGN_GUIDELINES.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Gigafibre (consumer brand of TARGO Internet) is a Quebec fiber ISP. We are migrating a legacy PHP/MariaDB billing system (used daily since ~2012) to **ERPNext v16 on PostgreSQL**, wrapping it in Vue 3/Quasar PWAs for ops, dispatch, field, and customer self-service. The accounting, subscriptions, invoices, and payments are **already migrated and balanced**. We are now in the **"live ops" transition phase**: new quotes flow through ERPNext + DocuSeal e-signature, dispatch runs on the new PWA, and the legacy system is kept in read-only mode pending customer-portal cutover.
|
||||
|
||||
---
|
||||
|
||||
## Where we are (at a glance)
|
||||
|
||||
| Area | State | Notes |
|
||||
|---|---|---|
|
||||
| **Accounting migration** | ✅ Shipped | 629,935 invoices + 343,684 payments, GL balanced ($130M debit/credit) |
|
||||
| **Master data migration** | ✅ Shipped | 15,303 customers · 833 items · 21,876 subscriptions · 7,241 equipment |
|
||||
| **Staff SSO (auth.targo.ca)** | ✅ Live | Authentik forwardAuth, 6 groups |
|
||||
| **Ops PWA (erp.gigafibre.ca/ops/)** | ✅ Live | ClientDetail, Tickets, Dispatch, Dashboard |
|
||||
| **Dispatch PWA v1** | ✅ Live | Timeline + Mapbox + drag-drop |
|
||||
| **Field tech PWA** | ✅ Live | Mobile via `/t/{token}` |
|
||||
| **GenieACS TR-069** | ✅ Live | ~7,560 CPEs polled every 5 min |
|
||||
| **targo-hub gateway** | ✅ Live | 11 modules (ERP, Twilio, SIP, AI, SSE, etc.) |
|
||||
| **Quotation + DocuSeal flow** | 🆕 Just shipped (2026-04-18) | See "New this cycle" below |
|
||||
| **Customer SSO (id.gigafibre.ca)** | ✅ Live | Authentik federated from staff |
|
||||
| **Customer portal v2** | 🚧 In progress | Vue SPA spec ready; checkout + Stripe pending |
|
||||
| **ERPNext scheduler** | ⏸ Paused | `pause_scheduler=1` — requires explicit approval to reactivate |
|
||||
| **Invoice PDF "Facture TARGO"** | ✅ Live | Inline-SVG logo, QR to Stripe/portal |
|
||||
| **Quote PDF "Soumission TARGO"** | ✅ Live | Same branding, QR to DocuSeal signing URL |
|
||||
| **Legacy PHP/MariaDB** | 🗃 Read-only | Accessed from 10.100.80.100 for residual lookups |
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
Internet (Cloudflare DNS for gigafibre.ca)
|
||||
│
|
||||
┌────────────────────┴───────────────────┐
|
||||
│ Proxmox VM — 96.125.196.67 │
|
||||
│ Ubuntu 24.04 · Docker · Traefik v2.11│
|
||||
└────────────────────┬───────────────────┘
|
||||
│
|
||||
┌──────────┬───────────┬────┴─────┬──────────┬─────────────┐
|
||||
│ │ │ │ │ │
|
||||
ERPNext Ops PWA targo-hub n8n DocuSeal Authentik
|
||||
erp. /ops/ msg. n8n. sign. auth.targo.ca
|
||||
giga.ca (nginx) giga.ca giga.ca giga.ca id.giga.ca
|
||||
│ │
|
||||
│ ├─── Twilio (SMS, +14382313838)
|
||||
│ ├─── 3CX PBX (targopbx.3cx.ca)
|
||||
│ ├─── Mailjet SMTP
|
||||
│ ├─── Fonoster/Routr (SIP @ voice.giga.ca)
|
||||
│ ├─── GenieACS NBI (10.5.2.115:7557)
|
||||
│ ├─── Oktopus TR-369 (oss.giga.ca)
|
||||
│ ├─── Gemini AI (agent/voice/OCR)
|
||||
│ ├─── Stripe (payments)
|
||||
│ └─── Traccar (tech GPS, tracker.targointernet.com)
|
||||
│
|
||||
└─── Legacy MariaDB (read-only, 10.100.80.100)
|
||||
```
|
||||
|
||||
For the authoritative breakdown see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Features inventory (by surface)
|
||||
|
||||
### 👥 Staff — Ops PWA (`erp.gigafibre.ca/ops/`)
|
||||
|
||||
- **ClientDetailPage** — master client view, 8 parallel API calls (contacts, devices, subscriptions, invoices, payments, tickets, equipment, SMS history). Inline-editable fields Odoo-style.
|
||||
- **TicketsPage** — issue triage, assignment, status, SLA indicators.
|
||||
- **DispatchPage (v1)** — today's jobs, technician rows, drag-drop re-assign, Mapbox job map.
|
||||
- **DashboardPage** — KPIs, outstanding AR, job throughput.
|
||||
- **Wizard (ProjectWizard.vue)** — creates Quotation with `wizard_steps` JSON describing dispatch + subscription creation deferred until acceptance.
|
||||
- **Theme** — light mode everywhere except Dispatch (dark by design).
|
||||
|
||||
### 🔧 Staff — Dispatch PWA v2 (`dispatch.gigafibre.ca`) *[in migration]*
|
||||
|
||||
- Larger Mapbox-driven timeline, undo support, technician lanes, planned features include Gantt view and auto-dispatch by skill tags.
|
||||
|
||||
### 📱 Field — Field tech PWA (mobile)
|
||||
|
||||
- Route at `/t/{token}` (no login — token-based).
|
||||
- Offline-first IndexedDB; photo upload, checklist, materials used, customer signature.
|
||||
- Barcode/serial scanner: native camera capture (`<input capture="environment">`) → Gemini 2.5 Flash Vision via `msg.gigafibre.ca/vision/barcodes` (extracts barcode / S/N / MAC / model from a single photo).
|
||||
|
||||
### 🧑💼 Customer — Client portal (`client.gigafibre.ca`) *[v2 in progress]*
|
||||
|
||||
**Live today:**
|
||||
- Authentik client SSO at `id.gigafibre.ca`.
|
||||
- Legacy password bridge: MD5 → PBKDF2 on first login (11,800 portal users provisioned).
|
||||
- ERPNext Portal Server Script resolves `X-Authentik-Email` → Customer.
|
||||
|
||||
**In flight (Phase 4):**
|
||||
- Vue SPA: see invoices, pay via Stripe Payment Element, view subscriptions, update contact info.
|
||||
- Online appointment booking.
|
||||
- Real-time tech arrival tracking via SMS.
|
||||
- QR code on modem → subscriber dashboard.
|
||||
|
||||
### 🌐 Public — Marketing site (`www.gigafibre.ca`)
|
||||
|
||||
- React + Vite + Tailwind + shadcn/ui.
|
||||
- RQA address eligibility check, AQ address normalization.
|
||||
- Uses Supabase for pre-signup intake; Twilio for SMS OTP on checkout intent.
|
||||
|
||||
### ✍️ Quotation → Signature flow (just shipped)
|
||||
|
||||
1. Wizard creates Quotation w/ `wizard_steps` JSON (dispatch + subscriptions to execute on acceptance).
|
||||
2. Wizard calls `POST /accept/generate` with `use_docuseal: true`.
|
||||
3. Hub creates DocuSeal submission (template 1), writes `sign_url` to `Quotation.custom_docuseal_signing_url`.
|
||||
4. PDF print format **Soumission TARGO** renders with QR + "SIGNER CETTE SOUMISSION" button.
|
||||
5. Customer signs → DocuSeal webhook fires `form.completed` → hub runs `acceptQuotation()` → dispatch jobs + subscriptions materialized from `wizard_steps`.
|
||||
|
||||
See [project_docuseal_quote.md](~/.claude/projects/.../memory/project_docuseal_quote.md) for deep detail.
|
||||
|
||||
---
|
||||
|
||||
## ERPNext customisations
|
||||
|
||||
### Custom doctypes (FSM module)
|
||||
|
||||
| Doctype | ID | Purpose |
|
||||
|---|---|---|
|
||||
| Service Location | `LOC-#####` | Customer premises (address, GPS, OLT port, network config) |
|
||||
| Service Equipment | `EQP-#####` | ONT / router / TV box — serial, MAC, IP, parent |
|
||||
| Service Subscription | `SUB-#####` | Active plans — speed, price, billing, RADIUS creds |
|
||||
| Service Contract | `CTR-#####` | Long-term agreement with benefits |
|
||||
| Dispatch Job | `DJ-#####` | Work order — materials, checklist, photos, signature |
|
||||
| Dispatch Technician | `DT-#####` | Tech profile — skills, Traccar GPS, color |
|
||||
| Dispatch Tag | — | Skill tag with level (1 base → 5 expert) |
|
||||
| Checklist Template | — | Re-usable job checklists |
|
||||
|
||||
### Custom fields (highlights)
|
||||
|
||||
| Doctype | Field | Purpose |
|
||||
|---|---|---|
|
||||
| Item | `custom_billing_frequency` | One-time / Monthly / Annual (source of truth) |
|
||||
| Quotation Item · Sales Invoice Item · Sales Order Item | `custom_billing_frequency` | Inherited from Item via `fetch_from`, overridable per-line |
|
||||
| Quotation | `custom_docuseal_signing_url`, `custom_docuseal_envelope_id` | Populated by hub after DocuSeal submit |
|
||||
| Quotation | `custom_quote_type` | Résidentiel / Commercial |
|
||||
| Quotation | `wizard_steps`, `wizard_context` | JSON blobs driving post-acceptance workflow |
|
||||
| Customer | `legacy_account_id`, `legacy_customer_id`, `ppa_enabled`, `stripe_id` | Legacy mapping + Stripe link |
|
||||
| Item | `legacy_product_id`, `download_speed`, `upload_speed`, `olt_profile` | Network profile |
|
||||
| Subscription | `radius_user`, `radius_pwd`, `legacy_service_id` | Network auth |
|
||||
| Issue | `legacy_ticket_id`, `assigned_staff`, `issue_type`, `is_important`, `service_location` | Ticket context |
|
||||
| User | `legacy_password_md5` | Bridge for legacy portal passwords |
|
||||
|
||||
### Print formats
|
||||
|
||||
| Format | Doctype | State |
|
||||
|---|---|---|
|
||||
| **Facture TARGO** | Sales Invoice | ✅ Live — used for monthly billing, QR to Stripe/portal |
|
||||
| **Soumission TARGO** | Quotation | ✅ Live (2026-04-18) — QR to DocuSeal signing URL, split totals (Services récurrents + Frais uniques) |
|
||||
|
||||
### Known ERPNext v16 quirks
|
||||
|
||||
- **PostgreSQL GROUP BY / HAVING / double-quote bugs** — patches applied, bulk submit workflow documented.
|
||||
- **Scheduler paused** (`pause_scheduler=1`) — do NOT reactivate without approval; Subscription → Invoice automation depends on it.
|
||||
- **Frappe REST filter on Quotation.customer** rejected — use `party_name` when `quotation_to == "Customer"`.
|
||||
|
||||
---
|
||||
|
||||
## Integrations (live + credentialed)
|
||||
|
||||
| Integration | URL / endpoint | Auth | Used by |
|
||||
|---|---|---|---|
|
||||
| Authentik (staff) | `auth.targo.ca` | OAuth client + forwardAuth | ERPNext, ops, dispatch, n8n, hub |
|
||||
| Authentik (client) | `id.gigafibre.ca` | forward_single proxy | client.gigafibre.ca |
|
||||
| Twilio | `api.twilio.com` | Basic (SID/token) | targo-hub twilio.js, SMS, OTP |
|
||||
| Cloudflare | `api.cloudflare.com` | X-Auth-Key + X-Auth-Email (Global) | DNS, ops app checks |
|
||||
| GenieACS | `http://10.5.2.115:7557` | LAN no-auth | targo-hub devices.js (5-min poll) |
|
||||
| Oktopus (TR-369) | `oss.gigafibre.ca` | user/pass | CPE next-gen mgmt |
|
||||
| Stripe | `api.stripe.com` | secret key | Customer checkout, webhook |
|
||||
| Fonoster / Routr | `voice.gigafibre.ca:5160/5163` | SIP creds | Voice calls via WSS |
|
||||
| 3CX PBX | `targopbx.3cx.ca` | Basic | Voice polling, call logs |
|
||||
| Mailjet SMTP | `in-v3.mailjet.com:587` | user/pass | DocuSeal + transactional email |
|
||||
| Traccar | `tracker.targointernet.com:8082` | user/pass | Tech GPS |
|
||||
| DocuSeal | `sign.gigafibre.ca` | API key | Quotation e-signature (just live) |
|
||||
| n8n | `n8n.gigafibre.ca` | webhook (no auth) | SMS, OLT SSH commands, subscription automations |
|
||||
| Gemini AI | `generativelanguage.googleapis.com` | Bearer | agent replies, OCR, voice |
|
||||
|
||||
All tokens/keys live in `/opt/targo-hub/.env` on the production host and are tracked in memory records (never in repo).
|
||||
|
||||
---
|
||||
|
||||
## 🆕 New this cycle (2026-04-08 → 2026-04-18)
|
||||
|
||||
- **DocuSeal e-signature for Quotations** — hub routes, Custom Fields, "Soumission TARGO" print format, signing URL writeback, webhook → `acceptQuotation`.
|
||||
- **Billing frequency system** — `custom_billing_frequency` on Item + Quotation Item + Sales Invoice Item + Sales Order Item; print format splits Récurrent vs One-time with their own subtotals.
|
||||
- **Print format fixes** — inline-SVG logo (was oversized PNG), `frappe.db.commit()` fix (was silently rolling back), correct `party_name` usage.
|
||||
- **Hub** — `DOCUSEAL_DEFAULT_TEMPLATE_ID` env knob, writeback of `sign_url` to Quotation.
|
||||
- **Custom app** — `gigafibre_utils.api.url_qr_base64(url)` helper for QR generation in print formats.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 In flight (right now)
|
||||
|
||||
1. **Customer portal v2 (Vue SPA)** — invoice list, Stripe Payment Element, subscription view, contact update. Spec ready. No front-end code yet.
|
||||
2. **Dispatch v2** — larger rewrite of dispatch PWA at `dispatch.gigafibre.ca`. Timeline + Mapbox + Gantt + auto-dispatch by tag/skill.
|
||||
3. **ClientDetailPage refactor** — 1,512-line monolith → composables (`useCustomer`, `useSubscriptionGroups`, `useFormatters`, `useStatusClasses`).
|
||||
4. **Service deactivation workflow** — n8n webhook ties Subscription cancellation to OLT port shutdown.
|
||||
5. **Multi-email account cleanup** — ~30 customers have multiple emails; manual triage pending.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next priorities (ordered)
|
||||
|
||||
> Update this list at the end of each working session.
|
||||
|
||||
| # | Item | Why now | Owner | Blocked by |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Register DocuSeal webhook in UI → `msg.gigafibre.ca/accept/docuseal-webhook` on `form.completed` | Without this, signed quotes don't materialise dispatch jobs | manual (UI) | — |
|
||||
| 2 | Validate Subscription → Sales Invoice scheduler flow on a pilot subscription | Before reactivating scheduler for full run | eng | — |
|
||||
| 3 | Reactivate `pause_scheduler=0` for April billing | Recurring invoices must generate | eng + ops approval | #2 |
|
||||
| 4 | Customer portal v2 MVP (invoice list + Stripe Payment Element) | Drive self-serve payment, reduce AR calls | eng | — |
|
||||
| 5 | Tag 46 techs with skills | Enable auto-dispatch | ops | — |
|
||||
| 6 | Issue → Dispatch Job auto-creation wiring | Close ticket/dispatch loop | eng | — |
|
||||
| 7 | Fix 15 invoices flagged "Credit Note Issued" with $3,695.77 overpaid | Accounting hygiene | accounting | — |
|
||||
| 8 | 10DLC Twilio production upgrade | Avoid SMS rate throttling at scale | ops | Twilio approval |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known issues & technical debt
|
||||
|
||||
- **Scheduler paused** — subscriptions won't produce invoices until reactivated.
|
||||
- **Tech debt in ClientDetailPage.vue (1,512 LOC)** — slated for composable extraction.
|
||||
- **Multiple `formatDate` / `formatMoney` implementations** across files — should consolidate.
|
||||
- **6+ ad-hoc `statusClass` functions** (`subStatusClass`, `eqStatusClass`, `ticketStatusClass`...) — extract to `useStatusClasses`.
|
||||
- **Dispatch v1 vs v2** live simultaneously — cutover plan not yet written.
|
||||
- **15 "Credit Note Issued" invoices** with overpayment of $3,695.77.
|
||||
- **~30 customers** share emails across accounts — migration chose one per; manual reconciliation TBD.
|
||||
- **GenieACS CPE count ≠ ERPNext Service Equipment count** — TPLG serial vs real serial mismatch; MAC-based lookup required.
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Gotchas (don't get burned)
|
||||
|
||||
- **Traefik HTTP → HTTPS global redirect breaks Let's Encrypt HTTP-01.** Add redirect per-route or use TLS-ALPN. See `feedback_traefik_ssl.md`.
|
||||
- **Docker 29 + Traefik v3 don't play well** — stay on v2.11.
|
||||
- **MongoDB 5+ needs AVX** — Proxmox CPU must be set to `host` type.
|
||||
- **netplan overrides systemd-networkd** — remove for static networking.
|
||||
- **Docker exec + nested SSH quoting is fragile** — always write `.py` to disk, `docker cp`, then exec. See `feedback_docker_exec.md`.
|
||||
- **iptables + Docker** — avoid `iptables-multiport`, don't persist bridge rules, use the raw table. See `feedback_ddos_iptables.md`.
|
||||
- **ERPNext Customer doctype has multi-address** — but legacy assumed 1:1. Device hierarchy is tied to **address**, not customer, intentionally — preserves manage IP / credentials / parent relationships across customer changes.
|
||||
- **Ops app deploy path** — upload `dist/spa/*` (contents), not `dist/spa/` (dir), to `/opt/ops-app/`. See `feedback_deploy_path.md`.
|
||||
|
||||
---
|
||||
|
||||
## How to share this context
|
||||
|
||||
- **"I need someone on-board in 30 min":** have them read the README + this file.
|
||||
- **"They need to work on dispatch":** add [DATA_AND_FLOWS.md](DATA_AND_FLOWS.md) + [APP_DESIGN_GUIDELINES.md](APP_DESIGN_GUIDELINES.md).
|
||||
- **"They need to work on billing/invoicing":** add [BILLING_AND_PAYMENTS.md](BILLING_AND_PAYMENTS.md).
|
||||
- **"They need to work on CPE / network gear":** add [CPE_MANAGEMENT.md](CPE_MANAGEMENT.md).
|
||||
- **"They need server / infra access":** credentials live on the host in `/opt/targo-hub/.env` — share out-of-band. Memory records in `~/.claude/.../memory/` note the shape of each credential.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-18 · Maintained alongside [ROADMAP.md](ROADMAP.md). When this file drifts more than a week out of date, create a new dated snapshot (`STATUS_YYYY-MM-DD.md`) and link it from the top of README.*
|
||||
107
docs/STATUS_2026-04-18b.md
Normal file
107
docs/STATUS_2026-04-18b.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Status — 2026-04-18 (session 2)
|
||||
|
||||
> Follow-up to STATUS_2026-04-18.md. This session was the sales-flow sprint.
|
||||
|
||||
## What changed this session
|
||||
|
||||
### 1. Field scanner — offline resilience for weak-signal zones
|
||||
Photos that can't reach Gemini Vision within 8s (weak LTE / no service) are
|
||||
now queued in IndexedDB and retried in the background; late results are merged
|
||||
back into the scanner UI when they arrive.
|
||||
|
||||
- `apps/field/src/stores/offline.js` — new `visionQueue`, `scanResults`,
|
||||
`enqueueVisionScan`, `syncVisionQueue`, `consumeScanResult`.
|
||||
Driven off queue length (not `navigator.onLine`, which lies in basements).
|
||||
- `apps/field/src/composables/useScanner.js` — `Promise.race` with 8s timeout,
|
||||
retryable errors enqueue, late arrivals dispatched through `onNewCode`.
|
||||
- `apps/field/src/pages/ScanPage.vue` — pending chip "N scan(s) en attente".
|
||||
|
||||
### 2. Sales flow — residential contract wiring
|
||||
The ProjectWizard now knows how to turn a quotation into the Service Contract
|
||||
(promotion-framing récap). The contract *is* the shopping-cart recap per
|
||||
your brief: "c'est un peu le récapitulatif d'un panier d'achat."
|
||||
|
||||
**Entry from customer page:** ClientDetailPage → Soumissions section now has a
|
||||
"**+ Nouvelle soumission**" button that opens the wizard pre-filled with
|
||||
customer phone/email/primary service location. Wizard skips to `quotation` mode
|
||||
+ `requireAcceptance=true` by default when launched from a customer.
|
||||
|
||||
**Residential presets (one-click):**
|
||||
- Installation standard (Internet 500 + Routeur offert + Frais offerts, 24 mois)
|
||||
- Duo 300 + Tél (79.99$/mois, 24 mois, installation offerte)
|
||||
- Trio complet (119.99$/mois, 24 mois, routeur + installation offerts)
|
||||
- Internet seul (89.99$/mois, 12 mois)
|
||||
|
||||
Presets live in `apps/ops/src/data/wizard-constants.js` → `RESIDENTIAL_PRESETS`
|
||||
— edit there to tune prices / add bundles.
|
||||
|
||||
**Promo framing on one-time items:** each one-time line has a "Prix régulier
|
||||
(si promo)" field. When `regular_price > rate`, the line becomes a **benefit**
|
||||
on the Service Contract (e.g. Installation 75$ → 0$ = 75$ de promotion étalée
|
||||
sur 24 mois). Shown with celebration chip + line-through regular price in
|
||||
review.
|
||||
|
||||
**What `publish()` now does on a residential quotation:**
|
||||
1. Creates Quotation (as before).
|
||||
2. Creates Service Contract via hub `/contract/create` with
|
||||
`duration_months = max(contract_months)`, `monthly_rate = sum(recurring)`,
|
||||
`benefits[] = onetime items where regular_price > rate`,
|
||||
`contract_type = 'Résidentiel'` (or `Commercial` when DocuSeal is selected).
|
||||
3. Sends acceptance via `/contract/send` (promotion-framed récap, not the old
|
||||
Quotation `/accept/generate`) — résidentiel gets the "J'accepte ce
|
||||
récapitulatif" page; commercial gets DocuSeal.
|
||||
|
||||
If no recurring commitment items exist, the old `/accept/generate` path is still
|
||||
used (unchanged). Direct-order / prepaid modes are untouched.
|
||||
|
||||
**Success screen** shows the Service Contract name when one was created.
|
||||
|
||||
## Files touched this session
|
||||
```
|
||||
apps/field/src/stores/offline.js +vision queue
|
||||
apps/field/src/composables/useScanner.js +timeout/retry
|
||||
apps/field/src/pages/ScanPage.vue +pending chip
|
||||
apps/ops/src/composables/useWizardPublish.js +contract creation branch
|
||||
apps/ops/src/composables/useWizardCatalog.js +applyPreset
|
||||
apps/ops/src/data/wizard-constants.js +RESIDENTIAL_PRESETS
|
||||
apps/ops/src/components/shared/ProjectWizard.vue +presets, promo UI, customer prop
|
||||
apps/ops/src/pages/ClientDetailPage.vue +Nouvelle soumission button + wizard dialog
|
||||
docs/STATUS_2026-04-18.md scanner stack corrected (Gemini, not html5-qrcode)
|
||||
```
|
||||
|
||||
## Deployed
|
||||
- Ops app: `dist/pwa/*` → `/opt/ops-app/` at 2026-04-18 ~08:05 EDT.
|
||||
New bundle: `index.caa91ade.js`. Hard-reload in browser to pick up.
|
||||
- Field app: **not yet deployed**. Build + deploy when you want it live:
|
||||
`cd apps/field && quasar build && scp -r dist/spa/* root@96.125.196.67:/opt/field-app/`
|
||||
|
||||
## End-to-end smoke test done
|
||||
- `CTR-00001` for C-LPB4 (Louis-Paul Bourdon) created via manual hub call
|
||||
during the Service Contract prod-doctype setup. See
|
||||
`memory/project_contract_acceptance.md` for creation gotchas (Dispatch User role).
|
||||
- The wizard integration has **not been exercised end-to-end** yet — it
|
||||
compiles, UI renders, but please run one manual test on a real customer
|
||||
before announcing it.
|
||||
|
||||
## Suggested test path when you're back
|
||||
1. Open ops → any customer → Soumissions → "+ Nouvelle soumission".
|
||||
2. Pick "Duo 300 + Tél" preset. Set Frais d'installation prix régulier = 75$ (rate stays 0 → benefit of 75$).
|
||||
3. Keep acceptation = JWT (Résidentiel).
|
||||
4. Publish. Confirm: Quotation created + Service Contract created + SMS sent.
|
||||
5. Open the SMS link on phone, click "J'accepte ce récapitulatif", verify
|
||||
contract goes Actif + signature_proof stored.
|
||||
|
||||
## Known follow-ups (deliberately deferred)
|
||||
- Wizard doesn't yet let the agent pick `Résidentiel` vs `Commercial`
|
||||
independently of the acceptance method. Today: JWT = Résidentiel,
|
||||
DocuSeal = Commercial. If you want a large commercial client on JWT
|
||||
(or vice-versa), add a `contract_type` toggle in the expansion panel.
|
||||
- Presets use hard-coded prices. Eventually these should come from the
|
||||
live catalog (ERPNext Item price list) — same place `loadCatalog()`
|
||||
reads from.
|
||||
- `/accept/send` (old) and `/contract/send` (new) are two parallel paths.
|
||||
When the residential flow is proven, consider deprecating
|
||||
`/accept/generate` for quotations with recurring commitments.
|
||||
- Two-column visual split (recurring | one-time) was mentioned — current
|
||||
design keeps them in one list with swap button + promo chip, which
|
||||
seemed to stay readable. Flag if you want the split.
|
||||
83
docs/STATUS_2026-04-19.md
Normal file
83
docs/STATUS_2026-04-19.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Status — 2026-04-19 (wizard legacy-alignment refinement)
|
||||
|
||||
> Follow-up to STATUS_2026-04-18b.md. Small session: reviewed the legacy
|
||||
> wizard (`facturation.targo.ca/.../client_wizard`), realized the initial
|
||||
> preset model fought the real workflow, refactored.
|
||||
|
||||
## What changed this session
|
||||
|
||||
### Wizard presets — now additive, not replacing
|
||||
The first pass of RESIDENTIAL_PRESETS replaced the cart. That conflicts with
|
||||
how the shop actually sells: a typical residential deal is **Installation
|
||||
Internet + Installation Téléphonie + Installation Télé + Ajout WiFi** — four
|
||||
presses that stack.
|
||||
|
||||
Legacy audit (`docs/legacy-wizard/account_wizard.php`) confirmed the model:
|
||||
the PHP wizard has no "presets" at all — it has 4 independent sections with
|
||||
checkboxes. Adding Internet adds lines; adding Phone adds more lines; the
|
||||
combo rebate (legacy RAB2X/RAB3X/RAB4X, IDs 556/557/558) is computed
|
||||
automatically from the count of distinct combo-eligible service types.
|
||||
|
||||
**Our presets are now building blocks:**
|
||||
- `preset_internet_install` — service_type `internet`, combo_eligible
|
||||
- `preset_phone_install` — service_type `phone`, combo_eligible
|
||||
- `preset_tv_install` — service_type `tv`, combo_eligible
|
||||
- `preset_wifi_addon` — service_type `addon`, NOT combo_eligible (ajout seul)
|
||||
|
||||
Each press **appends** its items. Item codes are deduped so double-clicking
|
||||
a preset is idempotent (no accidental doubles).
|
||||
|
||||
### Auto combo rebate
|
||||
`recomputeComboRebate()` in [apps/ops/src/composables/useWizardCatalog.js](apps/ops/src/composables/useWizardCatalog.js):
|
||||
- Counts distinct `service_type`s across recurring + combo_eligible items.
|
||||
- 2 services → Rabais combo 2 services (-5$/mois)
|
||||
- 3 services → Rabais combo 3 services (-10$/mois)
|
||||
- 4+ services → Rabais combo 4 services (-15$/mois)
|
||||
- Under 2 → removes the rebate line automatically.
|
||||
|
||||
Tune amounts in `apps/ops/src/data/wizard-constants.js → COMBO_REBATES`.
|
||||
|
||||
Driven by `watch(orderItems, recomputeComboRebate, { deep: true })`. The
|
||||
function is **idempotent** — compares `rate`, `item_name`, `contract_months`
|
||||
against the existing rebate line before mutating, so the deep watch can't
|
||||
spiral into an infinite loop.
|
||||
|
||||
### Ad-hoc items still work
|
||||
Per the "one simple way + ad-hoc onetime" directive: presets get the
|
||||
common composition in one click, but the catalog picker + "+ Ajouter un
|
||||
item" row are unchanged, so an agent can still add a router, an install
|
||||
fee, or any catalog item on top.
|
||||
|
||||
## Files touched this session
|
||||
```
|
||||
apps/ops/src/data/wizard-constants.js presets reshaped + COMBO_REBATES
|
||||
apps/ops/src/composables/useWizardCatalog.js applyPreset additive + recomputeComboRebate
|
||||
docs/legacy-wizard/ legacy PHP wizard copy for reference
|
||||
docs/STATUS_2026-04-19.md this file
|
||||
memory/project_contract_acceptance.md Wizard integration section
|
||||
```
|
||||
|
||||
## Deployed
|
||||
Ops app rebuilt and deployed 2026-04-19 ~08:25 EDT.
|
||||
Live bundle: `index.9ca1cb6e.js`. Hard-reload to pick up.
|
||||
|
||||
## Verification checklist (please run when you're back)
|
||||
1. Open a customer → Soumissions → "+ Nouvelle soumission".
|
||||
2. Press **Installation Internet** → 1 line Internet 500 + 1 line frais (0$, regular 75$).
|
||||
3. Press **Installation Téléphonie** → appends tel line. **Rabais combo 2 services (-5$/mois)** should appear automatically.
|
||||
4. Press **Installation Télé** → appends IPTV line. Rebate upgrades to **3 services (-10$/mois)**.
|
||||
5. Press **Ajout WiFi** → router line appended but rebate stays at 3 services (WiFi is not combo_eligible).
|
||||
6. Remove Télé → rebate downgrades to 2 services (-5$).
|
||||
7. Publish with JWT acceptance → Quotation created + Service Contract created + SMS sent.
|
||||
|
||||
If the rebate gets "stuck" or duplicates appear, screenshot the cart and
|
||||
send — the idempotency check may need tightening.
|
||||
|
||||
## Known follow-ups (deliberately deferred)
|
||||
- Rebate amounts are hard-coded in `COMBO_REBATES`. Should eventually read
|
||||
from legacy item IDs 556/557/558 (or their ERPNext equivalents once migrated).
|
||||
- `combo_eligible` is currently a preset-level flag. If ad-hoc catalog items
|
||||
should participate in combo counting, the catalog model needs a
|
||||
`combo_ready` column (legacy has one).
|
||||
- Commercial flow still piggybacks on acceptance-method toggle. A proper
|
||||
`contract_type` toggle in the wizard would decouple the two.
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# TR-069 (GenieACS) → TR-369 (Oktopus) Migration Plan
|
||||
|
||||
## Why Migrate
|
||||
|
||||
1. **GenieACS maintenance risk** — single maintainer, slow release cadence, Node.js/MongoDB stack aging
|
||||
2. **TR-069 is polling-based** — CPE connects every X minutes, no real-time push. Reboot command waits for next inform.
|
||||
3. **TR-369 (USP) is the ITU/BBF successor** — MQTT/WebSocket transport, bidirectional, real-time, certificate-based auth
|
||||
4. **Oktopus is actively developed** — Go-based, NATS messaging, supports both TR-069 and TR-369
|
||||
|
||||
## Current GenieACS Architecture
|
||||
|
||||
```
|
||||
CPE fleet (TR-069)
|
||||
│ HTTP/SOAP (CWMP)
|
||||
▼
|
||||
96.125.192.25 — GenieACS Core (CWMP, NBI, FS)
|
||||
│
|
||||
▼
|
||||
10.5.2.125 — MongoDB (devices, tasks, faults, provisions)
|
||||
│
|
||||
▼
|
||||
10.5.2.124 — GenieACS GUI (admin portal)
|
||||
│
|
||||
▼
|
||||
targo-hub /devices/* — Ops UI integration
|
||||
```
|
||||
|
||||
## Target Oktopus Architecture
|
||||
|
||||
```
|
||||
CPE fleet (TR-369 USP + legacy TR-069)
|
||||
│
|
||||
├── MQTT/WebSocket (USP) ──▶ Oktopus Controller
|
||||
│ │
|
||||
├── HTTP/SOAP (CWMP) ──────▶ Oktopus TR-069 Adapter
|
||||
│ │
|
||||
│ ▼
|
||||
│ NATS (internal messaging)
|
||||
│ │
|
||||
│ ▼
|
||||
│ PostgreSQL / TimescaleDB
|
||||
│
|
||||
▼
|
||||
targo-hub /devices/* — Ops UI integration (same endpoints, new backend)
|
||||
```
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 0: Export & Document (NOW)
|
||||
- Run `scripts/migration/export_genieacs.sh` on GenieACS server
|
||||
- Catalog all provisions, presets, virtual parameters
|
||||
- Document each provision's purpose and parameter paths
|
||||
- Download all firmware files from GridFS
|
||||
|
||||
### Phase 1: Deploy Oktopus with TR-069 Adapter (Parallel Run)
|
||||
- Deploy Oktopus CE alongside GenieACS (different port/IP)
|
||||
- Enable Oktopus TR-069 adapter to accept CWMP connections
|
||||
- Point a small test group of CPEs (5-10 units) to Oktopus
|
||||
- Verify: inform, reboot, parameter read/write, firmware upgrade
|
||||
- Keep GenieACS running for the rest of the fleet
|
||||
|
||||
### Phase 2: Reproduce Provisions in Oktopus
|
||||
- Map each GenieACS provision to Oktopus equivalent:
|
||||
|
||||
| GenieACS Concept | Oktopus Equivalent |
|
||||
|---|---|
|
||||
| Provision script (JS) | USP Set/Get/Operate messages + webhooks |
|
||||
| `declare(path, ...)` | USP Get/Set on TR-181 path (same paths!) |
|
||||
| `ext('script', ...)` | Oktopus webhook to targo-hub |
|
||||
| Preset (trigger rule) | Device group + event subscription |
|
||||
| Preset tag filter | Oktopus device group membership |
|
||||
| Preset event (inform/boot) | MQTT topic subscription |
|
||||
| Virtual parameter | Oktopus computed metric or targo-hub enrichment |
|
||||
| File (firmware) | Oktopus firmware repository |
|
||||
|
||||
### Phase 3: Gradual CPE Migration
|
||||
- Update CPE ACS URL via GenieACS setParameterValues:
|
||||
```
|
||||
Device.ManagementServer.URL = "https://acs-new.gigafibre.ca"
|
||||
```
|
||||
- Migrate in batches: 50 → 500 → all
|
||||
- Monitor for faults, missed informs, failed tasks
|
||||
- Keep GenieACS read-only as fallback for 30 days
|
||||
|
||||
### Phase 4: TR-369 Firmware Upgrade
|
||||
- For CPEs that support USP (newer models):
|
||||
- Push firmware with TR-369 agent enabled
|
||||
- Configure USP controller URL and MQTT broker
|
||||
- Migrate from TR-069 adapter to native TR-369
|
||||
- For legacy CPEs (TR-069 only):
|
||||
- Keep on Oktopus TR-069 adapter permanently
|
||||
- Replace hardware on failure with TR-369 capable units
|
||||
|
||||
### Phase 5: Decommission GenieACS
|
||||
- Verify 100% of fleet reports to Oktopus
|
||||
- Archive MongoDB data (export to JSON/PostgreSQL)
|
||||
- Shut down GenieACS servers
|
||||
- Update targo-hub to point to Oktopus API instead of GenieACS NBI
|
||||
|
||||
## targo-hub Integration Changes
|
||||
|
||||
The `/devices/*` endpoints in targo-hub currently proxy GenieACS NBI. For Oktopus:
|
||||
|
||||
```
|
||||
# Current (GenieACS NBI)
|
||||
GENIEACS_NBI_URL=http://10.5.2.115:7557
|
||||
|
||||
# Future (Oktopus API)
|
||||
OKTOPUS_API_URL=https://oss.gigafibre.ca/api
|
||||
```
|
||||
|
||||
The `summarizeDevice()` function already handles both TR-098 and TR-181 parameter paths, so the Ops UI won't need changes — only the backend proxy target.
|
||||
|
||||
## CPE Compatibility Check
|
||||
|
||||
Before migration, verify each CPE model's protocol support:
|
||||
|
||||
| Model | TR-069 | TR-369 | Notes |
|
||||
|---|---|---|---|
|
||||
| ZTE F670L | ✅ | ❌ | Legacy, keep on TR-069 adapter |
|
||||
| ZTE F680 | ✅ | ✅ | Firmware update enables USP |
|
||||
| Huawei HG8245H | ✅ | ❌ | Legacy |
|
||||
| Nokia G-010G-Q | ✅ | ✅ | USP via firmware 3.x+ |
|
||||
|
||||
(Fill in with actual fleet models after running device summary export)
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
1. **GenieACS stays running** during entire migration — no big bang cutover
|
||||
2. **Same TR-181 data model** — parameter paths don't change
|
||||
3. **targo-hub abstraction layer** — Ops UI doesn't care which ACS backend serves `/devices/*`
|
||||
4. **Export script** preserves all business logic — can recreate GenieACS from scratch if needed
|
||||
5. **Oktopus TR-069 adapter** means even legacy CPEs work without firmware changes
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Duration | Prerequisite |
|
||||
|---|---|---|
|
||||
| Phase 0: Export | 1 day | SSH access to GenieACS |
|
||||
| Phase 1: Parallel deploy | 1 week | Oktopus server provisioned |
|
||||
| Phase 2: Reproduce provisions | 1-2 weeks | Understanding of all scripts |
|
||||
| Phase 3: CPE migration | 2-4 weeks | Phase 2 validated |
|
||||
| Phase 4: TR-369 firmware | Ongoing | CPE vendor firmware availability |
|
||||
| Phase 5: Decommission | 1 day | 30 days after Phase 3 |
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
# XX230v Diagnostic Gaps & Oktopus Test Plan
|
||||
|
||||
## The Problem
|
||||
|
||||
When a customer reports WiFi issues, the tech currently has almost no data to diagnose whether the problem is:
|
||||
- **The XX230v ONT** (GPON side failing, internal routing broken)
|
||||
- **The Deco mesh** (WiFi radio issue, coverage gap)
|
||||
- **The fibre itself** (signal degradation, dirty connector)
|
||||
- **The customer's device** (driver issue, band-steering problem)
|
||||
|
||||
The current GenieACS provision (`xx230v_inform`) only reads:
|
||||
- GPON serial, MAC, uptime
|
||||
- WiFi SSID list (names only, no signal/client data)
|
||||
- Connected hosts (cleared every inform! → useless for diagnosis)
|
||||
|
||||
**We're blind.** The XX230v exposes a rich TR-181 data model that we're not reading.
|
||||
|
||||
---
|
||||
|
||||
## What TR-181 Parameters the XX230v Actually Has
|
||||
|
||||
The XX230v (TP-Link Deco XE75 ONT variant) supports the full TR-181:2 data model. Here's what we SHOULD be reading but currently aren't:
|
||||
|
||||
### 1. Optical Signal (fibre health)
|
||||
```
|
||||
Device.Optical.Interface.1.Status → Up/Down
|
||||
Device.Optical.Interface.1.Stats.SignalRxPower → dBm (target: -8 to -25)
|
||||
Device.Optical.Interface.1.Stats.SignalTxPower → dBm
|
||||
Device.Optical.Interface.1.Stats.BytesSent
|
||||
Device.Optical.Interface.1.Stats.BytesReceived
|
||||
Device.Optical.Interface.1.Stats.ErrorsSent
|
||||
Device.Optical.Interface.1.Stats.ErrorsReceived
|
||||
Device.Optical.Interface.1.Stats.DiscardPacketsSent
|
||||
Device.Optical.Interface.1.Stats.DiscardPacketsReceived
|
||||
Device.Optical.Interface.1.LowerLayers → underlying interface
|
||||
```
|
||||
|
||||
**Diagnosis power:** If RxPower < -25 dBm → fibre problem (dirty connector, bend, break). If errors high → OLT port issue or fibre degradation. No need to swap the ONT.
|
||||
|
||||
### 2. WiFi Radios (per-band stats)
|
||||
```
|
||||
Device.WiFi.Radio.1.Status → Up/Down (2.4GHz)
|
||||
Device.WiFi.Radio.1.Channel
|
||||
Device.WiFi.Radio.1.OperatingFrequencyBand → 2.4GHz / 5GHz / 6GHz
|
||||
Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth → 20MHz/40MHz/80MHz/160MHz
|
||||
Device.WiFi.Radio.1.Stats.Noise → dBm (background noise)
|
||||
Device.WiFi.Radio.1.Stats.BytesSent
|
||||
Device.WiFi.Radio.1.Stats.BytesReceived
|
||||
Device.WiFi.Radio.1.Stats.ErrorsSent
|
||||
Device.WiFi.Radio.1.Stats.ErrorsReceived
|
||||
Device.WiFi.Radio.1.Stats.PacketsSent
|
||||
Device.WiFi.Radio.1.Stats.PacketsReceived
|
||||
|
||||
Device.WiFi.Radio.2.* → 5GHz band
|
||||
Device.WiFi.Radio.3.* → 6GHz band (XE75 has WiFi 6E)
|
||||
```
|
||||
|
||||
**Diagnosis power:** High noise + errors on a specific band → channel congestion (not a hardware fault). Channel 1/6/11 comparison shows interference.
|
||||
|
||||
### 3. WiFi Access Points & Connected Clients
|
||||
```
|
||||
Device.WiFi.AccessPoint.1.AssociatedDevice.{i}.
|
||||
MACAddress → client MAC
|
||||
SignalStrength → dBm (how well client hears the AP)
|
||||
Noise → dBm (noise floor at client)
|
||||
Retransmissions → high = poor signal quality
|
||||
Active → currently connected?
|
||||
LastDataDownlinkRate → actual PHY rate to client (Mbps)
|
||||
LastDataUplinkRate → actual PHY rate from client (Mbps)
|
||||
OperatingStandard → ax/ac/n/g (WiFi generation)
|
||||
|
||||
Device.WiFi.AccessPoint.1.
|
||||
AssociatedDeviceNumberOfEntries → client count
|
||||
Status → Enabled/Disabled
|
||||
SSIDAdvertisementEnabled → SSID visible?
|
||||
```
|
||||
|
||||
**Diagnosis power:** If client SignalStrength > -65 dBm but Retransmissions high → interference. If LastDataDownlinkRate < 50 Mbps on WiFi 6 → something wrong with negotiation. If 0 clients on 5GHz → band steering issue.
|
||||
|
||||
### 4. Deco Mesh Topology (MultiAP / EasyMesh)
|
||||
```
|
||||
Device.WiFi.MultiAP.APDevice.{i}.
|
||||
MACAddress → mesh node MAC
|
||||
Manufacturer
|
||||
SerialNumber
|
||||
Radio.{j}.
|
||||
Noise
|
||||
Utilization → % channel busy
|
||||
AP.{k}.
|
||||
SSID
|
||||
UnicastBytesSent
|
||||
AssociatedDevice.{l}.
|
||||
MACAddress
|
||||
SignalStrength
|
||||
LastDataDownlinkRate
|
||||
|
||||
Device.WiFi.DataElements.Network.Device.{i}.
|
||||
ID → mesh node ID
|
||||
MultiAPCapabilities
|
||||
Radio.{j}.
|
||||
CurrentOperatingClassProfile
|
||||
UnassociatedSTA.{k}. → devices seen but not connected
|
||||
```
|
||||
|
||||
**Diagnosis power:** Shows which Deco node each client is connected to, signal quality per-node, backhaul utilization. If satellite Deco has poor backhaul → move it closer or add wired backhaul.
|
||||
|
||||
### 5. IP Diagnostics (built-in speed/ping test)
|
||||
```
|
||||
Device.IP.Diagnostics.IPPing.
|
||||
Host → target to ping
|
||||
NumberOfRepetitions
|
||||
Timeout
|
||||
DataBlockSize
|
||||
DiagnosticsState → set to "Requested" to trigger
|
||||
→ Results:
|
||||
SuccessCount, FailureCount
|
||||
AverageResponseTime
|
||||
MinimumResponseTime
|
||||
MaximumResponseTime
|
||||
|
||||
Device.IP.Diagnostics.DownloadDiagnostics.
|
||||
DownloadURL → URL to download from
|
||||
DiagnosticsState → "Requested" to trigger
|
||||
→ Results:
|
||||
ROMTime, BOMTime, EOMTime
|
||||
TestBytesReceived
|
||||
TotalBytesReceived
|
||||
→ Calculate: speed = TestBytesReceived / (EOMTime - BOMTime)
|
||||
|
||||
Device.IP.Diagnostics.UploadDiagnostics.
|
||||
UploadURL → URL to upload to
|
||||
DiagnosticsState → "Requested" to trigger
|
||||
→ Results: same pattern as download
|
||||
```
|
||||
|
||||
**Diagnosis power:** Remote speed test FROM the ONT itself. Eliminates WiFi as a variable. If ONT→server speed is good but client speed is bad → WiFi issue. If ONT→server speed is bad → fibre/OLT issue.
|
||||
|
||||
### 6. Ethernet Ports
|
||||
```
|
||||
Device.Ethernet.Interface.{i}.
|
||||
Status → Up/Down
|
||||
MACAddress
|
||||
MaxBitRate → negotiated link speed (100/1000/2500)
|
||||
DuplexMode → Full/Half
|
||||
Stats.BytesSent/Received
|
||||
Stats.ErrorsSent/Received
|
||||
Stats.PacketsSent/Received
|
||||
```
|
||||
|
||||
**Diagnosis power:** If Ethernet negotiated at 100Mbps instead of 1Gbps → bad cable or port. If errors high → physical layer issue.
|
||||
|
||||
### 7. DNS & Routing
|
||||
```
|
||||
Device.DNS.Client.Server.{i}.
|
||||
DNSServer → configured DNS
|
||||
Status
|
||||
Type → DHCPv4 / Static
|
||||
|
||||
Device.Routing.Router.1.IPv4Forwarding.{i}.
|
||||
DestIPAddress
|
||||
GatewayIPAddress
|
||||
Interface
|
||||
Status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Add to GenieACS Inform (Immediate)
|
||||
|
||||
Update the `xx230v_inform` provision to read diagnostic data on every inform:
|
||||
|
||||
```javascript
|
||||
// === DIAGNOSTIC DATA (add to xx230v_inform) ===
|
||||
|
||||
// Optical signal — THE most important diagnostic
|
||||
declare("Device.Optical.Interface.1.Status", {value: now});
|
||||
declare("Device.Optical.Interface.1.Stats.SignalRxPower", {value: now});
|
||||
declare("Device.Optical.Interface.1.Stats.SignalTxPower", {value: now});
|
||||
declare("Device.Optical.Interface.1.Stats.ErrorsSent", {value: now});
|
||||
declare("Device.Optical.Interface.1.Stats.ErrorsReceived", {value: now});
|
||||
|
||||
// WiFi radio stats (per band)
|
||||
declare("Device.WiFi.Radio.1.Status", {value: now});
|
||||
declare("Device.WiFi.Radio.1.Channel", {value: now});
|
||||
declare("Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth", {value: now});
|
||||
declare("Device.WiFi.Radio.1.Stats.Noise", {value: now});
|
||||
declare("Device.WiFi.Radio.1.Stats.ErrorsSent", {value: now});
|
||||
declare("Device.WiFi.Radio.2.Status", {value: now});
|
||||
declare("Device.WiFi.Radio.2.Channel", {value: now});
|
||||
declare("Device.WiFi.Radio.2.CurrentOperatingChannelBandwidth", {value: now});
|
||||
declare("Device.WiFi.Radio.2.Stats.Noise", {value: now});
|
||||
declare("Device.WiFi.Radio.3.Status", {value: now}); // 6GHz if supported
|
||||
declare("Device.WiFi.Radio.3.Channel", {value: now});
|
||||
|
||||
// Connected clients count per AP
|
||||
declare("Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries", {value: now});
|
||||
declare("Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries", {value: now});
|
||||
declare("Device.WiFi.AccessPoint.3.AssociatedDeviceNumberOfEntries", {value: now});
|
||||
|
||||
// WAN IP (already partially read)
|
||||
declare("Device.IP.Interface.1.IPv4Address.1.IPAddress", {value: now});
|
||||
|
||||
// Ethernet port status
|
||||
declare("Device.Ethernet.Interface.1.Status", {value: now});
|
||||
declare("Device.Ethernet.Interface.1.MaxBitRate", {value: now});
|
||||
declare("Device.Ethernet.Interface.2.Status", {value: now});
|
||||
declare("Device.Ethernet.Interface.2.MaxBitRate", {value: now});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Equipment Swap Status — Fix the "Not Sure It's Defective" Problem
|
||||
|
||||
Instead of immediately marking as "Défectueux", the swap flow should support **diagnostic swap**:
|
||||
|
||||
### Updated Status Flow
|
||||
```
|
||||
Normal equipment statuses:
|
||||
En inventaire → Actif → [issue reported] →
|
||||
|
||||
Option A: "En diagnostic" ← tech swaps to test, original goes back to warehouse
|
||||
Option B: "Défectueux" ← confirmed dead
|
||||
Option C: "Retourné" ← returned to stock after test (was fine)
|
||||
|
||||
Swap types:
|
||||
1. "Remplacement définitif" → old = Défectueux, new = Actif
|
||||
2. "Swap diagnostic" → old = En diagnostic, new = Actif (temporary)
|
||||
3. "Retour de diagnostic" → old = Actif (put back), temp = Retourné
|
||||
```
|
||||
|
||||
### Updated Wizard Step 2 (Reason)
|
||||
```
|
||||
Step 2: "Type de remplacement" [select-cards]
|
||||
🔴 Remplacement définitif — L'équipement est mort
|
||||
🟡 Swap diagnostic — Tester si le problème vient de l'équipement
|
||||
🔵 Mise à niveau — Remplacer par un modèle supérieur
|
||||
```
|
||||
|
||||
If "Swap diagnostic" is chosen:
|
||||
- Old equipment → status "En diagnostic" (not Défectueux)
|
||||
- A follow-up task is auto-created: "Diagnostic result for {serial}"
|
||||
- Due in 7 days
|
||||
- Options: "Confirmed defective" → mark Défectueux | "Works fine" → mark Retourné
|
||||
|
||||
---
|
||||
|
||||
## Oktopus Test Plan — What to Verify
|
||||
|
||||
### Step 1: Deploy Oktopus with TR-069 Adapter
|
||||
|
||||
Oktopus is already at `oss.gigafibre.ca` with 8 containers. We need to:
|
||||
|
||||
1. **Verify it's running**: `curl https://oss.gigafibre.ca/api/health`
|
||||
2. **Check the TR-069 adapter port**: Oktopus listens for CWMP connections (typically 7547)
|
||||
3. **Check the admin UI**: Oktopus CE has a web dashboard
|
||||
|
||||
### Step 2: Point ONE Test XX230v to Oktopus
|
||||
|
||||
On GenieACS, push a parameter change to a single test device:
|
||||
```
|
||||
Device.ManagementServer.URL = "https://acs-new.gigafibre.ca:7547"
|
||||
```
|
||||
|
||||
Or physically: configure the ACS URL in the XX230v admin panel.
|
||||
|
||||
### Step 3: What to Check on Oktopus
|
||||
|
||||
Once the XX230v connects to Oktopus:
|
||||
|
||||
| Check | What to Look For | Why It Matters |
|
||||
|-------|-----------------|----------------|
|
||||
| Device appears | Does Oktopus see the TR-069 inform? | Basic connectivity |
|
||||
| Data model | Does it show the full TR-181 tree? | TR-181 paths are the same |
|
||||
| Parameter read | Can we read `Device.Optical.Interface.1.Stats.SignalRxPower`? | Real-time diagnostics |
|
||||
| Parameter set | Can we push WiFi SSID/password? | Provisioning works |
|
||||
| Reboot | Can we trigger a remote reboot? | Basic management |
|
||||
| Firmware | Can we push a firmware update? | Fleet management |
|
||||
| Tasks | Can we queue tasks for next inform? | Offline task queue |
|
||||
| Webhooks | Does Oktopus fire webhooks on events? | n8n integration |
|
||||
| Multiple devices | Can it handle the full fleet? | Scalability |
|
||||
|
||||
### Step 4: TR-369 (USP) Check
|
||||
|
||||
If the XX230v firmware supports USP (check TP-Link release notes for your version):
|
||||
```
|
||||
Device.LocalAgent.Controller.{i}.
|
||||
EndpointID
|
||||
Protocol → MQTT / WebSocket
|
||||
MTP.{j}.
|
||||
Enable
|
||||
Protocol
|
||||
MQTT.Topic
|
||||
```
|
||||
|
||||
If USP is available → configure Oktopus as USP controller → real-time bidirectional management (no more polling!).
|
||||
|
||||
### Step 5: Compare GenieACS vs Oktopus Data
|
||||
|
||||
Run the same device through both ACS simultaneously (different inform intervals) and compare:
|
||||
|
||||
| Aspect | GenieACS | Oktopus | Notes |
|
||||
|--------|----------|---------|-------|
|
||||
| Inform data | _lastInform, basic params | ? | Same TR-181 paths |
|
||||
| WiFi clients | Cleared by provision (broken!) | ? | Don't clear in Oktopus |
|
||||
| Optical power | Only via summarizeDevice() | ? | Should be real-time |
|
||||
| Remote diagnostics | Via NBI task push | Via USP Operate | USP is synchronous |
|
||||
| Webhook events | None (we poll) | Built-in | Major improvement |
|
||||
| Firmware mgmt | GridFS upload + provision | Native firmware repo | Cleaner |
|
||||
| Bulk operations | JS provisions | Device groups + policies | More scalable |
|
||||
|
||||
---
|
||||
|
||||
## Immediate Actions
|
||||
|
||||
### 1. Update xx230v_inform provision (15 min)
|
||||
Add the diagnostic parameters listed above. Deploys instantly to all XX230v fleet via GenieACS.
|
||||
|
||||
### 2. Update summarizeDevice() in targo-hub (15 min)
|
||||
Add optical signal, WiFi radio stats, client counts, Ethernet link speed to the device summary.
|
||||
|
||||
### 3. Update Ops app device detail view (30 min)
|
||||
Show the new diagnostic data in the device panel (Optical power, WiFi channels, client count per band).
|
||||
|
||||
### 4. SSH to Oktopus server, verify it's running (10 min)
|
||||
Check `docker compose ps` at `/opt/oktopus/`, verify API responds, check admin UI.
|
||||
|
||||
### 5. Point one test XX230v to Oktopus (10 min)
|
||||
Via GenieACS: set `Device.ManagementServer.URL` on a single device.
|
||||
|
||||
### 6. Add "En diagnostic" status to Service Equipment (5 min)
|
||||
Add the new status option + update swap flow logic.
|
||||
BIN
docs/assets/screenshots/invoice-pdf.png
Normal file
BIN
docs/assets/screenshots/invoice-pdf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
BIN
docs/assets/screenshots/pay-public.png
Normal file
BIN
docs/assets/screenshots/pay-public.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/assets/soumission_v1.pdf
Normal file
BIN
docs/assets/soumission_v1.pdf
Normal file
Binary file not shown.
364
docs/build-billing-pptx.js
Normal file
364
docs/build-billing-pptx.js
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
// Billing & Payments — Dev handoff presentation
|
||||
// Regenerate with: cd docs && node build-billing-pptx.js
|
||||
//
|
||||
// Structure (per user feedback 2026-04-17):
|
||||
// 1. Title
|
||||
// 2. End-to-end flow (facture → paiement) with scripts listed under each stage
|
||||
// 3. OPS app modules with scripts/components per module
|
||||
// 4. PDF preview + pay-public preview (aspect ratios preserved)
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const pptxgen = require("pptxgenjs");
|
||||
const sharp = require("sharp");
|
||||
|
||||
function imgPath(rel) {
|
||||
const p = path.join(__dirname, rel);
|
||||
return fs.existsSync(p) ? p : null;
|
||||
}
|
||||
async function imgRatio(rel) {
|
||||
const p = path.join(__dirname, rel);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const m = await sharp(p).metadata();
|
||||
return { w: m.width, h: m.height, ratio: m.width / m.height, path: p };
|
||||
}
|
||||
// Fit an image inside a target box (tx, ty, tw, th) maintaining its native ratio
|
||||
function fitImage(meta, tx, ty, tw, th) {
|
||||
const boxR = tw / th;
|
||||
let w, h;
|
||||
if (meta.ratio > boxR) { w = tw; h = tw / meta.ratio; }
|
||||
else { h = th; w = th * meta.ratio; }
|
||||
const x = tx + (tw - w) / 2;
|
||||
const y = ty + (th - h) / 2;
|
||||
return { path: meta.path, x, y, w, h };
|
||||
}
|
||||
|
||||
const C = {
|
||||
darkBg: "0D0F18",
|
||||
cardBg: "181C2E",
|
||||
cardBg2: "111422",
|
||||
purple: "5C59A8",
|
||||
purpleDk: "3F3D7A",
|
||||
accent: "818CF8",
|
||||
green: "22C55E",
|
||||
amber: "F59E0B",
|
||||
red: "EF4444",
|
||||
white: "FFFFFF",
|
||||
text: "E2E4EF",
|
||||
muted: "7B80A0",
|
||||
indigo: "6366F1",
|
||||
};
|
||||
const shadow = () => ({ type: "outer", blur: 8, offset: 3, angle: 135, color: "000000", opacity: 0.3 });
|
||||
|
||||
let pres;
|
||||
|
||||
function header(s, title, subtitle) {
|
||||
s.background = { color: C.darkBg };
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
|
||||
s.addText(title, { x: 0.6, y: 0.22, w: 9, h: 0.6, fontSize: 26, fontFace: "Arial Black", color: C.white, margin: 0 });
|
||||
if (subtitle) s.addText(subtitle, { x: 0.6, y: 0.76, w: 9, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.muted, margin: 0 });
|
||||
}
|
||||
|
||||
async function build() {
|
||||
pres = new pptxgen();
|
||||
pres.layout = "LAYOUT_16x9";
|
||||
pres.author = "Targo";
|
||||
pres.title = "Facturation & Paiements — Handoff dev";
|
||||
|
||||
// ═════════ SLIDE 1 — Title ═════════
|
||||
let s = pres.addSlide();
|
||||
s.background = { color: C.darkBg };
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.2, w: 0.12, h: 1.8, fill: { color: C.indigo } });
|
||||
s.addText("Facturation", { x: 1.15, y: 1.2, w: 8, h: 0.7, fontSize: 42, fontFace: "Arial Black", color: C.white, bold: true, margin: 0 });
|
||||
s.addText("& Paiements", { x: 1.15, y: 1.85, w: 8, h: 0.6, fontSize: 36, fontFace: "Arial Black", color: C.accent, margin: 0 });
|
||||
s.addText("Handoff technique — pipeline end-to-end + modules OPS", { x: 1.15, y: 2.7, w: 8.5, h: 0.4, fontSize: 16, fontFace: "Calibri", color: C.muted, margin: 0 });
|
||||
|
||||
const heroStats = [
|
||||
{ v: "80", l: "Scripts migration" },
|
||||
{ v: "115 K", l: "Factures importées" },
|
||||
{ v: "1,6 M", l: "Items (incl. backfill)" },
|
||||
{ v: "15", l: "Pages OPS" },
|
||||
];
|
||||
heroStats.forEach((st, i) => {
|
||||
const sx = 1.15 + i * 2.1;
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: sx, y: 3.7, w: 1.8, h: 1.0, fill: { color: C.cardBg }, shadow: shadow() });
|
||||
s.addText(st.v, { x: sx, y: 3.72, w: 1.8, h: 0.55, fontSize: 22, fontFace: "Arial Black", color: C.accent, align: "center", valign: "middle", margin: 0 });
|
||||
s.addText(st.l, { x: sx, y: 4.25, w: 1.8, h: 0.35, fontSize: 10, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
|
||||
});
|
||||
s.addText("Avril 2026 • erp.gigafibre.ca + client.gigafibre.ca + ops", { x: 0.8, y: 5.1, w: 9, h: 0.3, fontSize: 10, fontFace: "Calibri", color: C.muted, margin: 0 });
|
||||
|
||||
// ═════════ SLIDE 2 — End-to-end flow with scripts per stage ═════════
|
||||
s = pres.addSlide();
|
||||
header(s, "Flux facture → paiement", "Scripts & fichiers utilisés à chaque étape");
|
||||
|
||||
// 6 stages laid out left-to-right, each with a card + scripts list below
|
||||
const stages = [
|
||||
{
|
||||
title: "1. Import legacy",
|
||||
tagline: "MariaDB → PostgreSQL",
|
||||
color: C.purple,
|
||||
scripts: [
|
||||
"import_invoices.py",
|
||||
"import_payments.py",
|
||||
"import_payment_methods.py",
|
||||
"import_payment_arrangements.py",
|
||||
"import_expro_payments.py",
|
||||
"backfill_service_location.py",
|
||||
"fix_invoice_outstanding.py",
|
||||
"fix_invoice_customer_names.py",
|
||||
"add_missing_custom_fields.py",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "2. Facture",
|
||||
tagline: "ERPNext Sales Invoice",
|
||||
color: C.accent,
|
||||
scripts: [
|
||||
"setup_invoice_print_format.py",
|
||||
"test_jinja_render.py",
|
||||
"Print Format: « Facture TARGO »",
|
||||
"Jinja template (inline)",
|
||||
"pdf_generator=\"chrome\"",
|
||||
"Doctype: Sales Invoice",
|
||||
"Custom field: service_location",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "3. Rendu PDF",
|
||||
tagline: "Chromium --print-to-pdf",
|
||||
color: C.green,
|
||||
scripts: [
|
||||
"gigafibre_utils.api.invoice_pdf",
|
||||
"Dockerfile (chromium install)",
|
||||
"common_site_config:",
|
||||
" chromium_path=/usr/bin/chromium",
|
||||
"cairosvg → logo PNG",
|
||||
"Producer: Skia/PDF m147",
|
||||
"~1 s / facture",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "4. QR / Magic-link",
|
||||
tagline: "Tokens HMAC-SHA256",
|
||||
color: C.indigo,
|
||||
scripts: [
|
||||
"api.invoice_qr(invoice)",
|
||||
"api.invoice_qr_datauri(invoice)",
|
||||
"api._sign_pay_token",
|
||||
"api.validate_pay_token",
|
||||
"api.request_magic_link",
|
||||
"api.pay_token (admin)",
|
||||
"api.pay_redirect (portal)",
|
||||
"Secret: gigafibre_pay_secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "5. Landing publique",
|
||||
tagline: "client.gigafibre.ca/pay-public",
|
||||
color: C.amber,
|
||||
scripts: [
|
||||
"gigafibre_utils/www/",
|
||||
" pay-public.html",
|
||||
"/opt/traefik/dynamic/",
|
||||
" pay-public.yml",
|
||||
"Traefik priority ≥ 250",
|
||||
"(carve-out Authentik)",
|
||||
"Vanilla JS, pas de build",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "6. Stripe + Payment Entry",
|
||||
tagline: "Checkout → webhook → réconciliation",
|
||||
color: C.red,
|
||||
scripts: [
|
||||
"api.create_checkout_session",
|
||||
"api.stripe_webhook",
|
||||
"api._stripe_post",
|
||||
"api._stripe_verify_signature",
|
||||
"Secret: gigafibre_stripe_secret_key",
|
||||
"Secret: gigafibre_stripe_webhook_secret",
|
||||
"Twilio: twilio_account_sid / token / from",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const cardW = 1.5, cardH = 0.7, gap = 0.08;
|
||||
const totalW = stages.length * cardW + (stages.length - 1) * gap;
|
||||
const startX = (10 - totalW) / 2;
|
||||
const cardY = 1.3;
|
||||
|
||||
stages.forEach((st, i) => {
|
||||
const x = startX + i * (cardW + gap);
|
||||
s.addShape(pres.shapes.RECTANGLE, {
|
||||
x, y: cardY, w: cardW, h: cardH,
|
||||
fill: { color: C.cardBg }, line: { color: st.color, width: 1.5 }, shadow: shadow(),
|
||||
});
|
||||
s.addText(st.title, { x, y: cardY + 0.03, w: cardW, h: 0.32, fontSize: 10, fontFace: "Arial Black", color: C.white, align: "center", valign: "middle", margin: 0 });
|
||||
s.addText(st.tagline, { x, y: cardY + 0.35, w: cardW, h: 0.3, fontSize: 7.5, fontFace: "Calibri", color: st.color, align: "center", valign: "middle", margin: 0 });
|
||||
|
||||
// Arrow to next stage
|
||||
if (i < stages.length - 1) {
|
||||
s.addText("→", { x: x + cardW - 0.05, y: cardY + 0.18, w: gap + 0.1, h: 0.35, fontSize: 14, color: C.muted, align: "center", valign: "middle", margin: 0, bold: true });
|
||||
}
|
||||
|
||||
// Scripts list under the card
|
||||
const listY = cardY + cardH + 0.15;
|
||||
s.addShape(pres.shapes.RECTANGLE, {
|
||||
x, y: listY, w: cardW, h: 3.3,
|
||||
fill: { color: C.cardBg2 }, line: { color: st.color, width: 0.5 },
|
||||
});
|
||||
const txt = st.scripts.map(sc => "• " + sc).join("\n");
|
||||
s.addText(txt, {
|
||||
x: x + 0.04, y: listY + 0.05, w: cardW - 0.08, h: 3.2,
|
||||
fontSize: 6.8, fontFace: "Courier New", color: C.text, valign: "top", margin: 0,
|
||||
paraSpaceAfter: 2,
|
||||
});
|
||||
});
|
||||
|
||||
// Footer: common secrets
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: 5.5, w: 9.2, h: 0.8, fill: { color: "1a1a2e" }, line: { color: C.purple, width: 0.5 } });
|
||||
s.addText("Configuration (common_site_config.json)", { x: 0.55, y: 5.55, w: 9, h: 0.2, fontSize: 9, fontFace: "Calibri", color: C.accent, bold: true, margin: 0 });
|
||||
s.addText("gigafibre_pay_secret · gigafibre_pay_host · gigafibre_stripe_secret_key · gigafibre_stripe_webhook_secret · twilio_account_sid · twilio_auth_token · twilio_from_number · chromium_path", {
|
||||
x: 0.55, y: 5.8, w: 9, h: 0.45, fontSize: 8, fontFace: "Courier New", color: C.text, margin: 0,
|
||||
});
|
||||
|
||||
// ═════════ SLIDE 3 — OPS modules with scripts/components ═════════
|
||||
s = pres.addSlide();
|
||||
header(s, "Modules OPS", "Pages & composants · apps/ops/src/");
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: "Clients",
|
||||
color: C.purple,
|
||||
files: [
|
||||
"pages/ClientsPage.vue",
|
||||
"pages/ClientDetailPage.vue",
|
||||
"components/customer/*",
|
||||
"components/shared/",
|
||||
" detail-sections/",
|
||||
" InvoiceDetail.vue",
|
||||
" PaymentDetail.vue",
|
||||
" SubscriptionDetail.vue",
|
||||
" EquipmentDetail.vue",
|
||||
" IssueDetail.vue",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Dispatch",
|
||||
color: C.amber,
|
||||
files: [
|
||||
"pages/DispatchPage.vue",
|
||||
"components/dispatch/",
|
||||
"pages/dispatch-styles.scss",
|
||||
"mode sombre (exception)",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Facturation",
|
||||
color: C.green,
|
||||
files: [
|
||||
"shared/CreateInvoiceModal.vue",
|
||||
"shared/detail-sections/",
|
||||
" InvoiceDetail.vue",
|
||||
" onglet Aperçu client",
|
||||
" iframe → invoice_pdf",
|
||||
"shared/detail-sections/",
|
||||
" PaymentDetail.vue",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Rapports",
|
||||
color: C.indigo,
|
||||
files: [
|
||||
"pages/RapportsPage.vue",
|
||||
"pages/ReportARPage.vue",
|
||||
"pages/ReportRevenuPage.vue",
|
||||
"pages/ReportTaxesPage.vue",
|
||||
"pages/ReportVentesPage.vue",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Network",
|
||||
color: C.accent,
|
||||
files: [
|
||||
"pages/NetworkPage.vue",
|
||||
"pages/TelephonyPage.vue",
|
||||
"pages/OcrPage.vue",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Équipe & Tickets",
|
||||
color: C.red,
|
||||
files: [
|
||||
"pages/EquipePage.vue",
|
||||
"pages/TicketsPage.vue",
|
||||
"pages/AgentFlowsPage.vue",
|
||||
"pages/DashboardPage.vue",
|
||||
"pages/SettingsPage.vue",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const cols = 3, rows = 2;
|
||||
const mW = 2.9, mH = 2.25;
|
||||
const mGapX = 0.15, mGapY = 0.15;
|
||||
const mStartX = (10 - (cols * mW + (cols - 1) * mGapX)) / 2;
|
||||
const mStartY = 1.3;
|
||||
|
||||
modules.forEach((mod, i) => {
|
||||
const cx = mStartX + (i % cols) * (mW + mGapX);
|
||||
const cy = mStartY + Math.floor(i / cols) * (mH + mGapY);
|
||||
s.addShape(pres.shapes.RECTANGLE, {
|
||||
x: cx, y: cy, w: mW, h: mH,
|
||||
fill: { color: C.cardBg }, line: { color: mod.color, width: 1.5 }, shadow: shadow(),
|
||||
});
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: cx, y: cy, w: mW, h: 0.35, fill: { color: mod.color } });
|
||||
s.addText(mod.title, { x: cx + 0.1, y: cy, w: mW - 0.2, h: 0.35, fontSize: 12, fontFace: "Arial Black", color: C.white, valign: "middle", margin: 0 });
|
||||
s.addText(mod.files.map(f => "• " + f).join("\n"), {
|
||||
x: cx + 0.1, y: cy + 0.42, w: mW - 0.2, h: mH - 0.5,
|
||||
fontSize: 8.5, fontFace: "Courier New", color: C.text, valign: "top", margin: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Footer: deploy note
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: mStartY + 2 * (mH + mGapY) + 0.1, w: 9.2, h: 0.5, fill: { color: "1a1a2e" }, line: { color: C.amber, width: 0.5 } });
|
||||
s.addText("Déploiement: scp dist/spa/* → /opt/ops-app/ · nginx proxy API (token) · Authentik SSO · Traefik routing", {
|
||||
x: 0.55, y: mStartY + 2 * (mH + mGapY) + 0.18, w: 9, h: 0.4, fontSize: 9, fontFace: "Calibri", color: C.text, margin: 0,
|
||||
});
|
||||
|
||||
// ═════════ SLIDE 4 — Visuels : PDF facture + landing pay-public ═════════
|
||||
s = pres.addSlide();
|
||||
header(s, "Visuels", "PDF Facture TARGO (Chrome) + Landing /pay-public");
|
||||
|
||||
const invoiceMeta = await imgRatio("assets/screenshots/invoice-pdf.png");
|
||||
const payMeta = await imgRatio("assets/screenshots/pay-public.png");
|
||||
|
||||
// Two equal 4.5" × 4.5" boxes, images fit maintaining ratio
|
||||
const box = { w: 4.3, h: 4.4, y: 1.2 };
|
||||
const leftX = 0.4;
|
||||
const rightX = 5.3;
|
||||
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: leftX, y: box.y, w: box.w, h: box.h, fill: { color: C.white }, line: { color: C.purple, width: 1 } });
|
||||
if (invoiceMeta) {
|
||||
const fit = fitImage(invoiceMeta, leftX + 0.05, box.y + 0.05, box.w - 0.1, box.h - 0.1);
|
||||
s.addImage({ path: fit.path, x: fit.x, y: fit.y, w: fit.w, h: fit.h });
|
||||
}
|
||||
s.addText("PDF Facture TARGO", { x: leftX, y: box.y + box.h + 0.05, w: box.w, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.white, bold: true, align: "center", margin: 0 });
|
||||
s.addText("Chromium --print-to-pdf · 1 page · ~1 s", { x: leftX, y: box.y + box.h + 0.32, w: box.w, h: 0.25, fontSize: 9, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
|
||||
|
||||
s.addShape(pres.shapes.RECTANGLE, { x: rightX, y: box.y, w: box.w, h: box.h, fill: { color: C.white }, line: { color: C.accent, width: 1 } });
|
||||
if (payMeta) {
|
||||
const fit = fitImage(payMeta, rightX + 0.05, box.y + 0.05, box.w - 0.1, box.h - 0.1);
|
||||
s.addImage({ path: fit.path, x: fit.x, y: fit.y, w: fit.w, h: fit.h });
|
||||
}
|
||||
s.addText("Landing /pay-public", { x: rightX, y: box.y + box.h + 0.05, w: box.w, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.white, bold: true, align: "center", margin: 0 });
|
||||
s.addText("Bypass Authentik · Stripe Checkout · Magic-link SMS/email", { x: rightX, y: box.y + box.h + 0.32, w: box.w, h: 0.25, fontSize: 9, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
|
||||
|
||||
// Save
|
||||
const out = path.join(__dirname, "Gigafibre-Billing-Handoff.pptx");
|
||||
await pres.writeFile({ fileName: out });
|
||||
console.log("Wrote:", out);
|
||||
}
|
||||
|
||||
build().catch(e => { console.error(e); process.exit(1); });
|
||||
1581
docs/legacy-wizard/account_wizard.php
Normal file
1581
docs/legacy-wizard/account_wizard.php
Normal file
File diff suppressed because it is too large
Load Diff
51
docs/legacy-wizard/account_wizard_ajax.php
Normal file
51
docs/legacy-wizard/account_wizard_ajax.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
include_once "authentication.php";
|
||||
|
||||
if(isset($_GET['code'])){
|
||||
|
||||
if($_GET['code'] == '1070')
|
||||
echo 1;
|
||||
else
|
||||
echo 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
if(isset($_POST['cp'])){
|
||||
|
||||
$zip = $sql->real_escape_string(str_replace(' ','',$_POST['cp']));
|
||||
$civic = $sql->real_escape_string($_POST['civic']);
|
||||
|
||||
$q_fibre = "SELECT * FROM `fibre` WHERE `zip` = '$zip' AND `terrain` = '$civic'";
|
||||
$res_fibre = $sql->query($q_fibre);
|
||||
|
||||
$fibre_find = 0;
|
||||
$fibre_result = '';
|
||||
|
||||
if($res_fibre->num_rows > 0){
|
||||
$fibre_result .= "Addresse disponible (<b>UN option DOIT être selectionné</b>):<br><br><table class='table table-hover'>";
|
||||
while($row_fibre = $res_fibre->fetch_array()){
|
||||
|
||||
$opt_input = "<input type='radio' id='opt_addr_{$row_fibre['id']}' name='opt_addr' value='{$row_fibre['id']}'>";
|
||||
$tr = "onclick='fibre_sel({$row_fibre['id']})'";
|
||||
|
||||
if($row_fibre['sn'] != ''){
|
||||
$opt_input = "";
|
||||
$tr = "title='Sn déja atrtibué pour cette adresse'";
|
||||
}
|
||||
|
||||
$boitier = ($row_fibre['boitier_pas_install'] == 1) ? "<span class='alert-warning'>Boitier non installé, ajouter 2 semaines de délais</span>" : '';
|
||||
$fibre_result .= "<tr $tr><td>$opt_input</td><td>{$row_fibre['terrain']}</td><td>{$row_fibre['rue']}</td><td>{$row_fibre['ville']}</td><td>{$row_fibre['zip']}</td><td>{$row_fibre['description']}</td><td>$boitier</td></tr>";
|
||||
}
|
||||
$fibre_result .= "</table>";
|
||||
}
|
||||
else
|
||||
$fibre_result = "<div class='alert alert-danger'>Aucune adresse trouvée. Non disponible.</div>";
|
||||
|
||||
echo $fibre_result;
|
||||
|
||||
}
|
||||
|
||||
|
||||
?>
|
||||
250
docs/legacy-wizard/tele_wizard_package.php
Normal file
250
docs/legacy-wizard/tele_wizard_package.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
|
||||
include_once "authentication.php";
|
||||
|
||||
|
||||
//echo "<pre>"; print_r($_POST); echo "</pre>";
|
||||
|
||||
/*
|
||||
$_POST['tw_delivery_id'] = 2;
|
||||
$_POST['tw_sub_id'] = 3169;
|
||||
*/
|
||||
|
||||
$q_delivery = "SELECT * FROM `delivery` WHERE `id` = {$_POST['tw_delivery_id']}";
|
||||
$res_delivery = $sql->query($q_delivery);
|
||||
$row_delivery = $res_delivery->fetch_assoc();
|
||||
|
||||
$q_account = "SELECT * FROM `account` WHERE `id` = {$row_delivery['account_id']}";
|
||||
$res_account = $sql->query($q_account);
|
||||
$row_account = $res_account->fetch_assoc();
|
||||
|
||||
|
||||
if(isset($_POST['date_due'])){
|
||||
|
||||
|
||||
|
||||
$time = time();
|
||||
$date_due = explode('-',$_POST['date_due']);
|
||||
$date_due = mktime(0,0,0,$date_due[1],$date_due[0],$date_due[2]);
|
||||
$aid = $_POST['tw_account_id'];
|
||||
$did = $_POST['tw_delivery_id'];
|
||||
$nb = $_POST['nb_stb'];
|
||||
$credit = (isset($_POST['chk_credit'])) ? 1 : 0;
|
||||
$fbase = $_POST['forfbase'];
|
||||
$fthem = (isset($_POST['theme_forfait'])) ? json_encode($_POST['theme_forfait']) : '';
|
||||
|
||||
|
||||
//die("<pre>$fthem -- {$row_account['customer_id']}</pre>");
|
||||
|
||||
$subject = change_quote("{$row_delivery['city']} | [TELE $nb STB] {$row_delivery['name']}");
|
||||
$msg = '';
|
||||
|
||||
|
||||
$q_ticket = "INSERT INTO `ticket` (`account_id`, `subject`, `dept_id`, `open_by`, `assign_to`, `due_date`, `date_create`, `last_update`) VALUES ($aid, '$subject', 41, $userid, 3301, '$date_due','$time','$time')";
|
||||
if($sql->query($q_ticket)){
|
||||
|
||||
$ticket_id = $sql->insert_id;
|
||||
|
||||
$q_wiz = "INSERT INTO `tele_wiz` (`account_id`, `delivery_id`, `ticket_id`, `nb_stb`, `credit`, `fbase`, `fthem`) VALUES ($aid,$did,$ticket_id,$nb,$credit,$fbase,'$fthem')";
|
||||
$sql->query($q_wiz);
|
||||
$wiz_id = $sql->insert_id;
|
||||
|
||||
$msg = $_POST['memo'] . "<br><br> Activer le(s) STB en cliquant <a href='https://store.targo.ca/targo/rep_ticket/connect_stb.php?wid=$wiz_id' target='_blank'>ici</a>";
|
||||
$msg = $sql->real_escape_string($msg);
|
||||
|
||||
$q_ticket_msg = "INSERT INTO `ticket_msg` (`ticket_id`, `staff_id`, `msg`, `date_orig`) VALUES ($ticket_id,$userid,'$msg','$time')";
|
||||
$sql->query($q_ticket_msg);
|
||||
|
||||
|
||||
if($_POST['opt_carte'] == 1){
|
||||
//next step chaine a la carte
|
||||
die("<form id='f_tele_carte_wiz' method='POST' action='accueil.php?menu=tele_carte_add'>
|
||||
<input type='hidden' name='fromaccount_accid' value='$aid'>
|
||||
<input type='hidden' name='fromaccount_cusid' value='{$row_account['customer_id']}'>
|
||||
<input type='hidden' id='from_account_delid' name='from_account_delid' value='$did'>
|
||||
</form>
|
||||
<script>$('#f_tele_carte_wiz').submit();</script>");
|
||||
}
|
||||
else{
|
||||
die("<script>location.replace('accueil.php?menu=ticket_view&id=$ticket_id');</script>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
##echo "<pre>$q_ticket<br>$q_ticket_msg<br>$q_wiz</pre>";
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
$option_base = "<option value='634'>Skinny</option><option value='635'>Standard</option><option value='636'>Evolue</option>";
|
||||
|
||||
$q_forfait = "SELECT `product`.`id`,`product`.`sku`, `product_translate`.`name` FROM `product` LEFT JOIN `product_translate` ON `product_translate`.`product_id` = `product`.`id` WHERE `product`.`type` = '4' AND `product_translate`.`language_id` = 'francais' AND `product`.`active` = 1";
|
||||
$res_forfait = $sql->query($q_forfait);
|
||||
$option_forfait = "";
|
||||
$black_list = array(629,634,635,636);
|
||||
while($row_forfait = $res_forfait->fetch_assoc()){
|
||||
|
||||
if(in_array($row_forfait['id'],$black_list)) continue;
|
||||
|
||||
$option_forfait .= "<option value='{$row_forfait['id']}_{$row_forfait['name']}'>{$row_forfait['name']}</option>";
|
||||
|
||||
}
|
||||
|
||||
$memo = "# Client:{$row_account['customer_id'] }\nNom: {$row_delivery['name']}\nAdresse: {$row_delivery['address1']}\nVille: {$row_delivery['city']}\nCode Postal:{$row_delivery['zip']}\nTéléphone: {$row_delivery['tel_home']}\nCell: {$row_delivery['cell']}\nEmail: {$row_delivery['email']}\n\n";
|
||||
|
||||
|
||||
?>
|
||||
|
||||
|
||||
<h3>Tele Wiz - Package</h3><br>
|
||||
|
||||
<form class="form-horizontal" id='f_tele_wiz' method='POST' data-toggle="validator" role="form">
|
||||
|
||||
<input type='hidden' name='tw_account_id' value='<?php echo $row_account['id'];?>'>
|
||||
<input type='hidden' name='tw_delivery_id' value='<?php echo $_POST['tw_delivery_id'];?>'>
|
||||
<input type='hidden' name='tw_sub_id' value='<?php echo $_POST['tw_sub_id'];?>'>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="invoice_date_due" class="col-sm-2 control-label">Date installation</label>
|
||||
<div class='col-sm-3'>
|
||||
<div class='input-group date' id='datetimepicker_date_due'>
|
||||
<input type='text' class="form-control" id='date_due' name='date_due' value="" required>
|
||||
<span class="input-group-addon">
|
||||
<span class="glyphicon glyphicon-calendar"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class='help-block with-errors'></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Nombre de STB</label>
|
||||
<div class="col-sm-1">
|
||||
<input type="number" class="form-control" id="nb_stb" name="nb_stb" value='1' min=1 max=6>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id='chk_credit' name='chk_credit' checked> Appliquer un credit pour le premier STB
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<span class="help-block with-errors"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title" class="col-sm-2 control-label">Ajouter a la carte</label>
|
||||
<div class="col-sm-6">
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="opt_carte" value="1"> Oui
|
||||
</label>
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="opt_carte" value="0" checked> Non
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="form-group">
|
||||
<label for="title" class="col-sm-2 control-label">Forfait de Base</label>
|
||||
<div class="col-sm-3">
|
||||
<select class="form-control" id="forfbase" name="forfbase">
|
||||
<?php echo $option_base; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title" class="col-sm-2 control-label">Forfait Thematique</label>
|
||||
<div class="col-sm-3">
|
||||
<select class="form-control" id="sel_fthem" name="sel_fthem">
|
||||
<?php echo $option_forfait; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<button class="btn btn-default" type="button" onclick='add_forfait($("#sel_fthem").val(),"ul_forfait","theme")'>Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-5">
|
||||
<ul id='ul_forfait'></ul>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Msg Ticket</label>
|
||||
<div class="col-sm-4">
|
||||
<textarea class='form-control' id='memo' name='memo' rows=15><?php echo $memo;?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<button id='btn_submit' type='submit' class='btn btn-success'>Enregistrer</button>
|
||||
|
||||
</form>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
$('#f_save').validator().on('submit', function (e) {
|
||||
if (!e.isDefaultPrevented()) {
|
||||
|
||||
var validated = 1;
|
||||
$("#btn_submit").prop('disabled',true);
|
||||
|
||||
if(validated == 0){
|
||||
e.preventDefault();
|
||||
$("#btn_submit").prop('disabled',false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
$(function () {
|
||||
$('#datetimepicker_date_due').datetimepicker({
|
||||
showTodayButton: true,
|
||||
showClear: true,
|
||||
showClose: true,
|
||||
useCurrent: false,
|
||||
format: "DD-MM-YYYY",
|
||||
locale: 'fr'
|
||||
});
|
||||
});
|
||||
|
||||
function add_forfait(value, parent, type){
|
||||
|
||||
tmp = value.split('_');
|
||||
id = tmp[0];
|
||||
//name = revert_quote(tmp[1]);
|
||||
name = tmp[1].replace("'","'");
|
||||
|
||||
if(!document.getElementById(parent+"_li_forfait_"+id)){
|
||||
|
||||
var node = document.createElement("LI");
|
||||
var textnode = document.createTextNode(name);
|
||||
var inputnode = document.createElement("INPUT");
|
||||
inputnode.setAttribute("type","hidden");
|
||||
inputnode.setAttribute("id",parent+"_input_forfait_"+id);
|
||||
inputnode.setAttribute("name",type+"_forfait[]");
|
||||
inputnode.setAttribute("value",id);
|
||||
|
||||
node.setAttribute("id",parent+"_li_forfait_"+id);
|
||||
node.setAttribute("title","Cliquez pour effacer");
|
||||
node.setAttribute("style","cursor:pointer;");
|
||||
node.setAttribute("onclick","remove_forfait('"+parent+"_li_forfait_"+id+"')");
|
||||
node.appendChild(textnode);
|
||||
node.appendChild(inputnode);
|
||||
document.getElementById(parent).appendChild(node);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function remove_forfait(id){
|
||||
|
||||
item = document.getElementById(id);
|
||||
item.parentNode.removeChild(item);
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
130
docs/legacy-wizard/tele_wizard_subs.php
Normal file
130
docs/legacy-wizard/tele_wizard_subs.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
include_once "authentication.php";
|
||||
|
||||
|
||||
if(isset($_POST['first_name'])){
|
||||
|
||||
$sql_epg = new mysqli('96.125.196.10', 'facturation', 'r2SnlWTyKjMzw5oQ', 'cargotv');
|
||||
$sql_epg->set_charset("latin1_swedish_ci");
|
||||
|
||||
$address_id = $_POST['ws_delivery_id'];
|
||||
$first_name = str_replace("'","",$sql_epg->real_escape_string(utf8_decode($_POST['first_name'])));
|
||||
$last_name = str_replace("'","",$sql_epg->real_escape_string(utf8_decode($_POST['last_name'])));
|
||||
$city = str_replace("'","", $sql_epg->real_escape_string(utf8_decode($_POST['city'])));
|
||||
$code_postal = $_POST['zip'];
|
||||
$address = str_replace("'","",$sql_epg->real_escape_string(utf8_decode($_POST['address'])));
|
||||
//$civicno = $_POST['civicno'];
|
||||
$civicno = $_POST['ws_delivery_id'];
|
||||
|
||||
$q_sub = "INSERT INTO `subs` (`groupid`,`fname`,`lname`,`city`,`zip`,`address`,`resellerid`,`status`,`defaultSaverTimeout`,`civicno`,`defPvrMaxHours`,`appProfileId`)
|
||||
VALUES (2,'$first_name','$last_name','$city','$code_postal','$address',1,'ACTIVE',2147483647,'$civicno',100,1)";
|
||||
|
||||
if($sql_epg->query($q_sub)){
|
||||
|
||||
//echo "$q_sub <br><br>";
|
||||
//print_r($sql_epg->error_list);
|
||||
|
||||
$subid = 0;
|
||||
$subid = $sql_epg->insert_id;
|
||||
|
||||
$q_update = "UPDATE `delivery` SET `epg_subid` = $subid WHERE `id` = {$_POST['ws_delivery_id']}";
|
||||
if($sql->query($q_update)){
|
||||
|
||||
//echo "$q_update <br><br>";
|
||||
|
||||
$sql_epg->close();
|
||||
|
||||
|
||||
die("<form id='form_package' method='post' action='accueil.php?menu=tele_wizard_package'>
|
||||
<input type='hidden' name='tw_delivery_id' value='$address_id'>
|
||||
<input type='hidden' name='tw_sub_id' value='$subid'>
|
||||
</form>
|
||||
<script>
|
||||
$('#form_package').submit();
|
||||
</script>");
|
||||
|
||||
}
|
||||
else{
|
||||
echo "<div class='alert alert-danger'>Erreur Link epg->F. subid: $subid - delivery: {$_POST['ws_delivery_id']}</div>";
|
||||
}
|
||||
}
|
||||
else{
|
||||
echo "<div class='alert alert-danger'>Le compte n'a pu etre cree dans epg: {$sql_epg->error_list}</div>";
|
||||
}
|
||||
|
||||
$sql_epg->close();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
//debug
|
||||
//$_POST['ws_delivery_id'] = 2;
|
||||
|
||||
|
||||
$delivery_id = $_POST['ws_delivery_id'];
|
||||
|
||||
$res_delivery = $sql->query("SELECT * FROM `delivery` WHERE `id` = $delivery_id");
|
||||
$row_delivery = $res_delivery->fetch_assoc();
|
||||
$name = explode(' ',$row_delivery['name']);
|
||||
$civicno = explode(' ',$row_delivery['address1']);
|
||||
$civicno = str_replace(',','',$civicno[0]);
|
||||
$civicno = str_replace(' ','',$civicno);
|
||||
|
||||
|
||||
?>
|
||||
|
||||
<h3>Tele Wiz - Ouverture compte tele (epg)</h3><br>
|
||||
|
||||
<form class="form-horizontal" id='f_wiz_subs' method='POST' data-toggle="validator" role="form">
|
||||
|
||||
<input type='hidden' name='ws_delivery_id' value='<?php echo $_POST['ws_delivery_id'];?>'>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Prenom</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class='form-control' name='first_name' id='first_name' value='<?php echo $name[0];?>'>
|
||||
</div>
|
||||
<span class='help-block with-errors'></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Nom</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class='form-control' name='last_name' id='last_name' value='<?php if(isset($name[1])) echo $name[1];?>'>
|
||||
</div>
|
||||
<span class='help-block with-errors'></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Civic</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class='form-control' name='civicno' id='civicno' value='<?php echo $civicno;?>'>
|
||||
</div>
|
||||
<span class='help-block with-errors'></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Adresse</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class='form-control' name='address' id='address' value='<?php echo $row_delivery['address1'];?>'>
|
||||
</div>
|
||||
<span class='help-block with-errors'>adresse complète incluant no. civic</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Ville</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class='form-control' name='city' id='city' value='<?php echo $row_delivery['city'];?>'>
|
||||
</div>
|
||||
<span class='help-block with-errors'></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">Code Postal</label>
|
||||
<div class="col-sm-4">
|
||||
<input type="text" class='form-control' name='zip' id='zip' value='<?php echo $row_delivery['zip'];?>'>
|
||||
</div>
|
||||
<span class='help-block with-errors'></span>
|
||||
</div>
|
||||
<br>
|
||||
<button id='btn_submit' type='submit' class='btn btn-success'>Enregistrer</button>
|
||||
|
||||
|
||||
</form>
|
||||
181
erpnext/flow_scheduler.py
Normal file
181
erpnext/flow_scheduler.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""
|
||||
flow_scheduler.py — Frappe scheduler tick for delayed Flow steps.
|
||||
|
||||
Purpose
|
||||
───────
|
||||
The Flow Runtime in targo-hub stores delayed steps as `Flow Step Pending`
|
||||
rows with a `trigger_at` timestamp. This script (run every minute by a
|
||||
Frappe cron hook) fetches rows whose `trigger_at <= now()` and nudges the
|
||||
hub to advance the owning Flow Run.
|
||||
|
||||
How it's hooked
|
||||
───────────────
|
||||
In ERPNext, add to hooks.py (or scheduler config):
|
||||
|
||||
scheduler_events = {
|
||||
"cron": {
|
||||
"* * * * *": [
|
||||
"path.to.flow_scheduler.tick",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
Or invoke manually from bench console for testing:
|
||||
|
||||
docker exec -u frappe erpnext-backend-1 bash -c \\
|
||||
'cd /home/frappe/frappe-bench/sites && \\
|
||||
/home/frappe/frappe-bench/env/bin/python -c \\
|
||||
"import frappe; frappe.init(site=\\"erp.gigafibre.ca\\"); frappe.connect(); \\
|
||||
from flow_scheduler import tick; tick()"'
|
||||
|
||||
Behaviour
|
||||
─────────
|
||||
- Atomic status flip: `pending` → `running` under a transaction so two
|
||||
ticks can't double-fire.
|
||||
- POSTs to `HUB_URL/flow/complete` with `{run, step_id}`. Hub fires the
|
||||
kind handler and advances the run.
|
||||
- On hub error: retry_count += 1, status stays `pending` until 5 retries
|
||||
exhausted (then marked `failed`).
|
||||
- On success: status = `completed`.
|
||||
|
||||
Idempotent: safe to call multiple times — only truly due rows are fired.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import frappe
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config — these default to the Docker compose network hostname.
|
||||
# Override via environment variables in ERPNext container if hub URL changes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HUB_URL = os.environ.get("HUB_URL", "http://targo-hub:3300")
|
||||
INTERNAL_TOKEN = os.environ.get("HUB_INTERNAL_TOKEN", "")
|
||||
MAX_RETRIES = 5
|
||||
BATCH_LIMIT = 50 # safety net: fire at most N pending steps per tick
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — Frappe cron calls this
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def tick():
|
||||
"""Process all due pending steps. Called every minute by Frappe cron."""
|
||||
due = _claim_due_rows()
|
||||
if not due:
|
||||
return
|
||||
frappe.logger().info(f"[flow_scheduler] claimed {len(due)} due rows")
|
||||
for row in due:
|
||||
_fire_row(row)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claim phase: atomically flip due rows from pending → running
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _claim_due_rows():
|
||||
"""
|
||||
Return all Flow Step Pending rows where:
|
||||
status = 'pending' AND trigger_at <= now()
|
||||
Flips them to 'running' in the same transaction to prevent double-firing.
|
||||
"""
|
||||
now = frappe.utils.now()
|
||||
rows = frappe.get_all(
|
||||
"Flow Step Pending",
|
||||
filters={
|
||||
"status": "pending",
|
||||
"trigger_at": ["<=", now],
|
||||
},
|
||||
fields=["name", "flow_run", "step_id", "retry_count"],
|
||||
limit=BATCH_LIMIT,
|
||||
order_by="trigger_at asc",
|
||||
)
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Optimistic claim: update in bulk. If it fails (concurrent tick), we
|
||||
# simply re-query; PostgreSQL serialisation will prevent double-runs.
|
||||
names = [r["name"] for r in rows]
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabFlow Step Pending`
|
||||
SET status='running', modified=%s, modified_by=%s
|
||||
WHERE name IN %s AND status='pending'""",
|
||||
(now, "Administrator", tuple(names)),
|
||||
)
|
||||
frappe.db.commit()
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fire phase: POST to Hub /flow/complete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fire_row(row):
|
||||
"""Send a completion event to the Hub. Update row status on response."""
|
||||
payload = {
|
||||
"run": row["flow_run"],
|
||||
"step_id": row["step_id"],
|
||||
"result": {"fired_by": "scheduler", "fired_at": frappe.utils.now()},
|
||||
}
|
||||
try:
|
||||
_post_hub("/flow/complete", payload)
|
||||
_mark_done(row["name"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
_handle_failure(row, str(e))
|
||||
|
||||
|
||||
def _post_hub(path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
HUB_URL + path,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if INTERNAL_TOKEN:
|
||||
req.add_header("Authorization", "Bearer " + INTERNAL_TOKEN)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
if resp.status >= 400:
|
||||
raise RuntimeError(f"Hub HTTP {resp.status}: {body[:200]}")
|
||||
return body
|
||||
|
||||
|
||||
def _mark_done(row_name):
|
||||
frappe.db.set_value(
|
||||
"Flow Step Pending",
|
||||
row_name,
|
||||
{
|
||||
"status": "completed",
|
||||
"executed_at": frappe.utils.now(),
|
||||
"last_error": "",
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def _handle_failure(row, err_msg):
|
||||
retries = int(row.get("retry_count") or 0) + 1
|
||||
final = retries >= MAX_RETRIES
|
||||
frappe.db.set_value(
|
||||
"Flow Step Pending",
|
||||
row["name"],
|
||||
{
|
||||
"status": "failed" if final else "pending",
|
||||
"retry_count": retries,
|
||||
"last_error": err_msg[:500],
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
frappe.db.commit()
|
||||
frappe.logger().error(
|
||||
f"[flow_scheduler] row={row['name']} retry={retries} err={err_msg[:200]}"
|
||||
)
|
||||
459
erpnext/seed_flow_templates.py
Normal file
459
erpnext/seed_flow_templates.py
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
"""
|
||||
seed_flow_templates.py — Seed initial Flow Templates (system-owned).
|
||||
|
||||
Migrates the 4 hardcoded project templates (fiber_install, phone_service,
|
||||
move_service, repair_service) from apps/ops/src/config/project-templates.js
|
||||
into Flow Template docs (is_system=1).
|
||||
|
||||
Also seeds:
|
||||
- residential_onboarding : runs on_contract_signed, ties install + reminders
|
||||
- quotation_follow_up : runs on_quotation_created, sends reminders
|
||||
|
||||
Run (inside backend container):
|
||||
docker exec -u frappe erpnext-backend-1 bash -c \\
|
||||
'cd /home/frappe/frappe-bench/sites && \\
|
||||
/home/frappe/frappe-bench/env/bin/python -c \\
|
||||
"import frappe; frappe.init(site=\\"erp.gigafibre.ca\\"); frappe.connect(); \\
|
||||
from seed_flow_templates import seed_all; seed_all()"'
|
||||
|
||||
Idempotent: skips templates that already exist by template_name.
|
||||
"""
|
||||
|
||||
import json
|
||||
import frappe
|
||||
|
||||
|
||||
# Flow definition schema:
|
||||
# {
|
||||
# "version": 1,
|
||||
# "trigger": { "event": "...", "condition": "" },
|
||||
# "variables": {...}, # flow-level defaults
|
||||
# "steps": [ <Step>, ... ]
|
||||
# }
|
||||
#
|
||||
# Step schema:
|
||||
# {
|
||||
# "id": "step_xxx", # stable ID
|
||||
# "kind": "dispatch_job"|"issue"|"notify"|"webhook"|"erp_update"|"wait"|"condition"|"subscription_activate",
|
||||
# "label": "...", # human-readable
|
||||
# "parent_id": null | "step_yyy",
|
||||
# "branch": null | "yes" | "no" | custom,
|
||||
# "depends_on": ["step_xxx"], # array of step IDs; empty = run at flow start
|
||||
# "trigger": { # when to execute this step
|
||||
# "type": "on_flow_start" | "on_prev_complete" | "after_delay" | "on_date" | "on_webhook" | "manual",
|
||||
# "delay_hours": 24, # for after_delay
|
||||
# "delay_days": 7, # for after_delay
|
||||
# "at": "2026-05-01T09:00", # for on_date
|
||||
# },
|
||||
# "payload": { ... } # kind-specific fields (see below)
|
||||
# }
|
||||
#
|
||||
# Payloads by kind:
|
||||
# dispatch_job: { subject, job_type, priority, duration_h, assigned_group,
|
||||
# on_open_webhook, on_close_webhook, merge_key }
|
||||
# issue: { subject, description, priority, raised_by, issue_type }
|
||||
# notify: { channel: "sms"|"email", to, template_id, subject, body }
|
||||
# webhook: { url, method: "POST", headers, body_template }
|
||||
# erp_update: { doctype, docname_ref, fields: {field: value} }
|
||||
# wait: {} (uses trigger.delay_hours/delay_days)
|
||||
# condition: { field, op: "==|!=|<|>|<=|>=|in|not_in", value }
|
||||
# Children on branch "yes" / "no"
|
||||
# subscription_activate: { subscription_ref }
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper builders (keep seed JSON readable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dispatch_step(sid, label, subject, job_type, priority, duration_h,
|
||||
group, depends_on=None, merge_key=None,
|
||||
open_wh="", close_wh=""):
|
||||
return {
|
||||
"id": sid,
|
||||
"kind": "dispatch_job",
|
||||
"label": label,
|
||||
"parent_id": None,
|
||||
"branch": None,
|
||||
"depends_on": depends_on or [],
|
||||
"trigger": {"type": "on_prev_complete" if depends_on else "on_flow_start"},
|
||||
"payload": {
|
||||
"subject": subject,
|
||||
"job_type": job_type,
|
||||
"priority": priority,
|
||||
"duration_h": duration_h,
|
||||
"assigned_group": group,
|
||||
"merge_key": merge_key or sid,
|
||||
"on_open_webhook": open_wh,
|
||||
"on_close_webhook": close_wh,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _notify_step(sid, label, channel, template_id, depends_on=None,
|
||||
trigger_type="on_prev_complete", delay_hours=None):
|
||||
trig = {"type": trigger_type}
|
||||
if delay_hours:
|
||||
trig["delay_hours"] = delay_hours
|
||||
return {
|
||||
"id": sid,
|
||||
"kind": "notify",
|
||||
"label": label,
|
||||
"parent_id": None,
|
||||
"branch": None,
|
||||
"depends_on": depends_on or [],
|
||||
"trigger": trig,
|
||||
"payload": {
|
||||
"channel": channel,
|
||||
"to": "{{customer.primary_phone}}" if channel == "sms" else "{{customer.email_id}}",
|
||||
"template_id": template_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _issue_step(sid, label, subject, depends_on=None):
|
||||
return {
|
||||
"id": sid,
|
||||
"kind": "issue",
|
||||
"label": label,
|
||||
"parent_id": None,
|
||||
"branch": None,
|
||||
"depends_on": depends_on or [],
|
||||
"trigger": {"type": "on_prev_complete" if depends_on else "on_flow_start"},
|
||||
"payload": {
|
||||
"subject": subject,
|
||||
"priority": "Medium",
|
||||
"issue_type": "Suivi",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _wait_step(sid, label, delay_hours=None, delay_days=None, depends_on=None):
|
||||
trig = {"type": "after_delay"}
|
||||
if delay_hours:
|
||||
trig["delay_hours"] = delay_hours
|
||||
if delay_days:
|
||||
trig["delay_days"] = delay_days
|
||||
return {
|
||||
"id": sid,
|
||||
"kind": "wait",
|
||||
"label": label,
|
||||
"parent_id": None,
|
||||
"branch": None,
|
||||
"depends_on": depends_on or [],
|
||||
"trigger": trig,
|
||||
"payload": {},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tpl_fiber_install():
|
||||
return {
|
||||
"template_name": "Installation fibre résidentielle",
|
||||
"category": "Internet",
|
||||
"applies_to": "Service Contract",
|
||||
"icon": "cable",
|
||||
"description": "Vérification pré-install, installation, activation, test de débit",
|
||||
"is_system": 1,
|
||||
"trigger_event": "manual",
|
||||
"flow_definition": {
|
||||
"version": 1,
|
||||
"trigger": {"event": "manual", "condition": ""},
|
||||
"variables": {},
|
||||
"steps": [
|
||||
_dispatch_step("s1", "Vérification pré-installation",
|
||||
"Vérification pré-installation (éligibilité & OLT)",
|
||||
"Autre", "medium", 0.5, "Admin", merge_key="fiber_pre_check"),
|
||||
_dispatch_step("s2", "Installation fibre",
|
||||
"Installation fibre chez le client",
|
||||
"Installation", "high", 3, "Tech Targo",
|
||||
depends_on=["s1"], merge_key="fiber_install_visit"),
|
||||
_dispatch_step("s3", "Activation & config ONT",
|
||||
"Activation du service & configuration ONT",
|
||||
"Installation", "high", 0.5, "Admin",
|
||||
depends_on=["s2"], merge_key="fiber_activation"),
|
||||
_dispatch_step("s4", "Test de débit",
|
||||
"Test de débit & validation client",
|
||||
"Dépannage", "medium", 0.5, "Tech Targo",
|
||||
depends_on=["s3"], merge_key="fiber_speed_test"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tpl_phone_service():
|
||||
return {
|
||||
"template_name": "Service téléphonique résidentiel",
|
||||
"category": "Téléphonie",
|
||||
"applies_to": "Service Contract",
|
||||
"icon": "phone_in_talk",
|
||||
"description": "Importation du numéro, installation fibre, portage du numéro",
|
||||
"is_system": 1,
|
||||
"trigger_event": "manual",
|
||||
"flow_definition": {
|
||||
"version": 1,
|
||||
"trigger": {"event": "manual", "condition": ""},
|
||||
"variables": {},
|
||||
"steps": [
|
||||
_dispatch_step("s1", "Importer numéro",
|
||||
"Importer le numéro de téléphone",
|
||||
"Autre", "medium", 0.5, "Admin", merge_key="port_phone_request"),
|
||||
_dispatch_step("s2", "Installation fibre",
|
||||
"Installation fibre chez le client",
|
||||
"Installation", "high", 3, "Tech Targo",
|
||||
depends_on=["s1"], merge_key="fiber_install_visit"),
|
||||
_dispatch_step("s3", "Portage numéro",
|
||||
"Portage du numéro vers Gigafibre",
|
||||
"Autre", "medium", 0.5, "Admin",
|
||||
depends_on=["s2"], merge_key="port_phone_execute"),
|
||||
_dispatch_step("s4", "Test téléphonie",
|
||||
"Validation et test du service téléphonique",
|
||||
"Dépannage", "medium", 0.5, "Tech Targo",
|
||||
depends_on=["s3"], merge_key="phone_service_test"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tpl_move_service():
|
||||
return {
|
||||
"template_name": "Déménagement de service",
|
||||
"category": "Déménagement",
|
||||
"applies_to": "Service Contract",
|
||||
"icon": "local_shipping",
|
||||
"description": "Retrait ancien site, installation nouveau site, transfert abonnement",
|
||||
"is_system": 1,
|
||||
"trigger_event": "manual",
|
||||
"flow_definition": {
|
||||
"version": 1,
|
||||
"trigger": {"event": "manual", "condition": ""},
|
||||
"variables": {},
|
||||
"steps": [
|
||||
_dispatch_step("s1", "Préparation",
|
||||
"Préparation déménagement (vérifier éligibilité nouveau site)",
|
||||
"Autre", "medium", 0.5, "Admin", merge_key="move_prep"),
|
||||
_dispatch_step("s2", "Retrait ancien site",
|
||||
"Retrait équipement ancien site",
|
||||
"Retrait", "medium", 1, "Tech Targo",
|
||||
depends_on=["s1"], merge_key="move_removal"),
|
||||
_dispatch_step("s3", "Installation nouveau site",
|
||||
"Installation au nouveau site",
|
||||
"Installation", "high", 3, "Tech Targo",
|
||||
depends_on=["s2"], merge_key="fiber_install_visit"),
|
||||
_dispatch_step("s4", "Transfert abonnement",
|
||||
"Transfert abonnement & mise à jour adresse",
|
||||
"Autre", "medium", 0.5, "Admin",
|
||||
depends_on=["s3"], merge_key="move_transfer"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tpl_repair_service():
|
||||
return {
|
||||
"template_name": "Réparation service client",
|
||||
"category": "Dépannage",
|
||||
"applies_to": "Issue",
|
||||
"icon": "build",
|
||||
"description": "Diagnostic, intervention terrain, validation",
|
||||
"is_system": 1,
|
||||
"trigger_event": "on_issue_opened",
|
||||
"flow_definition": {
|
||||
"version": 1,
|
||||
"trigger": {"event": "on_issue_opened", "condition": ""},
|
||||
"variables": {},
|
||||
"steps": [
|
||||
_dispatch_step("s1", "Diagnostic à distance",
|
||||
"Diagnostic à distance",
|
||||
"Dépannage", "high", 0.5, "Admin", merge_key="repair_diag"),
|
||||
_dispatch_step("s2", "Intervention terrain",
|
||||
"Intervention terrain",
|
||||
"Réparation", "high", 2, "Tech Targo",
|
||||
depends_on=["s1"], merge_key="repair_visit"),
|
||||
_dispatch_step("s3", "Validation client",
|
||||
"Validation & suivi client",
|
||||
"Dépannage", "medium", 0.5, "Admin",
|
||||
depends_on=["s2"], merge_key="repair_validate"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tpl_residential_onboarding():
|
||||
"""
|
||||
Flow complet déclenché à la signature du Service Contract résidentiel.
|
||||
Ferme la boucle CTR-00004 : installation + rappels + satisfaction + renouvellement.
|
||||
"""
|
||||
return {
|
||||
"template_name": "Onboarding résidentiel (post-signature)",
|
||||
"category": "Onboarding",
|
||||
"applies_to": "Service Contract",
|
||||
"icon": "rocket_launch",
|
||||
"description": "Flow complet après signature: SMS bienvenue, installation, test, survey, renouvellement",
|
||||
"is_system": 1,
|
||||
"trigger_event": "on_contract_signed",
|
||||
"trigger_condition": "contract.contract_type == 'Résidentiel'",
|
||||
"flow_definition": {
|
||||
"version": 1,
|
||||
"trigger": {
|
||||
"event": "on_contract_signed",
|
||||
"condition": "contract.contract_type == 'Résidentiel'",
|
||||
},
|
||||
"variables": {},
|
||||
"steps": [
|
||||
# Immédiat — accueil
|
||||
_notify_step("welcome_sms", "SMS de bienvenue",
|
||||
channel="sms", template_id="welcome_residential",
|
||||
trigger_type="on_flow_start"),
|
||||
_issue_step("onboarding_ticket",
|
||||
"Ticket suivi onboarding",
|
||||
"Onboarding résidentiel — suivi client"),
|
||||
|
||||
# Installation (dépend de rien, démarre immédiatement)
|
||||
_dispatch_step("install", "Installation fibre",
|
||||
"Installation fibre chez le client",
|
||||
"Installation", "high", 3, "Tech Targo",
|
||||
merge_key="fiber_install_visit",
|
||||
open_wh="", close_wh=""),
|
||||
|
||||
# Activation abonnement après installation complétée
|
||||
{
|
||||
"id": "activate_sub",
|
||||
"kind": "subscription_activate",
|
||||
"label": "Activer l'abonnement",
|
||||
"parent_id": None,
|
||||
"branch": None,
|
||||
"depends_on": ["install"],
|
||||
"trigger": {"type": "on_prev_complete"},
|
||||
"payload": {"subscription_ref": "{{contract.subscription}}"},
|
||||
},
|
||||
|
||||
# SMS confirmation service actif
|
||||
_notify_step("active_sms", "SMS service actif",
|
||||
channel="sms", template_id="service_activated",
|
||||
depends_on=["activate_sub"]),
|
||||
|
||||
# Survey satisfaction 24h après install
|
||||
_wait_step("wait_24h", "Attendre 24h après install",
|
||||
delay_hours=24, depends_on=["install"]),
|
||||
_notify_step("survey_sms", "SMS sondage satisfaction",
|
||||
channel="sms", template_id="satisfaction_survey",
|
||||
depends_on=["wait_24h"]),
|
||||
|
||||
# Rappel renouvellement à 11 mois (anticipation du renouvel à 12 mois)
|
||||
_wait_step("wait_11m", "Attendre 11 mois",
|
||||
delay_days=330, depends_on=["activate_sub"]),
|
||||
_issue_step("renewal_ticket",
|
||||
"Ticket renouvellement",
|
||||
"Rappel: contrat arrive à échéance dans 1 mois",
|
||||
depends_on=["wait_11m"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tpl_quotation_follow_up():
|
||||
"""Relance douce 48h après envoi de quotation non acceptée."""
|
||||
return {
|
||||
"template_name": "Relance quotation non signée",
|
||||
"category": "Custom",
|
||||
"applies_to": "Quotation",
|
||||
"icon": "mail",
|
||||
"description": "SMS de rappel 48h après envoi d'un devis résidentiel",
|
||||
"is_system": 1,
|
||||
"trigger_event": "on_quotation_created",
|
||||
"trigger_condition": "quotation.status == 'Submitted'",
|
||||
"flow_definition": {
|
||||
"version": 1,
|
||||
"trigger": {
|
||||
"event": "on_quotation_created",
|
||||
"condition": "quotation.status == 'Submitted'",
|
||||
},
|
||||
"variables": {},
|
||||
"steps": [
|
||||
_wait_step("wait_48h", "Attendre 48h", delay_hours=48),
|
||||
_notify_step("reminder_sms",
|
||||
"SMS rappel devis", channel="sms",
|
||||
template_id="quotation_reminder",
|
||||
depends_on=["wait_48h"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEEDS = [
|
||||
_tpl_fiber_install,
|
||||
_tpl_phone_service,
|
||||
_tpl_move_service,
|
||||
_tpl_repair_service,
|
||||
_tpl_residential_onboarding,
|
||||
_tpl_quotation_follow_up,
|
||||
]
|
||||
|
||||
|
||||
def _count_steps(flow_def):
|
||||
return len(flow_def.get("steps", []))
|
||||
|
||||
|
||||
def _upsert(tpl):
|
||||
name = tpl["template_name"]
|
||||
exists = frappe.db.exists("Flow Template", {"template_name": name})
|
||||
flow_def_json = json.dumps(tpl["flow_definition"], ensure_ascii=False, indent=2)
|
||||
step_count = _count_steps(tpl["flow_definition"])
|
||||
|
||||
if exists:
|
||||
doc = frappe.get_doc("Flow Template", exists)
|
||||
# Only update is_system=1 seeds automatically
|
||||
if not doc.is_system:
|
||||
print(f" SKIP {name!r} — exists as user-edited (is_system=0)")
|
||||
return
|
||||
doc.update({
|
||||
"category": tpl["category"],
|
||||
"applies_to": tpl["applies_to"],
|
||||
"icon": tpl.get("icon", "account_tree"),
|
||||
"description": tpl.get("description", ""),
|
||||
"trigger_event": tpl.get("trigger_event", "manual"),
|
||||
"trigger_condition": tpl.get("trigger_condition", ""),
|
||||
"flow_definition": flow_def_json,
|
||||
"step_count": step_count,
|
||||
"version": (doc.version or 0) + 1,
|
||||
})
|
||||
doc.save(ignore_permissions=True)
|
||||
print(f" UPDATED {name!r} -> v{doc.version}, {step_count} steps")
|
||||
return
|
||||
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Flow Template",
|
||||
"template_name": name,
|
||||
"category": tpl["category"],
|
||||
"applies_to": tpl["applies_to"],
|
||||
"icon": tpl.get("icon", "account_tree"),
|
||||
"is_active": 1,
|
||||
"is_system": tpl.get("is_system", 0),
|
||||
"version": 1,
|
||||
"description": tpl.get("description", ""),
|
||||
"trigger_event": tpl.get("trigger_event", "manual"),
|
||||
"trigger_condition": tpl.get("trigger_condition", ""),
|
||||
"flow_definition": flow_def_json,
|
||||
"step_count": step_count,
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(f" CREATED {name!r} -> {doc.name}, {step_count} steps")
|
||||
|
||||
|
||||
def seed_all():
|
||||
for factory in SEEDS:
|
||||
tpl = factory()
|
||||
_upsert(tpl)
|
||||
frappe.db.commit()
|
||||
print(f"\n[OK] Seeded {len(SEEDS)} Flow Templates.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_all()
|
||||
235
erpnext/setup_flow_templates.py
Normal file
235
erpnext/setup_flow_templates.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""
|
||||
setup_flow_templates.py — Create doctypes for the Flow Editor (project/service
|
||||
orchestration engine).
|
||||
|
||||
Three doctypes:
|
||||
- Flow Template : editable template library (admins manage in SettingsPage)
|
||||
- Flow Run : per-execution state attached to a context doc
|
||||
- Flow Step Pending : scheduled steps waiting for a time/condition trigger
|
||||
|
||||
Run inside the bench container:
|
||||
docker compose exec backend bench --site <site> execute setup_flow_templates.create_all
|
||||
|
||||
Or via docker exec python path (avoids IPython cell-split gotcha):
|
||||
docker exec -u frappe erpnext-backend-1 bash -c \\
|
||||
'cd /home/frappe/frappe-bench/sites && \\
|
||||
/home/frappe/frappe-bench/env/bin/python -c \\
|
||||
"import frappe; frappe.init(site=\\"erp.gigafibre.ca\\"); frappe.connect(); \\
|
||||
from setup_flow_templates import create_all; create_all()"'
|
||||
"""
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
def create_all():
|
||||
_create_flow_template()
|
||||
_create_flow_run()
|
||||
_create_flow_step_pending()
|
||||
frappe.db.commit()
|
||||
print("[OK] Flow Editor doctypes created.")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Flow Template — editable template library
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _create_flow_template():
|
||||
if frappe.db.exists("DocType", "Flow Template"):
|
||||
print(" Flow Template already exists — skipping.")
|
||||
return
|
||||
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "DocType",
|
||||
"name": "Flow Template",
|
||||
"module": "Dispatch",
|
||||
"custom": 1,
|
||||
"autoname": "FT-.#####",
|
||||
"track_changes": 1,
|
||||
"fields": [
|
||||
# -- Identification ---------------------------------------------
|
||||
{"fieldname": "template_name", "fieldtype": "Data", "label": "Nom du template",
|
||||
"reqd": 1, "in_list_view": 1, "unique": 1,
|
||||
"description": "Nom court et descriptif (ex: Installation fibre résidentielle)"},
|
||||
{"fieldname": "category", "fieldtype": "Select", "label": "Catégorie",
|
||||
"options": "Internet\nTéléphonie\nTélévision\nDéménagement\nRéparation\nOnboarding\nRenouvellement\nChurn\nDépannage\nCustom",
|
||||
"default": "Custom", "in_list_view": 1, "reqd": 1},
|
||||
{"fieldname": "applies_to", "fieldtype": "Select", "label": "S'applique à",
|
||||
"options": "Quotation\nService Contract\nIssue\nCustomer\nSubscription",
|
||||
"default": "Service Contract", "in_list_view": 1, "reqd": 1,
|
||||
"description": "Type de document qui déclenche le flow"},
|
||||
{"fieldname": "col_id1", "fieldtype": "Column Break"},
|
||||
{"fieldname": "icon", "fieldtype": "Data", "label": "Icône (Material)",
|
||||
"default": "account_tree",
|
||||
"description": "Nom d'icône Quasar/Material (ex: cable, phone_in_talk)"},
|
||||
{"fieldname": "is_active", "fieldtype": "Check", "label": "Actif",
|
||||
"default": "1", "in_list_view": 1},
|
||||
{"fieldname": "is_system", "fieldtype": "Check", "label": "Template système",
|
||||
"default": "0", "read_only": 1,
|
||||
"description": "Templates seedés — ne pas supprimer"},
|
||||
{"fieldname": "version", "fieldtype": "Int", "label": "Version",
|
||||
"default": "1", "read_only": 1,
|
||||
"description": "Incrémenté à chaque sauvegarde"},
|
||||
|
||||
# -- Description ------------------------------------------------
|
||||
{"fieldname": "sec_desc", "fieldtype": "Section Break", "label": "Description"},
|
||||
{"fieldname": "description", "fieldtype": "Small Text", "label": "Description",
|
||||
"description": "Phrase courte expliquant ce que fait le flow"},
|
||||
|
||||
# -- Déclencheur (trigger) --------------------------------------
|
||||
{"fieldname": "sec_trigger", "fieldtype": "Section Break",
|
||||
"label": "Déclencheur automatique",
|
||||
"description": "Quand ce flow doit-il se déclencher automatiquement?"},
|
||||
{"fieldname": "trigger_event", "fieldtype": "Select", "label": "Évènement",
|
||||
"options": "\nmanual\non_quotation_created\non_quotation_accepted\non_contract_signed\non_payment_received\non_subscription_active\non_issue_opened\non_customer_created\non_dispatch_completed",
|
||||
"default": "manual",
|
||||
"description": "manual = démarré à la main depuis ProjectWizard ou un bouton"},
|
||||
{"fieldname": "col_trigger", "fieldtype": "Column Break"},
|
||||
{"fieldname": "trigger_condition", "fieldtype": "Small Text",
|
||||
"label": "Condition (expression)",
|
||||
"description": "Expression JS/Python (ex: contract.contract_type == 'Résidentiel'). Vide = toujours."},
|
||||
|
||||
# -- Définition du flow (JSON) ---------------------------------
|
||||
{"fieldname": "sec_def", "fieldtype": "Section Break", "label": "Définition du flow"},
|
||||
{"fieldname": "flow_definition", "fieldtype": "Long Text",
|
||||
"label": "Flow definition (JSON)", "reqd": 1,
|
||||
"description": "Arbre complet des étapes. Éditer via SettingsPage > Flows."},
|
||||
{"fieldname": "step_count", "fieldtype": "Int", "label": "Nombre d'étapes",
|
||||
"read_only": 1, "in_list_view": 1,
|
||||
"description": "Calculé à la sauvegarde"},
|
||||
|
||||
# -- Métadonnées ------------------------------------------------
|
||||
{"fieldname": "sec_meta", "fieldtype": "Section Break",
|
||||
"label": "Métadonnées", "collapsible": 1},
|
||||
{"fieldname": "tags", "fieldtype": "Data", "label": "Tags (CSV)",
|
||||
"description": "Étiquettes libres (ex: résidentiel,fibre,nouveau-client)"},
|
||||
{"fieldname": "notes", "fieldtype": "Small Text", "label": "Notes internes"},
|
||||
],
|
||||
"permissions": [
|
||||
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
|
||||
{"role": "Dispatch User", "read": 1, "write": 1, "create": 1},
|
||||
{"role": "Sales User", "read": 1},
|
||||
],
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(" [+] Flow Template doctype created.")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Flow Run — one execution instance per context doc
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _create_flow_run():
|
||||
if frappe.db.exists("DocType", "Flow Run"):
|
||||
print(" Flow Run already exists — skipping.")
|
||||
return
|
||||
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "DocType",
|
||||
"name": "Flow Run",
|
||||
"module": "Dispatch",
|
||||
"custom": 1,
|
||||
"autoname": "FR-.######",
|
||||
"track_changes": 1,
|
||||
"fields": [
|
||||
# -- Identification ---------------------------------------------
|
||||
{"fieldname": "flow_template", "fieldtype": "Link", "label": "Flow Template",
|
||||
"options": "Flow Template", "reqd": 1, "in_list_view": 1},
|
||||
{"fieldname": "template_version", "fieldtype": "Int",
|
||||
"label": "Version du template au démarrage", "read_only": 1,
|
||||
"description": "Figée au démarrage — une modif ultérieure du template n'affecte pas ce run"},
|
||||
{"fieldname": "col_run1", "fieldtype": "Column Break"},
|
||||
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
|
||||
"options": "pending\nrunning\nwaiting\ncompleted\nfailed\ncancelled",
|
||||
"default": "pending", "reqd": 1, "in_list_view": 1},
|
||||
{"fieldname": "trigger_event", "fieldtype": "Data", "label": "Déclencheur",
|
||||
"description": "Évènement qui a démarré ce run"},
|
||||
|
||||
# -- Contexte (doc qui a déclenché) ----------------------------
|
||||
{"fieldname": "sec_ctx", "fieldtype": "Section Break", "label": "Contexte"},
|
||||
{"fieldname": "context_doctype", "fieldtype": "Link",
|
||||
"label": "DocType de contexte", "options": "DocType",
|
||||
"in_list_view": 1},
|
||||
{"fieldname": "context_docname", "fieldtype": "Dynamic Link",
|
||||
"label": "Document", "options": "context_doctype", "in_list_view": 1},
|
||||
{"fieldname": "col_ctx", "fieldtype": "Column Break"},
|
||||
{"fieldname": "customer", "fieldtype": "Link", "label": "Client",
|
||||
"options": "Customer", "in_list_view": 1},
|
||||
{"fieldname": "variables", "fieldtype": "Long Text",
|
||||
"label": "Variables (JSON)",
|
||||
"description": "Variables accumulées pendant l'exécution (pour les conditions/templates)"},
|
||||
|
||||
# -- État d'exécution ------------------------------------------
|
||||
{"fieldname": "sec_state", "fieldtype": "Section Break", "label": "Exécution"},
|
||||
{"fieldname": "step_state", "fieldtype": "Long Text",
|
||||
"label": "État des étapes (JSON)",
|
||||
"description": "{stepId: {status, started_at, completed_at, result, error}}"},
|
||||
{"fieldname": "current_step_ids", "fieldtype": "Small Text",
|
||||
"label": "Étapes en cours (CSV)",
|
||||
"description": "IDs des étapes actuellement running/waiting"},
|
||||
{"fieldname": "col_state", "fieldtype": "Column Break"},
|
||||
{"fieldname": "started_at", "fieldtype": "Datetime",
|
||||
"label": "Démarré le", "read_only": 1},
|
||||
{"fieldname": "completed_at", "fieldtype": "Datetime",
|
||||
"label": "Terminé le", "read_only": 1},
|
||||
{"fieldname": "last_error", "fieldtype": "Small Text",
|
||||
"label": "Dernière erreur", "read_only": 1},
|
||||
],
|
||||
"permissions": [
|
||||
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
|
||||
{"role": "Dispatch User", "read": 1, "write": 1},
|
||||
],
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(" [+] Flow Run doctype created.")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Flow Step Pending — scheduled/waiting steps picked up by cron
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _create_flow_step_pending():
|
||||
if frappe.db.exists("DocType", "Flow Step Pending"):
|
||||
print(" Flow Step Pending already exists — skipping.")
|
||||
return
|
||||
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "DocType",
|
||||
"name": "Flow Step Pending",
|
||||
"module": "Dispatch",
|
||||
"custom": 1,
|
||||
"autoname": "FSP-.######",
|
||||
"track_changes": 1,
|
||||
"fields": [
|
||||
{"fieldname": "flow_run", "fieldtype": "Link", "label": "Flow Run",
|
||||
"options": "Flow Run", "reqd": 1, "in_list_view": 1},
|
||||
{"fieldname": "step_id", "fieldtype": "Data", "label": "Step ID",
|
||||
"reqd": 1, "in_list_view": 1,
|
||||
"description": "ID de l'étape dans le flow_definition du template"},
|
||||
{"fieldname": "col_sp1", "fieldtype": "Column Break"},
|
||||
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
|
||||
"options": "waiting\nexecuting\ndone\nfailed\ncancelled",
|
||||
"default": "waiting", "reqd": 1, "in_list_view": 1},
|
||||
{"fieldname": "trigger_at", "fieldtype": "Datetime",
|
||||
"label": "Déclencher à", "reqd": 1, "in_list_view": 1,
|
||||
"description": "Le scheduler exécute l'étape quand NOW() >= trigger_at"},
|
||||
|
||||
{"fieldname": "sec_ctx", "fieldtype": "Section Break", "label": "Contexte"},
|
||||
{"fieldname": "context_snapshot", "fieldtype": "Long Text",
|
||||
"label": "Snapshot contexte (JSON)",
|
||||
"description": "Variables du flow_run figées au moment où l'étape a été mise en attente"},
|
||||
|
||||
{"fieldname": "sec_exec", "fieldtype": "Section Break", "label": "Exécution"},
|
||||
{"fieldname": "executed_at", "fieldtype": "Datetime",
|
||||
"label": "Exécuté le", "read_only": 1},
|
||||
{"fieldname": "last_error", "fieldtype": "Small Text",
|
||||
"label": "Erreur", "read_only": 1},
|
||||
{"fieldname": "retry_count", "fieldtype": "Int",
|
||||
"label": "Nb tentatives", "default": "0", "read_only": 1},
|
||||
],
|
||||
"permissions": [
|
||||
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
|
||||
{"role": "Dispatch User", "read": 1, "write": 1},
|
||||
],
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(" [+] Flow Step Pending doctype created.")
|
||||
|
|
@ -27,6 +27,8 @@ def create_all():
|
|||
_extend_dispatch_job()
|
||||
_create_contract_benefit()
|
||||
_create_service_contract()
|
||||
_add_quotation_custom_fields()
|
||||
_add_billing_frequency_fields()
|
||||
frappe.db.commit()
|
||||
print("✓ FSM doctypes created successfully.")
|
||||
|
||||
|
|
@ -664,3 +666,139 @@ def _create_service_contract():
|
|||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(" ✓ Service Contract doctype created.")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Custom fields on Quotation — DocuSeal + wizard integration
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_quotation_custom_fields():
|
||||
# Only create fields that don't exist — leave existing ones alone so we
|
||||
# never clobber a Long Text with Text, or overwrite migration-era labels.
|
||||
# The wizard_* fields and accepted_by_client already exist on the live
|
||||
# ERPNext site (created during legacy import); only the DocuSeal trio is
|
||||
# missing and needed for the "Soumission TARGO" print format.
|
||||
fields = [
|
||||
{
|
||||
"fieldname": "custom_docuseal_signing_url",
|
||||
"label": "DocuSeal Signing URL",
|
||||
"fieldtype": "Data",
|
||||
"read_only": 1,
|
||||
"insert_after": "terms",
|
||||
"description": "Populated by targo-hub after DocuSeal submission — used by Soumission TARGO QR code",
|
||||
},
|
||||
{
|
||||
"fieldname": "custom_docuseal_envelope_id",
|
||||
"label": "DocuSeal Submission ID",
|
||||
"fieldtype": "Data",
|
||||
"read_only": 1,
|
||||
"insert_after": "custom_docuseal_signing_url",
|
||||
},
|
||||
{
|
||||
"fieldname": "custom_quote_type",
|
||||
"label": "Type de contrat",
|
||||
"fieldtype": "Select",
|
||||
"options": "\nRésidentiel\nCommercial",
|
||||
"insert_after": "custom_docuseal_envelope_id",
|
||||
},
|
||||
# Fallback creation for fresh environments — skipped if already present.
|
||||
{
|
||||
"fieldname": "wizard_steps",
|
||||
"label": "Étapes du projet (JSON)",
|
||||
"fieldtype": "Long Text",
|
||||
"read_only": 1,
|
||||
"insert_after": "custom_quote_type",
|
||||
},
|
||||
{
|
||||
"fieldname": "wizard_context",
|
||||
"label": "Contexte du projet (JSON)",
|
||||
"fieldtype": "Long Text",
|
||||
"read_only": 1,
|
||||
"insert_after": "wizard_steps",
|
||||
},
|
||||
{
|
||||
"fieldname": "accepted_by_client",
|
||||
"label": "Accepté par le client",
|
||||
"fieldtype": "Check",
|
||||
"read_only": 1,
|
||||
"insert_after": "wizard_context",
|
||||
},
|
||||
]
|
||||
|
||||
for f in fields:
|
||||
fname = f["fieldname"]
|
||||
if frappe.db.exists("Custom Field", {"dt": "Quotation", "fieldname": fname}):
|
||||
print(f" Custom Field Quotation.{fname} already exists — skipping")
|
||||
continue
|
||||
frappe.get_doc({
|
||||
"doctype": "Custom Field",
|
||||
"dt": "Quotation",
|
||||
**f,
|
||||
}).insert(ignore_permissions=True)
|
||||
print(f" Created Custom Field Quotation.{fname}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Billing frequency — marks items as One-time / Monthly / Annual so the
|
||||
# Soumission TARGO print format can split totals (recurring vs. initial fees).
|
||||
# Lives on Item (source of truth) and Quotation Item (inherited via fetch_from,
|
||||
# but overridable per line). Also propagated to Sales Invoice Item so the same
|
||||
# information flows through the invoice pipeline.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_billing_frequency_fields():
|
||||
BILLING_OPTIONS = "One-time\nMonthly\nAnnual"
|
||||
|
||||
targets = [
|
||||
{
|
||||
"dt": "Item",
|
||||
"insert_after": "item_group",
|
||||
"fetch_from": None,
|
||||
"read_only": 0,
|
||||
"default": "One-time",
|
||||
},
|
||||
{
|
||||
"dt": "Quotation Item",
|
||||
"insert_after": "item_name",
|
||||
"fetch_from": "item_code.custom_billing_frequency",
|
||||
"read_only": 0,
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"dt": "Sales Invoice Item",
|
||||
"insert_after": "item_name",
|
||||
"fetch_from": "item_code.custom_billing_frequency",
|
||||
"read_only": 0,
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"dt": "Sales Order Item",
|
||||
"insert_after": "item_name",
|
||||
"fetch_from": "item_code.custom_billing_frequency",
|
||||
"read_only": 0,
|
||||
"default": None,
|
||||
},
|
||||
]
|
||||
|
||||
for t in targets:
|
||||
if frappe.db.exists("Custom Field", {"dt": t["dt"], "fieldname": "custom_billing_frequency"}):
|
||||
print(f" Custom Field {t['dt']}.custom_billing_frequency already exists — skipping")
|
||||
continue
|
||||
payload = {
|
||||
"doctype": "Custom Field",
|
||||
"dt": t["dt"],
|
||||
"fieldname": "custom_billing_frequency",
|
||||
"label": "Billing Frequency",
|
||||
"fieldtype": "Select",
|
||||
"options": BILLING_OPTIONS,
|
||||
"insert_after": t["insert_after"],
|
||||
"in_list_view": 0,
|
||||
"translatable": 0,
|
||||
}
|
||||
if t.get("fetch_from"):
|
||||
payload["fetch_from"] = t["fetch_from"]
|
||||
payload["fetch_if_empty"] = 1
|
||||
if t.get("default"):
|
||||
payload["default"] = t["default"]
|
||||
frappe.get_doc(payload).insert(ignore_permissions=True)
|
||||
print(f" Created Custom Field {t['dt']}.custom_billing_frequency")
|
||||
|
|
|
|||
|
|
@ -139,25 +139,36 @@ with legacy.cursor() as cur:
|
|||
print(" Loaded {} items for {} invoices".format(total_items, len(all_items)))
|
||||
|
||||
# 1c: Load invoice taxes
|
||||
# ⚠️ Keep BOTH the sum of TPS/TVQ amounts AND the maximum rate seen per tax-type
|
||||
# for each invoice. Historical invoices (< 2013) have TVQ @ 8.925% or 9.5%,
|
||||
# not 9.975%. We preserve the actual rate so descriptions don't lie.
|
||||
# Using `+=` (not `=`) because legacy occasionally splits a single tax into
|
||||
# two rows (e.g. adjustment + tax) and we want the grand total.
|
||||
print("\n--- 1c: Invoice taxes ---")
|
||||
with legacy.cursor() as cur:
|
||||
all_taxes = {}
|
||||
for idx in range(0, len(invoice_ids), chunk):
|
||||
batch = invoice_ids[idx:idx+chunk]
|
||||
cur.execute("""
|
||||
SELECT invoice_id, tax_name, amount
|
||||
SELECT invoice_id, tax_name, tax_rate, amount
|
||||
FROM invoice_tax
|
||||
WHERE invoice_id IN ({})
|
||||
""".format(",".join(batch)))
|
||||
for t in cur.fetchall():
|
||||
iid = t['invoice_id']
|
||||
if iid not in all_taxes:
|
||||
all_taxes[iid] = {'tps': 0, 'tvq': 0}
|
||||
all_taxes[iid] = {'tps': 0.0, 'tvq': 0.0, 'tps_rate': 5.0, 'tvq_rate': 9.975}
|
||||
name = (t['tax_name'] or '').upper()
|
||||
if 'TPS' in name:
|
||||
all_taxes[iid]['tps'] = float(t['amount'] or 0)
|
||||
elif 'TVQ' in name:
|
||||
all_taxes[iid]['tvq'] = float(t['amount'] or 0)
|
||||
amt = float(t['amount'] or 0)
|
||||
rate = float(t['tax_rate'] or 0) * 100 # legacy stores 0.05 → we want 5.0
|
||||
if 'TPS' in name or 'GST' in name:
|
||||
all_taxes[iid]['tps'] += amt
|
||||
if rate > 0:
|
||||
all_taxes[iid]['tps_rate'] = rate
|
||||
elif 'TVQ' in name or 'QST' in name or 'PST' in name:
|
||||
all_taxes[iid]['tvq'] += amt
|
||||
if rate > 0:
|
||||
all_taxes[iid]['tvq_rate'] = rate
|
||||
print(" Loaded {} tax records".format(len(all_taxes)))
|
||||
|
||||
# 1d: Load payments
|
||||
|
|
@ -329,24 +340,37 @@ for idx in range(0, len(invoices), CHUNK):
|
|||
is_return = 1 if total_amt < 0 else 0
|
||||
posting_date = str(inv['date_created'])[:10]
|
||||
|
||||
# Tax split
|
||||
# Tax split — authoritative from legacy, with safe fallback.
|
||||
tax_info = all_taxes.get(inv['id'])
|
||||
if tax_info:
|
||||
tps = float(tax_info['tps'])
|
||||
tvq = float(tax_info['tvq'])
|
||||
tps = round(float(tax_info['tps']), 2)
|
||||
tvq = round(float(tax_info['tvq']), 2)
|
||||
tps_rate = float(tax_info['tps_rate'])
|
||||
tvq_rate = float(tax_info['tvq_rate'])
|
||||
else:
|
||||
# Estimate: total_amt includes 14.975% tax
|
||||
# No legacy tax row → estimate from total_amt assuming 14.975% combined
|
||||
# (5% TPS + 9.975% TVQ, the modern Quebec rate since 2013).
|
||||
tps_rate = 5.0
|
||||
tvq_rate = 9.975
|
||||
if total_amt != 0:
|
||||
net = round(total_amt / 1.14975, 2)
|
||||
tps = round(net * 0.05, 2)
|
||||
tvq = round(net * 0.09975, 2)
|
||||
else:
|
||||
net = 0
|
||||
tps = 0
|
||||
tvq = 0
|
||||
net = 0.0
|
||||
tps = 0.0
|
||||
tvq = 0.0
|
||||
|
||||
net_total = round(total_amt - tps - tvq, 2)
|
||||
|
||||
# ⚠️ Sanity check: net + tps + tvq MUST equal total_amt (grand_total).
|
||||
# If drift > 1¢, ERPNext's later validation will throw
|
||||
# "Total (including taxes) and Grand Total do not match". Detect here.
|
||||
_drift = round(net_total + tps + tvq - total_amt, 2)
|
||||
if abs(_drift) > 0.01:
|
||||
print(" WARN invoice_id={} tax drift {:+.2f} (net={} tps={} tvq={} gt={})".format(
|
||||
inv['id'], _drift, net_total, tps, tvq, total_amt))
|
||||
|
||||
# return_against — set later in phase for credit allocations
|
||||
return_against = ""
|
||||
|
||||
|
|
@ -417,30 +441,36 @@ for idx in range(0, len(invoices), CHUNK):
|
|||
)
|
||||
)
|
||||
|
||||
# Tax rows
|
||||
if tps != 0 or tvq != 0:
|
||||
# Tax rows — each row carries the REAL rate from legacy so historical
|
||||
# invoices (pre-2013 TVQ at 8.925% / 9.5%) keep accurate descriptions.
|
||||
# Only emit non-zero rows: a zero-amount TPS or TVQ line would render
|
||||
# "TPS 5% $0.00" on the PDF which is cosmetically wrong.
|
||||
if tps != 0:
|
||||
si_tax_values.append(
|
||||
"('{name}', 'Administrator', NOW(), NOW(), 'Administrator', "
|
||||
"'{parent}', 'Sales Invoice', 'taxes', 1, "
|
||||
"'On Net Total', '2300 - TPS perçue - T', "
|
||||
"'TPS 5% (#834975559RT0001)', 5.0, "
|
||||
"'TPS {rate}% (#834975559RT0001)', {rate}, "
|
||||
"{tps}, {tps}, {running1}, "
|
||||
"'', 0)".format(
|
||||
name="stc-tps-{}".format(inv['id']),
|
||||
parent=sinv_name,
|
||||
rate=("%g" % tps_rate), # %g strips trailing zeros → "5" not "5.0"
|
||||
tps=tps,
|
||||
running1=round(net_total + tps, 2),
|
||||
)
|
||||
)
|
||||
if tvq != 0:
|
||||
si_tax_values.append(
|
||||
"('{name}', 'Administrator', NOW(), NOW(), 'Administrator', "
|
||||
"'{parent}', 'Sales Invoice', 'taxes', 2, "
|
||||
"'On Net Total', '2350 - TVQ perçue - T', "
|
||||
"'TVQ 9.975% (#1213765929TQ0001)', 9.975, "
|
||||
"'TVQ {rate}% (#1213765929TQ0001)', {rate}, "
|
||||
"{tvq}, {tvq}, {running2}, "
|
||||
"'', 0)".format(
|
||||
name="stc-tvq-{}".format(inv['id']),
|
||||
parent=sinv_name,
|
||||
rate=("%g" % tvq_rate),
|
||||
tvq=tvq,
|
||||
running2=total_amt,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,15 @@ def main():
|
|||
chunk = 10000
|
||||
for s in range(0, len(inv_ids), chunk):
|
||||
batch = inv_ids[s:s+chunk]
|
||||
cur.execute("SELECT * FROM invoice_item WHERE invoice_id IN ({})".format(",".join(["%s"]*len(batch))), batch)
|
||||
# Join service to carry delivery_id on each invoice_item row
|
||||
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(",".join(["%s"]*len(batch))),
|
||||
batch,
|
||||
)
|
||||
for r in cur.fetchall():
|
||||
items_by_inv.setdefault(r["invoice_id"], []).append(r)
|
||||
mc.close()
|
||||
|
|
@ -64,6 +72,14 @@ def main():
|
|||
pgc.execute('SELECT item_code FROM "tabItem"')
|
||||
valid_items = set(r[0] for r in pgc.fetchall())
|
||||
|
||||
# legacy delivery.id → ERPNext Service Location name
|
||||
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())
|
||||
log(" {} Service Locations with legacy_delivery_id".format(len(loc_by_delivery)))
|
||||
|
||||
pgc.execute("""SELECT name FROM "tabAccount" WHERE account_type = 'Receivable' AND company = %s AND is_group = 0 LIMIT 1""", (COMPANY,))
|
||||
receivable = pgc.fetchone()[0]
|
||||
|
||||
|
|
@ -156,6 +172,9 @@ def main():
|
|||
# Map to correct GL account via SKU → product_cat → num_compte
|
||||
income_acct = sku_to_gl.get(sku, income_acct_default)
|
||||
|
||||
# Resolve service_location via legacy delivery_id carried on the row
|
||||
service_location = loc_by_delivery.get(li.get("service_delivery_id"))
|
||||
|
||||
pgc.execute("""
|
||||
INSERT INTO "tabSales Invoice Item" (
|
||||
name, creation, modified, modified_by, owner, docstatus, idx,
|
||||
|
|
@ -163,7 +182,7 @@ def main():
|
|||
base_rate, base_amount, base_net_rate, base_net_amount,
|
||||
net_rate, net_amount,
|
||||
stock_uom, uom, conversion_factor,
|
||||
income_account, cost_center,
|
||||
income_account, cost_center, service_location,
|
||||
parent, parentfield, parenttype
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, 0, %s,
|
||||
|
|
@ -171,14 +190,14 @@ def main():
|
|||
%s, %s, %s, %s,
|
||||
%s, %s,
|
||||
'Nos', 'Nos', 1,
|
||||
%s, 'Main - T',
|
||||
%s, 'Main - T', %s,
|
||||
%s, 'items', 'Sales Invoice'
|
||||
)
|
||||
""", (uid("SII-"), ts, ts, ADMIN, ADMIN, j+1,
|
||||
item_code, desc[:140], desc[:140], qty, rate, amount,
|
||||
rate, amount, rate, amount,
|
||||
rate, amount,
|
||||
income_acct, sinv_name))
|
||||
income_acct, service_location, sinv_name))
|
||||
item_ok += 1
|
||||
|
||||
inv_ok += 1
|
||||
|
|
|
|||
232
scripts/migration/invoice_preview.jinja
Normal file
232
scripts/migration/invoice_preview.jinja
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Facture {{ invoice_number }}</title>
|
||||
<style>
|
||||
@page { size: Letter; margin: 10mm 15mm 8mm 20mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 8pt; color: #333; line-height: 1.3; margin: 0; }
|
||||
@media screen {
|
||||
body { padding: 40px; max-width: 8.5in; margin: 0 auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); background: #fff; }
|
||||
html { background: #f0f0f0; }
|
||||
}
|
||||
|
||||
/* ── Slogan ── */
|
||||
.slogan { font-size: 7pt; color: #019547; text-align: center; padding: 2px 0 4px; font-weight: 500; letter-spacing: 0.3px; }
|
||||
|
||||
/* ── Metadata Elements ── */
|
||||
.doc-label { font-size: 14pt; font-weight: 700; color: #019547; }
|
||||
.doc-page { font-size: 7pt; color: #888; }
|
||||
|
||||
/* ── Meta band (under logo) ── */
|
||||
.meta-band { width: 100%; padding: 6px 0; border-top: 1px solid #eee; border-bottom: 1px solid #eee; margin: 15px 0 0 0; }
|
||||
.meta-band td { vertical-align: middle; padding: 0 6px; text-align: center; }
|
||||
.meta-band .ml { color: #888; display: block; font-size: 5.5pt; text-transform: uppercase; margin-bottom: 2px; }
|
||||
.meta-band .mv { font-weight: 700; color: #333; font-size: 7pt; }
|
||||
|
||||
/* ── Main 2-column layout ── */
|
||||
.main { width: 100%; border-collapse: separate; border-spacing: 0; }
|
||||
.main td { vertical-align: top; padding: 0; }
|
||||
.col-l { width: 60%; padding-right: 0; }
|
||||
.col-r { width: 38%; padding-left: 0; }
|
||||
|
||||
/* ── Left: client address (fixed for #10 envelope window) ── */
|
||||
/* Target: 2.5"-3.5" from top of page = 63-89mm. At 10mm page margin, the text needs ~53mm from body top. */
|
||||
.cl-block { margin-top: 12mm; margin-bottom: 28px; padding-left: 4px; }
|
||||
.cl-lbl { font-size: 7pt; color: #888; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 2px; }
|
||||
.cl-name { font-size: 11pt; font-weight: 700; color: #222; line-height: 1.4; }
|
||||
.cl-addr { font-size: 10pt; color: #333; line-height: 1.5; }
|
||||
|
||||
/* ── Left: summary ── */
|
||||
.summary-hdr { font-size: 8.5pt; font-weight: 700; color: #019547; border-bottom: 1px solid #019547; padding-bottom: 3px; margin-bottom: 5px; }
|
||||
.section-hdr { font-weight: 600; font-size: 7.5pt; padding-top: 5px; margin-bottom: 2px; }
|
||||
.addr-hdr { font-weight: 600; font-size: 7pt; color: #019547; padding: 5px 0 2px; }
|
||||
.prev-row { width: 100%; font-size: 7.5pt; }
|
||||
.prev-row td { padding: 3px 4px; }
|
||||
.prev-row .r { text-align: right; }
|
||||
.prev-row .indent td { padding-left: 8px; color: #555; }
|
||||
.prev-row .pmt td { color: #019547; }
|
||||
.prev-row .bal td { font-style: italic; color: #888; }
|
||||
.prev-row .shdr td { font-weight: 600; padding-top: 6px; }
|
||||
.dtl { width: 100%; border-collapse: collapse; font-size: 7.5pt; margin-bottom: 16px; }
|
||||
.dtl th { text-align: left; padding: 4px 6px; background: #f3f4f6; font-weight: 600; color: #555; font-size: 6.5pt; text-transform: uppercase; }
|
||||
.dtl th.r, .dtl td.r { text-align: right; }
|
||||
.dtl td { padding: 4px 6px; border-bottom: 1px solid #f0f0f0; }
|
||||
.dtl tr.credit td { color: #019547; }
|
||||
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #ddd; border-bottom: none; padding-top: 3px; }
|
||||
.dtl tr.tax td { color: #888; font-size: 7pt; border-bottom: none; padding: 2px 6px; }
|
||||
.dtl tr.grand td { font-weight: 700; font-size: 8.5pt; border-top: 1px solid #019547; padding-top: 4px; }
|
||||
.tax-nums { font-size: 6.5pt; color: #999; margin-top: 4px; }
|
||||
|
||||
/* ── Right column: all content in a bordered container ── */
|
||||
.r-container { border: 1px solid #e5e7eb; border-left: 2px solid #019547; }
|
||||
.r-section { padding: 10px 14px; }
|
||||
.r-section + .r-section { border-top: 1px solid #e5e7eb; }
|
||||
.r-section.r-green { background: #e8f5ee; }
|
||||
.r-section.r-gray { background: #f8f9fa; }
|
||||
/* Summary in right col */
|
||||
.r-summary-hdr { font-size: 8.5pt; font-weight: 700; color: #019547; margin-bottom: 6px; }
|
||||
.r-prev { width: 100%; font-size: 7.5pt; }
|
||||
.r-prev td { padding: 3px 0; }
|
||||
.r-prev .r { text-align: right; white-space: nowrap; }
|
||||
.r-prev .indent td { padding-left: 6px; color: #555; }
|
||||
.r-prev .pmt td { color: #019547; }
|
||||
.r-prev .bal td { font-style: italic; color: #888; }
|
||||
.r-prev .shdr td { font-weight: 600; padding-top: 6px; }
|
||||
/* Amount due */
|
||||
.ab-label { font-size: 8.5pt; font-weight: 600; color: #019547; }
|
||||
.ab-val { font-size: 18pt; font-weight: 700; color: #222; text-align: right; white-space: nowrap; }
|
||||
.ab-date { font-size: 7pt; color: #555; margin-top: 2px; }
|
||||
.ab-table { width: 100%; border-collapse: collapse; }
|
||||
.ab-table td { padding: 0; border: none; vertical-align: middle; }
|
||||
/* QR */
|
||||
.qr-table { width: 100%; border-collapse: collapse; }
|
||||
.qr-table td { padding: 2px 6px; border: none; vertical-align: middle; font-size: 7.5pt; }
|
||||
.qr-placeholder { width: 40px; height: 40px; background: #ccc; font-size: 5pt; color: #888; text-align: center; line-height: 40px; }
|
||||
/* Info block */
|
||||
.r-info-text { font-size: 6.5pt; color: #555; line-height: 1.4; }
|
||||
.r-info-text strong { color: #333; }
|
||||
.r-info-text .ri-title { font-weight: 700; font-size: 7pt; color: #333; margin-bottom: 3px; }
|
||||
.r-info-text .ri-sep { border-top: 1px solid #e5e7eb; margin: 6px 0; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer-line { font-size: 6pt; color: #bbb; text-align: center; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════ HEADER & MAIN 2 COLUMNS ═══════ -->
|
||||
<div style="position: relative;">
|
||||
<div class="slogan" style="position: absolute; top: 6px; left: 0; right: 0;">Merci de choisir local — Merci de choisir TARGO</div>
|
||||
<table class="main"><tr>
|
||||
|
||||
<!-- ── LEFT 60% ── -->
|
||||
<td class="col-l">
|
||||
|
||||
<!-- Logo -->
|
||||
<div style="height: 26px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" style="height: 26px; width: auto;"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Account info directly under the logo -->
|
||||
<table class="meta-band"><tr>
|
||||
<td style="text-align:left;"><span class="ml">Nº compte</span><span class="mv">{{ account_number }}</span></td>
|
||||
<td style="text-align:center;"><span class="ml">Date</span><span class="mv">{{ invoice_date }}</span></td>
|
||||
<td style="text-align:right;"><span class="ml">Nº facture</span><span class="mv">{{ invoice_number }}</span></td>
|
||||
</tr></table>
|
||||
|
||||
<!-- Client address (calibrated for #10 envelope window) -->
|
||||
<div class="cl-block">
|
||||
<div class="cl-lbl">Facturer à</div>
|
||||
<div class="cl-name">{{ client_name }}</div>
|
||||
<div class="cl-addr">
|
||||
{{ client_address_line1 }}<br>
|
||||
{{ client_address_line2 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current charges -->
|
||||
<div class="summary-hdr">FRAIS COURANTS</div>
|
||||
|
||||
{% for location in current_charges_locations %}
|
||||
<div class="addr-hdr">◎ {{ location.name }}</div>
|
||||
<table class="dtl">
|
||||
<thead><tr><th style="width:55%">Description</th><th class="r">Qté</th><th class="r">Unit.</th><th class="r">Montant</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in location['items'] %}
|
||||
<tr class="{% if item.is_credit %}credit{% endif %}">
|
||||
<td>{{ item.description }}</td>
|
||||
<td class="r">{{ item.qty }}</td>
|
||||
<td class="r">{{ item.unit_price }}</td>
|
||||
<td class="r">{{ item.amount }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="stot"><td colspan="3">Sous-total</td><td class="r">$ {{ location.subtotal }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<table class="dtl" style="margin-top:4px;">
|
||||
<tr class="stot"><td colspan="3" style="width:75%">Sous-total avant taxes</td><td class="r">$ {{ subtotal_before_taxes }}</td></tr>
|
||||
<tr class="tax"><td colspan="3">Taxes: TPS ($ {{ tps_amount }}), TVQ ($ {{ tvq_amount }})</td><td class="r">$ {{ total_taxes }}</td></tr>
|
||||
<tr class="grand"><td colspan="3">TOTAL</td><td class="r">$ {{ current_charges_total }}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="tax-nums">TPS: 834975559RT0001 | TVQ: 1213765929TQ0001</div>
|
||||
</td>
|
||||
|
||||
<!-- ── SPACER ── -->
|
||||
<td style="width: 2%; border-left: 15px solid transparent;"></td>
|
||||
|
||||
<!-- ── RIGHT 38%: all in one bordered container ── -->
|
||||
<td class="col-r">
|
||||
<!-- FACTURE Header floating above summary -->
|
||||
<div style="text-align: right; height: 41px;">
|
||||
<div class="doc-label" style="line-height:1;">FACTURE</div>
|
||||
<div class="doc-page" style="line-height:1.2; margin-top:2px;">Page {{ current_page|default('1') }} de {{ total_pages|default('1') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="r-container">
|
||||
|
||||
<!-- Account summary -->
|
||||
<div class="r-section">
|
||||
<div class="r-summary-hdr">SOMMAIRE DU COMPTE</div>
|
||||
<table class="r-prev">
|
||||
<tr class="shdr"><td colspan="2">Facture précédente ({{ prev_invoice_date }})</td></tr>
|
||||
<tr class="indent"><td>Total facture précédente</td><td class="r">$ {{ prev_invoice_total }}</td></tr>
|
||||
|
||||
{% for payment in recent_payments %}
|
||||
<tr class="indent pmt"><td>Paiement reçu — Merci</td><td class="r">- $ {{ payment.amount }}</td></tr>
|
||||
{% endfor %}
|
||||
|
||||
<tr class="indent bal"><td>Solde restant</td><td class="r">$ {{ remaining_balance }}</td></tr>
|
||||
<tr class="shdr"><td>Frais courants</td><td class="r">$ {{ subtotal_before_taxes }}</td></tr>
|
||||
<tr class="indent"><td>Taxes</td><td class="r">$ {{ total_taxes }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Amount due -->
|
||||
<div class="r-section r-green">
|
||||
<table class="ab-table"><tr>
|
||||
<td><div class="ab-label">Montant dû</div><div class="ab-date">avant le {{ due_date }}</div></td>
|
||||
<td class="ab-val">$ {{ total_amount_due }}</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<!-- QR -->
|
||||
<div class="r-section r-green">
|
||||
<table class="qr-table"><tr>
|
||||
<td style="width:46px;">
|
||||
{% if qr_code_base64 %}
|
||||
<img src="data:image/png;base64,{{ qr_code_base64 }}" style="width:40px; height:40px;" />
|
||||
{% else %}
|
||||
<div class="qr-placeholder">QR</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>Payez en ligne</strong><br>Scannez le code QR ou visitez<br><strong style="color:#019547">client.gigafibre.ca</strong></td>
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<!-- Contact + Message -->
|
||||
<div class="r-section r-gray r-info-text">
|
||||
<div class="ri-title">Contactez-nous</div>
|
||||
Service à la clientèle<br>
|
||||
<strong>819 758-1555</strong><br>
|
||||
Lun-Ven 8h-17h<br>
|
||||
info@targo.ca • www.targo.ca
|
||||
<div class="ri-sep"></div>
|
||||
Prendre note que toute facture non acquittée à la date d'échéance sera sujette à des frais de retard.<br><br>
|
||||
Avez-vous une plainte relative à votre service de télécommunication ou de télévision que vous n'êtes pas parvenu à régler? La CPRST pourrait vous aider sans frais : <strong>www.ccts-cprst.ca</strong> ou <strong>1-888-221-1687</strong>.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<div class="footer-line">TARGO Communications Inc. • 134 rue Principale, Victoriaville, QC G6P 4E4 • 819 758-1555</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1
scripts/migration/logo-targo-green.svg
Normal file
1
scripts/migration/logo-targo-green.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,357 +1,699 @@
|
|||
"""
|
||||
Create custom Print Format for Sales Invoice — Gigafibre/TARGO style.
|
||||
Inspired by Cogeco layout: summary page 1, details page 2, envelope window address.
|
||||
Create / update the custom Print Format "Facture TARGO" on ERPNext.
|
||||
|
||||
Run inside erpnext-backend-1:
|
||||
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/setup_invoice_print_format.py
|
||||
Design
|
||||
------
|
||||
- Letter-size, #10 envelope-window compatible
|
||||
- 2-column layout: left = client + per-location charges, right = summary + QR
|
||||
- Items grouped by Sales Invoice Item.service_location (Link → Service Location).
|
||||
Identical (name, rate) rows within a location are consolidated with a "(xN)"
|
||||
suffix, matching the reference preview.
|
||||
- QR code is embedded as base64 data URI via the whitelisted method
|
||||
`gigafibre_utils.api.invoice_qr_base64`. Depends on the custom app
|
||||
`gigafibre_utils` being installed on the bench (see /opt/erpnext/custom/
|
||||
on the ERPNext host). wkhtmltopdf does NOT fetch the QR over HTTP.
|
||||
- SVG logo paths use inline `fill="#019547"` (wkhtmltopdf / QtWebKit does not
|
||||
honour `<defs><style>` CSS inside SVG).
|
||||
|
||||
Run (inside erpnext-backend-1)
|
||||
------------------------------
|
||||
docker cp scripts/migration/setup_invoice_print_format.py \\
|
||||
erpnext-backend-1:/tmp/setup_invoice_print_format.py
|
||||
docker exec -u frappe -w /home/frappe/frappe-bench erpnext-backend-1 \\
|
||||
env/bin/python /tmp/setup_invoice_print_format.py
|
||||
|
||||
The script is idempotent: it updates the Print Format if it already exists.
|
||||
"""
|
||||
import os, sys
|
||||
import os
|
||||
|
||||
os.chdir("/home/frappe/frappe-bench/sites")
|
||||
import frappe
|
||||
|
||||
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||
frappe.connect()
|
||||
print("Connected:", frappe.local.site)
|
||||
|
||||
# ── Update Print Settings to Letter size ──
|
||||
from frappe.installer import update_site_config
|
||||
# Print Settings: Letter + canonical number format
|
||||
frappe.db.set_single_value("Print Settings", "pdf_page_size", "Letter")
|
||||
frappe.db.set_single_value("System Settings", "number_format", "#,###.##")
|
||||
frappe.db.set_single_value("System Settings", "currency_precision", "2")
|
||||
frappe.db.commit()
|
||||
print(" PDF page size set to Letter")
|
||||
|
||||
# ── Register the logo file if not exists ──
|
||||
if not frappe.db.exists("File", {"file_url": "/files/targo-logo-green.svg"}):
|
||||
f = frappe.get_doc({
|
||||
"doctype": "File",
|
||||
"file_name": "targo-logo-green.svg",
|
||||
"file_url": "/files/targo-logo-green.svg",
|
||||
"is_private": 0,
|
||||
})
|
||||
f.insert(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
print(" Registered logo file")
|
||||
print(" PDF page size set to Letter, number format set to #,###.##")
|
||||
|
||||
PRINT_FORMAT_NAME = "Facture TARGO"
|
||||
|
||||
html_template = r"""
|
||||
{%- set company_name = "TARGO Communications" -%}
|
||||
{%- set company_addr = "123 rue Principale" -%}
|
||||
{%- set company_city = "Victoriaville QC G6P 1A1" -%}
|
||||
{%- set company_tel = "(819) 758-1555" -%}
|
||||
{%- set company_web = "gigafibre.ca" -%}
|
||||
{%- set tps_no = "TPS: #819304698RT0001" -%}
|
||||
{%- set tvq_no = "TVQ: #1215640113TQ0001" -%}
|
||||
{%- set brand_green = "#019547" -%}
|
||||
{%- set brand_light = "#e8f5ee" -%}
|
||||
# ── Jinja template ────────────────────────────────────────────────────────
|
||||
# Kept in-file so the script is self-contained and version-controlled.
|
||||
# Source of truth: this string. Regenerate PDFs via
|
||||
# /api/method/frappe.utils.print_format.download_pdf?doctype=Sales+Invoice&name=<SINV>&format=Facture+TARGO
|
||||
html_template = r"""{#- ══════════════════════════════════════════════════════════════════════════
|
||||
Facture TARGO — ERPNext Print Format (Sales Invoice)
|
||||
|
||||
{%- set is_credit = doc.is_return == 1 -%}
|
||||
The HTML/CSS below is a verbatim copy of scripts/migration/invoice_preview.jinja
|
||||
(the canonical design that renders correctly under Chromium/Antigravity).
|
||||
Only the top Jinja prelude is new: it resolves `doc.*` + whitelisted helpers
|
||||
into the plain context variables the preview template expects.
|
||||
|
||||
Depends on:
|
||||
- custom app `gigafibre_utils` (QR + logo + short_item_name helpers)
|
||||
- PDF generator set to `chrome` on the Print Format (Chromium in the image)
|
||||
══════════════════════════════════════════════════════════════════════════ -#}
|
||||
|
||||
{%- macro money(v) -%}{{ "%.2f"|format((v or 0)|float) }}{%- endmacro -%}
|
||||
|
||||
{%- macro _clean(s) -%}{{ (s or "") | replace("'","'") | replace("&","&") | replace("<","<") | replace(">",">") | replace(""",'"') }}{%- endmacro -%}
|
||||
|
||||
{%- set mois_fr = {"January":"janvier","February":"février","March":"mars","April":"avril","May":"mai","June":"juin","July":"juillet","August":"août","September":"septembre","October":"octobre","November":"novembre","December":"décembre"} -%}
|
||||
{%- macro date_fr(d) -%}
|
||||
{%- if d -%}
|
||||
{%- set dt = frappe.utils.getdate(d) -%}
|
||||
{{ dt.day }} {{ mois_fr.get(dt.strftime("%B"), dt.strftime("%B")) }} {{ dt.year }}
|
||||
{%- else -%}—{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
{%- macro date_short(d) -%}
|
||||
{%- if d -%}
|
||||
{%- set dt = frappe.utils.getdate(d) -%}
|
||||
{{ "%02d/%02d/%04d" | format(dt.day, dt.month, dt.year) }}
|
||||
{%- else -%}—{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
{%- macro date_fr(d) -%}
|
||||
{%- if d -%}
|
||||
{%- set dt = frappe.utils.getdate(d) -%}
|
||||
{{ dt.day }} {{ mois_fr.get(dt.strftime("%B"), dt.strftime("%B")) }} {{ dt.year }}
|
||||
{%- else -%}—{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{# Decode HTML entities in item names #}
|
||||
{%- macro clean(s) -%}{{ s | replace("'", "'") | replace("&", "&") | replace("<", "<") | replace(">", ">") | replace(""", '"') if s else "" }}{%- endmacro -%}
|
||||
{#- ── Preview context variables resolved from the Sales Invoice ───────────── -#}
|
||||
{%- set invoice_number = doc.name -%}
|
||||
{%- set invoice_date = date_short(doc.posting_date) -%}
|
||||
{%- set account_number = doc.customer -%}
|
||||
{%- set client_name = doc.customer_name -%}
|
||||
|
||||
{# Get customer address from Service Location or address_display #}
|
||||
{%- set cust_addr = doc.address_display or "" -%}
|
||||
{%- if not cust_addr and doc.customer_address -%}
|
||||
{%- set addr_doc = frappe.get_doc("Address", doc.customer_address) -%}
|
||||
{%- set cust_addr = (addr_doc.address_line1 or "") + "\n" + (addr_doc.city or "") + " " + (addr_doc.state or "") + " " + (addr_doc.pincode or "") -%}
|
||||
{%- set client_address_line1 = "" -%}
|
||||
{%- set client_address_line2 = "" -%}
|
||||
{%- if doc.customer_address -%}
|
||||
{%- set a = frappe.get_doc("Address", doc.customer_address) -%}
|
||||
{%- set client_address_line1 = a.address_line1 or "" -%}
|
||||
{%- set _city = (a.city or "") -%}
|
||||
{%- set _state = (a.state or "") -%}
|
||||
{%- set _pin = (a.pincode or "") -%}
|
||||
{%- set client_address_line2 = (_city ~ (", " if _city and _state else "") ~ _state ~ (" " if _pin else "") ~ _pin) | trim -%}
|
||||
{%- endif -%}
|
||||
|
||||
<style>
|
||||
@page { size: Letter; margin: 12mm 15mm 10mm 15mm; }
|
||||
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 9pt; color: #333; line-height: 1.4; }
|
||||
.inv { width: 100%; }
|
||||
.hdr-table { width: 100%; margin-bottom: 6px; }
|
||||
.hdr-table td { vertical-align: top; padding: 0; }
|
||||
.logo img { height: 36px; }
|
||||
.doc-title { font-size: 16pt; font-weight: 700; color: {{ brand_green }}; text-align: right; }
|
||||
.info-table { width: 100%; margin-bottom: 10px; }
|
||||
.info-table td { vertical-align: top; padding: 2px 0; }
|
||||
.info-table .lbl { color: #888; font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.info-table .val { font-weight: 600; }
|
||||
.info-left { width: 50%; }
|
||||
.info-right { width: 50%; text-align: right; }
|
||||
.tax-nums { font-size: 7.5pt; color: #888; margin: 4px 0 8px; }
|
||||
/* Total box — table-based for wkhtmltopdf */
|
||||
.total-wrap { width: 100%; margin: 10px 0; }
|
||||
.total-wrap td { padding: 10px 16px; color: white; vertical-align: middle; }
|
||||
.total-bg { background: {{ brand_green }}; }
|
||||
.total-bg.credit { background: #dc3545; }
|
||||
.total-label { font-size: 11pt; }
|
||||
.total-amount { font-size: 18pt; font-weight: 700; text-align: right; }
|
||||
/* Summary */
|
||||
.summary { border: 1.5px solid {{ brand_green }}; padding: 10px 14px; margin: 10px 0; }
|
||||
.summary-title { font-size: 10pt; font-weight: 700; color: {{ brand_green }}; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-bottom: 6px; }
|
||||
.s-row { width: 100%; }
|
||||
.s-row td { padding: 2px 0; font-size: 9pt; }
|
||||
.s-row .r { text-align: right; }
|
||||
.s-row .indent td { padding-left: 14px; color: #555; }
|
||||
.s-row .subtot td { border-top: 1px solid #ddd; padding-top: 4px; font-weight: 600; }
|
||||
/* Contact */
|
||||
.contact-table { width: 100%; margin: 10px 0; font-size: 8pt; color: #666; }
|
||||
.contact-table td { vertical-align: top; padding: 2px 0; }
|
||||
/* QR */
|
||||
.qr-wrap { background: {{ brand_light }}; padding: 8px 12px; margin: 8px 0; font-size: 8pt; }
|
||||
.qr-wrap table td { vertical-align: middle; padding: 2px 8px; }
|
||||
.qr-box { width: 55px; height: 55px; border: 1px solid #ccc; text-align: center; font-size: 7pt; color: #999; }
|
||||
/* Coupon */
|
||||
.coupon-line { border-top: 2px dashed #ccc; margin: 14px 0 4px; font-size: 7pt; color: #999; text-align: center; }
|
||||
.coupon-table { width: 100%; }
|
||||
.coupon-table td { text-align: center; font-size: 8pt; vertical-align: middle; padding: 4px; }
|
||||
.coupon-table .c-lbl { font-size: 6.5pt; color: #888; text-transform: uppercase; }
|
||||
.coupon-table .c-val { font-weight: 600; }
|
||||
.coupon-table .c-logo img { height: 22px; }
|
||||
/* Envelope address */
|
||||
.env-addr { margin-top: 16px; padding-top: 6px; font-size: 10pt; line-height: 1.5; text-transform: uppercase; }
|
||||
.footer-line { font-size: 7pt; color: #999; text-align: center; margin-top: 6px; }
|
||||
/* Return notice */
|
||||
.return-box { background: #fff3cd; border: 1px solid #ffc107; padding: 6px 12px; margin: 6px 0; font-size: 9pt; }
|
||||
/* Page 2 — details */
|
||||
.detail-title { font-size: 10pt; font-weight: 700; color: {{ brand_green }}; padding: 6px 0; border-bottom: 2px solid {{ brand_green }}; margin-bottom: 4px; }
|
||||
.dtl { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
|
||||
.dtl th { text-align: left; padding: 4px 6px; background: {{ brand_light }}; font-weight: 600; color: #555; font-size: 7.5pt; text-transform: uppercase; }
|
||||
.dtl th.r, .dtl td.r { text-align: right; }
|
||||
.dtl td { padding: 3px 6px; border-bottom: 1px solid #f0f0f0; }
|
||||
.dtl tr.tax td { color: #888; font-size: 8pt; border-bottom: none; padding: 1px 6px; }
|
||||
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #ddd; border-bottom: none; padding-top: 4px; }
|
||||
.page-break { page-break-before: always; }
|
||||
</style>
|
||||
{#- ── Language detection ─────────────────────────────────────────────────────
|
||||
Reads Customer.language (set on the Customer record → More Info tab).
|
||||
Frappe stores values like "fr", "fr-CA", "en", "en-US".
|
||||
Anything starting with "en" is treated as English; all else → French.
|
||||
This controls every user-visible label: document title, meta table, totals,
|
||||
legal text. The QUOTE template reuses the same block with lbl_doc_quote. -#}
|
||||
{%- set _cust_lang = (frappe.db.get_value("Customer", doc.customer, "language") or "fr") | lower -%}
|
||||
{%- set _is_en = _cust_lang[:2] == "en" -%}
|
||||
|
||||
<div class="inv">
|
||||
<!-- ═══════ PAGE 1: SOMMAIRE ═══════ -->
|
||||
|
||||
<!-- HEADER -->
|
||||
<table class="hdr-table"><tr>
|
||||
<td class="logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
|
||||
<td class="doc-title">{% if is_credit %}NOTE DE CRÉDIT{% else %}FACTURE{% endif %}</td>
|
||||
</tr></table>
|
||||
|
||||
<!-- INFO CLIENT + FACTURE -->
|
||||
<table class="info-table"><tr>
|
||||
<td class="info-left">
|
||||
<div class="lbl">Services fournis à</div>
|
||||
<div class="val">{{ doc.customer_name or doc.customer }}</div>
|
||||
{% if cust_addr %}
|
||||
<div style="margin-top:2px">{{ cust_addr | striptags | replace("\n","<br>") }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="info-right">
|
||||
<table style="float:right; text-align:right;">
|
||||
<tr><td class="lbl">Nº de compte</td></tr>
|
||||
<tr><td class="val">{{ doc.customer }}</td></tr>
|
||||
<tr><td class="lbl" style="padding-top:6px">Nº de facture</td></tr>
|
||||
<tr><td class="val">{{ doc.name }}</td></tr>
|
||||
<tr><td class="lbl" style="padding-top:6px">Date de facturation</td></tr>
|
||||
<tr><td class="val">{{ date_fr(doc.posting_date) }}</td></tr>
|
||||
{% if doc.due_date %}
|
||||
<tr><td class="lbl" style="padding-top:6px">Date d'échéance</td></tr>
|
||||
<tr><td class="val">{{ date_fr(doc.due_date) }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr></table>
|
||||
|
||||
<!-- TAX NUMBERS -->
|
||||
<div class="tax-nums">{{ tps_no }} | {{ tvq_no }}</div>
|
||||
|
||||
<!-- CREDIT NOTE -->
|
||||
{% if is_credit %}
|
||||
<div class="return-box">
|
||||
<strong>Note de crédit</strong>
|
||||
{% if doc.return_against %} — Renversement de la facture <strong>{{ doc.return_against }}</strong>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- MONTANT TOTAL -->
|
||||
<table class="total-wrap"><tr class="total-bg {% if is_credit %}credit{% endif %}">
|
||||
<td class="total-label">{% if is_credit %}MONTANT CRÉDITÉ{% else %}MONTANT TOTAL DÛ{% endif %}</td>
|
||||
<td class="total-amount">{{ frappe.utils.fmt_money(doc.grand_total | abs, currency=doc.currency) }}</td>
|
||||
</tr></table>
|
||||
|
||||
<!-- SOMMAIRE DU COMPTE -->
|
||||
<div class="summary">
|
||||
<div class="summary-title">SOMMAIRE DU COMPTE</div>
|
||||
<table class="s-row">
|
||||
{% if doc.outstanding_amount != doc.grand_total %}
|
||||
<tr>
|
||||
<td>Solde antérieur</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money((doc.outstanding_amount or 0) - (doc.grand_total or 0), currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr><td colspan="2" style="padding-top:6px"><strong>Frais du mois courant</strong></td></tr>
|
||||
|
||||
{%- set service_groups = {} -%}
|
||||
{%- for item in doc.items -%}
|
||||
{%- set group = item.item_group or "Services" -%}
|
||||
{%- if group not in service_groups -%}
|
||||
{%- set _ = service_groups.update({group: 0}) -%}
|
||||
{#- Document title — FACTURE / INVOICE or NOTE DE CRÉDIT / CREDIT NOTE -#}
|
||||
{%- if doc.is_return -%}
|
||||
{%- set doc_title = "CREDIT NOTE" if _is_en else "NOTE DE CR\u00c9DIT" -%}
|
||||
{%- else -%}
|
||||
{%- set doc_title = "INVOICE" if _is_en else "FACTURE" -%}
|
||||
{%- endif -%}
|
||||
{%- set _ = service_groups.update({group: service_groups[group] + (item.amount or 0)}) -%}
|
||||
|
||||
{#- ── TPS / TVQ split ──────────────────────────────────────────────────────── -#}
|
||||
{#- Jinja {% set %} inside a {% for %} loop does NOT mutate outer scope —
|
||||
must use a namespace object. Also accumulate (not assign) in case the
|
||||
Sales Invoice has multiple TPS/TVQ rows. Match on description OR account_head
|
||||
so renamed Tax Templates (e.g. "GST/HST", "QST") still classify correctly. -#}
|
||||
{%- set ns = namespace(tps=0, tvq=0) -%}
|
||||
{%- for t in doc.taxes or [] -%}
|
||||
{%- set _desc = ((t.description or "") ~ " " ~ (t.account_head or "")) | upper -%}
|
||||
{%- if "TPS" in _desc or "GST" in _desc or "HST" in _desc -%}
|
||||
{%- set ns.tps = ns.tps + (t.tax_amount or 0) -%}
|
||||
{%- elif "TVQ" in _desc or "QST" in _desc or "PST" in _desc -%}
|
||||
{%- set ns.tvq = ns.tvq + (t.tax_amount or 0) -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{#- Fallback: if no tax row matched the name heuristics but the invoice
|
||||
DOES have total taxes, split 1/3 TPS, 2/3 TVQ (Québec standard 5%/9.975%
|
||||
≈ 1:2 ratio). Keeps the displayed sum equal to total_taxes_and_charges. -#}
|
||||
{%- if (ns.tps + ns.tvq) == 0 and (doc.total_taxes_and_charges or 0) > 0 -%}
|
||||
{%- set _tot = doc.total_taxes_and_charges -%}
|
||||
{%- set ns.tps = (_tot * 5.0 / 14.975) | round(2) -%}
|
||||
{%- set ns.tvq = _tot - ns.tps -%}
|
||||
{%- endif -%}
|
||||
{%- set tps_amount = money(ns.tps) -%}
|
||||
{%- set tvq_amount = money(ns.tvq) -%}
|
||||
{%- set total_taxes = money(doc.total_taxes_and_charges) -%}
|
||||
{%- set subtotal_before_taxes = money(doc.net_total) -%}
|
||||
{%- set current_charges_total = money(doc.grand_total) -%}
|
||||
{%- set due_date = date_fr(doc.due_date) -%}
|
||||
|
||||
{#- ── Service period window (used both by the items loop for per-line
|
||||
period labels and by any block that needs the overall period). ── -#}
|
||||
{%- set svc_start = frappe.utils.getdate(doc.posting_date) -%}
|
||||
{%- set svc_end = frappe.utils.add_days(frappe.utils.add_months(doc.posting_date, 1), -1) -%}
|
||||
|
||||
{#- ── Group items by Service Location (address label). Consolidate duplicates. -#}
|
||||
{%- set _groups = {} -%}
|
||||
{%- set _order = [] -%}
|
||||
{%- for it in doc.items -%}
|
||||
{%- set loc_key = it.get("service_location") or "_default" -%}
|
||||
{%- if loc_key not in _groups -%}
|
||||
{%- set _ = _order.append(loc_key) -%}
|
||||
{%- set _label = "" -%}
|
||||
{%- if loc_key == "_default" -%}
|
||||
{%- set _label = client_address_line1 or "Tous les services" -%}
|
||||
{%- else -%}
|
||||
{%- set _ld = frappe.get_cached_doc("Service Location", loc_key) -%}
|
||||
{%- set _street = _ld.address_line or "" -%}
|
||||
{%- set _city = _ld.city or "" -%}
|
||||
{%- set _label = _street -%}
|
||||
{%- if _city and _street -%}{%- set _label = _label ~ ", " ~ _city -%}
|
||||
{%- elif _city -%}{%- set _label = _city -%}{%- endif -%}
|
||||
{%- set _label = _label or _ld.location_name or loc_key -%}
|
||||
{%- endif -%}
|
||||
{%- set _ = _groups.update({loc_key: {"name": _label, "items": [], "index": {}, "subtotal_val": 0}}) -%}
|
||||
{%- endif -%}
|
||||
{%- set g = _groups[loc_key] -%}
|
||||
{%- set _ikey = _clean(it.item_name or it.description) ~ "||" ~ ("%.4f" | format((it.rate or 0)|float)) -%}
|
||||
{%- set _eidx = g["index"].get(_ikey) -%}
|
||||
{%- if _eidx is not none -%}
|
||||
{%- set _r = g["items"][_eidx] -%}
|
||||
{%- set _ = _r.update({"qty": (_r.qty or 0) + (it.qty or 0), "amount_val": (_r.amount_val or 0) + (it.amount or 0), "amount": money((_r.amount_val or 0) + (it.amount or 0))}) -%}
|
||||
{%- else -%}
|
||||
{%- set _short = frappe.call("gigafibre_utils.api.short_item_name", name=(it.item_name or it.description), max_len=48) -%}
|
||||
{%- set _desc_base = _short or _clean(it.item_name or it.description) -%}
|
||||
|
||||
{#- Per-item service period. Only set when the item CARRIES EXPLICIT
|
||||
service_start_date + service_end_date (ERPNext deferred-revenue items).
|
||||
No more defaulting to the current billing window — most items don't
|
||||
need to repeat "16 avril au 15 mai" on every line. -#}
|
||||
{%- set _period_str = "" -%}
|
||||
{%- if it.get("service_start_date") and it.get("service_end_date") -%}
|
||||
{%- set _ps = frappe.utils.getdate(it.service_start_date) -%}
|
||||
{%- set _pe = frappe.utils.getdate(it.service_end_date) -%}
|
||||
{%- set _psm = mois_fr.get(_ps.strftime("%B"), "") -%}
|
||||
{%- set _pem = mois_fr.get(_pe.strftime("%B"), "") -%}
|
||||
{%- if _ps.month == _pe.month -%}
|
||||
{%- set _period_str = (("1er " if _ps.day == 1 else (_ps.day|string ~ " "))) ~ _psm ~ " au " ~ _pe.day|string ~ " " ~ _pem -%}
|
||||
{%- else -%}
|
||||
{%- set _period_str = _ps.day|string ~ " " ~ _psm ~ " au " ~ _pe.day|string ~ " " ~ _pem -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{#- Period is stored SEPARATELY (not appended to description) so the
|
||||
template can render it once per location block instead of once per
|
||||
item — much less noise when 5 items share the same billing window. -#}
|
||||
{%- set _ = g["items"].append({
|
||||
"description": _desc_base,
|
||||
"period_str": _period_str,
|
||||
"qty": (it.qty | int if it.qty == it.qty|int else it.qty),
|
||||
"unit_price": money(it.rate),
|
||||
"amount": money(it.amount),
|
||||
"amount_val": (it.amount or 0),
|
||||
"is_credit": (it.amount or 0) < 0,
|
||||
}) -%}
|
||||
{%- set _ = g["index"].update({_ikey: g["items"]|length - 1}) -%}
|
||||
{%- endif -%}
|
||||
{%- set _ = g.update({"subtotal_val": g["subtotal_val"] + (it.amount or 0)}) -%}
|
||||
{%- endfor -%}
|
||||
{%- set current_charges_locations = [] -%}
|
||||
{%- for k in _order -%}
|
||||
{%- set g = _groups[k] -%}
|
||||
{%- set _ = current_charges_locations.append({"name": g["name"], "items": g["items"], "subtotal": money(g["subtotal_val"])}) -%}
|
||||
{%- endfor -%}
|
||||
|
||||
{% for group, amount in service_groups.items() %}
|
||||
<tr class="indent">
|
||||
<td>{{ group }}</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(amount, currency=doc.currency) }}</td>
|
||||
{#- ── Previous invoice + recent payments ──────────────────────────────────── -#}
|
||||
{%- set _prev_list = frappe.get_all("Sales Invoice",
|
||||
filters={"customer": doc.customer, "docstatus": 1, "name": ["!=", doc.name], "posting_date": ["<", doc.posting_date]},
|
||||
fields=["name","posting_date","grand_total","outstanding_amount"],
|
||||
order_by="posting_date desc", limit_page_length=1) or [] -%}
|
||||
{%- set _prev = _prev_list[0] if _prev_list else None -%}
|
||||
{%- set prev_invoice_date = date_short(_prev.posting_date) if _prev else "—" -%}
|
||||
{%- set prev_invoice_total = money(_prev.grand_total) if _prev else "0.00" -%}
|
||||
{%- set remaining_balance = money(_prev.outstanding_amount) if _prev else "0.00" -%}
|
||||
{%- set recent_payments = [] -%}
|
||||
{%- if _prev -%}
|
||||
{%- set _refs = frappe.get_all("Payment Entry Reference",
|
||||
filters={"reference_doctype":"Sales Invoice","reference_name": _prev.name},
|
||||
fields=["allocated_amount"]) or [] -%}
|
||||
{%- for r in _refs -%}
|
||||
{%- set _ = recent_payments.append({"amount": money(r.allocated_amount)}) -%}
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
|
||||
{%- set total_amount_due = money((doc.outstanding_amount or 0) + (_prev.outstanding_amount if _prev else 0)) -%}
|
||||
|
||||
{#- ── QR code (base64 data URI generated server-side) ─────────────────────── -#}
|
||||
{%- set qr_code_base64 = frappe.call("gigafibre_utils.api.invoice_qr_base64", invoice=doc.name) -%}
|
||||
|
||||
{#- ── Referral code (stable 6 chars per Customer) ──────────────────────────── -#}
|
||||
{%- set referral_code = frappe.call("gigafibre_utils.api.referral_code", customer=doc.customer) -%}
|
||||
|
||||
{#- (service-period variables moved earlier in the prelude, before the
|
||||
items loop needs them) -#}
|
||||
|
||||
{#- ═════════════════════════════════════════════════════════════════════════
|
||||
ORIGINAL PREVIEW TEMPLATE (invoice_preview.jinja), unmodified below.
|
||||
═════════════════════════════════════════════════════════════════════════ -#}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Facture {{ invoice_number }}</title>
|
||||
<style>
|
||||
/* @page: size + margins. Chromium honors `counter(page)` / `counter(pages)`
|
||||
inside @top-right to inject page numbers on multi-page overflow.
|
||||
We suppress the counter on page 1 via `@page:first` — CSS can't test
|
||||
"total pages == 1", but it CAN hide on the first page. So:
|
||||
- Single-page invoice (99% case): no "Page 1 de 1" clutter.
|
||||
- Multi-page invoice: page 2+ shows "Page 2 de 3", "Page 3 de 3", etc.
|
||||
(page 1 stays clean, which is acceptable — the header clearly marks
|
||||
the document's first page). */
|
||||
@page {
|
||||
size: Letter;
|
||||
margin: 10mm 15mm 8mm 20mm;
|
||||
@top-right {
|
||||
content: "Page " counter(page) " de " counter(pages);
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 7pt;
|
||||
color: #888;
|
||||
padding-top: 5mm;
|
||||
}
|
||||
}
|
||||
@page:first {
|
||||
@top-right { content: ""; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
/* ⚠️ Kill Frappe's aggressive `!important` overrides:
|
||||
1. `.print-format { padding: 0.75in }` shoves content ~19mm down.
|
||||
2. `.print-format td img { width: 100% !important }` stretches inline
|
||||
QR image to the full td width, breaking its square aspect.
|
||||
3. `.print-format td { padding: 10px !important }` bloats row heights
|
||||
AND eats ~20mm of horizontal space from our 2-column main layout
|
||||
(10px each side × 2 cols × 2 edges ≈ 40px ≈ 10mm per column).
|
||||
Fix: blanket-reset ALL td/th padding to 0 with !important, then let
|
||||
each specific table add back exactly the padding it needs (again
|
||||
with !important). Same specificity (0,1,1) vs Frappe's (0,1,1),
|
||||
ours is declared later in the cascade → wins. */
|
||||
.print-format { padding: 0 !important; margin: 0 !important; max-width: none !important; }
|
||||
.print-format img, .print-format td img { width: auto !important; max-width: 100% !important; height: auto !important; }
|
||||
.print-format img.qr-img, .print-format td img.qr-img { width: 40px !important; height: 40px !important; max-width: 40px !important; }
|
||||
/* Blanket neutralisation of Frappe's default td/th padding. Every table
|
||||
below re-adds its own padding in its own rule. */
|
||||
.print-format td, .print-format th { padding: 0 !important; }
|
||||
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 8pt; color: #333; line-height: 1.2; margin: 0; }
|
||||
@media screen {
|
||||
body { padding: 40px; max-width: 8.5in; margin: 0 auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); background: #fff; }
|
||||
html { background: #f0f0f0; }
|
||||
}
|
||||
|
||||
/* ── Pre-envelope-window summary (previous balance + payments) ──
|
||||
Sits between the logo and the address. Hidden by the envelope when
|
||||
folded; becomes the first thing the customer reads after opening.
|
||||
Collapsed to minimum height: 3 rows + title ≈ 11-12mm so the address
|
||||
still lands at the ~2" envelope-window top. */
|
||||
.pre-window { margin-top: 4mm; }
|
||||
.pw-title { font-size: 6.5pt; font-weight: 700; color: #019547; border-bottom: 1px solid #019547; padding-bottom: 1px; margin-bottom: 1px; letter-spacing: 0.3px; line-height: 1.1; }
|
||||
.pw-table { width: 100%; font-size: 7pt; line-height: 1.25; border-collapse: collapse; }
|
||||
.pw-table td { padding: 1px 2px !important; vertical-align: baseline; line-height: 1.25; }
|
||||
.pw-table td.r { text-align: right; white-space: nowrap; }
|
||||
.pw-table tr.pmt td { color: #019547; }
|
||||
.pw-table tr.bal td { font-style: italic; color: #666; border-bottom: 1px solid #eee; }
|
||||
|
||||
/* ── Metadata Elements ── */
|
||||
.doc-label { font-size: 14pt; font-weight: 700; color: #019547; }
|
||||
.doc-page { font-size: 7pt; color: #888; }
|
||||
|
||||
/* ── Meta band (under logo) ── */
|
||||
.meta-band { width: 100%; padding: 6px 0; border-top: 1px solid #eee; border-bottom: 1px solid #eee; margin: 15px 0 0 0; }
|
||||
.meta-band td { vertical-align: middle; padding: 0 6px; text-align: center; }
|
||||
.meta-band .ml { color: #888; display: block; font-size: 5.5pt; text-transform: uppercase; margin-bottom: 2px; }
|
||||
.meta-band .mv { font-weight: 700; color: #333; font-size: 7pt; }
|
||||
|
||||
/* ── Main 2-column layout ──
|
||||
Letter content width (after @page margins 20mm L + 15mm R) = 180.9mm.
|
||||
Items list is the MAIN focus — left column dominates at 66%. Right
|
||||
column is 1/3 of usable width (= 60.3mm ≈ 2.37"). r-meta (account/
|
||||
date/invoice#) is stacked vertically as label|value rows since 3
|
||||
side-by-side cells no longer fit in 60mm.
|
||||
col-l = 66% → 119.4mm (4.70")
|
||||
spacer = 1% → 1.8mm (0.07")
|
||||
col-r = 33% → 59.7mm (2.35" = 1/3 usable width)
|
||||
`table-layout: fixed` forces the browser to honour declared widths
|
||||
instead of auto-sizing to content. */
|
||||
.main { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
|
||||
.main td { vertical-align: top; }
|
||||
.col-l { width: 66%; }
|
||||
.col-spacer { width: 1%; }
|
||||
.col-r { width: 33%; }
|
||||
|
||||
/* ── Envelope-window block — address only.
|
||||
Left column has logo (~7mm) + pre-window SOMMAIRE (~11-12mm, 3 rows:
|
||||
solde précédent / paiement reçu / solde reporté). margin-top is set
|
||||
inline on the element (30mm with prev, 33mm without — both target the
|
||||
~2" / 51mm envelope-window top counting from page-margin origin);
|
||||
margin-bottom 10mm clears FRAIS COURANTS past the window bottom. */
|
||||
.cl-block { margin-bottom: 6mm; padding-left: 4px; }
|
||||
.cl-lbl { font-size: 7pt; color: #888; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 1px; }
|
||||
.cl-name { font-size: 11pt; font-weight: 700; color: #222; line-height: 1.15; }
|
||||
.cl-addr { font-size: 10pt; color: #333; line-height: 1.2; }
|
||||
|
||||
/* ── Left: summary ── */
|
||||
.summary-hdr { font-size: 8pt; font-weight: 700; color: #111;
|
||||
background-color: #eef9f3; print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
||||
padding: 3px 6px; margin-bottom: 2px; line-height: 1; }
|
||||
.section-hdr { font-weight: 600; font-size: 7.5pt; padding-top: 3px; margin-bottom: 1px; }
|
||||
.addr-hdr { font-weight: 600; font-size: 6.5pt; color: #111;
|
||||
background-color: #eef9f3; print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
||||
padding: 2px 6px; margin-top: 3px; line-height: 1.2; }
|
||||
.addr-hdr .addr-period { color: #555; font-weight: 400; font-size: 6pt; margin-left: 3px; }
|
||||
/* table-layout: auto so colspan="2" sep rows span cleanly without
|
||||
Chromium forcing a phantom column boundary at the 86% mark. */
|
||||
.dtl { width: 100%; border-collapse: collapse; font-size: 7.5pt; margin-bottom: 2px; line-height: 1.35; }
|
||||
.dtl th.r, .dtl td.r { text-align: right; white-space: nowrap; }
|
||||
/* Item rows — white background; green is reserved for section/address headers. */
|
||||
.dtl td { padding: 3px 6px 1px !important; vertical-align: baseline; line-height: 1.35; }
|
||||
/* Separator row: block-level <div class="sep-line"> draws the dotted line —
|
||||
immune to border-collapse column-splitting, one continuous vector line. */
|
||||
.dtl tr.sep td { height: 3px; padding: 0 !important; line-height: 0; font-size: 0; border: none !important; }
|
||||
.sep-line { border-top: 1px solid #e5e7eb; height: 0; margin: 0; padding: 0; }
|
||||
/* Description column wraps instead of ellipsis-truncating. */
|
||||
.dtl td:first-child { white-space: normal; word-break: break-word; }
|
||||
.qty-inline { color: #888; font-size: 7pt; }
|
||||
.dtl tr.credit td { color: #019547; }
|
||||
/* Totals block: gray background for subtotal + grand total rows. */
|
||||
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #d1d5db; padding-top: 3px !important;
|
||||
background: #f3f4f6 !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.dtl tr.tax td { color: #666; font-size: 6.5pt; padding: 1px 6px !important; background: #fff !important; }
|
||||
.dtl tr.grand td { font-weight: 700; font-size: 8pt; border-top: 1px solid #019547; padding-top: 4px !important;
|
||||
background: #e5e7eb !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
/* Keep totals block together so an orphan row never lands alone on page 2. */
|
||||
.dtl.totals { page-break-inside: avoid; break-inside: avoid; }
|
||||
.dtl tr.taxnums-row td { font-size: 6pt; color: #999; padding: 2px 6px 3px !important; text-align: left; background: #fff !important; }
|
||||
|
||||
/* ── Right column: all content in a bordered container ── */
|
||||
.r-container { border: 1px solid #e5e7eb; border-left: 2px solid #019547; }
|
||||
.r-section { padding: 6px 8px; }
|
||||
.r-section.r-meta { padding: 4px 8px; background: #f8fafc; }
|
||||
/* r-meta: label | value rows (stacked) — fits in the narrow 45mm col-r
|
||||
where the old 3-column side-by-side layout couldn't. Label left,
|
||||
value right, each pair on its own row. */
|
||||
.r-meta-table { width: 100%; border-collapse: collapse; }
|
||||
.r-meta-table td { vertical-align: baseline; line-height: 1.25; padding: 1px 0; font-size: 7pt; }
|
||||
.r-meta-table td.ml { color: #888; font-size: 5.5pt; text-transform: uppercase; letter-spacing: 0.3px; text-align: left; }
|
||||
.r-meta-table td.mv { font-weight: 700; color: #333; white-space: nowrap; text-align: right; }
|
||||
.r-section + .r-section { border-top: 1px solid #e5e7eb; }
|
||||
.r-section.r-green { background: #e8f5ee; }
|
||||
.r-section.r-gray { background: #f8f9fa; }
|
||||
/* Amount due */
|
||||
.ab-label { font-size: 8.5pt; font-weight: 600; color: #019547; line-height: 1.1; }
|
||||
.ab-val { font-size: 18pt; font-weight: 700; color: #222; text-align: right; white-space: nowrap; line-height: 1.05; }
|
||||
.ab-date { font-size: 7pt; color: #555; margin-top: 1px; }
|
||||
.ab-table { width: 100%; border-collapse: collapse; }
|
||||
.ab-table td { padding: 0; border: none; vertical-align: middle; }
|
||||
/* QR */
|
||||
.qr-table { width: 100%; border-collapse: collapse; }
|
||||
.qr-table td { padding: 1px 6px; border: none; vertical-align: middle; font-size: 7.5pt; line-height: 1.2; }
|
||||
.qr-placeholder { width: 40px; height: 40px; background: #ccc; font-size: 5pt; color: #888; text-align: center; line-height: 40px; }
|
||||
/* Info block */
|
||||
.r-info-text { font-size: 6.5pt; color: #555; line-height: 1.25; }
|
||||
.r-info-text strong { color: #333; }
|
||||
.r-info-text .ri-title { font-weight: 700; font-size: 7pt; color: #333; margin-bottom: 2px; }
|
||||
.r-section.r-cprst { font-size: 5.5pt; padding: 3px 10px; color: #888; background: #fafbfc; border-top: 1px solid #e5e7eb; line-height: 1.2; }
|
||||
/* Referral program — light gray background, green accent on the code */
|
||||
.r-section.r-referral { background: #f8fafc; font-size: 7pt; color: #333; line-height: 1.2; }
|
||||
.r-section.r-referral .ri-title { font-weight: 700; font-size: 7.5pt; color: #019547; margin-bottom: 2px; }
|
||||
.ref-code-box { display: flex; align-items: center; gap: 8px; background: #fff; border: 1.5px solid #019547; border-radius: 4px; padding: 3px 8px; margin-top: 3px; box-shadow: 0 0 0 2px #e8f5ee; }
|
||||
.ref-code-label { font-size: 6pt; color: #888; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.ref-code { font-size: 13pt; font-weight: 700; color: #019547; letter-spacing: 2px; font-family: 'SF Mono', 'Menlo', 'Courier New', monospace; margin-left: auto; line-height: 1.1; }
|
||||
.r-section.r-referral small { display: block; font-size: 5.5pt; color: #019547; margin-top: 3px; font-weight: 600; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer-line { font-size: 6pt; color: #bbb; text-align: center; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════ HEADER & MAIN 2 COLUMNS ═══════ -->
|
||||
<div>
|
||||
<table class="main"><tr>
|
||||
|
||||
<!-- ── LEFT 58% (~105mm = 4.13") ── -->
|
||||
<td class="col-l">
|
||||
|
||||
<!-- ── FIXED-HEIGHT HEADER ZONE ──
|
||||
Logo + optional SOMMAIRE DU COMPTE live here. Height is HARDCODED
|
||||
regardless of SOMMAIRE presence, so the address block below ALWAYS
|
||||
lands at the same Y. Robust to variations (no prev invoice / etc.).
|
||||
30mm + @page margin 10mm + cl-block margin 3mm + FACTURER label
|
||||
= "Les..." at ~153pt (2.13") — matches v4 reference pixel-perfect. -->
|
||||
<div class="top-zone" style="height: 30mm; overflow: hidden;">
|
||||
<div style="height: 26px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" style="height: 26px; width: auto;"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
|
||||
</div>
|
||||
<!-- Pre-envelope-window zone — only rendered if there is a previous
|
||||
invoice. Hidden by the envelope when folded; first thing the
|
||||
customer reads once opened. -->
|
||||
{% if prev_invoice_date and prev_invoice_date != '—' %}
|
||||
<div class="pre-window">
|
||||
<div class="pw-title">{% if _is_en %}ACCOUNT SUMMARY{% else %}SOMMAIRE DU COMPTE{% endif %}</div>
|
||||
<table class="pw-table">
|
||||
<tr>
|
||||
<td>{% if _is_en %}Previous balance{% else %}Solde précédent{% endif %} ({{ prev_invoice_date }})</td>
|
||||
<td class="r">$ {{ prev_invoice_total }}</td>
|
||||
</tr>
|
||||
{% for payment in recent_payments %}
|
||||
<tr class="pmt"><td>{% if _is_en %}Payment received — thank you{% else %}Paiement reçu — merci{% endif %}</td><td class="r">− $ {{ payment.amount }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="bal"><td>{% if _is_en %}Carried forward{% else %}Solde reporté{% endif %}</td><td class="r">$ {{ remaining_balance }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div><!-- /top-zone -->
|
||||
|
||||
<!-- Address block — ALWAYS at same Y thanks to the fixed-height
|
||||
top-zone above. With @page margin-top=10mm + top-zone 38mm + this
|
||||
margin-top 3mm → "Les..." renders at ~51mm = 2" from page top
|
||||
edge (envelope window opening). -->
|
||||
<div class="cl-block" style="margin-top: 3mm;">
|
||||
<div class="cl-lbl">{% if _is_en %}Bill to{% else %}Facturer à{% endif %}</div>
|
||||
<div class="cl-name">{{ client_name }}</div>
|
||||
<div class="cl-addr">
|
||||
{{ client_address_line1 }}<br>
|
||||
{{ client_address_line2 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current charges -->
|
||||
<div class="summary-hdr">{% if _is_en %}CURRENT CHARGES{% else %}FRAIS COURANTS{% endif %}</div>
|
||||
|
||||
{% for location in current_charges_locations %}
|
||||
{#- Collect distinct non-empty periods from this location's items. If
|
||||
they're all the same, show once in the address header. If they
|
||||
differ (rare), append to each item's description as before. -#}
|
||||
{%- set _periods = [] -%}
|
||||
{%- for _it in location['items'] -%}
|
||||
{%- if _it.period_str and _it.period_str not in _periods -%}
|
||||
{%- set _ = _periods.append(_it.period_str) -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<div class="addr-hdr">◎ {{ location.name }}{% if _periods|length == 1 %} <span class="addr-period">· {{ _periods[0] }}</span>{% endif %}</div>
|
||||
<table class="dtl">
|
||||
<colgroup>
|
||||
<col style="width:86%">
|
||||
<col style="width:14%">
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{% for item in location['items'] %}
|
||||
<tr class="{% if item.is_credit %}credit{% endif %}">
|
||||
<td>{{ item.description }}{% if _periods|length > 1 and item.period_str %} <span class="qty-inline">({{ item.period_str }})</span>{% endif %}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %}</td>
|
||||
<td class="r">{{ item.amount }}</td>
|
||||
</tr>
|
||||
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<tr class="indent">
|
||||
<td>Sous-total avant taxes</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(doc.net_total, currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
{% for tax in doc.taxes %}
|
||||
<tr class="indent">
|
||||
<td>{{ "TPS" if "TPS" in (tax.description or tax.account_head or "") else "TVQ" }} ({{ tax.rate }}%)</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(tax.tax_amount, currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="subtot">
|
||||
<td>Total du mois courant</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
<table class="dtl totals" style="margin-top:3px;">
|
||||
<colgroup><col style="width:82%"><col style="width:18%"></colgroup>
|
||||
<tr class="stot"><td>{% if _is_en %}Subtotal before tax{% else %}Sous-total avant taxes{% endif %}</td><td class="r">$ {{ subtotal_before_taxes }}</td></tr>
|
||||
<tr class="tax"><td>{% if _is_en %}Tax: GST ${{ tps_amount }} · QST ${{ tvq_amount }}{% else %}Taxes : TPS ${{ tps_amount }} · TVQ ${{ tvq_amount }}{% endif %}</td><td class="r">$ {{ total_taxes }}</td></tr>
|
||||
<tr class="grand"><td>TOTAL</td><td class="r">$ {{ current_charges_total }}</td></tr>
|
||||
<tr class="taxnums-row"><td colspan="2">TPS: 834975559RT0001 | TVQ: 1213765929TQ0001</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
<!-- ── SPACER: 2% of content width ≈ 3.6mm ≈ 1/7 inch.
|
||||
Padding on this td is zeroed by the blanket `.print-format td { padding: 0 }`
|
||||
rule so the declared 2% width actually delivers the visible gap. -->
|
||||
<td class="col-spacer"></td>
|
||||
|
||||
<!-- ── RIGHT 40%: all in one bordered container ── -->
|
||||
<td class="col-r">
|
||||
<!-- Document title: FACTURE / INVOICE / NOTE DE CRÉDIT / CREDIT NOTE
|
||||
driven by _is_en + doc.is_return set in the Jinja prelude above. -->
|
||||
<div style="text-align: right; margin-bottom: 3px;">
|
||||
<div class="doc-label" style="line-height:1;">{{ doc_title }}</div>
|
||||
</div>
|
||||
|
||||
<div class="r-container">
|
||||
|
||||
<!-- Facture identifiers: stacked label|value rows so a long account
|
||||
number ("C-041452257623184") doesn't force col-r wider than the
|
||||
60mm target. Values right-aligned so the digits line up visually. -->
|
||||
<div class="r-section r-meta">
|
||||
<table class="r-meta-table">
|
||||
<tr><td class="ml">{% if _is_en %}Invoice #{% else %}Nº facture{% endif %}</td><td class="mv">{{ invoice_number }}</td></tr>
|
||||
<tr><td class="ml">Date</td><td class="mv">{{ invoice_date }}</td></tr>
|
||||
<tr><td class="ml">{% if _is_en %}Account #{% else %}Nº compte{% endif %}</td><td class="mv">{{ account_number }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- CONTACT -->
|
||||
<table class="contact-table"><tr>
|
||||
<td>
|
||||
<strong style="color:#333">Contactez-nous</strong><br>
|
||||
{{ company_tel }}<br>
|
||||
{{ company_web }}
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<strong style="color:#333">Service à la clientèle</strong><br>
|
||||
Lun-Ven 8h-17h<br>
|
||||
info@gigafibre.ca
|
||||
</td>
|
||||
</tr></table>
|
||||
<!-- (Sommaire du compte moved to the LEFT pre-window zone —
|
||||
right column stays action-focused: amount + QR + contact.) -->
|
||||
|
||||
<!-- QR CODE -->
|
||||
<div class="qr-wrap">
|
||||
<table><tr>
|
||||
<td><div class="qr-box"><br>QR</div></td>
|
||||
<td><strong>Payez en ligne</strong><br>Scannez le code QR ou visitez<br><strong style="color:{{ brand_green }}">{{ company_web }}/payer</strong></td>
|
||||
<!-- Amount due -->
|
||||
<div class="r-section r-green">
|
||||
<table class="ab-table"><tr>
|
||||
<td><div class="ab-label">{% if _is_en %}Amount due{% else %}Montant dû{% endif %}</div><div class="ab-date">{% if _is_en %}before{% else %}avant le{% endif %} {{ due_date }}</div></td>
|
||||
<td class="ab-val">$ {{ total_amount_due }}</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<!-- COUPON DÉTACHABLE -->
|
||||
<div class="coupon-line">✂ Prière d'expédier cette partie avec votre paiement</div>
|
||||
<table class="coupon-table"><tr>
|
||||
<td class="c-logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
|
||||
<td><div class="c-lbl">Montant versé</div><div class="c-val">________</div></td>
|
||||
<td><div class="c-lbl">Nº de compte</div><div class="c-val">{{ doc.customer }}</div></td>
|
||||
<td><div class="c-lbl">Date d'échéance</div><div class="c-val">{{ date_short(doc.due_date) }}</div></td>
|
||||
<td><div class="c-lbl">Montant à payer</div><div class="c-val">{{ frappe.utils.fmt_money(doc.grand_total | abs, currency=doc.currency) }}</div></td>
|
||||
<!-- QR -->
|
||||
<div class="r-section r-green">
|
||||
<table class="qr-table"><tr>
|
||||
<td style="width:46px;">
|
||||
{% if qr_code_base64 %}
|
||||
<img class="qr-img" src="data:image/png;base64,{{ qr_code_base64 }}" style="width:40px !important; height:40px !important; max-width:40px !important;" />
|
||||
{% else %}
|
||||
<div class="qr-placeholder">QR</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{% if _is_en %}Pay online{% else %}Payez en ligne{% endif %}</strong><br>{% if _is_en %}Scan the QR code or visit{% else %}Scannez le code QR ou visitez{% endif %}<br><strong style="color:#019547">client.gigafibre.ca</strong></td>
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<!-- ADRESSE FENÊTRE ENVELOPPE -->
|
||||
<div class="env-addr">
|
||||
<strong>{{ doc.customer_name or doc.customer }}</strong><br>
|
||||
{% if cust_addr %}
|
||||
{{ cust_addr | striptags | replace("\n","<br>") }}
|
||||
<!-- Referral program — URL now lives INSIDE the descriptive sentence
|
||||
(as a call-to-action), not below the code box where it had no
|
||||
visual connection to the rest of the content. -->
|
||||
<div class="r-section r-referral">
|
||||
{% if _is_en %}
|
||||
Refer a friend and each receive <strong>$50 credit</strong> on your monthly bill · <a href="https://targo.ca/parrainage" style="color:#019547; text-decoration:none; font-weight:600;">targo.ca/parrainage</a>
|
||||
{% else %}
|
||||
Référez un ami et recevez <strong>chacun 50 $ de crédit</strong> sur votre facture mensuelle · <a href="https://targo.ca/parrainage" style="color:#019547; text-decoration:none; font-weight:600;">targo.ca/parrainage</a>
|
||||
{% endif %}
|
||||
<div class="ref-code-box">
|
||||
<span class="ref-code-label">{% if _is_en %}Your code{% else %}Votre code{% endif %}</span>
|
||||
<span class="ref-code">{{ referral_code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notices & conditions -->
|
||||
<div class="r-section r-info-text">
|
||||
{% if _is_en %}
|
||||
By paying this invoice, you accept our <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">terms and conditions</a>.<br><br>
|
||||
Please note that any invoice not paid by the due date will be subject to a late payment fee.
|
||||
{% else %}
|
||||
En payant cette facture, vous acceptez nos <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">termes et conditions</a>.<br><br>
|
||||
Prendre note que toute facture non acquittée à la date d'échéance sera assujettie à des frais de retard.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="footer-line">{{ company_name }} • {{ company_addr }}, {{ company_city }} • {{ company_tel }}</div>
|
||||
<!-- Contact info + TARGO coordinates -->
|
||||
<div class="r-section r-gray r-info-text">
|
||||
<div class="ri-title">{% if _is_en %}Contact us{% else %}Contactez-nous{% endif %}</div>
|
||||
<strong>TARGO Communications Inc.</strong><br>
|
||||
1867 chemin de la Rivière<br>
|
||||
Sainte-Clotilde, QC J0L 1W0<br>
|
||||
<strong>855 888-2746</strong> · {% if _is_en %}Mon-Fri 8am-5pm{% else %}Lun-Ven 8h-17h{% endif %}<br>
|
||||
info@targo.ca • www.targo.ca
|
||||
</div>
|
||||
|
||||
<!-- ═══════ PAGE 2: DÉTAILS ═══════ -->
|
||||
<div class="page-break"></div>
|
||||
<!-- CPRST notice (required by law, kept compact) -->
|
||||
<div class="r-section r-info-text r-cprst">
|
||||
{% if _is_en %}
|
||||
Complaint about your telecom or TV service? The CCTS can help you for free: <strong>www.ccts-cprst.ca</strong>.
|
||||
{% else %}
|
||||
Plainte relative à votre service de télécommunication ou de télévision? La CPRST peut vous aider sans frais : <strong>www.ccts-cprst.ca</strong>.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table class="hdr-table"><tr>
|
||||
<td class="logo"><img src="/files/targo-logo-green.svg" alt="TARGO"></td>
|
||||
<td style="text-align:right; font-size:8pt; color:#888">
|
||||
Nº de facture: <strong>{{ doc.name }}</strong><br>
|
||||
Nº de compte: <strong>{{ doc.customer }}</strong><br>
|
||||
Date: {{ date_fr(doc.posting_date) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr></table>
|
||||
|
||||
<div class="detail-title">DÉTAILS DE LA FACTURE</div>
|
||||
|
||||
<table class="dtl">
|
||||
<thead><tr>
|
||||
<th style="width:50%">Description</th>
|
||||
<th class="r">Qté</th>
|
||||
<th class="r">Prix unit.</th>
|
||||
<th class="r">Montant</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for item in doc.items %}
|
||||
<tr>
|
||||
<td>{{ clean(item.item_name or item.item_code) }}</td>
|
||||
<td class="r">{{ item.qty | int if item.qty == (item.qty | int) else item.qty }}</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(item.rate, currency=doc.currency) }}</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(item.amount, currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="stot">
|
||||
<td colspan="3">Sous-total avant taxes</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(doc.net_total, currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
{% for tax in doc.taxes %}
|
||||
<tr class="tax">
|
||||
<td colspan="3">{{ "TPS" if "TPS" in (tax.description or tax.account_head or "") else "TVQ" }} ({{ tax.rate }}%)</td>
|
||||
<td class="r">{{ frappe.utils.fmt_money(tax.tax_amount, currency=doc.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="stot">
|
||||
<td colspan="3"><strong>TOTAL</strong></td>
|
||||
<td class="r"><strong>{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- FOOTER PAGE 2 -->
|
||||
<div style="margin-top:30px; text-align:center;">
|
||||
<img src="/files/targo-logo-green.svg" alt="TARGO" style="height:24px; opacity:0.25">
|
||||
<div class="footer-line">{{ company_name }} • {{ company_addr }}, {{ company_city }} • {{ company_tel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- (Company address moved to the Contactez-nous section in the right column) -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
"""
|
||||
|
||||
# Create or update the Print Format
|
||||
# Print Format options shared between create/update paths. pdf_generator="chrome"
|
||||
# uses Chromium headless for pixel-accurate modern-CSS rendering (matches the
|
||||
# Antigravity / browser preview). Chromium must be installed in the container;
|
||||
# see /opt/erpnext/custom/Dockerfile (apt-get install chromium) and
|
||||
# common_site_config.json → "chromium_path": "/usr/bin/chromium".
|
||||
#
|
||||
# Margins are explicit — Chrome PDF generator IGNORES `@page { margin: … }`
|
||||
# in the CSS (preferCSSPageSize=False). We keep 5mm top/bottom so the client
|
||||
# address stays aligned with the #10 envelope window (~2" from top edge).
|
||||
_PF_OPTIONS = {
|
||||
"print_format_type": "Jinja",
|
||||
"standard": "No",
|
||||
"custom_format": 1,
|
||||
"pdf_generator": "chrome",
|
||||
"margin_top": 5,
|
||||
"margin_bottom": 5,
|
||||
"margin_left": 15,
|
||||
"margin_right": 15,
|
||||
"disabled": 0,
|
||||
}
|
||||
|
||||
# ── Create or update the Print Format ──
|
||||
if frappe.db.exists("Print Format", PRINT_FORMAT_NAME):
|
||||
doc = frappe.get_doc("Print Format", PRINT_FORMAT_NAME)
|
||||
doc.html = html_template
|
||||
doc.save(ignore_permissions=True)
|
||||
print(f" Updated Print Format: {PRINT_FORMAT_NAME}")
|
||||
pf = frappe.get_doc("Print Format", PRINT_FORMAT_NAME)
|
||||
pf.html = html_template
|
||||
for k, v in _PF_OPTIONS.items():
|
||||
setattr(pf, k, v)
|
||||
pf.save(ignore_permissions=True)
|
||||
print(f" Updated Print Format: {PRINT_FORMAT_NAME} ({len(html_template)} bytes)")
|
||||
else:
|
||||
doc = frappe.get_doc({
|
||||
pf = frappe.get_doc({
|
||||
"doctype": "Print Format",
|
||||
"name": PRINT_FORMAT_NAME,
|
||||
"__newname": PRINT_FORMAT_NAME,
|
||||
"doc_type": "Sales Invoice",
|
||||
"module": "Accounts",
|
||||
"print_format_type": "Jinja",
|
||||
"standard": "No",
|
||||
"custom_format": 1,
|
||||
**_PF_OPTIONS,
|
||||
"html": html_template,
|
||||
"default_print_language": "fr",
|
||||
"disabled": 0,
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
print(f" Created Print Format: {PRINT_FORMAT_NAME}")
|
||||
pf.insert(ignore_permissions=True)
|
||||
print(f" Created Print Format: {PRINT_FORMAT_NAME} ({len(html_template)} bytes)")
|
||||
|
||||
# Set as default for Sales Invoice
|
||||
frappe.db.set_value("Property Setter", None, "value", PRINT_FORMAT_NAME, {
|
||||
# ── Set as default print format for Sales Invoice ──
|
||||
try:
|
||||
frappe.db.set_value(
|
||||
"Property Setter", None, "value", PRINT_FORMAT_NAME,
|
||||
{
|
||||
"doctype_or_field": "DocType",
|
||||
"doc_type": "Sales Invoice",
|
||||
"property": "default_print_format",
|
||||
})
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" (info) could not set default print format: {e}")
|
||||
|
||||
# ── Disable the legacy Server Script "invoice_qr_code" if still around ──
|
||||
# QR generation now lives in the custom app `gigafibre_utils` (whitelisted
|
||||
# method `gigafibre_utils.api.invoice_qr_base64`). The old Server Script
|
||||
# crashed under safe_exec because it tried `import qrcode`.
|
||||
if frappe.db.exists("Server Script", "invoice_qr_code"):
|
||||
ss = frappe.get_doc("Server Script", "invoice_qr_code")
|
||||
if not ss.disabled:
|
||||
ss.disabled = 1
|
||||
ss.save(ignore_permissions=True)
|
||||
print(" Disabled legacy Server Script: invoice_qr_code "
|
||||
"(superseded by gigafibre_utils.api.invoice_qr_base64)")
|
||||
|
||||
frappe.db.commit()
|
||||
print(f" Done! Print Format '{PRINT_FORMAT_NAME}' ready.")
|
||||
print(f"\n Done. Print Format '{PRINT_FORMAT_NAME}' ready.")
|
||||
print(" Dependency: custom app `gigafibre_utils` must be installed "
|
||||
"(bench --site erp.gigafibre.ca install-app gigafibre_utils).")
|
||||
print(" Preview: ERPNext → Sales Invoice → Print → Select 'Facture TARGO'")
|
||||
|
|
|
|||
480
scripts/migration/setup_quote_print_format.py
Normal file
480
scripts/migration/setup_quote_print_format.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
"""
|
||||
Create / update the custom Print Format "Soumission TARGO" on ERPNext.
|
||||
|
||||
Design
|
||||
------
|
||||
- Same CSS as "Facture TARGO" (invoice) — consistent branding.
|
||||
- Doctype: Quotation.
|
||||
- Right column: total estimé + QR code → DocuSeal signing link + "Signer" CTA.
|
||||
No payment section, no SOMMAIRE DU COMPTE, no CPRST notice.
|
||||
- Language-aware: SOUMISSION (fr) / QUOTE (en) driven by Customer.language.
|
||||
- Items grouped by Quotation Item.service_location (custom field, optional —
|
||||
falls back gracefully if the field is absent on the child table).
|
||||
- QR code links to DocuSeal signing URL stored in custom field
|
||||
`custom_docuseal_signing_url` on the Quotation. If blank, a placeholder
|
||||
is shown (URL will be populated by n8n after envelope creation).
|
||||
|
||||
Custom fields required on Quotation (create via ERPNext Customize Form):
|
||||
- custom_docuseal_signing_url (Data, read-only) — populated by n8n
|
||||
- custom_quote_type (Select: residential/commercial)
|
||||
- custom_docuseal_envelope_id (Data, read-only)
|
||||
|
||||
Run (inside erpnext-backend-1)
|
||||
------------------------------
|
||||
docker cp scripts/migration/setup_quote_print_format.py \\
|
||||
erpnext-backend-1:/tmp/setup_quote_print_format.py
|
||||
docker exec erpnext-backend-1 \\
|
||||
/home/frappe/frappe-bench/env/bin/python /tmp/setup_quote_print_format.py
|
||||
"""
|
||||
import os
|
||||
|
||||
os.chdir("/home/frappe/frappe-bench/sites")
|
||||
import frappe
|
||||
|
||||
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||
frappe.connect()
|
||||
print("Connected:", frappe.local.site)
|
||||
|
||||
PRINT_FORMAT_NAME = "Soumission TARGO"
|
||||
|
||||
html_template = r"""{#- ══════════════════════════════════════════════════════════════════════════
|
||||
Soumission TARGO — ERPNext Print Format (Quotation)
|
||||
|
||||
Same CSS as Facture TARGO; right column replaced with DocuSeal signing CTA.
|
||||
Depends on:
|
||||
- custom app `gigafibre_utils` (QR + logo + short_item_name helpers)
|
||||
- PDF generator set to `chrome`
|
||||
- Custom fields on Quotation: custom_docuseal_signing_url, custom_quote_type
|
||||
══════════════════════════════════════════════════════════════════════════ -#}
|
||||
|
||||
{%- macro money(v) -%}{{ "%.2f"|format((v or 0)|float) }}{%- endmacro -%}
|
||||
{%- macro _clean(s) -%}{{ (s or "") | replace("'","'") | replace("&","&") | replace("<","<") | replace(">",">") | replace(""",'"') }}{%- endmacro -%}
|
||||
|
||||
{%- set mois_fr = {"January":"janvier","February":"février","March":"mars","April":"avril","May":"mai","June":"juin","July":"juillet","August":"août","September":"septembre","October":"octobre","November":"novembre","December":"décembre"} -%}
|
||||
{%- macro date_short(d) -%}
|
||||
{%- if d -%}{%- set dt = frappe.utils.getdate(d) -%}{{ "%02d/%02d/%04d" | format(dt.day, dt.month, dt.year) }}{%- else -%}—{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
{%- macro date_fr(d) -%}
|
||||
{%- if d -%}{%- set dt = frappe.utils.getdate(d) -%}{{ dt.day }} {{ mois_fr.get(dt.strftime("%B"), dt.strftime("%B")) }} {{ dt.year }}{%- else -%}—{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{#- ── Core variables ── -#}
|
||||
{%- set quote_number = doc.name -%}
|
||||
{%- set quote_date = date_short(doc.transaction_date) -%}
|
||||
{%- set customer_id = (doc.party_name if doc.quotation_to == "Customer" else "") or "" -%}
|
||||
{%- set account_number = customer_id -%}
|
||||
{%- set client_name = doc.customer_name -%}
|
||||
{%- set valid_till = date_fr(doc.valid_till) if doc.valid_till else "—" -%}
|
||||
{%- set signing_url = doc.get("custom_docuseal_signing_url") or "" -%}
|
||||
|
||||
{#- ── Client address ── -#}
|
||||
{%- set client_address_line1 = "" -%}
|
||||
{%- set client_address_line2 = "" -%}
|
||||
{%- if doc.customer_address -%}
|
||||
{%- set a = frappe.get_doc("Address", doc.customer_address) -%}
|
||||
{%- set client_address_line1 = a.address_line1 or "" -%}
|
||||
{%- set _city = (a.city or "") -%}
|
||||
{%- set _state = (a.state or "") -%}
|
||||
{%- set _pin = (a.pincode or "") -%}
|
||||
{%- set client_address_line2 = (_city ~ (", " if _city and _state else "") ~ _state ~ (" " if _pin else "") ~ _pin) | trim -%}
|
||||
{%- endif -%}
|
||||
|
||||
{#- ── Language detection ── -#}
|
||||
{%- set _cust_lang = ((frappe.db.get_value("Customer", customer_id, "language") if customer_id else None) or "fr") | lower -%}
|
||||
{%- set _is_en = _cust_lang[:2] == "en" -%}
|
||||
{%- set doc_title = "QUOTE" if _is_en else "SOUMISSION" -%}
|
||||
|
||||
{#- ── TPS / TVQ split ── -#}
|
||||
{%- set ns = namespace(tps=0, tvq=0) -%}
|
||||
{%- for t in doc.taxes or [] -%}
|
||||
{%- set _desc = ((t.description or "") ~ " " ~ (t.account_head or "")) | upper -%}
|
||||
{%- if "TPS" in _desc or "GST" in _desc or "HST" in _desc -%}
|
||||
{%- set ns.tps = ns.tps + (t.tax_amount or 0) -%}
|
||||
{%- elif "TVQ" in _desc or "QST" in _desc or "PST" in _desc -%}
|
||||
{%- set ns.tvq = ns.tvq + (t.tax_amount or 0) -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if (ns.tps + ns.tvq) == 0 and (doc.total_taxes_and_charges or 0) > 0 -%}
|
||||
{%- set _tot = doc.total_taxes_and_charges -%}
|
||||
{%- set ns.tps = (_tot * 5.0 / 14.975) | round(2) -%}
|
||||
{%- set ns.tvq = _tot - ns.tps -%}
|
||||
{%- endif -%}
|
||||
{%- set tps_amount = money(ns.tps) -%}
|
||||
{%- set tvq_amount = money(ns.tvq) -%}
|
||||
{%- set total_taxes = money(doc.total_taxes_and_charges) -%}
|
||||
{%- set subtotal_before_taxes = money(doc.net_total) -%}
|
||||
{%- set grand_total = money(doc.grand_total) -%}
|
||||
|
||||
{#- ── Split items by billing frequency (Monthly / Annual / One-time) ── -#}
|
||||
{%- set _monthly = [] -%}
|
||||
{%- set _annual = [] -%}
|
||||
{%- set _onetime = [] -%}
|
||||
{%- set _sums = namespace(monthly=0, annual=0, onetime=0) -%}
|
||||
{%- for it in doc.items -%}
|
||||
{%- set _freq = (it.get("custom_billing_frequency") or "One-time") -%}
|
||||
{%- set _short = frappe.call("gigafibre_utils.api.short_item_name", name=(it.item_name or it.description), max_len=56) -%}
|
||||
{%- set _desc = _short or _clean(it.item_name or it.description) -%}
|
||||
{%- set _row = {
|
||||
"description": _desc,
|
||||
"qty": (it.qty | int if it.qty == it.qty|int else it.qty),
|
||||
"unit_price": money(it.rate),
|
||||
"amount": money(it.amount),
|
||||
"amount_val": (it.amount or 0),
|
||||
"frequency": _freq,
|
||||
} -%}
|
||||
{%- if _freq == "Monthly" -%}
|
||||
{%- set _ = _monthly.append(_row) -%}
|
||||
{%- set _sums.monthly = _sums.monthly + (it.amount or 0) -%}
|
||||
{%- elif _freq == "Annual" -%}
|
||||
{%- set _ = _annual.append(_row) -%}
|
||||
{%- set _sums.annual = _sums.annual + (it.amount or 0) -%}
|
||||
{%- else -%}
|
||||
{%- set _ = _onetime.append(_row) -%}
|
||||
{%- set _sums.onetime = _sums.onetime + (it.amount or 0) -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- set monthly_equiv_val = _sums.monthly + (_sums.annual / 12.0) -%}
|
||||
{%- set has_recurring = (_monthly|length + _annual|length) > 0 -%}
|
||||
{%- set has_onetime = _onetime|length > 0 -%}
|
||||
|
||||
{#- ── QR code — DocuSeal signing URL if set, else portal URL ── -#}
|
||||
{%- if signing_url -%}
|
||||
{%- set qr_code_base64 = frappe.call("gigafibre_utils.api.url_qr_base64", url=signing_url) -%}
|
||||
{%- else -%}
|
||||
{%- set qr_code_base64 = "" -%}
|
||||
{%- endif -%}
|
||||
|
||||
{#- ── Logo ── -#}
|
||||
{%- set logo_base64 = frappe.call("gigafibre_utils.api.logo_base64") -%}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ 'en' if _is_en else 'fr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ doc_title }} {{ quote_number }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: Letter;
|
||||
margin: 10mm 15mm 8mm 20mm;
|
||||
@top-right {
|
||||
content: "Page " counter(page) " de " counter(pages);
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 7pt; color: #888; padding-top: 5mm;
|
||||
}
|
||||
}
|
||||
@page:first { @top-right { content: ""; } }
|
||||
* { box-sizing: border-box; }
|
||||
.print-format { padding: 0 !important; margin: 0 !important; max-width: none !important; }
|
||||
.print-format img, .print-format td img { width: auto !important; max-width: 100% !important; height: auto !important; }
|
||||
.print-format img.qr-img, .print-format td img.qr-img { width: 40px !important; height: 40px !important; max-width: 40px !important; }
|
||||
.print-format td, .print-format th { padding: 0 !important; }
|
||||
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 8pt; color: #333; line-height: 1.2; margin: 0; }
|
||||
@media screen {
|
||||
body { padding: 40px; max-width: 8.5in; margin: 0 auto; box-shadow: 0 0 10px rgba(0,0,0,0.1); background: #fff; }
|
||||
html { background: #f0f0f0; }
|
||||
}
|
||||
|
||||
/* ── 2-column layout ── */
|
||||
.main { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
|
||||
.main td { vertical-align: top; }
|
||||
.col-l { width: 66%; }
|
||||
.col-spacer { width: 1%; }
|
||||
.col-r { width: 33%; }
|
||||
|
||||
/* ── Address block ── */
|
||||
.cl-block { margin-bottom: 6mm; padding-left: 4px; }
|
||||
.cl-lbl { font-size: 7pt; color: #888; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 1px; }
|
||||
.cl-name { font-size: 11pt; font-weight: 700; color: #222; line-height: 1.15; }
|
||||
.cl-addr { font-size: 10pt; color: #333; line-height: 1.2; }
|
||||
|
||||
/* ── Items section ── */
|
||||
.summary-hdr { font-size: 8pt; font-weight: 700; color: #111;
|
||||
background-color: #eef9f3; print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
||||
padding: 3px 6px; margin-bottom: 2px; line-height: 1; }
|
||||
.addr-hdr { font-weight: 600; font-size: 6.5pt; color: #111;
|
||||
background-color: #eef9f3; print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
||||
padding: 2px 6px; margin-top: 3px; line-height: 1.2; }
|
||||
.dtl { width: 100%; border-collapse: collapse; font-size: 7.5pt; margin-bottom: 2px; line-height: 1.35; }
|
||||
.dtl th.r, .dtl td.r { text-align: right; white-space: nowrap; }
|
||||
.dtl td { padding: 3px 6px 1px !important; vertical-align: baseline; line-height: 1.35; }
|
||||
.dtl tr.sep td { height: 3px; padding: 0 !important; line-height: 0; font-size: 0; border: none !important; }
|
||||
.sep-line { border-top: 1px solid #e5e7eb; height: 0; margin: 0; padding: 0; }
|
||||
.dtl td:first-child { white-space: normal; word-break: break-word; }
|
||||
.qty-inline { color: #888; font-size: 7pt; }
|
||||
.dtl tr.stot td { font-weight: 600; border-top: 1px solid #d1d5db; padding-top: 3px !important;
|
||||
background: #f3f4f6 !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.dtl tr.tax td { color: #666; font-size: 6.5pt; padding: 1px 6px !important; background: #fff !important; }
|
||||
.dtl tr.grand td { font-weight: 700; font-size: 8pt; border-top: 1px solid #019547; padding-top: 4px !important;
|
||||
background: #e5e7eb !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.dtl.totals { page-break-inside: avoid; break-inside: avoid; }
|
||||
.dtl tr.taxnums-row td { font-size: 6pt; color: #999; padding: 2px 6px 3px !important; text-align: left; background: #fff !important; }
|
||||
|
||||
/* ── Right column ── */
|
||||
.r-container { border: 1px solid #e5e7eb; border-left: 2px solid #019547; }
|
||||
.r-section { padding: 6px 8px; }
|
||||
.r-section.r-meta { padding: 4px 8px; background: #f8fafc; }
|
||||
.r-meta-table { width: 100%; border-collapse: collapse; }
|
||||
.r-meta-table td { vertical-align: baseline; line-height: 1.25; padding: 1px 0; font-size: 7pt; }
|
||||
.r-meta-table td.ml { color: #888; font-size: 5.5pt; text-transform: uppercase; letter-spacing: 0.3px; text-align: left; }
|
||||
.r-meta-table td.mv { font-weight: 700; color: #333; white-space: nowrap; text-align: right; }
|
||||
.r-section + .r-section { border-top: 1px solid #e5e7eb; }
|
||||
.r-section.r-green { background: #e8f5ee; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.r-section.r-gray { background: #f8f9fa; }
|
||||
.doc-label { font-size: 14pt; font-weight: 700; color: #019547; }
|
||||
/* Total estimé block */
|
||||
.ab-label { font-size: 8.5pt; font-weight: 600; color: #019547; line-height: 1.1; }
|
||||
.ab-val { font-size: 18pt; font-weight: 700; color: #222; text-align: right; white-space: nowrap; line-height: 1.05; }
|
||||
.ab-date { font-size: 7pt; color: #555; margin-top: 1px; }
|
||||
.ab-table { width: 100%; border-collapse: collapse; }
|
||||
.ab-table td { padding: 0; border: none; vertical-align: middle; }
|
||||
/* QR */
|
||||
.qr-table { width: 100%; border-collapse: collapse; }
|
||||
.qr-table td { padding: 1px 6px; border: none; vertical-align: middle; font-size: 7.5pt; line-height: 1.2; }
|
||||
.qr-placeholder { width: 40px; height: 40px; background: #ddd; font-size: 5pt; color: #888; text-align: center; line-height: 40px; border: 1px dashed #bbb; }
|
||||
/* Sign CTA button */
|
||||
.sign-btn-wrap { padding: 8px; text-align: center; }
|
||||
.sign-btn { display: block; background: #019547; color: #fff !important; font-weight: 700;
|
||||
font-size: 7.5pt; text-decoration: none; padding: 7px 10px; border-radius: 3px;
|
||||
text-align: center; letter-spacing: 0.4px; line-height: 1.2;
|
||||
print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
/* Info text */
|
||||
.r-info-text { font-size: 6.5pt; color: #555; line-height: 1.25; }
|
||||
.r-info-text strong { color: #333; }
|
||||
.ri-title { font-weight: 700; font-size: 7pt; color: #333; margin-bottom: 2px; }
|
||||
.r-section.r-cprst { font-size: 5.5pt; padding: 3px 10px; color: #888; background: #fafbfc; border-top: 1px solid #e5e7eb; line-height: 1.2; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div>
|
||||
<table class="main"><tr>
|
||||
|
||||
<!-- ══ LEFT COLUMN ══ -->
|
||||
<td class="col-l">
|
||||
|
||||
<!-- Logo (inline SVG — same asset as Facture TARGO) -->
|
||||
<div style="height: 26px; margin-bottom: 4mm;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" width="121" height="26" style="display:block;"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Address block -->
|
||||
<div class="cl-block" style="margin-top: 8mm;">
|
||||
<div class="cl-lbl">{% if _is_en %}Bill to{% else %}Soumission pour{% endif %}</div>
|
||||
<div class="cl-name">{{ client_name }}</div>
|
||||
<div class="cl-addr">
|
||||
{{ client_address_line1 }}<br>
|
||||
{{ client_address_line2 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
|
||||
{#- ── Recurring services ── -#}
|
||||
{% if has_recurring %}
|
||||
<div class="summary-hdr">{% if _is_en %}RECURRING SERVICES{% else %}SERVICES RÉCURRENTS{% endif %}</div>
|
||||
<table class="dtl">
|
||||
<colgroup>
|
||||
<col style="width:82%">
|
||||
<col style="width:18%">
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{% for item in _monthly %}
|
||||
<tr>
|
||||
<td>{{ item.description }}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %} <span class="qty-inline">({% if _is_en %}/mo{% else %}/mois{% endif %})</span></td>
|
||||
<td class="r">$ {{ item.amount }}</td>
|
||||
</tr>
|
||||
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
|
||||
{% endfor %}
|
||||
{% for item in _annual %}
|
||||
<tr>
|
||||
<td>{{ item.description }}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %} <span class="qty-inline">({% if _is_en %}/yr{% else %}/an{% endif %})</span></td>
|
||||
<td class="r">$ {{ item.amount }}</td>
|
||||
</tr>
|
||||
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
|
||||
{% endfor %}
|
||||
<tr class="stot">
|
||||
<td>{% if _is_en %}Recurring subtotal{% else %}Sous-total récurrent{% endif %}{% if _annual|length %} <span class="qty-inline">({% if _is_en %}monthly equiv.{% else %}équiv. mensuel{% endif %})</span>{% endif %}</td>
|
||||
<td class="r">$ {{ money(monthly_equiv_val) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{#- ── One-time fees ── -#}
|
||||
{% if has_onetime %}
|
||||
<div class="summary-hdr" style="margin-top:4mm;">{% if _is_en %}ONE-TIME FEES{% else %}FRAIS UNIQUES{% endif %}</div>
|
||||
<table class="dtl">
|
||||
<colgroup>
|
||||
<col style="width:86%">
|
||||
<col style="width:14%">
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{% for item in _onetime %}
|
||||
<tr>
|
||||
<td>{{ item.description }}{% if item.qty and item.qty|string not in ('1', '1.0') %} <span class="qty-inline">(×{{ item.qty }})</span>{% endif %}</td>
|
||||
<td class="r">$ {{ item.amount }}</td>
|
||||
</tr>
|
||||
<tr class="sep"><td colspan="2"><div class="sep-line"></div></td></tr>
|
||||
{% endfor %}
|
||||
<tr class="stot">
|
||||
<td>{% if _is_en %}One-time subtotal{% else %}Sous-total frais uniques{% endif %}</td>
|
||||
<td class="r">$ {{ money(_sums.onetime) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:4mm; font-size:6.5pt; color:#888; padding-left:4px;">
|
||||
{% if _is_en %}Prices exclude applicable taxes (GST/QST).{% else %}Les prix excluent les taxes applicables (TPS/TVQ).{% endif %}<br>
|
||||
TPS: 834975559RT0001 | TVQ: 1213765929TQ0001
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
||||
<td class="col-spacer"></td>
|
||||
|
||||
<!-- ══ RIGHT COLUMN ══ -->
|
||||
<td class="col-r">
|
||||
|
||||
<!-- Document title -->
|
||||
<div style="text-align: right; margin-bottom: 3px;">
|
||||
<div class="doc-label" style="line-height:1;">{{ doc_title }}</div>
|
||||
</div>
|
||||
|
||||
<div class="r-container">
|
||||
|
||||
<!-- Meta: quote #, date, validity, account -->
|
||||
<div class="r-section r-meta">
|
||||
<table class="r-meta-table">
|
||||
<tr><td class="ml">{% if _is_en %}Quote #{% else %}Nº soumission{% endif %}</td><td class="mv">{{ quote_number }}</td></tr>
|
||||
<tr><td class="ml">Date</td><td class="mv">{{ quote_date }}</td></tr>
|
||||
<tr><td class="ml">{% if _is_en %}Valid until{% else %}Valide jusqu'au{% endif %}</td><td class="mv">{{ valid_till }}</td></tr>
|
||||
<tr><td class="ml">{% if _is_en %}Account #{% else %}Nº compte{% endif %}</td><td class="mv">{{ account_number }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#- Split totals — recurring + one-time shown separately -#}
|
||||
{% if has_recurring %}
|
||||
<div class="r-section r-green">
|
||||
<table class="ab-table"><tr>
|
||||
<td>
|
||||
<div class="ab-label">{% if _is_en %}Recurring total{% else %}Total récurrent{% endif %}</div>
|
||||
<div class="ab-date">{% if _is_en %}Valid until{% else %}Valide jusqu'au{% endif %} {{ valid_till }}</div>
|
||||
</td>
|
||||
<td class="ab-val">$ {{ money(monthly_equiv_val) }}</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if has_onetime %}
|
||||
<div class="r-section r-gray">
|
||||
<table class="ab-table"><tr>
|
||||
<td>
|
||||
<div class="ab-label" style="color:#555;">{% if _is_en %}Initial fees{% else %}Frais initiaux{% endif %}</div>
|
||||
<div class="ab-date">{% if _is_en %}Billed on first invoice{% else %}Facturés à la 1re facture{% endif %}</div>
|
||||
</td>
|
||||
<td class="ab-val" style="font-size:14pt;">$ {{ money(_sums.onetime) }}</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- QR code → DocuSeal signing link -->
|
||||
<div class="r-section r-green">
|
||||
<table class="qr-table"><tr>
|
||||
<td style="width:46px;">
|
||||
{% if qr_code_base64 %}
|
||||
<img class="qr-img" src="data:image/png;base64,{{ qr_code_base64 }}"
|
||||
style="width:40px !important; height:40px !important; max-width:40px !important;" />
|
||||
{% else %}
|
||||
<div class="qr-placeholder">QR</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{% if _is_en %}Sign online{% else %}Signez en ligne{% endif %}</strong><br>
|
||||
{% if _is_en %}Scan the QR code or click below{% else %}Scannez le code QR ou cliquez ci-dessous{% endif %}<br>
|
||||
<strong style="color:#019547">sign.gigafibre.ca</strong>
|
||||
</td>
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<!-- Sign CTA button -->
|
||||
<div class="sign-btn-wrap r-section">
|
||||
{% if signing_url %}
|
||||
<a href="{{ signing_url }}" class="sign-btn">
|
||||
{% if _is_en %}✎ SIGN THIS QUOTE{% else %}✎ SIGNER CETTE SOUMISSION{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div style="font-size:6.5pt; color:#999; text-align:center; font-style:italic;">
|
||||
{% if _is_en %}Signing link will be sent by email / SMS{% else %}Lien de signature envoyé par courriel / SMS{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Conditions -->
|
||||
<div class="r-section r-info-text">
|
||||
{% if _is_en %}
|
||||
This quote is valid until {{ valid_till }}. Services are subject to network availability at the service address.
|
||||
By signing, you accept our <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">terms and conditions</a>.
|
||||
{% else %}
|
||||
Cette soumission est valide jusqu'au {{ valid_till }}. Les services sont sujets à la disponibilité du réseau à l'adresse de service.
|
||||
En signant, vous acceptez nos <a href="https://www.targo.ca/conditions" style="color:#019547; text-decoration:none;">termes et conditions</a>.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="r-section r-gray r-info-text">
|
||||
<div class="ri-title">{% if _is_en %}Contact us{% else %}Contactez-nous{% endif %}</div>
|
||||
<strong>TARGO Communications Inc.</strong><br>
|
||||
1867 chemin de la Rivière<br>
|
||||
Sainte-Clotilde, QC J0L 1W0<br>
|
||||
<strong>855 888-2746</strong> · {% if _is_en %}Mon-Fri 8am-5pm{% else %}Lun-Ven 8h-17h{% endif %}<br>
|
||||
info@targo.ca • www.targo.ca
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
_PF_OPTIONS = {
|
||||
"print_format_type": "Jinja",
|
||||
"standard": "No",
|
||||
"custom_format": 1,
|
||||
"pdf_generator": "chrome",
|
||||
"margin_top": 5,
|
||||
"margin_bottom": 5,
|
||||
"margin_left": 15,
|
||||
"margin_right": 15,
|
||||
"disabled": 0,
|
||||
}
|
||||
|
||||
if frappe.db.exists("Print Format", PRINT_FORMAT_NAME):
|
||||
pf = frappe.get_doc("Print Format", PRINT_FORMAT_NAME)
|
||||
pf.html = html_template
|
||||
for k, v in _PF_OPTIONS.items():
|
||||
setattr(pf, k, v)
|
||||
pf.save(ignore_permissions=True)
|
||||
print(f" Updated Print Format: {PRINT_FORMAT_NAME} ({len(html_template)} bytes)")
|
||||
else:
|
||||
pf = frappe.get_doc({
|
||||
"doctype": "Print Format",
|
||||
"name": PRINT_FORMAT_NAME,
|
||||
"__newname": PRINT_FORMAT_NAME,
|
||||
"doc_type": "Quotation",
|
||||
"module": "CRM",
|
||||
**_PF_OPTIONS,
|
||||
"html": html_template,
|
||||
"default_print_language": "fr",
|
||||
})
|
||||
pf.insert(ignore_permissions=True)
|
||||
print(f" Created Print Format: {PRINT_FORMAT_NAME} ({len(html_template)} bytes)")
|
||||
|
||||
frappe.db.commit()
|
||||
print(f"\n Done. Print Format '{PRINT_FORMAT_NAME}' ready.")
|
||||
print(f" Preview: ERPNext → Quotation → Print → Select '{PRINT_FORMAT_NAME}'")
|
||||
print(f" Note: add gigafibre_utils.api.url_qr_base64(url) to generate QR for DocuSeal links.")
|
||||
103
scripts/migration/test_jinja_render.py
Normal file
103
scripts/migration/test_jinja_render.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import os
|
||||
import subprocess
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
# Customer data payload matching our jinja variables
|
||||
customer_data = {
|
||||
"account_number": "C-998877665544",
|
||||
"invoice_date": "15/04/2026",
|
||||
"invoice_number": "SINV-0000999999",
|
||||
"client_name": "Jean Tremblay - Tremblay Avocats",
|
||||
"client_address_line1": "123 Rue de la Justice",
|
||||
"client_address_line2": "Sherbrooke, QC J1H 4G9",
|
||||
|
||||
"current_charges_locations": [
|
||||
{
|
||||
"name": "123 Rue de la Justice, Sherbrooke",
|
||||
"items": [
|
||||
{
|
||||
"description": "Fibre Affaires 50M/50M Illimité",
|
||||
"qty": 1,
|
||||
"unit_price": "109.95",
|
||||
"amount": "109.95",
|
||||
"is_credit": False
|
||||
},
|
||||
{
|
||||
"description": "Location modem Giga",
|
||||
"qty": 1,
|
||||
"unit_price": "15.00",
|
||||
"amount": "15.00",
|
||||
"is_credit": False
|
||||
},
|
||||
{
|
||||
"description": "Rabais fidélité",
|
||||
"qty": 1,
|
||||
"unit_price": "-10.00",
|
||||
"amount": "-10.00",
|
||||
"is_credit": True
|
||||
}
|
||||
],
|
||||
"subtotal": "114.95"
|
||||
}
|
||||
],
|
||||
|
||||
"subtotal_before_taxes": "114.95",
|
||||
"tps_amount": "5.75",
|
||||
"tvq_amount": "11.47",
|
||||
"total_taxes": "17.22",
|
||||
"current_charges_total": "132.17",
|
||||
|
||||
"current_page": "1",
|
||||
"total_pages": "1",
|
||||
|
||||
"prev_invoice_date": "15/03/2026",
|
||||
"prev_invoice_total": "132.17",
|
||||
"recent_payments": [
|
||||
{
|
||||
"date": "16/04/2026",
|
||||
"amount": "132.17"
|
||||
}
|
||||
],
|
||||
"remaining_balance": "0.00",
|
||||
|
||||
"due_date": "15/05/2026",
|
||||
"total_amount_due": "0.00",
|
||||
"qr_code_base64": "" # Leaving blank so the template falls back to the "QR" placeholder
|
||||
}
|
||||
|
||||
def main():
|
||||
# Setup rendering
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
env = Environment(loader=FileSystemLoader(script_dir))
|
||||
template = env.get_template('invoice_preview.jinja')
|
||||
|
||||
# Render HTML
|
||||
html_output = template.render(customer_data)
|
||||
|
||||
import time
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Save the rendered HTML temporarily
|
||||
temp_html_path = os.path.join(script_dir, f'rendered_jinja_invoice_{timestamp}.html')
|
||||
with open(temp_html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_output)
|
||||
|
||||
print(f"Generated HTML at {temp_html_path}")
|
||||
|
||||
# Generate PDF via Chrome Headless
|
||||
pdf_path = os.path.join(script_dir, f'rendered_jinja_invoice_{timestamp}.pdf')
|
||||
chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
cmd = [
|
||||
chrome_path,
|
||||
"--headless",
|
||||
"--no-pdf-header-footer",
|
||||
f"--print-to-pdf={pdf_path}",
|
||||
temp_html_path
|
||||
]
|
||||
|
||||
print("Running Chrome to generate PDF...")
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Success! Jinja data has been injected and PDF is ready at: {pdf_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
637
services/modem-bridge/lib/diagnostic-normalizer.js
Normal file
637
services/modem-bridge/lib/diagnostic-normalizer.js
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
// diagnostic-normalizer.js — Normalizes vendor-specific diagnostic data into a unified model
|
||||
// Supports: TP-Link XX230v ($.dm), Raisecom BOA (ASP), Raisecom PHP
|
||||
'use strict'
|
||||
|
||||
// TP-Link MAC OUI prefixes (first 3 octets, uppercase with colons)
|
||||
const TPLINK_OUIS = new Set([
|
||||
'30:DE:4B', '98:25:4A', 'E8:48:B8', '50:C7:BF', 'A8:42:A1', '60:32:B1',
|
||||
'B0:BE:76', 'C0:06:C3', 'F4:F2:6D', 'D8:07:B6', '6C:5A:B0', '14:EB:B6',
|
||||
'A8:29:48', '78:20:51', '3C:64:CF', '0C:EF:15', 'E4:FA:C4', 'E8:9C:25',
|
||||
'5C:A6:E6', '54:AF:97', '1C:3B:F3', 'B4:B0:24', 'EC:41:18', '68:FF:7B',
|
||||
'AC:15:A2', '88:C3:97', '34:60:F9', '98:DA:C4', '40:ED:00', 'CC:32:E5',
|
||||
])
|
||||
|
||||
const MESH_HOSTNAME_PATTERNS = [
|
||||
{ re: /deco\s*m4/i, model: 'Deco M4' },
|
||||
{ re: /deco\s*m5/i, model: 'Deco M5' },
|
||||
{ re: /deco\s*x20/i, model: 'Deco X20' },
|
||||
{ re: /deco\s*x50/i, model: 'Deco X50' },
|
||||
{ re: /deco/i, model: 'Deco' },
|
||||
{ re: /hx220/i, model: 'HX220' },
|
||||
{ re: /hx230/i, model: 'HX230' },
|
||||
{ re: /tp-?link/i, model: 'TP-Link' },
|
||||
{ re: /extender/i, model: 'Extender' },
|
||||
{ re: /repeater/i, model: 'Repeater' },
|
||||
]
|
||||
|
||||
function macOui(mac) {
|
||||
if (!mac) return ''
|
||||
return mac.replace(/[-]/g, ':').toUpperCase().substring(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect wired mesh repeaters from DHCP leases + WiFi client list.
|
||||
* A host is wired if it's NOT in the wifiClients list (by MAC).
|
||||
*/
|
||||
function detectWiredEquipment(dhcpLeases, wifiClientMacs) {
|
||||
const wifiSet = new Set((wifiClientMacs || []).map(m => m.toUpperCase().replace(/[-]/g, ':')))
|
||||
const equipment = []
|
||||
|
||||
for (const lease of dhcpLeases) {
|
||||
const mac = (lease.mac || '').toUpperCase().replace(/[-]/g, ':')
|
||||
const hostname = (lease.hostname || '').toLowerCase()
|
||||
const oui = macOui(mac)
|
||||
const isWired = !wifiSet.has(mac)
|
||||
|
||||
lease.isWired = isWired
|
||||
|
||||
let match = null
|
||||
let matchReason = null
|
||||
|
||||
// Check hostname patterns (most reliable)
|
||||
for (const p of MESH_HOSTNAME_PATTERNS) {
|
||||
if (p.re.test(hostname) || p.re.test(lease.hostname || '')) {
|
||||
match = { model: p.model, type: 'mesh_repeater' }
|
||||
matchReason = `hostname:${p.re.source}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: TP-Link OUI on a wired connection (probable mesh node)
|
||||
if (!match && isWired && TPLINK_OUIS.has(oui)) {
|
||||
match = { model: 'TP-Link device', type: 'possible_mesh_repeater' }
|
||||
matchReason = `oui:${oui}`
|
||||
}
|
||||
|
||||
if (match) {
|
||||
lease.isMeshRepeater = true
|
||||
equipment.push({
|
||||
hostname: lease.hostname || '', mac: lease.mac, ip: lease.ip,
|
||||
port: lease.interface || null,
|
||||
model: match.model, type: match.type, matchReason,
|
||||
})
|
||||
} else {
|
||||
lease.isMeshRepeater = false
|
||||
}
|
||||
}
|
||||
return equipment
|
||||
}
|
||||
|
||||
// --- Raisecom BOA normalizer ---
|
||||
|
||||
/**
|
||||
* Parse Raisecom BOA JavaScript data arrays from page HTML.
|
||||
* Raisecom BOA embeds data as: var arr = []; arr.push(new it_nr("idx", new it("key", val), ...))
|
||||
* Returns an array of objects with key-value pairs.
|
||||
*/
|
||||
function parseBoaArray(html, varName) {
|
||||
const items = []
|
||||
// Match full push line: varName.push(new it_nr("idx", new it("k1", v1), new it("k2", v2)));
|
||||
// Use line-based matching to avoid nested parenthesis issues
|
||||
const pushRe = new RegExp(varName + '\\.push\\(new it_nr\\((.+?)\\)\\);', 'g')
|
||||
let m
|
||||
while ((m = pushRe.exec(html)) !== null) {
|
||||
const line = m[1]
|
||||
const obj = {}
|
||||
// Extract new it("key", value) pairs from the line
|
||||
const itRe = /new it\("(\w+)",\s*("(?:[^"\\]|\\.)*"|[^,)]+)\)/g
|
||||
let im
|
||||
while ((im = itRe.exec(line)) !== null) {
|
||||
let val = im[2].trim()
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1)
|
||||
else if (!isNaN(val) && val !== '') val = Number(val)
|
||||
obj[im[1]] = val
|
||||
}
|
||||
if (Object.keys(obj).length > 0) items.push(obj)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Raisecom BOA key-value tables from HTML.
|
||||
* Structure: <tr><td>Label</td><td>Value</td></tr>
|
||||
*/
|
||||
function parseBoaKvTable(html) {
|
||||
const kv = {}
|
||||
// Collect KV pairs from ALL <tr><td>label</td><td>value</td></tr> patterns across the page
|
||||
const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
||||
let rm
|
||||
while ((rm = rowRe.exec(html)) !== null) {
|
||||
const cells = []
|
||||
const cellRe = /<td[^>]*>([\s\S]*?)<\/td>/gi
|
||||
let cm
|
||||
while ((cm = cellRe.exec(rm[1])) !== null) {
|
||||
cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim())
|
||||
}
|
||||
if (cells.length >= 2 && cells[0] && cells[1]) {
|
||||
kv[cells[0]] = cells[1]
|
||||
}
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
function normalizeRaisecomBoa(pages) {
|
||||
const result = {
|
||||
modemType: 'raisecom_boa',
|
||||
online: null,
|
||||
wanIPs: [],
|
||||
ethernetPorts: [],
|
||||
dhcpLeases: [],
|
||||
wiredEquipment: [],
|
||||
radios: [],
|
||||
meshNodes: [], // Raisecom has no EasyMesh
|
||||
wifiClients: [],
|
||||
issues: [],
|
||||
device: {},
|
||||
gpon: {},
|
||||
}
|
||||
|
||||
// --- Device basic info ---
|
||||
if (pages.deviceInfo) {
|
||||
const kv = parseBoaKvTable(pages.deviceInfo)
|
||||
result.device = {
|
||||
model: kv['Device Model'] || '',
|
||||
serial: kv['Device Serial Number'] || '',
|
||||
gponSn: kv['GPON SN'] || '',
|
||||
hardware: kv['Hardware Version'] || '',
|
||||
firmware: kv['Software Version'] || '',
|
||||
versionDate: kv['Version Date'] || '',
|
||||
cpu: kv['CPU Usage'] || '',
|
||||
memory: kv['Memory Usage'] || '',
|
||||
uptime: kv['UP Times'] || '',
|
||||
}
|
||||
}
|
||||
|
||||
// --- GPON optical info ---
|
||||
if (pages.gpon) {
|
||||
const kv = parseBoaKvTable(pages.gpon)
|
||||
result.gpon = {
|
||||
linkState: kv['Link State'] || '',
|
||||
sn: kv['GPONSN'] || '',
|
||||
txPower: kv['Tx Power'] || '',
|
||||
rxPower: kv['Rx Power'] || '',
|
||||
temperature: kv['Temperature'] || '',
|
||||
voltage: kv['Voltage'] || '',
|
||||
biasCurrent: kv['Bias Current'] || '',
|
||||
}
|
||||
// Check link state
|
||||
const linkOk = result.gpon.linkState && result.gpon.linkState.includes('O5')
|
||||
result.online = { ipv4: linkOk, ipv6: false, uptimeV4: 0 }
|
||||
}
|
||||
|
||||
// --- WAN interfaces ---
|
||||
if (pages.wan) {
|
||||
const links = parseBoaArray(pages.wan, 'links')
|
||||
for (const l of links) {
|
||||
const ip = l.ipAddr || ''
|
||||
const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
|
||||
: ip.startsWith('10.') ? 'service'
|
||||
: ip.startsWith('192.168.') ? 'lan'
|
||||
: (!ip.startsWith('169.254.') && ip) ? 'internet'
|
||||
: 'unknown'
|
||||
result.wanIPs.push({
|
||||
ip, mask: '', type: l.protocol || 'IPoE', role,
|
||||
vlanId: l.vlanId, name: l.servName || '', status: l.strStatus || '',
|
||||
gateway: l.gw || '', dns: l.DNS || '',
|
||||
})
|
||||
}
|
||||
// Set online based on WAN status
|
||||
const hasUpWan = links.some(l => l.strStatus === 'up' && l.ipAddr && !l.ipAddr.startsWith('10.') && !l.ipAddr.startsWith('172.'))
|
||||
if (result.online) result.online.ipv4 = hasUpWan || result.online.ipv4
|
||||
else result.online = { ipv4: hasUpWan, ipv6: false, uptimeV4: 0 }
|
||||
}
|
||||
|
||||
// --- Ethernet ports + connected clients ---
|
||||
if (pages.ethernet) {
|
||||
const ethers = parseBoaArray(pages.ethernet, 'ethers')
|
||||
const clients = parseBoaArray(pages.ethernet, 'clts')
|
||||
|
||||
for (const e of ethers) {
|
||||
const portNum = parseInt(e.ifname?.replace(/\D/g, '') || '0')
|
||||
const hasTraffic = (e.rx_packets || 0) > 0 || (e.tx_packets || 0) > 0
|
||||
result.ethernetPorts.push({
|
||||
port: portNum, label: e.ifname || `Port ${portNum}`,
|
||||
status: hasTraffic ? 'Up' : 'Down',
|
||||
speed: null, duplex: null,
|
||||
stats: {
|
||||
rxPackets: e.rx_packets || 0, rxBytes: e.rx_bytes || 0,
|
||||
rxErrors: e.rx_errors || 0, rxDropped: e.rx_dropped || 0,
|
||||
txPackets: e.tx_packets || 0, txBytes: e.tx_bytes || 0,
|
||||
txErrors: e.tx_errors || 0, txDropped: e.tx_dropped || 0,
|
||||
},
|
||||
connectedDevice: null, // Will be cross-referenced with DHCP
|
||||
})
|
||||
}
|
||||
|
||||
// DHCP clients from Ethernet info page
|
||||
for (const c of clients) {
|
||||
result.dhcpLeases.push({
|
||||
hostname: c.devname || '',
|
||||
ip: c.ipAddr || '',
|
||||
mac: c.macAddr || '',
|
||||
expiry: c.liveTime ? parseInt(c.liveTime) : null,
|
||||
interface: null, isWired: false, isMeshRepeater: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- WiFi clients ---
|
||||
if (pages.wlan) {
|
||||
const wlanClients = parseBoaArray(pages.wlan, 'clts') // Same var name
|
||||
// Also check for other var names
|
||||
const wlanClients2 = parseBoaArray(pages.wlan, 'clients')
|
||||
const allWlan = [...wlanClients, ...wlanClients2]
|
||||
|
||||
for (const c of allWlan) {
|
||||
result.wifiClients.push({
|
||||
mac: c.macAddr || c.MACAddress || '',
|
||||
hostname: '',
|
||||
ip: '',
|
||||
signal: c.rssi ? parseInt(c.rssi) : null,
|
||||
band: c.bandWidth ? `${c.bandWidth}MHz` : '',
|
||||
standard: '',
|
||||
active: c.linkState === 'Assoc' || c.linkState === 'associated' || true,
|
||||
linkDown: c.currRxRate ? parseInt(c.currRxRate) * 1000 : 0,
|
||||
linkUp: c.currTxRate ? parseInt(c.currTxRate) * 1000 : 0,
|
||||
lossPercent: 0,
|
||||
meshNode: '',
|
||||
})
|
||||
}
|
||||
|
||||
// Extract radio info from the wlan page too
|
||||
const radios = parseBoaArray(pages.wlan, 'ethers') // Sometimes reused var name
|
||||
// Fallback: parse KV from the wlan basic config if available
|
||||
if (pages.wlanConfig) {
|
||||
const kv = parseBoaKvTable(pages.wlanConfig)
|
||||
if (kv['Channel'] || kv['Band']) {
|
||||
result.radios.push({
|
||||
band: kv['Band'] || '2.4GHz',
|
||||
channel: parseInt(kv['Channel']) || 0,
|
||||
bandwidth: kv['Channel Width'] || kv['Bandwidth'] || '',
|
||||
standard: kv['Mode'] || '',
|
||||
txPower: 0, autoChannel: false, status: 'Up',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect wired equipment (mesh repeaters)
|
||||
const wifiMacs = result.wifiClients.map(c => c.mac)
|
||||
result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, wifiMacs)
|
||||
|
||||
// Cross-reference Ethernet ports with DHCP clients
|
||||
// (BOA doesn't tell us which port each client is on, but we flag wired devices)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Raisecom PHP (WS2) normalizer ---
|
||||
// WS2 has a single dashboard page with HTML tables (not JS arrays like BOA)
|
||||
|
||||
function parseHtmlTables(html) {
|
||||
const tables = []
|
||||
const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi
|
||||
let m
|
||||
while ((m = tableRe.exec(html)) !== null) {
|
||||
const rows = []
|
||||
const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
||||
let rm
|
||||
while ((rm = rowRe.exec(m[1])) !== null) {
|
||||
const cells = []
|
||||
const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
|
||||
let cm
|
||||
while ((cm = cellRe.exec(rm[1])) !== null) {
|
||||
cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
if (cells.length >= 2) rows.push(cells)
|
||||
}
|
||||
if (rows.length > 0) tables.push(rows)
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
function normalizeRaisecomPhp(pages) {
|
||||
const result = {
|
||||
modemType: 'raisecom_php',
|
||||
online: null,
|
||||
wanIPs: [],
|
||||
ethernetPorts: [],
|
||||
dhcpLeases: [],
|
||||
wiredEquipment: [],
|
||||
radios: [],
|
||||
meshNodes: [],
|
||||
wifiClients: [],
|
||||
issues: [],
|
||||
device: {},
|
||||
gpon: {},
|
||||
}
|
||||
|
||||
// WS2 returns the same dashboard for all ?parts= params
|
||||
// Use deviceInfo page (or any, they're the same)
|
||||
const html = pages.deviceInfo || pages.wan || pages.ethernet || ''
|
||||
if (!html) return result
|
||||
|
||||
const tables = parseHtmlTables(html)
|
||||
|
||||
for (const rows of tables) {
|
||||
// Device info table: 2-column KV pairs (label | value)
|
||||
if (rows.length >= 5 && rows.some(r => r[0] === 'Device Model')) {
|
||||
for (const [k, v] of rows) {
|
||||
if (k === 'Device Model') result.device.model = v
|
||||
if (k === 'Device Serial Number') result.device.serial = v
|
||||
if (k === 'Hardware Version') result.device.hardware = v
|
||||
if (k === 'Software Version') result.device.firmware = v
|
||||
if (k === 'Sub Version') result.device.subVersion = v
|
||||
if (k === 'System Up Time') result.device.uptime = v
|
||||
if (k === 'System Name') result.device.name = v
|
||||
}
|
||||
}
|
||||
|
||||
// CPU table: "CPU Usage:28% | Time:..."
|
||||
if (rows.length === 1 && rows[0][0]?.includes('CPU Usage')) {
|
||||
const cpuMatch = rows[0][0].match(/CPU Usage:\s*([\d.]+)%/)
|
||||
if (cpuMatch) result.device.cpu = cpuMatch[1] + '%'
|
||||
}
|
||||
|
||||
// Memory table
|
||||
if (rows.length === 1 && rows[0][0]?.includes('Memory Usage')) {
|
||||
const memMatch = rows[0][0].match(/Memory Usage:\s*([\d.]+)%/)
|
||||
if (memMatch) result.device.memory = memMatch[1] + '%'
|
||||
}
|
||||
|
||||
// WAN connections table: header row + data rows
|
||||
if (rows.length >= 2 && rows[0].some(h => h === 'Connection Name')) {
|
||||
const headers = rows[0]
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
const obj = {}
|
||||
headers.forEach((h, idx) => { if (row[idx]) obj[h] = row[idx] })
|
||||
|
||||
const ip = (obj['IP Address/Net Mask Length'] || '').split('/')[0] || ''
|
||||
const mask = (obj['IP Address/Net Mask Length'] || '').split('/')[1] || ''
|
||||
const status = obj['Link Status'] || ''
|
||||
const name = obj['Connection Name'] || ''
|
||||
|
||||
if (ip && ip !== '---') {
|
||||
const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
|
||||
: ip.startsWith('10.') ? 'service'
|
||||
: ip.startsWith('192.168.') ? 'lan'
|
||||
: !ip.startsWith('169.254.') ? 'internet' : 'unknown'
|
||||
result.wanIPs.push({
|
||||
ip, mask, type: obj['Address Assign Mode'] || '', role,
|
||||
name, status, gateway: obj['Gate Address'] || '',
|
||||
dns: obj['DNS'] || '',
|
||||
rxRate: obj['Receiving Rate(Kbits/s)'] || '',
|
||||
txRate: obj['Sending Rate(Kbits/s)'] || '',
|
||||
})
|
||||
}
|
||||
|
||||
if (status === 'UP') {
|
||||
result.online = result.online || { ipv4: false, ipv6: false, uptimeV4: 0 }
|
||||
if (name.includes('Internet')) result.online.ipv4 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WiFi table: "SSID | Service Status | connected count (下挂PC数量)"
|
||||
// Note: WS2 tables sometimes have header in <thead><tr> but data rows without <tr> opening tags
|
||||
if (rows.length >= 1 && rows[0].some(h => h === 'SSID(Wireless Network Name)' || h === 'SSID')) {
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (rows[i][0] && rows[i][1]) {
|
||||
const ssid = rows[i][0]
|
||||
const statusRaw = rows[i][1]
|
||||
const isOn = /^(Enable|UP|ON)$/i.test(statusRaw)
|
||||
const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz'
|
||||
result.radios.push({
|
||||
band, channel: 0, bandwidth: '', standard: '',
|
||||
txPower: 0, autoChannel: false,
|
||||
status: isOn ? 'Up' : 'Down',
|
||||
ssid,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ethernet port table: rows of [LANx, UP/DOWN] — either one row with all ports or multiple rows
|
||||
if (rows.length >= 1 && rows.some(r => /^LAN\d$/i.test(r[0]))) {
|
||||
for (const row of rows) {
|
||||
// Each row is either [LANx, UP/DOWN] or a flat row [LAN1, UP, LAN2, DOWN, ...]
|
||||
if (/^LAN\d$/i.test(row[0]) && row.length === 2) {
|
||||
result.ethernetPorts.push({
|
||||
port: parseInt(row[0].replace(/\D/g, '')),
|
||||
label: row[0],
|
||||
status: row[1] === 'UP' ? 'Up' : 'Down',
|
||||
speed: null, duplex: null, stats: null, connectedDevice: null,
|
||||
})
|
||||
} else {
|
||||
// Flat format: LAN1 UP LAN2 DOWN ...
|
||||
for (let i = 0; i < row.length - 1; i += 2) {
|
||||
if (/^LAN\d$/i.test(row[i])) {
|
||||
result.ethernetPorts.push({
|
||||
port: parseInt(row[i].replace(/\D/g, '')),
|
||||
label: row[i],
|
||||
status: row[i + 1] === 'UP' ? 'Up' : 'Down',
|
||||
speed: null, duplex: null, stats: null, connectedDevice: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fallback: direct HTML regex for WS2 malformed tables ---
|
||||
// WS2 <tbody> rows often lack <tr> opening tags, so parseHtmlTables misses them
|
||||
|
||||
// WiFi SSIDs: extract from <span class="content_td">SSID</span> + <span class="content_td">ON/OFF</span> pairs
|
||||
if (result.radios.length === 0 && html.includes('SSID(Wireless Network Name)')) {
|
||||
const ssidRe = /<td[^>]*>\s*<span[^>]*>([^<]+)<\/span>\s*<\/td>\s*<td[^>]*>\s*<span[^>]*>(ON|OFF|UP|DOWN|Enable|Disable)<\/span>/gi
|
||||
let sm
|
||||
while ((sm = ssidRe.exec(html)) !== null) {
|
||||
const ssid = sm[1].trim()
|
||||
const isOn = /^(ON|UP|Enable)$/i.test(sm[2])
|
||||
const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz'
|
||||
result.radios.push({
|
||||
band, channel: 0, bandwidth: '', standard: '',
|
||||
txPower: 0, autoChannel: false,
|
||||
status: isOn ? 'Up' : 'Down',
|
||||
ssid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ethernet ports: extract LAN1-4 status from <span>LANx</span>...<span>UP/DOWN</span> pairs
|
||||
if (result.ethernetPorts.length === 0 && html.includes('LAN1')) {
|
||||
const lanRe = /<span[^>]*>(LAN\d)<\/span>[\s\S]*?<span[^>]*>(UP|DOWN)<\/span>/gi
|
||||
let lm
|
||||
while ((lm = lanRe.exec(html)) !== null) {
|
||||
result.ethernetPorts.push({
|
||||
port: parseInt(lm[1].replace(/\D/g, '')),
|
||||
label: lm[1],
|
||||
status: lm[2] === 'UP' ? 'Up' : 'Down',
|
||||
speed: null, duplex: null, stats: null, connectedDevice: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If no online status determined, check if any WAN is UP
|
||||
if (!result.online && result.wanIPs.some(w => w.status === 'UP')) {
|
||||
result.online = { ipv4: true, ipv6: false, uptimeV4: 0 }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// --- TP-Link normalizer ---
|
||||
|
||||
function normalizeTplink(raw) {
|
||||
// Raw data from fullDiagnostic() — keys match the Promise.allSettled order
|
||||
const result = {
|
||||
modemType: 'tplink_xx230v',
|
||||
online: null,
|
||||
wanIPs: [],
|
||||
ethernetPorts: [],
|
||||
dhcpLeases: [],
|
||||
wiredEquipment: [],
|
||||
radios: [],
|
||||
meshNodes: [],
|
||||
wifiClients: [],
|
||||
issues: [],
|
||||
}
|
||||
|
||||
// Online status
|
||||
if (raw.onlineStatus && !raw.onlineStatus.error) {
|
||||
result.online = {
|
||||
ipv4: raw.onlineStatus.onlineStatusV4 === 'online',
|
||||
ipv6: raw.onlineStatus.onlineStatusV6 === 'online',
|
||||
uptimeV4: parseInt(raw.onlineStatus.onlineTimeV4) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// WAN IPs
|
||||
if (Array.isArray(raw.wanIPs)) {
|
||||
for (const w of raw.wanIPs) {
|
||||
if (!w.IPAddress || w.IPAddress === '0.0.0.0') continue
|
||||
if (w.status === 'Disabled') continue
|
||||
const ip = w.IPAddress
|
||||
const role = ip.startsWith('192.168.') ? 'lan'
|
||||
: ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
|
||||
: ip.startsWith('10.') ? 'service'
|
||||
: !ip.startsWith('169.254.') ? 'internet' : 'unknown'
|
||||
result.wanIPs.push({
|
||||
ip, mask: w.subnetMask || '', type: w.addressingType || '', role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Radios
|
||||
if (Array.isArray(raw.radios)) {
|
||||
for (const r of raw.radios) {
|
||||
result.radios.push({
|
||||
band: r.operatingFrequencyBand || '',
|
||||
channel: parseInt(r.channel) || 0,
|
||||
bandwidth: r.currentOperatingChannelBandwidth || '',
|
||||
standard: r.operatingStandards || '',
|
||||
txPower: parseInt(r.transmitPower) || 0,
|
||||
autoChannel: r.autoChannelEnable === '1',
|
||||
status: r.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mesh nodes
|
||||
if (Array.isArray(raw.meshNodes)) {
|
||||
for (const n of raw.meshNodes) {
|
||||
result.meshNodes.push({
|
||||
hostname: n.X_TP_HostName || 'unknown',
|
||||
model: n.X_TP_ModelName || '',
|
||||
mac: n.MACAddress || '',
|
||||
ip: n.X_TP_IPAddress || '',
|
||||
active: n.X_TP_Active === '1',
|
||||
isController: n.X_TP_IsController === '1',
|
||||
cpu: parseInt(n.X_TP_CPUUsage) || 0,
|
||||
uptime: parseInt(n.X_TP_UpTime) || 0,
|
||||
firmware: n.softwareVersion || '',
|
||||
backhaul: {
|
||||
type: n.backhaulLinkType || 'Ethernet',
|
||||
signal: parseInt(n.backhaulSignalStrength) || 0,
|
||||
utilization: parseInt(n.backhaulLinkUtilization) || 0,
|
||||
linkRate: parseInt(n.X_TP_LinkRate) || 0,
|
||||
},
|
||||
speedUp: parseInt(n.X_TP_UpSpeed) || 0,
|
||||
speedDown: parseInt(n.X_TP_DownSpeed) || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WiFi clients
|
||||
const clientMap = new Map()
|
||||
if (Array.isArray(raw.clients)) {
|
||||
for (const c of raw.clients) {
|
||||
clientMap.set(c.MACAddress, {
|
||||
mac: c.MACAddress || '', hostname: c.X_TP_HostName || '',
|
||||
ip: c.X_TP_IPAddress || '',
|
||||
signal: parseInt(c.signalStrength) || 0,
|
||||
band: '', standard: c.operatingStandard || '',
|
||||
active: c.active === '1',
|
||||
linkDown: parseInt(c.lastDataDownlinkRate) || 0,
|
||||
linkUp: parseInt(c.lastDataUplinkRate) || 0,
|
||||
lossPercent: 0, meshNode: '',
|
||||
apMac: c.X_TP_ApDeviceMac || '',
|
||||
radioMac: c.X_TP_RadioMac || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (Array.isArray(raw.clientStats)) {
|
||||
for (const s of raw.clientStats) {
|
||||
const existing = clientMap.get(s.MACAddress)
|
||||
if (existing) {
|
||||
existing.retrans = parseInt(s.retransCount) || 0
|
||||
existing.packetsSent = parseInt(s.packetsSent) || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compute loss
|
||||
for (const c of clientMap.values()) {
|
||||
if (c.packetsSent > 100 && c.retrans > 0) {
|
||||
c.lossPercent = Math.round((c.retrans / c.packetsSent) * 1000) / 10
|
||||
}
|
||||
}
|
||||
result.wifiClients = [...clientMap.values()]
|
||||
|
||||
// Ethernet ports (from DEV2_ETHERNET_IF if available)
|
||||
if (Array.isArray(raw.ethernetIfs)) {
|
||||
for (let i = 0; i < raw.ethernetIfs.length; i++) {
|
||||
const e = raw.ethernetIfs[i]
|
||||
result.ethernetPorts.push({
|
||||
port: i + 1, label: e.alias || e.name || `Port ${i + 1}`,
|
||||
status: e.status || 'Down',
|
||||
speed: parseInt(e.maxBitRate || e.currentBitRate) || null,
|
||||
duplex: e.duplexMode || null,
|
||||
stats: null, connectedDevice: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DHCP hosts (from DEV2_HOSTS_HOST if available)
|
||||
if (Array.isArray(raw.hosts)) {
|
||||
const wifiMacs = new Set([...clientMap.keys()].map(m => m.toUpperCase()))
|
||||
for (const h of raw.hosts) {
|
||||
const mac = (h.PhysAddress || h.physAddress || '').toUpperCase()
|
||||
result.dhcpLeases.push({
|
||||
hostname: h.HostName || h.hostName || '',
|
||||
ip: h.IPAddress || h.ipAddress || '',
|
||||
mac: h.PhysAddress || h.physAddress || '',
|
||||
expiry: h.LeaseTimeRemaining != null ? parseInt(h.LeaseTimeRemaining) : null,
|
||||
interface: h.Layer1Interface || '',
|
||||
isWired: !wifiMacs.has(mac),
|
||||
isMeshRepeater: false,
|
||||
})
|
||||
}
|
||||
result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, [...clientMap.keys()])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink, detectWiredEquipment, parseBoaArray, parseBoaKvTable }
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
// tplink-session.js — Playwright-based TP-Link ONU session manager
|
||||
// Uses headless Chromium because TP-Link XX230v GDPR_ENCRYPT requires
|
||||
// the modem's own JS to handle RSA/AES key exchange natively.
|
||||
// modem-session.js — Playwright-based multi-vendor modem session manager
|
||||
// Supports: TP-Link XX230v (GDPR encrypt), Raisecom BOA, Raisecom PHP
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
|
|
@ -43,45 +42,92 @@ function getSession(ip) {
|
|||
return s;
|
||||
}
|
||||
|
||||
async function login(ip, user, pass, path = '/superadmin') {
|
||||
if (sessions.has(ip)) await closeSession(ip);
|
||||
// --- Modem identification ---
|
||||
// Probes the device via HTTP/HTTPS to detect vendor and firmware type
|
||||
const MODEM_TYPES = {
|
||||
TPLINK_XX230V: 'tplink_xx230v',
|
||||
RAISECOM_BOA: 'raisecom_boa', // Type A: /admin/login.asp, BOA form, subcustom=raisecom
|
||||
RAISECOM_PHP: 'raisecom_php', // Type B: /, PHP form, encrypt() JS
|
||||
UNKNOWN: 'unknown',
|
||||
};
|
||||
|
||||
async function identify(ip) {
|
||||
const b = await getBrowser();
|
||||
const context = await b.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
});
|
||||
|
||||
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
const url = `https://${ip}${path}`;
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
||||
const result = { ip, type: MODEM_TYPES.UNKNOWN, title: '', protocol: 'https', loginPath: '/' };
|
||||
|
||||
// Try HTTPS first (TP-Link uses HTTPS)
|
||||
for (const proto of ['https', 'http']) {
|
||||
for (const path of ['/', '/superadmin', '/admin/login.asp']) {
|
||||
try {
|
||||
const resp = await page.goto(`${proto}://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: 8000 });
|
||||
if (!resp || resp.status() >= 400) continue;
|
||||
|
||||
const html = await page.content();
|
||||
const title = await page.title();
|
||||
result.title = title;
|
||||
result.protocol = proto;
|
||||
result.loginPath = path;
|
||||
|
||||
// TP-Link XX230v: has $.tpLang or pc-login-password
|
||||
if (html.includes('pc-login-password') || html.includes('tpLang') || html.includes('INCLUDE_LOGIN')) {
|
||||
result.type = MODEM_TYPES.TPLINK_XX230V;
|
||||
result.loginPath = path;
|
||||
await context.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Raisecom BOA: title "Home Gateway", subcustom="raisecom", formLogin action
|
||||
if (html.includes('subcustom') && html.includes('raisecom') && html.includes('formLogin')) {
|
||||
result.type = MODEM_TYPES.RAISECOM_BOA;
|
||||
await context.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Raisecom PHP: title "Web user login", encrypt() function, user_name field
|
||||
if ((title.includes('Web user login') || html.includes('Web user login')) && html.includes('user_name') && html.includes('encrypt')) {
|
||||
result.type = MODEM_TYPES.RAISECOM_PHP;
|
||||
await context.close();
|
||||
return result;
|
||||
}
|
||||
} catch { continue; }
|
||||
}
|
||||
}
|
||||
|
||||
await context.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Login handlers per modem type ---
|
||||
|
||||
async function loginTplink(ip, user, pass, path) {
|
||||
const b = await getBrowser();
|
||||
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`https://${ip}${path}`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
||||
await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS });
|
||||
|
||||
const hasUsername = await page.evaluate(() => {
|
||||
const el = document.getElementById('pc-login-user-div');
|
||||
return el && !el.classList.contains('nd');
|
||||
});
|
||||
|
||||
if (hasUsername) await page.fill('#pc-login-user', user);
|
||||
await page.fill('#pc-login-password', pass);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const navigationPromise = page.waitForNavigation({
|
||||
waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS
|
||||
}).catch(() => null);
|
||||
|
||||
const navP = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await page.click('#pc-login-btn');
|
||||
await navigationPromise;
|
||||
await navP;
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Handle force-logout dialog if another session was active
|
||||
const forceLogoutBtn = await page.$('#confirm-yes');
|
||||
if (forceLogoutBtn && await forceLogoutBtn.isVisible()) {
|
||||
console.log(`[modem-bridge] Force logout dialog for ${ip}, confirming...`);
|
||||
// Force-logout dialog
|
||||
const forceBtn = await page.$('#confirm-yes');
|
||||
if (forceBtn && await forceBtn.isVisible()) {
|
||||
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await forceLogoutBtn.click();
|
||||
await forceBtn.click();
|
||||
await navP2;
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
|
@ -90,51 +136,124 @@ async function login(ip, user, pass, path = '/superadmin') {
|
|||
const loginDiv = document.getElementById('pc-login');
|
||||
if (loginDiv && !loginDiv.classList.contains('nd')) {
|
||||
const errEl = document.querySelector('.errDiv .errText, .errDivP .errTextP');
|
||||
return { success: false, error: errEl ? errEl.textContent.trim() : 'Login failed - still on login page' };
|
||||
return { success: false, error: errEl ? errEl.textContent.trim() : 'Login failed' };
|
||||
}
|
||||
return { success: true, hasDm: typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function' };
|
||||
});
|
||||
|
||||
if (!loginResult.success) {
|
||||
await context.close();
|
||||
throw new Error(`Login failed for ${ip}: ${loginResult.error}`);
|
||||
if (!loginResult.success) { await context.close(); throw new Error(`Login failed for ${ip}: ${loginResult.error}`); }
|
||||
return { context, page, hasDm: loginResult.hasDm, modemType: MODEM_TYPES.TPLINK_XX230V };
|
||||
}
|
||||
|
||||
async function loginRaisecomBoa(ip, user, pass, path) {
|
||||
const b = await getBrowser();
|
||||
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`http://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
|
||||
await page.waitForSelector('input[name="username"]', { timeout: LOGIN_TIMEOUT_MS });
|
||||
|
||||
await page.fill('input[name="username"]', user);
|
||||
await page.fill('input[name="psd"]', pass);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// BOA form submits to /boaform/admin/formLogin — triggers page reload
|
||||
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await page.evaluate(() => document.forms[0].submit());
|
||||
await navP;
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if we're past login
|
||||
const url = page.url();
|
||||
const html = await page.content();
|
||||
const stillOnLogin = html.includes('formLogin') || html.includes('User Login');
|
||||
if (stillOnLogin) { await context.close(); throw new Error(`Raisecom BOA login failed for ${ip}`); }
|
||||
|
||||
return { context, page, hasDm: false, modemType: MODEM_TYPES.RAISECOM_BOA };
|
||||
}
|
||||
|
||||
async function loginRaisecomPhp(ip, user, pass, protocol = 'https') {
|
||||
const b = await getBrowser();
|
||||
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// WS2 models use HTTPS, WS1 PHP variants use HTTP — auto-detected by identify()
|
||||
await page.goto(`${protocol}://${ip}/`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
||||
await page.waitForSelector('input[name="user_name"]', { timeout: LOGIN_TIMEOUT_MS });
|
||||
|
||||
await page.fill('input[name="user_name"]', user);
|
||||
await page.fill('input[name="password"]', pass);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click the submit button to trigger JS encrypt() chain (mySubmit -> encrypt -> CryptoJS)
|
||||
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await page.click('input[name="login"]').catch(() => {
|
||||
// Fallback: evaluate mySubmit or native submit
|
||||
return page.evaluate(() => {
|
||||
if (typeof mySubmit === 'function') mySubmit(document.forms[0]);
|
||||
else document.forms[0].submit();
|
||||
});
|
||||
});
|
||||
await navP;
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const html = await page.content();
|
||||
const stillOnLogin = html.includes('Web user login') && html.includes('user_name');
|
||||
if (stillOnLogin) { await context.close(); throw new Error(`Raisecom PHP login failed for ${ip}`); }
|
||||
|
||||
return { context, page, hasDm: false, modemType: MODEM_TYPES.RAISECOM_PHP };
|
||||
}
|
||||
|
||||
// --- Unified login ---
|
||||
|
||||
async function login(ip, user, pass, path = '/superadmin') {
|
||||
if (sessions.has(ip)) await closeSession(ip);
|
||||
|
||||
let result;
|
||||
|
||||
// Always identify modem type first — don't assume from path alone
|
||||
const id = await identify(ip);
|
||||
console.log(`[modem-bridge] Identified ${ip} as ${id.type} (title: "${id.title}", path: ${id.loginPath})`);
|
||||
|
||||
if (id.type === MODEM_TYPES.TPLINK_XX230V) {
|
||||
result = await loginTplink(ip, user, pass, id.loginPath || path);
|
||||
} else if (id.type === MODEM_TYPES.RAISECOM_BOA) {
|
||||
result = await loginRaisecomBoa(ip, user, pass, id.loginPath || '/');
|
||||
} else if (id.type === MODEM_TYPES.RAISECOM_PHP) {
|
||||
result = await loginRaisecomPhp(ip, user, pass, id.protocol || 'https');
|
||||
} else {
|
||||
throw new Error(`Unknown modem type at ${ip} (title: "${id.title}")`);
|
||||
}
|
||||
|
||||
const session = {
|
||||
ip, context, page,
|
||||
loggedIn: true,
|
||||
loginTime: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
hasDm: loginResult.hasDm
|
||||
ip, context: result.context, page: result.page,
|
||||
loggedIn: true, loginTime: Date.now(), lastActivity: Date.now(),
|
||||
hasDm: result.hasDm, modemType: result.modemType,
|
||||
};
|
||||
|
||||
sessions.set(ip, session);
|
||||
resetIdleTimer(ip);
|
||||
|
||||
console.log(`[modem-bridge] Logged in to ${ip} (hasDm: ${loginResult.hasDm})`);
|
||||
return { ip, loggedIn: true, loginTime: session.loginTime, hasDm: loginResult.hasDm };
|
||||
console.log(`[modem-bridge] Logged in to ${ip} (type: ${result.modemType}, hasDm: ${result.hasDm})`);
|
||||
return { ip, loggedIn: true, loginTime: session.loginTime, hasDm: result.hasDm, modemType: result.modemType };
|
||||
}
|
||||
|
||||
// --- Data access (TP-Link specific: $.dm) ---
|
||||
|
||||
async function dmGet(ip, oid, opts = {}) {
|
||||
const s = getSession(ip);
|
||||
if (!s.hasDm) throw new Error(`Data manager not available on ${ip} (type: ${s.modemType})`);
|
||||
const stack = opts.stack || '0,0,0,0,0,0';
|
||||
const attrs = opts.attrs || null;
|
||||
|
||||
return await s.page.evaluate(({ oid, stack, attrs }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!$ || !$.dm || !$.dm.get) { reject(new Error('Data manager not available')); return; }
|
||||
|
||||
const req = {
|
||||
oid,
|
||||
data: { stack },
|
||||
callback: {
|
||||
const req = { oid, data: { stack }, callback: {
|
||||
success: (data, others) => resolve({ success: true, data, others }),
|
||||
fail: (errorcode, others, data) => resolve({ success: false, errorcode, data }),
|
||||
error: (status) => reject(new Error('Request error: ' + status))
|
||||
}
|
||||
};
|
||||
}};
|
||||
if (attrs) req.data.attrs = attrs;
|
||||
|
||||
try { $.dm.get(req); } catch(e) { reject(e); }
|
||||
setTimeout(() => reject(new Error('DM request timeout')), 8000);
|
||||
});
|
||||
|
|
@ -143,62 +262,31 @@ async function dmGet(ip, oid, opts = {}) {
|
|||
|
||||
async function cgiRequest(ip, oidPath) {
|
||||
const s = getSession(ip);
|
||||
|
||||
if (!s.hasDm) throw new Error(`CGI not available on ${ip} (type: ${s.modemType})`);
|
||||
return await s.page.evaluate((oidPath) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!$ || !$.dm || !$.dm.cgi) { reject(new Error('CGI not available')); return; }
|
||||
|
||||
$.dm.cgi({
|
||||
oid: oidPath,
|
||||
callback: {
|
||||
$.dm.cgi({ oid: oidPath, callback: {
|
||||
success: (data) => resolve({ success: true, data }),
|
||||
fail: (errorcode) => resolve({ success: false, errorcode })
|
||||
}
|
||||
});
|
||||
}});
|
||||
setTimeout(() => reject(new Error('CGI request timeout')), 8000);
|
||||
});
|
||||
}, oidPath);
|
||||
}
|
||||
|
||||
async function getStatus(ip) {
|
||||
const s = getSession(ip);
|
||||
|
||||
return await s.page.evaluate(() => {
|
||||
const status = {};
|
||||
try { if (typeof deviceInfo !== 'undefined') status.deviceInfo = deviceInfo; } catch(e) {}
|
||||
try { if ($ && $.status) status.cached = $.status; } catch(e) {}
|
||||
try {
|
||||
status.dom = {};
|
||||
document.querySelectorAll('[id*="status"], [id*="info"], [class*="status"]').forEach(el => {
|
||||
if (el.textContent.trim().length < 200) status.dom[el.id || el.className] = el.textContent.trim();
|
||||
});
|
||||
} catch(e) {}
|
||||
return status;
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshot(ip) {
|
||||
const s = getSession(ip);
|
||||
return await s.page.screenshot({ type: 'png', fullPage: false });
|
||||
}
|
||||
// --- TP-Link WiFi diagnostic ($.dm based) ---
|
||||
|
||||
async function wifiDiagnostic(ip) {
|
||||
const s = getSession(ip);
|
||||
if (s.modemType !== MODEM_TYPES.TPLINK_XX230V) throw new Error(`wifiDiagnostic not supported on ${s.modemType}`);
|
||||
|
||||
return await s.page.evaluate(() => {
|
||||
const start = Date.now();
|
||||
|
||||
// Factory for $.dm query calls — handles both getSubList and get
|
||||
function dmQuery(method, oid, extraData) {
|
||||
return new Promise((resolve) => {
|
||||
if (!$ || !$.dm || !$.dm[method]) { resolve({ oid, error: `${method} not available` }); return; }
|
||||
const req = {
|
||||
oid,
|
||||
callback: {
|
||||
success: (data) => resolve({ oid, data }),
|
||||
fail: (code) => resolve({ oid, error: code }),
|
||||
}
|
||||
};
|
||||
const req = { oid, callback: { success: (data) => resolve({ oid, data }), fail: (code) => resolve({ oid, error: code }) }};
|
||||
if (extraData) req.data = extraData;
|
||||
$.dm[method](req);
|
||||
setTimeout(() => resolve({ oid, error: 'timeout' }), 8000);
|
||||
|
|
@ -216,7 +304,7 @@ async function wifiDiagnostic(ip) {
|
|||
dmQuery('get', 'DEV2_ONLINESTATUS', { stack: '0,0,0,0,0,0' }),
|
||||
]).then(results => {
|
||||
const keys = ['radios', 'meshNodes', 'nodeRadios', 'clients', 'clientStats', 'packetStats', 'wanIPs', 'onlineStatus'];
|
||||
const out = { fetchedAt: new Date().toISOString(), durationMs: Date.now() - start };
|
||||
const out = { fetchedAt: new Date().toISOString(), durationMs: Date.now() - start, modemType: 'tplink_xx230v' };
|
||||
results.forEach((r, i) => {
|
||||
const val = r.status === 'fulfilled' ? r.value : { error: r.reason };
|
||||
out[keys[i]] = val.error ? { error: val.error } : val.data;
|
||||
|
|
@ -226,6 +314,257 @@ async function wifiDiagnostic(ip) {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Raisecom diagnostic (targeted JS extraction) ---
|
||||
|
||||
// Raisecom BOA pages embed data as JS arrays: var arr=[]; arr.push(new it_nr("idx", new it("key", val), ...))
|
||||
// We fetch each page's raw HTML and return it for server-side parsing by diagnostic-normalizer.js
|
||||
|
||||
const RAISECOM_BOA_PAGES = [
|
||||
{ path: '/status_ethernet_info.asp', key: 'ethernet' }, // Ethernet ports + DHCP clients
|
||||
{ path: '/status_device_basic_info.asp', key: 'deviceInfo' }, // Model, serial, firmware, CPU, uptime
|
||||
{ path: '/status_wlan_info_11n.asp', key: 'wlan' }, // WiFi clients with RSSI, rates
|
||||
{ path: '/status_net_connet_info.asp', key: 'wan' }, // WAN interfaces, IPs, gateways
|
||||
{ path: '/status_gpon.asp', key: 'gpon' }, // GPON optical power, link state
|
||||
];
|
||||
|
||||
// WS2 (PHP) uses /sys_manager/list_status.php?parts=X and /interface/ pages
|
||||
const RAISECOM_PHP_PAGES = [
|
||||
{ path: '/sys_manager/list_status.php?parts=hostinfo', key: 'deviceInfo' },
|
||||
{ path: '/sys_manager/list_status.php?parts=waninfo', key: 'wan' },
|
||||
{ path: '/sys_manager/list_status.php?parts=laninfo', key: 'ethernet' },
|
||||
{ path: '/sys_manager/list_status.php?parts=wlaninfo', key: 'wlan' },
|
||||
{ path: '/sys_manager/list_status.php?parts=poninfo', key: 'gpon' },
|
||||
{ path: '/sys_manager/list_session.php', key: 'sessions' },
|
||||
];
|
||||
|
||||
async function raisecomDiagnostic(ip) {
|
||||
const s = getSession(ip);
|
||||
if (!s.modemType.startsWith('raisecom')) throw new Error(`raisecomDiagnostic not supported on ${s.modemType}`);
|
||||
|
||||
const start = Date.now();
|
||||
const pages = s.modemType === MODEM_TYPES.RAISECOM_BOA ? RAISECOM_BOA_PAGES : RAISECOM_PHP_PAGES;
|
||||
const proto = s.modemType === MODEM_TYPES.RAISECOM_PHP ? 'https' : 'http';
|
||||
const baseUrl = `${proto}://${ip}`;
|
||||
const rawPages = {};
|
||||
|
||||
for (const pg of pages) {
|
||||
try {
|
||||
// Fetch page HTML directly (faster than navigating + waiting for render)
|
||||
const html = await s.page.evaluate((url) => fetch(url).then(r => r.text()), `${baseUrl}${pg.path}`);
|
||||
rawPages[pg.key] = html;
|
||||
} catch (e) {
|
||||
rawPages[pg.key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - start,
|
||||
modemType: s.modemType,
|
||||
rawPages,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Unified diagnostic (routes to correct handler, returns normalized data) ---
|
||||
|
||||
async function unifiedDiagnostic(ip) {
|
||||
const s = getSession(ip);
|
||||
const { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink } = require('./diagnostic-normalizer');
|
||||
|
||||
if (s.modemType === MODEM_TYPES.TPLINK_XX230V) {
|
||||
const raw = await wifiDiagnostic(ip);
|
||||
const result = normalizeTplink(raw);
|
||||
result.fetchedAt = raw.fetchedAt;
|
||||
result.durationMs = raw.durationMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (s.modemType.startsWith('raisecom')) {
|
||||
const raw = await raisecomDiagnostic(ip);
|
||||
const normalize = s.modemType === MODEM_TYPES.RAISECOM_PHP ? normalizeRaisecomPhp : normalizeRaisecomBoa;
|
||||
const result = normalize(raw.rawPages);
|
||||
result.fetchedAt = raw.fetchedAt;
|
||||
result.durationMs = raw.durationMs;
|
||||
|
||||
// WS2 (PHP) only allows 1 session — auto-logout to free it for the user
|
||||
if (s.modemType === MODEM_TYPES.RAISECOM_PHP) {
|
||||
const proto = 'https';
|
||||
s.page.evaluate((url) => fetch(url).catch(() => {}), `${proto}://${ip}/top_operation.php?top_parts=login_out_time`).catch(() => {});
|
||||
closeSession(ip).catch(() => {});
|
||||
console.log(`[modem-bridge] Auto-logout WS2 ${ip} after diagnostic`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported modem type: ${s.modemType}`);
|
||||
}
|
||||
|
||||
// --- Stateless one-shot diagnostic (no session reuse) ---
|
||||
// Single browser context: open page → detect type → login → scrape → logout → close
|
||||
// No separate identify() call — avoids WS2 session-slot consumption
|
||||
|
||||
async function oneshotDiagnostic(ip, user, pass) {
|
||||
const start = Date.now();
|
||||
const { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink } = require('./diagnostic-normalizer');
|
||||
|
||||
// Kill any existing session for this IP first
|
||||
if (sessions.has(ip)) {
|
||||
await closeSession(ip).catch(() => {});
|
||||
}
|
||||
|
||||
const b = await getBrowser();
|
||||
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// --- Phase 1: detect modem type from the same page/context we'll login with ---
|
||||
let modemType = MODEM_TYPES.UNKNOWN;
|
||||
let proto = 'https';
|
||||
|
||||
for (const p of ['https', 'http']) {
|
||||
try {
|
||||
await page.goto(`${p}://${ip}/`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
const html = await page.content();
|
||||
const title = await page.title();
|
||||
|
||||
if (html.includes('pc-login-password') || html.includes('tpLang') || html.includes('INCLUDE_LOGIN')) {
|
||||
modemType = MODEM_TYPES.TPLINK_XX230V; proto = p; break;
|
||||
}
|
||||
if (html.includes('subcustom') && html.includes('raisecom') && html.includes('formLogin')) {
|
||||
modemType = MODEM_TYPES.RAISECOM_BOA; proto = p; break;
|
||||
}
|
||||
if ((title.includes('Web user login') || html.includes('Web user login')) && html.includes('user_name') && html.includes('encrypt')) {
|
||||
modemType = MODEM_TYPES.RAISECOM_PHP; proto = p; break;
|
||||
}
|
||||
} catch { continue; }
|
||||
}
|
||||
|
||||
console.log(`[modem-bridge] oneshot ${ip}: type=${modemType} proto=${proto}`);
|
||||
if (modemType === MODEM_TYPES.UNKNOWN) throw new Error(`Unknown modem type at ${ip}`);
|
||||
|
||||
// --- Phase 2: login on the SAME page (no new context) ---
|
||||
let hasDm = false;
|
||||
|
||||
if (modemType === MODEM_TYPES.TPLINK_XX230V) {
|
||||
// TP-Link: navigate to superadmin, fill password
|
||||
await page.goto(`${proto}://${ip}/superadmin`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
||||
await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS });
|
||||
const hasUser = await page.evaluate(() => { const el = document.getElementById('pc-login-user-div'); return el && !el.classList.contains('nd'); });
|
||||
if (hasUser) await page.fill('#pc-login-user', user);
|
||||
await page.fill('#pc-login-password', pass);
|
||||
await page.waitForTimeout(300);
|
||||
const navP = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await page.click('#pc-login-btn');
|
||||
await navP;
|
||||
await page.waitForTimeout(2000);
|
||||
const forceBtn = await page.$('#confirm-yes');
|
||||
if (forceBtn && await forceBtn.isVisible()) {
|
||||
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await forceBtn.click();
|
||||
await navP2;
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
hasDm = await page.evaluate(() => typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function');
|
||||
|
||||
} else if (modemType === MODEM_TYPES.RAISECOM_BOA) {
|
||||
// BOA: already on login page from detection, fill form
|
||||
await page.waitForSelector('input[name="username"]', { timeout: LOGIN_TIMEOUT_MS });
|
||||
await page.fill('input[name="username"]', user);
|
||||
await page.fill('input[name="psd"]', pass);
|
||||
await page.waitForTimeout(200);
|
||||
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await page.evaluate(() => document.forms[0].submit());
|
||||
await navP;
|
||||
await page.waitForTimeout(2000);
|
||||
const html = await page.content();
|
||||
if (html.includes('formLogin') || html.includes('User Login')) throw new Error(`Raisecom BOA login failed for ${ip}`);
|
||||
|
||||
} else if (modemType === MODEM_TYPES.RAISECOM_PHP) {
|
||||
// WS2: already on login page from detection, fill and submit
|
||||
await page.waitForSelector('input[name="user_name"]', { timeout: LOGIN_TIMEOUT_MS });
|
||||
await page.fill('input[name="user_name"]', user);
|
||||
await page.fill('input[name="password"]', pass);
|
||||
await page.waitForTimeout(300);
|
||||
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
||||
await page.click('input[name="login"]').catch(() => {
|
||||
return page.evaluate(() => { if (typeof mySubmit === 'function') mySubmit(document.forms[0]); else document.forms[0].submit(); });
|
||||
});
|
||||
await navP;
|
||||
await page.waitForTimeout(3000);
|
||||
const html = await page.content();
|
||||
if (html.includes('Web user login') && html.includes('user_name')) throw new Error(`Raisecom PHP login failed for ${ip}`);
|
||||
}
|
||||
|
||||
console.log(`[modem-bridge] oneshot ${ip}: logged in (${modemType})`);
|
||||
|
||||
// --- Phase 3: register temp session, run diagnostic ---
|
||||
const session = {
|
||||
ip, context, page, loggedIn: true, loginTime: Date.now(),
|
||||
lastActivity: Date.now(), hasDm, modemType,
|
||||
};
|
||||
sessions.set(ip, session);
|
||||
|
||||
let result;
|
||||
if (modemType === MODEM_TYPES.TPLINK_XX230V) {
|
||||
const raw = await wifiDiagnostic(ip);
|
||||
result = normalizeTplink(raw);
|
||||
result.fetchedAt = raw.fetchedAt;
|
||||
} else {
|
||||
const raw = await raisecomDiagnostic(ip);
|
||||
const normalize = modemType === MODEM_TYPES.RAISECOM_PHP ? normalizeRaisecomPhp : normalizeRaisecomBoa;
|
||||
result = normalize(raw.rawPages);
|
||||
result.fetchedAt = new Date().toISOString();
|
||||
}
|
||||
result.durationMs = Date.now() - start;
|
||||
|
||||
// --- Phase 4: logout + close ---
|
||||
if (modemType === MODEM_TYPES.RAISECOM_PHP) {
|
||||
try {
|
||||
// Navigate to logout page (not fetch — ensures modem processes it fully)
|
||||
await page.goto(`${proto}://${ip}/top_operation.php?top_parts=login_out_time`, { waitUntil: 'domcontentloaded', timeout: 8000 }).catch(() => {});
|
||||
// Give the modem time to release the session slot
|
||||
await page.waitForTimeout(2000);
|
||||
} catch {}
|
||||
console.log(`[modem-bridge] oneshot ${ip}: WS2 logged out`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
sessions.delete(ip);
|
||||
await context.close().catch(() => {});
|
||||
console.log(`[modem-bridge] oneshot ${ip}: done (${Date.now() - start}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generic status (works for all types) ---
|
||||
|
||||
async function getStatus(ip) {
|
||||
const s = getSession(ip);
|
||||
if (s.modemType === MODEM_TYPES.TPLINK_XX230V) {
|
||||
return await s.page.evaluate(() => {
|
||||
const status = {};
|
||||
try { if (typeof deviceInfo !== 'undefined') status.deviceInfo = deviceInfo; } catch(e) {}
|
||||
try { if ($ && $.status) status.cached = $.status; } catch(e) {}
|
||||
try {
|
||||
status.dom = {};
|
||||
document.querySelectorAll('[id*="status"], [id*="info"], [class*="status"]').forEach(el => {
|
||||
if (el.textContent.trim().length < 200) status.dom[el.id || el.className] = el.textContent.trim();
|
||||
});
|
||||
} catch(e) {}
|
||||
return status;
|
||||
});
|
||||
}
|
||||
// Raisecom: return page scrape
|
||||
return raisecomDiagnostic(ip);
|
||||
}
|
||||
|
||||
async function screenshot(ip) {
|
||||
const s = getSession(ip);
|
||||
return await s.page.screenshot({ type: 'png', fullPage: false });
|
||||
}
|
||||
|
||||
async function evaluate(ip, code) {
|
||||
const s = getSession(ip);
|
||||
return await s.page.evaluate(new Function('return (' + code + ')'));
|
||||
|
|
@ -238,7 +577,8 @@ async function getPageContent(ip) {
|
|||
|
||||
async function navigate(ip, path) {
|
||||
const s = getSession(ip);
|
||||
await s.page.goto(`https://${ip}${path}`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
||||
const proto = s.modemType.startsWith('raisecom') ? 'http' : 'https';
|
||||
await s.page.goto(`${proto}://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function closeSession(ip) {
|
||||
|
|
@ -246,30 +586,27 @@ async function closeSession(ip) {
|
|||
if (!s) return;
|
||||
clearTimeout(s.idleTimer);
|
||||
try { await s.context.close(); }
|
||||
catch(e) { console.warn(`[modem-bridge] Error closing session for ${ip}:`, e.message); }
|
||||
catch(e) { console.warn(`[modem-bridge] Error closing ${ip}:`, e.message); }
|
||||
sessions.delete(ip);
|
||||
console.log(`[modem-bridge] Session closed for ${ip}`);
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
for (const [ip] of sessions) await closeSession(ip);
|
||||
if (browser) { await browser.close(); browser = null; }
|
||||
console.log('[modem-bridge] Shutdown complete');
|
||||
}
|
||||
|
||||
function listSessions() {
|
||||
const result = [];
|
||||
for (const [ip, s] of sessions) {
|
||||
result.push({
|
||||
return [...sessions.entries()].map(([ip, s]) => ({
|
||||
ip, loggedIn: s.loggedIn, loginTime: s.loginTime,
|
||||
lastActivity: s.lastActivity, idleMs: Date.now() - s.lastActivity, hasDm: s.hasDm
|
||||
});
|
||||
}
|
||||
return result;
|
||||
lastActivity: s.lastActivity, idleMs: Date.now() - s.lastActivity,
|
||||
hasDm: s.hasDm, modemType: s.modemType,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login, dmGet, cgiRequest, getStatus, wifiDiagnostic,
|
||||
identify, login, dmGet, cgiRequest, getStatus,
|
||||
wifiDiagnostic, raisecomDiagnostic, unifiedDiagnostic, oneshotDiagnostic,
|
||||
evaluate, screenshot, getPageContent, navigate,
|
||||
closeSession, shutdown, listSessions
|
||||
closeSession, shutdown, listSessions,
|
||||
MODEM_TYPES,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -89,6 +89,31 @@ const server = http.createServer(async (req, res) => {
|
|||
return json(res, { ok: true });
|
||||
}
|
||||
|
||||
// POST /diagnostic/oneshot — stateless: login → scrape → logout → close (no session reuse)
|
||||
if (path === '/diagnostic/oneshot' && method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
const { ip, user, pass } = body;
|
||||
if (!ip || !user || !pass) return err(res, 'Missing required fields: ip, user, pass');
|
||||
if (!isPrivateIp(ip)) return err(res, 'IP must be in a private range');
|
||||
try { json(res, await tp.oneshotDiagnostic(ip, user, pass)); }
|
||||
catch(e) { err(res, e.message, 500); }
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /identify/:ip — auto-detect modem vendor and type
|
||||
const idMatch = path.match(/^\/identify\/(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (idMatch && method === 'GET') {
|
||||
if (!isPrivateIp(idMatch[1])) return err(res, 'IP must be in a private range');
|
||||
try { json(res, await tp.identify(idMatch[1])); }
|
||||
catch(e) { err(res, e.message, 500); }
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /modem-types — list known modem types
|
||||
if (path === '/modem-types' && method === 'GET') {
|
||||
return json(res, tp.MODEM_TYPES);
|
||||
}
|
||||
|
||||
const modemMatch = path.match(/^\/modem\/(\d+\.\d+\.\d+\.\d+)\//);
|
||||
if (modemMatch) {
|
||||
const ip = modemMatch[1];
|
||||
|
|
@ -117,6 +142,14 @@ const server = http.createServer(async (req, res) => {
|
|||
return await modemHandler(res, () => tp.wifiDiagnostic(ip));
|
||||
}
|
||||
|
||||
if (subPath === '/diagnostic/full' && method === 'GET') {
|
||||
return await modemHandler(res, () => tp.unifiedDiagnostic(ip));
|
||||
}
|
||||
|
||||
if (subPath === '/diagnostic' && method === 'GET') {
|
||||
return await modemHandler(res, () => tp.raisecomDiagnostic(ip));
|
||||
}
|
||||
|
||||
if (subPath === '/screenshot' && method === 'GET') {
|
||||
try {
|
||||
const buf = await tp.screenshot(ip);
|
||||
|
|
|
|||
|
|
@ -500,6 +500,15 @@ async function handle (req, res, method, path) {
|
|||
userAgent: ua,
|
||||
})
|
||||
log(`Quotation accepted: ${payload.doc} by ${payload.sub} from ${ip}`)
|
||||
|
||||
// Fire flow trigger (on_quotation_accepted). Non-blocking.
|
||||
try {
|
||||
require('./flow-runtime').dispatchEvent('on_quotation_accepted', {
|
||||
doctype: 'Quotation', docname: payload.doc, customer: payload.sub,
|
||||
variables: { method: 'JWT', ip, user_agent: ua },
|
||||
}).catch(e => log('flow trigger on_quotation_accepted failed:', e.message))
|
||||
} catch (e) { log('flow trigger load error:', e.message) }
|
||||
|
||||
return json(res, 200, { ok: true, quotation: payload.doc, message: 'Devis accepté. Le projet a été lancé.' })
|
||||
} catch (e) {
|
||||
log('Accept confirm error:', e.message)
|
||||
|
|
@ -522,17 +531,28 @@ async function handle (req, res, method, path) {
|
|||
if (use_docuseal && DOCUSEAL_URL && DOCUSEAL_KEY) {
|
||||
const acceptLink = generateAcceptanceLink(quotation, customer, ttl_hours || 168)
|
||||
const dsResult = await createDocuSealSubmission({
|
||||
templateId: docuseal_template_id || 1,
|
||||
templateId: docuseal_template_id || cfg.DOCUSEAL_DEFAULT_TEMPLATE_ID || 1,
|
||||
email: email || '',
|
||||
name: customer,
|
||||
phone: phone || '',
|
||||
values: { 'Nom': customer, 'Quotation': quotation },
|
||||
values: { 'Nom': customer, 'Customer': customer, 'Quotation': quotation },
|
||||
completedRedirectUrl: acceptLink + '?signed=1',
|
||||
})
|
||||
if (dsResult) {
|
||||
result.method = 'docuseal'
|
||||
result.sign_url = dsResult.signUrl
|
||||
result.submission_id = dsResult.submissionId
|
||||
|
||||
// Persist signing URL on the Quotation so the print-format QR code is populated
|
||||
try {
|
||||
const { erpFetch } = require('./helpers')
|
||||
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ custom_docuseal_signing_url: dsResult.signUrl }),
|
||||
})
|
||||
} catch (e) {
|
||||
log('Failed to save DocuSeal signing URL to Quotation:', e.message)
|
||||
}
|
||||
} else {
|
||||
// Fallback to JWT if DocuSeal fails
|
||||
result.method = 'jwt'
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ module.exports = {
|
|||
CLIENT_PUBLIC_URL: env('CLIENT_PUBLIC_URL', 'https://msg.gigafibre.ca'),
|
||||
AI_API_KEY: env('AI_API_KEY'),
|
||||
AI_MODEL: env('AI_MODEL', 'gemini-2.5-flash'),
|
||||
AI_FALLBACK_MODEL: env('AI_FALLBACK_MODEL', 'gemini-2.0-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/'),
|
||||
VOICE_MODEL: env('VOICE_MODEL', 'models/gemini-2.5-flash-live-preview'),
|
||||
OLT_HOST: env('OLT_HOST'),
|
||||
|
|
@ -57,8 +57,10 @@ module.exports = {
|
|||
EXTERNAL_URL: env('EXTERNAL_URL', 'https://msg.gigafibre.ca'),
|
||||
TRACCAR_USER: env('TRACCAR_USER', 'louis@targo.ca'),
|
||||
TRACCAR_PASS: env('TRACCAR_PASS'),
|
||||
TRACCAR_TOKEN: env('TRACCAR_TOKEN'),
|
||||
DOCUSEAL_URL: env('DOCUSEAL_URL', 'https://sign.gigafibre.ca'),
|
||||
DOCUSEAL_API_KEY: env('DOCUSEAL_API_KEY'),
|
||||
DOCUSEAL_DEFAULT_TEMPLATE_ID: int('DOCUSEAL_DEFAULT_TEMPLATE_ID', 1),
|
||||
// Stripe
|
||||
STRIPE_SECRET_KEY: env('STRIPE_SECRET_KEY'),
|
||||
STRIPE_WEBHOOK_SECRET: env('STRIPE_WEBHOOK_SECRET'),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ const cfg = require('./config')
|
|||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||
const { signJwt, verifyJwt } = require('./magic-link')
|
||||
|
||||
// Lazy-loaded flow runtime. Kept local so the require() cost isn't paid on
|
||||
// modules that don't trigger flows.
|
||||
let _flowRuntime
|
||||
function _fireFlowTrigger (event, ctx) {
|
||||
if (!_flowRuntime) _flowRuntime = require('./flow-runtime')
|
||||
return _flowRuntime.dispatchEvent(event, ctx)
|
||||
}
|
||||
|
||||
// Résidentiel:
|
||||
// - Pénalité = valeur résiduelle des avantages non compensés
|
||||
// - Chaque mois d'abonnement "reconnaît" (compense) benefit_value / duration_months
|
||||
|
|
@ -396,6 +404,20 @@ async function handle (req, res, method, path) {
|
|||
})
|
||||
|
||||
log(`Contract ${contractName} accepted by ${payload.sub}`)
|
||||
|
||||
// Fire flow trigger: on_contract_signed
|
||||
// Non-blocking — flow runtime handles its own errors so the acceptance
|
||||
// endpoint never fails because of downstream automation.
|
||||
_fireFlowTrigger('on_contract_signed', {
|
||||
doctype: 'Service Contract',
|
||||
docname: contractName,
|
||||
customer: payload.sub,
|
||||
variables: {
|
||||
contract_type: payload.contract_type,
|
||||
signed_at: now,
|
||||
},
|
||||
}).catch(e => log('flow trigger on_contract_signed failed:', e.message))
|
||||
|
||||
return json(res, 200, { ok: true, contract: contractName })
|
||||
}
|
||||
|
||||
|
|
@ -455,74 +477,116 @@ async function createTerminationInvoice (contract, calc, reason) {
|
|||
}
|
||||
|
||||
function renderAcceptancePage (contract, token) {
|
||||
const benefits = (contract.benefits || []).map(b => {
|
||||
const val = (b.regular_price || 0) - (b.granted_price || 0)
|
||||
const monthly = round2(val / (contract.duration_months || 24))
|
||||
const benefitRows = (contract.benefits || []).map(b => {
|
||||
return `<tr>
|
||||
<td>${b.description}</td>
|
||||
<td style="text-align:right">${b.regular_price || 0}$</td>
|
||||
<td style="text-align:right">${b.granted_price || 0}$</td>
|
||||
<td style="text-align:right">${monthly}$/mois compensé</td>
|
||||
<td class="r"><s style="color:#9ca3af">${b.regular_price || 0} $</s> → <strong>${b.granted_price || 0} $</strong></td>
|
||||
</tr>`
|
||||
}).join('')
|
||||
|
||||
const duration = contract.duration_months || 24
|
||||
const totalBenefit = (contract.benefits || []).reduce(
|
||||
(s, b) => s + ((b.regular_price || 0) - (b.granted_price || 0)), 0,
|
||||
)
|
||||
|
||||
const TARGO_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" width="130" height="28"><path fill="#019547" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path fill="#019547" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path fill="#019547" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path fill="#019547" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path fill="#019547" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>`
|
||||
|
||||
const isCommercial = contract.contract_type === 'Commercial'
|
||||
const penaltyText = isCommercial
|
||||
? `la totalité des mensualités restantes au terme (${contract.monthly_rate || 0} $/mois × mois restants)`
|
||||
: `la portion non étalée de la promotion, au prorata des mois restants sur ${duration} mois`
|
||||
|
||||
return `<!DOCTYPE html><html lang="fr"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Entente de service — Gigafibre</title>
|
||||
<title>Entente de service — TARGO</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,system-ui,sans-serif;background:#f8fafc;color:#1e293b;padding:16px}
|
||||
.card{background:#fff;border-radius:12px;padding:24px;max-width:500px;margin:0 auto;box-shadow:0 2px 8px rgba(0,0,0,.08)}
|
||||
h1{font-size:20px;margin-bottom:4px}
|
||||
.sub{color:#64748b;font-size:14px;margin-bottom:20px}
|
||||
.field{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f1f5f9}
|
||||
.field .label{color:#64748b;font-size:14px}
|
||||
.field .value{font-weight:600}
|
||||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
|
||||
th{text-align:left;padding:6px 8px;background:#f1f5f9;font-weight:600}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f7fa;color:#1a1a1a;padding:16px;line-height:1.5}
|
||||
.card{background:#fff;border-radius:14px;padding:24px;max-width:560px;margin:0 auto;box-shadow:0 4px 16px rgba(0,0,0,.06)}
|
||||
.brand{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:2px solid #019547;margin-bottom:18px}
|
||||
.brand .num{margin-left:auto;font-size:12px;color:#888;text-align:right}
|
||||
h1{font-size:20px;font-weight:800;color:#019547;margin-bottom:4px;letter-spacing:.2px}
|
||||
.sub{color:#6b7280;font-size:13px;margin-bottom:18px}
|
||||
|
||||
.field{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #f1f5f9}
|
||||
.field:last-child{border-bottom:none}
|
||||
.field .label{color:#6b7280;font-size:13px}
|
||||
.field .value{font-weight:600;font-size:14px;text-align:right}
|
||||
|
||||
h3{font-size:14px;color:#019547;margin-top:18px;margin-bottom:8px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
|
||||
|
||||
table{width:100%;border-collapse:collapse;margin:6px 0 10px 0;font-size:12.5px}
|
||||
th{text-align:left;padding:6px 8px;background:#f4fff6;color:#00733a;font-weight:700;border-bottom:2px solid #019547}
|
||||
th.r,td.r{text-align:right}
|
||||
td{padding:6px 8px;border-bottom:1px solid #f1f5f9}
|
||||
.note{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px;margin:16px 0;font-size:13px;line-height:1.5}
|
||||
.btn{display:block;width:100%;padding:14px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:16px;font-weight:600;cursor:pointer;margin-top:20px}
|
||||
.btn:active{background:#059669}
|
||||
tfoot td{font-weight:700;color:#00733a;background:#f4fff6;border-top:1px solid #019547;border-bottom:none}
|
||||
|
||||
.callout{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:12px 14px;margin:14px 0;font-size:13px;line-height:1.55}
|
||||
.callout .head{color:#b45309;font-weight:700;margin-bottom:4px}
|
||||
|
||||
.legal{font-size:12px;color:#6b7280;margin-top:14px;line-height:1.55}
|
||||
.legal a{color:#019547;text-decoration:none;font-weight:600}
|
||||
|
||||
.btn{display:block;width:100%;padding:16px;background:#019547;color:#fff;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;margin-top:18px;letter-spacing:.3px}
|
||||
.btn:active{background:#01733a}
|
||||
.btn:disabled{background:#9ca3af;cursor:wait}
|
||||
|
||||
.ok{text-align:center;padding:40px 20px}
|
||||
.ok h2{color:#10b981;margin-bottom:8px}
|
||||
.ok .check{width:64px;height:64px;border-radius:50%;background:#019547;color:#fff;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px;font-weight:700}
|
||||
.ok h2{color:#019547;margin-bottom:8px;font-size:22px}
|
||||
.ok p{color:#4b5563;font-size:14px}
|
||||
#result{display:none}
|
||||
</style></head><body>
|
||||
|
||||
<div class="card" id="contract">
|
||||
<h1>Entente de service</h1>
|
||||
<div class="sub">Gigafibre — TARGO Communications Inc.</div>
|
||||
|
||||
<div class="field"><span class="label">Client</span><span class="value">${contract.customer_name || contract.customer}</span></div>
|
||||
<div class="field"><span class="label">Type</span><span class="value">${contract.contract_type}</span></div>
|
||||
<div class="field"><span class="label">Mensualité</span><span class="value">${contract.monthly_rate}$/mois</span></div>
|
||||
<div class="field"><span class="label">Durée</span><span class="value">${contract.duration_months} mois</span></div>
|
||||
<div class="field"><span class="label">Début</span><span class="value">${contract.start_date || 'À déterminer'}</span></div>
|
||||
|
||||
${benefits ? `
|
||||
<h3 style="margin-top:16px;font-size:15px">Avantages accordés</h3>
|
||||
<table><thead><tr><th>Description</th><th>Rég.</th><th>Accordé</th><th>Reconnaissance</th></tr></thead>
|
||||
<tbody>${benefits}</tbody></table>` : ''}
|
||||
|
||||
${contract.invoice_note ? `<div class="note">${contract.invoice_note.replace(/\n/g, '<br>')}</div>` : ''}
|
||||
|
||||
<div style="font-size:12px;color:#94a3b8;margin-top:16px;line-height:1.6">
|
||||
En acceptant, vous confirmez avoir lu et compris les termes de cette entente de service.
|
||||
${contract.contract_type === 'Résidentiel'
|
||||
? 'En cas de résiliation anticipée, les avantages non compensés au pro-rata des mois écoulés seront facturés.'
|
||||
: 'En cas de résiliation anticipée, la totalité des mensualités restantes au contrat sera facturée.'}
|
||||
<div class="brand">
|
||||
${TARGO_LOGO_SVG}
|
||||
<div class="num">Référence<br><strong>${contract.name || ''}</strong></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="accept()">✓ J'accepte cette entente</button>
|
||||
<h1>Récapitulatif de votre service</h1>
|
||||
<div class="sub">Un résumé clair des modalités convenues — rien de plus.</div>
|
||||
|
||||
<div class="intro" style="background:#f4fff6;border:1px solid #bde5cb;border-radius:10px;padding:12px 14px;margin-bottom:16px;font-size:13px;color:#00733a;line-height:1.55">
|
||||
Voici un récapitulatif de votre service Gigafibre. Il reprend ce que nous avons convenu ensemble pour que tout soit clair des deux côtés. Aucune surprise — tout est écrit noir sur blanc ci-dessous.
|
||||
</div>
|
||||
|
||||
<div class="field"><span class="label">Client</span><span class="value">${escapeHtml(contract.customer_name || contract.customer || '')}</span></div>
|
||||
<div class="field"><span class="label">Mensualité</span><span class="value">${contract.monthly_rate || 0} $/mois <span style="color:#9ca3af;font-weight:400;font-size:12px">(+taxes)</span></span></div>
|
||||
<div class="field"><span class="label">Durée</span><span class="value">${duration} mois</span></div>
|
||||
<div class="field"><span class="label">Début prévu</span><span class="value">${contract.start_date || 'À déterminer'}</span></div>
|
||||
|
||||
${benefitRows ? `
|
||||
<h3>Promotions appliquées</h3>
|
||||
<table>
|
||||
<tbody>${benefitRows}</tbody>
|
||||
<tfoot>
|
||||
<tr><td>Valeur totale de la promotion</td><td class="r">${round2(totalBenefit)} $</td></tr>
|
||||
<tr><td colspan="2" style="font-weight:400;color:#00733a;font-size:12px;text-align:left;">Étalée sur ${duration} mois</td></tr>
|
||||
</tfoot>
|
||||
</table>` : ''}
|
||||
|
||||
<div class="callout">
|
||||
<div class="head">Changement avant ${duration} mois ?</div>
|
||||
Pas de pénalité. On récupère simplement ${penaltyText}. Rien de plus.
|
||||
</div>
|
||||
|
||||
<div class="legal">
|
||||
En cliquant <strong>« J'accepte »</strong>, vous confirmez que les informations ci-dessus correspondent à ce que nous avons convenu. Les <a href="https://www.targo.ca/conditions" target="_blank">conditions complètes du service</a> (facturation, équipement, Loi 25, juridiction du Québec) s'appliquent également. Votre confirmation est horodatée.
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="accept()">J'accepte ce récapitulatif</button>
|
||||
</div>
|
||||
|
||||
<div class="card ok" id="result">
|
||||
<h2>✓ Entente acceptée</h2>
|
||||
<p>Merci ! Votre acceptation a été enregistrée.</p>
|
||||
<div class="check">✓</div>
|
||||
<h2>C'est confirmé !</h2>
|
||||
<p>Merci ! Une copie du récapitulatif vous sera envoyée par courriel sous peu.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function accept(){
|
||||
const btn=document.querySelector('.btn');btn.disabled=true;btn.textContent='En cours...';
|
||||
const btn=document.querySelector('.btn');btn.disabled=true;btn.textContent='Enregistrement...';
|
||||
try{
|
||||
const r=await fetch('/contract/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${token}'})});
|
||||
const d=await r.json();
|
||||
|
|
@ -533,6 +597,12 @@ async function accept(){
|
|||
</script></body></html>`
|
||||
}
|
||||
|
||||
function escapeHtml (s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]))
|
||||
}
|
||||
|
||||
function monthsBetween (d1, d2) {
|
||||
return (d2.getFullYear() - d1.getFullYear()) * 12 + (d2.getMonth() - d1.getMonth())
|
||||
}
|
||||
|
|
@ -545,4 +615,4 @@ function addMonths (dateStr, months) {
|
|||
|
||||
function round2 (v) { return Math.round(v * 100) / 100 }
|
||||
|
||||
module.exports = { handle, calculateTerminationFee, generateInvoiceNote, generateContractLink }
|
||||
module.exports = { handle, calculateTerminationFee, generateInvoiceNote, generateContractLink, renderAcceptancePage }
|
||||
|
|
|
|||
|
|
@ -6,9 +6,18 @@ const findWanIp = d => {
|
|||
return pub ? pub.ip : ''
|
||||
}
|
||||
|
||||
const classifyIpRole = (ip, name = '') => {
|
||||
if (ip.startsWith('192.168.') || (ip.startsWith('10.') && name === 'br0')) return 'lan'
|
||||
if (ip.startsWith('172.17.') || ip.startsWith('172.16.') || name.includes('_10') || name.includes('Management')) return 'management'
|
||||
if (ip.startsWith('10.')) return 'service'
|
||||
if (!ip.startsWith('169.254.')) return 'internet'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const extractAllInterfaces = d => {
|
||||
// Device:2 model (TP-Link)
|
||||
const ipIfaces = d.Device?.IP?.Interface
|
||||
if (!ipIfaces) return []
|
||||
if (ipIfaces) {
|
||||
const results = []
|
||||
for (const ifKey of Object.keys(ipIfaces)) {
|
||||
if (ifKey.startsWith('_')) continue
|
||||
|
|
@ -22,14 +31,30 @@ const extractAllInterfaces = d => {
|
|||
if (!ip || ip === '0.0.0.0') continue
|
||||
const status = addr.Status?._value
|
||||
if (status && status !== 'Enabled') continue
|
||||
const role = (ip.startsWith('192.168.') || (ip.startsWith('10.') && name === 'br0')) ? 'lan'
|
||||
: (!ip.startsWith('10.') && !ip.startsWith('172.') && !ip.startsWith('192.168.') && !ip.startsWith('169.254.')) ? 'internet'
|
||||
: (ip.startsWith('172.17.') || ip.startsWith('172.16.') || name.includes('_10')) ? 'management'
|
||||
: 'service'
|
||||
results.push({ iface: ifKey, name, ip, mask: addr.SubnetMask?._value || '', addrType: addr.AddressingType?._value || '', role })
|
||||
results.push({ iface: ifKey, name, ip, mask: addr.SubnetMask?._value || '', addrType: addr.AddressingType?._value || '', role: classifyIpRole(ip, name) })
|
||||
}
|
||||
}
|
||||
return results
|
||||
if (results.length) return results
|
||||
}
|
||||
|
||||
// IGD model (Raisecom) — WANDevice.1.WANConnectionDevice.*.WANIPConnection.*
|
||||
const wanDev = d.InternetGatewayDevice?.WANDevice?.['1']?.WANConnectionDevice
|
||||
if (wanDev) {
|
||||
const results = []
|
||||
for (const cdKey of Object.keys(wanDev)) {
|
||||
if (cdKey.startsWith('_')) continue
|
||||
const cd = wanDev[cdKey]
|
||||
const wanIp = cd?.WANIPConnection?.['1']
|
||||
if (!wanIp) continue
|
||||
const ip = wanIp.ExternalIPAddress?._value
|
||||
if (!ip || ip === '0.0.0.0') continue
|
||||
const name = wanIp.Name?._value || `WAN${cdKey}`
|
||||
const status = wanIp.ConnectionStatus?._value || ''
|
||||
results.push({ iface: cdKey, name, ip, mask: wanIp.SubnetMask?._value || '', addrType: wanIp.AddressingType?._value || '', role: classifyIpRole(ip, name), status })
|
||||
}
|
||||
if (results.length) return results
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const countAllWifiClients = d => {
|
||||
|
|
@ -74,6 +99,25 @@ const extractMeshTopology = d => {
|
|||
return nodes.length ? { nodes, totalClients } : null
|
||||
}
|
||||
|
||||
const extractIgdWifiSSIDs = d => {
|
||||
const wlanCfg = d.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration
|
||||
if (!wlanCfg) return null
|
||||
const ssids = []
|
||||
for (const k of Object.keys(wlanCfg)) {
|
||||
if (k.startsWith('_')) continue
|
||||
const cfg = wlanCfg[k]
|
||||
const ssid = cfg?.SSID?._value
|
||||
if (!ssid) continue
|
||||
ssids.push({
|
||||
index: parseInt(k),
|
||||
ssid,
|
||||
band: ssid.endsWith('-5G') ? '5GHz' : '2.4GHz',
|
||||
enabled: cfg.Enable?._value === true || cfg.Enable?._value === 'true',
|
||||
})
|
||||
}
|
||||
return ssids.length ? ssids : null
|
||||
}
|
||||
|
||||
const summarizeDevice = d => {
|
||||
const g = path => deepGetValue(d, path)
|
||||
const s = path => { const v = g(path); return v != null ? String(v) : '' }
|
||||
|
|
@ -87,9 +131,9 @@ const summarizeDevice = d => {
|
|||
|| d.DeviceID?.SerialNumber?._value
|
||||
|| (d._id ? decodeURIComponent(d._id.split('-').slice(2).join('-')) : '')
|
||||
|| '',
|
||||
manufacturer: s('DeviceID.Manufacturer') || d.DeviceID?.Manufacturer?._value || '',
|
||||
model: s('DeviceID.ProductClass') || d.DeviceID?.ProductClass?._value || '',
|
||||
oui: s('DeviceID.OUI') || d.DeviceID?.OUI?._value || '',
|
||||
manufacturer: s('DeviceID.Manufacturer') || d.DeviceID?.Manufacturer?._value || d._deviceId?._Manufacturer || '',
|
||||
model: s('DeviceID.ProductClass') || d.DeviceID?.ProductClass?._value || d._deviceId?._ProductClass || s('InternetGatewayDevice.DeviceInfo.ModelName') || '',
|
||||
oui: s('DeviceID.OUI') || d.DeviceID?.OUI?._value || d._deviceId?._OUI || '',
|
||||
firmware: s('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || s('Device.DeviceInfo.SoftwareVersion') || '',
|
||||
uptime: g('InternetGatewayDevice.DeviceInfo.UpTime') || g('Device.DeviceInfo.UpTime') || null,
|
||||
lastInform: d._lastInform || null, lastBoot: d._lastBootstrap || null, registered: d._registered || null,
|
||||
|
|
@ -118,11 +162,16 @@ const summarizeDevice = d => {
|
|||
meshClients: mesh ? (mesh.totalClients - counts.direct) : counts.mesh,
|
||||
},
|
||||
mesh: mesh ? mesh.nodes : null,
|
||||
hostsCount: g('Device.Hosts.HostNumberOfEntries') || null,
|
||||
hostsCount: g('Device.Hosts.HostNumberOfEntries')
|
||||
|| g('InternetGatewayDevice.LANDevice.1.Hosts.HostNumberOfEntries') || null,
|
||||
ethernet: {
|
||||
port1: { status: s('Device.Ethernet.Interface.1.Status') || null, speed: g('Device.Ethernet.Interface.1.MaxBitRate') || null },
|
||||
port2: { status: s('Device.Ethernet.Interface.2.Status') || null, speed: g('Device.Ethernet.Interface.2.MaxBitRate') || null },
|
||||
port1: { status: s('Device.Ethernet.Interface.1.Status') || s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.1.Status') || null, speed: g('Device.Ethernet.Interface.1.MaxBitRate') || null },
|
||||
port2: { status: s('Device.Ethernet.Interface.2.Status') || s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.2.Status') || null, speed: g('Device.Ethernet.Interface.2.MaxBitRate') || null },
|
||||
port3: { status: s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.3.Status') || null },
|
||||
port4: { status: s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.4.Status') || null },
|
||||
},
|
||||
// IGD WiFi SSIDs (Raisecom — no per-radio stats, just SSIDs)
|
||||
wifiSSIDs: extractIgdWifiSSIDs(d),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ async function initCacheDb () {
|
|||
await pool.query(`CREATE TABLE IF NOT EXISTS device_cache (serial TEXT PRIMARY KEY, summary JSONB NOT NULL DEFAULT '{}', previous JSONB, history JSONB NOT NULL DEFAULT '[]', updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS hosts_cache (serial TEXT PRIMARY KEY, data JSONB NOT NULL DEFAULT '{}', updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS device_events (id SERIAL PRIMARY KEY, serial TEXT NOT NULL, event TEXT NOT NULL, reason TEXT, details JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS modem_diagnostic_cache (serial TEXT PRIMARY KEY, diagnostic JSONB NOT NULL DEFAULT '{}', updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_device_events_serial ON device_events (serial, created_at DESC)`)
|
||||
const { rows } = await pool.query('SELECT serial, summary, previous, history, updated_at FROM device_cache')
|
||||
for (const row of rows) {
|
||||
|
|
@ -205,6 +206,8 @@ async function pollOnlineStatus () {
|
|||
allDevices.push(...page)
|
||||
skip += page.length
|
||||
if (page.length < pageSize || allDevices.length >= 10000) break
|
||||
// Pause between pages to avoid overwhelming GenieACS
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
} catch (e) { log(`Poll fetch error (skip=${skip}): ${e.message} — processing ${allDevices.length} devices`) }
|
||||
|
||||
|
|
@ -283,6 +286,21 @@ async function fetchDeviceDetails (serial) {
|
|||
devices = Array.isArray(r.data) ? r.data : []
|
||||
}
|
||||
|
||||
// Raisecom RCMG → try tag + UserName in GenieACS
|
||||
if (!devices.length && serial.startsWith('RCMG')) {
|
||||
try {
|
||||
q = JSON.stringify({ '_tags': serial })
|
||||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
devices = Array.isArray(r.data) ? r.data : []
|
||||
if (!devices.length) {
|
||||
q = JSON.stringify({ 'InternetGatewayDevice.X_CT-COM_UserInfo.UserName._value': serial })
|
||||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
devices = Array.isArray(r.data) ? r.data : []
|
||||
}
|
||||
if (devices.length) log(`Device ${serial} found via RCMG tag/UserName`)
|
||||
} catch (e) { log(`RCMG lookup failed for ${serial}: ${e.message}`) }
|
||||
}
|
||||
|
||||
if (!devices.length) {
|
||||
q = JSON.stringify({ '_id': { '$regex': serial } })
|
||||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
|
|
@ -396,6 +414,42 @@ async function handle (req, res, method, path, url) {
|
|||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
devices = Array.isArray(r.data) ? r.data : []
|
||||
}
|
||||
// Raisecom RCMG → resolve via OLT coords (slot/port/ontid) → SNMP WAN IP → GenieACS
|
||||
if (!devices.length && serial.startsWith('RCMG')) {
|
||||
try {
|
||||
// 1. Try GenieACS tag match
|
||||
q = JSON.stringify({ '_tags': serial })
|
||||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
devices = Array.isArray(r.data) ? r.data : []
|
||||
if (devices.length) { log(`Lookup ${serial}: found via tag`); }
|
||||
|
||||
// 2. Try GenieACS UserName field (older Raisecom firmware stores RCMG here)
|
||||
if (!devices.length) {
|
||||
q = JSON.stringify({ 'InternetGatewayDevice.X_CT-COM_UserInfo.UserName._value': serial })
|
||||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
devices = Array.isArray(r.data) ? r.data : []
|
||||
if (devices.length) log(`Lookup ${serial}: found via UserName`)
|
||||
}
|
||||
|
||||
// 3. Resolve current WAN IP via OLT SNMP coords → search GenieACS by IP
|
||||
if (!devices.length) {
|
||||
const oltIp = url.searchParams.get('olt_ip')
|
||||
const slot = url.searchParams.get('olt_slot')
|
||||
const port = url.searchParams.get('olt_port')
|
||||
const ontid = url.searchParams.get('olt_ontid')
|
||||
if (oltIp && slot && port && ontid) {
|
||||
const { getManageIp } = require('./olt-snmp')
|
||||
const mgmt = await getManageIp(null, { oltIp, slot: parseInt(slot), port: parseInt(port), ontId: parseInt(ontid) })
|
||||
if (mgmt?.manageIp) {
|
||||
q = JSON.stringify({ 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress._value': mgmt.manageIp })
|
||||
r = await nbiRequest(`/devices/?query=${encodeURIComponent(q)}&projection=${projection}`)
|
||||
devices = Array.isArray(r.data) ? r.data : []
|
||||
if (devices.length) log(`Lookup ${serial}: found via OLT SNMP ${oltIp} ${slot}/${port}/${ontid} → IP ${mgmt.manageIp}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { log(`RCMG lookup error for ${serial}: ${e.message}`) }
|
||||
}
|
||||
} else if (mac) {
|
||||
const cleanMac = mac.replace(/[:-]/g, '').toUpperCase()
|
||||
const q = JSON.stringify({ '$or': [
|
||||
|
|
|
|||
|
|
@ -14,6 +14,30 @@ function todayET () {
|
|||
return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||
}
|
||||
|
||||
function nowHoursET () {
|
||||
const parts = new Date().toLocaleString('en-CA', { timeZone: 'America/Toronto', hour12: false }).split(' ')
|
||||
const [hh, mm] = parts[1].split(':').map(Number)
|
||||
return hh + (mm || 0) / 60
|
||||
}
|
||||
|
||||
function timeToHours (t) {
|
||||
if (!t) return 0
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
return h + (m || 0) / 60
|
||||
}
|
||||
|
||||
function hoursToTime (h) {
|
||||
const hh = Math.floor(h)
|
||||
const mm = Math.round((h - hh) * 60)
|
||||
return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0')
|
||||
}
|
||||
|
||||
function dateAddDays (baseStr, n) {
|
||||
const d = new Date(baseStr + 'T12:00:00')
|
||||
d.setDate(d.getDate() + n)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// Euclidean approximation, km at Montreal latitude
|
||||
function distKm (a, b) {
|
||||
if (!a || !b) return 999
|
||||
|
|
@ -99,6 +123,147 @@ function rankTechs (techs, jobCoords, jobDuration = 1) {
|
|||
}).sort((a, b) => a.score - b.score)
|
||||
}
|
||||
|
||||
// ── Slot suggestion ────────────────────────────────────────────────────────
|
||||
// Finds open time windows across techs for the next N days. Keeps the logic
|
||||
// simple & predictable: gaps between pinned jobs within a default shift
|
||||
// window, minus a travel buffer before the new job, minus a "not in the
|
||||
// past" cutoff. Scores surface the most natural inserts (earliest first,
|
||||
// then shortest travel), with a 2-slot-per-tech cap to diversify results.
|
||||
const SLOT_DEFAULT_SHIFT = { start_h: 8, end_h: 17 }
|
||||
const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed start
|
||||
const SLOT_HORIZON_DAYS = 7
|
||||
const SLOT_MAX_PER_TECH = 2
|
||||
|
||||
async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5 } = {}) {
|
||||
const baseDate = after_date || todayET()
|
||||
const duration = parseFloat(duration_h) || 1
|
||||
const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i))
|
||||
const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null
|
||||
|
||||
const [techRes, jobRes] = await Promise.all([
|
||||
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
|
||||
'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude',
|
||||
'absence_from', 'absence_until',
|
||||
]))}&limit_page_length=50`),
|
||||
erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
|
||||
['status', 'in', ['open', 'assigned']],
|
||||
['scheduled_date', '>=', dates[0]],
|
||||
['scheduled_date', '<=', dates[dates.length - 1]],
|
||||
]))}&fields=${encodeURIComponent(JSON.stringify([
|
||||
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h',
|
||||
'longitude', 'latitude',
|
||||
]))}&limit_page_length=500`),
|
||||
])
|
||||
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
|
||||
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable')
|
||||
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
|
||||
|
||||
const today = todayET()
|
||||
const nowH = nowHoursET()
|
||||
const slots = []
|
||||
|
||||
for (const tech of techs) {
|
||||
const homeCoords = tech.longitude && tech.latitude
|
||||
? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null
|
||||
|
||||
for (const dateStr of dates) {
|
||||
// Absence window skip.
|
||||
if (tech.absence_from && tech.absence_until &&
|
||||
dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue
|
||||
|
||||
// Day's pinned jobs (only those with a real start_time — floating jobs
|
||||
// without a time are ignored since we don't know when they'll land).
|
||||
const dayJobs = allJobs
|
||||
.filter(j => j.assigned_tech === tech.technician_id &&
|
||||
j.scheduled_date === dateStr && j.start_time)
|
||||
.map(j => {
|
||||
const s = timeToHours(j.start_time)
|
||||
return {
|
||||
start_h: s,
|
||||
end_h: s + (parseFloat(j.duration_h) || 1),
|
||||
coords: j.latitude && j.longitude ? [parseFloat(j.longitude), parseFloat(j.latitude)] : null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.start_h - b.start_h)
|
||||
|
||||
// Build gaps bounded by shift_start/end.
|
||||
const shift = SLOT_DEFAULT_SHIFT
|
||||
const gaps = []
|
||||
let cursor = shift.start_h, prevCoords = homeCoords
|
||||
for (const j of dayJobs) {
|
||||
gaps.push({
|
||||
start_h: cursor, end_h: j.start_h, prev_coords: prevCoords,
|
||||
position: gaps.length === 0 ? 'first' : 'between',
|
||||
})
|
||||
cursor = j.end_h
|
||||
prevCoords = j.coords
|
||||
}
|
||||
gaps.push({
|
||||
start_h: cursor, end_h: shift.end_h, prev_coords: prevCoords,
|
||||
position: dayJobs.length === 0 ? 'free_day' : 'last',
|
||||
})
|
||||
|
||||
for (const g of gaps) {
|
||||
const gapLen = g.end_h - g.start_h
|
||||
if (gapLen < duration + SLOT_TRAVEL_BUFFER_H) continue
|
||||
|
||||
const startH = g.start_h + SLOT_TRAVEL_BUFFER_H
|
||||
const endH = startH + duration
|
||||
if (endH > g.end_h) continue
|
||||
|
||||
// Skip slots already in the past (or within 30 min).
|
||||
if (dateStr === today && startH < nowH + 0.5) continue
|
||||
|
||||
const distanceKm = jobCoords && g.prev_coords ? distKm(g.prev_coords, jobCoords) : null
|
||||
const travelMin = distanceKm != null
|
||||
? Math.max(5, Math.min(90, Math.round(distanceKm * 1.5)))
|
||||
: Math.round(SLOT_TRAVEL_BUFFER_H * 60)
|
||||
|
||||
const reasons = []
|
||||
if (g.position === 'free_day') reasons.push('Journée libre')
|
||||
else if (g.position === 'first') reasons.push('Début de journée')
|
||||
else if (g.position === 'last') reasons.push('Fin de journée')
|
||||
else reasons.push('Entre 2 rendez-vous')
|
||||
if (distanceKm != null) reasons.push(`${distanceKm.toFixed(1)} km du précédent`)
|
||||
|
||||
slots.push({
|
||||
tech_id: tech.technician_id,
|
||||
tech_name: tech.full_name,
|
||||
date: dateStr,
|
||||
start_time: hoursToTime(startH),
|
||||
end_time: hoursToTime(endH),
|
||||
travel_min: travelMin,
|
||||
distance_km: distanceKm != null ? +distanceKm.toFixed(1) : null,
|
||||
gap_h: +gapLen.toFixed(1),
|
||||
reasons,
|
||||
position: g.position,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: earliest first, then shortest travel.
|
||||
slots.sort((a, b) => {
|
||||
if (a.date !== b.date) return a.date < b.date ? -1 : 1
|
||||
if (a.start_time !== b.start_time) return a.start_time < b.start_time ? -1 : 1
|
||||
return (a.travel_min || 0) - (b.travel_min || 0)
|
||||
})
|
||||
|
||||
// Diversify: cap slots-per-tech so we don't return 5 options from the
|
||||
// same person. Dispatchers want to compare across resources.
|
||||
const byTech = {}
|
||||
const picked = []
|
||||
for (const s of slots) {
|
||||
byTech[s.tech_id] = byTech[s.tech_id] || 0
|
||||
if (byTech[s.tech_id] >= SLOT_MAX_PER_TECH) continue
|
||||
picked.push(s)
|
||||
byTech[s.tech_id]++
|
||||
if (picked.length >= limit) break
|
||||
}
|
||||
|
||||
return picked
|
||||
}
|
||||
|
||||
async function createDispatchJob ({ subject, address, priority, duration_h, job_type, customer, service_location, source_issue, notes, latitude, longitude, assigned_tech, scheduled_date }) {
|
||||
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase()
|
||||
const payload = {
|
||||
|
|
@ -142,6 +307,18 @@ async function handle (req, res, method, path) {
|
|||
}
|
||||
}
|
||||
|
||||
// POST /dispatch/suggest-slots — return 5 best available time windows
|
||||
if (sub === 'suggest-slots' && method === 'POST') {
|
||||
try {
|
||||
const body = await parseBody(req)
|
||||
const slots = await suggestSlots(body)
|
||||
return json(res, 200, { slots })
|
||||
} catch (e) {
|
||||
log('suggest-slots error:', e.message)
|
||||
return json(res, 500, { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /dispatch/create-job — create + optionally auto-assign to best tech
|
||||
if (sub === 'create-job' && method === 'POST') {
|
||||
try {
|
||||
|
|
@ -222,4 +399,4 @@ async function agentCreateDispatchJob ({ customer_id, service_location, subject,
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps }
|
||||
module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots }
|
||||
|
|
|
|||
126
services/targo-hub/lib/flow-api.js
Normal file
126
services/targo-hub/lib/flow-api.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
'use strict'
|
||||
/**
|
||||
* flow-api.js — HTTP endpoints for the flow runtime.
|
||||
*
|
||||
* POST /flow/start { template, doctype?, docname?, customer?, variables? }
|
||||
* POST /flow/advance { run } — re-evaluate a run (idempotent)
|
||||
* POST /flow/complete { run, step_id, result? } — external completion (scheduler / webhook / manual)
|
||||
* POST /flow/event { event, ctx? } — dispatch a trigger event to all listening templates
|
||||
* GET /flow/runs ?customer=…&template=…&status=…
|
||||
* GET /flow/runs/:name — fetch a run (with step_state parsed)
|
||||
*
|
||||
* Security: internal endpoints (start/advance/complete/event) optionally require
|
||||
* cfg.INTERNAL_TOKEN via `Authorization: Bearer …` so only the ERPNext scheduler
|
||||
* and trusted services can launch flows.
|
||||
*/
|
||||
|
||||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||
const runtime = require('./flow-runtime')
|
||||
const cfg = require('./config')
|
||||
|
||||
const ENC_FR = encodeURIComponent('Flow Run')
|
||||
|
||||
// ── Auth guard (soft — allows open in dev when INTERNAL_TOKEN is unset) ────
|
||||
|
||||
function checkInternalAuth (req) {
|
||||
if (!cfg.INTERNAL_TOKEN) return true
|
||||
const a = req.headers.authorization || ''
|
||||
return a === 'Bearer ' + cfg.INTERNAL_TOKEN
|
||||
}
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function startFlow (req, res) {
|
||||
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
|
||||
const body = await parseBody(req)
|
||||
if (!body.template) return json(res, 400, { error: 'template required' })
|
||||
try {
|
||||
const result = await runtime.startFlow(body.template, {
|
||||
doctype: body.doctype,
|
||||
docname: body.docname,
|
||||
customer: body.customer,
|
||||
variables: body.variables || {},
|
||||
triggerEvent: body.trigger_event || body.triggerEvent,
|
||||
})
|
||||
return json(res, 201, result)
|
||||
} catch (e) {
|
||||
log('[flow-api] start failed:', e.message)
|
||||
return json(res, 500, { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceFlow (req, res) {
|
||||
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
|
||||
const body = await parseBody(req)
|
||||
if (!body.run) return json(res, 400, { error: 'run required' })
|
||||
try { return json(res, 200, await runtime.advanceFlow(body.run)) }
|
||||
catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
async function completeStep (req, res) {
|
||||
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
|
||||
const body = await parseBody(req)
|
||||
if (!body.run || !body.step_id) return json(res, 400, { error: 'run and step_id required' })
|
||||
try { return json(res, 200, await runtime.completeStep(body.run, body.step_id, body.result || {})) }
|
||||
catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
async function dispatchEvent (req, res) {
|
||||
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
|
||||
const body = await parseBody(req)
|
||||
if (!body.event) return json(res, 400, { error: 'event required' })
|
||||
try {
|
||||
const results = await runtime.dispatchEvent(body.event, body.ctx || {})
|
||||
return json(res, 200, { event: body.event, started: results.length, results })
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function listRuns (req, res, urlObj) {
|
||||
const p = Object.fromEntries(urlObj.searchParams)
|
||||
const filters = []
|
||||
if (p.customer) filters.push(['customer', '=', p.customer])
|
||||
if (p.template) filters.push(['flow_template', '=', p.template])
|
||||
if (p.status) filters.push(['status', '=', p.status])
|
||||
if (p.doctype && p.docname) {
|
||||
filters.push(['context_doctype', '=', p.doctype])
|
||||
filters.push(['context_docname', '=', p.docname])
|
||||
}
|
||||
const qs = new URLSearchParams({
|
||||
fields: JSON.stringify(['name', 'flow_template', 'status', 'customer', 'context_doctype', 'context_docname', 'started_at', 'completed_at', 'last_error']),
|
||||
filters: JSON.stringify(filters),
|
||||
limit_page_length: String(p.limit || 50),
|
||||
order_by: 'started_at desc',
|
||||
})
|
||||
const r = await erpFetch(`/api/resource/${ENC_FR}?${qs}`)
|
||||
if (r.status !== 200) return json(res, r.status, { error: 'Failed to list', detail: r.data })
|
||||
return json(res, 200, { runs: r.data.data || [] })
|
||||
}
|
||||
|
||||
async function getRun (req, res, name) {
|
||||
const r = await erpFetch(`/api/resource/${ENC_FR}/${encodeURIComponent(name)}`)
|
||||
if (r.status !== 200) return json(res, r.status, { error: 'Not found' })
|
||||
const run = r.data.data
|
||||
try { run.variables = JSON.parse(run.variables || '{}') } catch { /* noop */ }
|
||||
try { run.step_state = JSON.parse(run.step_state || '{}') } catch { /* noop */ }
|
||||
return json(res, 200, { run })
|
||||
}
|
||||
|
||||
// ── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handle (req, res, method, urlPath, urlObj) {
|
||||
if (urlPath === '/flow/start' && method === 'POST') return startFlow(req, res)
|
||||
if (urlPath === '/flow/advance' && method === 'POST') return advanceFlow(req, res)
|
||||
if (urlPath === '/flow/complete' && method === 'POST') return completeStep(req, res)
|
||||
if (urlPath === '/flow/event' && method === 'POST') return dispatchEvent(req, res)
|
||||
|
||||
const runMatch = urlPath.match(/^\/flow\/runs\/([^/]+)$/)
|
||||
if (runMatch && method === 'GET') return getRun(req, res, decodeURIComponent(runMatch[1]))
|
||||
|
||||
if (urlPath === '/flow/runs' && method === 'GET') return listRuns(req, res, urlObj)
|
||||
|
||||
return json(res, 404, { error: 'Flow endpoint not found' })
|
||||
}
|
||||
|
||||
module.exports = { handle }
|
||||
671
services/targo-hub/lib/flow-runtime.js
Normal file
671
services/targo-hub/lib/flow-runtime.js
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
'use strict'
|
||||
/**
|
||||
* flow-runtime.js — Execution engine for Flow Templates.
|
||||
*
|
||||
* Responsibilities
|
||||
* ────────────────
|
||||
* 1. startFlow(templateName, ctx) → creates a Flow Run + kicks off the first wave of steps
|
||||
* 2. advanceFlow(runName) → evaluates which steps are ready and runs them
|
||||
* 3. completeStep(runName, stepId, result) → mark a step done and advance the flow
|
||||
* 4. Kind dispatcher: KIND_HANDLERS[step.kind](step, ctx) → { status, result }
|
||||
* 5. Scheduling: any step with trigger.type === 'after_delay' | 'on_date' creates
|
||||
* a Flow Step Pending row instead of executing inline
|
||||
*
|
||||
* Context shape
|
||||
* ─────────────
|
||||
* ctx = {
|
||||
* run: { name, variables, step_state }, // the Flow Run doc
|
||||
* template: { flow_definition, … }, // the Flow Template doc
|
||||
* customer: { name, customer_name, … }, // resolved customer doc
|
||||
* doc: { … }, // the trigger doc (contract, quotation …)
|
||||
* doctype, docname, // trigger doc reference
|
||||
* }
|
||||
*
|
||||
* Step state (persisted in Flow Run.step_state)
|
||||
* ─────────────────────────────────────────────
|
||||
* { [stepId]: { status: 'pending|running|done|failed|scheduled|skipped',
|
||||
* started_at, completed_at, result, error, retry_count } }
|
||||
*
|
||||
* See docs/FLOW_EDITOR_ARCHITECTURE.md for full data model + trigger wiring.
|
||||
*/
|
||||
|
||||
const { log, erpFetch } = require('./helpers')
|
||||
const cfg = require('./config')
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const FT_DOCTYPE = 'Flow Template'
|
||||
const FR_DOCTYPE = 'Flow Run'
|
||||
const FS_DOCTYPE = 'Flow Step Pending'
|
||||
|
||||
const ENC_FT = encodeURIComponent(FT_DOCTYPE)
|
||||
const ENC_FR = encodeURIComponent(FR_DOCTYPE)
|
||||
const ENC_FS = encodeURIComponent(FS_DOCTYPE)
|
||||
|
||||
/** All step statuses (for type-safety when reading step_state). */
|
||||
const STATUS = {
|
||||
PENDING: 'pending',
|
||||
RUNNING: 'running',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
SCHEDULED: 'scheduled',
|
||||
SKIPPED: 'skipped',
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Template rendering (simple {{var.path}} interpolation, no Mustache lib)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMPLATE_RE = /\{\{\s*([\w.]+)\s*\}\}/g
|
||||
|
||||
/** Follow dotted paths through nested objects. Safe on missing keys. */
|
||||
function getPath (obj, path) {
|
||||
if (!obj || !path) return undefined
|
||||
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj)
|
||||
}
|
||||
|
||||
/** Replace {{a.b.c}} tokens with ctx values; leaves non-strings alone. */
|
||||
function render (tpl, ctx) {
|
||||
if (typeof tpl !== 'string') return tpl
|
||||
return tpl.replace(TEMPLATE_RE, (match, path) => {
|
||||
const val = getPath(ctx, path)
|
||||
return val == null ? '' : String(val)
|
||||
})
|
||||
}
|
||||
|
||||
/** Deep-render every string inside an object. Recursive; arrays preserved. */
|
||||
function renderDeep (input, ctx) {
|
||||
if (input == null) return input
|
||||
if (typeof input === 'string') return render(input, ctx)
|
||||
if (Array.isArray(input)) return input.map(v => renderDeep(v, ctx))
|
||||
if (typeof input === 'object') {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(input)) out[k] = renderDeep(v, ctx)
|
||||
return out
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Condition evaluator — simple predicate language (no eval)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate a condition payload against the context.
|
||||
* Shape: { field, op, value }
|
||||
* Ops: == != < > <= >= in not_in empty not_empty contains starts_with ends_with
|
||||
* Returns boolean.
|
||||
*/
|
||||
function evalCondition (p, ctx) {
|
||||
const left = getPath(ctx, p.field)
|
||||
const right = typeof p.value === 'string' ? render(p.value, ctx) : p.value
|
||||
switch (p.op) {
|
||||
case '==': return String(left) == String(right) // eslint-disable-line eqeqeq
|
||||
case '!=': return String(left) != String(right) // eslint-disable-line eqeqeq
|
||||
case '<': return Number(left) < Number(right)
|
||||
case '>': return Number(left) > Number(right)
|
||||
case '<=': return Number(left) <= Number(right)
|
||||
case '>=': return Number(left) >= Number(right)
|
||||
case 'in': return String(right).split(',').map(s => s.trim()).includes(String(left))
|
||||
case 'not_in':return !String(right).split(',').map(s => s.trim()).includes(String(left))
|
||||
case 'empty': return left == null || left === ''
|
||||
case 'not_empty': return left != null && left !== ''
|
||||
case 'contains': return String(left || '').includes(String(right))
|
||||
case 'starts_with':return String(left || '').startsWith(String(right))
|
||||
case 'ends_with': return String(left || '').endsWith(String(right))
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Kind handlers — one function per step kind. All return:
|
||||
// { status: 'done'|'failed'|'scheduled', result?, error? }
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a Dispatch Job. Returns the new job name in result. */
|
||||
async function handleDispatchJob (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
const payload = {
|
||||
doctype: 'Dispatch Job',
|
||||
customer: ctx.customer?.name || p.customer || '',
|
||||
customer_name: ctx.customer?.customer_name || '',
|
||||
subject: p.subject || step.label || 'Tâche',
|
||||
job_type: p.job_type || 'Autre',
|
||||
priority: p.priority || 'medium',
|
||||
duration_h: p.duration_h || 1,
|
||||
assigned_group: p.assigned_group || 'Tech Targo',
|
||||
status: 'open',
|
||||
flow_run: ctx.run?.name,
|
||||
flow_step_id: step.id,
|
||||
}
|
||||
if (p.service_location) payload.service_location = p.service_location
|
||||
else if (ctx.doc?.service_location) payload.service_location = ctx.doc.service_location
|
||||
if (p.merge_key) payload.merge_key = p.merge_key
|
||||
if (p.on_open_webhook) payload.on_open_webhook = p.on_open_webhook
|
||||
if (p.on_close_webhook) payload.on_close_webhook = p.on_close_webhook
|
||||
if (p.notes) payload.description = p.notes
|
||||
|
||||
const r = await erpFetch('/api/resource/Dispatch Job', {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
return { status: STATUS.FAILED, error: 'Dispatch Job creation failed: ' + JSON.stringify(r.data).slice(0, 200) }
|
||||
}
|
||||
return { status: STATUS.DONE, result: { job: r.data.data?.name } }
|
||||
}
|
||||
|
||||
/** Create an ERPNext Issue (ticket). */
|
||||
async function handleIssue (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
const payload = {
|
||||
doctype: 'Issue',
|
||||
subject: p.subject || step.label || 'Suivi',
|
||||
description: p.description || '',
|
||||
priority: p.priority || 'Medium',
|
||||
issue_type: p.issue_type || 'Suivi',
|
||||
status: 'Open',
|
||||
customer: ctx.customer?.name || null,
|
||||
flow_run: ctx.run?.name,
|
||||
flow_step_id: step.id,
|
||||
}
|
||||
const r = await erpFetch('/api/resource/Issue', {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
return { status: STATUS.FAILED, error: 'Issue creation failed: ' + JSON.stringify(r.data).slice(0, 200) }
|
||||
}
|
||||
return { status: STATUS.DONE, result: { issue: r.data.data?.name } }
|
||||
}
|
||||
|
||||
/** Send a notification (SMS or email) via existing Hub modules. */
|
||||
async function handleNotify (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
const channel = (p.channel || 'sms').toLowerCase()
|
||||
const to = p.to || ctx.customer?.cell_phone || ctx.customer?.primary_phone || ctx.customer?.email_id
|
||||
if (!to) return { status: STATUS.FAILED, error: 'notify: no recipient resolved' }
|
||||
|
||||
try {
|
||||
if (channel === 'sms') {
|
||||
const { sendSms } = require('./twilio')
|
||||
const body = p.body || (p.template_id ? _lookupTemplateText(p.template_id, ctx) : '')
|
||||
if (!body) return { status: STATUS.FAILED, error: 'notify: empty SMS body' }
|
||||
const r = await sendSms({ to, body })
|
||||
return { status: STATUS.DONE, result: { channel, sid: r?.sid, simulated: r?.simulated } }
|
||||
}
|
||||
if (channel === 'email') {
|
||||
const { sendEmail } = require('./email')
|
||||
const subject = p.subject || 'Notification'
|
||||
const html = p.body || _lookupTemplateText(p.template_id, ctx) || ''
|
||||
const r = await sendEmail({ to, subject, html })
|
||||
return { status: STATUS.DONE, result: { channel, id: r?.id } }
|
||||
}
|
||||
return { status: STATUS.FAILED, error: `notify: unknown channel "${channel}"` }
|
||||
} catch (e) {
|
||||
return { status: STATUS.FAILED, error: 'notify failed: ' + e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up a template body from email-templates.js (stubbed with empty fallback). */
|
||||
function _lookupTemplateText (templateId, ctx) {
|
||||
if (!templateId) return ''
|
||||
try {
|
||||
const tpls = require('./email-templates')
|
||||
const tpl = tpls[templateId] || tpls.TEMPLATES?.[templateId]
|
||||
if (!tpl) return ''
|
||||
const body = typeof tpl === 'string' ? tpl : (tpl.body || tpl.text || '')
|
||||
return render(body, ctx)
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
/** POST/GET/PUT/DELETE an external webhook. Body JSON-rendered from context. */
|
||||
async function handleWebhook (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
if (!p.url) return { status: STATUS.FAILED, error: 'webhook: url required' }
|
||||
const method = (p.method || 'POST').toUpperCase()
|
||||
let body
|
||||
if (p.body_template) {
|
||||
try { body = JSON.parse(p.body_template) }
|
||||
catch (e) { return { status: STATUS.FAILED, error: 'webhook: body_template is not valid JSON: ' + e.message } }
|
||||
}
|
||||
try {
|
||||
const r = await fetch(p.url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return {
|
||||
status: r.ok ? STATUS.DONE : STATUS.FAILED,
|
||||
result: { http_status: r.status },
|
||||
error: r.ok ? undefined : `HTTP ${r.status}`,
|
||||
}
|
||||
} catch (e) {
|
||||
return { status: STATUS.FAILED, error: 'webhook request failed: ' + e.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a field (or set of fields) on an existing ERPNext doc. */
|
||||
async function handleErpUpdate (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
if (!p.doctype || !p.docname_ref) {
|
||||
return { status: STATUS.FAILED, error: 'erp_update: doctype and docname_ref required' }
|
||||
}
|
||||
let fields
|
||||
try { fields = typeof p.fields_json === 'string' ? JSON.parse(p.fields_json) : (p.fields_json || {}) }
|
||||
catch (e) { return { status: STATUS.FAILED, error: 'erp_update: fields_json invalid: ' + e.message } }
|
||||
|
||||
const path = `/api/resource/${encodeURIComponent(p.doctype)}/${encodeURIComponent(p.docname_ref)}`
|
||||
const r = await erpFetch(path, { method: 'PUT', body: JSON.stringify(fields) })
|
||||
if (r.status !== 200) {
|
||||
return { status: STATUS.FAILED, error: `erp_update failed: HTTP ${r.status}` }
|
||||
}
|
||||
return { status: STATUS.DONE, result: { doctype: p.doctype, docname: p.docname_ref } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a Subscription (ERPNext Subscription doctype).
|
||||
* Expects payload.subscription_ref → name of the Subscription doc.
|
||||
*/
|
||||
async function handleSubscriptionActivate (step, ctx) {
|
||||
const p = renderDeep(step.payload || {}, ctx)
|
||||
const subName = p.subscription_ref || ctx.doc?.subscription
|
||||
if (!subName) return { status: STATUS.FAILED, error: 'subscription_activate: subscription_ref required' }
|
||||
const path = `/api/resource/Subscription/${encodeURIComponent(subName)}`
|
||||
const r = await erpFetch(path, { method: 'PUT', body: JSON.stringify({ status: 'Active' }) })
|
||||
if (r.status !== 200) {
|
||||
return { status: STATUS.FAILED, error: 'subscription_activate failed: HTTP ' + r.status }
|
||||
}
|
||||
return { status: STATUS.DONE, result: { subscription: subName } }
|
||||
}
|
||||
|
||||
/** Wait step — purely declarative; always resolves "done" inline. */
|
||||
function handleWait () {
|
||||
return { status: STATUS.DONE, result: { waited: true } }
|
||||
}
|
||||
|
||||
/** Condition step — evaluates the predicate; returns DONE with a branch hint. */
|
||||
function handleCondition (step, ctx) {
|
||||
const ok = evalCondition(step.payload || {}, ctx)
|
||||
return { status: STATUS.DONE, result: { branch: ok ? 'yes' : 'no' } }
|
||||
}
|
||||
|
||||
const KIND_HANDLERS = {
|
||||
dispatch_job: handleDispatchJob,
|
||||
issue: handleIssue,
|
||||
notify: handleNotify,
|
||||
webhook: handleWebhook,
|
||||
erp_update: handleErpUpdate,
|
||||
subscription_activate: handleSubscriptionActivate,
|
||||
wait: handleWait,
|
||||
condition: handleCondition,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Run-level persistence helpers
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function _fetchTemplate (name) {
|
||||
const r = await erpFetch(`/api/resource/${ENC_FT}/${encodeURIComponent(name)}`)
|
||||
if (r.status !== 200) throw new Error(`Template ${name} not found`)
|
||||
const tpl = r.data.data
|
||||
try { tpl.flow_definition = JSON.parse(tpl.flow_definition || '{}') }
|
||||
catch { tpl.flow_definition = { steps: [] } }
|
||||
if (!tpl.flow_definition.steps) tpl.flow_definition.steps = []
|
||||
return tpl
|
||||
}
|
||||
|
||||
async function _fetchRun (name) {
|
||||
const r = await erpFetch(`/api/resource/${ENC_FR}/${encodeURIComponent(name)}`)
|
||||
if (r.status !== 200) throw new Error(`Run ${name} not found`)
|
||||
const run = r.data.data
|
||||
run.variables = _parseJson(run.variables, {})
|
||||
run.step_state = _parseJson(run.step_state, {})
|
||||
return run
|
||||
}
|
||||
|
||||
function _parseJson (s, fallback) {
|
||||
if (!s) return fallback
|
||||
if (typeof s === 'object') return s
|
||||
try { return JSON.parse(s) } catch { return fallback }
|
||||
}
|
||||
|
||||
async function _persistRun (run, patch) {
|
||||
const body = { ...patch }
|
||||
if (body.variables && typeof body.variables !== 'string') body.variables = JSON.stringify(body.variables)
|
||||
if (body.step_state && typeof body.step_state !== 'string') body.step_state = JSON.stringify(body.step_state)
|
||||
const r = await erpFetch(`/api/resource/${ENC_FR}/${encodeURIComponent(run.name)}`, {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
})
|
||||
if (r.status !== 200) throw new Error('Failed to persist Flow Run: HTTP ' + r.status)
|
||||
}
|
||||
|
||||
async function _schedulePending (run, step, triggerAt) {
|
||||
const payload = {
|
||||
doctype: FS_DOCTYPE,
|
||||
flow_run: run.name,
|
||||
step_id: step.id,
|
||||
status: 'pending',
|
||||
trigger_at: triggerAt,
|
||||
context_snapshot: JSON.stringify({ step }),
|
||||
retry_count: 0,
|
||||
}
|
||||
const r = await erpFetch(`/api/resource/${ENC_FS}`, {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
throw new Error('Failed to schedule step: HTTP ' + r.status)
|
||||
}
|
||||
log(`[flow] scheduled ${step.id} @ ${triggerAt} → ${r.data.data?.name}`)
|
||||
return r.data.data?.name
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Trigger resolution (decides when a step is ready to run)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute a "trigger fire time" in ISO format for delayed steps.
|
||||
* Returns null for immediate triggers.
|
||||
*/
|
||||
function resolveTriggerAt (step, now = new Date()) {
|
||||
const t = step.trigger || {}
|
||||
if (t.type === 'after_delay') {
|
||||
const hours = Number(t.delay_hours || 0) + Number(t.delay_days || 0) * 24
|
||||
if (!hours) return null
|
||||
return new Date(now.getTime() + hours * 3600 * 1000).toISOString()
|
||||
}
|
||||
if (t.type === 'on_date' && t.at) {
|
||||
return new Date(t.at).toISOString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** A step is "ready" when all its depends_on are done + parent branch matches. */
|
||||
function isStepReady (step, state, def) {
|
||||
// Already handled?
|
||||
const s = state[step.id]
|
||||
if (s && (s.status === STATUS.DONE || s.status === STATUS.SCHEDULED || s.status === STATUS.FAILED)) return false
|
||||
|
||||
// All depends_on must be done
|
||||
for (const dep of step.depends_on || []) {
|
||||
if (state[dep]?.status !== STATUS.DONE) return false
|
||||
}
|
||||
|
||||
// Parent step must be done (if any), AND branch must match
|
||||
if (step.parent_id) {
|
||||
const parentState = state[step.parent_id]
|
||||
if (parentState?.status !== STATUS.DONE) return false
|
||||
const parentStep = def.steps.find(s2 => s2.id === step.parent_id)
|
||||
if (parentStep?.kind === 'condition' || parentStep?.kind === 'switch') {
|
||||
if (step.branch && parentState.result?.branch !== step.branch) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start a new flow run. Creates the Flow Run doc + immediately advances it.
|
||||
*
|
||||
* @param {string} templateName e.g. "FT-00005"
|
||||
* @param {Object} opts
|
||||
* - doctype, docname Trigger doc (contract, quotation, …)
|
||||
* - customer Customer docname (optional; derived from doc if absent)
|
||||
* - variables Runtime bag merged into context
|
||||
* - triggerEvent For audit (e.g. "on_contract_signed")
|
||||
* @returns {Object} { run, executed: [...stepIds], scheduled: [...stepIds] }
|
||||
*/
|
||||
async function startFlow (templateName, opts = {}) {
|
||||
const template = await _fetchTemplate(templateName)
|
||||
if (!template.is_active) {
|
||||
throw new Error(`Template ${templateName} is inactive`)
|
||||
}
|
||||
|
||||
// Create Flow Run
|
||||
const runPayload = {
|
||||
doctype: FR_DOCTYPE,
|
||||
flow_template: template.name,
|
||||
template_version: template.version || 1,
|
||||
status: 'running',
|
||||
trigger_event: opts.triggerEvent || template.trigger_event || 'manual',
|
||||
context_doctype: opts.doctype || template.applies_to || '',
|
||||
context_docname: opts.docname || '',
|
||||
customer: opts.customer || '',
|
||||
variables: JSON.stringify(opts.variables || {}),
|
||||
step_state: JSON.stringify({}),
|
||||
started_at: new Date().toISOString(),
|
||||
}
|
||||
const r = await erpFetch(`/api/resource/${ENC_FR}`, {
|
||||
method: 'POST', body: JSON.stringify(runPayload),
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
throw new Error('Failed to create Flow Run: HTTP ' + r.status + ' ' + JSON.stringify(r.data).slice(0, 200))
|
||||
}
|
||||
const run = r.data.data
|
||||
log(`[flow] started ${run.name} from ${templateName}`)
|
||||
|
||||
// Advance immediately (fires the first wave of ready steps)
|
||||
const result = await advanceFlow(run.name)
|
||||
return { run: result.run, ...result }
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all steps and run those that became ready. Idempotent: can be
|
||||
* called again after a pending step resolves (via completeStep), after a
|
||||
* webhook trigger, or from the scheduler.
|
||||
*
|
||||
* @param {string} runName
|
||||
* @returns {Object} { run, executed, scheduled, errors }
|
||||
*/
|
||||
async function advanceFlow (runName) {
|
||||
const run = await _fetchRun(runName)
|
||||
if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
|
||||
return { run, executed: [], scheduled: [], errors: [], done: true }
|
||||
}
|
||||
|
||||
const template = await _fetchTemplate(run.flow_template)
|
||||
const def = template.flow_definition
|
||||
|
||||
// Resolve trigger context once (lookup doc + customer)
|
||||
const ctx = await _buildContext(run, template)
|
||||
|
||||
const state = run.step_state
|
||||
const executed = []
|
||||
const scheduled = []
|
||||
const errors = []
|
||||
let mutations = 0
|
||||
|
||||
// Linear pass — tree is small, we can re-check after mutations
|
||||
// This loop continues until no more steps become ready (max 3 waves for safety)
|
||||
for (let wave = 0; wave < 50; wave++) {
|
||||
let progressed = false
|
||||
for (const step of def.steps) {
|
||||
if (!isStepReady(step, state, def)) continue
|
||||
const trigType = step.trigger?.type
|
||||
// Delayed triggers → persist as pending
|
||||
if (trigType === 'after_delay' || trigType === 'on_date') {
|
||||
const at = resolveTriggerAt(step)
|
||||
if (at) {
|
||||
state[step.id] = { status: STATUS.SCHEDULED, scheduled_for: at }
|
||||
try { await _schedulePending(run, step, at) }
|
||||
catch (e) { errors.push({ step: step.id, error: e.message }) }
|
||||
scheduled.push(step.id)
|
||||
mutations++
|
||||
progressed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Webhook / manual triggers → leave in pending state, caller advances via event
|
||||
if (trigType === 'on_webhook' || trigType === 'manual') {
|
||||
state[step.id] = { status: STATUS.PENDING, reason: trigType }
|
||||
mutations++
|
||||
continue
|
||||
}
|
||||
// Inline execution
|
||||
state[step.id] = { status: STATUS.RUNNING, started_at: new Date().toISOString() }
|
||||
const handler = KIND_HANDLERS[step.kind]
|
||||
if (!handler) {
|
||||
state[step.id] = { status: STATUS.FAILED, error: `no handler for kind "${step.kind}"`, completed_at: new Date().toISOString() }
|
||||
errors.push({ step: step.id, error: 'no handler' })
|
||||
} else {
|
||||
try {
|
||||
const r2 = await handler(step, { ...ctx, run })
|
||||
state[step.id] = {
|
||||
status: r2.status, result: r2.result, error: r2.error,
|
||||
started_at: state[step.id].started_at, completed_at: new Date().toISOString(),
|
||||
}
|
||||
if (r2.status === STATUS.FAILED) errors.push({ step: step.id, error: r2.error })
|
||||
executed.push(step.id)
|
||||
} catch (e) {
|
||||
state[step.id] = { status: STATUS.FAILED, error: e.message, completed_at: new Date().toISOString() }
|
||||
errors.push({ step: step.id, error: e.message })
|
||||
}
|
||||
}
|
||||
mutations++
|
||||
progressed = true
|
||||
}
|
||||
if (!progressed) break
|
||||
}
|
||||
|
||||
// Persist new state + compute run-level status
|
||||
const allDone = def.steps.every(s => {
|
||||
const st = state[s.id]?.status
|
||||
return st === STATUS.DONE || st === STATUS.FAILED || st === STATUS.SKIPPED
|
||||
})
|
||||
const anyFailed = def.steps.some(s => state[s.id]?.status === STATUS.FAILED)
|
||||
const patch = { step_state: state }
|
||||
if (allDone) {
|
||||
patch.status = anyFailed ? 'failed' : 'completed'
|
||||
patch.completed_at = new Date().toISOString()
|
||||
}
|
||||
if (mutations > 0) {
|
||||
await _persistRun(run, patch)
|
||||
Object.assign(run, patch)
|
||||
}
|
||||
|
||||
return { run, executed, scheduled, errors, done: allDone }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a scheduled/manual/webhook step as complete externally. Useful for
|
||||
* the scheduler, manual "Je confirme" buttons, or webhook callbacks.
|
||||
*
|
||||
* @param {string} runName
|
||||
* @param {string} stepId
|
||||
* @param {Object} [result]
|
||||
*/
|
||||
async function completeStep (runName, stepId, result = {}) {
|
||||
const run = await _fetchRun(runName)
|
||||
const state = run.step_state
|
||||
state[stepId] = {
|
||||
...(state[stepId] || {}),
|
||||
status: STATUS.DONE,
|
||||
result,
|
||||
completed_at: new Date().toISOString(),
|
||||
}
|
||||
await _persistRun(run, { step_state: state })
|
||||
return advanceFlow(runName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the execution context: pulls in the trigger doc + customer.
|
||||
* Kept narrow to limit payload size on downstream template renders.
|
||||
*/
|
||||
async function _buildContext (run, template) {
|
||||
const ctx = {
|
||||
run: { name: run.name, variables: run.variables, step_state: run.step_state },
|
||||
template: { name: template.name, version: template.version },
|
||||
variables: run.variables || {},
|
||||
now: new Date().toISOString(),
|
||||
}
|
||||
if (run.context_doctype && run.context_docname) {
|
||||
try {
|
||||
const r = await erpFetch(`/api/resource/${encodeURIComponent(run.context_doctype)}/${encodeURIComponent(run.context_docname)}`)
|
||||
if (r.status === 200) {
|
||||
ctx.doc = r.data.data
|
||||
ctx.doctype = run.context_doctype
|
||||
ctx.docname = run.context_docname
|
||||
}
|
||||
} catch (e) { log('[flow] context fetch error:', e.message) }
|
||||
}
|
||||
if (run.customer) {
|
||||
try {
|
||||
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(run.customer)}`)
|
||||
if (r.status === 200) ctx.customer = r.data.data
|
||||
} catch (e) { log('[flow] customer fetch error:', e.message) }
|
||||
} else if (ctx.doc?.customer) {
|
||||
try {
|
||||
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(ctx.doc.customer)}`)
|
||||
if (r.status === 200) ctx.customer = r.data.data
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Trigger helpers — called from event hooks (contracts, payments, …)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find all active templates listening for `eventName` and start them.
|
||||
* Templates with a trigger_condition must match the provided context
|
||||
* (simple ==/!= JSON check, expanded later).
|
||||
*
|
||||
* @returns {Array} list of { template, run, executed, scheduled } results
|
||||
*/
|
||||
async function dispatchEvent (eventName, opts = {}) {
|
||||
const filters = JSON.stringify([['trigger_event', '=', eventName], ['is_active', '=', 1]])
|
||||
const r = await erpFetch(`/api/resource/${ENC_FT}?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(JSON.stringify(['name', 'template_name', 'trigger_condition']))}&limit_page_length=50`)
|
||||
if (r.status !== 200) {
|
||||
log('[flow] dispatchEvent list failed:', r.status)
|
||||
return []
|
||||
}
|
||||
const templates = r.data.data || []
|
||||
const results = []
|
||||
for (const t of templates) {
|
||||
if (t.trigger_condition && !_matchCondition(t.trigger_condition, opts)) continue
|
||||
try {
|
||||
const res = await startFlow(t.name, { ...opts, triggerEvent: eventName })
|
||||
results.push({ template: t.name, ...res })
|
||||
} catch (e) {
|
||||
log(`[flow] dispatchEvent "${eventName}" failed for ${t.name}:`, e.message)
|
||||
results.push({ template: t.name, error: e.message })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/** Minimal condition match (JSON == check). Extend later for full JSONLogic. */
|
||||
function _matchCondition (condJson, opts) {
|
||||
if (!condJson) return true
|
||||
try {
|
||||
const c = typeof condJson === 'string' ? JSON.parse(condJson) : condJson
|
||||
if (c['==']) {
|
||||
const [a, b] = c['==']
|
||||
const left = a?.var ? getPath(opts, a.var) : a
|
||||
return String(left) === String(b)
|
||||
}
|
||||
return true
|
||||
} catch { return true }
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
startFlow,
|
||||
advanceFlow,
|
||||
completeStep,
|
||||
dispatchEvent,
|
||||
// for testing
|
||||
KIND_HANDLERS,
|
||||
evalCondition,
|
||||
render,
|
||||
renderDeep,
|
||||
isStepReady,
|
||||
resolveTriggerAt,
|
||||
STATUS,
|
||||
}
|
||||
278
services/targo-hub/lib/flow-templates.js
Normal file
278
services/targo-hub/lib/flow-templates.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
'use strict'
|
||||
/**
|
||||
* flow-templates.js — Hub REST API for Flow Template doctype.
|
||||
*
|
||||
* Endpoints (mounted under /flow/templates):
|
||||
* GET /flow/templates List (?category=&applies_to=&is_active=&trigger_event=)
|
||||
* GET /flow/templates/:name Fetch one (flow_definition parsed)
|
||||
* POST /flow/templates Create new template
|
||||
* PUT /flow/templates/:name Update + bump version
|
||||
* DELETE /flow/templates/:name Delete (blocked if is_system=1)
|
||||
* POST /flow/templates/:name/duplicate Clone a template (new name)
|
||||
*
|
||||
* Flow definition JSON schema: see erpnext/seed_flow_templates.py header.
|
||||
*/
|
||||
|
||||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||
|
||||
const DOCTYPE = 'Flow Template'
|
||||
const ENC_DOC = encodeURIComponent(DOCTYPE)
|
||||
|
||||
// Fields returned in list view (keep small — flow_definition is heavy)
|
||||
const LIST_FIELDS = [
|
||||
'name', 'template_name', 'category', 'applies_to',
|
||||
'icon', 'description', 'is_active', 'is_system',
|
||||
'version', 'step_count', 'trigger_event', 'trigger_condition',
|
||||
'tags', 'modified',
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseFlowDefinition (raw) {
|
||||
if (!raw) return { version: 1, trigger: { event: 'manual', condition: '' }, variables: {}, steps: [] }
|
||||
if (typeof raw === 'object') return raw
|
||||
try { return JSON.parse(raw) }
|
||||
catch (e) { log('Flow Template: invalid flow_definition JSON:', e.message); return null }
|
||||
}
|
||||
|
||||
function validateFlowDefinition (def) {
|
||||
if (!def || typeof def !== 'object') return 'flow_definition must be an object'
|
||||
if (!Array.isArray(def.steps)) return 'flow_definition.steps must be an array'
|
||||
|
||||
const ids = new Set()
|
||||
for (const step of def.steps) {
|
||||
if (!step.id || typeof step.id !== 'string') return `step without id`
|
||||
if (ids.has(step.id)) return `duplicate step id: ${step.id}`
|
||||
ids.add(step.id)
|
||||
if (!step.kind) return `step ${step.id}: kind required`
|
||||
const validKinds = ['dispatch_job', 'issue', 'notify', 'webhook', 'erp_update',
|
||||
'wait', 'condition', 'subscription_activate']
|
||||
if (!validKinds.includes(step.kind)) return `step ${step.id}: invalid kind "${step.kind}"`
|
||||
if (step.depends_on && !Array.isArray(step.depends_on)) return `step ${step.id}: depends_on must be array`
|
||||
}
|
||||
|
||||
// Referential integrity: depends_on / parent_id must point to existing step IDs
|
||||
for (const step of def.steps) {
|
||||
for (const dep of step.depends_on || []) {
|
||||
if (!ids.has(dep)) return `step ${step.id}: depends_on references unknown step "${dep}"`
|
||||
}
|
||||
if (step.parent_id && !ids.has(step.parent_id)) {
|
||||
return `step ${step.id}: parent_id references unknown step "${step.parent_id}"`
|
||||
}
|
||||
}
|
||||
|
||||
return null // OK
|
||||
}
|
||||
|
||||
function serializeFlowDefinition (def) {
|
||||
return JSON.stringify(def, null, 2)
|
||||
}
|
||||
|
||||
function hydrate (row) {
|
||||
if (!row) return null
|
||||
const def = parseFlowDefinition(row.flow_definition)
|
||||
return { ...row, flow_definition: def }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listTemplates (req, res, params) {
|
||||
const filters = []
|
||||
if (params.category) filters.push(['category', '=', params.category])
|
||||
if (params.applies_to) filters.push(['applies_to', '=', params.applies_to])
|
||||
if (params.trigger_event) filters.push(['trigger_event', '=', params.trigger_event])
|
||||
if (params.is_active !== undefined) {
|
||||
filters.push(['is_active', '=', params.is_active === '1' || params.is_active === 'true' ? 1 : 0])
|
||||
}
|
||||
if (params.q) filters.push(['template_name', 'like', `%${params.q}%`])
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
fields: JSON.stringify(LIST_FIELDS),
|
||||
filters: JSON.stringify(filters),
|
||||
limit_page_length: String(params.limit || 200),
|
||||
order_by: 'template_name asc',
|
||||
})
|
||||
const r = await erpFetch(`/api/resource/${ENC_DOC}?${qs.toString()}`)
|
||||
if (r.status !== 200) return json(res, r.status, { error: 'Failed to list', detail: r.data })
|
||||
return json(res, 200, { templates: r.data.data || [] })
|
||||
}
|
||||
|
||||
async function getTemplate (req, res, name) {
|
||||
const r = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
|
||||
if (r.status !== 200) return json(res, r.status, { error: 'Not found' })
|
||||
return json(res, 200, { template: hydrate(r.data.data) })
|
||||
}
|
||||
|
||||
async function createTemplate (req, res) {
|
||||
const body = await parseBody(req)
|
||||
if (!body.template_name) return json(res, 400, { error: 'template_name required' })
|
||||
if (!body.flow_definition) return json(res, 400, { error: 'flow_definition required' })
|
||||
|
||||
const def = typeof body.flow_definition === 'string'
|
||||
? parseFlowDefinition(body.flow_definition)
|
||||
: body.flow_definition
|
||||
const err = validateFlowDefinition(def)
|
||||
if (err) return json(res, 400, { error: 'Invalid flow_definition', detail: err })
|
||||
|
||||
const payload = {
|
||||
doctype: DOCTYPE,
|
||||
template_name: body.template_name,
|
||||
category: body.category || 'Custom',
|
||||
applies_to: body.applies_to || 'Service Contract',
|
||||
icon: body.icon || 'account_tree',
|
||||
description: body.description || '',
|
||||
trigger_event: body.trigger_event || 'manual',
|
||||
trigger_condition: body.trigger_condition || '',
|
||||
is_active: body.is_active === false ? 0 : 1,
|
||||
is_system: 0, // API cannot create system templates
|
||||
version: 1,
|
||||
flow_definition: serializeFlowDefinition(def),
|
||||
step_count: def.steps.length,
|
||||
tags: body.tags || '',
|
||||
notes: body.notes || '',
|
||||
}
|
||||
|
||||
const r = await erpFetch(`/api/resource/${ENC_DOC}`, {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
return json(res, r.status, { error: 'Create failed', detail: r.data })
|
||||
}
|
||||
log(`Flow Template created: ${r.data.data?.name} (${body.template_name})`)
|
||||
return json(res, 201, { template: hydrate(r.data.data) })
|
||||
}
|
||||
|
||||
async function updateTemplate (req, res, name) {
|
||||
const body = await parseBody(req)
|
||||
|
||||
// Fetch current to check is_system + increment version
|
||||
const cur = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
|
||||
if (cur.status !== 200) return json(res, 404, { error: 'Not found' })
|
||||
const current = cur.data.data
|
||||
|
||||
const patch = {}
|
||||
// Always-editable fields
|
||||
for (const f of ['template_name', 'category', 'applies_to', 'icon', 'description',
|
||||
'trigger_event', 'trigger_condition', 'is_active', 'tags', 'notes']) {
|
||||
if (body[f] !== undefined) patch[f] = body[f]
|
||||
}
|
||||
|
||||
// Flow definition — validate
|
||||
if (body.flow_definition !== undefined) {
|
||||
const def = typeof body.flow_definition === 'string'
|
||||
? parseFlowDefinition(body.flow_definition)
|
||||
: body.flow_definition
|
||||
const err = validateFlowDefinition(def)
|
||||
if (err) return json(res, 400, { error: 'Invalid flow_definition', detail: err })
|
||||
patch.flow_definition = serializeFlowDefinition(def)
|
||||
patch.step_count = def.steps.length
|
||||
}
|
||||
|
||||
// Bump version on any change
|
||||
if (Object.keys(patch).length > 0) {
|
||||
patch.version = (current.version || 0) + 1
|
||||
}
|
||||
|
||||
const r = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT', body: JSON.stringify(patch),
|
||||
})
|
||||
if (r.status !== 200) return json(res, r.status, { error: 'Update failed', detail: r.data })
|
||||
log(`Flow Template updated: ${name} -> v${patch.version}`)
|
||||
return json(res, 200, { template: hydrate(r.data.data) })
|
||||
}
|
||||
|
||||
async function deleteTemplate (req, res, name) {
|
||||
// Check is_system
|
||||
const cur = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
|
||||
if (cur.status !== 200) return json(res, 404, { error: 'Not found' })
|
||||
if (cur.data.data.is_system) {
|
||||
return json(res, 403, {
|
||||
error: 'System template cannot be deleted',
|
||||
hint: 'Use is_active=0 to disable, or duplicate then modify the copy.',
|
||||
})
|
||||
}
|
||||
|
||||
const r = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
if (r.status !== 200 && r.status !== 202) {
|
||||
return json(res, r.status, { error: 'Delete failed', detail: r.data })
|
||||
}
|
||||
log(`Flow Template deleted: ${name}`)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
async function duplicateTemplate (req, res, name) {
|
||||
const body = await parseBody(req).catch(() => ({}))
|
||||
const cur = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
|
||||
if (cur.status !== 200) return json(res, 404, { error: 'Not found' })
|
||||
const src = cur.data.data
|
||||
const def = parseFlowDefinition(src.flow_definition)
|
||||
|
||||
const newName = body.template_name || `${src.template_name} (copie)`
|
||||
const payload = {
|
||||
doctype: DOCTYPE,
|
||||
template_name: newName,
|
||||
category: src.category,
|
||||
applies_to: src.applies_to,
|
||||
icon: src.icon,
|
||||
description: src.description,
|
||||
trigger_event: src.trigger_event,
|
||||
trigger_condition: src.trigger_condition,
|
||||
is_active: 0, // cloned = inactive until user reviews
|
||||
is_system: 0,
|
||||
version: 1,
|
||||
flow_definition: serializeFlowDefinition(def),
|
||||
step_count: def.steps?.length || 0,
|
||||
tags: src.tags || '',
|
||||
notes: `(Copie de ${src.template_name})${src.notes ? '\n' + src.notes : ''}`,
|
||||
}
|
||||
|
||||
const r = await erpFetch(`/api/resource/${ENC_DOC}`, {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
return json(res, r.status, { error: 'Duplicate failed', detail: r.data })
|
||||
}
|
||||
log(`Flow Template duplicated: ${name} -> ${r.data.data?.name}`)
|
||||
return json(res, 201, { template: hydrate(r.data.data) })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handle (req, res, method, urlPath, urlObj) {
|
||||
// /flow/templates/:name/duplicate
|
||||
const duplicateMatch = urlPath.match(/^\/flow\/templates\/([^/]+)\/duplicate$/)
|
||||
if (duplicateMatch && method === 'POST') {
|
||||
return duplicateTemplate(req, res, decodeURIComponent(duplicateMatch[1]))
|
||||
}
|
||||
|
||||
// /flow/templates/:name
|
||||
const oneMatch = urlPath.match(/^\/flow\/templates\/([^/]+)$/)
|
||||
if (oneMatch) {
|
||||
const name = decodeURIComponent(oneMatch[1])
|
||||
if (method === 'GET') return getTemplate(req, res, name)
|
||||
if (method === 'PUT') return updateTemplate(req, res, name)
|
||||
if (method === 'DELETE') return deleteTemplate(req, res, name)
|
||||
}
|
||||
|
||||
// /flow/templates
|
||||
if (urlPath === '/flow/templates') {
|
||||
if (method === 'GET') {
|
||||
const params = {}
|
||||
if (urlObj && urlObj.searchParams) {
|
||||
for (const [k, v] of urlObj.searchParams) params[k] = v
|
||||
}
|
||||
return listTemplates(req, res, params)
|
||||
}
|
||||
if (method === 'POST') return createTemplate(req, res)
|
||||
}
|
||||
|
||||
return json(res, 404, { error: 'Flow template endpoint not found' })
|
||||
}
|
||||
|
||||
module.exports = { handle, parseFlowDefinition, validateFlowDefinition }
|
||||
|
|
@ -96,8 +96,36 @@ 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 httpRequest(cfg.GENIEACS_NBI_URL, path, { method, body, timeout: 15000 })
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,73 @@
|
|||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { log, json: jsonResp, parseBody } = require('./helpers');
|
||||
const { log, json: jsonResp, parseBody, erpFetch } = require('./helpers');
|
||||
|
||||
const BRIDGE_HOST = process.env.BRIDGE_HOST || 'modem-bridge';
|
||||
const BRIDGE_PORT = parseInt(process.env.BRIDGE_PORT || '3301');
|
||||
const BRIDGE_TOKEN = process.env.BRIDGE_TOKEN || '';
|
||||
const DEFAULT_MODEM_USER = 'superadmin';
|
||||
const DEFAULT_MODEM_PASS = process.env.DEFAULT_MODEM_PASS || '';
|
||||
|
||||
// Concurrency limiter for Playwright sessions (512MB container limit)
|
||||
const MAX_CONCURRENT = 3;
|
||||
let activeCount = 0;
|
||||
const waitQueue = [];
|
||||
|
||||
function withConcurrencyLimit(fn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function run() {
|
||||
activeCount++;
|
||||
fn().then(resolve).catch(reject).finally(() => {
|
||||
activeCount--;
|
||||
if (waitQueue.length > 0) waitQueue.shift()();
|
||||
});
|
||||
}
|
||||
if (activeCount >= MAX_CONCURRENT) {
|
||||
waitQueue.push(run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Diagnostic result cache (in-memory, TTL-based)
|
||||
const diagCache = new Map();
|
||||
const DIAG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Lookup equipment credentials from ERPNext by serial number
|
||||
*/
|
||||
async function getEquipmentCredentials(serial) {
|
||||
// Find equipment by serial
|
||||
const filters = encodeURIComponent(JSON.stringify([['serial_number', '=', serial]]));
|
||||
const fields = encodeURIComponent(JSON.stringify(['name', 'ip_address', 'login_user', 'brand', 'model', 'serial_number']));
|
||||
const res = await erpFetch(`/api/resource/Service Equipment?filters=${filters}&fields=${fields}&limit_page_length=1`);
|
||||
|
||||
if (res.status !== 200 || !res.data?.data?.length) return null;
|
||||
const eq = res.data.data[0];
|
||||
|
||||
// Get password via Frappe's password API
|
||||
let pass = DEFAULT_MODEM_PASS;
|
||||
try {
|
||||
const passRes = await erpFetch(`/api/method/frappe.client.get_password?doctype=Service%20Equipment&name=${encodeURIComponent(eq.name)}&fieldname=login_password`);
|
||||
if (passRes.status === 200 && passRes.data?.message) {
|
||||
pass = passRes.data.message;
|
||||
}
|
||||
} catch (e) {
|
||||
log('getEquipmentCredentials: password fetch failed for', eq.name, e.message);
|
||||
}
|
||||
|
||||
return {
|
||||
ip: (typeof eq.ip_address === 'string' && eq.ip_address.trim()) ? eq.ip_address.trim() : null,
|
||||
user: eq.login_user || DEFAULT_MODEM_USER,
|
||||
pass,
|
||||
brand: eq.brand,
|
||||
model: eq.model,
|
||||
serial: eq.serial_number,
|
||||
equipmentName: eq.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to modem-bridge service
|
||||
|
|
@ -113,6 +175,82 @@ async function handleModemRequest(req, res, path) {
|
|||
return jsonResp(res, result.status, result.body);
|
||||
}
|
||||
|
||||
// GET /modem/identify?ip=X — auto-detect modem type (no login needed)
|
||||
if (path === '/modem/identify' && req.method === 'GET') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const ip = url.searchParams.get('ip');
|
||||
if (!ip) return jsonResp(res, 400, { error: 'Missing ip param' });
|
||||
const result = await bridgeRequest('GET', `/identify/${ip}`);
|
||||
return jsonResp(res, result.status, result.body);
|
||||
}
|
||||
|
||||
// GET /modem/manage-ip?serial=X — get management IP from OLT via SNMP
|
||||
// Optionally pass olt_ip, slot, port, ontid if not in SNMP cache
|
||||
if (path === '/modem/manage-ip' && req.method === 'GET') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const serial = url.searchParams.get('serial');
|
||||
const opts = {};
|
||||
if (url.searchParams.get('olt_ip')) opts.oltIp = url.searchParams.get('olt_ip');
|
||||
if (url.searchParams.get('slot')) opts.slot = parseInt(url.searchParams.get('slot'));
|
||||
if (url.searchParams.get('port')) opts.port = parseInt(url.searchParams.get('port'));
|
||||
if (url.searchParams.get('ontid')) opts.ontId = parseInt(url.searchParams.get('ontid'));
|
||||
try {
|
||||
const { getManageIp } = require('./olt-snmp');
|
||||
const result = await getManageIp(serial, opts);
|
||||
if (!result) return jsonResp(res, 404, { error: 'No management IP found' });
|
||||
return jsonResp(res, 200, result);
|
||||
} catch(e) {
|
||||
return jsonResp(res, 502, { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /modem/diagnostic/auto?serial=X — auto-fetch credentials from ERPNext, run diagnostic
|
||||
if (path === '/modem/diagnostic/auto' && req.method === 'GET') {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const serial = url.searchParams.get('serial');
|
||||
if (!serial) return jsonResp(res, 400, { error: 'Missing serial param' });
|
||||
|
||||
// Check cache first
|
||||
const cached = diagCache.get(serial);
|
||||
if (cached && (Date.now() - cached.ts) < DIAG_CACHE_TTL) {
|
||||
return jsonResp(res, 200, { ...cached.data, cached: true });
|
||||
}
|
||||
|
||||
// Lookup credentials from ERPNext
|
||||
const creds = await getEquipmentCredentials(serial);
|
||||
if (!creds) return jsonResp(res, 404, { error: 'Equipment not found in ERPNext', serial });
|
||||
|
||||
// Resolve management IP from OLT SNMP if not stored on equipment
|
||||
let ip = creds.ip;
|
||||
if (!ip) {
|
||||
try {
|
||||
const { getManageIp } = require('./olt-snmp');
|
||||
const mgmt = await getManageIp(serial);
|
||||
if (mgmt) ip = mgmt.manageIp || (typeof mgmt === 'string' ? mgmt : null);
|
||||
} catch (e) {
|
||||
log('diagnostic/auto: OLT IP resolve failed for', serial, e.message);
|
||||
}
|
||||
}
|
||||
if (!ip || typeof ip !== 'string') return jsonResp(res, 404, { error: 'No management IP found', serial });
|
||||
|
||||
if (!creds.pass) return jsonResp(res, 400, { error: 'No password configured for equipment', serial, equipment: creds.equipmentName });
|
||||
|
||||
log('diagnostic/auto: running for', serial, 'ip:', ip, 'user:', creds.user);
|
||||
|
||||
// Stateless oneshot: login → scrape → logout → close (no session reuse)
|
||||
const result = await withConcurrencyLimit(async () => {
|
||||
const diag = await bridgeRequest('POST', '/diagnostic/oneshot', {
|
||||
ip: String(ip), user: creds.user, pass: creds.pass,
|
||||
});
|
||||
if (diag.status !== 200) throw new Error(diag.body?.error || `Bridge returned ${diag.status}`);
|
||||
return diag.body;
|
||||
});
|
||||
|
||||
// Cache result
|
||||
diagCache.set(serial, { data: result, ts: Date.now() });
|
||||
return jsonResp(res, 200, result);
|
||||
}
|
||||
|
||||
// All other /modem/* routes need ip, user, pass
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const ip = url.searchParams.get('ip');
|
||||
|
|
@ -144,9 +282,9 @@ async function handleModemRequest(req, res, path) {
|
|||
return jsonResp(res, result.status, result.body);
|
||||
}
|
||||
|
||||
// GET /modem/diagnostic — full WiFi/mesh/WAN diagnostic
|
||||
// GET /modem/diagnostic — unified diagnostic via stateless oneshot
|
||||
if (path === '/modem/diagnostic' && req.method === 'GET') {
|
||||
const result = await bridgeRequest('GET', `/modem/${ip}/wifi/diagnostic`);
|
||||
const result = await bridgeRequest('POST', '/diagnostic/oneshot', { ip, user, pass });
|
||||
return jsonResp(res, result.status, result.body);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,10 +137,16 @@ function normalizeMac (mac) {
|
|||
return mac.replace(/[:\-. ]/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
/** Format as USP Agent Endpoint ID: USP::E4FAC4160688 */
|
||||
function macToEndpointId (mac) {
|
||||
const clean = normalizeMac(mac)
|
||||
return clean ? `USP::${clean}` : null
|
||||
/** Format as USP Agent Endpoint ID: ops::OUI-ProductClass-SerialNumber (TR-369 ops authority) */
|
||||
function generateEndpointId (mac, serial, productClass = 'Device2') {
|
||||
const cleanMac = normalizeMac(mac)
|
||||
if (!cleanMac) return null
|
||||
const oui = cleanMac.substring(0, 6)
|
||||
if (serial) {
|
||||
return `ops::${oui}-${productClass}-${serial}`
|
||||
}
|
||||
// Fallback if serial is missing
|
||||
return `USP::${cleanMac}`
|
||||
}
|
||||
|
||||
/** Generate a random password (alphanumeric, 16 chars) */
|
||||
|
|
@ -205,7 +211,7 @@ async function provisionDevice (opts) {
|
|||
serial,
|
||||
mac,
|
||||
deviceId: normalizeMac(mac),
|
||||
endpointId: macToEndpointId(mac),
|
||||
endpointId: generateEndpointId(mac, serial),
|
||||
actions: [],
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +360,7 @@ async function bulkSync (opts = {}) {
|
|||
const authRes = await preAuthorizeDevice(equip.mac_address)
|
||||
if (authRes.ok) {
|
||||
// Ensure MongoDB record exists
|
||||
const endpointId = macToEndpointId(equip.mac_address)
|
||||
const endpointId = generateEndpointId(equip.mac_address, equip.serial_number)
|
||||
await ensureDeviceRecord(endpointId, {
|
||||
vendor: equip.brand || '',
|
||||
model: equip.model || '',
|
||||
|
|
@ -541,5 +547,5 @@ module.exports = {
|
|||
provisionDevice,
|
||||
bulkSync,
|
||||
normalizeMac,
|
||||
macToEndpointId,
|
||||
generateEndpointId,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,68 @@ function snmpWalk (session, oid) {
|
|||
})
|
||||
}
|
||||
|
||||
function snmpGet (session, oid) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => { try { session.close() } catch {} resolve(null) }, SNMP_TIMEOUT + 1000)
|
||||
session.get([oid], (error, varbinds) => {
|
||||
clearTimeout(timer)
|
||||
if (error || !varbinds?.length || snmp.isVarbindError(varbinds[0])) return resolve(null)
|
||||
resolve(varbinds[0])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Raisecom OLT WAN IP OIDs: 1.3.6.1.4.1.8886.18.3.6.6.1.1.13.{olt_id}.{wan_idx}
|
||||
// olt_id = slot * 10000000 + port * 100000 + ontid
|
||||
const RAISECOM_WAN_IP_OID = '1.3.6.1.4.1.8886.18.3.6.6.1.1.13'
|
||||
|
||||
// Get management IP from Raisecom OLT via SNMP
|
||||
// Can use either cached ONU data (serial lookup) or explicit slot/port/ontid params
|
||||
async function getManageIp (serial, opts = {}) {
|
||||
let oltHost, community, oltName, slot, port, ontId
|
||||
|
||||
if (serial) {
|
||||
const onu = getOnuBySerial(serial)
|
||||
if (onu) {
|
||||
const olt = olts.get(onu.oltHost)
|
||||
if (!olt || olt.type !== 'raisecom') return null
|
||||
oltHost = olt.host; community = olt.community; oltName = olt.name
|
||||
// Raisecom cache stores port as "0/slot/port" and onuIdx as ontid
|
||||
const parts = (onu.port || '').split('/')
|
||||
if (parts.length === 3) { slot = parseInt(parts[1]); port = parseInt(parts[2]) }
|
||||
ontId = onu.onuIdx
|
||||
}
|
||||
}
|
||||
|
||||
// Allow explicit params (from ERPNext data when ONU not in cache yet)
|
||||
if (opts.oltIp) oltHost = opts.oltIp
|
||||
if (opts.community) community = opts.community
|
||||
if (opts.slot != null) slot = opts.slot
|
||||
if (opts.port != null) port = opts.port
|
||||
if (opts.ontId != null) ontId = opts.ontId
|
||||
if (!community) community = 'targosnmp'
|
||||
if (!oltName) oltName = oltHost
|
||||
|
||||
if (!oltHost || slot == null || port == null || ontId == null) return null
|
||||
|
||||
const oltId = slot * 10000000 + port * 100000 + ontId
|
||||
const session = createSession(oltHost, community)
|
||||
const ips = []
|
||||
try {
|
||||
for (let wan = 1; wan <= 4; wan++) {
|
||||
const vb = await snmpGet(session, `${RAISECOM_WAN_IP_OID}.${oltId}.${wan}`)
|
||||
if (vb) {
|
||||
const ip = extractVal(vb)
|
||||
if (ip && ip !== '0.0.0.0' && ip !== '') ips.push({ wan, ip })
|
||||
}
|
||||
}
|
||||
} finally { try { session.close() } catch {} }
|
||||
|
||||
if (!ips.length) return null
|
||||
const mgmt = ips.find(i => i.ip.startsWith('172.17.') || i.ip.startsWith('10.'))
|
||||
return { serial, oltName, oltHost, slot, port, ontId, ips, manageIp: mgmt?.ip || ips[0].ip }
|
||||
}
|
||||
|
||||
function extractVal (vb) {
|
||||
if (vb.type === snmp.ObjectType.OctetString) return vb.value.toString()
|
||||
if (vb.type === snmp.ObjectType.Integer) return vb.value
|
||||
|
|
@ -415,5 +477,5 @@ function stopOltPoller () {
|
|||
module.exports = {
|
||||
registerOlt, startOltPoller, stopOltPoller,
|
||||
getOnuBySerial, getPortNeighbors, getPortHealth, classifyOfflineCause,
|
||||
getOltStats, getAllOnus, pollAllOlts,
|
||||
getOltStats, getAllOnus, pollAllOlts, getManageIp,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1063,6 +1063,14 @@ async function handleWebhook (req, res) {
|
|||
|
||||
// Notify ops-app via SSE
|
||||
sse.broadcast('payments', 'payment_received', { customer, amount, reference: piId, invoice: invoiceName })
|
||||
|
||||
// Fire flow trigger (on_payment_received). Non-blocking: log + swallow.
|
||||
try {
|
||||
require('./flow-runtime').dispatchEvent('on_payment_received', {
|
||||
doctype: 'Sales Invoice', docname: invoiceName, customer,
|
||||
variables: { amount, payment_intent: piId },
|
||||
}).catch(e => log('flow trigger on_payment_received failed:', e.message))
|
||||
} catch (e) { log('flow trigger load error:', e.message) }
|
||||
} else if (session.mode === 'setup') {
|
||||
// Card saved — update Payment Method with card info
|
||||
const setupIntent = await stripeRequest('GET', `/setup_intents/${session.setup_intent}`)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user