Skip to content

Purchase Orders — Technical Reference

Useful links: User guide


Overview

Purchase Orders are managed in the transport Django app (backend/transport/). The main submodule is transport/purchase_orders/.

Key files:

File Purpose
transport/models.py PurchaseOrder, TransportLineItem, PurchaseOrderDocument, PurchaseOrderNote models
transport/constants.py PurchaseOrderStatus, PurchaseOrderActivityType enums
transport/purchase_orders/views.py PurchaseOrderViewSet, PurchaseOrderLineItemsViewSet, PurchaseOrderDocumentViewSet, PurchaseOrderNoteViewSet
transport/purchase_orders/serializers.py All PO serializers
transport/purchase_orders/utils.py get_purchase_orders_by_user, get_purchase_order_activity
transport/utils.py update_purchase_order_status
transport/filters.py PurchaseOrderFilter, LineItemsFilter

Data Models

PurchaseOrder

DB table: purchase_orders

Field Type Notes
id Auto PK
order_number CharField (unique) Generated as PO-{unix_timestamp} on create
status CharField See Statuses
client_reference CharField External reference, optional
buyer FK → support.Company Required to save
vendor FK → support.Supplier Required to save
final_destination FK → support.Company Optional
origin_agent FK → agents.Agent Auto-assigned by routing rules
incoterm FK → support.Incoterm PO-level transport detail
port_of_origin FK → support.Port PO-level transport detail
port_of_destination FK → support.Port PO-level transport detail
transport_mode FK → support.TransportMode PO-level transport detail
packaging_type FK → support.PackagingType PO-level transport detail
payment_term FK → support.PaymentTerm PO-level transport detail
same_transport_details BooleanField If True, all line items inherit PO transport fields
order_tags ArrayField(CharField) Optional tags
remarks ArrayField(CharField) Optional remarks
season CharField Optional
type_of_goods CharField Optional
requested_time_departure DateField Optional
requested_cargo_ready_date DateField Optional
requested_final_destination_arrival_date DateField Optional
requested_arrival_pod DateField Optional
is_deleted BooleanField Soft delete flag
created_at DateTimeField Auto
updated_at DateTimeField Auto
created_by FK → authentication.User Set on create

Audit trail: tracked by pghistory (excludes updated_at).


TransportLineItem

Each line item belongs to one PurchaseOrder via purchase_order FK.

Field Type Notes
id Auto PK
purchase_order FK → PurchaseOrder
item_number CharField Buyer's internal item ID
description CharField Product name
quantity IntegerField Ordered quantity
group CharField Product group
tags ArrayField Optional item tags
incoterm FK → support.Incoterm Item-level if same_transport_details=False
port_of_origin FK → support.Port
port_of_destination FK → support.Port
transport_mode FK → support.TransportMode
packaging_type FK → support.PackagingType
payment_term FK → support.PaymentTerm
origin_agent FK → agents.Agent Auto-assigned by routing
requested_time_departure DateField
requested_cargo_ready_date DateField
requested_final_destination_arrival_date DateField
requested_arrival_pod DateField

Computed annotations (added in get_queryset):

Annotation Description
booking_quantity Sum of quantities reserved in bookings
open_quantity quantity - booking_quantity
shipment_quantity Sum of quantities in shipments

PurchaseOrderDocument

DB table: purchase_order_documents

Field Type Notes
purchase_order FK → PurchaseOrder
file FileField Uploaded file path
name CharField Display name
document_type FK → support.DocumentType
created_at DateTimeField
created_by FK → authentication.User

PurchaseOrderNote

DB table: purchase_order_notes

Field Type Notes
purchase_order FK → PurchaseOrder
text TextField
created_at DateTimeField

On AFTER_CREATE and AFTER_DELETE hooks: updates purchase_order.updated_at.


Statuses

Defined in transport/constants.pyPurchaseOrderStatus.

Value Label Visible in UI
DRAFT Draft No
OPEN Open Yes
PARTIAL Partial Yes
BOOKED Booked Yes
ARCHIVED Archived No (excluded in frontend)

Status is updated automatically by update_purchase_order_status() in transport/utils.py whenever a line item is saved, deleted, or its booking changes.

Transition logic: - → BOOKED: all line items have open_quantity == 0 (fully covered by bookings) - → PARTIAL: some but not all line items are in active bookings - → OPEN: no line items are in active bookings, or open_quantity > 0 on all


API Endpoints

Base path: /api/purchase-orders/

Tag: purchase-orders

Purchase Orders

Method Path Action Description
GET /api/purchase-orders/ list Paginated list, filtered by user role
POST /api/purchase-orders/ create Create new PO (Buyer/Admin/Staff only)
GET /api/purchase-orders/{id}/ retrieve Get single PO
PUT /api/purchase-orders/{id}/ update Full update
PATCH /api/purchase-orders/{id}/ partial_update Partial update
POST /api/purchase-orders/{id}/delete/ delete Soft delete (sets is_deleted=True)
GET /api/purchase-orders/{id}/activity/ activity Activity log
POST /api/purchase-orders/{id}/toggle-favorite/ toggle_favorite Pin / unpin
GET /api/purchase-orders/buyers/ buyers List unique buyers
POST /api/purchase-orders/autocomplete_remarks/ autocomplete_remarks Suggest remarks
POST /api/purchase-orders/autocomplete_order_tags/ autocomplete_order_tags Suggest tags

