diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py
index 8c91518de0..193e807b7f 100644
--- a/InvenTree/stock/admin.py
+++ b/InvenTree/stock/admin.py
@@ -13,7 +13,7 @@ from .models import StockItemTracking
from build.models import Build
from company.models import Company, SupplierPart
-from order.models import PurchaseOrder
+from order.models import PurchaseOrder, SalesOrder
from part.models import Part
@@ -74,10 +74,12 @@ class StockItemResource(ModelResource):
belongs_to = Field(attribute='belongs_to', widget=widgets.ForeignKeyWidget(StockItem))
- customer = Field(attribute='customer', widget=widgets.ForeignKeyWidget(Company))
-
build = Field(attribute='build', widget=widgets.ForeignKeyWidget(Build))
+ sales_order = Field(attribute='sales_order', widget=widgets.ForeignKeyWidget(SalesOrder))
+
+ build_order = Field(attribute='build_order', widget=widgets.ForeignKeyWidget(Build))
+
purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder))
# Date management
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 75deff6bd3..c31c1b8993 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -369,10 +369,10 @@ class StockList(generics.ListCreateAPIView):
if in_stock:
# Filter out parts which are not actually "in stock"
- stock_list = stock_list.filter(customer=None, belongs_to=None, build_order=None)
+ stock_list = stock_list.filter(StockItem.IN_STOCK_FILTER)
else:
# Only show parts which are not in stock
- stock_list = stock_list.exclude(customer=None, belongs_to=None, build_order=None)
+ stock_list = stock_list.exclude(StockItem.IN_STOCK_FILTER)
# Filter by 'allocated' patrs?
allocated = self.request.query_params.get('allocated', None)
@@ -511,9 +511,9 @@ class StockList(generics.ListCreateAPIView):
filter_fields = [
'supplier_part',
- 'customer',
'belongs_to',
'build',
+ 'build_order',
'sales_order',
'build_order',
]
diff --git a/InvenTree/stock/migrations/0033_auto_20200426_0539.py b/InvenTree/stock/migrations/0033_auto_20200426_0539.py
new file mode 100644
index 0000000000..214a66feeb
--- /dev/null
+++ b/InvenTree/stock/migrations/0033_auto_20200426_0539.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.5 on 2020-04-26 05:39
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0032_stockitem_build_order'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stockitem',
+ name='status',
+ field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost'), (85, 'Returned'), (110, 'Shipped'), (120, 'Used for Build'), (130, 'Installed in Stock Item')], default=10, validators=[django.core.validators.MinValueValidator(0)]),
+ ),
+ ]
diff --git a/InvenTree/stock/migrations/0034_auto_20200426_0602.py b/InvenTree/stock/migrations/0034_auto_20200426_0602.py
new file mode 100644
index 0000000000..4bf3171aa2
--- /dev/null
+++ b/InvenTree/stock/migrations/0034_auto_20200426_0602.py
@@ -0,0 +1,96 @@
+# Generated by Django 3.0.5 on 2020-04-26 06:02
+
+import InvenTree.fields
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import markdownx.models
+import mptt.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('order', '0030_auto_20200426_0551'),
+ ('build', '0016_auto_20200426_0551'),
+ ('part', '0035_auto_20200406_0045'),
+ ('company', '0021_remove_supplierpart_manufacturer_name'),
+ ('stock', '0033_auto_20200426_0539'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='stockitem',
+ name='customer',
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='batch',
+ field=models.CharField(blank=True, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='belongs_to',
+ field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='owned_parts', to='stock.StockItem', verbose_name='Installed In'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='build',
+ field=models.ForeignKey(blank=True, help_text='Build for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='build_outputs', to='build.Build', verbose_name='Source Build'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='build_order',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='build.Build', verbose_name='Destination Build Order'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='link',
+ field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', max_length=125, verbose_name='External Link'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='location',
+ field=mptt.fields.TreeForeignKey(blank=True, help_text='Where is this stock item located?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='stock_items', to='stock.StockLocation', verbose_name='Stock Location'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='notes',
+ field=markdownx.models.MarkdownxField(blank=True, help_text='Stock Item Notes', null=True, verbose_name='Notes'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='parent',
+ field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='stock.StockItem', verbose_name='Parent Stock Item'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='part',
+ field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'is_template': False, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part', verbose_name='Base Part'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='purchase_order',
+ field=models.ForeignKey(blank=True, help_text='Purchase order for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.PurchaseOrder', verbose_name='Source Purchase Order'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='quantity',
+ field=models.DecimalField(decimal_places=5, default=1, max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Stock Quantity'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='sales_order',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_items', to='order.SalesOrder', verbose_name='Destination Sales Order'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='serial',
+ field=models.PositiveIntegerField(blank=True, help_text='Serial number for this item', null=True, verbose_name='Serial Number'),
+ ),
+ migrations.AlterField(
+ model_name='stockitem',
+ name='supplier_part',
+ field=models.ForeignKey(blank=True, help_text='Select a matching supplier part for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, to='company.SupplierPart', verbose_name='Supplier Part'),
+ ),
+ ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 89103703c6..25c00ca65a 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
from django.urls import reverse
from django.db import models, transaction
-from django.db.models import Sum
+from django.db.models import Sum, Q
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
@@ -30,7 +30,7 @@ from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree
from InvenTree.fields import InvenTreeURLField
-from part.models import Part
+from part import models as PartModels
from order.models import PurchaseOrder, SalesOrder
@@ -133,6 +133,9 @@ class StockItem(MPTTModel):
build_order: Link to a BuildOrder object (if the StockItem has been assigned to a BuildOrder)
"""
+ # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
+ IN_STOCK_FILTER = Q(sales_order=None, build_order=None, belongs_to=None)
+
def save(self, *args, **kwargs):
if not self.pk:
add_note = True
@@ -215,7 +218,7 @@ class StockItem(MPTTModel):
raise ValidationError({
'serial': _('A stock item with this serial number already exists')
})
- except Part.DoesNotExist:
+ except PartModels.Part.DoesNotExist:
pass
def clean(self):
@@ -228,6 +231,18 @@ class StockItem(MPTTModel):
- Quantity must be 1 if the StockItem has a serial number
"""
+ 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:
+ raise ValidationError({
+ 'belongs_to': "Belongs_to field must be specified as statis is marked as ASSIGNED_TO_OTHER_ITEM",
+ 'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set',
+ })
+
# The 'supplier_part' field must point to the same part!
try:
if self.supplier_part is not None:
@@ -261,7 +276,7 @@ class StockItem(MPTTModel):
if self.part.is_template:
raise ValidationError({'part': _('Stock item cannot be created for a template Part')})
- except Part.DoesNotExist:
+ except PartModels.Part.DoesNotExist:
# This gets thrown if self.supplier_part is null
# TODO - Find a test than can be perfomed...
pass
@@ -303,48 +318,75 @@ class StockItem(MPTTModel):
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
- parent = TreeForeignKey('self',
- on_delete=models.DO_NOTHING,
- blank=True, null=True,
- related_name='children')
+ parent = TreeForeignKey(
+ 'self',
+ verbose_name=_('Parent Stock Item'),
+ on_delete=models.DO_NOTHING,
+ blank=True, null=True,
+ related_name='children'
+ )
- part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
- related_name='stock_items', help_text=_('Base part'),
- limit_choices_to={
- 'is_template': False,
- 'active': True,
- 'virtual': False
- })
+ part = models.ForeignKey(
+ 'part.Part', on_delete=models.CASCADE,
+ verbose_name=_('Base Part'),
+ related_name='stock_items', help_text=_('Base part'),
+ limit_choices_to={
+ 'is_template': False,
+ 'active': True,
+ 'virtual': False
+ })
- supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
- help_text=_('Select a matching supplier part for this stock item'))
+ supplier_part = models.ForeignKey(
+ 'company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
+ verbose_name=_('Supplier Part'),
+ help_text=_('Select a matching supplier part for this stock item')
+ )
- location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING,
- related_name='stock_items', blank=True, null=True,
- help_text=_('Where is this stock item located?'))
+ location = TreeForeignKey(
+ StockLocation, on_delete=models.DO_NOTHING,
+ verbose_name=_('Stock Location'),
+ related_name='stock_items',
+ blank=True, null=True,
+ help_text=_('Where is this stock item located?')
+ )
- belongs_to = models.ForeignKey('self', on_delete=models.DO_NOTHING,
- related_name='owned_parts', blank=True, null=True,
- help_text=_('Is this item installed in another item?'))
+ belongs_to = models.ForeignKey(
+ 'self',
+ verbose_name=_('Installed In'),
+ on_delete=models.DO_NOTHING,
+ related_name='owned_parts', blank=True, null=True,
+ help_text=_('Is this item installed in another item?')
+ )
- customer = models.ForeignKey('company.Company', on_delete=models.SET_NULL,
- related_name='stockitems', blank=True, null=True,
- help_text=_('Item assigned to customer?'))
-
- serial = models.PositiveIntegerField(blank=True, null=True,
- help_text=_('Serial number for this item'))
+ serial = models.PositiveIntegerField(
+ verbose_name=_('Serial Number'),
+ blank=True, null=True,
+ help_text=_('Serial number for this item')
+ )
- link = InvenTreeURLField(max_length=125, blank=True, help_text=_("Link to external URL"))
+ link = InvenTreeURLField(
+ verbose_name=_('External Link'),
+ max_length=125, blank=True,
+ help_text=_("Link to external URL")
+ )
- batch = models.CharField(max_length=100, blank=True, null=True,
- help_text=_('Batch code for this stock item'))
+ batch = models.CharField(
+ verbose_name=_('Batch Code'),
+ max_length=100, blank=True, null=True,
+ help_text=_('Batch code for this stock item')
+ )
- quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1)
+ quantity = models.DecimalField(
+ verbose_name=_("Stock Quantity"),
+ max_digits=15, decimal_places=5, validators=[MinValueValidator(0)],
+ default=1
+ )
updated = models.DateField(auto_now=True, null=True)
build = models.ForeignKey(
'build.Build', on_delete=models.SET_NULL,
+ verbose_name=_('Source Build'),
blank=True, null=True,
help_text=_('Build for this stock item'),
related_name='build_outputs',
@@ -353,6 +395,7 @@ class StockItem(MPTTModel):
purchase_order = models.ForeignKey(
PurchaseOrder,
on_delete=models.SET_NULL,
+ verbose_name=_('Source Purchase Order'),
related_name='stock_items',
blank=True, null=True,
help_text=_('Purchase order for this stock item')
@@ -361,12 +404,14 @@ class StockItem(MPTTModel):
sales_order = models.ForeignKey(
SalesOrder,
on_delete=models.SET_NULL,
+ verbose_name=_("Destination Sales Order"),
related_name='stock_items',
null=True, blank=True)
build_order = models.ForeignKey(
'build.Build',
on_delete=models.SET_NULL,
+ verbose_name=_("Destination Build Order"),
related_name='stock_items',
null=True, blank=True
)
@@ -386,7 +431,11 @@ class StockItem(MPTTModel):
choices=StockStatus.items(),
validators=[MinValueValidator(0)])
- notes = MarkdownxField(blank=True, null=True, help_text=_('Stock Item Notes'))
+ notes = MarkdownxField(
+ blank=True, null=True,
+ verbose_name=_("Notes"),
+ help_text=_('Stock Item Notes')
+ )
# If stock item is incoming, an (optional) ETA field
# expected_arrival = models.DateField(null=True, blank=True)
@@ -447,7 +496,7 @@ class StockItem(MPTTModel):
- Has child StockItems
- Has a serial number and is tracked
- Is installed inside another StockItem
- - It has been delivered to a customer
+ - It has been assigned to a SalesOrder
- It has been assigned to a BuildOrder
"""
@@ -457,7 +506,7 @@ class StockItem(MPTTModel):
if self.part.trackable and self.serial is not None:
return False
- if self.customer is not None:
+ if self.sales_order is not None:
return False
if self.build_order is not None:
@@ -485,7 +534,7 @@ class StockItem(MPTTModel):
return False
# Not 'in stock' if it has been sent to a customer
- if self.customer is not None:
+ if self.sales_order is not None:
return False
# Not 'in stock' if it has been allocated to a BuildOrder
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 0c7674a088..4e586b789e 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -118,6 +118,8 @@ class StockItemSerializer(InvenTreeModelSerializer):
fields = [
'allocated',
'batch',
+ 'build_order',
+ 'belongs_to',
'in_stock',
'link',
'location',
@@ -127,6 +129,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'part_detail',
'pk',
'quantity',
+ 'sales_order',
'serial',
'supplier_part',
'supplier_part_detail',
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index ae38263001..bb209f309c 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -15,11 +15,15 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% block pre_content %}
{% include 'stock/loc_link.html' with location=item.location %}
-{% if item.customer %}
+{% if item.sales_order %}
-{% endif %}
+{% elif item.build_order %}
+
+{% else %}
{% for allocation in item.sales_order_allocations.all %}
@@ -32,6 +36,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% trans "This stock item is allocated to Build" %}
#{{ allocation.build.id }} ({% trans "Quantity" %}: {% decimal allocation.quantity %})
{% endfor %}
+{% endif %}
{% if item.serialized %}
@@ -46,11 +51,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
{% endif %}
-{% if item.parent %}
-
-{% endif %}
{% endblock %}
@@ -59,7 +59,19 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% endblock %}
{% block page_data %}
-
{% trans "Stock Item" %}{% if item.status in StockStatus.UNAVAILABLE_CODES %}{% stock_status_label item.status large=True %}{% endif %}
+
+ {% trans "Stock Item" %}
+ {% if item.sales_order %}
+
+ {% trans "Sold" $}
+
+ {% elif item.build_order %}
+
+ {% trans "Used in Build" %}
+
+ {% elif item.status in StockStatus.UNAVAILABLE_CODES %}{% stock_status_label item.status large=True %}
+ {% endif %}
+
{% if item.serialized %}
@@ -74,10 +86,10 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% if item.in_stock %}
{% if not item.serialized %}
-
+
-
+
@@ -125,11 +137,17 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% trans "Belongs To" %}
{{ item.belongs_to }}
- {% elif item.customer %}
+ {% elif item.sales_order %}
- {% trans "Customer" %}
- {{ item.customer.name }}
+ {% trans "Sales Order" %}
+ {{ item.sales_order.reference }} - {{ item.sales_order.customer.name }}
+
+ {% elif item.build_order %}
+
+
+ {% trans "Build Order" %}
+ {{ item.build_order }}
{% elif item.location %}
@@ -183,7 +201,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% trans "Parent Item" %}
- {{ item.parent }}
+ {% trans "Stock Item" %} #{{ item.parent.id }}
{% endif %}
{% if item.link %}
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 638f15569d..f32f3a9a95 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -258,9 +258,7 @@ class StockExport(AjaxView):
stock_items = stock_items.filter(supplier_part=supplier_part)
# Filter out stock items that are not 'in stock'
- # TODO - This might need some more thought in the future...
- stock_items = stock_items.filter(customer=None)
- stock_items = stock_items.filter(belongs_to=None)
+ stock_items = stock_items.filter(StockItem.IN_STOCK_FILTER)
# Pre-fetch related fields to reduce DB queries
stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build')
@@ -314,7 +312,7 @@ class StockAdjust(AjaxView, FormMixin):
"""
# Start with all 'in stock' items
- items = StockItem.objects.filter(customer=None, belongs_to=None)
+ items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Client provides a list of individual stock items
if 'stock[]' in self.request.GET:
diff --git a/InvenTree/templates/table_filters.html b/InvenTree/templates/table_filters.html
index b337b25ac8..a8a2fddcdf 100644
--- a/InvenTree/templates/table_filters.html
+++ b/InvenTree/templates/table_filters.html
@@ -21,6 +21,11 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Include sublocations" %}',
description: '{% trans "Include stock in sublocations" %}',
},
+ in_stock: {
+ type: 'bool',
+ title: '{% trans "In stock" %}',
+ description: '{% trans "Item is in stock" %}',
+ },
active: {
type: 'bool',
title: '{% trans "Active parts" %}',