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

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

Add basic "receive row" desktop view for receiving batches

not terribly polished yet, but works

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