Wiki

Wiki

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

Pulling Vendor Rates

Pulling Vendor Rates

Section: Vendor Rates & Combinations


Overview

Pulling Vendor Rates screenshot

"Pull Vendor Rates" is the action on an Export Costing that snapshots submitted Supplier Quotation (SQ) rates into the raw_rates child table (Export Costing Raw Rate). It is the bridge between the procurement layer (RFQs → SQs) and the combination engine that computes landed-cost scenarios.

The snapshot is intentionally a copy, not a live join. Once pulled, the rest of the pipeline — combination generation, selection, quoting — reads only the raw_rates table and never touches the source SQs again. This keeps audit trails intact even if a supplier later amends their quotation.

Entry point: CombinationEngineMixin.pull_vendor_rates in combination_engine.py.
Button location: Export Costing form → Combinations tab → "Pull Vendor Rates".
Return value: Integer count of raw-rate rows currently in the table after the pull (including any frozen rows that survived).


Prerequisites

The costing must have at least one row in its charges child table before the pull is attempted. The engine derives the set of charge heads to look for from {row.charge_item for row in self.charges}. If the charges table is empty, frappe.throw fires immediately:

"Add charge rows first — rates are pulled for the charge heads on them."

Similarly, if no submitted SQs are found after resolving both sourcing paths, the pull aborts:

"No submitted Supplier Quotations found for this costing — send the RFQ first."


The Two Sourcing Paths

Supplier Quotations reach the costing through two distinct mechanisms. The engine resolves both and takes the union before loading anything. The combined list is deduplicated and sorted: sorted(set(direct_names) | set(portal_names)).

Path 1 — Direct / Desk-Entered SQs (export_costing custom field)

Any submitted SQ that carries the costing's name in its custom export_costing field is included automatically. This covers:

  • SQs created manually on the desk by a procurement officer, who types the costing name into the field.
  • SQs that ERPNext created from an RFQ and where the RFQ-to-SQ flow copied the field across (only happens when the RFQ itself was desk-submitted; see Path 2 for the portal case).
direct_names = frappe.get_all(
    "Supplier Quotation",
    filters={"export_costing": self.name, "docstatus": 1},
    pluck="name",
)

Path 2 — Portal-Created SQs (via RFQ item rows)

When a supplier submits a quotation through the ERPNext supplier portal, the resulting SQ is created without any custom fields from the originating RFQ — ERPNext's portal code does not copy non-standard fields. This means export_costing is blank on those SQs, and Path 1 misses them entirely.

The engine recovers them by looking at Supplier Quotation Item rows whose request_for_quotation field points to any RFQ that itself belongs to this costing:

portal_names = frappe.get_all(
    "Supplier Quotation Item",
    filters={"request_for_quotation": ("in", list(rfq_route_by_name)), "docstatus": 1},
    pluck="parent",
)

rfq_route_by_name is built by querying Request for Quotation with export_costing = self.name, so the chain is: costing → RFQ → SQ item row → SQ parent.

Critical consequence: because portal SQs carry no charge-head, qty-basis, or goods-scoping metadata at the SQ or SQ-item level, the engine must resolve these attributes from the originating RFQ item rows instead. This is what rfq_item_meta provides — a dict keyed (rfq_name, item_code) that maps to the Request for Quotation Item row carrying charge_item, em_qty_basis, and applies_to_item.


Resolving the Charge Head

For each Supplier Quotation Item row, the charge head is resolved in priority order by the module-level resolve_charge_head function:

Priority Source When it applies
1 row.charge_item (on the SQ item) Desk-entered SQs where the buyer typed the charge head directly
2 rfq_fallback.charge_item (from the matching RFQ item row) Portal SQs — charge head comes from the RFQ that requested it
3 head_by_row_item.get(row.item_code) (via Export Charge Item.service_item) Service-type charge heads quoted by synthetic service item code

If the resolved charge head is not in the local charge_heads set (built from self.charges at the start of the pull), the SQ item row is silently skipped. This prevents stale or misconfigured RFQ items from polluting the rate table.


Resolving the Loading Port (resolve_row_port)

Each raw-rate row gets a port_of_loading that controls which combinations it participates in. A blank port is a wildcard that matches any origin port. The resolution logic handles a subtle edge case around mixed-port SQs:

