Skip to content

Quotation System — Developer Guide

This guide covers the iDrv5-MyFR8 quotation system end-to-end: schema, API endpoints, rate computation, email workflow, and integration patterns for all portals (Operations, Customer, Public Widget).

This guide covers the iDrv5-MyFR8 quotation system end-to-end: schema, API endpoints, rate computation, email workflow, and integration patterns for all portals (Operations, Customer, Public Widget).


┌─────────────────────────────────────────────────────────────────────┐
│ QUOTE SOURCES │
│ │
│ Operations Portal Customer Portal Public Widget REST API │
│ (source: OPERATIONS) (source: CUSTOMER) (source: PUBLIC) (source: API/SYSTEM)│
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ POST /api/quotes/ │ │
│ │ (Primary insertion endpoint) │ │
│ └──────────────────────────┬───────────────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ unified_quotes table │ │
│ │ (Single source of truth) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

All quotes from all sources flow into the unified_quotes table via POST /api/quotes/.


ColumnTypeNullableDefaultDescription
idSERIALNOT NULLautoPrimary key
quote_referenceVARCHAR(50)NOT NULLUnique reference (e.g., OQ-20260408-0001)
source_portalVARCHAR(20)NOT NULLSYSTEMOrigin of the quote (see Source Portals)
statusVARCHAR(20)PENDINGWorkflow state (see Status Lifecycle)
status_notesTEXTReason for rejection/decline
email_sent_atTIMESTAMPNULLWhen last email was sent
ColumnTypeDescription
customer_idINTEGERFK to customers.id (optional for public submissions)
customer_nameVARCHAR(255)Display name
customer_emailVARCHAR(255)Email for notifications and Accept/Decline flow
customer_phoneVARCHAR(50)Contact phone
company_nameVARCHAR(255)Company name
company_abnVARCHAR(20)ABN (Australia)
reference_numberVARCHAR(100)Customer’s own reference
ColumnTypeDescription
origin_locationVARCHAR(255)REQUIRED. Origin display name (suburb used as fallback)
destination_locationVARCHAR(255)REQUIRED. Destination display name
origin_addressTEXTStreet address
origin_suburbVARCHAR(100)Suburb (used for zone lookup)
origin_stateVARCHAR(20)State code (e.g., NSW, VIC)
origin_postcodeVARCHAR(10)Postcode
destination_addressTEXTStreet address
destination_suburbVARCHAR(100)Suburb
destination_stateVARCHAR(20)State code
destination_postcodeVARCHAR(10)Postcode
ColumnTypeDefaultDescription
freight_typeVARCHAR(50)STANDARDSTANDARD, PALLET, FTL
job_typeVARCHAR(20)LTLLTL, FTL, Towhaul
weight_kgNUMERIC(10,2)0Total weight in kg
volume_m3NUMERIC(10,3)0Total volume
quantityINTEGER1Number of items
packaging_typeVARCHAR(50)Pallet, Carton, etc.
length_cmNUMERIC(8,2)0Item dimensions
width_cmNUMERIC(8,2)0
height_cmNUMERIC(8,2)0
ColumnTypeDescription
base_rateNUMERIC(10,2)Base freight rate
fuel_surchargeNUMERIC(10,2)Fuel levy amount
additional_chargesNUMERIC(10,2)Other surcharges
gst_amountNUMERIC(10,2)GST (10% in AU)
total_amountNUMERIC(10,2)Grand total (inclusive of all)
currencyVARCHAR(3)Default AUD
ColumnTypeDefaultDescription
dangerous_goodsBOOLEANfalseDG shipment
dangerous_goods_classVARCHAR(20)DG class
tailgate_pickupBOOLEANfalseTail lift at pickup
tailgate_deliveryBOOLEANfalseTail lift at delivery
express_deliveryBOOLEANfalseExpress service
insurance_requiredBOOLEANfalseFreight insurance
pickup_dateDATERequested pickup date
delivery_dateDATERequested delivery date
expiry_dateDATENOW + 30 daysQuote validity expiry

Multi-Rate Storage — special_requirements

Section titled “Multi-Rate Storage — special_requirements”

When multiple service levels are quoted, the special_requirements TEXT column stores both user notes AND a JSON blob of rate options:

