diff --git a/tailbone/static/js/tailbone.mobile.js b/tailbone/static/js/tailbone.mobile.js
index fb392425..6f94c2f6 100644
--- a/tailbone/static/js/tailbone.mobile.js
+++ b/tailbone/static/js/tailbone.mobile.js
@@ -39,12 +39,14 @@ $(document).on('pagecreate', function() {
/**
* Automatically set focus to certain fields, on various pages
+ * TODO: this should accept selector params instead of hard-coding..?
*/
function setfocus() {
var el = null;
var queries = [
'#username',
'#new-purchasing-batch-vendor-text',
+ // '.receiving-upc-search',
];
$.each(queries, function(i, query) {
el = $(query);
@@ -92,3 +94,60 @@ $(document).on('click', 'form[name="new-purchasing-batch"] [data-role="listview"
$(document).on('click', '#datasync-restart', function() {
$(this).button('disable');
});
+
+
+
+// handle Enter press for receiving UPC lookup
+$(document).on('keydown', '.receiving-upc-search', function(event) {
+ if (event.which == 13) {
+ $.mobile.navigate($(this).data('url') + '?upc=' + $(this).val());
+ }
+});
+
+
+// handle numeric buttons for receiving
+// $(document).on('click', '#receiving-quantity-keypad-thingy .ui-btn', function() {
+$(document).on('click', '#receiving-quantity-keypad-thingy .keypad-button', function() {
+ var quantity = $('.receiving-quantity');
+ var value = quantity.text();
+ var key = $(this).text();
+ if (key == 'Del') {
+ if (value.length == 1) {
+ quantity.text('0');
+ } else {
+ quantity.text(value.substring(0, value.length - 1));
+ }
+ } else if (key == '.') {
+ if (value.indexOf('.') == -1) {
+ quantity.text(value + '.');
+ }
+ } else {
+ if (value == '0') {
+ quantity.text(key);
+ } else {
+ quantity.text(value + key);
+ }
+ }
+});
+
+
+// handle receiving action buttons
+$(document).on('click', '.receiving-actions button', function() {
+ var action = $(this).data('action');
+ var form = $('form.receiving-update');
+ var uom = form.find('[name="receiving-uom"]').val();
+ var qty = form.find('.receiving-quantity').text();
+ if (action == 'add' || action == 'subtract') {
+ if (qty != '0') {
+ if (action == 'subtract') {
+ qty = '-' + qty;
+ }
+ if (uom == 'CS') {
+ form.find('[name="cases"]').val(qty);
+ } else { // units
+ form.find('[name="units"]').val(qty);
+ }
+ form.submit();
+ }
+ }
+});
diff --git a/tailbone/templates/mobile/master/view_row.mako b/tailbone/templates/mobile/master/view_row.mako
new file mode 100644
index 00000000..74df7e9e
--- /dev/null
+++ b/tailbone/templates/mobile/master/view_row.mako
@@ -0,0 +1,4 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/master/view.mako" />
+
+${parent.body()}
diff --git a/tailbone/templates/mobile/receiving/create.mako b/tailbone/templates/mobile/receiving/create.mako
new file mode 100644
index 00000000..a82ee3aa
--- /dev/null
+++ b/tailbone/templates/mobile/receiving/create.mako
@@ -0,0 +1,48 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/base.mako" />
+
+<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » New Batch%def>
+
+${h.form(request.current_route_url(), class_='ui-filterable', name='new-purchasing-batch')}
+${h.csrf_token(request)}
+
+% if vendor is Undefined:
+
+
+
+ ${h.hidden('vendor')}
+ ${h.text('new-purchasing-batch-vendor-text', placeholder="Vendor name", autocomplete='off', **{'data-type': 'search'})}
+
+
+
+
+
+
+ ${h.submit('submit', "Find purchase orders")}
+ ##
+
+% else: ## vendor is known
+
+
+
+ ${h.hidden('vendor', value=vendor.uuid)}
+ ${vendor}
+
+
+
+ % if purchases:
+ ${h.hidden('purchase')}
+
+ % for uuid, purchase in purchases:
+ - ${h.link_to(purchase, '#')}
+ % endfor
+
+ % else:
+ (no eligible purchases found)
+ % endif
+
+ ## ${h.link_to("Receive from scratch for {}".format(vendor), '#', class_='ui-btn ui-corner-all')}
+
+% endif
+
+${h.end_form()}
diff --git a/tailbone/templates/mobile/receiving/index.mako b/tailbone/templates/mobile/receiving/index.mako
new file mode 100644
index 00000000..098a0078
--- /dev/null
+++ b/tailbone/templates/mobile/receiving/index.mako
@@ -0,0 +1,18 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/master/index.mako" />
+
+<%def name="title()">Receiving%def>
+
+% if request.has_perm('receiving.create'):
+ ${h.link_to("New Receiving Batch", url('mobile.receiving.create'), class_='ui-btn ui-corner-all')}
+% endif
+
+
+
+
+${parent.body()}
diff --git a/tailbone/templates/mobile/receiving/view.mako b/tailbone/templates/mobile/receiving/view.mako
new file mode 100644
index 00000000..e37a46e3
--- /dev/null
+++ b/tailbone/templates/mobile/receiving/view.mako
@@ -0,0 +1,25 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/master/view.mako" />
+
+<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${instance.id_str}%def>
+
+${form.render()|n}
+
+
+${h.text('upc-search', class_='receiving-upc-search', placeholder="Enter UPC", autocomplete='off', **{'data-type': 'search', 'data-url': url('mobile.receiving.lookup', uuid=batch.uuid)})}
+
+
+
+
+
+
+ % for obj in grid.iter_rows():
+ - ${grid.listitem.render_readonly()}
+ % endfor
+
diff --git a/tailbone/templates/mobile/receiving/view_row.mako b/tailbone/templates/mobile/receiving/view_row.mako
new file mode 100644
index 00000000..dd7440c5
--- /dev/null
+++ b/tailbone/templates/mobile/receiving/view_row.mako
@@ -0,0 +1,107 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/master/view_row.mako" />
+
+## TODO: this is broken for actual page (header) title
+<%def name="title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(instance.batch.id_str, url('mobile.receiving.view', uuid=instance.batch_uuid))} » ${row.upc.pretty()}%def>
+
+<% unit_uom = 'LB' if row.product.weighed else 'EA' %>
+
+
+
+
${instance.brand_name}
+ ${instance.description} ${instance.size}
+ ${h.pretty_quantity(row.case_quantity)} ${unit_uom} per CS
+
+
+ ${h.image(product_image_url, "product image")}
+
+
+
+
+
+
+ ordered |
+ ${h.pretty_quantity(row.cases_ordered or 0)} / ${h.pretty_quantity(row.units_ordered or 0)} |
+
+
+ received |
+ ${h.pretty_quantity(row.cases_received or 0)} / ${h.pretty_quantity(row.units_received or 0)} |
+
+
+ damaged |
+ ${h.pretty_quantity(row.cases_damaged or 0)} / ${h.pretty_quantity(row.units_damaged or 0)} |
+
+
+ expired |
+ ${h.pretty_quantity(row.cases_expired or 0)} / ${h.pretty_quantity(row.units_expired or 0)} |
+
+
+
+
+
+
+
+ ${h.link_to("7", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("8", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("9", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+
+
+ ${h.link_to("4", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("5", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("6", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+
+
+ ${h.link_to("1", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("2", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("3", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+
+
+ ${h.link_to("0", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to(".", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+ ${h.link_to("Del", '#', class_='keypad-button ui-btn ui-btn-inline ui-corner-all')} |
+
+
+
+
+${h.form(request.current_route_url(), class_='receiving-update')}
+${h.csrf_token(request)}
+${h.hidden('row', value=row.uuid)}
+${h.hidden('cases')}
+${h.hidden('units')}
+
+<%
+ uom = 'CS'
+ if row.units_ordered and not row.cases_ordered:
+ uom = 'EA'
+%>
+
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+${h.end_form()}
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 2112462b..dfa715a5 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -60,6 +60,7 @@ class MasterView(View):
downloadable = False
supports_mobile = False
+ mobile_creatable = False
listing = False
creating = False
@@ -87,6 +88,8 @@ class MasterView(View):
rows_deletable_speedbump = False
rows_bulk_deletable = False
+ mobile_rows_viewable = False
+
@property
def Session(self):
"""
@@ -238,6 +241,24 @@ class MasterView(View):
return ListItemRenderer
+ def mobile_row_listitem_renderer(self):
+ """
+ Must return a FormAlchemy field renderer callable for the mobile row
+ grid's list item field.
+ """
+ master = self
+
+ class ListItemRenderer(fa.FieldRenderer):
+ def render_readonly(self, **kwargs):
+ return master.render_mobile_row_listitem(self.raw_value, **kwargs)
+
+ return ListItemRenderer
+
+ def render_mobile_row_listitem(self, row, **kwargs):
+ if row is None:
+ return ''
+ return tags.link_to(row, '#')
+
def create(self):
"""
View for creating a new model record.
@@ -316,6 +337,9 @@ class MasterView(View):
# 'instance_deletable': self.deletable_instance(instance),
'form': form,
}
+ if self.has_rows:
+ context['model_row_class'] = self.model_row_class
+ context['grid'] = self.make_mobile_row_grid(instance=instance)
return self.render_to_response('view', context, mobile=True)
def make_mobile_form(self, instance, **kwargs):
@@ -331,6 +355,25 @@ class MasterView(View):
form.readonly = self.viewing
return form
+ def preconfigure_mobile_row_fieldset(self, fieldset):
+ self._preconfigure_row_fieldset(fieldset)
+
+ def configure_mobile_row_fieldset(self, fieldset):
+ self.configure_row_fieldset(fieldset)
+
+ def make_mobile_row_form(self, row, **kwargs):
+ """
+ Make a form for use with mobile CRUD views, for the given row object.
+ """
+ fieldset = self.make_fieldset(row)
+ self.preconfigure_mobile_row_fieldset(fieldset)
+ self.configure_mobile_row_fieldset(fieldset)
+ kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
+ factory = kwargs.pop('factory', forms.AlchemyForm)
+ form = factory(self.request, fieldset, **kwargs)
+ form.readonly = self.viewing
+ return form
+
def preconfigure_mobile_fieldset(self, fieldset):
self._preconfigure_fieldset(fieldset)
@@ -340,6 +383,66 @@ class MasterView(View):
"""
self.configure_fieldset(fieldset)
+ def get_mobile_row_data(self, parent):
+ return self.get_row_data(parent)
+
+ def make_mobile_row_grid_kwargs(self, **kwargs):
+ defaults = {
+ 'pageable': True,
+ 'sortable': True,
+ }
+ defaults.update(kwargs)
+ return defaults
+
+ def make_mobile_row_grid(self, **kwargs):
+ """
+ Make a new (configured) rows grid instance for mobile.
+ """
+ parent = kwargs.pop('instance', self.get_instance())
+ kwargs['instance'] = parent
+ kwargs['data'] = self.get_mobile_row_data(parent)
+ kwargs['key'] = 'mobile.{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()])
+ kwargs.setdefault('request', self.request)
+ kwargs.setdefault('model_class', self.model_row_class)
+ kwargs = self.make_mobile_row_grid_kwargs(**kwargs)
+ factory = self.get_grid_factory()
+ grid = factory(**kwargs)
+ self.configure_mobile_row_grid(grid)
+ grid.load_settings()
+ return grid
+
+ def mobile_row_listitem_field(self):
+ """
+ Must return a FormAlchemy field to be appended to row grid, or ``None``
+ if none is desired.
+ """
+ return fa.Field('listitem', value=lambda obj: obj,
+ renderer=self.mobile_row_listitem_renderer())
+
+ def configure_mobile_row_grid(self, grid):
+ listitem = self.mobile_row_listitem_field()
+ if listitem:
+ grid.append(listitem)
+ grid.configure(include=[grid.listitem])
+ else:
+ grid.configure()
+
+ def mobile_view_row(self):
+ """
+ Mobile view for row items
+ """
+ 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(),
+ 'form': form,
+ }
+ return self.render_to_response('view_row', context, mobile=True)
+
def make_default_row_grid_tools(self, obj):
if self.rows_creatable:
link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
@@ -1476,12 +1579,17 @@ class MasterView(View):
permission='{}.list'.format(permission_prefix))
# create
+ if cls.creatable or cls.mobile_creatable:
+ config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix),
+ "Create new {}".format(model_title))
if cls.creatable:
- config.add_route('{0}.create'.format(route_prefix), '{0}/new'.format(url_prefix))
- config.add_view(cls, attr='create', route_name='{0}.create'.format(route_prefix),
- permission='{0}.create'.format(permission_prefix))
- config.add_tailbone_permission(permission_prefix, '{0}.create'.format(permission_prefix),
- "Create new {0}".format(model_title))
+ config.add_route('{}.create'.format(route_prefix), '{}/new'.format(url_prefix))
+ config.add_view(cls, attr='create', route_name='{}.create'.format(route_prefix),
+ permission='{}.create'.format(permission_prefix))
+ if cls.mobile_creatable:
+ config.add_route('mobile.{}.create'.format(route_prefix), '/mobile{}/new'.format(url_prefix))
+ config.add_view(cls, attr='mobile_create', route_name='mobile.{}.create'.format(route_prefix),
+ permission='{}.create'.format(permission_prefix))
# bulk delete
if cls.bulk_deletable:
@@ -1556,10 +1664,15 @@ class MasterView(View):
"Create new {} rows".format(model_title))
# view row
- if cls.has_rows and cls.rows_viewable:
- config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix))
- config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix),
- permission='{}.view'.format(permission_prefix))
+ if cls.has_rows:
+ if cls.rows_viewable:
+ config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix))
+ config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix),
+ permission='{}.view'.format(permission_prefix))
+ if cls.mobile_rows_viewable:
+ config.add_route('mobile.{}.view'.format(row_route_prefix), '/mobile{}/{{uuid}}'.format(row_url_prefix))
+ config.add_view(cls, attr='mobile_view_row', route_name='mobile.{}.view'.format(row_route_prefix),
+ permission='{}.view'.format(permission_prefix))
# edit row
if cls.has_rows and cls.rows_editable:
diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py
index 52b6a4e0..069c38bc 100644
--- a/tailbone/views/purchasing/__init__.py
+++ b/tailbone/views/purchasing/__init__.py
@@ -31,3 +31,4 @@ from .batch import PurchasingBatchView
def includeme(config):
config.include('tailbone.views.purchasing.ordering')
+ config.include('tailbone.views.purchasing.receiving')
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 03988727..64d359c3 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -46,9 +46,6 @@ class PurchasingBatchView(BatchMasterView):
model_class = model.PurchaseBatch
model_row_class = model.PurchaseBatchRow
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
- rows_creatable = True
- rows_editable = True
- edit_with_rows = False
@property
def batch_mode(self):
@@ -530,3 +527,9 @@ class PurchasingBatchView(BatchMasterView):
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),
renderer='json', permission='{}.view'.format(permission_prefix))
+
+ @classmethod
+ def defaults(cls, config):
+ cls._purchasing_defaults(config)
+ cls._batch_defaults(config)
+ cls._defaults(config)
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index e2f145bd..7b3e402d 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -44,8 +44,6 @@ class OrderingBatchView(PurchasingBatchView):
url_prefix = '/ordering'
model_title = "Ordering Batch"
model_title_plural = "Ordering Batches"
- rows_creatable = False
- rows_editable = False
order_form_header_columns = [
"UPC",
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
new file mode 100644
index 00000000..6b172840
--- /dev/null
+++ b/tailbone/views/purchasing/receiving.py
@@ -0,0 +1,322 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2017 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+"""
+Views for 'receiving' (purchasing) batches
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import re
+
+import sqlalchemy as sa
+
+from rattail import pod
+from rattail.db import model
+from rattail.gpc import GPC
+from rattail.util import pretty_quantity
+
+import formalchemy as fa
+import formencode as fe
+from webhelpers.html import tags
+
+from tailbone import forms
+from tailbone.views.purchasing import PurchasingBatchView
+
+
+class ReceivingBatchView(PurchasingBatchView):
+ """
+ Master view for receiving batches
+ """
+ route_prefix = 'receiving'
+ url_prefix = '/receiving'
+ model_title = "Receiving Batch"
+ model_title_plural = "Receiving Batches"
+ creatable = False
+ rows_deletable = False
+ supports_mobile = True
+ mobile_creatable = True
+ mobile_rows_viewable = True
+
+ @property
+ def batch_mode(self):
+ return self.enum.PURCHASE_BATCH_MODE_RECEIVING
+
+ def mobile_create(self):
+ """
+ Mobile view for creating a new receiving batch
+ """
+ mode = self.batch_mode
+ data = {'mode': mode}
+
+ vendor = None
+ if self.request.method == 'POST' and self.request.POST.get('vendor'):
+ vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor'])
+ if vendor:
+ data['vendor'] = vendor
+
+ if self.request.POST.get('purchase'):
+ purchase = self.get_purchase(self.request.POST['purchase'])
+ if purchase:
+
+ batch = self.model_class()
+ batch.mode = mode
+ batch.vendor = vendor
+ batch.store = self.rattail_config.get_store(self.Session())
+ batch.buyer = self.request.user.employee
+ batch.created_by = self.request.user
+ kwargs = self.get_batch_kwargs(batch, mobile=True)
+ batch = self.handler.make_batch(self.Session(), **kwargs)
+ if self.handler.should_populate(batch):
+ self.handler.populate(batch)
+ return self.redirect(self.request.route_url('mobile.receiving.view', uuid=batch.uuid))
+
+ data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
+ if vendor:
+ purchases = self.eligible_purchases(vendor.uuid, mode=mode)
+ data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']]
+ return self.render_to_response('create', data, mobile=True)
+
+ def get_batch_kwargs(self, batch, mobile=False):
+ kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
+ if mobile:
+
+ purchase = self.get_purchase(self.request.POST['purchase'])
+ kwargs['sms_transaction_number'] = purchase.F1032
+
+ numbers = [d.F03 for d in purchase.details]
+ if numbers:
+ number = max(set(numbers), key=numbers.count)
+ kwargs['department'] = self.Session.query(model.Department)\
+ .filter(model.Department.number == number)\
+ .one()
+
+ else:
+ kwargs['sms_transaction_number'] = batch.sms_transaction_number
+ return kwargs
+
+ def get_mobile_data(self, session=None):
+ # TODO: this hard-codes list view to show Pending only
+ return super(ReceivingBatchView, self).get_mobile_data(session=session)\
+ .filter(model.PurchaseBatch.executed == None)\
+ .filter(sa.or_(
+ model.PurchaseBatch.complete == None,
+ model.PurchaseBatch.complete == False))
+
+ def configure_mobile_grid(self, g):
+ super(ReceivingBatchView, self).configure_mobile_grid(g)
+ g.listitem.set(renderer=ReceivingBatchRenderer)
+
+ def configure_mobile_fieldset(self, fs):
+ fs.configure(include=[
+ fs.vendor.with_renderer(fa.TextFieldRenderer),
+ fs.department.with_renderer(fa.TextFieldRenderer),
+ ])
+
+ def get_mobile_row_data(self, batch):
+ return super(ReceivingBatchView, self).get_mobile_row_data(batch)\
+ .order_by(model.PurchaseBatchRow.sequence)
+
+ def render_mobile_row_listitem(self, row, **kwargs):
+ if row is None:
+ return ''
+ title = "({}) {}".format(row.upc.pretty(), row.product.full_description)
+ url = self.request.route_url('mobile.receiving.rows.view', uuid=row.uuid)
+ return tags.link_to(title, url)
+
+ def mobile_lookup(self):
+ """
+ Try to locate a product by UPC, and validate it in the context of
+ current batch, returning some data for client JS.
+ """
+ batch = self.get_instance()
+ upc = self.request.GET.get('upc', '').strip()
+ upc = re.sub(r'\D', '', upc)
+ if upc:
+
+ # first try to locate existing batch row by UPC match
+ provided = GPC(upc, calc_check_digit=False)
+ checked = GPC(upc, calc_check_digit='upc')
+ rows = self.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]
+ return self.redirect(self.request.route_url('mobile.{}.view'.format(self.get_row_route_prefix()), uuid=row.uuid))
+
+ # TODO: how to handle product not found in system / purchase ?
+ raise NotImplementedError
+
+ def mobile_view_row(self):
+ """
+ Mobile view for receiving batch row items. Note that this also handles
+ updating a row.
+ """
+ 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=ReceivingForm)
+ if update_form.validate():
+ row = update_form.data['row']
+ mode = update_form.data['mode']
+ cases = update_form.data['cases']
+ units = update_form.data['units']
+ 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'):
+ if mode in ('damaged', 'expired'):
+ self.attach_credit(row, mode, cases, units,
+ # expiration_date=update_form.data['expiration_date'],
+ # discarded=update_form.data['trash'],
+ # mispick_product=shipped_product)
+ )
+
+ # first undo any totals previously in effect for the row, then refresh
+ if row.invoice_total:
+ row.batch.invoice_total -= row.invoice_total
+ 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 attach_credit(self, row, credit_type, cases, units, expiration_date=None, discarded=None, mispick_product=None):
+ batch = row.batch
+ credit = model.PurchaseBatchCredit()
+ credit.credit_type = credit_type
+ credit.store = batch.store
+ credit.vendor = batch.vendor
+ credit.date_ordered = batch.date_ordered
+ credit.date_shipped = batch.date_shipped
+ credit.date_received = batch.date_received
+ credit.invoice_number = batch.invoice_number
+ credit.invoice_date = batch.invoice_date
+ credit.product = row.product
+ credit.upc = row.upc
+ credit.brand_name = row.brand_name
+ credit.description = row.description
+ credit.size = row.size
+ credit.department_number = row.department_number
+ credit.department_name = row.department_name
+ credit.case_quantity = row.case_quantity
+ credit.cases_shorted = cases
+ credit.units_shorted = units
+ credit.invoice_line_number = row.invoice_line_number
+ credit.invoice_case_cost = row.invoice_case_cost
+ credit.invoice_unit_cost = row.invoice_unit_cost
+ credit.invoice_total = row.invoice_total
+ credit.product_discarded = discarded
+ if credit_type == 'expired':
+ credit.expiration_date = expiration_date
+ elif credit_type == 'mispick' and mispick_product:
+ credit.mispick_product = mispick_product
+ credit.mispick_upc = mispick_product.upc
+ if mispick_product.brand:
+ credit.mispick_brand_name = mispick_product.brand.name
+ credit.mispick_description = mispick_product.description
+ credit.mispick_size = mispick_product.size
+ row.credits.append(credit)
+ return credit
+
+ @classmethod
+ def defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ url_prefix = cls.get_url_prefix()
+ model_key = cls.get_model_key()
+ row_permission_prefix = cls.get_row_permission_prefix()
+
+ # mobile lookup
+ config.add_route('mobile.{}.lookup'.format(route_prefix), '/mobile{}/{{{}}}/lookup'.format(url_prefix, model_key))
+ config.add_view(cls, attr='mobile_lookup', route_name='mobile.{}.lookup'.format(route_prefix),
+ renderer='json', permission='{}.view'.format(row_permission_prefix))
+
+ cls._purchasing_defaults(config)
+ cls._batch_defaults(config)
+ cls._defaults(config)
+
+
+class ReceivingBatchRenderer(fa.FieldRenderer):
+
+ def render_readonly(self, **kwargs):
+ batch = self.raw_value
+ title = "({}) {} for ${:0,.2f} - {}, {}".format(
+ batch.id_str,
+ batch.vendor,
+ batch.po_total or 0,
+ batch.department,
+ batch.created_by)
+ url = self.request.route_url('mobile.receiving.view', uuid=batch.uuid)
+ return tags.link_to(title, url)
+
+
+class ValidBatchRow(forms.validators.ModelValidator):
+ model_class = model.PurchaseBatchRow
+
+ 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 ReceivingForm(forms.Schema):
+ allow_extra_fields = True
+ filter_extra_fields = True
+ row = ValidBatchRow()
+ mode = fe.validators.OneOf(['received', 'damaged', 'expired',
+ # 'mispick',
+ ])
+ # product = forms.validators.ValidProduct()
+ # upc = forms.validators.ValidGPC()
+ # 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()
+ # trash = fe.validators.Bool()
+ # ordered_product = forms.validators.ValidProduct()
+
+
+def includeme(config):
+ ReceivingBatchView.defaults(config)