Adds option for duplicating an existing purchase order (#3567)

* Adds option for duplicating an existing purchase order

- Copy line items across

* Update API version

* JS linting

* Adds option for duplication of extra lines

* Decimal point rounding fix

* Unit tests for PO duplication via the API
This commit is contained in:
Oliver 2022-08-19 11:34:37 +10:00 committed by GitHub
parent 868697392f
commit 1ec9795ede
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 189 additions and 7 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 71
INVENTREE_API_VERSION = 72
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v72 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3567
- Allow PurchaseOrder to be duplicated via the API
v71 -> 2022-08-18 : https://github.com/inventree/InvenTree/pull/3564
- Updates to the "part scheduling" API endpoint

View File

@ -1,10 +1,13 @@
"""JSON API for the Order app."""
from django.db import transaction
from django.db.models import F, Q
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from rest_framework import filters, status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
import order.models as models
@ -116,12 +119,48 @@ class PurchaseOrderList(APIDownloadMixin, ListCreateAPI):
def create(self, request, *args, **kwargs):
"""Save user information on create."""
serializer = self.get_serializer(data=self.clean_data(request.data))
data = self.clean_data(request.data)
duplicate_order = data.pop('duplicate_order', None)
duplicate_line_items = str2bool(data.pop('duplicate_line_items', False))
duplicate_extra_lines = str2bool(data.pop('duplicate_extra_lines', False))
if duplicate_order is not None:
try:
duplicate_order = models.PurchaseOrder.objects.get(pk=duplicate_order)
except (ValueError, models.PurchaseOrder.DoesNotExist):
raise ValidationError({
'duplicate_order': [_('No matching purchase order found')],
})
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
item = serializer.save()
item.created_by = request.user
item.save()
with transaction.atomic():
order = serializer.save()
order.created_by = request.user
order.save()
# Duplicate line items from other order if required
if duplicate_order is not None:
if duplicate_line_items:
for line in duplicate_order.lines.all():
# Copy the line across to the new order
line.pk = None
line.order = order
line.received = 0
line.save()
if duplicate_extra_lines:
for line in duplicate_order.extra_lines.all():
# Copy the line across to the new order
line.pk = None
line.order = order
line.save()
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@ -46,6 +46,9 @@
{% if order.can_cancel %}
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li>
{% endif %}
{% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='duplicate-order'><span class='fas fa-clone'></span> {% trans "Duplicate order" %}</a></li>
{% endif %}
</ul>
</div>
{% if order.status == PurchaseOrderStatus.PENDING %}
@ -217,6 +220,8 @@ $('#print-order-report').click(function() {
});
{% endif %}
{% if roles.purchase_order.change %}
$("#edit-order").click(function() {
editPurchaseOrder({{ order.pk }}, {
@ -275,6 +280,16 @@ $("#cancel-order").click(function() {
);
});
{% endif %}
{% if roles.purchase_order.add %}
$('#duplicate-order').click(function() {
duplicatePurchaseOrder(
{{ order.pk }},
);
});
{% endif %}
$("#export-order").click(function() {
exportOrder('{% url "po-export" order.id %}');
});

View File

@ -225,6 +225,64 @@ class PurchaseOrderTest(OrderTest):
expected_code=201
)
def test_po_duplicate(self):
"""Test that we can duplicate a PurchaseOrder via the API"""
self.assignRole('purchase_order.add')
po = models.PurchaseOrder.objects.get(pk=1)
self.assertTrue(po.lines.count() > 0)
# Add some extra line items to this order
for idx in range(5):
models.PurchaseOrderExtraLine.objects.create(
order=po,
quantity=idx + 10,
reference='some reference',
)
data = self.get(reverse('api-po-detail', kwargs={'pk': 1})).data
del data['pk']
del data['reference']
data['duplicate_order'] = 1
data['duplicate_line_items'] = True
data['duplicate_extra_lines'] = False
data['reference'] = 'PO-9999'
# Duplicate via the API
response = self.post(
reverse('api-po-list'),
data,
expected_code=201
)
# Order is for the same supplier
self.assertEqual(response.data['supplier'], po.supplier.pk)
po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk'])
self.assertEqual(po_dup.extra_lines.count(), 0)
self.assertEqual(po_dup.lines.count(), po.lines.count())
data['reference'] = 'PO-9998'
data['duplicate_line_items'] = False
data['duplicate_extra_lines'] = True
response = self.post(
reverse('api-po-list'),
data,
expected_code=201,
)
po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk'])
self.assertEqual(po_dup.extra_lines.count(), po.extra_lines.count())
self.assertEqual(po_dup.lines.count(), 0)
def test_po_cancel(self):
"""Test the PurchaseOrderCancel API endpoint."""
po = models.PurchaseOrder.objects.get(pk=1)

View File

@ -1049,7 +1049,7 @@ function loadBomTable(table, options={}) {
available += row.on_order;
can_build = available / row.quantity;
text += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Including On Order" %}: ${can_build}'></span>`;
text += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Including On Order" %}: ${formatDecimal(can_build, 2)}'></span>`;
}
return text;

View File

@ -30,6 +30,7 @@
createPurchaseOrderLineItem,
createSalesOrder,
createSalesOrderShipment,
duplicatePurchaseOrder,
editPurchaseOrder,
editPurchaseOrderLineItem,
exportOrder,
@ -538,6 +539,39 @@ function purchaseOrderFields(options={}) {
fields.supplier.hidden = true;
}
// Add fields for order duplication (only if required)
if (options.duplicate_order) {
fields.duplicate_order = {
value: options.duplicate_order,
group: 'duplicate',
required: 'true',
type: 'related field',
model: 'purchaseorder',
filters: {
supplier_detail: true,
},
api_url: '{% url "api-po-list" %}',
label: '{% trans "Purchase Order" %}',
help_text: '{% trans "Select purchase order to duplicate" %}',
};
fields.duplicate_line_items = {
value: true,
group: 'duplicate',
type: 'boolean',
label: '{% trans "Duplicate Line Items" %}',
help_text: '{% trans "Duplicate all line items from the selected order" %}',
};
fields.duplicate_extra_lines = {
value: true,
group: 'duplicate',
type: 'boolean',
label: '{% trans "Duplicate Extra Lines" %}',
help_text: '{% trans "Duplicate extra line items from the selected order" %}',
};
}
return fields;
}
@ -564,9 +598,20 @@ function createPurchaseOrder(options={}) {
var fields = purchaseOrderFields(options);
var groups = {};
if (options.duplicate_order) {
groups.duplicate = {
title: '{% trans "Duplication Options" %}',
collapsible: false,
};
};
constructForm('{% url "api-po-list" %}', {
method: 'POST',
fields: fields,
groups: groups,
data: options.data,
onSuccess: function(data) {
if (options.onSuccess) {
@ -576,7 +621,29 @@ function createPurchaseOrder(options={}) {
location.href = `/order/purchase-order/${data.pk}/`;
}
},
title: '{% trans "Create Purchase Order" %}',
title: options.title || '{% trans "Create Purchase Order" %}',
});
}
/*
* Duplicate an existing PurchaseOrder
* Provides user with option to duplicate line items for the order also.
*/
function duplicatePurchaseOrder(order_id, options={}) {
options.duplicate_order = order_id;
inventreeGet(`/api/order/po/${order_id}/`, {}, {
success: function(data) {
// Clear out data we do not want to be duplicated
delete data['pk'];
delete data['reference'];
options.data = data;
createPurchaseOrder(options);
}
});
}