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
This commit is contained in:
Oliver 2023-04-26 17:35:15 +10:00 committed by GitHub
parent 36d17c082b
commit 5fcab2aec3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 199 additions and 35 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 v109 -> 2023-04-19 : https://github.com/inventree/InvenTree/pull/4636
- Adds API endpoints for the "ProjectCode" model - Adds API endpoints for the "ProjectCode" model

View File

@ -73,10 +73,17 @@ class InvenTreeCurrencySerializer(serializers.ChoiceField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize the currency serializer""" """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: 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: if 'label' not in kwargs:
kwargs['label'] = _('Currency') kwargs['label'] = _('Currency')

View File

@ -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'),
),
]

View File

@ -25,6 +25,7 @@ from mptt.models import TreeForeignKey
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import InvenTree.validators
import order.validators import order.validators
import stock.models import stock.models
import users.models as UserModels import users.models as UserModels
@ -69,10 +70,36 @@ class TotalPriceMixin(models.Model):
help_text=_('Total price for this order') 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): def update_total_price(self, commit=True):
"""Recalculate and save the total_price for this order""" """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: if commit:
self.save() self.save()

View File

@ -41,7 +41,13 @@ class TotalPriceMixin(serializers.Serializer):
read_only=True, 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): class AbstractOrderSerializer(serializers.Serializer):
@ -168,7 +174,7 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre
'supplier_detail', 'supplier_detail',
'supplier_reference', 'supplier_reference',
'total_price', 'total_price',
'total_price_currency', 'order_currency',
]) ])
read_only_fields = [ read_only_fields = [
@ -178,7 +184,8 @@ class PurchaseOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTre
] ]
extra_kwargs = { extra_kwargs = {
'supplier': {'required': True} 'supplier': {'required': True},
'order_currency': {'required': False},
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -707,7 +714,7 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo
'customer_reference', 'customer_reference',
'shipment_date', 'shipment_date',
'total_price', 'total_price',
'total_price_currency', 'order_currency',
]) ])
read_only_fields = [ read_only_fields = [
@ -716,6 +723,10 @@ class SalesOrderSerializer(TotalPriceMixin, AbstractOrderSerializer, InvenTreeMo
'shipment_date', 'shipment_date',
] ]
extra_kwargs = {
'order_currency': {'required': False},
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer""" """Initialization routine for the serializer"""
customer_detail = kwargs.pop('customer_detail', False) customer_detail = kwargs.pop('customer_detail', False)

View File

@ -215,7 +215,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td> <td>{{ order.responsible }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% include "currency_data.html" with instance=order %}
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total cost" %}</td> <td>{% trans "Total cost" %}</td>
@ -224,7 +224,7 @@ src="{% static 'img/blank_image.png' %}"
{% if tp == None %} {% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span> <span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %} {% else %}
{% render_currency tp currency=order.supplier.currency %} {% render_currency tp currency=order.currency %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>

View File

@ -180,9 +180,7 @@ $('#new-po-line').click(function() {
createPurchaseOrderLineItem({{ order.pk }}, { createPurchaseOrderLineItem({{ order.pk }}, {
{% if order.supplier %} {% if order.supplier %}
supplier: {{ order.supplier.pk }}, supplier: {{ order.supplier.pk }},
{% if order.supplier.currency %} currency: '{{ order.currency }}',
currency: '{{ order.supplier.currency }}',
{% endif %}
{% endif %} {% endif %}
onSuccess: function() { onSuccess: function() {
$('#po-line-table').bootstrapTable('refresh'); $('#po-line-table').bootstrapTable('refresh');
@ -235,9 +233,7 @@ onPanelLoad('order-items', function() {
order: {{ order.pk }}, order: {{ order.pk }},
table: '#po-extra-lines-table', table: '#po-extra-lines-table',
url: '{% url "api-po-extra-line-list" %}', url: '{% url "api-po-extra-line-list" %}',
{% if order.supplier.currency %} currency: '{{ order.currency }}',
currency: '{{ order.supplier.currency }}',
{% endif %}
}); });
}); });

View File

@ -183,7 +183,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td> <td>{{ order.responsible }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% include "currency_data.html" with instance=order %}
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total Cost" %}</td> <td>{% trans "Total Cost" %}</td>
@ -192,7 +192,7 @@ src="{% static 'img/blank_image.png' %}"
{% if tp == None %} {% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span> <span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %} {% else %}
{% render_currency tp currency=order.customer.currency %} {% render_currency tp currency=order.currency %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>

View File

@ -223,7 +223,7 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ order.responsible }}</td> <td>{{ order.responsible }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% include "currency_data.html" with instance=order %}
<tr> <tr>
<td><span class='fas fa-dollar-sign'></span></td> <td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Total Cost" %}</td> <td>{% trans "Total Cost" %}</td>
@ -232,7 +232,7 @@ src="{% static 'img/blank_image.png' %}"
{% if tp == None %} {% if tp == None %}
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span> <span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
{% else %} {% else %}
{% render_currency tp currency=order.customer.currency %} {% render_currency tp currency=order.currency %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>

View File

@ -275,9 +275,7 @@
order: {{ order.pk }}, order: {{ order.pk }},
table: '#so-extra-lines-table', table: '#so-extra-lines-table',
url: '{% url "api-so-extra-line-list" %}', url: '{% url "api-so-extra-line-list" %}',
{% if order.customer.currency %} currency: '{{ order.currency }}',
currency: '{{ order.customer.currency }}',
{% endif %}
}); });
}); });

