Wiki

Wiki

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Ports & Shipping Lines

Ports & Shipping Lines

Section: Setup


Overview

Two thin reference doctypes underpin all routing in the system: Port and Shipping Line. Neither carries business logic — both controller classes are bare Document subclasses with no overrides. Their value is as shared lookup targets: the same Port record is linked from costings, RFQs, combinations, raw rates, and shipments, so a port rename or country correction propagates everywhere without data-patching.


Port

DocType: Port
Module: Export Management
Naming: by port_name field (unique, auto-named)
Quick Entry: enabled

Fields

Field Fieldname Type Required Notes
Port Name port_name Data Yes The display name and document key. Must be unique.
Port Code port_code Data No UN/LOCODE or internal shorthand (e.g. BJANB for Cotonou). Not validated by the system — informational only.
Country country Link → Country No Ties the port to Frappe's built-in Country master.

Where it appears

Document Field(s)
Costing Template port_of_discharge
Export Costing port_of_loading, port_of_discharge
Request for Quotation em_port_of_loading, em_port_of_discharge (custom fields)
Supplier Quotation Item em_port_of_loading (custom field, per-row)
Export Costing Raw Rate port_of_loading, port_of_discharge
Export Cost Combination port_of_loading, port_of_discharge
Export Cost Combination Line port_of_loading, port_of_discharge
Export Shipment port_of_loading, port_of_discharge

Shipping Line

DocType: Shipping Line
Module: Export Management
Naming: by line_name field (unique, auto-named)
Quick Entry: enabled

Fields

Field Fieldname Type Required Notes
Shipping Line Name line_name Data Yes The display name and document key. Must be unique.
Website website Data (URL) No Carrier booking or tracking portal URL.

Where it appears

Document Field
Export Shipment shipping_line

Shipping Line does not appear on costings or combinations. It is recorded on the Shipment only after a carrier is confirmed — it plays no role in the combination engine's rate selection.


Why the loading port is not fixed by the costing template

The Costing Template pins port_of_discharge (the buyer's destination port is known from the contract) but deliberately omits port_of_loading. The reason is that at the time a template is created, the cheapest origin port is unknown — that decision depends on which vendor offers the best freight rate out of which port on which day.

The combination engine resolves this at rate-comparison time. It collects every distinct port_of_loading value from the costing's raw rates (via origin_ports() in combination_engine.py) and runs the full landed-cost calculation once per port, producing a separate combination family for each origin. The user then selects whichever combination is cheapest across both supplier and port.

The costing's own port_of_loading field therefore acts as a seed / fallback: it is included in the candidate port list in case no raw rate carries an explicit port, but it does not restrict which ports the engine considers.

# combination_engine.py — origin_ports()
def origin_ports(self) -> list:
    ports = sorted({row.port_of_loading for row in self.raw_rates if row.port_of_loading})
    if self.port_of_loading and self.port_of_loading not in ports:
        ports.append(self.port_of_loading)
    # every rate is port-agnostic — the single combination space uses the parent port
    return ports or [self.port_of_loading]

The blank-port wildcard rule

A vendor rate with a blank port_of_loading is treated as an ex-works / any-port offer — it is compatible with every origin port the engine considers. The rule is encoded in two places:

matches_route — the general predicate:

# combination_engine.py
def matches_route(rate_value: str | None, costing_value: str | None) -> bool:
    # blank-is-wildcard: an unset value on either side matches anything
    return not rate_value or not costing_value or rate_value == costing_value

A blank on either side returns True. This is applied to port_of_discharge and container_type during group_rates_by_head() — if the rate carries no discharge port it participates in any discharge-port costing; likewise for container type.

compatible_rates_for_port — the per-port filter applied during combination building:

# combination_engine.py
def compatible_rates_for_port(port, variable_heads, rates_by_head) -> list | None:
    rates_per_head = []
    for head in variable_heads:
        compatible = [
            rate for rate in rates_by_head[head]
            if not rate.port_of_loading or rate.port_of_loading == port
        ]
        if not compatible:
            return None
        rates_per_head.append(compatible)
    return rates_per_head

A rate row with port_of_loading = "" passes the filter for every port. A rate scoped to Nhava Sheva will only appear in the Nhava Sheva combination family.

What is NOT a wildcard

The symmetry breaks for discharge port and container type. Those two dimensions are fixed per costing — there is only one destination and one container size per deal. A rate that names a different discharge port or container type is excluded entirely in group_rates_by_head() before combination building even starts:

# combination_engine.py — group_rates_by_head()
if not matches_route(row.port_of_discharge, self.port_of_discharge):
    continue
if not matches_route(row.container_type, self.container_type):
    continue

Gotcha: a vendor who quotes a rate for 20' containers only will never appear in a 40' costing, even if their freight price is lower. Suppliers must quote the correct container type, or leave it blank to signal "any container."


How port flows from RFQ to raw rate

When the RFQ is sent, it carries em_port_of_loading and em_port_of_discharge as custom fields. The combination engine reads these during pull_vendor_rates() to stamp each raw rate row via route_source_for() and resolve_row_port().

Route source resolution (route_source_for): If the Supplier Quotation has any of em_port_of_loading, em_port_of_discharge, or em_container_type set on its header, it is used as the route source — the SQ's own route takes precedence over the originating RFQ. Otherwise the engine falls back to the RFQ linked via the item row's request_for_quotation field (or the SQ itself if there is no RFQ link).

Per-row port resolution (resolve_row_port): Once a route source is established, the per-row port is resolved in priority order:

  1. If the individual Supplier Quotation item row carries em_port_of_loading, that value is used.
  2. If the SQ has other rows that carry em_port_of_loading (making it a row-level-port quotation), a blank row is stored as None — a true wildcard — rather than inheriting from the SQ header or RFQ. This prevents falsely scoping what the supplier intended as an any-port offer.
  3. If the SQ uses no row-level ports at all, the engine falls back to the route source's em_port_of_loading.

Summary

  • Create one Port record per physical port. The name is the link key used everywhere; the code field is a convenience label only.
  • Create one Shipping Line record per carrier. It is referenced only on the Shipment, not during costing or quoting.
  • The loading port is intentionally deferred: the combination engine shops across every port present in the raw rates, so a single costing can compare Nhava Sheva vs. Mundra freight in the same run without duplicating the document.
  • A vendor rate with no port_of_loading automatically participates in every port family — use this for door-to-port or ex-works offers where the origin is genuinely flexible.
  • Discharge port and container type on a raw rate are hard filters, not wildcards from the costing's perspective: a rate scoped to a different discharge port or container type is silently excluded from all combinations.
Last updated 3 months ago
Was this helpful?
Thanks!