Merge remote-tracking branch 'upstream/master' into modernjs

This commit is contained in:
Katharina Bogad 2021-06-18 21:15:43 +02:00
commit 93b2c00600
16 changed files with 528 additions and 51 deletions

View File

@ -13,6 +13,14 @@
- https://github.com/grocy/grocy/blob/master/docs/label-printing.md - https://github.com/grocy/grocy/blob/master/docs/label-printing.md
- (Thanks a lot @mistressofjellyfish) - (Thanks a lot @mistressofjellyfish)
### New feature: Shopping list thermal printer support
- The shopping list can now be printed on a thermal printer
- The printer must compatible to the `ESC/POS` protocol and needs to be locally attached or network reachable to/by the machine hosting grocy (so the server)
- See the new `TPRINTER*` `config.php` options to configure the printer connection and other options
- => New button on the shopping list print dialog
- Can be enabled via the new feature flag `FEATURE_FLAG_THERMAL_PRINTER` (defaults to disabled)
- (Thanks a lot @Forceu)
### Stock improvements/fixes ### Stock improvements/fixes
- Product barcodes are now enforced to be unique across products - Product barcodes are now enforced to be unique across products
- Fixed that editing stock entries was not possible - Fixed that editing stock entries was not possible

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": {

110
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "b8b8e77618038c44c21edac2c31c4b67", "content-hash": "9c4580f416241994ddaffb569998b7f8",
"packages": [ "packages": [
{ {
"name": "doctrine/inflector", "name": "doctrine/inflector",
@ -1113,6 +1113,112 @@
}, },
"time": "2017-06-05T04:41:51+00:00" "time": "2017-06-05T04:41:51+00:00"
}, },
{
"name": "mike42/escpos-php",
"version": "v3.0",
"source": {
"type": "git",
"url": "https://github.com/mike42/escpos-php.git",
"reference": "dcb569a123d75f9f6a4a927aae7625ca6b7fdcf3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mike42/escpos-php/zipball/dcb569a123d75f9f6a4a927aae7625ca6b7fdcf3",
"reference": "dcb569a123d75f9f6a4a927aae7625ca6b7fdcf3",
"shasum": ""
},
"require": {
"ext-intl": "*",
"ext-json": "*",
"ext-zlib": "*",
"mike42/gfx-php": "^0.6",
"php": ">=7.0.0"
},
"require-dev": {
"phpunit/phpunit": "^6.5",
"squizlabs/php_codesniffer": "^3.3"
},
"suggest": {
"ext-gd": "Used for image printing if present.",
"ext-imagick": "Will be used for image printing if present. Required for PDF printing or use of custom fonts."
},
"type": "library",
"autoload": {
"psr-4": {
"Mike42\\": "src/Mike42"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Billington",
"email": "michael.billington@gmail.com"
}
],
"description": "PHP receipt printer library for use with ESC/POS-compatible thermal and impact printers",
"homepage": "https://github.com/mike42/escpos-php",
"keywords": [
"Epson",
"barcode",
"escpos",
"printer",
"receipt-printer"
],
"support": {
"issues": "https://github.com/mike42/escpos-php/issues",
"source": "https://github.com/mike42/escpos-php/tree/v3.0"
},
"time": "2019-10-13T06:27:43+00:00"
},
{
"name": "mike42/gfx-php",
"version": "v0.6",
"source": {
"type": "git",
"url": "https://github.com/mike42/gfx-php.git",
"reference": "ed9ded2a9298e4084a9c557ab74a89b71e43dbdb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mike42/gfx-php/zipball/ed9ded2a9298e4084a9c557ab74a89b71e43dbdb",
"reference": "ed9ded2a9298e4084a9c557ab74a89b71e43dbdb",
"shasum": ""
},
"require": {
"php": ">=7.0.0"
},
"require-dev": {
"phpbench/phpbench": "@dev",
"phpunit/phpunit": "^6.5",
"squizlabs/php_codesniffer": "^3.3.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Mike42\\": "src/Mike42"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Michael Billington",
"email": "michael.billington@gmail.com"
}
],
"description": "The pure PHP graphics library",
"homepage": "https://github.com/mike42/gfx-php",
"support": {
"issues": "https://github.com/mike42/gfx-php/issues",
"source": "https://github.com/mike42/gfx-php/tree/v0.6"
},
"time": "2019-10-05T02:44:33+00:00"
},
{ {
"name": "morris/lessql", "name": "morris/lessql",
"version": "v0.4.1", "version": "v0.4.1",
@ -2865,5 +2971,5 @@
"php": ">=7.4" "php": ">=7.4"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.1.0" "plugin-api-version": "2.0.0"
} }

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

@ -1,4 +1,4 @@
// this needs to be explicitly imported for some reason, // this needs to be explicitly imported for some reason,
// otherwise rollup complains. // otherwise rollup complains.
import bwipjs from '../../node_modules/bwip-js/dist/bwip-js.mjs'; import bwipjs from '../../node_modules/bwip-js/dist/bwip-js.mjs';
import { WindowMessageBag } from '../helpers/messagebag'; import { WindowMessageBag } from '../helpers/messagebag';
@ -433,56 +433,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

@ -2138,3 +2138,15 @@ msgstr ""
msgid "Day" msgid "Day"
msgstr "" msgstr ""
msgid "Thermal printer"
msgstr ""
msgid "Printing"
msgstr ""
msgid "Connecting to printer..."
msgstr ""
msgid "Unable to print"
msgstr ""

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))