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'); var form = $(this).parents('form');
form.find('input[name="delete_row"]').val('true'); form.find('input[name="workflow"]').val('from_scratch');
form.submit(); form.submit();
}); });
@ -68,11 +68,15 @@ $(document).on('click', 'form.receiving-update .receiving-actions button', funct
}); });
// quick-receive (1 CS) // quick-receive (1 case or unit)
$(document).on('click', 'form.receiving-update .receive-one-case', function() { $(document).on('click', 'form.receiving-update .quick-receive', function() {
var form = $(this).parents('form:first'); var form = $(this).parents('form:first');
form.find('[name="mode"]').val('received'); form.find('[name="mode"]').val('received');
form.find('[name="cases"]').val('1'); 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.find('input[name="quick_receive"]').val('true');
form.submit(); form.submit();
}); });

View file

@ -149,7 +149,7 @@ def context_found(event):
return False return False
request.has_any_perm = has_any_perm request.has_any_perm = has_any_perm
def get_referrer(default=None): def get_referrer(default=None, mobile=False):
if request.params.get('referrer'): if request.params.get('referrer'):
return request.params['referrer'] return request.params['referrer']
if request.session.get('referrer'): if request.session.get('referrer'):
@ -157,7 +157,12 @@ def context_found(event):
referrer = request.referrer referrer = request.referrer
if (not referrer or referrer == request.current_route_url() if (not referrer or referrer == request.current_route_url()
or not referrer.startswith(request.host_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 return referrer
request.get_referrer = get_referrer request.get_referrer = get_referrer

View file

@ -16,3 +16,10 @@
## ${form.render(buttons=capture(self.buttons))|n} ## ${form.render(buttons=capture(self.buttons))|n}
${form.render()|n} ${form.render()|n}
</div><!-- form-wrapper --> </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> <button type="button" style="display: none;">Change</button>
</div> </div>
% else: % 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 % endif
${h.end_form()} ${h.end_form()}
% endif % 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)): % 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')} ${h.link_to("Edit", url('mobile.{}.edit_row'.format(route_prefix), uuid=instance.batch_uuid, row_uuid=instance.uuid), class_='ui-btn')}
% endif % 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 % endif
% if master.allow_from_scratch: % if master.allow_from_scratch:
<button type="button">Receive from Scratch</button> <button type="button" id="receive-from-scratch">Receive from Scratch</button>
% endif % endif
% if master.allow_truck_dump: % if master.allow_truck_dump:

View file

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

View file

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

View file

@ -139,6 +139,7 @@ class MasterView(View):
mobile_rows_filterable = False mobile_rows_filterable = False
mobile_rows_viewable = False mobile_rows_viewable = False
mobile_rows_editable = False mobile_rows_editable = False
mobile_rows_deletable = False
row_labels = {} row_labels = {}
@ -2670,11 +2671,12 @@ class MasterView(View):
parent = self.get_parent(row) parent = self.get_parent(row)
return self.render_to_response('edit_row', { return self.render_to_response('edit_row', {
'row': row,
'instance': row, 'instance': row,
'parent_instance': parent,
'instance_title': self.get_row_instance_title(row), 'instance_title': self.get_row_instance_title(row),
'instance_url': instance_url, 'instance_url': instance_url,
'instance_deletable': self.row_deletable(row), 'instance_deletable': self.row_deletable(row),
'parent_instance': parent,
'parent_title': self.get_instance_title(parent), 'parent_title': self.get_instance_title(parent),
'parent_url': self.get_action_url('view', parent, mobile=True), 'parent_url': self.get_action_url('view', parent, mobile=True),
'form': form}, 'form': form},
@ -2705,16 +2707,38 @@ class MasterView(View):
""" """
return True return True
def delete_row_object(self, row):
"""
Perform the actual deletion of given row object.
"""
self.Session.delete(row)
def delete_row(self): 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']) row = self.Session.query(self.model_row_class).get(self.request.matchdict['row_uuid'])
if not row: if not row:
raise httpexceptions.HTTPNotFound() raise self.notfound()
self.Session.delete(row) self.delete_row_object(row)
return self.redirect(self.get_action_url('edit', self.get_parent(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): def get_parent(self, row):
raise NotImplementedError raise NotImplementedError
@ -3050,9 +3074,15 @@ class MasterView(View):
permission='{}.edit_row'.format(permission_prefix)) permission='{}.edit_row'.format(permission_prefix))
# delete row # delete row
if cls.has_rows and cls.rows_deletable: if cls.has_rows:
config.add_route('{}.delete_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/delete'.format(url_prefix)) if cls.rows_deletable or cls.mobile_rows_deletable:
config.add_view(cls, attr='delete_row', route_name='{}.delete_row'.format(route_prefix), config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix),
permission='{}.delete_row'.format(permission_prefix)) "Delete individual {} rows".format(model_title))
config.add_tailbone_permission(permission_prefix, '{}.delete_row'.format(permission_prefix), if cls.rows_deletable:
"Delete individual {} rows".format(model_title)) 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))
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') default_active=True, default_verb='contains')
g.sorters['buyer'] = g.make_sorter(model.Person.display_name) 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_active = True
g.filters['date_ordered'].default_verb = 'equal' g.filters['date_ordered'].default_verb = 'equal'
g.set_label('date_ordered', "Ordered")
g.set_sort_defaults('date_ordered', 'desc') g.set_sort_defaults('date_ordered', 'desc')
g.set_enum('status', self.enum.PURCHASE_STATUS) # date_received
g.filters['date_received'].default_active = True
g.set_label('date_ordered', "Ordered") g.filters['date_received'].default_verb = 'equal'
g.set_label('date_received', "Received") 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.") g.set_label('invoice_number', "Invoice No.")
def configure_form(self, f): def configure_form(self, f):

