Source code for plata.product.stock.models

"""
Exact, transactional stock tracking for Plata
=============================================

Follow these steps to enable this module:

- Ensure your product model has an ``items_in_stock`` field with the
  following definiton::

      items_in_stock = models.IntegerField(default=0)

- Add ``'plata.product.stock'`` to ``INSTALLED_APPS``.
- Set ``PLATA_STOCK_TRACKING = True`` to enable stock tracking in the
  checkout and payment processes.
- Optionally modify your add-to-cart forms on product detail pages to take
  into account ``items_in_stock``.
"""

from __future__ import absolute_import, unicode_literals

from datetime import timedelta

from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, Sum
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext, ugettext_lazy as _

import plata
from plata.shop.models import Order, OrderPayment


class PeriodManager(models.Manager):
    def current(self):
        """
        Return the newest active period
        """

        try:
            return self.filter(start__lte=timezone.now()).order_by("-start")[0]
        except IndexError:
            return self.create(
                name=ugettext("Automatically created"),
                notes=ugettext("Automatically created because no period existed yet."),
            )


[docs]@python_2_unicode_compatible class Period(models.Model): """ A period in which stock changes are tracked You might want to create a new period every year and create initial amount transactions for every variation. ``StockTransaction.objects.open_new_period`` does this automatically. """ name = models.CharField(_("name"), max_length=100) notes = models.TextField(_("notes"), blank=True) start = models.DateTimeField( _("start"), default=timezone.now, help_text=_("Period starts at this time. May also be a future date."), ) class Meta: # See https://github.com/matthiask/plata/issues/27 abstract = not plata.settings.PLATA_STOCK_TRACKING get_latest_by = "start" ordering = ["-start"] verbose_name = _("period") verbose_name_plural = _("periods") objects = PeriodManager() def __str__(self): return self.name
class StockTransactionManager(models.Manager): def open_new_period(self, name=None): """ Create a new period and create initial transactions for all product variations with their current ``items_in_stock`` value """ period = Period.objects.create(name=name or ugettext("New period")) for p in plata.product_model()._default_manager.all(): p.stock_transactions.create( period=period, type=StockTransaction.INITIAL, change=p.items_in_stock, notes=ugettext("New period"), ) def items_in_stock( self, product, update=False, exclude_order=None, include_reservations=False ): """ Determine the items in stock for the given product variation, optionally updating the ``items_in_stock`` field in the database. If ``exclude_order`` is given, ``update`` is always switched off and transactions from the given order aren't taken into account. If ``include_reservations`` is ``True``, ``update`` is always switched off. """ queryset = self.filter(period=Period.objects.current(), product=product) if exclude_order: update = False queryset = queryset.filter(Q(order__isnull=True) | ~Q(order=exclude_order)) if include_reservations: update = False queryset = queryset.exclude( type=self.model.PAYMENT_PROCESS_RESERVATION, created__lt=timezone.now() - timedelta(seconds=15 * 60), ) else: queryset = queryset.exclude(type=self.model.PAYMENT_PROCESS_RESERVATION) count = queryset.aggregate(items=Sum("change")).get("items") or 0 product_model = plata.product_model() if isinstance(product, product_model): product.items_in_stock = count if update: product_model._default_manager.filter( id=getattr(product, "pk", product) ).update(items_in_stock=count) return count def bulk_create(self, order, type, negative, **kwargs): """ Create transactions in bulk for every order item Set ``negative`` to ``True`` for sales, lendings etc. (anything that diminishes the stock you have) """ # Set negative to True for sales, lendings etc. factor = negative and -1 or 1 for item in order.items.all(): self.model.objects.create( product=item.product, type=type, change=item.quantity * factor, order=order, name=item.name, sku=item.sku, line_item_price=item._line_item_price, line_item_discount=item._line_item_discount, line_item_tax=item._line_item_tax, **kwargs ) def current_period(): return Period.objects.current()
[docs]@python_2_unicode_compatible class StockTransaction(models.Model): """ Stores stock transactions transactionally :-) Stock transactions basically consist of a product variation reference, an amount, a type and a timestamp. The following types are available: - ``StockTransaction.INITIAL``: Initial amount, used when filling in the stock database - ``StockTransaction.CORRECTION``: Use this for any errors - ``StockTransaction.PURCHASE``: Product purchase from a supplier - ``StockTransaction.SALE``: Sales, f.e. through the webshop - ``StockTransaction.RETURNS``: Returned products (i.e. from lending) - ``StockTransaction.RESERVATION``: Reservations - ``StockTransaction.INCOMING``: Generic warehousing - ``StockTransaction.OUTGOING``: Generic warehousing - ``StockTransaction.PAYMENT_PROCESS_RESERVATION``: Product reservation during payment process Most of these types do not have a significance to Plata. The exceptions are: - ``INITIAL`` transactions are created by ``open_new_period`` - ``SALE`` transactions are created when orders are confirmed - ``PAYMENT_PROCESS_RESERVATION`` transactions are created by payment modules which send the user to a different domain for payment data entry (f.e. PayPal). These transactions are also special in that they are only valid for 15 minutes. After 15 minutes, other customers are able to put the product in their cart and proceed to checkout again. This time period is a security measure against customers buying products at the same time which cannot be delivered afterwards because stock isn't available. """ INITIAL = 10 CORRECTION = 20 PURCHASE = 30 SALE = 40 RETURNS = 50 RESERVATION = 60 # Generic warehousing INCOMING = 70 OUTGOING = 80 # Semi-internal use PAYMENT_PROCESS_RESERVATION = 100 # reservation during payment process TYPE_CHOICES = ( (INITIAL, _("initial amount")), (CORRECTION, _("correction")), (PURCHASE, _("purchase")), (SALE, _("sale")), (RETURNS, _("returns")), (RESERVATION, _("reservation")), (INCOMING, _("incoming")), (OUTGOING, _("outgoing")), (PAYMENT_PROCESS_RESERVATION, _("payment process reservation")), ) period = models.ForeignKey( Period, on_delete=models.CASCADE, default=current_period, related_name="stock_transactions", verbose_name=_("period"), ) created = models.DateTimeField(_("created"), default=timezone.now) product = models.ForeignKey( plata.settings.PLATA_SHOP_PRODUCT, related_name="stock_transactions", verbose_name=_("product"), on_delete=models.SET_NULL, null=True, ) type = models.PositiveIntegerField(_("type"), choices=TYPE_CHOICES) change = models.IntegerField( _("change"), help_text=_("Use negative numbers for sales, lendings and other" " outgoings."), ) order = models.ForeignKey( Order, on_delete=models.CASCADE, blank=True, null=True, related_name="stock_transactions", verbose_name=_("order"), ) payment = models.ForeignKey( OrderPayment, on_delete=models.CASCADE, blank=True, null=True, related_name="stock_transactions", verbose_name=_("order payment"), ) notes = models.TextField(_("notes"), blank=True) # There are purely informative fields; not required in any way # (but very useful for analysis down the road) name = models.CharField(_("name"), max_length=100, blank=True) sku = models.CharField(_("SKU"), max_length=100, blank=True) line_item_price = models.DecimalField( _("line item price"), max_digits=18, decimal_places=10, blank=True, null=True ) line_item_discount = models.DecimalField( _("line item discount"), max_digits=18, decimal_places=10, blank=True, null=True ) line_item_tax = models.DecimalField( _("line item tax"), max_digits=18, decimal_places=10, blank=True, null=True ) class Meta: # See https://github.com/matthiask/plata/issues/27 abstract = not plata.settings.PLATA_STOCK_TRACKING ordering = ["-id"] verbose_name = _("stock transaction") verbose_name_plural = _("stock transactions") objects = StockTransactionManager() def __str__(self): return "%s %s of %s" % (self.change, self.get_type_display(), self.product) def save(self, *args, **kwargs): if not self.period_id: self.period = Period.objects.current() if self.product and hasattr(self.product, "handle_stock_transaction"): self.product.handle_stock_transaction(self) super(StockTransaction, self).save(*args, **kwargs) save.alters_data = True
def update_items_in_stock(instance, **kwargs): StockTransaction.objects.items_in_stock(instance.product_id, update=True)
[docs]def validate_order_stock_available(order): """ Check whether enough stock is available for all selected products, taking into account payment process reservations. """ for item in order.items.select_related("product"): if item.quantity > StockTransaction.objects.items_in_stock( item.product, exclude_order=order, include_reservations=True ): raise ValidationError( _("Not enough stock available for %s.") % item.product, code="insufficient_stock", )