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:
parent
aa5921481b
commit
3db1dbae06
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user