<?xml version="1.0" encoding="UTF-8" ?>
<?xml-stylesheet type="text/xsl" href="https://sugarclub.sugarai.com/cfs-file/__key/system/syndication/rss.xsl" media="screen"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Creating Complex Quotes Documents with Doc Merge</title><link>https://sugarclub.sugarai.com/dev-club/w/dev-tutorials/1020/creating-complex-quotes-documents-with-doc-merge</link><description /><dc:language>en-US</dc:language><generator>Telligent Community 12</generator><item><title>Creating Complex Quotes Documents with Doc Merge</title><link>https://sugarclub.sugarai.com/dev-club/w/dev-tutorials/1020/creating-complex-quotes-documents-with-doc-merge</link><pubDate>Tue, 27 Jan 2026 00:10:05 GMT</pubDate><guid isPermaLink="false">5c521d64-519d-47a6-9065-134618b211bf:0a2397e9-eb30-4c91-8d0e-1aad875f5073</guid><dc:creator>Keith Neuendorff</dc:creator><comments>https://sugarclub.sugarai.com/dev-club/w/dev-tutorials/1020/creating-complex-quotes-documents-with-doc-merge#comments</comments><description>Current Revision posted to Dev Tutorials by Keith Neuendorff on 1/27/2026 12:10:05 AM&lt;br /&gt;
&lt;div class="table-of-contents"&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="#mcetoc_1jfuamvps0"&gt;Introduction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#mcetoc_1jfuamvps1"&gt;A need for a complex quote document&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#mcetoc_1jfuar2ks4"&gt;A new approach&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#mcetoc_1jfuamvps2"&gt;The technical details plus the code and template&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="mcetoc_1jfuamvps0" class="p1"&gt;&lt;span style="font-size:inherit;"&gt;Introduction&lt;/span&gt;&lt;/h2&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;Doc Merge is a very useful but underused feature in Sugar.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;We have leveraged it in a number of projects and recently had a chance to use it for a Quote generation project for a client that required us to think creatively to produce a complex quote document that&amp;nbsp;also required a&amp;nbsp;reasonably&amp;nbsp; short document generation time.&lt;/span&gt;&lt;/p&gt;
&lt;h2 id="mcetoc_1jfuamvps1" class="p1"&gt;&lt;span style="font-size:inherit;"&gt;A need for a complex quote document&lt;/span&gt;&lt;/h2&gt;
&lt;p class="p2"&gt;The client needed to be able to provide quotes that had multiple scenarios that&amp;nbsp;could show multiple items and the costs for each item across multiple years.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;They had previously developed a custom Line Item module that could represent an item and the pricing for&amp;nbsp;a single year and associate it to a scenario.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; An i&lt;/span&gt;tem could be in one or more scenarios.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; Even&lt;/span&gt;&amp;nbsp;though this was done with a custom module, the approach we used can also be applied to the stock Quoted Line Items module.&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;Here is an example of a set of Line Items that represents two scenarios with multiple items.&lt;/span&gt;&lt;/p&gt;
&lt;p class="p2"&gt;&lt;img style="max-height:375px;max-width:500px;" alt=" " src="/resized-image/__size/1000x750/__key/communityserver-wikis-components-files/00-00-00-00-14/ExampleData.png" /&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;They had a way to define their complex quote with the data for their custom module but they needed a way to translate it to a document they could present to their customers.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;They had an initial idea for the document which&amp;nbsp;we&amp;nbsp;then developed into a full version.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;Each scenario would exist as a separate section in the generated document.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;In each section, every item in that scenario would have its own table.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;In each of those tables there would be a line for each year showing the pricing being offered for that item in a single year.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;The following is an example of what we were trying to generated for a document&amp;nbsp;using&amp;nbsp;the previous example data.&lt;/span&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style="max-height:375px;max-width:500px;" alt=" " src="/resized-image/__size/1000x750/__key/communityserver-wikis-components-files/00-00-00-00-14/ExampleResults.png" /&gt;&lt;/p&gt;
&lt;p class="p2"&gt;With some effort, a person&amp;nbsp;could sort the data into those sections and tables to produce a document, but it is error prone and time consuming.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;There was a definite value to being able to have Doc Merge inside Sugar produce&amp;nbsp;the document.&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;Our initial solution had a way of getting all of the item numbers for a scenario, looping through all of the items to create a table for each one and then for each item table again looping through the Line Items to produce a list of the line items ordered by year.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;This approach used the standard looping for a related module that Doc Merge provides and it worked and produced the desired quote.&lt;/span&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;However, we found that beyond very simple cases, the time to generated the document was growing very long.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;And the more scenarios, items, and years we added Line Item records for, the longer the generation time would get.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;For a few scenarios with a few items with less than five years, we were getting generation times of over 30 minutes and it was getting worse as we added more data.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;The two layers of looping through the related records was simply not going to work.&lt;/span&gt;&lt;/p&gt;
&lt;h2 id="mcetoc_1jfuar2ks4" class="p1"&gt;&lt;span style="font-size:inherit;"&gt;A new Doc Merge approach&lt;/span&gt;&lt;/h2&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;After brainstorming the technical side of this problem, we came up with a new approach for creating the data that would be picked up by Doc Merge.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;We decided to &amp;ldquo;pre-render&amp;rdquo; the data tables for each line item in a scenario so that Doc Merge would only have a single loop for each scenario where it would just&amp;nbsp;need to pick up the block of data to be shown in each table.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;This does required a code customization, but after considering a few ways to do it, we settled on&amp;nbsp;using just a before_save logic hook on the Line Item module.&lt;/span&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;The logic hook we made triggers when a Line Item record is created or updated.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;When this hook is triggered, the table for that line item is generated and saved and then all of the other tables are also updated.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;This full update of all tables was done to cover the case where a Line Item record was initially in one scenario/item but was moved to another scenario/item, which guarantees that all pre-rendered data is correct.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;This approach in the logic hook did not add any noticeable time to the save of a Line Item, but it did improve the generation time of a quote document from 30+ minutes to an average of 15 seconds.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;We also found that adding more scenarios, items, and years added very little time under the new approach and it satisfied the client needs and provided a good user experience.&lt;/span&gt;&lt;/p&gt;
&lt;h2 id="mcetoc_1jfuamvps2" class="p1"&gt;&lt;span style="font-size:inherit;"&gt;The technical details plus the code and template&lt;/span&gt;&lt;/h2&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;To make this approach work, we needed to specify on the quote what the first year covered by the Line Items would be.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;This was passed down to the individual Line Items on a calculated field.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;The pre-rendered table data is saved on the earliest year Line Item record for each table.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;For the text in the tables, we needed to have all the pre-rendered text across multiple lines be&amp;nbsp;presented as one text block in the resulting table.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;We generated column divisions in that text data with the &amp;ldquo;|&amp;rdquo; character substituting for vertical lines in the data and used a monospaced font to make sure everything lined up.&lt;span class="Apple-converted-space"&gt;&amp;nbsp; &lt;/span&gt;We did look at using tab characters in the template to line up columns, but the complexity was judged to not be worth it.&lt;/span&gt;&lt;/p&gt;
&lt;p class="p1"&gt;&lt;span style="font-size:inherit;"&gt;Here is the code for the logic hook and the template file that was used.&amp;nbsp; We hope that this information provides some inspiration for solving interesting client customization challenges.&lt;/span&gt;&lt;/p&gt;
&lt;p class="p2"&gt;&lt;pre class="ui-code" data-mode="php"&gt;&amp;lt;?php
if (!defined(&amp;#39;sugarEntry&amp;#39;) || !sugarEntry) define(&amp;#39;sugarEntry&amp;#39;, true);

class CollectMatchingLineItemsHook
{
    /**
     * before_save hook: collect other line items under the same parent Quote
     * with quantity == 100 and attach them to $bean-&amp;gt;matching_line_items (non-persistent)
     */
    public function beforeSaveLineItemProcessing(&amp;amp;$bean, $event, $arguments)
    {
        $parentQuoteId = $this-&amp;gt;getParentQuoteId($bean);
        if (empty($parentQuoteId)) {
            $bean-&amp;gt;line_item_group_summary_c = &amp;#39;&amp;#39;;
            $bean-&amp;gt;save_flag_c = &amp;#39;&amp;#39;;
            return;
        }

        $subChar = &amp;#39;^&amp;#39;;
        $cpad = 6;
        $vpad = 7;
        $newRecordLine = &amp;#39;&amp;#39;;
        if (empty($bean-&amp;gt;fetched_row[&amp;#39;id&amp;#39;])) {
            if($bean-&amp;gt;scenario_key_value_c == &amp;#39;&amp;#39;) {
                if(strval($bean-&amp;gt;hide_line_item_c) !== &amp;#39;1&amp;#39;) {
                    $newRecordLine = str_pad(strval($bean-&amp;gt;year_text_c),$cpad).&amp;quot; |      &amp;quot;.sprintf(&amp;quot;%{$vpad}s&amp;quot;, number_format($bean-&amp;gt;volume)).&amp;quot; |  &amp;quot;.$this-&amp;gt;formatDecimal($bean-&amp;gt;base_unit_price).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($bean-&amp;gt;unit_surcharge).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($bean-&amp;gt;total_unit_price).$subChar;
                }
            }
        }

        if($bean-&amp;gt;save_flag_c == 1) {
            if($bean-&amp;gt;scenario_key_value_c == &amp;#39;&amp;#39;) {
                $bean-&amp;gt;line_item_group_summary_c = &amp;#39;&amp;#39;;
            }
            else {
                $this-&amp;gt;updateLineItemSummary($bean);
            }
            $bean-&amp;gt;save_flag_c = &amp;#39;&amp;#39;;
        }
        else {
            if($bean-&amp;gt;scenario_key_value_c == &amp;#39;&amp;#39;) {
                $bean-&amp;gt;line_item_group_summary_c = &amp;#39;&amp;#39;;
                $this-&amp;gt;getAndSaveQuoteBeanKeyItems( $parentQuoteId, null, $newRecordLine, $bean-&amp;gt;scenario_item_group_c );
            }
            else {
                $this-&amp;gt;updateLineItemSummary($bean);
                $this-&amp;gt;getAndSaveQuoteBeanKeyItems( $parentQuoteId, $bean-&amp;gt;id );
            }
        }
        $bean-&amp;gt;new_record_line_text_c = &amp;#39;&amp;#39;;
    }


    /* Returns array of beans for the related items
    * The primary key $bean will always be the one in the first [0] position
    */
    protected function updateLineItemSummary( &amp;amp;$lineItemBean ) {
        $parentQuoteId = $this-&amp;gt;getParentQuoteId($lineItemBean);
        if (empty($parentQuoteId)) {
            $lineItemBean-&amp;gt;line_item_group_summary_c = &amp;#39;&amp;#39;;
            return;
        }

        $newTextYear = &amp;#39;&amp;#39;;
        if($lineItemBean-&amp;gt;new_record_line_text_c !== &amp;#39;&amp;#39;) {
            $newTextYear = substr($lineItemBean-&amp;gt;new_record_line_text_c, 0, 4);
        }
        $lineArray = [];

        $itemBeansOnQuote = $this-&amp;gt;getItemBeansForQBean($parentQuoteId, $lineItemBean-&amp;gt;scenario_number, $lineItemBean-&amp;gt;item_number);

        $subChar = &amp;#39;^&amp;#39;;
        $cpad = 6;
        $vpad = 7;
        $lineItemSummaryNew = &amp;#39;&amp;#39;;
        if(strval($lineItemBean-&amp;gt;hide_line_item_c) !== &amp;#39;1&amp;#39;) {
            $lineItemSummaryNew .= str_pad(strval($lineItemBean-&amp;gt;year_text_c),$cpad).&amp;quot; |      &amp;quot;.sprintf(&amp;quot;%{$vpad}s&amp;quot;, number_format($lineItemBean-&amp;gt;volume)).&amp;quot; |  &amp;quot;.$this-&amp;gt;formatDecimal($lineItemBean-&amp;gt;base_unit_price).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($lineItemBean-&amp;gt;unit_surcharge).&amp;quot;| &amp;quot;.$this-&amp;gt;formatDecimal($lineItemBean-&amp;gt;total_unit_price).$subChar;
            $lineArray[$lineItemBean-&amp;gt;year_text_c] = str_pad(strval($lineItemBean-&amp;gt;year_text_c),$cpad).&amp;quot; |      &amp;quot;.sprintf(&amp;quot;%{$vpad}s&amp;quot;, number_format($lineItemBean-&amp;gt;volume)).&amp;quot; |  &amp;quot;.$this-&amp;gt;formatDecimal($lineItemBean-&amp;gt;base_unit_price).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($lineItemBean-&amp;gt;unit_surcharge).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($lineItemBean-&amp;gt;total_unit_price).$subChar;
        }
        $summaryRecordId = $lineItemBean-&amp;gt;id;
        foreach ($itemBeansOnQuote as $r) {
            if(strval($r-&amp;gt;hide_line_item_c) !== &amp;#39;1&amp;#39;) {
                if($r-&amp;gt;id == $lineItemBean-&amp;gt;id) {
                    //nothing
                }
                else {
                    $lineItemSummaryNew .= str_pad(strval($r-&amp;gt;year_text_c),$cpad).&amp;quot; |      &amp;quot;.sprintf(&amp;quot;%{$vpad}s&amp;quot;, number_format($r-&amp;gt;volume)).&amp;quot; |  &amp;quot;.$this-&amp;gt;formatDecimal($r-&amp;gt;base_unit_price).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($r-&amp;gt;unit_surcharge).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($r-&amp;gt;total_unit_price).$subChar;
                    $lineArray[$r-&amp;gt;year_text_c] = str_pad(strval($r-&amp;gt;year_text_c),$cpad).&amp;quot; |      &amp;quot;.sprintf(&amp;quot;%{$vpad}s&amp;quot;, number_format($r-&amp;gt;volume)).&amp;quot; |  &amp;quot;.$this-&amp;gt;formatDecimal($r-&amp;gt;base_unit_price).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($r-&amp;gt;unit_surcharge).&amp;quot;|  &amp;quot;.$this-&amp;gt;formatDecimal($r-&amp;gt;total_unit_price).$subChar;
                }                    
            }
        }
        $pos = strrpos($lineItemSummaryNew, $subChar);
        if($pos !== false)
        {
            $lineItemSummaryNew = substr_replace($lineItemSummaryNew, &amp;#39;&amp;#39;, $pos, 1);
        }
        $lineItemSummaryNew .= &amp;quot;\n&amp;quot;.$lineItemBean-&amp;gt;new_record_line_text_c;

        $reassemblItemsSummaryNew = &amp;#39;&amp;#39;;
        $extraLineInserted = false;
        foreach($lineArray as $k =&amp;gt; $v) {
            if ($k &amp;gt; $newTextYear &amp;amp;&amp;amp; !$extraLineInserted) {
                $reassemblItemsSummaryNew .= $lineItemBean-&amp;gt;new_record_line_text_c;
                $extraLineInserted = true;
            }
            $reassemblItemsSummaryNew .= $v;
        }
        if($extraLineInserted == false) {
            if($lineItemBean-&amp;gt;hide_line_item_c !== &amp;#39;1&amp;#39;) {
                $reassemblItemsSummaryNew .= $lineItemBean-&amp;gt;new_record_line_text_c;
            }
        }   
        $reassemblItemsSummaryNew = rtrim($reassemblItemsSummaryNew, &amp;quot;^&amp;quot;); 
        $reassemblItemsSummaryNew = str_replace($subChar, &amp;quot;\n&amp;quot;, $reassemblItemsSummaryNew);
        $lineItemBean-&amp;gt;line_item_group_summary_c = $reassemblItemsSummaryNew;
    }


    /* Returns array of beans for the related items
    * The primary key $bean will always be thte on ein the first [0] position
    */
    protected function getAndSaveQuoteBeanKeyItems( $quoteId, $omitBeanId = null, $newRecordTextLine = &amp;#39;&amp;#39;, $newRecordKey = &amp;#39;&amp;#39; ) {
        $quoteBean = BeanFactory::retrieveBean(&amp;#39;Quotes&amp;#39;, $quoteId);
        if(!$quoteBean) {
            return false;
        }

        $quoteBean-&amp;gt;load_relationship(&amp;#39;id_li_quotes&amp;#39;);
        $relatedBeans = $quoteBean-&amp;gt;id_li_quotes-&amp;gt;getBeans();    
        $filteredBeans = array();

        foreach($relatedBeans as $relatedBean) {
            if($relatedBean-&amp;gt;scenario_key_value_c != &amp;#39;&amp;#39;) {
                if($relatedBean-&amp;gt;id !== $omitBeanId) {
                    $relatedBean-&amp;gt;save_flag_c = 1;
                    $filteredBeans[] = $relatedBean;
                }
                else {
                    // continue;
                }
            }
        }

        foreach($filteredBeans as $b) {
            $b-&amp;gt;save_flag_c = 1;

            if($newRecordKey == $b-&amp;gt;scenario_key_value_c) {
                $b-&amp;gt;new_record_line_text_c = $newRecordTextLine;
            }
            else {
                $b-&amp;gt;new_record_line_text_c = &amp;#39;&amp;#39;;
            }
            $b-&amp;gt;save();
        }
    }


    /* Returns array of beans for the related items
    * The primary key $bean will always be thte on ein the first [0] position
    */
    protected function getItemBeansForQBean( $quoteId, $scenarioNumber, $itemNumber ) {
        $quoteBean = BeanFactory::retrieveBean(&amp;#39;Quotes&amp;#39;, $quoteId);
        if(!$quoteBean) {
            return false;
        }

        $quoteBean-&amp;gt;load_relationship(&amp;#39;id_li_quotes&amp;#39;);
        $relatedBeans = $quoteBean-&amp;gt;id_li_quotes-&amp;gt;getBeans();    
        $filteredBeans = array();

        foreach($relatedBeans as $relatedBean) {
            if($relatedBean-&amp;gt;scenario_number == $scenarioNumber &amp;amp;&amp;amp; $relatedBean-&amp;gt;item_number == $itemNumber ) {
                $filteredBeans[] = $relatedBean;
            }
        }

        //Bubble sort of relatedBeans by year_text_c
        $n = count($filteredBeans);
        for ($i = 0; $i &amp;lt; $n - 1; $i++) {
            for ($j = 0; $j &amp;lt; $n - $i - 1; $j++) {
                if (strcmp($filteredBeans[$j]-&amp;gt;year_text_c, $filteredBeans[$j + 1]-&amp;gt;year_text_c) &amp;gt; 0) {
                    $temp = $filteredBeans[$j];
                    $filteredBeans[$j] = $filteredBeans[$j + 1];
                    $filteredBeans[$j + 1] = $temp;
                }
            }
        }
        return $filteredBeans;
    }


    /**
     * Attempt to determine the parent Quote ID for a line-item bean.
     */
    protected function getParentQuoteId($bean)
    {
        try {
            if ($bean-&amp;gt;load_relationship(&amp;#39;id_li_quotes&amp;#39;)) {
                $related = $bean-&amp;gt;id_li_quotes-&amp;gt;getBeans();
                if (!empty($related)) {
                    $ids = array_keys($related);
                    return reset($ids);
                }
            }
        } catch (Exception $e) {
            // ignore
        }
        return null;
    }

    protected function formatDecimal($number)
    {
        $formattedNumber = sprintf(&amp;quot;%.5f&amp;quot;, $number);
        $paddedString = str_pad($formattedNumber, 12, &amp;quot; &amp;quot;, STR_PAD_LEFT).&amp;#39; &amp;#39;;
        return $paddedString;
    }
}

?&amp;gt;&lt;/pre&gt;&lt;/p&gt;
&lt;p class="p2"&gt;&lt;/p&gt;
&lt;p class="p2"&gt;&lt;a href="https://sugarclub.sugarai.com/cfs-file/__key/communityserver-wikis-components-files/00-00-00-00-14/SugarClubDocMergeTemplateEx.docx"&gt;sugarclub.sugarai.com/.../SugarClubDocMergeTemplateEx.docx&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;&lt;div style="clear:both;"&gt;&lt;/div&gt;
</description></item></channel></rss>