Overhaul the Receiving Form to account for "product not found" etc.

Also shows ordered/received/etc. quantities
This commit is contained in:
Lance Edgar 2016-12-13 22:28:50 -06:00
parent acbb3d289c
commit ed252c6465
2 changed files with 245 additions and 53 deletions

View file

@ -23,16 +23,37 @@
function invalid_product(msg) { function invalid_product(msg) {
$('#received-product-info p').text(msg); $('#received-product-info p').text(msg);
$('#received-product-info img').hide(); $('#received-product-info img').hide();
$('#received-product-info .rogue-item-warning').hide(); $('#upc').focus().select();
$('#product-textbox').focus().select();
$('.field-wrapper.cases input').prop('disabled', true); $('.field-wrapper.cases input').prop('disabled', true);
$('.field-wrapper.units input').prop('disabled', true); $('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable'); $('.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() { $(function() {
$('#product-textbox').keydown(function(event) { $('#upc').keydown(function(event) {
if (key_allowed(event)) { if (key_allowed(event)) {
return true; return true;
@ -41,35 +62,75 @@
$('#product').val(''); $('#product').val('');
$('#received-product-info p').html("please ENTER a scancode"); $('#received-product-info p').html("please ENTER a scancode");
$('#received-product-info img').hide(); $('#received-product-info img').hide();
$('#received-product-info .rogue-item-warning').hide(); $('#received-product-info .warning').hide();
$('.product-fields').hide();
$('.receiving-fields').hide();
$('.field-wrapper.cases input').prop('disabled', true); $('.field-wrapper.cases input').prop('disabled', true);
$('.field-wrapper.units input').prop('disabled', true); $('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable'); $('.buttons button').button('disable');
return true; return true;
} }
// when user presses ENTER, do product lookup
if (event.which == 13) { if (event.which == 13) {
var input = $(this); var upc = $(this).val();
var data = {upc: input.val()}; var data = {'upc': upc};
$.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) { $.get('${url('purchases.batch.receiving_lookup', uuid=batch.uuid)}', data, function(data) {
if (data.error) { if (data.error) {
alert(data.error); alert(data.error);
if (data.redirect) { if (data.redirect) {
$('#receiving-form').mask("Redirecting..."); $('#receiving-form').mask("Redirecting...");
location.href = data.redirect; location.href = data.redirect;
} }
} else if (data.product) { } else if (data.product) {
input.val(data.product.upc_pretty); $('#upc').val(data.product.upc_pretty);
$('#product').val(data.product.uuid); $('#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);
$('#received-product-info p').text(data.product.full_description); $('#received-product-info p').text(data.product.full_description);
$('#received-product-info img').attr('src', data.product.image_url).show(); $('#received-product-info img').attr('src', data.product.image_url).show();
$('#received-product-info .rogue-item-warning').hide(); if (! data.product.uuid) {
if (! data.product.found_in_batch) { // $('#received-product-info .warning.notfound').show();
$('#received-product-info .rogue-item-warning').show(); $('.product-fields').show();
}
if (data.product.found_in_batch) {
show_quantity('ordered', data.product.cases_ordered, data.product.units_ordered);
show_quantity('received', data.product.cases_received, data.product.units_received);
show_quantity('damaged', data.product.cases_damaged, data.product.units_damaged);
show_quantity('expired', data.product.cases_expired, data.product.units_expired);
show_quantity('mispick', data.product.cases_mispick, data.product.units_mispick);
$('.receiving-fields').show();
} else {
$('#received-product-info .warning.notordered').show();
} }
$('.field-wrapper.cases input').prop('disabled', false); $('.field-wrapper.cases input').prop('disabled', false);
$('.field-wrapper.units input').prop('disabled', false); $('.field-wrapper.units input').prop('disabled', false);
$('.buttons button').button('enable'); $('.buttons button').button('enable');
$('#cases').focus().select(); $('#cases').focus().select();
} else if (data.upc) {
$('#upc').val(data.upc_pretty);
$('#received-product-info p').text("product not found in our system");
$('#received-product-info img').attr('src', data.image_url).show();
$('#product').val('');
$('#brand_name').val('');
$('#description').val('');
$('#size').val('');
$('#case_quantity').val('');
$('#received-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 { } else {
invalid_product('product not found'); invalid_product('product not found');
} }
@ -212,7 +273,7 @@
$(this).mask("Working..."); $(this).mask("Working...");
}); });
$('#product-textbox').focus(); $('#upc').focus();
$('.field-wrapper.cases input').prop('disabled', true); $('.field-wrapper.cases input').prop('disabled', true);
$('.field-wrapper.units input').prop('disabled', true); $('.field-wrapper.units input').prop('disabled', true);
$('.buttons button').button('disable'); $('.buttons button').button('disable');
@ -235,7 +296,7 @@
margin: 0.5em 0; margin: 0.5em 0;
} }
.product-info .rogue-item-warning { #received-product-info .warning {
background: #f66; background: #f66;
display: none; display: none;
} }
@ -270,18 +331,72 @@
${h.hidden('ordered_product')} ${h.hidden('ordered_product')}
<div class="field-wrapper"> <div class="field-wrapper">
<label for="product-textbox">Product</label> <label for="upc">Product UPC</label>
<div class="field"> <div class="field">
${h.hidden('product')} ${h.hidden('product')}
<div>${h.text('product-textbox', autocomplete='off')}</div> <div>${h.text('upc', autocomplete='off')}</div>
<div id="received-product-info" class="product-info"> <div id="received-product-info" class="product-info">
<p>please ENTER a scancode</p> <p>please ENTER a scancode</p>
<div class="img-wrapper"><img /></div> <div class="img-wrapper"><img /></div>
<div class="rogue-item-warning">warning: product not found on current purchase</div> <div class="warning notfound">please confirm UPC and provide more details</div>
<div class="warning notordered">warning: product not found on current purchase</div>
</div> </div>
</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="receiving-fields" style="display: none;">
<div class="field-wrapper quantity_ordered">
<label for="quantity_ordered">Ordered</label>
<div class="field"></div>
</div>
<div class="field-wrapper quantity_received">
<label for="quantity_received">Received</label>
<div class="field"></div>
</div>
<div class="field-wrapper quantity_damaged">
<label for="quantity_damaged">Damaged</label>
<div class="field"></div>
</div>
<div class="field-wrapper quantity_expired">
<label for="quantity_expired">Expired</label>
<div class="field"></div>
</div>
<div class="field-wrapper quantity_mispick">
<label for="quantity_mispick">Mispick</label>
<div class="field"></div>
</div>
</div>
<div class="field-wrapper cases"> <div class="field-wrapper cases">
<label for="cases">Cases</label> <label for="cases">Cases</label>
<div class="field">${h.text('cases', autocomplete='off')}</div> <div class="field">${h.text('cases', autocomplete='off')}</div>

View file

@ -33,10 +33,11 @@ from sqlalchemy import orm
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.gpc import GPC from rattail.gpc import GPC
from rattail.time import localtime from rattail.time import localtime
from rattail.core import Object from rattail.core import Object
from rattail.util import OrderedDict from rattail.util import OrderedDict, pretty_quantity
import formalchemy as fa import formalchemy as fa
import formencode as fe import formencode as fe
@ -55,8 +56,13 @@ class ReceivingForm(forms.Schema):
filter_extra_fields = True filter_extra_fields = True
mode = fe.validators.OneOf(['received', 'damaged', 'expired', 'mispick']) mode = fe.validators.OneOf(['received', 'damaged', 'expired', 'mispick'])
product = forms.validators.ValidProduct() product = forms.validators.ValidProduct()
cases = fe.validators.Int() upc = forms.validators.ValidGPC()
units = fe.validators.Int() brand_name = fe.validators.String()
description = fe.validators.String()
size = fe.validators.String()
case_quantity = fe.validators.Number()
cases = fe.validators.Number()
units = fe.validators.Number()
expiration_date = fe.validators.DateValidator() expiration_date = fe.validators.DateValidator()
ordered_product = forms.validators.ValidProduct() ordered_product = forms.validators.ValidProduct()
@ -361,7 +367,9 @@ class PurchaseBatchView(BatchMasterView):
def row_grid_row_attrs(self, row, i): def row_grid_row_attrs(self, row, i):
attrs = {} attrs = {}
if row.status_code in (row.STATUS_INCOMPLETE, if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
attrs['class_'] = 'warning'
elif row.status_code in (row.STATUS_INCOMPLETE,
row.STATUS_ORDERED_RECEIVED_DIFFER): row.STATUS_ORDERED_RECEIVED_DIFFER):
attrs['class_'] = 'notice' attrs['class_'] = 'notice'
return attrs return attrs
@ -409,6 +417,9 @@ class PurchaseBatchView(BatchMasterView):
fs.item_lookup, fs.item_lookup,
fs.upc, fs.upc,
fs.product, fs.product,
fs.brand_name,
fs.description,
fs.size,
fs.case_quantity, fs.case_quantity,
fs.cases_ordered, fs.cases_ordered,
fs.units_ordered, fs.units_ordered,
@ -450,6 +461,12 @@ class PurchaseBatchView(BatchMasterView):
elif self.viewing: elif self.viewing:
del fs.item_lookup del fs.item_lookup
if fs.model.product:
del (fs.brand_name,
fs.description,
fs.size)
else:
del fs.product
def before_create_row(self, form): def before_create_row(self, form):
row = form.fieldset.model row = form.fieldset.model
@ -615,8 +632,8 @@ class PurchaseBatchView(BatchMasterView):
if row.po_total and not row.removed: if row.po_total and not row.removed:
batch.po_total -= row.po_total batch.po_total -= row.po_total
if cases_ordered or units_ordered: if cases_ordered or units_ordered:
row.cases_ordered = cases_ordered row.cases_ordered = cases_ordered or None
row.units_ordered = units_ordered row.units_ordered = units_ordered or None
row.removed = False row.removed = False
self.handler.refresh_row(row) self.handler.refresh_row(row)
else: else:
@ -627,13 +644,13 @@ class PurchaseBatchView(BatchMasterView):
row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1 row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1
row.product = product row.product = product
batch.data_rows.append(row) batch.data_rows.append(row)
row.cases_ordered = cases_ordered row.cases_ordered = cases_ordered or None
row.units_ordered = units_ordered row.units_ordered = units_ordered or None
self.handler.refresh_row(row) self.handler.refresh_row(row)
return { return {
'row_cases_ordered': '' if row.removed else int(row.cases_ordered), 'row_cases_ordered': '' if row.removed else int(row.cases_ordered or 0),
'row_units_ordered': '' if row.removed else int(row.units_ordered), 'row_units_ordered': '' if row.removed else int(row.units_ordered or 0),
'row_po_total': '' if row.removed else '${:0,.2f}'.format(row.po_total), 'row_po_total': '' if row.removed else '${:0,.2f}'.format(row.po_total),
'batch_po_total': '${:0,.2f}'.format(batch.po_total), 'batch_po_total': '${:0,.2f}'.format(batch.po_total),
} }
@ -689,7 +706,11 @@ class PurchaseBatchView(BatchMasterView):
mode = form.data['mode'] mode = form.data['mode']
shipped_product = form.data['product'] shipped_product = form.data['product']
product = form.data['ordered_product'] if mode == 'mispick' else shipped_product product = form.data['ordered_product'] if mode == 'mispick' else shipped_product
if product:
rows = [row for row in batch.active_rows() if row.product is product] rows = [row for row in batch.active_rows() if row.product is product]
else:
upc = form.data['upc']
rows = [row for row in batch.active_rows() if not row.product and row.upc == upc]
if rows: if rows:
if len(rows) > 1: if len(rows) > 1:
log.warning("found {} matching rows in batch {} for product: {}".format( log.warning("found {} matching rows in batch {} for product: {}".format(
@ -698,6 +719,11 @@ class PurchaseBatchView(BatchMasterView):
else: else:
row = model.PurchaseBatchRow() row = model.PurchaseBatchRow()
row.product = product row.product = product
row.upc = form.data['upc']
row.brand_name = form.data['brand_name']
row.description = form.data['description']
row.size = form.data['size']
row.case_quantity = form.data['case_quantity']
batch.add_row(row) batch.add_row(row)
cases = form.data['cases'] cases = form.data['cases']
@ -716,9 +742,12 @@ class PurchaseBatchView(BatchMasterView):
self.handler.refresh_row(row) self.handler.refresh_row(row)
description = make_full_description(form.data['brand_name'],
form.data['description'],
form.data['size'])
self.request.session.flash("({}) {} cases, {} units: {} {}".format( self.request.session.flash("({}) {} cases, {} units: {} {}".format(
form.data['mode'], form.data['cases'] or 0, form.data['units'] or 0, form.data['mode'], form.data['cases'] or 0, form.data['units'] or 0,
product.upc.pretty(), product)) form.data['upc'].pretty(), description))
return self.redirect(self.request.current_route_url()) return self.redirect(self.request.current_route_url())
title = self.get_instance_title(batch) title = self.get_instance_title(batch)
@ -743,35 +772,78 @@ class PurchaseBatchView(BatchMasterView):
'error': "Current batch has already been executed", 'error': "Current batch has already been executed",
'redirect': self.get_action_url('view', batch), 'redirect': self.get_action_url('view', batch),
} }
data = None data = {}
upc = self.request.GET.get('upc', '').strip() upc = self.request.GET.get('upc', '').strip()
upc = re.sub(r'\D', '', upc) upc = re.sub(r'\D', '', upc)
if upc: if upc:
product = api.get_product_by_upc(Session(), 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')
rows = Session.query(model.PurchaseBatchRow)\
.filter(model.PurchaseBatchRow.batch == batch)\
.filter(model.PurchaseBatchRow.upc.in_((provided, checked)))\
.all()
if rows:
if len(rows) > 1:
log.warning("found multiple UPC matches for {} in batch {}: {}".format(
upc, batch.id_str, batch))
row = rows[0]
data['uuid'] = row.product_uuid
data['upc'] = unicode(row.upc)
data['upc_pretty'] = row.upc.pretty()
data['full_description'] = make_full_description(row.brand_name, row.description, row.size)
data['brand_name'] = row.brand_name
data['description'] = row.description
data['size'] = row.size
data['case_quantity'] = pretty_quantity(row.case_quantity)
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc)
data['found_in_batch'] = True
data['cases_ordered'] = pretty_quantity(row.cases_ordered, empty_zero=True)
data['units_ordered'] = pretty_quantity(row.units_ordered, empty_zero=True)
data['cases_received'] = pretty_quantity(row.cases_received, empty_zero=True)
data['units_received'] = pretty_quantity(row.units_received, empty_zero=True)
data['cases_damaged'] = pretty_quantity(row.cases_damaged, empty_zero=True)
data['units_damaged'] = pretty_quantity(row.units_damaged, empty_zero=True)
data['cases_expired'] = pretty_quantity(row.cases_expired, empty_zero=True)
data['units_expired'] = pretty_quantity(row.units_expired, empty_zero=True)
data['cases_mispick'] = pretty_quantity(row.cases_mispick, empty_zero=True)
data['units_mispick'] = pretty_quantity(row.units_mispick, empty_zero=True)
else: # no match in our batch, do full product search
product = api.get_product_by_upc(Session(), provided)
if not product: if not product:
# Try again, assuming caller did not include check digit. product = api.get_product_by_upc(Session(), checked)
upc = GPC(upc, calc_check_digit='upc')
product = api.get_product_by_upc(Session(), upc)
if product and (not product.deleted or self.request.has_perm('products.view_deleted')): if product and (not product.deleted or self.request.has_perm('products.view_deleted')):
data = { data['uuid'] = product.uuid
'uuid': product.uuid, data['upc'] = unicode(product.upc)
'upc': unicode(product.upc), data['upc_pretty'] = product.upc.pretty()
'upc_pretty': product.upc.pretty(), data['full_description'] = product.full_description
'full_description': product.full_description, data['brand_name'] = unicode(product.brand or '')
'image_url': pod.get_image_url(self.rattail_config, product.upc), data['description'] = product.description
} data['size'] = product.size
data['case_quantity'] = 1 # default
cost = product.cost_for_vendor(batch.vendor) cost = product.cost_for_vendor(batch.vendor)
if cost: if cost:
data['cost_found'] = True data['cost_found'] = True
if int(cost.case_size) == cost.case_size: data['cost_case_size'] = pretty_quantity(cost.case_size)
data['cost_case_size'] = int(cost.case_size) data['case_quantity'] = pretty_quantity(cost.case_size)
else:
data['cost_case_size'] = '{:0.4f}'.format(cost.case_size)
else: else:
data['cost_found'] = False data['cost_found'] = False
data['image_url'] = pod.get_image_url(self.rattail_config, product.upc)
data['found_in_batch'] = product in [row.product for row in batch.active_rows()] data['found_in_batch'] = product in [row.product for row in batch.active_rows()]
return {'product': data} 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 mobile_index(self):
self.mobile = True
return self.render_to_response('mobile_index', {})
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
@ -781,6 +853,11 @@ class PurchaseBatchView(BatchMasterView):
model_key = cls.get_model_key() model_key = cls.get_model_key()
model_title = cls.get_model_title() model_title = cls.get_model_title()
# mobile
config.add_route('{}.mobile'.format(route_prefix), '/mobile{}'.format(url_prefix))
config.add_view(cls, attr='mobile_index', route_name='{}.mobile'.format(route_prefix),
permission='{}.list'.format(permission_prefix))
# eligible purchases (AJAX) # eligible purchases (AJAX)
config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix)) config.add_route('{}.eligible_purchases'.format(route_prefix), '{}/eligible-purchases'.format(url_prefix))
config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix), config.add_view(cls, attr='eligible_purchases', route_name='{}.eligible_purchases'.format(route_prefix),