---
title: "Creating an Export Costing"
space: "Wiki"
url: "https://jmtradelink.com/docs/the-costing-workflow/creating-an-export-costing"
updated: "2026-06-22"
---

# Creating an Export Costing

An Export Costing is the central working document in the costing workflow. It captures the full cost build-up from Ex-Works through CIF, computes a per-MT quoted price, and drives every downstream document — RFQs, combinations, the customer Quotation, Purchase Orders, and the Export Shipment. This page covers how to start one, structure it, and what happens under the hood on each save.

---

## 1. Starting a Costing

There are two entry points.

### From a qualified Opportunity (recommended)

On any Opportunity record, use **Create ▸ Export Costing**. This calls `make_export_costing` in `export_costing_actions.py`, which reads the Opportunity and creates a new `Export Costing` pre-populated with these fields:

| Field | Source |
|---|---|
| `opportunity` | Opportunity name |
| `customer` | `opportunity_doc.party_name` (only if `opportunity_from == "Customer"`) |
| `quote_currency` | `opportunity_doc.currency` |
| `expected_shipment_date` | `opportunity_doc.expected_closing` |
| `costing_date` | Today's date |

`items` is left entirely blank. Because `items` is a mandatory table on the DocType, the function calls `costing.insert(ignore_mandatory=True)`. This is deliberate: product lines are the user's next step, and every subsequent save re-validates from scratch. Skipping mandatory at insert is safe because `validate()` runs on every save and will catch incomplete state before the costing can progress.

### Direct creation

You can also open **Export Costing ▸ New** from the list view. No fields are pre-populated; you fill everything manually.

Either way, the costing opens in **Draft** status.

---

## 2. What Happens at Insert (`before_insert`)

Before the record hits the database for the first time, `before_insert` runs one check:

```python
def before_insert(self):
    if not self.charges:
        self.append_variable_charge_rows()
```

If the charges table is empty — which it always is on a fresh costing — `append_variable_charge_rows` queries all `Export Charge Item` records where `is_variable=1`, `is_goods=0`, and `disabled=0`, then appends one empty row per head (with `rate=0`) using that head's `default_stage`, `default_qty_basis`, and `default_currency`.

These are the vendor-sourced heads (sea freight, CFS handling, container, bags, etc.). They land on the costing as empty placeholders so the user can see what needs quoting without having to add rows manually.

> **Why empty rows, not zero values hidden away?**
> The user needs to see which heads are pending quotes and which are filled. A visible row with `rate=0` also participates in `calculate_charges` and correctly contributes ₹0 to the total — the math stays consistent while rates are being gathered.

---

## 3. Filling in Item Lines

After the costing opens, add one row per commodity in the **Items** table.

| Column | What to enter | Notes |
|---|---|---|
| `item_code` | ERPNext Item (e.g. "IR-64 Parboiled Rice") | Links to a real Item master |
| `item_packaging` | Packaging spec (e.g. "50 kg PP Bag") | Drives weight defaults |
| `no_of_containers` | Number of containers for this commodity | Integer |
| `unit_weight_kg` | Filled from packaging spec if blank | Gross weight per bag in kg |
| `empty_unit_weight_kg` | Filled from packaging spec if blank | Bag tare weight in kg |
| `units_per_container` | Filled from packaging spec if blank | Bags per container |

**Weights compute automatically.** `set_packaging_defaults` (called inside `validate`) reads the referenced `Item Packaging` record and fills `unit_weight_kg`, `empty_unit_weight_kg`, and `units_per_container` if the row has them blank. Then `calculate_item_totals` derives:

```
row.total_bags       = no_of_containers × units_per_container
row.gross_weight_mt  = total_bags × unit_weight_kg / 1000
row.net_weight_mt    = gross_weight_mt − (total_bags × empty_unit_weight_kg / 1000)
```

Costing-level roll-ups (`total_containers`, `total_bags`, `total_gross_mt`, `total_net_mt`) are then summed across all item rows. You never enter these by hand.

**Example:** 2 containers × 1,250 bags/container × 50.2 kg/bag → 2,500 total bags, 125.5 gross MT. With a 0.2 kg bag tare, net MT = 125.5 − (2,500 × 0.2 / 1000) = 125.0 MT.

