Various tweaks to support mobile inventory batches

still not fully there I think, but pretty close..
This commit is contained in:
Lance Edgar 2017-07-11 20:57:31 -05:00
parent 452cb99349
commit 32d256932e
10 changed files with 341 additions and 39 deletions

View file

@ -102,10 +102,16 @@ $(document).on('click', '#datasync-restart', function() {
}); });
// handle global keypress on receiving "row" page, for sake of scanner wedge // handle global keypress on product batch "row" page, for sake of scanner wedge
var product_batch_routes = [
'mobile.batch.inventory.view',
'mobile.receiving.view',
];
$(document).on('keypress', function(event) { $(document).on('keypress', function(event) {
if ($('.ui-page-active [role="main"]').data('route') == 'mobile.receiving.view') { var current_route = $('.ui-page-active [role="main"]').data('route');
var upc = $('#upc-search'); for (var route of product_batch_routes) {
if (current_route == route) {
var upc = $('.ui-page-active #upc-search');
if (upc.length) { if (upc.length) {
if (upc.is(':focus')) { if (upc.is(':focus')) {
if (event.which == 13) { if (event.which == 13) {
@ -128,16 +134,17 @@ $(document).on('keypress', function(event) {
} }
} }
} }
}
}); });
// handle numeric buttons for receiving // when numeric keypad button is clicked, update quantity accordingly
// $(document).on('click', '#receiving-quantity-keypad-thingy .ui-btn', function() { $(document).on('click', '.quantity-keypad-thingy .keypad-button', function() {
$(document).on('click', '#receiving-quantity-keypad-thingy .keypad-button', function() { var keypad = $(this).parents('.quantity-keypad-thingy');
var quantity = $('.receiving-quantity'); var quantity = keypad.find('.keypad-quantity');
var value = quantity.text(); var value = quantity.text();
var key = $(this).text(); var key = $(this).text();
var changed = $('#receiving-quantity-keypad-thingy').data('changed'); var changed = keypad.data('changed');
if (key == 'Del') { if (key == 'Del') {
if (value.length == 1) { if (value.length == 1) {
quantity.text('0'); quantity.text('0');
@ -166,7 +173,7 @@ $(document).on('click', '#receiving-quantity-keypad-thingy .keypad-button', func
} }
} }
if (changed) { if (changed) {
$('#receiving-quantity-keypad-thingy').data('changed', true); keypad.data('changed', true);
} }
}); });
@ -214,3 +221,17 @@ $(document).on('click', '.receiving-actions button', function() {
} }
} }
}); });
// handle inventory save button
$(document).on('click', '.inventory-actions button.save', function() {
var form = $(this).parents('form:first');
var uom = form.find('[name="keypad-uom"]:checked').val();
var qty = form.find('.keypad-quantity').text();
if (uom == 'CS') {
form.find('input[name="cases"]').val(qty);
} else { // units
form.find('input[name="units"]').val(qty);
}
form.submit();
});

View file

@ -0,0 +1,6 @@
## -*- coding: utf-8; -*-
<%inherit file="/mobile/master/create.mako" />
<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; New Batch</%def>
${parent.body()}

View file

@ -0,0 +1,10 @@
## -*- coding: utf-8; -*-
<%inherit file="/mobile/master/index.mako" />
<%def name="title()">Inventory</%def>
% if request.has_perm('batch.inventory.create'):
${h.link_to("New Inventory Batch", url('mobile.batch.inventory.create'), class_='ui-btn ui-corner-all')}
% endif
${parent.body()}

View file

@ -1,6 +1,16 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/mobile/newbatch/view.mako" /> <%inherit file="/mobile/newbatch/view.mako" />
<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; ${instance.id_str}</%def> <%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; ${batch.id_str}</%def>
${parent.body()} ${form.render()|n}
% if not batch.executed and not batch.complete:
<br />
${h.text('upc-search', class_='inventory-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.batch.inventory.row_from_upc', uuid=batch.uuid)})}
% endif
% if master.has_rows:
<br />
${grid.render_complete()|n}
% endif

View file

