From f2ec78a7d6ae97def26078d3731e3b5de7b8299a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Jagie=C5=82=C5=82o?= Date: Fri, 10 Mar 2017 11:54:33 +0100 Subject: [PATCH] Semi-automatic ordering of products from suppliers (#30) --- requirements/base.txt | 2 +- src/shop/admin.py | 81 +++++++++-- src/shop/api.py | 112 +++++++++++++-- src/shop/enums.py | 14 ++ .../commands/update_supplier_products.py | 16 +++ .../migrations/0018_auto_20170228_1103.py | 21 +++ .../migrations/0019_auto_20170228_2012.py | 22 +++ .../migrations/0020_auto_20170305_1715.py | 33 +++++ .../migrations/0021_auto_20170307_1159.py | 22 +++ src/shop/models.py | 23 ++- src/shop/suppliers/base.py | 19 ++- src/shop/suppliers/narlivs.py | 22 ++- .../admin/shop/supplier/change_form.html | 11 ++ src/shop/tests/factories.py | 12 ++ src/shop/tests/test_admin.py | 57 ++++++++ src/shop/tests/test_api.py | 134 ++++++++++++++---- src/shop/tests/test_narlivs.py | 49 ++++++- 17 files changed, 581 insertions(+), 69 deletions(-) create mode 100644 src/shop/management/commands/update_supplier_products.py create mode 100644 src/shop/migrations/0018_auto_20170228_1103.py create mode 100644 src/shop/migrations/0019_auto_20170228_2012.py create mode 100644 src/shop/migrations/0020_auto_20170305_1715.py create mode 100644 src/shop/migrations/0021_auto_20170307_1159.py create mode 100644 src/shop/templates/admin/shop/supplier/change_form.html create mode 100644 src/shop/tests/test_admin.py diff --git a/requirements/base.txt b/requirements/base.txt index 0d1cae0..33d35a5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -13,7 +13,7 @@ six==1.9.0 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 +git+https://github.com/uppsaladatavetare/pynarlivs.git@1726025#egg=pynarlivs diff --git a/src/shop/admin.py b/src/shop/admin.py index 144acad..0231ef9 100644 --- a/src/shop/admin.py +++ b/src/shop/admin.py @@ -11,6 +11,14 @@ from . import models, api, exceptions +def format_out_of_stock_forecast(forecast): + if forecast is None: + return None + fmt = '{}' + color = 'red' if forecast <= date.today() else 'default' + return fmt.format(color, forecast) + + class ReadonlyMixin: def has_add_permission(self, request, obj=None): return False @@ -19,6 +27,29 @@ def has_delete_permission(self, request, obj=None): return False +@admin.register(models.BaseStockLevel) +class BaseStockLevelAdmin(admin.ModelAdmin): + list_display = ('product_name', 'level', 'product_qty', + 'out_of_stock_forecast',) + list_select_related = ('product',) + + def product_name(self, obj): + return obj.product.name + + def product_qty(self, obj): + return obj.product.qty + + def out_of_stock_forecast(self, obj): + return format_out_of_stock_forecast(obj.product.out_of_stock_forecast) + out_of_stock_forecast.short_description = _('out of stock forecast') + out_of_stock_forecast.allow_tags = True + out_of_stock_forecast.admin_order_field = 'product__out_of_stock_forecast' + + +class BaseStockLevelInline(admin.StackedInline): + model = models.BaseStockLevel + + class StocktakeItemInline(ReadonlyMixin, admin.TabularInline): model = models.StocktakeItem fields = ('product', 'category', 'qty',) @@ -209,6 +240,35 @@ def queryset(self, request, queryset): return queryset +@admin.register(models.Supplier) +class SupplierAdmin(admin.ModelAdmin): + list_display = ('name',) + + def order(self, request, obj_id): + try: + supplier = get_object_or_404(models.Supplier, id=obj_id) + supplier_products = api.order_refill(supplier.id) + msg = _('Added %d products to the cart at %s.') + count = len(supplier_products) + self.message_user(request, msg % (count, supplier.name)) + except exceptions.APIException as e: + self.message_user(request, str(e), messages.ERROR) + return HttpResponseRedirect( + reverse('admin:shop_supplier_change', args=(obj_id,)) + ) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + url( + r'^(?P.+)/order/$', + self.admin_site.admin_view(self.order), + name='supplier-order', + ), + ] + return custom_urls + urls + + @admin.register(models.SupplierProduct) class SupplierProductAdmin(admin.ModelAdmin): list_display = ('name', 'supplier', 'sku', 'product',) @@ -422,14 +482,13 @@ def has_delete_permission(self, request, obj=None): @admin.register(models.Product) class ProductAdmin(admin.ModelAdmin): - list_display = ('name', 'code', 'qty', 'price', 'active', - '_out_of_stock_forecast',) + list_display = ('name', 'code', 'qty', 'price', 'active',) list_filter = ('active', 'category',) search_fields = ('code', 'name',) readonly_fields = ('qty', 'date_created', 'date_modified', '_out_of_stock_forecast',) ordering = ('name',) - inlines = (ProductTransactionCreatorInline,) + inlines = (BaseStockLevelInline, ProductTransactionCreatorInline,) fieldsets = ( (None, { 'fields': ( @@ -452,16 +511,6 @@ class ProductAdmin(admin.ModelAdmin): }), ) - 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': ( @@ -477,3 +526,9 @@ class Media: 'js/thunderpush.js', 'js/scan-card.js', ) + + def _out_of_stock_forecast(self, obj): + return format_out_of_stock_forecast(obj.out_of_stock_forecast) + _out_of_stock_forecast.short_description = _('out of stock forecast') + _out_of_stock_forecast.allow_tags = True + _out_of_stock_forecast.admin_order_field = 'out_of_stock_forecast' diff --git a/src/shop/api.py b/src/shop/api.py index ac16a2d..e6e7fea 100644 --- a/src/shop/api.py +++ b/src/shop/api.py @@ -1,12 +1,15 @@ import logging import numpy as np +import math from itertools import accumulate -from datetime import date +from datetime import date, timedelta 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 django.utils import timezone from sklearn.svm import SVR +from .suppliers.base import SupplierAPIException from . import models, enums, suppliers, exceptions log = logging.getLogger(__name__) @@ -92,19 +95,20 @@ def list_categories(): @transaction.atomic -def get_supplier_product(supplier_id, sku): +def get_supplier_product(supplier_id, sku, refresh=False): """Returns supplier product for given SKU. If the product does not exist in the local database, fetch it from the supplier. """ - try: - return models.SupplierProduct.objects.get( - supplier_id=supplier_id, - sku=sku - ) - except models.SupplierProduct.DoesNotExist: - pass + if not refresh: + try: + return models.SupplierProduct.objects.get( + supplier_id=supplier_id, + sku=sku + ) + except models.SupplierProduct.DoesNotExist: + pass # Product has not been found in the database. Let's fetch it from # the supplier. @@ -115,11 +119,14 @@ def get_supplier_product(supplier_id, sku): log.warning('Product not found (sku: %s, supplier: %s', sku, supplier_id) return None - product_obj = models.SupplierProduct.objects.create( + product_obj, _ = models.SupplierProduct.objects.update_or_create( supplier_id=supplier_id, sku=sku, - price=product_data.price, - name=product_data.name + defaults={ + 'price': product_data.price, + 'name': product_data.name, + 'units': product_data.units, + } ) return product_obj @@ -248,7 +255,7 @@ def assign_free_stocktake_chunk(user_id, stocktake_id): @transaction.atomic -def predict_quantity(product_id, target): +def predict_quantity(product_id, target, current_date=None): """Predicts when a product will reach the target quantity.""" product_obj = models.Product.objects.get(id=product_id) if product_obj.qty <= target: @@ -275,6 +282,7 @@ def predict_quantity(product_id, target): # No data points to base the prediction on. return None + today_ordinal = (current_date or date.today()).toordinal() date_offset = trx_objs[0]['date'].toordinal() # At this point we want to generate data-points that we will feed into a @@ -306,13 +314,22 @@ def predict_quantity(product_id, target): # | y | 100 | 95 | 85 | 83 | 80 | # +---+-----+----+----+----+----+ # + # Also, in case of the stock quantity not changing for a couple of days + # we insert additional data point at the current's day offset x + # with the current quantity of the product. + # # 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 + + if today_ordinal not in x: + x.append(today_ordinal - date_offset) + y.append(0) + + x = np.asarray(x).reshape(-1, 1) y = np.asarray(list(accumulate(y))) # Fit the SVR model using above data. We rely here on the linear kernel as @@ -331,3 +348,70 @@ 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() + + +def order_from_supplier(product_id, qty, supplier_id=None): + """Orders the cheapest product from a supplier.""" + products = models.SupplierProduct.objects.filter(product_id=product_id) + + if supplier_id is not None: + products = products.filter(supplier_id=supplier_id) + + # A product can be associated with several different supplier products from + # the same supplier. Supplier products are also most often sold in batches, + # which means you usually cannot purchase the exact amount you need. + # Following section of the code takes care of calculating the minimum cost + # of each product while taking `qty` into account. It will then try to + # purchase the cheapest one and if not possible (for example out of stock), + # continue onto next one. + def minimum_qty(sp): + return math.ceil(qty / sp.qty) + + def cost(sp): + # Calculate the minimum quantity of the supplier product that needs to + # be purchased in order to reach `qty`. + return minimum_qty(sp) * sp.qty * sp.unit_price + + products = sorted(products, key=cost) + for product in products: + supplier = product.supplier + supplier_api = suppliers.get_supplier_api(supplier.internal_name) + try: + supplier_api.order_product(product.sku, minimum_qty(product)) + return product + except SupplierAPIException: + # Log the error and try the next product. + log.warning('Failed to order product SKU %d from %s.', + product.sku, supplier.internal_name) + else: + msg = 'Could not order {}.'.format(product.sku) + raise exceptions.APIException(msg) + + +def order_refill(supplier_id, current_date=None): + """Orders products that will run out of stock before the next delivery.""" + def next_weekday(d, weekday): + days_ahead = weekday - d.weekday() + if days_ahead <= 0: + days_ahead += 7 + return d + timedelta(days_ahead) + + today = current_date or timezone.now().date() + supplier = models.Supplier.objects.get(id=supplier_id) + delivery_weekday = supplier.delivers_on + first_delivery = next_weekday(today, delivery_weekday) + second_delivery = next_weekday(first_delivery, delivery_weekday) + # Get base stock levels for the products that will run out of the stock + # before the second delivery. + base_levels = models.BaseStockLevel.objects.filter( + product__out_of_stock_forecast__lt=second_delivery + ) + ordered = [] + for base_level in base_levels: + sp = order_from_supplier( + base_level.product.id, + base_level.level, + supplier_id=supplier.id + ) + ordered.append(sp) + return ordered diff --git a/src/shop/enums.py b/src/shop/enums.py index 5c97129..2c272d8 100644 --- a/src/shop/enums.py +++ b/src/shop/enums.py @@ -21,3 +21,17 @@ class TrxType(enum.Enum): class TrxStatus(enum.Enum): FINALIZED = 0 CANCELED = 1 + + +class Weekdays(enum.Enum): + MONDAY = 0 + TUESDAY = 1 + WEDNESDAY = 2 + THURSDAY = 3 + FRIDAY = 4 + SATURDAY = 5 + SUNDAY = 6 + + @classmethod + def choices(cls): + return [(x.value, x.name) for x in cls] diff --git a/src/shop/management/commands/update_supplier_products.py b/src/shop/management/commands/update_supplier_products.py new file mode 100644 index 0000000..f1ae0b6 --- /dev/null +++ b/src/shop/management/commands/update_supplier_products.py @@ -0,0 +1,16 @@ +import shop.api +from django.core.management.base import BaseCommand +from shop.models import SupplierProduct + + +class Command(BaseCommand): + def handle(self, *args, **options): + products = shop.api.list_products() + products = SupplierProduct.objects.all() + for product in products: + print('Updating {}...'.format(product)) + shop.api.get_supplier_product( + product.supplier.id, + product.sku, + refresh=True + ) diff --git a/src/shop/migrations/0018_auto_20170228_1103.py b/src/shop/migrations/0018_auto_20170228_1103.py new file mode 100644 index 0000000..fa7b448 --- /dev/null +++ b/src/shop/migrations/0018_auto_20170228_1103.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-28 11:03 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0017_auto_20170222_2204'), + ] + + operations = [ + migrations.AddField( + model_name='supplierproduct', + name='units', + field=models.SmallIntegerField(default=1), + ), + ] diff --git a/src/shop/migrations/0019_auto_20170228_2012.py b/src/shop/migrations/0019_auto_20170228_2012.py new file mode 100644 index 0000000..8063ab6 --- /dev/null +++ b/src/shop/migrations/0019_auto_20170228_2012.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-28 20:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0018_auto_20170228_1103'), + ] + + operations = [ + migrations.AddField( + model_name='supplier', + name='delivers_on', + field=models.SmallIntegerField(choices=[(0, 'MONDAY'), (1, 'TUESDAY'), (2, 'WEDNESDAY'), (3, 'THURSDAY'), (4, 'FRIDAY'), (5, 'SATURDAY'), (6, 'SUNDAY')], default=0), + preserve_default=False, + ), + ] diff --git a/src/shop/migrations/0020_auto_20170305_1715.py b/src/shop/migrations/0020_auto_20170305_1715.py new file mode 100644 index 0000000..c2a449b --- /dev/null +++ b/src/shop/migrations/0020_auto_20170305_1715.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-03-05 17:15 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0019_auto_20170228_2012'), + ] + + operations = [ + migrations.CreateModel( + name='BaseStockLevel', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('level', models.SmallIntegerField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='basestocklevel', + name='product', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='shop.Product'), + ), + ] diff --git a/src/shop/migrations/0021_auto_20170307_1159.py b/src/shop/migrations/0021_auto_20170307_1159.py new file mode 100644 index 0000000..3e2f374 --- /dev/null +++ b/src/shop/migrations/0021_auto_20170307_1159.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-03-07 11:59 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0020_auto_20170305_1715'), + ] + + operations = [ + migrations.AlterField( + model_name='supplierproduct', + name='qty_multiplier', + field=models.PositiveIntegerField(default=1, help_text='The quantity in the report will be multiplied by this value.', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity multiplier'), + ), + ] diff --git a/src/shop/models.py b/src/shop/models.py index 24e9f07..b92d334 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -4,6 +4,7 @@ from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.core.files.storage import FileSystemStorage +from django.core.validators import MinValueValidator from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey from bananas.models import TimeStampedModel, UUIDModel @@ -45,9 +46,18 @@ def generate_delivery_report_filename(instance, filename): ) +class BaseStockLevel(UUIDModel): + product = models.OneToOneField('Product') + level = models.SmallIntegerField('Base quantity') + + def __str__(self): + return self.product.name + + class Supplier(UUIDModel): name = models.CharField(max_length=32) internal_name = models.CharField(max_length=32) + delivers_on = models.SmallIntegerField(choices=enums.Weekdays.choices()) class Meta: verbose_name = _('supplier') @@ -75,11 +85,13 @@ class SupplierProduct(UUIDModel, TimeStampedModel): image = models.ImageField(blank=True, null=True, upload_to=generate_supplier_product_filename, storage=OverwriteFileSystemStorage()) + units = models.SmallIntegerField(default=1) qty_multiplier = models.PositiveIntegerField( verbose_name=_('Quantity multiplier'), help_text=_('The quantity in the report will be multiplied by this ' 'value.'), - default=1 + default=1, + validators=[MinValueValidator(0)] ) class Meta: @@ -87,6 +99,15 @@ class Meta: verbose_name_plural = _('supplier products') unique_together = ('supplier', 'sku',) + @property + def unit_price(self): + return self.price / self.qty_multiplier + + @property + def qty(self): + """Number of the units in a single package at the supplier.""" + return self.qty_multiplier * self.units + def __str__(self): return self.name diff --git a/src/shop/suppliers/base.py b/src/shop/suppliers/base.py index 02db8a9..c8a1946 100644 --- a/src/shop/suppliers/base.py +++ b/src/shop/suppliers/base.py @@ -3,7 +3,7 @@ DeliveryItem = namedtuple('DeliveryItem', ['sku', 'price', 'qty']) -SupplierProduct = namedtuple('SupplierProduct', ['name', 'price']) +SupplierProduct = namedtuple('SupplierProduct', ['name', 'price', 'units']) class SupplierAPIException(Exception): @@ -11,7 +11,12 @@ class SupplierAPIException(Exception): class SupplierBase(metaclass=ABCMeta): - """Defines the interface of a supplier module.""" + """Defines the interface of a supplier module. + + The mysterious SKU all over this class is abbreviation for Stock Keeping + Unit and in this context it is basically an unique identifier for every + product at supplier. + """ @abstractmethod def parse_delivery_report(self, report_path): @@ -32,3 +37,13 @@ def retrieve_product(self, sku): :type sku: str :rtype: Union[SupplierProduct, None] """ + + @abstractmethod + def order_product(self, sku, qty): + """Places an order on product with given SKU. + + :param sku: SKU of the product to be ordered. + :type sku: str + :param qty: Quantity. + :type qty: int + """ diff --git a/src/shop/suppliers/narlivs.py b/src/shop/suppliers/narlivs.py index 25fbe16..2904f2d 100644 --- a/src/shop/suppliers/narlivs.py +++ b/src/shop/suppliers/narlivs.py @@ -3,8 +3,9 @@ import subprocess import tempfile +import narlivs + from django.conf import settings -from narlivs import Narlivs from .base import ( DeliveryItem, @@ -45,7 +46,7 @@ def pdf_to_text(path): Depends on the external pdftotext command line tool. """ with tempfile.NamedTemporaryFile(mode='r') as f: - code = subprocess.call(['pdftotext', '-layout', path, f.name]) + code = subprocess.call(['pdftotext', '-q', '-layout', path, f.name]) if code != 0: return None return f.read() @@ -55,7 +56,7 @@ class SupplierAPI(SupplierBase): """Supplier API implementation for Axfood Närlivs.""" def __init__(self): - self.narlivs = Narlivs( + self.narlivs = narlivs.Narlivs( username=settings.NARLIVS_USERNAME, password=settings.NARLIVS_PASSWORD ) @@ -125,7 +126,14 @@ def parse_delivery_report(self, report_path): def retrieve_product(self, sku): data = self.narlivs.get_product(sku=sku).data - return SupplierProduct( - name=data['name'].title(), - price=data['price'] - ) + if data is not None: + return SupplierProduct( + name=data['name'].title(), + price=data['price'], + units=data['units'] + ) + return None + + def order_product(self, sku, qty): + for _ in range(qty): + self.narlivs.get_cart().add_product(sku) diff --git a/src/shop/templates/admin/shop/supplier/change_form.html b/src/shop/templates/admin/shop/supplier/change_form.html new file mode 100644 index 0000000..e380b53 --- /dev/null +++ b/src/shop/templates/admin/shop/supplier/change_form.html @@ -0,0 +1,11 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block object-tools-items %} +{% if not original.locked %} +
  • + {% trans 'Order from supplier' %} +
  • +{% endif %} +{{ block.super }} +{% endblock %} diff --git a/src/shop/tests/factories.py b/src/shop/tests/factories.py index 2572d33..2f49eb9 100644 --- a/src/shop/tests/factories.py +++ b/src/shop/tests/factories.py @@ -38,6 +38,8 @@ class SupplierFactory(factory.django.DjangoModelFactory): class Meta: model = models.Supplier + delivers_on = enums.Weekdays.MONDAY.value + class SupplierProductFactory(factory.django.DjangoModelFactory): class Meta: @@ -48,6 +50,8 @@ class Meta: name = factory.Sequence(lambda n: 'Product #{0}'.format(n)) sku = factory.Sequence(lambda n: '1{0:010d}'.format(n)) price = FuzzyMoney(10, 50) + qty_multiplier = 1 + units = 1 class DeliveryFactory(factory.django.DjangoModelFactory): @@ -88,3 +92,11 @@ class Meta: chunk = factory.SubFactory(StocktakeChunkFactory) product = factory.SubFactory(ProductFactory) qty = factory.fuzzy.FuzzyInteger(1, 50) + + +class BaseStockLevel(factory.django.DjangoModelFactory): + class Meta: + model = models.BaseStockLevel + + product = factory.SubFactory(ProductFactory) + level = factory.fuzzy.FuzzyInteger(24, 48) diff --git a/src/shop/tests/test_admin.py b/src/shop/tests/test_admin.py new file mode 100644 index 0000000..1caa3b8 --- /dev/null +++ b/src/shop/tests/test_admin.py @@ -0,0 +1,57 @@ +from unittest import mock + +from django.contrib import messages +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.test import TestCase + +from .. import exceptions +from . import factories + + +class SupplierAdminTest(TestCase): + TESTUSER_NAME = 'the_baconator' + TESTUSER_PASS = '123' + + def setUp(self): + self.user = User.objects.create_superuser( + self.TESTUSER_NAME, + 'bacon@foobar.com', + self.TESTUSER_PASS + ) + self.client.login( + username=self.TESTUSER_NAME, + password=self.TESTUSER_PASS + ) + + @mock.patch('shop.api.order_refill') + def test_order(self, mock_order_refill): + supplier = factories.SupplierFactory() + sp = factories.SupplierProductFactory(supplier=supplier) + url = reverse('admin:supplier-order', args=(supplier.id,)) + + # Let's try the error handling + mock_order_refill.side_effect = exceptions.APIException('error') + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + response_messages = list(response.context['messages']) + self.assertEqual(len(response_messages), 1) + self.assertEqual(response_messages[0].level, messages.ERROR) + self.assertRedirects( + response, + reverse('admin:shop_supplier_change', args=(supplier.id,)) + ) + + mock_order_refill.reset_mock() + mock_order_refill.side_effect = None + mock_order_refill.return_value = [sp] + response = self.client.get(url, follow=True) + mock_order_refill.assert_called_once_with(supplier.id) + self.assertEqual(response.status_code, 200) + response_messages = list(response.context['messages']) + self.assertEqual(len(response_messages), 1) + self.assertEqual(response_messages[0].level, messages.INFO) + self.assertRedirects( + response, + reverse('admin:shop_supplier_change', args=(supplier.id,)) + ) diff --git a/src/shop/tests/test_api.py b/src/shop/tests/test_api.py index 95ded3f..de3a058 100644 --- a/src/shop/tests/test_api.py +++ b/src/shop/tests/test_api.py @@ -1,4 +1,4 @@ -from datetime import datetime, date +from datetime import date, datetime from decimal import Decimal from unittest import mock @@ -8,29 +8,16 @@ from django.contrib.auth.models import User from django.utils import timezone -from ..suppliers.base import SupplierBase, DeliveryItem, SupplierProduct +from ..suppliers.base import ( + DeliveryItem, + SupplierAPIException, + SupplierProduct +) from .. import api, models, enums, exceptions from .models import DummyModel from . import factories -class DummySupplierAPI(SupplierBase): - def parse_delivery_report(self, report_path): - return [ - DeliveryItem( - sku='101176931', - qty=20, - price=Decimal('9.25') - ) - ] - - def retrieve_product(self, sku): - return SupplierProduct( - name='Billys Original', - price=Decimal('9.25') - ) - - class ShopAPITest(TestCase): def test_create_product(self): product_obj = api.create_product(code='1234567812345', name='Banana') @@ -116,9 +103,7 @@ def test_list_products(self): objs = api.list_products(name__startswith='Billys') self.assertEqual(len(objs), 1) - @mock.patch('shop.suppliers.get_supplier_api') - def test_get_supplier_product_existing(self, mock_get_supplier_api): - mock_get_supplier_api.return_value = DummySupplierAPI() + def test_get_supplier_product_existing(self): supplier_obj = factories.SupplierFactory.create() factories.SupplierProductFactory.create( supplier=supplier_obj, @@ -133,7 +118,12 @@ def test_get_supplier_product_existing(self, mock_get_supplier_api): @mock.patch('shop.suppliers.get_supplier_api') def test_get_supplier_product_non_existing(self, mock_get_supplier_api): - mock_get_supplier_api.return_value = DummySupplierAPI() + m = mock_get_supplier_api.return_value = mock.MagicMock() + m.retrieve_product.return_value = SupplierProduct( + name='Billys Original', + price=Decimal('9.25'), + units=1 + ) supplier_obj = factories.SupplierFactory.create() product_obj = api.get_supplier_product(supplier_obj.id, '101176931') self.assertIsNotNone(product_obj) @@ -142,7 +132,14 @@ def test_get_supplier_product_non_existing(self, mock_get_supplier_api): @mock.patch('shop.suppliers.get_supplier_api') def test_populate_delivery(self, mock_get_supplier_api): - mock_get_supplier_api.return_value = DummySupplierAPI() + m = mock_get_supplier_api.return_value = mock.MagicMock() + m.parse_delivery_report.return_value = [ + DeliveryItem( + sku='101176931', + qty=20, + price=Decimal('9.25') + ) + ] supplier_obj = factories.SupplierFactory.create() factories.SupplierProductFactory.create( supplier=supplier_obj, @@ -334,7 +331,8 @@ def test_predict_quantity(self): ] product = factories.ProductFactory.create() # No product transactions yet - timestamp = api.predict_quantity(product.id, product.qty - 1) + timestamp = api.predict_quantity(product.id, product.qty - 1, + current_date=date(2016, 11, 18)) self.assertIsNone(timestamp) factories.ProductTrxFactory.create( product=product, @@ -343,7 +341,8 @@ def test_predict_quantity(self): date_created=timezone.make_aware(initial_timestamp) ) # Product restocked, but no purchases made yet - timestamp = api.predict_quantity(product.id, 0) + timestamp = api.predict_quantity(product.id, 0, + current_date=date(2016, 11, 18)) self.assertIsNone(timestamp) for qty, timestamp in trx_data: factories.ProductTrxFactory.create( @@ -353,9 +352,11 @@ def test_predict_quantity(self): trx_type=enums.TrxType.PURCHASE ) timestamp = api.predict_quantity(product.id, - target=product.qty) + target=product.qty, + current_date=date(2016, 11, 18)) self.assertIsNone(timestamp) - timestamp = api.predict_quantity(product.id, 0) + timestamp = api.predict_quantity(product.id, 0, + current_date=date(2016, 11, 18)) self.assertEqual(timestamp, date(2016, 11, 30)) def test_predict_quantity_non_decreasing_function(self): @@ -391,3 +392,80 @@ def test_update_quantity_prediction(self, predict_quantity_mock): 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)) + + @mock.patch('shop.suppliers.get_supplier_api') + def test_order_from_supplier(self, mock_get_supplier_api): + mock_supplier_api = mock.MagicMock() + mock_get_supplier_api.return_value = mock_supplier_api + mock_order_product = mock_supplier_api.order_product + supplier1 = factories.SupplierFactory() + supplier2 = factories.SupplierFactory() + product1 = factories.ProductFactory() + product2 = factories.ProductFactory() + sp1 = factories.SupplierProductFactory( + product=product1, + supplier=supplier1, + price=5, + units=64 + ) + sp2 = factories.SupplierProductFactory( + product=product1, + supplier=supplier1, + price=10, + units=30 + ) + sp3 = factories.SupplierProductFactory( + product=product1, + supplier=supplier1, + price=40, + qty_multiplier=10, + units=1 + ) + factories.SupplierProductFactory( + product=product2, + supplier=supplier2, + price=1, + units=1 + ) + sp5 = api.order_from_supplier(product1.id, 48) + mock_order_product.assert_called_once_with(sp3.sku, 5) + self.assertEqual(sp3.id, sp5.id) + # Let's break the supplier API and see what happens. + mock_order_product.reset_mock() + mock_order_product.side_effect = SupplierAPIException + with self.assertRaises(exceptions.APIException): + api.order_from_supplier(product1.id, 48) + mock_order_product.assert_has_calls([ + mock.call(sp3.sku, 5), + mock.call(sp1.sku, 1), + mock.call(sp2.sku, 2), + ]) + + @mock.patch('shop.api.order_from_supplier') + def test_order_refill(self, mock_order_from_supplier): + current_date = date(2017, 3, 2) + product1 = factories.ProductFactory( + qty=15, + out_of_stock_forecast=date(2017, 3, 8) + ) + product2 = factories.ProductFactory( + qty=10, + out_of_stock_forecast=date(2017, 3, 15) + ) + factories.BaseStockLevel(product=product1, level=48) + factories.BaseStockLevel(product=product2, level=32) + supplier = factories.SupplierFactory( + delivers_on=enums.Weekdays.WEDNESDAY.value + ) + factories.SupplierProductFactory( + supplier=supplier, + product=product1 + ) + factories.SupplierProductFactory( + supplier=supplier, + product=product2 + ) + api.order_refill(supplier.id, current_date) + mock_order_from_supplier.assert_has_calls([ + mock.call(product1.id, 48, supplier_id=supplier.id) + ]) diff --git a/src/shop/tests/test_narlivs.py b/src/shop/tests/test_narlivs.py index 18445b3..6377edd 100644 --- a/src/shop/tests/test_narlivs.py +++ b/src/shop/tests/test_narlivs.py @@ -1,14 +1,14 @@ +import decimal +from unittest import mock from os.path import join, dirname, abspath from django.test import TestCase from ..suppliers import get_supplier_api, narlivs +from ..suppliers.base import SupplierAPIException TESTDATA_DIR = join(dirname(abspath(__file__)), 'data') class NarlivsTest(TestCase): - def setUp(self): - self.api = get_supplier_api('narlivs') - def report_path(self, name): return join(TESTDATA_DIR, name) @@ -16,6 +16,20 @@ def test_pdf_to_text(self): text = narlivs.pdf_to_text(self.report_path('delivery_report.pdf')) self.assertTrue('001337' in text) + def test_receive_missing_delivery(self): + # Let's parse a missing file + with self.assertRaises(SupplierAPIException): + api = get_supplier_api('narlivs') + api.parse_delivery_report('404notfound') + + def test_receive_invalid_delivery(self): + # Let's parse a file with invalid format + with mock.patch('shop.suppliers.narlivs.pdf_to_text') as m: + api = get_supplier_api('narlivs') + m.return_value = 'bacon' + with self.assertRaises(SupplierAPIException): + api.parse_delivery_report('404notfound') + def test_receive_delivery(self): api = get_supplier_api('narlivs') path = self.report_path('delivery_report.pdf') @@ -24,6 +38,35 @@ def test_receive_delivery(self): for item in items: self.assertEqual(len(item.sku), 9) + @mock.patch('narlivs.Narlivs.get_product') + def test_retrieve_product(self, mock_narlivs): + m = mock_narlivs.return_value = mock.MagicMock() + m.data = { + 'name': 'GOOD KEBABA', + 'price': decimal.Decimal('13.37'), + 'units': 2 + } + api = get_supplier_api('narlivs') + product = api.retrieve_product('1337') + self.assertIsNotNone(product) + self.assertEqual(product.name, 'Good Kebaba') + self.assertEqual(product.price, decimal.Decimal('13.37')) + self.assertEqual(product.units, 2) + + # Let's try now to fetch a missing product + mock_narlivs.return_value.data = None + product = api.retrieve_product('1337') + self.assertIsNone(product) + + @mock.patch('narlivs.Narlivs.get_cart') + def test_order_product(self, mock_get_cart): + api = get_supplier_api('narlivs') + api.order_product('1337', 2) + mock_get_cart.return_value.add_product.assert_has_calls([ + mock.call('1337'), + mock.call('1337') + ]) + def test_receive_delivery2(self): api = get_supplier_api('narlivs') path = self.report_path('delivery_report2.pdf')