Native Salesforce Quoting Built for Manufacturing Sales Teams
Stop pasting spreadsheet quotes into emails. SilkQuote generates branded, buyer-ready manufacturing quotes directly from your Salesforce Opportunities — part numbers, lead times, freight terms, and payment conditions included. No CPQ. No external tools.
How It Works
- Install and set up the SilkQuote app — follow the setup guide →
- Create the Apex Class and Visualforce Page — copy the code below and paste each file into your Salesforce org via Setup
- Create a SilkQuote Template record — in the SilkQuote app, create a new template and set the Visualforce Page name to
SilkQuote_Manufacturing
If you need any assistance, reach out to support.
Requirements
Before you install, confirm you have:
- Salesforce edition — Professional, Enterprise, or Unlimited
- System Administrator profile — required for package installation
Install SilkQuote — free, installs in minutes →
Apex Class ManufacturingPDFController.cls
// Apex Controller Class/** * ManufacturingPDFController * * Unmanaged Apex controller for SilkQuote_Manufacturing.page. * Customers have full ownership of this class and can modify it freely. * * This controller is a pure data provider — it loads Salesforce records and * exposes simple string/boolean properties. All layout and HTML lives in * SilkQuote_Manufacturing.page, which is the only file you need to edit to * change how the document looks. * * Uses "without sharing" so it works in guest/site contexts (e.g. the * Collaboration Site iframe for quote review). Change to "with sharing" * if you only use this template in authenticated contexts. * * ============================================================ * CUSTOMIZATION GUIDE * ============================================================ * Accent color: Change ACCENT_COLOR constant below. * Logo: Set LOGO_ID to a ContentVersion or Document Id. * Company name: Set COMPANY_NAME (shown when no logo is set). * Tax: In loadLineItems(), replace taxVal = 0 with * your own tax logic. * Layout: Edit SilkQuote_Manufacturing.page — no Apex changes needed. * ============================================================ */public without sharing class ManufacturingPDFController { // ============================================================ // CUSTOMIZE THESE // ============================================================ private static final String ACCENT_COLOR = '#1B3A5C'; private static final String LOGO_ID = ''; // ContentVersion or Document Id private static final String COMPANY_NAME = ''; // Shown when no logo is set // ============================================================ // Styling public String accentColor { get; private set; } public String logoId { get; private set; } public String companyName { get; private set; } // Quote metadata public String quoteName { get; private set; } public String formattedDate { get; private set; } public String validUntil { get; private set; } // Rep / Sales contact public String repName { get; private set; } public String repPhone { get; private set; } // Bill To / Ship To address (uses billing address for both) public String accName { get; private set; } public String accStreet { get; private set; } public String accCityStateZip { get; private set; } public String accCountry { get; private set; } public String accPhone { get; private set; } // Line items public List<LineItemRow> lineItems { get; private set; } public Boolean hasLineItems { get; private set; } // Totals public String formattedSubtotal { get; private set; } public String formattedDiscount { get; private set; } public String formattedTax { get; private set; } public String formattedTotal { get; private set; } public Boolean hasDiscount { get; private set; } public Boolean hasTax { get; private set; } // Terms — raw HTML from the managed package, rendered with escape="false" public String termsHtml { get; private set; } public Boolean hasTerms { get; private set; } // ============================================================ // LineItemRow — one row of the products table. // ============================================================ public class LineItemRow { public Boolean isEven { get; set; } public String sku { get; set; } public String name { get; set; } public String description { get; set; } public Boolean hasDescription { get; set; } public String qty { get; set; } public String listPrice { get; set; } public String discount { get; set; } public Boolean hasDiscount { get; set; } public String unitPrice { get; set; } public String lineTotal { get; set; } } // ============================================================ // Constructor // ============================================================ public ManufacturingPDFController() { accentColor = ACCENT_COLOR; logoId = LOGO_ID; companyName = COMPANY_NAME; formattedDate = formatDate(Date.today()); lineItems = new List<LineItemRow>(); hasLineItems = false; hasTerms = false; hasDiscount = false; hasTax = false; termsHtml = ''; quoteName = repName = repPhone = ''; accName = accStreet = accCityStateZip = accCountry = accPhone = ''; validUntil = ''; formattedSubtotal = formattedDiscount = formattedTax = formattedTotal = '$0.00'; Map<String, String> params = ApexPages.currentPage().getParameters(); quoteName = safe(params.get('quoteName')); String oppId = sanitizeId(params.get('opportunityId')); if (String.isNotBlank(oppId)) { loadOpportunity(oppId); loadLineItems(oppId); } String termsId = sanitizeId(params.get('selectedTerms')); if (String.isNotBlank(termsId)) { loadTerms(termsId); } } // ============================================================ // Data Loading // ============================================================ private void loadOpportunity(String oppId) { List<Opportunity> opps = [ SELECT Id, Name, CloseDate, Account.Name, Account.BillingStreet, Account.BillingCity, Account.BillingState, Account.BillingPostalCode, Account.BillingCountry, Account.Phone, Owner.Name, Owner.Phone FROM Opportunity WHERE Id = :oppId LIMIT 1 ]; if (opps.isEmpty()) return; Opportunity opp = opps[0]; validUntil = opp.CloseDate != null ? formatDate(opp.CloseDate) : ''; repName = safe(opp.Owner.Name); repPhone = safe(opp.Owner.Phone); Account a = opp.Account; accName = safe(a.Name); accStreet = safe(a.BillingStreet); String city = safe(a.BillingCity); String state = safe(a.BillingState); String zip = safe(a.BillingPostalCode); accCityStateZip = city + (String.isNotBlank(state) ? ', ' + state : '') + (String.isNotBlank(zip) ? ' ' + zip : ''); accCountry = safe(a.BillingCountry); accPhone = safe(a.Phone); } private void loadLineItems(String oppId) { List<OpportunityLineItem> raw = [ SELECT Id, Name, ProductCode, Description, Quantity, ListPrice, UnitPrice, TotalPrice, Discount, silkquote__Hide_On_PDF__c FROM OpportunityLineItem WHERE OpportunityId = :oppId ORDER BY SortOrder ASC NULLS LAST, Name ASC ]; Decimal sub = 0, disc = 0; Integer n = 0; for (OpportunityLineItem item : raw) { if (item.silkquote__Hide_On_PDF__c == true) continue; n++; Decimal lineTotal = item.TotalPrice != null ? item.TotalPrice : 0; Decimal listTotal = (item.ListPrice != null ? item.ListPrice : 0) * (item.Quantity != null ? item.Quantity : 0); sub += lineTotal; disc += (listTotal - lineTotal); LineItemRow row = new LineItemRow(); row.isEven = Math.mod(n, 2) == 0; row.sku = safe(item.ProductCode); row.name = safe(item.Name); row.description = safe(item.Description); row.hasDescription = String.isNotBlank(item.Description); row.qty = formatQty(item.Quantity); row.listPrice = formatCurrency(item.ListPrice); row.hasDiscount = item.Discount != null && item.Discount > 0; row.discount = row.hasDiscount ? formatQty(item.Discount) + '%' : ''; row.unitPrice = formatCurrency(item.UnitPrice); row.lineTotal = formatCurrency(item.TotalPrice); lineItems.add(row); } hasLineItems = !lineItems.isEmpty(); Decimal subtotalVal = sub.setScale(2, RoundingMode.HALF_UP); Decimal discountVal = (disc < 0 ? 0 : disc).setScale(2, RoundingMode.HALF_UP); Decimal taxVal = 0; // ✏️ Add tax logic here if needed Decimal totalVal = (subtotalVal - discountVal + taxVal).setScale(2, RoundingMode.HALF_UP); formattedSubtotal = formatCurrency(subtotalVal); formattedDiscount = formatCurrency(discountVal); formattedTax = formatCurrency(taxVal); formattedTotal = formatCurrency(totalVal); hasDiscount = discountVal > 0; hasTax = taxVal > 0; } private void loadTerms(String termsId) { List<silkquote__Terms__c> termsList = [ SELECT silkquote__Terms_Text_Body__c FROM silkquote__Terms__c WHERE Id = :termsId LIMIT 1 ]; if (!termsList.isEmpty() && String.isNotBlank(termsList[0].silkquote__Terms_Text_Body__c)) { hasTerms = true; termsHtml = termsList[0].silkquote__Terms_Text_Body__c; } } // ============================================================ // Utilities // ============================================================ private static String safe(String val) { return val != null ? val : ''; } private static String formatCurrency(Decimal val) { if (val == null) return '$0.00'; Decimal v = val.setScale(2, RoundingMode.HALF_UP); String raw = String.valueOf(v.abs()); Integer dot = raw.indexOf('.'); String intPart = dot >= 0 ? raw.substring(0, dot) : raw; String decPart = dot >= 0 ? raw.substring(dot + 1) : '00'; while (decPart.length() < 2) decPart += '0'; if (decPart.length() > 2) decPart = decPart.substring(0, 2); String result = ''; Integer count = 0; for (Integer i = intPart.length() - 1; i >= 0; i--) { if (count > 0 && Math.mod(count, 3) == 0) result = ',' + result; result = intPart.substring(i, i + 1) + result; count++; } return (v < 0 ? '-' : '') + '$' + result + '.' + decPart; } private static String formatQty(Decimal val) { if (val == null) return ''; Long whole = val.longValue(); return (val == Decimal.valueOf(whole)) ? String.valueOf(whole) : String.valueOf(val.setScale(2, RoundingMode.HALF_UP)); } private static String formatDate(Date d) { if (d == null) return ''; String[] months = new String[]{ 'January','February','March','April','May','June', 'July','August','September','October','November','December' }; return months[d.month() - 1] + ' ' + d.day() + ', ' + d.year(); } private static String sanitizeId(String raw) { if (String.isBlank(raw)) return null; String clean = raw.trim().replaceAll('[^a-zA-Z0-9]', ''); return (clean.length() == 15 || clean.length() == 18) ? clean : null; }}Visualforce Page SilkQuote_Manufacturing.page
<apex:page controller="ManufacturingPDFController" renderAs="pdf" showHeader="false" sidebar="false" standardStylesheets="false" applyBodyTag="false" applyHtmlTag="false"><html><head> <meta charset="UTF-8"/> <style> @page { size: letter; margin: 0 0.35in 0.6in 0; } body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 11px; line-height: 18px; color: #1a1a1a; margin: 0; padding: 0; } /* ── Header band ─────────────────────────────────────────── */ .doc-header { width: 100%; padding: 16pt 20pt; box-sizing: border-box; } .doc-header table { width: 100%; border-collapse: collapse; } .doc-header .doc-title { font-size: 15pt; font-weight: bold; letter-spacing: 1.5pt; text-transform: uppercase; color: #1B3A5C; text-align: right; line-height: 1.2; } .doc-header .doc-sub { font-size: 8pt; color: rgba(255,255,255,0.6); text-align: right; margin-top: 2pt; letter-spacing: 0.5pt; } /* ── Content area ────────────────────────────────────────── */ .doc-body { padding: 14pt 4pt 20pt 20pt; box-sizing: border-box; } .section { margin-bottom: 14pt; } /* ── Address cards — Bill To / Ship To ───────────────────── */ .addr-table { width: 100%; border-collapse: collapse; } .addr-box { border: 1pt solid #b0bec8; vertical-align: top; } .addr-box-header { background-color: #1B3A5C; color: #ffffff; font-size: 7.5pt; font-weight: bold; text-transform: uppercase; letter-spacing: 1pt; padding: 4pt 10pt; } .addr-box-body { padding: 8pt 10pt; font-size: 10pt; line-height: 17px; color: #1a1a1a; } .addr-box-body .addr-name { font-weight: bold; margin-bottom: 2pt; } .addr-box-body .addr-line { color: #444; } /* ── Quote reference bar ─────────────────────────────────── */ .ref-table-wrap { border: 1pt solid #b0bec8; margin-bottom: 14pt; } .ref-table { width: 100%; border-collapse: collapse; font-size: 9.5pt; } .ref-table th { background-color: #2E86C1; color: #ffffff; font-size: 7.5pt; font-weight: bold; text-transform: uppercase; letter-spacing: 0.8pt; padding: 5pt 10pt; text-align: left; border-right: 1pt solid #5aa8d8; } .ref-table td { padding: 6pt 10pt; border-right: 1pt solid #b0bec8; vertical-align: top; } .ref-table th:last-child, .ref-table td:last-child { border-right: none; } /* ── Section header bar ──────────────────────────────────── */ .section-header { background-color: #1B3A5C; color: #ffffff; font-size: 8pt; font-weight: bold; text-transform: uppercase; letter-spacing: 1pt; padding: 5pt 8pt; margin-bottom: 0; } /* ── Line items table ────────────────────────────────────── */ .items-table-wrap { border: 1pt solid #b0bec8; } .items-table { width: 100%; border-collapse: collapse; font-size: 9.5pt; } .items-table th { background-color: #2E86C1; color: #ffffff; font-size: 8pt; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5pt; padding: 6pt 8pt; text-align: left; border-right: 1pt solid #5aa8d8; } .items-table th.right { text-align: right; } .items-table th:last-child { border-right: none; } .items-table td { padding: 6pt 8pt; border-bottom: 1pt solid #d0dae4; border-right: 1pt solid #d0dae4; vertical-align: top; color: #1a1a1a; } .items-table td.right { text-align: right; } .items-table td:last-child { border-right: none; } .items-table .even td { background-color: #f4f8fc; } .item-name { font-weight: bold; } .item-sku { font-size: 8pt; color: #666; margin-bottom: 2pt; } .item-desc { font-size: 8.5pt; color: #666; margin-top: 2pt; } .item-disc { font-size: 8pt; color: #888; } /* ── Totals ──────────────────────────────────────────────── */ .totals-wrap { width: 46%; margin-left: auto; } .totals-table { width: 100%; border-collapse: collapse; border: 1pt solid #b0bec8; font-size: 9.5pt; } .totals-table td { padding: 5pt 10pt; border-bottom: 1pt solid #d0dae4; } .totals-table .t-label { color: #444; } .totals-table .t-value { text-align: right; font-weight: bold; } .totals-table .total-row td { background-color: #1B3A5C; color: #ffffff; font-size: 10.5pt; font-weight: bold; border-bottom: none; padding: 7pt 10pt; } /* ── Footer ──────────────────────────────────────────────── */ .doc-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 5pt 20pt; font-size: 7.5pt; border-top: 0.5pt solid #b0bec8; background: #ffffff; color: #666; } .doc-footer table { width: 100%; border-collapse: collapse; } /* ── Terms page ──────────────────────────────────────────── */ .terms-page { page-break-before: always; padding: 20pt; } .terms-title { font-size: 14pt; font-weight: bold; color: #1a1a1a; margin-bottom: 16pt; text-align: center; } .terms-content { font-size: 10px; line-height: 18px; color: #444; white-space: pre-line; word-wrap: break-word; } </style></head><body> <!-- ══════════════════════════════════════════════════════════ HEADER BAND ══════════════════════════════════════════════════════════ --> <div class="doc-header"> <table> <tr> <td style="vertical-align:middle;width:50%;"> <apex:outputPanel rendered="{!NOT(ISBLANK(logoId))}"> <img src="/servlet/servlet.FileDownload?file={!URLENCODE(logoId)}" style="max-height:40pt; max-width:150pt;" alt="Logo"/> </apex:outputPanel> <apex:outputPanel rendered="{!ISBLANK(logoId)}"> <div style="font-size:14pt;font-weight:bold;color:#1B3A5C;letter-spacing:0.5pt;">{!companyName}</div> </apex:outputPanel> </td> <td style="vertical-align:middle;text-align:right;width:46%;"> <!-- ✏️ CUSTOMIZE: Change document title here --> <div class="doc-title">Purchase Quotation</div> </td> <td style="width:4%;"></td> </tr> </table> </div> <div class="doc-body"> <!-- ══════════════════════════════════════════════════════ BILL TO / SHIP TO — bordered address cards ══════════════════════════════════════════════════════ --> <div class="section"> <table class="addr-table"> <tr> <td style="width:48%;" class="addr-box"> <div class="addr-box-header">Bill To</div> <div class="addr-box-body"> <div class="addr-name">{!accName}</div> <apex:outputPanel rendered="{!NOT(ISBLANK(accStreet))}"> <div class="addr-line">{!accStreet}</div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(ISBLANK(accCityStateZip))}"> <div class="addr-line">{!accCityStateZip}</div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(ISBLANK(accCountry))}"> <div class="addr-line">{!accCountry}</div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(ISBLANK(accPhone))}"> <div class="addr-line" style="margin-top:3pt;">{!accPhone}</div> </apex:outputPanel> </div> </td> <td style="width:4%;"></td> <td style="width:48%;" class="addr-box"> <div class="addr-box-header">Ship To</div> <div class="addr-box-body"> <div class="addr-name">{!accName}</div> <apex:outputPanel rendered="{!NOT(ISBLANK(accStreet))}"> <div class="addr-line">{!accStreet}</div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(ISBLANK(accCityStateZip))}"> <div class="addr-line">{!accCityStateZip}</div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(ISBLANK(accCountry))}"> <div class="addr-line">{!accCountry}</div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(ISBLANK(accPhone))}"> <div class="addr-line" style="margin-top:3pt;">{!accPhone}</div> </apex:outputPanel> </div> </td> </tr> </table> </div> <!-- ══════════════════════════════════════════════════════ QUOTE REFERENCE BAR ══════════════════════════════════════════════════════ --> <div class="section"> <div class="ref-table-wrap"> <table class="ref-table"> <thead> <tr> <th style="width:25%;">Quote Name</th> <th style="width:20%;">Date</th> <th style="width:20%;">Valid Until</th> <th style="width:17.5%;">Sales Rep</th> <th style="width:17.5%;">Rep Phone</th> </tr> </thead> <tbody> <tr> <td>{!quoteName}</td> <td>{!formattedDate}</td> <td>{!validUntil}</td> <td>{!repName}</td> <td>{!repPhone}</td> </tr> </tbody> </table> </div> </div> <!-- ══════════════════════════════════════════════════════ PRODUCTS ══════════════════════════════════════════════════════ --> <div class="section"> <div class="section-header">Products</div> <apex:outputPanel rendered="{!hasLineItems}"> <div class="items-table-wrap"> <table class="items-table"> <thead> <tr> <th style="width:12%;">SKU</th> <th>Product</th> <th class="right" style="width:8%;">Qty</th> <th class="right" style="width:13%;">List Price</th> <th class="right" style="width:10%;">Discount</th> <th class="right" style="width:13%;">Unit Price</th> <th class="right" style="width:13%;">Total</th> </tr> </thead> <tbody> <apex:repeat value="{!lineItems}" var="row"> <tr class="{!IF(row.isEven, 'even', '')}"> <td style="font-size:8.5pt;">{!row.sku}</td> <td> <div class="item-name">{!row.name}</div> <apex:outputPanel rendered="{!row.hasDescription}"> <div class="item-desc">{!row.description}</div> </apex:outputPanel> </td> <td class="right">{!row.qty}</td> <td class="right">{!row.listPrice}</td> <td class="right"> <apex:outputPanel rendered="{!row.hasDiscount}"> <span class="item-disc">{!row.discount}</span> </apex:outputPanel> </td> <td class="right">{!row.unitPrice}</td> <td class="right">{!row.lineTotal}</td> </tr> </apex:repeat> </tbody> </table> </div> </apex:outputPanel> <apex:outputPanel rendered="{!NOT(hasLineItems)}"> <p style="color:#aaa;font-size:10pt;padding:8pt 0;">No products listed.</p> </apex:outputPanel> </div> <!-- ══════════════════════════════════════════════════════ TOTALS ══════════════════════════════════════════════════════ --> <div class="section"> <div class="totals-wrap"> <table class="totals-table"> <tr> <td class="t-label">Subtotal</td> <td class="t-value">{!formattedSubtotal}</td> </tr> <apex:outputPanel rendered="{!hasDiscount}" layout="none"> <tr> <td class="t-label">Discount</td> <td class="t-value">({!formattedDiscount})</td> </tr> </apex:outputPanel> <apex:outputPanel rendered="{!hasTax}" layout="none"> <tr> <td class="t-label">Tax</td> <td class="t-value">{!formattedTax}</td> </tr> </apex:outputPanel> <tr class="total-row"> <td>Total Due</td> <td style="text-align:right;font-weight:bold;">{!formattedTotal}</td> </tr> </table> </div> </div> </div><!-- end doc-body --> <!-- ══════════════════════════════════════════════════════════ FIXED FOOTER ══════════════════════════════════════════════════════════ --> <div class="doc-footer"> <table> <tr> <td style="text-align:left;">{!accName}</td> <td style="text-align:right;white-space:nowrap;">{!quoteName} | {!formattedDate}</td> </tr> </table> </div><!-- ══════════════════════════════════════════════════════════ TERMS & CONDITIONS — new page══════════════════════════════════════════════════════════ --><apex:outputPanel rendered="{!hasTerms}"> <div class="terms-page"> <div class="terms-title">Terms & Conditions</div> <div class="terms-content"> <apex:outputText value="{!termsHtml}" escape="false"/> </div> </div></apex:outputPanel></body></html></apex:page>Frequently Asked Questions
How do I create a bill of materials quote in Salesforce?
Salesforce doesn’t generate BOM-style quotes natively, but SilkQuote does. Once installed, you configure a manufacturing template with part number, description, quantity, unit price, extended total, and lead time columns — then generate a formatted PDF from any Opportunity with one click.
Can Salesforce generate a PDF purchase quote with part numbers?
Yes, with SilkQuote. SilkQuote reads your Opportunity Products and formats them into a professional PDF quote that includes part numbers, quantities, pricing, lead times, and payment terms. No spreadsheet, no copy-pasting.
What Salesforce quoting tools work for manufacturers without CPQ?
SilkQuote is built for exactly this use case. It works with standard Salesforce Opportunities and Products — no CPQ license required. Install it from AppExchange, configure your BOM-style template once, and your team can generate quotes directly from Salesforce.
How do I include freight and payment terms on a Salesforce quote?
SilkQuote’s template editor includes dedicated sections for freight and shipping terms (FOB point, carrier, prepaid/collect) and payment terms (Net 30, deposit requirements, progress billing). These appear as structured sections in the PDF below the line items.



