diff --git a/apps/ops/src/components/shared/TaskNode.vue b/apps/ops/src/components/shared/TaskNode.vue
index d039a6e..6c3a65f 100644
--- a/apps/ops/src/components/shared/TaskNode.vue
+++ b/apps/ops/src/components/shared/TaskNode.vue
@@ -133,6 +133,7 @@ import { Notify, Dialog } from 'quasar'
import { updateJob } from 'src/api/dispatch'
import { deleteDoc, listDocs } from 'src/api/erp'
import { fetchTags, fetchTechnicians } from 'src/api/dispatch'
+import { HUB_URL } from 'src/config/hub'
import { useRouter } from 'vue-router'
const props = defineProps({
@@ -308,17 +309,38 @@ async function assignTech (techId) {
}
// ── Delete ──
+// Use the targo-hub /dispatch/job-delete endpoint instead of the raw ERPNext
+// DELETE. The endpoint rewires any child job's `depends_on` (and re-parents
+// descendants if the victim was a chain root) before deleting, so the chain
+// stays intact and ERPNext's LinkExistsError doesn't fire.
function confirmDelete () {
+ // Warn the user when the task has dependents — the chain will be rewired
+ // (successors move up to the victim's parent) rather than broken.
+ const children = props.allJobs.filter(j => j.depends_on === props.job.name)
+ const warning = children.length
+ ? `
⚠ ${children.length} étape(s) dépendent de celle-ci — elles seront reliées à l'étape précédente.`
+ : ''
Dialog.create({
title: 'Supprimer cette tâche ?',
- message: `${props.job.name}: ${props.job.subject}`,
+ message: `${props.job.name}: ${props.job.subject}${warning}`,
+ html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer', unelevated: true },
persistent: true,
}).onOk(async () => {
try {
- await deleteDoc('Dispatch Job', props.job.name)
- Notify.create({ type: 'positive', message: 'Tâche supprimée', timeout: 2000 })
+ const res = await fetch(`${HUB_URL}/dispatch/job-delete`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ job: props.job.name }),
+ })
+ const body = await res.json().catch(() => ({}))
+ if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`)
+ const msg = body.rewired && body.rewired.length
+ ? `Tâche supprimée (${body.rewired.length} dépendance${body.rewired.length > 1 ? 's' : ''} recâblée${body.rewired.length > 1 ? 's' : ''})`
+ : 'Tâche supprimée'
+ Notify.create({ type: 'positive', message: msg, timeout: 2500 })
emit('deleted', props.job.name)
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
diff --git a/services/targo-hub/lib/contracts.js b/services/targo-hub/lib/contracts.js
index 483466c..c19cec7 100644
--- a/services/targo-hub/lib/contracts.js
+++ b/services/targo-hub/lib/contracts.js
@@ -483,9 +483,130 @@ async function handle (req, res, method, path) {
return json(res, 200, { ok: true, contract: contractName })
}
+ // GET /contract/audit-orphans[?fix=1]
+ // Audits the contract ↔ subscription integrity. Reports:
+ // - subs_without_contract: Service Subscriptions where service_contract is empty
+ // - subs_dangling_contract: service_contract is set but the contract is gone
+ // - contracts_without_sub: Service Contract is Actif but has no service_subscription
+ // When ?fix=1:
+ // - subs_without_contract → try to repair via customer + service_location match
+ // against a signed Service Contract; set service_contract on the sub and
+ // service_subscription on the contract if both sides are empty.
+ // - subs_dangling_contract → mark sub status='Annulé' + append an orphan note.
+ // - contracts_without_sub → no auto-fix (needs human review; report only).
+ if (path === '/contract/audit-orphans' && method === 'GET') {
+ const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
+ const fix = url.searchParams.get('fix') === '1'
+ return json(res, 200, await _auditOrphans(fix))
+ }
+
return json(res, 404, { error: 'Contract endpoint not found' })
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Subscription orphan audit
+// ─────────────────────────────────────────────────────────────────────────────
+// The contract → sub relationship is bi-directional:
+// Service Contract.service_subscription (custom Link)
+// Service Subscription.service_contract (custom Link)
+// Both are populated at signing (contracts.js:_createBuiltInInstallChain).
+// This audit walks both directions and reports any asymmetry.
+async function _auditOrphans (applyFixes = false) {
+ const report = {
+ checked_at: new Date().toISOString(),
+ subs_without_contract: [], // sub has no service_contract link (post-legacy)
+ subs_dangling_contract: [], // service_contract points at a deleted contract
+ contracts_without_sub: [], // contract is Actif but has no sub linked
+ legacy_subs_count: 0, // pre-contract-system migration — normal, not drift
+ fixed: [], // actions taken when applyFixes=true
+ }
+
+ // Legacy-migration cutoff: subs created before this timestamp came from the
+ // legacy PHP/MariaDB import (batch-inserted 2026-03-29 19:38:51). They're
+ // real customer service records that pre-date the contract system, so
+ // "service_contract = empty" is expected and not a drift signal.
+ const LEGACY_CUTOFF = '2026-03-30T00:00:00Z'
+
+ // 1. All Service Subscriptions (excluding Annulé — those are already dead).
+ // Pull in batches to avoid the 500-row cap.
+ const subs = []
+ let start = 0
+ for (let page = 0; page < 20; page++) { // cap at 10k rows defensively
+ const rows = await erp.list('Service Subscription', {
+ filters: [['status', '!=', 'Annulé']],
+ fields: ['name', 'customer', 'service_location', 'service_contract', 'status', 'plan_name', 'creation'],
+ limit: 500, start,
+ })
+ subs.push(...rows)
+ if (rows.length < 500) break
+ start += 500
+ }
+
+ // 2. Resolve which contracts actually exist (for the dangling-link check)
+ const suspectContractNames = [...new Set(subs.map(s => s.service_contract).filter(Boolean))]
+ const contractExists = {}
+ for (const cn of suspectContractNames) {
+ const c = await erp.get('Service Contract', cn)
+ contractExists[cn] = !!c
+ }
+
+ // 3. Bucket the subs, skipping legacy-migration rows from the "orphan" buckets
+ for (const sub of subs) {
+ const isLegacy = new Date(sub.creation) < new Date(LEGACY_CUTOFF)
+ if (!sub.service_contract) {
+ if (isLegacy) { report.legacy_subs_count++; continue }
+ report.subs_without_contract.push({ name: sub.name, customer: sub.customer, service_location: sub.service_location, status: sub.status, created: sub.creation })
+ } else if (!contractExists[sub.service_contract]) {
+ report.subs_dangling_contract.push({ name: sub.name, service_contract: sub.service_contract, customer: sub.customer, status: sub.status })
+ }
+ }
+
+ // 3. Contracts that should have a sub but don't (Actif status, has monthly_rate > 0)
+ const contracts = await erp.list('Service Contract', {
+ filters: [['status', '=', 'Actif']],
+ fields: ['name', 'customer', 'service_location', 'service_subscription', 'monthly_rate'],
+ limit: 500,
+ })
+ for (const c of contracts) {
+ if (Number(c.monthly_rate) === 0) continue // no sub needed
+ if (!c.service_subscription) {
+ report.contracts_without_sub.push({ name: c.name, customer: c.customer, service_location: c.service_location, monthly_rate: c.monthly_rate })
+ }
+ }
+
+ // 4. Apply fixes
+ if (applyFixes) {
+ // 4a. subs_without_contract: try to recover from customer+service_location
+ for (const row of report.subs_without_contract) {
+ const match = contracts.find(c => c.customer === row.customer && c.service_location === row.service_location && !c.service_subscription)
+ if (!match) continue
+ await erp.update('Service Subscription', row.name, { service_contract: match.name })
+ await erp.update('Service Contract', match.name, { service_subscription: row.name })
+ report.fixed.push({ action: 'linked', sub: row.name, contract: match.name })
+ // Prevent reusing this contract for another sub
+ match.service_subscription = row.name
+ }
+ // 4b. subs_dangling_contract: contract is gone → mark sub Annulé
+ for (const row of report.subs_dangling_contract) {
+ await erp.update('Service Subscription', row.name, {
+ status: 'Annulé',
+ cancellation_date: new Date().toISOString().slice(0, 10),
+ cancellation_reason: `Contrat lié ${row.service_contract} supprimé — abonnement marqué orphelin par audit du ${new Date().toISOString().slice(0, 10)}.`,
+ })
+ report.fixed.push({ action: 'cancelled_orphan', sub: row.name, dead_contract: row.service_contract })
+ }
+ }
+
+ report.summary = {
+ subs_without_contract: report.subs_without_contract.length,
+ subs_dangling_contract: report.subs_dangling_contract.length,
+ contracts_without_sub: report.contracts_without_sub.length,
+ legacy_subs: report.legacy_subs_count,
+ fixed: report.fixed.length,
+ }
+ return report
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Built-in install chain — fallback when no Flow Template handles the signing
// ─────────────────────────────────────────────────────────────────────────────
@@ -615,9 +736,10 @@ async function _createBuiltInInstallChain (contractName, payload) {
customer,
customer_name: customerName,
service_location: serviceLocation,
+ service_contract: contractName, // reverse link — lets us detect orphans
status: 'En attente',
service_category: _inferServiceCategory(contract),
- plan_name: _inferPlanName(contract),
+ plan_name: await _inferPlanName(contract),
monthly_price: Number(contract.monthly_rate),
billing_cycle: 'Mensuel',
contract_duration: Number(contract.duration_months || 0),
@@ -662,10 +784,26 @@ function _inferServiceCategory (contract) {
return 'Internet'
}
-function _inferPlanName (contract) {
- // Prefer an explicit plan if present on a linked quotation/benefit row.
+async function _inferPlanName (contract) {
+ // Priority 1: the source Quotation's first positive-rate item. That's
+ // where the actual product lives ("Internet Megafibre 80 Mbps", etc.) —
+ // what the customer signed for, what we want on the invoice line.
+ if (contract.quotation) {
+ try {
+ const q = await erp.get('Quotation', contract.quotation)
+ const items = q && Array.isArray(q.items) ? q.items : []
+ // First item with rate > 0 is the service (skip negative-rate rabais rows)
+ const plan = items.find(i => Number(i.rate) > 0)
+ if (plan && (plan.item_name || plan.item_code)) {
+ return String(plan.item_name || plan.item_code).slice(0, 140)
+ }
+ } catch { /* fall through to benefit / generic */ }
+ }
+ // Priority 2: a benefit row description (legacy contracts without a
+ // quotation link sometimes captured the plan there).
const firstBenefit = (contract.benefits || [])[0]
if (firstBenefit?.description) return String(firstBenefit.description).slice(0, 140)
+ // Last resort: build a generic label from the contract type + rate.
return `${contract.contract_type || 'Service'} ${contract.monthly_rate}$/mois`
}
diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js
index 98c8cd6..c1fc1cd 100644
--- a/services/targo-hub/lib/dispatch.js
+++ b/services/targo-hub/lib/dispatch.js
@@ -467,6 +467,103 @@ async function activateSubscriptionForJob (jobName) {
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 = ` 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.
@@ -510,6 +607,22 @@ async function handle (req, res, method, path) {
}
}
+ // 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 {
@@ -736,4 +849,4 @@ async function agentCreateDispatchJob ({ customer_id, service_location, subject,
}
}
-module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob }
+module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely }