2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-27 04:56:48 +00:00

Barcode workflow (#485)

* Refactor stock barcode operations into new file

* Add setting to control confirmation of stock transfer actions

* Update details when scannign stock item

* Confirm movement when moving items into location

* Cleanup
This commit is contained in:
Oliver 2024-04-18 22:53:21 +10:00 committed by GitHub
parent a889417fe0
commit 4499f3e00e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 403 additions and 281 deletions

View File

@ -3,6 +3,7 @@
- Support "active" field for Company model
- Support "active" field for SupplierPart model
- Adjustments to barcode scanning workflow
- Updated translations
### 0.14.2 - February 2024

View File

@ -10,7 +10,6 @@ import "package:flutter/material.dart";
import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/tones.dart";
import "package:inventree/helpers.dart";
import "package:inventree/inventree/sales_order.dart";
import "package:inventree/l10.dart";
@ -342,12 +341,7 @@ class APIFormField {
controller.text = hash;
data["value"] = hash;
barcodeSuccessTone();
showSnackIcon(
L10().barcodeAssigned,
success: true
);
barcodeSuccess(L10().barcodeAssigned);
});
scanBarcode(context, handler: handler);

View File

@ -9,7 +9,6 @@ import "package:one_context/one_context.dart";
import "package:inventree/api.dart";
import "package:inventree/helpers.dart";
import "package:inventree/l10.dart";
import "package:inventree/barcode/camera_controller.dart";
@ -33,6 +32,35 @@ import "package:inventree/widget/stock/stock_detail.dart";
import "package:inventree/widget/company/supplier_part_detail.dart";
// Signal a barcode scan success to the user
Future<void> barcodeSuccess(String msg) async {
barcodeSuccessTone();
showSnackIcon(msg, success: true);
}
// Signal a barcode scan failure to the user
Future<void> barcodeFailure(String msg, dynamic extra) async {
barcodeFailureTone();
showSnackIcon(
msg,
success: false,
onAction: () {
OneContext().showDialog(
builder: (BuildContext context) => SimpleDialog(
title: Text(L10().barcodeError),
children: <Widget>[
ListTile(
title: Text(L10().responseData),
subtitle: Text(extra.toString())
)
]
)
);
}
);
}
/*
* Launch a barcode scanner with a particular context and handler.
*
@ -266,234 +294,6 @@ class BarcodeScanHandler extends BarcodeHandler {
}
/*
* Generic class for scanning a StockLocation.
*
* - Validates that the scanned barcode matches a valid StockLocation
* - Runs a "callback" function if a valid StockLocation is found
*/
class BarcodeScanStockLocationHandler extends BarcodeHandler {
@override
String getOverlayText(BuildContext context) => L10().barcodeScanLocation;
@override
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
// We expect that the barcode points to a 'stocklocation'
if (data.containsKey("stocklocation")) {
int _loc = (data["stocklocation"]["pk"] ?? -1) as int;
// A valid stock location!
if (_loc > 0) {
debug("Scanned stock location ${_loc}");
final bool result = await onLocationScanned(_loc);
if (result && OneContext.hasContext) {
OneContext().pop();
return;
}
}
}
// If we get to this point, something went wrong during the scan process
barcodeFailureTone();
showSnackIcon(
L10().invalidStockLocation,
success: false,
);
}
// Callback function which runs when a valid StockLocation is scanned
// If this function returns 'true' the barcode scanning dialog will be closed
Future<bool> onLocationScanned(int locationId) async {
// Re-implement this for particular subclass
return false;
}
}
/*
* Generic class for scanning a StockItem
*
* - Validates that the scanned barcode matches a valid StockItem
* - Runs a "callback" function if a valid StockItem is found
*/
class BarcodeScanStockItemHandler extends BarcodeHandler {
@override
String getOverlayText(BuildContext context) => L10().barcodeScanItem;
@override
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
// We expect that the barcode points to a 'stockitem'
if (data.containsKey("stockitem")) {
int _item = (data["stockitem"]["pk"] ?? -1) as int;
// A valid stock location!
if (_item > 0) {
barcodeSuccessTone();
bool result = await onItemScanned(_item);
if (result && OneContext.hasContext) {
OneContext().pop();
return;
}
}
}
// If we get to this point, something went wrong during the scan process
barcodeFailureTone();
showSnackIcon(
L10().invalidStockItem,
success: false,
);
}
// Callback function which runs when a valid StockItem is scanned
Future<bool> onItemScanned(int itemId) async {
// Re-implement this for particular subclass
return false;
}
}
/*
* Barcode handler for scanning a provided StockItem into a scanned StockLocation.
*
* - The class is initialized by passing a valid StockItem object
* - Expects to scan barcode for a StockLocation
* - The StockItem is transferred into the scanned location
*/
class StockItemScanIntoLocationHandler extends BarcodeScanStockLocationHandler {
StockItemScanIntoLocationHandler(this.item);
final InvenTreeStockItem item;
@override
Future<bool> onLocationScanned(int locationId) async {
final result = await item.transferStock(locationId);
if (result) {
barcodeSuccessTone();
showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true);
} else {
barcodeFailureTone();
showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false);
}
return result;
}
}
/*
* Barcode handler for scanning stock item(s) into the specified StockLocation.
*
* - The class is initialized by passing a valid StockLocation object
* - Expects to scan a barcode for a StockItem
* - The scanned StockItem is transferred into the provided StockLocation
*/
class StockLocationScanInItemsHandler extends BarcodeScanStockItemHandler {
StockLocationScanInItemsHandler(this.location);
final InvenTreeStockLocation location;
@override
String getOverlayText(BuildContext context) => L10().barcodeScanItem;
@override
Future<bool> onItemScanned(int itemId) async {
final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?;
bool result = false;
if (item != null) {
// Item is already *in* the specified location
if (item.locationId == location.pk) {
barcodeFailureTone();
showSnackIcon(L10().itemInLocation, success: true);
return false;
} else {
result = await item.transferStock(location.pk);
}
}
showSnackIcon(
result ? L10().barcodeScanIntoLocationSuccess : L10().barcodeScanIntoLocationFailure,
success: result
);
// We always return false here, to ensure the barcode scan dialog remains open
return false;
}
}
/*
* Barcode handler class for scanning a StockLocation into another StockLocation
*
* - The class is initialized by passing a valid StockLocation object
* - Expects to scan barcode for another *parent* StockLocation
* - The scanned StockLocation is set as the "parent" of the provided StockLocation
*/
class ScanParentLocationHandler extends BarcodeScanStockLocationHandler {
ScanParentLocationHandler(this.location);
final InvenTreeStockLocation location;
@override
Future<bool> onLocationScanned(int locationId) async {
final response = await location.update(
values: {
"parent": locationId.toString(),
},
expectedStatusCode: null,
);
switch (response.statusCode) {
case 200:
case 201:
barcodeSuccessTone();
showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true);
return true;
case 400: // Invalid parent location chosen
barcodeFailureTone();
showSnackIcon(L10().invalidStockLocation, success: false);
return false;
default:
barcodeFailureTone();
showSnackIcon(
L10().barcodeScanIntoLocationFailure,
success: false,
actionText: L10().details,
onAction: () {
showErrorDialog(
L10().barcodeError,
response: response,
);
}
);
return false;
}
}
}
/*
* Barcode handler for finding a "unique" barcode (one that does not match an item in the database)
*/