---

## 4. Picking a Costing Template (`populate_from_template`)

A Costing Template bundles the **fixed** charge rows — port costs, surveyor fees, ECTN, BL charges, insurance, and POD clearing parameters. Pick one in the `costing_template` field, then click **Populate from Template**.

This calls the whitelisted `populate_from_template` method, which does the following in order:

1. **Sets lane defaults** — copies `port_of_discharge` and `destination_currency` from the template onto the costing, but only if those fields are currently blank. It will never overwrite something the user has already typed.

2. **Snapshots import-duty lane params** — copies `bfu_percent`, `transitor_per_container`, `tax_percent`, and `commission_per_bag` from the template, again only if the costing's own values are zero. These are snapshotted once so that later edits to the template do not silently change the economics of an existing costing.

3. **Clears and re-builds the charges table** — deletes all existing charge rows, then appends one row per template charge. Each template row carries a `rate_updated_on` date. If that date is older than `Export Settings.rate_staleness_days`, the row is flagged `rate_stale=1` so the user knows it may need refreshing.

4. **Appends variable heads** — calls `append_variable_charge_rows()` again to add the non-goods variable heads that are not already in the table (the template only carries fixed costs; variable rates come from vendors).

5. **Ensures exchange rate rows** — scans the charges table for any non-INR currency that appears on a non-Destination row, adds "EUR" unconditionally (it drives the CFA duty block), then calls ERPNext's `get_exchange_rate` for any currency not yet in the `exchange_rates` table, suggesting today's live rate as a starting point.

> **Templates do not carry loading port or container type.** Those are determined later by the combination engine (which spans multiple loading ports). Setting them on the template would force a single port too early.

---

## 5. Variable Heads and `sync_item_procurement_rows`

After `populate_from_template`, the charges table contains both template (fixed) rows and empty variable rows. There is one more automatic expansion that runs on **every save** via `validate`:

`sync_item_procurement_rows` handles the **goods** variable heads — charge items where `is_variable=1` and `is_goods=1`. These are commodity procurement rows (the cost of buying the rice, cashew, etc.).

For every goods head × every item line combination that does not already exist in the charges table, the method appends a new row carrying `item_code = item.item_code`. This means:

- A costing with two item lines (rice + cashew) and one goods head (Procurement Cost) gets **two** procurement charge rows — one scoped to rice, one to cashew.
- Each row's `qty` is later resolved against that specific item's own weight figures (`net_weight_mt`, `total_bags`, etc.), not the shipment total.
- The method is **additive**: if a row already exists (perhaps with a rate pulled from a Supplier Quotation), it is left completely untouched.

> **Why per-item procurement rows?** Rice and cashew have different ex-works prices and may come from different mills. A single undifferentiated "Procurement Cost" row would force a blended rate and break the combination engine's ability to compare mill A vs mill B per commodity.

---

## 6. The Selling Price Field

![Creating an Export Costing screenshot](/files/creating-an-export-costing.png)


At the top of the form, enter **Selling Price / Bag** (`present_selling_price_per_bag`) in the destination currency (typically CFA francs / XOF for the Cotonou lane). This is what the customer will pay per bag.

On every save, `set_destination_margins` in `PodClearingMixin` computes:

```
margin_per_bag            = present_selling_price_per_bag − landed_cost_per_bag
margin_percent            = margin_per_bag / landed_cost_per_bag × 100   (primary)
margin_percent_before_pod = margin_per_bag / cost_per_bag_destination × 100
```

Until a combination is applied, variable heads carry `rate=0`, so the landed cost is understated and the margin figure is meaningless. Enter the selling price early if you already know it — the margin will become meaningful once you run Generate Combinations.

---

## 7. The `validate()` Pipeline

Every save (and every programmatic `save()` call) runs the full calculation pipeline in order:

