diff --git a/assets/release_notes.md b/assets/release_notes.md index 5c8aae79..14d3df32 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,7 @@ ### 0.19.0 - June 2025 --- - Replace barcode scanning library for better performance +- Display part pricing information - Fix broken documentation link - Updated translations diff --git a/lib/helpers.dart b/lib/helpers.dart index 3a35d73b..349a821d 100644 --- a/lib/helpers.dart +++ b/lib/helpers.dart @@ -140,8 +140,7 @@ Future openLink(String url) async { */ String renderCurrency(double? amount, String currency, {int decimals = 2}) { - if (amount == null) return "-"; - if (amount.isInfinite || amount.isNaN) return "-"; + if (amount == null || amount.isInfinite || amount.isNaN) return "-"; currency = currency.trim(); @@ -157,3 +156,34 @@ String renderCurrency(double? amount, String currency, {int decimals = 2}) { return value; } +bool isValidNumber(double? value) { + return value != null && !value.isNaN && !value.isInfinite; +} + +/* + * Render a "range" of prices between two values. + */ +String formatPriceRange(double? minPrice, double? maxPrice, { String? currency }) { + + // Account for empty or null values + if (!isValidNumber(minPrice) && !isValidNumber(maxPrice)) { + return "-"; + } + + if (isValidNumber(minPrice) && isValidNumber(maxPrice)) { + // Two values are equal + if (minPrice == maxPrice) { + return renderCurrency(minPrice, currency ?? "USD"); + } else { + return "${renderCurrency(minPrice, currency ?? "USD")} - ${renderCurrency(maxPrice, currency ?? "USD")}"; + } + } + + if (isValidNumber(minPrice)) { + return renderCurrency(minPrice, currency ?? "USD"); + } else if (isValidNumber(maxPrice)) { + return renderCurrency(maxPrice, currency ?? "USD"); + } else { + return "-"; + } +} diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index af5b64b5..07b74f83 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -142,7 +142,7 @@ class InvenTreeModel { } // Helper function to get double value from JSON data - double getDouble(String key, {double backup = 0.0, String subKey = ""}) { + double? getDoubleOrNull(String key, {double? backup, String subKey = ""}) { dynamic value = getValue(key, backup: backup, subKey: subKey); if (value == null) { @@ -152,6 +152,11 @@ class InvenTreeModel { return double.tryParse(value.toString()) ?? backup; } + double getDouble(String key, {double backup = 0.0, String subkey = "" }) { + double? value = getDoubleOrNull(key, backup: backup, subKey: subkey); + return value ?? backup; + } + // Helper function to get boolean value from json data bool getBool(String key, {bool backup = false, String subKey = ""}) { dynamic value = getValue(key, backup: backup, subKey: subKey); diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 32c8e508..0aeec560 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -5,6 +5,7 @@ import "package:flutter/material.dart"; import "package:inventree/api.dart"; import "package:inventree/helpers.dart"; +import "package:inventree/inventree/sentry.dart"; import "package:inventree/l10.dart"; import "package:inventree/inventree/stock.dart"; @@ -287,6 +288,28 @@ class InvenTreePart extends InvenTreeModel { }); } + // Request pricing data for this part + Future getPricing() async { + + print("REQUEST PRICING FOR: ${pk}"); + + try { + final response = await InvenTreeAPI().get("/api/part/${pk}/pricing/"); + if (response.isValid()) { + final pricingData = response.data; + + if (pricingData is Map) { + return InvenTreePartPricing.fromJson(pricingData); + } + } + } catch (e, stackTrace) { + print("Exception while fetching pricing data for part $pk: $e"); + sentryReportError("getPricing", e, stackTrace); + } + + return null; + } + int get supplierCount => getInt("suppliers", backup: 0); // Request supplier parts for this part @@ -402,6 +425,8 @@ class InvenTreePart extends InvenTreeModel { bool get isVirtual => getBool("virtual"); + bool get isTemplate => getBool("is_template"); + bool get isTrackable => getBool("trackable"); // Get the IPN (internal part number) for the Part instance @@ -491,6 +516,54 @@ class InvenTreePart extends InvenTreeModel { InvenTreeModel createFromJson(Map json) => InvenTreePart.fromJson(json); } + +class InvenTreePartPricing extends InvenTreeModel { + + InvenTreePartPricing() : super(); + + InvenTreePartPricing.fromJson(Map json) : super.fromJson(json); + + @override + List get rolesRequired => ["part"]; + + @override + InvenTreeModel createFromJson(Map json) => InvenTreePartPricing.fromJson(json); + + // Price data accessors + String get currency => getString("currency", backup: "USD"); + + double? get overallMin => getDoubleOrNull("overall_min"); + double? get overallMax => getDoubleOrNull("overall_max"); + + double? get overrideMin => getDoubleOrNull("override_min"); + double? get overrideMax => getDoubleOrNull("override_max"); + + String get overrideMinCurrency => getString("override_min_currency", backup: currency); + String get overrideMaxCurrency => getString("override_max_currency", backup: currency); + + double? get bomCostMin => getDoubleOrNull("bom_cost_min"); + double? get bomCostMax => getDoubleOrNull("bom_cost_max"); + + double? get purchaseCostMin => getDoubleOrNull("purchase_cost_min"); + double? get purchaseCostMax => getDoubleOrNull("purchase_cost_max"); + + double? get internalCostMin => getDoubleOrNull("internal_cost_min"); + double? get internalCostMax => getDoubleOrNull("internal_cost_max"); + + double? get supplierPriceMin => getDoubleOrNull("supplier_price_min"); + double? get supplierPriceMax => getDoubleOrNull("supplier_price_max"); + + double? get variantCostMin => getDoubleOrNull("variant_cost_min"); + double? get variantCostMax => getDoubleOrNull("variant_cost_max"); + + double? get salePriceMin => getDoubleOrNull("sale_price_min"); + double? get salePriceMax => getDoubleOrNull("sale_price_max"); + + double? get saleHistoryMin => getDoubleOrNull("sale_history_min"); + double? get saleHistoryMax => getDoubleOrNull("sale_history_max"); +} + + /* * Class representing an attachment file against a Part object */ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0915a4f0..11a61b74 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -850,6 +850,12 @@ "partNoResults": "No parts matching query", "@partNoResults": {}, + "partPricing": "Part Pricing", + "@partPricing": {}, + + "partPricingSettingDetail": "Display part pricing information", + "@pricingSettingDetail": {}, + "partSettings": "Part Settings", "@partSettings": {}, @@ -1592,5 +1598,53 @@ "@viewSupplierPart": {}, "website": "Website", - "@website": {} + "@website": {}, + + "price": "Price", + "@price": {}, + + "priceRange": "Price Range", + "@priceRange": {}, + + "priceOverrideMin": "Minimum Price Override", + "@priceOverrideMin": {}, + + "priceOverrideMax": "Maximum Price Override", + "@priceOverrideMax": {}, + + "salePrice": "Sale Price", + "@salePrice": {}, + + "saleHistory": "Sale History", + "@saleHistory": {}, + + "supplierPricing": "Supplier Pricing", + "@supplierPricing": {}, + + "bomCost": "BOM Cost", + "@bomCost": {}, + + "internalCost": "Internal Cost", + "@internalCost": {}, + + "variantCost": "Variant Cost", + "@variantCost": {}, + + "overallPricing": "Overall Pricing", + "@overallPricing": {}, + + "pricingOverrides": "Pricing Overrides", + "@pricingOverrides": {}, + + "currency": "Currency", + "@currency": {}, + + "priceBreaks": "Price Breaks", + "@priceBreaks": {}, + + "noPricingAvailable": "No pricing available", + "@noPricingAvailable": {}, + + "noPricingDataFound": "No pricing data found for this part", + "@noPricingDataFound": {} } diff --git a/lib/preferences.dart b/lib/preferences.dart index 8507cd31..0959ff5b 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -30,6 +30,7 @@ const String INV_ENABLE_LABEL_PRINTING = "enableLabelPrinting"; // Part settings const String INV_PART_SHOW_PARAMETERS = "partShowParameters"; const String INV_PART_SHOW_BOM = "partShowBom"; +const String INV_PART_SHOW_PRICING = "partShowPricing"; // Stock settings const String INV_STOCK_SHOW_HISTORY = "stockShowHistory"; diff --git a/lib/settings/part_settings.dart b/lib/settings/part_settings.dart index 02952b68..5a8ccd23 100644 --- a/lib/settings/part_settings.dart +++ b/lib/settings/part_settings.dart @@ -19,6 +19,7 @@ class _InvenTreePartSettingsState extends State { bool partShowParameters = true; bool partShowBom = true; + bool partShowPricing = true; bool stockShowHistory = false; bool stockShowTests = false; bool stockConfirmScan = false; @@ -33,6 +34,7 @@ class _InvenTreePartSettingsState extends State { Future loadSettings() async { partShowParameters = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_PARAMETERS, true); partShowBom = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_BOM, true); + partShowPricing = await InvenTreeSettingsManager().getBool(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, false); @@ -81,6 +83,20 @@ class _InvenTreePartSettingsState extends State { }, ), ), + ListTile( + title: Text(L10().partPricing), + subtitle: Text(L10().partPricingSettingDetail), + leading: Icon(TablerIcons.currency_dollar), + trailing: Switch( + value: partShowPricing, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue(INV_PART_SHOW_PRICING, value); + setState(() { + partShowPricing = value; + }); + }, + ), + ), Divider(), ListTile( title: Text(L10().stockItemHistory), diff --git a/lib/widget/part/part_detail.dart b/lib/widget/part/part_detail.dart index 51c7d01e..ca4788e8 100644 --- a/lib/widget/part/part_detail.dart +++ b/lib/widget/part/part_detail.dart @@ -18,6 +18,7 @@ import "package:inventree/widget/part/bom_list.dart"; import "package:inventree/widget/part/part_list.dart"; import "package:inventree/widget/notes_widget.dart"; import "package:inventree/widget/part/part_parameter_widget.dart"; +import "package:inventree/widget/part/part_pricing.dart"; import "package:inventree/widget/progress.dart"; import "package:inventree/widget/part/category_display.dart"; import "package:inventree/widget/refreshable_state.dart"; @@ -52,14 +53,18 @@ class _PartDisplayState extends RefreshableState { int parameterCount = 0; + bool allowLabelPrinting = false; bool showParameters = false; bool showBom = false; + bool showPricing = false; int attachmentCount = 0; int bomCount = 0; int usedInCount = 0; int variantCount = 0; + InvenTreePartPricing? partPricing; + List> labels = []; @override @@ -151,6 +156,12 @@ class _PartDisplayState extends RefreshableState { final bool result = await part.reload(); + // Load page settings from local storage + showPricing = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_PRICING, true); + showParameters = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_PARAMETERS, true); + showBom = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_BOM, true); + allowLabelPrinting = await InvenTreeSettingsManager().getBool(INV_ENABLE_LABEL_PRINTING, true); + if (!result || part.pk == -1) { // Part could not be loaded, for some reason Navigator.of(context).pop(); @@ -179,9 +190,6 @@ class _PartDisplayState extends RefreshableState { } }); - // Request the number of parameters for this part - showParameters = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_PARAMETERS, true); - // Request the number of attachments InvenTreePartAttachment().countAttachments(part.pk).then((int value) { if (mounted) { @@ -191,7 +199,16 @@ class _PartDisplayState extends RefreshableState { } }); - showBom = await InvenTreeSettingsManager().getBool(INV_PART_SHOW_BOM, true); + // If show pricing information? + if (showPricing) { + part.getPricing().then((InvenTreePartPricing? pricing) { + if (mounted) { + setState(() { + partPricing = pricing; + }); + } + }); + } // Request the number of BOM items InvenTreePart().count( @@ -233,7 +250,6 @@ class _PartDisplayState extends RefreshableState { }); List> _labels = []; - bool allowLabelPrinting = await InvenTreeSettingsManager().getBool(INV_ENABLE_LABEL_PRINTING, true); allowLabelPrinting &= api.supportsMixin("labels"); if (allowLabelPrinting) { @@ -271,8 +287,8 @@ class _PartDisplayState extends RefreshableState { Widget headerTile() { return Card( child: ListTile( - title: Text("${part.fullname}"), - subtitle: Text("${part.description}"), + title: Text(part.fullname), + subtitle: Text(part.description), trailing: Text( part.stockString(), style: TextStyle( @@ -425,6 +441,36 @@ class _PartDisplayState extends RefreshableState { ), ); + if (showPricing && partPricing != null) { + + String pricing = formatPriceRange( + partPricing?.overallMin, + partPricing?.overallMax, + currency: partPricing?.currency + ); + + tiles.add( + ListTile( + title: Text(L10().partPricing), + leading: Icon(TablerIcons.currency_dollar, color: COLOR_ACTION), + trailing: Text( + pricing.isNotEmpty ? pricing : L10().noPricingAvailable, + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PartPricingWidget(part: part, partPricing: partPricing), + ), + ); + }, + ), + ); + } + // Tiles for "purchaseable" parts if (part.isPurchaseable) { diff --git a/lib/widget/part/part_pricing.dart b/lib/widget/part/part_pricing.dart new file mode 100644 index 00000000..62e709b3 --- /dev/null +++ b/lib/widget/part/part_pricing.dart @@ -0,0 +1,198 @@ +import "package:flutter/material.dart"; + +import "package:inventree/inventree/part.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/helpers.dart"; + +class PartPricingWidget extends StatefulWidget { + + const PartPricingWidget({Key? key, required this.part, required this.partPricing}) : super(key: key); + final InvenTreePart part; + final InvenTreePartPricing? partPricing; + + @override + _PartPricingWidgetState createState() => _PartPricingWidgetState(); +} + +class _PartPricingWidgetState extends RefreshableState { + + @override + String getAppBarTitle() { + return L10().partPricing; + } + + @override + List getTiles(BuildContext context) { + + List tiles = [ + Card( + child: ListTile( + title: Text(widget.part.fullname), + subtitle: Text(widget.part.description), + leading: api.getThumbnail(widget.part.thumbnail) + ) + ), + ]; + + if (widget.partPricing == null) { + tiles.add( + ListTile( + title: Text(L10().noPricingAvailable), + subtitle: Text(L10().noPricingDataFound), + ) + ); + + return tiles; + } + + final pricing = widget.partPricing!; + + tiles.add( + ListTile( + title: Text(L10().currency), + trailing: Text(pricing.currency), + ) + ); + + tiles.add( + ListTile( + title: Text(L10().priceRange), + trailing: Text( + formatPriceRange( + pricing.overallMin, + pricing.overallMax, + currency: pricing.currency + ) + ), + ) + ); + + if (pricing.overallMin != null) { + tiles.add( + ListTile( + title: Text(L10().priceOverrideMin), + trailing: Text( + renderCurrency(pricing.overallMin, pricing.overrideMinCurrency) + ) + ) + ); + } + + if (pricing.overrideMax != null) { + tiles.add( + ListTile( + title: Text(L10().priceOverrideMax), + trailing: Text( + renderCurrency(pricing.overallMax, pricing.overrideMaxCurrency) + ) + ) + ); + } + + tiles.add( + ListTile( + title: Text(L10().internalCost), + trailing: Text( + formatPriceRange( + pricing.internalCostMin, + pricing.internalCostMax, + currency: pricing.currency + ) + ), + ) + ); + + if (widget.part.isTemplate) { + tiles.add( + ListTile( + title: Text(L10().variantCost), + trailing: Text( + formatPriceRange( + pricing.variantCostMin, + pricing.variantCostMax, + currency: pricing.currency + ) + ), + ) + ); + } + + if (widget.part.isAssembly) { + tiles.add( + ListTile( + title: Text(L10().bomCost), + trailing: Text( + formatPriceRange( + pricing.bomCostMin, + pricing.bomCostMax, + currency: pricing.currency + ) + ) + ) + ); + } + + if (widget.part.isPurchaseable) { + tiles.add( + ListTile( + title: Text(L10().purchasePrice), + trailing: Text( + formatPriceRange( + pricing.purchaseCostMin, + pricing.purchaseCostMax, + currency: pricing.currency + ) + ), + ) + ); + + tiles.add( + ListTile( + title: Text(L10().supplierPricing), + trailing: Text( + formatPriceRange( + pricing.supplierPriceMin, + pricing.supplierPriceMax, + currency: pricing.currency + ) + ), + ) + ); + } + + if (widget.part.isSalable) { + tiles.add(Divider()); + + tiles.add( + ListTile( + title: Text(L10().salePrice), + trailing: Text( + formatPriceRange( + pricing.salePriceMin, + pricing.salePriceMax, + currency: pricing.currency + ) + ), + ) + ); + + tiles.add( + ListTile( + title: Text(L10().saleHistory), + trailing: Text( + formatPriceRange( + pricing.saleHistoryMin, + pricing.saleHistoryMax, + currency: pricing.currency + ) + ), + ) + ); + } + + return tiles; + } + +} diff --git a/lib/widget/stock/stock_detail.dart b/lib/widget/stock/stock_detail.dart index a3006fd5..a5ef499f 100644 --- a/lib/widget/stock/stock_detail.dart +++ b/lib/widget/stock/stock_detail.dart @@ -515,8 +515,8 @@ class _StockItemDisplayState extends RefreshableState { return Card( child: ListTile( - title: Text("${widget.item.partName}"), - subtitle: Text("${widget.item.partDescription}"), + title: Text(widget.item.partName), + subtitle: Text(widget.item.partDescription), leading: InvenTreeAPI().getThumbnail(widget.item.partImage), trailing: trailing, onTap: () async {