├── 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 |
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 |
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 |
--------------------------------------------------------------------------------