Wiki

Wiki

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

Cost Build-up

Cost Build-up

Section: The Costing Workflow

Cost Build-up diagram


Overview

The ExportCosting document computes a single INR cost stack that runs from the farm gate (Ex-Works) all the way to the vessel (CIF). Every charge row in the charges child table belongs to one of five stages: Ex-Works, FOB, CFR, CIF, or Destination. The first four form a cumulative cascade; the fifth is a separate destination-side block that never touches the INR total.

The cascade is defined in constants.py:

CASCADE_STAGES = ["Ex-Works", "FOB", "CFR", "CIF"]
DESTINATION_STAGE = "Destination"

The company's books currency is INR (COMPANY_CURRENCY = "INR"). Every foreign-currency charge is converted to INR before it enters the running total.


The Cascade: calculate_charges

Called once per validate() pass, calculate_charges iterates over CASCADE_STAGES in order. For each stage it scans the full charges table and processes only the rows whose stage matches the current stage. A running_total accumulates across all four stages, so each stage total already contains every rupee from every earlier stage.

running_total = 0.0
stage_totals = {}

for stage in CASCADE_STAGES:          # Ex-Works → FOB → CFR → CIF
    stage_start = running_total       # subtotal at the START of this stage
    for row in self.charges:
        if row.stage != stage:
            continue
        if row.qty_basis == "Percent of Subtotal":
            row.qty = 1
            row.exchange_rate_used = 1
            row.amount_inr = flt(row.rate) / 100 * stage_start
        else:
            row.qty = self.resolve_charge_qty(row)
            row.exchange_rate_used = self.get_buffered_rate(row.currency)
            row.amount_inr = flt(row.qty) * flt(row.rate) * row.exchange_rate_used
        running_total += flt(row.amount_inr)
    stage_totals[stage] = running_total

self.ex_works_total = stage_totals["Ex-Works"]
self.fob_total      = stage_totals["FOB"]
self.cfr_total      = stage_totals["CFR"]
self.cif_total      = stage_totals["CIF"]

Why the running total carries forward

Each stage total is a snapshot of running_total after all rows in that stage have been added. Because the variable never resets between stages, fob_total already contains ex_works_total; cfr_total already contains fob_total; and so on. This mirrors the client's Excel model where each Incoterms column is a cumulative cost, not a slice.

Field stored What it represents
ex_works_total Sum of all Ex-Works charges
fob_total Ex-Works + all FOB charges
cfr_total FOB + all CFR charges (freight)
cif_total CFR + all CIF charges (insurance)

Percent of Subtotal rows

When qty_basis == "Percent of Subtotal" the row's rate is treated as a percentage applied to stage_start — the running total at the moment that stage began, before any row in the current stage has been added.

This is intentional: a commission expressed as "2% of the Ex-Works sub-total" should reference what was accumulated before the current stage, not a mid-stage partial sum. The fields qty and exchange_rate_used are set to 1 so the stored values are meaningful for audit purposes (the formula resolves to rate% × stage_start).

Gotcha: Percent of Subtotal is evaluated against the stage's opening subtotal, not the total after the current stage's other rows. Row order within a stage does not affect it.


Quantity Resolution: resolve_charge_qty

Before any INR amount can be computed, the engine must know how many units a charge applies to. resolve_charge_qty(row) maps the charge row's qty_basis field to a concrete number.

Scope: item vs. shipment

The first decision the function makes is which set of weights to use:

  • If row.item_code is set, the function finds the matching ExportCostingItem row and reads its own net_weight_mt, gross_weight_mt, total_bags, and no_of_containers.
  • If row.item_code is blank, it reads the shipment-level totals: self.total_net_mt, self.total_gross_mt, self.total_bags, self.total_containers.

This is why a goods-procurement charge (e.g. rice purchase cost) carries an item_code — a multi-item shipment needs separate purchase rates for each commodity, and the weight basis must stay scoped to that item's containers.

If item_code is set but the item is not found in self.items, the function returns 0.0 rather than throwing, so a stale row does not crash validation.

Basis-to-quantity mapping

