Grocycode, label printing (#1500)

* Grocycode: Productpicker, StockService

* Grocycode: Datamatrix generation

* Grocycode: Display in UI, make Images downloadable

* Grocycode: Do not show on product card

* Grocycode: Stockentry Label view

* Grocycode: Webhooks & Labelprinter Feature

* Grocycode: Manual Label printing

* Grocycode: Print Label from product form

* Quagga2: use zxing for DataMatrix recognition

* Grocycode: Default settings for label printing

* Prepare merge of master

* Grocycode: docs

* Docs: label printing webhook

* Review

- "grocy" is currently written lower-case everywhere, so let's do this also for "grocycode"
- Unified phrases / capitalization
- Minor UI adjustments (mainly context menu item ordering / ordering/spacing on product edit page)
- Documented API changes for Swagger UI (grocy.openapi.json)
- Reverted German localizations (those are managed via Transifex; would cause conflicts when manually edited - will import them later there)
- Reverted a somehow messed up localization string (productform/help text for `cumulate_min_stock_amount_of_sub_products`)
- Suppress deprecation warnings when generating Datamatrix PNG (otherwise the PNG is invalid, https://github.com/jucksearm/php-barcode/issues/3)
- Default `FEATURE_FLAG_LABELPRINTER` to disabled

Co-authored-by: Bernd Bestel <bernd@berrnd.de>
This commit is contained in:
Katharina Bogad 2021-06-12 17:21:12 +02:00 committed by GitHub
parent d23fda245e
commit 2471e78188
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 27100 additions and 25 deletions

View File

@ -14,4 +14,4 @@
"php-cs-fixer.formatHtml": true, "php-cs-fixer.formatHtml": true,
"php-cs-fixer.autoFixBySemicolon": true, "php-cs-fixer.autoFixBySemicolon": true,
"php-cs-fixer.onsave": true, "php-cs-fixer.onsave": true,
} }

View File

@ -11,7 +11,9 @@
"eluceo/ical": "^0.16.0", "eluceo/ical": "^0.16.0",
"erusev/parsedown": "^1.7", "erusev/parsedown": "^1.7",
"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",
"guzzlehttp/guzzle": "^7.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

341
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": "6155c0eb959bd8118ce2aabce7fd8a6a", "content-hash": "b8b8e77618038c44c21edac2c31c4b67",
"packages": [ "packages": [
{ {
"name": "doctrine/inflector", "name": "doctrine/inflector",
@ -508,6 +508,239 @@
}, },
"time": "2019-01-01T13:53:00+00:00" "time": "2019-01-01T13:53:00+00:00"
}, },
{
"name": "guzzlehttp/guzzle",
"version": "7.3.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "7008573787b430c1c1f650e3722d9bba59967628"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/7008573787b430c1c1f650e3722d9bba59967628",
"reference": "7008573787b430c1c1f650e3722d9bba59967628",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.4",
"guzzlehttp/psr7": "^1.7 || ^2.0",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.4.1",
"ext-curl": "*",
"php-http/client-integration-tests": "^3.0",
"phpunit/phpunit": "^8.5.5 || ^9.3.5",
"psr/log": "^1.1"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.3-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "Guzzle is a PHP HTTP client library",
"homepage": "http://guzzlephp.org/",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.3.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://github.com/alexeyshockov",
"type": "github"
},
{
"url": "https://github.com/gmponos",
"type": "github"
}
],
"time": "2021-03-23T11:33:13+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "1.4.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d",
"reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"symfony/phpunit-bridge": "^4.4 || ^5.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.4-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/1.4.1"
},
"time": "2021-03-07T09:25:29+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "dc960a912984efb74d0a90222870c72c87f10c91"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91",
"reference": "dc960a912984efb74d0a90222870c72c87f10c91",
"shasum": ""
},
"require": {
"php": ">=5.4.0",
"psr/http-message": "~1.0",
"ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
},
"provide": {
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"ext-zlib": "*",
"phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.7-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Schultze",
"homepage": "https://github.com/Tobion"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/1.8.2"
},
"time": "2021-04-26T09:17:50+00:00"
},
{ {
"name": "illuminate/container", "name": "illuminate/container",
"version": "v5.8.36", "version": "v5.8.36",
@ -828,6 +1061,58 @@
}, },
"time": "2019-06-20T13:13:59+00:00" "time": "2019-06-20T13:13:59+00:00"
}, },
{
"name": "jucksearm/php-barcode",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/jucksearm/php-barcode.git",
"reference": "066a58776ec9e94dd6d5843c0fb9a3ff95e74d8b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jucksearm/php-barcode/zipball/066a58776ec9e94dd6d5843c0fb9a3ff95e74d8b",
"reference": "066a58776ec9e94dd6d5843c0fb9a3ff95e74d8b",
"shasum": ""
},
"require": {
"php": ">=5.4.0"
},
"require-dev": {
"phpunit/phpunit": "^6.1"
},
"type": "library",
"autoload": {
"psr-4": {
"jucksearm\\barcode\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPLv3"
],
"authors": [
{
"name": "jucksearm",
"email": "jucksearm.bkk@gmail.com"
}
],
"description": "Barcode Generation Package inspired by Nicola Asuni.",
"keywords": [
"CODE 128",
"barcode",
"datamatrix",
"pdf417",
"php barcode",
"qr code",
"qrcode"
],
"support": {
"issues": "https://github.com/jucksearm/php-barcode/issues",
"source": "https://github.com/jucksearm/php-barcode/tree/1.0.0"
},
"time": "2017-06-05T04:41:51+00:00"
},
{ {
"name": "morris/lessql", "name": "morris/lessql",
"version": "v0.4.1", "version": "v0.4.1",
@ -1360,6 +1645,58 @@
}, },
"time": "2017-02-14T16:28:37+00:00" "time": "2017-02-14T16:28:37+00:00"
}, },
{
"name": "psr/http-client",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
"reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client/tree/master"
},
"time": "2020-06-29T06:28:15+00:00"
},
{ {
"name": "psr/http-factory", "name": "psr/http-factory",
"version": "1.0.1", "version": "1.0.1",
@ -2528,5 +2865,5 @@
"php": ">=7.4" "php": ">=7.4"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.0.0" "plugin-api-version": "2.1.0"
} }

View File

@ -159,6 +159,21 @@ DefaultUserSetting('quagga2_patchsize', 'medium');
DefaultUserSetting('quagga2_frequency', 10); DefaultUserSetting('quagga2_frequency', 10);
DefaultUserSetting('quagga2_debug', true); DefaultUserSetting('quagga2_debug', true);
// Label Printer Settings
// This is the URI that grocy will POST to when asked to print a label.
Setting('LABEL_PRINTER_WEBHOOK', '');
// This setting decides whether the webhook will be called server- or clientside.
// If the machine grocy runs on has a network connection to the host
// the webhook receiver is on, this is probably a good idea.
// If, for example, grocy runs in the cloud and your printer daemon
// runs locally to you, set this to false to let your browser call
// the webhook instead.
Setting('LABEL_PRINTER_RUN_SERVER', true);
// Additional Parameters supplied to the webhook.
Setting('LABEL_PRINTER_PARAMS', ['font_family' => 'Source Sans Pro (Regular)']);
// Use JSON or normal POST request variables?
Setting('LABEL_PRINTER_HOOK_JSON', false);
// Feature flags // Feature flags
// grocy was initially about "stock management for your household", many other things // grocy was initially about "stock management for your household", many other things
// came and still come by, because they are useful - here you can disable the parts // came and still come by, because they are useful - here you can disable the parts
@ -172,6 +187,7 @@ Setting('FEATURE_FLAG_TASKS', true);
Setting('FEATURE_FLAG_BATTERIES', true); Setting('FEATURE_FLAG_BATTERIES', true);
Setting('FEATURE_FLAG_EQUIPMENT', true); Setting('FEATURE_FLAG_EQUIPMENT', true);
Setting('FEATURE_FLAG_CALENDAR', true); Setting('FEATURE_FLAG_CALENDAR', true);
Setting('FEATURE_FLAG_LABELPRINTER', false);
// Sub feature flags // Sub feature flags
Setting('FEATURE_FLAG_STOCK_PRICE_TRACKING', true); Setting('FEATURE_FLAG_STOCK_PRICE_TRACKING', true);

