class PurchaseOrder(Order): """ A PurchaseOrder represents goods shipped inwards from an external supplier. Attributes: supplier: Reference to the company supplying the goods in the order supplier_reference: Optional field for supplier order reference code received_by: User that received the goods target_date: Expected delivery target date for PurchaseOrder completion (optional) """ OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) @staticmethod def filterByDate(queryset, min_date, max_date): """ Filter by 'minimum and maximum date range' - Specified as min_date, max_date - Both must be specified for filter to be applied - Determine which "interesting" orders exist bewteen these dates To be "interesting": - A "received" order where the received date lies within the date range - A "pending" order where the target date lies within the date range - TODO: An "overdue" order where the target date is in the past """ date_fmt = '%Y-%m-%d' # ISO format date string # Ensure that both dates are valid try: min_date = datetime.strptime(str(min_date), date_fmt).date() max_date = datetime.strptime(str(max_date), date_fmt).date() except (ValueError, TypeError): # Date processing error, return queryset unchanged return queryset # Construct a queryset for "received" orders within the range received = Q(status=PurchaseOrderStatus.COMPLETE) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date) # Construct a queryset for "pending" orders within the range pending = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date) # TODO - Construct a queryset for "overdue" orders within the range queryset = queryset.filter(received | pending) return queryset def __str__(self): prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX') return f"{prefix}{self.reference} - {self.supplier.name}" status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(), help_text=_('Purchase order status')) supplier = models.ForeignKey( Company, on_delete=models.CASCADE, limit_choices_to={ 'is_supplier': True, }, related_name='purchase_orders', verbose_name=_('Supplier'), help_text=_('Company from which the items are being ordered') ) supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code")) received_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+', verbose_name=_('received by') ) issue_date = models.DateField( blank=True, null=True, verbose_name=_('Issue Date'), help_text=_('Date order was issued') ) target_date = models.DateField( blank=True, null=True, verbose_name=_('Target Delivery Date'), help_text=_('Expected date for order delivery. Order will be overdue after this date.'), ) complete_date = models.DateField( blank=True, null=True, verbose_name=_('Completion Date'), help_text=_('Date order was completed') ) def get_absolute_url(self): return reverse('po-detail', kwargs={'pk': self.id}) @transaction.atomic def add_line_item(self, supplier_part, quantity, group=True, reference='', purchase_price=None): """ Add a new line item to this purchase order. This function will check that: * The supplier part matches the supplier specified for this purchase order * The quantity is greater than zero Args: supplier_part - The supplier_part to add quantity - The number of items to add group - If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists) """ try: quantity = int(quantity) if quantity <= 0: raise ValidationError({ 'quantity': _("Quantity must be greater than zero")}) except ValueError: raise ValidationError({'quantity': _("Invalid quantity provided")}) if not supplier_part.supplier == self.supplier: raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) if group: # Check if there is already a matching line item (for this PO) matches = self.lines.filter(part=supplier_part) if matches.count() > 0: line = matches.first() # update quantity and price quantity_new = line.quantity + quantity line.quantity = quantity_new supplier_price = supplier_part.get_price(quantity_new) if line.purchase_price and supplier_price: line.purchase_price = supplier_price / quantity_new line.save() return line = PurchaseOrderLineItem( order=self, part=supplier_part, quantity=quantity, reference=reference, purchase_price=purchase_price, ) line.save() @transaction.atomic def place_order(self): """ Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ if self.status == PurchaseOrderStatus.PENDING: self.status = PurchaseOrderStatus.PLACED self.issue_date = datetime.now().date() self.save() @transaction.atomic def complete_order(self): """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ if self.status == PurchaseOrderStatus.PLACED: self.status = PurchaseOrderStatus.COMPLETE self.complete_date = datetime.now().date() self.save() @property def is_overdue(self): """ Returns True if this PurchaseOrder is "overdue" Makes use of the OVERDUE_FILTER to avoid code duplication. """ query = PurchaseOrder.objects.filter(pk=self.pk) query = query.filter(PurchaseOrder.OVERDUE_FILTER) return query.exists() def can_cancel(self): """ A PurchaseOrder can only be cancelled under the following circumstances: """ return self.status in [ PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING ] def cancel_order(self): """ Marks the PurchaseOrder as CANCELLED. """ if self.can_cancel(): self.status = PurchaseOrderStatus.CANCELLED self.save() def pending_line_items(self): """ Return a list of pending line items for this order. Any line item where 'received' < 'quantity' will be returned. """ return self.lines.filter(quantity__gt=F('received')) @property def is_complete(self): """ Return True if all line items have been received """ return self.pending_line_items().count() == 0 @transaction.atomic def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, purchase_price=None, **kwargs): """ Receive a line item (or partial line item) against this PO """ notes = kwargs.get('notes', '') if not self.status == PurchaseOrderStatus.PLACED: raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) try: if not (quantity % 1 == 0): raise ValidationError({"quantity": _("Quantity must be an integer")}) if quantity < 0: raise ValidationError({"quantity": _("Quantity must be a positive number")}) quantity = int(quantity) except (ValueError, TypeError): raise ValidationError({"quantity": _("Invalid quantity provided")}) # Create a new stock item if line.part and quantity > 0: stock = stock_models.StockItem( part=line.part.part, supplier_part=line.part, location=location, quantity=quantity, purchase_order=self, status=status, purchase_price=purchase_price, ) stock.save(add_note=False) tracking_info = { 'status': status, 'purchaseorder': self.pk, } stock.add_tracking_entry( StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, user, notes=notes, deltas=tracking_info, location=location, purchaseorder=self, quantity=quantity ) # Update the number of parts received against the particular line item line.received += quantity line.save() # Has this order been completed? if len(self.pending_line_items()) == 0: self.received_by = user self.complete_order() # This will save the model
def purchase_order_status_label(key, *args, **kwargs): """ Render a PurchaseOrder status label """ return mark_safe(PurchaseOrderStatus.render(key, large=kwargs.get('large', False)))
class PurchaseOrder(Order): """ A PurchaseOrder represents goods shipped inwards from an external supplier. Attributes: supplier: Reference to the company supplying the goods in the order supplier_reference: Optional field for supplier order reference code received_by: User that received the goods """ ORDER_PREFIX = "PO" def __str__(self): return "PO {ref} - {company}".format(ref=self.reference, company=self.supplier.name) status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(), help_text='Purchase order status') supplier = models.ForeignKey( Company, on_delete=models.CASCADE, limit_choices_to={ 'is_supplier': True, }, related_name='purchase_orders', help_text=_('Supplier') ) supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference code")) received_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+' ) issue_date = models.DateField(blank=True, null=True) complete_date = models.DateField(blank=True, null=True) def get_absolute_url(self): return reverse('po-detail', kwargs={'pk': self.id}) @transaction.atomic def add_line_item(self, supplier_part, quantity, group=True, reference=''): """ Add a new line item to this purchase order. This function will check that: * The supplier part matches the supplier specified for this purchase order * The quantity is greater than zero Args: supplier_part - The supplier_part to add quantity - The number of items to add group - If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists) """ try: quantity = int(quantity) if quantity <= 0: raise ValidationError({ 'quantity': _("Quantity must be greater than zero")}) except ValueError: raise ValidationError({'quantity': _("Invalid quantity provided")}) if not supplier_part.supplier == self.supplier: raise ValidationError({'supplier': _("Part supplier must match PO supplier")}) if group: # Check if there is already a matching line item (for this PO) matches = self.lines.filter(part=supplier_part) if matches.count() > 0: line = matches.first() line.quantity += quantity line.save() return line = PurchaseOrderLineItem( order=self, part=supplier_part, quantity=quantity, reference=reference) line.save() def place_order(self): """ Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ if self.status == PurchaseOrderStatus.PENDING: self.status = PurchaseOrderStatus.PLACED self.issue_date = datetime.now().date() self.save() def complete_order(self): """ Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. """ if self.status == PurchaseOrderStatus.PLACED: self.status = PurchaseOrderStatus.COMPLETE self.complete_date = datetime.now().date() self.save() def cancel_order(self): """ Marks the PurchaseOrder as CANCELLED. """ if self.status in [PurchaseOrderStatus.PLACED, PurchaseOrderStatus.PENDING]: self.status = PurchaseOrderStatus.CANCELLED self.save() def pending_line_items(self): """ Return a list of pending line items for this order. Any line item where 'received' < 'quantity' will be returned. """ return self.lines.filter(quantity__gt=F('received')) @property def is_complete(self): """ Return True if all line items have been received """ return self.pending_line_items().count() == 0 @transaction.atomic def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK): """ Receive a line item (or partial line item) against this PO """ if not self.status == PurchaseOrderStatus.PLACED: raise ValidationError({"status": _("Lines can only be received against an order marked as 'Placed'")}) try: quantity = int(quantity) if quantity <= 0: raise ValidationError({"quantity": _("Quantity must be greater than zero")}) except ValueError: raise ValidationError({"quantity": _("Invalid quantity provided")}) # Create a new stock item if line.part: stock = stock_models.StockItem( part=line.part.part, supplier_part=line.part, location=location, quantity=quantity, purchase_order=self, status=status ) stock.save() # Add a new transaction note to the newly created stock item stock.addTransactionNote("Received items", user, "Received {q} items against order '{po}'".format( q=quantity, po=str(self)) ) # Update the number of parts received against the particular line item line.received += quantity line.save() # Has this order been completed? if len(self.pending_line_items()) == 0: self.received_by = user self.complete_order() # This will save the model