fix(contract): always run built-in chain + send ack SMS + default scheduled_date

Three bugs combined to make CTR-00008 (and likely others) land silently:

1. Fallback was count-based, not outcome-based.
   _fireFlowTrigger returned >0 when a broken Flow Template (FT-00005)
   "matched" on_contract_signed but did nothing. We took that as success
   and skipped the built-in install chain. Now we ALWAYS run the built-in
   chain; the idempotency check inside (look up existing Issue linked to
   contract) lets a healthy Flow Template short-circuit us naturally.

2. scheduled_date was null on all chained jobs.
   createDeferredJobs passed '' when no step.scheduled_date was set, and
   the fiber_install template doesn't set one. Jobs with null dates are
   filtered out of most dispatch board views, giving the user-visible
   "Aucune job disponible pour dispatch" symptom even after the chain was
   built. Default to today (via ctx.scheduled_date) so jobs appear on the
   board; dispatcher reschedules per capacity.

3. No post-sign acknowledgment to the customer.
   Previously the Flow Template was expected to send the confirmation SMS;
   since the template was broken, the customer got nothing after signing.
   Add _sendPostSignAcknowledgment that sends a "Bon de commande reçu"
   SMS with contract ref + service details + next steps. Fires only when
   the chain is actually created (not on idempotent skip) so we never
   double-notify.

Also:
- Resolve phone/email from cell_phone + email_billing (legacy-migrated
  Customer records use those fields, not Frappe defaults mobile_no /
  email_id) — otherwise we'd keep skipping SMS with "no phone on file".
- _createBuiltInInstallChain now returns { created, issue, jobs,
  scheduled_date, reason } so callers can branch on outcome.
- Export sendPostSignAcknowledgment so one-shot backfill scripts can
  re-notify customers whose contracts were signed during the broken
  window.
- Set order_source='Contract' (existing Select options patched separately
  to include 'Contract' alongside Manual/Online/Quotation).

Backfilled CTR-00008: ISS-0000250003 + 4 chained Dispatch Jobs all with
scheduled_date=2026-04-23, ack SMS delivered to Louis-Paul's cell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 21:01:51 -04:00
parent aa5921481b
commit 3db1dbae06
2 changed files with 124 additions and 16 deletions

View File

@ -201,7 +201,12 @@ async function createDeferredJobs (steps, ctx, quotationName) {
`Source: ${quotationName}`, `Source: ${quotationName}`,
'Créé automatiquement après acceptation client', 'Créé automatiquement après acceptation client',
].filter(Boolean).join(' | '), ].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 { try {

View File

@ -405,17 +405,33 @@ async function handle (req, res, method, path) {
log(`Contract ${contractName} accepted by ${payload.sub}`) 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. // Fire flow trigger: on_contract_signed — then guarantee tasks exist.
// //
// The Flow Runtime is the "configurable" path: an admin-defined Flow // Two layers:
// Template with trigger_event='on_contract_signed' can fan out Issue + // 1. Flow Runtime (configurable) — an admin-defined Flow Template with
// Dispatch Job chains. That path is optional though — if no template is // trigger_event='on_contract_signed' can fan out Issue + Dispatch Job
// active, dispatchEvent() silently returns [] and the contract ends up // chains however they want.
// with zero downstream tasks (which is exactly what bit us on CTR-00007). // 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 // CRITICAL: we used to skip (2) when (1) ran at least one template, which
// built-in fallback when nothing matched: one Issue (master ticket) + // looked safe BUT silently broke signed contracts if the matched template
// a chained fiber_install project-template (4 jobs, On-Hold gated). // 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. // This runs in the background — the HTTP response returns right away.
;(async () => { ;(async () => {
@ -431,11 +447,33 @@ async function handle (req, res, method, path) {
}) })
const ranCount = Array.isArray(results) ? results.length : 0 const ranCount = Array.isArray(results) ? results.length : 0
if (ranCount > 0) { if (ranCount > 0) {
log(`[contract] ${contractName}: Flow Runtime handled on_contract_signed (${ranCount} template(s))`) log(`[contract] ${contractName}: Flow Runtime dispatched ${ranCount} template(s) — will verify outcome via built-in safety net`)
return } 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) { } catch (e) {
log(`[contract] ${contractName}: on_contract_signed automation failed: ${e.message}`) 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`) 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) { 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`) 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 */ } } catch { /* non-fatal — proceed to create */ }
@ -536,21 +574,85 @@ async function _createBuiltInInstallChain (contractName, payload) {
const steps = getTemplateSteps('fiber_install') const steps = getTemplateSteps('fiber_install')
if (!steps.length) { if (!steps.length) {
log(`[contract] ${contractName}: no fiber_install template — chain aborted`) log(`[contract] ${contractName}: no fiber_install template — chain aborted`)
return return { created: false, issue: issueName, jobs: [], reason: 'no_template' }
} }
const { createDeferredJobs } = require('./acceptance') 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 = { const ctx = {
customer, customer,
service_location: serviceLocation, service_location: serviceLocation,
address, address,
issue: issueName, issue: issueName,
scheduled_date: today,
order_source: 'Contract',
} }
let jobs = []
try { 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)'}`) log(`[contract] ${contractName}: created ${jobs.length} chained Dispatch Job(s) under Issue ${issueName || '(none)'}`)
} catch (e) { } catch (e) {
log(`[contract] ${contractName}: chained job creation failed: ${e.message}`) 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) { 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 // Exposed so ops tools / one-shot scripts can retro-create the install chain
// for contracts that were signed before the built-in fallback existed. // for contracts that were signed before the built-in fallback existed.
createBuiltInInstallChain: _createBuiltInInstallChain, createBuiltInInstallChain: _createBuiltInInstallChain,
sendPostSignAcknowledgment: _sendPostSignAcknowledgment,
} }