@ -1,7 +1,64 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<%inherit file="/mobile/newbatch/view_row.mako" /> <%inherit file="/mobile/newbatch/view_row.mako" />
<%namespace file="/mobile/keypad.mako" import="keypad" />
## TODO: this is broken for actual page (header) title ## TODO: this is broken for actual page (header) title
<%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; ${h.link_to(instance.batch.id_str, url('mobile.batch.inventory.view', uuid=instance.batch_uuid))} &raquo; row ${row.sequence}</%def> <%def name="title()">${h.link_to("Inventory", url('mobile.batch.inventory'))} &raquo; ${h.link_to(instance.batch.id_str, url('mobile.batch.inventory.view', uuid=instance.batch_uuid))} &raquo; ${row.upc.pretty()}</%def>
${parent.body()} <%
unit_uom = 'LB' if row.product and row.product.weighed else 'EA'
if row.cases:
uom = 'CS'
elif row.units:
uom = 'EA'
elif row.case_quantity:
uom = 'CS'
else:
uom = 'EA'
%>
<div class="ui-grid-a">
<div class="ui-block-a">
% if instance.product:
<h3>${row.brand_name or ""}</h3>
<h3>${row.description} ${row.size}</h3>
<h3>${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS</h3>
% else:
<h3>${row.description}</h3>
% endif
</div>
<div class="ui-block-b">
${h.image(product_image_url, "product image")}
</div>
</div>
<p>
currently:&nbsp;
% if uom == 'CS':
${h.pretty_quantity(row.cases or 0)}
% else:
${h.pretty_quantity(row.units or 0)}
% endif
${uom}
</p>
% if not row.batch.executed and not row.batch.complete:
${h.form(request.current_route_url())}
${h.csrf_token(request)}
${h.hidden('row', value=row.uuid)}
${h.hidden('cases')}
${h.hidden('units')}
${keypad(unit_uom, uom, quantity=row.cases or row.units or 1)}
<fieldset data-role="controlgroup" data-type="horizontal" class="inventory-actions">
<button type="button" class="ui-btn-inline ui-corner-all save">Save</button>
<button type="button" class="ui-btn-inline ui-corner-all delete" disabled="disabled">Delete</button>
${h.link_to("Cancel", url('mobile.batch.inventory.view', uuid=row.batch.uuid), class_='ui-btn ui-btn-inline ui-corner-all')}
</fieldset>
${h.end_form()}
% endif

View file

@ -0,0 +1,39 @@
## -*- coding: utf-8; -*-
<%def name="keypad(unit_uom, selected_uom, quantity=1)">
<div class="quantity-keypad-thingy" data-changed="false">
<table>
<tbody>
<tr>
<td>${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
</tr>
<tr>
<td>${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
</tr>
<tr>
<td>${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
</tr>
<tr>
<td>${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
<td>${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')}</td>
</tr>
</tbody>
</table>
<fieldset data-role="controlgroup" data-type="horizontal">
<button type="button" class="ui-btn-active keypad-quantity">${h.pretty_quantity(quantity or 1)}</button>
<button type="button" disabled="disabled">&nbsp;</button>
${h.radio('keypad-uom', value='CS', checked=selected_uom == 'CS', label="CS")}
${h.radio('keypad-uom', value=unit_uom, checked=selected_uom == unit_uom, label=unit_uom)}
</fieldset>
</div>
</%def>

View file

@ -0,0 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="/mobile/base.mako" />
<%def name="title()">New ${model_title}</%def>
<div class="form-wrapper">
${form.render()|n}
</div><!-- form-wrapper -->

View file

@ -204,7 +204,7 @@ class BatchMasterView(MasterView):
fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer, fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer,
readonly=True) readonly=True)
fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer) fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer)
fs.rowcount.set(label="Row Count") fs.rowcount.set(label="Row Count", readonly=True)
fs.status_code.set(label="Status", renderer=StatusRenderer(self.model_class.STATUS)) fs.status_code.set(label="Status", renderer=StatusRenderer(self.model_class.STATUS))
fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer) fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer)
fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10)) fs.notes.set(renderer=fa.TextAreaFieldRenderer, size=(80, 10))
@ -322,6 +322,7 @@ class BatchMasterView(MasterView):
kwargs['notes'] = batch.notes kwargs['notes'] = batch.notes
if hasattr(batch, 'filename'): if hasattr(batch, 'filename'):
kwargs['filename'] = batch.filename kwargs['filename'] = batch.filename
kwargs['complete'] = batch.complete
return kwargs return kwargs
# TODO: deprecate / remove this (is it used at all now?) # TODO: deprecate / remove this (is it used at all now?)
@ -338,13 +339,13 @@ class BatchMasterView(MasterView):
""" """
return True return True
def redirect_after_create(self, batch): def redirect_after_create(self, batch, mobile=False):
if self.handler.should_populate(batch): if self.handler.should_populate(batch):
return self.redirect(self.get_action_url('prefill', batch)) return self.redirect(self.get_action_url('prefill', batch, mobile=mobile))
elif self.refresh_after_create: elif self.refresh_after_create:
return self.redirect(self.get_action_url('refresh', batch)) return self.redirect(self.get_action_url('refresh', batch, mobile=mobile))
else: else:
return self.redirect(self.get_action_url('view', batch)) return self.redirect(self.get_action_url('view', batch, mobile=mobile))
# TODO: some of this at least can go to master now right? # TODO: some of this at least can go to master now right?
def edit(self): def edit(self):
@ -429,6 +430,7 @@ class BatchMasterView(MasterView):
def get_mobile_row_data(self, batch): def get_mobile_row_data(self, batch):
return super(BatchMasterView, self).get_mobile_row_data(batch)\ return super(BatchMasterView, self).get_mobile_row_data(batch)\
.order_by(self.model_row_class.sequence) .order_by(self.model_row_class.sequence)
def redirect_after_edit(self, batch): def redirect_after_edit(self, batch):
""" """
If refresh flag is set, do that; otherwise go (back) to view/edit page. If refresh flag is set, do that; otherwise go (back) to view/edit page.

