Add support for printing shoppinglist with thermal printer (#1273)

* Added escpos-php library

* Added button to shoppinglist print menu

* Added to translation

* Added basic printing logic and API call

* Working implementation for printing with the API

* Added openapi json

* Correctly parsing boolean parameter

* Working button in UI

* Change to grocy formatting

* Add Date

* Only show thermal print button when Feature Flag is set

* Fixed API call and added error message parsing

* Undo translation

* Add flag to print quantities as well

* Added printing notes

* Added quantity conversion

* Increse feed

* Fixed that checkbox was undefined, as dialog was already closed

* Added padding

* Formatting

* Added note about user permission

* Fixed error when using notes instead of products

* Review

- Default FEATURE_FLAG_THERMAL_PRINTER to disabled
- Added missing localization strings (and slightly adjusted one)

* Fixed merge conflicts

Co-authored-by: Bernd Bestel <bernd@berrnd.de>
This commit is contained in:
Marc Ole Bulling 2021-06-18 20:45:42 +02:00 committed by GitHub
parent fe59fac1c3
commit eb135aee39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 413 additions and 49 deletions

View File

@ -13,7 +13,8 @@
"gumlet/php-image-resize": "^1.9", "gumlet/php-image-resize": "^1.9",
"ezyang/htmlpurifier": "^4.13", "ezyang/htmlpurifier": "^4.13",
"jucksearm/php-barcode": "^1.0", "jucksearm/php-barcode": "^1.0",
"guzzlehttp/guzzle": "^7.0" "guzzlehttp/guzzle": "^7.0",
"mike42/escpos-php": "^3.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

View File

@ -95,6 +95,22 @@ Setting('MEAL_PLAN_FIRST_DAY_OF_WEEK', '');
// see the file controllers/Users/User.php for possible values // see the file controllers/Users/User.php for possible values
Setting('DEFAULT_PERMISSIONS', ['ADMIN']); 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 // Default user settings
// These settings can be changed per user, here the defaults // 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 // 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_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_SHOPPINGLIST_MULTIPLE_LISTS', true);
Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true); Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true);
Setting('FEATURE_FLAG_THERMAL_PRINTER', false);
// Feature settings // 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 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

View File

@ -11,6 +11,7 @@ use Grocy\Services\ChoresService;
use Grocy\Services\DatabaseService; use Grocy\Services\DatabaseService;
use Grocy\Services\FilesService; use Grocy\Services\FilesService;
use Grocy\Services\LocalizationService; use Grocy\Services\LocalizationService;
use Grocy\Services\PrintService;
use Grocy\Services\RecipesService; use Grocy\Services\RecipesService;
use Grocy\Services\SessionService; use Grocy\Services\SessionService;
use Grocy\Services\StockService; use Grocy\Services\StockService;
@ -93,6 +94,12 @@ class BaseController
return StockService::getInstance(); return StockService::getInstance();
} }
protected function getPrintService()
{
return PrintService::getInstance();
}
protected function getTasksService() protected function getTasksService()
{ {
return TasksService::getInstance(); return TasksService::getInstance();

View File

@ -0,0 +1,41 @@
<?php
namespace Grocy\Controllers;
use Grocy\Controllers\Users\User;
use Grocy\Services\StockService;
class PrintApiController extends BaseApiController
{
public function PrintShoppingListThermal(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) {
try
{
User::checkPermission($request, User::PERMISSION_SHOPPINGLIST);
$params = $request->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);
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace Grocy\Controllers; namespace Grocy\Controllers;
use Grocy\Controllers\Users\User; use Grocy\Controllers\Users\User;

View File

@ -512,6 +512,7 @@ class StockController extends BaseController
]); ]);
} }
public function Stockentries(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) public function Stockentries(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{ {
$usersService = $this->getUsersService(); $usersService = $this->getUsersService();

View File

@ -52,6 +52,9 @@
}, },
{ {
"name": "Files" "name": "Files"
},
{
"name": "Print"
} }
], ],
"paths": { "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": { "components": {
"internalSchemas": { "internalSchemas": {

View File

@ -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'; const REQUIRED_SQLITE_VERSION = '3.9.0';
class PrerequisiteChecker class PrerequisiteChecker

View File

@ -2129,3 +2129,16 @@ msgstr ""
msgid "Open stock entry print label in new window" msgid "Open stock entry print label in new window"
msgstr "" msgstr ""
msgid "Thermal printer"
msgstr ""
msgid "Printing"
msgstr ""
msgid "Connecting to printer..."
msgstr ""
msgid "Unable to print"
msgstr ""

View File

@ -1,4 +1,4 @@
var shoppingListTable = $('#shoppinglist-table').DataTable({ var shoppingListTable = $('#shoppinglist-table').DataTable({
'order': [[1, 'asc']], 'order': [[1, 'asc']],
"orderFixed": [[3, 'asc']], "orderFixed": [[3, 'asc']],
'columnDefs': [ 'columnDefs': [
@ -428,56 +428,106 @@ $(document).on("click", "#print-shopping-list-button", function(e)
</label> \ </label> \
</div>'; </div>';
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: '<p><i class="fa fa-spin fa-spinner"></i> ' + __t('Connecting to printer...') + '</p>'
});
//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') + '<br><pre><code>' + jsonError.error_message + '</pre></code>');
} else
{
thermalPrintDialog.find('.bootbox-body').html(__t('Unable to print') + '<br><pre><code>' + xhr.responseText + '</pre></code>');
}
}
);
}, 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({ bootbox.dialog({
message: dialogHtml, message: dialogHtml,
size: 'small', size: sizePrintDialog,
backdrop: true, backdrop: true,
closeButton: false, closeButton: false,
className: "d-print-none", className: "d-print-none",
buttons: { buttons: printButtons
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();
}
}
}
}); });
}); });

