class SupplierPriceBreak(models.Model): """ Represents a quantity price break for a SupplierPart. - Suppliers can offer discounts at larger quantities - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) Attributes: part: Link to a SupplierPart object that this price break applies to quantity: Quantity required for price break cost: Cost at specified quantity currency: Reference to the currency of this pricebreak (leave empty for base currency) """ part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks') quantity = RoundingDecimalField(max_digits=15, decimal_places=5, default=1, validators=[MinValueValidator(1)]) cost = RoundingDecimalField(max_digits=10, decimal_places=5, validators=[MinValueValidator(0)]) currency = models.ForeignKey(Currency, blank=True, null=True, on_delete=models.SET_NULL) @property def converted_cost(self): """ Return the cost of this price break, converted to the base currency """ scaler = Decimal(1.0) if self.currency: scaler = self.currency.value return self.cost * scaler class Meta: unique_together = ("part", "quantity") # This model was moved from the 'Part' app db_table = 'part_supplierpricebreak' def __str__(self): return "{mpn} - {cost} @ {quan}".format(mpn=self.part.MPN, cost=self.cost, quan=self.quantity)
class OrderLineItem(models.Model): """ Abstract model for an order line item Attributes: quantity: Number of items note: Annotation for the item """ class Meta: abstract = True quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Item quantity')) reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference')) notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
class OrderLineItem(models.Model): """ Abstract model for an order line item Attributes: quantity: Number of items reference: Reference text (e.g. customer reference) for this line item note: Annotation for the item target_date: An (optional) date for expected shipment of this line item. """ """ Query filter for determining if an individual line item is "overdue": - Amount received is less than the required quantity - Target date is not None - Target date is in the past """ OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q( target_date__lt=datetime.now().date()) class Meta: abstract = True quantity = RoundingDecimalField( verbose_name=_('Quantity'), help_text=_('Item quantity'), default=1, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], ) reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference')) notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes')) target_date = models.DateField( blank=True, null=True, verbose_name=_('Target Date'), help_text=_('Target shipping date for this line item'), )
class SalesOrderAllocation(models.Model): """ This model is used to 'allocate' stock items to a SalesOrder. Items that are "allocated" to a SalesOrder are not yet "attached" to the order, but they will be once the order is fulfilled. Attributes: line: SalesOrderLineItem reference item: StockItem reference quantity: Quantity to take from the StockItem """ class Meta: unique_together = [ # Cannot allocate any given StockItem to the same line more than once ('line', 'item'), ] def clean(self): """ Validate the SalesOrderAllocation object: - Cannot allocate stock to a line item without a part reference - The referenced part must match the part associated with the line item - Allocated quantity cannot exceed the quantity of the stock item - Allocation quantity must be "1" if the StockItem is serialized - Allocation quantity cannot be zero """ super().clean() errors = {} try: if not self.item: raise ValidationError({'item': _('Stock item has not been assigned')}) except stock_models.StockItem.DoesNotExist: raise ValidationError({'item': _('Stock item has not been assigned')}) try: if not self.line.part == self.item.part: errors['item'] = _('Cannot allocate stock item to a line with a different part') except PartModels.Part.DoesNotExist: errors['line'] = _('Cannot allocate stock to a line without a part') if self.quantity > self.item.quantity: errors['quantity'] = _('Allocation quantity cannot exceed stock quantity') # TODO: The logic here needs improving. Do we need to subtract our own amount, or something? if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity: errors['quantity'] = _('StockItem is over-allocated') if self.quantity <= 0: errors['quantity'] = _('Allocation quantity must be greater than zero') if self.item.serial and not self.quantity == 1: errors['quantity'] = _('Quantity must be 1 for serialized stock item') if len(errors) > 0: raise ValidationError(errors) line = models.ForeignKey(SalesOrderLineItem, on_delete=models.CASCADE, verbose_name=_('Line'), related_name='allocations') item = models.ForeignKey( 'stock.StockItem', on_delete=models.CASCADE, related_name='sales_order_allocations', limit_choices_to={ 'part__salable': True, 'belongs_to': None, 'sales_order': None, }, verbose_name=_('Item'), help_text=_('Select stock item to allocate') ) quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Enter stock allocation quantity')) def get_serial(self): return self.item.serial def get_location(self): return self.item.location.id if self.item.location else None def get_location_path(self): if self.item.location: return self.item.location.pathstring else: return "" def complete_allocation(self, user): """ Complete this allocation (called when the parent SalesOrder is marked as "shipped"): - Determine if the referenced StockItem needs to be "split" (if allocated quantity != stock quantity) - Mark the StockItem as belonging to the Customer (this will remove it from stock) """ order = self.line.order item = self.item.allocateToCustomer( order.customer, quantity=self.quantity, order=order, user=user ) # Update our own reference to the StockItem # (It may have changed if the stock was split) self.item = item self.save()
class SalesOrderLineItem(OrderLineItem): """ Model for a single LineItem in a SalesOrder Attributes: order: Link to the SalesOrder that this line item belongs to part: Link to a Part object (may be null) sale_price: The unit sale price for this OrderLineItem shipped: The number of items which have actually shipped against this line item """ @staticmethod def get_api_url(): return reverse('api-so-line-list') order = models.ForeignKey( SalesOrder, on_delete=models.CASCADE, related_name='lines', verbose_name=_('Order'), help_text=_('Sales Order') ) part = models.ForeignKey('part.Part', on_delete=models.SET_NULL, related_name='sales_order_line_items', null=True, verbose_name=_('Part'), help_text=_('Part'), limit_choices_to={'salable': True}) sale_price = InvenTreeModelMoneyField( max_digits=19, decimal_places=4, null=True, blank=True, verbose_name=_('Sale Price'), help_text=_('Unit sale price'), ) shipped = RoundingDecimalField( verbose_name=_('Shipped'), help_text=_('Shipped quantity'), default=0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)] ) class Meta: unique_together = [ ] def fulfilled_quantity(self): """ Return the total stock quantity fulfilled against this line item. """ query = self.order.stock_items.filter(part=self.part).aggregate(fulfilled=Coalesce(Sum('quantity'), Decimal(0))) return query['fulfilled'] def allocated_quantity(self): """ Return the total stock quantity allocated to this LineItem. This is a summation of the quantity of each attached StockItem """ query = self.allocations.aggregate(allocated=Coalesce(Sum('quantity'), Decimal(0))) return query['allocated'] def is_fully_allocated(self): """ Return True if this line item is fully allocated """ if self.order.status == SalesOrderStatus.SHIPPED: return self.fulfilled_quantity() >= self.quantity return self.allocated_quantity() >= self.quantity def is_over_allocated(self): """ Return True if this line item is over allocated """ return self.allocated_quantity() > self.quantity def is_completed(self): """ Return True if this line item is completed (has been fully shipped) """ return self.shipped >= self.quantity