From c869516449f6f03132dac2d8bcb4dd2c59a00b88 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Mar 2019 18:31:57 -0500 Subject: [PATCH] Add basic "receive row" desktop view for receiving batches not terribly polished yet, but works --- tailbone/forms/types.py | 10 + tailbone/forms/widgets.py | 42 ++++ tailbone/templates/deform/cases_units.pt | 29 +++ tailbone/templates/master/view_row.mako | 15 +- tailbone/templates/receiving/receive_row.mako | 49 +++++ tailbone/templates/receiving/view_row.mako | 16 ++ tailbone/views/batch/core.py | 3 + tailbone/views/purchasing/receiving.py | 188 +++++++++++++++++- 8 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 tailbone/templates/deform/cases_units.pt create mode 100644 tailbone/templates/receiving/receive_row.mako create mode 100644 tailbone/templates/receiving/view_row.mako diff --git a/tailbone/forms/types.py b/tailbone/forms/types.py index b29a605f..d9f7e828 100644 --- a/tailbone/forms/types.py +++ b/tailbone/forms/types.py @@ -98,6 +98,16 @@ class GPCType(colander.SchemaType): raise colander.Invalid(node, six.text_type(err)) +class ProductQuantity(colander.MappingSchema): + """ + Combo schema type for product cases and units; useful for inventory, + ordering, receiving etc. Meant to be used with the ``CasesUnitsWidget``. + """ + cases = colander.SchemaNode(colander.Decimal(), missing=colander.null) + + units = colander.SchemaNode(colander.Decimal(), missing=colander.null) + + class ModelType(colander.SchemaType): """ Custom schema type for scalar ORM relationship fields. diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index ddb71b78..5e85191e 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -36,6 +36,8 @@ import colander from deform import widget as dfwidget from webhelpers2.html import tags, HTML +from tailbone.forms.types import ProductQuantity + class ReadonlyWidget(dfwidget.HiddenWidget): @@ -88,6 +90,46 @@ class PercentInputWidget(dfwidget.TextInputWidget): return six.text_type(value) +class CasesUnitsWidget(dfwidget.Widget): + """ + Widget for collecting case and/or unit quantities. Most useful when you + need to ensure user provides cases *or* units but not both. + """ + template = 'cases_units' + amount_required = False + one_amount_only = False + + def serialize(self, field, cstruct, **kw): + if cstruct in (colander.null, None): + cstruct = '' + readonly = kw.get('readonly', self.readonly) + kw['cases'] = cstruct['cases'] or '' + kw['units'] = cstruct['units'] or '' + template = readonly and self.readonly_template or self.template + values = self.get_template_values(field, cstruct, kw) + return field.renderer(template, **values) + + def deserialize(self, field, pstruct): + if pstruct is colander.null: + return colander.null + + schema = ProductQuantity() + try: + validated = schema.deserialize(pstruct) + except colander.Invalid as exc: + raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc) + + if self.amount_required and not (validated['cases'] or validated['units']): + raise colander.Invalid(field.schema, "Must provide case or unit amount", + value=validated) + + if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']: + raise colander.Invalid(field.schema, "Must provide case *or* unit amount, " + "but *not* both", value=validated) + + return validated + + class PlainSelectWidget(dfwidget.SelectWidget): template = 'select_plain' diff --git a/tailbone/templates/deform/cases_units.pt b/tailbone/templates/deform/cases_units.pt new file mode 100644 index 00000000..05e06d50 --- /dev/null +++ b/tailbone/templates/deform/cases_units.pt @@ -0,0 +1,29 @@ + +
+ ${field.start_mapping()} +
+ + Cases +
+
+ + Units +
+ ${field.end_mapping()} +
diff --git a/tailbone/templates/master/view_row.mako b/tailbone/templates/master/view_row.mako index e786da05..08b6657d 100644 --- a/tailbone/templates/master/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -20,14 +20,23 @@ % endif +<%def name="object_helpers()"> + +
${form.render()|n}
- +
+
+ ${self.object_helpers()} +
+ +
    + ${self.context_menu_items()} +
+
diff --git a/tailbone/templates/receiving/receive_row.mako b/tailbone/templates/receiving/receive_row.mako new file mode 100644 index 00000000..d0f1c31f --- /dev/null +++ b/tailbone/templates/receiving/receive_row.mako @@ -0,0 +1,49 @@ +## -*- coding: utf-8; -*- +<%inherit file="/base.mako" /> + +<%def name="title()">Receive for Row #${row.sequence} + +<%def name="context_menu_items()"> + % if master.rows_viewable and request.has_perm('{}.view'.format(permission_prefix)): +
  • ${h.link_to("View this {}".format(row_model_title), row_action_url('view', row))}
  • + % endif + + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + + + +
    + +
    + ${form.render()|n} +
    + + + +
    diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako new file mode 100644 index 00000000..aabdb932 --- /dev/null +++ b/tailbone/templates/receiving/view_row.mako @@ -0,0 +1,16 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if not batch.executed and not batch.is_truck_dump_child(): +
    +

    Receiving Tools

    +
    + ${h.link_to("Receive Product", url('{}.receive_row'.format(route_prefix), uuid=batch.uuid, row_uuid=row.uuid), class_='button autodisable')} +
    +
    + % endif + + +${parent.body()} diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 3d01db8b..7bbf5b9f 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -1111,6 +1111,9 @@ class BatchMasterView(MasterView): def template_kwargs_view_row(self, **kwargs): kwargs['batch_model_title'] = kwargs['parent_model_title'] + # TODO: should these be set somewhere else? + kwargs['row'] = kwargs['instance'] + kwargs['batch'] = kwargs['row'].batch return kwargs def get_parent(self, row): diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index ff5b0c5a..42a3821c 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -800,6 +800,159 @@ class ReceivingBatchView(PurchasingBatchView): if row.product and row.product.is_pack_item(): return self.get_row_action_url('transform_unit', row) + def receive_row(self, mobile=False): + """ + Primary desktop view for row-level receiving. + """ + # TODO: this code was largely copied from mobile_receive_row() but it + # tries to pave the way for shared logic, i.e. where the latter would + # simply invoke this method and return the result. however we're not + # there yet...for now it's only tested for desktop + self.mobile = mobile + self.viewing = True + row = self.get_row_instance() + batch = row.batch + permission_prefix = self.get_permission_prefix() + possible_modes = [ + 'received', + 'damaged', + 'expired', + ] + 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), + 'allow_expired': self.handler.allow_expired_credits(), + 'allow_cases': self.handler.allow_cases(), + 'quick_receive': False, + 'quick_receive_all': False, + } + + if mobile: + 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) + + schema = ReceiveRowForm().bind(session=self.Session()) + form = forms.Form(schema=schema, request=self.request) + form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes])) + form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True, + one_amount_only=True)) + form.set_type('expiration_date', 'date_jquery') + + if not mobile: + form.remove_field('quick_receive') + + if form.validate(newstyle=True): + + # handler takes care of the row receiving logic for us + kwargs = dict(form.validated) + kwargs['cases'] = kwargs['quantity']['cases'] + kwargs['units'] = kwargs['quantity']['units'] + del kwargs['quantity'] + 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 + # TODO: should this be done for desktop too somehow? + sticky_case = None + if mobile and not form.validated['quick_receive']: + cases = form.validated['cases'] + units = form.validated['units'] + 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 + + if mobile: + return self.redirect(self.get_action_url('view', batch, mobile=True)) + else: + return self.redirect(self.get_row_action_url('view', row)) + + # 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 = None + if mobile: + # TODO: should do this for desktop also, but rename the session variable + 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'] + + # TODO: should do this for desktop in addition to mobile? + if mobile and 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') + + # TODO: should do this for desktop in addition to mobile? + if mobile: + # maybe alert user if they've already received some of this product + alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received', + default=False) + if alert_received: + if self.handler.get_units_confirmed(row): + msg = "You have already received some of this product; last update was {}.".format( + humanize.naturaltime(make_utc() - row.modified)) + self.request.session.flash(msg, 'receiving-warning') + + context['form'] = form + context['dform'] = form.make_deform_form() + context['parent_url'] = self.get_action_url('view', batch, mobile=mobile) + context['parent_title'] = self.get_instance_title(batch) + return self.render_to_response('receive_row', context, mobile=mobile) + def transform_unit_row(self): """ View which transforms the given row, which is assumed to associate with @@ -853,15 +1006,16 @@ class ReceivingBatchView(PurchasingBatchView): super(ReceivingBatchView, self).configure_row_form(f) batch = self.get_instance() - f.set_readonly('cases_ordered') - f.set_readonly('units_ordered') - f.set_readonly('po_unit_cost') - f.set_readonly('po_total') + # allow input for certain fields only; all others are readonly + mutable = [ + 'invoice_unit_cost', + ] + for name in f.fields: + if name not in mutable: + f.set_readonly(name) # invoice totals - f.set_readonly('invoice_total') f.set_label('invoice_total', "Invoice Total (Orig.)") - f.set_readonly('invoice_total_calculated') f.set_label('invoice_total_calculated', "Invoice Total (Calc.)") # claims @@ -1629,6 +1783,9 @@ class ReceivingBatchView(PurchasingBatchView): permission_prefix = cls.get_permission_prefix() # row-level receiving + config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix)) + config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), + permission='{}.edit_row'.format(permission_prefix)) 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)) @@ -1711,6 +1868,25 @@ def valid_purchase_batch_row(node, kw): return validate +class ReceiveRowForm(colander.MappingSchema): + + mode = colander.SchemaNode(colander.String(), + validator=colander.OneOf([ + 'received', + 'damaged', + 'expired', + # 'mispick', + ])) + + quantity = forms.types.ProductQuantity() + + expiration_date = colander.SchemaNode(colander.Date(), + widget=dfwidget.TextInputWidget(), + missing=colander.null) + + quick_receive = colander.SchemaNode(colander.Boolean()) + + class MobileReceivingForm(colander.MappingSchema): row = colander.SchemaNode(colander.String(),