---
title: "Sales Orders & Purchase Orders"
space: "Wiki"
url: "https://jmtradelink.com/docs/quoting-and-winning/sales-orders-purchase-orders"
updated: "2026-06-22"
---

# 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:

```python
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:

```python
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):

1. Rows whose `em_port_of_loading` does not match the winning combination's port are **skipped entirely** (`continue`).
2. A blank-port row is always eligible (it never fails the port check).
3. If both a blank-port row and an exact-port-match row exist for the same charge head, the **exact port match wins** — the `current` candidate 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):

```python
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:

```python
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):

```python
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):

```python
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

```python
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_mt` AND `containers_allotted >= required_containers`
- **Partially Ready** — any progress tracked (`purchased_net_mt`, `delivered_net_mt`, or `containers_allotted` is 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 |
