diff --git a/services/targo-hub/lib/acceptance.js b/services/targo-hub/lib/acceptance.js index 4bbfd78..0c0ff29 100644 --- a/services/targo-hub/lib/acceptance.js +++ b/services/targo-hub/lib/acceptance.js @@ -201,7 +201,12 @@ async function createDeferredJobs (steps, ctx, quotationName) { `Source: ${quotationName}`, 'Créé automatiquement après acceptation client', ].filter(Boolean).join(' | '), - scheduled_date: step.scheduled_date || '', + // scheduled_date precedence: explicit step date > ctx default > today. + // We default to today because ERPNext list views + dispatcher boards + // commonly filter "scheduled_date >= today", and null dates make jobs + // disappear from the dispatch queue (user-visible symptom: "Aucune + // job disponible pour dispatch"). Dispatcher can always reschedule. + scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10), } try { diff --git a/services/targo-hub/lib/contracts.js b/services/targo-hub/lib/contracts.js index 9983e04..13e80b5 100644 --- a/services/targo-hub/lib/contracts.js +++ b/services/targo-hub/lib/contracts.js @@ -405,17 +405,33 @@ async function handle (req, res, method, path) { log(`Contract ${contractName} accepted by ${payload.sub}`) + // Snapshot the contract NOW (before async chain-building) so the + // post-sign acknowledgment SMS has everything it needs regardless of + // what happens later. + let signedContract = null + try { + const snap = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`) + if (snap.status === 200) signedContract = snap.data?.data || null + } catch { /* non-fatal */ } + // Fire flow trigger: on_contract_signed — then guarantee tasks exist. // - // The Flow Runtime is the "configurable" path: an admin-defined Flow - // Template with trigger_event='on_contract_signed' can fan out Issue + - // Dispatch Job chains. That path is optional though — if no template is - // active, dispatchEvent() silently returns [] and the contract ends up - // with zero downstream tasks (which is exactly what bit us on CTR-00007). + // Two layers: + // 1. Flow Runtime (configurable) — an admin-defined Flow Template with + // trigger_event='on_contract_signed' can fan out Issue + Dispatch Job + // chains however they want. + // 2. Built-in install chain (always, as a safety net) — creates one + // master Issue + the fiber_install 4-job chain, idempotent per + // contract (keyed on the Issue subject containing contractName). // - // To make contract → tasks bulletproof, we await dispatchEvent and run a - // built-in fallback when nothing matched: one Issue (master ticket) + - // a chained fiber_install project-template (4 jobs, On-Hold gated). + // CRITICAL: we used to skip (2) when (1) ran at least one template, which + // looked safe BUT silently broke signed contracts if the matched template + // was disabled/broken/misconfigured (CTR-00008 did exactly that — a + // stale FT-00005 "handled" the event, produced nothing, and we thought + // we were done). So now we ALWAYS run (2); the idempotency guard inside + // _createBuiltInInstallChain (look up existing Issue) means a healthy + // flow template wins naturally — the built-in path short-circuits as + // soon as it sees an Issue already linked to the contract. // // This runs in the background — the HTTP response returns right away. ;(async () => { @@ -431,11 +447,33 @@ async function handle (req, res, method, path) { }) const ranCount = Array.isArray(results) ? results.length : 0 if (ranCount > 0) { - log(`[contract] ${contractName}: Flow Runtime handled on_contract_signed (${ranCount} template(s))`) - return + log(`[contract] ${contractName}: Flow Runtime dispatched ${ranCount} template(s) — will verify outcome via built-in safety net`) + } else { + log(`[contract] ${contractName}: no active Flow Template for on_contract_signed — built-in install chain will run`) + } + // Always attempt the built-in chain. If a Flow Template already + // produced an Issue for this contract, the idempotency check inside + // _createBuiltInInstallChain will short-circuit with a log line. + const chainResult = await _createBuiltInInstallChain(contractName, payload) + + // Post-sign acknowledgment SMS (bon de commande confirmation). + // We fire this ONLY when we actually created the chain — if a + // previous run already built it (idempotent skip), the customer + // was already notified then and we don't double-send. + if (chainResult?.created) { + // Resolve address for the SMS body if the chain built it + let address = '' + if (signedContract?.service_location) { + try { + const locR = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(signedContract.service_location)}`) + if (locR.status === 200 && locR.data?.data) { + const loc = locR.data.data + address = [loc.address_line_1, loc.city].filter(Boolean).join(', ') + } + } catch { /* non-fatal */ } + } + await _sendPostSignAcknowledgment(contractName, signedContract, { address }) } - log(`[contract] ${contractName}: no active Flow Template for on_contract_signed — running built-in install chain`) - await _createBuiltInInstallChain(contractName, payload) } catch (e) { log(`[contract] ${contractName}: on_contract_signed automation failed: ${e.message}`) } @@ -494,7 +532,7 @@ async function _createBuiltInInstallChain (contractName, payload) { const dup = await erpFetch(`/api/resource/Issue?filters=${encodeURIComponent(dupFilters)}&limit_page_length=1`) if (dup.status === 200 && Array.isArray(dup.data?.data) && dup.data.data.length) { log(`[contract] ${contractName}: chain already exists (Issue ${dup.data.data[0].name}) — skipping`) - return + return { created: false, issue: dup.data.data[0].name, jobs: [], reason: 'already_exists' } } } catch { /* non-fatal — proceed to create */ } @@ -536,21 +574,85 @@ async function _createBuiltInInstallChain (contractName, payload) { const steps = getTemplateSteps('fiber_install') if (!steps.length) { log(`[contract] ${contractName}: no fiber_install template — chain aborted`) - return + return { created: false, issue: issueName, jobs: [], reason: 'no_template' } } 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 + // scheduled_date=null and disappeared from the dispatch queue. + const today = new Date().toISOString().slice(0, 10) const ctx = { customer, service_location: serviceLocation, address, issue: issueName, + scheduled_date: today, + order_source: 'Contract', } + let jobs = [] try { - const jobs = await createDeferredJobs(steps, ctx, contractName) + jobs = await createDeferredJobs(steps, ctx, contractName) log(`[contract] ${contractName}: created ${jobs.length} chained Dispatch Job(s) under Issue ${issueName || '(none)'}`) } catch (e) { log(`[contract] ${contractName}: chained job creation failed: ${e.message}`) } + return { created: true, issue: issueName, jobs, scheduled_date: today } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Post-sign acknowledgment SMS — customer gets a confirmation that the order +// (bon de commande) was received and what happens next. +// ───────────────────────────────────────────────────────────────────────────── +async function _sendPostSignAcknowledgment (contractName, contract, extras = {}) { + if (!contract) return + const customer = contract.customer + if (!customer) return + + // Resolve phone. Priority: contract.customer_phone → Customer.cell_phone + // → Customer.mobile_no. Our legacy-migrated + // Customer records use `cell_phone` (not `mobile_no`, which is Frappe's + // default). Check both so we work for old + new records. + let phone = contract.customer_phone || '' + let customerName = contract.customer_name || customer + let email = '' + if (!phone || !customerName) { + try { + const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customer)}?fields=${encodeURIComponent(JSON.stringify(['customer_name','mobile_no','cell_phone','email_id','email_billing']))}`) + const d = cr.data?.data || {} + phone = phone || d.cell_phone || d.mobile_no || '' + email = d.email_billing || d.email_id || '' + customerName = customerName || d.customer_name || customer + } catch { /* non-fatal */ } + } + if (!phone) { + log(`[contract] ${contractName}: no phone on file — skipping acknowledgment SMS`) + return + } + + const monthly = contract.monthly_rate ? `${contract.monthly_rate}$/mois` : '' + const duration = contract.duration_months ? ` × ${contract.duration_months} mois` : '' + const planLine = monthly ? `${monthly}${duration}` : '' + const address = extras.address || '' + const firstName = String(customerName).split(/\s+/)[0] || '' + + // Short SMS body — Twilio caps at 1600 chars but single-segment (<160) is + // cheapest and renders best. Keep essentials only. + const msg = [ + `Gigafibre — Bon de commande ${contractName} ✓`, + `Merci ${firstName}, votre commande est reçue.`, + planLine ? `Service: ${planLine}` : '', + address ? `Adresse: ${address}` : '', + `Notre équipe planifie votre installation et vous contactera sous peu.`, + `Support: 438-231-3838`, + ].filter(Boolean).join('\n') + + try { + const { sendSmsInternal } = require('./twilio') + const sid = await sendSmsInternal(phone, msg) + log(`[contract] ${contractName}: acknowledgment SMS sent to ${phone} (${sid})`) + } catch (e) { + log(`[contract] ${contractName}: acknowledgment SMS failed: ${e.message}`) + } } async function createTerminationInvoice (contract, calc, reason) { @@ -753,4 +855,5 @@ module.exports = { // Exposed so ops tools / one-shot scripts can retro-create the install chain // for contracts that were signed before the built-in fallback existed. createBuiltInInstallChain: _createBuiltInInstallChain, + sendPostSignAcknowledgment: _sendPostSignAcknowledgment, }