Add desktop support for creating inventory batches

with a workflow form of sorts
This commit is contained in:
Lance Edgar 2018-02-28 21:53:39 -06:00
parent 52e9717288
commit 91bb38573b
4 changed files with 433 additions and 11 deletions

View file

@ -585,11 +585,14 @@ class Form(object):
else: else:
raise ValueError("unknown type for '{}' field: {}".format(key, type_)) raise ValueError("unknown type for '{}' field: {}".format(key, type_))
def set_enum(self, key, enum): def set_enum(self, key, enum, empty=None):
if enum: if enum:
self.enums[key] = enum self.enums[key] = enum
self.set_type(key, 'enum') self.set_type(key, 'enum')
self.set_widget(key, dfwidget.SelectWidget(values=list(enum.items()))) values = list(enum.items())
if empty:
values.insert(0, empty)
self.set_widget(key, dfwidget.SelectWidget(values=values))
else: else:
self.enums.pop(key, None) self.enums.pop(key, None)

View file

@ -26,9 +26,12 @@ Form Schema Types
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import re
import six import six
from rattail.db import model from rattail.db import model
from rattail.gpc import GPC
import colander import colander
@ -60,6 +63,28 @@ class JQueryTime(colander.Time):
return colander.timeparse(cstruct, formats[0]) return colander.timeparse(cstruct, formats[0])
class GPCType(colander.SchemaType):
"""
Schema type for product GPC data.
"""
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return six.text_type(appstruct)
def deserialize(self, node, cstruct):
if not cstruct:
return None
digits = re.sub(r'\D', '', cstruct)
if not digits:
return None
try:
return GPC(digits)
except Exception as err:
raise colander.Invalid(node, six.text_type(err))
class ModelType(colander.SchemaType): class ModelType(colander.SchemaType):
""" """
Custom schema type for scalar ORM relationship fields. Custom schema type for scalar ORM relationship fields.

View file

