source: tailbone/tailbone/views/purchasing/receiving.py @ 18ad664

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

Add validation when "declaring credit" for receiving batch row

i.e. don't just blindly attempt, when it isn't supported

  • Property mode set to 100644
File size: 85.5 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.cancel_url = self.get_row_action_url('view', row, mobile=mobile)
865        form.set_widget('mode', forms.widgets.JQuerySelectWidget(values=[(m, m) for m in possible_modes]))
866        form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True,
867                                                                   one_amount_only=True))
868        form.set_type('expiration_date', 'date_jquery')
869
870        if not mobile:
871            form.remove_field('quick_receive')
872
873        if form.validate(newstyle=True):
874
875            # handler takes care of the row receiving logic for us
876            kwargs = dict(form.validated)
877            kwargs['cases'] = kwargs['quantity']['cases']
878            kwargs['units'] = kwargs['quantity']['units']
879            del kwargs['quantity']
880            self.handler.receive_row(row, **kwargs)
881
882            # keep track of last-used uom, although we just track
883            # whether or not it was 'CS' since the unit_uom can vary
884            # TODO: should this be done for desktop too somehow?
885            sticky_case = None
886            if mobile and not form.validated['quick_receive']:
887                cases = form.validated['cases']
888                units = form.validated['units']
889                if cases and not units:
890                    sticky_case = True
891                elif units and not cases:
892                    sticky_case = False
893            if sticky_case is not None:
894                self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
895
896            if mobile:
897                return self.redirect(self.get_action_url('view', batch, mobile=True))
898            else:
899                return self.redirect(self.get_row_action_url('view', row))
900
901        # unit_uom can vary by product
902        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
903
904        if context['quick_receive'] and context['quick_receive_all']:
905            if context['allow_cases']:
906                context['quick_receive_uom'] = 'CS'
907                raise NotImplementedError("TODO: add CS support for quick_receive_all")
908            else:
909                context['quick_receive_uom'] = context['unit_uom']
910                accounted_for = self.handler.get_units_accounted_for(row)
911                remainder = self.handler.get_units_ordered(row) - accounted_for
912
913                if accounted_for:
914                    # some product accounted for; button should receive "remainder" only
915                    if remainder:
916                        remainder = pretty_quantity(remainder)
917                        context['quick_receive_quantity'] = remainder
918                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
919                    else:
920                        # unless there is no remainder, in which case disable it
921                        context['quick_receive'] = False
922
923                else: # nothing yet accounted for, button should receive "all"
924                    if not remainder:
925                        raise ValueError("why is remainder empty?")
926                    remainder = pretty_quantity(remainder)
927                    context['quick_receive_quantity'] = remainder
928                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
929
930        # effective uom can vary in a few ways...the basic default is 'CS' if
931        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
932        sticky_case = None
933        if mobile:
934            # TODO: should do this for desktop also, but rename the session variable
935            sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
936        if sticky_case is None:
937            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
938        elif sticky_case:
939            context['uom'] = 'CS'
940        else:
941            context['uom'] = context['unit_uom']
942        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
943            context['uom'] = context['unit_uom']
944
945        # TODO: should do this for desktop in addition to mobile?
946        if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
947            warn = True
948            if batch.is_truck_dump_parent() and row.product:
949                uuids = [child.uuid for child in batch.truck_dump_children]
950                if uuids:
951                    count = self.Session.query(model.PurchaseBatchRow)\
952                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
953                                        .filter(model.PurchaseBatchRow.product == row.product)\
954                                        .count()
955                    if count:
956                        warn = False
957            if warn:
958                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
959
960        # TODO: should do this for desktop in addition to mobile?
961        if mobile:
962            # maybe alert user if they've already received some of this product
963            alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
964                                                         default=False)
965            if alert_received:
966                if self.handler.get_units_confirmed(row):
967                    msg = "You have already received some of this product; last update was {}.".format(
968                        humanize.naturaltime(make_utc() - row.modified))
969                    self.request.session.flash(msg, 'receiving-warning')
970
971        context['form'] = form
972        context['dform'] = form.make_deform_form()
973        context['parent_url'] = self.get_action_url('view', batch, mobile=mobile)
974        context['parent_title'] = self.get_instance_title(batch)
975        return self.render_to_response('receive_row', context, mobile=mobile)
976
977    def declare_credit(self):
978        """
979        View for declaring a credit, i.e. converting some "received" or similar
980        quantity, to a credit of some sort.
981        """
982        row = self.get_row_instance()
983        batch = row.batch
984        possible_credit_types = [
985            'damaged',
986            'expired',
987        ]
988        context = {
989            'row': row,
990            'batch': batch,
991            'parent_instance': batch,
992            'instance': row,
993            'instance_title': self.get_row_instance_title(row),
994            'parent_model_title': self.get_model_title(),
995            'product_image_url': self.get_row_image_url(row),
996            'allow_expired': self.handler.allow_expired_credits(),
997            'allow_cases': self.handler.allow_cases(),
998        }
999
1000        schema = DeclareCreditForm()
1001        form = forms.Form(schema=schema, request=self.request)
1002        form.set_widget('credit_type', forms.widgets.JQuerySelectWidget(
1003            values=[(m, m) for m in possible_credit_types]))
1004        form.set_widget('quantity', forms.widgets.CasesUnitsWidget(
1005            amount_required=True, one_amount_only=True))
1006        form.set_type('expiration_date', 'date_jquery')
1007
1008        if form.validate(newstyle=True):
1009
1010            # handler takes care of the row receiving logic for us
1011            kwargs = dict(form.validated)
1012            kwargs['cases'] = kwargs['quantity']['cases']
1013            kwargs['units'] = kwargs['quantity']['units']
1014            del kwargs['quantity']
1015            try:
1016                result = self.handler.can_declare_credit(row, **kwargs)
1017            except Exception as error:
1018                self.request.session.flash("Handler says you can't declare that credit: {}".format(error), 'error')
1019            else:
1020                if result:
1021                    self.handler.declare_credit(row, **kwargs)
1022                    return self.redirect(self.get_row_action_url('view', row))
1023
1024                self.request.session.flash("Handler says you can't declare that credit; not sure why", 'error')
1025
1026        context['form'] = form
1027        context['dform'] = form.make_deform_form()
1028        context['parent_url'] = self.get_action_url('view', batch)
1029        context['parent_title'] = self.get_instance_title(batch)
1030        return self.render_to_response('declare_credit', context)
1031
1032    def transform_unit_row(self):
1033        """
1034        View which transforms the given row, which is assumed to associate with
1035        a "pack" item, such that it instead associates with the "unit" item,
1036        with quantities adjusted accordingly.
1037        """
1038        batch = self.get_instance()
1039
1040        row_uuid = self.request.params.get('row_uuid')
1041        row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None
1042        if row and row.batch is batch and not row.removed:
1043            pass # we're good
1044        else:
1045            if self.request.method == 'POST':
1046                raise self.notfound()
1047            return {'error': "Row not found."}
1048
1049        def normalize(product):
1050            data = {
1051                'upc': product.upc,
1052                'item_id': product.item_id,
1053                'description': product.description,
1054                'size': product.size,
1055                'case_quantity': None,
1056                'cases_received': row.cases_received,
1057            }
1058            cost = product.cost_for_vendor(batch.vendor)
1059            if cost:
1060                data['case_quantity'] = cost.case_size
1061            return data
1062
1063        if self.request.method == 'POST':
1064            self.handler.transform_pack_to_unit(row)
1065            self.request.session.flash("Transformed pack to unit item for: {}".format(row.product))
1066            return self.redirect(self.get_action_url('view', batch))
1067
1068        pack_data = normalize(row.product)
1069        pack_data['units_received'] = row.units_received
1070        unit_data = normalize(row.product.unit)
1071        unit_data['units_received'] = None
1072        if row.units_received:
1073            unit_data['units_received'] = row.units_received * row.product.pack_size
1074        diff = self.make_diff(pack_data, unit_data, monospace=True)
1075        return self.render_to_response('transform_unit_row', {
1076            'batch': batch,
1077            'row': row,
1078            'diff': diff,
1079        })
1080
1081    def configure_row_form(self, f):
1082        super(ReceivingBatchView, self).configure_row_form(f)
1083        batch = self.get_instance()
1084
1085        # allow input for certain fields only; all others are readonly
1086        mutable = [
1087            'invoice_unit_cost',
1088        ]
1089        for name in f.fields:
1090            if name not in mutable:
1091                f.set_readonly(name)
1092
1093        # invoice totals
1094        f.set_label('invoice_total', "Invoice Total (Orig.)")
1095        f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
1096
1097        # claims
1098        f.set_readonly('claims')
1099        if batch.is_truck_dump_parent():
1100            f.set_renderer('claims', self.render_parent_row_claims)
1101            f.set_helptext('claims', "Parent row is claimed by these child rows.")
1102        elif batch.is_truck_dump_child():
1103            f.set_renderer('claims', self.render_child_row_claims)
1104            f.set_helptext('claims', "Child row makes claims against these parent rows.")
1105        else:
1106            f.remove_field('claims')
1107
1108        # truck_dump_status
1109        if self.creating or not batch.is_truck_dump_parent():
1110            f.remove_field('truck_dump_status')
1111        else:
1112            f.set_readonly('truck_dump_status')
1113            f.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
1114
1115    def render_parent_row_claims(self, row, field):
1116        items = []
1117        for claim in row.claims:
1118            child_row = claim.claiming_row
1119            child_batch = child_row.batch
1120            text = child_batch.id_str
1121            if child_batch.description:
1122                text = "{} ({})".format(text, child_batch.description)
1123            text = "{}, row {}".format(text, child_row.sequence)
1124            url = self.get_row_action_url('view', child_row)
1125            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
1126        return HTML.tag('ul', c=items)
1127
1128    def render_child_row_claims(self, row, field):
1129        items = []
1130        for claim in row.truck_dump_claims:
1131            parent_row = claim.claimed_row
1132            parent_batch = parent_row.batch
1133            text = parent_batch.id_str
1134            if parent_batch.description:
1135                text = "{} ({})".format(text, parent_batch.description)
1136            text = "{}, row {}".format(text, parent_row.sequence)
1137            url = self.get_row_action_url('view', parent_row)
1138            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
1139        return HTML.tag('ul', c=items)
1140
1141    def validate_row_form(self, form):
1142
1143        # if normal validation fails, stop there
1144        if not super(ReceivingBatchView, self).validate_row_form(form):
1145            return False
1146
1147        # if user is editing row from truck dump child, then we must further
1148        # validate the form to ensure whatever new amounts they've requested
1149        # would in fact fall within the bounds of what is available from the
1150        # truck dump parent batch...
1151        if self.editing:
1152            batch = self.get_instance()
1153            if batch.is_truck_dump_child():
1154                old_row = self.get_row_instance()
1155                case_quantity = old_row.case_quantity
1156
1157                # get all "existing" (old) claim amounts
1158                old_claims = {}
1159                for claim in old_row.truck_dump_claims:
1160                    for key in self.claim_keys:
1161                        amount = getattr(claim, key)
1162                        if amount is not None:
1163                            old_claims[key] = old_claims.get(key, 0) + amount
1164
1165                # get all "proposed" (new) claim amounts
1166                new_claims = {}
1167                for key in self.claim_keys:
1168                    amount = form.validated[key]
1169                    if amount is not colander.null and amount is not None:
1170                        # do not allow user to request a negative claim amount
1171                        if amount < 0:
1172                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
1173                            return False
1174                        new_claims[key] = amount
1175
1176                # figure out what changes are actually being requested
1177                claim_diff = {}
1178                for key in new_claims:
1179                    if key not in old_claims:
1180                        claim_diff[key] = new_claims[key]
1181                    elif new_claims[key] != old_claims[key]:
1182                        claim_diff[key] = new_claims[key] - old_claims[key]
1183                        # do not allow user to request a negative claim amount
1184                        if claim_diff[key] < (0 - old_claims[key]):
1185                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
1186                            return False
1187                for key in old_claims:
1188                    if key not in new_claims:
1189                        claim_diff[key] = 0 - old_claims[key]
1190
1191                # find all rows from truck dump parent which "may" pertain to child row
1192                # TODO: perhaps would need to do a more "loose" match on UPC also?
1193                if not old_row.product_uuid:
1194                    raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
1195                parent_rows = [row for row in batch.truck_dump_batch.active_rows()
1196                               if row.product_uuid == old_row.product_uuid]
1197
1198                # NOTE: "confirmed" are the proper amounts which exist in the
1199                # parent batch.  "claimed" are the amounts claimed by this row.
1200
1201                # get existing "confirmed" and "claimed" amounts for all
1202                # (possibly related) truck dump parent rows
1203                confirmed = {}
1204                claimed = {}
1205                for parent_row in parent_rows:
1206                    for key in self.claim_keys:
1207                        amount = getattr(parent_row, key)
1208                        if amount is not None:
1209                            confirmed[key] = confirmed.get(key, 0) + amount
1210                    for claim in parent_row.claims:
1211                        for key in self.claim_keys:
1212                            amount = getattr(claim, key)
1213                            if amount is not None:
1214                                claimed[key] = claimed.get(key, 0) + amount
1215
1216                # now to see if user's request is possible, given what is
1217                # available...
1218
1219                # first we must (pretend to) "relinquish" any claims which are
1220                # to be reduced or eliminated, according to our diff
1221                for key, amount in claim_diff.items():
1222                    if amount < 0:
1223                        amount = abs(amount) # make positive, for more readable math
1224                        if key not in claimed or claimed[key] < amount:
1225                            self.request.session.flash("Cannot relinquish more claims than the "
1226                                                       "parent batch has to offer.", 'error')
1227                            return False
1228                        claimed[key] -= amount
1229
1230                # next we must determine if any "new" requests would increase
1231                # the claim(s) beyond what is available
1232                for key, amount in claim_diff.items():
1233                    if amount > 0:
1234                        claimed[key] = claimed.get(key, 0) + amount
1235                        if key not in confirmed or confirmed[key] < claimed[key]:
1236                            self.request.session.flash("Cannot request to claim more product than "
1237                                                       "is available in Truck Dump Parent batch", 'error')
1238                            return False
1239
1240                # looks like the claim diff is all good, so let's attach that
1241                # to the form now and then pick this up again in save()
1242                form._claim_diff = claim_diff
1243
1244        # all validation went ok
1245        return True
1246
1247    def save_edit_row_form(self, form):
1248        batch = self.get_instance()
1249        row = self.objectify(form)
1250
1251        # editing a row for truck dump child batch can be complicated...
1252        if batch.is_truck_dump_child():
1253
1254            # grab the claim diff which we attached to the form during validation
1255            claim_diff = form._claim_diff
1256
1257            # first we must "relinquish" any claims which are to be reduced or
1258            # eliminated, according to our diff
1259            for key, amount in claim_diff.items():
1260                if amount < 0:
1261                    amount = abs(amount) # make positive, for more readable math
1262
1263                    # we'd prefer to find an exact match, i.e. there was a 1CS
1264                    # claim and our diff said to reduce by 1CS
1265                    matches = [claim for claim in row.truck_dump_claims
1266                               if getattr(claim, key) == amount]
1267                    if matches:
1268                        claim = matches[0]
1269                        setattr(claim, key, None)
1270
1271                    else:
1272                        # but if no exact match(es) then we'll just whittle
1273                        # away at whatever (smallest) claims we do find
1274                        possible = [claim for claim in row.truck_dump_claims
1275                                    if getattr(claim, key) is not None]
1276                        for claim in sorted(possible, key=lambda claim: getattr(claim, key)):
1277                            previous = getattr(claim, key)
1278                            if previous:
1279                                if previous >= amount:
1280                                    if (previous - amount):
1281                                        setattr(claim, key, previous - amount)
1282                                    else:
1283                                        setattr(claim, key, None)
1284                                    amount = 0
1285                                    break
1286                                else:
1287                                    setattr(claim, key, None)
1288                                    amount -= previous
1289
1290                        if amount:
1291                            raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)")
1292
1293            # next we must stake all new claim(s) as requested, per our diff
1294            for key, amount in claim_diff.items():
1295                if amount > 0:
1296
1297                    # if possible, we'd prefer to add to an existing claim
1298                    # which already has an amount for this key
1299                    existing = [claim for claim in row.truck_dump_claims
1300                                if getattr(claim, key) is not None]
1301                    if existing:
1302                        claim = existing[0]
1303                        setattr(claim, key, getattr(claim, key) + amount)
1304
1305                    # next we'd prefer to add to an existing claim, of any kind
1306                    elif row.truck_dump_claims:
1307                        claim = row.truck_dump_claims[0]
1308                        setattr(claim, key, (getattr(claim, key) or 0) + amount)
1309
1310                    else:
1311                        # otherwise we must create a new claim...
1312
1313                        # find all rows from truck dump parent which "may" pertain to child row
1314                        # TODO: perhaps would need to do a more "loose" match on UPC also?
1315                        if not row.product_uuid:
1316                            raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
1317                        parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
1318                                       if parent_row.product_uuid == row.product_uuid]
1319
1320                        # remove any parent rows which are fully claimed
1321                        # TODO: should perhaps leverage actual amounts for this, instead
1322                        parent_rows = [parent_row for parent_row in parent_rows
1323                                       if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED]
1324
1325                        # try to find a parent row which is exact match on claim amount
1326                        matches = [parent_row for parent_row in parent_rows
1327                                   if getattr(parent_row, key) == amount]
1328                        if matches:
1329
1330                            # make the claim against first matching parent row
1331                            claim = model.PurchaseBatchRowClaim()
1332                            claim.claimed_row = parent_rows[0]
1333                            setattr(claim, key, amount)
1334                            row.truck_dump_claims.append(claim)
1335
1336                        else:
1337                            # but if no exact match(es) then we'll just whittle
1338                            # away at whatever (smallest) parent rows we do find
1339                            for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)):
1340
1341                                available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims])
1342                                if available:
1343                                    if available >= amount:
1344                                        # make claim against this parent row, making it fully claimed
1345                                        claim = model.PurchaseBatchRowClaim()
1346                                        claim.claimed_row = parent_row
1347                                        setattr(claim, key, amount)
1348                                        row.truck_dump_claims.append(claim)
1349                                        amount = 0
1350                                        break
1351                                    else:
1352                                        # make partial claim against this parent row
1353                                        claim = model.PurchaseBatchRowClaim()
1354                                        claim.claimed_row = parent_row
1355                                        setattr(claim, key, available)
1356                                        row.truck_dump_claims.append(claim)
1357                                        amount -= available
1358
1359                            if amount:
1360                                raise NotImplementedError("Had leftover amount when \"staking\" claim(s)")
1361
1362            # now we must be sure to refresh all truck dump parent batch rows
1363            # which were affected.  but along with that we also should purge
1364            # any empty claims, i.e. those which were fully relinquished
1365            pending_refresh = set()
1366            for claim in list(row.truck_dump_claims):
1367                parent_row = claim.claimed_row
1368                if claim.is_empty():
1369                    row.truck_dump_claims.remove(claim)
1370                    self.Session.flush()
1371                pending_refresh.add(parent_row)
1372            for parent_row in pending_refresh:
1373                self.handler.refresh_row(parent_row)
1374            self.handler.refresh_batch_status(batch.truck_dump_batch)
1375
1376        self.after_edit_row(row)
1377        self.Session.flush()
1378        return row
1379
1380    def redirect_after_edit_row(self, row, mobile=False):
1381        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
1382
1383    def render_mobile_row_listitem(self, row, i):
1384        key = self.render_product_key_value(row)
1385        description = row.product.full_description if row.product else row.description
1386        return "({}) {}".format(key, description)
1387
1388    def make_mobile_row_grid_kwargs(self, **kwargs):
1389        kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs)
1390
1391        # use custom `receive_row` instead of `view_row`
1392        # TODO: should still use `view_row` in some cases? e.g. executed batch
1393        kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True)
1394
1395        return kwargs
1396
1397    def should_aggregate_products(self, batch):
1398        """
1399        Must return a boolean indicating whether rows should be aggregated by
1400        product for the given batch.
1401        """
1402        return True
1403
1404    def quick_locate_rows(self, batch, entry, product):
1405        rows = []
1406
1407        # try to locate rows by product uuid match before other key
1408        if product:
1409            rows = [row for row in batch.active_rows()
1410                    if row.product_uuid == product.uuid]
1411            if rows:
1412                return rows
1413
1414        key = self.rattail_config.product_key()
1415        if key == 'upc':
1416
1417            if entry.isdigit():
1418
1419                # we prefer "exact" UPC matches, i.e. those which assumed the entry
1420                # already contained the check digit.
1421                provided = GPC(entry, calc_check_digit=False)
1422                rows = [row for row in batch.active_rows()
1423                        if row.upc == provided]
1424                if rows:
1425                    return rows
1426
1427                # if no "exact" UPC matches, we'll settle for those (UPC matches)
1428                # which assume the entry lacked a check digit.
1429                checked = GPC(entry, calc_check_digit='upc')
1430                rows = [row for row in batch.active_rows()
1431                        if row.upc == checked]
1432                return rows
1433
1434        elif key == 'item_id':
1435            rows = [row for row in batch.active_rows()
1436                    if row.item_id == entry]
1437            return rows
1438
1439    def quick_locate_product(self, batch, entry):
1440
1441        # first let the handler attempt lookup on product key (only)
1442        product = self.handler.locate_product_for_entry(self.Session(), entry,
1443                                                        lookup_by_code=False)
1444        if product:
1445            return product
1446
1447        # now we'll attempt lookup by vendor item code
1448        product = api.get_product_by_vendor_code(self.Session(), entry, vendor=batch.vendor)
1449        if product:
1450            return product
1451
1452        # okay then, let's attempt lookup by "alternate" code
1453        product = api.get_product_by_code(self.Session(), entry)
1454        if product:
1455            return product
1456
1457    def save_quick_row_form(self, form):
1458        batch = self.get_instance()
1459        entry = form.validated['quick_entry']
1460
1461        # first try to locate the product based on quick entry
1462        product = self.quick_locate_product(batch, entry)
1463
1464        # then try to locate existing row(s) which match product/entry
1465        rows = self.quick_locate_rows(batch, entry, product)
1466        if rows:
1467
1468            # if aggregating, just re-use matching row
1469            prefer_existing = self.should_aggregate_products(batch)
1470            if prefer_existing:
1471                if len(rows) > 1:
1472                    log.warning("found multiple row matches for '%s' in batch %s: %s",
1473                                entry, batch.id_str, batch)
1474                return rows[0]
1475
1476            else: # borrow product from matching row, but make new row
1477                other_row = rows[0]
1478                row = model.PurchaseBatchRow()
1479                row.item_entry = entry
1480                row.product = other_row.product
1481                self.handler.add_row(batch, row)
1482                self.Session.flush()
1483                self.handler.refresh_batch_status(batch)
1484                return row
1485
1486        # matching row(s) not found; add new row if product was identified
1487        # TODO: probably should be smarter about how we handle deleted?
1488        if product and not product.deleted:
1489            row = model.PurchaseBatchRow()
1490            row.item_entry = entry
1491            row.product = product
1492            self.handler.add_row(batch, row)
1493            self.Session.flush()
1494            self.handler.refresh_batch_status(batch)
1495            return row
1496
1497        key = self.rattail_config.product_key()
1498        if key == 'upc':
1499
1500            # check for "bad" upc
1501            if len(entry) > 14:
1502                return
1503
1504            if not entry.isdigit():
1505                return
1506
1507            provided = GPC(entry, calc_check_digit=False)
1508            checked = GPC(entry, calc_check_digit='upc')
1509
1510            # product not in system, but presumably sane upc, so add to batch anyway
1511            row = model.PurchaseBatchRow()
1512            row.item_entry = entry
1513            add_check_digit = True # TODO: make this dynamic, of course
1514            if add_check_digit:
1515                row.upc = checked
1516            else:
1517                row.upc = provided
1518            row.item_id = entry
1519            row.description = "(unknown product)"
1520            self.handler.add_row(batch, row)
1521            self.Session.flush()
1522            self.handler.refresh_batch_status(batch)
1523            return row
1524
1525        elif key == 'item_id':
1526
1527            # check for "too long" item_id
1528            if len(entry) > maxlen(model.PurchaseBatchRow.item_id):
1529                return
1530
1531            # product not in system, but presumably sane item_id, so add to batch anyway
1532            row = model.PurchaseBatchRow()
1533            row.item_entry = entry
1534            row.item_id = entry
1535            row.description = "(unknown product)"
1536            self.handler.add_row(batch, row)
1537            self.Session.flush()
1538            self.handler.refresh_batch_status(batch)
1539            return row
1540
1541        else:
1542            raise NotImplementedError("don't know how to handle product key: {}".format(key))
1543
1544    def redirect_after_quick_row(self, row, mobile=False):
1545        if mobile:
1546            return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
1547        return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile)
1548
1549    def get_row_image_url(self, row):
1550        if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
1551            return pod.get_image_url(self.rattail_config, row.upc)
1552
1553    def get_mobile_data(self, session=None):
1554        query = super(ReceivingBatchView, self).get_mobile_data(session=session)
1555
1556        # do not expose truck dump child batches on mobile
1557        # TODO: is there any case where we *would* want to?
1558        query = query.filter(model.PurchaseBatch.truck_dump_batch == None)
1559
1560        return query
1561
1562    def mobile_view_row(self):
1563        """
1564        Mobile view for receiving batch row items.  Note that this also handles
1565        updating a row.
1566        """
1567        self.mobile = True
1568        self.viewing = True
1569        row = self.get_row_instance()
1570        batch = row.batch
1571        permission_prefix = self.get_permission_prefix()
1572        form = self.make_mobile_row_form(row)
1573        context = {
1574            'row': row,
1575            'batch': batch,
1576            'parent_instance': batch,
1577            'instance': row,
1578            'instance_title': self.get_row_instance_title(row),
1579            'parent_model_title': self.get_model_title(),
1580            'product_image_url': self.get_row_image_url(row),
1581            'form': form,
1582            'allow_expired': self.handler.allow_expired_credits(),
1583            'allow_cases': self.handler.allow_cases(),
1584            'quick_receive': False,
1585            'quick_receive_all': False,
1586        }
1587
1588        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
1589                                                               default=True)
1590        if batch.order_quantities_known:
1591            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
1592                                                                       default=False)
1593
1594        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
1595            schema = MobileReceivingForm().bind(session=self.Session())
1596            update_form = forms.Form(schema=schema, request=self.request)
1597            if update_form.validate(newstyle=True):
1598                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
1599                mode = update_form.validated['mode']
1600                cases = update_form.validated['cases']
1601                units = update_form.validated['units']
1602
1603                # handler takes care of the row receiving logic for us
1604                kwargs = dict(update_form.validated)
1605                del kwargs['row']
1606                self.handler.receive_row(row, **kwargs)
1607
1608                # keep track of last-used uom, although we just track
1609                # whether or not it was 'CS' since the unit_uom can vary
1610                sticky_case = None
1611                if not update_form.validated['quick_receive']:
1612                    if cases and not units:
1613                        sticky_case = True
1614                    elif units and not cases:
1615                        sticky_case = False
1616                if sticky_case is not None:
1617                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
1618
1619                return self.redirect(self.get_action_url('view', batch, mobile=True))
1620
1621        # unit_uom can vary by product
1622        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
1623
1624        if context['quick_receive'] and context['quick_receive_all']:
1625            if context['allow_cases']:
1626                context['quick_receive_uom'] = 'CS'
1627                raise NotImplementedError("TODO: add CS support for quick_receive_all")
1628            else:
1629                context['quick_receive_uom'] = context['unit_uom']
1630                accounted_for = self.handler.get_units_accounted_for(row)
1631                remainder = self.handler.get_units_ordered(row) - accounted_for
1632
1633                if accounted_for:
1634                    # some product accounted for; button should receive "remainder" only
1635                    if remainder:
1636                        remainder = pretty_quantity(remainder)
1637                        context['quick_receive_quantity'] = remainder
1638                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
1639                    else:
1640                        # unless there is no remainder, in which case disable it
1641                        context['quick_receive'] = False
1642
1643                else: # nothing yet accounted for, button should receive "all"
1644                    if not remainder:
1645                        raise ValueError("why is remainder empty?")
1646                    remainder = pretty_quantity(remainder)
1647                    context['quick_receive_quantity'] = remainder
1648                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
1649
1650        # effective uom can vary in a few ways...the basic default is 'CS' if
1651        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
1652        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
1653        if sticky_case is None:
1654            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
1655        elif sticky_case:
1656            context['uom'] = 'CS'
1657        else:
1658            context['uom'] = context['unit_uom']
1659        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
1660            context['uom'] = context['unit_uom']
1661
1662        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
1663            warn = True
1664            if batch.is_truck_dump_parent() and row.product:
1665                uuids = [child.uuid for child in batch.truck_dump_children]
1666                if uuids:
1667                    count = self.Session.query(model.PurchaseBatchRow)\
1668                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
1669                                        .filter(model.PurchaseBatchRow.product == row.product)\
1670                                        .count()
1671                    if count:
1672                        warn = False
1673            if warn:
1674                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
1675        return self.render_to_response('view_row', context, mobile=True)
1676
1677    def mobile_receive_row(self):
1678        """
1679        Mobile view for row-level receiving.
1680        """
1681        self.mobile = True
1682        self.viewing = True
1683        row = self.get_row_instance()
1684        batch = row.batch
1685        permission_prefix = self.get_permission_prefix()
1686        form = self.make_mobile_row_form(row)
1687        context = {
1688            'row': row,
1689            'batch': batch,
1690            'parent_instance': batch,
1691            'instance': row,
1692            'instance_title': self.get_row_instance_title(row),
1693            'parent_model_title': self.get_model_title(),
1694            'product_image_url': self.get_row_image_url(row),
1695            'form': form,
1696            'allow_expired': self.handler.allow_expired_credits(),
1697            'allow_cases': self.handler.allow_cases(),
1698            'quick_receive': False,
1699            'quick_receive_all': False,
1700        }
1701
1702        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
1703                                                               default=True)
1704        if batch.order_quantities_known:
1705            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
1706                                                                       default=False)
1707
1708        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
1709            schema = MobileReceivingForm().bind(session=self.Session())
1710            update_form = forms.Form(schema=schema, request=self.request)
1711            if update_form.validate(newstyle=True):
1712                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
1713                mode = update_form.validated['mode']
1714                cases = update_form.validated['cases']
1715                units = update_form.validated['units']
1716
1717                # handler takes care of the row receiving logic for us
1718                kwargs = dict(update_form.validated)
1719                del kwargs['row']
1720                self.handler.receive_row(row, **kwargs)
1721
1722                # keep track of last-used uom, although we just track
1723                # whether or not it was 'CS' since the unit_uom can vary
1724                sticky_case = None
1725                if not update_form.validated['quick_receive']:
1726                    if cases and not units:
1727                        sticky_case = True
1728                    elif units and not cases:
1729                        sticky_case = False
1730                if sticky_case is not None:
1731                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
1732
1733                return self.redirect(self.get_action_url('view', batch, mobile=True))
1734
1735        # unit_uom can vary by product
1736        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
1737
1738        if context['quick_receive'] and context['quick_receive_all']:
1739            if context['allow_cases']:
1740                context['quick_receive_uom'] = 'CS'
1741                raise NotImplementedError("TODO: add CS support for quick_receive_all")
1742            else:
1743                context['quick_receive_uom'] = context['unit_uom']
1744                accounted_for = self.handler.get_units_accounted_for(row)
1745                remainder = self.handler.get_units_ordered(row) - accounted_for
1746
1747                if accounted_for:
1748                    # some product accounted for; button should receive "remainder" only
1749                    if remainder:
1750                        remainder = pretty_quantity(remainder)
1751                        context['quick_receive_quantity'] = remainder
1752                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
1753                    else:
1754                        # unless there is no remainder, in which case disable it
1755                        context['quick_receive'] = False
1756
1757                else: # nothing yet accounted for, button should receive "all"
1758                    if not remainder:
1759                        raise ValueError("why is remainder empty?")
1760                    remainder = pretty_quantity(remainder)
1761                    context['quick_receive_quantity'] = remainder
1762                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
1763
1764        # effective uom can vary in a few ways...the basic default is 'CS' if
1765        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
1766        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
1767        if sticky_case is None:
1768            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
1769        elif sticky_case:
1770            context['uom'] = 'CS'
1771        else:
1772            context['uom'] = context['unit_uom']
1773        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
1774            context['uom'] = context['unit_uom']
1775
1776        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
1777            warn = True
1778            if batch.is_truck_dump_parent() and row.product:
1779                uuids = [child.uuid for child in batch.truck_dump_children]
1780                if uuids:
1781                    count = self.Session.query(model.PurchaseBatchRow)\
1782                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
1783                                        .filter(model.PurchaseBatchRow.product == row.product)\
1784                                        .count()
1785                    if count:
1786                        warn = False
1787            if warn:
1788                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
1789
1790        # maybe alert user if they've already received some of this product
1791        alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
1792                                                     default=False)
1793        if alert_received:
1794            if self.handler.get_units_confirmed(row):
1795                msg = "You have already received some of this product; last update was {}.".format(
1796                    humanize.naturaltime(make_utc() - row.modified))
1797                self.request.session.flash(msg, 'receiving-warning')
1798
1799        return self.render_to_response('receive_row', context, mobile=True)
1800
1801    def auto_receive(self):
1802        """
1803        View which can "auto-receive" all items in the batch.  Meant only as a
1804        convenience for developers.
1805        """
1806        batch = self.get_instance()
1807        key = '{}.receive_all'.format(self.get_grid_key())
1808        progress = SessionProgress(self.request, key)
1809        kwargs = {'progress': progress}
1810        thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
1811        thread.start()
1812
1813        return self.render_progress(progress, {
1814            'instance': batch,
1815            'cancel_url': self.get_action_url('view', batch),
1816            'cancel_msg': "Auto-receive was canceled",
1817        })
1818
1819    def auto_receive_thread(self, uuid, user_uuid, progress=None):
1820        """
1821        Thread target for receiving all items on the given batch.
1822        """
1823        session = RattailSession()
1824        batch = session.query(model.PurchaseBatch).get(uuid)
1825        user = session.query(model.User).get(user_uuid)
1826        try:
1827            self.handler.auto_receive_all_items(batch, progress=progress)
1828
1829        # if anything goes wrong, rollback and log the error etc.
1830        except Exception as error:
1831            session.rollback()
1832            log.exception("auto-receive failed for: %s".format(batch))
1833            session.close()
1834            if progress:
1835                progress.session.load()
1836                progress.session['error'] = True
1837                progress.session['error_msg'] = "Auto-receive failed: {}: {}".format(
1838                    type(error).__name__, error)
1839                progress.session.save()
1840
1841        # if no error, check result flag (false means user canceled)
1842        else:
1843            session.commit()
1844            session.refresh(batch)
1845            success_url = self.get_action_url('view', batch)
1846            session.close()
1847            if progress:
1848                progress.session.load()
1849                progress.session['complete'] = True
1850                progress.session['success_url'] = success_url
1851                progress.session.save()
1852
1853    @classmethod
1854    def _receiving_defaults(cls, config):
1855        rattail_config = config.registry.settings.get('rattail_config')
1856        route_prefix = cls.get_route_prefix()
1857        url_prefix = cls.get_url_prefix()
1858        model_key = cls.get_model_key()
1859        permission_prefix = cls.get_permission_prefix()
1860
1861        # row-level receiving
1862        config.add_route('{}.receive_row'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
1863        config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
1864                        permission='{}.edit_row'.format(permission_prefix))
1865        config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
1866        config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
1867                        permission='{}.edit_row'.format(permission_prefix))
1868
1869        # declare credit for row
1870        config.add_route('{}.declare_credit'.format(route_prefix), '{}/{{uuid}}/rows/{{row_uuid}}/declare-credit'.format(url_prefix))
1871        config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix),
1872                        permission='{}.edit_row'.format(permission_prefix))
1873
1874        if cls.allow_truck_dump:
1875
1876            # add TD child batch, from invoice file
1877            config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key))
1878            config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
1879                            permission='{}.create'.format(permission_prefix))
1880
1881            # transform TD parent row from "pack" to "unit" item
1882            config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key))
1883            config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
1884                            permission='{}.edit_row'.format(permission_prefix), renderer='json')
1885
1886            # auto-receive all items
1887            if not rattail_config.production():
1888                config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key),
1889                                 request_method='POST')
1890                config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
1891                                permission='admin')
1892
1893
1894    @classmethod
1895    def defaults(cls, config):
1896        cls._receiving_defaults(config)
1897        cls._purchasing_defaults(config)
1898        cls._batch_defaults(config)
1899        cls._defaults(config)
1900
1901
1902# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
1903# session is not provided by the view at runtime (i.e. when it was instead
1904# being provided by the type instance, which was created upon app startup).
1905@colander.deferred
1906def valid_vendor(node, kw):
1907    session = kw['session']
1908    def validate(node, value):
1909        vendor = session.query(model.Vendor).get(value)
1910        if not vendor:
1911            raise colander.Invalid(node, "Vendor not found")
1912        return vendor.uuid
1913    return validate
1914
1915
1916class MobileNewReceivingBatch(colander.MappingSchema):
1917
1918    vendor = colander.SchemaNode(colander.String(),
1919                                 validator=valid_vendor)
1920
1921    workflow = colander.SchemaNode(colander.String(),
1922                                   validator=colander.OneOf([
1923                                       'from_po',
1924                                       'from_scratch',
1925                                       'truck_dump',
1926                                   ]))
1927
1928    phase = colander.SchemaNode(colander.Int())
1929
1930
1931class MobileNewReceivingFromPO(colander.MappingSchema):
1932
1933    purchase = colander.SchemaNode(colander.String())
1934
1935
1936# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
1937# session is not provided by the view at runtime (i.e. when it was instead
1938# being provided by the type instance, which was created upon app startup).
1939@colander.deferred
1940def valid_purchase_batch_row(node, kw):
1941    session = kw['session']
1942    def validate(node, value):
1943        row = session.query(model.PurchaseBatchRow).get(value)
1944        if not row:
1945            raise colander.Invalid(node, "Batch row not found")
1946        if row.batch.executed:
1947            raise colander.Invalid(node, "Batch has already been executed")
1948        return row.uuid
1949    return validate
1950
1951
1952class ReceiveRowForm(colander.MappingSchema):
1953
1954    mode = colander.SchemaNode(colander.String(),
1955                               validator=colander.OneOf([
1956                                   'received',
1957                                   'damaged',
1958                                   'expired',
1959                                   # 'mispick',
1960                               ]))
1961
1962    quantity = forms.types.ProductQuantity()
1963
1964    expiration_date = colander.SchemaNode(colander.Date(),
1965                                          widget=dfwidget.TextInputWidget(),
1966                                          missing=colander.null)
1967
1968    quick_receive = colander.SchemaNode(colander.Boolean())
1969
1970
1971class DeclareCreditForm(colander.MappingSchema):
1972
1973    credit_type = colander.SchemaNode(colander.String(),
1974                                      validator=colander.OneOf([
1975                                          'damaged',
1976                                          'expired',
1977                                          # 'mispick',
1978                                      ]))
1979
1980    quantity = forms.types.ProductQuantity()
1981
1982    expiration_date = colander.SchemaNode(colander.Date(),
1983                                          widget=dfwidget.TextInputWidget(),
1984                                          missing=colander.null)
1985
1986
1987class MobileReceivingForm(colander.MappingSchema):
1988
1989    row = colander.SchemaNode(colander.String(),
1990                              validator=valid_purchase_batch_row)
1991
1992    mode = colander.SchemaNode(colander.String(),
1993                               validator=colander.OneOf([
1994                                   'received',
1995                                   'damaged',
1996                                   'expired',
1997                                   # 'mispick',
1998                               ]))
1999
2000    cases = colander.SchemaNode(colander.Decimal(), missing=None)
2001
2002    units = colander.SchemaNode(colander.Decimal(), missing=None)
2003
2004    expiration_date = colander.SchemaNode(colander.Date(),
2005                                          widget=dfwidget.TextInputWidget(),
2006                                          missing=colander.null)
2007
2008    quick_receive = colander.SchemaNode(colander.Boolean())
2009
2010
2011def includeme(config):
2012    ReceivingBatchView.defaults(config)
Note: See TracBrowser for help on using the repository browser.