source: tailbone/tailbone/views/purchasing/receiving.py @ 05481f7

Last change on this file since 05481f7 was 05481f7, checked in by Lance Edgar <ledgar@…>, 6 months ago

Add new "receive row" view for mobile receiving

this frees us up to dumb-down the "view row" which thus far has been tasked
with actual receiving

  • Property mode set to 100644
File size: 70.4 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2019 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for 'receiving' (purchasing) batches
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import re
30import logging
31
32import six
33import sqlalchemy as sa
34
35from rattail import pod
36from rattail.db import model, api, Session as RattailSession
37from rattail.db.util import maxlen
38from rattail.gpc import GPC
39from rattail.time import localtime
40from rattail.util import pretty_quantity, prettify, OrderedDict
41from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
42from rattail.threads import Thread
43
44import colander
45from deform import widget as dfwidget
46from pyramid import httpexceptions
47from webhelpers2.html import tags, HTML
48
49from tailbone import forms, grids
50from tailbone.views.purchasing import PurchasingBatchView
51from tailbone.progress import SessionProgress
52
53
54log = logging.getLogger(__name__)
55
56
57class MobileItemStatusFilter(grids.filters.MobileFilter):
58
59    value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
60
61    def filter_equal(self, query, value):
62
63        # NOTE: this is only relevant for truck dump or "from scratch"
64        if value == 'received':
65            return query.filter(sa.or_(
66                model.PurchaseBatchRow.cases_received != 0,
67                model.PurchaseBatchRow.units_received != 0))
68
69        if value == 'incomplete':
70            # looking for any rows with "ordered" quantity, but where the
71            # status does *not* signify a "settled" row so to speak
72            return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0,
73                                       model.PurchaseBatchRow.units_ordered != 0))\
74                        .filter(~model.PurchaseBatchRow.status_code.in_((
75                            model.PurchaseBatchRow.STATUS_OK,
76                            model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
77                            model.PurchaseBatchRow.STATUS_TRUCKDUMP_CLAIMED)))
78
79        if value == 'invalid':
80            return query.filter(model.PurchaseBatchRow.status_code.in_((
81                model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
82                model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
83                model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
84                model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
85            )))
86
87        if value == 'unexpected':
88            # looking for any rows which have "received" quantity but which
89            # do *not* have any "ordered" quantity
90            return query.filter(sa.and_(
91                sa.or_(
92                    model.PurchaseBatchRow.cases_ordered == None,
93                    model.PurchaseBatchRow.cases_ordered == 0),
94                sa.or_(
95                    model.PurchaseBatchRow.units_ordered == None,
96                    model.PurchaseBatchRow.units_ordered == 0),
97                sa.or_(
98                    model.PurchaseBatchRow.cases_received != 0,
99                    model.PurchaseBatchRow.units_received != 0,
100                    model.PurchaseBatchRow.cases_damaged != 0,
101                    model.PurchaseBatchRow.units_damaged != 0,
102                    model.PurchaseBatchRow.cases_expired != 0,
103                    model.PurchaseBatchRow.units_expired != 0)))
104
105        if value == 'damaged':
106            return query.filter(sa.or_(
107                model.PurchaseBatchRow.cases_damaged != 0,
108                model.PurchaseBatchRow.units_damaged != 0))
109
110        if value == 'expired':
111            return query.filter(sa.or_(
112                model.PurchaseBatchRow.cases_expired != 0,
113                model.PurchaseBatchRow.units_expired != 0))
114
115        return query
116
117    def iter_choices(self):
118        for value in self.value_choices:
119            yield value, prettify(value)
120
121
122class ReceivingBatchView(PurchasingBatchView):
123    """
124    Master view for receiving batches
125    """
126    route_prefix = 'receiving'
127    url_prefix = '/receiving'
128    model_title = "Receiving Batch"
129    model_title_plural = "Receiving Batches"
130    index_title = "Receiving"
131    downloadable = True
132    rows_editable = True
133    mobile_creatable = True
134    mobile_rows_filterable = True
135    mobile_rows_creatable = True
136    mobile_rows_quickable = True
137    mobile_rows_deletable = True
138
139    allow_from_po = False
140    allow_from_scratch = True
141    allow_truck_dump = False
142
143    default_uom_is_case = True
144
145    purchase_order_fieldname = 'purchase'
146
147    labels = {
148        'truck_dump_batch': "Truck Dump Parent",
149        'invoice_parser_key': "Invoice Parser",
150    }
151
152    grid_columns = [
153        'id',
154        'vendor',
155        'truck_dump',
156        'description',
157        'department',
158        'buyer',
159        'date_ordered',
160        'created',
161        'created_by',
162        'rowcount',
163        'status_code',
164        'executed',
165    ]
166
167    form_fields = [
168        'id',
169        'batch_type',
170        'store',
171        'vendor',
172        'description',
173        'truck_dump',
174        'truck_dump_children_first',
175        'truck_dump_children',
176        'truck_dump_ready',
177        'truck_dump_batch',
178        'invoice_file',
179        'invoice_parser_key',
180        'department',
181        'purchase',
182        'vendor_email',
183        'vendor_fax',
184        'vendor_contact',
185        'vendor_phone',
186        'date_ordered',
187        'date_received',
188        'po_number',
189        'po_total',
190        'invoice_date',
191        'invoice_number',
192        'invoice_total',
193        'notes',
194        'created',
195        'created_by',
196        'status_code',
197        'rowcount',
198        'order_quantities_known',
199        'complete',
200        'executed',
201        'executed_by',
202    ]
203
204    mobile_form_fields = [
205        'vendor',
206        'truck_dump',
207        'department',
208    ]
209
210    row_grid_columns = [
211        'sequence',
212        'upc',
213        # 'item_id',
214        'brand_name',
215        'description',
216        'size',
217        'department_name',
218        'cases_ordered',
219        'units_ordered',
220        'cases_received',
221        'units_received',
222        # 'po_total',
223        'invoice_total',
224        'credits',
225        'status_code',
226    ]
227
228    row_form_fields = [
229        'item_entry',
230        'upc',
231        'item_id',
232        'product',
233        'brand_name',
234        'description',
235        'size',
236        'case_quantity',
237        'cases_ordered',
238        'units_ordered',
239        'cases_received',
240        'units_received',
241        'cases_damaged',
242        'units_damaged',
243        'cases_expired',
244        'units_expired',
245        'cases_mispick',
246        'units_mispick',
247        'po_line_number',
248        'po_unit_cost',
249        'po_total',
250        'invoice_line_number',
251        'invoice_unit_cost',
252        'invoice_total',
253        'status_code',
254        'claims',
255        'credits',
256    ]
257
258    # convenience list of all quantity attributes involved for a truck dump claim
259    claim_keys = [
260        'cases_received',
261        'units_received',
262        'cases_damaged',
263        'units_damaged',
264        'cases_expired',
265        'units_expired',
266    ]
267
268    @property
269    def batch_mode(self):
270        return self.enum.PURCHASE_BATCH_MODE_RECEIVING
271
272    def row_deletable(self, row):
273        batch = row.batch
274
275        # can always delete rows from truck dump parent
276        if batch.is_truck_dump_parent():
277            return True
278
279        # can always delete rows from truck dump child
280        elif batch.is_truck_dump_child():
281            return True
282
283        else: # okay, normal batch
284            if batch.order_quantities_known:
285                return False
286            else: # allow delete if receiving rom scratch
287                return True
288
289        # cannot delete row by default
290        return False
291
292    def get_instance_title(self, batch):
293        title = super(ReceivingBatchView, self).get_instance_title(batch)
294        if batch.truck_dump:
295            title = "{} (TRUCK DUMP PARENT)".format(title)
296        elif batch.truck_dump_batch:
297            title = "{} (TRUCK DUMP CHILD)".format(title)
298        return title
299
300    def configure_form(self, f):
301        super(ReceivingBatchView, self).configure_form(f)
302        batch = f.model_instance
303
304        # batch_type
305        if self.creating:
306            batch_types = OrderedDict()
307            if self.allow_from_scratch:
308                batch_types['from_scratch'] = "From Scratch"
309            if self.allow_from_po:
310                batch_types['from_po'] = "From PO"
311            if self.allow_truck_dump:
312                batch_types['truck_dump_children_first'] = "Truck Dump (children FIRST)"
313                batch_types['truck_dump_children_last'] = "Truck Dump (children LAST)"
314            f.set_enum('batch_type', batch_types)
315        else:
316            f.remove_field('batch_type')
317
318        # truck_dump*
319        if self.allow_truck_dump:
320
321            # truck_dump
322            if self.creating:
323                f.remove_field('truck_dump')
324            elif batch.truck_dump_batch:
325                f.remove_field('truck_dump')
326            else:
327                f.set_readonly('truck_dump')
328
329            # truck_dump_children_first
330            if self.creating or not batch.truck_dump:
331                f.remove_field('truck_dump_children_first')
332
333            # truck_dump_children
334            if self.viewing:
335                if batch.truck_dump:
336                    f.set_renderer('truck_dump_children', self.render_truck_dump_children)
337                else:
338                    f.remove_field('truck_dump_children')
339            else:
340                f.remove_field('truck_dump_children')
341
342            # truck_dump_ready
343            if self.creating or not batch.truck_dump:
344                f.remove_field('truck_dump_ready')
345
346            # truck_dump_batch
347            if self.creating:
348                f.replace('truck_dump_batch', 'truck_dump_batch_uuid')
349                batches = self.Session.query(model.PurchaseBatch)\
350                                      .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
351                                      .filter(model.PurchaseBatch.truck_dump == True)\
352                                      .filter(model.PurchaseBatch.complete == True)\
353                                      .filter(model.PurchaseBatch.executed == None)\
354                                      .order_by(model.PurchaseBatch.id)
355                batch_values = [(b.uuid, "({}) {}, {}".format(b.id_str, b.date_received, b.vendor))
356                                for b in batches]
357                batch_values.insert(0, ('', "(please choose)"))
358                f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values))
359                f.set_label('truck_dump_batch_uuid', "Truck Dump Parent")
360            elif batch.truck_dump:
361                f.remove_field('truck_dump_batch')
362            elif batch.truck_dump_batch:
363                f.set_readonly('truck_dump_batch')
364                f.set_renderer('truck_dump_batch', self.render_truck_dump_batch)
365            else:
366                f.remove_field('truck_dump_batch')
367
368            # truck_dump_vendor
369            if self.creating:
370                f.set_label('truck_dump_vendor', "Vendor")
371                f.set_readonly('truck_dump_vendor')
372                f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor)
373
374        else:
375            f.remove_fields('truck_dump',
376                            'truck_dump_children_first',
377                            'truck_dump_children',
378                            'truck_dump_ready',
379                            'truck_dump_batch')
380
381        # invoice_file
382        if self.creating:
383            f.set_type('invoice_file', 'file', required=False)
384        else:
385            f.set_readonly('invoice_file')
386            f.set_renderer('invoice_file', self.render_downloadable_file)
387
388        # invoice_parser_key
389        if self.creating:
390            parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display)
391            parser_values = [(p.key, p.display) for p in parsers]
392            parser_values.insert(0, ('', "(please choose)"))
393            f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values))
394        else:
395            f.remove_field('invoice_parser_key')
396
397        # store
398        if self.creating:
399            store = self.rattail_config.get_store(self.Session())
400            f.set_default('store_uuid', store.uuid)
401            # TODO: seems like set_hidden() should also set HiddenWidget
402            f.set_hidden('store_uuid')
403            f.set_widget('store_uuid', dfwidget.HiddenWidget())
404
405        # purchase
406        if self.creating:
407            f.remove_field('purchase')
408
409        # department
410        if self.creating:
411            f.remove_field('department_uuid')
412
413        # order_quantities_known
414        if not self.editing:
415            f.remove_field('order_quantities_known')
416
417    def template_kwargs_create(self, **kwargs):
418        kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
419        if self.allow_truck_dump:
420            vmap = {}
421            batches = self.Session.query(model.PurchaseBatch)\
422                                  .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
423                                  .filter(model.PurchaseBatch.truck_dump == True)\
424                                  .filter(model.PurchaseBatch.complete == True)
425            for batch in batches:
426                vmap[batch.uuid] = batch.vendor_uuid
427            kwargs['batch_vendor_map'] = vmap
428        return kwargs
429
430    def get_batch_kwargs(self, batch, mobile=False):
431        kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
432        if not mobile:
433            batch_type = self.request.POST['batch_type']
434            if batch_type == 'from_scratch':
435                kwargs.pop('truck_dump_batch', None)
436                kwargs.pop('truck_dump_batch_uuid', None)
437            elif batch_type == 'truck_dump_children_first':
438                kwargs['truck_dump'] = True
439                kwargs['truck_dump_children_first'] = True
440                kwargs['order_quantities_known'] = True
441                # TODO: this makes sense in some cases, but all?
442                # (should just omit that field when not relevant)
443                kwargs['date_ordered'] = None
444            elif batch_type == 'truck_dump_children_last':
445                kwargs['truck_dump'] = True
446                kwargs['truck_dump_ready'] = True
447                # TODO: this makes sense in some cases, but all?
448                # (should just omit that field when not relevant)
449                kwargs['date_ordered'] = None
450            elif batch_type.startswith('truck_dump_child'):
451                truck_dump = self.get_instance()
452                kwargs['store'] = truck_dump.store
453                kwargs['vendor'] = truck_dump.vendor
454                kwargs['truck_dump_batch'] = truck_dump
455            else:
456                raise NotImplementedError
457        return kwargs
458
459    def department_for_purchase(self, purchase):
460        pass
461
462    def delete_instance(self, batch):
463        """
464        Delete all data (files etc.) for the batch.
465        """
466        truck_dump = batch.truck_dump_batch
467        if batch.truck_dump:
468            for child in batch.truck_dump_children:
469                self.delete_instance(child)
470        super(ReceivingBatchView, self).delete_instance(batch)
471        if truck_dump:
472            self.handler.refresh(truck_dump)
473
474    def render_truck_dump_batch(self, batch, field):
475        truck_dump = batch.truck_dump_batch
476        if not truck_dump:
477            return ""
478        text = "({}) {}".format(truck_dump.id_str, truck_dump.description or '')
479        url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
480        return tags.link_to(text, url)
481
482    def render_truck_dump_vendor(self, batch, field):
483        truck_dump = self.get_instance()
484        vendor = truck_dump.vendor
485        text = "({}) {}".format(vendor.id, vendor.name)
486        url = self.request.route_url('vendors.view', uuid=vendor.uuid)
487        return tags.link_to(text, url)
488
489    def render_truck_dump_children(self, batch, field):
490        contents = []
491        children = batch.truck_dump_children
492        if children:
493            items = []
494            for child in children:
495                text = "({}) {}".format(child.id_str, child.description or '')
496                url = self.request.route_url('receiving.view', uuid=child.uuid)
497                items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
498            contents.append(HTML.tag('ul', c=items))
499        if not batch.executed and (batch.complete or batch.truck_dump_children_first):
500            buttons = self.make_truck_dump_child_buttons(batch)
501            if buttons:
502                buttons = HTML.literal(' ').join(buttons)
503                contents.append(HTML.tag('div', class_='buttons', c=[buttons]))
504        if not contents:
505            return ""
506        return HTML.tag('div', c=contents)
507
508    def make_truck_dump_child_buttons(self, batch):
509        return [
510            tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'),
511        ]
512
513    def add_child_from_invoice(self):
514        """
515        View for adding a child batch to a truck dump, from invoice file.
516        """
517        batch = self.get_instance()
518        if not batch.truck_dump:
519            self.request.session.flash("Batch is not a truck dump: {}".format(batch))
520            return self.redirect(self.get_action_url('view', batch))
521        if batch.executed:
522            self.request.session.flash("Batch has already been executed: {}".format(batch))
523            return self.redirect(self.get_action_url('view', batch))
524        if not batch.complete and not batch.truck_dump_children_first:
525            self.request.session.flash("Batch is not marked as complete: {}".format(batch))
526            return self.redirect(self.get_action_url('view', batch))
527        self.creating = True
528        form = self.make_child_from_invoice_form(self.get_model_class())
529        return self.create(form=form)
530
531    def make_child_from_invoice_form(self, instance, **kwargs):
532        """
533        Creates a new form for the given model class/instance
534        """
535        kwargs['configure'] = self.configure_child_from_invoice_form
536        return self.make_form(instance=instance, **kwargs)
537
538    def configure_child_from_invoice_form(self, f):
539        assert self.creating
540        truck_dump = self.get_instance()
541
542        self.configure_form(f)
543
544        f.set_fields([
545            'batch_type',
546            'truck_dump_parent',
547            'truck_dump_vendor',
548            'invoice_file',
549            'invoice_parser_key',
550            'invoice_number',
551            'description',
552            'notes',
553        ])
554
555        # batch_type
556        f.set_widget('batch_type', forms.widgets.ReadonlyWidget())
557        f.set_default('batch_type', 'truck_dump_child_from_invoice')
558
559        # truck_dump_batch_uuid
560        f.set_readonly('truck_dump_parent')
561        f.set_renderer('truck_dump_parent', self.render_truck_dump_parent)
562
563    def render_truck_dump_parent(self, batch, field):
564        truck_dump = self.get_instance()
565        text = six.text_type(truck_dump)
566        url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
567        return tags.link_to(text, url)
568
569    def render_mobile_listitem(self, batch, i):
570        title = "({}) {} for ${:0,.2f} - {}, {}".format(
571            batch.id_str,
572            batch.vendor,
573            batch.invoice_total or batch.po_total or 0,
574            batch.department,
575            batch.created_by)
576        return title
577
578    def make_mobile_row_filters(self):
579        """
580        Returns a set of filters for the mobile row grid.
581        """
582        batch = self.get_instance()
583        filters = grids.filters.GridFilterSet()
584
585        # visible filter options will depend on whether batch came from purchase
586        if batch.order_quantities_known:
587            value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all']
588            default_status = 'incomplete'
589        else:
590            value_choices = ['received', 'damaged', 'expired', 'invalid', 'all']
591            default_status = 'all'
592
593        # remove 'expired' filter option if not relevant
594        if 'expired' in value_choices and not self.handler.allow_expired_credits():
595            value_choices.remove('expired')
596
597        filters['status'] = MobileItemStatusFilter('status',
598                                                   value_choices=value_choices,
599                                                   default_value=default_status)
600        return filters
601
602    def get_purchase(self, uuid):
603        return self.Session.query(model.Purchase).get(uuid)
604
605    def mobile_create(self):
606        """
607        Mobile view for creating a new receiving batch
608        """
609        mode = self.batch_mode
610        data = {'mode': mode}
611        phase = 1
612
613        schema = MobileNewReceivingBatch().bind(session=self.Session())
614        form = forms.Form(schema=schema, request=self.request)
615        if form.validate(newstyle=True):
616            phase = form.validated['phase']
617
618            if form.validated['workflow'] == 'from_scratch':
619                if not self.allow_from_scratch:
620                    raise NotImplementedError("Requested workflow not supported: from_scratch")
621                batch = self.model_class()
622                batch.store = self.rattail_config.get_store(self.Session())
623                batch.mode = mode
624                batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
625                batch.created_by = self.request.user
626                batch.date_received = localtime(self.rattail_config).date()
627                kwargs = self.get_batch_kwargs(batch, mobile=True)
628                batch = self.handler.make_batch(self.Session(), **kwargs)
629                return self.redirect(self.get_action_url('view', batch, mobile=True))
630
631            elif form.validated['workflow'] == 'truck_dump':
632                if not self.allow_truck_dump:
633                    raise NotImplementedError("Requested workflow not supported: truck_dump")
634                batch = self.model_class()
635                batch.store = self.rattail_config.get_store(self.Session())
636                batch.mode = mode
637                batch.truck_dump = True
638                batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
639                batch.created_by = self.request.user
640                batch.date_received = localtime(self.rattail_config).date()
641                kwargs = self.get_batch_kwargs(batch, mobile=True)
642                batch = self.handler.make_batch(self.Session(), **kwargs)
643                return self.redirect(self.get_action_url('view', batch, mobile=True))
644
645            elif form.validated['workflow'] == 'from_po':
646                if not self.allow_from_po:
647                    raise NotImplementedError("Requested workflow not supported: from_po")
648
649                vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
650                data['vendor'] = vendor
651
652                schema = self.make_mobile_receiving_from_po_schema()
653                po_form = forms.Form(schema=schema, request=self.request)
654                if phase == 2:
655                    if po_form.validate(newstyle=True):
656                        batch = self.model_class()
657                        batch.store = self.rattail_config.get_store(self.Session())
658                        batch.mode = mode
659                        batch.vendor = vendor
660                        batch.buyer = self.request.user.employee
661                        batch.created_by = self.request.user
662                        batch.date_received = localtime(self.rattail_config).date()
663                        self.assign_purchase_order(batch, po_form)
664                        kwargs = self.get_batch_kwargs(batch, mobile=True)
665                        batch = self.handler.make_batch(self.Session(), **kwargs)
666                        if self.handler.should_populate(batch):
667                            self.handler.populate(batch)
668                        return self.redirect(self.get_action_url('view', batch, mobile=True))
669
670                else:
671                    phase = 2
672
673            else:
674                raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow']))
675
676        data['form'] = form
677        data['dform'] = form.make_deform_form()
678        data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
679        data['phase'] = phase
680
681        if phase == 1:
682            data['vendor_use_autocomplete'] = self.rattail_config.getbool(
683                'rattail', 'vendor.use_autocomplete', default=True)
684            if not data['vendor_use_autocomplete']:
685                vendors = self.Session.query(model.Vendor)\
686                                      .order_by(model.Vendor.name)
687                options = [(tags.Option(vendor.name, vendor.uuid))
688                           for vendor in vendors]
689                options.insert(0, tags.Option("(please choose)", ''))
690                data['vendor_options'] = options
691
692        elif phase == 2:
693            purchases = self.eligible_purchases(vendor.uuid, mode=mode)
694            data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']]
695            data['purchase_order_fieldname'] = self.purchase_order_fieldname
696
697        return self.render_to_response('create', data, mobile=True)
698
699    def make_mobile_receiving_from_po_schema(self):
700        schema = colander.MappingSchema()
701        schema.add(colander.SchemaNode(colander.String(),
702                                       name=self.purchase_order_fieldname,
703                                       validator=self.validate_purchase))
704        return schema.bind(session=self.Session())
705
706    @staticmethod
707    @colander.deferred
708    def validate_purchase(node, kw):
709        session = kw['session']
710        def validate(node, value):
711            purchase = session.query(model.Purchase).get(value)
712            if not purchase:
713                raise colander.Invalid(node, "Purchase not found")
714            return purchase.uuid
715        return validate
716
717    def assign_purchase_order(self, batch, po_form):
718        """
719        Assign the original purchase order to the given batch.  Default
720        behavior assumes a Rattail Purchase object is what we're after.
721        """
722        purchase = self.get_purchase(po_form.validated[self.purchase_order_fieldname])
723        if isinstance(purchase, model.Purchase):
724            batch.purchase_uuid = purchase.uuid
725
726        department = self.department_for_purchase(purchase)
727        if department:
728            batch.department_uuid = department.uuid
729
730    def configure_mobile_form(self, f):
731        super(ReceivingBatchView, self).configure_mobile_form(f)
732        batch = f.model_instance
733
734        # truck_dump
735        if not self.creating:
736            if not batch.truck_dump:
737                f.remove_field('truck_dump')
738
739        # department
740        if not self.creating:
741            if batch.truck_dump:
742                f.remove_field('department')
743
744    def configure_row_grid(self, g):
745        super(ReceivingBatchView, self).configure_row_grid(g)
746        g.set_label('department_name', "Department")
747
748        # hide 'ordered' columns for truck dump parent, if its "children first"
749        # flag is set, since that batch type is only concerned with receiving
750        batch = self.get_instance()
751        if batch.is_truck_dump_parent() and not batch.truck_dump_children_first:
752            g.hide_column('cases_ordered')
753            g.hide_column('units_ordered')
754
755        # add "Transform to Unit" action, if appropriate
756        if batch.is_truck_dump_parent():
757            permission_prefix = self.get_permission_prefix()
758            if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
759                transform = grids.GridAction('transform',
760                                             icon='shuffle',
761                                             label="Transform to Unit",
762                                             url=self.transform_unit_url)
763                g.more_actions.append(transform)
764                if g.main_actions and g.main_actions[-1].key == 'delete':
765                    delete = g.main_actions.pop()
766                    g.more_actions.append(delete)
767
768    def transform_unit_url(self, row, i):
769        # grid action is shown only when we return a URL here
770        if self.row_editable(row):
771            if row.batch.is_truck_dump_parent():
772                if row.product and row.product.is_pack_item():
773                    return self.get_row_action_url('transform_unit', row)
774
775    def transform_unit_row(self):
776        """
777        View which transforms the given row, which is assumed to associate with
778        a "pack" item, such that it instead associates with the "unit" item,
779        with quantities adjusted accordingly.
780        """
781        batch = self.get_instance()
782
783        row_uuid = self.request.params.get('row_uuid')
784        row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None
785        if row and row.batch is batch and not row.removed:
786            pass # we're good
787        else:
788            if self.request.method == 'POST':
789                raise self.notfound()
790            return {'error': "Row not found."}
791
792        def normalize(product):
793            data = {
794                'upc': product.upc,
795                'item_id': product.item_id,
796                'description': product.description,
797                'size': product.size,
798                'case_quantity': None,
799                'cases_received': row.cases_received,
800            }
801            cost = product.cost_for_vendor(batch.vendor)
802            if cost:
803                data['case_quantity'] = cost.case_size
804            return data
805
806        if self.request.method == 'POST':
807            self.handler.transform_pack_to_unit(row)
808            self.request.session.flash("Transformed pack to unit item for: {}".format(row.product))
809            return self.redirect(self.get_action_url('view', batch))
810
811        pack_data = normalize(row.product)
812        pack_data['units_received'] = row.units_received
813        unit_data = normalize(row.product.unit)
814        unit_data['units_received'] = None
815        if row.units_received:
816            unit_data['units_received'] = row.units_received * row.product.pack_size
817        diff = self.make_diff(pack_data, unit_data, monospace=True)
818        return self.render_to_response('transform_unit_row', {
819            'batch': batch,
820            'row': row,
821            'diff': diff,
822        })
823
824    def configure_row_form(self, f):
825        super(ReceivingBatchView, self).configure_row_form(f)
826        batch = self.get_instance()
827
828        f.set_readonly('cases_ordered')
829        f.set_readonly('units_ordered')
830        f.set_readonly('po_unit_cost')
831        f.set_readonly('po_total')
832        f.set_readonly('invoice_total')
833
834        # claims
835        f.set_readonly('claims')
836        if batch.is_truck_dump_parent():
837            f.set_renderer('claims', self.render_parent_row_claims)
838            f.set_helptext('claims', "Parent row is claimed by these child rows.")
839        elif batch.is_truck_dump_child():
840            f.set_renderer('claims', self.render_child_row_claims)
841            f.set_helptext('claims', "Child row makes claims against these parent rows.")
842        else:
843            f.remove_field('claims')
844
845    def render_parent_row_claims(self, row, field):
846        items = []
847        for claim in row.claims:
848            child_row = claim.claiming_row
849            child_batch = child_row.batch
850            text = child_batch.id_str
851            if child_batch.description:
852                text = "{} ({})".format(text, child_batch.description)
853            text = "{}, row {}".format(text, child_row.sequence)
854            url = self.get_row_action_url('view', child_row)
855            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
856        return HTML.tag('ul', c=items)
857
858    def render_child_row_claims(self, row, field):
859        items = []
860        for claim in row.truck_dump_claims:
861            parent_row = claim.claimed_row
862            parent_batch = parent_row.batch
863            text = parent_batch.id_str
864            if parent_batch.description:
865                text = "{} ({})".format(text, parent_batch.description)
866            text = "{}, row {}".format(text, parent_row.sequence)
867            url = self.get_row_action_url('view', parent_row)
868            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
869        return HTML.tag('ul', c=items)
870
871    def validate_row_form(self, form):
872
873        # if normal validation fails, stop there
874        if not super(ReceivingBatchView, self).validate_row_form(form):
875            return False
876
877        # if user is editing row from truck dump child, then we must further
878        # validate the form to ensure whatever new amounts they've requested
879        # would in fact fall within the bounds of what is available from the
880        # truck dump parent batch...
881        if self.editing:
882            batch = self.get_instance()
883            if batch.is_truck_dump_child():
884                old_row = self.get_row_instance()
885                case_quantity = old_row.case_quantity
886
887                # get all "existing" (old) claim amounts
888                old_claims = {}
889                for claim in old_row.truck_dump_claims:
890                    for key in self.claim_keys:
891                        amount = getattr(claim, key)
892                        if amount is not None:
893                            old_claims[key] = old_claims.get(key, 0) + amount
894
895                # get all "proposed" (new) claim amounts
896                new_claims = {}
897                for key in self.claim_keys:
898                    amount = form.validated[key]
899                    if amount is not colander.null and amount is not None:
900                        # do not allow user to request a negative claim amount
901                        if amount < 0:
902                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
903                            return False
904                        new_claims[key] = amount
905
906                # figure out what changes are actually being requested
907                claim_diff = {}
908                for key in new_claims:
909                    if key not in old_claims:
910                        claim_diff[key] = new_claims[key]
911                    elif new_claims[key] != old_claims[key]:
912                        claim_diff[key] = new_claims[key] - old_claims[key]
913                        # do not allow user to request a negative claim amount
914                        if claim_diff[key] < (0 - old_claims[key]):
915                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
916                            return False
917                for key in old_claims:
918                    if key not in new_claims:
919                        claim_diff[key] = 0 - old_claims[key]
920
921                # find all rows from truck dump parent which "may" pertain to child row
922                # TODO: perhaps would need to do a more "loose" match on UPC also?
923                if not old_row.product_uuid:
924                    raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
925                parent_rows = [row for row in batch.truck_dump_batch.active_rows()
926                               if row.product_uuid == old_row.product_uuid]
927
928                # NOTE: "confirmed" are the proper amounts which exist in the
929                # parent batch.  "claimed" are the amounts claimed by this row.
930
931                # get existing "confirmed" and "claimed" amounts for all
932                # (possibly related) truck dump parent rows
933                confirmed = {}
934                claimed = {}
935                for parent_row in parent_rows:
936                    for key in self.claim_keys:
937                        amount = getattr(parent_row, key)
938                        if amount is not None:
939                            confirmed[key] = confirmed.get(key, 0) + amount
940                    for claim in parent_row.claims:
941                        for key in self.claim_keys:
942                            amount = getattr(claim, key)
943                            if amount is not None:
944                                claimed[key] = claimed.get(key, 0) + amount
945
946                # now to see if user's request is possible, given what is
947                # available...
948
949                # first we must (pretend to) "relinquish" any claims which are
950                # to be reduced or eliminated, according to our diff
951                for key, amount in claim_diff.items():
952                    if amount < 0:
953                        amount = abs(amount) # make positive, for more readable math
954                        if key not in claimed or claimed[key] < amount:
955                            self.request.session.flash("Cannot relinquish more claims than the "
956                                                       "parent batch has to offer.", 'error')
957                            return False
958                        claimed[key] -= amount
959
960                # next we must determine if any "new" requests would increase
961                # the claim(s) beyond what is available
962                for key, amount in claim_diff.items():
963                    if amount > 0:
964                        claimed[key] = claimed.get(key, 0) + amount
965                        if key not in confirmed or confirmed[key] < claimed[key]:
966                            self.request.session.flash("Cannot request to claim more product than "
967                                                       "is available in Truck Dump Parent batch", 'error')
968                            return False
969
970                # looks like the claim diff is all good, so let's attach that
971                # to the form now and then pick this up again in save()
972                form._claim_diff = claim_diff
973
974        # all validation went ok
975        return True
976
977    def save_edit_row_form(self, form):
978        batch = self.get_instance()
979        row = self.objectify(form)
980
981        # editing a row for truck dump child batch can be complicated...
982        if batch.is_truck_dump_child():
983
984            # grab the claim diff which we attached to the form during validation
985            claim_diff = form._claim_diff
986
987            # first we must "relinquish" any claims which are to be reduced or
988            # eliminated, according to our diff
989            for key, amount in claim_diff.items():
990                if amount < 0:
991                    amount = abs(amount) # make positive, for more readable math
992
993                    # we'd prefer to find an exact match, i.e. there was a 1CS
994                    # claim and our diff said to reduce by 1CS
995                    matches = [claim for claim in row.truck_dump_claims
996                               if getattr(claim, key) == amount]
997                    if matches:
998                        claim = matches[0]
999                        setattr(claim, key, None)
1000
1001                    else:
1002                        # but if no exact match(es) then we'll just whittle
1003                        # away at whatever (smallest) claims we do find
1004                        possible = [claim for claim in row.truck_dump_claims
1005                                    if getattr(claim, key) is not None]
1006                        for claim in sorted(possible, key=lambda claim: getattr(claim, key)):
1007                            previous = getattr(claim, key)
1008                            if previous:
1009                                if previous >= amount:
1010                                    if (previous - amount):
1011                                        setattr(claim, key, previous - amount)
1012                                    else:
1013                                        setattr(claim, key, None)
1014                                    amount = 0
1015                                    break
1016                                else:
1017                                    setattr(claim, key, None)
1018                                    amount -= previous
1019
1020                        if amount:
1021                            raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)")
1022
1023            # next we must stake all new claim(s) as requested, per our diff
1024            for key, amount in claim_diff.items():
1025                if amount > 0:
1026
1027                    # if possible, we'd prefer to add to an existing claim
1028                    # which already has an amount for this key
1029                    existing = [claim for claim in row.truck_dump_claims
1030                                if getattr(claim, key) is not None]
1031                    if existing:
1032                        claim = existing[0]
1033                        setattr(claim, key, getattr(claim, key) + amount)
1034
1035                    # next we'd prefer to add to an existing claim, of any kind
1036                    elif row.truck_dump_claims:
1037                        claim = row.truck_dump_claims[0]
1038                        setattr(claim, key, (getattr(claim, key) or 0) + amount)
1039
1040                    else:
1041                        # otherwise we must create a new claim...
1042
1043                        # find all rows from truck dump parent which "may" pertain to child row
1044                        # TODO: perhaps would need to do a more "loose" match on UPC also?
1045                        if not row.product_uuid:
1046                            raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
1047                        parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
1048                                       if parent_row.product_uuid == row.product_uuid]
1049
1050                        # remove any parent rows which are fully claimed
1051                        # TODO: should perhaps leverage actual amounts for this, instead
1052                        parent_rows = [parent_row for parent_row in parent_rows
1053                                       if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED]
1054
1055                        # try to find a parent row which is exact match on claim amount
1056                        matches = [parent_row for parent_row in parent_rows
1057                                   if getattr(parent_row, key) == amount]
1058                        if matches:
1059
1060                            # make the claim against first matching parent row
1061                            claim = model.PurchaseBatchRowClaim()
1062                            claim.claimed_row = parent_rows[0]
1063                            setattr(claim, key, amount)
1064                            row.truck_dump_claims.append(claim)
1065
1066                        else:
1067                            # but if no exact match(es) then we'll just whittle
1068                            # away at whatever (smallest) parent rows we do find
1069                            for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)):
1070
1071                                available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims])
1072                                if available:
1073                                    if available >= amount:
1074                                        # make claim against this parent row, making it fully claimed
1075                                        claim = model.PurchaseBatchRowClaim()
1076                                        claim.claimed_row = parent_row
1077                                        setattr(claim, key, amount)
1078                                        row.truck_dump_claims.append(claim)
1079                                        amount = 0
1080                                        break
1081                                    else:
1082                                        # make partial claim against this parent row
1083                                        claim = model.PurchaseBatchRowClaim()
1084                                        claim.claimed_row = parent_row
1085                                        setattr(claim, key, available)
1086                                        row.truck_dump_claims.append(claim)
1087                                        amount -= available
1088
1089                            if amount:
1090                                raise NotImplementedError("Had leftover amount when \"staking\" claim(s)")
1091
1092            # now we must be sure to refresh all truck dump parent batch rows
1093            # which were affected.  but along with that we also should purge
1094            # any empty claims, i.e. those which were fully relinquished
1095            pending_refresh = set()
1096            for claim in list(row.truck_dump_claims):
1097                parent_row = claim.claimed_row
1098                if claim.is_empty():
1099                    row.truck_dump_claims.remove(claim)
1100                    self.Session.flush()
1101                pending_refresh.add(parent_row)
1102            for parent_row in pending_refresh:
1103                self.handler.refresh_row(parent_row)
1104            self.handler.refresh_batch_status(batch.truck_dump_batch)
1105
1106        self.after_edit_row(row)
1107        self.Session.flush()
1108        return row
1109
1110    def redirect_after_edit_row(self, row, mobile=False):
1111        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
1112
1113    def render_mobile_row_listitem(self, row, i):
1114        key = self.render_product_key_value(row)
1115        description = row.product.full_description if row.product else row.description
1116        return "({}) {}".format(key, description)
1117
1118    def make_mobile_row_grid_kwargs(self, **kwargs):
1119        kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs)
1120
1121        # use custom `receive_row` instead of `view_row`
1122        # TODO: should still use `view_row` in some cases? e.g. executed batch
1123        kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True)
1124
1125        return kwargs
1126
1127    def should_aggregate_products(self, batch):
1128        """
1129        Must return a boolean indicating whether rows should be aggregated by
1130        product for the given batch.
1131        """
1132        return True
1133
1134    def quick_locate_rows(self, batch, entry, product):
1135        rows = []
1136
1137        # try to locate rows by product uuid match before other key
1138        if product:
1139            rows = [row for row in batch.active_rows()
1140                    if row.product_uuid == product.uuid]
1141            if rows:
1142                return rows
1143
1144        key = self.rattail_config.product_key()
1145        if key == 'upc':
1146
1147            if entry.isdigit():
1148
1149                # we prefer "exact" UPC matches, i.e. those which assumed the entry
1150                # already contained the check digit.
1151                provided = GPC(entry, calc_check_digit=False)
1152                rows = [row for row in batch.active_rows()
1153                        if row.upc == provided]
1154                if rows:
1155                    return rows
1156
1157                # if no "exact" UPC matches, we'll settle for those (UPC matches)
1158                # which assume the entry lacked a check digit.
1159                checked = GPC(entry, calc_check_digit='upc')
1160                rows = [row for row in batch.active_rows()
1161                        if row.upc == checked]
1162                return rows
1163
1164        elif key == 'item_id':
1165            rows = [row for row in batch.active_rows()
1166                    if row.item_id == entry]
1167            return rows
1168
1169    def quick_locate_product(self, batch, entry):
1170
1171        # first let the handler attempt lookup on product key (only)
1172        product = self.handler.locate_product_for_entry(self.Session(), entry,
1173                                                        lookup_by_code=False)
1174        if product:
1175            return product
1176
1177        # now we'll attempt lookup by vendor item code
1178        product = api.get_product_by_vendor_code(self.Session(), entry, vendor=batch.vendor)
1179        if product:
1180            return product
1181
1182        # okay then, let's attempt lookup by "alternate" code
1183        product = api.get_product_by_code(self.Session(), entry)
1184        if product:
1185            return product
1186
1187    def save_quick_row_form(self, form):
1188        batch = self.get_instance()
1189        entry = form.validated['quick_entry']
1190
1191        # first try to locate the product based on quick entry
1192        product = self.quick_locate_product(batch, entry)
1193
1194        # then try to locate existing row(s) which match product/entry
1195        rows = self.quick_locate_rows(batch, entry, product)
1196        if rows:
1197
1198            # if aggregating, just re-use matching row
1199            prefer_existing = self.should_aggregate_products(batch)
1200            if prefer_existing:
1201                if len(rows) > 1:
1202                    log.warning("found multiple row matches for '%s' in batch %s: %s",
1203                                entry, batch.id_str, batch)
1204                return rows[0]
1205
1206            else: # borrow product from matching row, but make new row
1207                other_row = rows[0]
1208                row = model.PurchaseBatchRow()
1209                row.item_entry = entry
1210                row.product = other_row.product
1211                self.handler.add_row(batch, row)
1212                self.Session.flush()
1213                self.handler.refresh_batch_status(batch)
1214                return row
1215
1216        # matching row(s) not found; add new row if product was identified
1217        # TODO: probably should be smarter about how we handle deleted?
1218        if product and not product.deleted:
1219            row = model.PurchaseBatchRow()
1220            row.item_entry = entry
1221            row.product = product
1222            self.handler.add_row(batch, row)
1223            self.Session.flush()
1224            self.handler.refresh_batch_status(batch)
1225            return row
1226
1227        key = self.rattail_config.product_key()
1228        if key == 'upc':
1229
1230            # check for "bad" upc
1231            if len(entry) > 14:
1232                return
1233
1234            if not entry.isdigit():
1235                return
1236
1237            provided = GPC(entry, calc_check_digit=False)
1238            checked = GPC(entry, calc_check_digit='upc')
1239
1240            # product not in system, but presumably sane upc, so add to batch anyway
1241            row = model.PurchaseBatchRow()
1242            row.item_entry = entry
1243            add_check_digit = True # TODO: make this dynamic, of course
1244            if add_check_digit:
1245                row.upc = checked
1246            else:
1247                row.upc = provided
1248            row.item_id = entry
1249            row.description = "(unknown product)"
1250            self.handler.add_row(batch, row)
1251            self.Session.flush()
1252            self.handler.refresh_batch_status(batch)
1253            return row
1254
1255        elif key == 'item_id':
1256
1257            # check for "too long" item_id
1258            if len(entry) > maxlen(model.PurchaseBatchRow.item_id):
1259                return
1260
1261            # product not in system, but presumably sane item_id, so add to batch anyway
1262            row = model.PurchaseBatchRow()
1263            row.item_entry = entry
1264            row.item_id = entry
1265            row.description = "(unknown product)"
1266            self.handler.add_row(batch, row)
1267            self.Session.flush()
1268            self.handler.refresh_batch_status(batch)
1269            return row
1270
1271        else:
1272            raise NotImplementedError("don't know how to handle product key: {}".format(key))
1273
1274    def redirect_after_quick_row(self, row, mobile=False):
1275        if mobile:
1276            return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
1277        return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile)
1278
1279    def get_row_image_url(self, row):
1280        if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
1281            return pod.get_image_url(self.rattail_config, row.upc)
1282
1283    def get_mobile_data(self, session=None):
1284        query = super(ReceivingBatchView, self).get_mobile_data(session=session)
1285
1286        # do not expose truck dump child batches on mobile
1287        # TODO: is there any case where we *would* want to?
1288        query = query.filter(model.PurchaseBatch.truck_dump_batch == None)
1289
1290        return query
1291
1292    def mobile_view_row(self):
1293        """
1294        Mobile view for receiving batch row items.  Note that this also handles
1295        updating a row.
1296        """
1297        self.mobile = True
1298        self.viewing = True
1299        row = self.get_row_instance()
1300        batch = row.batch
1301        permission_prefix = self.get_permission_prefix()
1302        form = self.make_mobile_row_form(row)
1303        context = {
1304            'row': row,
1305            'batch': batch,
1306            'parent_instance': batch,
1307            'instance': row,
1308            'instance_title': self.get_row_instance_title(row),
1309            'parent_model_title': self.get_model_title(),
1310            'product_image_url': self.get_row_image_url(row),
1311            'form': form,
1312            'allow_expired': self.handler.allow_expired_credits(),
1313            'allow_cases': self.handler.allow_cases(),
1314            'quick_receive': False,
1315            'quick_receive_all': False,
1316        }
1317
1318        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
1319                                                               default=True)
1320        if batch.order_quantities_known:
1321            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
1322                                                                       default=False)
1323
1324        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
1325            schema = MobileReceivingForm().bind(session=self.Session())
1326            update_form = forms.Form(schema=schema, request=self.request)
1327            if update_form.validate(newstyle=True):
1328                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
1329                mode = update_form.validated['mode']
1330                cases = update_form.validated['cases']
1331                units = update_form.validated['units']
1332
1333                # handler takes care of the row receiving logic for us
1334                kwargs = dict(update_form.validated)
1335                del kwargs['row']
1336                self.handler.receive_row(row, **kwargs)
1337
1338                # keep track of last-used uom, although we just track
1339                # whether or not it was 'CS' since the unit_uom can vary
1340                sticky_case = None
1341                if not update_form.validated['quick_receive']:
1342                    if cases and not units:
1343                        sticky_case = True
1344                    elif units and not cases:
1345                        sticky_case = False
1346                if sticky_case is not None:
1347                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
1348
1349                return self.redirect(self.get_action_url('view', batch, mobile=True))
1350
1351        # unit_uom can vary by product
1352        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
1353
1354        if context['quick_receive'] and context['quick_receive_all']:
1355            if context['allow_cases']:
1356                context['quick_receive_uom'] = 'CS'
1357                raise NotImplementedError("TODO: add CS support for quick_receive_all")
1358            else:
1359                context['quick_receive_uom'] = context['unit_uom']
1360                accounted_for = self.handler.get_units_accounted_for(row)
1361                remainder = self.handler.get_units_ordered(row) - accounted_for
1362
1363                if accounted_for:
1364                    # some product accounted for; button should receive "remainder" only
1365                    if remainder:
1366                        remainder = pretty_quantity(remainder)
1367                        context['quick_receive_quantity'] = remainder
1368                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
1369                    else:
1370                        # unless there is no remainder, in which case disable it
1371                        context['quick_receive'] = False
1372
1373                else: # nothing yet accounted for, button should receive "all"
1374                    if not remainder:
1375                        raise ValueError("why is remainder empty?")
1376                    remainder = pretty_quantity(remainder)
1377                    context['quick_receive_quantity'] = remainder
1378                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
1379
1380        # effective uom can vary in a few ways...the basic default is 'CS' if
1381        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
1382        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
1383        if sticky_case is None:
1384            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
1385        elif sticky_case:
1386            context['uom'] = 'CS'
1387        else:
1388            context['uom'] = context['unit_uom']
1389        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
1390            context['uom'] = context['unit_uom']
1391
1392        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
1393            warn = True
1394            if batch.is_truck_dump_parent() and row.product:
1395                uuids = [child.uuid for child in batch.truck_dump_children]
1396                if uuids:
1397                    count = self.Session.query(model.PurchaseBatchRow)\
1398                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
1399                                        .filter(model.PurchaseBatchRow.product == row.product)\
1400                                        .count()
1401                    if count:
1402                        warn = False
1403            if warn:
1404                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
1405        return self.render_to_response('view_row', context, mobile=True)
1406
1407    def mobile_receive_row(self):
1408        """
1409        Mobile view for row-level receiving.
1410        """
1411        self.mobile = True
1412        self.viewing = True
1413        row = self.get_row_instance()
1414        batch = row.batch
1415        permission_prefix = self.get_permission_prefix()
1416        form = self.make_mobile_row_form(row)
1417        context = {
1418            'row': row,
1419            'batch': batch,
1420            'parent_instance': batch,
1421            'instance': row,
1422            'instance_title': self.get_row_instance_title(row),
1423            'parent_model_title': self.get_model_title(),
1424            'product_image_url': self.get_row_image_url(row),
1425            'form': form,
1426            'allow_expired': self.handler.allow_expired_credits(),
1427            'allow_cases': self.handler.allow_cases(),
1428            'quick_receive': False,
1429            'quick_receive_all': False,
1430        }
1431
1432        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
1433                                                               default=True)
1434        if batch.order_quantities_known:
1435            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
1436                                                                       default=False)
1437
1438        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
1439            schema = MobileReceivingForm().bind(session=self.Session())
1440            update_form = forms.Form(schema=schema, request=self.request)
1441            if update_form.validate(newstyle=True):
1442                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
1443                mode = update_form.validated['mode']
1444                cases = update_form.validated['cases']
1445                units = update_form.validated['units']
1446
1447                # handler takes care of the row receiving logic for us
1448                kwargs = dict(update_form.validated)
1449                del kwargs['row']
1450                self.handler.receive_row(row, **kwargs)
1451
1452                # keep track of last-used uom, although we just track
1453                # whether or not it was 'CS' since the unit_uom can vary
1454                sticky_case = None
1455                if not update_form.validated['quick_receive']:
1456                    if cases and not units:
1457                        sticky_case = True
1458                    elif units and not cases:
1459                        sticky_case = False
1460                if sticky_case is not None:
1461                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
1462
1463                return self.redirect(self.get_action_url('view', batch, mobile=True))
1464
1465        # unit_uom can vary by product
1466        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
1467
1468        if context['quick_receive'] and context['quick_receive_all']:
1469            if context['allow_cases']:
1470                context['quick_receive_uom'] = 'CS'
1471                raise NotImplementedError("TODO: add CS support for quick_receive_all")
1472            else:
1473                context['quick_receive_uom'] = context['unit_uom']
1474                accounted_for = self.handler.get_units_accounted_for(row)
1475                remainder = self.handler.get_units_ordered(row) - accounted_for
1476
1477                if accounted_for:
1478                    # some product accounted for; button should receive "remainder" only
1479                    if remainder:
1480                        remainder = pretty_quantity(remainder)
1481                        context['quick_receive_quantity'] = remainder
1482                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
1483                    else:
1484                        # unless there is no remainder, in which case disable it
1485                        context['quick_receive'] = False
1486
1487                else: # nothing yet accounted for, button should receive "all"
1488                    if not remainder:
1489                        raise ValueError("why is remainder empty?")
1490                    remainder = pretty_quantity(remainder)
1491                    context['quick_receive_quantity'] = remainder
1492                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
1493
1494        # effective uom can vary in a few ways...the basic default is 'CS' if
1495        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
1496        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
1497        if sticky_case is None:
1498            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
1499        elif sticky_case:
1500            context['uom'] = 'CS'
1501        else:
1502            context['uom'] = context['unit_uom']
1503        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
1504            context['uom'] = context['unit_uom']
1505
1506        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
1507            warn = True
1508            if batch.is_truck_dump_parent() and row.product:
1509                uuids = [child.uuid for child in batch.truck_dump_children]
1510                if uuids:
1511                    count = self.Session.query(model.PurchaseBatchRow)\
1512                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
1513                                        .filter(model.PurchaseBatchRow.product == row.product)\
1514                                        .count()
1515                    if count:
1516                        warn = False
1517            if warn:
1518                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
1519        return self.render_to_response('receive_row', context, mobile=True)
1520
1521    def auto_receive(self):
1522        """
1523        View which can "auto-receive" all items in the batch.  Meant only as a
1524        convenience for developers.
1525        """
1526        batch = self.get_instance()
1527        key = '{}.receive_all'.format(self.get_grid_key())
1528        progress = SessionProgress(self.request, key)
1529        kwargs = {'progress': progress}
1530        thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
1531        thread.start()
1532
1533        return self.render_progress(progress, {
1534            'instance': batch,
1535            'cancel_url': self.get_action_url('view', batch),
1536            'cancel_msg': "Auto-receive was canceled",
1537        })
1538
1539    def auto_receive_thread(self, uuid, user_uuid, progress=None):
1540        """
1541        Thread target for receiving all items on the given batch.
1542        """
1543        session = RattailSession()
1544        batch = session.query(model.PurchaseBatch).get(uuid)
1545        user = session.query(model.User).get(user_uuid)
1546        try:
1547            self.handler.auto_receive_all_items(batch, progress=progress)
1548
1549        # if anything goes wrong, rollback and log the error etc.
1550        except Exception as error:
1551            session.rollback()
1552            log.exception("auto-receive failed for: %s".format(batch))
1553            session.close()
1554            if progress:
1555                progress.session.load()
1556                progress.session['error'] = True
1557                progress.session['error_msg'] = "Auto-receive failed: {}: {}".format(
1558                    type(error).__name__, error)
1559                progress.session.save()
1560
1561        # if no error, check result flag (false means user canceled)
1562        else:
1563            session.commit()
1564            session.refresh(batch)
1565            success_url = self.get_action_url('view', batch)
1566            session.close()
1567            if progress:
1568                progress.session.load()
1569                progress.session['complete'] = True
1570                progress.session['success_url'] = success_url
1571                progress.session.save()
1572
1573    @classmethod
1574    def _receiving_defaults(cls, config):
1575        rattail_config = config.registry.settings.get('rattail_config')
1576        route_prefix = cls.get_route_prefix()
1577        url_prefix = cls.get_url_prefix()
1578        model_key = cls.get_model_key()
1579        permission_prefix = cls.get_permission_prefix()
1580
1581        # row-level receiving
1582        config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
1583        config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
1584                        permission='{}.edit_row'.format(permission_prefix))
1585
1586        if cls.allow_truck_dump:
1587
1588            # add TD child batch, from invoice file
1589            config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key))
1590            config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
1591                            permission='{}.create'.format(permission_prefix))
1592
1593            # transform TD parent row from "pack" to "unit" item
1594            config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key))
1595            config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
1596                            permission='{}.edit_row'.format(permission_prefix), renderer='json')
1597
1598            # auto-receive all items
1599            if not rattail_config.production():
1600                config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key),
1601                                 request_method='POST')
1602                config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
1603                                permission='admin')
1604
1605
1606    @classmethod
1607    def defaults(cls, config):
1608        cls._receiving_defaults(config)
1609        cls._purchasing_defaults(config)
1610        cls._batch_defaults(config)
1611        cls._defaults(config)
1612
1613
1614# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
1615# session is not provided by the view at runtime (i.e. when it was instead
1616# being provided by the type instance, which was created upon app startup).
1617@colander.deferred
1618def valid_vendor(node, kw):
1619    session = kw['session']
1620    def validate(node, value):
1621        vendor = session.query(model.Vendor).get(value)
1622        if not vendor:
1623            raise colander.Invalid(node, "Vendor not found")
1624        return vendor.uuid
1625    return validate
1626
1627
1628class MobileNewReceivingBatch(colander.MappingSchema):
1629
1630    vendor = colander.SchemaNode(colander.String(),
1631                                 validator=valid_vendor)
1632
1633    workflow = colander.SchemaNode(colander.String(),
1634                                   validator=colander.OneOf([
1635                                       'from_po',
1636                                       'from_scratch',
1637                                       'truck_dump',
1638                                   ]))
1639
1640    phase = colander.SchemaNode(colander.Int())
1641
1642
1643class MobileNewReceivingFromPO(colander.MappingSchema):
1644
1645    purchase = colander.SchemaNode(colander.String())
1646
1647
1648# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
1649# session is not provided by the view at runtime (i.e. when it was instead
1650# being provided by the type instance, which was created upon app startup).
1651@colander.deferred
1652def valid_purchase_batch_row(node, kw):
1653    session = kw['session']
1654    def validate(node, value):
1655        row = session.query(model.PurchaseBatchRow).get(value)
1656        if not row:
1657            raise colander.Invalid(node, "Batch row not found")
1658        if row.batch.executed:
1659            raise colander.Invalid(node, "Batch has already been executed")
1660        return row.uuid
1661    return validate
1662
1663
1664class MobileReceivingForm(colander.MappingSchema):
1665
1666    row = colander.SchemaNode(colander.String(),
1667                              validator=valid_purchase_batch_row)
1668
1669    mode = colander.SchemaNode(colander.String(),
1670                               validator=colander.OneOf([
1671                                   'received',
1672                                   'damaged',
1673                                   'expired',
1674                                   # 'mispick',
1675                               ]))
1676
1677    cases = colander.SchemaNode(colander.Decimal(), missing=None)
1678
1679    units = colander.SchemaNode(colander.Decimal(), missing=None)
1680
1681    expiration_date = colander.SchemaNode(colander.Date(),
1682                                          widget=dfwidget.TextInputWidget(),
1683                                          missing=colander.null)
1684
1685    quick_receive = colander.SchemaNode(colander.Boolean())
1686
1687
1688def includeme(config):
1689    ReceivingBatchView.defaults(config)
Note: See TracBrowser for help on using the repository browser.