mirror of
				https://github.com/inventree/inventree-app.git
				synced 2025-10-31 21:35:42 +00:00 
			
		
		
		
	Sales order allocation (#464)
* New string * Typo fix * Add model for SalesOrderShipment * Add placeholder button to sales order item * Create a new shipment from the sales order detail view * Fix API URL * Add paginated shipment list * Upate colors * Add API form for allocation of stock to sales order * Build out sales order line detail widge * Use unallocated quantity * Update release notes * linting fix
This commit is contained in:
		| @@ -9,7 +9,7 @@ | ||||
| - Add line items to purchase order using barcode scanner | ||||
| - Add line items to sales orders directly from the app | ||||
| - Add line items to sales order using barcode scanner | ||||
|  | ||||
| - Allocate stock items against existing sales orders | ||||
|  | ||||
| ### 0.13.0 - October 2023 | ||||
| --- | ||||
|   | ||||
| @@ -12,6 +12,7 @@ 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"; | ||||
|  | ||||
| import "package:inventree/inventree/company.dart"; | ||||
| @@ -591,6 +592,8 @@ class APIFormField { | ||||
|     switch (model) { | ||||
|       case "supplierpart": | ||||
|         return InvenTreeSupplierPart().defaultListFilters(); | ||||
|       case "stockitem": | ||||
|         return InvenTreeStockItem().defaultListFilters(); | ||||
|     } | ||||
|  | ||||
|     return {}; | ||||
| @@ -658,6 +661,16 @@ class APIFormField { | ||||
|             style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), | ||||
|           ) : null, | ||||
|         ); | ||||
|       case "stockitem": | ||||
|         var item = InvenTreeStockItem.fromJson(data); | ||||
|  | ||||
|         return ListTile( | ||||
|           title: Text( | ||||
|             item.partName, | ||||
|           ), | ||||
|           leading: InvenTreeAPI().getThumbnail(item.partThumbnail), | ||||
|           trailing: Text(item.quantityString()), | ||||
|         ); | ||||
|       case "stocklocation": | ||||
|  | ||||
|         var loc = InvenTreeStockLocation.fromJson(data); | ||||
| @@ -672,6 +685,14 @@ class APIFormField { | ||||
|             style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), | ||||
|           ) : null, | ||||
|         ); | ||||
|       case "salesordershipment": | ||||
|         var shipment = InvenTreeSalesOrderShipment.fromJson(data); | ||||
|  | ||||
|         return ListTile( | ||||
|           title: Text(shipment.reference), | ||||
|           subtitle: Text(shipment.tracking_number), | ||||
|           trailing: shipment.shipped ? Text(shipment.shipment_date!) : null, | ||||
|         ); | ||||
|       case "owner": | ||||
|         String name = (data["name"] ?? "") as String; | ||||
|         bool isGroup = (data["label"] ?? "") == "group"; | ||||
|   | ||||
| @@ -26,6 +26,8 @@ class InvenTreeSalesOrder extends InvenTreeOrder { | ||||
|   @override | ||||
|   List<String> get rolesRequired => ["sales_order"]; | ||||
|  | ||||
|   String get allocate_url => "${url}allocate/"; | ||||
|  | ||||
|   @override | ||||
|   Map<String, Map<String, dynamic>> formFields() { | ||||
|     Map<String, Map<String, dynamic>> fields = { | ||||
| @@ -148,10 +150,32 @@ class InvenTreeSOLineItem extends InvenTreeOrderLine { | ||||
|  | ||||
|   bool get isAllocated => allocated >= quantity; | ||||
|  | ||||
|   double get allocatedRatio { | ||||
|     if (quantity <= 0 || allocated <= 0) { | ||||
|       return 0; | ||||
|     } | ||||
|  | ||||
|     return allocated / quantity; | ||||
|   } | ||||
|  | ||||
|   double get unallocatedQuantity { | ||||
|     double unallocated = quantity - allocated; | ||||
|  | ||||
|     if (unallocated < 0) { | ||||
|       unallocated = 0; | ||||
|     } | ||||
|  | ||||
|     return unallocated; | ||||
|   } | ||||
|  | ||||
|   String get allocatedString => simpleNumberString(allocated) + " / " + simpleNumberString(quantity); | ||||
|  | ||||
|   double get shipped => getDouble("shipped"); | ||||
|  | ||||
|   double get outstanding => quantity - shipped; | ||||
|  | ||||
|   double get availableStock => getDouble("available_stock"); | ||||
|  | ||||
|   double get progressRatio { | ||||
|     if (quantity <= 0 || shipped <= 0) { | ||||
|       return 0; | ||||
| @@ -173,6 +197,47 @@ class InvenTreeSOLineItem extends InvenTreeOrderLine { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Class representing a sales order shipment | ||||
|  */ | ||||
| class InvenTreeSalesOrderShipment extends InvenTreeModel { | ||||
|  | ||||
|   InvenTreeSalesOrderShipment() : super(); | ||||
|  | ||||
|   InvenTreeSalesOrderShipment.fromJson(Map<String, dynamic> json) : super.fromJson(json); | ||||
|  | ||||
|   @override | ||||
|   InvenTreeModel createFromJson(Map<String, dynamic> json) => InvenTreeSalesOrderShipment.fromJson(json); | ||||
|  | ||||
|   @override | ||||
|   String get URL => "/order/so/shipment/"; | ||||
|  | ||||
|   @override | ||||
|   Map<String, Map<String, dynamic>> formFields() { | ||||
|     Map<String, Map<String, dynamic>> fields = { | ||||
|       "order": {}, | ||||
|       "reference": {}, | ||||
|       "tracking_number": {}, | ||||
|       "invoice_number": {}, | ||||
|       "link": {}, | ||||
|     }; | ||||
|  | ||||
|     return fields; | ||||
|   } | ||||
|  | ||||
|   String get reference => getString("reference"); | ||||
|  | ||||
|   String get tracking_number => getString("tracking_number"); | ||||
|  | ||||
|   String get invoice_number => getString("invoice_number"); | ||||
|  | ||||
|   String? get shipment_date => getString("shipment_date"); | ||||
|  | ||||
|   bool get shipped => shipment_date != null && shipment_date!.isNotEmpty; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Class representing an attachment file against a SalesOrder object | ||||
|  */ | ||||
| @@ -189,6 +254,6 @@ class InvenTreeSalesOrderAttachment extends InvenTreeAttachment { | ||||
|   String get REFERENCE_FIELD => "order"; | ||||
|  | ||||
|   @override | ||||
|   String get URL => "order/po/attachment/"; | ||||
|   String get URL => "order/so/attachment/"; | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -47,6 +47,12 @@ | ||||
|   "appDetails": "App Details", | ||||
|   "@appDetails": {}, | ||||
|  | ||||
|   "allocated": "Allocated", | ||||
|   "@allocated": {}, | ||||
|  | ||||
|   "allocateStock": "Allocate Stock", | ||||
|   "@allocateStock": {}, | ||||
|  | ||||
|   "appReleaseNotes": "Display app release notes", | ||||
|   "@appReleaseNotes": {}, | ||||
|  | ||||
| @@ -1182,6 +1188,12 @@ | ||||
|   "serverNotSelected": "Server not selected", | ||||
|   "@serverNotSelected": {}, | ||||
|  | ||||
|   "shipments": "Shipments", | ||||
|   "@shipments": {}, | ||||
|  | ||||
|   "shipmentAdd": "Add Shipment", | ||||
|   "@shipmentAdd": {}, | ||||
|  | ||||
|   "shipped": "Shipped", | ||||
|   "@shipped": {}, | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import "package:inventree/barcode/sales_order.dart"; | ||||
| import "package:inventree/inventree/company.dart"; | ||||
| import "package:inventree/inventree/sales_order.dart"; | ||||
| import "package:inventree/widget/order/so_line_list.dart"; | ||||
| import "package:inventree/widget/order/so_shipment_list.dart"; | ||||
| import "package:inventree/widget/refreshable_state.dart"; | ||||
|  | ||||
| import "package:inventree/l10.dart"; | ||||
| @@ -62,6 +63,25 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> { | ||||
|     return actions; | ||||
|   } | ||||
|  | ||||
|   // Add a new shipment against this sales order | ||||
|   Future<void> _addShipment(BuildContext context) async { | ||||
|  | ||||
|     var fields = InvenTreeSalesOrderShipment().formFields(); | ||||
|  | ||||
|     fields["order"]?["value"] = widget.order.pk; | ||||
|     fields["order"]?["hidden"] = true; | ||||
|  | ||||
|     InvenTreeSalesOrderShipment().createForm( | ||||
|       context, | ||||
|       L10().shipmentAdd, | ||||
|       fields: fields, | ||||
|       onSuccess: (result) async { | ||||
|         refresh(context); | ||||
|       } | ||||
|     ); | ||||
|  | ||||
|   } | ||||
|  | ||||
|   // Add a new line item to this sales order | ||||
|   Future<void> _addLineItem(BuildContext context) async { | ||||
|     var fields = InvenTreeSOLineItem().formFields(); | ||||
| @@ -94,6 +114,16 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> { | ||||
|           } | ||||
|         ) | ||||
|       ); | ||||
|  | ||||
|       actions.add( | ||||
|         SpeedDialChild( | ||||
|           child: FaIcon(FontAwesomeIcons.circlePlus), | ||||
|           label: L10().shipmentAdd, | ||||
|           onTap: () async { | ||||
|             _addShipment(context); | ||||
|           } | ||||
|         ) | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return actions; | ||||
| @@ -225,7 +255,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> { | ||||
|       )); | ||||
|     } | ||||
|  | ||||
|     Color lineColor = widget.order.complete ? COLOR_WARNING : COLOR_SUCCESS; | ||||
|     Color lineColor = widget.order.complete ? COLOR_SUCCESS : COLOR_WARNING; | ||||
|  | ||||
|     tiles.add(ListTile( | ||||
|       title: Text(L10().lineItems), | ||||
| @@ -292,8 +322,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> { | ||||
|     return [ | ||||
|       Tab(text: L10().details), | ||||
|       Tab(text: L10().lineItems), | ||||
|       // TODO: Add in the "shipped items" tab | ||||
|       // Tab(text: L10().shipped) | ||||
|       Tab(text: L10().shipments), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
| @@ -302,7 +331,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> { | ||||
|     return [ | ||||
|       ListView(children: orderTiles(context)), | ||||
|       PaginatedSOLineList({"order": widget.order.pk.toString()}), | ||||
|       // Center(), // TODO: Delivered stock | ||||
|       PaginatedSOShipmentList({"order": widget.order.pk.toString()}), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -3,21 +3,24 @@ | ||||
| /* | ||||
|  * Widget for displaying detail view of a single SalesOrderLineItem | ||||
|  */ | ||||
|  | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:flutter_speed_dial/flutter_speed_dial.dart"; | ||||
| import "package:font_awesome_flutter/font_awesome_flutter.dart"; | ||||
|  | ||||
| import "package:inventree/app_colors.dart"; | ||||
| import "package:inventree/l10.dart"; | ||||
| import "package:inventree/inventree/part.dart"; | ||||
| import "package:inventree/inventree/sales_order.dart"; | ||||
|  | ||||
| import "package:inventree/widget/refreshable_state.dart"; | ||||
| import "package:inventree/widget/progress.dart"; | ||||
| import "package:inventree/widget/part/part_detail.dart"; | ||||
|  | ||||
| import "package:inventree/helpers.dart"; | ||||
| import "package:inventree/widget/snacks.dart"; | ||||
|  | ||||
| import "package:inventree/app_colors.dart"; | ||||
| import "package:inventree/l10.dart"; | ||||
| import "package:inventree/helpers.dart"; | ||||
| import "package:inventree/api_form.dart"; | ||||
|  | ||||
|  | ||||
| class SoLineDetailWidget extends StatefulWidget { | ||||
|  | ||||
| @@ -35,6 +38,8 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> { | ||||
|  | ||||
|   _SOLineDetailWidgetState(); | ||||
|  | ||||
|   InvenTreeSalesOrder? order; | ||||
|  | ||||
|   @override | ||||
|   String getAppBarTitle() => L10().lineItem; | ||||
|  | ||||
| @@ -55,6 +60,53 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> { | ||||
|     return actions; | ||||
|   } | ||||
|  | ||||
|   Future<void> _allocateStock(BuildContext context) async { | ||||
|  | ||||
|     if (order == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     Map<String, dynamic> fields = { | ||||
|       "line_item": { | ||||
|         "parent": "items", | ||||
|         "nested": true, | ||||
|         "hidden": true, | ||||
|         "value": widget.item.pk, | ||||
|       }, | ||||
|       "stock_item": { | ||||
|         "parent": "items", | ||||
|         "nested": true, | ||||
|         "filters": { | ||||
|           "part": widget.item.partId, | ||||
|           "in_stock": true, | ||||
|         } | ||||
|       }, | ||||
|       "quantity": { | ||||
|         "parent": "items", | ||||
|         "nested": true, | ||||
|         "value": widget.item.unallocatedQuantity, | ||||
|       }, | ||||
|       "shipment": { | ||||
|         "filters": { | ||||
|           "order": order!.pk.toString(), | ||||
|         } | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     launchApiForm( | ||||
|       context, | ||||
|       L10().allocateStock, | ||||
|       order!.allocate_url, | ||||
|       fields, | ||||
|       method: "POST", | ||||
|       icon: FontAwesomeIcons.rightToBracket, | ||||
|       onSuccess: (data) async { | ||||
|         refresh(context); | ||||
|       } | ||||
|     ); | ||||
|  | ||||
|   } | ||||
|  | ||||
|   Future<void> _editLineItem(BuildContext context) async { | ||||
|     var fields = widget.item.formFields(); | ||||
|  | ||||
| @@ -76,13 +128,35 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> { | ||||
|  | ||||
|   @override | ||||
|   List<SpeedDialChild> actionButtons(BuildContext context) { | ||||
|     // TODO | ||||
|     return []; | ||||
|  | ||||
|     List<SpeedDialChild> buttons = []; | ||||
|  | ||||
|     if (order != null && order!.isOpen) { | ||||
|       buttons.add( | ||||
|         SpeedDialChild( | ||||
|           child: FaIcon(FontAwesomeIcons.rightToBracket, color: Colors.blue), | ||||
|           label: L10().allocateStock, | ||||
|           onTap: () async { | ||||
|             _allocateStock(context); | ||||
|           } | ||||
|         ) | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return buttons; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<void> request(BuildContext context) async { | ||||
|     await widget.item.reload(); | ||||
|  | ||||
|     final so = await InvenTreeSalesOrder().get(widget.item.orderId); | ||||
|  | ||||
|     if (mounted) { | ||||
|       setState(() { | ||||
|         order = (so is InvenTreeSalesOrder ? so : null); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -108,6 +182,30 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> { | ||||
|       ) | ||||
|     ); | ||||
|  | ||||
|     // Available quantity | ||||
|     tiles.add( | ||||
|       ListTile( | ||||
|         title: Text(L10().availableStock), | ||||
|         leading: FaIcon(FontAwesomeIcons.boxesStacked), | ||||
|         trailing: Text(simpleNumberString(widget.item.availableStock)) | ||||
|       ) | ||||
|     ); | ||||
|  | ||||
|     // Allocated quantity | ||||
|     tiles.add( | ||||
|       ListTile( | ||||
|         leading: FaIcon(FontAwesomeIcons.clipboardCheck), | ||||
|         title: Text(L10().allocated), | ||||
|         subtitle: ProgressBar(widget.item.allocatedRatio), | ||||
|         trailing: Text( | ||||
|           widget.item.allocatedString, | ||||
|           style: TextStyle( | ||||
|             color: widget.item.isAllocated ? COLOR_SUCCESS : COLOR_WARNING | ||||
|           ) | ||||
|         ) | ||||
|       ) | ||||
|     ); | ||||
|  | ||||
|     // Shipped quantity | ||||
|     tiles.add( | ||||
|       ListTile( | ||||
|   | ||||
| @@ -63,7 +63,7 @@ class _PaginatedSOLineListState extends PaginatedSearchState<PaginatedSOLineList | ||||
|         title: Text(part.name), | ||||
|         subtitle: Text(part.description), | ||||
|         leading: InvenTreeAPI().getThumbnail(part.thumbnail), | ||||
|         trailing: Text(item.progressString), | ||||
|         trailing: Text(item.progressString, style: TextStyle(color: item.isComplete ? COLOR_SUCCESS : COLOR_WARNING)), | ||||
|         onTap: () async { | ||||
|           showLoadingOverlay(context); | ||||
|           await item.reload(); | ||||
|   | ||||
							
								
								
									
										55
									
								
								lib/widget/order/so_shipment_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								lib/widget/order/so_shipment_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
|  | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:font_awesome_flutter/font_awesome_flutter.dart"; | ||||
| import "package:inventree/app_colors.dart"; | ||||
| import "package:inventree/inventree/sales_order.dart"; | ||||
| import "package:inventree/widget/paginator.dart"; | ||||
|  | ||||
| import "package:inventree/inventree/model.dart"; | ||||
| import "package:inventree/l10.dart"; | ||||
|  | ||||
| class PaginatedSOShipmentList extends PaginatedSearchWidget { | ||||
|  | ||||
|   const PaginatedSOShipmentList(Map<String, String> filters) : super(filters: filters); | ||||
|  | ||||
|   @override | ||||
|   String get searchTitle => L10().shipments; | ||||
|  | ||||
|   @override | ||||
|   _PaginatedSOShipmentListState createState() => _PaginatedSOShipmentListState(); | ||||
| } | ||||
|  | ||||
|  | ||||
| class _PaginatedSOShipmentListState extends PaginatedSearchState<PaginatedSOShipmentList> { | ||||
|  | ||||
|   _PaginatedSOShipmentListState() : super(); | ||||
|  | ||||
|   @override | ||||
|   String get prefix => "so_shipment_"; | ||||
|  | ||||
|   @override | ||||
|   Map<String, String> get orderingOptions => {}; | ||||
|  | ||||
|   @override | ||||
|   Map<String, Map<String, dynamic>> get filterOptions => {}; | ||||
|  | ||||
|   @override | ||||
|   Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async { | ||||
|     final page = await InvenTreeSalesOrderShipment().listPaginated(limit, offset, filters: params); | ||||
|     return page; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget buildItem(BuildContext context, InvenTreeModel model) { | ||||
|  | ||||
|     InvenTreeSalesOrderShipment shipment = model as InvenTreeSalesOrderShipment; | ||||
|  | ||||
|     return ListTile( | ||||
|       title: Text(shipment.reference), | ||||
|       subtitle: Text(shipment.tracking_number), | ||||
|       leading: shipment.shipped ? FaIcon(FontAwesomeIcons.calendarCheck, color: COLOR_SUCCESS) : FaIcon(FontAwesomeIcons.calendarXmark, color: COLOR_WARNING), | ||||
|       trailing: shipment.shipped ? Text(shipment.shipment_date ?? "") : null | ||||
|     ); | ||||
|  | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user