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 - Display Bill of Materials in the part detail view
- Indicate available quantity in stock detail view - Indicate available quantity in stock detail view
- Adds configurable filtering to various list views - Adds configurable filtering to various list views
- Allow stock location to be "scanned" into another location using barcode
### 0.7.3 - June 2022 ### 0.7.3 - June 2022
--- ---

View File

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

View File

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

View File

@ -43,14 +43,13 @@ Future <void> barcodeFailureTone() async {
} }
class BarcodeHandler { /* Generic class which "handles" a barcode, by communicating with the InvenTree server,
/*
* Class which "handles" a barcode, by communicating with the InvenTree server,
* and handling match / unknown / error cases. * and handling match / unknown / error cases.
* *
* Override functionality of this class to perform custom actions, * Override functionality of this class to perform custom actions,
* based on the response returned from the InvenTree server * based on the response returned from the InvenTree server
*/ */
class BarcodeHandler {
BarcodeHandler(); BarcodeHandler();
@ -58,12 +57,12 @@ class BarcodeHandler {
QRViewController? _controller; 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 // Called when the server "matches" a barcode
// Override this function // 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 // Called when the server does not know about a barcode
// Override this function // 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(); barcodeFailureTone();
@ -86,12 +85,27 @@ class BarcodeHandler {
_controller?.resumeCamera(); _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; this._controller = _controller;
print("Scanned barcode data: ${barcode}"); debug("Scanned barcode data: '${barcode}'");
barcode = barcode.trim();
// Empty barcode is invalid
if (barcode.isEmpty) { if (barcode.isEmpty) {
barcodeFailureTone();
showSnackIcon(
L10().barcodeError,
icon: FontAwesomeIcons.exclamationCircle,
success: false
);
return; return;
} }
@ -109,7 +123,9 @@ class BarcodeHandler {
// Handle strange response from the server // Handle strange response from the server
if (!response.isValid() || !response.isMap()) { if (!response.isValid() || !response.isMap()) {
onBarcodeUnknown(context, {}); onBarcodeUnknown({});
showSnackIcon(L10().serverError, success: false);
// We want to know about this one! // We want to know about this one!
await sentryReportMessage( await sentryReportMessage(
@ -122,31 +138,36 @@ class BarcodeHandler {
"valid": response.isValid().toString(), "valid": response.isValid().toString(),
"error": response.error, "error": response.error,
"errorDetail": response.errorDetail, "errorDetail": response.errorDetail,
"overlayText": getOverlayText(context), "className": "${this}",
} }
); );
} else if ((response.statusCode >= 400) || data.containsKey("error")) {
onBarcodeUnknown(context, data);
} else if (data.containsKey("success")) { } else if (data.containsKey("success")) {
onBarcodeMatched(context, data); await onBarcodeMatched(data);
} else if ((response.statusCode >= 400) || data.containsKey("error")) {
await onBarcodeUnknown(data);
} else { } else {
onBarcodeUnhandled(context, data); await onBarcodeUnhandled(data);
} }
} }
} }
/*
class BarcodeScanHandler extends BarcodeHandler {
/*
* Class for general barcode scanning. * Class for general barcode scanning.
* Scan *any* barcode without context, and then redirect app to correct view * Scan *any* barcode without context, and then redirect app to correct view.
*
* Handles scanning of:
*
* - StockLocation
* - StockItem
* - Part
*/ */
class BarcodeScanHandler extends BarcodeHandler {
@override @override
String getOverlayText(BuildContext context) => L10().barcodeScanGeneral; String getOverlayText(BuildContext context) => L10().barcodeScanGeneral;
@override @override
Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async { Future<void> onBarcodeUnknown(Map<String, dynamic> data) async {
barcodeFailureTone(); barcodeFailureTone();
@ -158,7 +179,7 @@ class BarcodeScanHandler extends BarcodeHandler {
} }
@override @override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
int pk = -1; int pk = -1;
@ -173,8 +194,13 @@ class BarcodeScanHandler extends BarcodeHandler {
InvenTreeStockLocation().get(pk).then((var loc) { InvenTreeStockLocation().get(pk).then((var loc) {
if (loc is InvenTreeStockLocation) { if (loc is InvenTreeStockLocation) {
Navigator.of(context).pop(); showSnackIcon(
Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc))); L10().stockLocation,
success: true,
icon: Icons.qr_code,
);
OneContext().pop();
OneContext().navigator.push(MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc)));
} }
}); });
} else { } else {
@ -196,12 +222,14 @@ class BarcodeScanHandler extends BarcodeHandler {
barcodeSuccessTone(); barcodeSuccessTone();
InvenTreeStockItem().get(pk).then((var item) { InvenTreeStockItem().get(pk).then((var item) {
showSnackIcon(
// Dispose of the barcode scanner L10().stockItem,
Navigator.of(context).pop(); success: true,
icon: Icons.qr_code,
);
OneContext().pop();
if (item is InvenTreeStockItem) { if (item is InvenTreeStockItem) {
Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item))); OneContext().push(MaterialPageRoute(builder: (context) => StockDetailWidget(item)));
} }
}); });
} else { } else {
@ -222,12 +250,16 @@ class BarcodeScanHandler extends BarcodeHandler {
barcodeSuccessTone(); barcodeSuccessTone();
InvenTreePart().get(pk).then((var part) { InvenTreePart().get(pk).then((var part) {
showSnackIcon(
L10().part,
success: true,
icon: Icons.qr_code,
);
// Dismiss the barcode scanner // Dismiss the barcode scanner
Navigator.of(context).pop(); OneContext().pop();
if (part is InvenTreePart) { if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part))); OneContext().push(MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
} }
}); });
} else { } 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); StockItemScanIntoLocationHandler(this.item);
final InvenTreeStockItem item; final InvenTreeStockItem item;
@override @override
String getOverlayText(BuildContext context) => L10().barcodeScanLocation; Future<bool> onLocationScanned(int locationId) async {
@override final result = await item.transferStock(locationId);
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;
if (location == -1) {
showSnackIcon(
L10().invalidStockLocation,
success: false,
);
return;
}
// Transfer stock to specified location
final result = await item.transferStock(context, location);
if (result) { if (result) {
barcodeSuccessTone(); barcodeSuccessTone();
showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true);
Navigator.of(context).pop();
showSnackIcon(
L10().barcodeScanIntoLocationSuccess,
success: true,
);
} else { } else {
barcodeFailureTone(); barcodeFailureTone();
showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false);
showSnackIcon(
L10().barcodeScanIntoLocationFailure,
success: false
);
} }
} else {
barcodeFailureTone(); return result;
showSnackIcon(
L10().invalidStockLocation,
success: false,
);
}
} }
} }
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); StockLocationScanInItemsHandler(this.location);
@ -341,69 +448,91 @@ class StockLocationScanInItemsHandler extends BarcodeHandler {
String getOverlayText(BuildContext context) => L10().barcodeScanItem; String getOverlayText(BuildContext context) => L10().barcodeScanItem;
@override @override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { Future<bool> onItemScanned(int itemId) async {
// Returned barcode must match a stock item final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?;
if (data.containsKey("stockitem")) {
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(); barcodeFailureTone();
showSnackIcon(L10().itemInLocation, success: true);
showSnackIcon( return false;
L10().invalidStockItem,
success: false,
);
} else if (item.locationId == location.pk) {
barcodeFailureTone();
showSnackIcon(
L10().itemInLocation,
success: true
);
} else { } else {
final result = await item.transferStock(context, location.pk); result = await item.transferStock(location.pk);
}
}
if (result) { 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(); barcodeSuccessTone();
showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true);
showSnackIcon( return true;
L10().barcodeScanIntoLocationSuccess, case 400: // Invalid parent location chosen
success: true barcodeFailureTone();
); showSnackIcon(L10().invalidStockLocation, success: false);
} else { return false;
default:
barcodeFailureTone(); barcodeFailureTone();
showSnackIcon( showSnackIcon(
L10().barcodeScanIntoLocationFailure, L10().barcodeScanIntoLocationFailure,
success: false
);
}
}
} else {
barcodeFailureTone();
// Does not match a valid stock item!
showSnackIcon(
L10().invalidStockItem,
success: false, success: false,
actionText: L10().details,
onAction: () {
showErrorDialog(
L10().barcodeError,
response: response,
); );
} }
);
return false;
}
} }
} }
class UniqueBarcodeHandler extends BarcodeHandler { /*
/*
* Barcode handler for finding a "unique" barcode (one that does not match an item in the database) * Barcode handler for finding a "unique" barcode (one that does not match an item in the database)
*/ */
class UniqueBarcodeHandler extends BarcodeHandler {
UniqueBarcodeHandler(this.callback, {this.overlayText = ""}); UniqueBarcodeHandler(this.callback, {this.overlayText = ""});
@ -422,7 +551,7 @@ class UniqueBarcodeHandler extends BarcodeHandler {
} }
@override @override
Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { Future<void> onBarcodeMatched(Map<String, dynamic> data) async {
barcodeFailureTone(); barcodeFailureTone();
@ -435,7 +564,7 @@ class UniqueBarcodeHandler extends BarcodeHandler {
} }
@override @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 the barcode is unknown, we *can* assign it to the stock item!
if (!data.containsKey("hash")) { if (!data.containsKey("hash")) {
@ -459,7 +588,9 @@ class UniqueBarcodeHandler extends BarcodeHandler {
barcodeSuccessTone(); barcodeSuccessTone();
// Close the barcode scanner // Close the barcode scanner
Navigator.of(context).pop(); if (OneContext.hasContext) {
OneContext().pop();
}
callback(hash); callback(hash);
} }
@ -521,7 +652,7 @@ class _QRViewState extends State<InvenTreeQRView> {
_controller?.pauseCamera(); _controller?.pauseCamera();
if (barcode.code != null) { 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"; 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 * Display a debug message if we are in testing mode, or running in debug mode
*/ */
void debug(dynamic msg) { void debug(dynamic msg) {
if (Platform.environment.containsKey("FLUTTER_TEST")) { if (Platform.environment.containsKey("FLUTTER_TEST")) {
debug_messages.add(msg.toString());
print("DEBUG: ${msg.toString()}"); print("DEBUG: ${msg.toString()}");
} }
} }
@ -38,11 +63,13 @@ String simpleNumberString(double number) {
*/ */
Future<void> playAudioFile(String path) async { Future<void> playAudioFile(String path) async {
// Debug message for unit testing
debug("Playing audio file: '${path}'");
if (!OneContext.hasContext) { if (!OneContext.hasContext) {
return; return;
} }
final player = AudioCache(); final player = AudioCache();
player.play(path); 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 { Future<void> editForm(BuildContext context, String title, {Map<String, dynamic> fields=const {}, Function(dynamic)? onSuccess}) async {
if (fields.isEmpty) { if (fields.isEmpty) {
@ -317,7 +320,7 @@ class InvenTreeModel {
} }
// POST data to update the model // 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()); var url = path.join(URL, pk.toString());
@ -325,17 +328,13 @@ class InvenTreeModel {
url += "/"; url += "/";
} }
var response = await api.patch( final response = await api.patch(
url, url,
body: values, body: values,
expectedStatusCode: 200 expectedStatusCode: expectedStatusCode,
); );
if (!response.isValid()) { return response;
return false;
}
return true;
} }
// Return the detail view for the associated pk // 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 pathstring => (jsondata["pathstring"] ?? "") as String;
String get parentpathstring { String get parentPathString {
// TODO - Drive the refactor tractor through this
List<String> psplit = pathstring.split("/"); List<String> psplit = pathstring.split("/");
if (psplit.isNotEmpty) { if (psplit.isNotEmpty) {

View File

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

View File

@ -82,6 +82,9 @@
"barcodeAssign": "Assign Barcode", "barcodeAssign": "Assign Barcode",
"@barcodeAssign": {}, "@barcodeAssign": {},
"barcodeAssignDetail": "Scan custom barcode to assign",
"@barcodeAssignDetail": {},
"barcodeAssigned": "Barcode assigned", "barcodeAssigned": "Barcode assigned",
"@barcodeAssigned": {}, "@barcodeAssigned": {},
@ -106,7 +109,7 @@
"barcodeScanGeneral": "Scan an InvenTree barcode", "barcodeScanGeneral": "Scan an InvenTree barcode",
"@barcodeScanGeneral": {}, "@barcodeScanGeneral": {},
"barcodeScanInItems": "Scan stock items into location", "barcodeScanInItems": "Scan stock items into this location",
"@barcodeScanInItems": {}, "@barcodeScanInItems": {},
"barcodeScanLocation": "Scan stock location", "barcodeScanLocation": "Scan stock location",
@ -807,6 +810,9 @@
"scanIntoLocation": "Scan Into Location", "scanIntoLocation": "Scan Into Location",
"@scanIntoLocation": {}, "@scanIntoLocation": {},
"scanIntoLocationDetail": "Scan this item into location",
"@scanIntoLocationDetail": {},
"search": "Search", "search": "Search",
"@search": { "@search": {
"description": "search" "description": "search"
@ -1087,6 +1093,15 @@
"description": "transfer stock" "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": "Translate",
"@translate": {}, "@translate": {},

View File

@ -57,6 +57,9 @@ class UserProfile {
} }
} }
/*
* Class for storing and managing user (server) profiles
*/
class UserProfileDBManager { class UserProfileDBManager {
final store = StoreRef("profiles"); final store = StoreRef("profiles");
@ -96,6 +99,8 @@ class UserProfileDBManager {
if (exists) { if (exists) {
debug("addProfile() : UserProfile '${profile.name}' already exists"); debug("addProfile() : UserProfile '${profile.name}' already exists");
return false; return false;
} else {
debug("Adding new profile: '${profile.name}'");
} }
int key = await store.add(await _db, profile.toJson()) as int; 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++) { 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) { if (profiles[idx].key is int && profiles[idx].key == selected) {
return UserProfile.fromJson( return UserProfile.fromJson(
profiles[idx].key as int, profiles[idx].key as int,
@ -190,6 +193,24 @@ class UserProfileDBManager {
return profileList; 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 * Mark the particular profile as selected
*/ */

View File

@ -131,7 +131,7 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
children.add( children.add(
ListTile( ListTile(
title: Text(L10().parentCategory), title: Text(L10().parentCategory),
subtitle: Text("${category?.parentpathstring}"), subtitle: Text("${category?.parentPathString}"),
leading: FaIcon( leading: FaIcon(
FontAwesomeIcons.levelUpAlt, FontAwesomeIcons.levelUpAlt,
color: COLOR_CLICK, 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:inventree/helpers.dart";
import "package:one_context/one_context.dart"; import "package:one_context/one_context.dart";
import "package:inventree/api.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
import "package:inventree/widget/snacks.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 { 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; 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( OneContext().showDialog(
builder: (context) => SimpleDialog( builder: (context) => SimpleDialog(
title: ListTile( title: ListTile(
title: Text(_error), title: Text(title),
leading: FaIcon(icon), leading: FaIcon(icon),
), ),
children: [ children: children
ListTile(
title: Text(title),
subtitle: Text(description),
)
],
) )
).then((value) { ).then((value) {
if (onDismissed != null) { if (onDismissed != null) {
@ -106,9 +174,8 @@ Future<void> showServerError(String url, String title, String description) async
actionText: L10().details, actionText: L10().details,
onAction: () { onAction: () {
showErrorDialog( showErrorDialog(
title, L10().serverError,
description, description: description,
error: L10().serverError,
icon: FontAwesomeIcons.server icon: FontAwesomeIcons.server
); );
} }

View File

@ -40,27 +40,6 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
List<Widget> actions = []; 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) { if (location != null) {
// Add "locate" button // Add "locate" button
@ -252,7 +231,7 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
children.add( children.add(
ListTile( ListTile(
title: Text(L10().parentLocation), title: Text(L10().parentLocation),
subtitle: Text("${location!.parentpathstring}"), subtitle: Text("${location!.parentPathString}"),
leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK), leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK),
onTap: () { onTap: () {
@ -381,6 +360,7 @@ List<Widget> detailTiles() {
title: Text(L10().locationCreate), title: Text(L10().locationCreate),
subtitle: Text(L10().locationCreateDetail), subtitle: Text(L10().locationCreateDetail),
leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK), leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
onTap: () async { onTap: () async {
_newLocation(context); _newLocation(context);
}, },
@ -392,6 +372,7 @@ List<Widget> detailTiles() {
title: Text(L10().stockItemCreate), title: Text(L10().stockItemCreate),
subtitle: Text(L10().stockItemCreateDetail), subtitle: Text(L10().stockItemCreateDetail),
leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK), leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
onTap: () async { onTap: () async {
_newStockItem(context); _newStockItem(context);
}, },
@ -401,14 +382,15 @@ List<Widget> detailTiles() {
} }
if (location != null) { if (location != null) {
// Stock adjustment actions
// Scan stock item into location
if (InvenTreeAPI().checkPermission("stock", "change")) { if (InvenTreeAPI().checkPermission("stock", "change")) {
// Scan items into location
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().barcodeScanInItems), title: Text(L10().barcodeScanItem),
subtitle: Text(L10().barcodeScanInItems),
leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
trailing: Icon(Icons.qr_code), trailing: Icon(Icons.qr_code, color: COLOR_CLICK),
onTap: () { onTap: () {
var _loc = location; var _loc = location;
@ -426,20 +408,34 @@ List<Widget> detailTiles() {
}, },
) )
); );
}
}
// Move location into another location // Scan this location into another one
// TODO: Implement this! if (InvenTreeAPI().checkPermission("stock_location", "change")) {
/*
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("Move Stock Location"), title: Text(L10().transferStockLocation),
leading: FaIcon(FontAwesomeIcons.sitemap), subtitle: Text(L10().transferStockLocationDetail),
trailing: Icon(Icons.qr_code), 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);
});
}
}
) )
); );
*/ }
}
}
if (tiles.length <= 1) { if (tiles.length <= 1) {
tiles.add( tiles.add(

View File

@ -381,7 +381,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("${part.keywords}"), 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) { void receiveLine(BuildContext context, InvenTreePOLineItem lineItem) {
Map<String, dynamic> fields = { Map<String, dynamic> fields = {

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
import "package:test/test.dart"; import "package:test/test.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/helpers.dart";
import "package:inventree/user_profile.dart"; import "package:inventree/user_profile.dart";
@ -94,6 +95,9 @@ void main() {
assert(!api.checkConnection()); assert(!api.checkConnection());
debugContains("Token request failed: STATUS 401");
debugContains("showSnackIcon: 'Not Connected'");
} else { } else {
assert(false); assert(false);
} }
@ -137,6 +141,9 @@ void main() {
assert(api.checkPermission("stocklocation", "delete")); assert(api.checkPermission("stocklocation", "delete"));
assert(!api.checkPermission("part", "weirdpermission")); assert(!api.checkPermission("part", "weirdpermission"));
assert(api.checkPermission("blah", "bloo")); 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 * Unit tests for accessing various model classes via the API
*/ */
import "package:inventree/inventree/model.dart";
import "package:test/test.dart"; import "package:test/test.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/user_profile.dart"; import "package:inventree/user_profile.dart";
import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/part.dart";
@ -112,32 +112,40 @@ void main() {
assert(result != null); assert(result != null);
assert(result is InvenTreePart); assert(result is InvenTreePart);
APIResponse? response;
if (result != null) { if (result != null) {
InvenTreePart part = result as InvenTreePart; InvenTreePart part = result as InvenTreePart;
assert(part.name == "M2x4 LPHS"); assert(part.name == "M2x4 LPHS");
// Change the name to something else // Change the name to something else
assert(await part.update(
response = await part.update(
values: { values: {
"name": "Woogle", "name": "Woogle",
} }
)); );
assert(response.isValid());
assert(response.statusCode == 200);
assert(await part.reload()); assert(await part.reload());
assert(part.name == "Woogle"); assert(part.name == "Woogle");
// And change it back again // And change it back again
assert(await part.update( response = await part.update(
values: { values: {
"name": "M2x4 LPHS" "name": "M2x4 LPHS"
} }
)); );
assert(response.isValid());
assert(response.statusCode == 200);
assert(await part.reload()); assert(await part.reload());
assert(part.name == "M2x4 LPHS"); assert(part.name == "M2x4 LPHS");
} }
}); });
}); });
} }