Changeset c8695164 in tailbone


Ignore:
Timestamp:
03/13/19 18:31:57 (6 months ago)
Author:
Lance Edgar <ledgar@…>
Branches:
master
Children:
5b9e97b
Parents:
7fab472
Message:

Add basic "receive row" desktop view for receiving batches

not terribly polished yet, but works

Location:
tailbone
Files:
3 added
5 edited

Legend:

Unmodified
Added
Removed
  • tailbone/forms/types.py

    r7fab472 rc8695164  
    9999
    100100
     101class ProductQuantity(colander.MappingSchema):
     102    """
     103    Combo schema type for product cases and units; useful for inventory,
     104    ordering, receiving etc.  Meant to be used with the ``CasesUnitsWidget``.
     105    """
     106    cases = colander.SchemaNode(colander.Decimal(), missing=colander.null)
     107
     108    units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
     109
     110
    101111class ModelType(colander.SchemaType):
    102112    """
  • tailbone/forms/widgets.py

    r7fab472 rc8695164  
    3636from deform import widget as dfwidget
    3737from webhelpers2.html import tags, HTML
     38
     39from tailbone.forms.types import ProductQuantity
    3840
    3941
     
    8991
    9092
     93class CasesUnitsWidget(dfwidget.Widget):
     94    """
     95    Widget for collecting case and/or unit quantities.  Most useful when you
     96    need to ensure user provides cases *or* units but not both.
     97    """
     98    template = 'cases_units'
     99    amount_required = False
     100    one_amount_only = False
     101
     102    def serialize(self, field, cstruct, **kw):
     103        if cstruct in (colander.null, None):
     104            cstruct = ''
     105        readonly = kw.get('readonly', self.readonly)
     106        kw['cases'] = cstruct['cases'] or ''
     107        kw['units'] = cstruct['units'] or ''
     108        template = readonly and self.readonly_template or self.template
     109        values = self.get_template_values(field, cstruct, kw)
     110        return field.renderer(template, **values)
     111
     112    def deserialize(self, field, pstruct):
     113        if pstruct is colander.null:
     114            return colander.null
     115
     116        schema = ProductQuantity()
     117        try:
     118            validated = schema.deserialize(pstruct)
     119        except colander.Invalid as exc:
     120            raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc)
     121
     122        if self.amount_required and not (validated['cases'] or validated['units']):
     123            raise colander.Invalid(field.schema, "Must provide case or unit amount",
     124                                   value=validated)
     125
     126        if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']:
     127            raise colander.Invalid(field.schema, "Must provide case *or* unit amount, "
     128                                   "but *not* both", value=validated)
     129
     130        return validated
     131
     132
    91133class PlainSelectWidget(dfwidget.SelectWidget):
    92134    template = 'select_plain'
  • tailbone/templates/master/view_row.mako

    r7fab472 rc8695164  
    2121</%def>
    2222
     23<%def name="object_helpers()"></%def>
     24
     25
    2326<div style="display: flex; justify-content: space-between;">
    2427
     
    2730  </div><!-- form-wrapper -->
    2831
    29   <ul id="context-menu">
    30     ${self.context_menu_items()}
    31   </ul>
     32  <div style="display: flex;">
     33    <div class="object-helpers">
     34      ${self.object_helpers()}
     35    </div>
     36
     37    <ul id="context-menu">
     38      ${self.context_menu_items()}
     39    </ul>
     40  </div>
    3241
    3342</div>
  • tailbone/views/batch/core.py

    r7fab472 rc8695164  
    11121112    def template_kwargs_view_row(self, **kwargs):
    11131113        kwargs['batch_model_title'] = kwargs['parent_model_title']
     1114        # TODO: should these be set somewhere else?
     1115        kwargs['row'] = kwargs['instance']
     1116        kwargs['batch'] = kwargs['row'].batch
    11141117        return kwargs
    11151118
  • tailbone/views/purchasing/receiving.py

    r7fab472 rc8695164  
    801801                    return self.get_row_action_url('transform_unit', row)
    802802
     803    def receive_row(self, mobile=False):
     804        """
     805        Primary desktop view for row-level receiving.
     806        """
     807        # TODO: this code was largely copied from mobile_receive_row() but it
     808        # tries to pave the way for shared logic, i.e. where the latter would
     809        # simply invoke this method and return the result.  however we're not
     810        # there yet...for now it's only tested for desktop
     811        self.mobile = mobile
     812        self.viewing = True
     813        row = self.get_row_instance()
     814        batch = row.batch
     815        permission_prefix = self.get_permission_prefix()
     816        possible_modes = [
     817            'received',
     818            'damaged',
     819            'expired',
     820        ]
     821        context = {
     822            'row': row,
     823            'batch': batch,
     824            'parent_instance': batch,
     825            'instance': row,
     826            'instance_title': self.get_row_instance_title(row),
     827            'parent_model_title': self.get_model_title(),
     828            'product_image_url': self.get_row_image_url(row),
     829            'allow_expired': self.handler.allow_expired_credits(),
     830            'allow_cases': self.handler.allow_cases(),
     831            'quick_receive': False,
     832            'quick_receive_all': False,
     833        }
     834
     835        if mobile:
     836            context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
     837                                                                   default=True)
     838            if batch.order_quantities_known:
     839                context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
     840                                                                           default=False)
     841
     842        schema = ReceiveRowForm().bind(session=self.Session())
     843        form = forms.Form(schema=schema, request=self.request)
     844        form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes]))
     845        form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True,
     846                                                                   one_amount_only=True))
     847        form.set_type('expiration_date', 'date_jquery')
     848
     849        if not mobile:
     850            form.remove_field('quick_receive')
     851
     852        if form.validate(newstyle=True):
     853
     854            # handler takes care of the row receiving logic for us
     855            kwargs = dict(form.validated)
     856            kwargs['cases'] = kwargs['quantity']['cases']
     857            kwargs['units'] = kwargs['quantity']['units']
     858            del kwargs['quantity']
     859            self.handler.receive_row(row, **kwargs)
     860
     861            # keep track of last-used uom, although we just track
     862            # whether or not it was 'CS' since the unit_uom can vary
     863            # TODO: should this be done for desktop too somehow?
     864            sticky_case = None
     865            if mobile and not form.validated['quick_receive']:
     866                cases = form.validated['cases']
     867                units = form.validated['units']
     868                if cases and not units:
     869                    sticky_case = True
     870                elif units and not cases:
     871                    sticky_case = False
     872            if sticky_case is not None:
     873                self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
     874
     875            if mobile:
     876                return self.redirect(self.get_action_url('view', batch, mobile=True))
     877            else:
     878                return self.redirect(self.get_row_action_url('view', row))
     879
     880        # unit_uom can vary by product
     881        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
     882
     883        if context['quick_receive'] and context['quick_receive_all']:
     884            if context['allow_cases']:
     885                context['quick_receive_uom'] = 'CS'
     886                raise NotImplementedError("TODO: add CS support for quick_receive_all")
     887            else:
     888                context['quick_receive_uom'] = context['unit_uom']
     889                accounted_for = self.handler.get_units_accounted_for(row)
     890                remainder = self.handler.get_units_ordered(row) - accounted_for
     891
     892                if accounted_for:
     893                    # some product accounted for; button should receive "remainder" only
     894                    if remainder:
     895                        remainder = pretty_quantity(remainder)
     896                        context['quick_receive_quantity'] = remainder
     897                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
     898                    else:
     899                        # unless there is no remainder, in which case disable it
     900                        context['quick_receive'] = False
     901
     902                else: # nothing yet accounted for, button should receive "all"
     903                    if not remainder:
     904                        raise ValueError("why is remainder empty?")
     905                    remainder = pretty_quantity(remainder)
     906                    context['quick_receive_quantity'] = remainder
     907                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
     908
     909        # effective uom can vary in a few ways...the basic default is 'CS' if
     910        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
     911        sticky_case = None
     912        if mobile:
     913            # TODO: should do this for desktop also, but rename the session variable
     914            sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
     915        if sticky_case is None:
     916            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
     917        elif sticky_case:
     918            context['uom'] = 'CS'
     919        else:
     920            context['uom'] = context['unit_uom']
     921        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
     922            context['uom'] = context['unit_uom']
     923
     924        # TODO: should do this for desktop in addition to mobile?
     925        if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
     926            warn = True
     927            if batch.is_truck_dump_parent() and row.product:
     928                uuids = [child.uuid for child in batch.truck_dump_children]
     929                if uuids:
     930                    count = self.Session.query(model.PurchaseBatchRow)\
     931                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
     932                                        .filter(model.PurchaseBatchRow.product == row.product)\
     933                                        .count()
     934                    if count:
     935                        warn = False
     936            if warn:
     937                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
     938
     939        # TODO: should do this for desktop in addition to mobile?
     940        if mobile:
     941            # maybe alert user if they've already received some of this product
     942            alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
     943                                                         default=False)
     944            if alert_received:
     945                if self.handler.get_units_confirmed(row):
     946                    msg = "You have already received some of this product; last update was {}.".format(
     947                        humanize.naturaltime(make_utc() - row.modified))
     948                    self.request.session.flash(msg, 'receiving-warning')
     949
     950        context['form'] = form
     951        context['dform'] = form.make_deform_form()
     952        context['parent_url'] = self.get_action_url('view', batch, mobile=mobile)
     953        context['parent_title'] = self.get_instance_title(batch)
     954        return self.render_to_response('receive_row', context, mobile=mobile)
     955
    803956    def transform_unit_row(self):
    804957        """
     
    8541007        batch = self.get_instance()
    8551008
    856         f.set_readonly('cases_ordered')
    857         f.set_readonly('units_ordered')
    858         f.set_readonly('po_unit_cost')
    859         f.set_readonly('po_total')
     1009        # allow input for certain fields only; all others are readonly
     1010        mutable = [
     1011            'invoice_unit_cost',
     1012        ]
     1013        for name in f.fields:
     1014            if name not in mutable:
     1015                f.set_readonly(name)
    8601016
    8611017        # invoice totals
    862         f.set_readonly('invoice_total')
    8631018        f.set_label('invoice_total', "Invoice Total (Orig.)")
    864         f.set_readonly('invoice_total_calculated')
    8651019        f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
    8661020
     
    16301784
    16311785        # row-level receiving
     1786        config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
     1787        config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
     1788                        permission='{}.edit_row'.format(permission_prefix))
    16321789        config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
    16331790        config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
     
    17121869
    17131870
    1714 class MobileReceivingForm(colander.MappingSchema):
    1715 
    1716     row = colander.SchemaNode(colander.String(),
    1717                               validator=valid_purchase_batch_row)
     1871class ReceiveRowForm(colander.MappingSchema):
    17181872
    17191873    mode = colander.SchemaNode(colander.String(),
     
    17251879                               ]))
    17261880
    1727     cases = colander.SchemaNode(colander.Decimal(), missing=None)
    1728 
    1729     units = colander.SchemaNode(colander.Decimal(), missing=None)
     1881    quantity = forms.types.ProductQuantity()
    17301882
    17311883    expiration_date = colander.SchemaNode(colander.Date(),
     
    17361888
    17371889
     1890class MobileReceivingForm(colander.MappingSchema):
     1891
     1892    row = colander.SchemaNode(colander.String(),
     1893                              validator=valid_purchase_batch_row)
     1894
     1895    mode = colander.SchemaNode(colander.String(),
     1896                               validator=colander.OneOf([
     1897                                   'received',
     1898                                   'damaged',
     1899                                   'expired',
     1900                                   # 'mispick',
     1901                               ]))
     1902
     1903    cases = colander.SchemaNode(colander.Decimal(), missing=None)
     1904
     1905    units = colander.SchemaNode(colander.Decimal(), missing=None)
     1906
     1907    expiration_date = colander.SchemaNode(colander.Date(),
     1908                                          widget=dfwidget.TextInputWidget(),
     1909                                          missing=colander.null)
     1910
     1911    quick_receive = colander.SchemaNode(colander.Boolean())
     1912
     1913
    17381914def includeme(config):
    17391915    ReceivingBatchView.defaults(config)
Note: See TracChangeset for help on using the changeset viewer.