source: tailbone/tailbone/views/purchasing/receiving.py @ 006a709

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

Add ability to sort by Credits? column for receiving batch rows

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