diff --git a/apps/ops/src/composables/useClientData.js b/apps/ops/src/composables/useClientData.js index 091289a..da8f017 100644 --- a/apps/ops/src/composables/useClientData.js +++ b/apps/ops/src/composables/useClientData.js @@ -60,83 +60,54 @@ export function useClientData (deps) { } async function loadSubscriptions (custFilter) { - // Load native ERPNext Subscriptions and flatten plan detail rows into UI-compatible format - const subs = await listDocs('Subscription', { - filters: { party_type: 'Customer', party: custFilter.customer }, - fields: ['name', 'status', 'start_date', 'end_date', 'cancelation_date', - 'cancel_at_period_end', 'current_invoice_start', 'current_invoice_end', - 'additional_discount_amount'], - limit: 50, orderBy: 'start_date desc', + // 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'], + limit: 100, orderBy: 'start_date desc, creation desc', }) - // Fetch full docs to get plans child table with custom fields - const fullDocs = await Promise.all(subs.map(s => getDoc('Subscription', s.name).catch(() => null))) - - // Collect unique plan names to resolve item info in one batch - const planNames = new Set() - for (const doc of fullDocs) { - if (!doc) continue - for (const p of (doc.plans || [])) { if (p.plan) planNames.add(p.plan) } + // 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' } - // Fetch Subscription Plan docs for item_code, cost, item_group - const planDocs = {} - if (planNames.size) { - const plans = await listDocs('Subscription Plan', { - filters: { name: ['in', [...planNames]] }, - fields: ['name', 'item', 'cost', 'plan_name'], - limit: planNames.size, - }) - // Resolve Item info for each plan - const itemCodes = [...new Set(plans.map(p => p.item).filter(Boolean))] - const itemMap = {} - if (itemCodes.length) { - const items = await listDocs('Item', { - filters: { name: ['in', itemCodes] }, - fields: ['name', 'item_name', 'item_group'], - limit: itemCodes.length, - }) - for (const it of items) itemMap[it.name] = it - } - for (const p of plans) { - const item = itemMap[p.item] || {} - planDocs[p.name] = { item_code: p.item, cost: p.cost, item_name: item.item_name || p.plan_name, item_group: item.item_group || '' } - } - } - - const rows = [] - for (const doc of fullDocs) { - if (!doc) continue - - for (const plan of (doc.plans || [])) { - const pd = planDocs[plan.plan] || {} - rows.push({ - name: plan.name, // child table row name (unique key) - subscription: doc.name, // parent Subscription - plan_name: plan.plan || '', - item_code: pd.item_code || plan.plan || '', - item_name: plan.custom_description || pd.item_name || plan.plan || '', - item_group: pd.item_group || '', - custom_description: plan.custom_description || '', - actual_price: plan.actual_price || pd.cost || 0, - service_location: plan.service_location || '', - billing_frequency: doc.cancel_at_period_end ? 'A' : 'M', - status: doc.status === 'Active' ? 'Active' : doc.status === 'Cancelled' ? 'Cancelled' : doc.status, - start_date: doc.start_date, - end_date: doc.end_date, - cancel_at_period_end: doc.cancel_at_period_end || 0, - cancelation_date: doc.cancelation_date, - current_invoice_start: doc.current_invoice_start, - current_invoice_end: doc.current_invoice_end, - radius_user: plan.radius_user || '', - radius_pwd: plan.radius_pwd || '', - device: plan.device || '', - qty: plan.qty || 1, - }) - } - } - - return rows + 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 || '', + qty: 1, + })) } function loadEquipment (custFilter) { diff --git a/apps/ops/src/composables/useSubscriptionActions.js b/apps/ops/src/composables/useSubscriptionActions.js index 47870d0..c153772 100644 --- a/apps/ops/src/composables/useSubscriptionActions.js +++ b/apps/ops/src/composables/useSubscriptionActions.js @@ -8,36 +8,41 @@ import { authFetch } from 'src/api/auth' import { BASE_URL } from 'src/config/erpnext' import { formatMoney } from 'src/composables/useFormatters' -// Frappe REST update — supports both Service Subscription (legacy) and native Subscription +// Frappe REST update — always targets Service Subscription now. Stock +// ERPNext Subscription rows are legacy-migration artefacts that we no +// longer display or mutate from ops; see useClientData.loadSubscriptions. async function updateSub (name, fields) { - // Detect which doctype based on name pattern: SUB-* = Service Subscription, ASUB-* = native Subscription - const doctype = name.startsWith('ASUB-') ? 'Subscription' : 'Service Subscription' - - // Map normalized field names back to target doctype fields + // Map normalized UI field names → Service Subscription fields. const mapped = {} for (const [k, v] of Object.entries(fields)) { - if (doctype === 'Service Subscription') { - // Map template field names → Service Subscription field names - if (k === 'actual_price') mapped.monthly_price = v - else if (k === 'custom_description') mapped.plan_name = v - else if (k === 'billing_frequency') mapped.billing_cycle = v === 'A' ? 'Annuel' : 'Mensuel' - else if (k === 'status') mapped.status = v === 'Active' ? 'Actif' : v === 'Cancelled' ? 'Annulé' : v - else if (k === 'cancelation_date') mapped.cancellation_date = v - else if (k === 'cancel_at_period_end') { /* not applicable to Service Subscription */ } - else mapped[k] = v - } else { - mapped[k] = v + if (k === 'actual_price') mapped.monthly_price = v + else if (k === 'custom_description') mapped.plan_name = v + else if (k === 'billing_frequency') mapped.billing_cycle = v === 'A' ? 'Annuel' : 'Mensuel' + else if (k === 'status') { + mapped.status = v === 'Active' ? 'Actif' + : v === 'Cancelled' ? 'Annulé' + : v === 'Suspended' ? 'Suspendu' + : v === 'Pending' ? 'En attente' + : v } + else if (k === 'cancelation_date') mapped.cancellation_date = v + else if (k === 'cancel_at_period_end') { /* Service Subscription uses end_date + status for lifecycle */ } + else mapped[k] = v } - const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name), { + const res = await authFetch(BASE_URL + '/api/resource/Service%20Subscription/' + encodeURIComponent(name), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mapped), }) if (!res.ok) { - const err = await res.text() - throw new Error('Update failed: ' + err) + // Try to surface the ERPNext exception one-liner instead of raw HTML. + let msg = 'Update failed: ' + res.status + try { + const body = await res.json() + msg = body?.exception?.split('\n')[0] || body?._server_messages || msg + } catch {} + throw new Error(msg) } return res.json() } diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 2af3141..ef21f61 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -752,7 +752,7 @@ @update:model-value="onPlanSelected">