Merge branch 'part-revision'

This commit is contained in:
Oliver Walters 2019-05-10 20:19:21 +10:00
commit 1b28326c5e
9 changed files with 181 additions and 64 deletions

View File

@ -17,6 +17,11 @@ class InvenTreeTree(models.Model):
- Each Category has one parent Category, which can be blank (for a top-level Category).
- Each Category can have zero-or-more child Categor(y/ies)
Attributes:
name: brief name
description: longer form description
parent: The item immediately above this one. An item with a null parent is a top-level item
"""
class Meta:

View File

@ -21,6 +21,7 @@ class EditBuildForm(HelperForm):
'title',
'part',
'quantity',
'take_from',
'batch',
'URL',
'notes',

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2 on 2019-05-10 08:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0015_stockitem_delete_on_deplete'),
('build', '0012_auto_20190508_2332'),
]
operations = [
migrations.AddField(
model_name='build',
name='take_from',
field=models.ForeignKey(blank=True, help_text='Select location to take stock from for this build (leave blank to take from any stock location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sourcing_builds', to='stock.StockLocation'),
),
]

View File

@ -27,6 +27,7 @@ class Build(models.Model):
part: The part to be built (from component BOM items)
title: Brief title describing the build (required)
quantity: Number of units to be built
take_from: Location to take stock from to make this build (if blank, can take from anywhere)
status: Build status code
batch: Batch code transferred to build parts (optional)
creation_date: Date the build was created (auto)
@ -41,6 +42,11 @@ class Build(models.Model):
def get_absolute_url(self):
return reverse('build-detail', kwargs={'pk': self.id})
title = models.CharField(
blank=False,
max_length=100,
help_text='Brief description of the build')
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
related_name='builds',
limit_choices_to={
@ -50,10 +56,11 @@ class Build(models.Model):
help_text='Select part to build',
)
title = models.CharField(
blank=False,
max_length=100,
help_text='Brief description of the build')
take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
related_name='sourcing_builds',
null=True, blank=True,
help_text='Select location to take stock from for this build (leave blank to take from any stock location'
)
quantity = models.PositiveIntegerField(
default=1,
@ -139,6 +146,11 @@ class Build(models.Model):
stock = StockItem.objects.filter(part=item.sub_part)
# Ensure that the available stock items are in the correct location
if self.take_from is not None:
# Filter for stock that is located downstream of the designated location
stock = stock.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
# Only one StockItem to choose from? Default to that one!
if len(stock) == 1:
stock_item = stock[0]
@ -330,7 +342,7 @@ class BuildItem(models.Model):
Attributes:
build: Link to a Build object
stock: Link to a StockItem object
stock_item: Link to a StockItem object
quantity: Number of units allocated
"""

View File

@ -45,6 +45,16 @@ InvenTree | Build - {{ build }}
<tr>
<td>Quantity</td><td>{{ build.quantity }}</td>
</tr>
<tr>
<td>Stock Source</td>
<td>
{% if build.take_from %}
<a href="{% url 'stock-location-detail' build.take_from.id %}">{{ build.take_from }}</a>
{% else %}
Stock can be taken from any available location.
{% endif %}
</td>
</tr>
<tr>
<td>Status</td><td>{% include "build_status.html" with build=build %}</td>
</tr>

View File

@ -374,6 +374,16 @@ class BuildItemCreate(AjaxCreateView):
query = query.filter(part=part_id)
if build_id is not None:
try:
build = Build.objects.get(id=build_id)
if build.take_from is not None:
# Limit query to stock items that are downstream of the 'take_from' location
query = query.filter(location__in=[loc for loc in build.take_from.getUniqueChildren()])
except Build.DoesNotExist:
pass
# Exclude StockItem objects which are already allocated to this build and part
query = query.exclude(id__in=[item.stock_item.id for item in BuildItem.objects.filter(build=build_id, stock_item__part=part_id)])

View File

