Merge pull request #2102 from SchrodingersGat/purchase-order-receive

[Refactor] Purchase order receive
This commit is contained in:
Oliver 2021-10-05 18:01:39 +11:00 committed by GitHub
commit 8dcd2ab7ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 478 additions and 455 deletions

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf.urls import url, include from django.conf.urls import url, include
from django.db.models import Q, F
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
from rest_framework import generics from rest_framework import generics
@ -251,6 +252,39 @@ class POReceive(generics.CreateAPIView):
return order return order
class POLineItemFilter(rest_filters.FilterSet):
"""
Custom filters for the POLineItemList endpoint
"""
class Meta:
model = PurchaseOrderLineItem
fields = [
'order',
'part'
]
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
def filter_completed(self, queryset, name, value):
"""
Filter by lines which are "completed" (or "not" completed)
A line is completed when received >= quantity
"""
value = str2bool(value)
q = Q(received__gte=F('quantity'))
if value:
queryset = queryset.filter(q)
else:
queryset = queryset.exclude(q)
return queryset
class POLineItemList(generics.ListCreateAPIView): class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of POLineItem objects """ API endpoint for accessing a list of POLineItem objects
@ -260,6 +294,7 @@ class POLineItemList(generics.ListCreateAPIView):
queryset = PurchaseOrderLineItem.objects.all() queryset = PurchaseOrderLineItem.objects.all()
serializer_class = POLineItemSerializer serializer_class = POLineItemSerializer
filterset_class = POLineItemFilter
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):

View File

@ -8,8 +8,6 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField from InvenTree.fields import InvenTreeMoneyField, RoundingDecimalFormField
@ -19,7 +17,6 @@ from common.forms import MatchItemForm
import part.models import part.models
from stock.models import StockLocation
from .models import PurchaseOrder from .models import PurchaseOrder
from .models import SalesOrder, SalesOrderLineItem from .models import SalesOrder, SalesOrderLineItem
from .models import SalesOrderAllocation from .models import SalesOrderAllocation
@ -80,22 +77,6 @@ class ShipSalesOrderForm(HelperForm):
] ]
class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(
queryset=StockLocation.objects.all(),
required=False,
label=_("Destination"),
help_text=_("Set all received parts listed above to this location (if left blank, use \"Destination\" column value in above table)"),
)
class Meta:
model = PurchaseOrder
fields = [
"location",
]
class AllocateSerialsToSalesOrderForm(forms.Form): class AllocateSerialsToSalesOrderForm(forms.Form):
""" """
Form for assigning stock to a sales order, Form for assigning stock to a sales order,

View File

@ -225,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer):
required=True, required=True,
) )
def validate_quantity(self, quantity):
if quantity <= 0:
raise ValidationError(_("Quantity must be greater than zero"))
return quantity
status = serializers.ChoiceField( status = serializers.ChoiceField(
choices=list(StockStatus.items()), choices=list(StockStatus.items()),
default=StockStatus.OK, default=StockStatus.OK,
@ -246,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
# Ignore empty barcode values # Ignore empty barcode values
if not barcode or barcode.strip() == '': if not barcode or barcode.strip() == '':
return return None
if stock.models.StockItem.objects.filter(uid=barcode).exists(): if stock.models.StockItem.objects.filter(uid=barcode).exists():
raise ValidationError(_('Barcode is already in use')) raise ValidationError(_('Barcode is already in use'))
@ -284,35 +291,11 @@ class POReceiveSerializer(serializers.Serializer):
items = data.get('items', []) items = data.get('items', [])
if len(items) == 0:
raise ValidationError({
'items': _('Line items must be provided')
})
# Ensure barcodes are unique
unique_barcodes = set()
for item in items:
barcode = item.get('barcode', '')
if barcode:
if barcode in unique_barcodes:
raise ValidationError(_('Supplied barcode values must be unique'))
else:
unique_barcodes.add(barcode)
return data
def save(self):
data = self.validated_data
request = self.context['request']
order = self.context['order']
items = data['items']
location = data.get('location', None) location = data.get('location', None)
if len(items) == 0:
raise ValidationError(_('Line items must be provided'))
# Check if the location is not specified for any particular item # Check if the location is not specified for any particular item
for item in items: for item in items:
@ -331,14 +314,44 @@ class POReceiveSerializer(serializers.Serializer):
'location': _("Destination location must be specified"), 'location': _("Destination location must be specified"),
}) })
# Ensure barcodes are unique
unique_barcodes = set()
for item in items:
barcode = item.get('barcode', '')
if barcode:
if barcode in unique_barcodes:
raise ValidationError(_('Supplied barcode values must be unique'))
else:
unique_barcodes.add(barcode)
return data
def save(self):
"""
Perform the actual database transaction to receive purchase order items
"""
data = self.validated_data
request = self.context['request']
order = self.context['order']
items = data['items']
location = data.get('location', None)
# Now we can actually receive the items into stock # Now we can actually receive the items into stock
with transaction.atomic(): with transaction.atomic():
for item in items: for item in items:
# Select location
loc = item.get('location', None) or item['line_item'].get_destination() or location
try: try:
order.receive_line_item( order.receive_line_item(
item['line_item'], item['line_item'],
item['location'], loc,
item['quantity'], item['quantity'],
request.user, request.user,
status=item['status'], status=item['status'],

View File

@ -49,7 +49,7 @@ src="{% static 'img/blank_image.png' %}"
</button> </button>
{% elif order.status == PurchaseOrderStatus.PLACED %} {% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'> <button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
<span class='fas fa-clipboard-check'></span> <span class='fas fa-sign-in-alt'></span>
</button> </button>
<button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'> <button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
<span class='fas fa-check-circle'></span> <span class='fas fa-check-circle'></span>
@ -188,17 +188,27 @@ $("#edit-order").click(function() {
}); });
$("#receive-order").click(function() { $("#receive-order").click(function() {
launchModalForm("{% url 'po-receive' order.id %}", {
reload: true, // Auto select items which have not been fully allocated
secondary: [ var items = $("#po-line-table").bootstrapTable('getData');
{
field: 'location', var items_to_receive = [];
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}', items.forEach(function(item) {
url: "{% url 'stock-location-create' %}", if (item.received < item.quantity) {
}, items_to_receive.push(item);
] }
}); });
receivePurchaseOrderItems(
{{ order.id }},
items_to_receive,
{
success: function() {
$("#po-line-table").bootstrapTable('refresh');
}
}
);
}); });
$("#complete-order").click(function() { $("#complete-order").click(function() {

View File

@ -18,14 +18,23 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'> <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %} {% if roles.purchase_order.change %}
{% if order.status == PurchaseOrderStatus.PENDING %}
<button type='button' class='btn btn-success' id='new-po-line'> <button type='button' class='btn btn-success' id='new-po-line'>
<span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %} <span class='fas fa-plus-circle'></span> {% trans "Add Line Item" %}
</button> </button>
<a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'> <a class='btn btn-primary' href='{% url "po-upload" order.id %}' role='button'>
<span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %} <span class='fas fa-file-upload side-icon'></span> {% trans "Upload File" %}
</a> </a>
{% elif order.status == PurchaseOrderStatus.PLACED %}
<button type='button' class='btn btn-success' id='receive-selected-items' title='{% trans "Receive selected items" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Receive Items" %}
</button>
{% endif %} {% endif %}
{% endif %}
<div class='filter-list' id='filter-list-purchase-order-lines'>
<!-- An empty div in which the filter list will be constructed-->
</div>
</div> </div>
<table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'> <table class='table table-striped table-condensed' id='po-line-table' data-toolbar='#order-toolbar-buttons'>
@ -207,6 +216,22 @@ $('#new-po-line').click(function() {
}); });
}); });
{% elif order.status == PurchaseOrderStatus.PLACED %}
$('#receive-selected-items').click(function() {
var items = $("#po-line-table").bootstrapTable('getSelections');
receivePurchaseOrderItems(
{{ order.id }},
items,
{
success: function() {
$("#po-line-table").bootstrapTable('refresh');
}
}
);
});
{% endif %} {% endif %}
loadPurchaseOrderLineItemTable('#po-line-table', { loadPurchaseOrderLineItemTable('#po-line-table', {

View File

@ -1,81 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% load status_codes %}
{% block form %}
{% blocktrans with desc=order.description %}Receive outstanding parts for <strong>{{order}}</strong> - <em>{{desc}}</em>{% endblocktrans %}
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
{% csrf_token %}
{% load crispy_forms_tags %}
<label class='control-label'>{% trans "Parts" %}</label>
<p class='help-block'>{% trans "Fill out number of parts received, the status and destination" %}</p>
<table class='table table-striped'>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Order Code" %}</th>
<th>{% trans "On Order" %}</th>
<th>{% trans "Received" %}</th>
<th>{% trans "Receive" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Destination" %}</th>
<th></th>
</tr>
{% for line in lines %}
<tr id='line_row_{{ line.id }}'>
{% if line.part %}
<td>
{% include "hover_image.html" with image=line.part.part.image hover=False %}
{{ line.part.part.full_name }}
</td>
<td>{{ line.part.SKU }}</td>
{% else %}
<td colspan='2'>{% trans "Error: Referenced part has been removed" %}</td>
{% endif %}
<td>{% decimal line.quantity %}</td>
<td>{% decimal line.received %}</td>
<td>
<div class='control-group'>
<div class='controls'>
<input class='numberinput' type='number' min='0' value='{% decimal line.receive_quantity %}' name='line-{{ line.id }}'/>
</div>
</div>
</td>
<td>
<div class='control-group'>
<select class='select' name='status-{{ line.id }}'>
{% for code in StockStatus.RECEIVING_CODES %}
<option value="{{ code }}" {% if code|add:"0" == line.status_code|add:"0" %}selected="selected"{% endif %}>{% stock_status_text code %}</option>
{% endfor %}
</select>
</div>
</td>
<td>
<div class='control-group'>
<select class='select' name='destination-{{ line.id }}'>
<option value="">----------</option>
{% for location in stock_locations %}
<option value="{{ location.pk }}" {% if location == line.get_destination %}selected="selected"{% endif %}>{{ location }}</option>
{% endfor %}
</select>
</div>
</td>
<td>
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>
</button>
</td>
</tr>
{% endfor %}
</table>
{% crispy form %}
<div id='form-errors'>{{ form_errors }}</div>
</form>
{% endblock %}

View File

@ -251,7 +251,7 @@ class PurchaseOrderReceiveTest(OrderTest):
expected_code=400 expected_code=400
).data ).data
self.assertIn('Line items must be provided', str(data['items'])) self.assertIn('Line items must be provided', str(data))
# No new stock items have been created # No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count()) self.assertEqual(self.n, StockItem.objects.count())

View File

@ -10,7 +10,7 @@ from django.contrib.auth.models import Group
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import PurchaseOrder
import json import json
@ -103,86 +103,3 @@ class POTests(OrderViewTestCase):
# Test that the order was actually placed # Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1) order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, PurchaseOrderStatus.PLACED) self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
class TestPOReceive(OrderViewTestCase):
""" Tests for receiving a purchase order """
def setUp(self):
super().setUp()
self.po = PurchaseOrder.objects.get(pk=1)
self.po.status = PurchaseOrderStatus.PLACED
self.po.save()
self.url = reverse('po-receive', args=(1,))
def post(self, data, validate=None):
response = self.client.post(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
if validate is not None:
data = json.loads(response.content)
if validate:
self.assertTrue(data['form_valid'])
else:
self.assertFalse(data['form_valid'])
return response
def test_get_dialog(self):
data = {
}
self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
def test_receive_lines(self):
post_data = {
}
self.post(post_data, validate=False)
# Try with an invalid location
post_data['location'] = 12345
self.post(post_data, validate=False)
# Try with a valid location
post_data['location'] = 1
# Should fail due to invalid quantity
self.post(post_data, validate=False)
# Try to receive against an invalid line
post_data['line-800'] = 100
# Remove an invalid quantity of items
post_data['line-1'] = '7x5q'
self.post(post_data, validate=False)
# Receive negative number
post_data['line-1'] = -100
self.post(post_data, validate=False)
# Receive 75 items
post_data['line-1'] = 75
self.post(post_data, validate=True)
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(line.received, 75)
# Receive 30 more items
post_data['line-1'] = 30
self.post(post_data, validate=True)
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(line.received, 105)

View File

@ -13,7 +13,6 @@ purchase_order_detail_urls = [
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'), url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'), url(r'^issue/', views.PurchaseOrderIssue.as_view(), name='po-issue'),
url(r'^receive/', views.PurchaseOrderReceive.as_view(), name='po-receive'),
url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'), url(r'^complete/', views.PurchaseOrderComplete.as_view(), name='po-complete'),
url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'), url(r'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),

View File

@ -26,7 +26,7 @@ from .models import SalesOrderAllocation
from .admin import POLineItemResource from .admin import POLineItemResource
from build.models import Build from build.models import Build
from company.models import Company, SupplierPart # ManufacturerPart from company.models import Company, SupplierPart # ManufacturerPart
from stock.models import StockItem, StockLocation from stock.models import StockItem
from part.models import Part from part.models import Part
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
@ -42,7 +42,7 @@ from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.helpers import extract_serial_numbers from InvenTree.helpers import extract_serial_numbers
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, StockStatus from InvenTree.status_codes import PurchaseOrderStatus
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView):
return DownloadFile(filedata, filename) return DownloadFile(filedata, filename)
class PurchaseOrderReceive(AjaxUpdateView):
""" View for receiving parts which are outstanding against a PurchaseOrder.
Any parts which are outstanding are listed.
If all parts are marked as received, the order is closed out.
"""
form_class = order_forms.ReceivePurchaseOrderForm
ajax_form_title = _("Receive Parts")
ajax_template_name = "order/receive_parts.html"
# Specify role as we do not specify a Model against this view
role_required = 'purchase_order.change'
# Where the parts will be going (selected in POST request)
destination = None
def get_context_data(self):
ctx = {
'order': self.order,
'lines': self.lines,
'stock_locations': StockLocation.objects.all(),
}
return ctx
def get_lines(self):
"""
Extract particular line items from the request,
or default to *all* pending line items if none are provided
"""
lines = None
if 'line' in self.request.GET:
line_id = self.request.GET.get('line')
try:
lines = PurchaseOrderLineItem.objects.filter(pk=line_id)
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
pass
# TODO - Option to pass multiple lines?
# No lines specified - default selection
if lines is None:
lines = self.order.pending_line_items()
return lines
def get(self, request, *args, **kwargs):
""" Respond to a GET request. Determines which parts are outstanding,
and presents a list of these parts to the user.
"""
self.request = request
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
self.lines = self.get_lines()
for line in self.lines:
# Pre-fill the remaining quantity
line.receive_quantity = line.remaining()
return self.renderJsonResponse(request, form=self.get_form())
def post(self, request, *args, **kwargs):
""" Respond to a POST request. Data checking and error handling.
If the request is valid, new StockItem objects will be made
for each received item.
"""
self.request = request
self.order = get_object_or_404(PurchaseOrder, pk=self.kwargs['pk'])
errors = False
self.lines = []
self.destination = None
msg = _("Items received")
# Extract the destination for received parts
if 'location' in request.POST:
pk = request.POST['location']
try:
self.destination = StockLocation.objects.get(id=pk)
except (StockLocation.DoesNotExist, ValueError):
pass
# Extract information on all submitted line items
for item in request.POST:
if item.startswith('line-'):
pk = item.replace('line-', '')
try:
line = PurchaseOrderLineItem.objects.get(id=pk)
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
continue
# Check that the StockStatus was set
status_key = 'status-{pk}'.format(pk=pk)
status = request.POST.get(status_key, StockStatus.OK)
try:
status = int(status)
except ValueError:
status = StockStatus.OK
if status in StockStatus.RECEIVING_CODES:
line.status_code = status
else:
line.status_code = StockStatus.OK
# Check the destination field
line.destination = None
if self.destination:
# If global destination is set, overwrite line value
line.destination = self.destination
else:
destination_key = f'destination-{pk}'
destination = request.POST.get(destination_key, None)
if destination:
try:
line.destination = StockLocation.objects.get(pk=destination)
except (StockLocation.DoesNotExist, ValueError):
pass
# Check that line matches the order
if not line.order == self.order:
# TODO - Display a non-field error?
continue
# Ignore a part that doesn't map to a SupplierPart
try:
if line.part is None:
continue
except SupplierPart.DoesNotExist:
continue
receive = self.request.POST[item]
try:
receive = Decimal(receive)
except InvalidOperation:
# In the case on an invalid input, reset to default
receive = line.remaining()
msg = _("Error converting quantity to number")
errors = True
if receive < 0:
receive = 0
errors = True
msg = _("Receive quantity less than zero")
line.receive_quantity = receive
self.lines.append(line)
if len(self.lines) == 0:
msg = _("No lines specified")
errors = True
# No errors? Receive the submitted parts!
if errors is False:
self.receive_parts()
data = {
'form_valid': errors is False,
'success': msg,
}
return self.renderJsonResponse(request, data=data, form=self.get_form())
@transaction.atomic
def receive_parts(self):
""" Called once the form has been validated.
Create new stockitems against received parts.
"""
for line in self.lines:
if not line.part:
continue
self.order.receive_line_item(
line,
line.destination,
line.receive_quantity,
self.request.user,
status=line.status_code,
purchase_price=line.purchase_price,
)
class OrderParts(AjaxView): class OrderParts(AjaxView):
""" View for adding various SupplierPart items to a Purchase Order. """ View for adding various SupplierPart items to a Purchase Order.

View File

@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
fields = [ fields = [
'pk', 'pk',
'IPN', 'IPN',
'default_location',
'name', 'name',
'revision', 'revision',
'full_name', 'full_name',

View File

@ -973,7 +973,6 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
</table> </table>
`; `;
constructForm(`/api/build/${build_id}/allocate/`, { constructForm(`/api/build/${build_id}/allocate/`, {
method: 'POST', method: 'POST',
fields: {}, fields: {},

View File

@ -273,6 +273,11 @@ function setupFilterList(tableKey, table, target) {
var element = $(target); var element = $(target);
if (!element) {
console.log(`WARNING: setupFilterList could not find target '${target}'`);
return;
}
// One blank slate, please // One blank slate, please
element.empty(); element.empty();

View File

@ -1515,6 +1515,7 @@ function initializeChoiceField(field, fields, options) {
select.select2({ select.select2({
dropdownAutoWidth: false, dropdownAutoWidth: false,
dropdownParent: $(options.modal), dropdownParent: $(options.modal),
width: '100%',
}); });
} }

View File

@ -112,7 +112,13 @@ function renderStockLocation(name, data, parameters, options) {
var html = `<span>${level}${data.pathstring}</span>`; var html = `<span>${level}${data.pathstring}</span>`;
if (data.description) { var render_description = true;
if ('render_description' in parameters) {
render_description = parameters['render_description'];
}
if (render_description && data.description) {
html += ` - <i>${data.description}</i>`; html += ` - <i>${data.description}</i>`;
} }

View File

@ -12,6 +12,7 @@
loadTableFilters, loadTableFilters,
makeIconBadge, makeIconBadge,
purchaseOrderStatusDisplay, purchaseOrderStatusDisplay,
receivePurchaseOrderItems,
renderLink, renderLink,
salesOrderStatusDisplay, salesOrderStatusDisplay,
setupFilterList, setupFilterList,
@ -234,6 +235,291 @@ function newPurchaseOrderFromOrderWizard(e) {
}); });
} }
/**
* Receive stock items against a PurchaseOrder
* Uses the POReceive API endpoint
*
* arguments:
* - order_id, ID / PK for the PurchaseOrder instance
* - line_items: A list of PurchaseOrderLineItems objects to be allocated
*
* options:
* -
*/
function receivePurchaseOrderItems(order_id, line_items, options={}) {
if (line_items.length == 0) {
showAlertDialog(
'{% trans "Select Line Items" %}',
'{% trans "At least one line item must be selected" %}',
);
return;
}
function renderLineItem(line_item, opts={}) {
var pk = line_item.pk;
// Part thumbnail + description
var thumb = thumbnailImage(line_item.part_detail.thumbnail);
var quantity = (line_item.quantity || 0) - (line_item.received || 0);
if (quantity < 0) {
quantity = 0;
}
// Quantity to Receive
var quantity_input = constructField(
`items_quantity_${pk}`,
{
type: 'decimal',
min_value: 0,
value: quantity,
title: '{% trans "Quantity to receive" %}',
required: true,
},
{
hideLabels: true,
}
);
// Construct list of StockItem status codes
var choices = [];
for (var key in stockCodes) {
choices.push({
value: key,
display_name: stockCodes[key].value,
});
}
var destination_input = constructField(
`items_location_${pk}`,
{
type: 'related field',
label: '{% trans "Location" %}',
required: false,
},
{
hideLabels: true,
}
);
var status_input = constructField(
`items_status_${pk}`,
{
type: 'choice',
label: '{% trans "Stock Status" %}',
required: true,
choices: choices,
value: 10, // OK
},
{
hideLabels: true,
}
);
// Button to remove the row
var delete_button = `<div class='btn-group float-right' role='group'>`;
delete_button += makeIconButton(
'fa-times icon-red',
'button-row-remove',
pk,
'{% trans "Remove row" %}',
);
delete_button += '</div>';
var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'>
<td id='part_${pk}'>
${thumb} ${line_item.part_detail.full_name}
</td>
<td id='sku_${pk}'>
${line_item.supplier_part_detail.SKU}
</td>
<td id='on_order_${pk}'>
${line_item.quantity}
</td>
<td id='received_${pk}'>
${line_item.received}
</td>
<td id='quantity_${pk}'>
${quantity_input}
</td>
<td id='status_${pk}'>
${status_input}
</td>
<td id='desination_${pk}'>
${destination_input}
</td>
<td id='actions_${pk}'>
${delete_button}
</td>
</tr>`;
return html;
}
var table_entries = '';
line_items.forEach(function(item) {
table_entries += renderLineItem(item);
});
var html = ``;
// Add table
html += `
<table class='table table-striped table-condensed' id='order-receive-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</th>
<th style='min-width: 50px;'>{% trans "Receive" %}</th>
<th style='min-width: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th>
</tr>
</thead>
<tbody>
${table_entries}
</tbody>
</table>
`;
constructForm(`/api/order/po/${order_id}/receive/`, {
method: 'POST',
fields: {
location: {},
},
preFormContent: html,
confirm: true,
confirmMessage: '{% trans "Confirm receipt of items" %}',
title: '{% trans "Receive Purchase Order Items" %}',
afterRender: function(fields, opts) {
// Initialize the "destination" field for each item
line_items.forEach(function(item) {
var pk = item.pk;
var name = `items_location_${pk}`;
var field_details = {
name: name,
api_url: '{% url "api-location-list" %}',
filters: {
},
type: 'related field',
model: 'stocklocation',
required: false,
auto_fill: false,
value: item.destination || item.part_detail.default_location,
render_description: false,
};
initializeRelatedField(
field_details,
null,
opts,
);
addClearCallback(
name,
field_details,
opts
);
initializeChoiceField(
{
name: `items_status_${pk}`,
},
null,
opts
);
});
// Add callbacks to remove rows
$(opts.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk');
$(opts.modal).find(`#receive_row_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
location: getFormFieldValue('location', {}, opts),
};
var item_pk_values = [];
line_items.forEach(function(item) {
var pk = item.pk;
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
var status = getFormFieldValue(`items_status_${pk}`, {}, opts);
var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) {
data.items.push({
line_item: pk,
quantity: quantity,
status: status,
location: location,
});
item_pk_values.push(pk);
}
});
// Provide list of nested values
opts.nested = {
'items': item_pk_values,
};
inventreePut(
opts.url,
data,
{
method: 'POST',
success: function(response) {
// Hide the modal
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr);
break;
}
}
}
);
}
});
}
function editPurchaseOrderLineItem(e) { function editPurchaseOrderLineItem(e) {
/* Edit a purchase order line item in a modal form. /* Edit a purchase order line item in a modal form.
@ -280,12 +566,10 @@ function loadPurchaseOrderTable(table, options) {
filters[key] = options.params[key]; filters[key] = options.params[key];
} }
options.url = options.url || '{% url "api-po-list" %}';
setupFilterList('purchaseorder', $(table)); setupFilterList('purchaseorder', $(table));
$(table).inventreeTable({ $(table).inventreeTable({
url: options.url, url: '{% url "api-po-list" %}',
queryParams: filters, queryParams: filters,
name: 'purchaseorder', name: 'purchaseorder',
groupBy: false, groupBy: false,
@ -379,6 +663,21 @@ function loadPurchaseOrderTable(table, options) {
*/ */
function loadPurchaseOrderLineItemTable(table, options={}) { function loadPurchaseOrderLineItemTable(table, options={}) {
options.params = options.params || {};
options.params['order'] = options.order;
options.params['part_detail'] = true;
var filters = loadTableFilters('purchaseorderlineitem');
for (var key in options.params) {
filters[key] = options.params[key];
}
var target = options.filter_target || '#filter-list-purchase-order-lines';
setupFilterList('purchaseorderlineitem', $(table), target);
function setupCallbacks() { function setupCallbacks() {
if (options.allow_edit) { if (options.allow_edit) {
$(table).find('.button-line-edit').click(function() { $(table).find('.button-line-edit').click(function() {
@ -424,22 +723,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
$(table).find('.button-line-receive').click(function() { $(table).find('.button-line-receive').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
launchModalForm(`/order/purchase-order/${options.order}/receive/`, { var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
if (!line_item) {
console.log('WARNING: getRowByUniqueId returned null');
return;
}
receivePurchaseOrderItems(
options.order,
[
line_item,
],
{
success: function() { success: function() {
$(table).bootstrapTable('refresh'); $(table).bootstrapTable('refresh');
}, }
data: { }
line: pk, );
},
secondary: [
{
field: 'location',
label: '{% trans "New Location" %}',
title: '{% trans "Create new stock location" %}',
url: '{% url "stock-location-create" %}',
},
]
});
}); });
} }
} }
@ -451,17 +752,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
formatNoMatches: function() { formatNoMatches: function() {
return '{% trans "No line items found" %}'; return '{% trans "No line items found" %}';
}, },
queryParams: { queryParams: filters,
order: options.order, original: options.params,
part_detail: true
},
url: '{% url "api-po-line-list" %}', url: '{% url "api-po-line-list" %}',
showFooter: true, showFooter: true,
uniqueId: 'pk',
columns: [ columns: [
{ {
field: 'pk', checkbox: true,
title: 'ID', visible: true,
visible: false,
switchable: false, switchable: false,
}, },
{ {
@ -618,7 +917,7 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
} }
if (options.allow_receive && row.received < row.quantity) { if (options.allow_receive && row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}'); html += makeIconButton('fa-sign-in-alt', 'button-line-receive', pk, '{% trans "Receive line item" %}');
} }
html += `</div>`; html += `</div>`;

View File

@ -274,7 +274,16 @@ function getAvailableTableFilters(tableKey) {
}; };
} }
// Filters for the "Order" table // Filters for PurchaseOrderLineItem table
if (tableKey == 'purchaseorderlineitem') {
return {
completed: {
type: 'bool',
title: '{% trans "Completed" %}',
},
};
}
// Filters for the PurchaseOrder table
if (tableKey == 'purchaseorder') { if (tableKey == 'purchaseorder') {
return { return {