'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/[?...] 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: " 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) return { ok: false, error: errorMessage(r), 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) return { ok: false, error: errorMessage(r), 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) return { ok: false, error: errorMessage(r), 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: " 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 `_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, }