├── modman ├── app ├── code │ └── local │ │ └── My │ │ └── Reports │ │ ├── Helper │ │ └── Data.php │ │ ├── etc │ │ ├── config.xml │ │ └── adminhtml.xml │ │ ├── Block │ │ └── Adminhtml │ │ │ ├── Report.php │ │ │ ├── Report │ │ │ ├── Grid │ │ │ │ └── Column │ │ │ │ │ └── Renderer │ │ │ │ │ └── Percent.php │ │ │ └── Grid.php │ │ │ └── Filter │ │ │ └── Form.php │ │ ├── controllers │ │ └── Adminhtml │ │ │ └── My │ │ │ └── ReportsController.php │ │ └── Model │ │ └── Mysql4 │ │ └── Report │ │ └── Collection.php ├── etc │ └── modules │ │ └── My_Reports.xml └── design │ └── adminhtml │ └── default │ └── default │ └── layout │ └── my_reports.xml └── README.md /modman: -------------------------------------------------------------------------------- 1 | app/code/local/My/Reports 2 | app/design/adminhtml/default/default/layout/my_reports.xml 3 | app/etc/modules/My_Reports.xml 4 | -------------------------------------------------------------------------------- /app/code/local/My/Reports/Helper/Data.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 0.1.0 7 | local 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/design/adminhtml/default/default/layout/my_reports.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/code/local/My/Reports/etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0.1.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | My_Reports_Model 17 | my_reports_mysql4 18 | 19 | 20 | My_Reports_Model_Mysql4 21 | 22 | 23 | 24 | 25 | My_Reports_Helper 26 | 27 | 28 | 29 | 30 | My_Reports_Block 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | My_Reports_Adminhtml 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | my_reports.xml 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/code/local/My/Reports/Block/Adminhtml/Report.php: -------------------------------------------------------------------------------- 1 | _headerText = Mage::helper('my_reports')->__('My Custom Reports'); 28 | // Set hard-coded template. As you can see, the layout.xml 29 | // attribute is ineffective, but we keep up with conventions 30 | $this->setTemplate('report/grid/container.phtml'); 31 | // call parent constructor and let it add the buttons 32 | parent::__construct(); 33 | // we create a report, not just a standard grid, so remove add button, we don't need it this time 34 | $this->_removeButton('add'); 35 | 36 | // add a button to our form to let the user kick-off our logic from the admin 37 | $this->addButton('filter_form_submit', array( 38 | 'label' => Mage::helper('my_reports')->__('Show Report'), 39 | 'onclick' => 'filterFormSubmit()' 40 | )); 41 | } 42 | 43 | /** 44 | * This function will prepare our filter URL 45 | * @return string 46 | */ 47 | public function getFilterUrl() 48 | { 49 | $this->getRequest()->setParam('filter', null); 50 | return $this->getUrl('*/*/index', array('_current' => true)); 51 | } 52 | } -------------------------------------------------------------------------------- /app/code/local/My/Reports/etc/adminhtml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | My Custom Reports 10 | adminhtml/my_reports 11 | 100 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | My Reports Section 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | My Custom Reports 39 | 40 | 41 | View 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/code/local/My/Reports/Block/Adminhtml/Report/Grid/Column/Renderer/Percent.php: -------------------------------------------------------------------------------- 1 | _getValue($row); 29 | $decimals = $this->_getDecimals(); 30 | return number_format($value, $decimals) . self::PERCENT_SIGN; 31 | } 32 | 33 | // add getter for decimals 34 | 35 | /* 36 | Note: as many objects in Magento, also the renderers inherit methods from Varien_Object 37 | (actually the renderer is a block, and all block inherits from Varien_Object). 38 | Therefore we could pass any value to this block using Varien_Object's methods. 39 | For example $renderer->setAnything(1) will set the 'anything''s value to 1. In our case 40 | we pass the decimals with a value of 2 when we add the 'shipping_rate' column to the grid 41 | (because the default is 2 this is not necessary, but the code is easier to understand 42 | and read this way). 43 | See: Varien_Object (especially ::__call), My_Reports_Block_Adminhtml_Report_Grid::_prepareColumns(). 44 | */ 45 | 46 | /** 47 | * Get decimal to round value by 48 | * The decimals value could be changed with specifying it from outside using 49 | * a setter method supported by Varien_Object (ie. with setData('decimals', 2) or setDecimals(2)) 50 | * @return int 51 | */ 52 | protected function _getDecimals() 53 | { 54 | $decimals = $this->getDecimals(); // this is a magic getter 55 | return !is_null($decimals) ? $decimals : self::DECIMALS; 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /app/code/local/My/Reports/controllers/Adminhtml/My/ReportsController.php: -------------------------------------------------------------------------------- 1 | _title($this->__('Reports'))->_title($this->__('Sales'))->_title($this->__('My Custom Reports')); 13 | $this->loadLayout() 14 | ->_setActiveMenu('report/sales') 15 | ->_addBreadcrumb(Mage::helper('my_reports')->__('Reports'), Mage::helper('my_reports')->__('Reports')) 16 | ->_addBreadcrumb(Mage::helper('my_reports')->__('Sales'), Mage::helper('my_reports')->__('Sales')) 17 | ->_addBreadcrumb(Mage::helper('my_reports')->__('My Custom Reports'), Mage::helper('my_reports')->__('My Custom Reports')); 18 | return $this; 19 | } 20 | 21 | /** 22 | * Prepare blocks with request data from our filter form 23 | * @return My_Reports_Adminhtml_ReportsController 24 | */ 25 | protected function _initReportAction($blocks) 26 | { 27 | if (!is_array($blocks)) { 28 | $blocks = array($blocks); 29 | } 30 | 31 | $requestData = Mage::helper('adminhtml')->prepareFilterString($this->getRequest()->getParam('filter')); 32 | $requestData = $this->_filterDates($requestData, array('from', 'to')); 33 | $params = $this->_getDefaultFilterData(); 34 | foreach ($requestData as $key => $value) { 35 | if (!empty($value)) { 36 | $params->setData($key, $value); 37 | } 38 | } 39 | 40 | foreach ($blocks as $block) { 41 | if ($block) { 42 | $block->setFilterData($params); 43 | } 44 | } 45 | return $this; 46 | } 47 | 48 | /** 49 | * Grid action 50 | */ 51 | public function indexAction() 52 | { 53 | $this->_initAction(); 54 | 55 | $gridBlock = $this->getLayout()->getBlock('adminhtml_report.grid'); 56 | $filterFormBlock = $this->getLayout()->getBlock('grid.filter.form'); 57 | $this->_initReportAction(array( 58 | $gridBlock, 59 | $filterFormBlock 60 | )); 61 | 62 | $this->renderLayout(); 63 | } 64 | 65 | /** 66 | * Export reports to CSV file 67 | */ 68 | public function exportCsvAction() 69 | { 70 | $fileName = 'my_reports.csv'; 71 | $grid = $this->getLayout()->createBlock('my_reports/adminhtml_report_grid'); 72 | $this->_initReportAction($grid); 73 | $this->_prepareDownloadResponse($fileName, $grid->getCsvFile()); 74 | } 75 | 76 | /** 77 | * Export reports to Excel XML file 78 | */ 79 | public function exportExcelAction() 80 | { 81 | $fileName = 'my_reports.xml'; 82 | $grid = $this->getLayout()->createBlock('my_reports/adminhtml_report_grid'); 83 | $this->_initReportAction($grid); 84 | $this->_prepareDownloadResponse($fileName, $grid->getExcelFile()); 85 | } 86 | 87 | /** 88 | * Returns default filter data 89 | * @return Varien_Object 90 | */ 91 | protected function _getDefaultFilterData() 92 | { 93 | return new Varien_Object(array( 94 | 'from' => date('Y-m-d G:i:s', strtotime('-1 month -1 day')), 95 | 'to' => date('Y-m-d G:i:s', strtotime('-1 day')) 96 | )); 97 | } 98 | } -------------------------------------------------------------------------------- /app/code/local/My/Reports/Block/Adminhtml/Filter/Form.php: -------------------------------------------------------------------------------- 1 | _fieldVisibility[$fieldId] = $visibility ? true : false; 27 | return $this; 28 | } 29 | 30 | /** 31 | * Returns the field is visible or not. If we hadn't set a value 32 | * for the field previously, it will return the value defined in the 33 | * defaultVisibility parameter (it's true by default) 34 | * @param string $fieldId 35 | * @param bool $defaultVisibility 36 | * @return bool 37 | */ 38 | public function getFieldVisibility($fieldId, $defaultVisibility = true) 39 | { 40 | if (isset($this->_fieldVisibility[$fieldId])) { 41 | return $this->_fieldVisibility[$fieldId]; 42 | } 43 | return $defaultVisibility; 44 | } 45 | 46 | /** 47 | * Set field option(s) 48 | * @param string $fieldId 49 | * @param string|array $option if option is an array, loop through it's keys and values 50 | * @param mixed $value if option is an array this option is meaningless 51 | * @return My_Reports_Block_Adminhtml_Filter_Form 52 | */ 53 | public function setFieldOption($fieldId, $option, $value = null) 54 | { 55 | if (is_array($option)) { 56 | $options = $option; 57 | } else { 58 | $options = array($option => $value); 59 | } 60 | 61 | if (!isset($this->_fieldOptions[$fieldId])) { 62 | $this->_fieldOptions[$fieldId] = array(); 63 | } 64 | 65 | foreach ($options as $key => $value) { 66 | $this->_fieldOptions[$fieldId][$key] = $value; 67 | } 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * Prepare our form elements 74 | * @return My_Reports_Block_Adminhtml_Filter_Form 75 | */ 76 | protected function _prepareForm() 77 | { 78 | // inicialise our form 79 | $actionUrl = $this->getCurrentUrl(); 80 | $form = new Varien_Data_Form(array( 81 | 'id' => 'filter_form', 82 | 'action' => $actionUrl, 83 | 'method' => 'get' 84 | )); 85 | 86 | // set ID prefix for all elements in our form 87 | $htmlIdPrefix = 'my_reports_'; 88 | $form->setHtmlIdPrefix($htmlIdPrefix); 89 | 90 | // create a fieldset to add elements to 91 | $fieldset = $form->addFieldset('base_fieldset', array('legend' => Mage::helper('my_reports')->__('Filter'))); 92 | 93 | // prepare our filter fields and add each to the fieldset 94 | 95 | // date filter 96 | $dateFormatIso = Mage::app()->getLocale()->getDateFormat(Mage_Core_Model_Locale::FORMAT_TYPE_SHORT); 97 | $fieldset->addField('from', 'date', array( 98 | 'name' => 'from', 99 | 'format' => $dateFormatIso, 100 | 'image' => $this->getSkinUrl('images/grid-cal.gif'), 101 | 'label' => Mage::helper('my_reports')->__('From'), 102 | 'title' => Mage::helper('my_reports')->__('From') 103 | )); 104 | $fieldset->addField('to', 'date', array( 105 | 'name' => 'to', 106 | 'format' => $dateFormatIso, 107 | 'image' => $this->getSkinUrl('images/grid-cal.gif'), 108 | 'label' => Mage::helper('my_reports')->__('To'), 109 | 'title' => Mage::helper('my_reports')->__('To') 110 | )); 111 | $fieldset->addField('period_type', 'select', array( 112 | 'name' => 'period_type', 113 | 'options' => $this->_getPeriodTypeOptions(), 114 | 'label' => Mage::helper('my_reports')->__('Period') 115 | )); 116 | 117 | // non-zero shipping rate filter 118 | $fieldset->addField('shipping_rate', 'select', array( 119 | 'name' => 'shipping_rate', 120 | 'options' => $this->_getShippingRateSelectOptions(), 121 | 'label' => Mage::helper('my_reports')->__('Show values where shipping rate greater than 0') 122 | )); 123 | 124 | $form->setUseContainer(true); 125 | $this->setForm($form); 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Get period type options 132 | * @return array 133 | */ 134 | protected function _getPeriodTypeOptions() 135 | { 136 | $options = array( 137 | 'day' => Mage::helper('my_reports')->__('Day'), 138 | 'month' => Mage::helper('my_reports')->__('Month'), 139 | 'year' => Mage::helper('my_reports')->__('Year'), 140 | ); 141 | 142 | return $options; 143 | } 144 | 145 | /** 146 | * Returns options for shipping rate select 147 | * @return array 148 | */ 149 | protected function _getShippingRateSelectOptions() 150 | { 151 | $options = array( 152 | '0' => 'Any', 153 | '1' => 'Specified' 154 | ); 155 | 156 | return $options; 157 | } 158 | 159 | /** 160 | * Inicialise form values 161 | * Called after prepareForm, we apply the previously set values from filter on the form 162 | * @return My_Reports_Block_Adminhtml_Filter_Form 163 | */ 164 | protected function _initFormValues() 165 | { 166 | $filterData = $this->getFilterData(); 167 | $this->getForm()->addValues($filterData->getData()); 168 | return parent::_initFormValues(); 169 | } 170 | 171 | /** 172 | * Apply field visibility and field options on our form fields before rendering 173 | * @return My_Reports_Block_Adminhtml_Filter_Form 174 | */ 175 | protected function _beforeHtml() 176 | { 177 | $result = parent::_beforeHtml(); 178 | 179 | $elements = $this->getForm()->getElements(); 180 | 181 | // iterate on our elements and select fieldsets 182 | foreach ($elements as $element) { 183 | $this->_applyFieldVisibiltyAndOptions($element); 184 | } 185 | 186 | return $result; 187 | } 188 | 189 | /** 190 | * Apply field visibility and options on fieldset element 191 | * Recursive 192 | * @param Varien_Data_Form_Element_Fieldset $element 193 | * @return Varien_Data_Form_Element_Fieldset 194 | */ 195 | protected function _applyFieldVisibiltyAndOptions($element) { 196 | if ($element instanceof Varien_Data_Form_Element_Fieldset) { 197 | foreach ($element->getElements() as $fieldElement) { 198 | // apply recursively 199 | if ($fieldElement instanceof Varien_Data_Form_Element_Fieldset) { 200 | $this->_applyFieldVisibiltyAndOptions($fieldElement); 201 | continue; 202 | } 203 | 204 | $fieldId = $fieldElement->getId(); 205 | // apply field visibility 206 | if (!$this->getFieldVisibility($fieldId)) { 207 | $element->removeField($fieldId); 208 | continue; 209 | } 210 | 211 | // apply field options 212 | if (isset($this->_fieldOptions[$fieldId])) { 213 | $fieldOptions = $this->_fieldOptions[$fieldId]; 214 | foreach ($fieldOptions as $k => $v) { 215 | $fieldElement->setDataUsingMethod($k, $v); 216 | } 217 | } 218 | } 219 | } 220 | 221 | return $element; 222 | } 223 | 224 | } -------------------------------------------------------------------------------- /app/code/local/My/Reports/Block/Adminhtml/Report/Grid.php: -------------------------------------------------------------------------------- 1 | setPagerVisibility(false); 29 | $this->setUseAjax(false); 30 | $this->setFilterVisibility(false); 31 | 32 | // set message for empty result 33 | $this->setEmptyCellLabel(Mage::helper('my_reports')->__('No records found.')); 34 | 35 | // set grid ID in adminhtml 36 | $this->setId('mxReportsGrid'); 37 | 38 | // set our grid to obtain totals 39 | $this->setCountTotals(true); 40 | } 41 | 42 | // add getters 43 | 44 | /** 45 | * Returns the resource collection name which we'll apply filters and display results 46 | * @return string 47 | */ 48 | public function getResourceCollectionName() 49 | { 50 | return $this->_resourceCollectionName; 51 | } 52 | 53 | /** 54 | * Factory method for our resource collection 55 | * @return Mage_Core_Model_Mysql4_Collection_Abstract 56 | */ 57 | public function getResourceCollection() 58 | { 59 | $resourceCollection = Mage::getResourceModel($this->getResourceCollectionName()); 60 | return $resourceCollection; 61 | } 62 | 63 | /** 64 | * Gets the actual used currency code. 65 | * We will convert every currency value to this currency. 66 | * @return string 67 | */ 68 | public function getCurrentCurrencyCode() 69 | { 70 | return Mage::app()->getStore()->getBaseCurrencyCode(); 71 | } 72 | 73 | /** 74 | * Get currency rate, base to given currency 75 | * @param string|Mage_Directory_Model_Currency $toCurrency currency code 76 | * @return int 77 | */ 78 | public function getRate($toCurrency) 79 | { 80 | return Mage::app()->getStore()->getBaseCurrency()->getRate($toCurrency); 81 | } 82 | 83 | /** 84 | * Return totals data 85 | * Count totals if it's not previously counted and set to retrieve 86 | * @return Varien_Object 87 | */ 88 | public function getTotals() 89 | { 90 | $result = parent::getTotals(); 91 | if (!$result && $this->getCountTotals()) { 92 | $filterData = $this->getFilterData(); 93 | $totalsCollection = $this->getResourceCollection(); 94 | 95 | // apply our custom filters on collection 96 | $this->_addCustomFilter( 97 | $totalsCollection, 98 | $filterData 99 | ); 100 | 101 | // isTotals is a flag, we will deal with this in the resource collection 102 | $totalsCollection->isTotals(true); 103 | 104 | // set totals row even if we didn't got a result 105 | if ($totalsCollection->count() < 1) { 106 | $this->setTotals(new Varien_Object); 107 | } else { 108 | $this->setTotals($totalsCollection->getFirstItem()); 109 | } 110 | 111 | $result = parent::getTotals(); 112 | } 113 | 114 | return $result; 115 | } 116 | 117 | // prepare columns and collection 118 | 119 | /** 120 | * Prepare our grid's columns to display 121 | * @return My_Reports_Block_Adminhtml_Grid 122 | */ 123 | protected function _prepareColumns() 124 | { 125 | // get currency code and currency rate for the currency renderers. 126 | // our orders could be in different currencies, therefore we should convert the values to the base currency 127 | $currencyCode = $this->getCurrentCurrencyCode(); 128 | $rate = $this->getRate($currencyCode); 129 | 130 | // add our first column, period which represents a date 131 | $this->addColumn('period', array( 132 | 'header' => Mage::helper('my_reports')->__('Period'), 133 | 'index' => 'created_at', // 'index' attaches a column from the SQL result set to the grid 134 | 'renderer' => 'adminhtml/report_sales_grid_column_renderer_date', 135 | 'width' => 100, 136 | 'sortable' => false, 137 | 'period_type' => $this->getFilterData()->getPeriodType() // could be day, month or year 138 | )); 139 | 140 | // add base grand total w/ a currency renderer, and add totals 141 | $this->addColumn('base_grand_total', array( 142 | 'header' => Mage::helper('my_reports')->__('Grand Total'), 143 | 'index' => 'base_grand_total', 144 | // type defines a grid column renderer; you could find the complete list 145 | // and the exact aliases at Mage_Adminhtml_Block_Widget_Grid_Column::_getRendererByType() 146 | 'type' => 'currency', 147 | 'currency_code' => $currencyCode, // set currency code.. 148 | 'rate' => $rate, // and currency rate, used by the column renderer 149 | 'total' => 'sum' 150 | )); 151 | 152 | // add the next column shipping_amount, with an average on totals 153 | $this->addColumn('base_shipping_amount', array( 154 | 'header' => Mage::helper('my_reports')->__('Shipping Amount'), 155 | 'index' => 'base_shipping_amount', 156 | 'type' => 'currency', 157 | 'currency_code' => $currencyCode, 158 | 'rate' => $rate, 159 | 'total' => 'sum' 160 | )); 161 | 162 | // rate, where base_shipping_amount/base_grand_total is a percent 163 | $this->addColumn('shipping_rate', array( 164 | 'header' => Mage::helper('my_reports')->__('Shipping Rate'), 165 | 'index' => 'shipping_rate', 166 | 'renderer' => 'my_reports/adminhtml_report_grid_column_renderer_percent', 167 | 'decimals' => 2, 168 | 'total' => 'avg' 169 | )); 170 | 171 | // add export types 172 | $this->addExportType('*/*/exportCsv', Mage::helper('my_reports')->__('CSV')); 173 | $this->addExportType('*/*/exportExcel', Mage::helper('my_reports')->__('MS Excel XML')); 174 | 175 | return parent::_prepareColumns(); 176 | } 177 | 178 | /** 179 | * Prepare our collection which we'll display in the grid 180 | * First, get the resource collection we're dealing with, with our custom filters applied. 181 | * In case of an export, we're done, otherwise calculate the totals 182 | * @return My_Reports_Block_Adminhtml_Grid 183 | */ 184 | protected function _prepareCollection() 185 | { 186 | $filterData = $this->getFilterData(); 187 | $resourceCollection = $this->getResourceCollection(); 188 | 189 | // get our resource collection and apply our filters on it 190 | $this->_addCustomFilter( 191 | $resourceCollection, 192 | $filterData 193 | ); 194 | 195 | // attach the prepared collection to our grid 196 | $this->setCollection($resourceCollection); 197 | 198 | // skip totals if we do an export (calling getTotals would be a duplicate, because 199 | // the export method calls it explicitly) 200 | if ($this->_isExport) { 201 | return $this; 202 | } 203 | 204 | // count totals if needed 205 | if ($this->getCountTotals()) { 206 | $this->getTotals(); 207 | } 208 | 209 | return parent::_prepareCollection(); 210 | } 211 | 212 | /** 213 | * Apply our custom filters on collection 214 | * @param Mage_Core_Model_Mysql4_Collection_Abstract $collection 215 | * @param Varien_Object $filterData 216 | * @return My_Reports_Block_Adminhtml_Report_Grid 217 | */ 218 | protected function _addCustomFilter($collection, $filterData) 219 | { 220 | $collection 221 | ->setPeriodType($filterData->getPeriodType()) 222 | ->setDateRange($filterData->getFrom(), $filterData->getTo()) 223 | ->isShippingRateNonZeroOnly($filterData->getShippingRate() ? true : false) 224 | ->setAggregatedColumns($this->_getAggregatedColumns()); 225 | 226 | return $this; 227 | } 228 | 229 | /** 230 | * Returns the columns we specified to summarize totals 231 | * 232 | * Collect all columns we added totals to. 233 | * The returned array would be ie. 'base_grand_total' => 'sum' 234 | * @return array 235 | */ 236 | protected function _getAggregatedColumns() 237 | { 238 | if (!isset($this->_aggregatedColumns) && $this->getColumns()) { 239 | $this->_aggregatedColumns = array(); 240 | foreach ($this->getColumns() as $column) { 241 | if ($column->hasTotal()) { 242 | $this->_aggregatedColumns[$column->getId()] = $column->getTotal(); 243 | } 244 | } 245 | } 246 | 247 | return $this->_aggregatedColumns; 248 | } 249 | 250 | } -------------------------------------------------------------------------------- /app/code/local/My/Reports/Model/Mysql4/Report/Collection.php: -------------------------------------------------------------------------------- 1 | 'total' 42 | * @var array 43 | */ 44 | protected $_aggregatedColumns = array(); 45 | 46 | // define basic setup of our collection 47 | 48 | /** 49 | * We should overwrite constructor to allow custom resources to use 50 | * The original constructor calls _initSelect by default which isn't suits our 51 | * needs, because the totals mode is set after instantiation of 52 | * the collection object (therefore we will handle this case right before 53 | * loading our collection). 54 | */ 55 | public function __construct($resource = null) 56 | { 57 | $this->setModel('adminhtml/report_item'); 58 | $this->setResourceModel('sales/order'); 59 | $this->setConnection($this->getResource()->getReadConnection()); 60 | } 61 | 62 | // add filter methods 63 | 64 | /** 65 | * Set period type 66 | * @param string $periodType 67 | * @return My_Reports_Model_Mysql4_Report_Collection 68 | */ 69 | public function setPeriodType($periodType) 70 | { 71 | $this->_periodType = $periodType; 72 | return $this; 73 | } 74 | 75 | /** 76 | * Set date range to filter on 77 | * @param string $from 78 | * @param string $to 79 | * @return My_Reports_Model_Mysql4_Report_Collection 80 | */ 81 | public function setDateRange($from, $to) 82 | { 83 | $this->_from = $from; 84 | $this->_to = $to; 85 | return $this; 86 | } 87 | 88 | /** 89 | * Setter/getter method for filtering items only with shipping rate greater than zero 90 | * @param bool $bool by default null it returns the current state flag 91 | * @return bool|My_Reports_Model_Mysql4_Report_Collection 92 | */ 93 | public function isShippingRateNonZeroOnly($bool = null) 94 | { 95 | if (is_null($bool)) { 96 | return $this->_isShippingRateNonZeroOnly; 97 | } 98 | $this->_isShippingRateNonZeroOnly = $bool ? true : false; 99 | return $this; 100 | } 101 | 102 | /** 103 | * Set aggregated columns used in totals mode 104 | * @param array $columns 105 | * @return My_Reports_Model_Mysql4_Report_Collection 106 | */ 107 | public function setAggregatedColumns($columns) 108 | { 109 | $this->_aggregatedColumns = $columns; 110 | return $this; 111 | } 112 | 113 | /** 114 | * Setter/getter for setting totals mode on collection 115 | * By default the collection selects columns we display in the grid, 116 | * by selecting this mode we will only query the aggregated columns 117 | * @param bool $bool by default null it returns the current state of flag 118 | * @return bool|My_Reports_Model_Mysql4_Report_Collection 119 | */ 120 | public function isTotals($bool = null) 121 | { 122 | if (is_null($bool)) { 123 | return $this->_isTotals; 124 | } 125 | $this->_isTotals = $bool ? true : false; 126 | return $this; 127 | } 128 | 129 | // prepare select 130 | 131 | /** 132 | * Get selected columns depending on totals mode 133 | */ 134 | protected function _getSelectedColumns() { 135 | if ($this->isTotals()) { 136 | $selectedColumns = $this->_getAggregatedColumns(); 137 | } else { 138 | $selectedColumns = array( 139 | 'created_at' => $this->_getPeriodFormat(), 140 | 'base_grand_total' => 'SUM(base_grand_total)', 141 | 'base_shipping_amount' => 'SUM(base_shipping_amount)', 142 | 'shipping_rate' => 'AVG((base_shipping_amount / base_grand_total) * 100)', 143 | 'base_currency_code' => 'base_currency_code', 144 | ); 145 | } 146 | 147 | return $selectedColumns; 148 | } 149 | 150 | /** 151 | * Return aggregated columns 152 | * This method uses ::_getAggregatedColumn for getting the db expression for the specified columnId 153 | * @return array 154 | */ 155 | protected function _getAggregatedColumns() 156 | { 157 | $aggregatedColumns = array(); 158 | foreach ($this->_aggregatedColumns as $columnId => $total) { 159 | $aggregatedColumns[$columnId] = $this->_getAggregatedColumn($columnId, $total); 160 | } 161 | return $aggregatedColumns; 162 | } 163 | 164 | /** 165 | * Returns the db expression based on total mode and column ID 166 | * @param string $columnId the column's ID used in expression 167 | * @param string $total mode of aggregation (could be sum or avg) 168 | * @return string 169 | */ 170 | protected function _getAggregatedColumn($columnId, $total) 171 | { 172 | switch ($columnId) { 173 | case 'shipping_rate' : { 174 | $expression = "{$total}((base_shipping_amount / base_grand_total) * 100)"; 175 | } break; 176 | default : { 177 | $expression = "{$total}({$columnId})"; 178 | } break; 179 | } 180 | 181 | return $expression; 182 | } 183 | 184 | /** 185 | * Get period format based on '_periodType' 186 | * @return string 187 | */ 188 | protected function _getPeriodFormat() 189 | { 190 | $adapter = $this->getConnection(); 191 | if ('month' == $this->_periodType) { 192 | $periodFormat = 'DATE_FORMAT(created_at, \'%Y-%m\')'; 193 | // From Magento EE 1.12 you should use the adapter's appropriate method: 194 | // $periodFormat = $adapter->getDateFormatSql('created_at', '%Y-%m'); 195 | } else if ('year' == $this->_periodType) { 196 | $periodFormat = 'EXTRACT(YEAR FROM created_at)'; 197 | // From Magento EE 1.12 you should use the adapter's appropriate method: 198 | // $periodFormat = $adapter->getDateExtractSql('created_at', Varien_Db_Adapter_Interface::INTERVAL_YEAR); 199 | } else { 200 | $periodFormat = 'created_at'; 201 | // From Magento EE 1.12 you should use the adapter's appropriate method: 202 | // $periodFormat = $adapter->getDateFormatSql('created_at', '%Y-%m-%d'); 203 | } 204 | 205 | return $periodFormat; 206 | } 207 | 208 | /** 209 | * Prepare select statement depending on totals is on or off 210 | * @return My_Reports_Model_Mysql4_Report_Collection 211 | */ 212 | protected function _initSelect() 213 | { 214 | $this->getSelect()->reset(); 215 | 216 | // select aggregated columns only in totals; w/o grouping by period 217 | $this->getSelect()->from($this->getResource()->getMainTable(), $this->_getSelectedColumns()); 218 | if (!$this->isTotals()) { 219 | $this->getSelect()->group($this->_getPeriodFormat()); 220 | } 221 | 222 | return $this; 223 | } 224 | 225 | // render filters 226 | 227 | /** 228 | * Apply our date range filter on select 229 | * @return My_Reports_Model_Mysql4_Report_Collection 230 | */ 231 | protected function _applyDateRangeFilter() 232 | { 233 | if (!is_null($this->_from)) { 234 | $this->_from = date('Y-m-d G:i:s', strtotime($this->_from)); 235 | $this->getSelect()->where('created_at >= ?', $this->_from); 236 | } 237 | if (!is_null($this->_to)) { 238 | $this->_to = date('Y-m-d G:i:s', strtotime($this->_to)); 239 | $this->getSelect()->where('created_at <= ?', $this->_to); 240 | } 241 | 242 | return $this; 243 | } 244 | 245 | /** 246 | * Apply shipping rate filter 247 | * @return My_Reports_Model_Mysql4_Report_Collection 248 | */ 249 | protected function _applyShippingRateNonZeroOnlyFilter() 250 | { 251 | if ($this->_isShippingRateNonZeroOnly) { 252 | $this->getSelect() 253 | ->where('((base_shipping_amount / base_grand_total) * 100) > 0'); 254 | } 255 | } 256 | 257 | /** 258 | * Inicialise select right before loading collection 259 | * We need to fire _initSelect here, because the isTotals mode creates different results depending 260 | * on it's value. The parent implementation of the collection originally fires this method in the 261 | * constructor. 262 | * @return My_Reports_Model_Mysql4_Report_Collection 263 | */ 264 | protected function _beforeLoad() 265 | { 266 | $this->_initSelect(); 267 | return parent::_beforeLoad(); 268 | } 269 | 270 | /** 271 | * This would render all of our pre-set filters on collection. 272 | * Calling of this method happens in Varien_Data_Collection_Db::_renderFilters(), while 273 | * the _renderFilters itself is called in Varien_Data_Collection_Db::load() before calling 274 | * _renderOrders() and _renderLimit() . 275 | * @return My_Reports_Model_Mysql4_Report_Collection 276 | */ 277 | protected function _renderFiltersBefore() 278 | { 279 | $this 280 | ->_applyDateRangeFilter() 281 | ->_applyShippingRateNonZeroOnlyFilter(); 282 | return $this; 283 | } 284 | 285 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Reports in Magento 2 | 3 | 4 | Because I haven't found any detailed article on how to create a report and how it works, I decided to write 5 | one myself, and try to give you some details, not just a plain-code-figure-out-everything-yourself stuff. 6 | The example would be quite simple, but it just fits for an excersice: list the orders grand total and shipping 7 | amount, and - to give this story a little twist - we would like to display how much percent was the shipping amount of the 8 | order's total. We would like to display totals too under our grid. 9 | Acceptance criteria for our module: 10 | - Ability to filter in a given date interval 11 | - Ability to change date interval (days, months or years) 12 | - Ability to filter results with a non-zero shipping percent only 13 | - Ability to export to CSV and MS Excel 14 | In the example code I would like to use some of the best practices, and to follow the conventions as much as possible. I 15 | created a public git repository from where you could download the whole source code. If you are impatient, scroll to 16 | the end of this article for the link. 17 | 18 | 19 | ## About the Reports in a Nutshell 20 | 21 | Basically it consists a grid, a collection and a form, where the form has fields to filter the displayed 22 | results of the grid. The grid displays the collection's items using the applied filters. There is an enermous amount 23 | of entry points which we could use to change data during runtime, but we won't use many of them. 24 | 25 | 26 | ## Creating the base module 27 | 28 | We will create some blocks, models, helpers for our module, overload a controller, define a layout, then 29 | place the whole thing into the admin menu. We have to define models because we will use a collection, 30 | it should have blocks to display the grid and the form, while the helper will handle the translations and it's 31 | required because we make an admin module. We will place our files under the 'local' codepool, under the `My` vendor 32 | and the module's name would be `Reports`. 33 | You can notice some difference while creating a module in the admin area compared to a frontend one. We will overload 34 | the controller under the config's 'admin' node instead of adding a new frontname to the system, also applying the 35 | layout updates would be in a different node named `adminhtml`. You may wonder why we won't place it 36 | under the same node as the controller; this could be traced back to legacy reasons. This node is also the place 37 | for the admin menu configuration, but we separate it into a file named after this node (adminhtml.xml). This is 38 | a feature of Magento, you could separate your module's configuration by the node names used. Usually we do 39 | this with system.xml, adminhtml.xml and api.xml/api_v2.xml, depending on needs. 40 | 41 | 42 | ## Configuration files 43 | 44 | First of all, we will write our module xml. Because we'll work in the `local` codepool, we should 45 | place all of our files under the `app/code/local` directory. 46 | 47 | app/etc/modules/My_Reports.xml 48 | 49 | 50 | 51 | 52 | 53 | true 54 | 0.1.0 55 | local 56 | 57 | 58 | 59 | 60 | In `config.xml`, we tell Magento's admin router to search the controller first in our module, before `Mage_Adminhtml`, 61 | then add the layout update file for creating the report's user interface. 62 | 63 | app/code/local/My/Reports/etc/config.xml 64 | 65 | 66 | 67 | 68 | 69 | 0.1.0 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | My_Reports_Model 81 | my_reports_mysql4 82 | 83 | 84 | My_Reports_Model_Mysql4 85 | 86 | 87 | 88 | 89 | My_Reports_Helper 90 | 91 | 92 | 93 | 94 | My_Reports_Block 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | My_Reports_Adminhtml 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | my_reports.xml 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | After that, add our module to the admin menu under Report > Sales. We define some basic ACL rule too, which 124 | allows every user to operate with our grid. 125 | 126 | app/code/local/My/Reports/etc/adminhtml.xml 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | My Custom Reports 137 | adminhtml/my_reports 138 | 100 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | My Reports Section 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | My Custom Reports 166 | 167 | 168 | View 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | To get a working admin module, we should create a helper class. Since we haven't got any logic which we should 183 | share between blocks, controllers or models, we just inherit everything from `Mage_Core_Helper_Abstract` and leave the body empty. 184 | There is a convention to use the helper's translate method to hook translations through it, so let's follow it on our code! 185 | 186 | app/code/local/My/Reports/Helper/Data.php 187 | 188 | _title($this->__('Reports')) 224 | ->_title($this->__('Sales')) 225 | ->_title($this->__('My Custom Reports')); 226 | $this->loadLayout() 227 | ->_setActiveMenu('report/sales') 228 | ->_addBreadcrumb(Mage::helper('my_reports')->__('Reports'), Mage::helper('my_reports')->__('Reports')) 229 | ->_addBreadcrumb(Mage::helper('my_reports')->__('Sales'), Mage::helper('my_reports')->__('Sales')) 230 | ->_addBreadcrumb(Mage::helper('my_reports')->__('My Custom Reports'), Mage::helper('my_reports')->__('My Custom Reports')); 231 | return $this; 232 | } 233 | 234 | /** 235 | * Prepare blocks with request data from our filter form 236 | * @return My_Reports_Adminhtml_ReportsController 237 | */ 238 | protected function _initReportAction($blocks) 239 | { 240 | if (!is_array($blocks)) { 241 | $blocks = array($blocks); 242 | } 243 | 244 | $requestData = Mage::helper('adminhtml')->prepareFilterString($this->getRequest()->getParam('filter')); 245 | $requestData = $this->_filterDates($requestData, array('from', 'to')); 246 | $params = $this->_getDefaultFilterData(); 247 | foreach ($requestData as $key => $value) { 248 | if (!empty($value)) { 249 | $params->setData($key, $value); 250 | } 251 | } 252 | 253 | foreach ($blocks as $block) { 254 | if ($block) { 255 | $block->setFilterData($params); 256 | } 257 | } 258 | return $this; 259 | } 260 | 261 | /** 262 | * Grid action 263 | */ 264 | public function indexAction() 265 | { 266 | $this->_initAction(); 267 | 268 | $gridBlock = $this->getLayout()->getBlock('adminhtml_report.grid'); 269 | $filterFormBlock = $this->getLayout()->getBlock('grid.filter.form'); 270 | $this->_initReportAction(array( 271 | $gridBlock, 272 | $filterFormBlock 273 | )); 274 | 275 | $this->renderLayout(); 276 | } 277 | 278 | /** 279 | * Export reports to CSV file 280 | */ 281 | public function exportCsvAction() 282 | { 283 | $fileName = 'my_reports.csv'; 284 | $grid = $this->getLayout()->createBlock('my_reports/adminhtml_report_grid'); 285 | $this->_initReportAction($grid); 286 | $this->_prepareDownloadResponse($fileName, $grid->getCsvFile()); 287 | } 288 | 289 | /** 290 | * Export reports to Excel XML file 291 | */ 292 | public function exportExcelAction() 293 | { 294 | $fileName = 'my_reports.xml'; 295 | $grid = $this->getLayout()->createBlock('my_reports/adminhtml_report_grid'); 296 | $this->_initReportAction($grid); 297 | $this->_prepareDownloadResponse($fileName, $grid->getExcelFile()); 298 | } 299 | 300 | /** 301 | * Returns default filter data 302 | * @return Varien_Object 303 | */ 304 | protected function _getDefaultFilterData() 305 | { 306 | return new Varien_Object(array( 307 | 'from' => date('Y-m-d G:i:s', strtotime('-1 month -1 day')), 308 | 'to' => date('Y-m-d G:i:s', strtotime('-1 day')) 309 | )); 310 | } 311 | } 312 | 313 | 314 | ## Layout, Grid Container 315 | 316 | The `indexAction` supplies our blocks with data, therefore it's time to start creating them! Let's start right now with the `layout.xml`. 317 | As you can see, we will need a container block, which would be the place of the grid and the filter form. Notice that nothing describes the grid block here. Don't worry, the container should add it later, dynamically. 318 | 319 | app/design/adminhtml/default/default/layout/my_reports.xml 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | Let's go on with the container. This block should build the the grid block in it's parent class' `_prepareLayout()` method in 333 | the following way: `{blockGroup}/{controller}_grid`. The {blockGroup} is the block alias (`my_reports`), which we already defined in our 334 | `config.xml` under the blocks node, and the {controller} is this block's identifier (`adminhtml_report`). The grid block's name 335 | would be `my_reports/adminhtml_report_grid` then. 336 | 337 | app/code/local/My/Reports/Block/Adminhtml/Report.php 338 | 339 | _headerText = Mage::helper('my_reports')->__('My Custom Reports'); 367 | // Set hard-coded template. As you can see, the layout.xml 368 | // attribute is ineffective, but we keep up with conventions 369 | $this->setTemplate('report/grid/container.phtml'); 370 | // call parent constructor and let it add the buttons 371 | parent::__construct(); 372 | // we create a report, not just a standard grid, so remove add button, we don't need it this time 373 | $this->_removeButton('add'); 374 | 375 | // add a button to our form to let the user kick-off our logic from the admin 376 | $this->addButton('filter_form_submit', array( 377 | 'label' => Mage::helper('my_reports')->__('Show Report'), 378 | 'onclick' => 'filterFormSubmit()' 379 | )); 380 | } 381 | 382 | /** 383 | * This function will prepare our filter URL 384 | * @return string 385 | */ 386 | public function getFilterUrl() 387 | { 388 | $this->getRequest()->setParam('filter', null); 389 | return $this->getUrl('*/*/index', array('_current' => true)); 390 | } 391 | } 392 | 393 | 394 | ## Grid 395 | 396 | The grid connects our backend data and the logic in templates to display everything on the frontend, so it's a 397 | bit of both worlds. 398 | The original sales report grid contains an abstract and a concrete class implementation, but for the purpose 399 | of easy understanding, we will place everything into only one class. 400 | The code which deals with displaying data on the user interface should be prepared in the `_prepareColumns`. Using 401 | the `type` key you can choose one column renderer from the bundled ones (you could find the full list of the renderers 402 | at `Mage_Adminhtml_Block_Widget_Grid_Column::_getRendererByType()`). However, there isn't one which could handle 403 | the percent values, therefore we should create one by ourselves. The `index` would attach the SQL result's column 404 | to the column renderer (you should define the 'alias' here as you defined it in your query in the resource model, 405 | for example you could see how we specified the `shipping_rate` column). 406 | The method which deals with supplying data from the backend is `_prepareCollection()`. Here we pass the values 407 | from the filters to the collection within the `_addCustomFilter()` method. 408 | 409 | app/code/local/My/Reports/Block/Adminhtml/Report/Grid.php 410 | 411 | setPagerVisibility(false); 439 | $this->setUseAjax(false); 440 | $this->setFilterVisibility(false); 441 | 442 | // set message for empty result 443 | $this->setEmptyCellLabel(Mage::helper('my_reports')->__('No records found.')); 444 | 445 | // set grid ID in adminhtml 446 | $this->setId('myReportsGrid'); 447 | 448 | // set our grid to obtain totals 449 | $this->setCountTotals(true); 450 | } 451 | 452 | // add getters 453 | 454 | /** 455 | * Returns the resource collection name which we'll apply filters and display results 456 | * @return string 457 | */ 458 | public function getResourceCollectionName() 459 | { 460 | return $this->_resourceCollectionName; 461 | } 462 | 463 | /** 464 | * Factory method for our resource collection 465 | * @return Mage_Core_Model_Mysql4_Collection_Abstract 466 | */ 467 | public function getResourceCollection() 468 | { 469 | $resourceCollection = Mage::getResourceModel($this->getResourceCollectionName()); 470 | return $resourceCollection; 471 | } 472 | 473 | /** 474 | * Gets the actual used currency code. 475 | * We will convert every currency value to this currency. 476 | * @return string 477 | */ 478 | public function getCurrentCurrencyCode() 479 | { 480 | return Mage::app()->getStore()->getBaseCurrencyCode(); 481 | } 482 | 483 | /** 484 | * Get currency rate, base to given currency 485 | * @param string|Mage_Directory_Model_Currency $toCurrency currency code 486 | * @return int 487 | */ 488 | public function getRate($toCurrency) 489 | { 490 | return Mage::app()->getStore()->getBaseCurrency()->getRate($toCurrency); 491 | } 492 | 493 | /** 494 | * Return totals data 495 | * Count totals if it's not previously counted and set to retrieve 496 | * @return Varien_Object 497 | */ 498 | public function getTotals() 499 | { 500 | $result = parent::getTotals(); 501 | if (!$result && $this->getCountTotals()) { 502 | $filterData = $this->getFilterData(); 503 | $totalsCollection = $this->getResourceCollection(); 504 | 505 | // apply our custom filters on collection 506 | $this->_addCustomFilter( 507 | $totalsCollection, 508 | $filterData 509 | ); 510 | 511 | // isTotals is a flag, we will deal with this in the resource collection 512 | $totalsCollection->isTotals(true); 513 | 514 | // set totals row even if we didn't got a result 515 | if ($totalsCollection->count() < 1) { 516 | $this->setTotals(new Varien_Object); 517 | } else { 518 | $this->setTotals($totalsCollection->getFirstItem()); 519 | } 520 | 521 | $result = parent::getTotals(); 522 | } 523 | 524 | return $result; 525 | } 526 | 527 | // prepare columns and collection 528 | 529 | /** 530 | * Prepare our grid's columns to display 531 | * @return My_Reports_Block_Adminhtml_Grid 532 | */ 533 | protected function _prepareColumns() 534 | { 535 | // get currency code and currency rate for the currency renderers. 536 | // our orders could be in different currencies, therefore we should convert the values to the base currency 537 | $currencyCode = $this->getCurrentCurrencyCode(); 538 | $rate = $this->getRate($currencyCode); 539 | 540 | // add our first column, period which represents a date 541 | $this->addColumn('period', array( 542 | 'header' => Mage::helper('my_reports')->__('Period'), 543 | 'index' => 'created_at', // 'index' attaches a column from the SQL result set to the grid 544 | 'renderer' => 'adminhtml/report_sales_grid_column_renderer_date', 545 | 'width' => 100, 546 | 'sortable' => false, 547 | 'period_type' => $this->getFilterData()->getPeriodType() // could be day, month or year 548 | )); 549 | 550 | // add base grand total w/ a currency renderer, and add totals 551 | $this->addColumn('base_grand_total', array( 552 | 'header' => Mage::helper('my_reports')->__('Grand Total'), 553 | 'index' => 'base_grand_total', 554 | // type defines a grid column renderer; you could find the complete list 555 | // and the exact aliases at Mage_Adminhtml_Block_Widget_Grid_Column::_getRendererByType() 556 | 'type' => 'currency', 557 | 'currency_code' => $currencyCode, // set currency code.. 558 | 'rate' => $rate, // and currency rate, used by the column renderer 559 | 'total' => 'sum' 560 | )); 561 | 562 | // add the next column shipping_amount, with an average on totals 563 | $this->addColumn('base_shipping_amount', array( 564 | 'header' => Mage::helper('my_reports')->__('Shipping Amount'), 565 | 'index' => 'base_shipping_amount', 566 | 'type' => 'currency', 567 | 'currency_code' => $currencyCode, 568 | 'rate' => $rate, 569 | 'total' => 'sum' 570 | )); 571 | 572 | // rate, where base_shipping_amount/base_grand_total is a percent 573 | $this->addColumn('shipping_rate', array( 574 | 'header' => Mage::helper('my_reports')->__('Shipping Rate'), 575 | 'index' => 'shipping_rate', 576 | 'renderer' => 'my_reports/adminhtml_report_grid_column_renderer_percent', 577 | 'decimals' => 2, 578 | 'total' => 'avg' 579 | )); 580 | 581 | // add export types 582 | $this->addExportType('*/*/exportCsv', Mage::helper('my_reports')->__('CSV')); 583 | $this->addExportType('*/*/exportExcel', Mage::helper('my_reports')->__('MS Excel XML')); 584 | 585 | return parent::_prepareColumns(); 586 | } 587 | 588 | /** 589 | * Prepare our collection which we'll display in the grid 590 | * First, get the resource collection we're dealing with, with our custom filters applied. 591 | * In case of an export, we're done, otherwise calculate the totals 592 | * @return My_Reports_Block_Adminhtml_Grid 593 | */ 594 | protected function _prepareCollection() 595 | { 596 | $filterData = $this->getFilterData(); 597 | $resourceCollection = $this->getResourceCollection(); 598 | 599 | // get our resource collection and apply our filters on it 600 | $this->_addCustomFilter( 601 | $resourceCollection, 602 | $filterData 603 | ); 604 | 605 | // attach the prepared collection to our grid 606 | $this->setCollection($resourceCollection); 607 | 608 | // skip totals if we do an export (calling getTotals would be a duplicate, because 609 | // the export method calls it explicitly) 610 | if ($this->_isExport) { 611 | return $this; 612 | } 613 | 614 | // count totals if needed 615 | if ($this->getCountTotals()) { 616 | $this->getTotals(); 617 | } 618 | 619 | return parent::_prepareCollection(); 620 | } 621 | 622 | /** 623 | * Apply our custom filters on collection 624 | * @param Mage_Core_Model_Mysql4_Collection_Abstract $collection 625 | * @param Varien_Object $filterData 626 | * @return My_Reports_Block_Adminhtml_Report_Grid 627 | */ 628 | protected function _addCustomFilter($collection, $filterData) 629 | { 630 | $collection 631 | ->setPeriodType($filterData->getPeriodType()) 632 | ->setDateRange($filterData->getFrom(), $filterData->getTo()) 633 | ->isShippingRateNonZeroOnly($filterData->getShippingRate() ? true : false) 634 | ->setAggregatedColumns($this->_getAggregatedColumns()); 635 | 636 | return $this; 637 | } 638 | 639 | /** 640 | * Returns the columns we specified to summarize totals 641 | * 642 | * Collect all columns we added totals to. 643 | * The returned array would be ie. 'base_grand_total' => 'sum' 644 | * @return array 645 | */ 646 | protected function _getAggregatedColumns() 647 | { 648 | if (!isset($this->_aggregatedColumns) && $this->getColumns()) { 649 | $this->_aggregatedColumns = array(); 650 | foreach ($this->getColumns() as $column) { 651 | if ($column->hasTotal()) { 652 | $this->_aggregatedColumns[$column->getId()] = $column->getTotal(); 653 | } 654 | } 655 | } 656 | 657 | return $this->_aggregatedColumns; 658 | } 659 | 660 | } 661 | 662 | We don't have a renderer to display the percent values yet, so we have to create it. Because 663 | every column object inherits from `Varien_Object`, you could pass any value to your column renderer in the 664 | grid's `_prepareColumns()` method. We will create our renderer by using this capability, but because we 665 | should have default values, we should wrap the getters within our own methods. 666 | If you'd like to display the value differently in an export, you have to overwrite the `renderExport()` 667 | method (by default it returns with the `render()` method's result). 668 | Also, it's worth mentioning that there are two column block types, the one which we would like to create 669 | now, and an other one which deals with inline filtering on values, placed on the top of the grid (we turned 670 | it off this time, see `setFilterVisibility` in the grid class). If you are interested, you could find everything 671 | in `Mage_Adminhtml_Block_Widget_Grid_Column_Filter_Abstract`. 672 | 673 | app/code/local/My/Reports/Block/Adminhtml/Report/Grid/Column/Renderer/Percent.php 674 | 675 | _getValue($row); 698 | $decimals = $this->_getDecimals(); 699 | return number_format($value, $decimals) . '%'; 700 | } 701 | 702 | // add getter for decimals 703 | 704 | /** 705 | * Get decimal to round value by 706 | * The decimals value could be changed with specifying it from outside using 707 | * a setter method supported by Varien_Object (ie. with setData('decimals', 2) or setDecimals(2)) 708 | * @return int 709 | */ 710 | protected function _getDecimals() 711 | { 712 | $decimals = $this->getDecimals(); // this is a magic getter 713 | return !is_null($decimals) ? $decimals : self::DECIMALS; 714 | } 715 | 716 | } 717 | 718 | 719 | ## Form 720 | 721 | We are already done with almost everything in our layout, except the filter form. 722 | This is a block which wraps the `Varien_Data_form` with a template (`widget/grid.phtml`). We will 723 | create a fieldset and place our form elements in it, and put the options for the select elements 724 | to protected getters. We may have to modify the fields in runtime from outside the class, therefore we 725 | will add functionality to achieve this behaviour. 726 | 727 | app/code/local/My/Reports/Block/Adminhtml/Filter/Form.php 728 | 729 | _fieldVisibility[$fieldId] = $visibility ? true : false; 755 | return $this; 756 | } 757 | 758 | /** 759 | * Returns the field is visible or not. If we hadn't set a value 760 | * for the field previously, it will return the value defined in the 761 | * defaultVisibility parameter (it's true by default) 762 | * @param string $fieldId 763 | * @param bool $defaultVisibility 764 | * @return bool 765 | */ 766 | public function getFieldVisibility($fieldId, $defaultVisibility = true) 767 | { 768 | if (isset($this->_fieldVisibility[$fieldId])) { 769 | return $this->_fieldVisibility[$fieldId]; 770 | } 771 | return $defaultVisibility; 772 | } 773 | 774 | /** 775 | * Set field option(s) 776 | * @param string $fieldId 777 | * @param string|array $option if option is an array, loop through it's keys and values 778 | * @param mixed $value if option is an array this option is meaningless 779 | * @return My_Reports_Block_Adminhtml_Filter_Form 780 | */ 781 | public function setFieldOption($fieldId, $option, $value = null) 782 | { 783 | if (is_array($option)) { 784 | $options = $option; 785 | } else { 786 | $options = array($option => $value); 787 | } 788 | 789 | if (!isset($this->_fieldOptions[$fieldId])) { 790 | $this->_fieldOptions[$fieldId] = array(); 791 | } 792 | 793 | foreach ($options as $key => $value) { 794 | $this->_fieldOptions[$fieldId][$key] = $value; 795 | } 796 | 797 | return $this; 798 | } 799 | 800 | /** 801 | * Prepare our form elements 802 | * @return My_Reports_Block_Adminhtml_Filter_Form 803 | */ 804 | protected function _prepareForm() 805 | { 806 | // inicialise our form 807 | $actionUrl = $this->getCurrentUrl(); 808 | $form = new Varien_Data_Form(array( 809 | 'id' => 'filter_form', 810 | 'action' => $actionUrl, 811 | 'method' => 'get' 812 | )); 813 | 814 | // set ID prefix for all elements in our form 815 | $htmlIdPrefix = 'my_reports_'; 816 | $form->setHtmlIdPrefix($htmlIdPrefix); 817 | 818 | // create a fieldset to add elements to 819 | $fieldset = $form->addFieldset( 820 | 'base_fieldset', 821 | array( 822 | 'legend' => Mage::helper('my_reports')->__('Filter') 823 | ) 824 | ); 825 | 826 | // prepare our filter fields and add each to the fieldset 827 | 828 | // date filter 829 | $dateFormatIso = Mage::app() 830 | ->getLocale() 831 | ->getDateFormat(Mage_Core_Model_Locale::FORMAT_TYPE_SHORT); 832 | $fieldset->addField('from', 'date', array( 833 | 'name' => 'from', 834 | 'format' => $dateFormatIso, 835 | 'image' => $this->getSkinUrl('images/grid-cal.gif'), 836 | 'label' => Mage::helper('my_reports')->__('From'), 837 | 'title' => Mage::helper('my_reports')->__('From') 838 | )); 839 | $fieldset->addField('to', 'date', array( 840 | 'name' => 'to', 841 | 'format' => $dateFormatIso, 842 | 'image' => $this->getSkinUrl('images/grid-cal.gif'), 843 | 'label' => Mage::helper('my_reports')->__('To'), 844 | 'title' => Mage::helper('my_reports')->__('To') 845 | )); 846 | $fieldset->addField('period_type', 'select', array( 847 | 'name' => 'period_type', 848 | 'options' => $this->_getPeriodTypeOptions(), 849 | 'label' => Mage::helper('my_reports')->__('Period') 850 | )); 851 | 852 | // non-zero shipping rate filter 853 | $fieldset->addField('shipping_rate', 'select', array( 854 | 'name' => 'shipping_rate', 855 | 'options' => $this->_getShippingRateSelectOptions(), 856 | 'label' => Mage::helper('my_reports')->__('Show values where shipping rate greater than 0') 857 | )); 858 | 859 | $form->setUseContainer(true); 860 | $this->setForm($form); 861 | 862 | return $this; 863 | } 864 | 865 | /** 866 | * Get period type options 867 | * @return array 868 | */ 869 | protected function _getPeriodTypeOptions() 870 | { 871 | $options = array( 872 | 'day' => Mage::helper('my_reports')->__('Day'), 873 | 'month' => Mage::helper('my_reports')->__('Month'), 874 | 'year' => Mage::helper('my_reports')->__('Year'), 875 | ); 876 | 877 | return $options; 878 | } 879 | 880 | /** 881 | * Returns options for shipping rate select 882 | * @return array 883 | */ 884 | protected function _getShippingRateSelectOptions() 885 | { 886 | $options = array( 887 | '0' => 'Any', 888 | '1' => 'Specified' 889 | ); 890 | 891 | return $options; 892 | } 893 | 894 | /** 895 | * Inicialise form values 896 | * Called after prepareForm, we apply the previously set values from filter on the form 897 | * @return My_Reports_Block_Adminhtml_Filter_Form 898 | */ 899 | protected function _initFormValues() 900 | { 901 | $filterData = $this->getFilterData(); 902 | $this->getForm()->addValues($filterData->getData()); 903 | return parent::_initFormValues(); 904 | } 905 | 906 | /** 907 | * Apply field visibility and field options on our form fields before rendering 908 | * @return My_Reports_Block_Adminhtml_Filter_Form 909 | */ 910 | protected function _beforeHtml() 911 | { 912 | $result = parent::_beforeHtml(); 913 | 914 | $elements = $this->getForm()->getElements(); 915 | 916 | // iterate on our elements and select fieldsets 917 | foreach ($elements as $element) { 918 | $this->_applyFieldVisibiltyAndOptions($element); 919 | } 920 | 921 | return $result; 922 | } 923 | 924 | /** 925 | * Apply field visibility and options on fieldset element 926 | * Recursive 927 | * @param Varien_Data_Form_Element_Fieldset $element 928 | * @return Varien_Data_Form_Element_Fieldset 929 | */ 930 | protected function _applyFieldVisibiltyAndOptions($element) { 931 | if ($element instanceof Varien_Data_Form_Element_Fieldset) { 932 | foreach ($element->getElements() as $fieldElement) { 933 | // apply recursively 934 | if ($fieldElement instanceof Varien_Data_Form_Element_Fieldset) { 935 | $this->_applyFieldVisibiltyAndOptions($fieldElement); 936 | continue; 937 | } 938 | 939 | $fieldId = $fieldElement->getId(); 940 | // apply field visibility 941 | if (!$this->getFieldVisibility($fieldId)) { 942 | $element->removeField($fieldId); 943 | continue; 944 | } 945 | 946 | // apply field options 947 | if (isset($this->_fieldOptions[$fieldId])) { 948 | $fieldOptions = $this->_fieldOptions[$fieldId]; 949 | foreach ($fieldOptions as $k => $v) { 950 | $fieldElement->setDataUsingMethod($k, $v); 951 | } 952 | } 953 | } 954 | } 955 | 956 | return $element; 957 | } 958 | 959 | } 960 | 961 | 962 | ## Collection 963 | 964 | Finally arrived to the point when we will code our last class: the collection. It will 965 | collect our data which we would like to display in the grid rows. We should have to write some getters, 966 | those ones which we already referenced to in the `_addCustomFilter()` method. The SQL query building starts 967 | in the `_initSelect()` method. It is originally called from the parent class' constructor, but it 968 | isn't fit for us this case, because the `isTotals` flag is set after the object has been 969 | instantiated, we will move the select initialisation into the `_beforeLoad()` method. 970 | We should define the displayed columns in the `_getSelectedColumns()` method based on the `isTotals` flag's 971 | value. The `_getAggregatedColumns()` method builds the SQL query's columns part in totals mode. In the 972 | original Sales Report the aggregated columns are prepared in the grid in this format: 973 | `'columnId' => '{$total}({$columnId})'`, but I think building queries are the resource model's 974 | responsibility; therefore I chose a different realisation (take a look at the `_getAggregatedColumn()` method). 975 | If you'd like to debug and see the actual queries, overwrite the `load()` method. The method's two 976 | parameters explains the functionality behind them. For a little hint you could take a look 977 | at `Varien_Data_Collection_Db::printLogQuery()`. 978 | 979 | app/code/local/My/Reports/Model/Mysql4/Report/Collection.php 980 | 981 | 'total' 1022 | * @var array 1023 | */ 1024 | protected $_aggregatedColumns = array(); 1025 | 1026 | // define basic setup of our collection 1027 | 1028 | /** 1029 | * We should overwrite constructor to allow custom resources to use 1030 | * The original constructor calls _initSelect by default which isn't suits our 1031 | * needs, because the totals mode is set after instantiation of 1032 | * the collection object (therefore we will handle this case right before 1033 | * loading our collection). 1034 | */ 1035 | public function __construct($resource = null) 1036 | { 1037 | $this->setModel('adminhtml/report_item'); 1038 | $this->setResourceModel('sales/order'); 1039 | $this->setConnection($this->getResource()->getReadConnection()); 1040 | } 1041 | 1042 | // add filter methods 1043 | 1044 | /** 1045 | * Set period type 1046 | * @param string $periodType 1047 | * @return My_Reports_Model_Mysql4_Report_Collection 1048 | */ 1049 | public function setPeriodType($periodType) 1050 | { 1051 | $this->_periodType = $periodType; 1052 | return $this; 1053 | } 1054 | 1055 | /** 1056 | * Set date range to filter on 1057 | * @param string $from 1058 | * @param string $to 1059 | * @return My_Reports_Model_Mysql4_Report_Collection 1060 | */ 1061 | public function setDateRange($from, $to) 1062 | { 1063 | $this->_from = $from; 1064 | $this->_to = $to; 1065 | return $this; 1066 | } 1067 | 1068 | /** 1069 | * Setter/getter method for filtering items only with shipping rate greater than zero 1070 | * @param bool $bool by default null it returns the current state flag 1071 | * @return bool|My_Reports_Model_Mysql4_Report_Collection 1072 | */ 1073 | public function isShippingRateNonZeroOnly($bool = null) 1074 | { 1075 | if (is_null($bool)) { 1076 | return $this->_isShippingRateNonZeroOnly; 1077 | } 1078 | $this->_isShippingRateNonZeroOnly = $bool ? true : false; 1079 | return $this; 1080 | } 1081 | 1082 | /** 1083 | * Set aggregated columns used in totals mode 1084 | * @param array $columns 1085 | * @return My_Reports_Model_Mysql4_Report_Collection 1086 | */ 1087 | public function setAggregatedColumns($columns) 1088 | { 1089 | $this->_aggregatedColumns = $columns; 1090 | return $this; 1091 | } 1092 | 1093 | /** 1094 | * Setter/getter for setting totals mode on collection 1095 | * By default the collection selects columns we display in the grid, 1096 | * by selecting this mode we will only query the aggregated columns 1097 | * @param bool $bool by default null it returns the current state of flag 1098 | * @return bool|My_Reports_Model_Mysql4_Report_Collection 1099 | */ 1100 | public function isTotals($bool = null) 1101 | { 1102 | if (is_null($bool)) { 1103 | return $this->_isTotals; 1104 | } 1105 | $this->_isTotals = $bool ? true : false; 1106 | return $this; 1107 | } 1108 | 1109 | // prepare select 1110 | 1111 | /** 1112 | * Get selected columns depending on totals mode 1113 | */ 1114 | protected function _getSelectedColumns() { 1115 | if ($this->isTotals()) { 1116 | $selectedColumns = $this->_getAggregatedColumns(); 1117 | } else { 1118 | $selectedColumns = array( 1119 | 'created_at' => $this->_getPeriodFormat(), 1120 | 'base_grand_total' => 'SUM(base_grand_total)', 1121 | 'base_shipping_amount' => 'SUM(base_shipping_amount)', 1122 | 'shipping_rate' => 'AVG((base_shipping_amount / base_grand_total) * 100)', 1123 | 'base_currency_code' => 'base_currency_code', 1124 | ); 1125 | } 1126 | 1127 | return $selectedColumns; 1128 | } 1129 | 1130 | /** 1131 | * Return aggregated columns 1132 | * This method uses ::_getAggregatedColumn for getting the db expression for the specified columnId 1133 | * @return array 1134 | */ 1135 | protected function _getAggregatedColumns() 1136 | { 1137 | $aggregatedColumns = array(); 1138 | foreach ($this->_aggregatedColumns as $columnId => $total) { 1139 | $aggregatedColumns[$columnId] = $this->_getAggregatedColumn($columnId, $total); 1140 | } 1141 | return $aggregatedColumns; 1142 | } 1143 | 1144 | /** 1145 | * Returns the db expression based on total mode and column ID 1146 | * @param string $columnId the column's ID used in expression 1147 | * @param string $total mode of aggregation (could be sum or avg) 1148 | * @return string 1149 | */ 1150 | protected function _getAggregatedColumn($columnId, $total) 1151 | { 1152 | switch ($columnId) { 1153 | case 'shipping_rate' : { 1154 | $expression = "{$total}((base_shipping_amount / base_grand_total) * 100)"; 1155 | } break; 1156 | default : { 1157 | $expression = "{$total}({$columnId})"; 1158 | } break; 1159 | } 1160 | 1161 | return $expression; 1162 | } 1163 | 1164 | /** 1165 | * Get period format based on '_periodType' 1166 | * @return string 1167 | */ 1168 | protected function _getPeriodFormat() 1169 | { 1170 | $adapter = $this->getConnection(); 1171 | if ('month' == $this->_periodType) { 1172 | $periodFormat = 'DATE_FORMAT(created_at, \'%Y-%m\')'; 1173 | // From Magento EE 1.12 you should use the adapter's appropriate method: 1174 | // $periodFormat = $adapter->getDateFormatSql('created_at', '%Y-%m'); 1175 | } else if ('year' == $this->_periodType) { 1176 | $periodFormat = 'EXTRACT(YEAR FROM created_at)'; 1177 | // From Magento EE 1.12 you should use the adapter's appropriate method: 1178 | // $periodFormat = $adapter->getDateExtractSql('created_at', Varien_Db_Adapter_Interface::INTERVAL_YEAR); 1179 | } else { 1180 | $periodFormat = 'created_at'; 1181 | // From Magento EE 1.12 you should use the adapter's appropriate method: 1182 | // $periodFormat = $adapter->getDateFormatSql('created_at', '%Y-%m-%d'); 1183 | } 1184 | 1185 | return $periodFormat; 1186 | } 1187 | 1188 | /** 1189 | * Prepare select statement depending on totals is on or off 1190 | * @return My_Reports_Model_Mysql4_Report_Collection 1191 | */ 1192 | protected function _initSelect() 1193 | { 1194 | $this->getSelect()->reset(); 1195 | 1196 | // select aggregated columns only in totals; w/o grouping by period 1197 | $this->getSelect()->from($this->getResource()->getMainTable(), $this->_getSelectedColumns()); 1198 | if (!$this->isTotals()) { 1199 | $this->getSelect()->group($this->_getPeriodFormat()); 1200 | } 1201 | 1202 | return $this; 1203 | } 1204 | 1205 | // render filters 1206 | 1207 | /** 1208 | * Apply our date range filter on select 1209 | * @return My_Reports_Model_Mysql4_Report_Collection 1210 | */ 1211 | protected function _applyDateRangeFilter() 1212 | { 1213 | if (!is_null($this->_from)) { 1214 | $this->_from = date('Y-m-d G:i:s', strtotime($this->_from)); 1215 | $this->getSelect()->where('created_at >= ?', $this->_from); 1216 | } 1217 | if (!is_null($this->_to)) { 1218 | $this->_to = date('Y-m-d G:i:s', strtotime($this->_to)); 1219 | $this->getSelect()->where('created_at <= ?', $this->_to); 1220 | } 1221 | 1222 | return $this; 1223 | } 1224 | 1225 | /** 1226 | * Apply shipping rate filter 1227 | * @return My_Reports_Model_Mysql4_Report_Collection 1228 | */ 1229 | protected function _applyShippingRateNonZeroOnlyFilter() 1230 | { 1231 | if ($this->_isShippingRateNonZeroOnly) { 1232 | $this->getSelect() 1233 | ->where('((base_shipping_amount / base_grand_total) * 100) > 0'); 1234 | } 1235 | } 1236 | 1237 | /** 1238 | * Inicialise select right before loading collection 1239 | * We need to fire _initSelect here, because the isTotals mode creates different results depending 1240 | * on it's value. The parent implementation of the collection originally fires this method in the 1241 | * constructor. 1242 | * @return My_Reports_Model_Mysql4_Report_Collection 1243 | */ 1244 | protected function _beforeLoad() 1245 | { 1246 | $this->_initSelect(); 1247 | return parent::_beforeLoad(); 1248 | } 1249 | 1250 | /** 1251 | * This would render all of our pre-set filters on collection. 1252 | * Calling of this method happens in Varien_Data_Collection_Db::_renderFilters(), while 1253 | * the _renderFilters itself is called in Varien_Data_Collection_Db::load() before calling 1254 | * _renderOrders() and _renderLimit() . 1255 | * @return My_Reports_Model_Mysql4_Report_Collection 1256 | */ 1257 | protected function _renderFiltersBefore() 1258 | { 1259 | $this 1260 | ->_applyDateRangeFilter() 1261 | ->_applyShippingRateNonZeroOnlyFilter(); 1262 | return $this; 1263 | } 1264 | 1265 | } 1266 | 1267 | 1268 | ## Final words 1269 | 1270 | As you could see, it's not rocket science to create a report. However it could be scary at first, but 1271 | I hope I could give you a better understanding of the process. Send me a beer if I was able to help you :) 1272 | Comments and opinions are more than welcome. 1273 | The module is available on GitHub: https://github.com/technodelight/magento_custom_reports_example 1274 | --------------------------------------------------------------------------------