def resolve_row_port(row, route_source, quotations_with_row_ports: set):
    if row.em_port_of_loading:
        return row.em_port_of_loading
    if row.parent in quotations_with_row_ports:
        return None
    return route_source.em_port_of_loading

Working through the three branches:

Condition Resolved port Why
row.em_port_of_loading is set That row's port The supplier explicitly scoped this line to a port — honour it
Row port is blank, but other rows on the same SQ have a port None (wildcard) The SQ uses row-level port granularity; a blank row is a genuine "any port / ex-works" offer, not an omission. Using the parent SQ's route port here would silently collide with the explicit-port rows on the same SQ and could drop one of them (since the key deduplication uses the port as part of the key)
Row port is blank and no rows on the SQ have a port route_source.em_port_of_loading The SQ as a whole is scoped by its header route (or the originating RFQ's route) — fall back to that

quotations_with_row_ports is pre-computed before the main loop:

quotations_with_row_ports = {row.parent for row in quotation_rows if row.em_port_of_loading}

This set-membership check is O(1) per row, so it adds no meaningful overhead even with large SQ lists.

Route Source Selection

Before resolve_row_port is called, route_source_for determines which document provides the route (port of loading, port of discharge, container type):

def route_source_for(self, row, quotation, rfq_route_by_name):
    if quotation.em_port_of_loading or quotation.em_port_of_discharge or quotation.em_container_type:
        return quotation
    return rfq_route_by_name.get(row.request_for_quotation) or quotation

If the SQ itself carries any route field, it wins. Otherwise the originating RFQ's route is used. This matters for portal SQs where the buyer set the route on the RFQ but the portal does not propagate it to the SQ header.


Deduplication Key

A raw-rate row is skipped if the same combination of (source SQ name, charge head, item code, loading port) has already been recorded in this pull cycle — or if it was a frozen row carried over from a prior pull:

key = (quotation.name, charge_item, row_item_code, row_port_of_loading or "")

The empty-string normalisation on row_port_of_loading ensures that two wildcard rows from the same SQ for the same charge head collapse into one row, not two. row_item_code is row.applies_to_item (from the SQ item) falling back to rfq_fallback.applies_to_item (from the RFQ item), so goods-scoped rates (e.g. Freight quoted per item type) are kept separate from non-scoped ones.


Fields Written to Each Raw Rate Row

The raw_rate_row method returns a dict with the following fields:

Field Source
charge_item Resolved via resolve_charge_head (see above)
supplier quotation.supplier
item_code row.applies_to_itemrfq_fallback.applies_to_item
rate row.rate (the SQ item line rate)
currency quotation.currency (header currency, not per-row)
qty_basis row.em_qty_basisrfq_fallback.em_qty_basisExport Charge Item.default_qty_basis
port_of_loading Resolved via resolve_row_port
port_of_discharge route_source.em_port_of_discharge
container_type route_source.em_container_type
quote_date quotation.transaction_date
valid_till quotation.valid_till
is_expired 1 if valid_till < today(), else 0 (set at pull time; read-only in the UI)
source_doctype "Supplier Quotation" (from RAW_RATE_SOURCE_DOCTYPE in constants.py)
source_name quotation.name (the SQ document name)

is_frozen is not set by raw_rate_row; new rows inherit the field's JSON default of 0. Frozen rows (those that survive a re-pull) are carried over whole by reset_to_frozen_raw_rates before the main loop runs.

qty_basis resolution has a three-level fallback so that even if a supplier omits the basis on their portal submission, the system defaults to what the charge head master specifies. If all three are blank, the field is left empty and the combination engine will still include the row in the Cartesian product, but the resulting charge amount will be wrong — correct either the RFQ item row or the Export Charge Item master's default_qty_basis.


Re-Pull Behaviour and Frozen Rows

A re-pull does not simply append — it replaces the raw_rates table with a fresh snapshot. The reset_to_frozen_raw_rates method implements this:

def reset_to_frozen_raw_rates(self) -> set:
    frozen_rows = [row for row in self.raw_rates if cint(row.is_frozen)]
    frozen_keys = {
        (row.source_name, row.charge_item, row.item_code or "", row.port_of_loading or "")
        for row in frozen_rows
    }
    self.set("raw_rates", frozen_rows)
    return frozen_keys

Steps:

  1. Collect every row where is_frozen = 1.
  2. Record their deduplication keys into frozen_keys.
  3. Replace raw_rates with only those frozen rows (wiping everything else).
  4. Return frozen_keys so the main loop initialises pulled_keys from them — preventing the re-pull from adding a duplicate of a frozen row.

Why frozen rows exist: When a combination is selected (the buyer picks the winning vendor mix), the backing raw-rate rows are marked is_frozen = 1 for audit. If the buyer then pulls rates again to add a new supplier, the already-selected rates must not disappear — that would make the combination's line items point to non-existent rows. Frozen rows survive indefinitely until the combination selection is cleared.

The is_frozen flag is read_only in the UI. It is set programmatically by the combination selection logic, not by the user. The field description in the JSON is: "Set when this rate backs the selected combination — survives re-pulls."


Expired Rate Handling

Expiry is evaluated at pull time against today():

is_expired = cint(bool(quotation.valid_till and getdate(quotation.valid_till) < getdate(today())))

Note: an SQ with no valid_till at all is never considered expired (bool(None and ...) is False).

Expired rows are kept by default and written to raw_rates with is_expired = 1. This preserves visibility — the buyer can see which rates have lapsed without losing the data.

The skip_expired boolean argument changes this: when True, any row whose SQ is expired is skipped entirely and never written. The argument defaults to False (or None from a frontend frm.call that omits it; the signature accepts bool | None because Frappe's type validation would reject a None against a bare bool).

Expired rates are excluded from combination generation (group_rates_by_head skips rows where cint(row.is_expired) is truthy), so keeping them in raw_rates does not affect the math — it only retains them for the buyer's reference.


Status Transition

At the end of the pull, if and only if the costing's current status is "Draft" and the raw_rates table is non-empty:

if self.status == "Draft" and self.raw_rates:
    self.status = "Rates Received"

The full status progression is: DraftRates ReceivedCosting SelectedQuotedWon / Lost.

The pull never downgrades a status. A costing that is already Quoted, Won, or Lost will have its raw rates replaced on a re-pull but its status field will not change. This prevents a re-pull (e.g. adding a second freight supplier) from silently rolling back a costing that the sales team has already acted on.


End-to-End Example (Demo Context)

In the JM Tradelink demo, a typical cashew export costing has three charge heads on its charges table: Freight, Marine Insurance, and Handling. After the buyer sends the RFQ to three freight forwarders:

  1. Supplier A responds via the portal with two rows — one for Abidjan (port of loading) and one for Tema. The SQ carries no export_costing custom field (portal path). The engine finds it via the RFQ item link.
  2. Supplier B submits a desk-entered SQ with export_costing set directly. It has a single freight row with no port (wildcard — will match any combination port).
  3. The bank rate SQ for Cotonou is desk-entered with a rate already keyed to the Cotonou route.

After "Pull Vendor Rates":

  • 4 raw-rate rows are created: Supplier A / Abidjan, Supplier A / Tema, Supplier B / wildcard, Bank / Cotonou.
  • If Supplier A's SQ has valid_till in the past, those two rows get is_expired = 1 but are still written (unless skip_expired was passed as true).
  • The costing status advances from Draft to Rates Received.
  • "Generate Combinations" can now proceed and will build the Cartesian product across the three charge heads for each origin port.

Common Pitfalls

Portal SQs are found via RFQ item rows, not via a custom field. If the RFQ was not linked to the costing (export_costing on the RFQ), those portal SQs will never be found. Always create RFQs from the costing's "Send RFQ" action, not from the standalone RFQ form.

Blank qty_basis on all three levels means the raw rate row has no basis. The combination engine will still include it in the Cartesian product, but the resulting charge amount will be wrong. Fix either the RFQ item row or the Export Charge Item master's default_qty_basis.

A re-pull with unfrozen rows destroys manual edits to raw_rates. If you edited a rate directly in the child table (e.g. to correct a typo), that edit is gone on the next pull unless the row is frozen. The intended workflow is: correct the source SQ, then re-pull.

skip_expired=True is not sticky. It is a one-shot argument to a single pull. If you pull with skip_expired=True and then pull again without it, expired rows come back.

Last updated 3 months ago
Was this helpful?
Thanks!