Line Items

Base path: /api/purchase-orders/{purchase_orders_pk}/items/

Tag: purchase-orders-items

Method Path Action Description
GET .../items/ list List line items for a PO
POST .../items/ create Add a line item
GET .../items/{id}/ retrieve Get single line item
PUT .../items/{id}/ update Update line item
PATCH .../items/{id}/ partial_update Partial update
DELETE .../items/{id}/ destroy Delete (see Delete Logic)
POST .../items/{id}/copy_item/ copy_item Duplicate item
POST .../items/{id}/split_item/ split_item Split into two

Documents

Base path: /api/purchase-orders/{purchase_orders_pk}/documents/

Tag: purchase-orders-documents

Method Path Description
GET .../documents/ List documents
POST .../documents/ Upload document
GET .../documents/{id}/ Get document
DELETE .../documents/{id}/ Delete document

Notes

Base path: /api/purchase-orders/{purchase_orders_pk}/notes/

Method Path Description
POST .../notes/ Create note

Access Control

Access filtering is applied in get_purchase_orders_by_user() (transport/purchase_orders/utils.py).

Role Filter applied is_readonly
ADMIN, STAFF All POs False
BUYER, FULL_CLIENT_USER POs where buyer is their company or linked company False for own, True for linked
CLIENT POs where buyer is linked to their client company True
CONSIGNEE, FINAL_DESTINATION POs with line items in bookings where consignee matches their company True
ORIGIN_AGENT POs where origin_agent matches (when same_transport_details=True) or line items origin_agent matches False
ORIGIN_AGENT_OPERATOR Same as ORIGIN_AGENT True
Other roles Empty queryset

is_readonly is returned in the list/retrieve serializer and used by the frontend to hide edit controls.

Origin Agents are blocked from creating, updating, or deleting POs at the view level (HTTP 403).


Business Logic

Order Number Generation

Generated in CreatePurchaseOrderSerializer.create(): python order_number = f"PO-{int(time.time())}" Unix timestamp in seconds — unique in practice, not guaranteed under concurrent load.


same_transport_details Flag

When same_transport_details=True:

  • On line item create: transport fields are copied from the PO to the new item.
  • On PO update (transport fields changed): all existing line items are updated to match, unless status is PARTIAL or BOOKED.

Constraints: - Cannot switch to True when status is PARTIAL or when any items are booked. - Cannot change same_transport_details at all when status is BOOKED.


Delete Line Item Logic

Implemented in PurchaseOrderLineItemsViewSet.destroy():

  1. If the item has no bookings → hard delete.
  2. If the item has bookings and available_quantity > 0 → reduce quantity to quantity - available_quantity (Open Qty becomes 0). Item stays.
  3. If the item has bookings and available_quantity == 0 → HTTP 400. Cannot delete a fully booked item.

After deletion, update_purchase_order_status() is called to recalculate PO status.


Split Line Item Logic

Implemented in PurchaseOrderLineItemsViewSet.split_item():

  1. Validates that current_quantity >= booked_quantity (cannot reduce below already booked amount).
  2. Validates that the item is not fully booked (booked_quantity < item.quantity).
  3. Sets item.quantity = current_quantity.
  4. Creates a new TransportLineItem with quantity = new_quantity, copying item_number, group, tags, description.
  5. If same_transport_details=True, the new item also inherits PO transport fields.

Routing Auto-Assignment

After a line item is created or its transport_mode / port_of_origin changes, RoutingService(instance).apply_rules() is called. This automatically assigns an origin_agent based on routing rules configured in the routing app.

If the item already has bookings, the origin agent cannot be changed (raises ValidationError), except by Admin/Staff overriding manually.


External Integration (Frends)

On every line item save, FrendsAPIService().post_po_item(instance) is called (integration/services/webhook_service.py). This sends item data to the external Frends integration.


Activity Log

Tracked via pghistory across four event models:

Event model Tracks
transport.PurchaseOrderEvent PO field changes
transport.TransportLineItemEvent Line item changes
transport.PurchaseOrderNoteEvent Note add/remove
transport.PurchaseOrderDocumentEvent Document add/remove

Activity types (PurchaseOrderActivityType):

Type Trigger
PURCHASE_ORDER_CREATED PO insert event
PURCHASE_ORDER_UPDATED PO update diff
PURCHASE_ORDER_LINE_ITEM_CREATED Line item add
PURCHASE_ORDER_LINE_ITEM_UPDATED Line item update diff
PURCHASE_ORDER_LINE_ITEM_REMOVED Line item remove
PURCHASE_ORDER_NOTICE_CREATED Note added
PURCHASE_ORDER_DOCUMENT_CREATED Document uploaded
PURCHASE_ORDER_DOCUMENT_REMOVED Document deleted

Returned by GET /api/purchase-orders/{id}/activity/, ordered newest first.