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

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

Use shipped instead of ordered, for receiving authority

i.e. compare receiving quantities to shipped quantities instead of ordered

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