2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-28 05:26:47 +00:00

Stock location scan (#169)

* Add action for scanning a stock location into another location

* Adds barcode scan handler for new functionality

* Handle scanning of stock location

* Cleanup

* Refactor existing barcode scanning functions

- Will require extensive testing and validation

* Add entry to release notes

* Delete dead code

* Improved ordering based on stock quantity

* Bug fix for 'adjustStock' function

* Improve error responses for barcode scanning

* Improve error responses for barcode scanning

* Remove old debug statements

* Add some extra explanatory texts

* Icon change

* Fixes for unit tests

* Adds extra functionality for user profile manager

* Refactor barcode code - do not rely on BuildContext

* Adds initial unit testing for barcode scanning

- Work on mocking barcode data
- Add hooks for testing snackBar and audio files

* Linting fixes

* More barcode unit tests

* Cleanup unit tests for barcode

* Remove unused import

* Handle HTTPException in API

* Improvements for API unit testing

* Unit testing for scanning item into location

* Add unit test for scanning in items from a location context

* Unit test for scanning location into parent location

* Improve feedback for barcode scanning events
This commit is contained in:
Oliver 2022-07-18 22:10:00 +10:00 committed by GitHub
parent c6678e201f
commit aa274b2e45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 711 additions and 255 deletions

View File

@ -8,6 +8,7 @@
- Display Bill of Materials in the part detail view
- Indicate available quantity in stock detail view
- Adds configurable filtering to various list views
- Allow stock location to be "scanned" into another location using barcode
### 0.7.3 - June 2022
---

View File

@ -413,6 +413,8 @@ class InvenTreeAPI {
break;
}
debug("Token request failed: STATUS ${response.statusCode}");
return false;
}
@ -1017,14 +1019,16 @@ class InvenTreeAPI {
}
if (statusCode != null) {
// Expected status code not returned
if (statusCode != _response.statusCode) {
showStatusCodeError(url, _response.statusCode);
}
}
}
} on HttpException catch (error) {
showServerError(url, L10().serverError, error.toString());
response.error = "HTTPException";
response.errorDetail = error.toString();
} on SocketException catch (error) {
showServerError(url, L10().connectionRefused, error.toString());
response.error = "SocketException";

View File

@ -857,6 +857,7 @@ Future<void> launchApiForm(
Map<String, dynamic> serverFields = {};
if (url.isNotEmpty) {
var options = await InvenTreeAPI().options(url);
// Invalid response from server

View File

@ -43,14 +43,13 @@ Future <void> barcodeFailureTone() async {
}
/* Generic class which "handles" a barcode, by communicating with the InvenTree server,
* and handling match / unknown / error cases.
*
* Override functionality of this class to perform custom actions,
* based on the response returned from the InvenTree server
*/
class BarcodeHandler {
/*
* Class which "handles" a barcode, by communicating with the InvenTree server,
* and handling match / unknown / error cases.
*
* Override functionality of this class to perform custom actions,
* based on the response returned from the InvenTree server
*/
BarcodeHandler();
@ -58,12 +57,12 @@ class BarcodeHandler {
QRViewController? _controller;
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
// Called when the server "matches" a barcode
// Override this function
}
Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeUnknown(Map<String, dynamic> data) async {
// Called when the server does not know about a barcode
// Override this function
@ -76,7 +75,7 @@ class BarcodeHandler {
);
}
Future<void> onBarcodeUnhandled(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeUnhandled(Map<String, dynamic> data) async {
barcodeFailureTone();
@ -86,12 +85,27 @@ class BarcodeHandler {
_controller?.resumeCamera();
}
Future<void> processBarcode(BuildContext context, QRViewController? _controller, String barcode, {String url = "barcode/"}) async {
/*
* Base function to capture and process barcode data.
*/
Future<void> processBarcode(QRViewController? _controller, String barcode, {String url = "barcode/"}) async {
this._controller = _controller;
print("Scanned barcode data: ${barcode}");
debug("Scanned barcode data: '${barcode}'");
barcode = barcode.trim();
// Empty barcode is invalid
if (barcode.isEmpty) {
barcodeFailureTone();
showSnackIcon(
L10().barcodeError,
icon: FontAwesomeIcons.exclamationCircle,
success: false
);
return;
}
@ -109,7 +123,9 @@ class BarcodeHandler {
// Handle strange response from the server
if (!response.isValid() || !response.isMap()) {
onBarcodeUnknown(context, {});
onBarcodeUnknown({});
showSnackIcon(L10().serverError, success: false);
// We want to know about this one!
await sentryReportMessage(
@ -122,31 +138,36 @@ class BarcodeHandler {
"valid": response.isValid().toString(),
"error": response.error,
"errorDetail": response.errorDetail,
"overlayText": getOverlayText(context),
"className": "${this}",
}
);
} else if ((response.statusCode >= 400) || data.containsKey("error")) {
onBarcodeUnknown(context, data);
} else if (data.containsKey("success")) {
onBarcodeMatched(context, data);
await onBarcodeMatched(data);
} else if ((response.statusCode >= 400) || data.containsKey("error")) {
await onBarcodeUnknown(data);
} else {
onBarcodeUnhandled(context, data);
await onBarcodeUnhandled(data);
}
}
}
/*
* Class for general barcode scanning.
* Scan *any* barcode without context, and then redirect app to correct view.
*
* Handles scanning of:
*
* - StockLocation
* - StockItem
* - Part
*/
class BarcodeScanHandler extends BarcodeHandler {
/*
* Class for general barcode scanning.
* Scan *any* barcode without context, and then redirect app to correct view
*/
@override
String getOverlayText(BuildContext context) => L10().barcodeScanGeneral;
@override
Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeUnknown(Map<String, dynamic> data) async {
barcodeFailureTone();
@ -158,7 +179,7 @@ class BarcodeScanHandler extends BarcodeHandler {
}
@override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
int pk = -1;
@ -173,8 +194,13 @@ class BarcodeScanHandler extends BarcodeHandler {
InvenTreeStockLocation().get(pk).then((var loc) {
if (loc is InvenTreeStockLocation) {
Navigator.of(context).pop();
Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc)));
showSnackIcon(
L10().stockLocation,
success: true,
icon: Icons.qr_code,
);
OneContext().pop();
OneContext().navigator.push(MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc)));
}
});
} else {
@ -196,13 +222,15 @@ class BarcodeScanHandler extends BarcodeHandler {
barcodeSuccessTone();
InvenTreeStockItem().get(pk).then((var item) {
// Dispose of the barcode scanner
Navigator.of(context).pop();
if (item is InvenTreeStockItem) {
Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item)));
}
showSnackIcon(
L10().stockItem,
success: true,
icon: Icons.qr_code,
);
OneContext().pop();
if (item is InvenTreeStockItem) {
OneContext().push(MaterialPageRoute(builder: (context) => StockDetailWidget(item)));
}
});
} else {
@ -222,13 +250,17 @@ class BarcodeScanHandler extends BarcodeHandler {
barcodeSuccessTone();
InvenTreePart().get(pk).then((var part) {
showSnackIcon(
L10().part,
success: true,
icon: Icons.qr_code,
);
// Dismiss the barcode scanner
OneContext().pop();
// Dismiss the barcode scanner
Navigator.of(context).pop();
if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
if (part is InvenTreePart) {
OneContext().push(MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
});
} else {
@ -265,73 +297,148 @@ class BarcodeScanHandler extends BarcodeHandler {
}
}
class StockItemScanIntoLocationHandler extends BarcodeHandler {
/*
* Barcode handler for scanning a provided StockItem into a scanned StockLocation
*/
/*
* 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();
final 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
String getOverlayText(BuildContext context) => L10().barcodeScanLocation;
Future<bool> onLocationScanned(int locationId) async {
@override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async {
// If the barcode points to a "stocklocation", great!
if (data.containsKey("stocklocation")) {
// Extract location information
int location = (data["stocklocation"]["pk"] ?? -1) as int;
final result = await item.transferStock(locationId);
if (location == -1) {
showSnackIcon(
L10().invalidStockLocation,
success: false,
);
return;
}
// Transfer stock to specified location
final result = await item.transferStock(context, location);
if (result) {
barcodeSuccessTone();
Navigator.of(context).pop();
showSnackIcon(
L10().barcodeScanIntoLocationSuccess,
success: true,
);
} else {
barcodeFailureTone();
showSnackIcon(
L10().barcodeScanIntoLocationFailure,
success: false
);
}
if (result) {
barcodeSuccessTone();
showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true);
} else {
barcodeFailureTone();
showSnackIcon(
L10().invalidStockLocation,
success: false,
);
showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false);
}
return result;
}
}
class StockLocationScanInItemsHandler extends BarcodeHandler {
/*
* Barcode handler for scanning stock item(s) into the specified StockLocation
*/
/*
* 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);
@ -341,69 +448,91 @@ class StockLocationScanInItemsHandler extends BarcodeHandler {
String getOverlayText(BuildContext context) => L10().barcodeScanItem;
@override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async {
Future<bool> onItemScanned(int itemId) async {
// Returned barcode must match a stock item
if (data.containsKey("stockitem")) {
final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?;
int item_id = data["stockitem"]["pk"] as int;
bool result = false;
final InvenTreeStockItem? item = await InvenTreeStockItem().get(item_id) as InvenTreeStockItem?;
if (item == null) {
if (item != null) {
// Item is already *in* the specified location
if (item.locationId == location.pk) {
barcodeFailureTone();
showSnackIcon(
L10().invalidStockItem,
success: false,
);
} else if (item.locationId == location.pk) {
barcodeFailureTone();
showSnackIcon(
L10().itemInLocation,
success: true
);
showSnackIcon(L10().itemInLocation, success: true);
return false;
} else {
final result = await item.transferStock(context, location.pk);
if (result) {
barcodeSuccessTone();
showSnackIcon(
L10().barcodeScanIntoLocationSuccess,
success: true
);
} else {
barcodeFailureTone();
showSnackIcon(
L10().barcodeScanIntoLocationFailure,
success: false
);
}
result = await item.transferStock(location.pk);
}
} else {
}
barcodeFailureTone();
showSnackIcon(
result ? L10().barcodeScanIntoLocationSuccess : L10().barcodeScanIntoLocationFailure,
success: result
);
// Does not match a valid stock item!
showSnackIcon(
L10().invalidStockItem,
success: false,
);
// 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)
*/
class UniqueBarcodeHandler extends BarcodeHandler {
/*
* Barcode handler for finding a "unique" barcode (one that does not match an item in the database)
*/
UniqueBarcodeHandler(this.callback, {this.overlayText = ""});
@ -422,7 +551,7 @@ class UniqueBarcodeHandler extends BarcodeHandler {
}
@override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
barcodeFailureTone();
@ -435,7 +564,7 @@ class UniqueBarcodeHandler extends BarcodeHandler {
}
@override
Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async {
Future<void> onBarcodeUnknown(Map<String, dynamic> data) async {
// If the barcode is unknown, we *can* assign it to the stock item!
if (!data.containsKey("hash")) {
@ -459,7 +588,9 @@ class UniqueBarcodeHandler extends BarcodeHandler {
barcodeSuccessTone();
// Close the barcode scanner
Navigator.of(context).pop();
if (OneContext.hasContext) {
OneContext().pop();
}
callback(hash);
}
@ -521,7 +652,7 @@ class _QRViewState extends State<InvenTreeQRView> {
_controller?.pauseCamera();
if (barcode.code != null) {
_handler.processBarcode(context, _controller, barcode.code ?? "");
_handler.processBarcode(_controller, barcode.code ?? "");
}
});
}

View File

@ -13,12 +13,37 @@ import "package:audioplayers/audioplayers.dart";
import "package:one_context/one_context.dart";
List<String> debug_messages = [];
void clearDebugMessage() => debug_messages.clear();
int debugMessageCount() => debug_messages.length;
// Check if the debug log contains a given message
bool debugContains(String msg, {bool raiseAssert = true}) {
bool result = false;
for (String element in debug_messages) {
if (element.contains(msg)) {
result = true;
break;
}
}
if (raiseAssert) {
assert(result);
}
return result;
}
/*
* Display a debug message if we are in testing mode, or running in debug mode
*/
void debug(dynamic msg) {
if (Platform.environment.containsKey("FLUTTER_TEST")) {
debug_messages.add(msg.toString());
print("DEBUG: ${msg.toString()}");
}
}
@ -38,11 +63,13 @@ String simpleNumberString(double number) {
*/
Future<void> playAudioFile(String path) async {
// Debug message for unit testing
debug("Playing audio file: '${path}'");
if (!OneContext.hasContext) {
return;
}
final player = AudioCache();
player.play(path);
}

View File

@ -107,6 +107,9 @@ class InvenTreeModel {
}
/*
* Launch a modal form to edit the fields available to this model instance.
*/
Future<void> editForm(BuildContext context, String title, {Map<String, dynamic> fields=const {}, Function(dynamic)? onSuccess}) async {
if (fields.isEmpty) {
@ -317,7 +320,7 @@ class InvenTreeModel {
}
// POST data to update the model
Future<bool> update({Map<String, String> values = const {}}) async {
Future<APIResponse> update({Map<String, String> values = const {}, int? expectedStatusCode = 200}) async {
var url = path.join(URL, pk.toString());
@ -325,17 +328,13 @@ class InvenTreeModel {
url += "/";
}
var response = await api.patch(
final response = await api.patch(
url,
body: values,
expectedStatusCode: 200
expectedStatusCode: expectedStatusCode,
);
if (!response.isValid()) {
return false;
}
return true;
return response;
}
// Return the detail view for the associated pk

View File

@ -43,8 +43,8 @@ class InvenTreePartCategory extends InvenTreeModel {
String get pathstring => (jsondata["pathstring"] ?? "") as String;
String get parentpathstring {
// TODO - Drive the refactor tractor through this
String get parentPathString {
List<String> psplit = pathstring.split("/");
if (psplit.isNotEmpty) {

View File

@ -535,7 +535,7 @@ class InvenTreeStockItem extends InvenTreeModel {
* - Remove
* - Count
*/
Future<bool> adjustStock(BuildContext context, String endpoint, double q, {String? notes, int? location}) async {
Future<bool> adjustStock(String endpoint, double q, {String? notes, int? location}) async {
// Serialized stock cannot be adjusted (unless it is a "transfer")
if (isSerialized() && location == null) {
@ -566,34 +566,33 @@ class InvenTreeStockItem extends InvenTreeModel {
var response = await api.post(
endpoint,
body: data,
expectedStatusCode: 200,
);
return response.isValid();
return response.isValid() && (response.statusCode == 200 || response.statusCode == 201);
}
Future<bool> countStock(BuildContext context, double q, {String? notes}) async {
Future<bool> countStock(double q, {String? notes}) async {
final bool result = await adjustStock(context, "/stock/count/", q, notes: notes);
final bool result = await adjustStock("/stock/count/", q, notes: notes);
return result;
}
Future<bool> addStock(BuildContext context, double q, {String? notes}) async {
Future<bool> addStock(double q, {String? notes}) async {
final bool result = await adjustStock(context, "/stock/add/", q, notes: notes);
final bool result = await adjustStock("/stock/add/", q, notes: notes);
return result;
}
Future<bool> removeStock(BuildContext context, double q, {String? notes}) async {
Future<bool> removeStock(double q, {String? notes}) async {
final bool result = await adjustStock(context, "/stock/remove/", q, notes: notes);
final bool result = await adjustStock("/stock/remove/", q, notes: notes);
return result;
}
Future<bool> transferStock(BuildContext context, int location, {double? quantity, String? notes}) async {
Future<bool> transferStock(int location, {double? quantity, String? notes}) async {
double q = this.quantity;
@ -602,7 +601,6 @@ class InvenTreeStockItem extends InvenTreeModel {
}
final bool result = await adjustStock(
context,
"/stock/transfer/",
q,
notes: notes,
@ -653,12 +651,14 @@ class InvenTreeStockLocation extends InvenTreeModel {
return {
"name": {},
"description": {},
"parent": {},
"parent": {
},
};
}
String get parentpathstring {
// TODO - Drive the refactor tractor through this
String get parentPathString {
List<String> psplit = pathstring.split("/");
if (psplit.isNotEmpty) {

View File

@ -82,6 +82,9 @@
"barcodeAssign": "Assign Barcode",
"@barcodeAssign": {},
"barcodeAssignDetail": "Scan custom barcode to assign",
"@barcodeAssignDetail": {},
"barcodeAssigned": "Barcode assigned",
"@barcodeAssigned": {},
@ -106,7 +109,7 @@
"barcodeScanGeneral": "Scan an InvenTree barcode",
"@barcodeScanGeneral": {},
"barcodeScanInItems": "Scan stock items into location",
"barcodeScanInItems": "Scan stock items into this location",
"@barcodeScanInItems": {},
"barcodeScanLocation": "Scan stock location",
@ -807,6 +810,9 @@
"scanIntoLocation": "Scan Into Location",
"@scanIntoLocation": {},
"scanIntoLocationDetail": "Scan this item into location",
"@scanIntoLocationDetail": {},
"search": "Search",
"@search": {
"description": "search"
@ -1087,6 +1093,15 @@
"description": "transfer stock"
},
"transferStockDetail": "Transfer item to a different location",
"@transferStockDetail": {},
"transferStockLocation": "Transfer Stock Location",
"@transferStockLocation": {},
"transferStockLocationDetail": "Transfer this stock location into another",
"@transferStockLocationDetail": {},
"translate": "Translate",
"@translate": {},

View File

@ -57,6 +57,9 @@ class UserProfile {
}
}
/*
* Class for storing and managing user (server) profiles
*/
class UserProfileDBManager {
final store = StoreRef("profiles");
@ -96,6 +99,8 @@ class UserProfileDBManager {
if (exists) {
debug("addProfile() : UserProfile '${profile.name}' already exists");
return false;
} else {
debug("Adding new profile: '${profile.name}'");
}
int key = await store.add(await _db, profile.toJson()) as int;
@ -149,8 +154,6 @@ class UserProfileDBManager {
for (int idx = 0; idx < profiles.length; idx++) {
debug("- Checking ${idx} - key = ${profiles[idx].key} - ${profiles[idx].value.toString()}");
if (profiles[idx].key is int && profiles[idx].key == selected) {
return UserProfile.fromJson(
profiles[idx].key as int,
@ -190,6 +193,24 @@ class UserProfileDBManager {
return profileList;
}
/*
* Retrieve a profile by name (or null if no match exists)
*/
Future<UserProfile?> getProfileByName(String name) async {
final profiles = await getAllProfiles();
UserProfile? prf;
for (UserProfile profile in profiles) {
if (profile.name == name) {
prf = profile;
break;
}
}
return prf;
}
/*
* Mark the particular profile as selected
*/

View File

@ -131,7 +131,7 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
children.add(
ListTile(
title: Text(L10().parentCategory),
subtitle: Text("${category?.parentpathstring}"),
subtitle: Text("${category?.parentPathString}"),
leading: FaIcon(
FontAwesomeIcons.levelUpAlt,
color: COLOR_CLICK,

View File

@ -3,11 +3,15 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/helpers.dart";
import "package:one_context/one_context.dart";
import "package:inventree/api.dart";
import "package:inventree/l10.dart";
import "package:inventree/preferences.dart";
import "package:inventree/widget/snacks.dart";
/*
* Display a "confirmation" dialog allowing the user to accept or reject an action
*/
Future<void> confirmationDialog(String title, String text, {IconData icon = FontAwesomeIcons.questionCircle, String? acceptText, String? rejectText, Function? onAccept, Function? onReject}) async {
String _accept = acceptText ?? L10().ok;
@ -51,22 +55,86 @@ Future<void> confirmationDialog(String title, String text, {IconData icon = Font
}
Future<void> showErrorDialog(String title, String description, {IconData icon = FontAwesomeIcons.exclamationCircle, String? error, Function? onDismissed}) async {
/*
* Construct an error dialog showing information to the user
*
* @title = Title to be displayed at the top of the dialog
* @description = Simple string description of error
* @data = Error response (e.g from server)
*/
Future<void> showErrorDialog(String title, {String description = "", APIResponse? response, IconData icon = FontAwesomeIcons.exclamationCircle, Function? onDismissed}) async {
String _error = error ?? L10().error;
List<Widget> children = [];
if (description.isNotEmpty) {
children.add(
ListTile(
title: Text(description),
)
);
} else if (response != null) {
// Look for extra error information in the provided APIResponse object
switch (response.statusCode) {
case 400: // Bad request (typically bad input)
if (response.data is Map<String, dynamic>) {
for (String field in response.data.keys) {
dynamic error = response.data[field];
if (error is List) {
for (int ii = 0; ii < error.length; ii++) {
children.add(
ListTile(
title: Text(field),
subtitle: Text(error[ii].toString()),
)
);
}
} else {
children.add(
ListTile(
title: Text(field),
subtitle: Text(response.data[field].toString()),
)
);
}
}
} else {
children.add(
ListTile(
title: Text(L10().responseInvalid),
subtitle: Text(response.data.toString())
)
);
}
break;
default:
// Unhandled server response
children.add(
ListTile(
title: Text(L10().statusCode),
subtitle: Text(response.statusCode.toString()),
)
);
children.add(
ListTile(
title: Text(L10().responseData),
subtitle: Text(response.data.toString()),
)
);
break;
}
}
OneContext().showDialog(
builder: (context) => SimpleDialog(
title: ListTile(
title: Text(_error),
title: Text(title),
leading: FaIcon(icon),
),
children: [
ListTile(
title: Text(title),
subtitle: Text(description),
)
],
children: children
)
).then((value) {
if (onDismissed != null) {
@ -106,9 +174,8 @@ Future<void> showServerError(String url, String title, String description) async
actionText: L10().details,
onAction: () {
showErrorDialog(
title,
description,
error: L10().serverError,
L10().serverError,
description: description,
icon: FontAwesomeIcons.server
);
}

View File

@ -40,27 +40,6 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
List<Widget> actions = [];
/*
actions.add(
IconButton(
icon: FaIcon(FontAwesomeIcons.search),
onPressed: () {
Map<String, String> filters = {};
if (location != null) {
filters["location"] = "${location.pk}";
}
showSearch(
context: context,
delegate: StockSearchDelegate(context, filters: filters)
);
}
),
);
*/
if (location != null) {
// Add "locate" button
@ -252,7 +231,7 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
children.add(
ListTile(
title: Text(L10().parentLocation),
subtitle: Text("${location!.parentpathstring}"),
subtitle: Text("${location!.parentPathString}"),
leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK),
onTap: () {
@ -381,6 +360,7 @@ List<Widget> detailTiles() {
title: Text(L10().locationCreate),
subtitle: Text(L10().locationCreateDetail),
leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
onTap: () async {
_newLocation(context);
},
@ -392,6 +372,7 @@ List<Widget> detailTiles() {
title: Text(L10().stockItemCreate),
subtitle: Text(L10().stockItemCreateDetail),
leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
onTap: () async {
_newStockItem(context);
},
@ -401,14 +382,15 @@ List<Widget> detailTiles() {
}
if (location != null) {
// Stock adjustment actions
// Scan stock item into location
if (InvenTreeAPI().checkPermission("stock", "change")) {
// Scan items into location
tiles.add(
ListTile(
title: Text(L10().barcodeScanInItems),
title: Text(L10().barcodeScanItem),
subtitle: Text(L10().barcodeScanInItems),
leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
trailing: Icon(Icons.qr_code),
trailing: Icon(Icons.qr_code, color: COLOR_CLICK),
onTap: () {
var _loc = location;
@ -426,21 +408,35 @@ List<Widget> detailTiles() {
},
)
);
// Scan this location into another one
if (InvenTreeAPI().checkPermission("stock_location", "change")) {
tiles.add(
ListTile(
title: Text(L10().transferStockLocation),
subtitle: Text(L10().transferStockLocationDetail),
leading: FaIcon(FontAwesomeIcons.signInAlt, color: COLOR_CLICK),
trailing: Icon(Icons.qr_code, color: COLOR_CLICK),
onTap: () {
var _loc = location;
if (_loc != null) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) =>
InvenTreeQRView(
ScanParentLocationHandler(_loc)))
).then((value) {
refresh(context);
});
}
}
)
);
}
}
}
// Move location into another location
// TODO: Implement this!
/*
tiles.add(
ListTile(
title: Text("Move Stock Location"),
leading: FaIcon(FontAwesomeIcons.sitemap),
trailing: Icon(Icons.qr_code),
)
);
*/
if (tiles.length <= 1) {
tiles.add(
ListTile(

View File

@ -381,7 +381,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
tiles.add(
ListTile(
title: Text("${part.keywords}"),
leading: FaIcon(FontAwesomeIcons.key),
leading: FaIcon(FontAwesomeIcons.tags),
)
);
}

View File

@ -210,6 +210,9 @@ class _PurchaseOrderDetailState extends RefreshableState<PurchaseOrderDetailWidg
}
/*
* Receive a specified PurchaseOrderLineItem into stock
*/
void receiveLine(BuildContext context, InvenTreePOLineItem lineItem) {
Map<String, dynamic> fields = {

View File

@ -1,16 +1,20 @@
import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:one_context/one_context.dart";
import "package:inventree/l10.dart";
import "package:inventree/helpers.dart";
import "package:inventree/l10.dart";
/*
* Display a configurable 'snackbar' at the bottom of the screen
*/
void showSnackIcon(String text, {IconData? icon, Function()? onAction, bool? success, String? actionText}) {
debug("showSnackIcon: '${text}'");
// Escape quickly if we do not have context
if (!OneContext.hasContext) {
// Debug message for unit testing
return;
}

View File

@ -402,20 +402,23 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
Future<void> _unassignBarcode(BuildContext context) async {
final bool result = await item.update(values: {"uid": ""});
final response = await item.update(values: {"uid": ""});
if (result) {
showSnackIcon(
L10().stockItemUpdateSuccess,
success: true
);
} else {
showSnackIcon(
L10().stockItemUpdateFailure,
success: false,
);
switch (response.statusCode) {
case 200:
case 201:
showSnackIcon(
L10().stockItemUpdateSuccess,
success: true
);
break;
default:
showSnackIcon(
L10().stockItemUpdateFailure,
success: false,
);
break;
}
refresh(context);
}
@ -779,6 +782,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add(
ListTile(
title: Text(L10().transferStock),
subtitle: Text(L10().transferStockDetail),
leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
onTap: () { _transferStockDialog(context); },
)
@ -788,6 +792,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add(
ListTile(
title: Text(L10().scanIntoLocation),
subtitle: Text(L10().scanIntoLocationDetail),
leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
trailing: Icon(Icons.qr_code_scanner),
onTap: () {
@ -806,6 +811,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add(
ListTile(
title: Text(L10().barcodeAssign),
subtitle: Text(L10().barcodeAssignDetail),
leading: Icon(Icons.qr_code),
trailing: Icon(Icons.qr_code_scanner),
onTap: () {
@ -815,17 +821,23 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
values: {
"uid": hash,
}
).then((result) {
if (result) {
barcodeSuccessTone();
).then((response) {
showSnackIcon(
L10().barcodeAssigned,
success: true,
icon: Icons.qr_code,
);
switch (response.statusCode) {
case 200:
case 201:
barcodeSuccessTone();
refresh(context);
showSnackIcon(
L10().barcodeAssigned,
success: true,
icon: Icons.qr_code,
);
refresh(context);
break;
default:
break;
}
});
});

View File

@ -59,7 +59,7 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt
Map<String, String> get orderingOptions => {
"part__name": L10().name,
"part__IPN": L10().internalPartNumber,
"quantity": L10().quantity,
"stock": L10().quantity,
"status": L10().status,
"batch": L10().batchCode,
"updated": L10().lastUpdated,

View File

@ -5,6 +5,7 @@
import "package:test/test.dart";
import "package:inventree/api.dart";
import "package:inventree/helpers.dart";
import "package:inventree/user_profile.dart";
@ -94,6 +95,9 @@ void main() {
assert(!api.checkConnection());
debugContains("Token request failed: STATUS 401");
debugContains("showSnackIcon: 'Not Connected'");
} else {
assert(false);
}
@ -137,6 +141,9 @@ void main() {
assert(api.checkPermission("stocklocation", "delete"));
assert(!api.checkPermission("part", "weirdpermission"));
assert(api.checkPermission("blah", "bloo"));
debugContains("Received token from server");
debugContains("showSnackIcon: 'Connected to Server'");
});
});

160
test/barcode_test.dart Normal file
View File

@ -0,0 +1,160 @@
/*
* Unit testing for barcode scanning functionality.
*
* As the unit testing framework cannot really "scan" barcode data,
* we will mock the scanned data by passing raw "barcode" data to the scanning framework.
*/
import "package:flutter_test/flutter_test.dart";
import "package:inventree/api.dart";
import "package:inventree/barcode.dart";
import "package:inventree/helpers.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/inventree/stock.dart";
void main() {
// Connect to the server
setUpAll(() async {
final prf = await UserProfileDBManager().getProfileByName("Test Profile");
if (prf != null) {
UserProfileDBManager().deleteProfile(prf);
}
bool result = await UserProfileDBManager().addProfile(
UserProfile(
name: "Test Profile",
server: "http://localhost:12345",
username: "testuser",
password: "testpassword",
selected: true,
),
);
assert(result);
assert(await UserProfileDBManager().selectProfileByName("Test Profile"));
assert(await InvenTreeAPI().connectToServer());
});
setUp(() async {
// Clear the debug log
clearDebugMessage();
});
group("Test BarcodeScanHandler:", () {
// Tests for scanning a "generic" barcode
var handler = BarcodeScanHandler();
test("Empty Barcode", () async {
// Handle an 'empty' barcode
await handler.processBarcode(null, "");
debugContains("Scanned barcode data: ''");
debugContains("showSnackIcon: 'Barcode scan error'");
assert(debugMessageCount() == 2);
});
test("Junk Data", () async {
// test scanning 'junk' data
await handler.processBarcode(null, "abcdefg");
debugContains("Scanned barcode data: 'abcdefg'");
debugContains("showSnackIcon: 'No match for barcode'");
});
test("Invalid StockLocation", () async {
// Scan an invalid stock location
await handler.processBarcode(null, '{"stocklocation": 999999}');
debugContains("Scanned barcode data: '{\"stocklocation\": 999999}'");
debugContains("showSnackIcon: 'No match for barcode'");
assert(debugMessageCount() == 2);
});
});
group("Test StockItemScanIntoLocationHandler:", () {
// Tests for scanning a stock item into a location
test("Scan Into Location", () async {
final item = await InvenTreeStockItem().get(1) as InvenTreeStockItem?;
assert(item != null);
assert(item!.pk == 1);
var handler = StockItemScanIntoLocationHandler(item!);
await handler.processBarcode(null, '{"stocklocation": 7}');
// Check the location has been updated
await item.reload();
assert(item.locationId == 7);
debugContains("Scanned stock location 7");
// Scan into a new location
await handler.processBarcode(null, '{"stocklocation": 1}');
await item.reload();
assert(item.locationId == 1);
});
});
group("Test StockLocationScanInItemsHandler:", () {
// Tests for scanning items into a stock location
test("Scan In Items", () async {
final location = await InvenTreeStockLocation().get(1) as InvenTreeStockLocation?;
assert(location != null);
assert(location!.pk == 1);
var handler = StockLocationScanInItemsHandler(location!);
// Scan multiple items into this location
for (int id in [1, 2, 11]) {
await handler.processBarcode(null, '{"stockitem": ${id}}');
var item = await InvenTreeStockItem().get(id) as InvenTreeStockItem?;
assert(item != null);
assert(item!.pk == id);
assert(item!.locationId == 1);
}
});
});
group("Test ScanParentLocationHandler:", () {
// Tests for scanning a location into a parent location
test("Scan Parent", () async {
final location = await InvenTreeStockLocation().get(7) as InvenTreeStockLocation?;
assert(location != null);
assert(location!.pk == 7);
assert(location!.parentId == 4);
var handler = ScanParentLocationHandler(location!);
// Scan into new parent location
await handler.processBarcode(null, '{"stocklocation": 1}');
await location.reload();
assert(location.parentId == 1);
// Scan back into old parent location
await handler.processBarcode(null, '{"stocklocation": 4}');
await location.reload();
assert(location.parentId == 4);
debugContains("showSnackIcon: 'Scanned into location'");
});
});
}

View File

@ -2,11 +2,11 @@
* Unit tests for accessing various model classes via the API
*/
import "package:inventree/inventree/model.dart";
import "package:test/test.dart";
import "package:inventree/api.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/part.dart";
@ -112,32 +112,40 @@ void main() {
assert(result != null);
assert(result is InvenTreePart);
APIResponse? response;
if (result != null) {
InvenTreePart part = result as InvenTreePart;
assert(part.name == "M2x4 LPHS");
// Change the name to something else
assert(await part.update(
response = await part.update(
values: {
"name": "Woogle",
}
));
);
assert(response.isValid());
assert(response.statusCode == 200);
assert(await part.reload());
assert(part.name == "Woogle");
// And change it back again
assert(await part.update(
response = await part.update(
values: {
"name": "M2x4 LPHS"
}
));
);
assert(response.isValid());
assert(response.statusCode == 200);
assert(await part.reload());
assert(part.name == "M2x4 LPHS");
}
});
});
}