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.
186 lines
7.0 KiB
JavaScript
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,
|
|
}
|
|
}
|