class StockItemTracking(models.Model): """ 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) URL: Optional URL to external page 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}) item = models.ForeignKey(StockItem, on_delete=models.CASCADE, related_name='tracking_info') date = models.DateTimeField(auto_now_add=True, editable=False) title = models.CharField(blank=False, max_length=250, help_text=_('Tracking entry title')) notes = models.CharField(blank=True, max_length=512, help_text=_('Entry notes')) URL = InvenTreeURLField(blank=True, help_text=_('Link to external page for further information')) user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) system = models.BooleanField(default=False) quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1)
class Build(MPTTModel): """ A Build object organises the creation of new parts from the component parts. Attributes: part: The part to be built (from component BOM items) title: Brief title describing the build (required) quantity: Number of units to be built parent: Reference to a Build object for which this Build is required sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order) 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) completion_date: Date the build was completed link: External URL for extra information notes: Text notes """ def __str__(self): return "{q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part.full_name)) def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) def clean(self): """ Validation for Build object. """ super().clean() try: if self.part.trackable: if not self.quantity == int(self.quantity): raise ValidationError({ 'quantity': _("Build quantity must be integer value for trackable parts" ) }) except PartModels.Part.DoesNotExist: pass title = models.CharField(verbose_name=_('Build Title'), blank=False, max_length=100, help_text=_('Brief description of the build')) parent = TreeForeignKey( 'self', on_delete=models.DO_NOTHING, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('Parent build to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'is_template': False, 'assembly': True, 'active': True, 'virtual': False, }, help_text=_('Select part to build'), ) sales_order = models.ForeignKey( 'order.SalesOrder', verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', null=True, blank=True, help_text=_('SalesOrder to which this build is allocated')) take_from = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Source Location'), 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( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of parts to build')) status = models.PositiveIntegerField(verbose_name=_('Build Status'), default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status code')) batch = models.CharField(verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) creation_date = models.DateField(auto_now_add=True, editable=False) completion_date = models.DateField(null=True, blank=True) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed') link = InvenTreeURLField(verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL')) notes = MarkdownxField(verbose_name=_('Notes'), blank=True, help_text=_('Extra build notes')) @property def output_count(self): return self.build_outputs.count() @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() def getAutoAllocations(self): """ Return a list of parts which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part: - If there is a single StockItem, use that StockItem - Take as many parts as available (up to the quantity required for the BOM) - If there are multiple StockItems available, ignore (leave up to the user) Returns: A list object containing the StockItem objects to be allocated (and the quantities) """ allocations = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): # How many parts required for this build? q_required = item.quantity * self.quantity # Grab a list of StockItem objects which are "in stock" stock = StockModels.StockItem.objects.filter( StockModels.StockItem.IN_STOCK_FILTER) # Filter by part reference stock = stock.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] # Check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item) if len(build_items) > 0: continue # Are there any parts available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < q_required: q_required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': q_required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self): """ Deletes all stock allocations for this build. """ BuildItem.objects.filter(build=self.id).delete() @transaction.atomic def autoAllocate(self): """ Run auto-allocation routine to allocate StockItems to this Build. Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations() for item in allocations: # Create a new allocation build_item = BuildItem(build=self, stock_item=item['stock_item'], quantity=item['quantity']) build_item.save() @transaction.atomic def completeBuild(self, location, serial_numbers, user): """ Mark the Build as COMPLETE - Takes allocated items from stock - Delete pending BuildItem objects """ # Complete the build allocation for each BuildItem for build_item in self.allocated_stock.all().prefetch_related( 'stock_item'): build_item.complete_allocation(user) # Check that the stock-item has been assigned to this build, and remove the builditem from the database if build_item.stock_item.build_order == self: build_item.delete() notes = 'Built {q} on {now}'.format(q=self.quantity, now=str(datetime.now().date())) # Generate the build outputs if self.part.trackable and serial_numbers: # Add new serial numbers for serial in serial_numbers: item = StockModels.StockItem.objects.create( part=self.part, build=self, location=location, quantity=1, serial=serial, batch=str(self.batch) if self.batch else '', notes=notes) item.save() else: # Add stock of the newly created item item = StockModels.StockItem.objects.create( part=self.part, build=self, location=location, quantity=self.quantity, batch=str(self.batch) if self.batch else '', notes=notes) item.save() # Finally, mark the build as complete self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() return True def isFullyAllocated(self): """ Return True if this build has been fully allocated. """ bom_items = self.part.bom_items.all() for item in bom_items: part = item.sub_part if not self.isPartFullyAllocated(part): return False return True def isPartFullyAllocated(self, part): """ Check if a given Part is fully allocated for this Build """ return self.getAllocatedQuantity(part) >= self.getRequiredQuantity( part) def getRequiredQuantity(self, part): """ Calculate the quantity of <part> required to make this build. """ try: item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id) q = item.quantity except PartModels.BomItem.DoesNotExist: q = 0 return q * self.quantity def getAllocatedQuantity(self, part): """ Calculate the total number of <part> currently allocated to this build """ allocated = BuildItem.objects.filter( build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0)) return allocated['q'] def getUnallocatedQuantity(self, part): """ Calculate the quantity of <part> which still needs to be allocated to this build. Args: Part - the part to be tested Returns: The remaining allocated quantity """ return max( self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0) @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): part = { 'part': item.sub_part, 'per_build': item.quantity, 'quantity': item.quantity * self.quantity, 'allocated': self.getAllocatedQuantity(item.sub_part) } parts.append(part) return parts @property def can_build(self): """ Return true if there are enough parts to supply build """ for item in self.required_parts: if item['part'].total_stock < item['quantity']: return False return True @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE
class Company(models.Model): """ A Company object represents an external company. It may be a supplier or a customer or a manufacturer (or a combination) - A supplier is a company from which parts can be purchased - A customer is a company to which parts can be sold - A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also) 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 link: 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 is_manufacturer: boolean value, is this company a manufacturer currency_code: Specifies the default currency for the company """ class Meta: ordering = [ 'name', ] constraints = [ UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') ] name = models.CharField(max_length=100, blank=False, help_text=_('Company name'), verbose_name=_('Company name')) description = models.CharField(max_length=500, verbose_name=_('Company description'), help_text=_('Description of the company')) website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL')) address = models.CharField(max_length=200, verbose_name=_('Address'), blank=True, help_text=_('Company address')) phone = models.CharField(max_length=50, verbose_name=_('Phone number'), blank=True, help_text=_('Contact phone number')) email = models.EmailField(blank=True, null=True, verbose_name=_('Email'), help_text=_('Contact email address')) contact = models.CharField(max_length=100, verbose_name=_('Contact'), blank=True, help_text=_('Point of contact')) link = InvenTreeURLField( blank=True, help_text=_('Link to external company information')) image = StdImageField( upload_to=rename_company_image, null=True, blank=True, variations={'thumbnail': (128, 128)}, delete_orphans=True, ) notes = MarkdownxField(blank=True) is_customer = models.BooleanField( default=False, help_text=_('Do you sell items to this company?')) is_supplier = models.BooleanField( default=True, help_text=_('Do you purchase items from this company?')) is_manufacturer = models.BooleanField( default=False, help_text=_('Does this company manufacture parts?')) currency = models.CharField( max_length=3, verbose_name=_('Currency'), blank=True, help_text=_('Default currency used for this company'), validators=[InvenTree.validators.validate_currency_code], ) @property def currency_code(self): """ Return the currency code associated with this company. - If the currency code is invalid, use the default currency - If the currency code is not specified, use the default currency """ code = self.currency if code not in CURRENCIES: code = common.settings.currency_code_default() return code def __str__(self): """ Get string representation of a Company """ return "{n} - {d}".format(n=self.name, d=self.description) def get_absolute_url(self): """ Get the web URL for the detail view for this Company """ return reverse('company-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this company """ if self.image: return getMediaUrl(self.image.url) else: return getBlankImage() def get_thumbnail_url(self): """ Return the URL for the thumbnail image for this Company """ if self.image: return getMediaUrl(self.image.thumbnail.url) else: return getBlankThumbnail() @property def manufactured_part_count(self): """ The number of parts manufactured by this company """ return self.manufactured_parts.count() @property def has_manufactured_parts(self): return self.manufactured_part_count > 0 @property def supplied_part_count(self): """ The number of parts supplied by this company """ return self.supplied_parts.count() @property def has_supplied_parts(self): """ Return True if this company supplies any parts """ return self.supplied_part_count > 0 @property def parts(self): """ Return SupplierPart objects which are supplied or manufactured by this company """ return SupplierPart.objects.filter( Q(supplier=self.id) | Q(manufacturer=self.id)) @property def part_count(self): """ The number of parts manufactured (or supplied) by this Company """ return self.parts.count() @property def has_parts(self): return self.part_count > 0 @property def stock_items(self): """ Return a list of all stock items supplied or manufactured by this company """ stock = apps.get_model('stock', 'StockItem') return stock.objects.filter( Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all() @property def stock_count(self): """ Return the number of stock items supplied or manufactured by this company """ return self.stock_items.count() def outstanding_purchase_orders(self): """ Return purchase orders which are 'outstanding' """ return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN) def pending_purchase_orders(self): """ Return purchase orders which are PENDING (not yet issued) """ return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING) def closed_purchase_orders(self): """ Return purchase orders which are not 'outstanding' - Complete - Failed / lost - Returned """ return self.purchase_orders.exclude( status__in=PurchaseOrderStatus.OPEN) def complete_purchase_orders(self): return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE) def failed_purchase_orders(self): """ Return any purchase orders which were not successful """ return self.purchase_orders.filter( status__in=PurchaseOrderStatus.FAILED)
class StockItem(MPTTModel): """ A StockItem object represents a quantity of physical instances of a part. Attributes: parent: Link to another StockItem from which this StockItem was created 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 serial: Unique serial 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: InvenTree.status_codes.StockStatus) notes: Extra notes field build: Link to a Build (if this stock item was created from a build) purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) infinite: If True this StockItem can never be exhausted """ def save(self, *args, **kwargs): if not self.pk: add_note = True else: add_note = False user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) super(StockItem, self).save(*args, **kwargs) if add_note: # This StockItem is being saved for the first time self.addTransactionNote( 'Created stock item', user, notes="Created new stock item for part '{p}'".format(p=str(self.part)), system=True ) @property def status_label(self): return StockStatus.label(self.status) @property def serialized(self): """ Return True if this StockItem is serialized """ return self.serial is not None and self.quantity == 1 @classmethod def check_serial_number(cls, part, serial_number): """ Check if a new stock item can be created with the provided part_id Args: part: The part to be checked """ if not part.trackable: return False # Return False if an invalid serial number is supplied try: serial_number = int(serial_number) except ValueError: return False items = StockItem.objects.filter(serial=serial_number) # Is this part a variant? If so, check S/N across all sibling variants if part.variant_of is not None: items = items.filter(part__variant_of=part.variant_of) else: items = items.filter(part=part) # An existing serial number exists if items.exists(): return False return True def validate_unique(self, exclude=None): super(StockItem, self).validate_unique(exclude) # If the Part object is a variant (of a template part), # ensure that the serial number is unique # across all variants of the same template part try: if self.serial is not None: # This is a variant part (check S/N across all sibling variants) if self.part.variant_of is not None: if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists(): raise ValidationError({ 'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of)) }) else: if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists(): raise ValidationError({ 'serial': _('A stock item with this serial number already exists') }) except Part.DoesNotExist: pass def clean(self): """ Validate the StockItem object (separate to field validation) The following validation checks are performed: - The 'part' and 'supplier_part.part' fields cannot point to the same Part object - The 'part' does not belong to itself - Quantity must be 1 if the StockItem has a serial number """ # The 'supplier_part' field must point to the same part! try: if self.supplier_part is not None: if not self.supplier_part.part == self.part: raise ValidationError({'supplier_part': _("Part type ('{pf}') must be {pe}").format( pf=str(self.supplier_part.part), pe=str(self.part)) }) if self.part is not None: # A part with a serial number MUST have the quantity set to 1 if self.serial is not None: if self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number'), 'serial': _('Serial number cannot be set if quantity greater than 1') }) if self.quantity == 0: self.quantity = 1 elif self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number') }) # Serial numbered items cannot be deleted on depletion self.delete_on_deplete = False # A template part cannot be instantiated as a StockItem if self.part.is_template: raise ValidationError({'part': _('Stock item cannot be created for a template Part')}) except Part.DoesNotExist: # This gets thrown if self.supplier_part is null # TODO - Find a test than can be perfomed... pass if self.belongs_to and self.belongs_to.pk == self.pk: raise ValidationError({ 'belongs_to': _('Item cannot belong to itself') }) def get_absolute_url(self): return reverse('stock-item-detail', kwargs={'pk': self.id}) def get_part_name(self): return self.part.full_name class Meta: unique_together = [ ('part', 'serial'), ] def format_barcode(self): """ Return a JSON string for formatting a barcode for this StockItem. Can be used to perform lookup of a stockitem using barcode Contains the following data: { type: 'StockItem', stock_id: <pk>, part_id: <part_pk> } Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change) """ return helpers.MakeBarcode( 'StockItem', self.id, reverse('api-stock-detail', kwargs={'pk': self.id}), { 'part_id': self.part.id, 'part_name': self.part.full_name } ) parent = TreeForeignKey('self', 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, }) 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')) location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING, 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?')) 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')) URL = InvenTreeURLField(max_length=125, blank=True) batch = models.CharField(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) updated = models.DateField(auto_now=True, null=True) build = models.ForeignKey( 'build.Build', on_delete=models.SET_NULL, blank=True, null=True, help_text=_('Build for this stock item'), related_name='build_outputs', ) purchase_order = models.ForeignKey( 'order.PurchaseOrder', on_delete=models.SET_NULL, related_name='stock_items', blank=True, null=True, help_text=_('Purchase order for this stock item') ) # last time the stock was checked / counted stocktake_date = models.DateField(blank=True, null=True) stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='stocktake_stock') review_needed = models.BooleanField(default=False) delete_on_deplete = models.BooleanField(default=True, help_text=_('Delete this Stock Item when stock is depleted')) status = models.PositiveIntegerField( default=StockStatus.OK, choices=StockStatus.items(), validators=[MinValueValidator(0)]) notes = MarkdownxField(blank=True, null=True, help_text=_('Stock Item Notes')) # If stock item is incoming, an (optional) ETA field # expected_arrival = models.DateField(null=True, blank=True) infinite = models.BooleanField(default=False) def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: - Has child StockItems - Has a serial number and is tracked - Is installed inside another StockItem """ if self.child_count > 0: return False if self.part.trackable and self.serial is not None: return False return True @property def children(self): """ Return a list of the child items which have been split from this stock item """ return self.get_descendants(include_self=False) @property def child_count(self): """ Return the number of 'child' items associated with this StockItem. A child item is one which has been split from this one. """ return self.children.count() @property def in_stock(self): if self.belongs_to or self.customer: return False return True @property def has_tracking_info(self): return self.tracking_info.count() > 0 def addTransactionNote(self, title, user, notes='', url='', system=True): """ Generation a stock transaction note for this item. Brief automated note detailing a movement or quantity change. """ track = StockItemTracking.objects.create( item=self, title=title, user=user, quantity=self.quantity, date=datetime.now().date(), notes=notes, URL=url, system=system ) track.save() @transaction.atomic def serializeStock(self, quantity, serials, user, notes='', location=None): """ Split this stock item into unique serial numbers. - Quantity can be less than or equal to the quantity of the stock item - Number of serial numbers must match the quantity - Provided serial numbers must not already be in use Args: quantity: Number of items to serialize (integer) serials: List of serial numbers (list<int>) user: User object associated with action notes: Optional notes for tracking location: If specified, serialized items will be placed in the given location """ # Cannot serialize stock that is already serialized! if self.serialized: return # Quantity must be a valid integer value try: quantity = int(quantity) except ValueError: raise ValidationError({"quantity": _("Quantity must be integer")}) if quantity <= 0: raise ValidationError({"quantity": _("Quantity must be greater than zero")}) if quantity > self.quantity: raise ValidationError({"quantity": _("Quantity must not exceed available stock quantity ({n})".format(n=self.quantity))}) if not type(serials) in [list, tuple]: raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")}) if any([type(i) is not int for i in serials]): raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")}) if not quantity == len(serials): raise ValidationError({"quantity": _("Quantity does not match serial numbers")}) # Test if each of the serial numbers are valid existing = [] for serial in serials: if not StockItem.check_serial_number(self.part, serial): existing.append(serial) if len(existing) > 0: raise ValidationError({"serial_numbers": _("Serial numbers already exist: ") + str(existing)}) # Create a new stock item for each unique serial number for serial in serials: # Create a copy of this StockItem new_item = StockItem.objects.get(pk=self.pk) new_item.quantity = 1 new_item.serial = serial new_item.pk = None new_item.parent = self if location: new_item.location = location # The item already has a transaction history, don't create a new note new_item.save(user=user, note=False) # Copy entire transaction history new_item.copyHistoryFrom(self) # Create a new stock tracking item new_item.addTransactionNote(_('Add serial number'), user, notes=notes) # Remove the equivalent number of items self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity))) @transaction.atomic def copyHistoryFrom(self, other): """ Copy stock history from another part """ for item in other.tracking_info.all(): item.item = self item.pk = None item.save() @transaction.atomic def splitStock(self, quantity, location, user): """ Split this stock item into two items, in the same location. Stock tracking notes for this StockItem will be duplicated, and added to the new StockItem. Args: quantity: Number of stock items to remove from this entity, and pass to the next location: Where to move the new StockItem to Notes: The provided quantity will be subtracted from this item and given to the new one. The new item will have a different StockItem ID, while this will remain the same. """ # Do not split a serialized part if self.serialized: return try: quantity = Decimal(quantity) except (InvalidOperation, ValueError): return # Doesn't make sense for a zero quantity if quantity <= 0: return # Also doesn't make sense to split the full amount if quantity >= self.quantity: return # Create a new StockItem object, duplicating relevant fields # Nullify the PK so a new record is created new_stock = StockItem.objects.get(pk=self.pk) new_stock.pk = None new_stock.parent = self new_stock.quantity = quantity # Move to the new location if specified, otherwise use current location if location: new_stock.location = location else: new_stock.location = self.location new_stock.save() # Copy the transaction history of this part into the new one new_stock.copyHistoryFrom(self) # Add a new tracking item for the new stock item new_stock.addTransactionNote( "Split from existing stock", user, "Split {n} from existing stock item".format(n=quantity)) # Remove the specified quantity from THIS stock item self.take_stock(quantity, user, 'Split {n} items into new stock item'.format(n=quantity)) @transaction.atomic def move(self, location, notes, user, **kwargs): """ Move part to a new location. If less than the available quantity is to be moved, a new StockItem is created, with the defined quantity, and that new StockItem is moved. The quantity is also subtracted from the existing StockItem. Args: location: Destination location (cannot be null) notes: User notes user: Who is performing the move kwargs: quantity: If provided, override the quantity (default = total stock quantity) """ try: quantity = Decimal(kwargs.get('quantity', self.quantity)) except InvalidOperation: return False if quantity <= 0: return False if location is None: # TODO - Raise appropriate error (cannot move to blank location) return False elif self.location and (location.pk == self.location.pk) and (quantity == self.quantity): # TODO - Raise appropriate error (cannot move to same location) return False # Test for a partial movement if quantity < self.quantity: # We need to split the stock! # Split the existing StockItem in two self.splitStock(quantity, location, user) return True msg = "Moved to {loc}".format(loc=str(location)) if self.location: msg += " (from {loc})".format(loc=str(self.location)) self.location = location self.addTransactionNote( msg, user, notes=notes, system=True) self.save() return True @transaction.atomic def updateQuantity(self, quantity): """ Update stock quantity for this item. If the quantity has reached zero, this StockItem will be deleted. Returns: - True if the quantity was saved - False if the StockItem was deleted """ # Do not adjust quantity of a serialized part if self.serialized: return try: self.quantity = Decimal(quantity) except (InvalidOperation, ValueError): return if quantity < 0: quantity = 0 self.quantity = quantity if quantity == 0 and self.delete_on_deplete and self.can_delete(): # TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag self.delete() return False else: self.save() return True @transaction.atomic def stocktake(self, count, user, notes=''): """ Perform item stocktake. When the quantity of an item is counted, record the date of stocktake """ try: count = Decimal(count) except InvalidOperation: return False if count < 0 or self.infinite: return False self.stocktake_date = datetime.now().date() self.stocktake_user = user if self.updateQuantity(count): self.addTransactionNote('Stocktake - counted {n} items'.format(n=count), user, notes=notes, system=True) return True @transaction.atomic def add_stock(self, quantity, user, notes=''): """ Add items to stock This function can be called by initiating a ProjectRun, or by manually adding the items to the stock location """ # Cannot add items to a serialized part if self.serialized: return False try: quantity = Decimal(quantity) except InvalidOperation: return False # Ignore amounts that do not make sense if quantity <= 0 or self.infinite: return False if self.updateQuantity(self.quantity + quantity): self.addTransactionNote('Added {n} items to stock'.format(n=quantity), user, notes=notes, system=True) return True @transaction.atomic def take_stock(self, quantity, user, notes=''): """ Remove items from stock """ # Cannot remove items from a serialized part if self.serialized: return False try: quantity = Decimal(quantity) except InvalidOperation: return False if quantity <= 0 or self.infinite: return False if self.updateQuantity(self.quantity - quantity): self.addTransactionNote('Removed {n} items from stock'.format(n=quantity), user, notes=notes, system=True) return True def __str__(self): if self.part.trackable and self.serial: s = '{part} #{sn}'.format( part=self.part.full_name, sn=self.serial) else: s = '{n} x {part}'.format( n=helpers.decimal2string(self.quantity), part=self.part.full_name) if self.location: s += ' @ {loc}'.format(loc=self.location.name) return s
class SupplierPart(models.Model): """ Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers Attributes: part: Link to the master Part (Obsolete) source_item: The sourcing item linked to this SupplierPart instance supplier: Company that supplies this SupplierPart object SKU: Stock keeping unit (supplier part number) link: Link to external website for this supplier part description: Descriptive notes field note: Longer form note field base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" multiple: Multiple that the part is provided in lead_time: Supplier lead time packaging: packaging that the part is supplied in, e.g. "Reel" """ objects = SupplierPartManager() @staticmethod def get_api_url(): return reverse('api-supplier-part-list') def get_absolute_url(self): return reverse('supplier-part-detail', kwargs={'pk': self.id}) def api_instance_filters(self): return {'manufacturer_part': {'part': self.part.pk}} class Meta: unique_together = ('part', 'supplier', 'SKU') # This model was moved from the 'Part' app db_table = 'part_supplierpart' def clean(self): super().clean() # Ensure that the linked manufacturer_part points to the same part! if self.manufacturer_part and self.part: if not self.manufacturer_part.part == self.part: raise ValidationError({ 'manufacturer_part': _("Linked manufacturer part must reference the same base part" ), }) def save(self, *args, **kwargs): """ Overriding save method to connect an existing ManufacturerPart """ manufacturer_part = None if all(key in kwargs for key in ('manufacturer', 'MPN')): manufacturer_name = kwargs.pop('manufacturer') MPN = kwargs.pop('MPN') # Retrieve manufacturer part try: manufacturer_part = ManufacturerPart.objects.get( manufacturer__name=manufacturer_name, MPN=MPN) except (ValueError, Company.DoesNotExist): # ManufacturerPart does not exist pass if manufacturer_part: if not self.manufacturer_part: # Connect ManufacturerPart to SupplierPart self.manufacturer_part = manufacturer_part else: raise ValidationError( f'SupplierPart {self.__str__} is already linked to {self.manufacturer_part}' ) self.clean() self.validate_unique() super().save(*args, **kwargs) part = models.ForeignKey( 'part.Part', on_delete=models.CASCADE, related_name='supplier_parts', verbose_name=_('Base Part'), limit_choices_to={ 'purchaseable': True, }, help_text=_('Select part'), ) supplier = models.ForeignKey( Company, on_delete=models.CASCADE, related_name='supplied_parts', limit_choices_to={'is_supplier': True}, verbose_name=_('Supplier'), help_text=_('Select supplier'), ) SKU = models.CharField(max_length=100, verbose_name=_('SKU'), help_text=_('Supplier stock keeping unit')) manufacturer_part = models.ForeignKey( ManufacturerPart, on_delete=models.CASCADE, blank=True, null=True, related_name='supplier_parts', verbose_name=_('Manufacturer Part'), help_text=_('Select manufacturer part'), ) link = InvenTreeURLField( blank=True, null=True, verbose_name=_('Link'), help_text=_('URL for external supplier part link')) description = models.CharField(max_length=250, blank=True, null=True, verbose_name=_('Description'), help_text=_('Supplier part description')) note = models.CharField(max_length=100, blank=True, null=True, verbose_name=_('Note'), help_text=_('Notes')) base_cost = models.DecimalField( max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)')) packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging')) multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple')) # TODO - Reimplement lead-time as a charfield with special validation (pattern matching). # lead_time = models.DurationField(blank=True, null=True) @property def manufacturer_string(self): """ Format a MPN string for this SupplierPart. Concatenates manufacture name and part number. """ items = [] if self.manufacturer_part: if self.manufacturer_part.manufacturer: items.append(self.manufacturer_part.manufacturer.name) if self.manufacturer_part.MPN: items.append(self.manufacturer_part.MPN) return ' | '.join(items) @property def has_price_breaks(self): return self.price_breaks.count() > 0 @property def price_breaks(self): """ Return the associated price breaks in the correct order """ return self.pricebreaks.order_by('quantity').all() @property def unit_pricing(self): return self.get_price(1) def add_price_break(self, quantity, price): """ Create a new price break for this part args: quantity - Numerical quantity price - Must be a Money object """ # Check if a price break at that quantity already exists... if self.price_breaks.filter(quantity=quantity, part=self.pk).exists(): return SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price) get_price = common.models.get_price def open_orders(self): """ Return a database query for PO line items for this SupplierPart, limited to purchase orders that are open / outstanding. """ return self.purchase_order_line_items.prefetch_related('order').filter( order__status__in=PurchaseOrderStatus.OPEN) def on_order(self): """ Return the total quantity of items currently on order. Subtract partially received stock as appropriate """ totals = self.open_orders().aggregate(Sum('quantity'), Sum('received')) # Quantity on order q = totals.get('quantity__sum', 0) # Quantity received r = totals.get('received__sum', 0) if q is None or r is None: return 0 else: return max(q - r, 0) def purchase_orders(self): """ Returns a list of purchase orders relating to this supplier part """ return [ line.order for line in self.purchase_order_line_items.all().prefetch_related('order') ] @property def pretty_name(self): return str(self) def __str__(self): s = '' if self.part.IPN: s += f'{self.part.IPN}' s += ' | ' s += f'{self.supplier.name} | {self.SKU}' if self.manufacturer_string: s = s + ' | ' + self.manufacturer_string return s
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: Company that manufactures the SupplierPart (leave blank if it is the sample as the Supplier!) MPN: Manufacture part number link: Link to external website for this part description: Descriptive notes field note: Longer form note field base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" multiple: Multiple that the part is provided in lead_time: Supplier lead time packaging: packaging that the part is supplied in, e.g. "Reel" """ def get_absolute_url(self): return reverse('supplier-part-detail', kwargs={'pk': self.id}) class Meta: unique_together = ('part', 'supplier', 'SKU') # This model was moved from the 'Part' app db_table = 'part_supplierpart' part = models.ForeignKey( 'part.Part', on_delete=models.CASCADE, related_name='supplier_parts', verbose_name=_('Base Part'), limit_choices_to={ 'purchaseable': True, 'is_template': False, }, help_text=_('Select part'), ) supplier = models.ForeignKey( Company, on_delete=models.CASCADE, related_name='supplied_parts', limit_choices_to={'is_supplier': True}, help_text=_('Select supplier'), ) SKU = models.CharField(max_length=100, help_text=_('Supplier stock keeping unit')) manufacturer = models.ForeignKey( Company, on_delete=models.SET_NULL, related_name='manufactured_parts', limit_choices_to={'is_manufacturer': True}, help_text=_('Select manufacturer'), null=True, blank=True) MPN = models.CharField(max_length=100, blank=True, help_text=_('Manufacturer part number')) link = InvenTreeURLField( blank=True, help_text=_('URL for external supplier part link')) description = models.CharField(max_length=250, blank=True, help_text=_('Supplier part description')) note = models.CharField(max_length=100, blank=True, help_text=_('Notes')) base_cost = models.DecimalField( max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text=_('Minimum charge (e.g. stocking fee)')) packaging = models.CharField(max_length=50, blank=True, help_text=_('Part packaging')) multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text=('Order multiple')) # TODO - Reimplement lead-time as a charfield with special validation (pattern matching). # lead_time = models.DurationField(blank=True, null=True) @property def manufacturer_string(self): """ Format a MPN string for this SupplierPart. Concatenates manufacture name and part number. """ items = [] if self.manufacturer: items.append(self.manufacturer.name) if self.MPN: items.append(self.MPN) return ' | '.join(items) @property def has_price_breaks(self): return self.price_breaks.count() > 0 @property def price_breaks(self): """ Return the associated price breaks in the correct order """ return self.pricebreaks.order_by('quantity').all() @property def unit_pricing(self): return self.get_price(1) def add_price_break(self, quantity, price): """ Create a new price break for this part args: quantity - Numerical quantity price - Must be a Money object """ # Check if a price break at that quantity already exists... if self.price_breaks.filter(quantity=quantity, part=self.pk).exists(): return SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price) def get_price(self, quantity, moq=True, multiples=True, currency=None): """ Calculate the supplier price based on quantity price breaks. - Don't forget to add in flat-fee cost (base_cost field) - If MOQ (minimum order quantity) is required, bump quantity - If order multiples are to be observed, then we need to calculate based on that, too """ price_breaks = self.price_breaks.filter(quantity__lte=quantity) # No price break information available? if len(price_breaks) == 0: return None # Order multiples if multiples: quantity = int(math.ceil(quantity / self.multiple) * self.multiple) pb_found = False pb_quantity = -1 pb_cost = 0.0 if currency is None: # Default currency selection currency = common.models.InvenTreeSetting.get_setting( 'INVENTREE_DEFAULT_CURRENCY') for pb in self.price_breaks.all(): # Ignore this pricebreak (quantity is too high) if pb.quantity > quantity: continue pb_found = True # If this price-break quantity is the largest so far, use it! if pb.quantity > pb_quantity: pb_quantity = pb.quantity # Convert everything to the selected currency pb_cost = pb.convert_to(currency) if pb_found: cost = pb_cost * quantity return normalize(cost + self.base_cost) else: return None def open_orders(self): """ Return a database query for PO line items for this SupplierPart, limited to purchase orders that are open / outstanding. """ return self.purchase_order_line_items.prefetch_related('order').filter( order__status__in=PurchaseOrderStatus.OPEN) def on_order(self): """ Return the total quantity of items currently on order. Subtract partially received stock as appropriate """ totals = self.open_orders().aggregate(Sum('quantity'), Sum('received')) # Quantity on order q = totals.get('quantity__sum', 0) # Quantity received r = totals.get('received__sum', 0) if q is None or r is None: return 0 else: return max(q - r, 0) def purchase_orders(self): """ Returns a list of purchase orders relating to this supplier part """ return [ line.order for line in self.purchase_order_line_items.all().prefetch_related('order') ] @property def pretty_name(self): return str(self) def __str__(self): s = '' if self.part.IPN: s += f'{self.part.IPN}' s += ' | ' s += f'{self.supplier.name} | {self.SKU}' if self.manufacturer_string: s = s + ' | ' + self.manufacturer_string return s
class Part(models.Model): """ 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 variant: Optional variant number for this part - Must be unique for the part name category: The PartCategory to which this part belongs description: Longer form description of the part keywords: Optional keywords for improving part search results IPN: Internal part number (optional) revision: Part revision is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem 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? assembly: Can this part be build from other parts? component: 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 virtual: Is this part "virtual"? e.g. a software product or similar notes: Additional notes field for this part """ class Meta: verbose_name = "Part" verbose_name_plural = "Parts" def __str__(self): return "{n} - {d}".format(n=self.full_name, d=self.description) @property def full_name(self): """ Format a 'full name' for this Part. - IPN (if not null) - Part name - Part variant (if not null) Elements are joined by the | character """ elements = [] if self.IPN: elements.append(self.IPN) elements.append(self.name) if self.revision: elements.append(self.revision) return ' | '.join(elements) def set_category(self, category): # Ignore if the category is already the same if self.category == category: return self.category = category self.save() def get_absolute_url(self): """ Return the web URL for viewing this part """ return reverse('part-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this part """ if self.image: return os.path.join(settings.MEDIA_URL, str(self.image.url)) else: return static('/img/blank_image.png') def validate_unique(self, exclude=None): """ Validate that a part is 'unique'. Uniqueness is checked across the following (case insensitive) fields: * Name * IPN * Revision e.g. there can exist multiple parts with the same name, but only if they have a different revision or internal part number. """ super().validate_unique(exclude) # Part name uniqueness should be case insensitive try: parts = Part.objects.exclude(id=self.id).filter( name__iexact=self.name, IPN__iexact=self.IPN, revision__iexact=self.revision) if parts.exists(): msg = _("Part must be unique for name, IPN and revision") raise ValidationError({ "name": msg, "IPN": msg, "revision": msg, }) except Part.DoesNotExist: pass def clean(self): """ Perform cleaning operations for the Part model """ if self.is_template and self.variant_of is not None: raise ValidationError({ 'is_template': _("Part cannot be a template part if it is a variant of another part" ), 'variant_of': _("Part cannot be a variant of another part if it is already a template" ), }) name = models.CharField(max_length=100, blank=False, help_text='Part name', validators=[validators.validate_part_name]) is_template = models.BooleanField( default=False, help_text='Is this part a template part?') variant_of = models.ForeignKey( 'part.Part', related_name='variants', null=True, blank=True, limit_choices_to={ 'is_template': True, 'active': True, }, on_delete=models.SET_NULL, help_text='Is this part a variant of another part?') description = models.CharField(max_length=250, blank=False, help_text='Part description') keywords = models.CharField( max_length=250, blank=True, help_text='Part keywords to improve visibility in search results') category = TreeForeignKey(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') revision = models.CharField(max_length=100, blank=True, help_text='Part revision or version number') URL = InvenTreeURLField(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 = TreeForeignKey( 'stock.StockLocation', on_delete=models.SET_NULL, blank=True, null=True, help_text='Where is this item normally stored?', related_name='default_parts') def get_default_location(self): """ Get the default location for a Part (may be None). If the Part does not specify a default location, look at the Category this part is in. The PartCategory object may also specify a default stock location """ if self.default_location: return self.default_location elif self.category: # Traverse up the category tree until we find a default location cats = self.category.get_ancestors(ascending=True, include_self=True) for cat in cats: if cat.default_location: return cat.default_location # Default case - no default category found return None def get_default_supplier(self): """ Get the default supplier part for this part (may be None). - If the part specifies a default_supplier, return that - If there is only one supplier part available, return that - Else, return None """ if self.default_supplier: return self.default_supplier if self.supplier_count == 1: return self.supplier_parts.first() # Default to None if there are multiple suppliers to choose from return None default_supplier = models.ForeignKey(SupplierPart, on_delete=models.SET_NULL, blank=True, null=True, help_text='Default supplier part', related_name='default_parts') minimum_stock = models.PositiveIntegerField( default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level') units = models.CharField(max_length=20, default="pcs", blank=True, help_text='Stock keeping units for this part') assembly = models.BooleanField( default=False, verbose_name='Assembly', help_text='Can this part be built from other parts?') component = models.BooleanField( default=True, verbose_name='Component', help_text='Can this part be used to build other parts?') trackable = models.BooleanField( default=False, help_text='Does this part have tracking for unique items?') purchaseable = models.BooleanField( default=True, help_text='Can this part be purchased from external suppliers?') salable = models.BooleanField( default=False, help_text="Can this part be sold to customers?") active = models.BooleanField(default=True, help_text='Is this part active?') virtual = models.BooleanField( default=False, help_text= 'Is this a virtual part, such as a software product or license?') notes = models.TextField(blank=True) bom_checksum = models.CharField(max_length=128, blank=True, help_text='Stored BOM checksum') bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='boms_checked') bom_checked_date = models.DateField(blank=True, null=True) def format_barcode(self): """ Return a JSON string for formatting a barcode for this Part object """ return helpers.MakeBarcode( "Part", self.id, reverse('api-part-detail', kwargs={'pk': self.id}), { 'name': self.name, }) @property def category_path(self): if self.category: return self.category.pathstring return '' @property def available_stock(self): """ Return the total available stock. - This subtracts stock which is already allocated to builds """ total = self.total_stock total -= self.allocation_count return max(total, 0) @property def quantity_to_order(self): """ Return the quantity needing to be ordered for this part. """ required = -1 * self.net_stock return max(required, 0) @property def net_stock(self): """ Return the 'net' stock. It takes into account: - Stock on hand (total_stock) - Stock on order (on_order) - Stock allocated (allocation_count) This number (unlike 'available_stock') can be negative. """ return self.total_stock - self.allocation_count + self.on_order def isStarredBy(self, user): """ Return True if this part has been starred by a particular user """ try: PartStar.objects.get(part=self, user=user) return True except PartStar.DoesNotExist: return False def need_to_restock(self): """ Return True if this part needs to be restocked (either by purchasing or building). If the allocated_stock exceeds the total_stock, then we need to restock. """ return (self.total_stock + self.on_order - self.allocation_count) < self.minimum_stock @property def can_build(self): """ Return the number of units that can be build with available stock """ # If this part does NOT have a BOM, result is simply the currently available stock if not self.has_bom: return 0 total = None # 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'): stock = item.sub_part.available_stock n = int(1.0 * stock / item.quantity) if total is None or n < total: total = n return max(total, 0) @property def active_builds(self): """ Return a list of outstanding builds. Builds marked as 'complete' or 'cancelled' are ignored """ return self.builds.filter(status__in=BuildStatus.ACTIVE_CODES) @property def inactive_builds(self): """ Return a list of inactive builds """ return self.builds.exclude(status__in=BuildStatus.ACTIVE_CODES) @property def quantity_being_built(self): """ Return the current number of parts currently being built """ return sum([b.quantity for b in self.active_builds]) @property def build_allocation(self): """ Return list of builds to which this part is allocated """ builds = [] for item in self.used_in.all().prefetch_related('part__builds'): active = item.part.active_builds for build in active: b = {} b['build'] = build b['quantity'] = item.quantity * build.quantity builds.append(b) prefetch_related_objects(builds, 'build_items') return builds @property def allocated_build_count(self): """ Return the total number of this part that are allocated for builds """ return sum([a['quantity'] for a in self.build_allocation]) @property def allocation_count(self): """ Return true if any of this part is allocated: - To another build - To a customer order """ return sum([ self.allocated_build_count, ]) @property def stock_entries(self): """ Return all 'in stock' items. To be in stock: - customer is None - belongs_to is None """ return self.stock_items.filter(customer=None, belongs_to=None) @property def total_stock(self): """ Return the total stock quantity for this part. Part may be stored in multiple locations """ if self.is_template: total = sum( [variant.total_stock for variant in self.variants.all()]) else: total = self.stock_entries.filter( status__in=StockStatus.AVAILABLE_CODES).aggregate( total=Sum('quantity'))['total'] if total: return total else: return 0 @property def has_bom(self): return self.bom_count > 0 @property def bom_count(self): """ Return the number of items contained in the BOM for this part """ return self.bom_items.count() @property def used_in_count(self): """ Return the number of part BOMs that this part appears in """ return self.used_in.count() def get_bom_hash(self): """ Return a checksum hash for the BOM for this part. Used to determine if the BOM has changed (and needs to be signed off!) The hash is calculated by hashing each line item in the BOM. returns a string representation of a hash object which can be compared with a stored value """ hash = hashlib.md5(str(self.id).encode()) for item in self.bom_items.all().prefetch_related('sub_part'): hash.update(str(item.get_item_hash()).encode()) return str(hash.digest()) @property def is_bom_valid(self): """ Check if the BOM is 'valid' - if the calculated checksum matches the stored value """ return self.get_bom_hash() == self.bom_checksum @transaction.atomic def validate_bom(self, user): """ Validate the BOM (mark the BOM as validated by the given User. - Calculates and stores the hash for the BOM - Saves the current date and the checking user """ # Validate each line item too for item in self.bom_items.all(): item.validate_hash() self.bom_checksum = self.get_bom_hash() self.bom_checked_by = user self.bom_checked_date = datetime.now().date() self.save() @transaction.atomic def clear_bom(self): """ Clear the BOM items for the part (delete all BOM lines). """ self.bom_items.all().delete() def required_parts(self): """ Return a list of parts required to make this part (list of BOM items) """ parts = [] for bom in self.bom_items.all().select_related('sub_part'): parts.append(bom.sub_part) return parts def get_allowed_bom_items(self): """ Return a list of parts which can be added to a BOM for this part. - Exclude parts which are not 'component' parts - Exclude parts which this part is in the BOM for """ parts = Part.objects.filter(component=True).exclude(id=self.id) parts = parts.exclude(id__in=[part.id for part in self.used_in.all()]) return parts @property def supplier_count(self): """ Return the number of supplier parts available for this part """ return self.supplier_parts.count() @property def has_pricing_info(self): """ Return true if there is pricing information for this part """ return self.get_price_range() is not None @property def has_complete_bom_pricing(self): """ Return true if there is pricing information for each item in the BOM. """ for item in self.bom_items.all().select_related('sub_part'): if not item.sub_part.has_pricing_info: return False return True def get_price_info(self, quantity=1, buy=True, bom=True): """ Return a simplified pricing string for this part Args: quantity: Number of units to calculate price for buy: Include supplier pricing (default = True) bom: Include BOM pricing (default = True) """ price_range = self.get_price_range(quantity, buy, bom) if price_range is None: return None min_price, max_price = price_range if min_price == max_price: return min_price return "{a} - {b}".format(a=min_price, b=max_price) def get_supplier_price_range(self, quantity=1): min_price = None max_price = None for supplier in self.supplier_parts.all(): price = supplier.get_price(quantity) if price is None: continue if min_price is None or price < min_price: min_price = price if max_price is None or price > max_price: max_price = price if min_price is None or max_price is None: return None return (min_price, max_price) def get_bom_price_range(self, quantity=1): """ Return the price range of the BOM for this part. Adds the minimum price for all components in the BOM. Note: If the BOM contains items without pricing information, these items cannot be included in the BOM! """ min_price = None max_price = None for item in self.bom_items.all().select_related('sub_part'): prices = item.sub_part.get_price_range(quantity * item.quantity) if prices is None: continue low, high = prices if min_price is None: min_price = 0 if max_price is None: max_price = 0 min_price += low max_price += high if min_price is None or max_price is None: return None return (min_price, max_price) def get_price_range(self, quantity=1, buy=True, bom=True): """ Return the price range for this part. This price can be either: - Supplier price (if purchased from suppliers) - BOM price (if built from other parts) Returns: Minimum of the supplier price or BOM price. If no pricing available, returns None """ buy_price_range = self.get_supplier_price_range( quantity) if buy else None bom_price_range = self.get_bom_price_range(quantity) if bom else None if buy_price_range is None: return bom_price_range elif bom_price_range is None: return buy_price_range else: return (min(buy_price_range[0], bom_price_range[0]), max(buy_price_range[1], bom_price_range[1])) def deepCopy(self, other, **kwargs): """ Duplicates non-field data from another part. Does not alter the normal fields of this part, but can be used to copy other data linked by ForeignKey refernce. Keyword Args: image: If True, copies Part image (default = True) bom: If True, copies BOM data (default = False) """ # Copy the part image if kwargs.get('image', True): if other.image: image_file = ContentFile(other.image.read()) image_file.name = rename_part_image(self, other.image.url) self.image = image_file # Copy the BOM data if kwargs.get('bom', False): for item in other.bom_items.all(): # Point the item to THIS part. # Set the pk to None so a new entry is created. item.part = self item.pk = None item.save() # Copy the fields that aren't available in the duplicate form self.salable = other.salable self.assembly = other.assembly self.component = other.component self.purchaseable = other.purchaseable self.trackable = other.trackable self.virtual = other.virtual self.save() def export_bom(self, **kwargs): """ Export Bill of Materials to a spreadsheet file. Includes a row for each item in the BOM. Also includes extra information such as supplier data. """ items = self.bom_items.all().order_by('id') supplier_names = set() headers = [ 'Part', 'Description', 'Quantity', 'Overage', 'Reference', 'Note', '', 'In Stock', ] # Contstruct list of suppliers for each part for item in items: part = item.sub_part supplier_parts = part.supplier_parts.all() item.suppliers = {} for sp in supplier_parts: name = sp.supplier.name supplier_names.add(name) item.suppliers[name] = sp if len(supplier_names) > 0: headers.append('') for name in supplier_names: headers.append(name) data = tablib.Dataset(headers=headers) for it in items: line = [] # Information about each BOM item line.append(it.sub_part.full_name) line.append(it.sub_part.description) line.append(it.quantity) line.append(it.overage) line.append(it.reference) line.append(it.note) # Extra information about the part line.append('') line.append(it.sub_part.available_stock) if len(supplier_names) > 0: line.append('') # Blank column separates supplier info for name in supplier_names: sp = it.suppliers.get(name, None) if sp: line.append(sp.SKU) else: line.append('') data.append(line) file_format = kwargs.get('format', 'csv').lower() return data.export(file_format) @property def attachment_count(self): """ Count the number of attachments for this part. If the part is a variant of a template part, include the number of attachments for the template part. """ n = self.attachments.count() if self.variant_of: n += self.variant_of.attachments.count() return n def purchase_orders(self): """ Return a list of purchase orders which reference this part """ orders = [] for part in self.supplier_parts.all().prefetch_related( 'purchase_order_line_items'): for order in part.purchase_orders(): if order not in orders: orders.append(order) return orders def open_purchase_orders(self): """ Return a list of open purchase orders against this part """ return [ order for order in self.purchase_orders() if order.status in OrderStatus.OPEN ] def closed_purchase_orders(self): """ Return a list of closed purchase orders against this part """ return [ order for order in self.purchase_orders() if order.status not in OrderStatus.OPEN ] @property def on_order(self): """ Return the total number of items on order for this part. """ return sum([ part.on_order() for part in self.supplier_parts.all().prefetch_related( 'purchase_order_line_items') ]) def get_parameters(self): """ Return all parameters for this part, ordered by name """ return self.parameters.order_by('template__name')
class ManufacturerPart(models.Model): """ Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers Attributes: part: Link to the master Part manufacturer: Company that manufactures the ManufacturerPart MPN: Manufacture part number link: Link to external website for this manufacturer part description: Descriptive notes field """ @staticmethod def get_api_url(): return reverse('api-manufacturer-part-list') class Meta: unique_together = ('part', 'manufacturer', 'MPN') part = models.ForeignKey( 'part.Part', on_delete=models.CASCADE, related_name='manufacturer_parts', verbose_name=_('Base Part'), limit_choices_to={ 'purchaseable': True, }, help_text=_('Select part'), ) manufacturer = models.ForeignKey( Company, on_delete=models.CASCADE, null=True, related_name='manufactured_parts', limit_choices_to={'is_manufacturer': True}, verbose_name=_('Manufacturer'), help_text=_('Select manufacturer'), ) MPN = models.CharField(null=True, max_length=100, verbose_name=_('MPN'), help_text=_('Manufacturer Part Number')) link = InvenTreeURLField( blank=True, null=True, verbose_name=_('Link'), help_text=_('URL for external manufacturer part link')) description = models.CharField( max_length=250, blank=True, null=True, verbose_name=_('Description'), help_text=_('Manufacturer part description')) @classmethod def create(cls, part, manufacturer, mpn, description, link=None): """ Check if ManufacturerPart instance does not already exist then create it """ manufacturer_part = None try: manufacturer_part = ManufacturerPart.objects.get( part=part, manufacturer=manufacturer, MPN=mpn) except ManufacturerPart.DoesNotExist: pass if not manufacturer_part: manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=mpn, description=description, link=link) manufacturer_part.save() return manufacturer_part def __str__(self): s = '' if self.manufacturer: s += f'{self.manufacturer.name}' s += ' | ' s += f'{self.MPN}' return s
class StockItem(MPTTModel): """ A StockItem object represents a quantity of physical instances of a part. Attributes: parent: Link to another StockItem from which this StockItem was created uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode) 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 serial: Unique serial number for this StockItem link: 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: InvenTree.status_codes.StockStatus) notes: Extra notes field build: Link to a Build (if this stock item was created from a build) purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) infinite: If True this StockItem can never be exhausted sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) 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, customer=None, status__in=StockStatus.AVAILABLE_CODES) def save(self, *args, **kwargs): """ Save this StockItem to the database. Performs a number of checks: - Unique serial number requirement - Adds a transaction note when the item is first created. """ self.validate_unique() self.clean() if not self.pk: # StockItem has not yet been saved add_note = True else: # StockItem has already been saved add_note = False user = kwargs.pop('user', None) add_note = add_note and kwargs.pop('note', True) super(StockItem, self).save(*args, **kwargs) if add_note: # This StockItem is being saved for the first time self.addTransactionNote( 'Created stock item', user, notes="Created new stock item for part '{p}'".format( p=str(self.part)), system=True) @property def status_label(self): return StockStatus.label(self.status) @property def serialized(self): """ Return True if this StockItem is serialized """ return self.serial is not None and self.quantity == 1 def validate_unique(self, exclude=None): """ Test that this StockItem is "unique". If the StockItem is serialized, the same serial number. cannot exist for the same part (or part tree). """ super(StockItem, self).validate_unique(exclude) if self.serial is not None: # Query to look for duplicate serial numbers parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id) stock = StockItem.objects.filter(part__in=parts, serial=self.serial) # Exclude myself from the search if self.pk is not None: stock = stock.exclude(pk=self.pk) if stock.exists(): raise ValidationError({ "serial": _("StockItem with this serial number already exists") }) def clean(self): """ Validate the StockItem object (separate to field validation) The following validation checks are performed: - The 'part' and 'supplier_part.part' fields cannot point to the same Part object - The 'part' does not belong to itself - Quantity must be 1 if the StockItem has a serial number """ super().clean() try: if self.part.trackable: # Trackable parts must have integer values for quantity field! if not self.quantity == int(self.quantity): raise ValidationError({ 'quantity': _('Quantity must be integer value for trackable parts') }) except PartModels.Part.DoesNotExist: # For some reason the 'clean' process sometimes throws errors because self.part does not exist # It *seems* that this only occurs in unit testing, though. # Probably should investigate this at some point. pass if self.quantity < 0: raise ValidationError( {'quantity': _('Quantity must be greater than zero')}) # The 'supplier_part' field must point to the same part! try: if self.supplier_part is not None: if not self.supplier_part.part == self.part: raise ValidationError({ 'supplier_part': _("Part type ('{pf}') must be {pe}").format( pf=str(self.supplier_part.part), pe=str(self.part)) }) if self.part is not None: # A part with a serial number MUST have the quantity set to 1 if self.serial is not None: if self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number' ), 'serial': _('Serial number cannot be set if quantity greater than 1' ) }) if self.quantity == 0: self.quantity = 1 elif self.quantity > 1: raise ValidationError({ 'quantity': _('Quantity must be 1 for item with a serial number' ) }) # Serial numbered items cannot be deleted on depletion self.delete_on_deplete = False except PartModels.Part.DoesNotExist: # This gets thrown if self.supplier_part is null # TODO - Find a test than can be perfomed... pass if self.belongs_to and self.belongs_to.pk == self.pk: raise ValidationError( {'belongs_to': _('Item cannot belong to itself')}) def get_absolute_url(self): return reverse('stock-item-detail', kwargs={'pk': self.id}) def get_part_name(self): return self.part.full_name def format_barcode(self, **kwargs): """ Return a JSON string for formatting a barcode for this StockItem. Can be used to perform lookup of a stockitem using barcode Contains the following data: { type: 'StockItem', stock_id: <pk>, part_id: <part_pk> } Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change) """ return helpers.MakeBarcode( "stockitem", self.id, { "url": reverse('api-stock-detail', kwargs={'pk': self.id}), }, **kwargs) uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field")) 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, verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), limit_choices_to={ 'active': True, 'virtual': False }) 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, 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', 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( CompanyModels.Company, on_delete=models.SET_NULL, null=True, blank=True, limit_choices_to={'is_customer': True}, related_name='assigned_stock', help_text=_("Customer"), verbose_name=_("Customer"), ) serial = models.PositiveIntegerField( verbose_name=_('Serial Number'), blank=True, null=True, help_text=_('Serial number for this item')) link = InvenTreeURLField(verbose_name=_('External Link'), max_length=125, blank=True, help_text=_("Link to external URL")) 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(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', ) purchase_order = models.ForeignKey( 'order.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')) sales_order = models.ForeignKey('order.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) # last time the stock was checked / counted stocktake_date = models.DateField(blank=True, null=True) stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='stocktake_stock') review_needed = models.BooleanField(default=False) delete_on_deplete = models.BooleanField( default=True, help_text=_('Delete this Stock Item when stock is depleted')) status = models.PositiveIntegerField(default=StockStatus.OK, choices=StockStatus.items(), validators=[MinValueValidator(0)]) notes = MarkdownxField(blank=True, null=True, verbose_name=_("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.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 def returnFromCustomer(self, location, user=None): """ Return stock item from customer, back into the specified location. """ self.addTransactionNote( _("Returned from customer") + " " + self.customer.name, user, notes=_("Returned to location") + " " + location.name, system=True) self.customer = None self.location = location self.save() # If stock item is incoming, an (optional) ETA field # expected_arrival = models.DateField(null=True, blank=True) infinite = models.BooleanField(default=False) def is_allocated(self): """ Return True if this StockItem is allocated to a SalesOrder or a Build """ # TODO - For now this only checks if the StockItem is allocated to a SalesOrder # TODO - In future, once the "build" is working better, check this too if self.allocations.count() > 0: return True if self.sales_order_allocations.count() > 0: return True return False def build_allocation_count(self): """ Return the total quantity allocated to builds """ query = self.allocations.aggregate( q=Coalesce(Sum('quantity'), Decimal(0))) return query['q'] def sales_order_allocation_count(self): """ Return the total quantity allocated to SalesOrders """ query = self.sales_order_allocations.aggregate( q=Coalesce(Sum('quantity'), Decimal(0))) return query['q'] def allocation_count(self): """ Return the total quantity allocated to builds or orders """ return self.build_allocation_count( ) + self.sales_order_allocation_count() def unallocated_quantity(self): """ Return the quantity of this StockItem which is *not* allocated """ return max(self.quantity - self.allocation_count(), 0) def can_delete(self): """ Can this stock item be deleted? It can NOT be deleted under the following circumstances: - Has child StockItems - Has a serial number and is tracked - Is installed inside another StockItem - It has been assigned to a SalesOrder - It has been assigned to a BuildOrder """ if self.child_count > 0: return False if self.part.trackable and self.serial is not None: return False if self.sales_order is not None: return False if self.build_order is not None: return False return True @property def children(self): """ Return a list of the child items which have been split from this stock item """ return self.get_descendants(include_self=False) @property def child_count(self): """ Return the number of 'child' items associated with this StockItem. A child item is one which has been split from this one. """ return self.children.count() @property def in_stock(self): # Not 'in stock' if it has been installed inside another StockItem if self.belongs_to is not None: return False # Not 'in stock' if it has been sent to a customer if self.sales_order is not None: return False # Not 'in stock' if it has been allocated to a BuildOrder if self.build_order is not None: return False # Not 'in stock' if it has been assigned to a customer if self.customer is not None: return False # Not 'in stock' if the status code makes it unavailable if self.status in StockStatus.UNAVAILABLE_CODES: return False return True @property def tracking_info_count(self): return self.tracking_info.count() @property def has_tracking_info(self): return self.tracking_info_count > 0 def addTransactionNote(self, title, user, notes='', url='', system=True): """ Generation a stock transaction note for this item. Brief automated note detailing a movement or quantity change. """ track = StockItemTracking.objects.create(item=self, title=title, user=user, quantity=self.quantity, date=datetime.now().date(), notes=notes, link=url, system=system) track.save() @transaction.atomic def serializeStock(self, quantity, serials, user, notes='', location=None): """ Split this stock item into unique serial numbers. - Quantity can be less than or equal to the quantity of the stock item - Number of serial numbers must match the quantity - Provided serial numbers must not already be in use Args: quantity: Number of items to serialize (integer) serials: List of serial numbers (list<int>) user: User object associated with action notes: Optional notes for tracking location: If specified, serialized items will be placed in the given location """ # Cannot serialize stock that is already serialized! if self.serialized: return if not self.part.trackable: raise ValidationError({"part": _("Part is not set as trackable")}) # Quantity must be a valid integer value try: quantity = int(quantity) except ValueError: raise ValidationError({"quantity": _("Quantity must be integer")}) if quantity <= 0: raise ValidationError( {"quantity": _("Quantity must be greater than zero")}) if quantity > self.quantity: raise ValidationError({ "quantity": _("Quantity must not exceed available stock quantity ({n})". format(n=self.quantity)) }) if not type(serials) in [list, tuple]: raise ValidationError({ "serial_numbers": _("Serial numbers must be a list of integers") }) if any([type(i) is not int for i in serials]): raise ValidationError({ "serial_numbers": _("Serial numbers must be a list of integers") }) if not quantity == len(serials): raise ValidationError( {"quantity": _("Quantity does not match serial numbers")}) # Test if each of the serial numbers are valid existing = [] for serial in serials: if self.part.checkIfSerialNumberExists(serial): existing.append(serial) if len(existing) > 0: raise ValidationError({ "serial_numbers": _("Serial numbers already exist: ") + str(existing) }) # Create a new stock item for each unique serial number for serial in serials: # Create a copy of this StockItem new_item = StockItem.objects.get(pk=self.pk) new_item.quantity = 1 new_item.serial = serial new_item.pk = None new_item.parent = self if location: new_item.location = location # The item already has a transaction history, don't create a new note new_item.save(user=user, note=False) # Copy entire transaction history new_item.copyHistoryFrom(self) # Copy test result history new_item.copyTestResultsFrom(self) # Create a new stock tracking item new_item.addTransactionNote(_('Add serial number'), user, notes=notes) # Remove the equivalent number of items self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity))) @transaction.atomic def copyHistoryFrom(self, other): """ Copy stock history from another StockItem """ for item in other.tracking_info.all(): item.item = self item.pk = None item.save() @transaction.atomic def copyTestResultsFrom(self, other, filters={}): """ Copy all test results from another StockItem """ for result in other.test_results.all().filter(**filters): # Create a copy of the test result by nulling-out the pk result.pk = None result.stock_item = self result.save() @transaction.atomic def splitStock(self, quantity, location, user): """ Split this stock item into two items, in the same location. Stock tracking notes for this StockItem will be duplicated, and added to the new StockItem. Args: quantity: Number of stock items to remove from this entity, and pass to the next location: Where to move the new StockItem to Notes: The provided quantity will be subtracted from this item and given to the new one. The new item will have a different StockItem ID, while this will remain the same. """ # Do not split a serialized part if self.serialized: return try: quantity = Decimal(quantity) except (InvalidOperation, ValueError): return # Doesn't make sense for a zero quantity if quantity <= 0: return # Also doesn't make sense to split the full amount if quantity >= self.quantity: return # Create a new StockItem object, duplicating relevant fields # Nullify the PK so a new record is created new_stock = StockItem.objects.get(pk=self.pk) new_stock.pk = None new_stock.parent = self new_stock.quantity = quantity # Move to the new location if specified, otherwise use current location if location: new_stock.location = location else: new_stock.location = self.location new_stock.save() # Copy the transaction history of this part into the new one new_stock.copyHistoryFrom(self) # Copy the test results of this part to the new one new_stock.copyTestResultsFrom(self) # Add a new tracking item for the new stock item new_stock.addTransactionNote( "Split from existing stock", user, "Split {n} from existing stock item".format(n=quantity)) # Remove the specified quantity from THIS stock item self.take_stock( quantity, user, 'Split {n} items into new stock item'.format(n=quantity)) # Return a copy of the "new" stock item return new_stock @transaction.atomic def move(self, location, notes, user, **kwargs): """ Move part to a new location. If less than the available quantity is to be moved, a new StockItem is created, with the defined quantity, and that new StockItem is moved. The quantity is also subtracted from the existing StockItem. Args: location: Destination location (cannot be null) notes: User notes user: Who is performing the move kwargs: quantity: If provided, override the quantity (default = total stock quantity) """ try: quantity = Decimal(kwargs.get('quantity', self.quantity)) except InvalidOperation: return False if not self.in_stock: raise ValidationError( _("StockItem cannot be moved as it is not in stock")) if quantity <= 0: return False if location is None: # TODO - Raise appropriate error (cannot move to blank location) return False elif self.location and (location.pk == self.location.pk) and (quantity == self.quantity): # TODO - Raise appropriate error (cannot move to same location) return False # Test for a partial movement if quantity < self.quantity: # We need to split the stock! # Split the existing StockItem in two self.splitStock(quantity, location, user) return True msg = "Moved to {loc}".format(loc=str(location)) if self.location: msg += " (from {loc})".format(loc=str(self.location)) self.location = location self.addTransactionNote(msg, user, notes=notes, system=True) self.save() return True @transaction.atomic def updateQuantity(self, quantity): """ Update stock quantity for this item. If the quantity has reached zero, this StockItem will be deleted. Returns: - True if the quantity was saved - False if the StockItem was deleted """ # Do not adjust quantity of a serialized part if self.serialized: return try: self.quantity = Decimal(quantity) except (InvalidOperation, ValueError): return if quantity < 0: quantity = 0 self.quantity = quantity if quantity == 0 and self.delete_on_deplete and self.can_delete(): # TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag self.delete() return False else: self.save() return True @transaction.atomic def stocktake(self, count, user, notes=''): """ Perform item stocktake. When the quantity of an item is counted, record the date of stocktake """ try: count = Decimal(count) except InvalidOperation: return False if count < 0 or self.infinite: return False self.stocktake_date = datetime.now().date() self.stocktake_user = user if self.updateQuantity(count): self.addTransactionNote( 'Stocktake - counted {n} items'.format(n=count), user, notes=notes, system=True) return True @transaction.atomic def add_stock(self, quantity, user, notes=''): """ Add items to stock This function can be called by initiating a ProjectRun, or by manually adding the items to the stock location """ # Cannot add items to a serialized part if self.serialized: return False try: quantity = Decimal(quantity) except InvalidOperation: return False # Ignore amounts that do not make sense if quantity <= 0 or self.infinite: return False if self.updateQuantity(self.quantity + quantity): self.addTransactionNote( 'Added {n} items to stock'.format(n=quantity), user, notes=notes, system=True) return True @transaction.atomic def take_stock(self, quantity, user, notes=''): """ Remove items from stock """ # Cannot remove items from a serialized part if self.serialized: return False try: quantity = Decimal(quantity) except InvalidOperation: return False if quantity <= 0 or self.infinite: return False if self.updateQuantity(self.quantity - quantity): self.addTransactionNote( 'Removed {n} items from stock'.format(n=quantity), user, notes=notes, system=True) return True def __str__(self): if self.part.trackable and self.serial: s = '{part} #{sn}'.format(part=self.part.full_name, sn=self.serial) else: s = '{n} x {part}'.format(n=helpers.decimal2string(self.quantity), part=self.part.full_name) if self.location: s += ' @ {loc}'.format(loc=self.location.name) return s def getTestResults(self, test=None, result=None, user=None): """ Return all test results associated with this StockItem. Optionally can filter results by: - Test name - Test result - User """ results = self.test_results if test: # Filter by test name results = results.filter(test=test) if result is not None: # Filter by test status results = results.filter(result=result) if user: # Filter by user results = results.filter(user=user) return results def testResultMap(self, **kwargs): """ Return a map of test-results using the test name as the key. Where multiple test results exist for a given name, the *most recent* test is used. This map is useful for rendering to a template (e.g. a test report), as all named tests are accessible. """ results = self.getTestResults(**kwargs).order_by('-date') result_map = {} for result in results: key = helpers.generateTestKey(result.test) result_map[key] = result return result_map def testResultList(self, **kwargs): """ Return a list of test-result objects for this StockItem """ return self.testResultMap(**kwargs).values() def requiredTestStatus(self): """ Return the status of the tests required for this StockItem. return: A dict containing the following items: - total: Number of required tests - passed: Number of tests that have passed - failed: Number of tests that have failed """ # All the tests required by the part object required = self.part.getRequiredTests() results = self.testResultMap() total = len(required) passed = 0 failed = 0 for test in required: key = helpers.generateTestKey(test.test_name) if key in results: result = results[key] if result.result: passed += 1 else: failed += 1 return { 'total': total, 'passed': passed, 'failed': failed, } @property def required_test_count(self): return self.part.getRequiredTests().count() def hasRequiredTests(self): return self.part.getRequiredTests().count() > 0 def passedAllRequiredTests(self): status = self.requiredTestStatus() return status['passed'] >= status['total']
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, help_text='Company name') description = models.CharField(max_length=500, help_text='Description of the company') website = models.URLField(blank=True, help_text='Company website URL') address = models.CharField(max_length=200, blank=True, help_text='Company address') phone = models.CharField(max_length=50, blank=True, help_text='Contact phone number') email = models.EmailField(blank=True, help_text='Contact email address') contact = models.CharField(max_length=100, blank=True, help_text='Point of contact') URL = InvenTreeURLField(blank=True, help_text='Link to external company information') image = models.ImageField(upload_to=rename_company_image, max_length=255, null=True, blank=True) notes = models.TextField(blank=True) is_customer = models.BooleanField( default=False, help_text='Do you sell items to this company?') is_supplier = models.BooleanField( default=True, help_text='Do you purchase items from this company?') def __str__(self): """ Get string representation of a Company """ return "{n} - {d}".format(n=self.name, d=self.description) def get_absolute_url(self): """ Get the web URL for the detail view for this Company """ return reverse('company-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this company """ if self.image: return os.path.join(settings.MEDIA_URL, str(self.image.url)) else: return os.path.join(settings.STATIC_URL, 'img/blank_image.png') @property def part_count(self): """ The number of parts supplied by this company """ return self.parts.count() @property def has_parts(self): """ Return True if this company supplies any parts """ return self.part_count > 0 @property def stock_items(self): """ Return a list of all stock items supplied by this company """ stock = apps.get_model('stock', 'StockItem') return stock.objects.filter(supplier_part__supplier=self.id).all() @property def stock_count(self): """ Return the number of stock items supplied by this company """ stock = apps.get_model('stock', 'StockItem') return stock.objects.filter(supplier_part__supplier=self.id).count() def outstanding_purchase_orders(self): """ Return purchase orders which are 'outstanding' """ return self.purchase_orders.filter(status__in=OrderStatus.OPEN) def pending_purchase_orders(self): """ Return purchase orders which are PENDING (not yet issued) """ return self.purchase_orders.filter(status=OrderStatus.PENDING) def closed_purchase_orders(self): """ Return purchase orders which are not 'outstanding' - Complete - Failed / lost - Returned """ return self.purchase_orders.exclude(status__in=OrderStatus.OPEN) def complete_purchase_orders(self): return self.purchase_orders.filter(status=OrderStatus.COMPLETE) def failed_purchase_orders(self): """ Return any purchase orders which were not successful """ return self.purchase_orders.filter(status__in=OrderStatus.FAILED)
class Company(models.Model): """A Company object represents an external company. It may be a supplier or a customer or a manufacturer (or a combination) - A supplier is a company from which parts can be purchased - A customer is a company to which parts can be sold - A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also) 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 link: 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 is_manufacturer: boolean value, is this company a manufacturer currency_code: Specifies the default currency for the company """ @staticmethod def get_api_url(): """Return the API URL associated with the Company model""" return reverse('api-company-list') class Meta: """Metaclass defines extra model options""" ordering = [ 'name', ] constraints = [ UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair') ] verbose_name_plural = "Companies" name = models.CharField(max_length=100, blank=False, help_text=_('Company name'), verbose_name=_('Company name')) description = models.CharField( max_length=500, verbose_name=_('Company description'), help_text=_('Description of the company'), blank=True, ) website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL')) address = models.CharField(max_length=200, verbose_name=_('Address'), blank=True, help_text=_('Company address')) phone = models.CharField(max_length=50, verbose_name=_('Phone number'), blank=True, help_text=_('Contact phone number')) email = models.EmailField(blank=True, null=True, verbose_name=_('Email'), help_text=_('Contact email address')) contact = models.CharField(max_length=100, verbose_name=_('Contact'), blank=True, help_text=_('Point of contact')) link = InvenTreeURLField( blank=True, verbose_name=_('Link'), help_text=_('Link to external company information')) image = StdImageField( upload_to=rename_company_image, null=True, blank=True, variations={ 'thumbnail': (128, 128), 'preview': (256, 256), }, delete_orphans=True, verbose_name=_('Image'), ) notes = InvenTree.fields.InvenTreeNotesField(help_text=_("Company Notes")) is_customer = models.BooleanField( default=False, verbose_name=_('is customer'), help_text=_('Do you sell items to this company?')) is_supplier = models.BooleanField( default=True, verbose_name=_('is supplier'), help_text=_('Do you purchase items from this company?')) is_manufacturer = models.BooleanField( default=False, verbose_name=_('is manufacturer'), help_text=_('Does this company manufacture parts?')) currency = models.CharField( max_length=3, verbose_name=_('Currency'), blank=True, default=currency_code_default, help_text=_('Default currency used for this company'), validators=[InvenTree.validators.validate_currency_code], ) @property def currency_code(self): """Return the currency code associated with this company. - If the currency code is invalid, use the default currency - If the currency code is not specified, use the default currency """ code = self.currency if code not in CURRENCIES: code = common.settings.currency_code_default() return code def __str__(self): """Get string representation of a Company.""" return "{n} - {d}".format(n=self.name, d=self.description) def get_absolute_url(self): """Get the web URL for the detail view for this Company.""" return reverse('company-detail', kwargs={'pk': self.id}) def get_image_url(self): """Return the URL of the image for this company.""" if self.image: return InvenTree.helpers.getMediaUrl(self.image.url) else: return InvenTree.helpers.getBlankImage() def get_thumbnail_url(self): """Return the URL for the thumbnail image for this Company.""" if self.image: return InvenTree.helpers.getMediaUrl(self.image.thumbnail.url) else: return InvenTree.helpers.getBlankThumbnail() @property def parts(self): """Return SupplierPart objects which are supplied or manufactured by this company.""" return SupplierPart.objects.filter( Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id)) @property def stock_items(self): """Return a list of all stock items supplied or manufactured by this company.""" stock = apps.get_model('stock', 'StockItem') return stock.objects.filter( Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer_part__manufacturer=self.id)).all()
class Build(models.Model): """ A Build object organises the creation of new parts from the component parts. Attributes: 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) completion_date: Date the build was completed URL: External URL for extra information notes: Text notes """ def __str__(self): return "Build {q} x {part}".format(q=self.quantity, part=str(self.part)) 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={ 'is_template': False, 'assembly': True, 'active': True }, help_text=_('Select part to 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, validators=[MinValueValidator(1)], help_text=_('Number of parts to build') ) status = models.PositiveIntegerField(default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status')) batch = models.CharField(max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) creation_date = models.DateField(auto_now=True, editable=False) completion_date = models.DateField(null=True, blank=True) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed' ) URL = InvenTreeURLField(blank=True, help_text=_('Link to external URL')) notes = models.TextField(blank=True, help_text=_('Extra build notes')) @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() def getAutoAllocations(self): """ Return a list of parts which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part: - If there is a single StockItem, use that StockItem - Take as many parts as available (up to the quantity required for the BOM) - If there are multiple StockItems available, ignore (leave up to the user) Returns: A list object containing the StockItem objects to be allocated (and the quantities) """ allocations = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): # How many parts required for this build? q_required = item.quantity * self.quantity 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] # Check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item) if len(build_items) > 0: continue # Are there any parts available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < q_required: q_required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': q_required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self): """ Deletes all stock allocations for this build. """ BuildItem.objects.filter(build=self.id).delete() @transaction.atomic def autoAllocate(self): """ Run auto-allocation routine to allocate StockItems to this Build. Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations() for item in allocations: # Create a new allocation build_item = BuildItem( build=self, stock_item=item['stock_item'], quantity=item['quantity']) build_item.save() @transaction.atomic def completeBuild(self, location, serial_numbers, user): """ Mark the Build as COMPLETE - Takes allocated items from stock - Delete pending BuildItem objects """ for item in self.allocated_stock.all().prefetch_related('stock_item'): # Subtract stock from the item item.stock_item.take_stock( item.quantity, user, 'Removed {n} items to build {m} x {part}'.format( n=item.quantity, m=self.quantity, part=self.part.full_name ) ) # Delete the item item.delete() # Mark the date of completion self.completion_date = datetime.now().date() self.completed_by = user notes = 'Built {q} on {now}'.format( q=self.quantity, now=str(datetime.now().date()) ) if self.part.trackable: # Add new serial numbers for serial in serial_numbers: item = StockItem.objects.create( part=self.part, build=self, location=location, quantity=1, serial=serial, batch=str(self.batch) if self.batch else '', notes=notes ) item.save() else: # Add stock of the newly created item item = StockItem.objects.create( part=self.part, build=self, location=location, quantity=self.quantity, batch=str(self.batch) if self.batch else '', notes=notes ) item.save() # Finally, mark the build as complete self.status = BuildStatus.COMPLETE self.save() def getRequiredQuantity(self, part): """ Calculate the quantity of <part> required to make this build. """ try: item = BomItem.objects.get(part=self.part.id, sub_part=part.id) return item.get_required_quantity(self.quantity) except BomItem.DoesNotExist: return 0 def getAllocatedQuantity(self, part): """ Calculate the total number of <part> currently allocated to this build """ allocated = BuildItem.objects.filter(build=self.id, stock_item__part=part.id).aggregate(Sum('quantity')) q = allocated['quantity__sum'] if q: return int(q) else: return 0 def getUnallocatedQuantity(self, part): """ Calculate the quantity of <part> which still needs to be allocated to this build. Args: Part - the part to be tested Returns: The remaining allocated quantity """ return max(self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0) @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): part = {'part': item.sub_part, 'per_build': item.quantity, 'quantity': item.quantity * self.quantity, 'allocated': self.getAllocatedQuantity(item.sub_part) } parts.append(part) return parts @property def can_build(self): """ Return true if there are enough parts to supply build """ for item in self.required_parts: if item['part'].total_stock < item['quantity']: return False return True @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE
class InvenTreeAttachment(models.Model): """Provides an abstracted class for managing file attachments. An attachment can be either an uploaded file, or an external URL Attributes: attachment: File comment: String descriptor for the attachment user: User associated with file upload upload_date: Date the file was uploaded """ def getSubdir(self): """Return the subdirectory under which attachments should be stored. Note: Re-implement this for each subclass of InvenTreeAttachment """ return "attachments" def save(self, *args, **kwargs): """Provide better validation error.""" # Either 'attachment' or 'link' must be specified! if not self.attachment and not self.link: raise ValidationError({ 'attachment': _('Missing file'), 'link': _('Missing external link'), }) super().save(*args, **kwargs) def __str__(self): """Human name for attachment.""" if self.attachment is not None: return os.path.basename(self.attachment.name) else: return str(self.link) attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'), help_text=_('Select file to attach'), blank=True, null=True ) link = InvenTreeURLField( blank=True, null=True, verbose_name=_('Link'), help_text=_('Link to external URL') ) comment = models.CharField(blank=True, max_length=100, verbose_name=_('Comment'), help_text=_('File comment')) user = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('User'), help_text=_('User'), ) upload_date = models.DateField(auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')) @property def basename(self): """Base name/path for attachment.""" if self.attachment: return os.path.basename(self.attachment.name) else: return None @basename.setter def basename(self, fn): """Function to rename the attachment file. - Filename cannot be empty - Filename cannot contain illegal characters - Filename must specify an extension - Filename cannot match an existing file """ fn = fn.strip() if len(fn) == 0: raise ValidationError(_('Filename must not be empty')) attachment_dir = os.path.join( settings.MEDIA_ROOT, self.getSubdir() ) old_file = os.path.join( settings.MEDIA_ROOT, self.attachment.name ) new_file = os.path.join( settings.MEDIA_ROOT, self.getSubdir(), fn ) new_file = os.path.abspath(new_file) # Check that there are no directory tricks going on... if os.path.dirname(new_file) != attachment_dir: logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'") raise ValidationError(_("Invalid attachment directory")) # Ignore further checks if the filename is not actually being renamed if new_file == old_file: return forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"] for c in forbidden: if c in fn: raise ValidationError(_(f"Filename contains illegal character '{c}'")) if len(fn.split('.')) < 2: raise ValidationError(_("Filename missing extension")) if not os.path.exists(old_file): logger.error(f"Trying to rename attachment '{old_file}' which does not exist") return if os.path.exists(new_file): raise ValidationError(_("Attachment with this filename already exists")) try: os.rename(old_file, new_file) self.attachment.name = os.path.join(self.getSubdir(), fn) self.save() except Exception: raise ValidationError(_("Error renaming file")) class Meta: """Metaclass options. Abstract ensures no database table is created.""" abstract = True