User's notes text here
__SERVICE_LEVEL_QUOTES__[{"service":"Express","base":22.5,"fuel":5.06,"surcharges":2.76,"gst":2.76,"total":64.07},{"service":"Standard","base":15.0,"fuel":3.38,"surcharges":1.84,"gst":1.84,"total":42.71}]

Parsing pattern (Python):

marker = '__SERVICE_LEVEL_QUOTES__'
idx = special_requirements.find(marker)
notes = special_requirements[:idx].strip() if idx >= 0 else special_requirements
sl_quotes = json.loads(special_requirements[idx + len(marker):]) if idx >= 0 else []

Parsing pattern (JavaScript):

const marker = '__SERVICE_LEVEL_QUOTES__';
const idx = raw.indexOf(marker);
const notes = idx >= 0 ? raw.slice(0, idx).trim() : raw;
const slQuotes = idx >= 0 ? JSON.parse(raw.slice(idx + marker.length)) : [];
ColumnTypeDescription
additional_dataJSONBFlexible key-value storage for custom fields
template_idINTEGERFK to quote_templates.id
created_byINTEGERUser ID who created the quote
updated_byINTEGERUser ID who last updated
created_atTIMESTAMPTZAuto-set on INSERT
updated_atTIMESTAMPTZAuto-set on UPDATE (via trigger)
-- Source portal must be one of:
chk_source_portal: CUSTOMER | OPERATIONS | PUBLIC | SYSTEM | API
-- Status must be one of:
chk_status: DRAFT | PENDING | QUOTED | APPROVED | REJECTED | ACCEPTED | DECLINED | EXPIRED | CONVERTED
PRIMARY KEY (id)
UNIQUE (quote_reference)
INDEX (created_at)
INDEX (customer_id)
INDEX (email_sent_at)
INDEX (quote_reference)
INDEX (origin_suburb, destination_suburb)
INDEX (source_portal)
INDEX (status)

ValueWho CreatesInitial StatusDescription
OPERATIONSOperations staffDRAFT or QUOTEDInternal quotes sent to customers
CUSTOMERLogged-in customerPENDINGCustomer portal submissions
PUBLICPublic widget (Osiris)PENDINGUnauthenticated widget submissions
SYSTEMAutomated/defaultPENDINGSystem-generated quotes
APIThird-party API integrationPENDINGExternal API submissions

Quote reference prefixes are derived from the first letter of source_portal:

  • OPERATIONSOQ-20260408-0001
  • CUSTOMERCQ-...
  • PUBLICPQ-...
  • SYSTEMSQ-...
  • APIAQ-...

Operations-Created Quotes (source_portal = OPERATIONS)

Section titled “Operations-Created Quotes (source_portal = OPERATIONS)”
DRAFT ──[Send to Customer]──> QUOTED ──[Customer Accepts]──> ACCEPTED
└──[Customer Declines]──> DECLINED
└──[Expires]────────────> EXPIRED
  • DRAFT: Saved but not sent. Operations can edit and send later.
  • QUOTED: Sent to customer via email with secure Accept/Decline links.
  • ACCEPTED: Customer accepted via email token link.
  • DECLINED: Customer declined (with optional comment in status_notes).

Customer/Widget/API Quotes (source_portal != OPERATIONS)

Section titled “Customer/Widget/API Quotes (source_portal != OPERATIONS)”
PENDING ──[Ops Approves]──> APPROVED
└──[Ops Rejects]──> REJECTED
  • PENDING: Awaiting operations review.
  • APPROVED: Operations approved the customer’s request.
  • REJECTED: Operations rejected (reason in status_notes).
StatusCan Transition ToDescription
ACCEPTEDCONVERTEDCustomer accepted; can be converted to booking
APPROVEDCONVERTEDOps approved; can be converted to booking
CONVERTEDBooking created from this quote
EXPIREDPast expiry date
REJECTEDOps rejected
DECLINEDCustomer declined

Authentication: None required (supports public submissions).

Minimum required fields:

{
"origin_location": "Melbourne",
"destination_location": "Brisbane"
}

Full payload (all optional fields):

