InvenTree/InvenTree/order/models.py

1362 lines
42 KiB
Python
Raw Normal View History

2019-06-10 12:14:23 +00:00
"""
Order model definitions
"""
# -*- coding: utf-8 -*-
import os
2022-03-05 22:10:20 +00:00
from datetime import datetime
from decimal import Decimal
from django.db import models, transaction
from django.db.models import Q, F, Sum
from django.db.models.functions import Coalesce
2022-01-10 06:28:44 +00:00
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
from django.urls import reverse
2021-04-03 02:01:40 +00:00
from django.utils.translation import ugettext_lazy as _
from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey
from djmoney.contrib.exchange.models import convert_money
2022-03-06 22:21:33 +00:00
from djmoney.money import Money
from common.settings import currency_code_default
from users import models as UserModels
from part import models as PartModels
2020-04-21 12:37:35 +00:00
from stock import models as stock_models
from company.models import Company, SupplierPart
from plugin.events import trigger_event
import InvenTree.helpers
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
from InvenTree.helpers import decimal2string, increment, getSetting
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
def get_next_po_number():
"""
Returns the next available PurchaseOrder reference number
"""
if PurchaseOrder.objects.count() == 0:
return '0001'
order = PurchaseOrder.objects.exclude(reference=None).last()
attempts = set([order.reference])
2021-07-08 14:18:03 +00:00
reference = order.reference
while 1:
2021-07-08 14:18:03 +00:00
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion
return reference
if PurchaseOrder.objects.filter(reference=reference).exists():
attempts.add(reference)
else:
break
return reference
def get_next_so_number():
"""
Returns the next available SalesOrder reference number
"""
if SalesOrder.objects.count() == 0:
return '0001'
order = SalesOrder.objects.exclude(reference=None).last()
attempts = set([order.reference])
2021-07-08 14:18:03 +00:00
reference = order.reference
while 1:
2021-07-08 14:18:03 +00:00
reference = increment(reference)
if reference in attempts:
# Escape infinite recursion
return reference
if SalesOrder.objects.filter(reference=reference).exists():
attempts.add(reference)
else:
break
return reference
class Order(ReferenceIndexingMixin):
""" Abstract model for an order.
Instances of this class:
- PuchaseOrder
Attributes:
reference: Unique order number / reference / code
description: Long form description (required)
notes: Extra note field (optional)
creation_date: Automatic date of order creation
created_by: User who created this order (automatically captured)
issue_date: Date the order was issued
complete_date: Date the order was completed
responsible: User (or group) responsible for managing the order
"""
def save(self, *args, **kwargs):
self.rebuild_reference_field()
if not self.creation_date:
self.creation_date = datetime.now().date()
super().save(*args, **kwargs)
class Meta:
abstract = True
2021-04-04 20:44:14 +00:00
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_('Order description'))
2021-04-04 20:44:14 +00:00
link = models.URLField(blank=True, verbose_name=_('Link'), help_text=_('Link to external page'))
2021-04-04 20:44:14 +00:00
creation_date = models.DateField(blank=True, null=True, verbose_name=_('Creation Date'))
created_by = models.ForeignKey(User,
on_delete=models.SET_NULL,
blank=True, null=True,
2021-04-04 20:44:14 +00:00
related_name='+',
verbose_name=_('Created By')
)
responsible = models.ForeignKey(
UserModels.Owner,
on_delete=models.SET_NULL,
blank=True, null=True,
help_text=_('User or group responsible for this order'),
verbose_name=_('Responsible'),
related_name='+',
)
2021-04-04 20:44:14 +00:00
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
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)
"""
@staticmethod
def get_api_url():
return reverse('api-po-list')
OVERDUE_FILTER = Q(status__in=PurchaseOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
2021-01-07 12:04:00 +00:00
@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
2021-01-07 12:04:00 +00:00
- 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)
2021-01-07 12:04:00 +00:00
# TODO - Construct a queryset for "overdue" orders within the range
queryset = queryset.filter(received | pending)
2021-01-07 12:04:00 +00:00
return queryset
def __str__(self):
prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX')
return f"{prefix}{self.reference} - {self.supplier.name}"
reference = models.CharField(
unique=True,
max_length=64,
blank=False,
verbose_name=_('Reference'),
help_text=_('Order reference'),
default=get_next_po_number,
)
status = models.PositiveIntegerField(default=PurchaseOrderStatus.PENDING, choices=PurchaseOrderStatus.items(),
2020-08-31 09:13:24 +00:00
help_text=_('Purchase order status'))
2019-06-04 14:21:19 +00:00
supplier = models.ForeignKey(
Company, on_delete=models.CASCADE,
limit_choices_to={
'is_supplier': True,
},
related_name='purchase_orders',
2021-04-04 20:44:14 +00:00
verbose_name=_('Supplier'),
2020-08-31 09:13:24 +00:00
help_text=_('Company from which the items are being ordered')
2019-06-04 14:21:19 +00:00
)
2021-04-04 20:44:14 +00:00
supplier_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Supplier Reference'), help_text=_("Supplier order reference code"))
2019-06-15 09:42:09 +00:00
received_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
2021-04-04 20:44:14 +00:00
related_name='+',
verbose_name=_('received by')
)
issue_date = models.DateField(
blank=True, null=True,
2021-01-14 11:06:53 +00:00
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):
2020-03-22 09:13:38 +00:00
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()
2020-10-30 05:54:05 +00:00
@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()
2022-01-10 06:28:44 +00:00
trigger_event('purchaseorder.placed', id=self.pk)
2020-10-30 05:54:05 +00:00
@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()
2022-01-10 06:28:44 +00:00
trigger_event('purchaseorder.completed', id=self.pk)
@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()
2020-10-30 05:54:05 +00:00
def can_cancel(self):
2021-01-14 03:37:49 +00:00
"""
A PurchaseOrder can only be cancelled under the following circumstances:
"""
2021-01-14 03:37:49 +00:00
return self.status in [
2020-10-30 05:54:05 +00:00
PurchaseOrderStatus.PLACED,
PurchaseOrderStatus.PENDING
]
def cancel_order(self):
""" Marks the PurchaseOrder as CANCELLED. """
2020-10-30 05:54:05 +00:00
if self.can_cancel():
self.status = PurchaseOrderStatus.CANCELLED
self.save()
2022-01-10 06:28:44 +00:00
trigger_event('purchaseorder.cancelled', id=self.pk)
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'))
def completed_line_items(self):
"""
Return a list of completed line items against this order
"""
return self.lines.filter(quantity__lte=F('received'))
@property
def line_count(self):
return self.lines.count()
@property
def completed_line_count(self):
return self.completed_line_items().count()
@property
def pending_line_count(self):
return self.pending_line_items().count()
@property
def is_complete(self):
""" Return True if all line items have been received """
return self.lines.count() > 0 and self.pending_line_items().count() == 0
@transaction.atomic
def receive_line_item(self, line, location, quantity, user, status=StockStatus.OK, **kwargs):
"""
Receive a line item (or partial line item) against this PO
"""
# Extract optional batch code for the new stock item
batch_code = kwargs.get('batch_code', '')
# Extract optional list of serial numbers
serials = kwargs.get('serials', None)
# Extract optional notes field
notes = kwargs.get('notes', '')
# Extract optional barcode field
barcode = kwargs.get('barcode', None)
2021-09-07 13:34:14 +00:00
# Prevent null values for barcode
if barcode is None:
barcode = ''
if not self.status == PurchaseOrderStatus.PLACED:
2021-10-02 15:03:40 +00:00
raise ValidationError(
"Lines can only be received against an order marked as 'PLACED'"
)
try:
2021-04-28 17:16:40 +00:00
if quantity < 0:
raise ValidationError({
"quantity": _("Quantity must be a positive number")
})
quantity = InvenTree.helpers.clean_decimal(quantity)
except TypeError:
raise ValidationError({
"quantity": _("Invalid quantity provided")
})
# Create a new stock item
2021-04-28 17:16:40 +00:00
if line.part and quantity > 0:
# Determine if we should individually serialize the items, or not
if type(serials) is list and len(serials) > 0:
2022-02-28 12:09:57 +00:00
serialize = True
else:
2022-02-28 12:09:57 +00:00
serialize = False
serials = [None]
for sn in serials:
stock = stock_models.StockItem(
part=line.part.part,
supplier_part=line.part,
location=location,
2022-02-28 12:09:57 +00:00
quantity=1 if serialize else quantity,
purchase_order=self,
status=status,
batch=batch_code,
serial=sn,
purchase_price=line.purchase_price,
uid=barcode
)
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
class SalesOrder(Order):
"""
A SalesOrder represents a list of goods shipped outwards to a customer.
Attributes:
customer: Reference to the company receiving the goods in the order
customer_reference: Optional field for customer order reference code
target_date: Target date for SalesOrder completion (optional)
"""
@staticmethod
def get_api_url():
return reverse('api-so-list')
OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
2021-01-07 07:47:29 +00:00
@staticmethod
2021-01-07 12:04:00 +00:00
def filterByDate(queryset, min_date, max_date):
2021-01-07 07:47:29 +00:00
"""
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 between these dates
To be "interesting":
- A "completed" order where the completion 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
"""
2021-01-07 11:34:17 +00:00
date_fmt = '%Y-%m-%d' # ISO format date string
2021-01-07 07:47:29 +00:00
# Ensure that both dates are valid
try:
2021-01-07 11:34:17 +00:00
min_date = datetime.strptime(str(min_date), date_fmt).date()
max_date = datetime.strptime(str(max_date), date_fmt).date()
2021-01-07 07:47:29 +00:00
except (ValueError, TypeError):
# Date processing error, return queryset unchanged
return queryset
2021-01-07 07:47:29 +00:00
# Construct a queryset for "completed" orders within the range
2021-01-07 11:34:17 +00:00
completed = Q(status__in=SalesOrderStatus.COMPLETE) & Q(shipment_date__gte=min_date) & Q(shipment_date__lte=max_date)
2021-01-07 07:47:29 +00:00
# Construct a queryset for "pending" orders within the range
2021-01-07 11:34:17 +00:00
pending = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
2021-01-07 07:47:29 +00:00
2021-01-07 12:04:00 +00:00
# TODO: Construct a queryset for "overdue" orders within the range
2021-01-07 12:41:54 +00:00
queryset = queryset.filter(completed | pending)
2021-01-07 07:47:29 +00:00
return queryset
def save(self, *args, **kwargs):
self.rebuild_reference_field()
super().save(*args, **kwargs)
def __str__(self):
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
return f"{prefix}{self.reference} - {self.customer.name}"
2020-04-20 12:13:07 +00:00
def get_absolute_url(self):
return reverse('so-detail', kwargs={'pk': self.id})
reference = models.CharField(
unique=True,
max_length=64,
blank=False,
verbose_name=_('Reference'),
help_text=_('Order reference'),
default=get_next_so_number,
)
2020-04-21 12:37:35 +00:00
customer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
null=True,
limit_choices_to={'is_customer': True},
related_name='sales_orders',
2021-04-04 20:44:14 +00:00
verbose_name=_('Customer'),
2020-08-31 09:13:24 +00:00
help_text=_("Company to which the items are being sold"),
)
status = models.PositiveIntegerField(default=SalesOrderStatus.PENDING, choices=SalesOrderStatus.items(),
2021-04-04 20:44:14 +00:00
verbose_name=_('Status'), help_text=_('Purchase order status'))
2021-04-04 20:44:14 +00:00
customer_reference = models.CharField(max_length=64, blank=True, verbose_name=_('Customer Reference '), help_text=_("Customer order reference code"))
target_date = models.DateField(
null=True, blank=True,
verbose_name=_('Target completion date'),
help_text=_('Target date for order completion. Order will be overdue after this date.')
)
2021-04-04 20:44:14 +00:00
shipment_date = models.DateField(blank=True, null=True, verbose_name=_('Shipment Date'))
shipped_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
2021-04-04 20:44:14 +00:00
related_name='+',
verbose_name=_('shipped by')
)
2022-03-06 22:21:33 +00:00
def get_total_price(self):
"""
Calculates the total price of all order lines
"""
target_currency = currency_code_default()
total = Money(0, target_currency)
# order items
total += sum([a.quantity * convert_money(a.sale_price, target_currency) for a in self.lines.all() if a.sale_price])
# additional lines
total += sum([a.quantity * convert_money(a.sale_price, target_currency) for a in self.additional_lines.all() if a.sale_price])
# set decimal-places
total.decimal_places = 4
return total
@property
def is_overdue(self):
"""
Returns true if this SalesOrder is "overdue":
Makes use of the OVERDUE_FILTER to avoid code duplication.
"""
query = SalesOrder.objects.filter(pk=self.pk)
query = query.filter(SalesOrder.OVERDUE_FILTER)
return query.exists()
@property
def is_pending(self):
return self.status == SalesOrderStatus.PENDING
@property
def stock_allocations(self):
"""
Return a queryset containing all allocations for this order
"""
return SalesOrderAllocation.objects.filter(
line__in=[line.pk for line in self.lines.all()]
)
2020-04-22 12:22:22 +00:00
def is_fully_allocated(self):
""" Return True if all line items are fully allocated """
for line in self.lines.all():
if not line.is_fully_allocated():
return False
2020-04-22 12:22:22 +00:00
return True
def is_over_allocated(self):
""" Return true if any lines in the order are over-allocated """
for line in self.lines.all():
if line.is_over_allocated():
return True
return False
def is_completed(self):
"""
Check if this order is "shipped" (all line items delivered),
"""
return self.lines.count() > 0 and all([line.is_completed() for line in self.lines.all()])
def can_complete(self, raise_error=False):
2021-12-03 07:42:36 +00:00
"""
Test if this SalesOrder can be completed.
Throws a ValidationError if cannot be completed.
2021-12-03 07:42:36 +00:00
"""
2021-12-20 08:29:08 +00:00
try:
# Order without line items cannot be completed
if self.lines.count() == 0:
raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
2021-12-20 08:29:08 +00:00
# Only a PENDING order can be marked as SHIPPED
elif self.status != SalesOrderStatus.PENDING:
raise ValidationError(_('Only a pending order can be marked as complete'))
2021-12-20 08:29:08 +00:00
elif self.pending_shipment_count > 0:
raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
2021-12-20 08:29:08 +00:00
elif self.pending_line_count > 0:
raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
2021-12-20 08:29:08 +00:00
except ValidationError as e:
2021-12-20 08:29:08 +00:00
if raise_error:
raise e
else:
return False
return True
2021-12-03 07:42:36 +00:00
def complete_order(self, user):
"""
Mark this order as "complete"
"""
2021-12-03 07:42:36 +00:00
if not self.can_complete():
2021-12-04 06:30:13 +00:00
return False
2021-12-03 07:42:36 +00:00
self.status = SalesOrderStatus.SHIPPED
self.shipped_by = user
self.shipment_date = datetime.now()
self.save()
2022-01-10 06:28:44 +00:00
trigger_event('salesorder.completed', id=self.pk)
2021-12-04 06:30:13 +00:00
return True
2020-10-30 05:54:05 +00:00
def can_cancel(self):
"""
Return True if this order can be cancelled
"""
if not self.status == SalesOrderStatus.PENDING:
return False
return True
@transaction.atomic
def cancel_order(self):
"""
Cancel this order (only if it is "pending")
- Mark the order as 'cancelled'
- Delete any StockItems which have been allocated
"""
2020-10-30 05:54:05 +00:00
if not self.can_cancel():
return False
self.status = SalesOrderStatus.CANCELLED
self.save()
for line in self.lines.all():
for allocation in line.allocations.all():
allocation.delete()
2022-01-10 06:28:44 +00:00
trigger_event('salesorder.cancelled', id=self.pk)
return True
@property
def line_count(self):
return self.lines.count()
def completed_line_items(self):
"""
Return a queryset of the completed line items for this order
"""
return self.lines.filter(shipped__gte=F('quantity'))
def pending_line_items(self):
"""
Return a queryset of the pending line items for this order
"""
return self.lines.filter(shipped__lt=F('quantity'))
@property
def completed_line_count(self):
return self.completed_line_items().count()
@property
def pending_line_count(self):
return self.pending_line_items().count()
def completed_shipments(self):
"""
Return a queryset of the completed shipments for this order
"""
return self.shipments.exclude(shipment_date=None)
def pending_shipments(self):
"""
Return a queryset of the pending shipments for this order
"""
return self.shipments.filter(shipment_date=None)
@property
def shipment_count(self):
return self.shipments.count()
@property
def completed_shipment_count(self):
return self.completed_shipments().count()
@property
def pending_shipment_count(self):
return self.pending_shipments().count()
class PurchaseOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a PurchaseOrder object
"""
@staticmethod
def get_api_url():
return reverse('api-po-attachment-list')
def getSubdir(self):
return os.path.join("po_files", str(self.order.id))
order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="attachments")
class SalesOrderAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a SalesOrder object
"""
@staticmethod
def get_api_url():
return reverse('api-so-attachment-list')
def getSubdir(self):
return os.path.join("so_files", str(self.order.id))
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='attachments')
class OrderLineItem(models.Model):
2019-06-04 14:21:19 +00:00
""" 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)],
)
2021-04-04 20:44:14 +00:00
reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference'))
2021-04-04 20:44:14 +00:00
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 PurchaseOrderLineItem(OrderLineItem):
2019-06-04 14:21:19 +00:00
""" Model for a purchase order line item.
Attributes:
order: Reference to a PurchaseOrder object
"""
class Meta:
unique_together = (
)
@staticmethod
def get_api_url():
return reverse('api-po-line-list')
def clean(self):
2022-01-26 12:17:58 +00:00
super().clean()
if self.order.supplier and self.part:
# Supplier part *must* point to the same supplier!
if self.part.supplier != self.order.supplier:
raise ValidationError({
'part': _('Supplier part must match supplier')
})
def __str__(self):
return "{n} x {part} from {supplier} (for {po})".format(
n=decimal2string(self.quantity),
part=self.part.SKU if self.part else 'unknown part',
supplier=self.order.supplier.name,
po=self.order)
order = models.ForeignKey(
PurchaseOrder, on_delete=models.CASCADE,
related_name='lines',
2021-04-04 20:44:14 +00:00
verbose_name=_('Order'),
help_text=_('Purchase Order')
)
def get_base_part(self):
"""
Return the base part.Part object for the line item
2021-11-22 23:28:23 +00:00
Note: Returns None if the SupplierPart is not set!
"""
if self.part is None:
return None
else:
return self.part.part
2019-06-06 11:56:20 +00:00
# TODO - Function callback for when the SupplierPart is deleted?
part = models.ForeignKey(
SupplierPart, on_delete=models.SET_NULL,
blank=True, null=True,
2019-06-05 11:47:22 +00:00
related_name='purchase_order_line_items',
2021-04-04 20:44:14 +00:00
verbose_name=_('Part'),
help_text=_("Supplier part"),
)
2021-09-02 01:11:25 +00:00
received = models.DecimalField(
decimal_places=5,
max_digits=15,
default=0,
verbose_name=_('Received'),
help_text=_('Number of items received')
)
purchase_price = InvenTreeModelMoneyField(
max_digits=19,
decimal_places=4,
null=True, blank=True,
verbose_name=_('Purchase Price'),
help_text=_('Unit purchase price'),
)
destination = TreeForeignKey(
'stock.StockLocation', on_delete=models.SET_NULL,
verbose_name=_('Destination'),
related_name='po_lines',
blank=True, null=True,
help_text=_('Where does the Purchaser want this item to be stored?')
)
def get_destination(self):
"""
Show where the line item is or should be placed
2021-12-02 12:58:02 +00:00
NOTE: If a line item gets split when recieved, only an arbitrary
stock items location will be reported as the location for the
entire line.
"""
for stock in stock_models.StockItem.objects.filter(supplier_part=self.part, purchase_order=self.order):
if stock.location:
return stock.location
if self.destination:
return self.destination
if self.part and self.part.part and self.part.part.default_location:
return self.part.part.default_location
def remaining(self):
""" Calculate the number of items remaining to be received """
r = self.quantity - self.received
2019-06-15 09:42:09 +00:00
return max(r, 0)
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)
2021-05-04 19:56:25 +00:00
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')
)
2021-04-04 20:44:14 +00:00
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(
2021-05-04 19:56:25 +00:00
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']
2020-04-22 12:22:22 +00:00
def is_fully_allocated(self):
2020-04-24 00:22:33 +00:00
""" Return True if this line item is fully allocated """
if self.order.status == SalesOrderStatus.SHIPPED:
return self.fulfilled_quantity() >= self.quantity
2020-04-22 12:22:22 +00:00
return self.allocated_quantity() >= self.quantity
def is_over_allocated(self):
2020-04-24 00:22:33 +00:00
""" Return True if this line item is over allocated """
2020-04-22 12:22:22 +00:00
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
2021-10-25 02:09:06 +00:00
class SalesOrderShipment(models.Model):
"""
The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object
- Multiple SalesOrderAllocation objects point to a particular SalesOrderShipment
- When a given SalesOrderShipment is "shipped", stock items are removed from stock
Attributes:
order: SalesOrder reference
shipment_date: Date this shipment was "shipped" (or null)
checked_by: User reference field indicating who checked this order
reference: Custom reference text for this shipment (e.g. consignment number?)
notes: Custom notes field for this shipment
"""
class Meta:
# Shipment reference must be unique for a given sales order
unique_together = [
'order', 'reference',
]
@staticmethod
def get_api_url():
return reverse('api-so-shipment-list')
2021-10-25 02:09:06 +00:00
order = models.ForeignKey(
SalesOrder,
on_delete=models.CASCADE,
blank=False, null=False,
related_name='shipments',
verbose_name=_('Order'),
help_text=_('Sales Order'),
)
shipment_date = models.DateField(
null=True, blank=True,
verbose_name=_('Shipment Date'),
help_text=_('Date of shipment'),
)
checked_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Checked By'),
help_text=_('User who checked this shipment'),
related_name='+',
)
reference = models.CharField(
max_length=100,
blank=False,
verbose_name=('Shipment'),
help_text=_('Shipment number'),
default='1',
2021-10-25 02:09:06 +00:00
)
notes = MarkdownxField(
blank=True,
verbose_name=_('Notes'),
help_text=_('Shipment notes'),
)
tracking_number = models.CharField(
max_length=100,
blank=True,
unique=False,
verbose_name=_('Tracking Number'),
help_text=_('Shipment tracking information'),
)
2021-12-03 07:42:36 +00:00
def is_complete(self):
return self.shipment_date is not None
def check_can_complete(self):
if self.shipment_date:
# Shipment has already been sent!
raise ValidationError(_("Shipment has already been sent"))
2021-12-02 12:58:02 +00:00
if self.allocations.count() == 0:
raise ValidationError(_("Shipment has no allocated stock items"))
@transaction.atomic
def complete_shipment(self, user, **kwargs):
"""
Complete this particular shipment:
1. Update any stock items associated with this shipment
2. Update the "shipped" quantity of all associated line items
3. Set the "shipment_date" to now
"""
# Check if the shipment can be completed (throw error if not)
self.check_can_complete()
allocations = self.allocations.all()
# Iterate through each stock item assigned to this shipment
for allocation in allocations:
# Mark the allocation as "complete"
allocation.complete_allocation(user)
2021-12-02 12:58:02 +00:00
# Update the "shipment" date
self.shipment_date = datetime.now()
self.shipped_by = user
# Was a tracking number provided?
tracking_number = kwargs.get('tracking_number', None)
if tracking_number is not None:
self.tracking_number = tracking_number
self.save()
2022-01-10 06:28:44 +00:00
trigger_event('salesordershipment.completed', id=self.pk)
2021-10-25 02:09:06 +00:00
class SalesOrderAdditionalLineItem(OrderLineItem):
"""
Model for a single AdditionalLineItem in a SalesOrder
Attributes:
order: Link to the SalesOrder that this line item belongs to
title: titile of line item
sale_price: The unit sale price for this OrderLineItem
"""
2022-03-06 17:44:05 +00:00
@staticmethod
def get_api_url():
return reverse('api-so-additional-line-list')
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='additional_lines', verbose_name=_('Order'), help_text=_('Sales Order'))
sale_price = InvenTreeModelMoneyField(
max_digits=19,
decimal_places=4,
null=True, blank=True,
verbose_name=_('Sale Price'),
help_text=_('Unit sale price'),
)
def sale_price_converted(self):
return convert_money(self.sale_price, currency_code_default())
def sale_price_converted_currency(self):
return currency_code_default()
class Meta:
unique_together = [
]
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
shipment: SalesOrderShipment reference
item: StockItem reference
quantity: Quantity to take from the StockItem
"""
@staticmethod
def get_api_url():
return reverse('api-so-allocation-list')
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:
2021-03-29 13:10:28 +00:00
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')
2020-04-27 10:16:41 +00:00
# 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 self.line.order != self.shipment.order:
errors['line'] = _('Sales order does not match shipment')
errors['shipment'] = _('Shipment does not match sales order')
if len(errors) > 0:
raise ValidationError(errors)
line = models.ForeignKey(
SalesOrderLineItem,
on_delete=models.CASCADE,
verbose_name=_('Line'),
related_name='allocations'
)
shipment = models.ForeignKey(
SalesOrderShipment,
on_delete=models.CASCADE,
related_name='allocations',
verbose_name=_('Shipment'),
help_text=_('Sales order shipment reference'),
)
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,
},
2021-04-04 20:44:14 +00:00
verbose_name=_('Item'),
help_text=_('Select stock item to allocate')
)
2021-04-04 20:44:14 +00:00
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 get_po(self):
return self.item.purchase_order
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 the 'shipped' quantity
self.line.shipped += self.quantity
self.line.save()
# Update our own reference to the StockItem
# (It may have changed if the stock was split)
self.item = item
self.save()