View file

@ -26,10 +26,16 @@ Views for inventory batches
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from rattail.db import model import re
from rattail import pod
from rattail.db import model, api
from rattail.time import localtime from rattail.time import localtime
from rattail.gpc import GPC
from rattail.util import pretty_quantity
import formalchemy as fa import formalchemy as fa
import formencode as fe
from webhelpers2.html import tags from webhelpers2.html import tags
from tailbone import forms from tailbone import forms
@ -46,7 +52,7 @@ class InventoryBatchView(BatchMasterView):
route_prefix = 'batch.inventory' route_prefix = 'batch.inventory'
url_prefix = '/batch/inventory' url_prefix = '/batch/inventory'
creatable = False creatable = False
editable = False mobile_creatable = True
model_row_class = model.InventoryBatchRow model_row_class = model.InventoryBatchRow
rows_editable = True rows_editable = True
@ -87,6 +93,7 @@ class InventoryBatchView(BatchMasterView):
fs.handheld_batches, fs.handheld_batches,
fs.mode, fs.mode,
fs.rowcount, fs.rowcount,
fs.complete,
fs.executed, fs.executed,
fs.executed_by, fs.executed_by,
]) ])
@ -100,6 +107,8 @@ class InventoryBatchView(BatchMasterView):
fs.executed_by, fs.executed_by,
]) ])
batch = fs.model batch = fs.model
if self.creating:
del fs.rowcount
if not batch.executed: if not batch.executed:
del [fs.executed, fs.executed_by] del [fs.executed, fs.executed_by]
if not batch.complete: if not batch.complete:
@ -107,6 +116,89 @@ class InventoryBatchView(BatchMasterView):
else: else:
del fs.complete del fs.complete
# TODO: this view can create new rows, with only a GET query. that should
# probably be changed to require POST; for now we just require the "create
# batch row" perm and call it good..
def mobile_row_from_upc(self):
"""
Locate and/or create a row within the batch, according to the given
product UPC, then redirect to the row view page.
"""
batch = self.get_instance()
row = None
upc = self.request.GET.get('upc', '').strip()
upc = re.sub(r'\D', '', upc)
if upc:
# try to locate general product by UPC; add to batch either way
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)
row = model.InventoryBatchRow()
if product:
row.product = product
row.upc = product.upc
else:
row.upc = provided # TODO: why not 'checked' instead? how to choose?
row.description = "(unknown product)"
self.handler.add_row(batch, row)
self.Session.flush()
return self.redirect(self.mobile_row_route_url('view', uuid=row.uuid))
def template_kwargs_view_row(self, **kwargs):
row = kwargs['instance']
kwargs['product_image_url'] = pod.get_image_url(self.rattail_config, row.upc)
return kwargs
def get_batch_kwargs(self, batch, mobile=False):
kwargs = super(InventoryBatchView, self).get_batch_kwargs(batch, mobile=False)
kwargs['mode'] = batch.mode
kwargs['complete'] = False
return kwargs
def get_mobile_row_data(self, batch):
# we want newest on top, for inventory batch rows
return self.get_row_data(batch)\
.order_by(self.model_row_class.sequence.desc())
# TODO: ugh, the hackiness. needs a refactor fo sho
def mobile_view_row(self):
"""
Mobile view for inventory batch rows. Note that this also handles
updating a row...ugh.
"""
self.viewing = True
row = self.get_row_instance()
form = self.make_mobile_row_form(row)
context = {
'row': row,
'instance': row,
'instance_title': self.get_row_instance_title(row),
'parent_model_title': self.get_model_title(),
'product_image_url': pod.get_image_url(self.rattail_config, row.upc),
'form': form,
}
if self.request.has_perm('{}.edit'.format(self.get_row_permission_prefix())):
update_form = forms.SimpleForm(self.request, schema=InventoryForm)
if update_form.validate():
row = update_form.data['row']
cases = update_form.data['cases']
units = update_form.data['units']
if cases:
row.cases = cases
row.units = None
elif units:
row.cases = None
row.units = units
self.handler.refresh_row(row)
return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_route_prefix()), uuid=row.batch_uuid))
return self.render_to_response('view_row', context, mobile=True)
def _preconfigure_row_grid(self, g): def _preconfigure_row_grid(self, g):
super(InventoryBatchView, self)._preconfigure_row_grid(g) super(InventoryBatchView, self)._preconfigure_row_grid(g)
g.upc.set(label="UPC") g.upc.set(label="UPC")
@ -139,7 +231,9 @@ class InventoryBatchView(BatchMasterView):
if row is None: if row is None:
return '' return ''
description = row.product.full_description if row.product else row.description description = row.product.full_description if row.product else row.description
title = "({}) {}".format(row.upc.pretty(), description) unit_uom = 'LB' if row.product and row.product.weighed else 'EA'
qty = "{} {}".format(pretty_quantity(row.cases or row.units), 'CS' if row.cases else unit_uom)
title = "({}) {} - {}".format(row.upc.pretty(), description, qty)
url = self.request.route_url('mobile.batch.inventory.rows.view', uuid=row.uuid) url = self.request.route_url('mobile.batch.inventory.rows.view', uuid=row.uuid)
return tags.link_to(title, url) return tags.link_to(title, url)
@ -164,6 +258,21 @@ class InventoryBatchView(BatchMasterView):
fs.units, fs.units,
]) ])
@classmethod
def defaults(cls, config):
model_key = cls.get_model_key()
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
row_permission_prefix = cls.get_row_permission_prefix()
cls._batch_defaults(config)
cls._defaults(config)
# 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'.format(row_permission_prefix))
class InventoryBatchRenderer(fa.FieldRenderer): class InventoryBatchRenderer(fa.FieldRenderer):
@ -178,5 +287,23 @@ class InventoryBatchRenderer(fa.FieldRenderer):
return tags.link_to(title, url) return tags.link_to(title, url)
class ValidBatchRow(forms.validators.ModelValidator):
model_class = model.InventoryBatchRow
def _to_python(self, value, state):
row = super(ValidBatchRow, self)._to_python(value, state)
if row.batch.executed:
raise fe.Invalid("Batch has already been executed", value, state)
return row
class InventoryForm(forms.Schema):
allow_extra_fields = True
filter_extra_fields = True
row = ValidBatchRow()
cases = fe.validators.Number()
units = fe.validators.Number()
def includeme(config): def includeme(config):
InventoryBatchView.defaults(config) InventoryBatchView.defaults(config)