{
"customer_id": 1,
"customer_name": "Auz Pack Pty Ltd",
"customer_email": "john@auzpack.com.au",
"customer_phone": "+61 400 123 456",
"company_name": "Auz Pack Pty Ltd",
"origin_location": "Melbourne",
"origin_address": "570 Bourke Street",
"origin_suburb": "Melbourne",
"origin_state": "VIC",
"origin_postcode": "3000",
"destination_location": "Brisbane City",
"destination_address": "340 Queen Street",
"destination_suburb": "Brisbane City",
"destination_state": "QLD",
"destination_postcode": "4000",
"job_type": "LTL",
"freight_type": "PALLET",
"weight_kg": 150.00,
"quantity": 2,
"packaging_type": "Pallet",
"length_cm": 120,
"width_cm": 120,
"height_cm": 150,
"base_rate": 22.50,
"fuel_surcharge": 5.06,
"additional_charges": 2.76,
"gst_amount": 2.76,
"total_amount": 64.07,
"source_portal": "PUBLIC",
"status": "PENDING",
"special_requirements": "Fragile items\n\n__SERVICE_LEVEL_QUOTES__[{...}]"
}

Behavior by status and source_portal:

statussource_portalEmail SentNotes
DRAFTOPERATIONSNoneSaved only, no notification
QUOTEDOPERATIONSQuote sent to customer with Accept/Decline links
PENDINGCUSTOMER / PUBLIC / APIConfirmation email to customer

Response:

{
"success": true,
"message": "Quote created and sent",
"data": {
"id": 42,
"quote_reference": "OQ-20260408-0001"
}
}

Query parameters: status, customer_id, search, limit (default 100), offset (default 0)

PUT /api/quotes/<id>/status — Update Status

Section titled “PUT /api/quotes/<id>/status — Update Status”
{
"status": "APPROVED",
"status_notes": "Approved by Sarah — customer is priority account"
}

POST /api/quotes/<id>/send-to-customer — Send Draft to Customer

Section titled “POST /api/quotes/<id>/send-to-customer — Send Draft to Customer”

Changes DRAFTQUOTED and sends email with Accept/Decline links.

POST /api/quotes/<id>/send-email — Resend Email

Section titled “POST /api/quotes/<id>/send-email — Resend Email”

Sends appropriate email based on current status and source_portal.

GET /api/quotes/statistics — Status Counts

Section titled “GET /api/quotes/statistics — Status Counts”
{
"success": true,
"data": {
"DRAFT": 2, "PENDING": 5, "QUOTED": 3, "ACCEPTED": 1,
"APPROVED": 10, "DECLINED": 0, "REJECTED": 2, "total": 23
}
}

6. Rate Computation — POST /api/rate-entries/compute-rate

Section titled “6. Rate Computation — POST /api/rate-entries/compute-rate”

This is the rate calculation engine. It does NOT save anything — it returns a pricing breakdown that you store in the quote payload.

{
"customer_id": 1,
"pickup_zone_id": null,
"delivery_zone_id": null,
"pickup_postcode": "3000",
"delivery_postcode": "4000",
"pickup_suburb": "Melbourne",
"delivery_suburb": "Brisbane City",
"service_level_id": 1,
"items": [
{
"packaging_type": "Pallet",
"quantity": 2,
"length_cm": 120,
"width_cm": 120,
"height_cm": 150,
"weight_kg": 75
}
],
"chargeable_weight_kg": 150,
"job_type": "LTL"
}

Key fields:

  • customer_idREQUIRED. Used to find customer-specific rate cards.
  • pickup_zone_id / delivery_zone_id — If not provided, resolved automatically from pickup_postcode or pickup_suburb.
  • service_level_id — Required to apply service-level multipliers (Express, Standard, Economy, etc.).
  • items — Array of cargo items. Fields: packaging_type, quantity, length_cm, width_cm, height_cm, weight_kg.
{
"success": true,
"found": true,
"computation": {
"rate_card_name": "JATT Per KG Rates",
"rate_card_id": 5,
"rate_entry_id": 123,
"customer_specific": true,
"service_level": {
"name": "Express",
"multiplier": 1.5,
"is_override": false
},
"initial_cost": 22.50,
"totals": {
"initial_cost": 22.50,
"base_charge": 0.57,
"flat_rate_charge": 0,
"subtotal": 56.25,
"minimum_rate": 56.25,
"minimum_applied": true,
"final_total": 56.25
},
"addons": {
"addons": [
{ "name": "Fuel Levy", "amount": 5.06, "is_tax_addon": false },
{ "name": "GST", "amount": 2.76, "is_tax_addon": true }
],
"total_addon_amount": 7.82
},
"calculation_steps": ["..."]
}
}
const comp = response.computation;
const totals = comp.totals;
let fuelLevy = 0, gst = 0, surcharges = 0;
if (comp.addons && comp.addons.addons) {
comp.addons.addons.forEach(a => {
if (a.is_tax_addon) gst += a.amount;
else if (/fuel/i.test(a.name)) fuelLevy += a.amount;
else surcharges += a.amount;
});
}
const grandTotal = totals.final_total + fuelLevy + surcharges + gst;

