Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-06-04 20:07:59 +10:00
commit 2c7e0bd321
10 changed files with 205 additions and 40 deletions

View File

@ -346,6 +346,8 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
# Include context data about the updated object # Include context data about the updated object
data['pk'] = obj.id data['pk'] = obj.id
self.post_save(obj)
try: try:
data['url'] = obj.get_absolute_url() data['url'] = obj.get_absolute_url()
except AttributeError: except AttributeError:
@ -353,6 +355,13 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data)
def post_save(self, obj, *args, **kwargs):
"""
Hook called after the form data is saved.
(Optional)
"""
pass
class AjaxDeleteView(AjaxMixin, UpdateView): class AjaxDeleteView(AjaxMixin, UpdateView):

View File

@ -632,24 +632,14 @@ class SalesOrderAllocation(models.Model):
order = self.line.order order = self.line.order
item = self.item item = self.item.allocateToCustomer(
order.customer,
quantity=self.quantity,
order=order,
user=user
)
# If the allocated quantity is less than the amount available, # Update our own reference to the StockItem
# then split the stock item into two lots # (It may have changed if the stock was split)
if item.quantity > self.quantity:
# Grab a copy of the new stock item (which will keep track of its "parent")
item = item.splitStock(self.quantity, None, user)
# Update our own reference to the new item
self.item = item self.item = item
self.save() self.save()
# Assign the StockItem to the SalesOrder customer
item.sales_order = order
# Clear the location
item.location = None
item.status = StockStatus.SHIPPED
item.save()

View File

@ -643,11 +643,20 @@ class Part(MPTTModel):
# Calculate the minimum number of parts that can be built using each sub-part # Calculate the minimum number of parts that can be built using each sub-part
for item in self.bom_items.all().prefetch_related('sub_part__stock_items'): for item in self.bom_items.all().prefetch_related('sub_part__stock_items'):
stock = item.sub_part.available_stock stock = item.sub_part.available_stock
# If (by some chance) we get here but the BOM item quantity is invalid,
# ignore!
if item.quantity <= 0:
continue
n = int(stock / item.quantity) n = int(stock / item.quantity)
if total is None or n < total: if total is None or n < total:
total = n total = n
if total is None:
total = 0
return max(total, 0) return max(total, 0)
@property @property

View File

@ -34,6 +34,18 @@ class EditStockItemAttachmentForm(HelperForm):
] ]
class AssignStockItemToCustomerForm(HelperForm):
"""
Form for manually assigning a StockItem to a Customer
"""
class Meta:
model = StockItem
fields = [
'customer',
]
class EditStockItemTestResultForm(HelperForm): class EditStockItemTestResultForm(HelperForm):
""" """
Form for creating / editing a StockItemTestResult object. Form for creating / editing a StockItemTestResult object.

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-06-04 03:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0021_remove_supplierpart_manufacturer_name'),
('stock', '0044_auto_20200528_1036'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='customer',
field=models.ForeignKey(help_text='Customer', limit_choices_to={'is_customer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_stock', to='company.Company', verbose_name='Customer'),
),
]

View File

