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

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

Tweak the "incomplete" row filter for mobile receiving batch

this really is not ideal...hopefully good enough to limp along for a while yet

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