Wiki

Wiki

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

Creating an Export Costing

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.

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:

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

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:

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.

Last updated 3 months ago
Was this helpful?
Thanks!