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:
parent
349f9af2da
commit
64d5751149
|
|
@ -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(() => [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user