diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js index e47db9a..39cf72f 100644 --- a/services/targo-hub/lib/conversation.js +++ b/services/targo-hub/lib/conversation.js @@ -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 } diff --git a/services/targo-hub/lib/erp.js b/services/targo-hub/lib/erp.js new file mode 100644 index 0000000..23ae5f1 --- /dev/null +++ b/services/targo-hub/lib/erp.js @@ -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/[?...] 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, +} diff --git a/services/targo-hub/lib/ical.js b/services/targo-hub/lib/ical.js index bf2c1cb..aab684e 100644 --- a/services/targo-hub/lib/ical.js +++ b/services/targo-hub/lib/ical.js @@ -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([ - ['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 || [] + 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'], + ], + orderBy: 'scheduled_date asc, start_time asc', + limit: 500, + }) const ical = buildICal(tech.full_name, jobs, techId) res.writeHead(200, { diff --git a/services/targo-hub/lib/magic-link.js b/services/targo-hub/lib/magic-link.js index a55ca50..348c1ee 100644 --- a/services/targo-hub/lib/magic-link.js +++ b/services/targo-hub/lib/magic-link.js @@ -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}` diff --git a/services/targo-hub/lib/otp.js b/services/targo-hub/lib/otp.js index cd8052a..6684e8f 100644 --- a/services/targo-hub/lib/otp.js +++ b/services/targo-hub/lib/otp.js @@ -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 || '', diff --git a/services/targo-hub/lib/referral.js b/services/targo-hub/lib/referral.js index adc28bc..a714e3f 100644 --- a/services/targo-hub/lib/referral.js +++ b/services/targo-hub/lib/referral.js @@ -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' } diff --git a/services/targo-hub/lib/tech-absence-sms.js b/services/targo-hub/lib/tech-absence-sms.js index 1ac7628..d62a396 100644 --- a/services/targo-hub/lib/tech-absence-sms.js +++ b/services/targo-hub/lib/tech-absence-sms.js @@ -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) { - 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) } + 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.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) { - 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) } + 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.error) return false } diff --git a/services/targo-hub/lib/tech-mobile.js b/services/targo-hub/lib/tech-mobile.js index 745110b..6eb00e3 100644 --- a/services/targo-hub/lib/tech-mobile.js +++ b/services/targo-hub/lib/tech-mobile.js @@ -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([ - ['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) + // 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]], + ], + 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({ - file_name: fileName, is_private: 1, content: base64, decode: 1, - attached_to_doctype: 'Dispatch Job', attached_to_name: body.job, - }), + 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([ - ['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 : [] + 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-%'], + ], + 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({ - 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', - }), + 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,16 +340,18 @@ 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' }, - { name: 'ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', item_group: 'CPE', standard_rate: 9.99, description: 'Routeur sans fil' }, - { name: 'DECODER-IPTV', item_name: 'Décodeur IPTV', item_group: 'CPE', standard_rate: 7.99, description: 'Décodeur télévision' }, - { name: 'VOIP-ATA', item_name: 'Adaptateur VoIP', item_group: 'CPE', standard_rate: 4.99, description: 'Adaptateur téléphonie' }, - { name: 'AMP-COAX', item_name: 'Amplificateur coaxial', item_group: 'Network Equipment', standard_rate: 0, description: 'Amplificateur signal' }, + { name: 'ONT-GPON', item_name: 'ONT GPON', item_group: 'CPE', standard_rate: 0, description: 'Terminal fibre client' }, + { name: 'ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', item_group: 'CPE', standard_rate: 9.99, description: 'Routeur sans fil' }, + { name: 'DECODER-IPTV', item_name: 'Décodeur IPTV', item_group: 'CPE', standard_rate: 7.99, description: 'Décodeur télévision' }, + { name: 'VOIP-ATA', item_name: 'Adaptateur VoIP', item_group: 'CPE', standard_rate: 4.99, description: 'Adaptateur téléphonie' }, + { name: 'AMP-COAX', item_name: 'Amplificateur coaxial', item_group: 'Network Equipment', standard_rate: 0, description: 'Amplificateur signal' }, ]}) } return json(res, 200, { ok: true, items }) @@ -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 }) } }