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:
louispaulb 2026-04-22 23:01:27 -04:00
parent 169426a6d8
commit 01bb99857f
8 changed files with 406 additions and 207 deletions

View File

@ -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 }

View 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,
}

View File

@ -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, {

View File

@ -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}`

View File

@ -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 || '',

View File

@ -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' }

View File

@ -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
}

View File

@ -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 }) }
}