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
|
// Service Contracts are the "offre de service" artifact — they carry
|
||||||
// monthly_rate, duration, and benefits (net promotions). Shown alongside
|
// monthly_rate, duration, and benefits (net promotions). Shown alongside
|
||||||
// Soumissions so the rep can retrieve the recap after publish.
|
// 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', {
|
return listDocs('Service Contract', {
|
||||||
filters: { customer: id },
|
filters: { customer: id },
|
||||||
fields: ['name', 'contract_type', 'status', 'start_date', 'end_date',
|
fields: ['name', 'contract_type', 'status', 'start_date', 'end_date',
|
||||||
'duration_months', 'monthly_rate', 'total_benefit_value',
|
'duration_months', 'monthly_rate', 'total_benefit_value',
|
||||||
'quotation', 'acceptance_method', 'signed_at'],
|
'quotation', 'acceptance_method', 'signed_at', 'service_subscription'],
|
||||||
limit: 50, orderBy: 'creation desc',
|
limit: 50, orderBy: 'creation desc',
|
||||||
}).catch(() => [])
|
}).catch(() => [])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -958,6 +958,8 @@ import { Notify, useQuasar } from 'quasar'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
|
import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
|
||||||
import { authFetch } from 'src/api/auth'
|
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 { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||||
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
|
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
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
|
// ─── Subscription delete with Service Contract awareness ─────────────
|
||||||
// because a click-through on a red bin would be too trigger-happy.
|
// 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) {
|
async function confirmDeleteSub (sub) {
|
||||||
const label = sub.plan_name || sub.item_name || sub.name
|
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({
|
$q.dialog({
|
||||||
title: 'Supprimer ce service ?',
|
title: 'Supprimer ce service ?',
|
||||||
message: `<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)})</div>`
|
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>',
|
+ '<div class="text-caption text-grey-6 q-mt-sm">L\'historique de facturation reste intact.</div>',
|
||||||
html: true,
|
html: true,
|
||||||
cancel: { flat: true, label: 'Annuler' },
|
cancel: { flat: true, label: 'Annuler' },
|
||||||
|
|
@ -1228,12 +1376,8 @@ async function confirmDeleteSub (sub) {
|
||||||
persistent: true,
|
persistent: true,
|
||||||
}).onOk(async () => {
|
}).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
await deleteDoc('Service Subscription', sub.name)
|
if (contract) await unlinkContractFromSub(contract)
|
||||||
const idx = subscriptions.value.findIndex(s => s.name === sub.name)
|
await rawDeleteSub(sub, label)
|
||||||
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 })
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Suppression impossible'), position: 'top', timeout: 4000 })
|
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Suppression impossible'), position: 'top', timeout: 4000 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user