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:
- If the individual Supplier Quotation item row carries
em_port_of_loading, that value is used. - If the SQ has other rows that carry
em_port_of_loading(making it a row-level-port quotation), a blank row is stored asNone— 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. - 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_loadingautomatically 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.