View File

@ -6,6 +6,7 @@ import "package:one_context/one_context.dart";
import "package:inventree/l10.dart";
import "package:inventree/api_form.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/handler.dart";
import "package:inventree/barcode/tones.dart";
@ -51,8 +52,7 @@ class POReceiveBarcodeHandler extends BarcodeHandler {
return onBarcodeUnknown(data);
}
barcodeSuccessTone();
showSnackIcon(L10().receivedItem, success: true);
barcodeSuccess(L10().receivedItem);
}
@override

View File

@ -8,6 +8,7 @@ import "package:one_context/one_context.dart";
import "package:inventree/l10.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/handler.dart";
import "package:inventree/barcode/tones.dart";
@ -115,8 +116,7 @@ class SOAllocateStockHandler extends BarcodeHandler {
return onBarcodeUnknown(data);
}
barcodeSuccessTone();
showSnackIcon(L10().allocated, success: true);
barcodeSuccess(L10().allocated);
}
@override

292
lib/barcode/stock.dart Normal file
View File

@ -0,0 +1,292 @@
import "package:flutter/cupertino.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/api_form.dart";
import "package:inventree/preferences.dart";
import "package:one_context/one_context.dart";
import "package:inventree/helpers.dart";
import "package:inventree/l10.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/handler.dart";
import "package:inventree/barcode/tones.dart";
import "package:inventree/inventree/stock.dart";
import "package:inventree/widget/dialogs.dart";
import "package:inventree/widget/snacks.dart";
/*
* Generic class for scanning a StockLocation.
*
* - Validates that the scanned barcode matches a valid StockLocation
* - Runs a "callback" function if a valid StockLocation is found
*/
class BarcodeScanStockLocationHandler extends BarcodeHandler {
@override
String getOverlayText(BuildContext context) => L10().barcodeScanLocation;
@override
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
// We expect that the barcode points to a 'stocklocation'
if (data.containsKey("stocklocation")) {
int _loc = (data["stocklocation"]["pk"] ?? -1) as int;
// A valid stock location!
if (_loc > 0) {
debug("Scanned stock location ${_loc}");
final bool result = await onLocationScanned(_loc);
if (result && OneContext.hasContext) {
OneContext().pop();
return;
}
}
}
// If we get to this point, something went wrong during the scan process
barcodeFailureTone();
showSnackIcon(
L10().invalidStockLocation,
success: false,
);
}
// Callback function which runs when a valid StockLocation is scanned
// If this function returns 'true' the barcode scanning dialog will be closed
Future<bool> onLocationScanned(int locationId) async {
// Re-implement this for particular subclass
return false;
}
}
/*
* Generic class for scanning a StockItem
*
* - Validates that the scanned barcode matches a valid StockItem
* - Runs a "callback" function if a valid StockItem is found
*/
class BarcodeScanStockItemHandler extends BarcodeHandler {
@override
String getOverlayText(BuildContext context) => L10().barcodeScanItem;
@override
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
// We expect that the barcode points to a 'stockitem'
if (data.containsKey("stockitem")) {
int _item = (data["stockitem"]["pk"] ?? -1) as int;
// A valid stock location!
if (_item > 0) {
barcodeSuccessTone();
bool result = await onItemScanned(_item);
if (result && OneContext.hasContext) {
OneContext().pop();
return;
}
}
}
// If we get to this point, something went wrong during the scan process
barcodeFailureTone();
showSnackIcon(
L10().invalidStockItem,
success: false,
);
}
// Callback function which runs when a valid StockItem is scanned
Future<bool> onItemScanned(int itemId) async {
// Re-implement this for particular subclass
return false;
}
}
/*
* Barcode handler for scanning a provided StockItem into a scanned StockLocation.
*
* - The class is initialized by passing a valid StockItem object
* - Expects to scan barcode for a StockLocation
* - The StockItem is transferred into the scanned location
*/
class StockItemScanIntoLocationHandler extends BarcodeScanStockLocationHandler {
StockItemScanIntoLocationHandler(this.item);
final InvenTreeStockItem item;
@override
Future<bool> onLocationScanned(int locationId) async {
final bool confirm = await InvenTreeSettingsManager().getBool(INV_STOCK_CONFIRM_SCAN, false);
bool result = false;
if (confirm) {
Map<String, dynamic> fields = item.transferFields();
// Override location with scanned value
fields["location"]?["value"] = locationId;
launchApiForm(
OneContext().context!,
L10().transferStock,
InvenTreeStockItem.transferStockUrl(),
fields,
method: "POST",
icon: FontAwesomeIcons.dolly,
onSuccess: (data) async {
showSnackIcon(L10().stockItemUpdated, success: true);
}
);
return true;
} else {
result = await item.transferStock(locationId);
}
if (result) {
barcodeSuccess(L10().barcodeScanIntoLocationSuccess);
} else {
barcodeFailureTone();
showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false);
}
return result;
}
}
/*
* Barcode handler for scanning stock item(s) into the specified StockLocation.
*
* - The class is initialized by passing a valid StockLocation object
* - Expects to scan a barcode for a StockItem
* - The scanned StockItem is transferred into the provided StockLocation
*/
class StockLocationScanInItemsHandler extends BarcodeScanStockItemHandler {
StockLocationScanInItemsHandler(this.location);
final InvenTreeStockLocation location;
@override
String getOverlayText(BuildContext context) => L10().barcodeScanItem;
@override
Future<bool> onItemScanned(int itemId) async {
final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?;
final bool confirm = await InvenTreeSettingsManager().getBool(INV_STOCK_CONFIRM_SCAN, false);
bool result = false;
if (item != null) {
// Item is already *in* the specified location
if (item.locationId == location.pk) {
barcodeFailureTone();
showSnackIcon(L10().itemInLocation, success: true);
return false;
} else {
if (confirm) {
Map<String, dynamic> fields = item.transferFields();
// Override location with provided location value
fields["location"]?["value"] = location.pk;
launchApiForm(
OneContext().context!,
L10().transferStock,
InvenTreeStockItem.transferStockUrl(),
fields,
method: "POST",
icon: FontAwesomeIcons.dolly,
onSuccess: (data) async {
showSnackIcon(L10().stockItemUpdated, success: true);
}
);
return true;
} else {
result = await item.transferStock(location.pk);
showSnackIcon(
result ? L10().barcodeScanIntoLocationSuccess : L10().barcodeScanIntoLocationFailure,
success: result
);
}
}
}
// We always return false here, to ensure the barcode scan dialog remains open
return false;
}
}
/*
* Barcode handler class for scanning a StockLocation into another StockLocation
*
* - The class is initialized by passing a valid StockLocation object
* - Expects to scan barcode for another *parent* StockLocation
* - The scanned StockLocation is set as the "parent" of the provided StockLocation
*/
class ScanParentLocationHandler extends BarcodeScanStockLocationHandler {
ScanParentLocationHandler(this.location);
final InvenTreeStockLocation location;
@override
Future<bool> onLocationScanned(int locationId) async {
final response = await location.update(
values: {
"parent": locationId.toString(),
},
expectedStatusCode: null,
);
switch (response.statusCode) {
case 200:
case 201:
barcodeSuccess(L10().barcodeScanIntoLocationSuccess);
return true;
case 400: // Invalid parent location chosen
barcodeFailureTone();
showSnackIcon(L10().invalidStockLocation, success: false);
return false;
default:
barcodeFailureTone();
showSnackIcon(
L10().barcodeScanIntoLocationFailure,
success: false,
actionText: L10().details,
onAction: () {
showErrorDialog(
L10().barcodeError,
response: response,
);
}
);
return false;
}
}
}

