From 64d575114907be52d80a85d7fbfa3dae13fbff48 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 23 Apr 2026 13:47:53 -0400 Subject: [PATCH] feat(ops/client): contract-aware sub delete with termination preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/ops/src/composables/useClientData.js | 5 +- apps/ops/src/pages/ClientDetailPage.vue | 160 ++++++++++++++++++++-- 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/apps/ops/src/composables/useClientData.js b/apps/ops/src/composables/useClientData.js index cc09770..32100cb 100644 --- a/apps/ops/src/composables/useClientData.js +++ b/apps/ops/src/composables/useClientData.js @@ -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(() => []) } diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 54f23d3..709dce5 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -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 + ? `
+
Pénalité de bris de contrat: ${formatMoney(fee)}
+ ${feeBenefits > 0 ? `
• Avantages à rembourser: ${formatMoney(feeBenefits)}
` : ''} + ${feeRemaining > 0 ? `
• Mensualités restantes: ${formatMoney(feeRemaining)}
` : ''} +
Une facture de résiliation sera créée automatiquement.
+
` + : '
✓ Aucune pénalité applicable.
' + + $q.dialog({ + title: `Contrat actif — ${activeContract.name}`, + message: + `
${sub.name} — ${label} (${formatMoney(sub.actual_price)}/mois)
+
+
${typ} — du ${formatDate(activeContract.start_date)} au ${endDate}
+
${monthsElapsed} mois écoulés, ${monthsRemaining} mois restants
+
+ ${feeBreakdown} +
Action à effectuer :
`, + 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: `
${sub.name} — ${label} (${formatMoney(sub.actual_price)})
` + + (contract + ? `
Le contrat ${contract.name} (${contract.status}) sera délié.
` + : '') + '
L\'historique de facturation reste intact.
', 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 }) }