diff --git a/requirements/base.txt b/requirements/base.txt index 3855081..0d1cae0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -14,3 +14,6 @@ requests==2.8.1 pytz==2015.7 raven==5.8.1 pynarlivs==0.9.0 +numpy==1.12.0 +scipy==0.18.1 +scikit-learn==0.18.1 diff --git a/src/shop/admin.py b/src/shop/admin.py index 6d25a84..144acad 100644 --- a/src/shop/admin.py +++ b/src/shop/admin.py @@ -1,4 +1,5 @@ import tempfile +from datetime import date from django import forms from django.shortcuts import get_object_or_404 from django.contrib import admin, messages @@ -421,10 +422,12 @@ def has_delete_permission(self, request, obj=None): @admin.register(models.Product) class ProductAdmin(admin.ModelAdmin): - list_display = ('name', 'code', 'qty', 'price', 'active',) + list_display = ('name', 'code', 'qty', 'price', 'active', + '_out_of_stock_forecast',) list_filter = ('active', 'category',) search_fields = ('code', 'name',) - readonly_fields = ('qty', 'date_created', 'date_modified',) + readonly_fields = ('qty', 'date_created', 'date_modified', + '_out_of_stock_forecast',) ordering = ('name',) inlines = (ProductTransactionCreatorInline,) fieldsets = ( @@ -444,10 +447,21 @@ class ProductAdmin(admin.ModelAdmin): 'qty', 'date_created', 'date_modified', + '_out_of_stock_forecast' ) }), ) + def _out_of_stock_forecast(self, obj=None): + if obj is None or obj.out_of_stock_forecast is None: + return None + fmt = '{}' + forecast = obj.out_of_stock_forecast + color = 'red' if forecast <= date.today() else 'default' + return fmt.format(color, forecast) + _out_of_stock_forecast.allow_tags = True + _out_of_stock_forecast.admin_order_field = 'out_of_stock_forecast' + class Media: css = { 'all': ( diff --git a/src/shop/api.py b/src/shop/api.py index 14c053a..ac16a2d 100644 --- a/src/shop/api.py +++ b/src/shop/api.py @@ -1,6 +1,12 @@ import logging +import numpy as np +from itertools import accumulate +from datetime import date from django.db import transaction +from django.db.models import Sum +from django.db.models.functions import TruncDay from django.contrib.contenttypes.models import ContentType +from sklearn.svm import SVR from . import models, enums, suppliers, exceptions log = logging.getLogger(__name__) @@ -239,3 +245,89 @@ def assign_free_stocktake_chunk(user_id, stocktake_id): chunk_obj.owner_id = user_id chunk_obj.save() return chunk_obj + + +@transaction.atomic +def predict_quantity(product_id, target): + """Predicts when a product will reach the target quantity.""" + product_obj = models.Product.objects.get(id=product_id) + if product_obj.qty <= target: + # No prediction if already at the target quantity. + return None + + # Find the last restock transaction + qs = product_obj.transactions.finalized() + restock_trx = qs.restocks().order_by('-date_created').first() + if restock_trx is None: + # The product has never been restocked. + return None + + initial_qty = qs \ + .filter(date_created__lte=restock_trx.date_created) \ + .aggregate(qty=Sum('qty'))['qty'] or 0 + trx_objs = qs \ + .filter(date_created__gt=restock_trx.date_created) \ + .annotate(date=TruncDay('date_created')) \ + .values('date') \ + .annotate(aggregated_qty=Sum('qty')) \ + .values('date', 'aggregated_qty') + if not trx_objs: + # No data points to base the prediction on. + return None + + date_offset = trx_objs[0]['date'].toordinal() + + # At this point we want to generate data-points that we will feed into a + # Epsilon-Support Vector Regression model. Initially, the data-points + # look like following: + # + # +---+----+-----+----+----+ + # | x | 0 | 1 | 2 | 4 | + # +---+----+-----+----+----+ + # | y | -5 | -10 | -2 | -3 | + # +---+----+-----+----+----+ + # + # We want however to include the initial quantity and we do that by adding + # it at x = -1: + # + # +---+-----+----+-----+----+----+ + # | x | -1 | 0 | 1 | 2 | 4 | + # +---+-----+----+-----+----+----+ + # | y | 100 | -5 | -10 | -2 | -3 | + # +---+-----+----+-----+----+----+ + # + # In the final step, we want to convert all the values at x >= 0 into + # actuall quantity levels, not just differences, so the data looks like + # this: + # + # +---+-----+----+----+----+----+ + # | x | -1 | 0 | 1 | 2 | 4 | + # +---+-----+----+----+----+----+ + # | y | 100 | 95 | 85 | 83 | 80 | + # +---+-----+----+----+----+----+ + # + # The cryptic code below does just that. + x = [trx_obj['date'].toordinal() - date_offset for trx_obj in trx_objs] + x = [-1] + x + x = np.asarray(x).reshape(-1, 1) + + y = [trx_obj['aggregated_qty'] for trx_obj in trx_objs] + y = [initial_qty] + y + y = np.asarray(list(accumulate(y))) + + # Fit the SVR model using above data. We rely here on the linear kernel as + # our experiments showed that that gave the best results. + svr = SVR(kernel='linear', C=1e2) + svr.fit(x, y) + if svr.coef_ >= 0: + # The function is non-decreasing, so no prediction can be made. + return None + days = (-initial_qty / svr.coef_).astype(int).item() + return date.fromordinal(date_offset + days) + + +@transaction.atomic +def update_out_of_stock_forecast(product_id): + product_obj = models.Product.objects.get(id=product_id) + product_obj.out_of_stock_forecast = predict_quantity(product_id, target=0) + product_obj.save() diff --git a/src/shop/management/commands/__init__.py b/src/shop/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shop/management/commands/predict_out_of_stock.py b/src/shop/management/commands/predict_out_of_stock.py new file mode 100644 index 0000000..a4d0621 --- /dev/null +++ b/src/shop/management/commands/predict_out_of_stock.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand +import shop.api + + +class Command(BaseCommand): + def handle(self, *args, **options): + products = shop.api.list_products() + for product in products: + shop.api.update_out_of_stock_forecast(product.id) diff --git a/src/shop/migrations/0017_auto_20170222_2204.py b/src/shop/migrations/0017_auto_20170222_2204.py new file mode 100644 index 0000000..37ef6ba --- /dev/null +++ b/src/shop/migrations/0017_auto_20170222_2204.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-22 22:04 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0016_auto_20170214_0749'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='out_of_stock_forecast', + field=models.DateField(blank=True, null=True), + ) + ] diff --git a/src/shop/models.py b/src/shop/models.py index bc35be3..24e9f07 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -254,6 +254,7 @@ class Product(UUIDModel, TimeStampedModel): currency_choices=settings.CURRENCY_CHOICES ) active = models.BooleanField(default=True) + out_of_stock_forecast = models.DateField(blank=True, null=True) # cached quantity qty = models.IntegerField(verbose_name=_('quantity'), default=0) diff --git a/src/shop/querysets.py b/src/shop/querysets.py index b99dc50..8e95817 100644 --- a/src/shop/querysets.py +++ b/src/shop/querysets.py @@ -11,5 +11,11 @@ class ProductTrxQuerySet(models.QuerySet): def finalized(self): return self.filter(trx_status=enums.TrxStatus.FINALIZED) + def restocks(self): + return self.filter(trx_type__in=[ + enums.TrxType.INVENTORY, + enums.TrxType.CORRECTION + ]) + def quantity(self): return self.finalized().aggregate(qty=models.Sum('qty'))['qty'] or 0 diff --git a/src/shop/tests/test_api.py b/src/shop/tests/test_api.py index 4615796..95ded3f 100644 --- a/src/shop/tests/test_api.py +++ b/src/shop/tests/test_api.py @@ -1,10 +1,12 @@ -from unittest import mock +from datetime import datetime, date from decimal import Decimal +from unittest import mock from moneyed import Money from django.test import TestCase from django.contrib.auth.models import User +from django.utils import timezone from ..suppliers.base import SupplierBase, DeliveryItem, SupplierProduct from .. import api, models, enums, exceptions @@ -320,3 +322,72 @@ def test_assign_free_stocktake_chunk(self): # Work is finished obj5 = api.assign_free_stocktake_chunk(user_obj1.id, stocktake_obj.id) self.assertIsNone(obj5) + + def test_predict_quantity(self): + initial_qty = 100 + initial_timestamp = datetime(2016, 11, 14, 0) + trx_data = [ + (-5, datetime(2016, 11, 15, 0)), + (-10, datetime(2016, 11, 16, 0)), + (-5, datetime(2016, 11, 18, 0)), + (-5, datetime(2016, 11, 18, 1)), + ] + product = factories.ProductFactory.create() + # No product transactions yet + timestamp = api.predict_quantity(product.id, product.qty - 1) + self.assertIsNone(timestamp) + factories.ProductTrxFactory.create( + product=product, + qty=initial_qty, + trx_type=enums.TrxType.INVENTORY, + date_created=timezone.make_aware(initial_timestamp) + ) + # Product restocked, but no purchases made yet + timestamp = api.predict_quantity(product.id, 0) + self.assertIsNone(timestamp) + for qty, timestamp in trx_data: + factories.ProductTrxFactory.create( + product=product, + qty=qty, + date_created=timezone.make_aware(timestamp), + trx_type=enums.TrxType.PURCHASE + ) + timestamp = api.predict_quantity(product.id, + target=product.qty) + self.assertIsNone(timestamp) + timestamp = api.predict_quantity(product.id, 0) + self.assertEqual(timestamp, date(2016, 11, 30)) + + def test_predict_quantity_non_decreasing_function(self): + initial_qty = 100 + initial_timestamp = datetime(2016, 11, 14, 0) + trx_data = [ + (-5, datetime(2016, 11, 15, 0)), + (+5, datetime(2016, 11, 15, 0)), + ] + product = factories.ProductFactory.create() + factories.ProductTrxFactory.create( + product=product, + qty=initial_qty, + trx_type=enums.TrxType.INVENTORY, + date_created=timezone.make_aware(initial_timestamp) + ) + for qty, timestamp in trx_data: + factories.ProductTrxFactory.create( + product=product, + qty=qty, + date_created=timezone.make_aware(timestamp), + trx_type=enums.TrxType.PURCHASE + ) + # Product restocked, but no purchases made yet + timestamp = api.predict_quantity(product.id, 0) + self.assertIsNone(timestamp) + + @mock.patch('shop.api.predict_quantity') + def test_update_quantity_prediction(self, predict_quantity_mock): + product = factories.ProductFactory.create() + predict_quantity_mock.return_value = date(1337, 1, 1) + api.update_out_of_stock_forecast(product.id) + predict_quantity_mock.assert_called_once_with(product.id, target=0) + product.refresh_from_db() + self.assertEqual(product.out_of_stock_forecast, date(1337, 1, 1))