Sending RFQs
Section: Vendor Rates & Combinations
Overview
Once an Export Costing has been built with its charge heads, the next step is to get vendor rates. The app generates one Request for Quotation (RFQ) per cost category rather than one combined sheet. This is deliberate: a rice mill should never see freight costs, and a freight forwarder should never see your purchase price. Each supplier receives only the rows that belong to their trade.
Vendors can then quote without an ERPNext login — via a tokenized guest link or the email that RFQ submission fires — and the result is a submitted Supplier Quotation that flows directly into the combination engine.
What Can Be Put on an RFQ
Not every charge head is quotable. The constant RFQ_QTY_BASES in export_costing_actions.py defines the allowed unit types:
| Qty Basis | Can be RFQ'd? | Reason |
|---|---|---|
| Per Net MT | Yes | Vendor can name a unit price |
| Per Gross MT | Yes | Vendor can name a unit price |
| Per Kg | Yes | Vendor can name a unit price |
| Per Bag | Yes | Vendor can name a unit price |
| Per Container | Yes | Vendor can name a unit price |
| Per BL | Yes | Vendor can name a unit price |
| Lumpsum | Yes | One total — still a direct vendor quote |
| Percent of Subtotal | No | Derived from other costs; no vendor sets this |
| Manual | No | Entered directly by the team |
Why this matters: A
Percent of Subtotalrow (e.g. insurance calculated as a percentage of the FOB value) is purely a formula — there is no vendor to ask. Sending it on an RFQ would make no sense and the function throws"No quotable charge head selected — derived bases (percent/manual) cannot be RFQ'd."if the selection collapses to zero valid rows.
Creating RFQs: make_requests_for_quotation
Entry point: Create ▸ Requests for Quotation on the Export Costing form.
Function: export_management.export_management.doctype.export_costing.export_costing_actions.make_requests_for_quotation
Parameters:
| Parameter | Type | Description |
|---|---|---|
export_costing |
str |
Name of the Export Costing document |
charge_items |
str \| list \| None |
Optional filter — only create RFQs for these charge heads. If omitted, all quotable heads get an RFQ. |
What the Function Does, Step by Step
1. Permission check. The function calls costing.check_permission("write") — a user who can only read the costing cannot generate RFQs.
2. Filter to quotable rows. All rows in costing.charges whose qty_basis is in RFQ_QTY_BASES are collected. If the caller passed a charge_items list, it narrows further to those heads.
3. Resolve item metadata in bulk. To avoid N+1 queries, a single frappe.get_all fetches every relevant Export Charge Item (to get is_goods and service_item), and a second frappe.get_all fetches every relevant Item (to get stock_uom, item_name, description).
4. One RFQ per charge row. For each quotable charge row the function creates a new Request for Quotation document and appends exactly one item row. The item placed on the RFQ depends on the head type:
Head type (is_goods) |
Item put on the RFQ | applies_to_item |
|---|---|---|
| Goods (e.g. Procurement Cost) | The real product Item from the costing row (charge_row.item_code) |
The product item code — scopes the rate to this commodity |
| Service (e.g. Sea Freight, CFS Handling) | The Export Charge Item's synthetic service_item |
None |
This means a rice procurement RFQ contains the actual rice Item — so the eventual Purchase Order buys the commodity rather than an abstract service. A freight RFQ contains the synthetic freight service item. For goods heads, applies_to_item is set to charge_row.item_code; for service heads it is left None.
5. Route fields stamped on the RFQ. Three fields from the costing are copied to the header of every RFQ so the supplier and the Pull Rates step know the trade route:
| Field on RFQ | Source on Costing |
|---|---|
em_port_of_loading |
costing.port_of_loading |
em_port_of_discharge |
costing.port_of_discharge |
em_container_type |
costing.container_type |
These custom fields carry the em_ prefix to prevent collisions with ERPNext's own fields. The standard RFQ → Supplier Quotation mapper copies them automatically to the SQ header.
6. Item row fields. Beyond the standard ERPNext item row fields, each row carries:
| Custom field | Value | Purpose |
|---|---|---|
charge_item |
The charge head name (e.g. "Sea Freight") |
Links the SQ row back to the right charge head during Pull Rates |
em_qty_basis |
The row's qty_basis (e.g. "Per Container") |
Tells Pull Rates how to apply the rate |
applies_to_item |
The product item code (goods heads only; None for service heads) |
Scopes the rate to one commodity when a costing has multiple products |
7. Email template. Every RFQ gets email_template = "Vendor Rate Quote Request" (the constant RFQ_EMAIL_TEMPLATE_NAME). This is the branded template shipped with the app (vendor_rate_quote_request.html). The template is created once on install/migrate and is intentionally never overwritten by later migrations so desk edits survive.
8. Inserted as Draft with ignore_mandatory. ERPNext's RFQ doctype requires at least one supplier before it can save. But the supplier list is the user's next step on the RFQ form — they know their vendors, not the code. The function inserts each RFQ with ignore_mandatory=True so the draft lands in the system immediately. When the user submits the RFQ after adding suppliers, ERPNext re-runs all mandatory validation, meaning an RFQ with no suppliers can never transition to the submitted state that triggers email and guest links.
The function returns the list of RFQ names it created.
After Creation: Add Suppliers and Submit
On each generated RFQ (linked from the Export Costing's RFQ list):
- Open the RFQ.
- Add the relevant suppliers to the
supplierstable — mills for goods heads, forwarders for service heads, etc. - Set
Send Email = Yesand confirm theemail_idfor each supplier row you want to notify. - Submit the RFQ.
On submit, the before_submit hook in doc_events/request_for_quotation.py mints a unique 32-character random token (em_guest_token) for every supplier row that does not already have one. These tokens are stored on the Request for Quotation Supplier child row with allow_on_submit = 1 (so they can be written to a submitted document).
Why tokens are minted before submit, not during email render: Frappe's Jinja sandbox forbids database writes mid-render. The email template calls
get_guest_quote_url, which must only read the existing token. Pre-minting inbefore_submitkeeps that path read-only.
Two Ways Vendors Quote
Both paths produce a submitted Supplier Quotation. This is intentional — the combination engine only consumes submitted SQs, so vendor rates flow into the costing without a manual review or submission step.
Path 1: No-Login Guest Link (/quote/<token>)
For vendors who will not or cannot use ERPNext.
After the RFQ is submitted, go to Guest Quote Links on the RFQ form. This calls get_guest_quote_links, which:
- Verifies the RFQ is submitted (throws if not).
- Iterates the RFQ's supplier rows.
- Returns each supplier's
em_guest_token(minting it lazily if somehow absent). - Returns a list of
{supplier, link}dicts wherelinkis the full URL/quote/<token>.
Send the link to the vendor by any channel (WhatsApp, manual email, etc.).
What the vendor sees at /quote/<token>:
The page resolves the token via resolve_token, which looks up the Request for Quotation Supplier row where em_guest_token matches and docstatus = 1 (i.e. the RFQ is submitted). If the token does not exist or the RFQ is not submitted, the page shows an "invalid or withdrawn" error.
The vendor sees the RFQ's item rows and can enter rates. Crucially, vendors can quote multiple port variants for the same head: one rate with a blank port (ex-works — buyer arranges pickup from the mill) and additional rates keyed to specific ports of loading (delivered-at-port). Each port variant becomes a separate row on the resulting Supplier Quotation item table, with em_port_of_loading set accordingly. A blank port means "any port / ex-works wildcard."
When the vendor submits, submit_guest_quote runs server-side. The server trusts only the rate values from the guest — everything else (supplier, company, item codes, route fields, charge_item, em_qty_basis, applies_to_item) is reconstructed from the RFQ via the token. This means a guest can only ever set prices, never forge metadata.
The resulting Supplier Quotation is immediately submitted (quotation.submit()). Optional notes the vendor types are saved as a Comment on the SQ.
One submission per link: if a Supplier Quotation already exists (draft or submitted) for this RFQ + supplier pair, the endpoint throws "A quotation for this link was already submitted — thank you." The endpoint is also rate-limited to 10 calls per 60 seconds per token.
Revoking a guest link: Clear the em_guest_token field on the RFQ supplier row. The field is hidden = 1 but the desk shows it via the Guest Quote Links helper. Once cleared, the old URL returns "invalid or withdrawn."
Path 2: Email
For vendors comfortable with email.
When the RFQ is submitted, ERPNext's standard submit flow sends the configured email_template to suppliers with send_email = Yes. The "Vendor Rate Quote Request" template renders a branded email with the team's identity and a "Submit your Quotation" button that links to /quote/<token> — the same guest page as Path 1.
There is also a Resend button on the RFQ (calling send_guest_rfq_emails) that re-sends the branded email to all supplier rows that have send_email = Yes and a valid email_id. Already-sent rows are re-sent; this is intentional (a manual desk action).
One page, two entry channels. Both paths land on
/quote/<token>. There is no separate "email portal" vs "direct link" portal — only one no-login quote page. This simplifies support: if a vendor claims they cannot submit, the diagnosis is always the same page.
Field Reference: Custom Fields Added by This App
On Request for Quotation (header)
| Fieldname | Type | Description |
|---|---|---|
export_costing |
Link → Export Costing | The costing this RFQ was raised from |
em_port_of_loading |
Link → Port | Trade route: origin port |
em_port_of_discharge |
Link → Port | Trade route: destination port |
em_container_type |
Select | Container specification (20' FCL, 40' FCL, 40' HC) |
On Request for Quotation Item (item rows)
| Fieldname | Type | Description |
|---|---|---|
charge_item |
Link → Export Charge Item | Which cost category this row prices |
em_qty_basis |
Select | Unit basis (Per Net MT, Per Container, Lumpsum, etc.) |
applies_to_item |
Link → Item | For goods heads: which product this rate is for; None for service heads |
em_port_of_loading |
Link → Port | Per-row port: blank = ex-works/any-port wildcard; set = rate landed at this port |
On Request for Quotation Supplier (supplier rows)
| Fieldname | Type | Description |
|---|---|---|
em_guest_token |
Data | 32-char random token backing /quote/<token>; hidden, read-only, allow_on_submit; clear to revoke |
The same route fields (export_costing, em_port_of_loading, em_port_of_discharge, em_container_type) and item row fields (charge_item, em_qty_basis, applies_to_item, em_port_of_loading) are added to Supplier Quotation and Supplier Quotation Item respectively, so the standard ERPNext RFQ → SQ mapper copies them without any custom logic.
Demo Walk-Through
In the demo seed (export.localhost:8010):
- The seed inserts submitted Supplier Quotations directly (bypassing the RFQ flow) so the demo costing already has rates and a winning combination applied.
- To exercise the full RFQ path from scratch, create a new Export Costing from any Opportunity, apply a costing template, then use Create ▸ Requests for Quotation. Tick "Procurement Cost" and "Sea Freight" to generate two RFQs — one will show the rice item, the other the freight service item. Add yourself as a supplier with a real email and submit.
Common Mistakes
Submitting without suppliers: The draft insert with
ignore_mandatory=Truemeans the RFQ saves even if the suppliers table is empty. ERPNext will refuse to submit in that state. Always add at least one supplier before clicking Submit.
Sending the guest link before submitting the RFQ:
get_guest_quote_linksthrows"Submit the RFQ first — guest links are for sent requests."Guest tokens are only meaningful on submitted RFQs.
Trying to RFQ a Percent-of-Subtotal head: The function silently drops non-quotable rows. If ALL selected heads are non-quotable, it throws. If only some are non-quotable, the others proceed without warning — this is expected behavior, not a bug.
Vendor submits twice: The one-submission guard on
submit_guest_quoteblocks a second submission on the same token. If the vendor made an error, a team member must cancel the first SQ and clear the token to issue a fresh link (or mint a new token by saving the supplier row).