View file

@ -312,6 +312,21 @@ class MasterView(View):
return self.redirect_after_create(obj) return self.redirect_after_create(obj)
return self.render_to_response('create', {'form': form}) return self.render_to_response('create', {'form': form})
def mobile_create(self):
"""
Mobile view for creating a new primary object
"""
self.creating = True
form = self.make_mobile_form(self.get_model_class())
if self.request.method == 'POST':
if form.validate():
# let save_create_form() return alternate object if necessary
obj = self.save_create_form(form) or form.fieldset.model
self.after_create(obj)
self.flash_after_create(obj)
return self.redirect_after_create(obj, mobile=True)
return self.render_to_response('create', {'form': form}, mobile=True)
def flash_after_create(self, obj): def flash_after_create(self, obj):
self.request.session.flash("{} has been created: {}".format( self.request.session.flash("{} has been created: {}".format(
self.get_model_title(), self.get_instance_title(obj))) self.get_model_title(), self.get_instance_title(obj)))
@ -320,8 +335,8 @@ class MasterView(View):
self.before_create(form) self.before_create(form)
form.save() form.save()
def redirect_after_create(self, instance): def redirect_after_create(self, instance, mobile=False):
return self.redirect(self.get_action_url('view', instance)) return self.redirect(self.get_action_url('view', instance, mobile=mobile))
def view(self, instance=None): def view(self, instance=None):
""" """
@ -573,6 +588,13 @@ class MasterView(View):
fieldset = self.make_fieldset(instance) fieldset = self.make_fieldset(instance)
self.preconfigure_mobile_fieldset(fieldset) self.preconfigure_mobile_fieldset(fieldset)
self.configure_mobile_fieldset(fieldset) self.configure_mobile_fieldset(fieldset)
kwargs.setdefault('creating', self.creating)
kwargs.setdefault('editing', self.editing)
kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
if self.creating:
kwargs.setdefault('cancel_url', self.get_index_url(mobile=True))
else:
kwargs.setdefault('cancel_url', self.get_action_url('view', instance, mobile=True))
factory = kwargs.pop('factory', forms.AlchemyForm) factory = kwargs.pop('factory', forms.AlchemyForm)
kwargs.setdefault('session', self.Session()) kwargs.setdefault('session', self.Session())
form = factory(self.request, fieldset, **kwargs) form = factory(self.request, fieldset, **kwargs)