- contracts.js: built-in install chain fallback when no Flow Template matches on_contract_signed — every accepted contract now creates a master Issue + chained Dispatch Jobs (fiber_install template) so we never lose a signed contract to a missing flow config. - acceptance.js: export createDeferredJobs + propagate assigned_group into Dispatch Job payload (was only in notes, not queryable). - dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal, setJobStatusWithChain) + terminal-node detection that activates pending Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing convention: activation day is free, first period starts next day. - dispatch.js: fix Sales Invoice 417 by resolving company default income account (Ventes - T) and passing company + income_account on each item. - dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech self-assignment from the group queue; enriches with customer_name / service_location via per-job fetches since those fetch_from fields aren't queryable in list API. - TechTasksPage.vue: redesigned mobile-first UI with progress arc, status chips, and new "Tâches du groupe" section showing claimable unassigned jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked. - NetworkPage.vue + poller-control.js: poller toggle semantics flipped — green when enabled, red/gray when paused; explicit status chips for clarity. E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress → Completed walks chain → SUB-0000100002 activated (start=2026-04-24) → SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
740 lines
33 KiB
JavaScript
740 lines
33 KiB
JavaScript
'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 }
|
||
}
|
||
|
||
// 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/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 }
|