diff --git a/services/targo-hub/lib/contracts.js b/services/targo-hub/lib/contracts.js index 13e80b5..483466c 100644 --- a/services/targo-hub/lib/contracts.js +++ b/services/targo-hub/lib/contracts.js @@ -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` } // ───────────────────────────────────────────────────────────────────────────── diff --git a/services/targo-hub/lib/project-templates.js b/services/targo-hub/lib/project-templates.js index 334fe2b..fa4e513 100644 --- a/services/targo-hub/lib/project-templates.js +++ b/services/targo-hub/lib/project-templates.js @@ -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 }