View file

@ -55,6 +55,7 @@ class OrderingBatchView(PurchasingBatchView):
mobile_rows_creatable = True mobile_rows_creatable = True
mobile_rows_quickable = True mobile_rows_quickable = True
mobile_rows_editable = True mobile_rows_editable = True
mobile_rows_deletable = True
has_worksheet = True has_worksheet = True
mobile_form_fields = [ mobile_form_fields = [
@ -186,7 +187,7 @@ class OrderingBatchView(PurchasingBatchView):
'history': history, 'history': history,
'get_upc': lambda p: p.upc.pretty() if p.upc else '', 'get_upc': lambda p: p.upc.pretty() if p.upc else '',
'header_columns': self.order_form_header_columns, '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): 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 from __future__ import unicode_literals, absolute_import
import re import re
import logging
import six import six
import sqlalchemy as sa import sqlalchemy as sa
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 maxlen
from rattail.gpc import GPC from rattail.gpc import GPC
from rattail.time import localtime from rattail.time import localtime
from rattail.util import pretty_quantity, prettify, OrderedDict from rattail.util import pretty_quantity, prettify, OrderedDict
@ -47,13 +49,16 @@ from tailbone import forms, grids
from tailbone.views.purchasing import PurchasingBatchView from tailbone.views.purchasing import PurchasingBatchView
log = logging.getLogger(__name__)
class MobileItemStatusFilter(grids.filters.MobileFilter): class MobileItemStatusFilter(grids.filters.MobileFilter):
value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
def filter_equal(self, query, value): 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': if value == 'received':
return query.filter(sa.or_( return query.filter(sa.or_(
model.PurchaseBatchRow.cases_received != 0, model.PurchaseBatchRow.cases_received != 0,
@ -105,6 +110,7 @@ class ReceivingBatchView(PurchasingBatchView):
mobile_rows_filterable = True mobile_rows_filterable = True
mobile_rows_creatable = True mobile_rows_creatable = True
mobile_rows_quickable = True mobile_rows_quickable = True
mobile_rows_deletable = True
allow_from_po = False allow_from_po = False
allow_from_scratch = True allow_from_scratch = True
@ -354,13 +360,14 @@ class ReceivingBatchView(PurchasingBatchView):
kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
if mobile: if mobile:
purchase = self.get_purchase(self.request.POST['purchase']) if 'purchase' in self.request.POST:
if isinstance(purchase, model.Purchase): purchase = self.get_purchase(self.request.POST['purchase'])
kwargs['purchase'] = purchase if isinstance(purchase, model.Purchase):
kwargs['purchase'] = purchase
department = self.department_for_purchase(purchase) department = self.department_for_purchase(purchase)
if department: if department:
kwargs['department'] = department kwargs['department'] = department
else: # not mobile else: # not mobile
batch_type = self.request.POST['batch_type'] batch_type = self.request.POST['batch_type']
@ -501,12 +508,19 @@ class ReceivingBatchView(PurchasingBatchView):
""" """
batch = self.get_instance() batch = self.get_instance()
filters = grids.filters.GridFilterSet() filters = grids.filters.GridFilterSet()
if batch.truck_dump:
value_choices = ['received', 'damaged', 'expired', 'all'] # visible filter options will depend on whether batch came from purchase
default_status = 'all' if self.handler.populated_from_purchase(batch):
else:
value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
default_status = 'incomplete' 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', filters['status'] = MobileItemStatusFilter('status',
value_choices=value_choices, value_choices=value_choices,
default_value=default_status) default_value=default_status)
@ -522,10 +536,24 @@ class ReceivingBatchView(PurchasingBatchView):
mode = self.batch_mode mode = self.batch_mode
data = {'mode': 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.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: if not self.allow_truck_dump:
raise NotImplementedError("Requested workflow not supported: truck_dump") raise NotImplementedError("Requested workflow not supported: truck_dump")
batch = self.model_class() batch = self.model_class()
@ -611,22 +639,36 @@ class ReceivingBatchView(PurchasingBatchView):
def quick_locate_rows(self, batch, entry): def quick_locate_rows(self, batch, entry):
rows = [] rows = []
# we prefer "exact" matches, i.e. those which assumed the entry already # try to locate rows by product uuid match before other key
# contained the check digit. product = self.Session.query(model.Product).get(entry)
provided = GPC(entry, calc_check_digit=False) if product:
for row in batch.active_rows(): rows = [row for row in batch.active_rows()
if row.upc == provided: if row.product_uuid == product.uuid]
rows.append(row) if rows:
if rows: return rows
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')
rows = [row for row in batch.active_rows()
if row.upc == checked]
return rows return rows
# if no "exact" matches, we'll settle for those which assume the entry elif key == 'item_id':
# lacked a check digit. rows = [row for row in batch.active_rows()
checked = GPC(entry, calc_check_digit='upc') if row.item_id == entry]
for row in batch.active_rows(): return rows
if row.upc == checked:
rows.append(row)
return rows
def save_quick_row_form(self, form): def save_quick_row_form(self, form):
batch = self.get_instance() batch = self.get_instance()
@ -652,30 +694,69 @@ class ReceivingBatchView(PurchasingBatchView):
self.Session.flush() self.Session.flush()
return row return row
# try to locate product by upc # try to locate product by uuid before other, more specific key
provided = GPC(entry, calc_check_digit=False) product = self.Session.query(model.Product).get(entry)
checked = GPC(entry, calc_check_digit='upc') if product and not product.deleted:
product = api.get_product_by_upc(self.Session(), provided)
if not product:
product = api.get_product_by_upc(self.Session(), checked)
if product:
row = model.PurchaseBatchRow() row = model.PurchaseBatchRow()
row.product = product row.product = product
self.handler.add_row(batch, row) self.handler.add_row(batch, row)
self.Session.flush() self.Session.flush()
return row return row
# check for "bad" upc key = self.rattail_config.product_key()
if len(entry) > 14: if key == 'upc':
return
# product not in system, but presumably sane upc, so add to batch anyway # try to locate product by upc
row = model.PurchaseBatchRow() provided = GPC(entry, calc_check_digit=False)
row.upc = provided # TODO: why not checked? how to know? checked = GPC(entry, calc_check_digit='upc')
row.description = "(unknown product)" product = api.get_product_by_upc(self.Session(), provided)
self.handler.add_row(batch, row) if not product:
self.Session.flush() product = api.get_product_by_upc(self.Session(), checked)
return row if product:
row = model.PurchaseBatchRow()
row.product = product
self.handler.add_row(batch, row)
self.Session.flush()
return row
# check for "bad" upc
if len(entry) > 14:
return
# 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): def redirect_after_quick_row(self, row, mobile=False):
if mobile: if mobile:
@ -695,11 +776,15 @@ class ReceivingBatchView(PurchasingBatchView):
context = { context = {
'row': row, 'row': row,
'batch': batch, 'batch': batch,
'parent_instance': batch,
'instance': row, 'instance': row,
'instance_title': self.get_row_instance_title(row), 'instance_title': self.get_row_instance_title(row),
'parent_model_title': self.get_model_title(), 'parent_model_title': self.get_model_title(),
'product_image_url': pod.get_image_url(self.rattail_config, row.upc), 'product_image_url': pod.get_image_url(self.rattail_config, row.upc),
'form': form, '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)): if self.request.has_perm('{}.create_row'.format(permission_prefix)):
@ -707,56 +792,47 @@ class ReceivingBatchView(PurchasingBatchView):
update_form = forms.Form(schema=schema, request=self.request) update_form = forms.Form(schema=schema, request=self.request)
if update_form.validate(newstyle=True): if update_form.validate(newstyle=True):
row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row']) row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
mode = update_form.validated['mode']
cases = update_form.validated['cases']
units = update_form.validated['units']
# TODO: surely this (delete_row) should be split out to a separate view # add values as-is to existing case/unit amounts. note
if update_form.validated['delete_row']: # that this can sometimes give us negative values! e.g. if
if not self.request.has_perm('{}.delete_row'.format(permission_prefix)): # user scans 1 CS and then subtracts 2 EA, then we would
raise httpexceptions.HTTPForbidden() # have 1 / -2 for our counts. but we consider that to be
self.handler.remove_row(row) # expected, and other logic must allow for the possibility
return self.redirect(self.get_action_url('view', batch, mobile=True)) if cases:
setattr(row, 'cases_{}'.format(mode),
(getattr(row, 'cases_{}'.format(mode)) or 0) + cases)
if units:
setattr(row, 'units_{}'.format(mode),
(getattr(row, 'units_{}'.format(mode)) or 0) + units)
else: # not delete_row # if mode in ('damaged', 'expired', 'mispick'):
mode = update_form.validated['mode'] if mode in ('damaged', 'expired'):
cases = update_form.validated['cases'] self.attach_credit(row, mode, cases, units,
units = update_form.validated['units'] expiration_date=update_form.validated['expiration_date'],
# discarded=update_form.data['trash'],
# mispick_product=shipped_product)
)
# add values as-is to existing case/unit amounts. note # first undo any totals previously in effect for the row, then refresh
# that this can sometimes give us negative values! e.g. if if row.invoice_total:
# user scans 1 CS and then subtracts 2 EA, then we would batch.invoice_total -= row.invoice_total
# have 1 / -2 for our counts. but we consider that to be self.handler.refresh_row(row)
# expected, and other logic must allow for the possibility
if cases:
setattr(row, 'cases_{}'.format(mode),
(getattr(row, 'cases_{}'.format(mode)) or 0) + cases)
if units:
setattr(row, 'units_{}'.format(mode),
(getattr(row, 'units_{}'.format(mode)) or 0) + units)
# if mode in ('damaged', 'expired', 'mispick'): # keep track of last-used uom, although we just track
if mode in ('damaged', 'expired'): # whether or not it was 'CS' since the unit_uom can vary
self.attach_credit(row, mode, cases, units, sticky_case = None
expiration_date=update_form.validated['expiration_date'], if not update_form.validated['quick_receive']:
# discarded=update_form.data['trash'], if cases and not units:
# mispick_product=shipped_product) sticky_case = True
) elif units and not cases:
sticky_case = False
if sticky_case is not None:
self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
# first undo any totals previously in effect for the row, then refresh return self.redirect(self.get_action_url('view', batch, mobile=True))
if row.invoice_total:
batch.invoice_total -= row.invoice_total
self.handler.refresh_row(row)
# keep track of last-used uom, although we just track
# whether or not it was 'CS' since the unit_uom can vary
sticky_case = None
if not update_form.validated['quick_receive']:
if cases and not units:
sticky_case = True
elif units and not cases:
sticky_case = False
if sticky_case is not None:
self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
return self.redirect(self.get_action_url('view', batch, mobile=True))
# unit_uom can vary by product # unit_uom can vary by product
context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
@ -773,7 +849,7 @@ class ReceivingBatchView(PurchasingBatchView):
if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
context['uom'] = context['unit_uom'] 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') 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) return self.render_to_response('view_row', context, mobile=True)
@ -844,9 +920,24 @@ class ReceivingBatchView(PurchasingBatchView):
cls._defaults(config) 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): class MobileNewReceivingBatch(colander.MappingSchema):
vendor = colander.SchemaNode(forms.types.VendorType()) vendor = colander.SchemaNode(colander.String(),
validator=valid_vendor)
workflow = colander.SchemaNode(colander.String(), workflow = colander.SchemaNode(colander.String(),
validator=colander.OneOf([ validator=colander.OneOf([
@ -895,8 +986,6 @@ class MobileReceivingForm(colander.MappingSchema):
quick_receive = colander.SchemaNode(colander.Boolean()) quick_receive = colander.SchemaNode(colander.Boolean())
delete_row = colander.SchemaNode(colander.Boolean())
def includeme(config): def includeme(config):
ReceivingBatchView.defaults(config) ReceivingBatchView.defaults(config)

View file

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