mirror of
https://github.com/grocy/grocy.git
synced 2026-04-05 12:26:15 +02:00
Merge branch 'master' into master
This commit is contained in:
commit
3d7b176344
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -14,4 +14,4 @@
|
|||
"php-cs-fixer.formatHtml": true,
|
||||
"php-cs-fixer.autoFixBySemicolon": true,
|
||||
"php-cs-fixer.onsave": true,
|
||||
}
|
||||
}
|
||||
|
|
@ -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".
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
66
docs/grocycode.md
Normal 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
40
docs/label-printing.md
Normal 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.
|
||||
|
|
@ -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
142
helpers/Grocycode.php
Normal 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
48
helpers/WebhookRunner.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2141,3 +2141,4 @@ msgstr ""
|
|||
|
||||
msgid "Unable to print"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
13
migrations/0128.sql
Normal 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
99
migrations/0129.sql
Normal 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
11
migrations/0130.sql
Normal 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;
|
||||
25793
public/components_unmanaged/quagga2-reader-datamatrix/index.js
Normal file
25793
public/components_unmanaged/quagga2-reader-datamatrix/index.js
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ function QrCodeImgHtml(text)
|
|||
includetext: false
|
||||
});
|
||||
img.src = dummyCanvas.toDataURL("image/png");
|
||||
img.classList.add("qr-code");
|
||||
|
||||
return img.outerHTML;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
12
routes.php
12
routes.php
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -68,4 +68,4 @@
|
|||
class="font-italic d-none">{{ $__t('No price history available') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) !!};
|
||||
|
|
|
|||
|
|
@ -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') }} <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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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') }} </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') }}
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
40
views/stockentrylabel.blade.php
Normal file
40
views/stockentrylabel.blade.php
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user