Sales Orders & Purchase Orders
Sales Orders & Purchase Orders
Section: Quoting and Winning
This page covers the order-to-procure leg of the export workflow: what happens after the customer says yes, how the costing flips to Won, how Purchase Orders are drafted against the winning vendors, and how the Export Shipment execution document is created. Every claim here is grounded in export_costing_actions.py, doc_events/sales_order.py, and USAGE.md.
Where this fits in the end-to-end flow
Opportunity ─▶ Export Costing ─▶ RFQs ─▶ Supplier Quotations ─▶ Pull Rates
─▶ Generate Combinations ─▶ Select Combination
─▶ Set Selling Price / Bag ─▶ Quotation (per bag, CFA)
─▶ [customer accepts]
─▶ Submit Quotation ─▶ Sales Order ─▶ (status → Won)
─▶ Purchase Orders (one per winning SQ)
─▶ Export Shipment
By the time you reach this page, the costing has a selected combination, a submitted customer Quotation, and the customer has confirmed the order.
Step 1 — Submit the Quotation and create the Sales Order
When the customer accepts, open the linked Quotation (reachable via the costing's "View Quotation" button) and Submit it. A submitted Quotation unlocks ERPNext's native Create > Sales Order action on the Quotation itself.
After the Sales Order is submitted, the Export Costing's status field flips to Won automatically. This is enforced by a doc_events/sales_order.py hook (on_submit) that resolves the costing through the Sales Order's linked Quotation and calls:
frappe.db.set_value("Export Costing", costing_name, {"sales_order": doc.name, "status": "Won"})
The hook skips costings already in Lost status — a Lost costing is never re-opened by a new Sales Order. The full status progression is:
Draft → Rates Received → Costing Selected → Quoted → Won
Auto-transitions never set a Lost costing to Won. Setting a Won costing's status to Won again (e.g. by re-submitting a linked SO) is idempotent.
Why submit the Quotation first, not the costing? The costing is a working document, not a transactional one — it has no docstatus lifecycle. The Quotation is the legally submitted price offer; submitting it is the customer-facing paper trail. The Sales Order then captures the binding order and triggers the status change on the costing.
The Sales Order reference is stored in export_costing.sales_order (a Link field on the costing).
Step 2 — Create Purchase Orders from a Won costing
On a Won Export Costing, the Create > Purchase Orders action calls make_purchase_orders (export_costing_actions.py, line 254). It drafts one PO per winning Supplier Quotation — the one-PO-per-SQ rule is explicit in the docstring:
"One PO per source Supplier Quotation (audit-clean — the PO traces to exactly one SQ)."
Guards that must pass before any PO is drafted
| Guard | What is checked | Error thrown |
|---|---|---|
| Combination selected | Export Cost Combination with is_selected = 1 exists for this costing |
"Select a winning combination first — POs follow the selected vendors." |
| Every pick has an SQ | All lines in the selected combination have both a supplier and a source_name |
Lists the unsourced charge heads by name |
| Matched SQ rows exist | After resolving rows (see below), at least one row is eligible | "No Supplier Quotation rows matched the winning picks." |
The first guard queries Export Cost Combination filtered by export_costing and is_selected = 1, taking at most one result (lines 267–272). If the list is empty, the function throws immediately — there is no silent fallback.
The second guard collects every Export Cost Combination Line under the selected combination and checks that neither supplier nor source_name is blank (lines 281–287). The unsourced heads are listed by name in the error, making it easy to identify which pick is missing its quotation.
How SQ rows are resolved to charge heads
Portal-submitted Supplier Quotations (the guest /quote/<token> flow) carry no charge_item on their item rows — the vendor fills in a rate against the item code only, not the internal charge classification. The same resolution logic used during Pull Rates applies here:
charge_item = row.charge_item or head_by_service_item.get(row.item_code)
head_by_service_item is a lookup built from Export Charge Item.service_item → Export Charge Item.name (lines 299–304). So if a portal SQ row has item_code = "SRV-SEA-FREIGHT" and no charge_item, the resolver maps it to the "Sea Freight" charge head via the service item link on the charge head master. This is the same path that make_requests_for_quotation uses when it sets row_item_code for service heads.
Port-variant row resolution
A supplier may quote different rates for different loading ports (e.g. Nhava Sheva vs Mundra). On the SQ, each variant is a separate item row with em_port_of_loading set. Blank em_port_of_loading means "any port / ex-works."
When resolving which SQ row goes on the PO, the engine applies this priority rule (lines 324–330):
- Rows whose
em_port_of_loadingdoes not match the winning combination's port are skipped entirely (continue). - A blank-port row is always eligible (it never fails the port check).
- If both a blank-port row and an exact-port-match row exist for the same charge head, the exact port match wins — the
currentcandidate is replaced only when the incoming row has a port and the existing candidate does not.
This means the PO always carries the rate the supplier explicitly quoted for the shipment's actual loading port, falling back to the wildcard rate only when no port-specific row exists.
How quantities are determined
The PO item quantities come from the costing's own resolved charge quantities, not from what the supplier quoted (lines 305, 345–347):
qty_by_head = {(row.charge_item, row.item_code or ""): flt(row.qty) for row in costing.charges}
After the mapper builds the PO, the code iterates purchase_order.items and overwrites row.qty from this lookup:
if head and flt(qty_by_head.get(head)):
row.qty = qty_by_head[head]
The quantities in costing.charges are computed by resolve_charge_qty (defined in export_costing.py, lines 210–237). The mapping of qty_basis to actual quantity is:
qty_basis |
What it resolves to |
|---|---|
| Per Net MT | total_net_mt (or the item's own net_weight_mt for scoped rows) |
| Per Gross MT | total_gross_mt (or item's gross_weight_mt) |
| Per Kg | net_mt × 1000 |
| Per Bag | total_bags (or item's total_bags) |
| Per Container | total_containers (or item's no_of_containers) |
| Per BL | no_of_bl |
| Lumpsum | 1 |
| Manual | the literal qty value on the charge row |
So rice procurement (a goods head, Per Net MT or Per Gross MT basis) goes on the PO in MT, and sea freight (Per Container) goes on in containers — both sourced from the costing's weight and container calculations, not re-entered by hand.
Example from the demo seed: a Won costing with 5 × 40' HC containers of Basmati rice, 110 MT net, would put 110 on the Procurement Cost PO line and 5 on the Sea Freight PO line.
Company override
ERPNext's make_purchase_order mapper defaults the PO company to the logged-in user's default company. This is wrong when the SQ was raised under a different entity. The action explicitly overrides it (line 341):
purchase_order.company = frappe.db.get_value("Supplier Quotation", quotation_name, "company")
This ensures the PO is always booked under the same company that issued the RFQ and received the SQ — critical for correct payables, tax registration, and audit trail.
Why the mapper is used instead of building the PO by hand
The code routes through ERPNext's make_purchase_order mapper with filtered_children (lines 336–338):
purchase_order = make_purchase_order(
quotation_name, args={"filtered_children": list(head_by_row_name)}
)
filtered_children is the list of SQ item row names to include — only the rows resolved as winners get passed. The mapper is used because base_rate and base_amount on PO Item are required + read-only — they are computed internally by set_missing_values during the mapper's run_method call. Setting them directly would be wrong and would fail validation. Using the mapper means ERPNext handles all the currency conversion and base-amount computation in the same code path used by the native UI.
The schedule_date on the PO (and each item row) is set to costing.expected_shipment_date if present, falling back to today (lines 342–344).
Summary of what one PO run produces
For a costing with two winning Supplier Quotations — say, one from a rice mill for Procurement Cost and one from a freight forwarder covering Sea Freight + CFS Handling — the action drafts two POs:
- PO-001 → Rice Mill, 1 item row (Basmati, qty = 110 MT), under the SQ's company
- PO-002 → Freight Forwarder, 2 item rows (Sea Freight = 5 containers, CFS Handling = 5 containers), under the SQ's company
Each PO is saved as a Draft. The team reviews and submits them through the normal ERPNext purchasing workflow.
Step 3 — Create the Export Shipment
On a Won costing, Create > Export Shipment calls make_shipment (export_costing_actions.py, line 14). This builds the execution document that tracks cargo delivery, container allotment, documents, and vessel position.
Guard
if costing.status != "Won":
frappe.throw(_("Only a Won costing can become an Export Shipment."))
The guard is hard — a Draft, Quoted, or Lost costing cannot generate a shipment. A second guard prevents creating a duplicate if costing.shipment is already set and the document still exists.
What the shipment carries
The mapper copies the following from the costing:
| Shipment field | Source on costing |
|---|---|
export_costing |
costing.name |
customer |
costing.customer |
port_of_loading |
costing.port_of_loading |
port_of_discharge |
costing.port_of_discharge |
container_type |
costing.container_type |
currency |
costing.quote_currency |
required_net_mt |
costing.total_net_mt |
required_containers |
costing.total_containers |
Each costing item line becomes a shipment item row, carrying item_code, total_bags, unit_weight_kg, net_weight_mt, gross_weight_mt, and rate_per_mt (set to costing.quoted_price_per_mt).
Standard document checklist
STANDARD_DOCUMENTS is defined in export_shipment.py (lines 12–24). The action appends all 11 rows automatically with status = "Pending":
| Document | Prepared by |
|---|---|
| Proforma Invoice | Us |
| Sales Contract | Us |
| Commercial Invoice | Us |
| Packing List | Us |
| Phytosanitary Certificate | External Agency |
| Fumigation Certificate | External Agency |
| Certificate of Origin | External Agency |
| ECTN | CHA |
| BL Draft | CHA |
| Bill of Lading | CHA |
| Insurance Certificate | External Agency |
The checklist is pre-populated so nothing is missed — the team marks each document as complete as it is prepared or received.
Print formats
The Export Shipment drives three customer-facing print formats:
- Commercial Invoice — the formal invoice for customs and payment
- Packing List — itemised bag/weight breakdown per container
- Proforma Invoice — the advance document sent before shipment
These print formats are referenced from the Export Shipment doctype and are produced from the shipment's item rows (quantities, weights, rates) combined with the exporter profile from Export Settings.
Readiness tracking
The shipment's validate method sets readiness_status automatically:
- Ready —
delivered_net_mt >= required_net_mtANDcontainers_allotted >= required_containers - Partially Ready — any progress tracked (
purchased_net_mt,delivered_net_mt, orcontainers_allottedis non-zero) but not both cargo and container conditions are met - Not Ready — no progress recorded on any of those three fields
Both cargo delivery and container allotment must be complete independently before the shipment is considered ready to load. This mirrors the client's documented flow where cargo arriving at the CFS and container booking are parallel tracks.
Status after each action
| Action | Costing status becomes |
|---|---|
| Save (no quotation) | Draft |
| Create Quotation | Quoted |
| Submit Quotation + Create Sales Order + Submit | Won |
| Create Purchase Orders | Won (unchanged) |
| Create Export Shipment | Won (unchanged); costing.shipment is set |
There is no "Procuring" or "Shipped" status on the costing — the costing is a pricing document, not an operations tracker. The Export Shipment carries the operational state.
Common errors and what they mean
| Error message | Cause | Fix |
|---|---|---|
| "Select a winning combination first — POs follow the selected vendors." | No combination has is_selected = 1 |
Open Combinations, run Generate or manually select one |
| "These picks have no Supplier Quotation behind them, so no PO can be drafted: Sea Freight" | The combination line for that head has no source_name |
Either pull rates for that head or manually assign an SQ to the combination line |
| "No Supplier Quotation rows matched the winning picks." | After port filtering, no SQ rows survived | Check that the SQ has a row for the correct loading port (or a blank-port wildcard) |
| "Only a Won costing can become an Export Shipment." | Costing is still in Draft, Quoted, etc. | Complete the Sales Order step first |
| "Export Shipment {name} already exists for this costing." | costing.shipment points to an existing document |
Open the existing shipment; cancel it before recreating |