Generating & Comparing Combinations
Generating & Comparing Combinations
Section: Vendor Rates & Combinations

Overview

The combination engine exists because the client's procurement rule is explicit: never select the cheapest individual rate per charge head in isolation. Ocean freight, inland haulage, procurement cost and every other head interact — a cheap freight vendor paired with an expensive origin location can result in a higher landed cost than a slightly more expensive freight vendor paired with a closer port. The only correct unit of comparison is the complete landed cost for a specific set of rate picks across all heads and a specific port of loading.
To enforce this, the engine computes the landed cost for every valid permutation of vendor rates across charge heads and origin ports. Each permutation runs the same locked calculation sequence that the costing itself uses, producing numbers that are in exact parity with the Excel model the client originally built. The results are stored as ranked Export Cost Combination documents and the cheapest is auto-applied.
Step 1 — Pulling Vendor Rates (Prerequisite)
Before combinations can be generated, submitted Supplier Quotation rates must be snapshotted into the costing's raw_rates child table via Pull Vendor Rates. This step is separate and is not documented here, but two things about it are relevant to understanding combination generation:
- Each raw-rate row captures:
charge_item,item_code,port_of_loading,port_of_discharge,container_type,rate,currency,supplier, and anis_expiredflag. - A rate with a blank
port_of_loadingis a wildcard — it applies to any origin port.
Step 2 — Generate Combinations
Triggered from the Combinations section on the Export Costing form via the Generate Combinations button (calls CombinationEngineMixin.generate_combinations).
2.1 Pre-flight
The engine runs these checks before any computation:
- Raw rates must exist; if
self.raw_ratesis empty, it throws. - Any currently selected combination is deselected (
clear_existing_selection) — regenerating always replaces the previous result. - FX rows are ensured for every currency that appears in the raw rates (
ensure_exchange_rate_rows_for_currencies). This is done once on the parent costing and persisted so that the transient clones inherit the same FX rates; the eventual selection save reads those same rows. - The parent costing is saved.
2.2 Grouping Rates by Head
rates_by_head = group_rates_by_head()
A "head" is the tuple (charge_item, item_code). This means rice procurement and cashew procurement are separate heads, even if both use the same charge item category. Each raw-rate row is assigned to exactly one head.
During grouping, two filters are applied:
| Filter | Reason |
|---|---|
is_expired == 1 → excluded |
Expired rates must not participate; including them would produce phantom-cheap combinations that can never actually be booked. |
port_of_discharge does not match the costing's port_of_discharge → excluded |
The discharge port is fixed for the entire costing. A rate scoped to a different discharge port cannot participate regardless of the loading port. Blank discharge port on either side is a wildcard (matches_route). |
container_type does not match → excluded |
Same logic as discharge port. |
2.3 Identifying Variable Heads
variable_heads = ordered_variable_heads(rates_by_head)
A head is "variable" if it has at least one raw-rate row after filtering. The order follows the costing's charges table order, which makes the combination layout deterministic and reproducible.
Fixed charges (those with no corresponding raw-rate rows) are not combinatorially varied; they remain at whatever rate is already in the charge row.
2.4 Determining Origin Ports
ports = origin_ports()
The set of origin ports is derived from the distinct port_of_loading values across all raw_rates rows (including expired ones — port discovery is not filtered). The costing's own port_of_loading is added if it is not already present. If every rate is a wildcard (blank loading port), the engine falls back to the costing's own port as the single combination space.
2.5 Building Candidates per Port
For each origin port, the engine calls compatible_rates_for_port:
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 # port is skipped entirely
rates_per_head.append(compatible)
return rates_per_head
The critical rule: if any variable head has zero compatible rates for a given port, that entire port is skipped. Falling back to the manual charge-row rate would silently hide the gap and produce a misleading comparison. A port only participates if every variable head has at least one rate for it (where a blank-port rate qualifies as compatible with any port).
The total combination count is:
total_count = Σ (over ports) ∏ (over heads for that port) len(compatible_rates_for_head)
2.6 Max-Combinations Cap Guard
def guard_combination_cap(self, total_count: int) -> None:
settings = get_export_settings()
if total_count > settings.max_combinations:
frappe.throw(...)
Before any clone is computed, the total count is checked against Export Settings.max_combinations. If the cap would be exceeded, the engine throws with a message naming the actual count, the cap, and the remediation options (prune expired/duplicate rates, or raise the cap in Export Settings).
Note: The cap is checked against the actual producible count after port pruning (ports with no compatible rate for some head are already excluded from the total). If you have 4 heads each with 5 vendors across 3 fully-compatible ports, that is 5⁴ × 3 = 1,875 combinations.
2.7 Computing Ranked Clones
Stale (non-selected) combinations from a previous generation run are hard-deleted before new ones are inserted.
For each port, itertools.product(*rates_per_head) produces every permutation of rate picks across heads. Each permutation is a tuple of raw-rate rows — one pick per variable head.
For each pick tuple, compute_combination_clone runs:
def compute_combination_clone(self, port, picks):
clone = frappe.copy_doc(self)
clone.port_of_loading = port
clone.reset_quoted_price()
apply_picks_to_charges(clone, picks)
clone.set_exchange_rate_buffers()
clone.calculate_charges()
clone.calculate_per_mt_costs()
clone.set_quoted_price()
clone.calculate_destination_block()
return clone
Key points:
frappe.copy_docproduces an in-memory transient copy — it is never saved to the database.- The cached quoted price is reset on the clone so
set_quoted_pricecomputes a price specific to this combination's cost, not a prior selection's. apply_picks_to_chargeswrites the chosen rate, currency, supplier, qty basis, and (if the source is a Supplier Quotation) the SQ link onto the relevant charge rows of the clone.- The calculation sequence (
calculate_charges → calculate_per_mt_costs → set_quoted_price → calculate_destination_block) is identical to what the costing runs on save — this is the Excel-parity guarantee. No separate formula; no approximation. - The sequence explicitly avoids calling
validate()to prevent any database writes or throws from mid-loop validation side-effects.
All computed (port, picks, clone) tuples are sorted ascending by (landed_cost_per_bag, cif_total). The cheapest landed cost per bag is rank 1.
2.8 Persisting as Export Cost Combination Docs
Each sorted entry becomes one Export Cost Combination document, inserted in rank order. The document stores:
| Field | Source |
|---|---|
rank / combination_no |
Position in sorted list (1 = cheapest) |
port_of_loading |
The origin port for this permutation |
port_of_discharge |
Copied from costing |
ex_works_total |
From clone |
fob_total |
From clone |
cfr_total |
From clone |
cif_total |
From clone |
cif_per_mt_usd |
From clone |
quoted_price_per_mt |
From clone |
cost_per_bag_destination |
From clone |
pod_clearing_per_bag |
From clone |
pod_expense_per_bag |
From clone |
landed_cost_per_bag |
From clone |
present_selling_price_per_bag |
From clone |
margin_per_bag |
From clone |
margin_percent |
From clone |
margin_percent_before_pod |
From clone |
status |
"Generated" |
lines |
One row per pick: charge item, supplier, item, rate, currency, qty basis, ports, source SQ |
The lines child table is the audit record of which rate was picked for each head in this combination. The converted_amount_inr on each line comes from the clone's charge row amount_inr, so the breakdown is in a single currency (INR) for comparability.
2.9 Auto-Select Best
After all combinations are persisted, auto_select_best calls select_combination on the rank-1 document. This means the costing is left with the cheapest combination already applied — the user can see real numbers immediately without having to manually select.
The return value of generate_combinations is {"count": total_count, "best": best_name}.
Step 3 — Combination Comparison Report
The Combination Comparison query report (combination_comparison.py) shows all Export Cost Combination documents for a costing in a single ranked table.
3.1 Filters
| Filter | Field | Behaviour |
|---|---|---|
| Export Costing | export_costing |
Required. Throws if missing. |
| Port of Loading | port_of_loading |
Filters combination documents directly. |
| Only Selected | only_selected |
Shows only the currently selected combination. |
| Vendor | vendor (like search) |
Resolves to parent combination names via Export Cost Combination Line (matches against supplier). |
| Charge Item | charge_item |
Same resolution via lines. |
| Min Margin % | min_margin_percent |
Applied in Python after the DB fetch (not a SQL filter) — combinations below the threshold are excluded from the result set. |
Vendor and charge-item filters work by querying Export Cost Combination Line first and then filtering the parent combination documents to those that contain a matching line. This is the correct pattern because the filters live on the child table, not the parent.
3.2 Columns
| Column | Field | Notes |
|---|---|---|
| Rank | rank |
1 = cheapest landed cost/bag |
| Combination | name |
Link to Export Cost Combination |
| Port of Loading | port_of_loading |
Origin port for this permutation |
| Landed Cost / Bag | landed_cost_per_bag |
The primary sort key; what the engine minimises |
| FOB (INR, Indian Port) | fob_total |
Up to and including loading at the Indian port |
| Freight to Destination (INR) | freight_to_destination |
Computed in Python: cfr_total − fob_total |
| Insurance (INR) | insurance |
Computed in Python: cif_total − cfr_total |
| CIF Total (INR) | cif_total |
Full cost landed at destination port |
| Cost / MT (USD) | cif_per_mt_usd |
CIF total converted to USD per metric ton |
| Margin / Bag | margin_per_bag |
Selling price minus landed cost, per bag |
| Margin % | margin_percent |
Margin as a percentage (base = full landed cost) |
| Selected | is_selected |
Check column; exactly one row will be checked per costing |
Why FOB / Freight / Insurance are separate columns: The CFR−FOB and CIF−CFR breakdowns let the user see exactly where the cost difference between two combinations comes from. A combination that looks expensive at FOB might be cheaper at CIF because it uses a more direct shipping lane. The columns make that visible without opening each combination document.
The combinations are fetched ordered by rank asc, so the table is always cheapest-first.
Step 4 — Selecting Another Combination
To override the auto-selected best, the user opens the desired Export Cost Combination document and clicks Select This Combination, which calls select_combination(combination).
What select_combination Does
- Fetches the combination and its parent costing.
- Marks any other currently-selected combination as
is_selected = 0,status = "Generated"(directly viafrappe.db.set_value— no full doc save needed). - Writes the combination's
linesback into the costing'schargestable: rate, currency, supplier, qty basis, and SQ link per charge head. - Sets
costing.port_of_loadingto the combination's origin port. - Ensures FX rows exist for the combination's currencies.
- Resets the costing's cached quoted price so
set_quoted_pricerecomputes it fresh for this combination. - Advances costing status to
"Costing Selected"(unless it is already"Won"or"Lost"). - Freezes the backing raw-rate snapshot rows whose
(source_name, charge_item, item_code, port_of_loading)keys match the picked lines. Frozen rows survive a re-pull, preserving the audit trail for the selected rates. - Saves the costing — the full calculation sequence reruns on the real document.
- Marks the combination as
is_selected = 1,status = "Selected"and saves it.
Parity Check
After the costing saves and recalculates, two assertions run:
PARITY_TOLERANCE_INR = 1.0 # ₹1 on totals that run into crores
if abs(flt(costing.cif_total) - flt(selected.cif_total)) > PARITY_TOLERANCE_INR:
frappe.throw("Parity check failed: CIF diverged ...")
if abs(flt(costing.quoted_price_per_mt) - flt(selected.quoted_price_per_mt)) > PARITY_TOLERANCE_INR:
frappe.throw("Parity check failed: quoted price/MT diverged ...")
The tolerance is ₹1.00 on totals that run into crores. If the real costing recomputes a CIF or quoted-price-per-MT that differs by more than ₹1 from what the combination stored at generation time, it means an input changed between generation and selection (an FX row was edited, a charge row was modified, etc.) and the engine throws, requiring regeneration.
Gotcha: Do not relax
PARITY_TOLERANCE_INR. The comment in the source says exactly this. A silent drift here means the combination table shows one set of numbers and the actual costing ships different ones to the customer.
Regenerating
Clicking Generate Combinations again:
- Deselects and unfreezes the currently selected combination (
clear_existing_selection→deselect_combination). - Hard-deletes all non-selected
Export Cost Combinationand theirExport Cost Combination Linerows. - Rebuilds from the current state of
raw_rates. - Re-ranks and persists new combinations.
- Auto-selects the new cheapest (rank 1).
- The new selection's
select_combinationresets the quoted price and the per-MT figure, so the costing reflects the new best.
The costing status returns to "Rates Received" during deselection (if it was "Costing Selected") and advances back to "Costing Selected" once the new best is auto-applied.
Design Decisions Worth Knowing
Why transient clones and not just summing the picked rates? The landed-cost calculation involves FX conversion, qty-basis normalisation, and a multi-stage charge accumulation (EXW → FOB → CFR → CIF → destination). Reproducing that inline would duplicate logic and drift over time. Running the same methods on an in-memory copy of the document guarantees parity without maintenance overhead.
Why is a missing head fatal for a port rather than falling back to the manual rate? Because the manual rate is whatever was last saved — it might be from a prior supplier, a prior port, or a placeholder. Including it would make the comparison meaningless: the user would see a "combination" that is partially vendor-priced and partially manual, and would not be able to tell which is which.
Why does ordered_variable_heads follow the charges table order? So that itertools.product always produces picks in the same order for a given costing. This makes the combination numbers (and lines rows) deterministic and human-readable when you open a combination document.
Why freeze raw-rate rows on selection rather than on generation? The raw-rate snapshot is a mutable staging area — vendors can re-quote and the user can re-pull. Freezing only the rows that feed the selected combination preserves the flexibility to pull updated rates and regenerate while protecting the audit trail for the combination that was actually used.