gigafibre-fsm/apps/ops/src/composables/useSubscriptionGroups.js
louispaulb 1186e50bbe fix(ops/client): cancelled subs no longer inflate monthly total + Lieu link in-app
Three connected dispatcher-facing issues from C-LPB4 audit:

1. **Monthly total was wrong on customer cards.** Section subtotal and
   `locSubsMonthlyTotal` summed `actual_price` for ALL subscriptions
   regardless of status, so cancelled rows (rendered with strikethrough)
   still pumped up the displayed billing figure. C-LPB4 showed
   "Total mensuel: 86,10$" computed as `196.05 - 109.95 = 86.10`,
   where 196.05 included 3 cancelled internet plans (Megafibre 80,
   TEST-E2E-FTTH, FTTH100 — all struck through in the UI). Real
   active monthly is 5.00$ (109.95 active + 5 frais réseau − 109.95
   loyalty rebate). Fixed both `sectionTotal` and `locSubsMonthlyTotal`
   /`locSubsAnnualTotal` to filter on `status === 'Active'`.

2. **"Lieu" link from a dispatch task pointed to ERPNext desk** which
   shows a raw doctype form (no abonnements, no totals, no contacts —
   just the bare fields). Now points in-app to
   `#/clients/<customer>?location=<SL>`. ClientDetailPage reads the
   query string on mount and:
     • scrolls the matching `loc-card` into view
     • pulses an indigo halo around it for ~2s so the rep finds it
       immediately even when the customer has many service locations.

3. **The shipping/billing distinction was invisible** on the customer
   page. Added an "Adresses de livraison" badge next to the "Lieux de
   service" section title — clarifies that this section IS the
   shipping address, distinct from the (future) billing address that
   will live on the Customer record. Cosmetic for now; the data
   migration to formalize that distinction is the next step.

These three round out the C-LPB4 audit triggered by the wrong
mapbox-pin location: now the customer card on the dispatcher's
screen shows correct totals, the dispatch link drops them right at
the spot they're trying to reach, and the role of each address-bearing
record is named explicitly.
2026-05-08 11:21:18 -04:00

186 lines
7.0 KiB
JavaScript