View File

@ -60,6 +60,50 @@ class PurchaseOrderTest(OrderTest):
LIST_URL = reverse('api-po-list') 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): def test_po_list(self):
"""Test the PurchaseOrder list API endpoint""" """Test the PurchaseOrder list API endpoint"""
# List *ALL* PurchaseOrder items # List *ALL* PurchaseOrder items
@ -155,7 +199,7 @@ class PurchaseOrderTest(OrderTest):
for result in response.data['results']: for result in response.data['results']:
self.assertIn('total_price', result) self.assertIn('total_price', result)
self.assertIn('total_price_currency', result) self.assertIn('order_currency', result)
def test_overdue(self): def test_overdue(self):
"""Test "overdue" status.""" """Test "overdue" status."""
@ -323,13 +367,18 @@ class PurchaseOrderTest(OrderTest):
self.assertTrue(po.lines.count() > 0) self.assertTrue(po.lines.count() > 0)
lines = []
# Add some extra line items to this order # Add some extra line items to this order
for idx in range(5): for idx in range(5):
models.PurchaseOrderExtraLine.objects.create( lines.append(models.PurchaseOrderExtraLine(
order=po, order=po,
quantity=idx + 10, quantity=idx + 10,
reference='some reference', reference='some reference',
) ))
# bulk create orders
models.PurchaseOrderExtraLine.objects.bulk_create(lines)
data = self.get(reverse('api-po-detail', kwargs={'pk': 1})).data data = self.get(reverse('api-po-detail', kwargs={'pk': 1})).data
@ -1157,7 +1206,7 @@ class SalesOrderTest(OrderTest):
for result in response.data['results']: for result in response.data['results']:
self.assertIn('total_price', result) self.assertIn('total_price', result)
self.assertIn('total_price_currency', result) self.assertIn('order_currency', result)
def test_overdue(self): def test_overdue(self):
"""Test "overdue" status.""" """Test "overdue" status."""

View File

@ -70,7 +70,7 @@
<td></td> <td></td>
<td></td> <td></td>
<th>{% trans "Total" %}</th> <th>{% trans "Total" %}</th>
<td>{% render_currency order.total_price decimal_places=2 currency=order.supplier.currency %}</td> <td>{% render_currency order.total_price decimal_places=2 currency=order.currency %}</td>
<td></td> <td></td>
</tr> </tr>

View File

@ -70,7 +70,7 @@
<td></td> <td></td>
<td></td> <td></td>
<th>{% trans "Total" %}</th> <th>{% trans "Total" %}</th>
<td>{% render_currency order.total_price currency=order.customer.currency %}</td> <td>{% render_currency order.total_price currency=order.currency %}</td>
<td></td> <td></td>
</tr> </tr>
</tbody> </tbody>