View File

@ -145,6 +145,50 @@ class InvenTreeStockItem extends InvenTreeModel {
@override
List<String> get rolesRequired => ["stock"];
// Return a set of fields to transfer this stock item via dialog
Map<String, dynamic> transferFields() {
Map<String, dynamic> fields = {
"pk": {
"parent": "items",
"nested": true,
"hidden": true,
"value": pk,
},
"quantity": {
"parent": "items",
"nested": true,
"value": quantity,
},
"location": {
"value": locationId,
},
"status": {
"parent": "items",
"nested": true,
"value": status,
},
"packaging": {
"parent": "items",
"nested": true,
"value": packaging,
},
"notes": {},
};
if (isSerialized()) {
// Prevent editing of 'quantity' field if the item is serialized
fields["quantity"]["hidden"] = true;
}
// Old API does not support these fields
if (!api.supportsStockAdjustExtraFields) {
fields.remove("packaging");
fields.remove("status");
}
return fields;
}
// URLs for performing stock actions
static String transferStockUrl() => "stock/transfer/";

View File

@ -240,6 +240,12 @@
"configureServer": "Configure server settings",
"@configureServer": {},
"confirmScan": "Confirm Transfer",
"@confirmScan": {},
"confirmScanDetail": "Confirm stock transfer details when scanning barcodes",
"@confirmScan": {},
"connectionRefused": "Connection Refused",
"@connectionRefused": {},

View File

@ -35,6 +35,7 @@ const String INV_PART_SHOW_BOM = "partShowBom";
// Stock settings
const String INV_STOCK_SHOW_HISTORY = "stockShowHistory";
const String INV_STOCK_SHOW_TESTS = "stockShowTests";
const String INV_STOCK_CONFIRM_SCAN = "stockConfirmScan";
const String INV_REPORT_ERRORS = "reportErrors";
const String INV_STRICT_HTTPS = "strictHttps";

View File

@ -110,7 +110,7 @@ class _InvenTreeBarcodeSettingsState extends State<InvenTreeBarcodeSettingsWidge
}
return Scaffold(
appBar: AppBar(title: Text(L10().barcodes)),
appBar: AppBar(title: Text(L10().barcodeSettings)),
body: Container(
child: ListView(
children: [

View File

@ -19,6 +19,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
bool partShowBom = true;
bool stockShowHistory = false;
bool stockShowTests = false;
bool stockConfirmScan = false;
@override
void initState() {
@ -32,6 +33,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
partShowBom = await InvenTreeSettingsManager().getValue(INV_PART_SHOW_BOM, true) as bool;
stockShowHistory = await InvenTreeSettingsManager().getValue(INV_STOCK_SHOW_HISTORY, false) as bool;
stockShowTests = await InvenTreeSettingsManager().getValue(INV_STOCK_SHOW_TESTS, true) as bool;
stockConfirmScan = await InvenTreeSettingsManager().getValue(INV_STOCK_CONFIRM_SCAN, false) as bool;
if (mounted) {
setState(() {
@ -42,7 +44,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(L10().part)),
appBar: AppBar(title: Text(L10().partSettings)),
body: Container(
child: ListView(
children: [
@ -74,6 +76,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
},
),
),
Divider(),
ListTile(
title: Text(L10().stockItemHistory),
subtitle: Text(L10().stockItemHistoryDetail),
@ -101,6 +104,20 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
});
},
),
),
ListTile(
title: Text(L10().confirmScan),
subtitle: Text(L10().confirmScanDetail),
leading: FaIcon(FontAwesomeIcons.qrcode),
trailing: Switch(
value: stockConfirmScan,
onChanged: (bool value) {
InvenTreeSettingsManager().setValue(INV_STOCK_CONFIRM_SCAN, value);
setState(() {
stockConfirmScan = value;
});
}
),
)
]
)

View File

@ -6,6 +6,7 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/purchase_order.dart";
import "package:inventree/barcode/stock.dart";
import "package:inventree/l10.dart";
import "package:inventree/inventree/stock.dart";

View File

@ -5,6 +5,7 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/stock.dart";
import "package:inventree/helpers.dart";
import "package:inventree/l10.dart";
import "package:inventree/api.dart";
@ -436,44 +437,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
*/
Future <void> _transferStockDialog(BuildContext context) async {
Map<String, dynamic> fields = {
"pk": {
"parent": "items",
"nested": true,
"hidden": true,
"value": widget.item.pk,
},
"quantity": {
"parent": "items",
"nested": true,
"value": widget.item.quantity,
},
"location": {
"value": widget.item.locationId,
},
"status": {
"parent": "items",
"nested": true,
"value": widget.item.status,
},
"packaging": {
"parent": "items",
"nested": true,
"value": widget.item.packaging,
},
"notes": {},
};
if (widget.item.isSerialized()) {
// Prevent editing of 'quantity' field if the item is serialized
fields["quantity"]["hidden"] = true;
}
// Old API does not support these fields
if (!api.supportsStockAdjustExtraFields) {
fields.remove("packaging");
fields.remove("status");
}
Map<String, dynamic> fields = widget.item.transferFields();
launchApiForm(
context,

View File

@ -8,9 +8,11 @@
import "package:flutter_test/flutter_test.dart";
import "package:inventree/api.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/helpers.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/stock.dart";
import "package:inventree/inventree/part.dart";
import "package:inventree/inventree/stock.dart";