```python
def validate(self):
    settings = get_export_settings()
    self.set_defaults_from_settings(settings)    # FX buffer, CFA rates if blank
    self.set_packaging_defaults()                # unit weights from Item Packaging
    self.calculate_item_totals()                 # bags, gross MT, net MT
    self.sync_item_procurement_rows()            # per-item goods charge rows
    self.set_exchange_rate_buffers()             # buffered_rate = base_rate + fx_buffer
    self.validate_destination_charges()          # Destination rows must use Per Bag or Lumpsum
    self.calculate_charges()                     # INR cascade through Ex-Works → FOB → CFR → CIF
    self.calculate_per_mt_costs()                # FOB/MT, CIF/MT in USD and EUR
    self.set_quoted_price()                      # quoted_price_per_mt in quote_currency
    self.calculate_destination_block()           # per-bag landed cost and margin in CFA
```

Key points about the cascade:

- Charges are grouped by `stage` and processed in the order `["Ex-Works", "FOB", "CFR", "CIF"]` (defined in `constants.CASCADE_STAGES`). "Destination" stage rows are excluded from the INR cascade entirely.
- Foreign-currency rates convert at the **buffered** rate (`base_rate + fx_buffer`) — a conservative bias that matches the client's Excel convention.
- `Percent of Subtotal` rows use `stage_start` (the running total at the beginning of their own stage) as the base, not the shipment total. This mirrors the way the client's sheet applies percentage-based charges.
- `set_quoted_price` defaults `quote_currency` to `"USD"` if it is not set, then recomputes `quoted_price_per_mt` only when it is zero or the `quote_currency` has changed; a manually typed value is preserved across saves. The result is banker's-rounded to zero decimal places.

---

## 8. Status

A newly created costing starts with `status = "Draft"`. The status field is a literal with these allowed values (from the type annotations):

```
"Draft" → "Rates Received" → "Costing Selected" → "Quoted" → "Won" (or "Lost")
```

Status advances are triggered by downstream actions (Pull Rates, Generate Combinations, Quotation submission, Sales Order submission). A costing in "Won" or "Lost" is never automatically downgraded.

---

## 9. Quick Reference: Fields Set at Each Stage

| Stage | Fields populated |
|---|---|
| Create from Opportunity | `opportunity`, `customer`, `quote_currency`, `expected_shipment_date`, `costing_date` |
| `before_insert` | Variable charge rows (empty, `rate=0`) |
| Add item lines + save | `total_bags`, `gross_weight_mt`, `net_weight_mt`, totals; per-item procurement rows |
| Populate from Template | Fixed charge rows with rates; `port_of_discharge`, `destination_currency`, duty lane params (snapshotted); exchange rate rows |
| Every save (`validate`) | All weight totals, all charge amounts in INR, `ex_works_total`, `fob_total`, `cfr_total`, `cif_total`, per-MT USD/EUR figures, `quoted_price_per_mt`, destination block (landed cost per bag, margin) |
| Enter Selling Price + save | `margin_per_bag`, `margin_percent`, `margin_percent_before_pod` |

---

## 10. Common Gotchas

> **Variable heads start at ₹0.** Until vendor rates are pulled and a combination is applied, `ex_works_total` and the derived margin figures are based only on the fixed template charges. The margin shown on a fresh costing is not meaningful for decision-making.

> **`ignore_mandatory=True` at insert is intentional.** The Opportunity → Create flow inserts with a blank `items` table because product lines are filled next. Do not add mandatory validation that would block this path — every full save re-validates.

> **Snapshotted lane params do not update retroactively.** `bfu_percent`, `transitor_per_container`, `tax_percent`, and `commission_per_bag` are written once (from the Costing Template) when blank. `cfa_rate_per_eur` and `cfa_rate_per_usd` are written once (from Export Settings) when blank. In both cases, changing the source record later will not affect any costing that already has values for these fields.

> **`sync_item_procurement_rows` is additive.** Adding a new item line to an existing costing will correctly insert a new procurement row for it on the next save. Removing an item line does not delete the corresponding charge row — clean those up manually if needed.

> **Destination charges are strictly validated.** Any charge row with `stage = "Destination"` must use `Per Bag` or `Lumpsum` as its `qty_basis`. If `destination_currency` is set on the costing, the row's `currency` must also match it. The save will throw if either rule is violated.
