source: tailbone/tailbone/views/purchasing/receiving.py @ 53917e9

Last change on this file since 53917e9 was 53917e9, checked in by Lance Edgar <ledgar@…>, 11 months ago

Require invoice parser selection for new truck dump child from invoice

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