@ -32,6 +32,7 @@ from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree, InvenTreeAttachment from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from company import models as CompanyModels
from part import models as PartModels from part import models as PartModels
@ -217,12 +218,6 @@ class StockItem(MPTTModel):
super().clean() super().clean()
if self.status == StockStatus.SHIPPED and self.sales_order is None:
raise ValidationError({
'sales_order': "SalesOrder must be specified as status is marked as SHIPPED",
'status': "Status cannot be marked as SHIPPED if the Customer is not set",
})
if self.status == StockStatus.ASSIGNED_TO_OTHER_ITEM and self.belongs_to is None: if self.status == StockStatus.ASSIGNED_TO_OTHER_ITEM and self.belongs_to is None:
raise ValidationError({ raise ValidationError({
'belongs_to': "Belongs_to field must be specified as statis is marked as ASSIGNED_TO_OTHER_ITEM", 'belongs_to': "Belongs_to field must be specified as statis is marked as ASSIGNED_TO_OTHER_ITEM",
@ -352,6 +347,16 @@ class StockItem(MPTTModel):
help_text=_('Is this item installed in another item?') help_text=_('Is this item installed in another item?')
) )
customer = models.ForeignKey(
CompanyModels.Company,
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='assigned_stock',
help_text=_("Customer"),
verbose_name=_("Customer"),
)
serial = models.PositiveIntegerField( serial = models.PositiveIntegerField(
verbose_name=_('Serial Number'), verbose_name=_('Serial Number'),
blank=True, null=True, blank=True, null=True,
@ -431,6 +436,64 @@ class StockItem(MPTTModel):
help_text=_('Stock Item Notes') help_text=_('Stock Item Notes')
) )
def clearAllocations(self):
"""
Clear all order allocations for this StockItem:
- SalesOrder allocations
- Build allocations
"""
# Delete outstanding SalesOrder allocations
self.sales_order_allocations.all().delete()
# Delete outstanding BuildOrder allocations
self.allocations.all().delete()
def allocateToCustomer(self, customer, quantity=None, order=None, user=None, notes=None):
"""
Allocate a StockItem to a customer.
This action can be called by the following processes:
- Completion of a SalesOrder
- User manually assigns a StockItem to the customer
Args:
customer: The customer (Company) to assign the stock to
quantity: Quantity to assign (if not supplied, total quantity is used)
order: SalesOrder reference
user: User that performed the action
notes: Notes field
"""
if quantity is None:
quantity = self.quantity
if quantity >= self.quantity:
item = self
else:
item = self.splitStock(quantity, None, user)
# Update StockItem fields with new information
item.sales_order = order
item.status = StockStatus.SHIPPED
item.customer = customer
item.location = None
item.save()
# TODO - Remove any stock item allocations from this stock item
item.addTransactionNote(
_("Assigned to Customer"),
user,
notes=_("Manually assigned to customer") + " " + customer.name,
system=True
)
# Return the reference to the stock item
return item
# If stock item is incoming, an (optional) ETA field # If stock item is incoming, an (optional) ETA field
# expected_arrival = models.DateField(null=True, blank=True) # expected_arrival = models.DateField(null=True, blank=True)

View File

@ -71,43 +71,48 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% include "qr_button.html" %} {% include "qr_button.html" %}
{% if item.in_stock %} {% if item.in_stock %}
{% if not item.serialized %} {% if not item.serialized %}
<button type='button' class='btn btn-default' id='stock-add' title='Add to stock'> <button type='button' class='btn btn-default' id='stock-add' title='{% trans "Add to stock" %}'>
<span class='fas fa-plus-circle icon-green'/> <span class='fas fa-plus-circle icon-green'/>
</button> </button>
<button type='button' class='btn btn-default' id='stock-remove' title='Take from stock'> <button type='button' class='btn btn-default' id='stock-remove' title='{% trans "Take from stock" %}'>
<span class='fas fa-minus-circle icon-red''/> <span class='fas fa-minus-circle icon-red''/>
</button> </button>
<button type='button' class='btn btn-default' id='stock-count' title='Count stock'> <button type='button' class='btn btn-default' id='stock-count' title='{% trans "Count stock" %}'>
<span class='fas fa-clipboard-list'/> <span class='fas fa-clipboard-list'/>
</button> </button>
{% if item.part.trackable %} {% if item.part.trackable %}
<button type='button' class='btn btn-default' id='stock-serialize' title='Serialize stock'> <button type='button' class='btn btn-default' id='stock-serialize' title='{% trans "Serialize stock" %}'>
<span class='fas fa-hashtag'/> <span class='fas fa-hashtag'/>
</button> </button>
{% endif %} {% endif %}
{% if item.part.salable %}
<button type='button' class='btn btn-default' id='stock-assign-to-customer' title='{% trans "Assign to Customer" %}'>
<span class='fas fa-user-tie'/>
</button>
{% endif %} {% endif %}
<button type='button' class='btn btn-default' id='stock-move' title='Transfer stock'> {% endif %}
<button type='button' class='btn btn-default' id='stock-move' title='{% trans "Transfer stock" %}'>
<span class='fas fa-exchange-alt icon-blue'/> <span class='fas fa-exchange-alt icon-blue'/>
</button> </button>
<button type='button' class='btn btn-default' id='stock-duplicate' title='Duplicate stock item'> <button type='button' class='btn btn-default' id='stock-duplicate' title='{% trans "Duplicate stock item" %}'>
<span class='fas fa-copy'/> <span class='fas fa-copy'/>
</button> </button>
{% endif %} {% endif %}
{% if item.part.has_variants %} {% if item.part.has_variants %}
<button type='button' class='btn btn-default' id='stock-convert' title="Convert stock to variant"> <button type='button' class='btn btn-default' id='stock-convert' title='{% trans "Convert stock to variant" %}'>
<span class='fas fa-screwdriver'/> <span class='fas fa-screwdriver'/>
</button> </button>
{% endif %} {% endif %}
{% if item.part.has_test_report_templates %} {% if item.part.has_test_report_templates %}
<button type='button' class='btn btn-default' id='stock-test-report' title='Generate test report'> <button type='button' class='btn btn-default' id='stock-test-report' title='{% trans "Generate test report" %}'>
<span class='fas fa-tasks'/> <span class='fas fa-tasks'/>
</button> </button>
{% endif %} {% endif %}
<button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'> <button type='button' class='btn btn-default' id='stock-edit' title='{% trans "Edit stock item" %}'>
<span class='fas fa-edit icon-blue'/> <span class='fas fa-edit icon-blue'/>
</button> </button>
{% if item.can_delete %} {% if item.can_delete %}
<button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'> <button type='button' class='btn btn-default' id='stock-delete' title='{% trans "Edit stock item" %}'>
<span class='fas fa-trash-alt icon-red'/> <span class='fas fa-trash-alt icon-red'/>
</button> </button>
{% endif %} {% endif %}
@ -126,6 +131,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<a href="{% url 'part-stock' item.part.id %}">{{ item.part.full_name }} <a href="{% url 'part-stock' item.part.id %}">{{ item.part.full_name }}
</td> </td>
</tr> </tr>
{% if item.customer %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' item.customer.id %}">{{ item.customer.name }}</a></td>
</tr>
{% endif %}
{% if item.belongs_to %} {% if item.belongs_to %}
<tr> <tr>
<td><span class='fas fa-box'></span></td> <td><span class='fas fa-box'></span></td>
@ -144,11 +156,15 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<td>{% trans "Build Order" %}</td> <td>{% trans "Build Order" %}</td>
<td><a href="{% url 'build-detail' item.build_order.id %}">{{ item.build_order }}</a></td> <td><a href="{% url 'build-detail' item.build_order.id %}">{{ item.build_order }}</a></td>
</tr> </tr>
{% elif item.location %} {% else %}
<tr> <tr>
<td><span class='fas fa-map-marker-alt'></span></td> <td><span class='fas fa-map-marker-alt'></span></td>
<td>{% trans "Location" %}</td> <td>{% trans "Location" %}</td>
{% if item.location %}
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td> <td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
{% else %}
<td>{% trans "No location set" %}</td>
{% endif %}
</tr> </tr>
{% endif %} {% endif %}
{% if item.uid %} {% if item.uid %}
@ -316,6 +332,16 @@ $("#show-qr-code").click(function() {
{% if item.in_stock %} {% if item.in_stock %}
{% if item.part.salable %}
$("#stock-assign-to-customer").click(function() {
launchModalForm("{% url 'stock-item-assign' item.id %}",
{
reload: true,
}
);
});
{% endif %}
function itemAdjust(action) { function itemAdjust(action) {
launchModalForm("/stock/adjust/", launchModalForm("/stock/adjust/",
{ {

View File

@ -189,9 +189,7 @@
$('#item-create').click(function () { $('#item-create').click(function () {
launchModalForm("{% url 'stock-item-create' %}", launchModalForm("{% url 'stock-item-create' %}",
{ {
success: function() { follow: true,
$("#stock-table").bootstrapTable('refresh');
},
data: { data: {
{% if location %} {% if location %}
location: {{ location.id }} location: {{ location.id }}

View File

@ -23,6 +23,7 @@ stock_item_detail_urls = [
url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),

View File

@ -223,6 +223,43 @@ class StockItemAttachmentDelete(AjaxDeleteView):
} }
class StockItemAssignToCustomer(AjaxUpdateView):
"""
View for manually assigning a StockItem to a Customer
"""
model = StockItem
ajax_form_title = _("Assign to Customer")
context_object_name = "item"
form_class = StockForms.AssignStockItemToCustomerForm
def post(self, request, *args, **kwargs):
customer = request.POST.get('customer', None)
if customer:
try:
customer = Company.objects.get(pk=customer)
except (ValueError, Company.DoesNotExist):
customer = None
if customer is not None:
stock_item = self.get_object()
item = stock_item.allocateToCustomer(
customer,
user=request.user
)
item.clearAllocations()
data = {
'form_valid': True,
}
return self.renderJsonResponse(request, self.get_form(), data)
class StockItemDeleteTestData(AjaxUpdateView): class StockItemDeleteTestData(AjaxUpdateView):
""" """
View for deleting all test data View for deleting all test data