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')