@ -0,0 +1,258 @@
## -*- coding: utf-8; -*-
<%inherit file="/base.mako" />
<%def name="title()">Inventory Form</%def>
<%def name="extra_javascript()">
${parent.extra_javascript()}
${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
<script type="text/javascript">
function assert_quantity() {
if ($('#cases').val() && parseFloat($('#cases').val())) {
return true;
}
if ($('#units').val() && parseFloat($('#units').val())) {
return true;
}
alert("Please provide case and/or unit quantity");
$('#cases').select().focus();
return false;
}
function invalid_product(msg) {
$('#product-info p').text(msg);
$('#product-info img').hide();
$('#upc').focus().select();
$('.field-wrapper.cases input').prop('disabled', true);
$('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable');
}
function pretty_quantity(cases, units) {
if (cases && units) {
return cases + " cases, " + units + " units";
} else if (cases) {
return cases + " cases";
} else if (units) {
return units + " units";
}
return '';
}
function show_quantity(name, cases, units) {
var quantity = pretty_quantity(cases, units);
var field = $('.field-wrapper.quantity_' + name);
field.find('.field').text(quantity);
if (quantity || name == 'ordered') {
field.show();
} else {
field.hide();
}
}
$(function() {
$('#upc').keydown(function(event) {
if (key_allowed(event)) {
return true;
}
if (key_modifies(event)) {
$('#product').val('');
$('#product-info p').html("please ENTER a scancode");
$('#product-info img').hide();
$('#product-info .warning').hide();
$('.product-fields').hide();
// $('.receiving-fields').hide();
$('.field-wrapper.cases input').prop('disabled', true);
$('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable');
return true;
}
// when user presses ENTER, do product lookup
if (event.which == 13) {
var upc = $(this).val();
var data = {'upc': upc};
$.get('${url('batch.inventory.desktop_lookup', uuid=batch.uuid)}', data, function(data) {
if (data.error) {
alert(data.error);
if (data.redirect) {
$('#inventory-form').mask("Redirecting...");
location.href = data.redirect;
}
} else if (data.product) {
$('#upc').val(data.product.upc_pretty);
$('#product').val(data.product.uuid);
$('#brand_name').val(data.product.brand_name);
$('#description').val(data.product.description);
$('#size').val(data.product.size);
$('#case_quantity').val(data.product.case_quantity);
$('#product-info p').text(data.product.full_description);
$('#product-info img').attr('src', data.product.image_url).show();
if (! data.product.uuid) {
// $('#product-info .warning.notfound').show();
$('.product-fields').show();
}
$('#product-info .warning.notordered').show();
$('.field-wrapper.cases input').prop('disabled', false);
$('.field-wrapper.units input').prop('disabled', false);
$('.buttons button').button('enable');
$('#cases').focus().select();
} else if (data.upc) {
$('#upc').val(data.upc_pretty);
$('#product-info p').text("product not found in our system");
$('#product-info img').attr('src', data.image_url).show();
$('#product').val('');
$('#brand_name').val('');
$('#description').val('');
$('#size').val('');
$('#case_quantity').val('');
$('#product-info .warning.notfound').show();
$('.product-fields').show();
$('#brand_name').focus();
$('.field-wrapper.cases input').prop('disabled', false);
$('.field-wrapper.units input').prop('disabled', false);
$('.buttons button').button('enable');
} else {
invalid_product('product not found');
}
});
}
return false;
});
$('#add').click(function() {
if (! assert_quantity()) {
return;
}
$(this).button('disable').button('option', 'label', "Working...");
$('#mode').val('add');
$('#inventory-form').submit();
});
$('#subtract').click(function() {
if (! assert_quantity()) {
return;
}
$(this).button('disable').button('option', 'label', "Working...");
$('#mode').val('subtract');
$('#inventory-form').submit();
});
$('#inventory-form').submit(function() {
$(this).mask("Working...");
});
$('#upc').focus();
$('.field-wrapper.cases input').prop('disabled', true);
$('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable');
});
</script>
</%def>
<%def name="extra_styles()">
${parent.extra_styles()}
<style type="text/css">
#product-info {
margin-top: 0.5em;
text-align: center;
}
#product-info p {
margin-left: 0.5em;
}
#product-info .img-wrapper {
height: 150px;
margin: 0.5em 0;
}
#product-info .warning {
background: #f66;
display: none;
}
</style>
</%def>
<%def name="context_menu_items()">
<li>${h.link_to("Back to Inventory Batch", url('batch.inventory.view', uuid=batch.uuid))}</li>
</%def>
<ul id="context-menu">
${self.context_menu_items()}
</ul>
<div class="form-wrapper">
${h.form(form.action_url, id='inventory-form')}
${h.csrf_token(request)}
${h.hidden('mode')}
<div class="field-wrapper">
<label for="upc">Product UPC</label>
<div class="field">
${h.hidden('product')}
<div>${h.text('upc', autocomplete='off')}</div>
<div id="product-info">
<p>please ENTER a scancode</p>
<div class="img-wrapper"><img /></div>
<div class="warning notfound">please confirm UPC and provide more details</div>
</div>
</div>
</div>
<div class="product-fields" style="display: none;">
<div class="field-wrapper brand_name">
<label for="brand_name">Brand Name</label>
<div class="field">${h.text('brand_name')}</div>
</div>
<div class="field-wrapper description">
<label for="description">Description</label>
<div class="field">${h.text('description')}</div>
</div>
<div class="field-wrapper size">
<label for="size">Size</label>
<div class="field">${h.text('size')}</div>
</div>
<div class="field-wrapper case_quantity">
<label for="case_quantity">Units in Case</label>
<div class="field">${h.text('case_quantity')}</div>
</div>
</div>
<div class="field-wrapper cases">
<label for="cases">Cases</label>
<div class="field">${h.text('cases', autocomplete='off')}</div>
</div>
<div class="field-wrapper units">
<label for="units">Units</label>
<div class="field">${h.text('units', autocomplete='off')}</div>
</div>
<div class="buttons">
<button type="button" id="add">Add</button>
<button type="button" id="subtract">Subtract</button>
</div>
${h.end_form()}
</div>

View file

