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>
This commit is contained in:
louispaulb 2026-04-23 10:19:56 -04:00
parent 2aee8f31df
commit ba4b5bae82
3 changed files with 280 additions and 7 deletions

View File

@ -133,6 +133,7 @@ import { Notify, Dialog } from 'quasar'
import { updateJob } from 'src/api/dispatch' import { updateJob } from 'src/api/dispatch'
import { deleteDoc, listDocs } from 'src/api/erp' import { deleteDoc, listDocs } from 'src/api/erp'
import { fetchTags, fetchTechnicians } from 'src/api/dispatch' import { fetchTags, fetchTechnicians } from 'src/api/dispatch'
import { HUB_URL } from 'src/config/hub'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const props = defineProps({ const props = defineProps({
@ -308,17 +309,38 @@ async function assignTech (techId) {
} }
// Delete // 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 () { 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
? ` <br><span style="color:#f59e0b;font-size:0.85em;">⚠ ${children.length} étape(s) dépendent de celle-ci — elles seront reliées à l'étape précédente.</span>`
: ''
Dialog.create({ Dialog.create({
title: 'Supprimer cette tâche ?', 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' }, cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer', unelevated: true }, ok: { color: 'red', label: 'Supprimer', unelevated: true },
persistent: true, persistent: true,
}).onOk(async () => { }).onOk(async () => {
try { try {
await deleteDoc('Dispatch Job', props.job.name) const res = await fetch(`${HUB_URL}/dispatch/job-delete`, {
Notify.create({ type: 'positive', message: 'Tâche supprimée', timeout: 2000 }) 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) emit('deleted', props.job.name)
} catch (err) { } catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` }) Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })

View File

@ -483,9 +483,130 @@ async function handle (req, res, method, path) {
return json(res, 200, { ok: true, contract: contractName }) 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' }) 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 // Built-in install chain — fallback when no Flow Template handles the signing
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -615,9 +736,10 @@ async function _createBuiltInInstallChain (contractName, payload) {
customer, customer,
customer_name: customerName, customer_name: customerName,
service_location: serviceLocation, service_location: serviceLocation,
service_contract: contractName, // reverse link — lets us detect orphans
status: 'En attente', status: 'En attente',
service_category: _inferServiceCategory(contract), service_category: _inferServiceCategory(contract),
plan_name: _inferPlanName(contract), plan_name: await _inferPlanName(contract),
monthly_price: Number(contract.monthly_rate), monthly_price: Number(contract.monthly_rate),
billing_cycle: 'Mensuel', billing_cycle: 'Mensuel',
contract_duration: Number(contract.duration_months || 0), contract_duration: Number(contract.duration_months || 0),
@ -662,10 +784,26 @@ function _inferServiceCategory (contract) {
return 'Internet' return 'Internet'
} }
function _inferPlanName (contract) { async function _inferPlanName (contract) {
// Prefer an explicit plan if present on a linked quotation/benefit row. // 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] const firstBenefit = (contract.benefits || [])[0]
if (firstBenefit?.description) return String(firstBenefit.description).slice(0, 140) 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` return `${contract.contract_type || 'Service'} ${contract.monthly_rate}$/mois`
} }

View File

@ -467,6 +467,103 @@ async function activateSubscriptionForJob (jobName) {
return { activated, invoices } 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 // Thin wrapper that PUTs the status + runs unblock logic. Used by
// tech-mobile (token auth) and the ops SPA (session auth) through a single // 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. // 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 // POST /dispatch/best-tech — find optimal tech for a job location
if (sub === 'best-tech' && method === 'POST') { if (sub === 'best-tech' && method === 'POST') {
try { 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 }