gigafibre-fsm/services/targo-hub/lib/dispatch.js
louispaulb ba4b5bae82 fix(chain+subs): safe job-delete, plan_name from Quotation, bi-dir sub link
- contracts.js: _inferPlanName now reads the Quotation's first positive-rate
  item ("Internet Megafibre 80 Mbps") instead of generic fallback.
- contracts.js: subPayload writes service_contract back-ref so an active/
  pending sub blocks its parent contract's deletion (LinkExistsError).
- contracts.js: GET /contract/audit-orphans[?fix=1] scans for orphaned subs
  (dangling contract link or no link at all) and contracts without a sub;
  filters out 2026-03-29 legacy-migration batch via LEGACY_CUTOFF.
- dispatch.js: deleteJobSafely() rewires children's depends_on to the
  victim's parent, re-parents descendants if victim was chain root, then
  deletes. POST /dispatch/job-delete exposes it. Fixes LinkExistsError
  when users delete a middle step in the UI.
- TaskNode.vue: confirmDelete calls /dispatch/job-delete and surfaces a
  warning when dependents will be rewired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 10:19:56 -04:00

853 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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
}
async function createDispatchJob ({ subject, address, priority, duration_h, job_type, customer, service_location, source_issue, notes, latitude, longitude, assigned_tech, scheduled_date }) {
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase()
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, rewrite start_date to today, and emit a
// prorated Sales Invoice for the remaining days of the current month.
// Returns { activated: [...], invoices: [...] }.
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: [], invoices: [] }
const { customer, service_location } = jRes.data.data
if (!customer || !service_location) return { activated: [], invoices: [] }
const subFilter = encodeURIComponent(JSON.stringify([
['customer', '=', customer],
['service_location', '=', service_location],
['status', '=', 'En attente'],
]))
const subFields = encodeURIComponent(JSON.stringify(['name', 'monthly_price', 'plan_name', 'service_category']))
const subRes = await erpFetch(`/api/resource/Service%20Subscription?filters=${subFilter}&fields=${subFields}&limit_page_length=10`)
const pending = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : []
if (!pending.length) return { activated: [], invoices: [] }
const today = new Date()
const todayStr = today.toISOString().split('T')[0]
// Billing convention: activation day is free (courtesy), subscription
// start_date = tomorrow, prorata covers tomorrow → end-of-month inclusive.
// This matches the commercial policy ("dès demain, jusqu'à la fin du mois")
// and means when the normal monthly run hits the 1st, the customer pays
// a full month with no overlap.
const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
const daysLeft = daysInMonth - today.getDate() // from tomorrow → EOM inclusive
const endOfMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`
// Edge case: activation on the last day of the month → no prorata (daysLeft === 0)
// → we skip the invoice and the subscription simply starts billing next month.
const activated = []
const invoices = []
for (const sub of pending) {
// 1. Flip subscription to Actif + start_date=tomorrow (courtesy day)
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})`)
// 2. Prorated invoice for (daysLeft / daysInMonth) × monthly_price
// Covers tomorrow → EOM. Negative monthly_price values are valid
// (credit subscriptions like "Rabais à durée limitée") — we still
// emit the proportional credit invoice so the math balances.
const monthly = Number(sub.monthly_price || 0)
if (monthly === 0) continue
if (daysLeft <= 0) {
log(`${sub.name}: activation on last day — no prorata (next full month billed normally)`)
continue
}
const proratedAmount = Number(((monthly * daysLeft) / daysInMonth).toFixed(2))
if (proratedAmount === 0) continue
try {
// Resolve company + default income account. Sales Invoice items REQUIRE
// an income_account that belongs to the company; otherwise ERPNext 417s
// with "The Income Account None does not belong to the company TARGO".
const company = 'TARGO'
const incomeAccount = await _defaultIncomeAccount(company)
const invPayload = {
customer,
company,
posting_date: todayStr,
due_date: todayStr,
items: [{
item_name: `${sub.plan_name || 'Abonnement'} — prorata ${daysLeft}/${daysInMonth} jours`,
description: `Activation confirmée le ${todayStr}. Facturation du ${tomorrowStr} au ${endOfMonth} (${daysLeft}/${daysInMonth} jours). Service: ${sub.service_category || ''} · Tarif mensuel: ${monthly.toFixed(2)}$ · Prorata: ${proratedAmount.toFixed(2)}$`,
qty: 1,
rate: proratedAmount,
amount: proratedAmount,
income_account: incomeAccount,
}],
}
const inv = await erpFetch('/api/resource/Sales%20Invoice', {
method: 'POST', body: JSON.stringify(invPayload),
})
if (inv.status === 200 && inv.data?.data?.name) {
invoices.push({ name: inv.data.data.name, amount: proratedAmount, subscription: sub.name })
log(` ✓ Sales Invoice ${inv.data.data.name}${proratedAmount.toFixed(2)}$ prorata for ${sub.name}`)
require('./sse').broadcast('dispatch', 'subscription-activated', {
subscription: sub.name, invoice: inv.data.data.name, amount: proratedAmount, customer,
})
} else {
log(` ! invoice creation returned ${inv.status} for ${sub.name}`)
}
} catch (e) { log(` ! invoice creation failed for ${sub.name}: ${e.message}`) }
}
return { activated, invoices }
}
// Delete a Dispatch Job safely by rewiring its chain neighbours first.
//
// Problem: ERPNext throws LinkExistsError if any other Dispatch Job has
// `depends_on = <victim>` or `parent_job = <victim>`. 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)) {
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 })
}
}
// 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
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 }