Skip to content

Commit

Permalink
Semi-automatic ordering of products from suppliers (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
kjagiello authored Mar 10, 2017
1 parent 7f20f80 commit f2ec78a
Show file tree
Hide file tree
Showing 17 changed files with 581 additions and 69 deletions.
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
81 changes: 68 additions & 13 deletions src/shop/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
from . import models, api, exceptions


def format_out_of_stock_forecast(forecast):
if forecast is None:
return None
fmt = '<span style="color: {};">{}</span>'
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
Expand All @@ -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',)
Expand Down Expand Up @@ -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<obj_id>.+)/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',)
Expand Down Expand Up @@ -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': (
Expand All @@ -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 = '<span style="color: {};">{}</span>'
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': (
Expand 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'
112 changes: 98 additions & 14 deletions src/shop/api.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
14 changes: 14 additions & 0 deletions src/shop/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
16 changes: 16 additions & 0 deletions src/shop/management/commands/update_supplier_products.py
Original file line number Diff line number Diff line change
@@ -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
)
21 changes: 21 additions & 0 deletions src/shop/migrations/0018_auto_20170228_1103.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
22 changes: 22 additions & 0 deletions src/shop/migrations/0019_auto_20170228_2012.py
Original file line number Diff line number Diff line change
@@ -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,
),
]
Loading

0 comments on commit f2ec78a

Please sign in to comment.