diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index a28b27a5eb..a7fc93bf3f 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -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: diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 57c31ff1db..66ec98ac77 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -21,6 +21,7 @@ class EditBuildForm(HelperForm): 'title', 'part', 'quantity', + 'take_from', 'batch', 'URL', 'notes', diff --git a/InvenTree/build/migrations/0013_build_take_from.py b/InvenTree/build/migrations/0013_build_take_from.py new file mode 100644 index 0000000000..9945da2be1 --- /dev/null +++ b/InvenTree/build/migrations/0013_build_take_from.py @@ -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'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 0b658dee1a..33a63797ac 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -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 """ diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index fef67f77c1..b4de8787b1 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -45,6 +45,16 @@ InvenTree | Build - {{ build }} Quantity{{ build.quantity }} + + Stock Source + + {% if build.take_from %} + {{ build.take_from }} + {% else %} + Stock can be taken from any available location. + {% endif %} + + Status{% include "build_status.html" with build=build %} diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 79e3819df5..85d07858fb 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -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)]) diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 9086d66f1f..2e6d3c694c 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index f91f4d3d7d..5a318c85b5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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') diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 67d92c87e7..b4216c46c1 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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