Procurement - Django Signals
Location: api/nextango/apps/procurement/signals.py
Last Updated: 2026-07-09
Overview
The Procurement signals module implements Django signal receivers for bidirectional synchronization of Supplier and PurchaseOrder models with Sanity CMS. Signals also handle automated inventory and cost updates when purchase orders are received, and keep PurchaseOrder.total_cost in sync with its line items. Multiple layers of loop prevention ensure Django-initiated saves do not re-trigger syncs originating from Sanity webhooks.
Signal Summary
| Signal | Model | Trigger | Purpose |
|---|---|---|---|
sync_supplier_to_sanity | Supplier | post_save | Sync supplier changes to Sanity CMS |
delete_supplier_from_sanity | Supplier | post_delete | Delete supplier document from Sanity |
sync_purchaseorder_to_sanity | PurchaseOrder | post_save | Sync PO changes to Sanity CMS |
delete_purchaseorder_from_sanity | PurchaseOrder | post_delete | Delete PO document from Sanity |
snapshot_po_status | PurchaseOrder | pre_save | Capture pre-save status for transition detection |
update_inventory_on_receiving | PurchaseOrder | post_save | Increment inventory and update average cost on PO receipt |
update_po_total_cost | PurchaseOrderLineItem | post_save, post_delete | Keep PurchaseOrder.total_cost in sync with line items |
Signal Reference
sync_supplier_to_sanity
Trigger: post_save on Supplier
Conditions: Fires on every save unless a loop prevention guard is active.
Purpose: Orchestrates outbound sync of a Supplier to Sanity CMS using SyncOrchestrator. Skips sync when the save originated from a Sanity webhook (_sync_source == 'sanity_webhook'), when a Redis cache key indicates a sync is already in progress, or when LOAD_TESTING_MODE=true is set.
Side Effects:
- Sets a
rapid_save_keycache entry (2 s TTL) to coalesce rapid successive saves - Sets a
django_sync_keycache entry (300 s TTL) while sync is in progress; deletes it in afinallyblock - Calls
SyncOrchestrator.orchestrate_sync_to_sanity()via theprocurement_sync_handler - Logs success or failure at INFO/ERROR level
delete_supplier_from_sanity
Trigger: post_delete on Supplier
Conditions: Only calls Sanity if instance.sanity_id is set.
Purpose: Deletes the corresponding Sanity document when a Supplier is deleted in Django.
Side Effects:
- Calls
SanityAPIClient().delete_document(instance.sanity_id) - Skipped entirely when
LOAD_TESTING_MODE=trueor whenshould_skip_sync_to_sanity()returnsTrue
sync_purchaseorder_to_sanity
Trigger: post_save on PurchaseOrder
Conditions: Same loop prevention guards as sync_supplier_to_sanity.
Purpose: Orchestrates outbound sync of a PurchaseOrder to Sanity CMS.
Side Effects:
- Sets
rapid_save_key(2 s) anddjango_sync_key(300 s) cache entries scoped topurchaseorder:{pk} - Calls
SyncOrchestrator.orchestrate_sync_to_sanity()viaprocurement_sync_handler
delete_purchaseorder_from_sanity
Trigger: post_delete on PurchaseOrder
Conditions: Only calls Sanity if instance.sanity_id is set.
Purpose: Deletes the corresponding Sanity PO document when a PurchaseOrder is deleted in Django.
Side Effects:
- Calls
SanityAPIClient().delete_document(instance.sanity_id)
snapshot_po_status
Trigger: pre_save on PurchaseOrder
Conditions: Only queries the database when instance.pk is set (i.e., not a new record).
Purpose: Reads the current status from the database before the save and stores it on instance._pre_save_status. This allows update_inventory_on_receiving (which fires in post_save) to detect the → Received status transition without a second database hit.
Side Effects:
- Sets
instance._pre_save_statusto the current DB value, orNonefor new records
update_inventory_on_receiving
Trigger: post_save on PurchaseOrder
Conditions: Only fires when status transitions into RECEIVED (i.e., _pre_save_status != 'Received' and new_status == 'Received'). Skipped for new records (created=True).
Purpose: When a PO is marked as received, increments stock_on_hand and stock_lifetime_received on every linked InventoryLevel, recalculates the weighted average cost, creates InventoryMovement audit records, records the received_date on the PO, and logs lead time analytics.
Side Effects:
- For each
PurchaseOrderLineItemwith a linkedInventoryLevel:- Calls
InventoryLevel.objects.filter(pk=...).update(stock_on_hand=F(...)+qty, stock_lifetime_received=F(...)+qty, average_cost=..., last_purchase_cost=..., last_cost_update=now()) - Creates an
InventoryMovementrecord withmovement_type='receiving'
- Calls
received_dateauto-set:PurchaseOrder.objects.filter(pk=...).update(received_date=date.today())is called viaQuerySet.update()to avoid re-triggeringpost_save. The field is set to today's date: no manual entry needed. Ifreceived_datewas set manually on the instance before status transition, the signal will override it.- Lead time analytics logged:
- Actual lead time (days from
created_at.date()to today) is logged at INFO level. - If
expected_dateis set: logs whether the PO arrived early, on time, or late (WARNING if late). - Supplier
lead_time_daysis updated via running average,round((old_lead_time + actual_days) / 2), usingQuerySet.update()to avoid triggering the supplier'spost_savesignal.
- Actual lead time (days from
- Errors for individual line items are caught and logged without aborting the remaining items
update_po_total_cost
Trigger: post_save and post_delete on PurchaseOrderLineItem
Purpose: Recalculates PurchaseOrder.total_cost as the sum of unit_cost × quantity across all line items and updates it via QuerySet.update().
Side Effects:
- Calls
PurchaseOrder.objects.filter(pk=...).update(total_cost=...) - Falls back to
Decimal('0.00')when no line items remain
Related Documentation
- Models: Supplier, PurchaseOrder, PurchaseOrderLineItem
- Tasks:
check_reorder_levelsCelery task - Integrations Signals: Shared sync architecture