Esempio n. 1
0
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)
Esempio n. 2
0
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
Esempio n. 3
0
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)
Esempio n. 4
0
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
Esempio n. 5
0
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
Esempio n. 6
0
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
Esempio n. 7
0
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')
Esempio n. 8
0
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
Esempio n. 9
0
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']
Esempio n. 10
0
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)
Esempio n. 11
0
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()
Esempio n. 12
0
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
Esempio n. 13
0
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