qty_basis Resolves to
Per Net MT net_weight_mt (item or shipment)
Per Gross MT gross_weight_mt (item or shipment)
Per Kg net_weight_mt × 1000
Per Bag total_bags (item or shipment)
Per Container no_of_containers (item or shipment)
Per BL self.no_of_bl (always shipment-level — a BL covers the whole consignment)
Lumpsum 1
Manual row.qty (the user-entered value; not recomputed)

Why Per Kg = net MT × 1000: Rice and similar commodities are commercially priced on net weight. Warehouse handling fees quoted per kg from a vendor come back as a flat rate; the engine expresses the shipment in kg by scaling the net MT figure rather than storing a separate kg field.

Per BL is always shipment-scope. A Bill of Lading covers the full shipment regardless of how many items are on it, so no_of_bl is never an item attribute.

Manual basis

When qty_basis == "Manual" the function returns whatever is already in row.qty without touching it. This is the escape hatch for one-off lump charges that don't fit any formula — the user types the quantity directly.


INR Conversion: the buffered exchange rate

For every non-Percent row, amount_inr is:

amount_inr = qty × rate × buffered_exchange_rate

get_buffered_rate(currency) reads the exchange_rates child table and returns base_rate + fx_buffer. The buffer is a conservative safety margin (set once from Export Settings, stored on the costing so it never shifts retroactively).

If INR is the charge currency, get_buffered_rate returns 1.0 so no conversion happens.

If no exchange rate row exists for the charge's currency, get_buffered_rate throws immediately with a clear message rather than silently returning zero. This is by design — a missing rate would silently understate cost.

Per-MT display fields use the BASE rate, not the buffered rate. calculate_per_mt_costs converts fob_total, cif_total (finance- and profit-loaded) to USD/EUR using get_base_rate (no buffer added), because those are quote-side numbers. The buffer is cost-side conservatism; the quote is computed separately from get_cost_per_mt_inr.


Destination-Stage Rows: excluded from the INR cascade

After the four-stage cascade loop completes, calculate_charges runs a second pass over only the Destination rows:

for row in self.charges:
    if row.stage == DESTINATION_STAGE:
        row.qty = self.resolve_charge_qty(row)
        row.exchange_rate_used = 0
        row.amount_inr = 0

Both exchange_rate_used and amount_inr are zeroed. qty is still resolved (so the user can see the computed unit count in the table) but the row contributes nothing to cif_total or any stage total.

Why destination rows are excluded

The destination block represents costs incurred by the buyer's clearing agent in Cotonou (or another port of discharge) — customs duty, transit fees, local transport. These are not the exporter's cost; they are quoted to the buyer as a separate landed-cost figure. Including them in the INR cascade would overstate the exporter's selling cost and corrupt the margin calculation.

Instead, these rows feed calculate_destination_block, which produces cost_per_bag_destination and related fields in the buyer's destination currency.

Validation rules for destination rows

validate_destination_charges enforces two invariants before the main loop runs:

  1. qty_basis must be either Per Bag or Lumpsum (the only two bases defined in DESTINATION_ALLOWED_BASES).
  2. currency must match self.destination_currency (e.g. XOF/CFA).

Gotcha: If you add a Per Net MT destination charge, validation throws before calculate_charges even runs. Only Per Bag and Lumpsum are allowed.


Layering Finance Cost and Profit: get_cost_per_mt_inr

After calculate_charges produces cif_total, the costing layers two multipliers on top before expressing the result per gross MT:

def get_cost_per_mt_inr(self) -> float:
    if not flt(self.total_gross_mt):
        return 0.0
    cost_total = (
        flt(self.cif_total)
        * (1 + flt(self.finance_cost_percent) / 100)
        * (1 + flt(self.profit_percent) / 100)
    )
    return cost_total / flt(self.total_gross_mt)
Field Role
finance_cost_percent Capital cost of financing the shipment (bank charges, LC cost). Applied as a percentage of cif_total.
profit_percent Desired margin. Applied on top of the finance-loaded cost.
total_gross_mt Gross MT divides the INR total into a per-MT figure.

