From 24b178fe752012a530582c35ab593a5f4c29c43a Mon Sep 17 00:00:00 2001 From: Travis Raup Date: Sun, 12 Feb 2023 08:29:44 -0500 Subject: [PATCH] Feature: Stock Purchase Metrics --- controllers/StockController.php | 47 ++++++++++ localization/strings.pot | 15 ++++ package.json | 2 + public/viewjs/metrics.js | 96 ++++++++++++++++++++ routes.php | 1 + services/DemoDataGeneratorService.php | 19 +++- views/layout/default.blade.php | 4 + views/stockmetricspurchases.blade.php | 121 ++++++++++++++++++++++++++ views/stockoverview.blade.php | 4 + yarn.lock | 19 +++- 10 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 public/viewjs/metrics.js create mode 100644 views/stockmetricspurchases.blade.php diff --git a/controllers/StockController.php b/controllers/StockController.php index 26ec8527..f75e6d0a 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -515,6 +515,53 @@ class StockController extends BaseController ]); } + public function StockMetricsPurchases(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) + { + if (isset($request->getQueryParams()['start_date']) AND isset($request->getQueryParams()['end_date'])) + { + $start_date = $request->getQueryParams()['start_date']; + $end_date = $request->getQueryParams()['end_date']; + $where = "purchased_date >= '$start_date' AND purchased_date <= '$end_date'"; + } + else + { + // Default this month + $where = "purchased_date >= DATE(DATE('now', 'localtime'), 'start of month')"; + } + + + if (isset($request->getQueryParams()['byGroup'])) + { + $sql = " + SELECT product_group_id as id, product_group as name, sum(quantity * price) as total + FROM product_purchase_history + where $where + GROUP BY product_group + ORDER BY product_group + "; + } else { + if (isset($request->getQueryParams()['product_group']) AND $request->getQueryParams()['product_group'] != 'all') + { + $where = $where . ' AND product_group_id = ' . $request->getQueryParams()['product_group']; + } + + $sql = " + SELECT product_id as id, product_name as name, product_group_id as group_id, product_group as group_name, sum(quantity * price) as total + FROM product_purchase_history + WHERE $where + GROUP BY product_name + ORDER BY product_name + "; + } + + return $this->renderPage($response, 'stockmetricspurchases', [ + 'metrics' => $this->getDatabaseService()->ExecuteDbQuery($sql)->fetchAll(\PDO::FETCH_OBJ), + 'productGroups' => $this->getDatabase()->product_groups()->orderBy('name', 'COLLATE NOCASE'), + 'selectedGroup' => isset($request->getQueryParams()['product_group']) ? $request->getQueryParams()['product_group'] : null, + 'byGroup' => isset($request->getQueryParams()['byGroup']) ? $request->getQueryParams()['byGroup'] : null + ]); + } + public function Transfer(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) { return $this->renderPage($response, 'transfer', [ diff --git a/localization/strings.pot b/localization/strings.pot index 92d05e62..64cb762d 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2371,3 +2371,18 @@ msgstr "" msgid "Quick open amount" msgstr "" + +msgid "Metrics" +msgstr "" + +msgid "Stock Metrics: Purchases" +msgstr "" + +msgid "by Product" +msgstr "" + +msgid "by Group" +msgstr "" + +msgid "Total" +msgstr "" diff --git a/package.json b/package.json index f9233c4c..312f77ee 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "bootstrap": "^4.5.2", "bootstrap-select": "^1.13.18", "bwip-js": "^3.0.1", + "canvasjs": "^1.8.3", "chart.js": "^2.8.0", "datatables.net": "^1.10.22", "datatables.net-bs4": "^1.10.22", @@ -21,6 +22,7 @@ "datatables.net-rowgroup-bs4": "^1.1.2", "datatables.net-select": "^1.3.1", "datatables.net-select-bs4": "^1.3.1", + "daterangepicker": "dangrossman/daterangepicker", "fullcalendar": "^3.10.1", "gettext-translator": "2.1.0", "jquery": "^3.6.0", diff --git a/public/viewjs/metrics.js b/public/viewjs/metrics.js new file mode 100644 index 00000000..3dc85807 --- /dev/null +++ b/public/viewjs/metrics.js @@ -0,0 +1,96 @@ +/* + * Metrics Javascript + */ + +/* Charting */ +var dataPoints = []; +$("#metrics-table tbody tr").each(function () { + var self = $(this); + var label = self.find("td:eq(0)").attr('data-chart-label'); + var value = Number(self.find("td:eq(1)").attr('data-chart-value')); + var dataPoint = { label: label, y: parseFloat((Math.round(value * 100) / 100).toFixed(2))}; + dataPoints.push(dataPoint); +}); + +var options = { + exportEnabled: true, + legend:{ + horizontalAlign: "center", + verticalAlign: "bottom" + }, + data: [{ + type: "pie", + showInLegend: true, + toolTipContent: "{label}: ${y} (#percent%)", + indexLabel: "{label}", + legendText: "{label} (#percent%)", + indexLabelPlacement: "outside", + valueFormatSTringt: "#,##0.##", + dataPoints: dataPoints + }] +}; + +// needed for recursionCount error +recursionCount=0; +$("#metrics-chart").CanvasJSChart(options); + +/* DataTables */ +var metricsTable = $('#metrics-table').DataTable({ + "columnDefs": [ + { "type": "num", "targets": 1 } + ] +}); +$('#metrics-table tbody').removeClass("d-none"); +metricsTable.columns.adjust().draw(); + +/* DateRangePicker */ +const urlParams = new URLSearchParams(window.location.search); + +var start_date = moment().startOf("month").format('MM/DD/YYYY'); +var end_date = moment().endOf("month").format('MM/DD/YYYY'); + +if (urlParams.get('start_date')) start_date = moment(urlParams.get('start_date')) ; +if (urlParams.get('end_date')) end_date = moment(urlParams.get('end_date')); + +$('#daterange-filter').daterangepicker({ + showDropdowns: true, + startDate: start_date, + endDate: end_date, + locale: { + "format": 'MM/DD/YYYY', + "firstDay": 1 + }, + ranges: { + 'Today': [moment(), moment()], + 'Yesterday': [moment().subtract(1, 'days'), moment().subtract(1, 'days')], + 'Last 7 Days': [moment().subtract(6, 'days'), moment()], + 'Last 14 Days': [moment().subtract(13, 'days'), moment()], + 'Last 30 Days': [moment().subtract(29, 'days'), moment()], + 'This Month': [moment().startOf('month'), moment().endOf('month')], + 'Last Month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')], + 'This Year': [moment().startOf('year'), moment().endOf('year')], + 'Last Year': [moment().subtract(1, 'year').startOf('year'), moment().subtract(1, 'year').endOf('year')] + }}, function(start, end, label) { + UpdateUriParam("start_date", start.format('YYYY-MM-DD')); + UpdateUriParam("end_date", end.format('YYYY-MM-DD')) + window.location.reload(); +}); + +$('#daterange-filter').on('cancel.daterangepicker', function(ev, picker) +{ + $(this).val(start_date + ' - ' + end_date); +}); + +$("#clear-filter-button").on("click", function() +{ + RemoveUriParam("start_date"); + RemoveUriParam("end_date"); + RemoveUriParam("product_group"); + window.location.reload(); +}); + +$("#product-group-filter").on("change", function() +{ + UpdateUriParam("product_group", $(this).val()); + window.location.reload(); +}); diff --git a/routes.php b/routes.php index b0b2710d..00c1ad81 100644 --- a/routes.php +++ b/routes.php @@ -56,6 +56,7 @@ $app->group('', function (RouteCollectorProxy $group) { $group->get('/locations', '\Grocy\Controllers\StockController:LocationsList'); $group->get('/location/{locationId}', '\Grocy\Controllers\StockController:LocationEditForm'); $group->get('/stockjournal', '\Grocy\Controllers\StockController:Journal'); + $group->get('/stockmetricspurchases', '\Grocy\Controllers\StockController:StockMetricsPurchases'); $group->get('/locationcontentsheet', '\Grocy\Controllers\StockController:LocationContentSheet'); $group->get('/quantityunitpluraltesting', '\Grocy\Controllers\StockController:QuantityUnitPluralFormTesting'); $group->get('/stockjournal/summary', '\Grocy\Controllers\StockController:JournalSummary'); diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php index c057b7b6..e202d793 100644 --- a/services/DemoDataGeneratorService.php +++ b/services/DemoDataGeneratorService.php @@ -66,6 +66,8 @@ class DemoDataGeneratorService extends BaseService INSERT INTO quantity_units (id, name, name_plural) VALUES (12, '{$this->__n_sql(1, 'Slice', 'Slices')}', '{$this->__n_sql(2, 'Slice', 'Slices')}'); --12 DELETE FROM quantity_units WHERE name = '{$this->__t_sql('Kilogram')}'; INSERT INTO quantity_units (id, name, name_plural) VALUES (13, '{$this->__n_sql(1, 'Kilogram', 'Kilograms')}', '{$this->__n_sql(2, 'Kilogram', 'Kilograms')}'); --13 + DELETE FROM quantity_units WHERE name = '{$this->__t_sql('pint')}'; + INSERT INTO quantity_units (id, name, name_plural) VALUES (14, '{$this->__n_sql(1, 'Pint', 'Pints')}', '{$this->__n_sql(2, 'Pint', 'Pint')}'); --14 INSERT INTO product_groups(name) VALUES ('01 {$this->__t_sql('Sweets')}'); --1 INSERT INTO product_groups(name) VALUES ('02 {$this->__t_sql('Bakery products')}'); --2 @@ -73,13 +75,14 @@ class DemoDataGeneratorService extends BaseService INSERT INTO product_groups(name) VALUES ('04 {$this->__t_sql('Butchery products')}'); --4 INSERT INTO product_groups(name) VALUES ('05 {$this->__t_sql('Vegetables/Fruits')}'); --5 INSERT INTO product_groups(name) VALUES ('06 {$this->__t_sql('Refrigerated products')}'); --6 + INSERT INTO product_groups(name) VALUES ('07 {$this->__t_sql('Beverages')}'); --7' DELETE FROM sqlite_sequence WHERE name = 'products'; --Just to keep IDs in order as mentioned here... INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, min_stock_amount, product_group_id, picture_file_name) VALUES ('{$this->__t_sql('Cookies')}', 4, 3, 3, 8, 1, 'cookies.jpg'); --1 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, min_stock_amount, product_group_id, cumulate_min_stock_amount_of_sub_products) VALUES ('{$this->__t_sql('Chocolate')}', 4, 3, 3, 8, 1, 1); --2 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, min_stock_amount, product_group_id, picture_file_name) VALUES ('{$this->__t_sql('Gummy bears')}', 4, 3, 3, 8, 1, 'gummybears.jpg'); --3 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, min_stock_amount, product_group_id) VALUES ('{$this->__t_sql('Crisps')}', 4, 3, 3, 10, 1); --4 - INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Eggs')}', 2, 3, 2, 5); --5 + INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Eggs')}', 2, 3, 2, 6); --5 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Noodles')}', 3, 3, 3, 6); --6 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Pickles')}', 5, 4, 4, 3); --7 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Gulash soup')}', 5, 5, 5, 3); --8 @@ -101,6 +104,10 @@ class DemoDataGeneratorService extends BaseService INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id, parent_product_id) VALUES ('{$this->__t_sql('Milk Chocolate')}', 4, 3, 3, 1, 2); --24 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id, parent_product_id) VALUES ('{$this->__t_sql('Dark Chocolate')}', 4, 3, 3, 1, 2); --25 INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Waffle rolls')}', 4, 3, 3, 1); --26 + INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Ice Cream')}', 6, 14, 14, 1); --27 + INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Soda')}', 2, 6, 6, 7); --28 + INSERT INTO products (name, location_id, qu_id_purchase, qu_id_stock, product_group_id) VALUES ('{$this->__t_sql('Beer')}', 2, 6, 6, 7); --29 + UPDATE products SET calories = 123 WHERE IFNULL(calories, 0) = 0; INSERT INTO product_barcodes (product_id, barcode) VALUES (8, '22111968'); @@ -289,6 +296,16 @@ class DemoDataGeneratorService extends BaseService $stockService->AddProduct(24, 2, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-10 days')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); $stockService->AddProduct(25, 2, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-10 days')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); $stockService->AddProduct(2, 1, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-10 days')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(27, 1, date('Y-m-d', strtotime('+30 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('now')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(23, 1, date('Y-m-d', strtotime('+60 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('now')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(27, 1, date('Y-m-d', strtotime('+30 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-2 weeks')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(27, 1, date('Y-m-d', strtotime('+30 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-3 weeks')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(28, 12, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-1 weeks')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(29, 12, date('Y-m-d', strtotime('+365 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-2 weeks')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(5, 1, date('Y-m-d', strtotime('+1 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-1 days')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(1, 12, date('Y-m-d', strtotime('+180 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-1 days')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddProduct(2, 12, date('Y-m-d', strtotime('+365 days')), StockService::TRANSACTION_TYPE_PURCHASE, date('Y-m-d', strtotime('-1 days')), $this->RandomPrice(), null, $this->NextSupermarketId(), $stockTransactionId); + $stockService->AddMissingProductsToShoppingList(); $stockService->OpenProduct(3, 1); $stockService->OpenProduct(6, 1); diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php index f4acb23f..4ac27c16 100644 --- a/views/layout/default.blade.php +++ b/views/layout/default.blade.php @@ -71,6 +71,8 @@ rel="stylesheet"> + @endif + + diff --git a/views/stockmetricspurchases.blade.php b/views/stockmetricspurchases.blade.php new file mode 100644 index 00000000..6a3f326f --- /dev/null +++ b/views/stockmetricspurchases.blade.php @@ -0,0 +1,121 @@ +@extends('layout.default') + +@section('title', $__t('Stock Metrics: Purchases')) +@section('activeNav', 'stockmetricspurchases') +@section('viewJsName', 'metrics') + +@section('content') +
+
+ +
+
+ +
+ +
+
+
+
+  {{ $__t('Date range') }} + +
+
+
+ @if(!$byGroup) +
+
+
+  {{ $__t('Product group') }} +
+ +
+
+ @endif +
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + @if(!$byGroup) + + @endif + + + + @foreach($metrics as $metric) + + + + @if(!$byGroup) + + @endif + + @endforeach + +
{{ $__t('Name') }}{{ $__t('Total') }}{{ $__t('Product group') }}
+ {{ $metric->name }} + + {{ $metric->total }} + + {{ $metric->group_name }} +
+
+
+@stop diff --git a/views/stockoverview.blade.php b/views/stockoverview.blade.php index f385f86f..87a6ebf4 100755 --- a/views/stockoverview.blade.php +++ b/views/stockoverview.blade.php @@ -40,6 +40,10 @@ href="{{ $U('/stockentries') }}"> {{ $__t('Stock entries') }} + + {{ $__t('Metrics') }} + @if(GROCY_FEATURE_FLAG_STOCK_LOCATION_TRACKING) diff --git a/yarn.lock b/yarn.lock index 3d0b6544..7e96eb79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -112,6 +112,11 @@ bwip-js@^3.0.1: resolved "https://registry.yarnpkg.com/bwip-js/-/bwip-js-3.2.2.tgz#bef1b5b566519754acd251dbe323a2fe1dc06c9a" integrity sha512-70aY2FSRVd1u6q8iXY+HDQDm6598lQt/toSNLrKeQhbmzw75y40Hmg85MTDtVv1NElbRPNtsX9aPuOQVsFuOzA== +canvasjs@^1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/canvasjs/-/canvasjs-1.8.3.tgz#181a526bbce09c1909431d9471f808494fe970cf" + integrity sha512-60eUT0VjqRgYqdIQcOkXg0Zptfbl4HefA/O51YEf1m/P0uXvE3icI/1KPrXpY9aVxn8gG/BB8DzVoTGCcyBnYg== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -307,6 +312,13 @@ datatables.net@>=1.12.1, datatables.net@^1.10.22: dependencies: jquery ">=1.7" +daterangepicker@dangrossman/daterangepicker: + version "3.1.0" + resolved "https://codeload.github.com/dangrossman/daterangepicker/tar.gz/8495717c4007a03fd5dee422f161811fd6140c0e" + dependencies: + jquery ">=1.10" + moment "^2.9.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -479,6 +491,11 @@ jquery@3.3.1: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" integrity sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg== +jquery@>=1.10: + version "3.6.3" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.3.tgz#23ed2ffed8a19e048814f13391a19afcdba160e6" + integrity sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg== + jquery@>=1.12.0, jquery@>=1.7, jquery@>=1.7.2, jquery@^3.6.0: version "3.6.3" resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.3.tgz#23ed2ffed8a19e048814f13391a19afcdba160e6" @@ -538,7 +555,7 @@ moment-timezone@^0.5.34: dependencies: moment ">= 2.9.0" -"moment@>= 2.9.0", moment@^2.10.2, moment@^2.27.0, moment@^2.29.2: +"moment@>= 2.9.0", moment@^2.10.2, moment@^2.27.0, moment@^2.29.2, moment@^2.9.0: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==