diff --git a/tailbone/templates/mobile/receiving/receive_row.mako b/tailbone/templates/mobile/receiving/receive_row.mako
new file mode 100644
index 00000000..53d8820f
--- /dev/null
+++ b/tailbone/templates/mobile/receiving/receive_row.mako
@@ -0,0 +1,151 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/mobile/master/view_row.mako" />
+<%namespace file="/mobile/keypad.mako" import="keypad" />
+
+<%def name="title()">Receiving » ${batch.id_str} » ${master.render_product_key_value(row)}%def>
+
+<%def name="page_title()">${h.link_to("Receiving", url('mobile.receiving'))} » ${h.link_to(batch.id_str, url('mobile.receiving.view', uuid=batch.uuid))} » ${master.render_product_key_value(row)}%def>
+
+
+
+
+ % if instance.product:
+
${instance.brand_name or ""}
+ ${instance.description} ${instance.size or ''}
+ % if allow_cases:
+ 1 CS = ${h.pretty_quantity(row.case_quantity or 1)} ${unit_uom}
+ % endif
+ % else:
+ ${instance.description}
+ % endif
+
+ % if product_image_url:
+
+ ${h.image(product_image_url, "product image")}
+
+ % endif
+
+
+
+
+ % if batch.order_quantities_known:
+
+ ordered
+
+ % if allow_cases:
+ ${h.pretty_quantity(row.cases_ordered or 0)} /
+ % endif
+ ${h.pretty_quantity(row.units_ordered or 0)}
+
+
+ % endif
+
+ received
+
+ % if allow_cases:
+ ${h.pretty_quantity(row.cases_received or 0)} /
+ % endif
+ ${h.pretty_quantity(row.units_received or 0)}
+
+
+
+ damaged
+
+ % if allow_cases:
+ ${h.pretty_quantity(row.cases_damaged or 0)} /
+ % endif
+ ${h.pretty_quantity(row.units_damaged or 0)}
+
+
+ % if allow_expired:
+
+ expired
+
+ % if allow_cases:
+ ${h.pretty_quantity(row.cases_expired or 0)} /
+ % endif
+ ${h.pretty_quantity(row.units_expired or 0)}
+
+
+ % endif
+
+
+
+% if request.session.peek_flash('receiving-warning'):
+ % for error in request.session.pop_flash('receiving-warning'):
+ ${error}
+ % endfor
+% endif
+
+% if not batch.executed and not batch.complete:
+
+ ${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')}
+
+ ## only show quick-receive if we have an identifiable product
+ % if quick_receive and instance.product:
+ % if quick_receive_all:
+ ${quick_receive_text}
+ % elif allow_cases:
+ Receive 1 CS
+
+ ## TODO: probably should make these optional / configurable
+ 1 EA
+ 3 EA
+ 6 EA
+
+
+ % else:
+ Receive 1 ${unit_uom}
+ % endif
+ % endif
+
+ ${keypad(unit_uom, uom, allow_cases=allow_cases)}
+
+
+
+ ${h.hidden('quick_receive', value='false')}
+ ${h.end_form()}
+
+ % if master.mobile_rows_deletable and master.row_deletable(row) 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
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 982b1983..3f86de61 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -1115,6 +1115,15 @@ class ReceivingBatchView(PurchasingBatchView):
description = row.product.full_description if row.product else row.description
return "({}) {}".format(key, description)
+ def make_mobile_row_grid_kwargs(self, **kwargs):
+ kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs)
+
+ # use custom `receive_row` instead of `view_row`
+ # TODO: should still use `view_row` in some cases? e.g. executed batch
+ kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True)
+
+ return kwargs
+
def should_aggregate_products(self, batch):
"""
Must return a boolean indicating whether rows should be aggregated by
@@ -1395,6 +1404,120 @@ class ReceivingBatchView(PurchasingBatchView):
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)
+ def mobile_receive_row(self):
+ """
+ Mobile view for row-level receiving.
+ """
+ self.mobile = True
+ self.viewing = True
+ row = self.get_row_instance()
+ batch = row.batch
+ permission_prefix = self.get_permission_prefix()
+ form = self.make_mobile_row_form(row)
+ 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': self.get_row_image_url(row),
+ 'form': form,
+ 'allow_expired': self.handler.allow_expired_credits(),
+ 'allow_cases': self.handler.allow_cases(),
+ 'quick_receive': False,
+ 'quick_receive_all': False,
+ }
+
+ context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
+ default=True)
+ if batch.order_quantities_known:
+ context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
+ default=False)
+
+ if self.request.has_perm('{}.create_row'.format(permission_prefix)):
+ schema = MobileReceivingForm().bind(session=self.Session())
+ 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'])
+ mode = update_form.validated['mode']
+ cases = update_form.validated['cases']
+ units = update_form.validated['units']
+
+ # handler takes care of the row receiving logic for us
+ kwargs = dict(update_form.validated)
+ del kwargs['row']
+ self.handler.receive_row(row, **kwargs)
+
+ # 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
+ context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
+
+ if context['quick_receive'] and context['quick_receive_all']:
+ if context['allow_cases']:
+ context['quick_receive_uom'] = 'CS'
+ raise NotImplementedError("TODO: add CS support for quick_receive_all")
+ else:
+ context['quick_receive_uom'] = context['unit_uom']
+ accounted_for = self.handler.get_units_accounted_for(row)
+ remainder = self.handler.get_units_ordered(row) - accounted_for
+
+ if accounted_for:
+ # some product accounted for; button should receive "remainder" only
+ if remainder:
+ remainder = pretty_quantity(remainder)
+ context['quick_receive_quantity'] = remainder
+ context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
+ else:
+ # unless there is no remainder, in which case disable it
+ context['quick_receive'] = False
+
+ else: # nothing yet accounted for, button should receive "all"
+ if not remainder:
+ raise ValueError("why is remainder empty?")
+ remainder = pretty_quantity(remainder)
+ context['quick_receive_quantity'] = remainder
+ context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
+
+ # effective uom can vary in a few ways...the basic default is 'CS' if
+ # self.default_uom_is_case is true, otherwise whatever unit_uom is.
+ sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
+ if sticky_case is None:
+ context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
+ elif sticky_case:
+ context['uom'] = 'CS'
+ else:
+ context['uom'] = context['unit_uom']
+ if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
+ context['uom'] = context['unit_uom']
+
+ if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
+ warn = True
+ if batch.is_truck_dump_parent() and row.product:
+ uuids = [child.uuid for child in batch.truck_dump_children]
+ if uuids:
+ count = self.Session.query(model.PurchaseBatchRow)\
+ .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
+ .filter(model.PurchaseBatchRow.product == row.product)\
+ .count()
+ if count:
+ warn = False
+ if warn:
+ self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
+ return self.render_to_response('receive_row', context, mobile=True)
+
def auto_receive(self):
"""
View which can "auto-receive" all items in the batch. Meant only as a
@@ -1455,6 +1578,11 @@ class ReceivingBatchView(PurchasingBatchView):
model_key = cls.get_model_key()
permission_prefix = cls.get_permission_prefix()
+ # row-level receiving
+ config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
+ config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
+ permission='{}.edit_row'.format(permission_prefix))
+
if cls.allow_truck_dump:
# add TD child batch, from invoice file