View File

@ -0,0 +1,12 @@
{% load i18n %}
{% if instance and instance.currency %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Currency" %}</td>
{% if instance.order_currency %}
<td>{{ instance.currency }}</td>
{% else %}
<td><em>{{ instance.currency }}</em></td>
{% endif %}
</tr>
{% endif %}

View File

@ -65,6 +65,9 @@ function purchaseOrderFields(options={}) {
project_code: { project_code: {
icon: 'fa-list', icon: 'fa-list',
}, },
order_currency: {
icon: 'fa-coins',
},
target_date: { target_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },
@ -1670,7 +1673,7 @@ function loadPurchaseOrderTable(table, options) {
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
return formatCurrency(value, { return formatCurrency(value, {
currency: row.total_price_currency, currency: row.order_currency,
}); });
}, },
}, },

View File

@ -49,6 +49,9 @@ function returnOrderFields(options={}) {
project_code: { project_code: {
icon: 'fa-list', icon: 'fa-list',
}, },
order_currency: {
icon: 'fa-coins',
},
target_date: { target_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },
@ -349,7 +352,7 @@ function loadReturnOrderTable(table, options={}) {
visible: false, visible: false,
formatter: function(value, row) { formatter: function(value, row) {
return formatCurrency(value, { return formatCurrency(value, {
currency: row.total_price_currency currency: row.order_currency
}); });
} }
} }

View File

@ -62,6 +62,9 @@ function salesOrderFields(options={}) {
project_code: { project_code: {
icon: 'fa-list', icon: 'fa-list',
}, },
order_currency: {
icon: 'fa-coins',
},
target_date: { target_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },
@ -802,7 +805,7 @@ function loadSalesOrderTable(table, options) {
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
return formatCurrency(value, { return formatCurrency(value, {
currency: row.total_price_currency, currency: row.order_currency,
}); });
} }
} }

View File

@ -21,7 +21,7 @@ $ pip install -r requirements.txt
To serve the pages locally, run the following command (from the top-level project directory): 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 ## Edit Documentation Files

View File

@ -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 | | Complete | The purchase order has been completed, and is now closed |
| Cancelled | The purchase order was cancelled, 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 ## Create Purchase Order
Once the purchase order page is loaded, click on <span class="badge inventree add"><span class='fas fa-plus-circle'></span> New Purchase Order</span> which opens the "Create Purchase Order" form. Once the purchase order page is loaded, click on <span class="badge inventree add"><span class='fas fa-plus-circle'></span> New Purchase Order</span> which opens the "Create Purchase Order" form.

View File

@ -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.creation_date | The date when the order was created |
| order.target_date | The date when the order should arrive | | order.target_date | The date when the order should arrive |
| order.if_overdue | Boolean value that tells if the target date has passed | | 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 #### Lines
The lines have sub variables.
Each line item have sub variables, as follows:
| Variable | Description | | Variable | Description |
| --- | --- | | --- | --- |
@ -39,9 +41,20 @@ The lines have sub variables.
| reference | The reference given in the part of the order | | reference | The reference given in the part of the order |
| notes | The notes 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 | | 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 | | 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 ### Default Report Template

View File

@ -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 | | customer | The [customer](../sell/customer.md) associated with the particular sales order |
| lines | A list of available line items for this order | | lines | A list of available line items for this order |
| extra_lines | A list of available *extra* 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 ### Default Report Template

View File

@ -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 | | Complete | The sales order has been completed, and is now closed |
| Cancelled | The sales order was cancelled, 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 ## Create a Sales Order
Once the sales order page is loaded, click on <span class="badge inventree add"><span class='fas fa-plus-circle'></span> New Sales Order</span> which opens the "Create Sales Order" form. Once the sales order page is loaded, click on <span class="badge inventree add"><span class='fas fa-plus-circle'></span> New Sales Order</span> which opens the "Create Sales Order" form.