gigafibre-fsm/apps/ops/src/composables/useSubscriptionGroups.js
louispaulb 0a0f1b2d8a feat: subscription reimport, customer/doctype ID rename, zero-padded format
- 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>
2026-04-01 17:17:23 -04:00

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,
}
}