Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/purchase-records | List purchase records, filterable by status |
POST | /api/purchase-records | Create a purchase record |
PATCH | /api/purchase-records/{id} | Update a purchase record |
POST | /api/purchase-records/bulk | Bulk upsert purchase records |
Required Headers
| Header | Required | Description |
|---|---|---|
X-User-Id | ✅ on POST / PATCH | ID of the user performing the action (audit log) |
X-API-Key | ✅ all requests | Service authentication key |
GET /api/purchase-records
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_status | string | ❌ | Filter by status: ORDERED, IN_TRANSIT, DELIVERED, CANCELLED. Omit to return all. |
# All purchase recordscurl http://localhost:8080/api/purchase-records \-H "X-API-Key: your-api-key"# Only records currently in transitcurl "http://localhost:8080/api/purchase-records?purchase_status=IN_TRANSIT" \-H "X-API-Key: your-api-key"
const records = await fetch('/api/purchase-records?purchase_status=IN_TRANSIT',{ headers: { 'X-API-Key': 'your-api-key' } }).then(r => r.json());
POST /api/purchase-records
Creates a new purchase record. CO₂ emissions are calculated automatically when transport_mode, distance_km, and weight_kg are all provided. A PurchaseStatusChangeLog entry is created automatically when the status transitions.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
supplier_id | integer | ✅ | FK to Supplier.id |
supplier_product_id | integer | ❌ | FK to SupplierProduct.id |
purchase_date | string (date-time) | ✅ | ISO 8601 datetime e.g. 2024-03-15T10:30:00 (replaces purchase_year) |
quantity_kg | number | ❌ | Quantity in kg |
unit_price | number | ❌ | Price per unit |
currency | string | ❌ | ISO 4217 currency code |
transport_mode | string | ❌ | ROAD, SEA, AIR, RAIL |
distance_km | number | ❌ | Transport distance in km |
weight_kg | number | ❌ | Shipment weight in kg |
purchase_status | string | ❌ | Initial status. Default: ORDERED |
purchase_order_number | string | ❌ | PO reference, max 100 chars |
invoice_number | string | ❌ | Invoice reference, max 100 chars |
ddt_number | string | ❌ | DDT (Documento di Trasporto) delivery note number, max 100 chars |
transaction_certificate_number | string | ❌ | TC number (GOTS/GRS), max 100 chars |
batch_number | string | ❌ | Batch/lot ID for traceability, max 100 chars. Unique per supplier_id. |
delivery_date | string (date-time) | ❌ | Actual delivery datetime |
expected_delivery_date | string (date-time) | ❌ | Expected delivery datetime |
delivery_notes | string | ❌ | Free-text delivery notes, max 2000 chars |
notes | string | ❌ | General free-text notes |
co2_emissions_kg is a calculated field — never include it in the request body. It is computed from transport_mode, distance_km, and weight_kg and returned in the response.
delivery_date must not be before purchase_date. If this constraint is violated the API returns 400 Bad Request.
curl -X POST http://localhost:8080/api/purchase-records \-H "Content-Type: application/json" \-H "X-User-Id: 1" \-H "X-API-Key: your-api-key" \-d '{"supplier_id": 1,"supplier_product_id": 5,"purchase_date": "2025-09-15T09:00:00","quantity_kg": 500.0,"transport_mode": "SEA","distance_km": 8500,"weight_kg": 600,"purchase_order_number": "PO-2025-001","invoice_number": "INV-2025-4521","batch_number": "BATCH-2025-09-A","purchase_status": "ORDERED"}'
const resp = await fetch('/api/purchase-records', {method: 'POST',headers: {'Content-Type': 'application/json','X-User-Id': '1','X-API-Key': 'your-api-key'},body: JSON.stringify({supplier_id: 1,supplier_product_id: 5,purchase_date: '2025-09-15T09:00:00',quantity_kg: 500.0,transport_mode: 'SEA',distance_km: 8500,weight_kg: 600,purchase_order_number: 'PO-2025-001',batch_number: 'BATCH-2025-09-A'})});const record = await resp.json();console.log(`Status: ${record.purchase_status}, CO₂: ${record.co2_emissions_kg} kg`);
import requestsresp = requests.post('http://localhost:8080/api/purchase-records',headers={'X-User-Id': '1', 'X-API-Key': 'your-api-key'},json={'supplier_id': 1,'purchase_date': '2025-09-15T09:00:00','quantity_kg': 500.0,'transport_mode': 'SEA','distance_km': 8500,'weight_kg': 600,'purchase_order_number': 'PO-2025-001',})record = resp.json()print(f"CO₂: {record['co2_emissions_kg']} kg, Status: {record['purchase_status']}")
Response 201 Created
{"id": 101,"supplier_id": 1,"supplier_product_id": 5,"purchase_date": "2025-09-15T09:00:00","quantity_kg": 500.0,"transport_mode": "SEA","distance_km": 8500.0,"weight_kg": 600.0,"co2_emissions_kg": 0.0561,"purchase_order_number": "PO-2025-001","invoice_number": "INV-2025-4521","batch_number": "BATCH-2025-09-A","purchase_status": "ORDERED","delivery_date": null,"expected_delivery_date": null,"delivery_notes": null,"created_at": "2025-09-15T09:12:00","updated_at": "2025-09-15T09:12:00"}
Purchase Status Lifecycle
Every purchase record has a purchase_status field that tracks its lifecycle. Valid transitions:
ORDERED → IN_TRANSIT → DELIVERED↘ ↗CANCELLED ←──
| Status | Meaning |
|---|---|
ORDERED | Purchase placed, awaiting shipment. Default. |
IN_TRANSIT | Shipment dispatched from supplier |
DELIVERED | Goods received at warehouse |
CANCELLED | Order cancelled before or during delivery |
Every status change is automatically recorded in the PurchaseStatusChangeLog table with the changed_by_user_id from the X-User-Id header. This provides a full audit trail.
To update the status, use PATCH /api/purchase-records/{id}:
curl -X PATCH http://localhost:8080/api/purchase-records/101 \-H "Content-Type: application/json" \-H "X-User-Id: 1" \-H "X-API-Key: your-api-key" \-d '{"purchase_status": "IN_TRANSIT","expected_delivery_date": "2025-10-05T08:00:00"}'
Document Tracking Fields
The following reference fields allow matching a purchase record to physical documents:
| Field | JSON Key | Max Length | Example | Notes |
|---|---|---|---|---|
| Purchase Order | purchase_order_number | 100 | PO-2025-001 | Unique per your ERP |
| Invoice | invoice_number | 100 | INV-2025-4521 | Supplier invoice number |
| DDT | ddt_number | 100 | DDT-IT-00123 | Italian delivery note (Documento di Trasporto) |
| Transaction Certificate | transaction_certificate_number | 100 | TC-GOTS-2025-001 | GOTS/GRS certification transaction |
| Batch / Lot | batch_number | 100 | BATCH-2025-09-A | Unique per supplier_id (enforced by DB constraint) |
All five fields share the same validation pattern: must start with an alphanumeric character and can contain letters, digits, spaces, ., /, _, -.
Migration: purchase_year → purchase_date
{"purchase_date": "2025-09-15T09:00:00"}
{"purchase_year": 2025}
If you need to preserve only the year from your existing data, use midnight on January 1st:
2025 → "2025-01-01T00:00:00"
The DB migration V2__replace_purchase_year_with_purchase_date.sql handles the column transformation automatically on deploy.
Emission Factor Reference
| Transport Mode | Emission Factor | Unit |
|---|---|---|
ROAD | 0.000096 | kg CO₂ / (tonne · km) |
SEA | 0.000011 | kg CO₂ / (tonne · km) |
AIR | 0.000602 | kg CO₂ / (tonne · km) |
RAIL | 0.000028 | kg CO₂ / (tonne · km) |
Formula: CO₂ = factor × distance_km × (weight_kg / 1000)
POST /api/purchase-records/bulk
Upserts by the combination of (supplier_id, batch_number) when batch_number is provided, otherwise by (supplier_id, supplier_product_id, purchase_date).
{"items": [{"supplier_id": 1,"supplier_product_id": 5,"purchase_date": "2025-09-15T09:00:00","quantity_kg": 500.0,"purchase_order_number": "PO-2025-001","batch_number": "BATCH-2025-09-A"},{"supplier_id": 2,"purchase_date": "2025-09-15T09:00:00","quantity_kg": 200.0,"transport_mode": "AIR","distance_km": 1200,"weight_kg": 50}]}