...
 
Commits (8)
......@@ -99,6 +99,10 @@ apipatterns = patterns("",
url(r'^api/sell/(?P<pk>\d+)/undo$', 'search.models.api.sell_undo', name="api_sell_undo"),
url(r'^api/sell$', 'search.models.api.sell', name="api_sell"),
# Restocking
url(r'^api/restocking/nb_ongoing/?$', 'search.models.api.nb_restocking_ongoing', name="api_restocking_nb_ongoing"),
url(r'^api/restocking/validate/?$', 'search.models.api.restocking_validate', name="api_restocking_validate"),
# Sells history
url(r'^api/history/sells/?$', 'search.models.api.history_sells', name="api_history_sells"),
url(r'^api/history/entries/?$', 'search.models.api.history_entries', name="api_history_entries"),
......
......@@ -28,6 +28,7 @@ from search.models import Inventory
from search.models import Place
from search.models import PlaceCopies
from search.models import Publisher
from search.models import RestockingCopies
from search.models import Sell
# Custom admin for the client admin:
......@@ -49,7 +50,7 @@ class CardAdmin(admin.ModelAdmin):
return super(CardAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
search_fields = ["title", "authors__name"]
search_fields = ["title", "authors__name", "isbn"]
list_display = ("title", "distributor", "price",)
list_editable = ("distributor", "price",)
filter_horizontal = ("authors", "publishers",)
......@@ -123,6 +124,7 @@ admin.site.register(Inventory, InventoryAdmin)
admin.site.register(Place)
admin.site.register(PlaceCopies)
admin.site.register(Publisher, PublisherAdmin)
admin.site.register(RestockingCopies)
admin.site.register(Sell)
admin_site = MyAdmin(name='myadmin')
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('search', '0063_auto_20191112_1835'),
]
operations = [
migrations.CreateModel(
name='Restocking',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
],
),
migrations.CreateModel(
name='RestockingCopies',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('quantity', models.IntegerField(default=0)),
('card', models.ForeignKey(to='search.Card')),
('restocking', models.ForeignKey(to='search.Restocking')),
],
),
]
......@@ -46,6 +46,7 @@ from models import Inventory
from models import Place
from models import Preferences
from models import Publisher
from models import Restocking
from models import Sell
from models import Shelf
from models import SoldCards
......@@ -723,6 +724,25 @@ def sell_undo(request, pk, **response_kwargs):
return JsonResponse(to_ret)
def restocking_validate(request):
"""
Validate the restocking list, with ids given as POST parameters if any.
"""
msgs = Messages()
status = True
to_ret = {'status': status, 'alerts': []}
if request.method == 'POST':
params = json.loads(request.body)
if params:
ids = params.get('ids')
if ids:
cards = Card.objects.filter(id__in=ids)
Restocking.validate(cards=cards)
msgs.add_success(_("Cards moved with success"))
to_ret['alerts'] = msgs.msgs
return JsonResponse(to_ret)
TO_RET = {"status": ALERT_SUCCESS,
"alerts": [],
"data": []}
......@@ -1815,6 +1835,15 @@ def nb_commands_ongoing(request, **kwargs):
}
return JsonResponse(to_ret)
def nb_restocking_ongoing(request, **kwargs):
if request.method == 'GET':
nb = Restocking.nb_ongoing()
to_ret = {
'status': ALERT_SUCCESS,
'data': nb,
}
return JsonResponse(to_ret)
def commands_ongoing(request, **kwargs):
if request.method == 'GET':
res = Command.ongoing(to_dict=True) or []
......
......@@ -1071,11 +1071,16 @@ class Card(TimeStampedModel):
def sell(id=None, quantity=1, place_id=None, place=None, silence=False):
"""Sell a card. Decreases its quantity in the given place.
If it is not present anymore in the selling place (aka in its
shelf), add it to the restocking list. If it is not present in
stock, add it to the command list (the autocommand basket).
This is a static method, use it like this:
>>> Card.sell(id=<id>)
:param int id: the id of the card to sell.
return: a tuple (return_code, "message")
"""
msgs = Messages()
try:
......@@ -1109,11 +1114,12 @@ class Card(TimeStampedModel):
return (None, MSG_INTERNAL_ERROR) # xxx to be propagated
else:
# Take the first place this card is present in.
# Take the first selling place this card is present in.
if card.placecopies_set.count():
# XXX: get the default place
# fix also the undo().
place_copy = card.placecopies_set.first()
place_copy = card.placecopies_set.filter(place__can_sell=True).first()
place_obj = place_copy.place
else:
place_obj = Preferences.get_default_place()
place_copy, created = place_obj.placecopies_set.get_or_create(card=card)
......@@ -1133,10 +1139,16 @@ class Card(TimeStampedModel):
except Exception as e:
log.error(u"Error selling a card: {}.".format(e))
# Didn't return an error message, returned OK !
return (None, _("Internal error, sorry."))
if card.quantity <= 0:
remaining_quantity = card.quantity
if remaining_quantity <= card.threshold:
Basket.add_to_auto_command(card)
# Possibly add to the restocking list.
if card.quantity_selling_places() <= 0 and card.quantity_reserve() >= 1:
Restocking.add_card(card)
return msgs.status, msgs.msgs
def sell_undo(self, quantity=1, place_id=None, place=None, deposit=None):
......@@ -1528,6 +1540,29 @@ class Card(TimeStampedModel):
"""
return sum(self.depositstatecopies_set.all().values_list('nb_current', flat=True))
def quantity_selling_places(self):
"""
Return the quantity in selling places (aka, the quantity remaining
in the shelves, not in the stock).
"""
return sum(self.placecopies_set.filter(place__can_sell=True).values_list('nb', flat=True))
def quantity_reserve(self):
"""
Return the quantity in no-selling places (aka, the stock)
"""
return sum(self.placecopies_set.filter(place__can_sell=False).values_list('nb', flat=True))
def quantity_to_restock(self):
"""
Return the quantity that ideally we want to move from the stock place to the selling place.
"""
res = 1 - self.quantity_selling_places()
# Shall we check that this number is not superior to the
# quantity available in the stock place?
# This should not happen since we re-filter the restocking list.
return res
@property
def deposits(self):
"""
......@@ -2273,6 +2308,171 @@ class BasketType (models.Model):
def __unicode__(self):
return u"{}".format(self.name)
class RestockingCopies(models.Model):
"""
Cards present in the restocking list with their quantities (intermediate table).
"""
card = models.ForeignKey("Card", blank=True, null=True)
restocking = models.ForeignKey("Restocking")
quantity = models.IntegerField(default=0)
def __unicode__(self):
return u"Restocking: %s copies of %s" % (self.quantity, self.card.title)
def to_dict(self):
"""
Card representation and its quantity in the list.
Return: a dict, with the added 'list_qty'.
"""
card = []
try:
card = self.card.to_dict()
card['list_qty'] = self.quantity
except Exception as e:
log.error(e)
return card
class Restocking(models.Model):
"""A list of cards to move from the stock place to their shelves.
All cards here are candidates to be moved. We can move a selection of them.
They are taken out of this list with an InternalMovement.
The movement procedure can detect inaccuracies in the stock. The
stock indeed can change between the card's entry in the list and
the moment the user does the restocking (another user might have
done another movement).
Likewise, the user is able to correct stock errors (s)he sees in
the stock: if he sees that there actually remains one exemplary in
the shelf, he can remove the card from this list.
He must be able to set the shelf target easily.
"""
# copies = models.OneToManyField(Card, through="RestockingCopies", blank=True)
# Has a many-to-one relationship with RestockingCopies.
pass
def get_absolute_url(self):
return "/restocking/"
@staticmethod
def get_or_create():
restock = Restocking.objects.first()
if not restock:
restock = Restocking()
restock.save()
return restock
def to_dict(self):
return {
"id": self.id,
"length": self.copies.count(),
}
@staticmethod
def cards():
"""
Return the list of cards that we need to move from the stock place to the selling one.
We must re-filter the list, it is possible that cards where moved since
they were added to the restocking list.
"""
restock = Restocking.get_or_create()
copies = restock.restockingcopies_set.all()
# cards = [it.card for it in copies]
res = []
for copy in copies:
if copy.card.quantity_selling_places() <= 0:
res.append(copy.card)
else:
copy.delete()
return res
@staticmethod
def add_card(card):
try:
restock = Restocking.get_or_create()
copies, created = restock.restockingcopies_set.get_or_create(card=card)
copies.quantity += 1
copies.save()
except Exception, e:
log.error(u"Error while adding '%s' to the list of restocking" % (card.title))
log.error(e)
return 0
return copies.quantity
@staticmethod
def remove_card(card):
"""
Remove this card from the restocking list.
If all went well, return True.
"""
# TODO:
try:
restock = Restocking.objects.first()
place_copy, created = restock.restockingcopies_set.get_or_create(card=card)
place_copy.delete()
except Exception, e:
log.error(u"Error while removing %s to the restocking list" % (card.title))
log.error(e)
return False
return True
@staticmethod
def nb_ongoing():
"""
Total quantity of cards in the restocking list.
Return: int (None on error)
"""
try:
restock = Restocking.get_or_create()
return restock.restockingcopies_set.count()
except Exception as e:
log.error(u"Error getting the total quantities in the restocking list: {}".format(e))
@staticmethod
def validate(cards=None):
"""
Validate the current list: create a movement.
If a list of cards is given, move only these ones and leave the others.
"""
restock = Restocking.get_or_create()
if not cards:
cards = restock.cards()
# To create the internal movement,
# we currently support one stock place and one selling place.
# It could be customized for each card.
origin = Place.objects.filter(can_sell=False).first()
dest = Place.objects.filter(can_sell=True).first()
for card in cards:
copy = restock.restockingcopies_set.filter(card=card).first()
# filter VS get: when we re-run the script (manual
# testing, it is possible that a card has already been
# removed from the list.
if copy:
copy.delete()
# So, don't create the movement twice.
# We currently can not edit the moved quantity.
# TODO: but it can differ on the page :S
mvt = history.InternalMovement(origin=origin, dest=dest, card=card, nb=1)
mvt.save()
return True
class DepositStateCopies(models.Model):
"""For each card of the deposit state, remember:
......
......@@ -72,6 +72,10 @@
background: #f8fb3d;
}
.my-strike-through {
text-decoration: line-through;
}
/* Make anchors appear below the fixed navbar */
.myanchor {
padding-top: 75px;
......
......@@ -48,6 +48,8 @@ from search.models import Place
from search.models import PlaceCopies
from search.models import Preferences
from search.models import Publisher
from search.models import Restocking
from search.models import RestockingCopies
from search.models import Sell
from search.models import Shelf
from search.models import SoldCards
......@@ -1149,6 +1151,27 @@ class TestSells(TestCase):
sell.undo()
self.assertEqual(self.depo.quantity_of(self.secondcard), 1)
def test_sell_restocking(self):
# Create one required Restocking intermediate record.
restock = Restocking()
restock.save()
self.reserve = Place(name="reserve", can_sell=False)
self.reserve.save()
# We have 1 copy in the shelf, 1 copy in the reserve.
self.reserve.add_copy(self.autobio, nb=1)
# Sell 1:
Sell.sell_card(self.autobio)
# When the cards reaches 0 in stock, we should see it in the restocking list.
self.assertEqual(Restocking.quantities_total(), 1)
# If we sell it again, its quantity becomes 0, so
# we shouldn't see it in the restocking list again.
Sell.sell_card(self.autobio)
self.assertEqual(Restocking.quantities_total(), 1)
self.assertEqual(self.autobio.quantity_compute(), 0)
class TestSellSearch(TestCase):
# fixtures = ['test_sell_search']
......
......@@ -74,6 +74,9 @@ urlpatterns = patterns('',
url(r'^sell/(?P<pk>\d+)', 'search.views.sell_details',
name="sell_details"),
url(r'^restocking/$', 'search.views.restocking',
name="card_restocking"),
url(r'^collection/', 'search.views.collection',
name="card_collection"),
......
......@@ -19,6 +19,7 @@ from abelujo import settings
import io # write to file in utf8
import datetime
import time
import toolz
import os
import urllib
......@@ -68,6 +69,7 @@ from search.models import InventoryCommand
from search.models import Place
from search.models import Preferences
from search.models import Publisher
from search.models import Restocking
from search.models import Sell
from search.models import Stats
from search.models import Entry
......@@ -776,6 +778,14 @@ def sell_details(request, pk):
"total_price_init": total_price_init,
})
def restocking(request):
template = "search/restocking.html"
cards = Restocking.cards()
return render(request, template, {
'cards': cards,
})
class DepositsListView(ListView):
model = Deposit
template_name = "search/deposits.jade"
......@@ -1159,7 +1169,6 @@ def _export_response(copies_set, report="", format="", inv=None, name="", distri
total_qty = sum([it[1] for it in cards_qties])
# barcode
import time
start = time.time()
if barcodes:
for card, __ in cards_qties:
......@@ -1236,14 +1245,12 @@ def history_sells_exports(request, **kwargs):
content = writer.writerow("")
rows = [(it['created'],
it['sell_id'],
it['price_sold'],
it['card']['title'],
it['card']['distributor']['name'] if it['card']['distributor'] else "",
)
for it in res]
header = (_("date sold"),
_("sell id"),
_("price sold"),
_("title"),
_("supplier"),
......@@ -1257,7 +1264,6 @@ def history_sells_exports(request, **kwargs):
elif outformat in ['txt']:
rows = [u"{}-+-{}-+-{}-+-{}-+-{}".format(
_("date sold"),
_("sell id"),
_("price sold"),
_("title"),
_("supplier"),
......@@ -1266,7 +1272,6 @@ def history_sells_exports(request, **kwargs):
# https://pyformat.info/
rows += sorted([u"{:10.10} {} {:5} {:30} {}".format(
it['created'],
it['sell_id'],
it.get('price_sold', 0),
truncate(it['card']['title']), # truncate long titles
it['card']['distributor']['name'] if it['card']['distributor'] else "",
......
......@@ -14,13 +14,14 @@
# You should have received a copy of the GNU General Public License
# along with Abelujo. If not, see <http://www.gnu.org/licenses/>.
angular.module "abelujo" .controller 'baseController', ['$http', '$scope', '$window', ($http, $scope, $window) !->
angular.module "abelujo" .controller 'baseController', ['$http', '$scope', '$window', '$log', ($http, $scope, $window, $log) !->
{Obj, join, reject, sum, map, filter, lines} = require 'prelude-ls'
$scope.alerts_open = null
$scope.auto_command_total = null
$scope.restocking_total = null
$http.get("/api/alerts/open")
.then (response) ->
......@@ -37,6 +38,11 @@ angular.module "abelujo" .controller 'baseController', ['$http', '$scope', '$win
$scope.ongoing_commands_nb = response.data.data
return response.data.data
$http.get("/api/restocking/nb_ongoing")
.then (response) ->
$scope.restocking_total = response.data.data
return response.data.data
# Goal: Grab what url we're on to highlight the active menu bar,
# with ng-class.
$scope.url = ""
......
......@@ -104,6 +104,10 @@ html(ng-app="abelujo")
li(ng-class="{active: url == 'sell' }")
a(href='{% url "card_sell" %}') {% trans "Sell" %}
//li(ng-class="{active: url == 'restocking' }")
a(href='{% url "card_restocking" %}' title='{% trans "Cards to move from the stock place to the shelves" %}') {% trans "Restocking" %}
span.badge.ng-cloak(title='{% trans "Number of cards to move from the stock to their shelves." %}') {{ "restocking_total" | ng }}
li(ng-class="{active: url == 'inventories' }")
a(href='{% url "inventories" %}') {% trans "Inventories" %}
......
<!-- Copyright 2014 - 2019 The Abelujo Developers -->
<!-- See the COPYRIGHT file at the top-level directory of this distribution -->
<!-- Abelujo is free software: you can redistribute it and/or modify -->
<!-- it under the terms of the GNU General Public License as published by -->
<!-- the Free Software Foundation, either version 3 of the License, or -->
<!-- (at your option) any later version. -->
<!-- Abelujo is distributed in the hope that it will be useful, -->
<!-- but WITHOUT ANY WARRANTY; without even the implied warranty of -->
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -->
<!-- GNU General Public License for more details. -->
<!-- You should have received a copy of the GNU General Public License -->
<!-- along with Abelujo. If not, see <http://www.gnu.org/licenses/>. -->
{% extends "base.jade" %}
{% block content %}
{% load i18n %}
{% load bootstrap3 %}
{% load ngfilter %}
{% bootstrap_css %}
<h3> Restocking </h3>
<h4> {% trans "Cards that should be moved from the reserve to their shelves. Confirm each card then click on validate" %}.</h4>
<div ng-controller="restockingController">
<div>
<div>
<uib-alert
ng-repeat="alert in alerts"
type="{{ 'alert.level' | ng }}"
close="closeAlert($item)"
ng-click="closeAlert($item)">
{{ 'alert.message' | ng }}
</uib-alert>
</div>
<a ng-click="validate()" type="button" class="btn btn-success" title='{% trans "Move all the selected cards to their shelves." %}'> {% trans "Validate" %}
</a>
<a type="button" class="btn btn-default" href="http://abelujo.cc/docs/restocking/" target="_blank" title='{% trans "See the documentation" %}'>
<i class="glyphicon glyphicon-question-sign"></i>
</a>
</div>
<table class="table table-condensed table-striped">
<thead>
<th> Title </th>
<th> Publisher </th>
<th> In reserve </th>
<th> In shelf </th>
<th> To move </th>
<th> </th>
</thead>
<tbody>
{% for card in cards %}
<tr>
<div>
<td>
<div ng-class="{ 'my-green-bg': is_ready({{ card.id }}) == true, 'my-strike-through': card_ignored({{ card.id }}) == true }">
<a href="{{ card.get_absolute_url }}"> {{ card.title }} </a>
</div>
</td>
</div>
<td> {{ card.pubs_repr }} </td>
<td> {{ card.quantity_reserve }} </td>
<td> {{ card.quantity_selling_places }} </td>
<td>
<!-- <input class="my-number-input" type="number" min=0 max=99 value="{{ card.quantity_to_restock }}"/> -->
{{ card.quantity_to_restock }}
</td>
<td>
<p class="btn-group">
<button ng-click="mark_ready({{ card.id }})" class="btn btn-default" type='button' title='{% trans "You have this card between your hands." %}'>
<i class="glyphicon glyphicon-ok"></i>
</button>
<button ng-click="ignore_card({{ card.id }})" class="btn btn-default" type='button' title='{% trans "Ignore this card for this session." %}'>
<i class="glyphicon glyphicon-ban-circle"></i>
</button>
<!-- <button ng-click="not_implemented()" class="btn btn-default" type='button' title='{% trans "Remove this card from the list." %}'> -->
<!-- <i class="glyphicon glyphicon-remove"></i> -->
<!-- </button> -->
</p>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock content %}