Finalized frontend external barcode lookup implementation (references #158)

This commit is contained in:
Bernd Bestel
2025-01-12 13:58:47 +01:00
parent c9ffe4885d
commit c73be7d18e
17 changed files with 160 additions and 59 deletions

View File

@@ -129,11 +129,11 @@ Example: Button "**P** Add as new product" can be "pressed" by using the `P` key
Products can be directly added to the database via looking them up against external services by a barcode.
This can be done in-place using the product picker workflow "External barcode lookup (via plugin)" (the workflow dialog is displayed when entering something unknown in any product input field).
This can be done in-place using the product picker workflow "External barcode lookup" (the workflow dialog is displayed when entering something unknown in any product input field).
A plugin for [Open Food Facts](https://world.openfoodfacts.org/) is included and used by default (see the `data/config.php` option `STOCK_BARCODE_LOOKUP_PLUGIN`).
See that plugin or the reference implementation in `data/plugins/DemoBarcodeLookupPlugin.php` if you want to build a plugin.
See that plugin or `plugins/DemoBarcodeLookupPlugin.php` for a commented example implementation if you want to build a plugin.
### Database migrations

View File

@@ -9,7 +9,7 @@
- Implemented using [QuaggaJS](https://github.com/serratus/quaggaJS) - camera stream processing happens totally offline / client-side
- Please note due to browser security restrictions, this only works when serving Grocy via a secure connection (`https://`)
- There is also a `config.php` setting `DISABLE_BROWSER_BARCODE_CAMERA_SCANNING` to disable this, if you don't need it at all (defaults to `false`)
- I you have problems that barcodes are not recognized properly, there is a little "barcode scanner testing page" at [/barcodescannertesting](https://demo.grocy.info/barcodescannertesting)
- If you have problems that barcodes are not recognized properly, there is a little "barcode scanner testing page" at [/barcodescannertesting](https://demo.grocy.info/barcodescannertesting)
- => Quick video demo: https://www.youtube.com/watch?v=Y5YH6IJFnfc
### Stock improvements/fixes

View File

@@ -10,7 +10,7 @@
### Stock
- Added a new product picker workflow "External barcode lookup (via plugin)"
- Added a new product picker workflow "External barcode lookup"
- This executes the configured barcode lookup plugin with the given barcode
- If the lookup was successful, the product edit page of the created product is displayed, where the product setup can be completed (if required)
- After that, the transaction is continued with that product as usual

View File

@@ -64,8 +64,9 @@ Setting('BASE_PATH', '');
Setting('BASE_URL', '/');
// The plugin to use for external barcode lookups,
// must be the filename (folder /data/plugins) without the .php extension,
// see /data/plugins/DemoBarcodeLookupPlugin.php for an example implementation
// must be the filename (folder "/plugins" for built-in plugins or "/data/plugins" for user plugins) without the .php extension,
// see /plugins/DemoBarcodeLookupPlugin.php for a commented example implementation
// Leave empty to disable external barcode lookups
Setting('STOCK_BARCODE_LOOKUP_PLUGIN', 'OpenFoodFactsBarcodeLookupPlugin');
// If, however, your webserver does not support URL rewriting, set this to true

View File

@@ -29,7 +29,7 @@ class BaseController
}
protected $AppContainer;
private $View;
protected $View;
protected function getApiKeyService()
{

View File

@@ -4,6 +4,7 @@ namespace Grocy\Controllers;
use Grocy\Helpers\Grocycode;
use Grocy\Services\RecipesService;
use DI\Container;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -11,6 +12,24 @@ class StockController extends BaseController
{
use GrocycodeTrait;
public function __construct(Container $container)
{
parent::__construct($container);
try
{
$externalBarcodeLookupPluginName = $this->getStockService()->GetExternalBarcodeLookupPluginName();
}
catch (\Exception)
{
$externalBarcodeLookupPluginName = '';
}
finally
{
$this->View->set('ExternalBarcodeLookupPluginName', $externalBarcodeLookupPluginName);
}
}
public function Consume(Request $request, Response $response, array $args)
{
return $this->renderPage($response, 'consume', [

View File

@@ -1,3 +1,2 @@
*
!.gitignore
!DemoBarcodeLookupPlugin.php

View File

@@ -4,14 +4,19 @@ namespace Grocy\Helpers;
abstract class BaseBarcodeLookupPlugin
{
final public function __construct($locations, $quantityUnits)
// That's a "self-referencing constant" and forces the child class to define it
public const PLUGIN_NAME = self::PLUGIN_NAME;
final public function __construct($locations, $quantityUnits, $userSettings)
{
$this->Locations = $locations;
$this->QuantityUnits = $quantityUnits;
$this->UserSettings = $userSettings;
}
protected $Locations;
protected $QuantityUnits;
protected $UserSettings;
final public function Lookup($barcode)
{
@@ -40,8 +45,8 @@ abstract class BaseBarcodeLookupPlugin
'location_id',
'qu_id_purchase',
'qu_id_stock',
'qu_factor_purchase_to_stock',
'barcode'
'__qu_factor_purchase_to_stock',
'__barcode'
];
foreach ($minimunNeededProperties as $prop)
@@ -58,25 +63,25 @@ abstract class BaseBarcodeLookupPlugin
$locationId = $pluginOutput['location_id'];
if (FindObjectInArrayByPropertyValue($this->Locations, 'id', $locationId) === null)
{
throw new \Exception("Location $locationId is not a valid location id");
throw new \Exception("Provided location_id ($locationId) is not a valid location id");
}
$quIdPurchase = $pluginOutput['qu_id_purchase'];
if (FindObjectInArrayByPropertyValue($this->QuantityUnits, 'id', $quIdPurchase) === null)
{
throw new \Exception("Location $quIdPurchase is not a valid quantity unit id");
throw new \Exception("Provided qu_id_purchase ($quIdPurchase) is not a valid quantity unit id");
}
$quIdStock = $pluginOutput['qu_id_stock'];
if (FindObjectInArrayByPropertyValue($this->QuantityUnits, 'id', $quIdStock) === null)
{
throw new \Exception("Location $quIdStock is not a valid quantity unit id");
throw new \Exception("Provided qu_id_stock ($quIdStock) is not a valid quantity unit id");
}
$quFactor = $pluginOutput['qu_factor_purchase_to_stock'];
$quFactor = $pluginOutput['__qu_factor_purchase_to_stock'];
if (empty($quFactor) || !is_numeric($quFactor))
{
throw new \Exception('Quantity unit factor is empty or not a number');
throw new \Exception('Provided __qu_factor_purchase_to_stock is empty or not a number');
}
return $pluginOutput;

View File

@@ -2424,7 +2424,7 @@ msgid_plural "This means %1$s labels will be printed"
msgstr[0] ""
msgstr[1] ""
msgid "External barcode lookup (via plugin)"
msgid "External barcode lookup"
msgstr ""
msgid "Error while executing the barcode lookup plugin"

10
migrations/0240.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
// This is executed inside DatabaseMigrationService class/context
// This is now a built-in plugin
$filePath = GROCY_DATAPATH . '/plugins/DemoBarcodeLookupPlugin.php';
if (file_exists($filePath))
{
unlink($filePath);
}

View File

@@ -17,7 +17,7 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
Call the API function at /api/stock/barcodes/external-lookup/{barcode}
Or use the product picker workflow "External barcode lookup (via plugin)"
Or use the product picker workflow "External barcode lookup"
When you also add ?add=true as a query parameter to the API call,
on a successful lookup the product is added to the database and in the output
@@ -29,6 +29,7 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
$this->Locations contains all locations
$this->QuantityUnits contains all quantity units
$this->UserSettings contains all user settings
*/
/*
@@ -41,12 +42,14 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
$location = FindObjectInArrayByPropertyValue($this->Locations, 'name', 'Fridge');
*/
// Provide a name
public const PLUGIN_NAME = 'Demo';
/*
This class must implement the protected abstract function ExecuteLookup($barcode),
which is called with the barcode that needs to be looked up and must return an
associative array of the product model or null, when nothing was found for the barcode
The returned array must be a valid product object (see the "products" database table for all available properties/columns):
associative array of the product model (see the "products" database table for all available properties/columns)
or null when nothing was found for the barcode:
[
// Required properties:
'name' => '',
@@ -54,12 +57,12 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
'qu_id_purchase' => 1, // A valid id of a quantity unit object, check against $this->QuantityUnits
'qu_id_stock' => 1, // A valid id of a quantity unit object, check against $this->QuantityUnits
// These are virtual properties (not part of the product object, will be automatically handled as needed)
'qu_factor_purchase_to_stock' => 1, // Normally 1 when quantity unit stock and purchase is the same
'barcode' => $barcode // The barcode of the product, maybe just pass through $barcode or manipulate it if necessary
// Required virtual properties (not part of the product object, will be automatically handled as needed):
'__qu_factor_purchase_to_stock' => 1, // Normally 1 when quantity unit stock and purchase is the same
'__barcode' => $barcode // The barcode of the product, maybe just pass through $barcode or manipulate it if necessary
// Optional virtual properties
'image_url' => '' // When provided, the corresponding image will be downloaded and set as the product picture
// Optional virtual properties (not part of the product object, will be automatically handled as needed):
'__image_url' => '' // When provided, the corresponding image will be downloaded and set as the product picture
]
*/
protected function ExecuteLookup($barcode)
@@ -76,13 +79,27 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
}
else
{
// Take the preset user setting or otherwise simply the first existing location
$locationId = $this->Locations[0]->id;
if ($this->UserSettings['product_presets_location_id'] != -1)
{
$locationId = $this->UserSettings['product_presets_location_id'];
}
// Take the preset user setting or otherwise simply the first existing quantity unit
$quId = $this->QuantityUnits[0]->id;
if ($this->UserSettings['product_presets_qu_id'] != -1)
{
$quId = $this->UserSettings['product_presets_qu_id'];
}
return [
'name' => 'LookedUpProduct_' . RandomString(5),
'location_id' => $this->Locations[0]->id, // Take the first location as a default
'qu_id_purchase' => $this->QuantityUnits[0]->id, // Take the first QU as a default
'qu_id_stock' => $this->QuantityUnits[0]->id, // Take the first QU as a default
'qu_factor_purchase_to_stock' => 1,
'barcode' => $barcode
'location_id' => $locationId,
'qu_id_purchase' => $quId,
'qu_id_stock' => $quId,
'__qu_factor_purchase_to_stock' => 1,
'__barcode' => $barcode
];
}
}

View File

@@ -10,10 +10,12 @@ use GuzzleHttp\Client;
class OpenFoodFactsBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
{
public const PLUGIN_NAME = 'Open Food Facts';
protected function ExecuteLookup($barcode)
{
$webClient = new Client(['http_errors' => false]);
$response = $webClient->request('GET', "https://world.openfoodfacts.net/api/v2/product/$barcode?fields=product_name,image_url");
$response = $webClient->request('GET', "https://world.openfoodfacts.net/api/v2/product/$barcode?fields=product_name,image_url", ['headers' => ['User-Agent' => 'GrocyOpenFoodFactsBarcodeLookupPlugin/1.0 (https://grocy.info)']]);
$statusCode = $response->getStatusCode();
// Guzzle throws exceptions for connection errors, so nothing to do on that here
@@ -32,14 +34,28 @@ class OpenFoodFactsBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
$imageUrl = $data->product->image_url;
}
// Take the preset user setting or otherwise simply the first existing location
$locationId = $this->Locations[0]->id;
if ($this->UserSettings['product_presets_location_id'] != -1)
{
$locationId = $this->UserSettings['product_presets_location_id'];
}
// Take the preset user setting or otherwise simply the first existing quantity unit
$quId = $this->QuantityUnits[0]->id;
if ($this->UserSettings['product_presets_qu_id'] != -1)
{
$quId = $this->UserSettings['product_presets_qu_id'];
}
return [
'name' => $data->product->product_name,
'location_id' => $this->Locations[0]->id,
'qu_id_purchase' => $this->QuantityUnits[0]->id,
'qu_id_stock' => $this->QuantityUnits[0]->id,
'qu_factor_purchase_to_stock' => 1,
'barcode' => $barcode,
'image_url' => $imageUrl
'location_id' => $locationId,
'qu_id_purchase' => $quId,
'qu_id_stock' => $quId,
'__qu_factor_purchase_to_stock' => 1,
'__barcode' => $barcode,
'__image_url' => $imageUrl
];
}
}

View File

@@ -250,9 +250,13 @@ $('#product_id_text_input').on('blur', function(e)
Grocy.Components.ProductPicker.PopupOpen = false;
window.location.href = U('/product/new?flow=InplaceNewProductWithBarcode&barcode=' + encodeURIComponent(input) + '&returnto=' + encodeURIComponent(Grocy.CurrentUrlRelative + "?flow=InplaceAddBarcodeToExistingProduct&barcode=" + input + "&" + embedded) + "&" + embedded);
}
},
barcodepluginlookup: {
label: '<strong>E</strong> ' + __t('External barcode lookup (via plugin)'),
}
};
if (Grocy.ExternalBarcodeLookupPluginName)
{
buttons.barcodepluginlookup = {
label: '<strong>E</strong> ' + __t('External barcode lookup') + ' <span class="badge badge-pill badge-light">' + Grocy.ExternalBarcodeLookupPluginName + '</span>',
className: 'btn-dark add-new-product-plugin-dialog-button responsive-button ' + addProductWorkflowsAdditionalCssClasses,
callback: function()
{
@@ -279,8 +283,8 @@ $('#product_id_text_input').on('blur', function(e)
}
);
}
}
};
};
}
if (!Grocy.FeatureFlags.GROCY_FEATURE_FLAG_DISABLE_BROWSER_BARCODE_CAMERA_SCANNING)
{
@@ -332,7 +336,8 @@ $('#product_id_text_input').on('blur', function(e)
size: 'large',
backdrop: true,
closeButton: false,
buttons: buttons
buttons: buttons,
className: "wider"
}).on('keypress', function(e)
{
if (e.key === 'B' || e.key === 'b')

View File

@@ -71,4 +71,9 @@ class BaseService
{
return FilesService::getInstance();
}
protected function getApplicationService()
{
return ApplicationService::getInstance();
}
}

View File

@@ -220,6 +220,9 @@ class DemoDataGeneratorService extends BaseService
INSERT INTO userfield_values (field_id, object_id, value) VALUES (1, 2, '{$this->__t_sql('Example field value...')}');
INSERT INTO userfield_values (field_id, object_id, value) VALUES (2, 2, '{$this->__t_sql('Example field value...')}');
INSERT INTO user_settings(user_id, key, value) VALUES (1, 'product_presets_location_id', '3'); -- Pantry
INSERT INTO user_settings(user_id, key, value) VALUES (1, 'product_presets_qu_id', '3'); -- Pack
INSERT INTO migrations (migration) VALUES (-1);
";
$this->getDatabaseService()->ExecuteDbStatement($sql);

View File

@@ -592,9 +592,15 @@ class StockService extends BaseService
return $transactionId;
}
public function GetExternalBarcodeLookupPluginName()
{
$plugin = $this->LoadExternalBarcodeLookupPlugin();
return $plugin::PLUGIN_NAME;
}
public function ExternalBarcodeLookup($barcode, $addFoundProduct)
{
$plugin = $this->LoadBarcodeLookupPlugin();
$plugin = $this->LoadExternalBarcodeLookupPlugin();
$pluginOutput = $plugin->Lookup($barcode);
if ($pluginOutput !== null)
@@ -602,21 +608,24 @@ class StockService extends BaseService
// Lookup was successful
if ($addFoundProduct === true)
{
if ($this->getDatabase()->products()->where('name = :1', $pluginOutput['name'])->fetch() !== null)
{
throw new \Exception('Product "' . $pluginOutput['name'] . '" already exists');
}
// Add product to database and include new product id in output
$productData = $pluginOutput;
unset($productData['barcode'], $productData['qu_factor_purchase_to_stock'], $productData['image_url']); // Virtual lookup plugin properties
unset($productData['__barcode'], $productData['__qu_factor_purchase_to_stock'], $productData['__image_url']); // Virtual lookup plugin properties
// Download and save image if provided
if (isset($pluginOutput['image_url']) && !empty($pluginOutput['image_url']))
if (isset($pluginOutput['__image_url']) && !empty($pluginOutput['__image_url']))
{
try
{
$webClient = new Client();
$response = $webClient->request('GET', $pluginOutput['image_url']);
$fileName = $pluginOutput['barcode'] . '.' . pathinfo($pluginOutput['image_url'], PATHINFO_EXTENSION);
$fileHandle = fopen($this->getFilesService()->GetFilePath('productpictures', $fileName), 'wb');
fwrite($fileHandle, $response->getBody());
fclose($fileHandle);
$response = $webClient->request('GET', $pluginOutput['__image_url'], ['headers' => ['User-Agent' => 'Grocy/' . $this->getApplicationService()->GetInstalledVersion()->Version . ' (https://grocy.info)']]);
$fileName = $pluginOutput['__barcode'] . '.' . pathinfo($pluginOutput['__image_url'], PATHINFO_EXTENSION);
file_put_contents($this->getFilesService()->GetFilePath('productpictures', $fileName), $response->getBody());
$productData['picture_file_name'] = $fileName;
}
catch (\Exception)
@@ -630,7 +639,7 @@ class StockService extends BaseService
$this->getDatabase()->product_barcodes()->createRow([
'product_id' => $newProductRow->id,
'barcode' => $pluginOutput['barcode']
'barcode' => $pluginOutput['__barcode']
])->save();
if ($pluginOutput['qu_id_stock'] != $pluginOutput['qu_id_purchase'])
@@ -639,7 +648,7 @@ class StockService extends BaseService
'product_id' => $newProductRow->id,
'from_qu_id' => $pluginOutput['qu_id_purchase'],
'to_qu_id' => $pluginOutput['qu_id_stock'],
'factor' => $pluginOutput['qu_factor_purchase_to_stock'],
'factor' => $pluginOutput['__qu_factor_purchase_to_stock'],
])->save();
}
@@ -1730,7 +1739,7 @@ class StockService extends BaseService
}
}
private function LoadBarcodeLookupPlugin()
private function LoadExternalBarcodeLookupPlugin()
{
$pluginName = defined('GROCY_STOCK_BARCODE_LOOKUP_PLUGIN') ? GROCY_STOCK_BARCODE_LOOKUP_PLUGIN : '';
if (empty($pluginName))
@@ -1738,11 +1747,18 @@ class StockService extends BaseService
throw new \Exception('No barcode lookup plugin defined');
}
$path = GROCY_DATAPATH . "/plugins/$pluginName.php";
if (file_exists($path))
// User plugins take precedence
$standardPluginPath = __DIR__ . "/../plugins/$pluginName.php";
$userPluginPath = GROCY_DATAPATH . "/plugins/$pluginName.php";
if (file_exists($userPluginPath))
{
require_once $path;
return new $pluginName($this->getDatabase()->locations()->where('active = 1')->fetchAll(), $this->getDatabase()->quantity_units()->fetchAll());
require_once $userPluginPath;
return new $pluginName($this->getDatabase()->locations()->where('active = 1')->fetchAll(), $this->getDatabase()->quantity_units()->where('active = 1')->fetchAll(), $this->getUsersService()->GetUserSettings(GROCY_USER_ID));
}
elseif (file_exists($standardPluginPath))
{
require_once $standardPluginPath;
return new $pluginName($this->getDatabase()->locations()->where('active = 1')->fetchAll(), $this->getDatabase()->quantity_units()->where('active = 1')->fetchAll(), $this->getUsersService()->GetUserSettings(GROCY_USER_ID));
}
else
{

View File

@@ -4,6 +4,11 @@
@push('componentScripts')
<script src="{{ $U('/viewjs/components/productpicker.js', true) }}?v={{ $version }}"></script>
@endpush
@push('componentScripts')
<script>
Grocy.ExternalBarcodeLookupPluginName = "{{ $ExternalBarcodeLookupPluginName }}";
</script>
@endpush
@endonce
@php if(empty($disallowAddProductWorkflows)) { $disallowAddProductWorkflows = false; } @endphp