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:
parent
60e300335b
commit
dfd41ee993
|
|
@ -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', {
|
|
||||||
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,
|
start_date: doc.start_date,
|
||||||
end_date: doc.end_date,
|
end_date: doc.end_date,
|
||||||
cancel_at_period_end: doc.cancel_at_period_end || 0,
|
cancel_at_period_end: doc.end_date ? 1 : 0,
|
||||||
cancelation_date: doc.cancelation_date,
|
cancelation_date: doc.cancellation_date,
|
||||||
current_invoice_start: doc.current_invoice_start,
|
current_invoice_start: null,
|
||||||
current_invoice_end: doc.current_invoice_end,
|
current_invoice_end: null,
|
||||||
radius_user: plan.radius_user || '',
|
radius_user: doc.radius_user || '',
|
||||||
radius_pwd: plan.radius_pwd || '',
|
radius_pwd: '',
|
||||||
device: plan.device || '',
|
device: doc.device || '',
|
||||||
qty: plan.qty || 1,
|
qty: 1,
|
||||||
})
|
}))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadEquipment (custFilter) {
|
function loadEquipment (custFilter) {
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
|
||||||
// Map template field names → Service Subscription field names
|
|
||||||
if (k === 'actual_price') mapped.monthly_price = v
|
if (k === 'actual_price') mapped.monthly_price = v
|
||||||
else if (k === 'custom_description') mapped.plan_name = 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 === '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 === 'status') {
|
||||||
else if (k === 'cancelation_date') mapped.cancellation_date = v
|
mapped.status = v === 'Active' ? 'Actif'
|
||||||
else if (k === 'cancel_at_period_end') { /* not applicable to Service Subscription */ }
|
: v === 'Cancelled' ? 'Annulé'
|
||||||
else mapped[k] = v
|
: v === 'Suspended' ? 'Suspendu'
|
||||||
} else {
|
: v === 'Pending' ? 'En attente'
|
||||||
mapped[k] = v
|
: 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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
||||||
|
...(browseAll ? {} : {
|
||||||
or_filters: [
|
or_filters: [
|
||||||
['name', 'like', `%${val}%`],
|
['name', 'like', `%${val}%`],
|
||||||
['item', 'like', `%${val}%`],
|
['item', 'like', `%${val}%`],
|
||||||
['plan_name', '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 || '',
|
||||||
|
|
||||||
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,
|
service_location: locName,
|
||||||
custom_description: newService.description || newService.item_name || '',
|
service_category: category,
|
||||||
actual_price: price || 0,
|
plan_name: planLabel.slice(0, 140),
|
||||||
}
|
monthly_price: price,
|
||||||
subDoc.plans.push(newPlan)
|
billing_cycle: 'Mensuel',
|
||||||
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,
|
start_date: today,
|
||||||
end_date: new Date(new Date(today).setFullYear(new Date(today).getFullYear() + 5)).toISOString().slice(0, 10),
|
status: 'Actif', // manual add → immediately active
|
||||||
follow_calendar_months: 1,
|
product_sku: newService.item_code || '',
|
||||||
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
|
// Add to local subscriptions list in the same UI row shape produced by
|
||||||
const lastPlan = (subDoc.plans || []).at(-1) || {}
|
// useClientData.loadSubscriptions.
|
||||||
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 })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user