refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.
Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
field with "Field not permitted in query" — the field is dropped and the
call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
service_location_name, …) in one query per link field — no N+1, no
v16 breakage.
- Consistent {ok, error, status} shape for mutations.
Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper
Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.
Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
169426a6d8
commit
01bb99857f
|
|
@ -3,7 +3,8 @@ const crypto = require('crypto')
|
|||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody, lookupCustomerByPhone, createCommunication, erpFetch } = require('./helpers')
|
||||
const { log, json, parseBody, lookupCustomerByPhone, createCommunication } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const sse = require('./sse')
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', 'data')
|
||||
|
|
@ -331,8 +332,9 @@ async function archiveDiscussion (tokens) {
|
|||
if (customer) issueData.customer = customer
|
||||
|
||||
try {
|
||||
const result = await erpFetch('/api/resource/Issue', { method: 'POST', body: JSON.stringify(issueData) })
|
||||
const issueName = result?.data?.name
|
||||
const result = await erp.create('Issue', issueData)
|
||||
if (!result.ok) throw new Error(result.error || 'Issue create failed')
|
||||
const issueName = result.name
|
||||
log(`Archived ${tokens.length} conversations → Issue ${issueName} (customer: ${customer || 'none'})`)
|
||||
deleteDiscussionByTokens(tokens)
|
||||
return { issue: issueName, messagesArchived: allMsgs.length, customer: customer || null }
|
||||
|
|
|
|||
221
services/targo-hub/lib/erp.js
Normal file
221
services/targo-hub/lib/erp.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'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) 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: <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,
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
'use strict'
|
||||
const crypto = require('crypto')
|
||||
const cfg = require('./config')
|
||||
const { log, json, erpFetch } = require('./helpers')
|
||||
const { log, json } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
|
||||
const ICAL_SECRET = cfg.ICAL_SECRET || cfg.INTERNAL_TOKEN || 'gigafibre-ical-2026'
|
||||
|
||||
|
|
@ -120,8 +121,11 @@ async function handleCalendar (req, res, techId, query) {
|
|||
}
|
||||
|
||||
// Fetch tech info
|
||||
const techRes = await erpFetch(`/api/resource/Dispatch Technician?filters=${encodeURIComponent(JSON.stringify({ technician_id: techId }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name']))}&limit_page_length=1`)
|
||||
const techs = techRes.data?.data || []
|
||||
const techs = await erp.list('Dispatch Technician', {
|
||||
fields: ['name', 'technician_id', 'full_name'],
|
||||
filters: [['technician_id', '=', techId]],
|
||||
limit: 1,
|
||||
})
|
||||
if (!techs.length) return json(res, 404, { error: 'Tech not found' })
|
||||
const tech = techs[0]
|
||||
|
||||
|
|
@ -132,18 +136,21 @@ async function handleCalendar (req, res, techId, query) {
|
|||
const fromStr = past.toISOString().slice(0, 10)
|
||||
const toStr = future.toISOString().slice(0, 10)
|
||||
|
||||
const jobRes = await erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
fields: [
|
||||
'name', 'ticket_id', 'subject', 'address', 'scheduled_date', 'start_time',
|
||||
'duration_h', 'priority', 'status', 'customer', 'notes',
|
||||
'is_recurring', 'recurrence_rule', 'recurrence_end',
|
||||
],
|
||||
filters: [
|
||||
['assigned_tech', '=', techId],
|
||||
['scheduled_date', '>=', fromStr],
|
||||
['scheduled_date', '<=', toStr],
|
||||
['status', '!=', 'cancelled'],
|
||||
]))}&fields=${encodeURIComponent(JSON.stringify([
|
||||
'name', 'ticket_id', 'subject', 'address', 'scheduled_date', 'start_time',
|
||||
'duration_h', 'priority', 'status', 'customer', 'notes',
|
||||
'is_recurring', 'recurrence_rule', 'recurrence_end',
|
||||
]))}&limit_page_length=500&order_by=${encodeURIComponent('scheduled_date asc, start_time asc')}`)
|
||||
|
||||
const jobs = jobRes.data?.data || []
|
||||
],
|
||||
orderBy: 'scheduled_date asc, start_time asc',
|
||||
limit: 500,
|
||||
})
|
||||
const ical = buildICal(tech.full_name, jobs, techId)
|
||||
|
||||
res.writeHead(200, {
|
||||
|
|
|
|||
|
|
@ -97,10 +97,9 @@ async function handle (req, res, method, path) {
|
|||
const body = await parseBody(req)
|
||||
const { tech_id } = body
|
||||
if (!tech_id) return json(res, 400, { error: 'tech_id required' })
|
||||
const { erpFetch } = require('./helpers')
|
||||
const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(tech_id)}`)
|
||||
if (techRes.status !== 200) return json(res, 404, { error: 'Tech not found' })
|
||||
const tech = techRes.data.data
|
||||
const erp = require('./erp')
|
||||
const tech = await erp.get('Dispatch Technician', tech_id)
|
||||
if (!tech) return json(res, 404, { error: 'Tech not found' })
|
||||
if (!tech.phone) return json(res, 400, { error: 'No phone number for this technician' })
|
||||
const link = generateTechLink(tech_id, 72)
|
||||
const msg = `Voici votre nouveau lien pour accéder à vos tâches:\n${link}`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use strict'
|
||||
const { log, erpFetch } = require('./helpers')
|
||||
const { log } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const { otpEmailHtml } = require('./email-templates')
|
||||
|
||||
const otpStore = new Map()
|
||||
|
|
@ -8,12 +9,6 @@ function generateOTP () {
|
|||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
function erpQuery (doctype, filters, fields, limit) {
|
||||
let url = `/api/resource/${encodeURIComponent(doctype)}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}`
|
||||
if (limit) url += `&limit_page_length=${limit}`
|
||||
return erpFetch(url)
|
||||
}
|
||||
|
||||
async function sendOTP (identifier) {
|
||||
const isEmail = identifier.includes('@')
|
||||
const code = generateOTP()
|
||||
|
|
@ -21,8 +16,11 @@ async function sendOTP (identifier) {
|
|||
|
||||
let customer = null
|
||||
if (isEmail) {
|
||||
const res = await erpQuery('Customer', [['email_id', '=', identifier]], ['name', 'customer_name', 'cell_phone', 'email_id'], 1)
|
||||
if (res.status === 200 && res.data?.data?.length) customer = res.data.data[0]
|
||||
const rows = await erp.list('Customer', {
|
||||
fields: ['name', 'customer_name', 'cell_phone', 'email_id'],
|
||||
filters: [['email_id', '=', identifier]], limit: 1,
|
||||
})
|
||||
if (rows.length) customer = rows[0]
|
||||
} else {
|
||||
const { lookupCustomerByPhone } = require('./helpers')
|
||||
customer = await lookupCustomerByPhone(identifier)
|
||||
|
|
@ -59,29 +57,31 @@ async function verifyOTP (identifier, code) {
|
|||
|
||||
const result = { valid: true, customer_id: entry.customerId, customer_name: entry.customerName }
|
||||
try {
|
||||
const custRes = await erpFetch(`/api/resource/Customer/${encodeURIComponent(entry.customerId)}?fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name', 'cell_phone', 'email_id', 'tel_home']))}`)
|
||||
if (custRes.status === 200 && custRes.data?.data) {
|
||||
const c = custRes.data.data
|
||||
const c = await erp.get('Customer', entry.customerId, {
|
||||
fields: ['name', 'customer_name', 'cell_phone', 'email_id', 'tel_home'],
|
||||
})
|
||||
if (c) {
|
||||
result.phone = c.cell_phone || c.tel_home || ''
|
||||
result.email = c.email_id || ''
|
||||
}
|
||||
|
||||
if (!result.email) {
|
||||
try {
|
||||
const contRes = await erpQuery('Contact',
|
||||
[['Dynamic Link', 'link_doctype', '=', 'Customer'], ['Dynamic Link', 'link_name', '=', entry.customerId]],
|
||||
['email_id'], 1)
|
||||
if (contRes.status === 200 && contRes.data?.data?.[0]?.email_id) {
|
||||
result.email = contRes.data.data[0].email_id
|
||||
}
|
||||
const contacts = await erp.list('Contact', {
|
||||
fields: ['email_id'],
|
||||
filters: [['Dynamic Link', 'link_doctype', '=', 'Customer'], ['Dynamic Link', 'link_name', '=', entry.customerId]],
|
||||
limit: 1,
|
||||
})
|
||||
if (contacts[0]?.email_id) result.email = contacts[0].email_id
|
||||
} catch (e) { log('OTP - Contact email fallback error:', e.message) }
|
||||
}
|
||||
|
||||
const locRes = await erpQuery('Service Location',
|
||||
[['customer', '=', entry.customerId]],
|
||||
['name', 'address_line', 'city', 'postal_code', 'location_name', 'latitude', 'longitude'], 20)
|
||||
if (locRes.status === 200 && locRes.data?.data?.length) {
|
||||
result.addresses = locRes.data.data.map(l => ({
|
||||
const locs = await erp.list('Service Location', {
|
||||
fields: ['name', 'address_line', 'city', 'postal_code', 'location_name', 'latitude', 'longitude'],
|
||||
filters: [['customer', '=', entry.customerId]], limit: 20,
|
||||
})
|
||||
if (locs.length) {
|
||||
result.addresses = locs.map(l => ({
|
||||
name: l.name,
|
||||
address: l.address_line || l.location_name || l.name,
|
||||
city: l.city || '',
|
||||
|
|
|
|||
|
|
@ -11,17 +11,17 @@
|
|||
// so the code flips to "Used" and links to the quotation. Fulfilled happens
|
||||
// server-side once the service invoice is submitted (not here).
|
||||
|
||||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||
const { log, json, parseBody } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
|
||||
const DT = 'Referral Credit'
|
||||
const encoded = encodeURIComponent(DT)
|
||||
|
||||
async function fetchByCode (code) {
|
||||
const filters = [['code', '=', code]]
|
||||
const fields = ['name', 'code', 'referrer', 'referrer_customer_name', 'status', 'new_subscriber_credit', 'referrer_credit']
|
||||
const res = await erpFetch(`/api/resource/${encoded}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}&limit_page_length=1`)
|
||||
if (res.status !== 200) return null
|
||||
const rows = res.data.data || []
|
||||
const rows = await erp.list(DT, {
|
||||
filters: [['code', '=', code]],
|
||||
fields: ['name', 'code', 'referrer', 'referrer_customer_name', 'status', 'new_subscriber_credit', 'referrer_credit'],
|
||||
limit: 1,
|
||||
})
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
|
|
@ -57,14 +57,10 @@ async function applyCode (code, quotation, customer) {
|
|||
}
|
||||
if (quotation) patch.referred_quotation = quotation
|
||||
if (customer) patch.referred_customer = customer
|
||||
const res = await erpFetch(`/api/resource/${encoded}/${encodeURIComponent(row.name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
log('referral.apply PUT failed:', res.status, res.data)
|
||||
return { ok: false, error: 'Erreur de mise à jour (' + res.status + ')' }
|
||||
const res = await erp.update(DT, row.name, patch)
|
||||
if (!res.ok) {
|
||||
log('referral.apply PUT failed:', res.status, res.error)
|
||||
return { ok: false, error: 'Erreur de mise à jour (' + (res.status || 'err') + ')' }
|
||||
}
|
||||
return { ok: true, code: row.code }
|
||||
}
|
||||
|
|
@ -74,9 +70,13 @@ async function applyCode (code, quotation, customer) {
|
|||
// one code perpetually until explicitly cancelled).
|
||||
async function generateCode (customer) {
|
||||
if (!customer) return { ok: false, error: 'customer manquant' }
|
||||
const existing = await erpFetch(`/api/resource/${encoded}?filters=${encodeURIComponent(JSON.stringify([['referrer', '=', customer], ['status', '=', 'Active']]))}&fields=${encodeURIComponent(JSON.stringify(['name', 'code']))}&limit_page_length=1`)
|
||||
if (existing.status === 200 && (existing.data.data || []).length) {
|
||||
return { ok: true, code: existing.data.data[0].code, created: false }
|
||||
const existing = await erp.list(DT, {
|
||||
filters: [['referrer', '=', customer], ['status', '=', 'Active']],
|
||||
fields: ['name', 'code'],
|
||||
limit: 1,
|
||||
})
|
||||
if (existing.length) {
|
||||
return { ok: true, code: existing[0].code, created: false }
|
||||
}
|
||||
// New code — 6 uppercase alphanumerics, prefixed with GIGA-. Retry on
|
||||
// collision (the unique constraint guarantees we don't double-insert).
|
||||
|
|
@ -85,16 +85,12 @@ async function generateCode (customer) {
|
|||
let suffix = ''
|
||||
for (let i = 0; i < 6; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
const code = `GIGA-${suffix}`
|
||||
const res = await erpFetch(`/api/resource/${encoded}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, referrer: customer, status: 'Active' }),
|
||||
})
|
||||
if (res.status === 200) return { ok: true, code, created: true }
|
||||
// 409 on duplicate code — retry with new suffix
|
||||
const res = await erp.create(DT, { code, referrer: customer, status: 'Active' })
|
||||
if (res.ok) return { ok: true, code, created: true }
|
||||
// 409/417 on duplicate code or field rejection — retry with new suffix
|
||||
if (res.status !== 409 && res.status !== 417) {
|
||||
log('referral.generate POST failed:', res.status, res.data)
|
||||
return { ok: false, error: 'Erreur ERP (' + res.status + ')' }
|
||||
log('referral.generate POST failed:', res.status, res.error)
|
||||
return { ok: false, error: 'Erreur ERP (' + (res.status || 'err') + ')' }
|
||||
}
|
||||
}
|
||||
return { ok: false, error: 'Génération échouée après 8 tentatives' }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, erpFetch } = require('./helpers')
|
||||
const { log } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const { broadcastAll } = require('./sse')
|
||||
|
||||
// Lazy require to avoid circular dependency with twilio.js
|
||||
|
|
@ -34,13 +35,12 @@ async function lookupTechByPhone (phone) {
|
|||
// Build a LIKE pattern that matches digits regardless of separators (dashes, spaces, dots)
|
||||
// e.g. 5149490739 → %514%949%0739%
|
||||
const pattern = '%' + digits.replace(/(\d{3})(\d{3})(\d{4})/, '$1%$2%$3') + '%'
|
||||
const fields = JSON.stringify(['name', 'full_name', 'phone', 'status'])
|
||||
const filters = JSON.stringify([['phone', 'like', pattern]])
|
||||
try {
|
||||
const res = await erpFetch(`/api/resource/Dispatch Technician?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`)
|
||||
if (res.status === 200 && res.data?.data?.length > 0) return res.data.data[0]
|
||||
} catch (e) { log('lookupTechByPhone error:', e.message) }
|
||||
return null
|
||||
const rows = await erp.list('Dispatch Technician', {
|
||||
fields: ['name', 'full_name', 'phone', 'status'],
|
||||
filters: [['phone', 'like', pattern]],
|
||||
limit: 1,
|
||||
})
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function setTechAbsence (techName, { from, until, startTime, endTime, reason }) {
|
||||
|
|
@ -52,17 +52,13 @@ async function setTechAbsence (techName, { from, until, startTime, endTime, reas
|
|||
absence_start_time: startTime || '',
|
||||
absence_end_time: endTime || '',
|
||||
}
|
||||
try {
|
||||
const res = await erpFetch(`/api/resource/Dispatch Technician/${encodeURIComponent(techName)}`, {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
})
|
||||
if (res.status === 200) {
|
||||
const res = await erp.update('Dispatch Technician', techName, body)
|
||||
if (res.ok) {
|
||||
log(`Tech absence set: ${techName} from=${from} until=${until || from}`)
|
||||
broadcastAll('tech-absence', { techName, action: 'set', from, until: until || from, startTime, endTime, reason })
|
||||
return true
|
||||
}
|
||||
log(`Tech absence set FAILED: ${techName}`, res.data)
|
||||
} catch (e) { log('setTechAbsence error:', e.message) }
|
||||
log(`Tech absence set FAILED: ${techName}`, res.error)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -72,17 +68,13 @@ async function clearTechAbsence (techName) {
|
|||
absence_from: '', absence_until: '', absence_reason: '',
|
||||
absence_start_time: '', absence_end_time: '',
|
||||
}
|
||||
try {
|
||||
const res = await erpFetch(`/api/resource/Dispatch Technician/${encodeURIComponent(techName)}`, {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
})
|
||||
if (res.status === 200) {
|
||||
const res = await erp.update('Dispatch Technician', techName, body)
|
||||
if (res.ok) {
|
||||
log(`Tech absence cleared: ${techName}`)
|
||||
broadcastAll('tech-absence', { techName, action: 'clear' })
|
||||
return true
|
||||
}
|
||||
log(`Tech absence clear FAILED: ${techName}`, res.data)
|
||||
} catch (e) { log('clearTechAbsence error:', e.message) }
|
||||
log(`Tech absence clear FAILED: ${techName}`, res.error)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, erpFetch } = require('./helpers')
|
||||
const { log, json } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const { verifyJwt } = require('./magic-link')
|
||||
const { extractField } = require('./vision')
|
||||
const ui = require('./ui')
|
||||
|
|
@ -68,21 +69,27 @@ async function handlePage (req, res, path) {
|
|||
const frontDate = ui.montrealDate(new Date(Date.now() + 60 * 86400 * 1000))
|
||||
|
||||
try {
|
||||
const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(techId)}?fields=${encodeURIComponent(JSON.stringify(['name', 'full_name', 'phone', 'email', 'assigned_group']))}`)
|
||||
const tech = techRes.status === 200 && techRes.data?.data ? techRes.data.data : { name: techId, full_name: techId }
|
||||
const tech = await erp.get('Dispatch Technician', techId, {
|
||||
fields: ['name', 'full_name', 'phone', 'email', 'assigned_group'],
|
||||
}) || { name: techId, full_name: techId }
|
||||
|
||||
// Frappe v16 blocks fetched/linked fields (customer_name, service_location_name)
|
||||
// and fields not in DocField. Real time field is `start_time`. We fetch link
|
||||
// IDs and resolve names in two batch follow-up queries.
|
||||
const fields = JSON.stringify(['name', 'subject', 'status', 'customer', 'service_location', 'start_time', 'scheduled_date', 'duration_h', 'priority', 'job_type', 'notes', 'assigned_group', 'address', 'ticket_id', 'source_issue', 'actual_start', 'actual_end'])
|
||||
const filters = JSON.stringify([
|
||||
// Real time field is `start_time`; customer_name / service_location_name
|
||||
// are fetched-from links that v16 blocks in queries, so we pull the link
|
||||
// IDs and resolve labels via hydrateLabels.
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
fields: ['name', 'subject', 'status', 'customer', 'service_location', 'start_time', 'scheduled_date', 'duration_h', 'priority', 'job_type', 'notes', 'assigned_group', 'address', 'ticket_id', 'source_issue', 'actual_start', 'actual_end'],
|
||||
filters: [
|
||||
['assigned_tech', '=', techId],
|
||||
['scheduled_date', 'between', [rearDate, frontDate]],
|
||||
])
|
||||
const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&order_by=scheduled_date+desc,start_time+asc&limit_page_length=300`)
|
||||
const jobs = jobsRes.status === 200 && Array.isArray(jobsRes.data?.data) ? jobsRes.data.data : []
|
||||
|
||||
await resolveJobLabels(jobs)
|
||||
],
|
||||
orderBy: 'scheduled_date desc, start_time asc',
|
||||
limit: 300,
|
||||
})
|
||||
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: l => [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name },
|
||||
})
|
||||
|
||||
const token = path.split('/')[2]
|
||||
res.writeHead(200, ui.htmlHeaders())
|
||||
|
|
@ -94,33 +101,6 @@ async function handlePage (req, res, path) {
|
|||
}
|
||||
}
|
||||
|
||||
// Batch-fill customer_name + service_location_name without triggering v16's
|
||||
// fetched-field permission block.
|
||||
async function resolveJobLabels (jobs) {
|
||||
const custIds = [...new Set(jobs.map(j => j.customer).filter(Boolean))]
|
||||
const locIds = [...new Set(jobs.map(j => j.service_location).filter(Boolean))]
|
||||
const custNames = {}, locNames = {}
|
||||
|
||||
if (custIds.length) {
|
||||
try {
|
||||
const f = encodeURIComponent(JSON.stringify([['name', 'in', custIds]]))
|
||||
const r = await erpFetch(`/api/resource/Customer?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name']))}&limit_page_length=200`)
|
||||
for (const c of (r.data?.data || [])) custNames[c.name] = c.customer_name || c.name
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
if (locIds.length) {
|
||||
try {
|
||||
const f = encodeURIComponent(JSON.stringify([['name', 'in', locIds]]))
|
||||
const r = await erpFetch(`/api/resource/Service%20Location?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'address_line_1', 'city']))}&limit_page_length=200`)
|
||||
for (const l of (r.data?.data || [])) locNames[l.name] = [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
for (const j of jobs) {
|
||||
j.customer_name = custNames[j.customer] || j.customer || ''
|
||||
j.service_location_name = locNames[j.service_location] || j.service_location || ''
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// API handlers (unchanged behavior — just cleaner layout)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -144,10 +124,8 @@ async function handleNote (req, res, path) {
|
|||
const body = await readBody(req)
|
||||
if (!body?.job) return json(res, 400, { error: 'job required' })
|
||||
try {
|
||||
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, {
|
||||
method: 'PUT', body: JSON.stringify({ notes: body.notes || '' }),
|
||||
})
|
||||
if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'save failed' })
|
||||
const r = await erp.update('Dispatch Job', body.job, { notes: body.notes || '' })
|
||||
if (!r.ok) return json(res, r.status || 500, { error: r.error })
|
||||
return json(res, 200, { ok: true })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
|
@ -161,15 +139,12 @@ async function handlePhotoUpload (req, res, path) {
|
|||
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
||||
const ext = (body.image.match(/^data:image\/([a-z]+);/) || [, 'jpg'])[1].replace('jpeg', 'jpg')
|
||||
const fileName = body.file_name || `dj-${body.job}-${Date.now()}.${ext}`
|
||||
const r = await erpFetch('/api/resource/File', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
const r = await erp.create('File', {
|
||||
file_name: fileName, is_private: 1, content: base64, decode: 1,
|
||||
attached_to_doctype: 'Dispatch Job', attached_to_name: body.job,
|
||||
}),
|
||||
})
|
||||
if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'upload failed' })
|
||||
return json(res, 200, { ok: true, name: r.data?.data?.name, file_url: r.data?.data?.file_url, file_name: r.data?.data?.file_name })
|
||||
if (!r.ok) return json(res, r.status || 500, { error: r.error })
|
||||
return json(res, 200, { ok: true, name: r.data?.name, file_url: r.data?.file_url, file_name: r.data?.file_name })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
|
|
@ -180,13 +155,16 @@ async function handlePhotoList (req, res, path) {
|
|||
const jobName = url.searchParams.get('job')
|
||||
if (!jobName) return json(res, 400, { error: 'job required' })
|
||||
try {
|
||||
const f = encodeURIComponent(JSON.stringify([
|
||||
const items = await erp.list('File', {
|
||||
fields: ['name', 'file_name', 'file_url', 'is_private', 'creation'],
|
||||
filters: [
|
||||
['attached_to_doctype', '=', 'Dispatch Job'],
|
||||
['attached_to_name', '=', jobName],
|
||||
['file_name', 'like', 'dj-%'],
|
||||
]))
|
||||
const r = await erpFetch(`/api/resource/File?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'file_name', 'file_url', 'is_private', 'creation']))}&order_by=creation+desc&limit_page_length=50`)
|
||||
const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
|
||||
],
|
||||
orderBy: 'creation desc',
|
||||
limit: 50,
|
||||
})
|
||||
return json(res, 200, { ok: true, items })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
|
@ -223,9 +201,9 @@ async function handleJobDetail (req, res, path) {
|
|||
const name = url.searchParams.get('name')
|
||||
if (!name) return json(res, 400, { error: 'name required' })
|
||||
try {
|
||||
const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(name)}`)
|
||||
if (r.status !== 200 || !r.data?.data) return json(res, 404, { error: 'not found' })
|
||||
return json(res, 200, { ok: true, job: r.data.data })
|
||||
const job = await erp.get('Dispatch Job', name)
|
||||
if (!job) return json(res, 404, { error: 'not found' })
|
||||
return json(res, 200, { ok: true, job })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
|
|
@ -255,13 +233,19 @@ async function handleScan (req, res, path) {
|
|||
const q = body.code.replace(/[:\-\s]/g, '').toUpperCase()
|
||||
try {
|
||||
for (const field of ['serial_number', 'mac_address']) {
|
||||
const f = encodeURIComponent(JSON.stringify([[field, 'like', `%${q}%`]]))
|
||||
const r = await erpFetch(`/api/resource/Service%20Equipment?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location']))}&limit_page_length=5`)
|
||||
if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Service Equipment', query: q })
|
||||
const rows = await erp.list('Service Equipment', {
|
||||
fields: ['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location'],
|
||||
filters: [[field, 'like', `%${q}%`]],
|
||||
limit: 5,
|
||||
})
|
||||
if (rows.length) return json(res, 200, { ok: true, results: rows, source: 'Service Equipment', query: q })
|
||||
}
|
||||
const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]]))
|
||||
const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_no', 'item_code', 'status', 'warehouse']))}&limit_page_length=5`)
|
||||
if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Serial No', query: q })
|
||||
const sn = await erp.list('Serial No', {
|
||||
fields: ['name', 'serial_no', 'item_code', 'status', 'warehouse'],
|
||||
filters: [['serial_no', 'like', `%${q}%`]],
|
||||
limit: 5,
|
||||
})
|
||||
if (sn.length) return json(res, 200, { ok: true, results: sn, source: 'Serial No', query: q })
|
||||
return json(res, 200, { ok: true, results: [], query: q })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
|
@ -304,13 +288,15 @@ async function handleEquipInstall (req, res, path) {
|
|||
].filter(Boolean).join(' | '),
|
||||
installation_date: today, technician: payload.sub, action: action || 'install',
|
||||
}
|
||||
const r = await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) })
|
||||
const r = await erp.create('Equipment Install', installData)
|
||||
if (!r.ok) return json(res, r.status || 500, { error: r.error })
|
||||
|
||||
if (barcode && (action === 'install' || action === 'replace')) {
|
||||
try {
|
||||
const q = barcode.replace(/[:\-\s]/g, '').toUpperCase()
|
||||
const ef = encodeURIComponent(JSON.stringify([['serial_number', 'like', `%${q}%`]]))
|
||||
const existing = await erpFetch(`/api/resource/Service%20Equipment?filters=${ef}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`)
|
||||
const existing = await erp.list('Service Equipment', {
|
||||
fields: ['name'], filters: [['serial_number', 'like', `%${q}%`]], limit: 1,
|
||||
})
|
||||
const seCommon = {
|
||||
status: 'Actif',
|
||||
customer: customer || '', service_location: location || '',
|
||||
|
|
@ -319,19 +305,14 @@ async function handleEquipInstall (req, res, path) {
|
|||
brand: brand || '', model: model || '', equipment_type: equipment_type || '',
|
||||
}
|
||||
const seUpdate = Object.fromEntries(Object.entries(seCommon).filter(([, v]) => v !== '' && v != null))
|
||||
if (existing.status === 200 && existing.data?.data?.length) {
|
||||
await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, {
|
||||
method: 'PUT', body: JSON.stringify(seUpdate),
|
||||
})
|
||||
if (existing.length) {
|
||||
await erp.update('Service Equipment', existing[0].name, seUpdate)
|
||||
} else {
|
||||
await erpFetch('/api/resource/Service%20Equipment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ serial_number: barcode, ...seCommon }),
|
||||
})
|
||||
await erp.create('Service Equipment', { serial_number: barcode, ...seCommon })
|
||||
}
|
||||
} catch (e) { log('Equip link/create warn:', e.message) }
|
||||
}
|
||||
return json(res, 200, { ok: true, name: r.data?.data?.name || '' })
|
||||
return json(res, 200, { ok: true, name: r.name || '' })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
|
|
@ -341,19 +322,15 @@ async function handleEquipRemove (req, res, path) {
|
|||
const body = await readBody(req)
|
||||
if (!body?.equipment) return json(res, 400, { error: 'equipment name required' })
|
||||
try {
|
||||
await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(body.equipment)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: body.status || 'Retourné', customer: '', service_location: '' }),
|
||||
await erp.update('Service Equipment', body.equipment, {
|
||||
status: body.status || 'Retourné', customer: '', service_location: '',
|
||||
})
|
||||
await erpFetch('/api/resource/Equipment%20Install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
await erp.create('Equipment Install', {
|
||||
request: body.job || '', barcode: body.serial || '',
|
||||
equipment_type: body.equipment_type || '',
|
||||
notes: `Retrait par ${payload.sub}`,
|
||||
installation_date: new Date().toISOString().slice(0, 10),
|
||||
technician: payload.sub, action: 'remove',
|
||||
}),
|
||||
})
|
||||
return json(res, 200, { ok: true })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
|
|
@ -363,9 +340,11 @@ async function handleCatalog (req, res, path) {
|
|||
const payload = authToken(path)
|
||||
if (!payload) return json(res, 401, { error: 'expired' })
|
||||
try {
|
||||
const groups = encodeURIComponent(JSON.stringify([['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]]))
|
||||
const r = await erpFetch(`/api/resource/Item?filters=${groups}&fields=${encodeURIComponent(JSON.stringify(['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description']))}&limit_page_length=50`)
|
||||
const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
|
||||
const items = await erp.list('Item', {
|
||||
fields: ['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description'],
|
||||
filters: [['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]],
|
||||
limit: 50,
|
||||
})
|
||||
if (!items.length) {
|
||||
return json(res, 200, { ok: true, items: [
|
||||
{ name: 'ONT-GPON', item_name: 'ONT GPON', item_group: 'CPE', standard_rate: 0, description: 'Terminal fibre client' },
|
||||
|
|
@ -386,9 +365,12 @@ async function handleEquipList (req, res, path) {
|
|||
const jobName = url.searchParams.get('job')
|
||||
if (!jobName) return json(res, 400, { error: 'job param required' })
|
||||
try {
|
||||
const f = encodeURIComponent(JSON.stringify([['request', '=', jobName]]))
|
||||
const r = await erpFetch(`/api/resource/Equipment%20Install?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date']))}&order_by=creation+desc&limit_page_length=20`)
|
||||
const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
|
||||
const items = await erp.list('Equipment Install', {
|
||||
fields: ['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date'],
|
||||
filters: [['request', '=', jobName]],
|
||||
orderBy: 'creation desc',
|
||||
limit: 20,
|
||||
})
|
||||
return json(res, 200, { ok: true, items })
|
||||
} catch (e) { return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user