feat(ops/client): contract-aware sub delete with termination preview

The raw DELETE on Service Subscription was blowing up with
LinkExistsError because Service Contract.service_subscription still
referenced the sub. Worse: silently unlinking a live contract would
cost the business the break fee (résidentiel = avantages résiduels,
commercial = mensualités restantes).

Now when the user clicks 🗑 on a sub:

  1. loadServiceContracts pulls `service_subscription` so the client
     can spot the link without a round-trip.
  2. If a non-terminal contract is linked, the dialog upgrades to:
       • header: Contract name + type
       • term bar: start → end, months elapsed / months remaining
         (pulled live from /contract/calculate-termination)
       • penalty breakdown box: total fee, split into benefits to
         refund + remaining months, plus a warning that a termination
         invoice will be created
       • radio: "Désactiver seulement (conserver le contrat)" vs
         "Résilier + facturer X$ + supprimer"
     Suspend-only route goes through toggleSubStatus (no fee).
     Terminate route hits /contract/terminate (status→Résilié +
     invoice), then unlinks + deletes the sub, and drops an audit
     line referencing the generated invoice.
  3. If the linked contract is already Résilié/Complété we just scrub
     the stale link inline in the plain confirm path so the
     dispatcher isn't forced into the termination UI.
This commit is contained in:
louispaulb 2026-04-23 13:47:53 -04:00
parent 349f9af2da
commit 64d5751149
2 changed files with 156 additions and 9 deletions

View File

@ -187,11 +187,14 @@ export function useClientData (deps) {
// Service Contracts are the "offre de service" artifact — they carry
// monthly_rate, duration, and benefits (net promotions). Shown alongside
// Soumissions so the rep can retrieve the recap after publish.
// `service_subscription` is the Link field ERPNext uses for referential
// integrity; we need it client-side so delete-sub can detect a live
// contract and surface termination fees before breaking the link.
return listDocs('Service Contract', {
filters: { customer: id },
fields: ['name', 'contract_type', 'status', 'start_date', 'end_date',
'duration_months', 'monthly_rate', 'total_benefit_value',
'quotation', 'acceptance_method', 'signed_at'],
'quotation', 'acceptance_method', 'signed_at', 'service_subscription'],
limit: 50, orderBy: 'creation desc',
}).catch(() => [])
}

View File

