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.
318 lines
12 KiB
JavaScript
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,
|
|
}
|
|
}
|