From 718698f0d0ef359908811e0f4e073fb93f731d26 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 10 Jul 2026 16:24:27 -0400 Subject: [PATCH] feat(ops): gate ticket activation on pending subs + show consolidated proration preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Activer + facture brouillon" button now appears ONLY when the ticket's customer+location has En attente subscriptions (read-only /billing/activation- preview) — defining which completed tickets trigger activation. The confirm shows all services that would activate and the consolidated prorated total (internet+tv+phone in one draft invoice). Preview writes nothing; commit gated by PRORATION_WRITE. Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/api/dispatch.js | 12 +++++++++ .../shared/detail-sections/IssueDetail.vue | 26 ++++++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/ops/src/api/dispatch.js b/apps/ops/src/api/dispatch.js index 3a24bfb..fdc9d1c 100644 --- a/apps/ops/src/api/dispatch.js +++ b/apps/ops/src/api/dispatch.js @@ -110,6 +110,18 @@ export async function setJobStatusChain (job, status) { return data } +// Aperçu d'activation (LECTURE SEULE) : abonnements « En attente » du client+lieu + facture prorata consolidée +// (TOUS services : internet+tv+téléphone en 1 facture). Sert à SAVOIR si une complétion activerait qqch + montre le total. +export async function activationPreview ({ customer, service_location }) { + const res = await fetch(`${HUB_URL}/billing/activation-preview`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customer, service_location, statuses: ['En attente'] }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || ('Billing API ' + res.status)) + return data +} + export async function createJob (payload) { const res = await authFetch( `${BASE_URL}/api/resource/Dispatch%20Job`, diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue index f972407..82a5bdd 100644 --- a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue @@ -6,8 +6,8 @@ - - Complète l'installation « {{ saleJob.subject || saleJob.name }} » → active l'abonnement en attente + crée une facture prorata (brouillon, à approuver) + + {{ (actPreview.lines || []).length }} abonnement(s) en attente → active + facture prorata BROUILLON (à approuver). Complète l'installation « {{ saleJob.subject || saleJob.name }} ». @@ -343,7 +343,7 @@ import UserAvatar from 'src/components/shared/UserAvatar.vue' import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré) import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet import TicketStatusMenu from 'src/components/shared/TicketStatusMenu.vue' // bouton « Statut » (Complété/Annulé) — distinct du snooze « Reporter » -import { setJobStatusChain } from 'src/api/dispatch' // complète le job lié → active abonnement + facture brouillon (flux existant, gaté) +import { setJobStatusChain, activationPreview } from 'src/api/dispatch' // complète le job lié → active abonnement + facture brouillon (flux existant, gaté) + aperçu lecture seule // Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) — montés ici pour la tâche dispatch principale du ticket. import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue' import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue' @@ -625,6 +625,18 @@ const primaryJob = computed(() => { const activating = ref(false) // Tâche liée encore ouverte (installation à compléter) → cible du bouton « Activer + facture brouillon ». const saleJob = computed(() => (props.dispatchJobs || []).find(j => !['Completed', 'Cancelled'].includes(j.status)) || null) +// Aperçu d'activation (lecture seule) : abonnements EN ATTENTE du client+lieu + total prorata consolidé (tous services). +// C'est LUI qui définit « quel ticket déclenche une activation » : on n'affiche le bouton que s'il y a du EN ATTENTE. +const actPreview = ref(null) +async function loadActivationPreview () { + const cust = props.doc?.customer; const loc = props.doc?.service_location + if (!cust) { actPreview.value = null; return } + try { const p = await activationPreview({ customer: cust, service_location: loc }); actPreview.value = (p && (p.lines || []).length) ? p : null } + catch (e) { actPreview.value = null } +} +onMounted(loadActivationPreview) +// Le bouton d'activation n'apparaît QUE si (installation liée ouverte) ET (abonnements en attente à activer). +const canActivate = computed(() => !!(saleJob.value && actPreview.value)) async function onSetStatus (status) { try { await updateDoc('Issue', props.docName, { status }) @@ -632,16 +644,18 @@ async function onSetStatus (status) { Notify.create({ type: 'positive', message: 'Statut : ' + status, timeout: 1400 }) emit('reply-sent', props.docName) // « Complété » sur une vente avec une installation à compléter → propose l'activation (pas d'auto : l'agent confirme). - if (status === 'Resolved' && saleJob.value) setTimeout(activateSale, 300) + if (status === 'Resolved' && canActivate.value) setTimeout(activateSale, 300) } catch (e) { Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e) }) } } // Réutilise le flux hub EXISTANT : compléter le job lié → déblocage chaîne + activation abonnement + facture prorata BROUILLON // (gaté PRORATION_WRITE / BILLING_APPROVAL_GATE). N'AUTO-FACTURE jamais : draft à approuver par la facturation. function activateSale () { const j = saleJob.value; if (!j || activating.value) return + const p = actPreview.value || {} + const lines = (p.lines || []).map(l => `• ${l.label}${l.amount != null ? ' — ' + l.amount + '$' : ''}`).join('
') Dialog.create({ title: 'Activer la vente', - message: `Compléter l'installation « ${j.subject || j.name} » et activer l'abonnement en attente ?

Une facture prorata BROUILLON sera créée (à éditer/approuver par la facturation) si la facturation est armée — sinon l'abonnement est activé sans facture.`, + message: `Compléter l'installation « ${j.subject || j.name} » et activer ${(p.lines || []).length} service(s) en attente ?

${lines}

Facture prorata consolidée${p.prorated_total != null ? ' ≈ ' + p.prorated_total + '$' : ''}${p.billed_days ? ' (' + p.billed_days + '/' + p.period_days + ' j)' : ''} → créée en BROUILLON (à éditer/approuver par la facturation) si la facturation est armée ; sinon activation sans facture.`, html: true, cancel: 'Annuler', ok: { label: 'Activer', color: 'deep-purple-6' }, }).onOk(async () => { activating.value = true @@ -654,7 +668,7 @@ function activateSale () { else if (acts) msg = acts + ' abonnement(s) activé(s) · facture non émise (écriture désarmée)' else msg = 'Installation complétée — aucun abonnement en attente à activer' Notify.create({ type: 'positive', message: msg, timeout: 6000 }) - j.status = 'Completed'; emit('dispatch-updated', j.name) + j.status = 'Completed'; actPreview.value = null; emit('dispatch-updated', j.name) } catch (e) { Notify.create({ type: 'negative', message: 'Activation : ' + (e.message || e), timeout: 5000 }) } finally { activating.value = false } }) }