---
title: "Pulling Vendor Rates"
space: "Wiki"
url: "https://jmtradelink.com/docs/vendor-rates-combinations/pulling-vendor-rates"
updated: "2026-06-22"
---

# Pulling Vendor Rates

**Section:** Vendor Rates & Combinations

---

## Overview

![Pulling Vendor Rates screenshot](/files/pulling-vendor-rates.png)


"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).

```python
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:

```python
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:

```python
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:

```python
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):

```python
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:

```python
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_item` → `rfq_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_basis` → `rfq_fallback.em_qty_basis` → `Export 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:

```python
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()`:

```python
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:

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

The full status progression is: `Draft` → `Rates Received` → `Costing Selected` → `Quoted` → `Won` / `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.
