gigafibre-fsm/services/targo-hub/lib/erp.js
louispaulb 48c2f53d18 Phase 1 (hygiène) : utils partagés + logique pure testable + observabilité erp + 1ers tests
Modularisation / dé-duplication :
- lib/util/text.js : `norm` canonique partagé (remplace 2 ré-implémentations : address-db, legacy-dispatch-sync).
- lib/util/legacy-parse.js : parseurs/mapping PURS du pont (DEPT_JOBTYPE, DUR, jobType, prio, tzDate,
  startTime, coord) extraits hors I/O → testables en isolation, sans pg/mysql/erp.
- legacy-dispatch-sync + address-db importent ces utils (pont vérifié en prod : preview OK, 0 erreur).

Observabilité (sûr, additif, 1 seul point) :
- erp.js create/update/remove : log de l'échec à la SOURCE quand HTTP≥400 → toutes les écritures ERPNext
  silencieuses des 50+ appelants sont désormais tracées, SANS changer aucun flux de contrôle.

Tests (fondation) :
- vitest + npm test ; test/util.test.js : 19 tests verts sur norm + coord(bornes QC)/prio/startTime/jobType/tzDate.
  Tournent sans installer les deps lourdes du hub (modules purs).

Aligné docs/architecture/VISION.md (P0 hygiène). Suite : audit r.ok des appelants financiers (payments/contracts)
en revue supervisée ; CI/CD minimal (Gitea Actions lint+test) ; décomposition des god-files (Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:36:41 -04:00

222 lines
10 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
}
async function list (doctype, opts = {}) {
// Try the requested fields first; if v16 blocks one, retry without it.
let fields = opts.fields ? [...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)) {
log(`erp.list ${doctype}: v16 blocks field "${bad}" — retrying without it`)
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,
}