View File

@ -4,6 +4,8 @@ namespace Grocy\Controllers;
use Grocy\Controllers\Users\User; use Grocy\Controllers\Users\User;
use Grocy\Services\StockService; use Grocy\Services\StockService;
use Grocy\Helpers\WebhookRunner;
use Grocy\Helpers\Grocycode;
class StockApiController extends BaseApiController class StockApiController extends BaseApiController
{ {
@ -138,8 +140,14 @@ class StockApiController extends BaseApiController
{ {
$transactionType = $requestBody['transactiontype']; $transactionType = $requestBody['transactiontype'];
} }
$runPrinterWebhook = false;
if (array_key_exists('print_stock_label', $requestBody) && intval($requestBody['print_stock_label']))
{
$runPrinterWebhook = intval($requestBody['print_stock_label']);
}
$transactionId = $this->getStockService()->AddProduct($args['productId'], $requestBody['amount'], $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId, $shoppingLocationId, $unusedTransactionId, $runPrinterWebhook);
$transactionId = $this->getStockService()->AddProduct($args['productId'], $requestBody['amount'], $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId, $shoppingLocationId);
$args['transactionId'] = $transactionId; $args['transactionId'] = $transactionId;
return $this->StockTransactions($request, $response, $args); return $this->StockTransactions($request, $response, $args);
} }
@ -604,6 +612,46 @@ class StockApiController extends BaseApiController
return $this->FilteredApiResponse($response, $this->getStockService()->GetProductStockLocations($args['productId'], $allowSubproductSubstitution), $request->getQueryParams()); return $this->FilteredApiResponse($response, $this->getStockService()->GetProductStockLocations($args['productId'], $allowSubproductSubstitution), $request->getQueryParams());
} }
public function ProductPrintLabel(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
$product = $this->getDatabase()->products()->where('id', $args['productId'])->fetch();
$webhookData = array_merge([
'product' => $product->name,
'grocycode' => (string)(new Grocycode(Grocycode::PRODUCT, $product->id)),
], GROCY_LABEL_PRINTER_PARAMS);
if (GROCY_LABEL_PRINTER_RUN_SERVER)
{
(new WebhookRunner())->run(GROCY_LABEL_PRINTER_WEBHOOK, $webhookData, GROCY_LABEL_PRINTER_HOOK_JSON);
}
return $this->EmptyApiResponse($response);
}
public function StockEntryPrintLabel(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
$stockEntry = $this->getDatabase()->stock()->where('id', $args['entryId'])->fetch();
$product = $this->getDatabase()->products()->where('id', $stockEntry->product_id)->fetch();
$webhookData = array_merge([
'product' => $product->name,
'grocycode' => (string)(new Grocycode(Grocycode::PRODUCT, $stockEntry->product_id, [$stockEntry->stock_id])),
], GROCY_LABEL_PRINTER_PARAMS);
if (GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING)
{
$webhookData['duedate'] = $this->getLocalizationService()->__t('DD') . ': ' . $stockEntry->best_before_date;
}
if (GROCY_LABEL_PRINTER_RUN_SERVER)
{
(new WebhookRunner())->run(GROCY_LABEL_PRINTER_WEBHOOK, $webhookData, GROCY_LABEL_PRINTER_HOOK_JSON);
}
return $this->EmptyApiResponse($response);
}
public function RemoveProductFromShoppingList(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) public function RemoveProductFromShoppingList(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{ {
User::checkPermission($request, User::PERMISSION_SHOPPINGLIST_ITEMS_DELETE); User::checkPermission($request, User::PERMISSION_SHOPPINGLIST_ITEMS_DELETE);

View File

@ -2,7 +2,9 @@
namespace Grocy\Controllers; namespace Grocy\Controllers;
use Grocy\Helpers\Grocycode;
use Grocy\Services\RecipesService; use Grocy\Services\RecipesService;
use jucksearm\barcode\lib\DatamatrixFactory;
class StockController extends BaseController class StockController extends BaseController
{ {
@ -39,7 +41,7 @@ class StockController extends BaseController
'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'), 'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'), 'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'),
'users' => $usersService->GetUsersAsDto(), 'users' => $usersService->GetUsersAsDto(),
'transactionTypes' => GetClassConstants('\Grocy\Services\StockService', 'TRANSACTION_TYPE_'), 'transactionTypes' => GetClassConstants('\Grocy\Services\StockService', 'TRANSACTION_TYPE_')
]); ]);
} }
@ -170,6 +172,38 @@ class StockController extends BaseController
} }
} }
public function ProductGrocycodeImage(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
$size = $request->getQueryParam('size', null);
$product = $this->getDatabase()->products($args['productId']);
$gc = new Grocycode(Grocycode::PRODUCT, $product->id);
// Explicitly suppress errors, otherwise deprecations warnings would cause invalid PNG data
// See also https://github.com/jucksearm/php-barcode/issues/3
$png = @(new DatamatrixFactory())->setCode((string) $gc)->setSize($size)->getDatamatrixPngData();
$isDownload = $request->getQueryParam('download', false);
if ($isDownload)
{
$response = $response->withHeader('Content-Type', 'application/octet-stream')
->withHeader('Content-Disposition', 'attachment; filename=grocycode.png')
->withHeader('Content-Length', strlen($png))
->withHeader('Cache-Control', 'no-cache')
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');
}
else
{
$response = $response->withHeader('Content-Type', 'image/png')
->withHeader('Content-Length', strlen($png))
->withHeader('Cache-Control', 'no-cache')
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');
}
$response->getBody()->write($png);
return $response;
}
public function ProductGroupEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) public function ProductGroupEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{ {
if ($args['productGroupId'] == 'new') if ($args['productGroupId'] == 'new')
@ -428,6 +462,47 @@ class StockController extends BaseController
]); ]);
} }
public function StockEntryGrocycodeImage(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
$size = $request->getQueryParam('size', null);
$stockEntry = $this->getDatabase()->stock()->where('id', $args['entryId'])->fetch();
$gc = new Grocycode(Grocycode::PRODUCT, $stockEntry->product_id, [$stockEntry->stock_id]);
// Explicitly suppress errors, otherwise deprecations warnings would cause invalid PNG data
// See also https://github.com/jucksearm/php-barcode/issues/3
$png = @(new DatamatrixFactory())->setCode((string) $gc)->setSize($size)->getDatamatrixPngData();
$isDownload = $request->getQueryParam('download', false);
if ($isDownload)
{
$response = $response->withHeader('Content-Type', 'application/octet-stream')
->withHeader('Content-Disposition', 'attachment; filename=grocycode.png')
->withHeader('Content-Length', strlen($png))
->withHeader('Cache-Control', 'no-cache')
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');
}
else
{
$response = $response->withHeader('Content-Type', 'image/png')
->withHeader('Content-Length', strlen($png))
->withHeader('Cache-Control', 'no-cache')
->withHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');
$response->getBody()->write($png);
}
return $response;
}
public function StockEntryGrocycodeLabel(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
$stockEntry = $this->getDatabase()->stock()->where('id', $args['entryId'])->fetch();
return $this->renderPage($response, 'stockentrylabel', [
'stockEntry' => $stockEntry,
'product' => $this->getDatabase()->products($stockEntry->product_id),
]);
}
public function StockSettings(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args) public function StockSettings(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{ {
return $this->renderPage($response, 'stocksettings', [ return $this->renderPage($response, 'stocksettings', [

66
docs/grocycode.md Normal file
View File

@ -0,0 +1,66 @@
grocycode
==========
grocycode is, in essence, a simple way to reference to arbitrary grocy entities.
Each grocycode includes a magic, an entitiy identifier, an id and an ordered set of extra data.
It is supported to be entered anywhere grocy expects one to read a barcode, but can also reference
grocy-internal properties like specific stock entries, or specific batteries.
Serialization
----
There are three mandatory parts in a grocycode:
1. The magic `grcy`
2. An entity identifer matching the regular expression `[a-z]+` (that is, lowercase english alphabet without any fancy accents, minimum length 1 character).
3. An object identifer matching the regular expression `[0-9]+`
Optionally, any number of further data without format restrictions besides not containing any double colons [0] may be appended.
These parts are then linearly appended, seperated by a double colon `:`.
Entity Identifers
----
Currently, there are three different entity types defined:
- `p` for Products
- `b` for Batteries
- `c` for Chores
Example
----
In this example, we encode a *Product* with ID *13*, which results in `grcy:p:13` when serialized.
Product grocycodes
----
Product grocycodes extend the data format to include an optional stock id, thus may reference a specific stock entry directly.
Example: `grcy:p:13:60bf8b5244b04`
Battery grocycodes
----
Currently, Battery grocycodes do not define any extra fields.
Chore grocycodes
----
Currently, Chore grocycodes do not define any extra fields.
Visual Encoding
----
Grocy uses DataMatrix 2D Barcodes to encode grocycodes into a visual representation. In principle, there is no problem with using
other encoding formats like QR codes; however DataMatrix uses less space for the same information and redundancy and is a bit
easier read by 2D barcode scanners, especially on non-flat surfaces.
You can pick up cheap-ish used scanners from ebay (about 45€ in germany). Make sure to set them to the correct keyboard emulation,
so that the double colons get entered correctly.
Notes
---
[0]: Obviously, it needs to be encoded into some usable visual representation and then read. So probably you only want to encode stuff that can be typed on a keyboard.

40
docs/label-printing.md Normal file
View File

@ -0,0 +1,40 @@
Label printing
====
To enable label printing, set `FEATURE_FLAG_LABELPRINTER` to `true`in your `config.php`. You also need to provide a webhook target that is responsible for printing.
Why webhook?
---
Label printers come in all shapes and forms, and your particular one is probably not the one used by the author of this feature. Also, grocy may does not have a
direct connection to a local label printer (e.g. grocy is hosted in a cloud vps). Thus, a lightweight implementation is provided by grocy: whenever something
should print, a POST request to a configured URL is made. The target then is responsible for label printing.
Reference implementation
---
The webhook was developed and tested against a Brother QL-600 label printer, using endless 62mm label paper. The webhook provider implementation was
implemented into [a fork of brother_ql_web](https://github.com/mistressofjellyfish/brother_ql_web).
Webhook request
---
Requests can be configured to be sent server-side (that is, from the machine hosting grocy through GuzzleHttp) or by an AJAX request directly from the browser.
The latter is neccesary for situations where the grocy hosting machine cannot reach your label printer, however server-side requests are a bit faster and
tend to be more stable.
Both methods fire this request upon printing:
```
POST /your/printing/api/endpoint HTTP/1.1
product=<productname>&grocycode=grocy:x:xxx&duedate=DD:%2021-06-09&...
```
If specified, the request body may also be JSON encoded, however the fields stay the same.
Additional POST parameters (like the font to use) may be supplied in `config.php`. Keep in mind that these config values will be distributed to all clients on all requests
if the webhook is configured to run client-side.
The webhook receiver is required to layout and print the resulting label.

View File

@ -1402,7 +1402,7 @@
"in": "path", "in": "path",
"name": "entryId", "name": "entryId",
"required": true, "required": true,
"description": "A valid stock row id", "description": "A valid stock entry id",
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
@ -1441,7 +1441,7 @@
"in": "path", "in": "path",
"name": "entryId", "name": "entryId",
"required": true, "required": true,
"description": "A valid stock row id", "description": "A valid stock entry id",
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
@ -1529,6 +1529,40 @@
} }
} }
}, },
"/stock/entry/{entryId}/printlabel": {
"get": {
"summary": "Prints the label of the given stock entry",
"tags": [
"Stock"
],
"parameters": [
{
"in": "path",
"name": "entryId",
"required": true,
"description": "A valid stock entry id",
"schema": {
"type": "integer"
}
}
],
"responses": {
"204": {
"description": "The operation was successful"
},
"400": {
"description": "The operation was not successful (possible errors are: Not existing stock entry, error on WebHook execution)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error400"
}
}
}
}
}
}
},
"/stock/volatile": { "/stock/volatile": {
"get": { "get": {
"summary": "Returns all products which are due soon, overdue, expired or currently missing", "summary": "Returns all products which are due soon, overdue, expired or currently missing",
@ -1850,6 +1884,10 @@
"type": "number", "type": "number",
"format": "integer", "format": "integer",
"description": "If omitted, no store will be affected" "description": "If omitted, no store will be affected"
},
"print_stock_label": {
"type": "boolean",
"description": "True when the stock entry label should be printed"
} }
}, },
"example": { "example": {
@ -2213,6 +2251,40 @@
} }
} }
}, },
"/stock/products/{productId}/printlabel": {
"get": {
"summary": "Prints the product label of the given product",
"tags": [
"Stock"
],
"parameters": [
{
"in": "path",
"name": "productId",
"required": true,
"description": "A valid product id",
"schema": {
"type": "integer"
}
}
],
"responses": {
"204": {
"description": "The operation was successful"
},
"400": {
"description": "The operation was not successful (possible errors are: Not existing product, error on WebHook execution)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error400"
}
}
}
}
}
}
},
"/stock/products/{productIdToKeep}/merge/{productIdToRemove}": { "/stock/products/{productIdToKeep}/merge/{productIdToRemove}": {
"post": { "post": {
"summary": "Merges two products into one", "summary": "Merges two products into one",

142
helpers/Grocycode.php Normal file
View File

@ -0,0 +1,142 @@
<?php
namespace Grocy\Helpers;
/**
* A class that abstracts grocycode.
*
* grocycode is a simple, easily serializable format to reference
* stuff within grocy. It consists of n (n 3) double-colon seperated parts:
*
* 1. The magic `grcy`
* 2. A type identifer, must match `[a-z]+` (i.e. only lowercase ascii, minimum length 1 character)
* 3. An object id
* 4. Any number of further data fields, double-colon seperated.
*
* @author Katharina Bogad <katharina@hacked.xyz>
*/
class Grocycode
{
public const PRODUCT = 'p';
public const BATTERY = 'b';
public const CHORE = 'c';
public const MAGIC = 'grcy';
/**
* An array that registers all valid grocycode types. Register yours here by appending to this array.
*/
public static $Items = [self::PRODUCT, self::BATTERY, self::CHORE];
private $type;
private $id;
private $extra_data = [];
/**
* Validates a grocycode.
*
* Returns true, if a supplied $code is a valid grocycode, false otherwise.
*
* @return bool
*/
public static function Validate(string $code)
{
try
{
$gc = new self($code);
return true;
}
catch (Exception $e)
{
return false;
}
}
/**
* Constructs a new instance of the Grocycode class.
*
* Because php doesn't support overloading, this is a proxy
* to either setFromCode($code) or setFromData($type, $id, $extra_data = []).
*/
public function __construct(...$args)
{
$argc = count($args);
if ($argc == 1)
{
$this->setFromCode($args[0]);
return;
}
elseif ($argc == 2 || $argc == 3)
{
if ($argc == 2)
{
$args[] = [];
}
$this->setFromData($args[0], $args[1], $args[2]);
return;
}
throw new \Exception('No suitable overload found.');
}
/**
* Parses a grocycode.
*/
private function setFromCode($code)
{
$parts = array_reverse(explode(':', $barcode));
if (array_pop($parts) != self::MAGIC)
{
throw new \Exception('Not a grocycode');
}
if (!in_array($this->type = array_pop($parts), self::$Items))
{
throw new \Exception('Unknown grocycode type');
}
$this->id = array_pop($parts);
$this->extra_data = array_reverse($parse);
}
/**
* Constructs a grocycode from data.
*/
private function setFromData($type, $id, $extra_data = [])
{
if (!is_array($extra_data))
{
throw new \Exception('Extra data must be array of string');
}
if (!in_array($type, self::$Items))
{
throw new \Exception('Unknown grocycode type');
}
$this->type = $type;
$this->id = $id;
$this->extra_data = $extra_data;
}
public function GetId()
{
return $this->id;
}
public function GetExtraData()
{
return $this->extra_data;
}
public function GetType()
{
return $this->type;
}
public function __toString(): string
{
$arr = array_merge([self::MAGIC, $this->type, $this->id], $this->extra_data);
return implode(':', $arr);
}
}

48
helpers/WebhookRunner.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace Grocy\Helpers;
use GuzzleHttp\Client;
use GuzzleHttp\ExceptionRequestException;
use Psr\Http\Message\ResponseInterface;
class WebhookRunner
{
private $client;
public function __construct()
{
$this->client = new Client(['timeout' => 2.0]);
}
public function run($url, $args, $json = false)
{
$reqArgs = [];
if ($json)
{
$reqArgs = ['json' => $args];
}
else
{
$reqArgs = ['form_params' => $args];
}
try
{
file_put_contents('php://stderr', 'Running Webhook: ' . $url . "\n" . print_r($reqArgs, true));
$this->client->request('POST', $url, $reqArgs);
}
catch (RequestException $e)
{
file_put_contents('php://stderr', 'Webhook failed: ' . $url . "\n" . $e->getMessage());
}
}
public function runAll($urls, $args)
{
foreach ($urls as $url)
{
$this->run($url, $args);
}
}
}

View File

@ -2077,3 +2077,55 @@ msgstr ""
msgid "A product or a note is required" msgid "A product or a note is required"
msgstr "" msgstr ""
msgid "grocycode"
msgstr ""
msgid "Download"
msgstr ""
msgid "Download stock entry grocycode"
msgstr ""
msgid "Download product grocycode"
msgstr ""
msgid "grocycode is a unique referer to this product in your grocy instance - print it onto a label and scan it like any other barcode"
msgstr ""
# Abbreviation for "due date"
msgid "DD"
msgstr ""
msgid "Print on label printer"
msgstr ""
msgid "Stock entry label"
msgstr ""
msgid "No label"
msgstr ""
msgid "Single label"
msgstr ""
msgid "Label per unit"
msgstr ""
msgid "Allow label printing per unit"
msgstr ""
msgid "Allow printing of one label per unit on purchase (after conversion) - e.g. 1 purchased pack adding 10 pieces of stock would print 10 labels"
msgstr ""
msgid "Error while executing WebHook"
msgstr ""
msgid "Print product grocycode on label printer"
msgstr ""
msgid "Print stock entry grocycode on label printer"
msgstr ""
msgid "Open stock entry print label in new window"
msgstr ""

11
migrations/0130.sql Normal file
View File

@ -0,0 +1,11 @@
ALTER TABLE products
ADD default_print_stock_label INTEGER NOT NULL DEFAULT 0;
UPDATE products
SET default_print_stock_label = 0;
ALTER TABLE products
ADD allow_label_per_unit INTEGER NOT NULL DEFAULT 0;
UPDATE products
SET allow_label_per_unit = 0;

File diff suppressed because it is too large Load Diff

View File

@ -476,6 +476,24 @@ Grocy.FrontendHelpers.DeleteUserSetting = function(settingsKey, reloadPageOnSucc
); );
} }
Grocy.FrontendHelpers.RunWebhook = function(webhook, data, repetitions = 1)
{
Object.assign(data, webhook.extra_data);
var hasAlreadyFailed = false;
for (i = 0; i < repetitions; i++)
{
$.post(webhook.hook, data).fail(function(req, status, errorThrown)
{
if (!hasAlreadyFailed)
{
hasAlreadyFailed = true;
Grocy.FrontendHelpers.ShowGenericError(__t("Error while executing WebHook", { "status": status, "errorThrown": errorThrown }));
}
});
}
}
$(document).on("keyup paste change", "input, textarea", function() $(document).on("keyup paste change", "input, textarea", function()
{ {
$(this).closest("form").addClass("is-dirty"); $(this).closest("form").addClass("is-dirty");

View File

@ -1,5 +1,9 @@
Grocy.Components.BarcodeScanner = {}; Grocy.Components.BarcodeScanner = {};
//import Quagga2DatamatrixReader from '../../components_unmanaged/quagga2-reader-datamatrix/index.js'
Quagga.registerReader("datamatrix", Quagga2DatamatrixReader);
Grocy.Components.BarcodeScanner.LiveVideoSizeAdjusted = false; Grocy.Components.BarcodeScanner.LiveVideoSizeAdjusted = false;
Grocy.Components.BarcodeScanner.CheckCapabilities = async function() Grocy.Components.BarcodeScanner.CheckCapabilities = async function()
{ {
@ -96,7 +100,8 @@ Grocy.Components.BarcodeScanner.StartScanning = function()
readers: [ readers: [
"ean_reader", "ean_reader",
"ean_8_reader", "ean_8_reader",
"code_128_reader" "code_128_reader",
"datamatrix"
], ],
debug: { debug: {
showCanvas: Grocy.UserSettings.quagga2_debug, showCanvas: Grocy.UserSettings.quagga2_debug,

View File

@ -147,7 +147,23 @@ $('#product_id_text_input').on('blur', function(e)
$('#product_id').attr("barcode", "null"); $('#product_id').attr("barcode", "null");
var input = $('#product_id_text_input').val().toString(); var input = $('#product_id_text_input').val().toString();
var possibleOptionElement = $("#product_id option[data-additional-searchdata*=\"" + input + ",\"]").first(); var possibleOptionElement = [];
// did we enter a grocycode?
if (input.startsWith("grcy"))
{
var gc = input.split(":");
if (gc[1] == "p")
{
// find product id
possibleOptionElement = $("#product_id option[value=\"" + gc[2] + "\"]").first();
$("#product_id").data("grocycode", true);
}
}
else // process barcode as usual
{
possibleOptionElement = $("#product_id option[data-additional-searchdata*=\"" + input + ",\"]").first();
}
if (GetUriParam('flow') === undefined && input.length > 0 && possibleOptionElement.length > 0) if (GetUriParam('flow') === undefined && input.length > 0 && possibleOptionElement.length > 0)
{ {

View File

@ -223,6 +223,18 @@ $("#location_id").on('change', function(e)
{ {
stockId = GetUriParam('stockId'); stockId = GetUriParam('stockId');
} }
else
{
// try to get stock id from grocycode
if ($("#product_id").data("grocycode"))
{
var gc = $("#product_id").attr("barcode").split(":");
if (gc.length == 4)
{
stockId = gc[3];
}
}
}
if (locationId) if (locationId)
{ {
@ -249,6 +261,7 @@ $("#location_id").on('change', function(e)
if (stockEntry.stock_id == stockId) if (stockEntry.stock_id == stockId)
{ {
$("#use_specific_stock_entry").click();
$("#specific_stock_entry").val(stockId); $("#specific_stock_entry").val(stockId);
} }
} }

View File

@ -301,6 +301,21 @@ $('#name').focus();
$('.input-group-qu').trigger('change'); $('.input-group-qu').trigger('change');
Grocy.FrontendHelpers.ValidateForm('product-form'); Grocy.FrontendHelpers.ValidateForm('product-form');
$(document).on('click', '.stockentry-grocycode-product-label-print', function(e)
{
e.preventDefault();
document.activeElement.blur();
var productId = $(e.currentTarget).attr('data-product-id');
Grocy.Api.Get('stock/products/' + productId + '/printlabel', function(labelData)
{
if (Grocy.Webhooks.labelprinter !== undefined)
{
Grocy.FrontendHelpers.RunWebhook(Grocy.Webhooks.labelprinter, labelData);
}
});
});
$(document).on('click', '.qu-conversion-delete-button', function(e) $(document).on('click', '.qu-conversion-delete-button', function(e)
{ {
var objectId = $(e.currentTarget).attr('data-qu-conversion-id'); var objectId = $(e.currentTarget).attr('data-qu-conversion-id');
@ -388,6 +403,22 @@ $('#qu_id_stock').change(function(e)
} }
}); });
$('#allow_label_per_unit').on('change', function()
{
if (this.checked)
{
$('#label-option-per-unit').prop("disabled", false);
}
else
{
if ($('#default_print_stock_label').val() == "2")
{
$("#default_print_stock_label").val("0");
}
$('#label-option-per-unit').prop("disabled", true);
}
});
$(window).on("message", function(e) $(window).on("message", function(e)
{ {
var data = e.originalEvent.data; var data = e.originalEvent.data;

View File

@ -23,6 +23,7 @@ $('#save-purchase-button').on('click', function(e)
{ {
var jsonData = {}; var jsonData = {};
jsonData.amount = jsonForm.amount; jsonData.amount = jsonForm.amount;
jsonData.print_stock_label = jsonForm.print_stock_label
if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING) if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
{ {
@ -116,6 +117,30 @@ $('#save-purchase-button').on('click', function(e)
} }
var successMessage = __t('Added %1$s of %2$s to stock', amountMessage + " " + __n(amountMessage, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + result[0].transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>'; var successMessage = __t('Added %1$s of %2$s to stock', amountMessage + " " + __n(amountMessage, productDetails.quantity_unit_stock.name, productDetails.quantity_unit_stock.name_plural), productDetails.product.name) + '<br><a class="btn btn-secondary btn-sm mt-2" href="#" onclick="UndoStockTransaction(\'' + result[0].transaction_id + '\')"><i class="fas fa-undo"></i> ' + __t("Undo") + '</a>';
if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_LABELPRINTER)
{
if (Grocy.Webhooks.labelprinter !== undefined)
{
var post_data = {};
post_data.product = productDetails.product.name;
post_data.grocycode = 'grcy:p:' + jsonForm.product_id + ":" + result[0].stock_id
if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING)
{
post_data.duedate = __t('DD') + ': ' + result[0].best_before_date
}
if (jsonForm.print_stock_label > 0)
{
var reps = 1;
if (jsonForm.print_stock_label == 2)
{
reps = Math.floor(jsonData.amount);
}
Grocy.FrontendHelpers.RunWebhook(Grocy.Webhooks.labelprinter, post_data, reps);
}
}
}
if (GetUriParam("embedded") !== undefined) if (GetUriParam("embedded") !== undefined)
{ {
window.parent.postMessage(WindowMessageBag("ProductChanged", jsonForm.product_id), Grocy.BaseUrl); window.parent.postMessage(WindowMessageBag("ProductChanged", jsonForm.product_id), Grocy.BaseUrl);
@ -279,6 +304,23 @@ if (Grocy.Components.ProductPicker !== undefined)
} }
} }
if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_LABELPRINTER)
{
$("#print_stock_label").val(productDetails.product.default_print_stock_label);
if (productDetails.product.allow_label_per_unit)
{
if ($('#default_print_stock_label').val() == "2")
{
$("#default_print_stock_label").val("0");
}
$('#label-option-per-unit').prop("disabled", true);
}
else
{
$('#label-option-per-unit').prop("disabled", false);
}
}
$("#display_amount").focus(); $("#display_amount").focus();
Grocy.FrontendHelpers.ValidateForm('purchase-form'); Grocy.FrontendHelpers.ValidateForm('purchase-form');

View File

@ -124,6 +124,21 @@ $(document).on("click", ".stock-name-cell", function(e)
$("#stockentry-productcard-modal").modal("show"); $("#stockentry-productcard-modal").modal("show");
}); });
$(document).on('click', '.stockentry-grocycode-stockentry-label-print', function(e)
{
e.preventDefault();
document.activeElement.blur();
var stockId = $(e.currentTarget).attr('data-stock-id');
Grocy.Api.Get('stock/entry/' + stockId + '/printlabel', function(labelData)
{
if (Grocy.Webhooks.labelprinter !== undefined)
{
Grocy.FrontendHelpers.RunWebhook(Grocy.Webhooks.labelprinter, labelData);
}
});
});
function RefreshStockEntryRow(stockRowId) function RefreshStockEntryRow(stockRowId)
{ {
Grocy.Api.Get("stock/entry/" + stockRowId, Grocy.Api.Get("stock/entry/" + stockRowId,

View File

@ -101,6 +101,21 @@ $("#search").on("keyup", Delay(function()
stockOverviewTable.search(value).draw(); stockOverviewTable.search(value).draw();
}, 200)); }, 200));
$(document).on('click', '.stockentry-grocycode-product-label-print', function(e)
{
e.preventDefault();
document.activeElement.blur();
var productId = $(e.currentTarget).attr('data-product-id');
Grocy.Api.Get('stock/products/' + productId + '/printlabel', function(labelData)
{
if (Grocy.Webhooks.labelprinter !== undefined)
{
Grocy.FrontendHelpers.RunWebhook(Grocy.Webhooks.labelprinter, labelData);
}
});
});
$(document).on('click', '.product-consume-button', function(e) $(document).on('click', '.product-consume-button', function(e)
{ {
e.preventDefault(); e.preventDefault();

View File

@ -1,10 +1,9 @@
<?php <?php
use Grocy\Middleware\AuthMiddleware;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Routing\RouteCollectorProxy;
use Grocy\Middleware\JsonMiddleware; use Grocy\Middleware\JsonMiddleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;
$app->group('', function (RouteCollectorProxy $group) { $app->group('', function (RouteCollectorProxy $group) {
// System routes // System routes
@ -41,6 +40,7 @@ $app->group('', function (RouteCollectorProxy $group) {
$group->get('/quantityunitconversion/{quConversionId}', '\Grocy\Controllers\StockController:QuantityUnitConversionEditForm'); $group->get('/quantityunitconversion/{quConversionId}', '\Grocy\Controllers\StockController:QuantityUnitConversionEditForm');
$group->get('/productgroups', '\Grocy\Controllers\StockController:ProductGroupsList'); $group->get('/productgroups', '\Grocy\Controllers\StockController:ProductGroupsList');
$group->get('/productgroup/{productGroupId}', '\Grocy\Controllers\StockController:ProductGroupEditForm'); $group->get('/productgroup/{productGroupId}', '\Grocy\Controllers\StockController:ProductGroupEditForm');
$group->get('/product/{productId}/grocycode', '\Grocy\Controllers\StockController:ProductGrocycodeImage');
// Stock handling routes // Stock handling routes
if (GROCY_FEATURE_FLAG_STOCK) if (GROCY_FEATURE_FLAG_STOCK)
@ -60,6 +60,8 @@ $app->group('', function (RouteCollectorProxy $group) {
$group->get('/quantityunitpluraltesting', '\Grocy\Controllers\StockController:QuantityUnitPluralFormTesting'); $group->get('/quantityunitpluraltesting', '\Grocy\Controllers\StockController:QuantityUnitPluralFormTesting');
$group->get('/stockjournal/summary', '\Grocy\Controllers\StockController:JournalSummary'); $group->get('/stockjournal/summary', '\Grocy\Controllers\StockController:JournalSummary');
$group->get('/productbarcodes/{productBarcodeId}', '\Grocy\Controllers\StockController:ProductBarcodesEditForm'); $group->get('/productbarcodes/{productBarcodeId}', '\Grocy\Controllers\StockController:ProductBarcodesEditForm');
$group->get('/stockentry/{entryId}/grocycode', '\Grocy\Controllers\StockController:StockEntryGrocycodeImage');
$group->get('/stockentry/{entryId}/label', '\Grocy\Controllers\StockController:StockEntryGrocycodeLabel');
} }
// Stock price tracking // Stock price tracking
@ -206,6 +208,8 @@ $app->group('/api', function (RouteCollectorProxy $group) {
$group->get('/stock/transactions/{transactionId}', '\Grocy\Controllers\StockApiController:StockTransactions'); $group->get('/stock/transactions/{transactionId}', '\Grocy\Controllers\StockApiController:StockTransactions');
$group->post('/stock/transactions/{transactionId}/undo', '\Grocy\Controllers\StockApiController:UndoTransaction'); $group->post('/stock/transactions/{transactionId}/undo', '\Grocy\Controllers\StockApiController:UndoTransaction');
$group->get('/stock/barcodes/external-lookup/{barcode}', '\Grocy\Controllers\StockApiController:ExternalBarcodeLookup'); $group->get('/stock/barcodes/external-lookup/{barcode}', '\Grocy\Controllers\StockApiController:ExternalBarcodeLookup');
$group->get('/stock/products/{productId}/printlabel', '\Grocy\Controllers\StockApiController:ProductPrintLabel');
$group->get('/stock/entry/{entryId}/printlabel', '\Grocy\Controllers\StockApiController:StockEntryPrintLabel');
// Shopping list // Shopping list
$group->post('/stock/shoppinglist/add-missing-products', '\Grocy\Controllers\StockApiController:AddMissingProductsToShoppingList'); $group->post('/stock/shoppinglist/add-missing-products', '\Grocy\Controllers\StockApiController:AddMissingProductsToShoppingList');

View File

@ -2,6 +2,9 @@
namespace Grocy\Services; namespace Grocy\Services;
use Grocy\Helpers\Grocycode;
use Grocy\Helpers\WebhookRunner;
class StockService extends BaseService class StockService extends BaseService
{ {
const TRANSACTION_TYPE_CONSUME = 'consume'; const TRANSACTION_TYPE_CONSUME = 'consume';
@ -102,7 +105,7 @@ class StockService extends BaseService
} }
} }
public function AddProduct(int $productId, float $amount, $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId = null, $shoppingLocationId = null, &$transactionId = null) public function AddProduct(int $productId, float $amount, $bestBeforeDate, $transactionType, $purchasedDate, $price, $locationId = null, $shoppingLocationId = null, &$transactionId = null, $runWebhook = 0)
{ {
if (!$this->ProductExists($productId)) if (!$this->ProductExists($productId))
{ {
@ -173,10 +176,36 @@ class StockService extends BaseService
'stock_id' => $stockId, 'stock_id' => $stockId,
'price' => $price, 'price' => $price,
'location_id' => $locationId, 'location_id' => $locationId,
'shopping_location_id' => $shoppingLocationId, 'shopping_location_id' => $shoppingLocationId
]); ]);
$stockRow->save(); $stockRow->save();
if (GROCY_FEATURE_FLAG_LABELPRINTER && GROCY_LABEL_PRINTER_RUN_SERVER && $runWebhook)
{
$reps = 1;
if ($runWebhook == 2)
{ // 2 == run $amount times
$reps = intval(floor($amount));
}
$webhookData = array_merge([
'product' => $productDetails->product->name,
'grocycode' => (string)(new Grocycode(Grocycode::PRODUCT, $productId, [$stockId])),
], GROCY_LABEL_PRINTER_PARAMS);
if (GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING)
{
$webhookData['duedate'] = $this->getLocalizationService()->__t('DD') . ': ' . $bestBeforeDate;
}
$runner = new WebhookRunner();
for ($i = 0; $i < $reps; $i++)
{
$runner->run(GROCY_LABEL_PRINTER_WEBHOOK, $webhookData, GROCY_LABEL_PRINTER_HOOK_JSON);
}
}
return $transactionId; return $transactionId;
} }
else else
@ -240,7 +269,7 @@ class StockService extends BaseService
throw new \Exception('Location does not exist'); throw new \Exception('Location does not exist');
} }
$productDetails = (object)$this->GetProductDetails($productId); $productDetails = (object) $this->GetProductDetails($productId);
// Tare weight handling // Tare weight handling
// The given amount is the new total amount including the container weight (gross) // The given amount is the new total amount including the container weight (gross)
@ -280,7 +309,7 @@ class StockService extends BaseService
// TODO: This check doesn't really check against products only at the given location // TODO: This check doesn't really check against products only at the given location
// (as GetProductDetails returns the stock_amount_aggregated of all locations) // (as GetProductDetails returns the stock_amount_aggregated of all locations)
// However, $potentialStockEntries are filtered accordingly, so this currently isn't really a problem at the end // However, $potentialStockEntries are filtered accordingly, so this currently isn't really a problem at the end
$productStockAmount = ((object)$this->GetProductDetails($productId))->stock_amount_aggregated; $productStockAmount = ((object) $this->GetProductDetails($productId))->stock_amount_aggregated;
if ($amount > $productStockAmount) if ($amount > $productStockAmount)
{ {
throw new \Exception('Amount to be consumed cannot be > current stock amount (if supplied, at the desired location)'); throw new \Exception('Amount to be consumed cannot be > current stock amount (if supplied, at the desired location)');
@ -639,6 +668,13 @@ class StockService extends BaseService
public function GetProductIdFromBarcode(string $barcode) public function GetProductIdFromBarcode(string $barcode)
{ {
// first, try to parse this as a product grocycode
if (Grocycode::Validate($barcode))
{
$gc = new Grocycode($barcode);
return intval($gc->GetId());
}
$potentialProduct = $this->getDatabase()->product_barcodes()->where('barcode = :1', $barcode)->fetch(); $potentialProduct = $this->getDatabase()->product_barcodes()->where('barcode = :1', $barcode)->fetch();
if ($potentialProduct === null) if ($potentialProduct === null)
@ -727,7 +763,7 @@ class StockService extends BaseService
throw new \Exception('Product does not exist or is inactive'); throw new \Exception('Product does not exist or is inactive');
} }
$productDetails = (object)$this->GetProductDetails($productId); $productDetails = (object) $this->GetProductDetails($productId);
if ($price === null) if ($price === null)
{ {
@ -786,7 +822,7 @@ class StockService extends BaseService
throw new \Exception('Product does not exist or is inactive'); throw new \Exception('Product does not exist or is inactive');
} }
$productDetails = (object)$this->GetProductDetails($productId); $productDetails = (object) $this->GetProductDetails($productId);
$productStockAmountUnopened = floatval($productDetails->stock_amount_aggregated) - floatval($productDetails->stock_amount_opened_aggregated); $productStockAmountUnopened = floatval($productDetails->stock_amount_aggregated) - floatval($productDetails->stock_amount_opened_aggregated);
$potentialStockEntries = $this->GetProductStockEntries($productId, true, $allowSubproductSubstitution); $potentialStockEntries = $this->GetProductStockEntries($productId, true, $allowSubproductSubstitution);
$product = $this->getDatabase()->products($productId); $product = $this->getDatabase()->products($productId);

View File

@ -6,6 +6,7 @@
@push('pageScripts') @push('pageScripts')
<script src="{{ $U('/node_modules/@ericblade/quagga2/dist/quagga.min.js?v=', true) }}{{ $version }}"></script> <script src="{{ $U('/node_modules/@ericblade/quagga2/dist/quagga.min.js?v=', true) }}{{ $version }}"></script>
<script src="{{ $U('/components_unmanaged/quagga2-reader-datamatrix/index.js', true) }}?v={{ $version }}"></script>
@endpush @endpush
@push('pageStyles') @push('pageStyles')
@ -21,8 +22,7 @@
.combobox-container #barcodescanner-start-button { .combobox-container #barcodescanner-start-button {
margin-right: 36px !important; margin-right: 36px !important;
} }
</style> </style>
@endpush @endpush
@endif @endif

View File

@ -68,4 +68,4 @@
class="font-italic d-none">{{ $__t('No price history available') }}</span> class="font-italic d-none">{{ $__t('No price history available') }}</span>
@endif @endif
</div> </div>
</div> </div>

View File

@ -95,6 +95,14 @@
Grocy.CalendarShowWeekNumbers = {{ BoolToString(GROCY_CALENDAR_SHOW_WEEK_OF_YEAR) }}; Grocy.CalendarShowWeekNumbers = {{ BoolToString(GROCY_CALENDAR_SHOW_WEEK_OF_YEAR) }};
Grocy.GettextPo = {!! $GettextPo !!}; Grocy.GettextPo = {!! $GettextPo !!};
Grocy.FeatureFlags = {!! json_encode($featureFlags) !!}; Grocy.FeatureFlags = {!! json_encode($featureFlags) !!};
Grocy.Webhooks = {
@if(GROCY_FEATURE_FLAG_LABELPRINTER && !GROCY_LABEL_PRINTER_RUN_SERVER)
"labelprinter" : {
"hook" : "{{ GROCY_LABEL_PRINTER_WEBHOOK}}",
"extra_data" : {!! json_encode(GROCY_LABEL_PRINTER_PARAMS) !!}
}
@endif
};
@if (GROCY_AUTHENTICATED) @if (GROCY_AUTHENTICATED)
Grocy.UserSettings = {!! json_encode($userSettings) !!}; Grocy.UserSettings = {!! json_encode($userSettings) !!};

View File

@ -400,6 +400,57 @@
'entity' => 'products' 'entity' => 'products'
)) ))
@if(GROCY_FEATURE_FLAG_LABELPRINTER)
<div class="form-group">
<div class="custom-control custom-checkbox">
<input @if($mode=='edit'
&&
$product->allow_label_per_unit == 1) checked @endif class="form-check-input custom-control-input" type="checkbox" id="allow_label_per_unit" name="allow_label_per_unit" value="1">
<label class="form-check-label custom-control-label"
for="allow_label_per_unit">{{ $__t('Allow label printing per unit') }}&nbsp;<i class="fas fa-question-circle text-muted"
data-toggle="tooltip"
title="{{ $__t('Allow printing of one label per unit on purchase (after conversion) - e.g. 1 purchased pack adding 10 pieces of stock would print 10 labels') }}"></i>
</label>
</div>
</div>
@php
$no_label = "";
$single_label = "";
$per_unit_label = "";
$disable_per_unit = "";
if($mode == 'edit') {
switch($product->default_print_stock_label) {
case 0: $no_label = "selected"; break;
case 1: $single_label = "selected"; break;
case 2: $per_unit_label = "selected"; break;
default: break; // yolo
}
if($product->allow_label_per_unit == 0) {
$disable_per_unit="disabled";
$per_unit_label = "";
}
}
@endphp
<div class="form-group">
<label for="default_print_stock_label">{{ $__t('Stock entry label') }}</label>
<select class="form-control"
id="default_print_stock_label"
name="default_print_stock_label">
<option value="0"
{{ $no_label }}>{{ $__t('No label') }}</option>
<option value="1"
{{ $single_label }}>{{ $__t('Single label') }}</option>
<option value="2"
{{ $per_unit_label }}
{{ $disable_per_unit }}
id="label-option-per-unit">{{ $__t('Label per unit') }}</option>
</select>
</div>
@endif
<div class="form-group"> <div class="form-group">
<div class="custom-control custom-checkbox"> <div class="custom-control custom-checkbox">
<input @if($mode=='edit' <input @if($mode=='edit'
@ -426,6 +477,7 @@
</div> </div>
<div class="col-lg-6 col-xs-12 @if($mode == 'create') d-none @endif"> <div class="col-lg-6 col-xs-12 @if($mode == 'create') d-none @endif">
<div class="row @if(!GROCY_FEATURE_FLAG_STOCK) d-none @endif"> <div class="row @if(!GROCY_FEATURE_FLAG_STOCK) d-none @endif">
<div class="col"> <div class="col">
<div class="title-related-links"> <div class="title-related-links">
@ -533,6 +585,34 @@
</div> </div>
</div> </div>
<div class="row mt-2">
<div class="col clearfix">
<div class="title-related-links">
<h4>
{{ $__t('grocycode') }}
<i class="fas fa-question-circle text-muted"
data-toggle="tooltip"
title="{{ $__t('grocycode is a unique referer to this product in your grocy instance - print it onto a label and scan it like any other barcode') }}"></i>
</h4>
<p>
<img src="{{ $U('/product/' . $product->id . '/grocycode?size=60') }}"
class="float-lg-left">
</p>
<p>
<a class="btn btn-outline-primary btn-sm"
href="{{ $U('/product/' . $product->id . '/grocycode?download=true') }}">{{ $__t('Download') }}</a>
@if(GROCY_FEATURE_FLAG_LABELPRINTER)
<a class="btn btn-outline-primary btn-sm stockentry-grocycode-product-label-print"
data-product-id="{{ $product->id }}"
href="#">
{{ $__t('Print on label printer') }}
</a>
@endif
</p>
</div>
</div>
</div>
<div class="row @if(GROCY_FEATURE_FLAG_STOCK) mt-5 @endif"> <div class="row @if(GROCY_FEATURE_FLAG_STOCK) mt-5 @endif">
<div class="col"> <div class="col">
<div class="title-related-links"> <div class="title-related-links">

View File

@ -12,7 +12,7 @@
<script> <script>
Grocy.QuantityUnits = {!! json_encode($quantityUnits) !!}; Grocy.QuantityUnits = {!! json_encode($quantityUnits) !!};
Grocy.QuantityUnitConversionsResolved = {!! json_encode($quantityUnitConversionsResolved) !!}; Grocy.QuantityUnitConversionsResolved = {!! json_encode($quantityUnitConversionsResolved) !!};
Grocy.DefaultMinAmount = '{{$DEFAULT_MIN_AMOUNT}}'; Grocy.DefaultMinAmount = '{{ $DEFAULT_MIN_AMOUNT }}';
</script> </script>
<div class="row"> <div class="row">
@ -148,6 +148,21 @@
)) ))
@endif @endif
@if(GROCY_FEATURE_FLAG_LABELPRINTER)
<div class="form-group">
<label for="print_stock_label">{{ $__t('Stock entry label') }}</label>
<select class="form-control"
id="print_stock_label"
name="print_stock_label">
<option value="0">{{ $__t('No label') }}</option>
<option value="1">{{ $__t('Single label') }}</option>
<option value="2"
id="label-option-per-unit">{{ $__t('Label per unit') }}</option>
</select>
<div class="invalid-feedback">{{ $__t('A quantity unit is required') }}</div>
</div>
@endif
<button id="save-purchase-button" <button id="save-purchase-button"
class="btn btn-success d-block">{{ $__t('OK') }}</button> class="btn btn-success d-block">{{ $__t('OK') }}</button>

View File

@ -200,6 +200,26 @@
href="{{ $U('/product/') }}{{ $stockEntry->product_id . '?returnto=/stockentries' }}"> href="{{ $U('/product/') }}{{ $stockEntry->product_id . '?returnto=/stockentries' }}">
{{ $__t('Edit product') }} {{ $__t('Edit product') }}
</a> </a>
<div class="dropdown-divider"></div>
<a class="dropdown-item stockentry-grocycode-link"
type="button"
href="{{ $U('/stockentry/' . $stockEntry->id . '/grocycode?download=true') }}">
{{ $__t('Download stock entry grocycode') }}
</a>
@if(GROCY_FEATURE_FLAG_LABELPRINTER)
<a class="dropdown-item stockentry-grocycode-stockentry-label-print"
data-stock-id="{{ $stockEntry->id }}"
type="button"
href="#">
{{ $__t('Print stock entry grocycode on label printer') }}
</a>
@endif
<a class="dropdown-item stockentry-label-link"
type="button"
target="_blank"
href="{{ $U('/stockentry/' . $stockEntry->id . '/label') }}">
{{ $__t('Open stock entry print label in new window') }}
</a>
</div> </div>
</div> </div>
</td> </td>

View File

@ -0,0 +1,40 @@
<html>
<head>
<title>{{ $product->name }}</title>
<link href="{{ $U('/components_unmanaged/noto-sans-v11-latin/noto-sans-v11-latin.min.css?v=', true) }}{{ $version }}"
rel="stylesheet">
<style>
body {
font-family: 'Noto Sans', sans-serif;
}
img {
float: left;
margin-right: .5rem;
max-height: 25px;
width: auto;
margin-top: 2px;
}
.productname {
font-size: 20px;
display: inline-block;
font-weight: bold;
}
</style>
</head>
<body>
<p>
<!-- Size gets determined by CSS, so printing works better (more pixels = sharper printed image).
Unfortunately, this also means the code is blurred on screen. -->
<img src="{{ $U('/stockentry/'. $stockEntry->id . '/grocycode?size=100') }}">
<span class="productname">{{ $product->name }}</span><br>
@if (GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING)
<span>{{ $__t('DD') }}: {{ $stockEntry->best_before_date }}</span>
@endif
</p>
</body>
</html>

View File

@ -298,6 +298,20 @@
href="{{ $U('/product/') }}{{ $currentStockEntry->product_id . '?returnto=%2Fstockoverview' }}"> href="{{ $U('/product/') }}{{ $currentStockEntry->product_id . '?returnto=%2Fstockoverview' }}">
<span class="dropdown-item-text">{{ $__t('Edit product') }}</span> <span class="dropdown-item-text">{{ $__t('Edit product') }}</span>
</a> </a>
<div class="dropdown-divider"></div>
<a class="dropdown-item stockentry-grocycode-link"
type="button"
href="{{ $U('/product/' . $currentStockEntry->product_id . '/grocycode?download=true') }}">
{{ $__t('Download product grocycode') }}
</a>
@if(GROCY_FEATURE_FLAG_LABELPRINTER)
<a class="dropdown-item stockentry-grocycode-product-label-print"
data-product-id="{{ $currentStockEntry->product_id }}"
type="button"
href="#">
{{ $__t('Print product grocycode on label printer') }}
</a>
@endif
</div> </div>
</div> </div>
</td> </td>
@ -308,7 +322,8 @@
<td> <td>
@if($currentStockEntry->product_group_name !== null){{ $currentStockEntry->product_group_name }}@endif @if($currentStockEntry->product_group_name !== null){{ $currentStockEntry->product_group_name }}@endif
</td> </td>
<td data-order={{ $currentStockEntry->amount }}> <td data-order={{
$currentStockEntry->amount }}>
<span id="product-{{ $currentStockEntry->product_id }}-amount" <span id="product-{{ $currentStockEntry->product_id }}-amount"
class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount }}</span> <span id="product-{{ $currentStockEntry->product_id }}-qu-name">{{ $__n($currentStockEntry->amount, $currentStockEntry->qu_unit_name, $currentStockEntry->qu_unit_name_plural) }}</span> class="locale-number locale-number-quantity-amount">{{ $currentStockEntry->amount }}</span> <span id="product-{{ $currentStockEntry->product_id }}-qu-name">{{ $__n($currentStockEntry->amount, $currentStockEntry->qu_unit_name, $currentStockEntry->qu_unit_name_plural) }}</span>
<span id="product-{{ $currentStockEntry->product_id }}-opened-amount" <span id="product-{{ $currentStockEntry->product_id }}-opened-amount"