Merge branch 'master' into master

This commit is contained in:
Bernd Bestel 2021-06-18 20:40:22 +02:00 committed by GitHub
commit 3d7b176344
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 26981 additions and 73 deletions

View File

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

View File

@ -21,10 +21,10 @@ Please don't send me private messages regarding grocy help. I check the issue tr
See the website for a list of community contributed Add-ons / Tools: [https://grocy.info/addons](https://grocy.info/addons)
## Motivation
A household needs to be managed. I did this so far (almost 10 years) with my first self written software (a C# windows forms application) and with a bunch of Excel sheets. The software is a pain to use and Excel is Excel. So I searched for and tried different things for a (very) long time, nothing 100 % fitted, so this is my aim for a "complete household management"-thing. ERP your fridge!
A household needs to be managed. I did this so far (almost 10 years) with my first self written software (a C# Windows forms application) and with a bunch of Excel sheets. The software is a pain to use and Excel is Excel. So I searched for and tried different things for a (very) long time, nothing 100 % fitted, so this is my aim for a "complete household management"-thing. ERP your fridge!
## How to install
> Checkout [grocy-desktop](https://github.com/grocy/grocy-desktop), if you want to run grocy without having to manage a webserver just like a normal ("indows) desktop application.
> Checkout [grocy-desktop](https://github.com/grocy/grocy-desktop), if you want to run grocy without having to manage a webserver just like a normal (Windows) desktop application.
>
> Directly download the [latest release](https://releases.grocy.info/latest-desktop) - the installation is nothing more than just clicking 2 times "next".

View File

@ -1,7 +1,44 @@
- Fixed that tracking chores with "Done by" a different user was not possible
### New feature: (Own) Product and stock entry labels/barcodes ("grocycode")
- Print own labels/barcodes for products and/or every stock entry and then scan that code on every place a product or stock entry can be selected
- Can be printed (or downloaded) via
- The product edit page
- The context/more menu per line on the stock overview and stock entries page
- Automatically on purchase (new option on the purchase page, defaults can be configured per product)
- The used barcode type is `DataMatrix`
- Label printer functionality can be enabled via the new feature flag `FEATURE_FLAG_LABELPRINTER` (defaults to disabled)
- Label printer communication happens via WebHooks - see the new `LABEL_PRINTER*` `config.php` options
- Those grocycodes can also be used without a label printer - you can view or download the pictures and print them manually
- More information:
- https://github.com/grocy/grocy/blob/master/docs/grocycode.md
- https://github.com/grocy/grocy/blob/master/docs/label-printing.md
- (Thanks a lot @mistressofjellyfish)
### Stock improvements/fixes
- Product barcodes are now enforced to be unique across products
- Fixed that editing stock entries was not possible
- Fixed that shopping list prints had a grey background (thanks @Forceu)
- Improved/fixed the form validation on the shopping list item page (thanks @Forceu)
- Fixed that consuming with Scan Mode was not possible
- Some night mode style improvements (thanks @BlizzWave and @KTibow)
- Fixed that the current stock total value (header of the stock overview page) didn't include decimal amounts (thanks @Ape)
- Fixed that the transfer page was not fully populated when opening it from the stock entries page
### Shopping list improvements/fixes
- The amount now defaults to `1` for adding items quicker
- Fixed that shopping list prints had a grey background (thanks @Forceu)
- Fixed the form validation on the shopping list item page (thanks @Forceu)
### Recipe improvements/fixes
- Recipe printing improvements (thanks @Ape)
- Calories are now always displayed per single serving (on the recipe and meal plan page)
- Fixed that "Only check if any amount is in stock" (recipe ingredient option) didn't work for stock amounts < 1
### Chores fixes
- Fixed that tracking chores with "Done by" a different user was not possible
### Userfield fixes
- Fixed that numeric Userfields were initialised with `1.0`
### General & other improvements/fixes
- Some night mode style improvements (thanks @BlizzWave and @KTibow)
- Fixed that the number picker up/down buttons did not work when the input field was empty or contained an invalid number
### API fixes
- Fixed that due soon products with `due_type` = "Expiration date" were missing in `due_products` of the `/stock/volatile` endpoint

View File

@ -14,7 +14,7 @@
"ezyang/htmlpurifier": "^4.13",
"jucksearm/php-barcode": "^1.0",
"guzzlehttp/guzzle": "^7.0",
"mike42/escpos-php": "^3.0"
"mike42/escpos-php": "^3.0"
},
"autoload": {
"psr-4": {

View File

@ -140,7 +140,7 @@ DefaultUserSetting('stock_default_consume_amount_use_quick_consume_amount', fals
DefaultUserSetting('scan_mode_consume_enabled', false);
DefaultUserSetting('scan_mode_purchase_enabled', false);
DefaultUserSetting('show_icon_on_stock_overview_page_when_product_is_on_shopping_list', true);
DefaultUserSetting('show_purchased_date_on_purchase', false); // Wheter the purchased date should be editable on purchase (defaults to today otherwise)
DefaultUserSetting('show_purchased_date_on_purchase', false); // Whether the purchased date should be editable on purchase (defaults to today otherwise)
DefaultUserSetting('show_warning_on_purchase_when_due_date_is_earlier_than_next', true); // Show a warning on purchase when the due date of the purchased product is earlier than the next due date in stock
// Shopping list settings
@ -175,6 +175,21 @@ DefaultUserSetting('quagga2_patchsize', 'medium');
DefaultUserSetting('quagga2_frequency', 10);
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
// 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
@ -188,6 +203,7 @@ Setting('FEATURE_FLAG_TASKS', true);
Setting('FEATURE_FLAG_BATTERIES', true);
Setting('FEATURE_FLAG_EQUIPMENT', true);
Setting('FEATURE_FLAG_CALENDAR', true);
Setting('FEATURE_FLAG_LABELPRINTER', false);
// Sub feature flags
Setting('FEATURE_FLAG_STOCK_PRICE_TRACKING', true);

View File

@ -210,6 +210,7 @@ class BaseController
if (self::$htmlPurifierInstance == null)
{
$htmlPurifierConfig = \HTMLPurifier_Config::createDefault();
$htmlPurifierConfig->set('Cache.SerializerPath', GROCY_DATAPATH . '/viewcache');
$htmlPurifierConfig->set('HTML.Allowed', 'div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src],table[border|width|style],tbody,tr,td,th,blockquote');
$htmlPurifierConfig->set('CSS.AllowedProperties', 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align');
$htmlPurifierConfig->set('URI.AllowedSchemes', ['data' => true]);

View File

@ -3,6 +3,8 @@ namespace Grocy\Controllers;
use Grocy\Controllers\Users\User;
use Grocy\Services\StockService;
use Grocy\Helpers\WebhookRunner;
use Grocy\Helpers\Grocycode;
class StockApiController extends BaseApiController
{
@ -137,8 +139,14 @@ class StockApiController extends BaseApiController
{
$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;
return $this->StockTransactions($request, $response, $args);
}
@ -603,6 +611,46 @@ class StockApiController extends BaseApiController
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->ApiResponse($response, $webhookData);
}
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->ApiResponse($response, $webhookData);
}
public function RemoveProductFromShoppingList(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
User::checkPermission($request, User::PERMISSION_SHOPPINGLIST_ITEMS_DELETE);

View File

@ -2,7 +2,9 @@
namespace Grocy\Controllers;
use Grocy\Helpers\Grocycode;
use Grocy\Services\RecipesService;
use jucksearm\barcode\lib\DatamatrixFactory;
class StockController extends BaseController
{
@ -39,7 +41,7 @@ class StockController extends BaseController
'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'),
'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)
{
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)
{
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

@ -1405,7 +1405,7 @@
"in": "path",
"name": "entryId",
"required": true,
"description": "A valid stock row id",
"description": "A valid stock entry id",
"schema": {
"type": "integer"
}
@ -1444,7 +1444,7 @@
"in": "path",
"name": "entryId",
"required": true,
"description": "A valid stock row id",
"description": "A valid stock entry id",
"schema": {
"type": "integer"
}
@ -1532,6 +1532,48 @@
}
}
},
"/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": {
"200": {
"description": "The operation was successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "WebHook data"
}
}
}
},
"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": {
"get": {
"summary": "Returns all products which are due soon, overdue, expired or currently missing",
@ -1853,6 +1895,10 @@
"type": "number",
"format": "integer",
"description": "If omitted, no store will be affected"
},
"print_stock_label": {
"type": "boolean",
"description": "True when the stock entry label should be printed"
}
},
"example": {
@ -2216,6 +2262,48 @@
}
}
},
"/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": {
"200": {
"description": "The operation was successful",
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "WebHook data"
}
}
}
},
"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}": {
"post": {
"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

@ -2141,3 +2141,4 @@ msgstr ""
msgid "Unable to print"
msgstr ""

View File

@ -87,7 +87,7 @@ abstract class AuthMiddleware extends BaseMiddleware
/**
* @param array $postParams
* @return bool True/False if the provided credentials were valid
* @throws \Exception Throws an \Exception if an error happended during credentials processing or if this AuthMiddleware doesn't provide credentials processing (e. g. handles this externally)
* @throws \Exception Throws an \Exception if an error happened during credentials processing or if this AuthMiddleware doesn't provide credentials processing (e. g. handles this externally)
*/
abstract public static function ProcessLogin(array $postParams);

13
migrations/0128.sql Normal file
View File

@ -0,0 +1,13 @@
-- Duplicate product barcodes were most probably not created on purpose,
-- so just keep the newer one for any duplicates
DELETE FROM product_barcodes
WHERE id IN (
SELECT MIN(id)
FROM product_barcodes
GROUP BY barcode
HAVING COUNT(*) > 1
);
CREATE UNIQUE INDEX ix_product_barcodes ON product_barcodes (
barcode
);

99
migrations/0129.sql Normal file
View File

@ -0,0 +1,99 @@
DROP VIEW recipes_pos_resolved;
CREATE VIEW recipes_pos_resolved
AS
-- Multiplication by 1.0 to force conversion to float (REAL)
SELECT
r.id AS recipe_id,
rp.id AS recipe_pos_id,
rp.product_id AS product_id,
rp.amount * (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 * CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN r.base_servings * rnrr.base_servings*1.0 ELSE 1 END) AS recipe_amount,
IFNULL(sc.amount_aggregated, 0) AS stock_amount,
CASE WHEN IFNULL(sc.amount_aggregated, 0) >= CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 0.00000001 ELSE rp.amount * (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) END THEN 1 ELSE 0 END AS need_fulfilled,
CASE WHEN IFNULL(sc.amount_aggregated, 0) - CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 0.00000001 ELSE rp.amount * (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) END < 0 THEN ABS(IFNULL(sc.amount_aggregated, 0) - (CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE rp.amount * (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 * CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN r.base_servings * rnrr.base_servings*1.0 ELSE 1 END) END)) ELSE 0 END AS missing_amount,
IFNULL(sl.amount, 0) * p.qu_factor_purchase_to_stock AS amount_on_shopping_list,
CASE WHEN IFNULL(sc.amount_aggregated, 0) + (CASE WHEN r.not_check_shoppinglist = 1 THEN 0 ELSE IFNULL(sl.amount, 0) END * p.qu_factor_purchase_to_stock) >= CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 0.00000001 ELSE rp.amount * (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) END THEN 1 ELSE 0 END AS need_fulfilled_with_shopping_list,
rp.qu_id,
(CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) END) * rp.amount * pop.price * rp.price_factor AS costs,
CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN 0 ELSE 1 END AS is_nested_recipe_pos,
rp.ingredient_group,
pg.name as product_group,
rp.id, -- Just a dummy id column
r.type as recipe_type,
rnr.includes_recipe_id as child_recipe_id,
rp.note,
rp.variable_amount AS recipe_variable_amount,
rp.only_check_single_unit_in_stock,
rp.amount / r.base_servings*1.0 * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) * IFNULL(p.calories, 0) AS calories,
p.active AS product_active
FROM recipes r
JOIN recipes_nestings_resolved rnr
ON r.id = rnr.recipe_id
JOIN recipes rnrr
ON rnr.includes_recipe_id = rnrr.id
JOIN recipes_pos rp
ON rnr.includes_recipe_id = rp.recipe_id
JOIN products p
ON rp.product_id = p.id
LEFT JOIN product_groups pg
ON p.product_group_id = pg.id
LEFT JOIN (
SELECT product_id, SUM(amount) AS amount
FROM shopping_list
GROUP BY product_id) sl
ON rp.product_id = sl.product_id
LEFT JOIN stock_current sc
ON rp.product_id = sc.product_id
LEFT JOIN products_oldest_stock_unit_price pop
ON rp.product_id = pop.product_id
WHERE rp.not_check_stock_fulfillment = 0
UNION
-- Just add all recipe positions which should not be checked against stock with fulfilled need
SELECT
r.id AS recipe_id,
rp.id AS recipe_pos_id,
rp.product_id AS product_id,
rp.amount * (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 * CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN r.base_servings * rnrr.base_servings*1.0 ELSE 1 END) AS recipe_amount,
IFNULL(sc.amount_aggregated, 0) AS stock_amount,
1 AS need_fulfilled,
0 AS missing_amount,
IFNULL(sl.amount, 0) * p.qu_factor_purchase_to_stock AS amount_on_shopping_list,
1 AS need_fulfilled_with_shopping_list,
rp.qu_id,
(CASE WHEN rp.only_check_single_unit_in_stock = 1 THEN 1 ELSE (r.desired_servings*1.0 / r.base_servings*1.0) * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) END) * rp.amount * IFNULL(pop.price, 0) * rp.price_factor AS costs,
CASE WHEN rnr.recipe_id = rnr.includes_recipe_id THEN 0 ELSE 1 END AS is_nested_recipe_pos,
rp.ingredient_group,
pg.name as product_group,
rp.id, -- Just a dummy id column
r.type as recipe_type,
rnr.includes_recipe_id as child_recipe_id,
rp.note,
rp.variable_amount AS recipe_variable_amount,
rp.only_check_single_unit_in_stock,
rp.amount / r.base_servings*1.0 * (rnr.includes_servings*1.0 / CASE WHEN rnr.recipe_id != rnr.includes_recipe_id THEN rnrr.base_servings*1.0 ELSE 1 END) * IFNULL(p.calories, 0) AS calories,
p.active AS product_active
FROM recipes r
JOIN recipes_nestings_resolved rnr
ON r.id = rnr.recipe_id
JOIN recipes rnrr
ON rnr.includes_recipe_id = rnrr.id
JOIN recipes_pos rp
ON rnr.includes_recipe_id = rp.recipe_id
JOIN products p
ON rp.product_id = p.id
LEFT JOIN product_groups pg
ON p.product_group_id = pg.id
LEFT JOIN (
SELECT product_id, SUM(amount) AS amount
FROM shopping_list
GROUP BY product_id) sl
ON rp.product_id = sl.product_id
LEFT JOIN stock_current sc
ON rp.product_id = sc.product_id
LEFT JOIN products_oldest_stock_unit_price pop
ON rp.product_id = pop.product_id
WHERE rp.not_check_stock_fulfillment = 1;

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

@ -7,6 +7,15 @@ body.night-mode {
filter: invert(0.9) hue-rotate(176deg);
}
.navbar-light .navbar-toggler {
color: rgba(255, 255, 255, .5);
border-color: rgba(255, 255, 255,.1);
}
.night-mode .navbar-light .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
}
.night-mode .table-info,
.night-mode .table-info > td,
.night-mode .table-info > th,
@ -42,16 +51,20 @@ body.night-mode {
border-color: #1ed1ee !important;
}
.night-mode .btn-outline-info:hover {
color: #e1e1e1 !important;
}
.night-mode .btn-info {
color: #c1c1c1;
background-color: #07373f;
border-color: #07373f;
background-color: #0c5f6e;
border-color: #0c5f6e;
}
.night-mode .btn-warning {
color: #c1c1c1;
background-color: #473604;
border-color: #473604;
background-color: #a9810a;
border-color: #a9810a;
}
.night-mode .btn-danger {
@ -107,15 +120,15 @@ body.night-mode {
}
.night-mode .bg-warning {
background-color: #473604!important;
background-color: #473604 !important;
}
.night-mode .bg-info {
background-color: #07373f!important;
background-color: #07373f !important;
}
.night-mode .bg-danger {
background-color: #471116!important;
background-color: #471116 !important;
}
.night-mode .form-control:focus {
@ -129,6 +142,16 @@ body.night-mode {
background-color: #333131;
}
.night-mode .custom-select {
color: #c1c1c1;
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23c1c1c1' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;
}
.night-mode .custom-select:valid,
.night-mode .custom-select:invalid {
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23c1c1c1' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;
}
.night-mode .dropdown-item:focus,
.night-mode .dropdown-item:hover {
color: #16181b;
@ -136,7 +159,7 @@ body.night-mode {
}
.night-mode .dropdown-item {
color: #7c7b6f;
color: #c1c1c1;
background-color: #333131;
}
@ -209,7 +232,7 @@ body.night-mode {
.night-mode .navbar-sidenav > li:hover,
.night-mode .sidenav-second-level > li:hover,
.night-mode .navbar-nav .dropdown-item:hover {
box-shadow: inset 5px 0 0 #112a3f !important;
box-shadow: inset 5px 0 0 #92bee2 !important;
background-color: #383838 !important;
color: #c1c1c1 !important;
}
@ -217,7 +240,7 @@ body.night-mode {
.night-mode .navbar-sidenav > li > a:focus,
.night-mode .sidenav-second-level > li > a:focus,
.night-mode .navbar-nav .dropdown-item:focus {
box-shadow: inset 5px 0 0 #350a0f !important;
box-shadow: inset 5px 0 0 #ff7585 !important;
background-color: #383838 !important;
color: #c1c1c1 !important;
}
@ -266,14 +289,32 @@ body.night-mode {
background-color: #4c4e50;
}
.night-mode .warning-message {
background-color: #7c5e10;
color: #d1d1d1;
}
.night-mode .secondary-message {
border-top-color: #4c4e50;
background-color: #4e575f;
color: #d1d1d1;
}
.night-mode .error-message {
background-color: #780a0a;
color: #d1d1d1;
}
.night-mode .normal-message {
border-top-color: #07373f;
background-color: #2d3a8c;
color: #d1d1d1;
}
.night-mode .text-muted {
color: #8f9ba5 !important;
}
.night-mode .qr-code {
background-color: #eee;
padding: min(1.5vw, 20px);
border-radius: 10px;
}

View File

@ -202,6 +202,7 @@ function QrCodeImgHtml(text)
includetext: false
});
img.src = dummyCanvas.toDataURL("image/png");
img.classList.add("qr-code");
return img.outerHTML;
}

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()
{
$(this).closest("form").addClass("is-dirty");

View File

@ -1,5 +1,9 @@
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.CheckCapabilities = async function()
{
@ -32,7 +36,7 @@ Grocy.Components.BarcodeScanner.CheckCapabilities = async function()
Grocy.Components.BarcodeScanner.TorchOn(track);
}
// Reduce the height of the video, if it's heigher than then the viewport
// Reduce the height of the video, if it's higher than then the viewport
if (!Grocy.Components.BarcodeScanner.LiveVideoSizeAdjusted)
{
var bc = document.getElementById('barcodescanner-container');
@ -96,7 +100,8 @@ Grocy.Components.BarcodeScanner.StartScanning = function()
readers: [
"ean_reader",
"ean_8_reader",
"code_128_reader"
"code_128_reader",
"datamatrix"
],
debug: {
showCanvas: Grocy.UserSettings.quagga2_debug,

View File

@ -1,7 +1,7 @@
$(".numberpicker-down-button").unbind('click').on("click", function()
{
var inputElement = $(this).parent().parent().find('input[type="number"]');
inputElement.val(parseFloat(inputElement.val()) - 1);
inputElement.val(parseFloat(inputElement.val() || 1) - 1);
inputElement.trigger('keyup');
inputElement.trigger('change');
});
@ -9,7 +9,7 @@ $(".numberpicker-down-button").unbind('click').on("click", function()
$(".numberpicker-up-button").unbind('click').on("click", function()
{
var inputElement = $(this).parent().parent().find('input[type="number"]');
inputElement.val(parseFloat(inputElement.val()) + 1);
inputElement.val(parseFloat(inputElement.val() || 0) + 1);
inputElement.trigger('keyup');
inputElement.trigger('change');
});

View File

@ -147,7 +147,23 @@ $('#product_id_text_input').on('blur', function(e)
$('#product_id').attr("barcode", "null");
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)
{

View File

@ -223,6 +223,18 @@ $("#location_id").on('change', function(e)
{
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)
{
@ -249,6 +261,7 @@ $("#location_id").on('change', function(e)
if (stockEntry.stock_id == stockId)
{
$("#use_specific_stock_entry").click();
$("#specific_stock_entry").val(stockId);
}
}

View File

@ -301,6 +301,21 @@ $('#name').focus();
$('.input-group-qu').trigger('change');
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)
{
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)
{
var data = e.originalEvent.data;

View File

@ -23,6 +23,7 @@ $('#save-purchase-button').on('click', function(e)
{
var jsonData = {};
jsonData.amount = jsonForm.amount;
jsonData.print_stock_label = jsonForm.print_stock_label
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>';
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)
{
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();
Grocy.FrontendHelpers.ValidateForm('purchase-form');

View File

@ -162,6 +162,12 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
Grocy.Components.ProductAmountPicker.SetQuantityUnit(productDetails.default_quantity_unit_purchase.id);
}
if ($("#display_amount").val().toString().isEmpty())
{
$("#display_amount").val(1);
$("#display_amount").trigger("change");
}
$('#display_amount').focus();
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');
Grocy.ShoppingListItemFormInitialLoadDone = true;
@ -244,8 +250,8 @@ if (GetUriParam("embedded") !== undefined)
}
var eitherRequiredFields = $("#product_id,#product_id_text_input,#note");
eitherRequiredFields.prop('required',"");
eitherRequiredFields.on('input', function ()
eitherRequiredFields.prop('required', "");
eitherRequiredFields.on('input', function()
{
eitherRequiredFields.not(this).prop('required', !$(this).val().length);
Grocy.FrontendHelpers.ValidateForm('shoppinglist-form');

View File

@ -124,6 +124,21 @@ $(document).on("click", ".stock-name-cell", function(e)
$("#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)
{
Grocy.Api.Get("stock/entry/" + stockRowId,

View File

@ -101,6 +101,21 @@ $("#search").on("keyup", Delay(function()
stockOverviewTable.search(value).draw();
}, 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)
{
e.preventDefault();
@ -214,12 +229,6 @@ function RefreshStatistics()
Grocy.Api.Get('stock',
function(result)
{
var amountSum = 0;
result.forEach(element =>
{
amountSum += parseInt(element.amount);
});
if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
{
$("#info-current-stock").text(__n(result.length, '%s Product', '%s Products'));
@ -229,7 +238,7 @@ function RefreshStatistics()
var valueSum = 0;
result.forEach(element =>
{
valueSum += parseInt(element.value);
valueSum += parseFloat(element.value);
});
$("#info-current-stock").text(__n(result.length, '%s Product', '%s Products') + ", " + __t('%s total value', valueSum.toLocaleString(undefined, { style: "currency", currency: Grocy.Currency })));
}

View File

@ -126,12 +126,15 @@
Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
{
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
if ($("#use_specific_stock_entry").is(":checked"))
if ($("#use_specific_stock_entry").is(":checked") && GetUriParam("stockId") == null)
{
$("#use_specific_stock_entry").click();
}
$("#location_id_to").val("");
$("#location_id_from").val("");
if (GetUriParam("stockId") == null)
{
$("#location_id_from").val("");
}
var productId = $(e.target).val();
@ -185,6 +188,12 @@ Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
$("#location_id_from").trigger('change');
}
});
if (GetUriParam("locationId") != null)
{
$("#location_id_from").val(GetUriParam("locationId"));
$("#location_id_from").trigger("change");
}
},
function(xhr)
{
@ -284,7 +293,7 @@ $("#location_id_from").on('change', function(e)
}
$("#specific_stock_entry").find("option").remove().end().append("<option></option>");
if ($("#use_specific_stock_entry").is(":checked"))
if ($("#use_specific_stock_entry").is(":checked") && GetUriParam("stockId") == null)
{
$("#use_specific_stock_entry").click();
}
@ -475,6 +484,7 @@ if (GetUriParam("embedded") !== undefined)
$("#location_id_from").trigger('change');
$("#use_specific_stock_entry").click();
$("#use_specific_stock_entry").trigger('change');
Grocy.Components.ProductPicker.GetPicker().trigger('change');
}
}

View File

@ -1,10 +1,9 @@
<?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 Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;
$app->group('', function (RouteCollectorProxy $group) {
// System routes
@ -41,6 +40,7 @@ $app->group('', function (RouteCollectorProxy $group) {
$group->get('/quantityunitconversion/{quConversionId}', '\Grocy\Controllers\StockController:QuantityUnitConversionEditForm');
$group->get('/productgroups', '\Grocy\Controllers\StockController:ProductGroupsList');
$group->get('/productgroup/{productGroupId}', '\Grocy\Controllers\StockController:ProductGroupEditForm');
$group->get('/product/{productId}/grocycode', '\Grocy\Controllers\StockController:ProductGrocycodeImage');
// Stock handling routes
if (GROCY_FEATURE_FLAG_STOCK)
@ -60,6 +60,8 @@ $app->group('', function (RouteCollectorProxy $group) {
$group->get('/quantityunitpluraltesting', '\Grocy\Controllers\StockController:QuantityUnitPluralFormTesting');
$group->get('/stockjournal/summary', '\Grocy\Controllers\StockController:JournalSummary');
$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
@ -206,6 +208,8 @@ $app->group('/api', function (RouteCollectorProxy $group) {
$group->get('/stock/transactions/{transactionId}', '\Grocy\Controllers\StockApiController:StockTransactions');
$group->post('/stock/transactions/{transactionId}/undo', '\Grocy\Controllers\StockApiController:UndoTransaction');
$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
$group->post('/stock/shoppinglist/add-missing-products', '\Grocy\Controllers\StockApiController:AddMissingProductsToShoppingList');

View File

@ -2,6 +2,9 @@
namespace Grocy\Services;
use Grocy\Helpers\Grocycode;
use Grocy\Helpers\WebhookRunner;
class StockService extends BaseService
{
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))
{
@ -173,10 +176,36 @@ class StockService extends BaseService
'stock_id' => $stockId,
'price' => $price,
'location_id' => $locationId,
'shopping_location_id' => $shoppingLocationId,
'shopping_location_id' => $shoppingLocationId
]);
$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;
}
else
@ -240,7 +269,7 @@ class StockService extends BaseService
throw new \Exception('Location does not exist');
}
$productDetails = (object)$this->GetProductDetails($productId);
$productDetails = (object) $this->GetProductDetails($productId);
// Tare weight handling
// 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
// (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
$productStockAmount = ((object)$this->GetProductDetails($productId))->stock_amount_aggregated;
$productStockAmount = ((object) $this->GetProductDetails($productId))->stock_amount_aggregated;
if ($amount > $productStockAmount)
{
throw new \Exception('Amount to be consumed cannot be > current stock amount (if supplied, at the desired location)');
@ -520,7 +549,6 @@ class StockService extends BaseService
{
$currentStock = $this->GetCurrentStock(false);
$currentStock = FindAllObjectsInArrayByPropertyValue($currentStock, 'best_before_date', date('Y-m-d 23:59:59', strtotime("+$days days")), '<');
$currentStock = FindAllObjectsInArrayByPropertyValue($currentStock, 'due_type', 1);
if ($excludeOverdue)
{
@ -640,6 +668,13 @@ class StockService extends BaseService
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();
if ($potentialProduct === null)
@ -728,7 +763,7 @@ class StockService extends BaseService
throw new \Exception('Product does not exist or is inactive');
}
$productDetails = (object)$this->GetProductDetails($productId);
$productDetails = (object) $this->GetProductDetails($productId);
if ($price === null)
{
@ -787,7 +822,7 @@ class StockService extends BaseService
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);
$potentialStockEntries = $this->GetProductStockEntries($productId, true, $allowSubproductSubstitution);
$product = $this->getDatabase()->products($productId);
@ -1041,8 +1076,8 @@ class StockService extends BaseService
if ($productDetails->product->enable_tare_weight_handling == 1)
{
// Hard fail for now, as we not yet support transfering tare weight enabled products
throw new \Exception('Transfering tare weight enabled products is not yet possible');
// Hard fail for now, as we not yet support transferring tare weight enabled products
throw new \Exception('Transferring tare weight enabled products is not yet possible');
if ($amount < floatval($productDetails->product->tare_weight))
{
throw new \Exception('The amount cannot be lower than the defined tare weight');
@ -1056,7 +1091,7 @@ class StockService extends BaseService
if ($amount > $productStockAmountAtFromLocation)
{
throw new \Exception('Amount to be transfered cannot be > current stock amount at the source location');
throw new \Exception('Amount to be transferred cannot be > current stock amount at the source location');
}
if ($specificStockEntryId !== 'default')
@ -1187,7 +1222,7 @@ class StockService extends BaseService
'amount' => $restStockAmount
]);
// The transfered amount gets into a new stock entry
// The transferred amount gets into a new stock entry
$stockEntryNew = $this->getDatabase()->stock()->createRow([
'product_id' => $stockEntry->product_id,
'amount' => $amount,

View File

@ -6,6 +6,7 @@
@push('pageScripts')
<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
@push('pageStyles')
@ -21,8 +22,7 @@
.combobox-container #barcodescanner-start-button {
margin-right: 36px !important;
}
</style>
@endpush
@endif
@endif

View File

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

View File

@ -34,7 +34,8 @@
'min' => 0,
'isRequired' => false,
'additionalCssClasses' => 'userfield-input',
'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"'
'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"',
'value' => ''
))
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_DECIMAL_NUMBER)
@include('components.numberpicker', array(
@ -45,7 +46,8 @@
'decimals' => 4,
'isRequired' => false,
'additionalCssClasses' => 'userfield-input',
'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"'
'additionalAttributes' => 'data-userfield-name="' . $userfield->name . '"',
'value' => ''
))
@elseif($userfield->type == \Grocy\Services\UserfieldsService::USERFIELD_TYPE_DATE)
@include('components.datetimepicker', array(

View File

@ -40,7 +40,7 @@
'id' => 'purchased_date',
'label' => 'Purchased date',
'format' => 'YYYY-MM-DD',
'hint' => 'This will apply to added products',
'hint' => $__t('This will apply to added products'),
'initWithNow' => true,
'limitEndToNow' => false,
'limitStartToNow' => false,
@ -61,7 +61,7 @@
@include('components.datetimepicker', array(
'id' => 'best_before_date',
'label' => 'Due date',
'hint' => 'This will apply to added products',
'hint' => $__t('This will apply to added products'),
'format' => 'YYYY-MM-DD',
'initWithNow' => false,
'limitEndToNow' => false,

View File

@ -95,6 +95,14 @@
Grocy.CalendarShowWeekNumbers = {{ BoolToString(GROCY_CALENDAR_SHOW_WEEK_OF_YEAR) }};
Grocy.GettextPo = {!! $GettextPo !!};
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)
Grocy.UserSettings = {!! json_encode($userSettings) !!};

View File

@ -400,6 +400,57 @@
'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="custom-control custom-checkbox">
<input @if($mode=='edit'
@ -426,6 +477,7 @@
</div>
<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="col">
<div class="title-related-links">
@ -533,6 +585,34 @@
</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="col">
<div class="title-related-links">
@ -609,7 +689,7 @@
@endif
</td>
<td class="font-italic">
{!! $__t('This means 1 %1$s is the same as %2$s %3$s', FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->from_qu_id)->name, '<span class="locale-number locale-number-quantity-amount">' . $quConversion->factor . '</span>', FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name) !!}
{!! $__t('This means 1 %1$s is the same as %2$s %3$s', FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->from_qu_id)->name, '<span class="locale-number locale-number-quantity-amount">' . $quConversion->factor . '</span>', $__n($quConversion->factor, FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name, FindObjectInArrayByPropertyValue($quantityunits, 'id', $quConversion->to_qu_id)->name_plural)) !!}
</td>
</tr>
@endif

View File

@ -12,7 +12,7 @@
<script>
Grocy.QuantityUnits = {!! json_encode($quantityUnits) !!};
Grocy.QuantityUnitConversionsResolved = {!! json_encode($quantityUnitConversionsResolved) !!};
Grocy.DefaultMinAmount = '{{$DEFAULT_MIN_AMOUNT}}';
Grocy.DefaultMinAmount = '{{ $DEFAULT_MIN_AMOUNT }}';
</script>
<div class="row">
@ -148,6 +148,21 @@
))
@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"
class="btn btn-success d-block">{{ $__t('OK') }}</button>

View File

@ -311,16 +311,20 @@
<div class="row ml-1">
@if(!empty($calories) && intval($calories) > 0)
<div class="col-6 col-xl-3">
<label>{{ $__t('Energy (kcal)') }}</label>
<label>{{ $__t('Energy (kcal)') }}</label>&nbsp;
<i class="fas fa-question-circle text-muted d-print-none"
data-toggle="tooltip"
title="{{ $__t('per serving') }}"></i>
<h3 class="locale-number locale-number-generic pt-0">{{ $calories }}</h3>
</div>
@endif
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
<div class="col-5">
<label>{{ $__t('Costs') }}&nbsp;</label>
<i class="fas fa-question-circle text-muted d-print-none"
data-toggle="tooltip"
title="{{ $__t('Based on the prices of the default consume rule which is "Opened first, then first due first, then first in first out"') }}"></i>
<label>{{ $__t('Costs') }}&nbsp;
<i class="fas fa-question-circle text-muted d-print-none"
data-toggle="tooltip"
title="{{ $__t('Based on the prices of the default consume rule which is "Opened first, then first due first, then first in first out"') }}"></i>
</label>
<h3 class="locale-number locale-number-currency pt-0">{{ $costs }}</h3>
</div>
@endif

View File

@ -200,6 +200,26 @@
href="{{ $U('/product/') }}{{ $stockEntry->product_id . '?returnto=/stockentries' }}">
{{ $__t('Edit product') }}
</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>
</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' }}">
<span class="dropdown-item-text">{{ $__t('Edit product') }}</span>
</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>
</td>
@ -308,7 +322,8 @@
<td>
@if($currentStockEntry->product_group_name !== null){{ $currentStockEntry->product_group_name }}@endif
</td>
<td data-order={{ $currentStockEntry->amount }}>
<td data-order={{
$currentStockEntry->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>
<span id="product-{{ $currentStockEntry->product_id }}-opened-amount"