The multipliers are chained, not added. A finance_cost_percent of 1.5 and a profit_percent of 3.0 yields a factor of 1.015 × 1.030 = 1.04545, not 1.045. This matches the client's sheet where margin is calculated on the fully-loaded (post-finance) cost.

Why gross MT, not net MT

The quoted price per MT is in gross MT terms because the commodity is sold by the bag/container load, not stripped of packaging weight. Gross MT is also what ocean freight is billed on, so using it as the denominator keeps the per-MT cost consistent with the freight line.

Conversion to per-MT in quote currency

calculate_per_mt_costs calls get_cost_per_mt_inr and then divides by the base (unbuffered) exchange rate. It also computes a FOB view:

cost_per_mt_inr = self.get_cost_per_mt_inr()
fob_per_mt_inr  = flt(self.fob_total) / flt(self.total_gross_mt) if flt(self.total_gross_mt) else 0.0
self.fob_per_mt_usd = fob_per_mt_inr / usd_rate if usd_rate else 0.0
self.cif_per_mt_usd = cost_per_mt_inr / usd_rate if usd_rate else 0.0
self.cif_per_mt_eur = cost_per_mt_inr / eur_rate if eur_rate else 0.0

The buffer is not applied here because the output is a quoted price going to the buyer, not a cost estimate with a conservative safety margin. Note that cif_per_mt_usd and cif_per_mt_eur reflect the finance- and profit-loaded cost (via get_cost_per_mt_inr), while fob_per_mt_usd is the raw FOB subtotal divided by gross MT.


Concrete Example (demo shipment)

A typical JM Tradelink costing might look like:

  • 1 × 20' FCL, 960 bags of parboiled rice
  • Net weight: 24.00 MT, Gross weight: 26.40 MT
  • Exchange rates: USD base 83.50, buffer 0.50 → buffered 84.00; EUR base 91.20, buffer 0.50 → buffered 91.70
  • fx_buffer: 0.50
Stage Representative charge Basis Rate Buffered FX amount_inr Running total
Ex-Works Rice (procurement) Per Net MT USD 315 84.00 24 × 315 × 84 = 6,35,040 6,35,040
Ex-Works Packaging material Per Bag INR 18 1.00 960 × 18 × 1 = 17,280 6,52,320
FOB Loading & stuffing Per Container INR 12,000 1.00 1 × 12,000 = 12,000 6,64,320
FOB Freight forwarder Lumpsum INR 8,500 1.00 1 × 8,500 = 8,500 6,72,820
CFR Ocean freight Per Container USD 950 84.00 1 × 950 × 84 = 79,800 7,52,620
CIF Insurance Percent of Subtotal 0.2% 0.002 × 7,52,620 = 1,505 7,54,125

cif_total = ₹7,54,125

With finance_cost_percent = 1.5 and profit_percent = 3.0:

cost_total = 7,54,125 × 1.015 × 1.030 = 7,89,005 (approx)
cost_per_mt_inr = 7,89,005 / 26.40 = ₹29,886 / gross MT
cif_per_mt_usd  = 29,886 / 83.50 = ~$358 / gross MT

Validation & validate() Call Order

calculate_charges is not called in isolation. The full validate() sequence ensures the inputs are ready:

set_defaults_from_settings    → snapshots fx_buffer, CFA rates once
set_packaging_defaults        → fills unit_weight_kg, empty_unit_weight_kg, units_per_container from Item Packaging
calculate_item_totals         → computes per-item and shipment gross/net MT, total_bags
sync_item_procurement_rows    → ensures every item has a procurement charge row
set_exchange_rate_buffers     → buffered_rate = base_rate + fx_buffer
validate_destination_charges  → rejects invalid destination rows BEFORE the loop
calculate_charges             ← the cascade runs here
calculate_per_mt_costs        → fob_per_mt_usd and cif_per_mt_usd/eur from base (not buffered) rate
set_quoted_price              → quoted_price_per_mt auto-set if blank or currency changed
calculate_destination_block   → per-bag destination cost in CFA/USD

If validate_destination_charges throws, calculate_charges never runs and the document is not saved. This order prevents a partially-computed costing from being persisted.

Last updated 3 months ago
Was this helpful?
Thanks!