@ -27,11 +27,13 @@ Views for inventory batches
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import re import re
import logging
import six import six
from rattail import pod from rattail import pod
from rattail.db import model, api from rattail.db import model, api
from rattail.db.util import make_full_description
from rattail.time import localtime from rattail.time import localtime
from rattail.gpc import GPC from rattail.gpc import GPC
from rattail.util import pretty_quantity from rattail.util import pretty_quantity
@ -45,6 +47,9 @@ from tailbone.views import MasterView
from tailbone.views.batch import BatchMasterView from tailbone.views.batch import BatchMasterView
log = logging.getLogger(__name__)
class InventoryAdjustmentReasonsView(MasterView): class InventoryAdjustmentReasonsView(MasterView):
""" """
Master view for inventory adjustment reasons. Master view for inventory adjustment reasons.
@ -84,7 +89,7 @@ class InventoryBatchView(BatchMasterView):
route_prefix = 'batch.inventory' route_prefix = 'batch.inventory'
url_prefix = '/batch/inventory' url_prefix = '/batch/inventory'
index_title = "Inventory" index_title = "Inventory"
creatable = False rows_creatable = True
results_executable = True results_executable = True
mobile_creatable = True mobile_creatable = True
mobile_rows_creatable = True mobile_rows_creatable = True
@ -108,6 +113,7 @@ class InventoryBatchView(BatchMasterView):
form_fields = [ form_fields = [
'id', 'id',
'description', 'description',
'notes',
'created', 'created',
'created_by', 'created_by',
'handheld_batches', 'handheld_batches',
@ -225,8 +231,15 @@ class InventoryBatchView(BatchMasterView):
f.set_type('total_cost', 'currency') f.set_type('total_cost', 'currency')
# handheld_batches # handheld_batches
f.set_readonly('handheld_batches') if self.creating:
f.set_renderer('handheld_batches', self.render_handheld_batches) f.remove_field('handheld_batches')
else:
f.set_readonly('handheld_batches')
f.set_renderer('handheld_batches', self.render_handheld_batches)
# complete
if self.creating:
f.remove_field('complete')
def render_handheld_batches(self, inventory_batch, field): def render_handheld_batches(self, inventory_batch, field):
items = '' items = ''
@ -250,7 +263,7 @@ class InventoryBatchView(BatchMasterView):
return super(InventoryBatchView, self).save_edit_row_form(form) return super(InventoryBatchView, self).save_edit_row_form(form)
def delete_row(self): def delete_row(self):
row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['uuid']) row = self.Session.query(model.InventoryBatchRow).get(self.request.matchdict['row_uuid'])
if not row: if not row:
raise self.notfound() raise self.notfound()
batch = row.batch batch = row.batch
@ -258,6 +271,99 @@ class InventoryBatchView(BatchMasterView):
batch.total_cost -= row.total_cost batch.total_cost -= row.total_cost
return super(InventoryBatchView, self).delete_row() return super(InventoryBatchView, self).delete_row()
def create_row(self):
"""
Desktop workflow view for adding items to inventory batch.
"""
batch = self.get_instance()
if batch.executed:
return self.redirect(self.get_action_url('view', batch))
form = forms.Form(schema=DesktopForm(), request=self.request)
if form.validate(newstyle=True):
mode = form.validated['mode']
product = self.Session.merge(form.validated['product'])
row = model.InventoryBatchRow()
row.product = product
row.upc = form.validated['upc']
row.brand_name = form.validated['brand_name']
row.description = form.validated['description']
row.size = form.validated['size']
row.case_quantity = form.validated['case_quantity']
cases = form.validated['cases']
units = form.validated['units']
if mode == 'add':
row.cases = cases
row.units = units
else:
assert mode == 'subtract'
row.cases = (0 - cases) if cases else None
row.units = (0 - units) if units else None
self.handler.add_row(batch, row)
description = make_full_description(form.validated['brand_name'],
form.validated['description'],
form.validated['size'])
self.request.session.flash("({}) {} cases, {} units: {} {}".format(
form.validated['mode'], form.validated['cases'] or 0, form.validated['units'] or 0,
form.validated['upc'].pretty(), description))
return self.redirect(self.request.current_route_url())
title = self.get_instance_title(batch)
return self.render_to_response('desktop_form', {
'batch': batch,
'instance': batch,
'instance_title': title,
'index_title': "{}: {}".format(self.get_model_title(), title),
'index_url': self.get_action_url('view', batch),
'form': form,
'dform': form.make_deform_form(),
})
def desktop_lookup(self):
"""
Try to locate a product by UPC, and validate it in the context of
current batch, returning some data for client JS.
"""
batch = self.get_instance()
if batch.executed:
return {
'error': "Current batch has already been executed",
'redirect': self.get_action_url('view', batch),
}
data = {}
upc = self.request.GET.get('upc', '').strip()
upc = re.sub(r'\D', '', upc)
if upc:
# first try to locate existing batch row by UPC match
provided = GPC(upc, calc_check_digit=False)
checked = GPC(upc, calc_check_digit='upc')
product = api.get_product_by_upc(self.Session(), provided)
if not product:
product = api.get_product_by_upc(self.Session(), checked)
if product and (not product.deleted or self.request.has_perm('products.view_deleted')):
data['uuid'] = product.uuid
data['upc'] = six.text_type(product.upc)
data['upc_pretty'] = product.upc.pretty()
data['full_description'] = product.full_description
data['brand_name'] = six.text_type(product.brand or '')
data['description'] = product.description
data['size'] = product.size
data['case_quantity'] = 1 # default
data['cost_found'] = False
data['image_url'] = pod.get_image_url(self.rattail_config, product.upc)
result = {'product': data or None, 'upc': None}
if not data and upc:
upc = GPC(upc)
result['upc'] = unicode(upc)
result['upc_pretty'] = upc.pretty()
result['image_url'] = pod.get_image_url(self.rattail_config, upc)
return result
def configure_mobile_form(self, f): def configure_mobile_form(self, f):
super(InventoryBatchView, self).configure_mobile_form(f) super(InventoryBatchView, self).configure_mobile_form(f)
batch = f.model_instance batch = f.model_instance
@ -466,17 +572,22 @@ class InventoryBatchView(BatchMasterView):
url_prefix = cls.get_url_prefix() url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix() permission_prefix = cls.get_permission_prefix()
# mobile - make new row from UPC
config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key))
config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix),
permission='{}.create_row'.format(permission_prefix))
# extra perms for creating batches per "mode" # extra perms for creating batches per "mode"
config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{}.create.replace'.format(permission_prefix),
"Create new {} with 'replace' mode".format(model_title)) "Create new {} with 'replace' mode".format(model_title))
config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{}.create.zero'.format(permission_prefix),
"Create new {} with 'zero' mode".format(model_title)) "Create new {} with 'zero' mode".format(model_title))
# row UPC lookup, for desktop
config.add_route('{}.desktop_lookup'.format(route_prefix), '{}/{{{}}}/desktop-form/lookup'.format(url_prefix, model_key))
config.add_view(cls, attr='desktop_lookup', route_name='{}.desktop_lookup'.format(route_prefix),
renderer='json', permission='{}.create_row'.format(permission_prefix))
# mobile - make new row from UPC
config.add_route('mobile.{}.row_from_upc'.format(route_prefix), '/mobile{}/{{{}}}/row-from-upc'.format(url_prefix, model_key))
config.add_view(cls, attr='mobile_row_from_upc', route_name='mobile.{}.row_from_upc'.format(route_prefix),
permission='{}.create_row'.format(permission_prefix))
class InventoryBatchRowType(forms.types.ObjectType): class InventoryBatchRowType(forms.types.ObjectType):
model_class = model.InventoryBatchRow model_class = model.InventoryBatchRow
@ -497,6 +608,31 @@ class InventoryForm(colander.MappingSchema):
units = colander.SchemaNode(colander.Decimal(), missing=colander.null) units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
class DesktopForm(colander.Schema):
mode = colander.SchemaNode(colander.String(),
validator=colander.OneOf(['add',
'subtract']))
product = colander.SchemaNode(forms.types.ProductType())
upc = colander.SchemaNode(forms.types.GPCType())
brand_name = colander.SchemaNode(colander.String())
description = colander.SchemaNode(colander.String())
size = colander.SchemaNode(colander.String())
case_quantity = colander.SchemaNode(colander.Decimal())
cases = colander.SchemaNode(colander.Decimal(),
missing=None)
units = colander.SchemaNode(colander.Decimal(),
missing=None)
def includeme(config): def includeme(config):
InventoryAdjustmentReasonsView.defaults(config) InventoryAdjustmentReasonsView.defaults(config)
InventoryBatchView.defaults(config) InventoryBatchView.defaults(config)