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
- (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
- Product barcodes are now enforced to be unique across products
- Fixed that editing stock entries was not possible

View File

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

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",
"This file is @generated automatically"
],
"content-hash": "b8b8e77618038c44c21edac2c31c4b67",
"content-hash": "9c4580f416241994ddaffb569998b7f8",
"packages": [
{
"name": "doctrine/inflector",
@ -1113,6 +1113,112 @@
},
"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",
"version": "v0.4.1",
@ -2865,5 +2971,5 @@
"php": ">=7.4"
},
"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
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

View File

@ -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();

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
namespace Grocy\Controllers;
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)
{
$usersService = $this->getUsersService();

View File

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

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';
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.
import bwipjs from '../../node_modules/bwip-js/dist/bwip-js.mjs';
import { WindowMessageBag } from '../helpers/messagebag';
@ -433,56 +433,106 @@ $(document).on("click", "#print-shopping-list-button", function(e)
</label> \
</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({
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
});
});

View File

@ -2138,3 +2138,15 @@ msgstr ""
msgid "Day"
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/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');

View File

@ -66,4 +66,10 @@ class BaseService
{
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)
{
if (!$this->ProductExists($productId))