Improve support for "receive from scratch" workflow, esp. for mobile

also try harder to make certain aspects easier to enable/disable via handler,
e.g. whether cases should be allowed as quantity input, or expired credits
should be a thing etc.
This commit is contained in:
Lance Edgar 2018-07-17 19:55:15 -05:00
parent a34a42d2b2
commit d8b45db331
13 changed files with 330 additions and 149 deletions

View file

@ -27,9 +27,9 @@ $(document).on('click', 'form[name="new-purchasing-batch"] #receive-truck-dump',
});
$(document).on('click', 'form.receiving-update #delete-receiving-row', function() {
$(document).on('click', 'form[name="new-purchasing-batch"] #receive-from-scratch', function() {
var form = $(this).parents('form');
form.find('input[name="delete_row"]').val('true');
form.find('input[name="workflow"]').val('from_scratch');
form.submit();
});
@ -68,11 +68,15 @@ $(document).on('click', 'form.receiving-update .receiving-actions button', funct
});
// quick-receive (1 CS)
$(document).on('click', 'form.receiving-update .receive-one-case', function() {
// quick-receive (1 case or unit)
$(document).on('click', 'form.receiving-update .quick-receive', function() {
var form = $(this).parents('form:first');
form.find('[name="mode"]').val('received');
if ($(this).data('uom') == 'CS') {
form.find('[name="cases"]').val('1');
} else {
form.find('[name="units"]').val('1');
}
form.find('input[name="quick_receive"]').val('true');
form.submit();
});

View file

@ -149,7 +149,7 @@ def context_found(event):
return False
request.has_any_perm = has_any_perm
def get_referrer(default=None):
def get_referrer(default=None, mobile=False):
if request.params.get('referrer'):
return request.params['referrer']
if request.session.get('referrer'):
@ -157,7 +157,12 @@ def context_found(event):
referrer = request.referrer
if (not referrer or referrer == request.current_route_url()
or not referrer.startswith(request.host_url)):
referrer = default or request.route_url('home')
if default:
referrer = default
elif mobile:
referrer = request.route_url('mobile.home')
else:
referrer = request.route_url('home')
return referrer
request.get_referrer = get_referrer

View file

@ -16,3 +16,10 @@
## ${form.render(buttons=capture(self.buttons))|n}
${form.render()|n}
</div><!-- form-wrapper -->
% if master.mobile_rows_deletable and request.has_perm('{}.delete_row'.format(permission_prefix)):
${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=parent_instance.uuid, row_uuid=row.uuid))}
${h.csrf_token(request)}
${h.submit('submit', "Delete this Row")}
${h.end_form()}
% endif

View file

@ -34,7 +34,7 @@ ${form.render()|n}
<button type="button" style="display: none;">Change</button>
</div>
% else:
${h.text('quick_row_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid), 'data-wedge': quick_row_keyboard_wedge})}
${h.text('quick_row_entry', placeholder=placeholder, autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.{}.quick_row'.format(route_prefix), uuid=instance.uuid), 'data-wedge': 'true' if quick_row_keyboard_wedge else 'false'})}
% endif
${h.end_form()}
% endif

View file

@ -10,3 +10,10 @@ ${form.render()|n}
% if master.mobile_rows_editable and instance_editable and request.has_perm('{}.edit_row'.format(permission_prefix)):
${h.link_to("Edit", url('mobile.{}.edit_row'.format(route_prefix), uuid=instance.batch_uuid, row_uuid=instance.uuid), class_='ui-btn')}
% endif
% if master.mobile_rows_deletable and request.has_perm('{}.delete_row'.format(permission_prefix)):
${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=parent_instance.uuid, row_uuid=row.uuid))}
${h.csrf_token(request)}
${h.submit('submit', "Delete this Row")}
${h.end_form()}
% endif

View file

@ -31,7 +31,7 @@ ${h.csrf_token(request)}
% endif
% if master.allow_from_scratch:
<button type="button">Receive from Scratch</button>
<button type="button" id="receive-from-scratch">Receive from Scratch</button>
% endif
% if master.allow_truck_dump:

View file

@ -11,37 +11,63 @@
<div class="ui-block-a">
% if instance.product:
<h3>${instance.brand_name or ""}</h3>
<h3>${instance.description} ${instance.size}</h3>
<h3>1 CS = ${h.pretty_quantity(row.case_quantity)} ${unit_uom}</h3>
<h3>${instance.description} ${instance.size or ''}</h3>
% if allow_cases:
<h3>1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom}</h3>
% endif
% else:
<h3>${instance.description}</h3>
% endif
</div>
<div class="ui-block-b">
% if product_image_url:
${h.image(product_image_url, "product image")}
% endif
</div>
</div>
<table>
<tbody>
% if not batch.truck_dump:
% if populated_from_purchase:
<tr>
<td>ordered</td>
<td>${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)}</td>
<td>
% if allow_cases:
${h.pretty_quantity(row.cases_ordered or 0)} /
% endif
${h.pretty_quantity(row.units_ordered or 0)}
</td>
</tr>
% endif
<tr>
<td>received</td>
<td>${h.pretty_quantity(row.cases_received or 0)} / ${h.pretty_quantity(row.units_received or 0)}</td>
<td>
% if allow_cases:
${h.pretty_quantity(row.cases_received or 0)} /
% endif
${h.pretty_quantity(row.units_received or 0)}
</td>
</tr>
<tr>
<td>damaged</td>
<td>${h.pretty_quantity(row.cases_damaged or 0)} / ${h.pretty_quantity(row.units_damaged or 0)}</td>
<td>
% if allow_cases:
${h.pretty_quantity(row.cases_damaged or 0)} /
% endif
${h.pretty_quantity(row.units_damaged or 0)}
</td>
</tr>
% if allow_expired:
<tr>
<td>expired</td>
<td>${h.pretty_quantity(row.cases_expired or 0)} / ${h.pretty_quantity(row.units_expired or 0)}</td>
<td>
% if allow_cases:
${h.pretty_quantity(row.cases_expired or 0)} /
% endif
${h.pretty_quantity(row.units_expired or 0)}
</td>
</tr>
% endif
</tbody>
</table>
@ -59,9 +85,13 @@
${h.hidden('cases')}
${h.hidden('units')}
<button type="button" class="receive-one-case">Receive 1 CS</button>
% if allow_cases:
<button type="button" class="quick-receive" data-uom="CS">Receive 1 CS</button>
% else:
<button type="button" class="quick-receive" data-uom="${unit_uom}">Receive 1 ${unit_uom}</button>
% endif
${keypad(unit_uom, uom)}
${keypad(unit_uom, uom, allow_cases=allow_cases)}
<table>
<tbody>
@ -70,7 +100,9 @@
<fieldset data-role="controlgroup" data-type="horizontal" class="receiving-mode">
${h.radio('mode', value='received', label="received", checked=True)}
${h.radio('mode', value='damaged', label="damaged")}
% if allow_expired:
${h.radio('mode', value='expired', label="expired")}
% endif
</fieldset>
</td>
</tr>
@ -95,11 +127,13 @@
</table>
${h.hidden('quick_receive', value='false')}
${h.end_form()}
${h.hidden('delete_row', value='false')}
% if request.has_perm('{}.delete_row'.format(permission_prefix)):
<button type="button" id="delete-receiving-row">Delete this Row</button>
% endif
% if master.mobile_rows_deletable and request.has_perm('{}.delete_row'.format(permission_prefix)):
${h.form(url('mobile.{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='receiving-update')}
${h.csrf_token(request)}
${h.submit('submit', "Delete this Row")}
${h.end_form()}
% endif
% endif

View file

@ -48,7 +48,6 @@ from rattail.progress import SocketProgress
import colander
import deform
from pyramid import httpexceptions
from pyramid.renderers import render_to_response
from pyramid.response import FileResponse
from webhelpers2.html import HTML, tags
@ -1074,20 +1073,11 @@ class BatchMasterView(MasterView):
def get_parent(self, row):
return row.batch
def delete_row(self):
def delete_row_object(self, row):
"""
"Delete" a row from the batch. This sets the ``removed`` flag on the
row but does not truly delete it.
Perform the actual deletion of given row object.
"""
row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid'])
if not row:
raise httpexceptions.HTTPNotFound()
row.removed = True
batch = self.get_parent(row)
self.handler.refresh_batch_status(batch)
if batch.rowcount is not None:
batch.rowcount -= 1
return self.redirect(self.get_action_url('view', batch))
self.handler.remove_row(row)
def bulk_delete_rows(self):
"""
@ -1096,9 +1086,14 @@ class BatchMasterView(MasterView):
"""
batch = self.get_instance()
query = self.get_effective_row_data(sort=False)
# TODO: this should surely be handled by the handler...
if batch.rowcount is not None:
batch.rowcount -= query.count()
query.update({'removed': True}, synchronize_session=False)
self.Session.refresh(batch)
self.handler.refresh_batch_status(batch)
return self.redirect(self.get_action_url('view', batch))
def execute(self):

View file

@ -139,6 +139,7 @@ class MasterView(View):
mobile_rows_filterable = False
mobile_rows_viewable = False
mobile_rows_editable = False
mobile_rows_deletable = False
row_labels = {}
@ -2670,11 +2671,12 @@ class MasterView(View):
parent = self.get_parent(row)
return self.render_to_response('edit_row', {
'row': row,
'instance': row,
'parent_instance': parent,
'instance_title': self.get_row_instance_title(row),
'instance_url': instance_url,
'instance_deletable': self.row_deletable(row),
'parent_instance': parent,
'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent, mobile=True),
'form': form},
@ -2705,16 +2707,38 @@ class MasterView(View):
"""
return True
def delete_row_object(self, row):
"""
Perform the actual deletion of given row object.
"""
self.Session.delete(row)
def delete_row(self):
"""
"Delete" a sub-row from the parent.
Desktop view which can "delete" a sub-row from the parent.
"""
row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid'])
if not row:
raise httpexceptions.HTTPNotFound()
self.Session.delete(row)
raise self.notfound()
self.delete_row_object(row)
return self.redirect(self.get_action_url('edit', self.get_parent(row)))
def mobile_delete_row(self):
"""
Mobile view which can "delete" a sub-row from the parent.
"""
if self.request.method == 'POST':
parent = self.get_instance()
row = self.get_row_instance()
if self.get_parent(row) is not parent:
raise RuntimeError("Can only delete rows which belong to current object")
self.delete_row_object(row)
return self.redirect(self.get_action_url('view', parent, mobile=True))
self.session.flash("Must POST to delete a row", 'error')
return self.redirect(self.request.get_referrer(mobile=True))
def get_parent(self, row):
raise NotImplementedError
@ -3050,9 +3074,15 @@ class MasterView(View):
permission='{}.edit_row'.format(permission_prefix))
# delete row
if cls.has_rows and cls.rows_deletable:
if cls.has_rows:
if cls.rows_deletable or cls.mobile_rows_deletable:
config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
"Delete individual {} rows".format(model_title))
if cls.rows_deletable:
config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix),
permission='{}.delete_row'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
"Delete individual {} rows".format(model_title))
if cls.mobile_rows_deletable:
config.add_route('mobile.{}.delete_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix))
config.add_view(cls, attr='mobile_delete_row', route_name='mobile.{}.delete_row'.format(route_prefix),
permission='{}.delete_row'.format(permission_prefix))

View file

@ -160,16 +160,23 @@ class PurchaseView(MasterView):
default_active=True, default_verb='contains')
g.sorters['buyer'] = g.make_sorter(model.Person.display_name)
g.filters['date_ordered'].label = "Ordered"
# date_ordered
g.filters['date_ordered'].default_active = True
g.filters['date_ordered'].default_verb = 'equal'
g.set_label('date_ordered', "Ordered")
g.set_sort_defaults('date_ordered', 'desc')
g.set_enum('status', self.enum.PURCHASE_STATUS)
g.set_label('date_ordered', "Ordered")
# date_received
g.filters['date_received'].default_active = True
g.filters['date_received'].default_verb = 'equal'
g.set_label('date_received', "Received")
# status
g.set_enum('status', self.enum.PURCHASE_STATUS)
g.filters['status'].default_active = True
g.filters['status'].verbs = ['equal', 'not_equal', 'is_any']
g.filters['status'].default_verb = 'is_any'
g.set_label('invoice_number', "Invoice No.")
def configure_form(self, f):

View file

@ -55,6 +55,7 @@ class OrderingBatchView(PurchasingBatchView):
mobile_rows_creatable = True
mobile_rows_quickable = True
mobile_rows_editable = True
mobile_rows_deletable = True
has_worksheet = True
mobile_form_fields = [
@ -186,7 +187,7 @@ class OrderingBatchView(PurchasingBatchView):
'history': history,
'get_upc': lambda p: p.upc.pretty() if p.upc else '',
'header_columns': self.order_form_header_columns,
'ignore_cases': self.handler.ignore_cases,
'ignore_cases': not self.handler.allow_cases(),
})
def get_order_form_history(self, batch, costs, count):

View file

@ -27,12 +27,14 @@ Views for 'receiving' (purchasing) batches
from __future__ import unicode_literals, absolute_import
import re
import logging
import six
import sqlalchemy as sa
from rattail import pod
from rattail.db import model, api
from rattail.db.util import maxlen
from rattail.gpc import GPC
from rattail.time import localtime
from rattail.util import pretty_quantity, prettify, OrderedDict
@ -47,13 +49,16 @@ from tailbone import forms, grids
from tailbone.views.purchasing import PurchasingBatchView
log = logging.getLogger(__name__)
class MobileItemStatusFilter(grids.filters.MobileFilter):
value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
def filter_equal(self, query, value):
# NOTE: this is only relevant for truck dump
# NOTE: this is only relevant for truck dump or "from scratch"
if value == 'received':
return query.filter(sa.or_(
model.PurchaseBatchRow.cases_received != 0,
@ -105,6 +110,7 @@ class ReceivingBatchView(PurchasingBatchView):
mobile_rows_filterable = True
mobile_rows_creatable = True
mobile_rows_quickable = True
mobile_rows_deletable = True
allow_from_po = False
allow_from_scratch = True
@ -354,6 +360,7 @@ class ReceivingBatchView(PurchasingBatchView):
kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
if mobile:
if 'purchase' in self.request.POST:
purchase = self.get_purchase(self.request.POST['purchase'])
if isinstance(purchase, model.Purchase):
kwargs['purchase'] = purchase
@ -501,12 +508,19 @@ class ReceivingBatchView(PurchasingBatchView):
"""
batch = self.get_instance()
filters = grids.filters.GridFilterSet()
if batch.truck_dump:
value_choices = ['received', 'damaged', 'expired', 'all']
default_status = 'all'
else:
# visible filter options will depend on whether batch came from purchase
if self.handler.populated_from_purchase(batch):
value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
default_status = 'incomplete'
else:
value_choices = ['received', 'damaged', 'expired', 'all']
default_status = 'all'
# remove 'expired' filter option if not relevant
if 'expired' in value_choices and not self.handler.allow_expired_credits():
value_choices.remove('expired')
filters['status'] = MobileItemStatusFilter('status',
value_choices=value_choices,
default_value=default_status)
@ -522,10 +536,24 @@ class ReceivingBatchView(PurchasingBatchView):
mode = self.batch_mode
data = {'mode': mode}
form = forms.Form(schema=MobileNewReceivingBatch(), request=self.request)
schema = MobileNewReceivingBatch().bind(session=self.Session())
form = forms.Form(schema=schema, request=self.request)
if form.validate(newstyle=True):
if form.validated['workflow'] == 'truck_dump':
if form.validated['workflow'] == 'from_scratch':
if not self.allow_from_scratch:
raise NotImplementedError("Requested workflow not supported: from_scratch")
batch = self.model_class()
batch.store = self.rattail_config.get_store(self.Session())
batch.mode = mode
batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
batch.created_by = self.request.user
batch.date_received = localtime(self.rattail_config).date()
kwargs = self.get_batch_kwargs(batch, mobile=True)
batch = self.handler.make_batch(self.Session(), **kwargs)
return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid))
elif form.validated['workflow'] == 'truck_dump':
if not self.allow_truck_dump:
raise NotImplementedError("Requested workflow not supported: truck_dump")
batch = self.model_class()
@ -611,21 +639,35 @@ class ReceivingBatchView(PurchasingBatchView):
def quick_locate_rows(self, batch, entry):
rows = []
# we prefer "exact" matches, i.e. those which assumed the entry already
# contained the check digit.
provided = GPC(entry, calc_check_digit=False)
for row in batch.active_rows():
if row.upc == provided:
rows.append(row)
# try to locate rows by product uuid match before other key
product = self.Session.query(model.Product).get(entry)
if product:
rows = [row for row in batch.active_rows()
if row.product_uuid == product.uuid]
if rows:
return rows
# if no "exact" matches, we'll settle for those which assume the entry
# lacked a check digit.
key = self.rattail_config.product_key()
if key == 'upc':
# we prefer "exact" UPC matches, i.e. those which assumed the entry
# already contained the check digit.
provided = GPC(entry, calc_check_digit=False)
rows = [row for row in batch.active_rows()
if row.upc == provided]
if rows:
return rows
# if no "exact" UPC matches, we'll settle for those (UPC matches)
# which assume the entry lacked a check digit.
checked = GPC(entry, calc_check_digit='upc')
for row in batch.active_rows():
if row.upc == checked:
rows.append(row)
rows = [row for row in batch.active_rows()
if row.upc == checked]
return rows
elif key == 'item_id':
rows = [row for row in batch.active_rows()
if row.item_id == entry]
return rows
def save_quick_row_form(self, form):
@ -652,6 +694,18 @@ class ReceivingBatchView(PurchasingBatchView):
self.Session.flush()
return row
# try to locate product by uuid before other, more specific key
product = self.Session.query(model.Product).get(entry)
if product and not product.deleted:
row = model.PurchaseBatchRow()
row.product = product
self.handler.add_row(batch, row)
self.Session.flush()
return row
key = self.rattail_config.product_key()
if key == 'upc':
# try to locate product by upc
provided = GPC(entry, calc_check_digit=False)
checked = GPC(entry, calc_check_digit='upc')
@ -672,11 +726,38 @@ class ReceivingBatchView(PurchasingBatchView):
# product not in system, but presumably sane upc, so add to batch anyway
row = model.PurchaseBatchRow()
row.upc = provided # TODO: why not checked? how to know?
row.item_id = entry
row.description = "(unknown product)"
self.handler.add_row(batch, row)
self.Session.flush()
return row
elif key == 'item_id':
# try to locate product by item_id
product = api.get_product_by_item_id(self.Session(), entry)
if product:
row = model.PurchaseBatchRow()
row.product = product
self.handler.add_row(batch, row)
self.Session.flush()
return row
# check for "too long" item_id
if len(entry) > maxlen(model.PurchaseBatchRow.item_id):
return
# product not in system, but presumably sane item_id, so add to batch anyway
row = model.PurchaseBatchRow()
row.item_id = entry
row.description = "(unknown product)"
self.handler.add_row(batch, row)
self.Session.flush()
return row
else:
raise NotImplementedError("don't know how to handle product key: {}".format(key))
def redirect_after_quick_row(self, row, mobile=False):
if mobile:
return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
@ -695,11 +776,15 @@ class ReceivingBatchView(PurchasingBatchView):
context = {
'row': row,
'batch': batch,
'parent_instance': batch,
'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,
'populated_from_purchase': self.handler.populated_from_purchase(batch),
'allow_expired': self.handler.allow_expired_credits(),
'allow_cases': self.handler.allow_cases(),
}
if self.request.has_perm('{}.create_row'.format(permission_prefix)):
@ -707,15 +792,6 @@ class ReceivingBatchView(PurchasingBatchView):
update_form = forms.Form(schema=schema, request=self.request)
if update_form.validate(newstyle=True):
row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
# TODO: surely this (delete_row) should be split out to a separate view
if update_form.validated['delete_row']:
if not self.request.has_perm('{}.delete_row'.format(permission_prefix)):
raise httpexceptions.HTTPForbidden()
self.handler.remove_row(row)
return self.redirect(self.get_action_url('view', batch, mobile=True))
else: # not delete_row
mode = update_form.validated['mode']
cases = update_form.validated['cases']
units = update_form.validated['units']
@ -773,7 +849,7 @@ class ReceivingBatchView(PurchasingBatchView):
if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
context['uom'] = context['unit_uom']
if not row.cases_ordered and not row.units_ordered and not batch.truck_dump:
if self.handler.populated_from_purchase(batch) and not row.cases_ordered and not row.units_ordered:
self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
return self.render_to_response('view_row', context, mobile=True)
@ -844,9 +920,24 @@ class ReceivingBatchView(PurchasingBatchView):
cls._defaults(config)
# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
# session is not provided by the view at runtime (i.e. when it was instead
# being provided by the type instance, which was created upon app startup).
@colander.deferred
def valid_vendor(node, kw):
session = kw['session']
def validate(node, value):
vendor = session.query(model.Vendor).get(value)
if not vendor:
raise colander.Invalid(node, "Vendor not found")
return vendor.uuid
return validate
class MobileNewReceivingBatch(colander.MappingSchema):
vendor = colander.SchemaNode(forms.types.VendorType())
vendor = colander.SchemaNode(colander.String(),
validator=valid_vendor)
workflow = colander.SchemaNode(colander.String(),
validator=colander.OneOf([
@ -895,8 +986,6 @@ class MobileReceivingForm(colander.MappingSchema):
quick_receive = colander.SchemaNode(colander.Boolean())
delete_row = colander.SchemaNode(colander.Boolean())
def includeme(config):
ReceivingBatchView.defaults(config)

View file

@ -40,6 +40,8 @@ class SettingsView(MasterView):
Master view for the settings model.
"""
model_class = model.Setting
model_title = "Raw Setting"
model_title_plural = "Raw Settings"
feedback = re.compile(r'^rattail\.mail\.user_feedback\..*')
grid_columns = [