From 3ea5f8934c06de8e7fe10fc336581216cf39e82b Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 27 Nov 2023 22:51:20 +1100 Subject: [PATCH] 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 --- assets/release_notes.md | 2 +- lib/api_form.dart | 21 +++++ lib/inventree/sales_order.dart | 67 +++++++++++++- lib/l10n/app_en.arb | 12 +++ lib/widget/order/sales_order_detail.dart | 37 +++++++- lib/widget/order/so_line_detail.dart | 110 +++++++++++++++++++++-- lib/widget/order/so_line_list.dart | 2 +- lib/widget/order/so_shipment_list.dart | 55 ++++++++++++ 8 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 lib/widget/order/so_shipment_list.dart diff --git a/assets/release_notes.md b/assets/release_notes.md index 58fb5f1e..5c767a75 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -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 --- diff --git a/lib/api_form.dart b/lib/api_form.dart index 043eedbb..97b685b4 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -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"; diff --git a/lib/inventree/sales_order.dart b/lib/inventree/sales_order.dart index 4cbc1ab0..709ddceb 100644 --- a/lib/inventree/sales_order.dart +++ b/lib/inventree/sales_order.dart @@ -26,6 +26,8 @@ class InvenTreeSalesOrder extends InvenTreeOrder { @override List get rolesRequired => ["sales_order"]; + String get allocate_url => "${url}allocate/"; + @override Map> formFields() { Map> 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 json) : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) => InvenTreeSalesOrderShipment.fromJson(json); + + @override + String get URL => "/order/so/shipment/"; + + @override + Map> formFields() { + Map> 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/"; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b8afdd5e..231a79e8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {}, diff --git a/lib/widget/order/sales_order_detail.dart b/lib/widget/order/sales_order_detail.dart index 2a489ac6..0f099616 100644 --- a/lib/widget/order/sales_order_detail.dart +++ b/lib/widget/order/sales_order_detail.dart @@ -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 { return actions; } + // Add a new shipment against this sales order + Future _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 _addLineItem(BuildContext context) async { var fields = InvenTreeSOLineItem().formFields(); @@ -94,6 +114,16 @@ class _SalesOrderDetailState extends RefreshableState { } ) ); + + actions.add( + SpeedDialChild( + child: FaIcon(FontAwesomeIcons.circlePlus), + label: L10().shipmentAdd, + onTap: () async { + _addShipment(context); + } + ) + ); } return actions; @@ -225,7 +255,7 @@ class _SalesOrderDetailState extends RefreshableState { )); } - 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 { 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 { return [ ListView(children: orderTiles(context)), PaginatedSOLineList({"order": widget.order.pk.toString()}), - // Center(), // TODO: Delivered stock + PaginatedSOShipmentList({"order": widget.order.pk.toString()}), ]; } diff --git a/lib/widget/order/so_line_detail.dart b/lib/widget/order/so_line_detail.dart index e0ddd898..b5d88e74 100644 --- a/lib/widget/order/so_line_detail.dart +++ b/lib/widget/order/so_line_detail.dart @@ -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 { _SOLineDetailWidgetState(); + InvenTreeSalesOrder? order; + @override String getAppBarTitle() => L10().lineItem; @@ -55,6 +60,53 @@ class _SOLineDetailWidgetState extends RefreshableState { return actions; } + Future _allocateStock(BuildContext context) async { + + if (order == null) { + return; + } + + Map 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 _editLineItem(BuildContext context) async { var fields = widget.item.formFields(); @@ -76,13 +128,35 @@ class _SOLineDetailWidgetState extends RefreshableState { @override List actionButtons(BuildContext context) { - // TODO - return []; + + List 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 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 { ) ); + // 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( diff --git a/lib/widget/order/so_line_list.dart b/lib/widget/order/so_line_list.dart index 6d7e1acd..11ba2273 100644 --- a/lib/widget/order/so_line_list.dart +++ b/lib/widget/order/so_line_list.dart @@ -63,7 +63,7 @@ class _PaginatedSOLineListState extends PaginatedSearchState filters) : super(filters: filters); + + @override + String get searchTitle => L10().shipments; + + @override + _PaginatedSOShipmentListState createState() => _PaginatedSOShipmentListState(); +} + + +class _PaginatedSOShipmentListState extends PaginatedSearchState { + + _PaginatedSOShipmentListState() : super(); + + @override + String get prefix => "so_shipment_"; + + @override + Map get orderingOptions => {}; + + @override + Map> get filterOptions => {}; + + @override + Future requestPage(int limit, int offset, Map 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 + ); + + } +} \ No newline at end of file