source: tailbone/tailbone/views/purchasing/receiving.py @ d60679a

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

Don't allow deletion of some receiving data rows on mobile

specifically, rows on a truck dump parent, which originated from a child
batch (and therefore presumably, an invoice)

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