View File

@ -232,6 +232,9 @@ $app->group('/api', function (RouteCollectorProxy $group) {
$group->post('/chores/executions/{executionId}/undo', '\Grocy\Controllers\ChoresApiController:UndoChoreExecution'); $group->post('/chores/executions/{executionId}/undo', '\Grocy\Controllers\ChoresApiController:UndoChoreExecution');
$group->post('/chores/executions/calculate-next-assignments', '\Grocy\Controllers\ChoresApiController:CalculateNextExecutionAssignments'); $group->post('/chores/executions/calculate-next-assignments', '\Grocy\Controllers\ChoresApiController:CalculateNextExecutionAssignments');
//Printing
$group->get('/print/shoppinglist/thermal', '\Grocy\Controllers\PrintApiController:PrintShoppingListThermal');
// Batteries // Batteries
$group->get('/batteries', '\Grocy\Controllers\BatteriesApiController:Current'); $group->get('/batteries', '\Grocy\Controllers\BatteriesApiController:Current');
$group->get('/batteries/{batteryId}', '\Grocy\Controllers\BatteriesApiController:BatteryDetails'); $group->get('/batteries/{batteryId}', '\Grocy\Controllers\BatteriesApiController:BatteryDetails');

View File

@ -66,4 +66,10 @@ class BaseService
{ {
return UsersService::getInstance(); return UsersService::getInstance();
} }
protected function getPrintService()
{
return PrintService::getInstance();
}
} }

84
services/PrintService.php Normal file
View File

@ -0,0 +1,84 @@
<?php
namespace Grocy\Services;
use DateTime;
use Exception;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\PrintConnectors\FilePrintConnector;
use Mike42\Escpos\Printer;
class PrintService extends BaseService
{
/**
* Initialises the printer
* @return Printer Printer handle
* @throws Exception If unable to connect to printer, an exception is thrown
*/
private static function getPrinterHandle()
{
if (GROCY_TPRINTER_IS_NETWORK_PRINTER) {
$connector = new NetworkPrintConnector(GROCY_TPRINTER_IP, GROCY_TPRINTER_PORT);
} else {
$connector = new FilePrintConnector(GROCY_TPRINTER_CONNECTOR);
}
return new Printer($connector);
}
/**
* Prints the grocy logo and date
* @param Printer $printer Printer handle
*/
private static function printHeader(Printer $printer)
{
$date = new DateTime();
$dateFormatted = $date->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"
];
}
}

View File

@ -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) public function TransferProduct(int $productId, float $amount, int $locationIdFrom, int $locationIdTo, $specificStockEntryId = 'default', &$transactionId = null)
{ {
if (!$this->ProductExists($productId)) if (!$this->ProductExists($productId))