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

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

Add "truck dump status" fields to receiving batch views

also refactor some code to use e.g. batch.is_truck_dump_parent() for clarity

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