source: tailbone/tailbone/views/purchasing/receiving.py @ 5b9e97b

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

Add "declare credit" UI for receiving batch rows

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