gigafibre-fsm/apps/ops/src/composables/useClientData.js
louispaulb 64d5751149 feat(ops/client): contract-aware sub delete with termination preview
The raw DELETE on Service Subscription was blowing up with
LinkExistsError because Service Contract.service_subscription still
referenced the sub. Worse: silently unlinking a live contract would
cost the business the break fee (résidentiel = avantages résiduels,
commercial = mensualités restantes).

Now when the user clicks 🗑 on a sub:

  1. loadServiceContracts pulls `service_subscription` so the client
     can spot the link without a round-trip.
  2. If a non-terminal contract is linked, the dialog upgrades to:
       • header: Contract name + type
       • term bar: start → end, months elapsed / months remaining
         (pulled live from /contract/calculate-termination)
       • penalty breakdown box: total fee, split into benefits to
         refund + remaining months, plus a warning that a termination
         invoice will be created
       • radio: "Désactiver seulement (conserver le contrat)" vs
         "Résilier + facturer X$ + supprimer"
     Suspend-only route goes through toggleSubStatus (no fee).
     Terminate route hits /contract/terminate (status→Résilié +
     invoice), then unlinks + deletes the sub, and drops an audit
     line referencing the generated invoice.
  3. If the linked contract is already Résilié/Complété we just scrub
     the stale link inline in the plain confirm path so the
     dispatcher isn't forced into the termination UI.
2026-04-23 13:47:53 -04:00

318 lines
12 KiB
JavaScript