@ -958,6 +958,8 @@ import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal'
@ -1214,13 +1216,159 @@ function onSubPriceSaved (sub, evt) {
}
}
// Delete a subscription row. Uses the Quasar Dialog for a confirm step
// because a click-through on a red bin would be too trigger-happy.
// Subscription delete with Service Contract awareness
// ERPNext blocks DELETE on a Service Subscription while any Service
// Contract.service_subscription still references it (LinkExistsError).
// When we detect a live contract we upgrade the dialog to:
// show contract type, term, months remaining
// preview the termination fee (résidentiel = avantages résiduels;
// commercial = mensualités restantes) via /contract/calculate-termination
// offer three outcomes: suspend-only, full termination (fires
// /contract/terminate + creates the invoice), or cancel.
// If the linked contract is already terminal (Résilié/Complété) we just
// scrub the link inline so a delete of a stale link doesn't explode.
const CONTRACT_TERMINAL = new Set(['Résilié', 'Complété'])
function findLinkedContract (sub) {
return (serviceContracts.value || []).find(c => c.service_subscription === sub.name) || null
}
async function unlinkContractFromSub (contract) {
await authFetch(`${BASE_URL}/api/resource/Service%20Contract/${encodeURIComponent(contract.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_subscription: '' }),
})
contract.service_subscription = ''
}
async function rawDeleteSub (sub, label) {
await deleteDoc('Service Subscription', sub.name)
const idx = subscriptions.value.findIndex(s => s.name === sub.name)
if (idx >= 0) subscriptions.value.splice(idx, 1)
if (sub.service_location) invalidateCache(sub.service_location)
invalidateAll()
Notify.create({ type: 'positive', message: `${label} supprimé`, position: 'top', timeout: 2500 })
}
async function fetchTerminationPreview (contractName) {
try {
const r = await fetch(`${HUB_URL}/contract/calculate-termination`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: contractName }),
})
if (!r.ok) return null
return await r.json()
} catch { return null }
}
async function terminateAndDelete (sub, contract, label, reason) {
try {
// 1) Hit /contract/terminate sets status=Résilié, fills termination
// fields, creates the penalty invoice (returned as termination_invoice).
const r = await fetch(`${HUB_URL}/contract/terminate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: contract.name, reason, create_invoice: true }),
})
if (!r.ok) {
const body = await r.json().catch(() => ({}))
throw new Error(body.error || 'Résiliation échouée')
}
const termResult = await r.json()
// 2) Scrub the link so the sub DELETE succeeds; contract is now Résilié
// so the ERPNext UI / reports still show the full audit trail.
await unlinkContractFromSub(contract)
contract.status = 'Résilié'
// 3) Delete the sub + log the termination on the customer timeline.
await rawDeleteSub(sub, label)
const fee = Number(termResult.termination_fee_total || 0)
const invoiceTag = termResult.termination_invoice
? ` — facture ${termResult.termination_invoice} (${formatMoney(fee)})`
: (fee > 0 ? ` — pénalité ${formatMoney(fee)}` : '')
logSubChange(sub, `Contrat ${contract.name} résilié${invoiceTag}`)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur résiliation: ' + (e.message || 'échec'), position: 'top', timeout: 5000 })
}
}
async function confirmDeleteSub (sub) {
const label = sub.plan_name || sub.item_name || sub.name
const contract = findLinkedContract(sub)
// Already-terminated contract still carries the link scrub it silently
// inside the normal confirm path so we don't make the dispatcher go
// re-open a résilié record just to remove a stale reference.
const activeContract = contract && !CONTRACT_TERMINAL.has(contract.status) ? contract : null
if (activeContract) {
// Contract-aware path: fetch the termination preview first so the fee
// is shown inside the dialog (not discovered post-click).
const calc = await fetchTerminationPreview(activeContract.name)
const monthsRemaining = calc?.months_remaining ?? activeContract.duration_months ?? 0
const monthsElapsed = calc?.months_elapsed ?? 0
const fee = Number(calc?.termination_fee_total || 0)
const feeBenefits = Number(calc?.termination_fee_benefits || 0)
const feeRemaining = Number(calc?.termination_fee_remaining || 0)
const endDate = activeContract.end_date ? formatDate(activeContract.end_date) : '—'
const typ = activeContract.contract_type || ''
const feeBreakdown = fee > 0
? `<div class="q-mt-sm q-pa-sm" style="background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
<div class="text-weight-bold text-negative">Pénalité de bris de contrat: ${formatMoney(fee)}</div>
${feeBenefits > 0 ? `<div class="text-caption text-grey-8">• Avantages à rembourser: ${formatMoney(feeBenefits)}</div>` : ''}
${feeRemaining > 0 ? `<div class="text-caption text-grey-8">• Mensualités restantes: ${formatMoney(feeRemaining)}</div>` : ''}
<div class="text-caption text-grey-7 q-mt-xs">Une facture de résiliation sera créée automatiquement.</div>
</div>`
: '<div class="text-caption text-positive q-mt-sm">✓ Aucune pénalité applicable.</div>'
$q.dialog({
title: `Contrat actif — ${activeContract.name}`,
message:
`<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)}/mois)</div>
<div class="q-mt-sm q-pa-sm" style="background:#f8fafc;border-radius:4px">
<div class="text-caption"><b>${typ}</b> du ${formatDate(activeContract.start_date)} au ${endDate}</div>
<div class="text-caption text-grey-7">${monthsElapsed} mois écoulés, <b>${monthsRemaining} mois restants</b></div>
</div>
${feeBreakdown}
<div class="text-caption q-mt-md">Action à effectuer :</div>`,
html: true,
options: {
type: 'radio',
model: 'suspend',
items: [
{ label: 'Désactiver le service (conserver le contrat)', value: 'suspend' },
{ label: fee > 0
? `Résilier le contrat + facturer ${formatMoney(fee)} + supprimer le service`
: 'Résilier le contrat et supprimer le service',
value: 'terminate' },
],
},
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', label: 'Valider', unelevated: true },
persistent: true,
}).onOk(async (choice) => {
if (choice === 'suspend') {
await toggleSubStatus(sub)
} else if (choice === 'terminate') {
await terminateAndDelete(sub, activeContract, label, 'Bris de contrat à la demande du client')
}
})
return
}
// No-contract path (or contract already résilié/complété): keep the
// existing two-button confirm, but scrub any stale link first.
$q.dialog({
title: 'Supprimer ce service ?',
message: `<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)})</div>`
+ (contract
? `<div class="text-caption text-grey-7 q-mt-sm">Le contrat <code>${contract.name}</code> (${contract.status}) sera délié.</div>`
: '')
+ '<div class="text-caption text-grey-6 q-mt-sm">L\'historique de facturation reste intact.</div>',
html: true,
cancel: { flat: true, label: 'Annuler' },
@ -1228,12 +1376,8 @@ async function confirmDeleteSub (sub) {
persistent: true,
}).onOk(async () => {
try {
await deleteDoc('Service Subscription', sub.name)
const idx = subscriptions.value.findIndex(s => s.name === sub.name)
if (idx >= 0) subscriptions.value.splice(idx, 1)
if (sub.service_location) invalidateCache(sub.service_location)
invalidateAll()
Notify.create({ type: 'positive', message: `${label} supprimé`, position: 'top', timeout: 2500 })
if (contract) await unlinkContractFromSub(contract)
await rawDeleteSub(sub, label)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Suppression impossible'), position: 'top', timeout: 4000 })
}