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">
- Tapez pour rechercher un forfait...
+ Aucun forfait dans le catalogue
@@ -926,7 +926,7 @@
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable'
-import { deleteDoc, createDoc, listDocs, getDoc, updateDoc } from 'src/api/erp'
+import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
@@ -1034,18 +1034,23 @@ function openAddService (loc, mode = 'service') {
}
async function searchPlans (val, update) {
- if (!val || val.length < 2) { update(() => { planSearchResults.value = [] }); return }
+ // Empty query → show a reasonable top-of-catalog listing so the dispatcher
+ // can browse even without typing ("list catalog + add manually" UX).
+ // Short query (1 char) also returns the top slice — Frappe's `like %a%`
+ // would match half the catalog, not helpful. 2+ chars → real filter.
+ const browseAll = !val || val.length < 2
try {
- // Search Subscription Plans (which link to Items with prices)
const plans = await listDocs('Subscription Plan', {
filters: {},
- or_filters: [
- ['name', 'like', `%${val}%`],
- ['item', 'like', `%${val}%`],
- ['plan_name', 'like', `%${val}%`],
- ],
+ ...(browseAll ? {} : {
+ or_filters: [
+ ['name', 'like', `%${val}%`],
+ ['item', 'like', `%${val}%`],
+ ['plan_name', 'like', `%${val}%`],
+ ],
+ }),
fields: ['name', 'item', 'cost', 'plan_name'],
- limit: 20,
+ limit: browseAll ? 50 : 20,
orderBy: 'plan_name asc',
})
// Enrich with Item info
@@ -1091,6 +1096,19 @@ function onPlanSelected (val) {
}
}
+// Map an item_group string (e.g. "Internet", "Téléphonie IP", "Forfait Bundle")
+// to one of the Service Subscription service_category enum values. Falls
+// back to "Autre" when we can't guess.
+function inferServiceCategory (itemGroup, planLabel = '') {
+ const g = `${itemGroup || ''} ${planLabel || ''}`.toLowerCase()
+ if (g.includes('iptv') || g.includes('tvbevo') || g.includes(' tv') || g.startsWith('tv')) return 'IPTV'
+ if (g.includes('voip') || g.includes('tele') || g.includes('phone')) return 'VoIP'
+ if (g.includes('ftth') || g.includes('fibre') || g.includes('internet')) return 'Internet'
+ if (g.includes('bundle') || g.includes('forfait')) return 'Bundle'
+ if (g.includes('heberg') || g.includes('host')) return 'Hébergement'
+ return 'Autre'
+}
+
async function createService () {
if (!addServiceLoc.value || (!newService.plan && !newService.description)) return
addingService.value = true
@@ -1102,73 +1120,48 @@ async function createService () {
const custId = customer.value.name
const locName = addServiceLoc.value.name
const today = newService.start_date
+ const planLabel = newService.description || newService.item_name || 'Service'
+ const category = inferServiceCategory(newService.item_group, planLabel)
- // Find or create the customer's active Subscription
- const existing = await listDocs('Subscription', {
- filters: { party_type: 'Customer', party: custId, status: 'Active' },
- fields: ['name'],
- limit: 1,
+ // Create a Service Subscription — flat doc, no parent/child plans table.
+ // This is also what contracts + chain activation produce, so anything
+ // manually added here lives in the same universe.
+ const doc = await createDoc('Service Subscription', {
+ customer: custId,
+ customer_name: customer.value.customer_name || '',
+ service_location: locName,
+ service_category: category,
+ plan_name: planLabel.slice(0, 140),
+ monthly_price: price,
+ billing_cycle: 'Mensuel',
+ start_date: today,
+ status: 'Actif', // manual add → immediately active
+ product_sku: newService.item_code || '',
})
- let subDoc
- if (existing.length) {
- // Add plan row to existing Subscription
- subDoc = await getDoc('Subscription', existing[0].name)
- const newPlan = {
- plan: newService.plan || '',
- qty: 1,
- service_location: locName,
- custom_description: newService.description || newService.item_name || '',
- actual_price: price || 0,
- }
- subDoc.plans.push(newPlan)
- subDoc = await updateDoc('Subscription', subDoc.name, { plans: subDoc.plans })
- } else {
- // Create new Subscription for this customer
- subDoc = await createDoc('Subscription', {
- party_type: 'Customer',
- party: custId,
- company: 'TARGO',
- start_date: today,
- end_date: new Date(new Date(today).setFullYear(new Date(today).getFullYear() + 5)).toISOString().slice(0, 10),
- follow_calendar_months: 1,
- generate_invoice_at: 'Beginning of the current subscription period',
- days_until_due: 30,
- submit_invoice: 0,
- sales_tax_template: 'QC TPS 5% + TVQ 9.975% - T',
- plans: [{
- plan: newService.plan || '',
- qty: 1,
- service_location: locName,
- custom_description: newService.description || newService.item_name || '',
- actual_price: price || 0,
- }],
- })
- }
-
- // Add to local subscriptions list for immediate UI update
- const lastPlan = (subDoc.plans || []).at(-1) || {}
+ // Add to local subscriptions list in the same UI row shape produced by
+ // useClientData.loadSubscriptions.
subscriptions.value.push({
- name: lastPlan.name || subDoc.name,
- subscription: subDoc.name,
- plan_name: newService.plan || '',
+ name: doc.name,
+ subscription: doc.name,
+ plan_name: planLabel,
item_code: newService.item_code || '',
- item_name: newService.description || newService.item_name || '',
- item_group: newService.item_group || '',
- custom_description: newService.description || '',
+ item_name: planLabel,
+ item_group: newService.item_group || category,
+ custom_description: planLabel,
actual_price: price,
service_location: locName,
billing_frequency: 'M',
status: 'Active',
start_date: today,
+ end_date: null,
cancel_at_period_end: 0,
qty: 1,
})
invalidateCache(locName)
invalidateAll()
- const label = newService.description || newService.item_name || 'Service'
- Notify.create({ type: 'positive', message: `${label} ajouté (${formatMoney(price)}/m)`, position: 'top', timeout: 3000 })
+ Notify.create({ type: 'positive', message: `${planLabel} ajouté (${formatMoney(price)}/m)`, position: 'top', timeout: 3000 })
addServiceOpen.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Création impossible'), position: 'top', timeout: 4000 })