gigafibre-fsm/services/targo-hub/lib/erp.js
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

228 lines
11 KiB
JavaScript

'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Structured Frappe/ERPNext REST wrapper.
//
// Wraps helpers.erpFetch with:
// - URL building (encodeURIComponent + JSON.stringify of filters/fields goes away)
// - Consistent error shape ({ ok, status, data, error })
// - v16 "Field not permitted" soft-retry (strip blocked fields and try once more)
// - Batch label resolution (replace fetched-from links without violating v16)
//
// Pattern across the codebase before this:
// const fields = JSON.stringify(['name', 'customer_name'])
// const filters = JSON.stringify([['status', '=', 'open']])
// const r = await erpFetch(`/api/resource/Dispatch%20Job?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&limit_page_length=50`)
// const rows = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
//
// Pattern after this:
// const rows = await erp.list('Dispatch Job', {
// fields: ['name', 'customer_name'],
// filters: [['status', '=', 'open']],
// limit: 50,
// })
// ─────────────────────────────────────────────────────────────────────────────
const { erpFetch, log } = require('./helpers')
// URL-encode a doctype name for /api/resource/ — spaces become %20, etc.
function encDocType (dt) { return encodeURIComponent(dt) }
function encName (n) { return encodeURIComponent(n) }
// Build /api/resource/<Doctype>[?...] URL with list params
function listUrl (doctype, { fields, filters, orderBy, limit = 20, start = 0 } = {}) {
const q = []
if (fields && fields.length) q.push('fields=' + encodeURIComponent(JSON.stringify(fields)))
if (filters && filters.length) q.push('filters=' + encodeURIComponent(JSON.stringify(filters)))
if (orderBy) q.push('order_by=' + encodeURIComponent(orderBy))
if (limit != null) q.push('limit_page_length=' + limit)
if (start) q.push('limit_start=' + start)
return `/api/resource/${encDocType(doctype)}${q.length ? '?' + q.join('&') : ''}`
}
// Extract a human-readable message from Frappe error responses
function errorMessage (r) {
if (!r) return 'no response'
if (r.error) return r.error
const d = r.data
if (!d) return `HTTP ${r.status}`
return d.exception || d._error_message || d.message || d._server_messages || `HTTP ${r.status}`
}
// Detect the v16 "Field not permitted in query: <fieldname>" error.
// Returns the offending field name, or null.
function fieldNotPermitted (r) {
const msg = [r?.data?.exception, r?.data?._error_message, r?.data?._server_messages]
.filter(Boolean).join(' ')
const m = msg.match(/Field not permitted in query:\s*([a-zA-Z0-9_]+)/)
return m ? m[1] : null
}
// ═════════════════════════════════════════════════════════════════════════
// Core CRUD
// ═════════════════════════════════════════════════════════════════════════
async function get (doctype, name, { fields } = {}) {
let url = `/api/resource/${encDocType(doctype)}/${encName(name)}`
if (fields && fields.length) url += `?fields=${encodeURIComponent(JSON.stringify(fields))}`
const r = await erpFetch(url)
if (r.status !== 200 || !r.data?.data) return null
return r.data.data
}
// Mémo PROCESSUS des champs refusés par v16, PAR doctype. v16 refuse certains champs (ex. Dispatch Job.customer_name/service_type) ;
// sans mémo, CHAQUE liste repartait à zéro = jusqu'à 3 allers-retours ERPNext (2 échecs « Field not permitted » avant succès).
// Après la 1re découverte on les retire d'emblée → 1 seul aller-retour. Stable car même token (perms doctype constantes).
const _blockedFields = new Map() // doctype -> Set(champ refusé)
async function list (doctype, opts = {}) {
// Retire d'emblée les champs déjà connus comme refusés (mémo), puis réessaie pour en découvrir de nouveaux.
const known = _blockedFields.get(doctype)
let fields = opts.fields ? (known ? opts.fields.filter(f => !known.has(f)) : [...opts.fields]) : null
const dropped = []
for (let attempt = 0; attempt < 5; attempt++) {
const url = listUrl(doctype, { ...opts, fields })
const r = await erpFetch(url)
if (r.status === 200 && Array.isArray(r.data?.data)) return r.data.data
const bad = fieldNotPermitted(r)
if (bad && fields && fields.includes(bad)) {
let set = _blockedFields.get(doctype); if (!set) { set = new Set(); _blockedFields.set(doctype, set) }
if (!set.has(bad)) { set.add(bad); log(`erp.list ${doctype}: v16 refuse "${bad}" — mémorisé (plus redemandé)`) }
dropped.push(bad)
fields = fields.filter(f => f !== bad)
continue
}
log(`erp.list ${doctype} failed: ${errorMessage(r)}`)
return []
}
if (dropped.length) log(`erp.list ${doctype}: dropped ${dropped.join(',')} after retries`)
return []
}
async function listRaw (doctype, opts = {}) {
// Same as list() but returns { ok, rows, error } so callers that care about
// failures can distinguish them from "empty result".
let fields = opts.fields ? [...opts.fields] : null
for (let attempt = 0; attempt < 5; attempt++) {
const url = listUrl(doctype, { ...opts, fields })
const r = await erpFetch(url)
if (r.status === 200 && Array.isArray(r.data?.data)) return { ok: true, rows: r.data.data }
const bad = fieldNotPermitted(r)
if (bad && fields && fields.includes(bad)) { fields = fields.filter(f => f !== bad); continue }
return { ok: false, rows: [], error: errorMessage(r), status: r.status }
}
return { ok: false, rows: [], error: 'too many field drops' }
}
async function create (doctype, body) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}`, {
method: 'POST', body: JSON.stringify(body),
})
if (r.status >= 400) { const error = errorMessage(r); log(`erp.create ${doctype} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
return { ok: true, data: r.data?.data, name: r.data?.data?.name }
}
async function update (doctype, name, body) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, {
method: 'PUT', body: JSON.stringify(body),
})
if (r.status >= 400) { const error = errorMessage(r); log(`erp.update ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
return { ok: true, data: r.data?.data }
}
async function remove (doctype, name) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, { method: 'DELETE' })
if (r.status >= 400) { const error = errorMessage(r); log(`erp.remove ${doctype}/${name} échec [${r.status}]: ${String(error).slice(0, 200)}`); return { ok: false, error, status: r.status } }
return { ok: true }
}
// ═════════════════════════════════════════════════════════════════════════
// Convenience helpers
// ═════════════════════════════════════════════════════════════════════════
// Fetch a batch of records by `name`, returning a { name: record } map.
// One query, no N+1. Used to resolve display labels after a list() that had
// link fields (e.g. customer → customer_name).
async function getMany (doctype, names, { fields } = {}) {
if (!names || !names.length) return {}
const ids = [...new Set(names.filter(Boolean))]
if (!ids.length) return {}
const rows = await list(doctype, {
filters: [['name', 'in', ids]],
fields: fields || ['name'],
limit: Math.max(ids.length, 50),
})
const map = {}
for (const r of rows) map[r.name] = r
return map
}
// Fill link-label fields on a list of records without tripping v16's
// "Field not permitted in query: <name>" for fetched fields.
//
// await erp.hydrateLabels(jobs, {
// customer: {
// doctype: 'Customer', out: 'customer_name',
// fields: ['name', 'customer_name'],
// },
// service_location: {
// doctype: 'Service Location', out: 'service_location_name',
// fields: ['name', 'address_line_1', 'city'],
// format: loc => [loc.address_line_1, loc.city].filter(Boolean).join(', ') || loc.name,
// },
// })
//
// Each config:
// linkField → doctype: doctype to look up
// out: field to write on the record (defaults to `<linkField>_name`)
// fields: fields to pull (must include 'name')
// format: fn(linkedRow) → string (defaults to row[fields[1]] || row.name)
async function hydrateLabels (records, configs) {
const linkFields = Object.keys(configs)
// 1. Collect unique ids per link field
const idsByField = {}
for (const lf of linkFields) {
idsByField[lf] = [...new Set(records.map(r => r[lf]).filter(Boolean))]
}
// 2. Fetch in parallel
const entries = await Promise.all(linkFields.map(async lf => {
const cfg = configs[lf]
if (!idsByField[lf].length) return [lf, {}]
const rows = await list(cfg.doctype, {
filters: [['name', 'in', idsByField[lf]]],
fields: cfg.fields || ['name'],
limit: Math.max(idsByField[lf].length, 50),
})
const fmt = cfg.format || (row => row[cfg.fields && cfg.fields[1]] || row.name)
const map = {}
for (const row of rows) map[row.name] = fmt(row)
return [lf, map]
}))
// 3. Write onto records
const mapsByField = Object.fromEntries(entries)
for (const r of records) {
for (const lf of linkFields) {
const out = configs[lf].out || (lf + '_name')
r[out] = mapsByField[lf][r[lf]] || r[lf] || ''
}
}
return records
}
// ═════════════════════════════════════════════════════════════════════════
// Escape hatch
// ═════════════════════════════════════════════════════════════════════════
// Raw passthrough for callers that need something outside this wrapper
// (e.g. custom Frappe method calls, reports, non-resource endpoints).
const raw = erpFetch
module.exports = {
get, list, listRaw, create, update, remove,
getMany, hydrateLabels,
raw,
// Useful internals if someone wants to build their own URL
listUrl, errorMessage, fieldNotPermitted,
}