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) {
// 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) {

View File

@ -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()
}

View File

@ -752,7 +752,7 @@
@update:model-value="onPlanSelected">
<template #no-option>
<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>
</template>
<template #option="scope">
@ -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 })