From ee553af93b4ca48d23f3d1f810d7bf4ccb1d0a40 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 2 Feb 2026 22:52:53 +1100 Subject: [PATCH] Part requirements (#761) * Add setting to control if part requirements are shown * Split settings for Part and Stock * Fetch part requirements information * Add "building" indicator * Show order allocation progress for part requirements * Bump release notes * Remove unused import --- assets/release_notes.md | 1 + lib/api.dart | 3 + lib/inventree/part.dart | 58 +++++++++++++ lib/l10n/app_en.arb | 21 +++++ lib/preferences.dart | 1 + lib/settings/part_settings.dart | 63 ++------------ lib/settings/settings.dart | 15 ++++ lib/settings/stock_settings.dart | 110 +++++++++++++++++++++++ lib/widget/part/part_detail.dart | 144 +++++++++++++++++++++++++++---- lib/widget/progress.dart | 11 +++ 10 files changed, 357 insertions(+), 70 deletions(-) create mode 100644 lib/settings/stock_settings.dart diff --git a/assets/release_notes.md b/assets/release_notes.md index 8c91ef4..0bd3704 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,7 @@ ### x.xx.x - Month Year --- +- Display overall part requirements on part detail view - Support display of custom status codes - Fix default values for list sorting - Fix bug related to null values in list filters diff --git a/lib/api.dart b/lib/api.dart index 61a6379..c4f6e8f 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -353,6 +353,9 @@ class InvenTreeAPI { // Supports separate search against "supplier" / "customer" / "manufacturer" bool get supportsSplitCompanySearch => apiVersion >= 315; + // Supports "requirements" information for specific part + bool get supportsPartRequirements => apiVersion >= 350; + // Does the server support the "modern" (consolidated) parameter API? // Ref: https://github.com/inventree/InvenTree/pull/10699 bool get supportsModernParameters => apiVersion >= 429; diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 033fae9..3510d50 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -214,6 +214,27 @@ class InvenTreePart extends InvenTreeModel { }); } + // Request requirements information for this part + Future getRequirements() async { + try { + final response = await InvenTreeAPI().get( + "/api/part/${pk}/requirements/", + ); + if (response.isValid()) { + final requirementsData = response.data; + + if (requirementsData is Map) { + return InvenTreePartRequirements.fromJson(requirementsData); + } + } + } catch (e, stackTrace) { + print("Exception while fetching requirements data for part $pk: $e"); + sentryReportError("getRequirements", e, stackTrace); + } + + return null; + } + // Request pricing data for this part Future getPricing() async { try { @@ -437,6 +458,43 @@ class InvenTreePart extends InvenTreeModel { InvenTreePart.fromJson(json); } +/* + * Class representing requirements information for a given Part instance. + */ +class InvenTreePartRequirements extends InvenTreeModel { + InvenTreePartRequirements() : super(); + + InvenTreePartRequirements.fromJson(Map json) + : super.fromJson(json); + + @override + List get rolesRequired => ["part"]; + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreePartRequirements.fromJson(json); + + // Data accessors + double get canBuild => getDouble("can_build"); + + double get ordering => getDouble("ordering"); + + double get building => getDouble("building"); + + double get scheduledToBuild => getDouble("scheduled_to_build"); + + double get requiredForBuildOrders => getDouble("required_for_build_orders"); + + double get allocatedToBuildOrders => getDouble("allocated_to_build_orders"); + + double get requiredForSalesOrders => getDouble("required_for_sales_orders"); + + double get allocatedToSalesOrders => getDouble("allocated_to_sales_orders"); +} + +/* + * Class representing pricing information for a given Part instance. + */ class InvenTreePartPricing extends InvenTreeModel { InvenTreePartPricing() : super(); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4f6560f..cd5faad 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -68,6 +68,12 @@ "allocatedStock": "Allocated Stock", "@allocatedStock": {}, + "allocatedToBuildOrders": "Allocated to Build Orders", + "@allocatedToBuildOrders": {}, + + "allocatedToSalesOrders": "Allocated to Sales Orders", + "@allocatedToSalesOrders": {}, + "appReleaseNotes": "Display app release notes", "@appReleaseNotes": {}, @@ -226,6 +232,12 @@ "cameraInternalDetail": "Use internal camera to read barcodes", "@cameraInternalDetail": {}, + "canBuild": "Can Build", + "@canBuild": {}, + + "canBuildDetail": "Can be produced with current stock", + "@canBuildDetail": {}, + "cancel": "Cancel", "@cancel": { "description": "Cancel" @@ -936,6 +948,12 @@ "partPricingSettingDetail": "Display part pricing information", "@pricingSettingDetail": {}, + "partRequirements": "Part Requirements", + "@partRequirements": {}, + + "partRequirementsSettingDetail": "Display part requirements", + "@partRequirementsSettingDetail": {}, + "partSettings": "Part Settings", "@partSettings": {}, @@ -1535,6 +1553,9 @@ "stockLocations": "Stock Locations", "@stockLocations": {}, + "stockSettings": "Stock Settings", + "@stockSettings": {}, + "stockTopLevel": "Top level stock location", "@stockTopLevel": {}, diff --git a/lib/preferences.dart b/lib/preferences.dart index 92eda1f..a4bc05d 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -34,6 +34,7 @@ const String INV_LABEL_DEFAULT_PLUGIN = "defaultLabelPlugin"; // Part settings const String INV_PART_SHOW_BOM = "partShowBom"; const String INV_PART_SHOW_PRICING = "partShowPricing"; +const String INV_PART_SHOW_REQUIREMENTS = "partShowRequirements"; // Stock settings const String INV_STOCK_SHOW_HISTORY = "stockShowHistory"; diff --git a/lib/settings/part_settings.dart b/lib/settings/part_settings.dart index aa13678..fdb5c45 100644 --- a/lib/settings/part_settings.dart +++ b/lib/settings/part_settings.dart @@ -15,9 +15,7 @@ class _InvenTreePartSettingsState extends State { bool partShowBom = true; bool partShowPricing = true; - bool stockShowHistory = false; - bool stockShowTests = false; - bool stockConfirmScan = false; + bool partShowRequirements = false; @override void initState() { @@ -35,16 +33,8 @@ class _InvenTreePartSettingsState extends State { INV_PART_SHOW_PRICING, true, ); - stockShowHistory = await InvenTreeSettingsManager().getBool( - INV_STOCK_SHOW_HISTORY, - false, - ); - stockShowTests = await InvenTreeSettingsManager().getBool( - INV_STOCK_SHOW_TESTS, - true, - ); - stockConfirmScan = await InvenTreeSettingsManager().getBool( - INV_STOCK_CONFIRM_SCAN, + partShowRequirements = await InvenTreeSettingsManager().getBool( + INV_PART_SHOW_REQUIREMENTS, false, ); @@ -94,54 +84,19 @@ class _InvenTreePartSettingsState extends State { }, ), ), - Divider(), ListTile( - title: Text(L10().stockItemHistory), - subtitle: Text(L10().stockItemHistoryDetail), - leading: Icon(TablerIcons.history), + title: Text(L10().partRequirements), + subtitle: Text(L10().partRequirementsSettingDetail), + leading: Icon(TablerIcons.list), trailing: Switch( - value: stockShowHistory, + value: partShowRequirements, onChanged: (bool value) { InvenTreeSettingsManager().setValue( - INV_STOCK_SHOW_HISTORY, + INV_PART_SHOW_REQUIREMENTS, value, ); setState(() { - stockShowHistory = value; - }); - }, - ), - ), - ListTile( - title: Text(L10().testResults), - subtitle: Text(L10().testResultsDetail), - leading: Icon(TablerIcons.test_pipe), - trailing: Switch( - value: stockShowTests, - onChanged: (bool value) { - InvenTreeSettingsManager().setValue( - INV_STOCK_SHOW_TESTS, - value, - ); - setState(() { - stockShowTests = value; - }); - }, - ), - ), - ListTile( - title: Text(L10().confirmScan), - subtitle: Text(L10().confirmScanDetail), - leading: Icon(TablerIcons.qrcode), - trailing: Switch( - value: stockConfirmScan, - onChanged: (bool value) { - InvenTreeSettingsManager().setValue( - INV_STOCK_CONFIRM_SCAN, - value, - ); - setState(() { - stockConfirmScan = value; + partShowRequirements = value; }); }, ), diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 3815127..15cd37a 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/settings/stock_settings.dart"; import "package:package_info_plus/package_info_plus.dart"; import "package:inventree/app_colors.dart"; @@ -119,6 +120,20 @@ class _InvenTreeSettingsState extends State { ); }, ), + ListTile( + title: Text(L10().stock), + subtitle: Text(L10().stockSettings), + leading: Icon(TablerIcons.packages, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvenTreeStockSettingsWidget(), + ), + ); + }, + ), ListTile( title: Text(L10().purchaseOrder), subtitle: Text(L10().purchaseOrderSettings), diff --git a/lib/settings/stock_settings.dart b/lib/settings/stock_settings.dart new file mode 100644 index 0000000..3856d23 --- /dev/null +++ b/lib/settings/stock_settings.dart @@ -0,0 +1,110 @@ +import "package:flutter/material.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/preferences.dart"; + +class InvenTreeStockSettingsWidget extends StatefulWidget { + @override + _InvenTreeStockSettingsState createState() => _InvenTreeStockSettingsState(); +} + +class _InvenTreeStockSettingsState extends State { + _InvenTreeStockSettingsState(); + + bool stockShowHistory = false; + bool stockShowTests = false; + bool stockConfirmScan = false; + + @override + void initState() { + super.initState(); + loadSettings(); + } + + Future loadSettings() async { + stockShowHistory = await InvenTreeSettingsManager().getBool( + INV_STOCK_SHOW_HISTORY, + false, + ); + stockShowTests = await InvenTreeSettingsManager().getBool( + INV_STOCK_SHOW_TESTS, + true, + ); + stockConfirmScan = await InvenTreeSettingsManager().getBool( + INV_STOCK_CONFIRM_SCAN, + false, + ); + + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(L10().stockSettings), + backgroundColor: COLOR_APP_BAR, + ), + body: Container( + child: ListView( + children: [ + ListTile( + title: Text(L10().stockItemHistory), + subtitle: Text(L10().stockItemHistoryDetail), + leading: Icon(TablerIcons.history), + trailing: Switch( + value: stockShowHistory, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_STOCK_SHOW_HISTORY, + value, + ); + setState(() { + stockShowHistory = value; + }); + }, + ), + ), + ListTile( + title: Text(L10().testResults), + subtitle: Text(L10().testResultsDetail), + leading: Icon(TablerIcons.test_pipe), + trailing: Switch( + value: stockShowTests, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_STOCK_SHOW_TESTS, + value, + ); + setState(() { + stockShowTests = value; + }); + }, + ), + ), + ListTile( + title: Text(L10().confirmScan), + subtitle: Text(L10().confirmScanDetail), + leading: Icon(TablerIcons.qrcode), + trailing: Switch( + value: stockConfirmScan, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_STOCK_CONFIRM_SCAN, + value, + ); + setState(() { + stockConfirmScan = value; + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widget/part/part_detail.dart b/lib/widget/part/part_detail.dart index 73a299f..08c6df5 100644 --- a/lib/widget/part/part_detail.dart +++ b/lib/widget/part/part_detail.dart @@ -54,6 +54,7 @@ class _PartDisplayState extends RefreshableState { bool allowLabelPrinting = false; bool showBom = false; bool showPricing = false; + bool showRequirements = false; int parameterCount = 0; int attachmentCount = 0; @@ -62,6 +63,7 @@ class _PartDisplayState extends RefreshableState { int variantCount = 0; InvenTreePartPricing? partPricing; + InvenTreePartRequirements? partRequirements; @override String getAppBarTitle() => L10().partDetails; @@ -148,6 +150,12 @@ class _PartDisplayState extends RefreshableState { final bool result = await part.reload(); // Load page settings from local storage + + showRequirements = await InvenTreeSettingsManager().getBool( + INV_PART_SHOW_REQUIREMENTS, + false, + ); + showPricing = await InvenTreeSettingsManager().getBool( INV_PART_SHOW_PRICING, true, @@ -233,6 +241,23 @@ class _PartDisplayState extends RefreshableState { }); } + // If show requirements information? + if (showRequirements && api.supportsPartRequirements) { + part.getRequirements().then((InvenTreePartRequirements? requirements) { + if (mounted) { + setState(() { + partRequirements = requirements; + }); + } + }); + } else { + if (mounted) { + setState(() { + partRequirements = null; + }); + } + } + // If show pricing information? if (showPricing) { part.getPricing().then((InvenTreePartPricing? pricing) { @@ -242,6 +267,12 @@ class _PartDisplayState extends RefreshableState { }); } }); + } else { + if (mounted) { + setState(() { + partPricing = null; + }); + } } // Request the number of BOM items @@ -434,6 +465,103 @@ class _PartDisplayState extends RefreshableState { ); } + // Part "requirements" + if (showRequirements && + api.supportsPartRequirements && + partRequirements != null) { + // Assembly parts + if (part.isAssembly) { + // Scheduled to build + if (partRequirements!.building > 0 || + partRequirements!.scheduledToBuild > 0) { + tiles.add( + ListTile( + title: Text(L10().building), + subtitle: ProgressBar( + partRequirements!.building, + maximum: partRequirements!.scheduledToBuild, + ), + leading: Icon(TablerIcons.tools), + trailing: ProgressText( + partRequirements!.building, + maximum: partRequirements!.scheduledToBuild, + ), + ), + ); + } + + // Can build + if (part.isActive) { + tiles.add( + ListTile( + title: Text(L10().canBuild), + subtitle: Text(L10().canBuildDetail), + trailing: LargeText( + simpleNumberString(partRequirements!.canBuild), + ), + leading: Icon(TablerIcons.check), + ), + ); + } + } + + // Build requirements + if (partRequirements!.requiredForBuildOrders > 0 || + partRequirements!.allocatedToBuildOrders > 0) { + tiles.add( + ListTile( + title: Text(L10().allocatedToBuildOrders), + subtitle: ProgressBar( + partRequirements!.allocatedToBuildOrders, + maximum: partRequirements!.requiredForBuildOrders, + ), + trailing: ProgressText( + partRequirements!.allocatedToBuildOrders, + maximum: partRequirements!.requiredForBuildOrders, + ), + leading: Icon(TablerIcons.tools), + ), + ); + } + + // Sales requirements + if (part.isSalable) { + if (partRequirements!.requiredForSalesOrders > 0 || + partRequirements!.allocatedToSalesOrders > 0) { + tiles.add( + ListTile( + title: Text(L10().allocatedToSalesOrders), + subtitle: ProgressBar( + partRequirements!.allocatedToSalesOrders, + maximum: partRequirements!.requiredForSalesOrders, + ), + trailing: ProgressText( + partRequirements!.allocatedToSalesOrders, + maximum: partRequirements!.requiredForSalesOrders, + ), + leading: Icon(TablerIcons.truck_delivery), + ), + ); + } + } + + // Ordering stats + if (part.isPurchaseable && partRequirements!.ordering > 0) { + // On order + tiles.add( + ListTile( + title: Text(L10().onOrder), + subtitle: Text(L10().onOrderDetails), + leading: Icon(TablerIcons.shopping_cart), + trailing: LargeText("${part.onOrderString}"), + onTap: () { + // TODO - Order views + }, + ), + ); + } + } + if (showPricing && partPricing != null) { String pricing = formatPriceRange( partPricing?.overallMin, @@ -462,22 +590,6 @@ class _PartDisplayState extends RefreshableState { ); } - // Tiles for "purchaseable" parts - if (part.isPurchaseable) { - // On order - tiles.add( - ListTile( - title: Text(L10().onOrder), - subtitle: Text(L10().onOrderDetails), - leading: Icon(TablerIcons.shopping_cart), - trailing: LargeText("${part.onOrderString}"), - onTap: () { - // TODO - Order views - }, - ), - ); - } - // Tiles for an "assembly" part if (part.isAssembly) { if (showBom && bomCount > 0) { diff --git a/lib/widget/progress.dart b/lib/widget/progress.dart index a95ace9..bb043ad 100644 --- a/lib/widget/progress.dart +++ b/lib/widget/progress.dart @@ -3,6 +3,8 @@ import "dart:io"; import "package:flutter/material.dart"; import "package:flutter_overlay_loader/flutter_overlay_loader.dart"; import "package:inventree/app_colors.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/widget/link_icon.dart"; import "package:one_context/one_context.dart"; /* @@ -25,6 +27,15 @@ Widget ProgressBar(double value, {double maximum = 1.0}) { ); } +Widget ProgressText(double value, {double maximum = 1.0}) { + Color textColor = value < maximum ? COLOR_WARNING : COLOR_SUCCESS; + + String v = simpleNumberString(value); + String m = simpleNumberString(maximum); + + return LargeText("${v} / ${m}", color: textColor); +} + /* * Construct a circular progress indicator */