import { reactive } from 'vue'
/**
* Section classification for subscription grouping.
* Maps item_group (legacy category) to display sections with icons.
*/
export const SECTION_MAP = {
'Internet': { match: ['Mensualités fibre', 'Mensualités sans fil', 'Internet camping', 'Adresse IP Fixe'], icon: 'language' },
'Téléphonie': { match: ['Téléphonie'], icon: 'phone' },
'Télévision': { match: ['Mensualités télévision'], icon: 'tv' },
'Équipement': { match: ['Installation et équipement fibre', 'Installation et équipement télé', 'Installation et équipement internet sans fil', 'Equipement internet fibre', 'Equipement internet sans fil', 'Location point à point', 'Quincaillerie'], icon: 'router' },
'Hébergement': { match: ['Hébergement', 'Nom de domaine', "Location d'espace", 'Location espace cloud'], icon: 'cloud' },
'Rabais': { match: ['Rabais'], icon: 'sell' },
}
const SECTION_ORDER = ['Internet', 'Téléphonie', 'Télévision', 'Équipement', 'Hébergement', 'Rabais', 'Autre']
/**
* Classify a subscription into a section key.
*/
export function subSection (sub) {
const price = parseFloat(sub.actual_price || 0)
const group = (sub.item_group || '').trim()
const sku = (sub.item_code || '').toUpperCase()
const label = (sub.custom_description || sub.item_name || sub.plan_name || '').toLowerCase()
// Negative price = rabais
if (price < 0 || sku.startsWith('RAB') || label.includes('rabais')) return 'Rabais'
// Match by item_group / service_category
for (const [section, cfg] of Object.entries(SECTION_MAP)) {
if (section === 'Rabais') continue
if (cfg.match.some(m => group.includes(m))) return section
}
// Category-based matching (from Service Subscription.service_category)
if (group === 'Internet') return 'Internet'
if (group === 'VoIP' || group === 'Téléphonie') return 'Téléphonie'
if (group === 'IPTV' || group === 'Télévision') return 'Télévision'
if (group === 'Hébergement') return 'Hébergement'
if (group === 'Bundle') return 'Internet'
// SKU-based fallback (works for both legacy item_codes and SUB-* names)
if (/^(FTTH|FTTB|TURBO|FIBRE|FORFERF|FORFBASE|FORFPERF|FORFPOP|FORFMES|SYM|VIP|ENT|COM|FIBCOM|HV)/.test(sku)) return 'Internet'
if (/^(TELEP|FAX|SERV911|SERVTEL|TELE_)/.test(sku)) return 'Téléphonie'
if (/^(TV|STB|RABTV)/.test(sku)) return 'Télévision'
if (/^(LOC|FTT_H|FTTH_LOC)/.test(sku)) return 'Équipement'
if (/^(HEB|DOM)/.test(sku)) return 'Hébergement'
// Label-based fallback (Service Subscription plan_name)
if (/fibre|internet|giga|illimit|turbo|optimum/i.test(label)) return 'Internet'
if (/téléphon|voip|phone|fax|911/i.test(label)) return 'Téléphonie'
if (/télé|tv|décodeur|stb/i.test(label)) return 'Télévision'
if (/location|modem|routeur|appareil|frais.*accès/i.test(label)) return 'Équipement'
if (/héberg|domaine|cloud|espace/i.test(label)) return 'Hébergement'
return 'Autre'
}
export function isRebate (sub) {
return parseFloat(sub.actual_price || sub.monthly_price || 0) < 0
}
export function subMainLabel (sub) {
if (sub.custom_description && sub.custom_description.trim()) {
return sub.custom_description
}
return sub.item_name || sub.plan_name || sub.item_code || sub.name
}
export function subSubLabel (sub) {
if (sub.custom_description && sub.custom_description.trim()) return ''
return ''
}
// Only Active subs contribute to the displayed monthly total. Cancelled/
// Suspended/Pending rows are still rendered (with strikethrough) for
// audit visibility but they shouldn't inflate the number the rep
// communicates to the customer. Was a real bug on C-LPB4: 5 internet
// rows summed to 196.05 because the 3 cancelled (Megafibre 80, TEST,
// FTTH100) were included in the math even though shown struck-through.
const isActiveBilling = sub => (sub.status || 'Active') === 'Active'
export function sectionTotal (items) {
return items.reduce((s, sub) => isActiveBilling(sub) ? s + parseFloat(sub.actual_price || 0) : s, 0)
}
export function annualPrice (sub) {
return parseFloat(sub.actual_price || 0) * 12
}
/**
* Composable for managing subscription grouping, section state, and location-level helpers.
* @param {import('vue').Ref<Array>} subscriptions - Reactive ref of all subscriptions
*/
export function useSubscriptionGroups (subscriptions) {
const subSections = reactive({}) // { "locName:freq": { sectionKey: [subs] } }
const openSections = reactive({}) // { "locName:sectionKey": true/false }
function locSubs (locName) {
return subscriptions.value.filter(s => s.service_location === locName)
}
function locSubsMonthly (locName) {
return locSubs(locName).filter(s => s.billing_frequency !== 'A')
}
function locSubsAnnual (locName) {
return locSubs(locName).filter(s => s.billing_frequency === 'A')
}
function locSubsMonthlyTotal (locName) {
// Same active-only filter as sectionTotal — keep them in sync.
return locSubsMonthly(locName).reduce((sum, s) => isActiveBilling(s) ? sum + parseFloat(s.actual_price || 0) : sum, 0)
}
function locSubsAnnualTotal (locName) {
return locSubsAnnual(locName).reduce((sum, s) => isActiveBilling(s) ? sum + annualPrice(s) : sum, 0)
}
function locSubsSections (locName, freq) {
const cacheKey = locName + ':' + freq
if (!subSections[cacheKey]) {
const subs = freq === 'A' ? locSubsAnnual(locName) : locSubsMonthly(locName)
const groups = {}
for (const sub of subs) {
const sec = subSection(sub)
if (!groups[sec]) groups[sec] = []
groups[sec].push(sub)
}
for (const items of Object.values(groups)) {
items.sort((a, b) => parseFloat(b.actual_price || 0) - parseFloat(a.actual_price || 0))
}
subSections[cacheKey] = groups
}
const result = []
for (const key of SECTION_ORDER) {
if (subSections[cacheKey][key]?.length) {
result.push({ key, label: key, icon: SECTION_MAP[key]?.icon || 'label', items: subSections[cacheKey][key] })
}
}
for (const [key, items] of Object.entries(subSections[cacheKey])) {
if (!SECTION_ORDER.includes(key) && items.length) {
result.push({ key, label: key, icon: SECTION_MAP[key]?.icon || 'label', items })
}
}
return result
}
function sectionOpen (locName, sectionKey) {
const k = locName + ':' + sectionKey
if (openSections[k] === undefined) openSections[k] = false
return openSections[k]
}
function toggleSection (locName, sectionKey) {
const k = locName + ':' + sectionKey
openSections[k] = !sectionOpen(locName, sectionKey)
}
function invalidateCache (locName) {
delete subSections[locName + ':A']
delete subSections[locName + ':M']
}
function invalidateAll () {
for (const k of Object.keys(subSections)) delete subSections[k]
}
return {
subSections,
openSections,
locSubs,
locSubsMonthly,
locSubsAnnual,
locSubsMonthlyTotal,
locSubsAnnualTotal,
locSubsSections,
sectionOpen,
toggleSection,
invalidateCache,
invalidateAll,
}
}