'use strict' const cfg = require('./config') const { log, json, parseBody, erpFetch } = require('./helpers') const SCORE_WEIGHTS = { proximityMultiplier: 4, // distance (km, capped at 100) * this = proximity penalty proximityMax: 100, // cap distance at this km value loadMultiplier: 30, // tech.load * this = load penalty overloadPenalty: 500, // added when tech has insufficient capacity gpsFreshnessBonus: 20, // subtracted when GPS is live } function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) } function nowHoursET () { const parts = new Date().toLocaleString('en-CA', { timeZone: 'America/Toronto', hour12: false }).split(' ') const [hh, mm] = parts[1].split(':').map(Number) return hh + (mm || 0) / 60 } function timeToHours (t) { if (!t) return 0 const [h, m] = t.split(':').map(Number) return h + (m || 0) / 60 } function hoursToTime (h) { const hh = Math.floor(h) const mm = Math.round((h - hh) * 60) return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } function dateAddDays (baseStr, n) { const d = new Date(baseStr + 'T12:00:00') d.setDate(d.getDate() + n) return d.toISOString().slice(0, 10) } // Euclidean approximation, km at Montreal latitude function distKm (a, b) { if (!a || !b) return 999 const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111 return Math.sqrt(dx * dx + dy * dy) } async function getTechsWithLoad (dateStr) { const [techRes, jobRes] = await Promise.all([ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'traccar_device_id', 'phone']))}&limit_page_length=50`), erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify({ status: ['in', ['open', 'assigned']], scheduled_date: dateStr }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'assigned_tech', 'duration_h', 'longitude', 'latitude', 'status', 'route_order']))}&limit_page_length=200`), ]) if (techRes.status !== 200) throw new Error('Failed to fetch technicians') const techs = techRes.data.data || [] const jobs = jobRes.status === 200 ? (jobRes.data.data || []) : [] return techs.map(t => { const queue = jobs .filter(j => j.assigned_tech === t.technician_id) .sort((a, b) => (a.route_order || 0) - (b.route_order || 0)) const load = queue.reduce((s, j) => s + (parseFloat(j.duration_h) || 1), 0) return { id: t.technician_id, name: t.full_name, status: t.status || 'available', coords: [t.longitude || -73.5673, t.latitude || 45.5017], traccarDeviceId: t.traccar_device_id, phone: t.phone, queue, load, tags: [], } }) } async function enrichWithGps (techs) { try { const { getDevices, getPositions } = require('./traccar') const devices = await getDevices() const deviceMap = {} techs.forEach(t => { if (!t.traccarDeviceId) return const dev = devices.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) if (dev) deviceMap[dev.id] = t }) const deviceIds = Object.keys(deviceMap).map(Number) if (!deviceIds.length) return const positions = await getPositions(deviceIds) positions.forEach(p => { const tech = deviceMap[p.deviceId] if (tech && p.latitude && p.longitude) { tech.gpsCoords = [p.longitude, p.latitude] tech.gpsOnline = true } }) } catch (e) { log('GPS enrichment error:', e.message) } } function rankTechs (techs, jobCoords, jobDuration = 1) { const hasCoords = jobCoords && (jobCoords[0] || jobCoords[1]) const candidates = techs.filter(t => t.status !== 'off' && t.status !== 'unavailable') return candidates.map(tech => { const pos = tech.gpsOnline ? tech.gpsCoords : tech.coords const distance = hasCoords ? distKm(pos, jobCoords) : 999 const remainingCap = Math.max(0, 8 - tech.load) let score = 0 const reasons = [] score += Math.min(distance, SCORE_WEIGHTS.proximityMax) * SCORE_WEIGHTS.proximityMultiplier reasons.push(distance < 5 ? `${distance.toFixed(1)} km (très proche)` : distance < 15 ? `${distance.toFixed(1)} km` : `${distance.toFixed(1)} km (loin)`) score += tech.load * SCORE_WEIGHTS.loadMultiplier if (remainingCap < jobDuration) { score += SCORE_WEIGHTS.overloadPenalty reasons.push(`Surchargé (${tech.load.toFixed(1)}h/8h)`) } else if (tech.load < 4) { reasons.push(`Dispo (${tech.load.toFixed(1)}h/8h)`) } else { reasons.push(`Chargé (${tech.load.toFixed(1)}h/8h)`) } if (tech.gpsOnline) { score -= SCORE_WEIGHTS.gpsFreshnessBonus; reasons.push('GPS en direct') } return { techId: tech.id, techName: tech.name, phone: tech.phone, score, distance, load: tech.load, remainingCap, reasons } }).sort((a, b) => a.score - b.score) } // ── Slot suggestion ──────────────────────────────────────────────────────── // Finds open time windows across techs for the next N days. Keeps the logic // simple & predictable: gaps between pinned jobs within a default shift // window, minus a travel buffer before the new job, minus a "not in the // past" cutoff. Scores surface the most natural inserts (earliest first, // then shortest travel), with a 2-slot-per-tech cap to diversify results. const SLOT_DEFAULT_SHIFT = { start_h: 8, end_h: 17 } const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed start const SLOT_HORIZON_DAYS = 7 const SLOT_MAX_PER_TECH = 2 async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5 } = {}) { const baseDate = after_date || todayET() const duration = parseFloat(duration_h) || 1 const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i)) const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null const [techRes, jobRes] = await Promise.all([ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([ 'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'absence_from', 'absence_until', ]))}&limit_page_length=50`), erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([ ['status', 'in', ['open', 'assigned']], ['scheduled_date', '>=', dates[0]], ['scheduled_date', '<=', dates[dates.length - 1]], ]))}&fields=${encodeURIComponent(JSON.stringify([ 'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'longitude', 'latitude', ]))}&limit_page_length=500`), ]) if (techRes.status !== 200) throw new Error('Failed to fetch technicians') const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable') const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : [] const today = todayET() const nowH = nowHoursET() const slots = [] for (const tech of techs) { const homeCoords = tech.longitude && tech.latitude ? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null for (const dateStr of dates) { // Absence window skip. if (tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue // Day's pinned jobs (only those with a real start_time — floating jobs // without a time are ignored since we don't know when they'll land). const dayJobs = allJobs .filter(j => j.assigned_tech === tech.technician_id && j.scheduled_date === dateStr && j.start_time) .map(j => { const s = timeToHours(j.start_time) return { start_h: s, end_h: s + (parseFloat(j.duration_h) || 1), coords: j.latitude && j.longitude ? [parseFloat(j.longitude), parseFloat(j.latitude)] : null, } }) .sort((a, b) => a.start_h - b.start_h) // Build gaps bounded by shift_start/end. const shift = SLOT_DEFAULT_SHIFT const gaps = [] let cursor = shift.start_h, prevCoords = homeCoords for (const j of dayJobs) { gaps.push({ start_h: cursor, end_h: j.start_h, prev_coords: prevCoords, position: gaps.length === 0 ? 'first' : 'between', }) cursor = j.end_h prevCoords = j.coords } gaps.push({ start_h: cursor, end_h: shift.end_h, prev_coords: prevCoords, position: dayJobs.length === 0 ? 'free_day' : 'last', }) for (const g of gaps) { const gapLen = g.end_h - g.start_h if (gapLen < duration + SLOT_TRAVEL_BUFFER_H) continue const startH = g.start_h + SLOT_TRAVEL_BUFFER_H const endH = startH + duration if (endH > g.end_h) continue // Skip slots already in the past (or within 30 min). if (dateStr === today && startH < nowH + 0.5) continue const distanceKm = jobCoords && g.prev_coords ? distKm(g.prev_coords, jobCoords) : null const travelMin = distanceKm != null ? Math.max(5, Math.min(90, Math.round(distanceKm * 1.5))) : Math.round(SLOT_TRAVEL_BUFFER_H * 60) const reasons = [] if (g.position === 'free_day') reasons.push('Journée libre') else if (g.position === 'first') reasons.push('Début de journée') else if (g.position === 'last') reasons.push('Fin de journée') else reasons.push('Entre 2 rendez-vous') if (distanceKm != null) reasons.push(`${distanceKm.toFixed(1)} km du précédent`) slots.push({ tech_id: tech.technician_id, tech_name: tech.full_name, date: dateStr, start_time: hoursToTime(startH), end_time: hoursToTime(endH), travel_min: travelMin, distance_km: distanceKm != null ? +distanceKm.toFixed(1) : null, gap_h: +gapLen.toFixed(1), reasons, position: g.position, }) } } } // Sort: earliest first, then shortest travel. slots.sort((a, b) => { if (a.date !== b.date) return a.date < b.date ? -1 : 1 if (a.start_time !== b.start_time) return a.start_time < b.start_time ? -1 : 1 return (a.travel_min || 0) - (b.travel_min || 0) }) // Diversify: cap slots-per-tech so we don't return 5 options from the // same person. Dispatchers want to compare across resources. const byTech = {} const picked = [] for (const s of slots) { byTech[s.tech_id] = byTech[s.tech_id] || 0 if (byTech[s.tech_id] >= SLOT_MAX_PER_TECH) continue picked.push(s) byTech[s.tech_id]++ if (picked.length >= limit) break } return picked } // Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id). // Best-effort (max du jour + 1) ; le VRAI garde-fou anti-collision = l'unicité du nom à l'insertion (le créateur réessaie au besoin). async function nextJobRef () { const ymd = String(todayET()).slice(0, 10).replace(/-/g, '') let max = 0 try { const filters = encodeURIComponent(JSON.stringify([['name', 'like', ymd + '-%']])) const fields = encodeURIComponent(JSON.stringify(['name'])) const r = await erpFetch(`/api/resource/Dispatch Job?filters=${filters}&fields=${fields}&limit_page_length=0`) const re = new RegExp('^' + ymd + '-(\\d{3})$') for (const j of ((r && r.data) || [])) { const m = re.exec(String(j.name || '')); if (m) { const n = parseInt(m[1], 10); if (n > max) max = n } } } catch (e) { log('nextJobRef: ' + e.message) } return ymd + '-' + String(max + 1).padStart(3, '0') } // Crée une CHAÎNE d'installation (projet + sous-tâches) en UN geste depuis un template (project-templates.js). // Numéro du projet = ref YYYYMMDD-XXX partagé ; étapes = ref-s1..sN. Chaînage = patron éprouvé de checkout.js // (étape racine 'open', dépendantes 'On Hold' jusqu'à complétion via depends_on ; parent_job = étape racine pour le regroupement). async function createInstallChain ({ customer, customer_name, template, scheduled_date, priority, assigned_tech, address }) { const { getTemplateSteps } = require('./project-templates') const steps = getTemplateSteps(template || 'fiber_install') if (!steps.length) return { ok: false, error: 'template inconnu: ' + (template || 'fiber_install') } const ref = await nextJobRef() const created = [] // noms = ticket_id (autoname field:ticket_id → name === ticket_id) → chaînage déterministe, sans dépendre de la réponse POST try { for (let i = 0; i < steps.length; i++) { const step = steps[i] const ticketId = `${ref}-s${i + 1}` const dependsOn = (step.depends_on_step != null && created[step.depends_on_step]) ? created[step.depends_on_step] : '' const payload = { ticket_id: ticketId, subject: step.subject, customer: customer || '', address: address || '', duration_h: step.duration_h || 1, priority: priority || step.priority || 'medium', status: dependsOn ? 'On Hold' : 'open', // racine 'open', dépendantes 'On Hold' jusqu'à complétion (unblockDependents) job_type: step.job_type || 'Autre', assigned_group: step.assigned_group || '', assigned_tech: (i === 0 && assigned_tech) ? assigned_tech : '', depends_on: dependsOn, parent_job: created.length ? created[0] : '', // regroupement sous l'étape racine step_order: i + 1, scheduled_date: scheduled_date || '', } await erpFetch('/api/resource/Dispatch Job', { method: 'POST', body: JSON.stringify(payload) }) created.push(ticketId) } } catch (e) { return { ok: false, error: 'échec étape ' + (created.length + 1) + ': ' + e.message, ref, jobs: created } } return { ok: true, ref, project: ref, jobs: created, count: created.length } } async function createDispatchJob ({ subject, address, priority, duration_h, job_type, customer, service_location, source_issue, notes, latitude, longitude, assigned_tech, scheduled_date }) { const ticketId = await nextJobRef() const payload = { ticket_id: ticketId, subject: subject || 'Travail urgent', address: address || '', duration_h: parseFloat(duration_h) || 1, priority: priority || 'high', status: assigned_tech ? 'assigned' : 'open', job_type: job_type || 'Dépannage', customer: customer || '', service_location: service_location || '', source_issue: source_issue || '', notes: notes || '', latitude: latitude || '', longitude: longitude || '', assigned_tech: assigned_tech || '', scheduled_date: scheduled_date || todayET(), } const r = await erpFetch('/api/resource/Dispatch Job', { method: 'POST', body: JSON.stringify(payload) }) if (r.status < 200 || r.status >= 300) throw new Error('Failed to create dispatch job') return { success: true, job_id: r.data?.data?.name || ticketId, ...payload } } // ── Chain-walk helpers ────────────────────────────────────────────────────── // A Dispatch Job can have `depends_on` → another Dispatch Job. When a job is // created as part of a chain (acceptance.js → createDeferredJobs), the // dependent child is born with status='On Hold' so it doesn't show up in the // tech's active list. When the parent flips to 'Completed', we walk the chain: // find all `depends_on == parent && status == 'On Hold'` and flip them to // 'open' so the next step becomes visible. // // We also handle fan-out (multiple children of one parent) and fire SSE // `job-unblocked` so the tech SPA can refresh without a poll. async function unblockDependents (jobName) { if (!jobName) return [] const filters = encodeURIComponent(JSON.stringify([ ['depends_on', '=', jobName], ['status', '=', 'On Hold'], ])) const fields = encodeURIComponent(JSON.stringify(['name', 'subject', 'assigned_tech', 'scheduled_date'])) const r = await erpFetch(`/api/resource/Dispatch%20Job?filters=${filters}&fields=${fields}&limit_page_length=20`) const deps = (r.status === 200 && Array.isArray(r.data?.data)) ? r.data.data : [] const unblocked = [] for (const j of deps) { try { const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(j.name)}`, { method: 'PUT', body: JSON.stringify({ status: 'open' }), }) if (up.status < 400) { unblocked.push(j.name) require('./sse').broadcast('dispatch', 'job-unblocked', { job: j.name, subject: j.subject, tech: j.assigned_tech, unblocked_by: jobName, }) log(` ↳ unblocked ${j.name} (depends_on=${jobName})`) } } catch (e) { log(` ! unblock failed for ${j.name}: ${e.message}`) } } return unblocked } // Chain terminal check — is `jobName` the LAST still-open job in its chain? // A chain is identified by its root (parent_job === '' or self). A job is // terminal when every sibling under the same root is Completed or Cancelled. async function _isChainTerminal (jobName) { const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['parent_job']))}`) if (jRes.status !== 200 || !jRes.data?.data) return false const rootName = jRes.data.data.parent_job || jobName const stillOpen = ['open', 'Scheduled', 'In Progress', 'On Hold'] const rootRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(rootName)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`) if (rootRes.status === 200 && stillOpen.includes(rootRes.data?.data?.status)) return false const childFilter = encodeURIComponent(JSON.stringify([ ['parent_job', '=', rootName], ['name', '!=', jobName], ['status', 'in', stillOpen], ])) const childRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`) return !(childRes.status === 200 && Array.isArray(childRes.data?.data) && childRes.data.data.length) } // Memoized company default income account — Sales Invoice items require // an income_account that belongs to the company, and the default line-item // resolution leaves it as None (→ 417 at validation time). We read the // Company.default_income_account once per process and reuse it. let _cachedIncomeAccount = null async function _defaultIncomeAccount (company) { if (_cachedIncomeAccount) return _cachedIncomeAccount try { const r = await erpFetch(`/api/resource/Company/${encodeURIComponent(company)}?fields=${encodeURIComponent(JSON.stringify(['default_income_account']))}`) const acc = r.data?.data?.default_income_account if (acc) { _cachedIncomeAccount = acc; return acc } } catch (_) { /* fall through */ } // Hard-coded fallback for TARGO if the lookup failed for some reason _cachedIncomeAccount = 'Ventes - T' return _cachedIncomeAccount } // Activate every 'En attente' Service Subscription for (customer, service_location) // belonging to the completed job, and emit ONE CONSOLIDATED prorated Sales Invoice // (all lines on a single draft) via the proration engine — replacing the previous // one-invoice-per-subscription behaviour. The invoice is built type-aware (recurring // prorated, one-time full, discounts follow their line) with largest-remainder rounding. // Returns { activated, invoice, invoices, written, prorated_total }. async function activateSubscriptionForJob (jobName) { const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['customer', 'service_location']))}`) if (jRes.status !== 200 || !jRes.data?.data) return { activated: [], invoice: null, invoices: [], written: false } const { customer, service_location } = jRes.data.data if (!customer || !service_location) return { activated: [], invoice: null, invoices: [], written: false } // Subscriptions waiting on THIS activation — strictly 'En attente' so we NEVER // re-bill subscriptions that are already Actif from a prior (partial) activation. const subFilter = encodeURIComponent(JSON.stringify([ ['customer', '=', customer], ['service_location', '=', service_location], ['status', '=', 'En attente'], ])) const subRes = await erpFetch(`/api/resource/Service%20Subscription?filters=${subFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=50`) const pending = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : [] if (!pending.length) return { activated: [], invoice: null, invoices: [], written: false } // 1. ONE consolidated, type-aware, prorated draft invoice for exactly these pending subs. // Built BEFORE flipping (the engine filters on status='En attente'). Draft-only and // DOUBLE-GATED (commit:true + env PRORATION_WRITE=on): with the gate off it computes the // preview but writes NOTHING, so activation never bills until explicitly armed. let billing = { written: false } try { billing = await require('./proration').commitActivationInvoice({ customer, service_location, statuses: ['En attente'], commit: true, }) } catch (e) { log(` ! consolidated activation invoice failed: ${e.message}`) } // 2. Flip each pending subscription to Actif (start_date=tomorrow — activation day is free). const now = new Date() const tm = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const tomorrowStr = `${tm.getFullYear()}-${String(tm.getMonth() + 1).padStart(2, '0')}-${String(tm.getDate()).padStart(2, '0')}` const activated = [] for (const sub of pending) { const up = await erpFetch(`/api/resource/Service%20Subscription/${encodeURIComponent(sub.name)}`, { method: 'PUT', body: JSON.stringify({ status: 'Actif', start_date: tomorrowStr }), }) if (up.status >= 400) { log(` ! activate ${sub.name} returned ${up.status}`); continue } activated.push(sub.name) log(` ✓ Service Subscription ${sub.name} → Actif (start_date=${tomorrowStr})`) } const invoices = (billing.written && billing.draft_invoice) ? [{ name: billing.draft_invoice, amount: billing.prorated_total, consolidated: true }] : [] if (billing.written && billing.draft_invoice) { log(` ✓ Facture consolidée (BROUILLON) ${billing.draft_invoice} — ${billing.prorated_total}$ · ${billing.billed_days}/${billing.period_days} j · ${activated.length} abonnement(s)`) } else { log(` • ${activated.length} abonnement(s) activé(s) — facture consolidée NON émise (écriture désarmée: ${billing.note || 'PRORATION_WRITE off'}) ; aperçu = ${billing.prorated_total != null ? billing.prorated_total + '$' : 'n/a'}`) } require('./sse').broadcast('dispatch', 'subscription-activated', { subscriptions: activated, invoice: billing.draft_invoice || null, amount: billing.prorated_total != null ? billing.prorated_total : null, customer, consolidated: true, preview_only: !billing.written, }) return { activated, invoice: billing.draft_invoice || null, invoices, written: !!billing.written, prorated_total: billing.prorated_total != null ? billing.prorated_total : null } } // Delete a Dispatch Job safely by rewiring its chain neighbours first. // // Problem: ERPNext throws LinkExistsError if any other Dispatch Job has // `depends_on = ` or `parent_job = `. The UI's generic // "Supprimer cette tâche" button hits this whenever the user tries to // drop a middle step from a chain. // // Strategy: before DELETE, repoint the chain so the victim is isolated: // 1. Children (depends_on = victim) → depends_on = victim.depends_on // (skips the victim in the chain; the successor's status recomputes // so it unblocks if it was On Hold and its new parent is Completed // or empty). // 2. Descendants (parent_job = victim, only fires when victim was the // chain root) → parent_job = new root. The new root is chosen as the // lowest-step_order former child (now a chain root itself). // 3. Finally DELETE the victim — should succeed since no FK references it. // // Returns { ok, deleted, rewired: [...], note }. async function deleteJobSafely (jobName) { if (!jobName) throw new Error('jobName required') const vRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`) if (vRes.status !== 200 || !vRes.data?.data) throw new Error(`job ${jobName} not found`) const victim = vRes.data.data const victimDependsOn = victim.depends_on || '' const victimParentJob = victim.parent_job || '' // 1. Immediate successors in the chain const childFilters = encodeURIComponent(JSON.stringify([['depends_on', '=', jobName]])) const childFields = encodeURIComponent(JSON.stringify(['name', 'status', 'step_order'])) const cRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilters}&fields=${childFields}&limit_page_length=50`) const children = (cRes.status === 200 && Array.isArray(cRes.data?.data)) ? cRes.data.data : [] const rewired = [] for (const child of children) { const patch = { depends_on: victimDependsOn } // Unblock if the new parent is empty or already Completed — otherwise the // child stays On Hold waiting on the new parent. if (child.status === 'On Hold') { let newParentDone = !victimDependsOn // no parent → chain root → unblock if (victimDependsOn) { const pRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(victimDependsOn)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`) const pStatus = pRes.data?.data?.status if (pStatus === 'Completed' || pStatus === 'done' || pStatus === 'Cancelled') newParentDone = true } if (newParentDone) patch.status = 'open' } const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(child.name)}`, { method: 'PUT', body: JSON.stringify(patch), }) if (up.status < 400) { rewired.push({ name: child.name, depends_on: victimDependsOn, status: patch.status }) log(` ↻ ${child.name}: depends_on=${jobName} → ${victimDependsOn || '(none)'}${patch.status ? ` + status→${patch.status}` : ''}`) } else { log(` ! rewire failed for ${child.name}: ${up.status}`) } } // 2. Was the victim a chain root? If so, any job with parent_job=victim // needs a new root. Pick the former lowest-step child (now the new head). if (!victimParentJob) { const descFilters = encodeURIComponent(JSON.stringify([['parent_job', '=', jobName]])) const descFields = encodeURIComponent(JSON.stringify(['name'])) const dRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${descFilters}&fields=${descFields}&limit_page_length=100`) const descendants = (dRes.status === 200 && Array.isArray(dRes.data?.data)) ? dRes.data.data : [] if (descendants.length) { // Former children became roots; pick the lowest-step one as the new // canonical root (so a chain with fan-out survives as a single chain). const sortedKids = [...children].sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) const newRoot = (sortedKids[0] && sortedKids[0].name) || '' for (const d of descendants) { if (d.name === newRoot) continue // the new root has parent_job='' (done above via children loop when we rewired depends_on; parent_job still references victim though — set to '') const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(d.name)}`, { method: 'PUT', body: JSON.stringify({ parent_job: newRoot || '' }), }) if (up.status < 400) log(` ↻ ${d.name}: parent_job=${jobName} → ${newRoot || '(none)'}`) } // And blank the new root's parent_job (it's the root now) if (newRoot) { await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(newRoot)}`, { method: 'PUT', body: JSON.stringify({ parent_job: '' }), }).catch(() => {}) } } } // 3. Delete the victim const dRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, { method: 'DELETE' }) if (dRes.status >= 400) { const msg = dRes.data?.exception || dRes.data?._error_message || `HTTP ${dRes.status}` throw new Error(msg) } require('./sse').broadcast('dispatch', 'job-deleted', { job: jobName, rewired: rewired.map(r => r.name) }) log(` ✗ deleted ${jobName} (rewired ${rewired.length} child${rewired.length === 1 ? '' : 'ren'})`) return { ok: true, deleted: jobName, rewired, note: children.length ? `Rewired ${children.length} dependent job(s) before deletion.` : 'No dependents — clean delete.' } } // Thin wrapper that PUTs the status + runs unblock logic. Used by // tech-mobile (token auth) and the ops SPA (session auth) through a single // code path — status changes always walk the chain, no matter who wrote them. async function setJobStatusWithChain (jobName, status) { const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, { method: 'PUT', body: JSON.stringify({ status }), }) if (r.status >= 400) throw new Error(`ERPNext ${r.status}`) require('./sse').broadcast('dispatch', 'job-status', { job: jobName, status }) const unblocked = status === 'Completed' ? await unblockDependents(jobName) : [] // Terminal-node detection: if this Completed job is the last open one in // the chain (no unblocked dependents AND no other siblings pending), // activate the linked Service Subscription and emit a prorated invoice. let activation = { activated: [], invoices: [] } if (status === 'Completed' && unblocked.length === 0) { if (await _isChainTerminal(jobName)) { if (process.env.BILLING_APPROVAL_GATE === 'on') { // P1 : la complétion ne facture/n'active plus directement — elle place l'activation // « à approuver ». L'activation (puis la facturation, gatée PRORATION_WRITE) se font // à l'approbation explicite dans Ops (cf docs/design/activation-billing-automation.md). const pending = require('./billing-approvals').markPending(jobName) activation = { pending_approval: pending, activated: [], invoices: [] } log(` ⏸ ${jobName} terminal → EN ATTENTE D'APPROBATION facturation (aucune activation/facture)`) } else { activation = await activateSubscriptionForJob(jobName) } } } return { ok: true, job: jobName, status, unblocked, ...activation } } async function handle (req, res, method, path) { const sub = path.replace('/dispatch/', '') // POST /dispatch/job-status — update status + auto-unblock dependents // This is the canonical status-write endpoint. Anything that flips a // Dispatch Job status (tech SPA, dispatcher SPA, tech-mobile token page) // should go through here so the chain-walk runs in exactly one place. if (sub === 'job-status' && method === 'POST') { try { const body = await parseBody(req) if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' }) const result = await setJobStatusWithChain(body.job, body.status) return json(res, 200, result) } catch (e) { log('job-status error:', e.message) return json(res, 500, { error: e.message }) } } // POST /dispatch/job-delete — safe delete that rewires chain neighbours. // The raw ERPNext DELETE fails with LinkExistsError whenever any sibling // references the victim via depends_on/parent_job. This endpoint unlinks // descendants first (see deleteJobSafely doc comment) then deletes. if (sub === 'job-delete' && method === 'POST') { try { const body = await parseBody(req) if (!body?.job) return json(res, 400, { error: 'job required' }) const result = await deleteJobSafely(body.job) return json(res, 200, result) } catch (e) { log('job-delete error:', e.message) return json(res, 500, { error: e.message }) } } // GET /dispatch/history?date=YYYY-MM-DD[&tech=TECH-x] — TOUS les jobs d'une journée, statuts INCLUS (Completed/Cancelled), groupés par technicien. // Corrige la perte de visibilité : le board n'affiche que open/assigned/in_progress → une fois un job terminé/annulé (ou son ticket fermé/réassigné) on ne voyait plus où le tech était allé. if (sub.startsWith('history') && method === 'GET') { try { const params = require('url').parse(req.url, true).query const date = params.date || todayET() const filters = [['scheduled_date', '=', date]] if (params.tech) filters.push(['assigned_tech', '=', params.tech]) const fields = ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'job_type', 'source_issue', 'customer', 'actual_start', 'actual_end'] const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(fields), limit_page_length: '500', order_by: 'assigned_tech asc, start_time asc, modified asc' }) const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`) if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status }) const jobs = r.data?.data || [] const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`) const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id const groups = {} for (const j of jobs) { const k = j.assigned_tech || '__none__'; (groups[k] = groups[k] || { tech: k, tech_name: k === '__none__' ? 'Non assigné' : (names[k] || k), jobs: [] }).jobs.push(j) } return json(res, 200, { date, count: jobs.length, techs: Object.values(groups).sort((a, b) => String(a.tech_name).localeCompare(String(b.tech_name))) }) } catch (e) { log('dispatch history error: ' + e.message); return json(res, 500, { error: e.message }) } } // GET /dispatch/audit?job=[&tech=TECH-x][&from=ISO][&to=ISO] — journal d'audit d'assignation (qui a eu le job, quand, chaque changement horodaté). if (sub.startsWith('audit') && method === 'GET') { const q = require('url').parse(req.url, true).query const rows = require('./dispatch-audit').read({ job: q.job, tech: q.tech, from: q.from, to: q.to, limit: Math.min(2000, parseInt(q.limit || '500', 10)) }) return json(res, 200, { count: rows.length, rows }) } // GET /dispatch/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des techs par jobs COMPLÉTÉS (reconnaissance / gamification). if (sub.startsWith('leaderboard') && method === 'GET') { try { const params = require('url').parse(req.url, true).query const to = params.to || todayET(); const from = params.from || dateAddDays(to, -30) const filters = [['status', '=', 'Completed'], ['scheduled_date', '>=', from], ['scheduled_date', '<=', to]] const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(['assigned_tech']), limit_page_length: '5000' }) const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`) if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status }) const counts = {}; for (const j of (r.data?.data || [])) { if (j.assigned_tech) counts[j.assigned_tech] = (counts[j.assigned_tech] || 0) + 1 } const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`) const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id const board = Object.keys(counts).map(k => ({ tech: k, tech_name: names[k] || k, completed: counts[k] })).sort((a, b) => b.completed - a.completed) return json(res, 200, { from, to, board }) } catch (e) { log('dispatch leaderboard error: ' + e.message); return json(res, 500, { error: e.message }) } } // POST /dispatch/best-tech — find optimal tech for a job location if (sub === 'best-tech' && method === 'POST') { try { const body = await parseBody(req) const dateStr = body.date || todayET() const jobCoords = body.latitude && body.longitude ? [parseFloat(body.longitude), parseFloat(body.latitude)] : null const techs = await getTechsWithLoad(dateStr) await enrichWithGps(techs) const ranked = rankTechs(techs, jobCoords, parseFloat(body.duration_h) || 1) return json(res, 200, { ranking: ranked }) } catch (e) { log('best-tech error:', e.message) return json(res, 500, { error: e.message }) } } // POST /dispatch/suggest-slots — return 5 best available time windows if (sub === 'suggest-slots' && method === 'POST') { try { const body = await parseBody(req) const slots = await suggestSlots(body) return json(res, 200, { slots }) } catch (e) { log('suggest-slots error:', e.message) return json(res, 500, { error: e.message }) } } // GET /dispatch/group-jobs?group=Tech+Targo&exclude_tech=TECH-001 // List unassigned jobs that a tech can self-claim. The tech PWA uses this // to render the "Tâches du groupe" subscription feed. // // Filters applied server-side: // - status in ['open', 'Scheduled'] (no 'On Hold' — those are chain-gated) // - assigned_tech empty/null // - optional: assigned_group == group (when provided) // - exclude current tech so the list only shows claimable work // Mint un numéro de job standardisé YYYYMMDD-XXX (le front crée directement dans ERPNext → il frappe le numéro ici d'abord). if (sub === 'next-ref' && method === 'GET') { return json(res, 200, { ref: await nextJobRef() }) } // Crée une chaîne d'installation (projet + sous-tâches) en un geste depuis un template. if (sub === 'create-install' && method === 'POST') { return json(res, 200, await createInstallChain((await parseBody(req)) || {})) } if (sub === 'group-jobs' && method === 'GET') { try { const params = require('url').parse(req.url, true).query const group = params.group || '' const filters = [ ['status', 'in', ['open', 'Scheduled']], // Frappe API requires `is not set` for empty-link queries (not = ''). ['assigned_tech', 'is', 'not set'], ] if (group) filters.push(['assigned_group', '=', group]) // `_name` fields (customer_name, service_location_name) are "fetched" // (fetch_from → Customer.customer_name) and Frappe blocks them from // list queries. Pull the base links here and enrich client-side via // a second batch query below. We also keep `scheduled_time` out of // the list query — it's not marked queryable on this doctype — and // re-add it per-job via the enrichment loop. const fields = ['name', 'subject', 'customer', 'service_location', 'scheduled_date', 'priority', 'assigned_group', 'job_type', 'duration_h', 'source_issue'] const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(fields), limit_page_length: '50', order_by: 'scheduled_date asc, modified desc', }) const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`) if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status, body: r.data?.exception?.slice(0, 200) }) const jobs = r.data?.data || [] // Enrich with customer_name + service_location_name so the tech PWA can // render address/customer without N extra round-trips. Parallel fetch // with a small cache in case multiple jobs share the same customer. const custNames = new Map() const locNames = new Map() await Promise.all(jobs.map(async j => { if (j.customer && !custNames.has(j.customer)) { try { const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(j.customer)}?fields=["customer_name"]`) if (cr.status === 200) custNames.set(j.customer, cr.data?.data?.customer_name || j.customer) } catch { custNames.set(j.customer, j.customer) } } if (j.service_location && !locNames.has(j.service_location)) { try { const lr = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(j.service_location)}?fields=["address_line_1","city"]`) if (lr.status === 200) { const d = lr.data?.data || {} locNames.set(j.service_location, [d.address_line_1, d.city].filter(Boolean).join(', ') || j.service_location) } } catch { locNames.set(j.service_location, j.service_location) } } })) for (const j of jobs) { j.customer_name = custNames.get(j.customer) || '' j.service_location_name = locNames.get(j.service_location) || '' } return json(res, 200, { jobs }) } catch (e) { log('group-jobs error:', e.message) return json(res, 500, { error: e.message }) } } // POST /dispatch/claim-job { job, tech_id } // Tech self-assignment. Accepts only jobs that are truly up for grabs // (status open/Scheduled + no assigned_tech). Idempotent per-tech: a tech // re-claiming a job they already own returns 200 (no-op). Another tech // trying to grab it returns 409 so the UI can refresh. if (sub === 'claim-job' && method === 'POST') { try { const body = await parseBody(req) if (!body.job || !body.tech_id) return json(res, 400, { error: 'job and tech_id required' }) const jobName = body.job const techId = body.tech_id const r = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`) if (r.status !== 200) return json(res, 404, { error: 'Job not found' }) const job = r.data.data if (job.assigned_tech && job.assigned_tech !== techId) { return json(res, 409, { error: 'Déjà pris par un autre technicien', assigned_to: job.assigned_tech }) } if (job.assigned_tech === techId) { return json(res, 200, { ok: true, job: jobName, tech: techId, note: 'already yours' }) } if (!['open', 'Scheduled'].includes(job.status)) { return json(res, 409, { error: `Statut "${job.status}" — ce travail n'est pas disponible` }) } const u = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`, { method: 'PUT', body: JSON.stringify({ assigned_tech: techId, status: 'assigned' }), }) if (u.status !== 200) return json(res, 500, { error: 'Update failed', erp: u.data }) // Broadcast so other techs' "Tâches du groupe" feeds refresh and the job // disappears from their claimable list. try { require('./sse').broadcast('dispatch', 'job-claimed', { job: jobName, tech: techId, subject: job.subject || '', }) } catch { /* sse best-effort */ } log(`[dispatch] ${techId} claimed ${jobName}`) return json(res, 200, { ok: true, job: jobName, tech: techId }) } catch (e) { log('claim-job error:', e.message) return json(res, 500, { error: e.message }) } } // POST /dispatch/create-job — create + optionally auto-assign to best tech if (sub === 'create-job' && method === 'POST') { try { const body = await parseBody(req) let assignedTech = body.assigned_tech if (body.auto_assign) { const dateStr = body.scheduled_date || todayET() const jobCoords = body.latitude && body.longitude ? [parseFloat(body.longitude), parseFloat(body.latitude)] : null const techs = await getTechsWithLoad(dateStr) await enrichWithGps(techs) const ranked = rankTechs(techs, jobCoords, parseFloat(body.duration_h) || 1) if (ranked.length) { assignedTech = ranked[0].techId log(`Auto-assigned to ${ranked[0].techName} (score: ${ranked[0].score.toFixed(0)}, ${ranked[0].reasons.join(', ')})`) } } const result = await createDispatchJob({ ...body, assigned_tech: assignedTech }) return json(res, 200, result) } catch (e) { log('create-job error:', e.message) return json(res, 500, { error: e.message }) } } json(res, 404, { error: 'Dispatch endpoint not found' }) } async function agentCreateDispatchJob ({ customer_id, service_location, subject, priority, job_type, notes, auto_assign }) { let address = '', latitude = null, longitude = null if (service_location) { const locRes = await erpFetch(`/api/resource/Service Location/${encodeURIComponent(service_location)}`) if (locRes.status === 200) { const loc = locRes.data.data address = [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ') latitude = loc.latitude || null longitude = loc.longitude || null } } const dateStr = todayET() let assignedTech = null, techInfo = null if (auto_assign !== false) { try { const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null const techs = await getTechsWithLoad(dateStr) await enrichWithGps(techs) const ranked = rankTechs(techs, jobCoords, 1) if (ranked.length) { assignedTech = ranked[0].techId techInfo = { name: ranked[0].techName, phone: ranked[0].phone, distance: ranked[0].distance, reasons: ranked[0].reasons } log(`Agent auto-assigned to ${ranked[0].techName}`) } } catch (e) { log('Agent auto-assign error:', e.message) } } const result = await createDispatchJob({ subject: subject || 'Intervention urgente', address, latitude, longitude, priority: priority || 'high', duration_h: 1, job_type: job_type || 'Dépannage', customer: customer_id || '', service_location: service_location || '', notes: notes || '', assigned_tech: assignedTech, scheduled_date: dateStr, }) return { success: true, job_id: result.job_id, assigned_tech: techInfo ? `${techInfo.name} (${techInfo.reasons.join(', ')})` : 'Aucun tech disponible — job créé en attente', address: result.address, message: techInfo ? `Travail créé et assigné à ${techInfo.name}` : 'Travail créé, en attente d\'assignation', } } module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }