feat(ops): gate ticket activation on pending subs + show consolidated proration preview

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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-10 16:24:27 -04:00
parent e1d3212bf7
commit 718698f0d0
2 changed files with 32 additions and 6 deletions

View File

@ -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`,

View File

@ -6,8 +6,8 @@
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
<!-- Fulfillment vente : complète l'installation liée → active l'abonnement + facture prorata BROUILLON (flux existant, gaté) -->
<q-btn v-if="saleJob" dense unelevated no-caps color="deep-purple-6" icon="paid" label="Activer + facture brouillon" :loading="activating" @click="activateSale">
<q-tooltip>Complète l'installation « {{ saleJob.subject || saleJob.name }} » → active l'abonnement en attente + crée une facture prorata (brouillon, à approuver)</q-tooltip>
<q-btn v-if="canActivate" dense unelevated no-caps color="deep-purple-6" icon="paid" :label="'Activer + facture brouillon' + (actPreview && actPreview.prorated_total != null ? ' (' + actPreview.prorated_total + '$)' : '')" :loading="activating" @click="activateSale">
<q-tooltip>{{ (actPreview.lines || []).length }} abonnement(s) en attente active + facture prorata BROUILLON (à approuver). Complète l'installation « {{ saleJob.subject || saleJob.name }} ».</q-tooltip>
</q-btn>
</div>
@ -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('<br>')
Dialog.create({
title: 'Activer la vente',
message: `Compléter l'installation « ${j.subject || j.name} » et activer l'abonnement en attente ?<br><br>Une <b>facture prorata BROUILLON</b> 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 <b>${(p.lines || []).length} service(s)</b> en attente ?<br><br>${lines}<br><br><b>Facture prorata consolidée</b>${p.prorated_total != null ? ' ≈ ' + p.prorated_total + '$' : ''}${p.billed_days ? ' (' + p.billed_days + '/' + p.period_days + ' j)' : ''} → créée en <b>BROUILLON</b> (à é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 }
})
}