import { ref } from 'vue'
import { listDocs, getDoc } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL as _HUB_URL } from 'src/config/hub'
export function useClientData (deps) {
const { equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll, fetchStatus, fetchOltStatus } = deps
const loading = ref(true)
const customer = ref(null)
const contact = ref(null)
const locations = ref([])
const subscriptions = ref([])
const tickets = ref([])
const invoices = ref([])
const payments = ref([])
const voipLines = ref([])
const paymentMethods = ref([])
const arrangements = ref([])
const quotations = ref([])
const serviceContracts = ref([])
const comments = ref([])
const accountBalance = ref(null)
const loadingMoreTickets = ref(false)
const loadingMoreInvoices = ref(false)
const loadingMorePayments = ref(false)
function resetState () {
loading.value = true
customer.value = null
locations.value = []
subscriptions.value = []
equipment.value = []
tickets.value = []
invoices.value = []
payments.value = []
voipLines.value = []
paymentMethods.value = []
arrangements.value = []
quotations.value = []
serviceContracts.value = []
comments.value = []
contact.value = null
modalOpen.value = false
ticketsExpanded.value = false
invoicesExpanded.value = false
paymentsExpanded.value = false
}
function loadLocations (custFilter) {
return listDocs('Service Location', {
filters: custFilter,
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
'connection_type', 'olt_port', 'network_id', 'contact_name', 'contact_phone',
'longitude', 'latitude'],
limit: 100, orderBy: 'status asc, address_line asc',
})
}
async function loadSubscriptions (custFilter) {
// Service Subscription is the canonical subscription doctype: contracts,
// chain activation, prorated billing and the "Add service" dialog all
// converge on it. Stock ERPNext Subscription rows are legacy-migration
// artefacts (~39k paired at import time) — we no longer create them and
// we no longer surface them here.
const subs = await listDocs('Service Subscription', {
filters: { customer: custFilter.customer },
fields: ['name', 'plan_name', 'service_category', 'monthly_price', 'billing_cycle',
'service_location', 'status', 'start_date', 'end_date', 'cancellation_date',
'contract_duration', 'product_sku', 'speed_down', 'speed_up',
'radius_user', 'device', 'display_order'],
limit: 100,
// display_order first (dispatcher-controlled), then by start date so
// newer subs float up when display_order hasn't been set yet (= 0).
orderBy: 'display_order asc, start_date desc, creation desc',
})
// Map Service Subscription → UI row shape (matches what useSubscriptionGroups
// / useSubscriptionActions / the template consume: `actual_price`, `custom_description`,
// `billing_frequency`, English status strings, `cancel_at_period_end`).
const toUiStatus = s => {
if (s === 'Actif') return 'Active'
if (s === 'Annulé') return 'Cancelled'
if (s === 'Suspendu') return 'Suspended'
if (s === 'En attente') return 'Pending'
return s || 'Active'
}
return subs.map(doc => ({
name: doc.name,
subscription: doc.name,
plan_name: doc.plan_name || '',
item_code: doc.product_sku || '',
item_name: doc.plan_name || '',
item_group: doc.service_category || '',
custom_description: doc.plan_name || '',
actual_price: Number(doc.monthly_price || 0),
service_location: doc.service_location || '',
billing_frequency: doc.billing_cycle === 'Annuel' ? 'A' : 'M',
status: toUiStatus(doc.status),
start_date: doc.start_date,
end_date: doc.end_date,
cancel_at_period_end: doc.end_date ? 1 : 0,
cancelation_date: doc.cancellation_date,
current_invoice_start: null,
current_invoice_end: null,
radius_user: doc.radius_user || '',
radius_pwd: '',
device: doc.device || '',
display_order: Number(doc.display_order || 0),
qty: 1,
}))
}
function loadEquipment (custFilter) {
return listDocs('Service Equipment', {
filters: custFilter,
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'mac_address',
'ip_address', 'status', 'service_location',
'olt_name', 'olt_ip', 'olt_frame', 'olt_slot', 'olt_port', 'olt_ontid'],
limit: 200, orderBy: 'equipment_type asc',
})
}
function loadTickets (custFilter) {
return listDocs('Issue', {
filters: custFilter,
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
limit: 10, orderBy: 'is_important desc, opening_date desc',
})
}
function loadInvoices (custFilter) {
return listDocs('Sales Invoice', {
filters: custFilter,
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
limit: 5, orderBy: 'posting_date desc, name desc',
})
}
function loadPayments (id) {
return listDocs('Payment Entry', {
filters: { party_type: 'Customer', party: id },
fields: ['name', 'posting_date', 'creation', 'paid_amount', 'mode_of_payment', 'reference_no'],
limit: 5, orderBy: 'creation desc',
})
}
function loadVoipLines (custFilter) {
return listDocs('VoIP Line', {
filters: custFilter,
fields: ['name', 'did', 'status', 'service_location', 'sip_user', 'sip_host',
'e911_civic_number', 'e911_street_name', 'e911_municipality', 'e911_synced',
'ata_model', 'ata_mac'],
limit: 50, orderBy: 'status asc, did asc',
}).catch(() => [])
}
function loadPaymentMethods (custId) {
// Fetch via targo-hub which enriches with live Stripe card data
return fetch(`${_HUB_URL}/payments/methods/${encodeURIComponent(custId)}`, {
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()).then(d => d.methods || []).catch(() => [])
}
function loadArrangements (custFilter) {
return listDocs('Payment Arrangement', {
filters: custFilter,
fields: ['name', 'status', 'total_amount', 'payment_method', 'date_agreed', 'date_due', 'date_cutoff', 'staff', 'note'],
limit: 50, orderBy: 'date_agreed desc',
}).catch(() => [])
}
function loadQuotations (id) {
// Both legacy (custom_legacy_soumission_id > 0) and new wizard-created
// Quotations are returned. Filtering only on party_name surfaces both.
return listDocs('Quotation', {
filters: { party_name: id },
fields: ['name', 'transaction_date', 'grand_total', 'status', 'custom_legacy_soumission_id', 'custom_po_number'],
limit: 50, orderBy: 'transaction_date desc',
}).catch(() => [])
}
function loadServiceContracts (id) {
// Service Contracts are the "offre de service" artifact — they carry
// monthly_rate, duration, and benefits (net promotions). Shown alongside
// Soumissions so the rep can retrieve the recap after publish.
// `service_subscription` is the Link field ERPNext uses for referential
// integrity; we need it client-side so delete-sub can detect a live
// contract and surface termination fees before breaking the link.
return listDocs('Service Contract', {
filters: { customer: id },
fields: ['name', 'contract_type', 'status', 'start_date', 'end_date',
'duration_months', 'monthly_rate', 'total_benefit_value',
'quotation', 'acceptance_method', 'signed_at', 'service_subscription'],
limit: 50, orderBy: 'creation desc',
}).catch(() => [])
}
function loadComments (id) {
return listDocs('Comment', {
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
fields: ['name', 'content', 'comment_by', 'creation'],
limit: 50, orderBy: 'creation desc',
}).catch(() => [])
}
function loadBalance (id) {
return authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)).catch(() => null)
}
async function loadCustomer (id) {
resetState()
try {
const custFilter = { customer: id }
const [cust, locs, subs, equip, tix, invs, pays, voip, pmethods, arrgs, quots, contracts, memos, balRes] = await Promise.all([
getDoc('Customer', id),
loadLocations(custFilter),
loadSubscriptions(custFilter),
loadEquipment(custFilter),
loadTickets(custFilter),
loadInvoices(custFilter),
loadPayments(id),
loadVoipLines(custFilter),
loadPaymentMethods(id),
loadArrangements(custFilter),
loadQuotations(id),
loadServiceContracts(id),
loadComments(id),
loadBalance(id),
])
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
customer.value = cust
locations.value = locs
subscriptions.value = subs
invalidateAll()
equipment.value = equip
if (equip.length) {
fetchStatus(equip).catch(() => {})
for (const eq of equip) {
if (eq.serial_number) fetchOltStatus(eq.serial_number).catch(() => {})
}
}
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
invoices.value = invs
payments.value = pays
voipLines.value = voip
paymentMethods.value = pmethods
arrangements.value = arrgs
quotations.value = quots
serviceContracts.value = contracts
contact.value = null
comments.value = memos
if (balRes?.ok) {
try { accountBalance.value = (await balRes.json()).message } catch {}
}
} catch {
customer.value = null
} finally {
loading.value = false
}
}
async function loadAllTickets () {
if (ticketsExpanded.value || !customer.value) return
loadingMoreTickets.value = true
try {
tickets.value = await listDocs('Issue', {
filters: { customer: customer.value.name },
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
limit: 500, orderBy: 'is_important desc, opening_date desc',
})
ticketsExpanded.value = true
} catch {}
loadingMoreTickets.value = false
}
async function loadAllInvoices () {
if (invoicesExpanded.value || !customer.value) return
loadingMoreInvoices.value = true
try {
invoices.value = await listDocs('Sales Invoice', {
filters: { customer: customer.value.name },
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
limit: 200, orderBy: 'posting_date desc, name desc',
})
invoicesExpanded.value = true
} catch {}
loadingMoreInvoices.value = false
}
async function loadAllPayments () {
if (paymentsExpanded.value || !customer.value) return
loadingMorePayments.value = true
try {
payments.value = await listDocs('Payment Entry', {
filters: { party_type: 'Customer', party: customer.value.name },
fields: ['name', 'posting_date', 'creation', 'paid_amount', 'mode_of_payment', 'reference_no'],
limit: 200, orderBy: 'creation desc',
})
paymentsExpanded.value = true
} catch {}
loadingMorePayments.value = false
}
return {
loading, customer, contact, locations, subscriptions, tickets,
invoices, payments, voipLines, paymentMethods, arrangements, quotations,
serviceContracts, comments, accountBalance,
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
}
}