fix(contracts): create pending Service Subscription on signing + test templates
Root cause of CTR-00008: _createBuiltInInstallChain only created Issue +
Dispatch Jobs. It never created a pending Service Subscription, so when
the chain's terminal job Completed, activateSubscriptionForJob found
nothing matching customer+service_location+status='En attente' to flip.
Result: 4/4 tasks done, no sub activation, no prorated invoice.
Changes:
- contracts.js: after chain creation, create Service Subscription with
status='En attente' (plan_name + service_category inferred from the
contract). Back-link it on Service Contract.service_subscription (a
new custom field — the stock 'subscription' field on Service Contract
points at the built-in ERPNext Subscription doctype, not ours).
- project-templates.js: add test_single (1-step) and test_parallel
(diamond: step0 → step1 ∥ step2) for faster lifecycle testing.
Extract chooseTemplate(contract) with precedence:
contract.install_template → contract_type mapping → fiber_install.
- contracts.js: chain builder now uses chooseTemplate instead of
hardcoded fiber_install, logs the chosen template per contract.
- _inferServiceCategory/_inferPlanName helpers map contract metadata
into the Service Subscription's required fields.
Companion changes on ERPNext (custom fields, no code):
Service Contract.service_subscription Link → Service Subscription
Service Contract.install_template Select (fiber_install,
phone_service, move_service, repair_service, test_single,
test_parallel)
Retroactive repair for CTR-00008 applied directly on prod:
→ SUB-0000100003 (Actif), SINV-2026-700014 (Draft, $9.32 prorata).
Smoke test of test_single path on prod (CTR-00010 synthetic, cleaned up):
template=test_single ✓ sub created ✓ activated on completion ✓
prorated invoice emitted ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9fda9eb0b0
commit
2aee8f31df
|
|
@ -1,6 +1,7 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
const { signJwt, verifyJwt } = require('./magic-link')
|
||||
|
||||
// Lazy-loaded flow runtime. Kept local so the require() cost isn't paid on
|
||||
|
|
@ -568,14 +569,19 @@ async function _createBuiltInInstallChain (contractName, payload) {
|
|||
log(`[contract] ${contractName}: Issue creation failed: ${e.message} — continuing with jobs only`)
|
||||
}
|
||||
|
||||
// 4. Build the chained Dispatch Jobs using acceptance.createDeferredJobs
|
||||
// (same proven On-Hold / depends_on chaining used by online checkout).
|
||||
const { getTemplateSteps } = require('./project-templates')
|
||||
const steps = getTemplateSteps('fiber_install')
|
||||
// 4. Pick the install template. Order of precedence:
|
||||
// (a) contract.install_template if set explicitly
|
||||
// (b) contract_type → template mapping (Test-Simple, Test-Parallèle, etc.)
|
||||
// (c) fiber_install as universal default
|
||||
const { getTemplateSteps, chooseTemplate } = require('./project-templates')
|
||||
const templateId = chooseTemplate(contract)
|
||||
const steps = getTemplateSteps(templateId)
|
||||
if (!steps.length) {
|
||||
log(`[contract] ${contractName}: no fiber_install template — chain aborted`)
|
||||
log(`[contract] ${contractName}: no "${templateId}" template — chain aborted`)
|
||||
return { created: false, issue: issueName, jobs: [], reason: 'no_template' }
|
||||
}
|
||||
log(`[contract] ${contractName}: using template "${templateId}" (${steps.length} step${steps.length > 1 ? 's' : ''})`)
|
||||
|
||||
const { createDeferredJobs } = require('./acceptance')
|
||||
// scheduled_date defaults to today so the jobs land on TODAY's dispatch
|
||||
// board. Dispatcher reschedules per capacity. Without this, jobs had
|
||||
|
|
@ -596,7 +602,71 @@ async function _createBuiltInInstallChain (contractName, payload) {
|
|||
} catch (e) {
|
||||
log(`[contract] ${contractName}: chained job creation failed: ${e.message}`)
|
||||
}
|
||||
return { created: true, issue: issueName, jobs, scheduled_date: today }
|
||||
|
||||
// 5. Create the pending Service Subscription.
|
||||
// Without this, activateSubscriptionForJob() on the last-job-completion
|
||||
// finds nothing to activate and no prorated invoice is ever emitted —
|
||||
// this was the CTR-00008 failure mode. The sub is born 'En attente' and
|
||||
// gets flipped to 'Actif' + prorated by dispatch when the chain finishes.
|
||||
let subscriptionName = ''
|
||||
if (serviceLocation && contract.monthly_rate && Number(contract.monthly_rate) !== 0) {
|
||||
try {
|
||||
const subPayload = {
|
||||
customer,
|
||||
customer_name: customerName,
|
||||
service_location: serviceLocation,
|
||||
status: 'En attente',
|
||||
service_category: _inferServiceCategory(contract),
|
||||
plan_name: _inferPlanName(contract),
|
||||
monthly_price: Number(contract.monthly_rate),
|
||||
billing_cycle: 'Mensuel',
|
||||
contract_duration: Number(contract.duration_months || 0),
|
||||
start_date: today,
|
||||
notes: `Créé automatiquement à la signature du contrat ${contractName}`,
|
||||
}
|
||||
const subRes = await erp.create('Service Subscription', subPayload)
|
||||
if (subRes.ok && subRes.name) {
|
||||
subscriptionName = subRes.name
|
||||
log(`[contract] ${contractName}: Service Subscription ${subscriptionName} (En attente) created`)
|
||||
// Link it back on the contract via our custom field. (The stock
|
||||
// `subscription` field on Service Contract is a Link to the built-in
|
||||
// ERPNext Subscription doctype — a different thing. We added a custom
|
||||
// `service_subscription` Link field that points at our doctype.)
|
||||
const linkRes = await erp.update('Service Contract', contractName, { service_subscription: subscriptionName })
|
||||
if (!linkRes.ok) log(`[contract] ${contractName}: back-link failed — ${linkRes.error || 'unknown'}`)
|
||||
} else {
|
||||
log(`[contract] ${contractName}: subscription creation failed — ${subRes.error || 'unknown'}`)
|
||||
}
|
||||
} catch (e) {
|
||||
log(`[contract] ${contractName}: subscription creation threw — ${e.message}`)
|
||||
}
|
||||
} else if (!serviceLocation) {
|
||||
log(`[contract] ${contractName}: no service_location — skipping subscription creation`)
|
||||
} else {
|
||||
log(`[contract] ${contractName}: no monthly_rate — skipping subscription creation`)
|
||||
}
|
||||
|
||||
return { created: true, issue: issueName, jobs, subscription: subscriptionName, template: templateId, scheduled_date: today }
|
||||
}
|
||||
|
||||
// ── Helpers to derive subscription metadata from a Service Contract ─────────
|
||||
// The Service Contract carries monthly_rate + contract_type but not a specific
|
||||
// plan name or service category, so we infer from free-text markers. Keep this
|
||||
// conservative — for contracts we don't recognize, default to 'Internet' since
|
||||
// that's our primary product.
|
||||
function _inferServiceCategory (contract) {
|
||||
const hay = `${contract.contract_type || ''} ${contract.invoice_note || ''}`.toLowerCase()
|
||||
if (/iptv|t[ée]l[ée]|cha[îi]ne/.test(hay)) return 'IPTV'
|
||||
if (/voip|t[ée]l[ée]phon|ligne|pbx/.test(hay)) return 'VoIP'
|
||||
if (/h[ée]bergement|hosting|cloud|email/.test(hay)) return 'Hébergement'
|
||||
return 'Internet'
|
||||
}
|
||||
|
||||
function _inferPlanName (contract) {
|
||||
// Prefer an explicit plan if present on a linked quotation/benefit row.
|
||||
const firstBenefit = (contract.benefits || [])[0]
|
||||
if (firstBenefit?.description) return String(firstBenefit.description).slice(0, 140)
|
||||
return `${contract.contract_type || 'Service'} ${contract.monthly_rate}$/mois`
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,33 +1,93 @@
|
|||
'use strict'
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Install chain templates for Service Contracts.
|
||||
//
|
||||
// Each template is an ordered array of steps. Steps chain via `depends_on_step`
|
||||
// (index into the same array, or null for "run as soon as chain starts").
|
||||
// `null` → the step is born "open" immediately; otherwise born "On Hold"
|
||||
// until the parent step completes (see dispatch.unblockDependents).
|
||||
//
|
||||
// Multiple steps with depends_on_step=null run in PARALLEL (tech sees both
|
||||
// at once, either can be completed first). Likewise multiple steps pointing
|
||||
// at the same parent run in parallel after the parent. The terminal-node
|
||||
// detector (_isChainTerminal) only fires when ALL siblings are Completed.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMPLATES = {
|
||||
// ── Production templates ────────────────────────────────────────────────
|
||||
fiber_install: [
|
||||
{ subject: 'Vérification pré-installation (éligibilité & OLT)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Installation fibre chez le client', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Activation du service & configuration ONT', job_type: 'Installation', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||
{ subject: 'Test de débit & validation client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 },
|
||||
{ subject: 'Vérification pré-installation (éligibilité & OLT)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Installation fibre chez le client', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Activation du service & configuration ONT', job_type: 'Installation', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||
{ subject: 'Test de débit & validation client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 },
|
||||
],
|
||||
phone_service: [
|
||||
{ subject: 'Importer le numéro de téléphone', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Installation fibre (pré-requis portage)', job_type: 'Installation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Portage du numéro vers Gigafibre', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||
{ subject: 'Validation et test du service téléphonique', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 },
|
||||
{ subject: 'Importer le numéro de téléphone', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Installation fibre (pré-requis portage)', job_type: 'Installation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Portage du numéro vers Gigafibre', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||
{ subject: 'Validation et test du service téléphonique', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Tech Targo', depends_on_step: 2 },
|
||||
],
|
||||
move_service: [
|
||||
{ subject: 'Préparation déménagement (vérifier éligibilité nouveau site)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Retrait équipement ancien site', job_type: 'Retrait', priority: 'medium', duration_h: 1, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Installation au nouveau site', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 1 },
|
||||
{ subject: 'Transfert abonnement & mise à jour adresse', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 2 },
|
||||
{ subject: 'Préparation déménagement (vérifier éligibilité nouveau site)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Retrait équipement ancien site', job_type: 'Retrait', priority: 'medium', duration_h: 1, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Installation au nouveau site', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 1 },
|
||||
{ subject: 'Transfert abonnement & mise à jour adresse', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 2 },
|
||||
],
|
||||
repair_service: [
|
||||
{ subject: 'Diagnostic à distance', job_type: 'Dépannage', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Intervention terrain', job_type: 'Réparation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Validation & suivi client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||
{ subject: 'Diagnostic à distance', job_type: 'Dépannage', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Intervention terrain', job_type: 'Réparation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||
{ subject: 'Validation & suivi client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||
],
|
||||
|
||||
// ── Test templates ──────────────────────────────────────────────────────
|
||||
// For end-to-end testing the contract → chain → activation → invoice
|
||||
// lifecycle without running through a 4-step production chain.
|
||||
|
||||
// One-step: tech completes one job, subscription activates immediately.
|
||||
// Fastest path to verify the full lifecycle.
|
||||
test_single: [
|
||||
{ subject: 'Test — Activation immédiate', job_type: 'Installation', priority: 'medium', duration_h: 0.25, assigned_group: 'Admin', depends_on_step: null },
|
||||
],
|
||||
|
||||
// Three steps with a parallel middle: step 0 → (step 1 ∥ step 2).
|
||||
// Both middle jobs are born "open" after step 0 completes (same depends_on).
|
||||
// Tech picks either one first; subscription activates when BOTH are Completed.
|
||||
// Exercises _isChainTerminal's sibling-completion check.
|
||||
test_parallel: [
|
||||
{ subject: 'Test — Étape initiale', job_type: 'Autre', priority: 'medium', duration_h: 0.25, assigned_group: 'Admin', depends_on_step: null },
|
||||
{ subject: 'Test — Branche A (parallèle)', job_type: 'Installation', priority: 'medium', duration_h: 0.25, assigned_group: 'Admin', depends_on_step: 0 },
|
||||
{ subject: 'Test — Branche B (parallèle)', job_type: 'Installation', priority: 'medium', duration_h: 0.25, assigned_group: 'Admin', depends_on_step: 0 },
|
||||
],
|
||||
}
|
||||
|
||||
// Pick a template by explicit id, then by contract_type mapping, else fall back
|
||||
// to the universal fiber_install safety net.
|
||||
//
|
||||
// chooseTemplate({ install_template: 'test_single' }) → test_single
|
||||
// chooseTemplate({ contract_type: 'Résidentiel' }) → fiber_install
|
||||
// chooseTemplate({ contract_type: 'Test-Simple' }) → test_single
|
||||
// chooseTemplate({ contract_type: 'Test-Parallèle' }) → test_parallel
|
||||
// chooseTemplate({}) → fiber_install
|
||||
function chooseTemplate (contract = {}) {
|
||||
const explicit = contract.install_template
|
||||
if (explicit && TEMPLATES[explicit]) return explicit
|
||||
|
||||
const byType = {
|
||||
'Résidentiel': 'fiber_install',
|
||||
'Commercial': 'fiber_install',
|
||||
'Déménagement': 'move_service',
|
||||
'Téléphonie': 'phone_service',
|
||||
'Réparation': 'repair_service',
|
||||
'Test-Simple': 'test_single',
|
||||
'Test-Parallèle': 'test_parallel',
|
||||
'Test-Parallele': 'test_parallel', // tolerate missing accent
|
||||
}
|
||||
const mapped = byType[contract.contract_type]
|
||||
return mapped || 'fiber_install'
|
||||
}
|
||||
|
||||
function getTemplateSteps (templateId) {
|
||||
return TEMPLATES[templateId] || []
|
||||
}
|
||||
|
||||
module.exports = { TEMPLATES, getTemplateSteps }
|
||||
module.exports = { TEMPLATES, getTemplateSteps, chooseTemplate }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user