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:
parent
2aee8f31df
commit
ba4b5bae82
|
|
@ -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
|
||||
? ` <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({
|
||||
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}` })
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = <victim>` or `parent_job = <victim>`. The UI's generic
|
||||
// "Supprimer cette tâche" button hits this whenever the user tries to
|
||||
// drop a middle step from a chain.
|
||||
//
|
||||
// Strategy: before DELETE, repoint the chain so the victim is isolated:
|
||||
// 1. Children (depends_on = victim) → depends_on = victim.depends_on
|
||||
// (skips the victim in the chain; the successor's status recomputes
|
||||
// so it unblocks if it was On Hold and its new parent is Completed
|
||||
// or empty).
|
||||
// 2. Descendants (parent_job = victim, only fires when victim was the
|
||||
// chain root) → parent_job = new root. The new root is chosen as the
|
||||
// lowest-step_order former child (now a chain root itself).
|
||||
// 3. Finally DELETE the victim — should succeed since no FK references it.
|
||||
//
|
||||
// Returns { ok, deleted, rewired: [...], note }.
|
||||
async function deleteJobSafely (jobName) {
|
||||
if (!jobName) throw new Error('jobName required')
|
||||
|
||||
const vRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`)
|
||||
if (vRes.status !== 200 || !vRes.data?.data) throw new Error(`job ${jobName} not found`)
|
||||
const victim = vRes.data.data
|
||||
const victimDependsOn = victim.depends_on || ''
|
||||
const victimParentJob = victim.parent_job || ''
|
||||
|
||||
// 1. Immediate successors in the chain
|
||||
const childFilters = encodeURIComponent(JSON.stringify([['depends_on', '=', jobName]]))
|
||||
const childFields = encodeURIComponent(JSON.stringify(['name', 'status', 'step_order']))
|
||||
const cRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilters}&fields=${childFields}&limit_page_length=50`)
|
||||
const children = (cRes.status === 200 && Array.isArray(cRes.data?.data)) ? cRes.data.data : []
|
||||
|
||||
const rewired = []
|
||||
for (const child of children) {
|
||||
const patch = { depends_on: victimDependsOn }
|
||||
// Unblock if the new parent is empty or already Completed — otherwise the
|
||||
// child stays On Hold waiting on the new parent.
|
||||
if (child.status === 'On Hold') {
|
||||
let newParentDone = !victimDependsOn // no parent → chain root → unblock
|
||||
if (victimDependsOn) {
|
||||
const pRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(victimDependsOn)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`)
|
||||
const pStatus = pRes.data?.data?.status
|
||||
if (pStatus === 'Completed' || pStatus === 'done' || pStatus === 'Cancelled') newParentDone = true
|
||||
}
|
||||
if (newParentDone) patch.status = 'open'
|
||||
}
|
||||
const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(child.name)}`, {
|
||||
method: 'PUT', body: JSON.stringify(patch),
|
||||
})
|
||||
if (up.status < 400) {
|
||||
rewired.push({ name: child.name, depends_on: victimDependsOn, status: patch.status })
|
||||
log(` ↻ ${child.name}: depends_on=${jobName} → ${victimDependsOn || '(none)'}${patch.status ? ` + status→${patch.status}` : ''}`)
|
||||
} else {
|
||||
log(` ! rewire failed for ${child.name}: ${up.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Was the victim a chain root? If so, any job with parent_job=victim
|
||||
// needs a new root. Pick the former lowest-step child (now the new head).
|
||||
if (!victimParentJob) {
|
||||
const descFilters = encodeURIComponent(JSON.stringify([['parent_job', '=', jobName]]))
|
||||
const descFields = encodeURIComponent(JSON.stringify(['name']))
|
||||
const dRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${descFilters}&fields=${descFields}&limit_page_length=100`)
|
||||
const descendants = (dRes.status === 200 && Array.isArray(dRes.data?.data)) ? dRes.data.data : []
|
||||
if (descendants.length) {
|
||||
// Former children became roots; pick the lowest-step one as the new
|
||||
// canonical root (so a chain with fan-out survives as a single chain).
|
||||
const sortedKids = [...children].sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
|
||||
const newRoot = (sortedKids[0] && sortedKids[0].name) || ''
|
||||
for (const d of descendants) {
|
||||
if (d.name === newRoot) continue // the new root has parent_job='' (done above via children loop when we rewired depends_on; parent_job still references victim though — set to '')
|
||||
const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(d.name)}`, {
|
||||
method: 'PUT', body: JSON.stringify({ parent_job: newRoot || '' }),
|
||||
})
|
||||
if (up.status < 400) log(` ↻ ${d.name}: parent_job=${jobName} → ${newRoot || '(none)'}`)
|
||||
}
|
||||
// And blank the new root's parent_job (it's the root now)
|
||||
if (newRoot) {
|
||||
await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(newRoot)}`, {
|
||||
method: 'PUT', body: JSON.stringify({ parent_job: '' }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the victim
|
||||
const dRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, { method: 'DELETE' })
|
||||
if (dRes.status >= 400) {
|
||||
const msg = dRes.data?.exception || dRes.data?._error_message || `HTTP ${dRes.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
require('./sse').broadcast('dispatch', 'job-deleted', { job: jobName, rewired: rewired.map(r => r.name) })
|
||||
log(` ✗ deleted ${jobName} (rewired ${rewired.length} child${rewired.length === 1 ? '' : 'ren'})`)
|
||||
return { ok: true, deleted: jobName, rewired, note: children.length ? `Rewired ${children.length} dependent job(s) before deletion.` : 'No dependents — clean delete.' }
|
||||
}
|
||||
|
||||
// Thin wrapper that PUTs the status + runs unblock logic. Used by
|
||||
// tech-mobile (token auth) and the ops SPA (session auth) through a single
|
||||
// code path — status changes always walk the chain, no matter who wrote them.
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user