diff --git a/composer.json b/composer.json index 5313a982..dbe94366 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "gumlet/php-image-resize": "^1.9", "ezyang/htmlpurifier": "^4.13", "jucksearm/php-barcode": "^1.0", - "guzzlehttp/guzzle": "^7.0" + "guzzlehttp/guzzle": "^7.0", + "mike42/escpos-php": "^3.0" }, "autoload": { "psr-4": { diff --git a/config-dist.php b/config-dist.php index aa653045..a39cc028 100644 --- a/config-dist.php +++ b/config-dist.php @@ -95,6 +95,22 @@ Setting('MEAL_PLAN_FIRST_DAY_OF_WEEK', ''); // see the file controllers/Users/User.php for possible values Setting('DEFAULT_PERMISSIONS', ['ADMIN']); +// When using a thermal printer (thermal printers are receipt printers, not regular printers) +// The printer must support the ESC/POS protocol, see https://github.com/mike42/escpos-php +Setting('TPRINTER_IS_NETWORK_PRINTER', false); // Set to true if it is a network printer +Setting('TPRINTER_PRINT_QUANTITY_NAME', true); // Set to false if you do not want to print the quantity names +Setting('TPRINTER_PRINT_NOTES', true); // Set to false if you do not want to print notes + +//Configuration below for network printers. If you are using a USB/serial printer, skip to next section +Setting('TPRINTER_IP', '127.0.0.1'); // IP of the network printer +Setting('TPRINTER_PORT', 9100); // Port of printer, eg. 9100 +//Configuration below if you are using a USB or serial printer +Setting('TPRINTER_CONNECTOR', '/dev/usb/lp0'); // Location of printer. For USB on Linux this is often '/dev/usb/lp0', + // for serial printers it could be similar to '/dev/ttyS0' + // Make sure that the user that runs the webserver has permissions to write to the printer! + // On Linux add your webserver user to the LP group with usermod -a -G lp www-data + + // Default user settings // These settings can be changed per user, here the defaults // are defined which are used when the user has not changed the setting so far @@ -198,6 +214,7 @@ Setting('FEATURE_FLAG_STOCK_PRODUCT_FREEZING', true); Setting('FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_FIELD_NUMBER_PAD', true); // Activate the number pad in due date fields on (supported) mobile browsers Setting('FEATURE_FLAG_SHOPPINGLIST_MULTIPLE_LISTS', true); Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true); +Setting('FEATURE_FLAG_THERMAL_PRINTER', false); // Feature settings Setting('FEATURE_SETTING_STOCK_COUNT_OPENED_PRODUCTS_AGAINST_MINIMUM_STOCK_AMOUNT', true); // When set to true, opened items will be counted as missing for calculating if a product is below its minimum stock amount diff --git a/controllers/BaseController.php b/controllers/BaseController.php index 0ca463a0..0a773501 100644 --- a/controllers/BaseController.php +++ b/controllers/BaseController.php @@ -11,6 +11,7 @@ use Grocy\Services\ChoresService; use Grocy\Services\DatabaseService; use Grocy\Services\FilesService; use Grocy\Services\LocalizationService; +use Grocy\Services\PrintService; use Grocy\Services\RecipesService; use Grocy\Services\SessionService; use Grocy\Services\StockService; @@ -93,6 +94,12 @@ class BaseController return StockService::getInstance(); } + protected function getPrintService() + { + return PrintService::getInstance(); + } + + protected function getTasksService() { return TasksService::getInstance(); diff --git a/controllers/PrintApiController.php b/controllers/PrintApiController.php new file mode 100644 index 00000000..7664300e --- /dev/null +++ b/controllers/PrintApiController.php @@ -0,0 +1,41 @@ +getQueryParams(); + + $listId = 1; + if (isset($params['list'])) { + $listId = $params['list']; + } + + $printHeader = true; + if (isset($params['printHeader'])) { + $printHeader = ($params['printHeader'] === "true"); + } + $items = $this->getStockService()->GetShoppinglistInPrintableStrings($listId); + return $this->ApiResponse($response, $this->getPrintService()->printShoppingList($printHeader, $items)); + } + catch (\Exception $ex) + { + return $this->GenericErrorResponse($response, $ex->getMessage()); + } + } + + public function __construct(\DI\Container $container) + { + parent::__construct($container); + } +} diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index e25c1144..c76fd24a 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -1,5 +1,4 @@ getUsersService(); diff --git a/grocy.openapi.json b/grocy.openapi.json index a5ce4512..72a36ee9 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -52,6 +52,9 @@ }, { "name": "Files" + }, + { + "name": "Print" } ], "paths": { @@ -4030,7 +4033,64 @@ } } } + }, + "/print/shoppinglist/thermal": { + "get": { + "summary": "Prints the shoppinglist with a thermal printer", + "tags": [ + "Print" + ], + "parameters": [ + { + "in": "query", + "name": "list", + "required": false, + "description": "Shopping list id", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "printHeader", + "required": false, + "description": "Prints grocy logo if true", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Returns OK if the printing was successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "The operation was not successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error400" + } + } + } + } } + } + } }, "components": { "internalSchemas": { diff --git a/helpers/PrerequisiteChecker.php b/helpers/PrerequisiteChecker.php index 7c0b628b..37f6b370 100644 --- a/helpers/PrerequisiteChecker.php +++ b/helpers/PrerequisiteChecker.php @@ -4,7 +4,7 @@ class ERequirementNotMet extends Exception { } -const REQUIRED_PHP_EXTENSIONS = ['fileinfo', 'pdo_sqlite', 'gd', 'ctype']; +const REQUIRED_PHP_EXTENSIONS = ['fileinfo', 'pdo_sqlite', 'gd', 'ctype', 'json', 'intl', 'zlib']; const REQUIRED_SQLITE_VERSION = '3.9.0'; class PrerequisiteChecker diff --git a/localization/strings.pot b/localization/strings.pot index 9847421a..40cb5fa4 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2129,3 +2129,16 @@ msgstr "" msgid "Open stock entry print label in new window" msgstr "" + +msgid "Thermal printer" +msgstr "" + +msgid "Printing" +msgstr "" + +msgid "Connecting to printer..." +msgstr "" + +msgid "Unable to print" +msgstr "" + diff --git a/public/viewjs/shoppinglist.js b/public/viewjs/shoppinglist.js index cbc2d9c2..bc472167 100644 --- a/public/viewjs/shoppinglist.js +++ b/public/viewjs/shoppinglist.js @@ -1,4 +1,4 @@ -var shoppingListTable = $('#shoppinglist-table').DataTable({ +var shoppingListTable = $('#shoppinglist-table').DataTable({ 'order': [[1, 'asc']], "orderFixed": [[3, 'asc']], 'columnDefs': [ @@ -428,56 +428,106 @@ $(document).on("click", "#print-shopping-list-button", function(e) \ '; + var sizePrintDialog = 'medium'; + var printButtons = { + cancel: { + label: __t('Cancel'), + className: 'btn-secondary', + callback: function() + { + bootbox.hideAll(); + } + }, + printtp: { + label: __t('Thermal printer'), + className: 'btn-secondary', + callback: function() + { + bootbox.hideAll(); + var printHeader = $("#print-show-header").prop("checked"); + var thermalPrintDialog = bootbox.dialog({ + title: __t('Printing'), + message: '
' + __t('Connecting to printer...') + '
' + }); + //Delaying for one second so that the alert can be closed + setTimeout(function() + { + Grocy.Api.Get('print/shoppinglist/thermal?list=' + $("#selected-shopping-list").val() + '&printHeader=' + printHeader, + function(result) + { + bootbox.hideAll(); + }, + function(xhr) + { + console.error(xhr); + var validResponse = true; + try + { + var jsonError = JSON.parse(xhr.responseText); + } catch (e) + { + validResponse = false; + } + if (validResponse) + { + thermalPrintDialog.find('.bootbox-body').html(__t('Unable to print') + '' + jsonError.error_message + '
');
+ } else
+ {
+ thermalPrintDialog.find('.bootbox-body').html(__t('Unable to print') + '' + xhr.responseText + '
');
+ }
+ }
+ );
+ }, 1000);
+ }
+ },
+ ok: {
+ label: __t('Print'),
+ className: 'btn-primary responsive-button',
+ callback: function()
+ {
+ bootbox.hideAll();
+ $('.modal-backdrop').remove();
+ $(".print-timestamp").text(moment().format("l LT"));
+
+ $("#description-for-print").html($("#description").val());
+ if ($("#description").text().isEmpty())
+ {
+ $("#description-for-print").parent().addClass("d-print-none");
+ }
+
+ if (!$("#print-show-header").prop("checked"))
+ {
+ $("#print-header").addClass("d-none");
+ }
+
+ if (!$("#print-group-by-product-group").prop("checked"))
+ {
+ shoppingListPrintShadowTable.rowGroup().enable(false);
+ shoppingListPrintShadowTable.order.fixed({});
+ shoppingListPrintShadowTable.draw();
+ }
+
+ $(".print-layout-container").addClass("d-none");
+ $("." + $("input[name='print-layout-type']:checked").val()).removeClass("d-none");
+
+ window.print();
+ }
+ }
+ }
+
+ if (!Grocy.FeatureFlags["GROCY_FEATURE_FLAG_THERMAL_PRINTER"])
+ {
+ delete printButtons['printtp'];
+ sizePrintDialog = 'small';
+ }
+
bootbox.dialog({
message: dialogHtml,
- size: 'small',
+ size: sizePrintDialog,
backdrop: true,
closeButton: false,
className: "d-print-none",
- buttons: {
- cancel: {
- label: __t('Cancel'),
- className: 'btn-secondary',
- callback: function()
- {
- bootbox.hideAll();
- }
- },
- ok: {
- label: __t('Print'),
- className: 'btn-primary responsive-button',
- callback: function()
- {
- bootbox.hideAll();
- $('.modal-backdrop').remove();
-
- $(".print-timestamp").text(moment().format("l LT"));
-
- $("#description-for-print").html($("#description").val());
- if ($("#description").text().isEmpty())
- {
- $("#description-for-print").parent().addClass("d-print-none");
- }
-
- if (!$("#print-show-header").prop("checked"))
- {
- $("#print-header").addClass("d-none");
- }
-
- if (!$("#print-group-by-product-group").prop("checked"))
- {
- shoppingListPrintShadowTable.rowGroup().enable(false);
- shoppingListPrintShadowTable.order.fixed({});
- shoppingListPrintShadowTable.draw();
- }
-
- $(".print-layout-container").addClass("d-none");
- $("." + $("input[name='print-layout-type']:checked").val()).removeClass("d-none");
-
- window.print();
- }
- }
- }
+ buttons: printButtons
});
});
diff --git a/routes.php b/routes.php
index be0b6f98..8268958a 100644
--- a/routes.php
+++ b/routes.php
@@ -232,6 +232,9 @@ $app->group('/api', function (RouteCollectorProxy $group) {
$group->post('/chores/executions/{executionId}/undo', '\Grocy\Controllers\ChoresApiController:UndoChoreExecution');
$group->post('/chores/executions/calculate-next-assignments', '\Grocy\Controllers\ChoresApiController:CalculateNextExecutionAssignments');
+ //Printing
+ $group->get('/print/shoppinglist/thermal', '\Grocy\Controllers\PrintApiController:PrintShoppingListThermal');
+
// Batteries
$group->get('/batteries', '\Grocy\Controllers\BatteriesApiController:Current');
$group->get('/batteries/{batteryId}', '\Grocy\Controllers\BatteriesApiController:BatteryDetails');
diff --git a/services/BaseService.php b/services/BaseService.php
index 86a4a5d4..ef04ec81 100644
--- a/services/BaseService.php
+++ b/services/BaseService.php
@@ -66,4 +66,10 @@ class BaseService
{
return UsersService::getInstance();
}
+
+ protected function getPrintService()
+ {
+ return PrintService::getInstance();
+ }
+
}
diff --git a/services/PrintService.php b/services/PrintService.php
new file mode 100644
index 00000000..b3b1a826
--- /dev/null
+++ b/services/PrintService.php
@@ -0,0 +1,84 @@
+format('d/m/Y H:i');
+
+ $printer->setJustification(Printer::JUSTIFY_CENTER);
+ $printer->selectPrintMode(Printer::MODE_DOUBLE_WIDTH);
+ $printer->setTextSize(4, 4);
+ $printer->setReverseColors(true);
+ $printer->text("grocy");
+ $printer->setJustification();
+ $printer->setTextSize(1, 1);
+ $printer->setReverseColors(false);
+ $printer->feed(2);
+ $printer->text($dateFormatted);
+ $printer->selectPrintMode();
+ $printer->feed(2);
+ }
+
+ /**
+ * @param bool $printHeader Printing of Grocy logo
+ * @param string[] $lines Items to print
+ * @return string[] Returns array with result OK if no exception
+ * @throws Exception If unable to print, an exception is thrown
+ */
+ public function printShoppingList(bool $printHeader, array $lines): array
+ {
+ $printer = self::getPrinterHandle();
+ if ($printer === false)
+ throw new Exception("Unable to connect to printer");
+
+ if ($printHeader)
+ {
+ self::printHeader($printer);
+ }
+
+ foreach ($lines as $line)
+ {
+ $printer->text($line);
+ $printer->feed();
+ }
+
+ $printer->feed(3);
+ $printer->cut();
+ $printer->close();
+ return [
+ 'result' => "OK"
+ ];
+ }
+}
diff --git a/services/StockService.php b/services/StockService.php
index 2bb41f6f..423b27bf 100644
--- a/services/StockService.php
+++ b/services/StockService.php
@@ -970,6 +970,88 @@ class StockService extends BaseService
}
}
+ /**
+ * Returns the shoppinglist as an array with lines for a printer
+ * @param int $listId ID of shopping list
+ * @return string[] Returns an array in the format "[amount] [name of product]"
+ * @throws \Exception
+ */
+ public function GetShoppinglistInPrintableStrings($listId = 1): array
+ {
+ if (!$this->ShoppingListExists($listId))
+ {
+ throw new \Exception('Shopping list does not exist');
+ }
+
+ $result_product = array();
+ $result_quantity = array();
+ $rowsShoppingListProducts = $this->getDatabase()->uihelper_shopping_list()->where('shopping_list_id = :1', $listId)->fetchAll();
+ foreach ($rowsShoppingListProducts as $row)
+ {
+ $isValidProduct = ($row->product_id != null && $row->product_id != "");
+ if ($isValidProduct)
+ {
+ $product = $this->getDatabase()->products()->where('id = :1', $row->product_id)->fetch();
+ $conversion = $this->getDatabase()->quantity_unit_conversions_resolved()->where('product_id = :1 AND from_qu_id = :2 AND to_qu_id = :3', $product->id, $product->qu_id_stock, $row->qu_id)->fetch();
+ $factor = 1.0;
+ if ($conversion != null)
+ {
+ $factor = floatval($conversion->factor);
+ }
+ $amount = round($row->amount * $factor);
+ $note = "";
+ if (GROCY_TPRINTER_PRINT_NOTES)
+ {
+ if ($row->note != "") {
+ $note = ' (' . $row->note . ')';
+ }
+ }
+ }
+ if (GROCY_TPRINTER_PRINT_QUANTITY_NAME && $isValidProduct)
+ {
+ $quantityname = $row->qu_name;
+ if ($amount > 1)
+ {
+ $quantityname = $row->qu_name_plural;
+ }
+ array_push($result_quantity, $amount . ' ' . $quantityname);
+ array_push($result_product, $row->product_name . $note);
+ }
+ else
+ {
+ if ($isValidProduct)
+ {
+ array_push($result_quantity, $amount);
+ array_push($result_product, $row->product_name . $note);
+ }
+ else
+ {
+ array_push($result_quantity, round($row->amount));
+ array_push($result_product, $row->note);
+ }
+
+ }
+ }
+ //Add padding to look nicer
+ $maxlength = 1;
+ foreach ($result_quantity as $quantity)
+ {
+ if (strlen($quantity) > $maxlength)
+ {
+ $maxlength = strlen($quantity);
+ }
+ }
+ $result = array();
+ $length = count($result_quantity);
+ for ($i = 0; $i < $length; $i++)
+ {
+ $quantity = str_pad($result_quantity[$i], $maxlength);
+ array_push($result, $quantity . ' ' . $result_product[$i]);
+ }
+ return $result;
+ }
+
+
public function TransferProduct(int $productId, float $amount, int $locationIdFrom, int $locationIdTo, $specificStockEntryId = 'default', &$transactionId = null)
{
if (!$this->ProductExists($productId))