@ -42,6 +42,19 @@ def rename_company_image(instance, filename):
class Company(models.Model):
""" A Company object represents an external company.
It may be a supplier or a customer (or both).
Attributes:
name: Brief name of the company
description: Longer form description
website: URL for the company website
address: Postal address
phone: contact phone number
email: contact email address
URL: Secondary URL e.g. for link to internal Wiki page
image: Company image / logo
notes: Extra notes about the company
is_customer: boolean value, is this company a customer
is_supplier: boolean value, is this company a supplier
"""
name = models.CharField(max_length=100, blank=False, unique=True,
@ -101,9 +114,19 @@ class Company(models.Model):
class Contact(models.Model):
""" A Contact represents a person who works at a particular company.
A Company may have zero or more associated Contact objects
A Company may have zero or more associated Contact objects.
Attributes:
company: Company link for this contact
name: Name of the contact
phone: contact phone number
email: contact email
role: position in company
"""
company = models.ForeignKey(Company, related_name='contacts',
on_delete=models.CASCADE)
name = models.CharField(max_length=100)
phone = models.CharField(max_length=100, blank=True)

View File

@ -116,9 +116,30 @@ def rename_part_image(instance, filename):
class Part(models.Model):
""" Represents an abstract part
Parts can be "stocked" in multiple warehouses,
and can be combined to form other parts
""" The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately.
Parts can be used to create other parts (as part of a Bill of Materials or BOM).
Attributes:
name: Brief name for this part
description: Longer form description of the part
category: The PartCategory to which this part belongs
IPN: Internal part number (optional)
URL: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part
default_location: Where the item is normally stored (may be null)
default_supplier: The default SupplierPart which should be used to procure and stock this part
minimum_stock: Minimum preferred quantity to keep in stock
units: Units of measure for this part (default='pcs')
salable: Can this part be sold to customers?
buildable: Can this part be build from other parts?
consumable: Can this part be used to make other parts?
purchaseable: Can this part be purchased from suppliers?
trackable: Trackable parts can have unique serial numbers assigned, etc, etc
active: Is this part active? Parts are deactivated instead of being deleted
notes: Additional notes field for this part
"""
def get_absolute_url(self):
@ -133,26 +154,19 @@ class Part(models.Model):
else:
return static('/img/blank_image.png')
# Short name of the part
name = models.CharField(max_length=100, unique=True, blank=False, help_text='Part name (must be unique)')
# Longer description of the part (optional)
description = models.CharField(max_length=250, blank=False, help_text='Part description')
# Internal Part Number (optional)
# Potentially multiple parts map to the same internal IPN (variants?)
# So this does not have to be unique
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
# Provide a URL for an external link
URL = models.URLField(blank=True, help_text='Link to extenal URL')
# Part category - all parts must be assigned to a category
category = models.ForeignKey(PartCategory, related_name='parts',
null=True, blank=True,
on_delete=models.DO_NOTHING,
help_text='Part category')
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
URL = models.URLField(blank=True, help_text='Link to extenal URL')
image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True)
default_location = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
@ -183,23 +197,18 @@ class Part(models.Model):
# Default case - no default category found
return None
# Default supplier part
default_supplier = models.ForeignKey('part.SupplierPart',
on_delete=models.SET_NULL,
blank=True, null=True,
help_text='Default supplier part',
related_name='default_parts')
# Minimum "allowed" stock level
minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level')
# Units of quantity for this part. Default is "pcs"
units = models.CharField(max_length=20, default="pcs", blank=True, help_text='Stock keeping units for this part')
# Can this part be built from other parts?
buildable = models.BooleanField(default=False, help_text='Can this part be built from other parts?')
# Can this part be used to make other parts?
consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?')
# Is this part "trackable"?
@ -434,6 +443,11 @@ def attach_file(instance, filename):
class PartAttachment(models.Model):
""" A PartAttachment links a file to a part
Parts can have multiple files such as datasheets, etc
Attributes:
part: Link to a Part object
attachment: File
comment: String descriptor for the attachment
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE,
@ -454,6 +468,10 @@ class PartStar(models.Model):
It is used to designate a Part as 'starred' (or favourited) for a given User,
so that the user can track a list of their favourite parts.
Attributes:
part: Link to a Part object
user: Link to a User object
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='starred_users')
@ -467,7 +485,13 @@ class PartStar(models.Model):
class BomItem(models.Model):
""" A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines
which parts are required (and in what quatity) to make it
which parts are required (and in what quatity) to make it.
Attributes:
part: Link to the parent part (the part that will be produced)
sub_part: Link to the child part (the part that will be consumed)
quantity: Number of 'sub_parts' consumed to produce one 'part'
note: Note field for this BOM item
"""
def get_absolute_url(self):
@ -530,8 +554,23 @@ class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a MPN (Manufacturer Part Number)
Each SupplierPart is also linked to a Part object.
A Part may be available from multiple suppliers
Attributes:
part: Link to the master Part
supplier: Company that supplies this SupplierPart object
SKU: Stock keeping unit (supplier part number)
manufacturer: Manufacturer name
MPN: Manufacture part number
URL: Link to external website for this part
description: Descriptive notes field
note: Longer form note field
single_price: Default price for a single unit
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
multiple: Multiple that the part is provided in
minimum: MOQ (minimum order quantity) required for purchase
lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel"
"""
def get_absolute_url(self):
@ -540,8 +579,6 @@ class SupplierPart(models.Model):
class Meta:
unique_together = ('part', 'supplier', 'SKU')
# Link to an actual part
# The part will have a field 'supplier_parts' which links to the supplier part options
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='supplier_parts',
limit_choices_to={'purchaseable': True},
@ -564,25 +601,18 @@ class SupplierPart(models.Model):
description = models.CharField(max_length=250, blank=True, help_text='Supplier part description')
# Note attached to this BOM line item
note = models.CharField(max_length=100, blank=True, help_text='Notes')
# Default price for a single unit
single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Price for single quantity')
# Base charge added to order independent of quantity e.g. "Reeling Fee"
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Minimum charge (e.g. stocking fee)')
# packaging that the part is supplied in, e.g. "Reel"
packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging')
# multiple that the part is provided in
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple')
# Mimumum number required to order
minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)')
# lead time for parts that cannot be delivered immediately
lead_time = models.DurationField(blank=True, null=True)
@property
@ -654,9 +684,14 @@ class SupplierPart(models.Model):
class SupplierPriceBreak(models.Model):
""" Represents a quantity price break for a SupplierPart
""" Represents a quantity price break for a SupplierPart.
- Suppliers can offer discounts at larger quantities
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
Attributes:
part: Link to a SupplierPart object that this price break applies to
quantity: Quantity required for price break
cost: Cost at specified quantity
"""
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks')

