- Switch Ops data source from Subscription to Service Subscription (source of truth)
- Reimport 39,630 native Subscriptions from Service Subscription data
- Rename 15,302 customers to CUST-{legacy_customer_id} (eliminates hex UUIDs)
- Rename all doctypes to zero-padded 10-digit numeric format:
SINV-0000001234, PE-0000001234, ISS-0000001234, LOC-0000001234,
EQP-0000001234, SUB-0000001234, ASUB-0000001234
- Fix subscription pricing: LPB4 now correctly shows 0$/month
- Update ASUB- prefix detection in useSubscriptionActions.js
- Add reconciliation, reimport, and rename migration scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
6.4 KiB
JavaScript
177 lines
6.4 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 ''
|
|
}
|
|
|
|
export function sectionTotal (items) {
|
|
return items.reduce((s, sub) => s + parseFloat(sub.actual_price || 0), 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) {
|
|
return locSubsMonthly(locName).reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
|
|
}
|
|
|
|
function locSubsAnnualTotal (locName) {
|
|
return locSubsAnnual(locName).reduce((sum, s) => sum + annualPrice(s), 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,
|
|
}
|
|
}
|