fix(ops/client): consolidate on Service Subscription + catalog browse

Adding a forfait from the client detail dialog failed with `Update
failed: 417` because the code path manipulated ERPNext's stock
Subscription doctype — a parent/child (Subscription Plan rows) model
with tight validation ("Subscription End Date is mandatory to follow
calendar months"), and whose `plan` field expects an `SP-<hash>` doc
name rather than a free-form string.

Meanwhile all new subscription work — contract signing, chain
activation, prorated invoicing — already writes to our flat custom
`Service Subscription` doctype. The two systems were not talking to
each other: the Service Subscription created for CTR-00008 was
invisible in the client UI (which only read stock Subscription), and
the stock Subscription created by "Ajouter un service" was invisible
to the contract/chain system.

This commit makes Service Subscription the canonical doctype for
everything the ops UI does:

- useClientData.loadSubscriptions: read Service Subscription directly
  (flat doc → UI row) instead of reading stock Subscription + joining
  its Subscription Plan child rows to Items. Legacy stock Subscription
  rows (~39k from the 2026-03-29 migration) stay as audit records
  but are no longer surfaced.
- ClientDetailPage.createService: POST a Service Subscription doc
  (category inferred from item_group). No parent/child logic, no
  calendar-month coupling, no SP-<hash> plan reference. Manual
  description + price entry now works without a catalog pick.
- useSubscriptionActions.updateSub: drop the bogus `ASUB-*` name-based
  doctype detection (ASUB is not a real prefix — both stock and
  Service subs are named SUB-<hex|digits>) and always target Service
  Subscription. Also surface ERPNext's exception one-liner instead of
  raw HTML when an update fails.
- searchPlans: empty/short query now returns top-50 of the Subscription
  Plan catalog so dispatchers can browse instead of being forced to
  guess a name prefix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-23 11:07:54 -04:00
parent 60e300335b
commit dfd41ee993
3 changed files with 122 additions and 153 deletions

View File

@ -60,83 +60,54 @@ export function useClientData (deps) {
} }
async function loadSubscriptions (custFilter) { async function loadSubscriptions (custFilter) {
// Load native ERPNext Subscriptions and flatten plan detail rows into UI-compatible format // Service Subscription is the canonical subscription doctype: contracts,
const subs = await listDocs('Subscription', { // chain activation, prorated billing and the "Add service" dialog all
filters: { party_type: 'Customer', party: custFilter.customer }, // converge on it. Stock ERPNext Subscription rows are legacy-migration
fields: ['name', 'status', 'start_date', 'end_date', 'cancelation_date', // artefacts (~39k paired at import time) — we no longer create them and
'cancel_at_period_end', 'current_invoice_start', 'current_invoice_end', // we no longer surface them here.
'additional_discount_amount'], const subs = await listDocs('Service Subscription', {
limit: 50, orderBy: 'start_date desc', 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 // Map Service Subscription → UI row shape (matches what useSubscriptionGroups
const fullDocs = await Promise.all(subs.map(s => getDoc('Subscription', s.name).catch(() => null))) // / useSubscriptionActions / the template consume: `actual_price`, `custom_description`,
// `billing_frequency`, English status strings, `cancel_at_period_end`).
// Collect unique plan names to resolve item info in one batch const toUiStatus = s => {
const planNames = new Set() if (s === 'Actif') return 'Active'
for (const doc of fullDocs) { if (s === 'Annulé') return 'Cancelled'
if (!doc) continue if (s === 'Suspendu') return 'Suspended'
for (const p of (doc.plans || [])) { if (p.plan) planNames.add(p.plan) } if (s === 'En attente') return 'Pending'
return s || 'Active'
} }
// Fetch Subscription Plan docs for item_code, cost, item_group return subs.map(doc => ({
const planDocs = {} name: doc.name,
if (planNames.size) { subscription: doc.name,
const plans = await listDocs('Subscription Plan', { plan_name: doc.plan_name || '',
filters: { name: ['in', [...planNames]] }, item_code: doc.product_sku || '',
fields: ['name', 'item', 'cost', 'plan_name'], item_name: doc.plan_name || '',
limit: planNames.size, item_group: doc.service_category || '',
}) custom_description: doc.plan_name || '',
// Resolve Item info for each plan actual_price: Number(doc.monthly_price || 0),
const itemCodes = [...new Set(plans.map(p => p.item).filter(Boolean))] service_location: doc.service_location || '',
const itemMap = {} billing_frequency: doc.billing_cycle === 'Annuel' ? 'A' : 'M',
if (itemCodes.length) { status: toUiStatus(doc.status),
const items = await listDocs('Item', { start_date: doc.start_date,
filters: { name: ['in', itemCodes] }, end_date: doc.end_date,
fields: ['name', 'item_name', 'item_group'], cancel_at_period_end: doc.end_date ? 1 : 0,
limit: itemCodes.length, cancelation_date: doc.cancellation_date,
}) current_invoice_start: null,
for (const it of items) itemMap[it.name] = it current_invoice_end: null,
} radius_user: doc.radius_user || '',
for (const p of plans) { radius_pwd: '',
const item = itemMap[p.item] || {} device: doc.device || '',
planDocs[p.name] = { item_code: p.item, cost: p.cost, item_name: item.item_name || p.plan_name, item_group: item.item_group || '' } qty: 1,
} }))
}
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
} }
function loadEquipment (custFilter) { function loadEquipment (custFilter) {

View File

@ -8,36 +8,41 @@ import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { formatMoney } from 'src/composables/useFormatters' 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) { async function updateSub (name, fields) {
// Detect which doctype based on name pattern: SUB-* = Service Subscription, ASUB-* = native Subscription // Map normalized UI field names → Service Subscription fields.
const doctype = name.startsWith('ASUB-') ? 'Subscription' : 'Service Subscription'
// Map normalized field names back to target doctype fields
const mapped = {} const mapped = {}
for (const [k, v] of Object.entries(fields)) { for (const [k, v] of Object.entries(fields)) {
if (doctype === 'Service Subscription') { if (k === 'actual_price') mapped.monthly_price = v
// Map template field names → Service Subscription field names else if (k === 'custom_description') mapped.plan_name = v
if (k === 'actual_price') mapped.monthly_price = v else if (k === 'billing_frequency') mapped.billing_cycle = v === 'A' ? 'Annuel' : 'Mensuel'
else if (k === 'custom_description') mapped.plan_name = v else if (k === 'status') {
else if (k === 'billing_frequency') mapped.billing_cycle = v === 'A' ? 'Annuel' : 'Mensuel' mapped.status = v === 'Active' ? 'Actif'
else if (k === 'status') mapped.status = v === 'Active' ? 'Actif' : v === 'Cancelled' ? 'Annulé' : v : v === 'Cancelled' ? 'Annulé'
else if (k === 'cancelation_date') mapped.cancellation_date = v : v === 'Suspended' ? 'Suspendu'
else if (k === 'cancel_at_period_end') { /* not applicable to Service Subscription */ } : v === 'Pending' ? 'En attente'
else mapped[k] = v : v
} else {
mapped[k] = 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mapped), body: JSON.stringify(mapped),
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.text() // Try to surface the ERPNext exception one-liner instead of raw HTML.
throw new Error('Update failed: ' + err) 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() return res.json()
} }

View File

@ -752,7 +752,7 @@
@update:model-value="onPlanSelected"> @update:model-value="onPlanSelected">
<template #no-option> <template #no-option>
<q-item> <q-item>
<q-item-section class="text-grey">Tapez pour rechercher un forfait...</q-item-section> <q-item-section class="text-grey">Aucun forfait dans le catalogue</q-item-section>
</q-item> </q-item>
</template> </template>
<template #option="scope"> <template #option="scope">
@ -926,7 +926,7 @@
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, reactive, watch } from 'vue'
import { Notify, useQuasar } from 'quasar' import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable' 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 { authFetch } from 'src/api/auth'
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters' import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses' import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
@ -1034,18 +1034,23 @@ function openAddService (loc, mode = 'service') {
} }
async function searchPlans (val, update) { 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 { try {
// Search Subscription Plans (which link to Items with prices)
const plans = await listDocs('Subscription Plan', { const plans = await listDocs('Subscription Plan', {
filters: {}, filters: {},
or_filters: [ ...(browseAll ? {} : {
['name', 'like', `%${val}%`], or_filters: [
['item', 'like', `%${val}%`], ['name', 'like', `%${val}%`],
['plan_name', 'like', `%${val}%`], ['item', 'like', `%${val}%`],
], ['plan_name', 'like', `%${val}%`],
],
}),
fields: ['name', 'item', 'cost', 'plan_name'], fields: ['name', 'item', 'cost', 'plan_name'],
limit: 20, limit: browseAll ? 50 : 20,
orderBy: 'plan_name asc', orderBy: 'plan_name asc',
}) })
// Enrich with Item info // 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 () { async function createService () {
if (!addServiceLoc.value || (!newService.plan && !newService.description)) return if (!addServiceLoc.value || (!newService.plan && !newService.description)) return
addingService.value = true addingService.value = true
@ -1102,73 +1120,48 @@ async function createService () {
const custId = customer.value.name const custId = customer.value.name
const locName = addServiceLoc.value.name const locName = addServiceLoc.value.name
const today = newService.start_date 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 // Create a Service Subscription flat doc, no parent/child plans table.
const existing = await listDocs('Subscription', { // This is also what contracts + chain activation produce, so anything
filters: { party_type: 'Customer', party: custId, status: 'Active' }, // manually added here lives in the same universe.
fields: ['name'], const doc = await createDoc('Service Subscription', {
limit: 1, 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 // Add to local subscriptions list in the same UI row shape produced by
if (existing.length) { // useClientData.loadSubscriptions.
// 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) || {}
subscriptions.value.push({ subscriptions.value.push({
name: lastPlan.name || subDoc.name, name: doc.name,
subscription: subDoc.name, subscription: doc.name,
plan_name: newService.plan || '', plan_name: planLabel,
item_code: newService.item_code || '', item_code: newService.item_code || '',
item_name: newService.description || newService.item_name || '', item_name: planLabel,
item_group: newService.item_group || '', item_group: newService.item_group || category,
custom_description: newService.description || '', custom_description: planLabel,
actual_price: price, actual_price: price,
service_location: locName, service_location: locName,
billing_frequency: 'M', billing_frequency: 'M',
status: 'Active', status: 'Active',
start_date: today, start_date: today,
end_date: null,
cancel_at_period_end: 0, cancel_at_period_end: 0,
qty: 1, qty: 1,
}) })
invalidateCache(locName) invalidateCache(locName)
invalidateAll() invalidateAll()
const label = newService.description || newService.item_name || 'Service' Notify.create({ type: 'positive', message: `${planLabel} ajouté (${formatMoney(price)}/m)`, position: 'top', timeout: 3000 })
Notify.create({ type: 'positive', message: `${label} ajouté (${formatMoney(price)}/m)`, position: 'top', timeout: 3000 })
addServiceOpen.value = false addServiceOpen.value = false
} catch (e) { } catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Création impossible'), position: 'top', timeout: 4000 }) Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Création impossible'), position: 'top', timeout: 4000 })