def validate(self, build, form, **kwargs): """ Custom validation steps for the BuildOutputComplete" form """ data = form.cleaned_data output = data.get('output', None) stock_status = data.get('stock_status', StockStatus.OK) # Any "invalid" stock status defaults to OK try: stock_status = int(stock_status) except (ValueError): stock_status = StockStatus.OK if int(stock_status) not in StockStatus.keys(): form.add_error('stock_status', _('Invalid stock status value selected')) if output: quantity = data.get('quantity', None) if quantity and quantity > output.quantity: form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity')) if not build.isFullyAllocated(output): confirm = str2bool(data.get('confirm_incomplete', False)) if not confirm: form.add_error('confirm_incomplete', _('Confirm completion of incomplete build')) else: form.add_error(None, _('Build output must be specified'))
def load_status_codes(context): """ Make the various StatusCodes available to the page context """ context['order_status_codes'] = OrderStatus.list() context['stock_status_codes'] = StockStatus.list() context['build_status_codes'] = BuildStatus.list() # Need to return something as the result is rendered to the page return ''
def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) # Instead of using the DRF serializer to LIST, # we will serialize the objects manually. # This is significantly faster data = queryset.values( 'pk', 'quantity', 'serial', 'batch', 'status', 'notes', 'location', 'location__name', 'location__description', 'part', 'part__IPN', 'part__name', 'part__revision', 'part__description', 'part__image', 'part__category', 'part__category__name', 'part__category__description', ) # Reduce the number of lookups we need to do for categories # Cache location lookups for this query locations = {} for item in data: item['part__image'] = os.path.join(settings.MEDIA_URL, item['part__image']) loc_id = item['location'] if loc_id: if loc_id not in locations: locations[loc_id] = StockLocation.objects.get( pk=loc_id).pathstring item['location__path'] = locations[loc_id] else: item['location__path'] = None item['status_text'] = StockStatus.label(item['status']) return Response(data)
class CompleteBuildOutputForm(HelperForm): """ Form for completing a single build output """ field_prefix = { 'serial_numbers': 'fa-hashtag', } field_placeholder = {} location = forms.ModelChoiceField( queryset=StockLocation.objects.all(), label=_('Location'), help_text=_('Location of completed parts'), ) stock_status = forms.ChoiceField( label=_('Status'), help_text=_('Build output stock status'), initial=StockStatus.OK, choices=StockStatus.items(), ) confirm_incomplete = forms.BooleanField( required=False, label=_('Confirm incomplete'), help_text=_("Confirm completion with incomplete stock allocation")) confirm = forms.BooleanField(required=True, label=_('Confirm'), help_text=_('Confirm build completion')) output = forms.ModelChoiceField( queryset=StockItem.objects.all(), # Queryset is narrowed in the view widget=forms.HiddenInput(), ) class Meta: model = Build fields = [ 'location', 'output', 'stock_status', 'confirm', 'confirm_incomplete', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)
class BuildCompleteSerializer(serializers.Serializer): """ DRF serializer for completing one or more build outputs """ class Meta: fields = [ 'outputs', 'location', 'status', 'notes', ] outputs = BuildOutputSerializer( many=True, required=True, ) location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), required=True, many=False, label=_("Location"), help_text=_("Location for completed build outputs"), ) status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, label=_("Status"), ) notes = serializers.CharField( label=_("Notes"), required=False, allow_blank=True, ) def validate(self, data): super().validate(data) outputs = data.get('outputs', []) if len(outputs) == 0: raise ValidationError( _("A list of build outputs must be provided")) return data def save(self): """ "save" the serializer to complete the build outputs """ build = self.context['build'] request = self.context['request'] data = self.validated_data outputs = data.get('outputs', []) # Mark the specified build outputs as "complete" with transaction.atomic(): for item in outputs: output = item['output'] build.complete_build_output(output, request.user, status=data['status'], notes=data.get('notes', ''))
def stock_status_text(key, *args, **kwargs): return mark_safe(StockStatus.text(key))
def stock_status_label(key, *args, **kwargs): """ Render a StockItem status label """ return mark_safe(StockStatus.render(key, large=kwargs.get('large', False)))
class StockItem(models.Model): """ A StockItem object represents a quantity of physical instances of a part. Attributes: part: Link to the master abstract part that this StockItem is an instance of supplier_part: Link to a specific SupplierPart (optional) location: Where this StockItem is located quantity: Number of stocked units batch: Batch number for this StockItem 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 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 super(StockItem, self).save(*args, **kwargs) if add_note: # This StockItem is being saved for the first time self.addTransactionNote( 'Created stock item', None, notes="Created new stock item for part '{p}'".format( p=str(self.part)), system=True) @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 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 part with this serial number already exists for template part {part}' .format(part=self.part.variant_of)) }) else: if StockItem.objects.filter(serial=self.serial).exclude( id=self.id).exists(): raise ValidationError({ 'serial': _('A part 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 trackable part must have a serial number if self.part.trackable: if not self.serial: raise ValidationError({ 'serial': _('Serial number must be set for trackable items') }) if self.delete_on_deplete: raise ValidationError({ 'delete_on_deplete': _("Must be set to False for trackable items") }) # Serial number cannot be set for items with quantity greater than 1 if not self.quantity == 1: raise ValidationError({ 'quantity': _("Quantity must be set to 1 for item with a serial number" ), 'serial': _("Serial number cannot be set if quantity > 1") }) # 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 }) 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 = models.ForeignKey(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 = models.URLField(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.PositiveIntegerField(validators=[MinValueValidator(0)], default=1) updated = models.DateField(auto_now=True, null=True) 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 = models.CharField(max_length=250, blank=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): # TODO - Return FALSE if this item cannot be deleted! return True @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 splitStock(self, quantity, 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 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. """ # 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 new_stock = StockItem.objects.create( part=self.part, quantity=quantity, supplier_part=self.supplier_part, location=self.location, batch=self.batch, delete_on_deplete=self.delete_on_deplete) new_stock.save() # 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. 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) """ quantity = int(kwargs.get('quantity', self.quantity)) 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): # 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! # Leave behind certain quantity self.splitStock(self.quantity - quantity, user) 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 """ if quantity < 0: quantity = 0 self.quantity = quantity if quantity <= 0 and self.delete_on_deplete: 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 """ count = int(count) 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 """ quantity = int(quantity) # 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 """ quantity = int(quantity) 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=self.quantity, part=self.part.full_name) if self.location: s += ' @ {loc}'.format(loc=self.location.name) return s
class POLineItemReceiveSerializer(serializers.Serializer): """ A serializer for receiving a single purchase order line item against a purchase order """ line_item = serializers.PrimaryKeyRelatedField( queryset=order.models.PurchaseOrderLineItem.objects.all(), many=False, allow_null=False, required=True, label=_('Line Item'), ) def validate_line_item(self, item): if item.order != self.context['order']: raise ValidationError(_('Line item does not match purchase order')) return item location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), many=False, allow_null=True, required=False, label=_('Location'), help_text=_('Select destination location for received items'), ) quantity = serializers.DecimalField( max_digits=15, decimal_places=5, min_value=0, required=True, ) def validate_quantity(self, quantity): if quantity <= 0: raise ValidationError(_("Quantity must be greater than zero")) return quantity status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, label=_('Status'), ) barcode = serializers.CharField( label=_('Barcode Hash'), help_text=_('Unique identifier field'), default='', required=False, allow_null=True, allow_blank=True, ) def validate_barcode(self, barcode): """ Cannot check in a LineItem with a barcode that is already assigned """ # Ignore empty barcode values if not barcode or barcode.strip() == '': return None if stock.models.StockItem.objects.filter(uid=barcode).exists(): raise ValidationError(_('Barcode is already in use')) return barcode class Meta: fields = [ 'barcode', 'line_item', 'location', 'quantity', 'status', ]
def status_label(self): return StockStatus.label(self.status)
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 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']
def stock_status(key, *args, **kwargs): return mark_safe(StockStatus.render(key))
class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): """ A serializer for receiving a single purchase order line item against a purchase order """ class Meta: fields = [ 'barcode', 'line_item', 'location', 'quantity', 'status', 'batch_code' 'serial_numbers', ] line_item = serializers.PrimaryKeyRelatedField( queryset=order.models.PurchaseOrderLineItem.objects.all(), many=False, allow_null=False, required=True, label=_('Line Item'), ) def validate_line_item(self, item): if item.order != self.context['order']: raise ValidationError(_('Line item does not match purchase order')) return item location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), many=False, allow_null=True, required=False, label=_('Location'), help_text=_('Select destination location for received items'), ) quantity = serializers.DecimalField( max_digits=15, decimal_places=5, min_value=0, required=True, ) def validate_quantity(self, quantity): if quantity <= 0: raise ValidationError(_("Quantity must be greater than zero")) return quantity batch_code = serializers.CharField( label=_('Batch Code'), help_text=_('Enter batch code for incoming stock items'), required=False, default='', allow_blank=True, ) serial_numbers = serializers.CharField( label=_('Serial Numbers'), help_text=_('Enter serial numbers for incoming stock items'), required=False, default='', allow_blank=True, ) status = serializers.ChoiceField( choices=list(StockStatus.items()), default=StockStatus.OK, label=_('Status'), ) barcode = serializers.CharField( label=_('Barcode Hash'), help_text=_('Unique identifier field'), default='', required=False, allow_null=True, allow_blank=True, ) def validate_barcode(self, barcode): """ Cannot check in a LineItem with a barcode that is already assigned """ # Ignore empty barcode values if not barcode or barcode.strip() == '': return None if stock.models.StockItem.objects.filter(uid=barcode).exists(): raise ValidationError(_('Barcode is already in use')) return barcode def validate(self, data): data = super().validate(data) line_item = data['line_item'] quantity = data['quantity'] serial_numbers = data.get('serial_numbers', '').strip() base_part = line_item.part.part # Does the quantity need to be "integer" (for trackable parts?) if base_part.trackable: if Decimal(quantity) != int(quantity): raise ValidationError({ 'quantity': _('An integer quantity must be provided for trackable parts' ), }) # If serial numbers are provided if serial_numbers: try: # Pass the serial numbers through to the parent serializer once validated data['serials'] = extract_serial_numbers( serial_numbers, quantity, base_part.getLatestSerialNumberInt()) except DjangoValidationError as e: raise ValidationError({ 'serial_numbers': e.messages, }) return data
def stock_status_text(key, *args, **kwargs): """Render the text value of a StockItem status value""" return mark_safe(StockStatus.text(key))
def get(self, request, *args, **kwargs): export_format = request.GET.get('format', 'csv').lower() # Check if a particular location was specified loc_id = request.GET.get('location', None) location = None if loc_id: try: location = StockLocation.objects.get(pk=loc_id) except (ValueError, StockLocation.DoesNotExist): pass # Check if a particular supplier was specified sup_id = request.GET.get('supplier', None) supplier = None if sup_id: try: supplier = Company.objects.get(pk=sup_id) except (ValueError, Company.DoesNotExist): pass # Check if a particular part was specified part_id = request.GET.get('part', None) part = None if part_id: try: part = Part.objects.get(pk=part_id) except (ValueError, Part.DoesNotExist): pass if export_format not in GetExportFormats(): export_format = 'csv' filename = 'InvenTree_Stocktake_{date}.{fmt}'.format( date=datetime.now().strftime("%d-%b-%Y"), fmt=export_format) if location: # CHeck if locations should be cascading cascade = str2bool(request.GET.get('cascade', True)) stock_items = location.get_stock_items(cascade) else: cascade = True stock_items = StockItem.objects.all() if part: stock_items = stock_items.filter(part=part) if supplier: stock_items = stock_items.filter(supplier_part__supplier=supplier) # Filter out stock items that are not 'in stock' stock_items = stock_items.filter(customer=None) stock_items = stock_items.filter(belongs_to=None) # Pre-fetch related fields to reduce DB queries stock_items = stock_items.prefetch_related('part', 'supplier_part__supplier', 'location', 'purchase_order', 'build') # Column headers headers = [ _('Stock ID'), _('Part ID'), _('Part'), _('Supplier Part ID'), _('Supplier ID'), _('Supplier'), _('Location ID'), _('Location'), _('Quantity'), _('Batch'), _('Serial'), _('Status'), _('Notes'), _('Review Needed'), _('Last Updated'), _('Last Stocktake'), _('Purchase Order ID'), _('Build ID'), ] data = tablib.Dataset(headers=headers) for item in stock_items: line = [] line.append(item.pk) line.append(item.part.pk) line.append(item.part.full_name) if item.supplier_part: line.append(item.supplier_part.pk) line.append(item.supplier_part.supplier.pk) line.append(item.supplier_part.supplier.name) else: line.append('') line.append('') line.append('') if item.location: line.append(item.location.pk) line.append(item.location.name) else: line.append('') line.append('') line.append(item.quantity) line.append(item.batch) line.append(item.serial) line.append(StockStatus.label(item.status)) line.append(item.notes) line.append(item.review_needed) line.append(item.updated) line.append(item.stocktake_date) if item.purchase_order: line.append(item.purchase_order.pk) else: line.append('') if item.build: line.append(item.build.pk) else: line.append('') data.append(line) filedata = data.export(export_format) return DownloadFile(filedata, filename)