View File

@ -77,10 +77,23 @@ def before_delete_stock_location(sender, instance, using, **kwargs):
class StockItem(models.Model):
"""
A 'StockItem' instance represents a quantity of physical instances of a part.
It may exist in a StockLocation, or as part of a sub-assembly installed into another StockItem
StockItems may be tracked using batch or serial numbers.
If a serial number is assigned, then StockItem cannot have a quantity other than 1
A StockItem object represents a quantity of physical instances of a part.
Attributes:
part: Link to the master abstract part that this StockItem is an instance of
supplier_part: Link to a specific SupplierPart (optional)
location: Where this StockItem is located
quantity: Number of stocked units
batch: Batch number for this StockItem
URL: Optional URL to link to external resource
updated: Date that this stock item was last updated (auto)
stocktake_date: Date of last stocktake for this item
stocktake_user: User that performed the most recent stocktake
review_needed: Flag if StockItem needs review
delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero
status: Status of this StockItem (ref: ITEM_STATUS_CODES)
notes: Extra notes field
infinite: If True this StockItem can never be exhausted
"""
def save(self, *args, **kwargs):
@ -171,46 +184,33 @@ class StockItem(models.Model):
}
)
# The 'master' copy of the part of which this stock item is an instance
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='locations', help_text='Base part')
# The 'supplier part' used in this instance. May be null if no supplier parts are defined the master part
supplier_part = models.ForeignKey('part.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
help_text='Select a matching supplier part for this stock item')
# Where the part is stored. If the part has been used to build another stock item, the location may not make sense
location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING,
related_name='stock_items', blank=True, null=True,
help_text='Where is this stock item located?')
# If this StockItem belongs to another StockItem (e.g. as part of a sub-assembly)
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?')
# The StockItem may be assigned to a particular customer
customer = models.ForeignKey('company.Company', on_delete=models.SET_NULL,
related_name='stockitems', blank=True, null=True,
help_text='Item assigned to customer?')
# Optional serial number
serial = models.PositiveIntegerField(blank=True, null=True,
help_text='Serial number for this item')
# Optional URL to link to external resource
URL = models.URLField(max_length=125, blank=True)
# Optional batch information
batch = models.CharField(max_length=100, blank=True, null=True,
help_text='Batch code for this stock item')
# If this part was produced by a build, point to that build here
# build = models.ForeignKey('build.Build', on_delete=models.SET_NULL, blank=True, null=True)
# Quantity of this stock item. Value may be overridden by other settings
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1)
# Last time this item was updated (set automagically)
updated = models.DateField(auto_now=True)
# last time the stock was checked / counted
@ -409,33 +409,34 @@ class StockItem(models.Model):
class StockItemTracking(models.Model):
""" Stock tracking entry
""" Stock tracking entry - breacrumb for keeping track of automated stock transactions
Attributes:
item: Link to StockItem
date: Date that this tracking info was created
title: Title of this tracking info (generated by system)
notes: Associated notes (input by user)
user: The user associated with this tracking info
quantity: The StockItem quantity at this point in time
"""
def get_absolute_url(self):
return '/stock/track/{pk}'.format(pk=self.id)
# return reverse('stock-tracking-detail', kwargs={'pk': self.id})
# Stock item
item = models.ForeignKey(StockItem, on_delete=models.CASCADE,
related_name='tracking_info')
# Date this entry was created (cannot be edited)
date = models.DateTimeField(auto_now_add=True, editable=False)
# Short-form title for this tracking entry
title = models.CharField(blank=False, max_length=250)
# Optional longer description
notes = models.TextField(blank=True)
# Which user created this tracking entry?
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
# Was this tracking note auto-generated by the system?
system = models.BooleanField(default=False)
# Keep track of the StockItem quantity throughout the tracking history
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)], default=1)
# TODO