To get rates for all service levels, call compute-rate once per service level:

const serviceLevels = [
{ id: 1, name: 'Express' },
{ id: 2, name: 'Hot Shot' },
{ id: 3, name: 'Standard' },
{ id: 4, name: 'Economy' }
];
const promises = serviceLevels.map(sl => {
const payload = { ...basePayload, service_level_id: sl.id };
return fetch('/api/rate-entries/compute-rate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(r => r.json());
});
const results = await Promise.all(promises);

7. Zone Lookup — POST /api/rate-entries/check-zone

Section titled “7. Zone Lookup — POST /api/rate-entries/check-zone”

Before calling compute-rate, resolve suburb/postcode to a zone ID:

// Request
{ "suburb": "Melbourne", "state": "VIC", "postcode": "3000" }
// Response
{ "success": true, "zone_id": 5, "zone_name": "Melbourne" }

The compute-rate endpoint can also resolve zones automatically from pickup_postcode/pickup_suburb, but explicit zone resolution is faster for UI feedback.


utils/email_service.py — SMTP EmailService (Mailosaur sandbox / production SMTP)
utils/quote_email_service.py — Quote-specific orchestration (prepare data, send, update DB)
templates/mail/*.py — Python email templates (get_html_content / get_text_content)
Template FileWhen SentRecipient
quote_confirmation.pyCustomer/Public quote createdCustomer
quote_sent.pyOps sends quote to customerCustomer (with Accept/Decline links)
quote_accepted.pyCustomer acceptsCustomer
quote_declined.pyCustomer declinesCustomer
quote_approved.pyOps approves customer quoteCustomer (with PDF)
quote_rejected.pyOps rejects customer quoteCustomer
quote_ops_notification.pyCustomer accepts/declinesOperations

Default config points to Mailosaur — no real emails sent:

MAIL_HOST=mailosaur.net
MAIL_PORT=2525
MAIL_USERNAME=dx4p4hhk@mailosaur.net
MAIL_PASSWORD=biXvRAo4

Auto-detected by EmailService._detect_mailosaur(). For production, swap to real SMTP credentials.

Customer Accept/Decline Flow (Token-Based)

Section titled “Customer Accept/Decline Flow (Token-Based)”

Tokens are generated using Flask’s itsdangerous.URLSafeTimedSerializer:

from utils.quote_tokens import generate_response_url
accept_url = generate_response_url(quote_id, 'accept')
# → http://localhost:5002/quotes/respond?token=SIGNED_TOKEN&action=accept

The /quotes/respond route (in modules/quote_response/routes.py) is public — no login required. Tokens expire after 30 days (matching quote expiry).


9. Integration Guide — Public Widget (Osiris Pattern)

Section titled “9. Integration Guide — Public Widget (Osiris Pattern)”
// POST /api/rate-entries/compute-rate
const response = await fetch('/api/rate-entries/compute-rate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: null, // null for public quotes (uses global rate cards)
pickup_suburb: 'Sydney',
pickup_postcode: '2000',
delivery_suburb: 'Melbourne',
delivery_postcode: '3000',
service_level_id: 3, // Standard
items: [{
packaging_type: 'Pallet',
quantity: 1,
length_cm: 120, width_cm: 120, height_cm: 150,
weight_kg: 100
}],
job_type: 'LTL'
})
});
// POST /api/quotes/
const quoteResponse = await fetch('/api/quotes/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Required
origin_location: 'Sydney',
destination_location: 'Melbourne',
// Source identification
source_portal: 'PUBLIC',
status: 'PENDING',
// Customer (collected from widget form)
customer_name: 'John Doe',
customer_email: 'john@example.com',
customer_phone: '+61 400 123 456',
company_name: 'Acme Corp',
// Route
origin_suburb: 'Sydney CBD', origin_state: 'NSW', origin_postcode: '2000',
destination_suburb: 'Melbourne CBD', destination_state: 'VIC', destination_postcode: '3000',
// Cargo
job_type: 'LTL',
freight_type: 'PALLET',
quantity: 1,
weight_kg: 100,
packaging_type: 'Pallet',
length_cm: 120, width_cm: 120, height_cm: 150,
// Pricing (from compute-rate response)
base_rate: 15.00,
fuel_surcharge: 3.38,
additional_charges: 1.84,
gst_amount: 1.84,
total_amount: 42.71,
// Multi-rate (optional, stored in special_requirements)
special_requirements: '__SERVICE_LEVEL_QUOTES__' + JSON.stringify([
{ service: 'Express', base: 22.50, fuel: 5.06, surcharges: 2.76, gst: 2.76, total: 64.07 },
{ service: 'Standard', base: 15.00, fuel: 3.38, surcharges: 1.84, gst: 1.84, total: 42.71 }
])
})
});

Step 3: Quote Appears in Operations Portal

Section titled “Step 3: Quote Appears in Operations Portal”

The quote will appear in /operations/quotes with status PENDING and the Approve / Reject buttons visible to operations staff.


Same pattern as Public Widget, but:

  • source_portal: 'CUSTOMER'
  • customer_id set from the logged-in user’s associated customer record
  • Authentication required (session-based)

FilePurpose
api/quotes_api.pyAll quote CRUD, status updates, email triggers
api/rate_entries_api.pyRate computation engine (compute-rate)
FilePurpose
utils/email_service.pyCore SMTP service (Mailosaur sandbox / production)
utils/quote_email_service.pyQuote-specific email orchestration
utils/quote_tokens.pySigned token generation for Accept/Decline links
templates/mail/*.pyPython email templates (HTML + plain text)
FilePurpose
modules/quote_response/routes.pyPublic Accept/Decline page (no login)
templates/quote_response/respond.htmlStandalone response page
FilePurpose
portals/operations/routes.pyQuote list, PDF generation
templates/portals/operations/simple_quote_form.htmlCreate Quote form (Save Draft / Send Quote)
templates/portals/operations/quotes_list.htmlQuotes list with drawer, actions, email status
templates/portals/operations/quote_pdf.htmlPrint-ready PDF template
FilePurpose
.env.devDevelopment config (Mailosaur defaults)
.env.exampleTemplate for all env vars

Terminal window
# SMTP Email (defaults to Mailosaur sandbox)
MAIL_HOST=mailosaur.net
MAIL_PORT=2525
MAIL_USERNAME=dx4p4hhk@mailosaur.net
MAIL_PASSWORD=biXvRAo4
MAIL_ENCRYPTION= # 'tls', 'ssl', or blank
MAIL_FROM_ADDRESS=no-reply@myfr8.com.au
MAILOSAUR_API_KEY=biXvRAo4
MAILOSAUR_SERVER_ID=dx4p4hhk
# Operations
OPERATIONS_EMAIL=operations@myfr8.com.au
COMPANY_NAME=MyFR8 Logistics
# Token signing (uses Flask's session secret)
SESSION_SECRET=your-secret-key

Run these against your database if setting up fresh:

Terminal window
# Add email tracking column
psql $DATABASE_URL -f migrations/add_email_sent_at_to_unified_quotes.sql
# If CHECK constraints need updating (DRAFT, ACCEPTED, DECLINED)
psql $DATABASE_URL -c "
ALTER TABLE unified_quotes DROP CONSTRAINT IF EXISTS chk_status;
ALTER TABLE unified_quotes ADD CONSTRAINT chk_status CHECK (
status IN ('DRAFT','PENDING','QUOTED','APPROVED','REJECTED','ACCEPTED','DECLINED','EXPIRED','CONVERTED')
);
"