mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Receiving items against a purchase order now makes use of the API forms
- Delete old unused code - Improve serializer validation
This commit is contained in:
parent
a579bc8721
commit
dd4428464d
@ -80,22 +80,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):
|
||||
"""
|
||||
Form for assigning stock to a sales order,
|
||||
|
@ -225,6 +225,13 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
required=True,
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_("Quantity must be greater than zero"))
|
||||
|
||||
return quantity
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=list(StockStatus.items()),
|
||||
default=StockStatus.OK,
|
||||
@ -246,7 +253,7 @@ class POLineItemReceiveSerializer(serializers.Serializer):
|
||||
|
||||
# Ignore empty barcode values
|
||||
if not barcode or barcode.strip() == '':
|
||||
return
|
||||
return None
|
||||
|
||||
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||
raise ValidationError(_('Barcode is already in use'))
|
||||
@ -284,9 +291,27 @@ class POReceiveSerializer(serializers.Serializer):
|
||||
|
||||
items = data.get('items', [])
|
||||
|
||||
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
|
||||
for item in items:
|
||||
|
||||
line = item['line_item']
|
||||
|
||||
if not item.get('location', None):
|
||||
# If a global location is specified, use that
|
||||
item['location'] = location
|
||||
|
||||
if not item['location']:
|
||||
# The line item specifies a location?
|
||||
item['location'] = line.get_destination()
|
||||
|
||||
if not item['location']:
|
||||
raise ValidationError({
|
||||
'items': _('Line items must be provided')
|
||||
'location': _("Destination location must be specified"),
|
||||
})
|
||||
|
||||
# Ensure barcodes are unique
|
||||
@ -313,24 +338,6 @@ class POReceiveSerializer(serializers.Serializer):
|
||||
items = data['items']
|
||||
location = data.get('location', None)
|
||||
|
||||
# Check if the location is not specified for any particular item
|
||||
for item in items:
|
||||
|
||||
line = item['line_item']
|
||||
|
||||
if not item.get('location', None):
|
||||
# If a global location is specified, use that
|
||||
item['location'] = location
|
||||
|
||||
if not item['location']:
|
||||
# The line item specifies a location?
|
||||
item['location'] = line.get_destination()
|
||||
|
||||
if not item['location']:
|
||||
raise ValidationError({
|
||||
'location': _("Destination location must be specified"),
|
||||
})
|
||||
|
||||
# Now we can actually receive the items into stock
|
||||
with transaction.atomic():
|
||||
for item in items:
|
||||
|
@ -204,22 +204,11 @@ $("#receive-order").click(function() {
|
||||
{{ order.id }},
|
||||
items_to_receive,
|
||||
{
|
||||
success: function() {
|
||||
$("#po-line-table").bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
launchModalForm("{% url 'po-receive' order.id %}", {
|
||||
reload: true,
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
url: "{% url 'stock-location-create' %}",
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
$("#complete-order").click(function() {
|
||||
|
@ -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 %}
|
@ -13,7 +13,6 @@ purchase_order_detail_urls = [
|
||||
|
||||
url(r'^cancel/', views.PurchaseOrderCancel.as_view(), name='po-cancel'),
|
||||
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'^upload/', views.PurchaseOrderUpload.as_view(), name='po-upload'),
|
||||
|
@ -468,202 +468,6 @@ class PurchaseOrderExport(AjaxView):
|
||||
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):
|
||||
""" View for adding various SupplierPart items to a Purchase Order.
|
||||
|
||||
|
@ -193,6 +193,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'IPN',
|
||||
'default_location',
|
||||
'name',
|
||||
'revision',
|
||||
'full_name',
|
||||
|
@ -1515,6 +1515,7 @@ function initializeChoiceField(field, fields, options) {
|
||||
select.select2({
|
||||
dropdownAutoWidth: false,
|
||||
dropdownParent: $(options.modal),
|
||||
width: '100%',
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -264,13 +264,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
// 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: opts.quantity || 0,
|
||||
value: quantity,
|
||||
title: '{% trans "Quantity to receive" %}',
|
||||
required: true,
|
||||
},
|
||||
@ -279,6 +285,16 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
}
|
||||
);
|
||||
|
||||
// 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}`,
|
||||
{
|
||||
@ -291,6 +307,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
}
|
||||
);
|
||||
|
||||
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'>`;
|
||||
|
||||
@ -321,7 +351,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
${quantity_input}
|
||||
</td>
|
||||
<td id='status_${pk}'>
|
||||
STATUS
|
||||
${status_input}
|
||||
</td>
|
||||
<td id='desination_${pk}'>
|
||||
${destination_input}
|
||||
@ -349,11 +379,11 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Order Code" %}</th>
|
||||
<th>{% trans "On Order" %}</th>
|
||||
<th>{% trans "Ordered" %}</th>
|
||||
<th>{% trans "Received" %}</th>
|
||||
<th style='min-width: 50px;'>{% trans "Receive" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th style='min-width: 350px;'>{% trans "Destination" %}</th>
|
||||
<th style='min-width: 150px;'>{% trans "Status" %}</th>
|
||||
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -390,7 +420,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
model: 'stocklocation',
|
||||
required: false,
|
||||
auto_fill: false,
|
||||
value: item.destination,
|
||||
value: item.destination || item.part_detail.default_location,
|
||||
render_description: false,
|
||||
};
|
||||
|
||||
@ -405,10 +435,86 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
|
||||
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) {
|
||||
// TODO
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -604,22 +710,24 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
$(table).find('.button-line-receive').click(function() {
|
||||
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() {
|
||||
$(table).bootstrapTable('refresh');
|
||||
},
|
||||
data: {
|
||||
line: pk,
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
field: 'location',
|
||||
label: '{% trans "New Location" %}',
|
||||
title: '{% trans "Create new stock location" %}',
|
||||
url: '{% url "stock-location-create" %}',
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -637,11 +745,11 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
},
|
||||
url: '{% url "api-po-line-list" %}',
|
||||
showFooter: true,
|
||||
uniqueId: 'pk',
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
checkbox: true,
|
||||
visible: true,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user