Wire fulfillment alerts to Telegram + surface order progress in portal + even out ERPNext sync

Telegram notifications:
- Add shared scripts/workers/telegram_notify.py (send_telegram, notify_fulfillment_todo,
  create_admin_todo) so every worker alerts the operator the same way; fire-and-forget.
- Fire notify_fulfillment_todo after each admin_todos insert across all 8 service
  handlers (9 sites) so no fulfillment task waits unseen.
  (Orders + quotes + tickets already notified via checkout/quotes/tickets routes.)

Client portal order progress:
- order-timeline: derive real per-step status from live signals (payment paid,
  e-signature signed, fulfillment_status) instead of a static template; add
  current_step to the response.
- Extract pure applyLiveStatus into order-timeline-status.ts (DB-free) + unit test
  (api/test/test_timeline_status.ts, 8 cases).
- portal /me now returns compliance_orders.fulfillment_status.
- Dashboard renders a client-safe Progress badge (In progress / Action needed /
  Filed-awaiting-confirmation / Completed); batches show the most actionable status.
  No back-office mechanics exposed.

ERPNext sync parity:
- Create a Sales Order for formation and fcc_carrier_registration orders (previously
  only canada_crtc + compliance synced); write erpnext_sales_order back to each table.
  Non-blocking, matches existing pattern.

Verified: API tsc clean, timeline unit tests 8/8, Astro build 58 pages,
cms10114/ink/paper_batch Python tests still green, no mechanics leaks.
This commit is contained in:
justin 2026-06-07 03:17:46 -05:00
parent 41df4d9553
commit 28b1af341d
15 changed files with 706 additions and 73 deletions

View file

@ -165,6 +165,50 @@
return '<span style="display:inline-block;padding:0.2rem 0.65rem;border-radius:9999px;font-size:0.7rem;font-weight:600;background:' + c.bg + ';color:' + c.text + ';">' + c.label + '</span>';
}
// Client-safe progress label derived from the order's fulfillment_status.
// We translate internal lifecycle values into plain-language stages and
// deliberately avoid exposing any back-office mechanics.
function fulfillmentBadge(status) {
if (!status) return '';
var map = {
authorization_required: { bg: '#fef9c3', text: '#a16207', label: 'Action needed: signature' },
awaiting_customer_delegation: { bg: '#fef9c3', text: '#a16207', label: 'Action needed' },
awaiting_secure_credentials: { bg: '#fef9c3', text: '#a16207', label: 'Action needed' },
awaiting_government_fee_approval: { bg: '#fef9c3', text: '#a16207', label: 'Action needed: approval' },
authorization_signed: { bg: '#dbeafe', text: '#1d4ed8', label: 'In progress' },
ready_to_file: { bg: '#dbeafe', text: '#1d4ed8', label: 'In progress' },
awaiting_insurance_filing: { bg: '#dbeafe', text: '#1d4ed8', label: 'Awaiting insurance' },
filed_waiting_state: { bg: '#dbeafe', text: '#1d4ed8', label: 'Filed — awaiting confirmation' },
completed: { bg: '#dcfce7', text: '#15803d', label: 'Completed' }
};
var c = map[status];
if (!c) return '';
return '<span style="display:inline-block;padding:0.2rem 0.65rem;border-radius:9999px;font-size:0.7rem;font-weight:600;background:' + c.bg + ';color:' + c.text + ';">' + c.label + '</span>';
}
// For a batch, show the single most actionable status: anything needing the
// client's attention wins, then in-progress, then completed.
function pickBatchFulfillment(items) {
var actionNeeded = [
'authorization_required', 'awaiting_customer_delegation',
'awaiting_secure_credentials', 'awaiting_government_fee_approval'
];
var inProgress = [
'authorization_signed', 'ready_to_file',
'awaiting_insurance_filing', 'filed_waiting_state'
];
var statuses = items.map(function(o) { return o.fulfillment_status; });
for (var i = 0; i < statuses.length; i++) {
if (actionNeeded.indexOf(statuses[i]) !== -1) return statuses[i];
}
for (var j = 0; j < statuses.length; j++) {
if (inProgress.indexOf(statuses[j]) !== -1) return statuses[j];
}
// All completed?
var allDone = statuses.length > 0 && statuses.every(function(s) { return s === 'completed'; });
return allDone ? 'completed' : (statuses[0] || null);
}
function paymentIcon(method) {
if (!method) return '';
var m = method.toLowerCase();
@ -261,6 +305,8 @@
totalCents += (o.service_fee_cents || 0) - (o.discount_cents || 0) + (o.surcharge_cents || 0);
if (names.indexOf(o.service_name) === -1) names.push(o.service_name);
});
// Surface the most actionable fulfillment status across the batch.
var batchFulfillment = pickBatchFulfillment(items);
html += '<div style="background:#fff;border:1px solid #e5e7eb;border-radius:0.75rem;padding:1.25rem;transition:box-shadow 0.2s;" onmouseover="this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.08)\'" onmouseout="this.style.boxShadow=\'none\'">';
html += '<div style="display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:0.75rem;">';
@ -279,7 +325,15 @@
html += '<span style="font-weight:700;color:#111827;font-size:1rem;">' + formatCents(totalCents) + '</span>';
html += statusBadge(first.payment_status);
html += '</div>';
html += '</div></div>';
html += '</div>';
// Fulfillment progress row (only when we have a status to show)
if (fulfillmentBadge(batchFulfillment)) {
html += '<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid #f3f4f6;display:flex;align-items:center;gap:0.5rem;">';
html += '<span style="color:#9ca3af;font-size:0.75rem;">Progress:</span>';
html += fulfillmentBadge(batchFulfillment);
html += '</div>';
}
html += '</div>';
});
// Render standalone orders
@ -301,7 +355,15 @@
html += '<span style="font-weight:700;color:#111827;font-size:1rem;">' + formatCents(totalCents) + '</span>';
html += statusBadge(o.payment_status);
html += '</div>';
html += '</div></div>';
html += '</div>';
// Fulfillment progress row (only when we have a status to show)
if (fulfillmentBadge(o.fulfillment_status)) {
html += '<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid #f3f4f6;display:flex;align-items:center;gap:0.5rem;">';
html += '<span style="color:#9ca3af;font-size:0.75rem;">Progress:</span>';
html += fulfillmentBadge(o.fulfillment_status);
html += '</div>';
}
html += '</div>';
});
$grid.innerHTML = html;