From ba4b5bae82a3814414f3818d8223416d731176c6 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 23 Apr 2026 10:19:56 -0400 Subject: [PATCH] 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 --- apps/ops/src/components/shared/TaskNode.vue | 28 +++- services/targo-hub/lib/contracts.js | 144 +++++++++++++++++++- services/targo-hub/lib/dispatch.js | 115 +++++++++++++++- 3 files changed, 280 insertions(+), 7 deletions(-) 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 }