From 5fcab2aec30a72c8708d845e71a2e1a7f55e6cbe Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 26 Apr 2023 17:35:15 +1000 Subject: [PATCH] Specify order currency (#4698) * Add 'order_currency' to the various external order models - By default will use the currency specified for the supplier (or customer) - Can be specified per order, also * Display order currency on order pgae * Add 'order_currency' field * Enable "blank" currency option (to default to the currency specified by the referenced company * Fix default currency code when adding line items * Remove 'total_price_currency' serializer field - Now replaced with 'order_currency' for greater flexibility * Bump api_version.py * Update default order report templates * Updated docs * More docs updaes * Adjust unit tests * Use 'order_currency' in order tables * Update purchase order api unit tests --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/serializers.py | 11 +++- .../migrations/0093_auto_20230426_0248.py | 29 ++++++++++ InvenTree/order/models.py | 29 +++++++++- InvenTree/order/serializers.py | 19 +++++-- .../order/templates/order/order_base.html | 4 +- .../order/purchase_order_detail.html | 8 +-- .../templates/order/return_order_base.html | 4 +- .../templates/order/sales_order_base.html | 4 +- .../templates/order/sales_order_detail.html | 4 +- InvenTree/order/test_api.py | 57 +++++++++++++++++-- .../report/inventree_po_report_base.html | 2 +- .../report/inventree_so_report_base.html | 2 +- InvenTree/templates/currency_data.html | 12 ++++ .../templates/js/translated/purchase_order.js | 5 +- .../templates/js/translated/return_order.js | 5 +- .../templates/js/translated/sales_order.js | 5 +- docs/README.md | 2 +- docs/docs/buy/po.md | 4 ++ docs/docs/report/purchase_order.md | 17 +++++- docs/docs/report/sales_order.md | 1 + docs/docs/sell/so.md | 5 ++ 22 files changed, 199 insertions(+), 35 deletions(-) create mode 100644 InvenTree/order/migrations/0093_auto_20230426_0248.py create mode 100644 InvenTree/templates/currency_data.html diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index a9fa115293..b39d3f1e43 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 109 +INVENTREE_API_VERSION = 110 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v110 -> 2023-04-26 : https://github.com/inventree/InvenTree/pull/4698 + - Adds 'order_currency' field for PurchaseOrder / SalesOrder endpoints + v109 -> 2023-04-19 : https://github.com/inventree/InvenTree/pull/4636 - Adds API endpoints for the "ProjectCode" model diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 018f2332f7..405d2c28bf 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -73,10 +73,17 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField): def __init__(self, *args, **kwargs): """Initialize the currency serializer""" - kwargs['choices'] = currency_code_mappings() + choices = currency_code_mappings() + + allow_blank = kwargs.get('allow_blank', False) or kwargs.get('allow_null', False) + + if allow_blank: + choices = [('', '---------')] + choices + + kwargs['choices'] = choices if 'default' not in kwargs and 'required' not in kwargs: - kwargs['default'] = currency_code_default + kwargs['default'] = '' if allow_blank else currency_code_default if 'label' not in kwargs: kwargs['label'] = _('Currency') diff --git a/InvenTree/order/migrations/0093_auto_20230426_0248.py b/InvenTree/order/migrations/0093_auto_20230426_0248.py new file mode 100644 index 0000000000..cefb7ed724 --- /dev/null +++ b/InvenTree/order/migrations/0093_auto_20230426_0248.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.18 on 2023-04-26 02:48 + +import InvenTree.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0092_auto_20230419_0250'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorder', + name='order_currency', + field=models.CharField(blank=True, help_text='Currency for this order (leave blank to use company default)', max_length=3, null=True, validators=[InvenTree.validators.validate_currency_code], verbose_name='Order Currency'), + ), + migrations.AddField( + model_name='returnorder', + name='order_currency', + field=models.CharField(blank=True, help_text='Currency for this order (leave blank to use company default)', max_length=3, null=True, validators=[InvenTree.validators.validate_currency_code], verbose_name='Order Currency'), + ), + migrations.AddField( + model_name='salesorder', + name='order_currency', + field=models.CharField(blank=True, help_text='Currency for this order (leave blank to use company default)', max_length=3, null=True, validators=[InvenTree.validators.validate_currency_code], verbose_name='Order Currency'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 6146305aef..94b3e22fb7 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -25,6 +25,7 @@ from mptt.models import TreeForeignKey import InvenTree.helpers import InvenTree.ready import InvenTree.tasks +import InvenTree.validators import order.validators import stock.models import users.models as UserModels @@ -69,10 +70,36 @@ class TotalPriceMixin(models.Model): help_text=_('Total price for this order') ) + order_currency = models.CharField( + max_length=3, + verbose_name=_('Order Currency'), + blank=True, null=True, + help_text=_('Currency for this order (leave blank to use company default)'), + validators=[InvenTree.validators.validate_currency_code] + ) + + @property + def currency(self): + """Return the currency associated with this order instance: + + - If the order_currency field is set, return that + - Otherwise, return the currency associated with the company + - Finally, return the default currency code + """ + + if self.order_currency: + return self.order_currency + + if self.company: + return self.company.currency_code + + # Return default currency code + return currency_code_default() + def update_total_price(self, commit=True): """Recalculate and save the total_price for this order""" - self.total_price = self.calculate_total_price() + self.total_price = self.calculate_total_price(target_currency=self.currency) if commit: self.save() diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index bc993fb8a4..fa315149ba 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -41,7 +41,13 @@ class TotalPriceMixin(serializers.Serializer): read_only=True, ) - total_price_currency = InvenTreeCurrencySerializer(read_only=True) + order_currency = InvenTreeCurrencySerializer( + allow_blank=True, + allow_null=True, + required=False, + label=_('Order Currency'), + help_text=_('Currency for this order (leave blank to use company default)'), + ) class AbstractOrderSerializer(serializers.Serializer): @@ -168,7 +174,7 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre 'supplier_detail', 'supplier_reference', 'total_price', - 'total_price_currency', + 'order_currency', ]) read_only_fields = [ @@ -178,7 +184,8 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre ] extra_kwargs = { - 'supplier': {'required': True} + 'supplier': {'required': True}, + 'order_currency': {'required': False}, } def __init__(self, *args, **kwargs): @@ -707,7 +714,7 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo 'customer_reference', 'shipment_date', 'total_price', - 'total_price_currency', + 'order_currency', ]) read_only_fields = [ @@ -716,6 +723,10 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo 'shipment_date', ] + extra_kwargs = { + 'order_currency': {'required': False}, + } + def __init__(self, *args, **kwargs): """Initialization routine for the serializer""" customer_detail = kwargs.pop('customer_detail', False) diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 3594703afb..bce90acfa2 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -215,7 +215,7 @@ src="{% static 'img/blank_image.png' %}" {{ order.responsible }} {% endif %} - + {% include "currency_data.html" with instance=order %} {% trans "Total cost" %} @@ -224,7 +224,7 @@ src="{% static 'img/blank_image.png' %}" {% if tp == None %} {% trans "Total cost could not be calculated" %} {% else %} - {% render_currency tp currency=order.supplier.currency %} + {% render_currency tp currency=order.currency %} {% endif %} {% endwith %} diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 4a2a095aa0..0e5839c6b5 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -180,9 +180,7 @@ $('#new-po-line').click(function() { createPurchaseOrderLineItem({{ order.pk }}, { {% if order.supplier %} supplier: {{ order.supplier.pk }}, - {% if order.supplier.currency %} - currency: '{{ order.supplier.currency }}', - {% endif %} + currency: '{{ order.currency }}', {% endif %} onSuccess: function() { $('#po-line-table').bootstrapTable('refresh'); @@ -235,9 +233,7 @@ onPanelLoad('order-items', function() { order: {{ order.pk }}, table: '#po-extra-lines-table', url: '{% url "api-po-extra-line-list" %}', - {% if order.supplier.currency %} - currency: '{{ order.supplier.currency }}', - {% endif %} + currency: '{{ order.currency }}', }); }); diff --git a/InvenTree/order/templates/order/return_order_base.html b/InvenTree/order/templates/order/return_order_base.html index 0499cfa232..178ea71b46 100644 --- a/InvenTree/order/templates/order/return_order_base.html +++ b/InvenTree/order/templates/order/return_order_base.html @@ -183,7 +183,7 @@ src="{% static 'img/blank_image.png' %}" {{ order.responsible }} {% endif %} - + {% include "currency_data.html" with instance=order %} {% trans "Total Cost" %} @@ -192,7 +192,7 @@ src="{% static 'img/blank_image.png' %}" {% if tp == None %} {% trans "Total cost could not be calculated" %} {% else %} - {% render_currency tp currency=order.customer.currency %} + {% render_currency tp currency=order.currency %} {% endif %} {% endwith %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index 15615c4fee..f6810553b1 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -223,7 +223,7 @@ src="{% static 'img/blank_image.png' %}" {{ order.responsible }} {% endif %} - + {% include "currency_data.html" with instance=order %} {% trans "Total Cost" %} @@ -232,7 +232,7 @@ src="{% static 'img/blank_image.png' %}" {% if tp == None %} {% trans "Total cost could not be calculated" %} {% else %} - {% render_currency tp currency=order.customer.currency %} + {% render_currency tp currency=order.currency %} {% endif %} {% endwith %} diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index c25b1862d7..055f85f046 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -275,9 +275,7 @@ order: {{ order.pk }}, table: '#so-extra-lines-table', url: '{% url "api-so-extra-line-list" %}', - {% if order.customer.currency %} - currency: '{{ order.customer.currency }}', - {% endif %} + currency: '{{ order.currency }}', }); }); diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 7b990c9639..f8e6846a5c 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -60,6 +60,50 @@ class PurchaseOrderTest(OrderTest): LIST_URL = reverse('api-po-list') + def test_options(self): + """Test the PurchaseOrder OPTIONS endpoint.""" + + self.assignRole('purchase_order.add') + + response = self.options(self.LIST_URL, expected_code=200) + + data = response.data + self.assertEqual(data['name'], 'Purchase Order List') + + post = data['actions']['POST'] + + def check_options(data, field_name, spec): + """Helper function to check that the options are configured correctly.""" + field_data = data[field_name] + + for k, v in spec.items(): + self.assertIn(k, field_data) + self.assertEqual(field_data[k], v) + + # Checks for the 'order_currency' field + check_options(post, 'order_currency', { + 'type': 'choice', + 'required': False, + 'read_only': False, + 'label': 'Order Currency', + 'help_text': 'Currency for this order (leave blank to use company default)', + }) + + # Checks for the 'reference' field + check_options(post, 'reference', { + 'type': 'string', + 'required': True, + 'read_only': False, + 'label': 'Reference', + }) + + # Checks for the 'supplier' field + check_options(post, 'supplier', { + 'type': 'related field', + 'required': True, + 'api_url': '/api/company/', + }) + def test_po_list(self): """Test the PurchaseOrder list API endpoint""" # List *ALL* PurchaseOrder items @@ -155,7 +199,7 @@ class PurchaseOrderTest(OrderTest): for result in response.data['results']: self.assertIn('total_price', result) - self.assertIn('total_price_currency', result) + self.assertIn('order_currency', result) def test_overdue(self): """Test "overdue" status.""" @@ -323,13 +367,18 @@ class PurchaseOrderTest(OrderTest): self.assertTrue(po.lines.count() > 0) + lines = [] + # Add some extra line items to this order for idx in range(5): - models.PurchaseOrderExtraLine.objects.create( + lines.append(models.PurchaseOrderExtraLine( order=po, quantity=idx + 10, reference='some reference', - ) + )) + + # bulk create orders + models.PurchaseOrderExtraLine.objects.bulk_create(lines) data = self.get(reverse('api-po-detail', kwargs={'pk': 1})).data @@ -1157,7 +1206,7 @@ class SalesOrderTest(OrderTest): for result in response.data['results']: self.assertIn('total_price', result) - self.assertIn('total_price_currency', result) + self.assertIn('order_currency', result) def test_overdue(self): """Test "overdue" status.""" diff --git a/InvenTree/report/templates/report/inventree_po_report_base.html b/InvenTree/report/templates/report/inventree_po_report_base.html index d6c5ee3ee3..88eb5cb2c4 100644 --- a/InvenTree/report/templates/report/inventree_po_report_base.html +++ b/InvenTree/report/templates/report/inventree_po_report_base.html @@ -70,7 +70,7 @@ {% trans "Total" %} - {% render_currency order.total_price decimal_places=2 currency=order.supplier.currency %} + {% render_currency order.total_price decimal_places=2 currency=order.currency %} diff --git a/InvenTree/report/templates/report/inventree_so_report_base.html b/InvenTree/report/templates/report/inventree_so_report_base.html index 7ee4ca0edd..daeace7b1d 100644 --- a/InvenTree/report/templates/report/inventree_so_report_base.html +++ b/InvenTree/report/templates/report/inventree_so_report_base.html @@ -70,7 +70,7 @@ {% trans "Total" %} - {% render_currency order.total_price currency=order.customer.currency %} + {% render_currency order.total_price currency=order.currency %} diff --git a/InvenTree/templates/currency_data.html b/InvenTree/templates/currency_data.html new file mode 100644 index 0000000000..ef9eb39531 --- /dev/null +++ b/InvenTree/templates/currency_data.html @@ -0,0 +1,12 @@ +{% load i18n %} +{% if instance and instance.currency %} + + + {% trans "Currency" %} + {% if instance.order_currency %} + {{ instance.currency }} + {% else %} + {{ instance.currency }} + {% endif %} + +{% endif %} diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js index dfe049a0a9..4eccbdf602 100644 --- a/InvenTree/templates/js/translated/purchase_order.js +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -65,6 +65,9 @@ function purchaseOrderFields(options={}) { project_code: { icon: 'fa-list', }, + order_currency: { + icon: 'fa-coins', + }, target_date: { icon: 'fa-calendar-alt', }, @@ -1670,7 +1673,7 @@ function loadPurchaseOrderTable(table, options) { sortable: true, formatter: function(value, row) { return formatCurrency(value, { - currency: row.total_price_currency, + currency: row.order_currency, }); }, }, diff --git a/InvenTree/templates/js/translated/return_order.js b/InvenTree/templates/js/translated/return_order.js index 31855438ce..13e34e721b 100644 --- a/InvenTree/templates/js/translated/return_order.js +++ b/InvenTree/templates/js/translated/return_order.js @@ -49,6 +49,9 @@ function returnOrderFields(options={}) { project_code: { icon: 'fa-list', }, + order_currency: { + icon: 'fa-coins', + }, target_date: { icon: 'fa-calendar-alt', }, @@ -349,7 +352,7 @@ function loadReturnOrderTable(table, options={}) { visible: false, formatter: function(value, row) { return formatCurrency(value, { - currency: row.total_price_currency + currency: row.order_currency }); } } diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js index 095160726b..fd10a94923 100644 --- a/InvenTree/templates/js/translated/sales_order.js +++ b/InvenTree/templates/js/translated/sales_order.js @@ -62,6 +62,9 @@ function salesOrderFields(options={}) { project_code: { icon: 'fa-list', }, + order_currency: { + icon: 'fa-coins', + }, target_date: { icon: 'fa-calendar-alt', }, @@ -802,7 +805,7 @@ function loadSalesOrderTable(table, options) { sortable: true, formatter: function(value, row) { return formatCurrency(value, { - currency: row.total_price_currency, + currency: row.order_currency, }); } } diff --git a/docs/README.md b/docs/README.md index 23f590d6fd..b5aef7a538 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ $ pip install -r requirements.txt To serve the pages locally, run the following command (from the top-level project directory): ``` -$ mkdocs serve -a localhost:8080 +$ mkdocs serve -f docs/mkdocs.yml -a localhost:8080 ``` ## Edit Documentation Files diff --git a/docs/docs/buy/po.md b/docs/docs/buy/po.md index 914360622e..3ebe0a795d 100644 --- a/docs/docs/buy/po.md +++ b/docs/docs/buy/po.md @@ -23,6 +23,10 @@ Each Purchase Order has a specific status code which indicates the current state | Complete | The purchase order has been completed, and is now closed | | Cancelled | The purchase order was cancelled, and is now closed | +### Purchase Order Currency + +The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./supplier.md) will be used. + ## Create Purchase Order Once the purchase order page is loaded, click on New Purchase Order which opens the "Create Purchase Order" form. diff --git a/docs/docs/report/purchase_order.md b/docs/docs/report/purchase_order.md index 8729e9ff1c..e80a68087f 100644 --- a/docs/docs/report/purchase_order.md +++ b/docs/docs/report/purchase_order.md @@ -27,9 +27,11 @@ In addition to the default report context variables, the following variables are | order.creation_date | The date when the order was created | | order.target_date | The date when the order should arrive | | order.if_overdue | Boolean value that tells if the target date has passed | +| order.currency | The currency code associated with this order, e.g. 'AUD' | #### Lines -The lines have sub variables. + +Each line item have sub variables, as follows: | Variable | Description | | --- | --- | @@ -39,9 +41,20 @@ The lines have sub variables. | reference | The reference given in the part of the order | | notes | The notes given in the part of the order | | target_date | The date when the part should arrive. Each part can have an individual date | -| price | The price the part supplierpart | +| price | The unit price the line item | +| total_line_price | The total price for this line item, calculated from the unit price and quantity | | destination | The stock location where the part will be stored | +A simple example below shows how to use the context variables for line items: + +```html +{% raw %} +{% for line in order.lines %} +Price: {% render_currency line.total_line_price %} +{% endfor %} +{% endraw %} +``` + ### Default Report Template diff --git a/docs/docs/report/sales_order.md b/docs/docs/report/sales_order.md index 54fcd808e0..e195dbddf7 100644 --- a/docs/docs/report/sales_order.md +++ b/docs/docs/report/sales_order.md @@ -22,6 +22,7 @@ In addition to the default report context variables, the following variables are | customer | The [customer](../sell/customer.md) associated with the particular sales order | | lines | A list of available line items for this order | | extra_lines | A list of available *extra* line items for this order | +| order.currency | The currency code associated with this order, e.g. 'CAD' | ### Default Report Template diff --git a/docs/docs/sell/so.md b/docs/docs/sell/so.md index bea918e9ca..fe15b597bc 100644 --- a/docs/docs/sell/so.md +++ b/docs/docs/sell/so.md @@ -23,6 +23,11 @@ Each Sales Order has a specific status code, which represents the state of the o | Complete | The sales order has been completed, and is now closed | | Cancelled | The sales order was cancelled, and is now closed | +### Sales Order Currency + +The currency code can be specified for an individual sales order. If not specified, the default currency specified against the [customer](./customer.md) will be used. + + ## Create a Sales Order Once the sales order page is loaded, click on New Sales Order which opens the "Create Sales Order" form.